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 | ||
|
|
b291d2a240 | ||
|
|
e2444a190c | ||
|
|
659cb815ea | ||
|
|
c2e053176c | ||
|
|
b595cb3978 | ||
|
|
35b033d77f | ||
|
|
4949aab68b | ||
|
|
0e632df85d | ||
|
|
04d80eb363 | ||
|
|
c942f5866c | ||
|
|
fb1cf53919 | ||
|
|
5bf826046c | ||
|
|
5dfe853154 |
@@ -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\""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.3",
|
||||
"version": "3.3.5",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.3.3",
|
||||
"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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.3.3",
|
||||
"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"
|
||||
|
||||
@@ -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
|
||||
@@ -6,6 +6,56 @@ 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
|
||||
@@ -157,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,11 +49,16 @@ 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
|
||||
@@ -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.3-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()
|
||||
@@ -7,27 +7,42 @@ 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**:
|
||||
@@ -48,4 +63,4 @@ mempalace mine ~/.claude/projects/ --mode convos
|
||||
|
||||
- **`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`
|
||||
- **`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
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ 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
|
||||
|
||||
@@ -41,17 +41,18 @@
|
||||
# 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:
|
||||
@@ -64,15 +65,57 @@ fi
|
||||
# Read JSON input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
SESSION_ID=$(echo "$INPUT" | "$MEMPAL_PYTHON_BIN" -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")"
|
||||
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 —
|
||||
|
||||
+53
-24
@@ -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,9 +56,10 @@ 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.
|
||||
@@ -82,9 +83,11 @@ fi
|
||||
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" | "$MEMPAL_PYTHON_BIN" -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')
|
||||
@@ -94,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
|
||||
@@ -157,19 +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.
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+880
-67
File diff suppressed because it is too large
Load Diff
+818
-85
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:
|
||||
|
||||
@@ -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",
|
||||
@@ -236,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)."""
|
||||
|
||||
+42
-24
@@ -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,6 +58,7 @@ CONVO_EXTENSIONS = {
|
||||
|
||||
MIN_CHUNK_SIZE = 30
|
||||
CHUNK_SIZE = 800 # chars per drawer — align with miner.py
|
||||
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
|
||||
@@ -330,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
|
||||
@@ -381,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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -396,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.
|
||||
|
||||
@@ -405,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)
|
||||
@@ -440,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 = []
|
||||
@@ -463,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 ====================
|
||||
|
||||
@@ -489,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.
|
||||
"""
|
||||
@@ -501,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:")
|
||||
@@ -570,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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+261
-67
@@ -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,9 +14,48 @@ 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:
|
||||
@@ -142,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:
|
||||
@@ -197,19 +239,64 @@ def _output(data: dict):
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
def _get_mine_dir(transcript_path: str = "") -> str:
|
||||
"""Determine directory to mine from MEMPAL_DIR or transcript path."""
|
||||
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 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 ""
|
||||
if mempal_dir:
|
||||
resolved = Path(mempal_dir).expanduser().resolve()
|
||||
if resolved.is_dir():
|
||||
targets.append((str(resolved), "projects"))
|
||||
return targets
|
||||
|
||||
|
||||
_MINE_PID_FILE = STATE_DIR / "mine.pid"
|
||||
# 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:
|
||||
@@ -245,55 +332,152 @@ def _pid_alive(pid: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _mine_already_running() -> bool:
|
||||
"""Return True if a background mine process from a previous hook fire is still alive."""
|
||||
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:
|
||||
pid = int(_MINE_PID_FILE.read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
recorded = pid_file.read_text().strip()
|
||||
except OSError:
|
||||
return False
|
||||
return _pid_alive(pid)
|
||||
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, write its PID to the lock file, log to hook.log."""
|
||||
"""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:
|
||||
proc = subprocess.Popen(cmd, stdout=log_f, stderr=log_f)
|
||||
_MINE_PID_FILE.write_text(str(proc.pid))
|
||||
|
||||
|
||||
def _maybe_auto_ingest(transcript_path: str = ""):
|
||||
"""Run mempalace mine in background if a mine directory is available."""
|
||||
mine_dir = _get_mine_dir(transcript_path)
|
||||
if not mine_dir:
|
||||
return
|
||||
if _mine_already_running():
|
||||
_log("Skipping auto-ingest: mine already running")
|
||||
return
|
||||
try:
|
||||
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:
|
||||
_spawn_mine([sys.executable, "-m", "mempalace", "mine", mine_dir])
|
||||
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
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "mempalace", "mine", mine_dir],
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
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"):
|
||||
@@ -303,6 +487,7 @@ def _desktop_toast(body: str, title: str = "MemPalace"):
|
||||
["notify-send", "--app-name=MemPalace", "--icon=brain", title, body],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**_detached_popen_kwargs(),
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -449,24 +634,22 @@ def _ingest_transcript(transcript_path: str):
|
||||
return
|
||||
|
||||
try:
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.Popen(
|
||||
[
|
||||
_mempalace_python(),
|
||||
"-m",
|
||||
"mempalace",
|
||||
"mine",
|
||||
str(path.parent),
|
||||
"--mode",
|
||||
"convos",
|
||||
"--wing",
|
||||
"sessions",
|
||||
],
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
)
|
||||
# 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
|
||||
@@ -522,6 +705,9 @@ def _wing_from_transcript_path(transcript_path: str) -> str:
|
||||
|
||||
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"]
|
||||
@@ -592,7 +778,7 @@ def hook_stop(data: dict, harness: str):
|
||||
transcript_path, session_id, wing=project_wing, toast=toast
|
||||
)
|
||||
_ingest_transcript(transcript_path)
|
||||
_maybe_auto_ingest(transcript_path)
|
||||
_maybe_auto_ingest()
|
||||
# Only advance save marker after successful save
|
||||
count = result.get("count", 0)
|
||||
if count > 0:
|
||||
@@ -622,7 +808,7 @@ def hook_stop(data: dict, harness: str):
|
||||
pass
|
||||
if transcript_path:
|
||||
_ingest_transcript(transcript_path)
|
||||
_maybe_auto_ingest(transcript_path)
|
||||
_maybe_auto_ingest()
|
||||
reason = STOP_BLOCK_REASON + f" Write diary entry to wing={project_wing}."
|
||||
_output({"decision": "block", "reason": reason})
|
||||
else:
|
||||
@@ -631,6 +817,9 @@ def hook_stop(data: dict, harness: str):
|
||||
|
||||
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"]
|
||||
|
||||
@@ -645,6 +834,9 @@ 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"]
|
||||
@@ -655,8 +847,10 @@ def hook_precompact(data: dict, harness: str):
|
||||
if transcript_path:
|
||||
_ingest_transcript(transcript_path)
|
||||
|
||||
# Mine synchronously so data lands before compaction proceeds
|
||||
_mine_sync(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({})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+161
-27
@@ -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
|
||||
@@ -128,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("'", "")
|
||||
|
||||
@@ -164,13 +252,31 @@ class KnowledgeGraph:
|
||||
|
||||
``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.
|
||||
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(" ", "_")
|
||||
@@ -180,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
|
||||
@@ -191,17 +299,14 @@ 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,
|
||||
id, subject, predicate, object, valid_from, valid_to,
|
||||
confidence, source_closet, source_file,
|
||||
source_drawer_id, adapter_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
@@ -218,20 +323,37 @@ class KnowledgeGraph:
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -242,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(
|
||||
{
|
||||
@@ -272,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(
|
||||
{
|
||||
@@ -296,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
|
||||
@@ -305,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:
|
||||
|
||||
+6
-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", " ")
|
||||
@@ -283,7 +287,7 @@ class Layer3:
|
||||
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
|
||||
meta = meta or {}
|
||||
doc = doc or ""
|
||||
similarity = round(1 - dist, 3)
|
||||
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 ""
|
||||
|
||||
+116
-4
@@ -28,9 +28,81 @@ 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."""
|
||||
|
||||
@@ -55,11 +127,18 @@ class LLMProvider:
|
||||
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
|
||||
@@ -68,6 +147,20 @@ class LLMProvider:
|
||||
"""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."""
|
||||
@@ -167,8 +260,20 @@ class OpenAICompatProvider(LLMProvider):
|
||||
timeout: int = 120,
|
||||
**_: object,
|
||||
):
|
||||
resolved_key = api_key or os.environ.get("OPENAI_API_KEY")
|
||||
super().__init__(model=model, endpoint=endpoint, api_key=resolved_key, timeout=timeout)
|
||||
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:
|
||||
@@ -235,12 +340,19 @@ class AnthropicProvider(LLMProvider):
|
||||
timeout: int = 120,
|
||||
**_: object,
|
||||
):
|
||||
key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
||||
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=key,
|
||||
api_key=resolved_key,
|
||||
timeout=timeout,
|
||||
api_key_source=source,
|
||||
)
|
||||
|
||||
def check_available(self) -> tuple[bool, str]:
|
||||
|
||||
+64
-10
@@ -197,13 +197,23 @@ def _apply_classifications(
|
||||
"""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": "uncertain",
|
||||
"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]] = []
|
||||
@@ -216,6 +226,7 @@ def _apply_classifications(
|
||||
new_detected: dict[str, list[dict]] = {
|
||||
"people": [],
|
||||
"projects": [],
|
||||
"topics": [],
|
||||
"uncertain": [],
|
||||
}
|
||||
|
||||
@@ -223,7 +234,7 @@ def _apply_classifications(
|
||||
decision = decisions.get(entry["name"])
|
||||
if decision is None:
|
||||
# No LLM opinion — keep as-is
|
||||
new_detected[old_bucket].append(entry)
|
||||
new_detected.setdefault(old_bucket, []).append(entry)
|
||||
continue
|
||||
|
||||
label, reason = decision
|
||||
@@ -245,18 +256,58 @@ def _apply_classifications(
|
||||
updated["signals"] = signals
|
||||
if target_bucket != old_bucket:
|
||||
reclassified += 1
|
||||
updated["type"] = (
|
||||
"person"
|
||||
if target_bucket == "people"
|
||||
else "project"
|
||||
if target_bucket == "projects"
|
||||
else "uncertain"
|
||||
)
|
||||
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()
|
||||
@@ -287,6 +338,7 @@ def refine_entities(
|
||||
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.
|
||||
|
||||
@@ -349,12 +401,14 @@ def refine_entities(
|
||||
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)
|
||||
resp = provider.classify(system_prompt, user_prompt, json_mode=True)
|
||||
except KeyboardInterrupt:
|
||||
cancelled = True
|
||||
break
|
||||
|
||||
+660
-65
File diff suppressed because it is too large
Load Diff
+108
-9
@@ -18,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.
|
||||
|
||||
@@ -134,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
|
||||
@@ -158,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)
|
||||
@@ -231,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}")
|
||||
|
||||
+431
-80
@@ -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",
|
||||
@@ -59,12 +66,22 @@ SKIP_FILENAMES = {
|
||||
"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
|
||||
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
|
||||
@@ -281,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}'). "
|
||||
@@ -307,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:
|
||||
"""
|
||||
@@ -326,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
|
||||
@@ -437,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):
|
||||
@@ -472,7 +528,39 @@ def _load_known_entities_raw() -> dict:
|
||||
return dict(_ENTITY_REGISTRY_CACHE["raw"])
|
||||
|
||||
|
||||
def add_to_known_entities(entities_by_category: dict) -> str:
|
||||
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``
|
||||
@@ -486,6 +574,15 @@ def add_to_known_entities(entities_by_category: dict) -> str:
|
||||
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.
|
||||
@@ -513,7 +610,16 @@ def add_to_known_entities(entities_by_category: dict) -> str:
|
||||
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)
|
||||
@@ -549,6 +655,9 @@ def add_to_known_entities(entities_by_category: dict) -> str:
|
||||
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)
|
||||
@@ -563,6 +672,28 @@ def add_to_known_entities(entities_by_category: dict) -> str:
|
||||
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
|
||||
|
||||
|
||||
@@ -633,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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -703,6 +856,13 @@ 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)")
|
||||
return len(chunks), room
|
||||
@@ -723,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
|
||||
@@ -851,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}")
|
||||
@@ -875,6 +1109,7 @@ 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:
|
||||
@@ -892,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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
+247
-9
@@ -15,18 +15,43 @@ 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()
|
||||
@@ -92,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", "")
|
||||
@@ -215,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(
|
||||
@@ -236,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]
|
||||
|
||||
@@ -313,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)
|
||||
@@ -325,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:
|
||||
@@ -362,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.
|
||||
|
||||
@@ -382,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.
|
||||
@@ -394,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 = {
|
||||
@@ -401,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:
|
||||
@@ -433,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
|
||||
|
||||
|
||||
@@ -454,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 = []
|
||||
|
||||
@@ -461,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",
|
||||
@@ -472,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",
|
||||
@@ -484,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")]
|
||||
@@ -496,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
|
||||
|
||||
@@ -558,6 +558,7 @@ def to_detected_dict(
|
||||
return {
|
||||
"people": people_entries,
|
||||
"projects": proj_entries,
|
||||
"topics": [],
|
||||
"uncertain": [],
|
||||
}
|
||||
|
||||
@@ -577,7 +578,7 @@ def _merge_detected(primary: dict, secondary: dict, drop_secondary_uncertain: bo
|
||||
"""
|
||||
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", "uncertain"):
|
||||
for cat_key in ("people", "projects", "topics", "uncertain"):
|
||||
if cat_key == "uncertain" and drop_secondary_uncertain:
|
||||
continue
|
||||
for e in secondary.get(cat_key, []):
|
||||
@@ -596,6 +597,7 @@ def discover_entities(
|
||||
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.
|
||||
|
||||
@@ -612,11 +614,19 @@ def discover_entities(
|
||||
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)
|
||||
|
||||
@@ -654,7 +664,7 @@ def discover_entities(
|
||||
prose_detected = (
|
||||
detect_entities(prose_files, languages=languages)
|
||||
if prose_files
|
||||
else {"people": [], "projects": [], "uncertain": []}
|
||||
else {"people": [], "projects": [], "topics": [], "uncertain": []}
|
||||
)
|
||||
|
||||
# Without LLM refinement, suppress regex "uncertain" noise when real
|
||||
@@ -667,7 +677,7 @@ def discover_entities(
|
||||
drop_secondary_uncertain=has_real_signal and llm_provider is None,
|
||||
)
|
||||
|
||||
# Optional phase 2: LLM refinement.
|
||||
# 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
|
||||
|
||||
@@ -678,6 +688,7 @@ def discover_entities(
|
||||
llm_provider,
|
||||
show_progress=show_progress,
|
||||
allow_project_promotions=not has_real_signal,
|
||||
corpus_origin=corpus_origin,
|
||||
)
|
||||
if show_progress:
|
||||
status_bits = []
|
||||
@@ -695,6 +706,14 @@ def discover_entities(
|
||||
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
|
||||
|
||||
|
||||
|
||||
+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}")
|
||||
|
||||
+508
-15
@@ -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
|
||||
@@ -46,7 +48,14 @@ def _first_or_empty(results, key: str) -> list:
|
||||
|
||||
|
||||
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())
|
||||
|
||||
|
||||
@@ -125,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.
|
||||
"""
|
||||
@@ -138,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))
|
||||
|
||||
@@ -227,7 +245,7 @@ def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, ra
|
||||
all_meta = drawers_col.get(where={"source_file": src}, include=["metadatas"])
|
||||
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,19 +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)
|
||||
meta = meta or {}
|
||||
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}")
|
||||
@@ -301,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,
|
||||
@@ -308,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.
|
||||
|
||||
@@ -323,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 {
|
||||
@@ -378,7 +841,8 @@ def search_memories(
|
||||
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
|
||||
@@ -392,6 +856,8 @@ 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
|
||||
@@ -408,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"),
|
||||
@@ -453,6 +924,7 @@ def search_memories(
|
||||
include=["documents", "metadatas"],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Neighbor fetch failed for %s", full_source, exc_info=True)
|
||||
continue
|
||||
docs = source_drawers.documents
|
||||
metas_ = source_drawers.metadatas
|
||||
@@ -490,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,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.3"
|
||||
__version__ = "3.3.5"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
OpenArena owner claim verification for MemPalace/mempalace: 09AE2C2E66CC4B5CBD7D
|
||||
+9
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mempalace"
|
||||
version = "3.3.3"
|
||||
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"
|
||||
@@ -53,6 +53,14 @@ chroma = "mempalace.backends.chroma:ChromaBackend"
|
||||
[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
|
||||
|
||||
|
||||
+845
-32
@@ -1,5 +1,9 @@
|
||||
import os
|
||||
import pickle
|
||||
import shutil
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
import chromadb
|
||||
import pytest
|
||||
@@ -15,7 +19,11 @@ from mempalace.backends import (
|
||||
from mempalace.backends.chroma import (
|
||||
ChromaBackend,
|
||||
ChromaCollection,
|
||||
_HNSW_MISSING_METADATA_DATA_FLOOR,
|
||||
_fix_blob_seq_ids,
|
||||
_pin_hnsw_threads,
|
||||
_segment_appears_healthy,
|
||||
quarantine_invalid_hnsw_metadata,
|
||||
quarantine_stale_hnsw,
|
||||
)
|
||||
|
||||
@@ -204,6 +212,52 @@ def test_query_empty_preserves_embeddings_outer_shape_when_requested():
|
||||
assert not_requested.embeddings is None
|
||||
|
||||
|
||||
def test_chroma_close_palace_releases_sqlite_lock_for_reopen(tmp_path):
|
||||
"""close_palace must release chromadb's rust-side SQLite file lock so
|
||||
a fresh PersistentClient on the same path after shutil.rmtree can
|
||||
write without hitting SQLITE_READONLY_DBMOVED."""
|
||||
backend = ChromaBackend()
|
||||
palace_path = tmp_path / "palace-a"
|
||||
ref = PalaceRef(id=str(palace_path), local_path=str(palace_path))
|
||||
|
||||
col = backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
|
||||
col.upsert(documents=["hello"], ids=["a"], metadatas=[{"k": "v"}])
|
||||
|
||||
backend.close_palace(ref)
|
||||
shutil.rmtree(palace_path)
|
||||
|
||||
col = backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
|
||||
col.upsert(documents=["world"], ids=["b"], metadatas=[{"k": "v2"}])
|
||||
assert col.count() == 1
|
||||
|
||||
|
||||
def test_chroma_close_releases_all_cached_clients(tmp_path):
|
||||
"""close() must release every cached client's SQLite file lock so any
|
||||
of their palace paths can be reopened by a fresh backend in the same
|
||||
process."""
|
||||
backend = ChromaBackend()
|
||||
palace_a = tmp_path / "palace-a"
|
||||
palace_b = tmp_path / "palace-b"
|
||||
ref_a = PalaceRef(id=str(palace_a), local_path=str(palace_a))
|
||||
ref_b = PalaceRef(id=str(palace_b), local_path=str(palace_b))
|
||||
|
||||
for ref in (ref_a, ref_b):
|
||||
backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True).upsert(
|
||||
documents=["x"], ids=["x"], metadatas=[{"k": "v"}]
|
||||
)
|
||||
|
||||
backend.close()
|
||||
|
||||
for path in (palace_a, palace_b):
|
||||
shutil.rmtree(path)
|
||||
ref = PalaceRef(id=str(path), local_path=str(path))
|
||||
fresh = ChromaBackend()
|
||||
col = fresh.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
|
||||
col.upsert(documents=["y"], ids=["y"], metadatas=[{"k": "v2"}])
|
||||
assert col.count() == 1
|
||||
fresh.close()
|
||||
|
||||
|
||||
def test_chroma_cache_invalidates_when_db_file_missing(tmp_path):
|
||||
"""A palace rebuild that removes chroma.sqlite3 must drop the stale cache.
|
||||
|
||||
@@ -334,45 +388,98 @@ def test_chroma_backend_creates_collection_with_cosine_distance(tmp_path):
|
||||
assert col.metadata.get("hnsw:space") == "cosine"
|
||||
|
||||
|
||||
def test_chroma_backend_sets_hnsw_bloat_guard_on_creation(tmp_path):
|
||||
"""The HNSW guard from #344 must land on freshly-created collection metadata.
|
||||
|
||||
Without batch_size + sync_threshold, mining ~10K+ drawers triggers the
|
||||
resize+persist drift that bloats link_lists.bin into hundreds of GB sparse
|
||||
and segfaults `status` / `search` / `repair`. The guard belongs at
|
||||
collection-creation time so every fresh palace gets it without needing
|
||||
a runtime retrofit. Asserting both keys land on the persisted metadata
|
||||
also covers the #1161 "config silently dropped" concern at CI time.
|
||||
"""
|
||||
palace_path = tmp_path / "palace"
|
||||
|
||||
ChromaBackend().get_collection(
|
||||
str(palace_path),
|
||||
collection_name="mempalace_drawers",
|
||||
create=True,
|
||||
)
|
||||
|
||||
client = chromadb.PersistentClient(path=str(palace_path))
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
assert col.metadata.get("hnsw:batch_size") == 50_000
|
||||
assert col.metadata.get("hnsw:sync_threshold") == 50_000
|
||||
|
||||
|
||||
def test_chroma_backend_create_collection_sets_hnsw_bloat_guard(tmp_path):
|
||||
"""Same guard must apply via the legacy create_collection() path."""
|
||||
palace_path = tmp_path / "palace"
|
||||
|
||||
ChromaBackend().create_collection(str(palace_path), "mempalace_drawers")
|
||||
|
||||
client = chromadb.PersistentClient(path=str(palace_path))
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
assert col.metadata.get("hnsw:batch_size") == 50_000
|
||||
assert col.metadata.get("hnsw:sync_threshold") == 50_000
|
||||
|
||||
|
||||
def test_get_collection_create_true_is_idempotent(tmp_path):
|
||||
"""Calling get_collection(create=True) twice on the same name must not crash.
|
||||
|
||||
ChromaDB 1.5.x's Rust bindings SIGSEGV when get_or_create_collection is
|
||||
called with metadata that differs from the stored collection metadata. The
|
||||
fix splits the call into get_collection -> fallback create_collection so the
|
||||
metadata-comparison codepath in chromadb_rust_bindings is never reached for
|
||||
existing collections. Regression guard for issue #1089.
|
||||
"""
|
||||
palace = str(tmp_path / "palace")
|
||||
backend = ChromaBackend()
|
||||
backend.get_collection(palace, collection_name="mempalace_drawers", create=True)
|
||||
col2 = backend.get_collection(palace, collection_name="mempalace_drawers", create=True)
|
||||
assert isinstance(col2, ChromaCollection)
|
||||
|
||||
|
||||
def test_get_collection_create_true_preserves_existing_metadata(tmp_path):
|
||||
"""Existing collection metadata is not overwritten when reopened with create=True."""
|
||||
palace = str(tmp_path / "palace")
|
||||
backend = ChromaBackend()
|
||||
backend.get_collection(palace, collection_name="mempalace_drawers", create=True)
|
||||
col = backend.get_collection(palace, collection_name="mempalace_drawers", create=True)
|
||||
assert col._collection.metadata["hnsw:space"] == "cosine"
|
||||
assert col._collection.metadata.get("hnsw:batch_size") == 50_000
|
||||
|
||||
|
||||
def test_fix_blob_seq_ids_converts_blobs_to_integers(tmp_path):
|
||||
"""Simulate a ChromaDB 0.6.x database with BLOB seq_ids and verify repair."""
|
||||
db_path = tmp_path / "chroma.sqlite3"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
|
||||
conn.execute("CREATE TABLE max_seq_id (rowid INTEGER PRIMARY KEY, seq_id)")
|
||||
# Insert BLOB seq_ids like ChromaDB 0.6.x would
|
||||
blob_42 = (42).to_bytes(8, byteorder="big")
|
||||
blob_99 = (99).to_bytes(8, byteorder="big")
|
||||
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", (blob_42,))
|
||||
conn.execute("INSERT INTO max_seq_id (seq_id) VALUES (?)", (blob_99,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
|
||||
# Insert BLOB seq_id like ChromaDB 0.6.x would
|
||||
blob_42 = (42).to_bytes(8, byteorder="big")
|
||||
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", (blob_42,))
|
||||
conn.commit()
|
||||
|
||||
_fix_blob_seq_ids(str(tmp_path))
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
|
||||
assert row == (42, "integer")
|
||||
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM max_seq_id").fetchone()
|
||||
assert row == (99, "integer")
|
||||
conn.close()
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
|
||||
assert row == (42, "integer")
|
||||
|
||||
|
||||
def test_fix_blob_seq_ids_noop_without_blobs(tmp_path):
|
||||
"""No error when seq_ids are already integers."""
|
||||
db_path = tmp_path / "chroma.sqlite3"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id INTEGER)")
|
||||
conn.execute("INSERT INTO embeddings (seq_id) VALUES (42)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id INTEGER)")
|
||||
conn.execute("INSERT INTO embeddings (seq_id) VALUES (42)")
|
||||
conn.commit()
|
||||
|
||||
_fix_blob_seq_ids(str(tmp_path))
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
|
||||
assert row == (42, "integer")
|
||||
conn.close()
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
|
||||
assert row == (42, "integer")
|
||||
|
||||
|
||||
def test_fix_blob_seq_ids_noop_without_database(tmp_path):
|
||||
@@ -380,39 +487,276 @@ def test_fix_blob_seq_ids_noop_without_database(tmp_path):
|
||||
_fix_blob_seq_ids(str(tmp_path)) # should not raise
|
||||
|
||||
|
||||
def test_fix_blob_seq_ids_does_not_touch_max_seq_id(tmp_path):
|
||||
"""chromadb 1.5.x owns max_seq_id; the shim must not interpret its BLOBs.
|
||||
|
||||
Regression guard for the 2026-04-20 incident: the old shim ran
|
||||
int.from_bytes(..., 'big') over chromadb 1.5.x's native
|
||||
b'\\x11\\x11' + ASCII-digit BLOB, producing a ~1.23e18 integer that
|
||||
silently suppressed every subsequent embeddings_queue write.
|
||||
"""
|
||||
db_path = tmp_path / "chroma.sqlite3"
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
|
||||
conn.execute("CREATE TABLE max_seq_id (rowid INTEGER PRIMARY KEY, seq_id)")
|
||||
sysdb10_blob = b"\x11\x11502607"
|
||||
conn.execute("INSERT INTO max_seq_id (seq_id) VALUES (?)", (sysdb10_blob,))
|
||||
conn.commit()
|
||||
|
||||
_fix_blob_seq_ids(str(tmp_path))
|
||||
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM max_seq_id").fetchone()
|
||||
assert row == (sysdb10_blob, "blob")
|
||||
|
||||
|
||||
def test_fix_blob_seq_ids_skips_sysdb10_prefix_in_embeddings(tmp_path):
|
||||
"""Defense-in-depth: sysdb-10 prefix in embeddings.seq_id is skipped."""
|
||||
db_path = tmp_path / "chroma.sqlite3"
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
|
||||
sysdb10_blob = b"\x11\x11502607"
|
||||
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", (sysdb10_blob,))
|
||||
conn.commit()
|
||||
|
||||
_fix_blob_seq_ids(str(tmp_path))
|
||||
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
|
||||
# Still a BLOB — not converted to 1.23e18.
|
||||
assert row == (sysdb10_blob, "blob")
|
||||
|
||||
|
||||
def test_fix_blob_seq_ids_still_converts_legacy_blobs_in_embeddings(tmp_path):
|
||||
"""Regression guard: pure big-endian u64 BLOBs still convert for genuine 0.6.x."""
|
||||
db_path = tmp_path / "chroma.sqlite3"
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
|
||||
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", ((42).to_bytes(8, "big"),))
|
||||
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", (b"\x11\x11502607",))
|
||||
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", ((7).to_bytes(8, "big"),))
|
||||
conn.commit()
|
||||
|
||||
_fix_blob_seq_ids(str(tmp_path))
|
||||
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT seq_id, typeof(seq_id) FROM embeddings ORDER BY rowid"
|
||||
).fetchall()
|
||||
assert rows[0] == (42, "integer")
|
||||
assert rows[1] == (b"\x11\x11502607", "blob") # sysdb-10 row left alone
|
||||
assert rows[2] == (7, "integer")
|
||||
|
||||
|
||||
def test_fix_blob_seq_ids_writes_marker_after_blob_path(tmp_path):
|
||||
"""The .blob_seq_ids_migrated marker is written after a successful BLOB → INTEGER conversion."""
|
||||
from mempalace.backends.chroma import _BLOB_FIX_MARKER
|
||||
|
||||
db_path = tmp_path / "chroma.sqlite3"
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
|
||||
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", ((42).to_bytes(8, "big"),))
|
||||
conn.commit()
|
||||
|
||||
marker = tmp_path / _BLOB_FIX_MARKER
|
||||
assert not marker.exists()
|
||||
|
||||
_fix_blob_seq_ids(str(tmp_path))
|
||||
|
||||
assert marker.is_file(), "marker must be written after a successful migration"
|
||||
|
||||
|
||||
def test_fix_blob_seq_ids_writes_marker_when_already_integer(tmp_path):
|
||||
"""The marker is written even when the migration is a no-op (already INTEGER).
|
||||
|
||||
The point of the marker is to skip the sqlite3 open on subsequent calls,
|
||||
not to record that a conversion happened. So a clean palace gets the
|
||||
marker on first run too — next ``_fix_blob_seq_ids`` call short-circuits
|
||||
before touching the sqlite3 file.
|
||||
"""
|
||||
from mempalace.backends.chroma import _BLOB_FIX_MARKER
|
||||
|
||||
db_path = tmp_path / "chroma.sqlite3"
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id INTEGER)")
|
||||
conn.execute("INSERT INTO embeddings (seq_id) VALUES (42)")
|
||||
conn.commit()
|
||||
|
||||
marker = tmp_path / _BLOB_FIX_MARKER
|
||||
assert not marker.exists()
|
||||
|
||||
_fix_blob_seq_ids(str(tmp_path))
|
||||
|
||||
assert marker.is_file(), "marker must be written even when no BLOBs found"
|
||||
|
||||
|
||||
def test_fix_blob_seq_ids_skips_sqlite_when_marker_present(tmp_path):
|
||||
"""When the marker exists, ``_fix_blob_seq_ids`` does not open sqlite3.
|
||||
|
||||
This is the load-bearing property of the marker — opening Python's
|
||||
sqlite3 against a live ChromaDB 1.5.x WAL DB corrupts the next
|
||||
PersistentClient call (#1090). Once a palace has been migrated, we
|
||||
never want to open it again, even read-only.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from mempalace.backends.chroma import _BLOB_FIX_MARKER
|
||||
|
||||
# Pre-create the marker so the function should short-circuit.
|
||||
db_path = tmp_path / "chroma.sqlite3"
|
||||
db_path.write_bytes(b"sentinel") # presence required for the function to proceed
|
||||
(tmp_path / _BLOB_FIX_MARKER).touch()
|
||||
|
||||
with patch("mempalace.backends.chroma.sqlite3.connect") as mock_connect:
|
||||
_fix_blob_seq_ids(str(tmp_path))
|
||||
|
||||
mock_connect.assert_not_called()
|
||||
|
||||
|
||||
# ── quarantine_stale_hnsw ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_palace_with_segment(tmp_path, hnsw_mtime, sqlite_mtime):
|
||||
"""Helper: build a palace dir with one HNSW segment + sqlite at given mtimes."""
|
||||
# Marker bytes for the chromadb segment metadata file. A complete
|
||||
# write begins with PROTO opcode (0x80) and ends with STOP opcode
|
||||
# (0x2e); _segment_appears_healthy sniffs these bytes without parsing
|
||||
# the file.
|
||||
_HEALTHY_META = b"\x80\x04" + b"\x00" * 32 + b"\x2e"
|
||||
_CORRUPT_META = b"\x00" * 64
|
||||
|
||||
|
||||
def _make_palace_with_segment(tmp_path, hnsw_mtime, sqlite_mtime, meta_bytes=_HEALTHY_META):
|
||||
"""Helper: build a palace dir with one HNSW segment + sqlite at given
|
||||
mtimes. ``meta_bytes`` controls whether the segment looks healthy
|
||||
(default), corrupt (``_CORRUPT_META``), or has no metadata file at
|
||||
all (``None``)."""
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
(palace / "chroma.sqlite3").write_text("")
|
||||
seg = palace / "abcd-1234-5678"
|
||||
seg.mkdir()
|
||||
(seg / "data_level0.bin").write_text("")
|
||||
if meta_bytes is not None:
|
||||
(seg / "index_metadata.pickle").write_bytes(meta_bytes)
|
||||
os.utime(seg / "data_level0.bin", (hnsw_mtime, hnsw_mtime))
|
||||
os.utime(palace / "chroma.sqlite3", (sqlite_mtime, sqlite_mtime))
|
||||
return palace, seg
|
||||
|
||||
|
||||
def test_quarantine_stale_hnsw_renames_drifted_segment(tmp_path):
|
||||
"""Segment whose data_level0.bin is 2h older than sqlite gets renamed."""
|
||||
def test_quarantine_stale_hnsw_renames_corrupt_segment(tmp_path):
|
||||
"""Segment with stale mtime AND a malformed metadata file gets renamed."""
|
||||
now = 1_700_000_000.0
|
||||
palace, seg = _make_palace_with_segment(tmp_path, hnsw_mtime=now - 7200, sqlite_mtime=now)
|
||||
palace, seg = _make_palace_with_segment(
|
||||
tmp_path,
|
||||
hnsw_mtime=now - 7200,
|
||||
sqlite_mtime=now,
|
||||
meta_bytes=_CORRUPT_META,
|
||||
)
|
||||
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
|
||||
assert len(moved) == 1
|
||||
assert ".drift-" in moved[0]
|
||||
assert not seg.exists()
|
||||
# the renamed directory still exists and contains the original file
|
||||
renamed = list(palace.iterdir())
|
||||
drift_dirs = [p for p in renamed if ".drift-" in p.name]
|
||||
assert len(drift_dirs) == 1
|
||||
assert (drift_dirs[0] / "data_level0.bin").exists()
|
||||
|
||||
|
||||
def test_quarantine_stale_hnsw_leaves_healthy_segment_with_drift_alone(tmp_path):
|
||||
"""Segment with stale mtime but a complete metadata file is NOT
|
||||
renamed — this is the chromadb-1.5.x async-flush steady state, not
|
||||
corruption. Production case at 06:24 PDT 2026-04-26: cold-start
|
||||
quarantine renamed three healthy segments after a clean shutdown,
|
||||
leaving 151K-drawer palace with vector_ranked=0."""
|
||||
now = 1_700_000_000.0
|
||||
palace, seg = _make_palace_with_segment(
|
||||
tmp_path,
|
||||
hnsw_mtime=now - 7200,
|
||||
sqlite_mtime=now,
|
||||
meta_bytes=_HEALTHY_META,
|
||||
)
|
||||
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
|
||||
assert moved == []
|
||||
assert seg.exists()
|
||||
|
||||
|
||||
def test_quarantine_stale_hnsw_leaves_empty_segment_without_metadata_alone(tmp_path):
|
||||
"""Missing metadata is okay only when the segment has no meaningful data yet."""
|
||||
|
||||
now = 1_700_000_000.0
|
||||
palace, seg = _make_palace_with_segment(
|
||||
tmp_path,
|
||||
hnsw_mtime=now - 7200,
|
||||
sqlite_mtime=now,
|
||||
meta_bytes=None,
|
||||
)
|
||||
|
||||
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
|
||||
|
||||
assert moved == []
|
||||
assert seg.exists()
|
||||
|
||||
|
||||
def test_segment_without_metadata_but_with_nontrivial_data_is_unhealthy(tmp_path):
|
||||
"""Data without index_metadata.pickle is a partial flush, not a fresh segment."""
|
||||
|
||||
seg = tmp_path / "abcd-1234-5678"
|
||||
seg.mkdir()
|
||||
(seg / "data_level0.bin").write_bytes(b"\0" * (_HNSW_MISSING_METADATA_DATA_FLOOR + 1))
|
||||
|
||||
assert not _segment_appears_healthy(str(seg))
|
||||
|
||||
|
||||
def test_segment_without_metadata_and_tiny_data_is_still_treated_as_fresh(tmp_path):
|
||||
"""Tiny data payloads can occur before metadata has flushed; leave them alone."""
|
||||
|
||||
seg = tmp_path / "abcd-1234-5678"
|
||||
seg.mkdir()
|
||||
(seg / "data_level0.bin").write_bytes(b"\0" * _HNSW_MISSING_METADATA_DATA_FLOOR)
|
||||
|
||||
assert _segment_appears_healthy(str(seg))
|
||||
|
||||
|
||||
def test_quarantine_stale_hnsw_renames_missing_metadata_with_nontrivial_data(tmp_path):
|
||||
"""Regression for #1274: missing pickle + non-trivial data must quarantine."""
|
||||
|
||||
now = 1_700_000_000.0
|
||||
palace, seg = _make_palace_with_segment(
|
||||
tmp_path,
|
||||
hnsw_mtime=now - 7200,
|
||||
sqlite_mtime=now,
|
||||
meta_bytes=None,
|
||||
)
|
||||
(seg / "data_level0.bin").write_bytes(b"\0" * (_HNSW_MISSING_METADATA_DATA_FLOOR + 1))
|
||||
os.utime(seg / "data_level0.bin", (now - 7200, now - 7200))
|
||||
|
||||
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
|
||||
|
||||
assert len(moved) == 1
|
||||
assert ".drift-" in moved[0]
|
||||
assert not seg.exists()
|
||||
|
||||
drift_dirs = [p for p in palace.iterdir() if ".drift-" in p.name]
|
||||
assert len(drift_dirs) == 1
|
||||
assert (drift_dirs[0] / "data_level0.bin").exists()
|
||||
|
||||
|
||||
def test_quarantine_stale_hnsw_renames_truncated_metadata(tmp_path):
|
||||
"""Segment with a truncated (under-floor-size) metadata file is
|
||||
quarantined — shape of a partial-flush during process kill."""
|
||||
now = 1_700_000_000.0
|
||||
palace, seg = _make_palace_with_segment(
|
||||
tmp_path,
|
||||
hnsw_mtime=now - 7200,
|
||||
sqlite_mtime=now,
|
||||
meta_bytes=b"\x80\x04",
|
||||
)
|
||||
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
|
||||
assert len(moved) == 1
|
||||
assert ".drift-" in moved[0]
|
||||
|
||||
|
||||
def test_quarantine_stale_hnsw_leaves_fresh_segment_alone(tmp_path):
|
||||
"""Segment with recent mtime vs sqlite is not touched."""
|
||||
"""Segment with recent mtime vs sqlite is not touched (mtime gate
|
||||
short-circuits before integrity gate)."""
|
||||
now = 1_700_000_000.0
|
||||
palace, seg = _make_palace_with_segment(tmp_path, hnsw_mtime=now - 10, sqlite_mtime=now)
|
||||
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
|
||||
@@ -443,3 +787,472 @@ def test_quarantine_stale_hnsw_skips_already_quarantined(tmp_path):
|
||||
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
|
||||
assert moved == []
|
||||
assert drift.exists()
|
||||
|
||||
|
||||
# ── make_client cold-start gate ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_make_client_quarantines_only_on_first_call_per_palace(tmp_path, monkeypatch):
|
||||
"""Quarantine fires on first ``make_client()`` for a palace, then is
|
||||
skipped on subsequent calls — prevents runtime thrash where a daemon's
|
||||
own steady writes bump ``chroma.sqlite3`` faster than HNSW flushes,
|
||||
making the mtime heuristic falsely trigger every reconnect.
|
||||
|
||||
Invalid metadata quarantine shares the same cold-start gate here; the
|
||||
more aggressive refresh path lives in ``_client()``."""
|
||||
from mempalace.backends.chroma import ChromaBackend
|
||||
|
||||
palace_path = str(tmp_path / "palace")
|
||||
os.makedirs(palace_path, exist_ok=True)
|
||||
(Path(palace_path) / "chroma.sqlite3").write_text("")
|
||||
|
||||
# Reset the per-process cache so this test is independent of others.
|
||||
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def _spy(path, stale_seconds=300.0):
|
||||
calls.append(path)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _spy)
|
||||
|
||||
ChromaBackend.make_client(palace_path)
|
||||
ChromaBackend.make_client(palace_path)
|
||||
ChromaBackend.make_client(palace_path)
|
||||
|
||||
assert calls == [
|
||||
palace_path
|
||||
], "quarantine_stale_hnsw should fire once per palace per process, not on every reconnect"
|
||||
|
||||
|
||||
def test_make_client_gates_invalid_metadata_on_first_call(tmp_path, monkeypatch):
|
||||
"""Invalid metadata quarantine is gated on the first make_client() call."""
|
||||
from mempalace.backends.chroma import ChromaBackend
|
||||
|
||||
palace_path = str(tmp_path / "palace")
|
||||
os.makedirs(palace_path, exist_ok=True)
|
||||
(Path(palace_path) / "chroma.sqlite3").write_text("")
|
||||
|
||||
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def _invalid(path, *args, **kwargs):
|
||||
calls.append(path)
|
||||
return []
|
||||
|
||||
def _stale(path, stale_seconds=300.0):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("mempalace.backends.chroma.quarantine_invalid_hnsw_metadata", _invalid)
|
||||
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _stale)
|
||||
|
||||
ChromaBackend.make_client(palace_path)
|
||||
ChromaBackend.make_client(palace_path)
|
||||
|
||||
assert calls == [palace_path]
|
||||
|
||||
|
||||
def test_make_client_quarantines_each_palace_independently(tmp_path, monkeypatch):
|
||||
"""Two distinct palaces each get one quarantine attempt — the gate is
|
||||
keyed by palace path, not global."""
|
||||
from mempalace.backends.chroma import ChromaBackend
|
||||
|
||||
palace_a = str(tmp_path / "palace_a")
|
||||
palace_b = str(tmp_path / "palace_b")
|
||||
for p in (palace_a, palace_b):
|
||||
os.makedirs(p, exist_ok=True)
|
||||
(Path(p) / "chroma.sqlite3").write_text("")
|
||||
|
||||
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def _spy(path, stale_seconds=300.0):
|
||||
calls.append(path)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _spy)
|
||||
|
||||
ChromaBackend.make_client(palace_a)
|
||||
ChromaBackend.make_client(palace_b)
|
||||
ChromaBackend.make_client(palace_a) # already gated
|
||||
ChromaBackend.make_client(palace_b) # already gated
|
||||
|
||||
assert calls == [palace_a, palace_b]
|
||||
|
||||
|
||||
# ── _client() cold-start gate (#1121, #1132, #1263) ──────────────────────
|
||||
|
||||
|
||||
def test_client_quarantines_corrupt_segment_on_first_open(tmp_path, monkeypatch):
|
||||
"""The instance ``_client()`` path must run ``quarantine_stale_hnsw``
|
||||
on first open, mirroring the ``make_client()`` static helper. Before
|
||||
PR #1173's wiring was extended here, CLI mining / search / repair /
|
||||
status all skipped the quarantine pass and would SIGSEGV on a stale
|
||||
HNSW segment (#1121, #1132, #1263)."""
|
||||
now = 1_700_000_000.0
|
||||
palace, seg = _make_palace_with_segment(
|
||||
tmp_path,
|
||||
hnsw_mtime=now - 7200,
|
||||
sqlite_mtime=now,
|
||||
meta_bytes=_CORRUPT_META,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
|
||||
|
||||
backend = ChromaBackend()
|
||||
try:
|
||||
backend._client(str(palace))
|
||||
finally:
|
||||
backend.close()
|
||||
|
||||
assert not seg.exists(), "_client() should have quarantined the corrupt segment"
|
||||
drift_dirs = [p for p in palace.iterdir() if ".drift-" in p.name]
|
||||
assert len(drift_dirs) == 1
|
||||
|
||||
|
||||
def test_client_quarantines_only_on_first_call_per_palace(tmp_path, monkeypatch):
|
||||
"""Repeated ``_client()`` calls for the same palace re-run quarantine
|
||||
at most once — the ``_quarantined_paths`` gate prevents runtime
|
||||
thrash on hot paths (``_client()`` is hit on every backend op)."""
|
||||
palace_path = str(tmp_path / "palace")
|
||||
os.makedirs(palace_path, exist_ok=True)
|
||||
(Path(palace_path) / "chroma.sqlite3").write_text("")
|
||||
|
||||
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def _spy(path, stale_seconds=300.0):
|
||||
calls.append(path)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _spy)
|
||||
|
||||
backend = ChromaBackend()
|
||||
try:
|
||||
backend._client(palace_path)
|
||||
backend._client(palace_path)
|
||||
backend._client(palace_path)
|
||||
finally:
|
||||
backend.close()
|
||||
|
||||
assert (
|
||||
calls == [palace_path]
|
||||
), "quarantine_stale_hnsw should fire once per palace per process from _client(), not on every call"
|
||||
|
||||
|
||||
# ── _pin_hnsw_threads (per-process retrofit, separate from this PR's gate) ──
|
||||
|
||||
|
||||
def test_pin_hnsw_threads_retrofits_legacy_collection(tmp_path):
|
||||
"""Legacy collections (created without num_threads) get the retrofit applied."""
|
||||
palace_path = tmp_path / "legacy-palace"
|
||||
palace_path.mkdir()
|
||||
|
||||
client = chromadb.PersistentClient(path=str(palace_path))
|
||||
col = client.create_collection(
|
||||
"mempalace_drawers",
|
||||
metadata={"hnsw:space": "cosine"}, # no num_threads — legacy
|
||||
)
|
||||
assert col.configuration_json.get("hnsw", {}).get("num_threads") is None
|
||||
|
||||
_pin_hnsw_threads(col)
|
||||
|
||||
assert col.configuration_json["hnsw"]["num_threads"] == 1
|
||||
|
||||
|
||||
def test_pin_hnsw_threads_swallows_all_errors():
|
||||
"""Retrofit never raises even when collection.modify explodes."""
|
||||
|
||||
class _ExplodingCollection:
|
||||
def modify(self, *args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
_pin_hnsw_threads(_ExplodingCollection()) # must not raise
|
||||
|
||||
|
||||
def test_get_collection_applies_retrofit_on_existing_palace(tmp_path):
|
||||
"""ChromaBackend.get_collection(create=False) applies the retrofit."""
|
||||
palace_path = tmp_path / "palace"
|
||||
palace_path.mkdir()
|
||||
|
||||
# Simulate a legacy palace: create collection without num_threads
|
||||
bootstrap_client = chromadb.PersistentClient(path=str(palace_path))
|
||||
bootstrap_client.create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
|
||||
del bootstrap_client # drop reference so a fresh client reopens cleanly
|
||||
|
||||
wrapper = ChromaBackend().get_collection(
|
||||
str(palace_path),
|
||||
collection_name="mempalace_drawers",
|
||||
create=False,
|
||||
)
|
||||
|
||||
assert wrapper._collection.configuration_json["hnsw"]["num_threads"] == 1
|
||||
|
||||
|
||||
def test_quarantine_invalid_hnsw_metadata_renames_missing_dimensionality(tmp_path):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
seg = palace / "abcd-1234-5678"
|
||||
seg.mkdir()
|
||||
with open(seg / "index_metadata.pickle", "wb") as f:
|
||||
pickle.dump({"dimensionality": None, "id_to_label": {"a": 1}}, f)
|
||||
|
||||
moved = quarantine_invalid_hnsw_metadata(str(palace))
|
||||
|
||||
assert len(moved) == 1
|
||||
assert ".corrupt-" in moved[0]
|
||||
assert not seg.exists()
|
||||
|
||||
|
||||
def test_quarantine_invalid_hnsw_metadata_allows_uninitialized_segment(tmp_path):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
seg = palace / "abcd-1234-5678"
|
||||
seg.mkdir()
|
||||
with open(seg / "index_metadata.pickle", "wb") as f:
|
||||
pickle.dump({"dimensionality": None, "id_to_label": {}}, f)
|
||||
|
||||
moved = quarantine_invalid_hnsw_metadata(str(palace))
|
||||
|
||||
assert moved == []
|
||||
assert seg.exists()
|
||||
|
||||
|
||||
def test_quarantine_invalid_hnsw_metadata_rejects_non_dict_id_to_label(tmp_path):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
seg = palace / "abcd-1234-5678"
|
||||
seg.mkdir()
|
||||
with open(seg / "index_metadata.pickle", "wb") as f:
|
||||
pickle.dump({"dimensionality": 8, "id_to_label": ["a", "b"]}, f)
|
||||
|
||||
moved = quarantine_invalid_hnsw_metadata(str(palace))
|
||||
|
||||
assert len(moved) == 1
|
||||
assert ".corrupt-" in moved[0]
|
||||
assert not seg.exists()
|
||||
|
||||
|
||||
def test_quarantine_invalid_hnsw_metadata_rejects_non_schema_payload(tmp_path):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
seg = palace / "abcd-1234-5678"
|
||||
seg.mkdir()
|
||||
with open(seg / "index_metadata.pickle", "wb") as f:
|
||||
pickle.dump(["not", "a", "metadata", "object"], f)
|
||||
|
||||
moved = quarantine_invalid_hnsw_metadata(str(palace))
|
||||
|
||||
assert len(moved) == 1
|
||||
assert ".corrupt-" in moved[0]
|
||||
assert not seg.exists()
|
||||
|
||||
|
||||
def _dangerous_pickle_payload_executed():
|
||||
raise AssertionError("unsafe pickle payload executed")
|
||||
|
||||
|
||||
class _DangerousPickle:
|
||||
def __reduce__(self):
|
||||
return (_dangerous_pickle_payload_executed, ())
|
||||
|
||||
|
||||
def test_quarantine_invalid_hnsw_metadata_rejects_unsafe_pickle(tmp_path):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
seg = palace / "abcd-1234-5678"
|
||||
seg.mkdir()
|
||||
with open(seg / "index_metadata.pickle", "wb") as f:
|
||||
pickle.dump(_DangerousPickle(), f)
|
||||
|
||||
moved = quarantine_invalid_hnsw_metadata(str(palace))
|
||||
|
||||
assert len(moved) == 1
|
||||
assert ".corrupt-" in moved[0]
|
||||
assert not seg.exists()
|
||||
|
||||
|
||||
def test_quarantine_invalid_hnsw_metadata_skips_transient_read_errors(tmp_path, monkeypatch):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
seg = palace / "abcd-1234-5678"
|
||||
seg.mkdir()
|
||||
meta = seg / "index_metadata.pickle"
|
||||
meta.write_bytes(b"partial")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mempalace.backends.chroma._SafePersistentDataUnpickler.load",
|
||||
lambda path: (_ for _ in ()).throw(EOFError("flush in progress")),
|
||||
)
|
||||
|
||||
moved = quarantine_invalid_hnsw_metadata(str(palace))
|
||||
|
||||
assert moved == []
|
||||
assert seg.exists()
|
||||
|
||||
|
||||
def test_quarantine_invalid_hnsw_metadata_skips_truncated_pickle(tmp_path, monkeypatch):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
seg = palace / "abcd-1234-5678"
|
||||
seg.mkdir()
|
||||
meta = seg / "index_metadata.pickle"
|
||||
meta.write_bytes(b"partial")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mempalace.backends.chroma._SafePersistentDataUnpickler.load",
|
||||
lambda path: (_ for _ in ()).throw(pickle.UnpicklingError("pickle data was truncated")),
|
||||
)
|
||||
|
||||
moved = quarantine_invalid_hnsw_metadata(str(palace))
|
||||
|
||||
assert moved == []
|
||||
assert seg.exists()
|
||||
|
||||
|
||||
def test_chroma_backend_preflights_metadata_before_persistent_client(tmp_path, monkeypatch):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
calls = []
|
||||
|
||||
def _record(name):
|
||||
def inner(path, *args, **kwargs):
|
||||
calls.append((name, path))
|
||||
return [] if name != "blob" else None
|
||||
|
||||
return inner
|
||||
|
||||
monkeypatch.setattr("mempalace.backends.chroma._fix_blob_seq_ids", _record("blob"))
|
||||
monkeypatch.setattr(
|
||||
"mempalace.backends.chroma.quarantine_invalid_hnsw_metadata", _record("invalid")
|
||||
)
|
||||
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _record("stale"))
|
||||
|
||||
class DummyClient:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mempalace.backends.chroma.chromadb.PersistentClient", lambda path: DummyClient()
|
||||
)
|
||||
|
||||
backend = ChromaBackend()
|
||||
backend._client(str(palace))
|
||||
|
||||
assert calls == [
|
||||
("blob", str(palace)),
|
||||
("invalid", str(palace)),
|
||||
("stale", str(palace)),
|
||||
]
|
||||
|
||||
|
||||
def test_chroma_backend_stale_quarantine_is_cold_start_only_on_refresh(tmp_path, monkeypatch):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
(palace / "chroma.sqlite3").write_text("")
|
||||
calls = []
|
||||
|
||||
def _record(name):
|
||||
def inner(path, *args, **kwargs):
|
||||
calls.append((name, path))
|
||||
return [] if name != "blob" else None
|
||||
|
||||
return inner
|
||||
|
||||
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
|
||||
monkeypatch.setattr("mempalace.backends.chroma._fix_blob_seq_ids", _record("blob"))
|
||||
monkeypatch.setattr(
|
||||
"mempalace.backends.chroma.quarantine_invalid_hnsw_metadata", _record("invalid")
|
||||
)
|
||||
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _record("stale"))
|
||||
|
||||
class DummyClient:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mempalace.backends.chroma.chromadb.PersistentClient", lambda path: DummyClient()
|
||||
)
|
||||
|
||||
backend = ChromaBackend()
|
||||
stats = iter([(1, 1.0), (1, 1.0), (1, 2.0), (1, 2.0)])
|
||||
monkeypatch.setattr(backend, "_db_stat", lambda path: next(stats))
|
||||
|
||||
backend._client(str(palace))
|
||||
backend._client(str(palace))
|
||||
|
||||
assert calls == [
|
||||
("blob", str(palace)),
|
||||
("invalid", str(palace)),
|
||||
("stale", str(palace)),
|
||||
("blob", str(palace)),
|
||||
]
|
||||
|
||||
|
||||
def test_chroma_backend_requarantines_after_inode_replacement(tmp_path, monkeypatch):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
(palace / "chroma.sqlite3").write_text("")
|
||||
calls = []
|
||||
|
||||
def _record(name):
|
||||
def inner(path, *args, **kwargs):
|
||||
calls.append((name, path))
|
||||
return [] if name != "blob" else None
|
||||
|
||||
return inner
|
||||
|
||||
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
|
||||
monkeypatch.setattr("mempalace.backends.chroma._fix_blob_seq_ids", _record("blob"))
|
||||
monkeypatch.setattr(
|
||||
"mempalace.backends.chroma.quarantine_invalid_hnsw_metadata", _record("invalid")
|
||||
)
|
||||
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _record("stale"))
|
||||
|
||||
class DummyClient:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"mempalace.backends.chroma.chromadb.PersistentClient", lambda path: DummyClient()
|
||||
)
|
||||
|
||||
backend = ChromaBackend()
|
||||
stats = iter([(1, 1.0), (1, 1.0), (2, 2.0), (2, 2.0)])
|
||||
monkeypatch.setattr(backend, "_db_stat", lambda path: next(stats))
|
||||
|
||||
backend._client(str(palace))
|
||||
backend._client(str(palace))
|
||||
|
||||
assert calls == [
|
||||
("blob", str(palace)),
|
||||
("invalid", str(palace)),
|
||||
("stale", str(palace)),
|
||||
("blob", str(palace)),
|
||||
("invalid", str(palace)),
|
||||
("stale", str(palace)),
|
||||
]
|
||||
|
||||
|
||||
def test_palace_get_collection_uses_configured_collection_name(monkeypatch):
|
||||
from mempalace import palace
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_get_collection(palace_path, collection_name=None, create=False):
|
||||
captured["palace_path"] = palace_path
|
||||
captured["collection_name"] = collection_name
|
||||
captured["create"] = create
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(palace._DEFAULT_BACKEND, "get_collection", fake_get_collection)
|
||||
monkeypatch.setattr("mempalace.config.get_configured_collection_name", lambda: "custom_drawers")
|
||||
|
||||
palace.get_collection("/palace", create=False)
|
||||
|
||||
assert captured == {
|
||||
"palace_path": "/palace",
|
||||
"collection_name": "custom_drawers",
|
||||
"create": False,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
+624
-6
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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")
|
||||
+177
-1
@@ -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,6 +27,30 @@ def test_config_from_file():
|
||||
assert cfg.palace_path == "/custom/palace"
|
||||
|
||||
|
||||
def test_embedding_device_defaults_to_auto(monkeypatch):
|
||||
monkeypatch.delenv("MEMPALACE_EMBEDDING_DEVICE", raising=False)
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
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
|
||||
@@ -86,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 ---
|
||||
|
||||
|
||||
@@ -169,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"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Unit tests for convo_miner pure functions (no chromadb needed)."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from mempalace.convo_miner import (
|
||||
_file_chunks_locked,
|
||||
chunk_exchanges,
|
||||
detect_convo_room,
|
||||
scan_convos,
|
||||
@@ -111,3 +114,36 @@ class TestScanConvos:
|
||||
def test_scan_empty_dir(self, tmp_path):
|
||||
files = scan_convos(str(tmp_path))
|
||||
assert files == []
|
||||
|
||||
|
||||
class TestFileChunksLocked:
|
||||
def test_uses_bounded_upsert_batches(self, monkeypatch):
|
||||
import mempalace.convo_miner as convo_miner
|
||||
|
||||
class FakeCol:
|
||||
def __init__(self):
|
||||
self.batch_sizes = []
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def upsert(self, documents, ids, metadatas):
|
||||
self.batch_sizes.append(len(documents))
|
||||
|
||||
chunks = [{"content": f"chunk {i} " * 20, "chunk_index": i} for i in range(5)]
|
||||
col = FakeCol()
|
||||
monkeypatch.setattr(convo_miner, "DRAWER_UPSERT_BATCH_SIZE", 2)
|
||||
monkeypatch.setattr(
|
||||
convo_miner, "file_already_mined", lambda collection, source_file: False
|
||||
)
|
||||
monkeypatch.setattr(convo_miner, "mine_lock", lambda source_file: contextlib.nullcontext())
|
||||
monkeypatch.setattr(convo_miner, "_detect_hall_cached", lambda content: "conversations")
|
||||
|
||||
drawers, room_counts, skipped = _file_chunks_locked(
|
||||
col, "chat.txt", chunks, "wing", "general", "agent", "exchange"
|
||||
)
|
||||
|
||||
assert drawers == 5
|
||||
assert dict(room_counts) == {}
|
||||
assert skipped is False
|
||||
assert col.batch_sizes == [2, 2, 1]
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Tests for corpus_origin detection.
|
||||
|
||||
The corpus-origin detector answers ONE foundational question before any
|
||||
downstream Pass 2 classification runs:
|
||||
|
||||
"Is this corpus a record of AI-agent dialogue, and if so, which platform
|
||||
and what persona names has the user assigned to the agent?"
|
||||
|
||||
Detection is two-tier:
|
||||
- Tier 1: cheap content-aware heuristic (grep for well-known AI terms
|
||||
and turn markers). No API calls. Always runs.
|
||||
- Tier 2: LLM-assisted confirmation + persona extraction. Takes a small
|
||||
sample of drawer texts and uses Haiku's pre-trained world knowledge
|
||||
about Claude/ChatGPT/Gemini/etc. to confirm platform + identify
|
||||
persona-names the user assigned to the agent.
|
||||
|
||||
Default stance: "this IS an AI-dialogue corpus" unless strong evidence
|
||||
otherwise. False-negative (missing an AI corpus) is catastrophic for
|
||||
downstream classification; false-positive is recoverable via per-drawer
|
||||
voice-profile detection in later passes.
|
||||
|
||||
TDD: these tests fail until mempalace/corpus_origin.py is implemented."""
|
||||
|
||||
from mempalace.corpus_origin import (
|
||||
CorpusOriginResult,
|
||||
detect_origin_heuristic,
|
||||
detect_origin_llm,
|
||||
)
|
||||
|
||||
|
||||
# ── Tier 1: heuristic (no LLM) ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHeuristic:
|
||||
def test_claude_heavy_corpus_detected(self):
|
||||
"""A corpus with abundant Claude references + turn markers should
|
||||
be confidently detected as AI-dialogue."""
|
||||
samples = [
|
||||
"user: hey Claude, can you help me\nassistant: sure, what do you need\n",
|
||||
"I was talking to Claude Opus about the MCP server setup",
|
||||
"Sonnet 4.5 handled this better than Haiku 4.5 did",
|
||||
"claude mcp add mempalace -- mempalace-mcp",
|
||||
"human: what's up\nassistant: I'm happy to help",
|
||||
]
|
||||
result = detect_origin_heuristic(samples)
|
||||
assert result.likely_ai_dialogue is True
|
||||
assert result.confidence >= 0.8
|
||||
assert (
|
||||
"Claude" in " ".join(result.evidence) or "claude" in " ".join(result.evidence).lower()
|
||||
)
|
||||
|
||||
def test_gpt_corpus_detected(self):
|
||||
samples = [
|
||||
"I asked ChatGPT to summarize my paper",
|
||||
"The GPT-4 response was surprisingly good",
|
||||
"user: explain quantum computing\nassistant: quantum computing uses qubits",
|
||||
"OpenAI's model was able to help with the code",
|
||||
]
|
||||
result = detect_origin_heuristic(samples)
|
||||
assert result.likely_ai_dialogue is True
|
||||
assert any("GPT" in e or "ChatGPT" in e or "OpenAI" in e for e in result.evidence)
|
||||
|
||||
def test_pure_narrative_corpus_detected_as_not_ai(self):
|
||||
"""A story/journal corpus with no AI signals should be flagged
|
||||
not-AI (default stance flipped only with evidence)."""
|
||||
samples = [
|
||||
"Today the cat finally ventured into the garden. The dog watched.",
|
||||
"The morning light came through the window as I wrote.",
|
||||
"Chapter 3: The Reckoning. It was a dark and stormy night.",
|
||||
"My father's old journal described the same field in 1972.",
|
||||
]
|
||||
result = detect_origin_heuristic(samples)
|
||||
assert result.likely_ai_dialogue is False
|
||||
assert result.confidence >= 0.8
|
||||
|
||||
def test_ambiguous_corpus_defaults_to_ai(self):
|
||||
"""When evidence is thin or mixed, default to assuming AI-dialogue.
|
||||
False-negative is worse than false-positive."""
|
||||
samples = [
|
||||
"some notes about the meeting today",
|
||||
"Later on I went to the store.",
|
||||
"Short file with little signal.",
|
||||
]
|
||||
result = detect_origin_heuristic(samples)
|
||||
# Low signal → default stance is ai_dialogue=True with low confidence
|
||||
assert result.likely_ai_dialogue is True
|
||||
assert result.confidence <= 0.6
|
||||
assert "default-stance" in " ".join(result.evidence).lower()
|
||||
|
||||
def test_turn_markers_alone_sufficient(self):
|
||||
"""Even without AI brand mentions, strong turn-marker presence
|
||||
indicates dialogue structure consistent with AI corpora."""
|
||||
samples = [
|
||||
"user: hello\nassistant: hi there, how can I help?\nuser: summarize X\nassistant: sure",
|
||||
"human: what's the weather\nai: I don't have real-time data\n",
|
||||
]
|
||||
result = detect_origin_heuristic(samples)
|
||||
assert result.likely_ai_dialogue is True
|
||||
|
||||
# ── Pattern + context (not capitalization, not English-rule) ──────────
|
||||
|
||||
def test_brand_terms_case_insensitive(self):
|
||||
"""Detection cannot rely on the user typing proper-cased brand names.
|
||||
Lowercase 'claude code', 'chatgpt', 'gemini-pro', 'mcp' must trip
|
||||
the same as their proper-cased equivalents. NO turn-marker fallback
|
||||
in this corpus — the brand matches must do the work."""
|
||||
samples = [
|
||||
"i love claude code, it just works for refactoring tasks",
|
||||
"asked chatgpt to write a regex and it nailed it on the first try",
|
||||
"switched to gemini-pro for the long-context summary task last week",
|
||||
"added mempalace as an mcp server in my .claude/ settings file",
|
||||
"anthropic's haiku model is cheap enough to run on every drawer",
|
||||
]
|
||||
result = detect_origin_heuristic(samples)
|
||||
assert (
|
||||
result.likely_ai_dialogue is True
|
||||
), f"lowercase brand terms missed; evidence: {result.evidence}"
|
||||
# Evidence must show MULTIPLE distinct case-insensitive brand matches.
|
||||
# 'chatgpt' lowercase only matches under case-insensitive search
|
||||
# (the brand list has 'ChatGPT' proper-cased only).
|
||||
evidence_str = " ".join(result.evidence).lower()
|
||||
matched = sum(t in evidence_str for t in ("chatgpt", "anthropic", "haiku", "gemini-pro"))
|
||||
assert (
|
||||
matched >= 2
|
||||
), f"case-insensitive brand matches did not fire — only got: {result.evidence}"
|
||||
|
||||
def test_zodiac_corpus_not_flagged_as_ai(self):
|
||||
"""An astrology forum post with high 'Gemini' density but ZERO
|
||||
unambiguous AI signals (no MCP/LLM/ChatGPT/turn markers) must NOT
|
||||
be flagged as AI-dialogue. Word-sense disambiguation is required:
|
||||
Gemini-the-zodiac-sign vs Gemini-the-AI-platform."""
|
||||
samples = [
|
||||
"I'm a Gemini sun, Pisces moon, and Leo rising.",
|
||||
"Geminis are dreamers and overthinkers — that's the dual nature.",
|
||||
"Compatibility between Gemini and Sagittarius is famously strong.",
|
||||
"If you're a Gemini, expect Mercury retrograde to hit you hardest.",
|
||||
"My horoscope this week says Gemini energy will dominate Wednesday.",
|
||||
"The Gemini twins in Greek mythology are Castor and Pollux.",
|
||||
]
|
||||
result = detect_origin_heuristic(samples)
|
||||
assert (
|
||||
result.likely_ai_dialogue is False
|
||||
), f"zodiac corpus wrongly flagged AI; evidence: {result.evidence}"
|
||||
|
||||
def test_french_novel_with_claude_name_not_flagged(self):
|
||||
"""A French novel where 'Claude' is a character name (Claude is a
|
||||
common French masculine name) must NOT trip AI-dialogue detection.
|
||||
Disambiguation is by context, not by the presence of the word."""
|
||||
samples = [
|
||||
"Claude marchait lentement le long de la Seine ce matin-là.",
|
||||
"« Claude, tu rentres dîner? » lui demanda sa mère depuis la cuisine.",
|
||||
"Pour Claude, l'art de vivre passait avant tout par la patience.",
|
||||
"Le vieux Claude se souvenait encore de la guerre, des champs déserts.",
|
||||
"Claude ouvrit la fenêtre. Le matin sentait le pain frais et la pluie.",
|
||||
"Les amis de Claude s'étaient réunis chez lui pour fêter ses soixante ans.",
|
||||
]
|
||||
result = detect_origin_heuristic(samples)
|
||||
assert (
|
||||
result.likely_ai_dialogue is False
|
||||
), f"French novel wrongly flagged AI; evidence: {result.evidence}"
|
||||
|
||||
def test_poetry_corpus_with_haiku_sonnet_not_flagged(self):
|
||||
"""A poetry corpus with high 'haiku', 'sonnet', 'opus' density
|
||||
(poetic forms / classical music terms) but no AI infrastructure
|
||||
terms must NOT be flagged as AI-dialogue."""
|
||||
samples = [
|
||||
"A haiku is seventeen syllables across three lines: 5-7-5.",
|
||||
"Shakespeare's sonnet 18 remains the most quoted in the English canon.",
|
||||
"Beethoven's opus 27 includes the Moonlight Sonata.",
|
||||
"I wrote three haiku this morning before coffee.",
|
||||
"The sonnet form arrived in England via Wyatt and Surrey.",
|
||||
"Her first opus, published at twenty, was a song cycle for soprano.",
|
||||
]
|
||||
result = detect_origin_heuristic(samples)
|
||||
assert (
|
||||
result.likely_ai_dialogue is False
|
||||
), f"poetry corpus wrongly flagged AI; evidence: {result.evidence}"
|
||||
|
||||
def test_word_boundary_brand_matching(self):
|
||||
"""Brand-term matching must use word boundaries. Embedded matches
|
||||
inside larger words ('Claudette' → 'Claude', 'opuscule' → 'Opus',
|
||||
'sonneteer' → 'Sonnet', 'llamas' → 'Llama', 'bardic' → 'Bard')
|
||||
must NOT be counted as brand hits.
|
||||
|
||||
Word boundaries don't change classification on the co-occurrence-
|
||||
suppressed cases, but they clean up the evidence strings — false
|
||||
matches must not appear in the audit trail. They also prevent
|
||||
'Claude Code' from triple-counting as 'Claude Code' + 'Claude'
|
||||
overlap."""
|
||||
samples = [
|
||||
"My grandmother Claudette baked the most beautiful tarts every Sunday.",
|
||||
"Two llamas were spotted near the trailhead this morning at sunrise.",
|
||||
"Beethoven's opuscule for solo violin remained unpublished for decades.",
|
||||
"She studied to become a sonneteer after reading the full Spenser cycle.",
|
||||
"Bardic traditions in the Hebrides survived well into the eighteenth century.",
|
||||
"The complete opuses of Mozart fill an entire wall of the library.",
|
||||
]
|
||||
result = detect_origin_heuristic(samples)
|
||||
evidence_str = " ".join(result.evidence).lower()
|
||||
|
||||
# None of the brand terms should show up in evidence — every
|
||||
# would-be match is an embedded false-positive that word
|
||||
# boundaries should suppress.
|
||||
for embedded_term in ("claude", "opus", "sonnet", "llama", "bard"):
|
||||
assert f"'{embedded_term}'" not in evidence_str, (
|
||||
f"word-boundary bug: '{embedded_term}' falsely matched inside "
|
||||
f"a longer word — evidence: {result.evidence}"
|
||||
)
|
||||
|
||||
# And classification should be not-AI (no real AI signals present).
|
||||
assert (
|
||||
result.likely_ai_dialogue is False
|
||||
), f"corpus has no real AI signals; evidence: {result.evidence}"
|
||||
|
||||
def test_ambiguous_brand_with_unambiguous_signal_flagged(self):
|
||||
"""When an ambiguous brand term ('Gemini') co-occurs with an
|
||||
UNAMBIGUOUS AI signal (turn markers, MCP, ChatGPT, Claude Code)
|
||||
in the same corpus, the Gemini hits SHOULD count and the corpus
|
||||
SHOULD be flagged as AI-dialogue."""
|
||||
samples = [
|
||||
"Switched the agent from Gemini to ChatGPT mid-session for cost reasons.",
|
||||
"Gemini handled the long-context task; user: please summarize\nassistant: here is the summary",
|
||||
"user: try Gemini for this\nassistant: running it through gemini-pro now",
|
||||
"MCP server config: Gemini as primary, OpenAI as fallback.",
|
||||
]
|
||||
result = detect_origin_heuristic(samples)
|
||||
assert (
|
||||
result.likely_ai_dialogue is True
|
||||
), f"ambiguous+unambiguous co-occurrence missed; evidence: {result.evidence}"
|
||||
|
||||
|
||||
# ── Tier 2: LLM-assisted (mocked) ─────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeProvider:
|
||||
"""Minimal stand-in for mempalace's LLMProvider used for testing."""
|
||||
|
||||
def __init__(self, canned_response):
|
||||
self._response = canned_response
|
||||
self.calls = []
|
||||
|
||||
def classify(self, system, user, json_mode=True):
|
||||
self.calls.append({"system": system, "user": user})
|
||||
|
||||
class R:
|
||||
text = self._response
|
||||
|
||||
return R()
|
||||
|
||||
def check_available(self):
|
||||
return True, "ok"
|
||||
|
||||
|
||||
class TestLLMConfirmation:
|
||||
def test_extracts_persona_names_and_platform(self):
|
||||
fake_response = """{
|
||||
"is_ai_dialogue_corpus": true,
|
||||
"confidence": 0.97,
|
||||
"primary_platform": "Claude Code (Anthropic CLI)",
|
||||
"agent_persona_names": ["Echo", "Sparrow", "Cipher", "Orc"],
|
||||
"evidence": [
|
||||
"user addresses agent as 'Echo' on assistant turns",
|
||||
"Claude Code banner text in samples",
|
||||
"references to MCP, CLAUDE.md, hooks"
|
||||
]
|
||||
}"""
|
||||
provider = _FakeProvider(fake_response)
|
||||
samples = [
|
||||
"user: hey Echo, what's up\nassistant: I'm here, what do you need\n",
|
||||
"Claude Code session banner Sonnet 4.5 Claude Pro",
|
||||
]
|
||||
result = detect_origin_llm(samples, provider)
|
||||
assert result.likely_ai_dialogue is True
|
||||
assert result.confidence >= 0.9
|
||||
assert "Echo" in result.agent_persona_names
|
||||
assert "Sparrow" in result.agent_persona_names
|
||||
assert "Claude" in result.primary_platform
|
||||
|
||||
def test_narrative_corpus_llm_confirms_no_agent(self):
|
||||
fake_response = """{
|
||||
"is_ai_dialogue_corpus": false,
|
||||
"confidence": 0.95,
|
||||
"primary_platform": null,
|
||||
"agent_persona_names": [],
|
||||
"evidence": ["pure narrative prose, no turn markers, no AI terms"]
|
||||
}"""
|
||||
provider = _FakeProvider(fake_response)
|
||||
samples = ["Once upon a time in a small village", "The old woman smiled"]
|
||||
result = detect_origin_llm(samples, provider)
|
||||
assert result.likely_ai_dialogue is False
|
||||
assert result.agent_persona_names == []
|
||||
assert result.primary_platform is None
|
||||
|
||||
def test_handles_malformed_llm_response(self):
|
||||
"""If the LLM returns garbage, fall back gracefully to the
|
||||
conservative default (assume AI-dialogue with low confidence)."""
|
||||
provider = _FakeProvider("not even close to JSON")
|
||||
result = detect_origin_llm(["sample text"], provider)
|
||||
# Fallback: conservative default, low confidence
|
||||
assert result.likely_ai_dialogue is True
|
||||
assert result.confidence <= 0.5
|
||||
assert (
|
||||
"fallback" in " ".join(result.evidence).lower()
|
||||
or "error" in " ".join(result.evidence).lower()
|
||||
)
|
||||
|
||||
def test_filters_user_name_out_of_personas(self):
|
||||
"""Regression test: Haiku sometimes leaks the user's own name into
|
||||
agent_persona_names despite the prompt's CRITICAL distinction. The
|
||||
parser must strip the user's name from personas if it appears in
|
||||
both fields (case-insensitive). The user is the human author of
|
||||
the corpus, not an agent persona."""
|
||||
fake_response = """{
|
||||
"is_ai_dialogue_corpus": true,
|
||||
"confidence": 0.97,
|
||||
"primary_platform": "Claude (Anthropic)",
|
||||
"user_name": "Jordan",
|
||||
"agent_persona_names": ["Echo", "Sparrow", "Jordan", "Cipher"],
|
||||
"evidence": ["user Jordan talks to agents Echo/Sparrow/Cipher"]
|
||||
}"""
|
||||
provider = _FakeProvider(fake_response)
|
||||
result = detect_origin_llm(["sample"], provider)
|
||||
# user_name is exposed in its own field
|
||||
assert result.user_name == "Jordan"
|
||||
# "Jordan" is filtered out of agent_persona_names
|
||||
assert "Jordan" not in result.agent_persona_names
|
||||
# Real personas are preserved
|
||||
for persona in ("Echo", "Sparrow", "Cipher"):
|
||||
assert persona in result.agent_persona_names
|
||||
|
||||
def test_filter_is_case_insensitive(self):
|
||||
"""The user-name filter works even when the LLM returns a casing
|
||||
mismatch between user_name and the personas list."""
|
||||
fake_response = """{
|
||||
"is_ai_dialogue_corpus": true,
|
||||
"confidence": 0.9,
|
||||
"primary_platform": "Claude",
|
||||
"user_name": "Jordan",
|
||||
"agent_persona_names": ["Echo", "jordan", "JORDAN", "Cipher"],
|
||||
"evidence": []
|
||||
}"""
|
||||
provider = _FakeProvider(fake_response)
|
||||
result = detect_origin_llm(["sample"], provider)
|
||||
# All case-variants of the user's name are filtered
|
||||
assert "jordan" not in [p.lower() for p in result.agent_persona_names]
|
||||
assert result.agent_persona_names == ["Echo", "Cipher"]
|
||||
|
||||
def test_user_name_field_surfaces_author(self):
|
||||
"""The user_name field captures the human author of the corpus,
|
||||
separate from agent personas. This gives downstream passes a
|
||||
clear 'who is the user, who is the agent' distinction."""
|
||||
fake_response = """{
|
||||
"is_ai_dialogue_corpus": true,
|
||||
"confidence": 0.95,
|
||||
"primary_platform": "ChatGPT (OpenAI)",
|
||||
"user_name": "Sarah",
|
||||
"agent_persona_names": ["MyAssistant"],
|
||||
"evidence": ["Sarah writes to MyAssistant"]
|
||||
}"""
|
||||
provider = _FakeProvider(fake_response)
|
||||
result = detect_origin_llm(["sample"], provider)
|
||||
assert result.user_name == "Sarah"
|
||||
assert result.agent_persona_names == ["MyAssistant"]
|
||||
|
||||
|
||||
# ── CorpusOriginResult dataclass ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResultDataclass:
|
||||
def test_result_has_all_fields(self):
|
||||
r = CorpusOriginResult(
|
||||
likely_ai_dialogue=True,
|
||||
confidence=0.95,
|
||||
primary_platform="Claude Code",
|
||||
agent_persona_names=["Echo"],
|
||||
evidence=["test"],
|
||||
)
|
||||
assert r.likely_ai_dialogue is True
|
||||
assert r.confidence == 0.95
|
||||
assert r.primary_platform == "Claude Code"
|
||||
assert r.agent_persona_names == ["Echo"]
|
||||
assert r.evidence == ["test"]
|
||||
|
||||
def test_result_serializes_to_dict(self):
|
||||
r = CorpusOriginResult(
|
||||
likely_ai_dialogue=False,
|
||||
confidence=0.9,
|
||||
primary_platform=None,
|
||||
agent_persona_names=[],
|
||||
evidence=[],
|
||||
)
|
||||
d = r.to_dict()
|
||||
assert d["likely_ai_dialogue"] is False
|
||||
assert d["primary_platform"] is None
|
||||
assert d["agent_persona_names"] == []
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
import pytest
|
||||
|
||||
import mempalace.embedding as embedding
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_embedding_state(monkeypatch):
|
||||
monkeypatch.setattr(embedding, "_EF_CACHE", {})
|
||||
monkeypatch.setattr(embedding, "_WARNED", set())
|
||||
|
||||
|
||||
def test_auto_picks_cuda(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"onnxruntime.get_available_providers",
|
||||
lambda: ["CUDAExecutionProvider", "CPUExecutionProvider"],
|
||||
)
|
||||
|
||||
assert embedding._resolve_providers("auto") == (
|
||||
["CUDAExecutionProvider", "CPUExecutionProvider"],
|
||||
"cuda",
|
||||
)
|
||||
|
||||
|
||||
def test_auto_falls_to_cpu(monkeypatch):
|
||||
monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])
|
||||
|
||||
assert embedding._resolve_providers("auto") == (["CPUExecutionProvider"], "cpu")
|
||||
|
||||
|
||||
def test_cuda_missing_warns_with_gpu_extra(monkeypatch, caplog):
|
||||
monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])
|
||||
|
||||
assert embedding._resolve_providers("cuda") == (["CPUExecutionProvider"], "cpu")
|
||||
assert "mempalace[gpu]" in caplog.text
|
||||
|
||||
|
||||
def test_coreml_missing_warns_with_coreml_extra(monkeypatch, caplog):
|
||||
monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])
|
||||
|
||||
assert embedding._resolve_providers("coreml") == (["CPUExecutionProvider"], "cpu")
|
||||
assert "mempalace[coreml]" in caplog.text
|
||||
|
||||
|
||||
def test_dml_missing_warns_with_dml_extra(monkeypatch, caplog):
|
||||
monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])
|
||||
|
||||
assert embedding._resolve_providers("dml") == (["CPUExecutionProvider"], "cpu")
|
||||
assert "mempalace[dml]" in caplog.text
|
||||
|
||||
|
||||
def test_unknown_device_warns_once(monkeypatch, caplog):
|
||||
monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])
|
||||
|
||||
assert embedding._resolve_providers("bogus") == (["CPUExecutionProvider"], "cpu")
|
||||
assert embedding._resolve_providers("bogus") == (["CPUExecutionProvider"], "cpu")
|
||||
assert caplog.text.count("Unknown embedding_device") == 1
|
||||
|
||||
|
||||
def test_onnxruntime_import_error_falls_back_to_cpu(monkeypatch):
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "onnxruntime":
|
||||
raise ImportError("missing")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
assert embedding._resolve_providers("cuda") == (["CPUExecutionProvider"], "cpu")
|
||||
|
||||
|
||||
def test_get_embedding_function_caches_by_resolved_provider_tuple(monkeypatch):
|
||||
class DummyEF:
|
||||
def __init__(self, preferred_providers):
|
||||
self.preferred_providers = preferred_providers
|
||||
|
||||
monkeypatch.setattr(embedding, "_build_ef_class", lambda: DummyEF)
|
||||
monkeypatch.setattr(
|
||||
embedding, "_resolve_providers", lambda device: (["CPUExecutionProvider"], "cpu")
|
||||
)
|
||||
|
||||
first = embedding.get_embedding_function("cpu")
|
||||
second = embedding.get_embedding_function("auto")
|
||||
|
||||
assert first is second
|
||||
assert first.preferred_providers == ["CPUExecutionProvider"]
|
||||
|
||||
|
||||
def test_describe_device_uses_resolved_effective_device(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
embedding,
|
||||
"_resolve_providers",
|
||||
lambda device: (["CUDAExecutionProvider", "CPUExecutionProvider"], "cuda"),
|
||||
)
|
||||
|
||||
assert embedding.describe_device("auto") == "cuda"
|
||||
@@ -235,13 +235,13 @@ def test_detect_entities_empty_files(tmp_path):
|
||||
f = tmp_path / "empty.txt"
|
||||
f.write_text("")
|
||||
result = detect_entities([f])
|
||||
assert result == {"people": [], "projects": [], "uncertain": []}
|
||||
assert result == {"people": [], "projects": [], "topics": [], "uncertain": []}
|
||||
|
||||
|
||||
def test_detect_entities_handles_missing_file(tmp_path):
|
||||
missing = tmp_path / "nonexistent.txt"
|
||||
result = detect_entities([missing])
|
||||
assert result == {"people": [], "projects": [], "uncertain": []}
|
||||
assert result == {"people": [], "projects": [], "topics": [], "uncertain": []}
|
||||
|
||||
|
||||
def test_detect_entities_respects_max_files(tmp_path):
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mempalace.entity_registry import (
|
||||
COMMON_ENGLISH_WORDS,
|
||||
PERSON_CONTEXT_PATTERNS,
|
||||
@@ -71,6 +73,50 @@ def test_save_creates_file(tmp_path):
|
||||
assert (tmp_path / "entity_registry.json").exists()
|
||||
|
||||
|
||||
def test_save_is_atomic_does_not_leave_tmp(tmp_path):
|
||||
# Atomic write must not leave the .tmp sidecar file after a successful save.
|
||||
registry = EntityRegistry.load(config_dir=tmp_path)
|
||||
registry.save()
|
||||
leftover = list(tmp_path.glob("entity_registry.json.tmp*"))
|
||||
assert leftover == [], f"atomic write leaked tmp file(s): {leftover}"
|
||||
|
||||
|
||||
def test_save_preserves_previous_on_serialization_failure(tmp_path, monkeypatch):
|
||||
# If serialization fails mid-write, the previous registry must remain
|
||||
# intact — this is the whole point of atomic write vs truncating in place.
|
||||
registry = EntityRegistry.load(config_dir=tmp_path)
|
||||
registry.seed(
|
||||
mode="personal",
|
||||
people=[{"name": "Alice", "relationship": "friend", "context": "personal"}],
|
||||
projects=[],
|
||||
)
|
||||
registry.save()
|
||||
target = tmp_path / "entity_registry.json"
|
||||
original = target.read_text(encoding="utf-8")
|
||||
|
||||
# Force os.replace to raise — simulates filesystem full / permission flip
|
||||
# AFTER the temp file is written but BEFORE the rename completes.
|
||||
import os as _os
|
||||
|
||||
real_replace = _os.replace
|
||||
|
||||
def boom(src, dst):
|
||||
raise OSError("simulated rename failure")
|
||||
|
||||
monkeypatch.setattr(_os, "replace", boom)
|
||||
with pytest.raises(OSError):
|
||||
registry.seed(
|
||||
mode="personal",
|
||||
people=[{"name": "Bob", "relationship": "friend", "context": "personal"}],
|
||||
projects=[],
|
||||
)
|
||||
registry.save()
|
||||
|
||||
# Restore os.replace before reading so the assertion can rely on it.
|
||||
monkeypatch.setattr(_os, "replace", real_replace)
|
||||
assert target.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
# ── seed ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -134,3 +134,104 @@ def test_export_empty_palace():
|
||||
assert stats == {"wings": 0, "rooms": 0, "drawers": 0}
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _try_symlink_or_skip(target: str, link: str):
|
||||
"""Create a symlink, skipping the test if the runtime forbids it.
|
||||
|
||||
Windows without Developer Mode/admin and some restricted CI sandboxes
|
||||
refuse os.symlink with OSError or NotImplementedError. The exporter
|
||||
hardening is meaningful only where symlinks can be created at all, so
|
||||
skipping is preferable to a hard failure.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
try:
|
||||
os.symlink(target, link)
|
||||
except (OSError, NotImplementedError) as e:
|
||||
pytest.skip(f"symlink creation not supported in this environment: {e}")
|
||||
|
||||
|
||||
def test_export_refuses_symlinked_output_dir():
|
||||
"""A symlink at the output path must not be followed (defense-in-depth)."""
|
||||
import pytest
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = _setup_palace(tmpdir)
|
||||
decoy_target = os.path.join(tmpdir, "decoy_target")
|
||||
os.makedirs(decoy_target)
|
||||
output_dir = os.path.join(tmpdir, "export")
|
||||
_try_symlink_or_skip(decoy_target, output_dir)
|
||||
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
export_palace(palace_path, output_dir)
|
||||
|
||||
# Decoy target must remain empty — nothing followed the symlink.
|
||||
assert os.listdir(decoy_target) == []
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_export_refuses_symlinked_wing_dir():
|
||||
"""A symlink pre-placed at a wing subdirectory must also be refused."""
|
||||
import pytest
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = _setup_palace(tmpdir)
|
||||
decoy_target = os.path.join(tmpdir, "decoy_target")
|
||||
os.makedirs(decoy_target)
|
||||
output_dir = os.path.join(tmpdir, "export")
|
||||
os.makedirs(output_dir)
|
||||
_try_symlink_or_skip(decoy_target, os.path.join(output_dir, "alpha"))
|
||||
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
export_palace(palace_path, output_dir)
|
||||
|
||||
assert os.listdir(decoy_target) == []
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_export_refuses_symlinked_room_file():
|
||||
"""A symlink pre-placed at a room file path must not be followed."""
|
||||
import pytest
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = _setup_palace(tmpdir)
|
||||
decoy_target = os.path.join(tmpdir, "decoy_target.md")
|
||||
Path(decoy_target).write_text("untouched\n", encoding="utf-8")
|
||||
output_dir = os.path.join(tmpdir, "export")
|
||||
os.makedirs(os.path.join(output_dir, "alpha"))
|
||||
_try_symlink_or_skip(decoy_target, os.path.join(output_dir, "alpha", "backend.md"))
|
||||
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
export_palace(palace_path, output_dir)
|
||||
|
||||
# Decoy file must remain unchanged — open did not follow the symlink.
|
||||
assert Path(decoy_target).read_text(encoding="utf-8") == "untouched\n"
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_export_refuses_symlinked_index_file():
|
||||
"""A symlink pre-placed at output_dir/index.md must not be followed."""
|
||||
import pytest
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = _setup_palace(tmpdir)
|
||||
decoy_target = os.path.join(tmpdir, "decoy_index.md")
|
||||
Path(decoy_target).write_text("untouched\n", encoding="utf-8")
|
||||
output_dir = os.path.join(tmpdir, "export")
|
||||
os.makedirs(output_dir)
|
||||
_try_symlink_or_skip(decoy_target, os.path.join(output_dir, "index.md"))
|
||||
|
||||
with pytest.raises(ValueError, match="symbolic link"):
|
||||
export_palace(palace_path, output_dir)
|
||||
|
||||
assert Path(decoy_target).read_text(encoding="utf-8") == "untouched\n"
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
@@ -286,3 +286,66 @@ class TestCLI:
|
||||
assert "similar_name" in out
|
||||
# Silence unused import warning.
|
||||
_ = (MagicMock, patch, fact_checker)
|
||||
|
||||
def test_reconfigures_stdio_to_utf8_on_windows(self):
|
||||
"""Windows fact_checker --stdin must decode payload as UTF-8.
|
||||
|
||||
Without this, Python defaults stdio to the system ANSI codepage
|
||||
(cp1252/cp1251/cp950), which mojibakes non-ASCII text before
|
||||
pattern parsing sees it.
|
||||
"""
|
||||
import io
|
||||
import sys
|
||||
|
||||
from mempalace.fact_checker import _reconfigure_stdio_utf8_on_windows
|
||||
|
||||
class _ReconfigurableStringIO(io.StringIO):
|
||||
def __init__(self, initial_value=""):
|
||||
super().__init__(initial_value)
|
||||
self.reconfigure_calls = []
|
||||
|
||||
def reconfigure(self, **kwargs):
|
||||
self.reconfigure_calls.append(kwargs)
|
||||
|
||||
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 uses surrogateescape so a stray
|
||||
# malformed byte from a redirected file does not crash the read,
|
||||
# stdout/stderr use replace so an extracted fact carrying a
|
||||
# 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(self):
|
||||
"""Linux/macOS already default to UTF-8 stdio -- helper must not touch streams."""
|
||||
import io
|
||||
import sys
|
||||
|
||||
from mempalace.fact_checker import _reconfigure_stdio_utf8_on_windows
|
||||
|
||||
class _ReconfigurableStringIO(io.StringIO):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.reconfigure_calls = []
|
||||
|
||||
def reconfigure(self, **kwargs):
|
||||
self.reconfigure_calls.append(kwargs)
|
||||
|
||||
stdin = _ReconfigurableStringIO()
|
||||
with (
|
||||
patch.object(sys, "platform", "linux"),
|
||||
patch.object(sys, "stdin", stdin),
|
||||
):
|
||||
_reconfigure_stdio_utf8_on_windows()
|
||||
|
||||
assert stdin.reconfigure_calls == []
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
"""Tests for the #1222 HNSW capacity probe and BM25-only fallback.
|
||||
|
||||
The probe and fallback never load chromadb's HNSW segment, so all of
|
||||
these tests synthesize the on-disk shape directly: a chroma.sqlite3 with
|
||||
the relevant schema rows and an ``index_metadata.pickle`` matching what
|
||||
chromadb 1.5.x writes (``{"id_to_label": {...}, ...}``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pickle
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from mempalace.backends.chroma import (
|
||||
_hnsw_element_count,
|
||||
_vector_segment_id,
|
||||
hnsw_capacity_status,
|
||||
)
|
||||
from mempalace.searcher import _bm25_only_via_sqlite
|
||||
|
||||
|
||||
COLLECTION = "mempalace_drawers"
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _seed_chroma_db(
|
||||
palace: str,
|
||||
sqlite_count: int,
|
||||
segment_id: str,
|
||||
sync_threshold: int | None = None,
|
||||
) -> None:
|
||||
"""Create a minimal chroma.sqlite3 with one collection + VECTOR segment.
|
||||
|
||||
Mirrors the columns the probe queries: ``segments``, ``collections``,
|
||||
``collection_metadata``, ``embeddings``, ``embedding_metadata``.
|
||||
Schema matches chromadb 1.5.x; column types are kept loose because
|
||||
we read with COUNT(*) and SELECT key, *_value rather than driver-
|
||||
specific casts.
|
||||
|
||||
When ``sync_threshold`` is supplied, an ``hnsw:sync_threshold`` row
|
||||
is added to ``collection_metadata`` so the divergence floor scales
|
||||
accordingly. Omit to model an older palace that pre-dates PR #1191.
|
||||
"""
|
||||
db_path = os.path.join(palace, "chroma.sqlite3")
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE collections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE collection_metadata (
|
||||
collection_id TEXT REFERENCES collections(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
str_value TEXT,
|
||||
int_value INTEGER,
|
||||
float_value REAL,
|
||||
bool_value INTEGER,
|
||||
PRIMARY KEY (collection_id, key)
|
||||
);
|
||||
CREATE TABLE segments (
|
||||
id TEXT PRIMARY KEY,
|
||||
collection TEXT NOT NULL,
|
||||
scope TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE embeddings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
segment_id TEXT NOT NULL,
|
||||
embedding_id TEXT NOT NULL,
|
||||
seq_id BLOB NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE embedding_metadata (
|
||||
id INTEGER REFERENCES embeddings(id),
|
||||
key TEXT NOT NULL,
|
||||
string_value TEXT,
|
||||
int_value INTEGER,
|
||||
float_value REAL,
|
||||
bool_value INTEGER,
|
||||
PRIMARY KEY (id, key)
|
||||
);
|
||||
CREATE VIRTUAL TABLE embedding_fulltext_search
|
||||
USING fts5(string_value, tokenize='trigram');
|
||||
"""
|
||||
)
|
||||
col_id = "col-test"
|
||||
meta_seg = "seg-meta"
|
||||
conn.execute("INSERT INTO collections (id, name) VALUES (?, ?)", (col_id, COLLECTION))
|
||||
if sync_threshold is not None:
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_metadata (collection_id, key, int_value)
|
||||
VALUES (?, 'hnsw:sync_threshold', ?)""",
|
||||
(col_id, sync_threshold),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO segments (id, collection, scope) VALUES (?, ?, 'VECTOR')",
|
||||
(segment_id, col_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO segments (id, collection, scope) VALUES (?, ?, 'METADATA')",
|
||||
(meta_seg, col_id),
|
||||
)
|
||||
for i in range(sqlite_count):
|
||||
conn.execute(
|
||||
"""INSERT INTO embeddings (id, segment_id, embedding_id, seq_id)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(i + 1, segment_id, f"d-{i}", b"\x00\x00\x00\x00\x00\x00\x00\x01"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _write_pickle(palace: str, segment_id: str, hnsw_count: int) -> None:
|
||||
"""Write an index_metadata.pickle matching chromadb 1.5.x's shape.
|
||||
|
||||
1.5.x ``__reduce_ex__`` serializes the PersistentData instance as a
|
||||
plain dict; we replicate that so the safe unpickler in
|
||||
``_hnsw_element_count`` reads the same bytes shape it would in
|
||||
production.
|
||||
"""
|
||||
seg_dir = os.path.join(palace, segment_id)
|
||||
os.makedirs(seg_dir, exist_ok=True)
|
||||
pickle_path = os.path.join(seg_dir, "index_metadata.pickle")
|
||||
state = {
|
||||
"dimensionality": 384,
|
||||
"total_elements_added": hnsw_count,
|
||||
"max_seq_id": None,
|
||||
"id_to_label": {f"d-{i}": i for i in range(hnsw_count)},
|
||||
"label_to_id": {i: f"d-{i}" for i in range(hnsw_count)},
|
||||
"id_to_seq_id": {},
|
||||
}
|
||||
with open(pickle_path, "wb") as f:
|
||||
pickle.dump(state, f, pickle.HIGHEST_PROTOCOL)
|
||||
|
||||
|
||||
# ── _vector_segment_id ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_vector_segment_id_returns_uuid(tmp_path):
|
||||
seg = "11111111-2222-3333-4444-555555555555"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=10, segment_id=seg)
|
||||
assert _vector_segment_id(str(tmp_path), COLLECTION) == seg
|
||||
|
||||
|
||||
def test_vector_segment_id_no_palace(tmp_path):
|
||||
assert _vector_segment_id(str(tmp_path), COLLECTION) is None
|
||||
|
||||
|
||||
def test_vector_segment_id_unknown_collection(tmp_path):
|
||||
seg = "11111111-2222-3333-4444-555555555555"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=10, segment_id=seg)
|
||||
assert _vector_segment_id(str(tmp_path), "nope") is None
|
||||
|
||||
|
||||
# ── _hnsw_element_count ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_hnsw_element_count_reads_pickle(tmp_path):
|
||||
seg = "seg-001"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=100, segment_id=seg)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=42)
|
||||
assert _hnsw_element_count(str(tmp_path), seg) == 42
|
||||
|
||||
|
||||
def test_hnsw_element_count_missing_pickle(tmp_path):
|
||||
seg = "seg-001"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=100, segment_id=seg)
|
||||
# Segment dir doesn't even exist — no flush ever happened.
|
||||
assert _hnsw_element_count(str(tmp_path), seg) is None
|
||||
|
||||
|
||||
def test_hnsw_element_count_rejects_arbitrary_class(tmp_path):
|
||||
"""Pickled references to unallowed classes must not deserialize.
|
||||
|
||||
Guards against a tampered ``index_metadata.pickle`` triggering code
|
||||
execution. The unpickler allowlist is the only protection between
|
||||
the file and arbitrary import-time side effects. We hand-craft the
|
||||
pickle bytes (rather than ``pickle.dump`` a local class) because
|
||||
pickle can't serialize locally-defined classes — but the bytes form
|
||||
that names an arbitrary stdlib class is a faithful proxy for the
|
||||
tampered-file threat we want to test.
|
||||
"""
|
||||
import pickle as _pickle
|
||||
|
||||
seg = "seg-evil"
|
||||
seg_dir = tmp_path / seg
|
||||
seg_dir.mkdir()
|
||||
pickle_path = seg_dir / "index_metadata.pickle"
|
||||
# GLOBAL opcode pointing at os.system, then STOP. If the unpickler
|
||||
# didn't enforce the allowlist, find_class would resolve os.system
|
||||
# and pickle would set up the call. The allowlist must reject it
|
||||
# before find_class returns anything.
|
||||
payload = b"c" + b"os\nsystem\n" + _pickle.STOP
|
||||
pickle_path.write_bytes(payload)
|
||||
assert _hnsw_element_count(str(tmp_path), seg) is None
|
||||
|
||||
|
||||
# ── hnsw_capacity_status ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_capacity_status_ok_when_balanced(tmp_path):
|
||||
seg = "seg-001"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=1000, segment_id=seg)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=950)
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
assert info["status"] == "ok"
|
||||
assert info["diverged"] is False
|
||||
assert info["sqlite_count"] == 1000
|
||||
assert info["hnsw_count"] == 950
|
||||
|
||||
|
||||
def test_capacity_status_flags_severe_divergence(tmp_path):
|
||||
"""Reproduces #1222: sqlite has 192k, HNSW frozen at ~16k."""
|
||||
seg = "seg-1222"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=20_000, segment_id=seg)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=2_000)
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
assert info["status"] == "diverged"
|
||||
assert info["diverged"] is True
|
||||
assert info["divergence"] == 18_000
|
||||
assert "repair" in info["message"].lower()
|
||||
|
||||
|
||||
def test_capacity_status_tolerates_flush_lag(tmp_path):
|
||||
"""A few hundred entries behind sqlite is normal post-mine state."""
|
||||
seg = "seg-lag"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=5_000, segment_id=seg)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=4_500)
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
assert info["diverged"] is False
|
||||
assert info["status"] == "ok"
|
||||
|
||||
|
||||
def test_capacity_status_does_not_flag_unflushed_with_large_sqlite(tmp_path):
|
||||
"""No pickle + many sqlite rows is inconclusive, not divergence."""
|
||||
seg = "seg-noflush"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=10_000, segment_id=seg)
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
assert info["diverged"] is False
|
||||
assert info["status"] == "unknown"
|
||||
assert info["divergence"] is None
|
||||
assert info["hnsw_count"] is None
|
||||
assert "capacity unavailable" in info["message"]
|
||||
assert "leaving vector search enabled" in info["message"]
|
||||
|
||||
|
||||
def test_mcp_probe_does_not_disable_vectors_for_unflushed_metadata(tmp_path, monkeypatch):
|
||||
"""The MCP preflight must not route all searches to BM25 on this signal."""
|
||||
from mempalace import mcp_server
|
||||
|
||||
seg = "seg-mcp-noflush"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=10_000, segment_id=seg)
|
||||
|
||||
class _Cfg:
|
||||
palace_path = str(tmp_path)
|
||||
collection_name = "mempalace_drawers"
|
||||
|
||||
monkeypatch.setattr(mcp_server, "_config", _Cfg())
|
||||
monkeypatch.setattr(mcp_server, "_vector_disabled", True)
|
||||
monkeypatch.setattr(mcp_server, "_vector_disabled_reason", "old divergence")
|
||||
|
||||
mcp_server._refresh_vector_disabled_flag()
|
||||
|
||||
assert mcp_server._vector_disabled is False
|
||||
assert mcp_server._vector_disabled_reason == ""
|
||||
assert mcp_server._vector_capacity_status["status"] == "unknown"
|
||||
assert "leaving vector search enabled" in mcp_server._vector_capacity_status["message"]
|
||||
|
||||
|
||||
def test_capacity_status_quiet_for_empty_palace(tmp_path):
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
assert info["diverged"] is False
|
||||
assert info["status"] == "unknown"
|
||||
|
||||
|
||||
# ── Divergence threshold scales with hnsw:sync_threshold ───────────────
|
||||
|
||||
|
||||
def test_capacity_status_tolerates_lag_under_large_sync_threshold(tmp_path):
|
||||
"""Regression for the PR #1191 / PR #1227 conflict.
|
||||
|
||||
Palaces created via mempalace's _HNSW_BLOAT_GUARD (sync_threshold=
|
||||
50_000) naturally accumulate up to ~50K queued entries between
|
||||
flushes. The pickle-vs-sqlite probe must scale its tolerance to
|
||||
``2 × sync_threshold`` so this expected lag is not flagged as
|
||||
corruption — otherwise vector search disables for ~80% of the
|
||||
write cycle on any actively-mined ≥100K palace.
|
||||
"""
|
||||
seg = "seg-bloat-guard"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=100_000, segment_id=seg, sync_threshold=50_000)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=50_000)
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
# 50K divergence is exactly one flush window — well within 2× = 100K.
|
||||
assert info["diverged"] is False, info["message"]
|
||||
assert info["status"] == "ok"
|
||||
assert info["divergence"] == 50_000
|
||||
|
||||
|
||||
def test_capacity_status_still_flags_real_corruption_under_large_sync(tmp_path):
|
||||
"""The dynamic floor must still catch genuine #1222-style corruption.
|
||||
|
||||
sqlite at 200K with HNSW frozen at 16K is the original #1222 shape —
|
||||
any reasonable threshold should flag it, regardless of whether the
|
||||
collection was created with sync_threshold=1000 or 50_000.
|
||||
"""
|
||||
seg = "seg-1222-with-bloat-guard"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=200_000, segment_id=seg, sync_threshold=50_000)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=16_384)
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
# 183,616 missing — far past 2 × 50K = 100K floor and 10% of 200K = 20K.
|
||||
assert info["diverged"] is True
|
||||
assert info["status"] == "diverged"
|
||||
assert info["divergence"] == 183_616
|
||||
|
||||
|
||||
def test_capacity_status_default_threshold_when_no_sync_metadata(tmp_path):
|
||||
"""Older palaces without ``hnsw:sync_threshold`` fall back to 2000 floor.
|
||||
|
||||
Pre-PR-#1191 collections only carry ``hnsw:space``. The probe must
|
||||
use chromadb's own default sync_threshold of 1000 → floor of 2000,
|
||||
matching pre-fix behavior.
|
||||
"""
|
||||
seg = "seg-legacy"
|
||||
# No sync_threshold supplied — collection_metadata stays empty.
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=10_000, segment_id=seg)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=7_500)
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
# 2,500 divergence > max(2000 floor, 10% of 10K = 1000) → DIVERGED
|
||||
assert info["diverged"] is True
|
||||
assert info["divergence"] == 2_500
|
||||
|
||||
|
||||
def test_unflushed_path_also_uses_dynamic_floor(tmp_path):
|
||||
"""The never-flushed branch must scale with sync_threshold too.
|
||||
|
||||
A 30K-drawer collection under sync_threshold=50_000 hasn't reached
|
||||
its first flush yet — pickle is absent. Pre-fix this would flag
|
||||
DIVERGED (30K > fixed 2000 floor); post-fix the 30K stays under
|
||||
the dynamic 100K floor.
|
||||
"""
|
||||
seg = "seg-preflush-large"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=30_000, segment_id=seg, sync_threshold=50_000)
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
assert info["hnsw_count"] is None
|
||||
assert info["diverged"] is False, info["message"]
|
||||
|
||||
|
||||
# ── BM25-only sqlite fallback ─────────────────────────────────────────
|
||||
|
||||
|
||||
def _seed_drawers(palace: str, segment_id: str, drawers: list[tuple[str, dict, str]]) -> None:
|
||||
"""Insert (text, metadata, embedding_id) tuples into a seeded palace.
|
||||
|
||||
Replaces the bare ``embeddings`` rows from ``_seed_chroma_db`` so the
|
||||
sqlite count matches what we insert here.
|
||||
"""
|
||||
db_path = os.path.join(palace, "chroma.sqlite3")
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute("DELETE FROM embeddings")
|
||||
for i, (text, meta, eid) in enumerate(drawers, start=1):
|
||||
conn.execute(
|
||||
"""INSERT INTO embeddings (id, segment_id, embedding_id, seq_id)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(i, segment_id, eid, b"\x00" * 8),
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO embedding_metadata (id, key, string_value)
|
||||
VALUES (?, 'chroma:document', ?)""",
|
||||
(i, text),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO embedding_fulltext_search (rowid, string_value) VALUES (?, ?)",
|
||||
(i, text),
|
||||
)
|
||||
for k, v in meta.items():
|
||||
if isinstance(v, int):
|
||||
conn.execute(
|
||||
"""INSERT INTO embedding_metadata (id, key, int_value)
|
||||
VALUES (?, ?, ?)""",
|
||||
(i, k, v),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO embedding_metadata (id, key, string_value)
|
||||
VALUES (?, ?, ?)""",
|
||||
(i, k, str(v)),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _set_drawer_created_at(palace: str, timestamps: dict[int, str]) -> None:
|
||||
db_path = os.path.join(palace, "chroma.sqlite3")
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
for emb_id, created_at in timestamps.items():
|
||||
conn.execute("UPDATE embeddings SET created_at = ? WHERE id = ?", (created_at, emb_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def palace_with_drawers(tmp_path):
|
||||
seg = "seg-bm25"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=0, segment_id=seg)
|
||||
drawers = [
|
||||
(
|
||||
"ChromaDB segfault on every tool call after HNSW divergence",
|
||||
{"wing": "ops", "room": "incidents", "source_file": "/x/incident.md"},
|
||||
"d-1",
|
||||
),
|
||||
(
|
||||
"Memory palace technique using rooms and drawers for recall",
|
||||
{"wing": "design", "room": "metaphor", "source_file": "/x/design.md"},
|
||||
"d-2",
|
||||
),
|
||||
(
|
||||
"Repair rebuild backs up only the sqlite database",
|
||||
{"wing": "ops", "room": "runbook", "source_file": "/x/repair.md"},
|
||||
"d-3",
|
||||
),
|
||||
]
|
||||
_seed_drawers(str(tmp_path), seg, drawers)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_bm25_fallback_returns_matches(palace_with_drawers):
|
||||
out = _bm25_only_via_sqlite("segfault chromadb", str(palace_with_drawers), n_results=5)
|
||||
assert out["fallback"] == "bm25_only_via_sqlite"
|
||||
assert len(out["results"]) >= 1
|
||||
top = out["results"][0]
|
||||
# The incident drawer is the closest BM25 match for these terms.
|
||||
assert "segfault" in top["text"].lower()
|
||||
assert top["matched_via"] == "bm25_sqlite"
|
||||
# Vector fields are intentionally absent in fallback mode.
|
||||
assert top["similarity"] is None
|
||||
assert top["distance"] is None
|
||||
|
||||
|
||||
def test_bm25_fallback_filters_by_wing(palace_with_drawers):
|
||||
out = _bm25_only_via_sqlite(
|
||||
"memory palace recall", str(palace_with_drawers), wing="design", n_results=5
|
||||
)
|
||||
assert all(r["wing"] == "design" for r in out["results"])
|
||||
|
||||
|
||||
def test_bm25_fallback_applies_wing_before_fts_candidate_limit(tmp_path):
|
||||
seg = "seg-bm25-fts-limit"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=0, segment_id=seg)
|
||||
_seed_drawers(
|
||||
str(tmp_path),
|
||||
seg,
|
||||
[
|
||||
(
|
||||
"shared token outside target wing",
|
||||
{"wing": "ops", "room": "incidents", "source_file": "/x/ops.md"},
|
||||
"d-1",
|
||||
),
|
||||
(
|
||||
"shared token inside target wing",
|
||||
{"wing": "project", "room": "diary", "source_file": "/x/project.md"},
|
||||
"d-2",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
out = _bm25_only_via_sqlite("shared token", str(tmp_path), wing="project", max_candidates=1)
|
||||
|
||||
assert out["total_before_filter"] == 1
|
||||
assert len(out["results"]) == 1
|
||||
assert out["results"][0]["wing"] == "project"
|
||||
|
||||
|
||||
def test_bm25_fallback_applies_room_before_fts_candidate_limit(tmp_path):
|
||||
seg = "seg-bm25-room-limit"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=0, segment_id=seg)
|
||||
_seed_drawers(
|
||||
str(tmp_path),
|
||||
seg,
|
||||
[
|
||||
(
|
||||
"shared token wrong room",
|
||||
{"wing": "project", "room": "scratch", "source_file": "/x/scratch.md"},
|
||||
"d-1",
|
||||
),
|
||||
(
|
||||
"shared token right room",
|
||||
{"wing": "project", "room": "diary", "source_file": "/x/diary.md"},
|
||||
"d-2",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
out = _bm25_only_via_sqlite(
|
||||
"shared token",
|
||||
str(tmp_path),
|
||||
wing="project",
|
||||
room="diary",
|
||||
max_candidates=1,
|
||||
)
|
||||
|
||||
assert out["total_before_filter"] == 1
|
||||
assert len(out["results"]) == 1
|
||||
assert out["results"][0]["wing"] == "project"
|
||||
assert out["results"][0]["room"] == "diary"
|
||||
|
||||
|
||||
def test_bm25_fallback_applies_wing_before_recency_candidate_limit(tmp_path):
|
||||
seg = "seg-bm25-recency-limit"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=0, segment_id=seg)
|
||||
_seed_drawers(
|
||||
str(tmp_path),
|
||||
seg,
|
||||
[
|
||||
(
|
||||
"target drawer for short query",
|
||||
{"wing": "project", "room": "diary", "source_file": "/x/project.md"},
|
||||
"d-1",
|
||||
),
|
||||
(
|
||||
"newer drawer outside target wing",
|
||||
{"wing": "ops", "room": "incidents", "source_file": "/x/ops.md"},
|
||||
"d-2",
|
||||
),
|
||||
],
|
||||
)
|
||||
_set_drawer_created_at(
|
||||
str(tmp_path),
|
||||
{
|
||||
1: "2026-01-01 00:00:00",
|
||||
2: "2026-02-01 00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
out = _bm25_only_via_sqlite("a", str(tmp_path), wing="project", max_candidates=1)
|
||||
|
||||
assert out["total_before_filter"] == 1
|
||||
assert len(out["results"]) == 1
|
||||
assert out["results"][0]["wing"] == "project"
|
||||
|
||||
|
||||
def test_bm25_fallback_returns_empty_when_filtered_wing_has_no_candidates(tmp_path):
|
||||
seg = "seg-bm25-empty-filter"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=0, segment_id=seg)
|
||||
_seed_drawers(
|
||||
str(tmp_path),
|
||||
seg,
|
||||
[
|
||||
(
|
||||
"shared token outside target wing",
|
||||
{"wing": "ops", "room": "incidents", "source_file": "/x/ops.md"},
|
||||
"d-1",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
out = _bm25_only_via_sqlite("shared token", str(tmp_path), wing="project", max_candidates=1)
|
||||
|
||||
assert out["total_before_filter"] == 0
|
||||
assert out["results"] == []
|
||||
|
||||
|
||||
def test_bm25_fallback_no_palace(tmp_path):
|
||||
out = _bm25_only_via_sqlite("anything", str(tmp_path))
|
||||
assert "error" in out
|
||||
|
||||
|
||||
def test_bm25_fallback_handles_short_query(palace_with_drawers):
|
||||
"""Single-character tokens are unmatchable in trigram FTS5 — must
|
||||
not crash, must fall back to the recency window."""
|
||||
out = _bm25_only_via_sqlite("a", str(palace_with_drawers), n_results=5)
|
||||
# Falls back to recency window; returns whatever it can rank.
|
||||
assert out["fallback"] == "bm25_only_via_sqlite"
|
||||
assert isinstance(out["results"], list)
|
||||
|
||||
|
||||
# ── repair.status CLI command ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_repair_status_reports_diverged(tmp_path, capsys):
|
||||
"""The status command prints DIVERGED and recommends rebuild."""
|
||||
from mempalace.repair import status as repair_status
|
||||
|
||||
seg = "seg-status"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=20_000, segment_id=seg)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=2_000)
|
||||
out = repair_status(palace_path=str(tmp_path))
|
||||
captured = capsys.readouterr().out
|
||||
assert "DIVERGED" in captured
|
||||
assert "mempalace repair`" in captured
|
||||
assert out["drawers"]["diverged"] is True
|
||||
|
||||
|
||||
def test_repair_status_quiet_on_healthy_palace(tmp_path, capsys):
|
||||
from mempalace.repair import status as repair_status
|
||||
|
||||
seg = "seg-status-ok"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=500, segment_id=seg)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=480)
|
||||
repair_status(palace_path=str(tmp_path))
|
||||
captured = capsys.readouterr().out
|
||||
assert "DIVERGED" not in captured
|
||||
assert "Recommended" not in captured
|
||||
|
||||
|
||||
# ── tool_status sqlite fallback (#1222 short-circuit) ─────────────────
|
||||
|
||||
|
||||
def test_tool_status_via_sqlite_returns_breakdown(palace_with_drawers, monkeypatch):
|
||||
"""When _vector_disabled is set, tool_status reads counts from sqlite
|
||||
instead of opening a chromadb client."""
|
||||
from mempalace import mcp_server
|
||||
|
||||
# _config.palace_path is a read-only property; swap the whole object
|
||||
# for a tiny stand-in so we don't have to monkey with the real
|
||||
# MempalaceConfig.
|
||||
class _Cfg:
|
||||
palace_path = str(palace_with_drawers)
|
||||
collection_name = "mempalace_drawers"
|
||||
|
||||
monkeypatch.setattr(mcp_server, "_config", _Cfg())
|
||||
monkeypatch.setattr(mcp_server, "_vector_disabled", True)
|
||||
monkeypatch.setattr(mcp_server, "_vector_disabled_reason", "test divergence")
|
||||
|
||||
out = mcp_server._tool_status_via_sqlite()
|
||||
assert out["vector_disabled"] is True
|
||||
assert out["vector_disabled_reason"] == "test divergence"
|
||||
assert out["total_drawers"] == 3
|
||||
# Wing breakdown comes from the seeded palace_with_drawers fixture:
|
||||
# ops×2 (incident + repair runbook), design×1 (metaphor).
|
||||
assert out["wings"].get("ops") == 2
|
||||
assert out["wings"].get("design") == 1
|
||||
@@ -0,0 +1,113 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from mempalace.backends.chroma import (
|
||||
_HNSW_LINK_TO_DATA_MAX_RATIO,
|
||||
_hnsw_link_to_data_ratio,
|
||||
_segment_appears_healthy,
|
||||
quarantine_stale_hnsw,
|
||||
)
|
||||
|
||||
|
||||
def _write_segment(
|
||||
seg_dir: Path,
|
||||
*,
|
||||
data_size: int = 100,
|
||||
link_size: int = 100,
|
||||
write_metadata: bool = True,
|
||||
) -> None:
|
||||
seg_dir.mkdir(parents=True, exist_ok=True)
|
||||
(seg_dir / "data_level0.bin").write_bytes(b"\0" * data_size)
|
||||
(seg_dir / "link_lists.bin").write_bytes(b"\0" * link_size)
|
||||
|
||||
if write_metadata:
|
||||
# Enough bytes to pass the existing pickle envelope sniff-test:
|
||||
# starts with pickle protocol marker 0x80 and ends with STOP 0x2e.
|
||||
(seg_dir / "index_metadata.pickle").write_bytes(b"\x80" + b"x" * 16 + b"\x2e")
|
||||
|
||||
|
||||
def test_hnsw_link_to_data_ratio_reports_payload_size_ratio(tmp_path):
|
||||
seg_dir = tmp_path / "11111111-2222-3333-4444-555555555555"
|
||||
_write_segment(seg_dir, data_size=100, link_size=250)
|
||||
|
||||
assert _hnsw_link_to_data_ratio(str(seg_dir)) == 2.5
|
||||
|
||||
|
||||
def test_segment_health_rejects_exploded_link_lists_even_with_valid_pickle(tmp_path):
|
||||
seg_dir = tmp_path / "11111111-2222-3333-4444-555555555555"
|
||||
_write_segment(
|
||||
seg_dir,
|
||||
data_size=100,
|
||||
link_size=int(100 * (_HNSW_LINK_TO_DATA_MAX_RATIO + 1)),
|
||||
write_metadata=True,
|
||||
)
|
||||
|
||||
assert not _segment_appears_healthy(str(seg_dir))
|
||||
|
||||
|
||||
def test_segment_health_keeps_reasonable_payload_with_valid_pickle(tmp_path):
|
||||
seg_dir = tmp_path / "11111111-2222-3333-4444-555555555555"
|
||||
_write_segment(
|
||||
seg_dir,
|
||||
data_size=100,
|
||||
link_size=int(100 * _HNSW_LINK_TO_DATA_MAX_RATIO),
|
||||
write_metadata=True,
|
||||
)
|
||||
|
||||
assert _segment_appears_healthy(str(seg_dir))
|
||||
|
||||
|
||||
def test_quarantine_catches_link_bloat_without_mtime_drift(tmp_path):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
|
||||
db_path = palace / "chroma.sqlite3"
|
||||
db_path.write_text("sqlite placeholder")
|
||||
|
||||
seg_dir = palace / "11111111-2222-3333-4444-555555555555"
|
||||
_write_segment(
|
||||
seg_dir,
|
||||
data_size=100,
|
||||
link_size=int(100 * (_HNSW_LINK_TO_DATA_MAX_RATIO + 1)),
|
||||
write_metadata=True,
|
||||
)
|
||||
|
||||
# Make sqlite and HNSW mtimes identical. The old mtime-only gate would
|
||||
# skip this segment even though the payload is structurally corrupt.
|
||||
same_time = 1_700_000_000
|
||||
os.utime(db_path, (same_time, same_time))
|
||||
os.utime(seg_dir / "data_level0.bin", (same_time, same_time))
|
||||
|
||||
moved = quarantine_stale_hnsw(str(palace), stale_seconds=999_999)
|
||||
|
||||
assert len(moved) == 1
|
||||
assert not seg_dir.exists()
|
||||
|
||||
moved_path = Path(moved[0])
|
||||
assert moved_path.exists()
|
||||
assert moved_path.name.startswith("11111111-2222-3333-4444-555555555555.drift-")
|
||||
|
||||
|
||||
def test_quarantine_leaves_reasonable_payload_in_place(tmp_path):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
|
||||
db_path = palace / "chroma.sqlite3"
|
||||
db_path.write_text("sqlite placeholder")
|
||||
|
||||
seg_dir = palace / "11111111-2222-3333-4444-555555555555"
|
||||
_write_segment(
|
||||
seg_dir,
|
||||
data_size=100,
|
||||
link_size=100,
|
||||
write_metadata=True,
|
||||
)
|
||||
|
||||
same_time = 1_700_000_000
|
||||
os.utime(db_path, (same_time, same_time))
|
||||
os.utime(seg_dir / "data_level0.bin", (same_time, same_time))
|
||||
|
||||
moved = quarantine_stale_hnsw(str(palace), stale_seconds=999_999)
|
||||
|
||||
assert moved == []
|
||||
assert seg_dir.exists()
|
||||
+532
-55
@@ -3,20 +3,23 @@ import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import mempalace.hooks_cli as hooks_cli_mod
|
||||
from mempalace.hooks_cli import (
|
||||
SAVE_INTERVAL,
|
||||
_count_human_messages,
|
||||
_extract_recent_messages,
|
||||
_get_mine_dir,
|
||||
_get_mine_targets,
|
||||
_log,
|
||||
_maybe_auto_ingest,
|
||||
_mempalace_python,
|
||||
_mine_already_running,
|
||||
_mine_sync,
|
||||
_parse_harness_input,
|
||||
_sanitize_session_id,
|
||||
_validate_transcript_path,
|
||||
@@ -434,27 +437,105 @@ def test_maybe_auto_ingest_no_env(tmp_path):
|
||||
|
||||
|
||||
def test_maybe_auto_ingest_with_env(tmp_path):
|
||||
"""With MEMPAL_DIR set to a valid directory, spawns subprocess."""
|
||||
"""With MEMPAL_DIR set, spawns mine in projects mode against that dir."""
|
||||
mempal_dir = tmp_path / "project"
|
||||
mempal_dir.mkdir()
|
||||
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", tmp_path / "mine_pids"):
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
_maybe_auto_ingest()
|
||||
mock_popen.assert_called_once()
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
assert "mine" in cmd
|
||||
assert str(mempal_dir.resolve()) in cmd
|
||||
assert cmd[cmd.index("--mode") + 1] == "projects"
|
||||
|
||||
|
||||
def test_maybe_auto_ingest_with_transcript(tmp_path):
|
||||
"""Falls back to transcript directory when MEMPAL_DIR is not set."""
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
def test_maybe_auto_ingest_uses_mempalace_python(tmp_path):
|
||||
"""Spawned mine command uses _mempalace_python(), not bare sys.executable.
|
||||
|
||||
Hook subprocesses inherit the harness PATH which on GUI-launched
|
||||
Claude Code may resolve to a system Python without chromadb. The
|
||||
interpreter used here must be the same one the hook itself runs
|
||||
under (typically the venv that owns mempalace).
|
||||
"""
|
||||
mempal_dir = tmp_path / "project"
|
||||
mempal_dir.mkdir()
|
||||
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", tmp_path / "mine_pids"):
|
||||
with patch(
|
||||
"mempalace.hooks_cli._mempalace_python", return_value="/fake/venv/python"
|
||||
):
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
_maybe_auto_ingest()
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
assert cmd[0] == "/fake/venv/python"
|
||||
|
||||
|
||||
def test_mine_sync_with_env_uses_projects_mode(tmp_path):
|
||||
"""Precompact sync path uses projects mode when MEMPAL_DIR is set."""
|
||||
mempal_dir = tmp_path / "project"
|
||||
mempal_dir.mkdir()
|
||||
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli.subprocess.run") as mock_run:
|
||||
_mine_sync()
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[cmd.index("--mode") + 1] == "projects"
|
||||
|
||||
|
||||
def test_mine_sync_uses_mempalace_python(tmp_path):
|
||||
"""Sync mine command uses _mempalace_python(), not bare sys.executable."""
|
||||
mempal_dir = tmp_path / "project"
|
||||
mempal_dir.mkdir()
|
||||
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._mempalace_python", return_value="/fake/venv/python"):
|
||||
with patch("mempalace.hooks_cli.subprocess.run") as mock_run:
|
||||
_mine_sync()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[0] == "/fake/venv/python"
|
||||
|
||||
|
||||
def test_maybe_auto_ingest_ignores_transcript_arg_path(tmp_path):
|
||||
"""_maybe_auto_ingest does NOT mine the transcript directory.
|
||||
|
||||
Transcript convos are handled by _ingest_transcript (called separately
|
||||
in hook handlers). _maybe_auto_ingest only handles MEMPAL_DIR — even
|
||||
when invoked in a context where a transcript is also being processed,
|
||||
no second spawn for the transcript dir should appear here.
|
||||
"""
|
||||
convo_dir = tmp_path / "convos"
|
||||
convo_dir.mkdir()
|
||||
transcript = convo_dir / "session.jsonl"
|
||||
transcript.write_text("")
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", tmp_path / "mine_pids"):
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
_maybe_auto_ingest(str(transcript))
|
||||
mock_popen.assert_called_once()
|
||||
_maybe_auto_ingest()
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
def test_mine_sync_ignores_transcript(tmp_path):
|
||||
"""_mine_sync does not run a convos mine for the transcript dir.
|
||||
|
||||
The precompact transcript ingest is the responsibility of
|
||||
_ingest_transcript; routing it through _mine_sync would stack a
|
||||
second 60s timeout against the harness 30s ceiling.
|
||||
"""
|
||||
convo_dir = tmp_path / "convos"
|
||||
convo_dir.mkdir()
|
||||
transcript = convo_dir / "session.jsonl"
|
||||
transcript.write_text("")
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli.subprocess.run") as mock_run:
|
||||
_mine_sync()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_maybe_auto_ingest_oserror(tmp_path):
|
||||
@@ -463,81 +544,360 @@ def test_maybe_auto_ingest_oserror(tmp_path):
|
||||
mempal_dir.mkdir()
|
||||
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", tmp_path / "mine_pids"):
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen", side_effect=OSError("fail")):
|
||||
_maybe_auto_ingest() # should not raise
|
||||
|
||||
|
||||
def test_maybe_auto_ingest_skips_when_mine_running(tmp_path):
|
||||
"""Does not spawn a new mine process if one is already running."""
|
||||
"""Does not spawn a new mine process if a mine for the same target is alive."""
|
||||
mempal_dir = tmp_path / "project"
|
||||
mempal_dir.mkdir()
|
||||
pid_dir = tmp_path / "mine_pids"
|
||||
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._mine_already_running", return_value=True):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
# Pre-populate the per-target slot with a live PID (our own).
|
||||
from mempalace.hooks_cli import _pid_file_for_cmd
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"mempalace",
|
||||
"mine",
|
||||
str(mempal_dir.resolve()),
|
||||
"--mode",
|
||||
"projects",
|
||||
]
|
||||
pid_file = _pid_file_for_cmd(cmd)
|
||||
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_file.write_text(str(os.getpid()))
|
||||
with patch("mempalace.hooks_cli._mempalace_python", return_value=sys.executable):
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
_maybe_auto_ingest()
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
# --- _detached_popen_kwargs ---
|
||||
|
||||
|
||||
def test_detached_popen_kwargs_posix(monkeypatch):
|
||||
"""On POSIX, kwargs include start_new_session so the child detaches."""
|
||||
from mempalace.hooks_cli import _detached_popen_kwargs
|
||||
|
||||
monkeypatch.setattr("mempalace.hooks_cli.os.name", "posix")
|
||||
kwargs = _detached_popen_kwargs()
|
||||
assert kwargs.get("start_new_session") is True
|
||||
assert kwargs.get("stdin") is subprocess.DEVNULL
|
||||
assert kwargs.get("close_fds") is True
|
||||
assert "creationflags" not in kwargs
|
||||
|
||||
|
||||
def test_detached_popen_kwargs_windows(monkeypatch):
|
||||
"""On Windows, kwargs include creationflags that fully detach the child.
|
||||
|
||||
Without these, the parent hook hangs at session end on Windows because
|
||||
the child's inherited stdout/stderr handles keep the parent's exit
|
||||
blocked (#1268 root cause for the Python hook path).
|
||||
"""
|
||||
from mempalace.hooks_cli import _detached_popen_kwargs
|
||||
|
||||
monkeypatch.setattr("mempalace.hooks_cli.os.name", "nt")
|
||||
# Simulate Windows-only Popen flag constants. Patch on the imported
|
||||
# subprocess module within hooks_cli so getattr() picks them up.
|
||||
monkeypatch.setattr(
|
||||
"mempalace.hooks_cli.subprocess.DETACHED_PROCESS", 0x00000008, raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"mempalace.hooks_cli.subprocess.CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False
|
||||
)
|
||||
kwargs = _detached_popen_kwargs()
|
||||
assert kwargs.get("stdin") is subprocess.DEVNULL
|
||||
assert kwargs.get("close_fds") is True
|
||||
flags = kwargs.get("creationflags", 0)
|
||||
assert flags & 0x00000008, "DETACHED_PROCESS must be set"
|
||||
assert flags & 0x00000200, "CREATE_NEW_PROCESS_GROUP must be set"
|
||||
|
||||
|
||||
def test_spawn_mine_uses_detached_kwargs(tmp_path):
|
||||
"""_spawn_mine forwards detached kwargs so the hook can exit cleanly."""
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", tmp_path / "mine_pids"):
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
mock_popen.return_value.pid = 9999
|
||||
from mempalace.hooks_cli import _spawn_mine
|
||||
|
||||
_spawn_mine(["mempalace", "mine", "/tmp/x"])
|
||||
kwargs = mock_popen.call_args.kwargs
|
||||
# The exact key set varies by platform; assert on the
|
||||
# shared invariants that protect against the Windows hang.
|
||||
assert kwargs.get("stdin") is subprocess.DEVNULL
|
||||
assert kwargs.get("close_fds") is True
|
||||
|
||||
|
||||
def test_spawn_mine_skips_when_target_running(tmp_path):
|
||||
"""A second spawn for the same cmd target while the first is alive must skip."""
|
||||
pid_dir = tmp_path / "mine_pids"
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
from mempalace.hooks_cli import _pid_file_for_cmd, _spawn_mine
|
||||
|
||||
cmd = ["mempalace", "mine", "/tmp/proj", "--mode", "projects"]
|
||||
pid_file = _pid_file_for_cmd(cmd)
|
||||
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_file.write_text(str(os.getpid())) # live PID
|
||||
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
_spawn_mine(cmd)
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
def test_spawn_mine_distinct_targets_dont_block_each_other(tmp_path):
|
||||
"""Two spawn calls for *different* targets both proceed."""
|
||||
pid_dir = tmp_path / "mine_pids"
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
from mempalace.hooks_cli import _spawn_mine
|
||||
|
||||
mock_popen.return_value.pid = 1111
|
||||
_spawn_mine(["mempalace", "mine", "/tmp/a", "--mode", "projects"])
|
||||
mock_popen.return_value.pid = 2222
|
||||
_spawn_mine(["mempalace", "mine", "/tmp/b", "--mode", "projects"])
|
||||
assert mock_popen.call_count == 2
|
||||
|
||||
|
||||
def test_spawn_mine_reclaims_stale_slot(tmp_path):
|
||||
"""A slot pointing at a dead PID is reclaimed silently."""
|
||||
pid_dir = tmp_path / "mine_pids"
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
from mempalace.hooks_cli import _pid_file_for_cmd, _spawn_mine
|
||||
|
||||
cmd = ["mempalace", "mine", "/tmp/proj", "--mode", "projects"]
|
||||
pid_file = _pid_file_for_cmd(cmd)
|
||||
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_file.write_text("999999999") # dead PID
|
||||
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
mock_popen.return_value.pid = 4242
|
||||
_spawn_mine(cmd)
|
||||
mock_popen.assert_called_once()
|
||||
# New PID is recorded in the reclaimed slot.
|
||||
assert pid_file.read_text().strip() == "4242"
|
||||
|
||||
|
||||
def test_spawn_mine_releases_slot_on_oserror(tmp_path):
|
||||
"""If Popen raises OSError, the claimed slot must be released."""
|
||||
pid_dir = tmp_path / "mine_pids"
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
from mempalace.hooks_cli import _pid_file_for_cmd, _spawn_mine
|
||||
|
||||
cmd = ["mempalace", "mine", "/tmp/proj", "--mode", "projects"]
|
||||
pid_file = _pid_file_for_cmd(cmd)
|
||||
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen", side_effect=OSError("spawn fail")):
|
||||
with pytest.raises(OSError):
|
||||
_spawn_mine(cmd)
|
||||
assert (
|
||||
not pid_file.exists()
|
||||
), "slot must be released so the next hook fire isn't permanently blocked"
|
||||
|
||||
|
||||
def test_spawn_mine_passes_pid_file_env_var(tmp_path):
|
||||
"""The child inherits MEMPALACE_MINE_PID_FILE so its cleanup hook can find the slot."""
|
||||
pid_dir = tmp_path / "mine_pids"
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
mock_popen.return_value.pid = 5555
|
||||
from mempalace.hooks_cli import _pid_file_for_cmd, _spawn_mine
|
||||
|
||||
cmd = ["mempalace", "mine", "/tmp/x", "--mode", "projects"]
|
||||
_spawn_mine(cmd)
|
||||
child_env = mock_popen.call_args.kwargs.get("env", {})
|
||||
expected = str(_pid_file_for_cmd(cmd))
|
||||
assert child_env.get("MEMPALACE_MINE_PID_FILE") == expected
|
||||
|
||||
|
||||
def test_ingest_transcript_uses_detached_kwargs(tmp_path):
|
||||
"""_ingest_transcript spawns the convos mine with detach kwargs."""
|
||||
transcript = tmp_path / "session.jsonl"
|
||||
transcript.write_text("x" * 200) # > 100 byte gate
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", tmp_path / "mine_pids"):
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
from mempalace.hooks_cli import _ingest_transcript
|
||||
|
||||
_ingest_transcript(str(transcript))
|
||||
assert mock_popen.called
|
||||
kwargs = mock_popen.call_args.kwargs
|
||||
assert kwargs.get("stdin") is subprocess.DEVNULL
|
||||
assert kwargs.get("close_fds") is True
|
||||
|
||||
|
||||
def test_ingest_transcript_skips_when_target_running(tmp_path):
|
||||
"""Repeated transcript ingests for the same transcript should dedup."""
|
||||
transcript = tmp_path / "session.jsonl"
|
||||
transcript.write_text("x" * 200)
|
||||
pid_dir = tmp_path / "mine_pids"
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
with patch("mempalace.hooks_cli._mempalace_python", return_value=sys.executable):
|
||||
from mempalace.hooks_cli import _ingest_transcript, _pid_file_for_cmd
|
||||
|
||||
expected_cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"mempalace",
|
||||
"mine",
|
||||
str(transcript.parent),
|
||||
"--mode",
|
||||
"convos",
|
||||
"--wing",
|
||||
"sessions",
|
||||
]
|
||||
pid_file = _pid_file_for_cmd(expected_cmd)
|
||||
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_file.write_text(str(os.getpid())) # live target
|
||||
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
_maybe_auto_ingest()
|
||||
_ingest_transcript(str(transcript))
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
|
||||
# --- _mine_already_running ---
|
||||
|
||||
|
||||
def _seed_slot(pid_dir, cmd, body: str):
|
||||
"""Write ``body`` into the per-target slot for ``cmd`` under ``pid_dir``."""
|
||||
from mempalace.hooks_cli import _pid_file_for_cmd
|
||||
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
slot = _pid_file_for_cmd(cmd)
|
||||
slot.parent.mkdir(parents=True, exist_ok=True)
|
||||
slot.write_text(body)
|
||||
return slot
|
||||
|
||||
|
||||
def test_mine_already_running_no_file(tmp_path):
|
||||
"""Returns False when no PID file exists."""
|
||||
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
|
||||
assert _mine_already_running() is False
|
||||
"""Returns False when no per-target slot exists."""
|
||||
cmd = ["mempalace", "mine", "/tmp/x", "--mode", "projects"]
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", tmp_path / "mine_pids"):
|
||||
assert _mine_already_running(cmd) is False
|
||||
|
||||
|
||||
def test_mine_already_running_dead_pid(tmp_path):
|
||||
"""Returns False when PID file contains a PID that no longer exists."""
|
||||
pid_file = tmp_path / "mine.pid"
|
||||
pid_file.write_text("999999999") # almost certainly not a real PID
|
||||
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
|
||||
assert _mine_already_running() is False
|
||||
"""Returns False when the slot's recorded PID is no longer alive."""
|
||||
pid_dir = tmp_path / "mine_pids"
|
||||
cmd = ["mempalace", "mine", "/tmp/x", "--mode", "projects"]
|
||||
_seed_slot(pid_dir, cmd, "999999999") # almost certainly not a real PID
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
assert _mine_already_running(cmd) is False
|
||||
|
||||
|
||||
def test_mine_already_running_live_pid(tmp_path):
|
||||
"""Returns True when PID file contains the current process's own PID."""
|
||||
pid_file = tmp_path / "mine.pid"
|
||||
pid_file.write_text(str(os.getpid())) # current process is definitely alive
|
||||
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
|
||||
assert _mine_already_running() is True
|
||||
"""Returns True when the slot's recorded PID is alive."""
|
||||
pid_dir = tmp_path / "mine_pids"
|
||||
cmd = ["mempalace", "mine", "/tmp/x", "--mode", "projects"]
|
||||
_seed_slot(pid_dir, cmd, str(os.getpid())) # current process is alive
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
assert _mine_already_running(cmd) is True
|
||||
|
||||
|
||||
def test_mine_already_running_corrupt_file(tmp_path):
|
||||
"""Returns False when PID file contains non-integer content."""
|
||||
pid_file = tmp_path / "mine.pid"
|
||||
pid_file.write_text("not-a-pid")
|
||||
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
|
||||
assert _mine_already_running() is False
|
||||
"""Returns False when the slot contains non-integer content."""
|
||||
pid_dir = tmp_path / "mine_pids"
|
||||
cmd = ["mempalace", "mine", "/tmp/x", "--mode", "projects"]
|
||||
_seed_slot(pid_dir, cmd, "not-a-pid")
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
assert _mine_already_running(cmd) is False
|
||||
|
||||
|
||||
# --- _get_mine_dir ---
|
||||
def test_mine_already_running_distinct_cmds_independent(tmp_path):
|
||||
"""Slots are keyed per cmd; an alive entry for cmd A doesn't shadow cmd B."""
|
||||
pid_dir = tmp_path / "mine_pids"
|
||||
cmd_a = ["mempalace", "mine", "/tmp/a", "--mode", "projects"]
|
||||
cmd_b = ["mempalace", "mine", "/tmp/b", "--mode", "projects"]
|
||||
_seed_slot(pid_dir, cmd_a, str(os.getpid()))
|
||||
with patch("mempalace.hooks_cli._MINE_PID_DIR", pid_dir):
|
||||
assert _mine_already_running(cmd_a) is True
|
||||
assert _mine_already_running(cmd_b) is False
|
||||
|
||||
|
||||
def test_get_mine_dir_mempal_dir(tmp_path):
|
||||
"""MEMPAL_DIR takes priority over transcript_path."""
|
||||
# --- _get_mine_targets ---
|
||||
|
||||
|
||||
def test_get_mine_targets_mempal_dir_only(tmp_path):
|
||||
"""MEMPAL_DIR alone yields a single projects target, expanded/resolved."""
|
||||
mempal_dir = tmp_path / "project"
|
||||
mempal_dir.mkdir()
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
transcript.write_text("")
|
||||
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
|
||||
assert _get_mine_dir(str(transcript)) == str(mempal_dir)
|
||||
targets = _get_mine_targets()
|
||||
assert len(targets) == 1
|
||||
assert Path(targets[0][0]).resolve() == mempal_dir.resolve()
|
||||
assert targets[0][1] == "projects"
|
||||
|
||||
|
||||
def test_get_mine_dir_transcript_fallback(tmp_path):
|
||||
"""Falls back to transcript parent dir when MEMPAL_DIR is not set."""
|
||||
def test_get_mine_targets_mempal_dir_tilde(tmp_path):
|
||||
"""MEMPAL_DIR with a tilde prefix is expanded correctly."""
|
||||
mempal_dir = tmp_path / "project"
|
||||
mempal_dir.mkdir()
|
||||
home = Path.home()
|
||||
try:
|
||||
rel = mempal_dir.relative_to(home)
|
||||
except ValueError:
|
||||
pytest.skip("tmp_path is not under home, cannot build ~-relative path")
|
||||
tilde_path = "~/" + str(rel)
|
||||
with patch.dict("os.environ", {"MEMPAL_DIR": tilde_path}):
|
||||
targets = _get_mine_targets()
|
||||
assert len(targets) == 1
|
||||
assert Path(targets[0][0]).resolve() == mempal_dir.resolve()
|
||||
assert targets[0][1] == "projects"
|
||||
|
||||
|
||||
def test_get_mine_targets_no_transcript_target(tmp_path):
|
||||
"""_get_mine_targets does not emit a convos target for the transcript path.
|
||||
|
||||
Transcript ingestion is owned by _ingest_transcript; emitting it
|
||||
here too would double-mine the same JSONL into a different wing on
|
||||
every hook fire (#1231 review).
|
||||
"""
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
transcript.write_text("")
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
assert _get_mine_dir(str(transcript)) == str(tmp_path)
|
||||
targets = _get_mine_targets()
|
||||
assert targets == []
|
||||
|
||||
|
||||
def test_get_mine_dir_empty():
|
||||
"""Returns empty string when nothing is available."""
|
||||
def test_get_mine_targets_only_returns_mempal_dir(tmp_path):
|
||||
"""When MEMPAL_DIR is set, exactly one projects target — never a convos target."""
|
||||
mempal_dir = tmp_path / "project"
|
||||
mempal_dir.mkdir()
|
||||
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
|
||||
targets = _get_mine_targets()
|
||||
assert len(targets) == 1
|
||||
assert targets[0][1] == "projects"
|
||||
|
||||
|
||||
def test_validate_transcript_path_traversal_rejected_jsonl(tmp_path):
|
||||
"""Path traversal is rejected even when the path has a .jsonl suffix.
|
||||
|
||||
The pre-fix test used "../../etc/passwd" which lacks an extension and
|
||||
so was rejected by the suffix gate before the traversal check ever
|
||||
fired (Copilot review on #1231). Use a .jsonl path with `..`
|
||||
segments to exercise the traversal guard specifically.
|
||||
"""
|
||||
assert _validate_transcript_path("../t.jsonl") is None
|
||||
assert _validate_transcript_path("a/../b.jsonl") is None
|
||||
assert _validate_transcript_path("/tmp/../etc/t.jsonl") is None
|
||||
|
||||
|
||||
def test_get_mine_targets_empty():
|
||||
"""Returns empty list when MEMPAL_DIR is unset or invalid."""
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
assert _get_mine_dir("") == ""
|
||||
assert _get_mine_targets() == []
|
||||
|
||||
|
||||
# --- _parse_harness_input ---
|
||||
@@ -657,21 +1017,33 @@ def test_precompact_with_timeout(tmp_path):
|
||||
|
||||
|
||||
def test_precompact_mines_transcript_dir(tmp_path, monkeypatch):
|
||||
"""Precompact mines transcript directory when no MEMPAL_DIR."""
|
||||
"""Precompact ingests the active transcript via _ingest_transcript.
|
||||
|
||||
With no MEMPAL_DIR, _mine_sync is a no-op; the transcript ingest is
|
||||
the only mining that should fire, and it goes through Popen
|
||||
(background) inside _ingest_transcript. Pre-#1231-review this test
|
||||
asserted against subprocess.run, which corresponded to the
|
||||
duplicate-mine path that has now been removed.
|
||||
"""
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
transcript.write_text("")
|
||||
# _ingest_transcript skips files smaller than 100 bytes, so pad it.
|
||||
transcript.write_text("x" * 200)
|
||||
monkeypatch.delenv("MEMPAL_DIR", raising=False)
|
||||
with patch("mempalace.hooks_cli.subprocess.run") as mock_run:
|
||||
result = _capture_hook_output(
|
||||
hook_precompact,
|
||||
{"session_id": "test", "transcript_path": str(transcript)},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
with patch("mempalace.hooks_cli.subprocess.run") as mock_run:
|
||||
result = _capture_hook_output(
|
||||
hook_precompact,
|
||||
{"session_id": "test", "transcript_path": str(transcript)},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
assert result == {}
|
||||
mock_run.assert_called_once()
|
||||
# Verify mine dir is the transcript's parent
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert str(tmp_path) in call_args[-1]
|
||||
mock_run.assert_not_called()
|
||||
mock_popen.assert_called_once()
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
# Mines the transcript's parent dir as convos, into wing "sessions".
|
||||
assert str(tmp_path) in cmd
|
||||
assert cmd[cmd.index("--mode") + 1] == "convos"
|
||||
assert cmd[cmd.index("--wing") + 1] == "sessions"
|
||||
|
||||
|
||||
# --- run_hook ---
|
||||
@@ -821,3 +1193,108 @@ def test_stop_hook_rejects_injected_stop_hook_active(tmp_path):
|
||||
# The injected value is not "true"/"1"/"yes", so the hook should NOT pass through.
|
||||
# Save must have been attempted.
|
||||
assert mock_save.called
|
||||
|
||||
|
||||
# --- Absent palace root: hooks must not recreate ~/.mempalace ---
|
||||
#
|
||||
# When the user removes ~/.mempalace (e.g. `rm -rf`), that is the strongest
|
||||
# possible "do not auto-capture" signal. Hooks must short-circuit BEFORE
|
||||
# touching disk — including before the log-line that previously triggered
|
||||
# STATE_DIR.mkdir() on its own.
|
||||
|
||||
|
||||
def _redirect_palace_root(monkeypatch, tmp_path):
|
||||
"""Point PALACE_ROOT and STATE_DIR at a tmp location that does NOT exist."""
|
||||
fake_root = tmp_path / "absent-mempalace"
|
||||
monkeypatch.setattr(hooks_cli_mod, "PALACE_ROOT", fake_root)
|
||||
monkeypatch.setattr(hooks_cli_mod, "STATE_DIR", fake_root / "hook_state")
|
||||
monkeypatch.setattr(hooks_cli_mod, "_state_dir_initialized", False)
|
||||
return fake_root
|
||||
|
||||
|
||||
def test_hook_stop_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
|
||||
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
transcript.write_text("")
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
hook_stop(
|
||||
{"session_id": "absent", "transcript_path": str(transcript), "stop_hook_active": False},
|
||||
"claude-code",
|
||||
)
|
||||
assert json.loads(buf.getvalue() or "{}") == {}
|
||||
assert not fake_root.exists()
|
||||
|
||||
|
||||
def test_hook_precompact_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
|
||||
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
transcript.write_text("")
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
hook_precompact(
|
||||
{"session_id": "absent", "transcript_path": str(transcript)},
|
||||
"claude-code",
|
||||
)
|
||||
assert json.loads(buf.getvalue() or "{}") == {}
|
||||
assert not fake_root.exists()
|
||||
|
||||
|
||||
def test_hook_session_start_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
|
||||
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
hook_session_start({"session_id": "absent"}, "claude-code")
|
||||
assert json.loads(buf.getvalue() or "{}") == {}
|
||||
assert not fake_root.exists()
|
||||
|
||||
|
||||
def test_log_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
|
||||
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
|
||||
_log("test message")
|
||||
assert not fake_root.exists()
|
||||
|
||||
|
||||
def test_existing_dir_proceeds_normally(tmp_path, monkeypatch):
|
||||
"""Regression: when PALACE_ROOT exists, hooks must proceed (no short-circuit)."""
|
||||
fake_root = tmp_path / "present-mempalace"
|
||||
fake_root.mkdir()
|
||||
monkeypatch.setattr(hooks_cli_mod, "PALACE_ROOT", fake_root)
|
||||
monkeypatch.setattr(hooks_cli_mod, "STATE_DIR", fake_root / "hook_state")
|
||||
monkeypatch.setattr(hooks_cli_mod, "_state_dir_initialized", False)
|
||||
_log("test message")
|
||||
# _log should have created the state dir under the existing palace root
|
||||
assert (fake_root / "hook_state").exists()
|
||||
assert (fake_root / "hook_state" / "hook.log").is_file()
|
||||
|
||||
|
||||
def test_regular_file_at_palace_root_treated_as_absent(tmp_path, monkeypatch):
|
||||
"""A regular file at ~/.mempalace must be treated the same as absent.
|
||||
|
||||
``Path.exists()`` returns True for a regular file, which would let the
|
||||
kill-switch be bypassed and crash later when ``STATE_DIR.mkdir()`` runs
|
||||
on ``NotADirectoryError``. ``_palace_root_exists()`` must use
|
||||
``is_dir()`` so a stray file (or broken symlink) short-circuits cleanly.
|
||||
"""
|
||||
fake_root = tmp_path / "file-not-dir"
|
||||
fake_root.write_text("oops, this is a file not a directory")
|
||||
monkeypatch.setattr(hooks_cli_mod, "PALACE_ROOT", fake_root)
|
||||
monkeypatch.setattr(hooks_cli_mod, "STATE_DIR", fake_root / "hook_state")
|
||||
monkeypatch.setattr(hooks_cli_mod, "_state_dir_initialized", False)
|
||||
|
||||
# _palace_root_exists() is the source of truth — it must return False.
|
||||
assert hooks_cli_mod._palace_root_exists() is False
|
||||
|
||||
# Hooks must short-circuit (return {} on stdout) and not touch disk.
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
hook_session_start({"session_id": "file-at-root"}, "claude-code")
|
||||
assert json.loads(buf.getvalue() or "{}") == {}
|
||||
|
||||
# _log must also short-circuit — it must NOT try to mkdir a path under a
|
||||
# regular file (which would raise NotADirectoryError).
|
||||
_log("test message") # would raise if not short-circuited
|
||||
|
||||
# The stray file is left untouched; we never try to convert it.
|
||||
assert fake_root.is_file()
|
||||
assert fake_root.read_text() == "oops, this is a file not a directory"
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Tests for ``candidate_strategy="union"`` in ``search_memories``.
|
||||
|
||||
The default ``"vector"`` strategy gathers candidates from the vector index
|
||||
only. Docs with strong BM25 signal but vector embeddings far from the query
|
||||
get skipped — terminology guides looked up by narrative-shaped queries are
|
||||
the canonical case.
|
||||
|
||||
The ``"union"`` strategy also pulls top-K BM25-only candidates from sqlite
|
||||
FTS5 and merges them into the rerank pool. Both signal sources contribute
|
||||
candidates; the hybrid rerank picks the best from a richer pool.
|
||||
|
||||
Default behavior is unchanged ("vector") — these tests exercise opt-in
|
||||
"union" mode.
|
||||
"""
|
||||
|
||||
from mempalace.palace import get_collection
|
||||
from mempalace.searcher import search_memories
|
||||
|
||||
|
||||
def _seed_drawers(palace_path):
|
||||
"""Seed a corpus where the right doc for one query is BM25-strong but
|
||||
vector-distant.
|
||||
|
||||
D1-D3 are short narrative tickets that semantically cluster around
|
||||
"customer support / order / shipped" vocabulary. D4 is a meta-document
|
||||
of bullet rules ("brand voice") that contains rare keywords like
|
||||
"Absolutely" and "apologize" the query repeats verbatim — strong BM25
|
||||
signal but stylistically far from the narrative tickets.
|
||||
"""
|
||||
col = get_collection(palace_path, create=True)
|
||||
col.upsert(
|
||||
ids=["D1", "D2", "D3", "D4"],
|
||||
documents=[
|
||||
"Customer wrote in asking why their order shipped without "
|
||||
"the promo sticker. Standard reply explaining the threshold.",
|
||||
"Order delivery delayed three days; customer requested a "
|
||||
"refund. Support agent processed return via ticket queue.",
|
||||
"Customer asked about the missing freebie; the reply "
|
||||
"explained the campaign mechanics and shipped status.",
|
||||
"Brand voice rules: dry, sturdy, never effusive. "
|
||||
"Never 'Absolutely!' Never apologize for policy — explain it. "
|
||||
"Avoid premium / curated / elevated vocabulary.",
|
||||
],
|
||||
metadatas=[
|
||||
{"wing": "shop", "room": "support", "source_file": "ticket_D1.md"},
|
||||
{"wing": "shop", "room": "support", "source_file": "ticket_D2.md"},
|
||||
{"wing": "shop", "room": "support", "source_file": "ticket_D3.md"},
|
||||
{"wing": "shop", "room": "guides", "source_file": "brand_voice_D4.md"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
_NARRATIVE_QUERY = (
|
||||
"A support agent is drafting a reply to a customer asking why their "
|
||||
"order shipped without a free sticker. Draft the reply, but never say "
|
||||
"'Absolutely!' and do not apologize for policy."
|
||||
)
|
||||
|
||||
|
||||
class TestCandidateUnion:
|
||||
def test_default_vector_strategy_unchanged(self, tmp_path):
|
||||
"""Default behavior must be identical to omitting the parameter."""
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
without = search_memories(_NARRATIVE_QUERY, palace, n_results=5)
|
||||
with_default = search_memories(
|
||||
_NARRATIVE_QUERY, palace, n_results=5, candidate_strategy="vector"
|
||||
)
|
||||
ids_a = [h["source_file"] for h in without["results"]]
|
||||
ids_b = [h["source_file"] for h in with_default["results"]]
|
||||
assert ids_a == ids_b, "explicit candidate_strategy='vector' must match default"
|
||||
|
||||
def test_union_surfaces_bm25_strong_vector_distant_doc(self, tmp_path):
|
||||
"""The brand-voice doc has strong BM25 signal for the query but is
|
||||
stylistically far from the narrative tickets. Union mode must
|
||||
retrieve it; vector-only mode is allowed to miss it."""
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
result = search_memories(_NARRATIVE_QUERY, palace, n_results=5, candidate_strategy="union")
|
||||
ids = [h["source_file"] for h in result["results"]]
|
||||
assert "brand_voice_D4.md" in ids, (
|
||||
"union mode must surface BM25-strong docs even when vector signal "
|
||||
f"is weak; got {ids}"
|
||||
)
|
||||
|
||||
def test_union_preserves_vector_hits(self, tmp_path):
|
||||
"""Union mode must not drop docs that vector-only mode finds —
|
||||
the rerank pool grows, it doesn't shrink."""
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
vector = search_memories(_NARRATIVE_QUERY, palace, n_results=5, candidate_strategy="vector")
|
||||
union = search_memories(_NARRATIVE_QUERY, palace, n_results=5, candidate_strategy="union")
|
||||
vec_ids = {h["source_file"] for h in vector["results"]}
|
||||
union_ids = {h["source_file"] for h in union["results"]}
|
||||
# In a 4-doc corpus with n_results=5, both should return all 4.
|
||||
# The invariant is: union should not lose anything vector found.
|
||||
missing = vec_ids - union_ids
|
||||
assert not missing, f"union dropped docs that vector found: {missing}"
|
||||
|
||||
def test_union_handles_empty_palace(self, tmp_path):
|
||||
"""No drawers — union mode should return empty results, not crash."""
|
||||
palace = str(tmp_path / "palace")
|
||||
get_collection(palace, create=True) # create empty collection
|
||||
result = search_memories("anything", palace, n_results=5, candidate_strategy="union")
|
||||
assert result.get("results", []) == []
|
||||
|
||||
def test_invalid_candidate_strategy_raises(self, tmp_path):
|
||||
"""Bad arg should raise rather than silently fall back."""
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="candidate_strategy"):
|
||||
search_memories("anything", palace, n_results=5, candidate_strategy="bogus")
|
||||
|
||||
def test_invalid_strategy_raises_even_when_vector_disabled(self, tmp_path):
|
||||
"""Validation must happen before the ``vector_disabled`` early return —
|
||||
invalid values must fail consistently regardless of routing."""
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="candidate_strategy"):
|
||||
search_memories(
|
||||
"anything",
|
||||
palace,
|
||||
n_results=5,
|
||||
vector_disabled=True,
|
||||
candidate_strategy="bogus",
|
||||
)
|
||||
|
||||
def test_union_respects_n_results_limit(self, tmp_path):
|
||||
"""When the merged candidate set is larger than ``n_results``, the
|
||||
result must be trimmed back to the requested size — the MCP
|
||||
``limit`` contract depends on this invariant."""
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
# 4-doc corpus, n_results=2 → union pool can grow to ~8 candidates,
|
||||
# rerank reorders them, but final list must respect the cap.
|
||||
result = search_memories(_NARRATIVE_QUERY, palace, n_results=2, candidate_strategy="union")
|
||||
assert (
|
||||
len(result["results"]) <= 2
|
||||
), f"union must trim to n_results=2; got {len(result['results'])} results"
|
||||
|
||||
def test_union_skipped_when_max_distance_set(self, tmp_path):
|
||||
"""``max_distance`` is a vector-distance threshold; BM25-only
|
||||
candidates have ``distance=None`` and cannot satisfy it. Union
|
||||
must not silently inject them when a strict threshold is set,
|
||||
otherwise the existing ``max_distance`` guarantee regresses."""
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
# Sanity: without max_distance, union surfaces the BM25-strong doc.
|
||||
unfiltered = search_memories(
|
||||
_NARRATIVE_QUERY, palace, n_results=5, candidate_strategy="union"
|
||||
)
|
||||
assert "brand_voice_D4.md" in {h["source_file"] for h in unfiltered["results"]}
|
||||
|
||||
# With a tight max_distance, union must NOT inject BM25-only hits —
|
||||
# every returned hit must have a real (non-None) distance.
|
||||
filtered = search_memories(
|
||||
_NARRATIVE_QUERY,
|
||||
palace,
|
||||
n_results=5,
|
||||
candidate_strategy="union",
|
||||
max_distance=0.5,
|
||||
)
|
||||
for h in filtered["results"]:
|
||||
assert h.get("distance") is not None, (
|
||||
f"union under max_distance must not inject BM25-only "
|
||||
f"(distance=None) candidates; offending hit: {h}"
|
||||
)
|
||||
assert h["distance"] <= 0.5, f"hit violates max_distance=0.5: distance={h['distance']}"
|
||||
|
||||
def test_union_dedup_is_chunk_precise_not_basename(self, tmp_path):
|
||||
"""Two files with the same basename in different directories must
|
||||
not collide — union must dedup on full path (or chunk-level key),
|
||||
not on basename alone. Otherwise a BM25-strong README from one
|
||||
directory silently shadows a BM25-strong README from another.
|
||||
"""
|
||||
palace = str(tmp_path / "palace")
|
||||
col = get_collection(palace, create=True)
|
||||
col.upsert(
|
||||
ids=["A_README", "B_README", "narrative"],
|
||||
documents=[
|
||||
# Both README files share the basename README.md but live
|
||||
# in different directories. Each contains distinctive
|
||||
# terminology a query might surface via BM25.
|
||||
"PROJECT ALPHA: configuration for the Frobnitz subsystem. "
|
||||
"Set FROBNITZ_TIMEOUT=30 to enable widget rotation.",
|
||||
"PROJECT BETA: configuration for the Wibble subsystem. "
|
||||
"Set WIBBLE_THRESHOLD=0.5 to enable signal smoothing.",
|
||||
"Engineers occasionally chat about how the legacy "
|
||||
"subsystems all need their config knobs tweaked.",
|
||||
],
|
||||
metadatas=[
|
||||
{"wing": "code", "room": "docs", "source_file": "alpha/README.md"},
|
||||
{"wing": "code", "room": "docs", "source_file": "beta/README.md"},
|
||||
{"wing": "code", "room": "docs", "source_file": "chat.md"},
|
||||
],
|
||||
)
|
||||
# Query that hits BM25 for BOTH READMEs (distinct vocab from each).
|
||||
# Vector-only might pick the chat doc as semantically "closest";
|
||||
# union must surface both READMEs without basename collision.
|
||||
result = search_memories(
|
||||
"FROBNITZ_TIMEOUT WIBBLE_THRESHOLD configuration",
|
||||
palace,
|
||||
n_results=5,
|
||||
candidate_strategy="union",
|
||||
)
|
||||
sources = [h["source_file"] for h in result["results"]]
|
||||
readme_count = sum(1 for s in sources if s == "README.md")
|
||||
assert readme_count >= 2, (
|
||||
f"union must surface both README.md files from different dirs "
|
||||
f"(basename collision would drop one); got sources={sources}"
|
||||
)
|
||||
|
||||
|
||||
class TestHybridRankTolerantOfMissingDistance:
|
||||
"""``_hybrid_rank`` accepts ``distance=None`` — required for BM25-only
|
||||
candidates injected by union mode."""
|
||||
|
||||
def test_distance_none_scored_as_zero_vector_sim(self):
|
||||
from mempalace.searcher import _hybrid_rank
|
||||
|
||||
results = [
|
||||
{"text": "alpha beta gamma", "distance": 0.2}, # close vector match
|
||||
{"text": "alpha alpha alpha", "distance": None}, # BM25-only — heavy term repetition
|
||||
]
|
||||
# Query matches "alpha" heavily; the BM25-only candidate with no
|
||||
# vector signal should still rank competitively on BM25 alone.
|
||||
ranked = _hybrid_rank(results, "alpha")
|
||||
assert all("bm25_score" in r for r in ranked), "rerank should add bm25_score"
|
||||
# Both must survive — neither should crash on distance=None.
|
||||
assert len(ranked) == 2
|
||||
@@ -5,6 +5,10 @@ Covers: entity CRUD, triple CRUD, temporal queries, invalidation,
|
||||
timeline, stats, and edge cases (duplicate triples, ID collisions).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sqlite3
|
||||
from mempalace.knowledge_graph import KnowledgeGraph
|
||||
|
||||
|
||||
class TestEntityOperations:
|
||||
def test_add_entity(self, kg):
|
||||
@@ -45,6 +49,38 @@ class TestTripleOperations:
|
||||
tid2 = kg.add_triple("Alice", "works_at", "Acme")
|
||||
assert tid1 != tid2 # new triple since old one was closed
|
||||
|
||||
def test_add_triple_rejects_inverted_interval(self, kg):
|
||||
# valid_to before valid_from would never satisfy
|
||||
# `valid_from <= as_of AND valid_to >= as_of` — silently invisible
|
||||
# to every query. Reject at write time instead.
|
||||
with pytest.raises(ValueError, match="before valid_from"):
|
||||
kg.add_triple(
|
||||
"Alice",
|
||||
"worked_at",
|
||||
"Acme",
|
||||
valid_from="2026-03-01",
|
||||
valid_to="2026-02-01",
|
||||
)
|
||||
|
||||
def test_add_triple_accepts_equal_dates(self, kg):
|
||||
# Same-day intervals are valid (point-in-time facts).
|
||||
tid = kg.add_triple(
|
||||
"Alice",
|
||||
"joined",
|
||||
"Acme",
|
||||
valid_from="2026-03-15",
|
||||
valid_to="2026-03-15",
|
||||
)
|
||||
assert tid.startswith("t_alice_joined_acme_")
|
||||
|
||||
def test_add_triple_allows_only_one_bound(self, kg):
|
||||
# The guard only fires when BOTH bounds are set.
|
||||
tid1 = kg.add_triple("Alice", "knows", "Bob", valid_from="2026-01-01")
|
||||
assert tid1.startswith("t_alice_knows_bob_")
|
||||
kg.invalidate("Alice", "knows", "Bob", ended="2026-02-01")
|
||||
tid2 = kg.add_triple("Alice", "knew", "Bob", valid_to="2026-03-01")
|
||||
assert tid2.startswith("t_alice_knew_bob_")
|
||||
|
||||
|
||||
class TestQueries:
|
||||
def test_query_outgoing(self, seeded_kg):
|
||||
@@ -137,3 +173,152 @@ class TestStats:
|
||||
assert stats["triples"] == 5
|
||||
assert stats["current_facts"] == 4 # 1 expired (Acme Corp)
|
||||
assert stats["expired_facts"] == 1
|
||||
|
||||
|
||||
class TestTemporalDateTimeCompatibility:
|
||||
def test_datetime_query_matches_legacy_date_only_fact(self, kg):
|
||||
kg.add_triple(
|
||||
"Alice",
|
||||
"ate_at",
|
||||
"Cafe",
|
||||
valid_from="2026-05-06",
|
||||
valid_to="2026-05-06",
|
||||
)
|
||||
|
||||
result = kg.query_entity("Alice", as_of="2026-05-06T15:00:00Z")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["object"] == "Cafe"
|
||||
|
||||
def test_datetime_query_before_legacy_date_only_fact_does_not_match(self, kg):
|
||||
kg.add_triple(
|
||||
"Alice",
|
||||
"ate_at",
|
||||
"Cafe",
|
||||
valid_from="2026-05-06",
|
||||
valid_to="2026-05-06",
|
||||
)
|
||||
|
||||
result = kg.query_entity("Alice", as_of="2026-05-05T23:59:59Z")
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_datetime_query_after_legacy_date_only_fact_does_not_match(self, kg):
|
||||
kg.add_triple(
|
||||
"Alice",
|
||||
"ate_at",
|
||||
"Cafe",
|
||||
valid_from="2026-05-06",
|
||||
valid_to="2026-05-06",
|
||||
)
|
||||
|
||||
result = kg.query_entity("Alice", as_of="2026-05-07T00:00:00Z")
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_rejects_timezone_offset_datetime_at_kg_layer(self, kg):
|
||||
with pytest.raises(ValueError):
|
||||
kg.add_triple(
|
||||
"Bob",
|
||||
"works_at",
|
||||
"Globex",
|
||||
valid_from="2026-05-06T20:30:00-05:00",
|
||||
)
|
||||
|
||||
def test_rejects_naive_datetime_at_kg_layer(self, kg):
|
||||
with pytest.raises(ValueError):
|
||||
kg.add_triple(
|
||||
"Carol",
|
||||
"is_in",
|
||||
"NYC",
|
||||
valid_from="2026-05-07T01:23:00",
|
||||
)
|
||||
|
||||
def test_rejects_space_separated_datetime_at_kg_layer(self, kg):
|
||||
with pytest.raises(ValueError):
|
||||
kg.add_triple(
|
||||
"Eve",
|
||||
"is_in",
|
||||
"London",
|
||||
valid_from="2026-05-06T15:00:00Z",
|
||||
valid_to="2026-05-06 20:00:00",
|
||||
)
|
||||
|
||||
def test_date_only_valid_to_is_end_of_day_for_interval_check(self, kg):
|
||||
kg.add_triple(
|
||||
"Eve",
|
||||
"is_in",
|
||||
"London",
|
||||
valid_from="2026-05-06T15:00:00Z",
|
||||
valid_to="2026-05-06",
|
||||
)
|
||||
|
||||
result = kg.query_entity("Eve", as_of="2026-05-06T20:00:00Z")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["object"] == "London"
|
||||
|
||||
def test_rejects_interval_when_date_only_end_is_before_datetime_start(self, kg):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"valid_to='2026-05-06'.*valid_from='2026-05-07T01:00:00Z'",
|
||||
):
|
||||
kg.add_triple(
|
||||
"Eve",
|
||||
"is_in",
|
||||
"London",
|
||||
valid_from="2026-05-07T01:00:00Z",
|
||||
valid_to="2026-05-06",
|
||||
)
|
||||
|
||||
def test_query_relationship_uses_safe_temporal_comparison(self, kg):
|
||||
kg.add_triple(
|
||||
"Alice",
|
||||
"visited",
|
||||
"Cafe",
|
||||
valid_from="2026-05-06",
|
||||
valid_to="2026-05-06",
|
||||
)
|
||||
|
||||
result = kg.query_relationship("visited", as_of="2026-05-06T15:00:00Z")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["subject"] == "Alice"
|
||||
assert result[0]["object"] == "Cafe"
|
||||
|
||||
def test_invalidate_rejects_timezone_offset_ended(self, kg):
|
||||
kg.add_triple(
|
||||
"Alice",
|
||||
"works_at",
|
||||
"Acme",
|
||||
valid_from="2026-05-06T14:00:00Z",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
kg.invalidate(
|
||||
"Alice",
|
||||
"works_at",
|
||||
"Acme",
|
||||
ended="2026-05-06T20:30:00-05:00",
|
||||
)
|
||||
|
||||
|
||||
class TestKnowledgeGraphConnectionCleanup:
|
||||
def test_close_closes_connection_and_resets_handle(self, tmp_path):
|
||||
kg = KnowledgeGraph(str(tmp_path / "kg.sqlite3"))
|
||||
conn = kg._conn()
|
||||
|
||||
kg.close()
|
||||
|
||||
assert kg._connection is None
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
def test_context_manager_closes_connection(self, tmp_path):
|
||||
with KnowledgeGraph(str(tmp_path / "kg.sqlite3")) as kg:
|
||||
conn = kg._conn()
|
||||
kg.add_entity("Alice")
|
||||
|
||||
assert kg._connection is None
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
@@ -206,3 +206,71 @@ def test_populated_registry_improves_miner_recall(temp_registry):
|
||||
# All four registered entities should land in the metadata string
|
||||
for expected in ("Julia Grib", "Kevin Heifner", "hyperion-history", "mempalace"):
|
||||
assert expected in tagged, f"expected '{expected}' in metadata {tagged!r}"
|
||||
|
||||
|
||||
# ── topics_by_wing — cross-wing tunnel signal source (issue #1180) ──
|
||||
|
||||
|
||||
def test_topics_persisted_under_topics_by_wing(temp_registry):
|
||||
miner.add_to_known_entities(
|
||||
{"people": ["Alice"], "topics": ["Angular", "OpenAPI"]},
|
||||
wing="wing_alpha",
|
||||
)
|
||||
data = json.loads(temp_registry.read_text())
|
||||
# Topics also stored as a flat list (existing-style aggregate).
|
||||
assert "Angular" in data["topics"]
|
||||
# And recorded by wing for tunnel computation.
|
||||
assert data["topics_by_wing"]["wing_alpha"] == ["Angular", "OpenAPI"]
|
||||
|
||||
|
||||
def test_topics_by_wing_replaces_on_reinit(temp_registry):
|
||||
"""Re-running init for the same wing should reflect the latest list,
|
||||
not accumulate stale topics indefinitely."""
|
||||
miner.add_to_known_entities({"topics": ["Angular", "OpenAPI"]}, wing="wing_alpha")
|
||||
miner.add_to_known_entities({"topics": ["OpenAPI", "Postgres"]}, wing="wing_alpha")
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert data["topics_by_wing"]["wing_alpha"] == ["OpenAPI", "Postgres"]
|
||||
|
||||
|
||||
def test_topics_by_wing_multiple_wings_coexist(temp_registry):
|
||||
miner.add_to_known_entities({"topics": ["foo"]}, wing="wing_a")
|
||||
miner.add_to_known_entities({"topics": ["foo", "bar"]}, wing="wing_b")
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert data["topics_by_wing"] == {"wing_a": ["foo"], "wing_b": ["foo", "bar"]}
|
||||
|
||||
|
||||
def test_topics_by_wing_skipped_without_wing(temp_registry):
|
||||
miner.add_to_known_entities({"topics": ["foo"]})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
# No wing → no topics_by_wing entry, but topics list still saved.
|
||||
assert "topics_by_wing" not in data
|
||||
assert data["topics"] == ["foo"]
|
||||
|
||||
|
||||
def test_topics_by_wing_dedupes_case_insensitive(temp_registry):
|
||||
miner.add_to_known_entities({"topics": ["OpenAPI", "openapi", "OPENAPI"]}, wing="wing_a")
|
||||
data = json.loads(temp_registry.read_text())
|
||||
# Only one entry, casing of the first observed name preserved.
|
||||
assert data["topics_by_wing"]["wing_a"] == ["OpenAPI"]
|
||||
|
||||
|
||||
def test_get_topics_by_wing_reads_registry(temp_registry):
|
||||
miner.add_to_known_entities({"topics": ["foo"]}, wing="wing_a")
|
||||
miner.add_to_known_entities({"topics": ["foo", "bar"]}, wing="wing_b")
|
||||
result = miner.get_topics_by_wing()
|
||||
assert result == {"wing_a": ["foo"], "wing_b": ["foo", "bar"]}
|
||||
|
||||
|
||||
def test_get_topics_by_wing_empty_when_missing(temp_registry):
|
||||
miner.add_to_known_entities({"people": ["Alice"]})
|
||||
assert miner.get_topics_by_wing() == {}
|
||||
|
||||
|
||||
def test_topics_by_wing_does_not_pollute_known_names(temp_registry):
|
||||
"""Wing names in topics_by_wing must NOT leak into the flat known-names
|
||||
set used by ``_extract_entities_for_metadata`` — only the topic strings
|
||||
themselves should be recognized."""
|
||||
miner.add_to_known_entities({"topics": ["Angular"]}, wing="wing_super_secret_project")
|
||||
known = miner._load_known_entities()
|
||||
assert "Angular" in known
|
||||
assert "wing_super_secret_project" not in known
|
||||
|
||||
@@ -655,3 +655,72 @@ def test_memory_stack_status_with_palace(tmp_path):
|
||||
|
||||
assert result["total_drawers"] == 42
|
||||
assert result["L0_identity"]["exists"] is True
|
||||
|
||||
|
||||
# ── Layer1 / Layer2 None-metadata guards ───────────────────────────────
|
||||
#
|
||||
# Chroma 1.5.x can return ``None`` inside the ``metadatas`` / ``documents``
|
||||
# lists for partially-flushed rows. The Layer1.generate() and
|
||||
# Layer2.retrieve() loops previously called ``meta.get(...)`` without
|
||||
# coercing, raising ``AttributeError: 'NoneType' object has no attribute
|
||||
# 'get'`` and blowing up the whole wake-up render. These tests guard that
|
||||
# the loops tolerate the None entries and render the rest of the result.
|
||||
|
||||
|
||||
def test_layer1_handles_none_metadata():
|
||||
"""Layer1.generate tolerates None entries in the metadatas list."""
|
||||
docs = ["important memory", "another memory"]
|
||||
metas = [{"room": "decisions", "source_file": "a.txt"}, None]
|
||||
mock_col = _mock_chromadb_for_layer(docs, metas)
|
||||
|
||||
with (
|
||||
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
|
||||
patch("mempalace.layers._get_collection", return_value=mock_col),
|
||||
):
|
||||
mock_cfg.return_value.palace_path = "/fake"
|
||||
layer = Layer1(palace_path="/fake")
|
||||
# Should not raise AttributeError on the None entry.
|
||||
result = layer.generate()
|
||||
|
||||
assert "ESSENTIAL STORY" in result
|
||||
assert "important memory" in result
|
||||
|
||||
|
||||
def test_layer1_handles_none_document():
|
||||
"""Layer1.generate tolerates None entries in the documents list."""
|
||||
docs = ["first doc", None]
|
||||
metas = [
|
||||
{"room": "r", "source_file": "a.txt"},
|
||||
{"room": "r", "source_file": "b.txt"},
|
||||
]
|
||||
mock_col = _mock_chromadb_for_layer(docs, metas)
|
||||
|
||||
with (
|
||||
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
|
||||
patch("mempalace.layers._get_collection", return_value=mock_col),
|
||||
):
|
||||
mock_cfg.return_value.palace_path = "/fake"
|
||||
layer = Layer1(palace_path="/fake")
|
||||
result = layer.generate()
|
||||
|
||||
assert result # Render succeeded despite the None document.
|
||||
|
||||
|
||||
def test_layer2_handles_none_metadata():
|
||||
"""Layer2.retrieve tolerates None entries in the metadatas list."""
|
||||
mock_col = MagicMock()
|
||||
mock_col.get.return_value = {
|
||||
"documents": ["first doc", "second doc"],
|
||||
"metadatas": [{"room": "r", "source_file": "a.txt"}, None],
|
||||
}
|
||||
|
||||
with (
|
||||
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
|
||||
patch("mempalace.layers._get_collection", return_value=mock_col),
|
||||
):
|
||||
mock_cfg.return_value.palace_path = "/fake"
|
||||
layer = Layer2(palace_path="/fake")
|
||||
# Should not raise AttributeError on the None entry.
|
||||
result = layer.retrieve()
|
||||
|
||||
assert "L2 — ON-DEMAND" in result
|
||||
|
||||
@@ -325,3 +325,161 @@ def test_anthropic_no_key_raises_on_classify(monkeypatch):
|
||||
p = AnthropicProvider(model="claude-haiku")
|
||||
with pytest.raises(LLMError, match="requires ANTHROPIC_API_KEY"):
|
||||
p.classify("s", "u")
|
||||
|
||||
|
||||
# ── is_external_service property (issue #24 — privacy warning support) ──
|
||||
#
|
||||
# `is_external_service` is True when this provider's endpoint sends data
|
||||
# off the user's machine/network. Used by mempalace init to print a
|
||||
# privacy warning before first run when an external API will receive
|
||||
# folder content. URL-based heuristic: localhost, 127.x, ::1, .local,
|
||||
# RFC1918 (10/8, 192.168/16, 172.16-31/12), and IPv6 ULA (fc/fd::) are
|
||||
# all treated as local. Everything else is treated as external.
|
||||
|
||||
|
||||
def test_ollama_provider_default_endpoint_is_local():
|
||||
"""OllamaProvider's default endpoint is http://localhost:11434, which
|
||||
must be classified as local — no privacy warning fires for the
|
||||
typical user running Ollama on their own machine."""
|
||||
p = OllamaProvider(model="gemma4:e4b")
|
||||
assert p.is_external_service is False, (
|
||||
f"Default OllamaProvider endpoint must be local; got "
|
||||
f"is_external_service={p.is_external_service} for endpoint={p.endpoint}"
|
||||
)
|
||||
|
||||
|
||||
def test_openai_compat_provider_localhost_endpoint_is_local():
|
||||
"""LM Studio / llama.cpp server / vLLM commonly bind to localhost.
|
||||
Those setups must NOT trigger the external-API warning."""
|
||||
p = OpenAICompatProvider(model="any", endpoint="http://localhost:1234")
|
||||
assert p.is_external_service is False
|
||||
p_127 = OpenAICompatProvider(model="any", endpoint="http://127.0.0.1:8000")
|
||||
assert p_127.is_external_service is False
|
||||
p_lan = OpenAICompatProvider(model="any", endpoint="http://192.168.1.50:1234")
|
||||
assert p_lan.is_external_service is False, "LAN (RFC1918) endpoints must be local"
|
||||
|
||||
|
||||
def test_openai_compat_provider_cloud_endpoint_is_external():
|
||||
"""A user pointing openai-compat at OpenAI's hosted API or any other
|
||||
non-local endpoint MUST trigger the external warning."""
|
||||
p = OpenAICompatProvider(model="gpt-4o", endpoint="https://api.openai.com")
|
||||
assert p.is_external_service is True, (
|
||||
f"https://api.openai.com must be classified external; got "
|
||||
f"is_external_service={p.is_external_service}"
|
||||
)
|
||||
|
||||
|
||||
def test_anthropic_provider_default_endpoint_is_external():
|
||||
"""AnthropicProvider's default endpoint is https://api.anthropic.com,
|
||||
which is always external by definition. The privacy warning MUST
|
||||
fire by default for users who pass --llm-provider anthropic."""
|
||||
p = AnthropicProvider(model="claude-haiku-4-5", api_key="sk-test")
|
||||
assert p.is_external_service is True, (
|
||||
f"Default AnthropicProvider endpoint must be external; got "
|
||||
f"is_external_service={p.is_external_service} for endpoint={p.endpoint}"
|
||||
)
|
||||
|
||||
|
||||
# ── Tailscale CGNAT range (issue #25 follow-up to #24) ──────────────────
|
||||
#
|
||||
# Tailscale assigns addresses in 100.64.0.0/10 (CGNAT range): first octet
|
||||
# always 100, second octet 64-127 inclusive. Users running LM Studio /
|
||||
# Ollama / any local LLM accessible via Tailscale would currently
|
||||
# (post-#24, pre-#25) get a wrong privacy warning because the heuristic
|
||||
# doesn't recognize CGNAT as private. These tests pin the fix.
|
||||
|
||||
|
||||
def test_openai_compat_provider_tailscale_cgnat_endpoint_is_local():
|
||||
"""Tailscale CGNAT range (100.64.0.0/10) — IPs where the first octet
|
||||
is 100 AND the second octet is 64-127 inclusive — must be classified
|
||||
as local. Tailscale users running LM Studio on their Tailnet should
|
||||
not trigger the external-API warning.
|
||||
"""
|
||||
cases = [
|
||||
("http://100.64.0.1:1234", "start of CGNAT"),
|
||||
("http://100.100.50.50:1234", "middle of CGNAT (typical Tailscale assignment)"),
|
||||
("http://100.127.255.254:1234", "near end of CGNAT"),
|
||||
]
|
||||
for endpoint, label in cases:
|
||||
p = OpenAICompatProvider(model="any", endpoint=endpoint)
|
||||
assert p.is_external_service is False, (
|
||||
f"Tailscale CGNAT address {endpoint} ({label}) must be classified "
|
||||
f"local; got is_external_service={p.is_external_service}"
|
||||
)
|
||||
|
||||
|
||||
def test_openai_compat_provider_outside_tailscale_cgnat_is_external():
|
||||
"""Addresses in 100.x.x.x that fall OUTSIDE the CGNAT range
|
||||
(100.64.0.0 - 100.127.255.255) are public IPs in regular allocated
|
||||
space and must remain classified as external. Specifically: anything
|
||||
where the second octet is < 64 or > 127.
|
||||
"""
|
||||
cases = [
|
||||
("http://100.0.0.1:1234", "below CGNAT (public)"),
|
||||
("http://100.63.255.255:1234", "just below CGNAT (boundary)"),
|
||||
("http://100.128.0.0:1234", "just above CGNAT (boundary)"),
|
||||
("http://100.255.255.255:1234", "well above CGNAT"),
|
||||
]
|
||||
for endpoint, label in cases:
|
||||
p = OpenAICompatProvider(model="any", endpoint=endpoint)
|
||||
assert p.is_external_service is True, (
|
||||
f"Address {endpoint} ({label}) is OUTSIDE Tailscale CGNAT and "
|
||||
f"should remain external; got is_external_service={p.is_external_service}"
|
||||
)
|
||||
|
||||
|
||||
# ── api_key_source provenance tracking (issue #26) ──────────────────────
|
||||
#
|
||||
# Distinguishes whether `api_key` was set via explicit constructor arg
|
||||
# (= --llm-api-key flag → "flag") vs via environment-variable fallback
|
||||
# (OPENAI_API_KEY / ANTHROPIC_API_KEY → "env"). cmd_init uses this to
|
||||
# decide whether to block on a consent prompt: stray env-fallback keys
|
||||
# require explicit user confirmation; explicit flag-passed keys are
|
||||
# treated as already-consented.
|
||||
|
||||
|
||||
def test_openai_compat_api_key_source_flag_when_explicit(monkeypatch):
|
||||
"""When ``api_key`` is passed explicitly to the constructor, the
|
||||
provider records ``api_key_source == "flag"`` even if the same env
|
||||
var is also set. Flag wins over env."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env-irrelevant")
|
||||
p = OpenAICompatProvider(model="x", endpoint="http://h", api_key="sk-from-flag")
|
||||
assert p.api_key == "sk-from-flag"
|
||||
assert (
|
||||
p.api_key_source == "flag"
|
||||
), f"Explicit api_key arg must produce api_key_source='flag'; got {p.api_key_source!r}"
|
||||
|
||||
|
||||
def test_openai_compat_api_key_source_env_when_fallback(monkeypatch):
|
||||
"""When ``api_key`` arg is None but ``OPENAI_API_KEY`` is set, the
|
||||
provider falls back to env and records ``api_key_source == "env"``.
|
||||
This is the "stray key" case — user didn't explicitly authorize this
|
||||
run to use the env-resolved credential."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env")
|
||||
p = OpenAICompatProvider(model="x", endpoint="http://h")
|
||||
assert p.api_key == "sk-from-env"
|
||||
assert (
|
||||
p.api_key_source == "env"
|
||||
), f"Env-fallback api_key must produce api_key_source='env'; got {p.api_key_source!r}"
|
||||
|
||||
|
||||
def test_anthropic_api_key_source_tracking(monkeypatch):
|
||||
"""AnthropicProvider tracks api_key_source the same way: 'flag' when
|
||||
passed explicitly, 'env' when resolved from ANTHROPIC_API_KEY env."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-env")
|
||||
p_flag = AnthropicProvider(model="claude-haiku", api_key="sk-ant-flag")
|
||||
assert (
|
||||
p_flag.api_key_source == "flag"
|
||||
), f"Explicit api_key must produce 'flag'; got {p_flag.api_key_source!r}"
|
||||
p_env = AnthropicProvider(model="claude-haiku")
|
||||
assert p_env.api_key == "sk-ant-env"
|
||||
assert (
|
||||
p_env.api_key_source == "env"
|
||||
), f"Env-fallback must produce 'env'; got {p_env.api_key_source!r}"
|
||||
|
||||
|
||||
def test_ollama_api_key_source_is_none():
|
||||
"""Ollama doesn't use api_key at all; ``api_key_source`` should be None."""
|
||||
p = OllamaProvider(model="gemma4:e4b")
|
||||
assert p.api_key is None
|
||||
assert p.api_key_source is None
|
||||
|
||||
@@ -272,7 +272,9 @@ def test_apply_classifications_appends_reason_signal():
|
||||
assert any("spoken of by name" in s for s in new["people"][0]["signals"])
|
||||
|
||||
|
||||
def test_apply_classifications_topic_goes_to_uncertain():
|
||||
def test_apply_classifications_topic_goes_to_topics_bucket():
|
||||
"""TOPIC classifications now route to a dedicated ``topics`` bucket so the
|
||||
miner can use them as cross-wing tunnel signal (issue #1180)."""
|
||||
detected = {
|
||||
"people": [],
|
||||
"projects": [
|
||||
@@ -289,8 +291,32 @@ def test_apply_classifications_topic_goes_to_uncertain():
|
||||
decisions = {"Paris": ("TOPIC", "city, not a project")}
|
||||
new, reclass, _ = _apply_classifications(detected, decisions)
|
||||
assert len(new["projects"]) == 0
|
||||
assert len(new["uncertain"]) == 0
|
||||
assert len(new["topics"]) == 1
|
||||
assert new["topics"][0]["name"] == "Paris"
|
||||
assert new["topics"][0]["type"] == "topic"
|
||||
assert reclass == 1
|
||||
|
||||
|
||||
def test_apply_classifications_ambiguous_still_goes_to_uncertain():
|
||||
detected = {
|
||||
"people": [],
|
||||
"projects": [
|
||||
{
|
||||
"name": "Foo",
|
||||
"type": "project",
|
||||
"confidence": 0.7,
|
||||
"frequency": 5,
|
||||
"signals": ["regex"],
|
||||
}
|
||||
],
|
||||
"uncertain": [],
|
||||
}
|
||||
decisions = {"Foo": ("AMBIGUOUS", "context insufficient")}
|
||||
new, reclass, _ = _apply_classifications(detected, decisions)
|
||||
assert len(new["projects"]) == 0
|
||||
assert len(new["uncertain"]) == 1
|
||||
assert new["uncertain"][0]["name"] == "Paris"
|
||||
assert new["uncertain"][0]["name"] == "Foo"
|
||||
assert reclass == 1
|
||||
|
||||
|
||||
@@ -469,7 +495,9 @@ def test_refine_entities_refines_high_confidence_regex_projects():
|
||||
assert provider.call_count == 1
|
||||
assert result.reclassified == 1
|
||||
assert result.merged["projects"] == []
|
||||
assert result.merged["uncertain"][0]["name"] == "OpenAPI"
|
||||
# TOPIC labels go to the dedicated ``topics`` bucket so the miner can
|
||||
# use them for cross-wing tunnel computation (issue #1180).
|
||||
assert result.merged["topics"][0]["name"] == "OpenAPI"
|
||||
|
||||
|
||||
def test_refine_entities_refines_regex_people_but_skips_git_people():
|
||||
|
||||
+996
-2
File diff suppressed because it is too large
Load Diff
+155
-1
@@ -1,9 +1,10 @@
|
||||
"""Tests for destructive-operation safety in mempalace.migrate."""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from mempalace.migrate import migrate
|
||||
from mempalace.migrate import collection_write_roundtrip_works, _restore_stale_palace, migrate
|
||||
|
||||
|
||||
def test_migrate_requires_palace_database(tmp_path, capsys):
|
||||
@@ -46,3 +47,156 @@ def test_migrate_aborts_without_confirmation(tmp_path, capsys):
|
||||
assert "Aborted." in out
|
||||
mock_copytree.assert_not_called()
|
||||
mock_rmtree.assert_not_called()
|
||||
|
||||
|
||||
def test_restore_stale_palace_with_clean_destination(tmp_path):
|
||||
"""Rollback when no partial copy exists at palace_path."""
|
||||
palace_path = tmp_path / "palace"
|
||||
stale_path = tmp_path / "palace.old"
|
||||
stale_path.mkdir()
|
||||
(stale_path / "chroma.sqlite3").write_bytes(b"original")
|
||||
|
||||
_restore_stale_palace(str(palace_path), str(stale_path))
|
||||
|
||||
assert palace_path.is_dir()
|
||||
assert (palace_path / "chroma.sqlite3").read_bytes() == b"original"
|
||||
assert not stale_path.exists()
|
||||
|
||||
|
||||
def test_restore_stale_palace_clears_partial_copy(tmp_path):
|
||||
"""Rollback must remove a partially-copied palace_path before restoring.
|
||||
|
||||
Simulates the Qodo-reported hazard: shutil.move() began creating
|
||||
palace_path, then failed. A bare os.replace(stale, palace_path) would
|
||||
trip on the existing destination; _restore_stale_palace must clear it.
|
||||
"""
|
||||
palace_path = tmp_path / "palace"
|
||||
stale_path = tmp_path / "palace.old"
|
||||
|
||||
stale_path.mkdir()
|
||||
(stale_path / "chroma.sqlite3").write_bytes(b"original")
|
||||
|
||||
palace_path.mkdir()
|
||||
(palace_path / "half-copied.bin").write_bytes(b"garbage")
|
||||
|
||||
_restore_stale_palace(str(palace_path), str(stale_path))
|
||||
|
||||
assert palace_path.is_dir()
|
||||
assert (palace_path / "chroma.sqlite3").read_bytes() == b"original"
|
||||
assert not (palace_path / "half-copied.bin").exists()
|
||||
assert not stale_path.exists()
|
||||
|
||||
|
||||
def test_restore_stale_palace_logs_and_swallows_on_failure(tmp_path, capsys):
|
||||
"""If restore itself fails, log both paths — don't raise from rollback."""
|
||||
palace_path = tmp_path / "palace"
|
||||
stale_path = tmp_path / "palace.old"
|
||||
stale_path.mkdir()
|
||||
|
||||
# Force os.replace to fail deterministically.
|
||||
with patch("mempalace.migrate.os.replace", side_effect=OSError("boom")):
|
||||
_restore_stale_palace(str(palace_path), str(stale_path))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "CRITICAL" in out
|
||||
assert os.fspath(palace_path) in out
|
||||
assert os.fspath(stale_path) in out
|
||||
|
||||
|
||||
class _FakeGetResult:
|
||||
def __init__(self, ids):
|
||||
self.ids = ids
|
||||
|
||||
|
||||
class _WritableFakeCollection:
|
||||
def __init__(self):
|
||||
self.ids = set()
|
||||
self.deleted = []
|
||||
|
||||
def upsert(self, *, ids, documents, metadatas):
|
||||
self.ids.update(ids)
|
||||
|
||||
def get(self, *, ids, include=None):
|
||||
return _FakeGetResult([drawer_id for drawer_id in ids if drawer_id in self.ids])
|
||||
|
||||
def delete(self, *, ids=None, where=None):
|
||||
for drawer_id in ids or []:
|
||||
self.ids.discard(drawer_id)
|
||||
self.deleted.append(drawer_id)
|
||||
|
||||
|
||||
class _SilentWriteDropCollection(_WritableFakeCollection):
|
||||
def upsert(self, *, ids, documents, metadatas):
|
||||
return None
|
||||
|
||||
|
||||
class _SilentDeleteDropCollection(_WritableFakeCollection):
|
||||
def delete(self, *, ids=None, where=None):
|
||||
self.deleted.extend(ids or [])
|
||||
|
||||
|
||||
def test_collection_write_roundtrip_works_when_probe_persists_and_deletes():
|
||||
col = _WritableFakeCollection()
|
||||
|
||||
assert collection_write_roundtrip_works(col) is True
|
||||
assert col.ids == set()
|
||||
assert len(col.deleted) == 1
|
||||
|
||||
|
||||
def test_collection_write_roundtrip_fails_when_upsert_silently_drops():
|
||||
col = _SilentWriteDropCollection()
|
||||
|
||||
assert collection_write_roundtrip_works(col) is False
|
||||
assert col.ids == set()
|
||||
|
||||
|
||||
def test_collection_write_roundtrip_fails_when_delete_silently_drops():
|
||||
col = _SilentDeleteDropCollection()
|
||||
|
||||
assert collection_write_roundtrip_works(col) is False
|
||||
assert len(col.ids) == 1
|
||||
|
||||
|
||||
def test_migrate_dry_run_rebuilds_when_collection_is_readable_but_not_writable(tmp_path, capsys):
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
(palace_dir / "chroma.sqlite3").write_text("db")
|
||||
|
||||
fake_col = MagicMock()
|
||||
fake_col.count.return_value = 102
|
||||
|
||||
drawers = [
|
||||
{
|
||||
"id": "id1",
|
||||
"document": "hello",
|
||||
"metadata": {"wing": "test-wing", "room": "general"},
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
patch("mempalace.migrate.detect_chromadb_version", return_value="1.x"),
|
||||
patch("mempalace.backends.chroma.ChromaBackend") as mock_backend,
|
||||
patch(
|
||||
"mempalace.migrate.collection_write_roundtrip_works", return_value=False
|
||||
) as mock_probe,
|
||||
patch(
|
||||
"mempalace.migrate.extract_drawers_from_sqlite", return_value=drawers
|
||||
) as mock_extract,
|
||||
):
|
||||
mock_backend.backend_version.return_value = "1.5.8"
|
||||
mock_backend.return_value.get_collection.return_value = fake_col
|
||||
|
||||
result = migrate(str(palace_dir), dry_run=True)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert result is True
|
||||
mock_probe.assert_called_once_with(fake_col)
|
||||
mock_extract.assert_called_once_with(
|
||||
os.path.join(os.path.abspath(os.fspath(palace_dir)), "chroma.sqlite3")
|
||||
)
|
||||
|
||||
assert "readable by chromadb 1.5.8, but write/delete verification failed" in out
|
||||
assert "Rebuilding from SQLite" in out
|
||||
assert "Extracted 1 drawers from SQLite" in out
|
||||
assert "DRY RUN" in out
|
||||
|
||||
+482
-1
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -6,7 +7,7 @@ from pathlib import Path
|
||||
import chromadb
|
||||
import yaml
|
||||
|
||||
from mempalace.miner import load_config, mine, scan_project, status
|
||||
from mempalace.miner import detect_room, load_config, mine, scan_project, status
|
||||
from mempalace.palace import NORMALIZE_VERSION, file_already_mined
|
||||
|
||||
|
||||
@@ -66,6 +67,24 @@ def test_load_config_uses_defaults_when_yaml_missing():
|
||||
shutil.rmtree(tmpdir)
|
||||
|
||||
|
||||
def test_load_config_no_yaml_normalizes_hyphenated_wing():
|
||||
"""Fallback wing name is normalized so it matches topics_by_wing keys.
|
||||
|
||||
Regression for the no-yaml branch of #1194: ``cmd_init`` writes
|
||||
``topics_by_wing`` under the normalized slug, so the miner's
|
||||
fallback wing must use the same normalization or the tunnel lookup
|
||||
misses every key for hyphenated dirnames.
|
||||
"""
|
||||
parent = tempfile.mkdtemp()
|
||||
try:
|
||||
project_root = Path(parent) / "my-cool-app"
|
||||
project_root.mkdir()
|
||||
config = load_config(str(project_root))
|
||||
assert config["wing"] == "my_cool_app"
|
||||
finally:
|
||||
shutil.rmtree(parent)
|
||||
|
||||
|
||||
def test_scan_project_skips_mempalace_generated_files():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project_root = Path(tmpdir).resolve()
|
||||
@@ -383,6 +402,46 @@ def test_status_handles_none_metadata_without_crash(tmp_path, capsys):
|
||||
assert "WING: proj" in out
|
||||
|
||||
|
||||
def test_process_file_uses_bounded_upsert_batches(tmp_path, monkeypatch):
|
||||
from mempalace import miner
|
||||
|
||||
class FakeCol:
|
||||
def __init__(self):
|
||||
self.batch_sizes = []
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
return {"ids": []}
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def upsert(self, documents, ids, metadatas):
|
||||
self.batch_sizes.append(len(documents))
|
||||
|
||||
source = tmp_path / "src.py"
|
||||
source.write_text("print('hello')\n" * 20, encoding="utf-8")
|
||||
chunks = [{"content": f"chunk {i} " * 20, "chunk_index": i} for i in range(5)]
|
||||
col = FakeCol()
|
||||
monkeypatch.setattr(miner, "DRAWER_UPSERT_BATCH_SIZE", 2)
|
||||
monkeypatch.setattr(miner, "chunk_text", lambda content, source_file: chunks)
|
||||
monkeypatch.setattr(miner, "detect_hall", lambda content: "code")
|
||||
monkeypatch.setattr(miner, "_extract_entities_for_metadata", lambda content: "")
|
||||
|
||||
drawers, room = miner.process_file(
|
||||
source,
|
||||
tmp_path,
|
||||
col,
|
||||
"wing",
|
||||
[{"name": "general", "description": "General"}],
|
||||
"agent",
|
||||
False,
|
||||
)
|
||||
|
||||
assert drawers == 5
|
||||
assert room == "general"
|
||||
assert col.batch_sizes == [2, 2, 1]
|
||||
|
||||
|
||||
# ── normalize_version schema gate ───────────────────────────────────────
|
||||
#
|
||||
# When the normalization pipeline changes shape (e.g., strip_noise lands),
|
||||
@@ -432,6 +491,103 @@ def test_file_already_mined_returns_false_for_stale_normalize_version():
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_detect_room_uses_token_boundary_matching(tmp_path):
|
||||
"""Path-part routing must not fire on incidental substrings.
|
||||
|
||||
Regression: "views" is a substring of "interviews", so the old
|
||||
substring check routed every file under views/ into a room keyed
|
||||
by "interviews". Token-boundary matching prevents this while still
|
||||
matching real tokens like "frontend" in "frontend-app".
|
||||
"""
|
||||
project = tmp_path
|
||||
rooms = [
|
||||
{"name": "billing-page", "keywords": ["billing-page"]},
|
||||
{"name": "interviews", "keywords": ["interviews"]},
|
||||
{"name": "general", "keywords": []},
|
||||
]
|
||||
|
||||
# views/<X>/... must NOT route to "interviews" on the "views"⊂"interviews" accident
|
||||
view_file = project / "views" / "billing-page" / "Foo.test.tsx"
|
||||
view_file.parent.mkdir(parents=True)
|
||||
view_file.write_text("content")
|
||||
assert detect_room(view_file, "content", rooms, project) == "billing-page"
|
||||
|
||||
# data/interviews/... must route to "interviews" via the real token
|
||||
data_file = project / "data" / "interviews" / "index.ts"
|
||||
data_file.parent.mkdir(parents=True)
|
||||
data_file.write_text("content")
|
||||
assert detect_room(data_file, "content", rooms, project) == "interviews"
|
||||
|
||||
|
||||
def test_detect_room_preserves_token_matches(tmp_path):
|
||||
"""Real separator-bounded tokens still match in both directions."""
|
||||
project = tmp_path
|
||||
rooms = [
|
||||
{"name": "frontend", "keywords": ["frontend"]},
|
||||
{"name": "general", "keywords": []},
|
||||
]
|
||||
|
||||
# path part contains keyword as a token
|
||||
f1 = project / "frontend-app" / "main.ts"
|
||||
f1.parent.mkdir(parents=True)
|
||||
f1.write_text("x")
|
||||
assert detect_room(f1, "x", rooms, project) == "frontend"
|
||||
|
||||
# keyword contains path part as a token (reverse direction)
|
||||
rooms2 = [
|
||||
{"name": "data-retention", "keywords": ["data-retention"]},
|
||||
{"name": "general", "keywords": []},
|
||||
]
|
||||
f2 = project / "data" / "data-retention" / "policy.ts"
|
||||
f2.parent.mkdir(parents=True)
|
||||
f2.write_text("x")
|
||||
assert detect_room(f2, "x", rooms2, project) == "data-retention"
|
||||
|
||||
|
||||
def test_detect_room_matches_keyword_distinct_from_name(tmp_path):
|
||||
"""Regression: PR #145 — path part must match a keyword even when the
|
||||
room name itself doesn't contain the path part as a token.
|
||||
|
||||
Scenario: a folder named ``docs/`` should route to a room named
|
||||
``documentation`` that declares ``"docs"`` as a keyword.
|
||||
"""
|
||||
project = tmp_path
|
||||
rooms = [
|
||||
{"name": "documentation", "keywords": ["docs"]},
|
||||
{"name": "general", "keywords": []},
|
||||
]
|
||||
|
||||
f = project / "docs" / "readme.md"
|
||||
f.parent.mkdir(parents=True)
|
||||
f.write_text("x")
|
||||
assert detect_room(f, "x", rooms, project) == "documentation"
|
||||
|
||||
|
||||
def test_detect_room_filename_match_uses_token_boundary(tmp_path):
|
||||
"""Priority 2 (filename match) must also use token-boundary rules."""
|
||||
project = tmp_path
|
||||
rooms = [
|
||||
{"name": "review", "keywords": []},
|
||||
{"name": "general", "keywords": []},
|
||||
]
|
||||
|
||||
# "review" is a substring of "reviewmodule" but not a token — should NOT match
|
||||
f1 = project / "reviewmodule.ts"
|
||||
f1.write_text("x")
|
||||
assert detect_room(f1, "x", rooms, project) != "review"
|
||||
|
||||
# "review" IS a token of "review-page" — should match
|
||||
f2 = project / "review-page.ts"
|
||||
f2.write_text("x")
|
||||
assert detect_room(f2, "x", rooms, project) == "review"
|
||||
|
||||
# Dotted filename stems like "Foo.test" split on "." too
|
||||
rooms3 = [{"name": "foo", "keywords": []}, {"name": "general", "keywords": []}]
|
||||
f3 = project / "foo.test.ts"
|
||||
f3.write_text("x")
|
||||
assert detect_room(f3, "x", rooms3, project) == "foo"
|
||||
|
||||
|
||||
def test_add_drawer_stamps_normalize_version(tmp_path):
|
||||
"""Fresh drawers carry the current schema version so future upgrades work."""
|
||||
from mempalace.miner import add_drawer
|
||||
@@ -456,3 +612,328 @@ def test_add_drawer_stamps_normalize_version(tmp_path):
|
||||
assert meta["normalize_version"] == NORMALIZE_VERSION
|
||||
finally:
|
||||
del col, client
|
||||
|
||||
|
||||
def test_mine_creates_topic_tunnels_for_shared_topics(tmp_path, monkeypatch):
|
||||
"""End-to-end: when two wings have already-confirmed topics that overlap,
|
||||
the miner's mine-time pass drops a cross-wing tunnel between them.
|
||||
|
||||
Issue #1180.
|
||||
"""
|
||||
from mempalace import miner, palace_graph
|
||||
|
||||
# Redirect both the registry and tunnel-storage paths into tmp_path
|
||||
# so we never touch the developer's real ~/.mempalace directory.
|
||||
registry = tmp_path / "known_entities.json"
|
||||
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
|
||||
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
|
||||
tunnels_file = tmp_path / "tunnels.json"
|
||||
monkeypatch.setattr(palace_graph, "_TUNNEL_FILE", str(tunnels_file))
|
||||
|
||||
# Pre-populate the registry as if init had been run for two wings that
|
||||
# share a topic.
|
||||
miner.add_to_known_entities({"topics": ["foo", "bar"]}, wing="wing_one")
|
||||
miner.add_to_known_entities({"topics": ["foo", "baz"]}, wing="wing_two")
|
||||
|
||||
# Mine wing_two — should drop tunnels between wing_two and wing_one
|
||||
# for every shared topic. Just one in this case.
|
||||
project_root = tmp_path / "wing_two_project"
|
||||
project_root.mkdir()
|
||||
write_file(
|
||||
project_root / "notes.md",
|
||||
"Some prose long enough to make a chunk. " * 20,
|
||||
)
|
||||
with open(project_root / "mempalace.yaml", "w") as f:
|
||||
yaml.dump({"wing": "wing_two", "rooms": [{"name": "general"}]}, f)
|
||||
|
||||
palace_path = tmp_path / "palace"
|
||||
mine(str(project_root), str(palace_path))
|
||||
|
||||
listed = palace_graph.list_tunnels()
|
||||
assert len(listed) == 1
|
||||
rooms = {listed[0]["source"]["room"], listed[0]["target"]["room"]}
|
||||
# Topic tunnels use a ``topic:<name>`` synthetic room so they can't
|
||||
# collide with literal folder-derived rooms of the same name.
|
||||
assert rooms == {"topic:foo"}
|
||||
assert listed[0]["kind"] == "topic"
|
||||
wings = {listed[0]["source"]["wing"], listed[0]["target"]["wing"]}
|
||||
assert wings == {"wing_one", "wing_two"}
|
||||
|
||||
|
||||
def test_mine_no_tunnel_when_threshold_blocks_overlap(tmp_path, monkeypatch):
|
||||
"""Bumping ``MEMPALACE_TOPIC_TUNNEL_MIN_COUNT`` above the actual overlap
|
||||
suppresses tunnel creation."""
|
||||
from mempalace import miner, palace_graph
|
||||
|
||||
registry = tmp_path / "known_entities.json"
|
||||
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
|
||||
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
|
||||
tunnels_file = tmp_path / "tunnels.json"
|
||||
monkeypatch.setattr(palace_graph, "_TUNNEL_FILE", str(tunnels_file))
|
||||
monkeypatch.setenv("MEMPALACE_TOPIC_TUNNEL_MIN_COUNT", "2")
|
||||
|
||||
miner.add_to_known_entities({"topics": ["foo"]}, wing="wing_one")
|
||||
miner.add_to_known_entities({"topics": ["foo"]}, wing="wing_two")
|
||||
|
||||
project_root = tmp_path / "wing_two_project"
|
||||
project_root.mkdir()
|
||||
write_file(
|
||||
project_root / "notes.md",
|
||||
"Some prose long enough to make a chunk. " * 20,
|
||||
)
|
||||
with open(project_root / "mempalace.yaml", "w") as f:
|
||||
yaml.dump({"wing": "wing_two", "rooms": [{"name": "general"}]}, f)
|
||||
|
||||
palace_path = tmp_path / "palace"
|
||||
mine(str(project_root), str(palace_path))
|
||||
|
||||
# min_count=2 but only 1 shared topic → no tunnel.
|
||||
assert palace_graph.list_tunnels() == []
|
||||
|
||||
|
||||
def test_mine_no_tunnel_when_only_one_wing_has_topics(tmp_path, monkeypatch):
|
||||
"""A wing in isolation (no other wing has confirmed topics) creates no tunnels."""
|
||||
from mempalace import miner, palace_graph
|
||||
|
||||
registry = tmp_path / "known_entities.json"
|
||||
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
|
||||
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
|
||||
tunnels_file = tmp_path / "tunnels.json"
|
||||
monkeypatch.setattr(palace_graph, "_TUNNEL_FILE", str(tunnels_file))
|
||||
|
||||
miner.add_to_known_entities({"topics": ["foo"]}, wing="wing_one")
|
||||
|
||||
project_root = tmp_path / "wing_one_project"
|
||||
project_root.mkdir()
|
||||
write_file(
|
||||
project_root / "notes.md",
|
||||
"Some prose long enough to make a chunk. " * 20,
|
||||
)
|
||||
with open(project_root / "mempalace.yaml", "w") as f:
|
||||
yaml.dump({"wing": "wing_one", "rooms": [{"name": "general"}]}, f)
|
||||
|
||||
palace_path = tmp_path / "palace"
|
||||
mine(str(project_root), str(palace_path))
|
||||
|
||||
assert palace_graph.list_tunnels() == []
|
||||
|
||||
|
||||
# ── graceful Ctrl-C handling (#1182) ────────────────────────────────────
|
||||
|
||||
|
||||
def _make_minable_project(project_root: Path, n_files: int = 3) -> None:
|
||||
"""Create a tiny project with N readable files + a config so mine() runs."""
|
||||
for idx in range(n_files):
|
||||
write_file(
|
||||
project_root / f"f{idx}.py",
|
||||
f"def fn_{idx}():\n print('hi {idx}')\n" * 20,
|
||||
)
|
||||
with open(project_root / "mempalace.yaml", "w") as f:
|
||||
yaml.dump(
|
||||
{
|
||||
"wing": "interrupt_test",
|
||||
"rooms": [{"name": "general", "description": "General"}],
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
|
||||
def test_mine_keyboard_interrupt_prints_summary_and_exits_130(tmp_path, capsys):
|
||||
"""A KeyboardInterrupt mid-loop produces the clean summary + exit 130."""
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
project_root = tmp_path / "proj"
|
||||
project_root.mkdir()
|
||||
_make_minable_project(project_root, n_files=4)
|
||||
palace_path = project_root / "palace"
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_process_file(*args, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 2:
|
||||
raise KeyboardInterrupt
|
||||
return (1, "general")
|
||||
|
||||
with patch("mempalace.miner.process_file", side_effect=fake_process_file):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
mine(str(project_root), str(palace_path))
|
||||
|
||||
assert exc_info.value.code == 130
|
||||
out = capsys.readouterr().out
|
||||
assert "Mine interrupted." in out
|
||||
assert "files_processed: 1/" in out
|
||||
assert "drawers_filed:" in out
|
||||
assert "last_file:" in out
|
||||
assert "upserted idempotently" in out
|
||||
|
||||
|
||||
def test_mine_keyboard_interrupt_quotes_path_with_spaces_in_resume_hint(tmp_path, capsys):
|
||||
"""Resume hint must shell-quote the project dir so a path containing
|
||||
spaces / metacharacters yields a copy-paste-safe `mempalace mine ...`
|
||||
command. Otherwise users on a path like "My Project" hit a broken
|
||||
invocation when they re-run after Ctrl-C."""
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
project_root = tmp_path / "my project"
|
||||
project_root.mkdir()
|
||||
_make_minable_project(project_root, n_files=2)
|
||||
palace_path = project_root / "palace"
|
||||
|
||||
def fake_process_file(*args, **kwargs):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
with patch("mempalace.miner.process_file", side_effect=fake_process_file):
|
||||
with pytest.raises(SystemExit):
|
||||
mine(str(project_root), str(palace_path))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
# Use shlex.quote so the assertion matches whatever the production
|
||||
# code emits on this platform (POSIX paths with spaces vs Windows
|
||||
# paths with backslashes both end up wrapped in single quotes).
|
||||
assert f"mempalace mine {shlex.quote(str(project_root))}" in out
|
||||
|
||||
|
||||
def test_skip_filenames_includes_lockfiles():
|
||||
"""pnpm-lock.yaml and yarn.lock must be skipped alongside package-lock.json
|
||||
so a Windows mine over a typical JS monorepo doesn't OOM the ONNX embedder
|
||||
on a 24K-line lockfile (#1296)."""
|
||||
from mempalace import miner
|
||||
|
||||
assert "package-lock.json" in miner.SKIP_FILENAMES
|
||||
assert "pnpm-lock.yaml" in miner.SKIP_FILENAMES
|
||||
assert "yarn.lock" in miner.SKIP_FILENAMES
|
||||
|
||||
|
||||
def test_process_file_skips_when_chunks_exceed_max(tmp_path, monkeypatch):
|
||||
"""A file producing more than MAX_CHUNKS_PER_FILE chunks must be skipped
|
||||
with a clear message and zero upserts. Generated artifacts (CSVs, lock
|
||||
files not in SKIP_FILENAMES) hit this — the cap is what prevents ONNX
|
||||
bad_alloc on Windows when the embedder is asked to swallow thousands of
|
||||
chunks in one batch (#1296)."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from mempalace import miner
|
||||
|
||||
monkeypatch.setattr(miner, "MAX_CHUNKS_PER_FILE", 5)
|
||||
over_cap = [{"content": f"chunk {i}", "chunk_index": i} for i in range(7)]
|
||||
monkeypatch.setattr(miner, "chunk_text", lambda content, source_file: over_cap)
|
||||
|
||||
source = tmp_path / "huge.csv"
|
||||
source.write_text("col1,col2\n" + "x,y\n" * 500, encoding="utf-8")
|
||||
col = MagicMock()
|
||||
col.get.return_value = {"ids": []}
|
||||
|
||||
drawers, room = miner.process_file(
|
||||
source,
|
||||
tmp_path,
|
||||
col,
|
||||
"wing",
|
||||
[{"name": "general", "description": "General"}],
|
||||
"agent",
|
||||
False,
|
||||
)
|
||||
|
||||
assert drawers == 0
|
||||
col.upsert.assert_not_called()
|
||||
|
||||
|
||||
def test_mine_arbitrary_exception_prints_summary_and_reraises(tmp_path, capsys):
|
||||
"""A non-KeyboardInterrupt exception mid-mine must surface a summary
|
||||
banner before propagating, so users don't see a silent exit-0 with no
|
||||
completion message (#1296 Failure 2). Re-raise preserves the traceback
|
||||
and yields a non-zero exit code."""
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
project_root = tmp_path / "proj"
|
||||
project_root.mkdir()
|
||||
_make_minable_project(project_root, n_files=4)
|
||||
palace_path = project_root / "palace"
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_process_file(*args, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 2:
|
||||
raise RuntimeError("simulated ONNX bad_alloc")
|
||||
return (1, "general")
|
||||
|
||||
with patch("mempalace.miner.process_file", side_effect=fake_process_file):
|
||||
with pytest.raises(RuntimeError, match="simulated ONNX bad_alloc"):
|
||||
mine(str(project_root), str(palace_path))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Mine aborted by exception." in out
|
||||
assert "files_processed: 1/" in out
|
||||
assert "drawers_filed:" in out
|
||||
assert "RuntimeError: simulated ONNX bad_alloc" in out
|
||||
assert "upserted idempotently" in out
|
||||
|
||||
|
||||
def test_mine_cleans_up_pid_file_on_interrupt(tmp_path):
|
||||
"""Our own per-target PID slot is removed in the finally clause."""
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
project_root = tmp_path / "proj"
|
||||
project_root.mkdir()
|
||||
_make_minable_project(project_root, n_files=2)
|
||||
palace_path = project_root / "palace"
|
||||
|
||||
pid_file = tmp_path / "mine_abc.pid"
|
||||
pid_file.write_text(str(os.getpid()))
|
||||
|
||||
def fake_process_file(*args, **kwargs):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
# The mine subprocess receives its slot path via env var; the cleanup
|
||||
# hook in miner.py reads that var and removes the slot if it matches.
|
||||
with (
|
||||
patch.dict(os.environ, {"MEMPALACE_MINE_PID_FILE": str(pid_file)}),
|
||||
patch("mempalace.miner.process_file", side_effect=fake_process_file),
|
||||
):
|
||||
with pytest.raises(SystemExit):
|
||||
mine(str(project_root), str(palace_path))
|
||||
|
||||
assert not pid_file.exists(), "Our PID entry should be cleaned up on interrupt"
|
||||
|
||||
|
||||
def test_mine_cleans_up_pid_file_on_clean_exit(tmp_path):
|
||||
"""Successful mine also removes its own per-target PID slot."""
|
||||
from unittest.mock import patch
|
||||
|
||||
project_root = tmp_path / "proj"
|
||||
project_root.mkdir()
|
||||
_make_minable_project(project_root, n_files=1)
|
||||
palace_path = project_root / "palace"
|
||||
|
||||
pid_file = tmp_path / "mine_abc.pid"
|
||||
pid_file.write_text(str(os.getpid()))
|
||||
|
||||
with patch.dict(os.environ, {"MEMPALACE_MINE_PID_FILE": str(pid_file)}):
|
||||
mine(str(project_root), str(palace_path))
|
||||
|
||||
assert not pid_file.exists()
|
||||
|
||||
|
||||
def test_mine_does_not_remove_other_processes_pid_file(tmp_path):
|
||||
"""A PID slot pointing at someone else's PID is left untouched."""
|
||||
from unittest.mock import patch
|
||||
|
||||
project_root = tmp_path / "proj"
|
||||
project_root.mkdir()
|
||||
_make_minable_project(project_root, n_files=1)
|
||||
palace_path = project_root / "palace"
|
||||
|
||||
other_pid = os.getpid() + 999_999 # a PID that isn't us
|
||||
pid_file = tmp_path / "mine_abc.pid"
|
||||
pid_file.write_text(str(other_pid))
|
||||
|
||||
with patch.dict(os.environ, {"MEMPALACE_MINE_PID_FILE": str(pid_file)}):
|
||||
mine(str(project_root), str(palace_path))
|
||||
|
||||
assert pid_file.exists(), "Foreign PID entries must not be removed"
|
||||
assert pid_file.read_text().strip() == str(other_pid)
|
||||
|
||||
@@ -11,6 +11,7 @@ from mempalace.normalize import (
|
||||
_try_claude_ai_json,
|
||||
_try_claude_code_jsonl,
|
||||
_try_codex_jsonl,
|
||||
_try_gemini_jsonl,
|
||||
_try_normalize_json,
|
||||
_try_slack_json,
|
||||
normalize,
|
||||
@@ -450,6 +451,168 @@ def test_codex_jsonl_payload_not_dict():
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ── _try_gemini_jsonl ──────────────────────────────────────────────────
|
||||
#
|
||||
# Gemini CLI sessions live at ``~/.gemini/tmp/<project_hash>/chats/`` as
|
||||
# JSONL. The schema (per google-gemini/gemini-cli#15292):
|
||||
#
|
||||
# {"type":"session_metadata","sessionId":"...","projectHash":"...",...}
|
||||
# {"type":"user","id":"msg1","content":[{"text":"Hello"}]}
|
||||
# {"type":"gemini","id":"msg2","content":[{"text":"Hi"}]}
|
||||
# {"type":"message_update","id":"msg2","tokens":{"input":10,"output":5}}
|
||||
#
|
||||
# Detection requires a ``session_metadata`` record so this parser does
|
||||
# not false-positive against Claude Code or Codex JSONL. ``message_update``
|
||||
# entries (token-count deltas only) are skipped — they carry no message
|
||||
# text. ``content`` is an array of ``{"text": "..."}`` blocks; we join
|
||||
# all text blocks for a given message.
|
||||
|
||||
|
||||
def test_gemini_jsonl_valid():
|
||||
lines = [
|
||||
json.dumps({"type": "session_metadata", "sessionId": "abc", "projectHash": "h"}),
|
||||
json.dumps({"type": "user", "id": "m1", "content": [{"text": "Hello"}]}),
|
||||
json.dumps({"type": "gemini", "id": "m2", "content": [{"text": "Hi there"}]}),
|
||||
]
|
||||
result = _try_gemini_jsonl("\n".join(lines))
|
||||
assert result is not None
|
||||
assert "> Hello" in result
|
||||
assert "Hi there" in result
|
||||
|
||||
|
||||
def test_gemini_jsonl_multi_turn():
|
||||
lines = [
|
||||
json.dumps({"type": "session_metadata", "sessionId": "s"}),
|
||||
json.dumps({"type": "user", "content": [{"text": "Q1"}]}),
|
||||
json.dumps({"type": "gemini", "content": [{"text": "A1"}]}),
|
||||
json.dumps({"type": "user", "content": [{"text": "Q2"}]}),
|
||||
json.dumps({"type": "gemini", "content": [{"text": "A2"}]}),
|
||||
]
|
||||
result = _try_gemini_jsonl("\n".join(lines))
|
||||
assert result is not None
|
||||
assert "> Q1" in result
|
||||
assert "A1" in result
|
||||
assert "> Q2" in result
|
||||
assert "A2" in result
|
||||
|
||||
|
||||
def test_gemini_jsonl_no_session_metadata():
|
||||
"""Without session_metadata, parser returns None — guards against false
|
||||
positives on Claude Code / Codex JSONL passed through the dispatch chain."""
|
||||
lines = [
|
||||
json.dumps({"type": "user", "content": [{"text": "Hi"}]}),
|
||||
json.dumps({"type": "gemini", "content": [{"text": "Hello"}]}),
|
||||
]
|
||||
result = _try_gemini_jsonl("\n".join(lines))
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_gemini_jsonl_skips_message_update():
|
||||
"""message_update records carry only token counts — must be ignored,
|
||||
not turned into empty drawers or duplicated assistant turns."""
|
||||
lines = [
|
||||
json.dumps({"type": "session_metadata"}),
|
||||
json.dumps({"type": "user", "content": [{"text": "Q"}]}),
|
||||
json.dumps({"type": "gemini", "content": [{"text": "A"}]}),
|
||||
json.dumps({"type": "message_update", "id": "m2", "tokens": {"input": 10, "output": 5}}),
|
||||
]
|
||||
result = _try_gemini_jsonl("\n".join(lines))
|
||||
assert result is not None
|
||||
assert "tokens" not in result
|
||||
assert "input" not in result
|
||||
|
||||
|
||||
def test_gemini_jsonl_too_few_messages():
|
||||
"""Mirror codex/claude_code behavior: < 2 conversational messages = None."""
|
||||
lines = [
|
||||
json.dumps({"type": "session_metadata"}),
|
||||
json.dumps({"type": "user", "content": [{"text": "only one msg"}]}),
|
||||
]
|
||||
result = _try_gemini_jsonl("\n".join(lines))
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_gemini_jsonl_multi_block_content():
|
||||
"""A single message can have multiple text blocks in its content array
|
||||
(e.g. a thinking block + a final answer). Both should be concatenated
|
||||
into one transcript turn, in order."""
|
||||
lines = [
|
||||
json.dumps({"type": "session_metadata"}),
|
||||
json.dumps({"type": "user", "content": [{"text": "Q"}]}),
|
||||
json.dumps(
|
||||
{
|
||||
"type": "gemini",
|
||||
"content": [{"text": "First part."}, {"text": "Second part."}],
|
||||
}
|
||||
),
|
||||
]
|
||||
result = _try_gemini_jsonl("\n".join(lines))
|
||||
assert result is not None
|
||||
assert "First part." in result
|
||||
assert "Second part." in result
|
||||
|
||||
|
||||
def test_gemini_jsonl_empty_content_skipped():
|
||||
"""A message whose content array yields no text should be skipped, not
|
||||
emit an empty turn that would corrupt the transcript."""
|
||||
lines = [
|
||||
json.dumps({"type": "session_metadata"}),
|
||||
json.dumps({"type": "user", "content": []}),
|
||||
json.dumps({"type": "user", "content": [{"text": "real Q"}]}),
|
||||
json.dumps({"type": "gemini", "content": [{"text": "real A"}]}),
|
||||
]
|
||||
result = _try_gemini_jsonl("\n".join(lines))
|
||||
assert result is not None
|
||||
assert "> real Q" in result
|
||||
assert "real A" in result
|
||||
|
||||
|
||||
def test_gemini_jsonl_invalid_json_lines_skipped():
|
||||
"""A malformed line in the middle of the stream must not abort parsing —
|
||||
the rest of the session should still produce a transcript."""
|
||||
lines = [
|
||||
json.dumps({"type": "session_metadata"}),
|
||||
"not-valid-json{",
|
||||
json.dumps({"type": "user", "content": [{"text": "Q"}]}),
|
||||
json.dumps({"type": "gemini", "content": [{"text": "A"}]}),
|
||||
]
|
||||
result = _try_gemini_jsonl("\n".join(lines))
|
||||
assert result is not None
|
||||
assert "> Q" in result
|
||||
|
||||
|
||||
def test_gemini_jsonl_does_not_match_codex():
|
||||
"""Codex JSONL passed in must NOT be parsed by the gemini adapter — the
|
||||
dispatch chain in _try_normalize_json relies on each adapter returning
|
||||
None when it doesn't recognize a format."""
|
||||
lines = [
|
||||
json.dumps({"type": "session_meta", "payload": {}}),
|
||||
json.dumps({"type": "event_msg", "payload": {"type": "user_message", "message": "Q"}}),
|
||||
json.dumps({"type": "event_msg", "payload": {"type": "agent_message", "message": "A"}}),
|
||||
]
|
||||
result = _try_gemini_jsonl("\n".join(lines))
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_gemini_jsonl_messages_before_session_metadata_discarded():
|
||||
"""user/gemini turns that appear before the session_metadata sentinel must
|
||||
be silently discarded, not counted as conversational messages. Only turns
|
||||
after the sentinel contribute to the transcript."""
|
||||
lines = [
|
||||
json.dumps({"type": "user", "content": [{"text": "preamble Q"}]}),
|
||||
json.dumps({"type": "gemini", "content": [{"text": "preamble A"}]}),
|
||||
json.dumps({"type": "session_metadata", "sessionId": "s"}),
|
||||
json.dumps({"type": "user", "content": [{"text": "real Q"}]}),
|
||||
json.dumps({"type": "gemini", "content": [{"text": "real A"}]}),
|
||||
]
|
||||
result = _try_gemini_jsonl("\n".join(lines))
|
||||
assert result is not None
|
||||
assert "preamble Q" not in result
|
||||
assert "preamble A" not in result
|
||||
assert "> real Q" in result
|
||||
assert "real A" in result
|
||||
|
||||
|
||||
# ── _try_claude_ai_json ───────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,27 @@ class TestBuildGraph:
|
||||
assert nodes == {}
|
||||
assert edges == []
|
||||
|
||||
def test_none_metadata_does_not_crash(self):
|
||||
"""ChromaDB can return None for drawers without metadata (legacy
|
||||
data, partial writes — upstream #1020 territory). build_graph
|
||||
must skip None entries silently rather than crash the whole
|
||||
graph build with AttributeError. Caught 2026-04-25 by
|
||||
palace-daemon's verify-routes.sh smoke test against the
|
||||
canonical 151K palace; /stats was 500-ing on a single None
|
||||
drawer and taking out every consumer of build_graph for the
|
||||
whole call path."""
|
||||
col = _make_fake_collection(
|
||||
[
|
||||
{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-01"},
|
||||
None, # legacy / partial-write drawer with no metadata
|
||||
{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-02"},
|
||||
]
|
||||
)
|
||||
nodes, edges = build_graph(col=col)
|
||||
# The two real drawers were processed; the None one was skipped.
|
||||
assert "auth" in nodes
|
||||
assert nodes["auth"]["count"] == 2
|
||||
|
||||
def test_single_wing_no_edges(self):
|
||||
col = _make_fake_collection(
|
||||
[
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Tests for explicit tunnel helpers in mempalace.palace_graph."""
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -37,8 +40,40 @@ class TestTunnelStorage:
|
||||
palace_graph._save_tunnels(tunnels)
|
||||
assert palace_graph._load_tunnels() == tunnels
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="POSIX file-permission bits only apply on Unix-like systems",
|
||||
)
|
||||
def test_save_tunnels_restricts_permissions(self, tmp_path, monkeypatch):
|
||||
"""Regression for #1165 — tunnels.json reveals cross-wing links and
|
||||
must not be world-readable on shared Linux/multi-user systems."""
|
||||
tunnel_file = _use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
palace_graph._save_tunnels(
|
||||
[
|
||||
{
|
||||
"id": "x",
|
||||
"source": {"wing": "a", "room": "r1"},
|
||||
"target": {"wing": "b", "room": "r2"},
|
||||
"label": "",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
file_mode = stat.S_IMODE(os.stat(tunnel_file).st_mode)
|
||||
assert file_mode == 0o600, f"tunnels.json mode is {oct(file_mode)}, expected 0o600"
|
||||
|
||||
parent_mode = stat.S_IMODE(os.stat(tunnel_file.parent).st_mode)
|
||||
assert (
|
||||
parent_mode == 0o700
|
||||
), f"tunnels.json parent dir mode is {oct(parent_mode)}, expected 0o700"
|
||||
|
||||
|
||||
class TestExplicitTunnels:
|
||||
def test_normalize_wing_uses_shared_rule_and_trims_empty(self):
|
||||
assert palace_graph._normalize_wing(" Mempalace-Public ") == "mempalace_public"
|
||||
assert palace_graph._normalize_wing(" ") is None
|
||||
assert palace_graph._normalize_wing(None) is None
|
||||
|
||||
def test_create_tunnel_deduplicates_reverse_order_and_updates_label(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
@@ -135,3 +170,212 @@ class TestExplicitTunnels:
|
||||
connections = palace_graph.follow_tunnels("wing_code", "auth", col=col)
|
||||
assert len(connections) == 1
|
||||
assert "drawer_preview" not in connections[0]
|
||||
|
||||
|
||||
class TestTopicTunnels:
|
||||
"""Cross-wing topic tunnels (issue #1180).
|
||||
|
||||
When two wings share confirmed TOPIC labels above a configurable
|
||||
threshold, a symmetric tunnel is created between them. Tunnels are
|
||||
routed through the existing ``create_tunnel`` storage so they share
|
||||
dedup and persistence with explicit tunnels.
|
||||
"""
|
||||
|
||||
def test_compute_topic_tunnels_creates_link_for_shared_topic(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
topics_by_wing = {
|
||||
"wing_alpha": ["Angular", "OpenAPI"],
|
||||
"wing_beta": ["OpenAPI", "Kubernetes"],
|
||||
}
|
||||
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
|
||||
assert len(created) == 1
|
||||
assert created[0]["source"]["wing"] in {"wing_alpha", "wing_beta"}
|
||||
assert created[0]["target"]["wing"] in {"wing_alpha", "wing_beta"}
|
||||
# Room is namespaced with the ``topic:`` prefix so it can't collide
|
||||
# with a literal folder-derived room of the same name. Casing of the
|
||||
# topic is preserved for display.
|
||||
assert created[0]["source"]["room"] == "topic:OpenAPI"
|
||||
assert created[0]["target"]["room"] == "topic:OpenAPI"
|
||||
assert created[0]["kind"] == "topic"
|
||||
# Label carries the human-readable topic without the prefix.
|
||||
assert "OpenAPI" in created[0]["label"]
|
||||
assert "topic:OpenAPI" not in created[0]["label"]
|
||||
|
||||
# Tunnel is retrievable via the standard list_tunnels API.
|
||||
listed = palace_graph.list_tunnels()
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["id"] == created[0]["id"]
|
||||
|
||||
def test_compute_topic_tunnels_no_link_below_threshold(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
topics_by_wing = {
|
||||
"wing_alpha": ["Angular", "OpenAPI"],
|
||||
"wing_beta": ["OpenAPI", "Kubernetes"],
|
||||
}
|
||||
# min_count=2 requires two overlapping topics — only one shared.
|
||||
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=2)
|
||||
assert created == []
|
||||
assert palace_graph.list_tunnels() == []
|
||||
|
||||
def test_compute_topic_tunnels_above_threshold_creates_per_topic_links(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
topics_by_wing = {
|
||||
"wing_alpha": ["Angular", "OpenAPI", "Postgres"],
|
||||
"wing_beta": ["Angular", "OpenAPI", "Redis"],
|
||||
}
|
||||
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=2)
|
||||
# Two shared topics × one wing pair = two tunnels.
|
||||
rooms = sorted(t["source"]["room"] for t in created)
|
||||
assert rooms == ["topic:Angular", "topic:OpenAPI"]
|
||||
|
||||
def test_compute_topic_tunnels_case_insensitive_overlap(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
topics_by_wing = {
|
||||
"wing_alpha": ["openapi"],
|
||||
"wing_beta": ["OpenAPI"],
|
||||
}
|
||||
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
|
||||
assert len(created) == 1
|
||||
|
||||
def test_compute_topic_tunnels_empty_input_is_noop(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
assert palace_graph.compute_topic_tunnels({}) == []
|
||||
assert palace_graph.compute_topic_tunnels({"wing_a": []}) == []
|
||||
assert palace_graph.list_tunnels() == []
|
||||
|
||||
def test_compute_topic_tunnels_three_wings_pairwise(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
topics_by_wing = {
|
||||
"wing_a": ["foo"],
|
||||
"wing_b": ["foo"],
|
||||
"wing_c": ["foo"],
|
||||
}
|
||||
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
|
||||
# 3 wings sharing the same topic → C(3,2) = 3 pairs → 3 tunnels.
|
||||
assert len(created) == 3
|
||||
endpoint_pairs = {
|
||||
tuple(sorted([t["source"]["wing"], t["target"]["wing"]])) for t in created
|
||||
}
|
||||
assert endpoint_pairs == {
|
||||
("wing_a", "wing_b"),
|
||||
("wing_a", "wing_c"),
|
||||
("wing_b", "wing_c"),
|
||||
}
|
||||
|
||||
def test_topic_tunnels_for_wing_only_links_that_wing(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
topics_by_wing = {
|
||||
"wing_a": ["foo", "bar"],
|
||||
"wing_b": ["foo"],
|
||||
"wing_c": ["bar"],
|
||||
}
|
||||
# wing_a should link to both b (via foo) and c (via bar).
|
||||
created = palace_graph.topic_tunnels_for_wing("wing_a", topics_by_wing)
|
||||
endpoint_pairs = {
|
||||
tuple(sorted([t["source"]["wing"], t["target"]["wing"]])) for t in created
|
||||
}
|
||||
assert endpoint_pairs == {("wing_a", "wing_b"), ("wing_a", "wing_c")}
|
||||
# The b-c pair is NOT created because wing_a's incremental pass
|
||||
# only computes pairs that include wing_a.
|
||||
assert len(palace_graph.list_tunnels()) == 2
|
||||
|
||||
def test_topic_tunnels_for_wing_unknown_wing_is_noop(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
topics_by_wing = {"wing_a": ["foo"], "wing_b": ["foo"]}
|
||||
assert palace_graph.topic_tunnels_for_wing("wing_missing", topics_by_wing) == []
|
||||
assert palace_graph.list_tunnels() == []
|
||||
|
||||
def test_compute_topic_tunnels_dedupe_on_recompute(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
topics_by_wing = {
|
||||
"wing_alpha": ["OpenAPI"],
|
||||
"wing_beta": ["OpenAPI"],
|
||||
}
|
||||
first = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
|
||||
second = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
|
||||
# create_tunnel is symmetric/dedupe — repeated computation should
|
||||
# not multiply the stored tunnels.
|
||||
assert first[0]["id"] == second[0]["id"]
|
||||
assert len(palace_graph.list_tunnels()) == 1
|
||||
|
||||
def test_topic_tunnel_room_does_not_collide_with_literal_room(self, tmp_path, monkeypatch):
|
||||
"""Regression: a literal "Angular" folder-room and a topic tunnel
|
||||
for "Angular" must resolve to distinct endpoints so ``follow_tunnels``
|
||||
from the real room doesn't accidentally surface topic connections
|
||||
(issue raised in review of #1184)."""
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
|
||||
# Explicit tunnel anchored at a literal "Angular" room in wing_alpha.
|
||||
palace_graph.create_tunnel(
|
||||
"wing_alpha", "Angular", "wing_gamma", "frontend", label="explicit"
|
||||
)
|
||||
# Topic tunnel between the same wings that share the "Angular" topic.
|
||||
palace_graph.compute_topic_tunnels(
|
||||
{"wing_alpha": ["Angular"], "wing_beta": ["Angular"]}, min_count=1
|
||||
)
|
||||
|
||||
# follow_tunnels on the literal Angular room only sees the explicit link.
|
||||
literal = palace_graph.follow_tunnels("wing_alpha", "Angular")
|
||||
assert len(literal) == 1
|
||||
assert literal[0]["connected_wing"] == "wing_gamma"
|
||||
|
||||
# The topic tunnel is stored under the namespaced room.
|
||||
topical = palace_graph.follow_tunnels("wing_alpha", "topic:Angular")
|
||||
assert len(topical) == 1
|
||||
assert topical[0]["connected_wing"] == "wing_beta"
|
||||
|
||||
def test_topic_tunnels_carry_kind_field(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
palace_graph.create_tunnel("wing_a", "auth", "wing_b", "users", label="x")
|
||||
palace_graph.compute_topic_tunnels({"wing_a": ["Redis"], "wing_b": ["Redis"]}, min_count=1)
|
||||
|
||||
tunnels = palace_graph.list_tunnels()
|
||||
kinds = sorted(t["kind"] for t in tunnels)
|
||||
assert kinds == ["explicit", "topic"]
|
||||
|
||||
|
||||
class TestHyphenatedWingNormalization:
|
||||
"""Wing names with hyphens or spaces are normalized to underscores on init.
|
||||
|
||||
Tunnel helpers must apply the same normalization at lookup time so that
|
||||
``mempalace-public`` resolves to ``mempalace_public`` and matches the
|
||||
metadata written by ``room_detector_local.py``.
|
||||
"""
|
||||
|
||||
def test_list_tunnels_filters_hyphenated_wing(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
|
||||
palace_graph.create_tunnel("mempalace_public", "auth", "wing_people", "users")
|
||||
|
||||
assert len(palace_graph.list_tunnels("mempalace-public")) == 1
|
||||
assert len(palace_graph.list_tunnels("mempalace_public")) == 1
|
||||
|
||||
def test_follow_tunnels_matches_hyphenated_wing(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
|
||||
palace_graph.create_tunnel("mempalace_public", "auth", "wing_people", "users")
|
||||
|
||||
by_hyphen = palace_graph.follow_tunnels("mempalace-public", "auth")
|
||||
by_under = palace_graph.follow_tunnels("mempalace_public", "auth")
|
||||
assert len(by_hyphen) == 1
|
||||
assert len(by_under) == 1
|
||||
assert by_hyphen[0]["connected_wing"] == "wing_people"
|
||||
|
||||
def test_create_tunnel_normalizes_wing_names(self, tmp_path, monkeypatch):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
|
||||
t = palace_graph.create_tunnel("my-project", "src", "your-project", "dst", label="cross")
|
||||
assert t["source"]["wing"] == "my_project"
|
||||
assert t["target"]["wing"] == "your_project"
|
||||
assert len(palace_graph.list_tunnels("my_project")) == 1
|
||||
assert len(palace_graph.list_tunnels("my-project")) == 1
|
||||
|
||||
def test_find_tunnels_warns_on_empty_result(self, tmp_path, monkeypatch, caplog):
|
||||
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||
# No data in collection, so build_graph returns empty nodes
|
||||
with caplog.at_level("WARNING", logger="mempalace_graph"):
|
||||
result = palace_graph.find_tunnels("nonexistent-wing")
|
||||
assert result == []
|
||||
assert "No tunnels found" in caplog.text
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Tests for mine_palace_lock — the per-palace non-blocking mine guard.
|
||||
|
||||
Covers the fix for the runaway mine fan-out described alongside issues
|
||||
#974 and #965: if N copies of `mempalace mine` are spawned concurrently
|
||||
against the same palace, they must collapse to a single runner rather
|
||||
than queue as waiters that will drive parallel HNSW inserts. Mines
|
||||
against *different* palaces must still be free to run in parallel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from mempalace.palace import (
|
||||
MineAlreadyRunning,
|
||||
mine_global_lock,
|
||||
mine_palace_lock,
|
||||
)
|
||||
|
||||
|
||||
def _get_mp_context():
|
||||
"""Always use ``spawn`` — ``fork`` deadlocks under modern Python.
|
||||
|
||||
The parent (pytest + chromadb + onnxruntime) is multi-threaded by the time
|
||||
these tests run. ``fork`` snapshots that state into the child without the
|
||||
threads that hold the locks, which Python 3.13 explicitly warns about and
|
||||
which deadlocks the CI runners. macOS additionally forbids
|
||||
fork-without-exec via CoreFoundation. ``spawn`` re-imports the package in
|
||||
the child (slower, but safe) and inherits ``os.environ`` — including the
|
||||
monkeypatched ``HOME`` — which is all these lock-file tests need.
|
||||
"""
|
||||
return multiprocessing.get_context("spawn")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hold_lock(palace_path: str, ready_flag: str, release_flag: str) -> int:
|
||||
"""Acquire mine_palace_lock, signal readiness, wait for release flag.
|
||||
|
||||
Returns 0 if we acquired the lock, 1 if MineAlreadyRunning was raised.
|
||||
Runs in a child process for true cross-process locking semantics.
|
||||
"""
|
||||
try:
|
||||
with mine_palace_lock(palace_path):
|
||||
# Tell the parent we hold the lock
|
||||
open(ready_flag, "w").close()
|
||||
# Wait until parent tells us to release
|
||||
for _ in range(500):
|
||||
if os.path.exists(release_flag):
|
||||
return 0
|
||||
time.sleep(0.01)
|
||||
return 0
|
||||
except MineAlreadyRunning:
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_single_acquire_succeeds(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
with mine_palace_lock(str(tmp_path / "palace")):
|
||||
pass # should not raise
|
||||
|
||||
|
||||
def test_lock_reusable_after_release(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
palace = str(tmp_path / "palace")
|
||||
with mine_palace_lock(palace):
|
||||
pass
|
||||
# Re-acquire must succeed now that the previous holder released
|
||||
with mine_palace_lock(palace):
|
||||
pass
|
||||
|
||||
|
||||
def test_same_palace_serializes_across_processes(tmp_path, monkeypatch):
|
||||
"""Two processes contending for the same palace: second must be rejected."""
|
||||
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:
|
||||
# Wait for the holder to acquire
|
||||
for _ in range(500):
|
||||
if os.path.exists(ready):
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert os.path.exists(ready), "holder failed to acquire lock in time"
|
||||
|
||||
# From the parent, we must not be able to acquire the same palace lock
|
||||
with pytest.raises(MineAlreadyRunning):
|
||||
with mine_palace_lock(palace):
|
||||
pytest.fail("second acquire of same palace should have raised")
|
||||
finally:
|
||||
open(release, "w").close()
|
||||
holder.join(timeout=5)
|
||||
assert holder.exitcode == 0
|
||||
|
||||
|
||||
def test_different_palaces_dont_conflict(tmp_path, monkeypatch):
|
||||
"""Mines against different palaces must NOT block each other."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
palace_a = str(tmp_path / "palace_a")
|
||||
palace_b = str(tmp_path / "palace_b")
|
||||
ready = str(tmp_path / "ready_a")
|
||||
release = str(tmp_path / "release_a")
|
||||
|
||||
ctx = _get_mp_context()
|
||||
holder = ctx.Process(target=_hold_lock, args=(palace_a, 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 in time"
|
||||
|
||||
# Different palace — must succeed even while palace_a is held
|
||||
with mine_palace_lock(palace_b):
|
||||
pass # no exception expected
|
||||
finally:
|
||||
open(release, "w").close()
|
||||
holder.join(timeout=5)
|
||||
|
||||
|
||||
def test_palace_path_is_normalized(tmp_path, monkeypatch):
|
||||
"""Relative and absolute forms of the same path must use the same lock.
|
||||
|
||||
Cross-process variant: a child holds the absolute form, a relative form
|
||||
in the parent must hash to the same lock key and raise
|
||||
``MineAlreadyRunning``. (The same-thread case is now a re-entrant
|
||||
pass-through by design — see ``test_reentrant_same_thread_passes_through``
|
||||
— so we exercise the normalization invariant across a process boundary
|
||||
where re-entrance does not apply.)
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
os.makedirs(tmp_path / "palace", exist_ok=True)
|
||||
absolute = 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=(absolute, 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 in time"
|
||||
|
||||
# Parent holds CWD = tmp_path so "palace" is the same on-disk dir as
|
||||
# the absolute form. The lock key is sha256(realpath+normcase) so the
|
||||
# two forms must collide.
|
||||
with pytest.raises(MineAlreadyRunning):
|
||||
with mine_palace_lock("palace"):
|
||||
pytest.fail("normalized path collision should have raised")
|
||||
finally:
|
||||
open(release, "w").close()
|
||||
holder.join(timeout=5)
|
||||
|
||||
|
||||
def test_reentrant_same_thread_passes_through(tmp_path, monkeypatch):
|
||||
"""Same thread re-acquiring the same palace lock must not deadlock or raise.
|
||||
|
||||
This is the invariant that makes ``ChromaCollection`` write methods
|
||||
(which take ``mine_palace_lock`` for MCP/direct-writer protection)
|
||||
compose with ``miner.mine()`` (which already holds the lock for the
|
||||
entire mine pipeline). Without the per-thread re-entrant guard the inner
|
||||
acquire would self-deadlock on the outer flock.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
palace = str(tmp_path / "palace")
|
||||
with mine_palace_lock(palace):
|
||||
# Re-enter from the same thread — must yield without raising or hanging.
|
||||
with mine_palace_lock(palace):
|
||||
pass
|
||||
# After the inner exits, the outer is still held. Use spawn so the
|
||||
# child does not inherit the parent's open lock fd or SQLite/Chroma
|
||||
# process state from the full test suite.
|
||||
ctx = _get_mp_context()
|
||||
result_q = ctx.Queue()
|
||||
child = ctx.Process(target=_try_acquire_expect_busy, args=(palace, result_q))
|
||||
try:
|
||||
child.start()
|
||||
assert (
|
||||
result_q.get(timeout=10) == "busy"
|
||||
), "outer lock should still be held by parent after inner re-entrant exit"
|
||||
child.join(timeout=5)
|
||||
assert child.exitcode == 0
|
||||
finally:
|
||||
if child.is_alive():
|
||||
child.terminate()
|
||||
child.join(timeout=5)
|
||||
|
||||
|
||||
def _try_acquire_expect_busy(palace_path, result_q):
|
||||
"""Helper: try to acquire, push 'busy' (raised) or 'free' (acquired) into queue."""
|
||||
try:
|
||||
with mine_palace_lock(palace_path):
|
||||
result_q.put("free")
|
||||
except MineAlreadyRunning:
|
||||
result_q.put("busy")
|
||||
|
||||
|
||||
def _hold_lock_send_pid(palace_path: str, ready_flag: str, release_flag: str, pid_q) -> None:
|
||||
"""Acquire the lock, push our PID + cmdline through the queue, then wait."""
|
||||
import sys as _sys
|
||||
|
||||
try:
|
||||
with mine_palace_lock(palace_path):
|
||||
pid_q.put((os.getpid(), list(_sys.argv[:3])))
|
||||
open(ready_flag, "w").close()
|
||||
for _ in range(500):
|
||||
if os.path.exists(release_flag):
|
||||
return
|
||||
time.sleep(0.01)
|
||||
except MineAlreadyRunning:
|
||||
pid_q.put(("error", "raised"))
|
||||
|
||||
|
||||
def test_lock_failure_message_names_holder(tmp_path, monkeypatch):
|
||||
"""Regression #1264: failed acquire must identify the holder by PID.
|
||||
|
||||
Before this fix, a `mempalace mine` colliding with another writer
|
||||
(mine, MCP server, anything taking mine_palace_lock) saw a generic
|
||||
"another `mempalace mine` is already running" message and exited
|
||||
silently. The operator had no signal of which process to wait for
|
||||
or stop. The new message includes ``PID N`` so the holder can be
|
||||
identified directly.
|
||||
"""
|
||||
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()
|
||||
pid_q = ctx.Queue()
|
||||
holder = ctx.Process(target=_hold_lock_send_pid, args=(palace, ready, release, pid_q))
|
||||
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 in time"
|
||||
holder_pid, _holder_argv = pid_q.get(timeout=2)
|
||||
|
||||
with pytest.raises(MineAlreadyRunning) as excinfo:
|
||||
with mine_palace_lock(palace):
|
||||
pytest.fail("second acquire of same palace should have raised")
|
||||
|
||||
msg = str(excinfo.value)
|
||||
assert (
|
||||
f"PID {holder_pid}" in msg
|
||||
), f"lock-failure message must name the holder PID; got: {msg!r}"
|
||||
finally:
|
||||
open(release, "w").close()
|
||||
holder.join(timeout=5)
|
||||
|
||||
|
||||
def test_lock_holder_identity_persists_across_release(tmp_path, monkeypatch):
|
||||
"""The holder line is overwritten by each new acquirer, not appended.
|
||||
|
||||
Without explicit truncate the lock file would accumulate lines across
|
||||
runs and grow without bound. Verify that re-acquire keeps the body
|
||||
bounded.
|
||||
"""
|
||||
# ``os.path.expanduser("~")`` reads HOME on POSIX but USERPROFILE on
|
||||
# Windows; setting both makes the ``~/.mempalace/locks`` lookup land
|
||||
# under ``tmp_path`` regardless of platform.
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
palace = str(tmp_path / "palace")
|
||||
for _ in range(5):
|
||||
with mine_palace_lock(palace):
|
||||
pass
|
||||
|
||||
# Locate the lock file. The key derivation is internal but we can find
|
||||
# it by scanning the mempalace locks dir for mine_palace_*.lock entries.
|
||||
lock_dir = tmp_path / ".mempalace" / "locks"
|
||||
lock_files = list(lock_dir.glob("mine_palace_*.lock"))
|
||||
assert lock_files, "expected the palace lock file to exist after acquire/release"
|
||||
# Read as bytes so the byte-0 sentinel (\x00) is preserved without
|
||||
# decode quirks; the bound is on the file size, not its line count.
|
||||
body = lock_files[0].read_bytes()
|
||||
# Body is byte-0 sentinel + identity (no trailing accumulation).
|
||||
# Identity is ``f"{pid} {sys.argv[:3]}"``; cap at a generous bound that
|
||||
# still rules out unbounded growth across the 5 re-acquires.
|
||||
assert len(body) < 1024, f"lock body must not grow across re-acquires; got {len(body)} bytes"
|
||||
|
||||
|
||||
def test_mine_global_lock_is_alias_for_back_compat(tmp_path, monkeypatch):
|
||||
"""Old callers of `mine_global_lock` should still work."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
assert mine_global_lock is mine_palace_lock
|
||||
with mine_global_lock(str(tmp_path / "palace")):
|
||||
pass # the alias accepts the same palace_path argument
|
||||
@@ -363,11 +363,14 @@ def test_to_detected_dict_shape():
|
||||
projects = [ProjectInfo(name="p", repo_root=Path("."), is_mine=True, manifest="package.json")]
|
||||
people = [PersonInfo(name="Jane Doe", total_commits=5, repos={"r"})]
|
||||
d = to_detected_dict(projects, people)
|
||||
assert set(d.keys()) == {"people", "projects", "uncertain"}
|
||||
# ``topics`` is the LLM-refine bucket for cross-wing tunnel signal —
|
||||
# always present even when empty so callers can rely on the shape.
|
||||
assert set(d.keys()) == {"people", "projects", "topics", "uncertain"}
|
||||
assert d["projects"][0]["name"] == "p"
|
||||
assert d["projects"][0]["type"] == "project"
|
||||
assert d["people"][0]["name"] == "Jane Doe"
|
||||
assert d["people"][0]["type"] == "person"
|
||||
assert d["topics"] == []
|
||||
assert d["uncertain"] == []
|
||||
|
||||
|
||||
|
||||
+1425
-7
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,9 @@ Written BEFORE the fix.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSaveHookAutoMines:
|
||||
@@ -16,7 +19,8 @@ class TestSaveHookAutoMines:
|
||||
|
||||
def test_hook_mines_transcript_path(self):
|
||||
"""The hook receives TRANSCRIPT_PATH from Claude Code.
|
||||
It should use that to mine the conversation, not depend on MEMPAL_DIR."""
|
||||
It should use that to mine the conversation as --mode convos,
|
||||
independently of MEMPAL_DIR (which is for project files only)."""
|
||||
hook_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)),
|
||||
"hooks",
|
||||
@@ -24,23 +28,17 @@ class TestSaveHookAutoMines:
|
||||
)
|
||||
src = open(hook_path).read()
|
||||
|
||||
# The hook ALREADY receives TRANSCRIPT_PATH in the JSON input.
|
||||
# It should use this to mine the current session's transcript
|
||||
# regardless of whether MEMPAL_DIR is set.
|
||||
# The hook must have a path that uses TRANSCRIPT_PATH to determine
|
||||
# what to mine, separate from the MEMPAL_DIR path.
|
||||
uses_transcript = "TRANSCRIPT_PATH" in src
|
||||
has_mine = "mempalace mine" in src
|
||||
# TRANSCRIPT_PATH must appear in the mining logic, not just the parse block
|
||||
transcript_drives_mine = "MINE_DIR" in src and "dirname" in src and "TRANSCRIPT_PATH" in src
|
||||
|
||||
assert uses_transcript and has_mine and transcript_drives_mine, (
|
||||
"Save hook only mines when MEMPAL_DIR is set (defaults to empty). "
|
||||
"The hook receives TRANSCRIPT_PATH from Claude Code — it should "
|
||||
"mine that file automatically so conversations are saved without "
|
||||
"the user setting an env var. Currently the hook says 'saved in "
|
||||
"background' but nothing actually saves."
|
||||
)
|
||||
# The hook must drive the conversation mine off TRANSCRIPT_PATH,
|
||||
# using `dirname` to derive the parent dir, and tagging it with
|
||||
# `--mode convos` so the convo miner runs (not the projects miner).
|
||||
assert "TRANSCRIPT_PATH" in src, "hook must read transcript_path"
|
||||
assert "mempalace mine" in src, "hook must invoke `mempalace mine`"
|
||||
assert (
|
||||
'dirname "$TRANSCRIPT_PATH"' in src
|
||||
), "hook must mine the transcript's parent directory"
|
||||
assert (
|
||||
"--mode convos" in src
|
||||
), "transcript mine must use --mode convos, not the projects miner"
|
||||
|
||||
def test_mempal_dir_default_not_empty(self):
|
||||
"""If MEMPAL_DIR is still used, it should have a sensible default,
|
||||
@@ -66,3 +64,69 @@ class TestSaveHookAutoMines:
|
||||
'MEMPAL_DIR defaults to "" which silently disables mining. '
|
||||
"Either set a default path or add transcript-based mining."
|
||||
)
|
||||
|
||||
|
||||
class TestShellHookTranscriptValidation:
|
||||
"""Both shell hooks must validate transcript paths before mining them.
|
||||
|
||||
Mirrors mempalace.hooks_cli._validate_transcript_path so unsafe paths
|
||||
(no extension, traversal segments) are rejected at the shell layer
|
||||
too — added in #1231 review (Copilot #7, #8).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _hook_src(name: str) -> str:
|
||||
path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hooks", name)
|
||||
return open(path).read()
|
||||
|
||||
@staticmethod
|
||||
def _strip_comments(src: str) -> str:
|
||||
return "\n".join(line for line in src.splitlines() if not line.lstrip().startswith("#"))
|
||||
|
||||
def test_save_hook_defines_and_uses_validator(self):
|
||||
src = self._strip_comments(self._hook_src("mempal_save_hook.sh"))
|
||||
assert "is_valid_transcript_path() {" in src, "validator function must be defined"
|
||||
assert (
|
||||
'is_valid_transcript_path "$TRANSCRIPT_PATH"' in src
|
||||
), "validator must be invoked against TRANSCRIPT_PATH before mining"
|
||||
|
||||
def test_precompact_hook_defines_and_uses_validator(self):
|
||||
src = self._strip_comments(self._hook_src("mempal_precompact_hook.sh"))
|
||||
assert "is_valid_transcript_path() {" in src, "validator function must be defined"
|
||||
assert (
|
||||
'is_valid_transcript_path "$TRANSCRIPT_PATH"' in src
|
||||
), "validator must be invoked against TRANSCRIPT_PATH before mining"
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="shell hooks are POSIX-only; Windows CI bash maps to wsl.exe with no distro",
|
||||
)
|
||||
def test_validators_run_via_bash(self, tmp_path):
|
||||
"""Source the validator out of each hook and exercise it directly."""
|
||||
import subprocess
|
||||
|
||||
for name in ("mempal_save_hook.sh", "mempal_precompact_hook.sh"):
|
||||
src = self._hook_src(name)
|
||||
# Extract just the function definition (first occurrence).
|
||||
start = src.index("is_valid_transcript_path() {")
|
||||
end = src.index("\n}\n", start) + 2
|
||||
func_src = src[start:end]
|
||||
script = tmp_path / "v.sh"
|
||||
script.write_text(
|
||||
f"{func_src}\n" 'is_valid_transcript_path "$1" && echo OK || echo NO\n'
|
||||
)
|
||||
|
||||
def run(arg: str) -> str:
|
||||
return subprocess.run(
|
||||
["bash", str(script), arg],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
|
||||
assert run("/tmp/sessions/abc.jsonl") == "OK"
|
||||
assert run("/tmp/sessions/abc.json") == "OK"
|
||||
assert run("") == "NO"
|
||||
assert run("/tmp/notes.txt") == "NO"
|
||||
assert run("../etc/passwd.jsonl") == "NO"
|
||||
assert run("/tmp/../etc/t.jsonl") == "NO"
|
||||
|
||||
+209
-1
@@ -84,6 +84,24 @@ class TestSearchMemories:
|
||||
assert "error" in result
|
||||
assert "query failed" in result["error"]
|
||||
|
||||
def test_search_memories_vector_path_uses_explicit_collection_name(self):
|
||||
mock_col = MagicMock()
|
||||
mock_col.query.return_value = {
|
||||
"documents": [[]],
|
||||
"metadatas": [[]],
|
||||
"distances": [[]],
|
||||
"ids": [[]],
|
||||
}
|
||||
|
||||
with patch("mempalace.searcher.get_collection", return_value=mock_col) as get_collection:
|
||||
search_memories("test", "/fake/path", collection_name="custom_drawers")
|
||||
|
||||
get_collection.assert_called_once_with(
|
||||
"/fake/path",
|
||||
collection_name="custom_drawers",
|
||||
create=False,
|
||||
)
|
||||
|
||||
def test_search_memories_filters_in_result(self, palace_path, seeded_collection):
|
||||
result = search_memories("test", palace_path, wing="project", room="backend")
|
||||
assert result["filters"]["wing"] == "project"
|
||||
@@ -102,7 +120,7 @@ class TestSearchMemories:
|
||||
"ids": [["d1", "d2"]],
|
||||
}
|
||||
|
||||
def mock_get_collection(path, create=False):
|
||||
def mock_get_collection(path, collection_name=None, create=False):
|
||||
# First call: drawers. Second call: closets — raise so hybrid
|
||||
# degrades to pure drawer search (the catch block covers it).
|
||||
if not hasattr(mock_get_collection, "_called"):
|
||||
@@ -120,6 +138,103 @@ class TestSearchMemories:
|
||||
assert none_hit["wing"] == "unknown"
|
||||
assert none_hit["room"] == "unknown"
|
||||
|
||||
def test_effective_distance_clamped_to_valid_cosine_range(self):
|
||||
"""A strong closet boost (up to 0.40) applied to a low-distance drawer
|
||||
can drive ``dist - boost`` negative. That violates the cosine-distance
|
||||
invariant ``[0, 2]``: the API returns ``similarity > 1.0`` and the
|
||||
internal ``_sort_key`` sinks below ordinary positive distances,
|
||||
inverting the ranking so the best hybrid matches sort last.
|
||||
|
||||
With the clamp, ``effective_distance`` stays in ``[0, 2]``,
|
||||
``similarity`` stays in ``[0, 1]``, and the sort order is stable.
|
||||
"""
|
||||
# Drawer a.md gets a tiny base distance (0.08) — nearly exact match.
|
||||
# Drawer b.md gets a larger base distance (0.35).
|
||||
drawers_col = MagicMock()
|
||||
drawers_col.query.return_value = {
|
||||
"documents": [["doc-a", "doc-b"]],
|
||||
"metadatas": [
|
||||
[
|
||||
{"source_file": "a.md", "wing": "w", "room": "r", "chunk_index": 0},
|
||||
{"source_file": "b.md", "wing": "w", "room": "r", "chunk_index": 0},
|
||||
]
|
||||
],
|
||||
"distances": [[0.08, 0.35]],
|
||||
"ids": [["d-a", "d-b"]],
|
||||
}
|
||||
# A strong closet at rank 0 points at a.md → boost = 0.40,
|
||||
# which exceeds a.md's base distance and would go negative without
|
||||
# the clamp. No closet for b.md.
|
||||
closets_col = MagicMock()
|
||||
closets_col.query.return_value = {
|
||||
"documents": [["closet-preview-a"]],
|
||||
"metadatas": [[{"source_file": "a.md"}]],
|
||||
"distances": [[0.2]], # within CLOSET_DISTANCE_CAP (1.5)
|
||||
"ids": [["c-a"]],
|
||||
}
|
||||
|
||||
with (
|
||||
patch("mempalace.searcher.get_collection", return_value=drawers_col),
|
||||
patch("mempalace.searcher.get_closets_collection", return_value=closets_col),
|
||||
):
|
||||
result = search_memories("query", "/fake/path", n_results=5)
|
||||
|
||||
hits = result["results"]
|
||||
assert hits, "should return results"
|
||||
|
||||
# Invariants on every hit.
|
||||
for h in hits:
|
||||
assert (
|
||||
0.0 <= h["similarity"] <= 1.0
|
||||
), f"similarity out of range: {h['similarity']} for {h['source_file']}"
|
||||
assert 0.0 <= h["effective_distance"] <= 2.0, (
|
||||
f"effective_distance out of range: {h['effective_distance']} "
|
||||
f"for {h['source_file']}"
|
||||
)
|
||||
|
||||
# With the clamp, the closet-boosted a.md still ranks ahead of b.md —
|
||||
# the boost still wins, but it no longer flips the ranking.
|
||||
assert hits[0]["source_file"] == "a.md"
|
||||
assert hits[0]["matched_via"] == "drawer+closet"
|
||||
|
||||
|
||||
# ── BM25 internals: None / empty document safety ─────────────────────
|
||||
|
||||
|
||||
class TestBM25NoneSafety:
|
||||
"""Regression tests for the AttributeError observed in production when
|
||||
Chroma returned ``None`` documents inside a hybrid-rerank pass.
|
||||
|
||||
Trace from the daemon log (2026-04-24 21:07:05):
|
||||
File "mempalace/searcher.py", line 81, in _bm25_scores
|
||||
tokenized = [_tokenize(d) for d in documents]
|
||||
File "mempalace/searcher.py", line 52, in _tokenize
|
||||
return _TOKEN_RE.findall(text.lower())
|
||||
AttributeError: 'NoneType' object has no attribute 'lower'
|
||||
"""
|
||||
|
||||
def test_tokenize_handles_none(self):
|
||||
from mempalace.searcher import _tokenize
|
||||
|
||||
assert _tokenize(None) == []
|
||||
|
||||
def test_tokenize_handles_empty_string(self):
|
||||
from mempalace.searcher import _tokenize
|
||||
|
||||
assert _tokenize("") == []
|
||||
|
||||
def test_bm25_scores_does_not_crash_on_none_documents(self):
|
||||
"""A ``None`` mixed into the corpus must yield score 0.0 for that doc
|
||||
and finite scores for the rest, not raise AttributeError."""
|
||||
from mempalace.searcher import _bm25_scores
|
||||
|
||||
scores = _bm25_scores(
|
||||
"postgres migration", ["postgres migration done", None, "kafka rebalance"]
|
||||
)
|
||||
assert len(scores) == 3
|
||||
assert scores[1] == 0.0
|
||||
assert scores[0] > 0.0
|
||||
|
||||
|
||||
# ── search() (CLI print function) ─────────────────────────────────────
|
||||
|
||||
@@ -173,6 +288,85 @@ class TestSearchCLI:
|
||||
# Should have output with at least one result block
|
||||
assert "[1]" in captured.out
|
||||
|
||||
def test_search_applies_bm25_hybrid_rerank(self, capsys):
|
||||
"""CLI search must call the same hybrid rerank that the MCP path uses.
|
||||
|
||||
Regression for a bug where the CLI only consulted ChromaDB cosine
|
||||
distance: a drawer whose body contained every query term still
|
||||
scored zero similarity if its embedding happened to be far from
|
||||
the query (e.g. the drawer was a shell-output fragment that
|
||||
embeds as "file tree noise"). Hybrid rerank fixes this by
|
||||
combining BM25 with cosine — lexical matches rise above pure
|
||||
vector noise.
|
||||
|
||||
Simulates: three candidates, all with distance >= 1.0 (cosine = 0);
|
||||
candidate 2 contains every query term. After the fix, candidate 2
|
||||
should rank first and display a non-zero bm25 score.
|
||||
"""
|
||||
mock_col = MagicMock()
|
||||
mock_col.metadata = {"hnsw:space": "cosine"}
|
||||
mock_col.query.return_value = {
|
||||
"documents": [
|
||||
[
|
||||
"unrelated directory listing -rw-rw-r-- file.txt",
|
||||
"foo bar baz is a multi-word phrase",
|
||||
"another unrelated chunk about colors",
|
||||
]
|
||||
],
|
||||
"metadatas": [
|
||||
[
|
||||
{"source_file": "a.md", "wing": "w", "room": "r"},
|
||||
{"source_file": "b.md", "wing": "w", "room": "r"},
|
||||
{"source_file": "c.md", "wing": "w", "room": "r"},
|
||||
]
|
||||
],
|
||||
"distances": [[1.5, 1.5, 1.5]],
|
||||
}
|
||||
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
||||
search("foo bar baz", "/fake/path")
|
||||
captured = capsys.readouterr()
|
||||
first_block, _, _ = captured.out.partition("[2]")
|
||||
# Lexical match must rank first
|
||||
assert (
|
||||
"b.md" in first_block
|
||||
), f"expected lexical match 'b.md' at rank 1, got:\n{captured.out}"
|
||||
# Non-zero bm25 reported
|
||||
assert "bm25=" in first_block
|
||||
assert "bm25=0.0" not in first_block
|
||||
# Cosine still reported for transparency
|
||||
assert "cosine=" in first_block
|
||||
|
||||
def test_search_warns_when_palace_uses_wrong_distance_metric(self, capsys):
|
||||
"""Legacy palaces created without `hnsw:space=cosine` silently
|
||||
use L2, which breaks similarity interpretation. CLI must warn
|
||||
the user and point them at `mempalace repair` rather than
|
||||
pretending the `Match` scores are meaningful."""
|
||||
mock_col = MagicMock()
|
||||
mock_col.metadata = {} # legacy: no hnsw:space set
|
||||
mock_col.query.return_value = {
|
||||
"documents": [["some drawer content"]],
|
||||
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}]],
|
||||
"distances": [[1.2]],
|
||||
}
|
||||
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
||||
search("anything", "/fake/path")
|
||||
captured = capsys.readouterr()
|
||||
assert "mempalace repair" in captured.err
|
||||
assert "cosine" in captured.err.lower()
|
||||
|
||||
def test_search_does_not_warn_when_palace_is_correctly_configured(self, capsys):
|
||||
mock_col = MagicMock()
|
||||
mock_col.metadata = {"hnsw:space": "cosine"}
|
||||
mock_col.query.return_value = {
|
||||
"documents": [["some drawer content"]],
|
||||
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}]],
|
||||
"distances": [[0.3]],
|
||||
}
|
||||
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
||||
search("anything", "/fake/path")
|
||||
captured = capsys.readouterr()
|
||||
assert "mempalace repair" not in captured.err
|
||||
|
||||
def test_search_handles_none_metadata_without_crash(self, palace_path, capsys):
|
||||
"""ChromaDB can return `None` entries in the metadatas list when a
|
||||
drawer has no metadata. The CLI print path must not crash on them
|
||||
@@ -191,3 +385,17 @@ class TestSearchCLI:
|
||||
assert "[2]" in captured.out
|
||||
# Second result renders with fallback '?' values instead of crashing
|
||||
assert "second doc" in captured.out
|
||||
|
||||
def test_search_handles_none_document_without_crash(self, capsys):
|
||||
mock_col = MagicMock()
|
||||
mock_col.metadata = {"hnsw:space": "cosine"}
|
||||
mock_col.query.return_value = {
|
||||
"documents": [["first doc", None]],
|
||||
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}, None]],
|
||||
"distances": [[0.1, 0.2]],
|
||||
}
|
||||
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
||||
search("anything", "/fake/path")
|
||||
captured = capsys.readouterr()
|
||||
assert "[1]" in captured.out
|
||||
assert "[2]" in captured.out
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user