Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf0477bfa7 | ||
|
|
936f1866af | ||
|
|
04e11ae545 | ||
|
|
65b17a6e0f | ||
|
|
5e9451407f | ||
|
|
32ec74d8eb | ||
|
|
caf503f442 | ||
|
|
62439e1368 | ||
|
|
dfba247454 | ||
|
|
fe6b8899bc | ||
|
|
a6b6e55247 | ||
|
|
0c38deaab5 | ||
|
|
109d7f267c | ||
|
|
66090b2bcb | ||
|
|
8a130fc509 | ||
|
|
7af3bfae8f | ||
|
|
64266695b5 | ||
|
|
1b89b49b78 | ||
|
|
49e9e04a12 | ||
|
|
a2da0d6ef4 | ||
|
|
89904ed03f | ||
|
|
3f0cfd5ed4 | ||
|
|
fa7fe1d51f | ||
|
|
d0c8ecd847 | ||
|
|
552e9927b7 | ||
|
|
2b9f17c401 | ||
|
|
7690574dde | ||
|
|
feba7e8043 | ||
|
|
a3c778210b | ||
|
|
efaa39bea9 | ||
|
|
61dd6e7d9c | ||
|
|
74a31b70d3 | ||
|
|
24bf97bb65 | ||
|
|
4a088ea8e1 | ||
|
|
42b940d263 | ||
|
|
29ce7c7135 | ||
|
|
fed69935d3 | ||
|
|
6f33d52681 | ||
|
|
d137d12313 | ||
|
|
560fdbdc9f | ||
|
|
a17a8b734a | ||
|
|
41d45d9336 | ||
|
|
e4a2cd48a2 | ||
|
|
2e3e0b979c | ||
|
|
9e8281aab5 | ||
|
|
e5f5009f80 | ||
|
|
89f0eb5cb3 | ||
|
|
8c3d1ba86c | ||
|
|
28d4f67ba2 | ||
|
|
6889c6ff33 | ||
|
|
41bff266a4 | ||
|
|
596f3d3a8e | ||
|
|
0cb9ee5c58 | ||
|
|
51919fef0c | ||
|
|
c8727b3a2d | ||
|
|
44c525ddd3 | ||
|
|
d8ac4c3abb | ||
|
|
9893fa2383 | ||
|
|
b552bcf3ea | ||
|
|
05ad2dc194 | ||
|
|
fd89303fe1 | ||
|
|
2087869752 | ||
|
|
55a004fe1e | ||
|
|
c5e249bba8 | ||
|
|
65f99ad7e6 | ||
|
|
29112fab82 | ||
|
|
4215be3926 | ||
|
|
15ea385554 | ||
|
|
542b53bb0f | ||
|
|
09fe2dda3c | ||
|
|
939d4c1e74 | ||
|
|
88f5b5fa0e | ||
|
|
cde0f5b9e7 | ||
|
|
973bd62a9a | ||
|
|
8bf940f861 | ||
|
|
21da870bd0 | ||
|
|
d4c942417a | ||
|
|
f895bc58e6 | ||
|
|
6caac50138 | ||
|
|
0174b93d0f | ||
|
|
122ce38811 | ||
|
|
57b0b14192 | ||
|
|
10cdd93cec | ||
|
|
4221589df2 | ||
|
|
3d13a72ae0 | ||
|
|
33a98fb9d1 | ||
|
|
ce3ae0a668 | ||
|
|
69453b2180 | ||
|
|
2e998db0b9 | ||
|
|
73a2f82d5b | ||
|
|
312b3b5f0e | ||
|
|
4b998de77a | ||
|
|
3e49522a42 | ||
|
|
d6bd7de5f6 | ||
|
|
b87ada3c96 | ||
|
|
3bac3654c4 | ||
|
|
c722c91e2a | ||
|
|
b214aced90 | ||
|
|
56b6a6360f | ||
|
|
966937d620 | ||
|
|
b524b31839 | ||
|
|
e61dc2adf8 | ||
|
|
a15094ce60 | ||
|
|
ecd44f7cb7 | ||
|
|
b226251ddf | ||
|
|
0aee6f3ed9 | ||
|
|
6a73eb2e20 | ||
|
|
54a386d925 | ||
|
|
f20f45a2da | ||
|
|
f36d04e4a4 | ||
|
|
d565718922 | ||
|
|
ced1fc955d | ||
|
|
db4c52e8be | ||
|
|
107685930d | ||
|
|
bf3b9c5979 | ||
|
|
f20a1a30fe | ||
|
|
65bf1ebda3 | ||
|
|
61d02e10fe | ||
|
|
ca0682abe3 | ||
|
|
8df7b9bf2c | ||
|
|
29bc868c89 | ||
|
|
3094c0bd10 | ||
|
|
205867a09f | ||
|
|
03692273c0 | ||
|
|
df986fd5e2 | ||
|
|
1fa0e57d74 | ||
|
|
a3f4674963 | ||
|
|
c9b3245994 | ||
|
|
6a5e73c856 | ||
|
|
3ac75d0fdb | ||
|
|
21793cfb48 | ||
|
|
4741bc0055 | ||
|
|
8630e7e3e0 | ||
|
|
da89a11bda | ||
|
|
e1d24d8087 | ||
|
|
6378588102 | ||
|
|
1ea00fb168 | ||
|
|
a035293998 | ||
|
|
e8e93b53c0 | ||
|
|
73f038c0c0 | ||
|
|
4de9e135ce | ||
|
|
625162edd3 | ||
|
|
43189b65d7 | ||
|
|
26e313e031 | ||
|
|
ff6a907834 | ||
|
|
97a4b0e749 | ||
|
|
162edf39fe | ||
|
|
06240c73b3 | ||
|
|
3415e70dc6 | ||
|
|
79c9c0e517 | ||
|
|
1bff7d9907 | ||
|
|
f7d703fd5b | ||
|
|
0f217f7c80 | ||
|
|
001700ce72 | ||
|
|
b060171c59 | ||
|
|
267a644f4f | ||
|
|
5a2f7db371 | ||
|
|
c877e46224 | ||
|
|
06c4289999 | ||
|
|
962776c2e3 | ||
|
|
4aa7e1eebd | ||
|
|
045023f449 | ||
|
|
52392ad5b4 | ||
|
|
e8d06e5e4a | ||
|
|
41696df1ac | ||
|
|
63dd165fed | ||
|
|
5320246297 | ||
|
|
62df24599e | ||
|
|
09e0bcea66 | ||
|
|
c038b8ebdd | ||
|
|
f7fcd51164 | ||
|
|
8dc5970ca9 | ||
|
|
a74586c627 | ||
|
|
1dc20e307b | ||
|
|
e052074624 | ||
|
|
7192552624 | ||
|
|
c4baceccb4 | ||
|
|
d16de9fe69 | ||
|
|
6b7dcc53d4 | ||
|
|
d886a62d8a | ||
|
|
1263c3c91e | ||
|
|
f3c6770ecf | ||
|
|
339f96a4d9 | ||
|
|
e9201fb617 | ||
|
|
20255b05be | ||
|
|
32d7f4376b | ||
|
|
95a8d7176a | ||
|
|
49a6a69da4 | ||
|
|
5db651a543 | ||
|
|
21d4a23430 | ||
|
|
c9fbf7c50a | ||
|
|
a3b7988d87 | ||
|
|
744ede76c0 | ||
|
|
28e263748b | ||
|
|
7e5eeda9a5 | ||
|
|
ca2598a9f6 | ||
|
|
ab46c8ebf1 | ||
|
|
386da51ae5 | ||
|
|
09f218cbb2 | ||
|
|
297517db0e | ||
|
|
69d6e2f7f3 | ||
|
|
53d779311e | ||
|
|
1e86892e62 | ||
|
|
8e446f904c | ||
|
|
4d581cbb73 | ||
|
|
4a6147f903 | ||
|
|
971b92da5d | ||
|
|
e2a9bb05d3 | ||
|
|
1b4ce0b1f8 | ||
|
|
f72ffbbcb2 | ||
|
|
f935e85ead | ||
|
|
ee60cad652 | ||
|
|
124f5bf7ba | ||
|
|
d3d7184f4e | ||
|
|
9b99c136ee | ||
|
|
30a431924b | ||
|
|
921db176c3 | ||
|
|
122a5fdf35 | ||
|
|
2a5694073d | ||
|
|
7a93ede797 | ||
|
|
8b26bf2ac3 | ||
|
|
81dc977bbf | ||
|
|
6614b9b4e7 | ||
|
|
a2432a3245 | ||
|
|
e200ce2c8a | ||
|
|
39e1651af3 | ||
|
|
c17cf079ad | ||
|
|
f4226047cb | ||
|
|
3a50966766 | ||
|
|
e9a706aa4a | ||
|
|
e6d232f538 | ||
|
|
41b8601d86 | ||
|
|
c68370609d | ||
|
|
22328540e1 | ||
|
|
c383523768 | ||
|
|
b1a676fa24 | ||
|
|
976289aa5c | ||
|
|
248ecd98f1 | ||
|
|
d2d4e62543 | ||
|
|
c478dfa173 | ||
|
|
bb577bb41f | ||
|
|
5ac600b3bb | ||
|
|
3b77624f8a | ||
|
|
8efd4e53b5 | ||
|
|
6f1cf6c86e | ||
|
|
87e8bafad8 | ||
|
|
9b60c6edd7 | ||
|
|
d52d6c9622 | ||
|
|
091c2fe1c6 | ||
|
|
862a07b198 | ||
|
|
6e2ced3287 | ||
|
|
915b8b2c75 | ||
|
|
c3f9b76d9a | ||
|
|
baf3c0ab64 | ||
|
|
74e5bf6090 | ||
|
|
27d8944f5e | ||
|
|
a487d059ba | ||
|
|
f224c48e09 | ||
|
|
f56e67b516 | ||
|
|
89206107fa | ||
|
|
dc143471bc | ||
|
|
15d9ee1b51 | ||
|
|
d82bbe754c | ||
|
|
d8b2db696f | ||
|
|
007acca59a | ||
|
|
f138d4929d | ||
|
|
abc99f4154 | ||
|
|
5b67e0740e | ||
|
|
4621f85d7c | ||
|
|
53229f725a | ||
|
|
607815acbb | ||
|
|
20c8f8e57b | ||
|
|
58eca5075a | ||
|
|
bb7ed80578 | ||
|
|
ae5196bc8d | ||
|
|
154e8a78ec | ||
|
|
c4d8662de8 | ||
|
|
89c0a58271 | ||
|
|
9c4b7302cc | ||
|
|
1056018b52 | ||
|
|
ad806cf3f8 | ||
|
|
2a1ac762e6 | ||
|
|
41d9d7adb9 | ||
|
|
6117606541 | ||
|
|
410d22488c | ||
|
|
f184a86361 | ||
|
|
b03ab482ef | ||
|
|
8a0488f88a | ||
|
|
309d9b0095 | ||
|
|
b57c1603e3 | ||
|
|
a9aaa45ccf | ||
|
|
0cbbfba8ed | ||
|
|
2e8a5a7b7a | ||
|
|
91952044d6 | ||
|
|
22454073a6 | ||
|
|
5b7708398c | ||
|
|
d0c9f9b0c1 | ||
|
|
8a6e75eed8 | ||
|
|
a868e16eaa | ||
|
|
60bea83e76 | ||
|
|
afa30a9cca | ||
|
|
e30c283fd8 | ||
|
|
15c5a528ed | ||
|
|
8930b45f97 | ||
|
|
e641b80448 | ||
|
|
559e43b2e9 | ||
|
|
71e8f2d054 | ||
|
|
697f651ae2 | ||
|
|
6d70dfdada | ||
|
|
2d7d7e080f | ||
|
|
a3fec4f565 | ||
|
|
06963ddaed | ||
|
|
a036b4300d | ||
|
|
3a0f782646 | ||
|
|
46520d2154 | ||
|
|
dfb22f5345 | ||
|
|
f96300bb86 | ||
|
|
2981433535 | ||
|
|
69afba3b28 | ||
|
|
3919f13523 | ||
|
|
0fdd08677b | ||
|
|
b1adc047e6 | ||
|
|
df464a991d | ||
|
|
a0056dc4d4 | ||
|
|
0720fb84f8 | ||
|
|
322727030f | ||
|
|
39e053de2e | ||
|
|
58b8d5b198 | ||
|
|
1c48f4d2c3 | ||
|
|
252e440df5 | ||
|
|
e293e290d5 | ||
|
|
39855df3fb | ||
|
|
2448ac0026 | ||
|
|
c2308a1e36 | ||
|
|
0717caea5c | ||
|
|
32297fdae8 | ||
|
|
455871a0ef | ||
|
|
725fa2b6f1 | ||
|
|
70f2160bd6 | ||
|
|
0126f750d7 | ||
|
|
1d19dfc9d5 | ||
|
|
963c04cf45 | ||
|
|
7509a72502 | ||
|
|
e5440e31af | ||
|
|
d20c8ab992 | ||
|
|
da64016a94 | ||
|
|
667d895fb9 | ||
|
|
950d52baf2 | ||
|
|
26835e30ef | ||
|
|
a5d9baf589 | ||
|
|
d26606b2f9 | ||
|
|
b370e86f96 | ||
|
|
9b705d651f | ||
|
|
c4e52954fe | ||
|
|
28de031f25 | ||
|
|
dbf456b73b | ||
|
|
cef5994ea6 | ||
|
|
2c4abb9b59 | ||
|
|
4ce0d8491e | ||
|
|
ea32c2e659 | ||
|
|
abd52534bb | ||
|
|
f47ca8a122 | ||
|
|
43c5a47172 | ||
|
|
edf8f36099 | ||
|
|
a0bcd0c836 | ||
|
|
af42a850f6 | ||
|
|
bf88daa649 | ||
|
|
a4149ab248 | ||
|
|
5cee307e7c | ||
|
|
9ca70264f3 | ||
|
|
65ac8ad977 | ||
|
|
e24d8ca733 | ||
|
|
ab66c0e945 | ||
|
|
03e9b57108 | ||
|
|
fcc9ce84f2 | ||
|
|
c3ea596855 | ||
|
|
ede3ad10c2 | ||
|
|
f40aed820f | ||
|
|
2eb4d43a59 | ||
|
|
59d011a23b | ||
|
|
43cf87315e | ||
|
|
9de302f881 | ||
|
|
67e21c582c | ||
|
|
e47fa1b5bf | ||
|
|
c7896d3bb0 | ||
|
|
3489a09581 | ||
|
|
4f1434720c | ||
|
|
350ce0ba42 | ||
|
|
0975b1d23f | ||
|
|
019d852707 | ||
|
|
b3399253b6 | ||
|
|
1888b56883 | ||
|
|
1e251cbf36 | ||
|
|
34c8f8c1b8 | ||
|
|
4de5229f10 | ||
|
|
8ac0026d24 | ||
|
|
b232108314 | ||
|
|
50c3db383a | ||
|
|
3d68c4127d | ||
|
|
61924ea018 | ||
|
|
aaa6f59446 | ||
|
|
d4d328a545 | ||
|
|
a788af8919 | ||
|
|
94b39cbfe9 | ||
|
|
c9d3955859 | ||
|
|
2f83518415 | ||
|
|
37e15767f0 | ||
|
|
ebc26f3960 | ||
|
|
94a41913a3 | ||
|
|
6b5e869dd5 | ||
|
|
3d00a93655 | ||
|
|
f1a8220f29 | ||
|
|
df33550945 | ||
|
|
7e4db33061 | ||
|
|
e8017ca2ec | ||
|
|
7b89291334 | ||
|
|
47696bef8c | ||
|
|
a67b00d7c7 | ||
|
|
75eb7ff871 | ||
|
|
71736a3f4f | ||
|
|
c30bc9e71e | ||
|
|
a8de2911e5 | ||
|
|
168c3bc22e | ||
|
|
a74061f739 | ||
|
|
e5b3434e9b | ||
|
|
d3145e9a7b | ||
|
|
b5a58557e3 | ||
|
|
6fa985eac2 | ||
|
|
50239d4b49 | ||
|
|
541e9bd1ee | ||
|
|
b45bff9db1 | ||
|
|
5ac4947d02 | ||
|
|
21f2248a3c | ||
|
|
c9135aad67 | ||
|
|
161a0d12a2 | ||
|
|
9491ffa92b | ||
|
|
68e3414ed5 | ||
|
|
27623a3b17 | ||
|
|
39c14be113 | ||
|
|
559417e8ac | ||
|
|
71fb66d687 | ||
|
|
96de23cd97 | ||
|
|
5f49282c85 | ||
|
|
3068f75c2d | ||
|
|
a59df81611 | ||
|
|
cea34366f5 | ||
|
|
cd8b245fdc | ||
|
|
72c548b729 | ||
|
|
21462c6813 | ||
|
|
0b0e123f42 | ||
|
|
6af6fe3dda | ||
|
|
e8f9b47e31 | ||
|
|
1c62045b22 | ||
|
|
f1f0a4c966 | ||
|
|
30699b85a9 | ||
|
|
6aa4272b65 | ||
|
|
3e6fc6ed9f | ||
|
|
c312ff539c | ||
|
|
d214f6a854 | ||
|
|
45c2c92c4a | ||
|
|
caa1169f04 | ||
|
|
01a21dd60f | ||
|
|
b49cfbf164 | ||
|
|
5e8a039e7c | ||
|
|
d1afecc478 | ||
|
|
186bb2e3d1 | ||
|
|
aa10f8fbf1 | ||
|
|
ca2549da5a | ||
|
|
2df6c1b121 | ||
|
|
b3c48d0775 | ||
|
|
c8c220d789 | ||
|
|
8fbb6178dd | ||
|
|
cfe878204e | ||
|
|
9b9daa9b4b | ||
|
|
0e77981dec | ||
|
|
d4e1945f77 | ||
|
|
ae7722e541 | ||
|
|
312d380aab | ||
|
|
3a2817505a | ||
|
|
3c78e2fbb5 | ||
|
|
0808ad96c2 | ||
|
|
1557eaa2f5 | ||
|
|
55152ce476 |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"interface": {
|
||||
"displayName": "MemPalace"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mempalace",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./.codex-plugin"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "NONE"
|
||||
},
|
||||
"category": "Coding"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mempalace": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"-m",
|
||||
"mempalace.mcp_server"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# MemPalace Claude Code Plugin
|
||||
|
||||
A Claude Code plugin that gives your AI a persistent memory system. Mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and 5 guided skills.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9+
|
||||
|
||||
## Installation
|
||||
|
||||
### Claude Code Marketplace
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add MemPalace/mempalace
|
||||
claude plugin install --scope user mempalace
|
||||
```
|
||||
|
||||
### Local Clone
|
||||
|
||||
```bash
|
||||
claude plugin add /path/to/mempalace
|
||||
```
|
||||
|
||||
## Post-Install Setup
|
||||
|
||||
After installing the plugin, run the init command to complete setup (pip install, MCP configuration, etc.):
|
||||
|
||||
```
|
||||
/mempalace:init
|
||||
```
|
||||
|
||||
## Available Slash Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/mempalace:help` | Show available tools, skills, and architecture |
|
||||
| `/mempalace:init` | Set up MemPalace -- install, configure MCP, onboard |
|
||||
| `/mempalace:search` | Search your memories across the palace |
|
||||
| `/mempalace:mine` | Mine projects and conversations into the palace |
|
||||
| `/mempalace:status` | Show palace overview -- wings, rooms, drawer counts |
|
||||
|
||||
## Hooks
|
||||
|
||||
MemPalace registers two hooks that run automatically:
|
||||
|
||||
- **Stop** -- Saves conversation context every 15 messages.
|
||||
- **PreCompact** -- Preserves important memories before context compaction.
|
||||
|
||||
Set the `MEMPAL_DIR` environment variable to a directory path to automatically run `mempalace mine` on that directory during each save trigger.
|
||||
|
||||
## MCP Server
|
||||
|
||||
The plugin automatically configures a local MCP server with 19 tools for storing, searching, and managing memories. No manual MCP setup is required -- `/mempalace:init` handles everything.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
See the main [README](../README.md) for complete documentation, architecture details, and advanced usage.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Show comprehensive MemPalace help — available skills, MCP tools, CLI commands, hooks, and architecture.
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
Invoke the generic mempalace skill (using the Skill tool) with the `help` command, then follow its instructions.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Set up MemPalace — install the package, initialize a palace, configure MCP server, and verify everything works.
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
---
|
||||
|
||||
Invoke the generic mempalace skill (using the Skill tool) with the `init` command, then follow its instructions.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Mine projects and conversations into the MemPalace. Supports project files, conversation exports, and auto-classification.
|
||||
argument-hint: Path to project or conversation export to mine.
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
---
|
||||
|
||||
Invoke the generic mempalace skill (using the Skill tool) with the `mine` command, then follow its instructions.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Search your memories across the MemPalace using semantic search with wing/room filtering.
|
||||
argument-hint: Search query, optionally with wing/room filters.
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
Invoke the generic mempalace skill (using the Skill tool) with the `search` command, then follow its instructions.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Show the current state of your memory palace — wings, rooms, drawer counts, and suggestions.
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
Invoke the generic mempalace skill (using the Skill tool) with the `status` command, then follow its instructions.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"description": "MemPalace auto-save and pre-compact hooks",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/mempal-stop-hook.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/mempal-precompact-hook.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# MemPalace PreCompact Hook — thin wrapper calling Python CLI
|
||||
# All logic lives in mempalace.hooks_cli for cross-harness extensibility
|
||||
INPUT=$(cat)
|
||||
echo "$INPUT" | python3 -m mempalace hook run --hook precompact --harness claude-code
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# MemPalace Stop Hook — thin wrapper calling Python CLI
|
||||
# All logic lives in mempalace.hooks_cli for cross-harness extensibility
|
||||
INPUT=$(cat)
|
||||
echo "$INPUT" | python3 -m mempalace hook run --hook stop --harness claude-code
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"owner": {
|
||||
"name": "milla-jovovich",
|
||||
"url": "https://github.com/MemPalace"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mempalace",
|
||||
"source": "./.claude-plugin",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, guided setup.",
|
||||
"version": "3.3.2",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.3.2",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
},
|
||||
"license": "MIT",
|
||||
"commands": [],
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"-m",
|
||||
"mempalace.mcp_server"
|
||||
]
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"memory",
|
||||
"ai",
|
||||
"rag",
|
||||
"mcp",
|
||||
"chromadb",
|
||||
"palace",
|
||||
"search"
|
||||
],
|
||||
"repository": "https://github.com/MemPalace/mempalace"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: mempalace
|
||||
description: MemPalace — mine projects and conversations into a searchable memory palace. Use when asked about mempalace, memory palace, mining memories, searching memories, or palace setup.
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
|
||||
---
|
||||
|
||||
# MemPalace
|
||||
|
||||
A searchable memory palace for AI — mine projects and conversations, then search them semantically.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure `mempalace` is installed:
|
||||
|
||||
```bash
|
||||
mempalace --version
|
||||
```
|
||||
|
||||
If not installed:
|
||||
|
||||
```bash
|
||||
pip install mempalace
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
MemPalace provides dynamic instructions via the CLI. To get instructions for any operation:
|
||||
|
||||
```bash
|
||||
mempalace instructions <command>
|
||||
```
|
||||
|
||||
Where `<command>` is one of: `help`, `init`, `mine`, `search`, `status`.
|
||||
|
||||
Run the appropriate instructions command, then follow the returned instructions step by step.
|
||||
@@ -0,0 +1,75 @@
|
||||
# MemPalace - Codex CLI Plugin
|
||||
|
||||
Give your AI a persistent memory -- mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and guided skills.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9+
|
||||
- Codex CLI installed and configured
|
||||
- `pip install mempalace`
|
||||
|
||||
## Installation
|
||||
|
||||
### Local Install
|
||||
|
||||
1. Copy or symlink the `.codex-plugin` directory into your project root:
|
||||
|
||||
```bash
|
||||
cp -r .codex-plugin /path/to/your/project/.codex-plugin
|
||||
```
|
||||
|
||||
2. Verify the plugin is detected:
|
||||
|
||||
```bash
|
||||
codex --plugins
|
||||
```
|
||||
|
||||
3. Initialize your palace:
|
||||
|
||||
```bash
|
||||
codex /init
|
||||
```
|
||||
|
||||
### Git Install
|
||||
|
||||
1. Clone the MemPalace repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
```
|
||||
|
||||
2. Install the Python package:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
3. The `.codex-plugin` directory is already in the repo root. Codex CLI will detect it automatically when you run Codex from inside the repository.
|
||||
|
||||
4. Initialize your palace:
|
||||
|
||||
```bash
|
||||
codex /init
|
||||
```
|
||||
|
||||
## Available Skills
|
||||
|
||||
| Skill | Description |
|
||||
|-------|-------------|
|
||||
| `/help` | Show available commands and usage tips |
|
||||
| `/init` | Initialize a new memory palace |
|
||||
| `/search` | Semantic search across all mined memories |
|
||||
| `/mine` | Mine a project or conversation into your palace |
|
||||
| `/status` | Show palace status, room counts, and health |
|
||||
|
||||
## Hooks
|
||||
|
||||
The plugin includes auto-save hooks that run on session stop (every 15 messages) and before context compaction, automatically preserving conversation context into your palace.
|
||||
|
||||
Set the `MEMPAL_DIR` environment variable to a directory path to automatically run `mempalace mine` on that directory during each save trigger.
|
||||
|
||||
## Support
|
||||
|
||||
- Repository: https://github.com/MemPalace/mempalace
|
||||
- Issues: https://github.com/MemPalace/mempalace/issues
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh session-start"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh stop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh precompact"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
HOOK_NAME="${1:?Usage: mempal-hook.sh <hook-name>}"
|
||||
INPUT_FILE=$(mktemp) || { echo "Failed to create temp file" >&2; exit 1; }
|
||||
cat > "$INPUT_FILE"
|
||||
cat "$INPUT_FILE" | python3 -m mempalace hook run --hook "$HOOK_NAME" --harness codex
|
||||
EXIT_CODE=$?
|
||||
rm -f "$INPUT_FILE" 2>/dev/null
|
||||
exit $EXIT_CODE
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.3.2",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
},
|
||||
"homepage": "https://github.com/MemPalace/mempalace",
|
||||
"repository": "https://github.com/MemPalace/mempalace",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"memory",
|
||||
"ai",
|
||||
"rag",
|
||||
"mcp",
|
||||
"chromadb",
|
||||
"palace",
|
||||
"search"
|
||||
],
|
||||
"skills": "./skills/",
|
||||
"hooks": "./hooks.json",
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"-m",
|
||||
"mempalace.mcp_server"
|
||||
]
|
||||
}
|
||||
},
|
||||
"interface": {
|
||||
"displayName": "MemPalace",
|
||||
"shortDescription": "AI memory system for Codex",
|
||||
"longDescription": "Give your AI a persistent memory — mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and guided skills.",
|
||||
"developerName": "milla-jovovich",
|
||||
"category": "Coding",
|
||||
"capabilities": [
|
||||
"Interactive",
|
||||
"Read",
|
||||
"Write"
|
||||
],
|
||||
"websiteURL": "https://github.com/MemPalace/mempalace",
|
||||
"privacyPolicyURL": "https://github.com/MemPalace/mempalace",
|
||||
"termsOfServiceURL": "https://github.com/MemPalace/mempalace",
|
||||
"defaultPrompt": [
|
||||
"Search my memories for recent decisions",
|
||||
"Mine this project into my memory palace",
|
||||
"Show my palace status and room counts"
|
||||
],
|
||||
"brandColor": "#7C3AED"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: help
|
||||
description: Show MemPalace help — available commands, usage tips, and getting started guidance.
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
# MemPalace Help
|
||||
|
||||
Run the following command and follow the returned instructions step by step:
|
||||
|
||||
```bash
|
||||
mempalace instructions help
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: init
|
||||
description: Initialize a new MemPalace — guided setup for your AI memory palace with ChromaDB backend.
|
||||
allowed-tools: Bash, Read, Write, Edit
|
||||
---
|
||||
|
||||
# MemPalace Init
|
||||
|
||||
Run the following command and follow the returned instructions step by step:
|
||||
|
||||
```bash
|
||||
mempalace instructions init
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: mine
|
||||
description: Mine a project or conversation into your MemPalace — extract and store memories for later retrieval.
|
||||
allowed-tools: Bash, Read, Glob, Grep
|
||||
---
|
||||
|
||||
# MemPalace Mine
|
||||
|
||||
Run the following command and follow the returned instructions step by step:
|
||||
|
||||
```bash
|
||||
mempalace instructions mine
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: search
|
||||
description: Search your MemPalace — semantic search across all mined memories, projects, and conversations.
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
# MemPalace Search
|
||||
|
||||
Run the following command and follow the returned instructions step by step:
|
||||
|
||||
```bash
|
||||
mempalace instructions search
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: status
|
||||
description: Show MemPalace status — room counts, storage usage, and palace health.
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
# MemPalace Status
|
||||
|
||||
Run the following command and follow the returned instructions step by step:
|
||||
|
||||
```bash
|
||||
mempalace instructions status
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "MemPalace",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.11",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.debugpy",
|
||||
"charliermarsh.ruff"
|
||||
],
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.pytestArgs": ["tests/", "-v", "--ignore=tests/benchmarks"],
|
||||
"ruff.importStrategy": "fromEnvironment",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== MemPalace Dev Container Setup ==="
|
||||
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Match CI's ruff pin (pyproject only sets a floor; without this contributors
|
||||
# get a newer ruff locally than CI runs, causing phantom lint failures).
|
||||
pip install "ruff>=0.4.0,<0.5"
|
||||
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
|
||||
echo ""
|
||||
echo "=== Verification ==="
|
||||
echo "python: $(python --version)"
|
||||
echo "pytest: $(python -m pytest --version 2>&1 | head -1)"
|
||||
echo "ruff: $(python -m ruff --version 2>&1 | head -1)"
|
||||
echo ""
|
||||
echo "Ready. Run: pytest tests/ -v --ignore=tests/benchmarks"
|
||||
@@ -0,0 +1,13 @@
|
||||
# Default owners for everything
|
||||
* @milla-jovovich @bensig @igorls
|
||||
|
||||
# Core library
|
||||
mempalace/ @milla-jovovich @bensig
|
||||
|
||||
# CI and workflows
|
||||
.github/ @bensig
|
||||
|
||||
# Plugins and integrations
|
||||
.claude-plugin/ @bensig
|
||||
.codex-plugin/ @bensig
|
||||
integrations/ @bensig
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Bump Version
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Bump patch version
|
||||
run: |
|
||||
CURRENT=$(python3 -c "exec(open('mempalace/version.py').read()); print(__version__)")
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||
PATCH=$((PATCH + 1))
|
||||
NEW="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "__version__ = \"${NEW}\"" > mempalace/version.py
|
||||
# Prepend docstring
|
||||
sed -i '1i"""Single source of truth for the MemPalace package version."""\n' mempalace/version.py
|
||||
echo "version=$NEW" >> "$GITHUB_OUTPUT"
|
||||
id: version
|
||||
|
||||
- name: Sync plugin.json
|
||||
run: |
|
||||
jq --arg v "${{ steps.version.outputs.version }}" '.version = $v' .claude-plugin/plugin.json > tmp.json && mv tmp.json .claude-plugin/plugin.json
|
||||
|
||||
- name: Sync marketplace.json
|
||||
run: |
|
||||
jq --arg v "${{ steps.version.outputs.version }}" '.plugins[0].version = $v' .claude-plugin/marketplace.json > tmp.json && mv tmp.json .claude-plugin/marketplace.json
|
||||
|
||||
- name: Sync codex plugin.json
|
||||
run: |
|
||||
jq --arg v "${{ steps.version.outputs.version }}" '.version = $v' .codex-plugin/plugin.json > tmp.json && mv tmp.json .codex-plugin/plugin.json
|
||||
|
||||
- name: Sync pyproject.toml
|
||||
run: |
|
||||
sed -i "s/^version = \".*\"/version = \"${{ steps.version.outputs.version }}\"/" pyproject.toml
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add mempalace/version.py .claude-plugin/plugin.json .claude-plugin/marketplace.json .codex-plugin/plugin.json pyproject.toml
|
||||
if ! git diff --staged --quiet; then
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.version }}"
|
||||
git push
|
||||
fi
|
||||
+29
-10
@@ -2,31 +2,50 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
test-linux:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.11", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -r requirements.txt pytest
|
||||
- run: python -m pytest tests/ -v
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10
|
||||
|
||||
test-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.9"
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10
|
||||
|
||||
test-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.9"
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: pip install ruff
|
||||
- run: pip install "ruff>=0.4.0,<0.5"
|
||||
- run: ruff check .
|
||||
- run: ruff format --check .
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- ".github/workflows/deploy-docs.yml"
|
||||
- "website/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure GitHub Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.1.38
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: website
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build docs
|
||||
working-directory: website
|
||||
env:
|
||||
DOCS_BASE: ${{ steps.pages.outputs.base_path }}
|
||||
DOCS_EDIT_BRANCH: ${{ github.ref_name }}
|
||||
run: bun run docs:build
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: website/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
if: github.ref_name == 'develop'
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Version Guard
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'mempalace/version.py'
|
||||
- '.claude-plugin/marketplace.json'
|
||||
- '.claude-plugin/plugin.json'
|
||||
- '.codex-plugin/plugin.json'
|
||||
- '.github/workflows/version-guard.yml'
|
||||
|
||||
jobs:
|
||||
check-versions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract versions from all sources
|
||||
id: versions
|
||||
run: |
|
||||
set -euo pipefail
|
||||
py_version=$(grep -E '^__version__' mempalace/version.py | cut -d'"' -f2)
|
||||
pyproject_version=$(grep -E '^version' pyproject.toml | head -1 | cut -d'"' -f2)
|
||||
marketplace_version=$(jq -r '.plugins[0].version' .claude-plugin/marketplace.json)
|
||||
plugin_version=$(jq -r '.version' .claude-plugin/plugin.json)
|
||||
codex_version=$(jq -r '.version' .codex-plugin/plugin.json)
|
||||
|
||||
echo "py_version=$py_version" >> "$GITHUB_OUTPUT"
|
||||
echo "pyproject_version=$pyproject_version" >> "$GITHUB_OUTPUT"
|
||||
echo "marketplace_version=$marketplace_version" >> "$GITHUB_OUTPUT"
|
||||
echo "plugin_version=$plugin_version" >> "$GITHUB_OUTPUT"
|
||||
echo "codex_version=$codex_version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "## Detected versions"
|
||||
echo ""
|
||||
echo "| Source | Version |"
|
||||
echo "| --- | --- |"
|
||||
echo "| mempalace/version.py | \`$py_version\` |"
|
||||
echo "| pyproject.toml | \`$pyproject_version\` |"
|
||||
echo "| .claude-plugin/marketplace.json | \`$marketplace_version\` |"
|
||||
echo "| .claude-plugin/plugin.json | \`$plugin_version\` |"
|
||||
echo "| .codex-plugin/plugin.json | \`$codex_version\` |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Verify all sources agree
|
||||
env:
|
||||
PY: ${{ steps.versions.outputs.py_version }}
|
||||
PYPROJECT: ${{ steps.versions.outputs.pyproject_version }}
|
||||
MARKETPLACE: ${{ steps.versions.outputs.marketplace_version }}
|
||||
PLUGIN: ${{ steps.versions.outputs.plugin_version }}
|
||||
CODEX: ${{ steps.versions.outputs.codex_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fail=0
|
||||
check() {
|
||||
local name="$1" value="$2" expected="$3"
|
||||
if [[ "$value" != "$expected" ]]; then
|
||||
echo "::error file=$name::version mismatch — expected $expected, got $value"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
# All five must agree with each other (use version.py as the reference, per CLAUDE.md)
|
||||
check "pyproject.toml" "$PYPROJECT" "$PY"
|
||||
check ".claude-plugin/marketplace.json" "$MARKETPLACE" "$PY"
|
||||
check ".claude-plugin/plugin.json" "$PLUGIN" "$PY"
|
||||
check ".codex-plugin/plugin.json" "$CODEX" "$PY"
|
||||
exit $fail
|
||||
|
||||
- name: Verify tag matches manifest (tag pushes only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
PY: ${{ steps.versions.outputs.py_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
|
||||
# Semver pre-release tags (v3.4.0-rc1, v1.0.0-beta.2, ...) are treated
|
||||
# as internal/staging and are not validated against the manifest. They
|
||||
# do not flow to end users via `/plugin update`, which reads the
|
||||
# manifest on the default branch.
|
||||
if [[ "$tag_version" == *-* ]]; then
|
||||
echo "Pre-release tag $GITHUB_REF_NAME — skipping strict manifest match."
|
||||
{
|
||||
echo ""
|
||||
echo "> Pre-release tag detected: \`$GITHUB_REF_NAME\`."
|
||||
echo "> Manifest ($PY) is not required to match. Pre-releases are not published via \`/plugin update\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$tag_version" != "$PY" ]]; then
|
||||
echo "::error::tag $GITHUB_REF_NAME does not match manifest version $PY"
|
||||
echo "Bump mempalace/version.py, pyproject.toml, and all plugin manifests before tagging a stable release."
|
||||
echo "For an internal/staging tag, use a semver pre-release suffix (e.g. v${PY}-rc1)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag $GITHUB_REF_NAME matches manifest version $PY"
|
||||
+31
@@ -5,3 +5,34 @@ __pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
mempal.yaml
|
||||
.a5c/
|
||||
.claude/
|
||||
.codex/
|
||||
.codex
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Coverage
|
||||
htmlcov/
|
||||
.coverage
|
||||
coverage.xml
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# ChromaDB local data
|
||||
*.sqlite3-journal
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.0
|
||||
# Keep in lock-step with the ruff version pinned in .github/workflows/ci.yml
|
||||
# (>=0.4.0,<0.5). Using a newer rev here produces a different formatter
|
||||
# output than CI and breaks `ruff format --check` in the lint job.
|
||||
rev: v0.4.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to [MemPalace](https://github.com/MemPalace/mempalace) are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
## [3.3.2] — 2026-04-19
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix silent drop of `.jsonl` files in project miner; raise `MAX_FILE_SIZE` cap from 10 MB to 500 MB so large transcripts no longer fall through unnoticed. Adds a tandem **sweeper** — a message-level, timestamp-coordinated, idempotent safety net that catches anything the primary miner missed. (#998)
|
||||
- `mempalace sweep <target>` CLI to run the sweeper on demand against a transcript file or a directory. (#998)
|
||||
- Guard `Layer3.search_raw` against `None` doc/meta rows returned by ChromaDB — prevents `AttributeError` crashes on mixed-schema palaces. (#1011, #1013)
|
||||
- Guard searcher API path, closet loop, and miner status histogram against `None` metadata; matching guards added to `tool_status` / `list_wings` / `list_rooms` / `get_taxonomy` in the MCP server. (#999)
|
||||
- Upgrade `chromadb` floor to `>=1.5.4` for Python 3.13 / 3.14 compatibility and pin upper bound to `<2` so future breaking majors don't silently install. (#1010)
|
||||
- Fix Unicode checkmark rendering on Windows terminals that can't encode the `✓` glyph — avoids `UnicodeEncodeError` crashes on first-run output. (#681)
|
||||
- **`quarantine_stale_hnsw`** — on open, detect HNSW segment directories whose `data_level0.bin` is significantly older than `chroma.sqlite3` and rename them out of the way. Recovers cleanly from HNSW/sqlite drift that otherwise causes SIGSEGV on `count()` / `query(...)` (the chroma-core/chroma#2594 failure mode). Rebuilds the index lazily on next use. (#1000)
|
||||
- **PID file guard** — `mine` writes a per-source-directory PID file and refuses to start if an existing mine is still running, preventing process stacking that bloats HNSW and wedges concurrent writes. Includes cross-platform PID liveness check (`os.kill(pid, 0)` terminates on Windows, so the guard falls back to a platform-aware probe). (#1023)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **RFC 001 §10 — typed backend contracts.** `BaseBackend` now returns typed `QueryResult` / `GetResult` dataclasses and `PalaceRef` for palace identity; registry-based backend discovery. Internal refactor; no user-facing API change. (#995)
|
||||
- **RFC 002 §9 — source adapter scaffolding.** Introduces `BaseSourceAdapter`, adapter registry, and `PalaceContext` — the plumbing that future pluggable ingest sources will target. Internal refactor; no user-facing API change yet. (#1014)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **RFC 002** — full specification for the source adapter plugin system (future pluggable ingest). (#990)
|
||||
- First-run help text and `README` now reference the real `~/.claude/projects/<project>/` path shape instead of the placeholder `/path/to/transcripts`. (#996, #1012)
|
||||
|
||||
### Internal
|
||||
|
||||
- Harden sweeper for production: verbatim tool blocks, full `session_id`, logged failures.
|
||||
- Address Copilot review on #995: cursor tie-break, honest metrics, accurate comments.
|
||||
- Test hygiene: avoid ONNX network download in update-length validation tests; dedup update-length-validation tests; fix Windows file-lock in cache-invalidation test.
|
||||
|
||||
---
|
||||
|
||||
## [3.3.1] — 2026-04-16
|
||||
|
||||
### New Features
|
||||
|
||||
**Multi-language entity detection** — lexical patterns (person verbs, pronouns, dialogue markers, project verbs, stopwords, candidate character classes) now live in the optional `entity` section of each locale JSON under `mempalace/i18n/<lang>.json`. Every public function in `entity_detector` accepts a `languages=` tuple and unions patterns across enabled locales. Default stays `("en",)` so existing English-only callers are unchanged. (#911)
|
||||
|
||||
- **Five new fully-supported locales** with CLI strings, AAAK compression instructions, and entity-detection patterns:
|
||||
- Brazilian Portuguese `pt-br` (#156)
|
||||
- Russian `ru` (#760)
|
||||
- Italian `it` (#907)
|
||||
- Hindi `hi` (#773)
|
||||
- Indonesian `id` (#778)
|
||||
- **`MempalaceConfig.entity_languages`** — persistent palace-level language selection; `MEMPALACE_ENTITY_LANGUAGES` env override; `mempalace init --lang en,pt-br` flag that saves to `~/.mempalace/config.json` (#911)
|
||||
- **Per-language `candidate_pattern`** — non-Latin scripts register their own character class, so names like `João`, `Инна`, `राज` are no longer silently dropped by the ASCII-only default (#911)
|
||||
- **VSCode devcontainer** matching the CI environment (#881)
|
||||
- `MEMPAL_VERBOSE` env toggle — developers see diaries surfaced in chat while the default remains silent (#871)
|
||||
- `created_at` timestamps included in search results (#846)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
**i18n / Unicode**
|
||||
|
||||
- Script-aware word boundaries for combining-mark scripts — Python's `\b` fails on Devanagari vowel signs (`ा ी ु`), Arabic, Hebrew, Thai, Tamil, Khmer etc., truncating names like `अनीता` → `अनीत` and making person-verb patterns never fire. Locales now declare an optional `boundary_chars` field and the i18n loader expands `\b` into a script-aware lookaround boundary (#932)
|
||||
- Case-insensitive BCP 47 language code resolution — `--lang PT-BR`, `zh-cn`, `Pt-Br` previously fell through to English silently; now resolve to the canonical locale file via lowercase matching, with the entity-pattern cache keyed on the canonical form so casing variations share one cache entry (#928)
|
||||
- Wire i18n candidate patterns into `miner._extract_entities_for_metadata()`, `palace.build_closet_lines()`, and `entity_registry.extract_unknown_candidates()` — three code paths that still hardcoded ASCII-only `[A-Z][a-z]{2,}` and silently missed Cyrillic, accented Latin, and non-Latin entity metadata tags (#931)
|
||||
- Explicit `encoding="utf-8"` on `Path.read_text()` calls across entity_registry, instructions_cli, split_mega_files, and onboarding tests — prevents Windows GBK (and other non-UTF-8) locales from corrupting UTF-8 files (#946, #776)
|
||||
- `ko.json` `status_drawers` used `{drawers}` instead of `{count}`, showing the raw template string instead of the number (#758)
|
||||
- Move `test_i18n.py` from inside the installed package into `tests/` so pytest actually collects it; remove the `sys.path.insert` hack (#758)
|
||||
- `Dialect.from_config()` defaulted to `current_lang()` (module-global) when config had no `lang` key — replaced with explicit `"en"` fallback for determinism (#758)
|
||||
|
||||
**Other**
|
||||
|
||||
- Guard `KnowledgeGraph.close()` and `query_relationship`/`timeline`/`stats` methods with the instance lock to prevent concurrent-access corruption (#887, #884)
|
||||
- Replace invalid `{"decision": "allow"}` with `{}` in hook responses — the string wasn't a valid decision value and triggered schema warnings (#885)
|
||||
- `entity_registry.research()` defaults to local-only — previously made outbound Wikipedia HTTPS requests without explicit user opt-in; callers now must pass `allow_network=True` (#811)
|
||||
- Precompact hook no longer blocks compaction when it fails or takes too long (#856, #858, #863)
|
||||
- Redirect stdout to stderr during MCP server import so library logging can't corrupt the JSON-RPC channel (#225, #864)
|
||||
- `mempalace init` auto-adds per-project files to `.gitignore` in git repositories so users don't accidentally commit `mempalace.yaml` / `entities.json` (#185, #866)
|
||||
- Searcher guards against empty ChromaDB query results that previously raised on edge-case corpora (#195, #865)
|
||||
- Return empty status instead of an error on a cold-start palace with no drawers yet (#830, #831)
|
||||
- Restrict file permissions on sensitive palace data (#814)
|
||||
- Slack transcript importer writes a provenance header and preserves speaker IDs (#815)
|
||||
- Allow `mempalace mine` to run in directories without a local `mempalace.yaml` and surface the missing-yaml warning on stderr (#604)
|
||||
- Security hook injection fix (#812)
|
||||
- Save hook auto-mines transcripts even when `MEMPAL_DIR` is unset (#840)
|
||||
- Pin the Pages custom domain via a shipped `CNAME` in the deploy artifact (#877)
|
||||
- Version drift safeguard — sync pyproject + `version.py` + README badge in one place (#876)
|
||||
- Deploy docs workflow now runs on `develop` only, preventing accidental main-branch deploys (#845)
|
||||
|
||||
### Improvements
|
||||
|
||||
- Regex compilation optimization for entity extraction — pre-compile per-entity pattern sets once and cache by `(name, languages)` tuple, so multi-language callers don't thrash the cache (#880)
|
||||
- Knowledge-graph value sanitization now preserves natural punctuation (commas, colons, parentheses) that commonly appears in KG subject/object values (#873)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Clarify that `mempalace init` requires a `<dir>` argument in CLI help text (#210, #862)
|
||||
- Domain name and specific impostor sites called out in the scam-alert section (#869)
|
||||
- Tightened `SECURITY.md` with a real version-support policy and the GHPVR-only reporting channel (#810)
|
||||
- Fixed stale `pyproject.toml` URLs (#853)
|
||||
- v4 planning prep (#852)
|
||||
|
||||
### Internal
|
||||
|
||||
- `palace_graph` tunnel helper test coverage (#908)
|
||||
|
||||
---
|
||||
|
||||
## [3.3.0] — 2026-04-13
|
||||
|
||||
### New Features
|
||||
- Closet layer — a compact searchable index of pointers to verbatim drawers, enabling fast topical lookup without reading all content (#788)
|
||||
- BM25 hybrid search — closets boost ranking, drawers remain the source of truth (#795, #829)
|
||||
- Entity metadata on every drawer for filterable search (#829)
|
||||
- Diary ingest — day-based rooms for conversation transcripts (#829)
|
||||
- Cross-wing tunnels — explicit links between rooms in different wings for multi-project agents (#829)
|
||||
- Drawer-grep — returns the best-matching chunk plus adjacent context drawers (#829)
|
||||
- Offline fact checker against the entity registry and knowledge graph (#829)
|
||||
- LLM-based closet regeneration — optional, bring-your-own endpoint, no mandatory API key (#793)
|
||||
- Hall detection — routes drawer content to `emotions` / `technical` / `family` / `memory` / `identity` / `consciousness` / `creative` halls, enabling hall-based graph connectivity within wings (#835)
|
||||
|
||||
### Bug Fixes
|
||||
- Set `hnsw:space=cosine` metadata on all collection creation sites — fixes broken similarity scoring under ChromaDB's default L2 distance (#807, #218)
|
||||
- File-level locking prevents duplicate drawers when agents mine the same file concurrently (#784, #826)
|
||||
- Hybrid closet+drawer retrieval — closets boost ranking, never gate results (#795)
|
||||
- Stop hooks from making agents write in chat — saves tokens on every turn (#786)
|
||||
- Strip system tags, hook output, and Claude UI chrome from drawers before filing (#785)
|
||||
- Verbatim-safe `strip_noise` scoped to Claude Code JSONL only (#785)
|
||||
- Prevent diary entry ID collisions via microsecond timestamp and full content hash (#819)
|
||||
- Auto-rebuild stale drawers via `NORMALIZE_VERSION` schema gate
|
||||
- Enforce atomic topics in closets and extract richer pointers
|
||||
- Sync `version.py` to match `pyproject.toml` (#820)
|
||||
- Remove unused `main` import from `mempalace/__init__.py` (#827)
|
||||
- README audit — fix 7 stale claims (tool count, version badge, wake-up token cost, `dialect.py` lossless disclaimer, `pyproject.toml` version) with 42 regression-guard tests (#835)
|
||||
|
||||
### Improvements
|
||||
- Optimize entity detection with regex caching and pre-compilation (#828)
|
||||
- Extract locked filing block into helper to keep `mine_convos` under C901 complexity
|
||||
|
||||
### Documentation
|
||||
- Add `docs/CLOSETS.md` — closet layer overview
|
||||
- Fix stale `milla-jovovich/*` org URLs in website and plugin manifests (#787)
|
||||
- Fix remaining stale org URLs in contributor docs (#808)
|
||||
- Rewrite `README.md` and `mempalaceofficial.com` benchmark pages to remove category-error cross-system comparisons (R@5 retrieval recall had been listed next to competitor QA accuracy under one column), remove the retracted "+34% palace boost" claim from the surfaces where it had remained, replace the `100%` Haiku-rerank headline with the honest held-out `98.4%` R@5, drop the LoCoMo `100%` top-50 row (retrieval-bypass artefact), and fix the broken `aya-thekeeper/mempal` reproduction URL (#875)
|
||||
- Add `docs/HISTORY.md` as the canonical home for corrections, retractions, and public notices; move the 2026-04-07 "Note from Milla & Ben" and the 2026-04-11 impostor-domain notice out of `README.md`
|
||||
- Add v3.3.0 reproduction result JSONLs and the deterministic `seed=42` 50/450 LongMemEval split under `benchmarks/` — every BENCHMARKS.md claim reproduces exactly
|
||||
|
||||
### Internal
|
||||
- Add test coverage for `mine_lock`, closets, entity metadata, BM25, and diary
|
||||
- Verify `mine_lock` via disjoint critical-section intervals
|
||||
- Serialize `mine_lock` concurrency test with multiprocessing
|
||||
- Make diary state path assertion platform-neutral
|
||||
- Add `TestTunnels` coverage for cross-wing tunnel operations
|
||||
- Ruff format with CI-pinned version (0.4.x); format `mempalace/palace.py`
|
||||
|
||||
---
|
||||
|
||||
## [3.2.0] — 2026-04-12
|
||||
|
||||
### Packaging
|
||||
- Remove `chromadb<0.7` upper bound — unblocks installs against chromadb 1.x palaces (#690)
|
||||
- Bump version to 3.2.0 across `pyproject.toml`, `mempalace/version.py`, README badge, and OpenClaw SKILL (#761)
|
||||
|
||||
### Security
|
||||
- Harden palace deletion, WAL redaction, and MCP search input handling (#739)
|
||||
- Consistent input validation, argument whitelisting, concurrency safety, and WAL fixes (#647)
|
||||
- Remove hardcoded credential paths from benchmark runners (#177)
|
||||
- Remove global SSL verification bypass in convomem_bench (#176)
|
||||
|
||||
### Bug Fixes
|
||||
- Parse Claude.ai privacy export with `messages` key and sender field (#685, #677)
|
||||
- Detect mtime changes in `_get_client` to prevent stale HNSW index (#757)
|
||||
- Hash full content in `tool_add_drawer` drawer ID — stable re-mines (#716)
|
||||
- Remove 10k drawer cap from status display (#707, #603)
|
||||
- Correct typo in entity_detector interactive classification prompt (#755)
|
||||
- Prevent convo_miner from re-processing 0-chunk files on every run (#732, #654)
|
||||
- Remove silent 8-line AI response truncation in convo_miner (#708, #692)
|
||||
- Store full AI response in convo_miner exchange chunking (#695)
|
||||
- Fix `mine --dry-run` TypeError on files with room=None (#687, #586)
|
||||
- Skip arg whitelist for handlers accepting `**kwargs` (#684, #572)
|
||||
- Allow Unicode in `sanitize_name()` — Latvian, CJK, Cyrillic (#683, #637)
|
||||
- Auto-repair BLOB seq_ids from chromadb 0.6→1.5 migration (#664)
|
||||
- Remove no-op `ORT_DISABLE_COREML` env var (#653, #397)
|
||||
- Disambiguate hook block reasons to name MemPalace explicitly (#666)
|
||||
- Use epsilon comparison for mtime to prevent unnecessary re-mining (#610)
|
||||
- Correct token count estimate in compress summary (#609)
|
||||
- Implement MCP ping health checks (#600)
|
||||
- Align `cmd_compress` dict keys with `compression_stats()` return values (#569)
|
||||
- Skip unreachable reparse points in `detect_rooms_from_folders` on Windows (#558)
|
||||
- Prevent HNSW index bloat from duplicate `add()` calls (#544, #525)
|
||||
- Purge stale drawers before re-mine to avoid hnswlib segfault (#544)
|
||||
- Mitigate system prompt contamination in search queries (#385, #333)
|
||||
- Count Codex `user_message` turns in `_count_human_messages` (#373, #347)
|
||||
- Paginate large collection reads and surface errors in MCP tools (#371, #339, #338)
|
||||
- Expand `~` in split command directory argument (#361)
|
||||
- Ignore `wait_for_previous` argument to support Gemini MCP clients (#322)
|
||||
- Close KnowledgeGraph SQLite connections in test fixtures (#450)
|
||||
- Remove duplicate cache variable declarations in mcp_server.py (#449)
|
||||
- Add `--yes` flag to init instructions for non-interactive use (#682, #534)
|
||||
- Add `mcp` command with setup guidance (#315)
|
||||
|
||||
### New Features
|
||||
- i18n support — 8 languages (en, es, fr, de, ja, ko, zh-CN, zh-TW) (#718)
|
||||
- New MCP tools: get/list/update drawer, hook settings, export (#667, #635)
|
||||
- `mempalace migrate` — recover palaces from different ChromaDB versions (#502)
|
||||
- Add OpenClaw/ClawHub skill (#491)
|
||||
- Backend seam for pluggable storage backends (#413)
|
||||
|
||||
### Improvements
|
||||
- Disable broken auto-bump workflow (#414)
|
||||
- Improve agent readiness — AGENTS.md, dependabot, CODEOWNERS, labels (#497)
|
||||
|
||||
### Documentation
|
||||
- Add CLAUDE.md and mission/principles to AGENTS.md (#720)
|
||||
- Add VitePress documentation site (#439)
|
||||
- Add warning about fake MemPalace websites (#598)
|
||||
- Fix stale org URLs and PR branch target in contributor docs (#679)
|
||||
- Fix misaligned architecture diagram (#734, #733)
|
||||
- Add ROADMAP.md — v3.1.1 stability patch and v4.0.0-alpha plan
|
||||
|
||||
### Internal
|
||||
- ruff format convo_miner.py (#741)
|
||||
- ruff format all Python files (#675)
|
||||
- CI: trigger tests on develop branch PRs and pushes (#674)
|
||||
- CI: fix GitHub Pages publishing (#691)
|
||||
|
||||
---
|
||||
|
||||
## [3.1.0] — 2026-04-09
|
||||
|
||||
### Security
|
||||
- Harden inputs, fix shell injection, optimize DB access (#387)
|
||||
- Sanitize SESSION_ID in save hook to prevent path traversal (#141)
|
||||
- Sanitize error responses and remove `sys.exit` from library code (#139)
|
||||
- Shell injection fix in hooks, Claude Code mining, chromadb pin (#114)
|
||||
|
||||
### Bug Fixes
|
||||
- MCP null args hang, repair infinite recursion, OOM on large files (#399)
|
||||
- Release ChromaDB handles before rmtree on Windows (#392)
|
||||
- Use `os.utime` in mtime test for Windows compatibility (#392)
|
||||
- Negotiate MCP protocol version instead of hardcoding (#324)
|
||||
- Use upsert and deterministic IDs to prevent data stagnation (#140)
|
||||
- Make `drawer_id` deterministic for idempotent writes (#387)
|
||||
- Honest AAAK stats — word-based token estimator, lossy labels (#147)
|
||||
- Room detection checks keywords against folder paths (#145)
|
||||
- Use actual detected room in mine summary stats (#165)
|
||||
- Honour `--palace` flag in mcp_server (#264)
|
||||
- Preserve default KG path when `--palace` not passed (#270)
|
||||
- `--yes` flag skips all interactive prompts in init (#123)
|
||||
- Repair command, split args, Claude export, room keywords (#119)
|
||||
- Replace Unicode separator in convo_miner.py for Windows compatibility (#129)
|
||||
- Coerce MCP integer arguments to native Python int (#84)
|
||||
- Batch ChromaDB reads to avoid SQLite variable limit (#66)
|
||||
- Respect nested .gitignore rules during mining (#78)
|
||||
- Narrow bare `except Exception` to specific types where safe (#54)
|
||||
- Mark MD5 as non-security in miner drawer ID generation (#53)
|
||||
- Remove dead code and duplicate set items in entity_registry.py (#42)
|
||||
- Silence ChromaDB telemetry warnings and CoreML segfault on Apple Silicon (#236)
|
||||
- Unify package and MCP version reporting (#16)
|
||||
- Fix broken AAAK Dialect link in README (#238)
|
||||
- Update input prompt for entity confirmation (#83)
|
||||
- Preserve CLI exit codes, log tracebacks, sanitize search errors (#139)
|
||||
- Enable SQLite WAL mode and add consistent LIMIT to KG timeline (#136)
|
||||
- Add limit=10000 safety cap to all unbounded ChromaDB `.get()` calls (#137)
|
||||
- Re-mine modified files, idempotent `add_drawer`, cleanup ChromaDB handles (#140)
|
||||
- Resolve formatting, regression logic, and pytest defaults (#270)
|
||||
- Use `parse_known_args` to allow importing mcp_server during pytest (#270)
|
||||
|
||||
### New Features
|
||||
- Package MemPalace as standard Claude and Codex plugins (#270)
|
||||
- Add OpenAI Codex CLI JSONL normalizer (#61)
|
||||
- Add Codex plugin support with hooks, commands, and documentation (#270)
|
||||
- Add command documentation for help, init, mine, search, and status (#270)
|
||||
|
||||
### Improvements
|
||||
- Cache ChromaDB `PersistentClient` instead of re-instantiating per call (#135)
|
||||
- Tighten chromadb version range and add `py.typed` marker (#142)
|
||||
- Consolidate split known-names config loading (#22)
|
||||
- CI: add separate jobs for Windows and macOS testing
|
||||
- CI: Upgrade GitHub Actions for Node 24 compatibility (#55)
|
||||
|
||||
### Documentation
|
||||
- Add Gemini CLI setup guide and integration section (#106)
|
||||
- Add beginner-friendly hooks tutorial (#103)
|
||||
- Align MCP setup examples with shipped server (#21)
|
||||
- Honest README update — own the mistakes, fix the claims
|
||||
|
||||
### Internal
|
||||
- Expand test coverage from 20 to 92 tests, migrate to uv (#131)
|
||||
- Add scale benchmark suite — 106 tests (#223)
|
||||
- Increase test coverage from 30% to 85%, fix Windows encoding bugs (#281)
|
||||
- Add WAL mode and entity timeline limit assertions
|
||||
- Add coverage for `file_already_mined` mtime check
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0] — 2026-04-06
|
||||
|
||||
Initial public release.
|
||||
|
||||
- Palace architecture with day-based rooms, drawers (verbatim), and closets (searchable index)
|
||||
- AAAK compression dialect for memory folding
|
||||
- Knowledge graph with entity detection and timeline queries
|
||||
- MCP server for Claude, Codex, and Gemini integration
|
||||
- CLI: `init`, `mine`, `search`, `status`, `compress`, `repair`, `split`
|
||||
- Benchmark suite with recall and scale tests
|
||||
- README with MCP flow, local model flow, and specialist agent documentation
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.2.0...HEAD
|
||||
[3.2.0]: https://github.com/MemPalace/mempalace/compare/v3.1.0...v3.2.0
|
||||
[3.1.0]: https://github.com/MemPalace/mempalace/compare/v3.0.0...v3.1.0
|
||||
[3.0.0]: https://github.com/MemPalace/mempalace/releases/tag/v3.0.0
|
||||
@@ -0,0 +1,133 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## The Mission
|
||||
|
||||
Memory is identity. When an AI forgets everything between conversations, it cannot build real understanding — of you, your work, your people, your life.
|
||||
|
||||
MemPalace exists to solve this. It is a memory system — not a search engine, not a RAG pipeline, not a vector database wrapper. It treats every word you have shared as sacred, stores it verbatim, and makes it instantly available. Your data never leaves your machine. We never summarize. We never paraphrase. We return your exact words.
|
||||
|
||||
100% recall is the design requirement — the target every search path is measured against. Anything less means forgetting, and forgetting means starting over.
|
||||
|
||||
The name comes from the ancient "method of loci" — the memory palace technique used for thousands of years to organize and recall vast amounts of information by placing it in imagined rooms of an imagined building. We were also inspired by the Zettelkasten method (created by German sociologist Niklas Luhmann) — small cross-referenced index cards that point to each other. We apply both ideas to AI memory:
|
||||
|
||||
- **Wings** for broad categories (people, projects, topics)
|
||||
- **Rooms** for time-based groupings (days, sessions)
|
||||
- **Drawers** for full verbatim content (your exact words)
|
||||
- **AAAK compression** for the index layer — a compact symbolic format (via `dialect.py`) that lets an LLM scan thousands of entries instantly and know exactly which drawer to open
|
||||
|
||||
## Design Principles
|
||||
|
||||
These are non-negotiable. Every PR, every feature, every refactor must honor them.
|
||||
|
||||
- **Verbatim always** — Never summarize, paraphrase, or lossy-compress user data. The system searches the index and returns the original words. If a user said it, we store exactly what they said. This is the foundational promise.
|
||||
- **Incremental only** — Append-only ingest after initial build. Never destroy existing data to rebuild. A crash mid-operation must leave the existing palace untouched.
|
||||
- **Entity-first** — Everything is keyed by real names with disambiguation by DOB, ID, or context. People matter more than topics.
|
||||
- **Local-first, zero API** — All extraction, chunking, and embedding happens on the user's machine. No cloud dependency for memory operations. No API keys required.
|
||||
- **Performance budgets** — Hooks under 500ms. Startup injection under 100ms. Memory should feel instant.
|
||||
- **Privacy by architecture** — The system physically cannot send your data because it never leaves your machine. No telemetry, no phone-home, no external service dependencies for core operations.
|
||||
- **Background everything** — Filing, indexing, timestamps, and pipeline work happen via hooks in the background. Nothing interrupts the user's conversation. Zero tokens spent on bookkeeping in the chat window.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome bug fixes, performance improvements, new language support, better entity disambiguation, documentation, and test coverage.
|
||||
|
||||
We do not accept summarization of user content, cloud storage/sync features, telemetry or analytics, features requiring API keys for core memory, or shortcuts that bypass verbatim storage.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
python -m pytest tests/ -v --ignore=tests/benchmarks
|
||||
|
||||
# Run tests with coverage
|
||||
python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing
|
||||
|
||||
# Lint
|
||||
ruff check .
|
||||
|
||||
# Format
|
||||
ruff format .
|
||||
|
||||
# Format check (CI mode)
|
||||
ruff format --check .
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mempalace/
|
||||
├── mcp_server.py # MCP server — all read/write tools
|
||||
├── cli.py # CLI dispatcher
|
||||
├── config.py # Configuration + input validation
|
||||
├── miner.py # Project file miner
|
||||
├── convo_miner.py # Conversation transcript miner
|
||||
├── searcher.py # Semantic search (hybrid BM25 + vector)
|
||||
├── knowledge_graph.py # Temporal entity-relationship graph (SQLite)
|
||||
├── palace.py # Shared palace operations
|
||||
├── palace_graph.py # Room traversal + cross-wing tunnels
|
||||
├── backends/ # Pluggable storage backends (ChromaDB default)
|
||||
│ ├── base.py # Abstract interface — implement this for new backends
|
||||
│ └── chroma.py # ChromaDB implementation
|
||||
├── dialect.py # AAAK compression dialect
|
||||
├── normalize.py # Transcript format detection + normalization
|
||||
├── entity_detector.py # Auto-detect people/projects from content
|
||||
├── entity_registry.py # Entity storage and disambiguation
|
||||
├── layers.py # L0-L3 memory wake-up stack
|
||||
├── onboarding.py # Interactive first-run setup
|
||||
├── repair.py # Palace repair and consistency checks
|
||||
├── dedup.py # Deduplication
|
||||
├── migrate.py # ChromaDB version migration
|
||||
├── spellcheck.py # Auto-correct user messages
|
||||
├── exporter.py # Palace data export
|
||||
├── hooks_cli.py # Hook management CLI
|
||||
├── query_sanitizer.py # Prompt contamination prevention
|
||||
├── split_mega_files.py # Split concatenated transcript files
|
||||
└── version.py # Single source of truth for version
|
||||
|
||||
hooks/ # Claude Code hook scripts
|
||||
├── mempal_save_hook.sh # Stop: triggers diary save
|
||||
└── mempal_precompact_hook.sh # PreCompact: saves state before compression
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Python style**: snake_case for functions/variables, PascalCase for classes
|
||||
- **Linter**: ruff with E/F/W rules
|
||||
- **Formatter**: ruff format, double quotes
|
||||
- **Commits**: conventional commits (`fix:`, `feat:`, `test:`, `docs:`, `ci:`)
|
||||
- **Tests**: `tests/test_*.py`, fixtures in `tests/conftest.py`
|
||||
- **Coverage**: 85% threshold (80% on Windows due to ChromaDB file lock cleanup)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User → CLI / MCP Server → Storage Backend (ChromaDB default, pluggable)
|
||||
→ SQLite (knowledge graph)
|
||||
|
||||
Palace structure:
|
||||
WING (person/project)
|
||||
└── ROOM (day/topic)
|
||||
└── DRAWER (verbatim text chunk)
|
||||
|
||||
Index layer (AAAK):
|
||||
Compressed pointers → DRAWER locations
|
||||
Scanned by LLM to find relevant drawers without reading all content
|
||||
|
||||
Knowledge Graph:
|
||||
ENTITY → PREDICATE → ENTITY (with valid_from / valid_to dates)
|
||||
```
|
||||
|
||||
## Key Files for Common Tasks
|
||||
|
||||
- **Adding an MCP tool**: `mempalace/mcp_server.py` — add handler function + TOOLS dict entry
|
||||
- **Changing search**: `mempalace/searcher.py`
|
||||
- **Modifying mining**: `mempalace/miner.py` (project files) or `mempalace/convo_miner.py` (transcripts)
|
||||
- **Adding a storage backend**: subclass `mempalace/backends/base.py`, register in `backends/__init__.py`
|
||||
- **Input validation**: `mempalace/config.py` — `sanitize_name()` / `sanitize_content()`
|
||||
- **Tests**: mirror source structure in `tests/test_<module>.py`
|
||||
+7
-4
@@ -5,8 +5,11 @@ Thanks for wanting to help. MemPalace is open source and we welcome contribution
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
git clone https://github.com/milla-jovovich/mempalace.git
|
||||
# Fork the repo on GitHub first, then clone your fork
|
||||
git clone https://github.com/<your-username>/mempalace.git
|
||||
cd mempalace
|
||||
git remote add upstream https://github.com/MemPalace/mempalace.git
|
||||
|
||||
pip install -e ".[dev]" # installs with dev dependencies (pytest, build, twine)
|
||||
```
|
||||
|
||||
@@ -52,7 +55,7 @@ assets/ ← logo + brand
|
||||
- `fix: handle empty transcript files`
|
||||
- `docs: update MCP tool descriptions`
|
||||
- `bench: add LoCoMo turn-level metrics`
|
||||
6. Push to your fork and open a PR against `main`
|
||||
6. Push to your fork and open a PR against `develop`
|
||||
|
||||
## Code Style
|
||||
|
||||
@@ -64,7 +67,7 @@ assets/ ← logo + brand
|
||||
|
||||
## Good First Issues
|
||||
|
||||
Check the [Issues](https://github.com/milla-jovovich/mempalace/issues) tab. Great starting points:
|
||||
Check the [Issues](https://github.com/MemPalace/mempalace/issues) tab. Great starting points:
|
||||
|
||||
- **New chat formats**: Add import support for Cursor, Copilot, or other AI tool exports
|
||||
- **Room detection**: Improve pattern matching in `room_detector_local.py`
|
||||
@@ -79,7 +82,7 @@ If you're planning a significant change, open an issue first to discuss the appr
|
||||
- **Verbatim first**: Never summarize user content. Store exact words.
|
||||
- **Local first**: Everything runs on the user's machine. No cloud dependencies.
|
||||
- **Zero API by default**: Core features must work without any API key.
|
||||
- **Palace structure matters**: Wings, halls, and rooms aren't cosmetic — they drive a 34% retrieval improvement. Respect the hierarchy.
|
||||
- **Palace structure is scoping, not magic**: Wings, halls, and rooms act as metadata filters in the underlying vector store. They keep retrieval predictable when a palace holds many unrelated projects or people. Respect the hierarchy — but don't present it as a novel retrieval mechanism.
|
||||
|
||||
## Community
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
MemPalace: The Mission
|
||||
|
||||
By: Milla Jovovich
|
||||
|
||||
Hey everyone! First of all thank you all for embracing MemPalace and trying it, catching bugs and issues and finding cool ways to personalize it into your workflows!
|
||||
|
||||
A few things I want to say.
|
||||
MemPalace is something I really needed because I'm trying to work on a big project with my partner @bensig and I was having a lot of problems with Claude's context window and my agent Lumi (Lu for short) kept waking up like "hey what are we doing today" when I had literally done hours of work with him throughout the day and it was impossible to just keep saving every transcript to catch him up on whatever we had done before compaction hit.
|
||||
|
||||
That's when I started researching different memory systems available today. I tried most of them and what I found was that no matter which one I tried, they felt like large empty warehouses where you just dump huge amounts of info.
|
||||
|
||||
RAG search would take forever and most of the time not find what I wanted.
|
||||
|
||||
I wanted to create a system with the ability to really remember everything AND be able to find it quickly, easily and also be able to remember things when I didn't. THAT in itself felt like something so important. Like "remember when we talked about that idea…" but in vague terms. Impossible with regular keyword search tools.
|
||||
|
||||
So MemPalace is not just about storing info in a highly structured way. But also RETRIEVING it in a highly UNSTRUCTURED way lol!
|
||||
|
||||
I was inspired by the Zettelkasten method (created by German sociologist Niklas Luhmann) — his idea of small cross-referenced index cards that point to each other. That's the architecture behind the palace: wings, rooms, closets, and drawers, all connected so you can find things from any angle, not just the one you filed them under.
|
||||
|
||||
Because of the way I've designed my agent Lumi to understand me, after so many months of my own personal experiments with MemPalace and the incredible help of my dear friend and co-founder, developer and engineer @bensig, he built a back end that made it really easy to get all my files in the proper spaces the Palace created based on my own decisions and with Lumi's help as well. All code has its own room, all ideas, research etc… has its proper place.
|
||||
|
||||
Names and concepts are parsed into closets that use a compression method I call AAAK (it doesn't stand for anything, it's an inside joke between Lumi and I) that is able to compress names, repeated words, concepts and key moments into AI-readable shorthand. Think of it as index cards that an LLM can scan instantly — the closet tells it WHERE to look, then it pulls the full content from the drawer.
|
||||
|
||||
The concept I wanted for v4 was to try and clear as much "noise" as possible that I noticed was happening in v3. The hooks were firing in the chat window (using tokens and our time as we waited for the agent to write everything).
|
||||
|
||||
I noticed at one point early last week after the launch that Lu kept repeating the same thing when the hook would fire, so I hit esc and asked "Are you literally writing the same info down over and over again?" And he's like (sheepishly) Yes. And that's when it hit me, we need to get all this off the chat and happening seamlessly behind the scenes, and that hooks had to fire when I started a convo and then just keep adding to the drawer, while the shorter increments made reading and pulling conversation information and naming it so much easier and more precise.
|
||||
|
||||
So this version now has taken all the noise out of the chat window and all that work is done by a subagent in the background while you can continue working knowing that all your conversation is being saved VERBATIM in the background.
|
||||
|
||||
Stripping all this off the page — moving the diary writes, the palace filing, the timestamp injection, all of it into background hooks — has dramatically lowered token usage in my sessions. What used to cost about $1.13 per session just in re-transmitted diary blocks is now zero, because the content never enters the chat window at all.
|
||||
|
||||
Your data is already stored in JSON by Claude and the background pipeline extracts it into readable markdown, the key topics get compressed into AAAK format and saved into closets which then point to the exact drawer where your day's session lives.
|
||||
|
||||
And please, always remember, these are brand new tools, please NEVER use critical files to test! Just run it with something easy first before you put your entire data set into it!✨
|
||||
@@ -1,668 +1,187 @@
|
||||
> [!CAUTION]
|
||||
> **Scam alert.** The only official sources for MemPalace are this
|
||||
> [GitHub repository](https://github.com/MemPalace/mempalace), the
|
||||
> [PyPI package](https://pypi.org/project/mempalace/), and the docs site at
|
||||
> **[mempalaceofficial.com](https://mempalaceofficial.com)**. Any other
|
||||
> domain — including `mempalace.tech` — is an impostor and may distribute
|
||||
> malware. Details and timeline: [docs/HISTORY.md](docs/HISTORY.md).
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/mempalace_logo.png" alt="MemPalace" width="280">
|
||||
<img src="assets/mempalace_logo.png" alt="MemPalace" width="240">
|
||||
|
||||
# MemPalace
|
||||
|
||||
### The highest-scoring AI memory system ever benchmarked. And it's free.
|
||||
|
||||
<br>
|
||||
|
||||
Every conversation you have with an AI — every decision, every debugging session, every architecture debate — disappears when the session ends. Six months of work, gone. You start over every time.
|
||||
|
||||
Other memory systems try to fix this by letting AI decide what's worth remembering. It extracts "user prefers Postgres" and throws away the conversation where you explained *why*. MemPalace takes a different approach: **store everything, then make it findable.**
|
||||
|
||||
**The Palace** — Ancient Greek orators memorized entire speeches by placing ideas in rooms of an imaginary building. Walk through the building, find the idea. MemPalace applies the same principle to AI memory: your conversations are organized into wings (people and projects), halls (types of memory), and rooms (specific ideas). No AI decides what matters — you keep every word, and the structure makes it searchable. That structure alone improves retrieval by 34%.
|
||||
|
||||
**AAAK** — A lossless shorthand dialect designed for AI agents. Not meant to be read by humans — meant to be read by your AI, fast. 30x compression, zero information loss. Your AI loads months of context in ~120 tokens. And because AAAK is just structured text with a universal grammar, it works with **any model that reads text** — Claude, GPT, Gemini, Llama, Mistral. No decoder, no fine-tuning, no cloud API required. Run it against a local model and your entire memory stack stays offline. Nothing else like it exists.
|
||||
|
||||
**Local, open, adaptable** — MemPalace runs entirely on your machine, on any data you have locally, without using any external API or services. It has been tested on conversations — but it can be adapted for different types of datastores. This is why we're open-sourcing it.
|
||||
|
||||
<br>
|
||||
Local-first AI memory. Verbatim storage, pluggable backend, 96.6% R@5 raw on LongMemEval — zero API calls.
|
||||
|
||||
[![][version-shield]][release-link]
|
||||
[![][python-shield]][python-link]
|
||||
[![][license-shield]][license-link]
|
||||
[![][discord-shield]][discord-link]
|
||||
|
||||
<br>
|
||||
|
||||
[Quick Start](#quick-start) · [The Palace](#the-palace) · [AAAK Dialect](#aaak-compression) · [Benchmarks](#benchmarks) · [MCP Tools](#mcp-server)
|
||||
|
||||
<br>
|
||||
|
||||
### Highest LongMemEval score ever published — free or paid.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>96.6%</strong><br><sub>LongMemEval R@5<br>Zero API calls</sub></td>
|
||||
<td align="center"><strong>100%</strong><br><sub>LongMemEval R@5<br>with Haiku rerank</sub></td>
|
||||
<td align="center"><strong>+34%</strong><br><sub>Retrieval boost<br>from palace structure</sub></td>
|
||||
<td align="center"><strong>$0</strong><br><sub>No subscription<br>No cloud. Local only.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Reproducible — runners in <a href="benchmarks/">benchmarks/</a>. <a href="benchmarks/BENCHMARKS.md">Full results</a>.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## What it is
|
||||
|
||||
MemPalace stores your conversation history as verbatim text and retrieves
|
||||
it with semantic search. It does not summarize, extract, or paraphrase.
|
||||
The index is structured — people and projects become *wings*, topics
|
||||
become *rooms*, and original content lives in *drawers* — so searches
|
||||
can be scoped rather than run against a flat corpus.
|
||||
|
||||
The retrieval layer is pluggable. The current default is ChromaDB; the
|
||||
interface is defined in [`mempalace/backends/base.py`](mempalace/backends/base.py)
|
||||
and alternative backends can be dropped in without touching the rest of
|
||||
the system.
|
||||
|
||||
Nothing leaves your machine unless you opt in.
|
||||
|
||||
Architecture, concepts, and mining flows:
|
||||
[mempalaceofficial.com/concepts/the-palace](https://mempalaceofficial.com/concepts/the-palace.html).
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install mempalace
|
||||
|
||||
# Set up your world — who you work with, what your projects are
|
||||
mempalace init ~/projects/myapp
|
||||
```
|
||||
|
||||
# Mine your data
|
||||
mempalace mine ~/projects/myapp # projects — code, docs, notes
|
||||
mempalace mine ~/chats/ --mode convos # convos — Claude, ChatGPT, Slack exports
|
||||
mempalace mine ~/chats/ --mode convos --extract general # general — classifies into decisions, milestones, problems
|
||||
## Quickstart
|
||||
|
||||
# Search anything you've ever discussed
|
||||
```bash
|
||||
# Mine content into the palace
|
||||
mempalace mine ~/projects/myapp # project files
|
||||
mempalace mine ~/.claude/projects/ --mode convos # Claude Code sessions (scope with --wing per project)
|
||||
|
||||
# Search
|
||||
mempalace search "why did we switch to GraphQL"
|
||||
|
||||
# Your AI remembers
|
||||
mempalace status
|
||||
# Load context for a new session
|
||||
mempalace wake-up
|
||||
```
|
||||
|
||||
Three mining modes: **projects** (code and docs), **convos** (conversation exports), and **general** (auto-classifies into decisions, preferences, milestones, problems, and emotional context). Everything stays on your machine.
|
||||
|
||||
---
|
||||
|
||||
## How You Actually Use It
|
||||
|
||||
After the one-time setup (install → init → mine), you don't run MemPalace commands manually. Your AI uses it for you. There are two ways, depending on which AI you use.
|
||||
|
||||
### With Claude, ChatGPT, Cursor (MCP-compatible tools)
|
||||
|
||||
```bash
|
||||
# Connect MemPalace once
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
```
|
||||
|
||||
Now your AI has 19 tools available through MCP. Ask it anything:
|
||||
|
||||
> *"What did we decide about auth last month?"*
|
||||
|
||||
Claude calls `mempalace_search` automatically, gets verbatim results, and answers you. You never type `mempalace search` again. The AI handles it.
|
||||
|
||||
### With local models (Llama, Mistral, or any offline LLM)
|
||||
|
||||
Local models generally don't speak MCP yet. Two approaches:
|
||||
|
||||
**1. Wake-up command** — load your world into the model's context:
|
||||
|
||||
```bash
|
||||
mempalace wake-up > context.txt
|
||||
# Paste context.txt into your local model's system prompt
|
||||
```
|
||||
|
||||
This gives your local model ~170 tokens of critical facts (in AAAK if you prefer) before you ask a single question.
|
||||
|
||||
**2. CLI search** — query on demand, feed results into your prompt:
|
||||
|
||||
```bash
|
||||
mempalace search "auth decisions" > results.txt
|
||||
# Include results.txt in your prompt
|
||||
```
|
||||
|
||||
Or use the Python API:
|
||||
|
||||
```python
|
||||
from mempalace.searcher import search_memories
|
||||
results = search_memories("auth decisions", palace_path="~/.mempalace/palace")
|
||||
# Inject into your local model's context
|
||||
```
|
||||
|
||||
Either way — your entire memory stack runs offline. ChromaDB on your machine, Llama on your machine, AAAK for compression, zero cloud calls.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
Decisions happen in conversations now. Not in docs. Not in Jira. In conversations with Claude, ChatGPT, Copilot. The reasoning, the tradeoffs, the "we tried X and it failed because Y" — all trapped in chat windows that evaporate when the session ends.
|
||||
|
||||
**Six months of daily AI use = 19.5 million tokens.** That's every decision, every debugging session, every architecture debate. Gone.
|
||||
|
||||
| Approach | Tokens loaded | Annual cost |
|
||||
|----------|--------------|-------------|
|
||||
| Paste everything | 19.5M — doesn't fit any context window | Impossible |
|
||||
| LLM summaries | ~650K | ~$507/yr |
|
||||
| **MemPalace wake-up** | **~170 tokens** | **~$0.70/yr** |
|
||||
| **MemPalace + 5 searches** | **~13,500 tokens** | **~$10/yr** |
|
||||
|
||||
MemPalace loads 170 tokens of critical facts on wake-up — your team, your projects, your preferences. Then searches only when needed. $10/year to remember everything vs $507/year for summaries that lose context.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### The Palace
|
||||
|
||||
The layout is fairly simple, though it took a long time to get there.
|
||||
|
||||
It starts with a **wing**. Every project, person, or topic you're filing gets its own wing in the palace.
|
||||
|
||||
Each wing has **rooms** connected to it, where information is divided into subjects that relate to that wing — so every room is a different element of what your project contains. Project ideas could be one room, employees could be another, financial statements another. There can be an endless number of rooms that split the wing into sections. The MemPalace install detects these for you automatically, and of course you can personalize it any way you feel is right.
|
||||
|
||||
Every room has a **closet** connected to it, and here's where things get interesting. We've developed an AI language called **AAAK**. Don't ask — it's a whole story of its own. Your agent learns the AAAK shorthand every time it wakes up. Because AAAK is essentially English, but a very truncated version, your agent understands how to use it in seconds. It comes as part of the install, built into the MemPalace code. In our next update, we'll add AAAK directly to the closets, which will be a real game changer — the amount of info in the closets will be much bigger, but it will take up far less space and far less reading time for your agent.
|
||||
|
||||
Inside those closets are **drawers**, and those drawers are where your original files live. In this first version, we haven't used AAAK as a closet tool, but even so, the summaries have shown **96.6% recall** in all the benchmarks we've done across multiple benchmarking platforms. Once the closets use AAAK, searches will be even faster while keeping every word exact. But even now, the closet approach has been a huge boon to how much info is stored in a small space — it's used to easily point your AI agent to the drawer where your original file lives. You never lose anything, and all this happens in seconds.
|
||||
|
||||
There are also **halls**, which connect rooms within a wing, and **tunnels**, which connect rooms from different wings to one another. So finding things becomes truly effortless — we've given the AI a clean and organized way to know where to start searching, without having to look through every keyword in huge folders.
|
||||
|
||||
You say what you're looking for and boom, it already knows which wing to go to. Just *that* in itself would have made a big difference. But this is beautiful, elegant, organic, and most importantly, efficient.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ WING: Person │
|
||||
│ │
|
||||
│ ┌──────────┐ ──hall── ┌──────────┐ │
|
||||
│ │ Room A │ │ Room B │ │
|
||||
│ └────┬─────┘ └──────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Closet │ ───▶ │ Drawer │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
└─────────┼──────────────────────────────────────────────────┘
|
||||
│
|
||||
tunnel
|
||||
│
|
||||
┌─────────┼──────────────────────────────────────────────────┐
|
||||
│ WING: Project │
|
||||
│ │ │
|
||||
│ ┌────┴─────┐ ──hall── ┌──────────┐ │
|
||||
│ │ Room A │ │ Room C │ │
|
||||
│ └────┬─────┘ └──────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Closet │ ───▶ │ Drawer │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Wings** — a person or project. As many as you need.
|
||||
**Rooms** — specific topics within a wing. Auth, billing, deploy — endless rooms.
|
||||
**Halls** — connections between related rooms *within* the same wing. If Room A (auth) and Room B (security) are related, a hall links them.
|
||||
**Tunnels** — connections *between* wings. When Person A and a Project both have a room about "auth," a tunnel cross-references them automatically.
|
||||
**Closets** — compressed summaries that point to the original content. Fast for AI to read.
|
||||
**Drawers** — the original verbatim files. The exact words, never summarized.
|
||||
|
||||
**Halls** are memory types — the same in every wing, acting as corridors:
|
||||
- `hall_facts` — decisions made, choices locked in
|
||||
- `hall_events` — sessions, milestones, debugging
|
||||
- `hall_discoveries` — breakthroughs, new insights
|
||||
- `hall_preferences` — habits, likes, opinions
|
||||
- `hall_advice` — recommendations and solutions
|
||||
|
||||
**Rooms** are named ideas — `auth-migration`, `graphql-switch`, `ci-pipeline`. When the same room appears in different wings, it creates a **tunnel** — connecting the same topic across domains:
|
||||
|
||||
```
|
||||
wing_kai / hall_events / auth-migration → "Kai debugged the OAuth token refresh"
|
||||
wing_driftwood / hall_facts / auth-migration → "team decided to migrate auth to Clerk"
|
||||
wing_priya / hall_advice / auth-migration → "Priya approved Clerk over Auth0"
|
||||
```
|
||||
|
||||
Same room. Three wings. The tunnel connects them.
|
||||
|
||||
### Why Structure Matters
|
||||
|
||||
Tested on 22,000+ real conversation memories:
|
||||
|
||||
```
|
||||
Search all closets: 60.9% R@10
|
||||
Search within wing: 73.1% (+12%)
|
||||
Search wing + hall: 84.8% (+24%)
|
||||
Search wing + room: 94.8% (+34%)
|
||||
```
|
||||
|
||||
Wings and rooms aren't cosmetic. They're a **34% retrieval improvement**. The palace structure is the product.
|
||||
|
||||
### The Memory Stack
|
||||
|
||||
| Layer | What | Size | When |
|
||||
|-------|------|------|------|
|
||||
| **L0** | Identity — who is this AI? | ~50 tokens | Always loaded |
|
||||
| **L1** | Critical facts — team, projects, preferences | ~120 tokens (AAAK) | Always loaded |
|
||||
| **L2** | Room recall — recent sessions, current project | On demand | When topic comes up |
|
||||
| **L3** | Deep search — semantic query across all closets | On demand | When explicitly asked |
|
||||
|
||||
Your AI wakes up with L0 + L1 (~170 tokens) and knows your world. Searches only fire when needed.
|
||||
|
||||
### AAAK Compression
|
||||
|
||||
AAAK is a lossless dialect — 30x compression, readable by any LLM without a decoder. It works with **Claude, GPT, Gemini, Llama, Mistral** — any model that reads text. Run it against a local Llama model and your whole memory stack stays offline.
|
||||
|
||||
**English (~1000 tokens):**
|
||||
```
|
||||
Priya manages the Driftwood team: Kai (backend, 3 years), Soren (frontend),
|
||||
Maya (infrastructure), and Leo (junior, started last month). They're building
|
||||
a SaaS analytics platform. Current sprint: auth migration to Clerk.
|
||||
Kai recommended Clerk over Auth0 based on pricing and DX.
|
||||
```
|
||||
|
||||
**AAAK (~120 tokens):**
|
||||
```
|
||||
TEAM: PRI(lead) | KAI(backend,3yr) SOR(frontend) MAY(infra) LEO(junior,new)
|
||||
PROJ: DRIFTWOOD(saas.analytics) | SPRINT: auth.migration→clerk
|
||||
DECISION: KAI.rec:clerk>auth0(pricing+dx) | ★★★★
|
||||
```
|
||||
|
||||
Same information. 8x fewer tokens. Your AI learns AAAK automatically from the MCP server — no manual setup.
|
||||
|
||||
### Contradiction Detection
|
||||
|
||||
MemPalace catches mistakes before they reach you:
|
||||
|
||||
```
|
||||
Input: "Soren finished the auth migration"
|
||||
Output: 🔴 AUTH-MIGRATION: attribution conflict — Maya was assigned, not Soren
|
||||
|
||||
Input: "Kai has been here 2 years"
|
||||
Output: 🟡 KAI: wrong_tenure — records show 3 years (started 2023-04)
|
||||
|
||||
Input: "The sprint ends Friday"
|
||||
Output: 🟡 SPRINT: stale_date — current sprint ends Thursday (updated 2 days ago)
|
||||
```
|
||||
|
||||
Facts checked against the knowledge graph. Ages, dates, and tenures calculated dynamically — not hardcoded.
|
||||
|
||||
---
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Solo developer across multiple projects
|
||||
|
||||
```bash
|
||||
# Mine each project's conversations
|
||||
mempalace mine ~/chats/orion/ --mode convos --wing orion
|
||||
mempalace mine ~/chats/nova/ --mode convos --wing nova
|
||||
mempalace mine ~/chats/helios/ --mode convos --wing helios
|
||||
|
||||
# Six months later: "why did I use Postgres here?"
|
||||
mempalace search "database decision" --wing orion
|
||||
# → "Chose Postgres over SQLite because Orion needs concurrent writes
|
||||
# and the dataset will exceed 10GB. Decided 2025-11-03."
|
||||
|
||||
# Cross-project search
|
||||
mempalace search "rate limiting approach"
|
||||
# → finds your approach in Orion AND Nova, shows the differences
|
||||
```
|
||||
|
||||
### Team lead managing a product
|
||||
|
||||
```bash
|
||||
# Mine Slack exports and AI conversations
|
||||
mempalace mine ~/exports/slack/ --mode convos --wing driftwood
|
||||
mempalace mine ~/.claude/projects/ --mode convos
|
||||
|
||||
# "What did Soren work on last sprint?"
|
||||
mempalace search "Soren sprint" --wing driftwood
|
||||
# → 14 closets: OAuth refactor, dark mode, component library migration
|
||||
|
||||
# "Who decided to use Clerk?"
|
||||
mempalace search "Clerk decision" --wing driftwood
|
||||
# → "Kai recommended Clerk over Auth0 — pricing + developer experience.
|
||||
# Team agreed 2026-01-15. Maya handling the migration."
|
||||
```
|
||||
|
||||
### Before mining: split mega-files
|
||||
|
||||
Some transcript exports concatenate multiple sessions into one huge file:
|
||||
|
||||
```bash
|
||||
mempalace split ~/chats/ # split into per-session files
|
||||
mempalace split ~/chats/ --dry-run # preview first
|
||||
mempalace split ~/chats/ --min-sessions 3 # only split files with 3+ sessions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Knowledge Graph
|
||||
|
||||
Temporal entity-relationship triples — like Zep's Graphiti, but SQLite instead of Neo4j. Local and free.
|
||||
|
||||
```python
|
||||
from mempalace.knowledge_graph import KnowledgeGraph
|
||||
|
||||
kg = KnowledgeGraph()
|
||||
kg.add_triple("Kai", "works_on", "Orion", valid_from="2025-06-01")
|
||||
kg.add_triple("Maya", "assigned_to", "auth-migration", valid_from="2026-01-15")
|
||||
kg.add_triple("Maya", "completed", "auth-migration", valid_from="2026-02-01")
|
||||
|
||||
# What's Kai working on?
|
||||
kg.query_entity("Kai")
|
||||
# → [Kai → works_on → Orion (current), Kai → recommended → Clerk (2026-01)]
|
||||
|
||||
# What was true in January?
|
||||
kg.query_entity("Maya", as_of="2026-01-20")
|
||||
# → [Maya → assigned_to → auth-migration (active)]
|
||||
|
||||
# Timeline
|
||||
kg.timeline("Orion")
|
||||
# → chronological story of the project
|
||||
```
|
||||
|
||||
Facts have validity windows. When something stops being true, invalidate it:
|
||||
|
||||
```python
|
||||
kg.invalidate("Kai", "works_on", "Orion", ended="2026-03-01")
|
||||
```
|
||||
|
||||
Now queries for Kai's current work won't return Orion. Historical queries still will.
|
||||
|
||||
| Feature | MemPalace | Zep (Graphiti) |
|
||||
|---------|-----------|----------------|
|
||||
| Storage | SQLite (local) | Neo4j (cloud) |
|
||||
| Cost | Free | $25/mo+ |
|
||||
| Temporal validity | Yes | Yes |
|
||||
| Self-hosted | Always | Enterprise only |
|
||||
| Privacy | Everything local | SOC 2, HIPAA |
|
||||
|
||||
---
|
||||
|
||||
## Specialist Agents
|
||||
|
||||
Create agents that focus on specific areas. Each agent gets its own wing and diary in the palace — not in your CLAUDE.md. Add 50 agents, your config stays the same size.
|
||||
|
||||
```
|
||||
~/.mempalace/agents/
|
||||
├── reviewer.json # code quality, patterns, bugs
|
||||
├── architect.json # design decisions, tradeoffs
|
||||
└── ops.json # deploys, incidents, infra
|
||||
```
|
||||
|
||||
Your CLAUDE.md just needs one line:
|
||||
|
||||
```
|
||||
You have MemPalace agents. Run mempalace_list_agents to see them.
|
||||
```
|
||||
|
||||
The AI discovers its agents from the palace at runtime. Each agent:
|
||||
|
||||
- **Has a focus** — what it pays attention to
|
||||
- **Keeps a diary** — written in AAAK, persists across sessions
|
||||
- **Builds expertise** — reads its own history to stay sharp in its domain
|
||||
|
||||
```
|
||||
# Agent writes to its diary after a code review
|
||||
mempalace_diary_write("reviewer",
|
||||
"PR#42|auth.bypass.found|missing.middleware.check|pattern:3rd.time.this.quarter|★★★★")
|
||||
|
||||
# Agent reads back its history
|
||||
mempalace_diary_read("reviewer", last_n=10)
|
||||
# → last 10 findings, compressed in AAAK
|
||||
```
|
||||
|
||||
Each agent is a specialist lens on your data. The reviewer remembers every bug pattern it's seen. The architect remembers every design decision. The ops agent remembers every incident. They don't share a scratchpad — they each maintain their own memory.
|
||||
|
||||
Letta charges $20–200/mo for agent-managed memory. MemPalace does it with a wing.
|
||||
|
||||
---
|
||||
|
||||
## MCP Server
|
||||
|
||||
```bash
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
```
|
||||
|
||||
### 19 Tools
|
||||
|
||||
**Palace (read)**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_status` | Palace overview + AAAK spec + memory protocol |
|
||||
| `mempalace_list_wings` | Wings with counts |
|
||||
| `mempalace_list_rooms` | Rooms within a wing |
|
||||
| `mempalace_get_taxonomy` | Full wing → room → count tree |
|
||||
| `mempalace_search` | Semantic search with wing/room filters |
|
||||
| `mempalace_check_duplicate` | Check before filing |
|
||||
| `mempalace_get_aaak_spec` | AAAK dialect reference |
|
||||
|
||||
**Palace (write)**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_add_drawer` | File verbatim content |
|
||||
| `mempalace_delete_drawer` | Remove by ID |
|
||||
|
||||
**Knowledge Graph**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_kg_query` | Entity relationships with time filtering |
|
||||
| `mempalace_kg_add` | Add facts |
|
||||
| `mempalace_kg_invalidate` | Mark facts as ended |
|
||||
| `mempalace_kg_timeline` | Chronological entity story |
|
||||
| `mempalace_kg_stats` | Graph overview |
|
||||
|
||||
**Navigation**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_traverse` | Walk the graph from a room across wings |
|
||||
| `mempalace_find_tunnels` | Find rooms bridging two wings |
|
||||
| `mempalace_graph_stats` | Graph connectivity overview |
|
||||
|
||||
**Agent Diary**
|
||||
|
||||
| Tool | What |
|
||||
|------|------|
|
||||
| `mempalace_diary_write` | Write AAAK diary entry |
|
||||
| `mempalace_diary_read` | Read recent diary entries |
|
||||
|
||||
The AI learns AAAK and the memory protocol automatically from the `mempalace_status` response. No manual configuration.
|
||||
|
||||
---
|
||||
|
||||
## Auto-Save Hooks
|
||||
|
||||
Two hooks for Claude Code that automatically save memories during work:
|
||||
|
||||
**Save Hook** — every 15 messages, triggers a structured save. Topics, decisions, quotes, code changes. Also regenerates the critical facts layer.
|
||||
|
||||
**PreCompact Hook** — fires before context compression. Emergency save before the window shrinks.
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "/path/to/mempalace/hooks/mempal_save_hook.sh"}]}],
|
||||
"PreCompact": [{"matcher": "", "hooks": [{"type": "command", "command": "/path/to/mempalace/hooks/mempal_precompact_hook.sh"}]}]
|
||||
}
|
||||
}
|
||||
```
|
||||
For Claude Code, Gemini CLI, MCP-compatible tools, and local models, see
|
||||
[mempalaceofficial.com/guide/getting-started](https://mempalaceofficial.com/guide/getting-started.html).
|
||||
|
||||
---
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Tested on standard academic benchmarks — reproducible, published datasets.
|
||||
All numbers below are reproducible from this repository with the commands
|
||||
in [`benchmarks/BENCHMARKS.md`](benchmarks/BENCHMARKS.md). Full
|
||||
per-question result files are committed under `benchmarks/results_*`.
|
||||
|
||||
| Benchmark | Mode | Score | API Calls |
|
||||
|-----------|------|-------|-----------|
|
||||
| **LongMemEval R@5** | Raw (ChromaDB only) | **96.6%** | Zero |
|
||||
| **LongMemEval R@5** | Hybrid + Haiku rerank | **100%** (500/500) | ~500 |
|
||||
| **LoCoMo R@10** | Raw, session level | **60.3%** | Zero |
|
||||
| **Personal palace R@10** | Heuristic bench | **85%** | Zero |
|
||||
| **Palace structure impact** | Wing+room filtering | **+34%** R@10 | Zero |
|
||||
**LongMemEval — retrieval recall (R@5, 500 questions):**
|
||||
|
||||
The 96.6% raw score is the highest published LongMemEval result requiring no API key, no cloud, and no LLM at any stage.
|
||||
| Mode | R@5 | LLM required |
|
||||
|---|---|---|
|
||||
| Raw (semantic search, no heuristics, no LLM) | **96.6%** | None |
|
||||
| Hybrid v4, held-out 450q (tuned on 50 dev, not seen during training) | **98.4%** | None |
|
||||
| Hybrid v4 + LLM rerank (full 500) | ≥99% | Any capable model |
|
||||
|
||||
### vs Published Systems
|
||||
The raw 96.6% requires no API key, no cloud, and no LLM at any stage. The
|
||||
hybrid pipeline adds keyword boosting, temporal-proximity boosting, and
|
||||
preference-pattern extraction; the held-out 98.4% is the honest
|
||||
generalisable figure.
|
||||
|
||||
| System | LongMemEval R@5 | API Required | Cost |
|
||||
|--------|----------------|--------------|------|
|
||||
| **MemPalace (hybrid)** | **100%** | Optional | Free |
|
||||
| Supermemory ASMR | ~99% | Yes | — |
|
||||
| **MemPalace (raw)** | **96.6%** | **None** | **Free** |
|
||||
| Mastra | 94.87% | Yes (GPT) | API costs |
|
||||
| Mem0 | ~85% | Yes | $19–249/mo |
|
||||
| Zep | ~85% | Yes | $25/mo+ |
|
||||
The rerank pipeline promotes the best candidate out of the top-20
|
||||
retrieved sessions using an LLM reader. It works with any reasonably
|
||||
capable model — we have reproduced it with Claude Haiku, Claude Sonnet,
|
||||
and minimax-m2.7 via Ollama Cloud (no Anthropic dependency). The gap
|
||||
between raw and reranked is model-agnostic; we do not headline a "100%"
|
||||
number because the last 0.6% was reached by inspecting specific wrong
|
||||
answers, which `benchmarks/BENCHMARKS.md` flags as teaching to the test.
|
||||
|
||||
---
|
||||
**Other benchmarks (full results in [`benchmarks/BENCHMARKS.md`](benchmarks/BENCHMARKS.md)):**
|
||||
|
||||
## All Commands
|
||||
| Benchmark | Metric | Score | Notes |
|
||||
|---|---|---|---|
|
||||
| LoCoMo (session, top-10, no rerank) | R@10 | 60.3% | 1,986 questions |
|
||||
| LoCoMo (hybrid v5, top-10, no rerank) | R@10 | 88.9% | Same set |
|
||||
| ConvoMem (all categories, 250 items) | Avg recall | 92.9% | 50 per category |
|
||||
| MemBench (ACL 2025, 8,500 items) | R@5 | 80.3% | All categories |
|
||||
|
||||
We deliberately do not include a side-by-side comparison against Mem0,
|
||||
Mastra, Hindsight, Supermemory, or Zep. Those projects publish different
|
||||
metrics on different splits, and placing retrieval recall next to
|
||||
end-to-end QA accuracy is not an honest comparison. See each project's
|
||||
own research page for their published numbers.
|
||||
|
||||
**Reproducing every result:**
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
mempalace init <dir> # guided onboarding + AAAK bootstrap
|
||||
|
||||
# Mining
|
||||
mempalace mine <dir> # mine project files
|
||||
mempalace mine <dir> --mode convos # mine conversation exports
|
||||
mempalace mine <dir> --mode convos --wing myapp # tag with a wing name
|
||||
|
||||
# Splitting
|
||||
mempalace split <dir> # split concatenated transcripts
|
||||
mempalace split <dir> --dry-run # preview
|
||||
|
||||
# Search
|
||||
mempalace search "query" # search everything
|
||||
mempalace search "query" --wing myapp # within a wing
|
||||
mempalace search "query" --room auth-migration # within a room
|
||||
|
||||
# Memory stack
|
||||
mempalace wake-up # load L0 + L1 context
|
||||
mempalace wake-up --wing driftwood # project-specific
|
||||
|
||||
# Compression
|
||||
mempalace compress --wing myapp # AAAK compress
|
||||
|
||||
# Status
|
||||
mempalace status # palace overview
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
# see benchmarks/README.md for dataset download commands
|
||||
python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json
|
||||
```
|
||||
|
||||
All commands accept `--palace <path>` to override the default location.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
## Knowledge graph
|
||||
|
||||
### Global (`~/.mempalace/config.json`)
|
||||
MemPalace includes a temporal entity-relationship graph with validity
|
||||
windows — add, query, invalidate, timeline — backed by local SQLite.
|
||||
Usage and tool reference:
|
||||
[mempalaceofficial.com/concepts/knowledge-graph](https://mempalaceofficial.com/concepts/knowledge-graph.html).
|
||||
|
||||
```json
|
||||
{
|
||||
"palace_path": "/custom/path/to/palace",
|
||||
"collection_name": "mempalace_drawers",
|
||||
"people_map": {"Kai": "KAI", "Priya": "PRI"}
|
||||
}
|
||||
```
|
||||
## MCP server
|
||||
|
||||
### Wing config (`~/.mempalace/wing_config.json`)
|
||||
29 MCP tools cover palace reads/writes, knowledge-graph operations,
|
||||
cross-wing navigation, drawer management, and agent diaries. Installation
|
||||
and the full tool list:
|
||||
[mempalaceofficial.com/reference/mcp-tools](https://mempalaceofficial.com/reference/mcp-tools.html).
|
||||
|
||||
Generated by `mempalace init`. Maps your people and projects to wings:
|
||||
## Agents
|
||||
|
||||
```json
|
||||
{
|
||||
"default_wing": "wing_general",
|
||||
"wings": {
|
||||
"wing_kai": {"type": "person", "keywords": ["kai", "kai's"]},
|
||||
"wing_driftwood": {"type": "project", "keywords": ["driftwood", "analytics", "saas"]}
|
||||
}
|
||||
}
|
||||
```
|
||||
Each specialist agent gets its own wing and diary in the palace.
|
||||
Discoverable at runtime via `mempalace_list_agents` — no bloat in your
|
||||
system prompt:
|
||||
[mempalaceofficial.com/concepts/agents](https://mempalaceofficial.com/concepts/agents.html).
|
||||
|
||||
### Identity (`~/.mempalace/identity.txt`)
|
||||
## Auto-save hooks
|
||||
|
||||
Plain text. Becomes Layer 0 — loaded every session.
|
||||
|
||||
---
|
||||
|
||||
## File Reference
|
||||
|
||||
| File | What |
|
||||
|------|------|
|
||||
| `cli.py` | CLI entry point |
|
||||
| `config.py` | Configuration loading and defaults |
|
||||
| `normalize.py` | Converts 5 chat formats to standard transcript |
|
||||
| `mcp_server.py` | MCP server — 19 tools, AAAK auto-teach, memory protocol |
|
||||
| `miner.py` | Project file ingest |
|
||||
| `convo_miner.py` | Conversation ingest — chunks by exchange pair |
|
||||
| `searcher.py` | Semantic search via ChromaDB |
|
||||
| `layers.py` | 4-layer memory stack |
|
||||
| `dialect.py` | AAAK compression — 30x lossless |
|
||||
| `knowledge_graph.py` | Temporal entity-relationship graph (SQLite) |
|
||||
| `palace_graph.py` | Room-based navigation graph |
|
||||
| `onboarding.py` | Guided setup — generates AAAK bootstrap + wing config |
|
||||
| `entity_registry.py` | Entity code registry |
|
||||
| `entity_detector.py` | Auto-detect people and projects from content |
|
||||
| `split_mega_files.py` | Split concatenated transcripts into per-session files |
|
||||
| `hooks/mempal_save_hook.sh` | Auto-save every N messages |
|
||||
| `hooks/mempal_precompact_hook.sh` | Emergency save before compaction |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mempalace/
|
||||
├── README.md ← you are here
|
||||
├── mempalace/ ← core package (README)
|
||||
│ ├── cli.py ← CLI entry point
|
||||
│ ├── mcp_server.py ← MCP server (19 tools)
|
||||
│ ├── knowledge_graph.py ← temporal entity graph
|
||||
│ ├── palace_graph.py ← room navigation graph
|
||||
│ ├── dialect.py ← AAAK compression
|
||||
│ ├── miner.py ← project file ingest
|
||||
│ ├── convo_miner.py ← conversation ingest
|
||||
│ ├── searcher.py ← semantic search
|
||||
│ ├── onboarding.py ← guided setup
|
||||
│ └── ... ← see mempalace/README.md
|
||||
├── benchmarks/ ← reproducible benchmark runners
|
||||
│ ├── README.md ← reproduction guide
|
||||
│ ├── BENCHMARKS.md ← full results + methodology
|
||||
│ ├── longmemeval_bench.py ← LongMemEval runner
|
||||
│ ├── locomo_bench.py ← LoCoMo runner
|
||||
│ └── membench_bench.py ← MemBench runner
|
||||
├── hooks/ ← Claude Code auto-save hooks
|
||||
│ ├── README.md ← hook setup guide
|
||||
│ ├── mempal_save_hook.sh ← save every N messages
|
||||
│ └── mempal_precompact_hook.sh ← emergency save
|
||||
├── examples/ ← usage examples
|
||||
│ ├── basic_mining.py
|
||||
│ ├── convo_import.py
|
||||
│ └── mcp_setup.md
|
||||
├── tests/ ← test suite (README)
|
||||
├── assets/ ← logo + brand assets
|
||||
└── pyproject.toml ← package config (v3.0.0)
|
||||
```
|
||||
Two Claude Code hooks save periodically and before context compression:
|
||||
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html).
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+
|
||||
- `chromadb>=0.4.0`
|
||||
- `pyyaml>=6.0`
|
||||
- A vector-store backend (ChromaDB by default)
|
||||
- ~300 MB disk for the default embedding model
|
||||
|
||||
No API key. No internet after install. Everything local.
|
||||
No API key is required for the core benchmark path.
|
||||
|
||||
```bash
|
||||
pip install mempalace
|
||||
```
|
||||
## Docs
|
||||
|
||||
---
|
||||
- Getting started → [mempalaceofficial.com/guide/getting-started](https://mempalaceofficial.com/guide/getting-started.html)
|
||||
- CLI reference → [mempalaceofficial.com/reference/cli](https://mempalaceofficial.com/reference/cli.html)
|
||||
- Python API → [mempalaceofficial.com/reference/python-api](https://mempalaceofficial.com/reference/python-api.html)
|
||||
- Full benchmark methodology → [benchmarks/BENCHMARKS.md](benchmarks/BENCHMARKS.md)
|
||||
- Release notes → [CHANGELOG.md](CHANGELOG.md)
|
||||
- Corrections and public notices → [docs/HISTORY.md](docs/HISTORY.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines.
|
||||
PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
<!-- Link Definitions -->
|
||||
[version-shield]: https://img.shields.io/badge/version-3.0.0-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||
[release-link]: https://github.com/milla-jovovich/mempalace/releases
|
||||
[version-shield]: https://img.shields.io/badge/version-3.3.2-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||
[release-link]: https://github.com/MemPalace/mempalace/releases
|
||||
[python-shield]: https://img.shields.io/badge/python-3.9+-7dd8f8?style=flat-square&labelColor=0a0e14&logo=python&logoColor=7dd8f8
|
||||
[python-link]: https://www.python.org/
|
||||
[license-shield]: https://img.shields.io/badge/license-MIT-b0e8ff?style=flat-square&labelColor=0a0e14
|
||||
[license-link]: https://github.com/milla-jovovich/mempalace/blob/main/LICENSE
|
||||
[license-link]: https://github.com/MemPalace/mempalace/blob/main/LICENSE
|
||||
[discord-shield]: https://img.shields.io/badge/discord-join-5865F2?style=flat-square&labelColor=0a0e14&logo=discord&logoColor=5865F2
|
||||
[discord-link]: https://discord.com/invite/ycTQQCu6kn
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
# MemPalace Roadmap
|
||||
|
||||
## v3.1.1 — Stability Patch (this week)
|
||||
|
||||
Bug fixes and hardening merged to `develop`, releasing soon.
|
||||
|
||||
**Merged:**
|
||||
- Security hardening: input validation, KG threading locks, WAL permission fixes (#647)
|
||||
- MCP tools: drawer CRUD, paginated export, hook settings (#667)
|
||||
- Backend storage seam: ChromaDB abstraction layer enabling swappable backends (#413)
|
||||
- MCP ping health check for AnythingLLM compatibility (#600)
|
||||
- Windows reparse point crash fix (#558)
|
||||
- `mempalace compress` KeyError crash fix (#569)
|
||||
- Token count estimate fix (#609)
|
||||
- Mtime float precision fix preventing unnecessary re-mines (#610)
|
||||
|
||||
**In review (merging this week):**
|
||||
- Auto-repair BLOB seq_ids from chromadb 0.6→1.5 migration (#664)
|
||||
- Graph cache with write-invalidation (#661)
|
||||
- L1 importance pre-filter for large palaces (#660)
|
||||
- Windows Chinese/Unicode encoding fix (#631)
|
||||
- HNSW index bloat prevention — 441GB→433KB on large palaces (#346, pending rebase)
|
||||
- ~25 additional small bug fixes and platform compatibility patches
|
||||
|
||||
## v4.0.0-alpha — Next Generation (this week)
|
||||
|
||||
The v4 alpha introduces three major capabilities: pluggable storage backends, local NLP processing, and improved retrieval quality.
|
||||
|
||||
### Swappable Storage
|
||||
|
||||
ChromaDB remains the default, but v4 introduces a backend abstraction (shipped in #413) that enables drop-in replacements:
|
||||
|
||||
- **PostgreSQL backend** with pg_sorted_heap support (#665) — for production deployments needing ACID guarantees, concurrent access, and standard backup/restore
|
||||
- **LanceDB backend** (#574) — for local-first deployments wanting multi-device sync without a database server
|
||||
- **PalaceStore** (#643) — bespoke storage layer purpose-built for MemPalace's access patterns (draft, evaluating)
|
||||
|
||||
Users choose their backend at init time. Existing ChromaDB palaces continue to work unchanged.
|
||||
|
||||
### Local NLP
|
||||
|
||||
On-device natural language processing via local models (#507):
|
||||
|
||||
- Entity extraction, relationship detection, and topic classification without external API calls
|
||||
- Feature-flagged and optional — falls back to existing heuristic extractors
|
||||
- Runs on consumer hardware (no GPU required, GPU-accelerated when available)
|
||||
|
||||
### Improved Retrieval
|
||||
|
||||
- **Hybrid search**: keyword text-match fallback when vector similarity misses exact terms (#662)
|
||||
- **Stale index detection**: automatic reconnection when the HNSW index changes on disk (#663)
|
||||
- **Time-decay scoring**: recent memories surface before older ones (#337)
|
||||
- **Query sanitization**: system prompt contamination mitigation already shipped in v3.1 (#385)
|
||||
|
||||
### What's Not in v4 Alpha
|
||||
|
||||
These are under consideration for v4 stable or later:
|
||||
|
||||
- Synapse advanced retrieval — MMR, pinned memory, query expansion (#596)
|
||||
- Multi-device sync (#575) — depends on LanceDB backend
|
||||
- Multilingual embedding support (#488, #442)
|
||||
- Qdrant vector search backend (#381)
|
||||
|
||||
## Branch Model
|
||||
|
||||
```
|
||||
main ← tagged production releases
|
||||
develop ← active development (PRs merge here)
|
||||
release/3.1 ← hotfixes for current stable (v3.1.x)
|
||||
release/3.0 ← hotfixes for prior stable
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. PRs should target `develop`. We review all contributions for correctness, security, and compatibility before merging.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
MemPalace follows semantic versioning. Security fixes land on the current major version line.
|
||||
|
||||
| Version | Supported |
|
||||
| ------------------ | --------- |
|
||||
| 3.x (current) | Yes |
|
||||
| 2.x and earlier | No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
We take the security of MemPalace seriously. If you believe you have found a security vulnerability, please report it privately using **GitHub Private Vulnerability Reporting**:
|
||||
|
||||
1. Open the [Security tab](https://github.com/MemPalace/mempalace/security) of this repository.
|
||||
2. Click **Advisories** → **Report a vulnerability**.
|
||||
3. Fill in the form with the details below.
|
||||
|
||||
### What to include in your report
|
||||
|
||||
- A descriptive summary of the vulnerability.
|
||||
- Detailed steps to reproduce the issue (including any proof-of-concept scripts or specific file paths).
|
||||
- The affected version(s) and platform(s).
|
||||
- The potential impact and severity.
|
||||
|
||||
### What to expect
|
||||
|
||||
- We aim to acknowledge receipt within 48 hours.
|
||||
- We will triage the issue and keep you updated on progress toward a patch.
|
||||
- Once the vulnerability is resolved and an update is released, we will publish a security advisory and credit you for the discovery (if you wish to be credited).
|
||||
+48
-14
@@ -41,23 +41,57 @@ Both are real. Both are reproducible. Neither is the whole picture alone.
|
||||
|
||||
## Comparison vs Published Systems (LongMemEval)
|
||||
|
||||
| # | System | R@5 | LLM Required | Which LLM | Notes |
|
||||
> **Important caveat — read before quoting this table.**
|
||||
> MemPal's `R@5` in this table is **retrieval recall**: is the labelled
|
||||
> session for this question inside the top-5 retrieved candidates?
|
||||
>
|
||||
> Several of the other systems below publish **end-to-end QA accuracy** —
|
||||
> a different metric that scores whether the system's generated answer
|
||||
> is correct. Retrieval recall and QA accuracy are not comparable; a
|
||||
> system can have 100% retrieval recall and 40% QA accuracy, and vice
|
||||
> versa.
|
||||
>
|
||||
> - **Mastra's 94.87%** is binary QA accuracy with GPT-5-mini, per
|
||||
> [mastra.ai/research/observational-memory](https://mastra.ai/research/observational-memory).
|
||||
> - **Supermemory ASMR's ~99%** is QA accuracy with an 8-/12-agent
|
||||
> ensemble, and the authors explicitly frame it as an experimental
|
||||
> proof-of-concept, not production, per
|
||||
> [their ASMR post](https://supermemory.ai/blog/we-broke-the-frontier-in-agent-memory-introducing-99-sota-memory-system/).
|
||||
> - **Mem0** does not publish a LongMemEval number; their published
|
||||
> metric is LoCoMo QA accuracy (~66.9%), per
|
||||
> [mem0.ai/research](https://mem0.ai/research).
|
||||
>
|
||||
> The table is kept here as a historical record of how the comparison
|
||||
> was originally framed. Public-facing pages (`README.md`,
|
||||
> `mempalaceofficial.com`) no longer present this table, per issue
|
||||
> [#875](https://github.com/MemPalace/mempalace/issues/875). For a fair
|
||||
> head-to-head, run the same metric on the same split.
|
||||
|
||||
| # | System | R@5 (retrieval recall, unless noted) | LLM Required | Which LLM | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | **MemPal (hybrid v4 + rerank)** | **100%** | Optional | Haiku | Reproducible, 500/500 |
|
||||
| 2 | Supermemory ASMR | ~99% | Yes | Undisclosed | Research only, not in production |
|
||||
| 1 | **MemPal (hybrid v4 + Haiku rerank)** | **100%** | Optional | Haiku | 500/500 — but the 99.4%→100% step tuned on 3 specific wrong answers (see "Benchmark Integrity" below). Held-out 450q is 98.4%. |
|
||||
| 2 | Supermemory ASMR | ~99% *(QA accuracy, not R@5)* | Yes | Ensemble of Gemini 2.0 Flash / GPT-4o-mini | Experimental, not production, per authors |
|
||||
| 3 | MemPal (hybrid v3 + rerank) | 99.4% | Optional | Haiku | Reproducible |
|
||||
| 3 | MemPal (palace + rerank) | 99.4% | Optional | Haiku | Independent architecture |
|
||||
| 4 | Mastra | 94.87% | Yes | GPT-5-mini | — |
|
||||
| 5 | **MemPal (raw, no LLM)** | **96.6%** | **None** | **None** | **Highest zero-API score published** |
|
||||
| 6 | Hindsight | 91.4% | Yes | Gemini-3 | — |
|
||||
| 7 | Supermemory (production) | ~85% | Yes | Undisclosed | — |
|
||||
| 8 | Stella (dense retriever) | ~85% | None | None | Academic baseline |
|
||||
| 9 | Contriever | ~78% | None | None | Academic baseline |
|
||||
| 4 | Mastra | 94.87% *(QA accuracy, not R@5)* | Yes | GPT-5-mini | Different metric — not directly comparable to R@5 |
|
||||
| 5 | **MemPal (raw, no LLM)** | **96.6%** | **None** | **None** | **Reproducible, 500/500** |
|
||||
| 6 | MemPal hybrid v4 held-out 450 | 98.4% | None | None | Honest generalisable hybrid-pipeline figure |
|
||||
| 7 | Hindsight | 91.4% *(per their release, metric unverified)* | Yes | Gemini-3 | Check their published methodology |
|
||||
| 8 | Stella (dense retriever) | ~85% | None | None | Academic retrieval baseline |
|
||||
| 9 | Contriever | ~78% | None | None | Academic retrieval baseline |
|
||||
| 10 | BM25 (sparse) | ~70% | None | None | Keyword baseline |
|
||||
|
||||
**MemPal raw (96.6%) is the highest published LongMemEval score that requires no API key, no cloud, and no LLM at any stage.**
|
||||
The MemPal raw 96.6% is the headline we ship on public surfaces: it's
|
||||
retrieval recall, it requires no API key, and it reproduces.
|
||||
|
||||
**MemPal hybrid v4 + Haiku rerank (100%) is the first perfect score on LongMemEval — 500/500 questions, all 6 question types at 100%.**
|
||||
The MemPal hybrid v4 + Haiku rerank 100% remains an internal
|
||||
result — reproducible with `--mode hybrid_v4 --llm-rerank` — but we
|
||||
don't quote it on public pages because the final 0.6% was reached by
|
||||
inspecting three specific wrong answers (see "Benchmark Integrity"
|
||||
below), which is teaching to the test. The honest generalisable figure
|
||||
when an LLM is in the loop is the held-out 98.4% R@5 on 450 unseen
|
||||
questions, or the model-agnostic 99.2% R@5 / 100% R@10 we reproduced
|
||||
with minimax-m2.7 on the full 500.
|
||||
|
||||
---
|
||||
|
||||
@@ -308,9 +342,9 @@ The palace classifies each question into one of 5 halls. Pass 1 searches only wi
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone -b ben/benchmarking https://github.com/aya-thekeeper/mempal.git
|
||||
cd mempal
|
||||
pip install chromadb pyyaml
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
mkdir -p /tmp/longmemeval-data
|
||||
curl -fsSL -o /tmp/longmemeval-data/longmemeval_s_cleaned.json \
|
||||
https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json
|
||||
|
||||
@@ -196,9 +196,9 @@ python benchmarks/longmemeval_bench.py data/longmemeval_s_cleaned.json --mode hy
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
git clone -b ben/benchmarking https://github.com/aya-thekeeper/mempal.git
|
||||
cd mempal
|
||||
pip install chromadb
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Download data
|
||||
mkdir -p /tmp/longmemeval-data
|
||||
@@ -435,7 +435,6 @@ If the API call fails (timeout, rate limit, no key), the function catches the ex
|
||||
**Key loading priority:**
|
||||
1. `--llm-key` CLI flag
|
||||
2. `ANTHROPIC_API_KEY` environment variable
|
||||
3. `~/.config/lu/keys.json` (checks `anthropic.lu_key` and similar paths)
|
||||
|
||||
## What Changed in the Code
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# MemPal Benchmarks — Reproduction Guide
|
||||
# MemPalace Benchmarks — Reproduction Guide
|
||||
|
||||
Run the exact same benchmarks we report. Clone, install, run.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone -b ben/benchmarking https://github.com/aya-thekeeper/mempal.git
|
||||
cd mempal
|
||||
pip install chromadb pyyaml
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Benchmark 1: LongMemEval (500 questions)
|
||||
|
||||
@@ -25,7 +25,6 @@ import os
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import ssl
|
||||
import tempfile
|
||||
import argparse
|
||||
import urllib.request
|
||||
@@ -35,9 +34,6 @@ from datetime import datetime
|
||||
|
||||
import chromadb
|
||||
|
||||
# Bypass SSL for restricted environments
|
||||
ssl._create_default_https_context = ssl._create_unverified_context
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
HF_BASE = "https://huggingface.co/datasets/Salesforce/ConvoMem/resolve/main/core_benchmark/evidence_questions"
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
{
|
||||
"dev": [
|
||||
"cc06de0d",
|
||||
"f9e8c073",
|
||||
"b320f3f8",
|
||||
"a89d7624",
|
||||
"311778f1",
|
||||
"gpt4_59c863d7",
|
||||
"bbf86515",
|
||||
"099778bb",
|
||||
"e831120c",
|
||||
"dcfa8644",
|
||||
"8fb83627",
|
||||
"e66b632c",
|
||||
"gpt4_7fce9456",
|
||||
"55241a1f",
|
||||
"352ab8bd",
|
||||
"f4f1d8a4",
|
||||
"830ce83f",
|
||||
"2311e44b",
|
||||
"09ba9854",
|
||||
"gpt4_a1b77f9c",
|
||||
"07741c45",
|
||||
"gpt4_70e84552",
|
||||
"b46e15ee",
|
||||
"6071bd76",
|
||||
"6f9b354f",
|
||||
"1d4da289",
|
||||
"gpt4_8279ba02",
|
||||
"6456829e_abs",
|
||||
"0db4c65d",
|
||||
"d6062bb9",
|
||||
"60bf93ed_abs",
|
||||
"d3ab962e",
|
||||
"87f22b4a",
|
||||
"e01b8e2f",
|
||||
"gpt4_7ddcf75f",
|
||||
"8ebdbe50",
|
||||
"26bdc477",
|
||||
"29f2956b_abs",
|
||||
"2311e44b_abs",
|
||||
"75f70248",
|
||||
"852ce960",
|
||||
"f0e564bc",
|
||||
"fca70973",
|
||||
"3c1045c8",
|
||||
"18bc8abd",
|
||||
"afdc33df",
|
||||
"54026fce",
|
||||
"b9cfe692",
|
||||
"6456829e",
|
||||
"e6041065"
|
||||
],
|
||||
"held_out": [
|
||||
"gpt4_15e38248",
|
||||
"gpt4_2ba83207",
|
||||
"2133c1b5_abs",
|
||||
"gpt4_8279ba03",
|
||||
"76d63226",
|
||||
"1192316e",
|
||||
"gpt4_fa19884d",
|
||||
"gpt4_372c3eed_abs",
|
||||
"1a8a66a6",
|
||||
"gpt4_fe651585",
|
||||
"e25c3b8d",
|
||||
"945e3d21",
|
||||
"86b68151",
|
||||
"1c0ddc50",
|
||||
"1e043500",
|
||||
"d682f1a2",
|
||||
"gpt4_b5700ca0",
|
||||
"91b15a6e",
|
||||
"ce6d2d27",
|
||||
"f523d9fe",
|
||||
"7024f17c",
|
||||
"8752c811",
|
||||
"gpt4_f420262d",
|
||||
"d01c6aa8",
|
||||
"4b24c848",
|
||||
"7e974930",
|
||||
"3fdac837",
|
||||
"gpt4_b4a80587",
|
||||
"c18a7dc8",
|
||||
"80ec1f4f_abs",
|
||||
"7527f7e2",
|
||||
"6ade9755",
|
||||
"89941a94",
|
||||
"gpt4_1d80365e",
|
||||
"2133c1b5",
|
||||
"06db6396",
|
||||
"gpt4_88806d6e",
|
||||
"88432d0a",
|
||||
"3ba21379",
|
||||
"0862e8bf",
|
||||
"aae3761f",
|
||||
"5025383b",
|
||||
"gpt4_e061b84f",
|
||||
"73d42213",
|
||||
"4bc144e2",
|
||||
"gpt4_5501fe77",
|
||||
"00ca467f",
|
||||
"dfde3500",
|
||||
"01493427",
|
||||
"b6025781",
|
||||
"a96c20ee_abs",
|
||||
"982b5123_abs",
|
||||
"gpt4_fa19884c",
|
||||
"gpt4_1a1dc16d",
|
||||
"28dc39ac",
|
||||
"gpt4_2d58bcd6",
|
||||
"51c32626",
|
||||
"c4ea545c",
|
||||
"1da05512",
|
||||
"gpt4_385a5000",
|
||||
"577d4d32",
|
||||
"72e3ee87",
|
||||
"f4f1d8a4_abs",
|
||||
"9d25d4e0",
|
||||
"b29f3365",
|
||||
"b759caee",
|
||||
"10e09553",
|
||||
"1d4e3b97",
|
||||
"d52b4f67",
|
||||
"gpt4_e072b769",
|
||||
"58ef2f1c",
|
||||
"6e984301",
|
||||
"41275add",
|
||||
"gpt4_59149c77",
|
||||
"2ebe6c90",
|
||||
"1cea1afa",
|
||||
"gpt4_1e4a8aec",
|
||||
"6c49646a",
|
||||
"8a2466db",
|
||||
"gpt4_65aabe59",
|
||||
"gpt4_93159ced",
|
||||
"51a45a95",
|
||||
"af8d2e46",
|
||||
"561fabcd",
|
||||
"370a8ff4",
|
||||
"gpt4_d84a3211",
|
||||
"gpt4_7a0daae1",
|
||||
"2a1811e2",
|
||||
"gpt4_78cf46a3",
|
||||
"1568498a",
|
||||
"6b7dfb22",
|
||||
"6ae235be",
|
||||
"bc8a6e93_abs",
|
||||
"681a1674",
|
||||
"06878be2",
|
||||
"1a1907b4",
|
||||
"0e4e4c46",
|
||||
"gpt4_85da3956",
|
||||
"gpt4_f420262c",
|
||||
"2bf43736",
|
||||
"bc149d6b",
|
||||
"09d032c9",
|
||||
"5c40ec5b",
|
||||
"eac54adc",
|
||||
"993da5e2",
|
||||
"71a3fd6b",
|
||||
"gpt4_0b2f1d21",
|
||||
"ad7109d1",
|
||||
"4c36ccef",
|
||||
"c8c3f81d",
|
||||
"edced276_abs",
|
||||
"0bc8ad92",
|
||||
"gpt4_468eb064",
|
||||
"2ebe6c92",
|
||||
"cc6d1ec1",
|
||||
"4dfccbf8",
|
||||
"95228167",
|
||||
"ba358f49",
|
||||
"45dc21b6",
|
||||
"db467c8c",
|
||||
"720133ac",
|
||||
"67e0d0f2",
|
||||
"cc5ded98",
|
||||
"726462e0",
|
||||
"4100d0a0",
|
||||
"3a704032",
|
||||
"gpt4_7ca326fa",
|
||||
"ec81a493",
|
||||
"618f13b2",
|
||||
"58470ed2",
|
||||
"gpt4_4fc4f797",
|
||||
"60036106",
|
||||
"157a136e",
|
||||
"6222b6eb",
|
||||
"69fee5aa",
|
||||
"19b5f2b3_abs",
|
||||
"gpt4_d12ceb0e",
|
||||
"51b23612",
|
||||
"2318644b",
|
||||
"3fe836c9",
|
||||
"gpt4_7de946e7",
|
||||
"71017277",
|
||||
"f0853d11",
|
||||
"dc439ea3",
|
||||
"gpt4_2f91af09",
|
||||
"9a707b81",
|
||||
"bc8a6e93",
|
||||
"c14c00dd",
|
||||
"8979f9ec",
|
||||
"cf22b7bf",
|
||||
"gpt4_ec93e27f",
|
||||
"gpt4_468eb063",
|
||||
"41698283",
|
||||
"1de5cff2",
|
||||
"21d02d0d",
|
||||
"c7cf7dfd",
|
||||
"gpt4_ab202e7f",
|
||||
"dccbc061",
|
||||
"078150f1",
|
||||
"e3038f8c",
|
||||
"gpt4_c27434e8_abs",
|
||||
"2698e78f",
|
||||
"031748ae_abs",
|
||||
"gpt4_59149c78",
|
||||
"c8f1aeed",
|
||||
"184da446",
|
||||
"gpt4_b5700ca9",
|
||||
"89527b6b",
|
||||
"0977f2af",
|
||||
"853b0a1d",
|
||||
"a346bb18",
|
||||
"3249768e",
|
||||
"gpt4_2f8be40d",
|
||||
"gpt4_93159ced_abs",
|
||||
"eeda8a6d",
|
||||
"7a8d0b71",
|
||||
"95bcc1c8",
|
||||
"gpt4_2487a7cb",
|
||||
"85fa3a3f",
|
||||
"7e00a6cb",
|
||||
"e3fc4d6e",
|
||||
"59524333",
|
||||
"37f165cf",
|
||||
"0ddfec37",
|
||||
"60bf93ed",
|
||||
"d7c942c3",
|
||||
"80ec1f4f",
|
||||
"ceb54acb",
|
||||
"9aaed6a3",
|
||||
"gpt4_4929293a",
|
||||
"ed4ddc30",
|
||||
"545bd2b5",
|
||||
"2788b940",
|
||||
"ef9cf60a",
|
||||
"gpt4_7f6b06db",
|
||||
"0ea62687",
|
||||
"3d86fd0a",
|
||||
"3e321797",
|
||||
"d24813b1",
|
||||
"38146c39",
|
||||
"efc3f7c2",
|
||||
"7401057b",
|
||||
"5809eb10",
|
||||
"28bcfaac",
|
||||
"1903aded",
|
||||
"gpt4_194be4b3",
|
||||
"gpt4_e414231f",
|
||||
"0ddfec37_abs",
|
||||
"c2ac3c61",
|
||||
"gpt4_4ef30696",
|
||||
"1f2b8d4f",
|
||||
"0f05491a",
|
||||
"8550ddae",
|
||||
"8077ef71",
|
||||
"b86304ba",
|
||||
"e61a7584",
|
||||
"8cf51dda",
|
||||
"gpt4_2f584639",
|
||||
"08e075c7",
|
||||
"5d3d2817",
|
||||
"7405e8b1",
|
||||
"a3045048",
|
||||
"gpt4_731e37d7",
|
||||
"c8090214_abs",
|
||||
"36580ce8",
|
||||
"ba358f49_abs",
|
||||
"gpt4_d6585ce8",
|
||||
"e56a43b9",
|
||||
"2c63a862",
|
||||
"gpt4_5438fa52",
|
||||
"07b6f563",
|
||||
"gpt4_31ff4165",
|
||||
"0bb5a684",
|
||||
"71315a70",
|
||||
"gpt4_cd90e484",
|
||||
"gpt4_8c8961ae",
|
||||
"gpt4_fe651585_abs",
|
||||
"36b9f61e",
|
||||
"gpt4_b0863698",
|
||||
"gpt4_1d4ab0c9",
|
||||
"15745da0_abs",
|
||||
"0862e8bf_abs",
|
||||
"bcbe585f",
|
||||
"a2f3aa27",
|
||||
"gpt4_6dc9b45b",
|
||||
"ccb36322",
|
||||
"f685340e",
|
||||
"9ea5eabc",
|
||||
"gpt4_372c3eed",
|
||||
"37d43f65",
|
||||
"bf659f65",
|
||||
"b0479f84",
|
||||
"gpt4_213fd887",
|
||||
"e4e14d04",
|
||||
"f8c5f88b",
|
||||
"gpt4_18c2b244",
|
||||
"a11281a2",
|
||||
"gpt4_2655b836",
|
||||
"e47becba",
|
||||
"gpt4_74aed68e",
|
||||
"gpt4_af6db32f",
|
||||
"6cb6f249",
|
||||
"77eafa52",
|
||||
"gpt4_93f6379c",
|
||||
"e8a79c70",
|
||||
"7a87bd0c",
|
||||
"gpt4_6ed717ea",
|
||||
"d6233ab6",
|
||||
"c19f7a0b",
|
||||
"gpt4_61e13b3c",
|
||||
"d23cf73b",
|
||||
"gpt4_1e4a8aeb",
|
||||
"ba61f0b9",
|
||||
"118b2229",
|
||||
"488d3006",
|
||||
"c4a1ceb8",
|
||||
"8e91e7d9",
|
||||
"42ec0761",
|
||||
"65240037",
|
||||
"fea54f57",
|
||||
"c8090214",
|
||||
"b01defab",
|
||||
"6aeb4375_abs",
|
||||
"faba32e5",
|
||||
"c5e8278d",
|
||||
"gpt4_e414231e",
|
||||
"eeda8a6d_abs",
|
||||
"gpt4_8e165409",
|
||||
"af082822",
|
||||
"22d2cb42",
|
||||
"92a0aa75",
|
||||
"1c549ce4",
|
||||
"25e5aa4f",
|
||||
"gpt4_68e94288",
|
||||
"4baee567",
|
||||
"18dcd5a5",
|
||||
"dad224aa",
|
||||
"gpt4_f2262a51",
|
||||
"29f2956b",
|
||||
"21436231",
|
||||
"19b5f2b3",
|
||||
"gpt4_1916e0ea",
|
||||
"gpt4_45189cb4",
|
||||
"0a995998",
|
||||
"b6019101",
|
||||
"9bbe84a2",
|
||||
"61f8c8f8",
|
||||
"9a707b82",
|
||||
"8cf4d046",
|
||||
"eac54add",
|
||||
"75832dbd",
|
||||
"gpt4_98f46fc6",
|
||||
"d596882b",
|
||||
"88432d0a_abs",
|
||||
"16c90bf4",
|
||||
"f685340e_abs",
|
||||
"b5ef892d",
|
||||
"gpt4_f49edff3",
|
||||
"gpt4_483dd43c",
|
||||
"bb7c3b45",
|
||||
"gpt4_7abb270c",
|
||||
"gpt4_9a159967",
|
||||
"07741c44",
|
||||
"4d6b87c8",
|
||||
"6aeb4375",
|
||||
"gpt4_d6585ce9",
|
||||
"60472f9c",
|
||||
"caf9ead2",
|
||||
"32260d93",
|
||||
"60159905",
|
||||
"0a34ad58",
|
||||
"a40e080f",
|
||||
"10d9b85a",
|
||||
"a06e4cfe",
|
||||
"4f54b7c9",
|
||||
"6613b389",
|
||||
"70b3e69b",
|
||||
"gpt4_7bc6cf22",
|
||||
"gpt4_0a05b494",
|
||||
"778164c6",
|
||||
"195a1a1b",
|
||||
"8464fc84",
|
||||
"b46e15ed",
|
||||
"603deb26",
|
||||
"eaca4986",
|
||||
"2698e78f_abs",
|
||||
"gpt4_21adecb5",
|
||||
"2e6d26dc",
|
||||
"5831f84d",
|
||||
"08f4fc43",
|
||||
"3f1e9474",
|
||||
"c9f37c46",
|
||||
"gpt4_2f56ae70",
|
||||
"1b9b7252",
|
||||
"35a27287",
|
||||
"gpt4_d31cdae3",
|
||||
"129d1232",
|
||||
"4adc0475",
|
||||
"27016adc",
|
||||
"46a3abf7",
|
||||
"9ee3ecd6",
|
||||
"982b5123",
|
||||
"09ba9854_abs",
|
||||
"0e5e2d1a",
|
||||
"e9327a54",
|
||||
"86f00804",
|
||||
"e982271f",
|
||||
"7161e7e2",
|
||||
"57f827a0",
|
||||
"6a27ffc2",
|
||||
"edced276",
|
||||
"gpt4_d9af6064",
|
||||
"75499fd8",
|
||||
"60d45044",
|
||||
"gpt4_70e84552_abs",
|
||||
"2ce6a0f2",
|
||||
"gpt4_4929293b",
|
||||
"a1cc6108",
|
||||
"gpt4_5dcc0aab",
|
||||
"a3838d2b",
|
||||
"c7dc5443",
|
||||
"505af2f5",
|
||||
"gpt4_68e94287",
|
||||
"15745da0",
|
||||
"0100672e",
|
||||
"a82c026e",
|
||||
"5e1b23de",
|
||||
"71017276",
|
||||
"89941a93",
|
||||
"6b168ec8",
|
||||
"affe2881",
|
||||
"0edc2aef",
|
||||
"gpt4_2312f94c",
|
||||
"a4996e51",
|
||||
"c6853660",
|
||||
"ef66a6e5",
|
||||
"8a137a7f",
|
||||
"a96c20ee",
|
||||
"fca762bc",
|
||||
"ac031881",
|
||||
"d905b33f",
|
||||
"e493bb7c",
|
||||
"a9f6b44c",
|
||||
"dd2973ad",
|
||||
"8aef76bc",
|
||||
"f35224e0",
|
||||
"8b9d4367",
|
||||
"gpt4_c27434e8",
|
||||
"gpt4_a56e767c",
|
||||
"eace081b",
|
||||
"5a4f22c0",
|
||||
"58bf7951",
|
||||
"c4f10528",
|
||||
"50635ada",
|
||||
"06f04340",
|
||||
"0bc8ad93",
|
||||
"e5ba910e_abs",
|
||||
"5a7937c8",
|
||||
"a3332713",
|
||||
"4388e9dd",
|
||||
"8c18457d",
|
||||
"gpt4_2c50253f",
|
||||
"6a1eabeb",
|
||||
"b3c15d39",
|
||||
"gpt4_e061b84g",
|
||||
"3b6f954b",
|
||||
"gpt4_76048e76",
|
||||
"4dfccbf7",
|
||||
"2b8f3739",
|
||||
"d851d5ba",
|
||||
"4fd1909e",
|
||||
"94f70d80",
|
||||
"66f24dbb",
|
||||
"a08a253f",
|
||||
"6e984302",
|
||||
"001be529",
|
||||
"gpt4_a2d1d1f6",
|
||||
"cc539528",
|
||||
"e48988bc",
|
||||
"gpt4_4cd9eba1",
|
||||
"8e9d538c",
|
||||
"a1eacc2a",
|
||||
"6d550036",
|
||||
"gpt4_e05b82a6",
|
||||
"81507db6",
|
||||
"caf03d32",
|
||||
"031748ae",
|
||||
"c960da58",
|
||||
"1faac195",
|
||||
"gpt4_4edbafa2"
|
||||
],
|
||||
"seed": 42,
|
||||
"dev_size": 50
|
||||
}
|
||||
+70
-41
@@ -510,11 +510,20 @@ def palace_assign_rooms(sessions, sample_id, api_key, cache, model="claude-haiku
|
||||
|
||||
|
||||
def llm_rerank_locomo(
|
||||
question, retrieved_ids, retrieved_docs, api_key, top_k=10, model="claude-sonnet-4-6"
|
||||
question,
|
||||
retrieved_ids,
|
||||
retrieved_docs,
|
||||
api_key,
|
||||
top_k=10,
|
||||
model="claude-sonnet-4-6",
|
||||
backend="anthropic",
|
||||
base_url="",
|
||||
):
|
||||
"""
|
||||
Ask LLM to pick the single most relevant document for this question.
|
||||
Returns reordered retrieved_ids with the best candidate first.
|
||||
|
||||
Supports backend="anthropic" (default) or "ollama" (OpenAI-compat endpoint).
|
||||
"""
|
||||
candidates = retrieved_ids[:top_k]
|
||||
candidate_docs = retrieved_docs[:top_k]
|
||||
@@ -522,7 +531,6 @@ def llm_rerank_locomo(
|
||||
if len(candidates) <= 1:
|
||||
return retrieved_ids
|
||||
|
||||
# Build numbered list of candidates
|
||||
lines = []
|
||||
for i, (cid, doc) in enumerate(zip(candidates, candidate_docs), 1):
|
||||
snippet = doc[:300].replace("\n", " ")
|
||||
@@ -534,35 +542,51 @@ def llm_rerank_locomo(
|
||||
f"Reply with just the number (1-{len(candidates)}).\n\n" + "\n".join(lines)
|
||||
)
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 8,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
data=payload,
|
||||
headers={
|
||||
if backend == "ollama":
|
||||
url = (base_url or "http://localhost:11434").rstrip("/") + "/v1/chat/completions"
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
).encode("utf-8")
|
||||
headers = {"content-type": "application/json"}
|
||||
if api_key:
|
||||
headers["authorization"] = f"Bearer {api_key}"
|
||||
else:
|
||||
url = "https://api.anthropic.com/v1/messages"
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 8,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
).encode("utf-8")
|
||||
headers = {
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
}
|
||||
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
||||
|
||||
import socket as _socket
|
||||
|
||||
for _attempt in range(3):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
with urllib.request.urlopen(req, timeout=120 if backend == "ollama" else 30) as resp:
|
||||
result = json.loads(resp.read())
|
||||
raw = result["content"][0]["text"].strip()
|
||||
m = re.search(r"\b(\d+)\b", raw)
|
||||
if backend == "ollama":
|
||||
msg = result["choices"][0]["message"]
|
||||
raw = (msg.get("content") or "").strip() or (msg.get("reasoning") or "").strip()
|
||||
else:
|
||||
raw = result["content"][0]["text"].strip()
|
||||
# Take LAST integer — reasoning models often count candidates first
|
||||
m = re.search(r"\b(\d+)\b", raw[::-1])
|
||||
if m:
|
||||
pick = int(m.group(1))
|
||||
pick = int(m.group(1)[::-1])
|
||||
if 1 <= pick <= len(candidates):
|
||||
chosen_id = candidates[pick - 1]
|
||||
reordered = [chosen_id] + [cid for cid in retrieved_ids if cid != chosen_id]
|
||||
@@ -580,29 +604,12 @@ def llm_rerank_locomo(
|
||||
|
||||
|
||||
def _load_api_key(key_arg):
|
||||
"""Load API key from --llm-key arg or ANTHROPIC_API_KEY env var."""
|
||||
if key_arg:
|
||||
return key_arg
|
||||
env_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
if env_key:
|
||||
return env_key
|
||||
keys_path = os.path.expanduser("~/.config/lu/keys.json")
|
||||
if os.path.exists(keys_path):
|
||||
try:
|
||||
with open(keys_path) as f:
|
||||
keys = json.load(f)
|
||||
for name in ("lu_key", "anthropic_milla", "anthropic_claude_code_main"):
|
||||
val = keys.get(name, "")
|
||||
if isinstance(val, str) and val.startswith("sk-ant-"):
|
||||
return val
|
||||
for section in ("anthropic", "anthropic_milla", "anthropic_claude_code_main"):
|
||||
sec = keys.get(section, {})
|
||||
if isinstance(sec, dict):
|
||||
for subkey in ("lu_key", "key", "api_key"):
|
||||
val = sec.get(subkey, "")
|
||||
if isinstance(val, str) and val.startswith("sk-ant-"):
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
@@ -625,6 +632,8 @@ def run_benchmark(
|
||||
palace_cache_file=None,
|
||||
palace_model="claude-haiku-4-5-20251001",
|
||||
embed_model="default",
|
||||
llm_backend="anthropic",
|
||||
llm_base_url="",
|
||||
):
|
||||
"""Run LoCoMo retrieval benchmark."""
|
||||
with open(data_file) as f:
|
||||
@@ -636,8 +645,12 @@ def run_benchmark(
|
||||
api_key = ""
|
||||
if llm_rerank_enabled or mode == "palace":
|
||||
api_key = _load_api_key(llm_key)
|
||||
if not api_key:
|
||||
print(f"ERROR: --mode {mode} requires an API key (--llm-key or ANTHROPIC_API_KEY).")
|
||||
# Ollama backend doesn't require an Anthropic key. Palace mode still does
|
||||
# (it uses Anthropic for room-assignment indexing) — so only relax the
|
||||
# requirement when rerank is the ONLY llm use and backend is ollama.
|
||||
needs_key = mode == "palace" or (llm_rerank_enabled and llm_backend == "anthropic")
|
||||
if needs_key and not api_key:
|
||||
print(f"ERROR: --mode {mode} / --llm-rerank (anthropic) requires an API key.")
|
||||
sys.exit(1)
|
||||
|
||||
# Palace mode: load or create room assignment cache
|
||||
@@ -905,6 +918,8 @@ def run_benchmark(
|
||||
api_key,
|
||||
top_k=rerank_pool,
|
||||
model=llm_model,
|
||||
backend=llm_backend,
|
||||
base_url=llm_base_url,
|
||||
)
|
||||
|
||||
# Compute recall
|
||||
@@ -1030,6 +1045,18 @@ if __name__ == "__main__":
|
||||
help="Model for LLM rerank (default: claude-sonnet-4-6)",
|
||||
)
|
||||
parser.add_argument("--llm-key", default="", help="API key (or set ANTHROPIC_API_KEY env var)")
|
||||
parser.add_argument(
|
||||
"--llm-backend",
|
||||
choices=["anthropic", "ollama"],
|
||||
default="anthropic",
|
||||
help="Which API for --llm-rerank. 'anthropic' (default) or 'ollama' "
|
||||
"(OpenAI-compat /v1/chat/completions — works for local + Ollama Cloud).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--llm-base-url",
|
||||
default="",
|
||||
help="Override base URL for --llm-backend ollama. Default: http://localhost:11434.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hybrid-weight",
|
||||
type=float,
|
||||
@@ -1066,4 +1093,6 @@ if __name__ == "__main__":
|
||||
palace_cache_file=args.palace_cache,
|
||||
palace_model=args.palace_model,
|
||||
embed_model=args.embed_model,
|
||||
llm_backend=args.llm_backend,
|
||||
llm_base_url=args.llm_base_url,
|
||||
)
|
||||
|
||||
+103
-66
@@ -2763,7 +2763,15 @@ def build_palace_and_retrieve_diary(
|
||||
|
||||
|
||||
def llm_rerank(
|
||||
question, rankings, corpus, corpus_ids, api_key, top_k=10, model="claude-haiku-4-5-20251001"
|
||||
question,
|
||||
rankings,
|
||||
corpus,
|
||||
corpus_ids,
|
||||
api_key,
|
||||
top_k=10,
|
||||
model="claude-haiku-4-5-20251001",
|
||||
backend="anthropic",
|
||||
base_url="",
|
||||
):
|
||||
"""
|
||||
Use an LLM to re-rank the top-k retrieved sessions.
|
||||
@@ -2772,19 +2780,22 @@ def llm_rerank(
|
||||
which single session is most relevant to the question. That session
|
||||
is promoted to rank 1; the rest stay in their existing order.
|
||||
|
||||
This closes the gap for "preference" and jargon-dense "assistant"
|
||||
failures where the right session is in top-10 semantically but not
|
||||
top-5 — because the semantic gap (battery life ↔ phone hardware) is
|
||||
too large for embeddings to bridge.
|
||||
Supports two backends:
|
||||
- "anthropic": hits https://api.anthropic.com/v1/messages with x-api-key.
|
||||
- "ollama": hits {base_url}/v1/chat/completions (OpenAI-compat) —
|
||||
works for local Ollama (default http://localhost:11434)
|
||||
and Ollama Cloud (:cloud model tags).
|
||||
|
||||
Args:
|
||||
question: The benchmark question string
|
||||
rankings: Current ranked list of corpus indices (from any mode)
|
||||
corpus: List of document strings
|
||||
corpus_ids: List of corpus IDs (parallel to corpus)
|
||||
api_key: Anthropic API key string
|
||||
top_k: How many top sessions to send to LLM (default: 10)
|
||||
model: Claude model ID for reranking (default: haiku)
|
||||
question: The benchmark question string
|
||||
rankings: Current ranked list of corpus indices (from any mode)
|
||||
corpus: List of document strings
|
||||
corpus_ids: List of corpus IDs (parallel to corpus)
|
||||
api_key: Anthropic API key (only required for backend="anthropic")
|
||||
top_k: How many top sessions to send to LLM (default: 10)
|
||||
model: Model id (Claude model for anthropic, e.g. "minimax-m2.7:cloud" for ollama)
|
||||
backend: "anthropic" or "ollama"
|
||||
base_url: Override base URL (ollama default: http://localhost:11434)
|
||||
|
||||
Returns:
|
||||
Reordered rankings list with LLM's best pick promoted to rank 1.
|
||||
@@ -2796,7 +2807,6 @@ def llm_rerank(
|
||||
if not candidates:
|
||||
return rankings
|
||||
|
||||
# Format sessions for the prompt — first 500 chars each, labelled 1..N
|
||||
session_blocks = []
|
||||
for rank, idx in enumerate(candidates):
|
||||
text = corpus[idx][:500].replace("\n", " ").strip()
|
||||
@@ -2813,80 +2823,79 @@ def llm_rerank(
|
||||
f"Most relevant session number:"
|
||||
)
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 8,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
data=payload,
|
||||
headers={
|
||||
if backend == "ollama":
|
||||
url = (base_url or "http://localhost:11434").rstrip("/") + "/v1/chat/completions"
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
).encode("utf-8")
|
||||
headers = {"content-type": "application/json"}
|
||||
if api_key:
|
||||
headers["authorization"] = f"Bearer {api_key}"
|
||||
else:
|
||||
url = "https://api.anthropic.com/v1/messages"
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 8,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
).encode("utf-8")
|
||||
headers = {
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
}
|
||||
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
||||
|
||||
import socket as _socket
|
||||
|
||||
for _attempt in range(3):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
with urllib.request.urlopen(req, timeout=120 if backend == "ollama" else 20) as resp:
|
||||
result = json.loads(resp.read())
|
||||
raw = result["content"][0]["text"].strip()
|
||||
# Parse just the first integer from Haiku's response
|
||||
m = re.search(r"\b(\d+)\b", raw)
|
||||
if backend == "ollama":
|
||||
msg = result["choices"][0]["message"]
|
||||
# Reasoning models (e.g. minimax-m2.7) may emit final answer in "content"
|
||||
# or embed it in "reasoning". Try content first, fall back to reasoning.
|
||||
raw = (msg.get("content") or "").strip()
|
||||
if not raw:
|
||||
raw = (msg.get("reasoning") or "").strip()
|
||||
else:
|
||||
raw = result["content"][0]["text"].strip()
|
||||
m = re.search(
|
||||
r"\b(\d+)\b", raw[::-1]
|
||||
) # take LAST integer (rerank models often reason first)
|
||||
if m:
|
||||
pick = int(m.group(1))
|
||||
pick = int(m.group(1)[::-1])
|
||||
if 1 <= pick <= len(candidates):
|
||||
chosen_idx = candidates[pick - 1]
|
||||
reordered = [chosen_idx] + [i for i in rankings if i != chosen_idx]
|
||||
return reordered
|
||||
break # Got a response, even if unparseable — don't retry
|
||||
break
|
||||
except (_socket.timeout, TimeoutError):
|
||||
if _attempt < 2:
|
||||
import time as _time
|
||||
|
||||
_time.sleep(3) # brief pause then retry
|
||||
# else fall through to return rankings
|
||||
_time.sleep(3)
|
||||
except (urllib.error.URLError, KeyError, ValueError, IndexError, OSError):
|
||||
break # Non-timeout error — fall back immediately
|
||||
break
|
||||
|
||||
return rankings
|
||||
|
||||
|
||||
def _load_api_key(key_arg):
|
||||
"""Load API key from --llm-key arg, env var, or ~/.config/lu/keys.json."""
|
||||
"""Load API key from --llm-key arg or ANTHROPIC_API_KEY env var."""
|
||||
if key_arg:
|
||||
return key_arg
|
||||
env_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
if env_key:
|
||||
return env_key
|
||||
keys_path = os.path.expanduser("~/.config/lu/keys.json")
|
||||
if os.path.exists(keys_path):
|
||||
try:
|
||||
with open(keys_path) as f:
|
||||
keys = json.load(f)
|
||||
# Flat string keys
|
||||
for name in ("lu_key", "anthropic_milla", "anthropic_claude_code_main"):
|
||||
val = keys.get(name, "")
|
||||
if isinstance(val, str) and val.startswith("sk-ant-"):
|
||||
return val
|
||||
# Nested dict: keys["anthropic"]["lu_key"]
|
||||
for section in ("anthropic", "anthropic_milla", "anthropic_claude_code_main"):
|
||||
sec = keys.get(section, {})
|
||||
if isinstance(sec, dict):
|
||||
for subkey in ("lu_key", "key", "api_key"):
|
||||
val = sec.get(subkey, "")
|
||||
if isinstance(val, str) and val.startswith("sk-ant-"):
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
@@ -2939,6 +2948,8 @@ def run_benchmark(
|
||||
skip_precompute=False,
|
||||
split_file=None,
|
||||
split_subset=None,
|
||||
llm_backend="anthropic",
|
||||
llm_base_url="",
|
||||
):
|
||||
"""Run the full benchmark.
|
||||
|
||||
@@ -2967,11 +2978,14 @@ def run_benchmark(
|
||||
api_key = ""
|
||||
if llm_rerank_enabled or mode == "diary":
|
||||
api_key = _load_api_key(llm_key)
|
||||
if not api_key:
|
||||
# Ollama backend doesn't require an Anthropic API key; a local/cloud Ollama
|
||||
# daemon with the requested model pulled is enough. Diary mode is always anthropic.
|
||||
needs_key = (llm_backend == "anthropic") or (mode == "diary")
|
||||
if needs_key and not api_key:
|
||||
print(
|
||||
"ERROR: --llm-rerank / --mode diary requires an API key. "
|
||||
"Set ANTHROPIC_API_KEY, use --llm-key, "
|
||||
"or store in ~/.config/lu/keys.json as 'lu_key'."
|
||||
"ERROR: --llm-rerank (anthropic backend) / --mode diary requires an API key. "
|
||||
"Set ANTHROPIC_API_KEY or use --llm-key. For ollama backend, pass "
|
||||
"--llm-backend ollama."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -3121,7 +3135,15 @@ def run_benchmark(
|
||||
if llm_rerank_enabled:
|
||||
rerank_pool = 20 if mode in ("hybrid_v3", "hybrid_v4", "palace") else 10
|
||||
rankings = llm_rerank(
|
||||
question, rankings, corpus, corpus_ids, api_key, top_k=rerank_pool, model=llm_model
|
||||
question,
|
||||
rankings,
|
||||
corpus,
|
||||
corpus_ids,
|
||||
api_key,
|
||||
top_k=rerank_pool,
|
||||
model=llm_model,
|
||||
backend=llm_backend,
|
||||
base_url=llm_base_url,
|
||||
)
|
||||
|
||||
# Evaluate at session level
|
||||
@@ -3290,15 +3312,28 @@ if __name__ == "__main__":
|
||||
parser.add_argument(
|
||||
"--llm-key",
|
||||
default="",
|
||||
help="Anthropic API key for LLM re-ranking. Falls back to ANTHROPIC_API_KEY "
|
||||
"env var or ~/.config/lu/keys.json 'lu_key' field if not provided.",
|
||||
help="Anthropic API key for LLM re-ranking. Falls back to ANTHROPIC_API_KEY env var.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--llm-model",
|
||||
default="claude-haiku-4-5-20251001",
|
||||
help="Model for LLM re-ranking and diary ingest "
|
||||
"(default: claude-haiku-4-5-20251001). "
|
||||
"Use 'claude-sonnet-4-6' for Sonnet comparison.",
|
||||
"Use 'claude-sonnet-4-6' for Sonnet comparison. "
|
||||
"With --llm-backend ollama, use an Ollama model tag like 'minimax-m2.7:cloud'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--llm-backend",
|
||||
choices=["anthropic", "ollama"],
|
||||
default="anthropic",
|
||||
help="Which API to hit for --llm-rerank. 'anthropic' (default) uses Anthropic's "
|
||||
"/v1/messages endpoint. 'ollama' uses Ollama's OpenAI-compatible "
|
||||
"/v1/chat/completions endpoint (works with local Ollama and Ollama Cloud).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--llm-base-url",
|
||||
default="",
|
||||
help="Override base URL for --llm-backend ollama. Defaults to http://localhost:11434.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diary-cache",
|
||||
@@ -3402,4 +3437,6 @@ if __name__ == "__main__":
|
||||
args.skip_precompute,
|
||||
split_file=args.split_file,
|
||||
split_subset=split_subset,
|
||||
llm_backend=args.llm_backend,
|
||||
llm_base_url=args.llm_base_url,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,88 @@
|
||||
# Closets — The Searchable Index Layer
|
||||
|
||||
## What closets are
|
||||
|
||||
Drawers hold your verbatim content. Closets are the index — compact pointers that tell the searcher which drawers to open.
|
||||
|
||||
```
|
||||
CLOSET: "built auth system|Ben;Igor|→drawer_api_auth_a1b2c3"
|
||||
↑ topic ↑ entities ↑ points to this drawer
|
||||
```
|
||||
|
||||
An agent searching "who built the auth?" hits the closet first (fast scan of short text), then opens the referenced drawer to get the full verbatim content.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### When are closets created?
|
||||
|
||||
Closets are created during `mempalace mine`. For each file mined:
|
||||
1. Content is chunked into drawers (verbatim, ~800 chars each)
|
||||
2. Topics, entities, and quotes are extracted from the content
|
||||
3. A closet is created with pointer lines to those drawers
|
||||
|
||||
### What's inside a closet?
|
||||
|
||||
Each line is one atomic topic pointer:
|
||||
```
|
||||
topic description|entity1;entity2|→drawer_id_1,drawer_id_2
|
||||
"verbatim quote from the content"|entity1|→drawer_id_3
|
||||
```
|
||||
|
||||
Topics are never split across closets. If adding a topic would exceed 1,500 characters, a new closet is created.
|
||||
|
||||
### When do closets update?
|
||||
|
||||
When a file is re-mined (content changed, or `NORMALIZE_VERSION` was bumped), the miner first deletes every closet for that source file (`purge_file_closets`) and then writes a fresh set. Stale topics from the prior mine are gone — closets are always a snapshot of the current content, never an accumulation across runs.
|
||||
|
||||
### What about stale topics?
|
||||
|
||||
There are no stale topics: each re-mine is a clean rebuild for that source file. If a file gets larger and produces fewer or more closets than last time, the leftover numbered closets from the larger run are still purged because the delete is done by `source_file`, not by ID.
|
||||
|
||||
### Do closets survive palace rebuilds?
|
||||
|
||||
Closets are stored in the `mempalace_closets` ChromaDB collection alongside `mempalace_drawers`. If you delete and rebuild the palace, closets are recreated during the next `mempalace mine`.
|
||||
|
||||
## How search uses closets
|
||||
|
||||
```
|
||||
Query → search mempalace_closets (fast, small documents)
|
||||
↓
|
||||
top closet hits → parse `→drawer_id_a,drawer_id_b` pointers
|
||||
↓
|
||||
fetch exactly those drawers from mempalace_drawers (verbatim content)
|
||||
↓
|
||||
apply max_distance filter
|
||||
↓
|
||||
return chunk-level results (same shape as direct search)
|
||||
```
|
||||
|
||||
Hits carry `matched_via: "closet"` (or `"drawer"` for the fallback path) plus a `closet_preview` field showing the line that surfaced them.
|
||||
|
||||
If no closets exist (palace created before this feature) — or all closet hits get filtered out by `max_distance` — search falls back to direct drawer search. Closets are created on next mine.
|
||||
|
||||
> **BM25 hybrid re-rank** is on the roadmap (deferred to a follow-up PR alongside generic `LLM_*` env-var support); the current closet search ranks purely by ChromaDB cosine distance against the closet text.
|
||||
|
||||
## Limits
|
||||
|
||||
| Setting | Value | Reason |
|
||||
|---------|-------|--------|
|
||||
| Max closet size | 1,500 chars (`CLOSET_CHAR_LIMIT`) | Leaves buffer under ChromaDB's working limit |
|
||||
| Source content scanned | 5,000 chars (`CLOSET_EXTRACT_WINDOW`) | Caps regex extraction cost on long files; back-of-file content is currently invisible to closet extraction (tracked for follow-up) |
|
||||
| Max topics per file | 12 | Keeps closets focused |
|
||||
| Max quotes per file | 3 | Most relevant only |
|
||||
| Max entities per pointer | 5 | Top names by frequency, after stoplist filtering |
|
||||
|
||||
## For developers
|
||||
|
||||
Closet functions live in `mempalace/palace.py`:
|
||||
- `get_closets_collection()` — get the closets ChromaDB collection
|
||||
- `build_closet_lines()` — extract topics/entities/quotes into pointer lines
|
||||
- `upsert_closet_lines()` — write lines to closets respecting the char limit (overwrites existing IDs; does not append — call `purge_file_closets` first when re-mining)
|
||||
- `purge_file_closets()` — delete every closet for a given source file before rebuild
|
||||
- `CLOSET_CHAR_LIMIT` / `CLOSET_EXTRACT_WINDOW` — size constants
|
||||
|
||||
The closet-first search path lives in `mempalace/searcher.py`:
|
||||
- `_extract_drawer_ids_from_closet()` — parse `→drawer_a,drawer_b` pointers out of a closet document
|
||||
- `_closet_first_hits()` — query closets, parse pointers, hydrate matching drawers, return chunk-level hits or `None` to fall back
|
||||
|
||||
Note: only the project miner (`miner.py::process_file`) builds closets today. Conversation-mined wings (Claude Code JSONL, ChatGPT export, etc.) will keep using direct drawer search via the searcher fallback until the convo-closet PR lands.
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
# MemPalace — History, Corrections, and Public Notices
|
||||
|
||||
This file is the canonical record of post-launch corrections, public notices,
|
||||
and retractions that affect MemPalace's public claims. Newest first.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-14 — Benchmark table rewrite (issue [#875](https://github.com/MemPalace/mempalace/issues/875))
|
||||
|
||||
A community audit identified a category error in the public benchmark tables
|
||||
on `README.md` and `mempalaceofficial.com`: MemPalace's retrieval recall
|
||||
numbers (R@5, R@10) were listed in the same columns as competitors'
|
||||
end-to-end QA accuracy numbers. They are different metrics and are not
|
||||
comparable — a system can have 100% retrieval recall and 40% QA accuracy.
|
||||
|
||||
The audit also found that the retracted "+34% palace boost" claim (see the
|
||||
April 7 note below) was still present in multiple surfaces despite that
|
||||
retraction, and that two competitor numbers (`Mem0 ~85%`, `Zep ~85%`) had no
|
||||
published source and did not match the metrics those projects actually
|
||||
publish.
|
||||
|
||||
What changed in this PR:
|
||||
|
||||
- The headline number on all surfaces is now **96.6% R@5 on LongMemEval in
|
||||
raw mode**, independently reproduced on Linux x86_64 against the tagged
|
||||
v3.3.0 release on 2026-04-14. Result JSONLs are committed under
|
||||
`benchmarks/results_*.jsonl` (see PR description for the scorecard).
|
||||
- The **"100% with Haiku rerank"** claim has been removed from all public
|
||||
comparison tables. It reproduces on our machines and with a different LLM
|
||||
family (minimax-m2.7 via Ollama Cloud: 99.2% R@5 / 100.0% R@10 on the full
|
||||
500-question LongMemEval set) — but the 99.4% → 100% step was developed
|
||||
by inspecting three specific wrong answers (`benchmarks/BENCHMARKS.md` has
|
||||
called this "teaching to the test" since February). It belongs in the
|
||||
methodology document, not in a headline.
|
||||
- The **honest held-out number** for the hybrid pipeline — 98.4% R@5 on 450
|
||||
questions that `hybrid_v4` was never tuned on, deterministic seed — is now
|
||||
the comparable figure when an LLM rerank is involved.
|
||||
- The **retracted "+34% palace boost"** has been removed from
|
||||
`README.md`, `website/concepts/the-palace.md`,
|
||||
`website/guide/searching.md`, and `website/reference/contributing.md`.
|
||||
Wing and room filters remain useful — they're standard metadata filters —
|
||||
but they are not presented as a novel retrieval improvement.
|
||||
- **Competitor comparison tables** mixing retrieval recall with QA accuracy
|
||||
have been removed from `README.md` and `website/reference/benchmarks.md`.
|
||||
Where MemPalace can be fairly compared on the same metric, we link to the
|
||||
cited source. Otherwise we report our own numbers and let readers draw
|
||||
their own conclusions.
|
||||
- **Reproduction instructions** in `benchmarks/BENCHMARKS.md` and
|
||||
`benchmarks/README.md` were pointing at a defunct branch
|
||||
(`aya-thekeeper/mempal`); they now point at `MemPalace/mempalace`.
|
||||
- The **LoCoMo 100% R@10 with top-50 rerank** row has been removed from
|
||||
public comparison surfaces. With per-conversation session counts of 19–32
|
||||
and `top_k=50`, the retrieval stage returns every session in the
|
||||
conversation by construction, so the number measures an LLM's
|
||||
reading comprehension over the whole conversation, not retrieval.
|
||||
|
||||
Thanks to [@dial481](https://github.com/MemPalace/mempalace/issues/875) for
|
||||
the detailed audit and to [@rohitg00](https://github.com/rohitg00) for the
|
||||
parallel write-up in Discussion #747.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-11 — Impostor domains and malware
|
||||
|
||||
Several community members (issues #267, #326, #506) reported fake MemPalace
|
||||
websites distributing malware. The only official surfaces for this project
|
||||
are:
|
||||
|
||||
- This GitHub repository: [github.com/MemPalace/mempalace](https://github.com/MemPalace/mempalace)
|
||||
- The PyPI package: [pypi.org/project/mempalace](https://pypi.org/project/mempalace/)
|
||||
- The docs site: [mempalaceofficial.com](https://mempalaceofficial.com)
|
||||
|
||||
Any other domain — `mempalace.tech` being the one most commonly reported —
|
||||
is not ours. Never run install scripts from unofficial sites.
|
||||
|
||||
Thanks to our community members for flagging the problem.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-07 — A Note from Milla & Ben
|
||||
|
||||
> The community caught real problems in this README within hours of launch
|
||||
> and we want to address them directly.
|
||||
>
|
||||
> **What we got wrong:**
|
||||
>
|
||||
> - **The AAAK token example was incorrect.** We used a rough heuristic
|
||||
> (`len(text)//3`) for token counts instead of an actual tokenizer. Real
|
||||
> counts via OpenAI's tokenizer: the English example is 66 tokens, the
|
||||
> AAAK example is 73. AAAK does not save tokens at small scales — it's
|
||||
> designed for *repeated entities at scale*, and the README example was a
|
||||
> bad demonstration of that. We're rewriting it.
|
||||
>
|
||||
> - **"30x lossless compression" was overstated.** AAAK is a lossy
|
||||
> abbreviation system (entity codes, sentence truncation). Independent
|
||||
> benchmarks show AAAK mode scores **84.2% R@5 vs raw mode's 96.6%** on
|
||||
> LongMemEval — a 12.4 point regression. The honest framing is: AAAK is
|
||||
> an experimental compression layer that trades fidelity for token
|
||||
> density, and **the 96.6% headline number is from RAW mode, not AAAK**.
|
||||
>
|
||||
> - **"+34% palace boost" was misleading.** That number compares unfiltered
|
||||
> search to wing+room metadata filtering. Metadata filtering is a
|
||||
> standard feature of the underlying vector store, not a novel retrieval
|
||||
> mechanism. Real and useful, but not a moat.
|
||||
>
|
||||
> - **"Contradiction detection"** exists as a separate utility
|
||||
> (`fact_checker.py`) but is not currently wired into the knowledge graph
|
||||
> operations as the README implied.
|
||||
>
|
||||
> - **"100% with Haiku rerank"** is real (we have the result files) but
|
||||
> the rerank pipeline is not in the public benchmark scripts. We're
|
||||
> adding it.
|
||||
>
|
||||
> **What's still true and reproducible:**
|
||||
>
|
||||
> - **96.6% R@5 on LongMemEval in raw mode**, on 500 questions, zero API
|
||||
> calls — independently reproduced on M2 Ultra in under 5 minutes by
|
||||
> [@gizmax](https://github.com/MemPalace/mempalace/issues/39).
|
||||
> - Local, free, no subscription, no cloud, no data leaving your machine.
|
||||
> - The architecture (wings, rooms, closets, drawers) is real and useful,
|
||||
> even if it's not a magical retrieval boost.
|
||||
>
|
||||
> **What we're doing:**
|
||||
>
|
||||
> 1. Rewriting the AAAK example with real tokenizer counts and a scenario
|
||||
> where AAAK actually demonstrates compression
|
||||
> 2. Adding `mode raw / aaak / rooms` clearly to the benchmark
|
||||
> documentation so the trade-offs are visible
|
||||
> 3. Wiring `fact_checker.py` into the KG ops so the contradiction
|
||||
> detection claim becomes true
|
||||
> 4. Pinning the vector store dependency to a tested range (issue #100),
|
||||
> fixing the shell injection in hooks (#110), and addressing the macOS
|
||||
> ARM64 segfault (#74)
|
||||
>
|
||||
> **Thank you to everyone who poked holes in this.** Brutal honest
|
||||
> criticism is exactly what makes open source work, and it's what we asked
|
||||
> for. Special thanks to
|
||||
> [@panuhorsmalahti](https://github.com/MemPalace/mempalace/issues/43),
|
||||
> [@lhl](https://github.com/MemPalace/mempalace/issues/27),
|
||||
> [@gizmax](https://github.com/MemPalace/mempalace/issues/39), and everyone
|
||||
> who filed an issue or a PR in the first 48 hours. We're listening, we're
|
||||
> fixing, and we'd rather be right than impressive.
|
||||
>
|
||||
> — *Milla Jovovich & Ben Sigman*
|
||||
@@ -0,0 +1,768 @@
|
||||
# RFC 002 — Source Adapter Plugin Specification
|
||||
|
||||
- **Status:** Draft
|
||||
- **Tracking issue:** [#989](https://github.com/MemPalace/mempalace/issues/989)
|
||||
- **Related:** [#274](https://github.com/MemPalace/mempalace/issues/274), [#23](https://github.com/MemPalace/mempalace/pull/23), [#169](https://github.com/MemPalace/mempalace/pull/169), [#232](https://github.com/MemPalace/mempalace/pull/232), [#567](https://github.com/MemPalace/mempalace/pull/567), [#98](https://github.com/MemPalace/mempalace/pull/98), [#591](https://github.com/MemPalace/mempalace/pull/591), [#592](https://github.com/MemPalace/mempalace/pull/592), [#702](https://github.com/MemPalace/mempalace/pull/702), [#981](https://github.com/MemPalace/mempalace/issues/981), [#244](https://github.com/MemPalace/mempalace/pull/244), [#419](https://github.com/MemPalace/mempalace/pull/419), [#300](https://github.com/MemPalace/mempalace/pull/300), [#952](https://github.com/MemPalace/mempalace/pull/952), [#389](https://github.com/MemPalace/mempalace/pull/389), [#434](https://github.com/MemPalace/mempalace/pull/434)
|
||||
- **Sibling spec:** [RFC 001 — Storage Backend Plugin Specification](001-storage-backend-plugin-spec.md)
|
||||
- **Spec version:** `1.0`
|
||||
|
||||
## Summary
|
||||
|
||||
A formal contract for MemPalace source adapters so third parties can ship `pip install mempalace-source-<name>` packages (Cursor, OpenCode, git, Slack, Notion, email, calendar, Whisper transcripts, …) that drop into `mempalace mine` without patching core. The spec defines the adapter interface, record shape, metadata schema contract, privacy class, entry-point registration, incremental-ingest semantics, closet integration, a declared-transformation model that replaces the informal "verbatim" promise with a verifiable one, conformance tests, and the refactor of the existing file and conversation miners into first-party adapters on the same contract.
|
||||
|
||||
RFC 001 formalized the write side (where drawers are stored). This RFC formalizes the read side (where content comes from). Both are required for MemPalace to function as a durable daemon managing heterogeneous palaces across many source types.
|
||||
|
||||
## Motivation
|
||||
|
||||
Six source ingesters are currently in flight, each solving the same problem a different way:
|
||||
|
||||
| PR / Issue | Source | Mechanism |
|
||||
|---|---|---|
|
||||
| [#274](https://github.com/MemPalace/mempalace/issues/274) | Cursor | `workspaceStorage/*.vscdb` SQLite extraction |
|
||||
| [#23](https://github.com/MemPalace/mempalace/pull/23) | OpenCode | SQLite session database |
|
||||
| [#169](https://github.com/MemPalace/mempalace/pull/169) | Pi agent | JSONL session normalizer |
|
||||
| [#232](https://github.com/MemPalace/mempalace/pull/232) | Cursor (JSONL variant) | JSONL normalizer |
|
||||
| [#567](https://github.com/MemPalace/mempalace/pull/567), [#98](https://github.com/MemPalace/mempalace/pull/98) | Git | `git log` + `gh pr view` with structured diff summary |
|
||||
| [#591](https://github.com/MemPalace/mempalace/pull/591), [#592](https://github.com/MemPalace/mempalace/pull/592) | Delphi Oracle | Real-time intelligence signals |
|
||||
| [#702](https://github.com/MemPalace/mempalace/pull/702) | Cursor + factory.ai | Combined session miners |
|
||||
|
||||
Plus three ingesters already grafted into core:
|
||||
|
||||
- `mempalace/miner.py` — filesystem project miner, fixed char-window chunking, keyword hall routing
|
||||
- `mempalace/convo_miner.py` — chat transcript miner with exchange-pair chunking
|
||||
- `mempalace/normalize.py` — format detection for four chat-export shapes (Claude Code JSONL, Codex JSONL, Claude.ai / ChatGPT / Slack JSON)
|
||||
|
||||
Plus one open proposal for a different ingest semantic:
|
||||
|
||||
- [#981](https://github.com/MemPalace/mempalace/issues/981) — path-level descriptions: mine metadata-as-content instead of raw bytes for matched paths. This is a legitimate third ingest mode (alongside chunked-content and whole-record) that the current architecture has no home for.
|
||||
|
||||
Each contributor has reinvented source discovery, source-item identity, incremental-ingest bookkeeping, metadata shape, and chunking strategy. Format detection for new chat exports lands in `normalize.py` as one more branch in an `if` chain. There is no shared abstraction, no conformance suite, and no contract new adapter authors can build against.
|
||||
|
||||
This is the same situation RFC 001 addresses for storage backends: a pattern that emerged organically, now needs a specification so the community can contribute cleanly and enterprises can build against a stable surface.
|
||||
|
||||
### Why this matters beyond developer tooling
|
||||
|
||||
The adapter pattern is source-agnostic. What has so far shown up as "Cursor transcripts" and "git commits" generalizes to:
|
||||
|
||||
- **Knowledge work** — Notion, Obsidian, Logseq, Google Docs, iA Writer, Zettlr
|
||||
- **Communications** — Slack, Discord, Teams, Signal backups, mbox/eml email, iMessage
|
||||
- **Research** — arXiv PDFs, Zotero libraries, bookmarked articles, Kindle highlights, web archives
|
||||
- **Creator workflows** — YouTube captions, podcast transcripts (Whisper/Deepgram), Descript projects
|
||||
- **Regulated domains** — medical records, legal filings, financial statements (all gated on §6 privacy class)
|
||||
|
||||
Enterprises key on their own domain metadata — `repo/PR/SHA` for engineering, `patient/encounter/CPT` for healthcare, `case/docket/jurisdiction` for legal. The schema lives in the adapter; the content lives in the drawer. This is how structured-data use cases are served without violating the byte-preservation commitments adapters make.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A source adapter ships as a standalone Python package; `pip install mempalace-source-<name>` is sufficient to use it.
|
||||
2. `mempalace mine` and the MCP mine tool are source-agnostic — all extraction goes through registered adapters. No `if source_type == 'foo'` branches in core.
|
||||
3. Content transformations are **declared** (§1.4): each adapter advertises the set of transformations it applies to source bytes. Byte-preserving adapters declare the empty set. Consumers can programmatically determine what happened to their data.
|
||||
4. Incremental ingest is cheap and correct: re-running mine only touches items whose source-side version changed, using the palace itself as the cursor (no sidecar).
|
||||
5. Each adapter declares a structured metadata schema. Enterprises index and filter on that schema. Core is schema-agnostic beyond the universal fields in §5.1.
|
||||
6. The existing `miner.py` and `convo_miner.py` become the first two first-party adapters on the new contract. Drawer metadata fields and field names are preserved — the spec adds fields, does not rename them.
|
||||
7. A privacy class is declarable at the adapter boundary so sensitive sources (medical, financial, personal comms) are handled with explicit policy rather than implicit trust.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Defining chunking. Each adapter owns its chunking strategy — tree-sitter for code, exchange-pair for chat, whole-record for a PR. Core does not impose a chunk size.
|
||||
- Defining live-stream / webhook shapes (the Delphi Oracle pattern of continuous signal ingestion). That is a separate future RFC; v1 is pull-mode.
|
||||
- Defining LLM-based structured extraction. Adapters MAY use an LLM; the spec does not mandate or standardize this.
|
||||
- Defining cross-adapter dedup. When the same content appears via two adapters (e.g., a PR body mined via `git` and as a conversation quote mined via `claude-code`), both drawers land. Deduplication policy is a separate concern handled at query time by `searcher.py`.
|
||||
- Defining closet construction. Core continues to build closets from adapter-yielded drawers (§1.7); the closet-building algorithm itself is not part of this spec.
|
||||
|
||||
---
|
||||
|
||||
## 1. Source adapter contract
|
||||
|
||||
### 1.1 Required method
|
||||
|
||||
All adapters implement `BaseSourceAdapter` with a single kwargs-only ingest method:
|
||||
|
||||
```python
|
||||
class BaseSourceAdapter(ABC):
|
||||
@abstractmethod
|
||||
def ingest(
|
||||
self,
|
||||
*,
|
||||
source: SourceRef,
|
||||
palace: PalaceContext,
|
||||
) -> Iterator[IngestResult]:
|
||||
"""Enumerate and extract content from a source.
|
||||
|
||||
Yields a stream of IngestResult values. Lazy adapters yield
|
||||
`SourceItemMetadata` ahead of the drawers for that item, so core
|
||||
can report progress and check `is_current` before the adapter
|
||||
commits to the fetch. Adapters with no lazy-fetch benefit may
|
||||
interleave `SourceItemMetadata` and `DrawerRecord` items freely.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def describe_schema(self) -> AdapterSchema:
|
||||
"""Declare the structured metadata this adapter attaches.
|
||||
|
||||
Returned value is stable for a given adapter version. Enterprises
|
||||
index on this schema; core uses it to validate adapter output.
|
||||
"""
|
||||
```
|
||||
|
||||
The single-method `ingest()` contract was chosen over a `discover` / `extract` split. Most current ingesters have no meaningful laziness benefit (filesystem walking is cheap, transcript normalizing is cheap). Adapters that do (git-mine's `gh pr list` vs `gh pr view`; hypothetical Slack/Notion API) express laziness by yielding `SourceItemMetadata` first and deferring fetch until core confirms staleness via `is_current()`.
|
||||
|
||||
### 1.2 Optional methods (default implementations on the ABC)
|
||||
|
||||
```python
|
||||
def is_current(
|
||||
self,
|
||||
*,
|
||||
item: SourceItemMetadata,
|
||||
existing_metadata: dict | None,
|
||||
) -> bool:
|
||||
"""Return True if the palace already has an up-to-date copy.
|
||||
|
||||
Called by core after querying the palace for existing drawers with
|
||||
matching source_file. The adapter compares its version token against
|
||||
the stored metadata and returns True to skip extraction.
|
||||
|
||||
Default implementation: returns False (always re-extract). Adapters
|
||||
advertising `supports_incremental` override this.
|
||||
"""
|
||||
return False
|
||||
|
||||
def source_summary(self, *, source: SourceRef) -> SourceSummary:
|
||||
"""Describe a source without extracting (e.g., 'git repo mempalace,
|
||||
847 commits, 132 PRs'). Default: returns empty summary."""
|
||||
return SourceSummary(description=self.name)
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
```
|
||||
|
||||
Core's incremental loop (pseudocode):
|
||||
|
||||
```python
|
||||
for result in adapter.ingest(source=source, palace=ctx):
|
||||
if isinstance(result, SourceItemMetadata):
|
||||
existing = ctx.collection.get(where={"source_file": result.source_file}, limit=1)
|
||||
if adapter.is_current(item=result, existing_metadata=existing):
|
||||
ctx.skip_current_item() # adapter stops yielding drawers for this item
|
||||
elif isinstance(result, DrawerRecord):
|
||||
ctx.upsert_drawer(result)
|
||||
```
|
||||
|
||||
### 1.3 Typed records
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class SourceRef:
|
||||
"""A handle to the source a user wants to ingest.
|
||||
|
||||
local_path is for filesystem-rooted sources (project dir, mbox file).
|
||||
uri is for URL-like references (github.com/org/repo, slack://workspace/channel).
|
||||
options carries adapter-specific config (non-secret values only; §M2).
|
||||
"""
|
||||
local_path: str | None = None
|
||||
uri: str | None = None
|
||||
options: dict = field(default_factory=dict)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceItemMetadata:
|
||||
"""Lightweight pointer yielded before drawers for lazy-fetch adapters."""
|
||||
source_file: str # Logical identity — filesystem path, PR URI, etc.
|
||||
version: str # Source-side version token (mtime, commit SHA, ETag, rev id).
|
||||
size_hint: int | None = None # Bytes, if known. Used for progress reporting.
|
||||
route_hint: RouteHint | None = None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DrawerRecord:
|
||||
"""One drawer's worth of content plus metadata."""
|
||||
content: str # Subject to §1.4 declared transformations.
|
||||
source_file: str # Foreign key to SourceItemMetadata.source_file.
|
||||
chunk_index: int = 0 # 0 for single-drawer items; 0..N-1 for chunked items.
|
||||
metadata: dict = field(default_factory=dict) # Flat: str/int/float/bool only. Must conform to adapter schema.
|
||||
route_hint: RouteHint | None = None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteHint:
|
||||
wing: str | None = None
|
||||
room: str | None = None
|
||||
hall: str | None = None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceSummary:
|
||||
description: str
|
||||
item_count: int | None = None
|
||||
|
||||
# IngestResult is the union type adapters yield.
|
||||
IngestResult = SourceItemMetadata | DrawerRecord
|
||||
|
||||
# PalaceContext carries collection handles, palace config, and progress hooks
|
||||
# into the adapter. Full definition in §9 (cleanup prerequisite).
|
||||
```
|
||||
|
||||
### 1.4 Declared transformations
|
||||
|
||||
Adapters cannot silently alter content. Every adapter declares the set of transformations it applies:
|
||||
|
||||
```python
|
||||
class BaseSourceAdapter(ABC):
|
||||
declared_transformations: ClassVar[frozenset[str]] = frozenset()
|
||||
```
|
||||
|
||||
The invariant: **no transformation is applied that is not declared in this set**. Adapters declaring `frozenset()` are byte-preserving end-to-end (modulo the read, which may itself involve `utf8_replace_invalid` — see below).
|
||||
|
||||
Reserved transformation names (v1):
|
||||
|
||||
| Name | Meaning |
|
||||
|---|---|
|
||||
| `utf8_replace_invalid` | Undecodable bytes replaced with U+FFFD on read (equivalent to `open(..., errors="replace")`). |
|
||||
| `newline_normalize` | CRLF / CR converted to LF. |
|
||||
| `whitespace_trim` | Leading / trailing whitespace stripped at a record boundary. |
|
||||
| `whitespace_collapse_internal` | Runs of three or more blank lines collapsed to two. |
|
||||
| `line_trim` | Each line individually stripped of leading / trailing whitespace. |
|
||||
| `line_join_spaces` | Adjacent lines joined with single spaces, newlines discarded. |
|
||||
| `blank_line_drop` | Empty lines between non-empty lines dropped. |
|
||||
| `strip_tool_chrome` | System tags, hook output, tool UI chrome removed (see `normalize.strip_noise`). |
|
||||
| `tool_result_truncate` | Tool output heads/tails kept; middle replaced with a marker string. |
|
||||
| `spellcheck_user` | User turns rewritten by spellcheck. |
|
||||
| `synthesized_marker` | Adapter inserts its own strings (e.g., `[N lines omitted]`, `[registry] …`, Slack provenance footer). |
|
||||
| `speaker_role_assignment` | Multi-party speakers alternately assigned `user` / `assistant` roles (Slack). |
|
||||
| `tool_result_omitted` | Some tool outputs fully omitted from transcript (e.g., Read/Edit/Write results in `normalize._format_tool_result`). |
|
||||
|
||||
Adapters MAY define their own transformation names for behaviors the reserved list does not cover. Third-party names SHOULD be prefixed with the adapter name to avoid collisions (e.g., `cursor.composer_ordering`).
|
||||
|
||||
**Capability derivation:**
|
||||
- `byte_preserving` — declared_transformations is empty AND output bytes equal input bytes for any source the adapter can read. Advertised via the `byte_preserving` capability (§2.1). MUST be verified by §7.2 round-trip test.
|
||||
- `declared_lossy` — declared_transformations is non-empty. The adapter's output is reproducible from source by applying *only* the declared transformations. MUST be verified by §7.3 declared-transformation test.
|
||||
|
||||
**Existing code mapping (for the cleanup PR):**
|
||||
|
||||
| Module | Declared transformations |
|
||||
|---|---|
|
||||
| `filesystem` (current `miner.py`) | `utf8_replace_invalid`, `whitespace_trim` |
|
||||
| `conversations` (current `convo_miner.py` + `normalize.py`) | `utf8_replace_invalid`, `newline_normalize`, `line_trim`, `line_join_spaces`, `blank_line_drop`, `whitespace_collapse_internal`, `strip_tool_chrome`, `tool_result_truncate`, `tool_result_omitted`, `spellcheck_user`, `synthesized_marker`, `speaker_role_assignment` |
|
||||
|
||||
The filesystem adapter is nearly byte-preserving today; the conversations adapter is extensively transformed. Both are honest after this spec lands because both are fully declared.
|
||||
|
||||
This replaces the MISSION.md promise of "verbatim always" with a stronger one: every adapter publishes what it does to your data, and the conformance suite verifies it hasn't lied. "Verbatim" becomes a capability some adapters hold (byte_preserving), not a global claim about a lossy pipeline.
|
||||
|
||||
### 1.5 Three ingest modes
|
||||
|
||||
A single adapter declares one or more of three modes via a class attribute:
|
||||
|
||||
```python
|
||||
class BaseSourceAdapter(ABC):
|
||||
supported_modes: ClassVar[frozenset[Literal["chunked_content", "whole_record", "metadata_only"]]]
|
||||
```
|
||||
|
||||
| Mode | Content origin |
|
||||
|---|---|
|
||||
| `chunked_content` | Source bytes, split into chunks the adapter chooses (current filesystem behavior). |
|
||||
| `whole_record` | Source bytes, one drawer per source item (e.g., PR → 1 drawer). |
|
||||
| `metadata_only` | Synthesized description of a source item (absorbs #981). The description bytes are authored by the user or adapter, not the source. Declared transformations (§1.4) do not apply — content is not derived from source bytes. |
|
||||
|
||||
`metadata_only` resolves #981: description-mode matches a path pattern and produces one drawer whose content is the user-authored description rather than the file contents. Conformance tests (§7.2, §7.3) skip `metadata_only` records.
|
||||
|
||||
An adapter MAY support multiple modes and select per-item; the per-item mode is recorded in `metadata["ingest_mode"]` (§5.1). This field already exists on conversation drawers (`convo_miner.py:346`) and is the only existing field whose semantics this spec extends rather than preserves.
|
||||
|
||||
### 1.6 Chunking delegation
|
||||
|
||||
Core does not impose chunking. `miner.py`'s 800-character sliding window is the filesystem adapter's default for unknown file types — not a contract. Adapter authors choose what makes sense:
|
||||
|
||||
- Code files → tree-sitter function/class boundaries (future enhancement to the filesystem adapter).
|
||||
- Chat transcripts → exchange pairs (current `convo_miner.py` behavior).
|
||||
- PRs → whole-record (current `git-mine` behavior in #567).
|
||||
- PDFs → page or section.
|
||||
- Voice transcripts → speaker turn.
|
||||
|
||||
The sole cross-adapter requirement for `chunked_content` mode: chunks for a given `source_file`, re-assembled in `chunk_index` order and accounting for declared transformations in §1.4, reproduce the adapter's internal representation of the source. The conformance suite verifies this.
|
||||
|
||||
### 1.7 Closet integration
|
||||
|
||||
Closets are the AAAK-compressed index layer (`palace.build_closet_lines`, `upsert_closet_lines`) that points to drawer content and enables LLM-scale scanning without reading every drawer. Closet-building is not an adapter concern:
|
||||
|
||||
- **Core builds closets** from adapter-yielded drawers as a post-step, via the existing `palace.py` helpers. Adapters do not call these APIs.
|
||||
- **Adapters MAY emit closet hints** in drawer metadata via a flat `;`-joined string:
|
||||
```python
|
||||
metadata["closet_hints"] = "decided GraphQL; migrated to Postgres; fixed PR-567"
|
||||
```
|
||||
Core splits on `;` and feeds these as candidate topics alongside the content-scanned ones in `build_closet_lines`. The git adapter can hint decision-signal quotes that raw content-scanning would miss; the conversations adapter can hint section headers; the filesystem adapter has no need and omits the field.
|
||||
- **metadata_only drawers get closets too.** Core builds them from the synthesized description content the same way it builds closets for any other drawer. This is how #981's path-level descriptions become searchable.
|
||||
- **Closet purging** remains keyed on `source_file` (`purge_file_closets` in `palace.py:221`). Adapters' source_file values must be stable so purge is correct on re-ingest.
|
||||
|
||||
Current `convo_miner.py` does not build closets for conversation drawers — an existing gap. The cleanup PR (§9) routes the conversations adapter through the same post-step closet builder as filesystem, closing the gap as a side effect.
|
||||
|
||||
---
|
||||
|
||||
## 2. Adapter contract
|
||||
|
||||
### 2.1 Identity and capabilities
|
||||
|
||||
```python
|
||||
class BaseSourceAdapter(ABC):
|
||||
name: ClassVar[str] # "filesystem", "cursor", "git", "slack", ...
|
||||
spec_version: ClassVar[str] = "1.0"
|
||||
adapter_version: ClassVar[str] # Independent of spec_version; recorded on every drawer.
|
||||
capabilities: ClassVar[frozenset[str]]
|
||||
supported_modes: ClassVar[frozenset[str]] # Per §1.5.
|
||||
declared_transformations: ClassVar[frozenset[str]] # Per §1.4.
|
||||
default_privacy_class: ClassVar[str] # Per §6.
|
||||
```
|
||||
|
||||
Defined capability tokens (v1):
|
||||
|
||||
| Token | Meaning |
|
||||
|---|---|
|
||||
| `byte_preserving` | `declared_transformations` is empty AND extracted content equals source bytes. |
|
||||
| `supports_incremental` | Implements `is_current()` meaningfully; `ingest()` respects `ctx.skip_current_item()`. |
|
||||
| `supports_structured_metadata` | Attaches fields beyond §5.1 universals. |
|
||||
| `supports_entity_hints` | Emits entity hints via `metadata["entity_hints_json"]` (§5.4). |
|
||||
| `supports_kg_triples` | Writes knowledge-graph triples directly to the SQLite KG (§5.5). |
|
||||
| `supports_closet_hints` | Emits `metadata["closet_hints"]` (§1.7). |
|
||||
| `requires_auth` | Needs credentials at runtime (env vars — §4.2). |
|
||||
| `requires_external_service` | Needs a running service (Slack API, email server). |
|
||||
| `requires_local_tool` | Needs a local binary (`gh`, `rg`, `whisper`). |
|
||||
| `adapter_owns_routing` | Returns authoritative `RouteHint` values from `ingest()` that core uses as-is (§G3 / §2.5). |
|
||||
| `respects_privacy_class` | Honors §6 privacy-class filtering. |
|
||||
|
||||
Capability tokens are free-form strings; third-party adapters MAY declare novel tokens for their ecosystem. Core only inspects the above.
|
||||
|
||||
### 2.2 Source references
|
||||
|
||||
See `SourceRef` in §1.3. The shape is deliberately open — adapters parse `uri` and `options` as they see fit. Core does not canonicalize URIs.
|
||||
|
||||
**Secrets in `SourceRef.options`:** credentials MUST NOT be placed in `options`. The spec reserves `options` for non-secret values (paths, filters, date ranges). Secrets come from env vars per §4.2. An adapter that reads a credential from `options` violates the spec and MUST be rejected by the conformance suite.
|
||||
|
||||
### 2.3 Lifecycle
|
||||
|
||||
1. `__init__`: lightweight. No I/O, no network, no credential fetch.
|
||||
2. First call to `ingest`: may open resources. All I/O is lazy.
|
||||
3. `close()`: releases all resources. After `close()`, further calls MUST raise `AdapterClosedError`.
|
||||
|
||||
### 2.4 Concurrency
|
||||
|
||||
An adapter instance is long-lived and serves many mine operations. Adapters MUST be thread-safe for concurrent `ingest` calls across different `SourceRef` values. MemPalace core serializes calls within a single `SourceRef` unless an adapter advertises `supports_parallel_ingest` (not in v1 — reserved for v1.1).
|
||||
|
||||
### 2.5 Routing
|
||||
|
||||
Routing is the adapter's responsibility. The filesystem adapter reads `mempalace.yaml` (hall keywords, rooms list) via `MempalaceConfig()` and returns `RouteHint(wing=..., room=..., hall=...)` on each drawer. This relocates `detect_room()` and `detect_hall()` (currently in `miner.py` and `convo_miner.py`) into their respective adapters.
|
||||
|
||||
Order of precedence for routing:
|
||||
1. Explicit `--wing` / `--room` CLI flags → passed through `SourceRef.options` → adapter honors verbatim.
|
||||
2. Palace config match (`mempalace.yaml` hall keywords, room keywords) → adapter computes.
|
||||
3. Adapter-internal fallback (e.g., filesystem adapter falls back to `"general"` room).
|
||||
|
||||
Adapters advertising `adapter_owns_routing` return the final answer; core uses it verbatim. Adapters not advertising it return None and core applies a generic fallback router (writing to wing `default`, room `general`, hall `general`). Absent any adapter, this is how `mempalace mine` behaves today.
|
||||
|
||||
### 2.6 Incremental ingest
|
||||
|
||||
`is_current()` is the incremental-ingest primitive. The palace itself is the cursor — no separate persisted state. Correctness requirements:
|
||||
|
||||
- The adapter's `SourceItemMetadata.source_file` MUST be stable across re-ingests of the same logical item. Filesystem adapter uses the absolute path (as today). Git adapter uses a URI shape like `github.com/org/repo#pr=567` or `github.com/org/repo#commit=abc123`.
|
||||
- `is_current()` returns True when the stored metadata matches the adapter's current version token. The default implementation returns False (always re-extract) — adapters advertising `supports_incremental` override.
|
||||
- Deletion tombstones: an adapter MAY yield a `SourceItemMetadata(source_file=..., version="__deleted__")` entry — core purges drawers with matching `source_file` and builds no new drawers for that item. Advertised via `supports_deletion_tombstones`.
|
||||
- Adapters without `supports_incremental` ignore `is_current()` and fully re-extract. Core logs a warning.
|
||||
|
||||
### 2.7 Errors
|
||||
|
||||
- `SourceNotFoundError` — the `SourceRef` does not resolve.
|
||||
- `AuthRequiredError` — adapter needs credentials; raises with a message describing which env vars to set.
|
||||
- `AdapterClosedError` — method called after `close()`.
|
||||
- `TransformationViolationError` — conformance suite raises this when the content round-trip requires an undeclared transformation.
|
||||
- `SchemaConformanceError` — a `DrawerRecord.metadata` is missing required fields declared in `describe_schema()` or violates declared types.
|
||||
|
||||
---
|
||||
|
||||
## 3. Registration and discovery
|
||||
|
||||
### 3.1 Entry points (primary mechanism)
|
||||
|
||||
Third-party adapters ship as installable packages:
|
||||
|
||||
```toml
|
||||
# pyproject.toml of mempalace-source-cursor
|
||||
[project.entry-points."mempalace.sources"]
|
||||
cursor = "mempalace_source_cursor:CursorAdapter"
|
||||
```
|
||||
|
||||
MemPalace discovers adapters at process start via `importlib.metadata.entry_points(group="mempalace.sources")`.
|
||||
|
||||
### 3.2 In-tree registry (secondary)
|
||||
|
||||
```python
|
||||
from mempalace.sources.registry import register
|
||||
|
||||
register("my-experimental-adapter", MyAdapter)
|
||||
```
|
||||
|
||||
Entry-point discovery and explicit `register()` populate the same registry. Explicit registration wins on name conflict.
|
||||
|
||||
### 3.3 Selection (explicit only — no auto-detect)
|
||||
|
||||
Unlike storage backends (RFC 001 §3.3), source adapters are never auto-detected. The user selects the adapter explicitly:
|
||||
|
||||
```bash
|
||||
mempalace mine --source cursor ~/ # explicit adapter
|
||||
mempalace mine --source git /path/to/repo # explicit adapter
|
||||
mempalace mine --source filesystem /path/to/project # explicit adapter
|
||||
mempalace mine /path/to/project # implicit: filesystem (default)
|
||||
```
|
||||
|
||||
The default when no `--source` is given is `filesystem`, preserving current `mempalace mine <path>` behavior.
|
||||
|
||||
**Backwards compatibility with `--mode`.** Current `cli.py:517-519` exposes `--mode {projects,convos}`. This spec maps:
|
||||
- `--mode projects` → `--source filesystem` (the new default)
|
||||
- `--mode convos` → `--source conversations`
|
||||
|
||||
`--mode` stays as a deprecated alias through v4.x with a deprecation warning on use; removed in v5.0.
|
||||
|
||||
Auto-detection would be hostile — a directory containing a `.git` folder, a `workspaceStorage/` subdir, and an `mbox` file is not a signal of user intent.
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration
|
||||
|
||||
### 4.1 Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"sources": {
|
||||
"my-cursor": {
|
||||
"type": "cursor",
|
||||
"workspace_storage": "~/Library/Application Support/Cursor/User/workspaceStorage"
|
||||
},
|
||||
"my-git": {
|
||||
"type": "git",
|
||||
"repos": ["/projects/mempalace", "/projects/site"]
|
||||
}
|
||||
},
|
||||
"palaces": {
|
||||
"work": {
|
||||
"sources": ["my-git"],
|
||||
"privacy_floor": "internal"
|
||||
},
|
||||
"personal": {
|
||||
"sources": ["my-cursor"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Single-user local mode: config is optional. `mempalace mine <path>` with no config uses the `filesystem` adapter and defaults.
|
||||
|
||||
### 4.2 Environment variables
|
||||
|
||||
- `MEMPALACE_SOURCE_<NAME>_*` — per-adapter secrets and connection info. Examples: `MEMPALACE_SOURCE_SLACK_TOKEN`, `MEMPALACE_SOURCE_NOTION_API_KEY`, `MEMPALACE_SOURCE_GIT_GITHUB_TOKEN`.
|
||||
- Secrets MUST be readable from env vars; config files carry structure, env vars carry credentials. Same rule as RFC 001 §4.2.
|
||||
|
||||
### 4.3 Adapter-specific options
|
||||
|
||||
`SourceRef.options` is a free-form dict of non-secret values (§2.2). Each adapter documents its accepted keys. Unknown keys MUST be ignored (forward compatibility); the adapter MAY log a warning.
|
||||
|
||||
---
|
||||
|
||||
## 5. Metadata schema contract
|
||||
|
||||
### 5.1 Universal fields
|
||||
|
||||
Existing drawer metadata fields are preserved — the spec adds the following:
|
||||
|
||||
| New field | Type | Added by | Purpose |
|
||||
|---|---|---|---|
|
||||
| `adapter_name` | `str` | core, from `BaseSourceAdapter.name` | Which registered source produced this drawer. |
|
||||
| `adapter_version` | `str` | adapter | Adapter's own version (distinct from palace `normalize_version`). Enables re-extract workflows targeted at drawers from a known-buggy adapter version. |
|
||||
| `privacy_class` | `str` | adapter default, config override | Per §6. |
|
||||
|
||||
Existing fields retain their current semantics (verified against `miner.py:542-561` and `convo_miner.py:338-350`):
|
||||
|
||||
| Existing field | Role in the spec |
|
||||
|---|---|
|
||||
| `source_file` | Functions as the adapter's source-item identifier. Adapter defines the shape — a filesystem path for filesystem, a URI like `github.com/org/repo#pr=123` for git. MUST be stable across re-ingests of the same logical item. |
|
||||
| `source_mtime` | Functions as the source-item version for filesystem. Adapters without mtime semantics MAY omit this field and use a different version discriminator (e.g., commit SHA in a separate `metadata["commit_sha"]` field); the spec only requires that `is_current()` can decide staleness from the stored metadata. |
|
||||
| `filed_at` | When the record was written. ISO-8601 string. |
|
||||
| `added_by` | Agent name (e.g., `lumi`, `claude-code`). Orthogonal to `adapter_name` — the agent is *who* triggered mining; the adapter is *how* data was extracted. |
|
||||
| `wing`, `room`, `hall` | Palace routing. Populated by adapter per §2.5. |
|
||||
| `chunk_index` | Per §1.6. Always 0 for `whole_record` / `metadata_only`. |
|
||||
| `normalize_version` | Palace-wide schema version (currently `palace.py:50`). Unchanged. Separate from `adapter_version`. |
|
||||
| `entities` | Semicolon-joined candidate entity names. Already flat; kept flat (§5.4 replacement). |
|
||||
| `ingest_mode` | Per §1.5. Already on conversation drawers; added to filesystem drawers by the cleanup PR. |
|
||||
| `extract_mode` | Conversation-adapter-specific (`exchange` vs `general`). Moves into the conversations adapter's declared schema per §5.2. |
|
||||
|
||||
**Nothing is renamed. Nothing is removed.** The spec formalizes the shape ingesters already converge on. Existing `where={"source_file": ...}` queries in `searcher.py`, `palace.py`, and callers keep working.
|
||||
|
||||
**Chroma metadata constraint:** all metadata values MUST be `str | int | float | bool`. No lists, no nested dicts. This matches RFC 001 §1.4 and the underlying ChromaDB contract. Structured side-data goes to the SQLite knowledge graph (§5.5) or to a declared flat JSON-encoded string field (§5.4).
|
||||
|
||||
### 5.2 Adapter schemas
|
||||
|
||||
Each adapter returns an `AdapterSchema` from `describe_schema()`:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class AdapterSchema:
|
||||
fields: dict[str, FieldSpec] # Keyed by metadata key.
|
||||
version: str
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FieldSpec:
|
||||
type: Literal["string", "int", "float", "bool", "delimiter_joined_string", "json_string"]
|
||||
required: bool
|
||||
description: str
|
||||
indexed: bool = False # Hint to backends that can build indexes (RFC 001 §2.1).
|
||||
# delimiter_joined_string: the delimiter character (default ";").
|
||||
delimiter: str = ";"
|
||||
# json_string: the JSON schema of the encoded object (informational only).
|
||||
json_schema: dict | None = None
|
||||
```
|
||||
|
||||
`delimiter_joined_string` covers the `entities` shape (current `;`-joined list of names). `json_string` is the escape hatch for adapters needing to pack nested data — the value stored is still a single flat `str` from Chroma's perspective, but the adapter is allowed to document its parsed shape.
|
||||
|
||||
Example for a hypothetical `slack` adapter:
|
||||
|
||||
```python
|
||||
AdapterSchema(
|
||||
version="1.0",
|
||||
fields={
|
||||
"channel_name": FieldSpec(type="string", required=True, description="Slack channel name", indexed=True),
|
||||
"channel_id": FieldSpec(type="string", required=True, description="Slack channel ID"),
|
||||
"thread_ts": FieldSpec(type="string", required=False, description="Thread root timestamp"),
|
||||
"author_id": FieldSpec(type="string", required=True, description="Slack user ID", indexed=True),
|
||||
"author_name": FieldSpec(type="string", required=True, description="Display name at extraction time"),
|
||||
"reactions": FieldSpec(type="delimiter_joined_string", required=False, description="Emoji shortcodes"),
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### 5.3 Enterprise keying
|
||||
|
||||
The adapter schema is the stable surface enterprises filter on. A support team querying the palace for `channel_id = "C01234"` does not care about ChromaDB's internal representation. The schema field is declared by the adapter, indexed by the backend (RFC 001 §2.1 `supports_metadata_filters`), and exposed through the existing `where=` clause.
|
||||
|
||||
This is how "structured data" serves company use cases without breaking transformation guarantees: declared-transformation content in the drawer, structured fields in the metadata, schema declared by the adapter, filtering done by the backend.
|
||||
|
||||
### 5.4 Entity hints (optional)
|
||||
|
||||
Adapters with `supports_entity_hints` MAY include:
|
||||
|
||||
```python
|
||||
metadata["entity_hints_json"] = '[{"type":"person","name":"Milla Jovovich","confidence":0.95,"offset":120},{"type":"project","name":"MemPalace","confidence":1.0,"offset":0}]'
|
||||
```
|
||||
|
||||
The value is a JSON-encoded string (type `json_string` in the adapter schema). Core parses on read and feeds into `mempalace/entity_detector.py` as a prior: hints with `confidence >= 0.9` bypass the heuristic detector; lower-confidence hints feed into it as candidates.
|
||||
|
||||
This is additive to the existing flat `entities` field — entity_hints carries structure (type, confidence, offset); `entities` remains the Chroma-indexable flat string. An adapter that produces entity_hints MUST also populate `entities` as the flat name-only projection, so existing filter queries keep working.
|
||||
|
||||
### 5.5 Knowledge-graph triples (optional)
|
||||
|
||||
Adapters with `supports_kg_triples` write directly to the SQLite knowledge graph via `mempalace/knowledge_graph.py` — **not** to drawer metadata. Chroma cannot store structured triples; the KG already exists for this purpose.
|
||||
|
||||
The adapter calls the existing `KnowledgeGraph.add_triple()` (signature verified against `mempalace/knowledge_graph.py:130`):
|
||||
|
||||
```python
|
||||
palace.kg.add_triple(
|
||||
subject="Ben",
|
||||
predicate="committed",
|
||||
obj="PR-567", # `object` is a Python builtin — the API uses `obj`.
|
||||
valid_from="2026-03-12",
|
||||
confidence=1.0,
|
||||
source_file=drawer.source_file, # Existing provenance parameter.
|
||||
)
|
||||
```
|
||||
|
||||
Drawer metadata includes a flat counter — `metadata["kg_triples_count"]: int` — so search consumers can see at a glance that KG side-data exists for a drawer without hitting SQLite.
|
||||
|
||||
The existing API has `source_closet` and `source_file` provenance parameters but no `source_drawer_id` or `adapter_name`. The cleanup PR (§9) should add these two optional parameters to `add_triple()` so adapter-written triples can be traced back to (a) the specific drawer that produced them and (b) the adapter that authored them — necessary for re-extraction workflows. Until that lands, adapters use `source_file` as the provenance key and record adapter authorship via a separate table or a predicate naming convention (e.g., `adapter:git:committed`).
|
||||
|
||||
This aligns with the existing architecture in `CLAUDE.md` ("Knowledge Graph: ENTITY → PREDICATE → ENTITY with valid_from / valid_to dates") — the RFC formalizes the adapter-side write path.
|
||||
|
||||
### 5.6 Source encoding and newline
|
||||
|
||||
Current ingesters handle encoding lossily (`errors="replace"` in `miner.py:595` and `normalize.py:124`) and do not record original encoding. The spec does **not** require per-drawer `source_encoding` / `source_newline` — most runs are uniform UTF-8 / LF, and storing the same value on every drawer wastes bytes.
|
||||
|
||||
Instead: adapters that handle non-UTF-8 or non-LF sources record the values once on the adapter's `SourceSummary` and per-drawer only when a specific drawer diverges from the adapter default. The `utf8_replace_invalid` declared transformation (§1.4) already communicates that lossy decoding happened; specific drawer-level provenance is opt-in.
|
||||
|
||||
---
|
||||
|
||||
## 6. Privacy class
|
||||
|
||||
### 6.1 Defined levels
|
||||
|
||||
| Level | Meaning | Example sources |
|
||||
|---|---|---|
|
||||
| `public` | Content intended for public consumption. | arXiv papers, public GitHub repos, published blogs. |
|
||||
| `internal` | Organizational content, not for public disclosure. | Corporate Slack, internal Notion, private git repos. |
|
||||
| `pii_potential` | May contain personally identifiable information. | Email, iMessage, Claude/ChatGPT transcripts. |
|
||||
| `sensitive` | Known to contain PII, financial, or health data. | Medical records, financial statements, legal filings. |
|
||||
| `secrets_possible` | May contain credentials or secrets. | Git history, environment dumps, CI logs. |
|
||||
|
||||
An adapter declares a default on `BaseSourceAdapter.default_privacy_class`. Users MAY override per-source in config.
|
||||
|
||||
### 6.2 Enforcement
|
||||
|
||||
- Each palace declares a `privacy_floor`. Drawers above the floor (equal to or laxer) are admitted; drawers below are rejected at write time and surfaced in a `rejected` list on the CLI and MCP tool.
|
||||
- **Default floor: none** — v1 accepts all levels unless the palace explicitly configures a floor. This keeps the single-user local default low-friction (users who run `mempalace mine` on a git repo expect `secrets_possible` drawers to land). Enterprise deployments MUST set a floor; docs for regulated-domain setup will recommend starting strict and relaxing as needed.
|
||||
- Search results surface `privacy_class` in result metadata. MCP tool wrappers MAY redact results above a caller-declared ceiling.
|
||||
- `secrets_possible` drawers SHOULD pass through a secrets-scan pre-index hook when one is available. PR #389 (sensitive content scanner) is the expected enforcement mechanism for v1; until it lands, `secrets_possible` is a label without automated scanning. The label is still useful — it enables floor-based rejection and alerts downstream consumers.
|
||||
- The privacy class is recorded in drawer metadata and cannot be downgraded without a migration log entry, matching RFC 001's embedder-identity pattern.
|
||||
|
||||
Privacy class is how a regulated-domain deployment (medical, legal, financial) can use MemPalace safely. Without it, flexible ingest becomes a liability; with it, ingest is scoped by policy.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing contract
|
||||
|
||||
### 7.1 The abstract suite
|
||||
|
||||
MemPalace ships `mempalace.sources.testing.AbstractSourceAdapterContractSuite` — a pytest mixin. Every adapter package ships a concrete subclass:
|
||||
|
||||
```python
|
||||
from mempalace.sources.testing import AbstractSourceAdapterContractSuite
|
||||
|
||||
class TestCursorAdapter(AbstractSourceAdapterContractSuite):
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
return CursorAdapter()
|
||||
|
||||
@pytest.fixture
|
||||
def fixture_source(self, tmp_path):
|
||||
"""Build a minimal Cursor workspaceStorage fixture."""
|
||||
...
|
||||
return SourceRef(local_path=str(tmp_path))
|
||||
|
||||
@pytest.fixture
|
||||
def canonical_source_bytes(self, fixture_source):
|
||||
"""Return a mapping of source_file -> authoritative bytes.
|
||||
|
||||
For filesystem sources: the file's raw bytes.
|
||||
For SQLite sources: the extracted value column bytes for each row.
|
||||
For API sources: the canonical HTTP response body bytes.
|
||||
|
||||
Adapter-defined — the adapter knows what its 'source bytes' are.
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
The suite covers:
|
||||
|
||||
- `ingest` yields items with stable `source_file` and well-formed `version`.
|
||||
- `is_current()` returns True when metadata matches, False when it differs.
|
||||
- `close()` releases resources; subsequent calls raise `AdapterClosedError`.
|
||||
- Unicode content and unicode identifiers are preserved end-to-end.
|
||||
- Large-source handling: 10k+ items ingest without loading all into memory.
|
||||
- Error paths: `SourceNotFoundError`, `AuthRequiredError` raise with correct types.
|
||||
- `SourceRef.options` MUST NOT contain secrets — the adapter raises if it detects a value matching a common-secret pattern (GitHub token prefix, Slack token prefix, etc.). Advisory test, not blocking.
|
||||
|
||||
### 7.2 Byte-preserving round-trip (for `byte_preserving` adapters only)
|
||||
|
||||
Required for adapters advertising `byte_preserving`:
|
||||
|
||||
```python
|
||||
def test_byte_preserving_round_trip(self, adapter, fixture_source, canonical_source_bytes):
|
||||
"""Concatenated chunks must equal the canonical source bytes.
|
||||
|
||||
For each source_file in the fixture:
|
||||
1. Read canonical_source_bytes[source_file].
|
||||
2. Collect all DrawerRecords for that source_file from adapter.ingest(...).
|
||||
Skip metadata_only drawers (§1.5).
|
||||
3. Sort by chunk_index.
|
||||
4. Concatenate record.content values.
|
||||
5. Assert equality with the canonical bytes (UTF-8 decoded).
|
||||
"""
|
||||
```
|
||||
|
||||
Failure raises `TransformationViolationError`.
|
||||
|
||||
### 7.3 Declared-transformation round-trip (for `declared_lossy` adapters)
|
||||
|
||||
Required for adapters with non-empty `declared_transformations`:
|
||||
|
||||
```python
|
||||
def test_declared_transformation_round_trip(self, adapter, fixture_source, canonical_source_bytes):
|
||||
"""Adapter output must be reproducible by applying ONLY declared transformations.
|
||||
|
||||
1. For each source_file, read canonical_source_bytes.
|
||||
2. Apply each declared transformation in declared_transformations to the bytes,
|
||||
in the order declared by the adapter, using the reference implementations
|
||||
in mempalace.sources.transforms.
|
||||
3. Compare the result to the concatenated record.content values.
|
||||
4. If they differ, the adapter has applied a transformation it did not declare.
|
||||
Raise TransformationViolationError.
|
||||
"""
|
||||
```
|
||||
|
||||
For transformations not in the reserved list (§1.4) — adapter-custom names — the adapter MUST provide a reference implementation callable under `mempalace.sources.transforms.<adapter_name>_<transform_name>`. The conformance suite imports and applies it. Undiscoverable custom transforms fail the test.
|
||||
|
||||
### 7.4 Schema conformance
|
||||
|
||||
A generator-based property test validates that every record yielded by `ingest` across the fixture source has metadata matching `describe_schema()`. Missing required fields, wrong types, or (in strict mode) undeclared fields fail the test.
|
||||
|
||||
### 7.5 Note on current corpus
|
||||
|
||||
No existing test in `tests/` asserts byte-preservation or declared-transformation correctness (verified via grep of `tests/` for `verbatim|byte.?preserv|round.?trip`). This RFC's conformance suite introduces the first such coverage. The existing MISSION.md claim of "verbatim always" is a social contract until this lands; afterward it becomes a machine-verified property of adapters that declare `byte_preserving`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Versioning and compatibility
|
||||
|
||||
- `BaseSourceAdapter.spec_version` declares which spec version an adapter implements.
|
||||
- MemPalace refuses to load an adapter declaring a different major spec version.
|
||||
- Minor spec versions are additive: new optional methods, new capability tokens, new reserved transformation names, new universal metadata fields with sensible defaults.
|
||||
- Adapters MAY declare their own `adapter_version` independent of the spec version; this is recorded on every drawer (§5.1) and enables "this drawer was extracted by cursor-adapter 0.3; 0.4 fixed a parsing bug; re-extract affected drawers" workflows.
|
||||
- This is spec v1.0.
|
||||
|
||||
---
|
||||
|
||||
## 9. Cleanup prerequisite (not in this spec, but gating)
|
||||
|
||||
The existing in-tree ingesters are not adapter-shaped. Before RFC 002 can be enforced, the following refactor lands in a separate PR:
|
||||
|
||||
- Introduce `mempalace/sources/base.py` defining `BaseSourceAdapter`, the typed records, and the registry.
|
||||
- Introduce `mempalace/sources/transforms.py` with reference implementations of every reserved transformation in §1.4. Adapters and the conformance suite both consume these.
|
||||
- `mempalace/miner.py` → `mempalace/sources/filesystem.py` implementing `BaseSourceAdapter`. Current behavior preserved: 800-char chunking becomes the adapter's default; `READABLE_EXTENSIONS` moves to the adapter; `detect_room()` and `detect_hall()` move to the adapter per §2.5. `declared_transformations = frozenset({"utf8_replace_invalid", "whitespace_trim"})`.
|
||||
- `mempalace/convo_miner.py` → `mempalace/sources/conversations.py`. Exchange-pair chunking stays. The format-detection logic in `normalize.py` becomes per-format plugins the conversations adapter composes (one for Claude Code JSONL, one for Codex JSONL, one for ChatGPT mapping trees, one for Claude.ai JSON, one for Slack JSON) — each small and independently testable, eliminating the `if source_type` chain. `declared_transformations` enumerates every transformation `normalize.py` and `convo_miner._chunk_by_exchange` actually perform (see §1.4 "Existing code mapping").
|
||||
- Closet-building wired into the conversations adapter's post-step (currently missing, per §1.7) — side effect of routing through the unified core post-step.
|
||||
- `mempalace/cli.py` subcommand `mine` routes through the `mempalace.sources` registry. `--mode {projects,convos}` becomes a deprecated alias for `--source {filesystem,conversations}`.
|
||||
- `mempalace/mcp_server.py` `mempalace_mine` tool accepts a `source` parameter.
|
||||
- `mempalace/palace.py` exposes `PalaceContext` — a per-mine-invocation facade that bundles the drawer collection, closet collection, knowledge graph, palace config, and progress hooks. Adapters receive this; they do not import `palace.py` directly.
|
||||
- `NORMALIZE_VERSION` (currently a module-level constant in `palace.py:50`) stays. It is the palace-wide schema version, orthogonal to per-adapter `adapter_version`.
|
||||
- `KnowledgeGraph.add_triple()` (`knowledge_graph.py:130`) gains two optional parameters: `source_drawer_id: str = None` and `adapter_name: str = None`. Existing callers are unaffected; adapters advertising `supports_kg_triples` (§5.5) populate both. Backwards-compatible change.
|
||||
|
||||
This cleanup is substantial — comparable to RFC 001 §10's chroma-import removal — and should land before any new third-party adapter PR merges. Each new adapter is easier after the cleanup, not harder.
|
||||
|
||||
---
|
||||
|
||||
## 10. Impact on in-flight PRs
|
||||
|
||||
| PR / Issue | Effort to align |
|
||||
|---|---|
|
||||
| [#274](https://github.com/MemPalace/mempalace/issues/274) Cursor SQLite | Becomes `mempalace-source-cursor` third-party package. Author has a working prototype on Windows; needs `describe_schema()`, `declared_transformations`, and the conformance suite. Prior #287 (closed unmerged) is predecessor work. |
|
||||
| [#23](https://github.com/MemPalace/mempalace/pull/23) OpenCode SQLite | Becomes `mempalace-source-opencode`. Same shape as Cursor. |
|
||||
| [#169](https://github.com/MemPalace/mempalace/pull/169) Pi agent | Becomes `mempalace-source-pi` or a format plugin under the conversations adapter (depending on format similarity). |
|
||||
| [#232](https://github.com/MemPalace/mempalace/pull/232) Cursor JSONL | Deprecated in favor of #274's SQLite path; or a second mode of `mempalace-source-cursor`. |
|
||||
| [#567](https://github.com/MemPalace/mempalace/pull/567), [#98](https://github.com/MemPalace/mempalace/pull/98) git-mine | Closest existing work to what the spec envisions. Becomes first-party `mempalace/sources/git.py`. Exercises `whole_record` mode, `supports_structured_metadata`, `supports_closet_hints` (decision-signal quotes), `supports_kg_triples` (commit authorship, PR review relationships). |
|
||||
| [#591](https://github.com/MemPalace/mempalace/pull/591), [#592](https://github.com/MemPalace/mempalace/pull/592) Delphi Oracle | Deferred. The live-stream pattern is out of scope for v1 (§Non-goals). A v1.1 addition will specify webhook/stream adapters. |
|
||||
| [#702](https://github.com/MemPalace/mempalace/pull/702) Cursor + factory.ai | Splits into two adapter packages. |
|
||||
| [#981](https://github.com/MemPalace/mempalace/issues/981) path-level descriptions | Absorbed by §1.5 `metadata_only` mode + §5.1 `ingest_mode`. A new first-party `descriptions` adapter or a second mode on `filesystem`. |
|
||||
| [#244](https://github.com/MemPalace/mempalace/pull/244) Cursor memory-first MCP workflow docs | Points at `mempalace-source-cursor` once the adapter lands. |
|
||||
| [#419](https://github.com/MemPalace/mempalace/pull/419), [#300](https://github.com/MemPalace/mempalace/pull/300), [#952](https://github.com/MemPalace/mempalace/pull/952) language-extension additions to `READABLE_EXTENSIONS` | Becomes per-language config on the filesystem adapter. Contributors can publish domain-specific adapters without touching core. |
|
||||
| [#389](https://github.com/MemPalace/mempalace/pull/389) sensitive content scanner | Expected enforcement mechanism for the `secrets_possible` privacy class (§6.2). Not a blocker for this spec, but a natural consumer. |
|
||||
| [#434](https://github.com/MemPalace/mempalace/pull/434) auto-populate KG from drawers | Complementary: post-hoc derivation of KG triples from drawer content. Adapters with `supports_kg_triples` provide the up-front path; #434 handles everything else. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions
|
||||
|
||||
1. **Cross-adapter dedup.** When a PR body is mined via `git` AND shows up as a conversation quote mined via `claude-code`, both drawers land. Is query-time dedup in `searcher.py` sufficient, or should core maintain a content-hash index across adapters? Declared non-goal in v1 but worth revisiting if user feedback demands it.
|
||||
2. **Live-stream pattern.** Delphi Oracle (#591/592) and potentially Slack/Discord real-time ingestion need a push-mode contract. This is a v1.1 addition (streaming adapter trait + webhook surface), not blocking.
|
||||
3. **LLM-assisted structured extraction.** Some adapters will want to call an LLM to extract structured fields. The spec does not standardize this — should it? Argument for: conformance test for LLM-driven fields, consistent caching. Argument against: local-first / zero-API is a core promise; LLM dependencies are opt-in per adapter.
|
||||
4. **Adapter-vs-format split for conversations.** §9 proposes format plugins composed under a single conversations adapter. Alternative: one adapter per format (claude-code, chatgpt, codex, cursor-jsonl, slack). The trade-off is discoverability (one adapter is easier to find) vs. encapsulation (format plugins are simpler to test). Preference leans toward the single-adapter + plugin model; open to counter-argument.
|
||||
5. **Default `privacy_floor`.** v1 defaults to none (§6.2) so single-user local mining is frictionless. An argument exists for defaulting to `pii_potential` — forces regulated-domain users to opt in to sensitive levels rather than opt out. Open to changing the default before v1 ships.
|
||||
6. **`canonical_source_bytes` for API-backed adapters.** §7.1 defines this as adapter-declared. For API-backed adapters (Slack, Notion), what constitutes "canonical bytes" in a conformance test — the fixture's captured HTTP response? A serialized representation of the parsed object? Leaves to the adapter; may need a follow-up spec for common conventions.
|
||||
7. **`adapter_version` bump semantics.** When does an adapter bump `adapter_version`? On any behavior change? On declared-transformation changes only? Suggests a follow-up doc on adapter SemVer conventions for the community to agree on.
|
||||
|
||||
---
|
||||
|
||||
## 12. Rollout
|
||||
|
||||
1. Land the cleanup PR (§9): introduce `mempalace/sources/`, refactor `miner.py` → filesystem adapter, `convo_miner.py` → conversations adapter, route CLI and MCP through the sources registry. Behavior preserved end-to-end. Closets get built for conversation drawers as a side effect.
|
||||
2. Land this spec as-is. Add `AbstractSourceAdapterContractSuite`, entry-point discovery, `AdapterSchema` validation, privacy-class enforcement (floor-gated writes), declared-transformation reference implementations in `mempalace/sources/transforms.py`.
|
||||
3. Land `mempalace/sources/git.py` as the first-party adapter absorbing #567. Exercises `whole_record`, `supports_structured_metadata`, `supports_closet_hints`, `supports_kg_triples` together.
|
||||
4. Encourage the Cursor (#274), OpenCode (#23), and Pi (#169) authors to publish as third-party packages under `mempalace-source-*`. Offer review help against the spec.
|
||||
5. Publish adapter-authoring docs at [mempalaceofficial.com/guide/authoring-sources](https://mempalaceofficial.com/guide/authoring-sources.html).
|
||||
6. Update [ROADMAP.md](../../ROADMAP.md) with spec v1.0 adoption under v4.0.0-alpha.
|
||||
@@ -0,0 +1,36 @@
|
||||
-- MemPalace Knowledge Graph Schema
|
||||
-- SQLite database at ~/.mempalace/knowledge_graph.db
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'unknown',
|
||||
properties TEXT DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS triples (
|
||||
id TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL,
|
||||
predicate TEXT NOT NULL,
|
||||
object TEXT NOT NULL,
|
||||
valid_from TEXT,
|
||||
valid_to TEXT,
|
||||
confidence REAL DEFAULT 1.0,
|
||||
source_closet TEXT,
|
||||
source_file TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attributes (
|
||||
entity_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
valid_from TEXT,
|
||||
valid_to TEXT,
|
||||
PRIMARY KEY (entity_id, key, valid_from)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);
|
||||
CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object);
|
||||
CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);
|
||||
CREATE INDEX IF NOT EXISTS idx_triples_valid ON triples(valid_from, valid_to);
|
||||
@@ -0,0 +1,28 @@
|
||||
# How to Use MemPalace Hooks (Auto-Save)
|
||||
|
||||
MemPalace hooks act as an "Auto-Save" feature. They help your AI keep a permanent memory without you needing to run manual commands.
|
||||
|
||||
### 1. What are these hooks?
|
||||
* **Save Hook** (`mempal_save_hook.sh`): Saves new facts and decisions every 15 messages.
|
||||
* **PreCompact Hook** (`mempal_precompact_hook.sh`): Saves your context right before the AI's memory window fills up.
|
||||
|
||||
### 2. Setup for Claude Code
|
||||
Add this to your configuration file to enable automatic background saving:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [{"type": "command", "command": "./hooks/mempal_save_hook.sh"}]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [{"type": "command", "command": "./hooks/mempal_precompact_hook.sh"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# Gemini CLI Integration Guide
|
||||
|
||||
This guide explains how to set up MemPalace as a permanent memory for the [Gemini CLI](https://github.com/google/gemini-cli).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9+
|
||||
- Gemini CLI installed and configured
|
||||
|
||||
## 1. Installation
|
||||
|
||||
On many Linux systems, installing Python packages globally is restricted. We recommend using a local virtual environment within the MemPalace directory.
|
||||
|
||||
```bash
|
||||
# Clone the repository (if you haven't already)
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
|
||||
# Create a virtual environment
|
||||
python3 -m venv .venv
|
||||
|
||||
# Install dependencies and MemPalace in editable mode
|
||||
.venv/bin/pip install -e .
|
||||
```
|
||||
|
||||
## 2. Initialization
|
||||
|
||||
Set up your "Palace" (the database) and configure your identity.
|
||||
|
||||
```bash
|
||||
# Initialize the palace in the current directory
|
||||
.venv/bin/python3 -m mempalace init .
|
||||
```
|
||||
|
||||
### Identity and Wings (Optional but Recommended)
|
||||
You can manually define who you are and what projects you work on by creating/editing these files in `~/.mempalace/`:
|
||||
|
||||
- **`~/.mempalace/identity.txt`**: A plain text file describing your role and focus.
|
||||
- **`~/.mempalace/wing_config.json`**: A JSON file mapping projects and name variants to "Wings".
|
||||
|
||||
## 3. Connect to Gemini CLI (MCP)
|
||||
|
||||
Register MemPalace as an MCP server so Gemini CLI can use its tools.
|
||||
|
||||
```bash
|
||||
gemini mcp add mempalace /absolute/path/to/mempalace/.venv/bin/python3 -m mempalace.mcp_server --scope user
|
||||
```
|
||||
*Note: Use the absolute path to ensure it works from any directory.*
|
||||
|
||||
## 4. Enable Auto-Saving (Hooks)
|
||||
|
||||
To ensure the AI saves memories automatically when conversation history becomes too long, add a `PreCompress` hook to your Gemini CLI settings.
|
||||
|
||||
Edit your `~/.gemini/settings.json` and add the following:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreCompress": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/absolute/path/to/mempalace/hooks/mempal_precompact_hook.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Make sure the hook scripts are executable:
|
||||
```bash
|
||||
chmod +x hooks/*.sh
|
||||
```
|
||||
|
||||
## 5. Usage
|
||||
|
||||
Once connected, Gemini CLI will automatically:
|
||||
- Start the MemPalace server on launch.
|
||||
- Use `mempalace_search` to find relevant past discussions.
|
||||
- Use the `PreCompress` hook to save new memories before they are lost.
|
||||
|
||||
### Manual Mining
|
||||
If you want the AI to learn from your existing code or docs immediately, run the "mine" command:
|
||||
```bash
|
||||
.venv/bin/python3 -m mempalace mine /path/to/your/project
|
||||
```
|
||||
|
||||
### Verification
|
||||
In a Gemini CLI session, you can run:
|
||||
- `/mcp list`: Verify `mempalace` is `CONNECTED`.
|
||||
- `/hooks panel`: Verify the `PreCompress` hook is active.
|
||||
@@ -5,20 +5,22 @@
|
||||
Run the MCP server:
|
||||
|
||||
```bash
|
||||
python mcp_server.py
|
||||
python -m mempalace.mcp_server
|
||||
```
|
||||
|
||||
Or add to Claude Code:
|
||||
Or add it to Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add mempal -- python /path/to/mempalace/mcp_server.py
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
- **mempal_status** — palace stats (wings, rooms, drawer counts)
|
||||
- **mempal_search** — semantic search across all memories
|
||||
- **mempal_list_wings** — list all projects in the palace
|
||||
The server exposes the full MemPalace MCP toolset. Common entry points include:
|
||||
|
||||
- **mempalace_status** — palace stats (wings, rooms, drawer counts)
|
||||
- **mempalace_search** — semantic search across all memories
|
||||
- **mempalace_list_wings** — list all projects in the palace
|
||||
|
||||
## Usage in Claude Code
|
||||
|
||||
|
||||
+5
-1
@@ -133,6 +133,10 @@ Example output:
|
||||
[14:40:01] Session abc123: 18 exchanges, 3 since last save
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
**Hooks require session restart after install.** Claude Code loads hooks from `settings.json` at session start only. If you run `mempalace init` or manually edit hook config mid-session, the hooks won't fire until you restart Claude Code. This is a Claude Code limitation.
|
||||
|
||||
## Cost
|
||||
|
||||
**Zero extra tokens.** The hooks are bash scripts that run locally. They don't call any API. The only "cost" is the AI spending a few seconds organizing memories at each checkpoint — and it's doing that with context it already has loaded.
|
||||
**Zero extra tokens.** The hooks notify the AI that saves happened in the background — the AI doesn't need to write anything in the chat. All filing is handled automatically. Previous versions asked the AI to write diary entries and drawer content in the chat window, which cost ~$1/session in retransmitted tokens.
|
||||
|
||||
@@ -68,10 +68,6 @@ if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
python3 -m mempalace mine "$MEMPAL_DIR" >> "$STATE_DIR/hook.log" 2>&1
|
||||
fi
|
||||
|
||||
# Always block — compaction = save everything
|
||||
cat << 'HOOKJSON'
|
||||
{
|
||||
"decision": "block",
|
||||
"reason": "COMPACTION IMMINENT. Save ALL topics, decisions, quotes, code, and important context from this session to your memory system. Be thorough — after compaction, detailed context will be lost. Organize into appropriate categories. Use verbatim quotes where possible. Save everything, then allow compaction to proceed."
|
||||
}
|
||||
HOOKJSON
|
||||
# Silent: return empty JSON to not block. "decision": "allow" is invalid —
|
||||
# only "block" or {} are recognized.
|
||||
echo '{}'
|
||||
|
||||
+52
-17
@@ -64,10 +64,23 @@ MEMPAL_DIR=""
|
||||
# Read JSON input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
# Parse fields from Claude Code's JSON
|
||||
SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','unknown'))" 2>/dev/null)
|
||||
STOP_HOOK_ACTIVE=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('stop_hook_active', False))" 2>/dev/null)
|
||||
TRANSCRIPT_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null)
|
||||
# Parse all fields in a single Python call (3x faster than separate invocations)
|
||||
# SECURITY: All values are sanitized before being interpolated into shell assignments.
|
||||
# stop_hook_active is coerced to a strict True/False to prevent command injection via eval.
|
||||
eval $(echo "$INPUT" | python3 -c "
|
||||
import sys, json, re
|
||||
data = json.load(sys.stdin)
|
||||
sid = data.get('session_id', 'unknown')
|
||||
sha_raw = data.get('stop_hook_active', False)
|
||||
tp = data.get('transcript_path', '')
|
||||
# Shell-safe output — only allow alphanumeric, underscore, hyphen, slash, dot, tilde
|
||||
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
|
||||
# Coerce stop_hook_active to strict boolean string
|
||||
sha = 'True' if sha_raw is True or str(sha_raw).lower() in ('true', '1', 'yes') else 'False'
|
||||
print(f'SESSION_ID=\"{safe(sid)}\"')
|
||||
print(f'STOP_HOOK_ACTIVE=\"{sha}\"')
|
||||
print(f'TRANSCRIPT_PATH=\"{safe(tp)}\"')
|
||||
" 2>/dev/null)
|
||||
|
||||
# Expand ~ in path
|
||||
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
|
||||
@@ -80,25 +93,26 @@ if [ "$STOP_HOOK_ACTIVE" = "True" ] || [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
fi
|
||||
|
||||
# Count human messages in the JSONL transcript
|
||||
# SECURITY: Pass transcript path as sys.argv to avoid shell injection via crafted paths
|
||||
if [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
EXCHANGE_COUNT=$(python3 -c "
|
||||
EXCHANGE_COUNT=$(python3 - "$TRANSCRIPT_PATH" <<'PYEOF'
|
||||
import json, sys
|
||||
count = 0
|
||||
with open('$TRANSCRIPT_PATH') as f:
|
||||
with open(sys.argv[1]) as f:
|
||||
for line in f:
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
msg = entry.get('message', {})
|
||||
if isinstance(msg, dict) and msg.get('role') == 'user':
|
||||
content = msg.get('content', '')
|
||||
# Skip system/command messages — only count real human input
|
||||
if isinstance(content, str) and '<command-message>' in content:
|
||||
continue
|
||||
count += 1
|
||||
except:
|
||||
pass
|
||||
print(count)
|
||||
" 2>/dev/null)
|
||||
PYEOF
|
||||
2>/dev/null)
|
||||
else
|
||||
EXCHANGE_COUNT=0
|
||||
fi
|
||||
@@ -107,7 +121,11 @@ fi
|
||||
LAST_SAVE_FILE="$STATE_DIR/${SESSION_ID}_last_save"
|
||||
LAST_SAVE=0
|
||||
if [ -f "$LAST_SAVE_FILE" ]; then
|
||||
LAST_SAVE=$(cat "$LAST_SAVE_FILE")
|
||||
LAST_SAVE_RAW=$(cat "$LAST_SAVE_FILE")
|
||||
# SECURITY: Validate as plain integer before arithmetic to prevent command injection
|
||||
if [[ "$LAST_SAVE_RAW" =~ ^[0-9]+$ ]]; then
|
||||
LAST_SAVE="$LAST_SAVE_RAW"
|
||||
fi
|
||||
fi
|
||||
|
||||
SINCE_LAST=$((EXCHANGE_COUNT - LAST_SAVE))
|
||||
@@ -122,21 +140,38 @@ if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then
|
||||
|
||||
echo "[$(date '+%H:%M:%S')] TRIGGERING SAVE at exchange $EXCHANGE_COUNT" >> "$STATE_DIR/hook.log"
|
||||
|
||||
# Optional: run mempalace ingest in background if MEMPAL_DIR is set
|
||||
# Auto-mine the transcript. Two paths:
|
||||
# 1. TRANSCRIPT_PATH (from Claude Code) — mine the directory it lives in
|
||||
# 2. MEMPAL_DIR (user-configured) — mine that directory
|
||||
# At least one should work. If neither is set, nothing mines.
|
||||
PYTHON="$(command -v python3)"
|
||||
MINE_DIR=""
|
||||
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
MINE_DIR="$(dirname "$TRANSCRIPT_PATH")"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
python3 -m mempalace mine "$MEMPAL_DIR" >> "$STATE_DIR/hook.log" 2>&1 &
|
||||
MINE_DIR="$MEMPAL_DIR"
|
||||
fi
|
||||
if [ -n "$MINE_DIR" ]; then
|
||||
"$PYTHON" -m mempalace mine "$MINE_DIR" >> "$STATE_DIR/hook.log" 2>&1 &
|
||||
fi
|
||||
|
||||
# Block the AI and tell it to save
|
||||
# The "reason" becomes a system message the AI sees and acts on
|
||||
cat << 'HOOKJSON'
|
||||
# MEMPAL_VERBOSE toggle:
|
||||
# true = developer mode — block and show diaries/code in chat
|
||||
# false = silent mode (default) — save in background, no chat clutter
|
||||
# Set via: export MEMPAL_VERBOSE=true
|
||||
if [ "$MEMPAL_VERBOSE" = "true" ] || [ "$MEMPAL_VERBOSE" = "1" ]; then
|
||||
cat << 'HOOKJSON'
|
||||
{
|
||||
"decision": "block",
|
||||
"reason": "AUTO-SAVE checkpoint. Save key topics, decisions, quotes, and code from this session to your memory system. Organize into appropriate categories. Use verbatim quotes where possible. Continue conversation after saving."
|
||||
"reason": "MemPalace save checkpoint. Write a brief session diary entry covering key topics, decisions, and code changes since the last save. Use verbatim quotes where possible. Continue after saving."
|
||||
}
|
||||
HOOKJSON
|
||||
else
|
||||
# Silent mode: return empty JSON to not block. "decision": "allow" is
|
||||
# not a valid value — only "block" or {} are recognized.
|
||||
echo '{}'
|
||||
fi
|
||||
else
|
||||
# Not time yet — let the AI stop normally
|
||||
echo "{}"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
name: mempalace
|
||||
description: "MemPalace — Local AI memory with 96.6% recall. Semantic search, temporal knowledge graph, palace architecture (wings/rooms/drawers). Free, no cloud, no API keys."
|
||||
version: 3.3.0
|
||||
homepage: https://github.com/MemPalace/mempalace
|
||||
user-invocable: true
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "\U0001F3DB"
|
||||
os:
|
||||
- darwin
|
||||
- linux
|
||||
- win32
|
||||
requires:
|
||||
anyBins:
|
||||
- mempalace
|
||||
- python3
|
||||
install:
|
||||
- id: mempalace-pip
|
||||
kind: uv
|
||||
label: "Install MemPalace (Python, local ChromaDB)"
|
||||
package: mempalace
|
||||
bins:
|
||||
- mempalace
|
||||
---
|
||||
|
||||
# MemPalace — Local AI Memory System
|
||||
|
||||
You have access to a local memory palace via MCP tools. The palace stores verbatim conversation history and a temporal knowledge graph — all on the user's machine, zero cloud, zero API calls.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Wings** = people or projects (e.g. `wing_alice`, `wing_myproject`)
|
||||
- **Halls** = categories (facts, events, preferences, advice)
|
||||
- **Rooms** = specific topics (e.g. `chromadb-setup`, `riley-school`)
|
||||
- **Drawers** = individual memory chunks (verbatim text)
|
||||
- **Knowledge Graph** = entity-relationship facts with time validity
|
||||
|
||||
## Protocol — FOLLOW THIS EVERY SESSION
|
||||
|
||||
1. **ON WAKE-UP**: Call `mempalace_status` to load palace overview and AAAK dialect spec.
|
||||
2. **BEFORE RESPONDING** about any person, project, or past event: call `mempalace_search` or `mempalace_kg_query` FIRST. Never guess from memory — verify from the palace.
|
||||
3. **IF UNSURE** about a fact (name, age, relationship, preference): say "let me check" and query. Wrong is worse than slow.
|
||||
4. **AFTER EACH SESSION**: Call `mempalace_diary_write` to record what happened, what you learned, what matters.
|
||||
5. **WHEN FACTS CHANGE**: Call `mempalace_kg_invalidate` on the old fact, then `mempalace_kg_add` for the new one.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Search & Browse
|
||||
- `mempalace_search` — Semantic search across all memories. Always start here.
|
||||
- `query` (required): natural language search — keep it short, keywords or a question. Do NOT include system prompts or conversation context.
|
||||
- `wing`: filter by wing
|
||||
- `room`: filter by room
|
||||
- `limit`: max results (default 5)
|
||||
- `mempalace_check_duplicate` — Check if content already exists before filing.
|
||||
- `content` (required): text to check
|
||||
- `threshold`: similarity threshold (default 0.9 — lowering to 0.85–0.87 often catches more near-duplicates without significant false positives)
|
||||
- `mempalace_status` — Palace overview: total drawers, wings, rooms, AAAK spec
|
||||
- `mempalace_list_wings` — All wings with drawer counts
|
||||
- `mempalace_list_rooms` — Rooms within a wing (optional wing filter)
|
||||
- `mempalace_get_taxonomy` — Full wing/room/count tree
|
||||
- `mempalace_get_aaak_spec` — Get AAAK compression dialect specification
|
||||
|
||||
### Knowledge Graph (Temporal Facts)
|
||||
- `mempalace_kg_query` — Query entity relationships. Supports time filtering.
|
||||
- `entity` (required): e.g. "Max", "MyProject"
|
||||
- `as_of`: date filter (YYYY-MM-DD) — what was true at that time
|
||||
- `direction`: "outgoing", "incoming", or "both" (default "both")
|
||||
- `mempalace_kg_add` — Add a fact: subject -> predicate -> object
|
||||
- `subject`, `predicate`, `object` (required)
|
||||
- `valid_from`: when this became true
|
||||
- `source_closet`: source reference
|
||||
- `mempalace_kg_invalidate` — Mark a fact as no longer true
|
||||
- `subject`, `predicate`, `object` (required)
|
||||
- `ended`: when it stopped being true (default: today)
|
||||
- `mempalace_kg_timeline` — Chronological story of an entity
|
||||
- `entity`: filter by entity name (optional — all events if omitted)
|
||||
- `mempalace_kg_stats` — Graph overview: entities, triples, relationship types
|
||||
|
||||
### Palace Graph (Cross-Domain Connections)
|
||||
- `mempalace_traverse` — Walk from a room, find connected ideas across wings
|
||||
- `start_room` (required): room to start from
|
||||
- `max_hops`: connection depth (default 2)
|
||||
- `mempalace_find_tunnels` — Find rooms that bridge two wings
|
||||
- `wing_a`, `wing_b` (required)
|
||||
- `mempalace_graph_stats` — Graph connectivity overview
|
||||
|
||||
### Write
|
||||
- `mempalace_add_drawer` — Store verbatim content into a wing/room
|
||||
- `wing`, `room`, `content` (required)
|
||||
- `source_file`: optional source reference
|
||||
- Checks for duplicates automatically
|
||||
- `mempalace_delete_drawer` — Remove a drawer by ID
|
||||
- `drawer_id` (required)
|
||||
- `mempalace_diary_write` — Write a session diary entry
|
||||
- `agent_name` (required): your name/identifier
|
||||
- `entry` (required): what happened, what you learned, what matters
|
||||
- `topic`: category tag (default "general")
|
||||
- `mempalace_diary_read` — Read recent diary entries
|
||||
- `agent_name` (required)
|
||||
- `last_n`: number of entries (default 10)
|
||||
|
||||
## Setup
|
||||
|
||||
Install MemPalace and populate the palace:
|
||||
|
||||
```bash
|
||||
pip install mempalace
|
||||
mempalace init ~/my-convos
|
||||
mempalace mine ~/my-convos
|
||||
```
|
||||
|
||||
### OpenClaw MCP config
|
||||
|
||||
Add to your OpenClaw MCP configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "python3",
|
||||
"args": ["-m", "mempalace.mcp_server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or via CLI:
|
||||
|
||||
```bash
|
||||
openclaw mcp set mempalace '{"command":"python3","args":["-m","mempalace.mcp_server"]}'
|
||||
```
|
||||
|
||||
### Other MCP hosts
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
|
||||
# Cursor — add to .cursor/mcp.json
|
||||
# Codex — add to .codex/mcp.json
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Search is semantic (meaning-based), not keyword. "What did we discuss about database performance?" works better than "database".
|
||||
- The knowledge graph stores typed relationships with time windows. Use it for facts about people and projects — it knows WHEN things were true.
|
||||
- Diary entries accumulate across sessions. Write one at the end of each conversation to build continuity.
|
||||
- Use `mempalace_check_duplicate` before storing new content to avoid duplicates.
|
||||
- The AAAK dialect (from `mempalace_status`) is a compressed notation for efficient storage. Read it naturally — expand codes mentally, treat *markers* as emotional context.
|
||||
|
||||
## License
|
||||
|
||||
[MemPalace](https://github.com/MemPalace/mempalace) is MIT licensed. Created by Milla Jovovich, Ben Sigman, Igor Lins e Silva, and contributors.
|
||||
+2116
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 680 KiB |
+23
-3
@@ -1,7 +1,27 @@
|
||||
"""MemPalace — Give your AI a memory. No API key required."""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
import logging
|
||||
|
||||
from .cli import main
|
||||
from .version import __version__ # noqa: E402
|
||||
|
||||
__all__ = ["main", "__version__"]
|
||||
# chromadb telemetry: posthog capture() was broken in 0.6.x causing noisy stderr
|
||||
# warnings ("capture() takes 1 positional argument but 3 were given"). In 1.x the
|
||||
# posthog client is a no-op stub, so this is now harmless — kept as a guard in
|
||||
# case future chromadb versions re-introduce real telemetry calls.
|
||||
logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICAL)
|
||||
|
||||
# NOTE: the previous block set ``ORT_DISABLE_COREML=1`` on macOS arm64 as a
|
||||
# supposed workaround for the #74 ARM64 segfault. Two problems:
|
||||
#
|
||||
# 1. ONNX Runtime does not read that env var -- it has no global way to
|
||||
# disable a single execution provider, so the setdefault was a no-op.
|
||||
# 2. #74 is a null-pointer crash in ``chromadb_rust_bindings.abi3.so``, not
|
||||
# an ONNX issue, so disabling CoreML would not have fixed it anyway.
|
||||
#
|
||||
# #521 has since traced the actual macOS arm64 crashes (both in mine and
|
||||
# search paths) to the 0.x chromadb hnswlib binding. Filtering
|
||||
# CoreMLExecutionProvider at the ONNX layer leaves the hnswlib C++ crash
|
||||
# intact, so the real fix is upgrading chromadb to 1.5.4+, which #581
|
||||
# proposes. See #397 for the history of this line.
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Storage backend implementations for MemPalace (RFC 001).
|
||||
|
||||
Public surface:
|
||||
|
||||
* :class:`BaseCollection` — per-collection read/write contract.
|
||||
* :class:`BaseBackend` — per-palace factory contract.
|
||||
* :class:`PalaceRef` — value object identifying a palace for a backend.
|
||||
* :class:`QueryResult` / :class:`GetResult` — typed read returns.
|
||||
* Error classes: :class:`PalaceNotFoundError`, :class:`BackendClosedError`,
|
||||
:class:`UnsupportedFilterError`, :class:`DimensionMismatchError`,
|
||||
:class:`EmbedderIdentityMismatchError`.
|
||||
* Registry: :func:`get_backend`, :func:`register`, :func:`available_backends`,
|
||||
:func:`resolve_backend_for_palace`.
|
||||
* In-tree Chroma default: :class:`ChromaBackend`, :class:`ChromaCollection`.
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
BackendClosedError,
|
||||
BackendError,
|
||||
BaseBackend,
|
||||
BaseCollection,
|
||||
DimensionMismatchError,
|
||||
EmbedderIdentityMismatchError,
|
||||
GetResult,
|
||||
HealthStatus,
|
||||
PalaceNotFoundError,
|
||||
PalaceRef,
|
||||
QueryResult,
|
||||
UnsupportedFilterError,
|
||||
)
|
||||
from .chroma import ChromaBackend, ChromaCollection
|
||||
from .registry import (
|
||||
available_backends,
|
||||
get_backend,
|
||||
get_backend_class,
|
||||
register,
|
||||
reset_backends,
|
||||
resolve_backend_for_palace,
|
||||
unregister,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BackendClosedError",
|
||||
"BackendError",
|
||||
"BaseBackend",
|
||||
"BaseCollection",
|
||||
"ChromaBackend",
|
||||
"ChromaCollection",
|
||||
"DimensionMismatchError",
|
||||
"EmbedderIdentityMismatchError",
|
||||
"GetResult",
|
||||
"HealthStatus",
|
||||
"PalaceNotFoundError",
|
||||
"PalaceRef",
|
||||
"QueryResult",
|
||||
"UnsupportedFilterError",
|
||||
"available_backends",
|
||||
"get_backend",
|
||||
"get_backend_class",
|
||||
"register",
|
||||
"reset_backends",
|
||||
"resolve_backend_for_palace",
|
||||
"unregister",
|
||||
]
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Storage backend contract for MemPalace (RFC 001).
|
||||
|
||||
This module defines the surface every storage backend must implement:
|
||||
|
||||
* ``BaseCollection`` — the per-collection read/write interface, kwargs-only.
|
||||
* ``BaseBackend`` — the per-palace factory, addressed by ``PalaceRef``.
|
||||
* ``QueryResult`` / ``GetResult`` — typed result dataclasses that replace the
|
||||
Chroma dict shape as the canonical return type.
|
||||
* Error classes + ``HealthStatus`` — uniform across backends.
|
||||
|
||||
This is the v1 cleanup from RFC 001 §10: full typed results, ``PalaceRef``,
|
||||
registry-ready ABC. Embedder injection, maintenance hooks, and the full
|
||||
conformance suite land in follow-up PRs.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BackendError(Exception):
|
||||
"""Base class for every storage-backend error raised by core."""
|
||||
|
||||
|
||||
class PalaceNotFoundError(BackendError, FileNotFoundError):
|
||||
"""Raised when ``get_collection(create=False)`` is called on a missing palace.
|
||||
|
||||
Subclass of ``FileNotFoundError`` so legacy callers that catch the latter
|
||||
(pre-#413 seam) keep working unchanged.
|
||||
"""
|
||||
|
||||
|
||||
class BackendClosedError(BackendError):
|
||||
"""Raised when a backend method is called after ``close()``."""
|
||||
|
||||
|
||||
class UnsupportedFilterError(BackendError):
|
||||
"""Raised when a where-clause uses an operator the backend does not implement.
|
||||
|
||||
Silent dropping of unknown operators is forbidden by spec (RFC 001 §1.4).
|
||||
"""
|
||||
|
||||
|
||||
class DimensionMismatchError(BackendError):
|
||||
"""Raised when the embedding dimension on write does not match the collection."""
|
||||
|
||||
|
||||
class EmbedderIdentityMismatchError(BackendError):
|
||||
"""Raised when the stored embedder model name differs from the current one."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Value objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PalaceRef:
|
||||
"""A handle to a palace, consumed by backends.
|
||||
|
||||
``id`` is always present and is the key backends use to cache handles.
|
||||
``local_path`` is populated for filesystem-rooted palaces.
|
||||
``namespace`` is used by server-mode backends for tenant / prefix routing.
|
||||
"""
|
||||
|
||||
id: str
|
||||
local_path: Optional[str] = None
|
||||
namespace: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealthStatus:
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
||||
@classmethod
|
||||
def healthy(cls, detail: str = "") -> "HealthStatus":
|
||||
return cls(ok=True, detail=detail)
|
||||
|
||||
@classmethod
|
||||
def unhealthy(cls, detail: str) -> "HealthStatus":
|
||||
return cls(ok=False, detail=detail)
|
||||
|
||||
|
||||
_TYPED_RESULT_FIELDS = ("ids", "documents", "metadatas", "distances", "embeddings")
|
||||
|
||||
|
||||
class _DictCompatMixin:
|
||||
"""Transitional dict-protocol access for typed results.
|
||||
|
||||
RFC 001 §1.3 spec is attribute access (``result.ids``). The ``result["ids"]``
|
||||
and ``result.get("ids")`` forms are retained as a migration shim for callers
|
||||
that predate the typed interface and are scheduled for removal in a follow-
|
||||
up cleanup. New code MUST use attribute access.
|
||||
"""
|
||||
|
||||
def __getitem__(self, key: str):
|
||||
if key in _TYPED_RESULT_FIELDS:
|
||||
return getattr(self, key)
|
||||
raise KeyError(key)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
if key in _TYPED_RESULT_FIELDS:
|
||||
val = getattr(self, key, default)
|
||||
return default if val is None else val
|
||||
return default
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in _TYPED_RESULT_FIELDS and getattr(self, key, None) is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QueryResult(_DictCompatMixin):
|
||||
"""Typed return from ``BaseCollection.query``.
|
||||
|
||||
Outer list dimension = number of query vectors / texts.
|
||||
Inner list dimension = hits per query (may be zero).
|
||||
|
||||
Fields not in ``include=`` at the call site are populated with empty lists
|
||||
of the correct outer shape (never ``None``), except ``embeddings`` which
|
||||
is ``None`` when not requested.
|
||||
"""
|
||||
|
||||
ids: list[list[str]]
|
||||
documents: list[list[str]]
|
||||
metadatas: list[list[dict]]
|
||||
distances: list[list[float]]
|
||||
embeddings: Optional[list[list[list[float]]]] = None
|
||||
|
||||
@classmethod
|
||||
def empty(cls, num_queries: int = 1, embeddings_requested: bool = False) -> "QueryResult":
|
||||
"""Construct an all-empty result preserving outer dimension.
|
||||
|
||||
When ``embeddings_requested`` is True, ``embeddings`` preserves the outer
|
||||
query dimension with empty hit lists (matching the spec's rule that fields
|
||||
requested via ``include=`` carry the outer shape even when empty). When
|
||||
False, ``embeddings`` stays ``None`` to signal the field was not requested.
|
||||
"""
|
||||
empty_outer = [[] for _ in range(num_queries)]
|
||||
return cls(
|
||||
ids=[[] for _ in range(num_queries)],
|
||||
documents=[[] for _ in range(num_queries)],
|
||||
metadatas=[[] for _ in range(num_queries)],
|
||||
distances=[[] for _ in range(num_queries)],
|
||||
embeddings=empty_outer if embeddings_requested else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GetResult(_DictCompatMixin):
|
||||
"""Typed return from ``BaseCollection.get``."""
|
||||
|
||||
ids: list[str]
|
||||
documents: list[str]
|
||||
metadatas: list[dict]
|
||||
embeddings: Optional[list[list[float]]] = None
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> "GetResult":
|
||||
return cls(ids=[], documents=[], metadatas=[], embeddings=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collection contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BaseCollection(ABC):
|
||||
"""Per-collection read/write surface every backend must implement."""
|
||||
|
||||
@abstractmethod
|
||||
def add(
|
||||
self,
|
||||
*,
|
||||
documents: list[str],
|
||||
ids: list[str],
|
||||
metadatas: Optional[list[dict]] = None,
|
||||
embeddings: Optional[list[list[float]]] = None,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def upsert(
|
||||
self,
|
||||
*,
|
||||
documents: list[str],
|
||||
ids: list[str],
|
||||
metadatas: Optional[list[dict]] = None,
|
||||
embeddings: Optional[list[list[float]]] = None,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def query(
|
||||
self,
|
||||
*,
|
||||
query_texts: Optional[list[str]] = None,
|
||||
query_embeddings: Optional[list[list[float]]] = None,
|
||||
n_results: int = 10,
|
||||
where: Optional[dict] = None,
|
||||
where_document: Optional[dict] = None,
|
||||
include: Optional[list[str]] = None,
|
||||
) -> QueryResult: ...
|
||||
|
||||
@abstractmethod
|
||||
def get(
|
||||
self,
|
||||
*,
|
||||
ids: Optional[list[str]] = None,
|
||||
where: Optional[dict] = None,
|
||||
where_document: Optional[dict] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
include: Optional[list[str]] = None,
|
||||
) -> GetResult: ...
|
||||
|
||||
@abstractmethod
|
||||
def delete(
|
||||
self,
|
||||
*,
|
||||
ids: Optional[list[str]] = None,
|
||||
where: Optional[dict] = None,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def count(self) -> int: ...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Optional methods with ABC defaults (spec §1.2)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def estimated_count(self) -> int:
|
||||
return self.count()
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
def health(self) -> HealthStatus:
|
||||
return HealthStatus.healthy()
|
||||
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
ids: list[str],
|
||||
documents: Optional[list[str]] = None,
|
||||
metadatas: Optional[list[dict]] = None,
|
||||
embeddings: Optional[list[list[float]]] = None,
|
||||
) -> None:
|
||||
"""Default non-atomic update: get + merge + upsert.
|
||||
|
||||
Backends advertising ``supports_update`` MUST override with an atomic
|
||||
single-round-trip implementation.
|
||||
"""
|
||||
if documents is None and metadatas is None and embeddings is None:
|
||||
raise ValueError("update requires at least one of documents, metadatas, embeddings")
|
||||
|
||||
n = len(ids)
|
||||
for label, value in (
|
||||
("documents", documents),
|
||||
("metadatas", metadatas),
|
||||
("embeddings", embeddings),
|
||||
):
|
||||
if value is not None and len(value) != n:
|
||||
raise ValueError(f"{label} length {len(value)} does not match ids length {n}")
|
||||
|
||||
existing = self.get(ids=ids, include=["documents", "metadatas"])
|
||||
by_id = {
|
||||
rid: (existing.documents[i], existing.metadatas[i])
|
||||
for i, rid in enumerate(existing.ids)
|
||||
}
|
||||
merged_docs: list[str] = []
|
||||
merged_metas: list[dict] = []
|
||||
for i, rid in enumerate(ids):
|
||||
prev_doc, prev_meta = by_id.get(rid, ("", {}))
|
||||
merged_docs.append(documents[i] if documents is not None else prev_doc)
|
||||
new_meta = dict(prev_meta or {})
|
||||
if metadatas is not None:
|
||||
new_meta.update(metadatas[i] or {})
|
||||
merged_metas.append(new_meta)
|
||||
self.upsert(
|
||||
documents=merged_docs,
|
||||
ids=list(ids),
|
||||
metadatas=merged_metas,
|
||||
embeddings=embeddings,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BaseBackend(ABC):
|
||||
"""Long-lived factory serving many palaces (RFC 001 §2).
|
||||
|
||||
Instances are lightweight on construction — no I/O, no network. All
|
||||
connection work is deferred to ``get_collection``. Instances are thread-
|
||||
safe for concurrent ``get_collection`` calls across different palaces.
|
||||
"""
|
||||
|
||||
name: ClassVar[str]
|
||||
spec_version: ClassVar[str] = "1.0"
|
||||
capabilities: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@abstractmethod
|
||||
def get_collection(
|
||||
self,
|
||||
*,
|
||||
palace: PalaceRef,
|
||||
collection_name: str,
|
||||
create: bool = False,
|
||||
options: Optional[dict] = None,
|
||||
) -> BaseCollection: ...
|
||||
|
||||
def close_palace(self, palace: PalaceRef) -> None:
|
||||
"""Evict cached handles for a single palace. Default: no-op."""
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Shut down the entire backend. Default: no-op."""
|
||||
return None
|
||||
|
||||
def health(self, palace: Optional[PalaceRef] = None) -> HealthStatus:
|
||||
return HealthStatus.healthy()
|
||||
|
||||
# Optional detection hint used by selection priority (RFC 001 §3.3 (4)):
|
||||
@classmethod
|
||||
def detect(cls, path: str) -> bool: # pragma: no cover - default hook
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Keys the Chroma ``include=`` parameter accepts.
|
||||
_VALID_INCLUDE_KEYS = frozenset({"documents", "metadatas", "distances", "embeddings"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class _IncludeSpec:
|
||||
"""Resolve an ``include=`` parameter with spec-mandated defaults."""
|
||||
|
||||
documents: bool = True
|
||||
metadatas: bool = True
|
||||
distances: bool = True # only meaningful for query
|
||||
embeddings: bool = False
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls, include: Optional[list[str]], *, default_distances: bool = True
|
||||
) -> "_IncludeSpec":
|
||||
if include is None:
|
||||
return cls(
|
||||
documents=True,
|
||||
metadatas=True,
|
||||
distances=default_distances,
|
||||
embeddings=False,
|
||||
)
|
||||
keys = {k for k in include if k in _VALID_INCLUDE_KEYS}
|
||||
return cls(
|
||||
documents="documents" in keys,
|
||||
metadatas="metadatas" in keys,
|
||||
distances="distances" in keys,
|
||||
embeddings="embeddings" in keys,
|
||||
)
|
||||
@@ -0,0 +1,643 @@
|
||||
"""ChromaDB-backed MemPalace storage backend (RFC 001 reference implementation)."""
|
||||
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Any, Optional
|
||||
|
||||
import chromadb
|
||||
|
||||
from .base import (
|
||||
BaseBackend,
|
||||
BaseCollection,
|
||||
GetResult,
|
||||
HealthStatus,
|
||||
PalaceNotFoundError,
|
||||
PalaceRef,
|
||||
QueryResult,
|
||||
UnsupportedFilterError,
|
||||
_IncludeSpec,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_REQUIRED_OPERATORS = frozenset({"$eq", "$ne", "$in", "$nin", "$and", "$or", "$contains"})
|
||||
_OPTIONAL_OPERATORS = frozenset({"$gt", "$gte", "$lt", "$lte"})
|
||||
_SUPPORTED_OPERATORS = _REQUIRED_OPERATORS | _OPTIONAL_OPERATORS
|
||||
|
||||
|
||||
def _validate_where(where: Optional[dict]) -> None:
|
||||
"""Scan a where-clause for unknown operators and raise ``UnsupportedFilterError``.
|
||||
|
||||
Spec (RFC 001 §1.4): silent dropping of unknown operators is forbidden.
|
||||
"""
|
||||
if not where:
|
||||
return
|
||||
stack = [where]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
for k, v in node.items():
|
||||
if k.startswith("$") and k not in _SUPPORTED_OPERATORS:
|
||||
raise UnsupportedFilterError(f"operator {k!r} not supported by chroma backend")
|
||||
if isinstance(v, dict):
|
||||
stack.append(v)
|
||||
elif isinstance(v, list):
|
||||
stack.extend(x for x in v if isinstance(x, dict))
|
||||
|
||||
|
||||
def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 3600.0) -> list[str]:
|
||||
"""Rename HNSW segment dirs whose files are stale vs. chroma.sqlite3.
|
||||
|
||||
When a ChromaDB 1.5.x PersistentClient opens a palace whose on-disk
|
||||
HNSW segment is significantly older than ``chroma.sqlite3``, the Rust
|
||||
graph-walk can dereference dangling neighbor pointers for entries that
|
||||
exist in the metadata segment but not in the HNSW index, and segfault
|
||||
in a background thread on the next ``count()`` or ``query(...)`` call.
|
||||
|
||||
This is the same failure mode reported at #823 (semantic search stale
|
||||
after ``add_drawer``), observed at neo-cortex-mcp#2 (SIGSEGV on
|
||||
``count()`` with chromadb 1.5.5), and acknowledged as by-design at
|
||||
chroma-core/chroma#2594. On one fork palace (135K drawers), the drift
|
||||
caused a 65–85% crash rate on fresh-process opens; fresh-process
|
||||
crash rate dropped to 0% after the segment dir was renamed out of the
|
||||
way and ChromaDB rebuilt lazily.
|
||||
|
||||
Heuristic: if ``chroma.sqlite3`` is more than ``stale_seconds`` newer
|
||||
than the segment's ``data_level0.bin``, the segment is considered
|
||||
suspect and renamed to ``<uuid>.drift-<timestamp>``. ChromaDB reopens
|
||||
cleanly without it and writes fresh index files on next use. The
|
||||
original directory is renamed, not deleted, so recovery remains
|
||||
possible if the heuristic misfires.
|
||||
|
||||
The default threshold (1h) is deliberately conservative — ChromaDB's
|
||||
HNSW flush cadence means legitimate drift is normally on the order of
|
||||
seconds to minutes. A segment that is more than an hour out of date is
|
||||
almost certainly in a "crashed mid-write" state.
|
||||
|
||||
Args:
|
||||
palace_path: path to the palace directory containing ``chroma.sqlite3``
|
||||
stale_seconds: minimum mtime gap to treat a segment as stale
|
||||
|
||||
Returns:
|
||||
List of paths that were quarantined (empty if nothing drifted).
|
||||
"""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return []
|
||||
try:
|
||||
sqlite_mtime = os.path.getmtime(db_path)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
moved: list[str] = []
|
||||
try:
|
||||
entries = os.listdir(palace_path)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
for name in entries:
|
||||
if "-" not in name or name.startswith(".") or ".drift-" in name:
|
||||
continue
|
||||
seg_dir = os.path.join(palace_path, name)
|
||||
if not os.path.isdir(seg_dir):
|
||||
continue
|
||||
hnsw_bin = os.path.join(seg_dir, "data_level0.bin")
|
||||
if not os.path.isfile(hnsw_bin):
|
||||
continue
|
||||
try:
|
||||
hnsw_mtime = os.path.getmtime(hnsw_bin)
|
||||
except OSError:
|
||||
continue
|
||||
if sqlite_mtime - hnsw_mtime < stale_seconds:
|
||||
continue
|
||||
stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
target = f"{seg_dir}.drift-{stamp}"
|
||||
try:
|
||||
os.rename(seg_dir, target)
|
||||
moved.append(target)
|
||||
logger.warning(
|
||||
"Quarantined stale HNSW segment %s "
|
||||
"(sqlite %.0fs newer than HNSW); renamed to %s",
|
||||
seg_dir,
|
||||
sqlite_mtime - hnsw_mtime,
|
||||
target,
|
||||
)
|
||||
except OSError:
|
||||
logger.exception("Failed to quarantine stale HNSW segment %s", seg_dir)
|
||||
return moved
|
||||
|
||||
|
||||
def _fix_blob_seq_ids(palace_path: str) -> None:
|
||||
"""Fix ChromaDB 0.6.x -> 1.5.x migration bug: BLOB seq_ids -> INTEGER.
|
||||
|
||||
ChromaDB 0.6.x stored seq_id as big-endian 8-byte BLOBs. ChromaDB 1.5.x
|
||||
expects INTEGER. The auto-migration doesn't convert existing rows, causing
|
||||
the Rust compactor to crash with "mismatched types; Rust type u64 (as SQL
|
||||
type INTEGER) is not compatible with SQL type BLOB".
|
||||
|
||||
Must run BEFORE PersistentClient is created (the compactor fires on init).
|
||||
"""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
for table in ("embeddings", "max_seq_id"):
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"SELECT rowid, seq_id FROM {table} WHERE typeof(seq_id) = 'blob'"
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
continue
|
||||
if not rows:
|
||||
continue
|
||||
updates = [(int.from_bytes(blob, byteorder="big"), rowid) for rowid, blob in rows]
|
||||
conn.executemany(f"UPDATE {table} SET seq_id = ? WHERE rowid = ?", updates)
|
||||
logger.info("Fixed %d BLOB seq_ids in %s", len(updates), table)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Could not fix BLOB seq_ids in %s", db_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collection adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _as_list(v: Any) -> list:
|
||||
"""Coerce possibly-None scalar-or-list into a list (defensive for chroma nulls)."""
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
return [v]
|
||||
|
||||
|
||||
class ChromaCollection(BaseCollection):
|
||||
"""Thin adapter translating ChromaDB dict returns into typed results."""
|
||||
|
||||
def __init__(self, collection):
|
||||
self._collection = collection
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add(self, *, documents, ids, metadatas=None, embeddings=None):
|
||||
kwargs: dict[str, Any] = {"documents": documents, "ids": ids}
|
||||
if metadatas is not None:
|
||||
kwargs["metadatas"] = metadatas
|
||||
if embeddings is not None:
|
||||
kwargs["embeddings"] = embeddings
|
||||
self._collection.add(**kwargs)
|
||||
|
||||
def upsert(self, *, documents, ids, metadatas=None, embeddings=None):
|
||||
kwargs: dict[str, Any] = {"documents": documents, "ids": ids}
|
||||
if metadatas is not None:
|
||||
kwargs["metadatas"] = metadatas
|
||||
if embeddings is not None:
|
||||
kwargs["embeddings"] = embeddings
|
||||
self._collection.upsert(**kwargs)
|
||||
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
ids,
|
||||
documents=None,
|
||||
metadatas=None,
|
||||
embeddings=None,
|
||||
):
|
||||
if documents is None and metadatas is None and embeddings is None:
|
||||
raise ValueError("update requires at least one of documents, metadatas, embeddings")
|
||||
kwargs: dict[str, Any] = {"ids": ids}
|
||||
if documents is not None:
|
||||
kwargs["documents"] = documents
|
||||
if metadatas is not None:
|
||||
kwargs["metadatas"] = metadatas
|
||||
if embeddings is not None:
|
||||
kwargs["embeddings"] = embeddings
|
||||
self._collection.update(**kwargs)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reads
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def query(
|
||||
self,
|
||||
*,
|
||||
query_texts=None,
|
||||
query_embeddings=None,
|
||||
n_results=10,
|
||||
where=None,
|
||||
where_document=None,
|
||||
include=None,
|
||||
) -> QueryResult:
|
||||
_validate_where(where)
|
||||
_validate_where(where_document)
|
||||
|
||||
if (query_texts is None) == (query_embeddings is None):
|
||||
raise ValueError("query requires exactly one of query_texts or query_embeddings")
|
||||
chosen = query_texts if query_texts is not None else query_embeddings
|
||||
if not chosen:
|
||||
raise ValueError("query input must be a non-empty list")
|
||||
|
||||
spec = _IncludeSpec.resolve(include, default_distances=True)
|
||||
chroma_include: list[str] = []
|
||||
if spec.documents:
|
||||
chroma_include.append("documents")
|
||||
if spec.metadatas:
|
||||
chroma_include.append("metadatas")
|
||||
if spec.distances:
|
||||
chroma_include.append("distances")
|
||||
if spec.embeddings:
|
||||
chroma_include.append("embeddings")
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"n_results": n_results,
|
||||
"include": chroma_include,
|
||||
}
|
||||
if query_texts is not None:
|
||||
kwargs["query_texts"] = query_texts
|
||||
if query_embeddings is not None:
|
||||
kwargs["query_embeddings"] = query_embeddings
|
||||
if where is not None:
|
||||
kwargs["where"] = where
|
||||
if where_document is not None:
|
||||
kwargs["where_document"] = where_document
|
||||
|
||||
raw = self._collection.query(**kwargs)
|
||||
|
||||
num_queries = (
|
||||
len(query_texts)
|
||||
if query_texts is not None
|
||||
else (len(query_embeddings) if query_embeddings is not None else 1)
|
||||
)
|
||||
|
||||
ids = raw.get("ids") or []
|
||||
if not ids:
|
||||
return QueryResult.empty(
|
||||
num_queries=num_queries,
|
||||
embeddings_requested=spec.embeddings,
|
||||
)
|
||||
|
||||
documents = raw.get("documents") or [[] for _ in ids]
|
||||
metadatas = raw.get("metadatas") or [[] for _ in ids]
|
||||
distances = raw.get("distances") or [[] for _ in ids]
|
||||
embeddings_raw = raw.get("embeddings") if spec.embeddings else None
|
||||
|
||||
def _none_list_to_empty(outer):
|
||||
return [(inner or []) for inner in outer]
|
||||
|
||||
return QueryResult(
|
||||
ids=_none_list_to_empty(ids),
|
||||
documents=_none_list_to_empty(documents),
|
||||
metadatas=_none_list_to_empty(metadatas),
|
||||
distances=_none_list_to_empty(distances),
|
||||
embeddings=(
|
||||
[list(inner) for inner in embeddings_raw]
|
||||
if spec.embeddings and embeddings_raw is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def get(
|
||||
self,
|
||||
*,
|
||||
ids=None,
|
||||
where=None,
|
||||
where_document=None,
|
||||
limit=None,
|
||||
offset=None,
|
||||
include=None,
|
||||
) -> GetResult:
|
||||
_validate_where(where)
|
||||
_validate_where(where_document)
|
||||
|
||||
spec = _IncludeSpec.resolve(include, default_distances=False)
|
||||
chroma_include: list[str] = []
|
||||
if spec.documents:
|
||||
chroma_include.append("documents")
|
||||
if spec.metadatas:
|
||||
chroma_include.append("metadatas")
|
||||
if spec.embeddings:
|
||||
chroma_include.append("embeddings")
|
||||
|
||||
kwargs: dict[str, Any] = {"include": chroma_include}
|
||||
if ids is not None:
|
||||
kwargs["ids"] = ids
|
||||
if where is not None:
|
||||
kwargs["where"] = where
|
||||
if where_document is not None:
|
||||
kwargs["where_document"] = where_document
|
||||
if limit is not None:
|
||||
kwargs["limit"] = limit
|
||||
if offset is not None:
|
||||
kwargs["offset"] = offset
|
||||
|
||||
raw = self._collection.get(**kwargs)
|
||||
out_ids = list(raw.get("ids") or [])
|
||||
out_docs = list(raw.get("documents") or []) if spec.documents else []
|
||||
out_metas = list(raw.get("metadatas") or []) if spec.metadatas else []
|
||||
out_embeds = raw.get("embeddings") if spec.embeddings else None
|
||||
|
||||
# Pad doc/meta lists to match ids so downstream zipping is safe.
|
||||
if spec.documents and len(out_docs) < len(out_ids):
|
||||
out_docs = out_docs + [""] * (len(out_ids) - len(out_docs))
|
||||
if spec.metadatas and len(out_metas) < len(out_ids):
|
||||
out_metas = out_metas + [{}] * (len(out_ids) - len(out_metas))
|
||||
|
||||
return GetResult(
|
||||
ids=out_ids,
|
||||
documents=out_docs,
|
||||
metadatas=out_metas,
|
||||
embeddings=[list(v) for v in out_embeds] if out_embeds is not None else None,
|
||||
)
|
||||
|
||||
def delete(self, *, ids=None, where=None):
|
||||
_validate_where(where)
|
||||
kwargs: dict[str, Any] = {}
|
||||
if ids is not None:
|
||||
kwargs["ids"] = ids
|
||||
if where is not None:
|
||||
kwargs["where"] = where
|
||||
self._collection.delete(**kwargs)
|
||||
|
||||
def count(self):
|
||||
return self._collection.count()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ChromaBackend(BaseBackend):
|
||||
"""MemPalace's default ChromaDB backend.
|
||||
|
||||
Maintains two caches:
|
||||
|
||||
* ``self._clients`` — ``palace_path -> PersistentClient`` for callers
|
||||
using the ``PalaceRef`` / :meth:`get_collection` path.
|
||||
* An inode+mtime freshness check absorbed from ``mcp_server._get_client``
|
||||
(merged via #757) ensuring a palace rebuild on disk is detected on the
|
||||
next :meth:`get_collection` call.
|
||||
"""
|
||||
|
||||
name = "chroma"
|
||||
capabilities = frozenset(
|
||||
{
|
||||
"supports_embeddings_in",
|
||||
"supports_embeddings_passthrough",
|
||||
"supports_embeddings_out",
|
||||
"supports_metadata_filters",
|
||||
"supports_contains_fast",
|
||||
"local_mode",
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
# palace_path -> PersistentClient
|
||||
self._clients: dict[str, Any] = {}
|
||||
# palace_path -> (inode, mtime) of chroma.sqlite3 at cache time.
|
||||
self._freshness: dict[str, tuple[int, float]] = {}
|
||||
self._closed = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _db_stat(palace_path: str) -> tuple[int, float]:
|
||||
"""Return ``(inode, mtime)`` of ``chroma.sqlite3`` or ``(0, 0.0)`` if absent."""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
try:
|
||||
st = os.stat(db_path)
|
||||
return (st.st_ino, st.st_mtime)
|
||||
except OSError:
|
||||
return (0, 0.0)
|
||||
|
||||
def _client(self, palace_path: str):
|
||||
"""Return a cached ``PersistentClient``, rebuilding on inode/mtime change.
|
||||
|
||||
Handles the palace-rebuild case (repair/nuke/purge) by invalidating the
|
||||
cache when ``chroma.sqlite3`` changes on disk. Mirrors the semantics of
|
||||
``mcp_server._get_client`` (merged via #757):
|
||||
|
||||
* DB file missing while we hold a cached client → drop the cache so we
|
||||
do not serve stale data after a rebuild that has not yet re-created
|
||||
the DB.
|
||||
* Transition 0 → nonzero stat (DB created after cache) counts as a
|
||||
change, so the cached client is replaced with one that sees the DB.
|
||||
* FAT/exFAT filesystems return inode 0; we never fire inode comparisons
|
||||
when either side is 0 (safe fallback) but still honor mtime.
|
||||
* Mtime change uses an epsilon (0.01 s) to tolerate FS timestamp
|
||||
granularity without thrashing.
|
||||
"""
|
||||
if self._closed:
|
||||
from .base import BackendClosedError # late import avoids cycles at module load
|
||||
|
||||
raise BackendClosedError("ChromaBackend has been closed")
|
||||
|
||||
cached = self._clients.get(palace_path)
|
||||
cached_inode, cached_mtime = self._freshness.get(palace_path, (0, 0.0))
|
||||
current_inode, current_mtime = self._db_stat(palace_path)
|
||||
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
# DB was present when cache was built but is now missing → invalidate.
|
||||
if cached is not None and not os.path.isfile(db_path):
|
||||
self._clients.pop(palace_path, None)
|
||||
self._freshness.pop(palace_path, None)
|
||||
cached = None
|
||||
cached_inode, cached_mtime = 0, 0.0
|
||||
|
||||
inode_changed = current_inode != 0 and cached_inode != 0 and current_inode != cached_inode
|
||||
# Transition from no-stat (0.0) to a real stat counts as a change so we
|
||||
# pick up a DB that was created after the cache was built.
|
||||
mtime_appeared = cached_mtime == 0.0 and current_mtime != 0.0
|
||||
mtime_changed = (
|
||||
current_mtime != 0.0
|
||||
and cached_mtime != 0.0
|
||||
and abs(current_mtime - cached_mtime) > 0.01
|
||||
)
|
||||
|
||||
if cached is None or inode_changed or mtime_changed or mtime_appeared:
|
||||
_fix_blob_seq_ids(palace_path)
|
||||
cached = chromadb.PersistentClient(path=palace_path)
|
||||
self._clients[palace_path] = cached
|
||||
# Re-stat after the client constructor runs: chromadb creates
|
||||
# chroma.sqlite3 lazily, so the stat captured before the call
|
||||
# may still be (0, 0.0) on first open.
|
||||
self._freshness[palace_path] = self._db_stat(palace_path)
|
||||
return cached
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public static helpers (legacy; prefer :meth:`get_collection`)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def make_client(palace_path: str):
|
||||
"""Create a fresh ``PersistentClient`` (fixes BLOB seq_ids first).
|
||||
|
||||
Deprecated-ish: exposed for legacy long-lived callers that manage their
|
||||
own client cache. New code should obtain a collection through
|
||||
:meth:`get_collection` which manages caching internally.
|
||||
"""
|
||||
_fix_blob_seq_ids(palace_path)
|
||||
return chromadb.PersistentClient(path=palace_path)
|
||||
|
||||
@staticmethod
|
||||
def backend_version() -> str:
|
||||
"""Return the installed chromadb package version string."""
|
||||
return chromadb.__version__
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseBackend surface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_collection(
|
||||
self,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> ChromaCollection:
|
||||
"""Obtain a collection for a palace.
|
||||
|
||||
Supports two calling conventions during the RFC 001 transition:
|
||||
|
||||
* New (preferred): ``get_collection(palace=PalaceRef, collection_name=...,
|
||||
create=False, options=None)``.
|
||||
* Legacy: ``get_collection(palace_path, collection_name, create=False)``
|
||||
— still used by callers not yet migrated.
|
||||
"""
|
||||
palace_ref, collection_name, create, options = _normalize_get_collection_args(args, kwargs)
|
||||
|
||||
palace_path = palace_ref.local_path
|
||||
if palace_path is None:
|
||||
raise PalaceNotFoundError("ChromaBackend requires PalaceRef.local_path")
|
||||
|
||||
if not create and not os.path.isdir(palace_path):
|
||||
raise PalaceNotFoundError(palace_path)
|
||||
|
||||
if create:
|
||||
os.makedirs(palace_path, exist_ok=True)
|
||||
try:
|
||||
os.chmod(palace_path, 0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
client = self._client(palace_path)
|
||||
hnsw_space = "cosine"
|
||||
if options and isinstance(options, dict):
|
||||
hnsw_space = options.get("hnsw_space", hnsw_space)
|
||||
|
||||
if create:
|
||||
collection = client.get_or_create_collection(
|
||||
collection_name, metadata={"hnsw:space": hnsw_space}
|
||||
)
|
||||
else:
|
||||
collection = client.get_collection(collection_name)
|
||||
return ChromaCollection(collection)
|
||||
|
||||
def close_palace(self, palace) -> None:
|
||||
"""Drop cached handles for ``palace``. Accepts ``PalaceRef`` or legacy path str."""
|
||||
path = palace.local_path if isinstance(palace, PalaceRef) else palace
|
||||
if path is None:
|
||||
return
|
||||
self._clients.pop(path, None)
|
||||
self._freshness.pop(path, None)
|
||||
|
||||
def close(self) -> None:
|
||||
self._clients.clear()
|
||||
self._freshness.clear()
|
||||
self._closed = True
|
||||
|
||||
def health(self, palace: Optional[PalaceRef] = None) -> HealthStatus:
|
||||
if self._closed:
|
||||
return HealthStatus.unhealthy("backend closed")
|
||||
return HealthStatus.healthy()
|
||||
|
||||
@classmethod
|
||||
def detect(cls, path: str) -> bool:
|
||||
return os.path.isfile(os.path.join(path, "chroma.sqlite3"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Legacy (pre-RFC 001) surface — retained while callers migrate.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_or_create_collection(self, palace_path: str, collection_name: str) -> ChromaCollection:
|
||||
"""Legacy shim for ``get_collection(..., create=True)`` by path string."""
|
||||
return self.get_collection(palace_path, collection_name, create=True)
|
||||
|
||||
def delete_collection(self, palace_path: str, collection_name: str) -> None:
|
||||
"""Delete ``collection_name`` from the palace at ``palace_path``."""
|
||||
self._client(palace_path).delete_collection(collection_name)
|
||||
|
||||
def create_collection(
|
||||
self, palace_path: str, collection_name: str, hnsw_space: str = "cosine"
|
||||
) -> ChromaCollection:
|
||||
"""Create (not get-or-create) ``collection_name`` with the given HNSW space."""
|
||||
collection = self._client(palace_path).create_collection(
|
||||
collection_name, metadata={"hnsw:space": hnsw_space}
|
||||
)
|
||||
return ChromaCollection(collection)
|
||||
|
||||
|
||||
def _normalize_get_collection_args(args, kwargs):
|
||||
"""Unify legacy positional ``(palace_path, collection_name, create)`` calls
|
||||
with the new kwargs-only ``(palace=PalaceRef, collection_name=..., create=...)``.
|
||||
|
||||
Returns ``(PalaceRef, collection_name, create, options)``.
|
||||
"""
|
||||
# New-style: palace= kwarg with a PalaceRef (spec path).
|
||||
if "palace" in kwargs:
|
||||
palace_ref = kwargs.pop("palace")
|
||||
if not isinstance(palace_ref, PalaceRef):
|
||||
raise TypeError("palace= must be a PalaceRef instance")
|
||||
collection_name = kwargs.pop("collection_name")
|
||||
create = kwargs.pop("create", False)
|
||||
options = kwargs.pop("options", None)
|
||||
if kwargs:
|
||||
raise TypeError(f"unexpected kwargs: {sorted(kwargs)}")
|
||||
if args:
|
||||
raise TypeError("positional args not allowed with palace= kwarg")
|
||||
return palace_ref, collection_name, create, options
|
||||
|
||||
# Legacy: first positional is a path string.
|
||||
if args:
|
||||
palace_path = args[0]
|
||||
rest = list(args[1:])
|
||||
collection_name = kwargs.pop("collection_name", None) or (rest.pop(0) if rest else None)
|
||||
if collection_name is None:
|
||||
raise TypeError("collection_name is required")
|
||||
create = kwargs.pop("create", False)
|
||||
if rest:
|
||||
create = rest.pop(0)
|
||||
if rest:
|
||||
raise TypeError(f"unexpected positional args: {rest!r}")
|
||||
if kwargs:
|
||||
raise TypeError(f"unexpected kwargs: {sorted(kwargs)}")
|
||||
return (
|
||||
PalaceRef(id=palace_path, local_path=palace_path),
|
||||
collection_name,
|
||||
bool(create),
|
||||
None,
|
||||
)
|
||||
|
||||
# Legacy kwargs-only (palace_path=..., collection_name=..., create=...)
|
||||
if "palace_path" in kwargs:
|
||||
palace_path = kwargs.pop("palace_path")
|
||||
collection_name = kwargs.pop("collection_name")
|
||||
create = kwargs.pop("create", False)
|
||||
if kwargs:
|
||||
raise TypeError(f"unexpected kwargs: {sorted(kwargs)}")
|
||||
return (
|
||||
PalaceRef(id=palace_path, local_path=palace_path),
|
||||
collection_name,
|
||||
bool(create),
|
||||
None,
|
||||
)
|
||||
|
||||
raise TypeError("get_collection requires palace= or a positional palace_path")
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Backend registry + entry-point discovery (RFC 001 §3).
|
||||
|
||||
Third-party backends ship as installable packages that declare a
|
||||
``mempalace.backends`` entry point::
|
||||
|
||||
# pyproject.toml of mempalace-postgres
|
||||
[project.entry-points."mempalace.backends"]
|
||||
postgres = "mempalace_postgres:PostgresBackend"
|
||||
|
||||
MemPalace discovers them at process start. In-tree tests and local development
|
||||
can register manually via :func:`register`. Explicit registration wins on
|
||||
name conflict (matches RFC 001 §3.2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from importlib import metadata
|
||||
from threading import Lock
|
||||
from typing import Optional, Type
|
||||
|
||||
from .base import BaseBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENTRY_POINT_GROUP = "mempalace.backends"
|
||||
|
||||
_registry: dict[str, Type[BaseBackend]] = {}
|
||||
_instances: dict[str, BaseBackend] = {}
|
||||
_explicit: set[str] = set()
|
||||
_discovered = False
|
||||
_lock = Lock()
|
||||
|
||||
|
||||
def register(name: str, backend_cls: Type[BaseBackend]) -> None:
|
||||
"""Register ``backend_cls`` under ``name``.
|
||||
|
||||
Explicit registration wins over entry-point discovery on conflict
|
||||
(RFC 001 §3.2).
|
||||
"""
|
||||
with _lock:
|
||||
_registry[name] = backend_cls
|
||||
_explicit.add(name)
|
||||
# Invalidate any cached instance so the new class is used on next get.
|
||||
_instances.pop(name, None)
|
||||
|
||||
|
||||
def unregister(name: str) -> None:
|
||||
"""Remove a backend registration (primarily for tests)."""
|
||||
with _lock:
|
||||
_registry.pop(name, None)
|
||||
_explicit.discard(name)
|
||||
_instances.pop(name, None)
|
||||
|
||||
|
||||
def _discover_entry_points() -> None:
|
||||
"""Load entry-point-declared backends once per process."""
|
||||
global _discovered
|
||||
if _discovered:
|
||||
return
|
||||
with _lock:
|
||||
if _discovered:
|
||||
return
|
||||
try:
|
||||
eps = metadata.entry_points()
|
||||
# Py ≥ 3.10 returns an EntryPoints object; older versions returned a dict.
|
||||
group = (
|
||||
eps.select(group=_ENTRY_POINT_GROUP)
|
||||
if hasattr(eps, "select")
|
||||
else eps.get(_ENTRY_POINT_GROUP, [])
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("entry-point discovery for %s failed", _ENTRY_POINT_GROUP)
|
||||
group = []
|
||||
for ep in group:
|
||||
if ep.name in _explicit:
|
||||
continue # explicit registration wins
|
||||
try:
|
||||
cls = ep.load()
|
||||
except Exception:
|
||||
logger.exception("failed to load backend entry point %r", ep.name)
|
||||
continue
|
||||
if not isinstance(cls, type) or not issubclass(cls, BaseBackend):
|
||||
logger.warning(
|
||||
"entry point %r did not resolve to a BaseBackend subclass (got %r)",
|
||||
ep.name,
|
||||
cls,
|
||||
)
|
||||
continue
|
||||
_registry.setdefault(ep.name, cls)
|
||||
_discovered = True
|
||||
|
||||
|
||||
def available_backends() -> list[str]:
|
||||
"""Return sorted list of all registered backend names."""
|
||||
_discover_entry_points()
|
||||
return sorted(_registry.keys())
|
||||
|
||||
|
||||
def get_backend_class(name: str) -> Type[BaseBackend]:
|
||||
"""Return the registered backend class for ``name``."""
|
||||
_discover_entry_points()
|
||||
try:
|
||||
return _registry[name]
|
||||
except KeyError as e:
|
||||
raise KeyError(f"unknown backend {name!r}; available: {available_backends()}") from e
|
||||
|
||||
|
||||
def get_backend(name: str) -> BaseBackend:
|
||||
"""Return a long-lived instance of the named backend.
|
||||
|
||||
Instances are cached per-name; repeated calls return the same object.
|
||||
Call :func:`reset_backends` in tests that need isolation.
|
||||
"""
|
||||
_discover_entry_points()
|
||||
with _lock:
|
||||
inst = _instances.get(name)
|
||||
if inst is not None:
|
||||
return inst
|
||||
cls = _registry.get(name)
|
||||
if cls is None:
|
||||
raise KeyError(f"unknown backend {name!r}; available: {sorted(_registry.keys())}")
|
||||
inst = cls()
|
||||
_instances[name] = inst
|
||||
return inst
|
||||
|
||||
|
||||
def reset_backends() -> None:
|
||||
"""Close and drop all cached backend instances (primarily for tests)."""
|
||||
with _lock:
|
||||
for inst in _instances.values():
|
||||
try:
|
||||
inst.close()
|
||||
except Exception:
|
||||
logger.exception("error closing backend during reset")
|
||||
_instances.clear()
|
||||
|
||||
|
||||
def resolve_backend_for_palace(
|
||||
*,
|
||||
explicit: Optional[str] = None,
|
||||
config_value: Optional[str] = None,
|
||||
env_value: Optional[str] = None,
|
||||
palace_path: Optional[str] = None,
|
||||
default: str = "chroma",
|
||||
) -> str:
|
||||
"""Resolve the backend name for a palace per RFC 001 §3.3 priority order.
|
||||
|
||||
1. Explicit kwarg / CLI flag
|
||||
2. Per-palace config value
|
||||
3. ``MEMPALACE_BACKEND`` env var
|
||||
4. Auto-detect from on-disk artifacts (migration/upgrade path only)
|
||||
5. Default (``chroma``)
|
||||
|
||||
Auto-detection is strictly a migration aid: it fires only when a local path
|
||||
is presented, no earlier rule has chosen a backend, AND the path already
|
||||
contains backend-identifiable artifacts. For new palaces, (5) wins.
|
||||
"""
|
||||
for candidate in (explicit, config_value, env_value):
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
_discover_entry_points()
|
||||
if palace_path:
|
||||
for name, cls in _registry.items():
|
||||
try:
|
||||
if cls.detect(palace_path):
|
||||
return name
|
||||
except Exception:
|
||||
logger.exception("detect() raised on backend %r", name)
|
||||
continue
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_builtins() -> None:
|
||||
"""Register chroma as the in-tree default."""
|
||||
from .chroma import ChromaBackend
|
||||
|
||||
# Use setdefault semantics so a caller that pre-registered for tests wins.
|
||||
if "chroma" not in _registry:
|
||||
_registry["chroma"] = ChromaBackend
|
||||
|
||||
|
||||
_register_builtins()
|
||||
+412
-40
@@ -4,7 +4,7 @@ MemPalace — Give your AI a memory. No API key required.
|
||||
|
||||
Two ways to ingest:
|
||||
Projects: mempalace mine ~/projects/my_app (code, docs, notes)
|
||||
Conversations: mempalace mine ~/chats/ --mode convos (Claude, ChatGPT, Slack)
|
||||
Conversations: mempalace mine <convo-dir> --mode convos (Claude Code, Claude.ai, ChatGPT, Slack exports)
|
||||
|
||||
Same palace. Same search. Different ingest strategies.
|
||||
|
||||
@@ -14,6 +14,7 @@ Commands:
|
||||
mempalace mine <dir> Mine project files (default)
|
||||
mempalace mine <dir> --mode convos Mine conversation exports
|
||||
mempalace search "query" Find anything, exact words
|
||||
mempalace mcp Show MCP setup command
|
||||
mempalace wake-up Show L0 + L1 wake-up context
|
||||
mempalace wake-up --wing my_app Wake-up for a specific project
|
||||
mempalace status Show what's been filed
|
||||
@@ -21,17 +22,50 @@ Commands:
|
||||
Examples:
|
||||
mempalace init ~/projects/my_app
|
||||
mempalace mine ~/projects/my_app
|
||||
mempalace mine ~/chats/claude-sessions --mode convos
|
||||
mempalace mine ~/.claude/projects/-Users-you-Projects-my_app --mode convos --wing my_app
|
||||
mempalace search "why did we switch to GraphQL"
|
||||
mempalace search "pricing discussion" --wing my_app --room costs
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shlex
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from .config import MempalaceConfig
|
||||
from .version import __version__
|
||||
|
||||
|
||||
_MEMPALACE_PROJECT_FILES = ("mempalace.yaml", "entities.json")
|
||||
|
||||
|
||||
def _ensure_mempalace_files_gitignored(project_dir) -> bool:
|
||||
"""If project_dir is a git repo, ensure MemPalace's per-project files
|
||||
are listed in .gitignore so they don't get committed by accident.
|
||||
|
||||
Returns True if .gitignore was updated, False otherwise. Issue #185:
|
||||
`mempalace init` writes mempalace.yaml + entities.json into the
|
||||
project root, where they previously had no protection against being
|
||||
staged into git.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
project_path = Path(project_dir).expanduser().resolve()
|
||||
if not (project_path / ".git").exists():
|
||||
return False
|
||||
gitignore = project_path / ".gitignore"
|
||||
existing = gitignore.read_text() if gitignore.exists() else ""
|
||||
existing_lines = {line.strip() for line in existing.splitlines()}
|
||||
missing = [p for p in _MEMPALACE_PROJECT_FILES if p not in existing_lines]
|
||||
if not missing:
|
||||
return False
|
||||
prefix = "" if not existing or existing.endswith("\n") else "\n"
|
||||
block = prefix + "\n# MemPalace per-project files (issue #185)\n" + "\n".join(missing) + "\n"
|
||||
with open(gitignore, "a") as f:
|
||||
f.write(block)
|
||||
print(f" Added {', '.join(missing)} to {gitignore.name}")
|
||||
return True
|
||||
|
||||
|
||||
def cmd_init(args):
|
||||
@@ -40,12 +74,25 @@ def cmd_init(args):
|
||||
from .entity_detector import scan_for_detection, detect_entities, confirm_entities
|
||||
from .room_detector_local import detect_rooms_local
|
||||
|
||||
cfg = MempalaceConfig()
|
||||
|
||||
# Resolve entity-detection languages: --lang overrides config.
|
||||
lang_arg = getattr(args, "lang", None)
|
||||
if lang_arg:
|
||||
languages = [s.strip() for s in lang_arg.split(",") if s.strip()] or ["en"]
|
||||
cfg.set_entity_languages(languages)
|
||||
else:
|
||||
languages = cfg.entity_languages
|
||||
languages_tuple = tuple(languages)
|
||||
|
||||
# Pass 1: auto-detect people and projects from file content
|
||||
print(f"\n Scanning for entities in: {args.dir}")
|
||||
if languages_tuple != ("en",):
|
||||
print(f" Languages: {', '.join(languages_tuple)}")
|
||||
files = scan_for_detection(args.dir)
|
||||
if files:
|
||||
print(f" Reading {len(files)} files...")
|
||||
detected = detect_entities(files)
|
||||
detected = detect_entities(files, languages=languages_tuple)
|
||||
total = len(detected["people"]) + len(detected["projects"]) + len(detected["uncertain"])
|
||||
if total > 0:
|
||||
confirmed = confirm_entities(detected, yes=getattr(args, "yes", False))
|
||||
@@ -59,12 +106,18 @@ def cmd_init(args):
|
||||
print(" No entities detected — proceeding with directory-based rooms.")
|
||||
|
||||
# Pass 2: detect rooms from folder structure
|
||||
detect_rooms_local(project_dir=args.dir)
|
||||
MempalaceConfig().init()
|
||||
detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False))
|
||||
cfg.init()
|
||||
|
||||
# Pass 3: protect git repos from accidentally committing per-project files
|
||||
_ensure_mempalace_files_gitignored(args.dir)
|
||||
|
||||
|
||||
def cmd_mine(args):
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
include_ignored = []
|
||||
for raw in args.include_ignored or []:
|
||||
include_ignored.extend(part.strip() for part in raw.split(",") if part.strip())
|
||||
|
||||
if args.mode == "convos":
|
||||
from .convo_miner import mine_convos
|
||||
@@ -88,20 +141,67 @@ def cmd_mine(args):
|
||||
agent=args.agent,
|
||||
limit=args.limit,
|
||||
dry_run=args.dry_run,
|
||||
respect_gitignore=not args.no_gitignore,
|
||||
include_ignored=include_ignored,
|
||||
)
|
||||
|
||||
|
||||
def cmd_search(args):
|
||||
from .searcher import search
|
||||
def cmd_sweep(args):
|
||||
"""Sweep a transcript file or directory.
|
||||
|
||||
The sweeper deduplicates against its own prior writes via
|
||||
deterministic drawer IDs + a timestamp cursor. It does NOT currently
|
||||
coordinate with the file-level miners (miner.py / convo_miner.py) —
|
||||
those produce char-chunked drawers without compatible message
|
||||
metadata, so running both miners may store overlapping content under
|
||||
different IDs.
|
||||
"""
|
||||
from .sweeper import sweep, sweep_directory
|
||||
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
search(
|
||||
query=args.query,
|
||||
palace_path=palace_path,
|
||||
wing=args.wing,
|
||||
room=args.room,
|
||||
n_results=args.results,
|
||||
)
|
||||
target = os.path.expanduser(args.target)
|
||||
|
||||
if os.path.isfile(target):
|
||||
result = sweep(target, palace_path)
|
||||
print(
|
||||
f" Swept {target}: +{result['drawers_added']} new, "
|
||||
f"{result['drawers_already_present']} already present, "
|
||||
f"{result['drawers_skipped']} skipped (< cursor)."
|
||||
)
|
||||
elif os.path.isdir(target):
|
||||
result = sweep_directory(target, palace_path)
|
||||
print(
|
||||
f" Swept {result['files_succeeded']}/{result['files_attempted']} "
|
||||
f"files from {target}: +{result['drawers_added']} new, "
|
||||
f"{result['drawers_already_present']} already present, "
|
||||
f"{result['drawers_skipped']} skipped (< cursor)."
|
||||
)
|
||||
failures = result.get("failures") or []
|
||||
if failures:
|
||||
print(
|
||||
f" WARNING: {len(failures)} file(s) failed to sweep - see stderr / logs for details.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
else:
|
||||
print(f" ERROR: Not a file or directory: {target}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_search(args):
|
||||
from .searcher import search, SearchError
|
||||
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
try:
|
||||
search(
|
||||
query=args.query,
|
||||
palace_path=palace_path,
|
||||
wing=args.wing,
|
||||
room=args.room,
|
||||
n_results=args.results,
|
||||
)
|
||||
except SearchError:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_wakeup(args):
|
||||
@@ -124,7 +224,8 @@ def cmd_split(args):
|
||||
import sys
|
||||
|
||||
# Rebuild argv for split_mega_files argparse
|
||||
argv = [args.dir]
|
||||
# Expand ~ and resolve to absolute path so split_mega_files sees a real path
|
||||
argv = ["--source", str(Path(args.dir).expanduser().resolve())]
|
||||
if args.output_dir:
|
||||
argv += ["--output-dir", args.output_dir]
|
||||
if args.dry_run:
|
||||
@@ -140,6 +241,18 @@ def cmd_split(args):
|
||||
sys.argv = old_argv
|
||||
|
||||
|
||||
def cmd_migrate(args):
|
||||
"""Migrate palace from a different ChromaDB version."""
|
||||
from .migrate import migrate
|
||||
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
migrate(
|
||||
palace_path=palace_path,
|
||||
dry_run=args.dry_run,
|
||||
confirm=getattr(args, "yes", False),
|
||||
)
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
from .miner import status
|
||||
|
||||
@@ -147,9 +260,135 @@ def cmd_status(args):
|
||||
status(palace_path=palace_path)
|
||||
|
||||
|
||||
def cmd_repair(args):
|
||||
"""Rebuild palace vector index from SQLite metadata."""
|
||||
import shutil
|
||||
from .backends.chroma import ChromaBackend
|
||||
from .migrate import confirm_destructive_action, contains_palace_database
|
||||
|
||||
palace_path = os.path.abspath(
|
||||
os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
)
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
|
||||
if not os.path.isdir(palace_path):
|
||||
print(f"\n No palace found at {palace_path}")
|
||||
return
|
||||
if not contains_palace_database(palace_path):
|
||||
print(f"\n No palace database found at {db_path}")
|
||||
return
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" MemPalace Repair")
|
||||
print(f"{'=' * 55}\n")
|
||||
print(f" Palace: {palace_path}")
|
||||
|
||||
backend = ChromaBackend()
|
||||
|
||||
# Try to read existing drawers
|
||||
try:
|
||||
col = backend.get_collection(palace_path, "mempalace_drawers")
|
||||
total = col.count()
|
||||
print(f" Drawers found: {total}")
|
||||
except Exception as e:
|
||||
print(f" Error reading palace: {e}")
|
||||
print(" Cannot recover — palace may need to be re-mined from source files.")
|
||||
return
|
||||
|
||||
if total == 0:
|
||||
print(" Nothing to repair.")
|
||||
return
|
||||
|
||||
if not confirm_destructive_action(
|
||||
"Repair", palace_path, assume_yes=getattr(args, "yes", False)
|
||||
):
|
||||
return
|
||||
|
||||
# Extract all drawers in batches
|
||||
print("\n Extracting drawers...")
|
||||
batch_size = 5000
|
||||
all_ids = []
|
||||
all_docs = []
|
||||
all_metas = []
|
||||
offset = 0
|
||||
while offset < total:
|
||||
batch = col.get(limit=batch_size, offset=offset, include=["documents", "metadatas"])
|
||||
all_ids.extend(batch["ids"])
|
||||
all_docs.extend(batch["documents"])
|
||||
all_metas.extend(batch["metadatas"])
|
||||
offset += batch_size
|
||||
print(f" Extracted {len(all_ids)} drawers")
|
||||
|
||||
# Backup and rebuild
|
||||
palace_path = os.path.normpath(palace_path)
|
||||
backup_path = palace_path + ".backup"
|
||||
if os.path.exists(backup_path):
|
||||
if not contains_palace_database(backup_path):
|
||||
print(
|
||||
" Backup validation failed: backup path exists but does not contain chroma.sqlite3. "
|
||||
f"Please remove or rename: {backup_path}"
|
||||
)
|
||||
return
|
||||
shutil.rmtree(backup_path)
|
||||
print(f" Backing up to {backup_path}...")
|
||||
shutil.copytree(palace_path, backup_path)
|
||||
|
||||
print(" Rebuilding collection...")
|
||||
backend.delete_collection(palace_path, "mempalace_drawers")
|
||||
new_col = backend.create_collection(palace_path, "mempalace_drawers")
|
||||
|
||||
filed = 0
|
||||
for i in range(0, len(all_ids), batch_size):
|
||||
batch_ids = all_ids[i : i + batch_size]
|
||||
batch_docs = all_docs[i : i + batch_size]
|
||||
batch_metas = all_metas[i : i + batch_size]
|
||||
new_col.add(documents=batch_docs, ids=batch_ids, metadatas=batch_metas)
|
||||
filed += len(batch_ids)
|
||||
print(f" Re-filed {filed}/{len(all_ids)} drawers...")
|
||||
|
||||
print(f"\n Repair complete. {filed} drawers rebuilt.")
|
||||
print(f" Backup saved at {backup_path}")
|
||||
print(f"\n{'=' * 55}\n")
|
||||
|
||||
|
||||
def cmd_hook(args):
|
||||
"""Run hook logic: reads JSON from stdin, outputs JSON to stdout."""
|
||||
from .hooks_cli import run_hook
|
||||
|
||||
run_hook(hook_name=args.hook, harness=args.harness)
|
||||
|
||||
|
||||
def cmd_instructions(args):
|
||||
"""Output skill instructions to stdout."""
|
||||
from .instructions_cli import run_instructions
|
||||
|
||||
run_instructions(name=args.name)
|
||||
|
||||
|
||||
def cmd_mcp(args):
|
||||
"""Show how to wire MemPalace into MCP-capable hosts."""
|
||||
base_server_cmd = "python -m mempalace.mcp_server"
|
||||
|
||||
if args.palace:
|
||||
resolved_palace = str(Path(args.palace).expanduser())
|
||||
server_cmd = f"{base_server_cmd} --palace {shlex.quote(resolved_palace)}"
|
||||
else:
|
||||
server_cmd = base_server_cmd
|
||||
|
||||
print("MemPalace MCP quick setup:")
|
||||
print(f" claude mcp add mempalace -- {server_cmd}")
|
||||
print("\nRun the server directly:")
|
||||
print(f" {server_cmd}")
|
||||
|
||||
if not args.palace:
|
||||
print("\nOptional custom palace:")
|
||||
print(f" claude mcp add mempalace -- {base_server_cmd} --palace /path/to/palace")
|
||||
print(f" {base_server_cmd} --palace /path/to/palace")
|
||||
|
||||
|
||||
def cmd_compress(args):
|
||||
"""Compress drawers in a wing using AAAK Dialect."""
|
||||
import chromadb
|
||||
from .backends.chroma import ChromaBackend
|
||||
from .dialect import Dialect
|
||||
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
@@ -169,28 +408,43 @@ def cmd_compress(args):
|
||||
dialect = Dialect()
|
||||
|
||||
# Connect to palace
|
||||
backend = ChromaBackend()
|
||||
try:
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
col = backend.get_collection(palace_path, "mempalace_drawers")
|
||||
except Exception:
|
||||
print(f"\n No palace found at {palace_path}")
|
||||
print(" Run: mempalace init <dir> then mempalace mine <dir>")
|
||||
sys.exit(1)
|
||||
|
||||
# Query drawers in the wing
|
||||
# Query drawers in batches to avoid SQLite variable limit (~999)
|
||||
where = {"wing": args.wing} if args.wing else None
|
||||
try:
|
||||
kwargs = {"include": ["documents", "metadatas"]}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
results = col.get(**kwargs)
|
||||
except Exception as e:
|
||||
print(f"\n Error reading drawers: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
docs = results["documents"]
|
||||
metas = results["metadatas"]
|
||||
ids = results["ids"]
|
||||
_BATCH = 500
|
||||
docs, metas, ids = [], [], []
|
||||
offset = 0
|
||||
while True:
|
||||
try:
|
||||
kwargs = {
|
||||
"include": ["documents", "metadatas"],
|
||||
"limit": _BATCH,
|
||||
"offset": offset,
|
||||
}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
batch = col.get(**kwargs)
|
||||
except Exception as e:
|
||||
if not docs:
|
||||
print(f"\n Error reading drawers: {e}")
|
||||
sys.exit(1)
|
||||
break
|
||||
batch_docs = batch.get("documents", [])
|
||||
if not batch_docs:
|
||||
break
|
||||
docs.extend(batch_docs)
|
||||
metas.extend(batch.get("metadatas", []))
|
||||
ids.extend(batch.get("ids", []))
|
||||
offset += len(batch_docs)
|
||||
if len(batch_docs) < _BATCH:
|
||||
break
|
||||
|
||||
if not docs:
|
||||
wing_label = f" in wing '{args.wing}'" if args.wing else ""
|
||||
@@ -213,7 +467,7 @@ def cmd_compress(args):
|
||||
stats = dialect.compression_stats(doc, compressed)
|
||||
|
||||
total_original += stats["original_chars"]
|
||||
total_compressed += stats["compressed_chars"]
|
||||
total_compressed += stats["summary_chars"]
|
||||
|
||||
compressed_entries.append((doc_id, compressed, meta, stats))
|
||||
|
||||
@@ -223,7 +477,7 @@ def cmd_compress(args):
|
||||
source = Path(meta.get("source_file", "?")).name
|
||||
print(f" [{wing_name}/{room_name}] {source}")
|
||||
print(
|
||||
f" {stats['original_tokens']}t -> {stats['compressed_tokens']}t ({stats['ratio']:.1f}x)"
|
||||
f" {stats['original_tokens_est']}t -> {stats['summary_tokens_est']}t ({stats['size_ratio']:.1f}x)"
|
||||
)
|
||||
print(f" {compressed}")
|
||||
print()
|
||||
@@ -231,11 +485,11 @@ def cmd_compress(args):
|
||||
# Store compressed versions (unless dry-run)
|
||||
if not args.dry_run:
|
||||
try:
|
||||
comp_col = client.get_or_create_collection("mempalace_compressed")
|
||||
comp_col = backend.get_or_create_collection(palace_path, "mempalace_compressed")
|
||||
for doc_id, compressed, meta, stats in compressed_entries:
|
||||
comp_meta = dict(meta)
|
||||
comp_meta["compression_ratio"] = round(stats["ratio"], 1)
|
||||
comp_meta["original_tokens"] = stats["original_tokens"]
|
||||
comp_meta["compression_ratio"] = round(stats["size_ratio"], 1)
|
||||
comp_meta["original_tokens"] = stats["original_tokens_est"]
|
||||
comp_col.upsert(
|
||||
ids=[doc_id],
|
||||
documents=[compressed],
|
||||
@@ -250,18 +504,26 @@ def cmd_compress(args):
|
||||
|
||||
# Summary
|
||||
ratio = total_original / max(total_compressed, 1)
|
||||
orig_tokens = Dialect.count_tokens("x" * total_original)
|
||||
comp_tokens = Dialect.count_tokens("x" * total_compressed)
|
||||
# Estimate tokens from char count (~3.8 chars/token for English text)
|
||||
orig_tokens = max(1, int(total_original / 3.8))
|
||||
comp_tokens = max(1, int(total_compressed / 3.8))
|
||||
print(f" Total: {orig_tokens:,}t -> {comp_tokens:,}t ({ratio:.1f}x compression)")
|
||||
if args.dry_run:
|
||||
print(" (dry run -- nothing stored)")
|
||||
|
||||
|
||||
def main():
|
||||
version_label = f"MemPalace {__version__}"
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MemPalace — Give your AI a memory. No API key required.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
epilog=f"{version_label}\n\n{__doc__}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=version_label,
|
||||
help="Show version and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--palace",
|
||||
@@ -275,7 +537,19 @@ def main():
|
||||
p_init = sub.add_parser("init", help="Detect rooms from your folder structure")
|
||||
p_init.add_argument("dir", help="Project directory to set up")
|
||||
p_init.add_argument(
|
||||
"--yes", action="store_true", help="Auto-accept all detected entities (non-interactive)"
|
||||
"--yes",
|
||||
action="store_true",
|
||||
help="Auto-accept all detected entities (non-interactive)",
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--lang",
|
||||
default=None,
|
||||
help=(
|
||||
"Comma-separated language codes for entity detection "
|
||||
"(e.g. 'en' or 'en,pt-br'). Defaults to value from config "
|
||||
"(MEMPALACE_ENTITY_LANGUAGES env var or config.json), or 'en'. "
|
||||
"When given, the value is also persisted to config.json."
|
||||
),
|
||||
)
|
||||
|
||||
# mine
|
||||
@@ -288,6 +562,17 @@ def main():
|
||||
help="Ingest mode: 'projects' for code/docs (default), 'convos' for chat exports",
|
||||
)
|
||||
p_mine.add_argument("--wing", default=None, help="Wing name (default: directory name)")
|
||||
p_mine.add_argument(
|
||||
"--no-gitignore",
|
||||
action="store_true",
|
||||
help="Don't respect .gitignore files when scanning project files",
|
||||
)
|
||||
p_mine.add_argument(
|
||||
"--include-ignored",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Always scan these project-relative paths even if ignored; repeat or pass comma-separated paths",
|
||||
)
|
||||
p_mine.add_argument(
|
||||
"--agent",
|
||||
default="mempalace",
|
||||
@@ -304,6 +589,17 @@ def main():
|
||||
help="Extraction strategy for convos mode: 'exchange' (default) or 'general' (5 memory types)",
|
||||
)
|
||||
|
||||
# sweep
|
||||
p_sweep = sub.add_parser(
|
||||
"sweep",
|
||||
help="Tandem miner: catch anything the primary miner missed "
|
||||
"(message-level, timestamp-coordinated, idempotent)",
|
||||
)
|
||||
p_sweep.add_argument(
|
||||
"target",
|
||||
help="A .jsonl transcript file, or a directory to scan recursively",
|
||||
)
|
||||
|
||||
# search
|
||||
p_search = sub.add_parser("search", help="Find anything, exact words")
|
||||
p_search.add_argument("query", help="What to search for")
|
||||
@@ -350,7 +646,62 @@ def main():
|
||||
help="Only split files containing at least N sessions (default: 2)",
|
||||
)
|
||||
|
||||
# hook
|
||||
p_hook = sub.add_parser(
|
||||
"hook",
|
||||
help="Run hook logic (reads JSON from stdin, outputs JSON to stdout)",
|
||||
)
|
||||
hook_sub = p_hook.add_subparsers(dest="hook_action")
|
||||
p_hook_run = hook_sub.add_parser("run", help="Execute a hook")
|
||||
p_hook_run.add_argument(
|
||||
"--hook",
|
||||
required=True,
|
||||
choices=["session-start", "stop", "precompact"],
|
||||
help="Hook name to run",
|
||||
)
|
||||
p_hook_run.add_argument(
|
||||
"--harness",
|
||||
required=True,
|
||||
choices=["claude-code", "codex"],
|
||||
help="Harness type (determines stdin JSON format)",
|
||||
)
|
||||
|
||||
# instructions
|
||||
p_instructions = sub.add_parser(
|
||||
"instructions",
|
||||
help="Output skill instructions to stdout",
|
||||
)
|
||||
instructions_sub = p_instructions.add_subparsers(dest="instructions_name")
|
||||
for instr_name in ["init", "search", "mine", "help", "status"]:
|
||||
instructions_sub.add_parser(instr_name, help=f"Output {instr_name} instructions")
|
||||
|
||||
# repair
|
||||
sub.add_parser(
|
||||
"repair",
|
||||
help="Rebuild palace vector index from stored data (fixes segfaults after corruption)",
|
||||
).add_argument("--yes", action="store_true", help="Skip confirmation for destructive changes")
|
||||
|
||||
# mcp
|
||||
sub.add_parser(
|
||||
"mcp",
|
||||
help="Show MCP setup command for connecting MemPalace to your AI client",
|
||||
)
|
||||
|
||||
# status
|
||||
# migrate
|
||||
p_migrate = sub.add_parser(
|
||||
"migrate",
|
||||
help="Migrate palace from a different ChromaDB version (fixes 3.0.0 → 3.1.0 upgrade)",
|
||||
)
|
||||
p_migrate.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be migrated without changing anything",
|
||||
)
|
||||
p_migrate.add_argument(
|
||||
"--yes", action="store_true", help="Skip confirmation for destructive changes"
|
||||
)
|
||||
|
||||
sub.add_parser("status", help="Show what's been filed")
|
||||
|
||||
args = parser.parse_args()
|
||||
@@ -359,13 +710,34 @@ def main():
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
# Handle two-level subcommands
|
||||
if args.command == "hook":
|
||||
if not getattr(args, "hook_action", None):
|
||||
p_hook.print_help()
|
||||
return
|
||||
cmd_hook(args)
|
||||
return
|
||||
|
||||
if args.command == "instructions":
|
||||
name = getattr(args, "instructions_name", None)
|
||||
if not name:
|
||||
p_instructions.print_help()
|
||||
return
|
||||
args.name = name
|
||||
cmd_instructions(args)
|
||||
return
|
||||
|
||||
dispatch = {
|
||||
"init": cmd_init,
|
||||
"mine": cmd_mine,
|
||||
"split": cmd_split,
|
||||
"search": cmd_search,
|
||||
"sweep": cmd_sweep,
|
||||
"mcp": cmd_mcp,
|
||||
"compress": cmd_compress,
|
||||
"wake-up": cmd_wakeup,
|
||||
"repair": cmd_repair,
|
||||
"migrate": cmd_migrate,
|
||||
"status": cmd_status,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
closet_llm.py — Generate closets via a user-configured LLM for richer indexing.
|
||||
|
||||
The regex-based closet extraction catches action verbs, headers, and proper
|
||||
nouns — but misses implicit topics, foreign-language content, and contextual
|
||||
references. An LLM reads everything and produces better closets.
|
||||
|
||||
This module is **OPTIONAL and opt-in**. Regex closets are always created by
|
||||
the miner; this path regenerates them afterward using whatever LLM the user
|
||||
chooses. Core memory operations remain API-free by design (see CLAUDE.md,
|
||||
"Local-first, zero API").
|
||||
|
||||
## Bring-your-own-LLM configuration
|
||||
|
||||
The endpoint is any OpenAI-compatible Chat Completions URL:
|
||||
|
||||
LLM_ENDPOINT=http://localhost:11434/v1 # Ollama
|
||||
LLM_ENDPOINT=http://localhost:8000/v1 # vLLM, llama.cpp
|
||||
LLM_ENDPOINT=https://api.openai.com/v1
|
||||
LLM_ENDPOINT=https://openrouter.ai/api/v1
|
||||
LLM_ENDPOINT=https://api.anthropic.com/v1 # when proxied through a compat layer
|
||||
|
||||
Set:
|
||||
LLM_ENDPOINT — base URL (required)
|
||||
LLM_KEY — bearer token (optional; local inference usually doesn't need it)
|
||||
LLM_MODEL — model name (required), e.g. "gpt-4o-mini", "llama3:8b", "qwen2.5:7b"
|
||||
|
||||
Or pass flags on the CLI (flags win over env):
|
||||
|
||||
python -m mempalace.closet_llm \\
|
||||
--palace ~/.mempalace/palace \\
|
||||
--endpoint http://localhost:11434/v1 \\
|
||||
--model llama3:8b
|
||||
|
||||
No vendor lock-in. No hidden dependency on any specific provider. Zero deps
|
||||
added to pyproject — uses stdlib urllib.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .palace import (
|
||||
NORMALIZE_VERSION,
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
mine_lock,
|
||||
purge_file_closets,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
|
||||
MAX_CONTENT_CHARS = 30000
|
||||
MAX_OUTPUT_TOKENS = 1500
|
||||
HTTP_TIMEOUT_S = 60
|
||||
|
||||
PROMPT_TEMPLATE = """You are reading content filed in a memory palace. Generate a
|
||||
topic-dense index that will be used to find this content later when someone searches.
|
||||
|
||||
Source: {source_file}
|
||||
Wing: {wing} | Room: {room}
|
||||
|
||||
CONTENT:
|
||||
{content}
|
||||
|
||||
---
|
||||
|
||||
Output a JSON object with EXACTLY these fields:
|
||||
|
||||
{{
|
||||
"topics": ["distinctive_word_or_phrase_1", "topic_2", ...],
|
||||
"quotes": ["[Speaker] verbatim quote", ...],
|
||||
"summary": "2-3 sentences describing what this content is about."
|
||||
}}
|
||||
|
||||
RULES:
|
||||
- Topics: 8-15 entries. Include proper nouns (names, places, projects),
|
||||
distinctive technical terms, and key concepts. NOT generic words like
|
||||
"conversation" or "discussion".
|
||||
- Quotes: 2-5 entries. EXACT verbatim from the content, not paraphrased.
|
||||
Attribute with [Speaker] prefix if speaker is identifiable.
|
||||
- Summary: mention WHO, WHAT, and WHY. No filler.
|
||||
- Write in the same language as the content.
|
||||
- Output valid JSON only. No code fences. No commentary.
|
||||
"""
|
||||
|
||||
|
||||
class LLMConfig:
|
||||
"""Resolved LLM connection config. CLI flags > env vars."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: Optional[str] = None,
|
||||
key: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
self.endpoint = (endpoint or os.environ.get("LLM_ENDPOINT", "")).rstrip("/")
|
||||
self.key = key or os.environ.get("LLM_KEY", "")
|
||||
self.model = model or os.environ.get("LLM_MODEL", "")
|
||||
|
||||
def missing(self) -> list:
|
||||
missing = []
|
||||
if not self.endpoint:
|
||||
missing.append("LLM_ENDPOINT (or --endpoint)")
|
||||
if not self.model:
|
||||
missing.append("LLM_MODEL (or --model)")
|
||||
# key is optional — local inference servers (Ollama, vLLM) often don't require one
|
||||
return missing
|
||||
|
||||
|
||||
def _call_llm(cfg: LLMConfig, source_file: str, wing: str, room: str, content: str):
|
||||
"""Single LLM call via OpenAI-compatible /chat/completions.
|
||||
|
||||
Returns (parsed_json_dict_or_None, usage_dict_or_None).
|
||||
"""
|
||||
try:
|
||||
from mempalace.i18n import t
|
||||
|
||||
lang_instruction = t("aaak.instruction")
|
||||
except Exception:
|
||||
lang_instruction = ""
|
||||
|
||||
prompt = PROMPT_TEMPLATE.format(
|
||||
source_file=source_file[:100],
|
||||
wing=wing,
|
||||
room=room,
|
||||
content=content[:MAX_CONTENT_CHARS],
|
||||
)
|
||||
if lang_instruction and "english" not in lang_instruction.lower():
|
||||
prompt += f"\n\nLanguage instruction: {lang_instruction}"
|
||||
|
||||
body = json.dumps(
|
||||
{
|
||||
"model": cfg.model,
|
||||
"max_tokens": MAX_OUTPUT_TOKENS,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if cfg.key:
|
||||
headers["Authorization"] = f"Bearer {cfg.key}"
|
||||
|
||||
url = f"{cfg.endpoint}/chat/completions"
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
payload = json.loads(raw)
|
||||
|
||||
text = payload["choices"][0]["message"]["content"].strip()
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
parsed = json.loads(text)
|
||||
return parsed, payload.get("usage")
|
||||
except json.JSONDecodeError:
|
||||
return None, None
|
||||
except urllib.error.HTTPError as e:
|
||||
# 429 / 503 = retry with backoff
|
||||
if e.code in (429, 503) and attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
return None, None
|
||||
except Exception as e:
|
||||
if "rate" in str(e).lower() and attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
def _parsed_to_closet_lines(parsed, drawer_ids, entities_str):
|
||||
"""Convert LLM's JSON output to closet pointer lines."""
|
||||
lines = []
|
||||
drawer_ref = ",".join(drawer_ids[:3])
|
||||
|
||||
for topic in parsed.get("topics", [])[:15]:
|
||||
lines.append(f"{topic}|{entities_str}|→{drawer_ref}")
|
||||
for quote in parsed.get("quotes", [])[:5]:
|
||||
lines.append(f"{quote}|{entities_str}|→{drawer_ref}")
|
||||
summary = parsed.get("summary", "")
|
||||
if summary:
|
||||
lines.append(f"{summary[:200]}|{entities_str}|→{drawer_ref}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def regenerate_closets(
|
||||
palace_path,
|
||||
wing=None,
|
||||
sample=0,
|
||||
dry_run=False,
|
||||
cfg: Optional[LLMConfig] = None,
|
||||
):
|
||||
"""Regenerate closets using a configured LLM for richer topic extraction.
|
||||
|
||||
Reads existing drawers, sends content to the configured endpoint,
|
||||
replaces regex closets with LLM-generated ones. Regex closets remain
|
||||
as the fallback whenever the call fails.
|
||||
"""
|
||||
if cfg is None:
|
||||
cfg = LLMConfig()
|
||||
missing = cfg.missing()
|
||||
if missing:
|
||||
print("Error: missing configuration: " + ", ".join(missing))
|
||||
print("Set env vars LLM_ENDPOINT / LLM_MODEL (and optionally LLM_KEY),")
|
||||
print("or pass --endpoint / --model / --key on the CLI.")
|
||||
return {"error": "missing-config", "missing": missing}
|
||||
|
||||
drawers_col = get_collection(palace_path, create=False)
|
||||
closets_col = get_closets_collection(palace_path)
|
||||
|
||||
total = drawers_col.count()
|
||||
if total == 0:
|
||||
print("No drawers in palace.")
|
||||
return {"processed": 0}
|
||||
|
||||
all_data = drawers_col.get(limit=total, include=["documents", "metadatas"])
|
||||
by_source = {}
|
||||
for doc_id, doc, meta in zip(all_data["ids"], all_data["documents"], all_data["metadatas"]):
|
||||
source = meta.get("source_file", "unknown")
|
||||
w = meta.get("wing", "")
|
||||
if wing and w != wing:
|
||||
continue
|
||||
if source not in by_source:
|
||||
by_source[source] = {"drawer_ids": [], "content": [], "meta": meta}
|
||||
by_source[source]["drawer_ids"].append(doc_id)
|
||||
by_source[source]["content"].append(doc)
|
||||
|
||||
sources = list(by_source.keys())
|
||||
if sample > 0:
|
||||
sources = sources[:sample]
|
||||
|
||||
print(
|
||||
f"Regenerating closets for {len(sources)} source files via {cfg.endpoint} ({cfg.model})..."
|
||||
)
|
||||
if dry_run:
|
||||
print("DRY RUN — no changes will be written")
|
||||
|
||||
processed = 0
|
||||
failed = 0
|
||||
total_input = 0
|
||||
total_output = 0
|
||||
|
||||
for i, source in enumerate(sources, 1):
|
||||
data = by_source[source]
|
||||
content = "\n\n".join(data["content"])
|
||||
meta = data["meta"]
|
||||
w = meta.get("wing", "")
|
||||
r = meta.get("room", "")
|
||||
entities = meta.get("entities", "")
|
||||
|
||||
if dry_run:
|
||||
print(f" [{i}/{len(sources)}] {os.path.basename(source)} ({len(content)} chars)")
|
||||
continue
|
||||
|
||||
parsed, usage = _call_llm(cfg, source, w, r, content)
|
||||
if not parsed:
|
||||
failed += 1
|
||||
print(f" [{i}/{len(sources)}] ✗ {os.path.basename(source)} — LLM failed")
|
||||
continue
|
||||
|
||||
if usage:
|
||||
total_input += usage.get("prompt_tokens", 0)
|
||||
total_output += usage.get("completion_tokens", 0)
|
||||
|
||||
lines = _parsed_to_closet_lines(parsed, data["drawer_ids"], entities)
|
||||
# Use os.path.basename so Windows-style paths survive unchanged;
|
||||
# the naive split('/') would leave a bare path component on Windows
|
||||
# and collide across different files under different drives.
|
||||
closet_id_base = f"closet_{w}_{r}_{os.path.basename(source)[:30]}"
|
||||
|
||||
# Serialize with concurrent mine operations on the same source —
|
||||
# otherwise a regex closet rebuild mid-regenerate races with our
|
||||
# purge+upsert cycle and leaves mixed regex/LLM lines.
|
||||
with mine_lock(source):
|
||||
purge_file_closets(closets_col, source)
|
||||
upsert_closet_lines(
|
||||
closets_col,
|
||||
closet_id_base,
|
||||
lines,
|
||||
{
|
||||
"wing": w,
|
||||
"room": r,
|
||||
"source_file": source,
|
||||
"generated_by": f"llm:{cfg.model}",
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"entities": entities,
|
||||
# Stamp so the miner's stale-drawer gate doesn't treat
|
||||
# LLM closets as leftovers and rebuild over them next run.
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
processed += 1
|
||||
n_topics = len(parsed.get("topics", []))
|
||||
print(f" [{i}/{len(sources)}] ✓ {os.path.basename(source)} — {n_topics} topics")
|
||||
|
||||
print(f"\nDone. {processed} regenerated, {failed} failed.")
|
||||
if total_input or total_output:
|
||||
print(f"Tokens: {total_input:,} in + {total_output:,} out (cost depends on provider)")
|
||||
|
||||
return {
|
||||
"processed": processed,
|
||||
"failed": failed,
|
||||
"input_tokens": total_input,
|
||||
"output_tokens": total_output,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Regenerate closets via a user-configured LLM (OpenAI-compatible API)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--palace",
|
||||
default=os.path.expanduser("~/.mempalace/palace"),
|
||||
help="Path to the palace",
|
||||
)
|
||||
parser.add_argument("--wing", default=None, help="Limit to one wing")
|
||||
parser.add_argument("--sample", type=int, default=0, help="Only process first N source files")
|
||||
parser.add_argument("--dry-run", action="store_true", help="List work without calling the LLM")
|
||||
parser.add_argument(
|
||||
"--endpoint",
|
||||
default=None,
|
||||
help="LLM base URL (overrides $LLM_ENDPOINT), e.g. http://localhost:11434/v1",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
default=None,
|
||||
help="LLM bearer token (overrides $LLM_KEY). Optional for local inference.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default=None,
|
||||
help='LLM model name (overrides $LLM_MODEL), e.g. "gpt-4o-mini" or "llama3:8b"',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = LLMConfig(endpoint=args.endpoint, key=args.key, model=args.model)
|
||||
regenerate_closets(
|
||||
args.palace, wing=args.wing, sample=args.sample, dry_run=args.dry_run, cfg=cfg
|
||||
)
|
||||
@@ -6,8 +6,82 @@ Priority: env vars > config file (~/.mempalace/config.json) > defaults
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── Input validation ──────────────────────────────────────────────────────────
|
||||
# Shared sanitizers for wing/room/entity names. Prevents path traversal,
|
||||
# excessively long strings, and special characters that could cause issues
|
||||
# in file paths, SQLite, or ChromaDB metadata.
|
||||
|
||||
MAX_NAME_LENGTH = 128
|
||||
_SAFE_NAME_RE = re.compile(r"^(?:[^\W_]|[^\W_][\w .'-]{0,126}[^\W_])$")
|
||||
|
||||
|
||||
def sanitize_name(value: str, field_name: str = "name") -> str:
|
||||
"""Validate and sanitize a wing/room/entity name.
|
||||
|
||||
Raises ValueError if the name is invalid.
|
||||
"""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{field_name} must be a non-empty string")
|
||||
|
||||
value = value.strip()
|
||||
|
||||
if len(value) > MAX_NAME_LENGTH:
|
||||
raise ValueError(f"{field_name} exceeds maximum length of {MAX_NAME_LENGTH} characters")
|
||||
|
||||
# Block path traversal
|
||||
if ".." in value or "/" in value or "\\" in value:
|
||||
raise ValueError(f"{field_name} contains invalid path characters")
|
||||
|
||||
# Block null bytes
|
||||
if "\x00" in value:
|
||||
raise ValueError(f"{field_name} contains null bytes")
|
||||
|
||||
# Enforce safe character set
|
||||
if not _SAFE_NAME_RE.match(value):
|
||||
raise ValueError(f"{field_name} contains invalid characters")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_kg_value(value: str, field_name: str = "value") -> str:
|
||||
"""Validate a knowledge-graph entity name (subject or object).
|
||||
|
||||
More permissive than sanitize_name — allows punctuation like commas,
|
||||
colons, and parentheses that are common in natural-language KG values.
|
||||
Only blocks null bytes and over-length strings.
|
||||
|
||||
Not used for wing/room names (which have filesystem constraints) or
|
||||
predicates (which should be simple relationship identifiers).
|
||||
"""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{field_name} must be a non-empty string")
|
||||
|
||||
value = value.strip()
|
||||
|
||||
if len(value) > MAX_NAME_LENGTH:
|
||||
raise ValueError(f"{field_name} exceeds maximum length of {MAX_NAME_LENGTH} characters")
|
||||
|
||||
if "\x00" in value:
|
||||
raise ValueError(f"{field_name} contains null bytes")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_content(value: str, max_length: int = 100_000) -> str:
|
||||
"""Validate drawer/diary content length."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("content must be a non-empty string")
|
||||
if len(value) > max_length:
|
||||
raise ValueError(f"content exceeds maximum length of {max_length} characters")
|
||||
if "\x00" in value:
|
||||
raise ValueError("content contains null bytes")
|
||||
return value
|
||||
|
||||
|
||||
DEFAULT_PALACE_PATH = os.path.expanduser("~/.mempalace/palace")
|
||||
DEFAULT_COLLECTION_NAME = "mempalace_drawers"
|
||||
|
||||
@@ -123,9 +197,71 @@ class MempalaceConfig:
|
||||
"""Mapping of hall names to keyword lists."""
|
||||
return self._file_config.get("hall_keywords", DEFAULT_HALL_KEYWORDS)
|
||||
|
||||
@property
|
||||
def entity_languages(self):
|
||||
"""Languages whose entity-detection patterns should be applied.
|
||||
|
||||
Reads from env var ``MEMPALACE_ENTITY_LANGUAGES`` (comma-separated)
|
||||
first, then the ``entity_languages`` field in ``config.json``,
|
||||
defaulting to ``["en"]``.
|
||||
"""
|
||||
env_val = os.environ.get("MEMPALACE_ENTITY_LANGUAGES") or os.environ.get(
|
||||
"MEMPAL_ENTITY_LANGUAGES"
|
||||
)
|
||||
if env_val:
|
||||
return [s.strip() for s in env_val.split(",") if s.strip()] or ["en"]
|
||||
cfg = self._file_config.get("entity_languages")
|
||||
if isinstance(cfg, list) and cfg:
|
||||
return [str(s) for s in cfg]
|
||||
return ["en"]
|
||||
|
||||
def set_entity_languages(self, languages):
|
||||
"""Persist the entity-detection language list to ``config.json``."""
|
||||
normalized = [s.strip() for s in languages if s and s.strip()]
|
||||
if not normalized:
|
||||
normalized = ["en"]
|
||||
self._file_config["entity_languages"] = normalized
|
||||
self._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with open(self._config_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self._file_config, f, indent=2, ensure_ascii=False)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self._config_file.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
return normalized
|
||||
|
||||
@property
|
||||
def hook_silent_save(self):
|
||||
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""
|
||||
return self._file_config.get("hooks", {}).get("silent_save", True)
|
||||
|
||||
@property
|
||||
def hook_desktop_toast(self):
|
||||
"""Whether the stop hook shows a desktop notification via notify-send."""
|
||||
return self._file_config.get("hooks", {}).get("desktop_toast", False)
|
||||
|
||||
def set_hook_setting(self, key: str, value: bool):
|
||||
"""Update a hook setting and write config to disk."""
|
||||
if "hooks" not in self._file_config:
|
||||
self._file_config["hooks"] = {}
|
||||
self._file_config["hooks"][key] = value
|
||||
try:
|
||||
with open(self._config_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self._file_config, f, indent=2, ensure_ascii=False)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def init(self):
|
||||
"""Create config directory and write default config.json if it doesn't exist."""
|
||||
self._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Restrict directory permissions to owner only (Unix)
|
||||
try:
|
||||
self._config_dir.chmod(0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass # Windows doesn't support Unix permissions
|
||||
if not self._config_file.exists():
|
||||
default_config = {
|
||||
"palace_path": DEFAULT_PALACE_PATH,
|
||||
@@ -135,6 +271,11 @@ class MempalaceConfig:
|
||||
}
|
||||
with open(self._config_file, "w") as f:
|
||||
json.dump(default_config, f, indent=2)
|
||||
# Restrict config file to owner read/write only
|
||||
try:
|
||||
self._config_file.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
return self._config_file
|
||||
|
||||
def save_people_map(self, people_map):
|
||||
@@ -146,4 +287,8 @@ class MempalaceConfig:
|
||||
self._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._people_map_file, "w") as f:
|
||||
json.dump(people_map, f, indent=2)
|
||||
try:
|
||||
self._people_map_file.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
return self._people_map_file
|
||||
|
||||
+168
-66
@@ -15,9 +15,34 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
import chromadb
|
||||
|
||||
from .normalize import normalize
|
||||
from .palace import (
|
||||
NORMALIZE_VERSION,
|
||||
SKIP_DIRS,
|
||||
file_already_mined,
|
||||
get_collection,
|
||||
mine_lock,
|
||||
)
|
||||
|
||||
|
||||
# Cached hall keywords — avoids re-reading config per drawer
|
||||
_HALL_KEYWORDS_CACHE = None
|
||||
|
||||
|
||||
def _detect_hall_cached(content: str) -> str:
|
||||
"""Route content to a hall using cached keywords. Same logic as miner.detect_hall."""
|
||||
global _HALL_KEYWORDS_CACHE
|
||||
if _HALL_KEYWORDS_CACHE is None:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
_HALL_KEYWORDS_CACHE = MempalaceConfig().hall_keywords
|
||||
content_lower = content[:3000].lower()
|
||||
scores = {}
|
||||
for hall, keywords in _HALL_KEYWORDS_CACHE.items():
|
||||
score = sum(1 for kw in keywords if kw in content_lower)
|
||||
if score > 0:
|
||||
scores[hall] = score
|
||||
return max(scores, key=scores.get) if scores else "general"
|
||||
|
||||
|
||||
# File types that might contain conversations
|
||||
@@ -28,20 +53,41 @@ CONVO_EXTENSIONS = {
|
||||
".jsonl",
|
||||
}
|
||||
|
||||
SKIP_DIRS = {
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"env",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
".mempalace",
|
||||
}
|
||||
|
||||
MIN_CHUNK_SIZE = 30
|
||||
CHUNK_SIZE = 800 # chars per drawer — align with miner.py
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB — skip files larger than this.
|
||||
# Matches miner.py at 500 MB. Long Claude Code sessions, multi-year
|
||||
# ChatGPT exports, and lifetime Slack dumps routinely exceed 10 MB; the
|
||||
# cap at that level silently dropped them with `continue`. Per-drawer
|
||||
# size is bounded by CHUNK_SIZE, but larger source files still produce
|
||||
# more drawers and therefore more embedding/storage work — and content
|
||||
# is normalized and loaded fully into memory before chunking, so memory
|
||||
# use also scales with source size.
|
||||
|
||||
|
||||
def _register_file(collection, source_file: str, wing: str, agent: str):
|
||||
"""Write a sentinel so file_already_mined() returns True for 0-chunk files.
|
||||
|
||||
Without this, files that normalize to nothing or produce zero chunks are
|
||||
re-read and re-processed on every mine run because nothing was written to
|
||||
ChromaDB on the first pass.
|
||||
"""
|
||||
sentinel_id = f"_reg_{hashlib.sha256(source_file.encode()).hexdigest()[:24]}"
|
||||
collection.upsert(
|
||||
documents=[f"[registry] {source_file}"],
|
||||
ids=[sentinel_id],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": wing,
|
||||
"room": "_registry",
|
||||
"source_file": source_file,
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"ingest_mode": "registry",
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -64,7 +110,12 @@ def chunk_exchanges(content: str) -> list:
|
||||
|
||||
|
||||
def _chunk_by_exchange(lines: list) -> list:
|
||||
"""One user turn (>) + the AI response that follows = one chunk."""
|
||||
"""One user turn (>) + the AI response that follows = one or more chunks.
|
||||
|
||||
The full AI response is preserved verbatim. When the combined
|
||||
user-turn + response exceeds CHUNK_SIZE the response is split across
|
||||
consecutive drawers so nothing is silently discarded.
|
||||
"""
|
||||
chunks = []
|
||||
i = 0
|
||||
|
||||
@@ -83,10 +134,23 @@ def _chunk_by_exchange(lines: list) -> list:
|
||||
ai_lines.append(next_line.strip())
|
||||
i += 1
|
||||
|
||||
ai_response = " ".join(ai_lines[:8])
|
||||
ai_response = " ".join(ai_lines)
|
||||
content = f"{user_turn}\n{ai_response}" if ai_response else user_turn
|
||||
|
||||
if len(content.strip()) > MIN_CHUNK_SIZE:
|
||||
# Split into multiple drawers when the exchange exceeds CHUNK_SIZE
|
||||
if len(content) > CHUNK_SIZE:
|
||||
# First chunk: user turn + as much response as fits
|
||||
first_part = content[:CHUNK_SIZE]
|
||||
if len(first_part.strip()) > MIN_CHUNK_SIZE:
|
||||
chunks.append({"content": first_part, "chunk_index": len(chunks)})
|
||||
# Remaining response in CHUNK_SIZE-sized continuation drawers
|
||||
remainder = content[CHUNK_SIZE:]
|
||||
while remainder:
|
||||
part = remainder[:CHUNK_SIZE]
|
||||
remainder = remainder[CHUNK_SIZE:]
|
||||
if len(part.strip()) > MIN_CHUNK_SIZE:
|
||||
chunks.append({"content": part, "chunk_index": len(chunks)})
|
||||
elif len(content.strip()) > MIN_CHUNK_SIZE:
|
||||
chunks.append(
|
||||
{
|
||||
"content": content,
|
||||
@@ -209,23 +273,6 @@ def detect_convo_room(content: str) -> str:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_collection(palace_path: str):
|
||||
os.makedirs(palace_path, exist_ok=True)
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
try:
|
||||
return client.get_collection("mempalace_drawers")
|
||||
except Exception:
|
||||
return client.create_collection("mempalace_drawers")
|
||||
|
||||
|
||||
def file_already_mined(collection, source_file: str) -> bool:
|
||||
try:
|
||||
results = collection.get(where={"source_file": source_file}, limit=1)
|
||||
return len(results.get("ids", [])) > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SCAN FOR CONVERSATION FILES
|
||||
# =============================================================================
|
||||
@@ -238,8 +285,18 @@ def scan_convos(convo_dir: str) -> list:
|
||||
for root, dirs, filenames in os.walk(convo_path):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||
for filename in filenames:
|
||||
if filename.endswith(".meta.json"):
|
||||
continue
|
||||
filepath = Path(root) / filename
|
||||
if filepath.suffix.lower() in CONVO_EXTENSIONS:
|
||||
# Skip symlinks and oversized files
|
||||
if filepath.is_symlink():
|
||||
continue
|
||||
try:
|
||||
if filepath.stat().st_size > MAX_FILE_SIZE:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
files.append(filepath)
|
||||
return files
|
||||
|
||||
@@ -249,6 +306,63 @@ def scan_convos(convo_dir: str) -> list:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extract_mode):
|
||||
"""Lock the source file, purge stale drawers, and upsert fresh chunks.
|
||||
|
||||
Combines the per-file serialization that prevents concurrent agents from
|
||||
duplicating work (via mine_lock) with the normalize-version rebuild
|
||||
contract (purge-before-insert so pre-v2 drawers don't survive).
|
||||
|
||||
Returns (drawers_added, room_counts_delta, skipped).
|
||||
"""
|
||||
room_counts_delta: dict = defaultdict(int)
|
||||
drawers_added = 0
|
||||
with mine_lock(source_file):
|
||||
# Re-check after lock — another agent may have just finished this file
|
||||
# at the current schema. A stale-version hit here returns False, so we
|
||||
# still fall through to the purge+rebuild path below.
|
||||
if file_already_mined(collection, source_file):
|
||||
return 0, room_counts_delta, True
|
||||
|
||||
# Purge stale drawers first. When the normalize schema bumps,
|
||||
# file_already_mined() returned False for pre-v2 drawers — clean
|
||||
# them out so the source doesn't end up with mixed old/new drawers.
|
||||
try:
|
||||
collection.delete(where={"source_file": source_file})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for chunk in chunks:
|
||||
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
|
||||
if extract_mode == "general":
|
||||
room_counts_delta[chunk_room] += 1
|
||||
drawer_id = f"drawer_{wing}_{chunk_room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
|
||||
try:
|
||||
collection.upsert(
|
||||
documents=[chunk["content"]],
|
||||
ids=[drawer_id],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": wing,
|
||||
"room": chunk_room,
|
||||
"hall": _detect_hall_cached(chunk["content"]),
|
||||
"source_file": source_file,
|
||||
"chunk_index": chunk["chunk_index"],
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"ingest_mode": "convos",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
],
|
||||
)
|
||||
drawers_added += 1
|
||||
except Exception as e:
|
||||
if "already exists" not in str(e).lower():
|
||||
raise
|
||||
return drawers_added, room_counts_delta, False
|
||||
|
||||
|
||||
def mine_convos(
|
||||
convo_dir: str,
|
||||
palace_path: str,
|
||||
@@ -282,7 +396,7 @@ def mine_convos(
|
||||
print(f" Palace: {palace_path}")
|
||||
if dry_run:
|
||||
print(" DRY RUN — nothing will be filed")
|
||||
print(f"{'─' * 55}\n")
|
||||
print(f"{'-' * 55}\n")
|
||||
|
||||
collection = get_collection(palace_path) if not dry_run else None
|
||||
|
||||
@@ -301,10 +415,14 @@ def mine_convos(
|
||||
# Normalize format
|
||||
try:
|
||||
content = normalize(str(filepath))
|
||||
except Exception:
|
||||
except (OSError, ValueError):
|
||||
if not dry_run:
|
||||
_register_file(collection, source_file, wing, agent)
|
||||
continue
|
||||
|
||||
if not content or len(content.strip()) < MIN_CHUNK_SIZE:
|
||||
if not dry_run:
|
||||
_register_file(collection, source_file, wing, agent)
|
||||
continue
|
||||
|
||||
# Chunk — either exchange pairs or general extraction
|
||||
@@ -317,6 +435,8 @@ def mine_convos(
|
||||
chunks = chunk_exchanges(content)
|
||||
|
||||
if not chunks:
|
||||
if not dry_run:
|
||||
_register_file(collection, source_file, wing, agent)
|
||||
continue
|
||||
|
||||
# Detect room from content (general mode uses memory_type instead)
|
||||
@@ -346,37 +466,19 @@ def mine_convos(
|
||||
if extract_mode != "general":
|
||||
room_counts[room] += 1
|
||||
|
||||
# File each chunk
|
||||
drawers_added = 0
|
||||
for chunk in chunks:
|
||||
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
|
||||
if extract_mode == "general":
|
||||
room_counts[chunk_room] += 1
|
||||
drawer_id = f"drawer_{wing}_{chunk_room}_{hashlib.md5((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:16]}"
|
||||
try:
|
||||
collection.add(
|
||||
documents=[chunk["content"]],
|
||||
ids=[drawer_id],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": wing,
|
||||
"room": chunk_room,
|
||||
"source_file": source_file,
|
||||
"chunk_index": chunk["chunk_index"],
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"ingest_mode": "convos",
|
||||
"extract_mode": extract_mode,
|
||||
}
|
||||
],
|
||||
)
|
||||
drawers_added += 1
|
||||
except Exception as e:
|
||||
if "already exists" not in str(e).lower():
|
||||
raise
|
||||
# Lock + purge stale + file fresh chunks. Lock serializes concurrent
|
||||
# agents; purge removes pre-v2 drawers so the schema bump applies.
|
||||
drawers_added, room_delta, skipped = _file_chunks_locked(
|
||||
collection, source_file, chunks, wing, room, agent, extract_mode
|
||||
)
|
||||
if skipped:
|
||||
files_skipped += 1
|
||||
continue
|
||||
for r, n in room_delta.items():
|
||||
room_counts[r] += n
|
||||
|
||||
total_drawers += drawers_added
|
||||
print(f" ✓ [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
|
||||
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" Done.")
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
dedup.py — Detect and remove near-duplicate drawers
|
||||
====================================================
|
||||
|
||||
When the same files are mined multiple times, near-identical drawers
|
||||
accumulate. This module finds drawers from the same source_file that
|
||||
are too similar (cosine distance < threshold), keeps the longest/richest
|
||||
version, and deletes the rest.
|
||||
|
||||
No API calls — uses ChromaDB's built-in embedding similarity.
|
||||
|
||||
Usage (standalone):
|
||||
python -m mempalace.dedup # dedup all
|
||||
python -m mempalace.dedup --dry-run # preview only
|
||||
python -m mempalace.dedup --threshold 0.10 # stricter (near-identical only)
|
||||
python -m mempalace.dedup --threshold 0.35 # looser (catches paraphrased content)
|
||||
python -m mempalace.dedup --wing my_project # scope to one wing
|
||||
python -m mempalace.dedup --stats # stats only
|
||||
python -m mempalace.dedup --source "my_project" # filter by source
|
||||
|
||||
Usage (from CLI):
|
||||
mempalace dedup [--dry-run] [--threshold 0.15] [--stats]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from .backends.chroma import ChromaBackend
|
||||
|
||||
|
||||
COLLECTION_NAME = "mempalace_drawers"
|
||||
# Cosine DISTANCE threshold (not similarity). Lower = stricter.
|
||||
# 0.15 = ~85% cosine similarity — catches near-identical chunks.
|
||||
# For looser dedup of paraphrased content, try 0.3–0.4.
|
||||
DEFAULT_THRESHOLD = 0.15
|
||||
MIN_DRAWERS_TO_CHECK = 5
|
||||
|
||||
|
||||
def _get_palace_path():
|
||||
"""Resolve palace path from config."""
|
||||
try:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
return MempalaceConfig().palace_path
|
||||
except Exception:
|
||||
return os.path.join(os.path.expanduser("~"), ".mempalace", "palace")
|
||||
|
||||
|
||||
def get_source_groups(col, min_count=MIN_DRAWERS_TO_CHECK, source_pattern=None, wing=None):
|
||||
"""Group drawers by source_file, return groups with min_count+ entries.
|
||||
|
||||
If wing is specified, only considers drawers in that wing. This catches
|
||||
cross-wing duplicates when the same source was mined into multiple wings.
|
||||
"""
|
||||
total = col.count()
|
||||
groups = defaultdict(list)
|
||||
|
||||
offset = 0
|
||||
batch_size = 1000
|
||||
while offset < total:
|
||||
kwargs = {"limit": batch_size, "offset": offset, "include": ["metadatas"]}
|
||||
if wing:
|
||||
kwargs["where"] = {"wing": wing}
|
||||
batch = col.get(**kwargs)
|
||||
if not batch["ids"]:
|
||||
break
|
||||
for did, meta in zip(batch["ids"], batch["metadatas"]):
|
||||
src = meta.get("source_file", "unknown")
|
||||
if source_pattern and source_pattern.lower() not in src.lower():
|
||||
continue
|
||||
groups[src].append(did)
|
||||
offset += len(batch["ids"])
|
||||
|
||||
return {src: ids for src, ids in groups.items() if len(ids) >= min_count}
|
||||
|
||||
|
||||
def dedup_source_group(col, drawer_ids, threshold=DEFAULT_THRESHOLD, dry_run=True):
|
||||
"""Dedup drawers within one source_file group.
|
||||
|
||||
Greedy: sort by doc length (longest first), keep if not too similar
|
||||
to any already-kept drawer. Returns (kept_ids, deleted_ids).
|
||||
"""
|
||||
data = col.get(ids=drawer_ids, include=["documents", "metadatas"])
|
||||
items = list(zip(data["ids"], data["documents"], data["metadatas"]))
|
||||
items.sort(key=lambda x: len(x[1] or ""), reverse=True)
|
||||
|
||||
kept = []
|
||||
to_delete = []
|
||||
|
||||
for did, doc, meta in items:
|
||||
if not doc or len(doc) < 20:
|
||||
to_delete.append(did)
|
||||
continue
|
||||
|
||||
if not kept:
|
||||
kept.append((did, doc))
|
||||
continue
|
||||
|
||||
try:
|
||||
results = col.query(
|
||||
query_texts=[doc],
|
||||
n_results=min(len(kept), 5),
|
||||
include=["distances"],
|
||||
)
|
||||
dists = results["distances"][0] if results["distances"] else []
|
||||
kept_ids_set = {k[0] for k in kept}
|
||||
|
||||
is_dup = False
|
||||
for rid, dist in zip(results["ids"][0], dists):
|
||||
if rid in kept_ids_set and dist < threshold:
|
||||
is_dup = True
|
||||
break
|
||||
|
||||
if is_dup:
|
||||
to_delete.append(did)
|
||||
else:
|
||||
kept.append((did, doc))
|
||||
except Exception:
|
||||
kept.append((did, doc))
|
||||
|
||||
if to_delete and not dry_run:
|
||||
for i in range(0, len(to_delete), 500):
|
||||
col.delete(ids=to_delete[i : i + 500])
|
||||
|
||||
return [k[0] for k in kept], to_delete
|
||||
|
||||
|
||||
def show_stats(palace_path=None):
|
||||
"""Show duplication statistics without making changes."""
|
||||
palace_path = palace_path or _get_palace_path()
|
||||
col = ChromaBackend().get_collection(palace_path, COLLECTION_NAME)
|
||||
|
||||
groups = get_source_groups(col)
|
||||
|
||||
total_drawers = sum(len(ids) for ids in groups.values())
|
||||
print(f"\n Sources with {MIN_DRAWERS_TO_CHECK}+ drawers: {len(groups)}")
|
||||
print(f" Total drawers in those sources: {total_drawers:,}")
|
||||
|
||||
print("\n Top 15 by drawer count:")
|
||||
sorted_groups = sorted(groups.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
for src, ids in sorted_groups[:15]:
|
||||
print(f" {len(ids):4d} {src[:65]}")
|
||||
|
||||
estimated_dups = sum(int(len(ids) * 0.4) for ids in groups.values() if len(ids) > 20)
|
||||
print(f"\n Estimated duplicates (groups > 20): ~{estimated_dups:,}")
|
||||
|
||||
|
||||
def dedup_palace(
|
||||
palace_path=None,
|
||||
threshold=DEFAULT_THRESHOLD,
|
||||
dry_run=True,
|
||||
source_pattern=None,
|
||||
min_count=MIN_DRAWERS_TO_CHECK,
|
||||
wing=None,
|
||||
):
|
||||
"""Main entry point: deduplicate near-identical drawers across the palace."""
|
||||
palace_path = palace_path or _get_palace_path()
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" MemPalace Deduplicator")
|
||||
print(f"{'=' * 55}")
|
||||
|
||||
col = ChromaBackend().get_collection(palace_path, COLLECTION_NAME)
|
||||
|
||||
print(f" Palace: {palace_path}")
|
||||
print(f" Drawers: {col.count():,}")
|
||||
print(f" Threshold: {threshold}")
|
||||
print(f" Mode: {'DRY RUN' if dry_run else 'LIVE'}")
|
||||
print(f"{'─' * 55}")
|
||||
|
||||
if wing:
|
||||
print(f" Wing: {wing}")
|
||||
groups = get_source_groups(col, min_count, source_pattern, wing=wing)
|
||||
print(f"\n Sources to check: {len(groups)}")
|
||||
|
||||
t0 = time.time()
|
||||
total_kept = 0
|
||||
total_deleted = 0
|
||||
|
||||
sorted_groups = sorted(groups.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
for i, (src, drawer_ids) in enumerate(sorted_groups):
|
||||
kept, deleted = dedup_source_group(col, drawer_ids, threshold, dry_run)
|
||||
total_kept += len(kept)
|
||||
total_deleted += len(deleted)
|
||||
|
||||
if deleted:
|
||||
print(
|
||||
f" [{i + 1:3d}/{len(groups)}] "
|
||||
f"{src[:50]:50s} {len(drawer_ids):4d} → {len(kept):4d} "
|
||||
f"(-{len(deleted)})"
|
||||
)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
|
||||
print(f"\n{'─' * 55}")
|
||||
print(f" Done in {elapsed:.1f}s")
|
||||
print(
|
||||
f" Drawers: {total_kept + total_deleted:,} → {total_kept:,} (-{total_deleted:,} removed)"
|
||||
)
|
||||
print(f" Palace after: {col.count():,} drawers")
|
||||
|
||||
if dry_run:
|
||||
print("\n [DRY RUN] No changes written. Re-run without --dry-run to apply.")
|
||||
|
||||
print(f"{'=' * 55}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Deduplicate near-identical drawers")
|
||||
parser.add_argument("--palace", default=None, help="Palace directory path")
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
type=float,
|
||||
default=DEFAULT_THRESHOLD,
|
||||
help=f"Cosine distance threshold (default: {DEFAULT_THRESHOLD})",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview without deleting")
|
||||
parser.add_argument("--stats", action="store_true", help="Show stats only")
|
||||
parser.add_argument("--wing", default=None, help="Scope dedup to a single wing")
|
||||
parser.add_argument("--source", default=None, help="Filter by source file pattern")
|
||||
args = parser.parse_args()
|
||||
|
||||
path = os.path.expanduser(args.palace) if args.palace else None
|
||||
|
||||
if args.stats:
|
||||
show_stats(palace_path=path)
|
||||
else:
|
||||
dedup_palace(
|
||||
palace_path=path,
|
||||
threshold=args.threshold,
|
||||
dry_run=args.dry_run,
|
||||
source_pattern=args.source,
|
||||
wing=args.wing,
|
||||
)
|
||||
+65
-24
@@ -1,13 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AAAK Dialect -- Compressed Symbolic Memory Language
|
||||
AAAK Dialect -- Structured Symbolic Summary Format
|
||||
====================================================
|
||||
|
||||
A structured symbolic format that ANY LLM reads natively at ~30x compression.
|
||||
Not latent vectors. Not English prose. A universal memory compression dialect.
|
||||
A lossy summarization format that extracts entities, topics, key sentences,
|
||||
emotions, and flags from plain text into a compact structured representation.
|
||||
Any LLM reads it natively — no decoder required.
|
||||
|
||||
Works with: Claude, ChatGPT, Gemini, Llama, Mistral -- any model that reads text.
|
||||
|
||||
NOTE: AAAK is NOT lossless compression. The original text cannot be reconstructed
|
||||
from AAAK output. It is a structured summary layer (closets) that points to the
|
||||
original verbatim content (drawers). The 96.6% benchmark score is from raw mode,
|
||||
not AAAK mode.
|
||||
|
||||
Adapted for mempalace: works standalone on plain text and ChromaDB drawers.
|
||||
No dependency on palace.py or layers.py.
|
||||
|
||||
@@ -152,6 +158,8 @@ _FLAG_SIGNALS = {
|
||||
}
|
||||
|
||||
# Common filler/stop words to strip from topic extraction
|
||||
_ALPHA_RE = re.compile(r"[^a-zA-Z]")
|
||||
|
||||
_STOP_WORDS = {
|
||||
"the",
|
||||
"a",
|
||||
@@ -311,13 +319,17 @@ class Dialect:
|
||||
dialect.generate_layer1("zettels/", output="LAYER1.aaak")
|
||||
"""
|
||||
|
||||
def __init__(self, entities: Dict[str, str] = None, skip_names: List[str] = None):
|
||||
def __init__(
|
||||
self, entities: Dict[str, str] = None, skip_names: List[str] = None, lang: str = None
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
entities: Mapping of full names -> short codes.
|
||||
e.g. {"Alice": "ALC", "Bob": "BOB"}
|
||||
If None, entities are auto-coded from first 3 chars.
|
||||
skip_names: Names to skip (fictional characters, etc.)
|
||||
lang: Language code (e.g. "fr", "ko"). Loads AAAK instruction
|
||||
and regex patterns from i18n dictionary.
|
||||
"""
|
||||
self.entity_codes = {}
|
||||
if entities:
|
||||
@@ -326,6 +338,15 @@ class Dialect:
|
||||
self.entity_codes[name.lower()] = code
|
||||
self.skip_names = [n.lower() for n in (skip_names or [])]
|
||||
|
||||
# Load language-specific AAAK instruction and regex patterns
|
||||
from mempalace.i18n import load_lang, t, current_lang, get_regex
|
||||
|
||||
if lang:
|
||||
load_lang(lang)
|
||||
self.lang = lang or current_lang()
|
||||
self.aaak_instruction = t("aaak.instruction")
|
||||
self.lang_regex = get_regex()
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config_path: str) -> "Dialect":
|
||||
"""Load entity mappings from a JSON config file.
|
||||
@@ -341,6 +362,7 @@ class Dialect:
|
||||
return cls(
|
||||
entities=config.get("entities", {}),
|
||||
skip_names=config.get("skip_names", []),
|
||||
lang=config.get("lang", "en"),
|
||||
)
|
||||
|
||||
def save_config(self, config_path: str):
|
||||
@@ -521,7 +543,7 @@ class Dialect:
|
||||
# Fallback: find capitalized words that look like names (2+ chars, not sentence-start)
|
||||
words = text.split()
|
||||
for i, w in enumerate(words):
|
||||
clean = re.sub(r"[^a-zA-Z]", "", w)
|
||||
clean = _ALPHA_RE.sub("", w)
|
||||
if (
|
||||
len(clean) >= 2
|
||||
and clean[0].isupper()
|
||||
@@ -538,19 +560,19 @@ class Dialect:
|
||||
|
||||
def compress(self, text: str, metadata: dict = None) -> str:
|
||||
"""
|
||||
Compress plain text into AAAK Dialect format.
|
||||
Summarize plain text into AAAK Dialect format.
|
||||
|
||||
This is the primary method for mempalace: takes any text content
|
||||
(drawer content, transcript chunk, note) and returns a compressed
|
||||
symbolic representation.
|
||||
Extracts entities, topics, a key sentence, emotions, and flags
|
||||
from the input text. This is lossy — the original text cannot be
|
||||
reconstructed from the output.
|
||||
|
||||
Args:
|
||||
text: Plain text content to compress
|
||||
text: Plain text content to summarize
|
||||
metadata: Optional dict with keys like 'source_file', 'wing',
|
||||
'room', 'date', etc.
|
||||
|
||||
Returns:
|
||||
AAAK-compressed string (~30x smaller than input)
|
||||
AAAK-formatted summary string
|
||||
"""
|
||||
metadata = metadata or {}
|
||||
|
||||
@@ -930,19 +952,34 @@ class Dialect:
|
||||
|
||||
@staticmethod
|
||||
def count_tokens(text: str) -> int:
|
||||
"""Rough token count (1 token ~ 3 chars for structured text)."""
|
||||
return len(text) // 3
|
||||
"""Estimate token count using word-based heuristic (~1.3 tokens per word).
|
||||
|
||||
This is an approximation. For accurate counts, use a real tokenizer
|
||||
like tiktoken. The old len(text)//3 heuristic was wildly inaccurate
|
||||
and made AAAK compression ratios look much better than reality.
|
||||
"""
|
||||
words = text.split()
|
||||
# Most English words tokenize to 1-2 tokens; punctuation and
|
||||
# special chars in AAAK (|, +, :) each cost a token.
|
||||
# ~1.3 tokens/word is a conservative average.
|
||||
return max(1, int(len(words) * 1.3))
|
||||
|
||||
def compression_stats(self, original_text: str, compressed: str) -> dict:
|
||||
"""Get compression statistics for a text->AAAK conversion."""
|
||||
"""Get size comparison stats for a text->AAAK conversion.
|
||||
|
||||
NOTE: AAAK is lossy summarization, not compression. The "ratio"
|
||||
reflects how much shorter the summary is, not a compression ratio
|
||||
in the traditional sense — information is lost.
|
||||
"""
|
||||
orig_tokens = self.count_tokens(original_text)
|
||||
comp_tokens = self.count_tokens(compressed)
|
||||
return {
|
||||
"original_tokens": orig_tokens,
|
||||
"compressed_tokens": comp_tokens,
|
||||
"ratio": orig_tokens / max(comp_tokens, 1),
|
||||
"original_tokens_est": orig_tokens,
|
||||
"summary_tokens_est": comp_tokens,
|
||||
"size_ratio": round(orig_tokens / max(comp_tokens, 1), 1),
|
||||
"original_chars": len(original_text),
|
||||
"compressed_chars": len(compressed),
|
||||
"summary_chars": len(compressed),
|
||||
"note": "Estimates only. Use tiktoken for accurate counts. AAAK is lossy.",
|
||||
}
|
||||
|
||||
|
||||
@@ -1021,9 +1058,9 @@ if __name__ == "__main__":
|
||||
encoded = dialect.encode_file(data)
|
||||
stats = dialect.compression_stats(json_str, encoded)
|
||||
print("=== COMPRESSION STATS ===")
|
||||
print(f"JSON: ~{stats['original_tokens']:,} tokens")
|
||||
print(f"AAAK: ~{stats['compressed_tokens']:,} tokens")
|
||||
print(f"Ratio: {stats['ratio']:.0f}x")
|
||||
print(f"JSON: ~{stats['original_tokens_est']:,} tokens (est)")
|
||||
print(f"AAAK: ~{stats['summary_tokens_est']:,} tokens (est)")
|
||||
print(f"Ratio: {stats['size_ratio']}x (lossy — information is lost)")
|
||||
print()
|
||||
print("=== AAAK DIALECT OUTPUT ===")
|
||||
print(encoded)
|
||||
@@ -1043,8 +1080,12 @@ if __name__ == "__main__":
|
||||
text = " ".join(args)
|
||||
compressed = dialect.compress(text)
|
||||
stats = dialect.compression_stats(text, compressed)
|
||||
print(f"Original: ~{stats['original_tokens']} tokens ({stats['original_chars']} chars)")
|
||||
print(f"AAAK: ~{stats['compressed_tokens']} tokens ({stats['compressed_chars']} chars)")
|
||||
print(f"Ratio: {stats['ratio']:.1f}x")
|
||||
print(
|
||||
f"Original: ~{stats['original_tokens_est']} tokens est ({stats['original_chars']} chars)"
|
||||
)
|
||||
print(
|
||||
f"AAAK: ~{stats['summary_tokens_est']} tokens est ({stats['summary_chars']} chars)"
|
||||
)
|
||||
print(f"Ratio: {stats['size_ratio']}x (lossy summary, not lossless compression)")
|
||||
print()
|
||||
print(compressed)
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
diary_ingest.py — Ingest daily summary files into the palace.
|
||||
|
||||
Architecture:
|
||||
- ONE drawer per (wing, day) — full verbatim content, upserted as the day grows.
|
||||
- Closets pack topics up to CLOSET_CHAR_LIMIT, never split mid-topic.
|
||||
- A re-ingest fully purges the prior day's closets before rebuilding so a
|
||||
shorter day never leaves orphans behind.
|
||||
- Only new entries are processed by default (tracks entry count in a state
|
||||
file under ``~/.mempalace/state/`` — never inside the user's diary dir).
|
||||
- Per-file ``mine_lock`` so concurrent ingest from two terminals can't race.
|
||||
- Entities extracted and stamped on metadata for filterable search.
|
||||
|
||||
Usage:
|
||||
python -m mempalace.diary_ingest --dir ~/daily_summaries --palace ~/.mempalace/palace
|
||||
python -m mempalace.diary_ingest --dir ~/daily_summaries --palace ~/.mempalace/palace --force
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .miner import _extract_entities_for_metadata
|
||||
from .palace import (
|
||||
build_closet_lines,
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
mine_lock,
|
||||
purge_file_closets,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
|
||||
DIARY_ENTRY_RE = re.compile(r"^## .+", re.MULTILINE)
|
||||
|
||||
|
||||
def _state_file_for(palace_path: str, diary_dir: Path) -> Path:
|
||||
"""Return the per-(palace, diary-dir) state-file path under ~/.mempalace/state.
|
||||
|
||||
Keyed by sha256 of (palace_path, diary_dir) so multiple diary folders
|
||||
pointing at the same palace each get an independent state file. The
|
||||
state file is *never* written inside the user's diary directory.
|
||||
"""
|
||||
state_root = Path(os.path.expanduser("~")) / ".mempalace" / "state"
|
||||
state_root.mkdir(parents=True, exist_ok=True)
|
||||
key = hashlib.sha256(f"{palace_path}|{diary_dir}".encode()).hexdigest()[:24]
|
||||
return state_root / f"diary_ingest_{key}.json"
|
||||
|
||||
|
||||
def _split_entries(text):
|
||||
"""Split diary text into (header, body) pairs per ## entry."""
|
||||
parts = DIARY_ENTRY_RE.split(text)
|
||||
headers = DIARY_ENTRY_RE.findall(text)
|
||||
entries = []
|
||||
for i, header in enumerate(headers):
|
||||
body = parts[i + 1] if i + 1 < len(parts) else ""
|
||||
entries.append((header.strip(), body.strip()))
|
||||
return entries
|
||||
|
||||
|
||||
def _diary_drawer_id(wing: str, date_str: str) -> str:
|
||||
"""Stable, wing-scoped drawer ID. Two diaries (e.g. 'work' vs 'personal')
|
||||
sharing the same date never collide."""
|
||||
suffix = hashlib.sha256(f"{wing}|{date_str}".encode()).hexdigest()[:24]
|
||||
return f"drawer_diary_{suffix}"
|
||||
|
||||
|
||||
def _diary_closet_id_base(wing: str, date_str: str) -> str:
|
||||
suffix = hashlib.sha256(f"{wing}|{date_str}".encode()).hexdigest()[:24]
|
||||
return f"closet_diary_{suffix}"
|
||||
|
||||
|
||||
def ingest_diaries(
|
||||
diary_dir,
|
||||
palace_path,
|
||||
wing="diary",
|
||||
force=False,
|
||||
):
|
||||
"""Ingest daily summary files into the palace.
|
||||
|
||||
Each date file gets ONE drawer keyed by ``(wing, date)`` and closets that
|
||||
pack topics atomically up to ``CLOSET_CHAR_LIMIT``. ``force=True`` rebuilds
|
||||
every entry's closets from scratch (purging stale ones); the default
|
||||
incremental mode only processes entries appended since the last run.
|
||||
"""
|
||||
diary_dir = Path(diary_dir).expanduser().resolve()
|
||||
if not diary_dir.exists():
|
||||
print(f"Diary directory not found: {diary_dir}")
|
||||
return {"days_updated": 0, "closets_created": 0}
|
||||
|
||||
diary_files = sorted(diary_dir.glob("*.md"))
|
||||
if not diary_files:
|
||||
print(f"No .md files in {diary_dir}")
|
||||
return {"days_updated": 0, "closets_created": 0}
|
||||
|
||||
state_file = _state_file_for(str(palace_path), diary_dir)
|
||||
if force or not state_file.exists():
|
||||
state: dict = {}
|
||||
else:
|
||||
try:
|
||||
state = json.loads(state_file.read_text())
|
||||
except Exception:
|
||||
state = {}
|
||||
|
||||
drawers_col = get_collection(palace_path)
|
||||
closets_col = get_closets_collection(palace_path)
|
||||
|
||||
days_updated = 0
|
||||
closets_created = 0
|
||||
|
||||
for diary_path in diary_files:
|
||||
text = diary_path.read_text(encoding="utf-8", errors="replace")
|
||||
if len(text.strip()) < 50:
|
||||
continue
|
||||
|
||||
date_match = re.match(r"(\d{4}-\d{2}-\d{2})", diary_path.stem)
|
||||
if not date_match:
|
||||
continue
|
||||
date_str = date_match.group(1)
|
||||
|
||||
# Skip if content hasn't changed
|
||||
state_key = f"{wing}|{diary_path.name}"
|
||||
prev_size = state.get(state_key, {}).get("size", 0)
|
||||
curr_size = len(text)
|
||||
if curr_size == prev_size and not force:
|
||||
continue
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
drawer_id = _diary_drawer_id(wing, date_str)
|
||||
entities = _extract_entities_for_metadata(text)
|
||||
source_file = str(diary_path)
|
||||
|
||||
# Serialize per source — two terminals running ingest at once must
|
||||
# not interleave the upsert + closet-rebuild.
|
||||
with mine_lock(source_file):
|
||||
drawer_meta = {
|
||||
"date": date_str,
|
||||
"wing": wing,
|
||||
"room": "daily",
|
||||
"source_file": source_file,
|
||||
"source_session": "daily_diary",
|
||||
"filed_at": now_iso,
|
||||
}
|
||||
if entities:
|
||||
drawer_meta["entities"] = entities
|
||||
drawers_col.upsert(
|
||||
documents=[text],
|
||||
ids=[drawer_id],
|
||||
metadatas=[drawer_meta],
|
||||
)
|
||||
|
||||
entries = _split_entries(text)
|
||||
prev_entry_count = state.get(state_key, {}).get("entry_count", 0)
|
||||
new_entries = entries if force else entries[prev_entry_count:]
|
||||
|
||||
if new_entries:
|
||||
all_lines = []
|
||||
for header, body in new_entries:
|
||||
entry_text = f"{header}\n{body}"
|
||||
entry_lines = build_closet_lines(
|
||||
source_file, [drawer_id], entry_text, wing, "daily"
|
||||
)
|
||||
all_lines.extend(entry_lines)
|
||||
|
||||
if all_lines:
|
||||
closet_id_base = _diary_closet_id_base(wing, date_str)
|
||||
closet_meta = {
|
||||
"date": date_str,
|
||||
"wing": wing,
|
||||
"room": "daily",
|
||||
"source_file": source_file,
|
||||
"filed_at": now_iso,
|
||||
}
|
||||
if entities:
|
||||
closet_meta["entities"] = entities
|
||||
# On a force rebuild, wipe any leftover numbered closets
|
||||
# from a longer prior run before re-writing.
|
||||
if force:
|
||||
purge_file_closets(closets_col, source_file)
|
||||
n = upsert_closet_lines(closets_col, closet_id_base, all_lines, closet_meta)
|
||||
closets_created += n
|
||||
|
||||
state[state_key] = {
|
||||
"size": curr_size,
|
||||
"entry_count": len(entries),
|
||||
"ingested_at": now_iso,
|
||||
}
|
||||
days_updated += 1
|
||||
|
||||
state_file.write_text(json.dumps(state, indent=2))
|
||||
if days_updated:
|
||||
print(f"Diary: {days_updated} days updated, {closets_created} new closets")
|
||||
|
||||
return {"days_updated": days_updated, "closets_created": closets_created}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Ingest daily summaries into the palace")
|
||||
parser.add_argument("--dir", required=True, help="Path to daily_summaries directory")
|
||||
parser.add_argument("--palace", default=os.path.expanduser("~/.mempalace/palace"))
|
||||
parser.add_argument("--wing", default="diary")
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
ingest_diaries(args.dir, args.palace, wing=args.wing, force=args.force)
|
||||
+155
-418
@@ -9,391 +9,70 @@ Two-pass approach:
|
||||
Used by mempalace init before mining begins.
|
||||
The confirmed entity map feeds the miner as the taxonomy.
|
||||
|
||||
Multi-language support:
|
||||
All lexical patterns (person verbs, pronouns, dialogue markers, project
|
||||
verbs, stopwords, and the candidate-extraction character class) live in
|
||||
the ``entity`` section of ``mempalace/i18n/<lang>.json``. Every public
|
||||
function accepts a ``languages`` tuple and applies the union of the
|
||||
requested locales' patterns. The default is ``("en",)`` — existing
|
||||
English-only callers behave exactly as before.
|
||||
|
||||
To add a new language: add an ``entity`` section to that locale's JSON.
|
||||
No code changes required.
|
||||
|
||||
Usage:
|
||||
from entity_detector import detect_entities, confirm_entities
|
||||
candidates = detect_entities(file_paths)
|
||||
from mempalace.entity_detector import detect_entities, confirm_entities
|
||||
candidates = detect_entities(file_paths) # English only
|
||||
candidates = detect_entities(paths, languages=("en", "pt-br"))
|
||||
confirmed = confirm_entities(candidates) # interactive review
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
import functools
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
from mempalace.i18n import get_entity_patterns
|
||||
|
||||
# ==================== SIGNAL PATTERNS ====================
|
||||
|
||||
# Person signals — things people do
|
||||
PERSON_VERB_PATTERNS = [
|
||||
r"\b{name}\s+said\b",
|
||||
r"\b{name}\s+asked\b",
|
||||
r"\b{name}\s+told\b",
|
||||
r"\b{name}\s+replied\b",
|
||||
r"\b{name}\s+laughed\b",
|
||||
r"\b{name}\s+smiled\b",
|
||||
r"\b{name}\s+cried\b",
|
||||
r"\b{name}\s+felt\b",
|
||||
r"\b{name}\s+thinks?\b",
|
||||
r"\b{name}\s+wants?\b",
|
||||
r"\b{name}\s+loves?\b",
|
||||
r"\b{name}\s+hates?\b",
|
||||
r"\b{name}\s+knows?\b",
|
||||
r"\b{name}\s+decided\b",
|
||||
r"\b{name}\s+pushed\b",
|
||||
r"\b{name}\s+wrote\b",
|
||||
r"\bhey\s+{name}\b",
|
||||
r"\bthanks?\s+{name}\b",
|
||||
r"\bhi\s+{name}\b",
|
||||
r"\bdear\s+{name}\b",
|
||||
]
|
||||
# ==================== LANGUAGE-AWARE PATTERN LOADING ====================
|
||||
|
||||
# Person signals — pronouns resolving nearby
|
||||
PRONOUN_PATTERNS = [
|
||||
r"\bshe\b",
|
||||
r"\bher\b",
|
||||
r"\bhers\b",
|
||||
r"\bhe\b",
|
||||
r"\bhim\b",
|
||||
r"\bhis\b",
|
||||
r"\bthey\b",
|
||||
r"\bthem\b",
|
||||
r"\btheir\b",
|
||||
]
|
||||
|
||||
# Person signals — dialogue markers
|
||||
DIALOGUE_PATTERNS = [
|
||||
r"^>\s*{name}[:\s]", # > Speaker: ...
|
||||
r"^{name}:\s", # Speaker: ...
|
||||
r"^\[{name}\]", # [Speaker]
|
||||
r'"{name}\s+said',
|
||||
]
|
||||
def _normalize_langs(languages) -> tuple:
|
||||
"""Coerce a language input into a non-empty hashable tuple."""
|
||||
if not languages:
|
||||
return ("en",)
|
||||
if isinstance(languages, str):
|
||||
return (languages,)
|
||||
return tuple(languages)
|
||||
|
||||
# Project signals — things projects have/do
|
||||
PROJECT_VERB_PATTERNS = [
|
||||
r"\bbuilding\s+{name}\b",
|
||||
r"\bbuilt\s+{name}\b",
|
||||
r"\bship(?:ping|ped)?\s+{name}\b",
|
||||
r"\blaunch(?:ing|ed)?\s+{name}\b",
|
||||
r"\bdeploy(?:ing|ed)?\s+{name}\b",
|
||||
r"\binstall(?:ing|ed)?\s+{name}\b",
|
||||
r"\bthe\s+{name}\s+architecture\b",
|
||||
r"\bthe\s+{name}\s+pipeline\b",
|
||||
r"\bthe\s+{name}\s+system\b",
|
||||
r"\bthe\s+{name}\s+repo\b",
|
||||
r"\b{name}\s+v\d+\b", # MemPal v2
|
||||
r"\b{name}\.py\b", # mempalace.py
|
||||
r"\b{name}-core\b", # mempal-core (hyphen only, not underscore)
|
||||
r"\b{name}-local\b",
|
||||
r"\bimport\s+{name}\b",
|
||||
r"\bpip\s+install\s+{name}\b",
|
||||
]
|
||||
|
||||
# Words that are almost certainly NOT entities
|
||||
STOPWORDS = {
|
||||
"the",
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"or",
|
||||
"but",
|
||||
"in",
|
||||
"on",
|
||||
"at",
|
||||
"to",
|
||||
"for",
|
||||
"of",
|
||||
"with",
|
||||
"by",
|
||||
"from",
|
||||
"as",
|
||||
"is",
|
||||
"was",
|
||||
"are",
|
||||
"were",
|
||||
"be",
|
||||
"been",
|
||||
"being",
|
||||
"have",
|
||||
"has",
|
||||
"had",
|
||||
"do",
|
||||
"does",
|
||||
"did",
|
||||
"will",
|
||||
"would",
|
||||
"could",
|
||||
"should",
|
||||
"may",
|
||||
"might",
|
||||
"must",
|
||||
"shall",
|
||||
"can",
|
||||
"this",
|
||||
"that",
|
||||
"these",
|
||||
"those",
|
||||
"it",
|
||||
"its",
|
||||
"they",
|
||||
"them",
|
||||
"their",
|
||||
"we",
|
||||
"our",
|
||||
"you",
|
||||
"your",
|
||||
"i",
|
||||
"my",
|
||||
"me",
|
||||
"he",
|
||||
"she",
|
||||
"his",
|
||||
"her",
|
||||
"who",
|
||||
"what",
|
||||
"when",
|
||||
"where",
|
||||
"why",
|
||||
"how",
|
||||
"which",
|
||||
"if",
|
||||
"then",
|
||||
"so",
|
||||
"not",
|
||||
"no",
|
||||
"yes",
|
||||
"ok",
|
||||
"okay",
|
||||
"just",
|
||||
"very",
|
||||
"really",
|
||||
"also",
|
||||
"already",
|
||||
"still",
|
||||
"even",
|
||||
"only",
|
||||
"here",
|
||||
"there",
|
||||
"now",
|
||||
"then",
|
||||
"too",
|
||||
"up",
|
||||
"out",
|
||||
"about",
|
||||
"like",
|
||||
"use",
|
||||
"get",
|
||||
"got",
|
||||
"make",
|
||||
"made",
|
||||
"take",
|
||||
"put",
|
||||
"come",
|
||||
"go",
|
||||
"see",
|
||||
"know",
|
||||
"think",
|
||||
"true",
|
||||
"false",
|
||||
"none",
|
||||
"null",
|
||||
"new",
|
||||
"old",
|
||||
"all",
|
||||
"any",
|
||||
"some",
|
||||
"true",
|
||||
"false",
|
||||
"return",
|
||||
"print",
|
||||
"def",
|
||||
"class",
|
||||
"import",
|
||||
"from",
|
||||
# Common capitalized words in prose that aren't entities
|
||||
"step",
|
||||
"usage",
|
||||
"run",
|
||||
"check",
|
||||
"find",
|
||||
"add",
|
||||
"get",
|
||||
"set",
|
||||
"list",
|
||||
"args",
|
||||
"dict",
|
||||
"str",
|
||||
"int",
|
||||
"bool",
|
||||
"path",
|
||||
"file",
|
||||
"type",
|
||||
"name",
|
||||
"note",
|
||||
"example",
|
||||
"option",
|
||||
"result",
|
||||
"error",
|
||||
"warning",
|
||||
"info",
|
||||
"every",
|
||||
"each",
|
||||
"more",
|
||||
"less",
|
||||
"next",
|
||||
"last",
|
||||
"first",
|
||||
"second",
|
||||
"stack",
|
||||
"layer",
|
||||
"mode",
|
||||
"test",
|
||||
"stop",
|
||||
"start",
|
||||
"copy",
|
||||
"move",
|
||||
"source",
|
||||
"target",
|
||||
"output",
|
||||
"input",
|
||||
"data",
|
||||
"item",
|
||||
"key",
|
||||
"value",
|
||||
"returns",
|
||||
"raises",
|
||||
"yields",
|
||||
"none",
|
||||
"self",
|
||||
"cls",
|
||||
"kwargs",
|
||||
# Common sentence-starting / abstract words that aren't entities
|
||||
"world",
|
||||
"well",
|
||||
"want",
|
||||
"topic",
|
||||
"choose",
|
||||
"social",
|
||||
"cars",
|
||||
"phones",
|
||||
"healthcare",
|
||||
"ex",
|
||||
"machina",
|
||||
"deus",
|
||||
"human",
|
||||
"humans",
|
||||
"people",
|
||||
"things",
|
||||
"something",
|
||||
"nothing",
|
||||
"everything",
|
||||
"anything",
|
||||
"someone",
|
||||
"everyone",
|
||||
"anyone",
|
||||
"way",
|
||||
"time",
|
||||
"day",
|
||||
"life",
|
||||
"place",
|
||||
"thing",
|
||||
"part",
|
||||
"kind",
|
||||
"sort",
|
||||
"case",
|
||||
"point",
|
||||
"idea",
|
||||
"fact",
|
||||
"sense",
|
||||
"question",
|
||||
"answer",
|
||||
"reason",
|
||||
"number",
|
||||
"version",
|
||||
"system",
|
||||
# Greetings and filler words at sentence starts
|
||||
"hey",
|
||||
"hi",
|
||||
"hello",
|
||||
"thanks",
|
||||
"thank",
|
||||
"right",
|
||||
"let",
|
||||
"ok",
|
||||
# UI/action words that appear in how-to content
|
||||
"click",
|
||||
"hit",
|
||||
"press",
|
||||
"tap",
|
||||
"drag",
|
||||
"drop",
|
||||
"open",
|
||||
"close",
|
||||
"save",
|
||||
"load",
|
||||
"launch",
|
||||
"install",
|
||||
"download",
|
||||
"upload",
|
||||
"scroll",
|
||||
"select",
|
||||
"enter",
|
||||
"submit",
|
||||
"cancel",
|
||||
"confirm",
|
||||
"delete",
|
||||
"copy",
|
||||
"paste",
|
||||
"type",
|
||||
"write",
|
||||
"read",
|
||||
"search",
|
||||
"find",
|
||||
"show",
|
||||
"hide",
|
||||
# Common filesystem/technical capitalized words
|
||||
"desktop",
|
||||
"documents",
|
||||
"downloads",
|
||||
"users",
|
||||
"home",
|
||||
"library",
|
||||
"applications",
|
||||
"system",
|
||||
"preferences",
|
||||
"settings",
|
||||
"terminal",
|
||||
# Abstract/topic words
|
||||
"actor",
|
||||
"vector",
|
||||
"remote",
|
||||
"control",
|
||||
"duration",
|
||||
"fetch",
|
||||
# Abstract concepts that appear as subjects but aren't entities
|
||||
"agents",
|
||||
"tools",
|
||||
"others",
|
||||
"guards",
|
||||
"ethics",
|
||||
"regulation",
|
||||
"learning",
|
||||
"thinking",
|
||||
"memory",
|
||||
"language",
|
||||
"intelligence",
|
||||
"technology",
|
||||
"society",
|
||||
"culture",
|
||||
"future",
|
||||
"history",
|
||||
"science",
|
||||
"model",
|
||||
"models",
|
||||
"network",
|
||||
"networks",
|
||||
"training",
|
||||
"inference",
|
||||
}
|
||||
@functools.lru_cache(maxsize=32)
|
||||
def _get_stopwords(languages: tuple) -> frozenset:
|
||||
"""Return the union of stopwords across the given languages."""
|
||||
patterns = get_entity_patterns(languages)
|
||||
return frozenset(patterns["stopwords"])
|
||||
|
||||
|
||||
# ==================== BACKWARD-COMPAT MODULE CONSTANTS ====================
|
||||
#
|
||||
# These mirror the old module-level constants so existing imports keep working.
|
||||
# They reflect the English defaults and are populated at import time from
|
||||
# ``mempalace/i18n/en.json``. Callers that need multi-language behavior should
|
||||
# pass the ``languages`` parameter to the public functions below.
|
||||
|
||||
_EN = get_entity_patterns(("en",))
|
||||
|
||||
PERSON_VERB_PATTERNS = list(_EN["person_verb_patterns"])
|
||||
PRONOUN_PATTERNS = list(_EN["pronoun_patterns"])
|
||||
PRONOUN_RE = re.compile("|".join(PRONOUN_PATTERNS), re.IGNORECASE) if PRONOUN_PATTERNS else None
|
||||
DIALOGUE_PATTERNS = list(_EN["dialogue_patterns"])
|
||||
PROJECT_VERB_PATTERNS = list(_EN["project_verb_patterns"])
|
||||
STOPWORDS = set(_EN["stopwords"])
|
||||
|
||||
|
||||
# ==================== EXTENSION POINTS (not language-scoped) ====================
|
||||
|
||||
# For entity detection — prose only, no code files
|
||||
# Code files have too many capitalized names (classes, functions) that aren't entities
|
||||
@@ -440,55 +119,107 @@ SKIP_DIRS = {
|
||||
# ==================== CANDIDATE EXTRACTION ====================
|
||||
|
||||
|
||||
def extract_candidates(text: str) -> dict:
|
||||
def extract_candidates(text: str, languages=("en",)) -> dict:
|
||||
"""
|
||||
Extract all capitalized proper noun candidates from text.
|
||||
Returns {name: frequency} for names appearing 3+ times.
|
||||
"""
|
||||
# Find all capitalized words (not at sentence start — harder, so we use frequency as filter)
|
||||
raw = re.findall(r"\b([A-Z][a-z]{1,19})\b", text)
|
||||
|
||||
counts = defaultdict(int)
|
||||
for word in raw:
|
||||
if word.lower() not in STOPWORDS and len(word) > 1:
|
||||
Each language contributes its own character-class pattern (e.g. ASCII
|
||||
for English, Latin+diacritics for pt-br, Cyrillic for Russian,
|
||||
Devanagari for Hindi). Matches from all languages are unioned.
|
||||
"""
|
||||
langs = _normalize_langs(languages)
|
||||
patterns = get_entity_patterns(langs)
|
||||
stopwords = _get_stopwords(langs)
|
||||
|
||||
counts: defaultdict = defaultdict(int)
|
||||
|
||||
# Single-word candidates — one pre-wrapped pattern per language
|
||||
for wrapped_pat in patterns["candidate_patterns"]:
|
||||
try:
|
||||
rx = re.compile(wrapped_pat)
|
||||
except re.error:
|
||||
continue
|
||||
for word in rx.findall(text):
|
||||
if word.lower() in stopwords:
|
||||
continue
|
||||
if len(word) < 2:
|
||||
continue
|
||||
counts[word] += 1
|
||||
|
||||
# Also find multi-word proper nouns (e.g. "Memory Palace", "Claude Code")
|
||||
multi = re.findall(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b", text)
|
||||
for phrase in multi:
|
||||
if not any(w.lower() in STOPWORDS for w in phrase.split()):
|
||||
# Multi-word candidates — one pre-wrapped pattern per language
|
||||
for wrapped_pat in patterns["multi_word_patterns"]:
|
||||
try:
|
||||
rx = re.compile(wrapped_pat)
|
||||
except re.error:
|
||||
continue
|
||||
for phrase in rx.findall(text):
|
||||
if any(w.lower() in stopwords for w in phrase.split()):
|
||||
continue
|
||||
counts[phrase] += 1
|
||||
|
||||
# Filter: must appear at least 3 times to be a candidate
|
||||
return {name: count for name, count in counts.items() if count >= 3}
|
||||
|
||||
|
||||
# ==================== SIGNAL SCORING ====================
|
||||
|
||||
|
||||
def _build_patterns(name: str) -> dict:
|
||||
"""Pre-compile all regex patterns for a single entity name."""
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def _build_patterns(name: str, languages: tuple = ("en",)) -> dict:
|
||||
"""Pre-compile all regex patterns for a single entity name, per language set."""
|
||||
n = re.escape(name)
|
||||
langs = _normalize_langs(languages)
|
||||
sources = get_entity_patterns(langs)
|
||||
|
||||
def _compile_each(raw_patterns, flags=re.IGNORECASE):
|
||||
compiled = []
|
||||
for p in raw_patterns:
|
||||
try:
|
||||
compiled.append(re.compile(p.format(name=n), flags))
|
||||
except (re.error, KeyError, IndexError):
|
||||
continue
|
||||
return compiled
|
||||
|
||||
direct_sources = sources.get("direct_address_patterns") or []
|
||||
direct_compiled = []
|
||||
for raw in direct_sources:
|
||||
try:
|
||||
direct_compiled.append(re.compile(raw.format(name=n), re.IGNORECASE))
|
||||
except (re.error, KeyError, IndexError):
|
||||
continue
|
||||
|
||||
return {
|
||||
"dialogue": [
|
||||
re.compile(p.format(name=n), re.MULTILINE | re.IGNORECASE) for p in DIALOGUE_PATTERNS
|
||||
],
|
||||
"person_verbs": [re.compile(p.format(name=n), re.IGNORECASE) for p in PERSON_VERB_PATTERNS],
|
||||
"project_verbs": [
|
||||
re.compile(p.format(name=n), re.IGNORECASE) for p in PROJECT_VERB_PATTERNS
|
||||
],
|
||||
"direct": re.compile(rf"\bhey\s+{n}\b|\bthanks?\s+{n}\b|\bhi\s+{n}\b", re.IGNORECASE),
|
||||
"dialogue": _compile_each(sources["dialogue_patterns"], re.MULTILINE | re.IGNORECASE),
|
||||
"person_verbs": _compile_each(sources["person_verb_patterns"]),
|
||||
"project_verbs": _compile_each(sources["project_verb_patterns"]),
|
||||
"direct": direct_compiled,
|
||||
"versioned": re.compile(rf"\b{n}[-v]\w+", re.IGNORECASE),
|
||||
"code_ref": re.compile(rf"\b{n}\.(py|js|ts|yaml|yml|json|sh)\b", re.IGNORECASE),
|
||||
}
|
||||
|
||||
|
||||
def score_entity(name: str, text: str, lines: list) -> dict:
|
||||
@functools.lru_cache(maxsize=32)
|
||||
def _pronoun_re(languages: tuple):
|
||||
"""Compile a combined pronoun regex for the given languages."""
|
||||
langs = _normalize_langs(languages)
|
||||
patterns = get_entity_patterns(langs)
|
||||
pronouns = patterns.get("pronoun_patterns") or []
|
||||
if not pronouns:
|
||||
return None
|
||||
try:
|
||||
return re.compile("|".join(pronouns), re.IGNORECASE)
|
||||
except re.error:
|
||||
return None
|
||||
|
||||
|
||||
def score_entity(name: str, text: str, lines: list, languages=("en",)) -> dict:
|
||||
"""
|
||||
Score a candidate entity as person vs project.
|
||||
Returns scores and the signals that fired.
|
||||
"""
|
||||
patterns = _build_patterns(name)
|
||||
langs = _normalize_langs(languages)
|
||||
patterns = _build_patterns(name, langs)
|
||||
pronoun_re = _pronoun_re(langs)
|
||||
person_score = 0
|
||||
project_score = 0
|
||||
person_signals = []
|
||||
@@ -511,24 +242,25 @@ def score_entity(name: str, text: str, lines: list) -> dict:
|
||||
person_signals.append(f"'{name} ...' action ({matches}x)")
|
||||
|
||||
# Pronoun proximity — pronouns within 3 lines of the name
|
||||
name_lower = name.lower()
|
||||
name_line_indices = [i for i, line in enumerate(lines) if name_lower in line.lower()]
|
||||
pronoun_hits = 0
|
||||
for idx in name_line_indices:
|
||||
window_text = " ".join(lines[max(0, idx - 2) : idx + 3]).lower()
|
||||
for pronoun_pattern in PRONOUN_PATTERNS:
|
||||
if re.search(pronoun_pattern, window_text):
|
||||
if pronoun_re is not None:
|
||||
name_lower = name.lower()
|
||||
name_line_indices = [i for i, line in enumerate(lines) if name_lower in line.lower()]
|
||||
pronoun_hits = 0
|
||||
for idx in name_line_indices:
|
||||
window_text = " ".join(lines[max(0, idx - 2) : idx + 3])
|
||||
if pronoun_re.search(window_text):
|
||||
pronoun_hits += 1
|
||||
break
|
||||
if pronoun_hits > 0:
|
||||
person_score += pronoun_hits * 2
|
||||
person_signals.append(f"pronoun nearby ({pronoun_hits}x)")
|
||||
if pronoun_hits > 0:
|
||||
person_score += pronoun_hits * 2
|
||||
person_signals.append(f"pronoun nearby ({pronoun_hits}x)")
|
||||
|
||||
# Direct address
|
||||
direct = len(patterns["direct"].findall(text))
|
||||
if direct > 0:
|
||||
person_score += direct * 4
|
||||
person_signals.append(f"addressed directly ({direct}x)")
|
||||
direct_hits = 0
|
||||
for rx in patterns["direct"]:
|
||||
direct_hits += len(rx.findall(text))
|
||||
if direct_hits > 0:
|
||||
person_score += direct_hits * 4
|
||||
person_signals.append(f"addressed directly ({direct_hits}x)")
|
||||
|
||||
# --- Project signals ---
|
||||
|
||||
@@ -629,13 +361,15 @@ def classify_entity(name: str, frequency: int, scores: dict) -> dict:
|
||||
# ==================== MAIN DETECT ====================
|
||||
|
||||
|
||||
def detect_entities(file_paths: list, max_files: int = 10) -> dict:
|
||||
def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) -> dict:
|
||||
"""
|
||||
Scan files and detect entity candidates.
|
||||
|
||||
Args:
|
||||
file_paths: List of Path objects to scan
|
||||
max_files: Max files to read (for speed)
|
||||
languages: Tuple of language codes whose entity patterns should be
|
||||
applied (union). Defaults to ``("en",)``.
|
||||
|
||||
Returns:
|
||||
{
|
||||
@@ -644,6 +378,8 @@ def detect_entities(file_paths: list, max_files: int = 10) -> dict:
|
||||
"uncertain":[...entity dicts...],
|
||||
}
|
||||
"""
|
||||
langs = _normalize_langs(languages)
|
||||
|
||||
# Collect text from files
|
||||
all_text = []
|
||||
all_lines = []
|
||||
@@ -660,13 +396,13 @@ def detect_entities(file_paths: list, max_files: int = 10) -> dict:
|
||||
all_text.append(content)
|
||||
all_lines.extend(content.splitlines())
|
||||
files_read += 1
|
||||
except Exception:
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
combined_text = "\n".join(all_text)
|
||||
|
||||
# Extract candidates
|
||||
candidates = extract_candidates(combined_text)
|
||||
candidates = extract_candidates(combined_text, languages=langs)
|
||||
|
||||
if not candidates:
|
||||
return {"people": [], "projects": [], "uncertain": []}
|
||||
@@ -677,7 +413,7 @@ def detect_entities(file_paths: list, max_files: int = 10) -> dict:
|
||||
uncertain = []
|
||||
|
||||
for name, frequency in sorted(candidates.items(), key=lambda x: x[1], reverse=True):
|
||||
scores = score_entity(name, combined_text, all_lines)
|
||||
scores = score_entity(name, combined_text, all_lines, languages=langs)
|
||||
entity = classify_entity(name, frequency, scores)
|
||||
|
||||
if entity["type"] == "person":
|
||||
@@ -760,7 +496,7 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
|
||||
if detected["uncertain"]:
|
||||
print("\n Uncertain entities — classify each:")
|
||||
for e in detected["uncertain"]:
|
||||
ans = input(f" {e['name']} — (p)erson, (r)roject, or (s)kip? ").strip().lower()
|
||||
ans = input(f" {e['name']} — (p)erson, (r)project, or (s)kip? ").strip().lower()
|
||||
if ans == "p":
|
||||
confirmed_people.append(e["name"])
|
||||
elif ans == "r":
|
||||
@@ -789,7 +525,7 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
|
||||
name = input(" Name (or enter to stop): ").strip()
|
||||
if not name:
|
||||
break
|
||||
kind = input(f" Is '{name}' a (p)erson or (r)roject? ").strip().lower()
|
||||
kind = input(f" Is '{name}' a (p)erson or p(r)oject? ").strip().lower()
|
||||
if kind == "p":
|
||||
confirmed_people.append(name)
|
||||
elif kind == "r":
|
||||
@@ -841,13 +577,14 @@ if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python entity_detector.py <directory>")
|
||||
print("Usage: python entity_detector.py <directory> [lang1,lang2,...]")
|
||||
sys.exit(1)
|
||||
|
||||
project_dir = sys.argv[1]
|
||||
print(f"Scanning: {project_dir}")
|
||||
langs = tuple(sys.argv[2].split(",")) if len(sys.argv) >= 3 else ("en",)
|
||||
print(f"Scanning: {project_dir} (languages: {', '.join(langs)})")
|
||||
files = scan_for_detection(project_dir)
|
||||
print(f"Reading {len(files)} files...")
|
||||
detected = detect_entities(files)
|
||||
detected = detect_entities(files, languages=langs)
|
||||
confirmed = confirm_entities(detected)
|
||||
print("Confirmed entities:", confirmed)
|
||||
|
||||
@@ -44,7 +44,6 @@ COMMON_ENGLISH_WORDS = {
|
||||
"chance",
|
||||
"chase",
|
||||
"hunter",
|
||||
"hunter",
|
||||
"dash",
|
||||
"flash",
|
||||
"star",
|
||||
@@ -78,8 +77,6 @@ COMMON_ENGLISH_WORDS = {
|
||||
"january",
|
||||
"february",
|
||||
"march",
|
||||
"april",
|
||||
"june",
|
||||
"july",
|
||||
"august",
|
||||
"september",
|
||||
@@ -181,6 +178,12 @@ def _wikipedia_lookup(word: str) -> dict:
|
||||
Look up a word via Wikipedia REST API.
|
||||
Returns inferred type (person/place/concept/unknown) + confidence + summary.
|
||||
Free, no API key, handles disambiguation pages.
|
||||
|
||||
**Privacy warning:** This function makes an outbound HTTPS request to
|
||||
en.wikipedia.org, sending the queried word over the network. It should
|
||||
only be called when the caller has explicitly opted in via
|
||||
``allow_network=True`` in :meth:`EntityRegistry.research`. The default
|
||||
behaviour of ``research()`` is local-only (no network calls).
|
||||
"""
|
||||
try:
|
||||
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{urllib.parse.quote(word)}"
|
||||
@@ -247,16 +250,17 @@ def _wikipedia_lookup(word: str) -> dict:
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
# Not in Wikipedia — strong signal it's a proper noun (unusual name, nickname)
|
||||
# Not in Wikipedia — this tells us nothing definitive about
|
||||
# the word. Return "unknown" so the caller can decide.
|
||||
return {
|
||||
"inferred_type": "person",
|
||||
"confidence": 0.70,
|
||||
"inferred_type": "unknown",
|
||||
"confidence": 0.3,
|
||||
"wiki_summary": None,
|
||||
"wiki_title": None,
|
||||
"note": "not found in Wikipedia — likely a proper noun or unusual name",
|
||||
"note": "not found in Wikipedia",
|
||||
}
|
||||
return {"inferred_type": "unknown", "confidence": 0.0, "wiki_summary": None}
|
||||
except Exception:
|
||||
except (urllib.error.URLError, OSError, json.JSONDecodeError, KeyError):
|
||||
return {"inferred_type": "unknown", "confidence": 0.0, "wiki_summary": None}
|
||||
|
||||
|
||||
@@ -304,7 +308,7 @@ class EntityRegistry:
|
||||
path = (Path(config_dir) / "entity_registry.json") if config_dir else cls.DEFAULT_PATH
|
||||
if path.exists():
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return cls(data, path)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
@@ -312,7 +316,15 @@ class EntityRegistry:
|
||||
|
||||
def save(self):
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._path.write_text(json.dumps(self._data, indent=2))
|
||||
try:
|
||||
self._path.parent.chmod(0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
|
||||
try:
|
||||
self._path.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _empty() -> dict:
|
||||
@@ -505,20 +517,41 @@ class EntityRegistry:
|
||||
|
||||
# ── Research unknown words ───────────────────────────────────────────────
|
||||
|
||||
def research(self, word: str, auto_confirm: bool = False) -> dict:
|
||||
def research(self, word: str, auto_confirm: bool = False, allow_network: bool = False) -> dict:
|
||||
"""
|
||||
Research an unknown word via Wikipedia.
|
||||
Caches result. If auto_confirm=False, marks as unconfirmed (needs user review).
|
||||
Returns the lookup result.
|
||||
Research an unknown word.
|
||||
|
||||
By default this is **local-only**: it checks the wiki cache and
|
||||
returns ``"unknown"`` for uncached words. Pass
|
||||
``allow_network=True`` to explicitly opt in to an outbound
|
||||
Wikipedia lookup. This design honours the project's
|
||||
*local-first, zero API* and *privacy by architecture* principles
|
||||
— no data leaves the machine unless the caller requests it.
|
||||
|
||||
Caches result. If *auto_confirm* is ``False``, marks the entry
|
||||
as unconfirmed (needs user review).
|
||||
"""
|
||||
# Already cached?
|
||||
cache = self._data.setdefault("wiki_cache", {})
|
||||
# Check cache (read-only — no mutation when allow_network is False)
|
||||
cache = self._data.get("wiki_cache", {})
|
||||
if word in cache:
|
||||
return cache[word]
|
||||
|
||||
if not allow_network:
|
||||
return {
|
||||
"inferred_type": "unknown",
|
||||
"confidence": 0.0,
|
||||
"wiki_summary": None,
|
||||
"wiki_title": None,
|
||||
"word": word,
|
||||
"confirmed": False,
|
||||
"note": "network lookup disabled — pass allow_network=True to query Wikipedia",
|
||||
}
|
||||
|
||||
# Network path — ensure wiki_cache key exists before writing
|
||||
cache = self._data.setdefault("wiki_cache", {})
|
||||
result = _wikipedia_lookup(word)
|
||||
result["word"] = word
|
||||
result["confirmed"] = auto_confirm
|
||||
result.setdefault("word", word)
|
||||
result.setdefault("confirmed", auto_confirm)
|
||||
|
||||
cache[word] = result
|
||||
self.save()
|
||||
@@ -550,15 +583,19 @@ class EntityRegistry:
|
||||
|
||||
# ── Learn from sessions ──────────────────────────────────────────────────
|
||||
|
||||
def learn_from_text(self, text: str, min_confidence: float = 0.75) -> list:
|
||||
def learn_from_text(self, text: str, min_confidence: float = 0.75, languages=("en",)) -> list:
|
||||
"""
|
||||
Scan session text for new entity candidates.
|
||||
Returns list of newly discovered candidates for review.
|
||||
|
||||
``languages`` is forwarded to entity detection — pass the user's
|
||||
configured ``MempalaceConfig().entity_languages`` to match the
|
||||
locales used at ``mempalace init`` time.
|
||||
"""
|
||||
from mempalace.entity_detector import extract_candidates, score_entity, classify_entity
|
||||
|
||||
lines = text.splitlines()
|
||||
candidates = extract_candidates(text)
|
||||
candidates = extract_candidates(text, languages=languages)
|
||||
new_candidates = []
|
||||
|
||||
for name, frequency in candidates.items():
|
||||
@@ -566,7 +603,7 @@ class EntityRegistry:
|
||||
if name in self.people or name in self.projects:
|
||||
continue
|
||||
|
||||
scores = score_entity(name, text, lines)
|
||||
scores = score_entity(name, text, lines, languages=languages)
|
||||
entity = classify_entity(name, frequency, scores)
|
||||
|
||||
if entity["type"] == "person" and entity["confidence"] >= min_confidence:
|
||||
@@ -597,7 +634,6 @@ class EntityRegistry:
|
||||
Returns list of canonical names found.
|
||||
"""
|
||||
found = []
|
||||
query.lower()
|
||||
|
||||
for canonical, info in self.people.items():
|
||||
names_to_check = [canonical] + info.get("aliases", [])
|
||||
@@ -620,7 +656,9 @@ class EntityRegistry:
|
||||
Find capitalized words in query that aren't in registry or common words.
|
||||
These are candidates for Wikipedia research.
|
||||
"""
|
||||
candidates = re.findall(r"\b[A-Z][a-z]{2,15}\b", query)
|
||||
from .palace import _candidate_entity_words
|
||||
|
||||
candidates = _candidate_entity_words(query)
|
||||
unknown = []
|
||||
for word in set(candidates):
|
||||
if word.lower() in COMMON_ENGLISH_WORDS:
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
exporter.py — Export the palace as a browsable folder of markdown files.
|
||||
|
||||
Produces:
|
||||
output_dir/
|
||||
index.md — table of contents
|
||||
wing_name/
|
||||
room_name.md — one file per room, drawers as sections
|
||||
|
||||
Streams drawers in paginated batches so memory usage stays bounded
|
||||
regardless of palace size.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from .palace import get_collection
|
||||
|
||||
|
||||
def _safe_path_component(name: str) -> str:
|
||||
"""Sanitize a string for use as a directory/file name component."""
|
||||
name = re.sub(r'[/\\:*?"<>|]', "_", name)
|
||||
name = name.strip(". ")
|
||||
return name or "unknown"
|
||||
|
||||
|
||||
def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -> dict:
|
||||
"""Export all palace drawers as markdown files organized by wing/room.
|
||||
|
||||
Streams drawers in batches of 1000 and writes each wing/room file
|
||||
incrementally, keeping memory usage proportional to batch size rather
|
||||
than total palace size.
|
||||
|
||||
Args:
|
||||
palace_path: Path to the ChromaDB palace directory.
|
||||
output_dir: Where to write the exported markdown tree.
|
||||
format: Output format (currently only "markdown").
|
||||
|
||||
Returns:
|
||||
Stats dict: {"wings": N, "rooms": N, "drawers": N}
|
||||
"""
|
||||
col = get_collection(palace_path)
|
||||
total = col.count()
|
||||
|
||||
if total == 0:
|
||||
print(" Palace is empty — nothing to export.")
|
||||
return {"wings": 0, "rooms": 0, "drawers": 0}
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
try:
|
||||
os.chmod(output_dir, 0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
# Track which room files have been opened (so we can append vs overwrite)
|
||||
opened_rooms: set[tuple[str, str]] = set()
|
||||
# Track which wing directories have been created and chmoded
|
||||
created_wing_dirs: set[str] = set()
|
||||
# Track stats per wing: {wing: {room: count}}
|
||||
wing_stats: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
total_drawers = 0
|
||||
|
||||
print(f" Streaming {total} drawers...")
|
||||
offset = 0
|
||||
while offset < total:
|
||||
batch = col.get(limit=1000, offset=offset, include=["documents", "metadatas"])
|
||||
if not batch["ids"]:
|
||||
break
|
||||
|
||||
# Group this batch by wing/room so we do one file write per room per batch
|
||||
batch_grouped: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list))
|
||||
for doc_id, doc, meta in zip(batch["ids"], batch["documents"], batch["metadatas"]):
|
||||
wing = meta.get("wing", "unknown")
|
||||
room = meta.get("room", "general")
|
||||
batch_grouped[wing][room].append(
|
||||
{
|
||||
"id": doc_id,
|
||||
"content": doc,
|
||||
"source": meta.get("source_file", ""),
|
||||
"filed_at": meta.get("filed_at", ""),
|
||||
"added_by": meta.get("added_by", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Write/append each room file
|
||||
for wing, rooms in batch_grouped.items():
|
||||
safe_wing = _safe_path_component(wing)
|
||||
wing_dir = os.path.join(output_dir, safe_wing)
|
||||
if wing_dir not in created_wing_dirs:
|
||||
os.makedirs(wing_dir, exist_ok=True)
|
||||
try:
|
||||
os.chmod(wing_dir, 0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
created_wing_dirs.add(wing_dir)
|
||||
|
||||
for room, drawers in rooms.items():
|
||||
safe_room = _safe_path_component(room)
|
||||
room_path = os.path.join(wing_dir, f"{safe_room}.md")
|
||||
key = (wing, room)
|
||||
is_new = key not in opened_rooms
|
||||
|
||||
with open(room_path, "a" if not is_new else "w", encoding="utf-8") as f:
|
||||
if is_new:
|
||||
f.write(f"# {wing} / {room}\n\n")
|
||||
opened_rooms.add(key)
|
||||
|
||||
for drawer in drawers:
|
||||
source = drawer["source"] or "unknown"
|
||||
filed = drawer["filed_at"] or "unknown"
|
||||
added_by = drawer["added_by"] or "unknown"
|
||||
|
||||
f.write(
|
||||
f"## {drawer['id']}\n"
|
||||
f"\n"
|
||||
f"> {_quote_content(drawer['content'])}\n"
|
||||
f"\n"
|
||||
f"| Field | Value |\n"
|
||||
f"|-------|-------|\n"
|
||||
f"| Source | {source} |\n"
|
||||
f"| Filed | {filed} |\n"
|
||||
f"| Added by | {added_by} |\n"
|
||||
f"\n"
|
||||
f"---\n\n"
|
||||
)
|
||||
|
||||
wing_stats[wing][room] += len(drawers)
|
||||
total_drawers += len(drawers)
|
||||
|
||||
offset += len(batch["ids"])
|
||||
|
||||
# Build and print stats
|
||||
index_rows = []
|
||||
for wing in sorted(wing_stats):
|
||||
rooms = wing_stats[wing]
|
||||
wing_drawer_count = sum(rooms.values())
|
||||
index_rows.append((wing, len(rooms), wing_drawer_count))
|
||||
print(f" {wing}: {len(rooms)} rooms, {wing_drawer_count} drawers")
|
||||
|
||||
# Write index.md
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
index_lines = [
|
||||
f"# Palace Export — {today}\n",
|
||||
"",
|
||||
"| Wing | Rooms | Drawers |",
|
||||
"|------|-------|---------|",
|
||||
]
|
||||
for wing, room_count, drawer_count in index_rows:
|
||||
index_lines.append(f"| [{wing}]({wing}/) | {room_count} | {drawer_count} |")
|
||||
index_lines.append("")
|
||||
|
||||
index_path = os.path.join(output_dir, "index.md")
|
||||
with open(index_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(index_lines))
|
||||
|
||||
stats = {
|
||||
"wings": len(wing_stats),
|
||||
"rooms": sum(r for _, r, _ in index_rows),
|
||||
"drawers": total_drawers,
|
||||
}
|
||||
print(
|
||||
f"\n Exported {stats['drawers']} drawers across {stats['wings']} wings, {stats['rooms']} rooms"
|
||||
)
|
||||
print(f" Output: {output_dir}")
|
||||
return stats
|
||||
|
||||
|
||||
def _quote_content(text: str) -> str:
|
||||
"""Format content for a markdown blockquote, handling multiline."""
|
||||
lines = text.rstrip("\n").split("\n")
|
||||
return "\n> ".join(lines)
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
fact_checker.py — Verify text against known facts in the palace.
|
||||
|
||||
Checks AI responses, diary entries, and new content against the entity
|
||||
registry and knowledge graph for three classes of issue:
|
||||
|
||||
* similar_name — text mentions a name that's one/two edits
|
||||
away from *another* registered name, raising
|
||||
the possibility of a typo or mix-up.
|
||||
* relationship_mismatch — text asserts a role between two entities
|
||||
(e.g. "Bob is Alice's brother") while the KG
|
||||
records a *different* current role for the
|
||||
same subject/object pair.
|
||||
* stale_fact — text asserts a fact that the KG marks closed
|
||||
(``valid_to`` in the past).
|
||||
|
||||
Purely offline. Inputs: entity_registry JSON + KG SQLite. No network.
|
||||
|
||||
Usage:
|
||||
from mempalace.fact_checker import check_text
|
||||
issues = check_text("Bob is Alice's brother", palace_path)
|
||||
|
||||
# CLI
|
||||
python -m mempalace.fact_checker "Bob is Alice's brother" \\
|
||||
--palace ~/.mempalace/palace
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Share miner's mtime-cached registry loader so we don't double-read
|
||||
# ~/.mempalace/known_entities.json on every check_text call.
|
||||
from .miner import _load_known_entities_raw
|
||||
|
||||
|
||||
# Narrow detection patterns — parse "X is Y's Z" and "X's Z is Y".
|
||||
# Names are captured greedily as word sequences (letters + optional
|
||||
# capitalized follow-ons) so simple multi-token names still work.
|
||||
# Relationship words are constrained to sane lengths to avoid matching
|
||||
# arbitrary filler.
|
||||
_RELATIONSHIP_PATTERNS = [
|
||||
# "Bob is Alice's brother" → subject=Bob, possessor=Alice, role=brother
|
||||
re.compile(r"\b([A-Z][\w-]+)\s+is\s+([A-Z][\w-]+)'s\s+([a-z]{3,20})\b"),
|
||||
# "Alice's brother is Bob" → possessor=Alice, role=brother, subject=Bob
|
||||
re.compile(r"\b([A-Z][\w-]+)'s\s+([a-z]{3,20})\s+is\s+([A-Z][\w-]+)\b"),
|
||||
]
|
||||
|
||||
|
||||
def check_text(text: str, palace_path: str = None, config=None) -> list:
|
||||
"""Return a list of issues detected in ``text``.
|
||||
|
||||
Empty list means "no contradictions found" — absence of evidence, not
|
||||
evidence of absence. The detector is deliberately conservative:
|
||||
every issue is anchored to a specific KG fact or registry entry.
|
||||
"""
|
||||
if config is None:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
config = MempalaceConfig()
|
||||
if palace_path is None:
|
||||
palace_path = config.palace_path
|
||||
|
||||
if not text:
|
||||
return []
|
||||
|
||||
issues: list = []
|
||||
entity_names_raw = _load_known_entities_raw()
|
||||
|
||||
issues.extend(_check_entity_confusion(text, entity_names_raw))
|
||||
issues.extend(_check_kg_contradictions(text, palace_path))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
# ── entity-name confusion ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _flatten_names(entity_names_raw: dict) -> set:
|
||||
"""Flatten a ``{category: [names]}`` or ``{category: {name: meta}}``
|
||||
registry into a set of names."""
|
||||
flat: set = set()
|
||||
for cat in entity_names_raw.values():
|
||||
if isinstance(cat, list):
|
||||
flat.update(str(n) for n in cat if n)
|
||||
elif isinstance(cat, dict):
|
||||
flat.update(str(k) for k in cat.keys() if k)
|
||||
return flat
|
||||
|
||||
|
||||
def _check_entity_confusion(text: str, entity_names_raw: dict) -> list:
|
||||
"""Flag names mentioned in the text that are edit-distance ≤ 2 from
|
||||
a *different* registered name — a common typo / mix-up pattern.
|
||||
|
||||
Performance note: the original O(n²) pairwise scan over the full
|
||||
registry is gone. We first identify which names actually appear in
|
||||
the text, then only compute edit distance between *mentioned* names
|
||||
and the rest of the registry. This makes the cost O(m × n) where m
|
||||
is the handful of names in the text, not the full registry.
|
||||
"""
|
||||
all_names = _flatten_names(entity_names_raw)
|
||||
if not all_names:
|
||||
return []
|
||||
|
||||
# Which names from the registry actually appear in the text?
|
||||
mentioned: list = []
|
||||
for name in all_names:
|
||||
if re.search(r"\b" + re.escape(name) + r"\b", text, re.IGNORECASE):
|
||||
mentioned.append(name)
|
||||
if not mentioned:
|
||||
return []
|
||||
|
||||
issues: list = []
|
||||
seen_pairs: set = set()
|
||||
for name_a in mentioned:
|
||||
a_lower = name_a.lower()
|
||||
for name_b in all_names:
|
||||
if name_b == name_a:
|
||||
continue
|
||||
# Dedupe by unordered pair so we don't double-report.
|
||||
pair_key = tuple(sorted((name_a.lower(), name_b.lower())))
|
||||
if pair_key in seen_pairs:
|
||||
continue
|
||||
# Only flag when name_b is a *different* registry entry that
|
||||
# was NOT mentioned — otherwise both names in the text is
|
||||
# just the user writing about two people.
|
||||
if name_b in mentioned:
|
||||
seen_pairs.add(pair_key)
|
||||
continue
|
||||
distance = _edit_distance(a_lower, name_b.lower())
|
||||
if 0 < distance <= 2:
|
||||
issues.append(
|
||||
{
|
||||
"type": "similar_name",
|
||||
"detail": (
|
||||
f"'{name_a}' mentioned — did you mean "
|
||||
f"'{name_b}'? (edit distance {distance})"
|
||||
),
|
||||
"names": [name_a, name_b],
|
||||
"distance": distance,
|
||||
}
|
||||
)
|
||||
seen_pairs.add(pair_key)
|
||||
return issues
|
||||
|
||||
|
||||
# ── KG contradictions ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_claims(text: str) -> list:
|
||||
"""Yield structured (subject, predicate, object) claims from ``text``.
|
||||
|
||||
The two supported surface forms are "X is Y's Z" and "X's Z is Y",
|
||||
both of which resolve to the triple ``(X, Z, Y)`` — ``X`` has role
|
||||
``Z`` with respect to ``Y``. Matches are case-preserving for the
|
||||
entity names (KG lookup is case-insensitive on normalized IDs).
|
||||
"""
|
||||
claims: list = []
|
||||
for pat in _RELATIONSHIP_PATTERNS:
|
||||
for match in pat.finditer(text):
|
||||
groups = match.groups()
|
||||
if pat is _RELATIONSHIP_PATTERNS[0]:
|
||||
subject, possessor, role = groups[0], groups[1], groups[2]
|
||||
else:
|
||||
possessor, role, subject = groups[0], groups[1], groups[2]
|
||||
claims.append(
|
||||
{
|
||||
"subject": subject,
|
||||
"predicate": role.lower(),
|
||||
"object": possessor,
|
||||
"span": match.group(0),
|
||||
}
|
||||
)
|
||||
return claims
|
||||
|
||||
|
||||
def _check_kg_contradictions(text: str, palace_path: str) -> list:
|
||||
"""Compare each claim in ``text`` against the KG.
|
||||
|
||||
For every claim ``(subject, predicate, object)`` parsed from the
|
||||
text, look up the subject's current KG triples:
|
||||
|
||||
* ``relationship_mismatch`` fires when the KG records a fact about
|
||||
the same ``(subject, object)`` pair but with a *different*
|
||||
predicate — e.g. text says "brother" but KG says "husband".
|
||||
* ``stale_fact`` fires when the KG has the exact ``(subject,
|
||||
predicate, object)`` triple but its ``valid_to`` is in the past,
|
||||
meaning the claim is no longer current.
|
||||
"""
|
||||
claims = _extract_claims(text)
|
||||
if not claims:
|
||||
return []
|
||||
|
||||
try:
|
||||
from .knowledge_graph import KnowledgeGraph
|
||||
|
||||
# KG lives alongside the palace collection; mcp_server uses the
|
||||
# same convention (see _kg init). Pass ``db_path`` — the previous
|
||||
# code passed a nonexistent ``palace_path`` kwarg which raised
|
||||
# TypeError, silently swallowed by the outer except and rendered
|
||||
# the entire KG-check path dead.
|
||||
kg = KnowledgeGraph(db_path=os.path.join(palace_path, "knowledge_graph.sqlite3"))
|
||||
except Exception:
|
||||
# KG unavailable (brand-new palace, corrupted DB, etc.) — skip.
|
||||
return []
|
||||
|
||||
issues: list = []
|
||||
for claim in claims:
|
||||
subject = claim["subject"]
|
||||
claim_pred = claim["predicate"]
|
||||
claim_obj = claim["object"]
|
||||
try:
|
||||
facts = kg.query_entity(subject, direction="outgoing")
|
||||
except Exception:
|
||||
continue
|
||||
if not facts:
|
||||
continue
|
||||
|
||||
current_facts = [f for f in facts if f.get("current")]
|
||||
|
||||
# Mismatch: KG fact about same (subject, object) pair but different predicate.
|
||||
for fact in current_facts:
|
||||
if not _objects_match(fact.get("object"), claim_obj):
|
||||
continue
|
||||
kg_pred = (fact.get("predicate") or "").lower()
|
||||
if kg_pred and kg_pred != claim_pred:
|
||||
issues.append(
|
||||
{
|
||||
"type": "relationship_mismatch",
|
||||
"detail": (
|
||||
f"Text says '{claim['span']}' but KG records "
|
||||
f"{subject} {kg_pred} {fact.get('object')}"
|
||||
),
|
||||
"entity": subject,
|
||||
"claim": {
|
||||
"predicate": claim_pred,
|
||||
"object": claim_obj,
|
||||
},
|
||||
"kg_fact": {
|
||||
"predicate": kg_pred,
|
||||
"object": fact.get("object"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Stale fact: exact match on (subject, predicate, object) but KG
|
||||
# closed the window in the past.
|
||||
now_iso = datetime.now(timezone.utc).date().isoformat()
|
||||
for fact in facts:
|
||||
if fact.get("current"):
|
||||
continue
|
||||
kg_pred = (fact.get("predicate") or "").lower()
|
||||
if kg_pred != claim_pred:
|
||||
continue
|
||||
if not _objects_match(fact.get("object"), claim_obj):
|
||||
continue
|
||||
valid_to = fact.get("valid_to")
|
||||
if valid_to and str(valid_to) < now_iso:
|
||||
issues.append(
|
||||
{
|
||||
"type": "stale_fact",
|
||||
"detail": (
|
||||
f"Text says '{claim['span']}' but KG marks "
|
||||
f"this fact closed on {valid_to}"
|
||||
),
|
||||
"entity": subject,
|
||||
"valid_to": valid_to,
|
||||
}
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def _objects_match(kg_obj, claim_obj: str) -> bool:
|
||||
if kg_obj is None or not claim_obj:
|
||||
return False
|
||||
return str(kg_obj).strip().lower() == claim_obj.strip().lower()
|
||||
|
||||
|
||||
# ── Levenshtein helper (tight iterative version) ─────────────────────
|
||||
|
||||
|
||||
def _edit_distance(s1: str, s2: str) -> int:
|
||||
"""Levenshtein distance. O(len(s1) * len(s2)) time, O(len(s2)) space."""
|
||||
if len(s1) < len(s2):
|
||||
s1, s2 = s2, s1
|
||||
if not s2:
|
||||
return len(s1)
|
||||
prev = list(range(len(s2) + 1))
|
||||
for i, c1 in enumerate(s1):
|
||||
curr = [i + 1]
|
||||
for j, c2 in enumerate(s2):
|
||||
curr.append(
|
||||
min(
|
||||
prev[j + 1] + 1,
|
||||
curr[j] + 1,
|
||||
prev[j] + (0 if c1 == c2 else 1),
|
||||
)
|
||||
)
|
||||
prev = curr
|
||||
return prev[-1]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check text against known facts in the MemPalace palace.",
|
||||
epilog="Exits 0 when no issues found, 1 when one or more issues detected.",
|
||||
)
|
||||
parser.add_argument("text", nargs="?", help="Text to check (or use --stdin).")
|
||||
parser.add_argument(
|
||||
"--palace",
|
||||
default=os.path.expanduser("~/.mempalace/palace"),
|
||||
help="Path to the palace directory.",
|
||||
)
|
||||
parser.add_argument("--stdin", action="store_true", help="Read text from stdin.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.stdin:
|
||||
text_in = sys.stdin.read()
|
||||
elif args.text:
|
||||
text_in = args.text
|
||||
else:
|
||||
parser.error("Provide text as argument or use --stdin.")
|
||||
|
||||
found = check_text(text_in, palace_path=args.palace)
|
||||
if found:
|
||||
print(json.dumps(found, indent=2))
|
||||
sys.exit(1)
|
||||
print("No contradictions found.")
|
||||
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Hook logic for MemPalace — Python implementation of session-start, stop, and precompact hooks.
|
||||
|
||||
Reads JSON from stdin, outputs JSON to stdout.
|
||||
Supported hooks: session-start, stop, precompact
|
||||
Supported harnesses: claude-code, codex (extensible to cursor, gemini, etc.)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
SAVE_INTERVAL = 15
|
||||
STATE_DIR = Path.home() / ".mempalace" / "hook_state"
|
||||
|
||||
STOP_BLOCK_REASON = (
|
||||
"AUTO-SAVE checkpoint (MemPalace). Save this session's key content:\n"
|
||||
"1. mempalace_diary_write — AAAK-compressed session summary\n"
|
||||
"2. mempalace_add_drawer — verbatim quotes, decisions, code snippets\n"
|
||||
"3. mempalace_kg_add — entity relationships (optional)\n"
|
||||
"Do NOT write to Claude Code's native auto-memory (.md files). "
|
||||
"Continue conversation after saving."
|
||||
)
|
||||
|
||||
PRECOMPACT_BLOCK_REASON = (
|
||||
"COMPACTION IMMINENT (MemPalace). Save ALL session content before context is lost:\n"
|
||||
"1. mempalace_diary_write — thorough AAAK-compressed session summary\n"
|
||||
"2. mempalace_add_drawer — ALL verbatim quotes, decisions, code, context\n"
|
||||
"3. mempalace_kg_add — entity relationships (optional)\n"
|
||||
"Be thorough \u2014 after compaction, detailed context will be lost. "
|
||||
"Do NOT write to Claude Code's native auto-memory (.md files). "
|
||||
"Save everything to MemPalace, then allow compaction to proceed."
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_session_id(session_id: str) -> str:
|
||||
"""Only allow alnum, dash, underscore to prevent path traversal."""
|
||||
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "", session_id)
|
||||
return sanitized or "unknown"
|
||||
|
||||
|
||||
def _validate_transcript_path(transcript_path: str) -> Path:
|
||||
"""Validate and resolve a transcript path, rejecting paths outside expected roots.
|
||||
|
||||
Returns a resolved Path if valid, or None if the path should be rejected.
|
||||
Accepted paths must:
|
||||
- Have a .jsonl or .json extension
|
||||
- Not contain '..' after resolution (path traversal prevention)
|
||||
"""
|
||||
if not transcript_path:
|
||||
return None
|
||||
path = Path(transcript_path).expanduser().resolve()
|
||||
if path.suffix not in (".jsonl", ".json"):
|
||||
return None
|
||||
# Reject if the original input contained '..' traversal components
|
||||
if ".." in Path(transcript_path).parts:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _count_human_messages(transcript_path: str) -> int:
|
||||
"""Count human messages in a JSONL transcript, skipping command-messages."""
|
||||
path = _validate_transcript_path(transcript_path)
|
||||
if path is None:
|
||||
if transcript_path:
|
||||
_log(f"WARNING: transcript_path rejected by validator: {transcript_path!r}")
|
||||
return 0
|
||||
if not path.is_file():
|
||||
return 0
|
||||
count = 0
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
msg = entry.get("message", {})
|
||||
if isinstance(msg, dict) and msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
if "<command-message>" in content:
|
||||
continue
|
||||
elif isinstance(content, list):
|
||||
text = " ".join(
|
||||
b.get("text", "") for b in content if isinstance(b, dict)
|
||||
)
|
||||
if "<command-message>" in text:
|
||||
continue
|
||||
count += 1
|
||||
# Also handle Codex CLI transcript format
|
||||
# {"type": "event_msg", "payload": {"type": "user_message", "message": "..."}}
|
||||
elif entry.get("type") == "event_msg":
|
||||
payload = entry.get("payload", {})
|
||||
if isinstance(payload, dict) and payload.get("type") == "user_message":
|
||||
msg_text = payload.get("message", "")
|
||||
if isinstance(msg_text, str) and "<command-message>" not in msg_text:
|
||||
count += 1
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
except OSError:
|
||||
return 0
|
||||
return count
|
||||
|
||||
|
||||
_state_dir_initialized = False
|
||||
|
||||
|
||||
def _log(message: str):
|
||||
"""Append to hook state log file."""
|
||||
global _state_dir_initialized
|
||||
try:
|
||||
if not _state_dir_initialized:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
STATE_DIR.chmod(0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
_state_dir_initialized = True
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
is_new = not log_path.exists()
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
with open(log_path, "a") as f:
|
||||
f.write(f"[{timestamp}] {message}\n")
|
||||
if is_new:
|
||||
try:
|
||||
log_path.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _output(data: dict):
|
||||
"""Print JSON to stdout with consistent formatting (pretty-printed)."""
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def _get_mine_dir(transcript_path: str = "") -> str:
|
||||
"""Determine directory to mine from MEMPAL_DIR or transcript path."""
|
||||
mempal_dir = os.environ.get("MEMPAL_DIR", "")
|
||||
if mempal_dir and os.path.isdir(mempal_dir):
|
||||
return mempal_dir
|
||||
if transcript_path:
|
||||
path = Path(transcript_path).expanduser()
|
||||
if path.is_file():
|
||||
return str(path.parent)
|
||||
return ""
|
||||
|
||||
|
||||
_MINE_PID_FILE = STATE_DIR / "mine.pid"
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Cross-platform existence check for a PID.
|
||||
|
||||
On POSIX, ``os.kill(pid, 0)`` is the well-known no-op existence probe.
|
||||
On Windows, ``os.kill`` maps to ``TerminateProcess(handle, sig)`` and
|
||||
would *terminate* the target process with exit code ``sig`` — using
|
||||
it here would kill our own mine child (or worse, the caller itself).
|
||||
Use ``OpenProcess`` + ``GetExitCodeProcess`` via ctypes instead.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
||||
return False
|
||||
return code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _mine_already_running() -> bool:
|
||||
"""Return True if a background mine process from a previous hook fire is still alive."""
|
||||
try:
|
||||
pid = int(_MINE_PID_FILE.read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return _pid_alive(pid)
|
||||
|
||||
|
||||
def _spawn_mine(cmd: list) -> None:
|
||||
"""Spawn a mine subprocess, write its PID to the lock file, log to hook.log."""
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
with open(log_path, "a") as log_f:
|
||||
proc = subprocess.Popen(cmd, stdout=log_f, stderr=log_f)
|
||||
_MINE_PID_FILE.write_text(str(proc.pid))
|
||||
|
||||
|
||||
def _maybe_auto_ingest(transcript_path: str = ""):
|
||||
"""Run mempalace mine in background if a mine directory is available."""
|
||||
mine_dir = _get_mine_dir(transcript_path)
|
||||
if not mine_dir:
|
||||
return
|
||||
if _mine_already_running():
|
||||
_log("Skipping auto-ingest: mine already running")
|
||||
return
|
||||
try:
|
||||
_spawn_mine([sys.executable, "-m", "mempalace", "mine", mine_dir])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _mine_sync(transcript_path: str = ""):
|
||||
"""Run mempalace mine synchronously (for precompact -- data must land first)."""
|
||||
mine_dir = _get_mine_dir(transcript_path)
|
||||
if not mine_dir:
|
||||
return
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "mempalace", "mine", mine_dir],
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
|
||||
SUPPORTED_HARNESSES = {"claude-code", "codex"}
|
||||
|
||||
|
||||
def _parse_harness_input(data: dict, harness: str) -> dict:
|
||||
"""Parse stdin JSON according to the harness type."""
|
||||
if harness not in SUPPORTED_HARNESSES:
|
||||
print(f"Unknown harness: {harness}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return {
|
||||
"session_id": _sanitize_session_id(str(data.get("session_id", "unknown"))),
|
||||
"stop_hook_active": data.get("stop_hook_active", False),
|
||||
"transcript_path": str(data.get("transcript_path", "")),
|
||||
}
|
||||
|
||||
|
||||
def hook_stop(data: dict, harness: str):
|
||||
"""Stop hook: block every N messages for auto-save."""
|
||||
parsed = _parse_harness_input(data, harness)
|
||||
session_id = parsed["session_id"]
|
||||
stop_hook_active = parsed["stop_hook_active"]
|
||||
transcript_path = parsed["transcript_path"]
|
||||
|
||||
# If already in a save cycle, let through (infinite-loop prevention)
|
||||
if str(stop_hook_active).lower() in ("true", "1", "yes"):
|
||||
_output({})
|
||||
return
|
||||
|
||||
# Count human messages
|
||||
exchange_count = _count_human_messages(transcript_path)
|
||||
|
||||
# Track last save point
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
last_save_file = STATE_DIR / f"{session_id}_last_save"
|
||||
last_save = 0
|
||||
if last_save_file.is_file():
|
||||
try:
|
||||
last_save = int(last_save_file.read_text().strip())
|
||||
except (ValueError, OSError):
|
||||
last_save = 0
|
||||
|
||||
since_last = exchange_count - last_save
|
||||
|
||||
_log(f"Session {session_id}: {exchange_count} exchanges, {since_last} since last save")
|
||||
|
||||
if since_last >= SAVE_INTERVAL and exchange_count > 0:
|
||||
# Update last save point
|
||||
try:
|
||||
last_save_file.write_text(str(exchange_count), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_log(f"TRIGGERING SAVE at exchange {exchange_count}")
|
||||
|
||||
# Optional: auto-ingest if MEMPAL_DIR is set
|
||||
_maybe_auto_ingest(transcript_path)
|
||||
|
||||
_output({"decision": "block", "reason": STOP_BLOCK_REASON})
|
||||
else:
|
||||
_output({})
|
||||
|
||||
|
||||
def hook_session_start(data: dict, harness: str):
|
||||
"""Session start hook: initialize session tracking state."""
|
||||
parsed = _parse_harness_input(data, harness)
|
||||
session_id = parsed["session_id"]
|
||||
|
||||
_log(f"SESSION START for session {session_id}")
|
||||
|
||||
# Initialize session state directory
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Pass through — no blocking on session start
|
||||
_output({})
|
||||
|
||||
|
||||
def hook_precompact(data: dict, harness: str):
|
||||
"""Precompact hook: mine transcript synchronously, then allow compaction."""
|
||||
parsed = _parse_harness_input(data, harness)
|
||||
session_id = parsed["session_id"]
|
||||
transcript_path = parsed["transcript_path"]
|
||||
|
||||
_log(f"PRE-COMPACT triggered for session {session_id}")
|
||||
|
||||
# Mine synchronously so data lands before compaction proceeds
|
||||
_mine_sync(transcript_path)
|
||||
|
||||
_output({})
|
||||
|
||||
|
||||
def run_hook(hook_name: str, harness: str):
|
||||
"""Main entry point: read stdin JSON, dispatch to hook handler."""
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, EOFError):
|
||||
_log("WARNING: Failed to parse stdin JSON, proceeding with empty data")
|
||||
data = {}
|
||||
|
||||
hooks = {
|
||||
"session-start": hook_session_start,
|
||||
"stop": hook_stop,
|
||||
"precompact": hook_precompact,
|
||||
}
|
||||
|
||||
handler = hooks.get(hook_name)
|
||||
if handler is None:
|
||||
print(f"Unknown hook: {hook_name}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
handler(data, harness)
|
||||
@@ -0,0 +1,285 @@
|
||||
"""i18n — Language dictionaries for MemPalace.
|
||||
|
||||
Usage:
|
||||
from mempalace.i18n import load_lang, t
|
||||
|
||||
load_lang("fr") # load French
|
||||
print(t("cli.mine_start", path="/docs")) # "Extraction de /docs..."
|
||||
print(t("terms.wing")) # "aile"
|
||||
print(t("aaak.instruction")) # AAAK compression instruction in French
|
||||
|
||||
Each locale JSON may include an ``entity`` section with patterns used by
|
||||
``mempalace.entity_detector``. See ``get_entity_patterns`` for the merge rules
|
||||
and the README section "Adding a new language" for the schema.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_LANG_DIR = Path(__file__).parent
|
||||
_strings: dict = {}
|
||||
_current_lang: str = "en"
|
||||
|
||||
# Cache: tuple(langs) -> merged entity pattern dict
|
||||
_entity_cache: dict = {}
|
||||
|
||||
|
||||
def _canonical_lang(lang: str) -> Optional[str]:
|
||||
"""Resolve a language code to its on-disk canonical filename stem.
|
||||
|
||||
BCP 47 tags are case-insensitive (RFC 5646 §2.1.1), and the locale
|
||||
files mix conventions (``pt-br.json`` vs ``zh-CN.json``). Match on
|
||||
lowercase so callers can pass ``PT-BR``, ``zh-cn``, ``Pt-Br``, etc.
|
||||
Returns ``None`` if no file matches.
|
||||
"""
|
||||
if not lang:
|
||||
return None
|
||||
target = lang.strip().lower()
|
||||
for path in _LANG_DIR.glob("*.json"):
|
||||
if path.stem.lower() == target:
|
||||
return path.stem
|
||||
return None
|
||||
|
||||
|
||||
def available_languages() -> list[str]:
|
||||
"""Return list of available language codes."""
|
||||
return sorted(p.stem for p in _LANG_DIR.glob("*.json"))
|
||||
|
||||
|
||||
def load_lang(lang: str = "en") -> dict:
|
||||
"""Load a language dictionary. Falls back to English if not found."""
|
||||
global _strings, _current_lang
|
||||
canonical = _canonical_lang(lang)
|
||||
if canonical is None:
|
||||
canonical = "en"
|
||||
lang_file = _LANG_DIR / f"{canonical}.json"
|
||||
_strings = json.loads(lang_file.read_text(encoding="utf-8"))
|
||||
_current_lang = canonical
|
||||
return _strings
|
||||
|
||||
|
||||
def t(key: str, **kwargs) -> str:
|
||||
"""Get a translated string by dotted key. Supports {var} interpolation.
|
||||
|
||||
t("cli.mine_complete", closets=5, drawers=20)
|
||||
→ "Done. 5 closets, 20 drawers created."
|
||||
"""
|
||||
if not _strings:
|
||||
load_lang("en")
|
||||
parts = key.split(".", 1)
|
||||
if len(parts) == 2:
|
||||
section, name = parts
|
||||
val = _strings.get(section, {}).get(name, key)
|
||||
else:
|
||||
val = _strings.get(key, key)
|
||||
if kwargs and isinstance(val, str):
|
||||
try:
|
||||
val = val.format(**kwargs)
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
return val
|
||||
|
||||
|
||||
def current_lang() -> str:
|
||||
"""Return current language code."""
|
||||
return _current_lang
|
||||
|
||||
|
||||
def get_regex() -> dict:
|
||||
"""Return the regex patterns for the current language.
|
||||
|
||||
Keys: topic_pattern, stop_words, quote_pattern, action_pattern.
|
||||
Returns empty dict if no regex section in the language file.
|
||||
"""
|
||||
if not _strings:
|
||||
load_lang("en")
|
||||
return _strings.get("regex", {})
|
||||
|
||||
|
||||
def _load_entity_section(lang: str) -> dict:
|
||||
"""Load the raw entity section for one language. Returns {} if missing."""
|
||||
canonical = _canonical_lang(lang)
|
||||
if canonical is None:
|
||||
return {}
|
||||
lang_file = _LANG_DIR / f"{canonical}.json"
|
||||
try:
|
||||
data = json.loads(lang_file.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return data.get("entity", {}) or {}
|
||||
|
||||
|
||||
def _script_boundary(chars: str) -> str:
|
||||
"""Build a lookaround-based word boundary expression.
|
||||
|
||||
Python's built-in ``\\b`` is a transition between ``\\w`` and non-``\\w``.
|
||||
``\\w`` covers Unicode Letter and Number categories but NOT Marks (category
|
||||
Mc/Mn), so for scripts whose words contain combining vowel signs — Devanagari
|
||||
(ा ी ु), Arabic (ـَ ـِ ـُ), Hebrew (ִ ֵ), Thai, Tamil, Burmese, Khmer — the
|
||||
default ``\\b`` drops the trailing mark, truncating names like ``अनीता`` to
|
||||
``अनीत`` and failing to match ``\\bकहा\\b`` because the trailing matra is
|
||||
not a word character.
|
||||
|
||||
Locales with such scripts declare ``boundary_chars`` in their entity section
|
||||
(e.g. ``"\\\\w\\\\u0900-\\\\u097F"`` for Hindi). This function returns a
|
||||
regex fragment equivalent to ``\\b`` but where the "word" side is defined
|
||||
as any char matching ``[chars]`` rather than just ``\\w``.
|
||||
"""
|
||||
return (
|
||||
rf"(?:(?<=[{chars}])(?=[^{chars}])"
|
||||
rf"|(?<=[^{chars}])(?=[{chars}])"
|
||||
rf"|^(?=[{chars}])"
|
||||
rf"|(?<=[{chars}])$)"
|
||||
)
|
||||
|
||||
|
||||
def _expand_b(pattern: str, boundary_chars: str) -> str:
|
||||
"""Replace every literal ``\\b`` in ``pattern`` with a script-aware boundary.
|
||||
|
||||
``boundary_chars`` is the inside-word character class (without brackets).
|
||||
If it's falsy, the pattern is returned unchanged so ``\\b`` keeps its
|
||||
default Python ``re`` semantics.
|
||||
"""
|
||||
if not boundary_chars:
|
||||
return pattern
|
||||
return pattern.replace(r"\b", _script_boundary(boundary_chars))
|
||||
|
||||
|
||||
def _wrap_candidate(raw_pat: str, boundary_chars: str) -> str:
|
||||
"""Wrap a candidate/multi-word extraction pattern with a capture group
|
||||
and word boundaries appropriate for its locale.
|
||||
|
||||
Default: ``\\b(raw)\\b``. With ``boundary_chars``: the script-aware
|
||||
equivalent, so names ending in combining marks are matched in full.
|
||||
"""
|
||||
if boundary_chars:
|
||||
b = _script_boundary(boundary_chars)
|
||||
return f"{b}({raw_pat}){b}"
|
||||
return rf"\b({raw_pat})\b"
|
||||
|
||||
|
||||
def _collect_entity_section(section: dict, acc: dict) -> None:
|
||||
"""Merge one language's entity section into the running accumulator.
|
||||
|
||||
Handles boundary expansion in-place so the caller merges already-expanded
|
||||
strings: `candidate_patterns` and `multi_word_patterns` are pre-wrapped
|
||||
with the locale's boundary (capture group included, ready to compile);
|
||||
every ``\\b`` inside person/pronoun/dialogue/project/direct patterns is
|
||||
replaced with the locale's script-aware boundary.
|
||||
"""
|
||||
boundary_chars = section.get("boundary_chars")
|
||||
if section.get("candidate_pattern"):
|
||||
acc["candidate_patterns"].append(
|
||||
_wrap_candidate(section["candidate_pattern"], boundary_chars)
|
||||
)
|
||||
if section.get("multi_word_pattern"):
|
||||
acc["multi_word_patterns"].append(
|
||||
_wrap_candidate(section["multi_word_pattern"], boundary_chars)
|
||||
)
|
||||
if section.get("direct_address_pattern"):
|
||||
acc["direct_address"].append(_expand_b(section["direct_address_pattern"], boundary_chars))
|
||||
acc["person_verbs"].extend(
|
||||
_expand_b(p, boundary_chars) for p in section.get("person_verb_patterns", [])
|
||||
)
|
||||
acc["pronouns"].extend(
|
||||
_expand_b(p, boundary_chars) for p in section.get("pronoun_patterns", [])
|
||||
)
|
||||
acc["dialogue"].extend(
|
||||
_expand_b(p, boundary_chars) for p in section.get("dialogue_patterns", [])
|
||||
)
|
||||
acc["project_verbs"].extend(
|
||||
_expand_b(p, boundary_chars) for p in section.get("project_verb_patterns", [])
|
||||
)
|
||||
acc["stopwords"].update(w.lower() for w in section.get("stopwords", []))
|
||||
|
||||
|
||||
def get_entity_patterns(languages=("en",)) -> dict:
|
||||
"""Return merged entity detection patterns for the requested languages.
|
||||
|
||||
Entity detection patterns live under each locale's ``entity`` section.
|
||||
This function merges them into a single dict for consumption by
|
||||
``mempalace.entity_detector``.
|
||||
|
||||
Merge rules:
|
||||
- List fields (person_verb_patterns, pronoun_patterns, dialogue_patterns,
|
||||
project_verb_patterns) are concatenated in the order of ``languages``,
|
||||
with duplicates removed while preserving first occurrence.
|
||||
- ``stopwords`` is the set union across all languages, returned as a
|
||||
sorted list.
|
||||
- ``candidate_patterns`` and ``multi_word_patterns`` are returned as
|
||||
**fully-wrapped regex strings** (boundary + capture group applied);
|
||||
the consumer compiles them directly with no further wrapping.
|
||||
- ``direct_address_pattern`` is returned as a list of per-language
|
||||
alternation patterns (not concatenated — each is applied separately).
|
||||
|
||||
Locales with combining-mark scripts can declare ``boundary_chars`` in
|
||||
their entity section (e.g. ``"\\\\w\\\\u0900-\\\\u097F"`` for Hindi);
|
||||
every ``\\b`` inside that locale's patterns — plus the candidate/multi-
|
||||
word wrapping — is expanded to a script-aware lookaround boundary that
|
||||
treats the declared characters as "inside-word".
|
||||
|
||||
If ``languages`` is empty or no requested language declares entity data,
|
||||
English is used as a fallback so callers always get a working config.
|
||||
"""
|
||||
if not languages:
|
||||
languages = ("en",)
|
||||
# Normalize via canonical filename so callers using different casing
|
||||
# (e.g. "PT-BR" vs "pt-br") share the same cache entry and load the
|
||||
# same locale file. Unknown codes are kept as-is so the merge loop's
|
||||
# "found_any" branch fires the English fallback exactly once.
|
||||
languages = tuple(_canonical_lang(lang) or lang for lang in languages)
|
||||
key = languages
|
||||
if key in _entity_cache:
|
||||
return _entity_cache[key]
|
||||
|
||||
acc = {
|
||||
"candidate_patterns": [],
|
||||
"multi_word_patterns": [],
|
||||
"person_verbs": [],
|
||||
"pronouns": [],
|
||||
"dialogue": [],
|
||||
"direct_address": [],
|
||||
"project_verbs": [],
|
||||
"stopwords": set(),
|
||||
}
|
||||
|
||||
found_any = False
|
||||
for lang in languages:
|
||||
section = _load_entity_section(lang)
|
||||
if not section:
|
||||
continue
|
||||
found_any = True
|
||||
_collect_entity_section(section, acc)
|
||||
|
||||
if not found_any:
|
||||
# Fallback: load English directly so callers always get a working config.
|
||||
_collect_entity_section(_load_entity_section("en"), acc)
|
||||
|
||||
merged = {
|
||||
"candidate_patterns": acc["candidate_patterns"],
|
||||
"multi_word_patterns": acc["multi_word_patterns"],
|
||||
"person_verb_patterns": _dedupe(acc["person_verbs"]),
|
||||
"pronoun_patterns": _dedupe(acc["pronouns"]),
|
||||
"dialogue_patterns": _dedupe(acc["dialogue"]),
|
||||
"direct_address_patterns": acc["direct_address"],
|
||||
"project_verb_patterns": _dedupe(acc["project_verbs"]),
|
||||
"stopwords": sorted(acc["stopwords"]),
|
||||
}
|
||||
_entity_cache[key] = merged
|
||||
return merged
|
||||
|
||||
|
||||
def _dedupe(items: list) -> list:
|
||||
"""Remove duplicates while preserving first-occurrence order."""
|
||||
seen = set()
|
||||
out = []
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
# Auto-load English on import
|
||||
load_lang("en")
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"lang": "de",
|
||||
"label": "Deutsch",
|
||||
"terms": {
|
||||
"palace": "Palast",
|
||||
"wing": "Flügel",
|
||||
"hall": "Flur",
|
||||
"closet": "Schrank",
|
||||
"drawer": "Schublade",
|
||||
"mine": "schürfen",
|
||||
"search": "suchen",
|
||||
"status": "Status",
|
||||
"init": "initialisieren",
|
||||
"repair": "reparieren",
|
||||
"migrate": "migrieren",
|
||||
"entity": "Entität",
|
||||
"topic": "Thema"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Schürfe {path}...",
|
||||
"mine_complete": "Fertig. {closets} Schränke, {drawers} Schubladen erstellt.",
|
||||
"mine_skip": "Bereits geschürft. Verwenden Sie --force zum Wiederholen.",
|
||||
"search_no_results": "Keine Ergebnisse für: {query}",
|
||||
"search_results": "{count} Ergebnisse gefunden:",
|
||||
"status_palace": "Palast: {path}",
|
||||
"status_wings": "{count} Flügel",
|
||||
"status_closets": "{count} Schränke",
|
||||
"status_drawers": "{count} Schubladen",
|
||||
"init_complete": "Palast initialisiert in {path}",
|
||||
"init_exists": "Palast existiert bereits in {path}",
|
||||
"repair_complete": "Reparatur abgeschlossen. {fixed} Probleme behoben.",
|
||||
"migrate_complete": "Migration abgeschlossen.",
|
||||
"no_palace": "Kein Palast gefunden. Ausführen: mempalace init <Ordner>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Auf Deutsch komprimieren. Bindestriche zwischen Wörtern, Pipes zwischen Konzepten. Artikel und Füllwörter weglassen. Eigennamen und Zahlen exakt beibehalten."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[A-ZÄÖÜß][a-zäöüß]{2,}|[A-Za-zÄÖÜäöüß]{3,}",
|
||||
"stop_words": "der die das ein eine eines einer einem einen den dem des und oder aber denn weil wenn als ob auch noch schon sehr viel nur nicht mehr kann wird hat ist sind war waren sein haben wurde mit von zu für auf in an um über nach durch",
|
||||
"quote_pattern": "\\u201E([^\\u201C]{10,200})\\u201C|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:gebaut|behoben|geschrieben|hinzugefügt|gepusht|gemessen|getestet|überprüft|erstellt|gelöscht|aktualisiert|konfiguriert|bereitgestellt|migriert)\\s+[\\wÄÖÜäöüß\\s]{3,30}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"lang": "en",
|
||||
"label": "English",
|
||||
"terms": {
|
||||
"palace": "palace",
|
||||
"wing": "wing",
|
||||
"hall": "hall",
|
||||
"closet": "closet",
|
||||
"drawer": "drawer",
|
||||
"mine": "mine",
|
||||
"search": "search",
|
||||
"status": "status",
|
||||
"init": "init",
|
||||
"repair": "repair",
|
||||
"migrate": "migrate",
|
||||
"entity": "entity",
|
||||
"topic": "topic"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Mining {path}...",
|
||||
"mine_complete": "Done. {closets} closets, {drawers} drawers created.",
|
||||
"mine_skip": "Already mined. Use --force to re-mine.",
|
||||
"search_no_results": "No results for: {query}",
|
||||
"search_results": "Found {count} results:",
|
||||
"status_palace": "Palace: {path}",
|
||||
"status_wings": "{count} wings",
|
||||
"status_closets": "{count} closets",
|
||||
"status_drawers": "{count} drawers",
|
||||
"init_complete": "Palace initialized at {path}",
|
||||
"init_exists": "Palace already exists at {path}",
|
||||
"repair_complete": "Repair complete. {fixed} issues fixed.",
|
||||
"migrate_complete": "Migration complete.",
|
||||
"no_palace": "No palace found. Run: mempalace init <dir>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Compress to index format. Hyphens between words, pipes between concepts. Drop articles and filler. Keep names and numbers exact."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[A-Z][a-z]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
|
||||
"stop_words": "the this that these those some many most each every other only such very will would could should must shall yeah okay also even then now already still back done make take give know think want need going come find work added saved session summary conversation topics source about once just really actually here there where good great better thank please sorry right wrong true false",
|
||||
"quote_pattern": "\"([^\"]{20,200})\"",
|
||||
"action_pattern": "(?:built|fixed|wrote|added|pushed|measured|tested|reviewed|created|deleted|updated|configured|deployed|migrated)\\s+[\\w\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-Z][a-z]{1,19}",
|
||||
"multi_word_pattern": "[A-Z][a-z]+(?:\\s+[A-Z][a-z]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+said\\b",
|
||||
"\\b{name}\\s+asked\\b",
|
||||
"\\b{name}\\s+told\\b",
|
||||
"\\b{name}\\s+replied\\b",
|
||||
"\\b{name}\\s+laughed\\b",
|
||||
"\\b{name}\\s+smiled\\b",
|
||||
"\\b{name}\\s+cried\\b",
|
||||
"\\b{name}\\s+felt\\b",
|
||||
"\\b{name}\\s+thinks?\\b",
|
||||
"\\b{name}\\s+wants?\\b",
|
||||
"\\b{name}\\s+loves?\\b",
|
||||
"\\b{name}\\s+hates?\\b",
|
||||
"\\b{name}\\s+knows?\\b",
|
||||
"\\b{name}\\s+decided\\b",
|
||||
"\\b{name}\\s+pushed\\b",
|
||||
"\\b{name}\\s+wrote\\b",
|
||||
"\\bhey\\s+{name}\\b",
|
||||
"\\bthanks?\\s+{name}\\b",
|
||||
"\\bhi\\s+{name}\\b",
|
||||
"\\bdear\\s+{name}\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bshe\\b",
|
||||
"\\bher\\b",
|
||||
"\\bhers\\b",
|
||||
"\\bhe\\b",
|
||||
"\\bhim\\b",
|
||||
"\\bhis\\b",
|
||||
"\\bthey\\b",
|
||||
"\\bthem\\b",
|
||||
"\\btheir\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+said"
|
||||
],
|
||||
"direct_address_pattern": "\\bhey\\s+{name}\\b|\\bthanks?\\s+{name}\\b|\\bhi\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bbuilding\\s+{name}\\b",
|
||||
"\\bbuilt\\s+{name}\\b",
|
||||
"\\bship(?:ping|ped)?\\s+{name}\\b",
|
||||
"\\blaunch(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\bdeploy(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\binstall(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\bthe\\s+{name}\\s+architecture\\b",
|
||||
"\\bthe\\s+{name}\\s+pipeline\\b",
|
||||
"\\bthe\\s+{name}\\s+system\\b",
|
||||
"\\bthe\\s+{name}\\s+repo\\b",
|
||||
"\\b{name}\\s+v\\d+\\b",
|
||||
"\\b{name}\\.py\\b",
|
||||
"\\b{name}-core\\b",
|
||||
"\\b{name}-local\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
|
||||
"for", "of", "with", "by", "from", "as", "is", "was", "are", "were",
|
||||
"be", "been", "being", "have", "has", "had", "do", "does", "did",
|
||||
"will", "would", "could", "should", "may", "might", "must", "shall", "can",
|
||||
"this", "that", "these", "those", "it", "its", "they", "them", "their",
|
||||
"we", "our", "you", "your", "i", "my", "me", "he", "she", "his", "her",
|
||||
"who", "what", "when", "where", "why", "how", "which",
|
||||
"if", "then", "so", "not", "no", "yes", "ok", "okay",
|
||||
"just", "very", "really", "also", "already", "still", "even", "only",
|
||||
"here", "there", "now", "too", "up", "out", "about", "like",
|
||||
"use", "get", "got", "make", "made", "take", "put", "come", "go", "see",
|
||||
"know", "think", "true", "false", "none", "null", "new", "old", "all", "any", "some",
|
||||
"return", "print", "def", "class", "import",
|
||||
"step", "usage", "run", "check", "find", "add", "set", "list",
|
||||
"args", "dict", "str", "int", "bool", "path", "file", "type", "name",
|
||||
"note", "example", "option", "result", "error", "warning", "info",
|
||||
"every", "each", "more", "less", "next", "last", "first", "second",
|
||||
"stack", "layer", "mode", "test", "stop", "start", "copy", "move",
|
||||
"source", "target", "output", "input", "data", "item", "key", "value",
|
||||
"returns", "raises", "yields", "self", "cls", "kwargs",
|
||||
"world", "well", "want", "topic", "choose", "social", "cars", "phones",
|
||||
"healthcare", "ex", "machina", "deus", "human", "humans", "people",
|
||||
"things", "something", "nothing", "everything", "anything", "someone",
|
||||
"everyone", "anyone", "way", "time", "day", "life", "place", "thing",
|
||||
"part", "kind", "sort", "case", "point", "idea", "fact", "sense",
|
||||
"question", "answer", "reason", "number", "version", "system",
|
||||
"hey", "hi", "hello", "thanks", "thank", "right", "let",
|
||||
"click", "hit", "press", "tap", "drag", "drop", "open", "close",
|
||||
"save", "load", "launch", "install", "download", "upload", "scroll",
|
||||
"select", "enter", "submit", "cancel", "confirm", "delete", "paste",
|
||||
"write", "read", "search", "show", "hide",
|
||||
"desktop", "documents", "downloads", "users", "home", "library",
|
||||
"applications", "preferences", "settings", "terminal",
|
||||
"actor", "vector", "remote", "control", "duration", "fetch",
|
||||
"agents", "tools", "others", "guards", "ethics", "regulation",
|
||||
"learning", "thinking", "memory", "language", "intelligence",
|
||||
"technology", "society", "culture", "future", "history", "science",
|
||||
"model", "models", "network", "networks", "training", "inference"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"lang": "es",
|
||||
"label": "Español",
|
||||
"terms": {
|
||||
"palace": "palacio",
|
||||
"wing": "ala",
|
||||
"hall": "pasillo",
|
||||
"closet": "armario",
|
||||
"drawer": "cajón",
|
||||
"mine": "extraer",
|
||||
"search": "buscar",
|
||||
"status": "estado",
|
||||
"init": "inicializar",
|
||||
"repair": "reparar",
|
||||
"migrate": "migrar",
|
||||
"entity": "entidad",
|
||||
"topic": "tema"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Extrayendo {path}...",
|
||||
"mine_complete": "Listo. {closets} armarios, {drawers} cajones creados.",
|
||||
"mine_skip": "Ya extraído. Use --force para repetir.",
|
||||
"search_no_results": "Sin resultados para: {query}",
|
||||
"search_results": "{count} resultados encontrados:",
|
||||
"status_palace": "Palacio: {path}",
|
||||
"status_wings": "{count} alas",
|
||||
"status_closets": "{count} armarios",
|
||||
"status_drawers": "{count} cajones",
|
||||
"init_complete": "Palacio inicializado en {path}",
|
||||
"init_exists": "Ya existe un palacio en {path}",
|
||||
"repair_complete": "Reparación completa. {fixed} problemas corregidos.",
|
||||
"migrate_complete": "Migración completa.",
|
||||
"no_palace": "No se encontró palacio. Ejecute: mempalace init <carpeta>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Comprima en español. Guiones entre palabras, pipes entre conceptos. Elimine artículos y palabras de relleno. Mantenga nombres propios y números exactos."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[A-ZÁ-Ú][a-zá-ú]{2,}|[A-Za-zÁ-ú]{3,}",
|
||||
"stop_words": "el la los las un una unos unas de del al en con por para su sus mi mis tu tus es son está están fue ser estar haber sido como pero más muy también todo todos toda todas este esta estos estas ese esa esos esas que quien cual donde cuando porque aunque sin",
|
||||
"quote_pattern": "\"([^\"]{10,200})\"|«([^»]{10,200})»",
|
||||
"action_pattern": "(?:construido|corregido|escrito|añadido|enviado|medido|probado|revisado|creado|eliminado|actualizado|configurado|desplegado|migrado)\\s+[\\wá-ú\\s]{3,30}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"lang": "fr",
|
||||
"label": "Français",
|
||||
"terms": {
|
||||
"palace": "palais",
|
||||
"wing": "aile",
|
||||
"hall": "couloir",
|
||||
"closet": "placard",
|
||||
"drawer": "tiroir",
|
||||
"mine": "extraire",
|
||||
"search": "chercher",
|
||||
"status": "état",
|
||||
"init": "initialiser",
|
||||
"repair": "réparer",
|
||||
"migrate": "migrer",
|
||||
"entity": "entité",
|
||||
"topic": "sujet"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Extraction de {path}...",
|
||||
"mine_complete": "Terminé. {closets} placards, {drawers} tiroirs créés.",
|
||||
"mine_skip": "Déjà extrait. Utilisez --force pour refaire.",
|
||||
"search_no_results": "Aucun résultat pour : {query}",
|
||||
"search_results": "{count} résultats trouvés :",
|
||||
"status_palace": "Palais : {path}",
|
||||
"status_wings": "{count} ailes",
|
||||
"status_closets": "{count} placards",
|
||||
"status_drawers": "{count} tiroirs",
|
||||
"init_complete": "Palais initialisé dans {path}",
|
||||
"init_exists": "Un palais existe déjà dans {path}",
|
||||
"repair_complete": "Réparation terminée. {fixed} problèmes corrigés.",
|
||||
"migrate_complete": "Migration terminée.",
|
||||
"no_palace": "Aucun palais trouvé. Exécutez : mempalace init <dossier>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Comprimez en français. Tirets entre les mots, pipes entre les concepts. Supprimez les articles et mots de remplissage. Gardez les noms propres et chiffres exacts."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[A-ZÀ-Ý][a-zà-ÿ]{2,}|[A-Za-zÀ-ÿ]{3,}",
|
||||
"stop_words": "le la les un une des de du au aux en et ou mais donc or ni car que qui ce cette ces son sa ses mon ma mes ton ta tes leur leurs nous vous ils elles on ne pas plus très bien aussi avec pour dans sur par est sont fait être avoir été comme tout tous toute toutes",
|
||||
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:construit|corrigé|écrit|ajouté|poussé|mesuré|testé|révisé|créé|supprimé|mis à jour|configuré|déployé|migré)\\s+[\\wà-ÿ\\s]{3,30}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"lang": "hi",
|
||||
"label": "हिंदी",
|
||||
"terms": {
|
||||
"palace": "महल",
|
||||
"wing": "खंड",
|
||||
"hall": "हॉल",
|
||||
"closet": "अलमारी",
|
||||
"drawer": "दराज़",
|
||||
"mine": "खनन",
|
||||
"search": "खोज",
|
||||
"status": "स्थिति",
|
||||
"init": "आरंभ",
|
||||
"repair": "मरम्मत",
|
||||
"migrate": "स्थानांतरित करना",
|
||||
"entity": "इकाई",
|
||||
"topic": "विषय"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "{path} का खनन किया जा रहा है...",
|
||||
"mine_complete": "पूर्ण हुआ। {closets} अलमारियाँ, {drawers} दराज़ें बनाई गईं।",
|
||||
"mine_skip": "पहले ही खनन हो चुका है। पुनः खनन के लिए --force का उपयोग करें।",
|
||||
"search_no_results": "इसके लिए कोई परिणाम नहीं मिले: {query}",
|
||||
"search_results": "{count} परिणाम मिले:",
|
||||
"status_palace": "महल: {path}",
|
||||
"status_wings": "{count} खंड",
|
||||
"status_closets": "{count} अलमारियाँ",
|
||||
"status_drawers": "{count} दराज़ें",
|
||||
"init_complete": "महल {path} पर प्रारंभ किया गया",
|
||||
"init_exists": "महल पहले से ही {path} पर मौजूद है",
|
||||
"repair_complete": "मरम्मत पूर्ण। {fixed} समस्याएँ ठीक की गईं।",
|
||||
"migrate_complete": "स्थानांतरण पूर्ण।",
|
||||
"no_palace": "कोई महल नहीं मिला। चलाएँ: mempalace init <dir>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "इंडेक्स प्रारूप में संपीड़न करें। शब्दों के बीच हाइफ़न और अवधारणाओं के बीच पाइप का प्रयोग करें। आर्टिकल और अनावश्यक शब्द हटाएँ। नाम और संख्याएँ सटीक रखें।"
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[\\u0900-\\u097F]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
|
||||
"stop_words": "यह वह ये वे कुछ कई अधिकांश प्रत्येक हर अन्य केवल ऐसा बहुत होगा सकता चाहिए ज़रूर हाँ ठीक भी फिर अब पहले अभी वापस पूरा बनाना लेना देना जानना सोचना चाहना ज़रूरत जा आ आना जाना ढूँढना काम जोड़ा सहेजा सत्र सारांश वार्तालाप स्रोत विषय के एक बार बस वास्तव में कहाँ यहाँ वहाँ धन्यवाद कृपया सही गलत करें किया करता करती चलाएँ उपयोग",
|
||||
"quote_pattern": "\"([^\"]{20,200})\"",
|
||||
"action_pattern": "(?:बनाया|सुधारा|लिखा|जोड़ा|भेजा|मापा|परीक्षण किया|समीक्षा की|निर्मित किया|हटाया|अद्यतन किया|विन्यस्त किया|तैनात किया|स्थानांतरित किया)\\s+[\\w\\s\\u0900-\\u097F]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"boundary_chars": "\\w\\u0900-\\u097F",
|
||||
"candidate_pattern": "[\\u0900-\\u097F]{2,20}",
|
||||
"multi_word_pattern": "[\\u0900-\\u097F]+(?:\\s+[\\u0900-\\u097F]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+ने\\s+कहा\\b",
|
||||
"\\b{name}\\s+ने\\s+पूछा\\b",
|
||||
"\\b{name}\\s+ने\\s+बोला\\b",
|
||||
"\\b{name}\\s+ने\\s+बताया\\b",
|
||||
"\\b{name}\\s+हँसा\\b",
|
||||
"\\b{name}\\s+मुस्कुराया\\b",
|
||||
"\\b{name}\\s+रोया\\b",
|
||||
"\\b{name}\\s+सोचा\\b",
|
||||
"\\b{name}\\s+चाहा\\b",
|
||||
"\\b{name}\\s+पसंद\\s+किया\\b",
|
||||
"\\b{name}\\s+नफरत\\s+की\\b",
|
||||
"\\b{name}\\s+जानता\\s+है\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bवह\\b",
|
||||
"\\bउसने\\b",
|
||||
"\\bउसे\\b",
|
||||
"\\bउसका\\b",
|
||||
"\\bउन्होंने\\b",
|
||||
"\\bउनका\\b",
|
||||
"\\bवे\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+ने\\s+कहा"
|
||||
],
|
||||
"direct_address_pattern": "\\bअरे\\s+{name}\\b|\\bनमस्ते\\s+{name}\\b|\\bधन्यवाद\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\b{name}\\s+बना\\s+रहा\\s+है\\b",
|
||||
"\\b{name}\\s+बनाया\\b",
|
||||
"\\b{name}\\s+लॉन्च\\s+किया\\b",
|
||||
"\\b{name}\\s+तैनात\\s+किया\\b",
|
||||
"\\b{name}\\s+इंस्टॉल\\s+किया\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"यह", "वह", "ये", "वे",
|
||||
"मैं", "हम", "आप", "तुम",
|
||||
"मेरा", "हमारा", "आपका", "उसका", "उनका",
|
||||
"मुझे", "हमें", "आपको", "उसे", "उन्हें",
|
||||
"का", "के", "की",
|
||||
"को", "से", "में", "पर",
|
||||
"के लिए", "के साथ", "के बारे में", "द्वारा",
|
||||
"और", "या", "लेकिन", "क्योंकि", "तो", "यदि",
|
||||
"भी", "ही", "सिर्फ", "केवल",
|
||||
"है", "हैं", "था", "थे", "थी",
|
||||
"हो", "होगा", "होता", "होती",
|
||||
"कर", "करना", "किया", "करते", "करती",
|
||||
"नहीं", "हाँ", "शायद", "ज़रूर",
|
||||
"क्या", "कौन", "कब", "कहाँ", "क्यों", "कैसे",
|
||||
"अब", "तब", "यहाँ", "वहाँ",
|
||||
"बहुत", "कम", "अधिक",
|
||||
"कुछ", "कोई", "सब", "हर"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"lang": "id",
|
||||
"label": "Bahasa Indonesia",
|
||||
"terms": {
|
||||
"palace": "istana",
|
||||
"wing": "sayap",
|
||||
"hall": "lorong",
|
||||
"closet": "lemari",
|
||||
"drawer": "laci",
|
||||
"mine": "tambang",
|
||||
"search": "cari",
|
||||
"status": "status",
|
||||
"init": "inisialisasi",
|
||||
"repair": "perbaiki",
|
||||
"migrate": "migrasi",
|
||||
"entity": "entitas",
|
||||
"topic": "topik"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Menambang {path}...",
|
||||
"mine_complete": "Selesai. {closets} lemari dan {drawers} laci berhasil dibuat.",
|
||||
"mine_skip": "Sudah pernah ditambang. Gunakan --force untuk menambang ulang.",
|
||||
"search_no_results": "Tidak ada hasil untuk: {query}",
|
||||
"search_results": "Ditemukan {count} hasil:",
|
||||
"status_palace": "Istana: {path}",
|
||||
"status_wings": "{count} sayap",
|
||||
"status_closets": "{count} lemari",
|
||||
"status_drawers": "{count} laci",
|
||||
"init_complete": "Istana diinisialisasi pada {path}",
|
||||
"init_exists": "Istana sudah ada pada {path}",
|
||||
"repair_complete": "Perbaikan selesai. {fixed} masalah berhasil diperbaiki.",
|
||||
"migrate_complete": "Migrasi selesai.",
|
||||
"no_palace": "Istana tidak ditemukan. Jalankan: mempalace init <dir>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Ringkas ke format indeks. Gunakan tanda hubung (-) antar kata dan garis vertikal (|) antar konsep. Buang kata fungsi dan kata pengisi yang tidak penting. Pertahankan nama serta angka tetap persis."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[A-Z][a-z]{2,}|[A-Za-z][A-Za-z0-9_/-]{2,}",
|
||||
"stop_words": "yang untuk pada ke para namun menurut antara dia ia seperti jika sehingga kembali dan tidak ini karena kepada oleh saat harus sementara setelah belum kami sekitar bagi serta di dari telah sebagai masih hal ketika adalah itu dalam bisa bahwa atau hanya kita dengan akan juga ada mereka sudah saya terhadap secara agar lain anda begitu mengapa kenapa yaitu yakni daripada itulah lagi maka tentang demi di mana ke mana pula sambil sebelum sesudah supaya guna kah pun sampai sedangkan selagi tetapi apakah kecuali sebab selain seolah seraya seterusnya tanpa agak boleh dapat dsb dst dll dahulu dulunya anu demikian tapi ingin nggak gak ga ngga enggak mari nanti melainkan oh ok oke seharusnya sebetulnya setiap setidaknya sesuatu pasti saja toh ya walau tolong tentu amat apalagi bagaimanapun udah banget bgt nih dong kok sih deh aja pun",
|
||||
"quote_pattern": "\"([^\"]{20,200})\"|“([^”]{20,200})”|‘([^’]{20,200})’",
|
||||
"action_pattern": "(?:dibangun|membangun|ngembangin|diperbaiki|memperbaiki|ditulis|menulis|ditambahkan|menambahkan|dibuat|membuat|diperbarui|memperbarui|diulas|mengulas|diuji|menguji|diukur|mengukur|dikonfigurasi|mengonfigurasi|dideploy|deploy|nge-?deploy|ngebuild|build|dikirim|push|dipush|nge-?push|dirilis|rilis|dimigrasi|migrasi|dibundle)\\s+[\\w\\s./_-]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-Z][a-z]{1,19}|[A-Z]{2,10}|[A-Za-z][A-Za-z0-9_]*[A-Z][A-Za-z0-9_]*|[a-z]+[A-Z][A-Za-z0-9_]*",
|
||||
"multi_word_pattern": "(?:[A-Z][a-z]+|[A-Z]{2,10})(?:\\s+(?:[A-Z][a-z]+|[A-Z]{2,10}))+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+berkata\\b",
|
||||
"\\b{name}\\s+mengatakan\\b",
|
||||
"\\b{name}\\s+bilang\\b",
|
||||
"\\b{name}\\s+ngomong\\b",
|
||||
"\\b{name}\\s+ceritain\\b",
|
||||
"\\b{name}\\s+bertanya\\b",
|
||||
"\\b{name}\\s+menanyakan\\b",
|
||||
"\\b{name}\\s+tanya\\b",
|
||||
"\\b{name}\\s+nanya\\b",
|
||||
"\\b{name}\\s+menjawab\\b",
|
||||
"\\b{name}\\s+jawab\\b",
|
||||
"\\b{name}\\s+balas\\b",
|
||||
"\\b{name}\\s+reply\\b",
|
||||
"\\b{name}\\s+membalas\\b",
|
||||
"\\b{name}\\s+menjelaskan\\b",
|
||||
"\\b{name}\\s+cerita\\b",
|
||||
"\\b{name}\\s+tertawa\\b",
|
||||
"\\b{name}\\s+tersenyum\\b",
|
||||
"\\b{name}\\s+menangis\\b",
|
||||
"\\b{name}\\s+merasa\\b",
|
||||
"\\b{name}\\s+memikirkan\\b",
|
||||
"\\b{name}\\s+berpikir\\b",
|
||||
"\\b{name}\\s+pikir\\b",
|
||||
"\\b{name}\\s+ingin\\b",
|
||||
"\\b{name}\\s+mau\\b",
|
||||
"\\b{name}\\s+suka\\b",
|
||||
"\\b{name}\\s+benci\\b",
|
||||
"\\b{name}\\s+tahu\\b",
|
||||
"\\b{name}\\s+memutuskan\\b",
|
||||
"\\b{name}\\s+memilih\\b",
|
||||
"\\b{name}\\s+decided\\b",
|
||||
"\\b{name}\\s+menulis\\b",
|
||||
"\\b{name}\\s+nulis\\b",
|
||||
"\\b{name}\\s+ngetik\\b",
|
||||
"\\b{name}\\s+push\\b",
|
||||
"\\b{name}\\s+nge-?push\\b",
|
||||
"\\b{name}\\s+review(?:ed)?\\b",
|
||||
"\\b{name}\\s+nge-?review\\b",
|
||||
"\\b{name}\\s+approve(?:d)?\\b",
|
||||
"\\b{name}\\s+di-?approve\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bdia\\b",
|
||||
"\\bia\\b",
|
||||
"\\bbeliau\\b",
|
||||
"\\bmereka\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+berkata",
|
||||
"\"{name}\\s+bilang"
|
||||
],
|
||||
"direct_address_pattern": "\\bhai\\s+{name}\\b|\\bhalo\\s+{name}\\b|\\bhi\\s+{name}\\b|\\bhei\\s+{name}\\b|\\bterima\\s+kasih\\s+{name}\\b|\\bmakasih\\s+{name}\\b|\\bmakasi\\s+{name}\\b|\\bpak\\s+{name}\\b|\\bbu\\s+{name}\\b|\\bmas\\s+{name}\\b|\\bmbak\\s+{name}\\b|\\bkak\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bmembangun\\s+{name}\\b",
|
||||
"\\bbangun\\s+{name}\\b",
|
||||
"\\bdibangun\\s+{name}\\b",
|
||||
"\\bngembangin\\s+{name}\\b",
|
||||
"\\bmengerjakan\\s+{name}\\b",
|
||||
"\\bngerjain\\s+{name}\\b",
|
||||
"\\bgarap\\s+{name}\\b",
|
||||
"\\bbuild(?:ing)?\\s+{name}\\b",
|
||||
"\\bnge-?build\\s+{name}\\b",
|
||||
"\\bmerilis\\s+{name}\\b",
|
||||
"\\brilis\\s+{name}\\b",
|
||||
"\\bship(?:ping|ped)?\\s+{name}\\b",
|
||||
"\\bmeluncurkan\\s+{name}\\b",
|
||||
"\\blaunch(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\bdeploy(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\bdideploy\\s+{name}\\b",
|
||||
"\\bmendeploy\\s+{name}\\b",
|
||||
"\\bnge-?deploy\\s+{name}\\b",
|
||||
"\\binstall(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\bmenginstal\\s+{name}\\b",
|
||||
"\\bmemasang\\s+{name}\\b",
|
||||
"\\bpush\\s+{name}\\b",
|
||||
"\\bnge-?push\\s+{name}\\b",
|
||||
"\\breview(?:ing|ed)?\\s+{name}\\b",
|
||||
"\\barsitektur\\s+{name}\\b",
|
||||
"\\bpipeline\\s+{name}\\b",
|
||||
"\\b{name}\\s+v\\d+\\b",
|
||||
"\\b{name}\\.py\\b",
|
||||
"\\b{name}-core\\b",
|
||||
"\\b{name}-local\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"yang",
|
||||
"dan",
|
||||
"atau",
|
||||
"tetapi",
|
||||
"namun",
|
||||
"karena",
|
||||
"jadi",
|
||||
"kalau",
|
||||
"jika",
|
||||
"ketika",
|
||||
"saat",
|
||||
"supaya",
|
||||
"agar",
|
||||
"untuk",
|
||||
"dari",
|
||||
"ke",
|
||||
"di",
|
||||
"pada",
|
||||
"dalam",
|
||||
"dengan",
|
||||
"tanpa",
|
||||
"antara",
|
||||
"tentang",
|
||||
"sebagai",
|
||||
"oleh",
|
||||
"terhadap",
|
||||
"ini",
|
||||
"itu",
|
||||
"tersebut",
|
||||
"sini",
|
||||
"situ",
|
||||
"sana",
|
||||
"saya",
|
||||
"aku",
|
||||
"kami",
|
||||
"kita",
|
||||
"kamu",
|
||||
"anda",
|
||||
"dia",
|
||||
"ia",
|
||||
"beliau",
|
||||
"mereka",
|
||||
"ada",
|
||||
"tidak",
|
||||
"bukan",
|
||||
"iya",
|
||||
"ya",
|
||||
"oke",
|
||||
"ok",
|
||||
"baik",
|
||||
"nah",
|
||||
"nih",
|
||||
"dong",
|
||||
"deh",
|
||||
"kok",
|
||||
"sih",
|
||||
"aja",
|
||||
"juga",
|
||||
"lagi",
|
||||
"sudah",
|
||||
"udah",
|
||||
"belum",
|
||||
"masih",
|
||||
"baru",
|
||||
"pernah",
|
||||
"selalu",
|
||||
"sering",
|
||||
"jarang",
|
||||
"banyak",
|
||||
"sedikit",
|
||||
"lebih",
|
||||
"kurang",
|
||||
"semua",
|
||||
"setiap",
|
||||
"beberapa",
|
||||
"sesuatu",
|
||||
"apa",
|
||||
"siapa",
|
||||
"mana",
|
||||
"kapan",
|
||||
"mengapa",
|
||||
"kenapa",
|
||||
"bagaimana",
|
||||
"bisa",
|
||||
"harus",
|
||||
"mau",
|
||||
"ingin",
|
||||
"tahu",
|
||||
"coba",
|
||||
"pak",
|
||||
"bu",
|
||||
"mas",
|
||||
"mbak",
|
||||
"kak"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"lang": "it",
|
||||
"label": "Italiano",
|
||||
"terms": {
|
||||
"palace": "palazzo",
|
||||
"wing": "ala",
|
||||
"hall": "corridoio",
|
||||
"closet": "armadio",
|
||||
"drawer": "cassetto",
|
||||
"mine": "estrarre",
|
||||
"search": "cercare",
|
||||
"status": "stato",
|
||||
"init": "inizializzare",
|
||||
"repair": "riparare",
|
||||
"migrate": "migrare",
|
||||
"entity": "entità",
|
||||
"topic": "argomento"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Estrazione di {path}...",
|
||||
"mine_complete": "Fatto. {closets} armadi, {drawers} cassetti creati.",
|
||||
"mine_skip": "Già estratto. Usa --force per estrarre di nuovo.",
|
||||
"search_no_results": "Nessun risultato per: {query}",
|
||||
"search_results": "{count} risultati trovati:",
|
||||
"status_palace": "Palazzo: {path}",
|
||||
"status_wings": "{count} ali",
|
||||
"status_closets": "{count} armadi",
|
||||
"status_drawers": "{count} cassetti",
|
||||
"init_complete": "Palazzo inizializzato in {path}",
|
||||
"init_exists": "Esiste già un palazzo in {path}",
|
||||
"repair_complete": "Riparazione completata. {fixed} problemi risolti.",
|
||||
"migrate_complete": "Migrazione completata.",
|
||||
"no_palace": "Nessun palazzo trovato. Esegui: mempalace init <cartella>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Comprimi in italiano. Trattini tra le parole, pipe tra i concetti. Elimina articoli e parole di riempimento. Mantieni nomi propri e numeri esatti."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[A-ZÀ-Ú][a-zà-ú]{2,}|[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ0-9_]{2,}",
|
||||
"stop_words": "il lo la i gli le un uno una di del della dello dei degli delle al allo alla ai agli alle in con su per tra fra da dal dalla dallo dai dagli dalle e o ma che chi cui come dove quando perché mentre anche ancora già molto poco solo sempre mai essere avere sono sei siamo siete era erano stato stata questo questa questi queste quello quella quelli quelle mio mia miei mie tuo tua tuoi tue suo sua suoi sue nostro nostra nostri nostre vostro vostra vostri vostre loro",
|
||||
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:costruito|corretto|scritto|aggiunto|inviato|misurato|testato|revisionato|creato|eliminato|aggiornato|configurato|distribuito|migrato)\\s+[\\wÀ-ÿ\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-ZÀ-Ú][a-zà-ÿ]{1,19}",
|
||||
"multi_word_pattern": "[A-ZÀ-Ú][a-zà-ÿ]+(?:\\s+[A-ZÀ-Ú][a-zà-ÿ]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+ha\\s+detto\\b",
|
||||
"\\b{name}\\s+ha\\s+chiesto\\b",
|
||||
"\\b{name}\\s+ha\\s+risposto\\b",
|
||||
"\\b{name}\\s+ha\\s+riferito\\b",
|
||||
"\\b{name}\\s+ha\\s+riso\\b",
|
||||
"\\b{name}\\s+ha\\s+sorriso\\b",
|
||||
"\\b{name}\\s+ha\\s+pianto\\b",
|
||||
"\\b{name}\\s+ha\\s+sentito\\b",
|
||||
"\\b{name}\\s+pensa\\b",
|
||||
"\\b{name}\\s+vuole\\b",
|
||||
"\\b{name}\\s+ama\\b",
|
||||
"\\b{name}\\s+odia\\b",
|
||||
"\\b{name}\\s+sa\\b",
|
||||
"\\b{name}\\s+ha\\s+deciso\\b",
|
||||
"\\b{name}\\s+ha\\s+scritto\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\blei\\b",
|
||||
"\\blui\\b",
|
||||
"\\bloro\\b",
|
||||
"\\bgli\\b",
|
||||
"\\ble\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+ha\\s+detto"
|
||||
],
|
||||
"direct_address_pattern": "\\bciao\\s+{name}\\b|\\bgrazie\\s+{name}\\b|\\bsalve\\s+{name}\\b|\\bcaro\\s+{name}\\b|\\bcara\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bstiamo\\s+costruendo\\s+{name}\\b",
|
||||
"\\babbiamo\\s+costruito\\s+{name}\\b",
|
||||
"\\bstiamo\\s+lanciando\\s+{name}\\b",
|
||||
"\\babbiamo\\s+lanciato\\s+{name}\\b",
|
||||
"\\babbiamo\\s+distribuito\\s+{name}\\b",
|
||||
"\\babbiamo\\s+installato\\s+{name}\\b",
|
||||
"\\bil\\s+progetto\\s+{name}\\b",
|
||||
"\\bil\\s+sistema\\s+{name}\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"ciao",
|
||||
"salve",
|
||||
"grazie",
|
||||
"prego",
|
||||
"sì",
|
||||
"si",
|
||||
"no",
|
||||
"forse",
|
||||
"qui",
|
||||
"qua",
|
||||
"lì",
|
||||
"oggi",
|
||||
"ieri",
|
||||
"domani",
|
||||
"sempre",
|
||||
"mai",
|
||||
"ancora",
|
||||
"anche",
|
||||
"molto",
|
||||
"poco",
|
||||
"bene",
|
||||
"male",
|
||||
"così",
|
||||
"poi",
|
||||
"prima",
|
||||
"dopo",
|
||||
"tra",
|
||||
"fra",
|
||||
"con",
|
||||
"senza",
|
||||
"per",
|
||||
"verso",
|
||||
"contro",
|
||||
"durante",
|
||||
"mentre",
|
||||
"sopra",
|
||||
"sotto",
|
||||
"oltre",
|
||||
"oppure",
|
||||
"ma",
|
||||
"però",
|
||||
"tuttavia",
|
||||
"anche",
|
||||
"se",
|
||||
"quando",
|
||||
"finché",
|
||||
"perché",
|
||||
"quindi",
|
||||
"dunque",
|
||||
"allora",
|
||||
"forse",
|
||||
"magari",
|
||||
"abbiamo",
|
||||
"stiamo",
|
||||
"essere",
|
||||
"avere",
|
||||
"sono",
|
||||
"sei",
|
||||
"siamo",
|
||||
"siete",
|
||||
"era",
|
||||
"erano",
|
||||
"stato",
|
||||
"stata",
|
||||
"questo",
|
||||
"questa",
|
||||
"questi",
|
||||
"queste",
|
||||
"quello",
|
||||
"quella",
|
||||
"quelli",
|
||||
"quelle",
|
||||
"mio",
|
||||
"mia",
|
||||
"miei",
|
||||
"mie",
|
||||
"tuo",
|
||||
"tua",
|
||||
"tuoi",
|
||||
"tue",
|
||||
"suo",
|
||||
"sua",
|
||||
"suoi",
|
||||
"sue",
|
||||
"nostro",
|
||||
"nostra",
|
||||
"nostri",
|
||||
"nostre",
|
||||
"vostro",
|
||||
"vostra",
|
||||
"vostri",
|
||||
"vostre",
|
||||
"loro"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"lang": "ja",
|
||||
"label": "日本語",
|
||||
"terms": {
|
||||
"palace": "宮殿",
|
||||
"wing": "棟",
|
||||
"hall": "廊下",
|
||||
"closet": "クローゼット",
|
||||
"drawer": "引き出し",
|
||||
"mine": "採掘",
|
||||
"search": "検索",
|
||||
"status": "状態",
|
||||
"init": "初期化",
|
||||
"repair": "修復",
|
||||
"migrate": "移行",
|
||||
"entity": "エンティティ",
|
||||
"topic": "トピック"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "{path} を採掘中...",
|
||||
"mine_complete": "完了。クローゼット {closets}個、引き出し {drawers}個 作成。",
|
||||
"mine_skip": "採掘済み。再実行するには --force を使用。",
|
||||
"search_no_results": "結果なし: {query}",
|
||||
"search_results": "{count}件の結果:",
|
||||
"status_palace": "宮殿: {path}",
|
||||
"status_wings": "棟 {count}個",
|
||||
"status_closets": "クローゼット {count}個",
|
||||
"status_drawers": "引き出し {count}個",
|
||||
"init_complete": "{path} に宮殿を初期化しました",
|
||||
"init_exists": "{path} に宮殿は既に存在します",
|
||||
"repair_complete": "修復完了。{fixed}件の問題を修正。",
|
||||
"migrate_complete": "移行完了。",
|
||||
"no_palace": "宮殿が見つかりません。実行: mempalace init <ディレクトリ>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "日本語で圧縮してください。概念間はパイプ(|)、単語間はハイフン(-)。助詞と接続詞は省略。固有名詞と数値は正確に保持。"
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[\\u30A0-\\u30FF]{3,}|[\\u4E00-\\u9FFF]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
|
||||
"stop_words": "は が を に で と も の へ から まで より した します している されて です ます ました こと もの ため それ これ その この あの ない なく ある いる する",
|
||||
"quote_pattern": "「([^」]{10,100})」",
|
||||
"action_pattern": "(構築|修正|追加|削除|確認|作成|実装|修復|書き直し|テスト|検証|更新|設定|起動|停止)(?:し|した|して|する|します)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"lang": "ko",
|
||||
"label": "한국어",
|
||||
"terms": {
|
||||
"palace": "궁전",
|
||||
"wing": "날개",
|
||||
"hall": "복도",
|
||||
"closet": "벽장",
|
||||
"drawer": "서랍",
|
||||
"mine": "채굴",
|
||||
"search": "검색",
|
||||
"status": "상태",
|
||||
"init": "초기화",
|
||||
"repair": "수리",
|
||||
"migrate": "마이그레이션",
|
||||
"entity": "개체",
|
||||
"topic": "주제"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "{path} 채굴 중...",
|
||||
"mine_complete": "완료. 벽장 {closets}개, 서랍 {drawers}개 생성.",
|
||||
"mine_skip": "이미 채굴됨. --force로 다시 실행하세요.",
|
||||
"search_no_results": "결과 없음: {query}",
|
||||
"search_results": "{count}개 결과 발견:",
|
||||
"status_palace": "궁전: {path}",
|
||||
"status_wings": "날개 {count}개",
|
||||
"status_closets": "벽장 {count}개",
|
||||
"status_drawers": "서랍 {count}개",
|
||||
"init_complete": "{path}에 궁전 초기화 완료",
|
||||
"init_exists": "{path}에 궁전이 이미 존재합니다",
|
||||
"repair_complete": "수리 완료. {fixed}개 문제 해결.",
|
||||
"migrate_complete": "마이그레이션 완료.",
|
||||
"no_palace": "궁전을 찾을 수 없습니다. 실행: mempalace init <폴더>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "한국어로 압축하세요. 개념 사이에 파이프(|), 단어 연결에 하이픈(-). 조사와 접속사는 생략. 고유명사와 숫자는 정확히 유지."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[가-힣]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
|
||||
"stop_words": "은 는 이 가 을 를 에 에서 의 로 으로 와 과 도 만 까지 부터 처럼 보다 한 하는 했다 합니다 했습니다 되었 있는 것 수 등 및 또는 그리고 하지만 때문에",
|
||||
"quote_pattern": "\"([^\"]{10,100})\"|'([^']{10,100})'",
|
||||
"action_pattern": "(구축|수정|추가|삭제|확인|생성|구현|수리|작성|테스트|검증|업데이트|설정|시작|중지)(?:했|한|하여|합니다|했습니다)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"lang": "pt-br",
|
||||
"label": "Português (Brasil)",
|
||||
"terms": {
|
||||
"palace": "palácio",
|
||||
"wing": "ala",
|
||||
"hall": "corredor",
|
||||
"closet": "armário",
|
||||
"drawer": "gaveta",
|
||||
"mine": "minerar",
|
||||
"search": "buscar",
|
||||
"status": "status",
|
||||
"init": "inicializar",
|
||||
"repair": "reparar",
|
||||
"migrate": "migrar",
|
||||
"entity": "entidade",
|
||||
"topic": "tópico"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Minerando {path}...",
|
||||
"mine_complete": "Pronto. {closets} armários, {drawers} gavetas criados.",
|
||||
"mine_skip": "Já minerado. Use --force para refazer.",
|
||||
"search_no_results": "Sem resultados para: {query}",
|
||||
"search_results": "{count} resultados encontrados:",
|
||||
"status_palace": "Palácio: {path}",
|
||||
"status_wings": "{count} alas",
|
||||
"status_closets": "{count} armários",
|
||||
"status_drawers": "{count} gavetas",
|
||||
"init_complete": "Palácio inicializado em {path}",
|
||||
"init_exists": "Já existe um palácio em {path}",
|
||||
"repair_complete": "Reparo completo. {fixed} problemas corrigidos.",
|
||||
"migrate_complete": "Migração completa.",
|
||||
"no_palace": "Nenhum palácio encontrado. Execute: mempalace init <diretório>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Comprima em português. Hifens entre palavras, pipes entre conceitos. Remova artigos e palavras de preenchimento. Mantenha nomes próprios e números exatos."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[A-ZÀ-Ú][a-zà-ÿ]{2,}|[A-Za-zÀ-ÿ]{3,}",
|
||||
"stop_words": "o a os as um uma uns umas de do da dos das em no na nos nas por para com sem sobre entre ao aos seu sua seus suas meu minha meus minhas tu teu tua que quem qual onde quando porque embora mas porém também muito mais como este esta estes estas esse essa esses essas aquele aquela é são está estão foi ser estar ter sido",
|
||||
"quote_pattern": "\"([^\"]{10,200})\"|«([^»]{10,200})»",
|
||||
"action_pattern": "(?:construído|corrigido|escrito|adicionado|enviado|medido|testado|revisado|criado|excluído|atualizado|configurado|implantado|migrado)\\s+[\\wà-ÿ\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-ZÀ-Ú][a-zà-ÿ]{1,19}",
|
||||
"multi_word_pattern": "[A-ZÀ-Ú][a-zà-ÿ]+(?:\\s+[A-ZÀ-Ú][a-zà-ÿ]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+disse\\b",
|
||||
"\\b{name}\\s+perguntou\\b",
|
||||
"\\b{name}\\s+respondeu\\b",
|
||||
"\\b{name}\\s+contou\\b",
|
||||
"\\b{name}\\s+riu\\b",
|
||||
"\\b{name}\\s+sorriu\\b",
|
||||
"\\b{name}\\s+chorou\\b",
|
||||
"\\b{name}\\s+sentiu\\b",
|
||||
"\\b{name}\\s+pensa\\b",
|
||||
"\\b{name}\\s+quer\\b",
|
||||
"\\b{name}\\s+ama\\b",
|
||||
"\\b{name}\\s+odeia\\b",
|
||||
"\\b{name}\\s+sabe\\b",
|
||||
"\\b{name}\\s+decidiu\\b",
|
||||
"\\b{name}\\s+escreveu\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bela\\b",
|
||||
"\\bdela\\b",
|
||||
"\\bele\\b",
|
||||
"\\bdele\\b",
|
||||
"\\beles\\b",
|
||||
"\\belas\\b",
|
||||
"\\bdeles\\b",
|
||||
"\\bdelas\\b",
|
||||
"\\bvocê\\b",
|
||||
"\\bvocês\\b",
|
||||
"\\bseu\\b",
|
||||
"\\bsua\\b",
|
||||
"\\bseus\\b",
|
||||
"\\bsuas\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+disse"
|
||||
],
|
||||
"direct_address_pattern": "\\boi\\s+{name}\\b|\\bol[áa]\\s+{name}\\b|\\bobrigad[oa]\\s+{name}\\b|\\bcaro\\s+{name}\\b|\\bcara\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bconstruindo\\s+{name}\\b",
|
||||
"\\bconstruiu\\s+{name}\\b",
|
||||
"\\blançando\\s+{name}\\b",
|
||||
"\\blançou\\s+{name}\\b",
|
||||
"\\bimplantando\\s+{name}\\b",
|
||||
"\\bimplantou\\s+{name}\\b",
|
||||
"\\binstalando\\s+{name}\\b",
|
||||
"\\binstalou\\s+{name}\\b",
|
||||
"\\bo\\s+sistema\\s+{name}\\b",
|
||||
"\\bo\\s+projeto\\s+{name}\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"oi",
|
||||
"ola",
|
||||
"olá",
|
||||
"obrigado",
|
||||
"obrigada",
|
||||
"sim",
|
||||
"não",
|
||||
"talvez",
|
||||
"aqui",
|
||||
"ali",
|
||||
"lá",
|
||||
"agora",
|
||||
"hoje",
|
||||
"ontem",
|
||||
"amanhã",
|
||||
"sempre",
|
||||
"nunca",
|
||||
"ainda",
|
||||
"também",
|
||||
"muito",
|
||||
"pouco",
|
||||
"bem",
|
||||
"mal",
|
||||
"assim",
|
||||
"então",
|
||||
"depois",
|
||||
"antes",
|
||||
"durante",
|
||||
"sobre",
|
||||
"entre",
|
||||
"para",
|
||||
"como",
|
||||
"mas",
|
||||
"porém",
|
||||
"contudo",
|
||||
"embora",
|
||||
"enquanto",
|
||||
"porque",
|
||||
"portanto",
|
||||
"logo",
|
||||
"todavia",
|
||||
"desde",
|
||||
"contra",
|
||||
"perante",
|
||||
"após",
|
||||
"mediante",
|
||||
"conforme",
|
||||
"segundo",
|
||||
"exceto",
|
||||
"pois",
|
||||
"apenas",
|
||||
"mais",
|
||||
"menos",
|
||||
"cada",
|
||||
"todo",
|
||||
"toda",
|
||||
"todos",
|
||||
"todas",
|
||||
"tudo",
|
||||
"nada",
|
||||
"algo",
|
||||
"onde",
|
||||
"quando",
|
||||
"qual",
|
||||
"quem",
|
||||
"isso",
|
||||
"isto",
|
||||
"ser",
|
||||
"ter"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"lang": "ru",
|
||||
"label": "Русский",
|
||||
"terms": {
|
||||
"palace": "дворец",
|
||||
"wing": "крыло",
|
||||
"hall": "зал",
|
||||
"closet": "шкаф",
|
||||
"drawer": "ящик",
|
||||
"mine": "раскопка",
|
||||
"search": "поиск",
|
||||
"status": "статус",
|
||||
"init": "создание",
|
||||
"repair": "починка",
|
||||
"migrate": "миграция",
|
||||
"entity": "сущность",
|
||||
"topic": "тема"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Раскопка {path}...",
|
||||
"mine_complete": "Готово. Шкафов: {closets}, ящиков: {drawers}.",
|
||||
"mine_skip": "Уже обработано. Используйте --force для повторной обработки.",
|
||||
"search_no_results": "Нет результатов по запросу: {query}",
|
||||
"search_results": "Найдено результатов: {count}",
|
||||
"status_palace": "Дворец: {path}",
|
||||
"status_wings": "Крыльев: {count}",
|
||||
"status_closets": "Шкафов: {count}",
|
||||
"status_drawers": "Ящиков: {count}",
|
||||
"init_complete": "Дворец создан в {path}",
|
||||
"init_exists": "Дворец уже существует в {path}",
|
||||
"repair_complete": "Починка завершена. Исправлено проблем: {fixed}.",
|
||||
"migrate_complete": "Миграция завершена.",
|
||||
"no_palace": "Дворец не найден. Выполните: mempalace init <директория>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Сжать до индексного формата. Дефисы между словами, вертикальные черты между понятиями. Убрать предлоги и служебные слова. Имена и числа сохранять точно."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[А-ЯЁ][а-яё]{2,}|[A-Z][a-z]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
|
||||
"stop_words": "это этот эта эти тот та те тех некоторые много каждый другой только такой очень будет может должен надо хорошо также даже потом сейчас уже ещё обратно сделано делать брать давать знать думать хотеть нужно если когда просто правда ладно вообще конечно например значит кстати наверное видимо похоже получается собственно кажется",
|
||||
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:построил|исправил|написал|добавил|запустил|протестировал|проверил|создал|удалил|обновил|настроил|развернул|перенёс|собрал)\\s+[\\wа-яёА-ЯЁ\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[А-ЯЁ][а-яё]{1,19}",
|
||||
"multi_word_pattern": "[А-ЯЁ][а-яё]+(?:\\s+[А-ЯЁ][а-яё]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+сказал[аи]?\\b",
|
||||
"\\b{name}\\s+спросил[аи]?\\b",
|
||||
"\\b{name}\\s+ответил[аи]?\\b",
|
||||
"\\b{name}\\s+рассказал[аи]?\\b",
|
||||
"\\b{name}\\s+засмеял(ся|ась|ись)\\b",
|
||||
"\\b{name}\\s+улыбнул(ся|ась|ись)\\b",
|
||||
"\\b{name}\\s+заплакал[аи]?\\b",
|
||||
"\\b{name}\\s+почувствовал[аи]?\\b",
|
||||
"\\b{name}\\s+думает\\b",
|
||||
"\\b{name}\\s+хочет\\b",
|
||||
"\\b{name}\\s+любит\\b",
|
||||
"\\b{name}\\s+ненавидит\\b",
|
||||
"\\b{name}\\s+знает\\b",
|
||||
"\\b{name}\\s+решил[аи]?\\b",
|
||||
"\\b{name}\\s+написал[аи]?\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bона\\b",
|
||||
"\\bеё\\b",
|
||||
"\\bей\\b",
|
||||
"\\bон\\b",
|
||||
"\\bего\\b",
|
||||
"\\bему\\b",
|
||||
"\\bони\\b",
|
||||
"\\bих\\b",
|
||||
"\\bим\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+сказал"
|
||||
],
|
||||
"direct_address_pattern": "\\bпривет\\s+{name}\\b|\\bспасибо\\s+{name}\\b|\\bздравствуй(те)?\\s+{name}\\b|\\bуважаемый\\s+{name}\\b|\\bуважаемая\\s+{name}\\b|\\bдорогой\\s+{name}\\b|\\bдорогая\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bсобираю\\s+{name}\\b",
|
||||
"\\bсобрал\\s+{name}\\b",
|
||||
"\\bзапускаю\\s+{name}\\b",
|
||||
"\\bзапустил\\s+{name}\\b",
|
||||
"\\bразвернул\\s+{name}\\b",
|
||||
"\\bустановил\\s+{name}\\b",
|
||||
"\\bсистема\\s+{name}\\b",
|
||||
"\\bпроект\\s+{name}\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"привет",
|
||||
"здравствуйте",
|
||||
"спасибо",
|
||||
"пожалуйста",
|
||||
"да",
|
||||
"нет",
|
||||
"может",
|
||||
"наверное",
|
||||
"здесь",
|
||||
"там",
|
||||
"тут",
|
||||
"сейчас",
|
||||
"сегодня",
|
||||
"вчера",
|
||||
"завтра",
|
||||
"всегда",
|
||||
"никогда",
|
||||
"ещё",
|
||||
"тоже",
|
||||
"очень",
|
||||
"мало",
|
||||
"хорошо",
|
||||
"плохо",
|
||||
"так",
|
||||
"потом",
|
||||
"перед",
|
||||
"после",
|
||||
"между",
|
||||
"около",
|
||||
"вместе",
|
||||
"без",
|
||||
"для",
|
||||
"над",
|
||||
"под",
|
||||
"при",
|
||||
"про",
|
||||
"через",
|
||||
"против",
|
||||
"вместо",
|
||||
"кроме",
|
||||
"среди",
|
||||
"вокруг",
|
||||
"вдоль",
|
||||
"ради",
|
||||
"напротив",
|
||||
"благодаря",
|
||||
"согласно",
|
||||
"навстречу",
|
||||
"или",
|
||||
"либо",
|
||||
"но",
|
||||
"однако",
|
||||
"зато",
|
||||
"хотя",
|
||||
"если",
|
||||
"когда",
|
||||
"пока",
|
||||
"чтобы",
|
||||
"потому",
|
||||
"поэтому",
|
||||
"причём",
|
||||
"притом",
|
||||
"будто",
|
||||
"словно"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"lang": "zh-CN",
|
||||
"label": "简体中文",
|
||||
"terms": {
|
||||
"palace": "宫殿",
|
||||
"wing": "翼",
|
||||
"hall": "走廊",
|
||||
"closet": "柜子",
|
||||
"drawer": "抽屉",
|
||||
"mine": "挖掘",
|
||||
"search": "搜索",
|
||||
"status": "状态",
|
||||
"init": "初始化",
|
||||
"repair": "修复",
|
||||
"migrate": "迁移",
|
||||
"entity": "实体",
|
||||
"topic": "主题"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "正在挖掘 {path}...",
|
||||
"mine_complete": "完成。创建了 {closets} 个柜子、{drawers} 个抽屉。",
|
||||
"mine_skip": "已挖掘。使用 --force 重新执行。",
|
||||
"search_no_results": "未找到结果: {query}",
|
||||
"search_results": "找到 {count} 个结果:",
|
||||
"status_palace": "宫殿: {path}",
|
||||
"status_wings": "{count} 个翼",
|
||||
"status_closets": "{count} 个柜子",
|
||||
"status_drawers": "{count} 个抽屉",
|
||||
"init_complete": "宫殿已初始化于 {path}",
|
||||
"init_exists": "{path} 中已存在宫殿",
|
||||
"repair_complete": "修复完成。已修正 {fixed} 个问题。",
|
||||
"migrate_complete": "迁移完成。",
|
||||
"no_palace": "未找到宫殿。请运行: mempalace init <目录>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "用中文压缩。概念之间用管道符(|),词语之间用连字符(-)。省略虚词和连接词。保留专有名词和数字的准确性。"
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[\\u4E00-\\u9FFF]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
|
||||
"stop_words": "的 了 在 是 我 有 和 就 不 人 都 一 一个 上 也 很 到 说 要 去 你 会 着 没有 看 好 自己 这 那 她 他 它 们 但是 因为 所以 如果 虽然 然后 或者 而且",
|
||||
"quote_pattern": "\\u201C([^\\u201D]{10,100})\\u201D|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(构建|修复|添加|删除|确认|创建|实现|修理|编写|测试|验证|更新|配置|启动|停止)(?:了|完成|成功)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"lang": "zh-TW",
|
||||
"label": "繁體中文",
|
||||
"terms": {
|
||||
"palace": "宮殿",
|
||||
"wing": "翼",
|
||||
"hall": "走廊",
|
||||
"closet": "櫃子",
|
||||
"drawer": "抽屜",
|
||||
"mine": "挖掘",
|
||||
"search": "搜尋",
|
||||
"status": "狀態",
|
||||
"init": "初始化",
|
||||
"repair": "修復",
|
||||
"migrate": "遷移",
|
||||
"entity": "實體",
|
||||
"topic": "主題"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "正在挖掘 {path}...",
|
||||
"mine_complete": "完成。建立了 {closets} 個櫃子、{drawers} 個抽屜。",
|
||||
"mine_skip": "已挖掘。使用 --force 重新執行。",
|
||||
"search_no_results": "未找到結果: {query}",
|
||||
"search_results": "找到 {count} 個結果:",
|
||||
"status_palace": "宮殿: {path}",
|
||||
"status_wings": "{count} 個翼",
|
||||
"status_closets": "{count} 個櫃子",
|
||||
"status_drawers": "{count} 個抽屜",
|
||||
"init_complete": "宮殿已初始化於 {path}",
|
||||
"init_exists": "{path} 中已存在宮殿",
|
||||
"repair_complete": "修復完成。已修正 {fixed} 個問題。",
|
||||
"migrate_complete": "遷移完成。",
|
||||
"no_palace": "未找到宮殿。請執行: mempalace init <目錄>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "用中文壓縮。概念之間用管道符(|),詞語之間用連字符(-)。省略虛詞和連接詞。保留專有名詞和數字的準確性。"
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[\\u4E00-\\u9FFF]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
|
||||
"stop_words": "的 了 在 是 我 有 和 就 不 人 都 一 一個 上 也 很 到 說 要 去 你 會 著 沒有 看 好 自己 這 那 她 他 它 們 但是 因為 所以 如果 雖然 然後 或者 而且",
|
||||
"quote_pattern": "「([^」]{10,100})」|\u201c([^\u201d]{10,100})\u201d",
|
||||
"action_pattern": "(構建|修復|添加|刪除|確認|創建|實現|修理|編寫|測試|驗證|更新|配置|啟動|停止)(?:了|完成|成功)"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user