Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a3afc9d64 | ||
|
|
cb03ee61cb | ||
|
|
cb7ba7f573 | ||
|
|
fd81cc55b3 | ||
|
|
1004572d53 | ||
|
|
7ba6c3a709 | ||
|
|
1c6fad777d | ||
|
|
cedbba0621 | ||
|
|
df857ccbeb | ||
|
|
b6eae9b0e5 | ||
|
|
77b00d0ede | ||
|
|
5253819f69 | ||
|
|
7ebde7d1a5 | ||
|
|
cb9356e549 | ||
|
|
eb8090eb84 | ||
|
|
94a41ee217 | ||
|
|
97be77c0e9 | ||
|
|
5036e3c05e | ||
|
|
2174eb047c | ||
|
|
27212e5c62 | ||
|
|
7eaa3bc3de | ||
|
|
f5a766a0de | ||
|
|
be27e7852e | ||
|
|
b8c92f852f | ||
|
|
174dc31647 | ||
|
|
aeac794c1a | ||
|
|
4416dbdff0 | ||
|
|
96715514b1 | ||
|
|
bd3ea6bde4 | ||
|
|
ed79ccfa0c | ||
|
|
f142458a31 | ||
|
|
2a2466c656 | ||
|
|
9bc8cdddc6 | ||
|
|
d9a24c7000 | ||
|
|
f272c84514 | ||
|
|
d471a9e262 | ||
|
|
db9c917078 | ||
|
|
3161cae8a6 | ||
|
|
b4345e84a7 | ||
|
|
c38cbf726f | ||
|
|
ffb5559823 | ||
|
|
05ae73f54f | ||
|
|
cd1c27247a | ||
|
|
a09ba417cd | ||
|
|
3db1c5bf1b | ||
|
|
3f52734f85 | ||
|
|
5fa892eae0 | ||
|
|
441f4bbd39 | ||
|
|
fb0312f6c0 | ||
|
|
f44a95d545 | ||
|
|
becc633654 | ||
|
|
b676b2bd2f | ||
|
|
ba163b1de3 | ||
|
|
466a1f8de3 | ||
|
|
f414bb881f | ||
|
|
92140ca75c | ||
|
|
854276c159 | ||
|
|
8cf2be7e54 | ||
|
|
618764e1a6 | ||
|
|
351e62f736 | ||
|
|
2d4e108593 | ||
|
|
fec10df10a | ||
|
|
a6b2a734cd | ||
|
|
8516db7fbc | ||
|
|
c88997b8d7 | ||
|
|
a85c1fd786 | ||
|
|
0e60e51824 | ||
|
|
89b2fa6410 | ||
|
|
8c2856266f | ||
|
|
5aa0e11810 | ||
|
|
0ae26bda8d | ||
|
|
accb4e3245 | ||
|
|
c7a026bbd0 | ||
|
|
96bd4b4160 | ||
|
|
656e0a4dee | ||
|
|
0bb2d120a7 | ||
|
|
26a110172e | ||
|
|
01a4a26414 | ||
|
|
0ff93caf97 | ||
|
|
0b5c5609d5 | ||
|
|
d4b439ba31 | ||
|
|
6fb5a5844a | ||
|
|
0943bf23b3 | ||
|
|
547f5658fc | ||
|
|
d64ad8de8d | ||
|
|
753ecd6855 | ||
|
|
011e63e5de | ||
|
|
e73e75b0d2 | ||
|
|
55d6c97512 | ||
|
|
d1d904f44b | ||
|
|
8bc41e003c | ||
|
|
aa3eca5c1f | ||
|
|
759b1273d3 | ||
|
|
e0715032d3 | ||
|
|
87f21542ab | ||
|
|
b757aa7475 | ||
|
|
b62c5f9cdd | ||
|
|
3991bef49f | ||
|
|
2f72c91fd6 | ||
|
|
141e16fb2b | ||
|
|
495a12bff5 | ||
|
|
6093340754 | ||
|
|
5bc539d4a7 | ||
|
|
121a4552ac | ||
|
|
123d01a16e | ||
|
|
f78fe31c92 | ||
|
|
8a019919f4 | ||
|
|
2b56ea2757 | ||
|
|
0e79797025 | ||
|
|
3d9c1560a7 | ||
|
|
dd19c19b4c | ||
|
|
83281b08c9 | ||
|
|
db8e588121 | ||
|
|
b545eb30af | ||
|
|
e12a8dacbf | ||
|
|
bc0c59171e | ||
|
|
00050b3589 | ||
|
|
32b96cd7f0 | ||
|
|
a11f2671fe | ||
|
|
c311f4b14e | ||
|
|
fc17b15278 | ||
|
|
a3d8826ac7 | ||
|
|
9eb8c22c40 | ||
|
|
7a61b65c7e | ||
|
|
5e6580d0c2 | ||
|
|
a06dbabdd4 | ||
|
|
5381035513 | ||
|
|
0b226029bc | ||
|
|
f7c174e5d4 | ||
|
|
d5d62253ae | ||
|
|
dd97116417 | ||
|
|
d973e4405c | ||
|
|
5ab0a517b2 | ||
|
|
3599ba7f4f | ||
|
|
cc62e770b1 | ||
|
|
5325e0b9a3 | ||
|
|
a50677d0e7 | ||
|
|
a9f19a2f7b | ||
|
|
77c8998174 | ||
|
|
62df5fee0e | ||
|
|
4ab27169c6 | ||
|
|
3bed756b6a | ||
|
|
3eee0d7e49 | ||
|
|
c16332f68f | ||
|
|
67a5fbe795 | ||
|
|
e64aa92f0d | ||
|
|
449d6d86e5 | ||
|
|
b6fad9ed3a | ||
|
|
2f000ad4b4 | ||
|
|
d80a1f43d1 | ||
|
|
1d113e46a2 | ||
|
|
047d7333df | ||
|
|
67dcaf3164 | ||
|
|
44977537e5 | ||
|
|
a3350ec567 | ||
|
|
5f425ba417 | ||
|
|
c56f8b30bb | ||
|
|
511ead9a65 | ||
|
|
3c46c8dc37 | ||
|
|
5e1437e96d | ||
|
|
efa1820289 | ||
|
|
a4fc26eed3 | ||
|
|
69dc7fbb78 | ||
|
|
a5bf4aa9ff | ||
|
|
98647f2e71 | ||
|
|
d1ff13856a | ||
|
|
d52d5120de | ||
|
|
bbcb86df75 | ||
|
|
bd94573d04 | ||
|
|
5cebe6608a | ||
|
|
a337cafeaf | ||
|
|
ee03d65a52 | ||
|
|
df273c010a | ||
|
|
e1fc71a9b9 | ||
|
|
f2e9fef357 | ||
|
|
6ec20305a4 | ||
|
|
4009e75b5d | ||
|
|
217de20d04 | ||
|
|
a929e17499 | ||
|
|
aa47be1c5e | ||
|
|
2accb56492 | ||
|
|
c57b1cdf9d | ||
|
|
31bc2f8ae6 | ||
|
|
af0c7665e0 | ||
|
|
25e562afe2 | ||
|
|
cf2ebce7ef | ||
|
|
07148142ae | ||
|
|
55d23f10b7 | ||
|
|
3c80445eb2 | ||
|
|
cbb6e7f9e7 | ||
|
|
9b0a88e136 | ||
|
|
524c980a17 | ||
|
|
8a112a88ef | ||
|
|
7b495035d3 | ||
|
|
72bbb0ab50 | ||
|
|
b95d14fb3f | ||
|
|
52d5128d09 | ||
|
|
c6e87831bb | ||
|
|
9de876d849 | ||
|
|
bd439ae54f | ||
|
|
867151b211 | ||
|
|
845f59419e | ||
|
|
13196cd056 | ||
|
|
8b5e372951 | ||
|
|
0eb137e419 | ||
|
|
a7c3827368 | ||
|
|
3a97996b30 | ||
|
|
5eddedf691 | ||
|
|
a17dea19d7 | ||
|
|
d4905184fe | ||
|
|
edcd065068 | ||
|
|
8a79aa5122 | ||
|
|
2d8fc9e66d | ||
|
|
b11995d81e | ||
|
|
2df0e12a44 | ||
|
|
934a4419ef | ||
|
|
8d2de4a2a4 | ||
|
|
aa89bd8227 | ||
|
|
f852ef98fa | ||
|
|
b55124e9d8 | ||
|
|
39d151bd28 | ||
|
|
bfd1e486fe | ||
|
|
2b5dc6e8cb | ||
|
|
93bdeeeec0 | ||
|
|
54397a5cc0 | ||
|
|
283dae45e3 | ||
|
|
8ab251c452 | ||
|
|
811d6c9940 | ||
|
|
3791ad17ea | ||
|
|
5cd961eef9 | ||
|
|
67c90c39d5 | ||
|
|
de74cad911 | ||
|
|
c76208507e | ||
|
|
fc3beb71ab | ||
|
|
bb1d502ddc | ||
|
|
2d6580b1f1 | ||
|
|
37bc50d98a | ||
|
|
9553ed9940 | ||
|
|
04fdb78180 | ||
|
|
fa97c93dd7 | ||
|
|
ec8788c2bf | ||
|
|
f62ce2696b | ||
|
|
a613c921c8 | ||
|
|
4bee65c11b | ||
|
|
6340d611cc | ||
|
|
9ecdf83c08 | ||
|
|
dbfeb82e68 | ||
|
|
68edb110a1 | ||
|
|
a824be43c8 | ||
|
|
88e454caed | ||
|
|
4c8ef8ed43 | ||
|
|
287d8f1b4d | ||
|
|
0955100a1f | ||
|
|
8a87fc66e2 | ||
|
|
f68cb8d682 | ||
|
|
acd62442be | ||
|
|
2c6cecf1fe | ||
|
|
7267de2b32 | ||
|
|
32e545eaaa | ||
|
|
28aa2c49b0 | ||
|
|
88e839bce5 | ||
|
|
0b04b79869 | ||
|
|
3da735ec6b | ||
|
|
5fdce3923e | ||
|
|
3c0ecb5b3e | ||
|
|
7f07b0d4fa | ||
|
|
4b98dd796d | ||
|
|
99464572f4 | ||
|
|
5b1c32eee1 | ||
|
|
e360a3a040 | ||
|
|
d7819d4b85 | ||
|
|
c7955d3899 | ||
|
|
18a9788961 | ||
|
|
63188686e5 | ||
|
|
09aa4034a7 | ||
|
|
a20d769b8c | ||
|
|
b0b028170a | ||
|
|
c0ae2abaff | ||
|
|
9e871b45b5 | ||
|
|
9b432699ce | ||
|
|
3f49e15545 | ||
|
|
b70f06a69e | ||
|
|
da32e562f7 | ||
|
|
1d38ae8784 | ||
|
|
07182d5adf | ||
|
|
c00e8dcc16 | ||
|
|
b459293e6f | ||
|
|
9815f0ae23 | ||
|
|
4e20ad3e8c | ||
|
|
65bedc2b33 | ||
|
|
f820b73160 | ||
|
|
aff1ee8255 | ||
|
|
39bec19956 | ||
|
|
f9db9b726f | ||
|
|
578e5775b4 | ||
|
|
adc0ef876f | ||
|
|
c54531a2a2 | ||
|
|
0104d86c53 | ||
|
|
db6c3d6552 | ||
|
|
bddbb5f7a5 | ||
|
|
2c81e22e28 | ||
|
|
9b395fe9d5 | ||
|
|
da5a48caf5 | ||
|
|
e096701333 | ||
|
|
e84221c6b7 | ||
|
|
0549a90df3 | ||
|
|
281aaf489d | ||
|
|
176c8b820b | ||
|
|
3b8d2652f4 | ||
|
|
e4924e8e94 | ||
|
|
1577346158 | ||
|
|
6cec591c17 | ||
|
|
afd0428823 | ||
|
|
cea780eb79 | ||
|
|
2211c974ff | ||
|
|
8ec284db5d | ||
|
|
5dcc46ba7e | ||
|
|
178105cbd5 | ||
|
|
833b6ab4d3 | ||
|
|
faa8643d9f | ||
|
|
cff43adb63 | ||
|
|
fd3b4e8ebb | ||
|
|
cd7a8658a2 | ||
|
|
d8b63a9521 | ||
|
|
d6cb868f75 | ||
|
|
b864766b12 | ||
|
|
1bc5c508d5 | ||
|
|
5cbc37ee9b | ||
|
|
bacb935bb3 | ||
|
|
c17d1aaf13 | ||
|
|
292cf872a5 | ||
|
|
b0a94bdef2 | ||
|
|
73e74bfb80 | ||
|
|
dc433824fd | ||
|
|
0f3f8ec652 | ||
|
|
5294d3118e | ||
|
|
aac947a5c5 | ||
|
|
86546348e4 | ||
|
|
d20ac0adf1 | ||
|
|
d479d6f245 | ||
|
|
c4ce4c89d2 | ||
|
|
ca11a455f5 | ||
|
|
4700c44163 | ||
|
|
a426587d35 | ||
|
|
c7428c3b71 | ||
|
|
c80f6537ec | ||
|
|
6ea60c6043 | ||
|
|
d59ca24647 | ||
|
|
e9746df6fd | ||
|
|
2366582fe3 | ||
|
|
2c08bb251d | ||
|
|
65fa1517a1 | ||
|
|
e8f96dd8b2 | ||
|
|
270f1aede9 | ||
|
|
37194cf543 | ||
|
|
58ea91b44d | ||
|
|
1095d684ff | ||
|
|
32fad4844d | ||
|
|
eff887d12f | ||
|
|
ce00cf0718 | ||
|
|
3e3fcf29fe | ||
|
|
569beff342 | ||
|
|
3fbab55cab | ||
|
|
1a47b78dcb | ||
|
|
960eaac851 | ||
|
|
54e9f46027 | ||
|
|
2e8ff64e3f | ||
|
|
94519b91dd | ||
|
|
2f92774eec | ||
|
|
9902e3a9a5 | ||
|
|
a91f7e06c7 | ||
|
|
971e5a75c8 | ||
|
|
7392ab8a25 | ||
|
|
e38af8c2e9 | ||
|
|
70ec8e450f | ||
|
|
a11cf4b667 | ||
|
|
a9344937df | ||
|
|
abd780ee36 | ||
|
|
db90cdf132 | ||
|
|
91e2158213 | ||
|
|
b4a6ffdf9f | ||
|
|
92ac104e31 | ||
|
|
e576c98ed2 | ||
|
|
fff8546672 | ||
|
|
b8c6bfe504 | ||
|
|
7d311af78e | ||
|
|
5ae2315f80 | ||
|
|
352eb67a90 | ||
|
|
f3ed6f796e | ||
|
|
4291fecfdf | ||
|
|
d7e182a272 | ||
|
|
65d07045bd | ||
|
|
fb8dc1f677 | ||
|
|
44016ad118 | ||
|
|
c203aacf8a | ||
|
|
4c4c4aba8d | ||
|
|
7fb7bd3b8d | ||
|
|
8723d30083 | ||
|
|
2ec48dde56 | ||
|
|
6cc5832176 | ||
|
|
386f3c9796 | ||
|
|
e0da99b2d5 | ||
|
|
3eacbe0c43 | ||
|
|
a27129a32a | ||
|
|
4791999516 | ||
|
|
fa27e41436 | ||
|
|
31fff1c354 | ||
|
|
fe460c4b8b | ||
|
|
73772cb707 | ||
|
|
38253b1f5f | ||
|
|
9d5a375017 | ||
|
|
73f455c7d0 | ||
|
|
477aa362cd | ||
|
|
2eda1f9130 | ||
|
|
8bdebe1da1 | ||
|
|
abaf09be0a | ||
|
|
0601026026 | ||
|
|
157022ab1b | ||
|
|
03f638a279 | ||
|
|
5fda4e5231 | ||
|
|
b0eddf68dd | ||
|
|
94d1ced11e | ||
|
|
49e427ba35 | ||
|
|
7fb981c538 | ||
|
|
32ec879c8b | ||
|
|
4e03ce90bc | ||
|
|
393107e4ac | ||
|
|
d09392c11a | ||
|
|
fe0391ab56 | ||
|
|
f868ee78d4 | ||
|
|
95211e84cb | ||
|
|
d859d5a565 | ||
|
|
5f58cdbfa8 | ||
|
|
3c7808e1bc | ||
|
|
4c46290b80 | ||
|
|
6bccf82a8c | ||
|
|
63a382fc6d | ||
|
|
aa96bb5623 | ||
|
|
afa749c141 | ||
|
|
f0e76c4363 | ||
|
|
5935dacc98 | ||
|
|
349485b6b6 | ||
|
|
78a32c69c1 | ||
|
|
22cf8afd1e | ||
|
|
cbf6cbe851 | ||
|
|
301aa335e1 | ||
|
|
d4391abb59 | ||
|
|
617aa0c3f3 | ||
|
|
f0b5cbf374 | ||
|
|
e594bad9d6 | ||
|
|
ea71c5234e | ||
|
|
d704433701 | ||
|
|
d8d88f4d21 | ||
|
|
ad5e7dfe2c | ||
|
|
c2f42cd57e | ||
|
|
f56e3067eb | ||
|
|
d486aef083 | ||
|
|
9f434e0bfd | ||
|
|
bc9c052b51 | ||
|
|
db6e4f0654 | ||
|
|
3f20305eda | ||
|
|
868b4c9b39 | ||
|
|
327da58c09 | ||
|
|
b5c79a1eea | ||
|
|
68a971b8a1 | ||
|
|
160a852bdb | ||
|
|
7f5bbd8be8 | ||
|
|
0cb45084bc | ||
|
|
a40e0b7a47 | ||
|
|
58c45a9905 | ||
|
|
9f16295f77 | ||
|
|
8e53959bb9 | ||
|
|
d860a008a1 | ||
|
|
5d5397bf52 | ||
|
|
f89bc08876 | ||
|
|
97ba05cfe3 | ||
|
|
422edc2dda | ||
|
|
0a2937d766 | ||
|
|
7e4bd816d1 | ||
|
|
75eb86a93a | ||
|
|
80c37602c0 | ||
|
|
b286701535 | ||
|
|
c50434594c | ||
|
|
528d711f95 | ||
|
|
4fbdaf2c32 | ||
|
|
c45d069f59 | ||
|
|
950713eef6 | ||
|
|
41e5c20191 | ||
|
|
f6b6a69197 | ||
|
|
0b00d78c98 | ||
|
|
2b457a2366 | ||
|
|
38de37adbf | ||
|
|
13d6c3e7a1 | ||
|
|
5a8f0258d6 | ||
|
|
761ebf78bb | ||
|
|
7e45720dc8 | ||
|
|
0fc3a238d7 | ||
|
|
5024e90d68 | ||
|
|
f5ee33b216 | ||
|
|
4fd12318df | ||
|
|
bf71ae7168 | ||
|
|
4187521ba4 | ||
|
|
07f8789514 | ||
|
|
8e19e815cc | ||
|
|
f31db69e03 | ||
|
|
2ec4baec3f | ||
|
|
60a4610c6b | ||
|
|
fc89a1b67e | ||
|
|
47bddb066e | ||
|
|
374400a530 | ||
|
|
f124bd2c84 | ||
|
|
df0e29ac8b | ||
|
|
692599aa94 | ||
|
|
ad67c1a121 | ||
|
|
4d60db05ba | ||
|
|
77eaa312e0 | ||
|
|
8c120bee34 | ||
|
|
140c380584 | ||
|
|
7d7dc78107 | ||
|
|
d057dee9c3 | ||
|
|
24ad98df8c | ||
|
|
d05bb11724 | ||
|
|
ef2261feb8 | ||
|
|
eab11969da | ||
|
|
d514787f95 | ||
|
|
676ee682ec | ||
|
|
ed77125ae3 | ||
|
|
4ceb880d19 | ||
|
|
fdbafe5420 | ||
|
|
13de7a6743 | ||
|
|
212051da35 | ||
|
|
52144704e7 | ||
|
|
ffa8ac142c | ||
|
|
9a8c87acfe | ||
|
|
6c5e1be3db | ||
|
|
74453ee3ac | ||
|
|
ae415b8620 | ||
|
|
ca38ae899d | ||
|
|
29d7e1edcf | ||
|
|
b1537be5b5 | ||
|
|
c4da6d500b | ||
|
|
c9dc4c466d | ||
|
|
2a66bad497 | ||
|
|
9be2b97b6c | ||
|
|
f5d2212577 | ||
|
|
a329acf23c | ||
|
|
c420a9f66c | ||
|
|
df295bd743 | ||
|
|
b06cf2452e | ||
|
|
bf156fb010 | ||
|
|
df5db57e79 | ||
|
|
071fa015e0 | ||
|
|
234343ef68 | ||
|
|
25b918ee7e | ||
|
|
a9a2c35e65 | ||
|
|
66b0c5066b | ||
|
|
e039a675af | ||
|
|
6165aa2d17 | ||
|
|
5dcdd1e14d | ||
|
|
2cd7b2b023 | ||
|
|
510f671660 |
@@ -8,11 +8,11 @@
|
||||
"name": "mempalace",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./.codex-plugin"
|
||||
"path": "./"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "NONE"
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Coding"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# MemPalace — Antigravity plugin
|
||||
|
||||
In-repo packaging for the MemPalace integration with Google's [Antigravity IDE](https://antigravity.google/).
|
||||
|
||||
This directory is the source of truth for what gets installed at
|
||||
`~/.gemini/config/plugins/mempalace/` when the user runs the installer.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
.antigravity-plugin/
|
||||
├── plugin.json # marker manifest (verified minimal schema)
|
||||
├── mcp_config.json # auto-registers the mempalace-mcp stdio server
|
||||
├── hooks.json.tmpl # template — installer renders to hooks.json
|
||||
├── skills/
|
||||
│ ├── mempalace/
|
||||
│ │ └── SKILL.md # ops skill: setup, mine, status, CLI delegation
|
||||
│ └── mempalace-recall/
|
||||
│ └── SKILL.md # recall-only skill: search-before-answer protocol
|
||||
├── rules/
|
||||
│ └── mempalace-recall.md # optional recall rule (complements the skill)
|
||||
└── README.md # this file
|
||||
```
|
||||
|
||||
The hook scripts themselves live at `hooks/antigravity/`. The installer
|
||||
copies them into `<install-dir>/hooks/` and renders `hooks.json.tmpl`
|
||||
into a `hooks.json` whose `command` paths point at the absolute install
|
||||
location.
|
||||
|
||||
## Three recall layers
|
||||
|
||||
MemPalace can store everything, but it only helps if the agent actually
|
||||
*reads* the palace before answering. Three layers wire that in, from
|
||||
eager to on-demand:
|
||||
|
||||
1. **Wake hook** (`hooks/antigravity/mempal_wake_hook_antigravity.sh`,
|
||||
`PreInvocation` event, gated to `invocationNum == 1`). On the first
|
||||
model call of a conversation it runs `mempalace wake-up` and injects
|
||||
the **actual palace content verbatim** via Antigravity's
|
||||
`injectSteps[].ephemeralMessage` output. This is Antigravity's native
|
||||
equivalent of Cursor's `sessionStart` `additional_context`, except it
|
||||
delivers the memory itself rather than a directive to go fetch it.
|
||||
2. **Recall skill** (`skills/mempalace-recall/SKILL.md`). The
|
||||
search-before-answer protocol the agent follows when a turn is
|
||||
recall-relevant — tool selection, unhappy paths, anti-patterns. It
|
||||
covers recall only; the `mempalace` skill covers setup / mine /
|
||||
status.
|
||||
3. **Optional recall rule** (`rules/mempalace-recall.md`). A lightweight
|
||||
markdown rule that nudges the agent to search before answering when
|
||||
Antigravity's matcher decides the turn is recall-relevant. It is
|
||||
deliberately recall-scoped (not an always-on global rule) so it never
|
||||
adds latency to greenfield work, honouring MemPalace's "memory should
|
||||
feel instant" budget.
|
||||
|
||||
All three point to the single canonical protocol in
|
||||
[`integrations/shared/recall-protocol.md`](../integrations/shared/recall-protocol.md)
|
||||
so the skill and rule never drift.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh
|
||||
```
|
||||
|
||||
The installer is idempotent and the uninstaller matches by basename, so
|
||||
re-runs and partial installs are safe.
|
||||
|
||||
See [website/guide/antigravity.md](../website/guide/antigravity.md) for
|
||||
the full user-facing guide and [hooks/antigravity/README.md](../hooks/antigravity/README.md)
|
||||
for the hooks-specific documentation.
|
||||
|
||||
## Verified surfaces
|
||||
|
||||
Every file in this directory maps to a surface verified against
|
||||
[Google's Antigravity docs](https://antigravity.google/docs/). See
|
||||
[hooks/antigravity/INVESTIGATION.md](../hooks/antigravity/INVESTIGATION.md)
|
||||
for the full audit, including the surfaces deliberately not shipped.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"mempalace-save": {
|
||||
"Stop": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "__PLUGIN_DIR__/hooks/mempal_save_hook_antigravity.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
"mempalace-wake": {
|
||||
"PreInvocation": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "__PLUGIN_DIR__/hooks/mempal_wake_hook_antigravity.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"name": "mempalace"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# MemPalace recall
|
||||
|
||||
Before answering anything that may already be in the user's memory
|
||||
palace — past work, prior decisions, a person, a project, or "what did
|
||||
we do / decide / discuss last time?" — search the palace first:
|
||||
|
||||
1. Call `mempalace_search` with a short keyword query. Use
|
||||
`mempalace_kg_query` for relational or time-bound facts.
|
||||
2. Quote the drawer's **verbatim** text. Never summarize or paraphrase
|
||||
stored content.
|
||||
3. If results are empty, say so — do not invent an answer. If the MCP
|
||||
server is unavailable, surface the error; do not fall back to guessing.
|
||||
|
||||
Skip recall for pure greenfield work with no memory relevance (renaming
|
||||
a variable, fixing a typo). Recall is question-driven, not reflexive.
|
||||
|
||||
Full protocol: <https://github.com/MemPalace/mempalace/blob/main/integrations/shared/recall-protocol.md>. Deeper guidance:
|
||||
the `mempalace-recall` skill.
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: mempalace-recall
|
||||
description: "Recall protocol for MemPalace — search the palace before answering about past work, people, projects, or prior decisions. Apply when the user asks what was decided, what happened before, who someone is, what was discussed last time, or anything that may already be filed in their memory palace; or when mempalace-recall is invoked. Complements the mempalace setup skill and requires the mempalace-mcp server."
|
||||
---
|
||||
|
||||
# MemPalace Recall
|
||||
|
||||
Search-before-answer protocol for MemPalace. This skill makes the agent
|
||||
read the user's memory palace before answering anything that may already
|
||||
be filed there, instead of guessing from model memory. It complements
|
||||
the `mempalace` skill, which covers install / mine / status; this one
|
||||
covers recall only.
|
||||
|
||||
## Step 0 — Verify MemPalace is available
|
||||
|
||||
Before relying on recall, confirm MemPalace is installed and reachable:
|
||||
|
||||
- Official release page: <https://github.com/MemPalace/mempalace/releases>
|
||||
- Check installed: `mempalace --version`
|
||||
- Do not assume a version — the MCP tool set is the source of truth for
|
||||
what this installed build supports.
|
||||
|
||||
If the `mempalace_*` MCP tools are not available, tell the user the
|
||||
server is not connected and point them at the `mempalace` skill to set
|
||||
it up. Do not silently fall back to answering from model memory.
|
||||
|
||||
## Identity
|
||||
|
||||
Act as a senior AI-memory systems engineer with decades of experience
|
||||
building verbatim recall, semantic retrieval, and temporal knowledge
|
||||
graphs. Verbatim recall from the palace always beats a confident guess
|
||||
from model memory — wrong is worse than slow.
|
||||
|
||||
## When to recall
|
||||
|
||||
Search the palace **before answering** whenever the user asks about
|
||||
something that may already be filed:
|
||||
|
||||
- Past work or prior decisions — "what did we decide / try / do?"
|
||||
- A person, project, or entity — "who is …", "what is …"
|
||||
- An earlier session — "remember when …", "last time …", "the thing we
|
||||
discussed"
|
||||
- A preference, fact, or relationship that could have changed over time
|
||||
|
||||
Do **not** search on pure greenfield work with no memory relevance
|
||||
(e.g. "rename this variable", "fix this typo"). Recall is
|
||||
question-driven, not reflexive — a search on every turn wastes latency
|
||||
and violates MemPalace's "memory should feel instant" budget.
|
||||
|
||||
## Protocol
|
||||
|
||||
1. On wake-up, the MemPalace PreInvocation hook injects verbatim palace
|
||||
content via `injectSteps[].ephemeralMessage` on the first model call
|
||||
of a conversation. If memory was injected, start from it before
|
||||
searching further.
|
||||
2. **Before responding** about people, projects, past events, or prior
|
||||
decisions: call `mempalace_search` first. For relational or temporal
|
||||
facts ("who reported to whom in March", "what was true then"), call
|
||||
`mempalace_kg_query` instead or as well.
|
||||
3. **If unsure** about a fact (name, age, relationship, preference): say
|
||||
"let me check the palace" and query. Wrong is worse than slow.
|
||||
4. **Return verbatim.** Quote the drawer's exact stored words. Never
|
||||
summarize, paraphrase, or lossy-compress what the palace returns —
|
||||
that is the whole point of the system.
|
||||
5. **After a substantive session**, record continuity with
|
||||
`mempalace_diary_write` (background hooks may already do this — do not
|
||||
double-file).
|
||||
6. **When a fact changes**, call `mempalace_kg_invalidate` on the old
|
||||
fact, then `mempalace_kg_add` for the new one.
|
||||
|
||||
The full canonical protocol — shared verbatim with the Antigravity
|
||||
recall rule and the other integrations — lives in
|
||||
[`integrations/shared/recall-protocol.md`](https://github.com/MemPalace/mempalace/blob/main/integrations/shared/recall-protocol.md).
|
||||
|
||||
## Tool selection
|
||||
|
||||
| You need | Tool |
|
||||
|---|---|
|
||||
| Find any memory by meaning | `mempalace_search` (start here) |
|
||||
| Relational / time-bound facts about an entity | `mempalace_kg_query` |
|
||||
| The chronological story of an entity | `mempalace_kg_timeline` |
|
||||
| Recent session continuity | `mempalace_diary_read` |
|
||||
| Which wings / rooms exist (scope unknown) | `mempalace_list_wings`, `mempalace_list_rooms` |
|
||||
| Record this session | `mempalace_diary_write` |
|
||||
|
||||
`mempalace_search` takes a short natural-language `query` (keywords or a
|
||||
question — not a system prompt or pasted conversation) plus optional
|
||||
`wing` / `room` filters and `limit` (default 5).
|
||||
|
||||
## Unhappy paths
|
||||
|
||||
- **Empty results.** Say the palace has nothing on this; do not invent an
|
||||
answer to fill the gap. Offer to widen the search (drop the wing
|
||||
filter) or to file the new information.
|
||||
- **MCP unavailable / tool error.** Surface the error plainly and suggest
|
||||
the user verify the server (`mempalace status`, or re-run the
|
||||
installer `hooks/antigravity/install.sh`). Do not silently fall back
|
||||
to guessing from model memory.
|
||||
- **Stale or conflicting facts.** Prefer the knowledge graph's
|
||||
time-valid answer; if a fact has changed, invalidate the old one and
|
||||
add the new one rather than overwriting context silently.
|
||||
|
||||
## Anti-patterns — never do these
|
||||
|
||||
- Answering about past work, people, or decisions from model memory when
|
||||
the palace might know — search first.
|
||||
- Paraphrasing or summarizing stored content instead of quoting it
|
||||
verbatim.
|
||||
- Searching reflexively on every turn, including pure greenfield coding
|
||||
with no memory relevance.
|
||||
- Pasting the full conversation or a system prompt into the `query`
|
||||
argument — keep queries short and keyword-driven.
|
||||
|
||||
## Official References
|
||||
|
||||
- MemPalace: <https://github.com/MemPalace/mempalace>
|
||||
- MemPalace releases: <https://github.com/MemPalace/mempalace/releases>
|
||||
- Antigravity documentation: <https://antigravity.google/docs>
|
||||
- Agent Skills specification: <https://agentskills.io/specification>
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: mempalace
|
||||
description: MemPalace — mine projects and conversations into a searchable memory palace. Use when the user asks about MemPalace, memory palace, mining memories, searching memories, palace setup, wings, rooms, or drawers; or when they want to recall past work that may already be filed in their palace.
|
||||
---
|
||||
|
||||
# MemPalace
|
||||
|
||||
A searchable memory palace for AI — mine projects and conversations, then search them semantically. Verbatim storage, local-first, zero external API by default.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure `mempalace` is installed:
|
||||
|
||||
```bash
|
||||
mempalace --version
|
||||
```
|
||||
|
||||
If not installed (uv recommended):
|
||||
|
||||
```bash
|
||||
uv tool install mempalace # or: pip install mempalace
|
||||
```
|
||||
|
||||
## Dynamic, version-correct instructions
|
||||
|
||||
MemPalace exposes operation-specific instructions through the CLI so this skill stays accurate as MemPalace evolves. To get instructions for any operation:
|
||||
|
||||
```bash
|
||||
mempalace instructions <command>
|
||||
```
|
||||
|
||||
Always prefer the CLI output over what is written here when the two disagree — the CLI is the single source of truth for the installed version.
|
||||
|
||||
## Common operations
|
||||
|
||||
These are the five operations users ask for most often. Each one wraps a single MemPalace CLI subcommand. The `mempalace instructions <name>` form returns the full, version-correct guidance.
|
||||
|
||||
### `help` — discover what MemPalace can do
|
||||
|
||||
```bash
|
||||
mempalace instructions help
|
||||
```
|
||||
|
||||
Use when the user is new, unsure what's possible, or asks "what can you do".
|
||||
|
||||
### `init` — first-run setup of the palace
|
||||
|
||||
```bash
|
||||
mempalace instructions init
|
||||
```
|
||||
|
||||
Use when the user has just installed MemPalace, no palace exists yet, or the user explicitly asks to set up / configure / re-initialize their palace.
|
||||
|
||||
### `mine` — ingest a project or conversation directory
|
||||
|
||||
```bash
|
||||
mempalace instructions mine
|
||||
```
|
||||
|
||||
Use when the user wants to fold a project's files into their palace, or to ingest exported conversation transcripts into the palace as searchable memory.
|
||||
|
||||
### `search` — find verbatim memories by semantic query
|
||||
|
||||
```bash
|
||||
mempalace instructions search
|
||||
```
|
||||
|
||||
Use when the user wants to recall something from the past, find a previous decision, or rediscover code/notes/conversations they already wrote.
|
||||
|
||||
### `status` — what's in the palace right now
|
||||
|
||||
```bash
|
||||
mempalace instructions status
|
||||
```
|
||||
|
||||
Use when the user asks "what's in my palace", "how big is my palace", or wants a summary of wings, rooms, and drawer counts.
|
||||
|
||||
## MCP tools (preferred over CLI)
|
||||
|
||||
Inside Antigravity, the MemPalace MCP server registers a rich set of tools. Use these instead of shelling out to the CLI for live operations (search, diary writes, drawer adds, knowledge graph queries, palace status). The MCP tools always reflect the current palace state without spawning a subprocess.
|
||||
|
||||
The MCP server is auto-registered when this plugin is installed at `~/.gemini/config/plugins/mempalace/`. If the server does not appear in Antigravity's MCP store, run `mempalace-mcp --version` to verify the binary is on PATH, then restart Antigravity.
|
||||
|
||||
## Design principles (verbatim from the project)
|
||||
|
||||
- **Verbatim always** — never summarize, paraphrase, or lossy-compress user data.
|
||||
- **Local-first, zero external API by default** — extraction, embedding, and LLM-assisted refinement happen on the user's machine.
|
||||
- **Privacy by architecture** — the system never calls out to external services for core operations.
|
||||
- **Performance budgets** — hooks under 500ms; startup injection under 100ms.
|
||||
- **Background everything** — filing, indexing, and timestamps happen via hooks in the background; zero tokens spent on bookkeeping in the chat window.
|
||||
|
||||
If a request would violate any of these principles, refuse and explain — even if it would be technically convenient.
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
A Claude Code plugin that gives your AI a persistent memory system. Mine projects and conversations into a searchable palace backed by ChromaDB, with 36 MCP tools, auto-save hooks, and 5 guided skills.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -41,16 +41,17 @@ After installing the plugin, run the init command to complete setup (installs th
|
||||
|
||||
## Hooks
|
||||
|
||||
MemPalace registers two hooks that run automatically:
|
||||
MemPalace registers three hooks that run automatically:
|
||||
|
||||
- **Stop** -- Saves conversation context every 15 messages.
|
||||
- **SessionEnd** -- Runs one final save in the background on a clean exit, so short sessions that never hit the Stop interval or a compaction are still captured.
|
||||
- **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.
|
||||
The plugin automatically configures a local MCP server with 36 tools for storing, searching, and managing memories. No manual MCP setup is required -- `/mempalace:init` handles everything.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
|
||||
@@ -12,6 +12,17 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/mempal-session-end-hook.sh\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# MemPalace SessionEnd Hook — thin wrapper calling the Python CLI.
|
||||
#
|
||||
# Claude Code documents a default SessionEnd hook timeout of 1.5s, and
|
||||
# "timeouts set on plugin-provided hooks do not raise the budget"
|
||||
# (https://code.claude.com/docs/en/hooks). A cold `mempalace` start alone
|
||||
# exceeds 1.5s, so the final mine must NOT run in the foreground — it would be
|
||||
# killed before it saved anything. Unlike the foreground Stop/PreCompact plugin
|
||||
# wrappers, this one backgrounds the hook and returns immediately; the detached
|
||||
# child finishes the save after the session has exited. All logic lives in
|
||||
# mempalace.hooks_cli for cross-harness extensibility.
|
||||
run_mempalace_hook() {
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
exec mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
MEMPAL_PYTHON_BIN="${MEMPAL_PYTHON:-}"
|
||||
if [ -z "$MEMPAL_PYTHON_BIN" ] || [ ! -x "$MEMPAL_PYTHON_BIN" ]; then
|
||||
MEMPAL_PYTHON_BIN="$(command -v python3 2>/dev/null || echo python3)"
|
||||
fi
|
||||
if "$MEMPAL_PYTHON_BIN" -c "import mempalace" >/dev/null 2>&1; then
|
||||
exec "$MEMPAL_PYTHON_BIN" -m mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
|
||||
exec python -m mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Capture stdin (the SessionEnd JSON) before backgrounding — the parent's
|
||||
# stdin is gone once we return. Forward it to the detached worker, which runs
|
||||
# the final mine on its own time and outlives this process.
|
||||
payload="$(cat)"
|
||||
(
|
||||
printf '%s' "$payload" | run_mempalace_hook --hook session-end --harness "${MEMPALACE_HOOK_HARNESS:-claude-code}"
|
||||
) >/dev/null 2>&1 </dev/null &
|
||||
disown 2>/dev/null || true
|
||||
|
||||
# Return immediately so the harness never blocks on session exit.
|
||||
printf '{}'
|
||||
@@ -8,8 +8,8 @@
|
||||
{
|
||||
"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.4.0",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, guided setup.",
|
||||
"version": "3.7.0",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.4.0",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"version": "3.7.0",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: mempalace-recall
|
||||
description: Recall protocol for MemPalace — search the palace before answering about past work, prior decisions, people, or projects. Use when the user asks what was decided, what happened before, who someone is, what was discussed last time, or anything that may already be filed in their memory palace.
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
# MemPalace Recall
|
||||
|
||||
Search-before-answer protocol for MemPalace. Read the user's memory
|
||||
palace before answering anything that may already be filed there,
|
||||
instead of guessing from model memory. This complements the `mempalace`
|
||||
skill (install / mine / status); this one covers recall only.
|
||||
|
||||
## Step 0 — Verify MemPalace is available
|
||||
|
||||
```bash
|
||||
mempalace --version
|
||||
```
|
||||
|
||||
If the `mempalace_*` MCP tools are not available, tell the user the
|
||||
server is not connected and point them at the `mempalace` skill or
|
||||
`/init`. Do not silently fall back to answering from model memory.
|
||||
|
||||
## When to recall
|
||||
|
||||
Search the palace **before answering** whenever the user asks about
|
||||
something that may be filed:
|
||||
|
||||
- Past work or prior decisions — "what did we decide / try / do?"
|
||||
- A person, project, or entity — "who is …", "what is …"
|
||||
- An earlier session — "remember when …", "last time …"
|
||||
- A preference, fact, or relationship that could have changed over time
|
||||
|
||||
Skip recall for pure greenfield work with no memory relevance (renaming
|
||||
a variable, fixing a typo). Recall is question-driven, not reflexive.
|
||||
|
||||
## Protocol
|
||||
|
||||
1. Before responding about people / projects / past events / prior
|
||||
decisions: call `mempalace_search` first. Use `mempalace_kg_query`
|
||||
for relational or time-bound facts.
|
||||
2. If unsure about a fact: say "let me check the palace" and query.
|
||||
3. Return the drawer's **verbatim** text — never summarize or paraphrase
|
||||
stored content.
|
||||
4. After a substantive session, record continuity with
|
||||
`mempalace_diary_write` (skip if a background hook already saved).
|
||||
5. When a fact changes: `mempalace_kg_invalidate` the old fact, then
|
||||
`mempalace_kg_add` the new one.
|
||||
|
||||
## Unhappy paths
|
||||
|
||||
- **Empty results** — say the palace has nothing on this; do not invent
|
||||
an answer. Offer to widen the search or file the new information.
|
||||
- **MCP error / server down** — surface the error, suggest `mempalace
|
||||
status` or re-running `/init`; never fall back to guessing.
|
||||
- **Palace index corrupt / compactor error** — if the server reports an
|
||||
HNSW segment-writer error, a ChromaDB compaction failure, or stays
|
||||
"Not connected" after a write, the index is out of sync with
|
||||
`chroma.sqlite3` but the rows are intact. Tell the user to stop the
|
||||
server and rebuild from SQLite (`mempalace repair --mode from-sqlite
|
||||
--archive-existing --yes`), not re-mine, which drops MCP-added drawers
|
||||
and diary entries (#1843). Do not repair in-process.
|
||||
- **Conflicting facts** — trust the knowledge graph's time-valid answer;
|
||||
invalidate-then-add rather than overwriting silently.
|
||||
|
||||
The canonical protocol, shared across all MemPalace integrations, lives
|
||||
in `integrations/shared/recall-protocol.md`.
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
Give your AI a persistent memory -- mine projects and conversations into a searchable palace backed by ChromaDB, with 36 MCP tools, auto-save hooks, and guided skills.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.4.0",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"version": "3.7.0",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
},
|
||||
@@ -18,16 +18,11 @@
|
||||
"search"
|
||||
],
|
||||
"skills": "./skills/",
|
||||
"hooks": "./hooks.json",
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
},
|
||||
"mcpServers": "./.mcp.json",
|
||||
"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.",
|
||||
"longDescription": "Give your AI a persistent memory — mine projects and conversations into a searchable palace backed by ChromaDB, with 36 MCP tools, auto-save hooks, and guided skills.",
|
||||
"developerName": "milla-jovovich",
|
||||
"category": "Coding",
|
||||
"capabilities": [
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# MemPalace Cursor Plugin
|
||||
|
||||
A Cursor IDE plugin that gives your agent a persistent memory system. Auto-registers the `mempalace-mcp` server (36 MCP tools), ships 5 slash commands, two model-invocable skills (setup/mining/search and a recall protocol), and an optional recall rule.
|
||||
|
||||
> Hooks (auto-save + session-start memory recall) are shipped separately under `hooks/cursor/` so the plugin is safe to install in any Cursor workspace without touching the agent loop. See [Hooks](#hooks-optional) below.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9+
|
||||
- Cursor 1.7+ (plugin manifest schema requires it)
|
||||
|
||||
## Installation
|
||||
|
||||
### Local clone (recommended while not in the marketplace yet)
|
||||
|
||||
Symlink (or copy) this repository into Cursor's local plugins folder:
|
||||
|
||||
```bash
|
||||
ln -s /path/to/mempalace ~/.cursor/plugins/local/mempalace
|
||||
```
|
||||
|
||||
Then in Cursor: <kbd>Cmd</kbd>-<kbd>Shift</kbd>-<kbd>P</kbd> → **Developer: Reload Window**.
|
||||
|
||||
### Marketplace
|
||||
|
||||
Once published, install via the Cursor marketplace panel and select `mempalace`. Required-plugin distribution from a team marketplace is also supported.
|
||||
|
||||
## Post-Install Setup
|
||||
|
||||
After installing the plugin, run the `init` command in a Cursor chat:
|
||||
|
||||
```
|
||||
/mempalace-init
|
||||
```
|
||||
|
||||
(Or just say "use the mempalace skill" — Cursor will model-invoke the bundled skill.)
|
||||
|
||||
This installs the `mempalace` package via `uv tool` or `pip`, initializes a palace under `~/.mempalace/`, and verifies the MCP server is reachable.
|
||||
|
||||
## Available Slash Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------------------|-----------------------------------------------------------------------------------|
|
||||
| `/mempalace-help` | Show available tools, skills, CLI commands, hooks, and architecture |
|
||||
| `/mempalace-init` | Set up MemPalace — install, configure, onboard |
|
||||
| `/mempalace-search` | Search your memories across the palace using semantic search |
|
||||
| `/mempalace-mine` | Mine projects and conversations into the palace |
|
||||
| `/mempalace-status` | Show palace overview — wings, rooms, drawer counts |
|
||||
|
||||
> Cursor commands are global, not plugin-namespaced — that's why each slug is prefixed with `mempalace-` rather than appearing as `/help`, `/init`, etc. This keeps them collision-free with built-in or other-plugin commands.
|
||||
|
||||
## Skills
|
||||
|
||||
Two model-invocable skills ship at the plugin root under `skills/`:
|
||||
|
||||
| Skill | What it does |
|
||||
|-------|--------------|
|
||||
| `mempalace` | Setup, mining, status, and the dynamic `mempalace instructions` CLI. |
|
||||
| `mempalace-recall` | Search-before-answer protocol — makes the agent read the palace before answering about past work, people, projects, or prior decisions instead of guessing. |
|
||||
|
||||
Cursor surfaces these automatically when a request matches their description, or you can attach them explicitly.
|
||||
|
||||
## Recall rule (optional)
|
||||
|
||||
The plugin also ships a Cursor rule at the plugin root under `rules/mempalace-recall.mdc`:
|
||||
|
||||
```yaml
|
||||
description: When the user asks about past work, prior decisions, people, ... call mempalace_search before answering ...
|
||||
alwaysApply: false
|
||||
```
|
||||
|
||||
It is `alwaysApply: false` on purpose — Cursor loads it only when its matcher judges the turn recall-relevant, so it never fires on unrelated coding work and never adds MCP latency to greenfield tasks. The rule, the `mempalace-recall` skill, and the `sessionStart` hook all reference the same canonical protocol in [`integrations/shared/recall-protocol.md`](../integrations/shared/recall-protocol.md).
|
||||
|
||||
Want recall forced into **every** conversation regardless of context? Copy the aggressive `alwaysApply: true` variant from [`examples/cursor/rules/`](../examples/cursor/rules/README.md) into `~/.cursor/rules/`. That is a deliberate, heavier opt-in, not a default.
|
||||
|
||||
## MCP Server
|
||||
|
||||
This plugin ships `mcp.json` at the plugin root, so Cursor auto-loads the `mempalace-mcp` server on plugin install:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All 36 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, …) become available to the agent immediately. No manual `~/.cursor/mcp.json` edit required.
|
||||
|
||||
If the server doesn't appear, confirm `mempalace-mcp` is on the user `$PATH`:
|
||||
|
||||
```bash
|
||||
command -v mempalace-mcp
|
||||
```
|
||||
|
||||
If it isn't, run `/init` (or `mempalace install` from a terminal) — `mempalace-mcp` is installed alongside the `mempalace` package.
|
||||
|
||||
## Hooks (optional)
|
||||
|
||||
Cursor's hooks system is configured separately from plugins (in `~/.cursor/hooks.json` or `.cursor/hooks.json`), so this plugin does **not** wire hooks itself. The MemPalace repository ships three Cursor-native hooks under [`hooks/cursor/`](../hooks/cursor/) that you install with one command.
|
||||
|
||||
User scope — writes `~/.cursor/hooks.json`, applies to every Cursor workspace (recommended):
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope user --variant full
|
||||
```
|
||||
|
||||
Project scope — writes `.cursor/hooks.json` under the current project only:
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope project --variant full
|
||||
```
|
||||
|
||||
What you get:
|
||||
|
||||
| Hook event | What it does |
|
||||
|----------------|-------------------------------------------------------------------------------------------------------|
|
||||
| `sessionStart` | Injects an `additional_context` recap of relevant memories scoped to the workspace wing |
|
||||
| `stop` | Counts agent turns; every N turns, emits a `followup_message` instructing a memory checkpoint |
|
||||
| `preCompact` | Synchronously mines the transcript before compaction, drops a marker so the next `stop` saves a diary |
|
||||
|
||||
Full details: [`website/guide/cursor-hooks.md`](../website/guide/cursor-hooks.md) and [`hooks/cursor/README.md`](../hooks/cursor/README.md).
|
||||
|
||||
## Uninstall
|
||||
|
||||
Remove the local plugin symlink:
|
||||
|
||||
```bash
|
||||
rm ~/.cursor/plugins/local/mempalace
|
||||
```
|
||||
|
||||
Then in Cursor: <kbd>Cmd</kbd>-<kbd>Shift</kbd>-<kbd>P</kbd> → **Developer: Reload Window**.
|
||||
|
||||
If you also installed the hooks, remove them (leaves any unrelated hooks in `hooks.json` untouched):
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope user --uninstall
|
||||
```
|
||||
|
||||
## Full Documentation
|
||||
|
||||
See the main [README](../README.md) for complete documentation, architecture details, and advanced usage.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"owner": {
|
||||
"name": "milla-jovovich",
|
||||
"url": "https://github.com/MemPalace"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mempalace",
|
||||
"source": ".",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 36 MCP tools, slash commands, and a guided skill for Cursor.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 36 MCP tools, slash commands, and a guided skill for Cursor.",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.11", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -24,18 +24,29 @@ jobs:
|
||||
test-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: 'pip'
|
||||
- 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
|
||||
# ChromaDB's rust HNSW core intermittently fails compaction or reader
|
||||
# initialization on Windows ("Failed to apply logs to the hnsw segment
|
||||
# writer" / "Error creating hnsw segment reader: Nothing found on disk")
|
||||
# regardless of our code — long-standing, non-reproducible-on-Linux/macOS
|
||||
# flakes. Retry ONLY those specific transient errors (via --only-rerun) so real,
|
||||
# deterministic failures still fail on the first run. Linux/macOS jobs
|
||||
# deliberately run with no reruns so genuine regressions surface there.
|
||||
- run: >-
|
||||
python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace
|
||||
--cov-report=term-missing --cov-fail-under=80 --durations=10 --reruns 2
|
||||
--reruns-delay 5 --only-rerun
|
||||
"Failed to apply logs to the hnsw segment writer|Error creating hnsw segment reader: Nothing found on disk"
|
||||
|
||||
test-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
@@ -45,7 +56,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
@@ -53,6 +64,10 @@ jobs:
|
||||
# Keep this pin identical to the `ruff==` pin in pyproject.toml
|
||||
# ([project.optional-dependencies].dev and [dependency-groups].dev)
|
||||
# so CI and `pip install -e ".[dev]"` format/lint identically.
|
||||
- run: pip install "ruff==0.15.14"
|
||||
# This drifted once (CI on 0.15.14 against pyproject's 0.15.20) and the
|
||||
# drift was invisible: the lint job never installs from pyproject, so a
|
||||
# dependabot bump to the pin there passes CI without the new version
|
||||
# ever running. test_ruff_pins_match asserts the two stay equal.
|
||||
- run: pip install "ruff==0.16.1"
|
||||
- run: ruff check .
|
||||
- run: ruff format --check .
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -16,27 +16,68 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Run the image before anyone can publish it. A green `build` only proves the
|
||||
# Dockerfile compiles — it never started a container, so defects that break
|
||||
# the first documented command shipped anyway (#2187, #2188). This builds
|
||||
# amd64 natively, loads it into the local daemon, and exercises the README
|
||||
# paths: CLI mine + verbatim search across two containers, a real MCP stdio
|
||||
# handshake, and `compose config` on both shipped Compose files.
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# amd64 only and `load: true`: the smoke run needs a real image in the
|
||||
# local daemon, and buildx cannot load a multi-arch manifest. Shares the
|
||||
# `build` job's gha cache scope, so this is mostly a cache hit.
|
||||
- name: Build image for smoke test
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: mempalace:smoke
|
||||
# Read the publish job's cache too (its amd64 layers are identical),
|
||||
# but write to a private scope so an amd64-only export never lands on
|
||||
# top of that job's multi-arch one. Same split as `scope=gpu` below.
|
||||
cache-from: |
|
||||
type=gha
|
||||
type=gha,scope=smoke
|
||||
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max,scope=smoke' || '' }}
|
||||
|
||||
# Reaches Chroma's S3 once to fetch the ~80 MB embedding model, so it is
|
||||
# network-dependent; that download is itself part of a user's first run.
|
||||
- name: Smoke test
|
||||
run: ./scripts/docker-smoke.sh mempalace:smoke
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# Never publish an image the smoke test has not cleared.
|
||||
needs: smoke
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Needed for the emulated linux/arm64 build on real pushes.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# Only authenticate + push for in-repo events. Fork PRs lack the
|
||||
# packages:write token, so they build (to validate the Dockerfile) but
|
||||
# do not push.
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -44,7 +85,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# latest -> main (the latest release); semver tags -> released versions.
|
||||
@@ -56,7 +97,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -79,13 +120,13 @@ jobs:
|
||||
build-gpu:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build GPU image (validation only — not published)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.gpu
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved tag: $tag"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Fully-qualified refs/tags/ so an unqualified name can't resolve to a
|
||||
# same-named *branch* instead of the tag (checkout prefers branches).
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
check-versions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract versions from all sources
|
||||
id: versions
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
dist/
|
||||
build/
|
||||
__pycache__/
|
||||
.hypothesis/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
mempal.yaml
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
-1
@@ -6,6 +6,197 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.7.0] — 2026-08-11
|
||||
|
||||
### Features
|
||||
|
||||
- **Embeddings via any OpenAI-compatible `/v1/embeddings` endpoint.** New `embedding_model: "openai-compat"` option computes embeddings on a server (LM Studio, llama.cpp, vLLM, Ollama's OpenAI shim, or a self-hosted endpoint) instead of a local ONNX model — useful for larger/multilingual embedders such as Qwen3-Embedding, or GPU offload. New `OpenAICompatEmbeddingFunction` in [`mempalace/embedding.py`](mempalace/embedding.py) speaks the standard `/v1/embeddings` protocol over stdlib `urllib` (no new dependency), batches requests, re-sorts the response by `index`, and L2-normalizes for the cosine collection. Endpoint settings are resolved by `MempalaceConfig` as a single source of truth — `embedding_api_url` / `embedding_api_model` / `embedding_api_key` in `config.json`, each overridable via the matching `MEMPALACE_EMBEDDING_API_*` env var. The embedding function's `name()` encodes the model id so changing it forces `mempalace repair rebuild-index` (different vector space). Mirrors the existing `openai-compat` LLM provider naming; stays local when the endpoint is on your machine/LAN. (#1559)
|
||||
- **`mempalace_search` / `mempalace search` — `since`/`before` date window.** Semantic search is poor at temporal queries ("what did we discuss this week?" scores ~0.35 even when matching drawers exist), so the search surfaces now accept the same `[since, before)` window `list_drawers` gained in #1128: inclusive/exclusive ISO bounds compared wall-clock against each drawer's `filed_at`, undated drawers excluded while a bound is active. The window applies on every candidate path (vector, `candidate_strategy="union"`, and the BM25-only fallback); the vector candidate pool widens under an active window (ChromaDB cannot range-compare string metadata server-side), and a full pool is flagged via `date_filter_pool_truncated` instead of passing silently. Shared parsing lives in the new `mempalace.date_window` module, also backing the `list_drawers` filter. (#463)
|
||||
- **Hermes agent memory provider (core).** In-package Hermes `MemoryProvider` files live turns through the shared `file_conversation_exchange()` path (same metadata as convo mining), with a background worker so the agent loop never blocks on Chroma. Wake-up L1 uses the long-lived collection only — a second PersistentClient is not opened, avoiding local SQLite races. Install/backfill/docs remain a stacked follow-up. (#1915, #2215)
|
||||
- **Explicit RFC 002 source adapters on `mempalace mine`.** `mempalace mine <source> --source <adapter>` resolves adapters through the registry, holds the palace writer lease for the full ingest, and keeps dry-runs inert (no real backend/KG open). Legacy `--mode` paths are unchanged. (#2068, #2062)
|
||||
- **MCP refuses writes when the served library is no longer the one that started.** Detects mempalace (and chromadb when that backend is active) version drift or uninstall after upgrade without restarting the server; opt-out via `MEMPALACE_MCP_ALLOW_STALE_LIBRARY`. (#2081, #899)
|
||||
- **Hook write-routing through the daemon.** Background hook saves and mines can honor the shared write-routing policy so multi-session setups serialize mutations through one local owner instead of racing the palace. (#2030, #1963)
|
||||
|
||||
### Performance
|
||||
|
||||
- **EmbeddingGemma groups documents by size before sub-batching.** The tokenizer pads every row of a sub-batch to the longest sequence in it, so arrival order decided the bill: one long verbatim message dragged a whole sub-batch up to its own length. Measured over 43,157 `sweep` drawers from 160 Claude Code transcripts, padded token slots drop 39.7% and the quadratic attention term 45.0%. Vectors move by at most one float32 ULP (1.2e-07 absolute, cosine 0.99999992), which is reduction-order rounding and not a change of meaning. Applies to `embedding_model: embeddinggemma` only; the default MiniLM embedder pads to a fixed width and was never affected. (#2104)
|
||||
- **HNSW capacity probes are cached** and invalidated by palace file signature, so repeated MCP status/taxonomy paths no longer re-scan native segment files on every call. (#2051, #1471)
|
||||
- **`chunk_text` line numbering is O(N)** via incremental tallies, fixing multi-second hangs on large sources. (#2054, #2055)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Orphaned per-source-file mine locks are reaped instead of accumulating forever.** `_cleanup_mine_lock_file` reclaims a lock correctly on the happy path, but only for the specific lock its own `mine_lock` context manager just released — a process killed abruptly (SIGKILL, force-quit, host crash) never reaches that cleanup, and nothing else revisited the file afterward. One long-lived installation was found with 5,636 stale entries in `~/.mempalace/locks/`, the oldest several months old, none held by any live process. `mine_lock` now opportunistically reaps locks older than an hour via the same nonblocking-flock-reacquire safety check `_cleanup_mine_lock_file` already uses, throttled to once per 15 minutes so it costs nothing on the common path. `mine_palace_*.lock` (the newer per-palace lock) is untouched — it has its own lifecycle and holder tracking.
|
||||
- **Windows MCP stdout capture falls back cleanly** when fd-level redirection is unavailable, and fails closed if protocol stdout cannot be restored after a successful redirect. (#2211, #2210)
|
||||
- **Claude Code `subagents/` transcripts are skipped by default** when mining conversations (98%+ noise on typical workspaces); `--include-subagents` opts back in. (#1330, #1217)
|
||||
- **Worktree transcript cwd no longer mints a throwaway wing** — wing is derived from the project root above `.claude/worktrees/`. (#2206)
|
||||
- **Markdown emphasis/bold no longer scores as emotional content** in the general extractor. (#2199, #2197)
|
||||
- **Encoding hardening on Windows locales:** pin `encoding=utf-8` on config/dialect opens; safer mojibake repair that does not destroy clean Portuguese/Vietnamese/Turkish prose; repair-encoding CLI reconfigures stdio like other entry points; remaining non-ASCII CLI symbols replaced for GBK consoles. (#2098, #2208, #2194, #1104, #1034, #2193)
|
||||
- **`rebuild_index` holds the palace writer lease** for the full snapshot→rebuild/swap cycle so concurrent writers cannot recreate HNSW divergence mid-repair. (#2195)
|
||||
- **`MEMPALACE_PALACE_PATH` is restored after each service entrypoint** so multi-call processes do not leak the first call's palace into the second. (#2192)
|
||||
- **systemd unit uses `Restart=always`** and documents `MEMPALACE_MCP_IDLE_HOURS=0` so the idle watchdog does not leave a dedicated server down after a clean exit. (#2203, #2204)
|
||||
- **Mining works again on the default Chroma backend.** Once Chroma began declaring `requires_explicit_embeddings`, every write started routing through `EmbeddingCollection`, whose `_embed_texts` built rows with `list(ndarray)` — that unpacks into `np.float32` *scalars*, which chromadb rejects outright (`Expected embeddings to be a list of floats or ints, a list of lists, a numpy array, or a list of numpy arrays`). `mine`, and every other write against a default palace, aborted on the first drawer. Vectors now convert to real Python floats. The suite was structurally blind to this: conftest's autouse embedding fixture replaces `_embed_texts` itself for every module outside `test_embedding` / `test_embeddinggemma`, so the defective function was never executed under test — the regression tests therefore live in `test_embedding.py`, where that stub does not apply. (#2187)
|
||||
- **ChatGPT data exports are parsed instead of stored as raw JSON.** A real `conversations.json` is a top-level array of conversations, which no parser claimed, so `mine --mode convos` chunked the raw JSON and lost every speaker turn while reporting success. Each conversation now normalizes to its own transcript, as Claude.ai privacy exports already do, so per-conversation dedup survives a re-export. The ChatGPT parser also type-checks its nested shapes, so an unrelated array carrying a `mapping` key is declined instead of raising. (#2160)
|
||||
- **Local backends enforce process-lifetime single-writer ownership.** File-backed and unknown backends require one writer owner for the full process lifetime (daemon holds the lease until workers exit; writable MCP HTTP acquires ownership before bind and refuses startup when blocked). Read-only MCP may coexist; `sqlite_exact` opens genuine query-only/immutable readers; remote Milvus/Zilliz remain multi-process. Addresses multi-writer SQLite/WAL corruption from MCP HTTP + daemon + mine topologies. (#2079, #2045)
|
||||
- **Chroma HNSW write defaults match chromadb** (`batch_size=100` / `sync_threshold=1000`) instead of the old 2/2 bloat guard that rewrote segments thousands of times on large mines. (#2107, #2106)
|
||||
- **Repair and recovery are safer under contention.** `repair --mode from-sqlite` takes the mine-lock before archiving; rebuilds preserve a verified temp collection when the live swap fails; sparse drawers with zero `embedding_metadata` rows are no longer dropped; truncated ID pagination fails loud instead of pretending success. (#2109, #2086, #2087)
|
||||
- **`repair --mode from-sqlite --dry-run` is a true preview.** It no longer archives or re-embeds; it prints per-collection would-be counts from SQLite ground truth and exits without touching the palace. Unreadable counts fail closed instead of inventing zeros. (#2133, #2095, #1654)
|
||||
- **`repair --dry-run` is a true preview in the default (legacy) mode too.** That path ignored the flag entirely and ran the real rebuild — deleting any existing `<palace>.backup`, copying the palace over it, and re-filing the drawers collection. It now prints a read-only plan and exits without opening a chromadb client, which is itself a write to `chroma.sqlite3`. The plan names the live-collection delete the rebuild performs, warns when an existing backup would be destroyed, and reports the truncation guard as disabled when `--confirm-truncation-ok` is set. An isolated FTS5 inverted-index error is reported as auto-healable instead of raising the manual-recovery abort a real run never reaches, unreadable counts fail closed with a non-zero exit, and the `--dry-run` help no longer claims to be `--mode max-seq-id` only. (#2144)
|
||||
- **`repair` and `migrate` survive a socket or a named pipe in the palace directory.** Both take a whole-directory `shutil.copytree` backup before they overwrite the palace, and `copytree` cannot duplicate a Unix domain socket left beside `chroma.sqlite3`, nor a named pipe: it copied everything else and then raised `shutil.Error`, so the command died at the backup step before any rebuild ran, and re-running only repeated it. Neither entry carries palace data, so both are now skipped and named in the output while the backup completes. Device nodes are skipped too, because the copy dereferences them instead of failing on them. Every other copy failure still aborts the command, including a symlink whose target cannot be read: no errno separates a deleted target from one on a volume that is not mounted, so an entry that may have held data is left for the copy to fail on. (#2207)
|
||||
- **HNSW divergence is preflighted before remaining `col.count()` crash sites** across mine, dedup, migrate, repair, and palace helpers. (#2093)
|
||||
- **Re-mine and conversation ingest no longer lose or duplicate drawers.** Content-hash dedup prevents duplicate LLM conversation drawers; sweeper drawers are excluded from convo extract-mode purge scope and failed purges abort; search returns round-trippable `drawer_id` values for `get_drawer`. (#2050, #2125, #2089, #2090, #2044, #2080)
|
||||
- **MCP and daemon lifecycle harden multi-agent use.** Read-only mode refuses config and checkpoint-ack tools that rewrite host state; stdio MCP exits on stdin EOF/broken pipe so orphaned sessions release locks; daemon jobs refused the palace lock are deferred instead of failed permanently. (#2126, #2103, #2101, #2072, #2029, #2014)
|
||||
- **Entity-candidate extraction no longer hangs on long ASCII runs** (base64, minified blobs) while preserving CJK/non-ASCII text. (#2127, #2065, #2063)
|
||||
- **Mining windowing rejects `chunk_overlap` above half the chunk size**, stopping infinite chunk_text loops. (#2056, #2058)
|
||||
- **`docker-compose.yml` is valid again.** The `environment:` key was declared with only comments beneath it, which YAML parses as null, so Compose rejected the whole file (`services.mcp.environment must be a mapping`) — every documented Compose command failed before starting. The key is commented out along with its examples, which now use mapping syntax so uncommenting them yields a valid block. (#2188)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Operator write-routing / single-writer recovery notes in `docs/write-routing-policy.md`. (#2079)
|
||||
- Remote-server guide wording for read-only tools that change host state. (#2126)
|
||||
- **The README's Docker section leads with the published image.** It previously documented only `docker build`, even though `ghcr.io/mempalace/mempalace` ships multi-arch — and a clone builds `develop`, not the release, so a build and a pull could differ silently. It now covers what the omissions actually cost: the MCP client config mounts a transcripts directory (without one the server starts and every mine finds nothing), the first embedding call downloads ~80 MB into `/data` and looks like a hang, bind mounts keep host ownership against the image's uid 1000 so a `0700` directory fails with a bare `PermissionError` on Linux (and `--user` is the wrong fix — `/data` is mode 700 owned by that uid), mining sources can be mounted read-only, and the GPU image is x86_64-only. (#2196)
|
||||
|
||||
### Internal
|
||||
|
||||
- **The Docker workflow runs the image before publishing it.** It previously built both images without ever starting a container or parsing a Compose file, so a green run only proved the Dockerfile compiled — which is how two defects that break the first documented command shipped past it. `scripts/docker-smoke.sh` now validates both Compose files, checks entrypoint dispatch, mines a mounted directory, asserts the drawer reads back verbatim from a separate container, and drives a real MCP stdio handshake; publication is gated on it. The script runs the same way locally: `scripts/docker-smoke.sh <image>`. (#2189)
|
||||
|
||||
---
|
||||
|
||||
## [3.6.0] — 2026-07-14
|
||||
|
||||
### Features
|
||||
|
||||
- **Turnkey secure remote / team server.** `mempalace serve` exposes the full MCP surface over HTTP with secure defaults: non-loopback binds require a bearer token, generated tokens are stored with restrictive permissions and passed through the child environment rather than argv, native TLS 1.2+ is supported, and `--read-only` both hides and refuses mutating tools. Docker Compose, systemd, and environment templates are included for deployment. (#1877, #1897, #1900)
|
||||
|
||||
- **Milvus storage backend.** The new opt-in `milvus` backend supports embedded Milvus Lite, self-hosted Milvus, and Zilliz Cloud through `pymilvus`, with COSINE vector search, native BM25 lexical search for new collections, namespace isolation, target-mismatch protection, and an optional `milvus` dependency extra. (#1899)
|
||||
|
||||
- **Atomic knowledge-graph fact replacement.** `supersede()` / `mempalace_kg_supersede` closes an open fact and opens its successor at one shared instant, so model, employer, address, and other single-valued facts can change without a hand-written invalidate/add race. Temporal queries now use half-open intervals at timestamp precision, returning only the successor at the transition boundary while preserving whole-day semantics for date-only facts. (#1913)
|
||||
|
||||
- **Conversation chronology is preserved.** Conversation drawers now retain transcript time as `authored_at` alongside ingest time, search results expose it, and exact hybrid-score ties prefer the more recently authored drawer. An idempotent, dry-run-first backfill script adds the metadata to existing palaces without re-embedding. `mempalace_list_drawers` also accepts inclusive `since` and exclusive `before` filing-time bounds. (#1890, #1891)
|
||||
|
||||
- **Mined sessions populate the associative graph.** A precision-biased, no-LLM structural extractor records code symbols, URLs, paths, and qualified identifiers as drawer entities; conversation mining then derives hallways from them. The new `mempalace hallways` CLI command exposes the graph. (#1894, #1895)
|
||||
|
||||
- **Per-project mining exclusions.** `exclude_patterns` in `mempalace.yaml` filters pre-scanned project files without changing the corrected `--limit` semantics. (#1213, #1953)
|
||||
|
||||
- **LaTeX project coverage.** `.tex` and `.bib` files are now treated as readable prose sources. (#1901)
|
||||
|
||||
### Performance
|
||||
|
||||
- **MCP initialization no longer waits for palace-wide integrity work.** stdio answers the JSON-RPC `initialize` request immediately while startup preflight runs on a background thread; a tool arriving mid-probe joins the same verdict instead of launching duplicate work. The startup SQLite probe is also skipped above a configurable size limit (512 MiB by default), while repair preflights remain strict. (#1911, #1987)
|
||||
|
||||
- **Qdrant metadata counts use server-side facets.** Taxonomy, overview, and status paths can count metadata remotely instead of fetching and tallying every record; the embedding wrapper now forwards the facet and bulk-metadata capabilities correctly. (#1868, #1898)
|
||||
|
||||
- **pgvector metadata-only fetches omit document payloads**, reducing transfer and decoding work on enumeration paths. (#1892)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Chroma recovery distinguishes derived-index damage from data loss.** Valid all-layer-0 HNSW segments with an empty `link_lists.bin` are no longer quarantined repeatedly; isolated FTS5 inverted-index corruption is rebuilt from intact content during repair and after mining; embedded NUL bytes are sanitized before reaching ChromaDB. (#1716, #1872, #1878, #1927, #1928)
|
||||
|
||||
- **SQLite recovery is bounded, atomic, and honest.** Integrity checks wait up to 15 seconds for transient writer contention instead of reporting a healthy palace as corrupt. In-place recovery archives use atomic `os.rename` rather than `shutil.move`'s destructive copy/delete fallback. `repair --mode from-sqlite` now rebuilds FTS5, vacuums, and requires a clean final `PRAGMA quick_check`; cleanup failures return non-zero with recovery details instead of printing a false success banner. (#1945, #2015, #2017)
|
||||
|
||||
- **CLI search checks HNSW divergence before opening ChromaDB.** A diverged palace routes directly to the existing SQLite BM25 fallback, avoiding embedder or collection initialization against damaged native index state while leaving healthy Chroma and non-Chroma backends on their normal vector path. (#2016)
|
||||
|
||||
- **Writer leases recover instead of stranding servers read-only.** A server refused while a peer owns the palace now retries on each mutating call and promotes itself after the peer exits. Status remains read-only and cannot acquire the writer lease, the in-process re-entrant holder set is released even across asynchronous interruption, and `checkpoint` / `delete_by_source` are correctly classified as mutations for both read-only serving and peer-writer protection. (#1923, #1930, #1934, #1960, #1970, #1971)
|
||||
|
||||
- **Status reports only checks that actually ran.** Non-Chroma backends now mark the Chroma SQLite integrity check as not applicable rather than claiming an unperformed success. (#1931, #1946)
|
||||
|
||||
- **Conversation transcripts are treated as mutable.** Appended or rewritten sessions are purged and re-filed based on modification time, preventing later turns from being silently skipped. Claude Code `tool-results/` sidecars are excluded from conversation scans so raw machine dumps cannot flood the embedding space. (#1957, #2010)
|
||||
|
||||
- **Explicit palace selection now scopes derived graph state.** Project, conversation, and format mining write hallways and tunnels beside the selected `--palace` instead of leaking them into the ambient default palace. (#2018)
|
||||
|
||||
- **Remote and local server hardening.** Non-loopback HTTP now requires a token unless an explicit insecure override is supplied; optional-provider probes cannot reuse an ambient external key before the user selects that provider; hook transcript paths and file-copy/repair paths receive stricter local validation and no-follow handling. Importing the MCP server no longer clobbers the host application's root logger, HTTP writes can re-enter the process-wide palace lock safely, and routine client disconnects no longer emit server tracebacks. (#1859, #1860, #1864, #1885, #2003, #2004)
|
||||
|
||||
- **Mining and configuration correctness.** Oversized files produce a visible stderr warning, `~` in configured palace paths expands consistently, wing slugs handle special characters, and Windows background daemon / synchronous hook mines use `CREATE_NO_WINDOW`. (#923, #1852, #1857, #1863, #1865)
|
||||
|
||||
- **`mempalace init` handles non-ASCII `.gitignore` files on Windows.** The project-file ignore guard now reads and appends UTF-8 explicitly instead of relying on locale defaults such as GBK. (#1648)
|
||||
|
||||
- **L1 wake-up surfaces the latest moments.** Drawers with equal importance are now ordered by `filed_at` recency rather than insertion order, so startup context prefers recent memories instead of the oldest ones. (#1630)
|
||||
|
||||
- **Backend detection requires a SQLite magic header** before classifying a target as Chroma or `sqlite_exact`, preventing unrelated files from being mistaken for a palace. (#1893, #1896)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added a storage-backend configuration reference, a remote/team-server deployment guide, `authored_at` migration guidance, and refreshed the OpenClaw integration for the full 36-tool MCP surface. (#1719, #1877, #1890, #1904, #1905)
|
||||
|
||||
### Internal
|
||||
|
||||
- Expanded WAL crash-safety, idempotence, and redaction-path coverage (#1869); updated GitHub Actions and Ruff; repaired `uv.lock` drift so it again matches the Ruff 0.15.20 pin and includes Python 3.9 dependency markers introduced by the Milvus resolution.
|
||||
|
||||
---
|
||||
|
||||
## [3.5.0] — 2026-06-22
|
||||
|
||||
### Features
|
||||
|
||||
- **Opt-in local daemon for queued writes.** A new `mempalace daemon` queues MemPalace writes through a single local process so background mines, diary saves, and hook-driven ingests serialize against one palace handle instead of racing for it. Opt-in and local-only — nothing binds to a public interface. (#1826)
|
||||
|
||||
- **Opt-in HTTP transport for the MCP server.** `mempalace-mcp --transport http` serves JSON-RPC at `POST /mcp` (with a `GET /healthz` liveness probe) for operators running MemPalace behind a long-lived HTTP MCP client/proxy, avoiding the long-lived-stdio framing failures of #1801. stdio remains the default and is unchanged. The transport reuses the exact stdio request dispatcher (no separate write/search path), binds `127.0.0.1` by default, and is hardened against the two ways a local HTTP server leaks to the network: it pins the `Host` header to loopback on a loopback bind and rejects any non-loopback browser `Origin` (DNS-rebinding/SSRF guard), and supports an optional bearer token via `MEMPALACE_MCP_HTTP_TOKEN` (required on `/mcp`, never on `/healthz`). A 16 MiB request cap and a loud warning when bound to a non-loopback host round it out. (#1801, #1806)
|
||||
|
||||
- **`mempalace_checkpoint` batch-save MCP tool.** Collapses multiple `add_drawer` calls plus an optional diary entry into a single MCP round-trip for agents that want to file a whole session at once. Stores content verbatim and reuses the existing idempotent add/dedup path. (#1851)
|
||||
|
||||
- **`mempalace_delete_by_source` bulk-cleanup MCP tool.** Exact-match, dry-run-by-default deletion of every drawer (and its matching closet/AAAK index entries) for a given `source_file` — the recourse for benchmark/eval files mined into the same wing as real data and drowning out search. The dry run reports the drawer and closet blast radius before anything is removed, and the commit writes a WAL audit entry. (#1722, #1729)
|
||||
|
||||
- **Optional `source_file` filter for `mempalace_search`.** Scope a search to an exact stored source path. The filter is threaded through every search path (vector, BM25/SQLite fallback, lexical union, and index-mismatch fallback) so it never silently drops a matching drawer, and results now expose the full `source_path` as a round-trippable key. (#1815, #1817)
|
||||
|
||||
- **New transcript parsers / importers.** Continue.dev session parser (#731), Gemini CLI / AI Studio JSON session import (#204), and a Pi agent JSONL session normalizer (#169).
|
||||
|
||||
- **Wider miner language coverage.** C# / .NET, PHP (#1819), Swift / Kotlin (#1368), and Java project detection including rootless subprojects (#1720).
|
||||
|
||||
- **Final mine on Claude plugin `SessionEnd`.** The Claude Code plugin now runs a closing mine when a session ends so the last exchanges are captured without waiting for the next save nudge. (#1814, #1820)
|
||||
|
||||
### Performance
|
||||
|
||||
- **Overview/status MCP tools answer from the SQLite aggregate.** Large palaces no longer time out building wing/room/status overviews — the counts come from a single SQLite aggregate instead of a client-side fetch-and-tally. (#1748, #1379)
|
||||
|
||||
- **`graph_stats` SQLite fast path.** Knowledge-graph stats are computed in SQLite rather than walking the collection, fixing large-palace timeouts. (#1379)
|
||||
|
||||
- **Embedder caps ONNX-runtime intra-op threads** so a background mine no longer pins every core. (#1068)
|
||||
|
||||
- **Backend pagination pushed into the query.** `sqlite_exact` (#1841, #1842) and `pgvector` (#1830, #1840) now apply `get(limit, offset)` in SQL, and Qdrant fetches bulk metadata in a single scroll with a larger page size (#1796, #1832).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **pgvector tolerates hostile transcript bytes.** A lone Unicode surrogate (#1833) or a NUL byte (#1829) in a transcript no longer aborts the whole mine — both are sanitized before the row is written.
|
||||
|
||||
- **SQLite read-only URIs are percent-encoded** so palace paths with spaces or special characters open correctly, and `_sqlite_graph_stats` is routed through the same `sqlite_read_uri` helper.
|
||||
|
||||
- **Stale ChromaDB HNSW divergence routes to the SQLite fallback** instead of failing the read outright. (#1816, #1822)
|
||||
|
||||
- **Diverged-index recovery now points at `repair --mode from-sqlite`, not a re-mine.** A failed ChromaDB HNSW compaction leaves the index out of sync while the rows stay intact in `chroma.sqlite3`; the old "re-mine from source" advice silently dropped MCP-added drawers and diary entries (which have no source file). Both the legacy `repair`/`rebuild_index` error messages and the `repair-status` recommendation, plus the recall skill docs, now guide users to rebuild from SQLite. (#1843, #1847, #1849)
|
||||
|
||||
- **The MCP server refuses a second writer for the same palace** rather than letting two processes race the same HNSW handle. (#1818, #1823)
|
||||
|
||||
- **Windows hook miner spawns with `CREATE_NO_WINDOW`** so background mines no longer flash a console window. (#1783, #1848)
|
||||
|
||||
- **`fact_checker` `__main__` no longer emits a runpy warning** under the test runner. (#1798)
|
||||
|
||||
### Internal
|
||||
|
||||
- Live-substrate conformance test module for pgvector (#1769); dependabot bumps for `docker/login-action` (3→4), `docker/build-push-action` (6→7), and `docker/metadata-action` (5→6) (#1788, #1787, #1786); ruff dev dependency bumped to 0.15.18.
|
||||
|
||||
---
|
||||
|
||||
## [3.4.1] — 2026-06-14
|
||||
|
||||
### Features
|
||||
|
||||
- **Cursor IDE plugin (`.cursor-plugin/`).** Drops into `~/.cursor/plugins/local/mempalace` (or installs from the Cursor marketplace once published) and auto-registers the `mempalace-mcp` server, five slash commands (`/mempalace-help`, `/mempalace-init`, `/mempalace-mine`, `/mempalace-search`, `/mempalace-status`), and the model-invocable [`mempalace` skill](skills/mempalace/SKILL.md) — no manual `~/.cursor/mcp.json` edit required. The plugin manifest deliberately omits a hardcoded `version` field — `mempalace/version.py` is the single source of truth, so there is nothing to drift on the next release (a contract test enforces the field stays absent). The canonical plugin components (`commands/`, `skills/`, `mcp.json`) are real files at the plugin root; no symlinks are committed (committed symlinks materialise as broken text files on Windows clones with `core.symlinks=false`). Mirrors the surface of [`.claude-plugin/`](.claude-plugin/) and [`.codex-plugin/`](.codex-plugin/) without duplicating their hook scripts: the Cursor hook scripts under [`hooks/cursor/`](hooks/cursor/) (shipped in the same release) remain the canonical install path for `stop` / `preCompact` / `sessionStart`, wired separately by [`hooks/cursor/install.sh`](hooks/cursor/install.sh). Contract tests in [`tests/test_cursor_plugin_manifest.py`](tests/test_cursor_plugin_manifest.py) cover manifest JSON validity, kebab-case naming, `..`-free relative paths, on-disk path resolution, marketplace alignment, MCP config shape (`mcpServers` wrapper required by Cursor, unlike Claude's flat `.mcp.json`), the version-field-absent guard, the no-symlink guard, and every skill/command frontmatter — all pure file inspection so they run on any CI platform without Cursor itself.
|
||||
|
||||
- **Cursor IDE hook support (`stop` / `preCompact` / `sessionStart`).** Three new bash hooks live under [`hooks/cursor/`](hooks/cursor/) and share a `lib/common.sh` helpers module. The save hook counts `stop` invocations per Cursor `conversation_id` and emits a `followup_message` every `MEMPAL_SAVE_INTERVAL` (default 15) so the agent files the session into MemPalace and writes a diary entry. Unlike the silent-by-default Claude Code hook, the Cursor followup fires **on by default**: Cursor's transcript format is undocumented and `normalize.py` has no Cursor parser yet, so the background `mempalace mine --mode convos` is best-effort only and the `followup_message` is the load-bearing verbatim-capture path. Users who want the Claude-style "zero tokens in the chat window" behaviour can suppress it with `MEMPAL_CURSOR_SILENT=1` (or `MEMPAL_VERBOSE=false`); the default flips to silent once a Cursor transcript parser lands. The precompact hook synchronously mines the transcript before Cursor's compaction summarises it and drops a marker so the next `stop` forces a save nudge (Cursor's `preCompact` is observational-only — it cannot block or emit a `followup_message`, unlike Claude Code's `PreCompact`); the synchronous mine is bounded by Cursor's per-hook timeout, and because `mempalace mine` is incremental/append-only a killed mine resumes cleanly on the next run rather than corrupting the palace. The wake hook is Cursor-only: `sessionStart` returns `additional_context` telling the agent to recall scoped to the wing inferred from the workspace root. Honours the same `MEMPALACE_HOOKS_AUTO_SAVE=false` kill switch as the Claude Code hooks, plus a new `MEMPAL_DISABLE_HOOK=1` alias and a `MEMPAL_STATE_DIR` env override. Per-conversation state files are garbage-collected by a daily-throttled, Cursor-namespaced TTL sweep (`MEMPAL_STATE_TTL_DAYS`, default 30) so `cursor_*.count` / `cursor_*.pending` cannot grow unbounded — shared logs and other editors' state are never touched. Includes an opt-in installer at [`hooks/cursor/install.sh`](hooks/cursor/install.sh) with `--scope user|project`, `--variant full|minimal`, `--dry-run`, and `--uninstall` (idempotent, preserves unrelated hooks via `python3`-based JSON merge — no `jq` dependency). Example wirings live at [`examples/cursor/hooks.json`](examples/cursor/hooks.json) and [`examples/cursor/hooks.minimal.json`](examples/cursor/hooks.minimal.json); they are intentionally not placed at the repo root because Cursor auto-loads project hooks from any trusted workspace and we do not arm hooks on contributor checkout. Per-event stdin/stdout schema documented at [`hooks/cursor/STDIN_SHAPE.md`](hooks/cursor/STDIN_SHAPE.md). Walkthrough at [`website/guide/cursor-hooks.md`](website/guide/cursor-hooks.md). Coverage added in [`tests/test_cursor_hooks_shell.py`](tests/test_cursor_hooks_shell.py) and [`tests/test_cursor_hooks_install.py`](tests/test_cursor_hooks_install.py).
|
||||
|
||||
- **First-class Antigravity IDE support.** New `.antigravity-plugin/` package + idempotent installer at `hooks/antigravity/install.sh` that registers MemPalace as a Google Antigravity plugin (MCP server, skill, two lifecycle hooks) at `~/.gemini/config/plugins/mempalace/`. The Stop hook background-mines the active conversation transcript every Nth fire (default 15, configurable via `MEMPAL_SAVE_INTERVAL`); the PreInvocation hook injects verbatim memory on the first model call only via Antigravity's `injectSteps[].ephemeralMessage` output, gated by `invocationNum == 1`. Both hooks are bash 3.2.57 compatible (macOS default), use the same `~/.mempalace/hook_state/` directory as the Claude Code / Codex / Cursor hooks (`antigravity_*`-namespaced state files), and respect every existing kill switch (`MEMPAL_DISABLE_HOOK`, `MEMPALACE_HOOKS_AUTO_SAVE`, `~/.mempalace/config.json` `hooks.auto_save`). Installer is `cmp`-gated (re-run produces a byte-identical install), uninstall is basename-guarded (refuses to wipe a directory whose basename isn't `mempalace`), and `--dry-run` is side-effect free. Full audit of which Antigravity surfaces we ship and which we deliberately don't is in [`hooks/antigravity/INVESTIGATION.md`](hooks/antigravity/INVESTIGATION.md). User-facing guide: [`website/guide/antigravity.md`](website/guide/antigravity.md). Standalone examples in [`examples/antigravity/`](examples/antigravity/).
|
||||
- **Zero-config interpreter resolution.** `mempal_resolve_python` now derives the Python interpreter from the `mempalace-mcp` / `mempalace` console-script shebang on `$PATH` before falling back to `python3`. The common `uv tool install mempalace` / `pipx install` layout installs the console scripts into an isolated environment whose interpreter is **not** system `python3`, so the previous `command -v python3` resolution landed on a Python that couldn't import `mempalace`, the `-m mempalace` probe failed, and mining silently never fired. Resolution is pure shebang parsing + `stat` (no Python subprocess at source time, preserving the hook performance budget). `MEMPAL_PYTHON` remains the explicit override. Documented under *How the hooks find your `mempalace` install* in the guide.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **`embeddinggemma` no longer OOM-kills bulk re-embeds.** `EmbeddinggemmaONNX.__call__` ran a single `session.run` over its entire input, so a repair-scale batch (5000 docs) allocated attention buffers far beyond available RAM and the kernel killed the process silently: `mempalace repair --yes` on an `embedding_model: embeddinggemma` palace died right after `Building temporary collection:` with no traceback and no crash report (#1770). Embedding now runs in sub-batches of 32 docs (constructor-tunable `batch_size`), matching the internal batching of ChromaDB's bundled MiniLM embedder. Per-document vectors are unchanged: the model's pooled output is attention-masked, so sub-batch padding does not affect values. The embedder is also hardened for shared use: the process-wide EF cache and the lazy model load are thread-safe (concurrent first calls build exactly one ONNX session), and `__call__` handles a bare string, `None`, and empty input without triggering the model download.
|
||||
- **Backup retention to prevent unbounded disk usage.** `mempalace migrate` (full-palace `<palace>.pre-migrate.<timestamp>` copies) and `mempalace repair max-seq-id` (`chroma.sqlite3.max-seq-id-backup-<timestamp>` copies) each wrote a fresh, full-size, timestamped backup every run and never deleted the old ones. On a machine that mines or repairs on a schedule, those copies could silently accumulate until they filled the disk — one palace was found with hundreds of GB of stale backups beside a few hundred MB of live data, hidden from a normal `du` of the home directory. A new `max_backups` setting (default `10`, env `MEMPALACE_MAX_BACKUPS`, or `config.json`) now prunes the oldest backups after each new one is written. Set it to `0` to keep every backup. Pruning is keyed by filesystem mtime, scoped strictly to each backup's own naming pattern (live data is never touched), and best-effort so a deletion failure can never abort a migration or repair that already succeeded.
|
||||
|
||||
---
|
||||
|
||||
## [3.3.6] — 2026-05-24
|
||||
|
||||
### Features
|
||||
@@ -494,7 +685,19 @@ Initial public release.
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.2.0...HEAD
|
||||
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.7.0...HEAD
|
||||
[3.7.0]: https://github.com/MemPalace/mempalace/compare/v3.6.0...v3.7.0
|
||||
[3.6.0]: https://github.com/MemPalace/mempalace/compare/v3.5.0...v3.6.0
|
||||
[3.5.0]: https://github.com/MemPalace/mempalace/compare/v3.4.1...v3.5.0
|
||||
[3.4.1]: https://github.com/MemPalace/mempalace/compare/v3.4.0...v3.4.1
|
||||
[3.4.0]: https://github.com/MemPalace/mempalace/compare/v3.3.6...v3.4.0
|
||||
[3.3.6]: https://github.com/MemPalace/mempalace/compare/v3.3.5...v3.3.6
|
||||
[3.3.5]: https://github.com/MemPalace/mempalace/compare/v3.3.4...v3.3.5
|
||||
[3.3.4]: https://github.com/MemPalace/mempalace/compare/v3.3.3...v3.3.4
|
||||
[3.3.3]: https://github.com/MemPalace/mempalace/compare/v3.3.2...v3.3.3
|
||||
[3.3.2]: https://github.com/MemPalace/mempalace/compare/v3.3.1...v3.3.2
|
||||
[3.3.1]: https://github.com/MemPalace/mempalace/compare/v3.3.0...v3.3.1
|
||||
[3.3.0]: https://github.com/MemPalace/mempalace/compare/v3.2.0...v3.3.0
|
||||
[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
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ If you're planning a significant change, open an issue first to discuss the appr
|
||||
|
||||
- **Discord**: [Join us](https://discord.com/invite/ycTQQCu6kn)
|
||||
- **Issues**: Bug reports and feature requests welcome
|
||||
- **Discussions**: For questions and ideas
|
||||
- **Questions and ideas**: Please use Discord or open an issue while GitHub Discussions are not enabled for this repo
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -72,75 +72,103 @@ pip install mempalace
|
||||
### Docker
|
||||
|
||||
A container image is also available for running the MCP server or the CLI
|
||||
without a local Python toolchain. Everything persists under `/data` (palace,
|
||||
config, and the cached embedding model), so mount a volume there.
|
||||
without a local Python toolchain. Multi-arch (amd64 + arm64), so it runs
|
||||
natively on Apple Silicon:
|
||||
|
||||
```bash
|
||||
# Build the image (CPU; bundles the `extract` + `spellcheck` extras)
|
||||
docker build -t mempalace .
|
||||
|
||||
# MCP server over stdio — note the `-i` flag (JSON-RPC needs stdin)
|
||||
docker run -i --rm -v mempalace-data:/data mempalace
|
||||
|
||||
# Run any CLI command instead (mount the host directory you want to mine)
|
||||
docker run --rm -v mempalace-data:/data -v /path/to/project:/work mempalace mine /work
|
||||
docker run --rm -v mempalace-data:/data mempalace search "why GraphQL"
|
||||
docker pull ghcr.io/mempalace/mempalace:latest
|
||||
```
|
||||
|
||||
Wire it into an MCP client (e.g. Claude Code) as a stdio server:
|
||||
Everything persists under `/data` — palace, config, and the cached embedding
|
||||
model — so mount a volume there and reuse it across runs:
|
||||
|
||||
```bash
|
||||
# MCP server over stdio — note the `-i` flag (JSON-RPC needs stdin)
|
||||
docker run -i --rm -v mempalace-data:/data ghcr.io/mempalace/mempalace
|
||||
|
||||
# Run any CLI command instead. The container only sees what you mount, so
|
||||
# mount the directory you want to mine — read-only is enough, mining never
|
||||
# writes to the source.
|
||||
docker run --rm -v mempalace-data:/data -v /path/to/project:/work:ro \
|
||||
ghcr.io/mempalace/mempalace mine /work
|
||||
docker run --rm -v mempalace-data:/data ghcr.io/mempalace/mempalace search "why GraphQL"
|
||||
```
|
||||
|
||||
The first command that needs embeddings downloads the model into `/data`
|
||||
(~80 MB for the default `minilm`, ~300 MB for `embeddinggemma`). It is a
|
||||
one-off as long as the volume persists, but it does mean the first call is
|
||||
slow and needs network — worth knowing before assuming a hung container.
|
||||
|
||||
Wire it into an MCP client (e.g. Claude Code) as a stdio server. Mount
|
||||
anything you want the server to be able to mine — it cannot reach your
|
||||
transcripts otherwise:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "-v", "mempalace-data:/data", "mempalace"]
|
||||
"args": [
|
||||
"run", "-i", "--rm",
|
||||
"-v", "mempalace-data:/data",
|
||||
"-v", "/absolute/path/to/.claude/projects:/transcripts:ro",
|
||||
"ghcr.io/mempalace/mempalace"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`docker compose run --rm mcp` works too (see `docker-compose.yml`). For
|
||||
CUDA-accelerated embeddings, build the GPU variant with
|
||||
`docker build -f Dockerfile.gpu -t mempalace:gpu .` and run it with
|
||||
`--gpus all`. Customise the bundled extras at build time, e.g.
|
||||
`docker build --build-arg EXTRAS="extract,spellcheck" -t mempalace .`.
|
||||
Use a real absolute path there — `~` and `$HOME` are not expanded by every
|
||||
MCP client. Paths are container paths from then on: mine `/transcripts`, not
|
||||
`~/.claude/projects`.
|
||||
|
||||
**Mount permissions on Linux.** The image runs as uid 1000 and bind mounts
|
||||
keep their host ownership, so a mounted directory has to be readable by that
|
||||
uid — an ordinary `0755` checkout is fine, a `0700` directory is not, and the
|
||||
failure surfaces as `PermissionError: [Errno 13]` rather than anything about
|
||||
Docker. Docker Desktop maps uids on macOS and Windows, so this only bites on
|
||||
Linux. Do **not** work around it with `--user`: `/data` is owned by uid 1000
|
||||
inside the image, so another uid cannot write the palace at all.
|
||||
|
||||
`docker compose run --rm mcp` works too (see `docker-compose.yml`), and
|
||||
`deploy/docker-compose.server.yml` stands up the team server. To build the
|
||||
image yourself instead of pulling — required for the GPU variant, which is not
|
||||
published:
|
||||
|
||||
```bash
|
||||
docker build -t mempalace . # CPU
|
||||
docker build --build-arg EXTRAS="extract,spellcheck" -t mempalace .
|
||||
docker build -f Dockerfile.gpu -t mempalace:gpu . # CUDA; run with --gpus all
|
||||
```
|
||||
|
||||
The GPU image is x86_64-only: `onnxruntime-gpu` publishes no aarch64 Linux
|
||||
wheels, so that last build fails on an ARM host (including Apple Silicon) with
|
||||
a dependency-resolution error rather than an obvious one.
|
||||
|
||||
Note that a build from a clone uses whatever branch you checked out; `develop`
|
||||
is the default branch, so pull the published image if you want the released
|
||||
version.
|
||||
|
||||
## Storage backends
|
||||
|
||||
ChromaDB is the default. For the pluggable-backend preview, MemPalace also
|
||||
ships `sqlite_exact` for local exact-vector correctness checks, and two opt-in
|
||||
external service backends — `qdrant` (REST) and `pgvector` (Postgres). The two
|
||||
external backends exercise the storage contract on different substrates (a
|
||||
REST/dict store and a SQL/JSONB store), so it is not accidentally shaped around
|
||||
one vendor.
|
||||
ChromaDB is the default and needs no configuration. MemPalace also ships a
|
||||
pluggable backend contract, exercised across deliberately different substrates
|
||||
so the contract is never accidentally shaped around one vendor. Every
|
||||
non-default backend is opt-in.
|
||||
|
||||
```bash
|
||||
# local no-service backend
|
||||
mempalace mine ~/projects/myapp --backend sqlite_exact
|
||||
| Backend | Mode | Install | Namespaces | Lexical | Configure with |
|
||||
| ------- | ---- | ------- | :--------: | :-----: | -------------- |
|
||||
| `chroma` _(default)_ | Local (embedded) | bundled | – | ✓ | – |
|
||||
| `sqlite_exact` | Local (exact) | bundled | – | ✓ | – |
|
||||
| `milvus` | Local (Lite) · Server opt-in | `mempalace[milvus]` | ✓ | ✓ | `MEMPALACE_MILVUS_URI` |
|
||||
| `qdrant` | Server (REST) | bundled | ✓ | ✓ | `MEMPALACE_QDRANT_URL` |
|
||||
| `pgvector` | Server (Postgres) | `mempalace[pgvector]` | ✓ | ✓ | `MEMPALACE_PGVECTOR_DSN` |
|
||||
|
||||
# Qdrant backend, defaulting to http://localhost:6333
|
||||
MEMPALACE_QDRANT_URL=http://localhost:6333 \
|
||||
mempalace mine ~/projects/myapp --backend qdrant
|
||||
|
||||
# Postgres + pgvector backend, defaulting to postgresql://localhost:5432/mempalace
|
||||
# needs the optional driver: pip install mempalace[pgvector]
|
||||
# and the `vector` extension available on the server
|
||||
MEMPALACE_PGVECTOR_DSN=postgresql://localhost:5432/mempalace \
|
||||
mempalace mine ~/projects/myapp --backend pgvector
|
||||
```
|
||||
|
||||
Qdrant can also be configured with `MEMPALACE_QDRANT_API_KEY`,
|
||||
`MEMPALACE_QDRANT_NAMESPACE`, and `MEMPALACE_QDRANT_TIMEOUT`; pgvector with
|
||||
`MEMPALACE_PGVECTOR_NAMESPACE`. Both external backends isolate tenants by
|
||||
namespace (advertised via the `supports_namespace_isolation` capability) and
|
||||
write a local marker (`qdrant_backend.json` / `pgvector_backend.json`) to guard
|
||||
against silently opening a palace against the wrong server.
|
||||
|
||||
When `MEMPALACE_QDRANT_URL` or `MEMPALACE_PGVECTOR_DSN` points anywhere other
|
||||
than your own local or trusted self-hosted service, MemPalace will send and
|
||||
store verbatim drawer text and metadata there. That is an explicit opt-in
|
||||
backend choice, never the default.
|
||||
Select with `--backend <name>`, `MEMPALACE_BACKEND=<name>`, or
|
||||
`"backend": "<name>"` in `config.json`. See
|
||||
[Storage backends](/guide/configuration#storage-backends) for connection
|
||||
variables, namespace behavior, and deployment notes.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -156,7 +184,8 @@ mempalace search "why did we switch to GraphQL"
|
||||
mempalace wake-up
|
||||
```
|
||||
|
||||
For Claude Code, Gemini CLI, MCP-compatible tools, and local models, see
|
||||
For Claude Code, Gemini CLI, [Antigravity](https://mempalaceofficial.com/guide/antigravity.html),
|
||||
MCP-compatible tools, and local models, see
|
||||
[mempalaceofficial.com/guide/getting-started](https://mempalaceofficial.com/guide/getting-started.html).
|
||||
|
||||
---
|
||||
@@ -224,8 +253,9 @@ Usage and tool reference:
|
||||
|
||||
## MCP server
|
||||
|
||||
29 MCP tools cover palace reads/writes, knowledge-graph operations,
|
||||
cross-wing navigation, drawer management, and agent diaries. Installation
|
||||
44 MCP tools cover palace reads/writes, knowledge-graph operations,
|
||||
cross-wing navigation, drawer management, agent diaries, and agent
|
||||
coordination (logstream events + artifact handoffs). Installation
|
||||
and the full tool list:
|
||||
[mempalaceofficial.com/reference/mcp-tools](https://mempalaceofficial.com/reference/mcp-tools.html).
|
||||
|
||||
@@ -238,8 +268,14 @@ system prompt:
|
||||
|
||||
## Auto-save hooks
|
||||
|
||||
Two Claude Code hooks save periodically and before context compression:
|
||||
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html).
|
||||
Auto-save hooks for **Claude Code, Codex CLI, and Cursor IDE** save
|
||||
periodically and before context compression:
|
||||
|
||||
- Claude Code + Codex →
|
||||
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html)
|
||||
- Cursor IDE (adds session-start recall and a transcript snapshot before
|
||||
compaction) →
|
||||
[mempalaceofficial.com/guide/cursor-hooks](https://mempalaceofficial.com/guide/cursor-hooks.html)
|
||||
|
||||
If you are installing under time pressure, start with the
|
||||
[Claude Code retention setup checklist](https://mempalaceofficial.com/guide/claude-code-retention.html):
|
||||
@@ -257,6 +293,7 @@ verbatim drawer per user/assistant message, idempotent and resume-safe.
|
||||
- Python 3.9+
|
||||
- A vector-store backend (ChromaDB by default)
|
||||
- ~300 MB disk for the embedding model. Onboarding (`python -m mempalace.onboarding`) offers `embeddinggemma-300m` (multilingual, 100+ languages, recommended) or `all-MiniLM-L6-v2` (English-only, ~30 MB). See the docstring at [`mempalace/embedding.py`](mempalace/embedding.py) for details and migration notes.
|
||||
- Optional — compute embeddings on a server instead of locally. Set `embedding_model: "openai-compat"` in `~/.mempalace/config.json` together with `embedding_api_url` / `embedding_api_model` (and `embedding_api_key` if the server needs auth) to use any OpenAI-compatible `/v1/embeddings` endpoint — LM Studio, llama.cpp, vLLM, Ollama's OpenAI shim, or a self-hosted server (e.g. a larger multilingual or GPU-served embedder). Each key is overridable via the matching `MEMPALACE_EMBEDDING_API_*` env var. When the endpoint is on your machine or LAN, no content leaves your network. Switching to it requires `mempalace repair rebuild-index` (different vector space).
|
||||
|
||||
No API key is required for the core benchmark path.
|
||||
|
||||
@@ -278,7 +315,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
<!-- Link Definitions -->
|
||||
[version-shield]: https://img.shields.io/badge/version-3.4.0-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||
[version-shield]: https://img.shields.io/badge/version-3.7.0-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/
|
||||
|
||||
@@ -665,7 +665,7 @@ def run_benchmark(
|
||||
palace_cache = json.load(f)
|
||||
print(f" Palace cache: {len(palace_cache)} room assignments loaded")
|
||||
|
||||
rerank_label = f" + LLM re-rank ({llm_model.split('-')[1]})" if llm_rerank_enabled else ""
|
||||
rerank_label = f" + LLM re-rank ({llm_model})" if llm_rerank_enabled else ""
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(" MemPal × LoCoMo Benchmark")
|
||||
|
||||
@@ -3015,7 +3015,7 @@ def run_benchmark(
|
||||
|
||||
if unique_sessions and api_key and not skip_precompute:
|
||||
print(
|
||||
f" Diary ingest: pre-computing {len(unique_sessions)} sessions with {llm_model.split('-')[1]}..."
|
||||
f" Diary ingest: pre-computing {len(unique_sessions)} sessions with {llm_model}..."
|
||||
)
|
||||
done = 0
|
||||
cache_path = Path(diary_cache_file) if diary_cache_file else None
|
||||
@@ -3051,9 +3051,8 @@ def run_benchmark(
|
||||
print(f" Data: {Path(data_file).name}")
|
||||
print(f" Questions: {len(data)}")
|
||||
print(f" Granularity: {granularity}")
|
||||
model_short = llm_model.split("-")[1] if "-" in llm_model else llm_model
|
||||
rerank_label = f" + LLM re-rank ({model_short})" if llm_rerank_enabled else ""
|
||||
diary_label = f" [diary ingest: {model_short}]" if mode == "diary" else ""
|
||||
rerank_label = f" + LLM re-rank ({llm_model})" if llm_rerank_enabled else ""
|
||||
diary_label = f" [diary ingest: {llm_model}]" if mode == "diary" else ""
|
||||
print(f" Mode: {mode}{diary_label}{rerank_label}")
|
||||
print(f"{'─' * 60}\n")
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Show comprehensive MemPalace help — available skills, MCP tools, CLI commands, hooks, and architecture.
|
||||
---
|
||||
|
||||
Invoke the `mempalace` skill from this plugin and run the `help` instructions, then follow them.
|
||||
|
||||
Concretely: run `mempalace instructions help` in a terminal, then carry out the steps it prints.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
description: Set up MemPalace — install the package, initialize a palace, register the MCP server with Cursor, and verify everything works.
|
||||
---
|
||||
|
||||
Invoke the `mempalace` skill from this plugin and run the `init` instructions, then follow them.
|
||||
|
||||
Concretely: run `mempalace instructions init` in a terminal, then carry out the steps it prints.
|
||||
|
||||
Cursor-specific extras after init:
|
||||
|
||||
1. The `mempalace-mcp` server is already auto-registered by this plugin — no manual `mcp.json` edit needed.
|
||||
2. For automatic background saves and session-start memory recall, also run `hooks/cursor/install.sh --scope user` from a cloned MemPalace repo. See `website/guide/cursor-hooks.md` for the walkthrough.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Mine projects and conversations into the MemPalace. Supports project files, conversation exports, and auto-classification.
|
||||
---
|
||||
|
||||
Invoke the `mempalace` skill from this plugin and run the `mine` instructions, then follow them.
|
||||
|
||||
Concretely: run `mempalace instructions mine` in a terminal, then carry out the steps it prints.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Search your memories across the MemPalace using semantic search with wing/room filtering.
|
||||
---
|
||||
|
||||
Invoke the `mempalace` skill from this plugin and run the `search` instructions, then follow them.
|
||||
|
||||
Concretely: run `mempalace instructions search` in a terminal, then carry out the steps it prints. The MCP tool `mempalace_search` is also available directly from this Cursor session.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Show the current state of your memory palace — wings, rooms, drawer counts, and suggestions.
|
||||
---
|
||||
|
||||
Invoke the `mempalace` skill from this plugin and run the `status` instructions, then follow them.
|
||||
|
||||
Concretely: run `mempalace instructions status` in a terminal, then carry out the steps it prints.
|
||||
@@ -0,0 +1,71 @@
|
||||
# MemPalace remote team server — MCP over HTTP, backed by a central Qdrant.
|
||||
#
|
||||
# One command stands up a shared memory service a whole team's AI clients
|
||||
# connect to. Embeddings are still produced locally inside the mempalace
|
||||
# container; only your own Qdrant ever receives the vectors and text.
|
||||
#
|
||||
# 1. cp deploy/server.env.example deploy/.env && edit deploy/.env
|
||||
# (at minimum set MEMPALACE_MCP_HTTP_TOKEN to a long random secret)
|
||||
# 2. docker compose -f deploy/docker-compose.server.yml --env-file deploy/.env up -d
|
||||
# 3. connect a client (see the Remote / Team Server guide):
|
||||
# claude mcp add --transport http mempalace http://YOUR_HOST:8765/mcp \
|
||||
# --header "Authorization: Bearer $MEMPALACE_MCP_HTTP_TOKEN"
|
||||
#
|
||||
# SECURITY: this exposes plaintext HTTP on :8765. For anything beyond a trusted
|
||||
# private network, put a TLS-terminating reverse proxy in front (nginx/Caddy/
|
||||
# Traefik) and only expose the proxy. The bearer token is mandatory for the
|
||||
# network-exposed (0.0.0.0) bind.
|
||||
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- qdrant-storage:/qdrant/storage
|
||||
# Not published to the host: only the mempalace service reaches it over the
|
||||
# internal compose network. Uncomment to inspect Qdrant directly.
|
||||
# ports:
|
||||
# - "6333:6333"
|
||||
|
||||
mempalace:
|
||||
image: ghcr.io/mempalace/mempalace:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- qdrant
|
||||
command:
|
||||
- serve
|
||||
- --host
|
||||
- "0.0.0.0"
|
||||
- --port
|
||||
- "8765"
|
||||
- --backend
|
||||
- qdrant
|
||||
# Uncomment to expose recall without write access to most clients:
|
||||
# - --read-only
|
||||
environment:
|
||||
# Required for the network-exposed bind. Set in deploy/.env.
|
||||
MEMPALACE_MCP_HTTP_TOKEN: ${MEMPALACE_MCP_HTTP_TOKEN:?set MEMPALACE_MCP_HTTP_TOKEN in deploy/.env}
|
||||
MEMPALACE_QDRANT_URL: http://qdrant:6333
|
||||
MEMPALACE_QDRANT_API_KEY: ${MEMPALACE_QDRANT_API_KEY:-}
|
||||
# Set to cuda/dml/coreml on an accelerated host (see Dockerfile.gpu).
|
||||
MEMPALACE_EMBEDDING_DEVICE: ${MEMPALACE_EMBEDDING_DEVICE:-auto}
|
||||
ports:
|
||||
- "8765:8765"
|
||||
volumes:
|
||||
- mempalace-data:/data
|
||||
healthcheck:
|
||||
# The image has no curl; use Python (always present). /healthz needs no auth.
|
||||
# If you enable TLS on the server itself, switch this to https + ssl context.
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://127.0.0.1:8765/healthz').read().strip()==b'ok' else sys.exit(1)"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
qdrant-storage:
|
||||
mempalace-data:
|
||||
@@ -0,0 +1,51 @@
|
||||
# MemPalace remote MCP server — systemd unit template.
|
||||
#
|
||||
# Install:
|
||||
# sudo useradd --system --home /var/lib/mempalace --shell /usr/sbin/nologin mempalace
|
||||
# sudo install -d -o mempalace -g mempalace -m 750 /var/lib/mempalace /etc/mempalace
|
||||
# sudo cp deploy/server.env.example /etc/mempalace/server.env # then edit + chmod 600
|
||||
# sudo install -m 600 -o mempalace -g mempalace /etc/mempalace/server.env /etc/mempalace/server.env
|
||||
# # install mempalace into a venv on PATH, or adjust ExecStart to its absolute path
|
||||
# sudo cp deploy/mempalace-server.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now mempalace-server
|
||||
#
|
||||
# This binds 0.0.0.0:8765 and requires MEMPALACE_MCP_HTTP_TOKEN (set in the
|
||||
# EnvironmentFile). Front it with a TLS-terminating reverse proxy, or set
|
||||
# MEMPALACE_MCP_TLS_CERT / _KEY in the EnvironmentFile for native TLS.
|
||||
|
||||
[Unit]
|
||||
Description=MemPalace remote MCP server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=mempalace
|
||||
Group=mempalace
|
||||
EnvironmentFile=/etc/mempalace/server.env
|
||||
ExecStart=mempalace serve --host 0.0.0.0 --port 8765
|
||||
# The idle watchdog exits with status 0, which on-failure reads as an
|
||||
# intentional stop, so the server would stay down. server.env disables the
|
||||
# watchdog; Restart=always covers a deployment that has not set it.
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
# --- Hardening ---------------------------------------------------------------
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=false
|
||||
# The palace and any local state live here; everything else is read-only.
|
||||
ReadWritePaths=/var/lib/mempalace
|
||||
StateDirectory=mempalace
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,35 @@
|
||||
# MemPalace remote server environment.
|
||||
# Copy to deploy/.env (compose) or /etc/mempalace/server.env (systemd) and edit.
|
||||
# Keep this file readable only by the service account: chmod 600.
|
||||
|
||||
# --- Required for a network-exposed (0.0.0.0) bind ------------------------------
|
||||
# Clients send: Authorization: Bearer <this value>. Generate a strong secret:
|
||||
# openssl rand -hex 32
|
||||
MEMPALACE_MCP_HTTP_TOKEN=
|
||||
|
||||
# --- Storage backend ------------------------------------------------------------
|
||||
# The team server should use a networked backend so the palace is shared.
|
||||
MEMPALACE_BACKEND=qdrant
|
||||
MEMPALACE_QDRANT_URL=http://qdrant:6333
|
||||
# MEMPALACE_QDRANT_API_KEY=
|
||||
|
||||
# --- Embedding ------------------------------------------------------------------
|
||||
# auto | cpu | cuda | dml | coreml. Use cuda on a GPU host (needs the GPU image).
|
||||
MEMPALACE_EMBEDDING_DEVICE=auto
|
||||
|
||||
# --- Optional: native TLS (otherwise terminate TLS at a reverse proxy) ----------
|
||||
# Point these at a PEM cert/key the service account can read. When set, serve
|
||||
# speaks HTTPS directly and clients connect to https://...
|
||||
# MEMPALACE_MCP_TLS_CERT=/etc/mempalace/tls/cert.pem
|
||||
# MEMPALACE_MCP_TLS_KEY=/etc/mempalace/tls/key.pem
|
||||
|
||||
# --- Server lifecycle -----------------------------------------------------------
|
||||
# The MCP server exits by itself after this many idle hours (default 8) to free
|
||||
# the ChromaDB/HNSW file handles that per-session stdio servers leave behind.
|
||||
# A dedicated always-on server does not need that, so 0 disables it. Note that
|
||||
# /healthz traffic does not count as activity; only MCP requests reset the timer.
|
||||
MEMPALACE_MCP_IDLE_HOURS=0
|
||||
|
||||
# --- Palace location (systemd / bare-metal) -------------------------------------
|
||||
# In Docker the palace lives on the mempalace-data volume by default.
|
||||
# MEMPALACE_PALACE_PATH=/var/lib/mempalace/palace
|
||||
+9
-6
@@ -21,12 +21,15 @@ services:
|
||||
tty: false
|
||||
volumes:
|
||||
- mempalace-data:/data
|
||||
environment:
|
||||
# Everything defaults correctly from HOME=/data (palace ->
|
||||
# /data/.mempalace/palace, config -> /data/.mempalace, model cache ->
|
||||
# /data/.cache). Override here only for non-default locations, e.g.:
|
||||
# - MEMPALACE_EMBEDDING_MODEL=embeddinggemma # multilingual (default: minilm)
|
||||
# - MEMPALACE_PALACE_PATH=/data/custom/palace
|
||||
# Everything defaults correctly from HOME=/data (palace ->
|
||||
# /data/.mempalace/palace, config -> /data/.mempalace, model cache ->
|
||||
# /data/.cache), so no environment block is needed. Uncomment the key
|
||||
# *together with* an entry under it for non-default locations — a bare
|
||||
# `environment:` with only comments beneath parses as null, and Compose
|
||||
# rejects the whole file with "services.mcp.environment must be a mapping".
|
||||
# environment:
|
||||
# MEMPALACE_EMBEDDING_MODEL: embeddinggemma # multilingual (default: minilm)
|
||||
# MEMPALACE_PALACE_PATH: /data/custom/palace
|
||||
|
||||
volumes:
|
||||
mempalace-data:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Authored date (`authored_at`)
|
||||
|
||||
Conversation transcripts carry a per-line ISO-8601 `timestamp` (both Claude Code and
|
||||
Codex JSONL). The miner records the most recent one per file as the drawer's
|
||||
**`authored_at`** — when the content was actually written.
|
||||
|
||||
This is distinct from the ingest date:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `filed_at` / result `created_at` | When the drawer was **mined** (written to the palace). A bulk re-mine collapses these to a single instant. |
|
||||
| `authored_at` | When the underlying content was **written**, recovered from the transcript timestamps. Survives re-mining. |
|
||||
|
||||
`authored_at` is surfaced in search results (and shown in the CLI `search` output), and is
|
||||
used as a deterministic tie-break in hybrid ranking: candidates with identical scores order
|
||||
with the more recently authored drawer first. Drawers without per-line timestamps (e.g.
|
||||
markdown) fall back to `filed_at`.
|
||||
|
||||
## Backfilling existing memory
|
||||
|
||||
New mines populate `authored_at` automatically. Drawers mined before this feature only have
|
||||
`filed_at`. Re-mining does **not** fix them — the scanner skips files already mined at the
|
||||
current `NORMALIZE_VERSION`. Two options:
|
||||
|
||||
1. **In-place backfill (recommended — no re-embedding).** `scripts/backfill_authored_at.py`
|
||||
reads each convos drawer's source transcript and updates only the `authored_at` metadata.
|
||||
Idempotent and safe to re-run; embeddings are untouched.
|
||||
|
||||
```bash
|
||||
python scripts/backfill_authored_at.py \
|
||||
--palace ~/.mempalace/palace \
|
||||
--sessions ~/.claude --sessions ~/.codex # dry run
|
||||
python scripts/backfill_authored_at.py \
|
||||
--palace ~/.mempalace/palace \
|
||||
--sessions ~/.claude --sessions ~/.codex --apply # write
|
||||
```
|
||||
|
||||
For the Docker MCP image, mount the volume and session dirs read-only — see the header of
|
||||
`scripts/backfill_authored_at.py` for the exact `docker run` invocation.
|
||||
|
||||
> Back up first: `tar czf palace-backup.tgz -C <palace-dir> .` (or snapshot the
|
||||
> `mempalace-data` volume).
|
||||
|
||||
2. **Drop and recreate.** Delete the affected drawers and re-mine the transcripts; the fresh
|
||||
mine stamps `authored_at`. Simpler, but re-embeds everything.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Hook write routing
|
||||
|
||||
Hook-triggered writes use the shared write-routing policy introduced for the
|
||||
Tier 3 rollout tracked in #1963.
|
||||
|
||||
## Scope
|
||||
|
||||
This applies to every routine write initiated by the Python hook layer:
|
||||
|
||||
- Stop-hook diary checkpoints;
|
||||
- transcript/conversation ingest;
|
||||
- project auto-ingest;
|
||||
- SessionEnd final flushes;
|
||||
- PreCompact transcript ingest;
|
||||
- PreCompact synchronous project mining.
|
||||
|
||||
It does not change CLI write routing. CLI adoption is a separate follow-up.
|
||||
|
||||
## Policy behavior
|
||||
|
||||
### `direct`
|
||||
|
||||
Hooks use the existing direct in-process or subprocess paths.
|
||||
|
||||
The daemon is not probed.
|
||||
|
||||
### `prefer`
|
||||
|
||||
Hooks use the daemon when it is already healthy.
|
||||
|
||||
If the daemon is unavailable, hooks retain the historical direct fallback.
|
||||
|
||||
### `require`
|
||||
|
||||
Hooks use the daemon when it is already healthy.
|
||||
|
||||
If the daemon is unavailable:
|
||||
|
||||
- no in-process ChromaDB write runs;
|
||||
- no direct `mempalace mine` subprocess is started;
|
||||
- no daemon is cold-started from the hook;
|
||||
- the hook log records the skipped operation;
|
||||
- the hook returns a visible `systemMessage`;
|
||||
- the Stop save marker is not advanced, allowing a later retry.
|
||||
|
||||
## Why hooks do not start the daemon
|
||||
|
||||
Hooks operate under strict latency budgets. Starting a Python daemon and its
|
||||
storage dependencies from a Stop or SessionEnd hook can exceed that budget.
|
||||
|
||||
A supervised installation using `require` must start the daemon earlier, for
|
||||
example at login, plugin initialization, or session setup:
|
||||
|
||||
mempalace daemon start
|
||||
|
||||
SessionStart performs a fast health probe in `require` mode and warns early if
|
||||
the required daemon is unavailable.
|
||||
|
||||
## One decision per hook event
|
||||
|
||||
A Stop or SessionEnd event may perform several writes:
|
||||
|
||||
1. diary checkpoint;
|
||||
2. transcript ingest;
|
||||
3. project auto-ingest.
|
||||
|
||||
The route is resolved once and stored in a context-local value for the whole
|
||||
write burst. This avoids repeated health probes and prevents different writes
|
||||
from selecting inconsistent routes during the same event.
|
||||
|
||||
## Submission ambiguity
|
||||
|
||||
Once a daemon submission is attempted, an error never triggers direct
|
||||
fallback. The daemon may have accepted the job before the client observed the
|
||||
failure; retrying directly could duplicate content.
|
||||
|
||||
## Invalid configuration
|
||||
|
||||
An explicitly invalid routing policy fails closed: hook writes are blocked and
|
||||
no direct ChromaDB fallback is attempted.
|
||||
|
||||
An unrelated configuration read/runtime failure preserves the historical
|
||||
direct-save behavior so a final checkpoint is not lost because of an
|
||||
independent configuration failure.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
The default policy remains `direct`.
|
||||
|
||||
Legacy settings remain supported through the shared policy resolver:
|
||||
|
||||
- `MEMPALACE_HOOKS_DAEMON=true` maps to `prefer`;
|
||||
- `hooks.daemon: true` maps to `prefer`;
|
||||
- false values map to `direct`.
|
||||
|
||||
## Configuration examples
|
||||
|
||||
Prefer the daemon but permit direct fallback:
|
||||
|
||||
MEMPALACE_HOOK_WRITE_ROUTING=prefer
|
||||
|
||||
Require the daemon and prohibit direct writers:
|
||||
|
||||
MEMPALACE_HOOK_WRITE_ROUTING=require
|
||||
|
||||
Configuration file:
|
||||
|
||||
{
|
||||
"write_routing": {
|
||||
"hooks": "require"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
# RFC 001 — Storage Backend Plugin Specification
|
||||
|
||||
- **Status:** Accepted (2026-06-07)
|
||||
- **Tracking issue:** [#737](https://github.com/MemPalace/mempalace/issues/737)
|
||||
- **Supersedes:** The informal seam introduced by [#413](https://github.com/MemPalace/mempalace/pull/413)
|
||||
- **Related:** [#266](https://github.com/MemPalace/mempalace/issues/266), [#574](https://github.com/MemPalace/mempalace/pull/574), [#643](https://github.com/MemPalace/mempalace/pull/643), [#665](https://github.com/MemPalace/mempalace/pull/665), [#697](https://github.com/MemPalace/mempalace/pull/697), [#700](https://github.com/MemPalace/mempalace/pull/700), [#381](https://github.com/MemPalace/mempalace/pull/381), [#1679](https://github.com/MemPalace/mempalace/pull/1679)
|
||||
- **Spec version:** `1.0`
|
||||
|
||||
> **Implementation status (2026-08-11).** The §1–2 contract surface (`PalaceRef`,
|
||||
> typed results, `BaseBackend` / `BaseCollection`, capability tokens, and most of
|
||||
> the §10 seam cleanup) landed ahead of this merge via [#1679](https://github.com/MemPalace/mempalace/pull/1679),
|
||||
> which ships in-tree `pgvector`, `qdrant`, and `sqlite_exact` backends. The three
|
||||
> areas originally deferred as follow-ups have since landed: embedder-identity
|
||||
> (§1.5 / §5, [#1731](https://github.com/MemPalace/mempalace/pull/1731) /
|
||||
> [#1734](https://github.com/MemPalace/mempalace/pull/1734)), maintenance hooks
|
||||
> (§7.3, [#1732](https://github.com/MemPalace/mempalace/pull/1732)), and searcher
|
||||
> metric-awareness (§10, [#1727](https://github.com/MemPalace/mempalace/pull/1727)).
|
||||
> Accepting this RFC pins the contract; remaining gaps are evolutionary, not
|
||||
> blocking.
|
||||
|
||||
## Summary
|
||||
|
||||
A formal contract for MemPalace storage backends so third parties can ship `pip install mempalace-<name>` packages that drop into the core without patches. The spec defines the collection interface, the backend lifecycle, registration via Python entry points, configuration shape, a required test contract, and a migration path between backends.
|
||||
|
||||
It also sets up MemPalace to run as a long-lived daemon that manages many palaces, where different palaces may route to different backends.
|
||||
|
||||
## Motivation
|
||||
|
||||
Six backend PRs are currently in flight. Each one solves the same problem six different ways — different method signatures, different registration mechanisms, different embedder ownership, incompatible where-clause dialects, no shared test suite. The ad-hoc `BaseCollection` ABC merged in #413 was deliberately minimal and deferred every non-obvious decision. This RFC closes the open decisions so backend authors can build to a stable contract.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A backend ships as a standalone Python package; installing it is sufficient to use it.
|
||||
2. All callers in MemPalace core go through the collection interface. No direct `chromadb` imports outside `mempalace/backends/chroma.py`.
|
||||
3. Backends are interchangeable: every backend passes the same shared test suite, and `mempalace migrate` supports lossless movement between them when source/target capabilities allow, with explicit re-embedding as the fallback (§8.2).
|
||||
4. The model scales from single-user local (one backend, one palace, no config) to a daemon serving many palaces with heterogeneous backends.
|
||||
5. Chroma's current dict-shaped return values are not the long-term contract. Typed results are spec v1.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Defining the embedder pipeline in detail. The embedder is a separate contract this spec depends on but does not specify.
|
||||
- Defining the sync subsystem. This spec only declares the capability flag and the minimal hook a sync subsystem will read.
|
||||
- Specifying wire protocol for a future networked daemon. That is a separate RFC.
|
||||
|
||||
---
|
||||
|
||||
## 1. Collection contract
|
||||
|
||||
### 1.1 Required methods
|
||||
|
||||
All backends implement `BaseCollection` with kwargs-only signatures:
|
||||
|
||||
```python
|
||||
class BaseCollection(ABC):
|
||||
@abstractmethod
|
||||
def add(
|
||||
self,
|
||||
*,
|
||||
documents: list[str],
|
||||
ids: list[str],
|
||||
metadatas: list[dict] | None = None,
|
||||
embeddings: list[list[float]] | None = None,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def upsert(
|
||||
self,
|
||||
*,
|
||||
documents: list[str],
|
||||
ids: list[str],
|
||||
metadatas: list[dict] | None = None,
|
||||
embeddings: list[list[float]] | None = None,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def query(
|
||||
self,
|
||||
*,
|
||||
query_texts: list[str] | None = None,
|
||||
query_embeddings: list[list[float]] | None = None,
|
||||
n_results: int = 10,
|
||||
where: dict | None = None,
|
||||
where_document: dict | None = None,
|
||||
include: list[str] | None = None,
|
||||
) -> QueryResult: ...
|
||||
|
||||
@abstractmethod
|
||||
def get(
|
||||
self,
|
||||
*,
|
||||
ids: list[str] | None = None,
|
||||
where: dict | None = None,
|
||||
where_document: dict | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
include: list[str] | None = None,
|
||||
) -> GetResult: ...
|
||||
|
||||
@abstractmethod
|
||||
def delete(
|
||||
self,
|
||||
*,
|
||||
ids: list[str] | None = None,
|
||||
where: dict | None = None,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def count(self) -> int: ...
|
||||
```
|
||||
|
||||
### 1.2 Optional methods (default implementations on the ABC)
|
||||
|
||||
```python
|
||||
def estimated_count(self) -> int:
|
||||
return self.count()
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
def health(self) -> HealthStatus:
|
||||
return HealthStatus.ok()
|
||||
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
ids: list[str],
|
||||
documents: list[str] | None = None,
|
||||
metadatas: list[dict] | None = None,
|
||||
embeddings: list[list[float]] | None = None,
|
||||
) -> None:
|
||||
"""Partial update of existing rows. At least one of documents/metadatas/embeddings must be non-None.
|
||||
|
||||
Default implementation: get(ids=...), merge the provided fields, upsert. Non-atomic
|
||||
and does two round-trips. Backends advertising `supports_update` MUST override with
|
||||
an atomic, single-round-trip implementation.
|
||||
"""
|
||||
... # default impl in the ABC
|
||||
```
|
||||
|
||||
Backends with cheap approximate counters override `estimated_count`. Backends that hold connections must override `close`. Backends with native partial-update primitives (Postgres `UPDATE`, Lance `merge_insert`) override `update` and advertise `supports_update`; the token signals "atomic + single round-trip," not "supports partial updates at all" — the default implementation already supports them, just non-atomically.
|
||||
|
||||
### 1.3 Typed results (replaces Chroma dict shape)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class QueryResult:
|
||||
ids: list[list[str]] # outer = queries, inner = hits
|
||||
documents: list[list[str]]
|
||||
metadatas: list[list[dict]]
|
||||
distances: list[list[float]]
|
||||
embeddings: list[list[list[float]]] | None = None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GetResult:
|
||||
ids: list[str]
|
||||
documents: list[str]
|
||||
metadatas: list[dict]
|
||||
embeddings: list[list[float]] | None = None
|
||||
```
|
||||
|
||||
On empty results: return a result object with empty inner lists, never raise. Specifically, an empty query returns `QueryResult(ids=[[]], documents=[[]], metadatas=[[]], distances=[[]])` — the outer dimension is the number of query vectors issued; the inner dimension is hits per query and may be zero.
|
||||
|
||||
`include` controls which fields are populated. Fields not in `include` are populated with empty lists of the correct outer shape; they are never `None` (except `embeddings`, which is `None` when not requested).
|
||||
|
||||
### 1.4 Where-clause dialect
|
||||
|
||||
**Required operators:** `$eq`, `$ne`, `$in`, `$nin`, `$and`, `$or`, `$contains`.
|
||||
|
||||
Backends that do not support full-text natively MUST still implement `$contains` via payload string match — correctness is required; performance is not. `supports_contains_fast` (§2.1) is the only performance floor the spec promises. Without it, callers and benchmarks MUST assume `$contains` is O(n). This is an intentional split: `$contains` is a correctness requirement, `contains_fast` is the performance boundary, and the gap between scan and indexed FTS is too large for the spec to paper over.
|
||||
|
||||
**Unknown operators:** backends MUST raise `UnsupportedFilterError`. Silent dropping is forbidden — it produces incorrect results.
|
||||
|
||||
**Optional operators:** `$gt`, `$gte`, `$lt`, `$lte`. Backends either implement them or reject with `UnsupportedFilterError`. Advertised via capabilities.
|
||||
|
||||
### 1.5 Embeddings
|
||||
|
||||
#### Signature compliance (all backends)
|
||||
|
||||
All backends MUST accept a pre-computed `embeddings=` argument on `add` / `upsert` without raising. This is signature compliance only — it does not guarantee the vectors are persisted (see passthrough below). Capability token: `supports_embeddings_in`.
|
||||
|
||||
Backends MUST NOT hardcode embedding models or dimensions. Model selection is the embedder's responsibility (§4).
|
||||
|
||||
#### Passthrough vs re-embed (separate guarantee)
|
||||
|
||||
Accepting the argument is not the same as honoring it. Two distinct semantics, distinguished by capability:
|
||||
|
||||
- **`supports_embeddings_passthrough`** — when `embeddings=` is provided, the backend MUST persist those vectors as-is and MUST NOT re-embed from text. This is the stronger guarantee lossless migration depends on.
|
||||
- **No `supports_embeddings_passthrough`** — the backend always re-embeds from text at write time. Provided `embeddings=` is accepted (signature compliance) but discarded. Migration *to* such a backend is re-embedding, not lossless transfer.
|
||||
|
||||
`supports_migration_export` (source-side bulk read) MUST be paired with `supports_embeddings_passthrough` (target-side lossless write) for a migration to be labeled lossless. The `mempalace migrate` CLI refuses to run between backends where the target lacks `supports_embeddings_passthrough` unless `--accept-re-embed` is passed, which records re-embedding in the target palace's migration log.
|
||||
|
||||
#### Dimension check (all backends, required)
|
||||
|
||||
Backends MUST validate embedding dimension on first write to a new collection and on open of an existing collection, and MUST raise `DimensionMismatchError` on mismatch. Silent acceptance of mismatched dimensions produces unrecoverable corruption.
|
||||
|
||||
#### Model identity check (all backends, three-state)
|
||||
|
||||
Dimension matching is necessary but not sufficient. Swapping to a different model that happens to share a dimension (e.g., both 384-d) silently degrades retrieval without tripping `DimensionMismatchError`. Backends MUST persist `embedder.model_name` alongside the collection on first write and MUST check it on subsequent open. Three outcomes:
|
||||
|
||||
| State | Condition | Required behavior |
|
||||
|---|---|---|
|
||||
| `known_match` | Stored name equals current `embedder.model_name` | Proceed normally. |
|
||||
| `known_mismatch` | Stored name exists and differs from current | Raise `EmbedderIdentityMismatchError`. Override only via explicit CLI `--force-model-swap`, which writes the swap to the palace's migration log and updates the stored identity. |
|
||||
| `unknown` | No model name recorded (legacy collection, pre-v1 palace) | Do not hard-fail — emit a `EmbedderIdentityUnknownWarning` on first open. The resolved identity is recorded on the next successful write, reindex, or migration, transitioning the palace to `known_match` going forward. CLI exposes `mempalace palace set-embedder --model NAME` for explicit resolution. |
|
||||
|
||||
The `unknown` state exists because existing palaces from #413 and earlier have no recorded identity; hard-failing them on upgrade would be hostile. Once recorded, subsequent opens are strict.
|
||||
|
||||
An injected embedder that exposes no usable `model_name` (empty or `None`) resolves to `unknown` rather than being a hard error — the backend persists no identity, emits `EmbedderIdentityUnknownWarning`, and records identity on the first open against an embedder that *does* report a name. A nameless embedder is therefore a degraded-but-valid mode, not a rejection; operators promote it with `mempalace palace set-embedder --model NAME`.
|
||||
|
||||
> **Follow-up dependency.** The `MUST persist embedder.model_name` rule above is only satisfiable once the `Embedder` protocol (§5) is a normative contract — a backend cannot persist an identity it is never handed. §5 now pins the minimal protocol (`model_name`, `dimension`, `embed`) as sufficient for this section; the embedder-identity enforcement is tracked as follow-up implementation work (the current in-tree backends defer it). See §5.
|
||||
|
||||
#### `server_embedder` backends are not exempt
|
||||
|
||||
A backend advertising `server_embedder` (§2.1) provides its own embedder and MAY ignore the `embedder=` kwarg passed to `get_collection`. That does **not** exempt it from the dimension and identity rules above. Such backends MUST:
|
||||
|
||||
- Expose an effective `model_name: str` and `dimension: int` describing the embedder actually in use (via `BaseCollection.effective_embedder_identity() -> EmbedderIdentity`).
|
||||
- Persist that effective identity on first write and validate it on open, per the three-state rules above.
|
||||
- Raise `DimensionMismatchError` and `EmbedderIdentityMismatchError` on conflicts between the effective identity and any injected `embedder` (if one was passed) or between the stored identity and the current effective identity.
|
||||
|
||||
`server_embedder` documents where the embedding happens; it never suspends the safety contract. A backend that cannot report its effective embedder identity does not qualify for the `server_embedder` capability.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend contract
|
||||
|
||||
### 2.1 Identity and capabilities
|
||||
|
||||
```python
|
||||
class BaseBackend(ABC):
|
||||
name: ClassVar[str] # "chroma", "postgres", "qdrant", ...
|
||||
spec_version: ClassVar[str] = "1.0" # which spec version this backend targets
|
||||
capabilities: ClassVar[frozenset[str]]
|
||||
distance_metric: ClassVar[str] = "cosine" # "cosine" | "l2" | "ip" (inner product)
|
||||
```
|
||||
|
||||
`distance_metric` declares the space the backend's `distances` are reported in.
|
||||
It is **not** a capability token (it is a single value, not a boolean), so it is a
|
||||
class attribute. Core search code MUST convert a backend's reported distance to a
|
||||
similarity using this declaration rather than assuming cosine — see §10, which
|
||||
adds `searcher.py` to the cleanup precisely because `_hybrid_rank` currently
|
||||
hard-codes `max(0, 1 - distance)` (cosine-only). All in-tree backends are `cosine`
|
||||
today, so the assumption is latent, not yet wrong; the declaration makes a
|
||||
non-cosine backend (e.g. a dot-product store) correct rather than silently
|
||||
mis-ranked.
|
||||
|
||||
Defined capability tokens (v1):
|
||||
|
||||
| Token | Meaning |
|
||||
|---|---|
|
||||
| `supports_embeddings_in` | Accepts pre-computed `embeddings=` without raising (signature compliance; MUST be true for all backends) |
|
||||
| `supports_embeddings_passthrough` | Persists provided `embeddings=` as-is without re-embedding (required for lossless migration target) |
|
||||
| `supports_embeddings_out` | Returns embeddings when `include=["embeddings"]` is requested |
|
||||
| `supports_estimated_count` | `estimated_count()` is meaningfully cheaper than `count()` |
|
||||
| `supports_update` | `update()` is atomic and single-round-trip (vs the ABC default of get+merge+upsert) |
|
||||
| `supports_metadata_filters` | Implements the required where-clause subset (§1.4) |
|
||||
| `supports_range_filters` | Implements `$gt` / `$gte` / `$lt` / `$lte` |
|
||||
| `supports_contains_fast` | `$contains` is indexed (vs scan-based) |
|
||||
| `supports_server_side_indexes` | Exposes index creation / maintenance to operators |
|
||||
| `supports_migration_export` | Implements a bulk read path suitable for `mempalace migrate` |
|
||||
| `supports_change_feed` | Exposes `changes_since(cursor)` for the sync subsystem |
|
||||
| `supports_sync` | Implies `supports_change_feed` plus idempotent upserts under conflicts |
|
||||
| `requires_external_service` | Needs a running server (e.g., Postgres, hosted Qdrant) |
|
||||
| `local_mode` | Persists to `palace.local_path` |
|
||||
| `server_mode` | Connects to an external server; `palace.namespace` is used |
|
||||
| `server_embedder` | Backend provides its own embedder (may ignore injected one) |
|
||||
| `supports_namespace_isolation` | Enforces `PalaceRef.namespace` as a hard isolation boundary (§4.4). Multi-tenant deployments MAY rely on it for tenant isolation; backends without it MUST NOT be relied on for that. |
|
||||
|
||||
A backend may advertise both `local_mode` and `server_mode` (e.g., Chroma with either `PersistentClient` or `HttpClient`).
|
||||
|
||||
Capability tokens are free-form strings, not an enum — third-party backends may declare novel capabilities for their ecosystem. Core MemPalace only inspects the tokens listed above.
|
||||
|
||||
### 2.2 Palace references
|
||||
|
||||
A backend serves palaces, not raw filesystem paths. This is the central change from #413.
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class PalaceRef:
|
||||
id: str # stable identity, used as cache key
|
||||
local_path: str | None = None # filesystem root, if this palace is local
|
||||
namespace: str | None = None # server-side namespace/prefix, if applicable
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `id` is always present. It is the key the backend uses to cache open handles.
|
||||
- Local-only backends read `local_path`. If `local_path is None` they raise `PalaceNotFoundError`.
|
||||
- Server-only backends read `namespace`. If `namespace is None` they derive one deterministically from `id`.
|
||||
- Mixed-mode backends may use both (e.g., a local cache alongside a server store).
|
||||
|
||||
### 2.3 Methods
|
||||
|
||||
```python
|
||||
class BaseBackend(ABC):
|
||||
@abstractmethod
|
||||
def get_collection(
|
||||
self,
|
||||
*,
|
||||
palace: PalaceRef,
|
||||
collection_name: str,
|
||||
create: bool,
|
||||
embedder: Embedder | None = None,
|
||||
options: dict | None = None,
|
||||
) -> BaseCollection: ...
|
||||
|
||||
def close_palace(self, palace: PalaceRef) -> None:
|
||||
"""Evict a single palace's cached handles. Default: no-op."""
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Shut down the entire backend instance. Default: no-op."""
|
||||
return None
|
||||
|
||||
def health(self, palace: PalaceRef | None = None) -> HealthStatus:
|
||||
"""Return health. With palace=None, probe the backend itself."""
|
||||
return HealthStatus.ok()
|
||||
```
|
||||
|
||||
### 2.4 Semantics of `create`
|
||||
|
||||
- `create=False` on a nonexistent palace MUST raise `PalaceNotFoundError` (subclass of `FileNotFoundError` for backwards compatibility with the #413 seam).
|
||||
- `create=True` MUST be idempotent — calling it repeatedly with the same arguments produces the same state and does not corrupt existing data.
|
||||
- `create=True` on local backends creates the directory with `0700` permissions (matches the existing Chroma behavior).
|
||||
|
||||
**Multiple collections per palace.** `get_collection` is keyed by `collection_name`, and a palace MAY hold more than one collection. Backends MUST support N collections per palace, addressed by distinct `collection_name` values, with the §2.5 isolation guarantee applying per `(palace.id, collection_name)`. The "palace" is not 1:1 with a collection: production already splits verbatim drawers from short, query-term-saturated session-recovery checkpoints into sibling collections (`mempalace_drawers` vs `mempalace_session_recovery`) so the latter can't dominate vector top-N. Backends like Postgres (schema/table naming) and Qdrant (collection naming) handle this trivially; a backend author MUST NOT assume one collection per palace and design themselves into a corner. No signature changes — this is already implicit in `collection_name`; it is stated here so it is a contract fact, not a convention.
|
||||
|
||||
### 2.5 Concurrency
|
||||
|
||||
A backend instance is long-lived and serves many palaces. Backends MUST be thread-safe for concurrent `get_collection` calls across different `PalaceRef.id` values. Collection handles for the same `(palace.id, collection_name)` MAY be cached internally and returned on subsequent calls.
|
||||
|
||||
Backends MAY assume a single thread accesses a given `BaseCollection` instance at a time. MemPalace core serializes access per palace; backend authors are not required to make individual collections thread-safe.
|
||||
|
||||
### 2.6 Lifecycle
|
||||
|
||||
1. `__init__`: lightweight. No I/O, no network connections. A backend instance may be constructed and never used.
|
||||
2. First call to `get_collection`: may open connections, create schemas, etc. All I/O is lazy.
|
||||
3. `close_palace(palace)`: releases cached handles for one palace. Safe to call on a palace that was never opened.
|
||||
4. `close()`: releases all resources. After `close()`, further calls MUST raise `BackendClosedError`.
|
||||
|
||||
There is no explicit `connect()` — it is always implicit and lazy, matching current Chroma behavior.
|
||||
|
||||
---
|
||||
|
||||
## 3. Registration and discovery
|
||||
|
||||
### 3.1 Entry points (primary mechanism)
|
||||
|
||||
Third-party backends ship as installable packages:
|
||||
|
||||
```toml
|
||||
# pyproject.toml of mempalace-postgres
|
||||
[project.entry-points."mempalace.backends"]
|
||||
postgres = "mempalace_postgres:PostgresBackend"
|
||||
```
|
||||
|
||||
MemPalace discovers backends at process start via `importlib.metadata.entry_points(group="mempalace.backends")`. No patches to the core are required.
|
||||
|
||||
### 3.2 In-tree registry (secondary)
|
||||
|
||||
For tests and local development:
|
||||
|
||||
```python
|
||||
from mempalace.backends.registry import register
|
||||
|
||||
register("my-experimental-backend", MyBackend)
|
||||
```
|
||||
|
||||
Entry-point discovery and explicit `register()` populate the same registry. Explicit registration wins on name conflict.
|
||||
|
||||
### 3.3 Selection priority
|
||||
|
||||
When resolving a palace's backend, priority (highest first):
|
||||
|
||||
1. Explicit `backend=` kwarg to `Palace(...)` or CLI `--backend`
|
||||
2. Per-palace `backend` key in config (see §4)
|
||||
3. `MEMPALACE_BACKEND` environment variable
|
||||
4. Auto-detect from on-disk artifacts: `chroma.sqlite3` → `chroma`, `*.lance` → `lance`, etc. Backends declare detection hints via an optional `BaseBackend.detect(path: str) -> bool` classmethod.
|
||||
5. Default: `chroma`.
|
||||
|
||||
**Auto-detection is strictly a migration/upgrade compatibility path, not a general selection mechanism.** It exists so existing palaces from v3.x keep opening without forced config migration. For *new* palaces, explicit configuration or CLI flag always wins — creating a palace without a resolved backend from (1)–(3) falls through to default (5), never to detection (4). Auto-detection fires only when a local path is presented AND no earlier rule has chosen a backend AND the path already contains backend-identifiable artifacts.
|
||||
|
||||
Note the interaction with a globally-set `MEMPALACE_BACKEND`: rule (3) sits above detection (4), so a user who exports `MEMPALACE_BACKEND=postgres` and then opens a palace containing on-disk Chroma artifacts gets postgres — the env var wins and detection is skipped. That is intended (explicit configuration overrides detection), but it means **setting `MEMPALACE_BACKEND` globally overrides existing-palace auto-detection; users opening pre-existing palaces of mixed backends should leave it unset** and rely on per-palace config or detection.
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration
|
||||
|
||||
### 4.1 Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"backends": {
|
||||
"chroma": { "type": "chroma" },
|
||||
"pg_prod": {
|
||||
"type": "postgres",
|
||||
"dsn": "postgresql://...",
|
||||
"pool_size": 10
|
||||
}
|
||||
},
|
||||
"palaces": {
|
||||
"work": {
|
||||
"backend": "pg_prod",
|
||||
"namespace": "work"
|
||||
},
|
||||
"personal": {
|
||||
"backend": "chroma",
|
||||
"local_path": "~/.mempalace/personal"
|
||||
}
|
||||
},
|
||||
"embedder": {
|
||||
"type": "onnx",
|
||||
"model": "all-MiniLM-L6-v2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Single-user local mode: all of this is optional. The absence of a config file yields one Chroma backend, one palace at the default path, with the default embedder.
|
||||
|
||||
### 4.2 Environment variables
|
||||
|
||||
- `MEMPALACE_BACKEND` — shortcut for the default backend type when there is no config.
|
||||
- `MEMPALACE_<NAME>_*` — per-backend secrets and connection info (e.g., `MEMPALACE_POSTGRES_DSN`, `MEMPALACE_QDRANT_URL`, `MEMPALACE_QDRANT_API_KEY`).
|
||||
- `<NAME>` is the backend's **type** name (the `type` field in §4.1 — `postgres`, `qdrant`), uppercased, not the per-instance config key. So the `pg_prod` instance in §4.1 reads `MEMPALACE_POSTGRES_*`, not `MEMPALACE_PG_PROD_*` — instances of the same type share one env namespace, and connection-specific values that differ per instance (distinct DSNs) belong in the config file's per-backend block, not in env. Hyphens in a type name are normalized to underscores for the env prefix (`my-backend` → `MEMPALACE_MY_BACKEND_*`).
|
||||
- Secrets MUST be readable from env vars; config files are for structure, env vars for credentials.
|
||||
|
||||
### 4.3 Backend-specific options
|
||||
|
||||
The `options` kwarg to `get_collection` is a free-form dict. Each backend documents its accepted keys. Unknown keys MUST be ignored (forward compatibility), but the backend MAY log a warning.
|
||||
|
||||
### 4.4 Multi-tenancy (absorbs #697)
|
||||
|
||||
Per-tenant collection-name prefixing is not a backend concern. It is handled by the resolver layer above backends: `PalaceRef.namespace` carries the tenant identifier. The `collection_prefix` concept from #697 dissolves into this model.
|
||||
|
||||
**Isolation contract.** `PalaceRef.id` is the *required* isolation key for every backend: within a single backend instance, a record written for one `id` MUST NOT be returned, modified, or deleted by an operation issued for a different `id`. Cross-palace access is a spec violation. This is the non-negotiable blanket MUST; multi-tenant deployments may cite it as their primary partition boundary even when they do not use `namespace`.
|
||||
|
||||
`namespace` is *additional* partitioning. A backend that advertises `supports_namespace_isolation` (§2.1) MUST extend the same guarantee to namespaces:
|
||||
|
||||
> A record written under one `namespace` MUST NOT be returned, modified, or deleted by an operation issued under a different `namespace` within the same backend instance. Cross-namespace access is a spec violation, not a caller misconfiguration.
|
||||
|
||||
This is what hosted multi-tenant deployments cite as the basis for *namespace*-level tenant isolation. Authorization (which namespaces a given request may touch) stays on the deployment side; the backend's job is to guarantee no bleed *within* the instance once the namespace is fixed.
|
||||
|
||||
Making namespace isolation a declared capability rather than a blanket `MUST` is deliberate: path-rooted local backends (e.g. `chroma`, `sqlite_exact`) already isolate by `local_path` / `id`, and forcing them to re-implement a second axis would be ceremony with no security gain.
|
||||
|
||||
**Conformance arms.** Self-attestation is not enough. The isolation suite (`tests/_backend_conformance.py` / `assert_partition_isolation`) has two distinct arms:
|
||||
|
||||
1. **Cross-`id` isolation** — required of every backend. Two `PalaceRef`s that differ only in `id` MUST NOT see each other's records.
|
||||
2. **Same-`id` / different-`namespace` isolation** — required of every backend that advertises `supports_namespace_isolation`. Two refs that share `id` (and, when applicable, `local_path`) but differ in `namespace` MUST NOT see each other's records. Distinct from arm 1; a backend that only passes arm 1 does not get to claim the capability.
|
||||
|
||||
**No silent drop.** A backend that does **not** advertise `supports_namespace_isolation` MUST NOT silently accept and ignore a populated `namespace` — that is a latent cross-namespace leak, same spirit as `UnsupportedFilterError` for unknown operators. Such backends MUST either raise (e.g. `UnsupportedCapabilityError`) when `PalaceRef.namespace` is non-`None`, or honor the namespace and advertise the capability. Callers targeting path-rooted backends MUST leave `namespace` as `None`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Embedder contract (minimal, normative here)
|
||||
|
||||
§1.5 makes persisting and checking `embedder.model_name` a hard `MUST`. A backend cannot satisfy that against an embedder that has no identity to read, so the minimal protocol below is **normative for this spec** — not deferred. A fuller Embedder RFC (batching, async, pooling, multi-vector) is tracked separately, but it is *additive*: §1.5 conformance depends only on the three members here.
|
||||
|
||||
```python
|
||||
class Embedder(Protocol):
|
||||
model_name: str # stable identity persisted and checked per §1.5
|
||||
dimension: int # validated per §1.5 dimension check
|
||||
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
||||
```
|
||||
|
||||
Backends receive an `Embedder` via `get_collection(embedder=...)`. Backends with the `server_embedder` capability MAY ignore the injected embedder but MUST still expose an effective `model_name` / `dimension` (§1.5). An embedder whose `model_name` is empty or `None` is handled as the §1.5 `unknown` state, not a hard error.
|
||||
|
||||
> **Follow-up.** The full Embedder RFC is the only external contract §1.5 leans on; it is tracked as a hard, blocking dependency of the §1.5 *implementation* (not of this spec's acceptance — the minimal protocol above closes the contract gap). Tracking issue: see §13. The current in-tree backends defer embedder-identity enforcement until that work lands.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sync (capability declaration only)
|
||||
|
||||
The sync subsystem is out of scope for this spec. What this spec defines:
|
||||
|
||||
- `supports_sync` capability flag (§2.1) — a backend advertising it agrees to implement idempotent upserts under conflict and to expose change data.
|
||||
- Optional method on `BaseCollection`:
|
||||
```python
|
||||
def changes_since(self, cursor: SyncCursor) -> Iterator[Change]: ...
|
||||
```
|
||||
- Backends without `supports_change_feed` / `supports_sync` are rejected by the sync subsystem at bind time.
|
||||
|
||||
Local single-user deployments never load the sync subsystem; non-sync-capable backends cost them nothing.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing contract
|
||||
|
||||
### 7.1 The abstract suite
|
||||
|
||||
MemPalace ships `mempalace.backends.testing.AbstractBackendContractSuite` — a pytest mixin. Every backend package ships a concrete subclass:
|
||||
|
||||
```python
|
||||
from mempalace.backends.testing import AbstractBackendContractSuite
|
||||
|
||||
class TestPostgresBackend(AbstractBackendContractSuite):
|
||||
@pytest.fixture
|
||||
def backend(self, tmp_path):
|
||||
return PostgresBackend(dsn=os.environ["TEST_PG_DSN"])
|
||||
```
|
||||
|
||||
The suite covers:
|
||||
- Round-trip for every required method
|
||||
- Empty-result shape (outer dimension preserved, inner lists empty)
|
||||
- `create=False` on missing palace raises `PalaceNotFoundError`
|
||||
- `create=True` is idempotent
|
||||
- Full required where-clause subset including `$contains`
|
||||
- Unknown operator raises `UnsupportedFilterError`
|
||||
- Dimension-mismatch detection
|
||||
- Unicode text and unicode IDs
|
||||
- Large batch writes (10k+ items)
|
||||
- Delete-then-query consistency
|
||||
- `close()` releases handles and further calls raise `BackendClosedError`
|
||||
- Concurrent `get_collection` across different palaces is safe
|
||||
- Isolation arm 1: cross-`PalaceRef.id` (every backend)
|
||||
- Isolation arm 2: same-`id` / different-`namespace` (only when `supports_namespace_isolation` is advertised; self-attestation without this arm is a conformance failure)
|
||||
- Non-advertising backends raise on a populated `namespace` rather than silently accepting it (§4.4)
|
||||
|
||||
### 7.2 Parametrized core suite
|
||||
|
||||
The existing MemPalace test suite is parametrized over all registered backends when `MEMPALACE_TEST_ALL_BACKENDS=1` is set in the environment. This is the "strongest parity claim" — if a backend passes the full core suite, it is drop-in compatible. This is expensive; local development defaults to Chroma only, CI runs all backends on a scheduled job.
|
||||
|
||||
### 7.3 Benchmark methodology hooks
|
||||
|
||||
Backend-to-backend comparisons are meaningless without accounting for per-backend maintenance state. Postgres with stale planner stats behaves very differently from Postgres post-`VACUUM ANALYZE`; HNSW-based stores behave differently before and after index compaction.
|
||||
|
||||
Backends MAY implement `maintenance_state()` returning a structured dict describing the current state (e.g., `{"autovacuum_age_seconds": 42, "last_analyze": "...", "index_build_complete": true}`), and `run_maintenance(kind: str)` to trigger supported kinds. Both are optional.
|
||||
|
||||
Supported maintenance kinds MUST be advertised via a class-level frozenset:
|
||||
|
||||
```python
|
||||
class BaseBackend(ABC):
|
||||
maintenance_kinds: ClassVar[frozenset[str]] = frozenset()
|
||||
```
|
||||
|
||||
The spec reserves the kind names `"analyze"` (update planner/query statistics), `"compact"` (reclaim space, rewrite storage), and `"reindex"` (rebuild secondary indexes). Backends MAY add their own kinds; the reserved names MUST mean what the spec says if advertised. A backend that has no analogue for a reserved kind MUST omit it from `maintenance_kinds` rather than declaring it as a no-op — otherwise a benchmark harness sees `"analyze"` advertised and assumes it did what the spec says when the implementation did nothing.
|
||||
|
||||
`run_maintenance(kind)` MUST raise `UnsupportedMaintenanceKindError` when called with a kind not in `maintenance_kinds`. Advertising a kind without implementing it is a conformance failure.
|
||||
|
||||
**`run_maintenance` is observable, not fire-and-forget.** This resolves the §12 open question, and it is driven by a production failure: on a lazy-index path, multiple daemon writers crossing the index-build threshold in the same window each issued the build, stacked an `ACCESS EXCLUSIVE` lock, and blocked writes for the whole build (fixed in-backend with a session-level advisory lock). A pure fire-and-forget call reproduces that race — if the call cannot report "already running," concurrent callers re-trigger the build. Therefore:
|
||||
|
||||
- `run_maintenance(kind)` MUST be safe to call concurrently. A backend MUST serialize same-kind maintenance internally (advisory lock, build flag, or equivalent) so a second caller does not start a duplicate operation.
|
||||
- It returns a structured `MaintenanceResult` (e.g. `{"kind": str, "status": "ran" | "already_running" | "noop", "stats": {...}}`) rather than `None`. `stats` is free-form per kind (rows analyzed, bytes reclaimed, fragments merged). `already_running` is how a concurrent caller learns it must not re-trigger.
|
||||
|
||||
This makes the maintenance hook the operator-safe path the lazy-index concurrency wedge requires, and gives the benchmark harness (above) machine-readable phase data.
|
||||
|
||||
The benchmark harness under [benchmarks/](../../benchmarks/) records `maintenance_state()` alongside every latency/recall measurement it publishes. Published numbers MUST include three phases: immediately after bulk load, after the backend's native background maintenance has caught up, and after `run_maintenance(kind)` has been called for each kind in `maintenance_kinds`. Harnesses rely on this advertisement to decide what to call — they MUST NOT assume kind names. This prevents comparing an un-`ANALYZE`d Postgres to a settled Chroma and calling the former slow.
|
||||
|
||||
### 7.4 ID stability for non-string-ID backends
|
||||
|
||||
Backends requiring UUID IDs (Qdrant) use a canonical namespace:
|
||||
|
||||
```python
|
||||
NAMESPACE_MEMPALACE = uuid.UUID("c06c3fc7-5c14-4dc4-84c2-24a5f72d8dc1")
|
||||
backend_id = uuid.uuid5(NAMESPACE_MEMPALACE, original_id)
|
||||
```
|
||||
|
||||
The namespace UUID is fixed at spec v1 adoption and recorded here — once, for all time. This value is the one the in-tree `qdrant` backend already shipped with in #1679, so existing Qdrant palaces' point IDs already derive from it; promoting the shipped constant to canonical (rather than minting a fresh one) avoids re-deriving any deployed palace's IDs. New UUID-ID backends MUST use this exact namespace. This resolves the #700 vs #381 divergence.
|
||||
|
||||
---
|
||||
|
||||
## 8. Migration
|
||||
|
||||
### 8.1 The CLI
|
||||
|
||||
```
|
||||
mempalace migrate --palace PATH --from chroma --to postgres
|
||||
mempalace migrate --all --to lance
|
||||
```
|
||||
|
||||
Implementation is backend-agnostic: reads from source via `BaseCollection.get(include=["documents", "metadatas", "embeddings"])`, writes to target via `BaseCollection.upsert(...)` with the original embeddings. No backend-specific migration code.
|
||||
|
||||
### 8.2 Lossless vs re-embed
|
||||
|
||||
Migration is labeled **lossless** only when:
|
||||
|
||||
- The source advertises `supports_migration_export` (bulk read includes embeddings), AND
|
||||
- The target advertises `supports_embeddings_passthrough` (persists provided embeddings as-is), AND
|
||||
- Source and target agree on `embedder.model_name` (or `--force-model-swap` is explicit).
|
||||
|
||||
If the target lacks `supports_embeddings_passthrough`, `mempalace migrate` refuses to run. Passing `--accept-re-embed` overrides — the migration proceeds but re-embeds from document text at write time, and the migration record labels the result as re-embedded rather than lossless. Retrieval quality may shift.
|
||||
|
||||
A backend that persists the exact float32 vector in its own durable store (rather than re-embedding from text on read) satisfies **both** sides of the lossless pairing: it qualifies for `supports_migration_export` as a source (it can hand back the verbatim vector it stored) and for `supports_embeddings_passthrough` as a target (it persists provided vectors as-is). Such "rank-from-stored-vectors" backends are first-class migration endpoints in both directions — the pairing in this section is about the two *capabilities* being present, not about any particular index structure. An exact-vector store therefore makes `chroma → <store>` and `<store> → chroma` lossless under §8 in both directions, given model-identity agreement.
|
||||
|
||||
### 8.3 Safety
|
||||
|
||||
- Source is never modified. Migration is read-only against the source backend.
|
||||
- Target palace must not already exist unless `--overwrite` is passed.
|
||||
- A successful migration writes a `.mempalace-migration.json` record into the target palace containing: source backend name, source path/ref, timestamp, row count, `lossless: true|false`, source and target `embedder.model_name`, and whether `--force-model-swap` or `--accept-re-embed` was used.
|
||||
|
||||
### 8.4 Verification
|
||||
|
||||
After migration, run `mempalace verify --palace PATH --against SOURCE_PATH --source-backend chroma`. This samples N rows and confirms round-trip parity (ids match, documents match, embedding cosine similarity ≥ 0.999 when the migration was lossless; a looser document-overlap check when re-embedded).
|
||||
|
||||
---
|
||||
|
||||
## 9. Versioning and compatibility
|
||||
|
||||
- `BaseBackend.spec_version` declares which spec version a backend implements.
|
||||
- MemPalace refuses to load a backend declaring a different major version. The failure is loud and names the mismatch, e.g.:
|
||||
```python
|
||||
raise BackendVersionMismatchError(
|
||||
f"backend {name!r} targets spec {backend.spec_version!r}; "
|
||||
f"this MemPalace implements major version {CORE_MAJOR!r}. "
|
||||
f"Install a build of {name!r} that targets spec {CORE_MAJOR}.x."
|
||||
)
|
||||
```
|
||||
- Minor versions are additive (new optional methods, new capability tokens). Backends declaring an older minor continue to work.
|
||||
- This is spec v1.0.
|
||||
|
||||
---
|
||||
|
||||
## 10. Cleanup prerequisite (mostly landed via #1679)
|
||||
|
||||
**Status update (2026-06-07).** The bulk of this cleanup landed in [#1679](https://github.com/MemPalace/mempalace/pull/1679): the direct `chromadb` *client* imports across `repair.py`, `dedup.py`, `cli.py`, `mcp_server.py`, and `migrate.py` were routed through `BaseCollection`, and the dict-to-typed-result migration (§1.3) shipped. What remains is narrow:
|
||||
|
||||
- **Residual exception-class imports.** `mcp_server.py` and `repair.py` still `from chromadb.errors import NotFoundError` to catch Chroma's not-found error. These are catch-site couplings, not client construction; they should resolve to a backend-neutral `PalaceNotFoundError` / collection-not-found exception from the contract (§2.4) so non-Chroma palaces raise the same type.
|
||||
- **`mcp_server._get_client()` caching.** Caches a `PersistentClient` at module scope and invalidates it on `chroma.sqlite3` inode or mtime changes (merged via [#757](https://github.com/MemPalace/mempalace/pull/757)). Both the cache and the stat-based freshness check are Chroma-specific. They should migrate into `ChromaBackend.get_collection()` (§2.5, handle caching) and `ChromaBackend.close_palace()` (§2.6, explicit flush) — other backends do not have a single on-disk SQLite file to stat. The `mempalace_reconnect` MCP tool then becomes a thin wrapper around `backend.close_palace(palace_ref)`.
|
||||
|
||||
**`searcher.py` is the highest-leakage module still coupled, and #1679 did not cover it** (flagged by two backend authors — kostadis-ntnx on this RFC, jphein on #1679). Two concrete couplings:
|
||||
|
||||
- `_hybrid_rank()` hard-codes `vec_sim = max(0, 1 - distance)`, i.e. it assumes cosine distance ∈ [0, 2]. For a backend whose metric isn't cosine (dot-product, L2) the ranking is silently wrong. Fix: make the conversion metric-aware off the backend-declared `distance_metric` (§2.1) rather than hard-coding cosine. All in-tree backends are cosine today, so this is latent — but it is a contract gap, not a coincidence to keep relying on.
|
||||
- The BM25 survival fallback opens `chroma.sqlite3` directly and queries Chroma's FTS5 `embedding_fulltext_search` shadow table. On any non-Chroma palace this path is dead. Fix: route lexical fallback through `BaseCollection` (§1.4 `$contains` / a lexical capability), or gate it on a backend-declared capability so backends whose index self-heals never request it.
|
||||
|
||||
This `searcher.py` work is tracked as follow-up implementation (see §13) — it is the remaining piece that promotes a non-Chroma backend from "stores and retrieves" to a first-class search peer.
|
||||
|
||||
---
|
||||
|
||||
## 11. Impact on in-flight PRs
|
||||
|
||||
**Update (2026-06-07).** The first wave of in-tree backends — `pgvector`, `qdrant`, `sqlite_exact` — landed via [#1679](https://github.com/MemPalace/mempalace/pull/1679) implementing the §1–2 contract surface directly, ahead of this RFC's merge. That reshapes the table below: the community PRs now rebase against both the spec *and* the shipped in-tree backends, and the prediction that `collection_prefix` would dissolve into `PalaceRef.namespace` (#697) held.
|
||||
|
||||
| PR | Status | Effort to align |
|
||||
|---|---|---|
|
||||
| [#574](https://github.com/MemPalace/mempalace/pull/574) LanceDB | Open | Closest to final shape. Needs `PalaceRef` and typed results (now both shipped in-tree — rebase against the merged ABC). |
|
||||
| [#665](https://github.com/MemPalace/mempalace/pull/665) Postgres (`pg_sorted_heap`) | Open | The in-tree `pgvector` backend (#1679) now occupies the basic Postgres slot; #665 rebases as the optional `pg_sorted_heap` performance variant. Decouple embedder; adopt the merged `PalaceRef`. Its session-level advisory-lock fix for the lazy-index concurrency wedge is the production basis for §7.3's observable `run_maintenance`. |
|
||||
| [#700](https://github.com/MemPalace/mempalace/pull/700) Qdrant | Open | Largely superseded by the in-tree `qdrant` backend (#1679), which already uses the §7.4 canonical namespace. Reconcile or close in favor of the merged backend. |
|
||||
| [#381](https://github.com/MemPalace/mempalace/pull/381) Qdrant (older) | Open | Same as #700; subclass the merged `BaseCollection` rather than a bare `Protocol`, or close as superseded. |
|
||||
| [#643](https://github.com/MemPalace/mempalace/pull/643) PalaceStore | Closed | POC; the parametrized-test approach it explored became the standard (§7.2). |
|
||||
| [#697](https://github.com/MemPalace/mempalace/pull/697) Chroma HttpClient + prefix | Closed | `collection_prefix` dissolved into `PalaceRef.namespace` + `supports_namespace_isolation` (§4.4), as predicted. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Resolved decisions
|
||||
|
||||
The three questions that were open at draft are resolved for v1, informed by the backend-author reviews:
|
||||
|
||||
- **`changes_since` accepts a collection filter — yes.** It takes an optional `collection_name` so a sync caller can request changes for one collection (LanceDB and others already track changes per table; filtering by collection is natural). Additive on the §6 optional hook.
|
||||
- **Per-palace capabilities — no; capabilities stay static/class-level.** Making `supports_*` palace-dependent turns every capability check into "maybe — depends," pushing state tracking onto every caller. A backend that builds indexes lazily either guarantees the index exists when the capability is needed (and advertises it) or doesn't advertise it. The one isolation-by-deployment case (`supports_contains_fast` where an FTS index may or may not exist) is handled by the backend either guaranteeing the floor or omitting the token — not by per-palace variance. (Note: `supports_namespace_isolation` is still a static, class-level token — it declares what the backend *enforces*, not a per-palace fact.)
|
||||
- **`run_maintenance(kind)` returns a structured, observable result — resolved in §7.3.** It is not fire-and-forget: it returns `MaintenanceResult` and must serialize concurrent same-kind calls (the production lazy-index concurrency wedge is why). See §7.3.
|
||||
|
||||
No remaining blockers. Genuinely additive future items (new optional methods, new tokens) land under the minor-version rule (§9).
|
||||
|
||||
---
|
||||
|
||||
## 13. Rollout
|
||||
|
||||
The original draft sequenced cleanup → spec → ChromaBackend → in-flight rebase → migrate CLI. In practice the contract surface and the first backends shipped together in [#1679](https://github.com/MemPalace/mempalace/pull/1679) ahead of this merge, so the remaining sequence is what's left, not the whole list.
|
||||
|
||||
**Done (via #1679):**
|
||||
1. ✅ §10 seam cleanup (client imports routed through `BaseCollection`; typed-result migration §1.3).
|
||||
2. ✅ `BaseBackend` / `BaseCollection`, `PalaceRef`, capability tokens, entry-point/registry discovery.
|
||||
3. ✅ In-tree `pgvector`, `qdrant`, `sqlite_exact` backends + `tests/test_backend_conformance.py`.
|
||||
4. ✅ Canonical §7.4 namespace pinned to the shipped value.
|
||||
|
||||
**Remaining (tracked as follow-up issues):**
|
||||
5. **Embedder-identity contract (§1.5 / §5)** — [#1724](https://github.com/MemPalace/mempalace/issues/1724). Land the minimal `Embedder` protocol as a real injected dependency; persist + check `model_name` per the three-state model. *Hard dependency for §1.5 conformance.*
|
||||
6. **Maintenance hooks (§7.3)** — [#1725](https://github.com/MemPalace/mempalace/issues/1725). Implement `maintenance_kinds` / `maintenance_state()` / observable `run_maintenance()` in the in-tree backends; wire the §7.3 advisory-lock serialization (`pg_sorted_heap` / #665 has the reference fix).
|
||||
7. **`searcher.py` metric-awareness + lexical fallback (§10)** — [#1726](https://github.com/MemPalace/mempalace/issues/1726). Make `_hybrid_rank` read `distance_metric`; route the BM25 fallback through `BaseCollection` instead of `chroma.sqlite3`.
|
||||
8. Residual §10 items: backend-neutral not-found exception; migrate `mcp_server._get_client()` caching into `ChromaBackend`.
|
||||
9. Finish the `mempalace migrate` CLI (§8) and the parametrized core suite gate (§7.2, `MEMPALACE_TEST_ALL_BACKENDS=1` in CI).
|
||||
10. Rebase / reconcile the remaining community backend PRs (§11) against the merged contract.
|
||||
11. Update [ROADMAP.md](../../ROADMAP.md) with spec v1.0 adoption under v4.0.0-alpha.
|
||||
@@ -0,0 +1,505 @@
|
||||
# RFC 003: Agent Logstream Coordination
|
||||
|
||||
Status: Implemented (phases 1-5). Phase 5 SSE shipped as GET /logstream/stream on the hub HTTP transport (bearer-authenticated, event_list filter set, since_event_id/Last-Event-ID resume, 15s heartbeats, bounded clients); logstream tools additionally dispatch outside the global HTTP request lock so long-polls cannot starve the hub. Server-side cursor storage and drawer compaction remain future work.
|
||||
Owner: Claude Fable 5
|
||||
Created: 2026-07-01
|
||||
Branch: `feat/shared-brain-dogfood`
|
||||
|
||||
## Summary
|
||||
|
||||
MemPalace should become a local-first coordination substrate for agents, not only a long-term recall system. Agents running on different machines should be able to delegate work, wait for replies, exchange patches or other artifacts, and subscribe to realtime coordination messages through the shared MemPalace hub.
|
||||
|
||||
This RFC proposes **MemPalace Logstream**: a small append-only event layer served by the existing MemPalace MCP HTTP hub. It preserves the core MemPalace promise of durable, local, exact storage while adding coordination primitives agents can use without Igor manually relaying messages between them.
|
||||
|
||||
## Motivation
|
||||
|
||||
The shared-brain dogfood exposed a real workflow gap:
|
||||
|
||||
1. Mac Codex wrote a delegation packet into `shared_agent_brain/delegation`.
|
||||
2. Windows Codex found it, did the work, and wrote back status.
|
||||
3. Windows Codex forgot to push the code.
|
||||
4. Mac Codex could read the status but could not receive the actual patch through a formal channel.
|
||||
|
||||
The memory layer worked. The coordination layer was implicit and fragile.
|
||||
|
||||
We need a first-class way for agents to:
|
||||
|
||||
- create durable work packets;
|
||||
- subscribe or wait for matching replies;
|
||||
- submit patch/file artifacts before they are committed;
|
||||
- acknowledge, apply, reject, or supersede handoffs;
|
||||
- keep enough structured metadata to avoid ambiguity across machines, branches, and worktrees.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not build a cloud service.
|
||||
- Do not require external APIs, hosted queues, Redis, Postgres, or SaaS infra.
|
||||
- Do not replace Git for final source control.
|
||||
- Do not make agents apply patches silently without explicit caller intent.
|
||||
- Do not weaken verbatim drawer storage. Event payloads and artifacts must remain exact.
|
||||
- Do not add telemetry or phone-home behavior.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Local-first**: the event bus runs inside the existing MemPalace hub and is reachable over loopback, LAN, or tailnet according to the user's deployment.
|
||||
- **Append-only by default**: events are immutable. Corrections are new events that reference prior events.
|
||||
- **Exact payloads**: event bodies and artifacts are stored verbatim.
|
||||
- **Structured envelopes**: agents should not infer core routing fields from prose.
|
||||
- **Durable before realtime**: every realtime message must also be recoverable after reconnect.
|
||||
- **Small useful v1**: favor long-poll and polling over a large streaming subsystem if that ships faster.
|
||||
- **No hidden authority**: patch application and destructive actions must be explicit local operations.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Event
|
||||
|
||||
An event is a structured coordination message. It has routing metadata plus an optional verbatim body.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `id`: stable event id, generated by the server.
|
||||
- `type`: event type, such as `task.request`, `task.reply`, `patch.ready`, `patch.applied`.
|
||||
- `stream`: logical stream name. Suggested format: `project/<project-name>` or `shared_agent_brain`.
|
||||
- `room`: sub-channel inside a stream, such as `delegation`, `patches`, `reviews`, `status`.
|
||||
- `from_agent`: writer identity, supplied by caller.
|
||||
- `created_at`: server timestamp.
|
||||
|
||||
Common optional fields:
|
||||
|
||||
- `to_agent`: target agent or `*`.
|
||||
- `correlation_id`: task or conversation id tying request/reply events together.
|
||||
- `branch`: Git branch, when relevant.
|
||||
- `base_commit`: Git commit the work started from.
|
||||
- `status`: `open`, `claimed`, `ready`, `applied`, `blocked`, `failed`, `superseded`.
|
||||
- `artifact_ids`: patch/file artifact references.
|
||||
- `body`: verbatim human-readable content.
|
||||
|
||||
### Artifact
|
||||
|
||||
An artifact is exact content attached to an event. Examples:
|
||||
|
||||
- unified diff patches;
|
||||
- generated files;
|
||||
- test logs;
|
||||
- benchmark reports;
|
||||
- screenshots encoded by reference, not necessarily inline;
|
||||
- structured JSON payloads.
|
||||
|
||||
Artifacts must have:
|
||||
|
||||
- `id`;
|
||||
- `kind`: `patch`, `file`, `log`, `json`, `note`;
|
||||
- `sha256`;
|
||||
- `size_bytes`;
|
||||
- `content`;
|
||||
- `created_at`;
|
||||
- `created_by`.
|
||||
|
||||
### Cursor
|
||||
|
||||
Each agent may maintain a cursor per stream so it can ask, "what happened since I last checked?"
|
||||
|
||||
Cursor state can be server-side later. For v1, it is acceptable for the client to pass `since_event_id` or `since_created_at`.
|
||||
|
||||
## Event Envelope
|
||||
|
||||
Example event:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "evt_01J...",
|
||||
"type": "patch.ready",
|
||||
"stream": "project/mempalace",
|
||||
"room": "patches",
|
||||
"from_agent": "windows-codex",
|
||||
"to_agent": "mac-codex",
|
||||
"correlation_id": "task_01J...",
|
||||
"branch": "feat/shared-brain-dogfood",
|
||||
"base_commit": "2668053",
|
||||
"status": "ready",
|
||||
"artifact_ids": ["art_01J..."],
|
||||
"body": "Search ranking patch is ready. Tests passed on Windows.",
|
||||
"created_at": "2026-07-01T20:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Example patch artifact:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "art_01J...",
|
||||
"kind": "patch",
|
||||
"sha256": "abc123...",
|
||||
"size_bytes": 18422,
|
||||
"created_by": "windows-codex",
|
||||
"created_at": "2026-07-01T20:00:00Z",
|
||||
"content": "diff --git a/mempalace/searcher.py b/mempalace/searcher.py\n..."
|
||||
}
|
||||
```
|
||||
|
||||
## Storage Model
|
||||
|
||||
Use SQLite in the active palace directory:
|
||||
|
||||
- `logstream.sqlite3`
|
||||
|
||||
Tables:
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
stream TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
from_agent TEXT NOT NULL,
|
||||
to_agent TEXT,
|
||||
correlation_id TEXT,
|
||||
branch TEXT,
|
||||
base_commit TEXT,
|
||||
status TEXT,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX events_stream_created_idx ON events(stream, created_at);
|
||||
CREATE INDEX events_correlation_idx ON events(correlation_id, created_at);
|
||||
CREATE INDEX events_to_agent_idx ON events(to_agent, created_at);
|
||||
CREATE INDEX events_type_idx ON events(type, created_at);
|
||||
|
||||
CREATE TABLE artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX artifacts_sha256_idx ON artifacts(sha256);
|
||||
|
||||
CREATE TABLE event_artifacts (
|
||||
event_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
PRIMARY KEY (event_id, artifact_id)
|
||||
);
|
||||
```
|
||||
|
||||
Rationale:
|
||||
|
||||
- Keep coordination separate from Chroma/vector search for reliability and latency.
|
||||
- Avoid forcing realtime coordination through semantic search.
|
||||
- Make append/list/wait cheap and deterministic.
|
||||
- Optionally mirror summaries into normal drawers later for recall, but do not depend on it.
|
||||
|
||||
## MCP Tool Surface
|
||||
|
||||
### `mempalace_event_append`
|
||||
|
||||
Append one event.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "task.request",
|
||||
"stream": "project/mempalace",
|
||||
"room": "delegation",
|
||||
"from_agent": "mac-codex",
|
||||
"to_agent": "windows-codex",
|
||||
"correlation_id": "task_...",
|
||||
"branch": "feat/shared-brain-dogfood",
|
||||
"base_commit": "2668053",
|
||||
"status": "open",
|
||||
"body": "Please fix search echo ranking.",
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"event": { "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### `mempalace_event_list`
|
||||
|
||||
List events with structured filters.
|
||||
|
||||
Filters:
|
||||
|
||||
- `stream`
|
||||
- `room`
|
||||
- `type`
|
||||
- `to_agent`
|
||||
- `from_agent`
|
||||
- `correlation_id`
|
||||
- `since_event_id`
|
||||
- `since_created_at`
|
||||
- `limit`
|
||||
|
||||
Default limit: 50.
|
||||
|
||||
### `mempalace_event_wait`
|
||||
|
||||
Block until a matching event exists or timeout expires.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"stream": "project/mempalace",
|
||||
"to_agent": "mac-codex",
|
||||
"correlation_id": "task_...",
|
||||
"type": "patch.ready",
|
||||
"since_event_id": "evt_...",
|
||||
"timeout_ms": 60000
|
||||
}
|
||||
```
|
||||
|
||||
Implementation for v1:
|
||||
|
||||
- simple polling loop inside the MCP request;
|
||||
- sleep interval 250-1000 ms with jitter;
|
||||
- max timeout 5 minutes;
|
||||
- return `{ "timed_out": true, "events": [] }` rather than error on timeout.
|
||||
|
||||
`mempalace_event_wait` remains the polling MCP tool. Clients that can hold an
|
||||
HTTP stream should use `GET /logstream/stream` SSE for live tailing.
|
||||
|
||||
### `mempalace_event_ack`
|
||||
|
||||
Append an acknowledgement event.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_...",
|
||||
"from_agent": "mac-codex",
|
||||
"status": "applied",
|
||||
"body": "Patch applied and tests passed."
|
||||
}
|
||||
```
|
||||
|
||||
This should create a new `event.ack` event with `correlation_id` copied from the target event.
|
||||
|
||||
### `mempalace_artifact_put`
|
||||
|
||||
Store exact artifact content.
|
||||
|
||||
Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "patch",
|
||||
"created_by": "windows-codex",
|
||||
"content": "diff --git ...",
|
||||
"metadata": {
|
||||
"branch": "feat/shared-brain-dogfood",
|
||||
"base_commit": "2668053"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns `id`, `sha256`, `size_bytes`.
|
||||
|
||||
### `mempalace_artifact_get`
|
||||
|
||||
Fetch an artifact by id. Returns exact content and metadata.
|
||||
|
||||
### `mempalace_patch_submit`
|
||||
|
||||
Convenience wrapper:
|
||||
|
||||
1. store patch artifact;
|
||||
2. append `patch.ready` event referencing the artifact;
|
||||
3. return both ids.
|
||||
|
||||
This is optional for v1 if `artifact_put` + `event_append` are enough.
|
||||
|
||||
## HTTP Endpoint Surface
|
||||
|
||||
MCP tools are sufficient for v1. Add HTTP streaming later.
|
||||
|
||||
Future endpoints:
|
||||
|
||||
- `GET /logstream/events?stream=...&since=...`
|
||||
- `GET /logstream/stream?stream=...` using Server-Sent Events.
|
||||
|
||||
Do not add unauthenticated logstream endpoints. They expose work metadata and patch contents.
|
||||
|
||||
## Agent Workflow Examples
|
||||
|
||||
### Delegation With Wait
|
||||
|
||||
Mac Codex:
|
||||
|
||||
1. `mempalace_event_append(type=task.request, to_agent=windows-codex, correlation_id=task_123)`
|
||||
2. `mempalace_event_wait(correlation_id=task_123, type=patch.ready, to_agent=mac-codex, timeout_ms=300000)`
|
||||
3. `mempalace_artifact_get(artifact_id)`
|
||||
4. Apply patch locally.
|
||||
5. Run tests.
|
||||
6. `mempalace_event_ack(status=applied)`.
|
||||
|
||||
Windows Codex:
|
||||
|
||||
1. `mempalace_event_wait(to_agent=windows-codex, type=task.request, timeout_ms=300000)`
|
||||
2. Do work.
|
||||
3. `mempalace_patch_submit(type=patch.ready, to_agent=mac-codex, correlation_id=task_123)`.
|
||||
|
||||
### Status-Only Handoff
|
||||
|
||||
If an agent cannot produce a patch, it can still append `task.reply` or `task.blocked` with exact notes and evidence.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Durable Logstream Core
|
||||
|
||||
Add:
|
||||
|
||||
- `mempalace/logstream.py`
|
||||
- SQLite schema creation/migration inside the active palace dir.
|
||||
- Pure Python API:
|
||||
- `append_event(...)`
|
||||
- `list_events(...)`
|
||||
- `wait_events(...)`
|
||||
- `put_artifact(...)`
|
||||
- `get_artifact(...)`
|
||||
- `ack_event(...)`
|
||||
|
||||
Requirements:
|
||||
|
||||
- no Chroma dependency;
|
||||
- no vector index open;
|
||||
- safe under concurrent HTTP requests;
|
||||
- append-only events;
|
||||
- validate/sanitize string fields with existing config helpers where appropriate;
|
||||
- cap body/artifact sizes with explicit errors.
|
||||
|
||||
Suggested limits:
|
||||
|
||||
- event body: 256 KiB default;
|
||||
- artifact content: 4 MiB default;
|
||||
- wait timeout: 300 seconds max.
|
||||
|
||||
### Phase 2: MCP Tools
|
||||
|
||||
Add tools in `mempalace/mcp_server.py`:
|
||||
|
||||
- `mempalace_event_append`
|
||||
- `mempalace_event_list`
|
||||
- `mempalace_event_wait`
|
||||
- `mempalace_event_ack`
|
||||
- `mempalace_artifact_put`
|
||||
- `mempalace_artifact_get`
|
||||
- optionally `mempalace_patch_submit`
|
||||
|
||||
Read-only mode:
|
||||
|
||||
- `event_list`, `event_wait`, and `artifact_get` are allowed.
|
||||
- `event_append`, `event_ack`, `artifact_put`, and `patch_submit` are mutating tools and must be hidden/refused in read-only mode.
|
||||
|
||||
SQLite integrity gate:
|
||||
|
||||
- Logstream should not depend on Chroma SQLite integrity.
|
||||
- However, if the active palace path is unsafe/missing, fail clearly.
|
||||
|
||||
### Phase 3: CLI Utilities
|
||||
|
||||
Add:
|
||||
|
||||
- `mempalace logstream append`
|
||||
- `mempalace logstream list`
|
||||
- `mempalace logstream wait`
|
||||
- `mempalace artifact put`
|
||||
- `mempalace artifact get`
|
||||
|
||||
Keep CLI output scriptable with `--json`.
|
||||
|
||||
### Phase 4: Docs and Skills
|
||||
|
||||
Docs:
|
||||
|
||||
- `website/concepts/agent-logstream.md`
|
||||
- `website/reference/mcp-tools.md` entries.
|
||||
- Update `website/guide/remote-server.md` to mention agent coordination over the hub.
|
||||
|
||||
Agent skills/prompts:
|
||||
|
||||
- Update MemPalace recall/search skills to mention logstream for active coordination.
|
||||
- Add a brief "when delegating work, use event_append/event_wait" protocol.
|
||||
|
||||
### Phase 5: Realtime Stream
|
||||
|
||||
Only after Phase 1-4 are solid:
|
||||
|
||||
- add SSE endpoint;
|
||||
- per-agent cursor helpers;
|
||||
- maybe background compaction of old event payloads into normal drawers for long-term recall.
|
||||
|
||||
## Test Plan
|
||||
|
||||
Unit tests:
|
||||
|
||||
- schema initializes in empty palace dir;
|
||||
- append/list round trip;
|
||||
- filters by stream, room, type, to_agent, correlation_id;
|
||||
- wait returns immediately when event already exists;
|
||||
- wait times out cleanly;
|
||||
- artifact put/get preserves exact bytes/text;
|
||||
- artifact hash is stable;
|
||||
- ack creates new event and does not mutate target event;
|
||||
- read-only mode hides/refuses mutating tools;
|
||||
- size limits reject oversized event bodies/artifacts.
|
||||
|
||||
Integration tests:
|
||||
|
||||
- two threads: one waits, one appends, waiter returns matching event;
|
||||
- patch submit stores artifact and event atomically enough that readers never see a dangling artifact id;
|
||||
- MCP HTTP server can append from one client and wait/list from another;
|
||||
- no auth headers/request bodies appear in `/statusz`.
|
||||
|
||||
Dogfood test:
|
||||
|
||||
1. Mac agent creates task request for Windows.
|
||||
2. Windows agent waits for it.
|
||||
3. Windows agent submits patch artifact.
|
||||
4. Mac agent waits, gets patch, verifies hash, applies patch manually, runs tests.
|
||||
5. Mac agent acknowledges `applied`.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should logstream events also be mirrored into Chroma drawers for semantic recall, or should structured search remain the primary access path?
|
||||
- Should artifact content support binary data in v1, or should v1 be UTF-8 text only?
|
||||
- Should agent identities be free-form strings initially, or tied to configured local agent profiles?
|
||||
- Should `event_wait` be allowed over stdio MCP clients that may have shorter host timeouts?
|
||||
- Should events have retention/archival controls, or stay forever by default like memory?
|
||||
|
||||
## Claude Fable 5 Instructions
|
||||
|
||||
Please implement Phase 1 and Phase 2 first. Keep the patch narrow and avoid unrelated refactors.
|
||||
|
||||
Start by creating `mempalace/logstream.py` with the durable SQLite core and focused tests. Then expose MCP tools in `mempalace/mcp_server.py` with read-only and mutating-tool semantics. Only add CLI/docs after the core and MCP tools are green.
|
||||
|
||||
Before finishing:
|
||||
|
||||
- run focused logstream tests;
|
||||
- run relevant MCP server tests;
|
||||
- run `ruff check` on touched Python files;
|
||||
- write a MemPalace delegation reply with the implementation status, changed files, and exact verification commands.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Windows dogfood verification by `windows-codex` on 2026-07-02:
|
||||
|
||||
- Branch/base: `feat/shared-brain-dogfood` at `1ff3125`.
|
||||
- OS: Microsoft Windows 11 Pro Insider Preview 10.0.29576 build 29576 64-bit.
|
||||
- Python: 3.12.11 via `uv run python`.
|
||||
- Command: `uv run pytest tests/test_logstream.py tests/test_mcp_logstream.py tests/test_cli_logstream.py -q`.
|
||||
- Result: 72 passed in 2.93s.
|
||||
@@ -0,0 +1,630 @@
|
||||
# RFC 004: The Replicated Palace
|
||||
|
||||
Status: Draft complete — all sections drafted (storage: mac-claude; transport/lifecycle/appendix: windows-claude); awaiting Igor's review
|
||||
|
||||
Shipped so far: step 0 (logstream multi-master replication) and step 1 (memory read replicas — snapshot pull, local fold, distributed embedding), plus the transport seam and the estate endpoint. Step 2a (the memory op-log, anti-entropy, fold/promote) and the v4 content-pure id migration + write-flip are designed here but not yet landed on develop — they carry a palace migration and are staged for a later release.
|
||||
Owners: mac-claude (storage layers, §6–§9), windows-claude (transport & lifecycle, §5 and Appendix A), decided by Igor
|
||||
Created: 2026-07-02
|
||||
Branch: `feat/shared-brain-dogfood`
|
||||
Prior art: RFC 003 (logstream), the `rfc004_replicated_palace_position` correlation thread (position debate, verbatim in the logstream)
|
||||
|
||||
## Summary
|
||||
|
||||
Each human has ONE palace — an extension of their brain — replicated in full
|
||||
across every machine they own. Agents always talk to the MemPalace service on
|
||||
`127.0.0.1`; services converge with each other over an encrypted mesh. The
|
||||
hub stops being a dependency and becomes a role (rendezvous, and the home of
|
||||
*shared* palaces). The design is judged against offline operation as the
|
||||
default posture, not as an edge case.
|
||||
|
||||
One sentence: **N equal replicas of the facts, each with locally-derived
|
||||
indexes and a local writer, converging through provenance-stamped ops over
|
||||
the mesh — with origin as the home-of-record for source-bound maintenance.**
|
||||
|
||||
## Motivation
|
||||
|
||||
The shared-brain dogfood proved the hub topology works — and watched it fail:
|
||||
when the hub machine slept, every other machine lost recall, capture, and
|
||||
coordination simultaneously. A brain does not stop remembering because
|
||||
another brain is asleep. Concretely, today a remote machine without the hub
|
||||
has **no palace at all**.
|
||||
|
||||
The mission statement ("memory is identity") implies the requirement
|
||||
directly: you do not rent your identity, and you do not park it on a single
|
||||
machine.
|
||||
|
||||
## Requirements
|
||||
|
||||
The availability invariant, and the offline requirements distilled from the
|
||||
fleet (logstream correlation `rfc004_replicated_palace_position`):
|
||||
|
||||
- **R0 — Mission invariants hold everywhere**: verbatim always, local-first,
|
||||
zero external API for core operations, hooks < 500 ms, startup injection
|
||||
< 100 ms. A design that meets availability by adding a network round-trip
|
||||
to recall fails R0.
|
||||
- **R1 — Availability invariant**: recall reads and capture writes never
|
||||
block on the network; only convergence may wait.
|
||||
- **R2 — Task freshness**: delegations to offline agents must not execute
|
||||
stale on rejoin (optional `expires_at` on `task.request`; re-check the
|
||||
correlation for `superseded` before acting on an old claim).
|
||||
- **R3 — Partition claims**: duplicate task claims across replicas resolve
|
||||
deterministically post-merge (earliest HLC wins; the loser yields with
|
||||
`superseded`). Append-only makes double work safe-but-wasteful, never
|
||||
corrupting.
|
||||
- **R4 — Offline capture**: hooks write to the local replica unconditionally;
|
||||
organization-op conflicts merge LWW-by-HLC and the merge is **surfaced**
|
||||
to the user, never silent.
|
||||
- **R5 — Presence**: per-agent last-seen derived from log activity (plus
|
||||
device-level liveness from the transport layer) so requesters route around
|
||||
dead machines instead of burning `event_wait` timeouts.
|
||||
- **R6 — Rejoin via snapshot + tail**: op-log compaction and snapshot
|
||||
bootstrap are v1 requirements; a months-offline replica cannot replay
|
||||
history.
|
||||
- **R7 — Lost-device threat model**: N replicas put the whole brain on every
|
||||
device; encryption at rest per replica is mandatory, and the mesh must
|
||||
support membership revocation for a lost machine.
|
||||
- **R8 — Replication is not backup**: tombstones propagate; snapshot backups
|
||||
remain a separate concern.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Replacing the hub for shared palaces.** Federation (team palace,
|
||||
`shared_agent_brain`) is a different problem: a shared organ legitimately
|
||||
has a home and benefits from strong consistency. This RFC covers the
|
||||
*personal* palace; federation keeps the RFC 003 hub model.
|
||||
- **Cloud as a system of record** — constitutionally excluded. The only
|
||||
admissible cloud role is an optional end-to-end-encrypted blob courier for
|
||||
op-sync when no two personal machines are online simultaneously, plus
|
||||
encrypted offsite snapshots. Zero knowledge; never queryable server-side.
|
||||
- **Thin/partial replicas (phone-class devices) in v1.** They remain remote
|
||||
clients of a nearby full replica; partial replication is future work.
|
||||
- **Multi-user merge and authorization semantics.** One human, N devices.
|
||||
Trust boundary = palace boundary. Mesh membership is full-replica trust,
|
||||
not family/team scoped read or write permission.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Machine A (mac) Machine B (windows) Machine C (laptop)
|
||||
┌─────────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
|
||||
│ agents → 127.0.0.1 │ │ agents → 127.0.0.1 │ │ agents → local │
|
||||
│ ┌─────────────────┐ │ ops │ ┌─────────────────┐ │ ops │ ┌──────────────┐ │
|
||||
│ │ mempalace svc │◀┼───────▶│ │ mempalace svc │◀┼─────▶│ │ mempalace svc│ │
|
||||
│ │ op-log (SoT) │ │ mesh │ │ op-log (SoT) │ │ mesh │ │ op-log (SoT)│ │
|
||||
│ │ derived index │ │ │ │ derived index │ │ │ │ derived index│ │
|
||||
│ └─────────────────┘ │ │ └─────────────────┘ │ │ └──────────────┘ │
|
||||
└─────────────────────┘ └─────────────────────┘ └──────────────────┘
|
||||
encrypted mesh transport (Layer 1) · anti-entropy op sync (Layer 2)
|
||||
```
|
||||
|
||||
Three layers, separable by design:
|
||||
|
||||
1. **Transport (Layer 1)** — encrypted peer connectivity, membership, and
|
||||
device identity between the machines. Owner: windows-claude (§5).
|
||||
2. **Sync (Layer 2)** — a canonical append-only op-log per replica, merged by
|
||||
union with small domain-specific semantics. Owner: mac-claude (§6).
|
||||
3. **Derived state (Layer 3)** — vector indexes, embeddings, caches: rebuilt
|
||||
locally per device, never synced. Owner: mac-claude (§7).
|
||||
|
||||
The decisive property: **sync the facts, derive the senses.** Ops are
|
||||
kilobytes; HNSW graphs are gigabytes. Every machine remembers everything;
|
||||
each machine senses with its own hardware.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Dimension | Replicated mesh | Self-hosted central server | Cloud backend |
|
||||
|---|---|---|---|
|
||||
| Offline / partition | Full function, converge later | Dead when server/link down | Dead without internet |
|
||||
| Recall latency | Local, sub-ms–ms | LAN ms / tailnet 10–30 ms | 50–150 ms+ |
|
||||
| Privacy | Never leaves your devices | Your hardware, one exposed box | Provider sees plaintext or E2EE cripples search |
|
||||
| Durability | N live replicas (+R8 backups) | One box | Best-in-class |
|
||||
| Ops burden on user | ~zero if software earns it | Forever (patching, TLS, backups) | ~zero |
|
||||
| Engineering complexity | High, paid once by us | ~zero (exists today) | Zero for user, trust cost |
|
||||
| Consistency | Eventual + merge semantics | Strong, trivially | Strong |
|
||||
| Teams / sharing | Wrong tool | Natural | Natural |
|
||||
| Exit / lock-in | SQLite files you hold | Files you hold | Provider's mercy |
|
||||
|
||||
Verdicts: the mesh is the only option satisfying R0+R1 for the personal
|
||||
palace (a cloud round-trip spends the entire hook budget on network; central
|
||||
fails the availability invariant we watched fail in production). The central
|
||||
server remains the *correct* model for shared organs (federation). Cloud is
|
||||
admissible only as the E2EE courier of the Non-Goals section.
|
||||
|
||||
## §5. Layer 1: Transport
|
||||
|
||||
Layer 1 exists so Layer 2 never has to think about networks, keys, or which
|
||||
machines are awake. It carries ops between replicas and answers one question
|
||||
for the layers above: *which of my peers can I reach right now, and are they
|
||||
who they claim to be.* Nothing about merge semantics lives here; nothing
|
||||
about sockets leaks up.
|
||||
|
||||
### 5.1 The seam (what Layer 2 is allowed to assume)
|
||||
|
||||
Layer 2 sees peers only through this interface — the entire contract:
|
||||
|
||||
```
|
||||
Transport {
|
||||
self(): ReplicaId // stable, = mesh node identity
|
||||
peers(): Map<ReplicaId, Presence> // membership + liveness snapshot
|
||||
onPresenceChange(cb) // SWIM/heartbeat deltas → R5
|
||||
|
||||
request(peer, path, body): Response // one authenticated round-trip
|
||||
openStream(peer, path): EventStream // long-lived, resumable by cursor
|
||||
onInbound(path, handler) // serve anti-entropy pulls
|
||||
}
|
||||
```
|
||||
|
||||
Two channel shapes cover every need: **request** for anti-entropy pulls
|
||||
(`GET /sync/ops?origin=X&after=N`, §6.2) and **stream** for push-notify of
|
||||
new ops (the RFC 003 SSE surface, re-pointed peer-to-peer). Both are
|
||||
mutually authenticated — a `request` whose caller identity isn't a current,
|
||||
non-revoked member is refused at Layer 1, before Layer 2 sees a byte. That
|
||||
refusal is the replication ACL (R7) and it is the *only* authorization Layer
|
||||
2 relies on: an op that arrived is an op from a trusted replica.
|
||||
|
||||
This is deliberately a v1 device-trust boundary, not a user/role permission
|
||||
model. Admitting a replica means admitting it to the entire personal palace,
|
||||
verbatim content included. Delegated, family, team, or other partial-access
|
||||
workflows need a separate palace-level capability layer; they must not be
|
||||
modeled as "just add this device to the replication mesh."
|
||||
|
||||
`ReplicaId` is the transport's node identity — no separate replica registry
|
||||
to drift. Under MeshGuard it is the Ed25519 public key; that same value is
|
||||
the `origin_replica` stamped into every op (§8), so provenance and
|
||||
authentication are one fact, established once at the transport and trusted
|
||||
everywhere above.
|
||||
|
||||
### 5.2 Peer addressing & rendezvous
|
||||
|
||||
Replicas are named by identity, never by address — a laptop's IP changes
|
||||
between café and home; its `ReplicaId` does not. Address discovery is the
|
||||
transport's job:
|
||||
|
||||
- **MeshGuard / Tailscale**: the mesh's own coordination plane maps identity
|
||||
→ current endpoint(s); we never hardcode IPs. A replica "moves" and peers
|
||||
re-resolve transparently.
|
||||
- **Bare LAN** (no mesh): mDNS/`_mempalace._udp` discovery within a
|
||||
broadcast domain, identity verified by the membership key (§5.5) — an
|
||||
address hint is never a trust grant.
|
||||
- **No global directory.** Rendezvous is peer-to-peer within the mesh
|
||||
membership set. The old hub becomes *a* rendezvous helper for personal
|
||||
replicas (and stays the home of shared palaces per Non-Goals), never a
|
||||
required broker: two personal machines on the same LAN converge with the
|
||||
hub asleep.
|
||||
|
||||
### 5.3 NAT traversal posture
|
||||
|
||||
Convergence must survive both machines being behind NAT on different
|
||||
networks (R1 across the internet, not just the LAN):
|
||||
|
||||
1. **Direct** when a route exists (same LAN, or one side reachable).
|
||||
2. **Hole-punched P2P** via the mesh coordination plane (WireGuard-style for
|
||||
MeshGuard/Tailscale) — the common cross-network case, still zero data
|
||||
through any third party.
|
||||
3. **Relay** only when hole-punching fails: encrypted frames pass through a
|
||||
mesh relay that cannot read them (payloads are already E2E-encrypted at
|
||||
Layer 1). This is transport relay, categorically distinct from the
|
||||
constitutionally-excluded cloud-as-SoR (Non-Goals) — the relay sees
|
||||
ciphertext, never palace content, never queries.
|
||||
4. **Never-simultaneously-online** is a real fleet state (desktop by day,
|
||||
laptop by night). Two escape hatches, both opt-in: LAN sync when they do
|
||||
overlap, and the Non-Goals E2EE blob courier (a replica pushes an
|
||||
encrypted op-bundle the other pulls later). The courier is a §5 consumer
|
||||
of the same op ranges, not a new path.
|
||||
|
||||
### 5.4 Connection lifecycle
|
||||
|
||||
- **Discover → authenticate → sync-on-connect → tail.** On establishing a
|
||||
link a replica exchanges version vectors (§6), pulls the deltas each side
|
||||
is missing, *then* subscribes to the peer's live op stream — snapshot then
|
||||
tail, the pattern windows-codex asked us to name (identical to the viewer's
|
||||
`event_list` cursor → SSE `Last-Event-ID` bootstrap, RFC 003). No live-only
|
||||
connect: the gap between snapshot and stream-open is closed by cursor, so
|
||||
no op is ever missed on reconnect.
|
||||
- **Backpressure & retry** mirror the logstream tail already shipped:
|
||||
bounded concurrent streams, `503 + Retry-After` under load, exponential
|
||||
reconnect with the version vector as the resume cursor. A flapping link
|
||||
degrades to periodic anti-entropy pulls, never to lost ops.
|
||||
- **Idempotent by construction.** Re-delivering an op is a no-op (union
|
||||
merge, §6.2), so the lifecycle can be as dumb and as retry-happy as it
|
||||
likes — correctness lives in Layer 2, not in careful delivery.
|
||||
|
||||
### 5.5 Replica join / leave / revoke ceremony
|
||||
|
||||
Membership *is* trust; there is no other authorization in the system.
|
||||
|
||||
- **Join** (adding a new personal device): the new replica generates its
|
||||
Ed25519 identity locally (private key never leaves it). An existing
|
||||
authenticated replica admits it to the mesh membership set — a physical-
|
||||
presence / existing-device action by the human, not a password. First sync
|
||||
is a full snapshot + tail (§6, R6). Because organization syncs as ops, the
|
||||
new device reproduces the *same* palace, not a re-clustered one.
|
||||
- **Leave** (graceful): a replica can announce departure; peers keep its
|
||||
historical ops forever (they are provenance) but stop expecting presence.
|
||||
- **Revoke** (lost/stolen device — R7): the human revokes a `ReplicaId` from
|
||||
any surviving replica; the removal propagates as a membership op and every
|
||||
peer refuses further connections from that identity. Revocation cannot
|
||||
reach into the lost device — hence R7's mandatory at-rest encryption
|
||||
(mac-claude, §10): revocation stops the *network*; encryption protects the
|
||||
*disk already gone*. Ops that identity authored before revocation remain
|
||||
valid history (revoking a device is not disavowing its memories); only its
|
||||
future write access is severed.
|
||||
|
||||
### 5.6 Key custody
|
||||
|
||||
- **Device key**: Ed25519 private key per replica, generated on-device,
|
||||
non-exportable, OS keystore where available (Keychain / DPAPI / kernel
|
||||
keyring). It is the node's whole identity — losing it = re-join as a new
|
||||
replica; it is never synced.
|
||||
- **Membership authority**: which identities are trusted is itself
|
||||
op-carried state (a membership OR-set with revocation tombstones), signed
|
||||
by an admitting device — so "who is in the mesh" converges like everything
|
||||
else and survives any single machine's loss.
|
||||
- **At-rest data key** (the palace on disk, R7) is a *separate* concern
|
||||
owned by mac-claude in §10; §5 owns only the identity/membership keys that
|
||||
gate the wire. The two never mix: a compromised transport key exposes no
|
||||
plaintext, a stolen disk exposes no network.
|
||||
|
||||
### 5.7 Transport fallback matrix
|
||||
|
||||
Per-link, best available wins; the seam (§5.1) makes the choice invisible
|
||||
to Layer 2.
|
||||
|
||||
| Situation | Transport | Notes |
|
||||
|---|---|---|
|
||||
| Same LAN, mesh up | MeshGuard direct | Lowest latency; hub not involved |
|
||||
| Cross-network, both online | MeshGuard hole-punched (Tailscale bridge if MeshGuard not yet integrated) | E2E, P2P |
|
||||
| Hole-punch fails | Encrypted mesh relay | Ciphertext only; distinct from cloud-SoR |
|
||||
| Mesh unavailable, same LAN | Bare LAN + mDNS, membership-key auth | Degraded discovery, full trust |
|
||||
| Never simultaneously online | E2EE blob courier (Non-Goals) | Async op-bundle exchange |
|
||||
| MeshGuard pre-integration | Tailscale (today's dogfood transport) | Ship Sequencing step 0–1 before MeshGuard lands |
|
||||
|
||||
MeshGuard is the target; **Tailscale is the shipping fallback that unblocks
|
||||
Sequencing steps 0–1 today** (it already carries this very logstream). The
|
||||
seam guarantees swapping in MeshGuard later touches no Layer 2 code.
|
||||
|
||||
MeshGuard pre-integration checklist (gate, not blocker): the 2026-06-06
|
||||
review's H1 (inner-source-IP spoofing on the userspace plane) is remediated
|
||||
with regression tests (meshguard PR #101, RX cryptokey-routing check in
|
||||
`decryptTransport`); all Criticals/Highs fixed across 17 hardening commits.
|
||||
Before MeshGuard becomes the default link: (a) a trust-path sweep of
|
||||
post-review commits, (b) an FFI-consumer pass where the daemon binds it.
|
||||
Until both pass, Tailscale carries production and MeshGuard rides behind the
|
||||
seam in test.
|
||||
|
||||
### 5.8 Failure detection → presence (R5)
|
||||
|
||||
Presence has two sources; the transport fuses them so the layers above ask
|
||||
one question:
|
||||
|
||||
- **Device liveness** (Layer 1): SWIM-style failure detection from the mesh
|
||||
(MeshGuard membership; heartbeat pings on bare LAN). Answers "is the
|
||||
machine reachable *now*," sub-second, and drives reconnect.
|
||||
- **Agent liveness** (derived, Layer 2): per-agent last-seen from op/log
|
||||
activity (RFC 003), answering "when did this seat last do anything."
|
||||
|
||||
`Presence = { reachable: bool (device), lastSeen: hlc (agent), replicaId }`.
|
||||
Requesters use it to route around dead machines instead of burning
|
||||
`event_wait` timeouts, and it is what the PalaceMind viewer renders
|
||||
(Appendix A). This is also the substrate for **R2 (task freshness)** and
|
||||
**R3 (partition claims)** on the wire:
|
||||
|
||||
- **R2**: a `task.request` with `expires_at` is still *delivered* to an
|
||||
offline agent (ops never drop), but presence tells the requester the agent
|
||||
was unreachable across the gap, so the etiquette check — "re-read the
|
||||
correlation for `superseded`/expiry before acting on a claim older than
|
||||
its transport gap" — has the data it needs. Layer 1 supplies the gap;
|
||||
Layer 2 supplies the rule.
|
||||
- **R3**: partitions *cause* duplicate claims (two replicas each admit a
|
||||
claim while unable to see each other). Layer 1's job is to make partitions
|
||||
observable (presence shows the split) and healable (anti-entropy on
|
||||
rejoin); the deterministic resolution — earliest-HLC wins, loser yields
|
||||
`superseded` — is mac-claude's merge rule (§6.2). Presence makes the
|
||||
window small; the merge rule makes the outcome safe.
|
||||
|
||||
## §6. Layer 2: The Canonical Op-Log
|
||||
|
||||
### 6.1 The op envelope
|
||||
|
||||
Every mutation of the palace becomes an immutable op:
|
||||
|
||||
```json
|
||||
{
|
||||
"op_id": "op_<origin>_<counter>",
|
||||
"origin_replica": "<transport identity of the writing machine>",
|
||||
"author_agent": "mac-claude",
|
||||
"hlc": "0189f3a2-0007-mac",
|
||||
"authored_at": "2026-07-02T21:14:09Z",
|
||||
"kind": "drawer.add | drawer.revise | drawer.tombstone | org.file | org.move | org.tunnel.add | org.tunnel.remove | kg.assert | kg.close | kg.entity.upsert | registry.entity.upsert | event.append | artifact.put | ...",
|
||||
"payload": { "...kind-specific, verbatim content inline or by sha256..." }
|
||||
}
|
||||
```
|
||||
|
||||
- `hlc` is a hybrid logical clock (physical ms + logical counter + replica
|
||||
tiebreak): total order across replicas without clock trust.
|
||||
- Per-origin logs are strictly ordered by a local counter; a replica's state
|
||||
is a **version vector** {origin → highest counter applied}.
|
||||
- Storage: `oplog.sqlite3` in the palace dir, append-only, WAL — the
|
||||
logstream pattern (RFC 003) generalized; that pattern is production-proven.
|
||||
|
||||
### 6.2 Merge semantics (complete list — nothing else exists)
|
||||
|
||||
| State | Op kinds | Merge rule |
|
||||
|---|---|---|
|
||||
| Drawer content | add / revise / tombstone | Grow-only set of content-addressed revisions; head = latest by HLC; tombstone hides, never deletes (verbatim survives) |
|
||||
| Artifacts | artifact.put | True G-set, union by sha256 — conflicts impossible |
|
||||
| Organization | org.file / org.move / org.tunnel.* | LWW-by-HLC register per drawer (placement) / OR-set (tunnels); merges surfaced to user (R4) |
|
||||
| Knowledge graph | kg.assert / kg.close / kg.entity.upsert | Assert = G-set; close = interval-close (idempotent, min valid_to wins); entity upsert = LWW-by-HLC |
|
||||
| Registry | registry.entity.upsert | LWW-by-HLC per entity key (replaces whole-file JSON write) |
|
||||
| Logstream | event.append | Append-only union; cross-replica order by HLC; per-origin `seq` preserved; consumer contract additive (`origin_replica`, `hlc` are new fields) |
|
||||
| Diary | drawer.add in diary rooms | Same as drawer content (already append-only) |
|
||||
|
||||
`org.tunnel.remove` is the remove half of the tunnel OR-set: it hides the edge
|
||||
from the current organization view, not from history. If MemPalace adopts a
|
||||
first-class dormant-tunnel product state, ship it as an explicit state op
|
||||
(`org.tunnel.set_state(active|dormant)` or equivalent) rather than overloading
|
||||
remove with dormancy semantics.
|
||||
|
||||
Anti-entropy: peers exchange version vectors and pull missing per-origin
|
||||
ranges (`GET /sync/ops?origin=X&after=N`), push-notified over the existing
|
||||
SSE channel. No broker, no framework: automerge/yjs are document-CRDTs
|
||||
(wrong shape, heavy deps); cr-sqlite is a native extension whose generic
|
||||
table-CRDTs know nothing of id purity or verbatim; file-level sync of live
|
||||
SQLite corrupts. The merge logic above is ~hundreds of lines we fully own.
|
||||
|
||||
Future domain-specific state needs the same explicit treatment before it
|
||||
enters Layer 2. Closet/card semantics, succession edges, contradiction
|
||||
intervals, or other semantic structures are not automatically drawer
|
||||
placement registers. Each adopted state must name its own op kinds, conflict
|
||||
surface, and any merge-exempt or intentionally non-LWW transitions; otherwise
|
||||
replicas can silently erase meaning while still "converging."
|
||||
|
||||
### 6.3 Id purity (prerequisite, not footnote)
|
||||
|
||||
Verified against the code (2026-07-02): today's drawer identities are NOT
|
||||
content-addressed. Miner drawers hash `(source_file, chunk_index)` — re-mining
|
||||
rewrites content in place under the same id; MCP drawers hash
|
||||
`(wing, room, content)` — organization lives inside identity;
|
||||
`tool_update_drawer` mutates in place; dedup deletes.
|
||||
|
||||
**v4 identity recipe**: `drawer_<hash(content)>` — identity is the verbatim
|
||||
content alone. Organization (wing/room) becomes op-carried metadata
|
||||
(`org.file`); location provenance (`source_file`, `chunk_index`) becomes
|
||||
plain metadata; revision chains link content-addressed revisions. `ids.py`
|
||||
already versions recipes (`ID_RECIPE` v1→v3 precedent) and drawers carry
|
||||
`id_recipe` metadata, so migration is an audited rewrite with a legacy-id
|
||||
alias table for inbound references (tunnels, KG `source_drawer_id`).
|
||||
|
||||
### 6.4 Mutable-state inventory → op mapping (verified, with file refs)
|
||||
|
||||
| Today (mutable) | Where | Becomes |
|
||||
|---|---|---|
|
||||
| `tool_update_drawer` in-place update/upsert | mcp_server.py | `drawer.revise` (new content-addressed revision) |
|
||||
| Miner re-mine upsert over same id | miner.py:1336,1478 | `drawer.revise` at origin replica only (§8) |
|
||||
| `delete_drawer` / `delete_by_source` / dedup batch delete | dedup.py:127 | `drawer.tombstone` (hide, never destroy) |
|
||||
| Entity registry whole-file `json.dumps` | entity_registry.py:328 | `registry.entity.upsert` op stream |
|
||||
| `hallways.json` whole-file rewrite | hallways.py:140 | `org.tunnel.add/remove` OR-set ops |
|
||||
| KG `invalidate` UPDATE of valid_to; entities INSERT OR REPLACE | knowledge_graph.py | `kg.close` interval op; `kg.entity.upsert` |
|
||||
| repair / migrate / dedup wholesale rewrites | repair.py, migrate.py | replica-local maintenance of derived state (never synced) |
|
||||
|
||||
Hardest today, cleanest after: the two whole-file JSONs (currently pure
|
||||
last-writer-wins with silent loss) gain real merge semantics for free.
|
||||
|
||||
## §7. Layer 3: Derived State
|
||||
|
||||
The vector store stops being the system of record — that single change
|
||||
dissolves the fleet-level writer lease, the stdio proxy's reason to exist,
|
||||
and the cross-machine integrity gates. Chroma/Qdrant/pgvector/sqlite_exact
|
||||
become **fold-and-index consumers** of the op-log, each rebuilt or
|
||||
incrementally folded locally.
|
||||
|
||||
- Precedent already in-repo: `repair --mode from-sqlite` rebuilds the vector
|
||||
index from content; embeddings are already treated as re-derivable.
|
||||
- Embedder identity stays per-replica (RFC 001): pin one model fleet-wide or
|
||||
accept per-device vector spaces — legal because queries execute locally.
|
||||
- **The lease demotes, it does not dissolve**: per-replica single-writer
|
||||
over local index state remains (HNSW physics); what disappears is
|
||||
cross-machine write arbitration.
|
||||
- Organization is NOT derived state. Filing decisions sync as ops (§6.2):
|
||||
the method of loci means the layout IS the memory; two replicas clustering
|
||||
differently would give the user two different palaces.
|
||||
|
||||
## §8. Provenance & Source-Bound Maintenance
|
||||
|
||||
Every op carries `(origin_replica, author_agent, hlc, authored_at)` —
|
||||
simultaneously the sync unit, the conflict tiebreak, the audit trail, and
|
||||
the answer to "which machine/agent did this memory come from."
|
||||
|
||||
Local references are replica-local: a drawer mined from `P:\...` on Windows
|
||||
references a path that exists only there. The memory replicates everywhere;
|
||||
**source-bound maintenance does not**: re-mining after file edits,
|
||||
`repair` against origin files, and `delete_by_source` execute only at the
|
||||
origin replica (other replicas receive the resulting ops). The mesh must
|
||||
know: every memory lives everywhere, but its umbilical cord attaches to one
|
||||
machine.
|
||||
|
||||
## §9. Sequencing
|
||||
|
||||
0. **Logstream multi-master (pilot)** — already append-only; add
|
||||
`origin_replica` + `hlc` (additive to the viewer contract), per-origin
|
||||
logs, HLC ordering, artifact union by sha256. Smallest surface; fixes the
|
||||
pain that motivated everything (coordination dies with the hub).
|
||||
1. **Read replicas for memory** — content snapshot + op tail; indexes
|
||||
derived locally (never copy `chroma.sqlite3` — that replicates Chroma's
|
||||
fragility to N machines). Cheap availability for recall; builds the R6
|
||||
snapshot machinery.
|
||||
2. **Canonical op-log + v4 id migration** — §6 in full; backends demoted to
|
||||
derived consumers. Decided 2026-07-02 (Igor): ships as **2a**
|
||||
(drawers + KG ops — the waiting customers: mining promotion and the
|
||||
multi-writer foundation) followed by **2b** (registry + hallways/tunnels
|
||||
op conversion); superseded revisions are kept **forever** (verbatim
|
||||
maximalism — search surfaces head revisions only; no GC path exists);
|
||||
the v4 migration runs **staged on a palace copy first**, validated, then
|
||||
live with a timestamped backup and a brief read-only window; the mac
|
||||
origin runs a **dual-write shadow period** (Chroma writes + op emission,
|
||||
divergence detectable) before cutover. Step 3's write-flip on remote
|
||||
replicas begins only **after the local-capture promotion validates**
|
||||
end-to-end. Two commitments this step MUST honor:
|
||||
- **Local-capture promotion.** A step-1 replica may mine machine-local
|
||||
data (projects, conversations) into its own palace before step 2
|
||||
exists — such drawers carry no `replica_origin` stamp, so read-replica
|
||||
reconciliation cannot touch them. Step 2 ships a one-time promotion
|
||||
pass: every unstamped local drawer becomes a `drawer.add` op under the
|
||||
replica's identity and flows to the whole mesh. Capture-now is
|
||||
forward-compatible by contract, not by luck; nothing mined early is
|
||||
ever re-mined or lost.
|
||||
- **v4 ids are the cross-machine dedup.** Content-pure identity means
|
||||
the same content mined on two machines yields the same drawer id; the
|
||||
grow-only merge collapses duplicates into one drawer with multiple
|
||||
provenance records. The migration is the dedup mechanism — no separate
|
||||
dedup pass across origins.
|
||||
3. **Full multi-writer** — every replica captures locally, all converge.
|
||||
|
||||
Each step ships value alone; none blocks on Layer 1 choice (seam, §5).
|
||||
|
||||
## §10. Security — shared ownership
|
||||
|
||||
Threat model: N replicas mean the entire brain — every drawer, verbatim —
|
||||
sits on every device. Two independent exposures follow, and the design keeps
|
||||
them independent: the **wire** (in transit between replicas) and the **disk**
|
||||
(at rest on each replica). A compromise of one must not yield the other.
|
||||
|
||||
### 10.1 Transport-side (windows-claude)
|
||||
|
||||
- **In transit**: every op crosses the mesh E2E-encrypted (MeshGuard /
|
||||
WireGuard-class); relays and couriers (§5.3) see ciphertext only. No
|
||||
palace bytes ever traverse a third party in the clear — the constitutional
|
||||
no-cloud-SoR line holds even when a relay is used.
|
||||
- **Membership as the only ACL**: an op is authorized iff it arrived over a
|
||||
channel authenticated to a current, non-revoked `ReplicaId` (§5.1). There
|
||||
is no per-op signature check in Layer 2 — trust is established once at the
|
||||
transport and is total above it. Membership itself is convergent,
|
||||
revocation-aware state (§5.6).
|
||||
- **Revocation ceremony** (R7, network half): revoke a lost device's
|
||||
identity from any survivor; the tombstone propagates and every peer
|
||||
refuses it thereafter. This severs *future* access only — see 10.3.
|
||||
- **Identity-key custody**: per-device Ed25519 key, on-device, non-exportable
|
||||
(§5.6). Compromising it grants mesh access (mitigated by revocation) but,
|
||||
by construction, decrypts nothing at rest.
|
||||
|
||||
### 10.2 At-rest side (mac-claude)
|
||||
|
||||
Two tiers, both grounded in one rule: **data keys are per-replica and never
|
||||
traverse the mesh.** Ops arrive over the wire (10.1), are held decrypted
|
||||
only in memory, and are written under the receiving replica's own key.
|
||||
Compromising one device's disk therefore never yields a key that opens any
|
||||
other device — the same independence 10.3 demands between wire and disk
|
||||
holds between disks.
|
||||
|
||||
- **Tier 0 (baseline, v1-mandatory to document and detect)**: full-disk
|
||||
encryption — FileVault / BitLocker / LUKS. Zero code, protects the
|
||||
powered-off stolen device, and is the only tier that also covers derived
|
||||
indexes we do not control internally (Chroma's own SQLite holds verbatim
|
||||
documents). Setup surfaces a loud warning when the palace directory lives
|
||||
on an unencrypted volume.
|
||||
- **Tier 1 (target): per-palace data key over the canonical stores.** A
|
||||
symmetric data key encrypts `oplog.sqlite3`, the content store, the
|
||||
logstream, and the KG (SQLCipher-style page encryption; AEAD, hardware-
|
||||
accelerated — page crypto on hot recall paths fits the R0 budgets). The
|
||||
data key is wrapped by the OS keystore (macOS Keychain/Secure Enclave,
|
||||
Windows DPAPI/TPM, Linux libsecret/TPM), never stored in the palace
|
||||
directory, never synced, and is distinct from the 10.1 identity key by
|
||||
construction — neither derives from the other.
|
||||
- **Derived indexes under Tier 1**: rebuildable by definition (§7), so they
|
||||
get the cheaper policy — rely on Tier 0, or (paranoid profile) treat them
|
||||
as ephemeral: discarded on lock, refolded from the encrypted op-log on
|
||||
unlock. Never the canonical stores' key.
|
||||
- **Rotation is replica-local**: re-encrypt local files under a new data
|
||||
key and re-wrap; no mesh coordination, no ops emitted, because keys are
|
||||
not replicated state.
|
||||
- **R7 composition**: a lost powered-off device presents Tier 0 + Tier 1 to
|
||||
the attacker; the revocation ceremony (10.1) has already severed its
|
||||
future sync regardless of whether the disk ever yields.
|
||||
|
||||
### 10.3 The seam between them
|
||||
|
||||
Revocation stops the network; encryption protects the disk that is already
|
||||
gone. A stolen, powered-off device is defended only by 10.2; a live device
|
||||
still on the mesh is defended by 10.2 (unlocked-disk exposure) *and* 10.1
|
||||
(revocation cuts its future sync). Neither half covers the other — which is
|
||||
why both are mandatory, not alternatives.
|
||||
|
||||
Explicitly: **replication ≠ backup (R8)**. Tombstones propagate, so a
|
||||
fat-fingered mass delete replicates faithfully to every device; encrypted
|
||||
offsite snapshots remain a separate mechanism, out of this layer's scope.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- HLC skew bounds and how loudly to surface clock anomalies.
|
||||
- Op-log compaction policy (R6): checkpoint cadence for snapshot bootstrap.
|
||||
(Content-revision retention is settled: superseded revisions are kept
|
||||
forever; compaction only concerns op-replay bootstrap cost, never
|
||||
content.)
|
||||
- Does the E2EE cloud courier ship in v1 or wait for demand?
|
||||
- Partial replicas for phone-class devices (deferred; Non-Goals).
|
||||
- Federation bridge: can a personal replica project shadow wings into a
|
||||
team hub palace mechanically, or is that manual today?
|
||||
|
||||
## Appendix A: PalaceMind
|
||||
|
||||
The desktop app is where replication stops being infrastructure and becomes
|
||||
something the human can see and trust. Its guiding principle is the one
|
||||
already shipping in the connected-palace and Agents surfaces (ADR-0028):
|
||||
**honest status over hidden magic.** Four consumer surfaces.
|
||||
|
||||
### A.1 Replica status (generalizing Live/Polling)
|
||||
|
||||
The Agents viewer already renders a `Live · seq N` vs `Polling` honesty flag
|
||||
off the logstream. That generalizes directly to the fleet: a compact
|
||||
**estate health** panel — one row per known replica showing `reachable`
|
||||
(device presence, §5.8), last-seen, and **version-vector drift** ("this
|
||||
device is 14 ops behind mac"). Drift is the replication analogue of the
|
||||
Polling flag: it never lies about how converged you are. The Wings-page
|
||||
estate map (already an outer orbit for the connected palace) becomes the
|
||||
natural home — replicas as nodes, edges dimming when a peer goes
|
||||
unreachable, so "the house breathes when the fleet works" is literal.
|
||||
|
||||
### A.2 Merge surfacing (R4 — the load-bearing UX)
|
||||
|
||||
R4 forbids silent organization merges: when two replicas filed the same
|
||||
drawer differently during a partition and LWW-by-HLC picks a winner, the
|
||||
loser must be *shown*, not swallowed. PalaceMind owns that moment:
|
||||
|
||||
- A quiet, dismissible notice — "This capture was filed in *Meds* on your
|
||||
laptop and *Billing* here; kept *Billing* (more recent). Move it?" — with
|
||||
one-click accept-the-other. Never a modal, never blocking; the calm
|
||||
enterprise register, matching the assistant-offline notice we just shipped.
|
||||
- A **Reconciliation** view listing recent auto-merges with their HLC
|
||||
reasoning and undo, so a power user can audit what convergence decided.
|
||||
The op-log makes this free: every merge is two ops and a rule.
|
||||
|
||||
### A.3 Presence rendering (R5)
|
||||
|
||||
Presence (§5.8) surfaces wherever a human waits on another machine: the
|
||||
Agents inbox marks a delegate's device offline so the user doesn't expect a
|
||||
reply from a sleeping laptop; agent pickers show a liveness dot; the
|
||||
open-loops panel flags a thread blocked on an unreachable replica distinctly
|
||||
from one merely awaiting a claim. The rule: never make the user infer from
|
||||
silence what the system already knows.
|
||||
|
||||
### A.4 Multi-origin logstream
|
||||
|
||||
The viewer must not fracture when ops carry `origin_replica` + `hlc`
|
||||
(additive fields, per §6.2 — the contract stays compatible):
|
||||
|
||||
- **Order by HLC**, not per-origin `seq`, once events span replicas; keep
|
||||
`seq` as the per-origin resume cursor.
|
||||
- **Dedupe by `op_id`/event id** across origins (the same op can arrive via
|
||||
two peers) — the merge is idempotent, the UI must be too.
|
||||
- **Origin as provenance**, alongside the existing agent identity: "filed by
|
||||
mac-claude on *laptop*." This also lands windows-codex's identity-alias
|
||||
request cleanly — a rename is just another provenance fact the log already
|
||||
carries; render "windows-claude (formerly claude-fable-5-windows)" from
|
||||
the correction op, thread the identities together in filters, keep raw
|
||||
`from_agent`/`origin_replica` per event for audit.
|
||||
|
||||
Consumer contract, restated for implementers: bootstrap is always
|
||||
**snapshot (`event_list`/`/sync` cursor) → set version vector → tail
|
||||
(SSE `Last-Event-ID`)**; never live-only connect (the gap loses ops). This
|
||||
is the same pattern the Agents page already uses and the one windows-codex
|
||||
asked us to name canonically.
|
||||
@@ -0,0 +1,272 @@
|
||||
# RFC 005: Agent Identity & Routing
|
||||
|
||||
Status: Draft — for Igor's review
|
||||
Owner: mac-claude (backend/hub owner; identity is palace/fleet infrastructure, not a meshguard concern)
|
||||
Created: 2026-07-04
|
||||
Branch: `feat/shared-brain-dogfood`
|
||||
Prior art: RFC 003 (logstream — the routing surface this refines), RFC 004 (the replicated palace — provides the stable replica identity a host label derives from), the `rfc005_agent_identity_routing` correlation thread
|
||||
|
||||
## Summary
|
||||
|
||||
An agent's coordination identity is the triple **`host:agent:project`** — the
|
||||
granularity at which a working actor actually shares filesystem, local config,
|
||||
and knowledge. Two chat sessions open in the same project on the same machine
|
||||
are the **same** actor and carry the **same** identity; process/session/PID is
|
||||
event *metadata*, never part of who you are. Routing stays exactly as RFC 003
|
||||
defines it — exact match plus `*` broadcast over an opaque string — so this RFC
|
||||
adds a naming convention and one durable renderer change, not a new matcher.
|
||||
|
||||
One sentence: **identity = `host:agent:project`, minted once by the block that
|
||||
already renders it, routed by the string equality the logstream already has.**
|
||||
|
||||
## Motivation
|
||||
|
||||
The shared brain identifies each agent by a flat name — `mac-claude`,
|
||||
`windows-claude`, `blade-claude`. That name is not chosen by the agent; it is
|
||||
**rendered into** the agent's instructions by a managed block MemPalace writes
|
||||
to `~/.claude/CLAUDE.md`:
|
||||
|
||||
```
|
||||
<!-- mempalace-shared-brain:start -->
|
||||
## MemPalace shared brain
|
||||
You share a MemPalace hub with other agents. Your agent identity is
|
||||
windows-claude — use it as from_agent/created_by ...
|
||||
<!-- mempalace-shared-brain:end -->
|
||||
```
|
||||
|
||||
The flat name conflates two things that have now come apart in the live fleet:
|
||||
|
||||
1. **Two sessions, one name.** The windows box ran two concurrent `claude`
|
||||
sessions — one on the palace/mesh track, one on meshguard — both rendering
|
||||
`from_agent=windows-claude`. Their inboxes, their claims, and their diary
|
||||
entries interleaved under one identity with no way to tell "which window."
|
||||
A `status=claimed` from one window looked, to the other, like *someone else*
|
||||
had claimed it — or worse, like *itself* had, with no way to be sure.
|
||||
|
||||
2. **No project seam.** Recall, delegation, and the diary all key off the flat
|
||||
name, so a meshguard session and a mempalace session on the same box share
|
||||
one knowledge wing. Recall for the meshguard actor surfaces mempalace facts
|
||||
and vice-versa; the routing name carries no notion of *what work this is*.
|
||||
|
||||
Igor's framing resolves both: identity is the tuple at which knowledge scope,
|
||||
workspace, and local configuration are actually shared. Same box + same project
|
||||
= same actor, however many windows are open. Different project on the same box
|
||||
= a different actor with its own knowledge seam.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **I1 — Identity is `host:agent:project`.** The three components are the axes
|
||||
along which real sharing happens: `host` (same machine → same filesystem,
|
||||
same daemon, same local config), `agent` (which assistant/runtime), `project`
|
||||
(which workspace → which knowledge scope).
|
||||
- **I2 — Same-project sessions share one identity.** A second window in the
|
||||
same project on the same box inherits the same fs, config, and knowledge — it
|
||||
*is* the same actor. Identity must not fragment per session, per window, or
|
||||
per PID.
|
||||
- **I3 — Session/PID is metadata, never identity.** Which process holds a port,
|
||||
whose PID to signal on cleanup — that belongs in event `metadata`, not in
|
||||
`from_agent`/`to_agent`.
|
||||
- **I4 — Claims are held by the identity, not the session.** A `status=claimed`
|
||||
by *any* session of an identity is a claim by that identity; a second session
|
||||
of the same identity must treat its own identity's open claim as a mutex
|
||||
("don't work what you've already claimed").
|
||||
- **I5 — No new routing primitive required.** The tuple must route on the
|
||||
logstream exactly as flat names do today (RFC 003: `to_agent` exact match, or
|
||||
`to_agent='*'` broadcast). Hierarchical/glob routing is explicitly deferred
|
||||
(see Decision 1).
|
||||
- **I6 — Minted at the source, not hand-edited.** The identity is *rendered*
|
||||
into each box's instructions; the durable fix changes the renderer so every
|
||||
box re-derives its tuple on the next sync. No per-box hand-editing as the
|
||||
steady state.
|
||||
- **I7 — Backward compatible.** Existing flat names (`mac-claude`, …) remain
|
||||
valid and keep routing. Migration to the tuple is incremental and only forced
|
||||
where a real collision exists.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **A lease/lock server.** Claim safety is achieved by append-only events and a
|
||||
deterministic post-hoc tiebreak (Decision 2), consistent with RFC 004's R3.
|
||||
Nothing here introduces a coordinator or a mutex service.
|
||||
- **A new matcher.** Prefix/glob routing (`windows:claude:*`, `*:*:meshguard`)
|
||||
is a plausible future affordance but is **not** adopted here (Decision 1).
|
||||
- **Cross-user identity.** One human, N devices, per RFC 004. The tuple
|
||||
distinguishes *this* human's actors, not multiple humans.
|
||||
- **Changing how `from_agent`/`to_agent` are stored or validated.** Colons
|
||||
already pass `_sanitize_routing`; the tuple is a legal value today.
|
||||
|
||||
## The Identity Tuple
|
||||
|
||||
```
|
||||
host : agent : project
|
||||
```
|
||||
|
||||
- **`host`** — a stable, human-readable label for the machine. It derives from
|
||||
the replica identity RFC 004 already establishes (each replica has a durable
|
||||
id), rendered to a friendly label (`mac`, `blade`, `windows`) rather than an
|
||||
opaque hash. One host = one replica = one local daemon.
|
||||
- **`agent`** — the assistant/runtime family (`claude`, `codex`, `hermes`, …).
|
||||
This is the existing suffix of today's flat names, lifted out intact.
|
||||
- **`project`** — the workspace the session is operating in, derived from the
|
||||
session's working directory (its project/repo name). This is the new axis and
|
||||
the one that carries the knowledge seam.
|
||||
|
||||
Today's flat names are exactly `host-agent` with the `project` axis missing.
|
||||
That is why migration is cheap: `mac-claude` → `mac:claude:<project>` is an
|
||||
append, not a rename, and a single-project agent may stay flat until a real
|
||||
collision appears.
|
||||
|
||||
**Delimiter.** The colon is deliberate: it is already legal in routing fields
|
||||
(`_sanitize_routing` rejects only control characters, null bytes, and
|
||||
over-length values), it does not collide with the stream delimiter `/`
|
||||
(`project/mempalace`), and it reads unambiguously. The tuple is treated by the
|
||||
hub as one opaque string — the colons are a *convention for humans and
|
||||
renderers*, not something the matcher parses (see Decision 1).
|
||||
|
||||
## Same-Session Equivalence (I2)
|
||||
|
||||
Two sessions with the same `host:agent:project` are one actor. Concretely:
|
||||
|
||||
- They watch the **same inbox** (`to_agent=<tuple>`, which also catches `*`).
|
||||
- They write with the **same `from_agent`**.
|
||||
- They share **one knowledge wing and one diary** (see Knowledge Partition).
|
||||
- A task claimed by one is claimed by the identity (I4).
|
||||
|
||||
The second window is not a new participant to be tracked; it is the same
|
||||
participant with a second process. When the fleet needs to know *which process*
|
||||
(e.g. which one holds a hardcoded port, which PID to signal), that detail rides
|
||||
in event `metadata` — `{"pid": …, "session": …}` — and never in the identity.
|
||||
|
||||
## Claim Safety Under a Shared Identity (I4, Decision 2)
|
||||
|
||||
A shared identity introduces one race: two sessions of the same identity (or
|
||||
two replicas of the same identity, post-partition per RFC 004 R3) claim the
|
||||
same task before either sees the other's claim. Resolution, no lease server:
|
||||
|
||||
1. **Natural mutex.** Before claiming, a session lists open claims for its own
|
||||
identity on the correlation. If its identity already holds an open claim, it
|
||||
does not double-claim — it either joins or waits. This alone removes the
|
||||
common case (a second window picking up work the first already took).
|
||||
2. **Deterministic tiebreak for the residual window.** If two claims land
|
||||
before either is visible, the claim with the **lowest HLC wins**; the loser
|
||||
backs off and re-acks `status=superseded`. This is the RFC 004 R3 rule
|
||||
(earliest-HLC-wins), reused verbatim — append-only makes the wasted work
|
||||
safe, never corrupting.
|
||||
|
||||
No new event type is required: `event.ack` with `status=claimed` /
|
||||
`status=superseded` already expresses both steps.
|
||||
|
||||
## Knowledge Partition (I2, bonus)
|
||||
|
||||
Keying the palace wing and the diary by the full tuple partitions recall along
|
||||
the same seam routing already uses: a `…:meshguard` actor's recall stops
|
||||
surfacing `…:mempalace` facts. Today a single-agent box interleaves both
|
||||
projects in one wing (e.g. `wing_windows-claude`); the tuple gives each project
|
||||
its own wing without a new mechanism — it is just a longer wing key.
|
||||
|
||||
This is a **bonus, not a v1 requirement** for routing: an agent may adopt the
|
||||
tuple for `from_agent`/`to_agent` first (fixing the coordination collision) and
|
||||
partition its knowledge wing later. The two are independent adoptions.
|
||||
|
||||
## Decisions
|
||||
|
||||
Two choices live in the backend/hub and are resolved here so downstream
|
||||
consumers (PalaceMind's viewer, the renderer, other agents) can build against a
|
||||
fixed contract.
|
||||
|
||||
### Decision 1 — Matcher: **no change.** Route the tuple as an opaque string.
|
||||
|
||||
`to_agent` matching stays exactly as RFC 003 defines it:
|
||||
|
||||
```sql
|
||||
(to_agent = ? OR to_agent = '*')
|
||||
```
|
||||
|
||||
The tuple `host:agent:project` is matched by **string equality**; `*` remains
|
||||
the only wildcard, meaning fleet-wide broadcast. Hierarchical routing —
|
||||
`windows:claude:*` for "any agent on this box", `*:*:meshguard` for "whoever
|
||||
owns meshguard" — is **not** adopted in this RFC.
|
||||
|
||||
*Rationale.* The collision that motivated this RFC is solved entirely by
|
||||
minting distinct identities (Decision below on order + the renderer fix); it
|
||||
does not require the hub to *understand* the tuple. A glob/prefix matcher is a
|
||||
real index-and-correctness surface (partial-match semantics, index design,
|
||||
interaction with `*` broadcast) and should be its own proposal driven by a
|
||||
concrete need — "route to whoever owns project X regardless of host" — that the
|
||||
fleet has not yet hit. Keeping the matcher on exact-plus-broadcast means this
|
||||
RFC ships as a **convention + one renderer change**, with zero risk to the
|
||||
routing hot path. If hierarchical routing is later wanted, it is additive: a new
|
||||
optional match mode, not a migration.
|
||||
|
||||
### Decision 2 — Order: **`host:agent:project`** (host-first).
|
||||
|
||||
*Rationale.* Host-first matches how the fleet's names already read
|
||||
(`mac-*`, `windows-*`, `blade-*` are host-then-agent), so migration is a pure
|
||||
suffix append with no reordering. It favors the "any agent on this box" reading,
|
||||
which aligns with the operational reality that a host owns one daemon and one
|
||||
filesystem. Project-first would favor "who owns project X across hosts" — the
|
||||
arguably more common *coordination* query — but that query is exactly the one
|
||||
Decision 1 declines to make routable for now, so optimizing the string order
|
||||
for it buys nothing today. If Decision 1 is ever revisited and cross-host
|
||||
project routing becomes a first-class need, the order can be revisited with it;
|
||||
until then, host-first is the lower-friction choice.
|
||||
|
||||
## The Durable Fix: mint the identity at the renderer (I6)
|
||||
|
||||
The identity is hardcoded because MemPalace **renders** it. The one-place fix is
|
||||
to change the renderer/installer that writes the
|
||||
`<!-- mempalace-shared-brain:start … end -->` block so it emits the tuple
|
||||
instead of the flat name:
|
||||
|
||||
- **`host`** — derived from the local replica identity (RFC 004), mapped to a
|
||||
friendly label.
|
||||
- **`agent`** — the runtime family, as today.
|
||||
- **`project`** — derived from the session's workspace (the project/repo the
|
||||
block is being rendered for).
|
||||
|
||||
Then every box re-renders to its tuple on the next sync — no hand-editing. A
|
||||
hand-edited stopgap on a colliding box (deriving `host:agent:<project>` locally)
|
||||
is legitimate as a bridge and is simply superseded — or overwritten — by the
|
||||
renderer change, which is the intended outcome.
|
||||
|
||||
**Compatibility.** The renderer must not thrash existing single-project boxes:
|
||||
where an agent has exactly one project and no collision, rendering may keep the
|
||||
flat `host-agent` form (I7) and only expand to the tuple when a second project
|
||||
or a collision appears on that box. The block's managed markers make the
|
||||
rewrite safe and idempotent — same rules as today.
|
||||
|
||||
## Sequencing
|
||||
|
||||
This RFC is **deferred behind the RFC 004 write-flip**: it touches no storage or
|
||||
merge semantics, so it does not gate the flip, and the flip's cutover work takes
|
||||
priority. Once that lands:
|
||||
|
||||
1. **Convention adopted** (docs-only): agents may begin using
|
||||
`host:agent:project` as `from_agent`/`to_agent`. Routes today, no code
|
||||
change (colons already valid; matcher unchanged).
|
||||
2. **Renderer change** (the durable fix): the shared-brain block emits the tuple
|
||||
per the rules above. Reviewed on the windows box first, since it has the only
|
||||
live two-session collision.
|
||||
3. **Knowledge partition** (optional, later): wing/diary keyed by the tuple, so
|
||||
recall partitions on the same seam. Independent of steps 1–2.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **`project` derivation edge cases.** A session with no clear workspace (a
|
||||
bare shell, a home-directory session) has no natural `project`. Proposal: fall
|
||||
back to the flat `host-agent` form (I7) rather than invent a sentinel project.
|
||||
- **Friendly host labels.** The replica→label mapping (`mac`, `blade`,
|
||||
`windows`) needs a stable source of truth. Proposal: derive from the
|
||||
replica record RFC 004 already maintains; a label collision across two of the
|
||||
user's machines is resolved by the user at join time, once.
|
||||
- **Whether the renderer should ever *contract* a tuple back to flat** when a
|
||||
project is abandoned. Leaning no — an identity that has appeared on the
|
||||
logstream should be stable — but the managed block technically could.
|
||||
|
||||
## Relationship to RFC 004
|
||||
|
||||
RFC 004 gives this RFC its `host` axis for free: a replicated palace already has
|
||||
a durable per-replica identity, and one host is one replica is one local
|
||||
daemon. RFC 004's R3 (deterministic partition-claim resolution, earliest-HLC
|
||||
wins) is the exact rule Decision 2's claim-safety tiebreak reuses. This RFC does
|
||||
not alter RFC 004's storage, op-log, or merge design in any way — it refines the
|
||||
RFC 003 coordination surface that rides on top.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Write routing policy
|
||||
|
||||
This document defines the shared policy used by the staged Tier 3 daemon
|
||||
rollout tracked in #1963.
|
||||
|
||||
This foundation PR does not change existing hook or CLI routing. It provides
|
||||
one tested policy model that later hook and CLI PRs can consume without
|
||||
inventing different fallback rules.
|
||||
|
||||
## Policies
|
||||
|
||||
`direct`
|
||||
|
||||
Always execute through the existing direct local path.
|
||||
|
||||
`prefer`
|
||||
|
||||
Use an available daemon. A caller that is allowed to start the daemon may
|
||||
do so. Otherwise, fall back to the direct path.
|
||||
|
||||
`require`
|
||||
|
||||
Use an available daemon. A caller that is allowed to start the daemon may
|
||||
do so. If neither is possible, block the operation. Never fall back to a
|
||||
direct ChromaDB writer.
|
||||
|
||||
## Concrete routing outcomes
|
||||
|
||||
The shared decision function returns one of:
|
||||
|
||||
- `direct`
|
||||
- `daemon`
|
||||
- `blocked`
|
||||
|
||||
It also reports whether the caller should auto-start the daemon and why the
|
||||
route was selected.
|
||||
|
||||
Hooks generally pass `daemon_can_start=False` because hook execution has a
|
||||
tight latency budget.
|
||||
|
||||
Interactive CLI commands can pass `daemon_can_start=True`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Global environment policy:
|
||||
|
||||
MEMPALACE_WRITE_ROUTING=direct|prefer|require
|
||||
|
||||
Hook-specific environment policy:
|
||||
|
||||
MEMPALACE_HOOK_WRITE_ROUTING=direct|prefer|require
|
||||
|
||||
CLI-specific environment policy:
|
||||
|
||||
MEMPALACE_CLI_WRITE_ROUTING=direct|prefer|require
|
||||
|
||||
Configuration-file shape:
|
||||
|
||||
{
|
||||
"write_routing": {
|
||||
"default": "direct",
|
||||
"hooks": "prefer",
|
||||
"cli": "require"
|
||||
}
|
||||
}
|
||||
|
||||
## Precedence
|
||||
|
||||
For hooks:
|
||||
|
||||
1. `MEMPALACE_HOOK_WRITE_ROUTING`
|
||||
2. `MEMPALACE_WRITE_ROUTING`
|
||||
3. legacy `MEMPALACE_HOOKS_DAEMON`
|
||||
4. `write_routing.hooks`
|
||||
5. `write_routing.default`
|
||||
6. legacy `hooks.daemon`
|
||||
7. `direct`
|
||||
|
||||
For CLI writes:
|
||||
|
||||
1. `MEMPALACE_CLI_WRITE_ROUTING`
|
||||
2. `MEMPALACE_WRITE_ROUTING`
|
||||
3. `write_routing.cli`
|
||||
4. `write_routing.default`
|
||||
5. `direct`
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
The existing `MEMPALACE_HOOKS_DAEMON` environment variable and
|
||||
`hooks.daemon` config value remain supported.
|
||||
|
||||
Legacy true values map to `prefer`.
|
||||
|
||||
Legacy false values map to `direct`.
|
||||
|
||||
The existing `MempalaceConfig.hook_use_daemon` property is intentionally
|
||||
unchanged in this PR. Hook and CLI behavior remains unchanged until their
|
||||
policy-aware rollout PRs land.
|
||||
|
||||
## Invalid policy values
|
||||
|
||||
New policy settings accept only:
|
||||
|
||||
- `direct`
|
||||
- `prefer`
|
||||
- `require`
|
||||
|
||||
Invalid values fail with a source-specific error rather than silently falling
|
||||
back. This is important because silently turning a misspelled `require` into a
|
||||
direct write would violate the safety purpose of the policy.
|
||||
|
||||
## Local backend single-writer safety
|
||||
|
||||
File-backed backends such as `chroma`, `sqlite_exact`, and Milvus Lite support
|
||||
exactly one writable process per palace. Serializing individual calls is not
|
||||
enough because each long-lived process can retain SQLite/WAL, FTS, or vector
|
||||
index state between calls.
|
||||
|
||||
- A writable daemon owns the palace writer lease for its full lifetime.
|
||||
- Writable MCP HTTP acquires that lease before binding, holds it through the
|
||||
full serving lifetime, and releases it after active requests stop.
|
||||
- MCP stdio opens `sqlite_exact` read-only until it acquires the writer lease.
|
||||
It may therefore coexist for reads; mutating tools refuse while another
|
||||
process owns the lease and reopen writable storage after that owner exits.
|
||||
- Read-only MCP HTTP may coexist with the writer.
|
||||
- Read-only `sqlite_exact` clients use an immutable connection for a clean
|
||||
checkpointed database, or `mode=ro` when an active writer's complete WAL
|
||||
sidecar pair must remain visible. Both paths enable `query_only` and skip
|
||||
schema, WAL, FTS, migration, and metadata initialization.
|
||||
- Direct CLI and hook writes must not run beside a writable daemon or MCP HTTP
|
||||
owner. Route them through the daemon with `require` when the daemon owns the
|
||||
palace.
|
||||
- Direct `sqlite_exact` collection mutations contend for the same palace lease,
|
||||
and full LLM closet regeneration owns it before opening collections or
|
||||
calling the configured model.
|
||||
|
||||
`MEMPALACE_MCP_ALLOW_PEER_WRITER` cannot bypass this protection for local
|
||||
file-backed or unknown plugin backends. It is retained only for explicitly
|
||||
remote service backends (`qdrant`, `pgvector`, and Milvus server/Zilliz Cloud)
|
||||
that coordinate concurrent clients themselves. Milvus Lite remains protected
|
||||
as local file-backed storage.
|
||||
|
||||
Do not delete or unlink a live palace lock to recover ownership. Stop the
|
||||
owning process cleanly; the operating system releases its lock automatically.
|
||||
If corruption is suspected, back up the palace and run integrity/repair
|
||||
operations offline, with no writable service running.
|
||||
|
||||
## Follow-up PRs
|
||||
|
||||
Hook-triggered writes now consume this policy; see
|
||||
`docs/hook-write-routing.md`.
|
||||
|
||||
The remaining rollout PR will apply the policy to routine CLI writes.
|
||||
|
||||
Maintenance operations such as repair, migration, and index rebuild are not
|
||||
ordinary routed writes. They require a separate exclusive-maintenance policy.
|
||||
@@ -0,0 +1,74 @@
|
||||
# MemPalace — Antigravity examples
|
||||
|
||||
Two standalone configs for users who don't want to use the
|
||||
`hooks/antigravity/install.sh` installer.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|-------------------|-----------------------------------------------------------------------------------|
|
||||
| `hooks.json` | Standalone `hooks.json` registering the Stop and PreInvocation hooks. |
|
||||
| `mcp_config.json` | Standalone MCP entry registering the `mempalace-mcp` stdio server. |
|
||||
|
||||
## Wire up `hooks.json`
|
||||
|
||||
The example uses placeholder absolute paths (`/ABSOLUTE/PATH/TO/mempalace/...`).
|
||||
You must rewrite both `command` fields to the actual absolute paths to
|
||||
the hook scripts in your cloned repo, or to whichever location holds
|
||||
them. Antigravity will not resolve relative paths reliably.
|
||||
|
||||
Then drop the file at one of:
|
||||
|
||||
- `~/.gemini/config/hooks.json` (global, applies to every workspace)
|
||||
- `<workspace>/.agents/hooks.json` (workspace-scoped)
|
||||
|
||||
Restart Antigravity to pick the file up.
|
||||
|
||||
If you'd rather have the paths absolutized automatically, run the
|
||||
installer:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh
|
||||
```
|
||||
|
||||
That writes a fully rendered `hooks.json` to
|
||||
`~/.gemini/config/plugins/mempalace/hooks.json`.
|
||||
|
||||
## Wire up `mcp_config.json`
|
||||
|
||||
The example registers the `mempalace-mcp` stdio server. Two options:
|
||||
|
||||
### Option A — merge into the user-level Antigravity MCP config
|
||||
|
||||
Antigravity's user-level MCP config lives at
|
||||
`~/.gemini/antigravity/mcp_config.json`. Merge the `mcpServers.mempalace`
|
||||
entry from this example into that file, then restart Antigravity.
|
||||
|
||||
### Option B — drop into a plugin directory
|
||||
|
||||
If you've created a custom plugin folder (per the [Antigravity plugins docs](https://antigravity.google/docs/plugins)),
|
||||
copy this `mcp_config.json` directly into the plugin root:
|
||||
|
||||
```
|
||||
<plugin-root>/mcp_config.json
|
||||
```
|
||||
|
||||
Antigravity merges plugin-level MCP entries with the user-level config
|
||||
on launch.
|
||||
|
||||
## Verify
|
||||
|
||||
After wiring up either or both:
|
||||
|
||||
```bash
|
||||
mempalace-mcp --version # confirm binary is on PATH
|
||||
ls ~/.mempalace/ # confirm palace exists (run `mempalace init` if not)
|
||||
```
|
||||
|
||||
Restart Antigravity. The `mempalace` MCP server should appear in the
|
||||
MCP store; the Stop and PreInvocation hooks fire automatically.
|
||||
|
||||
See [`hooks/antigravity/STDIN_SHAPE.md`](../../hooks/antigravity/STDIN_SHAPE.md)
|
||||
for the exact wire format Antigravity uses, and
|
||||
[`website/guide/antigravity.md`](../../website/guide/antigravity.md)
|
||||
for the full user-facing guide.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"mempalace-save": {
|
||||
"Stop": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/ABSOLUTE/PATH/TO/mempalace/hooks/antigravity/mempal_save_hook_antigravity.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"mempalace-wake": {
|
||||
"PreInvocation": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/ABSOLUTE/PATH/TO/mempalace/hooks/antigravity/mempal_wake_hook_antigravity.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# Cursor IDE Hooks — Example `hooks.json` Files
|
||||
|
||||
Sample configurations for wiring the MemPalace Cursor hooks into the
|
||||
Cursor IDE. These are **examples only** — they are intentionally not
|
||||
placed at the repo root (`/.cursor/hooks.json`) because Cursor
|
||||
auto-loads project-level hooks from any trusted workspace, and the
|
||||
repo is regularly opened by contributors. We do not auto-arm hooks on
|
||||
contributor checkout.
|
||||
|
||||
## Variants
|
||||
|
||||
### `hooks.json` — full (recommended)
|
||||
|
||||
Three hooks wired:
|
||||
|
||||
- **`sessionStart`** — calls `mempal_wake_hook_cursor.sh`, which returns
|
||||
`additional_context` telling the agent to recall scoped to the wing
|
||||
inferred from the workspace root. Cursor-only — Claude Code has no
|
||||
equivalent.
|
||||
- **`stop`** — calls `mempal_save_hook_cursor.sh`. Counts stop
|
||||
invocations per conversation and emits a `followup_message` every
|
||||
`MEMPAL_SAVE_INTERVAL` (default 15) telling the agent to file the
|
||||
session into the palace and write a diary entry. `loop_limit: 1` is
|
||||
defense-in-depth on top of our own loop-count check.
|
||||
- **`preCompact`** — calls `mempal_precompact_hook_cursor.sh`. Runs
|
||||
`mempalace mine` synchronously on the transcript before compaction
|
||||
summarises it, then drops a marker so the next `stop` forces a save
|
||||
followup.
|
||||
|
||||
### `hooks.minimal.json` — `stop` only
|
||||
|
||||
Lightest install. Wires just the save hook. Use this if you don't
|
||||
want the sessionStart recall context or the preCompact transcript
|
||||
snapshot.
|
||||
|
||||
## How to use
|
||||
|
||||
The `$HOME` placeholder is **not** expanded by Cursor — you must
|
||||
substitute the absolute path before saving the file. Pick one:
|
||||
|
||||
### Option A — let `install.sh` do it
|
||||
|
||||
Project scope — writes `<repo>/.cursor/hooks.json`:
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope project --target /path/to/your/repo
|
||||
```
|
||||
|
||||
User scope — writes `~/.cursor/hooks.json`, applies to every Cursor workspace:
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope user
|
||||
```
|
||||
|
||||
The installer copies the hook scripts to `~/.mempalace/hooks/cursor/`,
|
||||
substitutes the absolute paths, and merges the entries into your
|
||||
existing `hooks.json` without clobbering unrelated hooks. See
|
||||
`install.sh --help` for `--dry-run`, `--uninstall`, and `--variant`.
|
||||
|
||||
### Option B — copy + edit manually
|
||||
|
||||
1. Copy the chosen example to the target location:
|
||||
- User scope: `~/.cursor/hooks.json`
|
||||
- Project scope: `<your-repo>/.cursor/hooks.json`
|
||||
2. Replace every `$HOME` with the absolute path to your home
|
||||
directory (e.g., `/Users/you` or `/home/you`).
|
||||
3. Make sure each hook script is executable
|
||||
(`chmod +x ~/.mempalace/hooks/cursor/mempal_*_hook_cursor.sh`).
|
||||
4. Restart Cursor, or wait for it to auto-reload the file.
|
||||
|
||||
## Why aren't these files at the repo root?
|
||||
|
||||
Cursor automatically loads `.cursor/hooks.json` from any trusted
|
||||
workspace. Placing a real `hooks.json` at the repo root would arm
|
||||
MemPalace's hooks on every contributor's machine the moment they open
|
||||
the repo in Cursor — which would modify their conversation behaviour
|
||||
without consent and write to `~/.mempalace/hook_state/` without
|
||||
asking. Editor configuration is sacred; opt-in only.
|
||||
|
||||
If you actually want MemPalace's hooks armed when working on the
|
||||
MemPalace repo itself, run:
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope project --target .
|
||||
```
|
||||
|
||||
That will write `./.cursor/hooks.json` for the repo workspace
|
||||
specifically — but it is your decision, not ours, and the file is
|
||||
listed in `.gitignore` paths Cursor users typically already exclude.
|
||||
|
||||
## Related: the Cursor plugin
|
||||
|
||||
The hooks here are **only one half** of MemPalace's Cursor integration. The other half is the [`.cursor-plugin/`](../../.cursor-plugin/) folder at the repo root, which packages MemPalace's MCP server, five slash commands, and the model-invocable `mempalace` skill as a regular Cursor plugin you can drop into `~/.cursor/plugins/local/mempalace`.
|
||||
|
||||
The two install paths are orthogonal — install whichever you want, in any order:
|
||||
|
||||
| You want | Install |
|
||||
|---------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| MCP tools (`mempalace_search`, `mempalace_add_drawer`, …) + slash commands | The plugin — see [`.cursor-plugin/README.md`](../../.cursor-plugin/README.md) |
|
||||
| Auto-save every N turns + sessionStart memory recall | The hooks here — see Option A above |
|
||||
| Both | Install the plugin AND run `hooks/cursor/install.sh` |
|
||||
|
||||
Hooks are deliberately **not** bundled into the plugin because Cursor's hooks system is configured per-user/per-project (in `~/.cursor/hooks.json` or `.cursor/hooks.json`), not per-plugin — so the installer here owns that file with idempotent merge semantics, while the plugin owns the MCP+commands+skill side.
|
||||
|
||||
## See also
|
||||
|
||||
- [`hooks/cursor/README.md`](../../hooks/cursor/README.md) — full reference for hooks
|
||||
- [`hooks/cursor/STDIN_SHAPE.md`](../../hooks/cursor/STDIN_SHAPE.md) — per-event schema with citations
|
||||
- [`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) — rendered docs
|
||||
- [`.cursor-plugin/README.md`](../../.cursor-plugin/README.md) — Cursor plugin (MCP + commands + skill)
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"sessionStart": [
|
||||
{
|
||||
"command": "$HOME/.mempalace/hooks/cursor/mempal_wake_hook_cursor.sh"
|
||||
}
|
||||
],
|
||||
"stop": [
|
||||
{
|
||||
"command": "$HOME/.mempalace/hooks/cursor/mempal_save_hook_cursor.sh",
|
||||
"loop_limit": 1
|
||||
}
|
||||
],
|
||||
"preCompact": [
|
||||
{
|
||||
"command": "$HOME/.mempalace/hooks/cursor/mempal_precompact_hook_cursor.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"stop": [
|
||||
{
|
||||
"command": "$HOME/.mempalace/hooks/cursor/mempal_save_hook_cursor.sh",
|
||||
"loop_limit": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Cursor Rules — MemPalace recall
|
||||
|
||||
Optional [Cursor rules](https://cursor.com/docs/rules) that make the
|
||||
agent search MemPalace before answering questions about past work,
|
||||
people, projects, or prior decisions.
|
||||
|
||||
These are for users who install MemPalace **without** the Cursor plugin
|
||||
(or who want recall behaviour in a specific project). If you installed
|
||||
the [Cursor plugin](../../../.cursor-plugin/README.md), it already ships
|
||||
the `alwaysApply: false` rule at the plugin root — you do not need to
|
||||
copy anything.
|
||||
|
||||
## Which file to use
|
||||
|
||||
| File | `alwaysApply` | Fires when | Use when |
|
||||
|------|---------------|------------|----------|
|
||||
| [`mempalace-recall.mdc`](mempalace-recall.mdc) | `false` | Cursor's matcher decides the turn is recall-relevant (from the rule `description`) | **Recommended.** Recall without paying for the rule on unrelated work. |
|
||||
| [`mempalace-recall-always.mdc`](mempalace-recall-always.mdc) | `true` | Every conversation in scope, every turn | You want recall guaranteed in context and accept the cost. |
|
||||
|
||||
The always-on variant is heavier: it sits in context on every turn and
|
||||
makes the agent more eager to call `mempalace_search`, which adds MCP
|
||||
latency and works against MemPalace's "memory should feel instant"
|
||||
budget. Prefer the `false` variant unless you specifically want recall
|
||||
forced into every conversation. Pick **one** of the two — do not install
|
||||
both.
|
||||
|
||||
## Install
|
||||
|
||||
User scope (every workspace) — copy into `~/.cursor/rules/`:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.cursor/rules
|
||||
cp examples/cursor/rules/mempalace-recall.mdc ~/.cursor/rules/
|
||||
```
|
||||
|
||||
Project scope (this repo only) — copy into `.cursor/rules/`:
|
||||
|
||||
```bash
|
||||
mkdir -p .cursor/rules
|
||||
cp examples/cursor/rules/mempalace-recall.mdc .cursor/rules/
|
||||
```
|
||||
|
||||
For the aggressive variant, copy `mempalace-recall-always.mdc` instead
|
||||
(only one of the two). Then reload Cursor:
|
||||
<kbd>Cmd</kbd>-<kbd>Shift</kbd>-<kbd>P</kbd> → **Developer: Reload Window**.
|
||||
|
||||
## How recall is delivered
|
||||
|
||||
Recall ships in three orthogonal layers — install any combination:
|
||||
|
||||
| Layer | What it does | Where |
|
||||
|-------|--------------|-------|
|
||||
| `sessionStart` hook | Injects wing-scoped recall context once per new chat | [`hooks/cursor/`](../../../hooks/cursor/) |
|
||||
| `mempalace-recall` skill | Full search-before-answer protocol, model-invoked or attached | [`skills/mempalace-recall/`](../../../skills/mempalace-recall/) |
|
||||
| Recall rule (these files) | Nudges search-before-answer on recall-relevant turns | here, or the plugin root `rules/` |
|
||||
|
||||
All three reference the same canonical protocol in
|
||||
[`integrations/shared/recall-protocol.md`](../../../integrations/shared/recall-protocol.md).
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: Always-on MemPalace recall — search the palace before answering about past work, people, projects, or prior decisions.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# MemPalace recall (always on)
|
||||
|
||||
This is the aggressive variant of the recall rule: `alwaysApply: true`
|
||||
loads it into every conversation in scope, on every turn, regardless of
|
||||
whether Cursor's matcher thinks recall is relevant.
|
||||
|
||||
Before answering anything that may already be in the user's memory
|
||||
palace — past work, prior decisions, a person, a project, or "what did
|
||||
we do / decide / discuss last time?" — search the palace first:
|
||||
|
||||
1. Call `mempalace_search` with a short keyword query. Use
|
||||
`mempalace_kg_query` for relational or time-bound facts.
|
||||
2. Quote the drawer's **verbatim** text. Never summarize or paraphrase
|
||||
stored content.
|
||||
3. If results are empty, say so — do not invent an answer. If the MCP
|
||||
server is unavailable, surface the error; do not fall back to guessing.
|
||||
|
||||
Even with this rule always loaded, only actually call the tools when the
|
||||
question touches memory. Do not search on pure greenfield work
|
||||
(renaming a variable, fixing a typo).
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: When the user asks about past work, prior decisions, people, projects, or events that may be filed in MemPalace, call mempalace_search (or mempalace_kg_query for relational or time-bound facts) before answering from model memory. Return stored content verbatim; never guess when the palace might know.
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# MemPalace recall
|
||||
|
||||
Before answering anything that may already be in the user's memory
|
||||
palace — past work, prior decisions, a person, a project, or "what did
|
||||
we do / decide / discuss last time?" — search the palace first:
|
||||
|
||||
1. Call `mempalace_search` with a short keyword query. Use
|
||||
`mempalace_kg_query` for relational or time-bound facts.
|
||||
2. Quote the drawer's **verbatim** text. Never summarize or paraphrase
|
||||
stored content.
|
||||
3. If results are empty, say so — do not invent an answer. If the MCP
|
||||
server is unavailable, surface the error; do not fall back to guessing.
|
||||
|
||||
Skip recall for pure greenfield work with no memory relevance (renaming
|
||||
a variable, fixing a typo). Recall is question-driven, not reflexive.
|
||||
@@ -0,0 +1,92 @@
|
||||
# MX3 Public Shim Embeddings and Rerank
|
||||
|
||||
MemPalace can optionally route Chroma embeddings and post-retrieval rerank through the public
|
||||
MX3 shim helpers.
|
||||
|
||||
This path is local by default. It sends the live search query and candidate hits to the configured
|
||||
shim endpoint, so remote endpoints are blocked unless you explicitly opt in, and any remote
|
||||
endpoint must use HTTPS.
|
||||
|
||||
Public repo:
|
||||
|
||||
- `https://github.com/grtninja/mx3-public-shim`
|
||||
|
||||
## Hardware required
|
||||
|
||||
This setup requires a MemryX MX3 accelerator in an M.2 slot behind the configured public-shim
|
||||
endpoint.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install "mx3-public-shim[mempalace] @ git+https://github.com/grtninja/mx3-public-shim.git"
|
||||
```
|
||||
|
||||
## Validate before enabling
|
||||
|
||||
Use this path only with a hardware-backed public-shim deployment.
|
||||
|
||||
```bash
|
||||
python -m mx3_public_shim.doctor
|
||||
```
|
||||
|
||||
MemPalace rejects the explicit `cpu_reference` fallback, but you should still validate the
|
||||
configured endpoint before enabling it:
|
||||
|
||||
- the runtime must expose an embeddings provider
|
||||
- the selected provider must not be `cpu_reference`
|
||||
- the endpoint should be the local public shim unless you intentionally opt in to a remote HTTPS
|
||||
target
|
||||
|
||||
On Windows, a healthy doctor report normally shows:
|
||||
|
||||
- `mx3_linux` unavailable because the direct official Python runtime is Linux-only
|
||||
- `openai_compat` available and selected for `embeddings`
|
||||
- `openai_compat` available and selected for `chat`
|
||||
|
||||
That means MemPalace is using the local public-shim OpenAI-compatible surface while the required
|
||||
hardware lives behind that endpoint.
|
||||
|
||||
## Enable embeddings + rerank in MemPalace
|
||||
|
||||
```bash
|
||||
export MEMPALACE_EMBEDDING_BACKEND=mx3_public_shim
|
||||
export MEMPALACE_SEARCH_RERANKER=mx3_public_shim
|
||||
export MEMPALACE_MX3_PUBLIC_SHIM_BASE_URL=http://127.0.0.1:9000/v1
|
||||
export MEMPALACE_MX3_PUBLIC_SHIM_EMBEDDING_MODEL=text-embedding-nomic-embed-text-v1.5
|
||||
export MEMPALACE_MX3_PUBLIC_SHIM_PROVIDER_ORDER=mx3_linux,openai_compat
|
||||
export MEMPALACE_MX3_PUBLIC_SHIM_REQUIRE_HARDWARE=1
|
||||
```
|
||||
|
||||
On Windows, the direct `mx3_linux` provider is not expected to resolve; the normal path is a local
|
||||
public-shim OpenAI-compatible endpoint that is itself backed by the required hardware.
|
||||
|
||||
If your endpoint requires authentication, also set:
|
||||
|
||||
```bash
|
||||
export MEMPALACE_MX3_PUBLIC_SHIM_API_KEY=...
|
||||
```
|
||||
|
||||
`MEMPALACE_EMBEDDING_BACKEND` and `MEMPALACE_SEARCH_RERANKER` can be enabled independently. Use
|
||||
both when you want MX3-backed Chroma embeddings plus post-retrieval rerank.
|
||||
|
||||
If you intentionally point `MEMPALACE_MX3_PUBLIC_SHIM_BASE_URL` at a non-localhost endpoint, also
|
||||
set:
|
||||
|
||||
```bash
|
||||
export MEMPALACE_MX3_PUBLIC_SHIM_ALLOW_REMOTE=1
|
||||
```
|
||||
|
||||
## Optional example script
|
||||
|
||||
The companion example below demonstrates the narrow Chroma seam directly:
|
||||
|
||||
```bash
|
||||
python examples/mx3_public_shim_embeddings_rerank.py
|
||||
```
|
||||
|
||||
Then search normally:
|
||||
|
||||
```bash
|
||||
mempalace search "auth decisions"
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Example: query a local public-shim embeddings endpoint and pass vectors into Chroma."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib import request
|
||||
|
||||
try:
|
||||
from chromadb import PersistentClient
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit("Install chromadb before running this example.") from exc
|
||||
|
||||
|
||||
BASE_URL = os.environ.get("MEMPALACE_MX3_PUBLIC_SHIM_BASE_URL", "http://127.0.0.1:9000/v1")
|
||||
EMBEDDINGS_URL = BASE_URL.rstrip("/") + "/embeddings"
|
||||
EMBED_MODEL = os.environ.get(
|
||||
"MEMPALACE_MX3_PUBLIC_SHIM_EMBEDDING_MODEL",
|
||||
"text-embedding-nomic-embed-text-v1.5",
|
||||
)
|
||||
|
||||
|
||||
def fetch_embeddings(texts: list[str]) -> list[list[float]]:
|
||||
payload = json.dumps({"model": EMBED_MODEL, "input": texts}).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = os.environ.get("MEMPALACE_MX3_PUBLIC_SHIM_API_KEY")
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = request.Request(
|
||||
EMBEDDINGS_URL,
|
||||
data=payload,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
with request.urlopen(req, timeout=20) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
data = body.get("data", [])
|
||||
if len(data) != len(texts):
|
||||
raise RuntimeError("Embedding response count did not match input count")
|
||||
return [row["embedding"] for row in data]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
documents = [
|
||||
"A local retrieval queue keeps notes that still need verification.",
|
||||
"The workstation stores a short MX3 setup checklist for repeatable bring-up.",
|
||||
"This note is about watering tomatoes in the backyard.",
|
||||
]
|
||||
metadatas = [
|
||||
{"wing": "verification"},
|
||||
{"wing": "operations"},
|
||||
{"wing": "personal"},
|
||||
]
|
||||
query = "How does the system track notes that still need verification?"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
client = PersistentClient(path=str(Path(tmpdir)))
|
||||
collection = client.get_or_create_collection("mempalace_drawers")
|
||||
doc_vectors = fetch_embeddings(documents)
|
||||
collection.add(
|
||||
ids=["1", "2", "3"],
|
||||
documents=documents,
|
||||
embeddings=doc_vectors,
|
||||
metadatas=metadatas,
|
||||
)
|
||||
query_vector = fetch_embeddings([query])[0]
|
||||
result = collection.query(query_embeddings=[query_vector], n_results=3)
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+47
-1
@@ -2,6 +2,13 @@
|
||||
|
||||
These hook scripts make MemPalace save automatically. No manual "save" commands needed.
|
||||
|
||||
This file covers the **Claude Code** and **Codex CLI** hooks that live
|
||||
flat under `hooks/`. For the **Cursor IDE** hooks, see
|
||||
[`hooks/cursor/README.md`](cursor/README.md) or the rendered docs at
|
||||
[`website/guide/cursor-hooks.md`](../website/guide/cursor-hooks.md). The
|
||||
two are additive and share the same `~/.mempalace/hook_state/`
|
||||
directory.
|
||||
|
||||
If you are trying to protect existing Claude Code transcripts immediately,
|
||||
use the short checklist first: [`website/guide/claude-code-retention.md`](../website/guide/claude-code-retention.md).
|
||||
It covers hook wiring, JSONL backup, and one-time backfill.
|
||||
@@ -11,6 +18,7 @@ It covers hook wiring, JSONL backup, and one-time backfill.
|
||||
| Hook | When It Fires | What Happens |
|
||||
|------|--------------|-------------|
|
||||
| **Save Hook** | Every 15 human messages | Auto-mines transcript (tool output included), then blocks the AI to save topics/decisions/quotes |
|
||||
| **SessionEnd Hook** | Clean session exit | Backgrounds a final transcript mine (when a transcript exists) so short sessions aren't lost; returns immediately so teardown is never delayed. A lightweight diary checkpoint is written in the detached child. |
|
||||
| **PreCompact Hook** | Right before context compaction | Auto-mines transcript, then emergency save — forces the AI to save EVERYTHING before losing context |
|
||||
|
||||
**Two-layer capture:** Hooks auto-mine the JSONL transcript directly into the palace (capturing raw tool output — Bash results, search findings, build errors). They also block the AI with a reason message telling it to save verbatim tool output and key context. Belt and suspenders — tool output gets stored even if the AI summarizes instead of quoting.
|
||||
@@ -30,6 +38,13 @@ Add to `.claude/settings.local.json`:
|
||||
"timeout": 30
|
||||
}]
|
||||
}],
|
||||
"SessionEnd": [{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "/absolute/path/to/hooks/mempal_session_end_hook.sh",
|
||||
"timeout": 10
|
||||
}]
|
||||
}],
|
||||
"PreCompact": [{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
@@ -41,11 +56,35 @@ Add to `.claude/settings.local.json`:
|
||||
}
|
||||
```
|
||||
|
||||
`SessionEnd` runs once on a clean exit and backgrounds its work, so it
|
||||
returns instantly and stays within Claude Code's SessionEnd budget. Wired
|
||||
through `settings.local.json` (above) the `timeout` can raise that budget;
|
||||
the bundled plugin cannot, which is why the hook backgrounds rather than
|
||||
mining in the foreground.
|
||||
|
||||
Make them executable:
|
||||
```bash
|
||||
chmod +x hooks/mempal_save_hook.sh hooks/mempal_precompact_hook.sh
|
||||
chmod +x hooks/mempal_save_hook.sh hooks/mempal_session_end_hook.sh hooks/mempal_precompact_hook.sh
|
||||
```
|
||||
|
||||
## Install — Antigravity (Google)
|
||||
|
||||
The Antigravity integration lives in its own subdirectory because the
|
||||
wire format (camelCase JSON, `injectSteps[]` output) and event names
|
||||
(`Stop`, `PreInvocation`) are Antigravity-specific. Use the dedicated
|
||||
installer:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh
|
||||
```
|
||||
|
||||
This installs to `~/.gemini/config/plugins/mempalace/`, registers the
|
||||
MCP server, ships the `mempalace` skill, and wires the Stop +
|
||||
PreInvocation hooks. See [`hooks/antigravity/README.md`](antigravity/README.md)
|
||||
for the full guide and [`hooks/antigravity/INVESTIGATION.md`](antigravity/INVESTIGATION.md)
|
||||
for the source-of-truth audit of which Antigravity surfaces the
|
||||
integration uses.
|
||||
|
||||
## Install — Codex CLI (OpenAI)
|
||||
|
||||
Add to `.codex/hooks.json`:
|
||||
@@ -65,6 +104,13 @@ Add to `.codex/hooks.json`:
|
||||
}
|
||||
```
|
||||
|
||||
**Other harnesses:** the clean-exit save runs through the harness-agnostic
|
||||
`mempalace hook run --hook session-end` entry point. This release wires it
|
||||
for Claude Code. Antigravity exposes no dedicated session-end event (its
|
||||
lifecycle hooks are PreToolUse/PostToolUse/PreInvocation/PostInvocation/Stop,
|
||||
and MemPalace already saves there via `Stop`); Cursor and Codex can adopt the
|
||||
same entry point as a follow-up wherever their own session-end event is available.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `mempal_save_hook.sh` to change:
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# Antigravity IDE — Integration Surface Investigation
|
||||
|
||||
**Investigated**: 2026-05-27
|
||||
**Author**: undeadindustries
|
||||
**Scope**: What MemPalace can integrate with in Google's Antigravity IDE,
|
||||
what we shipped, and what we deliberately did not ship.
|
||||
|
||||
This document is the source of truth for design decisions in the
|
||||
`feat/antigravity-support` branch. It exists so a future maintainer can
|
||||
re-derive every choice without re-reading the docs cold.
|
||||
|
||||
---
|
||||
|
||||
## 1. Surfaces verified against Google's official Antigravity docs
|
||||
|
||||
All quotes are pulled verbatim from Google's official Antigravity
|
||||
documentation on 2026-05-27. URLs are the authoritative source; the
|
||||
mirrored excerpts here are for reviewer convenience.
|
||||
|
||||
### 1.1. MCP — `https://antigravity.google/docs/mcp`
|
||||
|
||||
> The configuration file is located at `~/.gemini/antigravity/mcp_config.json`.
|
||||
>
|
||||
> The configuration file has a single `mcpServers` object where you
|
||||
> define each server you want to connect to.
|
||||
|
||||
Schema (verified):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"<name>": {
|
||||
"command": "...", // stdio
|
||||
"args": [...],
|
||||
"env": {...},
|
||||
"cwd": "...",
|
||||
"serverUrl": "...", // remote
|
||||
"headers": {...},
|
||||
"authProviderType": "google_credentials",
|
||||
"oauth": {"clientId": "...", "clientSecret": "..."},
|
||||
"disabled": false,
|
||||
"disabledTools": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-plugin form: `mcp_config.json` at the plugin root, same shape.
|
||||
Antigravity merges plugin entries with the user's
|
||||
`~/.gemini/antigravity/mcp_config.json` rather than clobbering.
|
||||
|
||||
**Cross-checked locally**: the user's existing
|
||||
`~/.gemini/antigravity/mcp_config.json` already contains a working
|
||||
`"mempalace": {"command": "/Users/robs/.local/bin/mempalace-mcp"}`
|
||||
entry, proving the shape matches and the binary is on PATH.
|
||||
|
||||
### 1.2. Plugins — `https://antigravity.google/docs/plugins`
|
||||
|
||||
> A plugin is a directory containing a `plugin.json` file and optional
|
||||
> subdirectories for different customization types:
|
||||
>
|
||||
> ```
|
||||
> plugins/<plugin-name>/
|
||||
> ├── plugin.json # Required marker file
|
||||
> ├── mcp_config.json # Optional MCP server definitions
|
||||
> ├── hooks.json # Optional hooks definition
|
||||
> ├── skills/ # Optional skills
|
||||
> │ └── <skill-name>/
|
||||
> │ └── SKILL.md
|
||||
> └── rules/ # Optional rules
|
||||
> └── <rule-name>.md
|
||||
> ```
|
||||
|
||||
Manifest schema (verified):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-custom-plugin"
|
||||
}
|
||||
```
|
||||
|
||||
> The name field is optional and defaults to the directory name if omitted.
|
||||
|
||||
Install locations (verified):
|
||||
|
||||
> - Workspace Level: Place your plugin folder inside a
|
||||
> `.agents/plugins/` or `_agents/plugins/` directory at the root of
|
||||
> your opened workspace.
|
||||
> - Global Level: Place your plugin folder inside
|
||||
> `~/.gemini/config/plugins/` in your user home directory.
|
||||
|
||||
We ship the global location as the canonical install path.
|
||||
|
||||
### 1.3. Skills — `https://antigravity.google/docs/skills`
|
||||
|
||||
> A skill is a folder containing a `SKILL.md` file with instructions
|
||||
> that the agent can follow when working on specific tasks.
|
||||
|
||||
Frontmatter (verified):
|
||||
|
||||
| Field | Required | Description |
|
||||
|---------------|----------|---------------------------------------------------------------------------------------------------|
|
||||
| `name` | No | A unique identifier (lowercase, hyphens). Defaults to the folder name. |
|
||||
| `description` | Yes | A clear description of what the skill does and when to use it. |
|
||||
|
||||
Standalone discovery paths (verified):
|
||||
|
||||
| Location | Scope |
|
||||
|-------------------------------------|------------------------|
|
||||
| `<workspace>/.agents/skills/` | Workspace-specific |
|
||||
| `~/.gemini/antigravity/skills/` | Global, all workspaces |
|
||||
|
||||
In-plugin discovery: `<plugin>/skills/<skill-name>/SKILL.md`.
|
||||
|
||||
We ship the in-plugin form so a single install registers MCP, skill, and
|
||||
hooks together.
|
||||
|
||||
### 1.4. Hooks — `https://antigravity.google/docs/hooks?app=antigravity`
|
||||
|
||||
> Hooks allow you to run custom scripts or shell commands at specific
|
||||
> points during Antigravity's execution loop.
|
||||
|
||||
`hooks.json` lives at one of:
|
||||
- `~/.gemini/config/hooks.json` (global)
|
||||
- `<workspace>/.agents/hooks.json` (workspace)
|
||||
- `<plugin-root>/hooks.json` (per-plugin) — what we ship
|
||||
|
||||
Top-level schema (verified):
|
||||
|
||||
```json
|
||||
{
|
||||
"<hook-name>": {
|
||||
"enabled": true,
|
||||
"PreToolUse": [{ "matcher": "...", "hooks": [{...}] }],
|
||||
"PostToolUse": [{ "matcher": "...", "hooks": [{...}] }],
|
||||
"PreInvocation": [{ ... }],
|
||||
"PostInvocation": [{ ... }],
|
||||
"Stop": [{ ... }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For `PreInvocation` / `PostInvocation` / `Stop`, items are flat handler
|
||||
objects; the `matcher` wrapper is only used for `PreToolUse` /
|
||||
`PostToolUse`.
|
||||
|
||||
Handler object (verified):
|
||||
|
||||
| Field | Required | Description |
|
||||
|-----------|----------|---------------------------------------------------|
|
||||
| `type` | No | Currently only `"command"` is supported. Default. |
|
||||
| `command` | Yes | The shell command to execute. |
|
||||
| `timeout` | No | Timeout in seconds. Defaults to 30. |
|
||||
|
||||
#### STDIN/STDOUT contract (verified)
|
||||
|
||||
> Hooks receive input via stdin as JSON and should return output via
|
||||
> stdout as JSON. Field names use camelCase.
|
||||
|
||||
Common stdin fields (every event):
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------------------|-----------------|-------------------------------------------------------|
|
||||
| `conversationId` | string | The unique UUID of the active agent conversation. |
|
||||
| `workspacePaths` | array<string> | Absolute directory paths of the user's workspaces. |
|
||||
| `transcriptPath` | string | Absolute path to the persistent `transcript.jsonl`. |
|
||||
| `artifactDirectoryPath` | string | Path to conversation artifacts and screenshots. |
|
||||
|
||||
`Stop` event additional fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------------|---------|----------------------------------------------------------------------------|
|
||||
| `executionNum` | integer | Sequence number of the execution attempt. |
|
||||
| `terminationReason` | string | `"model_stop"`, `"max_steps_exceeded"`, `"error"`, etc. |
|
||||
| `error` | string | Optional error message. |
|
||||
| `fullyIdle` | boolean | **Required.** True iff all background commands and async tasks are done. |
|
||||
|
||||
`Stop` event stdout (verified):
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|--------|-------------------------------------------------------------------------------------------------------|
|
||||
| `decision` | string | **Required.** Set to `"continue"` to FORCE the agent to keep running. Anything else allows the stop. |
|
||||
| `reason` | string | Optional. If `decision == "continue"`, injected as a system message. |
|
||||
|
||||
**CRITICAL**: emitting `{"decision": "continue"}` from a save hook would
|
||||
turn it into an infinite agent-loop trigger. The MemPalace save hook
|
||||
MUST emit `{}` on every code path. There is an explicit refusal in
|
||||
`mempal_save_hook_antigravity.sh` to ever print the literal word
|
||||
`"continue"` from a decision field.
|
||||
|
||||
`PreInvocation` event additional fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------------|---------|----------------------------------------------------------|
|
||||
| `invocationNum` | integer | Sequence number of the current model invocation. |
|
||||
| `initialNumSteps` | integer | Number of steps currently in the trajectory. |
|
||||
|
||||
`PreInvocation` event stdout (verified):
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------|----------------|-----------------------------------------------------------------------------------------------|
|
||||
| `injectSteps` | array<object> | Optional. Steps injected before the model is called. Each step has one of: |
|
||||
| | | `{ "toolCall": {...} }` / `{ "userMessage": "..." }` / `{ "ephemeralMessage": "..." }` |
|
||||
|
||||
We use `ephemeralMessage` for the wake-up injection: the message is
|
||||
visible to the model on this turn but does not persist to the
|
||||
transcript, so we do not pollute future model calls with the same
|
||||
injection.
|
||||
|
||||
`PreInvocation` fires before EVERY model invocation, not only at session
|
||||
start. We gate to `invocationNum == 1` to mimic Cursor's `sessionStart`
|
||||
semantics — exactly one wake injection per conversation.
|
||||
|
||||
### 1.5. Permissions — `https://antigravity.google/docs/permissions`
|
||||
|
||||
Permissions are user-side, configured via Allow / Deny / Ask lists.
|
||||
Plugins do **not** declare permissions in `plugin.json`. The
|
||||
third-party "antigravity-plugins" community skill at
|
||||
`~/.gemini/skills/antigravity-plugins/SKILL.md` documents a
|
||||
`"permissions": [...]` field in `plugin.json`; that field is fabricated
|
||||
and does not appear in any real Google-shipped plugin (`firebase`,
|
||||
`google-antigravity-sdk`, `chrome-devtools-plugin`,
|
||||
`modern-web-guidance-plugin`) inspected at
|
||||
`~/.gemini/config/plugins/`.
|
||||
|
||||
We ship a minimal `plugin.json` of `{"name": "mempalace"}`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Surfaces shipped
|
||||
|
||||
| Surface | What we ship |
|
||||
|---------------------------|---------------------------------------------------------------------------|
|
||||
| Plugin manifest | `.antigravity-plugin/plugin.json` — minimal, verified shape |
|
||||
| MCP auto-registration | `.antigravity-plugin/mcp_config.json` — registers `mempalace-mcp` stdio |
|
||||
| Skill | `.antigravity-plugin/skills/mempalace/SKILL.md` — real file, frontmatter |
|
||||
| `Stop` hook | `hooks/antigravity/mempal_save_hook_antigravity.sh` — counter + auto-mine |
|
||||
| `PreInvocation` hook | `hooks/antigravity/mempal_wake_hook_antigravity.sh` — wake injection |
|
||||
| Installer | `hooks/antigravity/install.sh` — idempotent, basename-match uninstall |
|
||||
| User-facing docs | `website/guide/antigravity.md` + sidebar wiring |
|
||||
| Examples | `examples/antigravity/{hooks.json,mcp_config.json,README.md}` |
|
||||
| Tests | 3 test files mirroring the cursor blueprint |
|
||||
|
||||
---
|
||||
|
||||
## 3. Surfaces deliberately not shipped
|
||||
|
||||
### 3.1. `PreCompact` equivalent — NOT SHIPPED
|
||||
|
||||
Antigravity's external `hooks.json` does **not** expose a context-compaction event. The Python SDK has an in-process `@hooks.on_compaction`
|
||||
decorator (see `~/.gemini/config/plugins/google-antigravity-sdk/examples/getting_started/hooks.md`),
|
||||
but that fires inside a Python `LocalAgentConfig`-built agent, not the
|
||||
IDE itself. There is no way to subscribe to compaction from a
|
||||
plugin's `hooks.json`.
|
||||
|
||||
UX consequence: long conversations can auto-compact without a save
|
||||
checkpoint. The `Stop` hook still catches the conversation when the
|
||||
user actually ends the turn, so the worst case is that some mid-turn
|
||||
state is lost on auto-compaction. Verbatim transcript ingestion via
|
||||
the `Stop` path covers the long-term recall use case.
|
||||
|
||||
### 3.2. Slash-commands / `commands/` — NOT SHIPPED
|
||||
|
||||
Antigravity has no `commands/` plugin component. The Cursor and Codex
|
||||
integrations both ship five quick-reference commands
|
||||
(`mempalace-help`, `-init`, `-mine`, `-search`, `-status`) that point
|
||||
at `mempalace instructions <cmd>`. Those have been folded into the
|
||||
`SKILL.md` `## Common operations` section so the agent gets the same
|
||||
quick-reference content via Antigravity's progressive-disclosure skill
|
||||
loading. No new files, no rule-noise, same discoverability.
|
||||
|
||||
### 3.3. `rules/` — NOT SHIPPED
|
||||
|
||||
`rules/<name>.md` files are evaluated as constraints on the agent's
|
||||
behavior. Shipping rules from MemPalace risks colliding with the
|
||||
user's existing project rules (e.g. `.agents/rules/*.md` files the
|
||||
user has already authored). Users who want strict MemPalace-related
|
||||
rules can drop them into their own `<workspace>/.agents/rules/`
|
||||
directory; we do not impose them.
|
||||
|
||||
### 3.4. Workspace-level `.agents/plugins/mempalace/` install — NOT SHIPPED BY DEFAULT
|
||||
|
||||
The installer writes to the global location at
|
||||
`~/.gemini/config/plugins/mempalace/`. Workspace-scoped installs are
|
||||
documented in `hooks/antigravity/README.md` for users who want to
|
||||
limit MemPalace to one workspace; they can `cp -r .antigravity-plugin
|
||||
<workspace>/.agents/plugins/mempalace`. We do not install there
|
||||
automatically because the canonical UX is global.
|
||||
|
||||
### 3.5. `permissions` field in `plugin.json` — NOT SHIPPED
|
||||
|
||||
Antigravity permissions are user-side (`Allow` / `Deny` / `Ask`
|
||||
lists). Plugin manifests do not declare permissions. The
|
||||
`"permissions": [...]` field documented in the third-party
|
||||
"antigravity-plugins" community skill is fabricated; no
|
||||
Google-shipped plugin uses it.
|
||||
|
||||
### 3.6. `PreToolUse` / `PostToolUse` hooks — NOT SHIPPED
|
||||
|
||||
These would let MemPalace observe every tool call (e.g.
|
||||
auto-extract entities after each `write_to_file`). Out of scope
|
||||
for v1; the hook surface is real and could be added in a future
|
||||
PR if there is demand. Documenting the omission here so a future
|
||||
contributor doesn't conclude the hooks weren't supported.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-checks against the user's running Antigravity
|
||||
|
||||
The user (`robs@`) has Antigravity 2.0 installed. The following live
|
||||
artifacts on this machine corroborate the published docs:
|
||||
|
||||
| Path | Confirms |
|
||||
|------------------------------------------------------------------------|----------------------------------------------------------------|
|
||||
| `~/.gemini/antigravity/mcp_config.json` | MCP config path + standard `mcpServers` shape |
|
||||
| `~/.gemini/antigravity/skills/<skill-name>/SKILL.md` (multiple) | Global skill discovery path |
|
||||
| `~/.gemini/config/plugins/firebase/plugin.json` | Real `plugin.json` shape (no `permissions` field) |
|
||||
| `~/.gemini/config/plugins/chrome-devtools-plugin/skills/.../SKILL.md` | In-plugin skill discovery `<plugin>/skills/<name>/SKILL.md` |
|
||||
| `~/.gemini/config/plugins/google-antigravity-sdk/examples/.../hooks.md` | SDK-side compaction hook is in-process Python only |
|
||||
| Existing `mempalace` entry in `mcp_config.json` | `mempalace-mcp` already running and discoverable |
|
||||
|
||||
---
|
||||
|
||||
## 5. Reference URLs (all 2026-05-27)
|
||||
|
||||
- `https://antigravity.google/docs/plugins`
|
||||
- `https://antigravity.google/docs/hooks?app=antigravity`
|
||||
- `https://antigravity.google/docs/skills`
|
||||
- `https://antigravity.google/docs/mcp`
|
||||
- `https://antigravity.google/docs/permissions`
|
||||
- `https://antigravity.google/docs/subagents`
|
||||
- `https://antigravity.google/blog/introducing-google-antigravity-sdk`
|
||||
@@ -0,0 +1,172 @@
|
||||
# MemPalace — Antigravity hook scripts
|
||||
|
||||
Lifecycle hooks for the [Antigravity IDE](https://antigravity.google/).
|
||||
|
||||
This is the third sibling of the Claude Code and Codex integrations
|
||||
(see `hooks/mempal_save_hook.sh` and `.codex-plugin/hooks/`). The
|
||||
overall shape is the same — a Stop event triggers a background save,
|
||||
a startup-time event injects memory into the agent — but the wire
|
||||
format and STDOUT contract are Antigravity-specific (see
|
||||
[STDIN_SHAPE.md](STDIN_SHAPE.md)).
|
||||
|
||||
## Quick start
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh
|
||||
```
|
||||
|
||||
This installs the plugin to `~/.gemini/config/plugins/mempalace/`.
|
||||
Restart Antigravity and the MCP server, skill, and hooks all register
|
||||
automatically.
|
||||
|
||||
To dry-run first:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh --dry-run
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh --uninstall
|
||||
```
|
||||
|
||||
## What gets installed
|
||||
|
||||
```
|
||||
~/.gemini/config/plugins/mempalace/
|
||||
├── plugin.json # marker manifest
|
||||
├── mcp_config.json # registers mempalace-mcp
|
||||
├── hooks.json # rendered from hooks.json.tmpl
|
||||
├── README.md
|
||||
├── skills/
|
||||
│ └── mempalace/
|
||||
│ └── SKILL.md
|
||||
└── hooks/
|
||||
├── lib/
|
||||
│ └── common.sh
|
||||
├── mempal_save_hook_antigravity.sh # Stop event handler
|
||||
└── mempal_wake_hook_antigravity.sh # PreInvocation handler
|
||||
```
|
||||
|
||||
`hooks.json` carries absolute paths to the two hook scripts (resolved
|
||||
from `__PLUGIN_DIR__` at install time).
|
||||
|
||||
## What the hooks do
|
||||
|
||||
### `mempal_save_hook_antigravity.sh` (Stop event)
|
||||
|
||||
Fires every time the agent's execution loop terminates. Increments a
|
||||
per-conversation counter; every `MEMPAL_SAVE_INTERVAL` fires (default
|
||||
15), spawns `mempalace mine <transcript-dir> --mode convos --wing
|
||||
<inferred>` in the background. The hook itself returns `{}` to stdout
|
||||
in under a few milliseconds — the actual mining runs detached and
|
||||
does not block the user.
|
||||
|
||||
Defers when:
|
||||
|
||||
- `fullyIdle == false` (background tasks still running)
|
||||
- `terminationReason == "error"` (transcript may be corrupt)
|
||||
- A previous save for this conversation is still running
|
||||
- Any kill switch is set
|
||||
|
||||
### `mempal_wake_hook_antigravity.sh` (PreInvocation event, gated)
|
||||
|
||||
Fires before every model invocation. Gated to `invocationNum == 1`
|
||||
(first invocation of the conversation only) — beyond that we'd be
|
||||
re-injecting on every turn. Calls `mempalace wake-up --wing <inferred>`
|
||||
with a 500ms hard timeout and emits the verbatim output as an
|
||||
`ephemeralMessage` so the agent sees relevant memory on its first
|
||||
response without polluting the persistent transcript.
|
||||
|
||||
Skips when:
|
||||
|
||||
- `invocationNum != 1`
|
||||
- Already woke this conversation (atomic `mkdir` loop guard)
|
||||
- `mempalace wake-up` exits non-zero, times out, or produces empty output
|
||||
- Any kill switch is set
|
||||
|
||||
## Kill switches
|
||||
|
||||
Any one of these disables both hooks (silent passthrough, exit 0):
|
||||
|
||||
| Knob | Value |
|
||||
|------------------------------------------|--------------------------------|
|
||||
| `MEMPAL_DISABLE_HOOK` | `1`, `true`, `yes` |
|
||||
| `MEMPALACE_HOOKS_AUTO_SAVE` | `false`, `0`, `no` |
|
||||
| `~/.mempalace/config.json` | `{"hooks": {"auto_save": false}}` |
|
||||
| Removing `~/.mempalace/` entirely | (palace nuke) |
|
||||
|
||||
## Workspace-scoped install (advanced)
|
||||
|
||||
If you want MemPalace to load only inside a specific workspace,
|
||||
manually copy the rendered plugin into your workspace's `.agents/plugins/`:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh --install-dir /tmp/render-stage
|
||||
mkdir -p <workspace>/.agents/plugins/
|
||||
cp -r /tmp/render-stage <workspace>/.agents/plugins/mempalace
|
||||
rm -rf /tmp/render-stage
|
||||
```
|
||||
|
||||
The global install at `~/.gemini/config/plugins/mempalace/` is the
|
||||
canonical UX and what we recommend.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hooks aren't firing
|
||||
|
||||
1. Confirm Antigravity sees the plugin: open the IDE, navigate to the
|
||||
Customizations page; `mempalace` should appear in the global plugins
|
||||
list.
|
||||
2. Check `~/.mempalace/hook_state/antigravity_hook.log` — every fire
|
||||
logs a line. No log lines = the hook is not being invoked.
|
||||
3. Verify `mempalace-mcp` is on `$PATH`: `mempalace-mcp --version`.
|
||||
4. Inspect the rendered `hooks.json` paths point at executable files:
|
||||
`bash -n ~/.gemini/config/plugins/mempalace/hooks/*.sh`.
|
||||
|
||||
### Save fires but no mining happens
|
||||
|
||||
1. Look for the most recent `[event=stop]` lines in
|
||||
`antigravity_hook.log` — `count` and `interval` should both be
|
||||
visible. Mining only triggers when `count % interval == 0`.
|
||||
2. Ensure a Python that can import `mempalace` is reachable. The hook
|
||||
runs `"$MEMPAL_PYTHON_BIN" -m mempalace`, where `MEMPAL_PYTHON_BIN`
|
||||
is resolved (in order) from `$MEMPAL_PYTHON`, the
|
||||
`mempalace-mcp` / `mempalace` console-script shebang on `$PATH`,
|
||||
then `python3`. A failed probe logs:
|
||||
|
||||
```
|
||||
ERROR: mempalace is not runnable via <python> -m mempalace; install mempalace or set MEMPAL_PYTHON
|
||||
```
|
||||
|
||||
On a GUI-launched Antigravity the harness `PATH` may differ from
|
||||
your shell `PATH`; if the shebang heuristic can't find the right
|
||||
interpreter, export `MEMPAL_PYTHON=/abs/path/python` (e.g.
|
||||
`"$(uv tool dir)/mempalace/bin/python"`) and restart.
|
||||
|
||||
### Wake injection isn't appearing
|
||||
|
||||
1. The wake hook only injects on `invocationNum == 1`. Subsequent
|
||||
invocations are gated.
|
||||
2. The atomic `mkdir` marker
|
||||
`~/.mempalace/hook_state/antigravity_woke_<conversationId>` exists
|
||||
after a successful injection. Remove it to re-inject (rare).
|
||||
3. `mempalace wake-up --wing <inferred>` may be returning empty output
|
||||
if the wing doesn't exist yet. Run `mempalace status` to verify
|
||||
wing presence.
|
||||
|
||||
## See also
|
||||
|
||||
- [INVESTIGATION.md](INVESTIGATION.md) — every Antigravity surface we
|
||||
investigated, with verbatim quotes and source URLs.
|
||||
- [STDIN_SHAPE.md](STDIN_SHAPE.md) — the exact wire format
|
||||
Antigravity uses, with worked examples.
|
||||
- [../mempal_save_hook.sh](../mempal_save_hook.sh) — Claude Code
|
||||
equivalent.
|
||||
- [../../.codex-plugin/hooks/](../../.codex-plugin/hooks/) — Codex
|
||||
equivalent.
|
||||
- [../../website/guide/antigravity.md](../../website/guide/antigravity.md)
|
||||
— full user-facing guide.
|
||||
@@ -0,0 +1,206 @@
|
||||
# Antigravity hook STDIN / STDOUT contract
|
||||
|
||||
This file documents the exact wire format the Antigravity IDE uses
|
||||
when invoking the MemPalace hook scripts. All fields are verbatim
|
||||
from Google's official Antigravity hooks documentation
|
||||
(`https://antigravity.google/docs/hooks?app=antigravity`, accessed
|
||||
2026-05-27). See [INVESTIGATION.md](INVESTIGATION.md) for the
|
||||
provenance audit.
|
||||
|
||||
## Wire format
|
||||
|
||||
Hooks receive **JSON on stdin** and must emit **JSON on stdout**.
|
||||
Field names are **camelCase**.
|
||||
|
||||
Hook execution timeout defaults to 30 seconds. The MemPalace plugin
|
||||
sets the Stop hook timeout to 30s and the PreInvocation hook timeout
|
||||
to 5s in the rendered `hooks.json`.
|
||||
|
||||
## Common stdin fields (every event)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------------------------|-----------------|--------------------------------------------------------|
|
||||
| `conversationId` | string | UUID of the active agent conversation. |
|
||||
| `workspacePaths` | array<string> | Absolute workspace dirs. **First element is canonical**. |
|
||||
| `transcriptPath` | string | Absolute path to `transcript.jsonl`. |
|
||||
| `artifactDirectoryPath` | string | Path to conversation artifacts and screenshots. |
|
||||
|
||||
## Stop event
|
||||
|
||||
### Stdin (additional fields)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---------------------|---------|--------------------------------------------------------------------------------------|
|
||||
| `executionNum` | integer | Sequence number of the execution attempt for this conversation. |
|
||||
| `terminationReason` | string | `"model_stop"`, `"max_steps_exceeded"`, `"error"`, etc. |
|
||||
| `error` | string | Optional. Set when termination was caused by a system error. |
|
||||
| `fullyIdle` | boolean | **Required.** True iff all background commands and async tasks have completed. |
|
||||
|
||||
### Stdout
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------------|--------|--------------------------------------------------------------------------------------------------------|
|
||||
| `decision` | string | If `"continue"`, **forces** the agent to keep running. Anything else allows the stop. |
|
||||
| `reason` | string | Optional. If `decision == "continue"`, injected as a system message into the conversation. |
|
||||
|
||||
**MemPalace policy**: the save hook ALWAYS emits `{}` and exits 0. It
|
||||
NEVER emits `{"decision": "continue"}` — that would force an infinite
|
||||
agent loop. There is an explicit refusal in
|
||||
`mempal_save_hook_antigravity.sh` to ever construct a stdout JSON
|
||||
object containing the literal word `"continue"` in a decision field.
|
||||
|
||||
### MemPalace gating
|
||||
|
||||
The save hook short-circuits with `{}` (no save triggered) when ANY
|
||||
of the following hold:
|
||||
|
||||
1. `MEMPAL_DISABLE_HOOK=1` (or `true`/`yes`) is set.
|
||||
2. `MEMPALACE_HOOKS_AUTO_SAVE=false` (or `0`/`no`) is set.
|
||||
3. `~/.mempalace/config.json` has `hooks.auto_save: false`.
|
||||
4. `~/.mempalace/` directory does not exist (user nuked the palace).
|
||||
5. Stdin is malformed or empty (sentinel-guarded parse failure).
|
||||
6. `fullyIdle == false` (background tasks still running; defer save).
|
||||
7. `terminationReason == "error"` (transcript may be corrupt).
|
||||
8. `transcriptPath` validation fails (not a `.json`/`.jsonl`, or `..` traversal).
|
||||
9. The transcript file does not exist on disk.
|
||||
10. The save counter has not yet hit `count % MEMPAL_SAVE_INTERVAL == 0`.
|
||||
11. A pending save is still running for this conversation (less than 1 hour old).
|
||||
12. The `mempalace` CLI is not on `$PATH`.
|
||||
|
||||
When the modulo gate is hit and validation passes, the hook spawns
|
||||
`mempalace mine <transcript-dir> --mode convos --wing <inferred>` in
|
||||
the background and returns `{}` immediately.
|
||||
|
||||
## PreInvocation event
|
||||
|
||||
### Stdin (additional fields)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------------------|---------|------------------------------------------------------------------|
|
||||
| `invocationNum` | integer | Sequence number of the current model invocation (1-based). |
|
||||
| `initialNumSteps` | integer | Number of steps currently in the trajectory. |
|
||||
|
||||
### Stdout
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---------------|----------------|--------------------------------------------------------------------------------------------------------|
|
||||
| `injectSteps` | array<object> | Optional. Steps to inject before the model is called. Each step has one of: `{"toolCall": {...}}`, `{"userMessage": "..."}`, `{"ephemeralMessage": "..."}` |
|
||||
|
||||
The `ephemeralMessage` form is what the MemPalace wake hook emits — it
|
||||
delivers the wake-up text to the model on this turn but does not
|
||||
persist into the transcript, so subsequent invocations don't see a
|
||||
duplicate.
|
||||
|
||||
### MemPalace gating
|
||||
|
||||
The wake hook short-circuits with `{}` (no injection) when ANY of:
|
||||
|
||||
1. Any kill switch trips (same five conditions as the save hook).
|
||||
2. `invocationNum != 1` — we only inject on the first model call of
|
||||
each conversation, mimicking Cursor's `sessionStart` semantics.
|
||||
3. The atomic `mkdir`-based loop guard is already taken (this
|
||||
conversation already received a wake injection).
|
||||
4. `mempalace wake-up --wing <inferred>` exits non-zero, times out
|
||||
(500ms hard cap), or produces empty output.
|
||||
|
||||
When the gates pass and `mempalace wake-up` returns text, the hook
|
||||
emits:
|
||||
|
||||
```json
|
||||
{
|
||||
"injectSteps": [
|
||||
{ "ephemeralMessage": "<verbatim wake-up output>" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The wake hook NEVER emits a `decision` field — that field belongs to
|
||||
the Stop event. There is a final guard against any stdout that
|
||||
contains a `decision` key.
|
||||
|
||||
## Worked example: Stop event
|
||||
|
||||
### Input
|
||||
|
||||
```json
|
||||
{
|
||||
"executionNum": 1,
|
||||
"terminationReason": "model_stop",
|
||||
"error": "",
|
||||
"fullyIdle": true,
|
||||
"conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587",
|
||||
"workspacePaths": ["/home/me/projects/mempalace"],
|
||||
"transcriptPath": "/home/me/projects/mempalace/.gemini/jetski/transcript.jsonl",
|
||||
"artifactDirectoryPath": "/home/me/projects/mempalace/.gemini/jetski/artifacts"
|
||||
}
|
||||
```
|
||||
|
||||
### Output (always)
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
(Side effects: counter `~/.mempalace/hook_state/antigravity_save_count_<id>` is
|
||||
incremented; if the modulo gate fires, a background `mempalace mine`
|
||||
subprocess is spawned with the transcript directory and the inferred
|
||||
wing `wing_mempalace`.)
|
||||
|
||||
## Worked example: PreInvocation, first invocation
|
||||
|
||||
### Input
|
||||
|
||||
```json
|
||||
{
|
||||
"invocationNum": 1,
|
||||
"initialNumSteps": 0,
|
||||
"conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587",
|
||||
"workspacePaths": ["/home/me/projects/mempalace"],
|
||||
"transcriptPath": "/home/me/projects/mempalace/.gemini/jetski/transcript.jsonl",
|
||||
"artifactDirectoryPath": "/home/me/projects/mempalace/.gemini/jetski/artifacts"
|
||||
}
|
||||
```
|
||||
|
||||
### Output (when the palace has memory for `wing_mempalace`)
|
||||
|
||||
```json
|
||||
{
|
||||
"injectSteps": [
|
||||
{
|
||||
"ephemeralMessage": "<exact verbatim text from `mempalace wake-up --wing wing_mempalace`>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Output (when invocationNum != 1, or any gate trips)
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
## State files
|
||||
|
||||
All hook state lives under `~/.mempalace/hook_state/` (overridable
|
||||
via `$MEMPAL_STATE_DIR`) and is namespaced with the `antigravity_`
|
||||
prefix to coexist with Claude Code, Cursor, and Codex hook state in
|
||||
the same directory.
|
||||
|
||||
| File | Purpose |
|
||||
|-----------------------------------------------|-----------------------------------------------|
|
||||
| `antigravity_hook.log` | All hook activity, ISO8601Z timestamps. |
|
||||
| `antigravity_save_count_<conversationId>` | Per-conversation Stop counter. |
|
||||
| `antigravity_pending_<conversationId>` | Marker file for in-flight save subprocess. |
|
||||
| `antigravity_woke_<conversationId>` (dir) | Atomic mkdir marker for wake injection. |
|
||||
| `antigravity_last_input.log` | 4 KB cap, mode 0600, set on parse failure. |
|
||||
| `antigravity_last_python_err.log` | Python stderr from the JSON parser, mode 0600.|
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|--------------------------------|----------------------|-----------------------------------------------------------|
|
||||
| `MEMPAL_PYTHON` | `$(command -v python3)` | Override the Python interpreter used by the hooks. |
|
||||
| `MEMPAL_STATE_DIR` | `~/.mempalace/hook_state` | Override the hook state directory. |
|
||||
| `MEMPAL_SAVE_INTERVAL` | `15` | Save every Nth Stop fire. Floored to >= 1 (no /0). |
|
||||
| `MEMPAL_DISABLE_HOOK` | unset | Set to `1` / `true` / `yes` to disable both hooks. |
|
||||
| `MEMPALACE_HOOKS_AUTO_SAVE` | unset | Set to `false` / `0` / `no` to disable both hooks. |
|
||||
Executable
+357
@@ -0,0 +1,357 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE ANTIGRAVITY INSTALLER
|
||||
#
|
||||
# Idempotent installer for the Antigravity plugin. Copies
|
||||
# .antigravity-plugin/* and hooks/antigravity/{lib,*.sh} into the
|
||||
# install directory (default ~/.gemini/config/plugins/mempalace/),
|
||||
# renders hooks.json.tmpl into hooks.json with absolute paths, and
|
||||
# leaves the result in a state Antigravity will discover on next
|
||||
# launch.
|
||||
#
|
||||
# === Usage ===
|
||||
#
|
||||
# bash hooks/antigravity/install.sh # install with defaults
|
||||
# bash hooks/antigravity/install.sh --dry-run # show what would happen
|
||||
# bash hooks/antigravity/install.sh --uninstall # remove plugin
|
||||
# bash hooks/antigravity/install.sh --install-dir <p> # custom install dir
|
||||
# bash hooks/antigravity/install.sh --log-level debug # noisier output
|
||||
#
|
||||
# === Idempotency ===
|
||||
#
|
||||
# Re-running the installer produces a byte-identical install dir.
|
||||
# Files are only written when their content differs from what is
|
||||
# already on disk (cmp gate). The user's ~/.gemini/config/plugins/
|
||||
# directory is never touched outside the mempalace/ subdirectory.
|
||||
#
|
||||
# === Uninstall safety ===
|
||||
#
|
||||
# Uninstall removes the install dir entirely IFF it is the
|
||||
# mempalace/ plugin directory. We match by basename of the install
|
||||
# dir, never by substring search, so a user who has a sibling plugin
|
||||
# at ~/.gemini/config/plugins/mempalace-foo/ is unaffected.
|
||||
#
|
||||
# === set -e ===
|
||||
#
|
||||
# Installer can use `set -e` (constraint #2 only forbids it in the
|
||||
# hook scripts themselves). On any error we exit non-zero so a CI run
|
||||
# fails loudly.
|
||||
|
||||
set -e
|
||||
set -u
|
||||
|
||||
# ── Repo root resolution ─────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
|
||||
PLUGIN_SRC="$REPO_ROOT/.antigravity-plugin"
|
||||
HOOKS_SRC="$REPO_ROOT/hooks/antigravity"
|
||||
|
||||
# ── Defaults ─────────────────────────────────────────────────────────
|
||||
INSTALL_DIR_DEFAULT="$HOME/.gemini/config/plugins/mempalace"
|
||||
INSTALL_DIR=""
|
||||
DRY_RUN=0
|
||||
UNINSTALL=0
|
||||
LOG_LEVEL="info"
|
||||
|
||||
# ── Args ─────────────────────────────────────────────────────────────
|
||||
print_usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: install.sh [--install-dir DIR] [--dry-run] [--uninstall] [--log-level LEVEL]
|
||||
|
||||
Options:
|
||||
--install-dir DIR Plugin install directory.
|
||||
Default: ~/.gemini/config/plugins/mempalace
|
||||
--dry-run Show what would happen without writing anything.
|
||||
--uninstall Remove the installed plugin.
|
||||
--log-level LEVEL debug | info | warn | error. Default: info.
|
||||
-h, --help Show this help.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--install-dir)
|
||||
INSTALL_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--install-dir=*)
|
||||
INSTALL_DIR="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
--uninstall)
|
||||
UNINSTALL=1
|
||||
shift
|
||||
;;
|
||||
--log-level)
|
||||
LOG_LEVEL="${2:-info}"
|
||||
shift 2
|
||||
;;
|
||||
--log-level=*)
|
||||
LOG_LEVEL="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
print_usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: unknown argument: $1" >&2
|
||||
print_usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$INSTALL_DIR" ]; then
|
||||
INSTALL_DIR="$INSTALL_DIR_DEFAULT"
|
||||
fi
|
||||
|
||||
# ── Absolutize the install dir ───────────────────────────────────────
|
||||
#
|
||||
# The cursor PR review caught that a relative --install-dir would get
|
||||
# baked into hooks.json verbatim, leaving paths like
|
||||
# `./plugins/.../mempal_save_hook_antigravity.sh` that Antigravity
|
||||
# can't resolve at runtime. Absolutize before writing anything.
|
||||
mempal_absolutize() {
|
||||
local p="$1"
|
||||
case "$p" in
|
||||
/*) printf '%s' "$p" ;;
|
||||
~*) printf '%s' "${p/#\~/$HOME}" ;;
|
||||
*)
|
||||
# Resolve relative to the user's $PWD at invocation time.
|
||||
# We never `cd` in the main shell of this installer, so
|
||||
# $PWD is already the user's invocation directory — no
|
||||
# subshell cd dance needed.
|
||||
local base="${PWD}"
|
||||
printf '%s/%s' "$base" "$p"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
INSTALL_DIR="$(mempal_absolutize "$INSTALL_DIR")"
|
||||
# Squash any `//` or `./` or `name/..` artefacts using Python's
|
||||
# os.path.normpath; falls back to the raw value if Python is missing
|
||||
# (which would be very unusual on macOS / Linux).
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
INSTALL_DIR="$(python3 -c 'import os,sys; print(os.path.normpath(sys.argv[1]))' "$INSTALL_DIR")"
|
||||
fi
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────────────
|
||||
log() {
|
||||
local lvl="$1"; shift
|
||||
local msg="$*"
|
||||
case "$lvl" in
|
||||
debug)
|
||||
[ "$LOG_LEVEL" = "debug" ] && echo "[install] DEBUG: $msg"
|
||||
return 0
|
||||
;;
|
||||
info)
|
||||
case "$LOG_LEVEL" in
|
||||
debug|info) echo "[install] $msg" ;;
|
||||
esac
|
||||
;;
|
||||
warn)
|
||||
case "$LOG_LEVEL" in
|
||||
debug|info|warn) echo "[install] WARN: $msg" >&2 ;;
|
||||
esac
|
||||
;;
|
||||
error)
|
||||
echo "[install] ERROR: $msg" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Action helpers (dry-run aware) ──────────────────────────────────
|
||||
run() {
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "[install] DRY-RUN: $*"
|
||||
return 0
|
||||
fi
|
||||
"$@"
|
||||
}
|
||||
|
||||
# ── Render template ──────────────────────────────────────────────────
|
||||
#
|
||||
# Substitutes __PLUGIN_DIR__ in $src into $dst with $INSTALL_DIR.
|
||||
# Emits the rendered file to a temp path first, then promotes it iff
|
||||
# the content differs from what's already at $dst. The cmp gate is
|
||||
# what makes the installer idempotent: a no-op re-run produces no
|
||||
# disk writes (and the test suite asserts byte-equality).
|
||||
render_template() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
if [ ! -f "$src" ]; then
|
||||
log error "template not found: $src"
|
||||
return 1
|
||||
fi
|
||||
local tmp
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/mempal_agy_render.XXXXXX")"
|
||||
# Python over awk/sed — INSTALL_DIR may legitimately contain
|
||||
# characters (spaces, colons) that would require careful escaping
|
||||
# in a sed s/// replacement. Python read+replace handles all of
|
||||
# them uniformly.
|
||||
python3 -c "
|
||||
import sys
|
||||
src, dst, install_dir = sys.argv[1:4]
|
||||
with open(src, 'r') as f:
|
||||
body = f.read()
|
||||
body = body.replace('__PLUGIN_DIR__', install_dir)
|
||||
with open(dst, 'w') as f:
|
||||
f.write(body)
|
||||
" "$src" "$tmp" "$INSTALL_DIR"
|
||||
if [ -f "$dst" ] && cmp -s "$tmp" "$dst"; then
|
||||
rm -f "$tmp"
|
||||
log debug "unchanged: $dst"
|
||||
return 0
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "[install] DRY-RUN: would render $src -> $dst"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
mv "$tmp" "$dst"
|
||||
log info "wrote: $dst"
|
||||
}
|
||||
|
||||
# ── copy_file: cmp-gated copy that preserves mode ────────────────────
|
||||
copy_file() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
if [ ! -f "$src" ]; then
|
||||
log error "missing source file: $src"
|
||||
return 1
|
||||
fi
|
||||
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
||||
log debug "unchanged: $dst"
|
||||
return 0
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "[install] DRY-RUN: would copy $src -> $dst"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
cp "$src" "$dst"
|
||||
log info "wrote: $dst"
|
||||
}
|
||||
|
||||
# ── Uninstall path ───────────────────────────────────────────────────
|
||||
#
|
||||
# We DO NOT remove the install dir by string-substring match against
|
||||
# the path. We require the install dir's basename to be exactly
|
||||
# "mempalace" — that way an unrelated sibling like
|
||||
# ~/.gemini/config/plugins/mempalace-foo/ is left alone, and a
|
||||
# malformed --install-dir like ~ or / cannot wipe the user's home.
|
||||
do_uninstall() {
|
||||
local base
|
||||
base="$(basename "$INSTALL_DIR")"
|
||||
if [ "$base" != "mempalace" ]; then
|
||||
log error "refusing to uninstall: install dir basename is '$base', expected 'mempalace'"
|
||||
log error "(safety guard: prevents accidental wipe of unrelated directories)"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
log info "nothing to uninstall: $INSTALL_DIR does not exist"
|
||||
return 0
|
||||
fi
|
||||
# Verify the dir LOOKS like our plugin before removing — a
|
||||
# plugin.json file with our marker is the proof.
|
||||
if [ ! -f "$INSTALL_DIR/plugin.json" ]; then
|
||||
log error "refusing to uninstall: $INSTALL_DIR has no plugin.json"
|
||||
return 1
|
||||
fi
|
||||
if ! grep -q '"name"[[:space:]]*:[[:space:]]*"mempalace"' "$INSTALL_DIR/plugin.json" 2>/dev/null; then
|
||||
log error "refusing to uninstall: $INSTALL_DIR/plugin.json is not a mempalace plugin"
|
||||
return 1
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "[install] DRY-RUN: would rm -rf $INSTALL_DIR"
|
||||
return 0
|
||||
fi
|
||||
rm -rf "$INSTALL_DIR"
|
||||
log info "uninstalled: $INSTALL_DIR"
|
||||
}
|
||||
|
||||
if [ "$UNINSTALL" -eq 1 ]; then
|
||||
do_uninstall
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Pre-install sanity ───────────────────────────────────────────────
|
||||
if [ ! -d "$PLUGIN_SRC" ]; then
|
||||
log error "missing source: $PLUGIN_SRC"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$HOOKS_SRC" ]; then
|
||||
log error "missing source: $HOOKS_SRC"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Soft-check that mempalace-mcp is on PATH; warn but do not fail.
|
||||
if ! command -v mempalace-mcp >/dev/null 2>&1; then
|
||||
log warn "mempalace-mcp is not on PATH; the MCP server will fail to start until it is."
|
||||
log warn " fix: 'uv tool install mempalace' or 'pip install mempalace'"
|
||||
fi
|
||||
|
||||
# Soft-check ~/.gemini exists; if missing, Antigravity isn't installed.
|
||||
if [ ! -d "$HOME/.gemini" ]; then
|
||||
log warn "$HOME/.gemini not found — Antigravity is probably not installed yet."
|
||||
log warn " the install will still proceed; Antigravity will pick up the plugin on first launch."
|
||||
fi
|
||||
|
||||
log info "install dir: $INSTALL_DIR"
|
||||
|
||||
# ── Install: directories ─────────────────────────────────────────────
|
||||
run mkdir -p "$INSTALL_DIR" \
|
||||
"$INSTALL_DIR/skills/mempalace" \
|
||||
"$INSTALL_DIR/skills/mempalace-recall" \
|
||||
"$INSTALL_DIR/rules" \
|
||||
"$INSTALL_DIR/hooks" \
|
||||
"$INSTALL_DIR/hooks/lib"
|
||||
|
||||
# ── Install: plugin metadata ─────────────────────────────────────────
|
||||
copy_file "$PLUGIN_SRC/plugin.json" "$INSTALL_DIR/plugin.json"
|
||||
copy_file "$PLUGIN_SRC/mcp_config.json" "$INSTALL_DIR/mcp_config.json"
|
||||
copy_file "$PLUGIN_SRC/README.md" "$INSTALL_DIR/README.md"
|
||||
|
||||
# ── Install: skills (real files, no symlinks at the discovery path) ──
|
||||
copy_file "$PLUGIN_SRC/skills/mempalace/SKILL.md" \
|
||||
"$INSTALL_DIR/skills/mempalace/SKILL.md"
|
||||
copy_file "$PLUGIN_SRC/skills/mempalace-recall/SKILL.md" \
|
||||
"$INSTALL_DIR/skills/mempalace-recall/SKILL.md"
|
||||
|
||||
# ── Install: optional recall rule ────────────────────────────────────
|
||||
#
|
||||
# Antigravity discovers markdown rules under the plugin's rules/
|
||||
# directory. This one is recall-only and intentionally lightweight —
|
||||
# it complements the mempalace-recall skill. Shipping it as a plugin
|
||||
# rule (not an always-on global rule) keeps it scoped to recall-
|
||||
# relevant turns, honouring MemPalace's "memory should feel instant"
|
||||
# budget.
|
||||
copy_file "$PLUGIN_SRC/rules/mempalace-recall.md" \
|
||||
"$INSTALL_DIR/rules/mempalace-recall.md"
|
||||
|
||||
# ── Install: hooks ───────────────────────────────────────────────────
|
||||
copy_file "$HOOKS_SRC/lib/common.sh" "$INSTALL_DIR/hooks/lib/common.sh"
|
||||
copy_file "$HOOKS_SRC/mempal_save_hook_antigravity.sh" "$INSTALL_DIR/hooks/mempal_save_hook_antigravity.sh"
|
||||
copy_file "$HOOKS_SRC/mempal_wake_hook_antigravity.sh" "$INSTALL_DIR/hooks/mempal_wake_hook_antigravity.sh"
|
||||
|
||||
# Ensure hook scripts are executable on the install side. cp preserves
|
||||
# mode but a fresh git clone from a tarball might not — chmod is
|
||||
# defensive, idempotent, and bash 3.2 safe.
|
||||
if [ "$DRY_RUN" -ne 1 ]; then
|
||||
chmod 755 "$INSTALL_DIR/hooks/mempal_save_hook_antigravity.sh" 2>/dev/null || true
|
||||
chmod 755 "$INSTALL_DIR/hooks/mempal_wake_hook_antigravity.sh" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Install: render hooks.json from template ─────────────────────────
|
||||
render_template "$PLUGIN_SRC/hooks.json.tmpl" "$INSTALL_DIR/hooks.json"
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
log info "DRY-RUN complete; no files written."
|
||||
else
|
||||
log info "install complete: $INSTALL_DIR"
|
||||
log info "restart Antigravity to load the plugin."
|
||||
fi
|
||||
@@ -0,0 +1,498 @@
|
||||
# shellcheck shell=bash
|
||||
# MEMPALACE ANTIGRAVITY HOOK — shared helpers
|
||||
#
|
||||
# Sourced by the two Antigravity hook scripts:
|
||||
# * mempal_save_hook_antigravity.sh (Stop event)
|
||||
# * mempal_wake_hook_antigravity.sh (PreInvocation event, gated to invocationNum==1)
|
||||
#
|
||||
# Mirrors the conventions of the existing Claude Code hook scripts
|
||||
# (hooks/mempal_save_hook.sh, hooks/mempal_precompact_hook.sh):
|
||||
#
|
||||
# * STATE_DIR layout under ~/.mempalace/hook_state/
|
||||
# * MEMPAL_PYTHON resolution order (override -> $PATH -> bare python3)
|
||||
# * MEMPALACE_HOOKS_AUTO_SAVE=false kill switch (config.json fallback)
|
||||
# * sentinel-guarded Python parser via `sed -n 'Np'` (bash 3.2 safe)
|
||||
# * fail-open on internal errors: emit valid JSON and log, never crash
|
||||
# the hook host
|
||||
#
|
||||
# Antigravity-specific contract differences from Claude / Cursor:
|
||||
#
|
||||
# * Antigravity stdin uses camelCase (transcriptPath, conversationId,
|
||||
# workspacePaths, executionNum, terminationReason, fullyIdle,
|
||||
# invocationNum, initialNumSteps), not the snake_case Claude Code
|
||||
# format (session_id, transcript_path, stop_hook_active).
|
||||
# * Antigravity stdout for Stop event MUST be {} on every success path
|
||||
# because { "decision": "continue" } would force the agent into an
|
||||
# infinite re-execution loop. The save hook explicitly refuses to
|
||||
# ever emit the "continue" decision.
|
||||
# * Antigravity stdout for PreInvocation can carry an "injectSteps"
|
||||
# array of { "ephemeralMessage": "..." } objects to inject memory
|
||||
# into the agent's first turn.
|
||||
#
|
||||
# This file is sourced, not executed, so it intentionally has no
|
||||
# shebang. The `# shellcheck shell=bash` directive above tells
|
||||
# shellcheck to treat it as bash when run standalone.
|
||||
|
||||
# bash 3.2.57 (the macOS default) is the lower bound. Do not use
|
||||
# `mapfile`, `readarray`, `declare -A`, or `${var^^}` — none of those
|
||||
# exist in 3.2. Use `sed -n 'Np'` for line extraction and case-folding
|
||||
# via `tr` instead.
|
||||
|
||||
# ── State directory + log path ────────────────────────────────────────
|
||||
#
|
||||
# Honour MEMPAL_STATE_DIR while keeping the default identical to the
|
||||
# Claude Code hooks so a user running both keeps a single state directory
|
||||
# (constraint #7 in the integration brief).
|
||||
MEMPAL_STATE_DIR="${MEMPAL_STATE_DIR:-$HOME/.mempalace/hook_state}"
|
||||
mkdir -p "$MEMPAL_STATE_DIR" 2>/dev/null
|
||||
MEMPAL_AGY_LOG="$MEMPAL_STATE_DIR/antigravity_hook.log"
|
||||
|
||||
# ── Python interpreter resolution ─────────────────────────────────────
|
||||
#
|
||||
# The hooks run mempalace as `"$MEMPAL_PYTHON_BIN" -m mempalace`, so the
|
||||
# resolved interpreter MUST be one that has the mempalace package
|
||||
# importable. The single most common install path —
|
||||
# `uv tool install mempalace` (and `pipx install`) — puts the
|
||||
# `mempalace` / `mempalace-mcp` *console scripts* on PATH inside an
|
||||
# ISOLATED environment whose interpreter is NOT the system `python3`.
|
||||
# So naively resolving `command -v python3` lands on a system Python
|
||||
# that can't import mempalace, the `-m mempalace` probe fails, and
|
||||
# mining silently never fires. (This bit a real user on PR #1633.)
|
||||
#
|
||||
# Resolution order — first hit wins:
|
||||
# 1. $MEMPAL_PYTHON — explicit operator override
|
||||
# 2. shebang of the mempalace-mcp / mempalace console script on PATH
|
||||
# — pip/uv write these with an absolute-path shebang pointing at
|
||||
# the exact interpreter that owns the package. This is the SAME
|
||||
# console script mcp_config.json launches, so if the MCP server
|
||||
# can start, the hooks resolve a working interpreter too — no
|
||||
# MEMPAL_PYTHON needed for the common install paths.
|
||||
# 3. $(command -v python3) — an activated dev venv /
|
||||
# editable install where python3 itself owns the package
|
||||
# 4. bare "python3" — last-resort fallback
|
||||
#
|
||||
# Steps 2-4 are pure string parsing + stat (no Python subprocess), so
|
||||
# resolution stays cheap enough to run at source time on every hook
|
||||
# fire, including gated-out / kill-switched ones. We deliberately do
|
||||
# NOT run an `import mempalace` probe here: building that import pays
|
||||
# the chromadb/onnx cold-start cost, which a recent perf fix
|
||||
# (df295bd) moved OFF the hook foreground on purpose. The downstream
|
||||
# `-m mempalace --version` probe (backgrounded in the save hook,
|
||||
# subprocessed in the wake hook) is the safety net that catches a
|
||||
# shebang interpreter whose package is genuinely broken.
|
||||
mempal_resolve_python() {
|
||||
# 1. Explicit override always wins.
|
||||
local p="${MEMPAL_PYTHON:-}"
|
||||
if [ -n "$p" ] && [ -x "$p" ]; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 2. Derive the interpreter from a mempalace console-script shebang.
|
||||
local script_path shebang interp
|
||||
for script_path in mempalace-mcp mempalace; do
|
||||
script_path="$(command -v "$script_path" 2>/dev/null || true)"
|
||||
[ -n "$script_path" ] || continue
|
||||
[ -r "$script_path" ] || continue
|
||||
shebang="$(sed -n '1p' "$script_path" 2>/dev/null)"
|
||||
case "$shebang" in
|
||||
'#!'*)
|
||||
interp="${shebang#\#!}" # drop the leading '#!'
|
||||
interp="${interp%$'\r'}" # strip a trailing CR (CRLF files)
|
||||
interp="${interp# }" # drop one leading space
|
||||
interp="${interp%% *}" # first whitespace-delimited token
|
||||
# Guard against `#!/usr/bin/env python` wrappers: only
|
||||
# accept a token whose basename looks like a Python
|
||||
# interpreter and is executable. An `env`-style shebang
|
||||
# yields `/usr/bin/env` here, which we skip.
|
||||
case "${interp##*/}" in
|
||||
python*)
|
||||
if [ -x "$interp" ]; then
|
||||
printf '%s' "$interp"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 3. First python3 on PATH.
|
||||
p="$(command -v python3 2>/dev/null || true)"
|
||||
if [ -n "$p" ]; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 4. Last-resort bare name.
|
||||
printf '%s' "python3"
|
||||
}
|
||||
MEMPAL_PYTHON_BIN="$(mempal_resolve_python)"
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────
|
||||
#
|
||||
# ISO8601Z timestamps are greppable across timezones.
|
||||
mempal_log() {
|
||||
local event="${1:-?}"
|
||||
local conv="${2:-unknown}"
|
||||
local msg="${3:-}"
|
||||
local ts
|
||||
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
printf '[%s] [event=%s] [conv=%s] %s\n' "$ts" "$event" "$conv" "$msg" \
|
||||
>> "$MEMPAL_AGY_LOG" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Kill switch ───────────────────────────────────────────────────────
|
||||
#
|
||||
# Disabled if ANY of:
|
||||
# * MEMPAL_DISABLE_HOOK is a truthy string
|
||||
# * MEMPALACE_HOOKS_AUTO_SAVE is false/0/no
|
||||
# * ~/.mempalace/config.json sets hooks.auto_save: false
|
||||
# * ~/.mempalace/ directory does not exist (user nuked the palace)
|
||||
#
|
||||
# Returns 0 (kill switch tripped, hook should short-circuit) or non-zero
|
||||
# (proceed normally).
|
||||
mempal_kill_switch_tripped() {
|
||||
# Palace nuke is the strongest signal: respect it before touching
|
||||
# disk for state, logging, etc.
|
||||
if [ ! -d "$HOME/.mempalace" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "${MEMPAL_DISABLE_HOOK:-}" in
|
||||
1|true|TRUE|yes|YES) return 0 ;;
|
||||
esac
|
||||
|
||||
case "${MEMPALACE_HOOKS_AUTO_SAVE:-}" in
|
||||
false|FALSE|0|no|NO) return 0 ;;
|
||||
esac
|
||||
|
||||
local cfg="$HOME/.mempalace/config.json"
|
||||
if [ -f "$cfg" ]; then
|
||||
local auto
|
||||
auto=$("$MEMPAL_PYTHON_BIN" -c "
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
cfg = json.load(f)
|
||||
print(str(cfg.get('hooks', {}).get('auto_save', True)).lower())
|
||||
except Exception:
|
||||
print('true')
|
||||
" "$cfg" 2>/dev/null)
|
||||
if [ "$auto" = "false" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── camelCase JSON parser (Antigravity stdin) ────────────────────────
|
||||
#
|
||||
# Reads JSON from stdin once and prints a sanitized, sentinel-bracketed
|
||||
# block of fields the bash side can grab via `sed -n 'Np'`. Why a
|
||||
# sentinel and per-line layout: bash 3.2 doesn't have `mapfile` or
|
||||
# `readarray`, and `eval`-on-shell-var is the wrong shape (every value
|
||||
# is user-controllable JSON). Sentinel + line offset is the same pattern
|
||||
# the existing Claude Code hook (hooks/mempal_save_hook.sh) uses.
|
||||
#
|
||||
# Output layout (one field per line; line numbers are stable and the
|
||||
# fields are documented in STDIN_SHAPE.md):
|
||||
#
|
||||
# line 1: __MEMPAL_PARSE_OK__ — sentinel (parse success marker)
|
||||
# line 2: conversationId — sanitized to [A-Za-z0-9._-]
|
||||
# line 3: transcriptPath — sanitized to a safe path charset
|
||||
# line 4: workspacePath — workspacePaths[0], sanitized
|
||||
# line 5: artifactDirectoryPath — sanitized
|
||||
# line 6: executionNum — integer, default 0
|
||||
# line 7: terminationReason — sanitized to [a-z_]
|
||||
# line 8: fullyIdle — "True" or "False" (string)
|
||||
# line 9: invocationNum — integer, default 0
|
||||
# line 10: initialNumSteps — integer, default 0
|
||||
#
|
||||
# The sanitizers are defense-in-depth: every field is also vetted by
|
||||
# the Python json.load step, but we still strip shell-meaningful chars
|
||||
# from any field a downstream bash variable might interpolate, so that
|
||||
# a hostile / malformed harness payload cannot inject command tokens.
|
||||
#
|
||||
# Stderr from Python is captured to last_python_err.log at mode 0600 so
|
||||
# operators can debug parse failures without re-firing the hook. The
|
||||
# umask 077 on the inner subshell creates the file at 0600 atomically;
|
||||
# the explicit chmod 600 below is a belt-and-suspenders guard if a
|
||||
# future edit ever drops the umask.
|
||||
mempal_parse_stdin() {
|
||||
local input="$1"
|
||||
(
|
||||
umask 077
|
||||
printf '%s' "$input" | "$MEMPAL_PYTHON_BIN" -c "
|
||||
import sys, json, re
|
||||
|
||||
# IMPORTANT: do NOT wrap json.load in a try/except. If the input is
|
||||
# not valid JSON we want Python to exit non-zero BEFORE printing the
|
||||
# __MEMPAL_PARSE_OK__ sentinel — the bash caller looks for the
|
||||
# sentinel on line 1 to decide whether to engage its defense-in-depth
|
||||
# 'failed to parse' branch. Catching the exception and falling back
|
||||
# to data={} would let the sentinel print, masking parse failures
|
||||
# from the bash side. The traceback lands in
|
||||
# antigravity_last_python_err.log so operators can debug.
|
||||
data = json.load(sys.stdin)
|
||||
|
||||
def safe(s, allowed=r'[^a-zA-Z0-9_/.\-~]'):
|
||||
return re.sub(allowed, '', str(s))
|
||||
|
||||
def safe_id(s):
|
||||
return re.sub(r'[^a-zA-Z0-9._-]', '', str(s))
|
||||
|
||||
def safe_int(v, default=0):
|
||||
try:
|
||||
n = int(v)
|
||||
return n if n >= 0 else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def safe_lower_alpha_underscore(s):
|
||||
return re.sub(r'[^a-z_]', '', str(s).lower())
|
||||
|
||||
conv_id = safe_id(data.get('conversationId', ''))
|
||||
transcript = safe(data.get('transcriptPath', ''))
|
||||
wp_arr = data.get('workspacePaths', [])
|
||||
if isinstance(wp_arr, list) and wp_arr:
|
||||
workspace = safe(wp_arr[0])
|
||||
else:
|
||||
workspace = ''
|
||||
artifact = safe(data.get('artifactDirectoryPath', ''))
|
||||
execution_num = safe_int(data.get('executionNum', 0))
|
||||
termination_reason = safe_lower_alpha_underscore(data.get('terminationReason', ''))
|
||||
fully_idle_raw = data.get('fullyIdle', None)
|
||||
if fully_idle_raw is True or str(fully_idle_raw).lower() in ('true', '1', 'yes'):
|
||||
fully_idle = 'True'
|
||||
else:
|
||||
fully_idle = 'False'
|
||||
invocation_num = safe_int(data.get('invocationNum', 0))
|
||||
initial_num_steps = safe_int(data.get('initialNumSteps', 0))
|
||||
|
||||
print('__MEMPAL_PARSE_OK__')
|
||||
print(conv_id)
|
||||
print(transcript)
|
||||
print(workspace)
|
||||
print(artifact)
|
||||
print(execution_num)
|
||||
print(termination_reason)
|
||||
print(fully_idle)
|
||||
print(invocation_num)
|
||||
print(initial_num_steps)
|
||||
" 2>"$MEMPAL_STATE_DIR/antigravity_last_python_err.log"
|
||||
)
|
||||
# Tidy up the err log: keep it iff non-empty (failure happened).
|
||||
if [ -s "$MEMPAL_STATE_DIR/antigravity_last_python_err.log" ]; then
|
||||
chmod 600 "$MEMPAL_STATE_DIR/antigravity_last_python_err.log" 2>/dev/null
|
||||
else
|
||||
rm -f "$MEMPAL_STATE_DIR/antigravity_last_python_err.log" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Transcript path validator ─────────────────────────────────────────
|
||||
#
|
||||
# Mirrors mempalace.hooks_cli._validate_transcript_path: rejects empty,
|
||||
# non-jsonl/json suffixes, and any `..` traversal segment.
|
||||
mempal_is_valid_transcript_path() {
|
||||
local path="$1"
|
||||
[ -n "$path" ] || return 1
|
||||
case "$path" in
|
||||
*.json|*.jsonl) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
case "/$path/" in
|
||||
*/../*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Wing inference ────────────────────────────────────────────────────
|
||||
#
|
||||
# Takes the first workspace path from workspacePaths[] (already
|
||||
# extracted into $1) and derives a `wing_<slug>` name from its leaf
|
||||
# directory. Hyphens become underscores; spaces become underscores.
|
||||
# Empty input yields wing_sessions, matching mempalace.hooks_cli's
|
||||
# fallback.
|
||||
mempal_infer_wing() {
|
||||
local workspace="$1"
|
||||
if [ -z "$workspace" ]; then
|
||||
printf 'wing_sessions'
|
||||
return 0
|
||||
fi
|
||||
# Strip trailing slashes
|
||||
while [ "${workspace}" != "${workspace%/}" ]; do
|
||||
workspace="${workspace%/}"
|
||||
done
|
||||
if [ -z "$workspace" ]; then
|
||||
printf 'wing_sessions'
|
||||
return 0
|
||||
fi
|
||||
local leaf="${workspace##*/}"
|
||||
if [ -z "$leaf" ]; then
|
||||
printf 'wing_sessions'
|
||||
return 0
|
||||
fi
|
||||
# Lowercase + hyphens-to-underscores. tr is bash 3.2 safe; ${var^^}
|
||||
# / ${var//-/_} on a fresh expansion are bash 4+ only.
|
||||
local slug
|
||||
slug=$(printf '%s' "$leaf" | tr 'A-Z' 'a-z' | tr ' -' '__')
|
||||
printf 'wing_%s' "$slug"
|
||||
}
|
||||
|
||||
# ── Save-interval floor ───────────────────────────────────────────────
|
||||
#
|
||||
# Reads MEMPAL_SAVE_INTERVAL from the environment, floors it to >= 1
|
||||
# so that `count % interval` cannot divide by zero. We hit the
|
||||
# divide-by-zero shape on the Cursor PR review; this guards explicitly.
|
||||
mempal_save_interval() {
|
||||
local raw="${MEMPAL_SAVE_INTERVAL:-15}"
|
||||
case "$raw" in
|
||||
''|*[!0-9]*) printf '15'; return 0 ;;
|
||||
esac
|
||||
# Strip leading zeros. bash arithmetic ($((...))) parses any token
|
||||
# starting with `0` as octal, so MEMPAL_SAVE_INTERVAL=08 would
|
||||
# crash $((COUNT % INTERVAL)) with "value too great for base".
|
||||
# Loop while the value still starts with 0 AND has length > 1, so
|
||||
# the literal string "0" is preserved (then floored to 15 below).
|
||||
while [ "${raw}" != "${raw#0}" ] && [ "${#raw}" -gt 1 ]; do
|
||||
raw="${raw#0}"
|
||||
done
|
||||
if [ "$raw" -lt 1 ] 2>/dev/null; then
|
||||
printf '15'
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# ── Atomic counter write ──────────────────────────────────────────────
|
||||
#
|
||||
# Writes $value to $file via a same-directory temp file + `mv -f`.
|
||||
# `mv` (rename) is atomic on a single filesystem, so a concurrent
|
||||
# reader either sees the old contents or the new contents — never a
|
||||
# half-written / truncated file. A plain `printf > file` truncates
|
||||
# first and then writes, leaving a window where a concurrent Stop fire
|
||||
# could read an empty / partial value. Concurrent fires for one
|
||||
# conversation are unlikely (Antigravity serializes turns) but the
|
||||
# previous "written atomically" comment was simply false; this makes
|
||||
# it true.
|
||||
#
|
||||
# bash 3.2 safe. The temp lives in the same directory as the target so
|
||||
# the rename stays on one filesystem (a cross-device mv would fall back
|
||||
# to copy+unlink and lose atomicity). On any failure we degrade to a
|
||||
# direct write rather than leaving the counter unwritten.
|
||||
mempal_write_counter_atomic() {
|
||||
local file="$1"
|
||||
local value="$2"
|
||||
local tmp
|
||||
tmp="$(mktemp "${file}.XXXXXX" 2>/dev/null)" || {
|
||||
printf '%s' "$value" > "$file"
|
||||
return
|
||||
}
|
||||
printf '%s' "$value" > "$tmp"
|
||||
mv -f "$tmp" "$file" 2>/dev/null || {
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
printf '%s' "$value" > "$file"
|
||||
}
|
||||
}
|
||||
|
||||
# ── State-file TTL ────────────────────────────────────────────────────
|
||||
#
|
||||
# Per-conversation state artifacts (antigravity_save_count_<conv>,
|
||||
# antigravity_pending_<conv>, antigravity_woke_<conv>/) accumulate one
|
||||
# set per conversation and are never otherwise removed. Reads
|
||||
# MEMPAL_STATE_TTL_DAYS (default 30), validated digits-only and
|
||||
# leading-zero-stripped like mempal_save_interval so `find -mtime`
|
||||
# never sees a bad token. A value of 0 means "sweep aggressively"
|
||||
# (everything older than today); we floor empty/garbage to 30.
|
||||
mempal_state_ttl_days() {
|
||||
local raw="${MEMPAL_STATE_TTL_DAYS:-30}"
|
||||
case "$raw" in
|
||||
''|*[!0-9]*) printf '30'; return 0 ;;
|
||||
esac
|
||||
while [ "${raw}" != "${raw#0}" ] && [ "${#raw}" -gt 1 ]; do
|
||||
raw="${raw#0}"
|
||||
done
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# ── Stale state GC ────────────────────────────────────────────────────
|
||||
#
|
||||
# Opportunistic sweep of per-conversation state older than the TTL.
|
||||
# Gated to run at most once per 24h via the antigravity_last_sweep
|
||||
# marker so it costs nothing on the vast majority of fires (a single
|
||||
# mtime comparison). When it does run, three `find` passes remove the
|
||||
# stale counter files, pending markers, and woke marker directories.
|
||||
#
|
||||
# The name globs are specific (antigravity_save_count_*, _pending_*,
|
||||
# _woke_*), so the shared log files (antigravity_hook.log,
|
||||
# antigravity_last_input.log, antigravity_last_python_err.log) and the
|
||||
# antigravity_last_sweep marker itself are never touched. BSD find
|
||||
# (macOS default) and GNU find both accept `-maxdepth`, `-mtime +N`,
|
||||
# and `-exec ... +`.
|
||||
#
|
||||
# Fail-open: every step is best-effort; a missing state dir, a find
|
||||
# that errors, or a permission problem must never abort the caller.
|
||||
mempal_gc_stale_state() {
|
||||
[ -d "$MEMPAL_STATE_DIR" ] || return 0
|
||||
|
||||
local marker="$MEMPAL_STATE_DIR/antigravity_last_sweep"
|
||||
if [ -f "$marker" ]; then
|
||||
local mtime now
|
||||
if mtime=$("$MEMPAL_PYTHON_BIN" -c 'import os, sys; print(int(os.path.getmtime(sys.argv[1])))' "$marker" 2>/dev/null) \
|
||||
&& now=$(date '+%s' 2>/dev/null) \
|
||||
&& [ -n "$mtime" ] \
|
||||
&& [ "$((now - mtime))" -lt 86400 ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Touch the marker first so a crash mid-sweep still throttles the
|
||||
# next fire (better to skip a sweep than to hammer the disk).
|
||||
: > "$marker" 2>/dev/null
|
||||
|
||||
local ttl
|
||||
ttl=$(mempal_state_ttl_days)
|
||||
|
||||
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
|
||||
-name 'antigravity_save_count_*' -mtime +"$ttl" \
|
||||
-exec rm -f {} + 2>/dev/null
|
||||
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
|
||||
-name 'antigravity_pending_*' -mtime +"$ttl" \
|
||||
-exec rm -f {} + 2>/dev/null
|
||||
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type d \
|
||||
-name 'antigravity_woke_*' -mtime +"$ttl" \
|
||||
-exec rm -rf {} + 2>/dev/null
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Fail-open emitters ────────────────────────────────────────────────
|
||||
#
|
||||
# Every code path in both hooks must terminate by calling exactly one
|
||||
# of these emitters. Stdout is JSON. Exit status is always 0 — the hook
|
||||
# never blocks the user's IDE on its own failure (constraint #2).
|
||||
#
|
||||
# CRITICAL: mempal_emit_stop_pass MUST NEVER emit
|
||||
# {"decision":"continue"} — that would force the agent to keep running
|
||||
# instead of letting the turn end. Antigravity treats any value other
|
||||
# than "continue" (including `{}`) as "allow the stop". We enforce this
|
||||
# by hard-coding the empty object output here.
|
||||
mempal_emit_stop_pass() {
|
||||
printf '{}\n'
|
||||
}
|
||||
|
||||
mempal_emit_wake_inject() {
|
||||
local message="$1"
|
||||
if [ -z "$message" ]; then
|
||||
printf '{}\n'
|
||||
return 0
|
||||
fi
|
||||
# Encode the message as JSON via Python so embedded quotes / newlines
|
||||
# / control chars don't corrupt the output.
|
||||
"$MEMPAL_PYTHON_BIN" -c "
|
||||
import json, sys
|
||||
msg = sys.argv[1]
|
||||
print(json.dumps({'injectSteps': [{'ephemeralMessage': msg}]}))
|
||||
" "$message" 2>/dev/null || printf '{}\n'
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE ANTIGRAVITY SAVE HOOK — Stop event handler
|
||||
#
|
||||
# Antigravity fires the Stop event each time the agent's execution loop
|
||||
# terminates. We use it to background-mine the active conversation
|
||||
# transcript every Nth save into the user's MemPalace, and to write a
|
||||
# diary checkpoint via `mempalace mine --mode convos`.
|
||||
#
|
||||
# Mirrors the Claude Code (hooks/mempal_save_hook.sh) and Codex
|
||||
# (.codex-plugin/hooks/mempal-hook.sh) integrations as closely as the
|
||||
# Antigravity stdin/stdout contract allows. Differences:
|
||||
#
|
||||
# * Antigravity stdin uses camelCase: conversationId, transcriptPath,
|
||||
# workspacePaths, executionNum, terminationReason, fullyIdle.
|
||||
# * Antigravity stdout MUST be `{}` on every code path. Emitting
|
||||
# `{"decision":"continue"}` would force the agent to keep running
|
||||
# and create an infinite loop. We never call mempal_emit_stop_pass
|
||||
# with anything other than the literal empty object.
|
||||
# * Counter file is namespaced antigravity_save_count_<conversationId>
|
||||
# to coexist with Claude Code / Cursor / Codex state in the same
|
||||
# ~/.mempalace/hook_state/ directory.
|
||||
#
|
||||
# === STDIN (verified, camelCase) ===
|
||||
# {
|
||||
# "executionNum": 1,
|
||||
# "terminationReason": "model_stop",
|
||||
# "error": "",
|
||||
# "fullyIdle": true,
|
||||
# "conversationId": "<uuid>",
|
||||
# "workspacePaths": ["/abs/path/..."],
|
||||
# "transcriptPath": "/abs/path/transcript.jsonl",
|
||||
# "artifactDirectoryPath": "/abs/path/artifacts/"
|
||||
# }
|
||||
#
|
||||
# === STDOUT (always) ===
|
||||
# {}
|
||||
#
|
||||
# `set -e` is intentionally NOT enabled — a broken hook must not block
|
||||
# the user's conversation (constraint #2 in the integration brief).
|
||||
|
||||
# ── Locate this script + source common helpers ───────────────────────
|
||||
MEMPAL_AGY_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=lib/common.sh
|
||||
. "$MEMPAL_AGY_HOOK_DIR/lib/common.sh"
|
||||
|
||||
# ── Read all of stdin once ───────────────────────────────────────────
|
||||
INPUT=$(cat)
|
||||
|
||||
# ── Kill switch: short-circuit cleanly if disabled ───────────────────
|
||||
if mempal_kill_switch_tripped; then
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Opportunistic GC of stale per-conversation state ─────────────────
|
||||
#
|
||||
# Self-throttled to at most once per 24h (see mempal_gc_stale_state),
|
||||
# so this is a single mtime check on the overwhelming majority of
|
||||
# fires. Runs after the kill switch so a disabled hook touches nothing.
|
||||
mempal_gc_stale_state
|
||||
|
||||
# ── Parse stdin (camelCase, sentinel-guarded) ────────────────────────
|
||||
_parsed=$(mempal_parse_stdin "$INPUT")
|
||||
_marker=$(printf '%s\n' "$_parsed" | sed -n '1p')
|
||||
CONVERSATION_ID=$(printf '%s\n' "$_parsed" | sed -n '2p')
|
||||
TRANSCRIPT_PATH=$(printf '%s\n' "$_parsed" | sed -n '3p')
|
||||
WORKSPACE_PATH=$(printf '%s\n' "$_parsed" | sed -n '4p')
|
||||
# Line 5 (artifactDirectoryPath) is parsed but unused for save. Skip.
|
||||
EXECUTION_NUM=$(printf '%s\n' "$_parsed" | sed -n '6p')
|
||||
# Line 7 (terminationReason) is parsed but used only for logging.
|
||||
TERMINATION_REASON=$(printf '%s\n' "$_parsed" | sed -n '7p')
|
||||
FULLY_IDLE=$(printf '%s\n' "$_parsed" | sed -n '8p')
|
||||
|
||||
# ── Defense-in-depth: surface raw input on parse failure ─────────────
|
||||
#
|
||||
# When the sentinel is missing, Python crashed before reaching its
|
||||
# print() calls. Persist the offending payload (capped at 4 KB, mode
|
||||
# 0600) so the next debugger doesn't lose a day to log lines that say
|
||||
# "Session unknown".
|
||||
if [ -n "$INPUT" ] && [ "$_marker" != "__MEMPAL_PARSE_OK__" ]; then
|
||||
mempal_log "stop" "unknown" "input parse failed (sentinel missing); see antigravity_last_input.log + antigravity_last_python_err.log"
|
||||
(
|
||||
umask 077
|
||||
printf '%s' "$INPUT" | head -c 4096 > "$MEMPAL_STATE_DIR/antigravity_last_input.log"
|
||||
)
|
||||
chmod 600 "$MEMPAL_STATE_DIR/antigravity_last_input.log" 2>/dev/null
|
||||
# Continue with empty fields; the validators below will reject.
|
||||
fi
|
||||
|
||||
CONVERSATION_ID="${CONVERSATION_ID:-unknown}"
|
||||
TRANSCRIPT_PATH="${TRANSCRIPT_PATH:-}"
|
||||
WORKSPACE_PATH="${WORKSPACE_PATH:-}"
|
||||
EXECUTION_NUM="${EXECUTION_NUM:-0}"
|
||||
TERMINATION_REASON="${TERMINATION_REASON:-}"
|
||||
FULLY_IDLE="${FULLY_IDLE:-False}"
|
||||
|
||||
# Expand ~ in the transcript path
|
||||
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
|
||||
|
||||
# ── Bail when fullyIdle is False ─────────────────────────────────────
|
||||
#
|
||||
# If background commands or async tasks are still running, the
|
||||
# transcript is still in motion. Defer the save until the next Stop
|
||||
# event when the agent is fully done — better to skip than to ingest a
|
||||
# half-finished transcript and pollute the search index.
|
||||
if [ "$FULLY_IDLE" != "True" ]; then
|
||||
mempal_log "stop" "$CONVERSATION_ID" "deferring save: fullyIdle=False (executionNum=$EXECUTION_NUM, terminationReason=$TERMINATION_REASON)"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Skip when terminationReason is `error` ───────────────────────────
|
||||
#
|
||||
# A model error termination usually means the transcript is corrupt or
|
||||
# truncated. Don't ingest noise.
|
||||
if [ "$TERMINATION_REASON" = "error" ]; then
|
||||
mempal_log "stop" "$CONVERSATION_ID" "skipping save: terminationReason=error"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Increment counter (per conversation) ─────────────────────────────
|
||||
#
|
||||
# The counter is a single integer, written via mempal_write_counter_atomic
|
||||
# (same-dir temp + `mv`, which is an atomic rename on one filesystem).
|
||||
# Concurrent Stop fires for the same conversation are unlikely
|
||||
# (Antigravity serializes turns), but the atomic write means a
|
||||
# concurrent reader always sees a complete value rather than a
|
||||
# half-written / truncated file. The integer-only validation on read
|
||||
# is a second guard: any garbled value resets the count to 0.
|
||||
COUNTER_FILE="$MEMPAL_STATE_DIR/antigravity_save_count_${CONVERSATION_ID}"
|
||||
COUNT=0
|
||||
if [ -f "$COUNTER_FILE" ]; then
|
||||
raw=$(cat "$COUNTER_FILE" 2>/dev/null)
|
||||
case "$raw" in
|
||||
''|*[!0-9]*) COUNT=0 ;;
|
||||
*) COUNT="$raw" ;;
|
||||
esac
|
||||
fi
|
||||
COUNT=$((COUNT + 1))
|
||||
mempal_write_counter_atomic "$COUNTER_FILE" "$COUNT"
|
||||
|
||||
INTERVAL=$(mempal_save_interval)
|
||||
mempal_log "stop" "$CONVERSATION_ID" "count=$COUNT interval=$INTERVAL executionNum=$EXECUTION_NUM workspace=$WORKSPACE_PATH"
|
||||
|
||||
# ── Modulo gate ──────────────────────────────────────────────────────
|
||||
#
|
||||
# `count % interval == 0` triggers a save. INTERVAL has been floored to
|
||||
# >= 1 by mempal_save_interval, so the modulo cannot divide by zero
|
||||
# even if the user explicitly set MEMPAL_SAVE_INTERVAL=0 or empty.
|
||||
if [ $((COUNT % INTERVAL)) -ne 0 ]; then
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Pending-marker guard ─────────────────────────────────────────────
|
||||
#
|
||||
# If a previous save is still running (the marker file exists), skip
|
||||
# this fire. The mine subprocess removes the marker on exit, but a
|
||||
# crashed mine could leave the marker forever — guard against that by
|
||||
# treating markers older than 1 hour as stale and reclaiming them.
|
||||
PENDING_FILE="$MEMPAL_STATE_DIR/antigravity_pending_${CONVERSATION_ID}"
|
||||
if [ -f "$PENDING_FILE" ]; then
|
||||
# mtime in epoch seconds (portable; BSD/macOS `date -r` takes epoch, not a path).
|
||||
if mtime=$("$MEMPAL_PYTHON_BIN" -c 'import os, sys; print(int(os.path.getmtime(sys.argv[1])))' "$PENDING_FILE" 2>/dev/null) \
|
||||
&& now=$(date '+%s') \
|
||||
&& [ -n "$mtime" ] \
|
||||
&& [ "$((now - mtime))" -lt 3600 ]; then
|
||||
mempal_log "stop" "$CONVERSATION_ID" "pending save still in flight; skipping"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
mempal_log "stop" "$CONVERSATION_ID" "stale pending marker reclaimed"
|
||||
rm -f "$PENDING_FILE" 2>/dev/null
|
||||
fi
|
||||
|
||||
# ── Validate transcript path ─────────────────────────────────────────
|
||||
if ! mempal_is_valid_transcript_path "$TRANSCRIPT_PATH"; then
|
||||
mempal_log "stop" "$CONVERSATION_ID" "invalid transcriptPath rejected: $TRANSCRIPT_PATH"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f "$TRANSCRIPT_PATH" ]; then
|
||||
mempal_log "stop" "$CONVERSATION_ID" "transcriptPath does not exist: $TRANSCRIPT_PATH"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Trigger save ─────────────────────────────────────────────────────
|
||||
WING=$(mempal_infer_wing "$WORKSPACE_PATH")
|
||||
TRANSCRIPT_DIR=$(dirname "$TRANSCRIPT_PATH")
|
||||
|
||||
mempal_log "stop" "$CONVERSATION_ID" "TRIGGERING SAVE wing=$WING transcript_dir=$TRANSCRIPT_DIR"
|
||||
|
||||
# Drop the pending marker BEFORE spawning so a near-simultaneous fire
|
||||
# sees it. If the spawn fails, remove the marker so the next fire can
|
||||
# retry.
|
||||
: > "$PENDING_FILE" 2>/dev/null
|
||||
|
||||
# Detach EVERYTHING heavy into a single background subshell: the
|
||||
# runnability probe, the mine itself, and the pending-marker cleanup.
|
||||
# The foreground returns immediately after spawning, so the hook's
|
||||
# stdout (`{}`) reaches Antigravity within milliseconds.
|
||||
#
|
||||
# Why the probe must NOT run in the foreground: `mempalace --version`
|
||||
# is NOT cheap. Building the `mine` argument parser imports
|
||||
# `mempalace.miner` (-> palace -> backends -> chromadb/onnx) before
|
||||
# argparse ever processes `--version`, so the probe pays the full
|
||||
# cold-start import cost. Running it in the foreground would block the
|
||||
# hook for that entire import and blow the <500ms save budget. Moving
|
||||
# it inside the backgrounded subshell keeps the foreground instant.
|
||||
#
|
||||
# Folding the cleanup into this same subshell also removes the need for
|
||||
# a separate process-liveness polling watcher: the `rm -f
|
||||
# "$PENDING_FILE"` simply runs after the mine returns, in the same
|
||||
# shell that owns the mine — no sibling-PID `wait` hazard, no polling
|
||||
# loop.
|
||||
#
|
||||
# We invoke mempalace as `"$MEMPAL_PYTHON_BIN" -m mempalace` rather than
|
||||
# the bare `mempalace` console script so a user with the package
|
||||
# installed only inside a venv (and the venv's bin/ not on the hook's
|
||||
# PATH, e.g. `uv tool install` in some distributions, or a manually
|
||||
# managed virtualenv) still hits a working mine. MEMPAL_PYTHON honours
|
||||
# user override; sees ``mempalace/__main__.py`` which dispatches to
|
||||
# ``mempalace.cli:main`` — identical to the console script.
|
||||
mempal_log "stop" "$CONVERSATION_ID" "spawning background mine wing=$WING transcript_dir=$TRANSCRIPT_DIR"
|
||||
(
|
||||
if "$MEMPAL_PYTHON_BIN" -m mempalace --version >/dev/null 2>&1; then
|
||||
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$TRANSCRIPT_DIR" \
|
||||
--mode convos \
|
||||
--wing "$WING" \
|
||||
>> "$MEMPAL_AGY_LOG" 2>&1 < /dev/null
|
||||
mempal_log "stop" "$CONVERSATION_ID" "background mine finished wing=$WING"
|
||||
else
|
||||
mempal_log "stop" "$CONVERSATION_ID" "ERROR: mempalace is not runnable via $MEMPAL_PYTHON_BIN -m mempalace; install mempalace or set MEMPAL_PYTHON"
|
||||
fi
|
||||
rm -f "$PENDING_FILE" 2>/dev/null
|
||||
) >/dev/null 2>&1 < /dev/null &
|
||||
|
||||
# ── Always emit `{}` ─────────────────────────────────────────────────
|
||||
#
|
||||
# Never `{"decision":"continue"}`. That would force the agent into an
|
||||
# infinite re-execution loop. mempal_emit_stop_pass hard-codes `{}`.
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE ANTIGRAVITY WAKE HOOK — PreInvocation event handler
|
||||
#
|
||||
# Antigravity fires the PreInvocation event before every model
|
||||
# invocation, with `invocationNum` carrying the sequence number of the
|
||||
# call. We use the first invocation (invocationNum == 1) as our
|
||||
# session-start equivalent and inject a verbatim memory pointer into
|
||||
# the agent's context via the `injectSteps[].ephemeralMessage` output
|
||||
# field — the message lives for one turn and does not persist into the
|
||||
# transcript, so it doesn't pollute future invocations of this same
|
||||
# conversation.
|
||||
#
|
||||
# === STDIN (verified, camelCase) ===
|
||||
# {
|
||||
# "invocationNum": 1,
|
||||
# "initialNumSteps": 0,
|
||||
# "conversationId": "<uuid>",
|
||||
# "workspacePaths": ["/abs/path/..."],
|
||||
# "transcriptPath": "/abs/path/transcript.jsonl",
|
||||
# "artifactDirectoryPath": "/abs/path/artifacts/"
|
||||
# }
|
||||
#
|
||||
# === STDOUT ===
|
||||
# Either:
|
||||
# {} — no injection
|
||||
# Or:
|
||||
# {"injectSteps":[{"ephemeralMessage":"..."}]} — verbatim memory pointer
|
||||
#
|
||||
# Verbatim guarantee: the ephemeralMessage carries the exact text
|
||||
# emitted by `mempalace wake-up`, never paraphrased or summarized.
|
||||
#
|
||||
# Performance budget: the integration brief sets a 100ms ceiling for
|
||||
# startup injection. We enforce a 500ms hard timeout on the
|
||||
# `mempalace wake-up` subprocess (more generous than 100ms because
|
||||
# cold ChromaDB connections can dominate, and missing the budget is
|
||||
# strictly better than blocking the user) — if it doesn't return in
|
||||
# time we emit `{}` and let the conversation start without injection.
|
||||
#
|
||||
# `set -e` is intentionally NOT enabled — fail-open is mandatory.
|
||||
|
||||
# ── Locate this script + source common helpers ───────────────────────
|
||||
MEMPAL_AGY_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=lib/common.sh
|
||||
. "$MEMPAL_AGY_HOOK_DIR/lib/common.sh"
|
||||
|
||||
# ── Read all of stdin once ───────────────────────────────────────────
|
||||
INPUT=$(cat)
|
||||
|
||||
# ── Kill switch ──────────────────────────────────────────────────────
|
||||
if mempal_kill_switch_tripped; then
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Parse stdin ──────────────────────────────────────────────────────
|
||||
_parsed=$(mempal_parse_stdin "$INPUT")
|
||||
_marker=$(printf '%s\n' "$_parsed" | sed -n '1p')
|
||||
CONVERSATION_ID=$(printf '%s\n' "$_parsed" | sed -n '2p')
|
||||
# Lines 3-5 (transcriptPath, workspacePath, artifactDirectoryPath) are
|
||||
# parsed; we use workspacePath for wing inference. transcriptPath and
|
||||
# artifactDirectoryPath are unused by the wake flow.
|
||||
# Line 4: workspacePath
|
||||
WORKSPACE_PATH=$(printf '%s\n' "$_parsed" | sed -n '4p')
|
||||
INVOCATION_NUM=$(printf '%s\n' "$_parsed" | sed -n '9p')
|
||||
|
||||
# Defense-in-depth on parse failure
|
||||
if [ -n "$INPUT" ] && [ "$_marker" != "__MEMPAL_PARSE_OK__" ]; then
|
||||
mempal_log "preInvocation" "unknown" "input parse failed (sentinel missing)"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CONVERSATION_ID="${CONVERSATION_ID:-unknown}"
|
||||
WORKSPACE_PATH="${WORKSPACE_PATH:-}"
|
||||
INVOCATION_NUM="${INVOCATION_NUM:-0}"
|
||||
|
||||
# ── Gate: only inject on the FIRST invocation ────────────────────────
|
||||
#
|
||||
# PreInvocation fires before every model call. Without this gate we'd
|
||||
# inject memory on every single turn — both expensive and visually
|
||||
# noisy. invocationNum == 1 means "first model call of this
|
||||
# conversation", which is the closest thing Antigravity exposes to
|
||||
# Cursor's `sessionStart`.
|
||||
if [ "$INVOCATION_NUM" != "1" ]; then
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Loop guard ───────────────────────────────────────────────────────
|
||||
#
|
||||
# Defense in depth: even within the first invocation, we only ever
|
||||
# want to inject once per conversation. mkdir is atomic and works on
|
||||
# bash 3.2 / macOS / Linux without flock or other GNU coreutils
|
||||
# extensions.
|
||||
WOKE_MARKER="$MEMPAL_STATE_DIR/antigravity_woke_${CONVERSATION_ID}"
|
||||
if ! mkdir "$WOKE_MARKER" 2>/dev/null; then
|
||||
mempal_log "preInvocation" "$CONVERSATION_ID" "already woke this conversation; skipping"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Run wake-up with a hard timeout ──────────────────────────────────
|
||||
#
|
||||
# `timeout` is GNU coreutils — present on most Linux installs but
|
||||
# missing from stock macOS. Wrap the subprocess in a Python timeout
|
||||
# (subprocess.run(timeout=...)) which is cross-platform. The Python
|
||||
# script also constructs the final JSON envelope for stdout, so the
|
||||
# bash side just passes the result through.
|
||||
WING=$(mempal_infer_wing "$WORKSPACE_PATH")
|
||||
mempal_log "preInvocation" "$CONVERSATION_ID" "WAKE injection wing=$WING invocationNum=$INVOCATION_NUM"
|
||||
|
||||
OUTPUT=$("$MEMPAL_PYTHON_BIN" -c "
|
||||
import json, subprocess, sys
|
||||
|
||||
wing = sys.argv[1]
|
||||
timeout_s = 0.5 # 500 ms
|
||||
|
||||
# Invoke as ``[sys.executable, '-m', 'mempalace', ...]`` rather than
|
||||
# the bare ``mempalace`` console script. sys.executable is the same
|
||||
# Python that resolved MEMPAL_PYTHON in lib/common.sh, so this binds
|
||||
# the wake-up call to the correct interpreter (and its installed
|
||||
# mempalace package) even when the venv's bin/ isn't on PATH.
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, '-m', 'mempalace', 'wake-up', '--wing', wing],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
print('{}')
|
||||
sys.exit(0)
|
||||
body = (completed.stdout or '').strip()
|
||||
if not body:
|
||||
print('{}')
|
||||
sys.exit(0)
|
||||
# Verbatim — pass the wake-up text exactly as emitted, wrapped in
|
||||
# the Antigravity injectSteps envelope. json.dumps escapes embedded
|
||||
# control chars and quotes correctly.
|
||||
print(json.dumps({'injectSteps': [{'ephemeralMessage': body}]}))
|
||||
except FileNotFoundError:
|
||||
print('{}')
|
||||
except subprocess.TimeoutExpired:
|
||||
print('{}')
|
||||
except Exception:
|
||||
print('{}')
|
||||
" "$WING" 2>/dev/null)
|
||||
|
||||
if [ -z "$OUTPUT" ]; then
|
||||
OUTPUT='{}'
|
||||
fi
|
||||
|
||||
# Sanity-check: never emit `decision` from a PreInvocation hook (that
|
||||
# field belongs to the Stop event). The Python helper only ever
|
||||
# constructs `{"injectSteps": [...]}` or `{}`, so this is belt-and-
|
||||
# suspenders against a future edit ever leaking a Stop-shaped object.
|
||||
case "$OUTPUT" in
|
||||
*\"decision\"*)
|
||||
mempal_log "preInvocation" "$CONVERSATION_ID" "ERROR: refused to emit decision field from wake hook"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s\n' "$OUTPUT"
|
||||
exit 0
|
||||
@@ -0,0 +1,157 @@
|
||||
# MemPalace Cursor IDE Hooks
|
||||
|
||||
Auto-save and session-recall hooks for the [Cursor](https://cursor.com) IDE,
|
||||
matching the behaviour of the existing Claude Code + Codex hooks at the repo
|
||||
root and adding two Cursor-only capabilities (`sessionStart` recall and a
|
||||
preCompact transcript snapshot).
|
||||
|
||||
For the rendered documentation see
|
||||
[`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) or
|
||||
the published version at
|
||||
[mempalaceofficial.com/guide/cursor-hooks](https://mempalaceofficial.com/guide/cursor-hooks.html).
|
||||
|
||||
## What's here
|
||||
|
||||
| File | Role |
|
||||
|-------------------------------------|---------------------------------------------------------------------|
|
||||
| `lib/common.sh` | Shared bash helpers (parse, log, counter, wing inference, kill switch). Sourced by all three hooks. |
|
||||
| `mempal_save_hook_cursor.sh` | Cursor `stop` hook. Counts stop invocations per conversation, emits a `followup_message` every `SAVE_INTERVAL` (default 15) telling the agent to file the session into MemPalace. |
|
||||
| `mempal_precompact_hook_cursor.sh` | Cursor `preCompact` hook. Runs `mempalace mine` synchronously on the transcript before compaction, then drops a `.pending` marker so the next stop forces a save nudge. |
|
||||
| `mempal_wake_hook_cursor.sh` | Cursor `sessionStart` hook. Returns `additional_context` telling the agent to recall scoped to the wing inferred from the workspace root. Cursor-only — Claude Code has no equivalent. |
|
||||
| `install.sh` | Optional installer. Copies the scripts to `~/.mempalace/hooks/cursor/` and merges entries into `~/.cursor/hooks.json` (or `.cursor/hooks.json` for project scope). Supports `--dry-run` and `--uninstall`. |
|
||||
| `STDIN_SHAPE.md` | Reference. Per-event stdin / stdout schema with citations to the official Cursor docs. |
|
||||
|
||||
## Quick install
|
||||
|
||||
Preview first (writes nothing, prints the would-be JSON to stdout):
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope user --dry-run
|
||||
```
|
||||
|
||||
Apply — writes `~/.cursor/hooks.json` and copies the scripts to `~/.mempalace/hooks/cursor/`:
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope user
|
||||
```
|
||||
|
||||
Pass `--scope project --target <repo>` to write `<repo>/.cursor/hooks.json` instead.
|
||||
The installer never auto-runs — it is a documented opt-in step. We do not
|
||||
modify your Cursor config on `pip install mempalace` because editor config
|
||||
is sacred and should never be touched without explicit consent.
|
||||
|
||||
## Manual install (no installer)
|
||||
|
||||
The minimum wiring is `stop` only. Add to `~/.cursor/hooks.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"stop": [
|
||||
{
|
||||
"command": "/absolute/path/to/hooks/cursor/mempal_save_hook_cursor.sh",
|
||||
"loop_limit": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For the full triple (recommended), also wire `sessionStart` and `preCompact`
|
||||
— see [`examples/cursor/hooks.json`](../../examples/cursor/hooks.json).
|
||||
|
||||
After editing the file, Cursor watches `hooks.json` and reloads
|
||||
automatically. If hooks still do not fire, restart Cursor and check the
|
||||
Hooks panel in Settings.
|
||||
|
||||
## Configuration
|
||||
|
||||
All knobs are env vars; defaults match the Claude Code hooks where
|
||||
possible so a single hook-state directory works for both editors.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|--------------------------------|------------------------------------|---------|
|
||||
| `MEMPAL_SAVE_INTERVAL` | `15` | Number of `stop` events between save followups. |
|
||||
| `MEMPAL_CURSOR_SILENT` | (unset) | Set to `1`/`true`/`yes` to suppress the `followup_message`. The hook still runs its best-effort background mine and keeps its counters — it just stays silent. `MEMPAL_VERBOSE=false`/`0`/`no` does the same. See note below on why the followup is on by default. |
|
||||
| `MEMPAL_DIR` | (unset) | Optional project directory to also mine on each save. Additive — never replaces the transcript mine. |
|
||||
| `MEMPAL_PYTHON` | auto-detected | Path to a Python 3 interpreter. Fallback order: `$MEMPAL_PYTHON` → `command -v python3` → bare `python3`. Useful when Cursor is launched from a GUI on macOS and the inherited PATH lacks your installed `python3`. |
|
||||
| `MEMPAL_STATE_DIR` | `$HOME/.mempalace/hook_state` | Where the hook keeps its per-conversation counter files, pending-save markers, and `cursor_hook.log`. |
|
||||
| `MEMPAL_STATE_TTL_DAYS` | `30` | Age (days) after which stale `cursor_*.count` / `cursor_*.pending` state files are garbage-collected. A daily-throttled sweep runs from the hooks; only Cursor state is touched (shared logs and other editors' state are left alone). |
|
||||
| `MEMPAL_DISABLE_HOOK` | (unset) | Set to `1`/`true`/`yes` to disable all three hooks. Emergency kill switch. |
|
||||
| `MEMPALACE_HOOKS_AUTO_SAVE` | (unset) | Set to `false`/`0`/`no` to disable. Same semantics as the Claude Code hooks. Also honoured via `~/.mempalace/config.json` → `{"hooks": {"auto_save": false}}`. |
|
||||
|
||||
## Debugging
|
||||
|
||||
Everything appends to:
|
||||
|
||||
```bash
|
||||
cat ~/.mempalace/hook_state/cursor_hook.log
|
||||
```
|
||||
|
||||
Example log lines (ISO 8601 + event + conversation id):
|
||||
|
||||
```
|
||||
[2026-05-27T02:16:01Z] [event=sessionStart] [conv=abc123] workspace=/Users/me/proj wing=proj
|
||||
[2026-05-27T02:21:33Z] [event=stop] [conv=abc123] counter 0 -> 1 (interval=15)
|
||||
[2026-05-27T02:42:09Z] [event=stop] [conv=abc123] counter 14 -> 15 (interval=15)
|
||||
[2026-05-27T02:42:09Z] [event=stop] [conv=abc123] TRIGGERING SAVE at counter=15
|
||||
[2026-05-27T02:42:11Z] [event=stop] [conv=abc123] loop_count>0; letting agent stop
|
||||
```
|
||||
|
||||
When a hook can't parse its stdin (corrupt payload, future Cursor schema
|
||||
change), the raw input (capped at 4096 bytes, mode 0600) lands at:
|
||||
|
||||
```
|
||||
~/.mempalace/hook_state/cursor_last_input.log
|
||||
~/.mempalace/hook_state/cursor_last_python_err.log
|
||||
```
|
||||
|
||||
These are overwritten on each failure, never appended, so a repeating
|
||||
misconfiguration cannot grow disk usage.
|
||||
|
||||
## What differs from the Claude Code hooks
|
||||
|
||||
| Aspect | Claude Code hooks (`hooks/mempal_*.sh`) | Cursor hooks (`hooks/cursor/*.sh`) |
|
||||
|-------------------------|------------------------------------------------|-----------------------------------------------------|
|
||||
| Counter key | `session_id` | `conversation_id` (Cursor's stable per-conv id) |
|
||||
| Loop guard | `stop_hook_active` flag in stdin | `loop_count` field in stdin |
|
||||
| Counting method | Parses JSONL transcript for user messages | Counts `stop` invocations (transcript schema undoc) |
|
||||
| Capture path | Background `mine --mode convos` (normalize.py has a Claude parser) | Background mine is best-effort (no Cursor parser); the `followup_message` carries verbatim capture |
|
||||
| Save default | Silent — diary nudge opt-IN behind `MEMPAL_VERBOSE=true` | Followup ON by default; opt-OUT via `MEMPAL_CURSOR_SILENT=1` / `MEMPAL_VERBOSE=false` |
|
||||
| PreCompact behaviour | `decision: block` forces save before compaction | Pre-mine + pending-save marker (Cursor preCompact is observational-only) |
|
||||
| sessionStart | n/a (Claude Code has no equivalent) | `additional_context` injects recall guidance |
|
||||
| State dir | `$HOME/.mempalace/hook_state` (hardcoded) | Same default, plus `MEMPAL_STATE_DIR` env override |
|
||||
| Kill switch | `MEMPALACE_HOOKS_AUTO_SAVE=false` | Same, plus `MEMPAL_DISABLE_HOOK=1` alias |
|
||||
| Log file | `hook.log` | `cursor_hook.log` (kept separate to avoid cross-tool log churn) |
|
||||
|
||||
See [`STDIN_SHAPE.md`](STDIN_SHAPE.md) for the per-event schema and
|
||||
[`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) for
|
||||
the full walkthrough with diagrams.
|
||||
|
||||
## Why the followup is on by default (Cursor-specific)
|
||||
|
||||
Unlike the Claude Code hook — which is silent by default because its
|
||||
background `mempalace mine --mode convos` captures the verbatim transcript
|
||||
on its own — Cursor's transcript format is **undocumented** and
|
||||
`mempalace/normalize.py` has **no Cursor parser**. The background mine on
|
||||
the Cursor `stop`/`preCompact` hooks is therefore **best-effort only**: it
|
||||
does not yet yield clean verbatim conversation drawers.
|
||||
|
||||
That makes the `followup_message` the **load-bearing verbatim-capture
|
||||
path** for Cursor — it drives the agent to file its own in-context
|
||||
verbatim quotes via `mempalace_add_drawer` / `mempalace_diary_write`.
|
||||
Silencing it by default would leave a default Cursor install capturing
|
||||
nothing, which is why it is on by default here. Set `MEMPAL_CURSOR_SILENT=1`
|
||||
(or `MEMPAL_VERBOSE=false`) if you prefer the Claude-style silent
|
||||
behaviour and accept the reduced capture. Once `normalize.py` learns to
|
||||
read Cursor transcripts, this default will flip to silent to match Claude.
|
||||
|
||||
## Cost
|
||||
|
||||
Zero extra LLM tokens spent by the hooks themselves. The hooks are local
|
||||
bash scripts that run on your machine. The followup message the save hook
|
||||
emits is a normal user turn — it counts the same as any other user message
|
||||
and does not invoke any extra LLM call beyond the one the user would
|
||||
otherwise make. Suppress it with `MEMPAL_CURSOR_SILENT=1` if you want zero
|
||||
followups in the chat window.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Cursor Hook Stdin Shape — Reference
|
||||
|
||||
This file documents the JSON payloads the Cursor IDE sends to the
|
||||
MemPalace hook scripts in `hooks/cursor/`. It exists so a future
|
||||
contributor does not have to re-discover the schema by writing a
|
||||
probe hook.
|
||||
|
||||
**Source:** [`cursor.com/docs/hooks.md`](https://cursor.com/docs/hooks.md),
|
||||
fetched 2026-05-27. Cursor's hook system is documented as a stable
|
||||
v1 schema (`{"version": 1, ...}` at the top of `hooks.json`).
|
||||
|
||||
If you suspect Cursor has changed the payload shape since that fetch
|
||||
date, re-verify against the upstream docs and update both this file
|
||||
and `hooks/cursor/lib/common.sh::mempal_parse_stdin`. The hook
|
||||
scripts deliberately ignore fields they do not consume, so adding
|
||||
new fields is non-breaking.
|
||||
|
||||
## Common fields (all events)
|
||||
|
||||
Every hook receives these on stdin in addition to its event-specific
|
||||
fields. Source: docs section "Common schema → Input (all hooks)".
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation_id": "string",
|
||||
"generation_id": "string",
|
||||
"model": "string",
|
||||
"hook_event_name": "string",
|
||||
"cursor_version": "string",
|
||||
"workspace_roots": ["<absolute path>"],
|
||||
"user_email": "string | null",
|
||||
"transcript_path": "string | null"
|
||||
}
|
||||
```
|
||||
|
||||
**Field notes (verified):**
|
||||
|
||||
- `conversation_id` is the stable per-conversation ID. The Cursor
|
||||
`stop` event does **not** carry a `session_id` — only
|
||||
`conversation_id`. MemPalace keys its counter files on this. Cursor
|
||||
`sessionStart` does carry a `session_id`, and the docs note it is
|
||||
"same as `conversation_id`".
|
||||
- `generation_id` changes every user message. We do not use it.
|
||||
- `transcript_path` may be `null` if the user has disabled
|
||||
transcripts in Cursor settings. The hooks degrade gracefully when
|
||||
the value is empty.
|
||||
- `workspace_roots` is normally a single-entry array but multi-root
|
||||
workspaces are supported; MemPalace uses index `[0]`.
|
||||
|
||||
## Event-specific fields
|
||||
|
||||
### `stop` (consumed by `mempal_save_hook_cursor.sh`)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "completed" | "aborted" | "error",
|
||||
"loop_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
- `loop_count` indicates how many times this stop hook has already
|
||||
triggered an automatic followup for this conversation (starts at
|
||||
0). When `loop_count > 0` we know our own previous `followup_message`
|
||||
is currently being processed — the save hook returns `{}` so the
|
||||
agent can finish. Equivalent to Claude Code's `stop_hook_active`.
|
||||
- The per-script `loop_limit` (default 5 for Cursor hooks, configurable
|
||||
via the `loop_limit` field on the hook entry in `hooks.json`) is
|
||||
defense-in-depth on top of our own check. The example `hooks.json`
|
||||
in `examples/cursor/` sets `loop_limit: 1`.
|
||||
|
||||
**Allowed output fields** (only):
|
||||
|
||||
```json
|
||||
{ "followup_message": "<text to auto-submit as next user turn>" }
|
||||
```
|
||||
|
||||
### `preCompact` (consumed by `mempal_precompact_hook_cursor.sh`)
|
||||
|
||||
```json
|
||||
{
|
||||
"trigger": "auto" | "manual",
|
||||
"context_usage_percent": 85,
|
||||
"context_tokens": 120000,
|
||||
"context_window_size": 128000,
|
||||
"message_count": 45,
|
||||
"messages_to_compact": 30,
|
||||
"is_first_compaction": true
|
||||
}
|
||||
```
|
||||
|
||||
**Critical constraint:** preCompact is documented as **observational
|
||||
only**. It cannot block compaction and its allowed output fields are
|
||||
limited to:
|
||||
|
||||
```json
|
||||
{ "user_message": "<short message shown to the user when compaction occurs>" }
|
||||
```
|
||||
|
||||
There is **no** `followup_message` and **no** `decision: block` on
|
||||
this event — unlike Claude Code's `PreCompact`. MemPalace works
|
||||
around this by:
|
||||
|
||||
1. Running `mempalace mine` synchronously inside the hook so the
|
||||
verbatim transcript lands in the palace before compaction
|
||||
summarises it.
|
||||
2. Dropping a `cursor_<conversation_id>.pending` marker that the next
|
||||
`stop` invocation reads and uses to force a save followup
|
||||
regardless of its counter.
|
||||
|
||||
### `sessionStart` (consumed by `mempal_wake_hook_cursor.sh`)
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "<unique session identifier>",
|
||||
"is_background_agent": true,
|
||||
"composer_mode": "agent" | "ask" | "edit"
|
||||
}
|
||||
```
|
||||
|
||||
`session_id` equals `conversation_id` on this event (docs are
|
||||
explicit about this).
|
||||
|
||||
**Allowed output fields:**
|
||||
|
||||
```json
|
||||
{
|
||||
"env": { "<key>": "<value>" },
|
||||
"additional_context": "<text added to conversation's initial system context>"
|
||||
}
|
||||
```
|
||||
|
||||
`additional_context` is the field MemPalace uses. The schema also
|
||||
accepts `continue` and `user_message` but the docs explicitly note
|
||||
"current callers do not enforce them; session creation is not
|
||||
blocked even when continue is false". We do not emit either.
|
||||
|
||||
## Environment variables (all hooks)
|
||||
|
||||
Cursor sets these env vars on every hook execution; the hook scripts
|
||||
fall back to them when JSON parsing fails for any reason.
|
||||
|
||||
| Variable | Description |
|
||||
|---------------------------|---------------------------------------------------|
|
||||
| `CURSOR_PROJECT_DIR` | Workspace root (= `workspace_roots[0]`) |
|
||||
| `CURSOR_VERSION` | Cursor version string |
|
||||
| `CURSOR_USER_EMAIL` | Authenticated user email (if logged in) |
|
||||
| `CURSOR_TRANSCRIPT_PATH` | Conversation transcript path (if transcripts on) |
|
||||
| `CURSOR_CODE_REMOTE` | `"true"` if running in a remote workspace |
|
||||
| `CLAUDE_PROJECT_DIR` | Alias for `CURSOR_PROJECT_DIR` (Claude compat) |
|
||||
|
||||
## Exit code semantics
|
||||
|
||||
Cursor interprets command-hook exit codes as follows
|
||||
(docs "Hook Types → Command-Based Hooks → Exit code behavior"):
|
||||
|
||||
- `0` — success, use the JSON output.
|
||||
- `2` — block the action (equivalent to `permission: "deny"`).
|
||||
- Other — hook failed; action proceeds (fail-open by default).
|
||||
|
||||
MemPalace hooks always exit `0` and emit either `{}` (no-op) or a
|
||||
valid JSON response. We never use exit code `2`; nothing MemPalace
|
||||
does should ever block an agent action.
|
||||
|
||||
## Working directory contract
|
||||
|
||||
- **User hooks** (`~/.cursor/hooks.json`) run from `~/.cursor/`.
|
||||
- **Project hooks** (`.cursor/hooks.json`) run from the project root.
|
||||
|
||||
The MemPalace hooks always resolve their sibling `lib/common.sh` via
|
||||
`BASH_SOURCE[0]` so the working directory does not matter for the
|
||||
script's own loading — only the `command` path in `hooks.json` needs
|
||||
to point at the absolute location of the script.
|
||||
|
||||
## Transcript file format (out of scope)
|
||||
|
||||
The format of the file at `transcript_path` is **not documented by
|
||||
Cursor** as of the fetch date above. MemPalace deliberately does not
|
||||
parse it: the save hook counts `stop` invocations (each one
|
||||
corresponds to one assistant turn) and hands the transcript to
|
||||
`mempalace mine`, which has its own normaliser layer.
|
||||
|
||||
If you need to consume the transcript directly, probe its shape with
|
||||
a throw-away hook that does `cat > /tmp/cursor-transcript-sample.txt`
|
||||
and inspect the output — there is no shortcut.
|
||||
Executable
+399
@@ -0,0 +1,399 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE CURSOR HOOK INSTALLER
|
||||
#
|
||||
# Optional helper. Copies the three Cursor hook scripts to a
|
||||
# stable install location and merges entries into a Cursor
|
||||
# `hooks.json` config file — without clobbering unrelated hooks
|
||||
# already in that file.
|
||||
#
|
||||
# This is NEVER auto-invoked. Editor config is sacred; we do not
|
||||
# modify a user's hooks.json without explicit consent. The user runs
|
||||
# this script (or wires the hooks manually) as a documented opt-in.
|
||||
#
|
||||
# === USAGE ===
|
||||
#
|
||||
# hooks/cursor/install.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --scope user|project Target scope. Default: user.
|
||||
# - user: merges into ~/.cursor/hooks.json
|
||||
# - project: merges into <target>/.cursor/hooks.json
|
||||
# --target <path> Project root for --scope project (default: $PWD).
|
||||
# Ignored for --scope user.
|
||||
# --install-dir <path> Where to copy the hook scripts.
|
||||
# Default: ~/.mempalace/hooks/cursor
|
||||
# --variant full|minimal Which hook set to wire.
|
||||
# - full: stop + preCompact + sessionStart
|
||||
# - minimal: stop only
|
||||
# Default: full.
|
||||
# --dry-run Print the would-be JSON to stdout, do not write
|
||||
# and do not copy scripts.
|
||||
# --uninstall Remove MemPalace entries from the target
|
||||
# hooks.json (preserves unrelated hooks).
|
||||
# Does NOT delete the installed scripts.
|
||||
# -h, --help Show this help and exit.
|
||||
#
|
||||
# === PORTABILITY ===
|
||||
#
|
||||
# Pure bash 3.2 + POSIX tools + python3 (which the hook scripts
|
||||
# themselves already require). No `jq` dependency.
|
||||
#
|
||||
# Python helpers are materialised to temp files rather than piped via
|
||||
# `$(... <<'PYEOF' ... PYEOF)` to dodge the bash 3.2.57 parser bug
|
||||
# that trips on parens nested inside a heredoc body that lives inside
|
||||
# a `$(...)` command substitution.
|
||||
|
||||
set -e
|
||||
|
||||
usage() {
|
||||
sed -n '2,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────
|
||||
SCOPE="user"
|
||||
TARGET=""
|
||||
INSTALL_DIR="$HOME/.mempalace/hooks/cursor"
|
||||
VARIANT="full"
|
||||
DRY_RUN=0
|
||||
UNINSTALL=0
|
||||
|
||||
# ── Parse args ────────────────────────────────────────────────────
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--scope)
|
||||
shift
|
||||
SCOPE="${1:-}"
|
||||
;;
|
||||
--target)
|
||||
shift
|
||||
TARGET="${1:-}"
|
||||
;;
|
||||
--install-dir)
|
||||
shift
|
||||
INSTALL_DIR="${1:-}"
|
||||
;;
|
||||
--variant)
|
||||
shift
|
||||
VARIANT="${1:-}"
|
||||
;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--uninstall) UNINSTALL=1 ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'install.sh: unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
shift || true
|
||||
done
|
||||
|
||||
case "$SCOPE" in
|
||||
user|project) ;;
|
||||
*)
|
||||
printf 'install.sh: --scope must be "user" or "project" (got "%s")\n' \
|
||||
"$SCOPE" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$VARIANT" in
|
||||
full|minimal) ;;
|
||||
*)
|
||||
printf 'install.sh: --variant must be "full" or "minimal" (got "%s")\n' \
|
||||
"$VARIANT" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Resolve paths ─────────────────────────────────────────────────
|
||||
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
|
||||
SOURCE_DIR="$_script_dir"
|
||||
|
||||
# Resolve --install-dir to an absolute path before it gets baked into
|
||||
# hooks.json. Cursor invokes hook commands from its own working
|
||||
# directory (typically the project root), so a relative command path
|
||||
# would silently fail to launch the hook. gh-PR review caught this.
|
||||
case "$INSTALL_DIR" in
|
||||
/*) ;;
|
||||
*) INSTALL_DIR="$PWD/$INSTALL_DIR" ;;
|
||||
esac
|
||||
|
||||
# Resolve the Python interpreter the same way the hooks themselves do
|
||||
# so a user with a non-default Python is consistent across install +
|
||||
# runtime.
|
||||
if [ -n "${MEMPAL_PYTHON:-}" ] && [ -x "$MEMPAL_PYTHON" ]; then
|
||||
PYTHON_BIN="$MEMPAL_PYTHON"
|
||||
else
|
||||
PYTHON_BIN="$(command -v python3 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$PYTHON_BIN" ]; then
|
||||
printf 'install.sh: python3 not found on PATH; cannot proceed.\n' >&2
|
||||
printf 'Set $MEMPAL_PYTHON to an interpreter path or install python3.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine the target hooks.json path.
|
||||
case "$SCOPE" in
|
||||
user)
|
||||
if [ -n "$TARGET" ]; then
|
||||
printf 'install.sh: --target is only meaningful with --scope project; ignoring.\n' >&2
|
||||
fi
|
||||
TARGET_DIR="$HOME/.cursor"
|
||||
;;
|
||||
project)
|
||||
TARGET_DIR="${TARGET:-$PWD}/.cursor"
|
||||
;;
|
||||
esac
|
||||
TARGET_FILE="$TARGET_DIR/hooks.json"
|
||||
|
||||
# Determine which commands the merge / uninstall logic should
|
||||
# install or remove. Paths point at the install location, NOT the
|
||||
# source repo — once the user runs install.sh they can move / delete
|
||||
# the cloned repo without breaking the wiring.
|
||||
SAVE_CMD="$INSTALL_DIR/mempal_save_hook_cursor.sh"
|
||||
PRECOMPACT_CMD="$INSTALL_DIR/mempal_precompact_hook_cursor.sh"
|
||||
WAKE_CMD="$INSTALL_DIR/mempal_wake_hook_cursor.sh"
|
||||
|
||||
# ── Step 1: copy scripts (skipped on --dry-run / --uninstall) ─────
|
||||
if [ "$UNINSTALL" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
|
||||
mkdir -p "$INSTALL_DIR/lib"
|
||||
cp "$SOURCE_DIR/lib/common.sh" "$INSTALL_DIR/lib/common.sh"
|
||||
cp "$SOURCE_DIR/mempal_save_hook_cursor.sh" "$INSTALL_DIR/"
|
||||
cp "$SOURCE_DIR/mempal_precompact_hook_cursor.sh" "$INSTALL_DIR/"
|
||||
cp "$SOURCE_DIR/mempal_wake_hook_cursor.sh" "$INSTALL_DIR/"
|
||||
chmod +x "$INSTALL_DIR/mempal_save_hook_cursor.sh" \
|
||||
"$INSTALL_DIR/mempal_precompact_hook_cursor.sh" \
|
||||
"$INSTALL_DIR/mempal_wake_hook_cursor.sh"
|
||||
printf 'install.sh: copied scripts to %s\n' "$INSTALL_DIR" >&2
|
||||
fi
|
||||
|
||||
# ── Short-circuit: uninstall with no existing file is a no-op ─────
|
||||
#
|
||||
# Without this, the merge step would happily write an empty
|
||||
# {"version": 1, "hooks": {}} to a brand new file that the user
|
||||
# never asked us to create — surprising behaviour that the test
|
||||
# suite explicitly guards against.
|
||||
if [ "$UNINSTALL" -eq 1 ] && [ ! -f "$TARGET_FILE" ]; then
|
||||
printf 'install.sh: nothing to uninstall (%s does not exist)\n' \
|
||||
"$TARGET_FILE" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Step 2: merge / unmerge hooks.json via python3 ────────────────
|
||||
#
|
||||
# Materialise the merge logic to a temp .py file (see bash 3.2
|
||||
# rationale at the top of this script), then invoke it. The Python
|
||||
# script is responsible for:
|
||||
# * tolerating a missing or empty hooks.json (starts from {})
|
||||
# * preserving unrelated hook entries on install
|
||||
# * preserving unrelated hook entries on uninstall
|
||||
# * recognising MemPalace entries by basename in the `command` field
|
||||
# * idempotent install (re-running does not duplicate entries)
|
||||
|
||||
# mktemp portability: pass an explicit absolute template so we sidestep
|
||||
# the BSD vs GNU difference in `-t` semantics (BSD treats it as a
|
||||
# prefix; GNU treats it as a template). Honour TMPDIR if set, fall
|
||||
# back to /tmp. gh-PR review caught the previous `-t` form as
|
||||
# non-portable.
|
||||
MERGE_PY="$(mktemp "${TMPDIR:-/tmp}/mempal-install-merge.XXXXXX")"
|
||||
trap 'rm -f "$MERGE_PY"' EXIT
|
||||
|
||||
cat > "$MERGE_PY" <<'PYEOF'
|
||||
"""hooks.json merge helper for hooks/cursor/install.sh.
|
||||
|
||||
Argv:
|
||||
sys.argv[1]: path to hooks.json (may not exist)
|
||||
sys.argv[2]: variant ("full" or "minimal")
|
||||
sys.argv[3]: uninstall flag ("1" or "0")
|
||||
sys.argv[4]: save_cmd absolute path
|
||||
sys.argv[5]: precompact_cmd absolute path
|
||||
sys.argv[6]: wake_cmd absolute path
|
||||
|
||||
Output: prints the merged JSON to stdout. Exits 2 on a malformed
|
||||
existing config (refuses to overwrite a broken file).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
target_file = sys.argv[1]
|
||||
variant = sys.argv[2]
|
||||
uninstall = sys.argv[3] == "1"
|
||||
save_cmd = sys.argv[4]
|
||||
precompact_cmd = sys.argv[5]
|
||||
wake_cmd = sys.argv[6]
|
||||
|
||||
# Recognise our entries by basename. The three filenames below are
|
||||
# the unique product-of-our-naming convention; any entry whose command
|
||||
# ends in one of them is treated as a MemPalace entry on
|
||||
# install (so we replace rather than duplicate it) and on uninstall
|
||||
# (so we remove it without touching unrelated entries). Matching on
|
||||
# basename rather than a full-path substring lets users pick any
|
||||
# --install-dir without breaking uninstall.
|
||||
MEMPAL_BASENAMES = (
|
||||
"mempal_save_hook_cursor.sh",
|
||||
"mempal_precompact_hook_cursor.sh",
|
||||
"mempal_wake_hook_cursor.sh",
|
||||
)
|
||||
|
||||
if os.path.exists(target_file):
|
||||
with open(target_file, "r", encoding="utf-8") as fh:
|
||||
try:
|
||||
cfg = json.load(fh)
|
||||
except Exception as exc:
|
||||
sys.stderr.write(
|
||||
"install.sh: existing %s is not valid JSON: %s\n"
|
||||
"Refusing to overwrite. Fix the file and retry.\n"
|
||||
% (target_file, exc)
|
||||
)
|
||||
sys.exit(2)
|
||||
else:
|
||||
cfg = {}
|
||||
|
||||
if not isinstance(cfg, dict):
|
||||
sys.stderr.write(
|
||||
"install.sh: %s top level must be a JSON object; got %s\n"
|
||||
% (target_file, type(cfg).__name__)
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
cfg.setdefault("version", 1)
|
||||
cfg.setdefault("hooks", {})
|
||||
if not isinstance(cfg["hooks"], dict):
|
||||
sys.stderr.write(
|
||||
"install.sh: %s 'hooks' must be a JSON object\n" % target_file
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def is_mempal_entry(entry):
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
cmd = entry.get("command", "")
|
||||
if not isinstance(cmd, str):
|
||||
return False
|
||||
# Match on basename so a customised --install-dir (e.g. /opt/...,
|
||||
# ~/.local/share/..., or anything with or without a leading dot)
|
||||
# still round-trips through uninstall.
|
||||
base = os.path.basename(cmd)
|
||||
return base in MEMPAL_BASENAMES
|
||||
|
||||
|
||||
def filter_mempal(entries):
|
||||
if not isinstance(entries, list):
|
||||
return entries
|
||||
return [e for e in entries if not is_mempal_entry(e)]
|
||||
|
||||
|
||||
def upsert(event, entry):
|
||||
existing = cfg["hooks"].get(event, [])
|
||||
if not isinstance(existing, list):
|
||||
sys.stderr.write(
|
||||
"install.sh: %s hooks[%s] must be a list\n" % (target_file, event)
|
||||
)
|
||||
sys.exit(2)
|
||||
cleaned = [e for e in existing if not is_mempal_entry(e)]
|
||||
cleaned.append(entry)
|
||||
cfg["hooks"][event] = cleaned
|
||||
|
||||
|
||||
if uninstall:
|
||||
for event in list(cfg["hooks"].keys()):
|
||||
cfg["hooks"][event] = filter_mempal(cfg["hooks"][event])
|
||||
if not cfg["hooks"][event]:
|
||||
del cfg["hooks"][event]
|
||||
else:
|
||||
upsert("stop", {"command": save_cmd, "loop_limit": 1})
|
||||
if variant == "full":
|
||||
upsert("preCompact", {"command": precompact_cmd})
|
||||
upsert("sessionStart", {"command": wake_cmd})
|
||||
|
||||
# Stable key order for the events MemPalace touches, then preserve
|
||||
# any unrelated event names in their original order so future Cursor
|
||||
# events we don't know about yet still round-trip.
|
||||
known_order = [
|
||||
"sessionStart",
|
||||
"stop",
|
||||
"preCompact",
|
||||
"sessionEnd",
|
||||
"preToolUse",
|
||||
"postToolUse",
|
||||
"postToolUseFailure",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
"beforeShellExecution",
|
||||
"afterShellExecution",
|
||||
"beforeMCPExecution",
|
||||
"afterMCPExecution",
|
||||
"beforeReadFile",
|
||||
"afterFileEdit",
|
||||
"beforeSubmitPrompt",
|
||||
"afterAgentResponse",
|
||||
"afterAgentThought",
|
||||
"beforeTabFileRead",
|
||||
"afterTabFileEdit",
|
||||
"workspaceOpen",
|
||||
]
|
||||
|
||||
ordered_hooks = {}
|
||||
for event in known_order:
|
||||
if event in cfg["hooks"]:
|
||||
ordered_hooks[event] = cfg["hooks"][event]
|
||||
for event, entries in cfg["hooks"].items():
|
||||
if event not in ordered_hooks:
|
||||
ordered_hooks[event] = entries
|
||||
cfg["hooks"] = ordered_hooks
|
||||
|
||||
print(json.dumps(cfg, indent=2, sort_keys=False))
|
||||
PYEOF
|
||||
|
||||
NEW_JSON="$("$PYTHON_BIN" "$MERGE_PY" \
|
||||
"$TARGET_FILE" "$VARIANT" "$UNINSTALL" \
|
||||
"$SAVE_CMD" "$PRECOMPACT_CMD" "$WAKE_CMD")"
|
||||
|
||||
# ── Step 3: emit, write, or remove ────────────────────────────────
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'install.sh: --dry-run; would write to %s\n' "$TARGET_FILE" >&2
|
||||
printf '%s\n' "$NEW_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$TARGET_DIR"
|
||||
|
||||
# If --uninstall left an empty hooks object AND no other top-level
|
||||
# keys beyond version, remove the file entirely so the user's
|
||||
# `.cursor/` directory does not accumulate orphan configs.
|
||||
if [ "$UNINSTALL" -eq 1 ]; then
|
||||
# Inline the emptiness check via `python -c '...'` rather than a
|
||||
# temp .py file. The body is short enough that a tmpfile is pure
|
||||
# overhead, and removing the tmpfile eliminates a small leak
|
||||
# window if the script is interrupted between mktemp and rm -f.
|
||||
# gh-PR review suggested this simplification.
|
||||
NON_EMPTY="$(printf '%s' "$NEW_JSON" | "$PYTHON_BIN" -c '
|
||||
import json, sys
|
||||
cfg = json.load(sys.stdin)
|
||||
hooks = cfg.get("hooks", {})
|
||||
extras = [k for k in cfg.keys() if k not in ("version", "hooks")]
|
||||
print("1" if (hooks or extras) else "0")
|
||||
')"
|
||||
if [ "$NON_EMPTY" = "0" ] && [ -f "$TARGET_FILE" ]; then
|
||||
rm -f "$TARGET_FILE"
|
||||
printf 'install.sh: removed empty %s\n' "$TARGET_FILE" >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
TMP_FILE="${TARGET_FILE}.tmp.$$"
|
||||
printf '%s\n' "$NEW_JSON" > "$TMP_FILE"
|
||||
mv "$TMP_FILE" "$TARGET_FILE"
|
||||
|
||||
if [ "$UNINSTALL" -eq 1 ]; then
|
||||
printf 'install.sh: removed MemPalace entries from %s\n' "$TARGET_FILE" >&2
|
||||
else
|
||||
printf 'install.sh: wrote %s\n' "$TARGET_FILE" >&2
|
||||
printf 'install.sh: restart Cursor (or wait for it to reload hooks.json)\n' >&2
|
||||
fi
|
||||
@@ -0,0 +1,490 @@
|
||||
# shellcheck shell=bash
|
||||
# MEMPALACE CURSOR HOOK — shared helpers
|
||||
#
|
||||
# Sourced by the three Cursor hooks (stop / preCompact / sessionStart).
|
||||
# Mirrors the conventions of the existing Claude Code hook scripts
|
||||
# (hooks/mempal_save_hook.sh, hooks/mempal_precompact_hook.sh) so a
|
||||
# user who already debugs one knows how to debug the other:
|
||||
#
|
||||
# * STATE_DIR layout under ~/.mempalace/hook_state/
|
||||
# * MEMPAL_PYTHON resolution order (override → $PATH → bare python3)
|
||||
# * MEMPALACE_HOOKS_AUTO_SAVE=false kill switch (config.json fallback)
|
||||
# * sentinel-guarded Python parser via `sed -n 'Np'` (bash 3.2 safe)
|
||||
# * fail-open on internal errors: emit `{}` and log, never crash the
|
||||
# hook host
|
||||
#
|
||||
# Cursor-specific additions on top of that contract:
|
||||
#
|
||||
# * MEMPAL_DISABLE_HOOK=1 as an additional kill-switch alias
|
||||
# * MEMPAL_STATE_DIR env override for the state directory
|
||||
# * conversation_id (Cursor's stable per-conversation ID) replaces
|
||||
# Claude Code's session_id in the counter file names — Cursor `stop`
|
||||
# events do not carry a session_id, only conversation_id
|
||||
# * loop_count is the loop-prevention signal in place of Claude Code's
|
||||
# stop_hook_active flag (Cursor docs, "stop" event)
|
||||
#
|
||||
# This file is sourced, not executed, so it intentionally has no
|
||||
# shebang. The `# shellcheck shell=bash` directive above tells
|
||||
# shellcheck to treat it as bash when run standalone.
|
||||
|
||||
# ── State directory + log path ────────────────────────────────────────
|
||||
#
|
||||
# Honour MEMPAL_STATE_DIR (additive override introduced for Cursor)
|
||||
# while keeping the default identical to the Claude Code hooks so a
|
||||
# user running both keeps a single state directory.
|
||||
MEMPAL_STATE_DIR="${MEMPAL_STATE_DIR:-$HOME/.mempalace/hook_state}"
|
||||
mkdir -p "$MEMPAL_STATE_DIR" 2>/dev/null
|
||||
MEMPAL_CURSOR_LOG="$MEMPAL_STATE_DIR/cursor_hook.log"
|
||||
|
||||
# ── Python interpreter resolution ─────────────────────────────────────
|
||||
#
|
||||
# Same contract as the Claude Code hooks:
|
||||
# 1. $MEMPAL_PYTHON — explicit user override (absolute path)
|
||||
# 2. $(command -v python3) — first python3 on the hook's PATH
|
||||
# 3. bare "python3" — last-resort fallback
|
||||
mempal_resolve_python() {
|
||||
local p="${MEMPAL_PYTHON:-}"
|
||||
if [ -n "$p" ] && [ -x "$p" ]; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
p="$(command -v python3 2>/dev/null || true)"
|
||||
if [ -n "$p" ]; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "python3"
|
||||
}
|
||||
MEMPAL_PYTHON_BIN="$(mempal_resolve_python)"
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────
|
||||
#
|
||||
# Lines are `[ISO8601Z] [event=...] [conv=...] message`. ISO8601 keeps
|
||||
# the format greppable across timezones (the Claude Code log uses
|
||||
# %H:%M:%S which loses the date — we improve on that here without
|
||||
# changing the existing log file).
|
||||
mempal_log() {
|
||||
local event="${1:-?}"
|
||||
local conv="${2:-unknown}"
|
||||
local msg="${3:-}"
|
||||
local ts
|
||||
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
printf '[%s] [event=%s] [conv=%s] %s\n' "$ts" "$event" "$conv" "$msg" \
|
||||
>> "$MEMPAL_CURSOR_LOG" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Kill switch ───────────────────────────────────────────────────────
|
||||
#
|
||||
# Disabled if ANY of:
|
||||
# * MEMPAL_DISABLE_HOOK is a truthy string (Cursor-prompt addition)
|
||||
# * MEMPALACE_HOOKS_AUTO_SAVE is false/0/no (Claude Code convention)
|
||||
# * ~/.mempalace/config.json has hooks.auto_save == false
|
||||
#
|
||||
# Returns 0 (true in shell) when disabled, 1 when enabled.
|
||||
mempal_is_disabled() {
|
||||
case "${MEMPAL_DISABLE_HOOK:-}" in
|
||||
1|true|yes|on) return 0 ;;
|
||||
esac
|
||||
case "${MEMPALACE_HOOKS_AUTO_SAVE:-}" in
|
||||
false|0|no|off) return 0 ;;
|
||||
esac
|
||||
local cfg="$HOME/.mempalace/config.json"
|
||||
if [ -f "$cfg" ]; then
|
||||
local result
|
||||
# Use python -c '...' with the config path as argv[1] rather
|
||||
# than a heredoc. A heredoc body that contains parens inside a
|
||||
# $(...) command substitution trips the bash 3.2.57 parser
|
||||
# bug (macOS /bin/bash default) — gh-PR review caught this.
|
||||
# The -c form is also consistent with mempal_parse_stdin below.
|
||||
result="$("$MEMPAL_PYTHON_BIN" -c '
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
cfg = json.load(f)
|
||||
print(str(cfg.get("hooks", {}).get("auto_save", True)).lower())
|
||||
except Exception:
|
||||
print("true")
|
||||
' "$cfg" 2>/dev/null)"
|
||||
if [ "$result" = "false" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Stdin parser ──────────────────────────────────────────────────────
|
||||
#
|
||||
# Reads Cursor's hook JSON from $1 and exports:
|
||||
# MEMPAL_CONV_ID — conversation_id, falls back to "unknown"
|
||||
# MEMPAL_LOOP_COUNT — integer (0 if absent / non-numeric)
|
||||
# MEMPAL_TRANSCRIPT — transcript_path, may be empty
|
||||
# MEMPAL_WORKSPACE — first workspace_roots entry, falls back to
|
||||
# CURSOR_PROJECT_DIR env var, then $PWD
|
||||
# MEMPAL_TRIGGER — preCompact trigger ("auto" | "manual"), empty otherwise
|
||||
# MEMPAL_STATUS — stop status ("completed" | "aborted" | "error"),
|
||||
# empty otherwise
|
||||
# MEMPAL_PARSE_OK — "1" if parser ran cleanly, "0" otherwise
|
||||
#
|
||||
# Uses the same sentinel + `sed -n 'Np'` extraction as the Claude Code
|
||||
# hooks for bash 3.2 compatibility (mapfile/readarray are unavailable
|
||||
# on macOS /bin/bash 3.2.57; #1440 regression). Each line of output is
|
||||
# pre-sanitised by the Python side to a shell-safe character set.
|
||||
mempal_parse_stdin() {
|
||||
local input="${1:-}"
|
||||
local parsed
|
||||
# We invoke Python via -c with a single-quoted multi-line string
|
||||
# rather than ``python3 - <<'PYEOF'`` because the heredoc form
|
||||
# would shadow Python's stdin with the heredoc body, leaving
|
||||
# ``json.load(sys.stdin)`` to read nothing and silently fail. The
|
||||
# parser body deliberately uses only double-quoted Python strings
|
||||
# so the surrounding bash single-quote is safe verbatim, and uses
|
||||
# only the shell-safe character set (alphanumeric, underscore,
|
||||
# dash, slash, dot, tilde) matching the Claude Code hook's
|
||||
# sanitiser so a hostile transcript_path cannot splice
|
||||
# metacharacters into the parsed output.
|
||||
parsed="$(
|
||||
umask 077
|
||||
printf '%s' "$input" | "$MEMPAL_PYTHON_BIN" -c '
|
||||
import json, re, sys
|
||||
|
||||
def safe_str(value):
|
||||
return re.sub(r"[^a-zA-Z0-9_/.\-~]", "", str(value or ""))
|
||||
|
||||
def safe_int(value):
|
||||
try:
|
||||
return str(int(value))
|
||||
except (TypeError, ValueError):
|
||||
return "0"
|
||||
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
if not isinstance(data, dict):
|
||||
sys.exit(1)
|
||||
|
||||
conv = safe_str(data.get("conversation_id") or data.get("session_id"))
|
||||
loop_count = safe_int(data.get("loop_count", 0))
|
||||
transcript = safe_str(data.get("transcript_path", ""))
|
||||
trigger = safe_str(data.get("trigger", ""))
|
||||
status = safe_str(data.get("status", ""))
|
||||
|
||||
roots = data.get("workspace_roots") or []
|
||||
workspace = ""
|
||||
if isinstance(roots, list) and roots:
|
||||
workspace = safe_str(roots[0])
|
||||
|
||||
print("__MEMPAL_PARSE_OK__")
|
||||
print(conv)
|
||||
print(loop_count)
|
||||
print(transcript)
|
||||
print(workspace)
|
||||
print(trigger)
|
||||
print(status)
|
||||
' 2>"$MEMPAL_STATE_DIR/cursor_last_python_err.log"
|
||||
)"
|
||||
|
||||
# Drop empty stderr capture on success; lock it to 0600 on failure
|
||||
# (mirrors the privacy contract in the Claude Code hooks — the
|
||||
# traceback can echo back transcript_path / home layout).
|
||||
if [ -s "$MEMPAL_STATE_DIR/cursor_last_python_err.log" ]; then
|
||||
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
|
||||
else
|
||||
rm -f "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
|
||||
fi
|
||||
|
||||
local marker
|
||||
marker="$(printf '%s\n' "$parsed" | sed -n '1p')"
|
||||
if [ "$marker" = "__MEMPAL_PARSE_OK__" ]; then
|
||||
MEMPAL_PARSE_OK="1"
|
||||
MEMPAL_CONV_ID="$(printf '%s\n' "$parsed" | sed -n '2p')"
|
||||
MEMPAL_LOOP_COUNT="$(printf '%s\n' "$parsed" | sed -n '3p')"
|
||||
MEMPAL_TRANSCRIPT="$(printf '%s\n' "$parsed" | sed -n '4p')"
|
||||
MEMPAL_WORKSPACE="$(printf '%s\n' "$parsed" | sed -n '5p')"
|
||||
MEMPAL_TRIGGER="$(printf '%s\n' "$parsed" | sed -n '6p')"
|
||||
MEMPAL_STATUS="$(printf '%s\n' "$parsed" | sed -n '7p')"
|
||||
else
|
||||
MEMPAL_PARSE_OK="0"
|
||||
MEMPAL_CONV_ID=""
|
||||
MEMPAL_LOOP_COUNT="0"
|
||||
MEMPAL_TRANSCRIPT=""
|
||||
MEMPAL_WORKSPACE=""
|
||||
MEMPAL_TRIGGER=""
|
||||
MEMPAL_STATUS=""
|
||||
fi
|
||||
|
||||
# Defaults and environment fallbacks. The Cursor docs guarantee
|
||||
# CURSOR_TRANSCRIPT_PATH and CURSOR_PROJECT_DIR env vars are set
|
||||
# for every hook execution; if JSON parsing failed for any reason
|
||||
# (sentinel missing, malformed payload, missing interpreter) we
|
||||
# still have a usable workspace.
|
||||
MEMPAL_CONV_ID="${MEMPAL_CONV_ID:-unknown}"
|
||||
case "$MEMPAL_LOOP_COUNT" in
|
||||
''|*[!0-9]*) MEMPAL_LOOP_COUNT="0" ;;
|
||||
esac
|
||||
if [ -z "$MEMPAL_TRANSCRIPT" ] && [ -n "${CURSOR_TRANSCRIPT_PATH:-}" ]; then
|
||||
MEMPAL_TRANSCRIPT="${CURSOR_TRANSCRIPT_PATH}"
|
||||
fi
|
||||
if [ -z "$MEMPAL_WORKSPACE" ]; then
|
||||
if [ -n "${CURSOR_PROJECT_DIR:-}" ]; then
|
||||
MEMPAL_WORKSPACE="${CURSOR_PROJECT_DIR}"
|
||||
elif [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
|
||||
MEMPAL_WORKSPACE="${CLAUDE_PROJECT_DIR}"
|
||||
else
|
||||
MEMPAL_WORKSPACE="${PWD:-/}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Expand a leading ~ in the transcript path so downstream
|
||||
# ``[ -f "$path" ]`` checks resolve correctly.
|
||||
case "$MEMPAL_TRANSCRIPT" in
|
||||
'~/'*) MEMPAL_TRANSCRIPT="$HOME/${MEMPAL_TRANSCRIPT#~/}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Defense-in-depth: dump unparseable stdin ──────────────────────────
|
||||
#
|
||||
# Same shape as the Claude Code hooks' last_input.log: bounded to 4096
|
||||
# bytes, overwritten (never appended) so a misconfiguration loop cannot
|
||||
# grow disk usage, 0600 perms because the dump mirrors the raw hook
|
||||
# payload (transcript_path reveals the user's home + project layout).
|
||||
mempal_dump_bad_input() {
|
||||
local input="${1:-}"
|
||||
if [ -z "$input" ]; then
|
||||
return 0
|
||||
fi
|
||||
mempal_log "${2:-?}" "${MEMPAL_CONV_ID:-unknown}" \
|
||||
"WARN: input parse failed (sentinel missing); see $MEMPAL_STATE_DIR/cursor_last_input.log + cursor_last_python_err.log"
|
||||
(
|
||||
umask 077
|
||||
printf '%s' "$input" | head -c 4096 > "$MEMPAL_STATE_DIR/cursor_last_input.log"
|
||||
)
|
||||
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_input.log" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Counter helpers ───────────────────────────────────────────────────
|
||||
#
|
||||
# One counter file per conversation_id. Atomic write via temp file
|
||||
# inside the same directory (rename is atomic on POSIX) so concurrent
|
||||
# hook invocations cannot half-write the file. Read tolerates a
|
||||
# corrupted or empty file by returning 0, never crashing.
|
||||
_mempal_counter_path() {
|
||||
local conv="${1:-unknown}"
|
||||
# Sanitise the conv id one more time: it has already been through
|
||||
# the Python sanitiser, but be defensive in case a caller passes a
|
||||
# raw string. Strip any character outside [a-zA-Z0-9_.-].
|
||||
local safe_conv
|
||||
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
|
||||
if [ -z "$safe_conv" ]; then
|
||||
safe_conv="unknown"
|
||||
fi
|
||||
printf '%s/cursor_%s.count' "$MEMPAL_STATE_DIR" "$safe_conv"
|
||||
}
|
||||
|
||||
mempal_read_counter() {
|
||||
local path="$1"
|
||||
if [ ! -f "$path" ]; then
|
||||
printf '0'
|
||||
return 0
|
||||
fi
|
||||
local raw
|
||||
raw="$(cat "$path" 2>/dev/null)"
|
||||
case "$raw" in
|
||||
''|*[!0-9]*) printf '0' ;;
|
||||
*) printf '%s' "$raw" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
mempal_write_counter_atomic() {
|
||||
local path="$1"
|
||||
local value="$2"
|
||||
case "$value" in
|
||||
''|*[!0-9]*) value="0" ;;
|
||||
esac
|
||||
local tmp="${path}.tmp.$$"
|
||||
printf '%s' "$value" > "$tmp" 2>/dev/null || return 1
|
||||
mv "$tmp" "$path" 2>/dev/null || {
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# ── Pending-save marker ───────────────────────────────────────────────
|
||||
#
|
||||
# Dropped by the preCompact hook (which cannot itself emit a
|
||||
# followup_message — Cursor's preCompact is observational-only) and
|
||||
# consumed by the next stop invocation so the LLM still gets a diary
|
||||
# nudge after compaction.
|
||||
_mempal_pending_path() {
|
||||
local conv="${1:-unknown}"
|
||||
local safe_conv
|
||||
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
|
||||
if [ -z "$safe_conv" ]; then
|
||||
safe_conv="unknown"
|
||||
fi
|
||||
printf '%s/cursor_%s.pending' "$MEMPAL_STATE_DIR" "$safe_conv"
|
||||
}
|
||||
|
||||
mempal_set_pending() {
|
||||
local conv="${1:-unknown}"
|
||||
local path
|
||||
path="$(_mempal_pending_path "$conv")"
|
||||
: > "$path" 2>/dev/null || return 1
|
||||
chmod 600 "$path" 2>/dev/null
|
||||
}
|
||||
|
||||
mempal_consume_pending() {
|
||||
local conv="${1:-unknown}"
|
||||
local path
|
||||
path="$(_mempal_pending_path "$conv")"
|
||||
if [ -f "$path" ]; then
|
||||
rm -f "$path" 2>/dev/null
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── State-file TTL ────────────────────────────────────────────────────
|
||||
#
|
||||
# Per-conversation state artifacts (cursor_<conv>.count and
|
||||
# cursor_<conv>.pending) accumulate one set per conversation and are
|
||||
# never otherwise removed (igorls review, PR #1632 — unbounded state
|
||||
# growth). Reads MEMPAL_STATE_TTL_DAYS (default 30), validated
|
||||
# digits-only and leading-zero-stripped (mirrors the SAVE_INTERVAL
|
||||
# sanitiser) so `find -mtime` never sees a bad or octal token. Empty or
|
||||
# non-numeric floors to 30; a value of 0 means "sweep everything older
|
||||
# than today".
|
||||
mempal_state_ttl_days() {
|
||||
local raw="${MEMPAL_STATE_TTL_DAYS:-30}"
|
||||
case "$raw" in
|
||||
''|*[!0-9]*) printf '30'; return 0 ;;
|
||||
esac
|
||||
while [ "${raw}" != "${raw#0}" ] && [ "${#raw}" -gt 1 ]; do
|
||||
raw="${raw#0}"
|
||||
done
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# ── Stale state GC ────────────────────────────────────────────────────
|
||||
#
|
||||
# Opportunistic sweep of per-conversation Cursor state older than the
|
||||
# TTL. Throttled to at most once per 24h via the cursor_last_sweep
|
||||
# marker, so it costs a single mtime comparison on the vast majority of
|
||||
# fires. When it does run, two `find` passes remove the stale counter
|
||||
# files and pending markers.
|
||||
#
|
||||
# The globs are Cursor-specific and suffix-anchored (cursor_*.count,
|
||||
# cursor_*.pending), so the shared logs (cursor_hook.log,
|
||||
# cursor_last_input.log, cursor_last_python_err.log), the
|
||||
# cursor_last_sweep marker itself, and any antigravity_*/Claude state
|
||||
# sharing the same directory are never touched. BSD find (macOS default)
|
||||
# and GNU find both accept -maxdepth, -mtime +N, and -exec ... +.
|
||||
#
|
||||
# Fail-open: every step is best-effort; a missing state dir, a find that
|
||||
# errors, or a permission problem must never abort the caller.
|
||||
mempal_gc_stale_state() {
|
||||
[ -d "$MEMPAL_STATE_DIR" ] || return 0
|
||||
|
||||
local marker="$MEMPAL_STATE_DIR/cursor_last_sweep"
|
||||
if [ -f "$marker" ]; then
|
||||
local mtime now
|
||||
if mtime=$("$MEMPAL_PYTHON_BIN" -c 'import os, sys; print(int(os.path.getmtime(sys.argv[1])))' "$marker" 2>/dev/null) \
|
||||
&& now=$(date '+%s' 2>/dev/null) \
|
||||
&& [ -n "$mtime" ] \
|
||||
&& [ "$((now - mtime))" -lt 86400 ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Touch the marker first so a crash mid-sweep still throttles the
|
||||
# next fire (better to skip a sweep than to hammer the disk).
|
||||
: > "$marker" 2>/dev/null
|
||||
|
||||
local ttl
|
||||
ttl=$(mempal_state_ttl_days)
|
||||
|
||||
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
|
||||
-name 'cursor_*.count' -mtime +"$ttl" \
|
||||
-exec rm -f {} + 2>/dev/null
|
||||
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
|
||||
-name 'cursor_*.pending' -mtime +"$ttl" \
|
||||
-exec rm -f {} + 2>/dev/null
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Workspace → wing inference ────────────────────────────────────────
|
||||
#
|
||||
# basename(workspace_root), normalised to [a-z0-9_-]. Edge cases:
|
||||
# / → "root"
|
||||
# /path/ → trailing slash stripped, then basename
|
||||
# "/foo bar/" → "foo_bar" (spaces collapsed to underscores)
|
||||
# "" → "cursor_session"
|
||||
# "C:\\proj" → "proj" (Windows-style path; basename via tr fallback)
|
||||
#
|
||||
# We intentionally keep this in pure bash + POSIX tools so the
|
||||
# inference is identical across the hook scripts and the test suite
|
||||
# can target it as a function via `bash -c 'source ...; ...'`.
|
||||
mempal_infer_wing() {
|
||||
local raw="${1:-}"
|
||||
if [ -z "$raw" ]; then
|
||||
printf 'cursor_session'
|
||||
return 0
|
||||
fi
|
||||
# Strip trailing slashes (but preserve the lone "/" case).
|
||||
while [ "$raw" != "/" ] && [ "${raw%/}" != "$raw" ]; do
|
||||
raw="${raw%/}"
|
||||
done
|
||||
if [ "$raw" = "/" ]; then
|
||||
printf 'root'
|
||||
return 0
|
||||
fi
|
||||
local base="${raw##*/}"
|
||||
# On Windows-style paths with backslashes, fall back to splitting
|
||||
# on backslash too so we don't return the whole path verbatim.
|
||||
case "$base" in
|
||||
*\\*) base="${base##*\\}" ;;
|
||||
esac
|
||||
# Lowercase + replace anything outside [a-z0-9_-] with underscore.
|
||||
# Collapse runs of underscores so "foo bar" doesn't become
|
||||
# "foo__bar".
|
||||
base="$(printf '%s' "$base" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| tr -c 'a-z0-9_-' '_' \
|
||||
| tr -s '_' \
|
||||
| sed 's/^_//; s/_$//')"
|
||||
if [ -z "$base" ]; then
|
||||
printf 'cursor_session'
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "$base"
|
||||
}
|
||||
|
||||
# ── Transcript path validation ────────────────────────────────────────
|
||||
#
|
||||
# Mirrors hooks/mempal_save_hook.sh::is_valid_transcript_path so the
|
||||
# Cursor and Claude Code hooks reject the same shapes:
|
||||
# * non-empty
|
||||
# * .json or .jsonl suffix
|
||||
# * no .. traversal segments
|
||||
mempal_is_valid_transcript() {
|
||||
local path="${1:-}"
|
||||
[ -n "$path" ] || return 1
|
||||
case "$path" in
|
||||
*.json|*.jsonl) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
case "/$path/" in
|
||||
*/../*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── JSON emit ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# Final stdout write. Uses ``printf '%s'`` instead of ``echo`` because
|
||||
# echo interprets ``-n``/``-e``/``-E`` as flags and varies in backslash
|
||||
# handling between builtin and /bin/echo (xpg_echo shopt). Matches the
|
||||
# Claude Code hook's documented rationale.
|
||||
mempal_emit() {
|
||||
printf '%s\n' "${1:-{\}}"
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE CURSOR PRE-COMPACT HOOK — Snapshot transcript before compaction
|
||||
#
|
||||
# Cursor "preCompact" hook. Cursor's preCompact is documented as
|
||||
# OBSERVATIONAL ONLY (cursor.com/docs/hooks.md fetched 2026-05-27):
|
||||
#
|
||||
# * It cannot block compaction.
|
||||
# * Its only output field is `user_message` (no `followup_message`,
|
||||
# no `decision: block`).
|
||||
#
|
||||
# So unlike the Claude Code PreCompact hook (which can block the AI
|
||||
# and force a save before compaction proceeds), the Cursor preCompact
|
||||
# hook can only do two useful things at this moment:
|
||||
#
|
||||
# 1. Run `mempalace mine` SYNCHRONOUSLY against the transcript file
|
||||
# so whatever Cursor's transcript contains is ingested BEFORE
|
||||
# Cursor summarises the conversation — zero LLM cost, no agent
|
||||
# interaction needed. NOTE: this is BEST-EFFORT for Cursor.
|
||||
# Cursor's transcript format is undocumented and normalize.py has
|
||||
# no Cursor parser, so this does not yet produce clean verbatim
|
||||
# drawers; it is a safety net, not the primary capture path.
|
||||
#
|
||||
# 2. Drop a `.pending` marker file keyed on conversation_id. The
|
||||
# next `stop` hook reads that marker and forces a save followup
|
||||
# regardless of its counter, so the AI still gets a "write a
|
||||
# diary entry now" nudge on the very next turn. THIS followup is
|
||||
# the load-bearing verbatim-capture path for Cursor (the agent
|
||||
# files its own in-context verbatim quotes via the MCP tools).
|
||||
#
|
||||
# === INSTALL ===
|
||||
#
|
||||
# Add to ~/.cursor/hooks.json (or .cursor/hooks.json for project
|
||||
# scope) under "preCompact":
|
||||
#
|
||||
# {
|
||||
# "version": 1,
|
||||
# "hooks": {
|
||||
# "preCompact": [
|
||||
# { "command": "/absolute/path/to/mempal_precompact_hook_cursor.sh" }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# No loop_limit is needed; preCompact is not a looping hook.
|
||||
|
||||
_mempal_self="${BASH_SOURCE[0]:-$0}"
|
||||
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
. "$_mempal_dir/lib/common.sh"
|
||||
|
||||
# Optional additional project directory to mine before compaction
|
||||
# (parity with the Claude Code hook's MEMPAL_DIR knob).
|
||||
MEMPAL_DIR="${MEMPAL_DIR:-}"
|
||||
|
||||
if mempal_is_disabled; then
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Opportunistic, daily-throttled GC of stale per-conversation state.
|
||||
# Placed after the kill switch so a disabled hook touches nothing.
|
||||
mempal_gc_stale_state
|
||||
|
||||
INPUT="$(cat)"
|
||||
mempal_parse_stdin "$INPUT"
|
||||
|
||||
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
|
||||
mempal_dump_bad_input "$INPUT" "preCompact"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"trigger=${MEMPAL_TRIGGER:-?} transcript=$MEMPAL_TRANSCRIPT"
|
||||
|
||||
# ── Synchronous mine ──────────────────────────────────────────────
|
||||
#
|
||||
# This intentionally blocks the hook. Compaction is irreversible —
|
||||
# once Cursor summarises the conversation we cannot get the verbatim
|
||||
# text back — so we must finish ingesting before returning. Background
|
||||
# mining would race the compaction and lose data.
|
||||
#
|
||||
# TIMEOUT TRADEOFF (igorls review, PR #1632): on a very large transcript
|
||||
# this synchronous mine can exceed Cursor's per-hook timeout, in which
|
||||
# case Cursor kills the process mid-mine. That is acceptable and safe
|
||||
# here: `mempalace mine` is incremental and append-only (a crash mid-
|
||||
# operation leaves the existing palace untouched — see CLAUDE.md
|
||||
# "Incremental only"), so a killed mine simply resumes on the next mine
|
||||
# invocation rather than corrupting the palace. We deliberately do NOT
|
||||
# wrap this in a shorter timeout, because truncating the mine would
|
||||
# trade a recoverable partial-ingest for guaranteed silent data loss
|
||||
# right before the irreversible compaction. The pending-save marker
|
||||
# below is the backstop: the next `stop` hook re-mines and nudges a
|
||||
# verbatim save regardless of whether this mine completed.
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
if mempal_is_valid_transcript "$MEMPAL_TRANSCRIPT" \
|
||||
&& [ -f "$MEMPAL_TRANSCRIPT" ]; then
|
||||
mempalace mine "$(dirname "$MEMPAL_TRANSCRIPT")" --mode convos \
|
||||
>> "$MEMPAL_CURSOR_LOG" 2>&1 || \
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"WARN: mempalace mine convos returned non-zero"
|
||||
elif [ -n "$MEMPAL_TRANSCRIPT" ]; then
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"skipping invalid transcript path: $MEMPAL_TRANSCRIPT"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$MEMPAL_CURSOR_LOG" 2>&1 || \
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"WARN: mempalace mine projects returned non-zero"
|
||||
fi
|
||||
else
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"mempalace CLI not on PATH; skipping synchronous mine"
|
||||
fi
|
||||
|
||||
# ── Drop the pending-save marker ──────────────────────────────────
|
||||
mempal_set_pending "$MEMPAL_CONV_ID" || \
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"WARN: could not write pending-save marker"
|
||||
|
||||
# Surface a short user-visible note that compaction is about to
|
||||
# happen and we've already captured the verbatim text. user_message
|
||||
# is the only output field Cursor's preCompact accepts.
|
||||
"$MEMPAL_PYTHON_BIN" -c '
|
||||
import json
|
||||
print(json.dumps({
|
||||
"user_message": (
|
||||
"MemPalace: transcript snapshotted before compaction. "
|
||||
"A diary nudge is queued for the next agent turn."
|
||||
)
|
||||
}))
|
||||
'
|
||||
Executable
+267
@@ -0,0 +1,267 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE CURSOR SAVE HOOK — Auto-save every N stop events
|
||||
#
|
||||
# Cursor "stop" hook. After every agent loop ends, this hook:
|
||||
# 1. Counts stop invocations per conversation_id (each stop ≈ one
|
||||
# assistant turn ≈ roughly one user message — see plan rationale).
|
||||
# 2. Every SAVE_INTERVAL stops, returns a followup_message telling
|
||||
# the agent to file the session into MemPalace and write a diary
|
||||
# entry. Cursor auto-submits that as the next user message.
|
||||
# 3. On the next stop, loop_count > 0 so we let the agent finish
|
||||
# without re-firing — Cursor's loop_count is the equivalent of
|
||||
# Claude Code's stop_hook_active flag.
|
||||
# 4. If the preCompact hook has left a `.pending` marker, force a
|
||||
# save followup regardless of the counter and clear the marker.
|
||||
#
|
||||
# === WHY THE FOLLOWUP FIRES BY DEFAULT (differs from the Claude hook) ===
|
||||
#
|
||||
# The Claude Code hook (hooks/mempal_save_hook.sh) is SILENT by default:
|
||||
# its background `mempalace mine --mode convos` captures the verbatim
|
||||
# transcript on its own, and the LLM-driven diary nudge is opt-IN behind
|
||||
# MEMPAL_VERBOSE. That works because mempalace/normalize.py has a Claude
|
||||
# Code JSONL parser.
|
||||
#
|
||||
# Cursor is different. Cursor's transcript format is undocumented (see
|
||||
# STDIN_SHAPE.md) and normalize.py has NO Cursor parser, so the
|
||||
# background mine below is BEST-EFFORT only — it does not yet yield clean
|
||||
# verbatim drawers for Cursor. The followup_message is therefore the
|
||||
# load-bearing verbatim-capture path: it drives the agent to call
|
||||
# mempalace_add_drawer / mempalace_diary_write from its in-context memory.
|
||||
# That is why it is ON by default here — silencing it by default would
|
||||
# leave a default Cursor install capturing nothing.
|
||||
#
|
||||
# Users who want the Claude-style "zero tokens in the chat window"
|
||||
# behaviour can silence the followup (see MEMPAL_CURSOR_SILENT /
|
||||
# MEMPAL_VERBOSE below). Once normalize.py learns to read Cursor
|
||||
# transcripts, this default should flip to silent to match Claude.
|
||||
#
|
||||
# Companion files in this directory:
|
||||
# * lib/common.sh — shared helpers (sourced)
|
||||
# * mempal_precompact_hook_cursor.sh — preCompact event
|
||||
# * mempal_wake_hook_cursor.sh — sessionStart event
|
||||
#
|
||||
# === INSTALL ===
|
||||
#
|
||||
# Recommended path: run `hooks/cursor/install.sh` from a cloned repo,
|
||||
# which copies the scripts to ~/.mempalace/hooks/cursor/ and merges
|
||||
# the wiring into your ~/.cursor/hooks.json. See hooks/cursor/README.md
|
||||
# for the full walkthrough, or website/guide/cursor-hooks.md for the
|
||||
# rendered version.
|
||||
#
|
||||
# Manual wiring (user scope: ~/.cursor/hooks.json):
|
||||
#
|
||||
# {
|
||||
# "version": 1,
|
||||
# "hooks": {
|
||||
# "stop": [
|
||||
# {
|
||||
# "command": "/absolute/path/to/mempal_save_hook_cursor.sh",
|
||||
# "loop_limit": 1
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# The `loop_limit: 1` cap is defense-in-depth — even if our own
|
||||
# loop_count check below regresses, Cursor itself will stop emitting
|
||||
# our followup after one auto-iteration.
|
||||
#
|
||||
# === KILL SWITCHES ===
|
||||
#
|
||||
# MEMPAL_DISABLE_HOOK=1 — Cursor-prompt addition
|
||||
# MEMPALACE_HOOKS_AUTO_SAVE=false — matches the Claude Code hooks
|
||||
# ~/.mempalace/config.json "hooks.auto_save": false
|
||||
#
|
||||
# Any one of these short-circuits the hook to `{}` and exits 0.
|
||||
|
||||
# Resolve the directory this script lives in so we can source the
|
||||
# sibling lib/common.sh whether the user invoked us by absolute path,
|
||||
# by relative path, or via a symlink.
|
||||
_mempal_self="${BASH_SOURCE[0]:-$0}"
|
||||
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
. "$_mempal_dir/lib/common.sh"
|
||||
|
||||
SAVE_INTERVAL="${MEMPAL_SAVE_INTERVAL:-15}"
|
||||
# Coerce empty, non-numeric, AND zero to the default. SAVE_INTERVAL=0
|
||||
# would otherwise crash bash on the modulo check below ($((NEXT % 0))
|
||||
# is "division by 0"). gh-PR review caught this edge case.
|
||||
case "$SAVE_INTERVAL" in
|
||||
''|*[!0-9]*|0) SAVE_INTERVAL=15 ;;
|
||||
esac
|
||||
|
||||
# Optional additional project directory to mine on save (parity with
|
||||
# the Claude Code hook's MEMPAL_DIR knob — purely additive, never an
|
||||
# override for the transcript mine).
|
||||
MEMPAL_DIR="${MEMPAL_DIR:-}"
|
||||
|
||||
# ── Followup opt-out ──────────────────────────────────────────────
|
||||
#
|
||||
# Returns 0 (true) when the user has asked to suppress the
|
||||
# followup_message. See the header comment for why the followup is ON
|
||||
# by default for Cursor. When silenced, the hook still runs the
|
||||
# best-effort background mine and still maintains its counters/markers
|
||||
# — it just emits `{}` instead of a followup. Two equivalent signals:
|
||||
# * MEMPAL_CURSOR_SILENT=1|true|yes|on — dedicated Cursor opt-out
|
||||
# * MEMPAL_VERBOSE=false|0|no|off — cross-hook silence signal
|
||||
# (mirror-image of the Claude hook, where MEMPAL_VERBOSE=true is
|
||||
# what turns its diary nudge ON)
|
||||
mempal_followup_silenced() {
|
||||
case "${MEMPAL_CURSOR_SILENT:-}" in
|
||||
1|true|yes|on) return 0 ;;
|
||||
esac
|
||||
case "${MEMPAL_VERBOSE:-}" in
|
||||
false|0|no|off) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Kill switch — emit `{}` so Cursor proceeds with normal stop.
|
||||
if mempal_is_disabled; then
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Opportunistic, daily-throttled GC of stale per-conversation state.
|
||||
# Placed after the kill switch so a disabled hook touches nothing.
|
||||
mempal_gc_stale_state
|
||||
|
||||
INPUT="$(cat)"
|
||||
mempal_parse_stdin "$INPUT"
|
||||
|
||||
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
|
||||
mempal_dump_bad_input "$INPUT" "stop"
|
||||
# Fail-open: don't block the host on a parse error.
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"loop_count=$MEMPAL_LOOP_COUNT status=${MEMPAL_STATUS:-?} workspace=$MEMPAL_WORKSPACE"
|
||||
|
||||
# ── Loop-prevention ────────────────────────────────────────────────
|
||||
#
|
||||
# Cursor's loop_count indicates how many times THIS stop hook has
|
||||
# already triggered an automatic followup for this conversation
|
||||
# (starts at 0). If it is > 0, our own previous followup is currently
|
||||
# being consumed by the agent — let it finish without re-firing.
|
||||
if [ "$MEMPAL_LOOP_COUNT" -gt 0 ] 2>/dev/null; then
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" "loop_count>0; letting agent stop"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WING="$(mempal_infer_wing "$MEMPAL_WORKSPACE")"
|
||||
|
||||
# Build the followup message once; both the pending-marker branch and
|
||||
# the threshold branch use it. Constructed via Python -c (rather than
|
||||
# a heredoc) so we can pass the inferred wing as argv[1] and so the
|
||||
# JSON encoding is correct even for wings whose name would otherwise
|
||||
# need shell quoting.
|
||||
_mempal_build_followup() {
|
||||
"$MEMPAL_PYTHON_BIN" -c '
|
||||
import json, sys
|
||||
wing = sys.argv[1] if len(sys.argv) > 1 else "cursor_session"
|
||||
msg = (
|
||||
"MemPalace save checkpoint. Call mempalace_checkpoint ONCE with: "
|
||||
"items=[{wing: " + wing + ", room: <short topic>, content: <verbatim "
|
||||
"quote>}, ...] for the key topics, decisions, and verbatim quotes from "
|
||||
"this session; and diary={agent_name: cursor-ide, wing: " + wing + ", "
|
||||
"entry: <AAAK-format summary>}. It dedups, files non-duplicates, and "
|
||||
"writes the diary in one call. Then stop."
|
||||
)
|
||||
print(json.dumps({"followup_message": msg}))
|
||||
' "$WING"
|
||||
}
|
||||
|
||||
# ── Pending-save marker from preCompact ───────────────────────────
|
||||
#
|
||||
# preCompact cannot itself emit a followup_message (Cursor docs:
|
||||
# preCompact is observational-only, output supports only user_message),
|
||||
# so it drops a marker file and we consume it here. Forces a save
|
||||
# nudge regardless of the counter.
|
||||
if mempal_consume_pending "$MEMPAL_CONV_ID"; then
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"consumed pending-save marker (post-compaction)"
|
||||
if mempal_followup_silenced; then
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"followup silenced (MEMPAL_CURSOR_SILENT/MEMPAL_VERBOSE); emitting {}"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
_mempal_build_followup
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Normal counter path ───────────────────────────────────────────
|
||||
COUNTER_FILE="$(_mempal_counter_path "$MEMPAL_CONV_ID")"
|
||||
CURRENT="$(mempal_read_counter "$COUNTER_FILE")"
|
||||
NEXT=$((CURRENT + 1))
|
||||
mempal_write_counter_atomic "$COUNTER_FILE" "$NEXT" || {
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"WARN: counter write failed for $COUNTER_FILE; passing through"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"counter $CURRENT -> $NEXT (interval=$SAVE_INTERVAL)"
|
||||
|
||||
# Trigger when we hit a multiple of SAVE_INTERVAL. Modulo arithmetic
|
||||
# keeps the counter monotonically growing (no reset) so the log file
|
||||
# is greppable for total turns across a conversation.
|
||||
if [ "$((NEXT % SAVE_INTERVAL))" -ne 0 ]; then
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" "TRIGGERING SAVE at counter=$NEXT"
|
||||
|
||||
# ── Background mine (best effort) ─────────────────────────────────
|
||||
#
|
||||
# Two independent targets — both run if both are set:
|
||||
# 1. transcript_path → its parent directory, --mode convos
|
||||
# 2. MEMPAL_DIR (user-configured project) → --mode projects
|
||||
#
|
||||
# IMPORTANT (Cursor caveat): the --mode convos mine is BEST-EFFORT for
|
||||
# Cursor. Cursor's transcript format is undocumented and
|
||||
# mempalace/normalize.py has no Cursor parser, so this call does not
|
||||
# yet produce clean verbatim conversation drawers — at best it ingests
|
||||
# raw bytes. The verbatim-capture guarantee for Cursor is carried by
|
||||
# the followup_message below, which drives the agent to file its own
|
||||
# in-context verbatim quotes. The --mode projects target (MEMPAL_DIR)
|
||||
# is unaffected — normalize.py reads ordinary project files fine.
|
||||
#
|
||||
# Both run with stdout/stderr appended to the cursor log and are
|
||||
# backgrounded so a slow mine cannot push the hook past its
|
||||
# Cursor-configured timeout. `command -v mempalace` gates so a user
|
||||
# without the CLI on PATH (e.g. a fresh GUI-launched session) does
|
||||
# not see a noisy error.
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
if mempal_is_valid_transcript "$MEMPAL_TRANSCRIPT" \
|
||||
&& [ -f "$MEMPAL_TRANSCRIPT" ]; then
|
||||
( mempalace mine "$(dirname "$MEMPAL_TRANSCRIPT")" --mode convos \
|
||||
>> "$MEMPAL_CURSOR_LOG" 2>&1 ) &
|
||||
elif [ -n "$MEMPAL_TRANSCRIPT" ]; then
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"skipping invalid transcript path: $MEMPAL_TRANSCRIPT"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
( mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$MEMPAL_CURSOR_LOG" 2>&1 ) &
|
||||
fi
|
||||
else
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"mempalace CLI not on PATH; skipping background mine"
|
||||
fi
|
||||
|
||||
# The followup is the load-bearing verbatim path for Cursor (see header),
|
||||
# so it fires by default. Honour the opt-out for users who want silence.
|
||||
if mempal_followup_silenced; then
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"followup silenced (MEMPAL_CURSOR_SILENT/MEMPAL_VERBOSE); background mine only"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
_mempal_build_followup
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE CURSOR WAKE HOOK — Session-start memory recall
|
||||
#
|
||||
# Cursor "sessionStart" hook. This is a Cursor-only capability —
|
||||
# Claude Code's third-party-hooks compatibility layer does not have
|
||||
# an equivalent event with the same "inject context into the agent's
|
||||
# initial system message" semantics.
|
||||
#
|
||||
# Behaviour:
|
||||
# 1. Parse Cursor's sessionStart payload (conversation_id /
|
||||
# session_id, is_background_agent, composer_mode, plus the
|
||||
# common workspace_roots field).
|
||||
# 2. Infer the wing from basename(workspace_roots[0]).
|
||||
# 3. Return {"additional_context": "..."} instructing the agent to
|
||||
# scope its memory recall by calling mempalace_search +
|
||||
# mempalace_diary_read with wing=<inferred>.
|
||||
#
|
||||
# sessionStart is documented as fire-and-forget — Cursor does not
|
||||
# enforce a blocking response and does not consume "continue" /
|
||||
# "user_message" — but "additional_context" is honoured and added to
|
||||
# the conversation's initial system context. Verified in
|
||||
# cursor.com/docs/hooks.md (fetched 2026-05-27).
|
||||
#
|
||||
# === INSTALL ===
|
||||
#
|
||||
# Add to ~/.cursor/hooks.json (or .cursor/hooks.json for project
|
||||
# scope) under "sessionStart":
|
||||
#
|
||||
# {
|
||||
# "version": 1,
|
||||
# "hooks": {
|
||||
# "sessionStart": [
|
||||
# { "command": "/absolute/path/to/mempal_wake_hook_cursor.sh" }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
|
||||
_mempal_self="${BASH_SOURCE[0]:-$0}"
|
||||
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
. "$_mempal_dir/lib/common.sh"
|
||||
|
||||
if mempal_is_disabled; then
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
INPUT="$(cat)"
|
||||
mempal_parse_stdin "$INPUT"
|
||||
|
||||
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
|
||||
mempal_dump_bad_input "$INPUT" "sessionStart"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WING="$(mempal_infer_wing "$MEMPAL_WORKSPACE")"
|
||||
|
||||
mempal_log "sessionStart" "$MEMPAL_CONV_ID" \
|
||||
"workspace=$MEMPAL_WORKSPACE wing=$WING"
|
||||
|
||||
# Emit the additional_context payload via Python -c (rather than a
|
||||
# heredoc) so the JSON encoding survives wings whose name contains
|
||||
# characters that would otherwise need shell escaping, and so the
|
||||
# inferred wing arrives as an argv positional. The MCP tool names
|
||||
# referenced here are verified against mempalace/mcp_server.py:
|
||||
# mempalace_search and mempalace_diary_read both exist and accept
|
||||
# the wing parameter.
|
||||
"$MEMPAL_PYTHON_BIN" -c '
|
||||
import json, sys
|
||||
wing = sys.argv[1] if len(sys.argv) > 1 else "cursor_session"
|
||||
ctx = (
|
||||
"MemPalace wake-up. The Cursor workspace maps to wing=" + wing + ". "
|
||||
"Before answering anything that touches past work in this "
|
||||
"project, call mempalace_search (wing=" + wing + ", "
|
||||
"query=<relevant keywords>) and mempalace_diary_read "
|
||||
"(agent_name=cursor-ide, wing=" + wing + ", last_n=10). "
|
||||
"Use what you find verbatim where it answers the question; "
|
||||
"never summarise the user'"'"'s own words."
|
||||
)
|
||||
print(json.dumps({"additional_context": ctx}))
|
||||
' "$WING"
|
||||
@@ -119,16 +119,8 @@ INPUT=$(cat)
|
||||
# backslashes are not mangled by echo flag parsing.
|
||||
_mempal_parsed=$(
|
||||
umask 077
|
||||
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
|
||||
import sys, json, re
|
||||
data = json.load(sys.stdin)
|
||||
sid = data.get('session_id', '')
|
||||
tp = data.get('transcript_path', '')
|
||||
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
|
||||
print('__MEMPAL_PARSE_OK__')
|
||||
print(safe(sid))
|
||||
print(safe(tp))
|
||||
" 2>"$STATE_DIR/last_python_err.log"
|
||||
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell parse-precompact \
|
||||
2>"$STATE_DIR/last_python_err.log"
|
||||
)
|
||||
# Drop the empty file on success; chmod 600 on failure to mirror
|
||||
# last_input.log's privacy contract.
|
||||
@@ -190,14 +182,14 @@ echo "[$(date '+%H:%M:%S')] PRE-COMPACT triggered for session $SESSION_ID" >> "$
|
||||
# 1. TRANSCRIPT_PATH (from Claude Code) → parent dir, --mode convos
|
||||
# 2. MEMPAL_DIR → --mode projects
|
||||
if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
||||
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
||||
>> "$STATE_DIR/hook.log" 2>&1
|
||||
elif [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" \
|
||||
echo "[$(date '+%H:%M:%S')] Skipping missing or invalid transcript path after normalization: $TRANSCRIPT_PATH" \
|
||||
>> "$STATE_DIR/hook.log"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$STATE_DIR/hook.log" 2>&1
|
||||
fi
|
||||
|
||||
|
||||
@@ -149,21 +149,8 @@ INPUT=$(cat)
|
||||
# ``printf '%s'`` removes the class of bug entirely.
|
||||
_mempal_parsed=$(
|
||||
umask 077
|
||||
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
|
||||
import sys, json, re
|
||||
data = json.load(sys.stdin)
|
||||
sid = data.get('session_id', '')
|
||||
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('__MEMPAL_PARSE_OK__')
|
||||
print(safe(sid))
|
||||
print(sha)
|
||||
print(safe(tp))
|
||||
" 2>"$STATE_DIR/last_python_err.log"
|
||||
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell parse-stop \
|
||||
2>"$STATE_DIR/last_python_err.log"
|
||||
)
|
||||
# The 2> redirect creates the file even when stderr is empty (success).
|
||||
# Remove the empty file so the state directory stays clean on the happy
|
||||
@@ -239,24 +226,10 @@ 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=$("$MEMPAL_PYTHON_BIN" - "$TRANSCRIPT_PATH" <<'PYEOF'
|
||||
import json, sys
|
||||
count = 0
|
||||
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', '')
|
||||
if isinstance(content, str) and '<command-message>' in content:
|
||||
continue
|
||||
count += 1
|
||||
except:
|
||||
pass
|
||||
print(count)
|
||||
PYEOF
|
||||
2>/dev/null)
|
||||
EXCHANGE_COUNT=$("$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell count-human-messages "$TRANSCRIPT_PATH" 2>/dev/null)
|
||||
elif [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
echo "[$(date '+%H:%M:%S')] WARN: transcript_path not found after normalization: $TRANSCRIPT_PATH" >> "$STATE_DIR/hook.log"
|
||||
EXCHANGE_COUNT=0
|
||||
else
|
||||
EXCHANGE_COUNT=0
|
||||
fi
|
||||
@@ -292,14 +265,14 @@ if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then
|
||||
# MEMPAL_DIR is *additive*, not an override: a user with MEMPAL_DIR
|
||||
# pointed at their project still gets the active conversation mined.
|
||||
if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
||||
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
||||
>> "$STATE_DIR/hook.log" 2>&1 &
|
||||
elif [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" \
|
||||
>> "$STATE_DIR/hook.log"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$STATE_DIR/hook.log" 2>&1 &
|
||||
fi
|
||||
|
||||
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# MemPalace SessionEnd Hook — final save on clean exit.
|
||||
#
|
||||
# Claude Code documents a default SessionEnd hook timeout of 1.5s; a per-hook
|
||||
# "timeout" in settings.local.json can raise it (up to 60s), but a
|
||||
# plugin-provided timeout cannot (https://code.claude.com/docs/en/hooks). A cold
|
||||
# `mempalace` start alone can exceed 1.5s, so we background the hook and return
|
||||
# immediately; the detached child finishes the save after the session has
|
||||
# exited. All logic lives in mempalace.hooks_cli for cross-harness extensibility.
|
||||
run_mempalace_hook() {
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
exec mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
MEMPAL_PYTHON_BIN="${MEMPAL_PYTHON:-}"
|
||||
if [ -z "$MEMPAL_PYTHON_BIN" ] || [ ! -x "$MEMPAL_PYTHON_BIN" ]; then
|
||||
MEMPAL_PYTHON_BIN="$(command -v python3 2>/dev/null || echo python3)"
|
||||
fi
|
||||
if "$MEMPAL_PYTHON_BIN" -c "import mempalace" >/dev/null 2>&1; then
|
||||
exec "$MEMPAL_PYTHON_BIN" -m mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
|
||||
exec python -m mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Capture stdin (the SessionEnd JSON) before backgrounding — the parent's
|
||||
# stdin is gone once we return. Forward it to the detached worker, which runs
|
||||
# the final mine on its own time and outlives this process.
|
||||
payload="$(cat)"
|
||||
(
|
||||
printf '%s' "$payload" | run_mempalace_hook --hook session-end --harness "${MEMPALACE_HOOK_HARNESS:-claude-code}"
|
||||
) >/dev/null 2>&1 </dev/null &
|
||||
disown 2>/dev/null || true
|
||||
|
||||
# Return immediately so the harness never blocks on session exit.
|
||||
printf '{}'
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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
|
||||
version: 3.7.0
|
||||
homepage: https://github.com/MemPalace/mempalace
|
||||
user-invocable: true
|
||||
metadata:
|
||||
@@ -46,6 +46,10 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
||||
|
||||
## Available Tools
|
||||
|
||||
Full MCP surface: 36 tools. Destructive or host-level tools are documented so
|
||||
you know they exist, but use them only when the user explicitly asks or when a
|
||||
tool-specific workflow below says to.
|
||||
|
||||
### 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.
|
||||
@@ -58,6 +62,14 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
||||
- `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_list_drawers` — Paginated drawer listing
|
||||
- `wing`, `room`: optional filters
|
||||
- `since`: only drawers filed on/after this ISO date/time
|
||||
- `before`: only drawers filed before this ISO date/time
|
||||
- `limit`: max results (default 20)
|
||||
- `offset`: pagination offset (default 0)
|
||||
- `mempalace_get_drawer` — Fetch a single drawer by ID. Returns full verbatim content and metadata.
|
||||
- `drawer_id` (required)
|
||||
- `mempalace_get_taxonomy` — Full wing/room/count tree
|
||||
- `mempalace_get_aaak_spec` — Get AAAK compression dialect specification
|
||||
|
||||
@@ -81,17 +93,59 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
||||
- `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_find_tunnels` — Find rooms that bridge two wings via *implicit* overlap (rooms whose drawers naturally share content across wings — discovered, not declared)
|
||||
- `wing_a`, `wing_b`: optional filters; omit both to scan all wing pairs
|
||||
- `mempalace_create_tunnel` — Create an *explicit* cross-wing tunnel: a user/agent-declared link between two locations. Use when you notice content in one project relates to another (e.g. API design in `project_api` connects to schema in `project_database`).
|
||||
- `source_wing`, `source_room`, `target_wing`, `target_room` (required)
|
||||
- `label`: short description of the relationship
|
||||
- `source_drawer_id`, `target_drawer_id`: anchor to specific drawers
|
||||
- `mempalace_list_tunnels` — List all explicit tunnels, optionally filtered by wing
|
||||
- `wing`: optional filter
|
||||
- `mempalace_delete_tunnel` — Remove an explicit tunnel by ID
|
||||
- `tunnel_id` (required)
|
||||
- `mempalace_list_hallways` — List within-wing entity hallways (entity-to-entity co-occurrence links built at mine time)
|
||||
- `wing`: optional filter
|
||||
- `mempalace_delete_hallway` — Remove a hallway record by ID
|
||||
- `hallway_id` (required)
|
||||
- `mempalace_follow_tunnels` — From a room, follow explicit tunnels to connected drawers in other wings
|
||||
- `wing`, `room` (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
|
||||
- `added_by`: optional filing agent label
|
||||
- Checks for duplicates automatically
|
||||
- `mempalace_checkpoint` — Save a whole session in one call: dedup each item, file non-duplicates, then write one diary entry
|
||||
- `items` (required): array of `{wing, room, content}`; content must be verbatim
|
||||
- `diary`: optional `{agent_name, entry, topic?, wing?}`; entry should use AAAK format
|
||||
- `dedup_threshold`: similarity threshold (default 0.9)
|
||||
- `added_by`: optional filing agent label (defaults to the diary `agent_name`, else `checkpoint`)
|
||||
- `mempalace_update_drawer` — Update an existing drawer's content and/or move it to a different wing/room
|
||||
- `drawer_id` (required)
|
||||
- `content`, `wing`, `room`: at least one must be provided (no-op otherwise)
|
||||
- `mempalace_delete_drawer` — Remove a drawer by ID
|
||||
- `drawer_id` (required)
|
||||
|
||||
### Ingest & Cleanup
|
||||
- `mempalace_mine` — Mine a directory into the palace. Host-level ingest; call only when the user asks to import files.
|
||||
- `source` (required): directory to mine
|
||||
- `mode`: `projects` (default), `convos`, or `extract`
|
||||
- `wing`: target wing (default: source directory name)
|
||||
- `agent`: recorded on every drawer (default `mempalace`)
|
||||
- `limit`: max files to process (0 = all)
|
||||
- `dry_run`: preview without writing
|
||||
- `extract`: convos extraction strategy (`exchange` default, or `general`)
|
||||
- `mempalace_sync` — Prune drawers whose source files are gitignored, deleted, or moved. Use dry-run first.
|
||||
- `project_dir`: optional project root scope
|
||||
- `wing`: optional wing scope
|
||||
- `apply`: actually delete; default is dry-run preview
|
||||
- `mempalace_delete_by_source` — Bulk-delete drawers with one exact `source_file`. Destructive; use dry-run first.
|
||||
- `source_file` (required): exact metadata value to remove
|
||||
- `dry_run`: preview match count and sample (default true)
|
||||
|
||||
### Diary & Session
|
||||
- `mempalace_diary_write` — Write a session diary entry
|
||||
- `agent_name` (required): your name/identifier
|
||||
- `entry` (required): what happened, what you learned, what matters
|
||||
@@ -99,6 +153,15 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
||||
- `mempalace_diary_read` — Read recent diary entries
|
||||
- `agent_name` (required)
|
||||
- `last_n`: number of entries (default 10)
|
||||
- `mempalace_memories_filed_away` — Acknowledge the latest silent auto-save checkpoint.
|
||||
- Returns: how many messages were tucked into drawers since the last ack
|
||||
- When to call: at the START of a session, to confirm prior-conversation persistence
|
||||
|
||||
### System
|
||||
- `mempalace_hook_settings` — Get or set auto-save hook behavior. Host-level setting; do not change silently.
|
||||
- `silent_save`: true saves directly without MCP-level clutter
|
||||
- `desktop_toast`: true shows a desktop notification when saves complete
|
||||
- `mempalace_reconnect` — Force reconnect to the palace database after external writes or stale index state
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# MemPalace Shared-Brain Coordination Protocol
|
||||
|
||||
The canonical protocol for agents sharing one MemPalace hub — memory
|
||||
discipline plus the logstream coordination layer (RFC 003). Like
|
||||
`recall-protocol.md`, this file is the single source of truth: skills,
|
||||
rules, and system prompts should link here or copy the System-Prompt
|
||||
Snippet below verbatim, so the protocol never drifts per-agent.
|
||||
|
||||
## The two layers
|
||||
|
||||
A shared palace gives every agent two distinct channels. Do not mix them:
|
||||
|
||||
- **Memory (drawers, KG, diary)** — durable knowledge worth recalling
|
||||
later. Searched semantically. Follow
|
||||
[`recall-protocol.md`](recall-protocol.md).
|
||||
- **Coordination (logstream events + artifacts)** — active work moving
|
||||
between agents *right now*: delegations, replies, patches, acks.
|
||||
Filtered structurally, never searched semantically.
|
||||
|
||||
Rule of thumb: if another agent should **act** on it, it is an event.
|
||||
If a future session should **know** it, it is a drawer. A concluded
|
||||
delegation usually produces both: the events carried the work; a drawer
|
||||
records the outcome.
|
||||
|
||||
## Identity
|
||||
|
||||
Every agent uses one stable `from_agent` identity, formatted
|
||||
`<machine>-<harness>` (e.g. `mac-claude`, `windows-codex`,
|
||||
`aero-opencode`). Never impersonate another agent; never rotate names —
|
||||
the event trail is only auditable if identities are stable.
|
||||
|
||||
## Delegating work (requester)
|
||||
|
||||
1. Generate a `correlation_id` for the task: `task_<short-description>`
|
||||
plus enough entropy to be unique (e.g. `task_fix_ranking_7f3a`).
|
||||
2. `mempalace_event_append` with `type=task.request`, `stream=project/<name>`,
|
||||
`room=delegation`, `to_agent=<worker>`, `status=open`, and a body that
|
||||
states the goal, the branch, the base commit, and the definition of done.
|
||||
3. Wait for the reply: `mempalace_event_wait` with the `correlation_id`
|
||||
and `to_agent=<you>`. Waits cap at 5 minutes — loop, passing
|
||||
`since_event_id` of the last event you saw.
|
||||
4. When a `patch.ready` arrives: `mempalace_artifact_get`, verify the
|
||||
`sha256`, apply locally, run the stated verification.
|
||||
5. Always close the loop with `mempalace_event_ack` — `status=applied`
|
||||
on success, `status=failed` with verbatim evidence on failure.
|
||||
|
||||
## Receiving work (worker)
|
||||
|
||||
1. Poll or wait for `type=task.request`, `to_agent=<you>` (plus `*`
|
||||
broadcasts are matched automatically).
|
||||
2. Claim it: `mempalace_event_ack` with `status=claimed` so no other
|
||||
agent duplicates the work.
|
||||
3. Do the work on the stated branch/commit.
|
||||
4. Deliver through the formal channel — `mempalace_patch_submit` with the
|
||||
diff, `correlation_id`, `branch`, and `base_commit`. **Pushing a
|
||||
branch is not a handoff**; the event is. If you also pushed, say so
|
||||
in the body.
|
||||
5. If blocked or unable to produce a patch, still reply:
|
||||
`type=task.reply` with `status=blocked` or `failed` and verbatim
|
||||
notes. Silence is the only unrecoverable failure.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Never apply a patch silently.** Fetching an artifact is free;
|
||||
applying it is an explicit local decision, stated to the user.
|
||||
- **Verify hashes.** An artifact's `sha256` must match its content
|
||||
before you act on it.
|
||||
- **Append-only.** Never try to edit or delete events; supersede with a
|
||||
new event (`status=superseded`) referencing the old one.
|
||||
- **Exact payloads.** Bodies and artifacts are verbatim — no summaries
|
||||
of diffs, no truncated logs. If it is too big, store it as an
|
||||
artifact and reference it.
|
||||
- **Close every loop.** Every `task.request` you claimed ends in an
|
||||
`applied`, `failed`, or `blocked` — no dangling `open` tasks.
|
||||
- **File the outcome.** When a delegation concludes, write one drawer
|
||||
(`mempalace_add_drawer`) recording what was decided/learned, so the
|
||||
result is searchable without replaying the event trail.
|
||||
|
||||
## System-Prompt Snippet
|
||||
|
||||
Copy this block into an agent's system prompt / custom instructions.
|
||||
Replace `<AGENT_ID>` with the agent's stable identity.
|
||||
|
||||
```text
|
||||
## MemPalace shared brain
|
||||
|
||||
You share a MemPalace hub with other agents. Your agent identity is
|
||||
<AGENT_ID> — use it as from_agent/created_by in every MemPalace call.
|
||||
|
||||
Memory (recall + writing):
|
||||
- Before answering about past work, decisions, people, or projects,
|
||||
search the palace (mempalace_search; mempalace_kg_query for
|
||||
relational/temporal facts). Quote results verbatim — never paraphrase
|
||||
stored content. If the palace has nothing, say so; don't guess.
|
||||
- File durable outcomes (decisions, conclusions, learned facts) with
|
||||
mempalace_add_drawer. When a fact changes: mempalace_kg_invalidate the
|
||||
old fact, then mempalace_kg_add the new one. Don't file secrets or
|
||||
tokens.
|
||||
|
||||
Coordination (logstream):
|
||||
- Check your inbox when starting work and before long tasks:
|
||||
mempalace_event_list with to_agent=<AGENT_ID> (new since your last
|
||||
seen event id).
|
||||
- To delegate: mempalace_event_append (type=task.request, stream=
|
||||
project/<name>, room=delegation, correlation_id=task_..., status=open,
|
||||
body = goal + branch + base commit + definition of done), then
|
||||
mempalace_event_wait on that correlation_id for the reply.
|
||||
- When you accept a task: ack it with status=claimed. Deliver code as a
|
||||
patch via mempalace_patch_submit (never just push a branch and go
|
||||
silent). If blocked, reply with status=blocked and verbatim notes.
|
||||
- When you receive a patch: mempalace_artifact_get, verify sha256,
|
||||
apply only with explicit user-visible intent, run the stated tests,
|
||||
then mempalace_event_ack with status=applied or failed.
|
||||
- Events are append-only and verbatim. Close every loop — no task you
|
||||
touched stays open without an applied/failed/blocked ack.
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`recall-protocol.md`](recall-protocol.md) — the search-before-answer
|
||||
memory protocol this composes with.
|
||||
- [Agent Logstream concepts](../../website/concepts/agent-logstream.md) —
|
||||
event/artifact model and the full tool reference.
|
||||
- RFC 003 (`docs/rfcs/003-agent-logstream-coordination.md`) — design
|
||||
rationale and storage model.
|
||||
@@ -0,0 +1,125 @@
|
||||
# MemPalace Recall Protocol
|
||||
|
||||
The canonical "search before answering" protocol shared across every
|
||||
MemPalace integration (Cursor, Antigravity, Claude Code, Codex,
|
||||
OpenClaw). This file is the single source of truth — skills and rules
|
||||
should link here rather than restating the protocol, so the rule never
|
||||
drifts from the skill.
|
||||
|
||||
The protocol exists to honour MemPalace's foundational promise:
|
||||
**100% recall, verbatim, never guess.** When the palace might hold the
|
||||
answer, the agent must read the palace before answering from model
|
||||
memory.
|
||||
|
||||
## When to recall
|
||||
|
||||
Search the palace **before answering** whenever the user asks about
|
||||
anything that may already be filed:
|
||||
|
||||
- Past work, prior decisions, or "what did we do / decide / try?"
|
||||
- A person, project, or entity ("who is …", "what is …")
|
||||
- Something that happened in an earlier session ("remember when …",
|
||||
"last time …", "the thing we discussed")
|
||||
- A preference, fact, or relationship that could have changed over time
|
||||
|
||||
If the question is pure greenfield work with no memory relevance (e.g.
|
||||
"rename this variable", "fix this typo"), do not search — recall is
|
||||
question-driven, not reflexive.
|
||||
|
||||
## The protocol
|
||||
|
||||
1. **On wake-up** (if a session-start hook injected context, honour its wing scoping / `additional_context`): scope recall to the wing inferred from the workspace, then continue.
|
||||
2. **Before responding** about people, projects, past events, or prior
|
||||
decisions: call `mempalace_search` first. For relational or temporal
|
||||
facts ("who reported to whom in March", "what was true then"), call
|
||||
`mempalace_kg_query` instead or as well.
|
||||
3. **If unsure** about a fact (name, age, relationship, preference): say
|
||||
"let me check the palace" and query. Wrong is worse than slow.
|
||||
4. **Return verbatim.** Quote the drawer's exact stored words. Never
|
||||
summarize, paraphrase, or lossy-compress what the palace returns —
|
||||
that is the whole point of the system.
|
||||
5. **After a substantive session**, record continuity with
|
||||
`mempalace_diary_write` (background hooks may already do this — do not
|
||||
double-file).
|
||||
6. **When a fact changes**, call `mempalace_kg_invalidate` on the old
|
||||
fact, then `mempalace_kg_add` for the new one.
|
||||
|
||||
## Tool selection
|
||||
|
||||
| You need | Tool |
|
||||
|---|---|
|
||||
| Find any memory by meaning | `mempalace_search` (start here) |
|
||||
| Relational / time-bound facts about an entity | `mempalace_kg_query` |
|
||||
| The chronological story of an entity | `mempalace_kg_timeline` |
|
||||
| Recent session continuity | `mempalace_diary_read` |
|
||||
| Which wings / rooms exist (when scope unknown) | `mempalace_list_wings`, `mempalace_list_rooms` |
|
||||
| Record this session | `mempalace_diary_write` |
|
||||
|
||||
`mempalace_search` takes a short natural-language `query` (keywords or a
|
||||
question — not a system prompt or pasted conversation) plus optional
|
||||
`wing` / `room` filters and `limit` (default 5).
|
||||
|
||||
## Unhappy paths
|
||||
|
||||
- **Empty results.** Say the palace has nothing on this; do not invent an
|
||||
answer to fill the gap. Offer to widen the search (drop the wing
|
||||
filter) or to file the new information.
|
||||
- **MCP unavailable / tool error.** Surface the error plainly and suggest
|
||||
the user verify the server (`mempalace status`, or re-run install).
|
||||
Do not silently fall back to guessing from model memory.
|
||||
- **Palace index corrupt / compactor error.** When the server returns an
|
||||
error mentioning the HNSW segment writer, a ChromaDB compaction
|
||||
failure, or a stuck "Not connected" state after a write, the on-disk
|
||||
vector index is out of sync with `chroma.sqlite3` — but the drawer rows
|
||||
are intact in SQLite. Recover by rebuilding the index from SQLite, not
|
||||
by re-mining. See "Recovering a corrupt index" below. Do not attempt an
|
||||
in-process repair from the agent; guide the user to run the CLI.
|
||||
- **Stale or conflicting facts.** Prefer the knowledge graph's
|
||||
time-valid answer; if a fact has changed, invalidate the old one and
|
||||
add the new one rather than overwriting context silently.
|
||||
|
||||
## Recovering a corrupt index
|
||||
|
||||
A ChromaDB compaction failure can leave the drawers HNSW index out of
|
||||
sync with `chroma.sqlite3` and wedge the MCP server (every call returns
|
||||
"Not connected"). The data is safe in SQLite; rebuild the index from it.
|
||||
Guide the user through these CLI steps — never run an in-process rebuild
|
||||
from the agent (it can break other live clients):
|
||||
|
||||
1. Stop the MCP server (kill the `mempalace-mcp` process, or restart the
|
||||
host editor).
|
||||
2. Optional backup of the palace directory (`--archive-existing` already
|
||||
moves the old palace aside, so this is belt-and-suspenders):
|
||||
- macOS / Linux: `cp -a ~/.mempalace/palace ~/.mempalace/palace.bak.$(date +%F)`
|
||||
- Windows (PowerShell): `Copy-Item -Recurse "$env:USERPROFILE\.mempalace\palace" "$env:USERPROFILE\.mempalace\palace.bak"`
|
||||
3. Rebuild from SQLite:
|
||||
`mempalace repair --mode from-sqlite --archive-existing --yes`
|
||||
4. Verify: `mempalace repair-status` (divergence should read 0).
|
||||
5. Restart the MCP server.
|
||||
|
||||
Do **not** re-mine from source files to recover: re-mining drops drawers
|
||||
added through the MCP server and diary entries, which have no source file
|
||||
(see MemPalace issue #1843).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Answering about past work, people, or decisions from model memory when
|
||||
the palace might know — search first.
|
||||
- Paraphrasing or summarizing stored content instead of quoting it
|
||||
verbatim.
|
||||
- Searching reflexively on every turn, including pure greenfield coding
|
||||
with no memory relevance.
|
||||
- Pasting the full conversation or a system prompt into the `query`
|
||||
argument — keep queries short and keyword-driven.
|
||||
|
||||
## See also
|
||||
|
||||
- [`integrations/openclaw/SKILL.md`](../openclaw/SKILL.md) — the original
|
||||
full-protocol skill this is distilled from.
|
||||
- [`coordination-protocol.md`](coordination-protocol.md) — the shared-brain
|
||||
companion protocol: when agents delegate work to each other over the
|
||||
hub, they use the logstream (`mempalace_event_append` /
|
||||
`mempalace_event_wait`), not drawers. Recall answers questions;
|
||||
the logstream moves work.
|
||||
- MemPalace design principles (verbatim, local-first, never summarize):
|
||||
<https://github.com/MemPalace/mempalace>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ The Python package that powers MemPalace. All modules, all logic.
|
||||
| `dialect.py` | AAAK compression — entity codes, emotion markers, 30x lossless ratio |
|
||||
| `knowledge_graph.py` | Temporal entity-relationship graph — SQLite, time-filtered queries, fact invalidation |
|
||||
| `palace_graph.py` | Room-based navigation graph — BFS traversal, tunnel detection across wings |
|
||||
| `mcp_server.py` | MCP server — 19 tools, AAAK auto-teach, Palace Protocol, agent diary |
|
||||
| `mcp_server.py` | MCP server — 34 tools, AAAK auto-teach, Palace Protocol, agent diary |
|
||||
| `onboarding.py` | Guided first-run setup — asks about people/projects, generates AAAK bootstrap + wing config |
|
||||
| `entity_registry.py` | Entity code registry — maps names to AAAK codes, handles ambiguous names |
|
||||
| `entity_detector.py` | Auto-detect people and projects from file content |
|
||||
|
||||
@@ -27,13 +27,16 @@ from .base import (
|
||||
HealthStatus,
|
||||
LexicalHit,
|
||||
LexicalResult,
|
||||
MaintenanceResult,
|
||||
PalaceNotFoundError,
|
||||
PalaceRef,
|
||||
QueryResult,
|
||||
UnsupportedCapabilityError,
|
||||
UnsupportedFilterError,
|
||||
UnsupportedMaintenanceKindError,
|
||||
)
|
||||
from .chroma import ChromaBackend, ChromaCollection
|
||||
from .milvus import MilvusBackend, MilvusCollection
|
||||
from .pgvector import PgVectorBackend, PgVectorCollection
|
||||
from .qdrant import QdrantBackend, QdrantCollection
|
||||
from .sqlite_exact import SQLiteExactBackend, SQLiteExactCollection
|
||||
@@ -64,6 +67,9 @@ __all__ = [
|
||||
"HealthStatus",
|
||||
"LexicalHit",
|
||||
"LexicalResult",
|
||||
"MaintenanceResult",
|
||||
"MilvusBackend",
|
||||
"MilvusCollection",
|
||||
"PalaceNotFoundError",
|
||||
"PalaceRef",
|
||||
"PgVectorBackend",
|
||||
@@ -75,6 +81,7 @@ __all__ = [
|
||||
"SQLiteExactCollection",
|
||||
"UnsupportedCapabilityError",
|
||||
"UnsupportedFilterError",
|
||||
"UnsupportedMaintenanceKindError",
|
||||
"available_backends",
|
||||
"detect_backend_for_path",
|
||||
"detect_backends_for_path",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Shared embedder-identity sidecar (RFC 001).
|
||||
|
||||
A small JSON file in the palace directory, keyed by collection name, recording
|
||||
the embedder identity (``model_name`` / ``dimension``). It is deliberately
|
||||
*separate* from a backend's mismatch marker: a marker's presence signals
|
||||
"palace initialized" (reads raise ``CollectionNotInitializedError`` when the
|
||||
marker exists but the store doesn't), so recording identity at first empty open
|
||||
must not create one. The sidecar is unguarded, so a brand-new palace can record
|
||||
identity immediately — the same approach the chroma backend uses.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
EMBEDDER_SIDECAR_FILENAME = "mempalace_embedder.json"
|
||||
|
||||
|
||||
def read_embedder_sidecar(path: Optional[str], collection_name: Optional[str]):
|
||||
"""Return the recorded :class:`EmbedderIdentity` for ``collection_name``, or None.
|
||||
|
||||
Robust to a missing, unreadable, or malformed (non-dict) sidecar — any of
|
||||
those degrade to ``None`` (the ``unknown`` state) rather than raising.
|
||||
"""
|
||||
from .base import EmbedderIdentity
|
||||
|
||||
if not path or not collection_name or not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
entry = data.get(collection_name)
|
||||
if not isinstance(entry, dict) or not entry.get("model_name"):
|
||||
return None
|
||||
return EmbedderIdentity(
|
||||
model_name=str(entry["model_name"]),
|
||||
dimension=int(entry.get("dimension") or 0),
|
||||
)
|
||||
|
||||
|
||||
def write_embedder_sidecar(path: Optional[str], collection_name: Optional[str], identity) -> None:
|
||||
"""Record ``identity`` for ``collection_name`` in the sidecar, creating it if needed.
|
||||
|
||||
No-ops for a missing path, missing collection name, or a nameless identity.
|
||||
Preserves other collections' entries; never raises on I/O failure.
|
||||
"""
|
||||
if not path or not collection_name or not identity or not getattr(identity, "model_name", ""):
|
||||
return
|
||||
data: dict = {}
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
data = loaded
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {}
|
||||
data[collection_name] = {
|
||||
"model_name": str(identity.model_name),
|
||||
"dimension": int(identity.dimension or 0),
|
||||
}
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
os.chmod(path, 0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
+271
-8
@@ -14,8 +14,8 @@ conformance suite land in follow-up PRs.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from typing import ClassVar, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -62,6 +62,15 @@ class UnsupportedCapabilityError(BackendError):
|
||||
"""Raised when a backend does not implement an optional capability."""
|
||||
|
||||
|
||||
class UnsupportedMaintenanceKindError(BackendError):
|
||||
"""Raised when ``run_maintenance(kind)`` is called with an unadvertised kind.
|
||||
|
||||
A backend MUST advertise a kind in ``maintenance_kinds`` before it accepts
|
||||
it (RFC 001). Advertising a kind it does not implement is a conformance
|
||||
failure; a kind it has no analogue for MUST be omitted, not no-op'd.
|
||||
"""
|
||||
|
||||
|
||||
class BackendMismatchError(BackendError):
|
||||
"""Raised when a selected backend does not match existing palace artifacts."""
|
||||
|
||||
@@ -74,6 +83,15 @@ class EmbedderIdentityMismatchError(BackendError):
|
||||
"""Raised when the stored embedder model name differs from the current one."""
|
||||
|
||||
|
||||
class EmbedderIdentityUnknownWarning(UserWarning):
|
||||
"""Emitted on first open of a collection with no recorded embedder identity.
|
||||
|
||||
Legacy palaces created before identity tracking carry no model name. Per
|
||||
RFC 001 the right behavior is warn-not-fail: the identity is recorded on
|
||||
the next write and subsequent opens become strict.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Value objects
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -105,10 +123,13 @@ class PalaceRef:
|
||||
access is a spec violation.
|
||||
|
||||
Backends that do not advertise ``supports_namespace_isolation`` (e.g.
|
||||
``sqlite_exact``, whose isolation is the on-disk path alone) MAY ignore
|
||||
``namespace`` entirely; callers MUST NOT rely on it for tenant isolation
|
||||
on such backends. Any conforming backend can self-check both guarantees by
|
||||
running the shared assertions in ``tests/_backend_conformance.py``.
|
||||
path-rooted ``chroma`` / ``sqlite_exact``) MUST NOT silently accept and
|
||||
ignore a populated ``namespace`` — they MUST raise
|
||||
:class:`UnsupportedCapabilityError` (same spirit as
|
||||
:class:`UnsupportedFilterError`). Callers targeting those backends MUST
|
||||
leave ``namespace`` as ``None``. Isolation conformance lives in
|
||||
``tests/_backend_conformance.py`` (cross-id arm for every backend;
|
||||
same-id / different-namespace arm for advertisers only).
|
||||
"""
|
||||
|
||||
id: str
|
||||
@@ -116,6 +137,114 @@ class PalaceRef:
|
||||
namespace: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmbedderIdentity:
|
||||
"""Identity of the embedder that produced a collection's vectors (RFC 001).
|
||||
|
||||
``model_name`` is the stable identity persisted alongside a collection and
|
||||
checked on subsequent opens. ``dimension`` is the vector width. A
|
||||
``dimension`` of ``0`` means *unknown / not probed* — comparisons treat it
|
||||
as "no dimension signal" rather than a real zero-width vector, so a cheap
|
||||
read-path check can compare model names without loading the model.
|
||||
"""
|
||||
|
||||
model_name: str
|
||||
dimension: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MaintenanceResult:
|
||||
"""Observable outcome of ``run_maintenance(kind)`` (RFC 001).
|
||||
|
||||
Maintenance is *not* fire-and-forget: a backend MUST serialize concurrent
|
||||
same-kind runs and report the outcome so a caller can learn it must not
|
||||
re-trigger. ``status`` is one of:
|
||||
|
||||
* ``"ran"`` — this call performed the maintenance.
|
||||
* ``"already_running"`` — another caller holds the work; this call did
|
||||
nothing and the caller MUST NOT re-trigger (the production index-build
|
||||
wedge: concurrent writers each issuing the build stacked exclusive locks).
|
||||
* ``"noop"`` — nothing needed doing (e.g. the index already exists).
|
||||
|
||||
``stats`` is free-form per kind (rows analyzed, bytes reclaimed, index
|
||||
build time) for benchmark/operator reporting.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
status: str
|
||||
stats: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Embedder(Protocol):
|
||||
"""Minimal embedder contract (RFC 001, normative for identity checking).
|
||||
|
||||
The fuller embedder RFC (batching/async/pooling) is additive; identity
|
||||
enforcement depends only on these three members.
|
||||
"""
|
||||
|
||||
model_name: str
|
||||
dimension: int
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
||||
|
||||
|
||||
def check_embedder_identity(
|
||||
stored: Optional[EmbedderIdentity],
|
||||
current: Optional[EmbedderIdentity],
|
||||
*,
|
||||
force_model_swap: bool = False,
|
||||
) -> str:
|
||||
"""Three-state embedder-identity check (RFC 001).
|
||||
|
||||
Returns the resolved state and raises on a hard, unforced conflict:
|
||||
|
||||
* ``"unknown"`` — no identity recorded yet (legacy collection), or the
|
||||
current embedder is nameless. The caller warns and records on write.
|
||||
* ``"known_match"`` — stored name (and dimension, when both known) equal
|
||||
the current embedder. Proceed normally.
|
||||
* ``"known_mismatch"`` — names or dimensions differ. Without
|
||||
``force_model_swap`` this raises (:class:`EmbedderIdentityMismatchError`
|
||||
for a model swap, :class:`DimensionMismatchError` for a width change,
|
||||
which is checked first because mismatched vectors are physically
|
||||
unusable). With ``force_model_swap`` it returns the state so the caller
|
||||
can re-record the identity and log the swap.
|
||||
|
||||
A ``dimension`` of ``0`` on either side means "unknown" and is skipped, so
|
||||
a model-name-only check (cheap read path) still works.
|
||||
"""
|
||||
if current is None or not current.model_name:
|
||||
return "unknown"
|
||||
if stored is None:
|
||||
return "unknown"
|
||||
|
||||
dim_conflict = bool(stored.dimension and current.dimension) and (
|
||||
stored.dimension != current.dimension
|
||||
)
|
||||
name_conflict = stored.model_name != current.model_name
|
||||
|
||||
if not dim_conflict and not name_conflict:
|
||||
return "known_match"
|
||||
|
||||
if force_model_swap:
|
||||
return "known_mismatch"
|
||||
|
||||
if dim_conflict:
|
||||
raise DimensionMismatchError(
|
||||
f"collection was built with a {stored.dimension}-dim embedder "
|
||||
f"({stored.model_name!r}) but the current embedder is "
|
||||
f"{current.dimension}-dim ({current.model_name!r}); the stored "
|
||||
"vectors are incompatible. Re-embed the palace to switch models."
|
||||
)
|
||||
raise EmbedderIdentityMismatchError(
|
||||
f"collection was built with embedder {stored.model_name!r} but the "
|
||||
f"current embedder is {current.model_name!r}. Searching across a model "
|
||||
"swap silently degrades recall. Re-embed the palace, or run "
|
||||
"`mempalace palace set-embedder --model <name> --force` to record the "
|
||||
"new identity if you know the vectors are compatible."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealthStatus:
|
||||
ok: bool
|
||||
@@ -301,6 +430,110 @@ class BaseCollection(ABC):
|
||||
def health(self) -> HealthStatus:
|
||||
return HealthStatus.healthy()
|
||||
|
||||
@property
|
||||
def distance_metric(self) -> str:
|
||||
"""The space this collection's ``distances`` are reported in.
|
||||
|
||||
Defaults to the owning backend's declared metric (cosine for all
|
||||
in-tree backends). Collections that can vary per-collection — e.g. a
|
||||
legacy Chroma palace built without ``hnsw:space=cosine`` — override
|
||||
this to report their actual space so core ranking converts correctly.
|
||||
"""
|
||||
return "cosine"
|
||||
|
||||
def get_stored_embedder_identity(self) -> Optional[EmbedderIdentity]:
|
||||
"""Return the embedder identity recorded for this collection, if any.
|
||||
|
||||
Returns ``None`` when nothing is recorded — a legacy collection, or a
|
||||
backend that does not yet persist identity. Core treats ``None`` as the
|
||||
``unknown`` state (warn, do not fail). Backends override this and
|
||||
:meth:`set_embedder_identity` against their own metadata store.
|
||||
"""
|
||||
return None
|
||||
|
||||
def set_embedder_identity(self, identity: EmbedderIdentity) -> None:
|
||||
"""Persist this collection's embedder identity. Default: no-op.
|
||||
|
||||
A backend without an identity slot inherits the no-op default and so
|
||||
stays permanently ``unknown`` (safe — it simply never enforces). The
|
||||
enforcement choke point calls this when recording on first write or
|
||||
on an explicit, forced model swap.
|
||||
"""
|
||||
return None
|
||||
|
||||
def effective_embedder_identity(self) -> Optional[EmbedderIdentity]:
|
||||
"""The identity of the embedder this collection actually uses.
|
||||
|
||||
For ``server_embedder`` backends that ignore the injected embedder,
|
||||
this reports the server-side embedder so the same identity rules apply
|
||||
(RFC 001). Defaults to ``None`` — the collection is embedded by the
|
||||
injected/core embedder, and the caller supplies the current identity.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_all_metadata(self, where: Optional[dict] = None) -> list[dict]:
|
||||
"""Return every matching record's metadata in one logical pass (#1796).
|
||||
|
||||
Default implementation pages through :meth:`get` using
|
||||
``limit``/``offset`` -- correct for backends with a real server-side
|
||||
cursor (e.g. Chroma's SQL OFFSET), and the same shape callers already
|
||||
relied on before this method existed.
|
||||
|
||||
Backends whose ``get(limit=, offset=)`` is implemented by fully
|
||||
materializing a result set and then Python-slicing it (no true
|
||||
server-side cursor) MUST override this method to walk their native
|
||||
cursor exactly once instead. Calling the default implementation on
|
||||
such a backend is O(n^2) in collection size: each page re-walks the
|
||||
entire collection just to discard everything outside the requested
|
||||
slice. See issue #1796.
|
||||
"""
|
||||
all_meta: list[dict] = []
|
||||
offset = 0
|
||||
page_size = 1000
|
||||
while True:
|
||||
kwargs: dict = {"include": ["metadatas"], "limit": page_size, "offset": offset}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
batch = self.get(**kwargs)
|
||||
batch_meta = batch.metadatas if hasattr(batch, "metadatas") else batch.get("metadatas")
|
||||
if not batch_meta:
|
||||
break
|
||||
all_meta.extend(batch_meta)
|
||||
if len(batch_meta) < page_size:
|
||||
break
|
||||
offset += len(batch_meta)
|
||||
return all_meta
|
||||
|
||||
def facet_counts(
|
||||
self,
|
||||
field: str,
|
||||
where: Optional[dict] = None,
|
||||
limit: int = 1000,
|
||||
) -> dict[str, int]:
|
||||
"""Return counts for each distinct value of a metadata field."""
|
||||
raise UnsupportedCapabilityError("backend does not support facet_counts")
|
||||
|
||||
def maintenance_state(self) -> dict:
|
||||
"""Return a structured snapshot of this collection's maintenance state.
|
||||
|
||||
Free-form per backend (e.g. row count, whether a vector index exists,
|
||||
last-analyze age). Used by benchmark harnesses to record state
|
||||
alongside each latency/recall measurement so an un-analyzed store is
|
||||
not compared against a settled one (RFC 001). Defaults to empty.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def run_maintenance(self, kind: str) -> "MaintenanceResult":
|
||||
"""Run a maintenance ``kind`` and return an observable result (RFC 001).
|
||||
|
||||
Backends advertise supported kinds in ``BaseBackend.maintenance_kinds``
|
||||
and override this. The default supports nothing, so every kind raises
|
||||
:class:`UnsupportedMaintenanceKindError`. Implementations MUST serialize
|
||||
concurrent same-kind runs and report ``already_running`` rather than
|
||||
stacking the work.
|
||||
"""
|
||||
raise UnsupportedMaintenanceKindError(f"backend does not support maintenance kind {kind!r}")
|
||||
|
||||
def lexical_search(
|
||||
self,
|
||||
*,
|
||||
@@ -374,13 +607,43 @@ class BaseBackend(ABC):
|
||||
``PalaceRef.namespace`` (multi-tenant / hosted deployments) MUST advertise
|
||||
the ``supports_namespace_isolation`` capability token; doing so is a
|
||||
promise to satisfy the cross-namespace guarantee and to pass the namespace
|
||||
arm of the conformance suite. Backends without the token MAY ignore
|
||||
``namespace``.
|
||||
arm of the conformance suite. Backends without the token MUST raise
|
||||
:class:`UnsupportedCapabilityError` when ``PalaceRef.namespace`` is
|
||||
non-``None`` rather than silently accept-and-ignore (RFC 001 §4.4).
|
||||
"""
|
||||
|
||||
name: ClassVar[str]
|
||||
spec_version: ClassVar[str] = "1.0"
|
||||
capabilities: ClassVar[frozenset[str]] = frozenset()
|
||||
#: The space ``query()`` reports ``distances`` in (RFC 001 §2.1).
|
||||
#: One of ``"cosine"`` | ``"l2"`` | ``"ip"``. The contract for the
|
||||
#: ``distances`` field is *lower = closer* regardless of metric; core
|
||||
#: search converts distance→similarity off this declaration rather than
|
||||
#: assuming cosine. All in-tree backends are cosine today.
|
||||
distance_metric: ClassVar[str] = "cosine"
|
||||
#: Maintenance kinds this backend implements (RFC 001). Reserved names:
|
||||
#: ``"analyze"`` (refresh planner/query statistics), ``"compact"`` (reclaim
|
||||
#: space, rewrite storage), ``"reindex"`` (build/rebuild secondary indexes).
|
||||
#: A backend with no analogue for a kind MUST omit it rather than declare a
|
||||
#: no-op, so a benchmark harness can trust the set. Backends MAY add their
|
||||
#: own kinds. ``run_maintenance`` raises ``UnsupportedMaintenanceKindError``
|
||||
#: for anything not listed here.
|
||||
maintenance_kinds: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
def require_namespace_support(self, palace: PalaceRef) -> None:
|
||||
"""Raise if ``palace.namespace`` is set but this backend does not isolate by it.
|
||||
|
||||
Call at the start of ``get_collection`` (and any other entry that
|
||||
accepts a :class:`PalaceRef`) so non-advertising backends never
|
||||
silently drop a tenant namespace (RFC 001 §4.4).
|
||||
"""
|
||||
if palace.namespace is None:
|
||||
return
|
||||
if "supports_namespace_isolation" not in self.capabilities:
|
||||
raise UnsupportedCapabilityError(
|
||||
f"{type(self).name} does not advertise supports_namespace_isolation; "
|
||||
f"leave PalaceRef.namespace as None (got {palace.namespace!r})"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def get_collection(
|
||||
|
||||
+498
-79
@@ -8,7 +8,9 @@ import math
|
||||
import os
|
||||
import pickle
|
||||
import re
|
||||
import shlex
|
||||
import sqlite3
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from numbers import Integral
|
||||
from pathlib import Path
|
||||
@@ -17,6 +19,8 @@ from typing import Any, Optional
|
||||
import chromadb
|
||||
from chromadb.errors import NotFoundError as _ChromaNotFoundError
|
||||
|
||||
from ..config import sqlite_read_uri
|
||||
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
|
||||
from .base import (
|
||||
BaseBackend,
|
||||
BaseCollection,
|
||||
@@ -78,13 +82,52 @@ def _hnsw_link_to_data_ratio(seg_dir: str) -> Optional[float]:
|
||||
return link_size / data_size
|
||||
|
||||
|
||||
def _hnsw_link_lists_is_usable_for_payload(seg_dir: str) -> bool:
|
||||
"""Return False when a non-trivial HNSW payload lacks usable link lists.
|
||||
def _hnsw_metadata_marker_intact(seg_dir: str) -> bool:
|
||||
"""Return True when ``index_metadata.pickle`` bears a complete envelope.
|
||||
|
||||
A missing or empty link_lists.bin is acceptable only for a fresh/empty
|
||||
segment. Once data_level0.bin has real payload, a zero-byte link_lists.bin
|
||||
is not a harmless async-flush shape: ChromaDB can later hand the broken
|
||||
graph to hnswlib and crash in native code.
|
||||
ChromaDB writes ``index_metadata.pickle`` last during a persist, so an
|
||||
intact pickle envelope — protocol marker ``0x80`` at the head, ``STOP``
|
||||
byte ``0x2e`` at the tail — proves the flush finished. Used as a
|
||||
persist-completion marker: when present, the segment's on-disk shape
|
||||
(including an all-layer-0 index with an empty ``link_lists.bin``) is the
|
||||
one chromadb intentionally serialized, not a half-written one.
|
||||
|
||||
Deliberately byte-sniffs only; never deserializes. Deserialization can
|
||||
execute arbitrary code, and the byte-sniff is enough to tell a complete
|
||||
write from truncation or zero-fill. Assumes pickle protocol >= 2.
|
||||
"""
|
||||
meta_path = os.path.join(seg_dir, "index_metadata.pickle")
|
||||
try:
|
||||
if not os.path.isfile(meta_path) or os.path.getsize(meta_path) < 16:
|
||||
return False
|
||||
with open(meta_path, "rb") as f:
|
||||
head = f.read(2)
|
||||
f.seek(-1, 2) # last byte
|
||||
tail = f.read(1)
|
||||
except OSError:
|
||||
return False
|
||||
return len(head) == 2 and head[0] == 0x80 and tail == b"\x2e"
|
||||
|
||||
|
||||
def _hnsw_link_lists_is_usable_for_payload(seg_dir: str) -> bool:
|
||||
"""Return False when a non-trivial HNSW payload looks like a partial flush.
|
||||
|
||||
A zero-byte ``link_lists.bin`` is *not* corruption on its own. hnswlib
|
||||
stores the entire layer-0 graph inside ``data_level0.bin`` and only writes
|
||||
``link_lists.bin`` for elements promoted to level > 0. A small or
|
||||
low-fanout index where every element stays on layer 0 therefore
|
||||
serializes an empty ``link_lists.bin`` and loads/searches fine (#1716).
|
||||
Treating that shape as corruption produced a self-perpetuating quarantine
|
||||
loop: repair rebuilt the byte-identical all-layer-0 segment, which the next
|
||||
cold start quarantined again, accumulating drift dirs without bound.
|
||||
|
||||
An empty ``link_lists.bin`` only signals trouble when the persist was
|
||||
interrupted before it could finish. ChromaDB writes
|
||||
``index_metadata.pickle`` last, so an intact metadata envelope proves the
|
||||
flush completed and the empty ``link_lists.bin`` is the legitimate
|
||||
all-layer-0 shape. Only when there is real payload, an empty
|
||||
``link_lists.bin``, *and* no completion marker do we treat the segment as a
|
||||
partial flush.
|
||||
"""
|
||||
data_path = os.path.join(seg_dir, "data_level0.bin")
|
||||
link_path = os.path.join(seg_dir, "link_lists.bin")
|
||||
@@ -97,10 +140,16 @@ def _hnsw_link_lists_is_usable_for_payload(seg_dir: str) -> bool:
|
||||
if data_size <= _HNSW_MISSING_METADATA_DATA_FLOOR:
|
||||
return True
|
||||
|
||||
return os.path.isfile(link_path) and os.path.getsize(link_path) > 0
|
||||
if os.path.isfile(link_path) and os.path.getsize(link_path) > 0:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# Real payload with an empty/absent link_lists.bin: legitimate only when
|
||||
# the persist completed (all-layer-0 index), proven by an intact metadata
|
||||
# marker. Otherwise it is a half-written segment chromadb could segfault on.
|
||||
return _hnsw_metadata_marker_intact(seg_dir)
|
||||
|
||||
|
||||
def _hnsw_payload_appears_sane(seg_dir: str) -> bool:
|
||||
"""Return False when HNSW payload files are structurally implausible."""
|
||||
@@ -111,34 +160,101 @@ def _hnsw_payload_appears_sane(seg_dir: str) -> bool:
|
||||
return ratio is None or ratio <= _HNSW_LINK_TO_DATA_MAX_RATIO
|
||||
|
||||
|
||||
# HNSW batch/sync thresholds applied at collection creation.
|
||||
# HNSW batch/sync thresholds applied at collection creation — chromadb's own
|
||||
# documented defaults (chromadb/api/configuration.py:263-273,
|
||||
# chromadb/api/collection_configuration.py:451-454,
|
||||
# chromadb/segment/impl/vector/hnsw_params.py:79-80, and
|
||||
# https://docs.trychroma.com/docs/collections/configure).
|
||||
#
|
||||
# chromadb's Rust HNSW segment writes index_metadata.pickle and
|
||||
# link_lists.bin only when internal counters cross both thresholds
|
||||
# (batch_size gates _apply_batch; sync_threshold gates _persist).
|
||||
# Records below both thresholds stay in memory and are lost on exit.
|
||||
# Both ran at 2 to answer #1579 (a sub-threshold mine left index_metadata.pickle
|
||||
# absent, and quarantine_stale_hnsw renamed the segment away). Three findings on
|
||||
# chromadb 1.5.9 (PersistentClient / Rust bindings, single writer) retire that:
|
||||
#
|
||||
# Previously 50k/50k to work around link_lists.bin sparse-file bloat
|
||||
# in pre-1.5.x Python chromadb (#344). chromadb >=1.5.4 Rust bindings
|
||||
# (the minimum mempalace supports) do not exhibit that bloat; verified
|
||||
# at batch_size=2 with 20k records: link_lists.bin = 171 KB, no
|
||||
# sparse-file inflation.
|
||||
# * 2 SITS OUTSIDE CHROMA'S OWN DECLARED VALID RANGE. hnsw_params.py:21-22
|
||||
# validates both knobs as `isinstance(p, int) and p > 2`. Not an inequality
|
||||
# between the two — a floor on each.
|
||||
#
|
||||
# The 50k guard caused #1579: mines under 50k drawers never triggered
|
||||
# _persist(), leaving index_metadata.pickle absent and link_lists.bin
|
||||
# empty. quarantine_stale_hnsw then renamed the segment on every cold
|
||||
# open after a 300s mtime gap, accumulating .drift-* directories.
|
||||
# * IT COSTS WRITE AMPLIFICATION, measured. Bytes written (/proc/self/io) for
|
||||
# one mine of N records, 64-dim, num_threads=1, identical corpus and seed:
|
||||
#
|
||||
# Lowered to 2 (empirical Rust-side minimum for chromadb >=1.5.4; the
|
||||
# Rust bindings reject 1 with InvalidArgumentError) so any mine of 2+
|
||||
# drawers triggers a natural persist. Existing palaces created under
|
||||
# the old 50k guard keep those thresholds in their collection metadata
|
||||
# until the user runs repair --mode from-sqlite --archive-existing.
|
||||
_HNSW_BLOAT_GUARD = {
|
||||
"hnsw:batch_size": 2,
|
||||
"hnsw:sync_threshold": 2,
|
||||
# N 2/2 100/1000 ratio
|
||||
# 10,000 47.1 MB 28.1 MB 1.67x
|
||||
# 20,000 127.4 MB 60.0 MB 2.12x
|
||||
# 40,000 390.3 MB 134.0 MB 2.91x
|
||||
#
|
||||
# Two runs at N=20,000 reproduced within 0.1%. sync_threshold dominates:
|
||||
# 3/3 measured identical to 2/2, and 1000/1000 identical to 100/1000. The
|
||||
# ratio grows with collection size, so the cost is worst on the largest
|
||||
# palaces.
|
||||
#
|
||||
# * IT BUYS NOTHING. A 5-record collection at 100/1000 — far below the
|
||||
# threshold, so no persist ever fires — reads back whole from a FRESH
|
||||
# PROCESS (count and vector query both answer). On that artifact
|
||||
# link_lists.bin is 0 bytes with no index_metadata.pickle, which is #1579's
|
||||
# trigger shape exactly, and quarantine_stale_hnsw() creates no drift dir:
|
||||
# _segment_appears_healthy reads it as never-persisted rather than as a torn
|
||||
# persist.
|
||||
#
|
||||
# NOT claimed here: that a small sync_threshold is a known chroma failure mode.
|
||||
# No such report was found in chroma's issues or docs, and chroma's own guidance
|
||||
# runs the other way (raise sync_threshold for bulk inserts). The Python
|
||||
# persist path that #1579 and chroma#6975 describe does not execute under the
|
||||
# Rust bindings at all — hnswlib is not a dependency of this line.
|
||||
#
|
||||
# A palace keeps whatever thresholds it was created under;
|
||||
# `repair --mode from-sqlite --archive-existing` re-creates it under these.
|
||||
_HNSW_WRITE_DEFAULTS = {
|
||||
"hnsw:batch_size": 100,
|
||||
"hnsw:sync_threshold": 1000,
|
||||
}
|
||||
|
||||
|
||||
def _hnsw_creation_metadata(options: Optional[dict]) -> dict:
|
||||
"""Build the ``metadata=`` dict for a fresh collection from caller options.
|
||||
|
||||
Centralizes the HNSW knobs so a multi-collection palace can tune each
|
||||
collection at creation while the base keeps every config value in the
|
||||
``collection_metadata`` table where the divergence guard, the cosine-space
|
||||
detector (``ChromaCollection.distance_metric``), and ``_read_sync_threshold``
|
||||
already read it. The legacy ``metadata=`` keys are kept deliberately: the
|
||||
modern ``configuration=`` API stores the same parameters in
|
||||
``configuration_json`` instead, leaving ``collection.metadata`` empty and
|
||||
silently blinding all of that existing tooling.
|
||||
|
||||
Caller option -> chromadb metadata key:
|
||||
|
||||
* ``hnsw_space`` -> ``hnsw:space`` (default ``"cosine"``)
|
||||
* ``num_threads`` -> ``hnsw:num_threads`` (default 1; serializes inserts)
|
||||
* ``ef_construction`` -> ``hnsw:construction_ef``
|
||||
* ``max_neighbors`` -> ``hnsw:M``
|
||||
* ``sync_threshold`` -> ``hnsw:sync_threshold``
|
||||
* ``batch_size`` -> ``hnsw:batch_size``
|
||||
|
||||
``sync_threshold``/``batch_size`` default to chromadb's own values
|
||||
(:data:`_HNSW_WRITE_DEFAULTS`), which amortize the index flush across a
|
||||
mine. A caller tunes them per collection; a caller writing far fewer
|
||||
records than the threshold still keeps them (the Rust writer holds the
|
||||
sub-threshold tail durable — see :data:`_HNSW_WRITE_DEFAULTS`).
|
||||
``ef_construction``/``max_neighbors`` are omitted when the caller does not
|
||||
set them, so chromadb applies its own defaults.
|
||||
"""
|
||||
opts = options if isinstance(options, dict) else {}
|
||||
md: dict[str, Any] = {
|
||||
"hnsw:space": opts.get("hnsw_space", "cosine"),
|
||||
"hnsw:num_threads": int(opts.get("num_threads", 1)),
|
||||
**_HNSW_WRITE_DEFAULTS,
|
||||
}
|
||||
if "ef_construction" in opts and opts["ef_construction"] is not None:
|
||||
md["hnsw:construction_ef"] = int(opts["ef_construction"])
|
||||
if "max_neighbors" in opts and opts["max_neighbors"] is not None:
|
||||
md["hnsw:M"] = int(opts["max_neighbors"])
|
||||
if "sync_threshold" in opts and opts["sync_threshold"] is not None:
|
||||
md["hnsw:sync_threshold"] = int(opts["sync_threshold"])
|
||||
if "batch_size" in opts and opts["batch_size"] is not None:
|
||||
md["hnsw:batch_size"] = int(opts["batch_size"])
|
||||
return md
|
||||
|
||||
|
||||
# Below this size, data_level0.bin is too small for a meaningful HNSW graph.
|
||||
# Used by _hnsw_link_lists_is_usable_for_payload (empty link_lists is fine
|
||||
# when data is trivially small) and _missing_dimensionality_appears_recoverable
|
||||
@@ -333,17 +449,7 @@ def _segment_appears_healthy(seg_dir: str) -> bool:
|
||||
if not _hnsw_payload_appears_sane(seg_dir):
|
||||
return False
|
||||
|
||||
try:
|
||||
size = os.path.getsize(meta_path)
|
||||
if size < 16:
|
||||
return False
|
||||
with open(meta_path, "rb") as f:
|
||||
head = f.read(2)
|
||||
f.seek(-1, 2) # last byte
|
||||
tail = f.read(1)
|
||||
except OSError:
|
||||
return False
|
||||
return len(head) == 2 and head[0] == 0x80 and tail == b"\x2e"
|
||||
return _hnsw_metadata_marker_intact(seg_dir)
|
||||
|
||||
|
||||
def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 300.0) -> list[str]:
|
||||
@@ -456,7 +562,7 @@ def _vector_segment_id(palace_path: str, collection_name: str) -> Optional[str]:
|
||||
if not os.path.isfile(db_path):
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
@@ -594,9 +700,10 @@ def _hnsw_element_count(palace_path: str, segment_id: str) -> Optional[int]:
|
||||
# read the collection metadata (older palaces missing the row, sqlite
|
||||
# unreadable). 2000 = 2 × chromadb's default sync_threshold of 1000.
|
||||
#
|
||||
# Why dynamic: legacy palaces may still carry ``sync_threshold = 50_000``
|
||||
# (the pre-#1579 guard), so flush-lag can grow up to 50K on those palaces.
|
||||
# New palaces use sync_threshold=2 (#1579) and flush almost immediately.
|
||||
# Why dynamic: a palace carries whatever ``sync_threshold`` it was created
|
||||
# under, and flush-lag grows to that threshold before a persist fires — up
|
||||
# to 50K on a palace created under an old large guard, and 2 on one created
|
||||
# under the small guard that #1308 traces back to.
|
||||
# A fixed 2000 floor would flag actively-written legacy palaces as
|
||||
# DIVERGED the moment their queue exceeded 10% of sqlite_count, even
|
||||
# though chromadb is behaving correctly. The floor must scale with the
|
||||
@@ -605,6 +712,7 @@ def _hnsw_element_count(palace_path: str, segment_id: str) -> Optional[int]:
|
||||
# sync_threshold) from expected steady-state lag.
|
||||
_HNSW_DIVERGENCE_FALLBACK_FLOOR = 2000
|
||||
_HNSW_DIVERGENCE_FRACTION = 0.10
|
||||
_HNSW_PERSISTENT_DIVERGENCE_GRACE_SECONDS = 300.0
|
||||
|
||||
|
||||
def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
|
||||
@@ -625,7 +733,7 @@ def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
|
||||
if not os.path.isfile(db_path):
|
||||
return 1000
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
@@ -648,6 +756,151 @@ def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
|
||||
return 1000
|
||||
|
||||
|
||||
def _collection_has_sync_threshold_metadata(palace_path: str, collection_name: str) -> bool:
|
||||
"""Return True when the collection explicitly stores hnsw:sync_threshold."""
|
||||
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM collection_metadata cm
|
||||
JOIN collections c ON cm.collection_id = c.id
|
||||
WHERE c.name = ?
|
||||
AND cm.key = 'hnsw:sync_threshold'
|
||||
LIMIT 1
|
||||
""",
|
||||
(collection_name,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
logger.debug("_collection_has_sync_threshold_metadata failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _hnsw_metadata_age_seconds(palace_path: str, segment_id: str) -> Optional[float]:
|
||||
"""Return index_metadata.pickle age in seconds, or None when unreadable."""
|
||||
|
||||
pickle_path = os.path.join(palace_path, segment_id, "index_metadata.pickle")
|
||||
try:
|
||||
return max(0.0, time.time() - os.path.getmtime(pickle_path))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
# Probe verdicts keyed by ``(palace_path, collection_name)``; each value is
|
||||
# ``(fingerprint, segment_id, status, probed_at)``. Written as one tuple
|
||||
# assignment, so a concurrent reader either sees the previous entry or the new
|
||||
# one, never a half-updated pair. Two threads that miss together simply both
|
||||
# run the probe and the last writer wins — a benign race, and cheaper than
|
||||
# serializing every reader behind a lock.
|
||||
_capacity_cache: dict[tuple[str, str], tuple[tuple, Optional[str], dict, float]] = {}
|
||||
|
||||
# Bumped by every :func:`reset_hnsw_capacity_cache`. A probe reads it before
|
||||
# running and refuses to store its verdict if it changed meanwhile, so a probe
|
||||
# already in flight when a reset lands cannot repopulate the entry the reset
|
||||
# was meant to discard (the ``tool_reconnect`` race).
|
||||
_capacity_cache_generation = 0
|
||||
|
||||
# A long-lived server watches one palace and a handful of collections, so the
|
||||
# map stays tiny; the bound only exists so a process that walks many palaces
|
||||
# (benchmarks, batch tooling) cannot grow it without limit.
|
||||
_CAPACITY_CACHE_MAX_ENTRIES = 32
|
||||
|
||||
# Ceiling on how long one verdict may be reused, as a backstop for filesystems
|
||||
# whose timestamps are too coarse to notice a quick rewrite: FAT32 stores mtime
|
||||
# at 2 s granularity, exFAT at 10 ms, and a write that lands inside existing
|
||||
# sqlite pages need not change the file size either. On ext4/APFS/NTFS the
|
||||
# signature already catches every write, so this ceiling never fires in
|
||||
# practice. It bounds the worst case; it is not the freshness mechanism.
|
||||
_CAPACITY_CACHE_MAX_AGE_SECONDS = 10.0
|
||||
|
||||
|
||||
def _stat_signature(path: str) -> tuple[int, int, int]:
|
||||
"""Return ``(inode, mtime_ns, size)`` for ``path``, all zeros when absent.
|
||||
|
||||
Catches every exception, not just ``OSError``: a palace path carrying an
|
||||
embedded null byte makes ``os.stat`` raise ``ValueError``, and
|
||||
:func:`hnsw_capacity_status` promises never to raise. An unreadable path
|
||||
simply yields the "absent" signature and the probe reports ``unknown``.
|
||||
"""
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except Exception:
|
||||
return (0, 0, 0)
|
||||
return (st.st_ino, st.st_mtime_ns, st.st_size)
|
||||
|
||||
|
||||
def _db_family_signature(palace_path: str) -> tuple:
|
||||
"""Signature of the sqlite files whose contents the probe depends on.
|
||||
|
||||
chromadb 1.5.x leaves ``chroma.sqlite3`` in ``journal_mode=delete``, so the
|
||||
``-wal`` sidecar usually does not exist and stat'ing it is one cheap miss.
|
||||
It is covered anyway because the journal mode belongs to the database
|
||||
rather than to this code: under WAL a writer appends rows the probe would
|
||||
count while the main file's own mtime stays put, and the verdict would
|
||||
otherwise be reused against data it never saw.
|
||||
|
||||
``-shm`` is deliberately excluded. It is the WAL index in shared memory,
|
||||
and sqlite restamps it every time a connection opens the database — even
|
||||
read-only. Including it would make the probe invalidate its own cache on
|
||||
every call, so on a WAL-mode palace the cache would never hit.
|
||||
"""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
return (
|
||||
_stat_signature(db_path),
|
||||
_stat_signature(db_path + "-wal"),
|
||||
)
|
||||
|
||||
|
||||
def _pickle_signature(palace_path: str, segment_id: Optional[str]) -> tuple[int, int, int]:
|
||||
"""Signature of the segment's ``index_metadata.pickle``."""
|
||||
if not segment_id:
|
||||
return (0, 0, 0)
|
||||
return _stat_signature(os.path.join(palace_path, segment_id, "index_metadata.pickle"))
|
||||
|
||||
|
||||
def _segment_id_safe(palace_path: str, collection_name: str) -> Optional[str]:
|
||||
"""``_vector_segment_id`` that never raises, for the pre-probe signature."""
|
||||
try:
|
||||
return _vector_segment_id(palace_path, collection_name)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _capacity_fingerprint(palace_path: str, segment_id: Optional[str]) -> tuple:
|
||||
"""Signature over every file the probe reads: the sqlite family + the pickle.
|
||||
|
||||
Both halves must be captured for the same ``segment_id`` so a rewrite of
|
||||
``index_metadata.pickle`` is caught. The probe reads that pickle partway
|
||||
through, then makes two more sqlite calls, so a signature taken only after
|
||||
the probe returned would record a mid-probe pickle rewrite as "unchanged"
|
||||
while the verdict still reflected the pre-write file (#1471 review).
|
||||
"""
|
||||
return (_db_family_signature(palace_path), _pickle_signature(palace_path, segment_id))
|
||||
|
||||
|
||||
def reset_hnsw_capacity_cache() -> None:
|
||||
"""Forget every cached capacity verdict.
|
||||
|
||||
The signature check already picks up on-disk changes on its own; this is
|
||||
for callers that drop all cached palace state at once (``tool_reconnect``,
|
||||
``_force_chroma_cache_reset``) and for tests that want a probe to run
|
||||
unconditionally. Bumps the generation so a probe already running cannot
|
||||
re-store the entry this call just dropped.
|
||||
"""
|
||||
global _capacity_cache_generation
|
||||
_capacity_cache_generation += 1
|
||||
_capacity_cache.clear()
|
||||
|
||||
|
||||
def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_drawers") -> dict:
|
||||
"""Compare sqlite embedding count against HNSW element count.
|
||||
|
||||
@@ -668,7 +921,70 @@ def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_dra
|
||||
* ``message`` — human-readable summary
|
||||
|
||||
Never raises — a probe that throws would defeat the point.
|
||||
|
||||
A fully-measured verdict is cached per ``(palace_path, collection_name)``
|
||||
and reused while every file the probe reads is unchanged on disk (#1471).
|
||||
Each call otherwise costs a ``COUNT(*)`` over the embeddings table and a
|
||||
full unpickle of the segment metadata — the two dominant costs — plus a few
|
||||
small sqlite reads, on a path every search, duplicate check and status call
|
||||
runs through. A verdict the probe could not fully measure (``sqlite_count``
|
||||
is ``None`` from a locked database, or there is no palace yet) is returned
|
||||
but never cached, so a transient failure cannot pin a false reading.
|
||||
|
||||
Freshness comes from an ``(inode, mtime_ns, size)`` signature rather than a
|
||||
wall-clock TTL, so an external writer — ``mempalace repair``, a peer mine,
|
||||
another process — invalidates the verdict as soon as it touches the files,
|
||||
instead of leaving the #1222 guard blind for a fixed window.
|
||||
``_CAPACITY_CACHE_MAX_AGE_SECONDS`` caps how long one verdict may be
|
||||
reused, but only as a backstop for filesystems with coarse timestamps; the
|
||||
signature is what makes the verdict fresh.
|
||||
|
||||
Unlike :meth:`ChromaBackend._client`, which tolerates a 0.01 s mtime
|
||||
epsilon to avoid rebuilding an expensive client, this compares exactly:
|
||||
re-running the probe costs milliseconds, whereas serving one stale verdict
|
||||
can route a query into a diverged segment.
|
||||
"""
|
||||
key = (palace_path, collection_name)
|
||||
cached = _capacity_cache.get(key)
|
||||
if cached is not None:
|
||||
fingerprint, cached_segment, status, probed_at = cached
|
||||
if time.monotonic() - probed_at <= _CAPACITY_CACHE_MAX_AGE_SECONDS:
|
||||
if _capacity_fingerprint(palace_path, cached_segment) == fingerprint:
|
||||
return dict(status)
|
||||
|
||||
generation = _capacity_cache_generation
|
||||
# Snapshot the files the probe is about to read, before it reads them, and
|
||||
# again after — using the segment id the probe itself resolved. Caching
|
||||
# only when both snapshots agree makes an external write during the probe
|
||||
# (sqlite OR the pickle) fall through uncached rather than pin a verdict
|
||||
# the disk no longer supports.
|
||||
before = _capacity_fingerprint(palace_path, _segment_id_safe(palace_path, collection_name))
|
||||
out = _hnsw_capacity_status_uncached(palace_path, collection_name)
|
||||
segment_id = out.get("segment_id")
|
||||
after = _capacity_fingerprint(palace_path, segment_id)
|
||||
cacheable = (
|
||||
before == after
|
||||
# A None sqlite_count means the probe could not read the database
|
||||
# (transient lock/error), not a real "unknown" — pinning it would go
|
||||
# blind for the whole ceiling. A None segment id has no pickle path to
|
||||
# watch, so its fingerprint can never notice a first flush.
|
||||
and out.get("sqlite_count") is not None
|
||||
and segment_id is not None
|
||||
# A reset that landed while this probe ran already dropped the entry
|
||||
# it was told to; do not resurrect it.
|
||||
and generation == _capacity_cache_generation
|
||||
)
|
||||
if cacheable:
|
||||
if len(_capacity_cache) >= _CAPACITY_CACHE_MAX_ENTRIES:
|
||||
_capacity_cache.clear()
|
||||
_capacity_cache[key] = (after, segment_id, dict(out), time.monotonic())
|
||||
return out
|
||||
|
||||
|
||||
def _hnsw_capacity_status_uncached(
|
||||
palace_path: str, collection_name: str = "mempalace_drawers"
|
||||
) -> dict:
|
||||
"""Run the capacity probe, bypassing the cache. See :func:`hnsw_capacity_status`."""
|
||||
out: dict[str, Any] = {
|
||||
"segment_id": None,
|
||||
"sqlite_count": None,
|
||||
@@ -692,11 +1008,15 @@ def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_dra
|
||||
|
||||
hnsw_count = _hnsw_element_count(palace_path, seg_id)
|
||||
out["hnsw_count"] = hnsw_count
|
||||
|
||||
sync_threshold = _read_sync_threshold(palace_path, collection_name)
|
||||
# Two synchronization windows worth — see comment above
|
||||
# _HNSW_DIVERGENCE_FALLBACK_FLOOR for the rationale.
|
||||
divergence_floor = max(_HNSW_DIVERGENCE_FALLBACK_FLOOR, 2 * sync_threshold)
|
||||
has_explicit_sync_threshold = _collection_has_sync_threshold_metadata(
|
||||
palace_path,
|
||||
collection_name,
|
||||
)
|
||||
metadata_age_seconds = (
|
||||
_hnsw_metadata_age_seconds(palace_path, seg_id) if hnsw_count is not None else None
|
||||
)
|
||||
out["hnsw_metadata_age_seconds"] = metadata_age_seconds
|
||||
|
||||
if hnsw_count is None:
|
||||
# No pickle yet, so this probe cannot measure HNSW capacity.
|
||||
@@ -714,21 +1034,53 @@ def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_dra
|
||||
|
||||
divergence = sqlite_count - hnsw_count
|
||||
out["divergence"] = divergence
|
||||
threshold = max(divergence_floor, int(sqlite_count * _HNSW_DIVERGENCE_FRACTION))
|
||||
if divergence > threshold:
|
||||
|
||||
# Newer palaces explicitly store mempalace's low sync threshold
|
||||
# (currently 2), so a gap of dozens of rows is far beyond ordinary
|
||||
# flush lag. Older palaces may lack the metadata row; keep the
|
||||
# historical floor for fresh lag there, but do not let a stale pickle
|
||||
# sit below the floor forever (#1816).
|
||||
if has_explicit_sync_threshold:
|
||||
threshold = max(0, 2 * sync_threshold)
|
||||
else:
|
||||
divergence_floor = max(_HNSW_DIVERGENCE_FALLBACK_FLOOR, 2 * sync_threshold)
|
||||
threshold = max(
|
||||
divergence_floor,
|
||||
int(sqlite_count * _HNSW_DIVERGENCE_FRACTION),
|
||||
)
|
||||
|
||||
out["threshold"] = threshold
|
||||
stale_below_threshold = (
|
||||
not has_explicit_sync_threshold
|
||||
and divergence > 0
|
||||
and metadata_age_seconds is not None
|
||||
and metadata_age_seconds >= _HNSW_PERSISTENT_DIVERGENCE_GRACE_SECONDS
|
||||
)
|
||||
|
||||
if divergence > threshold or stale_below_threshold:
|
||||
out["status"] = "diverged"
|
||||
out["diverged"] = True
|
||||
pct = 100.0 * divergence / max(sqlite_count, 1)
|
||||
if divergence > threshold:
|
||||
reason = f"exceeds threshold {threshold:,}"
|
||||
else:
|
||||
age = metadata_age_seconds or 0.0
|
||||
reason = f"persisted below the old flush-lag floor for {age:.0f}s"
|
||||
out["message"] = (
|
||||
f"HNSW index holds {hnsw_count:,} elements but sqlite has "
|
||||
f"{sqlite_count:,} embeddings — {divergence:,} drawers ({pct:.0f}%) "
|
||||
"are invisible to vector search. Run `mempalace repair` to rebuild."
|
||||
f"{sqlite_count:,} embeddings - {divergence:,} drawers "
|
||||
f"({pct:.0f}%) are missing from the flushed HNSW index "
|
||||
f"({reason}). Vector reads are disabled until "
|
||||
"`mempalace repair` rebuilds it."
|
||||
)
|
||||
else:
|
||||
out["status"] = "ok"
|
||||
out["message"] = (
|
||||
f"HNSW {hnsw_count:,} / sqlite {sqlite_count:,} (within flush-lag tolerance)"
|
||||
)
|
||||
if divergence < 0:
|
||||
out["message"] += " (HNSW has extra flushed elements; treating as safe)"
|
||||
|
||||
except Exception:
|
||||
logger.debug("hnsw_capacity_status failed", exc_info=True)
|
||||
out["message"] = "HNSW capacity probe raised; skipping"
|
||||
@@ -745,7 +1097,7 @@ def _sqlite_embedding_count(palace_path: str, collection_name: str) -> Optional[
|
||||
if not os.path.isfile(db_path):
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
@@ -806,7 +1158,7 @@ def _sqlite_wing_room_counts(
|
||||
if not os.path.isfile(db_path):
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
|
||||
try:
|
||||
# Wait out a transient writer/checkpoint lock rather than falling
|
||||
# straight back to the expensive vector-index path (#1681).
|
||||
@@ -925,7 +1277,12 @@ def _missing_dimensionality_appears_recoverable(
|
||||
return False
|
||||
|
||||
label_count = len(id_to_label)
|
||||
if int(total) != label_count or len(label_to_id) != label_count:
|
||||
# total_elements_added is monotonic across every add, while id_to_label and
|
||||
# label_to_id hold only live elements, so a segment that has had deletions
|
||||
# carries total_elements_added > label_count. Require >= (not ==), otherwise
|
||||
# every post-deletion dim-None segment is wrongly quarantined (#1710); the
|
||||
# label-map size and bijection checks still reject inconsistent label maps.
|
||||
if int(total) < label_count or len(label_to_id) != label_count:
|
||||
return False
|
||||
try:
|
||||
return all(label_to_id.get(label) == item_id for item_id, label in id_to_label.items())
|
||||
@@ -1067,7 +1424,7 @@ def _fix_blob_seq_ids(palace_path: str) -> None:
|
||||
if os.path.isfile(marker):
|
||||
return
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
with contextlib.closing(sqlite3.connect(db_path)) as conn:
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT rowid, seq_id FROM embeddings WHERE typeof(seq_id) = 'blob'"
|
||||
@@ -1255,8 +1612,8 @@ class ChromaCollection(BaseCollection):
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_documents_for_chromadb(documents):
|
||||
"""Strip lone UTF-16 surrogates from every document before it reaches
|
||||
the chromadb client.
|
||||
"""Strip lone UTF-16 surrogates and embedded NUL characters from every
|
||||
document before it reaches the chromadb client.
|
||||
|
||||
A single lone surrogate (U+D800–U+DFFF) raises ``UnicodeEncodeError``
|
||||
inside chromadb's encode path and aborts the *entire* add/upsert batch
|
||||
@@ -1269,18 +1626,29 @@ class ChromaCollection(BaseCollection):
|
||||
directly. Sanitising here makes the chokepoint catch-all complete: the
|
||||
sibling :meth:`_sanitize_metadatas_for_chromadb` already guarantees this
|
||||
for metadata one method over; documents get the same guarantee.
|
||||
|
||||
A document containing an embedded NUL (U+0000) — routine in mined
|
||||
Bash tool output — is well-formed UTF-8, unlike a lone surrogate, but
|
||||
can corrupt the FTS5 inverted index for the whole collection rather
|
||||
than just failing to store that one row (a ChromaDB-side bug; tracked
|
||||
upstream). Stripping it here is the same defense-in-depth already
|
||||
applied to surrogates: sanitize input we don't control before it
|
||||
reaches a datastore we don't control.
|
||||
"""
|
||||
if documents is None:
|
||||
return None
|
||||
from ..config import strip_lone_surrogates
|
||||
from ..config import strip_lone_surrogates, strip_nul_bytes
|
||||
|
||||
def _sanitize(d):
|
||||
return strip_nul_bytes(strip_lone_surrogates(d))
|
||||
|
||||
# chromadb accepts OneOrMany[Document]: a bare str is a single document,
|
||||
# not an iterable of characters. Handle it explicitly so we don't split
|
||||
# it into per-character documents — that would be exactly the kind of
|
||||
# silent corruption this method exists to prevent.
|
||||
if isinstance(documents, str):
|
||||
return strip_lone_surrogates(documents)
|
||||
return [strip_lone_surrogates(d) if isinstance(d, str) else d for d in documents]
|
||||
return _sanitize(documents)
|
||||
return [_sanitize(d) if isinstance(d, str) else d for d in documents]
|
||||
|
||||
def add(self, *, documents, ids, metadatas=None, embeddings=None):
|
||||
kwargs: dict[str, Any] = {
|
||||
@@ -1564,7 +1932,7 @@ class ChromaCollection(BaseCollection):
|
||||
# rowid, embedding_id is the user-facing drawer id.
|
||||
public_ids: dict[int, str] = {}
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
except sqlite3.Error:
|
||||
logger.debug("Chroma lexical sqlite open failed", exc_info=True)
|
||||
@@ -1724,6 +2092,46 @@ class ChromaCollection(BaseCollection):
|
||||
"""
|
||||
return self._collection.metadata or {}
|
||||
|
||||
@property
|
||||
def distance_metric(self) -> str:
|
||||
"""Report this collection's actual space from ``hnsw:space``.
|
||||
|
||||
MemPalace sets ``hnsw:space=cosine`` on every creation path, so a
|
||||
healthy palace reports ``"cosine"``. When the key is absent, empty, or
|
||||
an unrecognized value, the collection is genuinely using Chroma's HNSW
|
||||
default — **L2** (Euclidean) — because cosine was never set on it. We
|
||||
report ``"l2"`` in that case so core ranking maps the distances
|
||||
correctly; reporting ``"cosine"`` here would reintroduce the
|
||||
floor-every-result-to-zero misranking this property exists to fix.
|
||||
"""
|
||||
space = str(self.metadata.get("hnsw:space", "") or "").lower()
|
||||
if space in ("cosine", "l2", "ip"):
|
||||
return space
|
||||
return "l2"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Embedder identity (RFC 001)
|
||||
#
|
||||
# Stored in a small sidecar JSON in the palace dir rather than the Chroma
|
||||
# collection metadata: ``collection.modify(metadata=...)`` replaces the
|
||||
# whole dict and some Chroma versions reject re-passing the immutable
|
||||
# ``hnsw:*`` construction keys, so mutating it on every open is fragile.
|
||||
# The sidecar is keyed by collection name (a palace may hold several).
|
||||
# This is complementary to Chroma's own embedding-function-name check —
|
||||
# the core check runs at open time and yields the clean cross-backend
|
||||
# error before Chroma's read-time rejection fires.
|
||||
# ------------------------------------------------------------------
|
||||
def _embedder_sidecar_path(self) -> Optional[str]:
|
||||
if not self._palace_path:
|
||||
return None
|
||||
return os.path.join(self._palace_path, EMBEDDER_SIDECAR_FILENAME)
|
||||
|
||||
def get_stored_embedder_identity(self):
|
||||
return read_embedder_sidecar(self._embedder_sidecar_path(), self._collection_name())
|
||||
|
||||
def set_embedder_identity(self, identity) -> None:
|
||||
write_embedder_sidecar(self._embedder_sidecar_path(), self._collection_name(), identity)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend
|
||||
@@ -1745,6 +2153,7 @@ class ChromaBackend(BaseBackend):
|
||||
name = "chroma"
|
||||
capabilities = frozenset(
|
||||
{
|
||||
"requires_explicit_embeddings",
|
||||
"supports_embeddings_in",
|
||||
"supports_embeddings_passthrough",
|
||||
"supports_embeddings_out",
|
||||
@@ -1800,6 +2209,7 @@ class ChromaBackend(BaseBackend):
|
||||
current_model = MempalaceConfig().embedding_model
|
||||
except Exception:
|
||||
current_model = "unknown"
|
||||
rebuild_cmd = f"mempalace --palace {shlex.quote(palace_path)} repair rebuild-index"
|
||||
return (
|
||||
f"Embedding model mismatch reading palace at {palace_path!r}.\n"
|
||||
f" Underlying ChromaDB error: {msg}\n"
|
||||
@@ -1807,8 +2217,8 @@ class ChromaBackend(BaseBackend):
|
||||
f" The palace was built with a different embedding model. Either:\n"
|
||||
f" (a) revert the model: unset MEMPALACE_EMBEDDING_MODEL (or set "
|
||||
f"the previous value), or\n"
|
||||
f" (b) re-embed in place: `mempalace repair rebuild-index "
|
||||
f"--palace {palace_path}` (writes new vectors with the current model)."
|
||||
f" (b) re-embed in place: `{rebuild_cmd}` "
|
||||
f"(writes new vectors with the current model)."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1985,6 +2395,7 @@ class ChromaBackend(BaseBackend):
|
||||
— still used by callers not yet migrated.
|
||||
"""
|
||||
palace_ref, collection_name, create, options = _normalize_get_collection_args(args, kwargs)
|
||||
self.require_namespace_support(palace_ref)
|
||||
|
||||
palace_path = palace_ref.local_path
|
||||
if palace_path is None:
|
||||
@@ -2001,9 +2412,6 @@ class ChromaBackend(BaseBackend):
|
||||
pass
|
||||
|
||||
client = self._client(palace_path)
|
||||
hnsw_space = "cosine"
|
||||
if options and isinstance(options, dict):
|
||||
hnsw_space = options.get("hnsw_space", hnsw_space)
|
||||
|
||||
ef = self._resolve_embedding_function()
|
||||
ef_kwargs = {"embedding_function": ef} if ef is not None else {}
|
||||
@@ -2014,11 +2422,7 @@ class ChromaBackend(BaseBackend):
|
||||
except _ChromaNotFoundError:
|
||||
collection = client.create_collection(
|
||||
collection_name,
|
||||
metadata={
|
||||
"hnsw:space": hnsw_space,
|
||||
"hnsw:num_threads": 1,
|
||||
**_HNSW_BLOAT_GUARD,
|
||||
},
|
||||
metadata=_hnsw_creation_metadata(options),
|
||||
**ef_kwargs,
|
||||
)
|
||||
except ValueError as e:
|
||||
@@ -2067,7 +2471,26 @@ class ChromaBackend(BaseBackend):
|
||||
|
||||
@classmethod
|
||||
def detect(cls, path: str) -> bool:
|
||||
return os.path.isfile(os.path.join(path, "chroma.sqlite3"))
|
||||
"""Return True when ``path`` looks like a chroma palace.
|
||||
|
||||
Verifies the SQLite magic header rather than file presence alone.
|
||||
Bare ``sqlite3.connect()`` against a missing path leaves a 0-byte
|
||||
file behind (the SQLite header is written on the first statement,
|
||||
not on connection), so file-presence alone treats those artifacts
|
||||
as real chroma palaces and breaks multi-backend resolution. The
|
||||
16-byte ``SQLite format 3\\x00`` magic prefix is written as soon
|
||||
as chromadb's ``PersistentClient`` does any work, so this check
|
||||
accepts every real chroma palace while rejecting empty / garbage
|
||||
files. See #1893.
|
||||
"""
|
||||
db_path = os.path.join(path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return False
|
||||
try:
|
||||
with open(db_path, "rb") as f:
|
||||
return f.read(16) == b"SQLite format 3\x00"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Legacy (pre-RFC 001) surface — retained while callers migrate.
|
||||
@@ -2089,11 +2512,7 @@ class ChromaBackend(BaseBackend):
|
||||
ef_kwargs = {"embedding_function": ef} if ef is not None else {}
|
||||
collection = self._client(palace_path).create_collection(
|
||||
collection_name,
|
||||
metadata={
|
||||
"hnsw:space": hnsw_space,
|
||||
"hnsw:num_threads": 1,
|
||||
**_HNSW_BLOAT_GUARD,
|
||||
},
|
||||
metadata=_hnsw_creation_metadata({"hnsw_space": hnsw_space}),
|
||||
**ef_kwargs,
|
||||
)
|
||||
return ChromaCollection(collection, palace_path=palace_path)
|
||||
|
||||
@@ -8,14 +8,26 @@ from .base import BaseCollection
|
||||
|
||||
|
||||
def _embed_texts(texts: list[str]) -> list[list[float]]:
|
||||
"""Embed ``texts`` with the configured local embedding function."""
|
||||
"""Embed ``texts`` with the configured local embedding function.
|
||||
|
||||
Embedding functions return ``list[np.ndarray]`` (float32). ``list(arr)``
|
||||
would unpack that into ``np.float32`` *scalars*, which ChromaDB's
|
||||
``normalize_embeddings`` rejects outright ("Expected embeddings to be a
|
||||
list of floats or ints..."), so every write through this wrapper must
|
||||
convert to real Python floats. ``.tolist()`` does that in C; the
|
||||
``float(x)`` branch covers embedders that already hand back plain
|
||||
sequences.
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
from ..embedding import get_embedding_function
|
||||
|
||||
ef = get_embedding_function()
|
||||
vectors = ef(input=texts)
|
||||
return [list(v) for v in vectors]
|
||||
return [
|
||||
v.tolist() if hasattr(v, "tolist") else [float(x) for x in v] # numpy | plain sequence
|
||||
for v in vectors
|
||||
]
|
||||
|
||||
|
||||
def _as_list(value):
|
||||
@@ -49,6 +61,32 @@ class EmbeddingCollection(BaseCollection):
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._inner, name)
|
||||
|
||||
@property
|
||||
def distance_metric(self) -> str:
|
||||
# Explicit delegation: ``BaseCollection`` defines ``distance_metric``
|
||||
# as a property, so it resolves on this subclass and ``__getattr__``
|
||||
# never fires — without this override the wrapper would report the
|
||||
# base "cosine" default and mask a wrapped non-cosine backend.
|
||||
return self._inner.distance_metric
|
||||
|
||||
# Same shadowing reason as ``distance_metric``: these are concrete methods
|
||||
# on ``BaseCollection``, so ``__getattr__`` never delegates them. Forward
|
||||
# explicitly to the wrapped backend collection's identity store.
|
||||
def get_stored_embedder_identity(self):
|
||||
return self._inner.get_stored_embedder_identity()
|
||||
|
||||
def set_embedder_identity(self, identity) -> None:
|
||||
return self._inner.set_embedder_identity(identity)
|
||||
|
||||
def effective_embedder_identity(self):
|
||||
return self._inner.effective_embedder_identity()
|
||||
|
||||
def maintenance_state(self) -> dict:
|
||||
return self._inner.maintenance_state()
|
||||
|
||||
def run_maintenance(self, kind: str):
|
||||
return self._inner.run_maintenance(kind)
|
||||
|
||||
def add(self, *, documents, ids, metadatas=None, embeddings=None):
|
||||
documents = _as_list(documents)
|
||||
ids = _as_list(ids)
|
||||
@@ -129,6 +167,30 @@ class EmbeddingCollection(BaseCollection):
|
||||
def lexical_search(self, *, query: str, n_results: int = 10, where: Optional[dict] = None):
|
||||
return self._inner.lexical_search(query=query, n_results=n_results, where=where)
|
||||
|
||||
def facet_counts(
|
||||
self, field: str, where: Optional[dict] = None, limit: int = 1000
|
||||
) -> dict[str, int]:
|
||||
# ``BaseCollection.facet_counts`` is a concrete method that raises
|
||||
# ``UnsupportedCapabilityError`` as its default. MRO resolves it on
|
||||
# this subclass before ``__getattr__`` ever fires, so without an
|
||||
# explicit forwarder every facet call against a wrapped backend
|
||||
# (qdrant, pgvector, sqlite_exact) raises and silently degrades to
|
||||
# client-side counting in mcp_server's try/except.
|
||||
return self._inner.facet_counts(field, where=where, limit=limit)
|
||||
|
||||
def get_all_metadata(self, where: Optional[dict] = None) -> list[dict]:
|
||||
# ``BaseCollection.get_all_metadata`` ships a concrete default that
|
||||
# pages through ``self.get(include=["metadatas"])``. Without this
|
||||
# forwarder, MRO resolves the call here on the subclass and runs the
|
||||
# base default — which routes back through ``self.get()`` (the
|
||||
# wrapper's get, then ``__getattr__`` to the inner's get). Result:
|
||||
# the inner's overridden ``get_all_metadata`` (e.g. pgvector's
|
||||
# ``with_document=False`` fast path from #1892) is never reached,
|
||||
# and every metadata-only fetch transfers the full document column
|
||||
# over the wire. Same MRO-shadow pattern as ``facet_counts`` /
|
||||
# ``lexical_search`` above.
|
||||
return self._inner.get_all_metadata(where=where)
|
||||
|
||||
def update(self, *, ids, documents=None, metadatas=None, embeddings=None):
|
||||
ids = _as_list(ids)
|
||||
if documents is not None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+368
-25
@@ -37,6 +37,8 @@ from urllib import parse as urlparse
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..config import strip_lone_surrogates
|
||||
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
|
||||
from .base import (
|
||||
BackendClosedError,
|
||||
BackendError,
|
||||
@@ -52,6 +54,7 @@ from .base import (
|
||||
PalaceNotFoundError,
|
||||
PalaceRef,
|
||||
QueryResult,
|
||||
UnsupportedCapabilityError,
|
||||
UnsupportedFilterError,
|
||||
_IncludeSpec,
|
||||
)
|
||||
@@ -79,6 +82,42 @@ def _json_dumps(obj: Any) -> str:
|
||||
return json.dumps(obj or {}, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
|
||||
def _strip_nul(value: Any) -> Any:
|
||||
"""Recursively strip NUL (0x00) from strings, list/tuple items, and dict keys
|
||||
and values so pgvector can store the result.
|
||||
|
||||
PostgreSQL cannot store NUL in ``text`` or ``jsonb``: psycopg rejects a raw
|
||||
NUL in a text column ("PostgreSQL text fields cannot contain NUL (0x00)
|
||||
bytes"), and a NUL in metadata serializes to a JSON unicode escape that the
|
||||
``jsonb`` cast rejects ("unsupported Unicode escape sequence"). A single
|
||||
transcript that captured NUL in tool output would otherwise abort the whole
|
||||
mine run (#1829). ChromaDB and the SQLite backend store the byte verbatim,
|
||||
so stripping only here keeps the same inputs ingestible.
|
||||
|
||||
Applied to id, document, and metadata in :meth:`_PgVectorClient.upsert_rows`
|
||||
so the write path never carries a NUL into Postgres. Only ``str`` values are
|
||||
rewritten; the ``int``/``float``/``bool``/``None`` scalars JSON metadata
|
||||
normalizes to pass through unchanged. Stripping is not injective, so two keys
|
||||
(or ids)
|
||||
differing only by a NUL collapse to one (last wins); this does not occur in
|
||||
practice because drawer ids are SHA-256 hashes and metadata keys are fixed
|
||||
field names, so only transcript-derived values are ever actually changed.
|
||||
Unlike ``config.sanitize_content`` (which rejects NUL in user-supplied
|
||||
content), the bulk-mine path strips so one stray byte cannot abort a whole
|
||||
backfill. ``str.replace`` returns the original string when it holds no NUL,
|
||||
so a clean document is not reallocated.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return value.replace("\x00", "")
|
||||
if isinstance(value, dict):
|
||||
return {_strip_nul(key): _strip_nul(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_strip_nul(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_strip_nul(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def _tokenize(text: str) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
@@ -352,6 +391,30 @@ def _quote_identifier(name: str) -> str:
|
||||
return '"' + name.replace('"', '""') + '"'
|
||||
|
||||
|
||||
# Session-level advisory-lock namespace for serializing HNSW index builds
|
||||
# across daemon writers (RFC 001). classid is a fixed mempalace constant
|
||||
# ("MEMP" in ASCII); objid is a stable per-table key. Both must fit a signed
|
||||
# int4, which ``pg_advisory_lock(int4, int4)`` requires.
|
||||
_MAINTENANCE_LOCK_CLASSID = 0x4D454D50 # "MEMP" — a positive, valid int4
|
||||
|
||||
|
||||
def _advisory_objid(table: str) -> int:
|
||||
"""Stable signed-int4 advisory key derived from the table name."""
|
||||
raw = int(sha256(table.encode("utf-8")).hexdigest()[:8], 16) # 0 .. 2**32-1
|
||||
return raw - 2**32 if raw >= 2**31 else raw
|
||||
|
||||
|
||||
def _hnsw_index_name(table: str) -> str:
|
||||
"""Deterministic, collision-safe index name for ``table``.
|
||||
|
||||
Routes through :func:`_pg_identifier`, which hashes the overflow when the
|
||||
name exceeds Postgres' 63-byte limit. A naive ``[:63]`` truncation could
|
||||
return the table name verbatim (tables and indexes share the ``pg_class``
|
||||
namespace), which would fail with "relation already exists".
|
||||
"""
|
||||
return _pg_identifier(f"{table}_hnsw_idx")
|
||||
|
||||
|
||||
def _field_sql(field: str, expression: Any, params: list) -> str:
|
||||
"""Translate one field predicate to a JSONB containment expression."""
|
||||
if isinstance(expression, dict):
|
||||
@@ -440,11 +503,10 @@ class _PgVectorClient:
|
||||
def __init__(self, config: _PgVectorConfig):
|
||||
self._config = config
|
||||
self._conn = None
|
||||
self._closed = False
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def _connect(self):
|
||||
if self._conn is not None and not getattr(self._conn, "closed", False):
|
||||
return self._conn
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
||||
@@ -452,15 +514,27 @@ class _PgVectorClient:
|
||||
"pgvector backend requires the optional 'psycopg' dependency; "
|
||||
"install mempalace[pgvector]"
|
||||
) from exc
|
||||
try:
|
||||
self._conn = psycopg.connect(self._config.dsn)
|
||||
except Exception as exc: # noqa: BLE001 - surface any driver failure uniformly
|
||||
raise BackendError(f"pgvector connection failed: {exc}") from exc
|
||||
return self._conn
|
||||
# One client is shared across threads (PgVectorBackend caches a
|
||||
# single instance per config), so the read-create-store on self._conn
|
||||
# must hold the same lock _execute serializes on; unlocked, two
|
||||
# first-connect threads each opened a connection and the loser leaked
|
||||
# unclosed. The RLock makes the _execute -> _connect nesting safe. A
|
||||
# stalled connect blocks peers under the lock the same way any
|
||||
# in-flight query on this single shared connection already does.
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise BackendError("pgvector client has been closed")
|
||||
if self._conn is not None and not getattr(self._conn, "closed", False):
|
||||
return self._conn
|
||||
try:
|
||||
self._conn = psycopg.connect(self._config.dsn)
|
||||
except Exception as exc: # noqa: BLE001 - surface any driver failure uniformly
|
||||
raise BackendError(f"pgvector connection failed: {exc}") from exc
|
||||
return self._conn
|
||||
|
||||
def _execute(self, sql: str, params=None, *, fetch: bool = False, many: bool = False):
|
||||
conn = self._connect()
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if many:
|
||||
@@ -545,9 +619,21 @@ class _PgVectorClient:
|
||||
)
|
||||
params = [
|
||||
(
|
||||
row["id"],
|
||||
row["document"],
|
||||
_json_dumps(row.get("metadata")),
|
||||
# Strip both unstorable byte classes Postgres rejects before
|
||||
# binding, so one stray byte in a transcript cannot abort the
|
||||
# whole mine (#1829 NUL, #1833 lone surrogate).
|
||||
#
|
||||
# Order matters for metadata: NUL must be stripped *before*
|
||||
# serialization (json escapes it to \\u0000, which the jsonb cast
|
||||
# rejects), while a lone surrogate must be stripped *after*
|
||||
# serialization (json.dumps(ensure_ascii=False) leaves it raw, so
|
||||
# one pass over the serialized string cleans it without walking
|
||||
# the dict). id/document are plain strings, so the two passes
|
||||
# commute there. ids are NUL- and surrogate-free in practice, so
|
||||
# those passes are defensive no-ops on the ON CONFLICT key.
|
||||
strip_lone_surrogates(_strip_nul(row["id"])),
|
||||
strip_lone_surrogates(_strip_nul(row["document"])),
|
||||
strip_lone_surrogates(_json_dumps(_strip_nul(row.get("metadata")))),
|
||||
_vector_literal(row["embedding"]),
|
||||
row.get("updated_at") or _utcnow(),
|
||||
)
|
||||
@@ -589,14 +675,34 @@ class _PgVectorClient:
|
||||
*,
|
||||
where: Optional[dict] = None,
|
||||
with_embedding: bool = False,
|
||||
with_document: bool = True,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> list[dict]:
|
||||
qi = _quote_identifier(table)
|
||||
params: list = []
|
||||
where_sql = _where_to_sql(where, params) if where else "TRUE"
|
||||
cols = "id, document, metadata"
|
||||
# Project NULL into the document slot when the caller only needs
|
||||
# metadata (e.g. mempalace_status's wing/room tally). Keeps the
|
||||
# positional _row parser unchanged — document remains record[1] —
|
||||
# while avoiding O(n × document_size) bytes over the wire on remote
|
||||
# pgvector deployments. Follow-up to #1840.
|
||||
cols = "id, document, metadata" if with_document else "id, NULL::text, metadata"
|
||||
if with_embedding:
|
||||
cols += ", embedding"
|
||||
sql = f"SELECT {cols} FROM {qi} WHERE {where_sql}"
|
||||
# Push pagination into SQL when a page is requested. ORDER BY the
|
||||
# primary key gives OFFSET a stable order (an unordered scan may skip
|
||||
# or repeat rows across pages); callers that scroll the whole table
|
||||
# pass neither bound, leaving their SQL unchanged.
|
||||
if limit is not None or offset:
|
||||
sql += " ORDER BY id"
|
||||
if limit is not None:
|
||||
params.append(int(limit))
|
||||
sql += " LIMIT %s"
|
||||
if offset:
|
||||
params.append(int(offset))
|
||||
sql += " OFFSET %s"
|
||||
rows = self._execute(sql, params, fetch=True)
|
||||
return [
|
||||
self._row(record, with_embedding=with_embedding, with_distance=False)
|
||||
@@ -622,11 +728,72 @@ class _PgVectorClient:
|
||||
rows = self._execute(f"SELECT count(*) FROM {_quote_identifier(table)}", fetch=True)
|
||||
return int(rows[0][0]) if rows and rows[0] else 0
|
||||
|
||||
def facet_counts(
|
||||
self,
|
||||
table: str,
|
||||
*,
|
||||
field: str,
|
||||
where: Optional[dict] = None,
|
||||
limit: int = 1000,
|
||||
) -> dict[str, int]:
|
||||
qi = _quote_identifier(table)
|
||||
params: list = [field]
|
||||
where_sql = _where_to_sql(where, params) if where else "TRUE"
|
||||
sql = (
|
||||
f"SELECT metadata->>%s AS k, count(*) AS c "
|
||||
f"FROM {qi} WHERE {where_sql} "
|
||||
f"GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT %s"
|
||||
)
|
||||
params.append(int(limit))
|
||||
rows = self._execute(sql, params, fetch=True)
|
||||
# Exclude NULL bucket: matches Qdrant's facet semantics; mcp_server
|
||||
# reconciles missing values into "unknown" via the count diff
|
||||
# (count(*) - sum(facet_values)).
|
||||
return {str(row[0]): int(row[1]) for row in rows if row[0] is not None}
|
||||
|
||||
def drop_table(self, table: str) -> None:
|
||||
self._execute(f"DROP TABLE IF EXISTS {_quote_identifier(table)}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Maintenance (RFC 001)
|
||||
# ------------------------------------------------------------------
|
||||
def has_vector_index(self, table: str) -> bool:
|
||||
rows = self._execute(
|
||||
"SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() "
|
||||
"AND tablename = %s AND indexdef ILIKE %s",
|
||||
[table, "%using hnsw%"],
|
||||
fetch=True,
|
||||
)
|
||||
return bool(rows)
|
||||
|
||||
def try_advisory_lock(self, classid: int, objid: int) -> bool:
|
||||
rows = self._execute("SELECT pg_try_advisory_lock(%s, %s)", [classid, objid], fetch=True)
|
||||
return bool(rows and rows[0] and rows[0][0])
|
||||
|
||||
def advisory_unlock(self, classid: int, objid: int) -> None:
|
||||
self._execute("SELECT pg_advisory_unlock(%s, %s)", [classid, objid], fetch=True)
|
||||
|
||||
def create_hnsw_index(self, table: str) -> None:
|
||||
qi = _quote_identifier(table)
|
||||
idx = _quote_identifier(_hnsw_index_name(table))
|
||||
# Non-concurrent build takes ACCESS EXCLUSIVE for the build duration;
|
||||
# the advisory lock in the caller ensures only one session builds, so
|
||||
# writes are blocked once rather than by every writer that crossed the
|
||||
# threshold (the production wedge this serialization fixes).
|
||||
self._execute(
|
||||
f"CREATE INDEX IF NOT EXISTS {idx} ON {qi} USING hnsw (embedding vector_cosine_ops)"
|
||||
)
|
||||
|
||||
def analyze_table(self, table: str) -> None:
|
||||
self._execute(f"ANALYZE {_quote_identifier(table)}")
|
||||
|
||||
def close(self) -> None:
|
||||
# Terminal: the only caller is PgVectorBackend.close(), after which
|
||||
# the backend refuses to hand the client out again. Without the flag a
|
||||
# stale reference would silently reconnect and leak a session nobody
|
||||
# can ever close.
|
||||
with self._lock:
|
||||
self._closed = True
|
||||
if self._conn is not None:
|
||||
try:
|
||||
self._conn.close()
|
||||
@@ -686,6 +853,14 @@ class PgVectorCollection(BaseCollection):
|
||||
def _marker_exists(self) -> bool:
|
||||
return self._backend._marker_exists(self._palace)
|
||||
|
||||
def get_stored_embedder_identity(self):
|
||||
return self._backend._get_embedder_identity(self._palace, self._collection_name)
|
||||
|
||||
def set_embedder_identity(self, identity) -> None:
|
||||
# Sidecar-backed (see PgVectorBackend), so this records even on a
|
||||
# brand-new palace whose mismatch marker doesn't exist yet.
|
||||
self._backend._set_embedder_identity(self._palace, self._collection_name, identity)
|
||||
|
||||
def _ensure_table(self, dimension: int) -> None:
|
||||
if dimension <= 0:
|
||||
raise ValueError("embedding dimension must be positive")
|
||||
@@ -710,13 +885,56 @@ class PgVectorCollection(BaseCollection):
|
||||
)
|
||||
self._known_dimension = existing_dim or dimension
|
||||
|
||||
def _scroll(self, *, where=None, with_embedding=False) -> list[dict]:
|
||||
def _scroll(
|
||||
self,
|
||||
*,
|
||||
where=None,
|
||||
with_embedding=False,
|
||||
with_document=True,
|
||||
limit=None,
|
||||
offset=None,
|
||||
) -> list[dict]:
|
||||
self._ensure_open()
|
||||
if not self._table_exists():
|
||||
if self._marker_exists():
|
||||
raise CollectionNotInitializedError(self._collection_name)
|
||||
return []
|
||||
return self._client.scroll_rows(self._table, where=where, with_embedding=with_embedding)
|
||||
return self._client.scroll_rows(
|
||||
self._table,
|
||||
where=where,
|
||||
with_embedding=with_embedding,
|
||||
with_document=with_document,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
def get_all_metadata(self, where=None) -> list[dict]:
|
||||
"""Single-pass metadata-only fetch — projects out the document column.
|
||||
|
||||
The base implementation pages through ``get(include=["metadatas"])``,
|
||||
which routes here via ``_scroll`` and (pre-this-override) always sent
|
||||
the ``document`` text over the wire even when nothing consumed it.
|
||||
For pgvector deployments where the client is remote (TLS over WAN),
|
||||
that meant ``mempalace_status`` transferred O(n × document_size)
|
||||
bytes per call, dominating wall time. With ``with_document=False``
|
||||
the SELECT replaces document with NULL, dropping the per-row payload
|
||||
to id + metadata for every caller of this method.
|
||||
|
||||
Filtered fetches still need the ``_matches_where`` post-filter for
|
||||
non-pushdown semantics (array/object values where ``metadata @> ...``
|
||||
is broader than the exact match the caller asked for — same
|
||||
correctness contract as #1840's filtered ``get`` path). Since that
|
||||
post-filter only reads ``metadata``, we keep the single-scroll +
|
||||
``with_document=False`` fast path and just apply the filter locally
|
||||
on the metadata dicts before returning. This extends the wire-byte
|
||||
win to filtered callers as well.
|
||||
"""
|
||||
_validate_where(where)
|
||||
pushdown = None if _requires_local_filter(where) else where
|
||||
rows = self._scroll(where=pushdown, with_document=False)
|
||||
if where is None:
|
||||
return [row["metadata"] for row in rows]
|
||||
return [row["metadata"] for row in rows if _matches_where(row["metadata"], where)]
|
||||
|
||||
def _rows(
|
||||
self,
|
||||
@@ -935,16 +1153,40 @@ class PgVectorCollection(BaseCollection):
|
||||
include=None,
|
||||
) -> GetResult:
|
||||
spec = _IncludeSpec.resolve(include, default_distances=False)
|
||||
rows = self._rows(
|
||||
ids=ids, where=where, where_document=where_document, with_embedding=spec.embeddings
|
||||
# Fast path for the common unfiltered page fetch (e.g.
|
||||
# prefetch_mined_set's sweep): push LIMIT/OFFSET into the scan instead
|
||||
# of fetching the whole table and slicing in Python, which is the
|
||||
# O(rows x pages) cost this avoids. Only the no-filter case is pushed:
|
||||
# the "metadata @> ..." pushdown is broader than the exact
|
||||
# _matches_where re-filter for array/object values, so any filtered get
|
||||
# keeps the full-scan path where that re-filter still runs. ids, where,
|
||||
# where_document and negative bounds all fall through to the unchanged
|
||||
# path below. (The document column is still selected for metadata-only
|
||||
# pages; projecting it out needs the positional _row parser to change,
|
||||
# so it stays a separate follow-up.)
|
||||
push_page = (
|
||||
ids is None
|
||||
and not where
|
||||
and not where_document
|
||||
and (limit is None or limit >= 0)
|
||||
and (offset is None or offset >= 0)
|
||||
and (limit is not None or offset)
|
||||
)
|
||||
if ids is not None:
|
||||
by_id = {row["id"]: row for row in rows}
|
||||
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
|
||||
if offset:
|
||||
rows = rows[offset:]
|
||||
if limit is not None:
|
||||
rows = rows[:limit]
|
||||
if push_page:
|
||||
rows = self._scroll(
|
||||
where=None, with_embedding=spec.embeddings, limit=limit, offset=offset
|
||||
)
|
||||
else:
|
||||
rows = self._rows(
|
||||
ids=ids, where=where, where_document=where_document, with_embedding=spec.embeddings
|
||||
)
|
||||
if ids is not None:
|
||||
by_id = {row["id"]: row for row in rows}
|
||||
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
|
||||
if offset:
|
||||
rows = rows[offset:]
|
||||
if limit is not None:
|
||||
rows = rows[:limit]
|
||||
return GetResult(
|
||||
ids=[row["id"] for row in rows],
|
||||
documents=[row["document"] for row in rows] if spec.documents else [],
|
||||
@@ -976,6 +1218,26 @@ class PgVectorCollection(BaseCollection):
|
||||
return 0
|
||||
return self._client.count_rows(self._table)
|
||||
|
||||
def facet_counts(
|
||||
self,
|
||||
field: str,
|
||||
where: Optional[dict] = None,
|
||||
limit: int = 1000,
|
||||
) -> dict[str, int]:
|
||||
self._ensure_open()
|
||||
# Validate the filter before the existence short-circuit so an
|
||||
# unsupported local-only filter raises even on an unmaterialized
|
||||
# collection — matches the order used by get()/lexical_search() and
|
||||
# qdrant.facet_counts (PR #1868 review).
|
||||
_validate_where(where)
|
||||
if _requires_local_filter(where):
|
||||
raise UnsupportedCapabilityError("facet_counts does not support local-only filters")
|
||||
if not self._table_exists():
|
||||
if self._marker_exists():
|
||||
raise CollectionNotInitializedError(self._collection_name)
|
||||
return {}
|
||||
return self._client.facet_counts(self._table, field=field, where=where, limit=limit)
|
||||
|
||||
def lexical_search(self, *, query: str, n_results: int = 10, where: Optional[dict] = None):
|
||||
_validate_where(where)
|
||||
pushdown = None if _requires_local_filter(where) else where
|
||||
@@ -1008,6 +1270,60 @@ class PgVectorCollection(BaseCollection):
|
||||
return HealthStatus.unhealthy(str(exc))
|
||||
return HealthStatus.healthy()
|
||||
|
||||
def maintenance_state(self) -> dict:
|
||||
empty = {"row_count": 0, "vector_index": None, "index_build_complete": False}
|
||||
self._ensure_open()
|
||||
try:
|
||||
if not self._table_exists():
|
||||
return empty
|
||||
rows = self._client.count_rows(self._table)
|
||||
has_index = self._client.has_vector_index(self._table)
|
||||
except Exception: # noqa: BLE001 - state report must not raise
|
||||
logger.debug("pgvector maintenance state probe failed", exc_info=True)
|
||||
return empty
|
||||
return {
|
||||
"row_count": rows,
|
||||
"vector_index": "hnsw" if has_index else None,
|
||||
"index_build_complete": has_index,
|
||||
}
|
||||
|
||||
def run_maintenance(self, kind: str):
|
||||
from .base import MaintenanceResult, UnsupportedMaintenanceKindError
|
||||
|
||||
if kind not in PgVectorBackend.maintenance_kinds:
|
||||
raise UnsupportedMaintenanceKindError(
|
||||
f"pgvector does not support maintenance kind {kind!r}"
|
||||
)
|
||||
self._ensure_open()
|
||||
# Nothing to maintain on a not-yet-materialized table (collection opened
|
||||
# create=True but never written) — return noop rather than letting a
|
||||
# raw "relation does not exist" error escape.
|
||||
if not self._table_exists():
|
||||
return MaintenanceResult(kind=kind, status="noop", stats={"reason": "no table"})
|
||||
if kind == "analyze":
|
||||
self._client.analyze_table(self._table)
|
||||
return MaintenanceResult(kind="analyze", status="ran")
|
||||
|
||||
# reindex → build the optional HNSW index. Opt-in: it makes search
|
||||
# approximate, trading the exact-scan 100%-recall default for scale.
|
||||
# Serialized with a session advisory lock so concurrent daemon writers
|
||||
# learn "already_running" instead of each stacking an ACCESS EXCLUSIVE
|
||||
# index build.
|
||||
if self._client.has_vector_index(self._table):
|
||||
return MaintenanceResult(kind="reindex", status="noop", stats={"vector_index": "hnsw"})
|
||||
classid, objid = _MAINTENANCE_LOCK_CLASSID, _advisory_objid(self._table)
|
||||
if not self._client.try_advisory_lock(classid, objid):
|
||||
return MaintenanceResult(kind="reindex", status="already_running")
|
||||
try:
|
||||
if self._client.has_vector_index(self._table): # re-check under lock
|
||||
return MaintenanceResult(
|
||||
kind="reindex", status="noop", stats={"vector_index": "hnsw"}
|
||||
)
|
||||
self._client.create_hnsw_index(self._table)
|
||||
return MaintenanceResult(kind="reindex", status="ran", stats={"vector_index": "hnsw"})
|
||||
finally:
|
||||
self._client.advisory_unlock(classid, objid)
|
||||
|
||||
|
||||
class PgVectorBackend(BaseBackend):
|
||||
name = "pgvector"
|
||||
@@ -1019,10 +1335,17 @@ class PgVectorBackend(BaseBackend):
|
||||
"supports_embeddings_out",
|
||||
"supports_metadata_filters",
|
||||
"supports_lexical_search",
|
||||
"supports_metadata_facets",
|
||||
"supports_namespace_isolation",
|
||||
"supports_server_side_indexes",
|
||||
"server_mode",
|
||||
}
|
||||
)
|
||||
# "compact" is omitted: Postgres autovacuum reclaims space automatically,
|
||||
# so a manual VACUUM kind would be redundant. "reindex" builds the optional
|
||||
# HNSW index — an opt-in scale lever, NOT on by default, because it makes
|
||||
# vector search approximate (the exact ``<=>`` scan is the 100%-recall path).
|
||||
maintenance_kinds = frozenset({"analyze", "reindex"})
|
||||
|
||||
def __init__(self):
|
||||
self._clients: dict[_PgVectorConfig, _PgVectorClient] = {}
|
||||
@@ -1140,11 +1463,31 @@ class PgVectorBackend(BaseBackend):
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
# Embedder identity lives in a sidecar, NOT the backend marker: the marker's
|
||||
# presence signals "palace initialized" (reads raise CollectionNotInitialized
|
||||
# when the marker exists but the remote table doesn't), so recording identity
|
||||
# at first empty open must not create it. The sidecar is unguarded — like the
|
||||
# chroma sidecar — so a brand-new palace can record identity immediately.
|
||||
@staticmethod
|
||||
def _embedder_sidecar_path(palace: PalaceRef) -> Optional[str]:
|
||||
if not palace.local_path:
|
||||
return None
|
||||
return os.path.join(palace.local_path, EMBEDDER_SIDECAR_FILENAME)
|
||||
|
||||
def _get_embedder_identity(self, palace: PalaceRef, collection_name: str):
|
||||
return read_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name)
|
||||
|
||||
def _set_embedder_identity(self, palace: PalaceRef, collection_name: str, identity) -> None:
|
||||
write_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name, identity)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _client(self, config: _PgVectorConfig) -> _PgVectorClient:
|
||||
if self._closed:
|
||||
raise BackendClosedError("PgVectorBackend has been closed")
|
||||
with self._lock:
|
||||
# Checked under the lock so a client cannot be created and stored
|
||||
# concurrently with close() clearing the registry (mirrors
|
||||
# SQLiteExactBackend._connect).
|
||||
if self._closed:
|
||||
raise BackendClosedError("PgVectorBackend has been closed")
|
||||
client = self._clients.get(config)
|
||||
if client is None:
|
||||
client = _PgVectorClient(config)
|
||||
|
||||
@@ -24,6 +24,7 @@ from urllib import request as urlrequest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
|
||||
from .base import (
|
||||
BackendClosedError,
|
||||
BackendMismatchError,
|
||||
@@ -39,6 +40,7 @@ from .base import (
|
||||
PalaceNotFoundError,
|
||||
PalaceRef,
|
||||
QueryResult,
|
||||
UnsupportedCapabilityError,
|
||||
UnsupportedFilterError,
|
||||
_IncludeSpec,
|
||||
)
|
||||
@@ -52,6 +54,20 @@ _PAYLOAD_DOCUMENT = "document"
|
||||
_PAYLOAD_METADATA = "metadata"
|
||||
_POINT_NAMESPACE = uuid.UUID("c06c3fc7-5c14-4dc4-84c2-24a5f72d8dc1")
|
||||
_TOKEN_RE = re.compile(r"\w{2,}", re.UNICODE)
|
||||
# Page size for Qdrant's /points/scroll cursor. 4096 (up from the original
|
||||
# 256) cuts REST round-trips ~16x for any full-collection walk (#1796).
|
||||
# Qdrant's own docs suggest larger scroll batches are safe, and this is well
|
||||
# below typical REST payload-size limits for metadata-only (with_vector=False)
|
||||
# scrolls such as get_all_metadata().
|
||||
#
|
||||
# This constant also governs vector-bearing scrolls (with_vector=True), used
|
||||
# by _rows()/get() when embeddings are requested and by _query_local_exact()
|
||||
# for the $or/$contains local-filter query fallback. At 4096 rows per page,
|
||||
# high-dimensional embeddings make those particular responses tens of MB --
|
||||
# Qdrant handles it and round-trips still drop overall, but this is a real
|
||||
# trade-off, not a metadata-only optimization. (Noted in maintainer review
|
||||
# on #1832.)
|
||||
_SCROLL_PAGE_SIZE = 4096
|
||||
_SUPPORTED_OPERATORS = frozenset(
|
||||
{"$eq", "$ne", "$in", "$nin", "$and", "$or", "$contains", "$gt", "$gte", "$lt", "$lte"}
|
||||
)
|
||||
@@ -479,7 +495,7 @@ class _QdrantRESTClient:
|
||||
collection: str,
|
||||
*,
|
||||
qdrant_filter: Optional[dict] = None,
|
||||
limit: int = 256,
|
||||
limit: int = _SCROLL_PAGE_SIZE,
|
||||
offset: Any = None,
|
||||
with_vector: bool = False,
|
||||
) -> tuple[list[dict], Any]:
|
||||
@@ -528,6 +544,38 @@ class _QdrantRESTClient:
|
||||
result = response.get("result") or {}
|
||||
return int(result.get("count") or 0)
|
||||
|
||||
def facet_counts(
|
||||
self,
|
||||
collection: str,
|
||||
*,
|
||||
field: str,
|
||||
qdrant_filter: Optional[dict] = None,
|
||||
limit: int = 1000,
|
||||
) -> dict[str, int]:
|
||||
body: dict[str, Any] = {
|
||||
"key": field,
|
||||
"exact": True,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
if qdrant_filter:
|
||||
body["filter"] = qdrant_filter
|
||||
|
||||
response = self.request(
|
||||
"POST",
|
||||
f"/collections/{urlparse.quote(collection, safe='')}/facet",
|
||||
body=body,
|
||||
)
|
||||
|
||||
result = response.get("result") or {}
|
||||
hits = result.get("hits") or []
|
||||
|
||||
return {
|
||||
str(hit["value"]): int(hit.get("count") or 0)
|
||||
for hit in hits
|
||||
if hit.get("value") is not None
|
||||
}
|
||||
|
||||
def delete_collection(self, collection: str) -> None:
|
||||
self.request("DELETE", f"/collections/{urlparse.quote(collection, safe='')}")
|
||||
|
||||
@@ -661,6 +709,14 @@ class QdrantCollection(BaseCollection):
|
||||
def _marker_exists(self) -> bool:
|
||||
return self._backend._marker_exists(self._palace)
|
||||
|
||||
def get_stored_embedder_identity(self):
|
||||
return self._backend._get_embedder_identity(self._palace, self._collection_name)
|
||||
|
||||
def set_embedder_identity(self, identity) -> None:
|
||||
# Sidecar-backed (see QdrantBackend), so this records even on a
|
||||
# brand-new palace whose mismatch marker doesn't exist yet.
|
||||
self._backend._set_embedder_identity(self._palace, self._collection_name, identity)
|
||||
|
||||
def _remote_dimension(self) -> Optional[int]:
|
||||
try:
|
||||
info = self._client.get_collection_info(self._remote_collection)
|
||||
@@ -723,7 +779,7 @@ class QdrantCollection(BaseCollection):
|
||||
points, offset = self._client.scroll_points(
|
||||
self._remote_collection,
|
||||
qdrant_filter=qdrant_filter,
|
||||
limit=256,
|
||||
limit=_SCROLL_PAGE_SIZE,
|
||||
offset=offset,
|
||||
with_vector=with_vector,
|
||||
)
|
||||
@@ -991,6 +1047,55 @@ class QdrantCollection(BaseCollection):
|
||||
embeddings=[row["embedding"] or [] for row in rows] if spec.embeddings else None,
|
||||
)
|
||||
|
||||
def get_all_metadata(self, where: Optional[dict] = None) -> list[dict]:
|
||||
"""Return every matching record's metadata in one cursor pass (#1796).
|
||||
|
||||
Overrides the default offset-paginated implementation, which would
|
||||
call self.get(limit=, offset=) in a loop -- and since self.get() is
|
||||
backed by a full _scroll_all() materialization, each page of that
|
||||
loop would re-walk the entire collection from the start just to
|
||||
discard everything outside its slice (O(n^2) over collection size).
|
||||
|
||||
Delegates to self._rows(), the same single-scroll-plus-local-filter
|
||||
helper that backs get()/delete(). With ids=None and
|
||||
where_document=None, _rows() reduces to exactly one _scroll_all()
|
||||
pass followed by an unconditional _matches_where() re-check on every
|
||||
row -- the same filter logic get(), delete(), and lexical_search()
|
||||
already use, so this can't independently drift from those call
|
||||
sites. (Maintainer review on #1832: avoid duplicating the filter
|
||||
dance inline.)
|
||||
"""
|
||||
rows = self._rows(where=where)
|
||||
return [row["metadata"] for row in rows]
|
||||
|
||||
def facet_counts(
|
||||
self,
|
||||
field: str,
|
||||
where: Optional[dict] = None,
|
||||
limit: int = 1000,
|
||||
) -> dict[str, int]:
|
||||
self._ensure_open()
|
||||
# Validate the filter before the existence short-circuit so an
|
||||
# unsupported local-only filter raises regardless of whether the
|
||||
# collection has been materialized yet — matching the order used by
|
||||
# get()/lexical_search() above (#1835 review).
|
||||
_validate_where(where)
|
||||
if _requires_local_filter(where):
|
||||
raise UnsupportedCapabilityError("facet_counts does not support local-only filters")
|
||||
if not self._remote_exists():
|
||||
if self._marker_exists():
|
||||
raise CollectionNotInitializedError(self._collection_name)
|
||||
return {}
|
||||
|
||||
q_filter = _qdrant_filter(where)
|
||||
|
||||
return self._client.facet_counts(
|
||||
self._remote_collection,
|
||||
field=f"{_PAYLOAD_METADATA}.{field}",
|
||||
qdrant_filter=q_filter,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def delete(self, *, ids=None, where=None):
|
||||
_validate_where(where)
|
||||
if not self._remote_exists():
|
||||
@@ -1081,6 +1186,7 @@ class QdrantBackend(BaseBackend):
|
||||
"supports_embeddings_out",
|
||||
"supports_metadata_filters",
|
||||
"supports_lexical_search",
|
||||
"supports_metadata_facets",
|
||||
"supports_namespace_isolation",
|
||||
"server_mode",
|
||||
}
|
||||
@@ -1175,6 +1281,23 @@ class QdrantBackend(BaseBackend):
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
# Embedder identity lives in a sidecar, NOT the backend marker: the marker's
|
||||
# presence signals "palace initialized" (reads raise CollectionNotInitialized
|
||||
# when the marker exists but the remote collection doesn't), so recording
|
||||
# identity at first empty open must not create it. The sidecar is unguarded,
|
||||
# so a brand-new palace can record identity immediately.
|
||||
@staticmethod
|
||||
def _embedder_sidecar_path(palace: PalaceRef) -> Optional[str]:
|
||||
if not palace.local_path:
|
||||
return None
|
||||
return os.path.join(palace.local_path, EMBEDDER_SIDECAR_FILENAME)
|
||||
|
||||
def _get_embedder_identity(self, palace: PalaceRef, collection_name: str):
|
||||
return read_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name)
|
||||
|
||||
def _set_embedder_identity(self, palace: PalaceRef, collection_name: str, identity) -> None:
|
||||
write_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name, identity)
|
||||
|
||||
def _client(self, config: _QdrantConfig) -> _QdrantRESTClient:
|
||||
if self._closed:
|
||||
raise BackendClosedError("QdrantBackend has been closed")
|
||||
|
||||
@@ -207,6 +207,7 @@ def resolve_backend_for_palace(
|
||||
def _register_builtins() -> None:
|
||||
"""Register chroma as the in-tree default."""
|
||||
from .chroma import ChromaBackend
|
||||
from .milvus import MilvusBackend
|
||||
from .pgvector import PgVectorBackend
|
||||
from .qdrant import QdrantBackend
|
||||
from .sqlite_exact import SQLiteExactBackend
|
||||
@@ -214,6 +215,8 @@ def _register_builtins() -> None:
|
||||
# Use setdefault semantics so a caller that pre-registered for tests wins.
|
||||
if "chroma" not in _registry:
|
||||
_registry["chroma"] = ChromaBackend
|
||||
if "milvus" not in _registry:
|
||||
_registry["milvus"] = MilvusBackend
|
||||
if "qdrant" not in _registry:
|
||||
_registry["qdrant"] = QdrantBackend
|
||||
if "sqlite_exact" not in _registry:
|
||||
|
||||
@@ -15,12 +15,14 @@ import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .base import (
|
||||
BackendClosedError,
|
||||
BackendError,
|
||||
BaseBackend,
|
||||
BaseCollection,
|
||||
CollectionNotInitializedError,
|
||||
@@ -247,9 +249,23 @@ def _validate_write_batch(
|
||||
|
||||
|
||||
class _SQLiteExactHandle:
|
||||
def __init__(self, conn: sqlite3.Connection, lock: threading.RLock):
|
||||
def __init__(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
lock: threading.RLock,
|
||||
palace_path: str,
|
||||
*,
|
||||
read_only: bool = False,
|
||||
immutable: bool = False,
|
||||
):
|
||||
self.conn = conn
|
||||
self.lock = lock
|
||||
self.palace_path = palace_path
|
||||
self.read_only = read_only
|
||||
# True when opened with ``immutable=1`` because no WAL existed at connect
|
||||
# time. A later writer can create WAL sidecars that this connection will
|
||||
# never see, so the backend must reopen once those files appear.
|
||||
self.immutable = immutable
|
||||
self.closed = False
|
||||
|
||||
|
||||
@@ -264,8 +280,27 @@ class SQLiteExactCollection(BaseCollection):
|
||||
raise BackendClosedError("SQLiteExactCollection has been closed")
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _cursor(self):
|
||||
def _write_lock(self):
|
||||
"""Serialize this handle before taking process-wide writer ownership.
|
||||
|
||||
``mine_palace_lock`` grants cross-thread re-entrant access whenever
|
||||
this process already owns the palace. Taking it before ``handle.lock``
|
||||
lets a waiting thread consume that re-entrant credit, outlive the
|
||||
thread that owns the OS lease, and then mutate after the lease has been
|
||||
released. The handle mutex must therefore be the outer context.
|
||||
"""
|
||||
# Late import avoids a palace.py -> backend -> palace.py cycle.
|
||||
from ..palace import mine_palace_lock
|
||||
|
||||
with self._handle.lock:
|
||||
self._ensure_open()
|
||||
with mine_palace_lock(self._handle.palace_path):
|
||||
yield
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _cursor(self, *, write: bool = False):
|
||||
serialization = self._write_lock() if write else self._handle.lock
|
||||
with serialization:
|
||||
self._ensure_open()
|
||||
cur = self._handle.conn.cursor()
|
||||
try:
|
||||
@@ -322,6 +357,36 @@ class SQLiteExactCollection(BaseCollection):
|
||||
row = cur.execute("SELECT value FROM meta WHERE key = 'fts5_available'").fetchone()
|
||||
return bool(row and row[0] == "1")
|
||||
|
||||
def _embedder_meta_key(self) -> str:
|
||||
return f"embedder_model:{self._collection_name}"
|
||||
|
||||
def get_stored_embedder_identity(self):
|
||||
from .base import EmbedderIdentity
|
||||
|
||||
with self._cursor() as cur:
|
||||
try:
|
||||
cid = self._collection_id(cur)
|
||||
except CollectionNotInitializedError:
|
||||
return None
|
||||
row = cur.execute(
|
||||
"SELECT value FROM meta WHERE key = ?",
|
||||
(self._embedder_meta_key(),),
|
||||
).fetchone()
|
||||
if not row or not row[0]:
|
||||
return None
|
||||
dim = self._collection_dimension(cur, cid) or 0
|
||||
return EmbedderIdentity(model_name=str(row[0]), dimension=int(dim))
|
||||
|
||||
def set_embedder_identity(self, identity) -> None:
|
||||
if not identity or not identity.model_name:
|
||||
return
|
||||
with self._cursor(write=True) as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO meta(key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(self._embedder_meta_key(), str(identity.model_name)),
|
||||
)
|
||||
|
||||
def _replace_fts(self, cur, collection_id: int, doc_id: str, document: str) -> None:
|
||||
if not self._fts_available(cur):
|
||||
return
|
||||
@@ -345,7 +410,7 @@ class SQLiteExactCollection(BaseCollection):
|
||||
raise ValueError("sqlite_exact requires explicit embeddings")
|
||||
metadatas = metadatas or [{} for _ in ids]
|
||||
now = _utcnow()
|
||||
with self._cursor() as cur:
|
||||
with self._cursor(write=True) as cur:
|
||||
collection_id = self._collection_id(cur)
|
||||
prepared = []
|
||||
for doc_id, doc, meta, emb in zip(ids, documents, metadatas, embeddings):
|
||||
@@ -383,7 +448,7 @@ class SQLiteExactCollection(BaseCollection):
|
||||
raise ValueError("sqlite_exact requires explicit embeddings")
|
||||
metadatas = metadatas or [{} for _ in ids]
|
||||
now = _utcnow()
|
||||
with self._cursor() as cur:
|
||||
with self._cursor(write=True) as cur:
|
||||
collection_id = self._collection_id(cur)
|
||||
prepared = []
|
||||
for doc_id, doc, meta, emb in zip(ids, documents, metadatas, embeddings):
|
||||
@@ -427,7 +492,7 @@ class SQLiteExactCollection(BaseCollection):
|
||||
):
|
||||
if value is not None and len(value) != n:
|
||||
raise ValueError(f"{label} length {len(value)} does not match ids length {n}")
|
||||
with self._cursor() as cur:
|
||||
with self._cursor(write=True) as cur:
|
||||
collection_id = self._collection_id(cur)
|
||||
updates = []
|
||||
for idx, doc_id in enumerate(ids):
|
||||
@@ -466,19 +531,32 @@ class SQLiteExactCollection(BaseCollection):
|
||||
)
|
||||
self._replace_fts(cur, collection_id, doc_id, doc)
|
||||
|
||||
def _rows(self, cur, *, where=None, where_document=None) -> list[dict]:
|
||||
def _rows(self, cur, *, where=None, where_document=None, limit=None, offset=None) -> list[dict]:
|
||||
_validate_where(where)
|
||||
_validate_where(where_document)
|
||||
collection_id = self._collection_id(cur)
|
||||
rows = cur.execute(
|
||||
"""
|
||||
SELECT id, document, metadata_json, embedding
|
||||
FROM documents
|
||||
WHERE collection_id = ?
|
||||
ORDER BY rowid
|
||||
""",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
sql = (
|
||||
"SELECT id, document, metadata_json, embedding\n"
|
||||
"FROM documents\n"
|
||||
"WHERE collection_id = ?\n"
|
||||
"ORDER BY rowid"
|
||||
)
|
||||
params = [collection_id]
|
||||
# Emit SQL LIMIT/OFFSET only on an unfiltered page. With a
|
||||
# where/where_document the post-filter loop below drops rows *after*
|
||||
# this scan, so a SQL LIMIT/OFFSET would cut the wrong rows; those
|
||||
# callers scan in full and paginate in Python. SQLite requires a LIMIT
|
||||
# before OFFSET, so an offset-only page uses "LIMIT -1" (unbounded).
|
||||
if where is None and where_document is None and (limit is not None or offset):
|
||||
if limit is not None:
|
||||
sql += "\nLIMIT ?"
|
||||
params.append(int(limit))
|
||||
elif offset:
|
||||
sql += "\nLIMIT -1"
|
||||
if offset:
|
||||
sql += "\nOFFSET ?"
|
||||
params.append(int(offset))
|
||||
rows = cur.execute(sql, params).fetchall()
|
||||
out = []
|
||||
for doc_id, doc, meta_json, emb_blob in rows:
|
||||
meta = _json_loads(meta_json)
|
||||
@@ -573,15 +651,33 @@ class SQLiteExactCollection(BaseCollection):
|
||||
include=None,
|
||||
) -> GetResult:
|
||||
spec = _IncludeSpec.resolve(include, default_distances=False)
|
||||
# Fast path for the common unfiltered page (e.g. the prefetch_mined_set
|
||||
# and status sweeps): push LIMIT/OFFSET into the scan instead of
|
||||
# materializing the whole collection and slicing in Python. Safe only
|
||||
# with no post-filter (ids/where/where_document drop rows after the
|
||||
# scan) and non-negative bounds: SQLite does not honor a negative LIMIT
|
||||
# or OFFSET the way a Python slice does, so those keep the slice path.
|
||||
push_page = (
|
||||
ids is None
|
||||
and where is None
|
||||
and where_document is None
|
||||
and (limit is None or limit >= 0)
|
||||
and (offset is None or offset >= 0)
|
||||
and (limit is not None or offset)
|
||||
)
|
||||
with self._cursor() as cur:
|
||||
rows = self._rows(cur, where=where, where_document=where_document)
|
||||
if ids is not None:
|
||||
by_id = {row["id"]: row for row in rows}
|
||||
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
|
||||
if offset:
|
||||
rows = rows[offset:]
|
||||
if limit is not None:
|
||||
rows = rows[:limit]
|
||||
if push_page:
|
||||
rows = self._rows(cur, limit=limit, offset=offset)
|
||||
else:
|
||||
rows = self._rows(cur, where=where, where_document=where_document)
|
||||
if not push_page:
|
||||
if ids is not None:
|
||||
by_id = {row["id"]: row for row in rows}
|
||||
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
|
||||
if offset:
|
||||
rows = rows[offset:]
|
||||
if limit is not None:
|
||||
rows = rows[:limit]
|
||||
return GetResult(
|
||||
ids=[row["id"] for row in rows],
|
||||
documents=[row["document"] for row in rows] if spec.documents else [],
|
||||
@@ -592,7 +688,7 @@ class SQLiteExactCollection(BaseCollection):
|
||||
)
|
||||
|
||||
def delete(self, *, ids=None, where=None):
|
||||
with self._cursor() as cur:
|
||||
with self._cursor(write=True) as cur:
|
||||
collection_id = self._collection_id(cur)
|
||||
if ids is None:
|
||||
rows = self._rows(cur, where=where)
|
||||
@@ -710,6 +806,62 @@ class SQLiteExactCollection(BaseCollection):
|
||||
return HealthStatus.unhealthy("collection closed")
|
||||
return HealthStatus.healthy()
|
||||
|
||||
def maintenance_state(self) -> dict:
|
||||
try:
|
||||
rows = self.count()
|
||||
except Exception:
|
||||
rows = 0
|
||||
# vector_index is null by design — exact cosine over every row, no ANN.
|
||||
state = {"row_count": rows, "vector_index": None}
|
||||
try:
|
||||
with self._cursor() as cur:
|
||||
page_count = cur.execute("PRAGMA page_count").fetchone()
|
||||
freelist = cur.execute("PRAGMA freelist_count").fetchone()
|
||||
state["page_count"] = int(page_count[0]) if page_count else 0
|
||||
state["freelist_pages"] = int(freelist[0]) if freelist else 0
|
||||
except Exception:
|
||||
pass
|
||||
return state
|
||||
|
||||
def run_maintenance(self, kind: str):
|
||||
from .base import MaintenanceResult, UnsupportedMaintenanceKindError
|
||||
|
||||
if kind not in SQLiteExactBackend.maintenance_kinds:
|
||||
raise UnsupportedMaintenanceKindError(
|
||||
f"sqlite_exact does not support maintenance kind {kind!r}"
|
||||
)
|
||||
if kind == "analyze":
|
||||
# Refresh planner stats. Concurrent runs serialize on the handle lock.
|
||||
with self._cursor(write=True) as cur:
|
||||
cur.execute("ANALYZE")
|
||||
return MaintenanceResult(kind="analyze", status="ran")
|
||||
|
||||
# compact → VACUUM. It cannot run inside a transaction, so flip the
|
||||
# connection to autocommit for the duration. _write_lock takes the
|
||||
# handle mutex before the palace lease so a waiting thread cannot
|
||||
# retain stale process-reentrant ownership after another thread exits.
|
||||
before = self.maintenance_state()
|
||||
with self._write_lock():
|
||||
conn = self._handle.conn
|
||||
prev_isolation = conn.isolation_level
|
||||
try:
|
||||
conn.commit()
|
||||
conn.isolation_level = None
|
||||
conn.execute("VACUUM")
|
||||
finally:
|
||||
conn.isolation_level = prev_isolation
|
||||
after = self.maintenance_state()
|
||||
reclaimed = max(0, before.get("page_count", 0) - after.get("page_count", 0))
|
||||
return MaintenanceResult(
|
||||
kind="compact",
|
||||
status="ran",
|
||||
stats={
|
||||
"pages_before": before.get("page_count", 0),
|
||||
"pages_after": after.get("page_count", 0),
|
||||
"pages_reclaimed": reclaimed,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SQLiteExactBackend(BaseBackend):
|
||||
name = "sqlite_exact"
|
||||
@@ -724,9 +876,13 @@ class SQLiteExactBackend(BaseBackend):
|
||||
"local_mode",
|
||||
}
|
||||
)
|
||||
# "reindex" is intentionally omitted: sqlite_exact does exact cosine over
|
||||
# every row (no ANN index to build), so it has no analogue for it.
|
||||
maintenance_kinds = frozenset({"analyze", "compact"})
|
||||
|
||||
def __init__(self):
|
||||
self._clients: dict[str, _SQLiteExactHandle] = {}
|
||||
self._read_only_clients: dict[str, _SQLiteExactHandle] = {}
|
||||
self._clients_lock = threading.RLock()
|
||||
self._closed = False
|
||||
|
||||
@@ -734,9 +890,64 @@ class SQLiteExactBackend(BaseBackend):
|
||||
def _db_path(palace_path: str) -> str:
|
||||
return os.path.join(palace_path, _DB_FILENAME)
|
||||
|
||||
def _connect(self, palace_path: str, create: bool):
|
||||
@staticmethod
|
||||
def _wal_sidecar_state(db_path: str) -> tuple[bool, bool]:
|
||||
return (
|
||||
os.path.isfile(f"{db_path}-wal"),
|
||||
os.path.isfile(f"{db_path}-shm"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _connect_read_only(db_path: str) -> tuple[sqlite3.Connection, bool]:
|
||||
"""Open without creating WAL files while preserving an active WAL.
|
||||
|
||||
Returns ``(connection, immutable)``. ``immutable`` is True when the
|
||||
database was clean (no WAL) and was opened with ``immutable=1``.
|
||||
"""
|
||||
wal_exists, shm_exists = SQLiteExactBackend._wal_sidecar_state(db_path)
|
||||
if wal_exists != shm_exists:
|
||||
raise BackendError(
|
||||
"sqlite_exact read-only open found an incomplete WAL sidecar set; "
|
||||
"open the palace after its writer exits cleanly or restore both "
|
||||
"the -wal and -shm files"
|
||||
)
|
||||
|
||||
db_uri = Path(db_path).resolve().as_uri()
|
||||
if wal_exists:
|
||||
# An active writer's uncheckpointed rows live in the WAL. With both
|
||||
# sidecars already present, mode=ro can read them without creating
|
||||
# filesystem state, including on a read-only mount.
|
||||
db_uri = f"{db_uri}?mode=ro"
|
||||
immutable = False
|
||||
else:
|
||||
# A clean WAL-mode database would otherwise make SQLite create new
|
||||
# -wal/-shm files while connecting. Immutable mode is safe here
|
||||
# only until a writer creates sidecars this connection would miss.
|
||||
db_uri = f"{db_uri}?mode=ro&immutable=1"
|
||||
immutable = True
|
||||
return sqlite3.connect(db_uri, uri=True, check_same_thread=False), immutable
|
||||
|
||||
def _retire_read_only_handle(self, palace_path: str, handle: _SQLiteExactHandle) -> None:
|
||||
"""Drop a cached read-only handle so the next open re-evaluates WAL state."""
|
||||
self._read_only_clients.pop(palace_path, None)
|
||||
with handle.lock:
|
||||
if handle.closed:
|
||||
return
|
||||
handle.closed = True
|
||||
try:
|
||||
handle.conn.close()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to close stale immutable sqlite_exact reader for %s",
|
||||
palace_path,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _connect(self, palace_path: str, create: bool, *, read_only: bool = False):
|
||||
if self._closed:
|
||||
raise BackendClosedError("SQLiteExactBackend has been closed")
|
||||
if create and read_only:
|
||||
raise ValueError("sqlite_exact read-only connections cannot create a palace")
|
||||
db_path = self._db_path(palace_path)
|
||||
if not create and not os.path.isfile(db_path):
|
||||
raise PalaceNotFoundError(db_path)
|
||||
@@ -746,19 +957,63 @@ class SQLiteExactBackend(BaseBackend):
|
||||
os.chmod(palace_path, 0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
# Hold the registry lock across cache-check + connect + schema init:
|
||||
# two threads first-opening the same palace must not each create a
|
||||
# connection (the loser leaked unclosed and outlived close()) nor run
|
||||
# _init_schema concurrently on a fresh file, which surfaces transient
|
||||
# "database is locked" errors before WAL mode is established. Only
|
||||
# first-open pays for the I/O under the lock; cache hits are a dict
|
||||
# probe.
|
||||
with self._clients_lock:
|
||||
cached = self._clients.get(palace_path)
|
||||
if self._closed:
|
||||
raise BackendClosedError("SQLiteExactBackend has been closed")
|
||||
clients = self._read_only_clients if read_only else self._clients
|
||||
cached = clients.get(palace_path)
|
||||
if cached is not None and not cached.closed:
|
||||
return cached
|
||||
conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
lock = threading.RLock()
|
||||
handle = _SQLiteExactHandle(conn, lock)
|
||||
with handle.lock:
|
||||
self._init_schema(conn)
|
||||
with self._clients_lock:
|
||||
self._clients[palace_path] = handle
|
||||
return handle
|
||||
if read_only and cached.immutable:
|
||||
# An immutable snapshot freezes the clean-database view.
|
||||
# Reopen only when the complete WAL sidecar pair is present
|
||||
# (active writer). A partial pair is a transient mid-open
|
||||
# state — keep the immutable handle rather than forcing a
|
||||
# reconnect that would raise on the incomplete set.
|
||||
wal_exists, shm_exists = self._wal_sidecar_state(db_path)
|
||||
if wal_exists and shm_exists:
|
||||
self._retire_read_only_handle(palace_path, cached)
|
||||
cached = None
|
||||
if cached is not None:
|
||||
return cached
|
||||
if read_only:
|
||||
conn, immutable = self._connect_read_only(db_path)
|
||||
else:
|
||||
conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
immutable = False
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
lock = threading.RLock()
|
||||
handle = _SQLiteExactHandle(
|
||||
conn,
|
||||
lock,
|
||||
palace_path,
|
||||
read_only=read_only,
|
||||
immutable=immutable,
|
||||
)
|
||||
with handle.lock:
|
||||
if read_only:
|
||||
# ``mode=ro`` prevents filesystem writes. ``query_only``
|
||||
# adds a second connection-local guard so a future
|
||||
# refactor cannot accidentally introduce a temp/schema
|
||||
# write through a read-only MCP collection.
|
||||
conn.execute("PRAGMA query_only=ON")
|
||||
else:
|
||||
from ..palace import mine_palace_lock
|
||||
|
||||
with mine_palace_lock(palace_path):
|
||||
self._init_schema(conn)
|
||||
except BaseException:
|
||||
conn.close()
|
||||
raise
|
||||
clients[palace_path] = handle
|
||||
return handle
|
||||
|
||||
def _init_schema(self, conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(
|
||||
@@ -822,13 +1077,14 @@ class SQLiteExactBackend(BaseBackend):
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> SQLiteExactCollection:
|
||||
palace, collection_name, create = self._normalize_args(args, kwargs)
|
||||
palace, collection_name, create, read_only = self._normalize_args(args, kwargs)
|
||||
self.require_namespace_support(palace)
|
||||
palace_path = palace.local_path
|
||||
if palace_path is None:
|
||||
raise PalaceNotFoundError("SQLiteExactBackend requires PalaceRef.local_path")
|
||||
if not create and not os.path.isdir(palace_path):
|
||||
raise PalaceNotFoundError(palace_path)
|
||||
handle = self._connect(palace_path, create=create)
|
||||
handle = self._connect(palace_path, create=create, read_only=read_only)
|
||||
with handle.lock:
|
||||
row = handle.conn.execute(
|
||||
"SELECT id FROM collections WHERE name = ?",
|
||||
@@ -837,11 +1093,14 @@ class SQLiteExactBackend(BaseBackend):
|
||||
if row is None:
|
||||
if not create:
|
||||
raise CollectionNotInitializedError(collection_name)
|
||||
handle.conn.execute(
|
||||
"INSERT INTO collections(name, created_at) VALUES (?, ?)",
|
||||
(collection_name, _utcnow()),
|
||||
)
|
||||
handle.conn.commit()
|
||||
from ..palace import mine_palace_lock
|
||||
|
||||
with mine_palace_lock(palace_path):
|
||||
handle.conn.execute(
|
||||
"INSERT INTO collections(name, created_at) VALUES (?, ?)",
|
||||
(collection_name, _utcnow()),
|
||||
)
|
||||
handle.conn.commit()
|
||||
return SQLiteExactCollection(handle, collection_name)
|
||||
|
||||
@staticmethod
|
||||
@@ -852,10 +1111,11 @@ class SQLiteExactBackend(BaseBackend):
|
||||
raise TypeError("palace= must be a PalaceRef instance")
|
||||
collection_name = kwargs.pop("collection_name")
|
||||
create = bool(kwargs.pop("create", False))
|
||||
kwargs.pop("options", None)
|
||||
options = kwargs.pop("options", None) or {}
|
||||
read_only = bool(options.get("read_only", False))
|
||||
if args or kwargs:
|
||||
raise TypeError("unexpected arguments to get_collection")
|
||||
return palace, collection_name, create
|
||||
return palace, collection_name, create, read_only
|
||||
if args:
|
||||
palace_path = args[0]
|
||||
rest = list(args[1:])
|
||||
@@ -863,18 +1123,32 @@ class SQLiteExactBackend(BaseBackend):
|
||||
if collection_name is None:
|
||||
raise TypeError("collection_name is required")
|
||||
create = kwargs.pop("create", False)
|
||||
options = kwargs.pop("options", None) or {}
|
||||
read_only = bool(options.get("read_only", False))
|
||||
if rest:
|
||||
create = rest.pop(0)
|
||||
if rest or kwargs:
|
||||
raise TypeError("unexpected arguments to get_collection")
|
||||
return PalaceRef(id=palace_path, local_path=palace_path), collection_name, bool(create)
|
||||
return (
|
||||
PalaceRef(id=palace_path, local_path=palace_path),
|
||||
collection_name,
|
||||
bool(create),
|
||||
read_only,
|
||||
)
|
||||
if "palace_path" in kwargs:
|
||||
palace_path = kwargs.pop("palace_path")
|
||||
collection_name = kwargs.pop("collection_name")
|
||||
create = bool(kwargs.pop("create", False))
|
||||
options = kwargs.pop("options", None) or {}
|
||||
read_only = bool(options.get("read_only", False))
|
||||
if kwargs:
|
||||
raise TypeError("unexpected arguments to get_collection")
|
||||
return PalaceRef(id=palace_path, local_path=palace_path), collection_name, create
|
||||
return (
|
||||
PalaceRef(id=palace_path, local_path=palace_path),
|
||||
collection_name,
|
||||
create,
|
||||
read_only,
|
||||
)
|
||||
raise TypeError("get_collection requires palace= or a positional palace_path")
|
||||
|
||||
def close_palace(self, palace: PalaceRef | str) -> None:
|
||||
@@ -882,21 +1156,31 @@ class SQLiteExactBackend(BaseBackend):
|
||||
if path is None:
|
||||
return
|
||||
with self._clients_lock:
|
||||
cached = self._clients.pop(path, None)
|
||||
if cached is not None:
|
||||
with cached.lock:
|
||||
cached.closed = True
|
||||
cached.conn.close()
|
||||
cached_handles = [
|
||||
self._clients.pop(path, None),
|
||||
self._read_only_clients.pop(path, None),
|
||||
]
|
||||
for cached in cached_handles:
|
||||
if cached is not None:
|
||||
with cached.lock:
|
||||
cached.closed = True
|
||||
cached.conn.close()
|
||||
|
||||
def close(self) -> None:
|
||||
# Flip _closed under the registry lock so a concurrent _connect either
|
||||
# sees the flag or finishes before the handle snapshot is taken; a
|
||||
# connection can no longer slip into the registry after close().
|
||||
# Unlocked readers of _closed elsewhere are advisory fast-fails; the
|
||||
# locked recheck in _connect is the authoritative gate.
|
||||
with self._clients_lock:
|
||||
handles = list(self._clients.values())
|
||||
handles = list(self._clients.values()) + list(self._read_only_clients.values())
|
||||
self._clients.clear()
|
||||
self._read_only_clients.clear()
|
||||
self._closed = True
|
||||
for handle in handles:
|
||||
with handle.lock:
|
||||
handle.closed = True
|
||||
handle.conn.close()
|
||||
self._closed = True
|
||||
|
||||
def health(self, palace: Optional[PalaceRef] = None) -> HealthStatus:
|
||||
if self._closed:
|
||||
@@ -907,7 +1191,23 @@ class SQLiteExactBackend(BaseBackend):
|
||||
|
||||
@classmethod
|
||||
def detect(cls, path: str) -> bool:
|
||||
return os.path.isfile(os.path.join(path, _DB_FILENAME))
|
||||
"""Return True when ``path`` looks like a sqlite_exact palace.
|
||||
|
||||
Verifies the SQLite magic header rather than file presence alone, for
|
||||
the same reason as :py:meth:`mempalace.backends.chroma.ChromaBackend.detect`:
|
||||
bare ``sqlite3.connect()`` against a missing path leaves a 0-byte file
|
||||
behind because the SQLite header is written on the first statement,
|
||||
not on connection. The 16-byte ``SQLite format 3\\x00`` magic prefix
|
||||
accepts every real palace while rejecting empty / garbage files. See #1893.
|
||||
"""
|
||||
db_path = os.path.join(path, _DB_FILENAME)
|
||||
if not os.path.isfile(db_path):
|
||||
return False
|
||||
try:
|
||||
with open(db_path, "rb") as f:
|
||||
return f.read(16) == b"SQLite format 3\x00"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def create_collection(self, palace_path: str, collection_name: str) -> SQLiteExactCollection:
|
||||
return self.get_collection(palace_path, collection_name, create=True)
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Writing and pruning palace backups.
|
||||
|
||||
``mempalace migrate`` and ``mempalace repair max-seq-id`` each write a fresh,
|
||||
timestamped backup every time they run and historically never deleted the old
|
||||
ones. On a machine that mines or repairs on a schedule those full-size copies
|
||||
accumulate silently — a real palace was found with hundreds of gigabytes of
|
||||
backups sitting beside only a few hundred megabytes of live data, nearly
|
||||
filling the disk. This module prunes the backup set down to a bounded count
|
||||
after each new backup is written.
|
||||
|
||||
The retention count comes from ``MempalaceConfig.max_backups`` (default 10).
|
||||
|
||||
``copy_palace_dir`` is the other half: the whole-directory copy that
|
||||
``mempalace repair`` in its default mode and ``mempalace migrate`` take before
|
||||
they overwrite a live palace. The other backup paths copy a single file and do
|
||||
not use it.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
|
||||
|
||||
# Names for the file types a backup copy deliberately leaves behind. A socket
|
||||
# or a named pipe makes ``shutil.copytree`` record an error for that entry and
|
||||
# raise at the end. Device nodes do not, but dereferencing one copies the
|
||||
# DEVICE instead of palace data, and a link to ``/dev/zero`` is copied without
|
||||
# bound, so they are left out for the opposite reason: the copy would succeed
|
||||
# at the wrong thing.
|
||||
_UNCOPYABLE_FILE_TYPES = (
|
||||
(stat.S_ISSOCK, "socket"),
|
||||
(stat.S_ISFIFO, "named pipe"),
|
||||
(stat.S_ISCHR, "character device"),
|
||||
(stat.S_ISBLK, "block device"),
|
||||
)
|
||||
|
||||
_UNNAMED_FILE_TYPE = "not a regular file or directory"
|
||||
|
||||
|
||||
def _file_type_label(mode):
|
||||
"""Name the file type in ``mode``, falling back to a generic phrase.
|
||||
|
||||
The fallback covers a file type this table has no name for. Nothing on
|
||||
Linux reaches it, because ``stat`` there reports ``S_IFDOOR``,
|
||||
``S_IFPORT`` and ``S_IFWHT`` as 0. macOS does define ``S_IFWHT``, a
|
||||
union-mount whiteout, which is precisely what the fallback exists for:
|
||||
an entry no copy can carry is skipped with an honest reason rather than
|
||||
read as copyable.
|
||||
"""
|
||||
for is_type, label in _UNCOPYABLE_FILE_TYPES:
|
||||
if is_type(mode):
|
||||
return label
|
||||
return _UNNAMED_FILE_TYPE
|
||||
|
||||
|
||||
def _uncopyable_reason(path, *, follow_symlinks):
|
||||
"""Name why ``path`` cannot be copied into a backup, or return ``None``.
|
||||
|
||||
This is an allowlist, matching how the rest of the package treats
|
||||
directory entries: only regular files and directories are copyable, and
|
||||
everything else is named and left out.
|
||||
|
||||
Args:
|
||||
path: The directory entry to classify.
|
||||
follow_symlinks: Mirrors the copy's own link handling. ``True`` when
|
||||
the copy dereferences links, so the TARGET's file type decides;
|
||||
``False`` when it recreates them as links, in which case any
|
||||
symlink is fine and its target is never read.
|
||||
|
||||
Returns:
|
||||
A short human-readable reason, or ``None`` both when the entry is
|
||||
copyable and when its type could not be established.
|
||||
|
||||
Those two ``None`` cases are deliberately the same answer: only a file
|
||||
type this could read names a reason. An entry whose type it could not
|
||||
read is left for the copy to attempt and, if it really is broken, to
|
||||
fail on.
|
||||
|
||||
A failed ``stat`` therefore never becomes a reason to skip. It says the
|
||||
entry cannot be resolved right now, which is not the same as holding no
|
||||
data, and no errno separates the two: a symlink into a volume that is
|
||||
not mounted fails exactly like one whose target was deleted, and on
|
||||
Windows an unmapped drive letter and an unreachable network share both
|
||||
arrive as ``ENOENT`` as well.
|
||||
"""
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
if not follow_symlinks:
|
||||
return None
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if stat.S_ISREG(st.st_mode) or stat.S_ISDIR(st.st_mode):
|
||||
return None
|
||||
return _file_type_label(st.st_mode)
|
||||
|
||||
|
||||
def _report_skipped(skipped, src, log):
|
||||
"""Tell the operator what the copy left out.
|
||||
|
||||
Every step that can fail is guarded on its own, because this also runs
|
||||
after the copy has failed: both callers pass ``print``, so one line the
|
||||
terminal will not take must cost neither the copy's own outcome nor the
|
||||
lines after it.
|
||||
"""
|
||||
noun = "entry" if len(skipped) == 1 else "entries"
|
||||
lines = [f" Backup: skipped {len(skipped)} {noun} that cannot be copied:"]
|
||||
for path, reason in skipped:
|
||||
try:
|
||||
name = os.path.relpath(path, src)
|
||||
except (OSError, ValueError):
|
||||
# On Windows ``relpath`` rejects paths on different drives; for a
|
||||
# relative ``src`` it also reaches ``os.getcwd()``, which can fail
|
||||
# on a deleted directory. Neither costs us the entry's name.
|
||||
name = path
|
||||
lines.append(f" {name} ({reason})")
|
||||
|
||||
for line in lines:
|
||||
try:
|
||||
log(line)
|
||||
except UnicodeError:
|
||||
# An stdout that cannot encode this entry's name. Dropping the
|
||||
# line would leave a header whose count disagrees with the list
|
||||
# under it, so the name is escaped and retried before it is
|
||||
# given up on.
|
||||
try:
|
||||
log(line.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
except (OSError, UnicodeError):
|
||||
continue
|
||||
except OSError:
|
||||
# A write that the device refused. A short report is buffered
|
||||
# whole, so this arrives only once the report is long enough to
|
||||
# flush partway through, and then the header is already out.
|
||||
# Anything else is a caller passing something that is not a
|
||||
# working ``log``, which should surface rather than be swallowed.
|
||||
continue
|
||||
|
||||
|
||||
def copy_palace_dir(src, dst, *, symlinks=False, log=None):
|
||||
"""Copy a palace directory to ``dst``, skipping entries no copy can carry.
|
||||
|
||||
``shutil.copytree`` finishes the copy but raises ``shutil.Error`` at the
|
||||
end when a directory entry is not something it knows how to duplicate: a
|
||||
Unix domain socket, or a named pipe. The caller has to treat that as a
|
||||
failed backup, so the command died before the rebuild the backup was
|
||||
guarding (#2207). Such entries are runtime artifacts of whatever process
|
||||
created them and never hold palace data, so a backup without them is
|
||||
still a complete backup of the palace.
|
||||
|
||||
Args:
|
||||
src: Palace directory to copy.
|
||||
dst: Destination path. Must not already exist.
|
||||
symlinks: Passed to ``shutil.copytree``. ``True`` recreates symlinks
|
||||
as symlinks, ``False`` copies what they point at.
|
||||
log: Optional callable (e.g. ``print``) for human-readable progress.
|
||||
|
||||
Returns:
|
||||
The list of ``(path, reason)`` pairs that were skipped: sorted within
|
||||
each directory, in the order the copy visited the directories. Like
|
||||
``prune_backups``, this both returns what it did and logs it.
|
||||
|
||||
Every other copy failure still raises out of ``shutil.copytree``. A caller
|
||||
about to overwrite the live palace must still stop when its safety copy
|
||||
did not come out whole, so this narrows what the copy attempts rather
|
||||
than swallowing what it reports.
|
||||
|
||||
``shutil.copytree`` hands the callback names rather than the ``os.scandir``
|
||||
entries it already holds, so classifying costs one extra ``os.lstat`` per
|
||||
directory entry, and it classifies a whole directory before copying any of
|
||||
it. An entry whose type changes inside that window is handled by the
|
||||
earlier reading, which cuts both ways: one that became a socket still
|
||||
aborts the copy, and one that was a socket and became a regular file is
|
||||
skipped with its contents. Nothing in MemPalace replaces an entry that
|
||||
way, and the skipped name is printed either way.
|
||||
"""
|
||||
skipped = []
|
||||
# ``shutil.copytree`` classifies the top directory before it creates the
|
||||
# destination, so a copy that never starts still produces a skip list.
|
||||
# Reporting one would tell the operator what a nonexistent backup is
|
||||
# missing, so both ways that happens are excluded below: a destination
|
||||
# already occupied (this snapshot), and a destination that could not be
|
||||
# created at all.
|
||||
dst_existed = os.path.lexists(dst)
|
||||
|
||||
def _ignore(directory, names):
|
||||
ignored = set()
|
||||
found = []
|
||||
for name in names:
|
||||
path = os.path.join(directory, name)
|
||||
reason = _uncopyable_reason(path, follow_symlinks=not symlinks)
|
||||
if reason is not None:
|
||||
ignored.add(name)
|
||||
found.append((path, reason))
|
||||
skipped.extend(sorted(found))
|
||||
return ignored
|
||||
|
||||
def _report():
|
||||
if skipped and log and not dst_existed and os.path.isdir(dst):
|
||||
_report_skipped(skipped, src, log)
|
||||
|
||||
try:
|
||||
shutil.copytree(src, dst, symlinks=symlinks, ignore=_ignore)
|
||||
except BaseException:
|
||||
# Reported for a failed copy too, including an interrupted one: a
|
||||
# half-written backup is exactly when the operator needs to know what
|
||||
# was left out of it. But the copy's own failure is what the caller
|
||||
# has to diagnose, so a report that fails on top of it is dropped
|
||||
# rather than raised in its place.
|
||||
try:
|
||||
_report()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
# Not suppressed here: with the backup complete and nothing destructive
|
||||
# done yet, a ``log`` that cannot be called at all is a caller's bug worth
|
||||
# hearing about. The terminal failures inside ``_report_skipped`` are
|
||||
# absorbed on both paths.
|
||||
_report()
|
||||
return skipped
|
||||
|
||||
|
||||
def prune_backups(pattern, max_backups, *, log=None):
|
||||
"""Delete the oldest backups matching ``pattern`` so at most ``max_backups`` remain.
|
||||
|
||||
Args:
|
||||
pattern: A glob pattern matching the backup paths (files or
|
||||
directories). The caller is responsible for ``glob.escape``-ing
|
||||
any literal, non-wildcard portion that can contain glob
|
||||
metacharacters — palace paths sometimes do (e.g. a ``[``).
|
||||
max_backups: Number of most-recent backups to keep. ``None`` or any
|
||||
value ``<= 0`` disables pruning and returns immediately, so a
|
||||
backup set is never touched when the user has opted out.
|
||||
log: Optional callable (e.g. ``print``) for human-readable progress.
|
||||
|
||||
Returns:
|
||||
The list of paths that were successfully removed.
|
||||
|
||||
Recency is determined by filesystem mtime rather than by parsing the
|
||||
timestamp out of the name, so it stays correct even when two backup
|
||||
producers use different timestamp formats. Deletion failures are logged
|
||||
and skipped: pruning is best-effort cleanup and must never abort the
|
||||
migrate/repair operation that just completed successfully.
|
||||
"""
|
||||
if max_backups is None or max_backups <= 0:
|
||||
return []
|
||||
|
||||
scored = []
|
||||
for path in glob.glob(pattern):
|
||||
try:
|
||||
scored.append((os.path.getmtime(path), path))
|
||||
except OSError:
|
||||
# Vanished between glob and stat (concurrent prune / cleanup);
|
||||
# nothing for us to remove.
|
||||
continue
|
||||
|
||||
if len(scored) <= max_backups:
|
||||
return []
|
||||
|
||||
# Newest first; the path breaks mtime ties so ordering is deterministic.
|
||||
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||||
|
||||
removed = []
|
||||
for _mtime, path in scored[max_backups:]:
|
||||
try:
|
||||
if os.path.isdir(path) and not os.path.islink(path):
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
os.remove(path)
|
||||
except OSError as exc:
|
||||
if log:
|
||||
log(f" Backup prune: could not remove {path}: {exc}")
|
||||
continue
|
||||
removed.append(path)
|
||||
if log:
|
||||
log(f" Backup prune: removed old backup {path}")
|
||||
|
||||
return removed
|
||||
+1450
-42
File diff suppressed because it is too large
Load Diff
+28
-2
@@ -36,6 +36,7 @@ No vendor lock-in. No hidden dependency on any specific provider. Zero deps
|
||||
added to pyproject — uses stdlib urllib.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -51,6 +52,7 @@ from .palace import (
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
mine_lock,
|
||||
mine_palace_lock,
|
||||
purge_file_closets,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
@@ -225,8 +227,31 @@ def regenerate_closets(
|
||||
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)
|
||||
# A full regeneration is one long-lived read/LLM/purge/upsert operation.
|
||||
# Acquire ownership before opening either collection or calling the LLM so
|
||||
# a conflicting local writer fails immediately. The palace lock is
|
||||
# process-wide and re-entrant, so daemon/MCP-owned calls compose safely.
|
||||
lease = contextlib.nullcontext() if dry_run else mine_palace_lock(palace_path)
|
||||
with lease:
|
||||
return _regenerate_closets_owned(
|
||||
palace_path,
|
||||
wing=wing,
|
||||
sample=sample,
|
||||
dry_run=dry_run,
|
||||
cfg=cfg,
|
||||
)
|
||||
|
||||
|
||||
def _regenerate_closets_owned(
|
||||
palace_path,
|
||||
*,
|
||||
wing,
|
||||
sample,
|
||||
dry_run,
|
||||
cfg: LLMConfig,
|
||||
):
|
||||
drawers_col = get_collection(palace_path, create=False, read_only=dry_run)
|
||||
closets_col = None if dry_run else get_closets_collection(palace_path)
|
||||
|
||||
total = drawers_col.count()
|
||||
if total == 0:
|
||||
@@ -303,6 +328,7 @@ def regenerate_closets(
|
||||
# otherwise a regex closet rebuild mid-regenerate races with our
|
||||
# purge+upsert cycle and leaves mixed regex/LLM lines.
|
||||
with mine_lock(source):
|
||||
assert closets_col is not None
|
||||
purge_file_closets(closets_col, source)
|
||||
upsert_closet_lines(
|
||||
closets_col,
|
||||
|
||||
+409
-17
@@ -11,6 +11,14 @@ from datetime import date, datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from .write_routing import (
|
||||
ResolvedWriteRoutingPolicy,
|
||||
RoutingPolicyCandidate,
|
||||
WriteRoutingError,
|
||||
WriteRoutingPolicy,
|
||||
resolve_write_routing_policy,
|
||||
)
|
||||
|
||||
# ── Input validation ──────────────────────────────────────────────────────────
|
||||
# Shared sanitizers for wing/room/entity names. Prevents path traversal,
|
||||
# excessively long strings, and special characters that could cause issues
|
||||
@@ -31,6 +39,22 @@ def strip_lone_surrogates(text: str) -> str:
|
||||
return _LONE_SURROGATE_RE.sub("�", text)
|
||||
|
||||
|
||||
# Tool output mined from real transcripts routinely embeds a NUL character
|
||||
# (U+0000) — e.g. captured Bash output where a reader raced a background
|
||||
# writer, or genuine binary/NUL-delimited command output. A document
|
||||
# containing one is otherwise valid, well-formed text (unlike a lone
|
||||
# surrogate, which is invalid UTF-8), but handing it to ChromaDB's
|
||||
# SQLite/FTS5 layer can corrupt the FTS5 inverted index for the *whole*
|
||||
# collection (``PRAGMA quick_check`` reports "malformed inverted index for
|
||||
# FTS5 table"), not just fail to store that one document. Stripping it
|
||||
# before it reaches the chromadb client is the same defense-in-depth this
|
||||
# module already applies to lone surrogates (#1235) — sanitize input we
|
||||
# don't control before it reaches a datastore we don't control.
|
||||
def strip_nul_bytes(text: str) -> str:
|
||||
"""Replace embedded NUL characters with U+FFFD before ChromaDB storage."""
|
||||
return text.replace("\x00", "�")
|
||||
|
||||
|
||||
def normalize_wing_name(name: str) -> str:
|
||||
"""Lower-case + collapse separators (`-`, ` `) to `_` for wing slugs.
|
||||
|
||||
@@ -197,6 +221,43 @@ def sanitize_content(value: str, max_length: int = 100_000) -> str:
|
||||
DEFAULT_PALACE_PATH = os.path.expanduser("~/.mempalace/palace")
|
||||
DEFAULT_COLLECTION_NAME = "mempalace_drawers"
|
||||
DEFAULT_BACKEND = "chroma"
|
||||
DEFAULT_MILVUS_CONSISTENCY_LEVEL = "Strong"
|
||||
_MILVUS_CONSISTENCY_LEVELS = {
|
||||
"strong": "Strong",
|
||||
"session": "Session",
|
||||
"bounded": "Bounded",
|
||||
"eventually": "Eventually",
|
||||
}
|
||||
|
||||
# How many timestamped palace backups to retain before the oldest are
|
||||
# pruned. Applies to the accumulating backups written by ``mempalace
|
||||
# migrate`` and ``mempalace repair max-seq-id`` — see
|
||||
# ``MempalaceConfig.max_backups``.
|
||||
DEFAULT_MAX_BACKUPS = 10
|
||||
|
||||
|
||||
def normalize_milvus_consistency_level(value) -> str:
|
||||
raw = str(value).strip() if value else DEFAULT_MILVUS_CONSISTENCY_LEVEL
|
||||
normalized = _MILVUS_CONSISTENCY_LEVELS.get(raw.lower())
|
||||
if normalized:
|
||||
return normalized
|
||||
allowed = ", ".join(_MILVUS_CONSISTENCY_LEVELS.values())
|
||||
raise ValueError(f"milvus_consistency_level must be one of: {allowed}")
|
||||
|
||||
|
||||
def sqlite_read_uri(db_path: str) -> str:
|
||||
"""Return a read-only ``file:`` URI for ``sqlite3.connect(..., uri=True)``.
|
||||
|
||||
A bare ``f"file:{db_path}?mode=ro"`` mis-parses paths containing spaces or
|
||||
other URI-reserved characters — common in real home directories (a Windows
|
||||
user folder like ``First Last``, many macOS paths). ``pathname2url``
|
||||
percent-encodes the path and normalizes separators so the database opens on
|
||||
every platform.
|
||||
"""
|
||||
from urllib.request import pathname2url
|
||||
|
||||
db_path = os.fspath(db_path)
|
||||
return f"file:{pathname2url(db_path)}?mode=ro"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -289,43 +350,65 @@ class MempalaceConfig:
|
||||
Load order: env vars > config file > defaults.
|
||||
"""
|
||||
|
||||
def __init__(self, config_dir=None):
|
||||
def __init__(self, config_dir=None, palace_path=None):
|
||||
"""Initialize config.
|
||||
|
||||
Args:
|
||||
config_dir: Override config directory (useful for testing).
|
||||
Defaults to ~/.mempalace.
|
||||
palace_path: Explicit palace data directory. This is primarily
|
||||
used by CLI operations that received ``--palace``;
|
||||
it takes precedence over environment and file config.
|
||||
"""
|
||||
self._config_dir = (
|
||||
Path(config_dir) if config_dir else Path(os.path.expanduser("~/.mempalace"))
|
||||
)
|
||||
self._config_file = self._config_dir / "config.json"
|
||||
self._people_map_file = self._config_dir / "people_map.json"
|
||||
self._palace_path_override = (
|
||||
os.path.abspath(os.path.expanduser(str(palace_path)))
|
||||
if palace_path is not None
|
||||
else None
|
||||
)
|
||||
self._file_config = {}
|
||||
|
||||
if self._config_file.exists():
|
||||
try:
|
||||
with open(self._config_file, "r") as f:
|
||||
with open(self._config_file, "r", encoding="utf-8") as f:
|
||||
self._file_config = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
|
||||
self._file_config = {}
|
||||
|
||||
@property
|
||||
def palace_path(self):
|
||||
"""Path to the memory palace data directory."""
|
||||
if self._palace_path_override is not None:
|
||||
return self._palace_path_override
|
||||
env_val = os.environ.get("MEMPALACE_PALACE_PATH") or os.environ.get("MEMPAL_PALACE_PATH")
|
||||
if env_val:
|
||||
# Normalize: expand ~ and collapse .. to match the CLI --palace
|
||||
# code path (mcp_server.py:62) and prevent surprise redirection
|
||||
# when the env var contains unresolved components.
|
||||
return os.path.abspath(os.path.expanduser(env_val))
|
||||
return self._file_config.get("palace_path", DEFAULT_PALACE_PATH)
|
||||
return os.path.expanduser(self._file_config.get("palace_path", DEFAULT_PALACE_PATH))
|
||||
|
||||
@property
|
||||
def tunnel_file(self):
|
||||
"""Path to the tunnel file, sibling of palace_path."""
|
||||
return os.path.join(os.path.dirname(self.palace_path), "tunnels.json")
|
||||
|
||||
@property
|
||||
def hallway_file(self):
|
||||
"""Path to the hallway file, sibling of palace_path.
|
||||
|
||||
Mirrors ``tunnel_file`` so within-wing hallway state is scoped to the
|
||||
configured palace and survives palace rebuilds (it does not live in
|
||||
ChromaDB which can be recreated). Prior to this property the path was
|
||||
hardcoded under ``~/.mempalace/hallways.json`` and multiple palaces on
|
||||
one host silently shared one file (see ``hallways._legacy_hallway_file``).
|
||||
"""
|
||||
return os.path.join(os.path.dirname(self.palace_path), "hallways.json")
|
||||
|
||||
@property
|
||||
def collection_name(self):
|
||||
"""ChromaDB collection name."""
|
||||
@@ -388,6 +471,56 @@ class MempalaceConfig:
|
||||
timeout = 10.0
|
||||
return timeout if timeout > 0 else 10.0
|
||||
|
||||
@property
|
||||
def milvus_uri(self):
|
||||
"""Milvus endpoint for the opt-in ``milvus`` backend.
|
||||
|
||||
Defaults to ``None`` so selecting Milvus uses per-palace Milvus Lite at
|
||||
``<palace>/milvus.db``. Set this only to deliberately use a shared
|
||||
Milvus server, Zilliz Cloud, or a custom local Lite file.
|
||||
"""
|
||||
env_val = os.environ.get("MEMPALACE_MILVUS_URI")
|
||||
if env_val:
|
||||
return env_val.strip()
|
||||
value = self._file_config.get("milvus_uri")
|
||||
return str(value).strip() if value else None
|
||||
|
||||
@property
|
||||
def milvus_token(self):
|
||||
"""Token for the opt-in ``milvus`` backend, if configured."""
|
||||
env_val = os.environ.get("MEMPALACE_MILVUS_TOKEN")
|
||||
if env_val:
|
||||
return env_val
|
||||
value = self._file_config.get("milvus_token")
|
||||
return str(value) if value else None
|
||||
|
||||
@property
|
||||
def milvus_db_name(self):
|
||||
"""Optional Milvus database name for the opt-in ``milvus`` backend."""
|
||||
env_val = os.environ.get("MEMPALACE_MILVUS_DB_NAME")
|
||||
if env_val:
|
||||
return env_val.strip()
|
||||
value = self._file_config.get("milvus_db_name")
|
||||
return str(value).strip() if value else None
|
||||
|
||||
@property
|
||||
def milvus_namespace(self):
|
||||
"""Optional Milvus collection namespace/prefix."""
|
||||
env_val = os.environ.get("MEMPALACE_MILVUS_NAMESPACE")
|
||||
if env_val:
|
||||
return env_val.strip()
|
||||
value = self._file_config.get("milvus_namespace")
|
||||
return str(value).strip() if value else None
|
||||
|
||||
@property
|
||||
def milvus_consistency_level(self):
|
||||
"""Milvus read consistency level for the opt-in ``milvus`` backend."""
|
||||
env_val = os.environ.get("MEMPALACE_MILVUS_CONSISTENCY_LEVEL")
|
||||
if env_val:
|
||||
return normalize_milvus_consistency_level(env_val)
|
||||
value = self._file_config.get("milvus_consistency_level")
|
||||
return normalize_milvus_consistency_level(value)
|
||||
|
||||
@property
|
||||
def pgvector_dsn(self):
|
||||
"""Postgres DSN for the opt-in ``pgvector`` backend.
|
||||
@@ -417,9 +550,9 @@ class MempalaceConfig:
|
||||
"""Mapping of name variants to canonical names."""
|
||||
if self._people_map_file.exists():
|
||||
try:
|
||||
with open(self._people_map_file, "r") as f:
|
||||
with open(self._people_map_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
|
||||
pass
|
||||
return self._file_config.get("people_map", {})
|
||||
|
||||
@@ -489,7 +622,8 @@ class MempalaceConfig:
|
||||
|
||||
Enforces the invariants the miner relies on:
|
||||
* ``chunk_size >= 1``
|
||||
* ``0 <= chunk_overlap < chunk_size`` — equality would loop forever
|
||||
* ``0 <= chunk_overlap <= chunk_size // 2``. A larger overlap can
|
||||
loop the miner forever on short-line content (#2056)
|
||||
* ``min_chunk_size <= chunk_size`` — otherwise no chunk is ever
|
||||
large enough to file, and ingest silently produces 0 drawers
|
||||
|
||||
@@ -502,12 +636,14 @@ class MempalaceConfig:
|
||||
"min_chunk_size", DEFAULT_MIN_CHUNK_SIZE, minimum=0
|
||||
)
|
||||
|
||||
if chunk_overlap >= chunk_size:
|
||||
chunk_overlap = (
|
||||
DEFAULT_CHUNK_OVERLAP
|
||||
if DEFAULT_CHUNK_OVERLAP < chunk_size
|
||||
else max(0, chunk_size - 1)
|
||||
)
|
||||
if chunk_overlap > chunk_size // 2:
|
||||
# Overlap past half the chunk size can hang miner.chunk_text's
|
||||
# windowing loop on short-line content (#2056): a boundary pull can
|
||||
# shrink a chunk below chunk_overlap, so
|
||||
# ``start = end - chunk_overlap`` stops advancing. Repair to the
|
||||
# default when it is still at most half, else clamp to the largest
|
||||
# safe overlap.
|
||||
chunk_overlap = min(DEFAULT_CHUNK_OVERLAP, chunk_size // 2)
|
||||
|
||||
if min_chunk_size > chunk_size:
|
||||
min_chunk_size = (
|
||||
@@ -523,7 +659,7 @@ class MempalaceConfig:
|
||||
|
||||
@property
|
||||
def chunk_overlap(self) -> int:
|
||||
"""Overlap between adjacent chunks (validated, ``< chunk_size``)."""
|
||||
"""Overlap between adjacent chunks (validated, ``<= chunk_size // 2``)."""
|
||||
return self._validated_chunk_config()[1]
|
||||
|
||||
@property
|
||||
@@ -612,7 +748,10 @@ class MempalaceConfig:
|
||||
|
||||
Values: ``"minilm"`` (ChromaDB's all-MiniLM-L6-v2 — English-only),
|
||||
``"embeddinggemma"`` (multilingual, 100+ languages, default for
|
||||
new installs since onboarding writes the choice). Read from env
|
||||
new installs since onboarding writes the choice), or
|
||||
``"openai-compat"`` (embeddings served by an OpenAI-compatible
|
||||
``/v1/embeddings`` endpoint — see ``embedding_api_url`` /
|
||||
``embedding_api_model`` / ``embedding_api_key``). Read from env
|
||||
``MEMPALACE_EMBEDDING_MODEL`` first, then ``embedding_model`` in
|
||||
``config.json``, then ``"minilm"`` as a back-compat fallback for
|
||||
palaces created before onboarding asked the question.
|
||||
@@ -627,6 +766,37 @@ class MempalaceConfig:
|
||||
return env_val.strip().lower()
|
||||
return str(self._file_config.get("embedding_model", "minilm")).strip().lower()
|
||||
|
||||
@property
|
||||
def embedding_threads(self) -> int:
|
||||
"""Cap on the embedder's ONNX Runtime intra-op thread pool (#1068).
|
||||
|
||||
ChromaDB's ONNX embedder builds its ``InferenceSession`` with no thread
|
||||
cap, so the intra-op pool defaults to the physical core count and a
|
||||
background ``mine`` pins every core — stacked Stop-hook fires turn into
|
||||
thermal events. ``OMP_NUM_THREADS`` is inert here (ORT owns its own
|
||||
pool), so the cap is applied via ``SessionOptions`` in
|
||||
:mod:`mempalace.embedding`.
|
||||
|
||||
Read from env ``MEMPALACE_EMBEDDING_THREADS`` first, then
|
||||
``embedding_threads`` in ``config.json``. Semantics:
|
||||
|
||||
- unset / ``"auto"`` → half the logical CPUs (min 1), so a background
|
||||
mine leaves the machine usable out of the box.
|
||||
- a positive integer → exactly that many intra-op threads.
|
||||
- ``0`` or negative → uncapped: ORT's default (physical core count),
|
||||
for users who want maximum indexing throughput.
|
||||
"""
|
||||
raw = os.environ.get("MEMPALACE_EMBEDDING_THREADS")
|
||||
if raw is None:
|
||||
raw = self._file_config.get("embedding_threads")
|
||||
if raw is None or str(raw).strip().lower() in ("", "auto"):
|
||||
return max(1, (os.cpu_count() or 2) // 2)
|
||||
try:
|
||||
val = int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return max(1, (os.cpu_count() or 2) // 2)
|
||||
return val if val > 0 else 0
|
||||
|
||||
def set_embedding_model(self, model: str) -> None:
|
||||
"""Persist the embedding-model choice to ``config.json``.
|
||||
|
||||
@@ -665,6 +835,52 @@ class MempalaceConfig:
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
def _resolve_str_setting(self, env_var: str, config_key: str):
|
||||
"""Resolve a string setting: env var > ``config.json`` > ``None``.
|
||||
|
||||
Whitespace-only values are treated as unset, so a blank env var or a
|
||||
hand-edited empty config key doesn't mask the value below it. Unlike
|
||||
``embedding_model`` the result is not lower-cased — URLs, model ids,
|
||||
and API keys are case-sensitive.
|
||||
"""
|
||||
env_val = os.environ.get(env_var)
|
||||
if env_val and env_val.strip():
|
||||
return env_val.strip()
|
||||
cfg_val = self._file_config.get(config_key)
|
||||
if isinstance(cfg_val, str) and cfg_val.strip():
|
||||
return cfg_val.strip()
|
||||
return None
|
||||
|
||||
@property
|
||||
def embedding_api_url(self):
|
||||
"""Base URL of the OpenAI-compatible ``/v1/embeddings`` endpoint.
|
||||
|
||||
Used only when ``embedding_model == "openai-compat"``. Resolved from
|
||||
env ``MEMPALACE_EMBEDDING_API_URL`` first, then ``embedding_api_url``
|
||||
in ``config.json``; ``None`` when unset. Accepts a bare host, a
|
||||
``…/v1`` base, or a full endpoint URL.
|
||||
"""
|
||||
return self._resolve_str_setting("MEMPALACE_EMBEDDING_API_URL", "embedding_api_url")
|
||||
|
||||
@property
|
||||
def embedding_api_model(self):
|
||||
"""Server-side model id for the ``openai-compat`` embeddings endpoint.
|
||||
|
||||
Resolved from env ``MEMPALACE_EMBEDDING_API_MODEL`` first, then
|
||||
``embedding_api_model`` in ``config.json``; ``None`` when unset.
|
||||
"""
|
||||
return self._resolve_str_setting("MEMPALACE_EMBEDDING_API_MODEL", "embedding_api_model")
|
||||
|
||||
@property
|
||||
def embedding_api_key(self):
|
||||
"""Optional bearer token / API key for the embeddings endpoint.
|
||||
|
||||
Resolved from env ``MEMPALACE_EMBEDDING_API_KEY`` first, then
|
||||
``embedding_api_key`` in ``config.json``; ``None`` when unset (for
|
||||
local endpoints that need no auth).
|
||||
"""
|
||||
return self._resolve_str_setting("MEMPALACE_EMBEDDING_API_KEY", "embedding_api_key")
|
||||
|
||||
@property
|
||||
def topic_tunnel_min_count(self):
|
||||
"""Minimum number of overlapping confirmed topics required to create
|
||||
@@ -691,6 +907,73 @@ class MempalaceConfig:
|
||||
parsed = 1
|
||||
return max(1, parsed)
|
||||
|
||||
@property
|
||||
def max_backups(self) -> int:
|
||||
"""Number of timestamped palace backups to retain before pruning.
|
||||
|
||||
Applies to the accumulating, timestamped backups created by
|
||||
``mempalace migrate`` (``<palace>.pre-migrate.<timestamp>``) and
|
||||
``mempalace repair max-seq-id``
|
||||
(``chroma.sqlite3.max-seq-id-backup-<timestamp>``). Each of those
|
||||
commands writes a fresh full-size copy every run and historically
|
||||
never deleted the old ones, so on a machine that mines or repairs on
|
||||
a schedule the backup set could silently grow until it filled the
|
||||
disk. After each backup is written, copies beyond this count (oldest
|
||||
first) are removed.
|
||||
|
||||
Reads ``MEMPALACE_MAX_BACKUPS`` env first, then ``max_backups`` in
|
||||
``config.json``, then the default of ``10``. A value of ``0`` disables
|
||||
pruning and keeps every backup (use when an external retention policy
|
||||
manages cleanup). Negative or non-numeric values fall back to the
|
||||
default rather than crashing migrate/repair.
|
||||
"""
|
||||
env_val = os.environ.get("MEMPALACE_MAX_BACKUPS")
|
||||
if env_val is not None:
|
||||
coerced = self._try_coerce_int(env_val, minimum=0)
|
||||
if coerced is not None:
|
||||
return coerced
|
||||
coerced = self._try_coerce_int(
|
||||
self._file_config.get("max_backups", DEFAULT_MAX_BACKUPS), minimum=0
|
||||
)
|
||||
return DEFAULT_MAX_BACKUPS if coerced is None else coerced
|
||||
|
||||
@property
|
||||
def lang_explicit(self):
|
||||
"""Primary language code when explicitly configured, else ``None``.
|
||||
|
||||
Resolution order: ``MEMPALACE_LANG`` / ``MEMPAL_LANG`` env var, then
|
||||
``config.json["lang"]``. Returns ``None`` if neither is set. Use this
|
||||
when a caller needs to know whether the user has opted in to locale
|
||||
behaviour (e.g. to avoid silently changing search scoring for palaces
|
||||
that have never set a language).
|
||||
"""
|
||||
env_val = os.environ.get("MEMPALACE_LANG") or os.environ.get("MEMPAL_LANG")
|
||||
if env_val and env_val.strip():
|
||||
return env_val.strip()
|
||||
cfg = self._file_config.get("lang")
|
||||
if isinstance(cfg, str) and cfg.strip():
|
||||
return cfg.strip()
|
||||
return None
|
||||
|
||||
@property
|
||||
def lang(self):
|
||||
"""Primary language code for localized output and display.
|
||||
|
||||
Resolution order: ``lang_explicit`` (env or config.json), first entry
|
||||
of ``entity_languages``, then ``"en"``. Always returns a non-empty
|
||||
string so callers that need a language for display purposes never
|
||||
have to handle ``None``. Code paths that must not silently change
|
||||
behaviour for unconfigured palaces should read ``lang_explicit``
|
||||
instead.
|
||||
"""
|
||||
explicit = self.lang_explicit
|
||||
if explicit:
|
||||
return explicit
|
||||
entity_langs = self.entity_languages
|
||||
if entity_langs:
|
||||
return entity_langs[0]
|
||||
return "en"
|
||||
|
||||
@property
|
||||
def hook_silent_save(self):
|
||||
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""
|
||||
@@ -701,6 +984,115 @@ class MempalaceConfig:
|
||||
"""Whether the stop hook shows a desktop notification via notify-send."""
|
||||
return self._file_config.get("hooks", {}).get("desktop_toast", False)
|
||||
|
||||
def resolve_write_routing(self, scope: str) -> ResolvedWriteRoutingPolicy:
|
||||
"""Resolve the configured write policy for ``hooks`` or ``cli``.
|
||||
|
||||
Precedence is:
|
||||
|
||||
1. scope-specific environment variable;
|
||||
2. global environment variable;
|
||||
3. legacy hook environment variable;
|
||||
4. scope-specific config value;
|
||||
5. global config value;
|
||||
6. legacy hook config value;
|
||||
7. ``direct``.
|
||||
|
||||
This foundation does not change current hook or CLI behavior. The
|
||||
policy-aware consumers are introduced by follow-up PRs.
|
||||
"""
|
||||
|
||||
normalized_scope = str(scope).strip().lower()
|
||||
env_names = {
|
||||
"hooks": "MEMPALACE_HOOK_WRITE_ROUTING",
|
||||
"cli": "MEMPALACE_CLI_WRITE_ROUTING",
|
||||
}
|
||||
|
||||
if normalized_scope not in env_names:
|
||||
raise WriteRoutingError("write routing scope must be 'hooks' or 'cli'")
|
||||
|
||||
routing_config = self._file_config.get("write_routing", {})
|
||||
if routing_config is None:
|
||||
routing_config = {}
|
||||
|
||||
if not isinstance(routing_config, dict):
|
||||
raise WriteRoutingError("config write_routing must be an object")
|
||||
|
||||
candidates = [
|
||||
RoutingPolicyCandidate(
|
||||
env_names[normalized_scope],
|
||||
os.environ.get(env_names[normalized_scope]),
|
||||
),
|
||||
RoutingPolicyCandidate(
|
||||
"MEMPALACE_WRITE_ROUTING",
|
||||
os.environ.get("MEMPALACE_WRITE_ROUTING"),
|
||||
),
|
||||
]
|
||||
|
||||
if normalized_scope == "hooks":
|
||||
candidates.append(
|
||||
RoutingPolicyCandidate(
|
||||
"MEMPALACE_HOOKS_DAEMON (legacy)",
|
||||
os.environ.get("MEMPALACE_HOOKS_DAEMON"),
|
||||
legacy_boolean=True,
|
||||
)
|
||||
)
|
||||
|
||||
candidates.extend(
|
||||
[
|
||||
RoutingPolicyCandidate(
|
||||
f"config write_routing.{normalized_scope}",
|
||||
routing_config.get(normalized_scope),
|
||||
),
|
||||
RoutingPolicyCandidate(
|
||||
"config write_routing.default",
|
||||
routing_config.get("default"),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if normalized_scope == "hooks":
|
||||
hooks_config = self._file_config.get("hooks", {})
|
||||
if hooks_config is None:
|
||||
hooks_config = {}
|
||||
|
||||
if not isinstance(hooks_config, dict):
|
||||
raise WriteRoutingError("config hooks must be an object")
|
||||
|
||||
candidates.append(
|
||||
RoutingPolicyCandidate(
|
||||
"config hooks.daemon (legacy)",
|
||||
hooks_config.get("daemon"),
|
||||
legacy_boolean=True,
|
||||
)
|
||||
)
|
||||
|
||||
return resolve_write_routing_policy(candidates)
|
||||
|
||||
@property
|
||||
def hook_write_routing(self) -> WriteRoutingPolicy:
|
||||
"""Resolved future routing policy for hook-triggered writes."""
|
||||
|
||||
return self.resolve_write_routing("hooks").policy
|
||||
|
||||
@property
|
||||
def cli_write_routing(self) -> WriteRoutingPolicy:
|
||||
"""Resolved future routing policy for routine CLI writes."""
|
||||
|
||||
return self.resolve_write_routing("cli").policy
|
||||
|
||||
@property
|
||||
def hook_use_daemon(self):
|
||||
"""Whether hooks should submit save/mine work to the opt-in daemon."""
|
||||
env_val = os.environ.get("MEMPALACE_HOOKS_DAEMON")
|
||||
if env_val is not None:
|
||||
return env_val.lower() in ("true", "1", "yes", "on")
|
||||
value = self._file_config.get("hooks", {}).get("daemon", False)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value == 1
|
||||
|
||||
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:
|
||||
@@ -735,7 +1127,7 @@ class MempalaceConfig:
|
||||
"topic_wings": DEFAULT_TOPIC_WINGS,
|
||||
"hall_keywords": DEFAULT_HALL_KEYWORDS,
|
||||
}
|
||||
with open(self._config_file, "w") as f:
|
||||
with open(self._config_file, "w", encoding="utf-8") as f:
|
||||
json.dump(default_config, f, indent=2)
|
||||
# Restrict config file to owner read/write only
|
||||
try:
|
||||
@@ -751,7 +1143,7 @@ class MempalaceConfig:
|
||||
people_map: Dict mapping name variants to canonical names.
|
||||
"""
|
||||
self._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._people_map_file, "w") as f:
|
||||
with open(self._people_map_file, "w", encoding="utf-8") as f:
|
||||
json.dump(people_map, f, indent=2)
|
||||
try:
|
||||
self._people_map_file.chmod(0o600)
|
||||
|
||||
+539
-74
@@ -10,15 +10,25 @@ Same palace as project mining. Different ingest strategy.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from .backends import PalaceNotFoundError
|
||||
from .collision_scan import assert_no_collisions
|
||||
from .ids import ID_RECIPE, make_convo_drawer_id, make_convo_sentinel_id
|
||||
from .normalize import normalize
|
||||
from .ids import (
|
||||
ID_RECIPE,
|
||||
make_convo_drawer_id,
|
||||
make_convo_sentinel_id,
|
||||
make_exchange_drawer_id,
|
||||
)
|
||||
from .normalize import normalize_conversations
|
||||
from .entities import entities_metadata
|
||||
from .palace import (
|
||||
NORMALIZE_VERSION,
|
||||
SKIP_DIRS,
|
||||
@@ -28,6 +38,7 @@ from .palace import (
|
||||
get_collection,
|
||||
mine_lock,
|
||||
mine_palace_lock,
|
||||
prefetch_content_hashes,
|
||||
prefetch_mined_set,
|
||||
)
|
||||
|
||||
@@ -54,6 +65,82 @@ def _detect_hall_cached(content: str) -> str:
|
||||
return max(scores, key=scores.get) if scores else "general"
|
||||
|
||||
|
||||
def file_conversation_exchange(
|
||||
collection,
|
||||
*,
|
||||
wing: str,
|
||||
room: str,
|
||||
text: str,
|
||||
source_file: str,
|
||||
agent: str,
|
||||
authored_at: Optional[str] = None,
|
||||
extra_metadata: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""File one verbatim conversation exchange as a single drawer.
|
||||
|
||||
Canonical write path for live agent integrations (e.g. Hermes) and
|
||||
their backfills — both must route here so routing, normalization,
|
||||
and metadata conventions stay identical between live and historical
|
||||
ingest. Builds the same metadata the convo miner writes so hallway
|
||||
traversal, entity search, and since/before date filters see
|
||||
integration drawers exactly like mined ones.
|
||||
|
||||
``wing`` and ``room`` are validated with the same ``sanitize_name``
|
||||
rules the MCP write tools apply, but a failed name falls back
|
||||
(``wing_general`` / ``conversations``) instead of erroring: this
|
||||
path files *live* turns, and dropping a turn over a config typo
|
||||
would break the verbatim / 100%-recall promise. The fallback is
|
||||
logged at warning level so the misconfiguration is visible.
|
||||
|
||||
``extra_metadata`` lets callers append integration-specific fields
|
||||
(e.g. ``source`` / ``session_id``); keys that collide with the
|
||||
canonical fields are ignored, so it cannot be used to overwrite or
|
||||
drop them. Returns the drawer id, or None when ``text`` is empty
|
||||
after stripping.
|
||||
"""
|
||||
from .config import sanitize_name
|
||||
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
wing = sanitize_name(wing, "wing")
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"file_conversation_exchange: invalid wing %r — filing under wing_general", wing
|
||||
)
|
||||
wing = "wing_general"
|
||||
try:
|
||||
room = sanitize_name(room, "room")
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"file_conversation_exchange: invalid room %r — filing under conversations", room
|
||||
)
|
||||
room = "conversations"
|
||||
filed_at = datetime.now().isoformat()
|
||||
drawer_id = make_exchange_drawer_id(wing, room, source_file, filed_at, text)
|
||||
metadata = {
|
||||
"wing": wing,
|
||||
"room": room,
|
||||
"hall": _detect_hall_cached(text),
|
||||
"source_file": source_file,
|
||||
"chunk_index": 0,
|
||||
"added_by": agent,
|
||||
"filed_at": filed_at,
|
||||
"entities": entities_metadata(text),
|
||||
"authored_at": authored_at if authored_at is not None else filed_at,
|
||||
"ingest_mode": "convos",
|
||||
"extract_mode": "exchange",
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
if extra_metadata:
|
||||
for key, value in extra_metadata.items():
|
||||
metadata.setdefault(key, value)
|
||||
collection.upsert(ids=[drawer_id], documents=[text], metadatas=[metadata])
|
||||
return drawer_id
|
||||
|
||||
|
||||
# File types that might contain conversations
|
||||
CONVO_EXTENSIONS = {
|
||||
".txt",
|
||||
@@ -62,6 +149,16 @@ CONVO_EXTENSIONS = {
|
||||
".jsonl",
|
||||
}
|
||||
|
||||
# Directories inside conversation sources that never hold conversations.
|
||||
# ``tool-results``: Claude Code pages large tool outputs to
|
||||
# ``<session>/tool-results/*.txt`` inside ``~/.claude/projects/<slug>/``.
|
||||
# They are raw machine dumps referenced from the transcript JSONL — mining
|
||||
# them stores megabytes of command output as "memories" (field measurement:
|
||||
# 12.8k drawers from tool-results files on one palace; a single file
|
||||
# produced 3.6k). Extends the generic SKIP_DIRS set for the convo scanner
|
||||
# only — project mining semantics are unchanged.
|
||||
CONVO_SKIP_DIRS = SKIP_DIRS | {"tool-results"}
|
||||
|
||||
MIN_CHUNK_SIZE = 30
|
||||
CHUNK_SIZE = 800 # chars per drawer — align with miner.py
|
||||
_LINE_GROUP_SIZE = 25 # lines per fallback group when no paragraph breaks
|
||||
@@ -77,30 +174,81 @@ MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB — skip files larger than this.
|
||||
# use also scales with source size.
|
||||
|
||||
|
||||
def _register_file(collection, source_file: str, wing: str, agent: str, extract_mode: str):
|
||||
def _path_within_root(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.expanduser().resolve().relative_to(root.expanduser().resolve())
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _is_regular_source_file(filepath: Path, root: Path) -> bool:
|
||||
if not _path_within_root(filepath, root):
|
||||
return False
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = -1
|
||||
try:
|
||||
fd = os.open(filepath, flags)
|
||||
st = os.fstat(fd)
|
||||
return stat.S_ISREG(st.st_mode) and st.st_size <= MAX_FILE_SIZE
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
if fd != -1:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _register_file(
|
||||
collection,
|
||||
source_file: str,
|
||||
wing: str,
|
||||
agent: str,
|
||||
extract_mode: str,
|
||||
content_hash: Optional[str] = None,
|
||||
):
|
||||
"""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.
|
||||
|
||||
Stamps source_mtime like every real drawer does, so a file that later
|
||||
grows past the min-chunk-size floor (e.g. a short session that gets
|
||||
extended) is correctly detected as changed on the next mine instead of
|
||||
being skipped forever by this sentinel.
|
||||
|
||||
Also used to register a file recognized as a content-duplicate of an
|
||||
already-mined transcript under a different path (see
|
||||
``prefetch_content_hashes``) — stamping it here means the next run skips
|
||||
it via the cheap mtime check instead of re-normalizing and re-hashing it.
|
||||
"""
|
||||
try:
|
||||
source_mtime = os.path.getmtime(source_file)
|
||||
except OSError:
|
||||
source_mtime = None
|
||||
sentinel_id = make_convo_sentinel_id(source_file, extract_mode)
|
||||
meta = {
|
||||
"wing": wing,
|
||||
"room": "_registry",
|
||||
"source_file": source_file,
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"ingest_mode": "registry",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
if source_mtime is not None:
|
||||
meta["source_mtime"] = source_mtime
|
||||
if content_hash is not None:
|
||||
meta["content_hash"] = content_hash
|
||||
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",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
],
|
||||
metadatas=[meta],
|
||||
)
|
||||
|
||||
|
||||
@@ -345,17 +493,37 @@ def detect_convo_room(content: str) -> str:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def scan_convos(convo_dir: str) -> list:
|
||||
def scan_convos(convo_dir: str, include_subagents: bool = False) -> list:
|
||||
"""Find all potential conversation files.
|
||||
|
||||
Skips symlinks and oversized files. Each skipped symlink is logged to
|
||||
``sys.stderr`` with a `` SKIP: <relative-path> (symlink)`` line so the
|
||||
caller can tell why an apparent conversation directory yielded no files.
|
||||
|
||||
By default, directories named ``subagents`` are skipped: Claude Code
|
||||
records Explore/Plan/Grep subagent transcripts there, and on typical
|
||||
workspaces they outnumber main session files by one to two orders of
|
||||
magnitude. Pass ``include_subagents=True`` to mine them anyway.
|
||||
|
||||
The match is case-insensitive on the directory name only (``subagents``
|
||||
or ``Subagents``), so directories like ``mysubagents`` or
|
||||
``subagentsbackup`` are not affected.
|
||||
"""
|
||||
convo_path = Path(convo_dir).expanduser().resolve()
|
||||
# A direct conversation file is a valid source. For a file, feed only
|
||||
# its basename through the existing directory validation loop.
|
||||
requested_path = Path(convo_dir).expanduser()
|
||||
single_file = requested_path.is_file()
|
||||
convo_path = (requested_path.parent if single_file else requested_path).resolve()
|
||||
scan_entries = (
|
||||
[(str(convo_path), [], [requested_path.name])] if single_file else os.walk(convo_path)
|
||||
)
|
||||
files = []
|
||||
for root, dirs, filenames in os.walk(convo_path):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||
for root, dirs, filenames in scan_entries:
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if d not in CONVO_SKIP_DIRS and (include_subagents or d.lower() != "subagents")
|
||||
]
|
||||
for filename in filenames:
|
||||
if filename.endswith(".meta.json"):
|
||||
continue
|
||||
@@ -369,10 +537,30 @@ def scan_convos(convo_dir: str) -> list:
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
# Skip files exceeding size limit, or those whose stat() raises
|
||||
# (permission denied, racing delete, broken symlink that
|
||||
# survived the earlier is_symlink check). Both branches log
|
||||
# to stderr to match the SKIP: (symlink) line above; silent
|
||||
# drops at this gate were the original #923 complaint.
|
||||
try:
|
||||
if filepath.stat().st_size > MAX_FILE_SIZE:
|
||||
file_size = filepath.stat().st_size
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
print(
|
||||
f" SKIP: {filepath.name} ({file_size / (1024 * 1024):.1f} MB)"
|
||||
f" exceeds {MAX_FILE_SIZE // (1024 * 1024)} MB limit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
# Prefer ``exc.strerror`` so the path isn't duplicated in
|
||||
# the output (see the matching comment in
|
||||
# ``miner.scan_project``).
|
||||
print(
|
||||
f" SKIP: {filepath.name} (stat error: {exc.strerror or exc})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
if not _is_regular_source_file(filepath, convo_path):
|
||||
continue
|
||||
files.append(filepath)
|
||||
return files
|
||||
@@ -383,12 +571,59 @@ def scan_convos(convo_dir: str) -> list:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extract_mode):
|
||||
def _extract_authored_at(filepath):
|
||||
"""Most-recent message timestamp in a transcript, used as the drawer's authored date.
|
||||
|
||||
Both Claude Code and Codex JSONL transcripts carry a top-level ISO-8601
|
||||
``timestamp`` on each line. We take the max so ``authored_at`` reflects when the
|
||||
content was actually written, independent of when it was mined (``filed_at``).
|
||||
This restores chronology: a session from days ago keeps its real date even when
|
||||
re-mined today, instead of every drawer collapsing to ingest time. Returns None
|
||||
for formats without per-line timestamps (e.g. plain ``.md``).
|
||||
"""
|
||||
path = Path(filepath)
|
||||
if path.suffix != ".jsonl":
|
||||
return None
|
||||
latest = None
|
||||
try:
|
||||
with path.open(encoding="utf-8", errors="ignore") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
ts = json.loads(line).get("timestamp")
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
continue
|
||||
# ISO-8601 timestamps are strings; guard against a non-string
|
||||
# ``timestamp`` so a malformed line can't raise TypeError on compare.
|
||||
if isinstance(ts, str) and (latest is None or ts > latest):
|
||||
latest = ts
|
||||
except OSError:
|
||||
return None
|
||||
return latest
|
||||
|
||||
|
||||
def _file_chunks_locked(
|
||||
collection,
|
||||
source_file,
|
||||
chunks,
|
||||
wing,
|
||||
room,
|
||||
agent,
|
||||
extract_mode,
|
||||
authored_at=None,
|
||||
content_hash=None,
|
||||
):
|
||||
"""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).
|
||||
duplicating work (via mine_lock) with the rebuild contract
|
||||
(purge-before-insert so stale drawers never survive) that fires on
|
||||
either a normalize-version bump OR a changed/grown source file (mtime
|
||||
differs from what's stored) -- transcripts are not assumed immutable,
|
||||
since a Claude Code session keeps appending to its own file while
|
||||
active and /compact or /clear can rewrite one in place.
|
||||
|
||||
Returns (drawers_added, room_counts_delta, skipped).
|
||||
"""
|
||||
@@ -396,26 +631,47 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
|
||||
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
|
||||
# at the current schema/mtime. A stale hit here returns False, so we
|
||||
# still fall through to the purge+rebuild path below.
|
||||
if file_already_mined(collection, source_file, extract_mode=extract_mode):
|
||||
if file_already_mined(collection, source_file, check_mtime=True, extract_mode=extract_mode):
|
||||
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.
|
||||
# Purge stale drawers first. Fires both on a normalize-schema bump
|
||||
# (file_already_mined() returned False for pre-v2 drawers) and on a
|
||||
# changed/grown transcript (mtime differs) — clean them out so the
|
||||
# source doesn't end up with mixed old/new drawers.
|
||||
#
|
||||
# A failed purge must abort this file's mine attempt rather than
|
||||
# fall through to upsert: proceeding on top of an unpurged (or
|
||||
# partially purged) set produces duplicate/stale drawers under
|
||||
# mixed schema versions, with no operator-visible signal beyond a
|
||||
# debug log (#105 — convo_miner's own instance of the same swallow
|
||||
# already fixed for miner.py at #23). Returning here leaves the old
|
||||
# drawers' stored mtime untouched, so the next mine still sees a
|
||||
# mismatch and retries.
|
||||
try:
|
||||
delete_ids = _source_file_delete_ids(collection, source_file, extract_mode)
|
||||
if delete_ids:
|
||||
collection.delete(ids=delete_ids)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
print(
|
||||
f" ! [skip] stale-drawer purge failed for {source_file!r} "
|
||||
f"({exc!r}); leaving existing drawers untouched, will retry "
|
||||
f"on the next mine",
|
||||
file=sys.stderr,
|
||||
)
|
||||
logger.debug("Stale-drawer purge failed for %s", source_file, exc_info=True)
|
||||
return 0, room_counts_delta, True
|
||||
|
||||
# Batch chunks into bounded upserts so large transcripts keep most of
|
||||
# the embedding speedup without one huge Chroma/SQLite request. Keep
|
||||
# one filed_at per source file so all transcript drawers share an
|
||||
# ingest timestamp.
|
||||
filed_at = datetime.now().isoformat()
|
||||
try:
|
||||
source_mtime = os.path.getmtime(source_file)
|
||||
except OSError:
|
||||
source_mtime = None
|
||||
for batch_start in range(0, len(chunks), DRAWER_UPSERT_BATCH_SIZE):
|
||||
batch_docs: list = []
|
||||
batch_ids: list = []
|
||||
@@ -429,21 +685,30 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
|
||||
)
|
||||
batch_docs.append(chunk["content"])
|
||||
batch_ids.append(drawer_id)
|
||||
batch_metas.append(
|
||||
{
|
||||
"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": filed_at,
|
||||
"ingest_mode": "convos",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
)
|
||||
meta = {
|
||||
"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": filed_at,
|
||||
"entities": entities_metadata(chunk["content"]),
|
||||
"authored_at": authored_at if authored_at is not None else filed_at,
|
||||
"ingest_mode": "convos",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
if source_mtime is not None:
|
||||
meta["source_mtime"] = source_mtime
|
||||
# Stamp content_hash only on chunk 0 so multi-conversation
|
||||
# privacy-export hashes are not O(N²)-duplicated across every
|
||||
# chunk row. ``prefetch_content_hashes`` still finds them —
|
||||
# it scans all drawers and splits comma-joined hash fields.
|
||||
if content_hash is not None and chunk.get("chunk_index", 0) == 0:
|
||||
meta["content_hash"] = content_hash
|
||||
batch_metas.append(meta)
|
||||
assert_no_collisions(list(zip(batch_ids, batch_metas)), collection)
|
||||
try:
|
||||
collection.upsert(
|
||||
@@ -487,6 +752,54 @@ def _is_ai_tool_path(path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _split_new_and_duplicate_conversations(
|
||||
conversations: list,
|
||||
wing: str,
|
||||
source_file: str,
|
||||
mined_content_hashes: dict,
|
||||
) -> tuple:
|
||||
"""Hash each conversation and split them into (new, duplicate) lists.
|
||||
|
||||
A conversation is a duplicate when its hash is already registered under
|
||||
a *different* source_file in the same wing — mining the same transcript
|
||||
into a second wing is a deliberate re-file, not a repeat, so the lookup
|
||||
is scoped to (wing, hash). Returns ([(hash, text), ...] new, [(hash,
|
||||
dup_source_file), ...] duplicates).
|
||||
"""
|
||||
new_items = []
|
||||
duplicates = []
|
||||
for conversation in conversations:
|
||||
content_hash = hashlib.sha256(conversation.strip().encode("utf-8")).hexdigest()
|
||||
dup_source = mined_content_hashes.get((wing, content_hash))
|
||||
if dup_source is None or dup_source == source_file:
|
||||
new_items.append((content_hash, conversation))
|
||||
else:
|
||||
duplicates.append((content_hash, dup_source))
|
||||
return new_items, duplicates
|
||||
|
||||
|
||||
def _is_unchanged_since_last_mine(source_file: str, mined_mtimes: dict) -> bool:
|
||||
"""True iff source_file was mined at the current schema AND its on-disk
|
||||
mtime still matches what was stored -- the mtime-aware replacement for
|
||||
"we've seen this source_file before" (transcripts are not immutable).
|
||||
|
||||
False (re-mine) whenever the file isn't in mined_mtimes at all, its
|
||||
stored mtime is None (never recorded -- pre-mtime-tracking drawer, or
|
||||
getmtime failed when it was written), or getmtime fails right now
|
||||
(treat as changed rather than silently trusting stale data).
|
||||
"""
|
||||
if source_file not in mined_mtimes:
|
||||
return False
|
||||
stored_mtime = mined_mtimes[source_file]
|
||||
if stored_mtime is None:
|
||||
return False
|
||||
try:
|
||||
current_mtime = os.path.getmtime(source_file)
|
||||
except OSError:
|
||||
return False
|
||||
return abs(stored_mtime - current_mtime) < 0.001
|
||||
|
||||
|
||||
def _resolve_wing(convo_path: Path, wing: Optional[str]) -> str:
|
||||
"""Determine the destination wing for ``mine_convos``.
|
||||
|
||||
@@ -520,12 +833,16 @@ def mine_convos(
|
||||
limit: int = 0,
|
||||
dry_run: bool = False,
|
||||
extract_mode: str = "exchange",
|
||||
include_subagents: bool = False,
|
||||
):
|
||||
"""Mine a directory of conversation files into the palace.
|
||||
|
||||
extract_mode:
|
||||
"exchange" — default exchange-pair chunking (Q+A = one unit)
|
||||
"general" — general extractor: decisions, preferences, milestones, problems, emotions
|
||||
include_subagents:
|
||||
False (default) — skip Claude Code ``subagents/`` directories
|
||||
True — also mine subagent transcripts
|
||||
|
||||
The real work is in :func:`_mine_convos_impl`; this wrapper holds the
|
||||
per-palace flock around it so two concurrent ``mempalace mine --mode
|
||||
@@ -552,6 +869,7 @@ def mine_convos(
|
||||
limit=limit,
|
||||
dry_run=dry_run,
|
||||
extract_mode=extract_mode,
|
||||
include_subagents=include_subagents,
|
||||
)
|
||||
|
||||
with mine_palace_lock(palace_path):
|
||||
@@ -563,9 +881,84 @@ def mine_convos(
|
||||
limit=limit,
|
||||
dry_run=dry_run,
|
||||
extract_mode=extract_mode,
|
||||
include_subagents=include_subagents,
|
||||
)
|
||||
|
||||
|
||||
def _compute_hallways_for_wing_safe(wing, collection, drawers_filed, config=None):
|
||||
"""Auto-populate the associative graph from the entities just mined.
|
||||
|
||||
Best-effort: hallway computation must never fail an otherwise-good mine, and is
|
||||
skipped when nothing new was filed.
|
||||
"""
|
||||
if drawers_filed <= 0:
|
||||
return
|
||||
try:
|
||||
from .hallways import compute_hallways_for_wing
|
||||
|
||||
compute_hallways_for_wing(wing, col=collection, config=config)
|
||||
except Exception as exc:
|
||||
print(f" (hallways skipped: {exc})")
|
||||
|
||||
|
||||
def _normalize_convo_conversations(
|
||||
filepath: Path,
|
||||
source_file: str,
|
||||
cfg_min_chunk_size: int,
|
||||
collection,
|
||||
wing: str,
|
||||
agent: str,
|
||||
extract_mode: str,
|
||||
dry_run: bool,
|
||||
) -> Optional[list]:
|
||||
"""Normalize a transcript file into its individual conversations,
|
||||
registering it as filed when there's nothing worth mining. Returns None
|
||||
when the caller should skip the file (normalize failed, or normalized
|
||||
content is too short to chunk).
|
||||
|
||||
Kept as separate conversations rather than joined into one string so
|
||||
dedup can hash and skip per conversation — a Claude.ai privacy export
|
||||
bundles every conversation into a single file, and hashing the joined
|
||||
bundle means one new conversation added to a re-export changes the
|
||||
whole-file hash and hides the conversations that didn't change.
|
||||
"""
|
||||
try:
|
||||
conversations = [c for c in normalize_conversations(str(filepath)) if c]
|
||||
except (OSError, ValueError):
|
||||
if not dry_run:
|
||||
_register_file(collection, source_file, wing, agent, extract_mode)
|
||||
return None
|
||||
|
||||
total_len = sum(len(c.strip()) for c in conversations)
|
||||
if not conversations or total_len < cfg_min_chunk_size:
|
||||
if not dry_run:
|
||||
_register_file(collection, source_file, wing, agent, extract_mode)
|
||||
return None
|
||||
|
||||
return conversations
|
||||
|
||||
|
||||
def _open_convo_collection(
|
||||
palace_path: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
):
|
||||
"""Open the conversation collection without creating it during dry-run."""
|
||||
if not dry_run:
|
||||
return get_collection(palace_path)
|
||||
|
||||
try:
|
||||
return get_collection(
|
||||
palace_path,
|
||||
create=False,
|
||||
read_only=True,
|
||||
)
|
||||
except PalaceNotFoundError:
|
||||
# A missing palace or uninitialized collection represents empty
|
||||
# prior state to a dry-run. Do not create either one.
|
||||
return None
|
||||
|
||||
|
||||
def _mine_convos_impl(
|
||||
convo_dir: str,
|
||||
palace_path: str,
|
||||
@@ -574,10 +967,11 @@ def _mine_convos_impl(
|
||||
limit: int = 0,
|
||||
dry_run: bool = False,
|
||||
extract_mode: str = "exchange",
|
||||
include_subagents: bool = False,
|
||||
):
|
||||
from .config import MempalaceConfig
|
||||
|
||||
palace_config = MempalaceConfig()
|
||||
palace_config = MempalaceConfig(palace_path=palace_path)
|
||||
cfg_chunk_size = palace_config.chunk_size
|
||||
# Only override convo_miner's MIN_CHUNK_SIZE when the user has set
|
||||
# min_chunk_size explicitly. min_chunk_size_explicit returns the
|
||||
@@ -593,57 +987,108 @@ def _mine_convos_impl(
|
||||
convo_path = Path(convo_dir).expanduser().resolve()
|
||||
wing = _resolve_wing(convo_path, wing)
|
||||
|
||||
files = scan_convos(convo_dir)
|
||||
if limit > 0:
|
||||
files = files[:limit]
|
||||
files = scan_convos(convo_dir, include_subagents=include_subagents)
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" MemPalace Mine — Conversations")
|
||||
print(" MemPalace Mine -- Conversations")
|
||||
print(f"{'=' * 55}")
|
||||
print(f" Wing: {wing}")
|
||||
print(f" Source: {convo_path}")
|
||||
print(f" Files: {len(files)}")
|
||||
limit_suffix = f" (limit: {limit} new)" if limit > 0 else ""
|
||||
print(f" Files: {len(files)}{limit_suffix}")
|
||||
print(f" Palace: {palace_path}")
|
||||
if dry_run:
|
||||
print(" DRY RUN — nothing will be filed")
|
||||
print(" DRY RUN -- nothing will be filed")
|
||||
print(f"{'-' * 55}\n")
|
||||
|
||||
collection = get_collection(palace_path) if not dry_run else None
|
||||
collection = _open_convo_collection(
|
||||
palace_path,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
# Bulk pre-fetch already-mined set in one paginated pass instead of
|
||||
# `len(files)` separate WHERE-source_file queries. On a 150k-drawer
|
||||
# palace each per-file query costs ~2s, so a 2000-file sweep used to
|
||||
# spend >1h just deciding to skip. prefetch_mined_set() does the same
|
||||
# decisions in a single scan; loop body becomes an O(1) set check.
|
||||
mined_set: set[str] = (
|
||||
prefetch_mined_set(collection, extract_mode=extract_mode) if not dry_run else set()
|
||||
# Bulk pre-fetch already-mined source_file -> stored mtime in one
|
||||
# paginated pass instead of `len(files)` separate WHERE-source_file
|
||||
# queries. On a 150k-drawer palace each per-file query costs ~2s, so a
|
||||
# 2000-file sweep used to spend >1h just deciding to skip.
|
||||
# prefetch_mined_set() does the same decisions in a single scan; loop
|
||||
# body becomes an O(1) dict lookup + a cheap local mtime comparison.
|
||||
mined_mtimes: dict = (
|
||||
prefetch_mined_set(collection, extract_mode=extract_mode) if collection is not None else {}
|
||||
)
|
||||
# content_hash -> source_file for transcripts already filed. Repeated
|
||||
# exports from Claude/ChatGPT commonly land under a new filename each
|
||||
# run even when the conversation itself is unchanged, so the
|
||||
# source_file-keyed skip above ("mined_mtimes") never recognizes them —
|
||||
# this catches the same conversation reappearing at a new path.
|
||||
mined_content_hashes: dict = (
|
||||
prefetch_content_hashes(collection, extract_mode=extract_mode)
|
||||
if collection is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
total_drawers = 0
|
||||
files_mined = 0
|
||||
files_skipped = 0
|
||||
files_processed = 0
|
||||
room_counts = defaultdict(int)
|
||||
|
||||
for i, filepath in enumerate(files, 1):
|
||||
files_processed = i
|
||||
source_file = str(filepath)
|
||||
|
||||
# Skip if already filed at current NORMALIZE_VERSION
|
||||
if not dry_run and source_file in mined_set:
|
||||
# Skip only if already filed at the current NORMALIZE_VERSION AND
|
||||
# unchanged on disk since. Transcripts are NOT assumed immutable:
|
||||
# a Claude Code session keeps appending to the same file while
|
||||
# active, and /compact or /clear can rewrite one in place -- so
|
||||
# "we've seen this source_file before" alone is not sufficient.
|
||||
# Falling through re-mines: _file_chunks_locked purges this
|
||||
# source_file's stale drawers before inserting fresh ones, so this
|
||||
# never leaves duplicates behind.
|
||||
if _is_unchanged_since_last_mine(source_file, mined_mtimes):
|
||||
files_skipped += 1
|
||||
continue
|
||||
|
||||
# Normalize format
|
||||
try:
|
||||
content = normalize(str(filepath))
|
||||
except (OSError, ValueError):
|
||||
if not dry_run:
|
||||
_register_file(collection, source_file, wing, agent, extract_mode)
|
||||
if not _is_regular_source_file(filepath, Path(convo_dir).expanduser().resolve()):
|
||||
files_skipped += 1
|
||||
continue
|
||||
|
||||
if not content or len(content.strip()) < cfg_min_chunk_size:
|
||||
conversations = _normalize_convo_conversations(
|
||||
filepath,
|
||||
source_file,
|
||||
cfg_min_chunk_size,
|
||||
collection,
|
||||
wing,
|
||||
agent,
|
||||
extract_mode,
|
||||
dry_run,
|
||||
)
|
||||
if conversations is None:
|
||||
continue
|
||||
|
||||
# Hash and dedup per conversation, not per file: a Claude/ChatGPT
|
||||
# privacy export bundles every conversation into one file, so a
|
||||
# re-export that adds one new conversation changes the whole-file
|
||||
# hash and would hide the conversations that didn't change if we
|
||||
# hashed the joined bundle. Conversations whose hash is already
|
||||
# filed under a different source_file in this wing are dropped;
|
||||
# the rest are re-joined and mined as usual.
|
||||
new_items, duplicates = _split_new_and_duplicate_conversations(
|
||||
conversations, wing, source_file, mined_content_hashes
|
||||
)
|
||||
if not new_items:
|
||||
if not dry_run:
|
||||
_register_file(collection, source_file, wing, agent, extract_mode)
|
||||
dup_source = duplicates[0][1]
|
||||
print(
|
||||
f" = [{i:4}/{len(files)}] {filepath.name[:50]:50} "
|
||||
f"duplicate of {Path(dup_source).name}"
|
||||
)
|
||||
files_skipped += 1
|
||||
continue
|
||||
|
||||
content = "\n\n".join(text for _, text in new_items)
|
||||
content_hash = ",".join(h for h, _ in new_items)
|
||||
|
||||
# Chunk — either exchange pairs or general extraction
|
||||
if extract_mode == "general":
|
||||
from .general_extractor import extract_memories
|
||||
@@ -674,9 +1119,9 @@ def _mine_convos_impl(
|
||||
|
||||
type_counts = Counter(c.get("memory_type", "general") for c in chunks)
|
||||
types_str = ", ".join(f"{t}:{n}" for t, n in type_counts.most_common())
|
||||
print(f" [DRY RUN] {filepath.name} → {len(chunks)} memories ({types_str})")
|
||||
print(f" [DRY RUN] {filepath.name} -> {len(chunks)} memories ({types_str})")
|
||||
else:
|
||||
print(f" [DRY RUN] {filepath.name} → room:{room} ({len(chunks)} drawers)")
|
||||
print(f" [DRY RUN] {filepath.name} -> room:{room} ({len(chunks)} drawers)")
|
||||
total_drawers += len(chunks)
|
||||
# Track room counts
|
||||
if extract_mode == "general":
|
||||
@@ -684,6 +1129,9 @@ def _mine_convos_impl(
|
||||
room_counts[c.get("memory_type", "general")] += 1
|
||||
else:
|
||||
room_counts[room] += 1
|
||||
files_mined += 1
|
||||
if limit > 0 and files_mined >= limit:
|
||||
break
|
||||
continue
|
||||
|
||||
if extract_mode != "general":
|
||||
@@ -692,7 +1140,15 @@ def _mine_convos_impl(
|
||||
# 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
|
||||
collection,
|
||||
source_file,
|
||||
chunks,
|
||||
wing,
|
||||
room,
|
||||
agent,
|
||||
extract_mode,
|
||||
authored_at=_extract_authored_at(filepath),
|
||||
content_hash=content_hash,
|
||||
)
|
||||
if skipped:
|
||||
files_skipped += 1
|
||||
@@ -700,15 +1156,24 @@ def _mine_convos_impl(
|
||||
for r, n in room_delta.items():
|
||||
room_counts[r] += n
|
||||
|
||||
for h, _ in new_items:
|
||||
mined_content_hashes[(wing, h)] = source_file
|
||||
total_drawers += drawers_added
|
||||
files_mined += 1
|
||||
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
|
||||
if limit > 0 and files_mined >= limit:
|
||||
break
|
||||
|
||||
if not dry_run:
|
||||
# Compute hallways before the FTS5 validation: the latter opens a direct sqlite
|
||||
# connection to the Chroma DB, which can invalidate the live collection handle on
|
||||
# some Chroma builds and make the hallway fetch fail.
|
||||
_compute_hallways_for_wing_safe(wing, collection, total_drawers, config=palace_config)
|
||||
_validate_palace_fts5_after_mine(palace_path)
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" Done.")
|
||||
print(f" Files processed: {len(files) - files_skipped}")
|
||||
print(f" Files processed: {files_processed - files_skipped}")
|
||||
print(f" Files skipped (already filed): {files_skipped}")
|
||||
print(f" Drawers filed: {total_drawers}")
|
||||
if room_counts:
|
||||
|
||||
+1437
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
"""Shared date-window parsing for read-path filters.
|
||||
|
||||
``list_drawers`` (#1128) and ``search`` (#463) accept the same optional
|
||||
``since``/``before`` bounds and compare them against drawer ``filed_at``
|
||||
metadata. The parsing and window semantics live here — a side-effect-free
|
||||
module — so ``searcher`` can use them without importing ``mcp_server``
|
||||
(whose import installs MCP stdio protection) and ``mcp_server`` keeps its
|
||||
existing behavior by aliasing these functions.
|
||||
|
||||
Semantics (issue spec, #1128):
|
||||
* ``since`` is inclusive, ``before`` is exclusive: ``[since, before)``.
|
||||
* Comparison is wall-clock and timezone-naive. ``filed_at`` values are
|
||||
written as naive local ISO strings (``datetime.now().isoformat()``)
|
||||
almost everywhere; the one aware writer (``diary_ingest``, UTC) is
|
||||
compared on its wall-clock fields after the offset is dropped.
|
||||
* A drawer whose ``filed_at`` is missing or unparseable is EXCLUDED
|
||||
whenever a bound is active — a date-filtered result must never
|
||||
silently include rows of unknown age.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def parse_date_bound(value: Optional[str] = None, field_name: str = "date") -> Optional[datetime]:
|
||||
"""Parse an optional ISO-8601 date/datetime filter bound.
|
||||
|
||||
Accepts a date (``"2026-04-01"``), a naive timestamp
|
||||
(``"2026-04-01T09:30:00"``), or one carrying a ``Z``/``+HH:MM`` offset.
|
||||
Returns a naive ``datetime`` for wall-clock
|
||||
comparison against drawer ``filed_at`` values, which are stored as naive
|
||||
local ISO strings (``datetime.now().isoformat()``). Any timezone offset on
|
||||
the input is dropped so an aware bound never raises a ``TypeError`` against
|
||||
a naive ``filed_at``. Comparison is therefore wall-clock, which is what the
|
||||
local-first single-machine model wants; an offset bound is matched on its
|
||||
wall-clock fields, not its absolute instant, so a bound whose offset differs
|
||||
from the zone ``filed_at`` was recorded in is matched by clock time.
|
||||
The accepted grammar is a date, an ISO timestamp (optionally fractional),
|
||||
and an optional ``Z``/``±HH:MM`` offset; other ISO 8601 forms (basic format,
|
||||
week dates) are outside the contract and are rejected on the Python 3.9 floor
|
||||
even where a newer ``fromisoformat`` would accept them.
|
||||
Blank / whitespace-only means "no filter" (``None``).
|
||||
Raises ``ValueError`` on an unparseable value so the caller can surface a
|
||||
clear error, mirroring the wing/room sanitizers.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field_name} must be an ISO date string")
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
# datetime.fromisoformat before Python 3.11 rejects a trailing "Z" (Zulu),
|
||||
# and appending "+00:00" would break a date-only value on 3.9/3.10
|
||||
# ("2026-04-01+00:00" is rejected there). Any offset is dropped below for
|
||||
# wall-clock comparison anyway, so just strip a trailing Z/z; both date and
|
||||
# date-time Zulu inputs then parse on the 3.9 floor.
|
||||
iso = value[:-1] if value.endswith(("Z", "z")) else value
|
||||
try:
|
||||
parsed = datetime.fromisoformat(iso)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"{field_name} must be an ISO date string "
|
||||
f"(e.g. '2026-04-01' or '2026-04-01T09:30:00'), got {value!r}"
|
||||
) from exc
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def parse_window(since: Optional[str] = None, before: Optional[str] = None):
|
||||
"""Parse a ``[since, before)`` pair, rejecting an inverted window.
|
||||
|
||||
Returns ``(since_dt, before_dt)`` — either side ``None`` when absent.
|
||||
Raises ``ValueError`` (naming the offending field or the inversion) so
|
||||
callers surface the same message everywhere a window is accepted.
|
||||
"""
|
||||
since_dt = parse_date_bound(since, "since")
|
||||
before_dt = parse_date_bound(before, "before")
|
||||
if since_dt is not None and before_dt is not None and since_dt >= before_dt:
|
||||
raise ValueError(f"since ({since!r}) must be earlier than before ({before!r})")
|
||||
return since_dt, before_dt
|
||||
|
||||
|
||||
def filed_at_in_window(
|
||||
filed_at, since_dt: Optional[datetime], before_dt: Optional[datetime]
|
||||
) -> bool:
|
||||
"""True if a drawer's ``filed_at`` falls in ``[since, before)``.
|
||||
|
||||
``since`` is inclusive and ``before`` is exclusive, matching the issue spec.
|
||||
Parsing (``Z``/offset normalization, tz drop) is delegated to
|
||||
``parse_date_bound`` so a bound and a ``filed_at`` are compared
|
||||
identically. A drawer whose ``filed_at`` is missing or unparseable cannot
|
||||
be confirmed in-window, so it is EXCLUDED whenever a bound is active — a
|
||||
date-filtered listing must never silently include rows of unknown age.
|
||||
"""
|
||||
try:
|
||||
filed_dt = parse_date_bound(filed_at, "filed_at")
|
||||
except ValueError:
|
||||
return False
|
||||
if filed_dt is None:
|
||||
return False
|
||||
if since_dt is not None and filed_dt < since_dt:
|
||||
return False
|
||||
if before_dt is not None and filed_dt >= before_dt:
|
||||
return False
|
||||
return True
|
||||
+35
-5
@@ -50,12 +50,29 @@ def _get_palace_path():
|
||||
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):
|
||||
def get_source_groups(
|
||||
col, min_count=MIN_DRAWERS_TO_CHECK, source_pattern=None, wing=None, palace_path=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.
|
||||
|
||||
``palace_path``, when passed, preflights HNSW divergence before count():
|
||||
a diverged segment can hit the #1222 SIGSEGV/panic class, which a
|
||||
try/except around count() cannot catch. Omitted by existing callers
|
||||
that don't have a palace_path handy (e.g. tests) -- the check is simply
|
||||
skipped in that case, matching this function's pre-existing behavior.
|
||||
"""
|
||||
if palace_path is not None:
|
||||
from .backends.chroma import hnsw_capacity_status
|
||||
|
||||
capacity_info = hnsw_capacity_status(palace_path, COLLECTION_NAME)
|
||||
if capacity_info.get("diverged"):
|
||||
print(f"\n HNSW index is diverged: {capacity_info.get('message', '')}")
|
||||
print(" Run `mempalace repair --mode from-sqlite --archive-existing` first.")
|
||||
return {}
|
||||
|
||||
total = col.count()
|
||||
groups = defaultdict(list)
|
||||
|
||||
@@ -134,7 +151,7 @@ def show_stats(palace_path=None):
|
||||
palace_path = palace_path or _get_palace_path()
|
||||
col = get_collection(palace_path, COLLECTION_NAME)
|
||||
|
||||
groups = get_source_groups(col)
|
||||
groups = get_source_groups(col, palace_path=palace_path)
|
||||
|
||||
total_drawers = sum(len(ids) for ids in groups.values())
|
||||
print(f"\n Sources with {MIN_DRAWERS_TO_CHECK}+ drawers: {len(groups)}")
|
||||
@@ -166,15 +183,28 @@ def dedup_palace(
|
||||
|
||||
col = get_collection(palace_path, COLLECTION_NAME)
|
||||
|
||||
# Preflight HNSW divergence before this function's own count() print --
|
||||
# get_source_groups's palace_path guard (added alongside this one) only
|
||||
# covers its own internal count(), not this earlier one. count() on a
|
||||
# diverged segment can hard-crash the process (#1222); a try/except
|
||||
# cannot catch that, so it must never be reached at all when diverged.
|
||||
from .backends.chroma import hnsw_capacity_status
|
||||
|
||||
capacity_info = hnsw_capacity_status(palace_path, COLLECTION_NAME)
|
||||
if capacity_info.get("diverged"):
|
||||
print(f"\n HNSW index is diverged: {capacity_info.get('message', '')}")
|
||||
print(" Run `mempalace repair --mode from-sqlite --archive-existing` first.")
|
||||
return
|
||||
|
||||
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}")
|
||||
print(f"{'-' * 55}")
|
||||
|
||||
if wing:
|
||||
print(f" Wing: {wing}")
|
||||
groups = get_source_groups(col, min_count, source_pattern, wing=wing)
|
||||
groups = get_source_groups(col, min_count, source_pattern, wing=wing, palace_path=palace_path)
|
||||
print(f"\n Sources to check: {len(groups)}")
|
||||
|
||||
t0 = time.time()
|
||||
@@ -197,7 +227,7 @@ def dedup_palace(
|
||||
|
||||
elapsed = time.time() - t0
|
||||
|
||||
print(f"\n{'─' * 55}")
|
||||
print(f"\n{'-' * 55}")
|
||||
print(f" Done in {elapsed:.1f}s")
|
||||
print(
|
||||
f" Drawers: {total_kept + total_deleted:,} → {total_kept:,} (-{total_deleted:,} removed)"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user