Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0163a7bec | ||
|
|
a5ec32561c | ||
|
|
40524d5b04 | ||
|
|
1247e175b5 | ||
|
|
6e9d057a42 | ||
|
|
36100f820e | ||
|
|
b2bab382f3 | ||
|
|
fa9b7e0525 | ||
|
|
b9cec32528 | ||
|
|
df5ca114b2 | ||
|
|
7d5e6c4e85 | ||
|
|
11d0a64202 | ||
|
|
d9e60d84ad | ||
|
|
b2ce45d301 | ||
|
|
03f9a73d44 | ||
|
|
710fa529c0 | ||
|
|
00041ca736 | ||
|
|
f34b78c8b1 | ||
|
|
2fc47a52fc | ||
|
|
1c26ecef0a | ||
|
|
5453ad701a | ||
|
|
1822756afe | ||
|
|
18f877869b | ||
|
|
0ff4121404 | ||
|
|
1d3eecbf9d | ||
|
|
02dd6dc19e | ||
|
|
1fbcb739c9 | ||
|
|
29c0c8059e | ||
|
|
db69d1f1cc | ||
|
|
6694198c29 | ||
|
|
4adc99f755 | ||
|
|
eebf48e975 | ||
|
|
018ded556b | ||
|
|
424da244ef | ||
|
|
d4c476b7d3 | ||
|
|
3a76360301 | ||
|
|
c08ec00f4d | ||
|
|
c70d5182dd | ||
|
|
bc7392aa29 | ||
|
|
c35686c9e1 | ||
|
|
25bfd37644 | ||
|
|
11a35de5ac | ||
|
|
d5ce97c7af | ||
|
|
ef8d83cc8a | ||
|
|
71804c0aa5 | ||
|
|
ea36a00f5f | ||
|
|
3d0d037b87 | ||
|
|
ead2c5d299 | ||
|
|
26bc3d4f91 | ||
|
|
ba30ab6951 | ||
|
|
83d53644bb | ||
|
|
2ff6283b32 | ||
|
|
75452380a8 | ||
|
|
8e21b5abd4 | ||
|
|
0d1c1fbcaa | ||
|
|
40e2c8b056 | ||
|
|
2a0ed0cb8f | ||
|
|
03ed4c45cf | ||
|
|
7b151039c9 | ||
|
|
5134a635ed | ||
|
|
f38d9eb109 | ||
|
|
aecd543d75 | ||
|
|
3893228e03 | ||
|
|
557b9b1908 | ||
|
|
f2291b0320 | ||
|
|
a1e19081d9 | ||
|
|
bdaac9d9a6 | ||
|
|
88a2ebb85a | ||
|
|
e272ed3fbf | ||
|
|
72685f3aef | ||
|
|
52c70c9f88 | ||
|
|
e9aee19433 | ||
|
|
ec6d2dde01 | ||
|
|
5488e7bb22 | ||
|
|
88493acd0d | ||
|
|
7cf9b17582 | ||
|
|
be05a2e179 | ||
|
|
be6dc033fd | ||
|
|
670aba974f | ||
|
|
8d8f54a807 | ||
|
|
be95ea7f33 | ||
|
|
435f0ad348 | ||
|
|
7c679ba625 | ||
|
|
921ff5a6fa | ||
|
|
bddba59ae3 | ||
|
|
e28ac94603 | ||
|
|
f0d236019a | ||
|
|
e334e257bf | ||
|
|
d92c741084 | ||
|
|
cb6bfd5231 | ||
|
|
a7c4ed24d7 | ||
|
|
6741b6908e | ||
|
|
ca5899e361 | ||
|
|
b68485dfd4 | ||
|
|
01880f674d | ||
|
|
8a9b2bed63 | ||
|
|
f3d9801c73 | ||
|
|
9b24cfc93b | ||
|
|
f4617b3d83 | ||
|
|
f2bed9284f | ||
|
|
eef053d750 | ||
|
|
74288f1cdd | ||
|
|
7b49478ef7 | ||
|
|
869ab38095 | ||
|
|
a85d432b54 | ||
|
|
55d79dc8cd | ||
|
|
0fdb480e12 | ||
|
|
aac8437979 | ||
|
|
5347c2c71c | ||
|
|
733e435332 | ||
|
|
46d9eb5df0 | ||
|
|
f854da779f | ||
|
|
67cda9d455 | ||
|
|
0c8314f919 | ||
|
|
642a073305 | ||
|
|
d9ab5b7fd3 | ||
|
|
ea6f2c0c4c | ||
|
|
d1e27b8c42 | ||
|
|
5ae83d8ec3 | ||
|
|
2c0ef2c04e | ||
|
|
53675dd194 | ||
|
|
7ede231da9 | ||
|
|
3824ea610c | ||
|
|
778f830cd0 | ||
|
|
e18981a527 | ||
|
|
ef0e45ad92 | ||
|
|
0cfb4b3ef1 | ||
|
|
6b042982e8 | ||
|
|
bb40a529fd | ||
|
|
37e7d394b8 | ||
|
|
eff844b168 | ||
|
|
2e441d17a2 | ||
|
|
4f36145c2e | ||
|
|
f9d939ae1b | ||
|
|
285b3b4f2e | ||
|
|
75ad8ae781 | ||
|
|
b8816e0fe2 | ||
|
|
03643eb507 | ||
|
|
32f4dfa26d | ||
|
|
7cee74c8c8 | ||
|
|
45df1a2657 | ||
|
|
0a62658051 | ||
|
|
19f8a4ff68 | ||
|
|
84f9726a39 | ||
|
|
c69a622a18 | ||
|
|
9e730098e9 | ||
|
|
beac5d9954 | ||
|
|
1888b671e2 | ||
|
|
a91b7ee5c2 | ||
|
|
5380189f82 | ||
|
|
0e65c54978 | ||
|
|
2ad379b547 | ||
|
|
3eb7980e55 | ||
|
|
3e6f6480c0 | ||
|
|
7fc260f752 | ||
|
|
b2f259c253 | ||
|
|
6f88b2a34e | ||
|
|
a690eb398f | ||
|
|
339924a387 | ||
|
|
2397481158 | ||
|
|
f854d86d2f | ||
|
|
2857948c1e | ||
|
|
6ffbf6ffc3 | ||
|
|
b4a9f2adf2 | ||
|
|
4b0fc44451 | ||
|
|
e9222b4c7b | ||
|
|
10733f1df4 | ||
|
|
01b3183e5d | ||
|
|
e4e25ed186 | ||
|
|
cbd6e5d65d | ||
|
|
2d50b214d4 | ||
|
|
8472d553a3 | ||
|
|
d07b730f08 | ||
|
|
6509071b8e | ||
|
|
5ddaf7abf6 | ||
|
|
cd98d6674e | ||
|
|
ac6c2b6af6 | ||
|
|
0e32b9643c | ||
|
|
f57f30025f | ||
|
|
2f509b4789 | ||
|
|
7fa27bd231 | ||
|
|
c3e1104e75 | ||
|
|
10a0bc1a2b | ||
|
|
d53129ed12 | ||
|
|
58da0b15c3 | ||
|
|
7a466054b6 | ||
|
|
942f9ababa | ||
|
|
95b60b8316 | ||
|
|
3f254a5c31 | ||
|
|
5e540da06b | ||
|
|
9dd56ecb0a | ||
|
|
73541d1606 | ||
|
|
96bb80a356 | ||
|
|
7bc6090026 | ||
|
|
3b5ebcc9fc | ||
|
|
4a0f330cc1 | ||
|
|
abe85763d4 | ||
|
|
4d98b05240 | ||
|
|
db28bf1e84 | ||
|
|
37f373124e | ||
|
|
d7ca3c59a3 | ||
|
|
446795ab0c | ||
|
|
4cfbd60639 | ||
|
|
d7f4638157 | ||
|
|
fdfaf017ab | ||
|
|
e7fe6cae14 | ||
|
|
a3e3691e86 | ||
|
|
4ffd0bd57a | ||
|
|
f4440f1ce0 | ||
|
|
72cbfb5967 | ||
|
|
7a61ac75a4 | ||
|
|
de7801ecff | ||
|
|
003e569b39 | ||
|
|
c3ec708b12 | ||
|
|
f80c9ffa56 | ||
|
|
342270d6e5 | ||
|
|
cfca40c5ec | ||
|
|
3bebef1503 | ||
|
|
b7f0a8af01 | ||
|
|
347464146d | ||
|
|
04c48dd0fe | ||
|
|
f5c8b095dd | ||
|
|
88a53b2ffa | ||
|
|
bc5d3fa911 | ||
|
|
3deebfed19 | ||
|
|
fe56797762 | ||
|
|
8bb17724a7 | ||
|
|
f30fdf2672 | ||
|
|
eb4de04339 | ||
|
|
6a8beef604 | ||
|
|
1e3e89a78f | ||
|
|
9dbb4ced83 | ||
|
|
57ac669dbc | ||
|
|
0d349c3d86 | ||
|
|
899a5ec4c6 | ||
|
|
22f3d9be30 | ||
|
|
a0b7ba005d | ||
|
|
5e33592ba2 | ||
|
|
c92256f08f | ||
|
|
4d33f14988 | ||
|
|
4400734867 | ||
|
|
414aa3e20b | ||
|
|
30fc0ab865 | ||
|
|
025dd03047 | ||
|
|
0908786921 | ||
|
|
8a1fd95b1b | ||
|
|
4aa93e82ad | ||
|
|
43aa1aa24e | ||
|
|
7294b69ac8 | ||
|
|
247744296d | ||
|
|
942aae3ed5 | ||
|
|
9d18a1c32e | ||
|
|
b99e54546b | ||
|
|
5de5b0923d | ||
|
|
74ff5e6b98 | ||
|
|
e5e7a57930 | ||
|
|
db80f6e26c | ||
|
|
0b8c2c158f | ||
|
|
6890948e09 | ||
|
|
452630e927 | ||
|
|
5e57404502 | ||
|
|
34bc08fb30 | ||
|
|
57854c7e2e | ||
|
|
5b07b869b0 | ||
|
|
bc24aa14e2 | ||
|
|
67248330c5 | ||
|
|
ee12c07c54 | ||
|
|
0d9929c0dd | ||
|
|
7773432bca | ||
|
|
8df944a54d | ||
|
|
40d7958ca1 | ||
|
|
1998aede66 | ||
|
|
99b820cb42 | ||
|
|
7e18a70796 | ||
|
|
91a60263e3 | ||
|
|
320aab31e3 | ||
|
|
5ed24ad061 | ||
|
|
036742e888 | ||
|
|
000acc1e33 | ||
|
|
9b7536a1f7 | ||
|
|
374fb5656b | ||
|
|
c4eeec8642 | ||
|
|
8faf0042b5 | ||
|
|
23d534f8f3 | ||
|
|
f13b9a46a2 | ||
|
|
91c1d159af | ||
|
|
ec5f4eba9d | ||
|
|
133dfbfb41 | ||
|
|
b9e41286fa | ||
|
|
8d49b009e0 | ||
|
|
0197b2eea9 | ||
|
|
865a36bc5c | ||
|
|
fe051adc73 | ||
|
|
ed2ba726c9 | ||
|
|
031512438e | ||
|
|
3d529e7028 | ||
|
|
9fbdba17ca | ||
|
|
25c885ae0b | ||
|
|
fbd0904799 | ||
|
|
a4868a3589 | ||
|
|
5fd09d3693 | ||
|
|
94f168929a | ||
|
|
7a757916b3 | ||
|
|
174ecaf42c | ||
|
|
431e42a720 | ||
|
|
f246d25b7f | ||
|
|
8a6ebbe363 | ||
|
|
55c83e9f3d | ||
|
|
19ce58c143 | ||
|
|
61d6c3cc3c | ||
|
|
a851c7a7df | ||
|
|
ae1c52e43b | ||
|
|
02a88b0864 | ||
|
|
bcd07916a3 | ||
|
|
8ac98f038c | ||
|
|
b291d2a240 | ||
|
|
e2444a190c | ||
|
|
1b1854e5ae | ||
|
|
4631d6a7db | ||
|
|
b150d33398 | ||
|
|
035fe6d658 | ||
|
|
659cb815ea | ||
|
|
9486d8b129 | ||
|
|
d4cc367261 | ||
|
|
ec9084f4d8 | ||
|
|
851ebebc29 | ||
|
|
70d4c5471e | ||
|
|
36a8f219c2 | ||
|
|
10a743d5d8 | ||
|
|
df6c7d0dc3 | ||
|
|
c7bd2cd8e4 | ||
|
|
14d7444abe | ||
|
|
9e7fa1ceb5 | ||
|
|
6aebf458ff | ||
|
|
6fcfd34aa4 | ||
|
|
1fd16daac2 | ||
|
|
d1583750e8 | ||
|
|
6d252a0de4 | ||
|
|
4f799afd76 | ||
|
|
102372b179 | ||
|
|
9947ad06df | ||
|
|
df3ee289fc | ||
|
|
818b7f4d48 | ||
|
|
4f00390335 | ||
|
|
c2e053176c | ||
|
|
b595cb3978 | ||
|
|
9b35d9f760 | ||
|
|
23ee2a02fd | ||
|
|
02aafc0715 | ||
|
|
810f9a56c4 | ||
|
|
74e9cbcfd3 | ||
|
|
35b033d77f | ||
|
|
1b00f93b37 | ||
|
|
48eb6271a7 | ||
|
|
5522d348a5 | ||
|
|
9e53228ea3 | ||
|
|
982d421510 | ||
|
|
67a067701e | ||
|
|
be89e49add | ||
|
|
9f5b8f5fd6 | ||
|
|
4fb0ee57e7 | ||
|
|
1a180cd7cb | ||
|
|
6d42f61e64 | ||
|
|
2a5914b630 | ||
|
|
87102fb188 | ||
|
|
cf0477bfa7 | ||
|
|
936f1866af | ||
|
|
04e11ae545 | ||
|
|
54c314d8d9 | ||
|
|
65b17a6e0f | ||
|
|
5e9451407f | ||
|
|
d657626736 | ||
|
|
2629ae5b71 | ||
|
|
0b316d4053 | ||
|
|
4949aab68b | ||
|
|
5d2da04bcd | ||
|
|
3004ac4ee4 | ||
|
|
32ec74d8eb | ||
|
|
caf503f442 | ||
|
|
62439e1368 | ||
|
|
dfba247454 | ||
|
|
fe6b8899bc | ||
|
|
a6b6e55247 | ||
|
|
2183d866f3 | ||
|
|
1531a253be | ||
|
|
5deb815f0b | ||
|
|
6a3a5c7a3d | ||
|
|
914945637c | ||
|
|
0c38deaab5 | ||
|
|
109d7f267c | ||
|
|
66090b2bcb | ||
|
|
8a130fc509 | ||
|
|
7af3bfae8f | ||
|
|
64266695b5 | ||
|
|
1b89b49b78 | ||
|
|
49e9e04a12 | ||
|
|
a2da0d6ef4 | ||
|
|
89904ed03f | ||
|
|
3f0cfd5ed4 | ||
|
|
fa7fe1d51f | ||
|
|
d0c8ecd847 | ||
|
|
552e9927b7 | ||
|
|
2b9f17c401 | ||
|
|
7690574dde | ||
|
|
feba7e8043 | ||
|
|
a3c778210b | ||
|
|
5189e0d652 | ||
|
|
118cbe40bd | ||
|
|
e17f219be8 | ||
|
|
efaa39bea9 | ||
|
|
7006a6b42d | ||
|
|
61dd6e7d9c | ||
|
|
74a31b70d3 | ||
|
|
24bf97bb65 | ||
|
|
4a088ea8e1 | ||
|
|
42b940d263 | ||
|
|
29ce7c7135 | ||
|
|
fed69935d3 | ||
|
|
6f33d52681 | ||
|
|
d137d12313 | ||
|
|
560fdbdc9f | ||
|
|
a17a8b734a | ||
|
|
41d45d9336 | ||
|
|
e4a2cd48a2 | ||
|
|
2e3e0b979c | ||
|
|
9e8281aab5 | ||
|
|
e5f5009f80 | ||
|
|
89f0eb5cb3 | ||
|
|
8c3d1ba86c | ||
|
|
28d4f67ba2 | ||
|
|
0e632df85d | ||
|
|
04d80eb363 | ||
|
|
c942f5866c | ||
|
|
41bff266a4 | ||
|
|
596f3d3a8e | ||
|
|
0cb9ee5c58 | ||
|
|
51919fef0c | ||
|
|
c8727b3a2d | ||
|
|
44c525ddd3 | ||
|
|
d8ac4c3abb | ||
|
|
9893fa2383 | ||
|
|
c88b8a2e17 | ||
|
|
8adf35a13c | ||
|
|
1657a79649 | ||
|
|
84e2aa16e4 | ||
|
|
15ea385554 | ||
|
|
542b53bb0f | ||
|
|
683e940f70 | ||
|
|
1dc55a791d | ||
|
|
be9214a190 | ||
|
|
5fe0c1c2ac | ||
|
|
e083cd6c84 | ||
|
|
fb1cf53919 | ||
|
|
5bf826046c | ||
|
|
5dfe853154 | ||
|
|
10cdd93cec | ||
|
|
c924d07b3e |
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"mempalace": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"-m",
|
||||
"mempalace.mcp_server"
|
||||
]
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ claude plugin add /path/to/mempalace
|
||||
|
||||
## Post-Install Setup
|
||||
|
||||
After installing the plugin, run the init command to complete setup (pip install, MCP configuration, etc.):
|
||||
After installing the plugin, run the init command to complete setup (installs the `mempalace` package via `uv tool` or `pip`, configures MCP, etc.):
|
||||
|
||||
```
|
||||
/mempalace:init
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/mempal-stop-hook.sh"
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/mempal-stop-hook.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/mempal-precompact-hook.sh"
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/mempal-precompact-hook.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
#!/bin/bash
|
||||
# MemPalace PreCompact Hook — thin wrapper calling Python CLI
|
||||
# All logic lives in mempalace.hooks_cli for cross-harness extensibility
|
||||
INPUT=$(cat)
|
||||
echo "$INPUT" | python3 -m mempalace hook run --hook precompact --harness claude-code
|
||||
run_mempalace_hook() {
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1 && python3 -c "import mempalace" >/dev/null 2>&1; then
|
||||
python3 -m mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
|
||||
python -m mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
run_mempalace_hook --hook precompact --harness claude-code
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
#!/bin/bash
|
||||
# MemPalace Stop Hook — thin wrapper calling Python CLI
|
||||
# All logic lives in mempalace.hooks_cli for cross-harness extensibility
|
||||
INPUT=$(cat)
|
||||
echo "$INPUT" | python3 -m mempalace hook run --hook stop --harness claude-code
|
||||
run_mempalace_hook() {
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1 && python3 -c "import mempalace" >/dev/null 2>&1; then
|
||||
python3 -m mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
|
||||
python -m mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
run_mempalace_hook --hook stop --harness claude-code
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"name": "mempalace",
|
||||
"source": "./.claude-plugin",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, guided setup.",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.5",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.5",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
@@ -9,11 +9,7 @@
|
||||
"commands": [],
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"-m",
|
||||
"mempalace.mcp_server"
|
||||
]
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -16,10 +16,10 @@ Ensure `mempalace` is installed:
|
||||
mempalace --version
|
||||
```
|
||||
|
||||
If not installed:
|
||||
If not installed (uv recommended):
|
||||
|
||||
```bash
|
||||
pip install mempalace
|
||||
uv tool install mempalace # or: pip install mempalace
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -6,7 +6,7 @@ Give your AI a persistent memory -- mine projects and conversations into a searc
|
||||
|
||||
- Python 3.9+
|
||||
- Codex CLI installed and configured
|
||||
- `pip install mempalace`
|
||||
- `uv tool install mempalace` (recommended) or `pip install mempalace`
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -39,12 +39,17 @@ git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
```
|
||||
|
||||
2. Install the Python package:
|
||||
2. Install the Python package so the `mempalace-mcp` script lands on
|
||||
your PATH (the bundled `plugin.json` invokes it by bare name):
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
uv tool install --editable . # or: pip install -e .
|
||||
```
|
||||
|
||||
Plain `uv sync` is **not** enough here — it installs the scripts into
|
||||
`.venv/bin/`, which Codex will not find unless you activate the venv
|
||||
before launching Codex.
|
||||
|
||||
3. The `.codex-plugin` directory is already in the repo root. Codex CLI will detect it automatically when you run Codex from inside the repository.
|
||||
|
||||
4. Initialize your palace:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh session-start"
|
||||
"command": "\"${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh\" session-start"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh stop"
|
||||
"command": "\"${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh\" stop"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh precompact"
|
||||
"command": "\"${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh\" precompact"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
HOOK_NAME="${1:?Usage: mempal-hook.sh <hook-name>}"
|
||||
INPUT_FILE=$(mktemp) || { echo "Failed to create temp file" >&2; exit 1; }
|
||||
cat > "$INPUT_FILE"
|
||||
cat "$INPUT_FILE" | python3 -m mempalace hook run --hook "$HOOK_NAME" --harness codex
|
||||
cat "$INPUT_FILE" | mempalace hook run --hook "$HOOK_NAME" --harness codex
|
||||
EXIT_CODE=$?
|
||||
rm -f "$INPUT_FILE" 2>/dev/null
|
||||
exit $EXIT_CODE
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.5",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
@@ -21,11 +21,7 @@
|
||||
"hooks": "./hooks.json",
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"-m",
|
||||
"mempalace.mcp_server"
|
||||
]
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
},
|
||||
"interface": {
|
||||
|
||||
@@ -17,6 +17,7 @@ jobs:
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
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
|
||||
|
||||
@@ -26,7 +27,8 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.9"
|
||||
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
|
||||
|
||||
@@ -36,7 +38,8 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.9"
|
||||
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
|
||||
lint:
|
||||
@@ -46,6 +49,7 @@ jobs:
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: 'pip'
|
||||
- run: pip install "ruff>=0.4.0,<0.5"
|
||||
- run: ruff check .
|
||||
- run: ruff format --check .
|
||||
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure GitHub Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
DOCS_EDIT_BRANCH: ${{ github.ref_name }}
|
||||
run: bun run docs:build
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: website/.vitepress/dist
|
||||
|
||||
@@ -63,4 +63,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
check-versions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract versions from all sources
|
||||
id: versions
|
||||
|
||||
@@ -36,3 +36,4 @@ venv/
|
||||
|
||||
# ChromaDB local data
|
||||
*.sqlite3-journal
|
||||
.envrc
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
+120
@@ -6,6 +6,125 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
---
|
||||
|
||||
## [3.3.5] — 2026-05-09
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **MCP `tool_search` now retries once on transient `Error finding id` from chromadb's HNSW flush window.** After a bulk CLI mine, ChromaDB's HNSW segment metadata can be unflushed for ~30-60s; wing-scoped MCP search hits `Internal error: Error finding id` during that window. `tool_search` now detects this transient via response-shape sniffing, drops both the MCP-local client cache and `_DEFAULT_BACKEND._clients` / `_freshness` for the palace, sleeps 2s, and retries once. Successful retries are tagged with `index_recovered: true` so callers can observe when it fired; non-transient errors bypass the retry path entirely. Partial fix for the broader #1315 cluster — `tool_check_duplicate` and other index-touching tools still need the same wrapper. (#1396, refs #1082, #1315)
|
||||
- **`mempalace_diary_read` silently dropped entries on agent-name case mismatch.** `tool_diary_write` stored the `agent` metadata verbatim after `sanitize_name`, which preserves case, while `tool_diary_read` filtered by exact match. Writing as `"Claude"` and reading as `"claude"` (or vice-versa) returned zero rows. Both endpoints now lowercase `agent_name` immediately after sanitization, so reads are case-insensitive and the default per-agent wing slug is stable across casings. **Behavior change:** entries written prior to this fix under mixed-case agent names will not match the new lowercase filter; run `mempalace repair` if you need to migrate legacy diary metadata. (#1243)
|
||||
- **Knowledge-graph triples with `valid_to < valid_from` were silently invisible.** `KnowledgeGraph.query_entity()` filters with `valid_from <= as_of AND valid_to >= as_of`, so an inverted interval matches no `as_of` and the row is durably stored but unreachable — a P0 data-integrity foot-gun any caller that mixes up the two date params can hit. `add_triple()` now rejects inverted intervals at write time with a clear `ValueError` naming both bounds. Open intervals (one bound only) and point-in-time facts (`valid_from == valid_to`) remain accepted unchanged. (#1214)
|
||||
- **`ChromaBackend.close_palace()` / `close()` did not release the SQLite file lock.** Evicted clients sat in `_clients` without `close()`, and chromadb 1.5.x retains the rust-side SQLite lock until GC. Reopening the same palace path after `shutil.rmtree` + recreate within one process failed with `SQLITE_READONLY_DBMOVED` (code 1032). New `_close_client()` helper now calls `PersistentClient.close()` (with a try/except fallback for older chromadb) on `close_palace()`, on whole-backend `close()`, and on the `_client()` invalidation path that detects a missing `chroma.sqlite3`. The mtime/inode auto-invalidation branch is intentionally left alone — callers there may still hold a live `ChromaCollection`. (#1067, #1105)
|
||||
- **`EntityRegistry.save()` could leave a corrupt or empty `entity_registry.json` on crash.** `Path.write_text()` is not atomic — kernel sees `open('w')` (truncate), `write`, `close`, and any failure between truncate and full-flush (power loss, OOM, FS-full, kill -9) wipes the months-of-mining people/projects map silently (the registry's `load()` swallows `JSONDecodeError`). Save now writes to a sibling `.tmp` in the same directory, `fsync`s, `chmod 0o600`s, then `os.replace()`s into place — atomic on POSIX and Windows. The previous registry stays intact on any crash before the rename returns. (#1215)
|
||||
- **`miner.detect_room` bidirectional substring matching caused systemic misrouting.** The priority-1 (path parts) and priority-2 (filename) checks used `c in part or part in c` against room names + keywords, so any token that was an unbounded substring of a room name (or vice versa) matched. Priority-1 iterates left-to-right and returns on first match, so `views/billing-page/src/Foo.test.tsx` routed to an `interviews` room because `"views" in "interviews"` matched before reaching `billing-page`. Both call sites now use a `_name_matches` helper that compares names as equal or as separator-bounded tokens of each other (split on `-`, `_`, `.`, `/`). (#1004, closes #1002)
|
||||
- **`mempalace compress` crashed on large palaces.** `regenerate_closets` fetched all closet_llm drawers in a single `col.get()`, which trips `SQLITE_MAX_VARIABLE_NUMBER` on palaces above ~32k drawers. Mirrors the #851 fix in `miner.py`: drawer fetch is now paginated at `batch_size=5000`. Per-source aggregation works across batches, so the LLM regeneration call still groups chunks correctly. (#1073, #1107)
|
||||
- **CLI and `fact_checker --stdin` mojibaked non-ASCII content on Windows.** Python defaults `sys.stdin`/`stdout`/`stderr` to the system ANSI codepage (cp1252/cp1251/cp950), so `mempalace search > out.txt` and piped fact_checker invocations corrupted Cyrillic / CJK drawer text at the process boundary. New `mempalace/_stdio.py` helper reconfigures all three streams to UTF-8 on `sys.platform == "win32"`, with per-stream `errors` policy: `surrogateescape` on stdin (preserves bad bytes from redirected files for the consumer's parser), `replace` on stdout/stderr (substitutes U+FFFD instead of `UnicodeEncodeError`-ing mid-print). With this, all three user-facing console_scripts (`mcp_server`, `hooks_cli`, `cli`/`fact_checker`) now reconfigure identically on Windows. (#1282)
|
||||
- **MCP knowledge-graph tools forwarded malformed date strings to SQLite.** `tool_kg_query` (`as_of`), `tool_kg_add` (`valid_from`), and `tool_kg_invalidate` (`ended`) accepted any string and produced empty result sets on natural-language inputs like `"March 2026"` or `"yesterday"` — callers (especially LLM agents) could not distinguish "no fact at this time" from "your date format was unrecognized." New `sanitize_iso_temporal()` validator in `config.py` (with `sanitize_iso_date()` retained as a backwards-compat wrapper) accepts `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM:SSZ`, and `YYYY-MM-DDTHH:MM:SS+00:00` (normalized to the `Z` form), and passes `None`/`""` through unchanged; all three KG tools call it before values reach the storage layer. Partial dates (`YYYY`, `YYYY-MM`), naive datetimes, and non-UTC timezone offsets are rejected because KG queries compare TEXT temporal values where mixed formats silently return wrong results. **Behavior change:** previously-silent date typos now raise a clear `ValueError` naming the offending field; partial-date inputs that worked in 3.3.4 (`"2026"`, `"2026-05"`) no longer parse — pass a full `YYYY-MM-DD` or a canonical UTC datetime instead. (#1164, #1167, #1374, #1417)
|
||||
- **MCP server's `_kg` was a module-level singleton.** Multi-tenant hosts that rotate `MEMPALACE_PALACE_PATH` between tool calls hit the wrong sqlite file, because the KG was constructed once at import time while the ChromaDB side was already per-call via `_get_client()`. The KG is now resolved per-call through a lazy per-path cache (`_kg_by_path` keyed by `os.path.abspath`, with a double-checked-locking init under `_kg_cache_lock`). `tool_reconnect` drains and `close()`s cached KGs alongside the existing chroma reconnect. A `_call_kg` retry guard catches `sqlite3.ProgrammingError` once after a reconnect race. (#1136, #1160)
|
||||
- **`mempalace repair` can now recover palaces whose HNSW segment writer is stuck on `apply_logs`.** Both the existing `--mode legacy` rebuild and the inline `cli.cmd_repair` path call `Collection.count()` as their first read — exactly the call that raises `chromadb.errors.InternalError: Failed to apply logs to the hnsw segment writer` on the corruption class introduced upstream and reported in #1308. Repair would print `Cannot recover — palace may need to be re-mined from source files` even though the underlying SQLite tables were fully intact (the corruption lives in the on-disk index files, not the data layer). New `--mode from-sqlite` reads `(id, document, metadata)` rows directly from `chroma.sqlite3` via a `segments` → `embeddings` → `embedding_metadata` join, never opens a chromadb client against the corrupt palace, and re-upserts everything into a fresh palace at `--palace`. `--source PATH` extracts from a corrupt palace already moved aside; `--archive-existing` handles the in-place case by renaming the existing palace to `<palace>.pre-rebuild-<timestamp>` before reading from it. Documents are re-embedded under the user's configured embedding function (the original HNSW vectors live in the corrupt `data_level0.bin` and cannot be recovered, but the embedding model is deterministic so search results remain semantically equivalent). Verified end-to-end on a 52,300-row real-world corrupt palace. (#1308)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **`CONTRIBUTING.md` git-identity guidance.** New section asks contributors to verify `git config user.name` and `git config user.email` before pushing, with an explicit warning for agentic coding tools that may not inherit the user's normal Git config. Avoids placeholder/template author values in commit history. (#1385, closes #1317)
|
||||
|
||||
### Internal
|
||||
|
||||
- **Test reliability: `multiprocessing` start method.** `tests/test_palace_locks.py` and `tests/test_chroma_collection_lock.py` switched from `fork` to `spawn` for child processes. Under Python 3.13 the pytest parent is multi-threaded by the time these tests run (chromadb + onnxruntime each spawn background threads on import); `fork` snapshotting that state into the child without the threads themselves deadlocked Linux 3.13 and macOS CI jobs indefinitely while Linux 3.9 / 3.11 / Windows finished normally. macOS additionally forbids fork-without-exec via CoreFoundation. `spawn` re-imports modules in the child (~0.5s per Process — bounded by the 10 subprocesses these tests fork) but is safe under threading. (#1431)
|
||||
- **Test cleanup: SQLite connection lifecycle.** Wrapped naked `conn = sqlite3.connect(...)` blocks in `tests/test_backends.py`, `tests/test_sources.py`, and `tests/test_repair.py` with `contextlib.closing(...)`. The flat `conn.close()` pattern at the end of each test leaked the connection on any exception or assertion failure between connect and close, producing `ResourceWarning: unclosed database` noise in CI logs and creating a secondary risk of advisory-lock starvation on Python 3.13 / macOS. Mirrors the `try/finally` pattern already used in production code. (#1430)
|
||||
|
||||
---
|
||||
|
||||
## [3.3.4] — 2026-04-30
|
||||
|
||||
### Added
|
||||
|
||||
- **`mempalace init` now prompts to mine the same directory.** After entity confirmation, room detection, and gitignore guard, `init` shows a one-line scope estimate (e.g. `~423 files (~12 MB) would be mined into this palace.`) computed from its existing corpus walk, then asks `Mine this directory now? [Y/n]` (default yes) and runs `mine()` in-process if accepted. The estimate fires before the prompt so users on a real corpus aren't surprised by a minutes-long ChromaDB write. Declining prints the exact `mempalace mine <dir>` command for later. (#1181)
|
||||
- **New `--auto-mine` flag on `mempalace init`** for the non-interactive path (`mempalace init --auto-mine <dir>` skips the mine prompt and runs mine directly). `--yes` retains its existing scope of entity auto-accept only and still prompts for the mine step, so existing scripted callers see no behaviour change; combining `--yes --auto-mine` gives a fully non-interactive setup. (#1181)
|
||||
- **Cross-wing topic tunnels.** When two wings have confirmed `TOPIC` labels in common (the LLM-refine bucket from `mempalace init --llm`), the miner now drops a symmetric tunnel between them at mine time so the palace graph reflects shared themes (frameworks, vendors, recurring concepts). Tunnels are routed through the existing `create_tunnel` storage so they share dedup and persistence with explicit tunnels. Topic tunnels are stored under a synthetic `topic:<name>` room and tagged with `kind: "topic"` on the stored dict — this keeps them distinct from literal folder-derived rooms of the same name (a wing with both an `Angular` folder room and an `Angular` topic tunnel no longer collides at `follow_tunnels` read time) and gives LLMs scanning `list_tunnels` a visible discriminator. Threshold is configurable via `MEMPALACE_TOPIC_TUNNEL_MIN_COUNT` env var or `topic_tunnel_min_count` in `~/.mempalace/config.json` (default `1`). Manifest-dependency overlap and per-topic allow/deny lists remain out of scope. (#1180)
|
||||
- **Context-aware corpus detection at `mempalace init`.** A new Pass 0 runs at the start of `init` — before entity detection — and answers one question: *is this corpus an AI-dialogue record, and if so, which platform and what persona names has the user assigned to the agents?* Tier 1 is a free regex heuristic (well-known AI brand terms + turn-marker patterns, with a co-occurrence rule that suppresses ambiguous terms like `Claude`/`Gemini`/`Haiku` when no unambiguous AI signal is present, so French novels and astrology forums don't false-positive). Tier 2 is an LLM call (~$0.01 with Anthropic Haiku, free with local Ollama/LM Studio/llama.cpp/vLLM) that extracts `user_name` and `agent_persona_names` from dialogue structure. Result is persisted to `<palace>/.mempalace/origin.json` with a `schema_version: 1` envelope so downstream tools can read it. Entity classification then routes names matching `agent_persona_names` (case-insensitive) into a new `agent_personas` bucket instead of `people`, so a Claude Code transcript no longer misclassifies the user's `Echo`/`Sparrow`/`Cipher` agents as biological people. `llm_refine` receives the same context as a system-prompt preamble so it can disambiguate other ambiguous candidates with corpus-level knowledge too. Backwards compatible: callers that don't pass `corpus_origin` see the v3.3.3 return shape unchanged. (#TBD)
|
||||
- **`mempalace init` runs LLM-assisted refinement by default.** v3.3.3 made `--llm` opt-in; the LLM-assisted path is qualitatively better (extracts persona names, refines ambiguous classifications) so it now runs by default. Provider precedence is unchanged — Ollama at `http://localhost:11434` first, then openai-compat, then anthropic with API key. **Never blocks init on a missing LLM**: if no provider is reachable (Ollama not running, no API key set), init prints a one-line message pointing at `--no-llm` and falls through to the heuristic-only path. `--no-llm` is the new explicit opt-out. The legacy `--llm` flag is preserved as a deprecated alias of the default so scripted callers see no behaviour change. Cost story: zero for users with a local LLM (the majority on this repo), ~$0.01 per init for users with `ANTHROPIC_API_KEY` set who explicitly choose `--llm-provider anthropic`, zero for users with no LLM (graceful fallback). (#TBD)
|
||||
- **`mempalace mine --redetect-origin` flag.** Re-runs corpus-origin detection on the current corpus state and overwrites `<palace>/.mempalace/origin.json`. Useful when the corpus has grown since `mempalace init` and the stored origin may be stale. Heuristic-only by design (the flag is meant to be cheap); re-run `mempalace init` for full Tier 2 LLM refinement. Default `mempalace mine` does not touch `origin.json` — the flag is opt-in. (#TBD)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **MCP server `tool_diary_write` SIGSEGV when default EF provider differs.** `mcp_server._get_collection` bypassed `ChromaBackend.get_collection` and called `client.get_collection` / `client.create_collection` without `embedding_function=`. ChromaDB 1.x persists the EF *identity* (its `name()`) with the collection but not the EF *instance/configuration*, so the MCP server's reopen silently bound chromadb's built-in `DefaultEmbeddingFunction` — its `name()` matches `mempalace.embedding`'s spoofed `"default"` so the identity check passes, but its provider list is chromadb's default rather than the user's resolved device. The miner / Stop hook ingest path routes through the backend helper and binds the configured EF instead. On bleeding-edge interpreters (python 3.14 + chromadb 1.5.x on Apple Silicon) the default provider selection could SIGSEGV the host process on first `col.add()`, killing the MCP stdio server and leaving every subsequent tool call returning `Connection closed` until Claude Code was relaunched. `_get_collection` now reuses `ChromaBackend._resolve_embedding_function()` on the reopen branches that actually open a collection (warm-cache reads stay zero-cost), matching the miner/backend path. (#1299, follow-up to #1262 / #1289)
|
||||
- **Hooks no longer recreate `~/.mempalace/` after the user removes it.** When `~/.mempalace/` is deleted (a strong "do not auto-capture" signal), the next `Stop`, `PreCompact`, or `SessionStart` hook would silently rebuild the dir hierarchy and ingest existing transcripts: `_log()` called `STATE_DIR.mkdir(parents=True, exist_ok=True)` unconditionally, so the very act of writing `[HH:MM] SESSION START …` recreated `~/.mempalace/hook_state/`; subsequent calls in the save path then materialized `palace/`, `wal/`, `knowledge_graph.sqlite3`, and N drawers from `~/.claude/projects/*.jsonl`. All four entry points (`hook_stop`, `hook_precompact`, `hook_session_start`, and `_log` itself) now check a new module-level `PALACE_ROOT = Path.home() / ".mempalace"` constant first and short-circuit (returning `{}` on stdout, never logging) when the directory is absent. The user-removable directory becomes a kill-switch — `rm -rf ~/.mempalace` is now a stable state. Net: 23 lines added in `mempalace/hooks_cli.py`, 5 unit tests in `tests/test_hooks_cli.py`. (#1305)
|
||||
- **Cross-wing topic tunnels for hyphenated dir names.** `mempalace init` recorded the `topics_by_wing` registry key under the raw directory name (e.g. `mempalace-public`), while `mempalace.yaml`'s `wing` field used the lower-cased + separator-collapsed slug (`mempalace_public`). At mine time the miner read the slug from the yaml and missed the registry, so `_compute_topic_tunnels_for_wing` returned `0` silently. Real-world: any project whose folder contained a hyphen or space lost every topic tunnel. Now both call sites route through a shared `normalize_wing_name()` in `config.py`. (#1194, follow-up to #1180)
|
||||
- **CLI `mempalace search` retrieval quality.** The CLI was using pure ChromaDB cosine distance with no BM25 rerank, so drawers containing every query term but embedding as noise (directory listings, diff output, shell logs) scored `Match: 0.0` alongside genuinely irrelevant results with no way to tell them apart. Wired the CLI through the same `_hybrid_rank` the `mempalace_search` MCP tool already used, and surfaced both `cosine=` and `bm25=` scores in the output so users see which component of the match is firing. MCP search was unaffected; this fixes the human-facing CLI parity gap.
|
||||
- **Legacy-palace distance-metric warning.** CLI search now detects palaces created before `hnsw:space=cosine` was consistently set and prints a one-line notice pointing at `mempalace repair`. Without the warning such palaces silently used L2 distance, under which the similarity display floored every result to `Match: 0.0`. New palaces mined today already set cosine correctly and now have invariant tests pinning that behavior so future refactors can't silently regress it. (#1179)
|
||||
- **Graceful Ctrl-C during `mempalace mine`.** Interrupting a long mine no longer dumps a multi-frame `KeyboardInterrupt` traceback. The main file-processing loop now catches the signal, prints `files_processed: N/M`, `drawers_filed: K`, and `last_file:` so the user knows what landed, then exits with code 130 (standard SIGINT). Already-filed drawers are upserted idempotently on re-mine via deterministic IDs, so resuming is safe. The hooks PID lock at `~/.mempalace/hook_state/mine.pid` is now also actively cleaned up in a `finally` when its entry points at us — clean exit, error, or interrupt — preventing the next hook fire from briefly waiting on a stale PID. (#1182)
|
||||
- **`mempalace init` is now idempotent across re-runs.** Running `init` twice on the same project produced different `origin.json` results because the first run wrote `entities.json` into the project directory, and the second run's corpus-origin sampling included that file as corpus content — shifting Tier 1's character-density math. Sampling now skips the per-project artifacts (`entities.json`, `mempalace.yaml`), so re-running `init` produces the same classification it did the first time. Pinned by an integration test in `tests/test_corpus_origin_integration.py`. (#TBD)
|
||||
|
||||
---
|
||||
|
||||
## [3.3.3] — 2026-04-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Install regression** — `mempalace-mcp` console script is now declared in `pyproject.toml` alongside `.claude-plugin/plugin.json`'s reference to it. In v3.3.2 the two drifted apart (plugin.json shipped the new `"command": "mempalace-mcp"` form before the matching entry point landed), so every fresh `pip install mempalace==3.3.2` produced a Claude Code plugin config pointing at a binary that wasn't installed. (#1093, #340)
|
||||
- Restore silent-save visibility after the Claude Code 2.1.114 client regression — production transcript saves were failing silently until this PR. (#1021)
|
||||
- Paginate `status`-path metadata fetches so large palaces don't trip SQLite variable limits. (#851)
|
||||
- Resolve the Claude plugin hook runner across platform / plugin-dir variations; previously broke on Windows and some macOS layouts. (#942)
|
||||
- Real `python3` resolution for `.sh` hooks with a `MEMPAL_PYTHON` override path. (#833)
|
||||
- Add optional `wing` parameter to `tool_diary_write` / `tool_diary_read` and derive per-project wing from the Claude Code transcript path when writing from the stop hook — diary entries from different projects no longer collapse into a shared default wing. (#659)
|
||||
- Treat empty string as "no filter" in `mempalace_search` `wing`/`room`; LLM agents that default to filling every optional parameter with `""` no longer get bounced with `must be a non-empty string`. (#1097, #1084)
|
||||
- Broaden `_wing_from_transcript_path` to handle Claude Code project folders without a `-Projects-` segment (e.g. `~/dev/<parent>/<project>`, `~/code/<project>`). The project name is now derived from the final dash-separated token of the encoded folder, so Linux users with code outside `~/Projects/` get per-project diary scoping instead of falling through to `wing_sessions`. (#1145, follow-up to #659)
|
||||
- `mempalace_diary_read(wing="")` now returns diary entries from every wing this agent has written to, matching the #1097 "empty-string as no filter" pattern. Previously defaulted to `wing_<agent>`, siloing entries that hooks wrote to project-derived wings. (#1145)
|
||||
- `mempalace mine` now skips the generated `entities.json` file so its contents aren't re-ingested as project content. (#1175)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Deterministic hook saves.** Save hook now uses a silent Python API path, so successive hook invocations produce reproducible results and zero data loss on the hot path. (#673)
|
||||
- **Graph cache with write-invalidation** inside `build_graph()` — warm-path calls no longer rebuild the palace-graph per request. (#661)
|
||||
- **`mempalace init` entity detection overhaul.** Canonical project names now come from package manifests (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`) and real people come from git commit authors, rather than being inferred from prose. Includes union-find dedup across name/email aliases, bot filtering that keeps `@users.noreply.github.com` humans, and automatic "mine" flagging by contribution share. (#1148)
|
||||
- **Regex detector accuracy.** CamelCase extraction so `MemPalace`, `ChromaDB`, `OpenAI` aren't fragmented; tighter versioned/hyphenated pattern kills `context-manager` / `multi-word` false positives; dialogue `^NAME:\s` requires ≥2 hits so `Created: <date>` metadata stops classifying field names as people; expanded stopwords for common English participles and descriptors; high-pronoun signal classifies as person rather than dumping to uncertain. (#1148)
|
||||
- **Init → miner wire-up.** Confirmed entities merge into `~/.mempalace/known_entities.json` on init, which the miner reads to tag drawer metadata for entity-filtered search. Previously init's output was not consumed by the miner; the per-project `entities.json` is kept as an audit trail. (#1157)
|
||||
- **Case-insensitive project dedup** across manifest, git, and convo sources so casing variants of the same project name collapse into one review entry. (#1175)
|
||||
|
||||
### Added
|
||||
|
||||
- i18n: Belarusian translation. (#1051)
|
||||
- i18n: entity detection for German, Spanish, and French locales. (#1001)
|
||||
- i18n: Traditional + Simplified Chinese entity detection. (#945)
|
||||
- **`mempalace init --llm`**: optional LLM-assisted entity classification. Defaults to local Ollama (zero-API); also supports any OpenAI-compatible endpoint (LM Studio, llama.cpp server, vLLM, OpenRouter, etc.) and the Anthropic Messages API. Runs interactively with a progress indicator; Ctrl-C cancels cleanly and returns partial results. Useful for prose-heavy folders where the regex detector struggles (diaries, transcripts, research notes). Opt-in only — default init path remains zero-API. (#1150)
|
||||
- **Claude Code conversation scanner.** `~/.claude/projects/<slug>/` directories now contribute project entities using each session's authoritative `cwd` metadata, avoiding slug-decoding ambiguity. (#1150)
|
||||
|
||||
### Known — deferred to v3.3.4
|
||||
|
||||
- HNSW parallel-insert SIGSEGV when `hnsw:num_threads` is unset on collection creation (#974) — fix in-flight as #976, awaiting rebase against develop.
|
||||
|
||||
---
|
||||
|
||||
## [3.3.2] — 2026-04-19
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix silent drop of `.jsonl` files in project miner; raise `MAX_FILE_SIZE` cap from 10 MB to 500 MB so large transcripts no longer fall through unnoticed. Adds a tandem **sweeper** — a message-level, timestamp-coordinated, idempotent safety net that catches anything the primary miner missed. (#998)
|
||||
- `mempalace sweep <target>` CLI to run the sweeper on demand against a transcript file or a directory. (#998)
|
||||
- Guard `Layer3.search_raw` against `None` doc/meta rows returned by ChromaDB — prevents `AttributeError` crashes on mixed-schema palaces. (#1011, #1013)
|
||||
- Guard searcher API path, closet loop, and miner status histogram against `None` metadata; matching guards added to `tool_status` / `list_wings` / `list_rooms` / `get_taxonomy` in the MCP server. (#999)
|
||||
- Upgrade `chromadb` floor to `>=1.5.4` for Python 3.13 / 3.14 compatibility and pin upper bound to `<2` so future breaking majors don't silently install. (#1010)
|
||||
- Fix Unicode checkmark rendering on Windows terminals that can't encode the `✓` glyph — avoids `UnicodeEncodeError` crashes on first-run output. (#681)
|
||||
- **`quarantine_stale_hnsw`** — on open, detect HNSW segment directories whose `data_level0.bin` is significantly older than `chroma.sqlite3` and rename them out of the way. Recovers cleanly from HNSW/sqlite drift that otherwise causes SIGSEGV on `count()` / `query(...)` (the chroma-core/chroma#2594 failure mode). Rebuilds the index lazily on next use. (#1000)
|
||||
- **PID file guard** — `mine` writes a per-source-directory PID file and refuses to start if an existing mine is still running, preventing process stacking that bloats HNSW and wedges concurrent writes. Includes cross-platform PID liveness check (`os.kill(pid, 0)` terminates on Windows, so the guard falls back to a platform-aware probe). (#1023)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **RFC 001 §10 — typed backend contracts.** `BaseBackend` now returns typed `QueryResult` / `GetResult` dataclasses and `PalaceRef` for palace identity; registry-based backend discovery. Internal refactor; no user-facing API change. (#995)
|
||||
- **RFC 002 §9 — source adapter scaffolding.** Introduces `BaseSourceAdapter`, adapter registry, and `PalaceContext` — the plumbing that future pluggable ingest sources will target. Internal refactor; no user-facing API change yet. (#1014)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **RFC 002** — full specification for the source adapter plugin system (future pluggable ingest). (#990)
|
||||
- First-run help text and `README` now reference the real `~/.claude/projects/<project>/` path shape instead of the placeholder `/path/to/transcripts`. (#996, #1012)
|
||||
|
||||
### Internal
|
||||
|
||||
- Harden sweeper for production: verbatim tool blocks, full `session_id`, logged failures.
|
||||
- Address Copilot review on #995: cursor tie-break, honest metrics, accurate comments.
|
||||
- Test hygiene: avoid ONNX network download in update-length validation tests; dedup update-length-validation tests; fix Windows file-lock in cache-invalidation test.
|
||||
|
||||
---
|
||||
|
||||
## [3.3.1] — 2026-04-16
|
||||
|
||||
### New Features
|
||||
@@ -88,6 +207,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
- Hall detection — routes drawer content to `emotions` / `technical` / `family` / `memory` / `identity` / `consciousness` / `creative` halls, enabling hall-based graph connectivity within wings (#835)
|
||||
|
||||
### Bug Fixes
|
||||
- Repair `max_seq_id` corruption caused by `_fix_blob_seq_ids` misinterpreting chromadb 1.5.x's sysdb-10 BLOB format (`b'\x11\x11'` + ASCII digits) as legacy 0.6.x big-endian BLOBs. The shim now skips the `max_seq_id` table entirely and guards the `embeddings` branch with a prefix check. New subcommand `mempalace repair --mode max-seq-id [--from-sidecar <path>]` restores affected palaces. Fixes silent drawer-write drops that began after chromadb 1.5.x upgrades on palaces that still had BLOB-typed `max_seq_id` rows at migration time.
|
||||
- Set `hnsw:space=cosine` metadata on all collection creation sites — fixes broken similarity scoring under ChromaDB's default L2 distance (#807, #218)
|
||||
- File-level locking prevents duplicate drawers when agents mine the same file concurrently (#784, #826)
|
||||
- Hybrid closet+drawer retrieval — closets boost ranking, never gate results (#795)
|
||||
|
||||
@@ -22,7 +22,7 @@ These are non-negotiable. Every PR, every feature, every refactor must honor the
|
||||
- **Verbatim always** — Never summarize, paraphrase, or lossy-compress user data. The system searches the index and returns the original words. If a user said it, we store exactly what they said. This is the foundational promise.
|
||||
- **Incremental only** — Append-only ingest after initial build. Never destroy existing data to rebuild. A crash mid-operation must leave the existing palace untouched.
|
||||
- **Entity-first** — Everything is keyed by real names with disambiguation by DOB, ID, or context. People matter more than topics.
|
||||
- **Local-first, zero API** — All extraction, chunking, and embedding happens on the user's machine. No cloud dependency for memory operations. No API keys required.
|
||||
- **Local-first, zero external API by default** — All extraction, chunking, embedding, and LLM-assisted refinement happens on the user's machine by default, using locally-hosted runtimes (Ollama, LM Studio, llama.cpp, vLLM, unsloth studio, etc.). External providers (Anthropic, OpenAI, Google) are supported via BYOK but are never required and never enabled silently. The system never sends user content to a service the user has not explicitly configured. "Local LLM" is not an external API — Ollama and equivalents running on localhost are part of the user's machine. External BYOK is always a deliberate user choice, never a default and never a silent fallback.
|
||||
- **Performance budgets** — Hooks under 500ms. Startup injection under 100ms. Memory should feel instant.
|
||||
- **Privacy by architecture** — The system physically cannot send your data because it never leaves your machine. No telemetry, no phone-home, no external service dependencies for core operations.
|
||||
- **Background everything** — Filing, indexing, timestamps, and pipeline work happen via hooks in the background. Nothing interrupts the user's conversation. Zero tokens spent on bookkeeping in the chat window.
|
||||
@@ -36,26 +36,26 @@ We do not accept summarization of user content, cloud storage/sync features, tel
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
uv sync --extra dev # recommended; or: pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
python -m pytest tests/ -v --ignore=tests/benchmarks
|
||||
uv run pytest tests/ -v --ignore=tests/benchmarks
|
||||
|
||||
# Run tests with coverage
|
||||
python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing
|
||||
uv run pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing
|
||||
|
||||
# Lint
|
||||
ruff check .
|
||||
uv run ruff check .
|
||||
|
||||
# Format
|
||||
ruff format .
|
||||
uv run ruff format .
|
||||
|
||||
# Format check (CI mode)
|
||||
ruff format --check .
|
||||
uv run ruff format --check .
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
+21
-5
@@ -10,13 +10,17 @@ git clone https://github.com/<your-username>/mempalace.git
|
||||
cd mempalace
|
||||
git remote add upstream https://github.com/MemPalace/mempalace.git
|
||||
|
||||
pip install -e ".[dev]" # installs with dev dependencies (pytest, build, twine)
|
||||
# Recommended: uv (https://docs.astral.sh/uv/) handles the venv for you
|
||||
uv sync --extra dev
|
||||
|
||||
# Or with pip in your own venv:
|
||||
# pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
All tests must pass before submitting a PR. Tests should run without API keys or network access.
|
||||
@@ -25,10 +29,10 @@ All tests must pass before submitting a PR. Tests should run without API keys or
|
||||
|
||||
```bash
|
||||
# Quick test (20 questions, ~30 seconds)
|
||||
python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json --limit 20
|
||||
uv run python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json --limit 20
|
||||
|
||||
# Full benchmark (500 questions, ~5 minutes)
|
||||
python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json
|
||||
uv run python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json
|
||||
```
|
||||
|
||||
See [benchmarks/README.md](benchmarks/README.md) for data download instructions and reproduction guide.
|
||||
@@ -49,7 +53,7 @@ assets/ ← logo + brand
|
||||
1. Fork the repo and create a feature branch: `git checkout -b feat/my-thing`
|
||||
2. Write your code
|
||||
3. Add or update tests if applicable
|
||||
4. Run `pytest tests/ -v` — everything must pass
|
||||
4. Run `uv run pytest tests/ -v` — everything must pass
|
||||
5. Commit with a clear message following [conventional commits](https://www.conventionalcommits.org/):
|
||||
- `feat: add Notion export format`
|
||||
- `fix: handle empty transcript files`
|
||||
@@ -93,3 +97,15 @@ If you're planning a significant change, open an issue first to discuss the appr
|
||||
## License
|
||||
|
||||
MIT — your contributions will be released under the same license.
|
||||
|
||||
## Git identity for contributions
|
||||
|
||||
Before pushing commits, verify that Git is configured with an email address that GitHub can associate with your account:
|
||||
|
||||
```bash
|
||||
git config user.name
|
||||
git config user.email
|
||||
```
|
||||
|
||||
This is especially important when commits are created through agentic coding tools or automation, because those tools may not inherit your normal shell Git configuration. Avoid placeholder values such as `your@email.com` or localized template text; unresolved author emails can create avoidable provenance and SBOM review friction for downstream users.
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
> domain — including `mempalace.tech` — is an impostor and may distribute
|
||||
> malware. Details and timeline: [docs/HISTORY.md](docs/HISTORY.md).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **🚨 Claude Code sessions expire in 30 days w/out auto-save hooks wired!** **[Read this →](https://github.com/MemPalace/mempalace/discussions/1388)**
|
||||
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/mempalace_logo.png" alt="MemPalace" width="240">
|
||||
@@ -45,17 +49,22 @@ Architecture, concepts, and mining flows:
|
||||
|
||||
## Install
|
||||
|
||||
We recommend [`uv`](https://docs.astral.sh/uv/) — `uv tool install` puts
|
||||
the `mempalace` CLI in an isolated environment on your PATH:
|
||||
|
||||
```bash
|
||||
pip install mempalace
|
||||
uv tool install mempalace
|
||||
mempalace init ~/projects/myapp
|
||||
```
|
||||
|
||||
If you prefer pip, `pip install mempalace` still works.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# Mine content into the palace
|
||||
mempalace mine ~/projects/myapp # project files
|
||||
mempalace mine ~/chats/ --mode convos # conversation exports
|
||||
mempalace mine ~/.claude/projects/ --mode convos # Claude Code sessions (scope with --wing per project)
|
||||
|
||||
# Search
|
||||
mempalace search "why did we switch to GraphQL"
|
||||
@@ -116,9 +125,9 @@ own research page for their published numbers.
|
||||
```bash
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
uv sync --extra dev # or: pip install -e ".[dev]"
|
||||
# see benchmarks/README.md for dataset download commands
|
||||
python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json
|
||||
uv run python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json
|
||||
```
|
||||
|
||||
---
|
||||
@@ -149,6 +158,10 @@ system prompt:
|
||||
Two Claude Code hooks save periodically and before context compression:
|
||||
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html).
|
||||
|
||||
For per-message recall on top of the file-level chunks the hooks produce,
|
||||
run `mempalace sweep <transcript-dir>` periodically — it stores one
|
||||
verbatim drawer per user/assistant message, idempotent and resume-safe.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
@@ -177,7 +190,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
<!-- Link Definitions -->
|
||||
[version-shield]: https://img.shields.io/badge/version-3.3.1-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||
[version-shield]: https://img.shields.io/badge/version-3.3.5-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/
|
||||
|
||||
@@ -344,7 +344,7 @@ The palace classifies each question into one of 5 halls. Pass 1 searches only wi
|
||||
```bash
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
uv sync --extra dev # or: pip install -e ".[dev]"
|
||||
mkdir -p /tmp/longmemeval-data
|
||||
curl -fsSL -o /tmp/longmemeval-data/longmemeval_s_cleaned.json \
|
||||
https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json
|
||||
@@ -724,8 +724,8 @@ python benchmarks/longmemeval_bench.py /tmp/longmemeval-data/longmemeval_s_clean
|
||||
The question: how much of the 96.6% → 99.4% improvement is the heuristics, and how much would come from just using a better embedding model?
|
||||
|
||||
```bash
|
||||
pip install fastembed
|
||||
python benchmarks/longmemeval_bench.py /tmp/longmemeval-data/longmemeval_s_cleaned.json \
|
||||
uv pip install fastembed # or: pip install fastembed
|
||||
uv run python benchmarks/longmemeval_bench.py /tmp/longmemeval-data/longmemeval_s_cleaned.json \
|
||||
--mode raw --embed-model bge-large
|
||||
```
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ python benchmarks/longmemeval_bench.py data/longmemeval_s_cleaned.json --mode hy
|
||||
# Setup
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
uv sync --extra dev # or: pip install -e ".[dev]"
|
||||
|
||||
# Download data
|
||||
mkdir -p /tmp/longmemeval-data
|
||||
|
||||
@@ -7,7 +7,7 @@ Run the exact same benchmarks we report. Clone, install, run.
|
||||
```bash
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
pip install -e ".[dev]"
|
||||
uv sync --extra dev # or: pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Benchmark 1: LongMemEval (500 questions)
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Mining throughput benchmark: per-chunk vs batched upsert, CPU vs GPU.
|
||||
|
||||
Compares the legacy per-chunk ``add_drawer`` loop against the batched
|
||||
``collection.upsert`` path introduced in the "batched upsert + GPU" PR.
|
||||
Runs both paths on an identical seeded synthetic corpus, reports
|
||||
wall-clock time + drawers/sec, and prints a markdown table suitable
|
||||
for pasting into a PR description.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
# CPU (whatever onnxruntime is installed — CPU if you don't have
|
||||
# onnxruntime-gpu):
|
||||
uv run python benchmarks/mine_bench.py
|
||||
|
||||
# GPU (NVIDIA):
|
||||
uv venv /tmp/gpu && source /tmp/gpu/bin/activate
|
||||
uv pip install -e '.[gpu]' 'nvidia-cudnn-cu12>=9,<10' \\
|
||||
'nvidia-cuda-runtime-cu12' 'nvidia-cublas-cu12'
|
||||
export LD_LIBRARY_PATH=$(python -c "import nvidia.cudnn, os; \\
|
||||
print(os.path.dirname(nvidia.cudnn.__file__)+'/lib')"):$LD_LIBRARY_PATH
|
||||
MEMPALACE_EMBEDDING_DEVICE=cuda python benchmarks/mine_bench.py
|
||||
|
||||
Flags
|
||||
-----
|
||||
|
||||
--device cpu|cuda|coreml|dml|auto Override MEMPALACE_EMBEDDING_DEVICE
|
||||
--scenarios small,medium,large Which scenarios to run
|
||||
--seed 42 RNG seed for reproducibility
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import string
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def build_corpus(dest: Path, n_files: int, paragraphs_per_file: int, seed: int) -> None:
|
||||
"""Generate ``n_files`` markdown files of random words under ``dest``."""
|
||||
rng = random.Random(seed)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for i in range(n_files):
|
||||
paragraphs = []
|
||||
for _ in range(paragraphs_per_file):
|
||||
words = [
|
||||
"".join(rng.choices(string.ascii_lowercase, k=rng.randint(3, 10)))
|
||||
for _ in range(12)
|
||||
]
|
||||
paragraphs.append(" ".join(words))
|
||||
(dest / f"doc_{i:03d}.md").write_text("\n\n".join(paragraphs))
|
||||
(dest / "mempalace.yaml").write_text(
|
||||
"wing: bench\n"
|
||||
"rooms:\n"
|
||||
" - name: general\n"
|
||||
" description: all\n"
|
||||
" keywords: [general]\n"
|
||||
)
|
||||
|
||||
|
||||
def _process_file_unbatched(filepath, project_path, collection, wing, rooms, agent, closets_col):
|
||||
"""Legacy per-chunk upsert path (pre-batching).
|
||||
|
||||
Reproduces the exact loop shape the miner used before this PR so the
|
||||
comparison is apples-to-apples; only the upsert granularity differs.
|
||||
"""
|
||||
from mempalace import miner
|
||||
from mempalace.palace import (
|
||||
build_closet_lines,
|
||||
file_already_mined,
|
||||
mine_lock,
|
||||
purge_file_closets,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
|
||||
source_file = str(filepath)
|
||||
if file_already_mined(collection, source_file, check_mtime=True):
|
||||
return 0, "general"
|
||||
try:
|
||||
content = filepath.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return 0, "general"
|
||||
content = content.strip()
|
||||
if len(content) < miner.MIN_CHUNK_SIZE:
|
||||
return 0, "general"
|
||||
room = miner.detect_room(filepath, content, rooms, project_path)
|
||||
chunks = miner.chunk_text(content, source_file)
|
||||
|
||||
with mine_lock(source_file):
|
||||
if file_already_mined(collection, source_file, check_mtime=True):
|
||||
return 0, room
|
||||
try:
|
||||
collection.delete(where={"source_file": source_file})
|
||||
except Exception:
|
||||
pass
|
||||
drawers_added = 0
|
||||
for chunk in chunks:
|
||||
miner.add_drawer(
|
||||
collection=collection,
|
||||
wing=wing,
|
||||
room=room,
|
||||
content=chunk["content"],
|
||||
source_file=source_file,
|
||||
chunk_index=chunk["chunk_index"],
|
||||
agent=agent,
|
||||
)
|
||||
drawers_added += 1
|
||||
if closets_col and drawers_added > 0:
|
||||
drawer_ids = [
|
||||
f"drawer_{wing}_{room}_"
|
||||
f"{hashlib.sha256((source_file + str(c['chunk_index'])).encode()).hexdigest()[:24]}"
|
||||
for c in chunks
|
||||
]
|
||||
closet_lines = build_closet_lines(source_file, drawer_ids, content, wing, room)
|
||||
closet_id_base = (
|
||||
f"closet_{wing}_{room}_"
|
||||
f"{hashlib.sha256(source_file.encode()).hexdigest()[:24]}"
|
||||
)
|
||||
closet_meta = {
|
||||
"wing": wing,
|
||||
"room": room,
|
||||
"source_file": source_file,
|
||||
"drawer_count": drawers_added,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"normalize_version": miner.NORMALIZE_VERSION,
|
||||
}
|
||||
purge_file_closets(closets_col, source_file)
|
||||
upsert_closet_lines(closets_col, closet_id_base, closet_lines, closet_meta)
|
||||
return drawers_added, room
|
||||
|
||||
|
||||
def mine_once(project_dir: str, palace_path: str, batched: bool) -> tuple[int, float]:
|
||||
"""Mine a project dir with either the batched (new) or per-chunk (old) path."""
|
||||
from mempalace import miner
|
||||
from mempalace.miner import load_config, scan_project
|
||||
from mempalace.palace import get_closets_collection, get_collection
|
||||
|
||||
project_path = Path(project_dir).resolve()
|
||||
config = load_config(project_dir)
|
||||
wing = config["wing"]
|
||||
rooms = config.get("rooms", [])
|
||||
files = scan_project(project_dir)
|
||||
collection = get_collection(palace_path)
|
||||
closets = get_closets_collection(palace_path)
|
||||
|
||||
total = 0
|
||||
t0 = time.perf_counter()
|
||||
for filepath in files:
|
||||
if batched:
|
||||
drawers, _ = miner.process_file(
|
||||
filepath=filepath,
|
||||
project_path=project_path,
|
||||
collection=collection,
|
||||
wing=wing,
|
||||
rooms=rooms,
|
||||
agent="bench",
|
||||
dry_run=False,
|
||||
closets_col=closets,
|
||||
)
|
||||
else:
|
||||
drawers, _ = _process_file_unbatched(
|
||||
filepath, project_path, collection, wing, rooms, "bench", closets
|
||||
)
|
||||
total += drawers
|
||||
return total, time.perf_counter() - t0
|
||||
|
||||
|
||||
def _reset_backend_caches() -> None:
|
||||
"""Drop the in-process client cache so each run pays cold-open cost equally."""
|
||||
from mempalace.palace import _DEFAULT_BACKEND
|
||||
|
||||
_DEFAULT_BACKEND._clients.clear()
|
||||
_DEFAULT_BACKEND._freshness.clear()
|
||||
|
||||
|
||||
def run_scenario(label: str, n_files: int, paragraphs_per_file: int, seed: int) -> dict:
|
||||
"""Run one scenario under both code paths and return a result dict."""
|
||||
print(f"\n=== {label}: {n_files} files × {paragraphs_per_file} paragraphs ===")
|
||||
results = {}
|
||||
for mode in ("unbatched", "batched"):
|
||||
tmp = Path(tempfile.mkdtemp(prefix=f"mp_{mode}_"))
|
||||
try:
|
||||
proj = tmp / "proj"
|
||||
palace = tmp / "palace"
|
||||
build_corpus(proj, n_files, paragraphs_per_file, seed=seed)
|
||||
_reset_backend_caches()
|
||||
drawers, dt = mine_once(str(proj), str(palace), batched=(mode == "batched"))
|
||||
rate = drawers / dt if dt > 0 else 0.0
|
||||
results[mode] = (drawers, dt, rate)
|
||||
print(f" {mode:10} {drawers:5} drawers in {dt:6.2f}s → {rate:7.1f} drawers/sec")
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
_, t_u, r_u = results["unbatched"]
|
||||
d_b, t_b, r_b = results["batched"]
|
||||
speedup = t_u / t_b if t_b > 0 else 0.0
|
||||
print(f" speedup: {speedup:.2f}× ({t_u:.2f}s → {t_b:.2f}s)")
|
||||
return {
|
||||
"label": label,
|
||||
"n_files": n_files,
|
||||
"paragraphs": paragraphs_per_file,
|
||||
"drawers": d_b,
|
||||
"unbatched_time": t_u,
|
||||
"unbatched_rate": r_u,
|
||||
"batched_time": t_b,
|
||||
"batched_rate": r_b,
|
||||
"speedup": speedup,
|
||||
}
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
"small": ("Small files (~50 paragraphs)", 10, 50),
|
||||
"medium": ("Medium files (~200 paragraphs)", 20, 200),
|
||||
"large": ("Large files (~500 paragraphs)", 10, 500),
|
||||
}
|
||||
|
||||
|
||||
def _env_summary(device_label: str) -> list[str]:
|
||||
"""Short hardware + version lines included with the printed table."""
|
||||
import platform
|
||||
|
||||
try:
|
||||
import chromadb
|
||||
|
||||
chromadb_v = chromadb.__version__
|
||||
except Exception:
|
||||
chromadb_v = "?"
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
|
||||
ort_v = ort.__version__
|
||||
providers = ",".join(p.replace("ExecutionProvider", "") for p in ort.get_available_providers())
|
||||
except Exception:
|
||||
ort_v = "?"
|
||||
providers = "?"
|
||||
|
||||
return [
|
||||
f"device: **{device_label}** (onnxruntime {ort_v}, providers={providers})",
|
||||
f"chromadb {chromadb_v} · python {sys.version.split()[0]} · {platform.platform()}",
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
|
||||
parser.add_argument(
|
||||
"--device",
|
||||
default=None,
|
||||
help="Override MEMPALACE_EMBEDDING_DEVICE (cpu|cuda|coreml|dml|auto)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scenarios",
|
||||
default="small,medium,large",
|
||||
help="Comma-separated scenario names (default: all)",
|
||||
)
|
||||
parser.add_argument("--seed", type=int, default=42)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.device:
|
||||
os.environ["MEMPALACE_EMBEDDING_DEVICE"] = args.device
|
||||
|
||||
from mempalace.embedding import describe_device, get_embedding_function
|
||||
|
||||
device_label = describe_device()
|
||||
print(f"Warming up ONNX model on device={device_label}...")
|
||||
ef = get_embedding_function()
|
||||
ef(["warmup sentence one", "warmup sentence two"])
|
||||
|
||||
picked = [s.strip() for s in args.scenarios.split(",") if s.strip()]
|
||||
results = []
|
||||
for key in picked:
|
||||
if key not in SCENARIOS:
|
||||
print(f"Unknown scenario {key!r}; choices: {sorted(SCENARIOS)}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
label, n_files, paras = SCENARIOS[key]
|
||||
results.append(run_scenario(label, n_files, paras, args.seed))
|
||||
|
||||
print("\n\n## Mining benchmark\n")
|
||||
for line in _env_summary(device_label):
|
||||
print(line + " ")
|
||||
print()
|
||||
print("| Scenario | Files | Drawers | Per-chunk (old) | Batched (new) | Speedup |")
|
||||
print("| --- | ---: | ---: | ---: | ---: | ---: |")
|
||||
for r in results:
|
||||
print(
|
||||
f"| {r['label']} | {r['n_files']} | {r['drawers']} | "
|
||||
f"{r['unbatched_time']:.2f}s · {r['unbatched_rate']:.0f} drw/s | "
|
||||
f"{r['batched_time']:.2f}s · {r['batched_rate']:.0f} drw/s | "
|
||||
f"**{r['speedup']:.2f}×** |"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,768 @@
|
||||
# RFC 002 — Source Adapter Plugin Specification
|
||||
|
||||
- **Status:** Draft
|
||||
- **Tracking issue:** [#989](https://github.com/MemPalace/mempalace/issues/989)
|
||||
- **Related:** [#274](https://github.com/MemPalace/mempalace/issues/274), [#23](https://github.com/MemPalace/mempalace/pull/23), [#169](https://github.com/MemPalace/mempalace/pull/169), [#232](https://github.com/MemPalace/mempalace/pull/232), [#567](https://github.com/MemPalace/mempalace/pull/567), [#98](https://github.com/MemPalace/mempalace/pull/98), [#591](https://github.com/MemPalace/mempalace/pull/591), [#592](https://github.com/MemPalace/mempalace/pull/592), [#702](https://github.com/MemPalace/mempalace/pull/702), [#981](https://github.com/MemPalace/mempalace/issues/981), [#244](https://github.com/MemPalace/mempalace/pull/244), [#419](https://github.com/MemPalace/mempalace/pull/419), [#300](https://github.com/MemPalace/mempalace/pull/300), [#952](https://github.com/MemPalace/mempalace/pull/952), [#389](https://github.com/MemPalace/mempalace/pull/389), [#434](https://github.com/MemPalace/mempalace/pull/434)
|
||||
- **Sibling spec:** [RFC 001 — Storage Backend Plugin Specification](001-storage-backend-plugin-spec.md)
|
||||
- **Spec version:** `1.0`
|
||||
|
||||
## Summary
|
||||
|
||||
A formal contract for MemPalace source adapters so third parties can ship `pip install mempalace-source-<name>` packages (Cursor, OpenCode, git, Slack, Notion, email, calendar, Whisper transcripts, …) that drop into `mempalace mine` without patching core. The spec defines the adapter interface, record shape, metadata schema contract, privacy class, entry-point registration, incremental-ingest semantics, closet integration, a declared-transformation model that replaces the informal "verbatim" promise with a verifiable one, conformance tests, and the refactor of the existing file and conversation miners into first-party adapters on the same contract.
|
||||
|
||||
RFC 001 formalized the write side (where drawers are stored). This RFC formalizes the read side (where content comes from). Both are required for MemPalace to function as a durable daemon managing heterogeneous palaces across many source types.
|
||||
|
||||
## Motivation
|
||||
|
||||
Six source ingesters are currently in flight, each solving the same problem a different way:
|
||||
|
||||
| PR / Issue | Source | Mechanism |
|
||||
|---|---|---|
|
||||
| [#274](https://github.com/MemPalace/mempalace/issues/274) | Cursor | `workspaceStorage/*.vscdb` SQLite extraction |
|
||||
| [#23](https://github.com/MemPalace/mempalace/pull/23) | OpenCode | SQLite session database |
|
||||
| [#169](https://github.com/MemPalace/mempalace/pull/169) | Pi agent | JSONL session normalizer |
|
||||
| [#232](https://github.com/MemPalace/mempalace/pull/232) | Cursor (JSONL variant) | JSONL normalizer |
|
||||
| [#567](https://github.com/MemPalace/mempalace/pull/567), [#98](https://github.com/MemPalace/mempalace/pull/98) | Git | `git log` + `gh pr view` with structured diff summary |
|
||||
| [#591](https://github.com/MemPalace/mempalace/pull/591), [#592](https://github.com/MemPalace/mempalace/pull/592) | Delphi Oracle | Real-time intelligence signals |
|
||||
| [#702](https://github.com/MemPalace/mempalace/pull/702) | Cursor + factory.ai | Combined session miners |
|
||||
|
||||
Plus three ingesters already grafted into core:
|
||||
|
||||
- `mempalace/miner.py` — filesystem project miner, fixed char-window chunking, keyword hall routing
|
||||
- `mempalace/convo_miner.py` — chat transcript miner with exchange-pair chunking
|
||||
- `mempalace/normalize.py` — format detection for four chat-export shapes (Claude Code JSONL, Codex JSONL, Claude.ai / ChatGPT / Slack JSON)
|
||||
|
||||
Plus one open proposal for a different ingest semantic:
|
||||
|
||||
- [#981](https://github.com/MemPalace/mempalace/issues/981) — path-level descriptions: mine metadata-as-content instead of raw bytes for matched paths. This is a legitimate third ingest mode (alongside chunked-content and whole-record) that the current architecture has no home for.
|
||||
|
||||
Each contributor has reinvented source discovery, source-item identity, incremental-ingest bookkeeping, metadata shape, and chunking strategy. Format detection for new chat exports lands in `normalize.py` as one more branch in an `if` chain. There is no shared abstraction, no conformance suite, and no contract new adapter authors can build against.
|
||||
|
||||
This is the same situation RFC 001 addresses for storage backends: a pattern that emerged organically, now needs a specification so the community can contribute cleanly and enterprises can build against a stable surface.
|
||||
|
||||
### Why this matters beyond developer tooling
|
||||
|
||||
The adapter pattern is source-agnostic. What has so far shown up as "Cursor transcripts" and "git commits" generalizes to:
|
||||
|
||||
- **Knowledge work** — Notion, Obsidian, Logseq, Google Docs, iA Writer, Zettlr
|
||||
- **Communications** — Slack, Discord, Teams, Signal backups, mbox/eml email, iMessage
|
||||
- **Research** — arXiv PDFs, Zotero libraries, bookmarked articles, Kindle highlights, web archives
|
||||
- **Creator workflows** — YouTube captions, podcast transcripts (Whisper/Deepgram), Descript projects
|
||||
- **Regulated domains** — medical records, legal filings, financial statements (all gated on §6 privacy class)
|
||||
|
||||
Enterprises key on their own domain metadata — `repo/PR/SHA` for engineering, `patient/encounter/CPT` for healthcare, `case/docket/jurisdiction` for legal. The schema lives in the adapter; the content lives in the drawer. This is how structured-data use cases are served without violating the byte-preservation commitments adapters make.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A source adapter ships as a standalone Python package; `pip install mempalace-source-<name>` is sufficient to use it.
|
||||
2. `mempalace mine` and the MCP mine tool are source-agnostic — all extraction goes through registered adapters. No `if source_type == 'foo'` branches in core.
|
||||
3. Content transformations are **declared** (§1.4): each adapter advertises the set of transformations it applies to source bytes. Byte-preserving adapters declare the empty set. Consumers can programmatically determine what happened to their data.
|
||||
4. Incremental ingest is cheap and correct: re-running mine only touches items whose source-side version changed, using the palace itself as the cursor (no sidecar).
|
||||
5. Each adapter declares a structured metadata schema. Enterprises index and filter on that schema. Core is schema-agnostic beyond the universal fields in §5.1.
|
||||
6. The existing `miner.py` and `convo_miner.py` become the first two first-party adapters on the new contract. Drawer metadata fields and field names are preserved — the spec adds fields, does not rename them.
|
||||
7. A privacy class is declarable at the adapter boundary so sensitive sources (medical, financial, personal comms) are handled with explicit policy rather than implicit trust.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Defining chunking. Each adapter owns its chunking strategy — tree-sitter for code, exchange-pair for chat, whole-record for a PR. Core does not impose a chunk size.
|
||||
- Defining live-stream / webhook shapes (the Delphi Oracle pattern of continuous signal ingestion). That is a separate future RFC; v1 is pull-mode.
|
||||
- Defining LLM-based structured extraction. Adapters MAY use an LLM; the spec does not mandate or standardize this.
|
||||
- Defining cross-adapter dedup. When the same content appears via two adapters (e.g., a PR body mined via `git` and as a conversation quote mined via `claude-code`), both drawers land. Deduplication policy is a separate concern handled at query time by `searcher.py`.
|
||||
- Defining closet construction. Core continues to build closets from adapter-yielded drawers (§1.7); the closet-building algorithm itself is not part of this spec.
|
||||
|
||||
---
|
||||
|
||||
## 1. Source adapter contract
|
||||
|
||||
### 1.1 Required method
|
||||
|
||||
All adapters implement `BaseSourceAdapter` with a single kwargs-only ingest method:
|
||||
|
||||
```python
|
||||
class BaseSourceAdapter(ABC):
|
||||
@abstractmethod
|
||||
def ingest(
|
||||
self,
|
||||
*,
|
||||
source: SourceRef,
|
||||
palace: PalaceContext,
|
||||
) -> Iterator[IngestResult]:
|
||||
"""Enumerate and extract content from a source.
|
||||
|
||||
Yields a stream of IngestResult values. Lazy adapters yield
|
||||
`SourceItemMetadata` ahead of the drawers for that item, so core
|
||||
can report progress and check `is_current` before the adapter
|
||||
commits to the fetch. Adapters with no lazy-fetch benefit may
|
||||
interleave `SourceItemMetadata` and `DrawerRecord` items freely.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def describe_schema(self) -> AdapterSchema:
|
||||
"""Declare the structured metadata this adapter attaches.
|
||||
|
||||
Returned value is stable for a given adapter version. Enterprises
|
||||
index on this schema; core uses it to validate adapter output.
|
||||
"""
|
||||
```
|
||||
|
||||
The single-method `ingest()` contract was chosen over a `discover` / `extract` split. Most current ingesters have no meaningful laziness benefit (filesystem walking is cheap, transcript normalizing is cheap). Adapters that do (git-mine's `gh pr list` vs `gh pr view`; hypothetical Slack/Notion API) express laziness by yielding `SourceItemMetadata` first and deferring fetch until core confirms staleness via `is_current()`.
|
||||
|
||||
### 1.2 Optional methods (default implementations on the ABC)
|
||||
|
||||
```python
|
||||
def is_current(
|
||||
self,
|
||||
*,
|
||||
item: SourceItemMetadata,
|
||||
existing_metadata: dict | None,
|
||||
) -> bool:
|
||||
"""Return True if the palace already has an up-to-date copy.
|
||||
|
||||
Called by core after querying the palace for existing drawers with
|
||||
matching source_file. The adapter compares its version token against
|
||||
the stored metadata and returns True to skip extraction.
|
||||
|
||||
Default implementation: returns False (always re-extract). Adapters
|
||||
advertising `supports_incremental` override this.
|
||||
"""
|
||||
return False
|
||||
|
||||
def source_summary(self, *, source: SourceRef) -> SourceSummary:
|
||||
"""Describe a source without extracting (e.g., 'git repo mempalace,
|
||||
847 commits, 132 PRs'). Default: returns empty summary."""
|
||||
return SourceSummary(description=self.name)
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
```
|
||||
|
||||
Core's incremental loop (pseudocode):
|
||||
|
||||
```python
|
||||
for result in adapter.ingest(source=source, palace=ctx):
|
||||
if isinstance(result, SourceItemMetadata):
|
||||
existing = ctx.collection.get(where={"source_file": result.source_file}, limit=1)
|
||||
if adapter.is_current(item=result, existing_metadata=existing):
|
||||
ctx.skip_current_item() # adapter stops yielding drawers for this item
|
||||
elif isinstance(result, DrawerRecord):
|
||||
ctx.upsert_drawer(result)
|
||||
```
|
||||
|
||||
### 1.3 Typed records
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class SourceRef:
|
||||
"""A handle to the source a user wants to ingest.
|
||||
|
||||
local_path is for filesystem-rooted sources (project dir, mbox file).
|
||||
uri is for URL-like references (github.com/org/repo, slack://workspace/channel).
|
||||
options carries adapter-specific config (non-secret values only; §M2).
|
||||
"""
|
||||
local_path: str | None = None
|
||||
uri: str | None = None
|
||||
options: dict = field(default_factory=dict)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceItemMetadata:
|
||||
"""Lightweight pointer yielded before drawers for lazy-fetch adapters."""
|
||||
source_file: str # Logical identity — filesystem path, PR URI, etc.
|
||||
version: str # Source-side version token (mtime, commit SHA, ETag, rev id).
|
||||
size_hint: int | None = None # Bytes, if known. Used for progress reporting.
|
||||
route_hint: RouteHint | None = None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DrawerRecord:
|
||||
"""One drawer's worth of content plus metadata."""
|
||||
content: str # Subject to §1.4 declared transformations.
|
||||
source_file: str # Foreign key to SourceItemMetadata.source_file.
|
||||
chunk_index: int = 0 # 0 for single-drawer items; 0..N-1 for chunked items.
|
||||
metadata: dict = field(default_factory=dict) # Flat: str/int/float/bool only. Must conform to adapter schema.
|
||||
route_hint: RouteHint | None = None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteHint:
|
||||
wing: str | None = None
|
||||
room: str | None = None
|
||||
hall: str | None = None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceSummary:
|
||||
description: str
|
||||
item_count: int | None = None
|
||||
|
||||
# IngestResult is the union type adapters yield.
|
||||
IngestResult = SourceItemMetadata | DrawerRecord
|
||||
|
||||
# PalaceContext carries collection handles, palace config, and progress hooks
|
||||
# into the adapter. Full definition in §9 (cleanup prerequisite).
|
||||
```
|
||||
|
||||
### 1.4 Declared transformations
|
||||
|
||||
Adapters cannot silently alter content. Every adapter declares the set of transformations it applies:
|
||||
|
||||
```python
|
||||
class BaseSourceAdapter(ABC):
|
||||
declared_transformations: ClassVar[frozenset[str]] = frozenset()
|
||||
```
|
||||
|
||||
The invariant: **no transformation is applied that is not declared in this set**. Adapters declaring `frozenset()` are byte-preserving end-to-end (modulo the read, which may itself involve `utf8_replace_invalid` — see below).
|
||||
|
||||
Reserved transformation names (v1):
|
||||
|
||||
| Name | Meaning |
|
||||
|---|---|
|
||||
| `utf8_replace_invalid` | Undecodable bytes replaced with U+FFFD on read (equivalent to `open(..., errors="replace")`). |
|
||||
| `newline_normalize` | CRLF / CR converted to LF. |
|
||||
| `whitespace_trim` | Leading / trailing whitespace stripped at a record boundary. |
|
||||
| `whitespace_collapse_internal` | Runs of three or more blank lines collapsed to two. |
|
||||
| `line_trim` | Each line individually stripped of leading / trailing whitespace. |
|
||||
| `line_join_spaces` | Adjacent lines joined with single spaces, newlines discarded. |
|
||||
| `blank_line_drop` | Empty lines between non-empty lines dropped. |
|
||||
| `strip_tool_chrome` | System tags, hook output, tool UI chrome removed (see `normalize.strip_noise`). |
|
||||
| `tool_result_truncate` | Tool output heads/tails kept; middle replaced with a marker string. |
|
||||
| `spellcheck_user` | User turns rewritten by spellcheck. |
|
||||
| `synthesized_marker` | Adapter inserts its own strings (e.g., `[N lines omitted]`, `[registry] …`, Slack provenance footer). |
|
||||
| `speaker_role_assignment` | Multi-party speakers alternately assigned `user` / `assistant` roles (Slack). |
|
||||
| `tool_result_omitted` | Some tool outputs fully omitted from transcript (e.g., Read/Edit/Write results in `normalize._format_tool_result`). |
|
||||
|
||||
Adapters MAY define their own transformation names for behaviors the reserved list does not cover. Third-party names SHOULD be prefixed with the adapter name to avoid collisions (e.g., `cursor.composer_ordering`).
|
||||
|
||||
**Capability derivation:**
|
||||
- `byte_preserving` — declared_transformations is empty AND output bytes equal input bytes for any source the adapter can read. Advertised via the `byte_preserving` capability (§2.1). MUST be verified by §7.2 round-trip test.
|
||||
- `declared_lossy` — declared_transformations is non-empty. The adapter's output is reproducible from source by applying *only* the declared transformations. MUST be verified by §7.3 declared-transformation test.
|
||||
|
||||
**Existing code mapping (for the cleanup PR):**
|
||||
|
||||
| Module | Declared transformations |
|
||||
|---|---|
|
||||
| `filesystem` (current `miner.py`) | `utf8_replace_invalid`, `whitespace_trim` |
|
||||
| `conversations` (current `convo_miner.py` + `normalize.py`) | `utf8_replace_invalid`, `newline_normalize`, `line_trim`, `line_join_spaces`, `blank_line_drop`, `whitespace_collapse_internal`, `strip_tool_chrome`, `tool_result_truncate`, `tool_result_omitted`, `spellcheck_user`, `synthesized_marker`, `speaker_role_assignment` |
|
||||
|
||||
The filesystem adapter is nearly byte-preserving today; the conversations adapter is extensively transformed. Both are honest after this spec lands because both are fully declared.
|
||||
|
||||
This replaces the MISSION.md promise of "verbatim always" with a stronger one: every adapter publishes what it does to your data, and the conformance suite verifies it hasn't lied. "Verbatim" becomes a capability some adapters hold (byte_preserving), not a global claim about a lossy pipeline.
|
||||
|
||||
### 1.5 Three ingest modes
|
||||
|
||||
A single adapter declares one or more of three modes via a class attribute:
|
||||
|
||||
```python
|
||||
class BaseSourceAdapter(ABC):
|
||||
supported_modes: ClassVar[frozenset[Literal["chunked_content", "whole_record", "metadata_only"]]]
|
||||
```
|
||||
|
||||
| Mode | Content origin |
|
||||
|---|---|
|
||||
| `chunked_content` | Source bytes, split into chunks the adapter chooses (current filesystem behavior). |
|
||||
| `whole_record` | Source bytes, one drawer per source item (e.g., PR → 1 drawer). |
|
||||
| `metadata_only` | Synthesized description of a source item (absorbs #981). The description bytes are authored by the user or adapter, not the source. Declared transformations (§1.4) do not apply — content is not derived from source bytes. |
|
||||
|
||||
`metadata_only` resolves #981: description-mode matches a path pattern and produces one drawer whose content is the user-authored description rather than the file contents. Conformance tests (§7.2, §7.3) skip `metadata_only` records.
|
||||
|
||||
An adapter MAY support multiple modes and select per-item; the per-item mode is recorded in `metadata["ingest_mode"]` (§5.1). This field already exists on conversation drawers (`convo_miner.py:346`) and is the only existing field whose semantics this spec extends rather than preserves.
|
||||
|
||||
### 1.6 Chunking delegation
|
||||
|
||||
Core does not impose chunking. `miner.py`'s 800-character sliding window is the filesystem adapter's default for unknown file types — not a contract. Adapter authors choose what makes sense:
|
||||
|
||||
- Code files → tree-sitter function/class boundaries (future enhancement to the filesystem adapter).
|
||||
- Chat transcripts → exchange pairs (current `convo_miner.py` behavior).
|
||||
- PRs → whole-record (current `git-mine` behavior in #567).
|
||||
- PDFs → page or section.
|
||||
- Voice transcripts → speaker turn.
|
||||
|
||||
The sole cross-adapter requirement for `chunked_content` mode: chunks for a given `source_file`, re-assembled in `chunk_index` order and accounting for declared transformations in §1.4, reproduce the adapter's internal representation of the source. The conformance suite verifies this.
|
||||
|
||||
### 1.7 Closet integration
|
||||
|
||||
Closets are the AAAK-compressed index layer (`palace.build_closet_lines`, `upsert_closet_lines`) that points to drawer content and enables LLM-scale scanning without reading every drawer. Closet-building is not an adapter concern:
|
||||
|
||||
- **Core builds closets** from adapter-yielded drawers as a post-step, via the existing `palace.py` helpers. Adapters do not call these APIs.
|
||||
- **Adapters MAY emit closet hints** in drawer metadata via a flat `;`-joined string:
|
||||
```python
|
||||
metadata["closet_hints"] = "decided GraphQL; migrated to Postgres; fixed PR-567"
|
||||
```
|
||||
Core splits on `;` and feeds these as candidate topics alongside the content-scanned ones in `build_closet_lines`. The git adapter can hint decision-signal quotes that raw content-scanning would miss; the conversations adapter can hint section headers; the filesystem adapter has no need and omits the field.
|
||||
- **metadata_only drawers get closets too.** Core builds them from the synthesized description content the same way it builds closets for any other drawer. This is how #981's path-level descriptions become searchable.
|
||||
- **Closet purging** remains keyed on `source_file` (`purge_file_closets` in `palace.py:221`). Adapters' source_file values must be stable so purge is correct on re-ingest.
|
||||
|
||||
Current `convo_miner.py` does not build closets for conversation drawers — an existing gap. The cleanup PR (§9) routes the conversations adapter through the same post-step closet builder as filesystem, closing the gap as a side effect.
|
||||
|
||||
---
|
||||
|
||||
## 2. Adapter contract
|
||||
|
||||
### 2.1 Identity and capabilities
|
||||
|
||||
```python
|
||||
class BaseSourceAdapter(ABC):
|
||||
name: ClassVar[str] # "filesystem", "cursor", "git", "slack", ...
|
||||
spec_version: ClassVar[str] = "1.0"
|
||||
adapter_version: ClassVar[str] # Independent of spec_version; recorded on every drawer.
|
||||
capabilities: ClassVar[frozenset[str]]
|
||||
supported_modes: ClassVar[frozenset[str]] # Per §1.5.
|
||||
declared_transformations: ClassVar[frozenset[str]] # Per §1.4.
|
||||
default_privacy_class: ClassVar[str] # Per §6.
|
||||
```
|
||||
|
||||
Defined capability tokens (v1):
|
||||
|
||||
| Token | Meaning |
|
||||
|---|---|
|
||||
| `byte_preserving` | `declared_transformations` is empty AND extracted content equals source bytes. |
|
||||
| `supports_incremental` | Implements `is_current()` meaningfully; `ingest()` respects `ctx.skip_current_item()`. |
|
||||
| `supports_structured_metadata` | Attaches fields beyond §5.1 universals. |
|
||||
| `supports_entity_hints` | Emits entity hints via `metadata["entity_hints_json"]` (§5.4). |
|
||||
| `supports_kg_triples` | Writes knowledge-graph triples directly to the SQLite KG (§5.5). |
|
||||
| `supports_closet_hints` | Emits `metadata["closet_hints"]` (§1.7). |
|
||||
| `requires_auth` | Needs credentials at runtime (env vars — §4.2). |
|
||||
| `requires_external_service` | Needs a running service (Slack API, email server). |
|
||||
| `requires_local_tool` | Needs a local binary (`gh`, `rg`, `whisper`). |
|
||||
| `adapter_owns_routing` | Returns authoritative `RouteHint` values from `ingest()` that core uses as-is (§G3 / §2.5). |
|
||||
| `respects_privacy_class` | Honors §6 privacy-class filtering. |
|
||||
|
||||
Capability tokens are free-form strings; third-party adapters MAY declare novel tokens for their ecosystem. Core only inspects the above.
|
||||
|
||||
### 2.2 Source references
|
||||
|
||||
See `SourceRef` in §1.3. The shape is deliberately open — adapters parse `uri` and `options` as they see fit. Core does not canonicalize URIs.
|
||||
|
||||
**Secrets in `SourceRef.options`:** credentials MUST NOT be placed in `options`. The spec reserves `options` for non-secret values (paths, filters, date ranges). Secrets come from env vars per §4.2. An adapter that reads a credential from `options` violates the spec and MUST be rejected by the conformance suite.
|
||||
|
||||
### 2.3 Lifecycle
|
||||
|
||||
1. `__init__`: lightweight. No I/O, no network, no credential fetch.
|
||||
2. First call to `ingest`: may open resources. All I/O is lazy.
|
||||
3. `close()`: releases all resources. After `close()`, further calls MUST raise `AdapterClosedError`.
|
||||
|
||||
### 2.4 Concurrency
|
||||
|
||||
An adapter instance is long-lived and serves many mine operations. Adapters MUST be thread-safe for concurrent `ingest` calls across different `SourceRef` values. MemPalace core serializes calls within a single `SourceRef` unless an adapter advertises `supports_parallel_ingest` (not in v1 — reserved for v1.1).
|
||||
|
||||
### 2.5 Routing
|
||||
|
||||
Routing is the adapter's responsibility. The filesystem adapter reads `mempalace.yaml` (hall keywords, rooms list) via `MempalaceConfig()` and returns `RouteHint(wing=..., room=..., hall=...)` on each drawer. This relocates `detect_room()` and `detect_hall()` (currently in `miner.py` and `convo_miner.py`) into their respective adapters.
|
||||
|
||||
Order of precedence for routing:
|
||||
1. Explicit `--wing` / `--room` CLI flags → passed through `SourceRef.options` → adapter honors verbatim.
|
||||
2. Palace config match (`mempalace.yaml` hall keywords, room keywords) → adapter computes.
|
||||
3. Adapter-internal fallback (e.g., filesystem adapter falls back to `"general"` room).
|
||||
|
||||
Adapters advertising `adapter_owns_routing` return the final answer; core uses it verbatim. Adapters not advertising it return None and core applies a generic fallback router (writing to wing `default`, room `general`, hall `general`). Absent any adapter, this is how `mempalace mine` behaves today.
|
||||
|
||||
### 2.6 Incremental ingest
|
||||
|
||||
`is_current()` is the incremental-ingest primitive. The palace itself is the cursor — no separate persisted state. Correctness requirements:
|
||||
|
||||
- The adapter's `SourceItemMetadata.source_file` MUST be stable across re-ingests of the same logical item. Filesystem adapter uses the absolute path (as today). Git adapter uses a URI shape like `github.com/org/repo#pr=567` or `github.com/org/repo#commit=abc123`.
|
||||
- `is_current()` returns True when the stored metadata matches the adapter's current version token. The default implementation returns False (always re-extract) — adapters advertising `supports_incremental` override.
|
||||
- Deletion tombstones: an adapter MAY yield a `SourceItemMetadata(source_file=..., version="__deleted__")` entry — core purges drawers with matching `source_file` and builds no new drawers for that item. Advertised via `supports_deletion_tombstones`.
|
||||
- Adapters without `supports_incremental` ignore `is_current()` and fully re-extract. Core logs a warning.
|
||||
|
||||
### 2.7 Errors
|
||||
|
||||
- `SourceNotFoundError` — the `SourceRef` does not resolve.
|
||||
- `AuthRequiredError` — adapter needs credentials; raises with a message describing which env vars to set.
|
||||
- `AdapterClosedError` — method called after `close()`.
|
||||
- `TransformationViolationError` — conformance suite raises this when the content round-trip requires an undeclared transformation.
|
||||
- `SchemaConformanceError` — a `DrawerRecord.metadata` is missing required fields declared in `describe_schema()` or violates declared types.
|
||||
|
||||
---
|
||||
|
||||
## 3. Registration and discovery
|
||||
|
||||
### 3.1 Entry points (primary mechanism)
|
||||
|
||||
Third-party adapters ship as installable packages:
|
||||
|
||||
```toml
|
||||
# pyproject.toml of mempalace-source-cursor
|
||||
[project.entry-points."mempalace.sources"]
|
||||
cursor = "mempalace_source_cursor:CursorAdapter"
|
||||
```
|
||||
|
||||
MemPalace discovers adapters at process start via `importlib.metadata.entry_points(group="mempalace.sources")`.
|
||||
|
||||
### 3.2 In-tree registry (secondary)
|
||||
|
||||
```python
|
||||
from mempalace.sources.registry import register
|
||||
|
||||
register("my-experimental-adapter", MyAdapter)
|
||||
```
|
||||
|
||||
Entry-point discovery and explicit `register()` populate the same registry. Explicit registration wins on name conflict.
|
||||
|
||||
### 3.3 Selection (explicit only — no auto-detect)
|
||||
|
||||
Unlike storage backends (RFC 001 §3.3), source adapters are never auto-detected. The user selects the adapter explicitly:
|
||||
|
||||
```bash
|
||||
mempalace mine --source cursor ~/ # explicit adapter
|
||||
mempalace mine --source git /path/to/repo # explicit adapter
|
||||
mempalace mine --source filesystem /path/to/project # explicit adapter
|
||||
mempalace mine /path/to/project # implicit: filesystem (default)
|
||||
```
|
||||
|
||||
The default when no `--source` is given is `filesystem`, preserving current `mempalace mine <path>` behavior.
|
||||
|
||||
**Backwards compatibility with `--mode`.** Current `cli.py:517-519` exposes `--mode {projects,convos}`. This spec maps:
|
||||
- `--mode projects` → `--source filesystem` (the new default)
|
||||
- `--mode convos` → `--source conversations`
|
||||
|
||||
`--mode` stays as a deprecated alias through v4.x with a deprecation warning on use; removed in v5.0.
|
||||
|
||||
Auto-detection would be hostile — a directory containing a `.git` folder, a `workspaceStorage/` subdir, and an `mbox` file is not a signal of user intent.
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration
|
||||
|
||||
### 4.1 Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"sources": {
|
||||
"my-cursor": {
|
||||
"type": "cursor",
|
||||
"workspace_storage": "~/Library/Application Support/Cursor/User/workspaceStorage"
|
||||
},
|
||||
"my-git": {
|
||||
"type": "git",
|
||||
"repos": ["/projects/mempalace", "/projects/site"]
|
||||
}
|
||||
},
|
||||
"palaces": {
|
||||
"work": {
|
||||
"sources": ["my-git"],
|
||||
"privacy_floor": "internal"
|
||||
},
|
||||
"personal": {
|
||||
"sources": ["my-cursor"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Single-user local mode: config is optional. `mempalace mine <path>` with no config uses the `filesystem` adapter and defaults.
|
||||
|
||||
### 4.2 Environment variables
|
||||
|
||||
- `MEMPALACE_SOURCE_<NAME>_*` — per-adapter secrets and connection info. Examples: `MEMPALACE_SOURCE_SLACK_TOKEN`, `MEMPALACE_SOURCE_NOTION_API_KEY`, `MEMPALACE_SOURCE_GIT_GITHUB_TOKEN`.
|
||||
- Secrets MUST be readable from env vars; config files carry structure, env vars carry credentials. Same rule as RFC 001 §4.2.
|
||||
|
||||
### 4.3 Adapter-specific options
|
||||
|
||||
`SourceRef.options` is a free-form dict of non-secret values (§2.2). Each adapter documents its accepted keys. Unknown keys MUST be ignored (forward compatibility); the adapter MAY log a warning.
|
||||
|
||||
---
|
||||
|
||||
## 5. Metadata schema contract
|
||||
|
||||
### 5.1 Universal fields
|
||||
|
||||
Existing drawer metadata fields are preserved — the spec adds the following:
|
||||
|
||||
| New field | Type | Added by | Purpose |
|
||||
|---|---|---|---|
|
||||
| `adapter_name` | `str` | core, from `BaseSourceAdapter.name` | Which registered source produced this drawer. |
|
||||
| `adapter_version` | `str` | adapter | Adapter's own version (distinct from palace `normalize_version`). Enables re-extract workflows targeted at drawers from a known-buggy adapter version. |
|
||||
| `privacy_class` | `str` | adapter default, config override | Per §6. |
|
||||
|
||||
Existing fields retain their current semantics (verified against `miner.py:542-561` and `convo_miner.py:338-350`):
|
||||
|
||||
| Existing field | Role in the spec |
|
||||
|---|---|
|
||||
| `source_file` | Functions as the adapter's source-item identifier. Adapter defines the shape — a filesystem path for filesystem, a URI like `github.com/org/repo#pr=123` for git. MUST be stable across re-ingests of the same logical item. |
|
||||
| `source_mtime` | Functions as the source-item version for filesystem. Adapters without mtime semantics MAY omit this field and use a different version discriminator (e.g., commit SHA in a separate `metadata["commit_sha"]` field); the spec only requires that `is_current()` can decide staleness from the stored metadata. |
|
||||
| `filed_at` | When the record was written. ISO-8601 string. |
|
||||
| `added_by` | Agent name (e.g., `lumi`, `claude-code`). Orthogonal to `adapter_name` — the agent is *who* triggered mining; the adapter is *how* data was extracted. |
|
||||
| `wing`, `room`, `hall` | Palace routing. Populated by adapter per §2.5. |
|
||||
| `chunk_index` | Per §1.6. Always 0 for `whole_record` / `metadata_only`. |
|
||||
| `normalize_version` | Palace-wide schema version (currently `palace.py:50`). Unchanged. Separate from `adapter_version`. |
|
||||
| `entities` | Semicolon-joined candidate entity names. Already flat; kept flat (§5.4 replacement). |
|
||||
| `ingest_mode` | Per §1.5. Already on conversation drawers; added to filesystem drawers by the cleanup PR. |
|
||||
| `extract_mode` | Conversation-adapter-specific (`exchange` vs `general`). Moves into the conversations adapter's declared schema per §5.2. |
|
||||
|
||||
**Nothing is renamed. Nothing is removed.** The spec formalizes the shape ingesters already converge on. Existing `where={"source_file": ...}` queries in `searcher.py`, `palace.py`, and callers keep working.
|
||||
|
||||
**Chroma metadata constraint:** all metadata values MUST be `str | int | float | bool`. No lists, no nested dicts. This matches RFC 001 §1.4 and the underlying ChromaDB contract. Structured side-data goes to the SQLite knowledge graph (§5.5) or to a declared flat JSON-encoded string field (§5.4).
|
||||
|
||||
### 5.2 Adapter schemas
|
||||
|
||||
Each adapter returns an `AdapterSchema` from `describe_schema()`:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class AdapterSchema:
|
||||
fields: dict[str, FieldSpec] # Keyed by metadata key.
|
||||
version: str
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FieldSpec:
|
||||
type: Literal["string", "int", "float", "bool", "delimiter_joined_string", "json_string"]
|
||||
required: bool
|
||||
description: str
|
||||
indexed: bool = False # Hint to backends that can build indexes (RFC 001 §2.1).
|
||||
# delimiter_joined_string: the delimiter character (default ";").
|
||||
delimiter: str = ";"
|
||||
# json_string: the JSON schema of the encoded object (informational only).
|
||||
json_schema: dict | None = None
|
||||
```
|
||||
|
||||
`delimiter_joined_string` covers the `entities` shape (current `;`-joined list of names). `json_string` is the escape hatch for adapters needing to pack nested data — the value stored is still a single flat `str` from Chroma's perspective, but the adapter is allowed to document its parsed shape.
|
||||
|
||||
Example for a hypothetical `slack` adapter:
|
||||
|
||||
```python
|
||||
AdapterSchema(
|
||||
version="1.0",
|
||||
fields={
|
||||
"channel_name": FieldSpec(type="string", required=True, description="Slack channel name", indexed=True),
|
||||
"channel_id": FieldSpec(type="string", required=True, description="Slack channel ID"),
|
||||
"thread_ts": FieldSpec(type="string", required=False, description="Thread root timestamp"),
|
||||
"author_id": FieldSpec(type="string", required=True, description="Slack user ID", indexed=True),
|
||||
"author_name": FieldSpec(type="string", required=True, description="Display name at extraction time"),
|
||||
"reactions": FieldSpec(type="delimiter_joined_string", required=False, description="Emoji shortcodes"),
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### 5.3 Enterprise keying
|
||||
|
||||
The adapter schema is the stable surface enterprises filter on. A support team querying the palace for `channel_id = "C01234"` does not care about ChromaDB's internal representation. The schema field is declared by the adapter, indexed by the backend (RFC 001 §2.1 `supports_metadata_filters`), and exposed through the existing `where=` clause.
|
||||
|
||||
This is how "structured data" serves company use cases without breaking transformation guarantees: declared-transformation content in the drawer, structured fields in the metadata, schema declared by the adapter, filtering done by the backend.
|
||||
|
||||
### 5.4 Entity hints (optional)
|
||||
|
||||
Adapters with `supports_entity_hints` MAY include:
|
||||
|
||||
```python
|
||||
metadata["entity_hints_json"] = '[{"type":"person","name":"Milla Jovovich","confidence":0.95,"offset":120},{"type":"project","name":"MemPalace","confidence":1.0,"offset":0}]'
|
||||
```
|
||||
|
||||
The value is a JSON-encoded string (type `json_string` in the adapter schema). Core parses on read and feeds into `mempalace/entity_detector.py` as a prior: hints with `confidence >= 0.9` bypass the heuristic detector; lower-confidence hints feed into it as candidates.
|
||||
|
||||
This is additive to the existing flat `entities` field — entity_hints carries structure (type, confidence, offset); `entities` remains the Chroma-indexable flat string. An adapter that produces entity_hints MUST also populate `entities` as the flat name-only projection, so existing filter queries keep working.
|
||||
|
||||
### 5.5 Knowledge-graph triples (optional)
|
||||
|
||||
Adapters with `supports_kg_triples` write directly to the SQLite knowledge graph via `mempalace/knowledge_graph.py` — **not** to drawer metadata. Chroma cannot store structured triples; the KG already exists for this purpose.
|
||||
|
||||
The adapter calls the existing `KnowledgeGraph.add_triple()` (signature verified against `mempalace/knowledge_graph.py:130`):
|
||||
|
||||
```python
|
||||
palace.kg.add_triple(
|
||||
subject="Ben",
|
||||
predicate="committed",
|
||||
obj="PR-567", # `object` is a Python builtin — the API uses `obj`.
|
||||
valid_from="2026-03-12",
|
||||
confidence=1.0,
|
||||
source_file=drawer.source_file, # Existing provenance parameter.
|
||||
)
|
||||
```
|
||||
|
||||
Drawer metadata includes a flat counter — `metadata["kg_triples_count"]: int` — so search consumers can see at a glance that KG side-data exists for a drawer without hitting SQLite.
|
||||
|
||||
The existing API has `source_closet` and `source_file` provenance parameters but no `source_drawer_id` or `adapter_name`. The cleanup PR (§9) should add these two optional parameters to `add_triple()` so adapter-written triples can be traced back to (a) the specific drawer that produced them and (b) the adapter that authored them — necessary for re-extraction workflows. Until that lands, adapters use `source_file` as the provenance key and record adapter authorship via a separate table or a predicate naming convention (e.g., `adapter:git:committed`).
|
||||
|
||||
This aligns with the existing architecture in `CLAUDE.md` ("Knowledge Graph: ENTITY → PREDICATE → ENTITY with valid_from / valid_to dates") — the RFC formalizes the adapter-side write path.
|
||||
|
||||
### 5.6 Source encoding and newline
|
||||
|
||||
Current ingesters handle encoding lossily (`errors="replace"` in `miner.py:595` and `normalize.py:124`) and do not record original encoding. The spec does **not** require per-drawer `source_encoding` / `source_newline` — most runs are uniform UTF-8 / LF, and storing the same value on every drawer wastes bytes.
|
||||
|
||||
Instead: adapters that handle non-UTF-8 or non-LF sources record the values once on the adapter's `SourceSummary` and per-drawer only when a specific drawer diverges from the adapter default. The `utf8_replace_invalid` declared transformation (§1.4) already communicates that lossy decoding happened; specific drawer-level provenance is opt-in.
|
||||
|
||||
---
|
||||
|
||||
## 6. Privacy class
|
||||
|
||||
### 6.1 Defined levels
|
||||
|
||||
| Level | Meaning | Example sources |
|
||||
|---|---|---|
|
||||
| `public` | Content intended for public consumption. | arXiv papers, public GitHub repos, published blogs. |
|
||||
| `internal` | Organizational content, not for public disclosure. | Corporate Slack, internal Notion, private git repos. |
|
||||
| `pii_potential` | May contain personally identifiable information. | Email, iMessage, Claude/ChatGPT transcripts. |
|
||||
| `sensitive` | Known to contain PII, financial, or health data. | Medical records, financial statements, legal filings. |
|
||||
| `secrets_possible` | May contain credentials or secrets. | Git history, environment dumps, CI logs. |
|
||||
|
||||
An adapter declares a default on `BaseSourceAdapter.default_privacy_class`. Users MAY override per-source in config.
|
||||
|
||||
### 6.2 Enforcement
|
||||
|
||||
- Each palace declares a `privacy_floor`. Drawers above the floor (equal to or laxer) are admitted; drawers below are rejected at write time and surfaced in a `rejected` list on the CLI and MCP tool.
|
||||
- **Default floor: none** — v1 accepts all levels unless the palace explicitly configures a floor. This keeps the single-user local default low-friction (users who run `mempalace mine` on a git repo expect `secrets_possible` drawers to land). Enterprise deployments MUST set a floor; docs for regulated-domain setup will recommend starting strict and relaxing as needed.
|
||||
- Search results surface `privacy_class` in result metadata. MCP tool wrappers MAY redact results above a caller-declared ceiling.
|
||||
- `secrets_possible` drawers SHOULD pass through a secrets-scan pre-index hook when one is available. PR #389 (sensitive content scanner) is the expected enforcement mechanism for v1; until it lands, `secrets_possible` is a label without automated scanning. The label is still useful — it enables floor-based rejection and alerts downstream consumers.
|
||||
- The privacy class is recorded in drawer metadata and cannot be downgraded without a migration log entry, matching RFC 001's embedder-identity pattern.
|
||||
|
||||
Privacy class is how a regulated-domain deployment (medical, legal, financial) can use MemPalace safely. Without it, flexible ingest becomes a liability; with it, ingest is scoped by policy.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing contract
|
||||
|
||||
### 7.1 The abstract suite
|
||||
|
||||
MemPalace ships `mempalace.sources.testing.AbstractSourceAdapterContractSuite` — a pytest mixin. Every adapter package ships a concrete subclass:
|
||||
|
||||
```python
|
||||
from mempalace.sources.testing import AbstractSourceAdapterContractSuite
|
||||
|
||||
class TestCursorAdapter(AbstractSourceAdapterContractSuite):
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
return CursorAdapter()
|
||||
|
||||
@pytest.fixture
|
||||
def fixture_source(self, tmp_path):
|
||||
"""Build a minimal Cursor workspaceStorage fixture."""
|
||||
...
|
||||
return SourceRef(local_path=str(tmp_path))
|
||||
|
||||
@pytest.fixture
|
||||
def canonical_source_bytes(self, fixture_source):
|
||||
"""Return a mapping of source_file -> authoritative bytes.
|
||||
|
||||
For filesystem sources: the file's raw bytes.
|
||||
For SQLite sources: the extracted value column bytes for each row.
|
||||
For API sources: the canonical HTTP response body bytes.
|
||||
|
||||
Adapter-defined — the adapter knows what its 'source bytes' are.
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
The suite covers:
|
||||
|
||||
- `ingest` yields items with stable `source_file` and well-formed `version`.
|
||||
- `is_current()` returns True when metadata matches, False when it differs.
|
||||
- `close()` releases resources; subsequent calls raise `AdapterClosedError`.
|
||||
- Unicode content and unicode identifiers are preserved end-to-end.
|
||||
- Large-source handling: 10k+ items ingest without loading all into memory.
|
||||
- Error paths: `SourceNotFoundError`, `AuthRequiredError` raise with correct types.
|
||||
- `SourceRef.options` MUST NOT contain secrets — the adapter raises if it detects a value matching a common-secret pattern (GitHub token prefix, Slack token prefix, etc.). Advisory test, not blocking.
|
||||
|
||||
### 7.2 Byte-preserving round-trip (for `byte_preserving` adapters only)
|
||||
|
||||
Required for adapters advertising `byte_preserving`:
|
||||
|
||||
```python
|
||||
def test_byte_preserving_round_trip(self, adapter, fixture_source, canonical_source_bytes):
|
||||
"""Concatenated chunks must equal the canonical source bytes.
|
||||
|
||||
For each source_file in the fixture:
|
||||
1. Read canonical_source_bytes[source_file].
|
||||
2. Collect all DrawerRecords for that source_file from adapter.ingest(...).
|
||||
Skip metadata_only drawers (§1.5).
|
||||
3. Sort by chunk_index.
|
||||
4. Concatenate record.content values.
|
||||
5. Assert equality with the canonical bytes (UTF-8 decoded).
|
||||
"""
|
||||
```
|
||||
|
||||
Failure raises `TransformationViolationError`.
|
||||
|
||||
### 7.3 Declared-transformation round-trip (for `declared_lossy` adapters)
|
||||
|
||||
Required for adapters with non-empty `declared_transformations`:
|
||||
|
||||
```python
|
||||
def test_declared_transformation_round_trip(self, adapter, fixture_source, canonical_source_bytes):
|
||||
"""Adapter output must be reproducible by applying ONLY declared transformations.
|
||||
|
||||
1. For each source_file, read canonical_source_bytes.
|
||||
2. Apply each declared transformation in declared_transformations to the bytes,
|
||||
in the order declared by the adapter, using the reference implementations
|
||||
in mempalace.sources.transforms.
|
||||
3. Compare the result to the concatenated record.content values.
|
||||
4. If they differ, the adapter has applied a transformation it did not declare.
|
||||
Raise TransformationViolationError.
|
||||
"""
|
||||
```
|
||||
|
||||
For transformations not in the reserved list (§1.4) — adapter-custom names — the adapter MUST provide a reference implementation callable under `mempalace.sources.transforms.<adapter_name>_<transform_name>`. The conformance suite imports and applies it. Undiscoverable custom transforms fail the test.
|
||||
|
||||
### 7.4 Schema conformance
|
||||
|
||||
A generator-based property test validates that every record yielded by `ingest` across the fixture source has metadata matching `describe_schema()`. Missing required fields, wrong types, or (in strict mode) undeclared fields fail the test.
|
||||
|
||||
### 7.5 Note on current corpus
|
||||
|
||||
No existing test in `tests/` asserts byte-preservation or declared-transformation correctness (verified via grep of `tests/` for `verbatim|byte.?preserv|round.?trip`). This RFC's conformance suite introduces the first such coverage. The existing MISSION.md claim of "verbatim always" is a social contract until this lands; afterward it becomes a machine-verified property of adapters that declare `byte_preserving`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Versioning and compatibility
|
||||
|
||||
- `BaseSourceAdapter.spec_version` declares which spec version an adapter implements.
|
||||
- MemPalace refuses to load an adapter declaring a different major spec version.
|
||||
- Minor spec versions are additive: new optional methods, new capability tokens, new reserved transformation names, new universal metadata fields with sensible defaults.
|
||||
- Adapters MAY declare their own `adapter_version` independent of the spec version; this is recorded on every drawer (§5.1) and enables "this drawer was extracted by cursor-adapter 0.3; 0.4 fixed a parsing bug; re-extract affected drawers" workflows.
|
||||
- This is spec v1.0.
|
||||
|
||||
---
|
||||
|
||||
## 9. Cleanup prerequisite (not in this spec, but gating)
|
||||
|
||||
The existing in-tree ingesters are not adapter-shaped. Before RFC 002 can be enforced, the following refactor lands in a separate PR:
|
||||
|
||||
- Introduce `mempalace/sources/base.py` defining `BaseSourceAdapter`, the typed records, and the registry.
|
||||
- Introduce `mempalace/sources/transforms.py` with reference implementations of every reserved transformation in §1.4. Adapters and the conformance suite both consume these.
|
||||
- `mempalace/miner.py` → `mempalace/sources/filesystem.py` implementing `BaseSourceAdapter`. Current behavior preserved: 800-char chunking becomes the adapter's default; `READABLE_EXTENSIONS` moves to the adapter; `detect_room()` and `detect_hall()` move to the adapter per §2.5. `declared_transformations = frozenset({"utf8_replace_invalid", "whitespace_trim"})`.
|
||||
- `mempalace/convo_miner.py` → `mempalace/sources/conversations.py`. Exchange-pair chunking stays. The format-detection logic in `normalize.py` becomes per-format plugins the conversations adapter composes (one for Claude Code JSONL, one for Codex JSONL, one for ChatGPT mapping trees, one for Claude.ai JSON, one for Slack JSON) — each small and independently testable, eliminating the `if source_type` chain. `declared_transformations` enumerates every transformation `normalize.py` and `convo_miner._chunk_by_exchange` actually perform (see §1.4 "Existing code mapping").
|
||||
- Closet-building wired into the conversations adapter's post-step (currently missing, per §1.7) — side effect of routing through the unified core post-step.
|
||||
- `mempalace/cli.py` subcommand `mine` routes through the `mempalace.sources` registry. `--mode {projects,convos}` becomes a deprecated alias for `--source {filesystem,conversations}`.
|
||||
- `mempalace/mcp_server.py` `mempalace_mine` tool accepts a `source` parameter.
|
||||
- `mempalace/palace.py` exposes `PalaceContext` — a per-mine-invocation facade that bundles the drawer collection, closet collection, knowledge graph, palace config, and progress hooks. Adapters receive this; they do not import `palace.py` directly.
|
||||
- `NORMALIZE_VERSION` (currently a module-level constant in `palace.py:50`) stays. It is the palace-wide schema version, orthogonal to per-adapter `adapter_version`.
|
||||
- `KnowledgeGraph.add_triple()` (`knowledge_graph.py:130`) gains two optional parameters: `source_drawer_id: str = None` and `adapter_name: str = None`. Existing callers are unaffected; adapters advertising `supports_kg_triples` (§5.5) populate both. Backwards-compatible change.
|
||||
|
||||
This cleanup is substantial — comparable to RFC 001 §10's chroma-import removal — and should land before any new third-party adapter PR merges. Each new adapter is easier after the cleanup, not harder.
|
||||
|
||||
---
|
||||
|
||||
## 10. Impact on in-flight PRs
|
||||
|
||||
| PR / Issue | Effort to align |
|
||||
|---|---|
|
||||
| [#274](https://github.com/MemPalace/mempalace/issues/274) Cursor SQLite | Becomes `mempalace-source-cursor` third-party package. Author has a working prototype on Windows; needs `describe_schema()`, `declared_transformations`, and the conformance suite. Prior #287 (closed unmerged) is predecessor work. |
|
||||
| [#23](https://github.com/MemPalace/mempalace/pull/23) OpenCode SQLite | Becomes `mempalace-source-opencode`. Same shape as Cursor. |
|
||||
| [#169](https://github.com/MemPalace/mempalace/pull/169) Pi agent | Becomes `mempalace-source-pi` or a format plugin under the conversations adapter (depending on format similarity). |
|
||||
| [#232](https://github.com/MemPalace/mempalace/pull/232) Cursor JSONL | Deprecated in favor of #274's SQLite path; or a second mode of `mempalace-source-cursor`. |
|
||||
| [#567](https://github.com/MemPalace/mempalace/pull/567), [#98](https://github.com/MemPalace/mempalace/pull/98) git-mine | Closest existing work to what the spec envisions. Becomes first-party `mempalace/sources/git.py`. Exercises `whole_record` mode, `supports_structured_metadata`, `supports_closet_hints` (decision-signal quotes), `supports_kg_triples` (commit authorship, PR review relationships). |
|
||||
| [#591](https://github.com/MemPalace/mempalace/pull/591), [#592](https://github.com/MemPalace/mempalace/pull/592) Delphi Oracle | Deferred. The live-stream pattern is out of scope for v1 (§Non-goals). A v1.1 addition will specify webhook/stream adapters. |
|
||||
| [#702](https://github.com/MemPalace/mempalace/pull/702) Cursor + factory.ai | Splits into two adapter packages. |
|
||||
| [#981](https://github.com/MemPalace/mempalace/issues/981) path-level descriptions | Absorbed by §1.5 `metadata_only` mode + §5.1 `ingest_mode`. A new first-party `descriptions` adapter or a second mode on `filesystem`. |
|
||||
| [#244](https://github.com/MemPalace/mempalace/pull/244) Cursor memory-first MCP workflow docs | Points at `mempalace-source-cursor` once the adapter lands. |
|
||||
| [#419](https://github.com/MemPalace/mempalace/pull/419), [#300](https://github.com/MemPalace/mempalace/pull/300), [#952](https://github.com/MemPalace/mempalace/pull/952) language-extension additions to `READABLE_EXTENSIONS` | Becomes per-language config on the filesystem adapter. Contributors can publish domain-specific adapters without touching core. |
|
||||
| [#389](https://github.com/MemPalace/mempalace/pull/389) sensitive content scanner | Expected enforcement mechanism for the `secrets_possible` privacy class (§6.2). Not a blocker for this spec, but a natural consumer. |
|
||||
| [#434](https://github.com/MemPalace/mempalace/pull/434) auto-populate KG from drawers | Complementary: post-hoc derivation of KG triples from drawer content. Adapters with `supports_kg_triples` provide the up-front path; #434 handles everything else. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions
|
||||
|
||||
1. **Cross-adapter dedup.** When a PR body is mined via `git` AND shows up as a conversation quote mined via `claude-code`, both drawers land. Is query-time dedup in `searcher.py` sufficient, or should core maintain a content-hash index across adapters? Declared non-goal in v1 but worth revisiting if user feedback demands it.
|
||||
2. **Live-stream pattern.** Delphi Oracle (#591/592) and potentially Slack/Discord real-time ingestion need a push-mode contract. This is a v1.1 addition (streaming adapter trait + webhook surface), not blocking.
|
||||
3. **LLM-assisted structured extraction.** Some adapters will want to call an LLM to extract structured fields. The spec does not standardize this — should it? Argument for: conformance test for LLM-driven fields, consistent caching. Argument against: local-first / zero-API is a core promise; LLM dependencies are opt-in per adapter.
|
||||
4. **Adapter-vs-format split for conversations.** §9 proposes format plugins composed under a single conversations adapter. Alternative: one adapter per format (claude-code, chatgpt, codex, cursor-jsonl, slack). The trade-off is discoverability (one adapter is easier to find) vs. encapsulation (format plugins are simpler to test). Preference leans toward the single-adapter + plugin model; open to counter-argument.
|
||||
5. **Default `privacy_floor`.** v1 defaults to none (§6.2) so single-user local mining is frictionless. An argument exists for defaulting to `pii_potential` — forces regulated-domain users to opt in to sensitive levels rather than opt out. Open to changing the default before v1 ships.
|
||||
6. **`canonical_source_bytes` for API-backed adapters.** §7.1 defines this as adapter-declared. For API-backed adapters (Slack, Notion), what constitutes "canonical bytes" in a conformance test — the fixture's captured HTTP response? A serialized representation of the parsed object? Leaves to the adapter; may need a follow-up spec for common conventions.
|
||||
7. **`adapter_version` bump semantics.** When does an adapter bump `adapter_version`? On any behavior change? On declared-transformation changes only? Suggests a follow-up doc on adapter SemVer conventions for the community to agree on.
|
||||
|
||||
---
|
||||
|
||||
## 12. Rollout
|
||||
|
||||
1. Land the cleanup PR (§9): introduce `mempalace/sources/`, refactor `miner.py` → filesystem adapter, `convo_miner.py` → conversations adapter, route CLI and MCP through the sources registry. Behavior preserved end-to-end. Closets get built for conversation drawers as a side effect.
|
||||
2. Land this spec as-is. Add `AbstractSourceAdapterContractSuite`, entry-point discovery, `AdapterSchema` validation, privacy-class enforcement (floor-gated writes), declared-transformation reference implementations in `mempalace/sources/transforms.py`.
|
||||
3. Land `mempalace/sources/git.py` as the first-party adapter absorbing #567. Exercises `whole_record`, `supports_structured_metadata`, `supports_closet_hints`, `supports_kg_triples` together.
|
||||
4. Encourage the Cursor (#274), OpenCode (#23), and Pi (#169) authors to publish as third-party packages under `mempalace-source-*`. Offer review help against the spec.
|
||||
5. Publish adapter-authoring docs at [mempalaceofficial.com/guide/authoring-sources](https://mempalaceofficial.com/guide/authoring-sources.html).
|
||||
6. Update [ROADMAP.md](../../ROADMAP.md) with spec v1.0 adoption under v4.0.0-alpha.
|
||||
@@ -7,22 +7,60 @@ MemPalace hooks act as an "Auto-Save" feature. They help your AI keep a permanen
|
||||
* **PreCompact Hook** (`mempal_precompact_hook.sh`): Saves your context right before the AI's memory window fills up.
|
||||
|
||||
### 2. Setup for Claude Code
|
||||
Add this to your configuration file to enable automatic background saving:
|
||||
Add this to `~/.claude/settings.local.json` (global) or `.claude/settings.local.json` (project-scoped) to enable automatic background saving:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [{"type": "command", "command": "./hooks/mempal_save_hook.sh"}]
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "/absolute/path/to/hooks/mempal_save_hook.sh",
|
||||
"timeout": 30
|
||||
}]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [{"type": "command", "command": "./hooks/mempal_precompact_hook.sh"}]
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "/absolute/path/to/hooks/mempal_precompact_hook.sh",
|
||||
"timeout": 30
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Make the hooks executable:
|
||||
```bash
|
||||
chmod +x /absolute/path/to/hooks/mempal_save_hook.sh
|
||||
chmod +x /absolute/path/to/hooks/mempal_precompact_hook.sh
|
||||
```
|
||||
|
||||
**Note:** Replace `/absolute/path/to/hooks/` with the actual path where you cloned the MemPalace repository (e.g., `~/projects/mempalace/hooks/`).
|
||||
|
||||
### 3. What changed (v3.1.0+)
|
||||
|
||||
Both hooks now have **two-layer capture**:
|
||||
|
||||
1. **Auto-mine**: Before blocking the AI, the hook runs the normalizer on the JSONL transcript and upserts chunks directly into the palace. This captures raw tool output (Bash results, search findings, build errors) that the AI would otherwise summarize away.
|
||||
|
||||
2. **Updated reason messages**: The block reason now explicitly tells the AI to save tool output verbatim — not just topics and decisions.
|
||||
|
||||
### 4. Backfill past conversations (one-time)
|
||||
|
||||
The hooks capture conversations going forward, but you probably have months of past sessions. Run this once to mine them all:
|
||||
|
||||
```bash
|
||||
mempalace mine ~/.claude/projects/ --mode convos
|
||||
```
|
||||
|
||||
### 5. Configuration
|
||||
|
||||
- **`SAVE_INTERVAL=15`** — How many human messages between saves
|
||||
- **`MEMPALACE_PYTHON`** — Python interpreter with mempalace + chromadb. Auto-detects: env var → repo venv → system python3
|
||||
- **`MEMPAL_DIR`** — Optional directory for auto-ingest via `mempalace mine`
|
||||
|
||||
@@ -9,17 +9,24 @@ This guide explains how to set up MemPalace as a permanent memory for the [Gemin
|
||||
|
||||
## 1. Installation
|
||||
|
||||
On many Linux systems, installing Python packages globally is restricted. We recommend using a local virtual environment within the MemPalace directory.
|
||||
On many Linux systems, installing Python packages globally is restricted. We
|
||||
recommend [`uv`](https://docs.astral.sh/uv/), which creates and manages a
|
||||
local virtual environment for you.
|
||||
|
||||
```bash
|
||||
# Clone the repository (if you haven't already)
|
||||
git clone https://github.com/MemPalace/mempalace.git
|
||||
cd mempalace
|
||||
|
||||
# Create a virtual environment
|
||||
python3 -m venv .venv
|
||||
# Create the venv and install MemPalace + dependencies in editable mode
|
||||
uv sync
|
||||
```
|
||||
|
||||
# Install dependencies and MemPalace in editable mode
|
||||
This produces a `.venv/` directory inside the repo with everything installed.
|
||||
If you prefer plain pip:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -e .
|
||||
```
|
||||
|
||||
@@ -29,7 +36,7 @@ Set up your "Palace" (the database) and configure your identity.
|
||||
|
||||
```bash
|
||||
# Initialize the palace in the current directory
|
||||
.venv/bin/python3 -m mempalace init .
|
||||
uv run python -m mempalace init .
|
||||
```
|
||||
|
||||
### Identity and Wings (Optional but Recommended)
|
||||
@@ -86,7 +93,7 @@ Once connected, Gemini CLI will automatically:
|
||||
### Manual Mining
|
||||
If you want the AI to learn from your existing code or docs immediately, run the "mine" command:
|
||||
```bash
|
||||
.venv/bin/python3 -m mempalace mine /path/to/your/project
|
||||
uv run python -m mempalace mine /path/to/your/project
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
Run the MCP server:
|
||||
|
||||
```bash
|
||||
python -m mempalace.mcp_server
|
||||
mempalace-mcp
|
||||
```
|
||||
|
||||
Or add it to Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
claude mcp add mempalace -- mempalace-mcp
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
+54
-15
@@ -6,10 +6,10 @@ These hook scripts make MemPalace save automatically. No manual "save" commands
|
||||
|
||||
| Hook | When It Fires | What Happens |
|
||||
|------|--------------|-------------|
|
||||
| **Save Hook** | Every 15 human messages | Blocks the AI, tells it to save key topics/decisions/quotes to the palace |
|
||||
| **PreCompact Hook** | Right before context compaction | Emergency save — forces the AI to save EVERYTHING before losing context |
|
||||
| **Save Hook** | Every 15 human messages | Auto-mines transcript (tool output included), then blocks the AI to save topics/decisions/quotes |
|
||||
| **PreCompact Hook** | Right before context compaction | Auto-mines transcript, then emergency save — forces the AI to save EVERYTHING before losing context |
|
||||
|
||||
The AI does the actual filing — it knows the conversation context, so it classifies memories into the right wings/halls/closets. The hooks just tell it WHEN to save.
|
||||
**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.
|
||||
|
||||
## Install — Claude Code
|
||||
|
||||
@@ -67,7 +67,8 @@ Edit `mempal_save_hook.sh` to change:
|
||||
|
||||
- **`SAVE_INTERVAL=15`** — How many human messages between saves. Lower = more frequent saves, higher = less interruption.
|
||||
- **`STATE_DIR`** — Where hook state is stored (defaults to `~/.mempalace/hook_state/`)
|
||||
- **`MEMPAL_DIR`** — Optional. Set to a conversations directory to auto-run `mempalace mine <dir>` on each save trigger. Leave blank (default) to let the AI handle saving via the block reason message.
|
||||
- **`MEMPAL_DIR`** — Optional **project directory** (code, notes, docs) to also mine on each save trigger, with `--mode projects`. The hook ALWAYS mines the active conversation transcript automatically with `--mode convos` — `MEMPAL_DIR` is purely additive, never an override. Leave blank if you don't want to ingest project files.
|
||||
- **`MEMPALACE_PYTHON`** — Optional env var. Python interpreter with mempalace + chromadb installed. Auto-detects: `MEMPALACE_PYTHON` env var → repo `venv/bin/python3` → system `python3`. Set this if your venv is in a non-standard location.
|
||||
|
||||
### mempalace CLI
|
||||
|
||||
@@ -91,15 +92,19 @@ User sends message → AI responds → Claude Code fires Stop hook
|
||||
↓
|
||||
┌─── < 15 since last save ──→ echo "{}" (let AI stop)
|
||||
│
|
||||
└─── ≥ 15 since last save ──→ {"decision": "block", "reason": "save..."}
|
||||
↓
|
||||
AI saves to palace
|
||||
↓
|
||||
AI tries to stop again
|
||||
↓
|
||||
stop_hook_active = true
|
||||
↓
|
||||
Hook sees flag → echo "{}" (let it through)
|
||||
└─── ≥ 15 since last save
|
||||
↓
|
||||
Auto-mine transcript → palace (tool output captured)
|
||||
↓
|
||||
{"decision": "block", "reason": "save tool output verbatim..."}
|
||||
↓
|
||||
AI saves to palace (topics, decisions, quotes)
|
||||
↓
|
||||
AI tries to stop again
|
||||
↓
|
||||
stop_hook_active = true
|
||||
↓
|
||||
Hook sees flag → echo "{}" (let it through)
|
||||
```
|
||||
|
||||
The `stop_hook_active` flag prevents infinite loops: block once → AI saves → tries to stop → flag is true → we let it through.
|
||||
@@ -109,14 +114,18 @@ The `stop_hook_active` flag prevents infinite loops: block once → AI saves →
|
||||
```
|
||||
Context window getting full → Claude Code fires PreCompact
|
||||
↓
|
||||
Hook ALWAYS blocks
|
||||
Find transcript (from input or session_id lookup)
|
||||
↓
|
||||
Auto-mine transcript → palace (tool output captured)
|
||||
↓
|
||||
{"decision": "block", "reason": "save tool output verbatim..."}
|
||||
↓
|
||||
AI saves everything
|
||||
↓
|
||||
Compaction proceeds
|
||||
```
|
||||
|
||||
No counting needed — compaction always warrants a save.
|
||||
No counting needed — compaction always warrants a save. The auto-mine captures raw tool output before the AI gets a chance to summarize it away.
|
||||
|
||||
## Debugging
|
||||
|
||||
@@ -137,6 +146,36 @@ Example output:
|
||||
|
||||
**Hooks require session restart after install.** Claude Code loads hooks from `settings.json` at session start only. If you run `mempalace init` or manually edit hook config mid-session, the hooks won't fire until you restart Claude Code. This is a Claude Code limitation.
|
||||
|
||||
**`MEMPAL_PYTHON` override for the hook's internal Python calls.** The save hook parses its JSON input and counts transcript messages with `python3`. When the harness is launched from a GUI on macOS — `open -a`, Spotlight, the dock — its `PATH` is the minimal `/usr/bin:/bin:/usr/sbin:/sbin` inherited from `launchd`, not your shell PATH. If `python3` isn't on that PATH, those internal calls fail and the hook can't count exchanges.
|
||||
|
||||
Point the hook at any Python 3 interpreter to fix it:
|
||||
|
||||
```bash
|
||||
export MEMPAL_PYTHON="/usr/bin/python3" # system Python is fine
|
||||
export MEMPAL_PYTHON="$HOME/.venvs/mempalace/bin/python" # or your venv
|
||||
```
|
||||
|
||||
Resolution priority: `$MEMPAL_PYTHON` (if set and executable) → `$(command -v python3)` → bare `python3`. The interpreter only needs `json` and `sys` from the standard library — `mempalace` itself does not need to be installed in it.
|
||||
|
||||
Note: the `mempalace mine` auto-ingest runs via the `mempalace` CLI, so that command also needs to be on the hook's `PATH`. Installing with `pipx install mempalace` or `uv tool install mempalace` puts it on a stable global location; otherwise extend the hook environment's `PATH` to include your venv's `bin/`.
|
||||
|
||||
## Backfill Past Conversations
|
||||
|
||||
The hooks only capture conversations going forward. To mine **past** Claude Code sessions into your palace, run a one-time backfill:
|
||||
|
||||
```bash
|
||||
mempalace mine ~/.claude/projects/ --mode convos
|
||||
```
|
||||
|
||||
This scans all JSONL transcripts from previous sessions and files them into the `conversations` wing. On a typical developer machine with months of history, this can yield 50K–200K drawers.
|
||||
|
||||
For Codex CLI sessions:
|
||||
```bash
|
||||
mempalace mine ~/.codex/sessions/ --mode convos
|
||||
```
|
||||
|
||||
This only needs to be done once — after that, the hooks auto-mine each session as you go.
|
||||
|
||||
## Cost
|
||||
|
||||
**Zero extra tokens.** The hooks notify the AI that saves happened in the background — the AI doesn't need to write anything in the chat. All filing is handled automatically. Previous versions asked the AI to write diary entries and drawer content in the chat window, which cost ~$1/session in retransmitted tokens.
|
||||
|
||||
@@ -41,31 +41,81 @@
|
||||
# to save everything. After the AI saves, compaction proceeds normally.
|
||||
#
|
||||
# === MEMPALACE CLI ===
|
||||
# This repo uses: mempalace mine <dir>
|
||||
# or: mempalace mine <dir> --mode convos
|
||||
# Set MEMPAL_DIR below if you want the hook to auto-ingest before compaction.
|
||||
# Leave blank to rely on the AI's own save instructions.
|
||||
# The hook ALWAYS mines the active conversation transcript synchronously
|
||||
# before compaction (via `mempalace mine <transcript-dir> --mode convos`).
|
||||
# MEMPAL_DIR is an *additional*, optional target for project files — it
|
||||
# does not replace the conversation mine.
|
||||
|
||||
STATE_DIR="$HOME/.mempalace/hook_state"
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Optional: set to the directory you want auto-ingested before compaction.
|
||||
# Example: MEMPAL_DIR="$HOME/conversations"
|
||||
# Leave empty to skip auto-ingest (AI handles saving via the block reason).
|
||||
# Optional: project directory (code / notes / docs) to also mine before
|
||||
# compaction. Mined with `--mode projects`. The conversation transcript
|
||||
# is always mined regardless — this is purely additive.
|
||||
# Example: MEMPAL_DIR="$HOME/projects/my_app"
|
||||
MEMPAL_DIR=""
|
||||
|
||||
# Resolve the Python interpreter. Same contract as mempal_save_hook.sh:
|
||||
# MEMPAL_PYTHON (explicit override) → $(command -v python3) → bare python3.
|
||||
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
|
||||
|
||||
# Read JSON input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','unknown'))" 2>/dev/null)
|
||||
# Parse session_id and transcript_path in one call. Sanitize both, then
|
||||
# read sanitized values from one-per-line stdout into shell variables —
|
||||
# avoids ``eval`` on generated code (#1231 review). Same contract as
|
||||
# mempal_save_hook.sh.
|
||||
mapfile -t _mempal_parsed < <(echo "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
|
||||
import sys, json, re
|
||||
data = json.load(sys.stdin)
|
||||
sid = data.get('session_id', 'unknown')
|
||||
tp = data.get('transcript_path', '')
|
||||
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
|
||||
print(safe(sid))
|
||||
print(safe(tp))
|
||||
" 2>/dev/null)
|
||||
SESSION_ID="${_mempal_parsed[0]:-unknown}"
|
||||
TRANSCRIPT_PATH="${_mempal_parsed[1]:-}"
|
||||
|
||||
# Expand ~ in path
|
||||
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
|
||||
|
||||
# Validate that TRANSCRIPT_PATH looks like a transcript file. Mirrors
|
||||
# mempalace.hooks_cli._validate_transcript_path so the shell hook
|
||||
# rejects the same shapes the Python hook rejects (#1231 review).
|
||||
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
|
||||
}
|
||||
|
||||
echo "[$(date '+%H:%M:%S')] PRE-COMPACT triggered for session $SESSION_ID" >> "$STATE_DIR/hook.log"
|
||||
|
||||
# Optional: run mempalace ingest synchronously so memories land before compaction
|
||||
# Run ingest synchronously so memories land before compaction. Two
|
||||
# independent targets — both run if both are set:
|
||||
# 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 \
|
||||
>> "$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
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
python3 -m mempalace mine "$MEMPAL_DIR" >> "$STATE_DIR/hook.log" 2>&1
|
||||
mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$STATE_DIR/hook.log" 2>&1
|
||||
fi
|
||||
|
||||
# Silent: return empty JSON to not block. "decision": "allow" is invalid —
|
||||
|
||||
+71
-26
@@ -45,10 +45,10 @@
|
||||
# stop_hook_active=true so we let it through. No infinite loop.
|
||||
#
|
||||
# === MEMPALACE CLI ===
|
||||
# This repo uses: mempalace mine <dir>
|
||||
# or: mempalace mine <dir> --mode convos
|
||||
# Set MEMPAL_DIR below if you want the hook to auto-ingest after blocking.
|
||||
# Leave blank to rely on the AI's own save instructions.
|
||||
# The hook ALWAYS mines the active conversation transcript automatically
|
||||
# (via `mempalace mine <transcript-dir> --mode convos`). MEMPAL_DIR is an
|
||||
# *additional*, optional target for project files — it does not replace
|
||||
# the conversation mine.
|
||||
#
|
||||
# === CONFIGURATION ===
|
||||
|
||||
@@ -56,18 +56,38 @@ SAVE_INTERVAL=15 # Save every N human messages (adjust to taste)
|
||||
STATE_DIR="$HOME/.mempalace/hook_state"
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Optional: set to the directory you want auto-ingested on each save trigger.
|
||||
# Example: MEMPAL_DIR="$HOME/conversations"
|
||||
# Leave empty to skip auto-ingest (AI handles saving via the block reason).
|
||||
# Optional: project directory (code / notes / docs) to also mine each
|
||||
# save trigger. Mined with `--mode projects`. The conversation transcript
|
||||
# is always mined regardless — this is purely additive.
|
||||
# Example: MEMPAL_DIR="$HOME/projects/my_app"
|
||||
MEMPAL_DIR=""
|
||||
|
||||
# Resolve the Python interpreter the hook should use.
|
||||
#
|
||||
# Why this is nontrivial: GUI-launched Claude Code on macOS (or any harness
|
||||
# that doesn't inherit the user's shell PATH) may find a `python3` on PATH
|
||||
# that lacks mempalace — e.g. /usr/bin/python3 while the user installed
|
||||
# mempalace into a venv or pyenv. Users in that situation can point the
|
||||
# hook at the right interpreter by exporting MEMPAL_PYTHON.
|
||||
#
|
||||
# Resolution order (first hit wins):
|
||||
# 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 (hope the PATH has it)
|
||||
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
|
||||
|
||||
# Read JSON input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
# Parse all fields in a single Python call (3x faster than separate invocations)
|
||||
# SECURITY: All values are sanitized before being interpolated into shell assignments.
|
||||
# stop_hook_active is coerced to a strict True/False to prevent command injection via eval.
|
||||
eval $(echo "$INPUT" | python3 -c "
|
||||
# without invoking ``eval`` on generated code: Python prints one sanitized
|
||||
# value per line, the shell reads them via ``mapfile`` and does plain
|
||||
# variable assignment — same data, smaller blast radius if the sanitizer
|
||||
# is ever bypassed (#1231 review).
|
||||
mapfile -t _mempal_parsed < <(echo "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
|
||||
import sys, json, re
|
||||
data = json.load(sys.stdin)
|
||||
sid = data.get('session_id', 'unknown')
|
||||
@@ -77,14 +97,36 @@ tp = data.get('transcript_path', '')
|
||||
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
|
||||
# Coerce stop_hook_active to strict boolean string
|
||||
sha = 'True' if sha_raw is True or str(sha_raw).lower() in ('true', '1', 'yes') else 'False'
|
||||
print(f'SESSION_ID=\"{safe(sid)}\"')
|
||||
print(f'STOP_HOOK_ACTIVE=\"{sha}\"')
|
||||
print(f'TRANSCRIPT_PATH=\"{safe(tp)}\"')
|
||||
print(safe(sid))
|
||||
print(sha)
|
||||
print(safe(tp))
|
||||
" 2>/dev/null)
|
||||
SESSION_ID="${_mempal_parsed[0]:-unknown}"
|
||||
STOP_HOOK_ACTIVE="${_mempal_parsed[1]:-False}"
|
||||
TRANSCRIPT_PATH="${_mempal_parsed[2]:-}"
|
||||
|
||||
# Expand ~ in path
|
||||
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
|
||||
|
||||
# Validate that TRANSCRIPT_PATH looks like a transcript file:
|
||||
# - non-empty
|
||||
# - .jsonl or .json suffix
|
||||
# - no traversal segments (.. components)
|
||||
# Mirrors mempalace.hooks_cli._validate_transcript_path so the shell hook
|
||||
# rejects the same shapes the Python hook rejects (#1231 review).
|
||||
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
|
||||
}
|
||||
|
||||
# If we're already in a save cycle, let the AI stop normally
|
||||
# This is the infinite-loop prevention: block once → AI saves → tries to stop again → we let it through
|
||||
if [ "$STOP_HOOK_ACTIVE" = "True" ] || [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
@@ -95,7 +137,7 @@ fi
|
||||
# Count human messages in the JSONL transcript
|
||||
# SECURITY: Pass transcript path as sys.argv to avoid shell injection via crafted paths
|
||||
if [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
EXCHANGE_COUNT=$(python3 - "$TRANSCRIPT_PATH" <<'PYEOF'
|
||||
EXCHANGE_COUNT=$("$MEMPAL_PYTHON_BIN" - "$TRANSCRIPT_PATH" <<'PYEOF'
|
||||
import json, sys
|
||||
count = 0
|
||||
with open(sys.argv[1]) as f:
|
||||
@@ -140,20 +182,23 @@ if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then
|
||||
|
||||
echo "[$(date '+%H:%M:%S')] TRIGGERING SAVE at exchange $EXCHANGE_COUNT" >> "$STATE_DIR/hook.log"
|
||||
|
||||
# Auto-mine the transcript. Two paths:
|
||||
# 1. TRANSCRIPT_PATH (from Claude Code) — mine the directory it lives in
|
||||
# 2. MEMPAL_DIR (user-configured) — mine that directory
|
||||
# At least one should work. If neither is set, nothing mines.
|
||||
PYTHON="$(command -v python3)"
|
||||
MINE_DIR=""
|
||||
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
MINE_DIR="$(dirname "$TRANSCRIPT_PATH")"
|
||||
# Auto-mine. Two independent targets — both run if both are set:
|
||||
# 1. TRANSCRIPT_PATH (from Claude Code) → parent dir, --mode convos
|
||||
# (Claude Code session JSONL — must use the convo miner)
|
||||
# 2. MEMPAL_DIR (user-configured project) → --mode projects
|
||||
# (code, notes, docs)
|
||||
# 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 \
|
||||
>> "$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
|
||||
MINE_DIR="$MEMPAL_DIR"
|
||||
fi
|
||||
if [ -n "$MINE_DIR" ]; then
|
||||
"$PYTHON" -m mempalace mine "$MINE_DIR" >> "$STATE_DIR/hook.log" 2>&1 &
|
||||
mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$STATE_DIR/hook.log" 2>&1 &
|
||||
fi
|
||||
|
||||
# MEMPAL_VERBOSE toggle:
|
||||
|
||||
@@ -102,10 +102,10 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
||||
|
||||
## Setup
|
||||
|
||||
Install MemPalace and populate the palace:
|
||||
Install MemPalace and populate the palace (uv recommended):
|
||||
|
||||
```bash
|
||||
pip install mempalace
|
||||
uv tool install mempalace # or: pip install mempalace
|
||||
mempalace init ~/my-convos
|
||||
mempalace mine ~/my-convos
|
||||
```
|
||||
|
||||
+2116
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 680 KiB |
@@ -4,10 +4,10 @@ import logging
|
||||
|
||||
from .version import __version__ # noqa: E402
|
||||
|
||||
# ChromaDB 0.6.x ships a Posthog telemetry client whose capture() signature is
|
||||
# incompatible with the bundled posthog library, producing noisy stderr warnings
|
||||
# on every client operation ("Failed to send telemetry event … capture() takes
|
||||
# 1 positional argument but 3 were given"). Silence just that logger.
|
||||
# chromadb telemetry: posthog capture() was broken in 0.6.x causing noisy stderr
|
||||
# warnings ("capture() takes 1 positional argument but 3 were given"). In 1.x the
|
||||
# posthog client is a no-op stub, so this is now harmless — kept as a guard in
|
||||
# case future chromadb versions re-introduce real telemetry calls.
|
||||
logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICAL)
|
||||
|
||||
# NOTE: the previous block set ``ORT_DISABLE_COREML=1`` on macOS arm64 as a
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Stdio UTF-8 reconfiguration helper for Windows entry points.
|
||||
|
||||
Python on Windows defaults stdio to the system ANSI codepage
|
||||
(cp1252/cp1251/cp950 depending on locale), which mojibakes UTF-8 input
|
||||
or output the moment a non-Latin character shows up. Every console
|
||||
entry point that touches stdio needs to fix this on Windows -- the MCP
|
||||
server, the CLI, the fact_checker `--stdin` mode -- so the
|
||||
reconfigure code lives here in one place to keep the per-stream
|
||||
errors policies aligned across them.
|
||||
|
||||
Per-stream errors policy is caller-chosen:
|
||||
|
||||
* MCP server uses ``strict`` on stdout/stderr because everything written
|
||||
there is server-controlled JSON-RPC; any encode failure is a real bug
|
||||
the operator wants loud.
|
||||
* CLI / fact_checker use ``replace`` on stdout/stderr because they print
|
||||
verbatim drawer text that may contain surrogate halves round-tripped
|
||||
from filenames -- ``strict`` would crash mid-print.
|
||||
* All callers use ``surrogateescape`` on stdin so a malformed byte from
|
||||
a redirected file or a misbehaving client survives as a lone surrogate
|
||||
the consumer's parser surfaces, instead of ``UnicodeDecodeError``
|
||||
killing the read loop on the first bad byte.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
def reconfigure_stdio_utf8_on_windows(
|
||||
*,
|
||||
stdin_errors: str = "surrogateescape",
|
||||
stdout_errors: str = "strict",
|
||||
stderr_errors: str = "strict",
|
||||
on_failure: Optional[Callable[[str, BaseException], None]] = None,
|
||||
) -> None:
|
||||
"""Reconfigure stdio to UTF-8 on Windows. No-op elsewhere.
|
||||
|
||||
Args:
|
||||
stdin_errors: errors= policy for stdin.reconfigure().
|
||||
stdout_errors: errors= policy for stdout.reconfigure().
|
||||
stderr_errors: errors= policy for stderr.reconfigure().
|
||||
on_failure: optional ``(stream_name, exc) -> None`` callback for
|
||||
streams whose ``reconfigure`` raises (e.g. Jupyter-replaced
|
||||
streams that lack the method-shape we expect). Defaults to a
|
||||
``WARNING:`` line on the original sys.stderr.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
|
||||
policies = (
|
||||
("stdin", stdin_errors),
|
||||
("stdout", stdout_errors),
|
||||
("stderr", stderr_errors),
|
||||
)
|
||||
for name, errors in policies:
|
||||
stream = getattr(sys, name, None)
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if reconfigure is None:
|
||||
continue
|
||||
try:
|
||||
reconfigure(encoding="utf-8", errors=errors)
|
||||
except Exception as exc: # noqa: BLE001 -- last-resort guard
|
||||
if on_failure is not None:
|
||||
on_failure(name, exc)
|
||||
else:
|
||||
print(
|
||||
f"WARNING: Could not reconfigure {name} to UTF-8: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -1,6 +1,64 @@
|
||||
"""Storage backend implementations for MemPalace."""
|
||||
"""Storage backend implementations for MemPalace (RFC 001).
|
||||
|
||||
from .base import BaseCollection
|
||||
Public surface:
|
||||
|
||||
* :class:`BaseCollection` — per-collection read/write contract.
|
||||
* :class:`BaseBackend` — per-palace factory contract.
|
||||
* :class:`PalaceRef` — value object identifying a palace for a backend.
|
||||
* :class:`QueryResult` / :class:`GetResult` — typed read returns.
|
||||
* Error classes: :class:`PalaceNotFoundError`, :class:`BackendClosedError`,
|
||||
:class:`UnsupportedFilterError`, :class:`DimensionMismatchError`,
|
||||
:class:`EmbedderIdentityMismatchError`.
|
||||
* Registry: :func:`get_backend`, :func:`register`, :func:`available_backends`,
|
||||
:func:`resolve_backend_for_palace`.
|
||||
* In-tree Chroma default: :class:`ChromaBackend`, :class:`ChromaCollection`.
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
BackendClosedError,
|
||||
BackendError,
|
||||
BaseBackend,
|
||||
BaseCollection,
|
||||
DimensionMismatchError,
|
||||
EmbedderIdentityMismatchError,
|
||||
GetResult,
|
||||
HealthStatus,
|
||||
PalaceNotFoundError,
|
||||
PalaceRef,
|
||||
QueryResult,
|
||||
UnsupportedFilterError,
|
||||
)
|
||||
from .chroma import ChromaBackend, ChromaCollection
|
||||
from .registry import (
|
||||
available_backends,
|
||||
get_backend,
|
||||
get_backend_class,
|
||||
register,
|
||||
reset_backends,
|
||||
resolve_backend_for_palace,
|
||||
unregister,
|
||||
)
|
||||
|
||||
__all__ = ["BaseCollection", "ChromaBackend", "ChromaCollection"]
|
||||
__all__ = [
|
||||
"BackendClosedError",
|
||||
"BackendError",
|
||||
"BaseBackend",
|
||||
"BaseCollection",
|
||||
"ChromaBackend",
|
||||
"ChromaCollection",
|
||||
"DimensionMismatchError",
|
||||
"EmbedderIdentityMismatchError",
|
||||
"GetResult",
|
||||
"HealthStatus",
|
||||
"PalaceNotFoundError",
|
||||
"PalaceRef",
|
||||
"QueryResult",
|
||||
"UnsupportedFilterError",
|
||||
"available_backends",
|
||||
"get_backend",
|
||||
"get_backend_class",
|
||||
"register",
|
||||
"reset_backends",
|
||||
"resolve_backend_for_palace",
|
||||
"unregister",
|
||||
]
|
||||
|
||||
+348
-27
@@ -1,49 +1,370 @@
|
||||
"""Abstract collection interface for MemPalace storage backends."""
|
||||
"""Storage backend contract for MemPalace (RFC 001).
|
||||
|
||||
This module defines the surface every storage backend must implement:
|
||||
|
||||
* ``BaseCollection`` — the per-collection read/write interface, kwargs-only.
|
||||
* ``BaseBackend`` — the per-palace factory, addressed by ``PalaceRef``.
|
||||
* ``QueryResult`` / ``GetResult`` — typed result dataclasses that replace the
|
||||
Chroma dict shape as the canonical return type.
|
||||
* Error classes + ``HealthStatus`` — uniform across backends.
|
||||
|
||||
This is the v1 cleanup from RFC 001 §10: full typed results, ``PalaceRef``,
|
||||
registry-ready ABC. Embedder injection, maintenance hooks, and the full
|
||||
conformance suite land in follow-up PRs.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BackendError(Exception):
|
||||
"""Base class for every storage-backend error raised by core."""
|
||||
|
||||
|
||||
class PalaceNotFoundError(BackendError, FileNotFoundError):
|
||||
"""Raised when ``get_collection(create=False)`` is called on a missing palace.
|
||||
|
||||
Subclass of ``FileNotFoundError`` so legacy callers that catch the latter
|
||||
(pre-#413 seam) keep working unchanged.
|
||||
"""
|
||||
|
||||
|
||||
class BackendClosedError(BackendError):
|
||||
"""Raised when a backend method is called after ``close()``."""
|
||||
|
||||
|
||||
class UnsupportedFilterError(BackendError):
|
||||
"""Raised when a where-clause uses an operator the backend does not implement.
|
||||
|
||||
Silent dropping of unknown operators is forbidden by spec (RFC 001 §1.4).
|
||||
"""
|
||||
|
||||
|
||||
class DimensionMismatchError(BackendError):
|
||||
"""Raised when the embedding dimension on write does not match the collection."""
|
||||
|
||||
|
||||
class EmbedderIdentityMismatchError(BackendError):
|
||||
"""Raised when the stored embedder model name differs from the current one."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Value objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PalaceRef:
|
||||
"""A handle to a palace, consumed by backends.
|
||||
|
||||
``id`` is always present and is the key backends use to cache handles.
|
||||
``local_path`` is populated for filesystem-rooted palaces.
|
||||
``namespace`` is used by server-mode backends for tenant / prefix routing.
|
||||
"""
|
||||
|
||||
id: str
|
||||
local_path: Optional[str] = None
|
||||
namespace: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealthStatus:
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
||||
@classmethod
|
||||
def healthy(cls, detail: str = "") -> "HealthStatus":
|
||||
return cls(ok=True, detail=detail)
|
||||
|
||||
@classmethod
|
||||
def unhealthy(cls, detail: str) -> "HealthStatus":
|
||||
return cls(ok=False, detail=detail)
|
||||
|
||||
|
||||
_TYPED_RESULT_FIELDS = ("ids", "documents", "metadatas", "distances", "embeddings")
|
||||
|
||||
|
||||
class _DictCompatMixin:
|
||||
"""Transitional dict-protocol access for typed results.
|
||||
|
||||
RFC 001 §1.3 spec is attribute access (``result.ids``). The ``result["ids"]``
|
||||
and ``result.get("ids")`` forms are retained as a migration shim for callers
|
||||
that predate the typed interface and are scheduled for removal in a follow-
|
||||
up cleanup. New code MUST use attribute access.
|
||||
"""
|
||||
|
||||
def __getitem__(self, key: str):
|
||||
if key in _TYPED_RESULT_FIELDS:
|
||||
return getattr(self, key)
|
||||
raise KeyError(key)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
if key in _TYPED_RESULT_FIELDS:
|
||||
val = getattr(self, key, default)
|
||||
return default if val is None else val
|
||||
return default
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in _TYPED_RESULT_FIELDS and getattr(self, key, None) is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QueryResult(_DictCompatMixin):
|
||||
"""Typed return from ``BaseCollection.query``.
|
||||
|
||||
Outer list dimension = number of query vectors / texts.
|
||||
Inner list dimension = hits per query (may be zero).
|
||||
|
||||
Fields not in ``include=`` at the call site are populated with empty lists
|
||||
of the correct outer shape (never ``None``), except ``embeddings`` which
|
||||
is ``None`` when not requested.
|
||||
"""
|
||||
|
||||
ids: list[list[str]]
|
||||
documents: list[list[str]]
|
||||
metadatas: list[list[dict]]
|
||||
distances: list[list[float]]
|
||||
embeddings: Optional[list[list[list[float]]]] = None
|
||||
|
||||
@classmethod
|
||||
def empty(cls, num_queries: int = 1, embeddings_requested: bool = False) -> "QueryResult":
|
||||
"""Construct an all-empty result preserving outer dimension.
|
||||
|
||||
When ``embeddings_requested`` is True, ``embeddings`` preserves the outer
|
||||
query dimension with empty hit lists (matching the spec's rule that fields
|
||||
requested via ``include=`` carry the outer shape even when empty). When
|
||||
False, ``embeddings`` stays ``None`` to signal the field was not requested.
|
||||
"""
|
||||
empty_outer = [[] for _ in range(num_queries)]
|
||||
return cls(
|
||||
ids=[[] for _ in range(num_queries)],
|
||||
documents=[[] for _ in range(num_queries)],
|
||||
metadatas=[[] for _ in range(num_queries)],
|
||||
distances=[[] for _ in range(num_queries)],
|
||||
embeddings=empty_outer if embeddings_requested else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GetResult(_DictCompatMixin):
|
||||
"""Typed return from ``BaseCollection.get``."""
|
||||
|
||||
ids: list[str]
|
||||
documents: list[str]
|
||||
metadatas: list[dict]
|
||||
embeddings: Optional[list[list[float]]] = None
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> "GetResult":
|
||||
return cls(ids=[], documents=[], metadatas=[], embeddings=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collection contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BaseCollection(ABC):
|
||||
"""Smallest collection contract the rest of MemPalace relies on."""
|
||||
"""Per-collection read/write surface every backend must implement."""
|
||||
|
||||
@abstractmethod
|
||||
def add(
|
||||
self,
|
||||
*,
|
||||
documents: List[str],
|
||||
ids: List[str],
|
||||
metadatas: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
documents: list[str],
|
||||
ids: list[str],
|
||||
metadatas: Optional[list[dict]] = None,
|
||||
embeddings: Optional[list[list[float]]] = None,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def upsert(
|
||||
self,
|
||||
*,
|
||||
documents: List[str],
|
||||
ids: List[str],
|
||||
metadatas: Optional[List[Dict[str, Any]]] = None,
|
||||
documents: list[str],
|
||||
ids: list[str],
|
||||
metadatas: Optional[list[dict]] = None,
|
||||
embeddings: Optional[list[list[float]]] = None,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def query(
|
||||
self,
|
||||
*,
|
||||
query_texts: Optional[list[str]] = None,
|
||||
query_embeddings: Optional[list[list[float]]] = None,
|
||||
n_results: int = 10,
|
||||
where: Optional[dict] = None,
|
||||
where_document: Optional[dict] = None,
|
||||
include: Optional[list[str]] = None,
|
||||
) -> QueryResult: ...
|
||||
|
||||
@abstractmethod
|
||||
def get(
|
||||
self,
|
||||
*,
|
||||
ids: Optional[list[str]] = None,
|
||||
where: Optional[dict] = None,
|
||||
where_document: Optional[dict] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
include: Optional[list[str]] = None,
|
||||
) -> GetResult: ...
|
||||
|
||||
@abstractmethod
|
||||
def delete(
|
||||
self,
|
||||
*,
|
||||
ids: Optional[list[str]] = None,
|
||||
where: Optional[dict] = None,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def count(self) -> int: ...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Optional methods with ABC defaults (spec §1.2)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def estimated_count(self) -> int:
|
||||
return self.count()
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
def health(self) -> HealthStatus:
|
||||
return HealthStatus.healthy()
|
||||
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
ids: list[str],
|
||||
documents: Optional[list[str]] = None,
|
||||
metadatas: Optional[list[dict]] = None,
|
||||
embeddings: Optional[list[list[float]]] = None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
"""Default non-atomic update: get + merge + upsert.
|
||||
|
||||
Backends advertising ``supports_update`` MUST override with an atomic
|
||||
single-round-trip implementation.
|
||||
"""
|
||||
if documents is None and metadatas is None and embeddings is None:
|
||||
raise ValueError("update requires at least one of documents, metadatas, embeddings")
|
||||
|
||||
n = len(ids)
|
||||
for label, value in (
|
||||
("documents", documents),
|
||||
("metadatas", metadatas),
|
||||
("embeddings", embeddings),
|
||||
):
|
||||
if value is not None and len(value) != n:
|
||||
raise ValueError(f"{label} length {len(value)} does not match ids length {n}")
|
||||
|
||||
existing = self.get(ids=ids, include=["documents", "metadatas"])
|
||||
by_id = {
|
||||
rid: (existing.documents[i], existing.metadatas[i])
|
||||
for i, rid in enumerate(existing.ids)
|
||||
}
|
||||
merged_docs: list[str] = []
|
||||
merged_metas: list[dict] = []
|
||||
for i, rid in enumerate(ids):
|
||||
prev_doc, prev_meta = by_id.get(rid, ("", {}))
|
||||
merged_docs.append(documents[i] if documents is not None else prev_doc)
|
||||
new_meta = dict(prev_meta or {})
|
||||
if metadatas is not None:
|
||||
new_meta.update(metadatas[i] or {})
|
||||
merged_metas.append(new_meta)
|
||||
self.upsert(
|
||||
documents=merged_docs,
|
||||
ids=list(ids),
|
||||
metadatas=merged_metas,
|
||||
embeddings=embeddings,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BaseBackend(ABC):
|
||||
"""Long-lived factory serving many palaces (RFC 001 §2).
|
||||
|
||||
Instances are lightweight on construction — no I/O, no network. All
|
||||
connection work is deferred to ``get_collection``. Instances are thread-
|
||||
safe for concurrent ``get_collection`` calls across different palaces.
|
||||
"""
|
||||
|
||||
name: ClassVar[str]
|
||||
spec_version: ClassVar[str] = "1.0"
|
||||
capabilities: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@abstractmethod
|
||||
def update(self, **kwargs: Any) -> None:
|
||||
"""Update existing records. Must raise if any ID is missing."""
|
||||
raise NotImplementedError
|
||||
def get_collection(
|
||||
self,
|
||||
*,
|
||||
palace: PalaceRef,
|
||||
collection_name: str,
|
||||
create: bool = False,
|
||||
options: Optional[dict] = None,
|
||||
) -> BaseCollection: ...
|
||||
|
||||
@abstractmethod
|
||||
def query(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
def close_palace(self, palace: PalaceRef) -> None:
|
||||
"""Evict cached handles for a single palace. Default: no-op."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def get(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
def close(self) -> None:
|
||||
"""Shut down the entire backend. Default: no-op."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, **kwargs: Any) -> None:
|
||||
raise NotImplementedError
|
||||
def health(self, palace: Optional[PalaceRef] = None) -> HealthStatus:
|
||||
return HealthStatus.healthy()
|
||||
|
||||
@abstractmethod
|
||||
def count(self) -> int:
|
||||
raise NotImplementedError
|
||||
# Optional detection hint used by selection priority (RFC 001 §3.3 (4)):
|
||||
@classmethod
|
||||
def detect(cls, path: str) -> bool: # pragma: no cover - default hook
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Keys the Chroma ``include=`` parameter accepts.
|
||||
_VALID_INCLUDE_KEYS = frozenset({"documents", "metadatas", "distances", "embeddings"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class _IncludeSpec:
|
||||
"""Resolve an ``include=`` parameter with spec-mandated defaults."""
|
||||
|
||||
documents: bool = True
|
||||
metadatas: bool = True
|
||||
distances: bool = True # only meaningful for query
|
||||
embeddings: bool = False
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls, include: Optional[list[str]], *, default_distances: bool = True
|
||||
) -> "_IncludeSpec":
|
||||
if include is None:
|
||||
return cls(
|
||||
documents=True,
|
||||
metadatas=True,
|
||||
distances=default_distances,
|
||||
embeddings=False,
|
||||
)
|
||||
keys = {k for k in include if k in _VALID_INCLUDE_KEYS}
|
||||
return cls(
|
||||
documents="documents" in keys,
|
||||
metadatas="metadatas" in keys,
|
||||
distances="distances" in keys,
|
||||
embeddings="embeddings" in keys,
|
||||
)
|
||||
|
||||
+1366
-63
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
"""Backend registry + entry-point discovery (RFC 001 §3).
|
||||
|
||||
Third-party backends ship as installable packages that declare a
|
||||
``mempalace.backends`` entry point::
|
||||
|
||||
# pyproject.toml of mempalace-postgres
|
||||
[project.entry-points."mempalace.backends"]
|
||||
postgres = "mempalace_postgres:PostgresBackend"
|
||||
|
||||
MemPalace discovers them at process start. In-tree tests and local development
|
||||
can register manually via :func:`register`. Explicit registration wins on
|
||||
name conflict (matches RFC 001 §3.2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from importlib import metadata
|
||||
from threading import Lock
|
||||
from typing import Optional, Type
|
||||
|
||||
from .base import BaseBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENTRY_POINT_GROUP = "mempalace.backends"
|
||||
|
||||
_registry: dict[str, Type[BaseBackend]] = {}
|
||||
_instances: dict[str, BaseBackend] = {}
|
||||
_explicit: set[str] = set()
|
||||
_discovered = False
|
||||
_lock = Lock()
|
||||
|
||||
|
||||
def register(name: str, backend_cls: Type[BaseBackend]) -> None:
|
||||
"""Register ``backend_cls`` under ``name``.
|
||||
|
||||
Explicit registration wins over entry-point discovery on conflict
|
||||
(RFC 001 §3.2).
|
||||
"""
|
||||
with _lock:
|
||||
_registry[name] = backend_cls
|
||||
_explicit.add(name)
|
||||
# Invalidate any cached instance so the new class is used on next get.
|
||||
_instances.pop(name, None)
|
||||
|
||||
|
||||
def unregister(name: str) -> None:
|
||||
"""Remove a backend registration (primarily for tests)."""
|
||||
with _lock:
|
||||
_registry.pop(name, None)
|
||||
_explicit.discard(name)
|
||||
_instances.pop(name, None)
|
||||
|
||||
|
||||
def _discover_entry_points() -> None:
|
||||
"""Load entry-point-declared backends once per process."""
|
||||
global _discovered
|
||||
if _discovered:
|
||||
return
|
||||
with _lock:
|
||||
if _discovered:
|
||||
return
|
||||
try:
|
||||
eps = metadata.entry_points()
|
||||
# Py ≥ 3.10 returns an EntryPoints object; older versions returned a dict.
|
||||
group = (
|
||||
eps.select(group=_ENTRY_POINT_GROUP)
|
||||
if hasattr(eps, "select")
|
||||
else eps.get(_ENTRY_POINT_GROUP, [])
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("entry-point discovery for %s failed", _ENTRY_POINT_GROUP)
|
||||
group = []
|
||||
for ep in group:
|
||||
if ep.name in _explicit:
|
||||
continue # explicit registration wins
|
||||
try:
|
||||
cls = ep.load()
|
||||
except Exception:
|
||||
logger.exception("failed to load backend entry point %r", ep.name)
|
||||
continue
|
||||
if not isinstance(cls, type) or not issubclass(cls, BaseBackend):
|
||||
logger.warning(
|
||||
"entry point %r did not resolve to a BaseBackend subclass (got %r)",
|
||||
ep.name,
|
||||
cls,
|
||||
)
|
||||
continue
|
||||
_registry.setdefault(ep.name, cls)
|
||||
_discovered = True
|
||||
|
||||
|
||||
def available_backends() -> list[str]:
|
||||
"""Return sorted list of all registered backend names."""
|
||||
_discover_entry_points()
|
||||
return sorted(_registry.keys())
|
||||
|
||||
|
||||
def get_backend_class(name: str) -> Type[BaseBackend]:
|
||||
"""Return the registered backend class for ``name``."""
|
||||
_discover_entry_points()
|
||||
try:
|
||||
return _registry[name]
|
||||
except KeyError as e:
|
||||
raise KeyError(f"unknown backend {name!r}; available: {available_backends()}") from e
|
||||
|
||||
|
||||
def get_backend(name: str) -> BaseBackend:
|
||||
"""Return a long-lived instance of the named backend.
|
||||
|
||||
Instances are cached per-name; repeated calls return the same object.
|
||||
Call :func:`reset_backends` in tests that need isolation.
|
||||
"""
|
||||
_discover_entry_points()
|
||||
with _lock:
|
||||
inst = _instances.get(name)
|
||||
if inst is not None:
|
||||
return inst
|
||||
cls = _registry.get(name)
|
||||
if cls is None:
|
||||
raise KeyError(f"unknown backend {name!r}; available: {sorted(_registry.keys())}")
|
||||
inst = cls()
|
||||
_instances[name] = inst
|
||||
return inst
|
||||
|
||||
|
||||
def reset_backends() -> None:
|
||||
"""Close and drop all cached backend instances (primarily for tests)."""
|
||||
with _lock:
|
||||
for inst in _instances.values():
|
||||
try:
|
||||
inst.close()
|
||||
except Exception:
|
||||
logger.exception("error closing backend during reset")
|
||||
_instances.clear()
|
||||
|
||||
|
||||
def resolve_backend_for_palace(
|
||||
*,
|
||||
explicit: Optional[str] = None,
|
||||
config_value: Optional[str] = None,
|
||||
env_value: Optional[str] = None,
|
||||
palace_path: Optional[str] = None,
|
||||
default: str = "chroma",
|
||||
) -> str:
|
||||
"""Resolve the backend name for a palace per RFC 001 §3.3 priority order.
|
||||
|
||||
1. Explicit kwarg / CLI flag
|
||||
2. Per-palace config value
|
||||
3. ``MEMPALACE_BACKEND`` env var
|
||||
4. Auto-detect from on-disk artifacts (migration/upgrade path only)
|
||||
5. Default (``chroma``)
|
||||
|
||||
Auto-detection is strictly a migration aid: it fires only when a local path
|
||||
is presented, no earlier rule has chosen a backend, AND the path already
|
||||
contains backend-identifiable artifacts. For new palaces, (5) wins.
|
||||
"""
|
||||
for candidate in (explicit, config_value, env_value):
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
_discover_entry_points()
|
||||
if palace_path:
|
||||
for name, cls in _registry.items():
|
||||
try:
|
||||
if cls.detect(palace_path):
|
||||
return name
|
||||
except Exception:
|
||||
logger.exception("detect() raised on backend %r", name)
|
||||
continue
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_builtins() -> None:
|
||||
"""Register chroma as the in-tree default."""
|
||||
from .chroma import ChromaBackend
|
||||
|
||||
# Use setdefault semantics so a caller that pre-registered for tests wins.
|
||||
if "chroma" not in _registry:
|
||||
_registry["chroma"] = ChromaBackend
|
||||
|
||||
|
||||
_register_builtins()
|
||||
+941
-78
File diff suppressed because it is too large
Load Diff
+34
-11
@@ -40,6 +40,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
@@ -101,6 +102,14 @@ class LLMConfig:
|
||||
self.endpoint = (endpoint or os.environ.get("LLM_ENDPOINT", "")).rstrip("/")
|
||||
self.key = key or os.environ.get("LLM_KEY", "")
|
||||
self.model = model or os.environ.get("LLM_MODEL", "")
|
||||
if self.endpoint:
|
||||
# Privacy-by-architecture: reject file:// and other non-HTTP schemes
|
||||
# so a misconfigured endpoint cannot exfiltrate local files.
|
||||
scheme = urllib.parse.urlparse(self.endpoint).scheme.lower()
|
||||
if scheme not in ("http", "https"):
|
||||
raise ValueError(
|
||||
f"LLM_ENDPOINT must use http:// or https:// (got scheme {scheme!r})"
|
||||
)
|
||||
|
||||
def missing(self) -> list:
|
||||
missing = []
|
||||
@@ -160,6 +169,9 @@ def _call_llm(cfg: LLMConfig, source_file: str, wing: str, room: str, content: s
|
||||
parsed = json.loads(text)
|
||||
return parsed, payload.get("usage")
|
||||
except json.JSONDecodeError:
|
||||
if attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
return None, None
|
||||
except urllib.error.HTTPError as e:
|
||||
# 429 / 503 = retry with backoff
|
||||
@@ -221,17 +233,28 @@ def regenerate_closets(
|
||||
print("No drawers in palace.")
|
||||
return {"processed": 0}
|
||||
|
||||
all_data = drawers_col.get(limit=total, include=["documents", "metadatas"])
|
||||
by_source = {}
|
||||
for doc_id, doc, meta in zip(all_data["ids"], all_data["documents"], all_data["metadatas"]):
|
||||
source = meta.get("source_file", "unknown")
|
||||
w = meta.get("wing", "")
|
||||
if wing and w != wing:
|
||||
continue
|
||||
if source not in by_source:
|
||||
by_source[source] = {"drawer_ids": [], "content": [], "meta": meta}
|
||||
by_source[source]["drawer_ids"].append(doc_id)
|
||||
by_source[source]["content"].append(doc)
|
||||
# Paginate the fetch — a single get(limit=total, ...) blows through
|
||||
# SQLite's SQLITE_MAX_VARIABLE_NUMBER (32766) on large palaces and
|
||||
# crashes inside chromadb (see #802, #850, #1073).
|
||||
by_source: dict = {}
|
||||
batch_size = 5000
|
||||
offset = 0
|
||||
while offset < total:
|
||||
batch = drawers_col.get(limit=batch_size, offset=offset, include=["documents", "metadatas"])
|
||||
ids = batch["ids"]
|
||||
if not ids:
|
||||
break
|
||||
for doc_id, doc, meta in zip(ids, batch["documents"], batch["metadatas"]):
|
||||
meta = meta or {}
|
||||
source = meta.get("source_file", "unknown")
|
||||
w = meta.get("wing", "")
|
||||
if wing and w != wing:
|
||||
continue
|
||||
if source not in by_source:
|
||||
by_source[source] = {"drawer_ids": [], "content": [], "meta": meta}
|
||||
by_source[source]["drawer_ids"].append(doc_id)
|
||||
by_source[source]["content"].append(doc)
|
||||
offset += len(ids)
|
||||
|
||||
sources = list(by_source.keys())
|
||||
if sample > 0:
|
||||
|
||||
+151
-1
@@ -7,6 +7,8 @@ Priority: env vars > config file (~/.mempalace/config.json) > defaults
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -19,6 +21,16 @@ MAX_NAME_LENGTH = 128
|
||||
_SAFE_NAME_RE = re.compile(r"^(?:[^\W_]|[^\W_][\w .'-]{0,126}[^\W_])$")
|
||||
|
||||
|
||||
def normalize_wing_name(name: str) -> str:
|
||||
"""Lower-case + collapse separators (`-`, ` `) to `_` for wing slugs.
|
||||
|
||||
The same rule is applied by ``init`` when persisting `topics_by_wing`
|
||||
and when writing `mempalace.yaml`, so the miner's lookup matches at
|
||||
mine time regardless of the source dirname.
|
||||
"""
|
||||
return name.lower().replace(" ", "_").replace("-", "_")
|
||||
|
||||
|
||||
def sanitize_name(value: str, field_name: str = "name") -> str:
|
||||
"""Validate and sanitize a wing/room/entity name.
|
||||
|
||||
@@ -71,6 +83,91 @@ def sanitize_kg_value(value: str, field_name: str = "value") -> str:
|
||||
return value
|
||||
|
||||
|
||||
# ISO-8601 temporal validator for knowledge-graph temporal parameters
|
||||
# (as_of, valid_from, valid_to, ended).
|
||||
#
|
||||
# The KG stores temporal values as TEXT. Lexicographic comparisons are only
|
||||
# safe when datetime values use one canonical shape. Accept full dates for
|
||||
# legacy compatibility and exact UTC datetimes for sub-day precision.
|
||||
#
|
||||
# Accepted:
|
||||
# YYYY-MM-DD
|
||||
# YYYY-MM-DDTHH:MM:SSZ
|
||||
# YYYY-MM-DDTHH:MM:SS+00:00 (normalized to ...Z)
|
||||
#
|
||||
# Rejected:
|
||||
# partial dates, naive datetimes, non-UTC timezone offsets, fractional
|
||||
# seconds, and SQLite-style space-separated datetimes.
|
||||
_ISO_DATE_RE = re.compile(r"^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$")
|
||||
|
||||
_ISO_UTC_DATETIME_RE = re.compile(
|
||||
r"^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])"
|
||||
r"T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:Z|\+00:00)$"
|
||||
)
|
||||
|
||||
|
||||
def _validate_iso_temporal_calendar(value: str) -> None:
|
||||
"""Reject impossible calendar values after regex shape validation."""
|
||||
|
||||
if _ISO_DATE_RE.match(value):
|
||||
date.fromisoformat(value)
|
||||
return
|
||||
|
||||
if _ISO_UTC_DATETIME_RE.match(value):
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return
|
||||
|
||||
raise ValueError
|
||||
|
||||
|
||||
def sanitize_iso_temporal(value, field_name: str = "date"):
|
||||
"""Validate an ISO-8601 date or canonical UTC datetime string.
|
||||
|
||||
Accepts ``None`` and ``""`` as pass-through values.
|
||||
|
||||
Accepted non-empty string forms:
|
||||
|
||||
- ``YYYY-MM-DD``
|
||||
- ``YYYY-MM-DDTHH:MM:SSZ``
|
||||
- ``YYYY-MM-DDTHH:MM:SS+00:00`` normalized to ``...Z``
|
||||
|
||||
Partial dates are rejected because KG queries compare TEXT temporal values.
|
||||
Non-canonical datetime forms are rejected because mixed temporal string
|
||||
formats can silently return wrong KG query results.
|
||||
"""
|
||||
|
||||
if value is None or value == "":
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field_name} must be a string")
|
||||
|
||||
value = value.strip()
|
||||
|
||||
try:
|
||||
_validate_iso_temporal_calendar(value)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"{field_name}={value!r} is not a valid ISO-8601 date or UTC datetime "
|
||||
"(expected YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)"
|
||||
) from None
|
||||
|
||||
if value.endswith("+00:00"):
|
||||
value = f"{value[:-6]}Z"
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_iso_date(value, field_name: str = "date"):
|
||||
"""Backward-compatible wrapper for ISO temporal validation.
|
||||
|
||||
Historically this accepted only full dates. It now also accepts canonical
|
||||
UTC datetimes, but the old name is kept so existing imports continue to
|
||||
work.
|
||||
"""
|
||||
|
||||
return sanitize_iso_temporal(value, field_name)
|
||||
|
||||
|
||||
def sanitize_content(value: str, max_length: int = 100_000) -> str:
|
||||
"""Validate drawer/diary content length."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
@@ -85,6 +182,13 @@ def sanitize_content(value: str, max_length: int = 100_000) -> str:
|
||||
DEFAULT_PALACE_PATH = os.path.expanduser("~/.mempalace/palace")
|
||||
DEFAULT_COLLECTION_NAME = "mempalace_drawers"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_configured_collection_name() -> str:
|
||||
"""Return the configured drawer collection name without repeated config-file reads."""
|
||||
return MempalaceConfig().collection_name
|
||||
|
||||
|
||||
DEFAULT_TOPIC_WINGS = [
|
||||
"emotions",
|
||||
"consciousness",
|
||||
@@ -168,7 +272,10 @@ class MempalaceConfig:
|
||||
"""Path to the memory palace data directory."""
|
||||
env_val = os.environ.get("MEMPALACE_PALACE_PATH") or os.environ.get("MEMPAL_PALACE_PATH")
|
||||
if env_val:
|
||||
return 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)
|
||||
|
||||
@property
|
||||
@@ -233,6 +340,49 @@ class MempalaceConfig:
|
||||
pass
|
||||
return normalized
|
||||
|
||||
@property
|
||||
def embedding_device(self):
|
||||
"""Hardware device for the ONNX embedding model.
|
||||
|
||||
Values: ``"auto"`` (default), ``"cpu"``, ``"cuda"``, ``"coreml"``,
|
||||
``"dml"``. Read from env ``MEMPALACE_EMBEDDING_DEVICE`` first, then
|
||||
``embedding_device`` in ``config.json``, then ``"auto"``.
|
||||
|
||||
``auto`` resolves to the first available accelerator at runtime via
|
||||
:mod:`mempalace.embedding`; requesting an unavailable accelerator
|
||||
logs a warning and falls back to CPU.
|
||||
"""
|
||||
env_val = os.environ.get("MEMPALACE_EMBEDDING_DEVICE")
|
||||
if env_val:
|
||||
return env_val.strip().lower()
|
||||
return str(self._file_config.get("embedding_device", "auto")).strip().lower()
|
||||
|
||||
@property
|
||||
def topic_tunnel_min_count(self):
|
||||
"""Minimum number of overlapping confirmed topics required to create
|
||||
a cross-wing tunnel between two wings.
|
||||
|
||||
Default is ``1`` — any single shared topic produces a tunnel. Bump
|
||||
to ``2+`` if your projects share lots of common-tech labels (Python,
|
||||
Docker, Git) and you want only meaningfully overlapping wings to
|
||||
link. Reads ``MEMPALACE_TOPIC_TUNNEL_MIN_COUNT`` env first, then the
|
||||
config-file value, then ``1``.
|
||||
"""
|
||||
env_val = os.environ.get("MEMPALACE_TOPIC_TUNNEL_MIN_COUNT")
|
||||
if env_val:
|
||||
try:
|
||||
parsed = int(env_val)
|
||||
if parsed >= 1:
|
||||
return parsed
|
||||
except ValueError:
|
||||
pass
|
||||
cfg_val = self._file_config.get("topic_tunnel_min_count")
|
||||
try:
|
||||
parsed = int(cfg_val) if cfg_val is not None else 1
|
||||
except (TypeError, ValueError):
|
||||
parsed = 1
|
||||
return max(1, parsed)
|
||||
|
||||
@property
|
||||
def hook_silent_save(self):
|
||||
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""
|
||||
|
||||
+51
-26
@@ -11,6 +11,7 @@ Same palace as project mining. Different ingest strategy.
|
||||
import os
|
||||
import sys
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
@@ -24,6 +25,8 @@ from .palace import (
|
||||
mine_lock,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mempalace_mcp")
|
||||
|
||||
|
||||
# Cached hall keywords — avoids re-reading config per drawer
|
||||
_HALL_KEYWORDS_CACHE = None
|
||||
@@ -55,7 +58,15 @@ CONVO_EXTENSIONS = {
|
||||
|
||||
MIN_CHUNK_SIZE = 30
|
||||
CHUNK_SIZE = 800 # chars per drawer — align with miner.py
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB — skip files larger than this
|
||||
DRAWER_UPSERT_BATCH_SIZE = 1000
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB — skip files larger than this.
|
||||
# Matches miner.py at 500 MB. Long Claude Code sessions, multi-year
|
||||
# ChatGPT exports, and lifetime Slack dumps routinely exceed 10 MB; the
|
||||
# cap at that level silently dropped them with `continue`. Per-drawer
|
||||
# size is bounded by CHUNK_SIZE, but larger source files still produce
|
||||
# more drawers and therefore more embedding/storage work — and content
|
||||
# is normalized and loaded fully into memory before chunking, so memory
|
||||
# use also scales with source size.
|
||||
|
||||
|
||||
def _register_file(collection, source_file: str, wing: str, agent: str):
|
||||
@@ -323,33 +334,45 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
|
||||
try:
|
||||
collection.delete(where={"source_file": source_file})
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Stale-drawer purge failed for %s", source_file, exc_info=True)
|
||||
|
||||
for chunk in chunks:
|
||||
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
|
||||
if extract_mode == "general":
|
||||
room_counts_delta[chunk_room] += 1
|
||||
drawer_id = f"drawer_{wing}_{chunk_room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
|
||||
# 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()
|
||||
for batch_start in range(0, len(chunks), DRAWER_UPSERT_BATCH_SIZE):
|
||||
batch_docs: list = []
|
||||
batch_ids: list = []
|
||||
batch_metas: list = []
|
||||
for chunk in chunks[batch_start : batch_start + DRAWER_UPSERT_BATCH_SIZE]:
|
||||
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
|
||||
if extract_mode == "general":
|
||||
room_counts_delta[chunk_room] += 1
|
||||
drawer_id = f"drawer_{wing}_{chunk_room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
|
||||
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,
|
||||
}
|
||||
)
|
||||
try:
|
||||
collection.upsert(
|
||||
documents=[chunk["content"]],
|
||||
ids=[drawer_id],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": wing,
|
||||
"room": chunk_room,
|
||||
"hall": _detect_hall_cached(chunk["content"]),
|
||||
"source_file": source_file,
|
||||
"chunk_index": chunk["chunk_index"],
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"ingest_mode": "convos",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
],
|
||||
documents=batch_docs,
|
||||
ids=batch_ids,
|
||||
metadatas=batch_metas,
|
||||
)
|
||||
drawers_added += 1
|
||||
drawers_added += len(batch_docs)
|
||||
except Exception as e:
|
||||
if "already exists" not in str(e).lower():
|
||||
raise
|
||||
@@ -374,7 +397,9 @@ def mine_convos(
|
||||
|
||||
convo_path = Path(convo_dir).expanduser().resolve()
|
||||
if not wing:
|
||||
wing = convo_path.name.lower().replace(" ", "_").replace("-", "_")
|
||||
from .config import normalize_wing_name
|
||||
|
||||
wing = normalize_wing_name(convo_path.name)
|
||||
|
||||
files = scan_convos(convo_dir)
|
||||
if limit > 0:
|
||||
@@ -471,7 +496,7 @@ def mine_convos(
|
||||
room_counts[r] += n
|
||||
|
||||
total_drawers += drawers_added
|
||||
print(f" ✓ [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
|
||||
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" Done.")
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
convo_scanner.py — Parse Claude Code conversation directories into ProjectInfo.
|
||||
|
||||
Claude Code stores sessions under ``~/.claude/projects/<slug>/<id>.jsonl``,
|
||||
where the ``<slug>`` is the original CWD with ``/`` replaced by ``-``. That
|
||||
encoding is lossy: we can't tell whether ``foo-bar`` in a slug is the
|
||||
literal project name ``foo-bar`` or two path segments ``foo/bar``.
|
||||
|
||||
Fortunately, every message record in the JSONL carries a ``cwd`` field with
|
||||
the true path. This scanner reads one record per session to recover the
|
||||
accurate project name, falling back to slug-decoding only if the JSONL
|
||||
is malformed or empty.
|
||||
|
||||
Output is the same ``ProjectInfo`` shape used by ``project_scanner``, so the
|
||||
``discover_entities`` orchestrator can mix-and-match sources.
|
||||
|
||||
Public:
|
||||
is_claude_projects_root(path) -> bool
|
||||
scan_claude_projects(path) -> list[ProjectInfo]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mempalace.project_scanner import ProjectInfo
|
||||
|
||||
|
||||
MAX_HEADER_LINES = 20 # lines to read per session looking for `cwd`
|
||||
|
||||
|
||||
def is_claude_projects_root(path: Path) -> bool:
|
||||
"""Return True if path looks like `.claude/projects/`.
|
||||
|
||||
Heuristic: at least one child dir whose name starts with ``-`` and which
|
||||
contains at least one ``.jsonl`` file.
|
||||
"""
|
||||
if not path.is_dir():
|
||||
return False
|
||||
try:
|
||||
children = list(path.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
for child in children:
|
||||
if not (child.is_dir() and child.name.startswith("-")):
|
||||
continue
|
||||
try:
|
||||
if any(p.suffix == ".jsonl" for p in child.iterdir() if p.is_file()):
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _extract_cwd_from_session(session_file: Path) -> Optional[str]:
|
||||
"""Return the ``cwd`` from the first message record that carries one.
|
||||
|
||||
Returns None if the file can't be read, has no JSON, or no record has cwd.
|
||||
"""
|
||||
try:
|
||||
with open(session_file, encoding="utf-8", errors="replace") as f:
|
||||
for i, line in enumerate(f):
|
||||
if i >= MAX_HEADER_LINES:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
cwd = obj.get("cwd")
|
||||
if isinstance(cwd, str) and cwd:
|
||||
return cwd
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _decode_slug_fallback(slug: str) -> str:
|
||||
"""Best-effort project name from slug when cwd is unavailable.
|
||||
|
||||
The slug is lossy (`/` and `-` both become `-`). Last non-empty segment
|
||||
is the closest guess at the project name, preserving kebab-case is
|
||||
impossible without cwd.
|
||||
"""
|
||||
stripped = slug.lstrip("-")
|
||||
parts = [p for p in stripped.split("-") if p]
|
||||
return parts[-1] if parts else slug
|
||||
|
||||
|
||||
def _safe_mtime(path: Path) -> float:
|
||||
"""Return file mtime, defaulting old on permission or filesystem errors."""
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _resolve_project_name(project_dir: Path) -> str:
|
||||
"""Read one session's cwd to recover the original project name.
|
||||
|
||||
Falls back to slug-decoding if no session has a readable cwd.
|
||||
"""
|
||||
sessions = sorted(
|
||||
(p for p in project_dir.iterdir() if p.is_file() and p.suffix == ".jsonl"),
|
||||
key=_safe_mtime,
|
||||
reverse=True, # newest first — most likely to be well-formed
|
||||
)
|
||||
for session in sessions:
|
||||
cwd = _extract_cwd_from_session(session)
|
||||
if cwd:
|
||||
return Path(cwd).name or cwd
|
||||
return _decode_slug_fallback(project_dir.name)
|
||||
|
||||
|
||||
def scan_claude_projects(path: str | Path) -> list[ProjectInfo]:
|
||||
"""Scan a ``.claude/projects/`` directory for Claude Code conversations.
|
||||
|
||||
One ProjectInfo per subdir. ``has_git`` is False (the directory isn't a
|
||||
repo itself) but ``total_commits`` is repurposed here as session count so
|
||||
the UX surfaces a density signal for ranking.
|
||||
"""
|
||||
root = Path(path).expanduser().resolve()
|
||||
if not is_claude_projects_root(root):
|
||||
return []
|
||||
|
||||
projects: dict[str, ProjectInfo] = {}
|
||||
for sub in sorted(root.iterdir()):
|
||||
if not (sub.is_dir() and sub.name.startswith("-")):
|
||||
continue
|
||||
try:
|
||||
sessions = [p for p in sub.iterdir() if p.is_file() and p.suffix == ".jsonl"]
|
||||
except OSError:
|
||||
continue
|
||||
if not sessions:
|
||||
continue
|
||||
|
||||
name = _resolve_project_name(sub)
|
||||
session_count = len(sessions)
|
||||
|
||||
proj = ProjectInfo(
|
||||
name=name,
|
||||
repo_root=sub,
|
||||
manifest=None,
|
||||
has_git=False,
|
||||
total_commits=session_count,
|
||||
user_commits=session_count,
|
||||
is_mine=True, # Claude Code sessions are authored by the user
|
||||
)
|
||||
existing = projects.get(name)
|
||||
if existing is None or session_count > existing.user_commits:
|
||||
projects[name] = proj
|
||||
|
||||
return sorted(
|
||||
projects.values(),
|
||||
key=lambda p: (-p.user_commits, p.name),
|
||||
)
|
||||
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
corpus_origin.py — Detect whether a corpus is an AI-dialogue record and,
|
||||
if so, what platform and what persona names the user has assigned to the
|
||||
agent.
|
||||
|
||||
This is the first question any downstream Pass 2 classification needs
|
||||
answered. Without it, a drawer like "my three sons" in a Claude Code
|
||||
dialogue corpus can't be correctly resolved to "three AI instances"
|
||||
rather than "three biological children."
|
||||
|
||||
Two-tier detection:
|
||||
|
||||
Tier 1 — detect_origin_heuristic(samples)
|
||||
Cheap, no API. Grep for well-known AI brand terms + turn
|
||||
markers. Always runs. Outputs a hypothesis.
|
||||
|
||||
Tier 2 — detect_origin_llm(samples, provider)
|
||||
Uses an LLMProvider (typically Haiku via mempalace.llm_client)
|
||||
with the model's pre-trained knowledge of Claude/ChatGPT/Gemini
|
||||
etc. Confirms platform, extracts agent persona-names the user
|
||||
has assigned. One call, ~$0.01 cost.
|
||||
|
||||
Design principle:
|
||||
Don't make the classifier re-discover what Claude, ChatGPT, Gemini, MCP,
|
||||
or other well-known entities ARE — the LLM already knows them from its
|
||||
training. Only corpus-specific entities (e.g. the user's persona-name
|
||||
for their Claude instance) need discovery.
|
||||
|
||||
Default stance (when evidence is thin):
|
||||
"This IS an AI-dialogue corpus" — false-negative is catastrophic for
|
||||
downstream classification; false-positive is recoverable via per-drawer
|
||||
voice-profile detection in later passes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Well-known AI brand terms (expand as new platforms emerge) ────────────
|
||||
# Detection is by PATTERN + CONTEXT, not by capitalization or English-language
|
||||
# rules. Two categories:
|
||||
#
|
||||
# UNAMBIGUOUS — terms that have essentially no meaning outside of AI context.
|
||||
# Always counted toward AI-dialogue evidence.
|
||||
#
|
||||
# AMBIGUOUS — terms that share a string with common English words, names,
|
||||
# poetry forms, zodiac signs, animals, etc. Counted toward AI-dialogue
|
||||
# evidence ONLY when at least one unambiguous AI signal also appears in
|
||||
# the corpus (turn marker, unambiguous brand term, or AI infrastructure
|
||||
# term). This avoids false-positives on French novels with characters
|
||||
# named "Claude", astrology corpora discussing "Gemini", poetry corpora
|
||||
# full of "haiku" / "sonnet", etc.
|
||||
#
|
||||
# All matching is CASE-INSENSITIVE — users type lowercase constantly.
|
||||
|
||||
_AI_UNAMBIGUOUS_TERMS = [
|
||||
# Anthropic-specific
|
||||
"Anthropic",
|
||||
"Claude Code",
|
||||
"Claude 3",
|
||||
"Claude 4",
|
||||
"claude mcp",
|
||||
"CLAUDE.md",
|
||||
".claude/",
|
||||
# OpenAI-specific
|
||||
"ChatGPT",
|
||||
"GPT-4",
|
||||
"GPT-3",
|
||||
"GPT-5",
|
||||
"OpenAI",
|
||||
"gpt-4o",
|
||||
"gpt-4-turbo",
|
||||
"o1-preview",
|
||||
"o3",
|
||||
# Google-specific
|
||||
"gemini-pro",
|
||||
"gemini-1.5",
|
||||
"Google AI",
|
||||
# Meta / others (specific model identifiers, not bare common words)
|
||||
"Mixtral",
|
||||
"Cohere",
|
||||
# AI-infrastructure terms with no common-English collision
|
||||
"MCP",
|
||||
"LLM",
|
||||
"RAG",
|
||||
"fine-tune",
|
||||
"context window",
|
||||
"embedding",
|
||||
]
|
||||
|
||||
_AI_AMBIGUOUS_TERMS = [
|
||||
# Anthropic — bare brand/model names that collide with names + poetry
|
||||
"Claude", # also a common French masculine name
|
||||
"Opus", # also a musical work, comic strip, magazine
|
||||
"Sonnet", # also a 14-line poem form
|
||||
"Haiku", # also a 17-syllable poem form
|
||||
# Google — bare brand that collides with zodiac sign
|
||||
"Gemini", # also the zodiac sign
|
||||
"Bard", # also a poet / Shakespeare
|
||||
# Meta / others
|
||||
"Llama", # also the South American animal
|
||||
"Mistral", # also a Mediterranean wind
|
||||
# Note: 'prompt', 'completion', 'tokens' previously lived here but were
|
||||
# removed: they're suppressed without an unambiguous co-signal anyway,
|
||||
# and by the time a co-signal is present the corpus is already flagged.
|
||||
# Keeping them just produced noisier evidence strings.
|
||||
]
|
||||
|
||||
# Turn-marker patterns commonly seen in AI-dialogue transcripts
|
||||
_TURN_MARKERS = [
|
||||
r"\buser\s*:\s*",
|
||||
r"\bassistant\s*:\s*",
|
||||
r"\bhuman\s*:\s*",
|
||||
r"\bai\s*:\s*",
|
||||
r"\b>>>\s*User\b",
|
||||
r"\b>>>\s*Assistant\b",
|
||||
]
|
||||
|
||||
|
||||
def _brand_pattern(term: str) -> str:
|
||||
"""Build a regex for a brand term that uses word boundaries
|
||||
only on edges where the term itself starts/ends with a word
|
||||
character. Without this nuance:
|
||||
- 'Claude' would falsely match inside 'Claudette' (no \\b)
|
||||
- '.claude/' would fail to match at start of string (\\b
|
||||
before non-word char requires preceding word char)
|
||||
So we only attach \\b where it actually makes sense."""
|
||||
escaped = re.escape(term)
|
||||
prefix = r"\b" if term[0].isalnum() or term[0] == "_" else ""
|
||||
suffix = r"\b" if term[-1].isalnum() or term[-1] == "_" else ""
|
||||
return prefix + escaped + suffix
|
||||
|
||||
|
||||
@dataclass
|
||||
class CorpusOriginResult:
|
||||
"""Structured output from corpus-origin detection.
|
||||
|
||||
Fields:
|
||||
likely_ai_dialogue — best hypothesis about whether this is AI-dialogue
|
||||
confidence — 0.0 to 1.0
|
||||
primary_platform — e.g. "Claude Code (Anthropic CLI)" or None
|
||||
user_name — the corpus author's name if identifiable from context, else None
|
||||
agent_persona_names — names the user has assigned to the AI agent(s)
|
||||
(e.g. ["Echo", "Sparrow"]). Does NOT include the user's own name.
|
||||
evidence — human-readable reasons for the classification
|
||||
"""
|
||||
|
||||
likely_ai_dialogue: bool
|
||||
confidence: float
|
||||
primary_platform: Optional[str]
|
||||
user_name: Optional[str] = None
|
||||
agent_persona_names: list[str] = field(default_factory=list)
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# ── Tier 1: cheap heuristic ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def detect_origin_heuristic(samples: list[str]) -> CorpusOriginResult:
|
||||
"""Fast grep-based detection. No API calls.
|
||||
|
||||
Scores AI-dialogue likelihood by counting:
|
||||
- occurrences of well-known AI brand terms
|
||||
- turn-marker patterns (user:, assistant:, etc.)
|
||||
|
||||
Returns a CorpusOriginResult with confidence derived from signal density.
|
||||
"""
|
||||
combined = "\n\n".join(samples)
|
||||
total_chars = max(1, len(combined))
|
||||
|
||||
# Count UNAMBIGUOUS brand-term hits (case-insensitive — users type
|
||||
# lowercase constantly, so 'chatgpt' must trip the same as 'ChatGPT').
|
||||
# Word boundaries prevent false in-word matches (see _brand_pattern).
|
||||
unambiguous_hits: dict[str, int] = {}
|
||||
total_unambiguous = 0
|
||||
for term in _AI_UNAMBIGUOUS_TERMS:
|
||||
matches = re.findall(_brand_pattern(term), combined, re.IGNORECASE)
|
||||
if matches:
|
||||
unambiguous_hits[term] = len(matches)
|
||||
total_unambiguous += len(matches)
|
||||
|
||||
# Count AMBIGUOUS brand-term hits separately. These will only be
|
||||
# counted toward AI-dialogue evidence if the corpus also contains
|
||||
# at least one unambiguous AI signal — see co-occurrence rule below.
|
||||
ambiguous_hits: dict[str, int] = {}
|
||||
total_ambiguous = 0
|
||||
for term in _AI_AMBIGUOUS_TERMS:
|
||||
matches = re.findall(_brand_pattern(term), combined, re.IGNORECASE)
|
||||
if matches:
|
||||
ambiguous_hits[term] = len(matches)
|
||||
total_ambiguous += len(matches)
|
||||
|
||||
# Count turn-marker hits (case-insensitive — transcripts vary).
|
||||
turn_hits = 0
|
||||
turn_types_found = set()
|
||||
for pattern in _TURN_MARKERS:
|
||||
matches = re.findall(pattern, combined, re.IGNORECASE)
|
||||
if matches:
|
||||
turn_hits += len(matches)
|
||||
turn_types_found.add(pattern)
|
||||
|
||||
# Co-occurrence rule for ambiguous terms.
|
||||
# Ambiguous terms (e.g. 'Claude' as a French name, 'Gemini' as a zodiac
|
||||
# sign, 'Haiku' as a poem form) only count toward brand evidence if
|
||||
# the corpus also contains at least one unambiguous AI signal. Otherwise
|
||||
# we'd false-positive on French novels, astrology forums, poetry corpora,
|
||||
# llama-rancher journals, etc.
|
||||
has_ai_context = total_unambiguous > 0 or turn_hits > 0
|
||||
counted_brand_hits = total_unambiguous + (total_ambiguous if has_ai_context else 0)
|
||||
|
||||
# Brand-term density per 1000 chars; turn-marker density likewise.
|
||||
# Tuned on a small set of examples; these aren't magic numbers and
|
||||
# can be revisited as we see more corpora.
|
||||
brand_density = counted_brand_hits / (total_chars / 1000)
|
||||
turn_density = turn_hits / (total_chars / 1000)
|
||||
|
||||
# Build evidence list
|
||||
evidence: list[str] = []
|
||||
shown_hits = dict(unambiguous_hits)
|
||||
if has_ai_context:
|
||||
shown_hits.update(ambiguous_hits)
|
||||
if shown_hits:
|
||||
top_terms = sorted(shown_hits.items(), key=lambda x: -x[1])[:5]
|
||||
evidence.append("AI brand terms: " + ", ".join(f"'{k}' ({v}x)" for k, v in top_terms))
|
||||
elif ambiguous_hits and not has_ai_context:
|
||||
# Be transparent that we saw ambiguous matches but suppressed them
|
||||
# for lack of co-occurring AI context.
|
||||
suppressed = sorted(ambiguous_hits.items(), key=lambda x: -x[1])[:3]
|
||||
evidence.append(
|
||||
"Ambiguous terms present but suppressed (no co-occurring AI signal): "
|
||||
+ ", ".join(f"'{k}' ({v}x)" for k, v in suppressed)
|
||||
)
|
||||
if turn_hits:
|
||||
evidence.append(
|
||||
f"Turn markers detected: {turn_hits} occurrences across {len(turn_types_found)} pattern types"
|
||||
)
|
||||
|
||||
# Decision logic:
|
||||
# strong signal (brand OR turn hits both >= threshold) → confident AI-dialogue
|
||||
# MEANINGFUL absence (enough text, zero brand, zero turn) → confident narrative
|
||||
# ambiguous or insufficient text → default stance: AI-dialogue with low confidence
|
||||
#
|
||||
# Threshold for "meaningful absence": the samples collectively have to
|
||||
# be long enough that the absence of AI signals would be expected to
|
||||
# surface if the corpus really is narrative. 150 chars is the working
|
||||
# floor — below that, we cannot confidently say "this is narrative."
|
||||
MEANINGFUL_TEXT_FLOOR = 150
|
||||
|
||||
if brand_density >= 0.5 or turn_density >= 2.0:
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=True,
|
||||
confidence=min(0.95, 0.6 + 0.1 * (brand_density + turn_density)),
|
||||
primary_platform=None, # tier 2 will refine
|
||||
evidence=evidence,
|
||||
)
|
||||
if counted_brand_hits == 0 and turn_hits == 0 and total_chars >= MEANINGFUL_TEXT_FLOOR:
|
||||
# Note: ambiguous-only matches (e.g. a French novel with 'Claude' as
|
||||
# a character name) flow through here because counted_brand_hits == 0
|
||||
# when no unambiguous AI signal co-occurs. The 'evidence' list still
|
||||
# records that the ambiguous matches were seen and suppressed.
|
||||
narrative_evidence = list(evidence) + [
|
||||
f"no unambiguous AI signal across {total_chars} chars of text — pure narrative"
|
||||
]
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=False,
|
||||
confidence=0.9,
|
||||
primary_platform=None,
|
||||
evidence=narrative_evidence,
|
||||
)
|
||||
# Ambiguous or too-short-to-tell case: default stance is AI-dialogue
|
||||
# with explicit low confidence. Tier 2 (LLM) should be called to confirm.
|
||||
reason = "weak signal" if (counted_brand_hits or turn_hits) else "insufficient text"
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=True,
|
||||
confidence=0.4,
|
||||
primary_platform=None,
|
||||
evidence=evidence
|
||||
+ [
|
||||
f"{reason} — applying default-stance (ai_dialogue=True, low confidence). "
|
||||
"Tier 2 LLM check recommended to confirm or override."
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ── Tier 2: LLM-assisted confirmation + persona extraction ────────────────
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = """You are analyzing a corpus of text to determine whether it is a \
|
||||
record of conversations with an AI agent (e.g. Claude, ChatGPT, Gemini, custom LLM \
|
||||
apps), or some other kind of text (personal narrative, story, research notes, \
|
||||
journal, code, etc.).
|
||||
|
||||
Use your pre-existing knowledge of well-known AI platforms. You don't need the \
|
||||
corpus to explain what Claude or ChatGPT is — you already know. Your job is to \
|
||||
detect evidence of their presence and identify what persona-names the user has \
|
||||
assigned to the agent(s) they converse with.
|
||||
|
||||
CRITICAL distinction:
|
||||
- agent_persona_names are names the USER has assigned to the AI AGENT(S)
|
||||
they converse with. Example: "Echo", "Sparrow", "Henry" might be names
|
||||
the user calls a Claude instance they're building a relationship with.
|
||||
- Do NOT include the USER's own name in agent_persona_names. The user
|
||||
is the human author of the corpus, not a persona of the agent. Even
|
||||
if the user's name appears frequently in the text (writing about
|
||||
themselves), that is NOT an agent persona.
|
||||
- If you can identify the user's name from context, put it in user_name
|
||||
(separate field). If unclear, leave user_name null.
|
||||
|
||||
Respond with JSON only (no prose before or after):
|
||||
{
|
||||
"is_ai_dialogue_corpus": <true|false>,
|
||||
"confidence": <0.0 to 1.0>,
|
||||
"primary_platform": <"Claude (Anthropic)" | "ChatGPT (OpenAI)" | "Gemini (Google)" | other platform name | null>,
|
||||
"user_name": <user's name if clearly identifiable from context, else null>,
|
||||
"agent_persona_names": [<names the user has assigned to the AI AGENT(S), NOT the user's own name>],
|
||||
"evidence": [<short bullet strings explaining the decision>]
|
||||
}
|
||||
|
||||
Default stance: if evidence is thin or mixed, return is_ai_dialogue_corpus=true \
|
||||
with low confidence. False-negatives on AI-dialogue detection break downstream \
|
||||
classification; false-positives are recoverable later.
|
||||
"""
|
||||
|
||||
|
||||
def _extract_json(text: str) -> Optional[dict]:
|
||||
"""Pull the first JSON object out of a possibly-messy LLM response."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
# Try straight parse first
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# Try to find a {...} block
|
||||
start = text.find("{")
|
||||
if start < 0:
|
||||
return None
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start : i + 1]
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def detect_origin_llm(samples: list[str], provider) -> CorpusOriginResult:
|
||||
"""LLM-assisted detection. Takes samples (list of drawer-text excerpts)
|
||||
and an LLMProvider (mempalace.llm_client.LLMProvider). Returns the
|
||||
same CorpusOriginResult shape as the heuristic.
|
||||
|
||||
Falls back conservatively (default-stance ai=True, low confidence)
|
||||
on any LLM error or malformed response — never raises.
|
||||
"""
|
||||
# Build the user prompt: concise excerpts, capped so we stay cheap
|
||||
max_excerpt_chars = 800
|
||||
excerpts = "\n\n---\n\n".join(
|
||||
f"[sample {i + 1}]\n{s[:max_excerpt_chars]}" for i, s in enumerate(samples[:20])
|
||||
)
|
||||
user_prompt = f"CORPUS EXCERPTS:\n\n{excerpts}\n\nAnalyze and respond with JSON."
|
||||
|
||||
try:
|
||||
resp = provider.classify(system=_SYSTEM_PROMPT, user=user_prompt, json_mode=True)
|
||||
raw = getattr(resp, "text", "") or ""
|
||||
except Exception as e:
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=True,
|
||||
confidence=0.3,
|
||||
primary_platform=None,
|
||||
evidence=[f"LLM provider error (fallback to default stance): {e}"],
|
||||
)
|
||||
|
||||
parsed = _extract_json(raw)
|
||||
if not parsed or not isinstance(parsed, dict):
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=True,
|
||||
confidence=0.3,
|
||||
primary_platform=None,
|
||||
evidence=["LLM response was not valid JSON (fallback to default stance)"],
|
||||
)
|
||||
|
||||
# Pull fields defensively. If the LLM leaked the user_name into
|
||||
# agent_persona_names despite the prompt telling it not to, filter it out.
|
||||
user_name = parsed.get("user_name") or None
|
||||
personas = list(parsed.get("agent_persona_names") or [])
|
||||
if user_name:
|
||||
personas = [p for p in personas if p.lower() != user_name.lower()]
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=bool(parsed.get("is_ai_dialogue_corpus", True)),
|
||||
confidence=float(parsed.get("confidence", 0.5)),
|
||||
primary_platform=parsed.get("primary_platform") or None,
|
||||
user_name=user_name,
|
||||
agent_persona_names=personas,
|
||||
evidence=list(parsed.get("evidence") or []),
|
||||
)
|
||||
+1
-1
@@ -89,7 +89,7 @@ def dedup_source_group(col, drawer_ids, threshold=DEFAULT_THRESHOLD, dry_run=Tru
|
||||
kept = []
|
||||
to_delete = []
|
||||
|
||||
for did, doc, meta in items:
|
||||
for did, doc, _meta in items:
|
||||
if not doc or len(doc) < 20:
|
||||
to_delete.append(did)
|
||||
continue
|
||||
|
||||
@@ -873,7 +873,7 @@ class Dialect:
|
||||
|
||||
for date_key in sorted(by_date.keys()):
|
||||
lines.append(f"=MOMENTS[{date_key}]=")
|
||||
for z, fnum in by_date[date_key]:
|
||||
for z, _fnum in by_date[date_key]:
|
||||
entities = []
|
||||
for p in z.get("people", []):
|
||||
code = self.encode_entity(p)
|
||||
|
||||
@@ -120,12 +120,28 @@ def ingest_diaries(
|
||||
continue
|
||||
date_str = date_match.group(1)
|
||||
|
||||
# Skip if content hasn't changed
|
||||
# Skip if content hasn't changed. Hash-based — size alone false-negatives
|
||||
# on same-length edits (e.g. "teh" → "the"), silently dropping real edits.
|
||||
state_key = f"{wing}|{diary_path.name}"
|
||||
prev_size = state.get(state_key, {}).get("size", 0)
|
||||
prev_entry = state.get(state_key, {})
|
||||
prev_hash = prev_entry.get("content_hash")
|
||||
prev_size = prev_entry.get("size", 0)
|
||||
curr_size = len(text)
|
||||
if curr_size == prev_size and not force:
|
||||
continue
|
||||
curr_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
if not force:
|
||||
if prev_hash is not None:
|
||||
if curr_hash == prev_hash:
|
||||
continue
|
||||
elif curr_size == prev_size and prev_size > 0:
|
||||
# Legacy state without content_hash: keep size-based skip but
|
||||
# backfill the hash so future runs use the strict check.
|
||||
state[state_key] = {**prev_entry, "content_hash": curr_hash}
|
||||
continue
|
||||
|
||||
# An in-place edit (same entry count, different content) means existing
|
||||
# closets are stale. Force a full rebuild whenever the hash changes,
|
||||
# not only on entry-count growth.
|
||||
content_changed = prev_hash is not None and curr_hash != prev_hash
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
drawer_id = _diary_drawer_id(wing, date_str)
|
||||
@@ -153,7 +169,8 @@ def ingest_diaries(
|
||||
|
||||
entries = _split_entries(text)
|
||||
prev_entry_count = state.get(state_key, {}).get("entry_count", 0)
|
||||
new_entries = entries if force else entries[prev_entry_count:]
|
||||
full_rebuild = force or content_changed
|
||||
new_entries = entries if full_rebuild else entries[prev_entry_count:]
|
||||
|
||||
if new_entries:
|
||||
all_lines = []
|
||||
@@ -175,15 +192,16 @@ def ingest_diaries(
|
||||
}
|
||||
if entities:
|
||||
closet_meta["entities"] = entities
|
||||
# On a force rebuild, wipe any leftover numbered closets
|
||||
# from a longer prior run before re-writing.
|
||||
if force:
|
||||
# On any full rebuild (force or detected content edit),
|
||||
# wipe leftover closets from a prior run before re-writing.
|
||||
if full_rebuild:
|
||||
purge_file_closets(closets_col, source_file)
|
||||
n = upsert_closet_lines(closets_col, closet_id_base, all_lines, closet_meta)
|
||||
closets_created += n
|
||||
|
||||
state[state_key] = {
|
||||
"size": curr_size,
|
||||
"content_hash": curr_hash,
|
||||
"entry_count": len(entries),
|
||||
"ingested_at": now_iso,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Embedding function factory with hardware acceleration.
|
||||
|
||||
Returns a ChromaDB-compatible embedding function bound to a user-selected
|
||||
ONNX Runtime execution provider. The same ``all-MiniLM-L6-v2`` model and
|
||||
384-dim vectors ChromaDB ships by default are reused, so switching device
|
||||
does not invalidate existing palaces.
|
||||
|
||||
Supported devices (env ``MEMPALACE_EMBEDDING_DEVICE`` or ``embedding_device``
|
||||
in ``~/.mempalace/config.json``):
|
||||
|
||||
* ``auto`` — prefer CUDA ▸ CoreML ▸ DirectML, fall back to CPU
|
||||
* ``cpu`` — force CPU (the historical default)
|
||||
* ``cuda`` — NVIDIA GPU via ``onnxruntime-gpu`` (``pip install mempalace[gpu]``)
|
||||
* ``coreml`` — Apple Neural Engine (macOS)
|
||||
* ``dml`` — DirectML (Windows / AMD / Intel GPUs)
|
||||
|
||||
Requesting an unavailable accelerator emits a warning and falls back to CPU
|
||||
rather than hard-failing — mining must still work on a laptop without CUDA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROVIDER_MAP = {
|
||||
"cpu": ["CPUExecutionProvider"],
|
||||
"cuda": ["CUDAExecutionProvider", "CPUExecutionProvider"],
|
||||
"coreml": ["CoreMLExecutionProvider", "CPUExecutionProvider"],
|
||||
"dml": ["DmlExecutionProvider", "CPUExecutionProvider"],
|
||||
}
|
||||
|
||||
_DEVICE_EXTRA = {
|
||||
"cuda": "mempalace[gpu]",
|
||||
"coreml": "mempalace[coreml]",
|
||||
"dml": "mempalace[dml]",
|
||||
}
|
||||
|
||||
_AUTO_ORDER = [
|
||||
("CUDAExecutionProvider", "cuda"),
|
||||
("CoreMLExecutionProvider", "coreml"),
|
||||
("DmlExecutionProvider", "dml"),
|
||||
]
|
||||
|
||||
_EF_CACHE: dict = {}
|
||||
_WARNED: set = set()
|
||||
|
||||
|
||||
def _resolve_providers(device: str) -> tuple[list, str]:
|
||||
"""Return ``(provider_list, effective_device)`` for ``device``.
|
||||
|
||||
Falls back to CPU (with a one-shot warning) when the requested
|
||||
accelerator is not compiled into the installed ``onnxruntime``.
|
||||
"""
|
||||
device = (device or "auto").strip().lower()
|
||||
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
|
||||
available = set(ort.get_available_providers())
|
||||
except ImportError:
|
||||
return (["CPUExecutionProvider"], "cpu")
|
||||
|
||||
if device == "auto":
|
||||
for provider, name in _AUTO_ORDER:
|
||||
if provider in available:
|
||||
return ([provider, "CPUExecutionProvider"], name)
|
||||
return (["CPUExecutionProvider"], "cpu")
|
||||
|
||||
requested = _PROVIDER_MAP.get(device)
|
||||
if requested is None:
|
||||
if device not in _WARNED:
|
||||
logger.warning("Unknown embedding_device %r — falling back to cpu", device)
|
||||
_WARNED.add(device)
|
||||
return (["CPUExecutionProvider"], "cpu")
|
||||
|
||||
preferred = requested[0]
|
||||
if preferred == "CPUExecutionProvider":
|
||||
return (requested, "cpu")
|
||||
|
||||
if preferred not in available:
|
||||
if device not in _WARNED:
|
||||
extra = _DEVICE_EXTRA.get(device, "the matching mempalace extra for your device")
|
||||
logger.warning(
|
||||
"embedding_device=%r requested but %s is not installed — "
|
||||
"falling back to CPU. Install %s.",
|
||||
device,
|
||||
preferred,
|
||||
extra,
|
||||
)
|
||||
_WARNED.add(device)
|
||||
return (["CPUExecutionProvider"], "cpu")
|
||||
|
||||
return (requested, device)
|
||||
|
||||
|
||||
def _build_ef_class():
|
||||
"""Subclass ``ONNXMiniLM_L6_V2`` with name ``"default"``.
|
||||
|
||||
Why the rename: ChromaDB 1.5 persists the EF identity on the collection
|
||||
and rejects reads that pass a differently-named EF (``onnx_mini_lm_l6_v2``
|
||||
vs ``default``). The vectors and model are identical — only the
|
||||
``name()`` tag differs — so spoofing the name lets one EF class serve
|
||||
palaces created with ``DefaultEmbeddingFunction`` *and* palaces we
|
||||
create ourselves, with the same GPU-capable ``preferred_providers``.
|
||||
"""
|
||||
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2
|
||||
|
||||
class _MempalaceONNX(ONNXMiniLM_L6_V2):
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "default"
|
||||
|
||||
return _MempalaceONNX
|
||||
|
||||
|
||||
def get_embedding_function(device: Optional[str] = None):
|
||||
"""Return a cached embedding function bound to the requested device.
|
||||
|
||||
``device=None`` reads from :class:`MempalaceConfig.embedding_device`.
|
||||
The returned function is shared across calls with the same resolved
|
||||
provider list so we only pay model-load cost once per process.
|
||||
"""
|
||||
if device is None:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
device = MempalaceConfig().embedding_device
|
||||
|
||||
providers, effective = _resolve_providers(device)
|
||||
cache_key = tuple(providers)
|
||||
cached = _EF_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
ef_cls = _build_ef_class()
|
||||
ef = ef_cls(preferred_providers=providers)
|
||||
_EF_CACHE[cache_key] = ef
|
||||
logger.info("Embedding function initialized (device=%s providers=%s)", effective, providers)
|
||||
return ef
|
||||
|
||||
|
||||
def describe_device(device: Optional[str] = None) -> str:
|
||||
"""Return a short human-readable label for the resolved device.
|
||||
|
||||
Used by the miner CLI header so users can see at a glance whether GPU
|
||||
acceleration actually engaged.
|
||||
"""
|
||||
if device is None:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
device = MempalaceConfig().embedding_device
|
||||
_, effective = _resolve_providers(device)
|
||||
return effective
|
||||
+161
-17
@@ -2,6 +2,9 @@
|
||||
"""
|
||||
entity_detector.py — Auto-detect people and projects from file content.
|
||||
|
||||
Uses ``from __future__ import annotations`` so PEP 604 union syntax
|
||||
(``dict | None``) works on the Python 3.9 baseline.
|
||||
|
||||
Two-pass approach:
|
||||
Pass 1: scan files, extract entity candidates with signal counts
|
||||
Pass 2: score and classify each candidate as person, project, or uncertain
|
||||
@@ -27,6 +30,8 @@ Usage:
|
||||
confirmed = confirm_entities(candidates) # interactive review
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import os
|
||||
import functools
|
||||
@@ -113,6 +118,23 @@ SKIP_DIRS = {
|
||||
".next",
|
||||
"coverage",
|
||||
".mempalace",
|
||||
".terraform",
|
||||
"vendor",
|
||||
"target",
|
||||
}
|
||||
|
||||
# Files whose content is boilerplate prose — poisons entity detection.
|
||||
# Matched by stem (case-insensitive), with or without an extension.
|
||||
SKIP_FILENAMES = {
|
||||
"license",
|
||||
"licence",
|
||||
"copying",
|
||||
"copyright",
|
||||
"notice",
|
||||
"authors",
|
||||
"patents",
|
||||
"third_party_notices",
|
||||
"third-party-notices",
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +215,7 @@ def _build_patterns(name: str, languages: tuple = ("en",)) -> dict:
|
||||
"person_verbs": _compile_each(sources["person_verb_patterns"]),
|
||||
"project_verbs": _compile_each(sources["project_verb_patterns"]),
|
||||
"direct": direct_compiled,
|
||||
"versioned": re.compile(rf"\b{n}[-v]\w+", re.IGNORECASE),
|
||||
"versioned": re.compile(rf"\b{n}[-_]v?\d+(?:\.\d+)*\b", re.IGNORECASE),
|
||||
"code_ref": re.compile(rf"\b{n}\.(py|js|ts|yaml|yml|json|sh)\b", re.IGNORECASE),
|
||||
}
|
||||
|
||||
@@ -227,12 +249,19 @@ def score_entity(name: str, text: str, lines: list, languages=("en",)) -> dict:
|
||||
|
||||
# --- Person signals ---
|
||||
|
||||
# Dialogue markers (strong signal)
|
||||
# Dialogue markers (strong signal).
|
||||
# The bare `^NAME:\s` colon-prefix pattern matches metadata lines like
|
||||
# `Created: 2026-04-21`, so we require >= 2 hits for it to count as dialogue
|
||||
# (real speaker markers repeat; single-line metadata doesn't).
|
||||
for rx in patterns["dialogue"]:
|
||||
matches = len(rx.findall(text))
|
||||
if matches > 0:
|
||||
person_score += matches * 3
|
||||
person_signals.append(f"dialogue marker ({matches}x)")
|
||||
if matches == 0:
|
||||
continue
|
||||
is_bare_colon = rx.pattern.endswith(r":\s") and not rx.pattern.endswith(r"[:\s]")
|
||||
if is_bare_colon and matches < 2:
|
||||
continue
|
||||
person_score += matches * 3
|
||||
person_signals.append(f"dialogue marker ({matches}x)")
|
||||
|
||||
# Person verbs
|
||||
for rx in patterns["person_verbs"]:
|
||||
@@ -328,17 +357,28 @@ def classify_entity(name: str, frequency: int, scores: dict) -> dict:
|
||||
signal_categories.add("addressed")
|
||||
|
||||
has_two_signal_types = len(signal_categories) >= 2
|
||||
_ = signal_categories - {"pronoun"} # reserved for future thresholds
|
||||
# Single-category pronoun signal still classifies as person when the
|
||||
# evidence is overwhelming — a diary's main character is referenced
|
||||
# with pronouns, not dialogue markers. Require both: many pronoun hits
|
||||
# AND a high pronoun-to-frequency ratio so common sentence-start words
|
||||
# (Never, Before, etc.) with incidental pronoun proximity don't qualify.
|
||||
pronoun_hits = 0
|
||||
for s in scores["person_signals"]:
|
||||
m = re.search(r"pronoun nearby \((\d+)x\)", s)
|
||||
if m:
|
||||
pronoun_hits = int(m.group(1))
|
||||
break
|
||||
strong_pronoun_signal = pronoun_hits >= 5 and frequency > 0 and pronoun_hits / frequency >= 0.2
|
||||
|
||||
if person_ratio >= 0.7 and has_two_signal_types and ps >= 5:
|
||||
if person_ratio >= 0.7 and (has_two_signal_types and ps >= 5 or strong_pronoun_signal):
|
||||
entity_type = "person"
|
||||
confidence = min(0.99, 0.5 + person_ratio * 0.5)
|
||||
signals = scores["person_signals"] or [f"appears {frequency}x"]
|
||||
elif person_ratio >= 0.7 and (not has_two_signal_types or ps < 5):
|
||||
# Pronoun-only match — downgrade to uncertain
|
||||
elif person_ratio >= 0.7:
|
||||
# Weak single-category person signal — downgrade to uncertain
|
||||
entity_type = "uncertain"
|
||||
confidence = 0.4
|
||||
signals = scores["person_signals"] + [f"appears {frequency}x — pronoun-only match"]
|
||||
signals = scores["person_signals"] + [f"appears {frequency}x — weak person signal"]
|
||||
elif person_ratio <= 0.3:
|
||||
entity_type = "project"
|
||||
confidence = min(0.99, 0.5 + (1 - person_ratio) * 0.5)
|
||||
@@ -361,7 +401,12 @@ def classify_entity(name: str, frequency: int, scores: dict) -> dict:
|
||||
# ==================== MAIN DETECT ====================
|
||||
|
||||
|
||||
def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) -> dict:
|
||||
def detect_entities(
|
||||
file_paths: list,
|
||||
max_files: int = 10,
|
||||
languages=("en",),
|
||||
corpus_origin: dict | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Scan files and detect entity candidates.
|
||||
|
||||
@@ -370,12 +415,24 @@ def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) ->
|
||||
max_files: Max files to read (for speed)
|
||||
languages: Tuple of language codes whose entity patterns should be
|
||||
applied (union). Defaults to ``("en",)``.
|
||||
corpus_origin: Optional corpus-origin context (the dict produced
|
||||
by ``mempalace.corpus_origin`` and persisted to
|
||||
``<palace>/.mempalace/origin.json`` by ``mempalace init``).
|
||||
When supplied and the corpus is identified as AI-dialogue with
|
||||
known agent persona names, candidates whose name matches an
|
||||
agent persona are moved out of ``people``/``uncertain`` and
|
||||
into a new ``agent_personas`` bucket. Shape:
|
||||
``{"schema_version": 1, "result": {"agent_persona_names": [...], ...}}``.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"people": [...entity dicts...],
|
||||
"projects": [...entity dicts...],
|
||||
"topics": [...entity dicts...],
|
||||
"uncertain":[...entity dicts...],
|
||||
# Only present when corpus_origin reclassifies at least one
|
||||
# candidate as an agent persona:
|
||||
"agent_personas": [...entity dicts...],
|
||||
}
|
||||
"""
|
||||
langs = _normalize_langs(languages)
|
||||
@@ -405,7 +462,10 @@ def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) ->
|
||||
candidates = extract_candidates(combined_text, languages=langs)
|
||||
|
||||
if not candidates:
|
||||
return {"people": [], "projects": [], "uncertain": []}
|
||||
return _apply_corpus_origin(
|
||||
{"people": [], "projects": [], "topics": [], "uncertain": []},
|
||||
corpus_origin,
|
||||
)
|
||||
|
||||
# Score and classify each candidate
|
||||
people = []
|
||||
@@ -428,13 +488,76 @@ def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) ->
|
||||
projects.sort(key=lambda x: x["confidence"], reverse=True)
|
||||
uncertain.sort(key=lambda x: x["frequency"], reverse=True)
|
||||
|
||||
# Cap results to most relevant
|
||||
return {
|
||||
detected = {
|
||||
"people": people[:15],
|
||||
"projects": projects[:10],
|
||||
"topics": [],
|
||||
"uncertain": uncertain[:8],
|
||||
}
|
||||
|
||||
return _apply_corpus_origin(detected, corpus_origin)
|
||||
|
||||
|
||||
def _apply_corpus_origin(detected: dict, corpus_origin: dict | None) -> dict:
|
||||
"""Reclassify per-candidate buckets using corpus-origin context.
|
||||
|
||||
When the corpus is identified as AI-dialogue with known agent persona
|
||||
names, a candidate whose name case-insensitively matches one of those
|
||||
personas is moved from ``people``/``uncertain`` into an
|
||||
``agent_personas`` bucket. The candidate's per-entity ``type`` is also
|
||||
rewritten to ``"agent_persona"``.
|
||||
|
||||
No-op when ``corpus_origin`` is ``None`` or contains no usable persona
|
||||
names. Pure: returns a new dict, does not mutate the input.
|
||||
"""
|
||||
if not corpus_origin:
|
||||
return detected
|
||||
|
||||
origin_result = corpus_origin.get("result") or {}
|
||||
raw_personas = origin_result.get("agent_persona_names") or []
|
||||
persona_lower = {n.lower() for n in raw_personas if isinstance(n, str)}
|
||||
if not persona_lower:
|
||||
return detected
|
||||
|
||||
agent_personas: list = []
|
||||
new_people: list = []
|
||||
new_uncertain: list = []
|
||||
|
||||
for entity in detected.get("people", []):
|
||||
if entity["name"].lower() in persona_lower:
|
||||
agent_personas.append(_tag_as_persona(entity))
|
||||
else:
|
||||
new_people.append(entity)
|
||||
|
||||
for entity in detected.get("uncertain", []):
|
||||
if entity["name"].lower() in persona_lower:
|
||||
agent_personas.append(_tag_as_persona(entity))
|
||||
else:
|
||||
new_uncertain.append(entity)
|
||||
|
||||
if not agent_personas:
|
||||
return detected
|
||||
|
||||
agent_personas.sort(key=lambda x: x.get("confidence", 0), reverse=True)
|
||||
|
||||
return {
|
||||
**detected,
|
||||
"people": new_people,
|
||||
"uncertain": new_uncertain,
|
||||
"agent_personas": agent_personas,
|
||||
}
|
||||
|
||||
|
||||
def _tag_as_persona(entity: dict) -> dict:
|
||||
"""Return a new entity dict tagged as agent_persona with provenance signal."""
|
||||
existing_signals = entity.get("signals", [])
|
||||
return {
|
||||
**entity,
|
||||
"type": "agent_persona",
|
||||
"confidence": max(0.95, entity.get("confidence", 0.0)),
|
||||
"signals": ["matched corpus_origin agent_persona_names"] + existing_signals[:2],
|
||||
}
|
||||
|
||||
|
||||
# ==================== INTERACTIVE CONFIRM ====================
|
||||
|
||||
@@ -454,7 +577,13 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
|
||||
"""
|
||||
Interactive confirmation step.
|
||||
User reviews detected entities, removes wrong ones, adds missing ones.
|
||||
Returns confirmed {people: [names], projects: [names]}
|
||||
Returns confirmed {people: [names], projects: [names], topics: [names]}.
|
||||
|
||||
Topics are not surfaced for interactive review — they come from the
|
||||
LLM-refined ``TOPIC`` bucket and are passed through verbatim. They
|
||||
feed cross-wing tunnel computation at mine time (see
|
||||
``palace_graph.compute_topic_tunnels``); a wrong topic at worst adds
|
||||
a low-traffic tunnel and never alters drawer storage.
|
||||
|
||||
Pass yes=True to auto-accept all detected entities without prompting.
|
||||
"""
|
||||
@@ -466,18 +595,28 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
|
||||
_print_entity_list(detected["people"], "PEOPLE")
|
||||
_print_entity_list(detected["projects"], "PROJECTS")
|
||||
|
||||
if detected.get("topics"):
|
||||
_print_entity_list(detected["topics"], "TOPICS (cross-wing tunnel signal)")
|
||||
|
||||
if detected["uncertain"]:
|
||||
_print_entity_list(detected["uncertain"], "UNCERTAIN (need your call)")
|
||||
|
||||
confirmed_people = [e["name"] for e in detected["people"]]
|
||||
confirmed_projects = [e["name"] for e in detected["projects"]]
|
||||
confirmed_topics = [e["name"] for e in detected.get("topics", [])]
|
||||
|
||||
if yes:
|
||||
# Auto-accept: include all detected (skip uncertain — ambiguous without user input)
|
||||
print(
|
||||
f"\n Auto-accepting {len(confirmed_people)} people, {len(confirmed_projects)} projects."
|
||||
f"\n Auto-accepting {len(confirmed_people)} people, "
|
||||
f"{len(confirmed_projects)} projects, "
|
||||
f"{len(confirmed_topics)} topics."
|
||||
)
|
||||
return {"people": confirmed_people, "projects": confirmed_projects}
|
||||
return {
|
||||
"people": confirmed_people,
|
||||
"projects": confirmed_projects,
|
||||
"topics": confirmed_topics,
|
||||
}
|
||||
|
||||
print(f"\n{'─' * 58}")
|
||||
print(" Options:")
|
||||
@@ -535,11 +674,14 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
|
||||
print(" Confirmed:")
|
||||
print(f" People: {', '.join(confirmed_people) or '(none)'}")
|
||||
print(f" Projects: {', '.join(confirmed_projects) or '(none)'}")
|
||||
if confirmed_topics:
|
||||
print(f" Topics: {', '.join(confirmed_topics)}")
|
||||
print(f"{'=' * 58}\n")
|
||||
|
||||
return {
|
||||
"people": confirmed_people,
|
||||
"projects": confirmed_projects,
|
||||
"topics": confirmed_topics,
|
||||
}
|
||||
|
||||
|
||||
@@ -560,6 +702,8 @@ def scan_for_detection(project_dir: str, max_files: int = 10) -> list:
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||
for filename in filenames:
|
||||
filepath = Path(root) / filename
|
||||
if filepath.stem.lower() in SKIP_FILENAMES:
|
||||
continue
|
||||
ext = filepath.suffix.lower()
|
||||
if ext in PROSE_EXTENSIONS:
|
||||
prose_files.append(filepath)
|
||||
|
||||
@@ -16,6 +16,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
@@ -320,11 +321,35 @@ class EntityRegistry:
|
||||
self._path.parent.chmod(0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
|
||||
# Atomic write: serialize to a sibling temp file in the same dir
|
||||
# (so os.replace stays on one filesystem), fsync, then rename over
|
||||
# the target. A crash mid-write leaves the previous registry intact
|
||||
# instead of a half-written file or an empty file from the truncate.
|
||||
payload = json.dumps(self._data, indent=2)
|
||||
tmp_path = self._path.with_name(self._path.name + ".tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
f.write(payload)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
try:
|
||||
self._path.chmod(0o600)
|
||||
tmp_path.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
os.replace(tmp_path, self._path)
|
||||
# On ext4 (and similar) the rename's durability across power loss
|
||||
# requires an additional fsync on the parent directory. Without it,
|
||||
# the kernel can ack the rename and a crash reverts to the state
|
||||
# where the temp file is present and the target is at the old version.
|
||||
try:
|
||||
dir_fd = os.open(str(self._path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
except OSError:
|
||||
# Windows and some special filesystems reject directory fds — they
|
||||
# have different durability semantics on rename anyway.
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _empty() -> dict:
|
||||
|
||||
+43
-2
@@ -11,6 +11,7 @@ Streams drawers in paginated batches so memory usage stays bounded
|
||||
regardless of palace size.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
@@ -26,6 +27,44 @@ def _safe_path_component(name: str) -> str:
|
||||
return name or "unknown"
|
||||
|
||||
|
||||
def _reject_symlink(path: str, label: str) -> None:
|
||||
"""Refuse to write into a path that is itself a symlink.
|
||||
|
||||
Defense-in-depth: a pre-placed symlink at the export target would
|
||||
redirect writes to wherever it points (e.g., system directories).
|
||||
Mirrors the miner's input-side caution.
|
||||
"""
|
||||
if os.path.islink(path):
|
||||
raise ValueError(
|
||||
f"refusing to export: {label} is a symbolic link ({path!r}). "
|
||||
f"Remove the symlink or choose a different output path."
|
||||
)
|
||||
|
||||
|
||||
def _safe_open_for_write(path: str, mode: str, encoding: str = "utf-8"):
|
||||
"""Open a file for writing, refusing to follow a symlink at the target path.
|
||||
|
||||
On POSIX (O_NOFOLLOW available) the open itself fails with ELOOP if path is
|
||||
a symlink — closing the TOCTOU window between an islink check and the open.
|
||||
On platforms without O_NOFOLLOW (Windows), pre-checks ``os.path.islink``,
|
||||
which is narrower than no check at all.
|
||||
"""
|
||||
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
|
||||
if o_nofollow:
|
||||
flags = os.O_WRONLY | os.O_CREAT | o_nofollow
|
||||
flags |= os.O_APPEND if "a" in mode else os.O_TRUNC
|
||||
try:
|
||||
fd = os.open(path, flags, 0o600)
|
||||
except OSError as e:
|
||||
if e.errno == errno.ELOOP:
|
||||
raise ValueError(f"refusing to write: {path!r} is a symbolic link.") from None
|
||||
raise
|
||||
return os.fdopen(fd, mode, encoding=encoding)
|
||||
if os.path.islink(path):
|
||||
raise ValueError(f"refusing to write: {path!r} is a symbolic link.")
|
||||
return open(path, mode, encoding=encoding)
|
||||
|
||||
|
||||
def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -> dict:
|
||||
"""Export all palace drawers as markdown files organized by wing/room.
|
||||
|
||||
@@ -48,6 +87,7 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
|
||||
print(" Palace is empty — nothing to export.")
|
||||
return {"wings": 0, "rooms": 0, "drawers": 0}
|
||||
|
||||
_reject_symlink(output_dir, "output_dir")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
try:
|
||||
os.chmod(output_dir, 0o700)
|
||||
@@ -89,6 +129,7 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
|
||||
safe_wing = _safe_path_component(wing)
|
||||
wing_dir = os.path.join(output_dir, safe_wing)
|
||||
if wing_dir not in created_wing_dirs:
|
||||
_reject_symlink(wing_dir, f"wing directory {safe_wing!r}")
|
||||
os.makedirs(wing_dir, exist_ok=True)
|
||||
try:
|
||||
os.chmod(wing_dir, 0o700)
|
||||
@@ -102,7 +143,7 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
|
||||
key = (wing, room)
|
||||
is_new = key not in opened_rooms
|
||||
|
||||
with open(room_path, "a" if not is_new else "w", encoding="utf-8") as f:
|
||||
with _safe_open_for_write(room_path, "a" if not is_new else "w") as f:
|
||||
if is_new:
|
||||
f.write(f"# {wing} / {room}\n\n")
|
||||
opened_rooms.add(key)
|
||||
@@ -152,7 +193,7 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
|
||||
index_lines.append("")
|
||||
|
||||
index_path = os.path.join(output_dir, "index.md")
|
||||
with open(index_path, "w", encoding="utf-8") as f:
|
||||
with _safe_open_for_write(index_path, "w") as f:
|
||||
f.write("\n".join(index_lines))
|
||||
|
||||
stats = {
|
||||
|
||||
@@ -27,6 +27,7 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
@@ -35,6 +36,8 @@ from datetime import datetime, timezone
|
||||
# ~/.mempalace/known_entities.json on every check_text call.
|
||||
from .miner import _load_known_entities_raw
|
||||
|
||||
logger = logging.getLogger("mempalace_mcp")
|
||||
|
||||
|
||||
# Narrow detection patterns — parse "X is Y's Z" and "X's Z is Y".
|
||||
# Names are captured greedily as word sequences (letters + optional
|
||||
@@ -214,6 +217,7 @@ def _check_kg_contradictions(text: str, palace_path: str) -> list:
|
||||
try:
|
||||
facts = kg.query_entity(subject, direction="outgoing")
|
||||
except Exception:
|
||||
logger.debug("KG lookup failed for subject %r", subject, exc_info=True)
|
||||
continue
|
||||
if not facts:
|
||||
continue
|
||||
@@ -303,11 +307,27 @@ def _edit_distance(s1: str, s2: str) -> int:
|
||||
return prev[-1]
|
||||
|
||||
|
||||
def _reconfigure_stdio_utf8_on_windows():
|
||||
"""Decode --stdin payload as UTF-8 on Windows.
|
||||
|
||||
Thin wrapper around the shared helper in ``mempalace._stdio``. Mirrors
|
||||
the primary CLI policy: stdout/stderr use ``replace`` because
|
||||
extracted fact text can include surrogate halves round-tripped from
|
||||
filenames -- ``strict`` would raise UnicodeEncodeError mid-print.
|
||||
stdin keeps the default ``surrogateescape``.
|
||||
"""
|
||||
from ._stdio import reconfigure_stdio_utf8_on_windows
|
||||
|
||||
reconfigure_stdio_utf8_on_windows(stdout_errors="replace", stderr_errors="replace")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
_reconfigure_stdio_utf8_on_windows()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check text against known facts in the MemPalace palace.",
|
||||
epilog="Exits 0 when no issues found, 1 when one or more issues detected.",
|
||||
|
||||
+638
-59
@@ -6,6 +6,7 @@ Supported hooks: session-start, stop, precompact
|
||||
Supported harnesses: claude-code, codex (extensible to cursor, gemini, etc.)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -13,26 +14,97 @@ import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
SAVE_INTERVAL = 15
|
||||
STATE_DIR = Path.home() / ".mempalace" / "hook_state"
|
||||
PALACE_ROOT = Path.home() / ".mempalace"
|
||||
|
||||
|
||||
def _detached_popen_kwargs() -> dict:
|
||||
"""Kwargs that fully detach a Popen child so the hook process can exit.
|
||||
|
||||
Without these, Windows holds the parent open until the child closes the
|
||||
inherited stdout/stderr handles — manifesting as "Stop hook hangs" at
|
||||
session end (#1268). On POSIX the parent can already exit (orphan
|
||||
reparents to init), but ``start_new_session`` makes the boundary
|
||||
explicit so signals to the hook don't propagate to the background mine.
|
||||
"""
|
||||
kwargs: dict = {"stdin": subprocess.DEVNULL, "close_fds": True}
|
||||
if os.name == "nt":
|
||||
flags = 0
|
||||
for name in ("DETACHED_PROCESS", "CREATE_NEW_PROCESS_GROUP", "CREATE_BREAKAWAY_FROM_JOB"):
|
||||
flags |= getattr(subprocess, name, 0)
|
||||
if flags:
|
||||
kwargs["creationflags"] = flags
|
||||
else:
|
||||
kwargs["start_new_session"] = True
|
||||
return kwargs
|
||||
|
||||
|
||||
def _palace_root_exists() -> bool:
|
||||
"""User-removable kill-switch.
|
||||
|
||||
If ~/.mempalace/ does not exist, the user has explicitly cleared it.
|
||||
All hook side effects (logging, state dir creation, mining, ingestion)
|
||||
must respect this and short-circuit BEFORE touching disk — including
|
||||
before logging the short-circuit itself.
|
||||
|
||||
Uses ``is_dir()`` rather than ``exists()`` so a stray regular file at
|
||||
``~/.mempalace`` (or a broken symlink) is treated as absent — otherwise
|
||||
the kill-switch would be bypassed and ``STATE_DIR.mkdir()`` would later
|
||||
crash on ``NotADirectoryError``.
|
||||
"""
|
||||
return PALACE_ROOT.is_dir()
|
||||
|
||||
|
||||
def _mempalace_python() -> str:
|
||||
"""Return the python interpreter that has mempalace installed.
|
||||
|
||||
When hooks are invoked by Claude Code, sys.executable may be the system
|
||||
python which lacks chromadb and other deps. Resolution order:
|
||||
1. MEMPALACE_PYTHON env var (explicit override)
|
||||
2. Venv python from package install path
|
||||
3. Editable install: venv/ sibling to mempalace/
|
||||
4. sys.executable fallback
|
||||
"""
|
||||
# Honor explicit override (used by shell hook wrappers)
|
||||
env_python = os.environ.get("MEMPALACE_PYTHON", "")
|
||||
if env_python and os.path.isfile(env_python) and os.access(env_python, os.X_OK):
|
||||
return env_python
|
||||
# This file lives at <venv>/lib/pythonX.Y/site-packages/mempalace/hooks_cli.py
|
||||
# or <project>/mempalace/hooks_cli.py (editable install).
|
||||
venv_bin = Path(__file__).resolve().parents[3] / "bin" / "python"
|
||||
if venv_bin.is_file():
|
||||
return str(venv_bin)
|
||||
# Editable install: assumes project root has a venv/ sibling to mempalace/
|
||||
project_venv = Path(__file__).resolve().parents[1] / "venv" / "bin" / "python"
|
||||
if project_venv.is_file():
|
||||
return str(project_venv)
|
||||
return sys.executable
|
||||
|
||||
|
||||
_RECENT_MSG_COUNT = 30 # how many recent user messages to summarize
|
||||
|
||||
STOP_BLOCK_REASON = (
|
||||
"AUTO-SAVE checkpoint (MemPalace). Save this session's key content:\n"
|
||||
"1. mempalace_diary_write — AAAK-compressed session summary\n"
|
||||
"2. mempalace_add_drawer — verbatim quotes, decisions, code snippets\n"
|
||||
"1. mempalace_diary_write — session summary (what was discussed, "
|
||||
"key decisions, current state of work)\n"
|
||||
"2. mempalace_add_drawer — verbatim quotes, decisions, code snippets "
|
||||
"(place in appropriate wing and room)\n"
|
||||
"3. mempalace_kg_add — entity relationships (optional)\n"
|
||||
"Do NOT write to Claude Code's native auto-memory (.md files). "
|
||||
"Continue conversation after saving."
|
||||
"For THIS save, use MemPalace MCP tools only (not auto-memory .md files). "
|
||||
"Use verbatim quotes where possible. Continue conversation after saving."
|
||||
)
|
||||
|
||||
PRECOMPACT_BLOCK_REASON = (
|
||||
"COMPACTION IMMINENT (MemPalace). Save ALL session content before context is lost:\n"
|
||||
"1. mempalace_diary_write — thorough AAAK-compressed session summary\n"
|
||||
"2. mempalace_add_drawer — ALL verbatim quotes, decisions, code, context\n"
|
||||
"1. mempalace_diary_write — thorough session summary\n"
|
||||
"2. mempalace_add_drawer — ALL verbatim quotes, decisions, code, context "
|
||||
"(place each in appropriate wing and room)\n"
|
||||
"3. mempalace_kg_add — entity relationships (optional)\n"
|
||||
"Be thorough \u2014 after compaction, detailed context will be lost. "
|
||||
"Do NOT write to Claude Code's native auto-memory (.md files). "
|
||||
"For THIS save, use MemPalace MCP tools only (not auto-memory .md files). "
|
||||
"Be thorough — after compaction this is all that survives. "
|
||||
"Save everything to MemPalace, then allow compaction to proceed."
|
||||
)
|
||||
|
||||
@@ -110,6 +182,8 @@ _state_dir_initialized = False
|
||||
|
||||
def _log(message: str):
|
||||
"""Append to hook state log file."""
|
||||
if not _palace_root_exists():
|
||||
return # User removed the palace; do not recreate by logging
|
||||
global _state_dir_initialized
|
||||
try:
|
||||
if not _state_dir_initialized:
|
||||
@@ -134,56 +208,450 @@ def _log(message: str):
|
||||
|
||||
|
||||
def _output(data: dict):
|
||||
"""Print JSON to stdout with consistent formatting (pretty-printed)."""
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
"""Print JSON to stdout without importing modules that may redirect streams.
|
||||
|
||||
If mempalace.mcp_server is already loaded, reuse its saved real stdout fd.
|
||||
Otherwise, write directly to fd 1 so hook responses still go to stdout even
|
||||
if sys.stdout has been redirected elsewhere.
|
||||
"""
|
||||
payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
def _get_mine_dir(transcript_path: str = "") -> str:
|
||||
"""Determine directory to mine from MEMPAL_DIR or transcript path."""
|
||||
mempal_dir = os.environ.get("MEMPAL_DIR", "")
|
||||
if mempal_dir and os.path.isdir(mempal_dir):
|
||||
return mempal_dir
|
||||
if transcript_path:
|
||||
path = Path(transcript_path).expanduser()
|
||||
if path.is_file():
|
||||
return str(path.parent)
|
||||
return ""
|
||||
real_stdout_fd: int | None = None
|
||||
mcp_mod = sys.modules.get("mempalace.mcp_server") or sys.modules.get(
|
||||
f"{__package__}.mcp_server" if __package__ else "mcp_server"
|
||||
)
|
||||
if mcp_mod is not None:
|
||||
real_stdout_fd = getattr(mcp_mod, "_REAL_STDOUT_FD", None)
|
||||
|
||||
|
||||
def _maybe_auto_ingest(transcript_path: str = ""):
|
||||
"""Run mempalace mine in background if a mine directory is available."""
|
||||
mine_dir = _get_mine_dir(transcript_path)
|
||||
if not mine_dir:
|
||||
return
|
||||
fd = real_stdout_fd if real_stdout_fd is not None else 1
|
||||
offset = 0
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-m", "mempalace", "mine", mine_dir],
|
||||
while offset < len(payload):
|
||||
try:
|
||||
offset += os.write(fd, payload[offset:])
|
||||
except InterruptedError:
|
||||
continue
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
sys.stdout.buffer.write(payload)
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
def _get_mine_targets() -> list[tuple[str, str]]:
|
||||
"""Return the list of ``(dir, mode)`` targets for auto-ingest.
|
||||
|
||||
MEMPAL_DIR (when set and resolvable) contributes a ``"projects"``
|
||||
target. Transcript ingestion is handled separately by
|
||||
``_ingest_transcript`` — emitting it here too would double-mine the
|
||||
same JSONL into a different wing on every hook fire (#1231 review).
|
||||
|
||||
An empty list means no MEMPAL_DIR ingest should run.
|
||||
"""
|
||||
targets: list[tuple[str, str]] = []
|
||||
mempal_dir = os.environ.get("MEMPAL_DIR", "")
|
||||
if mempal_dir:
|
||||
resolved = Path(mempal_dir).expanduser().resolve()
|
||||
if resolved.is_dir():
|
||||
targets.append((str(resolved), "projects"))
|
||||
return targets
|
||||
|
||||
|
||||
# Per-target PID guard.
|
||||
#
|
||||
# Hook fires ingest mines in the background. If a previous fire's child is
|
||||
# still running for the *same* target (same source dir, mode, wing), the new
|
||||
# fire should skip rather than pile up — multiple concurrent mines against the
|
||||
# same source corrupt the HNSW index and exhaust disk via duplicate upserts
|
||||
# (#1212, #1206). But mines targeting *different* sources / modes must remain
|
||||
# independent so the user can have e.g. project-mining and transcript-ingest
|
||||
# running in parallel.
|
||||
#
|
||||
# The single ``mine.pid`` global file used previously failed both ways: the
|
||||
# guard was rebuilt every spawn (so two near-simultaneous fires both passed
|
||||
# the check before either wrote), and the file was unconditionally overwritten
|
||||
# (so the second spawn lost the first PID, orphaning it). The replacement is
|
||||
# a directory of per-target slots, claimed via ``O_CREAT | O_EXCL`` so the
|
||||
# claim is atomic and per-target.
|
||||
_MINE_PID_DIR = STATE_DIR / "mine_pids"
|
||||
|
||||
# The per-process PID file path is communicated to the mine subprocess via
|
||||
# this env var so the child's cleanup hook (in miner.py) can remove its
|
||||
# own slot on exit without scanning the whole directory.
|
||||
_MINE_PID_FILE_ENV = "MEMPALACE_MINE_PID_FILE"
|
||||
|
||||
|
||||
def _pid_file_for_cmd(cmd: list[str]) -> Path:
|
||||
"""Return the per-target PID file path for a mine subcommand.
|
||||
|
||||
The key is derived from the mine arguments (everything after ``mine``)
|
||||
so different (dir, mode, wing) combinations get independent slots.
|
||||
Two fires with the same arguments collapse to the same slot — which is
|
||||
exactly the dedup we want.
|
||||
"""
|
||||
try:
|
||||
idx = cmd.index("mine")
|
||||
key = " ".join(cmd[idx:])
|
||||
except ValueError:
|
||||
key = " ".join(cmd)
|
||||
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
|
||||
return _MINE_PID_DIR / f"mine_{digest}.pid"
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Cross-platform existence check for a PID.
|
||||
|
||||
On POSIX, ``os.kill(pid, 0)`` is the well-known no-op existence probe.
|
||||
On Windows, ``os.kill`` maps to ``TerminateProcess(handle, sig)`` and
|
||||
would *terminate* the target process with exit code ``sig`` — using
|
||||
it here would kill our own mine child (or worse, the caller itself).
|
||||
Use ``OpenProcess`` + ``GetExitCodeProcess`` via ctypes instead.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
||||
return False
|
||||
return code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _mine_already_running(cmd: list[str]) -> bool:
|
||||
"""Return True if a previous mine for ``cmd``'s target is still alive."""
|
||||
pid_file = _pid_file_for_cmd(cmd)
|
||||
try:
|
||||
recorded = pid_file.read_text().strip()
|
||||
except OSError:
|
||||
return False
|
||||
if not recorded.isdigit():
|
||||
return False
|
||||
return _pid_alive(int(recorded))
|
||||
|
||||
|
||||
def _claim_mine_slot(cmd: list[str]) -> Optional[Path]:
|
||||
"""Atomically reserve the per-target PID slot for ``cmd``.
|
||||
|
||||
Returns the slot path on success, or ``None`` if the target is
|
||||
already being mined by a live process. The reservation is done via
|
||||
``O_CREAT | O_EXCL`` so two simultaneous hook fires can never both
|
||||
pass the check; one wins, the other returns None.
|
||||
|
||||
A stale slot (file exists but the recorded PID is dead) is reclaimed
|
||||
transparently — orphan miners that crashed without cleanup do not
|
||||
block future hook fires forever.
|
||||
"""
|
||||
pid_file = _pid_file_for_cmd(cmd)
|
||||
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
fd = os.open(str(pid_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
os.close(fd)
|
||||
return pid_file
|
||||
except FileExistsError:
|
||||
pass
|
||||
# Slot exists. If the holder is alive, defer.
|
||||
if _mine_already_running(cmd):
|
||||
return None
|
||||
# Stale entry; reclaim. The unlink+create is racy against another hook
|
||||
# firing right now, but the second create's O_EXCL will fail and that
|
||||
# caller will see the live PID via the next round.
|
||||
try:
|
||||
pid_file.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
fd = os.open(str(pid_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
os.close(fd)
|
||||
return pid_file
|
||||
except FileExistsError:
|
||||
return None
|
||||
|
||||
|
||||
def _spawn_mine(cmd: list) -> None:
|
||||
"""Spawn a mine subprocess if no live mine is already targeting it.
|
||||
|
||||
The PID slot is claimed atomically *before* the spawn, so two near-
|
||||
simultaneous hook fires can't both proceed — the second sees the
|
||||
claimed slot and silently skips. The spawned process inherits a
|
||||
``MEMPALACE_MINE_PID_FILE`` env var so its cleanup hook can remove
|
||||
the slot on exit without scanning the directory.
|
||||
"""
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
pid_file = _claim_mine_slot(cmd)
|
||||
if pid_file is None:
|
||||
_log(f"Skipping mine: target already running ({' '.join(cmd[-3:])})")
|
||||
return
|
||||
child_env = os.environ.copy()
|
||||
child_env[_MINE_PID_FILE_ENV] = str(pid_file)
|
||||
with open(log_path, "a") as log_f:
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
env=child_env,
|
||||
**_detached_popen_kwargs(),
|
||||
)
|
||||
except OSError:
|
||||
# Spawn failed; release the slot we just claimed so the next
|
||||
# hook fire can try again rather than skipping forever.
|
||||
try:
|
||||
pid_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
try:
|
||||
pid_file.write_text(str(proc.pid))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _mine_sync(transcript_path: str = ""):
|
||||
"""Run mempalace mine synchronously (for precompact -- data must land first)."""
|
||||
mine_dir = _get_mine_dir(transcript_path)
|
||||
if not mine_dir:
|
||||
def _maybe_auto_ingest():
|
||||
"""Background-mine MEMPAL_DIR (project files) if set.
|
||||
|
||||
Transcript convos are ingested separately via ``_ingest_transcript``
|
||||
in the hook handlers — this function does not handle them, to avoid
|
||||
asymmetric interpreter handling and PID-file overwrite when both
|
||||
targets fire from a single hook call (#1231 review).
|
||||
|
||||
Per-target dedup is done by ``_spawn_mine`` itself: each (dir, mode)
|
||||
target gets its own PID slot, so distinct targets never block each
|
||||
other but a re-fire of the same target while the previous one is
|
||||
still running is silently skipped.
|
||||
"""
|
||||
targets = _get_mine_targets()
|
||||
if not targets:
|
||||
return
|
||||
for mine_dir, mode in targets:
|
||||
try:
|
||||
_spawn_mine([_mempalace_python(), "-m", "mempalace", "mine", mine_dir, "--mode", mode])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _mine_sync():
|
||||
"""Synchronously mine MEMPAL_DIR (precompact path).
|
||||
|
||||
Transcript convos are ingested separately via ``_ingest_transcript``
|
||||
in ``hook_precompact`` — keeping them out of this function avoids
|
||||
timeout stacking against the harness 30s ceiling (#1231 review).
|
||||
"""
|
||||
targets = _get_mine_targets()
|
||||
if not targets:
|
||||
return
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
for mine_dir, mode in targets:
|
||||
try:
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.run(
|
||||
[
|
||||
_mempalace_python(),
|
||||
"-m",
|
||||
"mempalace",
|
||||
"mine",
|
||||
mine_dir,
|
||||
"--mode",
|
||||
mode,
|
||||
],
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
|
||||
def _desktop_toast(body: str, title: str = "MemPalace"):
|
||||
"""Send a desktop notification via notify-send. Fails silently."""
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "mempalace", "mine", mine_dir],
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
subprocess.Popen(
|
||||
["notify-send", "--app-name=MemPalace", "--icon=brain", title, body],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**_detached_popen_kwargs(),
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _extract_recent_messages(transcript_path: str, count: int = _RECENT_MSG_COUNT) -> list[str]:
|
||||
"""Extract the last N user messages from a JSONL transcript."""
|
||||
path = Path(transcript_path).expanduser()
|
||||
if not path.is_file():
|
||||
return []
|
||||
messages = []
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
# Claude Code format
|
||||
msg = entry.get("message") or entry.get("event_message") or {}
|
||||
if isinstance(msg, dict) and msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
b.get("text", "") for b in content if isinstance(b, dict)
|
||||
)
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
continue
|
||||
if "<command-message>" in content or "<system-reminder>" in content:
|
||||
continue
|
||||
messages.append(content.strip()[:200])
|
||||
# Codex CLI format
|
||||
elif entry.get("type") == "event_msg":
|
||||
payload = entry.get("payload", {})
|
||||
if isinstance(payload, dict) and payload.get("type") == "user_message":
|
||||
text = payload.get("message", "")
|
||||
if isinstance(text, str) and text.strip():
|
||||
if "<command-message>" not in text:
|
||||
messages.append(text.strip()[:200])
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
except OSError:
|
||||
return []
|
||||
return messages[-count:]
|
||||
|
||||
|
||||
_THEME_STOPWORDS = frozenset(
|
||||
"the a an and or but in on at to for of is it i me my you your we our "
|
||||
"this that with from by was were be been are not no yes can do did dont "
|
||||
"will would should could have has had lets let just also like so if then "
|
||||
"ok okay sure yeah hey hi here there what when where how why which some "
|
||||
"all any each every about into out up down over after before between "
|
||||
"get got make made need want use used using check look see run try "
|
||||
"know think right now still already really very much more most too "
|
||||
"file files code one two new first last next thing things way well".split()
|
||||
)
|
||||
|
||||
|
||||
def _extract_themes(messages: list[str], max_themes: int = 3) -> list[str]:
|
||||
"""Pull 2-3 distinctive topic words from recent messages.
|
||||
|
||||
Note: stopword list is English-only; non-English corpora will produce noisy themes.
|
||||
"""
|
||||
from collections import Counter
|
||||
|
||||
words: Counter[str] = Counter()
|
||||
for msg in messages:
|
||||
for word in msg.lower().split():
|
||||
# Strip punctuation, keep words 4+ chars
|
||||
clean = word.strip(".,;:!?\"'`()[]{}#<>/\\-_=+@$%^&*~")
|
||||
if len(clean) >= 4 and clean not in _THEME_STOPWORDS and clean.isalpha():
|
||||
words[clean] += 1
|
||||
return [w for w, _ in words.most_common(max_themes)]
|
||||
|
||||
|
||||
def _save_diary_direct(
|
||||
transcript_path: str,
|
||||
session_id: str,
|
||||
wing: str = "",
|
||||
toast: bool = False,
|
||||
) -> dict:
|
||||
"""Write a diary checkpoint by calling the tool function directly (no MCP roundtrip).
|
||||
|
||||
If `wing` is set, the entry lands in that wing (typically the project wing
|
||||
derived from the transcript path). Otherwise falls back to `tool_diary_write`'s
|
||||
default of `wing_session-hook`.
|
||||
|
||||
Returns {"count": N, "themes": [...]} on success, {"count": 0} on failure.
|
||||
"""
|
||||
messages = _extract_recent_messages(transcript_path)
|
||||
if not messages:
|
||||
_log("No recent messages to save")
|
||||
return {"count": 0}
|
||||
|
||||
themes = _extract_themes(messages)
|
||||
|
||||
# Build a compressed diary entry from recent conversation
|
||||
now = datetime.now()
|
||||
topics = "|".join(m[:80] for m in messages[-10:])
|
||||
entry = (
|
||||
f"CHECKPOINT:{now.strftime('%Y-%m-%d')}|session:{session_id}"
|
||||
f"|msgs:{len(messages)}|recent:{topics}"
|
||||
)
|
||||
|
||||
try:
|
||||
from .mcp_server import tool_diary_write
|
||||
|
||||
result = tool_diary_write(
|
||||
agent_name="session-hook",
|
||||
entry=entry,
|
||||
topic="checkpoint",
|
||||
wing=wing,
|
||||
)
|
||||
if result.get("success"):
|
||||
_log(f"Diary checkpoint saved: {result.get('entry_id', '?')}")
|
||||
# Write state for ack tool to read
|
||||
try:
|
||||
ack_file = STATE_DIR / "last_checkpoint"
|
||||
ack_file.write_text(
|
||||
json.dumps({"msgs": len(messages), "ts": now.isoformat()}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
if toast:
|
||||
_desktop_toast(f"Checkpoint saved \u2014 {len(messages)} messages archived")
|
||||
return {"count": len(messages), "themes": themes}
|
||||
else:
|
||||
_log(f"Diary checkpoint failed: {result.get('error', 'unknown')}")
|
||||
except Exception as e:
|
||||
_log(f"Diary checkpoint error: {e}")
|
||||
return {"count": 0}
|
||||
|
||||
|
||||
def _ingest_transcript(transcript_path: str):
|
||||
"""Mine a Claude Code session transcript into the palace as a conversation."""
|
||||
path = Path(transcript_path).expanduser()
|
||||
if not path.is_file() or path.stat().st_size < 100:
|
||||
return
|
||||
|
||||
from .config import MempalaceConfig
|
||||
|
||||
try:
|
||||
MempalaceConfig() # validate config loads
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
# Route through ``_spawn_mine`` so the per-target PID guard kicks
|
||||
# in here too — repeated Stop/PreCompact fires for the same
|
||||
# transcript should not stack up parallel ingest mines.
|
||||
_spawn_mine(
|
||||
[
|
||||
_mempalace_python(),
|
||||
"-m",
|
||||
"mempalace",
|
||||
"mine",
|
||||
str(path.parent),
|
||||
"--mode",
|
||||
"convos",
|
||||
"--wing",
|
||||
"sessions",
|
||||
]
|
||||
)
|
||||
_log(f"Transcript ingest started: {path.name}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -202,17 +670,72 @@ def _parse_harness_input(data: dict, harness: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _wing_from_transcript_path(transcript_path: str) -> str:
|
||||
"""Derive a project wing name from a Claude Code transcript path.
|
||||
|
||||
Claude Code encodes the project's source directory by replacing path
|
||||
separators with dashes, producing folders like:
|
||||
~/.claude/projects/-home-<user>-Projects-<project>/session.jsonl
|
||||
~/.claude/projects/-home-<user>-dev-<parent>-<project>/session.jsonl
|
||||
~/.claude/projects/-Users-<user>-<folder>-<project>/session.jsonl
|
||||
|
||||
The project directory name is the final dash-separated token of the
|
||||
encoded folder. Returns ``wing_<project>`` (lowercased, spaces → ``_``).
|
||||
Falls back to ``wing_sessions`` if the path does not match a Claude Code
|
||||
project-folder layout.
|
||||
"""
|
||||
# Normalize path separators for cross-platform (Windows backslashes)
|
||||
normalized = transcript_path.replace("\\", "/")
|
||||
# Primary: pull the encoded project folder out of ``.claude/projects/``
|
||||
# and take its last dash-separated token.
|
||||
match = re.search(r"/\.claude/projects/-([^/]+)", normalized)
|
||||
if match:
|
||||
encoded = match.group(1)
|
||||
project = encoded.rsplit("-", 1)[-1]
|
||||
if project:
|
||||
return f"wing_{project.lower().replace(' ', '_')}"
|
||||
# Legacy fallback: explicit ``-Projects-<name>`` segment, useful for
|
||||
# transcripts not under the standard Claude Code projects dir.
|
||||
match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized)
|
||||
if match:
|
||||
project = match.group(1).lower().replace(" ", "_")
|
||||
return f"wing_{project}"
|
||||
return "wing_sessions"
|
||||
|
||||
|
||||
def hook_stop(data: dict, harness: str):
|
||||
"""Stop hook: block every N messages for auto-save."""
|
||||
if not _palace_root_exists():
|
||||
_output({})
|
||||
return
|
||||
parsed = _parse_harness_input(data, harness)
|
||||
session_id = parsed["session_id"]
|
||||
stop_hook_active = parsed["stop_hook_active"]
|
||||
transcript_path = parsed["transcript_path"]
|
||||
|
||||
# If already in a save cycle, let through (infinite-loop prevention)
|
||||
# If already in a block-mode save cycle, let through (infinite-loop prevention).
|
||||
# Silent mode saves directly without returning {"decision":"block"}, so there's
|
||||
# no loop to prevent — and Claude Code's plugin dispatch sets this flag on every
|
||||
# fire after the first, which would otherwise suppress all subsequent auto-saves.
|
||||
if str(stop_hook_active).lower() in ("true", "1", "yes"):
|
||||
_output({})
|
||||
return
|
||||
# Safe default: assume silent mode on any config-read failure so saves
|
||||
# proceed rather than being silently dropped. Silent mode is the default
|
||||
# (v3.3.0+), so if we can't read config, behave as if it's still on.
|
||||
silent_guard = True
|
||||
try:
|
||||
from .config import MempalaceConfig
|
||||
except ImportError as exc:
|
||||
_log(
|
||||
f"WARNING: could not import MempalaceConfig for stop guard: {exc}; defaulting to silent mode"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
silent_guard = MempalaceConfig().hook_silent_save
|
||||
except AttributeError as exc:
|
||||
_log(f"WARNING: could not read hook_silent_save: {exc}; defaulting to silent mode")
|
||||
if not silent_guard:
|
||||
_output({})
|
||||
return
|
||||
|
||||
# Count human messages
|
||||
exchange_count = _count_human_messages(transcript_path)
|
||||
@@ -232,24 +755,71 @@ def hook_stop(data: dict, harness: str):
|
||||
_log(f"Session {session_id}: {exchange_count} exchanges, {since_last} since last save")
|
||||
|
||||
if since_last >= SAVE_INTERVAL and exchange_count > 0:
|
||||
# Update last save point
|
||||
try:
|
||||
last_save_file.write_text(str(exchange_count), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_log(f"TRIGGERING SAVE at exchange {exchange_count}")
|
||||
|
||||
# Optional: auto-ingest if MEMPAL_DIR is set
|
||||
_maybe_auto_ingest(transcript_path)
|
||||
# Read hook settings from config
|
||||
from .config import MempalaceConfig
|
||||
|
||||
_output({"decision": "block", "reason": STOP_BLOCK_REASON})
|
||||
try:
|
||||
config = MempalaceConfig()
|
||||
silent = config.hook_silent_save
|
||||
toast = config.hook_desktop_toast
|
||||
except Exception:
|
||||
silent = True
|
||||
toast = False
|
||||
|
||||
project_wing = _wing_from_transcript_path(transcript_path)
|
||||
|
||||
if silent:
|
||||
# Save directly via Python API — systemMessage renders in terminal
|
||||
result = {"count": 0}
|
||||
if transcript_path:
|
||||
result = _save_diary_direct(
|
||||
transcript_path, session_id, wing=project_wing, toast=toast
|
||||
)
|
||||
_ingest_transcript(transcript_path)
|
||||
_maybe_auto_ingest()
|
||||
# Only advance save marker after successful save
|
||||
count = result.get("count", 0)
|
||||
if count > 0:
|
||||
try:
|
||||
last_save_file.write_text(str(exchange_count), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
themes = result.get("themes", [])
|
||||
if themes:
|
||||
tag = " \u2014 " + ", ".join(themes)
|
||||
else:
|
||||
tag = ""
|
||||
_output(
|
||||
{
|
||||
"systemMessage": f"\u2726 {count} memories woven into the palace{tag}",
|
||||
}
|
||||
)
|
||||
else:
|
||||
_output({})
|
||||
else:
|
||||
# Legacy: block and ask Claude to save via MCP tools.
|
||||
# Marker advances before confirmed save — best-effort; if Claude
|
||||
# fails to save, the checkpoint is lost but won't retry endlessly.
|
||||
try:
|
||||
last_save_file.write_text(str(exchange_count), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
if transcript_path:
|
||||
_ingest_transcript(transcript_path)
|
||||
_maybe_auto_ingest()
|
||||
reason = STOP_BLOCK_REASON + f" Write diary entry to wing={project_wing}."
|
||||
_output({"decision": "block", "reason": reason})
|
||||
else:
|
||||
_output({})
|
||||
|
||||
|
||||
def hook_session_start(data: dict, harness: str):
|
||||
"""Session start hook: initialize session tracking state."""
|
||||
if not _palace_root_exists():
|
||||
_output({})
|
||||
return
|
||||
parsed = _parse_harness_input(data, harness)
|
||||
session_id = parsed["session_id"]
|
||||
|
||||
@@ -264,14 +834,23 @@ def hook_session_start(data: dict, harness: str):
|
||||
|
||||
def hook_precompact(data: dict, harness: str):
|
||||
"""Precompact hook: mine transcript synchronously, then allow compaction."""
|
||||
if not _palace_root_exists():
|
||||
_output({})
|
||||
return
|
||||
parsed = _parse_harness_input(data, harness)
|
||||
session_id = parsed["session_id"]
|
||||
transcript_path = parsed["transcript_path"]
|
||||
|
||||
_log(f"PRE-COMPACT triggered for session {session_id}")
|
||||
|
||||
# Mine synchronously so data lands before compaction proceeds
|
||||
_mine_sync(transcript_path)
|
||||
# Capture tool output via our normalize path before compaction loses it
|
||||
if transcript_path:
|
||||
_ingest_transcript(transcript_path)
|
||||
|
||||
# Mine MEMPAL_DIR synchronously so project data lands before
|
||||
# compaction proceeds. Transcript convos were already kicked off
|
||||
# above via _ingest_transcript.
|
||||
_mine_sync()
|
||||
|
||||
_output({})
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"lang": "be",
|
||||
"label": "Беларуская",
|
||||
"terms": {
|
||||
"palace": "палац",
|
||||
"wing": "крыло",
|
||||
"hall": "зала",
|
||||
"closet": "шафа",
|
||||
"drawer": "шуфляда",
|
||||
"mine": "майнінг",
|
||||
"search": "пошук",
|
||||
"status": "статус",
|
||||
"init": "ініцыялізацыя",
|
||||
"repair": "аднаўленне",
|
||||
"migrate": "міграцыя",
|
||||
"entity": "аб'ект",
|
||||
"topic": "тэма"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Майнінг {path}...",
|
||||
"mine_complete": "Гатова. Створана шаф: {closets}, шуфляд: {drawers}.",
|
||||
"mine_skip": "Майнінг скончаны. Выкарыстайце --force, каб перамайніць.",
|
||||
"search_no_results": "Няма вынікаў для: {query}",
|
||||
"search_results": "Знойдзена {count} вынікаў:",
|
||||
"status_palace": "Палац: {path}",
|
||||
"status_wings": "Крылаў: {count}",
|
||||
"status_closets": "Шаф: {count}",
|
||||
"status_drawers": "Шуфляд: {count}",
|
||||
"init_complete": "Палац ініцыялізаваны ў {path}",
|
||||
"init_exists": "Палац ужо існуе ў {path}",
|
||||
"repair_complete": "Аднаўленне скончана. Выпраўлена праблем: {fixed}.",
|
||||
"migrate_complete": "Міграцыя завершана.",
|
||||
"no_palace": "Палац не знойдзены. Запусціце: mempalace init <дырэкторыя>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Сцісні да фармату індэкса. Злучкі паміж словамі, вертыкальныя рысы паміж паняткамі. Выдалі падставы і службовыя словы. Захавай дакладнасць імёнаў, нумароў, скланенняў і формаў."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[А-ЯЁІЎ][а-яёіў]{2,}|[A-Z][a-z]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
|
||||
"stop_words": "і а але бо каб калі калісьці хоць што хто дзе куды адкуль як чаму таму ж не ні так таксама яшчэ ўжо цяпер потым тут там адсюль туды ў на па з са без для над пад пры пра праз супраць замест акрамя сярод вакол уздоўж каля паміж пасля перад да ад у або альбо то ці быццам нібыта вось гэты гэта гэтыя той тая тыя некаторыя кожны кожная кожныя толькі вельмі можа трэба будзе быў была былі ёсць няма",
|
||||
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:зрабіў|зрабіла|зрабілі|стварыў|стварыла|стварылі|дадаў|дадала|дадалі|абнавіў|абнавіла|абнавілі|наладзіў|наладзіла|наладзілі|пратэставаў|пратэставала|пратэставалі)\\s+[\\wа-яёА-ЯЁІіЎў\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[А-ЯЁІЎ][а-яёіў]{1,19}",
|
||||
"multi_word_pattern": "[А-ЯЁІЎ][а-яёіў]+(?:\\s+[А-ЯЁІЎ][а-яёіў]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+сказа(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+спыта(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+адказа(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+расказа(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+засмея(?:ўся|лася|ліся)\\b",
|
||||
"\\b{name}\\s+усміхну(?:ўся|лася|ліся)\\b",
|
||||
"\\b{name}\\s+заплака(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+адчу(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+думае\\b",
|
||||
"\\b{name}\\s+хоча\\b",
|
||||
"\\b{name}\\s+кахае\\b",
|
||||
"\\b{name}\\s+ненавідзіць\\b",
|
||||
"\\b{name}\\s+ведае\\b",
|
||||
"\\b{name}\\s+вырашы(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+напіса(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+каза(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+зрабі(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+спытваецца\\b",
|
||||
"\\b{name}\\s+адказвае\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bён\\b",
|
||||
"\\bяго\\b",
|
||||
"\\bяму\\b",
|
||||
"\\bім\\b",
|
||||
"\\bяна\\b",
|
||||
"\\bяе\\b",
|
||||
"\\bёй\\b",
|
||||
"\\bёю\\b",
|
||||
"\\bяны\\b",
|
||||
"\\bіх\\b",
|
||||
"\\bімі\\b",
|
||||
"\\bяно\\b",
|
||||
"\\bвы\\b",
|
||||
"\\bвас\\b",
|
||||
"\\bвам\\b",
|
||||
"\\bвамі\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+сказа(?:ў|ла|лі)"
|
||||
],
|
||||
"direct_address_pattern": "\\bпрывітанне[,:!.]?\\s+{name}\\b|\\bвітаю[,:!.]?\\s+{name}\\b|\\bдзякуй[,:!.]?\\s+{name}\\b|\\bдарагі[,:!.]?\\s+{name}\\b|\\bдарагая[,:!.]?\\s+{name}\\b|\\bпаважаны[,:!.]?\\s+{name}\\b|\\bпаважаная[,:!.]?\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bзбіраю\\s+{name}\\b",
|
||||
"\\bсабраў\\s+{name}\\b",
|
||||
"\\bзапускаю\\s+{name}\\b",
|
||||
"\\bзапусціў\\s+{name}\\b",
|
||||
"\\bразгарнуў\\s+{name}\\b",
|
||||
"\\bусталяваў\\s+{name}\\b",
|
||||
"\\bсістэма\\s+{name}\\b",
|
||||
"\\bпраект\\s+{name}\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"і",
|
||||
"ў",
|
||||
"з",
|
||||
"са",
|
||||
"на",
|
||||
"па",
|
||||
"да",
|
||||
"ад",
|
||||
"у",
|
||||
"без",
|
||||
"для",
|
||||
"над",
|
||||
"пад",
|
||||
"пры",
|
||||
"пра",
|
||||
"праз",
|
||||
"супраць",
|
||||
"замест",
|
||||
"акрамя",
|
||||
"сярод",
|
||||
"вакол",
|
||||
"уздоўж",
|
||||
"каля",
|
||||
"паміж",
|
||||
"пасля",
|
||||
"перад",
|
||||
"або",
|
||||
"альбо",
|
||||
"каб",
|
||||
"калі",
|
||||
"хоць",
|
||||
"бо",
|
||||
"ж",
|
||||
"не",
|
||||
"ні",
|
||||
"так",
|
||||
"яшчэ",
|
||||
"ужо",
|
||||
"цяпер",
|
||||
"вось",
|
||||
"гэта",
|
||||
"тое",
|
||||
"тут",
|
||||
"там",
|
||||
"вельмі",
|
||||
"толькі",
|
||||
"можа",
|
||||
"трэба",
|
||||
"ёсць",
|
||||
"няма",
|
||||
"як",
|
||||
"што",
|
||||
"хто",
|
||||
"чаму",
|
||||
"таму",
|
||||
"прывітанне",
|
||||
"дзякуй",
|
||||
"ласка"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -40,5 +40,87 @@
|
||||
"stop_words": "der die das ein eine eines einer einem einen den dem des und oder aber denn weil wenn als ob auch noch schon sehr viel nur nicht mehr kann wird hat ist sind war waren sein haben wurde mit von zu für auf in an um über nach durch",
|
||||
"quote_pattern": "\\u201E([^\\u201C]{10,200})\\u201C|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:gebaut|behoben|geschrieben|hinzugefügt|gepusht|gemessen|getestet|überprüft|erstellt|gelöscht|aktualisiert|konfiguriert|bereitgestellt|migriert)\\s+[\\wÄÖÜäöüß\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-ZÄÖÜ][a-zäöüß]{1,19}",
|
||||
"multi_word_pattern": "[A-ZÄÖÜ][a-zäöüß]+(?:\\s+[A-ZÄÖÜ][a-zäöüß]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+sagte\\b",
|
||||
"\\b{name}\\s+fragte\\b",
|
||||
"\\b{name}\\s+antwortete\\b",
|
||||
"\\b{name}\\s+erzählte\\b",
|
||||
"\\b{name}\\s+lachte\\b",
|
||||
"\\b{name}\\s+lächelte\\b",
|
||||
"\\b{name}\\s+weinte\\b",
|
||||
"\\b{name}\\s+fühlte\\b",
|
||||
"\\b{name}\\s+denkt\\b",
|
||||
"\\b{name}\\s+will\\b",
|
||||
"\\b{name}\\s+liebt\\b",
|
||||
"\\b{name}\\s+hasst\\b",
|
||||
"\\b{name}\\s+weiß\\b",
|
||||
"\\b{name}\\s+entschied\\b",
|
||||
"\\b{name}\\s+schrieb\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\ber\\b",
|
||||
"\\bsie\\b",
|
||||
"\\bes\\b",
|
||||
"\\bihn\\b",
|
||||
"\\bihm\\b",
|
||||
"\\bihr\\b",
|
||||
"\\bsein\\b",
|
||||
"\\bihre\\b",
|
||||
"\\bihnen\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+sagte"
|
||||
],
|
||||
"direct_address_pattern": "\\bhallo\\s+{name}\\b|\\bhi\\s+{name}\\b|\\bhey\\s+{name}\\b|\\bdanke\\s+{name}\\b|\\bservus\\s+{name}\\b|\\blieber\\s+{name}\\b|\\bliebe\\s+{name}\\b|\\bsehr\\s+geehrter\\s+{name}\\b|\\bsehr\\s+geehrte\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bbaue\\s+{name}\\b",
|
||||
"\\bgebaut\\s+{name}\\b",
|
||||
"\\bstarte\\s+{name}\\b",
|
||||
"\\bgestartet\\s+{name}\\b",
|
||||
"\\bdeploye\\s+{name}\\b",
|
||||
"\\binstalliert\\s+{name}\\b",
|
||||
"\\bdie\\s+{name}\\s+architektur\\b",
|
||||
"\\bdie\\s+{name}\\s+pipeline\\b",
|
||||
"\\bdas\\s+{name}\\s+system\\b",
|
||||
"\\bdas\\s+{name}\\s+repository\\b",
|
||||
"\\b{name}\\s+v\\d+\\b",
|
||||
"\\b{name}\\.py\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"der", "die", "das", "ein", "eine", "eines", "einer", "einem", "einen",
|
||||
"den", "dem", "des", "und", "oder", "aber", "denn", "weil", "wenn", "als",
|
||||
"ob", "auch", "noch", "schon", "sehr", "viel", "nur", "nicht", "mehr",
|
||||
"kann", "wird", "hat", "ist", "sind", "war", "waren", "sein", "haben",
|
||||
"wurde", "worden", "werden", "mit", "von", "zu", "für", "auf", "in",
|
||||
"an", "um", "über", "nach", "durch", "bei", "aus", "seit", "vor", "zwischen",
|
||||
"ich", "du", "er", "sie", "es", "wir", "ihr", "mich", "dich", "mir", "dir",
|
||||
"uns", "euch", "mein", "dein", "unser", "euer", "ihre", "seine",
|
||||
"wer", "was", "wann", "wo", "wie", "warum", "welcher", "welche", "welches",
|
||||
"so", "dann", "jetzt", "heute", "gestern", "morgen", "hier", "dort", "da",
|
||||
"immer", "nie", "manchmal", "oft", "selten", "bald", "spät",
|
||||
"ja", "nein", "vielleicht", "gut", "schlecht", "besser", "bitte", "danke",
|
||||
"hallo", "hi", "hey", "tschüss",
|
||||
"tag", "tage", "woche", "monat", "jahr", "jahre", "zeit", "welt", "leben",
|
||||
"mensch", "menschen", "leute", "person", "ding", "dinge", "sache", "sachen",
|
||||
"teil", "art", "weise", "stelle", "platz", "ort", "zimmer", "haus", "land",
|
||||
"grund", "frage", "antwort", "fakt", "sinn", "idee", "punkt", "fall", "aspekt",
|
||||
"beispiel", "version", "nummer", "zahl", "name", "namen", "system", "modell",
|
||||
"sprache", "technologie", "gesellschaft", "kultur", "geschichte",
|
||||
"wissenschaft", "zukunft", "erinnerung", "gedächtnis",
|
||||
"datei", "ordner", "pfad", "schlüssel", "wert", "fehler", "warnung",
|
||||
"ergebnis", "eingabe", "ausgabe", "quelle", "ziel", "daten", "elemente",
|
||||
"montag", "dienstag", "mittwoch", "donnerstag", "freitag", "samstag", "sonntag",
|
||||
"januar", "februar", "märz", "april", "mai", "juni", "juli", "august",
|
||||
"september", "oktober", "november", "dezember"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -42,7 +42,7 @@
|
||||
"action_pattern": "(?:built|fixed|wrote|added|pushed|measured|tested|reviewed|created|deleted|updated|configured|deployed|migrated)\\s+[\\w\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-Z][a-z]{1,19}",
|
||||
"candidate_pattern": "[A-Z][a-z]+(?:[A-Z][a-z]+|[A-Z]{2,})+|[A-Z][a-z]{1,19}",
|
||||
"multi_word_pattern": "[A-Z][a-z]+(?:\\s+[A-Z][a-z]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+said\\b",
|
||||
@@ -140,7 +140,17 @@
|
||||
"agents", "tools", "others", "guards", "ethics", "regulation",
|
||||
"learning", "thinking", "memory", "language", "intelligence",
|
||||
"technology", "society", "culture", "future", "history", "science",
|
||||
"model", "models", "network", "networks", "training", "inference"
|
||||
"model", "models", "network", "networks", "training", "inference",
|
||||
"created", "updated", "deleted", "added", "removed", "modified",
|
||||
"extracted", "processed", "generated", "compiled", "launched", "installed",
|
||||
"deployed", "executed", "loaded", "parsed", "validated", "configured",
|
||||
"total", "summary", "covered", "included", "pending", "failed", "success",
|
||||
"ready", "active", "disabled", "enabled", "available", "completed",
|
||||
"auto", "multi", "mini", "micro", "meta", "super", "hybrid",
|
||||
"context", "bridge", "batch", "local", "global", "native", "cloud",
|
||||
"before", "after", "during", "often", "always", "never",
|
||||
"project", "contributor", "software",
|
||||
"backend", "frontend", "server", "client", "service", "app", "api"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +40,89 @@
|
||||
"stop_words": "el la los las un una unos unas de del al en con por para su sus mi mis tu tus es son está están fue ser estar haber sido como pero más muy también todo todos toda todas este esta estos estas ese esa esos esas que quien cual donde cuando porque aunque sin",
|
||||
"quote_pattern": "\"([^\"]{10,200})\"|«([^»]{10,200})»",
|
||||
"action_pattern": "(?:construido|corregido|escrito|añadido|enviado|medido|probado|revisado|creado|eliminado|actualizado|configurado|desplegado|migrado)\\s+[\\wá-ú\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]{1,19}",
|
||||
"multi_word_pattern": "[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]+(?:\\s+[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+dijo\\b",
|
||||
"\\b{name}\\s+preguntó\\b",
|
||||
"\\b{name}\\s+respondió\\b",
|
||||
"\\b{name}\\s+contó\\b",
|
||||
"\\b{name}\\s+rió\\b",
|
||||
"\\b{name}\\s+sonrió\\b",
|
||||
"\\b{name}\\s+lloró\\b",
|
||||
"\\b{name}\\s+sintió\\b",
|
||||
"\\b{name}\\s+piensa\\b",
|
||||
"\\b{name}\\s+quiere\\b",
|
||||
"\\b{name}\\s+ama\\b",
|
||||
"\\b{name}\\s+odia\\b",
|
||||
"\\b{name}\\s+sabe\\b",
|
||||
"\\b{name}\\s+decidió\\b",
|
||||
"\\b{name}\\s+escribió\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bél\\b",
|
||||
"\\bella\\b",
|
||||
"\\bellos\\b",
|
||||
"\\bellas\\b",
|
||||
"\\blo\\b",
|
||||
"\\bla\\b",
|
||||
"\\ble\\b",
|
||||
"\\bles\\b",
|
||||
"\\bse\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+dijo"
|
||||
],
|
||||
"direct_address_pattern": "\\bhola\\s+{name}\\b|\\bhey\\s+{name}\\b|\\bhi\\s+{name}\\b|\\bgracias\\s+{name}\\b|\\bquerido\\s+{name}\\b|\\bquerida\\s+{name}\\b|\\bestimado\\s+{name}\\b|\\bestimada\\s+{name}\\b|\\bdon\\s+{name}\\b|\\bdoña\\s+{name}\\b|\\bseñor\\s+{name}\\b|\\bseñora\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bconstruyo\\s+{name}\\b",
|
||||
"\\bconstruí\\s+{name}\\b",
|
||||
"\\barmé\\s+{name}\\b",
|
||||
"\\blancé\\s+{name}\\b",
|
||||
"\\bdesplegué\\s+{name}\\b",
|
||||
"\\binstalé\\s+{name}\\b",
|
||||
"\\bla\\s+arquitectura\\s+{name}\\b",
|
||||
"\\bel\\s+pipeline\\s+{name}\\b",
|
||||
"\\bel\\s+sistema\\s+{name}\\b",
|
||||
"\\bel\\s+proyecto\\s+{name}\\b",
|
||||
"\\bel\\s+repositorio\\s+{name}\\b",
|
||||
"\\b{name}\\s+v\\d+\\b",
|
||||
"\\b{name}\\.py\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"el", "la", "los", "las", "un", "una", "unos", "unas",
|
||||
"de", "del", "al", "a", "en", "con", "sin", "por", "para", "sobre",
|
||||
"entre", "hasta", "desde", "hacia", "contra", "según", "tras",
|
||||
"y", "o", "u", "ni", "pero", "sino", "aunque", "porque", "pues",
|
||||
"que", "quien", "quienes", "cual", "cuales", "cuyo", "cuya",
|
||||
"donde", "cuando", "como", "cuanto", "cuanta",
|
||||
"yo", "tú", "él", "ella", "nosotros", "vosotros", "ellos", "ellas",
|
||||
"me", "te", "se", "nos", "os", "lo", "la", "le", "los", "las", "les",
|
||||
"mi", "mis", "tu", "tus", "su", "sus", "nuestro", "nuestra", "vuestro",
|
||||
"este", "esta", "estos", "estas", "ese", "esa", "esos", "esas",
|
||||
"aquel", "aquella", "aquellos", "aquellas", "esto", "eso", "aquello",
|
||||
"ser", "estar", "haber", "tener", "hacer", "poder", "querer", "saber",
|
||||
"es", "son", "fue", "fueron", "era", "eran", "está", "están", "estaba",
|
||||
"he", "ha", "hemos", "han", "había", "hay",
|
||||
"muy", "mucho", "mucha", "muchos", "muchas", "poco", "poca", "pocos", "pocas",
|
||||
"más", "menos", "tan", "tanto", "también", "tampoco",
|
||||
"sí", "no", "quizás", "tal", "vez",
|
||||
"aquí", "allí", "allá", "ahí", "acá",
|
||||
"hoy", "ayer", "mañana", "ahora", "antes", "después", "luego", "entonces",
|
||||
"siempre", "nunca", "jamás", "todavía", "aún", "ya",
|
||||
"bien", "mal", "mejor", "peor", "bueno", "buena", "malo", "mala",
|
||||
"gracias", "hola", "adiós", "por favor", "perdón",
|
||||
"día", "días", "semana", "mes", "año", "años", "tiempo", "vez", "veces",
|
||||
"cosa", "cosas", "persona", "gente", "mundo", "vida", "casa", "lugar",
|
||||
"forma", "manera", "parte", "caso", "punto", "idea", "hecho", "razón",
|
||||
"nombre", "número", "versión", "sistema", "modelo"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +40,87 @@
|
||||
"stop_words": "le la les un une des de du au aux en et ou mais donc or ni car que qui ce cette ces son sa ses mon ma mes ton ta tes leur leurs nous vous ils elles on ne pas plus très bien aussi avec pour dans sur par est sont fait être avoir été comme tout tous toute toutes",
|
||||
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:construit|corrigé|écrit|ajouté|poussé|mesuré|testé|révisé|créé|supprimé|mis à jour|configuré|déployé|migré)\\s+[\\wà-ÿ\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-ZÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸÆŒ][a-zàâäçéèêëîïôöùûüÿæœ]{1,19}",
|
||||
"multi_word_pattern": "[A-ZÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸÆŒ][a-zàâäçéèêëîïôöùûüÿæœ]+(?:\\s+[A-ZÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸÆŒ][a-zàâäçéèêëîïôöùûüÿæœ]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+a\\s+dit\\b",
|
||||
"\\b{name}\\s+a\\s+demandé\\b",
|
||||
"\\b{name}\\s+a\\s+répondu\\b",
|
||||
"\\b{name}\\s+a\\s+raconté\\b",
|
||||
"\\b{name}\\s+a\\s+ri\\b",
|
||||
"\\b{name}\\s+a\\s+souri\\b",
|
||||
"\\b{name}\\s+a\\s+pleuré\\b",
|
||||
"\\b{name}\\s+a\\s+senti\\b",
|
||||
"\\b{name}\\s+pense\\b",
|
||||
"\\b{name}\\s+veut\\b",
|
||||
"\\b{name}\\s+aime\\b",
|
||||
"\\b{name}\\s+déteste\\b",
|
||||
"\\b{name}\\s+sait\\b",
|
||||
"\\b{name}\\s+a\\s+décidé\\b",
|
||||
"\\b{name}\\s+a\\s+écrit\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bil\\b",
|
||||
"\\belle\\b",
|
||||
"\\blui\\b",
|
||||
"\\bils\\b",
|
||||
"\\belles\\b",
|
||||
"\\bleur\\b",
|
||||
"\\bleurs\\b",
|
||||
"\\beux\\b",
|
||||
"\\bse\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+a\\s+dit"
|
||||
],
|
||||
"direct_address_pattern": "\\bbonjour\\s+{name}\\b|\\bsalut\\s+{name}\\b|\\bmerci\\s+{name}\\b|\\bcher\\s+{name}\\b|\\bchère\\s+{name}\\b|\\bmonsieur\\s+{name}\\b|\\bmadame\\s+{name}\\b|\\bhey\\s+{name}\\b|\\bhi\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bconstruit\\s+{name}\\b",
|
||||
"\\blancé\\s+{name}\\b",
|
||||
"\\bdéployé\\s+{name}\\b",
|
||||
"\\binstallé\\s+{name}\\b",
|
||||
"\\bl'architecture\\s+{name}\\b",
|
||||
"\\ble\\s+pipeline\\s+{name}\\b",
|
||||
"\\ble\\s+système\\s+{name}\\b",
|
||||
"\\ble\\s+projet\\s+{name}\\b",
|
||||
"\\ble\\s+dépôt\\s+{name}\\b",
|
||||
"\\b{name}\\s+v\\d+\\b",
|
||||
"\\b{name}\\.py\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"le", "la", "les", "un", "une", "des", "du", "de", "au", "aux",
|
||||
"en", "dans", "sur", "sous", "avec", "sans", "pour", "par", "vers",
|
||||
"chez", "entre", "depuis", "pendant", "avant", "après", "jusqu",
|
||||
"et", "ou", "mais", "donc", "or", "ni", "car", "que", "qui",
|
||||
"dont", "où", "quand", "comment", "pourquoi", "combien", "lequel",
|
||||
"ce", "cet", "cette", "ces", "celui", "celle", "ceux", "celles",
|
||||
"mon", "ma", "mes", "ton", "ta", "tes", "son", "sa", "ses",
|
||||
"notre", "nos", "votre", "vos", "leur", "leurs",
|
||||
"je", "tu", "il", "elle", "on", "nous", "vous", "ils", "elles",
|
||||
"me", "te", "se", "lui", "eux",
|
||||
"être", "avoir", "faire", "dire", "aller", "voir", "savoir", "pouvoir",
|
||||
"est", "sont", "était", "étaient", "fut", "furent", "sera", "seront",
|
||||
"ai", "as", "a", "avons", "avez", "ont", "avait", "avaient",
|
||||
"très", "bien", "mal", "peu", "beaucoup", "trop", "assez", "aussi",
|
||||
"plus", "moins", "tant", "si", "tellement",
|
||||
"oui", "non", "peut-être", "vraiment",
|
||||
"ici", "là", "là-bas", "partout", "ailleurs",
|
||||
"aujourd'hui", "hier", "demain", "maintenant", "alors", "ensuite",
|
||||
"toujours", "jamais", "souvent", "parfois", "déjà", "encore",
|
||||
"bon", "bonne", "mauvais", "mauvaise", "meilleur", "pire",
|
||||
"merci", "bonjour", "salut", "au revoir",
|
||||
"jour", "jours", "semaine", "mois", "année", "temps", "fois",
|
||||
"chose", "choses", "personne", "gens", "monde", "vie", "maison",
|
||||
"endroit", "lieu", "partie", "façon", "manière", "sorte", "type",
|
||||
"cas", "point", "idée", "fait", "raison", "nom", "nombre",
|
||||
"version", "système", "modèle", "question", "réponse"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +40,93 @@
|
||||
"stop_words": "的 了 在 是 我 有 和 就 不 人 都 一 一个 上 也 很 到 说 要 去 你 会 着 没有 看 好 自己 这 那 她 他 它 们 但是 因为 所以 如果 虽然 然后 或者 而且",
|
||||
"quote_pattern": "\\u201C([^\\u201D]{10,100})\\u201D|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(构建|修复|添加|删除|确认|创建|实现|修理|编写|测试|验证|更新|配置|启动|停止)(?:了|完成|成功)"
|
||||
},
|
||||
"entity": {
|
||||
"boundary_chars": "\\u4E00-\\u9FFF",
|
||||
"candidate_pattern": "[王李张刘陈杨赵黄周吴徐孙朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾萧田董袁潘于蒋蔡余杜叶程苏魏吕丁任沈姚卢姜崔钟谭陆汪范金石廖贾夏韦方白邹孟熊秦邱江尹薛阎段雷侯龙史陶黎贺顾毛郝龚邵万钱严武戴莫孔向汤温庞殷章葛管甘卞冉蓝殷习][\\u4E00-\\u9FFF]{1,2}",
|
||||
"person_verb_patterns": [
|
||||
"{name}说",
|
||||
"{name}问",
|
||||
"{name}答",
|
||||
"{name}表示",
|
||||
"{name}回答",
|
||||
"{name}提出",
|
||||
"{name}决定",
|
||||
"{name}认为",
|
||||
"{name}指出",
|
||||
"{name}解释",
|
||||
"{name}告诉",
|
||||
"{name}写道",
|
||||
"{name}想",
|
||||
"{name}觉得",
|
||||
"{name}知道",
|
||||
"{name}喜欢",
|
||||
"{name}讨厌",
|
||||
"{name}确认",
|
||||
"{name}提醒",
|
||||
"{name}分享",
|
||||
"{name}建议",
|
||||
"{name}同意",
|
||||
"{name}反对"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"他们",
|
||||
"她们",
|
||||
"他",
|
||||
"她",
|
||||
"它",
|
||||
"您",
|
||||
"咱"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[::\\s]",
|
||||
"^{name}[::]\\s?",
|
||||
"^\\[{name}\\]",
|
||||
"\u201C{name}[\u201D::]",
|
||||
"「{name}[」::]"
|
||||
],
|
||||
"direct_address_pattern": "嘿\\s*{name}|喂\\s*{name}|谢谢\\s*{name}|感谢\\s*{name}|哈喽\\s*{name}|亲爱的\\s*{name}",
|
||||
"project_verb_patterns": [
|
||||
"建立{name}",
|
||||
"打造{name}",
|
||||
"部署{name}",
|
||||
"启动{name}",
|
||||
"发布{name}",
|
||||
"上线{name}",
|
||||
"开发{name}",
|
||||
"维护{name}",
|
||||
"{name}系统",
|
||||
"{name}平台",
|
||||
"{name}项目",
|
||||
"{name}架构",
|
||||
"{name}管线",
|
||||
"{name}v\\d+",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"的", "了", "着", "过", "得", "地", "吗", "吧", "呢", "啊", "喔", "耶",
|
||||
"我", "你", "妳", "他", "她", "它", "您", "咱",
|
||||
"我们", "你们", "妳们", "他们", "她们", "它们", "咱们",
|
||||
"自己", "大家", "有人", "没人",
|
||||
"今天", "明天", "昨天", "前天", "后天", "今年", "明年", "去年",
|
||||
"早上", "下午", "晚上", "中午", "凌晨",
|
||||
"现在", "刚才", "刚刚", "等等", "等下", "待会",
|
||||
"最近", "以前", "之前", "之后", "以后", "后来",
|
||||
"什么", "为什么", "怎么", "怎样", "哪里", "哪个",
|
||||
"这个", "那个", "这里", "那里", "这些", "那些", "这样", "那样",
|
||||
"但是", "可是", "然后", "所以", "因为", "如果", "虽然",
|
||||
"而且", "或者", "或是", "还是", "不过", "只是", "不只",
|
||||
"既然", "不然", "否则", "此外", "另外",
|
||||
"很", "非常", "相当", "真的", "确实", "当然", "其实",
|
||||
"已经", "正在", "即将", "将要", "刚好", "恰好",
|
||||
"可能", "也许", "或许", "大概", "应该", "必须", "一定",
|
||||
"完成", "执行", "进行", "开始", "结束", "继续", "停止", "完毕",
|
||||
"没有", "有点", "有些", "一些", "许多", "很多",
|
||||
"问题", "答案", "原因", "结果", "情况", "状况",
|
||||
"主要", "重要", "基本", "简单", "复杂", "特别",
|
||||
"谢谢", "感谢", "对不起", "不好意思", "请问",
|
||||
"欢迎", "再见", "你好", "您好", "哈喽", "拜拜"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +40,93 @@
|
||||
"stop_words": "的 了 在 是 我 有 和 就 不 人 都 一 一個 上 也 很 到 說 要 去 你 會 著 沒有 看 好 自己 這 那 她 他 它 們 但是 因為 所以 如果 雖然 然後 或者 而且",
|
||||
"quote_pattern": "「([^」]{10,100})」|\u201c([^\u201d]{10,100})\u201d",
|
||||
"action_pattern": "(構建|修復|添加|刪除|確認|創建|實現|修理|編寫|測試|驗證|更新|配置|啟動|停止)(?:了|完成|成功)"
|
||||
},
|
||||
"entity": {
|
||||
"boundary_chars": "\\u4E00-\\u9FFF",
|
||||
"candidate_pattern": "[王李張劉陳楊趙黃周吳徐孫朱胡郭何高林羅鄭梁謝宋唐許韓馮鄧曹彭曾蕭田董袁潘于蔣蔡余杜葉程蘇魏呂丁任沈姚盧姜崔鍾譚陸汪范金石廖賈夏韋方白鄒孟熊秦邱江尹薛閻段雷侯龍史陶黎賀顧毛郝龔邵萬錢嚴武戴莫孔向湯溫龐殷章葛管甘卞冉藍殷習][\\u4E00-\\u9FFF]{1,2}",
|
||||
"person_verb_patterns": [
|
||||
"{name}說",
|
||||
"{name}問",
|
||||
"{name}答",
|
||||
"{name}表示",
|
||||
"{name}回答",
|
||||
"{name}提出",
|
||||
"{name}決定",
|
||||
"{name}認為",
|
||||
"{name}指出",
|
||||
"{name}解釋",
|
||||
"{name}告訴",
|
||||
"{name}寫道",
|
||||
"{name}想",
|
||||
"{name}覺得",
|
||||
"{name}知道",
|
||||
"{name}喜歡",
|
||||
"{name}討厭",
|
||||
"{name}確認",
|
||||
"{name}提醒",
|
||||
"{name}分享",
|
||||
"{name}建議",
|
||||
"{name}同意",
|
||||
"{name}反對"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"他們",
|
||||
"她們",
|
||||
"他",
|
||||
"她",
|
||||
"它",
|
||||
"您",
|
||||
"咱"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[::\\s]",
|
||||
"^{name}[::]\\s?",
|
||||
"^\\[{name}\\]",
|
||||
"「{name}[」::]",
|
||||
"『{name}[』::]"
|
||||
],
|
||||
"direct_address_pattern": "嘿\\s*{name}|喂\\s*{name}|謝謝\\s*{name}|感謝\\s*{name}|哈囉\\s*{name}|親愛的\\s*{name}",
|
||||
"project_verb_patterns": [
|
||||
"建立{name}",
|
||||
"打造{name}",
|
||||
"部署{name}",
|
||||
"啟動{name}",
|
||||
"發布{name}",
|
||||
"上線{name}",
|
||||
"開發{name}",
|
||||
"維護{name}",
|
||||
"{name}系統",
|
||||
"{name}平台",
|
||||
"{name}專案",
|
||||
"{name}架構",
|
||||
"{name}管線",
|
||||
"{name}v\\d+",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"的", "了", "著", "過", "得", "地", "嗎", "吧", "呢", "啊", "喔", "耶",
|
||||
"我", "你", "妳", "他", "她", "它", "您", "咱",
|
||||
"我們", "你們", "妳們", "他們", "她們", "它們", "咱們",
|
||||
"自己", "大家", "有人", "沒人",
|
||||
"今天", "明天", "昨天", "前天", "後天", "今年", "明年", "去年",
|
||||
"早上", "下午", "晚上", "中午", "凌晨",
|
||||
"現在", "剛才", "剛剛", "等等", "等下", "待會",
|
||||
"最近", "以前", "之前", "之後", "以後", "後來",
|
||||
"什麼", "甚麼", "為什麼", "怎麼", "怎樣", "哪裡", "哪個",
|
||||
"這個", "那個", "這裡", "那裡", "這些", "那些", "這樣", "那樣",
|
||||
"但是", "可是", "然後", "所以", "因為", "如果", "雖然",
|
||||
"而且", "或者", "或是", "還是", "不過", "只是", "不只",
|
||||
"既然", "不然", "否則", "此外", "另外",
|
||||
"很", "非常", "相當", "真的", "確實", "當然", "其實",
|
||||
"已經", "正在", "即將", "將要", "剛好", "恰好",
|
||||
"可能", "也許", "或許", "大概", "應該", "必須", "一定",
|
||||
"完成", "執行", "進行", "開始", "結束", "繼續", "停止", "完畢",
|
||||
"沒有", "有點", "有些", "一些", "許多", "很多",
|
||||
"問題", "答案", "原因", "結果", "情況", "狀況",
|
||||
"主要", "重要", "基本", "簡單", "複雜", "特別",
|
||||
"謝謝", "感謝", "對不起", "不好意思", "請問",
|
||||
"歡迎", "再見", "你好", "您好", "哈囉", "掰掰"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,27 +11,39 @@ tell the user they need Python 3.9+ installed and stop.
|
||||
|
||||
## Step 2: Check if mempalace is already installed
|
||||
|
||||
Run `pip show mempalace` to see if the package is already present. If it is,
|
||||
report the installed version and skip to Step 4.
|
||||
Run `mempalace --version`. If it succeeds, the CLI is on PATH — report
|
||||
the installed version and skip to Step 4.
|
||||
|
||||
If `mempalace --version` fails, **do not** skip to Step 4 just because
|
||||
`pip show mempalace` or `uv tool list` reports the package as installed:
|
||||
the package may live inside a venv that isn't activated, in which case
|
||||
Step 5 (`mempalace init ...`) will fail with `command not found`. Treat
|
||||
that case as not-installed and continue to Step 3, which will (re)install
|
||||
into a PATH-visible location via `uv tool install` or `pip`.
|
||||
|
||||
## Step 3: Install mempalace
|
||||
|
||||
Run `pip install mempalace`.
|
||||
Prefer [`uv`](https://docs.astral.sh/uv/) — it isolates the CLI from system
|
||||
Python and avoids most environment-related failures:
|
||||
|
||||
### Error handling -- pip failures
|
||||
1. If `uv` is on PATH (`uv --version`), run `uv tool install mempalace`.
|
||||
2. Otherwise run `pip install mempalace`.
|
||||
|
||||
If `pip install mempalace` fails, try these fallbacks in order:
|
||||
### Error handling -- install failures
|
||||
|
||||
1. Try `pip3 install mempalace`
|
||||
2. Try `python -m pip install mempalace` (or `python3 -m pip install mempalace`)
|
||||
3. If the error mentions missing build tools or compilation failures (commonly
|
||||
If the install command fails, try these fallbacks in order:
|
||||
|
||||
1. If `uv tool install` failed, try `pip install mempalace` (or vice versa).
|
||||
2. Try `pip3 install mempalace`.
|
||||
3. Try `python -m pip install mempalace` (or `python3 -m pip install mempalace`).
|
||||
4. If the error mentions missing build tools or compilation failures (commonly
|
||||
from chromadb or its native dependencies):
|
||||
- On Linux/macOS: suggest `sudo apt-get install build-essential python3-dev`
|
||||
(Debian/Ubuntu) or `xcode-select --install` (macOS)
|
||||
- On Windows: suggest installing Microsoft C++ Build Tools from
|
||||
https://visualstudio.microsoft.com/visual-cpp-build-tools/
|
||||
- Then retry the install command
|
||||
4. If all attempts fail, report the error clearly and stop.
|
||||
5. If all attempts fail, report the error clearly and stop.
|
||||
|
||||
## Step 4: Ask for project directory
|
||||
|
||||
@@ -49,7 +61,7 @@ If this fails, report the error and stop.
|
||||
|
||||
Run the following command to register the MemPalace MCP server with Claude:
|
||||
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
claude mcp add mempalace -- mempalace-mcp
|
||||
|
||||
If this fails, report the error but continue to the next step (MCP
|
||||
configuration can be done manually later).
|
||||
|
||||
+190
-25
@@ -42,11 +42,90 @@ import sqlite3
|
||||
import threading
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .config import sanitize_iso_temporal
|
||||
|
||||
|
||||
DEFAULT_KG_PATH = os.path.expanduser("~/.mempalace/knowledge_graph.sqlite3")
|
||||
|
||||
|
||||
def _is_date_only_temporal(value: str) -> bool:
|
||||
return isinstance(value, str) and len(value) == 10 and value[4] == "-" and value[7] == "-"
|
||||
|
||||
|
||||
def _temporal_start_key(value: Optional[str]) -> Optional[str]:
|
||||
"""Return the comparable instant for a valid_from/as_of value."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if _is_date_only_temporal(value):
|
||||
return f"{value}T00:00:00Z"
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _temporal_end_key(value: Optional[str]) -> Optional[str]:
|
||||
"""Return the comparable instant for a valid_to value.
|
||||
|
||||
Date-only valid_to values represent the whole day for backward
|
||||
compatibility with existing KG facts.
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if _is_date_only_temporal(value):
|
||||
return f"{value}T23:59:59Z"
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _sql_temporal_start_expr(column: str) -> str:
|
||||
"""SQLite expression for comparing valid_from-style temporal values."""
|
||||
|
||||
return (
|
||||
f"CASE WHEN length({column}) = 10 "
|
||||
f"AND substr({column}, 5, 1) = '-' "
|
||||
f"AND substr({column}, 8, 1) = '-' "
|
||||
f"THEN {column} || 'T00:00:00Z' ELSE {column} END"
|
||||
)
|
||||
|
||||
|
||||
def _sql_temporal_end_expr(column: str) -> str:
|
||||
"""SQLite expression for comparing valid_to-style temporal values."""
|
||||
|
||||
return (
|
||||
f"CASE WHEN length({column}) = 10 "
|
||||
f"AND substr({column}, 5, 1) = '-' "
|
||||
f"AND substr({column}, 8, 1) = '-' "
|
||||
f"THEN {column} || 'T23:59:59Z' ELSE {column} END"
|
||||
)
|
||||
|
||||
|
||||
def _temporal_filter_sql(as_of: str) -> tuple[str, list[str]]:
|
||||
"""Return SQL and parameters for an as-of temporal filter.
|
||||
|
||||
Date-only KG values are normalized for comparison:
|
||||
|
||||
- valid_from='2026-05-06' compares as '2026-05-06T00:00:00Z'
|
||||
- valid_to='2026-05-06' compares as '2026-05-06T23:59:59Z'
|
||||
|
||||
This keeps legacy date-only facts working when callers query with
|
||||
canonical UTC datetimes such as '2026-05-06T15:00:00Z'.
|
||||
"""
|
||||
|
||||
as_of_key = _temporal_start_key(as_of)
|
||||
valid_from_expr = _sql_temporal_start_expr("t.valid_from")
|
||||
valid_to_expr = _sql_temporal_end_expr("t.valid_to")
|
||||
|
||||
return (
|
||||
f" AND (t.valid_from IS NULL OR {valid_from_expr} <= ?) "
|
||||
f"AND (t.valid_to IS NULL OR {valid_to_expr} >= ?)",
|
||||
[as_of_key, as_of_key],
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeGraph:
|
||||
def __init__(self, db_path: str = None):
|
||||
self.db_path = db_path or DEFAULT_KG_PATH
|
||||
@@ -83,6 +162,8 @@ class KnowledgeGraph:
|
||||
confidence REAL DEFAULT 1.0,
|
||||
source_closet TEXT,
|
||||
source_file TEXT,
|
||||
source_drawer_id TEXT,
|
||||
adapter_name TEXT,
|
||||
extracted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (subject) REFERENCES entities(id),
|
||||
FOREIGN KEY (object) REFERENCES entities(id)
|
||||
@@ -93,8 +174,25 @@ class KnowledgeGraph:
|
||||
CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);
|
||||
CREATE INDEX IF NOT EXISTS idx_triples_valid ON triples(valid_from, valid_to);
|
||||
""")
|
||||
self._migrate_schema(conn)
|
||||
conn.commit()
|
||||
|
||||
def _migrate_schema(self, conn):
|
||||
"""Backwards-compatible schema migration for older triples tables.
|
||||
|
||||
Fresh palaces get ``source_drawer_id`` / ``adapter_name`` (RFC 002 §5.5)
|
||||
directly from the canonical ``CREATE TABLE`` above, so this path is a
|
||||
no-op on new installs. It exists for palaces that were created before
|
||||
those columns were added: SQLite has no ``ADD COLUMN IF NOT EXISTS``,
|
||||
so we introspect the schema and only issue the ALTER when the column
|
||||
is missing.
|
||||
"""
|
||||
existing = {row["name"] for row in conn.execute("PRAGMA table_info(triples)")}
|
||||
if "source_drawer_id" not in existing:
|
||||
conn.execute("ALTER TABLE triples ADD COLUMN source_drawer_id TEXT")
|
||||
if "adapter_name" not in existing:
|
||||
conn.execute("ALTER TABLE triples ADD COLUMN adapter_name TEXT")
|
||||
|
||||
def _conn(self):
|
||||
if self._connection is None:
|
||||
self._connection = sqlite3.connect(self.db_path, timeout=10, check_same_thread=False)
|
||||
@@ -109,6 +207,15 @@ class KnowledgeGraph:
|
||||
self._connection.close()
|
||||
self._connection = None
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow KnowledgeGraph to be used as a context manager."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
"""Close the SQLite connection when leaving a context manager block."""
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def _entity_id(self, name: str) -> str:
|
||||
return name.lower().replace(" ", "_").replace("'", "")
|
||||
|
||||
@@ -137,15 +244,39 @@ class KnowledgeGraph:
|
||||
confidence: float = 1.0,
|
||||
source_closet: str = None,
|
||||
source_file: str = None,
|
||||
source_drawer_id: str = None,
|
||||
adapter_name: str = None,
|
||||
):
|
||||
"""
|
||||
Add a relationship triple: subject → predicate → object.
|
||||
|
||||
``source_drawer_id`` and ``adapter_name`` are RFC 002 §5.5 provenance
|
||||
fields populated by adapters that advertise ``supports_kg_triples``;
|
||||
they default to ``None`` so every existing caller stays
|
||||
source-compatible.
|
||||
|
||||
Examples:
|
||||
add_triple("Max", "child_of", "Alice", valid_from="2015-04-01")
|
||||
add_triple("Max", "does", "swimming", valid_from="2025-01-01")
|
||||
add_triple("Alice", "worried_about", "Max injury", valid_from="2026-01", valid_to="2026-02")
|
||||
add_triple("Alice", "worried_about", "Max injury", valid_from="2026-01-01")
|
||||
"""
|
||||
|
||||
valid_from = sanitize_iso_temporal(valid_from, "valid_from")
|
||||
valid_to = sanitize_iso_temporal(valid_to, "valid_to")
|
||||
|
||||
# Reject inverted intervals. Use temporal comparison keys rather than
|
||||
# raw string comparison so legacy date-only values and canonical UTC
|
||||
# datetimes can safely coexist.
|
||||
if (
|
||||
valid_from is not None
|
||||
and valid_to is not None
|
||||
and _temporal_end_key(valid_to) < _temporal_start_key(valid_from)
|
||||
):
|
||||
raise ValueError(
|
||||
f"valid_to={valid_to!r} is before valid_from={valid_from!r}; "
|
||||
"an inverted interval would be invisible to every KG query"
|
||||
)
|
||||
|
||||
sub_id = self._entity_id(subject)
|
||||
obj_id = self._entity_id(obj)
|
||||
pred = predicate.lower().replace(" ", "_")
|
||||
@@ -155,10 +286,12 @@ class KnowledgeGraph:
|
||||
conn = self._conn()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (sub_id, subject)
|
||||
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)",
|
||||
(sub_id, subject),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (obj_id, obj)
|
||||
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)",
|
||||
(obj_id, obj),
|
||||
)
|
||||
|
||||
# Check for existing identical triple
|
||||
@@ -166,15 +299,16 @@ class KnowledgeGraph:
|
||||
"SELECT id FROM triples WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
|
||||
(sub_id, pred, obj_id),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
return existing["id"] # Already exists and still valid
|
||||
|
||||
triple_id = f"t_{sub_id}_{pred}_{obj_id}_{hashlib.sha256(f'{valid_from}{datetime.now().isoformat()}'.encode()).hexdigest()[:12]}"
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO triples (id, subject, predicate, object, valid_from, valid_to, confidence, source_closet, source_file)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
"""INSERT INTO triples (
|
||||
id, subject, predicate, object, valid_from, valid_to,
|
||||
confidence, source_closet, source_file,
|
||||
source_drawer_id, adapter_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
triple_id,
|
||||
sub_id,
|
||||
@@ -185,22 +319,41 @@ class KnowledgeGraph:
|
||||
confidence,
|
||||
source_closet,
|
||||
source_file,
|
||||
source_drawer_id,
|
||||
adapter_name,
|
||||
),
|
||||
)
|
||||
return triple_id
|
||||
return triple_id
|
||||
|
||||
def invalidate(self, subject: str, predicate: str, obj: str, ended: str = None):
|
||||
"""Mark a relationship as no longer valid (set valid_to date)."""
|
||||
"""Mark a relationship as no longer valid (set valid_to date/time)."""
|
||||
sub_id = self._entity_id(subject)
|
||||
obj_id = self._entity_id(obj)
|
||||
pred = predicate.lower().replace(" ", "_")
|
||||
ended = ended or date.today().isoformat()
|
||||
ended = sanitize_iso_temporal(ended or date.today().isoformat(), "ended")
|
||||
|
||||
with self._lock:
|
||||
conn = self._conn()
|
||||
with conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, valid_from FROM triples "
|
||||
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
|
||||
(sub_id, pred, obj_id),
|
||||
).fetchall()
|
||||
|
||||
for row in rows:
|
||||
valid_from = row["valid_from"]
|
||||
if valid_from is not None and _temporal_end_key(ended) < _temporal_start_key(
|
||||
valid_from
|
||||
):
|
||||
raise ValueError(
|
||||
f"valid_to={ended!r} is before valid_from={valid_from!r}; "
|
||||
"an inverted interval would be invisible to every KG query"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE triples SET valid_to=? WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
|
||||
"UPDATE triples SET valid_to=? "
|
||||
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
|
||||
(ended, sub_id, pred, obj_id),
|
||||
)
|
||||
|
||||
@@ -211,20 +364,27 @@ class KnowledgeGraph:
|
||||
Get all relationships for an entity.
|
||||
|
||||
direction: "outgoing" (entity → ?), "incoming" (? → entity), "both"
|
||||
as_of: date string — only return facts valid at that time
|
||||
as_of: ISO date or canonical UTC datetime — only return facts valid then
|
||||
"""
|
||||
as_of = sanitize_iso_temporal(as_of, "as_of")
|
||||
eid = self._entity_id(name)
|
||||
|
||||
results = []
|
||||
|
||||
temporal_sql = ""
|
||||
temporal_params = []
|
||||
if as_of:
|
||||
temporal_sql, temporal_params = _temporal_filter_sql(as_of)
|
||||
|
||||
with self._lock:
|
||||
conn = self._conn()
|
||||
|
||||
if direction in ("outgoing", "both"):
|
||||
query = "SELECT t.*, e.name as obj_name FROM triples t JOIN entities e ON t.object = e.id WHERE t.subject = ?"
|
||||
params = [eid]
|
||||
if as_of:
|
||||
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
|
||||
params.extend([as_of, as_of])
|
||||
query = (
|
||||
"SELECT t.*, e.name as obj_name FROM triples t "
|
||||
"JOIN entities e ON t.object = e.id WHERE t.subject = ?" + temporal_sql
|
||||
)
|
||||
params = [eid] + temporal_params
|
||||
|
||||
for row in conn.execute(query, params).fetchall():
|
||||
results.append(
|
||||
{
|
||||
@@ -241,11 +401,12 @@ class KnowledgeGraph:
|
||||
)
|
||||
|
||||
if direction in ("incoming", "both"):
|
||||
query = "SELECT t.*, e.name as sub_name FROM triples t JOIN entities e ON t.subject = e.id WHERE t.object = ?"
|
||||
params = [eid]
|
||||
if as_of:
|
||||
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
|
||||
params.extend([as_of, as_of])
|
||||
query = (
|
||||
"SELECT t.*, e.name as sub_name FROM triples t "
|
||||
"JOIN entities e ON t.subject = e.id WHERE t.object = ?" + temporal_sql
|
||||
)
|
||||
params = [eid] + temporal_params
|
||||
|
||||
for row in conn.execute(query, params).fetchall():
|
||||
results.append(
|
||||
{
|
||||
@@ -265,7 +426,9 @@ class KnowledgeGraph:
|
||||
|
||||
def query_relationship(self, predicate: str, as_of: str = None):
|
||||
"""Get all triples with a given relationship type."""
|
||||
as_of = sanitize_iso_temporal(as_of, "as_of")
|
||||
pred = predicate.lower().replace(" ", "_")
|
||||
|
||||
query = """
|
||||
SELECT t.*, s.name as sub_name, o.name as obj_name
|
||||
FROM triples t
|
||||
@@ -274,9 +437,11 @@ class KnowledgeGraph:
|
||||
WHERE t.predicate = ?
|
||||
"""
|
||||
params = [pred]
|
||||
|
||||
if as_of:
|
||||
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
|
||||
params.extend([as_of, as_of])
|
||||
temporal_sql, temporal_params = _temporal_filter_sql(as_of)
|
||||
query += temporal_sql
|
||||
params.extend(temporal_params)
|
||||
|
||||
results = []
|
||||
with self._lock:
|
||||
|
||||
+15
-2
@@ -124,6 +124,8 @@ class Layer1:
|
||||
# Score each drawer: prefer high importance, recent filing
|
||||
scored = []
|
||||
for doc, meta in zip(docs, metas):
|
||||
meta = meta or {}
|
||||
doc = doc or ""
|
||||
importance = 3
|
||||
# Try multiple metadata keys that might carry weight info
|
||||
for key in ("importance", "emotional_weight", "weight"):
|
||||
@@ -155,7 +157,7 @@ class Layer1:
|
||||
lines.append(room_line)
|
||||
total_len += len(room_line)
|
||||
|
||||
for imp, meta, doc in entries:
|
||||
for _imp, meta, doc in entries:
|
||||
source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
|
||||
|
||||
# Truncate doc to keep L1 compact
|
||||
@@ -222,6 +224,8 @@ class Layer2:
|
||||
|
||||
lines = [f"## L2 — ON-DEMAND ({len(docs)} drawers)"]
|
||||
for doc, meta in zip(docs[:n_results], metas[:n_results]):
|
||||
meta = meta or {}
|
||||
doc = doc or ""
|
||||
room_name = meta.get("room", "?")
|
||||
source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
|
||||
snippet = doc.strip().replace("\n", " ")
|
||||
@@ -281,7 +285,9 @@ class Layer3:
|
||||
|
||||
lines = [f'## L3 — SEARCH RESULTS for "{query}"']
|
||||
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
|
||||
similarity = round(1 - dist, 3)
|
||||
meta = meta or {}
|
||||
doc = doc or ""
|
||||
similarity = round(max(0.0, 1 - dist), 3)
|
||||
wing_name = meta.get("wing", "?")
|
||||
room_name = meta.get("room", "?")
|
||||
source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
|
||||
@@ -327,6 +333,13 @@ class Layer3:
|
||||
_first_or_empty(results, "metadatas"),
|
||||
_first_or_empty(results, "distances"),
|
||||
):
|
||||
# ChromaDB may return None for doc/meta when a drawer's HNSW entry
|
||||
# exists but its metadata/document rows haven't been materialized
|
||||
# (partial-flush states, mid-delete, schema upgrade boundaries).
|
||||
# Degrade gracefully — the hit still appears with real distance;
|
||||
# storage fields show their fallback where content is missing.
|
||||
meta = meta or {}
|
||||
doc = doc or ""
|
||||
hits.append(
|
||||
{
|
||||
"text": doc,
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
llm_client.py — Minimal provider abstraction for LLM-assisted entity refinement.
|
||||
|
||||
Three providers cover the useful space:
|
||||
|
||||
- ``ollama`` (default): local models via http://localhost:11434. Works fully
|
||||
offline. Honors MemPalace's "zero-API required" principle.
|
||||
- ``openai-compat``: any OpenAI-compatible ``/v1/chat/completions`` endpoint.
|
||||
Covers OpenRouter, LM Studio, llama.cpp server, vLLM, Groq, Fireworks,
|
||||
Together, and most self-hosted setups.
|
||||
- ``anthropic``: the official Messages API. Opt-in for users who want Haiku
|
||||
quality without setting up a local model.
|
||||
|
||||
All providers expose the same ``classify(system, user, json_mode)`` method and
|
||||
the same ``check_available()`` probe. No external SDK dependencies — stdlib
|
||||
``urllib`` only.
|
||||
|
||||
JSON mode matters here: we always ask for structured output. Providers
|
||||
differ on how to request it (Ollama: ``format: json``; OpenAI-compat:
|
||||
``response_format``; Anthropic: prompt-level instruction) and this module
|
||||
normalizes that away from the caller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
# ── External-service heuristic (issue #24 — privacy warning support) ─────
|
||||
# Used by ``LLMProvider.is_external_service`` to decide whether the
|
||||
# provider's configured endpoint will send user content off the local
|
||||
# machine/network. Single source of truth so all three providers share
|
||||
# identical "local vs external" semantics.
|
||||
|
||||
_LOCALHOST_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
||||
|
||||
|
||||
def _endpoint_is_local(url: Optional[str]) -> bool:
|
||||
"""Return True if ``url``'s hostname is on the user's machine or
|
||||
private network.
|
||||
|
||||
Local includes:
|
||||
- localhost, 127.0.0.1, ::1
|
||||
- hostnames ending in .local (mDNS/Bonjour)
|
||||
- IPv4 RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
- IPv4 CGNAT (Tailscale and similar VPN/tunnel networks):
|
||||
100.64.0.0/10 — first octet 100, second octet 64-127 inclusive
|
||||
- IPv6 unique-local addresses (fc00::/7) — fc.../fd... prefixes
|
||||
|
||||
None / empty / unparseable URLs are treated as local (defensive default —
|
||||
no endpoint means no external request can happen yet).
|
||||
|
||||
Anything else (including public IPs and FQDNs) is external.
|
||||
"""
|
||||
if not url:
|
||||
return True
|
||||
try:
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
if not host:
|
||||
return True
|
||||
if host in _LOCALHOST_HOSTS:
|
||||
return True
|
||||
if host.endswith(".local"):
|
||||
return True
|
||||
if host.startswith("10."):
|
||||
return True
|
||||
if host.startswith("192.168."):
|
||||
return True
|
||||
if host.startswith("172."):
|
||||
# 172.16.0.0 - 172.31.255.255
|
||||
parts = host.split(".")
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
if 16 <= int(parts[1]) <= 31:
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
if host.startswith("100."):
|
||||
# 100.64.0.0/10 — Tailscale CGNAT range. First octet 100, second
|
||||
# octet 64-127 inclusive. Users running a local LLM (LM Studio,
|
||||
# Ollama, etc.) accessible via Tailscale on a 100.x.x.x address
|
||||
# should not trigger the external-API privacy warning.
|
||||
# 100.x.x.x outside this range is regular allocated public space
|
||||
# and remains external.
|
||||
parts = host.split(".")
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
if 64 <= int(parts[1]) <= 127:
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
# IPv6 unique-local addresses fc00::/7 — match leading hex chars
|
||||
if host.startswith("fc") or host.startswith("fd"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class LLMError(RuntimeError):
|
||||
"""Raised for any provider failure — transport, parse, auth, missing model."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
text: str
|
||||
model: str
|
||||
provider: str
|
||||
raw: dict
|
||||
|
||||
|
||||
# ==================== BASE ====================
|
||||
|
||||
|
||||
class LLMProvider:
|
||||
name: str = "base"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
endpoint: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
api_key_source: Optional[str] = None,
|
||||
):
|
||||
self.model = model
|
||||
self.endpoint = endpoint
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
# Provenance of api_key (issue #26): "flag" when the constructor
|
||||
# received an explicit api_key arg, "env" when it fell back to an
|
||||
# environment variable, None when no key is in play. cmd_init
|
||||
# uses this to gate the consent prompt — stray env-resolved keys
|
||||
# require explicit user confirmation.
|
||||
self.api_key_source = api_key_source
|
||||
|
||||
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
def check_available(self) -> tuple[bool, str]:
|
||||
"""Return ``(ok, message)``. Fast probe that the provider is reachable."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def is_external_service(self) -> bool:
|
||||
"""Return True if this provider's endpoint will send user content
|
||||
off the local machine/network.
|
||||
|
||||
Used by ``mempalace init`` to decide whether to print a privacy
|
||||
warning before first use (issue #24). URL-based heuristic only —
|
||||
the endpoint determines, regardless of which provider class.
|
||||
Subclasses that resolve their endpoint dynamically should override
|
||||
if needed; the default works for the three in-tree providers
|
||||
(Ollama / OpenAI-compat / Anthropic).
|
||||
"""
|
||||
return not _endpoint_is_local(self.endpoint)
|
||||
|
||||
|
||||
def _http_post_json(url: str, body: dict, headers: dict, timeout: int) -> dict:
|
||||
"""POST JSON and return the parsed response. Raises LLMError on any failure."""
|
||||
req = Request(
|
||||
url,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json", **headers},
|
||||
)
|
||||
try:
|
||||
with urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read())
|
||||
except HTTPError as e:
|
||||
detail = ""
|
||||
try:
|
||||
detail = e.read().decode("utf-8", errors="replace")[:500]
|
||||
except Exception:
|
||||
pass
|
||||
raise LLMError(f"HTTP {e.code} from {url}: {detail or e.reason}") from e
|
||||
except (URLError, OSError) as e:
|
||||
raise LLMError(f"Cannot reach {url}: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise LLMError(f"Malformed response from {url}: {e}") from e
|
||||
|
||||
|
||||
# ==================== OLLAMA ====================
|
||||
|
||||
|
||||
class OllamaProvider(LLMProvider):
|
||||
name = "ollama"
|
||||
DEFAULT_ENDPOINT = "http://localhost:11434"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
endpoint: Optional[str] = None,
|
||||
timeout: int = 180,
|
||||
**_: object,
|
||||
):
|
||||
super().__init__(
|
||||
model=model,
|
||||
endpoint=endpoint or self.DEFAULT_ENDPOINT,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def check_available(self) -> tuple[bool, str]:
|
||||
try:
|
||||
with urlopen(f"{self.endpoint}/api/tags", timeout=5) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except (URLError, HTTPError, OSError, json.JSONDecodeError) as e:
|
||||
return False, f"Cannot reach Ollama at {self.endpoint}: {e}"
|
||||
names = {m.get("name", "") for m in data.get("models", []) or []}
|
||||
# Ollama tags may or may not include ':latest' — accept either form
|
||||
wanted = {self.model, f"{self.model}:latest"}
|
||||
if not names & wanted:
|
||||
return (
|
||||
False,
|
||||
f"Model '{self.model}' not loaded in Ollama. Run: ollama pull {self.model}",
|
||||
)
|
||||
return True, "ok"
|
||||
|
||||
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
|
||||
body: dict = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1},
|
||||
}
|
||||
if json_mode:
|
||||
body["format"] = "json"
|
||||
data = _http_post_json(f"{self.endpoint}/api/chat", body, headers={}, timeout=self.timeout)
|
||||
text = (data.get("message") or {}).get("content", "")
|
||||
if not text:
|
||||
raise LLMError(f"Empty response from Ollama (model={self.model})")
|
||||
return LLMResponse(text=text, model=self.model, provider=self.name, raw=data)
|
||||
|
||||
|
||||
# ==================== OPENAI-COMPAT ====================
|
||||
|
||||
|
||||
class OpenAICompatProvider(LLMProvider):
|
||||
"""Any OpenAI-compatible ``/v1/chat/completions`` endpoint.
|
||||
|
||||
Supply ``--llm-endpoint http://host:port`` (with or without ``/v1``).
|
||||
API key via ``--llm-api-key`` or the ``OPENAI_API_KEY`` env var.
|
||||
"""
|
||||
|
||||
name = "openai-compat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
endpoint: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
**_: object,
|
||||
):
|
||||
if api_key:
|
||||
resolved_key = api_key
|
||||
source: Optional[str] = "flag"
|
||||
else:
|
||||
env_key = os.environ.get("OPENAI_API_KEY")
|
||||
resolved_key = env_key or None
|
||||
source = "env" if env_key else None
|
||||
super().__init__(
|
||||
model=model,
|
||||
endpoint=endpoint,
|
||||
api_key=resolved_key,
|
||||
timeout=timeout,
|
||||
api_key_source=source,
|
||||
)
|
||||
|
||||
def _resolve_url(self) -> str:
|
||||
if not self.endpoint:
|
||||
raise LLMError("openai-compat provider requires --llm-endpoint")
|
||||
url = self.endpoint.rstrip("/")
|
||||
if url.endswith("/chat/completions"):
|
||||
return url
|
||||
if not url.endswith("/v1"):
|
||||
url = f"{url}/v1"
|
||||
return f"{url}/chat/completions"
|
||||
|
||||
def check_available(self) -> tuple[bool, str]:
|
||||
if not self.endpoint:
|
||||
return False, "no --llm-endpoint configured"
|
||||
base = self.endpoint.rstrip("/")
|
||||
base = base.removesuffix("/chat/completions").removesuffix("/v1")
|
||||
try:
|
||||
req = Request(f"{base}/v1/models")
|
||||
if self.api_key:
|
||||
req.add_header("Authorization", f"Bearer {self.api_key}")
|
||||
with urlopen(req, timeout=5):
|
||||
pass
|
||||
except (URLError, HTTPError, OSError) as e:
|
||||
return False, f"Cannot reach {self.endpoint}: {e}"
|
||||
return True, "ok"
|
||||
|
||||
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
|
||||
body: dict = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
}
|
||||
if json_mode:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
data = _http_post_json(self._resolve_url(), body, headers=headers, timeout=self.timeout)
|
||||
try:
|
||||
text = data["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
raise LLMError(f"Unexpected response shape: {e}") from e
|
||||
if not text:
|
||||
raise LLMError(f"Empty response from {self.name} (model={self.model})")
|
||||
return LLMResponse(text=text, model=self.model, provider=self.name, raw=data)
|
||||
|
||||
|
||||
# ==================== ANTHROPIC ====================
|
||||
|
||||
|
||||
class AnthropicProvider(LLMProvider):
|
||||
name = "anthropic"
|
||||
DEFAULT_ENDPOINT = "https://api.anthropic.com"
|
||||
API_VERSION = "2023-06-01"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
api_key: Optional[str] = None,
|
||||
endpoint: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
**_: object,
|
||||
):
|
||||
if api_key:
|
||||
resolved_key = api_key
|
||||
source: Optional[str] = "flag"
|
||||
else:
|
||||
env_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
resolved_key = env_key or None
|
||||
source = "env" if env_key else None
|
||||
super().__init__(
|
||||
model=model,
|
||||
endpoint=endpoint or self.DEFAULT_ENDPOINT,
|
||||
api_key=resolved_key,
|
||||
timeout=timeout,
|
||||
api_key_source=source,
|
||||
)
|
||||
|
||||
def check_available(self) -> tuple[bool, str]:
|
||||
if not self.api_key:
|
||||
return False, "ANTHROPIC_API_KEY not set (use --llm-api-key or env)"
|
||||
# Don't probe — a live request would cost money. First real call will
|
||||
# surface auth errors if the key is invalid.
|
||||
return True, "ok"
|
||||
|
||||
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
|
||||
if not self.api_key:
|
||||
raise LLMError("Anthropic provider requires ANTHROPIC_API_KEY env or --llm-api-key")
|
||||
sys_prompt = system
|
||||
if json_mode:
|
||||
sys_prompt += "\n\nRespond with valid JSON only, no prose."
|
||||
body = {
|
||||
"model": self.model,
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.1,
|
||||
"system": sys_prompt,
|
||||
"messages": [{"role": "user", "content": user}],
|
||||
}
|
||||
headers = {
|
||||
"X-API-Key": self.api_key,
|
||||
"anthropic-version": self.API_VERSION,
|
||||
}
|
||||
data = _http_post_json(
|
||||
f"{self.endpoint}/v1/messages", body, headers=headers, timeout=self.timeout
|
||||
)
|
||||
try:
|
||||
text = "".join(
|
||||
b.get("text", "") for b in data.get("content", []) or [] if b.get("type") == "text"
|
||||
)
|
||||
except (AttributeError, TypeError) as e:
|
||||
raise LLMError(f"Unexpected response shape: {e}") from e
|
||||
if not text:
|
||||
raise LLMError(f"Empty response from Anthropic (model={self.model})")
|
||||
return LLMResponse(text=text, model=self.model, provider=self.name, raw=data)
|
||||
|
||||
|
||||
# ==================== FACTORY ====================
|
||||
|
||||
|
||||
PROVIDERS: dict[str, type[LLMProvider]] = {
|
||||
"ollama": OllamaProvider,
|
||||
"openai-compat": OpenAICompatProvider,
|
||||
"anthropic": AnthropicProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(
|
||||
name: str,
|
||||
model: str,
|
||||
endpoint: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
) -> LLMProvider:
|
||||
"""Build a provider by name. Raises LLMError on unknown provider."""
|
||||
cls = PROVIDERS.get(name)
|
||||
if cls is None:
|
||||
raise LLMError(f"Unknown provider '{name}'. Choices: {sorted(PROVIDERS.keys())}")
|
||||
return cls(model=model, endpoint=endpoint, api_key=api_key, timeout=timeout)
|
||||
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
llm_refine.py — Optional LLM refinement of regex-detected entities.
|
||||
|
||||
Takes the candidate set produced by phase-1 detection (manifests, git
|
||||
authors, regex on prose) and asks an LLM to reclassify each candidate as
|
||||
PERSON / PROJECT / TOPIC / COMMON_WORD / AMBIGUOUS.
|
||||
|
||||
Design constraints:
|
||||
- Opt-in. Default init path never imports this module.
|
||||
- Local-first by default (Ollama).
|
||||
- Interactive UX: visible progress, clean cancellation (Ctrl-C returns
|
||||
whatever was classified before the interrupt).
|
||||
- Don't feed the raw corpus to the LLM — feed candidates + a few sampled
|
||||
context lines each. Keeps total input to ~50-100K tokens even for huge
|
||||
prose corpora.
|
||||
|
||||
Public:
|
||||
refine_entities(detected, corpus_text, provider, ...) -> dict
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
from mempalace.llm_client import LLMError, LLMProvider
|
||||
|
||||
|
||||
BATCH_SIZE = 25 # candidates per LLM call; tuned for 4B local models
|
||||
CONTEXT_LINES_PER_CANDIDATE = 3
|
||||
CONTEXT_WINDOW_CHARS = 240 # max chars per context line to keep tokens bounded
|
||||
|
||||
# Valid labels the LLM is allowed to return. Anything else is treated as
|
||||
# AMBIGUOUS so the user reviews it.
|
||||
VALID_LABELS = {"PERSON", "PROJECT", "TOPIC", "COMMON_WORD", "AMBIGUOUS"}
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are helping organize a user's memory palace by classifying capitalized tokens found in their files.
|
||||
|
||||
For each candidate, pick exactly ONE label:
|
||||
- PERSON: a specific real person the user knows (colleague, family, character they write about)
|
||||
- PROJECT: a named product, codebase, or effort the user works on
|
||||
- TOPIC: a recurring theme or subject (not a person, not a project) — cities, technologies, concepts
|
||||
- COMMON_WORD: an English word, verb, or fragment that isn't a named entity at all (e.g. "Created", "Before", "Never")
|
||||
- AMBIGUOUS: context is insufficient to decide between two of the above
|
||||
|
||||
Frameworks, runtimes, APIs, cloud services, vendors, and third-party products
|
||||
(e.g. Angular, OpenAPI, Terraform, Bun, Google) are TOPIC unless the context
|
||||
clearly says this is the user's own named codebase, product, or active effort.
|
||||
|
||||
Use the provided context lines to disambiguate. A capitalized word that only appears in metadata ("Created: 2026-04-24") is COMMON_WORD. A name that appears with pronouns and dialogue is PERSON.
|
||||
|
||||
Respond with JSON only. Schema:
|
||||
{"classifications": [{"name": "<exact candidate name>", "label": "<LABEL>", "reason": "<one short sentence>"}]}
|
||||
|
||||
One entry per candidate, same order as the input."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefineResult:
|
||||
merged: dict # updated detected dict
|
||||
reclassified: int # entries whose type changed
|
||||
dropped: int # entries removed from the merged result (COMMON_WORD only)
|
||||
errors: list[str] # per-batch error messages (transport/parse failures)
|
||||
batches_completed: int
|
||||
batches_total: int
|
||||
cancelled: bool
|
||||
|
||||
|
||||
def _collect_contexts(
|
||||
corpus_lines: list[str], name: str, max_lines: int = CONTEXT_LINES_PER_CANDIDATE
|
||||
) -> list[str]:
|
||||
"""Return up to `max_lines` distinct lines from the corpus that mention `name`.
|
||||
|
||||
Case-insensitive token-boundary match. Lines are truncated to
|
||||
CONTEXT_WINDOW_CHARS chars to keep token usage bounded.
|
||||
"""
|
||||
needle = re.compile(rf"(?<!\w){re.escape(name)}(?!\w)", re.IGNORECASE)
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for line in corpus_lines:
|
||||
if not needle.search(line):
|
||||
continue
|
||||
trimmed = line.strip()[:CONTEXT_WINDOW_CHARS]
|
||||
if not trimmed or trimmed in seen:
|
||||
continue
|
||||
seen.add(trimmed)
|
||||
out.append(trimmed)
|
||||
if len(out) >= max_lines:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _build_user_prompt(candidates_with_contexts: list[tuple[str, str, list[str]]]) -> str:
|
||||
"""Shape: for each candidate, list its current type guess + sampled contexts."""
|
||||
parts: list[str] = ["CANDIDATES:"]
|
||||
for i, (name, current_type, contexts) in enumerate(candidates_with_contexts, 1):
|
||||
parts.append(f"\n{i}. {name} (currently: {current_type})")
|
||||
if contexts:
|
||||
for c in contexts:
|
||||
parts.append(f" > {c}")
|
||||
else:
|
||||
parts.append(" > (no context available)")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _extract_json_candidates(text: str) -> list[str]:
|
||||
"""Return plausible JSON payloads extracted from an LLM response."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
candidates: list[str] = [text]
|
||||
|
||||
for match in re.finditer(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE):
|
||||
candidate = match.group(1).strip()
|
||||
if candidate and candidate not in candidates:
|
||||
candidates.append(candidate)
|
||||
|
||||
for start, opener in ((i, ch) for i, ch in enumerate(text) if ch in "{["):
|
||||
closer = "}" if opener == "{" else "]"
|
||||
depth = 0
|
||||
in_string = False
|
||||
escaped = False
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
elif ch == opener:
|
||||
depth += 1
|
||||
elif ch == closer:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start : i + 1].strip()
|
||||
if candidate and candidate not in candidates:
|
||||
candidates.append(candidate)
|
||||
break
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _parse_response(text: str, expected_names: list[str]) -> dict[str, tuple[str, str]]:
|
||||
"""Parse the LLM's JSON response into {name: (label, reason)}.
|
||||
|
||||
Robust to the model occasionally wrapping JSON in text or returning
|
||||
slight schema variations. Falls back to matching by candidate name.
|
||||
"""
|
||||
data = None
|
||||
for candidate in _extract_json_candidates(text):
|
||||
try:
|
||||
data = json.loads(candidate)
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if data is None:
|
||||
return {}
|
||||
|
||||
entries = data.get("classifications") if isinstance(data, dict) else data
|
||||
if not isinstance(entries, list):
|
||||
return {}
|
||||
|
||||
name_to_label: dict[str, tuple[str, str]] = {}
|
||||
expected_set = {n.lower(): n for n in expected_names}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("name") or entry.get("candidate")
|
||||
label = entry.get("label") or entry.get("type") or entry.get("classification")
|
||||
reason = entry.get("reason") or ""
|
||||
if not isinstance(name, str) or not isinstance(label, str):
|
||||
continue
|
||||
# Restore canonical casing from expected_names
|
||||
canonical = expected_set.get(name.lower(), name)
|
||||
lbl = label.strip().upper()
|
||||
if lbl not in VALID_LABELS:
|
||||
lbl = "AMBIGUOUS"
|
||||
name_to_label[canonical] = (lbl, reason.strip()[:120])
|
||||
return name_to_label
|
||||
|
||||
|
||||
def _apply_classifications(
|
||||
detected: dict,
|
||||
decisions: dict[str, tuple[str, str]],
|
||||
allow_project_promotions: bool = True,
|
||||
) -> tuple[dict, int, int]:
|
||||
"""Merge LLM decisions back into the detected dict.
|
||||
|
||||
Returns (new_detected, reclassified_count, dropped_count).
|
||||
|
||||
Topics get their own bucket so the caller can persist them as
|
||||
cross-wing tunnel signal. ``AMBIGUOUS`` still falls back to
|
||||
``uncertain`` for human review.
|
||||
"""
|
||||
label_to_bucket = {
|
||||
"PERSON": "people",
|
||||
"PROJECT": "projects",
|
||||
"TOPIC": "topics",
|
||||
"AMBIGUOUS": "uncertain",
|
||||
}
|
||||
bucket_to_type = {
|
||||
"people": "person",
|
||||
"projects": "project",
|
||||
"topics": "topic",
|
||||
"uncertain": "uncertain",
|
||||
}
|
||||
|
||||
# Index every entity by name for in-place update
|
||||
all_entries: list[tuple[str, dict]] = []
|
||||
for bucket, items in detected.items():
|
||||
for e in items:
|
||||
all_entries.append((bucket, e))
|
||||
|
||||
reclassified = 0
|
||||
dropped = 0
|
||||
new_detected: dict[str, list[dict]] = {
|
||||
"people": [],
|
||||
"projects": [],
|
||||
"topics": [],
|
||||
"uncertain": [],
|
||||
}
|
||||
|
||||
for old_bucket, entry in all_entries:
|
||||
decision = decisions.get(entry["name"])
|
||||
if decision is None:
|
||||
# No LLM opinion — keep as-is
|
||||
new_detected.setdefault(old_bucket, []).append(entry)
|
||||
continue
|
||||
|
||||
label, reason = decision
|
||||
if label == "COMMON_WORD":
|
||||
dropped += 1
|
||||
continue
|
||||
|
||||
target_bucket = label_to_bucket[label]
|
||||
if (
|
||||
label == "PROJECT"
|
||||
and not allow_project_promotions
|
||||
and not _is_authoritative_project(entry)
|
||||
):
|
||||
target_bucket = "uncertain"
|
||||
updated = dict(entry)
|
||||
# Append the LLM's reason as a new signal so the user sees why it moved
|
||||
signals = list(updated.get("signals", []))
|
||||
signals.append(f"LLM: {label.lower()} — {reason}" if reason else f"LLM: {label.lower()}")
|
||||
updated["signals"] = signals
|
||||
if target_bucket != old_bucket:
|
||||
reclassified += 1
|
||||
updated["type"] = bucket_to_type.get(target_bucket, "uncertain")
|
||||
new_detected[target_bucket].append(updated)
|
||||
|
||||
return new_detected, reclassified, dropped
|
||||
|
||||
|
||||
def _build_corpus_origin_preamble(corpus_origin: dict | None) -> str:
|
||||
"""Build a system-prompt preamble carrying corpus-origin context.
|
||||
|
||||
When the corpus has been identified as AI-dialogue with known persona
|
||||
names, this preamble lets the LLM disambiguate ambiguous candidates
|
||||
with knowledge that this is AI-dialogue. It does NOT add a new label
|
||||
or change the classification schema — the post-refine sweep in
|
||||
project_scanner.discover_entities still moves persona names into
|
||||
``agent_personas``. The preamble is purely classification context for
|
||||
the OTHER candidates (ambiguous, common-word) that benefit from
|
||||
knowing the corpus shape.
|
||||
|
||||
Returns ``""`` when no usable origin context is available, so callers
|
||||
can concatenate unconditionally without changing the v3.3.3 prompt
|
||||
shape for opt-out paths.
|
||||
"""
|
||||
if not corpus_origin:
|
||||
return ""
|
||||
result = corpus_origin.get("result") or {}
|
||||
if not result.get("likely_ai_dialogue"):
|
||||
return ""
|
||||
|
||||
lines = ["\n\nCORPUS CONTEXT (corpus-origin detection):"]
|
||||
platform = result.get("primary_platform")
|
||||
if platform:
|
||||
lines.append(f"- This corpus is AI-dialogue from {platform}.")
|
||||
user_name = result.get("user_name")
|
||||
if user_name:
|
||||
lines.append(
|
||||
f"- The corpus author (the human user) is named '{user_name}'. "
|
||||
f"Treat this name as PERSON."
|
||||
)
|
||||
personas = result.get("agent_persona_names") or []
|
||||
if personas:
|
||||
lines.append(
|
||||
"- The user has assigned these persona names to AI agents in "
|
||||
f"this corpus: {', '.join(personas)}."
|
||||
)
|
||||
lines.append(
|
||||
"- Persona names refer to AI agents, not biological people. "
|
||||
"Classify them as PERSON (a downstream step tags them as "
|
||||
"agent personas)."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _is_authoritative_person(entry: dict) -> bool:
|
||||
"""Return True for git-author people that should not be second-guessed."""
|
||||
signals = " ".join(entry.get("signals", [])).lower()
|
||||
return "commit" in signals and "repo" in signals
|
||||
|
||||
|
||||
def _is_authoritative_project(entry: dict) -> bool:
|
||||
"""Return True for manifest/git-backed projects that are already source-backed."""
|
||||
signals = " ".join(entry.get("signals", [])).lower()
|
||||
manifest_markers = ("package.json", "pyproject.toml", "cargo.toml", "go.mod")
|
||||
return any(marker in signals for marker in manifest_markers) or "commit" in signals
|
||||
|
||||
|
||||
def _print_progress(batch_idx: int, total: int, current_name: str) -> None:
|
||||
"""Overwrite-line progress indicator."""
|
||||
width = 40
|
||||
filled = int(width * batch_idx / total) if total else 0
|
||||
bar = "█" * filled + "░" * (width - filled)
|
||||
msg = f"\r LLM refine: [{bar}] batch {batch_idx}/{total} current: {current_name[:30]:<30}"
|
||||
sys.stderr.write(msg)
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def refine_entities(
|
||||
detected: dict,
|
||||
corpus_text: str,
|
||||
provider: LLMProvider,
|
||||
batch_size: int = BATCH_SIZE,
|
||||
show_progress: bool = True,
|
||||
allow_project_promotions: bool = True,
|
||||
corpus_origin: dict | None = None,
|
||||
) -> RefineResult:
|
||||
"""Reclassify detected entities using the LLM provider.
|
||||
|
||||
Only regex-derived candidates are sent for refinement. Git authors and
|
||||
manifest/git-backed projects are already source-backed and don't benefit
|
||||
from LLM second-guessing.
|
||||
|
||||
Ctrl-C during refinement: cancels the remaining batches, returns a
|
||||
RefineResult with ``cancelled=True`` and whatever was classified before
|
||||
the interrupt. The partial result is safe to pass straight to
|
||||
``confirm_entities``.
|
||||
|
||||
Transport or parse failures in individual batches are recorded in
|
||||
``errors`` and do not abort the run.
|
||||
|
||||
``allow_project_promotions=False`` keeps LLM-only project guesses in the
|
||||
uncertain bucket. This is useful when manifest/git signal already supplied
|
||||
canonical projects and regex/LLM hits are likely tools, vendors, or topics.
|
||||
"""
|
||||
candidates: list[tuple[str, str]] = []
|
||||
current_type = {"people": "person", "projects": "project", "uncertain": "uncertain"}
|
||||
for bucket in ("people", "projects", "uncertain"):
|
||||
for e in detected.get(bucket, []):
|
||||
if bucket == "people" and _is_authoritative_person(e):
|
||||
continue
|
||||
if bucket == "projects" and _is_authoritative_project(e):
|
||||
continue
|
||||
candidates.append((e["name"], current_type[bucket]))
|
||||
|
||||
corpus_lines = corpus_text.splitlines() if corpus_text else []
|
||||
|
||||
# Deduplicate candidate names while preserving order
|
||||
seen: set[str] = set()
|
||||
unique: list[tuple[str, str]] = []
|
||||
for name, kind in candidates:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
unique.append((name, kind))
|
||||
|
||||
if not unique:
|
||||
return RefineResult(
|
||||
merged=detected,
|
||||
reclassified=0,
|
||||
dropped=0,
|
||||
errors=[],
|
||||
batches_completed=0,
|
||||
batches_total=0,
|
||||
cancelled=False,
|
||||
)
|
||||
|
||||
# Build batches
|
||||
batches: list[list[tuple[str, str, list[str]]]] = []
|
||||
for i in range(0, len(unique), batch_size):
|
||||
chunk = unique[i : i + batch_size]
|
||||
enriched = [(name, kind, _collect_contexts(corpus_lines, name)) for name, kind in chunk]
|
||||
batches.append(enriched)
|
||||
|
||||
all_decisions: dict[str, tuple[str, str]] = {}
|
||||
errors: list[str] = []
|
||||
completed = 0
|
||||
cancelled = False
|
||||
|
||||
system_prompt = SYSTEM_PROMPT + _build_corpus_origin_preamble(corpus_origin)
|
||||
|
||||
for idx, batch in enumerate(batches, 1):
|
||||
if show_progress and batch:
|
||||
_print_progress(idx - 1, len(batches), batch[0][0])
|
||||
user_prompt = _build_user_prompt(batch)
|
||||
try:
|
||||
resp = provider.classify(system_prompt, user_prompt, json_mode=True)
|
||||
except KeyboardInterrupt:
|
||||
cancelled = True
|
||||
break
|
||||
except LLMError as e:
|
||||
errors.append(f"batch {idx}: {e}")
|
||||
continue
|
||||
names_in_batch = [name for name, _, _ in batch]
|
||||
decisions = _parse_response(resp.text, names_in_batch)
|
||||
if not decisions:
|
||||
errors.append(f"batch {idx}: could not parse response")
|
||||
all_decisions.update(decisions)
|
||||
completed += 1
|
||||
if show_progress:
|
||||
_print_progress(idx, len(batches), batch[-1][0])
|
||||
|
||||
if show_progress:
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
merged, reclassified, dropped = _apply_classifications(
|
||||
detected,
|
||||
all_decisions,
|
||||
allow_project_promotions=allow_project_promotions,
|
||||
)
|
||||
|
||||
return RefineResult(
|
||||
merged=merged,
|
||||
reclassified=reclassified,
|
||||
dropped=dropped,
|
||||
errors=errors,
|
||||
batches_completed=completed,
|
||||
batches_total=len(batches),
|
||||
cancelled=cancelled,
|
||||
)
|
||||
|
||||
|
||||
def collect_corpus_text(
|
||||
project_dir: str,
|
||||
max_files: int = 30,
|
||||
max_bytes_per_file: int = 20_000,
|
||||
) -> str:
|
||||
"""Gather prose text from ``project_dir`` for use as LLM context source.
|
||||
|
||||
Stratified: reads up to ``max_files`` prose files (``.md``, ``.txt``,
|
||||
``.rst``), preferring recently-modified. Each file capped at
|
||||
``max_bytes_per_file`` to bound total input.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from mempalace.entity_detector import PROSE_EXTENSIONS, SKIP_DIRS
|
||||
|
||||
root = Path(project_dir).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
return ""
|
||||
candidates: list[tuple[float, Path]] = []
|
||||
for dirpath, dirs, files in _walk_prose(root, SKIP_DIRS):
|
||||
for fname in files:
|
||||
p = dirpath / fname
|
||||
if p.suffix.lower() not in PROSE_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
mtime = p.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
candidates.append((mtime, p))
|
||||
candidates.sort(reverse=True)
|
||||
selected = [p for _, p in candidates[:max_files]]
|
||||
chunks: list[str] = []
|
||||
for p in selected:
|
||||
try:
|
||||
with open(p, encoding="utf-8", errors="replace") as f:
|
||||
chunks.append(f.read(max_bytes_per_file))
|
||||
except OSError:
|
||||
continue
|
||||
return "\n".join(chunks)
|
||||
|
||||
|
||||
def _walk_prose(root, skip_dirs):
|
||||
"""Walk a directory yielding (Path, dirs, files), pruning skip_dirs.
|
||||
|
||||
Inlined from ``project_scanner._walk`` to avoid a private-name import
|
||||
coupling. Functionality is intentionally narrow: prose collection only.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith(".")]
|
||||
yield Path(dirpath), dirs, files
|
||||
+697
-73
File diff suppressed because it is too large
Load Diff
+112
-11
@@ -7,8 +7,10 @@ Reads documents and metadata directly from the palace's SQLite database
|
||||
then re-imports everything into a fresh palace using the currently installed
|
||||
ChromaDB version.
|
||||
|
||||
This fixes the 3.0.0 → 3.1.0 upgrade path where chromadb was downgraded
|
||||
from 1.5.x to 0.6.x, breaking the on-disk storage format.
|
||||
Since mempalace 3.2.0 (chromadb>=1.5.4), chromadb automatically migrates
|
||||
0.4.1+ databases on first open — no manual migration needed for upgrades.
|
||||
Use this command only when downgrading chromadb (e.g. rolling back to an
|
||||
older mempalace release) or if automatic migration fails.
|
||||
|
||||
Usage:
|
||||
mempalace migrate # migrate default palace
|
||||
@@ -16,13 +18,35 @@ Usage:
|
||||
mempalace migrate --dry-run # show what would be migrated
|
||||
"""
|
||||
|
||||
import errno
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def _restore_stale_palace(palace_path: str, stale_path: str) -> None:
|
||||
"""Roll back a failed swap.
|
||||
|
||||
shutil.move() can partially create palace_path before raising, which
|
||||
would make a bare os.replace(stale_path, palace_path) fail (dest exists).
|
||||
Clear any partial destination first, then restore. Best-effort: if the
|
||||
restore itself fails, log both paths so the operator can recover by hand.
|
||||
"""
|
||||
try:
|
||||
if os.path.lexists(palace_path):
|
||||
shutil.rmtree(palace_path, ignore_errors=True)
|
||||
os.replace(stale_path, palace_path)
|
||||
except Exception as err:
|
||||
print(
|
||||
f" CRITICAL: rollback failed — original palace at {stale_path}, "
|
||||
f"partial migration data at {palace_path}. Restore manually. "
|
||||
f"({err})"
|
||||
)
|
||||
|
||||
|
||||
def extract_drawers_from_sqlite(db_path: str) -> list:
|
||||
"""Read all drawers directly from ChromaDB's SQLite, bypassing the API.
|
||||
|
||||
@@ -132,6 +156,55 @@ def confirm_destructive_action(
|
||||
return True
|
||||
|
||||
|
||||
def _result_ids(result) -> list:
|
||||
"""Return ids from either the backend typed result or raw Chroma dict."""
|
||||
|
||||
if isinstance(result, dict):
|
||||
return list(result.get("ids") or [])
|
||||
|
||||
return list(getattr(result, "ids", []) or [])
|
||||
|
||||
|
||||
def collection_write_roundtrip_works(col) -> bool:
|
||||
"""Return True only if the collection can upsert, read, and delete.
|
||||
|
||||
Some ChromaDB 0.6.x -> 1.5.x migrated collections remain readable while
|
||||
writes and deletes silently no-op. A plain ``count()`` probe misses that
|
||||
failure mode, so migrate must verify an actual write round-trip before
|
||||
deciding that no rebuild is needed.
|
||||
"""
|
||||
|
||||
probe_id = f"_mempalace_migrate_probe_{uuid.uuid4().hex}"
|
||||
probe_doc = "mempalace migrate write round-trip probe"
|
||||
probe_meta = {
|
||||
"wing": "_mempalace_probe",
|
||||
"room": "_mempalace_probe",
|
||||
"source_file": "mempalace_migrate_probe",
|
||||
"chunk_index": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
col.upsert(
|
||||
ids=[probe_id],
|
||||
documents=[probe_doc],
|
||||
metadatas=[probe_meta],
|
||||
)
|
||||
|
||||
after_upsert = col.get(ids=[probe_id], include=[])
|
||||
if probe_id not in _result_ids(after_upsert):
|
||||
return False
|
||||
|
||||
col.delete(ids=[probe_id])
|
||||
|
||||
after_delete = col.get(ids=[probe_id], include=[])
|
||||
if probe_id in _result_ids(after_delete):
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
|
||||
"""Migrate a palace to the currently installed ChromaDB version."""
|
||||
from .backends.chroma import ChromaBackend
|
||||
@@ -156,16 +229,27 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
|
||||
print(f" Source: ChromaDB {source_version}")
|
||||
print(f" Target: ChromaDB {target_version}")
|
||||
|
||||
# Try reading with current chromadb first
|
||||
# Try reading and writing with current chromadb first.
|
||||
#
|
||||
# A plain count() is not enough: some 0.6.x -> 1.5.x migrated collections
|
||||
# are readable but silently drop upsert/delete operations. In that state,
|
||||
# migrate must rebuild from SQLite instead of returning "No migration needed."
|
||||
try:
|
||||
col = ChromaBackend().get_collection(palace_path, "mempalace_drawers")
|
||||
count = col.count()
|
||||
print(f"\n Palace is already readable by chromadb {target_version}.")
|
||||
print(f" {count} drawers found. No migration needed.")
|
||||
return True
|
||||
|
||||
if collection_write_roundtrip_works(col):
|
||||
print(f"\n Palace is already readable and writable by chromadb {target_version}.")
|
||||
print(f" {count} drawers found. No migration needed.")
|
||||
return True
|
||||
|
||||
print(
|
||||
f"\n Palace is readable by chromadb {target_version}, but write/delete verification failed."
|
||||
)
|
||||
print(" Rebuilding from SQLite to restore native write/delete behavior...")
|
||||
except Exception:
|
||||
print(f"\n Palace is NOT readable by chromadb {target_version}.")
|
||||
print(" Extracting from SQLite directly...")
|
||||
print(f"\n Palace is NOT readable by chromadb {target_version}.")
|
||||
print(" Extracting from SQLite directly...")
|
||||
|
||||
# Extract all drawers via raw SQL
|
||||
drawers = extract_drawers_from_sqlite(db_path)
|
||||
@@ -229,10 +313,27 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
|
||||
del col
|
||||
del fresh_backend
|
||||
|
||||
# Swap: remove old palace, move new one into place
|
||||
# Swap: rename old palace aside, then move new one into place.
|
||||
# This avoids a window where both old and new are missing.
|
||||
print(" Swapping old palace for migrated version...")
|
||||
shutil.rmtree(palace_path)
|
||||
shutil.move(temp_palace, palace_path)
|
||||
stale_path = palace_path + ".old"
|
||||
if os.path.exists(stale_path):
|
||||
shutil.rmtree(stale_path)
|
||||
os.replace(palace_path, stale_path)
|
||||
try:
|
||||
os.replace(temp_palace, palace_path)
|
||||
except OSError as e:
|
||||
# EXDEV = temp lives on a different filesystem; fall back to copy+delete.
|
||||
# Anything else is a real error — don't mask it with shutil.move.
|
||||
if getattr(e, "errno", None) != errno.EXDEV:
|
||||
_restore_stale_palace(palace_path, stale_path)
|
||||
raise
|
||||
try:
|
||||
shutil.move(temp_palace, palace_path)
|
||||
except Exception:
|
||||
_restore_stale_palace(palace_path, stale_path)
|
||||
raise
|
||||
shutil.rmtree(stale_path, ignore_errors=True)
|
||||
|
||||
print("\n Migration complete.")
|
||||
print(f" Drawers migrated: {final_count}")
|
||||
|
||||
+548
-90
@@ -8,12 +8,16 @@ Stores verbatim chunks as drawers. No summaries. Ever.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import shlex
|
||||
import hashlib
|
||||
import fnmatch
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from .palace import (
|
||||
NORMALIZE_VERSION,
|
||||
@@ -23,10 +27,13 @@ from .palace import (
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
mine_lock,
|
||||
mine_palace_lock,
|
||||
purge_file_closets,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mempalace_mcp")
|
||||
|
||||
READABLE_EXTENSIONS = {
|
||||
".txt",
|
||||
".md",
|
||||
@@ -36,6 +43,7 @@ READABLE_EXTENSIONS = {
|
||||
".jsx",
|
||||
".tsx",
|
||||
".json",
|
||||
".jsonl",
|
||||
".yaml",
|
||||
".yml",
|
||||
".html",
|
||||
@@ -51,18 +59,36 @@ READABLE_EXTENSIONS = {
|
||||
}
|
||||
|
||||
SKIP_FILENAMES = {
|
||||
"entities.json",
|
||||
"mempalace.yaml",
|
||||
"mempalace.yml",
|
||||
"mempal.yaml",
|
||||
"mempal.yml",
|
||||
".gitignore",
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
}
|
||||
|
||||
CHUNK_SIZE = 800 # chars per drawer
|
||||
CHUNK_OVERLAP = 100 # overlap between chunks
|
||||
MIN_CHUNK_SIZE = 50 # skip tiny chunks
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB — skip files larger than this
|
||||
DRAWER_UPSERT_BATCH_SIZE = 1000
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB — skip files larger than this.
|
||||
# A single file producing more chunks than this is almost always a generated
|
||||
# artifact (CSV/JSON dump, lockfile not in SKIP_FILENAMES, etc.). Embedding
|
||||
# thousands of chunks from one file in one batch has triggered ONNX runtime
|
||||
# `bad allocation` errors on Windows (#1296). The cap is conservative: a
|
||||
# 500-chunk file at CHUNK_SIZE=800 is ~400 KB of source, which covers most
|
||||
# legitimate hand-written content while bounding the worst-case batch.
|
||||
MAX_CHUNKS_PER_FILE = 500
|
||||
# Long Claude Code sessions and large transcript exports routinely exceed
|
||||
# 10 MB. The cap exists as a defensive rail against pathological binary
|
||||
# files, not as a limit on legitimate text. Per-drawer size is bounded
|
||||
# by CHUNK_SIZE, but larger sources still produce proportionally more
|
||||
# drawers and therefore more storage, embedding, and processing work —
|
||||
# and file reads are not streamed (the whole content is loaded into
|
||||
# memory before chunking), so memory use scales with source size too.
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -272,7 +298,15 @@ def load_config(project_dir: str) -> dict:
|
||||
if legacy_path.exists():
|
||||
config_path = legacy_path
|
||||
else:
|
||||
wing_name = resolved_project_dir.name
|
||||
from .config import normalize_wing_name
|
||||
|
||||
# Normalize the dirname-derived fallback wing the same way
|
||||
# ``cmd_init`` and ``room_detector_local`` do — otherwise a
|
||||
# hyphenated project mined without a yaml file lands under a
|
||||
# raw-name wing while ``topics_by_wing`` was keyed under the
|
||||
# normalized slug, silently dropping every topic tunnel
|
||||
# (the no-yaml branch of issue #1194).
|
||||
wing_name = normalize_wing_name(resolved_project_dir.name)
|
||||
print(
|
||||
f" No mempalace.yaml found in {resolved_project_dir} "
|
||||
f"— using auto-detected defaults (wing='{wing_name}'). "
|
||||
@@ -298,6 +332,28 @@ def load_config(project_dir: str) -> dict:
|
||||
# FILE ROUTING — which room does this file belong to?
|
||||
# =============================================================================
|
||||
|
||||
_TOKEN_SPLIT = re.compile(r"[-_./]+")
|
||||
|
||||
|
||||
def _tokens(value: str) -> set:
|
||||
"""Split ``value`` into lowercased tokens bounded by ``-``, ``_``, ``.`` or ``/``."""
|
||||
return {t for t in _TOKEN_SPLIT.split(value.lower()) if t}
|
||||
|
||||
|
||||
def _name_matches(a: str, b: str) -> bool:
|
||||
"""Return True when ``a`` and ``b`` match as equal strings or as
|
||||
separator-bounded tokens of each other.
|
||||
|
||||
Prevents incidental substring collisions (e.g., ``"views" in "interviews"``)
|
||||
that a raw ``in`` check would produce, while preserving the intended
|
||||
match for real tokens (e.g., ``"frontend"`` in ``"frontend-app"``).
|
||||
"""
|
||||
a = a.lower()
|
||||
b = b.lower()
|
||||
if a == b:
|
||||
return True
|
||||
return b in _tokens(a) or a in _tokens(b)
|
||||
|
||||
|
||||
def detect_room(filepath: Path, content: str, rooms: list, project_path: Path) -> str:
|
||||
"""
|
||||
@@ -317,12 +373,12 @@ def detect_room(filepath: Path, content: str, rooms: list, project_path: Path) -
|
||||
for part in path_parts[:-1]: # skip filename itself
|
||||
for room in rooms:
|
||||
candidates = [room["name"].lower()] + [k.lower() for k in room.get("keywords", [])]
|
||||
if any(part == c or c in part or part in c for c in candidates):
|
||||
if any(_name_matches(part, c) for c in candidates):
|
||||
return room["name"]
|
||||
|
||||
# Priority 2: filename matches room name
|
||||
for room in rooms:
|
||||
if room["name"].lower() in filename or filename in room["name"].lower():
|
||||
if _name_matches(filename, room["name"]):
|
||||
return room["name"]
|
||||
|
||||
# Priority 3: keyword scoring from room keywords + name
|
||||
@@ -428,7 +484,16 @@ def _refresh_known_entities_cache() -> None:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
raw = data
|
||||
for cat in data.values():
|
||||
for cat_key, cat in data.items():
|
||||
# Special wing-keyed map — its inner values are topic
|
||||
# names but its outer keys are wings, which must NOT be
|
||||
# surfaced as known entities. Pull the topic names out
|
||||
# explicitly instead of treating it as a generic category.
|
||||
if cat_key == "topics_by_wing" and isinstance(cat, dict):
|
||||
for topic_list in cat.values():
|
||||
if isinstance(topic_list, list):
|
||||
names.update(str(n) for n in topic_list if n)
|
||||
continue
|
||||
if isinstance(cat, list):
|
||||
names.update(str(n) for n in cat if n)
|
||||
elif isinstance(cat, dict):
|
||||
@@ -463,6 +528,172 @@ def _load_known_entities_raw() -> dict:
|
||||
return dict(_ENTITY_REGISTRY_CACHE["raw"])
|
||||
|
||||
|
||||
def _set_wing_topics(existing: dict, wing_key: str, topics_for_wing: list, coerce) -> None:
|
||||
"""Update ``existing['topics_by_wing'][wing_key]`` to the deduped list.
|
||||
|
||||
Replaces (does not union) the wing's topic list — re-running ``init``
|
||||
should reflect the user's latest confirmation rather than accumulate
|
||||
stale labels. Empty input drops the wing entry; an empty map drops
|
||||
the ``topics_by_wing`` key entirely.
|
||||
"""
|
||||
topics_map = existing.get("topics_by_wing")
|
||||
if not isinstance(topics_map, dict):
|
||||
topics_map = {}
|
||||
seen_lower: set = set()
|
||||
ordered: list = []
|
||||
for n in topics_for_wing:
|
||||
name = coerce(n)
|
||||
if not name:
|
||||
continue
|
||||
key = name.lower()
|
||||
if key in seen_lower:
|
||||
continue
|
||||
seen_lower.add(key)
|
||||
ordered.append(name)
|
||||
if ordered:
|
||||
topics_map[wing_key] = ordered
|
||||
else:
|
||||
topics_map.pop(wing_key, None)
|
||||
if topics_map:
|
||||
existing["topics_by_wing"] = topics_map
|
||||
else:
|
||||
existing.pop("topics_by_wing", None)
|
||||
|
||||
|
||||
def add_to_known_entities(entities_by_category: dict, wing: str = None) -> str:
|
||||
"""Union ``entities_by_category`` into ``~/.mempalace/known_entities.json``.
|
||||
|
||||
Accepts ``{category: [names]}`` shape as produced by ``mempalace init``
|
||||
and merges into the registry the miner reads at mine time. Existing
|
||||
categories are preserved untouched unless also present in the input;
|
||||
for categories present in both, entries are unioned case-insensitively
|
||||
without changing the on-disk ordering of pre-existing names.
|
||||
|
||||
If a category is stored on-disk as ``{name: code}`` (the alternate
|
||||
miner-supported shape, used by dialect-style configs), new names are
|
||||
added as keys with ``None`` values so existing code mappings aren't
|
||||
overwritten. A later compress pass can assign codes.
|
||||
|
||||
When ``wing`` is provided AND ``entities_by_category`` contains a
|
||||
``topics`` list, those topics are also recorded under
|
||||
``topics_by_wing[wing]`` (case-insensitive dedup, preserving the
|
||||
casing of the first observed name). This is the signal source for
|
||||
``palace_graph.compute_topic_tunnels`` at mine time. Topics for a
|
||||
wing are *replaced*, not unioned, so a re-run of ``init`` reflects
|
||||
the user's latest confirmation rather than accumulating stale labels
|
||||
indefinitely.
|
||||
|
||||
The in-process cache is invalidated on write so same-process callers
|
||||
(notably ``cmd_init`` → ``cmd_mine`` in sequence) see the update
|
||||
immediately instead of waiting for a mtime re-check.
|
||||
|
||||
Returns the registry path as a string for logging.
|
||||
"""
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
|
||||
registry_path = _Path(_ENTITY_REGISTRY_PATH)
|
||||
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing: dict = {}
|
||||
if registry_path.exists():
|
||||
try:
|
||||
loaded = _json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
existing = loaded
|
||||
except (_json.JSONDecodeError, OSError):
|
||||
existing = {}
|
||||
|
||||
def _coerce_name(value):
|
||||
if not value:
|
||||
return None
|
||||
name = str(value)
|
||||
return name if name else None
|
||||
|
||||
# Separate the topics_by_wing key from regular categories so we don't
|
||||
# treat it as a flat name-list elsewhere in this function.
|
||||
topics_for_wing = None
|
||||
if wing and isinstance(wing, str) and wing.strip():
|
||||
topics_for_wing = entities_by_category.get("topics") or []
|
||||
|
||||
for category, names in entities_by_category.items():
|
||||
if category == "topics_by_wing":
|
||||
# Reserved key — managed separately below.
|
||||
continue
|
||||
if not isinstance(names, list) or not names:
|
||||
continue
|
||||
current = existing.get(category)
|
||||
if isinstance(current, list):
|
||||
seen_lower = {str(n).lower() for n in current}
|
||||
for n in names:
|
||||
name = _coerce_name(n)
|
||||
if not name:
|
||||
continue
|
||||
if name.lower() not in seen_lower:
|
||||
current.append(name)
|
||||
seen_lower.add(name.lower())
|
||||
elif isinstance(current, dict):
|
||||
seen_lower = {str(name).lower() for name in current}
|
||||
for n in names:
|
||||
name = _coerce_name(n)
|
||||
if not name or name.lower() in seen_lower:
|
||||
continue
|
||||
current[name] = None
|
||||
seen_lower.add(name.lower())
|
||||
else:
|
||||
# Missing or unrecognized shape — seed as a fresh list, deduped
|
||||
seen: set = set()
|
||||
ordered: list = []
|
||||
for n in names:
|
||||
name = _coerce_name(n)
|
||||
if not name:
|
||||
continue
|
||||
key = name.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ordered.append(name)
|
||||
existing[category] = ordered
|
||||
|
||||
if topics_for_wing is not None:
|
||||
_set_wing_topics(existing, wing.strip(), topics_for_wing, _coerce_name)
|
||||
|
||||
registry_path.write_text(_json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
registry_path.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
# Invalidate in-process cache so later calls in the same run see the write.
|
||||
_ENTITY_REGISTRY_CACHE["mtime"] = None
|
||||
_ENTITY_REGISTRY_CACHE["names"] = frozenset()
|
||||
_ENTITY_REGISTRY_CACHE["raw"] = {}
|
||||
|
||||
return str(registry_path)
|
||||
|
||||
|
||||
def get_topics_by_wing() -> dict:
|
||||
"""Return ``topics_by_wing`` from the global registry as a dict.
|
||||
|
||||
Returns ``{}`` if the registry is missing, malformed, or has no
|
||||
``topics_by_wing`` key. Casing is preserved from disk; callers that
|
||||
need case-insensitive comparison should normalize themselves.
|
||||
"""
|
||||
raw = _load_known_entities_raw()
|
||||
topics_map = raw.get("topics_by_wing")
|
||||
if not isinstance(topics_map, dict):
|
||||
return {}
|
||||
out: dict = {}
|
||||
for wing, topics in topics_map.items():
|
||||
if not isinstance(wing, str) or not wing.strip():
|
||||
continue
|
||||
if isinstance(topics, list):
|
||||
cleaned = [str(t) for t in topics if isinstance(t, str) and t.strip()]
|
||||
if cleaned:
|
||||
out[wing.strip()] = cleaned
|
||||
return out
|
||||
|
||||
|
||||
_HALL_KEYWORDS_CACHE = None
|
||||
|
||||
|
||||
@@ -533,40 +764,62 @@ def _extract_entities_for_metadata(content: str) -> str:
|
||||
return ";".join(capped)
|
||||
|
||||
|
||||
def _build_drawer_metadata(
|
||||
wing: str,
|
||||
room: str,
|
||||
source_file: str,
|
||||
chunk_index: int,
|
||||
agent: str,
|
||||
content: str,
|
||||
source_mtime: Optional[float],
|
||||
) -> dict:
|
||||
"""Build the metadata dict for one drawer without upserting.
|
||||
|
||||
Split out from ``add_drawer`` so ``process_file`` can batch all chunks
|
||||
of a file into a single ``collection.upsert`` — one embedding forward
|
||||
pass per batch instead of per chunk.
|
||||
"""
|
||||
metadata = {
|
||||
"wing": wing,
|
||||
"room": room,
|
||||
"source_file": source_file,
|
||||
"chunk_index": chunk_index,
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
if source_mtime is not None:
|
||||
metadata["source_mtime"] = source_mtime
|
||||
metadata["hall"] = detect_hall(content)
|
||||
entities = _extract_entities_for_metadata(content)
|
||||
if entities:
|
||||
metadata["entities"] = entities
|
||||
return metadata
|
||||
|
||||
|
||||
def add_drawer(
|
||||
collection, wing: str, room: str, content: str, source_file: str, chunk_index: int, agent: str
|
||||
):
|
||||
"""Add one drawer to the palace."""
|
||||
"""Add one drawer to the palace.
|
||||
|
||||
Kept for backward compatibility with external callers. In-tree the
|
||||
miner uses ``_build_drawer_metadata`` + a batched ``collection.upsert``
|
||||
to amortize the embedding model's forward-pass cost across chunks.
|
||||
"""
|
||||
drawer_id = f"drawer_{wing}_{room}_{hashlib.sha256((source_file + str(chunk_index)).encode()).hexdigest()[:24]}"
|
||||
try:
|
||||
metadata = {
|
||||
"wing": wing,
|
||||
"room": room,
|
||||
"source_file": source_file,
|
||||
"chunk_index": chunk_index,
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
# Store file mtime so we can detect modifications later.
|
||||
try:
|
||||
metadata["source_mtime"] = os.path.getmtime(source_file)
|
||||
except OSError:
|
||||
pass
|
||||
# Tag with hall for graph connectivity within wings
|
||||
metadata["hall"] = detect_hall(content)
|
||||
# Tag with entity names for filterable search
|
||||
entities = _extract_entities_for_metadata(content)
|
||||
if entities:
|
||||
metadata["entities"] = entities
|
||||
collection.upsert(
|
||||
documents=[content],
|
||||
ids=[drawer_id],
|
||||
metadatas=[metadata],
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
raise
|
||||
source_mtime = os.path.getmtime(source_file)
|
||||
except OSError:
|
||||
source_mtime = None
|
||||
metadata = _build_drawer_metadata(
|
||||
wing, room, source_file, chunk_index, agent, content, source_mtime
|
||||
)
|
||||
collection.upsert(
|
||||
documents=[content],
|
||||
ids=[drawer_id],
|
||||
metadatas=[metadata],
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -603,8 +856,15 @@ def process_file(
|
||||
room = detect_room(filepath, content, rooms, project_path)
|
||||
chunks = chunk_text(content, source_file)
|
||||
|
||||
if len(chunks) > MAX_CHUNKS_PER_FILE:
|
||||
print(
|
||||
f" ! [skip] {filepath.name[:50]:50} produced {len(chunks)} chunks "
|
||||
f"(> {MAX_CHUNKS_PER_FILE}); add to SKIP_FILENAMES or .gitignore"
|
||||
)
|
||||
return 0, room
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] {filepath.name} → room:{room} ({len(chunks)} drawers)")
|
||||
print(f" [DRY RUN] {filepath.name} -> room:{room} ({len(chunks)} drawers)")
|
||||
return len(chunks), room
|
||||
|
||||
# Lock this file so concurrent agents don't interleave delete+insert.
|
||||
@@ -623,21 +883,43 @@ def process_file(
|
||||
try:
|
||||
collection.delete(where={"source_file": source_file})
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Stale-drawer purge failed for %s", source_file, exc_info=True)
|
||||
|
||||
# Batch chunks into bounded upserts so the embedding model sees many
|
||||
# chunks per forward pass without building one huge Chroma/SQLite
|
||||
# request for pathological files. A bad chunk can fail its sub-batch;
|
||||
# that is the deliberate trade-off for amortizing embedding overhead.
|
||||
try:
|
||||
source_mtime = os.path.getmtime(source_file)
|
||||
except OSError:
|
||||
source_mtime = None
|
||||
|
||||
drawers_added = 0
|
||||
for chunk in chunks:
|
||||
added = add_drawer(
|
||||
collection=collection,
|
||||
wing=wing,
|
||||
room=room,
|
||||
content=chunk["content"],
|
||||
source_file=source_file,
|
||||
chunk_index=chunk["chunk_index"],
|
||||
agent=agent,
|
||||
for batch_start in range(0, len(chunks), DRAWER_UPSERT_BATCH_SIZE):
|
||||
batch_docs: list = []
|
||||
batch_ids: list = []
|
||||
batch_metas: list = []
|
||||
for chunk in chunks[batch_start : batch_start + DRAWER_UPSERT_BATCH_SIZE]:
|
||||
drawer_id = f"drawer_{wing}_{room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
|
||||
batch_docs.append(chunk["content"])
|
||||
batch_ids.append(drawer_id)
|
||||
batch_metas.append(
|
||||
_build_drawer_metadata(
|
||||
wing,
|
||||
room,
|
||||
source_file,
|
||||
chunk["chunk_index"],
|
||||
agent,
|
||||
chunk["content"],
|
||||
source_mtime,
|
||||
)
|
||||
)
|
||||
collection.upsert(
|
||||
documents=batch_docs,
|
||||
ids=batch_ids,
|
||||
metadatas=batch_metas,
|
||||
)
|
||||
if added:
|
||||
drawers_added += 1
|
||||
drawers_added += len(batch_docs)
|
||||
|
||||
# Build closet — the searchable index pointing to these drawers.
|
||||
# Purge first: a re-mine (mtime change or normalize_version bump) must
|
||||
@@ -751,23 +1033,75 @@ def mine(
|
||||
dry_run: bool = False,
|
||||
respect_gitignore: bool = True,
|
||||
include_ignored: list = None,
|
||||
files: list = None,
|
||||
):
|
||||
"""Mine a project directory into the palace."""
|
||||
"""Mine a project directory into the palace.
|
||||
|
||||
``files`` may optionally be a pre-scanned list of file paths from
|
||||
:func:`scan_project`. When provided, the corpus walk is skipped — the
|
||||
caller (e.g. ``init`` showing a file-count estimate before the mine
|
||||
prompt) avoids walking the tree twice. When ``None`` (the default),
|
||||
``mine`` walks the tree itself just like before.
|
||||
"""
|
||||
|
||||
if dry_run:
|
||||
return _mine_impl(
|
||||
project_dir,
|
||||
palace_path,
|
||||
wing_override=wing_override,
|
||||
agent=agent,
|
||||
limit=limit,
|
||||
dry_run=dry_run,
|
||||
respect_gitignore=respect_gitignore,
|
||||
include_ignored=include_ignored,
|
||||
files=files,
|
||||
)
|
||||
|
||||
# MineAlreadyRunning propagates so the CLI can render a clear holder-aware
|
||||
# message and exit non-zero. In-process callers (tests, library users) that
|
||||
# expect to coexist with another writer should handle the exception.
|
||||
with mine_palace_lock(palace_path):
|
||||
return _mine_impl(
|
||||
project_dir,
|
||||
palace_path,
|
||||
wing_override=wing_override,
|
||||
agent=agent,
|
||||
limit=limit,
|
||||
dry_run=dry_run,
|
||||
respect_gitignore=respect_gitignore,
|
||||
include_ignored=include_ignored,
|
||||
files=files,
|
||||
)
|
||||
|
||||
|
||||
def _mine_impl(
|
||||
project_dir: str,
|
||||
palace_path: str,
|
||||
wing_override: str = None,
|
||||
agent: str = "mempalace",
|
||||
limit: int = 0,
|
||||
dry_run: bool = False,
|
||||
respect_gitignore: bool = True,
|
||||
include_ignored: list = None,
|
||||
files: list = None,
|
||||
):
|
||||
project_path = Path(project_dir).expanduser().resolve()
|
||||
config = load_config(project_dir)
|
||||
|
||||
wing = wing_override or config["wing"]
|
||||
rooms = config.get("rooms", [{"name": "general", "description": "All project files"}])
|
||||
|
||||
files = scan_project(
|
||||
project_dir,
|
||||
respect_gitignore=respect_gitignore,
|
||||
include_ignored=include_ignored,
|
||||
)
|
||||
if files is None:
|
||||
files = scan_project(
|
||||
project_dir,
|
||||
respect_gitignore=respect_gitignore,
|
||||
include_ignored=include_ignored,
|
||||
)
|
||||
if limit > 0:
|
||||
files = files[:limit]
|
||||
|
||||
from .embedding import describe_device
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" MemPalace Mine")
|
||||
print(f"{'=' * 55}")
|
||||
@@ -775,13 +1109,14 @@ def mine(
|
||||
print(f" Rooms: {', '.join(r['name'] for r in rooms)}")
|
||||
print(f" Files: {len(files)}")
|
||||
print(f" Palace: {palace_path}")
|
||||
print(f" Device: {describe_device()}")
|
||||
if dry_run:
|
||||
print(" DRY RUN — nothing will be filed")
|
||||
if not respect_gitignore:
|
||||
print(" .gitignore: DISABLED")
|
||||
if include_ignored:
|
||||
print(f" Include: {', '.join(sorted(normalize_include_paths(include_ignored)))}")
|
||||
print(f"{'─' * 55}\n")
|
||||
print(f"{'-' * 55}\n")
|
||||
|
||||
if not dry_run:
|
||||
collection = get_collection(palace_path)
|
||||
@@ -792,37 +1127,153 @@ def mine(
|
||||
|
||||
total_drawers = 0
|
||||
files_skipped = 0
|
||||
files_processed = 0
|
||||
last_file = None
|
||||
room_counts = defaultdict(int)
|
||||
|
||||
for i, filepath in enumerate(files, 1):
|
||||
drawers, room = process_file(
|
||||
filepath=filepath,
|
||||
project_path=project_path,
|
||||
collection=collection,
|
||||
wing=wing,
|
||||
rooms=rooms,
|
||||
agent=agent,
|
||||
dry_run=dry_run,
|
||||
closets_col=closets_col,
|
||||
)
|
||||
if drawers == 0 and not dry_run:
|
||||
files_skipped += 1
|
||||
else:
|
||||
total_drawers += drawers
|
||||
room_counts[room] += 1
|
||||
if not dry_run:
|
||||
print(f" ✓ [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers}")
|
||||
try:
|
||||
for i, filepath in enumerate(files, 1):
|
||||
try:
|
||||
drawers, room = process_file(
|
||||
filepath=filepath,
|
||||
project_path=project_path,
|
||||
collection=collection,
|
||||
wing=wing,
|
||||
rooms=rooms,
|
||||
agent=agent,
|
||||
dry_run=dry_run,
|
||||
closets_col=closets_col,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
# Re-raise so the outer handler prints the summary; we
|
||||
# capture the last-attempted file via last_file below.
|
||||
last_file = filepath.name
|
||||
raise
|
||||
files_processed = i
|
||||
last_file = filepath.name
|
||||
if drawers == 0 and not dry_run:
|
||||
files_skipped += 1
|
||||
else:
|
||||
total_drawers += drawers
|
||||
room_counts[room] += 1
|
||||
if not dry_run:
|
||||
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers}")
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" Done.")
|
||||
print(f" Files processed: {len(files) - files_skipped}")
|
||||
print(f" Files skipped (already filed): {files_skipped}")
|
||||
print(f" Drawers filed: {total_drawers}")
|
||||
print("\n By room:")
|
||||
for room, count in sorted(room_counts.items(), key=lambda x: x[1], reverse=True):
|
||||
print(f" {room:20} {count} files")
|
||||
print('\n Next: mempalace search "what you\'re looking for"')
|
||||
print(f"{'=' * 55}\n")
|
||||
if not dry_run:
|
||||
# Cross-wing topic tunnels: after every file in this wing has been
|
||||
# processed, link this wing to any other wing that shares a
|
||||
# confirmed TOPIC label. Out of scope for v1: manifest-dependency
|
||||
# overlap, per-topic allow/deny lists, search-result surfacing.
|
||||
try:
|
||||
tunnels_added = _compute_topic_tunnels_for_wing(wing)
|
||||
if tunnels_added:
|
||||
print(f"\n Topic tunnels: +{tunnels_added} cross-wing link(s)")
|
||||
except Exception as e:
|
||||
# Tunnel computation must never fail a mine — degrade quietly.
|
||||
print(
|
||||
f"\n WARNING: topic tunnel computation skipped — {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" Done.")
|
||||
print(f" Files processed: {len(files) - files_skipped}")
|
||||
print(f" Files skipped (already filed): {files_skipped}")
|
||||
print(f" Drawers filed: {total_drawers}")
|
||||
print("\n By room:")
|
||||
for room, count in sorted(room_counts.items(), key=lambda x: x[1], reverse=True):
|
||||
print(f" {room:20} {count} files")
|
||||
print('\n Next: mempalace search "what you\'re looking for"')
|
||||
print(f"{'=' * 55}\n")
|
||||
except KeyboardInterrupt:
|
||||
# Idempotent re-mine: deterministic drawer IDs mean already-filed
|
||||
# drawers upsert to the same row on next run, so partial progress
|
||||
# is safe to leave in place. A second Ctrl-C during this print
|
||||
# propagates to the default handler — we don't try to catch
|
||||
# everything.
|
||||
print("\n\n Mine interrupted.")
|
||||
print(f" files_processed: {files_processed}/{len(files)}")
|
||||
print(f" drawers_filed: {total_drawers}")
|
||||
print(f" last_file: {last_file or '<none>'}")
|
||||
print(
|
||||
f"\n Re-run `mempalace mine {shlex.quote(project_dir)}` to resume — "
|
||||
"already-filed drawers are\n upserted idempotently and will not duplicate.\n"
|
||||
)
|
||||
sys.exit(130)
|
||||
except Exception as exc:
|
||||
# Without this, an arbitrary exception (ONNX bad_alloc, chromadb HNSW
|
||||
# error, OS fault) propagates and the process exits with no completion
|
||||
# banner — the operator sees only the final progress line and assumes
|
||||
# the mine succeeded (#1296). Print the partial-progress summary the
|
||||
# way we do for KeyboardInterrupt, then re-raise so the original
|
||||
# traceback still surfaces and the exit code is non-zero.
|
||||
print("\n\n Mine aborted by exception.")
|
||||
print(f" files_processed: {files_processed}/{len(files)}")
|
||||
print(f" drawers_filed: {total_drawers}")
|
||||
print(f" last_file: {last_file or '<none>'}")
|
||||
print(f" error: {type(exc).__name__}: {exc}")
|
||||
print(
|
||||
f"\n Re-run `mempalace mine {shlex.quote(project_dir)}` after addressing "
|
||||
"the cause — already-filed\n drawers are upserted idempotently and will "
|
||||
"not duplicate.\n"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Clean up the hooks-side PID lock if it points at us. Stale
|
||||
# entries already pass _pid_alive() == False on POSIX, but
|
||||
# actively removing the file makes the state observable
|
||||
# (callers can stat it) and avoids accidental PID reuse on
|
||||
# short-lived test runs. Only remove if the file claims our
|
||||
# own PID — never another process's.
|
||||
_cleanup_mine_pid_file()
|
||||
|
||||
|
||||
def _cleanup_mine_pid_file() -> None:
|
||||
"""Remove this process's per-target PID slot on exit.
|
||||
|
||||
Hook-spawned mines receive ``MEMPALACE_MINE_PID_FILE`` in their env
|
||||
pointing at the slot the hook claimed for them
|
||||
(``~/.mempalace/hook_state/mine_pids/mine_<sha>.pid``). When the
|
||||
subprocess exits — cleanly, on error, or via Ctrl-C — it removes its
|
||||
own slot so the next hook fire isn't briefly fooled by a stale PID
|
||||
before ``_pid_alive`` returns False.
|
||||
|
||||
Only delete the slot if it claims our own PID; any other PID is left
|
||||
alone (it could belong to an unrelated mine that just claimed the
|
||||
same slot via a stale-reclaim race).
|
||||
"""
|
||||
pid_file_env = os.environ.get("MEMPALACE_MINE_PID_FILE", "")
|
||||
if not pid_file_env:
|
||||
return
|
||||
try:
|
||||
pid_file = Path(pid_file_env)
|
||||
if not pid_file.exists():
|
||||
return
|
||||
recorded = pid_file.read_text().strip()
|
||||
if recorded and recorded.isdigit() and int(recorded) == os.getpid():
|
||||
pid_file.unlink()
|
||||
except OSError:
|
||||
# Best-effort cleanup; never fail the mine over PID bookkeeping.
|
||||
pass
|
||||
|
||||
|
||||
def _compute_topic_tunnels_for_wing(wing: str) -> int:
|
||||
"""Drop tunnels between ``wing`` and every other wing that shares
|
||||
confirmed topics, honoring the ``topic_tunnel_min_count`` config knob.
|
||||
|
||||
Returns the number of tunnels created or refreshed. Zero means no
|
||||
overlap found (or the registry has no ``topics_by_wing`` map yet).
|
||||
"""
|
||||
from .config import MempalaceConfig
|
||||
from .palace_graph import topic_tunnels_for_wing
|
||||
|
||||
topics_map = get_topics_by_wing()
|
||||
if not topics_map or wing not in topics_map:
|
||||
return 0
|
||||
cfg = MempalaceConfig()
|
||||
min_count = cfg.topic_tunnel_min_count
|
||||
created = topic_tunnels_for_wing(wing, topics_map, min_count=min_count)
|
||||
return len(created)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -839,17 +1290,24 @@ def status(palace_path: str):
|
||||
print(" Run: mempalace init <dir> then mempalace mine <dir>")
|
||||
return
|
||||
|
||||
# Count by wing and room
|
||||
# Count by wing and room — paginate to avoid SQLite "too many SQL
|
||||
# variables" error on large palaces (see #802, #850).
|
||||
total = col.count()
|
||||
r = col.get(limit=total, include=["metadatas"]) if total else {"metadatas": []}
|
||||
metas = r["metadatas"]
|
||||
|
||||
wing_rooms = defaultdict(lambda: defaultdict(int))
|
||||
for m in metas:
|
||||
wing_rooms[m.get("wing", "?")][m.get("room", "?")] += 1
|
||||
wing_rooms: dict = defaultdict(lambda: defaultdict(int))
|
||||
batch_size = 5000
|
||||
offset = 0
|
||||
while offset < total:
|
||||
r = col.get(limit=batch_size, offset=offset, include=["metadatas"])
|
||||
batch = r["metadatas"]
|
||||
if not batch:
|
||||
break
|
||||
for m in batch:
|
||||
m = m or {}
|
||||
wing_rooms[m.get("wing", "?")][m.get("room", "?")] += 1
|
||||
offset += len(batch)
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(f" MemPalace Status — {len(metas)} drawers")
|
||||
print(f" MemPalace Status — {total} drawers")
|
||||
print(f"{'=' * 55}\n")
|
||||
for wing, rooms in sorted(wing_rooms.items()):
|
||||
print(f" WING: {wing}")
|
||||
|
||||
+75
-2
@@ -8,6 +8,7 @@ Supported:
|
||||
- ChatGPT conversations.json
|
||||
- Claude Code JSONL (with tool_use/tool_result block capture)
|
||||
- OpenAI Codex CLI JSONL
|
||||
- Gemini CLI JSONL (~/.gemini/tmp/<project_hash>/chats/session-*.jsonl)
|
||||
- Slack JSON export
|
||||
- Plain text (pass through for paragraph chunking)
|
||||
|
||||
@@ -117,14 +118,14 @@ def normalize(filepath: str) -> str:
|
||||
try:
|
||||
file_size = os.path.getsize(filepath)
|
||||
except OSError as e:
|
||||
raise IOError(f"Could not read {filepath}: {e}")
|
||||
raise IOError(f"Could not read {filepath}: {e}") from e
|
||||
if file_size > 500 * 1024 * 1024: # 500 MB safety limit
|
||||
raise IOError(f"File too large ({file_size // (1024 * 1024)} MB): {filepath}")
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
raise IOError(f"Could not read {filepath}: {e}")
|
||||
raise IOError(f"Could not read {filepath}: {e}") from e
|
||||
|
||||
if not content.strip():
|
||||
return content
|
||||
@@ -157,6 +158,10 @@ def _try_normalize_json(content: str) -> Optional[str]:
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
normalized = _try_gemini_jsonl(content)
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
@@ -280,6 +285,74 @@ def _try_codex_jsonl(content: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _try_gemini_jsonl(content: str) -> Optional[str]:
|
||||
"""Gemini CLI sessions (~/.gemini/tmp/<project_hash>/chats/session-*.jsonl).
|
||||
|
||||
Schema (per google-gemini/gemini-cli#15292): a session_metadata record
|
||||
on the first line, then a stream of ``{"type": "user", "content":
|
||||
[{"text": "..."}]}`` and ``{"type": "gemini", "content": [...]}``
|
||||
records, with optional ``message_update`` records carrying token
|
||||
counts only.
|
||||
|
||||
Detection requires a ``session_metadata`` record so this parser does
|
||||
not false-positive against Claude Code or Codex JSONL passed through
|
||||
the dispatch chain. Any ``user``/``gemini`` lines that appear before
|
||||
``session_metadata`` are discarded — they are treated as preamble
|
||||
noise, not conversational turns. ``message_update`` entries are
|
||||
skipped — they have no message text. Multiple text blocks within a
|
||||
single message's content array are concatenated in order, separated
|
||||
by newlines.
|
||||
"""
|
||||
lines = [line.strip() for line in content.strip().split("\n") if line.strip()]
|
||||
messages = []
|
||||
has_session_metadata = False
|
||||
for line in lines:
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
entry_type = entry.get("type", "")
|
||||
if entry_type == "session_metadata":
|
||||
has_session_metadata = True
|
||||
continue
|
||||
|
||||
# Discard everything (including user/gemini turns) until the
|
||||
# session_metadata sentinel has been seen.
|
||||
if not has_session_metadata:
|
||||
continue
|
||||
|
||||
if entry_type not in ("user", "gemini"):
|
||||
# Skips message_update, system events, anything else.
|
||||
continue
|
||||
|
||||
content_blocks = entry.get("content", [])
|
||||
if not isinstance(content_blocks, list):
|
||||
continue
|
||||
|
||||
parts = []
|
||||
for block in content_blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
text = block.get("text", "")
|
||||
if isinstance(text, str) and text.strip():
|
||||
parts.append(text)
|
||||
if not parts:
|
||||
continue
|
||||
joined = "\n".join(parts)
|
||||
|
||||
if entry_type == "user":
|
||||
messages.append(("user", joined))
|
||||
else: # "gemini"
|
||||
messages.append(("assistant", joined))
|
||||
|
||||
if len(messages) >= 2 and has_session_metadata:
|
||||
return _messages_to_transcript(messages)
|
||||
return None
|
||||
|
||||
|
||||
def _try_claude_ai_json(data) -> Optional[str]:
|
||||
"""Claude.ai JSON export: flat messages list or privacy export with chat_messages."""
|
||||
if isinstance(data, dict):
|
||||
|
||||
+224
-3
@@ -6,11 +6,17 @@ Consolidates collection access patterns used by both miners and the MCP server.
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from .backends.chroma import ChromaBackend
|
||||
|
||||
logger = logging.getLogger("mempalace_mcp")
|
||||
|
||||
SKIP_DIRS = {
|
||||
".git",
|
||||
"node_modules",
|
||||
@@ -52,10 +58,14 @@ NORMALIZE_VERSION = 2
|
||||
|
||||
def get_collection(
|
||||
palace_path: str,
|
||||
collection_name: str = "mempalace_drawers",
|
||||
collection_name: Optional[str] = None,
|
||||
create: bool = True,
|
||||
):
|
||||
"""Get the palace collection through the backend layer."""
|
||||
if collection_name is None:
|
||||
from .config import get_configured_collection_name
|
||||
|
||||
collection_name = get_configured_collection_name()
|
||||
return _DEFAULT_BACKEND.get_collection(
|
||||
palace_path,
|
||||
collection_name=collection_name,
|
||||
@@ -228,7 +238,7 @@ def purge_file_closets(closets_col, source_file: str) -> None:
|
||||
try:
|
||||
closets_col.delete(where={"source_file": source_file})
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Closet purge failed for %s", source_file, exc_info=True)
|
||||
|
||||
|
||||
def upsert_closet_lines(closets_col, closet_id_base, lines, metadata):
|
||||
@@ -306,10 +316,221 @@ def mine_lock(source_file: str):
|
||||
|
||||
fcntl.flock(lf, fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Mine-lock release failed", exc_info=True)
|
||||
lf.close()
|
||||
|
||||
|
||||
class MineAlreadyRunning(RuntimeError):
|
||||
"""Raised when another `mempalace mine` already holds the per-palace lock."""
|
||||
|
||||
|
||||
# Per-thread record of palaces this thread already holds the lock for. Used by
|
||||
# `mine_palace_lock` to short-circuit re-entrant acquisition from the same
|
||||
# thread (e.g. miner.mine() acquires the outer lock then calls
|
||||
# ChromaCollection.upsert which now also tries to acquire). Without this guard
|
||||
# the inner call would block on its own outer flock (Linux fcntl locks are per
|
||||
# open file description, so a same-thread second open of the lock file is a
|
||||
# distinct lock and self-deadlocks).
|
||||
#
|
||||
# The holder set is tagged with ``pid`` so that a forked child does NOT
|
||||
# inherit re-entrant credit from its parent: the OS-level flock IS NOT
|
||||
# inherited as a "we hold it" semantically — the child must reacquire — but
|
||||
# Python's ``threading.local`` IS inherited across fork. The pid check
|
||||
# clears stale state so a forked child correctly hits the fcntl path.
|
||||
_palace_lock_holders = threading.local()
|
||||
|
||||
|
||||
def _holder_state():
|
||||
"""Return the per-thread (pid, keys) record, refreshing after fork."""
|
||||
keys = getattr(_palace_lock_holders, "keys", None)
|
||||
pid = getattr(_palace_lock_holders, "pid", None)
|
||||
current_pid = os.getpid()
|
||||
if keys is None or pid != current_pid:
|
||||
keys = set()
|
||||
_palace_lock_holders.keys = keys
|
||||
_palace_lock_holders.pid = current_pid
|
||||
return keys
|
||||
|
||||
|
||||
def _held_by_this_thread(lock_key: str) -> bool:
|
||||
"""Return True if this thread already holds ``mine_palace_lock`` for ``lock_key``."""
|
||||
return lock_key in _holder_state()
|
||||
|
||||
|
||||
def _mark_held(lock_key: str) -> None:
|
||||
_holder_state().add(lock_key)
|
||||
|
||||
|
||||
def _mark_released(lock_key: str) -> None:
|
||||
_holder_state().discard(lock_key)
|
||||
|
||||
|
||||
def _format_lock_holder(content: str) -> str:
|
||||
"""Render a lock-file body as 'PID N (cmdline)' for diagnostic messages."""
|
||||
parts = content.split(maxsplit=1)
|
||||
if not parts or not parts[0].isdigit():
|
||||
return "another writer (identity not recorded)"
|
||||
pid = parts[0]
|
||||
if len(parts) > 1 and parts[1].strip():
|
||||
return f"PID {pid} ({parts[1].strip()})"
|
||||
return f"PID {pid}"
|
||||
|
||||
|
||||
# Byte 0 of the lock file is reserved as the OS lock sentinel.
|
||||
# Holder identity is written from byte 1 onward so contenders can read
|
||||
# the identity without colliding with byte 0 (Windows msvcrt.locking
|
||||
# blocks both reads and writes on the locked byte).
|
||||
_LOCK_SENTINEL_BYTES = 1
|
||||
|
||||
|
||||
def _read_lock_holder(lock_file) -> str:
|
||||
"""Read the prior holder's identity from the lock-file body, best-effort."""
|
||||
try:
|
||||
lock_file.seek(_LOCK_SENTINEL_BYTES)
|
||||
content = lock_file.read().strip()
|
||||
except OSError:
|
||||
return "another writer (identity not recorded)"
|
||||
if not content:
|
||||
return "another writer (identity not recorded)"
|
||||
return _format_lock_holder(content)
|
||||
|
||||
|
||||
def _write_lock_holder(lock_file) -> None:
|
||||
"""Record this process's identity in the lock-file body. Best-effort.
|
||||
|
||||
Writes from byte 1 onward; byte 0 is the lock sentinel and must not
|
||||
be touched after acquire (truncating it on Windows can interact
|
||||
badly with the active byte-range lock).
|
||||
"""
|
||||
try:
|
||||
ident = f"{os.getpid()} {' '.join(sys.argv[:3])}".strip()
|
||||
lock_file.seek(_LOCK_SENTINEL_BYTES)
|
||||
lock_file.truncate(_LOCK_SENTINEL_BYTES + len(ident.encode("utf-8")))
|
||||
lock_file.write(ident)
|
||||
lock_file.flush()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def mine_palace_lock(palace_path: str):
|
||||
"""Per-palace non-blocking lock around the full `mine` pipeline.
|
||||
|
||||
The per-file `mine_lock` only protects delete+insert interleave for a
|
||||
single source; it does not prevent N copies of `mempalace mine <dir>`
|
||||
from being spawned concurrently by hooks. When that happens, each copy
|
||||
drives ChromaDB HNSW inserts in parallel against the same palace,
|
||||
which (combined with chromadb's multi-threaded ParallelFor) can
|
||||
corrupt the HNSW graph and produce sparse link_lists.bin blowups.
|
||||
|
||||
The lock file is keyed by sha256(palace_path) so mines against
|
||||
*different* palaces can still run in parallel — we only serialize
|
||||
writes into the same palace, which is the correctness boundary.
|
||||
|
||||
The key is derived from a fully normalized form of the path:
|
||||
`realpath` resolves symlinks and `..` segments, and `normcase` folds
|
||||
case on Windows (which has a case-insensitive filesystem). Without
|
||||
normcase, `C:\\Palace` and `c:\\palace` would hash to different keys
|
||||
on Windows and let two concurrent mines touch the same on-disk palace.
|
||||
|
||||
Non-blocking: if another `mine` is already writing to this palace,
|
||||
raise MineAlreadyRunning so the caller can exit cleanly instead of
|
||||
piling up as a waiting worker.
|
||||
|
||||
Re-entrant: if the current thread already holds the lock for the same
|
||||
palace, the context manager passes through without re-acquiring. This
|
||||
lets ChromaCollection write methods (which acquire the lock themselves
|
||||
to protect MCP/direct callers) compose with miner.mine() (which holds
|
||||
the outer lock for the entire mine pipeline) without self-deadlock.
|
||||
"""
|
||||
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
|
||||
os.makedirs(lock_dir, exist_ok=True)
|
||||
resolved = os.path.realpath(os.path.expanduser(palace_path))
|
||||
lock_key_source = os.path.normcase(resolved)
|
||||
palace_key = hashlib.sha256(lock_key_source.encode()).hexdigest()[:16]
|
||||
lock_path = os.path.join(lock_dir, f"mine_palace_{palace_key}.lock")
|
||||
|
||||
if _held_by_this_thread(palace_key):
|
||||
# Same thread already holds the lock for this palace — pass through.
|
||||
yield
|
||||
return
|
||||
|
||||
# Ensure the file exists, then open r+ so we can both read the prior
|
||||
# holder's identity (for failure diagnostics) and write our own. "w"
|
||||
# truncates and erases the prior holder. "a+" puts the position at EOF,
|
||||
# which on Windows breaks ``msvcrt.locking`` (it locks 1 byte at the
|
||||
# *current* position, so two contenders end up locking different bytes
|
||||
# and silently both acquire — observed as Windows-CI lock test
|
||||
# failures during #1264 development).
|
||||
if not os.path.exists(lock_path):
|
||||
# Touch atomically: O_CREAT|O_EXCL would fail if a concurrent
|
||||
# contender just created it, which is fine — we proceed to open.
|
||||
try:
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_WRONLY, 0o600)
|
||||
os.close(fd)
|
||||
except FileExistsError:
|
||||
pass
|
||||
lf = open(lock_path, "r+")
|
||||
acquired = False
|
||||
try:
|
||||
# Lock byte 0 explicitly. msvcrt.locking is byte-position dependent;
|
||||
# fcntl.flock is whole-file but the seek is harmless there.
|
||||
lf.seek(0)
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
try:
|
||||
msvcrt.locking(lf.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
acquired = True
|
||||
except OSError as exc:
|
||||
holder = _read_lock_holder(lf)
|
||||
raise MineAlreadyRunning(
|
||||
f"palace {resolved} is held by {holder}; "
|
||||
"wait for it to finish or stop the holder before retrying"
|
||||
) from exc
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
try:
|
||||
fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
acquired = True
|
||||
except BlockingIOError as exc:
|
||||
holder = _read_lock_holder(lf)
|
||||
raise MineAlreadyRunning(
|
||||
f"palace {resolved} is held by {holder}; "
|
||||
"wait for it to finish or stop the holder before retrying"
|
||||
) from exc
|
||||
# Record our own identity for any later contender's diagnostic message.
|
||||
_write_lock_holder(lf)
|
||||
_mark_held(palace_key)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_mark_released(palace_key)
|
||||
finally:
|
||||
if acquired:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
# Match the lock region: byte 0.
|
||||
lf.seek(0)
|
||||
msvcrt.locking(lf.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(lf, fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
lf.close()
|
||||
|
||||
|
||||
# Backward-compatible alias (previous patch iteration used a single global
|
||||
# lock). Kept so third-party callers that imported it continue to work; new
|
||||
# code should use `mine_palace_lock(palace_path)` for per-palace scoping.
|
||||
mine_global_lock = mine_palace_lock
|
||||
|
||||
|
||||
def file_already_mined(collection, source_file: str, check_mtime: bool = False) -> bool:
|
||||
"""Check if a file has already been filed in the palace.
|
||||
|
||||
|
||||
+289
-9
@@ -15,16 +15,60 @@ Enables queries like:
|
||||
No external graph DB needed — built from ChromaDB metadata.
|
||||
"""
|
||||
|
||||
# PEP 604 (``str | None``) needs 3.10+ at runtime; the project still
|
||||
# supports 3.9, so defer annotation evaluation to keep the union syntax
|
||||
# working on the older interpreter.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .config import MempalaceConfig
|
||||
from .config import MempalaceConfig, normalize_wing_name
|
||||
from .palace import get_collection as _get_palace_collection
|
||||
from .palace import mine_lock
|
||||
|
||||
logger = logging.getLogger("mempalace_graph")
|
||||
|
||||
|
||||
def _normalize_wing(wing: str | None) -> str | None:
|
||||
"""Normalize a wing name for consistent lookup.
|
||||
|
||||
``init`` stores wing names with hyphens and spaces replaced by underscores
|
||||
(e.g. ``mempalace_public``). Callers that pass the raw directory name
|
||||
(``mempalace-public``) would silently miss. This helper aligns the lookup
|
||||
key with the stored metadata.
|
||||
"""
|
||||
if wing is None:
|
||||
return None
|
||||
wing = wing.strip()
|
||||
if not wing:
|
||||
return None
|
||||
return normalize_wing_name(wing)
|
||||
|
||||
|
||||
# Module-level graph cache with TTL and write-invalidation.
|
||||
# Warm cache serves build_graph() in O(1); invalidate_graph_cache() clears on writes.
|
||||
_graph_cache_lock = threading.Lock()
|
||||
_graph_cache_nodes = None
|
||||
_graph_cache_edges = None
|
||||
_graph_cache_time = 0.0
|
||||
_GRAPH_CACHE_TTL = 60.0 # seconds — graph changes less often than metadata
|
||||
|
||||
|
||||
def invalidate_graph_cache():
|
||||
"""Clear the graph cache. Called from mcp_server.py on writes."""
|
||||
global _graph_cache_nodes, _graph_cache_edges, _graph_cache_time
|
||||
with _graph_cache_lock:
|
||||
_graph_cache_nodes = None
|
||||
_graph_cache_edges = None
|
||||
_graph_cache_time = 0.0
|
||||
|
||||
|
||||
def _get_collection(config=None):
|
||||
config = config or MempalaceConfig()
|
||||
@@ -42,10 +86,25 @@ def build_graph(col=None, config=None):
|
||||
"""
|
||||
Build the palace graph from ChromaDB metadata.
|
||||
|
||||
Returns cached result if fresh (within TTL). Cache is invalidated
|
||||
on writes via invalidate_graph_cache(). Thread-safe via _graph_cache_lock.
|
||||
|
||||
Note: warm cache ignores ``col`` and ``config`` arguments — this is
|
||||
intentional for the MCP server's single-palace use case. Callers
|
||||
switching collections should call ``invalidate_graph_cache()`` first.
|
||||
|
||||
Returns:
|
||||
nodes: dict of {room: {wings: set, halls: set, count: int}}
|
||||
edges: list of {room, wing_a, wing_b, hall} — one per tunnel crossing
|
||||
"""
|
||||
global _graph_cache_nodes, _graph_cache_edges, _graph_cache_time
|
||||
now = time.time()
|
||||
# NOTE: warm cache ignores col/config args — intentional for the MCP server's
|
||||
# single-palace use case. Callers switching collections must invalidate first.
|
||||
with _graph_cache_lock:
|
||||
if _graph_cache_nodes is not None and (now - _graph_cache_time) < _GRAPH_CACHE_TTL:
|
||||
return _graph_cache_nodes, _graph_cache_edges
|
||||
|
||||
if col is None:
|
||||
col = _get_collection(config)
|
||||
if not col:
|
||||
@@ -58,6 +117,16 @@ def build_graph(col=None, config=None):
|
||||
while offset < total:
|
||||
batch = col.get(limit=1000, offset=offset, include=["metadatas"])
|
||||
for meta in batch["metadatas"]:
|
||||
# ChromaDB can return ``None`` for drawers without metadata
|
||||
# (legacy data, partial writes — upstream #1020 territory).
|
||||
# Skip these silently rather than crash the whole graph
|
||||
# build — a single None drawer shouldn't take down /stats
|
||||
# or any caller of build_graph for the entire palace. Caught
|
||||
# 2026-04-25 by palace-daemon's verify-routes.sh smoke test
|
||||
# against the canonical 151K palace. Closes the same gap as
|
||||
# upstream #999 / fork PR #1094 in a different read path.
|
||||
if meta is None:
|
||||
continue
|
||||
room = meta.get("room", "")
|
||||
wing = meta.get("wing", "")
|
||||
hall = meta.get("hall", "")
|
||||
@@ -101,6 +170,14 @@ def build_graph(col=None, config=None):
|
||||
"dates": sorted(data["dates"])[-5:] if data["dates"] else [],
|
||||
}
|
||||
|
||||
# Only cache non-empty graphs so new data is picked up immediately
|
||||
# when the palace is first populated.
|
||||
if nodes:
|
||||
with _graph_cache_lock:
|
||||
_graph_cache_nodes = nodes
|
||||
_graph_cache_edges = edges
|
||||
_graph_cache_time = time.time()
|
||||
|
||||
return nodes, edges
|
||||
|
||||
|
||||
@@ -173,15 +250,18 @@ def find_tunnels(wing_a: str = None, wing_b: str = None, col=None, config=None):
|
||||
"""
|
||||
nodes, edges = build_graph(col, config)
|
||||
|
||||
norm_a = _normalize_wing(wing_a)
|
||||
norm_b = _normalize_wing(wing_b)
|
||||
|
||||
tunnels = []
|
||||
for room, data in nodes.items():
|
||||
wings = data["wings"]
|
||||
if len(wings) < 2:
|
||||
continue
|
||||
|
||||
if wing_a and wing_a not in wings:
|
||||
if norm_a and norm_a not in wings:
|
||||
continue
|
||||
if wing_b and wing_b not in wings:
|
||||
if norm_b and norm_b not in wings:
|
||||
continue
|
||||
|
||||
tunnels.append(
|
||||
@@ -194,6 +274,15 @@ def find_tunnels(wing_a: str = None, wing_b: str = None, col=None, config=None):
|
||||
}
|
||||
)
|
||||
|
||||
if not tunnels and (wing_a or wing_b):
|
||||
logger.warning(
|
||||
"No tunnels found for wing filter(s): wing_a=%r (normalized=%r), wing_b=%r (normalized=%r)",
|
||||
wing_a,
|
||||
norm_a,
|
||||
wing_b,
|
||||
norm_b,
|
||||
)
|
||||
|
||||
tunnels.sort(key=lambda x: -x["count"])
|
||||
return tunnels[:50]
|
||||
|
||||
@@ -271,8 +360,20 @@ def _save_tunnels(tunnels):
|
||||
Writes to ``tunnels.json.tmp`` then ``os.replace``s it into place, so
|
||||
a crash mid-write can never leave a partial/empty tunnels.json that
|
||||
silently wipes every tunnel on next read.
|
||||
|
||||
Also restricts the parent directory to 0o700 and the file to 0o600 —
|
||||
tunnels reveal cross-wing connections (which projects/people/rooms
|
||||
the user has explicitly linked) and should not be world-readable on
|
||||
shared Linux/multi-user systems. Matches the file-permission pattern
|
||||
established by #814 for the other sensitive palace files.
|
||||
"""
|
||||
os.makedirs(os.path.dirname(_TUNNEL_FILE), exist_ok=True)
|
||||
parent = os.path.dirname(_TUNNEL_FILE)
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
try:
|
||||
os.chmod(parent, 0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
# Windows / unsupported filesystems — tolerate.
|
||||
pass
|
||||
tmp_path = _TUNNEL_FILE + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(tunnels, f, indent=2)
|
||||
@@ -283,6 +384,10 @@ def _save_tunnels(tunnels):
|
||||
# Not all filesystems (or Windows file handles) support fsync — tolerate.
|
||||
pass
|
||||
os.replace(tmp_path, _TUNNEL_FILE)
|
||||
try:
|
||||
os.chmod(_TUNNEL_FILE, 0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
|
||||
def _endpoint_key(wing: str, room: str) -> str:
|
||||
@@ -320,6 +425,7 @@ def create_tunnel(
|
||||
label: str = "",
|
||||
source_drawer_id: str = None,
|
||||
target_drawer_id: str = None,
|
||||
kind: str = "explicit",
|
||||
):
|
||||
"""Create an explicit (symmetric) tunnel between two locations in the palace.
|
||||
|
||||
@@ -340,6 +446,11 @@ def create_tunnel(
|
||||
label: Description of the connection.
|
||||
source_drawer_id: Optional specific drawer ID.
|
||||
target_drawer_id: Optional specific drawer ID.
|
||||
kind: Tunnel category — ``"explicit"`` (default, user-created link
|
||||
between real rooms) or ``"topic"`` (auto-generated cross-wing
|
||||
topical link where rooms are synthetic ``topic:<name>``
|
||||
identifiers). Preserved on the stored dict so readers can
|
||||
distinguish real-room traversals from topic connections.
|
||||
|
||||
Returns:
|
||||
The stored tunnel dict.
|
||||
@@ -352,6 +463,9 @@ def create_tunnel(
|
||||
target_wing = _require_name(target_wing, "target_wing")
|
||||
target_room = _require_name(target_room, "target_room")
|
||||
|
||||
source_wing = _normalize_wing(source_wing)
|
||||
target_wing = _normalize_wing(target_wing)
|
||||
|
||||
tunnel_id = _canonical_tunnel_id(source_wing, source_room, target_wing, target_room)
|
||||
|
||||
tunnel = {
|
||||
@@ -359,6 +473,7 @@ def create_tunnel(
|
||||
"source": {"wing": source_wing, "room": source_room},
|
||||
"target": {"wing": target_wing, "room": target_room},
|
||||
"label": label,
|
||||
"kind": kind,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if source_drawer_id:
|
||||
@@ -391,9 +506,14 @@ def list_tunnels(wing: str = None):
|
||||
Returns tunnels where ``wing`` appears as either source or target
|
||||
(tunnels are symmetric, so either endpoint is a valid filter match).
|
||||
"""
|
||||
norm_wing = _normalize_wing(wing)
|
||||
tunnels = _load_tunnels()
|
||||
if wing:
|
||||
tunnels = [t for t in tunnels if t["source"]["wing"] == wing or t["target"]["wing"] == wing]
|
||||
if norm_wing:
|
||||
tunnels = [
|
||||
t
|
||||
for t in tunnels
|
||||
if t["source"]["wing"] == norm_wing or t["target"]["wing"] == norm_wing
|
||||
]
|
||||
return tunnels
|
||||
|
||||
|
||||
@@ -412,6 +532,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
Given a location (wing/room), finds all tunnels leading from or to it,
|
||||
and optionally fetches the connected drawer content.
|
||||
"""
|
||||
norm_wing = _normalize_wing(wing) or wing
|
||||
tunnels = _load_tunnels()
|
||||
connections = []
|
||||
|
||||
@@ -419,7 +540,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
src = t["source"]
|
||||
tgt = t["target"]
|
||||
|
||||
if src["wing"] == wing and src["room"] == room:
|
||||
if src["wing"] == norm_wing and src["room"] == room:
|
||||
connections.append(
|
||||
{
|
||||
"direction": "outgoing",
|
||||
@@ -430,7 +551,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
"tunnel_id": t["id"],
|
||||
}
|
||||
)
|
||||
elif tgt["wing"] == wing and tgt["room"] == room:
|
||||
elif tgt["wing"] == norm_wing and tgt["room"] == room:
|
||||
connections.append(
|
||||
{
|
||||
"direction": "incoming",
|
||||
@@ -442,6 +563,9 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
}
|
||||
)
|
||||
|
||||
if not connections:
|
||||
logger.warning("No explicit tunnels found for %s/%s", wing, room)
|
||||
|
||||
# If we have a collection, fetch drawer content for connected items
|
||||
if col and connections:
|
||||
drawer_ids = [c["drawer_id"] for c in connections if c.get("drawer_id")]
|
||||
@@ -454,6 +578,162 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
if did and did in drawer_map:
|
||||
c["drawer_preview"] = drawer_map[did][:300]
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Drawer preview hydration failed", exc_info=True)
|
||||
|
||||
return connections
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TOPIC TUNNELS — auto-link wings that share confirmed TOPIC labels
|
||||
# =============================================================================
|
||||
# When two wings have one or more confirmed topics in common (e.g. both
|
||||
# discuss "Angular" or "OpenAPI"), drop a symmetric tunnel between them.
|
||||
# Topics come from the LLM-refined ``TOPIC`` bucket in the per-project
|
||||
# ``entities.json`` and are persisted by wing in
|
||||
# ``~/.mempalace/known_entities.json`` under ``topics_by_wing``.
|
||||
#
|
||||
# Tunnels are created via the existing ``create_tunnel`` API so they share
|
||||
# storage and dedup with explicit tunnels. The room is a synthetic
|
||||
# ``topic:<original-casing>`` identifier — the ``topic:`` prefix namespaces
|
||||
# these tunnels away from literal folder-derived rooms so a wing with an
|
||||
# auto-detected "Angular" folder room and a "shared topic: Angular" tunnel
|
||||
# remain distinct at ``follow_tunnels`` / ``list_tunnels`` time. The prefix
|
||||
# is also visible to any LLM scanning the tunnel list. The ``kind: "topic"``
|
||||
# field on the stored dict gives callers a machine-readable discriminator.
|
||||
|
||||
TOPIC_ROOM_PREFIX = "topic:"
|
||||
|
||||
|
||||
def _normalize_topic(name: str) -> str:
|
||||
"""Lowercase + strip topics for case-insensitive overlap detection."""
|
||||
return str(name).strip().lower()
|
||||
|
||||
|
||||
def topic_room(name: str) -> str:
|
||||
"""Return the synthetic room identifier for a topic tunnel.
|
||||
|
||||
Prefixing avoids collisions with literal folder-derived rooms of the
|
||||
same name (e.g. a wing that has both an "Angular" folder room and an
|
||||
"Angular" topic tunnel).
|
||||
"""
|
||||
return f"{TOPIC_ROOM_PREFIX}{name}"
|
||||
|
||||
|
||||
def compute_topic_tunnels(
|
||||
topics_by_wing: dict,
|
||||
min_count: int = 1,
|
||||
label_prefix: str = "shared topic",
|
||||
) -> list[dict]:
|
||||
"""Create tunnels for every pair of wings that share >= ``min_count`` topics.
|
||||
|
||||
Args:
|
||||
topics_by_wing: ``{wing_name: [topic_name, ...]}`` mapping. Topic
|
||||
names are compared case-insensitively; the first observed
|
||||
casing is used for the tunnel room name.
|
||||
min_count: minimum number of overlapping topics required to drop
|
||||
any tunnel between a wing pair. ``1`` means a single shared
|
||||
topic is enough; bumping to e.g. ``2`` requires multiple
|
||||
overlaps and filters out coincidental single-topic links.
|
||||
label_prefix: human-readable string prefixed to the tunnel label.
|
||||
|
||||
Returns:
|
||||
List of tunnel dicts as returned by ``create_tunnel`` — one per
|
||||
(wing_a, wing_b, topic) triple that crossed the threshold. A
|
||||
wing-pair below ``min_count`` produces no tunnels at all (not
|
||||
even for its single shared topic).
|
||||
|
||||
No-op semantics:
|
||||
- empty/None ``topics_by_wing`` returns ``[]``.
|
||||
- wings whose topic list is empty are skipped.
|
||||
- ``min_count <= 0`` is clamped to 1.
|
||||
"""
|
||||
if not topics_by_wing:
|
||||
return []
|
||||
|
||||
min_count = max(1, int(min_count))
|
||||
|
||||
# Build a normalized-topic -> first-seen casing map per wing so we
|
||||
# preserve display casing while still doing case-insensitive overlap.
|
||||
wing_topics: dict[str, dict[str, str]] = {}
|
||||
for wing, names in topics_by_wing.items():
|
||||
if not isinstance(wing, str) or not wing.strip():
|
||||
continue
|
||||
if not isinstance(names, (list, tuple)):
|
||||
continue
|
||||
bucket: dict[str, str] = {}
|
||||
for n in names:
|
||||
if not isinstance(n, str):
|
||||
continue
|
||||
key = _normalize_topic(n)
|
||||
if not key:
|
||||
continue
|
||||
bucket.setdefault(key, n.strip())
|
||||
if bucket:
|
||||
wing_topics[wing.strip()] = bucket
|
||||
|
||||
wings = sorted(wing_topics.keys())
|
||||
created: list[dict] = []
|
||||
for i, wa in enumerate(wings):
|
||||
topics_a = wing_topics[wa]
|
||||
for wb in wings[i + 1 :]:
|
||||
topics_b = wing_topics[wb]
|
||||
shared_keys = set(topics_a.keys()) & set(topics_b.keys())
|
||||
if len(shared_keys) < min_count:
|
||||
continue
|
||||
# Stable sort for deterministic tunnel ordering across runs.
|
||||
for key in sorted(shared_keys):
|
||||
# Prefer the casing from whichever wing sorts first — both
|
||||
# are valid; this just keeps the displayed room consistent.
|
||||
topic_name = topics_a[key] if topics_a[key] else topics_b[key]
|
||||
room = topic_room(topic_name)
|
||||
tunnel = create_tunnel(
|
||||
source_wing=wa,
|
||||
source_room=room,
|
||||
target_wing=wb,
|
||||
target_room=room,
|
||||
label=f"{label_prefix}: {topic_name}",
|
||||
kind="topic",
|
||||
)
|
||||
created.append(tunnel)
|
||||
return created
|
||||
|
||||
|
||||
def topic_tunnels_for_wing(
|
||||
wing: str,
|
||||
topics_by_wing: dict,
|
||||
min_count: int = 1,
|
||||
label_prefix: str = "shared topic",
|
||||
) -> list[dict]:
|
||||
"""Compute topic tunnels involving a single wing.
|
||||
|
||||
Used by the miner to incrementally update tunnels for the wing that
|
||||
just finished mining without recomputing pairs that don't involve it.
|
||||
Returns the list of tunnels created or refreshed.
|
||||
"""
|
||||
if not topics_by_wing or not isinstance(wing, str) or not wing.strip():
|
||||
return []
|
||||
|
||||
wing = wing.strip()
|
||||
own = topics_by_wing.get(wing)
|
||||
if not isinstance(own, (list, tuple)) or not own:
|
||||
return []
|
||||
|
||||
# Restrict the pair-wise computation to (wing, other) pairs only by
|
||||
# building a 2-wing slice for each other wing. Reusing
|
||||
# ``compute_topic_tunnels`` keeps the threshold and casing logic in
|
||||
# one place.
|
||||
created: list[dict] = []
|
||||
for other, other_topics in topics_by_wing.items():
|
||||
if not isinstance(other, str) or not other.strip() or other == wing:
|
||||
continue
|
||||
if not isinstance(other_topics, (list, tuple)) or not other_topics:
|
||||
continue
|
||||
slice_map = {wing: list(own), other: list(other_topics)}
|
||||
created.extend(
|
||||
compute_topic_tunnels(
|
||||
slice_map,
|
||||
min_count=min_count,
|
||||
label_prefix=label_prefix,
|
||||
)
|
||||
)
|
||||
return created
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
"""
|
||||
project_scanner.py — Detect projects and people from real signal.
|
||||
|
||||
For a codebase with build manifests or git history, this beats regex-based
|
||||
entity detection by a wide margin: the project's own name is already written
|
||||
down in package.json / pyproject.toml / Cargo.toml / go.mod, and the people
|
||||
who worked on it are in `git log`.
|
||||
|
||||
This module is used as the primary signal in `mempalace init`. The regex
|
||||
detector in entity_detector.py stays as a fallback for prose-only folders
|
||||
(notes, research, writing).
|
||||
|
||||
Public:
|
||||
scan(root) -> (projects, people)
|
||||
to_detected_dict(projects, people) -> {people: [...], projects: [...], uncertain: []}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError: # pragma: no cover
|
||||
try:
|
||||
import tomli as tomllib # Python 3.9/3.10 backport
|
||||
except ImportError:
|
||||
tomllib = None # type: ignore
|
||||
|
||||
|
||||
SKIP_DIRS = {
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"env",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
"coverage",
|
||||
".terraform",
|
||||
"vendor",
|
||||
"target",
|
||||
".mempalace",
|
||||
".cache",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
}
|
||||
|
||||
MAX_DEPTH = 6
|
||||
MAX_COMMITS_PER_REPO = 1000
|
||||
GIT_TIMEOUT = 10
|
||||
|
||||
|
||||
# ==================== DATACLASSES ====================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectInfo:
|
||||
name: str
|
||||
repo_root: Path
|
||||
manifest: Optional[str] = None
|
||||
has_git: bool = False
|
||||
total_commits: int = 0
|
||||
user_commits: int = 0
|
||||
is_mine: bool = False
|
||||
|
||||
@property
|
||||
def confidence(self) -> float:
|
||||
if self.is_mine:
|
||||
return 0.99
|
||||
if self.has_git and self.total_commits > 0:
|
||||
return 0.7
|
||||
return 0.85 # manifest-only, no git
|
||||
|
||||
def to_signal(self) -> str:
|
||||
parts: list[str] = []
|
||||
if self.manifest:
|
||||
parts.append(self.manifest)
|
||||
if self.has_git:
|
||||
if self.is_mine and self.user_commits:
|
||||
parts.append(f"{self.user_commits} of your commits")
|
||||
elif self.user_commits:
|
||||
parts.append(f"{self.user_commits}/{self.total_commits} yours")
|
||||
else:
|
||||
parts.append(f"{self.total_commits} commits (none by you)")
|
||||
return ", ".join(parts) or "repo"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonInfo:
|
||||
name: str
|
||||
total_commits: int = 0
|
||||
emails: set[str] = field(default_factory=set)
|
||||
repos: set[str] = field(default_factory=set)
|
||||
|
||||
@property
|
||||
def confidence(self) -> float:
|
||||
if self.total_commits >= 100 or len(self.repos) >= 3:
|
||||
return 0.99
|
||||
if self.total_commits >= 20:
|
||||
return 0.85
|
||||
return 0.65
|
||||
|
||||
def to_signal(self) -> str:
|
||||
r = len(self.repos)
|
||||
return f"{self.total_commits} commit{'s' if self.total_commits != 1 else ''} across {r} repo{'s' if r != 1 else ''}"
|
||||
|
||||
|
||||
# ==================== MANIFEST PARSING ====================
|
||||
|
||||
|
||||
def _parse_package_json(path: Path) -> Optional[str]:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
name = data.get("name")
|
||||
return name if isinstance(name, str) and name else None
|
||||
|
||||
|
||||
def _parse_toml(path: Path) -> dict:
|
||||
if tomllib is None:
|
||||
return {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_pyproject(path: Path) -> Optional[str]:
|
||||
data = _parse_toml(path)
|
||||
name = data.get("project", {}).get("name")
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
name = data.get("tool", {}).get("poetry", {}).get("name")
|
||||
return name if isinstance(name, str) and name else None
|
||||
|
||||
|
||||
def _parse_cargo(path: Path) -> Optional[str]:
|
||||
data = _parse_toml(path)
|
||||
name = data.get("package", {}).get("name")
|
||||
return name if isinstance(name, str) and name else None
|
||||
|
||||
|
||||
def _parse_gomod(path: Path) -> Optional[str]:
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("module "):
|
||||
mod = line.split(None, 1)[1].strip()
|
||||
return mod.split("/")[-1] or None
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
MANIFEST_PRIORITY = {
|
||||
"pyproject.toml": 0,
|
||||
"package.json": 1,
|
||||
"Cargo.toml": 2,
|
||||
"go.mod": 3,
|
||||
}
|
||||
# Sentinel so unknown manifests always sort after the known manifest types above.
|
||||
UNKNOWN_MANIFEST_PRIORITY = max(MANIFEST_PRIORITY.values()) + 1
|
||||
MANIFEST_PARSERS = {
|
||||
"package.json": _parse_package_json,
|
||||
"pyproject.toml": _parse_pyproject,
|
||||
"Cargo.toml": _parse_cargo,
|
||||
"go.mod": _parse_gomod,
|
||||
}
|
||||
|
||||
|
||||
# ==================== GIT HELPERS ====================
|
||||
|
||||
|
||||
def _run_git(cwd: Path, *args: str, timeout: int = GIT_TIMEOUT) -> str:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "-C", str(cwd), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
|
||||
def _git_user_identity(repo: Path) -> tuple[str, str]:
|
||||
"""Return (name, email) for this repo, falling back to global config."""
|
||||
name = _run_git(repo, "config", "user.name", timeout=2).strip()
|
||||
email = _run_git(repo, "config", "user.email", timeout=2).strip()
|
||||
return name, email
|
||||
|
||||
|
||||
def _global_git_identity() -> tuple[str, str]:
|
||||
try:
|
||||
n = subprocess.run(
|
||||
["git", "config", "--global", "user.name"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
e = subprocess.run(
|
||||
["git", "config", "--global", "user.email"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
return n, e
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return "", ""
|
||||
|
||||
|
||||
def _git_authors(repo: Path) -> list[tuple[str, str]]:
|
||||
out = _run_git(
|
||||
repo,
|
||||
"log",
|
||||
f"--max-count={MAX_COMMITS_PER_REPO}",
|
||||
"--format=%aN|%aE",
|
||||
)
|
||||
result = []
|
||||
for line in out.splitlines():
|
||||
if "|" in line:
|
||||
name, email = line.split("|", 1)
|
||||
result.append((name.strip(), email.strip()))
|
||||
return result
|
||||
|
||||
|
||||
# ==================== BOT / NAME FILTERING ====================
|
||||
|
||||
|
||||
_BOT_NAME_PATTERNS = [
|
||||
r"\[bot\]",
|
||||
r"^dependabot",
|
||||
r"^renovate",
|
||||
r"^github-actions",
|
||||
r"^actions-user",
|
||||
r"-bot$",
|
||||
r"\bbot$", # catches "PR Bot", "Release Bot", etc. Not "robot" (no \b)
|
||||
r"^bot-",
|
||||
r"^snyk",
|
||||
r"^greenkeeper",
|
||||
r"^semantic-release",
|
||||
r"^allcontributors",
|
||||
r"-autoroll$",
|
||||
r"^auto-format",
|
||||
r"^pre-commit-ci",
|
||||
]
|
||||
_BOT_EMAIL_PATTERNS = [
|
||||
# `@users.noreply.github.com` is GitHub's privacy-protected human email —
|
||||
# do NOT filter it. Real bots identify themselves via the display name
|
||||
# (usually containing "[bot]"), which is caught by _BOT_NAME_PATTERNS.
|
||||
r"bot@",
|
||||
r"-bot@",
|
||||
r"\[bot\]@",
|
||||
]
|
||||
|
||||
_BOT_RE_NAMES = [re.compile(p) for p in _BOT_NAME_PATTERNS]
|
||||
_BOT_RE_EMAILS = [re.compile(p) for p in _BOT_EMAIL_PATTERNS]
|
||||
|
||||
|
||||
def _is_bot(name: str, email: str) -> bool:
|
||||
ln, le = name.lower(), email.lower()
|
||||
return any(rx.search(ln) for rx in _BOT_RE_NAMES) or any(rx.search(le) for rx in _BOT_RE_EMAILS)
|
||||
|
||||
|
||||
def _looks_like_real_name(name: str) -> bool:
|
||||
"""Heuristic: a human's name has a space and at least two title-cased parts.
|
||||
|
||||
Filters out handles (lowercase, digits, one-token usernames).
|
||||
"""
|
||||
if not name or " " not in name:
|
||||
return False
|
||||
parts = name.split()
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
# First and last parts must start with an uppercase letter
|
||||
return parts[0][:1].isupper() and parts[-1][:1].isupper()
|
||||
|
||||
|
||||
# ==================== DIRECTORY WALK ====================
|
||||
|
||||
|
||||
def _walk(root: Path, max_depth: int = MAX_DEPTH):
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
|
||||
rel = Path(dirpath).relative_to(root)
|
||||
depth = 0 if rel == Path(".") else len(rel.parts)
|
||||
if depth > max_depth:
|
||||
dirs.clear()
|
||||
continue
|
||||
yield Path(dirpath), dirs, files
|
||||
|
||||
|
||||
def _has_git_marker(path: Path) -> bool:
|
||||
git_path = path / ".git"
|
||||
return git_path.is_dir() or git_path.is_file()
|
||||
|
||||
|
||||
def _manifest_sort_key(entry: tuple[str, str, Path], repo_root: Path) -> tuple[int, int, str]:
|
||||
"""Sort manifests by shallowest path first, then known manifest priority,
|
||||
then lexicographic path for deterministic tie-breaking.
|
||||
"""
|
||||
manifest_file, _project_name, manifest_dir = entry
|
||||
try:
|
||||
rel = manifest_dir.relative_to(repo_root)
|
||||
depth = len(rel.parts)
|
||||
rel_str = rel.as_posix()
|
||||
except ValueError:
|
||||
depth = MAX_DEPTH + 1
|
||||
rel_str = manifest_dir.as_posix()
|
||||
return (depth, MANIFEST_PRIORITY.get(manifest_file, UNKNOWN_MANIFEST_PRIORITY), rel_str)
|
||||
|
||||
|
||||
def find_git_repos(root: Path, max_depth: int = MAX_DEPTH) -> list[Path]:
|
||||
"""Return git repo roots under `root` (including root itself if it's a repo)."""
|
||||
root = root.resolve()
|
||||
repos: list[Path] = []
|
||||
if _has_git_marker(root):
|
||||
# Root is a repo — still walk for nested repos (submodules, etc.)
|
||||
repos.append(root)
|
||||
for dirpath, dirs, _ in _walk(root, max_depth):
|
||||
if dirpath == root:
|
||||
continue
|
||||
if _has_git_marker(dirpath):
|
||||
repos.append(dirpath)
|
||||
dirs.clear() # don't descend into this repo's contents from here
|
||||
return repos
|
||||
|
||||
|
||||
def _collect_manifest_names(repo_root: Path) -> list[tuple[str, str, Path]]:
|
||||
"""Return (manifest_filename, project_name, dirpath) within a repo.
|
||||
|
||||
Does not descend into nested git repos.
|
||||
"""
|
||||
found: list[tuple[str, str, Path]] = []
|
||||
for dirpath, dirs, files in _walk(repo_root):
|
||||
if dirpath != repo_root and _has_git_marker(dirpath):
|
||||
dirs.clear()
|
||||
continue
|
||||
for fname in files:
|
||||
parser = MANIFEST_PARSERS.get(fname)
|
||||
if not parser:
|
||||
continue
|
||||
name = parser(dirpath / fname)
|
||||
if name:
|
||||
found.append((fname, name, dirpath))
|
||||
return sorted(found, key=lambda entry: _manifest_sort_key(entry, repo_root))
|
||||
|
||||
|
||||
# ==================== MAIN SCAN ====================
|
||||
|
||||
|
||||
class _UnionFind:
|
||||
"""Minimal union-find for (name, email) identity resolution."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.parent: dict = {}
|
||||
|
||||
def find(self, x):
|
||||
if x not in self.parent:
|
||||
self.parent[x] = x
|
||||
return x
|
||||
root = x
|
||||
while self.parent[root] != root:
|
||||
root = self.parent[root]
|
||||
while self.parent[x] != root:
|
||||
self.parent[x], x = root, self.parent[x]
|
||||
return root
|
||||
|
||||
def union(self, a, b) -> None:
|
||||
ra, rb = self.find(a), self.find(b)
|
||||
if ra != rb:
|
||||
self.parent[ra] = rb
|
||||
|
||||
|
||||
def _dedupe_people(
|
||||
all_commits: list[tuple[str, str, str]],
|
||||
) -> dict[str, PersonInfo]:
|
||||
"""Group commits by identity. Two commits are the same person if they
|
||||
share a name OR an email. Display name = most frequent non-bot variant.
|
||||
|
||||
``all_commits`` is a list of (name, email, repo_str) triples from every repo.
|
||||
"""
|
||||
uf = _UnionFind()
|
||||
for name, email, _repo in all_commits:
|
||||
uf.union(("name", name), ("email", email) if email else ("name", name))
|
||||
|
||||
# Aggregate by component root
|
||||
component_commits: dict = {}
|
||||
for name, email, repo in all_commits:
|
||||
key = uf.find(("name", name))
|
||||
entry = component_commits.setdefault(
|
||||
key, {"name_counts": {}, "emails": set(), "repos": set(), "total": 0}
|
||||
)
|
||||
entry["name_counts"][name] = entry["name_counts"].get(name, 0) + 1
|
||||
if email:
|
||||
entry["emails"].add(email)
|
||||
entry["repos"].add(repo)
|
||||
entry["total"] += 1
|
||||
|
||||
# Pick display name per component: the most-frequent variant that looks
|
||||
# like a real name; fall back to most-frequent overall.
|
||||
people: dict[str, PersonInfo] = {}
|
||||
for _key, entry in component_commits.items():
|
||||
candidates = sorted(entry["name_counts"].items(), key=lambda x: -x[1])
|
||||
display = next(
|
||||
(n for n, _ in candidates if _looks_like_real_name(n)),
|
||||
candidates[0][0],
|
||||
)
|
||||
if not _looks_like_real_name(display):
|
||||
continue # Skip handles and single-token names
|
||||
# If we already have this display (rare — distinct components with the
|
||||
# same chosen display), merge into the existing entry.
|
||||
existing = people.get(display)
|
||||
if existing:
|
||||
existing.total_commits += entry["total"]
|
||||
existing.emails.update(entry["emails"])
|
||||
existing.repos.update(entry["repos"])
|
||||
else:
|
||||
people[display] = PersonInfo(
|
||||
name=display,
|
||||
total_commits=entry["total"],
|
||||
emails=set(entry["emails"]),
|
||||
repos=set(entry["repos"]),
|
||||
)
|
||||
return people
|
||||
|
||||
|
||||
def scan(root: str | os.PathLike) -> tuple[list[ProjectInfo], list[PersonInfo]]:
|
||||
"""Scan `root` for projects and people. Returns (projects, people) sorted."""
|
||||
root_path = Path(root).expanduser().resolve()
|
||||
if not root_path.is_dir():
|
||||
return [], []
|
||||
|
||||
repos = find_git_repos(root_path)
|
||||
|
||||
# Identify current user from first repo's git config, fall back to global
|
||||
me_name, me_email = "", ""
|
||||
if repos:
|
||||
me_name, me_email = _git_user_identity(repos[0])
|
||||
if not me_name and not me_email:
|
||||
me_name, me_email = _global_git_identity()
|
||||
|
||||
projects: dict[str, ProjectInfo] = {}
|
||||
all_commits: list[tuple[str, str, str]] = []
|
||||
|
||||
for repo in repos:
|
||||
manifests = _collect_manifest_names(repo)
|
||||
if manifests:
|
||||
manifest_file, proj_name, _ = manifests[0]
|
||||
else:
|
||||
manifest_file, proj_name = None, repo.name
|
||||
|
||||
authors = _git_authors(repo)
|
||||
non_bot_authors = [(name, email) for name, email in authors if not _is_bot(name, email)]
|
||||
total_commits = len(non_bot_authors)
|
||||
user_commits = 0
|
||||
author_counts: dict[str, int] = {}
|
||||
for name, email in non_bot_authors:
|
||||
author_counts[name] = author_counts.get(name, 0) + 1
|
||||
all_commits.append((name, email, str(repo)))
|
||||
if (me_name and name == me_name) or (me_email and email == me_email):
|
||||
user_commits += 1
|
||||
|
||||
is_mine = False
|
||||
if user_commits > 0:
|
||||
sorted_authors = sorted(author_counts.items(), key=lambda x: -x[1])
|
||||
top5 = {n for n, _ in sorted_authors[:5]}
|
||||
if me_name and me_name in top5:
|
||||
is_mine = True
|
||||
elif total_commits and user_commits / total_commits >= 0.10:
|
||||
is_mine = True
|
||||
elif user_commits >= 20:
|
||||
is_mine = True
|
||||
|
||||
proj = ProjectInfo(
|
||||
name=proj_name,
|
||||
repo_root=repo,
|
||||
manifest=manifest_file,
|
||||
has_git=True,
|
||||
total_commits=total_commits,
|
||||
user_commits=user_commits,
|
||||
is_mine=is_mine,
|
||||
)
|
||||
existing = projects.get(proj_name)
|
||||
if existing is None or proj.user_commits > existing.user_commits:
|
||||
projects[proj_name] = proj
|
||||
|
||||
people = _dedupe_people(all_commits)
|
||||
|
||||
# Handle case: root has manifests but no git repo anywhere
|
||||
if not repos:
|
||||
manifests = _collect_manifest_names(root_path)
|
||||
for manifest_file, proj_name, _dirpath in manifests:
|
||||
if proj_name in projects:
|
||||
continue
|
||||
projects[proj_name] = ProjectInfo(
|
||||
name=proj_name,
|
||||
repo_root=root_path,
|
||||
manifest=manifest_file,
|
||||
has_git=False,
|
||||
)
|
||||
|
||||
project_list = sorted(
|
||||
projects.values(),
|
||||
key=lambda p: (not p.is_mine, -p.user_commits, -p.total_commits, p.name),
|
||||
)
|
||||
people_list = sorted(people.values(), key=lambda p: -p.total_commits)
|
||||
|
||||
return project_list, people_list
|
||||
|
||||
|
||||
# ==================== ADAPTER ====================
|
||||
|
||||
|
||||
def to_detected_dict(
|
||||
projects: list[ProjectInfo],
|
||||
people: list[PersonInfo],
|
||||
project_cap: int = 15,
|
||||
people_cap: int = 15,
|
||||
) -> dict:
|
||||
"""Convert scan results into the dict shape produced by entity_detector.detect_entities."""
|
||||
proj_entries = [
|
||||
{
|
||||
"name": p.name,
|
||||
"type": "project",
|
||||
"confidence": round(p.confidence, 2),
|
||||
"frequency": p.user_commits or p.total_commits,
|
||||
"signals": [p.to_signal()],
|
||||
}
|
||||
for p in projects[:project_cap]
|
||||
]
|
||||
people_entries = [
|
||||
{
|
||||
"name": p.name,
|
||||
"type": "person",
|
||||
"confidence": round(p.confidence, 2),
|
||||
"frequency": p.total_commits,
|
||||
"signals": [p.to_signal()],
|
||||
}
|
||||
for p in people[:people_cap]
|
||||
]
|
||||
return {
|
||||
"people": people_entries,
|
||||
"projects": proj_entries,
|
||||
"topics": [],
|
||||
"uncertain": [],
|
||||
}
|
||||
|
||||
|
||||
# ==================== MERGE WITH REGEX DETECTOR ====================
|
||||
|
||||
|
||||
def _merge_detected(primary: dict, secondary: dict, drop_secondary_uncertain: bool = False) -> dict:
|
||||
"""Merge two detected dicts. Primary entries win on name conflict.
|
||||
|
||||
Dedup is case-insensitive so "mempalace" (manifest name) absorbs "MemPalace"
|
||||
(docs/prose reference) instead of surfacing both.
|
||||
|
||||
If ``drop_secondary_uncertain`` is True, the secondary's uncertain bucket is
|
||||
dropped entirely — useful when the primary signal is strong (real repo
|
||||
found) and we'd rather not ask the user to adjudicate prose-regex noise.
|
||||
"""
|
||||
seen = {e["name"].lower() for cat in primary.values() for e in cat}
|
||||
merged = {k: list(v) for k, v in primary.items()}
|
||||
for cat_key in ("people", "projects", "topics", "uncertain"):
|
||||
if cat_key == "uncertain" and drop_secondary_uncertain:
|
||||
continue
|
||||
for e in secondary.get(cat_key, []):
|
||||
if e["name"].lower() in seen:
|
||||
continue
|
||||
merged.setdefault(cat_key, []).append(e)
|
||||
seen.add(e["name"].lower())
|
||||
return merged
|
||||
|
||||
|
||||
def discover_entities(
|
||||
project_dir: str | os.PathLike,
|
||||
languages: tuple = ("en",),
|
||||
prose_file_cap: int = 10,
|
||||
project_cap: int = 15,
|
||||
people_cap: int = 15,
|
||||
llm_provider: object = None,
|
||||
show_progress: bool = True,
|
||||
corpus_origin: dict | None = None,
|
||||
) -> dict:
|
||||
"""Top-level entity discovery: real signals first, prose detection second.
|
||||
|
||||
Returns the same dict shape as ``entity_detector.detect_entities`` so it
|
||||
plugs into ``confirm_entities`` unchanged.
|
||||
|
||||
Order of signal preference:
|
||||
1. Package manifests (package.json, pyproject.toml, Cargo.toml, go.mod)
|
||||
→ canonical project names
|
||||
2. Git commit authors → real people with real commit counts
|
||||
3. Claude Code conversation dirs (~/.claude/projects/) → per-session
|
||||
project names (pulled from each session's ``cwd`` metadata)
|
||||
4. Regex entity detection on prose files → supplementary names only
|
||||
mentioned in docs/notes (not code)
|
||||
5. Optional LLM refinement pass — reclassifies ambiguous candidates
|
||||
using the caller-supplied provider
|
||||
6. Optional corpus-origin persona filter — when the corpus is
|
||||
identified as AI-dialogue, candidates whose name matches an
|
||||
agent_persona_name are moved to an ``agent_personas`` bucket
|
||||
instead of being reported as people.
|
||||
|
||||
Passing ``llm_provider`` enables phase-2 refinement. The caller is
|
||||
responsible for constructing the provider (``llm_client.get_provider``)
|
||||
and confirming availability. Refinement is blocking-interactive:
|
||||
progress prints to stderr; Ctrl-C returns partial results.
|
||||
|
||||
Passing ``corpus_origin`` enables corpus-origin persona reclassification.
|
||||
The expected shape is the dict written by ``mempalace init`` to
|
||||
``<palace>/.mempalace/origin.json`` (see ``corpus_origin.py``).
|
||||
"""
|
||||
projects, people = scan(project_dir)
|
||||
|
||||
# If the target is a Claude Code conversations root, extract per-project
|
||||
# entries from there too. Same ProjectInfo shape, so dedup logic works.
|
||||
from mempalace.convo_scanner import is_claude_projects_root, scan_claude_projects
|
||||
|
||||
root_path = Path(project_dir).expanduser().resolve()
|
||||
if is_claude_projects_root(root_path):
|
||||
convo_projects = scan_claude_projects(root_path)
|
||||
# Dedup by name against the git-manifest list, preferring entries
|
||||
# with more user_commits as signal strength. Keyed case-insensitively
|
||||
# so a `pyproject.toml` name like `mempalace` and a Claude Code
|
||||
# `cwd` variant like `MemPalace` collapse into one entry — matches
|
||||
# the case-insensitive dedup used in `_merge_detected` and
|
||||
# `miner.add_to_known_entities`.
|
||||
by_name: dict[str, ProjectInfo] = {p.name.lower(): p for p in projects}
|
||||
for cp in convo_projects:
|
||||
key = cp.name.lower()
|
||||
existing = by_name.get(key)
|
||||
if existing is None or cp.user_commits > existing.user_commits:
|
||||
by_name[key] = cp
|
||||
projects = sorted(
|
||||
by_name.values(),
|
||||
key=lambda p: (not p.is_mine, -p.user_commits, -p.total_commits, p.name),
|
||||
)
|
||||
|
||||
real_signal = to_detected_dict(projects, people, project_cap=project_cap, people_cap=people_cap)
|
||||
|
||||
# Secondary pass: prose-only extraction catches names mentioned in docs
|
||||
# that never made a commit (e.g. a stakeholder or family member in notes).
|
||||
from mempalace.entity_detector import detect_entities, scan_for_detection
|
||||
|
||||
prose_files = scan_for_detection(str(project_dir), max_files=prose_file_cap)
|
||||
prose_detected = (
|
||||
detect_entities(prose_files, languages=languages)
|
||||
if prose_files
|
||||
else {"people": [], "projects": [], "topics": [], "uncertain": []}
|
||||
)
|
||||
|
||||
# Without LLM refinement, suppress regex "uncertain" noise when real
|
||||
# manifest/git signal exists. With LLM refinement enabled, keep those
|
||||
# candidates so the model can promote real entities or drop common words.
|
||||
has_real_signal = bool(projects) or bool(people)
|
||||
merged = _merge_detected(
|
||||
real_signal,
|
||||
prose_detected,
|
||||
drop_secondary_uncertain=has_real_signal and llm_provider is None,
|
||||
)
|
||||
|
||||
# Optional LLM refinement pass (when an llm_provider was supplied).
|
||||
if llm_provider is not None:
|
||||
from mempalace.llm_refine import collect_corpus_text, refine_entities
|
||||
|
||||
corpus = collect_corpus_text(str(project_dir))
|
||||
result = refine_entities(
|
||||
merged,
|
||||
corpus,
|
||||
llm_provider,
|
||||
show_progress=show_progress,
|
||||
allow_project_promotions=not has_real_signal,
|
||||
corpus_origin=corpus_origin,
|
||||
)
|
||||
if show_progress:
|
||||
status_bits = []
|
||||
if result.cancelled:
|
||||
status_bits.append("cancelled")
|
||||
if result.reclassified:
|
||||
status_bits.append(f"reclassified {result.reclassified}")
|
||||
if result.dropped:
|
||||
status_bits.append(f"dropped {result.dropped}")
|
||||
if result.errors:
|
||||
status_bits.append(f"{len(result.errors)} batch error(s)")
|
||||
if status_bits:
|
||||
import sys as _sys
|
||||
|
||||
print(f" LLM refine: {', '.join(status_bits)}", file=_sys.stderr)
|
||||
merged = result.merged
|
||||
|
||||
# Corpus-origin persona reclassification — applied last so it sweeps
|
||||
# candidates contributed by every upstream source (manifests, git authors,
|
||||
# prose, LLM refinement). Idempotent: no corpus_origin → exact v3.3.3 shape.
|
||||
if corpus_origin is not None:
|
||||
from mempalace.entity_detector import _apply_corpus_origin
|
||||
|
||||
merged = _apply_corpus_origin(merged, corpus_origin)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
# ==================== CLI ====================
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||
projs, ppl = scan(target)
|
||||
print(f"=== PROJECTS ({len(projs)}) ===")
|
||||
for p in projs[:30]:
|
||||
mark = "★" if p.is_mine else " "
|
||||
print(f" {mark} {p.name:35} conf={p.confidence:.2f} {p.to_signal()}")
|
||||
print()
|
||||
print(f"=== PEOPLE ({len(ppl)}) ===")
|
||||
for p in ppl[:30]:
|
||||
print(f" {p.name:30} conf={p.confidence:.2f} {p.to_signal()}")
|
||||
+1183
-39
File diff suppressed because it is too large
Load Diff
@@ -202,7 +202,7 @@ def detect_rooms_from_files(project_dir: str) -> list:
|
||||
|
||||
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build"}
|
||||
|
||||
for root, dirs, filenames in os.walk(project_path):
|
||||
for _root, dirs, filenames in os.walk(project_path):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||
for filename in filenames:
|
||||
name_lower = filename.lower().replace("-", "_").replace(" ", "_")
|
||||
@@ -303,8 +303,10 @@ def save_config(project_dir: str, project_name: str, rooms: list):
|
||||
|
||||
def detect_rooms_local(project_dir: str, yes: bool = False):
|
||||
"""Main entry point for local setup."""
|
||||
from .config import normalize_wing_name
|
||||
|
||||
project_path = Path(project_dir).expanduser().resolve()
|
||||
project_name = project_path.name.lower().replace(" ", "_").replace("-", "_")
|
||||
project_name = normalize_wing_name(project_path.name)
|
||||
|
||||
if not project_path.exists():
|
||||
print(f"ERROR: Directory not found: {project_dir}")
|
||||
|
||||
+522
-26
@@ -11,7 +11,9 @@ hide drawers the direct path would have found.
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .palace import get_closets_collection, get_collection
|
||||
@@ -30,22 +32,30 @@ class SearchError(Exception):
|
||||
_TOKEN_RE = re.compile(r"\w{2,}", re.UNICODE)
|
||||
|
||||
|
||||
def _first_or_empty(results: dict, key: str) -> list:
|
||||
"""Return the first inner list of a ChromaDB query result, or [].
|
||||
def _first_or_empty(results, key: str) -> list:
|
||||
"""Return the first inner list of a query result field, or [].
|
||||
|
||||
ChromaDB returns shapes like ``{"documents": [["a", "b"]], ...}`` for a
|
||||
successful query, but ``{"documents": [], ...}`` (empty outer list) when
|
||||
the collection is empty or the filter excludes everything. Indexing
|
||||
``[0]`` blindly raises IndexError in that case (issue #195).
|
||||
Accepts both the typed :class:`QueryResult` (attribute access) and the
|
||||
pre-typed chroma dict shape; this polymorphism is retained so test mocks
|
||||
still work and callers mid-migration do not crash. Preserves the empty-
|
||||
collection semantics from issue #195: when no queries returned hits, the
|
||||
outer list may be empty and indexing ``[0]`` would raise.
|
||||
"""
|
||||
outer = results.get(key)
|
||||
outer = getattr(results, key, None) if not isinstance(results, dict) else results.get(key)
|
||||
if not outer:
|
||||
return []
|
||||
return outer[0] or []
|
||||
|
||||
|
||||
def _tokenize(text: str) -> list:
|
||||
"""Lowercase + strip to alphanumeric tokens of length ≥ 2."""
|
||||
"""Lowercase + strip to alphanumeric tokens of length ≥ 2.
|
||||
|
||||
Tolerates ``None`` documents — Chroma can return ``None`` in the
|
||||
``documents`` field for drawers without text content, which would
|
||||
otherwise raise ``AttributeError`` mid-rerank.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
return _TOKEN_RE.findall(text.lower())
|
||||
|
||||
|
||||
@@ -124,6 +134,11 @@ def _hybrid_rank(
|
||||
themselves. Since the absolute scale is unbounded, BM25 is min-max
|
||||
normalized within the candidate set so weights are commensurable.
|
||||
|
||||
Candidates with ``distance=None`` are treated as vector-unknown
|
||||
(no vector signal available) and scored on BM25 contribution alone.
|
||||
Used by candidate-union mode to merge BM25-only candidates that the
|
||||
vector index didn't surface.
|
||||
|
||||
Mutates each result dict to add ``bm25_score`` and reorders the list
|
||||
in place. Returns the same list for convenience.
|
||||
"""
|
||||
@@ -137,7 +152,11 @@ def _hybrid_rank(
|
||||
|
||||
scored = []
|
||||
for r, raw, norm in zip(results, bm25_raw, bm25_norm):
|
||||
vec_sim = max(0.0, 1.0 - r.get("distance", 1.0))
|
||||
distance = r.get("distance")
|
||||
if distance is None:
|
||||
vec_sim = 0.0
|
||||
else:
|
||||
vec_sim = max(0.0, 1.0 - distance)
|
||||
r["bm25_score"] = round(raw, 3)
|
||||
scored.append((vector_weight * vec_sim + bm25_weight * norm, r))
|
||||
|
||||
@@ -209,7 +228,7 @@ def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, ra
|
||||
return {"text": matched_doc, "drawer_index": chunk_idx, "total_drawers": None}
|
||||
|
||||
indexed_docs = []
|
||||
for doc, meta in zip(neighbors.get("documents") or [], neighbors.get("metadatas") or []):
|
||||
for doc, meta in zip(neighbors.documents, neighbors.metadatas):
|
||||
ci = meta.get("chunk_index")
|
||||
if isinstance(ci, int):
|
||||
indexed_docs.append((ci, doc))
|
||||
@@ -224,10 +243,9 @@ def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, ra
|
||||
total_drawers = None
|
||||
try:
|
||||
all_meta = drawers_col.get(where={"source_file": src}, include=["metadatas"])
|
||||
ids = all_meta.get("ids") or []
|
||||
total_drawers = len(ids) if ids else None
|
||||
total_drawers = len(all_meta.ids) if all_meta.ids else None
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("total_drawers lookup failed for %s", src, exc_info=True)
|
||||
|
||||
return {
|
||||
"text": combined_text,
|
||||
@@ -236,6 +254,42 @@ def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, ra
|
||||
}
|
||||
|
||||
|
||||
def _warn_if_legacy_metric(col) -> None:
|
||||
"""Print a one-line notice if the palace was created without
|
||||
``hnsw:space=cosine``.
|
||||
|
||||
ChromaDB's default is L2 (Euclidean), under which cosine-based
|
||||
similarity interpretation falls apart — distances routinely exceed
|
||||
1.0 and the display ``max(0, 1 - dist)`` floors every result to 0.
|
||||
Legacy palaces (mined before this metadata was consistently set)
|
||||
need ``mempalace repair`` to rebuild with the correct metric.
|
||||
|
||||
The warning fires only for palaces that clearly have the wrong
|
||||
metric; palaces with no metadata table at all (empty dict) also
|
||||
fall under this check since that is the signal of a pre-metadata
|
||||
palace.
|
||||
"""
|
||||
try:
|
||||
meta = getattr(col, "metadata", None)
|
||||
except Exception:
|
||||
return
|
||||
if not isinstance(meta, dict):
|
||||
return
|
||||
space = meta.get("hnsw:space")
|
||||
if space == "cosine":
|
||||
return
|
||||
# Either missing or set to something else — both are suspect.
|
||||
import sys as _sys
|
||||
|
||||
detail = f"hnsw:space={space!r}" if space else "no hnsw:space metadata"
|
||||
print(
|
||||
f"\n NOTICE: this palace was created without cosine distance ({detail}).\n"
|
||||
" Semantic similarity scores will not be meaningful.\n"
|
||||
" Run `mempalace repair` to rebuild the index with the correct metric.",
|
||||
file=_sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def search(query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5):
|
||||
"""
|
||||
Search the palace. Returns verbatim drawer content.
|
||||
@@ -243,10 +297,14 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
|
||||
"""
|
||||
try:
|
||||
col = get_collection(palace_path, create=False)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
print(f"\n No palace found at {palace_path}")
|
||||
print(" Run: mempalace init <dir> then mempalace mine <dir>")
|
||||
raise SearchError(f"No palace found at {palace_path}")
|
||||
raise SearchError(f"No palace found at {palace_path}") from e
|
||||
|
||||
# Alert the user if this palace predates hnsw:space=cosine being set on
|
||||
# creation — their similarity scores will be junk until they run repair.
|
||||
_warn_if_legacy_metric(col)
|
||||
|
||||
where = build_where_filter(wing, room)
|
||||
|
||||
@@ -273,6 +331,20 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
|
||||
print(f'\n No results found for: "{query}"')
|
||||
return
|
||||
|
||||
# Pure-cosine retrieval on the CLI path was missing lexical matches:
|
||||
# a drawer whose text contains every query term can still score distance
|
||||
# >= 1.0 against the natural-language query when the drawer is a
|
||||
# mechanical artifact (directory listing, diff, log fragment) that
|
||||
# embeds as file-tree noise rather than as prose about its subject.
|
||||
# The MCP tool path already hybridizes BM25 with vector sim via
|
||||
# `_hybrid_rank`; do the same here so CLI results match what agents
|
||||
# see via `mempalace_search`.
|
||||
hits = [
|
||||
{"text": doc or "", "distance": float(dist), "metadata": meta or {}}
|
||||
for doc, meta, dist in zip(docs, metas, dists)
|
||||
]
|
||||
hits = _hybrid_rank(hits, query)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f' Results for: "{query}"')
|
||||
if wing:
|
||||
@@ -281,18 +353,20 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
|
||||
print(f" Room: {room}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
|
||||
similarity = round(max(0.0, 1 - dist), 3)
|
||||
for i, hit in enumerate(hits, 1):
|
||||
vec_sim = round(max(0.0, 1 - hit["distance"]), 3)
|
||||
bm25 = hit.get("bm25_score", 0.0)
|
||||
meta = hit["metadata"]
|
||||
source = Path(meta.get("source_file", "?")).name
|
||||
wing_name = meta.get("wing", "?")
|
||||
room_name = meta.get("room", "?")
|
||||
|
||||
print(f" [{i}] {wing_name} / {room_name}")
|
||||
print(f" Source: {source}")
|
||||
print(f" Match: {similarity}")
|
||||
print(f" Match: cosine={vec_sim} bm25={bm25}")
|
||||
print()
|
||||
# Print the verbatim text, indented
|
||||
for line in doc.strip().split("\n"):
|
||||
for line in hit["text"].strip().split("\n"):
|
||||
print(f" {line}")
|
||||
print()
|
||||
print(f" {'─' * 56}")
|
||||
@@ -300,6 +374,356 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
|
||||
print()
|
||||
|
||||
|
||||
def _bm25_only_via_sqlite(
|
||||
query: str,
|
||||
palace_path: str,
|
||||
wing: str = None,
|
||||
room: str = None,
|
||||
n_results: int = 5,
|
||||
max_candidates: int = 500,
|
||||
_include_internal: bool = False,
|
||||
collection_name: str = None,
|
||||
) -> dict:
|
||||
"""BM25-only search reading drawers directly from chroma.sqlite3.
|
||||
|
||||
Used when HNSW is diverged or unloadable (#1222). Bypasses chromadb's
|
||||
Python client entirely so a corrupt vector segment can't segfault the
|
||||
MCP server. Routes through chromadb's own FTS5 trigram index
|
||||
(``embedding_fulltext_search``) for candidate selection, then re-ranks
|
||||
with the same Okapi-BM25 used in :func:`_hybrid_rank` so the result
|
||||
shape matches the vector path.
|
||||
|
||||
The query is split into ≥3-char trigram-tokens and OR-joined for the
|
||||
FTS5 MATCH — chromadb writes the index with ``tokenize='trigram'``,
|
||||
so single-character tokens never match. When no usable token survives
|
||||
(e.g. "is a"), candidate selection falls back to the most-recent
|
||||
``max_candidates`` rows so we still return *something* rather than
|
||||
nothing.
|
||||
"""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return {
|
||||
"error": "No palace found",
|
||||
"hint": "Run: mempalace init <dir> && mempalace mine <dir>",
|
||||
}
|
||||
if collection_name is None:
|
||||
from .config import get_configured_collection_name
|
||||
|
||||
collection_name = get_configured_collection_name()
|
||||
|
||||
def _metadata_filter_sql(row_id_expr: str) -> tuple[str, list[str]]:
|
||||
clauses = []
|
||||
params = []
|
||||
for key, value in (("wing", wing), ("room", room)):
|
||||
if not value:
|
||||
continue
|
||||
clauses.append(
|
||||
f"""
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM embedding_metadata mf
|
||||
WHERE mf.id = {row_id_expr}
|
||||
AND mf.key = ?
|
||||
AND COALESCE(
|
||||
mf.string_value,
|
||||
CAST(mf.int_value AS TEXT),
|
||||
CAST(mf.float_value AS TEXT),
|
||||
CAST(mf.bool_value AS TEXT)
|
||||
) = ?
|
||||
)
|
||||
"""
|
||||
)
|
||||
params.extend([key, value])
|
||||
return "".join(clauses), params
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
except sqlite3.Error as e:
|
||||
return {"error": f"sqlite open failed: {e}"}
|
||||
|
||||
try:
|
||||
# FTS5 MATCH expects whitespace-separated tokens. Drop tokens
|
||||
# shorter than 3 chars (trigram tokenizer can't match them).
|
||||
tokens = [t for t in _tokenize(query) if len(t) >= 3]
|
||||
candidate_ids: list[int] = []
|
||||
use_recency_fallback = not tokens
|
||||
if tokens:
|
||||
fts_query = " OR ".join(tokens)
|
||||
filter_sql, filter_params = _metadata_filter_sql("embedding_fulltext_search.rowid")
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT embedding_fulltext_search.rowid
|
||||
FROM embedding_fulltext_search
|
||||
JOIN embeddings e ON e.id = embedding_fulltext_search.rowid
|
||||
JOIN segments s ON e.segment_id = s.id
|
||||
JOIN collections c ON s.collection = c.id
|
||||
WHERE embedding_fulltext_search MATCH ?
|
||||
AND c.name = ?
|
||||
{filter_sql}
|
||||
LIMIT ?
|
||||
""",
|
||||
(fts_query, collection_name, *filter_params, max_candidates),
|
||||
).fetchall()
|
||||
candidate_ids = [r[0] for r in rows]
|
||||
except sqlite3.Error:
|
||||
# FTS5 tokenizer mismatch or syntax error — fall through
|
||||
# to the recency-window selector below.
|
||||
logger.debug("FTS5 MATCH failed; using recency fallback", exc_info=True)
|
||||
use_recency_fallback = True
|
||||
|
||||
if not candidate_ids and use_recency_fallback:
|
||||
# No usable FTS tokens, or FTS itself failed — pull the most
|
||||
# recent rows for the drawers segment so we can BM25-rank
|
||||
# something rather than return empty-handed. A clean FTS miss
|
||||
# must stay empty, especially after wing/room filtering, because
|
||||
# recency fallback would return unrelated scoped drawers.
|
||||
# Wrapped in try/except because the schema may differ on legacy
|
||||
# palaces (older chromadb without ``created_at``, missing
|
||||
# ``segments`` rows after partial restore, etc.); on schema
|
||||
# mismatch we fall back to ordering by primary-key id and finally
|
||||
# to an empty result rather than letting search raise.
|
||||
try:
|
||||
filter_sql, filter_params = _metadata_filter_sql("e.id")
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT e.id
|
||||
FROM embeddings e
|
||||
JOIN segments s ON e.segment_id = s.id
|
||||
JOIN collections c ON s.collection = c.id
|
||||
WHERE c.name = ?
|
||||
{filter_sql}
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(collection_name, *filter_params, max_candidates),
|
||||
).fetchall()
|
||||
candidate_ids = [r[0] for r in rows]
|
||||
except sqlite3.Error:
|
||||
logger.debug(
|
||||
"recency-window query failed; trying id-ordered fallback",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
filter_sql, filter_params = _metadata_filter_sql("e.id")
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT e.id
|
||||
FROM embeddings e
|
||||
JOIN segments s ON e.segment_id = s.id
|
||||
JOIN collections c ON s.collection = c.id
|
||||
WHERE c.name = ?
|
||||
{filter_sql}
|
||||
ORDER BY e.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(collection_name, *filter_params, max_candidates),
|
||||
).fetchall()
|
||||
candidate_ids = [r[0] for r in rows]
|
||||
except sqlite3.Error:
|
||||
logger.debug("id-ordered fallback also failed", exc_info=True)
|
||||
candidate_ids = []
|
||||
|
||||
if not candidate_ids:
|
||||
return {
|
||||
"query": query,
|
||||
"filters": {"wing": wing, "room": room},
|
||||
"total_before_filter": 0,
|
||||
"results": [],
|
||||
"fallback": "bm25_only_via_sqlite",
|
||||
}
|
||||
|
||||
placeholders = ",".join(["?"] * len(candidate_ids))
|
||||
meta_rows = conn.execute(
|
||||
f"""
|
||||
SELECT id, key, string_value, int_value
|
||||
FROM embedding_metadata
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
candidate_ids,
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Group metadata rows into per-drawer dicts.
|
||||
drawers: dict[int, dict] = {}
|
||||
for emb_id, key, sval, ival in meta_rows:
|
||||
d = drawers.setdefault(emb_id, {"_id": emb_id, "metadata": {}, "text": ""})
|
||||
if key == "chroma:document":
|
||||
d["text"] = sval or ""
|
||||
else:
|
||||
d["metadata"][key] = sval if sval is not None else ival
|
||||
|
||||
# Apply wing/room filters in Python (FTS5 candidates may include
|
||||
# entries from other wings).
|
||||
candidates = []
|
||||
for d in drawers.values():
|
||||
meta = d["metadata"]
|
||||
if wing and meta.get("wing") != wing:
|
||||
continue
|
||||
if room and meta.get("room") != room:
|
||||
continue
|
||||
full_source = meta.get("source_file", "") or ""
|
||||
candidates.append(
|
||||
{
|
||||
"text": d["text"],
|
||||
"wing": meta.get("wing", "unknown"),
|
||||
"room": meta.get("room", "unknown"),
|
||||
"source_file": Path(full_source).name if full_source else "?",
|
||||
"created_at": meta.get("filed_at", "unknown"),
|
||||
# No vector distance available in BM25-only mode.
|
||||
"similarity": None,
|
||||
"distance": None,
|
||||
"matched_via": "bm25_sqlite",
|
||||
# Internal: full path + chunk_index let callers (notably
|
||||
# candidate_strategy="union") dedupe at chunk granularity
|
||||
# rather than basename — two files in different directories
|
||||
# may share a basename, and one source_file is split across
|
||||
# multiple chunks. Stripped before this helper returns.
|
||||
"_source_file_full": full_source,
|
||||
"_chunk_index": meta.get("chunk_index"),
|
||||
}
|
||||
)
|
||||
|
||||
# Local BM25 over the candidate set.
|
||||
docs = [c["text"] for c in candidates]
|
||||
bm25_raw = _bm25_scores(query, docs)
|
||||
max_bm25 = max(bm25_raw) if bm25_raw else 0.0
|
||||
for c, raw in zip(candidates, bm25_raw):
|
||||
c["bm25_score"] = round(raw, 3)
|
||||
c["_score"] = (raw / max_bm25) if max_bm25 > 0 else 0.0
|
||||
candidates.sort(key=lambda c: c["_score"], reverse=True)
|
||||
hits = candidates[:n_results]
|
||||
for h in hits:
|
||||
h.pop("_score", None)
|
||||
# Strip internal fields by default so the public BM25-only fallback
|
||||
# response stays clean. Callers that need chunk-precise dedup
|
||||
# (notably the union-merge path) opt in via _include_internal.
|
||||
if not _include_internal:
|
||||
h.pop("_source_file_full", None)
|
||||
h.pop("_chunk_index", None)
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"filters": {"wing": wing, "room": room},
|
||||
"total_before_filter": len(candidates),
|
||||
"results": hits,
|
||||
"fallback": "bm25_only_via_sqlite",
|
||||
"fallback_reason": "vector_search_disabled",
|
||||
}
|
||||
|
||||
|
||||
def _merge_bm25_union_candidates(
|
||||
hits: list,
|
||||
query: str,
|
||||
palace_path: str,
|
||||
wing: str,
|
||||
room: str,
|
||||
n_results: int,
|
||||
max_distance: float = 0.0,
|
||||
) -> None:
|
||||
"""Append top-K BM25-only candidates from sqlite into ``hits`` in place.
|
||||
|
||||
Used by ``search_memories(..., candidate_strategy="union")`` to widen
|
||||
the rerank pool's *source* (not just its size) — vector-only candidate
|
||||
selection skips docs whose embeddings are far from the query even when
|
||||
BM25 signal is strong.
|
||||
|
||||
Dedup is chunk-precise: the key is ``(_source_file_full, _chunk_index)``
|
||||
so two files sharing a basename in different directories don't collide,
|
||||
and a vector hit on chunk N of a file doesn't block BM25 from
|
||||
contributing chunk M of the same file. Falls back to ``source_file``
|
||||
only when full-path/chunk metadata is absent.
|
||||
|
||||
BM25-only additions carry ``distance=None`` so ``_hybrid_rank`` scores
|
||||
them on BM25 contribution alone.
|
||||
|
||||
When ``max_distance > 0.0`` (a strict vector-distance threshold is
|
||||
set), BM25-only candidates are skipped entirely — they have no vector
|
||||
distance to satisfy the threshold, and silently injecting them would
|
||||
break the existing ``max_distance`` guarantee that hybrid results lie
|
||||
within the requested vector-distance bound.
|
||||
"""
|
||||
if max_distance > 0.0:
|
||||
return
|
||||
|
||||
try:
|
||||
bm25_extra = _bm25_only_via_sqlite(
|
||||
query,
|
||||
palace_path,
|
||||
wing=wing,
|
||||
room=room,
|
||||
n_results=n_results * 3,
|
||||
_include_internal=True,
|
||||
).get("results", [])
|
||||
except Exception:
|
||||
logger.debug("candidate_strategy=union: BM25 fetch failed", exc_info=True)
|
||||
return
|
||||
|
||||
def _dedup_key(entry: dict):
|
||||
full = entry.get("_source_file_full")
|
||||
ci = entry.get("_chunk_index")
|
||||
if full and ci is not None:
|
||||
return (full, ci)
|
||||
# Fall back to basename only when richer metadata is missing —
|
||||
# avoids silently dropping candidates on legacy data while still
|
||||
# giving chunk-precise dedup whenever the metadata is present.
|
||||
return entry.get("source_file")
|
||||
|
||||
seen = {_dedup_key(h) for h in hits}
|
||||
for bh in bm25_extra:
|
||||
key = _dedup_key(bh)
|
||||
if not key or key == "?" or key in seen:
|
||||
continue
|
||||
bh["distance"] = None
|
||||
bh["effective_distance"] = None
|
||||
bh["closet_boost"] = 0.0
|
||||
hits.append(bh)
|
||||
seen.add(key)
|
||||
|
||||
|
||||
# Strategy dispatch — keeps search_memories' branch count under the
|
||||
# project's complexity ceiling (C901 max-complexity=25). New strategies
|
||||
# register here.
|
||||
_CANDIDATE_MERGERS = {
|
||||
"vector": None, # default no-op
|
||||
"union": _merge_bm25_union_candidates,
|
||||
}
|
||||
|
||||
|
||||
def _validate_candidate_strategy(strategy: str) -> None:
|
||||
"""Raise ``ValueError`` for unknown strategies.
|
||||
|
||||
Called eagerly at the top of ``search_memories`` so invalid values
|
||||
fail consistently regardless of whether the call routes through the
|
||||
vector path, the BM25-only fallback, or returns an early error dict.
|
||||
"""
|
||||
if strategy not in _CANDIDATE_MERGERS:
|
||||
raise ValueError(
|
||||
f"candidate_strategy must be one of {tuple(_CANDIDATE_MERGERS)}, got {strategy!r}"
|
||||
)
|
||||
|
||||
|
||||
def _apply_candidate_strategy(
|
||||
strategy: str,
|
||||
hits: list,
|
||||
query: str,
|
||||
palace_path: str,
|
||||
wing: str,
|
||||
room: str,
|
||||
n_results: int,
|
||||
max_distance: float = 0.0,
|
||||
) -> None:
|
||||
"""Dispatch to the registered merger for ``strategy``.
|
||||
|
||||
Strategy validity is assumed (``_validate_candidate_strategy`` runs
|
||||
earlier); ``"vector"`` is a no-op.
|
||||
"""
|
||||
merger = _CANDIDATE_MERGERS[strategy]
|
||||
if merger is not None:
|
||||
merger(hits, query, palace_path, wing, room, n_results, max_distance=max_distance)
|
||||
|
||||
|
||||
def search_memories(
|
||||
query: str,
|
||||
palace_path: str,
|
||||
@@ -307,6 +731,9 @@ def search_memories(
|
||||
room: str = None,
|
||||
n_results: int = 5,
|
||||
max_distance: float = 0.0,
|
||||
vector_disabled: bool = False,
|
||||
candidate_strategy: str = "vector",
|
||||
collection_name: str = None,
|
||||
) -> dict:
|
||||
"""Programmatic search — returns a dict instead of printing.
|
||||
|
||||
@@ -322,9 +749,46 @@ def search_memories(
|
||||
cosine distance (hnsw:space=cosine) — 0 = identical, 2 = opposite.
|
||||
Results with distance > this value are filtered out. A value of
|
||||
0.0 disables filtering. Typical useful range: 0.3–1.0.
|
||||
vector_disabled: When True, route to the sqlite-only BM25 fallback
|
||||
(#1222). Set by the MCP server when the HNSW capacity probe
|
||||
detects a divergence that would segfault chromadb on segment
|
||||
load.
|
||||
candidate_strategy: How candidates for the hybrid re-rank are gathered.
|
||||
|
||||
* ``"vector"`` (default) — preserves historical behavior: top
|
||||
``n_results * 3`` rows from the vector index are the rerank pool.
|
||||
Cheap; works well when query and target docs agree in the
|
||||
embedding space.
|
||||
* ``"union"`` — also pull top ``n_results * 3`` BM25 candidates
|
||||
from the sqlite FTS5 index and merge them into the rerank pool
|
||||
(deduped by source_file). Catches docs with strong BM25 signal
|
||||
that are vector-distant from the query (e.g. terminology guides
|
||||
looked up by narrative-shaped queries; policy clauses surfaced
|
||||
by scenario descriptions). Adds one sqlite open + FTS5 MATCH
|
||||
per query; perf cost is small but unmeasured at corpus scale.
|
||||
Opt in until the cost is characterized.
|
||||
|
||||
When ``max_distance > 0.0`` is also set, BM25-only candidates
|
||||
are skipped — they have no vector distance and would silently
|
||||
violate the requested distance threshold.
|
||||
"""
|
||||
# Validate the strategy eagerly so invalid values fail the same way
|
||||
# regardless of whether the call routes through the vector path or
|
||||
# the BM25-only fallback below.
|
||||
_validate_candidate_strategy(candidate_strategy)
|
||||
|
||||
if vector_disabled:
|
||||
return _bm25_only_via_sqlite(
|
||||
query,
|
||||
palace_path,
|
||||
wing=wing,
|
||||
room=room,
|
||||
n_results=n_results,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
try:
|
||||
drawers_col = get_collection(palace_path, create=False)
|
||||
drawers_col = get_collection(palace_path, collection_name=collection_name, create=False)
|
||||
except Exception as e:
|
||||
logger.error("No palace found at %s: %s", palace_path, e)
|
||||
return {
|
||||
@@ -372,11 +836,13 @@ def search_memories(
|
||||
_first_or_empty(closet_results, "distances"),
|
||||
)
|
||||
):
|
||||
cmeta = cmeta or {}
|
||||
source = cmeta.get("source_file", "")
|
||||
if source and source not in closet_boost_by_source:
|
||||
closet_boost_by_source[source] = (rank, cdist, cdoc[:200])
|
||||
except Exception:
|
||||
pass # no closets yet — hybrid degrades to pure drawer search
|
||||
# No closets yet — hybrid degrades to pure drawer search.
|
||||
logger.debug("Closet collection unavailable; using drawer-only search", exc_info=True)
|
||||
|
||||
# Rank-based boost. The ordinal signal ("which closet matched best") is
|
||||
# more reliable than absolute distance on narrative content, where
|
||||
@@ -390,10 +856,13 @@ def search_memories(
|
||||
_first_or_empty(drawer_results, "metadatas"),
|
||||
_first_or_empty(drawer_results, "distances"),
|
||||
):
|
||||
meta = meta or {}
|
||||
doc = doc or ""
|
||||
# Filter on raw distance before rounding to avoid precision loss.
|
||||
if max_distance > 0.0 and dist > max_distance:
|
||||
continue
|
||||
|
||||
meta = meta or {}
|
||||
source = meta.get("source_file", "") or ""
|
||||
boost = 0.0
|
||||
matched_via = "drawer"
|
||||
@@ -405,7 +874,12 @@ def search_memories(
|
||||
matched_via = "drawer+closet"
|
||||
closet_preview = c_preview
|
||||
|
||||
effective_dist = dist - boost
|
||||
# Clamp to the valid cosine-distance range [0, 2]. When a strong
|
||||
# closet boost (up to 0.40) exceeds the raw distance, the subtraction
|
||||
# can go negative — which (a) yields ``similarity > 1.0`` downstream
|
||||
# and (b) makes the sort key land *below* ordinary positive distances,
|
||||
# inverting the ranking so the best hybrid matches sort last.
|
||||
effective_dist = max(0.0, min(2.0, dist - boost))
|
||||
entry = {
|
||||
"text": doc,
|
||||
"wing": meta.get("wing", "unknown"),
|
||||
@@ -450,9 +924,10 @@ def search_memories(
|
||||
include=["documents", "metadatas"],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Neighbor fetch failed for %s", full_source, exc_info=True)
|
||||
continue
|
||||
docs = source_drawers.get("documents") or []
|
||||
metas_ = source_drawers.get("metadatas") or []
|
||||
docs = source_drawers.documents
|
||||
metas_ = source_drawers.metadatas
|
||||
if len(docs) <= 1:
|
||||
continue
|
||||
|
||||
@@ -487,8 +962,29 @@ def search_memories(
|
||||
h["drawer_index"] = best_idx
|
||||
h["total_drawers"] = len(ordered_docs)
|
||||
|
||||
# BM25 hybrid re-rank within the final candidate set.
|
||||
hits = _hybrid_rank(hits, query)
|
||||
# Candidate strategy hook: optionally widen the rerank pool's *source*
|
||||
# before ranking. Default ("vector") is a no-op; "union" merges top-K
|
||||
# BM25 candidates from sqlite. See `_apply_candidate_strategy`.
|
||||
# ``max_distance`` is forwarded so union mode can refuse to inject
|
||||
# BM25-only (distance=None) candidates that would silently bypass the
|
||||
# caller's strict distance threshold.
|
||||
_apply_candidate_strategy(
|
||||
candidate_strategy,
|
||||
hits,
|
||||
query,
|
||||
palace_path,
|
||||
wing,
|
||||
room,
|
||||
n_results,
|
||||
max_distance=max_distance,
|
||||
)
|
||||
|
||||
# BM25 hybrid re-rank within the final candidate set, then trim back
|
||||
# to the requested size. Without the trim, ``candidate_strategy="union"``
|
||||
# would return up to 4× ``n_results`` (vector hits + BM25 union pool),
|
||||
# breaking the existing ``search_memories`` size contract that the MCP
|
||||
# ``limit`` parameter is built on.
|
||||
hits = _hybrid_rank(hits, query)[:n_results]
|
||||
for h in hits:
|
||||
h.pop("_sort_key", None)
|
||||
h.pop("_source_file_full", None)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Source adapter subsystem (RFC 002).
|
||||
|
||||
Public surface:
|
||||
|
||||
* :class:`BaseSourceAdapter` — per-source read-side contract.
|
||||
* Typed records: :class:`SourceRef`, :class:`SourceItemMetadata`,
|
||||
:class:`DrawerRecord`, :class:`RouteHint`, :class:`SourceSummary`,
|
||||
:class:`AdapterSchema`, :class:`FieldSpec`.
|
||||
* Error classes: :class:`SourceNotFoundError`, :class:`AuthRequiredError`,
|
||||
:class:`AdapterClosedError`, :class:`TransformationViolationError`,
|
||||
:class:`SchemaConformanceError`.
|
||||
* Registry: :func:`register`, :func:`get_adapter`, :func:`available_adapters`,
|
||||
:func:`resolve_adapter_for_source`.
|
||||
* :class:`PalaceContext` — facade core passes to adapters during ``ingest``.
|
||||
* :mod:`transforms` — reference implementations of the reserved §1.4
|
||||
transformations + :func:`get_transformation` resolver.
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
AdapterClosedError,
|
||||
AdapterSchema,
|
||||
AuthRequiredError,
|
||||
BaseSourceAdapter,
|
||||
DrawerRecord,
|
||||
FieldSpec,
|
||||
IngestMode,
|
||||
IngestResult,
|
||||
RouteHint,
|
||||
SchemaConformanceError,
|
||||
SourceAdapterError,
|
||||
SourceItemMetadata,
|
||||
SourceNotFoundError,
|
||||
SourceRef,
|
||||
SourceSummary,
|
||||
TransformationViolationError,
|
||||
)
|
||||
from .context import PalaceContext, ProgressHook
|
||||
from .registry import (
|
||||
available_adapters,
|
||||
get_adapter,
|
||||
get_adapter_class,
|
||||
register,
|
||||
reset_adapters,
|
||||
resolve_adapter_for_source,
|
||||
unregister,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AdapterClosedError",
|
||||
"AdapterSchema",
|
||||
"AuthRequiredError",
|
||||
"BaseSourceAdapter",
|
||||
"DrawerRecord",
|
||||
"FieldSpec",
|
||||
"IngestMode",
|
||||
"IngestResult",
|
||||
"PalaceContext",
|
||||
"ProgressHook",
|
||||
"RouteHint",
|
||||
"SchemaConformanceError",
|
||||
"SourceAdapterError",
|
||||
"SourceItemMetadata",
|
||||
"SourceNotFoundError",
|
||||
"SourceRef",
|
||||
"SourceSummary",
|
||||
"TransformationViolationError",
|
||||
"available_adapters",
|
||||
"get_adapter",
|
||||
"get_adapter_class",
|
||||
"register",
|
||||
"reset_adapters",
|
||||
"resolve_adapter_for_source",
|
||||
"unregister",
|
||||
]
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Source adapter contract for MemPalace (RFC 002).
|
||||
|
||||
Mirrors what ``mempalace/backends/base.py`` does for the write side: it defines
|
||||
the read-side surface every source adapter must implement. A source adapter
|
||||
extracts content from a specific origin (filesystem, git, Slack, Cursor …) and
|
||||
yields typed records (``SourceItemMetadata`` / ``DrawerRecord``) that core
|
||||
routes into the palace.
|
||||
|
||||
This module is spec scaffolding. The first-party miners (``mempalace/miner.py``
|
||||
and ``mempalace/convo_miner.py``) are migrated onto it in a follow-up PR;
|
||||
in this PR we publish the contract so third-party adapters can begin building
|
||||
against a stable surface.
|
||||
|
||||
See ``docs/rfcs/002-source-adapter-plugin-spec.md`` for the authoritative
|
||||
spec text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, ClassVar, Iterator, Literal, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .context import PalaceContext # noqa: F401 (used in string annotation)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SourceAdapterError(Exception):
|
||||
"""Base class for every source-adapter error raised by core."""
|
||||
|
||||
|
||||
class SourceNotFoundError(SourceAdapterError):
|
||||
"""Raised when a ``SourceRef`` does not resolve to a readable source."""
|
||||
|
||||
|
||||
class AuthRequiredError(SourceAdapterError):
|
||||
"""Raised when an adapter needs credentials that were not provided.
|
||||
|
||||
The message MUST name the env vars (or other supported mechanism) the
|
||||
operator needs to set.
|
||||
"""
|
||||
|
||||
|
||||
class AdapterClosedError(SourceAdapterError):
|
||||
"""Raised when an adapter method is called after ``close()``."""
|
||||
|
||||
|
||||
class TransformationViolationError(SourceAdapterError):
|
||||
"""Raised by the conformance suite when round-tripping a drawer requires
|
||||
an undeclared transformation (RFC 002 §7.2–7.3)."""
|
||||
|
||||
|
||||
class SchemaConformanceError(SourceAdapterError):
|
||||
"""Raised when a ``DrawerRecord.metadata`` violates the adapter schema
|
||||
returned by :meth:`BaseSourceAdapter.describe_schema`."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Value objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceRef:
|
||||
"""A handle to the source a user wants to ingest.
|
||||
|
||||
``local_path`` is for filesystem-rooted sources (project dir, mbox file).
|
||||
``uri`` is for URL-like references (``github.com/org/repo``,
|
||||
``slack://workspace/channel``).
|
||||
``options`` carries adapter-specific non-secret config. Secrets MUST NOT
|
||||
be placed here; see §4.2.
|
||||
"""
|
||||
|
||||
local_path: Optional[str] = None
|
||||
uri: Optional[str] = None
|
||||
options: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteHint:
|
||||
"""Adapter-supplied routing hint (RFC 002 §2.5)."""
|
||||
|
||||
wing: Optional[str] = None
|
||||
room: Optional[str] = None
|
||||
hall: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceItemMetadata:
|
||||
"""Lightweight pointer yielded before drawers for lazy-fetch adapters.
|
||||
|
||||
Core inspects ``version`` via :meth:`BaseSourceAdapter.is_current` to
|
||||
decide whether to skip extraction; an adapter that responds positively
|
||||
stops yielding drawers for this item and moves to the next.
|
||||
"""
|
||||
|
||||
source_file: str
|
||||
version: str
|
||||
size_hint: Optional[int] = None
|
||||
route_hint: Optional[RouteHint] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DrawerRecord:
|
||||
"""One drawer's worth of extracted content plus flat metadata.
|
||||
|
||||
``metadata`` values MUST be flat scalars (``str``/``int``/``float``/``bool``)
|
||||
per RFC 001 §1.4 — the chroma constraint. Nested data belongs on the
|
||||
knowledge graph (§5.5) or in a declared ``json_string`` field (§5.4).
|
||||
"""
|
||||
|
||||
content: str
|
||||
source_file: str
|
||||
chunk_index: int = 0
|
||||
metadata: dict = field(default_factory=dict)
|
||||
route_hint: Optional[RouteHint] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceSummary:
|
||||
"""High-level description of a source returned by :meth:`source_summary`."""
|
||||
|
||||
description: str
|
||||
item_count: Optional[int] = None
|
||||
|
||||
|
||||
IngestMode = Literal["chunked_content", "whole_record", "metadata_only"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FieldSpec:
|
||||
"""Declared shape of a single per-adapter metadata field (§5.2)."""
|
||||
|
||||
type: Literal["string", "int", "float", "bool", "delimiter_joined_string", "json_string"]
|
||||
required: bool
|
||||
description: str
|
||||
indexed: bool = False
|
||||
delimiter: str = ";"
|
||||
json_schema: Optional[dict] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdapterSchema:
|
||||
"""The per-adapter metadata schema returned by :meth:`describe_schema`."""
|
||||
|
||||
fields: dict[str, FieldSpec]
|
||||
version: str
|
||||
|
||||
|
||||
# The union type adapters yield from ``ingest``.
|
||||
IngestResult = object # intentionally broad; runtime checks in core
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BaseSourceAdapter(ABC):
|
||||
"""Long-lived adapter serving many ``SourceRef`` invocations (RFC 002 §2).
|
||||
|
||||
Instances are lightweight on construction — no I/O, no network, no
|
||||
credential fetch. All work is deferred to :meth:`ingest`. Instances are
|
||||
thread-safe for concurrent ``ingest`` calls across different ``SourceRef``
|
||||
values (v1 serializes within a single ``SourceRef``).
|
||||
|
||||
Class attributes form the adapter's identity contract:
|
||||
|
||||
* ``name`` — stable adapter name used for registration and drawer metadata.
|
||||
* ``adapter_version`` — adapter's own version, independent of
|
||||
``spec_version``. Recorded on every drawer so re-extract workflows can
|
||||
target drawers from a known-buggy adapter version.
|
||||
* ``capabilities`` — free-form tokens; core inspects a documented subset.
|
||||
* ``supported_modes`` — subset of ``chunked_content``, ``whole_record``,
|
||||
``metadata_only``.
|
||||
* ``declared_transformations`` — set of transformation names the adapter
|
||||
applies to source bytes. The empty set marks a byte-preserving adapter.
|
||||
* ``default_privacy_class`` — privacy class level (§6) applied unless the
|
||||
palace config overrides it.
|
||||
"""
|
||||
|
||||
name: ClassVar[str]
|
||||
spec_version: ClassVar[str] = "1.0"
|
||||
adapter_version: ClassVar[str] = "0.0.0"
|
||||
capabilities: ClassVar[frozenset[str]] = frozenset()
|
||||
supported_modes: ClassVar[frozenset[str]] = frozenset({"chunked_content"})
|
||||
declared_transformations: ClassVar[frozenset[str]] = frozenset()
|
||||
default_privacy_class: ClassVar[str] = "pii_potential"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Required methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
def ingest(
|
||||
self,
|
||||
*,
|
||||
source: SourceRef,
|
||||
palace: "PalaceContext",
|
||||
) -> Iterator[IngestResult]:
|
||||
"""Enumerate and extract content from a source.
|
||||
|
||||
Yields a stream of ``SourceItemMetadata`` and ``DrawerRecord`` values.
|
||||
Lazy adapters yield ``SourceItemMetadata`` ahead of the drawers for
|
||||
that item so core can check :meth:`is_current` before committing to
|
||||
the fetch. Eager adapters MAY interleave freely.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def describe_schema(self) -> AdapterSchema:
|
||||
"""Declare the structured metadata this adapter attaches.
|
||||
|
||||
The returned schema MUST be stable for a given ``adapter_version``.
|
||||
Enterprises index on it; core uses it to validate adapter output.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Optional methods with default implementations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_current(
|
||||
self,
|
||||
*,
|
||||
item: SourceItemMetadata,
|
||||
existing_metadata: Optional[dict],
|
||||
) -> bool:
|
||||
"""Return True if the palace already has an up-to-date copy of ``item``.
|
||||
|
||||
Default: always returns False (re-extract every time). Adapters
|
||||
advertising ``supports_incremental`` MUST override.
|
||||
"""
|
||||
return False
|
||||
|
||||
def source_summary(self, *, source: SourceRef) -> SourceSummary:
|
||||
"""Describe a source without extracting."""
|
||||
return SourceSummary(description=self.name)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release any resources the adapter holds. Default: no-op."""
|
||||
return None
|
||||
@@ -0,0 +1,142 @@
|
||||
"""``PalaceContext`` facade passed to source adapters (RFC 002 §9).
|
||||
|
||||
Bundles the palace-side surface an adapter needs during :meth:`ingest`:
|
||||
drawer collection, closet collection, knowledge graph, palace config, and
|
||||
progress hooks. Adapters receive a ``PalaceContext`` instance and MUST NOT
|
||||
import ``mempalace.palace`` directly — that coupling is what the facade
|
||||
exists to prevent.
|
||||
|
||||
This module publishes the shape third-party adapters target. Core's mine
|
||||
loop will construct a concrete ``PalaceContext`` and pass it to adapters
|
||||
when the filesystem/conversations miners are migrated onto ``BaseSourceAdapter``
|
||||
in a follow-up PR; until then, no in-tree code constructs one, but the
|
||||
contract is stable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional, Protocol
|
||||
|
||||
from .base import DrawerRecord
|
||||
|
||||
|
||||
class _CollectionLike(Protocol):
|
||||
"""Minimum of :class:`mempalace.backends.BaseCollection` adapters rely on.
|
||||
|
||||
Declared as a Protocol so tests and third-party adapters can substitute
|
||||
any object with compatible method signatures without importing the
|
||||
concrete backend. See ``mempalace/backends/base.py`` for the full surface.
|
||||
"""
|
||||
|
||||
def add(self, **kwargs: Any) -> None: ...
|
||||
def upsert(self, **kwargs: Any) -> None: ...
|
||||
def query(self, **kwargs: Any) -> Any: ...
|
||||
def get(self, **kwargs: Any) -> Any: ...
|
||||
def delete(self, **kwargs: Any) -> None: ...
|
||||
def count(self) -> int: ...
|
||||
|
||||
|
||||
class _KnowledgeGraphLike(Protocol):
|
||||
def add_triple(self, subject: str, predicate: str, obj: str, **kwargs: Any) -> Any: ...
|
||||
|
||||
|
||||
# Progress hook signature: ``fn(event_name, **details) -> None``.
|
||||
ProgressHook = Callable[..., None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PalaceContext:
|
||||
"""Per-mine-invocation facade passed to :meth:`BaseSourceAdapter.ingest`.
|
||||
|
||||
Fields:
|
||||
drawer_collection: The palace's drawer collection (via RFC 001 backend).
|
||||
closet_collection: The palace's closet collection, or ``None`` if the
|
||||
palace has no closets yet. Adapters should not write to this
|
||||
directly; core builds closets post-step (RFC 002 §1.7).
|
||||
knowledge_graph: The palace's SQLite knowledge graph. Adapters
|
||||
advertising ``supports_kg_triples`` call ``add_triple`` on it.
|
||||
palace_path: Filesystem root of the palace (convenience; same as
|
||||
``backend.PalaceRef.local_path``).
|
||||
config: Palace config object (hall keywords, rooms list, privacy
|
||||
floor, etc.). Shape is the existing :class:`MempalaceConfig`.
|
||||
adapter_name: Name of the adapter currently ingesting; populated by
|
||||
core so drawers can carry ``metadata["adapter_name"]``.
|
||||
adapter_version: Version of the adapter currently ingesting.
|
||||
progress_hooks: Optional callables core invokes on progress events.
|
||||
|
||||
Methods are intentionally thin wrappers so the concrete mine loop in
|
||||
core can swap implementations without changing adapter code.
|
||||
"""
|
||||
|
||||
drawer_collection: _CollectionLike
|
||||
knowledge_graph: _KnowledgeGraphLike
|
||||
palace_path: str
|
||||
closet_collection: Optional[_CollectionLike] = None
|
||||
config: Optional[Any] = None
|
||||
adapter_name: str = ""
|
||||
adapter_version: str = ""
|
||||
progress_hooks: list[ProgressHook] = field(default_factory=list)
|
||||
|
||||
# Internal: flag set by :meth:`skip_current_item` and checked by the core
|
||||
# mine loop between yields. Not part of the adapter-facing contract; the
|
||||
# adapter only needs to know that calling :meth:`skip_current_item` stops
|
||||
# drawer emission for the current ``SourceItemMetadata``.
|
||||
_skip_requested: bool = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Adapter-facing surface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def upsert_drawer(self, record: DrawerRecord) -> None:
|
||||
"""Persist a ``DrawerRecord`` to the drawer collection.
|
||||
|
||||
Applies the spec-mandated ``adapter_name`` and ``adapter_version``
|
||||
metadata stamps (§5.1) so adapters never need to populate them.
|
||||
"""
|
||||
meta = dict(record.metadata)
|
||||
meta.setdefault("source_file", record.source_file)
|
||||
meta.setdefault("chunk_index", record.chunk_index)
|
||||
if self.adapter_name:
|
||||
meta.setdefault("adapter_name", self.adapter_name)
|
||||
if self.adapter_version:
|
||||
meta.setdefault("adapter_version", self.adapter_version)
|
||||
drawer_id = _build_drawer_id(record)
|
||||
self.drawer_collection.upsert(
|
||||
documents=[record.content],
|
||||
ids=[drawer_id],
|
||||
metadatas=[meta],
|
||||
)
|
||||
|
||||
def skip_current_item(self) -> None:
|
||||
"""Signal to core that the current ``SourceItemMetadata`` is up-to-date
|
||||
and no drawers should be emitted for it. Core resets the flag after
|
||||
advancing past the item."""
|
||||
self._skip_requested = True
|
||||
|
||||
def emit(self, event: str, **details: Any) -> None:
|
||||
"""Invoke each registered progress hook with ``(event, **details)``."""
|
||||
for hook in self.progress_hooks:
|
||||
try:
|
||||
hook(event, **details)
|
||||
except Exception: # pragma: no cover - hook errors never fail mine
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception("progress hook failed on %r", event)
|
||||
|
||||
|
||||
def _build_drawer_id(record: DrawerRecord) -> str:
|
||||
"""Deterministic drawer id: ``<sha256(source_file)[:24]>_<chunk_index>``.
|
||||
|
||||
Matches the shape existing miners rely on (``source_file`` + chunk index
|
||||
pair) while keeping the id chroma-safe (no separators that collide with
|
||||
existing metadata values). 96-bit SHA-256 prefix keeps collision risk
|
||||
negligible across corpora the size of a palace (sha1@64 bits was too
|
||||
close to the birthday bound for large ingests). Adapters that need a
|
||||
different id scheme can bypass :meth:`PalaceContext.upsert_drawer` and
|
||||
write through ``drawer_collection.upsert`` directly.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
digest = hashlib.sha256(record.source_file.encode("utf-8")).hexdigest()[:24]
|
||||
return f"{digest}_{record.chunk_index}"
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Source adapter registry + entry-point discovery (RFC 002 §3).
|
||||
|
||||
Third-party adapters ship as installable packages that declare a
|
||||
``mempalace.sources`` entry point::
|
||||
|
||||
# pyproject.toml of mempalace-source-cursor
|
||||
[project.entry-points."mempalace.sources"]
|
||||
cursor = "mempalace_source_cursor:CursorAdapter"
|
||||
|
||||
MemPalace discovers them at process start. In-tree tests and local
|
||||
development can register manually via :func:`register`. Explicit
|
||||
registration wins on name conflict (RFC 002 §3.2).
|
||||
|
||||
Unlike storage backends (RFC 001 §3.3), source adapters are never auto-
|
||||
detected — the user selects the adapter explicitly via ``--source NAME``
|
||||
or config (§3.3). The default when no adapter is named is ``filesystem``
|
||||
(to preserve current ``mempalace mine <path>`` behavior).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from importlib import metadata
|
||||
from threading import Lock
|
||||
from typing import Type
|
||||
|
||||
from .base import BaseSourceAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENTRY_POINT_GROUP = "mempalace.sources"
|
||||
_DEFAULT_ADAPTER = "filesystem"
|
||||
|
||||
_registry: dict[str, Type[BaseSourceAdapter]] = {}
|
||||
_instances: dict[str, BaseSourceAdapter] = {}
|
||||
_explicit: set[str] = set()
|
||||
_discovered = False
|
||||
_lock = Lock()
|
||||
|
||||
|
||||
def register(name: str, adapter_cls: Type[BaseSourceAdapter]) -> None:
|
||||
"""Register ``adapter_cls`` under ``name``.
|
||||
|
||||
Explicit registration wins over entry-point discovery on conflict (§3.2).
|
||||
"""
|
||||
with _lock:
|
||||
_registry[name] = adapter_cls
|
||||
_explicit.add(name)
|
||||
_instances.pop(name, None)
|
||||
|
||||
|
||||
def unregister(name: str) -> None:
|
||||
"""Remove an adapter registration (primarily for tests)."""
|
||||
with _lock:
|
||||
_registry.pop(name, None)
|
||||
_explicit.discard(name)
|
||||
_instances.pop(name, None)
|
||||
|
||||
|
||||
def _discover_entry_points() -> None:
|
||||
global _discovered
|
||||
if _discovered:
|
||||
return
|
||||
with _lock:
|
||||
if _discovered:
|
||||
return
|
||||
try:
|
||||
eps = metadata.entry_points()
|
||||
group = (
|
||||
eps.select(group=_ENTRY_POINT_GROUP)
|
||||
if hasattr(eps, "select")
|
||||
else eps.get(_ENTRY_POINT_GROUP, [])
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("entry-point discovery for %s failed", _ENTRY_POINT_GROUP)
|
||||
group = []
|
||||
for ep in group:
|
||||
if ep.name in _explicit:
|
||||
continue # explicit registration wins
|
||||
try:
|
||||
cls = ep.load()
|
||||
except Exception:
|
||||
logger.exception("failed to load adapter entry point %r", ep.name)
|
||||
continue
|
||||
if not isinstance(cls, type) or not issubclass(cls, BaseSourceAdapter):
|
||||
logger.warning(
|
||||
"entry point %r did not resolve to a BaseSourceAdapter subclass (got %r)",
|
||||
ep.name,
|
||||
cls,
|
||||
)
|
||||
continue
|
||||
_registry.setdefault(ep.name, cls)
|
||||
_discovered = True
|
||||
|
||||
|
||||
def available_adapters() -> list[str]:
|
||||
"""Return sorted list of all registered adapter names."""
|
||||
_discover_entry_points()
|
||||
return sorted(_registry.keys())
|
||||
|
||||
|
||||
def get_adapter_class(name: str) -> Type[BaseSourceAdapter]:
|
||||
"""Return the registered adapter class for ``name``."""
|
||||
_discover_entry_points()
|
||||
try:
|
||||
return _registry[name]
|
||||
except KeyError as e:
|
||||
raise KeyError(f"unknown source adapter {name!r}; available: {available_adapters()}") from e
|
||||
|
||||
|
||||
def get_adapter(name: str) -> BaseSourceAdapter:
|
||||
"""Return a long-lived instance of the named adapter.
|
||||
|
||||
Instances are cached per-name; repeated calls return the same object.
|
||||
Call :func:`reset_adapters` in tests that need isolation.
|
||||
"""
|
||||
_discover_entry_points()
|
||||
with _lock:
|
||||
inst = _instances.get(name)
|
||||
if inst is not None:
|
||||
return inst
|
||||
cls = _registry.get(name)
|
||||
if cls is None:
|
||||
raise KeyError(
|
||||
f"unknown source adapter {name!r}; available: {sorted(_registry.keys())}"
|
||||
)
|
||||
inst = cls()
|
||||
_instances[name] = inst
|
||||
return inst
|
||||
|
||||
|
||||
def reset_adapters() -> None:
|
||||
"""Close and drop all cached adapter instances (primarily for tests)."""
|
||||
with _lock:
|
||||
for inst in _instances.values():
|
||||
try:
|
||||
inst.close()
|
||||
except Exception:
|
||||
logger.exception("error closing adapter during reset")
|
||||
_instances.clear()
|
||||
|
||||
|
||||
def resolve_adapter_for_source(
|
||||
*,
|
||||
explicit: str | None = None,
|
||||
config_value: str | None = None,
|
||||
default: str = _DEFAULT_ADAPTER,
|
||||
) -> str:
|
||||
"""Resolve the adapter name per RFC 002 §3.3 priority order.
|
||||
|
||||
1. Explicit ``--source`` flag or kwarg
|
||||
2. Per-source config value
|
||||
3. Default (``filesystem``)
|
||||
|
||||
Auto-detection is *intentionally* absent on the read side (§3.3); a
|
||||
directory containing ``.git`` + ``workspaceStorage/`` + an ``mbox`` file
|
||||
is not a signal of user intent.
|
||||
"""
|
||||
for candidate in (explicit, config_value):
|
||||
if candidate:
|
||||
return candidate
|
||||
return default
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Reference implementations of the reserved content transformations (RFC 002 §1.4).
|
||||
|
||||
Every source adapter declares the set of transformations it applies to source
|
||||
bytes via ``declared_transformations``. The conformance suite then verifies
|
||||
that the adapter's output can be reproduced from the source bytes by applying
|
||||
*only* the declared transformations in declaration order, using these
|
||||
reference implementations.
|
||||
|
||||
Each transformation is a pure function on strings (text content after UTF-8
|
||||
decoding). ``utf8_replace_invalid`` is the one that operates on bytes.
|
||||
|
||||
The invariant the spec enforces: **no transformation is applied that is not
|
||||
declared in the adapter's set**. Adapters with an empty set are byte-preserving
|
||||
end-to-end (modulo the initial UTF-8 decode itself, which is captured by
|
||||
``utf8_replace_invalid`` when applicable).
|
||||
|
||||
Adapters MAY add custom transformations beyond the reserved set; third-party
|
||||
names SHOULD be prefixed with the adapter name (``cursor.composer_ordering``).
|
||||
Custom transformations MUST expose a reference implementation under
|
||||
``mempalace.sources.transforms.<adapter_name>_<transform_name>`` so the
|
||||
conformance suite can locate and apply them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Protocol, Union
|
||||
|
||||
|
||||
class Transformation(Protocol):
|
||||
"""Callable signature every reserved transformation conforms to.
|
||||
|
||||
Accepts the current stage of the pipeline — ``bytes`` on input
|
||||
(``utf8_replace_invalid``) or ``str`` after decoding — and returns ``str``.
|
||||
Adapters compose them in declaration order; the first step operates on the
|
||||
original source bytes, every subsequent step on the prior step's output.
|
||||
"""
|
||||
|
||||
def __call__(self, data: Union[bytes, str], /) -> str: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reserved transformations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def utf8_replace_invalid(raw: bytes) -> str:
|
||||
"""Decode bytes as UTF-8; replace invalid sequences with U+FFFD.
|
||||
|
||||
Equivalent to ``raw.decode("utf-8", errors="replace")``. This is the one
|
||||
reserved transformation that operates on bytes rather than decoded text.
|
||||
"""
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def newline_normalize(text: str) -> str:
|
||||
"""Convert CRLF and bare-CR line endings to LF."""
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
|
||||
def whitespace_trim(text: str) -> str:
|
||||
"""Strip leading and trailing whitespace at the record boundary only."""
|
||||
return text.strip()
|
||||
|
||||
|
||||
_RUN_OF_THREE_OR_MORE_BLANK = re.compile(r"(?:\n[ \t]*){3,}\n")
|
||||
|
||||
|
||||
def whitespace_collapse_internal(text: str) -> str:
|
||||
"""Collapse runs of three or more blank lines to exactly two blank lines.
|
||||
|
||||
A "blank line" here is a line containing only spaces or tabs. Single and
|
||||
double blank-line runs are preserved.
|
||||
"""
|
||||
# Normalise inputs before collapsing: turn internal blank lines with
|
||||
# whitespace content into pure \n so the regex matches consistently.
|
||||
lines = text.split("\n")
|
||||
normalised = "\n".join(line if line.strip() else "" for line in lines)
|
||||
return _RUN_OF_THREE_OR_MORE_BLANK.sub("\n\n\n", normalised)
|
||||
|
||||
|
||||
def line_trim(text: str) -> str:
|
||||
"""Strip leading and trailing whitespace from each individual line."""
|
||||
return "\n".join(line.strip() for line in text.split("\n"))
|
||||
|
||||
|
||||
def line_join_spaces(text: str) -> str:
|
||||
"""Join adjacent non-blank lines with a single space, preserving paragraph breaks.
|
||||
|
||||
Two lines separated by at least one blank line remain on separate lines;
|
||||
runs of non-blank lines collapse into a single space-separated line.
|
||||
"""
|
||||
paragraphs = re.split(r"\n[ \t]*\n", text)
|
||||
joined = [" ".join(line.strip() for line in p.split("\n") if line.strip()) for p in paragraphs]
|
||||
return "\n\n".join(joined)
|
||||
|
||||
|
||||
def blank_line_drop(text: str) -> str:
|
||||
"""Drop blank lines between non-blank lines, keeping non-blank lines only."""
|
||||
return "\n".join(line for line in text.split("\n") if line.strip())
|
||||
|
||||
|
||||
# The following reserved transformations are declared in the spec but are
|
||||
# deeply adapter-specific. Rather than guess a single reference implementation
|
||||
# now, we provide identity shims that leave the input unchanged when no
|
||||
# adapter-specific implementation is available. Adapters that declare these
|
||||
# MUST either override with a concrete implementation or provide a namespaced
|
||||
# reference under
|
||||
# ``mempalace.sources.transforms.<adapter_name>_<transform_name>`` (per the
|
||||
# module docstring). The conformance suite looks up the adapter-specific
|
||||
# implementation first, falling back to these identity shims only when none
|
||||
# exists.
|
||||
|
||||
|
||||
def strip_tool_chrome(text: str) -> str:
|
||||
"""Adapter-supplied: remove system tags, hook output, tool UI chrome.
|
||||
|
||||
The reference implementation here is intentionally an identity function
|
||||
because the noise patterns differ per transcript format (Claude Code,
|
||||
Codex, ChatGPT, Slack). The conversations adapter, when migrated, will
|
||||
register a concrete reference implementation under
|
||||
``mempalace.sources.transforms.conversations_strip_tool_chrome``.
|
||||
"""
|
||||
return text
|
||||
|
||||
|
||||
def tool_result_truncate(text: str) -> str:
|
||||
"""Adapter-supplied: head/tail window on tool output with a middle marker."""
|
||||
return text
|
||||
|
||||
|
||||
def tool_result_omitted(text: str) -> str:
|
||||
"""Adapter-supplied: fully omit some tool outputs (e.g., Read/Edit/Write)."""
|
||||
return text
|
||||
|
||||
|
||||
def spellcheck_user(text: str) -> str:
|
||||
"""Adapter-supplied: rewrite user turns via autocorrect.
|
||||
|
||||
Requires the optional ``spellcheck`` extra and a tokenizer; the spec does
|
||||
not mandate a specific language model, so the reference is adapter-owned.
|
||||
"""
|
||||
return text
|
||||
|
||||
|
||||
def synthesized_marker(text: str) -> str:
|
||||
"""Adapter-supplied: adapter inserts its own strings (e.g., '[N lines omitted]')."""
|
||||
return text
|
||||
|
||||
|
||||
def speaker_role_assignment(text: str) -> str:
|
||||
"""Adapter-supplied: multi-party speakers alternately assigned user/assistant."""
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Reserved transformation name → reference implementation.
|
||||
# Adapters look up by name to compose a round-trip pipeline during testing.
|
||||
# The value conforms to the :class:`Transformation` protocol above; we type
|
||||
# it as that Protocol rather than a concrete ``Callable`` so static checkers
|
||||
# accept both the bytes→str (``utf8_replace_invalid``) and str→str shapes.
|
||||
RESERVED_TRANSFORMATIONS: dict[str, Transformation] = {
|
||||
"utf8_replace_invalid": utf8_replace_invalid,
|
||||
"newline_normalize": newline_normalize,
|
||||
"whitespace_trim": whitespace_trim,
|
||||
"whitespace_collapse_internal": whitespace_collapse_internal,
|
||||
"line_trim": line_trim,
|
||||
"line_join_spaces": line_join_spaces,
|
||||
"blank_line_drop": blank_line_drop,
|
||||
"strip_tool_chrome": strip_tool_chrome,
|
||||
"tool_result_truncate": tool_result_truncate,
|
||||
"tool_result_omitted": tool_result_omitted,
|
||||
"spellcheck_user": spellcheck_user,
|
||||
"synthesized_marker": synthesized_marker,
|
||||
"speaker_role_assignment": speaker_role_assignment,
|
||||
}
|
||||
|
||||
|
||||
def get_transformation(name: str) -> Transformation:
|
||||
"""Resolve a reserved transformation by name.
|
||||
|
||||
Raises :class:`KeyError` if the name is neither reserved nor registered as
|
||||
an adapter-namespaced reference (``<adapter>_<transform>``). Callers
|
||||
looking for adapter-specific references SHOULD ``getattr`` on this module
|
||||
first; this helper only covers the reserved names.
|
||||
"""
|
||||
try:
|
||||
return RESERVED_TRANSFORMATIONS[name]
|
||||
except KeyError as e:
|
||||
raise KeyError(
|
||||
f"unknown transformation {name!r}; reserved names: {sorted(RESERVED_TRANSFORMATIONS)}"
|
||||
) from e
|
||||
@@ -224,7 +224,7 @@ def split_file(filepath, output_dir, dry_run=False):
|
||||
print(f" [{i + 1}/{len(boundaries) - 1}] {name} ({len(chunk)} lines)")
|
||||
else:
|
||||
out_path.write_text("".join(chunk), encoding="utf-8")
|
||||
print(f" ✓ {name} ({len(chunk)} lines)")
|
||||
print(f" + {name} ({len(chunk)} lines)")
|
||||
|
||||
written.append(out_path)
|
||||
|
||||
@@ -290,7 +290,7 @@ def main():
|
||||
print(f" Source: {src_dir}")
|
||||
print(f" Output: {output_dir or 'same dir as source'}")
|
||||
print(f" Mega-files: {len(mega_files)}")
|
||||
print(f"{'─' * 60}\n")
|
||||
print(f"{'-' * 60}\n")
|
||||
|
||||
total_written = 0
|
||||
for f, n_sessions in mega_files:
|
||||
@@ -301,11 +301,11 @@ def main():
|
||||
if not args.dry_run and written:
|
||||
backup = f.with_suffix(".mega_backup")
|
||||
f.rename(backup)
|
||||
print(f" → Original renamed to {backup.name}\n")
|
||||
print(f" -> Original renamed to {backup.name}\n")
|
||||
else:
|
||||
print()
|
||||
|
||||
print(f"{'─' * 60}")
|
||||
print(f"{'-' * 60}")
|
||||
if args.dry_run:
|
||||
print(f" DRY RUN — would create {total_written} files from {len(mega_files)} mega-files")
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
sweeper.py — Message-granular miner that catches what the file-level
|
||||
primary miners dropped.
|
||||
|
||||
Algorithm, per session:
|
||||
|
||||
cursor = max(timestamp of sweeper-written drawers for this session_id)
|
||||
For each user/assistant message in the jsonl:
|
||||
if cursor is not None and message.timestamp < cursor: skip
|
||||
else: upsert a drawer keyed by (session_id, message_uuid)
|
||||
|
||||
Properties:
|
||||
|
||||
- Idempotent on its own writes: rerunning is a no-op because drawer
|
||||
IDs are deterministic and existence is pre-checked before counting.
|
||||
- Resume-safe: a crash mid-sweep is recovered on the next run — the
|
||||
cursor advances to the last ingested timestamp and re-attempts at
|
||||
that boundary are de-duped by the deterministic ID.
|
||||
- Tie-break safe: uses ``< cursor`` (not ``<=``), so if multiple
|
||||
messages share the max timestamp and only some were ingested, the
|
||||
rest are still picked up on re-run.
|
||||
- No size caps: each drawer holds one exchange, ~1-5 KB.
|
||||
|
||||
Coordination with the primary file-level miners (``miner.py`` /
|
||||
``convo_miner.py``) is limited: those miners chunk at a fixed char size
|
||||
and do not currently stamp ``session_id``/``timestamp`` metadata that
|
||||
the sweeper can key off. In practice the sweeper coordinates with its
|
||||
own prior runs, and may ingest content that also got chunked into
|
||||
primary-miner drawers (under different IDs). Follow-up: add uniform
|
||||
``ingest_mode`` + message metadata to the primary miners so dedup spans
|
||||
both paths.
|
||||
|
||||
Usage:
|
||||
from mempalace.sweeper import sweep
|
||||
result = sweep("/path/to/session.jsonl", "/path/to/palace")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional
|
||||
|
||||
from .palace import get_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── JSONL parsing ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _flatten_content(content) -> str:
|
||||
"""Normalize Claude Code's message content to a plain string.
|
||||
|
||||
User messages are strings already; assistant messages are a list of
|
||||
content blocks like [{"type": "text", "text": "..."}, {"type":
|
||||
"tool_use", ...}]. All blocks are preserved verbatim — the design
|
||||
principle is "verbatim always", so tool inputs and results are
|
||||
serialized in full, never truncated.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
btype = block.get("type", "")
|
||||
if btype == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
elif btype == "tool_use":
|
||||
parts.append(
|
||||
f"[tool_use: {block.get('name', '?')} "
|
||||
f"input={json.dumps(block.get('input', {}), default=str)}]"
|
||||
)
|
||||
elif btype == "tool_result":
|
||||
parts.append(f"[tool_result: {json.dumps(block.get('content', ''), default=str)}]")
|
||||
else:
|
||||
parts.append(f"[{btype}: {json.dumps(block, default=str)}]")
|
||||
return "\n".join(p for p in parts if p)
|
||||
return str(content)
|
||||
|
||||
|
||||
def parse_claude_jsonl(path: str) -> Iterator[dict]:
|
||||
"""Yield user/assistant records from a Claude Code .jsonl file.
|
||||
|
||||
Each yield is:
|
||||
{
|
||||
"session_id": str,
|
||||
"uuid": str, # per-message UUID
|
||||
"timestamp": str, # ISO 8601
|
||||
"role": "user" | "assistant",
|
||||
"content": str, # flattened text
|
||||
}
|
||||
|
||||
Non-message records (progress, file-history-snapshot, system,
|
||||
queue-operation, last-prompt) are filtered out. Malformed lines are
|
||||
skipped silently — data quality is the transcript writer's problem,
|
||||
not ours.
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
rtype = record.get("type")
|
||||
if rtype not in ("user", "assistant"):
|
||||
continue
|
||||
msg = record.get("message") or {}
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
role = msg.get("role")
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
timestamp = record.get("timestamp")
|
||||
if not timestamp:
|
||||
continue
|
||||
uuid = record.get("uuid")
|
||||
if not uuid:
|
||||
continue
|
||||
session_id = record.get("sessionId") or record.get("session_id")
|
||||
if not session_id:
|
||||
continue
|
||||
content = _flatten_content(msg.get("content", ""))
|
||||
if not content.strip():
|
||||
continue
|
||||
yield {
|
||||
"session_id": session_id,
|
||||
"uuid": uuid,
|
||||
"timestamp": timestamp,
|
||||
"role": role,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
# ── Cursor resolution ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_palace_cursor(collection, session_id: str) -> Optional[str]:
|
||||
"""Return the max timestamp of drawers for this session_id, or None.
|
||||
|
||||
ISO-8601 strings compare lexically in the right order, so we don't
|
||||
need to parse them. Query scans metadatas for the session via the
|
||||
backend's where-filter, then reduces.
|
||||
|
||||
Backend errors are logged at WARNING and surface as a `None` cursor —
|
||||
which makes the caller treat the session as empty and ingest every
|
||||
message. That's intentional: a no-cursor sweep is recovered from on
|
||||
the next run by deterministic drawer IDs, so a degraded cursor never
|
||||
causes silent data loss.
|
||||
"""
|
||||
try:
|
||||
data = collection.get(
|
||||
where={"session_id": session_id},
|
||||
include=["metadatas"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"sweeper: cursor lookup failed for session_id=%s (%s); "
|
||||
"treating as empty — drawers will be re-upserted idempotently.",
|
||||
session_id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
metas = data.get("metadatas") or []
|
||||
timestamps = [m.get("timestamp") for m in metas if m and m.get("timestamp")]
|
||||
if not timestamps:
|
||||
return None
|
||||
return max(timestamps)
|
||||
|
||||
|
||||
# ── Sweep ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _drawer_id_for_message(session_id: str, message_uuid: str) -> str:
|
||||
"""Deterministic drawer ID so upserts at the same message are no-ops.
|
||||
|
||||
Uses the full session_id (not a prefix) to avoid any cross-session
|
||||
collision risk if a transcript source ever uses non-UUID session
|
||||
identifiers or shares prefixes across sessions.
|
||||
"""
|
||||
return f"sweep_{session_id}_{message_uuid}"
|
||||
|
||||
|
||||
def sweep(jsonl_path: str, palace_path: str, source_label: Optional[str] = None) -> dict:
|
||||
"""Ingest every user/assistant message not already represented.
|
||||
|
||||
For each message in the jsonl:
|
||||
- If timestamp < cursor for that session, skip (strictly earlier
|
||||
than anything already in the palace — already covered).
|
||||
- At timestamp == cursor we do NOT skip, because multiple messages
|
||||
can share the same ISO-8601 timestamp; if only some of them were
|
||||
ingested before a crash, a `<= cursor` skip would lose the rest
|
||||
forever. Deterministic drawer IDs make re-attempting at the
|
||||
cursor boundary safe (existing rows are found via a pre-flight
|
||||
`get(ids=...)` and counted as "already present", not "added").
|
||||
- Else, upsert a drawer with deterministic ID so reruns dedupe.
|
||||
|
||||
Returns ``{drawers_added, drawers_already_present, drawers_skipped,
|
||||
drawers_upserted, cursor_by_session}``:
|
||||
|
||||
* ``drawers_added`` — rows that did not exist before this sweep.
|
||||
* ``drawers_already_present`` — rows whose deterministic ID was
|
||||
already in the palace and got rewritten idempotently.
|
||||
* ``drawers_skipped`` — records skipped by the cursor (strictly
|
||||
earlier than what's already stored).
|
||||
* ``drawers_upserted`` — total writes = added + already_present.
|
||||
"""
|
||||
collection = get_collection(palace_path, create=True)
|
||||
cursors: dict = {}
|
||||
|
||||
drawers_added = 0
|
||||
drawers_already_present = 0
|
||||
drawers_skipped = 0
|
||||
|
||||
batch_ids: list[str] = []
|
||||
batch_docs: list[str] = []
|
||||
batch_metas: list[dict] = []
|
||||
BATCH_SIZE = 64
|
||||
|
||||
def _flush():
|
||||
nonlocal drawers_added, drawers_already_present
|
||||
if not batch_ids:
|
||||
return
|
||||
# Pre-flight: which IDs in this batch are already present?
|
||||
# Upsert is idempotent on data but counts as "added" would lie;
|
||||
# this pre-query makes the metric honest (Copilot PR 998 review).
|
||||
try:
|
||||
existing = collection.get(ids=list(batch_ids), include=[])
|
||||
# Chroma returns a dict; typed backends return GetResult — the
|
||||
# compat shim makes ``.get("ids")`` work on both.
|
||||
present = set(existing.get("ids") or [])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"sweeper: existence pre-check failed (%s); "
|
||||
"counting all batch rows as new (metric may over-count on reruns).",
|
||||
exc,
|
||||
)
|
||||
present = set()
|
||||
new_count = sum(1 for rid in batch_ids if rid not in present)
|
||||
already_count = len(batch_ids) - new_count
|
||||
|
||||
collection.upsert(
|
||||
ids=batch_ids,
|
||||
documents=batch_docs,
|
||||
metadatas=batch_metas,
|
||||
)
|
||||
drawers_added += new_count
|
||||
drawers_already_present += already_count
|
||||
batch_ids.clear()
|
||||
batch_docs.clear()
|
||||
batch_metas.clear()
|
||||
|
||||
for rec in parse_claude_jsonl(jsonl_path):
|
||||
sid = rec["session_id"]
|
||||
if sid not in cursors:
|
||||
cursors[sid] = get_palace_cursor(collection, sid)
|
||||
|
||||
cursor = cursors[sid]
|
||||
if cursor is not None and rec["timestamp"] < cursor:
|
||||
drawers_skipped += 1
|
||||
continue
|
||||
|
||||
drawer_id = _drawer_id_for_message(sid, rec["uuid"])
|
||||
document = f"{rec['role'].upper()}: {rec['content']}"
|
||||
metadata = {
|
||||
"session_id": sid,
|
||||
"timestamp": rec["timestamp"],
|
||||
"message_uuid": rec["uuid"],
|
||||
"role": rec["role"],
|
||||
"source_file": source_label or jsonl_path,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"ingest_mode": "sweep",
|
||||
}
|
||||
|
||||
batch_ids.append(drawer_id)
|
||||
batch_docs.append(document)
|
||||
batch_metas.append(metadata)
|
||||
|
||||
if len(batch_ids) >= BATCH_SIZE:
|
||||
_flush()
|
||||
|
||||
_flush()
|
||||
|
||||
return {
|
||||
"drawers_added": drawers_added,
|
||||
"drawers_already_present": drawers_already_present,
|
||||
"drawers_upserted": drawers_added + drawers_already_present,
|
||||
"drawers_skipped": drawers_skipped,
|
||||
"cursor_by_session": cursors,
|
||||
}
|
||||
|
||||
|
||||
def sweep_directory(dir_path: str, palace_path: str) -> dict:
|
||||
"""Sweep every .jsonl file in a directory (recursive).
|
||||
|
||||
Returns aggregated summary across all files. ``files_attempted``
|
||||
includes files that raised, so the count reflects discovery rather
|
||||
than only successes; ``files_succeeded`` is the subset that
|
||||
completed without error.
|
||||
"""
|
||||
dir_p = Path(dir_path).expanduser().resolve()
|
||||
files = sorted(dir_p.rglob("*.jsonl"))
|
||||
|
||||
total_added = 0
|
||||
total_already_present = 0
|
||||
total_skipped = 0
|
||||
per_file = []
|
||||
|
||||
failures: list[dict] = []
|
||||
for f in files:
|
||||
try:
|
||||
result = sweep(str(f), palace_path, source_label=str(f))
|
||||
except Exception as exc:
|
||||
logger.error("sweeper: sweep failed on %s: %s", f, exc)
|
||||
print(f" WARNING: sweep failed on {f}: {exc}", file=sys.stderr)
|
||||
failures.append({"file": str(f), "error": str(exc)})
|
||||
continue
|
||||
total_added += result["drawers_added"]
|
||||
total_already_present += result.get("drawers_already_present", 0)
|
||||
total_skipped += result["drawers_skipped"]
|
||||
per_file.append(
|
||||
{
|
||||
"file": str(f),
|
||||
"added": result["drawers_added"],
|
||||
"already_present": result.get("drawers_already_present", 0),
|
||||
"skipped": result["drawers_skipped"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"files_attempted": len(files),
|
||||
"files_succeeded": len(per_file),
|
||||
"drawers_added": total_added,
|
||||
"drawers_already_present": total_already_present,
|
||||
"drawers_skipped": total_skipped,
|
||||
"per_file": per_file,
|
||||
"failures": failures,
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
sync.py — Gitignore-aware drawer prune (#1252).
|
||||
|
||||
Removes drawers whose source files are now gitignored, deleted, or moved
|
||||
out of the project. Reuses the same GitignoreMatcher infrastructure that
|
||||
the miner uses on the way in, so the same rules that block ingest also
|
||||
drive the corresponding cleanup.
|
||||
|
||||
Usage:
|
||||
from mempalace.sync import sync_palace
|
||||
report = sync_palace(palace_path, project_dirs=["/repo"], dry_run=True)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, TypedDict
|
||||
|
||||
from .miner import is_gitignored, load_gitignore_matcher
|
||||
from .palace import (
|
||||
MineAlreadyRunning,
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
mine_palace_lock,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_BATCH = 1000
|
||||
|
||||
|
||||
class SyncReport(TypedDict):
|
||||
scanned: int
|
||||
kept: int
|
||||
gitignored: int
|
||||
missing: int
|
||||
no_source: int
|
||||
out_of_scope: int
|
||||
removed_drawers: int
|
||||
removed_closets: int
|
||||
dry_run: bool
|
||||
by_source: dict[str, int]
|
||||
|
||||
|
||||
def _resolve_project_root(source_file: Path, project_roots: list) -> Optional[Path]:
|
||||
"""Return the longest project_root that source_file lives under.
|
||||
|
||||
Assumes ``project_roots`` is sorted by path-length descending so the
|
||||
first match is the longest (deepest) prefix.
|
||||
"""
|
||||
for root in project_roots:
|
||||
try:
|
||||
source_file.relative_to(root)
|
||||
return root
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _ancestor_matchers(source_file: Path, root: Path, matcher_cache: dict) -> list:
|
||||
"""Build the ancestor-chain matcher list, root → file's parent.
|
||||
|
||||
Callers are expected to invoke this only after `_resolve_project_root`
|
||||
confirms `source_file` lives under `root`. The defensive try/except
|
||||
keeps the function safe if a future caller skips that check.
|
||||
"""
|
||||
matchers: list = []
|
||||
try:
|
||||
parts = source_file.relative_to(root).parts
|
||||
except ValueError:
|
||||
return matchers
|
||||
cursor = root
|
||||
matcher = load_gitignore_matcher(cursor, matcher_cache)
|
||||
if matcher is not None:
|
||||
matchers.append(matcher)
|
||||
for part in parts[:-1]:
|
||||
cursor = cursor / part
|
||||
matcher = load_gitignore_matcher(cursor, matcher_cache)
|
||||
if matcher is not None:
|
||||
matchers.append(matcher)
|
||||
return matchers
|
||||
|
||||
|
||||
def _is_registry_row(meta: dict, drawer_id: str) -> bool:
|
||||
"""Convo miner sentinels track 'have I seen this transcript' — preserve them.
|
||||
|
||||
Deleting a `_reg_*` sentinel makes the next mine pass re-chunk and re-embed
|
||||
the entire transcript even though its content has not changed.
|
||||
"""
|
||||
if (meta or {}).get("room") == "_registry":
|
||||
return True
|
||||
if (meta or {}).get("ingest_mode") == "registry":
|
||||
return True
|
||||
if drawer_id and drawer_id.startswith("_reg_"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _classify_drawer(
|
||||
meta: dict, matcher_cache: dict, project_roots: list, drawer_id: str = ""
|
||||
) -> str:
|
||||
"""Classify a drawer by its source_file metadata.
|
||||
|
||||
Returns one of: kept, gitignored, missing, no_source, out_of_scope.
|
||||
"""
|
||||
# Defensive: main loop filters registry rows; this guards direct callers.
|
||||
if _is_registry_row(meta, drawer_id):
|
||||
return "kept"
|
||||
|
||||
source_file = (meta or {}).get("source_file")
|
||||
if not source_file:
|
||||
return "no_source"
|
||||
|
||||
src = Path(source_file)
|
||||
if not src.is_absolute():
|
||||
return "no_source"
|
||||
src = src.resolve(strict=False)
|
||||
|
||||
root = _resolve_project_root(src, project_roots)
|
||||
if root is None:
|
||||
return "out_of_scope"
|
||||
|
||||
if not src.exists():
|
||||
return "missing"
|
||||
|
||||
matchers = _ancestor_matchers(src, root, matcher_cache)
|
||||
if matchers and is_gitignored(src, matchers, is_dir=False):
|
||||
return "gitignored"
|
||||
|
||||
return "kept"
|
||||
|
||||
|
||||
def _iter_drawer_metadata(col, wing: Optional[str]):
|
||||
"""Yield (id, metadata) tuples from the drawers collection in batches."""
|
||||
offset = 0
|
||||
where = {"wing": wing} if wing else None
|
||||
while True:
|
||||
kwargs = {"include": ["metadatas"], "limit": _BATCH, "offset": offset}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
batch = col.get(**kwargs)
|
||||
ids = batch.get("ids") or []
|
||||
metas = batch.get("metadatas") or []
|
||||
if not ids:
|
||||
return
|
||||
for drawer_id, meta in zip(ids, metas):
|
||||
yield drawer_id, meta
|
||||
if len(ids) < _BATCH:
|
||||
return
|
||||
offset += len(ids)
|
||||
|
||||
|
||||
def _auto_detect_project_roots(col, wing: Optional[str]) -> list:
|
||||
"""Walk drawer metadata once collecting candidate project roots.
|
||||
|
||||
A path is a project root if any ancestor up to filesystem root holds
|
||||
a `.git` directory or a `.gitignore` file. The deepest such ancestor
|
||||
wins, so nested-but-still-tracked subprojects are honoured.
|
||||
`Path.parents` iterates deepest-first, so the first hit IS deepest.
|
||||
|
||||
Dedupes on ``source_file`` string so a 200-chunk file costs one disk
|
||||
walk, not 200.
|
||||
"""
|
||||
roots: set = set()
|
||||
seen_sources: set = set()
|
||||
for _, meta in _iter_drawer_metadata(col, wing):
|
||||
source_file = (meta or {}).get("source_file")
|
||||
if not source_file or source_file in seen_sources:
|
||||
continue
|
||||
seen_sources.add(source_file)
|
||||
src = Path(source_file)
|
||||
if not src.is_absolute():
|
||||
continue
|
||||
for parent in src.parents:
|
||||
if (parent / ".git").exists() or (parent / ".gitignore").is_file():
|
||||
roots.add(parent.resolve(strict=False))
|
||||
break
|
||||
return sorted(roots, key=lambda p: (-len(str(p)), str(p)))
|
||||
|
||||
|
||||
def _normalize_project_dirs(project_dirs) -> list:
|
||||
"""Resolve and sort project dirs so deepest-prefix wins on first match."""
|
||||
resolved = [Path(p).resolve(strict=False) for p in project_dirs]
|
||||
return sorted(resolved, key=lambda p: (-len(str(p)), str(p)))
|
||||
|
||||
|
||||
def _delete_in_batches(col, ids: list, batch_size: int, wal_log: Optional[Callable]):
|
||||
"""Delete drawer IDs in batches, optionally logging each batch to WAL."""
|
||||
deleted = 0
|
||||
for i in range(0, len(ids), batch_size):
|
||||
chunk = ids[i : i + batch_size]
|
||||
col.delete(ids=chunk)
|
||||
deleted += len(chunk)
|
||||
if wal_log is not None:
|
||||
wal_log(
|
||||
"sync_prune",
|
||||
{"first_id": chunk[0]},
|
||||
{"removed_count": len(chunk)},
|
||||
)
|
||||
return deleted
|
||||
|
||||
|
||||
def sync_palace(
|
||||
palace_path: str,
|
||||
project_dirs: Optional[list] = None,
|
||||
wing: Optional[str] = None,
|
||||
dry_run: bool = True,
|
||||
batch_size: int = _BATCH,
|
||||
wal_log: Optional[Callable] = None,
|
||||
) -> SyncReport:
|
||||
"""Prune drawers whose source files are gitignored, missing, or moved.
|
||||
|
||||
Returns a SyncReport with bucket counts. Dry-run by default; pass
|
||||
dry_run=False to actually delete drawers and matching closets.
|
||||
|
||||
Holds ``mine_palace_lock`` for the whole call so the classify pass and
|
||||
the apply branch see the same drawer snapshot. Raises
|
||||
``MineAlreadyRunning`` if another mine is in progress on this palace.
|
||||
|
||||
On apply (``dry_run=False``), at least one of ``wing`` or
|
||||
``project_dirs`` must be set so a caller cannot accidentally prune
|
||||
every wing in a multi-project palace via auto-detected roots.
|
||||
"""
|
||||
if not dry_run and not wing and not project_dirs:
|
||||
raise ValueError(
|
||||
"sync apply requires explicit wing= or project_dirs= so it cannot "
|
||||
"auto-prune every wing in a multi-project palace; pass --wing or "
|
||||
"a project directory"
|
||||
)
|
||||
if project_dirs is not None and not project_dirs:
|
||||
raise ValueError(
|
||||
"project_dirs was provided but is empty; pass at least one project "
|
||||
"root or pass project_dirs=None to auto-detect from drawer metadata"
|
||||
)
|
||||
|
||||
counts = {
|
||||
"scanned": 0,
|
||||
"kept": 0,
|
||||
"gitignored": 0,
|
||||
"missing": 0,
|
||||
"no_source": 0,
|
||||
"out_of_scope": 0,
|
||||
}
|
||||
by_source: dict = defaultdict(int)
|
||||
removable_ids: list = []
|
||||
removable_sources: set = set()
|
||||
|
||||
with mine_palace_lock(palace_path):
|
||||
col = get_collection(palace_path, create=False)
|
||||
|
||||
if project_dirs is not None:
|
||||
roots = _normalize_project_dirs(project_dirs)
|
||||
else:
|
||||
roots = _auto_detect_project_roots(col, wing)
|
||||
|
||||
matcher_cache: dict = {}
|
||||
# Same source_file → same verdict holds because mine_palace_lock
|
||||
# blocks concurrent writers and the loop is synchronous.
|
||||
classification_cache: dict = {}
|
||||
|
||||
for drawer_id, meta in _iter_drawer_metadata(col, wing):
|
||||
counts["scanned"] += 1
|
||||
meta = meta or {}
|
||||
source_file = meta.get("source_file")
|
||||
|
||||
if _is_registry_row(meta, drawer_id):
|
||||
bucket = "kept"
|
||||
elif source_file and source_file in classification_cache:
|
||||
bucket = classification_cache[source_file]
|
||||
else:
|
||||
bucket = _classify_drawer(meta, matcher_cache, roots, drawer_id)
|
||||
if source_file:
|
||||
classification_cache[source_file] = bucket
|
||||
|
||||
counts[bucket] += 1
|
||||
if bucket in ("gitignored", "missing"):
|
||||
removable_ids.append(drawer_id)
|
||||
if source_file:
|
||||
removable_sources.add(source_file)
|
||||
by_source[source_file] += 1
|
||||
|
||||
report: SyncReport = {
|
||||
**counts,
|
||||
"removed_drawers": 0,
|
||||
"removed_closets": 0,
|
||||
"dry_run": dry_run,
|
||||
"by_source": dict(by_source),
|
||||
}
|
||||
|
||||
if dry_run or not removable_ids:
|
||||
return report
|
||||
|
||||
report["removed_drawers"] = _delete_in_batches(col, removable_ids, batch_size, wal_log)
|
||||
|
||||
closets_col = None
|
||||
try:
|
||||
closets_col = get_closets_collection(palace_path, create=False)
|
||||
except Exception as exc:
|
||||
logger.warning("Closet purge skipped (collection unavailable): %s", exc)
|
||||
|
||||
closets_removed = 0
|
||||
if closets_col is not None and removable_sources:
|
||||
closet_ids = (
|
||||
closets_col.get(
|
||||
where={"source_file": {"$in": list(removable_sources)}},
|
||||
include=[],
|
||||
).get("ids")
|
||||
or []
|
||||
)
|
||||
if closet_ids:
|
||||
closets_col.delete(ids=closet_ids)
|
||||
closets_removed = len(closet_ids)
|
||||
report["removed_closets"] = closets_removed
|
||||
return report
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MineAlreadyRunning",
|
||||
"SyncReport",
|
||||
"sync_palace",
|
||||
]
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the MemPalace package version."""
|
||||
|
||||
__version__ = "3.3.1"
|
||||
__version__ = "3.3.5"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
OpenArena owner claim verification for MemPalace/mempalace: 09AE2C2E66CC4B5CBD7D
|
||||
+23
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mempalace"
|
||||
version = "3.3.1"
|
||||
version = "3.3.5"
|
||||
description = "Give your AI a memory — mine projects and conversations into a searchable palace. No API key required."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -21,12 +21,15 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
dependencies = [
|
||||
"chromadb>=0.5.0",
|
||||
"chromadb>=1.5.4,<2",
|
||||
"pyyaml>=6.0,<7",
|
||||
"tomli>=2.0.0; python_version < '3.11'",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -36,10 +39,28 @@ Repository = "https://github.com/MemPalace/mempalace"
|
||||
|
||||
[project.scripts]
|
||||
mempalace = "mempalace.cli:main"
|
||||
mempalace-mcp = "mempalace.mcp_server:main"
|
||||
|
||||
[project.entry-points."mempalace.backends"]
|
||||
chroma = "mempalace.backends.chroma:ChromaBackend"
|
||||
|
||||
# RFC 002 source-adapter entry-point group. Core publishes no first-party
|
||||
# adapters under this group yet; ``miner.py`` and ``convo_miner.py`` migrate
|
||||
# onto ``BaseSourceAdapter`` in a follow-up PR. Third-party adapter packages
|
||||
# (``mempalace-source-cursor``, ``mempalace-source-git``, …) register here.
|
||||
[project.entry-points."mempalace.sources"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0", "psutil>=5.9"]
|
||||
spellcheck = ["autocorrect>=2.0"]
|
||||
# Hardware acceleration for the ONNX embedding model. Install exactly one:
|
||||
# pip install mempalace[gpu] — NVIDIA CUDA
|
||||
# pip install mempalace[dml] — DirectML (Windows AMD/Intel/NVIDIA)
|
||||
# pip install mempalace[coreml] — macOS Neural Engine
|
||||
# After install, set MEMPALACE_EMBEDDING_DEVICE=cuda|dml|coreml (or "auto").
|
||||
gpu = ["onnxruntime-gpu>=1.16"]
|
||||
dml = ["onnxruntime-directml>=1.16"]
|
||||
coreml = ["onnxruntime>=1.16"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0", "psutil>=5.9"]
|
||||
|
||||
@@ -40,8 +40,9 @@ def _patch_mcp_config(monkeypatch, palace_path, tmp_path):
|
||||
|
||||
import mempalace.mcp_server as mcp_mod
|
||||
|
||||
kg = KnowledgeGraph(db_path=str(tmp_path / "kg.sqlite3"))
|
||||
monkeypatch.setattr(mcp_mod, "_config", cfg)
|
||||
monkeypatch.setattr(mcp_mod, "_kg", KnowledgeGraph(db_path=str(tmp_path / "kg.sqlite3")))
|
||||
monkeypatch.setattr(mcp_mod, "_get_kg", lambda: kg)
|
||||
|
||||
|
||||
def _get_rss_mb():
|
||||
|
||||
@@ -84,8 +84,9 @@ class TestToolStatusMemoryProfile:
|
||||
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path / "cfg"))
|
||||
monkeypatch.setattr(cfg, "_file_config", {"palace_path": palace_path})
|
||||
kg = KnowledgeGraph(db_path=str(tmp_path / "kg.sqlite3"))
|
||||
monkeypatch.setattr(mcp_mod, "_config", cfg)
|
||||
monkeypatch.setattr(mcp_mod, "_kg", KnowledgeGraph(db_path=str(tmp_path / "kg.sqlite3")))
|
||||
monkeypatch.setattr(mcp_mod, "_get_kg", lambda: kg)
|
||||
|
||||
from mempalace.mcp_server import tool_status
|
||||
|
||||
|
||||
+31
-4
@@ -36,14 +36,41 @@ from mempalace.knowledge_graph import KnowledgeGraph # noqa: E402
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_mcp_cache():
|
||||
"""Reset the MCP server's cached ChromaDB client/collection between tests."""
|
||||
"""Reset cached MCP state between tests without importing mcp_server.
|
||||
|
||||
If mempalace.mcp_server is already imported, close/clear its KG cache and
|
||||
Chroma client cache. If it has not been imported, leave it unloaded so
|
||||
fork/spawn-based tests do not inherit extra Chroma/SQLite state.
|
||||
"""
|
||||
|
||||
def _clear_cache():
|
||||
try:
|
||||
from mempalace import mcp_server
|
||||
import sys
|
||||
|
||||
mcp_server._client_cache = None
|
||||
mcp_server._collection_cache = None
|
||||
mcp_server = sys.modules.get("mempalace.mcp_server")
|
||||
if mcp_server is not None:
|
||||
for kg in list(getattr(mcp_server, "_kg_by_path", {}).values()):
|
||||
close = getattr(kg, "close", None)
|
||||
if close is not None:
|
||||
try:
|
||||
close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(mcp_server, "_kg_by_path"):
|
||||
mcp_server._kg_by_path.clear()
|
||||
|
||||
mcp_server._client_cache = None
|
||||
mcp_server._collection_cache = None
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Reset the per-process quarantine gate so tests don't leak
|
||||
# state through ChromaBackend._quarantined_paths.
|
||||
from mempalace.backends.chroma import ChromaBackend
|
||||
|
||||
ChromaBackend._quarantined_paths.clear()
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
+1157
-41
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
||||
"""Tests for ChromaCollection's palace-write-lock integration.
|
||||
|
||||
Closes the gap left by ``mine_palace_lock`` only protecting the
|
||||
``mempalace mine`` pipeline: MCP/direct writers that call
|
||||
``ChromaCollection.add/upsert/update/delete`` must also serialize against
|
||||
mine and against each other to avoid the multi-threaded HNSW corruption
|
||||
documented in #974/#965.
|
||||
|
||||
Property tested:
|
||||
|
||||
* ``ChromaCollection(c, palace_path=p)`` wraps every write with
|
||||
``mine_palace_lock(p)``.
|
||||
* Writes raise ``MineAlreadyRunning`` when another holder owns the lock
|
||||
(instead of silently racing into the underlying chromadb call).
|
||||
* Re-entrant composition with ``miner.mine()`` does not self-deadlock:
|
||||
``with mine_palace_lock(p): col.upsert(...)`` runs to completion.
|
||||
* ``ChromaCollection(c)`` (no palace_path) preserves legacy no-lock
|
||||
behaviour for tests/callers that build the adapter directly without
|
||||
going through ``ChromaBackend``.
|
||||
|
||||
POSIX-only: ``mine_palace_lock`` uses ``fcntl`` on Unix and ``msvcrt`` on
|
||||
Windows; the contention semantics differ enough that the cross-process
|
||||
tests are skipped on Windows runners.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from mempalace.backends.chroma import ChromaCollection
|
||||
from mempalace.palace import MineAlreadyRunning, mine_palace_lock
|
||||
|
||||
|
||||
def _get_mp_context():
|
||||
"""Same start-method picker as test_palace_locks.py — ``spawn`` everywhere.
|
||||
|
||||
``fork`` deadlocks under Python 3.13 when the parent is multi-threaded
|
||||
(pytest + chromadb + onnxruntime), and macOS forbids fork-without-exec via
|
||||
CoreFoundation. ``spawn`` is slower (re-imports) but safe.
|
||||
"""
|
||||
return multiprocessing.get_context("spawn")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeChromaCollection:
|
||||
"""Records calls; never blocks. Stand-in for chromadb.Collection."""
|
||||
|
||||
def __init__(self):
|
||||
self.adds: list[dict] = []
|
||||
self.upserts: list[dict] = []
|
||||
self.updates: list[dict] = []
|
||||
self.deletes: list[dict] = []
|
||||
|
||||
def add(self, **kwargs):
|
||||
self.adds.append(kwargs)
|
||||
|
||||
def upsert(self, **kwargs):
|
||||
self.upserts.append(kwargs)
|
||||
|
||||
def update(self, **kwargs):
|
||||
self.updates.append(kwargs)
|
||||
|
||||
def delete(self, **kwargs):
|
||||
self.deletes.append(kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hold_lock(palace_path: str, ready_flag: str, release_flag: str) -> int:
|
||||
"""Acquire ``mine_palace_lock``, signal readiness, wait for release.
|
||||
|
||||
Mirrors the helper in ``test_palace_locks.py`` so the contention
|
||||
semantics match across both test files.
|
||||
"""
|
||||
try:
|
||||
with mine_palace_lock(palace_path):
|
||||
open(ready_flag, "w").close()
|
||||
for _ in range(500):
|
||||
if os.path.exists(release_flag):
|
||||
return 0
|
||||
time.sleep(0.01)
|
||||
return 0
|
||||
except MineAlreadyRunning:
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — opt-in lock wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_palace_path_none_skips_lock(tmp_path, monkeypatch):
|
||||
"""Legacy callers (``ChromaCollection(c)``) keep no-lock behaviour.
|
||||
|
||||
A ``ChromaCollection`` built without ``palace_path`` must not touch the
|
||||
lock infrastructure at all. This guards against regressions where a
|
||||
test or third-party caller relies on the historical bare-write path.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
fake = _FakeChromaCollection()
|
||||
col = ChromaCollection(fake) # no palace_path -> no lock
|
||||
|
||||
# Hold the lock in a child process. Without palace_path, the parent
|
||||
# write must still succeed (the lock does not gate this caller).
|
||||
palace = str(tmp_path / "palace")
|
||||
ready = str(tmp_path / "ready")
|
||||
release = str(tmp_path / "release")
|
||||
ctx = _get_mp_context()
|
||||
holder = ctx.Process(target=_hold_lock, args=(palace, ready, release))
|
||||
holder.start()
|
||||
try:
|
||||
for _ in range(500):
|
||||
if os.path.exists(ready):
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert os.path.exists(ready), "holder failed to acquire lock"
|
||||
|
||||
col.upsert(documents=["doc"], ids=["id-1"])
|
||||
assert fake.upserts == [{"documents": ["doc"], "ids": ["id-1"]}]
|
||||
finally:
|
||||
open(release, "w").close()
|
||||
holder.join(timeout=5)
|
||||
|
||||
|
||||
def test_writer_blocks_during_mine(tmp_path, monkeypatch):
|
||||
"""A held ``mine_palace_lock`` causes ``ChromaCollection`` writes to raise.
|
||||
|
||||
This is the property that closes the MCP-bypass gap: when a mine is in
|
||||
flight, MCP/direct writes raise ``MineAlreadyRunning`` rather than
|
||||
silently entering chromadb's write path concurrent with mine.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
palace = str(tmp_path / "palace")
|
||||
ready = str(tmp_path / "ready")
|
||||
release = str(tmp_path / "release")
|
||||
|
||||
ctx = _get_mp_context()
|
||||
holder = ctx.Process(target=_hold_lock, args=(palace, ready, release))
|
||||
holder.start()
|
||||
try:
|
||||
for _ in range(500):
|
||||
if os.path.exists(ready):
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert os.path.exists(ready), "holder failed to acquire lock"
|
||||
|
||||
fake = _FakeChromaCollection()
|
||||
col = ChromaCollection(fake, palace_path=palace)
|
||||
|
||||
with pytest.raises(MineAlreadyRunning):
|
||||
col.upsert(documents=["doc"], ids=["id-1"])
|
||||
with pytest.raises(MineAlreadyRunning):
|
||||
col.add(documents=["doc"], ids=["id-2"])
|
||||
with pytest.raises(MineAlreadyRunning):
|
||||
col.update(ids=["id-3"], documents=["doc"])
|
||||
with pytest.raises(MineAlreadyRunning):
|
||||
col.delete(ids=["id-4"])
|
||||
|
||||
# The fake must have received NO calls — the lock must gate
|
||||
# before reaching the underlying chromadb layer.
|
||||
assert fake.upserts == []
|
||||
assert fake.adds == []
|
||||
assert fake.updates == []
|
||||
assert fake.deletes == []
|
||||
finally:
|
||||
open(release, "w").close()
|
||||
holder.join(timeout=5)
|
||||
|
||||
|
||||
def test_reentrant_inside_mine_passes_through(tmp_path, monkeypatch):
|
||||
"""``ChromaCollection.upsert`` inside ``mine_palace_lock`` does not deadlock.
|
||||
|
||||
``miner.mine()`` already holds ``mine_palace_lock(palace_path)`` for the
|
||||
full mine pipeline; ``_mine_body`` then calls
|
||||
``collection.upsert(...)``. With the per-thread re-entrant guard in
|
||||
``mine_palace_lock``, the inner acquire is a pass-through and the
|
||||
underlying chromadb call runs immediately.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
palace = str(tmp_path / "palace")
|
||||
fake = _FakeChromaCollection()
|
||||
col = ChromaCollection(fake, palace_path=palace)
|
||||
|
||||
with mine_palace_lock(palace):
|
||||
# If the re-entrant guard were missing, this would self-deadlock on
|
||||
# the underlying flock. We rely on pytest-timeout (configured in
|
||||
# pyproject.toml) to enforce this in CI; the assertion just confirms
|
||||
# the call landed.
|
||||
col.upsert(documents=["d"], ids=["i"], metadatas=[{"k": "v"}])
|
||||
col.add(documents=["d2"], ids=["i2"])
|
||||
col.update(ids=["i"], documents=["d-updated"])
|
||||
col.delete(ids=["i2"])
|
||||
|
||||
assert len(fake.upserts) == 1
|
||||
assert len(fake.adds) == 1
|
||||
assert len(fake.updates) == 1
|
||||
assert len(fake.deletes) == 1
|
||||
|
||||
|
||||
class _SlowFakeChromaCollection(_FakeChromaCollection):
|
||||
"""Fake whose write methods hold the caller for ``hold_seconds``.
|
||||
|
||||
Used to keep ``mine_palace_lock`` acquired long enough for a sibling
|
||||
process to contend deterministically.
|
||||
"""
|
||||
|
||||
def __init__(self, hold_seconds: float = 0.3):
|
||||
super().__init__()
|
||||
self._hold = hold_seconds
|
||||
|
||||
def upsert(self, **kwargs):
|
||||
time.sleep(self._hold)
|
||||
super().upsert(**kwargs)
|
||||
|
||||
|
||||
def _slow_writer_target(palace_path, tmp_path_str, pid, result_q):
|
||||
"""Subprocess target: try a slow upsert, report ok/busy."""
|
||||
os.environ["HOME"] = tmp_path_str
|
||||
# Fresh import inside child so HOME monkeypatch routes the lock dir.
|
||||
from mempalace.backends.chroma import ChromaCollection as _CC
|
||||
from mempalace.palace import MineAlreadyRunning as _MAR
|
||||
|
||||
fake = _SlowFakeChromaCollection(hold_seconds=0.3)
|
||||
col = _CC(fake, palace_path=palace_path)
|
||||
try:
|
||||
col.upsert(documents=[f"d{pid}"], ids=[f"i{pid}"])
|
||||
result_q.put(("ok", pid))
|
||||
except _MAR:
|
||||
result_q.put(("busy", pid))
|
||||
|
||||
|
||||
def test_concurrent_writers_serialize(tmp_path, monkeypatch):
|
||||
"""Two processes calling ``ChromaCollection.upsert`` against the same
|
||||
palace must be serialized: at most one enters chromadb at a time, the
|
||||
other raises ``MineAlreadyRunning``.
|
||||
|
||||
This is the property that prevents the parallel HNSW insert race that
|
||||
drives #974/#965 — under concurrent MCP write fan-out, exactly one
|
||||
writer reaches chromadb and the rest fail loudly instead of corrupting
|
||||
the index.
|
||||
|
||||
The slow fake holds the lock for 0.3s per writer, large enough for the
|
||||
second process to contend even on slow CI runners.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
palace = str(tmp_path / "palace")
|
||||
|
||||
ctx = _get_mp_context()
|
||||
result_q = ctx.Queue()
|
||||
|
||||
p1 = ctx.Process(target=_slow_writer_target, args=(palace, str(tmp_path), 1, result_q))
|
||||
p2 = ctx.Process(target=_slow_writer_target, args=(palace, str(tmp_path), 2, result_q))
|
||||
p1.start()
|
||||
# Tiny stagger so p1 wins the race deterministically; without it the
|
||||
# OS scheduler can pick either, which is also a valid outcome but
|
||||
# makes the assertion brittle on slow CI.
|
||||
time.sleep(0.05)
|
||||
p2.start()
|
||||
p1.join(timeout=5)
|
||||
p2.join(timeout=5)
|
||||
|
||||
outcomes = [result_q.get(timeout=1) for _ in range(2)]
|
||||
statuses = sorted(o[0] for o in outcomes)
|
||||
assert statuses == ["busy", "ok"], f"expected one ok + one busy, got {outcomes}"
|
||||
|
||||
|
||||
def test_read_path_does_not_acquire_lock(tmp_path, monkeypatch):
|
||||
"""``query`` / ``get`` / ``count`` must not be gated by the write lock.
|
||||
|
||||
Read traffic is the dominant workload (semantic search, MCP get, etc.)
|
||||
and serializing it against mine would tank latency for no correctness
|
||||
benefit. This test pins that property: with another process holding
|
||||
the write lock, reads must still complete instantly.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
palace = str(tmp_path / "palace")
|
||||
ready = str(tmp_path / "ready")
|
||||
release = str(tmp_path / "release")
|
||||
|
||||
ctx = _get_mp_context()
|
||||
holder = ctx.Process(target=_hold_lock, args=(palace, ready, release))
|
||||
holder.start()
|
||||
try:
|
||||
for _ in range(500):
|
||||
if os.path.exists(ready):
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert os.path.exists(ready), "holder failed to acquire lock"
|
||||
|
||||
# _FakeChromaCollection doesn't implement query/get/count; we only
|
||||
# need to confirm the wrapper does not call into mine_palace_lock
|
||||
# for reads, which we assert by observing the wrapped methods are
|
||||
# NOT in ChromaCollection's _write_lock path. A direct check via
|
||||
# source inspection is more honest than mocking the entire chroma
|
||||
# surface here.
|
||||
import inspect
|
||||
|
||||
from mempalace.backends.chroma import ChromaCollection as _CC
|
||||
|
||||
for write_attr in ("add", "upsert", "update", "delete"):
|
||||
src = inspect.getsource(getattr(_CC, write_attr))
|
||||
assert "_write_lock" in src, f"{write_attr} should acquire write lock"
|
||||
|
||||
for read_attr in ("query", "get", "count"):
|
||||
method = getattr(_CC, read_attr, None)
|
||||
if method is None:
|
||||
continue
|
||||
src = inspect.getsource(method)
|
||||
assert (
|
||||
"_write_lock" not in src
|
||||
), f"{read_attr} must NOT acquire the write lock (read path)"
|
||||
finally:
|
||||
open(release, "w").close()
|
||||
holder.join(timeout=5)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Execution tests for Claude plugin hook wrapper scripts."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PLUGIN_HOOKS_DIR = REPO_ROOT / ".claude-plugin" / "hooks"
|
||||
BASH = shutil.which("bash")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
BASH is None,
|
||||
reason="bash required for Claude plugin hook wrapper tests",
|
||||
)
|
||||
|
||||
SCRIPT_CASES = [
|
||||
("mempal-stop-hook.sh", "stop"),
|
||||
("mempal-precompact-hook.sh", "precompact"),
|
||||
]
|
||||
|
||||
|
||||
def _shell_path(path: Path) -> str:
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def _write_executable(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def _make_bin_dir(tmp_path: Path, executables: dict[str, str]) -> Path:
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
for name, content in executables.items():
|
||||
_write_executable(bin_dir / name, content)
|
||||
return bin_dir
|
||||
|
||||
|
||||
def _capture_stdin_to(output_path: Path) -> str:
|
||||
return (
|
||||
'stdin_payload=""\n'
|
||||
'while IFS= read -r line || [ -n "$line" ]; do\n'
|
||||
' stdin_payload="${stdin_payload}${line}"\n'
|
||||
"done\n"
|
||||
f'printf \'%s\' "$stdin_payload" > "{_shell_path(output_path)}"\n'
|
||||
)
|
||||
|
||||
|
||||
def _run_hook(
|
||||
script_name: str,
|
||||
payload: str,
|
||||
bin_dir: Path,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
assert BASH is not None
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = str(bin_dir)
|
||||
|
||||
return subprocess.run(
|
||||
[BASH, _shell_path(PLUGIN_HOOKS_DIR / script_name)],
|
||||
input=payload,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
|
||||
def test_plugin_hook_wrapper_prefers_mempalace_cli(
|
||||
tmp_path: Path, script_name: str, hook_name: str
|
||||
) -> None:
|
||||
args_file = tmp_path / "args.txt"
|
||||
stdin_file = tmp_path / "stdin.json"
|
||||
|
||||
bin_dir = _make_bin_dir(
|
||||
tmp_path,
|
||||
{
|
||||
"mempalace": (
|
||||
"#!/bin/sh\n"
|
||||
f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n'
|
||||
f"{_capture_stdin_to(stdin_file)}"
|
||||
"printf '{}\\n'\n"
|
||||
),
|
||||
"python": "#!/bin/sh\nexit 99\n",
|
||||
"python3": "#!/bin/sh\nexit 99\n",
|
||||
},
|
||||
)
|
||||
|
||||
payload = '{"session_id":"abc123"}'
|
||||
result = _run_hook(script_name, payload, bin_dir)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "{}\n"
|
||||
assert (
|
||||
args_file.read_text(encoding="utf-8")
|
||||
== f"hook run --hook {hook_name} --harness claude-code"
|
||||
)
|
||||
assert stdin_file.read_text(encoding="utf-8") == payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
|
||||
@pytest.mark.parametrize("python_name", ["python3", "python"])
|
||||
def test_plugin_hook_wrapper_falls_back_to_importable_python(
|
||||
tmp_path: Path, script_name: str, hook_name: str, python_name: str
|
||||
) -> None:
|
||||
args_file = tmp_path / "args.txt"
|
||||
stdin_file = tmp_path / "stdin.json"
|
||||
|
||||
python_stub = (
|
||||
"#!/bin/sh\n"
|
||||
'if [ "$1" = "-c" ]; then\n'
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n'
|
||||
f"{_capture_stdin_to(stdin_file)}"
|
||||
"printf '{}\\n'\n"
|
||||
)
|
||||
bin_dir = _make_bin_dir(tmp_path, {python_name: python_stub})
|
||||
|
||||
payload = '{"session_id":"xyz789"}'
|
||||
result = _run_hook(script_name, payload, bin_dir)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "{}\n"
|
||||
assert (
|
||||
args_file.read_text(encoding="utf-8")
|
||||
== f"-m mempalace hook run --hook {hook_name} --harness claude-code"
|
||||
)
|
||||
assert stdin_file.read_text(encoding="utf-8") == payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
|
||||
def test_plugin_hook_wrapper_errors_cleanly_when_no_runner_exists(
|
||||
tmp_path: Path, script_name: str, hook_name: str
|
||||
) -> None:
|
||||
bin_dir = _make_bin_dir(tmp_path, {})
|
||||
|
||||
payload = '{"session_id":"no-runner"}'
|
||||
result = _run_hook(script_name, payload, bin_dir)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert result.stdout == ""
|
||||
assert "could not find a runnable mempalace command or module" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
|
||||
def test_plugin_hook_wrapper_falls_back_to_python_when_python3_cannot_import(
|
||||
tmp_path: Path, script_name: str, hook_name: str
|
||||
) -> None:
|
||||
args_file = tmp_path / "args.txt"
|
||||
stdin_file = tmp_path / "stdin.json"
|
||||
bad_python3_used = tmp_path / "bad_python3_used.txt"
|
||||
|
||||
bin_dir = _make_bin_dir(
|
||||
tmp_path,
|
||||
{
|
||||
"python3": (
|
||||
"#!/bin/sh\n"
|
||||
'if [ "$1" = "-c" ]; then\n'
|
||||
" exit 1\n"
|
||||
"fi\n"
|
||||
f"printf 'used' > \"{_shell_path(bad_python3_used)}\"\n"
|
||||
"echo 'No module named mempalace' >&2\n"
|
||||
"exit 1\n"
|
||||
),
|
||||
"python": (
|
||||
"#!/bin/sh\n"
|
||||
'if [ "$1" = "-c" ]; then\n'
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n'
|
||||
f"{_capture_stdin_to(stdin_file)}"
|
||||
"printf '{}\\n'\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
payload = '{"session_id":"fallback"}'
|
||||
result = _run_hook(script_name, payload, bin_dir)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "{}\n"
|
||||
assert (
|
||||
args_file.read_text(encoding="utf-8")
|
||||
== f"-m mempalace hook run --hook {hook_name} --harness claude-code"
|
||||
)
|
||||
assert stdin_file.read_text(encoding="utf-8") == payload
|
||||
assert not bad_python3_used.exists()
|
||||
+627
-9
@@ -1,9 +1,11 @@
|
||||
"""Tests for mempalace.cli — the main CLI dispatcher."""
|
||||
|
||||
import argparse
|
||||
import shlex
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -108,6 +110,7 @@ def test_cmd_init_no_entities(mock_config_cls, tmp_path):
|
||||
with (
|
||||
patch("mempalace.entity_detector.scan_for_detection", return_value=[]),
|
||||
patch("mempalace.room_detector_local.detect_rooms_local") as mock_rooms,
|
||||
patch("mempalace.cli._maybe_run_mine_after_init"),
|
||||
):
|
||||
cmd_init(args)
|
||||
mock_rooms.assert_called_once_with(project_dir=str(tmp_path), yes=True)
|
||||
@@ -125,11 +128,109 @@ def test_cmd_init_with_entities(mock_config_cls, tmp_path):
|
||||
patch("mempalace.entity_detector.detect_entities", return_value=detected),
|
||||
patch("mempalace.entity_detector.confirm_entities", return_value=confirmed),
|
||||
patch("mempalace.room_detector_local.detect_rooms_local"),
|
||||
# Pass 0 (corpus_origin) needs real file IO; this test mocks
|
||||
# builtins.open globally for the entities.json write, which would
|
||||
# break Pass 0's file-reading path. Patch Pass 0 out — a separate
|
||||
# suite (tests/test_corpus_origin_integration.py) covers it directly.
|
||||
patch("mempalace.cli._run_pass_zero", return_value=None),
|
||||
patch("builtins.open", MagicMock()),
|
||||
patch("mempalace.cli._maybe_run_mine_after_init"),
|
||||
):
|
||||
cmd_init(args)
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_init_normalizes_wing_name_for_topics_registry(mock_config_cls, tmp_path):
|
||||
"""Regression for #1194: hyphenated dir names must be normalized to the
|
||||
same slug ``mempalace.yaml`` uses, otherwise ``topics_by_wing`` keys
|
||||
miss the miner's lookup at mine time and tunnels are silently dropped.
|
||||
"""
|
||||
project = tmp_path / "my-cool-app"
|
||||
project.mkdir()
|
||||
fake_files = [project / "a.txt"]
|
||||
detected = {
|
||||
"people": [{"name": "Alice"}],
|
||||
"projects": [],
|
||||
"topics": [{"name": "Bun"}],
|
||||
"uncertain": [],
|
||||
}
|
||||
confirmed = {"people": ["Alice"], "projects": [], "topics": ["Bun"]}
|
||||
args = argparse.Namespace(dir=str(project), yes=True)
|
||||
with (
|
||||
patch("mempalace.entity_detector.scan_for_detection", return_value=fake_files),
|
||||
patch("mempalace.entity_detector.detect_entities", return_value=detected),
|
||||
patch("mempalace.entity_detector.confirm_entities", return_value=confirmed),
|
||||
patch("mempalace.miner.add_to_known_entities") as mock_register,
|
||||
patch("mempalace.room_detector_local.detect_rooms_local"),
|
||||
patch("builtins.open", MagicMock()),
|
||||
patch("mempalace.cli._maybe_run_mine_after_init"),
|
||||
# Pass-zero corpus-origin detection runs unconditionally inside
|
||||
# cmd_init now (#1221 / #1223). It accesses MempalaceConfig fields
|
||||
# that don't survive MagicMock stringification, so stub it out —
|
||||
# this test only cares about the wing-slug write to the registry.
|
||||
patch("mempalace.cli._run_pass_zero", return_value=None),
|
||||
):
|
||||
mock_register.return_value = "/tmp/known_entities.json"
|
||||
cmd_init(args)
|
||||
mock_register.assert_called_once()
|
||||
assert mock_register.call_args.kwargs["wing"] == "my_cool_app"
|
||||
|
||||
|
||||
def test_cmd_init_honors_palace_flag(tmp_path, monkeypatch):
|
||||
"""Regression for #1313: ``cmd_init`` must honor ``--palace`` instead of
|
||||
silently writing to ``~/.mempalace``. Mirrors the env-var pattern used
|
||||
by ``cmd_mine`` / ``cmd_status`` / ``mcp_server`` so every downstream
|
||||
read of ``cfg.palace_path`` (Pass 0, ``cfg.init()``, post-init mine)
|
||||
routes to the user-specified location.
|
||||
"""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
palace = tmp_path / "custom_palace"
|
||||
|
||||
# Make sure no leftover env var from another test leaks in — we want to
|
||||
# verify that --palace ALONE drives the resolution. Prime monkeypatch's
|
||||
# undo list with setenv first so that the env var ``cmd_init`` writes
|
||||
# below is rolled back at teardown (``delenv(raising=False)`` on a
|
||||
# missing key registers no undo entry, which would leak into the next
|
||||
# test).
|
||||
monkeypatch.setenv("MEMPALACE_PALACE_PATH", "")
|
||||
monkeypatch.setenv("MEMPAL_PALACE_PATH", "")
|
||||
monkeypatch.delenv("MEMPALACE_PALACE_PATH")
|
||||
monkeypatch.delenv("MEMPAL_PALACE_PATH")
|
||||
|
||||
args = argparse.Namespace(
|
||||
dir=str(project),
|
||||
palace=str(palace),
|
||||
yes=True,
|
||||
auto_mine=False,
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_pass_zero(project_dir, palace_dir, llm_provider):
|
||||
# Capture the palace_dir Pass 0 sees — this is the smoking-gun
|
||||
# value for the bug. Pre-fix it was always ~/.mempalace.
|
||||
captured["pass_zero_palace_dir"] = palace_dir
|
||||
return None
|
||||
|
||||
with (
|
||||
patch("mempalace.entity_detector.scan_for_detection", return_value=[]),
|
||||
patch("mempalace.room_detector_local.detect_rooms_local"),
|
||||
patch("mempalace.cli._run_pass_zero", side_effect=fake_pass_zero),
|
||||
patch("mempalace.cli._maybe_run_mine_after_init"),
|
||||
):
|
||||
cmd_init(args)
|
||||
|
||||
expected = str(palace)
|
||||
# Pass 0 must have been handed the --palace location, not ~/.mempalace.
|
||||
assert captured["pass_zero_palace_dir"] == expected
|
||||
# And the env var must point at the custom palace so any downstream
|
||||
# ``cfg.palace_path`` read in this process resolves correctly too.
|
||||
import os
|
||||
|
||||
assert os.environ.get("MEMPALACE_PALACE_PATH") == os.path.abspath(expected)
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_init_with_entities_zero_total(mock_config_cls, tmp_path, capsys):
|
||||
"""When entities detected but total is 0, prints 'No entities' message."""
|
||||
@@ -140,12 +241,238 @@ def test_cmd_init_with_entities_zero_total(mock_config_cls, tmp_path, capsys):
|
||||
patch("mempalace.entity_detector.scan_for_detection", return_value=fake_files),
|
||||
patch("mempalace.entity_detector.detect_entities", return_value=detected),
|
||||
patch("mempalace.room_detector_local.detect_rooms_local"),
|
||||
patch("mempalace.cli._maybe_run_mine_after_init"),
|
||||
):
|
||||
cmd_init(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "No entities detected" in out
|
||||
|
||||
|
||||
# ── _maybe_run_mine_after_init (init → mine prompt, #1181) ─────────────
|
||||
|
||||
|
||||
def _init_args(tmp_path, *, yes=False, auto_mine=False):
|
||||
return argparse.Namespace(dir=str(tmp_path), yes=yes, auto_mine=auto_mine)
|
||||
|
||||
|
||||
def _fake_cfg(tmp_path):
|
||||
cfg = MagicMock()
|
||||
cfg.palace_path = str(tmp_path / "palace")
|
||||
return cfg
|
||||
|
||||
|
||||
def _fake_scanned(tmp_path, n=3):
|
||||
"""Build n real Path objects with stat()-able sizes for the scan estimate."""
|
||||
paths = []
|
||||
for i in range(n):
|
||||
p = tmp_path / f"f{i}.txt"
|
||||
p.write_text("x" * 1024) # 1 KB each
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
|
||||
def test_maybe_run_mine_prompt_accepted_runs_mine(tmp_path):
|
||||
"""Empty / 'y' / 'yes' on the prompt triggers mine() in-process."""
|
||||
from mempalace.cli import _maybe_run_mine_after_init
|
||||
|
||||
args = _init_args(tmp_path, yes=False, auto_mine=False)
|
||||
cfg = _fake_cfg(tmp_path)
|
||||
scanned = _fake_scanned(tmp_path, n=3)
|
||||
with (
|
||||
patch("mempalace.miner.mine") as mock_mine,
|
||||
patch("mempalace.miner.scan_project", return_value=scanned),
|
||||
patch("builtins.input", return_value=""),
|
||||
):
|
||||
_maybe_run_mine_after_init(args, cfg)
|
||||
mock_mine.assert_called_once_with(
|
||||
project_dir=str(tmp_path),
|
||||
palace_path=cfg.palace_path,
|
||||
files=scanned,
|
||||
)
|
||||
|
||||
|
||||
def test_maybe_run_mine_prompt_yes_accepted_runs_mine(tmp_path):
|
||||
"""Explicit 'y' answer also runs mine()."""
|
||||
from mempalace.cli import _maybe_run_mine_after_init
|
||||
|
||||
args = _init_args(tmp_path, yes=False, auto_mine=False)
|
||||
cfg = _fake_cfg(tmp_path)
|
||||
with (
|
||||
patch("mempalace.miner.mine") as mock_mine,
|
||||
patch("mempalace.miner.scan_project", return_value=[]),
|
||||
patch("builtins.input", return_value="Y"),
|
||||
):
|
||||
_maybe_run_mine_after_init(args, cfg)
|
||||
mock_mine.assert_called_once()
|
||||
|
||||
|
||||
def test_maybe_run_mine_prompt_declined_prints_hint(tmp_path, capsys):
|
||||
"""'n' answer skips mine() and prints the resume hint."""
|
||||
from mempalace.cli import _maybe_run_mine_after_init
|
||||
|
||||
args = _init_args(tmp_path, yes=False, auto_mine=False)
|
||||
cfg = _fake_cfg(tmp_path)
|
||||
with (
|
||||
patch("mempalace.miner.mine") as mock_mine,
|
||||
patch("mempalace.miner.scan_project", return_value=[]),
|
||||
patch("builtins.input", return_value="n"),
|
||||
):
|
||||
_maybe_run_mine_after_init(args, cfg)
|
||||
mock_mine.assert_not_called()
|
||||
out = capsys.readouterr().out
|
||||
# shlex.quote is a no-op on POSIX-safe paths but wraps Windows paths
|
||||
# (which contain backslashes) in single quotes, so the assertion has
|
||||
# to mirror what the production code actually emits.
|
||||
assert f"mempalace mine {shlex.quote(str(tmp_path))}" in out
|
||||
assert "Skipped" in out
|
||||
|
||||
|
||||
def test_maybe_run_mine_yes_alone_still_prompts(tmp_path):
|
||||
"""`--yes` is scoped to entity auto-accept and MUST still prompt for mine.
|
||||
|
||||
Regression guard for the flag-overload review feedback on #1183: extending
|
||||
`--yes` to also auto-mine would silently change behaviour for scripted
|
||||
callers and turn a fast command into a minutes-long ChromaDB write.
|
||||
"""
|
||||
from mempalace.cli import _maybe_run_mine_after_init
|
||||
|
||||
args = _init_args(tmp_path, yes=True, auto_mine=False)
|
||||
cfg = _fake_cfg(tmp_path)
|
||||
with (
|
||||
patch("mempalace.miner.mine") as mock_mine,
|
||||
patch("mempalace.miner.scan_project", return_value=[]),
|
||||
patch("builtins.input", return_value="n") as mock_input,
|
||||
):
|
||||
_maybe_run_mine_after_init(args, cfg)
|
||||
mock_input.assert_called_once() # the prompt MUST fire
|
||||
mock_mine.assert_not_called()
|
||||
|
||||
|
||||
def test_maybe_run_mine_auto_mine_skips_prompt(tmp_path):
|
||||
"""`--auto-mine` runs mine() automatically without calling input()."""
|
||||
from mempalace.cli import _maybe_run_mine_after_init
|
||||
|
||||
args = _init_args(tmp_path, yes=False, auto_mine=True)
|
||||
cfg = _fake_cfg(tmp_path)
|
||||
scanned = _fake_scanned(tmp_path, n=2)
|
||||
with (
|
||||
patch("mempalace.miner.mine") as mock_mine,
|
||||
patch("mempalace.miner.scan_project", return_value=scanned),
|
||||
patch("builtins.input", side_effect=AssertionError("input() must not be called")),
|
||||
):
|
||||
_maybe_run_mine_after_init(args, cfg)
|
||||
mock_mine.assert_called_once_with(
|
||||
project_dir=str(tmp_path),
|
||||
palace_path=cfg.palace_path,
|
||||
files=scanned,
|
||||
)
|
||||
|
||||
|
||||
def test_maybe_run_mine_yes_and_auto_mine_fully_noninteractive(tmp_path):
|
||||
"""`--yes --auto-mine` together: never call input(), always mine."""
|
||||
from mempalace.cli import _maybe_run_mine_after_init
|
||||
|
||||
args = _init_args(tmp_path, yes=True, auto_mine=True)
|
||||
cfg = _fake_cfg(tmp_path)
|
||||
with (
|
||||
patch("mempalace.miner.mine") as mock_mine,
|
||||
patch("mempalace.miner.scan_project", return_value=[]),
|
||||
patch("builtins.input", side_effect=AssertionError("input() must not be called")),
|
||||
):
|
||||
_maybe_run_mine_after_init(args, cfg)
|
||||
mock_mine.assert_called_once()
|
||||
|
||||
|
||||
def test_maybe_run_mine_decline_quotes_path_with_spaces(tmp_path, capsys):
|
||||
"""The resume hint must shell-quote the project dir so paths with
|
||||
spaces / metacharacters produce a copy-paste-safe command."""
|
||||
from mempalace.cli import _maybe_run_mine_after_init
|
||||
|
||||
spaced_dir = tmp_path / "my project dir"
|
||||
spaced_dir.mkdir()
|
||||
args = argparse.Namespace(dir=str(spaced_dir), yes=False, auto_mine=False)
|
||||
cfg = _fake_cfg(tmp_path)
|
||||
with (
|
||||
patch("mempalace.miner.mine"),
|
||||
patch("mempalace.miner.scan_project", return_value=[]),
|
||||
patch("builtins.input", return_value="n"),
|
||||
):
|
||||
_maybe_run_mine_after_init(args, cfg)
|
||||
out = capsys.readouterr().out
|
||||
# shlex.quote wraps paths with spaces (and Windows backslashes) in
|
||||
# single quotes — the assertion must use the same shlex form so the
|
||||
# test passes on every platform's tmp_path layout.
|
||||
assert f"mempalace mine {shlex.quote(str(spaced_dir))}" in out
|
||||
# Bare unquoted form must NOT appear — that's the bug we're guarding.
|
||||
assert f"mempalace mine {spaced_dir} " not in out
|
||||
assert f"mempalace mine {spaced_dir}`" not in out
|
||||
|
||||
|
||||
def test_maybe_run_mine_eof_on_stdin_treated_as_decline(tmp_path, capsys):
|
||||
"""Piped / non-interactive stdin (EOFError) declines without crashing."""
|
||||
from mempalace.cli import _maybe_run_mine_after_init
|
||||
|
||||
args = _init_args(tmp_path, yes=False, auto_mine=False)
|
||||
cfg = _fake_cfg(tmp_path)
|
||||
with (
|
||||
patch("mempalace.miner.mine") as mock_mine,
|
||||
patch("mempalace.miner.scan_project", return_value=[]),
|
||||
patch("builtins.input", side_effect=EOFError),
|
||||
):
|
||||
_maybe_run_mine_after_init(args, cfg)
|
||||
mock_mine.assert_not_called()
|
||||
assert "Skipped" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_maybe_run_mine_failure_surfaces_via_exit(tmp_path, capsys):
|
||||
"""Mine errors are not swallowed — they exit non-zero with an error line."""
|
||||
from mempalace.cli import _maybe_run_mine_after_init
|
||||
|
||||
args = _init_args(tmp_path, yes=False, auto_mine=True)
|
||||
cfg = _fake_cfg(tmp_path)
|
||||
with (
|
||||
patch("mempalace.miner.mine", side_effect=RuntimeError("boom")),
|
||||
patch("mempalace.miner.scan_project", return_value=[]),
|
||||
):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_maybe_run_mine_after_init(args, cfg)
|
||||
assert exc_info.value.code == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "boom" in err
|
||||
|
||||
|
||||
def test_maybe_run_mine_estimate_appears_before_prompt(tmp_path, capsys):
|
||||
"""The file-count + size estimate line MUST render BEFORE the prompt.
|
||||
|
||||
Required by the spec: hitting Enter on a default-Y prompt with no size
|
||||
info is a footgun on a real corpus where mine takes minutes. The user
|
||||
must see scope before being asked to confirm.
|
||||
"""
|
||||
from mempalace.cli import _maybe_run_mine_after_init
|
||||
|
||||
args = _init_args(tmp_path, yes=False, auto_mine=False)
|
||||
cfg = _fake_cfg(tmp_path)
|
||||
scanned = _fake_scanned(tmp_path, n=4) # 4 files * 1 KB each
|
||||
captured_when_prompted = {}
|
||||
|
||||
def fake_input(prompt):
|
||||
# Snapshot what stdout looked like at the moment the prompt fires.
|
||||
captured_when_prompted["stdout"] = capsys.readouterr().out
|
||||
return "n"
|
||||
|
||||
with (
|
||||
patch("mempalace.miner.mine"),
|
||||
patch("mempalace.miner.scan_project", return_value=scanned),
|
||||
patch("builtins.input", side_effect=fake_input),
|
||||
):
|
||||
_maybe_run_mine_after_init(args, cfg)
|
||||
|
||||
pre_prompt = captured_when_prompted["stdout"]
|
||||
assert "4 files" in pre_prompt, f"file count missing from pre-prompt output: {pre_prompt!r}"
|
||||
assert "MB" in pre_prompt, f"size estimate missing from pre-prompt output: {pre_prompt!r}"
|
||||
assert "would be mined" in pre_prompt
|
||||
|
||||
|
||||
# ── cmd_mine ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -228,6 +555,45 @@ def test_cmd_mine_include_ignored_comma_split(mock_config_cls):
|
||||
assert call_kwargs["include_ignored"] == ["a.txt", "b.txt", "c.txt"]
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_mine_exits_nonzero_on_lock_holder(mock_config_cls, capsys):
|
||||
"""Regression #1264: lock contention must exit non-zero with a clear message.
|
||||
|
||||
Before this fix the CLI silently returned 0 when another writer held
|
||||
the palace lock — operators using nohup/scripts had no way to detect
|
||||
the contention. The new behavior raises MineAlreadyRunning out of
|
||||
miner.mine() and cmd_mine catches it, printing the holder identity
|
||||
to stderr and exiting non-zero.
|
||||
"""
|
||||
from mempalace.palace import MineAlreadyRunning
|
||||
|
||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||
args = argparse.Namespace(
|
||||
dir="/src",
|
||||
palace=None,
|
||||
mode="projects",
|
||||
wing=None,
|
||||
agent="mempalace",
|
||||
limit=0,
|
||||
dry_run=False,
|
||||
no_gitignore=False,
|
||||
include_ignored=[],
|
||||
extract="exchange",
|
||||
)
|
||||
with patch(
|
||||
"mempalace.miner.mine",
|
||||
side_effect=MineAlreadyRunning(
|
||||
"palace /fake/palace is held by PID 12345 (mempalace mcp_server); wait for it to finish"
|
||||
),
|
||||
):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cmd_mine(args)
|
||||
assert excinfo.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "PID 12345" in captured.err
|
||||
assert "mcp_server" in captured.err
|
||||
|
||||
|
||||
# ── cmd_wakeup ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -334,9 +700,9 @@ def test_mcp_command_prints_setup_guidance(monkeypatch, capsys):
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "MemPalace MCP quick setup:" in captured.out
|
||||
assert "claude mcp add mempalace -- python -m mempalace.mcp_server" in captured.out
|
||||
assert "claude mcp add mempalace -- mempalace-mcp" in captured.out
|
||||
assert "\nOptional custom palace:\n" in captured.out
|
||||
assert "python -m mempalace.mcp_server --palace /path/to/palace" in captured.out
|
||||
assert "mempalace-mcp --palace /path/to/palace" in captured.out
|
||||
assert "[--palace /path/to/palace]" not in captured.out
|
||||
assert captured.err == ""
|
||||
|
||||
@@ -349,7 +715,7 @@ def test_mcp_command_uses_custom_palace_path_when_provided(monkeypatch, capsys):
|
||||
captured = capsys.readouterr()
|
||||
expanded = str(Path("~/tmp/my palace").expanduser())
|
||||
|
||||
assert "python -m mempalace.mcp_server --palace" in captured.out
|
||||
assert "mempalace-mcp --palace" in captured.out
|
||||
assert expanded in captured.out
|
||||
assert "Optional custom palace:" not in captured.out
|
||||
assert "[--palace /path/to/palace]" not in captured.out
|
||||
@@ -448,8 +814,9 @@ def test_cmd_repair_requires_palace_database(mock_config_cls, tmp_path, capsys):
|
||||
def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys):
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
(palace_dir / "chroma.sqlite3").write_text("db")
|
||||
sqlite3.connect(str(palace_dir / "chroma.sqlite3")).close()
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
mock_config_cls.return_value.collection_name = "mempalace_drawers"
|
||||
args = argparse.Namespace(palace=None)
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.get_collection.side_effect = Exception("corrupt db")
|
||||
@@ -463,8 +830,9 @@ def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys):
|
||||
def test_cmd_repair_zero_drawers(mock_config_cls, tmp_path, capsys):
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
(palace_dir / "chroma.sqlite3").write_text("db")
|
||||
sqlite3.connect(str(palace_dir / "chroma.sqlite3")).close()
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
mock_config_cls.return_value.collection_name = "mempalace_drawers"
|
||||
args = argparse.Namespace(palace=None)
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 0
|
||||
@@ -479,8 +847,9 @@ def test_cmd_repair_zero_drawers(mock_config_cls, tmp_path, capsys):
|
||||
def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
(palace_dir / "chroma.sqlite3").write_text("db")
|
||||
sqlite3.connect(str(palace_dir / "chroma.sqlite3")).close()
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
mock_config_cls.return_value.collection_name = "mempalace_drawers"
|
||||
args = argparse.Namespace(palace=None, yes=True)
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 2
|
||||
@@ -489,21 +858,107 @@ def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
|
||||
"documents": ["doc1", "doc2"],
|
||||
"metadatas": [{"wing": "a"}, {"wing": "b"}],
|
||||
}
|
||||
mock_temp_col = MagicMock()
|
||||
mock_temp_col.count.return_value = 2
|
||||
mock_new_col = MagicMock()
|
||||
mock_new_col.count.return_value = 2
|
||||
mock_backend = _mock_backend_for(col=mock_col, new_col=mock_new_col)
|
||||
mock_backend.create_collection.side_effect = [mock_temp_col, mock_new_col]
|
||||
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||
cmd_repair(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "Repair complete" in out
|
||||
assert "2 drawers rebuilt" in out
|
||||
assert mock_backend.delete_collection.call_args_list == [
|
||||
call(str(palace_dir), "mempalace_drawers__repair_tmp"),
|
||||
call(str(palace_dir), "mempalace_drawers"),
|
||||
call(str(palace_dir), "mempalace_drawers__repair_tmp"),
|
||||
]
|
||||
mock_temp_col.upsert.assert_called_once()
|
||||
mock_new_col.upsert.assert_called_once()
|
||||
mock_new_col.add.assert_not_called()
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_repair_uses_configured_collection(mock_config_cls, tmp_path, capsys):
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
sqlite3.connect(str(palace_dir / "chroma.sqlite3")).close()
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
mock_config_cls.return_value.collection_name = "custom_drawers"
|
||||
args = argparse.Namespace(palace=None, yes=True)
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 2
|
||||
mock_col.get.return_value = {
|
||||
"ids": ["id1", "id2"],
|
||||
"documents": ["doc1", "doc2"],
|
||||
"metadatas": [{"wing": "a"}, {"wing": "b"}],
|
||||
}
|
||||
mock_temp_col = MagicMock()
|
||||
mock_temp_col.count.return_value = 2
|
||||
mock_new_col = MagicMock()
|
||||
mock_new_col.count.return_value = 2
|
||||
mock_backend = _mock_backend_for(col=mock_col, new_col=mock_new_col)
|
||||
mock_backend.create_collection.side_effect = [mock_temp_col, mock_new_col]
|
||||
|
||||
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||
cmd_repair(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Repair complete" in out
|
||||
mock_backend.get_collection.assert_called_once_with(str(palace_dir), "custom_drawers")
|
||||
assert mock_backend.create_collection.call_args_list == [
|
||||
call(str(palace_dir), "custom_drawers__repair_tmp"),
|
||||
call(str(palace_dir), "custom_drawers"),
|
||||
]
|
||||
assert mock_backend.delete_collection.call_args_list == [
|
||||
call(str(palace_dir), "custom_drawers__repair_tmp"),
|
||||
call(str(palace_dir), "custom_drawers"),
|
||||
call(str(palace_dir), "custom_drawers__repair_tmp"),
|
||||
]
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_repair_restores_backup_on_live_rebuild_failure(mock_config_cls, tmp_path, capsys):
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
sqlite3.connect(str(palace_dir / "chroma.sqlite3")).close()
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
mock_config_cls.return_value.collection_name = "mempalace_drawers"
|
||||
args = argparse.Namespace(palace=None, yes=True)
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 2
|
||||
mock_col.get.return_value = {
|
||||
"ids": ["id1", "id2"],
|
||||
"documents": ["doc1", "doc2"],
|
||||
"metadatas": [{"wing": "a"}, {"wing": "b"}],
|
||||
}
|
||||
mock_temp_col = MagicMock()
|
||||
mock_temp_col.count.return_value = 2
|
||||
mock_backend = _mock_backend_for(col=mock_col)
|
||||
mock_backend.create_collection.side_effect = [mock_temp_col, RuntimeError("live build failed")]
|
||||
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cmd_repair(args)
|
||||
out = capsys.readouterr().out
|
||||
assert excinfo.value.code == 1
|
||||
assert "Repair failed" in out
|
||||
assert "restoring from backup" in out
|
||||
mock_backend.close_palace.assert_called_once_with(str(palace_dir))
|
||||
assert mock_backend.delete_collection.call_args_list == [
|
||||
call(str(palace_dir), "mempalace_drawers__repair_tmp"),
|
||||
call(str(palace_dir), "mempalace_drawers"),
|
||||
call(str(palace_dir), "mempalace_drawers__repair_tmp"),
|
||||
]
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_repair_aborts_without_confirmation(mock_config_cls, tmp_path, capsys):
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
(palace_dir / "chroma.sqlite3").write_text("db")
|
||||
sqlite3.connect(str(palace_dir / "chroma.sqlite3")).close()
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
mock_config_cls.return_value.collection_name = "mempalace_drawers"
|
||||
args = argparse.Namespace(palace=None)
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 1
|
||||
@@ -618,7 +1073,7 @@ def test_cmd_compress_with_config(mock_config_cls, tmp_path, capsys):
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_compress_stores_results(mock_config_cls, capsys):
|
||||
"""Non-dry-run compress stores to mempalace_compressed collection."""
|
||||
"""Non-dry-run compress stores to mempalace_closets collection (#1244)."""
|
||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||
args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None)
|
||||
mock_col = MagicMock()
|
||||
@@ -656,6 +1111,53 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
|
||||
assert "Stored" in out
|
||||
assert "Total:" in out
|
||||
mock_comp_col.upsert.assert_called_once()
|
||||
# Verify the compress output goes to the closets collection so that
|
||||
# palace.get_closets_collection() / searcher can read it back (#1244).
|
||||
(call_args, _kwargs) = mock_backend.get_or_create_collection.call_args
|
||||
assert (
|
||||
call_args[1] == "mempalace_closets"
|
||||
), f"compress should write to mempalace_closets, got {call_args[1]!r}"
|
||||
assert "mempalace_closets" in out
|
||||
|
||||
|
||||
def test_cmd_compress_output_readable_via_get_closets_collection(tmp_path, capsys):
|
||||
"""End-to-end: cmd_compress output must be readable via the same code
|
||||
path palace.py uses (`get_closets_collection`). Regression for #1244."""
|
||||
from mempalace.backends.chroma import ChromaBackend
|
||||
from mempalace.palace import get_closets_collection, get_collection
|
||||
|
||||
palace_path = str(tmp_path / "palace")
|
||||
|
||||
# Seed a drawer in the palace so cmd_compress has something to compress.
|
||||
drawers = get_collection(palace_path, "mempalace_drawers", create=True)
|
||||
drawers.upsert(
|
||||
ids=["drawer-1"],
|
||||
documents=["The quick brown fox jumps over the lazy dog."],
|
||||
metadatas=[{"wing": "test", "room": "demo", "source_file": "fox.txt"}],
|
||||
)
|
||||
|
||||
args = argparse.Namespace(palace=palace_path, wing=None, dry_run=False, config=None)
|
||||
with patch("mempalace.cli.MempalaceConfig") as mock_config_cls:
|
||||
mock_config_cls.return_value.palace_path = palace_path
|
||||
# Use a real ChromaBackend so the write actually lands on disk and
|
||||
# the read-side helper can find it.
|
||||
with patch("mempalace.backends.chroma.ChromaBackend", side_effect=ChromaBackend):
|
||||
cmd_compress(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Stored" in out
|
||||
|
||||
# Now read via the *same* code path palace.py / searcher uses.
|
||||
closets = get_closets_collection(palace_path, create=False)
|
||||
got = closets.get(ids=["drawer-1"], include=["documents", "metadatas"])
|
||||
assert got["ids"] == ["drawer-1"], (
|
||||
"compressed drawer not found in mempalace_closets — "
|
||||
"cmd_compress wrote to the wrong collection (#1244)"
|
||||
)
|
||||
assert got["documents"] and got["documents"][0], "empty compressed doc"
|
||||
meta = got["metadatas"][0]
|
||||
assert meta.get("wing") == "test"
|
||||
assert "compression_ratio" in meta
|
||||
|
||||
|
||||
def test_cmd_repair_trailing_slash_does_not_recurse():
|
||||
@@ -669,3 +1171,119 @@ def test_cmd_repair_trailing_slash_does_not_recurse():
|
||||
palace_path = os.path.expanduser(args.palace).rstrip(os.sep)
|
||||
backup_path = palace_path + ".backup"
|
||||
assert not backup_path.startswith(palace_path + os.sep)
|
||||
|
||||
|
||||
# ── stdio reconfigure on Windows ─────────────────────────────────────
|
||||
|
||||
|
||||
class _ReconfigurableStringIO:
|
||||
def __init__(self):
|
||||
self.reconfigure_calls = []
|
||||
|
||||
def reconfigure(self, **kwargs):
|
||||
self.reconfigure_calls.append(kwargs)
|
||||
|
||||
|
||||
def test_reconfigures_stdio_to_utf8_on_windows():
|
||||
"""Windows `mempalace` CLI must decode/encode stdio as UTF-8.
|
||||
|
||||
Without this, piped non-ASCII input (`mempalace search ... < q.txt`)
|
||||
or piped non-ASCII output (`mempalace search "..." > out.txt`) is
|
||||
mojibaked through the system ANSI codepage on non-Latin Windows
|
||||
locales (cp1252/cp1251/cp950).
|
||||
"""
|
||||
from mempalace.cli import _reconfigure_stdio_utf8_on_windows
|
||||
|
||||
stdin = _ReconfigurableStringIO()
|
||||
stdout = _ReconfigurableStringIO()
|
||||
stderr = _ReconfigurableStringIO()
|
||||
with (
|
||||
patch.object(sys, "platform", "win32"),
|
||||
patch.object(sys, "stdin", stdin),
|
||||
patch.object(sys, "stdout", stdout),
|
||||
patch.object(sys, "stderr", stderr),
|
||||
):
|
||||
_reconfigure_stdio_utf8_on_windows()
|
||||
|
||||
# Per-stream errors policy: stdin survives bad bytes via
|
||||
# surrogateescape so a redirected non-UTF-8 file does not crash
|
||||
# the read; stdout/stderr use replace so a drawer carrying a
|
||||
# round-tripped surrogate half does not crash mid-print.
|
||||
assert stdin.reconfigure_calls == [{"encoding": "utf-8", "errors": "surrogateescape"}]
|
||||
assert stdout.reconfigure_calls == [{"encoding": "utf-8", "errors": "replace"}]
|
||||
assert stderr.reconfigure_calls == [{"encoding": "utf-8", "errors": "replace"}]
|
||||
|
||||
|
||||
def test_reconfigure_stdio_is_noop_off_windows():
|
||||
"""Linux/macOS already default to UTF-8 stdio -- helper must not touch streams."""
|
||||
from mempalace.cli import _reconfigure_stdio_utf8_on_windows
|
||||
|
||||
stdin = _ReconfigurableStringIO()
|
||||
with (
|
||||
patch.object(sys, "platform", "linux"),
|
||||
patch.object(sys, "stdin", stdin),
|
||||
):
|
||||
_reconfigure_stdio_utf8_on_windows()
|
||||
|
||||
assert stdin.reconfigure_calls == []
|
||||
|
||||
|
||||
# ── cmd_repair: from-sqlite mode exit codes ──────────────────────────
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_repair_from_sqlite_validation_refusal_exits_nonzero(mock_config_cls, tmp_path, capsys):
|
||||
"""When ``rebuild_from_sqlite`` returns ``{}`` for a validation
|
||||
refusal (missing source DB, in-place without --archive-existing,
|
||||
refusing to overwrite an existing dest), the CLI must surface a
|
||||
non-zero exit so unattended scripts and CI distinguish "invalid
|
||||
inputs" from "successful recovery that found zero rows."
|
||||
|
||||
Catches: a regression where the CLI treats the validation-refusal
|
||||
sentinel as success, leaving CI green on a no-op repair that should
|
||||
have alerted an operator.
|
||||
"""
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
|
||||
args = argparse.Namespace(
|
||||
palace=str(palace_dir),
|
||||
mode="from-sqlite",
|
||||
source=None,
|
||||
archive_existing=False,
|
||||
yes=True,
|
||||
)
|
||||
with patch("mempalace.repair.rebuild_from_sqlite", return_value={}):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cmd_repair(args)
|
||||
assert excinfo.value.code == 1
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_repair_from_sqlite_success_does_not_exit(mock_config_cls, tmp_path):
|
||||
"""A successful from-sqlite rebuild — even one that finds zero rows
|
||||
in a legitimately empty source palace — must NOT call ``sys.exit``.
|
||||
A populated counts dict (with ``0`` values) is the success signal;
|
||||
only the empty dict ``{}`` is reserved for validation refusal.
|
||||
|
||||
Catches: a regression where ``if not counts`` is replaced by
|
||||
``if not sum(counts.values())`` or similar, conflating "empty source"
|
||||
with "validation refused" and breaking idempotent recovery scripts.
|
||||
"""
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
|
||||
args = argparse.Namespace(
|
||||
palace=str(palace_dir),
|
||||
mode="from-sqlite",
|
||||
source=None,
|
||||
archive_existing=False,
|
||||
yes=True,
|
||||
)
|
||||
# Zero rows but per-collection keys present → success, no exit.
|
||||
fake_counts = {"mempalace_drawers": 0, "mempalace_closets": 0}
|
||||
with patch("mempalace.repair.rebuild_from_sqlite", return_value=fake_counts):
|
||||
# Should return cleanly; no SystemExit raised.
|
||||
cmd_repair(args)
|
||||
|
||||
+202
-2
@@ -196,10 +196,34 @@ class TestCallLLM:
|
||||
}
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
parsed, usage = _call_llm(cfg, "/tmp/x", "w", "r", "c")
|
||||
with (
|
||||
patch("urllib.request.urlopen", side_effect=fake_urlopen),
|
||||
patch("mempalace.closet_llm.time.sleep"),
|
||||
):
|
||||
parsed, _ = _call_llm(cfg, "/tmp/x", "w", "r", "c")
|
||||
assert parsed is None
|
||||
|
||||
def test_retries_on_json_decode_error(self):
|
||||
cfg = self._make_cfg()
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
call_count["n"] += 1
|
||||
return _FakeResp(
|
||||
{
|
||||
"choices": [{"message": {"content": "not json at all"}}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1},
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("urllib.request.urlopen", side_effect=fake_urlopen),
|
||||
patch("mempalace.closet_llm.time.sleep"),
|
||||
):
|
||||
parsed, _ = _call_llm(cfg, "/tmp/x", "w", "r", "c")
|
||||
assert parsed is None
|
||||
assert call_count["n"] == 3
|
||||
|
||||
|
||||
# ── regenerate_closets error paths ───────────────────────────────────────
|
||||
|
||||
@@ -296,6 +320,182 @@ class TestRegenerateClosets:
|
||||
assert meta.get("generated_by", "").startswith("llm:")
|
||||
assert meta.get("normalize_version") == NORMALIZE_VERSION
|
||||
|
||||
def test_regen_paginates_drawer_fetch(self, tmp_path):
|
||||
"""Regression for #1073: drawers_col.get must be paginated at
|
||||
batch_size=5000. A single get(limit=total, ...) on a palace with
|
||||
more than SQLite's SQLITE_MAX_VARIABLE_NUMBER (32766) drawers
|
||||
blows up inside chromadb. Matches the miner.status pattern
|
||||
introduced in #851 (see #802, #850, #1073)."""
|
||||
from mempalace import closet_llm as closet_llm_mod
|
||||
|
||||
palace = str(tmp_path / "palace")
|
||||
|
||||
# Build a fake collection: 12_000 drawers across 3 source files,
|
||||
# enough to force 3 batches of batch_size=5000 (5000 + 5000 + 2000).
|
||||
n_drawers = 12_000
|
||||
ids = [f"d{i:05d}" for i in range(n_drawers)]
|
||||
docs = [f"doc body {i}" for i in range(n_drawers)]
|
||||
metas = [
|
||||
{
|
||||
"wing": "w",
|
||||
"room": "r",
|
||||
"source_file": f"/src/file_{i % 3}.md",
|
||||
"entities": "",
|
||||
}
|
||||
for i in range(n_drawers)
|
||||
]
|
||||
|
||||
get_calls: list = []
|
||||
|
||||
class FakeDrawersCol:
|
||||
def count(self):
|
||||
return n_drawers
|
||||
|
||||
def get(self, limit=None, offset=0, include=None, **kwargs):
|
||||
get_calls.append({"limit": limit, "offset": offset, "include": include})
|
||||
end = min(offset + (limit or n_drawers), n_drawers)
|
||||
return {
|
||||
"ids": ids[offset:end],
|
||||
"documents": docs[offset:end],
|
||||
"metadatas": metas[offset:end],
|
||||
}
|
||||
|
||||
class FakeClosetsCol:
|
||||
"""Accept the purge + upsert calls the success path makes."""
|
||||
|
||||
def get(self, *a, **kw):
|
||||
return {"ids": [], "documents": [], "metadatas": []}
|
||||
|
||||
def delete(self, *a, **kw):
|
||||
return None
|
||||
|
||||
def upsert(self, *a, **kw):
|
||||
return None
|
||||
|
||||
fake_drawers = FakeDrawersCol()
|
||||
fake_closets = FakeClosetsCol()
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
return _FakeResp(
|
||||
{
|
||||
"choices": [
|
||||
{"message": {"content": '{"topics":["t1"],"quotes":[],"summary":""}'}}
|
||||
],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1},
|
||||
}
|
||||
)
|
||||
|
||||
cfg = LLMConfig(endpoint="http://local/v1", model="m")
|
||||
|
||||
with (
|
||||
patch.object(closet_llm_mod, "get_collection", return_value=fake_drawers),
|
||||
patch.object(closet_llm_mod, "get_closets_collection", return_value=fake_closets),
|
||||
patch.object(closet_llm_mod, "purge_file_closets", return_value=None),
|
||||
patch.object(closet_llm_mod, "upsert_closet_lines", return_value=None),
|
||||
patch("urllib.request.urlopen", side_effect=fake_urlopen),
|
||||
):
|
||||
result = regenerate_closets(palace, cfg=cfg, dry_run=True)
|
||||
|
||||
# Three paginated calls: (limit=5000, offset=0), (5000, 5000), (5000, 10000).
|
||||
assert len(get_calls) == 3, f"expected 3 batched fetches, got {len(get_calls)}"
|
||||
for call in get_calls:
|
||||
assert (
|
||||
call["limit"] == 5000
|
||||
), f"batch must be 5000 — got {call['limit']} (would risk SQLITE_MAX_VARIABLE_NUMBER)"
|
||||
# include must still request both documents and metadatas
|
||||
assert "documents" in call["include"]
|
||||
assert "metadatas" in call["include"]
|
||||
assert [c["offset"] for c in get_calls] == [0, 5000, 10_000]
|
||||
|
||||
# by_source aggregation must be preserved exactly across batches:
|
||||
# 12_000 drawers, 3 source files → 4_000 drawers each.
|
||||
# dry_run=True short-circuits LLM calls but still walks by_source.
|
||||
assert result.get("processed", 0) == 0 # dry_run
|
||||
# Verify no single call tried to pull more than batch_size.
|
||||
assert max(c["limit"] for c in get_calls) <= 5000
|
||||
|
||||
def test_regen_by_source_aggregates_across_batches(self, tmp_path):
|
||||
"""Pagination must not change the by_source grouping — drawers for
|
||||
the same source_file split across batches still land in one group."""
|
||||
from mempalace import closet_llm as closet_llm_mod
|
||||
|
||||
palace = str(tmp_path / "palace")
|
||||
|
||||
# 7_500 drawers, alternating between two source files → forces
|
||||
# splits across the 5000/2500 boundary. Each source ends up with
|
||||
# 3_750 drawers after regrouping.
|
||||
n_drawers = 7_500
|
||||
ids = [f"d{i:05d}" for i in range(n_drawers)]
|
||||
docs = [f"body-{i}" for i in range(n_drawers)]
|
||||
metas = [
|
||||
{
|
||||
"wing": "w",
|
||||
"room": "r",
|
||||
"source_file": f"/src/file_{i % 2}.md",
|
||||
"entities": "",
|
||||
}
|
||||
for i in range(n_drawers)
|
||||
]
|
||||
|
||||
captured_sources: dict = {}
|
||||
|
||||
class FakeDrawersCol:
|
||||
def count(self):
|
||||
return n_drawers
|
||||
|
||||
def get(self, limit=None, offset=0, include=None, **kwargs):
|
||||
end = min(offset + (limit or n_drawers), n_drawers)
|
||||
return {
|
||||
"ids": ids[offset:end],
|
||||
"documents": docs[offset:end],
|
||||
"metadatas": metas[offset:end],
|
||||
}
|
||||
|
||||
class FakeClosetsCol:
|
||||
def get(self, *a, **kw):
|
||||
return {"ids": [], "documents": [], "metadatas": []}
|
||||
|
||||
def delete(self, *a, **kw):
|
||||
return None
|
||||
|
||||
def upsert(self, *a, **kw):
|
||||
return None
|
||||
|
||||
# Hook _call_llm to inspect what regenerate_closets aggregated
|
||||
# per source before the HTTP boundary.
|
||||
real_call_llm = closet_llm_mod._call_llm
|
||||
|
||||
def spying_call_llm(cfg, source_file, wing, room, content):
|
||||
captured_sources[source_file] = content
|
||||
return (
|
||||
{"topics": ["t"], "quotes": [], "summary": ""},
|
||||
{"prompt_tokens": 1, "completion_tokens": 1},
|
||||
)
|
||||
|
||||
cfg = LLMConfig(endpoint="http://local/v1", model="m")
|
||||
|
||||
with (
|
||||
patch.object(closet_llm_mod, "get_collection", return_value=FakeDrawersCol()),
|
||||
patch.object(closet_llm_mod, "get_closets_collection", return_value=FakeClosetsCol()),
|
||||
patch.object(closet_llm_mod, "purge_file_closets", return_value=None),
|
||||
patch.object(closet_llm_mod, "upsert_closet_lines", return_value=None),
|
||||
patch.object(closet_llm_mod, "_call_llm", side_effect=spying_call_llm),
|
||||
):
|
||||
regenerate_closets(palace, cfg=cfg)
|
||||
|
||||
# Both sources survived the pagination boundary.
|
||||
assert set(captured_sources.keys()) == {"/src/file_0.md", "/src/file_1.md"}
|
||||
# Each source accumulated exactly 3_750 drawer bodies, concatenated
|
||||
# with the "\n\n" separator the regenerate path uses.
|
||||
for source, content in captured_sources.items():
|
||||
assert content.count("\n\n") == 3_749, (
|
||||
f"{source}: expected 3_750 chunks joined (3_749 separators), "
|
||||
f"got {content.count(chr(10) + chr(10)) + 1}"
|
||||
)
|
||||
|
||||
# Silence unused-var lint.
|
||||
assert real_call_llm is not None
|
||||
|
||||
def test_regen_uses_basename_not_split_slash(self, tmp_path, monkeypatch):
|
||||
"""Regression: the old closet_id base used ``source.split('/')[-1]``
|
||||
which silently degrades on Windows paths (``C:\\proj\\a.md`` →
|
||||
|
||||
@@ -23,6 +23,7 @@ Coverage map:
|
||||
cross-diary collisions, force=True purges leftover closets.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
@@ -605,6 +606,84 @@ class TestDiaryIngest:
|
||||
result = ingest_diaries(str(diary_dir), str(palace_dir))
|
||||
assert result["days_updated"] == 0
|
||||
|
||||
def test_ingest_detects_same_size_content_edit(self, tmp_path):
|
||||
# Regression #925: the prior skip-check compared byte length only, so
|
||||
# any in-place edit preserving total length (typo fix "teh"→"the",
|
||||
# word swap, character reorder) was silently dropped. Content-hash
|
||||
# check must catch the change AND rebuild the searchable closet so
|
||||
# the index does not stay stale while the drawer updates.
|
||||
diary_dir = tmp_path / "diaries"
|
||||
diary_dir.mkdir()
|
||||
diary_file = diary_dir / "2026-04-13.md"
|
||||
# Original has the typo "Teh"; the edit fixes it to "The" — same length.
|
||||
original = "# 2026-04-13\n\n## 10:00 — Test\n\nTeh elaborate jakarta postgres bug.\n"
|
||||
edited = "# 2026-04-13\n\n## 10:00 — Test\n\nThe elaborate jakarta postgres bug.\n"
|
||||
assert len(original) == len(edited), "test setup: edited content must be same length"
|
||||
diary_file.write_text(original)
|
||||
palace_dir = tmp_path / "palace"
|
||||
|
||||
from mempalace.diary_ingest import ingest_diaries
|
||||
|
||||
ingest_diaries(str(diary_dir), str(palace_dir), force=True)
|
||||
diary_file.write_text(edited)
|
||||
result = ingest_diaries(str(diary_dir), str(palace_dir))
|
||||
assert result["days_updated"] == 1, "same-size content edit must trigger re-ingest"
|
||||
|
||||
# Drawer must hold the corrected text.
|
||||
drawers = get_collection(str(palace_dir)).get(where={"source_file": str(diary_file)})
|
||||
joined_drawers = "\n".join(drawers["documents"])
|
||||
assert "The elaborate" in joined_drawers
|
||||
assert "Teh elaborate" not in joined_drawers, "drawer still holds pre-edit content"
|
||||
|
||||
# And the closet (search index) must reflect the edit too — not just the
|
||||
# drawer. Otherwise searches would surface stale text.
|
||||
closets = get_closets_collection(str(palace_dir)).get(
|
||||
where={"source_file": str(diary_file)}
|
||||
)
|
||||
joined_closets = "\n".join(closets["documents"])
|
||||
assert "Teh elaborate" not in joined_closets, "closet index still holds stale content"
|
||||
|
||||
def test_legacy_state_backfills_content_hash(self, tmp_path):
|
||||
# Upgraded users can carry legacy state entries without ``content_hash``.
|
||||
# Same-size skip is preserved for that one run, but the hash must be
|
||||
# recorded so the strict check engages on subsequent runs.
|
||||
diary_dir = tmp_path / "diaries"
|
||||
diary_dir.mkdir()
|
||||
diary_file = diary_dir / "2026-04-13.md"
|
||||
# Write explicit UTF-8 so the round-trip matches how diary_ingest reads.
|
||||
# Windows' default text-mode encoding is cp1252; without this the em
|
||||
# dash would round-trip lossy and the hash assertion below would fail.
|
||||
text = "# 2026-04-13\n\n## 10:00 — Test\n\nUnchanged body content here.\n"
|
||||
diary_file.write_text(text, encoding="utf-8")
|
||||
palace_dir = tmp_path / "palace"
|
||||
|
||||
from mempalace.diary_ingest import _state_file_for, ingest_diaries
|
||||
|
||||
# Simulate a legacy state file: only size + entry_count, no content_hash.
|
||||
state_file = _state_file_for(str(palace_dir), diary_dir.resolve())
|
||||
state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
state_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
f"diary|{diary_file.name}": {
|
||||
"size": len(text),
|
||||
"entry_count": 1,
|
||||
"ingested_at": "2026-04-12T00:00:00+00:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Run with no force — size matches, so this should skip ingest.
|
||||
result = ingest_diaries(str(diary_dir), str(palace_dir))
|
||||
assert result["days_updated"] == 0
|
||||
|
||||
# Hash must have been backfilled into state for the next run's strict check.
|
||||
persisted = json.loads(state_file.read_text())
|
||||
entry = persisted[f"diary|{diary_file.name}"]
|
||||
assert "content_hash" in entry, "legacy skip path must record the hash"
|
||||
assert entry["content_hash"] == hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
def test_state_file_lives_outside_diary_dir(self, tmp_path):
|
||||
# Regression: the original implementation wrote
|
||||
# ``.diary_ingest_state.json`` *inside* the user's diary directory,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Invariant tests: every ChromaDB collection-creation path must set
|
||||
``hnsw:space=cosine``.
|
||||
|
||||
Reason: ChromaDB's default HNSW distance is L2 (Euclidean). Under L2,
|
||||
the searcher's ``max(0, 1 - distance)`` similarity formula systematically
|
||||
floors to 0 because L2 distances on normalized 384-dim vectors routinely
|
||||
exceed 1.0 — users then see flat ``Match: 0.0`` across every result and
|
||||
have no signal that their palace is broken.
|
||||
|
||||
This test file locks the invariant so a future refactor that drops the
|
||||
``metadata={"hnsw:space": "cosine"}`` parameter from any creation path
|
||||
gets caught at test time rather than silently degrading search quality.
|
||||
"""
|
||||
|
||||
from mempalace.backends.chroma import ChromaBackend
|
||||
from mempalace.palace import get_collection
|
||||
|
||||
|
||||
EXPECTED_METRIC = "cosine"
|
||||
|
||||
|
||||
def _assert_cosine(col, where: str) -> None:
|
||||
meta = col.metadata if hasattr(col, "metadata") else col._collection.metadata
|
||||
assert isinstance(meta, dict), f"{where}: expected metadata dict, got {meta!r}"
|
||||
assert meta.get("hnsw:space") == EXPECTED_METRIC, (
|
||||
f"{where}: expected hnsw:space={EXPECTED_METRIC!r}, got {meta!r}. "
|
||||
"A collection without cosine metric will silently break the "
|
||||
"similarity formula used by the searcher."
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_get_or_create_collection_sets_cosine(tmp_path):
|
||||
backend = ChromaBackend()
|
||||
col = backend.get_or_create_collection(str(tmp_path), "mempalace_drawers")
|
||||
_assert_cosine(col, "legacy get_or_create_collection")
|
||||
|
||||
|
||||
def test_legacy_create_collection_sets_cosine(tmp_path):
|
||||
backend = ChromaBackend()
|
||||
col = backend.create_collection(str(tmp_path), "mempalace_drawers")
|
||||
_assert_cosine(col, "legacy create_collection")
|
||||
|
||||
|
||||
def test_new_get_collection_with_create_sets_cosine(tmp_path):
|
||||
"""RFC 001 typed surface — ``get_collection(..., create=True)`` is the
|
||||
path the miner + init flow take. Must also set cosine."""
|
||||
backend = ChromaBackend()
|
||||
col = backend.get_collection(str(tmp_path), "mempalace_drawers", create=True)
|
||||
_assert_cosine(col, "get_collection(create=True)")
|
||||
|
||||
|
||||
def test_palace_module_get_collection_sets_cosine(tmp_path):
|
||||
"""The public ``mempalace.palace.get_collection`` is what most callers
|
||||
use. Must produce cosine palaces."""
|
||||
col = get_collection(str(tmp_path), "mempalace_drawers", create=True)
|
||||
_assert_cosine(col, "palace.get_collection(create=True)")
|
||||
|
||||
|
||||
def test_reopening_cosine_palace_preserves_metric(tmp_path):
|
||||
"""Opening a previously-created cosine palace (create=False) must
|
||||
still expose the cosine metadata — catches any regression where
|
||||
reopening drops or overwrites metadata."""
|
||||
backend = ChromaBackend()
|
||||
backend.create_collection(str(tmp_path), "mempalace_drawers")
|
||||
# Fresh backend simulates a process restart
|
||||
backend2 = ChromaBackend()
|
||||
col = backend2.get_collection(str(tmp_path), "mempalace_drawers", create=False)
|
||||
_assert_cosine(col, "re-opened palace")
|
||||
|
||||
|
||||
def test_fresh_palace_via_full_stack_gets_cosine(tmp_path):
|
||||
"""End-to-end: build a palace with the public API the way a new user
|
||||
would, confirm the resulting collection uses cosine distance.
|
||||
|
||||
Uses the ``tmp_path`` fixture rather than ``tempfile.TemporaryDirectory``
|
||||
so ChromaDB's persistent SQLite file handles aren't asked to release
|
||||
during the test body — pytest cleans the path at session end, by which
|
||||
point the process is exiting and Windows' file-lock contention is
|
||||
moot. Matches the cleanup strategy used by the rest of this file and
|
||||
the project's 80% Windows coverage note in CLAUDE.md.
|
||||
"""
|
||||
col = get_collection(str(tmp_path), "mempalace_drawers", create=True)
|
||||
_assert_cosine(col, "full-stack new palace")
|
||||
|
||||
# And the closets collection too
|
||||
closets = get_collection(str(tmp_path), "mempalace_closets", create=True)
|
||||
_assert_cosine(closets, "full-stack new closets")
|
||||
+233
-5
@@ -3,7 +3,14 @@ import json
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from mempalace.config import MempalaceConfig, sanitize_kg_value, sanitize_name
|
||||
from mempalace.config import (
|
||||
MempalaceConfig,
|
||||
normalize_wing_name,
|
||||
sanitize_iso_date,
|
||||
sanitize_iso_temporal,
|
||||
sanitize_kg_value,
|
||||
sanitize_name,
|
||||
)
|
||||
|
||||
|
||||
def test_default_config():
|
||||
@@ -20,11 +27,87 @@ def test_config_from_file():
|
||||
assert cfg.palace_path == "/custom/palace"
|
||||
|
||||
|
||||
def test_env_override():
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = "/env/palace"
|
||||
def test_embedding_device_defaults_to_auto(monkeypatch):
|
||||
monkeypatch.delenv("MEMPALACE_EMBEDDING_DEVICE", raising=False)
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
assert cfg.palace_path == "/env/palace"
|
||||
del os.environ["MEMPALACE_PALACE_PATH"]
|
||||
assert cfg.embedding_device == "auto"
|
||||
|
||||
|
||||
def test_embedding_device_from_config_is_normalized(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MEMPALACE_EMBEDDING_DEVICE", raising=False)
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"embedding_device": " CUDA "}, f)
|
||||
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.embedding_device == "cuda"
|
||||
|
||||
|
||||
def test_embedding_device_env_overrides_config(tmp_path, monkeypatch):
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"embedding_device": "cpu"}, f)
|
||||
monkeypatch.setenv("MEMPALACE_EMBEDDING_DEVICE", " CoreML ")
|
||||
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.embedding_device == "coreml"
|
||||
|
||||
|
||||
def test_env_override():
|
||||
raw = "/env/palace"
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = raw
|
||||
try:
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
# palace_path normalizes with abspath + expanduser to match the
|
||||
# --palace CLI code path. On Unix that's a no-op for "/env/palace";
|
||||
# on Windows abspath prepends the current drive letter.
|
||||
assert cfg.palace_path == os.path.abspath(os.path.expanduser(raw))
|
||||
finally:
|
||||
del os.environ["MEMPALACE_PALACE_PATH"]
|
||||
|
||||
|
||||
def test_env_path_expanduser():
|
||||
# Tilde must be expanded to match the --palace CLI code path. We don't
|
||||
# assert "~" is absent from the final string because Windows 8.3 short
|
||||
# paths (e.g. C:\Users\RUNNER~1\...) legitimately contain tildes — the
|
||||
# equality check is authoritative.
|
||||
raw = os.path.join("~", "mempalace-test")
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = raw
|
||||
try:
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
assert cfg.palace_path == os.path.abspath(os.path.expanduser(raw))
|
||||
assert cfg.palace_path.endswith("mempalace-test")
|
||||
finally:
|
||||
del os.environ["MEMPALACE_PALACE_PATH"]
|
||||
|
||||
|
||||
def test_env_path_abspath_collapses_traversal():
|
||||
# Build a raw path with a .. segment using the platform separator so
|
||||
# the assertion is portable (Windows uses \, POSIX uses /).
|
||||
raw = os.path.join(tempfile.gettempdir(), "palace", "..", "mempalace-test")
|
||||
expected = os.path.abspath(os.path.expanduser(raw))
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = raw
|
||||
try:
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
# .. segments must be collapsed, not preserved literally.
|
||||
assert ".." not in cfg.palace_path
|
||||
assert cfg.palace_path == expected
|
||||
finally:
|
||||
del os.environ["MEMPALACE_PALACE_PATH"]
|
||||
|
||||
|
||||
def test_env_path_legacy_alias_normalized():
|
||||
# Legacy MEMPAL_PALACE_PATH gets the same normalization treatment as
|
||||
# MEMPALACE_PALACE_PATH. We don't assert "~" is absent from the final
|
||||
# string because Windows 8.3 short paths (e.g. C:\Users\RUNNER~1\...)
|
||||
# legitimately contain tildes — the equality check below is authoritative.
|
||||
os.environ.pop("MEMPALACE_PALACE_PATH", None)
|
||||
raw = os.path.join("~", "legacy-alias", "..", "mempalace-test")
|
||||
os.environ["MEMPAL_PALACE_PATH"] = raw
|
||||
try:
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
assert ".." not in cfg.palace_path
|
||||
assert cfg.palace_path == os.path.abspath(os.path.expanduser(raw))
|
||||
finally:
|
||||
del os.environ["MEMPAL_PALACE_PATH"]
|
||||
|
||||
|
||||
def test_init():
|
||||
@@ -34,6 +117,25 @@ def test_init():
|
||||
assert os.path.exists(os.path.join(tmpdir, "config.json"))
|
||||
|
||||
|
||||
# --- normalize_wing_name ---
|
||||
|
||||
|
||||
def test_normalize_wing_name_hyphen():
|
||||
assert normalize_wing_name("mempal-private") == "mempal_private"
|
||||
|
||||
|
||||
def test_normalize_wing_name_space():
|
||||
assert normalize_wing_name("My Project") == "my_project"
|
||||
|
||||
|
||||
def test_normalize_wing_name_already_clean():
|
||||
assert normalize_wing_name("memorymark") == "memorymark"
|
||||
|
||||
|
||||
def test_normalize_wing_name_mixed():
|
||||
assert normalize_wing_name("My-Cool App") == "my_cool_app"
|
||||
|
||||
|
||||
# --- sanitize_name ---
|
||||
|
||||
|
||||
@@ -117,3 +219,129 @@ def test_kg_value_rejects_null_bytes():
|
||||
def test_kg_value_rejects_over_length():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_kg_value("a" * 129)
|
||||
|
||||
|
||||
# --- sanitize_iso_date ---
|
||||
|
||||
|
||||
def test_iso_date_rejects_year_only():
|
||||
# Partial dates re-introduce silent empty result sets via lexicographic
|
||||
# TEXT comparison in KG queries (e.g. "2026-01-01" <= "2026" is False).
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_date("2026")
|
||||
|
||||
|
||||
def test_iso_date_rejects_year_month():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_date("2026-03")
|
||||
|
||||
|
||||
def test_iso_date_accepts_full_date():
|
||||
assert sanitize_iso_date("2026-03-15") == "2026-03-15"
|
||||
|
||||
|
||||
def test_iso_date_passes_through_none():
|
||||
assert sanitize_iso_date(None) is None
|
||||
|
||||
|
||||
def test_iso_date_passes_through_empty_string():
|
||||
assert sanitize_iso_date("") == ""
|
||||
|
||||
|
||||
def test_iso_date_strips_whitespace():
|
||||
assert sanitize_iso_date(" 2026-03-15 ") == "2026-03-15"
|
||||
|
||||
|
||||
def test_iso_date_rejects_natural_language():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_date("March 2026")
|
||||
|
||||
|
||||
def test_iso_date_rejects_abbreviated_month():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_date("Jan 2025")
|
||||
|
||||
|
||||
def test_iso_date_rejects_us_format():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_date("03/15/2026")
|
||||
|
||||
|
||||
def test_iso_date_rejects_invalid_month():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_date("2026-13")
|
||||
|
||||
|
||||
def test_iso_date_rejects_invalid_day():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_date("2026-02-32")
|
||||
|
||||
|
||||
def test_iso_date_rejects_non_string():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_date(20260315)
|
||||
|
||||
|
||||
def test_iso_date_error_names_field():
|
||||
with pytest.raises(ValueError, match="valid_from"):
|
||||
sanitize_iso_date("yesterday", "valid_from")
|
||||
|
||||
|
||||
def test_iso_temporal_accepts_full_date():
|
||||
assert sanitize_iso_temporal("2026-05-06") == "2026-05-06"
|
||||
|
||||
|
||||
def test_iso_temporal_accepts_canonical_utc_datetime():
|
||||
assert sanitize_iso_temporal("2026-05-06T14:23:00Z") == "2026-05-06T14:23:00Z"
|
||||
|
||||
|
||||
def test_iso_temporal_strips_datetime_whitespace():
|
||||
assert sanitize_iso_temporal(" 2026-05-06T14:23:00Z ") == "2026-05-06T14:23:00Z"
|
||||
|
||||
|
||||
def test_iso_date_backward_compatible_wrapper_accepts_datetime():
|
||||
assert sanitize_iso_date("2026-05-06T14:23:00Z") == "2026-05-06T14:23:00Z"
|
||||
|
||||
|
||||
def test_iso_temporal_rejects_datetime_without_seconds():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_temporal("2026-05-06T14:23")
|
||||
|
||||
|
||||
def test_iso_temporal_rejects_naive_datetime():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_temporal("2026-05-06T14:23:00")
|
||||
|
||||
|
||||
def test_iso_temporal_rejects_fractional_seconds():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_temporal("2026-05-06T14:23:00.123Z")
|
||||
|
||||
|
||||
def test_iso_temporal_rejects_timezone_offset():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_temporal("2026-05-06T14:23:00+02:00")
|
||||
|
||||
|
||||
def test_iso_temporal_rejects_space_separator():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_temporal("2026-05-06 14:23:00")
|
||||
|
||||
|
||||
def test_iso_temporal_rejects_invalid_datetime_hour():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_temporal("2026-05-06T24:00:00Z")
|
||||
|
||||
|
||||
def test_iso_temporal_rejects_invalid_calendar_date():
|
||||
with pytest.raises(ValueError):
|
||||
sanitize_iso_temporal("2026-02-31")
|
||||
|
||||
|
||||
def test_iso_temporal_error_names_field():
|
||||
with pytest.raises(ValueError, match="as_of"):
|
||||
sanitize_iso_temporal("2026-05-06T14:23", "as_of")
|
||||
|
||||
|
||||
def test_iso_temporal_normalizes_plus_zero_offset_to_z():
|
||||
assert sanitize_iso_temporal("2026-05-06T14:23:00+00:00") == "2026-05-06T14:23:00Z"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user