Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ab251c452 | ||
|
|
5cd961eef9 | ||
|
|
67c90c39d5 | ||
|
|
de74cad911 | ||
|
|
c76208507e | ||
|
|
fa97c93dd7 | ||
|
|
ec8788c2bf | ||
|
|
f62ce2696b | ||
|
|
a613c921c8 | ||
|
|
4bee65c11b | ||
|
|
6340d611cc | ||
|
|
9ecdf83c08 | ||
|
|
dbfeb82e68 | ||
|
|
68edb110a1 | ||
|
|
a824be43c8 | ||
|
|
88e454caed | ||
|
|
4c8ef8ed43 | ||
|
|
287d8f1b4d | ||
|
|
0955100a1f | ||
|
|
8a87fc66e2 | ||
|
|
f68cb8d682 | ||
|
|
acd62442be | ||
|
|
2c6cecf1fe | ||
|
|
7267de2b32 | ||
|
|
32e545eaaa | ||
|
|
28aa2c49b0 | ||
|
|
88e839bce5 | ||
|
|
0b04b79869 | ||
|
|
3da735ec6b | ||
|
|
5fdce3923e | ||
|
|
3c0ecb5b3e | ||
|
|
7f07b0d4fa | ||
|
|
4b98dd796d | ||
|
|
99464572f4 | ||
|
|
5b1c32eee1 | ||
|
|
e360a3a040 | ||
|
|
d7819d4b85 | ||
|
|
c7955d3899 | ||
|
|
18a9788961 | ||
|
|
63188686e5 | ||
|
|
09aa4034a7 | ||
|
|
a20d769b8c | ||
|
|
b0b028170a | ||
|
|
c0ae2abaff | ||
|
|
9e871b45b5 | ||
|
|
9b432699ce | ||
|
|
3f49e15545 | ||
|
|
b70f06a69e | ||
|
|
da32e562f7 | ||
|
|
1d38ae8784 | ||
|
|
07182d5adf | ||
|
|
c00e8dcc16 | ||
|
|
b459293e6f | ||
|
|
9815f0ae23 | ||
|
|
4e20ad3e8c | ||
|
|
65bedc2b33 | ||
|
|
f820b73160 | ||
|
|
aff1ee8255 | ||
|
|
39bec19956 | ||
|
|
f9db9b726f | ||
|
|
578e5775b4 | ||
|
|
adc0ef876f | ||
|
|
c54531a2a2 | ||
|
|
0104d86c53 | ||
|
|
db6c3d6552 | ||
|
|
bddbb5f7a5 | ||
|
|
2c81e22e28 | ||
|
|
9b395fe9d5 | ||
|
|
da5a48caf5 | ||
|
|
e096701333 | ||
|
|
e84221c6b7 | ||
|
|
0549a90df3 | ||
|
|
281aaf489d | ||
|
|
176c8b820b | ||
|
|
3b8d2652f4 | ||
|
|
e4924e8e94 | ||
|
|
1577346158 | ||
|
|
6cec591c17 | ||
|
|
afd0428823 | ||
|
|
cea780eb79 | ||
|
|
2211c974ff | ||
|
|
8ec284db5d | ||
|
|
5dcc46ba7e | ||
|
|
178105cbd5 | ||
|
|
833b6ab4d3 | ||
|
|
faa8643d9f | ||
|
|
cff43adb63 | ||
|
|
fd3b4e8ebb | ||
|
|
cd7a8658a2 | ||
|
|
d8b63a9521 | ||
|
|
d6cb868f75 | ||
|
|
b864766b12 | ||
|
|
1bc5c508d5 | ||
|
|
5cbc37ee9b | ||
|
|
bacb935bb3 | ||
|
|
c17d1aaf13 | ||
|
|
292cf872a5 | ||
|
|
b0a94bdef2 | ||
|
|
73e74bfb80 | ||
|
|
dc433824fd | ||
|
|
0f3f8ec652 | ||
|
|
5294d3118e | ||
|
|
aac947a5c5 | ||
|
|
86546348e4 | ||
|
|
d20ac0adf1 | ||
|
|
d479d6f245 | ||
|
|
c4ce4c89d2 | ||
|
|
ca11a455f5 | ||
|
|
4700c44163 | ||
|
|
a426587d35 | ||
|
|
c7428c3b71 | ||
|
|
c80f6537ec | ||
|
|
6ea60c6043 | ||
|
|
d59ca24647 | ||
|
|
e9746df6fd | ||
|
|
2366582fe3 | ||
|
|
2c08bb251d | ||
|
|
65fa1517a1 | ||
|
|
e8f96dd8b2 | ||
|
|
270f1aede9 | ||
|
|
37194cf543 | ||
|
|
58ea91b44d | ||
|
|
1095d684ff | ||
|
|
32fad4844d | ||
|
|
eff887d12f | ||
|
|
ce00cf0718 | ||
|
|
3e3fcf29fe | ||
|
|
569beff342 | ||
|
|
3fbab55cab | ||
|
|
1a47b78dcb | ||
|
|
960eaac851 | ||
|
|
54e9f46027 | ||
|
|
2e8ff64e3f | ||
|
|
94519b91dd | ||
|
|
2f92774eec | ||
|
|
9902e3a9a5 | ||
|
|
a91f7e06c7 | ||
|
|
971e5a75c8 | ||
|
|
7392ab8a25 | ||
|
|
e38af8c2e9 | ||
|
|
70ec8e450f | ||
|
|
a11cf4b667 | ||
|
|
a9344937df | ||
|
|
abd780ee36 | ||
|
|
db90cdf132 | ||
|
|
91e2158213 | ||
|
|
b4a6ffdf9f | ||
|
|
92ac104e31 | ||
|
|
e576c98ed2 | ||
|
|
fff8546672 | ||
|
|
b8c6bfe504 | ||
|
|
7d311af78e | ||
|
|
5ae2315f80 | ||
|
|
352eb67a90 | ||
|
|
f3ed6f796e | ||
|
|
4291fecfdf | ||
|
|
d7e182a272 | ||
|
|
65d07045bd | ||
|
|
fb8dc1f677 | ||
|
|
44016ad118 | ||
|
|
c203aacf8a | ||
|
|
4c4c4aba8d | ||
|
|
7fb7bd3b8d | ||
|
|
8723d30083 | ||
|
|
2ec48dde56 | ||
|
|
6cc5832176 | ||
|
|
386f3c9796 | ||
|
|
e0da99b2d5 | ||
|
|
3eacbe0c43 | ||
|
|
a27129a32a | ||
|
|
4791999516 | ||
|
|
fa27e41436 | ||
|
|
31fff1c354 | ||
|
|
fe460c4b8b | ||
|
|
73772cb707 | ||
|
|
38253b1f5f | ||
|
|
9d5a375017 | ||
|
|
73f455c7d0 | ||
|
|
477aa362cd | ||
|
|
2eda1f9130 | ||
|
|
8bdebe1da1 | ||
|
|
abaf09be0a | ||
|
|
0601026026 | ||
|
|
157022ab1b | ||
|
|
03f638a279 | ||
|
|
5fda4e5231 | ||
|
|
b0eddf68dd | ||
|
|
94d1ced11e | ||
|
|
49e427ba35 | ||
|
|
7fb981c538 | ||
|
|
32ec879c8b | ||
|
|
4e03ce90bc | ||
|
|
393107e4ac | ||
|
|
d09392c11a | ||
|
|
fe0391ab56 | ||
|
|
f868ee78d4 | ||
|
|
95211e84cb | ||
|
|
d859d5a565 | ||
|
|
5f58cdbfa8 | ||
|
|
3c7808e1bc | ||
|
|
4c46290b80 | ||
|
|
6bccf82a8c | ||
|
|
63a382fc6d | ||
|
|
aa96bb5623 | ||
|
|
afa749c141 | ||
|
|
f0e76c4363 | ||
|
|
5935dacc98 | ||
|
|
349485b6b6 | ||
|
|
78a32c69c1 | ||
|
|
22cf8afd1e | ||
|
|
cbf6cbe851 | ||
|
|
301aa335e1 | ||
|
|
d4391abb59 | ||
|
|
617aa0c3f3 | ||
|
|
f0b5cbf374 | ||
|
|
e594bad9d6 | ||
|
|
ea71c5234e | ||
|
|
d704433701 | ||
|
|
d8d88f4d21 | ||
|
|
ad5e7dfe2c | ||
|
|
c2f42cd57e | ||
|
|
f56e3067eb | ||
|
|
d486aef083 | ||
|
|
9f434e0bfd | ||
|
|
c45d069f59 | ||
|
|
2b457a2366 | ||
|
|
38de37adbf | ||
|
|
13d6c3e7a1 | ||
|
|
47bddb066e | ||
|
|
374400a530 | ||
|
|
74453ee3ac | ||
|
|
ae415b8620 | ||
|
|
ca38ae899d | ||
|
|
29d7e1edcf | ||
|
|
f5d2212577 | ||
|
|
25b918ee7e | ||
|
|
a9a2c35e65 | ||
|
|
66b0c5066b | ||
|
|
e039a675af | ||
|
|
6165aa2d17 |
@@ -1,6 +1,6 @@
|
||||
# MemPalace Claude Code Plugin
|
||||
|
||||
A Claude Code plugin that gives your AI a persistent memory system. Mine projects and conversations into a searchable palace backed by ChromaDB, with 33 MCP tools, auto-save hooks, and 5 guided skills.
|
||||
A Claude Code plugin that gives your AI a persistent memory system. Mine projects and conversations into a searchable palace backed by ChromaDB, with 36 MCP tools, auto-save hooks, and 5 guided skills.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -41,16 +41,17 @@ After installing the plugin, run the init command to complete setup (installs th
|
||||
|
||||
## Hooks
|
||||
|
||||
MemPalace registers two hooks that run automatically:
|
||||
MemPalace registers three hooks that run automatically:
|
||||
|
||||
- **Stop** -- Saves conversation context every 15 messages.
|
||||
- **SessionEnd** -- Runs one final save in the background on a clean exit, so short sessions that never hit the Stop interval or a compaction are still captured.
|
||||
- **PreCompact** -- Preserves important memories before context compaction.
|
||||
|
||||
Set the `MEMPAL_DIR` environment variable to a directory path to automatically run `mempalace mine` on that directory during each save trigger.
|
||||
|
||||
## MCP Server
|
||||
|
||||
The plugin automatically configures a local MCP server with 33 tools for storing, searching, and managing memories. No manual MCP setup is required -- `/mempalace:init` handles everything.
|
||||
The plugin automatically configures a local MCP server with 36 tools for storing, searching, and managing memories. No manual MCP setup is required -- `/mempalace:init` handles everything.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
|
||||
@@ -12,6 +12,17 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/mempal-session-end-hook.sh\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# MemPalace SessionEnd Hook — thin wrapper calling the Python CLI.
|
||||
#
|
||||
# Claude Code documents a default SessionEnd hook timeout of 1.5s, and
|
||||
# "timeouts set on plugin-provided hooks do not raise the budget"
|
||||
# (https://code.claude.com/docs/en/hooks). A cold `mempalace` start alone
|
||||
# exceeds 1.5s, so the final mine must NOT run in the foreground — it would be
|
||||
# killed before it saved anything. Unlike the foreground Stop/PreCompact plugin
|
||||
# wrappers, this one backgrounds the hook and returns immediately; the detached
|
||||
# child finishes the save after the session has exited. All logic lives in
|
||||
# mempalace.hooks_cli for cross-harness extensibility.
|
||||
run_mempalace_hook() {
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
exec mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
MEMPAL_PYTHON_BIN="${MEMPAL_PYTHON:-}"
|
||||
if [ -z "$MEMPAL_PYTHON_BIN" ] || [ ! -x "$MEMPAL_PYTHON_BIN" ]; then
|
||||
MEMPAL_PYTHON_BIN="$(command -v python3 2>/dev/null || echo python3)"
|
||||
fi
|
||||
if "$MEMPAL_PYTHON_BIN" -c "import mempalace" >/dev/null 2>&1; then
|
||||
exec "$MEMPAL_PYTHON_BIN" -m mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
|
||||
exec python -m mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Capture stdin (the SessionEnd JSON) before backgrounding — the parent's
|
||||
# stdin is gone once we return. Forward it to the detached worker, which runs
|
||||
# the final mine on its own time and outlives this process.
|
||||
payload="$(cat)"
|
||||
(
|
||||
printf '%s' "$payload" | run_mempalace_hook --hook session-end --harness "${MEMPALACE_HOOK_HARNESS:-claude-code}"
|
||||
) >/dev/null 2>&1 </dev/null &
|
||||
disown 2>/dev/null || true
|
||||
|
||||
# Return immediately so the harness never blocks on session exit.
|
||||
printf '{}'
|
||||
@@ -8,8 +8,8 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"source": "./.claude-plugin",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 33 MCP tools, auto-save hooks, guided setup.",
|
||||
"version": "3.4.1",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, guided setup.",
|
||||
"version": "3.6.0",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.4.1",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 33 MCP tools, auto-save hooks, and guided setup.",
|
||||
"version": "3.6.0",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
},
|
||||
|
||||
@@ -53,6 +53,13 @@ a variable, fixing a typo). Recall is question-driven, not reflexive.
|
||||
an answer. Offer to widen the search or file the new information.
|
||||
- **MCP error / server down** — surface the error, suggest `mempalace
|
||||
status` or re-running `/init`; never fall back to guessing.
|
||||
- **Palace index corrupt / compactor error** — if the server reports an
|
||||
HNSW segment-writer error, a ChromaDB compaction failure, or stays
|
||||
"Not connected" after a write, the index is out of sync with
|
||||
`chroma.sqlite3` but the rows are intact. Tell the user to stop the
|
||||
server and rebuild from SQLite (`mempalace repair --mode from-sqlite
|
||||
--archive-existing --yes`), not re-mine, which drops MCP-added drawers
|
||||
and diary entries (#1843). Do not repair in-process.
|
||||
- **Conflicting facts** — trust the knowledge graph's time-valid answer;
|
||||
invalidate-then-add rather than overwriting silently.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MemPalace - Codex CLI Plugin
|
||||
|
||||
Give your AI a persistent memory -- mine projects and conversations into a searchable palace backed by ChromaDB, with 33 MCP tools, auto-save hooks, and guided skills.
|
||||
Give your AI a persistent memory -- mine projects and conversations into a searchable palace backed by ChromaDB, with 36 MCP tools, auto-save hooks, and guided skills.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.4.1",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 33 MCP tools, auto-save hooks, and guided setup.",
|
||||
"version": "3.6.0",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
},
|
||||
@@ -27,7 +27,7 @@
|
||||
"interface": {
|
||||
"displayName": "MemPalace",
|
||||
"shortDescription": "AI memory system for Codex",
|
||||
"longDescription": "Give your AI a persistent memory — mine projects and conversations into a searchable palace backed by ChromaDB, with 33 MCP tools, auto-save hooks, and guided skills.",
|
||||
"longDescription": "Give your AI a persistent memory — mine projects and conversations into a searchable palace backed by ChromaDB, with 36 MCP tools, auto-save hooks, and guided skills.",
|
||||
"developerName": "milla-jovovich",
|
||||
"category": "Coding",
|
||||
"capabilities": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MemPalace Cursor Plugin
|
||||
|
||||
A Cursor IDE plugin that gives your agent a persistent memory system. Auto-registers the `mempalace-mcp` server (33 MCP tools), ships 5 slash commands, two model-invocable skills (setup/mining/search and a recall protocol), and an optional recall rule.
|
||||
A Cursor IDE plugin that gives your agent a persistent memory system. Auto-registers the `mempalace-mcp` server (36 MCP tools), ships 5 slash commands, two model-invocable skills (setup/mining/search and a recall protocol), and an optional recall rule.
|
||||
|
||||
> Hooks (auto-save + session-start memory recall) are shipped separately under `hooks/cursor/` so the plugin is safe to install in any Cursor workspace without touching the agent loop. See [Hooks](#hooks-optional) below.
|
||||
|
||||
@@ -87,7 +87,7 @@ This plugin ships `mcp.json` at the plugin root, so Cursor auto-loads the `mempa
|
||||
}
|
||||
```
|
||||
|
||||
All 33 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, …) become available to the agent immediately. No manual `~/.cursor/mcp.json` edit required.
|
||||
All 36 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, …) become available to the agent immediately. No manual `~/.cursor/mcp.json` edit required.
|
||||
|
||||
If the server doesn't appear, confirm `mempalace-mcp` is on the user `$PATH`:
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"source": ".",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 33 MCP tools, slash commands, and a guided skill for Cursor.",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 36 MCP tools, slash commands, and a guided skill for Cursor.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 33 MCP tools, slash commands, and a guided skill for Cursor.",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 36 MCP tools, slash commands, and a guided skill for Cursor.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.11", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -24,18 +24,24 @@ jobs:
|
||||
test-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: 'pip'
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10
|
||||
# ChromaDB's rust HNSW core intermittently fails compaction on Windows
|
||||
# ("Failed to apply logs to the hnsw segment writer") regardless of our
|
||||
# code — a long-standing, non-reproducible-on-Linux/macOS flake. Retry
|
||||
# ONLY that specific transient error (via --only-rerun) so real,
|
||||
# deterministic failures still fail on the first run. Linux/macOS jobs
|
||||
# deliberately run with no reruns so genuine regressions surface there.
|
||||
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10 --reruns 2 --reruns-delay 5 --only-rerun "Failed to apply logs to the hnsw segment writer"
|
||||
|
||||
test-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
@@ -45,7 +51,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -22,21 +22,21 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Needed for the emulated linux/arm64 build on real pushes.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# Only authenticate + push for in-repo events. Fork PRs lack the
|
||||
# packages:write token, so they build (to validate the Dockerfile) but
|
||||
# do not push.
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# latest -> main (the latest release); semver tags -> released versions.
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -79,13 +79,13 @@ jobs:
|
||||
build-gpu:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build GPU image (validation only — not published)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.gpu
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved tag: $tag"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Fully-qualified refs/tags/ so an unqualified name can't resolve to a
|
||||
# same-named *branch* instead of the tag (checkout prefers branches).
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
check-versions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract versions from all sources
|
||||
id: versions
|
||||
|
||||
+117
-1
@@ -10,6 +10,120 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
---
|
||||
|
||||
## [3.6.0] — 2026-07-14
|
||||
|
||||
### Features
|
||||
|
||||
- **Turnkey secure remote / team server.** `mempalace serve` exposes the full MCP surface over HTTP with secure defaults: non-loopback binds require a bearer token, generated tokens are stored with restrictive permissions and passed through the child environment rather than argv, native TLS 1.2+ is supported, and `--read-only` both hides and refuses mutating tools. Docker Compose, systemd, and environment templates are included for deployment. (#1877, #1897, #1900)
|
||||
|
||||
- **Milvus storage backend.** The new opt-in `milvus` backend supports embedded Milvus Lite, self-hosted Milvus, and Zilliz Cloud through `pymilvus`, with COSINE vector search, native BM25 lexical search for new collections, namespace isolation, target-mismatch protection, and an optional `milvus` dependency extra. (#1899)
|
||||
|
||||
- **Atomic knowledge-graph fact replacement.** `supersede()` / `mempalace_kg_supersede` closes an open fact and opens its successor at one shared instant, so model, employer, address, and other single-valued facts can change without a hand-written invalidate/add race. Temporal queries now use half-open intervals at timestamp precision, returning only the successor at the transition boundary while preserving whole-day semantics for date-only facts. (#1913)
|
||||
|
||||
- **Conversation chronology is preserved.** Conversation drawers now retain transcript time as `authored_at` alongside ingest time, search results expose it, and exact hybrid-score ties prefer the more recently authored drawer. An idempotent, dry-run-first backfill script adds the metadata to existing palaces without re-embedding. `mempalace_list_drawers` also accepts inclusive `since` and exclusive `before` filing-time bounds. (#1890, #1891)
|
||||
|
||||
- **Mined sessions populate the associative graph.** A precision-biased, no-LLM structural extractor records code symbols, URLs, paths, and qualified identifiers as drawer entities; conversation mining then derives hallways from them. The new `mempalace hallways` CLI command exposes the graph. (#1894, #1895)
|
||||
|
||||
- **Per-project mining exclusions.** `exclude_patterns` in `mempalace.yaml` filters pre-scanned project files without changing the corrected `--limit` semantics. (#1213, #1953)
|
||||
|
||||
- **LaTeX project coverage.** `.tex` and `.bib` files are now treated as readable prose sources. (#1901)
|
||||
|
||||
### Performance
|
||||
|
||||
- **MCP initialization no longer waits for palace-wide integrity work.** stdio answers the JSON-RPC `initialize` request immediately while startup preflight runs on a background thread; a tool arriving mid-probe joins the same verdict instead of launching duplicate work. The startup SQLite probe is also skipped above a configurable size limit (512 MiB by default), while repair preflights remain strict. (#1911, #1987)
|
||||
|
||||
- **Qdrant metadata counts use server-side facets.** Taxonomy, overview, and status paths can count metadata remotely instead of fetching and tallying every record; the embedding wrapper now forwards the facet and bulk-metadata capabilities correctly. (#1868, #1898)
|
||||
|
||||
- **pgvector metadata-only fetches omit document payloads**, reducing transfer and decoding work on enumeration paths. (#1892)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Chroma recovery distinguishes derived-index damage from data loss.** Valid all-layer-0 HNSW segments with an empty `link_lists.bin` are no longer quarantined repeatedly; isolated FTS5 inverted-index corruption is rebuilt from intact content during repair and after mining; embedded NUL bytes are sanitized before reaching ChromaDB. (#1716, #1872, #1878, #1927, #1928)
|
||||
|
||||
- **SQLite recovery is bounded, atomic, and honest.** Integrity checks wait up to 15 seconds for transient writer contention instead of reporting a healthy palace as corrupt. In-place recovery archives use atomic `os.rename` rather than `shutil.move`'s destructive copy/delete fallback. `repair --mode from-sqlite` now rebuilds FTS5, vacuums, and requires a clean final `PRAGMA quick_check`; cleanup failures return non-zero with recovery details instead of printing a false success banner. (#1945, #2015, #2017)
|
||||
|
||||
- **CLI search checks HNSW divergence before opening ChromaDB.** A diverged palace routes directly to the existing SQLite BM25 fallback, avoiding embedder or collection initialization against damaged native index state while leaving healthy Chroma and non-Chroma backends on their normal vector path. (#2016)
|
||||
|
||||
- **Writer leases recover instead of stranding servers read-only.** A server refused while a peer owns the palace now retries on each mutating call and promotes itself after the peer exits. Status remains read-only and cannot acquire the writer lease, the in-process re-entrant holder set is released even across asynchronous interruption, and `checkpoint` / `delete_by_source` are correctly classified as mutations for both read-only serving and peer-writer protection. (#1923, #1930, #1934, #1960, #1970, #1971)
|
||||
|
||||
- **Status reports only checks that actually ran.** Non-Chroma backends now mark the Chroma SQLite integrity check as not applicable rather than claiming an unperformed success. (#1931, #1946)
|
||||
|
||||
- **Conversation transcripts are treated as mutable.** Appended or rewritten sessions are purged and re-filed based on modification time, preventing later turns from being silently skipped. Claude Code `tool-results/` sidecars are excluded from conversation scans so raw machine dumps cannot flood the embedding space. (#1957, #2010)
|
||||
|
||||
- **Explicit palace selection now scopes derived graph state.** Project, conversation, and format mining write hallways and tunnels beside the selected `--palace` instead of leaking them into the ambient default palace. (#2018)
|
||||
|
||||
- **Remote and local server hardening.** Non-loopback HTTP now requires a token unless an explicit insecure override is supplied; optional-provider probes cannot reuse an ambient external key before the user selects that provider; hook transcript paths and file-copy/repair paths receive stricter local validation and no-follow handling. Importing the MCP server no longer clobbers the host application's root logger, HTTP writes can re-enter the process-wide palace lock safely, and routine client disconnects no longer emit server tracebacks. (#1859, #1860, #1864, #1885, #2003, #2004)
|
||||
|
||||
- **Mining and configuration correctness.** Oversized files produce a visible stderr warning, `~` in configured palace paths expands consistently, wing slugs handle special characters, and Windows background daemon / synchronous hook mines use `CREATE_NO_WINDOW`. (#923, #1852, #1857, #1863, #1865)
|
||||
|
||||
- **`mempalace init` handles non-ASCII `.gitignore` files on Windows.** The project-file ignore guard now reads and appends UTF-8 explicitly instead of relying on locale defaults such as GBK. (#1648)
|
||||
|
||||
- **L1 wake-up surfaces the latest moments.** Drawers with equal importance are now ordered by `filed_at` recency rather than insertion order, so startup context prefers recent memories instead of the oldest ones. (#1630)
|
||||
|
||||
- **Backend detection requires a SQLite magic header** before classifying a target as Chroma or `sqlite_exact`, preventing unrelated files from being mistaken for a palace. (#1893, #1896)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added a storage-backend configuration reference, a remote/team-server deployment guide, `authored_at` migration guidance, and refreshed the OpenClaw integration for the full 36-tool MCP surface. (#1719, #1877, #1890, #1904, #1905)
|
||||
|
||||
### Internal
|
||||
|
||||
- Expanded WAL crash-safety, idempotence, and redaction-path coverage (#1869); updated GitHub Actions and Ruff; repaired `uv.lock` drift so it again matches the Ruff 0.15.20 pin and includes Python 3.9 dependency markers introduced by the Milvus resolution.
|
||||
|
||||
---
|
||||
|
||||
## [3.5.0] — 2026-06-22
|
||||
|
||||
### Features
|
||||
|
||||
- **Opt-in local daemon for queued writes.** A new `mempalace daemon` queues MemPalace writes through a single local process so background mines, diary saves, and hook-driven ingests serialize against one palace handle instead of racing for it. Opt-in and local-only — nothing binds to a public interface. (#1826)
|
||||
|
||||
- **Opt-in HTTP transport for the MCP server.** `mempalace-mcp --transport http` serves JSON-RPC at `POST /mcp` (with a `GET /healthz` liveness probe) for operators running MemPalace behind a long-lived HTTP MCP client/proxy, avoiding the long-lived-stdio framing failures of #1801. stdio remains the default and is unchanged. The transport reuses the exact stdio request dispatcher (no separate write/search path), binds `127.0.0.1` by default, and is hardened against the two ways a local HTTP server leaks to the network: it pins the `Host` header to loopback on a loopback bind and rejects any non-loopback browser `Origin` (DNS-rebinding/SSRF guard), and supports an optional bearer token via `MEMPALACE_MCP_HTTP_TOKEN` (required on `/mcp`, never on `/healthz`). A 16 MiB request cap and a loud warning when bound to a non-loopback host round it out. (#1801, #1806)
|
||||
|
||||
- **`mempalace_checkpoint` batch-save MCP tool.** Collapses multiple `add_drawer` calls plus an optional diary entry into a single MCP round-trip for agents that want to file a whole session at once. Stores content verbatim and reuses the existing idempotent add/dedup path. (#1851)
|
||||
|
||||
- **`mempalace_delete_by_source` bulk-cleanup MCP tool.** Exact-match, dry-run-by-default deletion of every drawer (and its matching closet/AAAK index entries) for a given `source_file` — the recourse for benchmark/eval files mined into the same wing as real data and drowning out search. The dry run reports the drawer and closet blast radius before anything is removed, and the commit writes a WAL audit entry. (#1722, #1729)
|
||||
|
||||
- **Optional `source_file` filter for `mempalace_search`.** Scope a search to an exact stored source path. The filter is threaded through every search path (vector, BM25/SQLite fallback, lexical union, and index-mismatch fallback) so it never silently drops a matching drawer, and results now expose the full `source_path` as a round-trippable key. (#1815, #1817)
|
||||
|
||||
- **New transcript parsers / importers.** Continue.dev session parser (#731), Gemini CLI / AI Studio JSON session import (#204), and a Pi agent JSONL session normalizer (#169).
|
||||
|
||||
- **Wider miner language coverage.** C# / .NET, PHP (#1819), Swift / Kotlin (#1368), and Java project detection including rootless subprojects (#1720).
|
||||
|
||||
- **Final mine on Claude plugin `SessionEnd`.** The Claude Code plugin now runs a closing mine when a session ends so the last exchanges are captured without waiting for the next save nudge. (#1814, #1820)
|
||||
|
||||
### Performance
|
||||
|
||||
- **Overview/status MCP tools answer from the SQLite aggregate.** Large palaces no longer time out building wing/room/status overviews — the counts come from a single SQLite aggregate instead of a client-side fetch-and-tally. (#1748, #1379)
|
||||
|
||||
- **`graph_stats` SQLite fast path.** Knowledge-graph stats are computed in SQLite rather than walking the collection, fixing large-palace timeouts. (#1379)
|
||||
|
||||
- **Embedder caps ONNX-runtime intra-op threads** so a background mine no longer pins every core. (#1068)
|
||||
|
||||
- **Backend pagination pushed into the query.** `sqlite_exact` (#1841, #1842) and `pgvector` (#1830, #1840) now apply `get(limit, offset)` in SQL, and Qdrant fetches bulk metadata in a single scroll with a larger page size (#1796, #1832).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **pgvector tolerates hostile transcript bytes.** A lone Unicode surrogate (#1833) or a NUL byte (#1829) in a transcript no longer aborts the whole mine — both are sanitized before the row is written.
|
||||
|
||||
- **SQLite read-only URIs are percent-encoded** so palace paths with spaces or special characters open correctly, and `_sqlite_graph_stats` is routed through the same `sqlite_read_uri` helper.
|
||||
|
||||
- **Stale ChromaDB HNSW divergence routes to the SQLite fallback** instead of failing the read outright. (#1816, #1822)
|
||||
|
||||
- **Diverged-index recovery now points at `repair --mode from-sqlite`, not a re-mine.** A failed ChromaDB HNSW compaction leaves the index out of sync while the rows stay intact in `chroma.sqlite3`; the old "re-mine from source" advice silently dropped MCP-added drawers and diary entries (which have no source file). Both the legacy `repair`/`rebuild_index` error messages and the `repair-status` recommendation, plus the recall skill docs, now guide users to rebuild from SQLite. (#1843, #1847, #1849)
|
||||
|
||||
- **The MCP server refuses a second writer for the same palace** rather than letting two processes race the same HNSW handle. (#1818, #1823)
|
||||
|
||||
- **Windows hook miner spawns with `CREATE_NO_WINDOW`** so background mines no longer flash a console window. (#1783, #1848)
|
||||
|
||||
- **`fact_checker` `__main__` no longer emits a runpy warning** under the test runner. (#1798)
|
||||
|
||||
### Internal
|
||||
|
||||
- Live-substrate conformance test module for pgvector (#1769); dependabot bumps for `docker/login-action` (3→4), `docker/build-push-action` (6→7), and `docker/metadata-action` (5→6) (#1788, #1787, #1786); ruff dev dependency bumped to 0.15.18.
|
||||
|
||||
---
|
||||
|
||||
## [3.4.1] — 2026-06-14
|
||||
|
||||
### Features
|
||||
@@ -516,7 +630,9 @@ Initial public release.
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.4.1...HEAD
|
||||
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.6.0...HEAD
|
||||
[3.6.0]: https://github.com/MemPalace/mempalace/compare/v3.5.0...v3.6.0
|
||||
[3.5.0]: https://github.com/MemPalace/mempalace/compare/v3.4.1...v3.5.0
|
||||
[3.4.1]: https://github.com/MemPalace/mempalace/compare/v3.4.0...v3.4.1
|
||||
[3.4.0]: https://github.com/MemPalace/mempalace/compare/v3.3.6...v3.4.0
|
||||
[3.3.6]: https://github.com/MemPalace/mempalace/compare/v3.3.5...v3.3.6
|
||||
|
||||
@@ -108,39 +108,23 @@ CUDA-accelerated embeddings, build the GPU variant with
|
||||
|
||||
## Storage backends
|
||||
|
||||
ChromaDB is the default. For the pluggable-backend preview, MemPalace also
|
||||
ships `sqlite_exact` for local exact-vector correctness checks, and two opt-in
|
||||
external service backends — `qdrant` (REST) and `pgvector` (Postgres). The two
|
||||
external backends exercise the storage contract on different substrates (a
|
||||
REST/dict store and a SQL/JSONB store), so it is not accidentally shaped around
|
||||
one vendor.
|
||||
ChromaDB is the default and needs no configuration. MemPalace also ships a
|
||||
pluggable backend contract, exercised across deliberately different substrates
|
||||
so the contract is never accidentally shaped around one vendor. Every
|
||||
non-default backend is opt-in.
|
||||
|
||||
```bash
|
||||
# local no-service backend
|
||||
mempalace mine ~/projects/myapp --backend sqlite_exact
|
||||
| Backend | Mode | Install | Namespaces | Lexical | Configure with |
|
||||
| ------- | ---- | ------- | :--------: | :-----: | -------------- |
|
||||
| `chroma` _(default)_ | Local (embedded) | bundled | – | ✓ | – |
|
||||
| `sqlite_exact` | Local (exact) | bundled | – | ✓ | – |
|
||||
| `milvus` | Local (Lite) · Server opt-in | `mempalace[milvus]` | ✓ | ✓ | `MEMPALACE_MILVUS_URI` |
|
||||
| `qdrant` | Server (REST) | bundled | ✓ | ✓ | `MEMPALACE_QDRANT_URL` |
|
||||
| `pgvector` | Server (Postgres) | `mempalace[pgvector]` | ✓ | ✓ | `MEMPALACE_PGVECTOR_DSN` |
|
||||
|
||||
# Qdrant backend, defaulting to http://localhost:6333
|
||||
MEMPALACE_QDRANT_URL=http://localhost:6333 \
|
||||
mempalace mine ~/projects/myapp --backend qdrant
|
||||
|
||||
# Postgres + pgvector backend, defaulting to postgresql://localhost:5432/mempalace
|
||||
# needs the optional driver: pip install mempalace[pgvector]
|
||||
# and the `vector` extension available on the server
|
||||
MEMPALACE_PGVECTOR_DSN=postgresql://localhost:5432/mempalace \
|
||||
mempalace mine ~/projects/myapp --backend pgvector
|
||||
```
|
||||
|
||||
Qdrant can also be configured with `MEMPALACE_QDRANT_API_KEY`,
|
||||
`MEMPALACE_QDRANT_NAMESPACE`, and `MEMPALACE_QDRANT_TIMEOUT`; pgvector with
|
||||
`MEMPALACE_PGVECTOR_NAMESPACE`. Both external backends isolate tenants by
|
||||
namespace (advertised via the `supports_namespace_isolation` capability) and
|
||||
write a local marker (`qdrant_backend.json` / `pgvector_backend.json`) to guard
|
||||
against silently opening a palace against the wrong server.
|
||||
|
||||
When `MEMPALACE_QDRANT_URL` or `MEMPALACE_PGVECTOR_DSN` points anywhere other
|
||||
than your own local or trusted self-hosted service, MemPalace will send and
|
||||
store verbatim drawer text and metadata there. That is an explicit opt-in
|
||||
backend choice, never the default.
|
||||
Select with `--backend <name>`, `MEMPALACE_BACKEND=<name>`, or
|
||||
`"backend": "<name>"` in `config.json`. See
|
||||
[Storage backends](/guide/configuration#storage-backends) for connection
|
||||
variables, namespace behavior, and deployment notes.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -225,7 +209,7 @@ Usage and tool reference:
|
||||
|
||||
## MCP server
|
||||
|
||||
33 MCP tools cover palace reads/writes, knowledge-graph operations,
|
||||
36 MCP tools cover palace reads/writes, knowledge-graph operations,
|
||||
cross-wing navigation, drawer management, and agent diaries. Installation
|
||||
and the full tool list:
|
||||
[mempalaceofficial.com/reference/mcp-tools](https://mempalaceofficial.com/reference/mcp-tools.html).
|
||||
@@ -285,7 +269,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
<!-- Link Definitions -->
|
||||
[version-shield]: https://img.shields.io/badge/version-3.4.1-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||
[version-shield]: https://img.shields.io/badge/version-3.6.0-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||
[release-link]: https://github.com/MemPalace/mempalace/releases
|
||||
[python-shield]: https://img.shields.io/badge/python-3.9+-7dd8f8?style=flat-square&labelColor=0a0e14&logo=python&logoColor=7dd8f8
|
||||
[python-link]: https://www.python.org/
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# MemPalace remote team server — MCP over HTTP, backed by a central Qdrant.
|
||||
#
|
||||
# One command stands up a shared memory service a whole team's AI clients
|
||||
# connect to. Embeddings are still produced locally inside the mempalace
|
||||
# container; only your own Qdrant ever receives the vectors and text.
|
||||
#
|
||||
# 1. cp deploy/server.env.example deploy/.env && edit deploy/.env
|
||||
# (at minimum set MEMPALACE_MCP_HTTP_TOKEN to a long random secret)
|
||||
# 2. docker compose -f deploy/docker-compose.server.yml --env-file deploy/.env up -d
|
||||
# 3. connect a client (see the Remote / Team Server guide):
|
||||
# claude mcp add --transport http mempalace http://YOUR_HOST:8765/mcp \
|
||||
# --header "Authorization: Bearer $MEMPALACE_MCP_HTTP_TOKEN"
|
||||
#
|
||||
# SECURITY: this exposes plaintext HTTP on :8765. For anything beyond a trusted
|
||||
# private network, put a TLS-terminating reverse proxy in front (nginx/Caddy/
|
||||
# Traefik) and only expose the proxy. The bearer token is mandatory for the
|
||||
# network-exposed (0.0.0.0) bind.
|
||||
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- qdrant-storage:/qdrant/storage
|
||||
# Not published to the host: only the mempalace service reaches it over the
|
||||
# internal compose network. Uncomment to inspect Qdrant directly.
|
||||
# ports:
|
||||
# - "6333:6333"
|
||||
|
||||
mempalace:
|
||||
image: ghcr.io/mempalace/mempalace:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- qdrant
|
||||
command:
|
||||
- serve
|
||||
- --host
|
||||
- "0.0.0.0"
|
||||
- --port
|
||||
- "8765"
|
||||
- --backend
|
||||
- qdrant
|
||||
# Uncomment to expose recall without write access to most clients:
|
||||
# - --read-only
|
||||
environment:
|
||||
# Required for the network-exposed bind. Set in deploy/.env.
|
||||
MEMPALACE_MCP_HTTP_TOKEN: ${MEMPALACE_MCP_HTTP_TOKEN:?set MEMPALACE_MCP_HTTP_TOKEN in deploy/.env}
|
||||
MEMPALACE_QDRANT_URL: http://qdrant:6333
|
||||
MEMPALACE_QDRANT_API_KEY: ${MEMPALACE_QDRANT_API_KEY:-}
|
||||
# Set to cuda/dml/coreml on an accelerated host (see Dockerfile.gpu).
|
||||
MEMPALACE_EMBEDDING_DEVICE: ${MEMPALACE_EMBEDDING_DEVICE:-auto}
|
||||
ports:
|
||||
- "8765:8765"
|
||||
volumes:
|
||||
- mempalace-data:/data
|
||||
healthcheck:
|
||||
# The image has no curl; use Python (always present). /healthz needs no auth.
|
||||
# If you enable TLS on the server itself, switch this to https + ssl context.
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://127.0.0.1:8765/healthz').read().strip()==b'ok' else sys.exit(1)"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
qdrant-storage:
|
||||
mempalace-data:
|
||||
@@ -0,0 +1,48 @@
|
||||
# MemPalace remote MCP server — systemd unit template.
|
||||
#
|
||||
# Install:
|
||||
# sudo useradd --system --home /var/lib/mempalace --shell /usr/sbin/nologin mempalace
|
||||
# sudo install -d -o mempalace -g mempalace -m 750 /var/lib/mempalace /etc/mempalace
|
||||
# sudo cp deploy/server.env.example /etc/mempalace/server.env # then edit + chmod 600
|
||||
# sudo install -m 600 -o mempalace -g mempalace /etc/mempalace/server.env /etc/mempalace/server.env
|
||||
# # install mempalace into a venv on PATH, or adjust ExecStart to its absolute path
|
||||
# sudo cp deploy/mempalace-server.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now mempalace-server
|
||||
#
|
||||
# This binds 0.0.0.0:8765 and requires MEMPALACE_MCP_HTTP_TOKEN (set in the
|
||||
# EnvironmentFile). Front it with a TLS-terminating reverse proxy, or set
|
||||
# MEMPALACE_MCP_TLS_CERT / _KEY in the EnvironmentFile for native TLS.
|
||||
|
||||
[Unit]
|
||||
Description=MemPalace remote MCP server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=mempalace
|
||||
Group=mempalace
|
||||
EnvironmentFile=/etc/mempalace/server.env
|
||||
ExecStart=mempalace serve --host 0.0.0.0 --port 8765
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
# --- Hardening ---------------------------------------------------------------
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=false
|
||||
# The palace and any local state live here; everything else is read-only.
|
||||
ReadWritePaths=/var/lib/mempalace
|
||||
StateDirectory=mempalace
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,28 @@
|
||||
# MemPalace remote server environment.
|
||||
# Copy to deploy/.env (compose) or /etc/mempalace/server.env (systemd) and edit.
|
||||
# Keep this file readable only by the service account: chmod 600.
|
||||
|
||||
# --- Required for a network-exposed (0.0.0.0) bind ------------------------------
|
||||
# Clients send: Authorization: Bearer <this value>. Generate a strong secret:
|
||||
# openssl rand -hex 32
|
||||
MEMPALACE_MCP_HTTP_TOKEN=
|
||||
|
||||
# --- Storage backend ------------------------------------------------------------
|
||||
# The team server should use a networked backend so the palace is shared.
|
||||
MEMPALACE_BACKEND=qdrant
|
||||
MEMPALACE_QDRANT_URL=http://qdrant:6333
|
||||
# MEMPALACE_QDRANT_API_KEY=
|
||||
|
||||
# --- Embedding ------------------------------------------------------------------
|
||||
# auto | cpu | cuda | dml | coreml. Use cuda on a GPU host (needs the GPU image).
|
||||
MEMPALACE_EMBEDDING_DEVICE=auto
|
||||
|
||||
# --- Optional: native TLS (otherwise terminate TLS at a reverse proxy) ----------
|
||||
# Point these at a PEM cert/key the service account can read. When set, serve
|
||||
# speaks HTTPS directly and clients connect to https://...
|
||||
# MEMPALACE_MCP_TLS_CERT=/etc/mempalace/tls/cert.pem
|
||||
# MEMPALACE_MCP_TLS_KEY=/etc/mempalace/tls/key.pem
|
||||
|
||||
# --- Palace location (systemd / bare-metal) -------------------------------------
|
||||
# In Docker the palace lives on the mempalace-data volume by default.
|
||||
# MEMPALACE_PALACE_PATH=/var/lib/mempalace/palace
|
||||
@@ -0,0 +1,45 @@
|
||||
# Authored date (`authored_at`)
|
||||
|
||||
Conversation transcripts carry a per-line ISO-8601 `timestamp` (both Claude Code and
|
||||
Codex JSONL). The miner records the most recent one per file as the drawer's
|
||||
**`authored_at`** — when the content was actually written.
|
||||
|
||||
This is distinct from the ingest date:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `filed_at` / result `created_at` | When the drawer was **mined** (written to the palace). A bulk re-mine collapses these to a single instant. |
|
||||
| `authored_at` | When the underlying content was **written**, recovered from the transcript timestamps. Survives re-mining. |
|
||||
|
||||
`authored_at` is surfaced in search results (and shown in the CLI `search` output), and is
|
||||
used as a deterministic tie-break in hybrid ranking: candidates with identical scores order
|
||||
with the more recently authored drawer first. Drawers without per-line timestamps (e.g.
|
||||
markdown) fall back to `filed_at`.
|
||||
|
||||
## Backfilling existing memory
|
||||
|
||||
New mines populate `authored_at` automatically. Drawers mined before this feature only have
|
||||
`filed_at`. Re-mining does **not** fix them — the scanner skips files already mined at the
|
||||
current `NORMALIZE_VERSION`. Two options:
|
||||
|
||||
1. **In-place backfill (recommended — no re-embedding).** `scripts/backfill_authored_at.py`
|
||||
reads each convos drawer's source transcript and updates only the `authored_at` metadata.
|
||||
Idempotent and safe to re-run; embeddings are untouched.
|
||||
|
||||
```bash
|
||||
python scripts/backfill_authored_at.py \
|
||||
--palace ~/.mempalace/palace \
|
||||
--sessions ~/.claude --sessions ~/.codex # dry run
|
||||
python scripts/backfill_authored_at.py \
|
||||
--palace ~/.mempalace/palace \
|
||||
--sessions ~/.claude --sessions ~/.codex --apply # write
|
||||
```
|
||||
|
||||
For the Docker MCP image, mount the volume and session dirs read-only — see the header of
|
||||
`scripts/backfill_authored_at.py` for the exact `docker run` invocation.
|
||||
|
||||
> Back up first: `tar czf palace-backup.tgz -C <palace-dir> .` (or snapshot the
|
||||
> `mempalace-data` volume).
|
||||
|
||||
2. **Drop and recreate.** Delete the affected drawers and re-mine the transcripts; the fresh
|
||||
mine stamps `authored_at`. Simpler, but re-embeds everything.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Write routing policy
|
||||
|
||||
This document defines the shared policy used by the staged Tier 3 daemon
|
||||
rollout tracked in #1963.
|
||||
|
||||
This foundation PR does not change existing hook or CLI routing. It provides
|
||||
one tested policy model that later hook and CLI PRs can consume without
|
||||
inventing different fallback rules.
|
||||
|
||||
## Policies
|
||||
|
||||
`direct`
|
||||
|
||||
Always execute through the existing direct local path.
|
||||
|
||||
`prefer`
|
||||
|
||||
Use an available daemon. A caller that is allowed to start the daemon may
|
||||
do so. Otherwise, fall back to the direct path.
|
||||
|
||||
`require`
|
||||
|
||||
Use an available daemon. A caller that is allowed to start the daemon may
|
||||
do so. If neither is possible, block the operation. Never fall back to a
|
||||
direct ChromaDB writer.
|
||||
|
||||
## Concrete routing outcomes
|
||||
|
||||
The shared decision function returns one of:
|
||||
|
||||
- `direct`
|
||||
- `daemon`
|
||||
- `blocked`
|
||||
|
||||
It also reports whether the caller should auto-start the daemon and why the
|
||||
route was selected.
|
||||
|
||||
Hooks generally pass `daemon_can_start=False` because hook execution has a
|
||||
tight latency budget.
|
||||
|
||||
Interactive CLI commands can pass `daemon_can_start=True`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Global environment policy:
|
||||
|
||||
MEMPALACE_WRITE_ROUTING=direct|prefer|require
|
||||
|
||||
Hook-specific environment policy:
|
||||
|
||||
MEMPALACE_HOOK_WRITE_ROUTING=direct|prefer|require
|
||||
|
||||
CLI-specific environment policy:
|
||||
|
||||
MEMPALACE_CLI_WRITE_ROUTING=direct|prefer|require
|
||||
|
||||
Configuration-file shape:
|
||||
|
||||
{
|
||||
"write_routing": {
|
||||
"default": "direct",
|
||||
"hooks": "prefer",
|
||||
"cli": "require"
|
||||
}
|
||||
}
|
||||
|
||||
## Precedence
|
||||
|
||||
For hooks:
|
||||
|
||||
1. `MEMPALACE_HOOK_WRITE_ROUTING`
|
||||
2. `MEMPALACE_WRITE_ROUTING`
|
||||
3. legacy `MEMPALACE_HOOKS_DAEMON`
|
||||
4. `write_routing.hooks`
|
||||
5. `write_routing.default`
|
||||
6. legacy `hooks.daemon`
|
||||
7. `direct`
|
||||
|
||||
For CLI writes:
|
||||
|
||||
1. `MEMPALACE_CLI_WRITE_ROUTING`
|
||||
2. `MEMPALACE_WRITE_ROUTING`
|
||||
3. `write_routing.cli`
|
||||
4. `write_routing.default`
|
||||
5. `direct`
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
The existing `MEMPALACE_HOOKS_DAEMON` environment variable and
|
||||
`hooks.daemon` config value remain supported.
|
||||
|
||||
Legacy true values map to `prefer`.
|
||||
|
||||
Legacy false values map to `direct`.
|
||||
|
||||
The existing `MempalaceConfig.hook_use_daemon` property is intentionally
|
||||
unchanged in this PR. Hook and CLI behavior remains unchanged until their
|
||||
policy-aware rollout PRs land.
|
||||
|
||||
## Invalid policy values
|
||||
|
||||
New policy settings accept only:
|
||||
|
||||
- `direct`
|
||||
- `prefer`
|
||||
- `require`
|
||||
|
||||
Invalid values fail with a source-specific error rather than silently falling
|
||||
back. This is important because silently turning a misspelled `require` into a
|
||||
direct write would violate the safety purpose of the policy.
|
||||
|
||||
## Follow-up PRs
|
||||
|
||||
PR 2 will apply the policy to hook-triggered writes.
|
||||
|
||||
PR 3 will apply the policy to routine CLI writes.
|
||||
|
||||
Maintenance operations such as repair, migration, and index rebuild are not
|
||||
ordinary routed writes. They require a separate exclusive-maintenance policy.
|
||||
+22
-1
@@ -18,6 +18,7 @@ It covers hook wiring, JSONL backup, and one-time backfill.
|
||||
| Hook | When It Fires | What Happens |
|
||||
|------|--------------|-------------|
|
||||
| **Save Hook** | Every 15 human messages | Auto-mines transcript (tool output included), then blocks the AI to save topics/decisions/quotes |
|
||||
| **SessionEnd Hook** | Clean session exit | Backgrounds a final transcript mine (when a transcript exists) so short sessions aren't lost; returns immediately so teardown is never delayed. A lightweight diary checkpoint is written in the detached child. |
|
||||
| **PreCompact Hook** | Right before context compaction | Auto-mines transcript, then emergency save — forces the AI to save EVERYTHING before losing context |
|
||||
|
||||
**Two-layer capture:** Hooks auto-mine the JSONL transcript directly into the palace (capturing raw tool output — Bash results, search findings, build errors). They also block the AI with a reason message telling it to save verbatim tool output and key context. Belt and suspenders — tool output gets stored even if the AI summarizes instead of quoting.
|
||||
@@ -37,6 +38,13 @@ Add to `.claude/settings.local.json`:
|
||||
"timeout": 30
|
||||
}]
|
||||
}],
|
||||
"SessionEnd": [{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "/absolute/path/to/hooks/mempal_session_end_hook.sh",
|
||||
"timeout": 10
|
||||
}]
|
||||
}],
|
||||
"PreCompact": [{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
@@ -48,9 +56,15 @@ Add to `.claude/settings.local.json`:
|
||||
}
|
||||
```
|
||||
|
||||
`SessionEnd` runs once on a clean exit and backgrounds its work, so it
|
||||
returns instantly and stays within Claude Code's SessionEnd budget. Wired
|
||||
through `settings.local.json` (above) the `timeout` can raise that budget;
|
||||
the bundled plugin cannot, which is why the hook backgrounds rather than
|
||||
mining in the foreground.
|
||||
|
||||
Make them executable:
|
||||
```bash
|
||||
chmod +x hooks/mempal_save_hook.sh hooks/mempal_precompact_hook.sh
|
||||
chmod +x hooks/mempal_save_hook.sh hooks/mempal_session_end_hook.sh hooks/mempal_precompact_hook.sh
|
||||
```
|
||||
|
||||
## Install — Antigravity (Google)
|
||||
@@ -90,6 +104,13 @@ Add to `.codex/hooks.json`:
|
||||
}
|
||||
```
|
||||
|
||||
**Other harnesses:** the clean-exit save runs through the harness-agnostic
|
||||
`mempalace hook run --hook session-end` entry point. This release wires it
|
||||
for Claude Code. Antigravity exposes no dedicated session-end event (its
|
||||
lifecycle hooks are PreToolUse/PostToolUse/PreInvocation/PostInvocation/Stop,
|
||||
and MemPalace already saves there via `Stop`); Cursor and Codex can adopt the
|
||||
same entry point as a follow-up wherever their own session-end event is available.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `mempal_save_hook.sh` to change:
|
||||
|
||||
@@ -163,14 +163,12 @@ _mempal_build_followup() {
|
||||
import json, sys
|
||||
wing = sys.argv[1] if len(sys.argv) > 1 else "cursor_session"
|
||||
msg = (
|
||||
"MemPalace save checkpoint. "
|
||||
"(1) Call mempalace_check_duplicate on the key topics, decisions, "
|
||||
"and verbatim quotes from this session. "
|
||||
"(2) For each non-duplicate, call mempalace_add_drawer (wing="
|
||||
+ wing + ", room=<short topic>, content=verbatim quote). "
|
||||
"(3) Call mempalace_diary_write (agent_name=cursor-ide, wing="
|
||||
+ wing + ", entry=AAAK-format summary). "
|
||||
"Then stop."
|
||||
"MemPalace save checkpoint. Call mempalace_checkpoint ONCE with: "
|
||||
"items=[{wing: " + wing + ", room: <short topic>, content: <verbatim "
|
||||
"quote>}, ...] for the key topics, decisions, and verbatim quotes from "
|
||||
"this session; and diary={agent_name: cursor-ide, wing: " + wing + ", "
|
||||
"entry: <AAAK-format summary>}. It dedups, files non-duplicates, and "
|
||||
"writes the diary in one call. Then stop."
|
||||
)
|
||||
print(json.dumps({"followup_message": msg}))
|
||||
' "$WING"
|
||||
|
||||
@@ -182,14 +182,14 @@ echo "[$(date '+%H:%M:%S')] PRE-COMPACT triggered for session $SESSION_ID" >> "$
|
||||
# 1. TRANSCRIPT_PATH (from Claude Code) → parent dir, --mode convos
|
||||
# 2. MEMPAL_DIR → --mode projects
|
||||
if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
||||
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
||||
>> "$STATE_DIR/hook.log" 2>&1
|
||||
elif [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
echo "[$(date '+%H:%M:%S')] Skipping missing or invalid transcript path after normalization: $TRANSCRIPT_PATH" \
|
||||
>> "$STATE_DIR/hook.log"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$STATE_DIR/hook.log" 2>&1
|
||||
fi
|
||||
|
||||
|
||||
@@ -265,14 +265,14 @@ if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then
|
||||
# MEMPAL_DIR is *additive*, not an override: a user with MEMPAL_DIR
|
||||
# pointed at their project still gets the active conversation mined.
|
||||
if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
||||
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
||||
>> "$STATE_DIR/hook.log" 2>&1 &
|
||||
elif [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" \
|
||||
>> "$STATE_DIR/hook.log"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$STATE_DIR/hook.log" 2>&1 &
|
||||
fi
|
||||
|
||||
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# MemPalace SessionEnd Hook — final save on clean exit.
|
||||
#
|
||||
# Claude Code documents a default SessionEnd hook timeout of 1.5s; a per-hook
|
||||
# "timeout" in settings.local.json can raise it (up to 60s), but a
|
||||
# plugin-provided timeout cannot (https://code.claude.com/docs/en/hooks). A cold
|
||||
# `mempalace` start alone can exceed 1.5s, so we background the hook and return
|
||||
# immediately; the detached child finishes the save after the session has
|
||||
# exited. All logic lives in mempalace.hooks_cli for cross-harness extensibility.
|
||||
run_mempalace_hook() {
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
exec mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
MEMPAL_PYTHON_BIN="${MEMPAL_PYTHON:-}"
|
||||
if [ -z "$MEMPAL_PYTHON_BIN" ] || [ ! -x "$MEMPAL_PYTHON_BIN" ]; then
|
||||
MEMPAL_PYTHON_BIN="$(command -v python3 2>/dev/null || echo python3)"
|
||||
fi
|
||||
if "$MEMPAL_PYTHON_BIN" -c "import mempalace" >/dev/null 2>&1; then
|
||||
exec "$MEMPAL_PYTHON_BIN" -m mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
|
||||
exec python -m mempalace hook run "$@"
|
||||
fi
|
||||
|
||||
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Capture stdin (the SessionEnd JSON) before backgrounding — the parent's
|
||||
# stdin is gone once we return. Forward it to the detached worker, which runs
|
||||
# the final mine on its own time and outlives this process.
|
||||
payload="$(cat)"
|
||||
(
|
||||
printf '%s' "$payload" | run_mempalace_hook --hook session-end --harness "${MEMPALACE_HOOK_HARNESS:-claude-code}"
|
||||
) >/dev/null 2>&1 </dev/null &
|
||||
disown 2>/dev/null || true
|
||||
|
||||
# Return immediately so the harness never blocks on session exit.
|
||||
printf '{}'
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: mempalace
|
||||
description: "MemPalace — Local AI memory with 96.6% recall. Semantic search, temporal knowledge graph, palace architecture (wings/rooms/drawers). Free, no cloud, no API keys."
|
||||
version: 3.3.0
|
||||
version: 3.6.0
|
||||
homepage: https://github.com/MemPalace/mempalace
|
||||
user-invocable: true
|
||||
metadata:
|
||||
@@ -46,6 +46,10 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
||||
|
||||
## Available Tools
|
||||
|
||||
Full MCP surface: 36 tools. Destructive or host-level tools are documented so
|
||||
you know they exist, but use them only when the user explicitly asks or when a
|
||||
tool-specific workflow below says to.
|
||||
|
||||
### Search & Browse
|
||||
- `mempalace_search` — Semantic search across all memories. Always start here.
|
||||
- `query` (required): natural language search — keep it short, keywords or a question. Do NOT include system prompts or conversation context.
|
||||
@@ -58,6 +62,14 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
||||
- `mempalace_status` — Palace overview: total drawers, wings, rooms, AAAK spec
|
||||
- `mempalace_list_wings` — All wings with drawer counts
|
||||
- `mempalace_list_rooms` — Rooms within a wing (optional wing filter)
|
||||
- `mempalace_list_drawers` — Paginated drawer listing
|
||||
- `wing`, `room`: optional filters
|
||||
- `since`: only drawers filed on/after this ISO date/time
|
||||
- `before`: only drawers filed before this ISO date/time
|
||||
- `limit`: max results (default 20)
|
||||
- `offset`: pagination offset (default 0)
|
||||
- `mempalace_get_drawer` — Fetch a single drawer by ID. Returns full verbatim content and metadata.
|
||||
- `drawer_id` (required)
|
||||
- `mempalace_get_taxonomy` — Full wing/room/count tree
|
||||
- `mempalace_get_aaak_spec` — Get AAAK compression dialect specification
|
||||
|
||||
@@ -81,17 +93,59 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
||||
- `mempalace_traverse` — Walk from a room, find connected ideas across wings
|
||||
- `start_room` (required): room to start from
|
||||
- `max_hops`: connection depth (default 2)
|
||||
- `mempalace_find_tunnels` — Find rooms that bridge two wings
|
||||
- `wing_a`, `wing_b` (required)
|
||||
- `mempalace_find_tunnels` — Find rooms that bridge two wings via *implicit* overlap (rooms whose drawers naturally share content across wings — discovered, not declared)
|
||||
- `wing_a`, `wing_b`: optional filters; omit both to scan all wing pairs
|
||||
- `mempalace_create_tunnel` — Create an *explicit* cross-wing tunnel: a user/agent-declared link between two locations. Use when you notice content in one project relates to another (e.g. API design in `project_api` connects to schema in `project_database`).
|
||||
- `source_wing`, `source_room`, `target_wing`, `target_room` (required)
|
||||
- `label`: short description of the relationship
|
||||
- `source_drawer_id`, `target_drawer_id`: anchor to specific drawers
|
||||
- `mempalace_list_tunnels` — List all explicit tunnels, optionally filtered by wing
|
||||
- `wing`: optional filter
|
||||
- `mempalace_delete_tunnel` — Remove an explicit tunnel by ID
|
||||
- `tunnel_id` (required)
|
||||
- `mempalace_list_hallways` — List within-wing entity hallways (entity-to-entity co-occurrence links built at mine time)
|
||||
- `wing`: optional filter
|
||||
- `mempalace_delete_hallway` — Remove a hallway record by ID
|
||||
- `hallway_id` (required)
|
||||
- `mempalace_follow_tunnels` — From a room, follow explicit tunnels to connected drawers in other wings
|
||||
- `wing`, `room` (required)
|
||||
- `mempalace_graph_stats` — Graph connectivity overview
|
||||
|
||||
### Write
|
||||
- `mempalace_add_drawer` — Store verbatim content into a wing/room
|
||||
- `wing`, `room`, `content` (required)
|
||||
- `source_file`: optional source reference
|
||||
- `added_by`: optional filing agent label
|
||||
- Checks for duplicates automatically
|
||||
- `mempalace_checkpoint` — Save a whole session in one call: dedup each item, file non-duplicates, then write one diary entry
|
||||
- `items` (required): array of `{wing, room, content}`; content must be verbatim
|
||||
- `diary`: optional `{agent_name, entry, topic?, wing?}`; entry should use AAAK format
|
||||
- `dedup_threshold`: similarity threshold (default 0.9)
|
||||
- `added_by`: optional filing agent label (defaults to the diary `agent_name`, else `checkpoint`)
|
||||
- `mempalace_update_drawer` — Update an existing drawer's content and/or move it to a different wing/room
|
||||
- `drawer_id` (required)
|
||||
- `content`, `wing`, `room`: at least one must be provided (no-op otherwise)
|
||||
- `mempalace_delete_drawer` — Remove a drawer by ID
|
||||
- `drawer_id` (required)
|
||||
|
||||
### Ingest & Cleanup
|
||||
- `mempalace_mine` — Mine a directory into the palace. Host-level ingest; call only when the user asks to import files.
|
||||
- `source` (required): directory to mine
|
||||
- `mode`: `projects` (default), `convos`, or `extract`
|
||||
- `wing`: target wing (default: source directory name)
|
||||
- `agent`: recorded on every drawer (default `mempalace`)
|
||||
- `limit`: max files to process (0 = all)
|
||||
- `dry_run`: preview without writing
|
||||
- `extract`: convos extraction strategy (`exchange` default, or `general`)
|
||||
- `mempalace_sync` — Prune drawers whose source files are gitignored, deleted, or moved. Use dry-run first.
|
||||
- `project_dir`: optional project root scope
|
||||
- `wing`: optional wing scope
|
||||
- `apply`: actually delete; default is dry-run preview
|
||||
- `mempalace_delete_by_source` — Bulk-delete drawers with one exact `source_file`. Destructive; use dry-run first.
|
||||
- `source_file` (required): exact metadata value to remove
|
||||
- `dry_run`: preview match count and sample (default true)
|
||||
|
||||
### Diary & Session
|
||||
- `mempalace_diary_write` — Write a session diary entry
|
||||
- `agent_name` (required): your name/identifier
|
||||
- `entry` (required): what happened, what you learned, what matters
|
||||
@@ -99,6 +153,15 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
||||
- `mempalace_diary_read` — Read recent diary entries
|
||||
- `agent_name` (required)
|
||||
- `last_n`: number of entries (default 10)
|
||||
- `mempalace_memories_filed_away` — Acknowledge the latest silent auto-save checkpoint.
|
||||
- Returns: how many messages were tucked into drawers since the last ack
|
||||
- When to call: at the START of a session, to confirm prior-conversation persistence
|
||||
|
||||
### System
|
||||
- `mempalace_hook_settings` — Get or set auto-save hook behavior. Host-level setting; do not change silently.
|
||||
- `silent_save`: true saves directly without MCP-level clutter
|
||||
- `desktop_toast`: true shows a desktop notification when saves complete
|
||||
- `mempalace_reconnect` — Force reconnect to the palace database after external writes or stale index state
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -67,10 +67,40 @@ question — not a system prompt or pasted conversation) plus optional
|
||||
- **MCP unavailable / tool error.** Surface the error plainly and suggest
|
||||
the user verify the server (`mempalace status`, or re-run install).
|
||||
Do not silently fall back to guessing from model memory.
|
||||
- **Palace index corrupt / compactor error.** When the server returns an
|
||||
error mentioning the HNSW segment writer, a ChromaDB compaction
|
||||
failure, or a stuck "Not connected" state after a write, the on-disk
|
||||
vector index is out of sync with `chroma.sqlite3` — but the drawer rows
|
||||
are intact in SQLite. Recover by rebuilding the index from SQLite, not
|
||||
by re-mining. See "Recovering a corrupt index" below. Do not attempt an
|
||||
in-process repair from the agent; guide the user to run the CLI.
|
||||
- **Stale or conflicting facts.** Prefer the knowledge graph's
|
||||
time-valid answer; if a fact has changed, invalidate the old one and
|
||||
add the new one rather than overwriting context silently.
|
||||
|
||||
## Recovering a corrupt index
|
||||
|
||||
A ChromaDB compaction failure can leave the drawers HNSW index out of
|
||||
sync with `chroma.sqlite3` and wedge the MCP server (every call returns
|
||||
"Not connected"). The data is safe in SQLite; rebuild the index from it.
|
||||
Guide the user through these CLI steps — never run an in-process rebuild
|
||||
from the agent (it can break other live clients):
|
||||
|
||||
1. Stop the MCP server (kill the `mempalace-mcp` process, or restart the
|
||||
host editor).
|
||||
2. Optional backup of the palace directory (`--archive-existing` already
|
||||
moves the old palace aside, so this is belt-and-suspenders):
|
||||
- macOS / Linux: `cp -a ~/.mempalace/palace ~/.mempalace/palace.bak.$(date +%F)`
|
||||
- Windows (PowerShell): `Copy-Item -Recurse "$env:USERPROFILE\.mempalace\palace" "$env:USERPROFILE\.mempalace\palace.bak"`
|
||||
3. Rebuild from SQLite:
|
||||
`mempalace repair --mode from-sqlite --archive-existing --yes`
|
||||
4. Verify: `mempalace repair-status` (divergence should read 0).
|
||||
5. Restart the MCP server.
|
||||
|
||||
Do **not** re-mine from source files to recover: re-mining drops drawers
|
||||
added through the MCP server and diary entries, which have no source file
|
||||
(see MemPalace issue #1843).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Answering about past work, people, or decisions from model memory when
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ The Python package that powers MemPalace. All modules, all logic.
|
||||
| `dialect.py` | AAAK compression — entity codes, emotion markers, 30x lossless ratio |
|
||||
| `knowledge_graph.py` | Temporal entity-relationship graph — SQLite, time-filtered queries, fact invalidation |
|
||||
| `palace_graph.py` | Room-based navigation graph — BFS traversal, tunnel detection across wings |
|
||||
| `mcp_server.py` | MCP server — 33 tools, AAAK auto-teach, Palace Protocol, agent diary |
|
||||
| `mcp_server.py` | MCP server — 34 tools, AAAK auto-teach, Palace Protocol, agent diary |
|
||||
| `onboarding.py` | Guided first-run setup — asks about people/projects, generates AAAK bootstrap + wing config |
|
||||
| `entity_registry.py` | Entity code registry — maps names to AAAK codes, handles ambiguous names |
|
||||
| `entity_detector.py` | Auto-detect people and projects from file content |
|
||||
|
||||
@@ -36,6 +36,7 @@ from .base import (
|
||||
UnsupportedMaintenanceKindError,
|
||||
)
|
||||
from .chroma import ChromaBackend, ChromaCollection
|
||||
from .milvus import MilvusBackend, MilvusCollection
|
||||
from .pgvector import PgVectorBackend, PgVectorCollection
|
||||
from .qdrant import QdrantBackend, QdrantCollection
|
||||
from .sqlite_exact import SQLiteExactBackend, SQLiteExactCollection
|
||||
@@ -67,6 +68,8 @@ __all__ = [
|
||||
"LexicalHit",
|
||||
"LexicalResult",
|
||||
"MaintenanceResult",
|
||||
"MilvusBackend",
|
||||
"MilvusCollection",
|
||||
"PalaceNotFoundError",
|
||||
"PalaceRef",
|
||||
"PgVectorBackend",
|
||||
|
||||
@@ -468,6 +468,48 @@ class BaseCollection(ABC):
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_all_metadata(self, where: Optional[dict] = None) -> list[dict]:
|
||||
"""Return every matching record's metadata in one logical pass (#1796).
|
||||
|
||||
Default implementation pages through :meth:`get` using
|
||||
``limit``/``offset`` -- correct for backends with a real server-side
|
||||
cursor (e.g. Chroma's SQL OFFSET), and the same shape callers already
|
||||
relied on before this method existed.
|
||||
|
||||
Backends whose ``get(limit=, offset=)`` is implemented by fully
|
||||
materializing a result set and then Python-slicing it (no true
|
||||
server-side cursor) MUST override this method to walk their native
|
||||
cursor exactly once instead. Calling the default implementation on
|
||||
such a backend is O(n^2) in collection size: each page re-walks the
|
||||
entire collection just to discard everything outside the requested
|
||||
slice. See issue #1796.
|
||||
"""
|
||||
all_meta: list[dict] = []
|
||||
offset = 0
|
||||
page_size = 1000
|
||||
while True:
|
||||
kwargs: dict = {"include": ["metadatas"], "limit": page_size, "offset": offset}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
batch = self.get(**kwargs)
|
||||
batch_meta = batch.metadatas if hasattr(batch, "metadatas") else batch.get("metadatas")
|
||||
if not batch_meta:
|
||||
break
|
||||
all_meta.extend(batch_meta)
|
||||
if len(batch_meta) < page_size:
|
||||
break
|
||||
offset += len(batch_meta)
|
||||
return all_meta
|
||||
|
||||
def facet_counts(
|
||||
self,
|
||||
field: str,
|
||||
where: Optional[dict] = None,
|
||||
limit: int = 1000,
|
||||
) -> dict[str, int]:
|
||||
"""Return counts for each distinct value of a metadata field."""
|
||||
raise UnsupportedCapabilityError("backend does not support facet_counts")
|
||||
|
||||
def maintenance_state(self) -> dict:
|
||||
"""Return a structured snapshot of this collection's maintenance state.
|
||||
|
||||
|
||||
+184
-39
@@ -8,7 +8,9 @@ import math
|
||||
import os
|
||||
import pickle
|
||||
import re
|
||||
import shlex
|
||||
import sqlite3
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from numbers import Integral
|
||||
from pathlib import Path
|
||||
@@ -17,6 +19,7 @@ from typing import Any, Optional
|
||||
import chromadb
|
||||
from chromadb.errors import NotFoundError as _ChromaNotFoundError
|
||||
|
||||
from ..config import sqlite_read_uri
|
||||
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
|
||||
from .base import (
|
||||
BaseBackend,
|
||||
@@ -79,13 +82,52 @@ def _hnsw_link_to_data_ratio(seg_dir: str) -> Optional[float]:
|
||||
return link_size / data_size
|
||||
|
||||
|
||||
def _hnsw_link_lists_is_usable_for_payload(seg_dir: str) -> bool:
|
||||
"""Return False when a non-trivial HNSW payload lacks usable link lists.
|
||||
def _hnsw_metadata_marker_intact(seg_dir: str) -> bool:
|
||||
"""Return True when ``index_metadata.pickle`` bears a complete envelope.
|
||||
|
||||
A missing or empty link_lists.bin is acceptable only for a fresh/empty
|
||||
segment. Once data_level0.bin has real payload, a zero-byte link_lists.bin
|
||||
is not a harmless async-flush shape: ChromaDB can later hand the broken
|
||||
graph to hnswlib and crash in native code.
|
||||
ChromaDB writes ``index_metadata.pickle`` last during a persist, so an
|
||||
intact pickle envelope — protocol marker ``0x80`` at the head, ``STOP``
|
||||
byte ``0x2e`` at the tail — proves the flush finished. Used as a
|
||||
persist-completion marker: when present, the segment's on-disk shape
|
||||
(including an all-layer-0 index with an empty ``link_lists.bin``) is the
|
||||
one chromadb intentionally serialized, not a half-written one.
|
||||
|
||||
Deliberately byte-sniffs only; never deserializes. Deserialization can
|
||||
execute arbitrary code, and the byte-sniff is enough to tell a complete
|
||||
write from truncation or zero-fill. Assumes pickle protocol >= 2.
|
||||
"""
|
||||
meta_path = os.path.join(seg_dir, "index_metadata.pickle")
|
||||
try:
|
||||
if not os.path.isfile(meta_path) or os.path.getsize(meta_path) < 16:
|
||||
return False
|
||||
with open(meta_path, "rb") as f:
|
||||
head = f.read(2)
|
||||
f.seek(-1, 2) # last byte
|
||||
tail = f.read(1)
|
||||
except OSError:
|
||||
return False
|
||||
return len(head) == 2 and head[0] == 0x80 and tail == b"\x2e"
|
||||
|
||||
|
||||
def _hnsw_link_lists_is_usable_for_payload(seg_dir: str) -> bool:
|
||||
"""Return False when a non-trivial HNSW payload looks like a partial flush.
|
||||
|
||||
A zero-byte ``link_lists.bin`` is *not* corruption on its own. hnswlib
|
||||
stores the entire layer-0 graph inside ``data_level0.bin`` and only writes
|
||||
``link_lists.bin`` for elements promoted to level > 0. A small or
|
||||
low-fanout index where every element stays on layer 0 therefore
|
||||
serializes an empty ``link_lists.bin`` and loads/searches fine (#1716).
|
||||
Treating that shape as corruption produced a self-perpetuating quarantine
|
||||
loop: repair rebuilt the byte-identical all-layer-0 segment, which the next
|
||||
cold start quarantined again, accumulating drift dirs without bound.
|
||||
|
||||
An empty ``link_lists.bin`` only signals trouble when the persist was
|
||||
interrupted before it could finish. ChromaDB writes
|
||||
``index_metadata.pickle`` last, so an intact metadata envelope proves the
|
||||
flush completed and the empty ``link_lists.bin`` is the legitimate
|
||||
all-layer-0 shape. Only when there is real payload, an empty
|
||||
``link_lists.bin``, *and* no completion marker do we treat the segment as a
|
||||
partial flush.
|
||||
"""
|
||||
data_path = os.path.join(seg_dir, "data_level0.bin")
|
||||
link_path = os.path.join(seg_dir, "link_lists.bin")
|
||||
@@ -98,10 +140,16 @@ def _hnsw_link_lists_is_usable_for_payload(seg_dir: str) -> bool:
|
||||
if data_size <= _HNSW_MISSING_METADATA_DATA_FLOOR:
|
||||
return True
|
||||
|
||||
return os.path.isfile(link_path) and os.path.getsize(link_path) > 0
|
||||
if os.path.isfile(link_path) and os.path.getsize(link_path) > 0:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# Real payload with an empty/absent link_lists.bin: legitimate only when
|
||||
# the persist completed (all-layer-0 index), proven by an intact metadata
|
||||
# marker. Otherwise it is a half-written segment chromadb could segfault on.
|
||||
return _hnsw_metadata_marker_intact(seg_dir)
|
||||
|
||||
|
||||
def _hnsw_payload_appears_sane(seg_dir: str) -> bool:
|
||||
"""Return False when HNSW payload files are structurally implausible."""
|
||||
@@ -334,17 +382,7 @@ def _segment_appears_healthy(seg_dir: str) -> bool:
|
||||
if not _hnsw_payload_appears_sane(seg_dir):
|
||||
return False
|
||||
|
||||
try:
|
||||
size = os.path.getsize(meta_path)
|
||||
if size < 16:
|
||||
return False
|
||||
with open(meta_path, "rb") as f:
|
||||
head = f.read(2)
|
||||
f.seek(-1, 2) # last byte
|
||||
tail = f.read(1)
|
||||
except OSError:
|
||||
return False
|
||||
return len(head) == 2 and head[0] == 0x80 and tail == b"\x2e"
|
||||
return _hnsw_metadata_marker_intact(seg_dir)
|
||||
|
||||
|
||||
def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 300.0) -> list[str]:
|
||||
@@ -457,7 +495,7 @@ def _vector_segment_id(palace_path: str, collection_name: str) -> Optional[str]:
|
||||
if not os.path.isfile(db_path):
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
@@ -606,6 +644,7 @@ def _hnsw_element_count(palace_path: str, segment_id: str) -> Optional[int]:
|
||||
# sync_threshold) from expected steady-state lag.
|
||||
_HNSW_DIVERGENCE_FALLBACK_FLOOR = 2000
|
||||
_HNSW_DIVERGENCE_FRACTION = 0.10
|
||||
_HNSW_PERSISTENT_DIVERGENCE_GRACE_SECONDS = 300.0
|
||||
|
||||
|
||||
def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
|
||||
@@ -626,7 +665,7 @@ def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
|
||||
if not os.path.isfile(db_path):
|
||||
return 1000
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
@@ -649,6 +688,45 @@ def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
|
||||
return 1000
|
||||
|
||||
|
||||
def _collection_has_sync_threshold_metadata(palace_path: str, collection_name: str) -> bool:
|
||||
"""Return True when the collection explicitly stores hnsw:sync_threshold."""
|
||||
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM collection_metadata cm
|
||||
JOIN collections c ON cm.collection_id = c.id
|
||||
WHERE c.name = ?
|
||||
AND cm.key = 'hnsw:sync_threshold'
|
||||
LIMIT 1
|
||||
""",
|
||||
(collection_name,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
logger.debug("_collection_has_sync_threshold_metadata failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _hnsw_metadata_age_seconds(palace_path: str, segment_id: str) -> Optional[float]:
|
||||
"""Return index_metadata.pickle age in seconds, or None when unreadable."""
|
||||
|
||||
pickle_path = os.path.join(palace_path, segment_id, "index_metadata.pickle")
|
||||
try:
|
||||
return max(0.0, time.time() - os.path.getmtime(pickle_path))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_drawers") -> dict:
|
||||
"""Compare sqlite embedding count against HNSW element count.
|
||||
|
||||
@@ -693,11 +771,15 @@ def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_dra
|
||||
|
||||
hnsw_count = _hnsw_element_count(palace_path, seg_id)
|
||||
out["hnsw_count"] = hnsw_count
|
||||
|
||||
sync_threshold = _read_sync_threshold(palace_path, collection_name)
|
||||
# Two synchronization windows worth — see comment above
|
||||
# _HNSW_DIVERGENCE_FALLBACK_FLOOR for the rationale.
|
||||
divergence_floor = max(_HNSW_DIVERGENCE_FALLBACK_FLOOR, 2 * sync_threshold)
|
||||
has_explicit_sync_threshold = _collection_has_sync_threshold_metadata(
|
||||
palace_path,
|
||||
collection_name,
|
||||
)
|
||||
metadata_age_seconds = (
|
||||
_hnsw_metadata_age_seconds(palace_path, seg_id) if hnsw_count is not None else None
|
||||
)
|
||||
out["hnsw_metadata_age_seconds"] = metadata_age_seconds
|
||||
|
||||
if hnsw_count is None:
|
||||
# No pickle yet, so this probe cannot measure HNSW capacity.
|
||||
@@ -715,21 +797,53 @@ def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_dra
|
||||
|
||||
divergence = sqlite_count - hnsw_count
|
||||
out["divergence"] = divergence
|
||||
threshold = max(divergence_floor, int(sqlite_count * _HNSW_DIVERGENCE_FRACTION))
|
||||
if divergence > threshold:
|
||||
|
||||
# Newer palaces explicitly store mempalace's low sync threshold
|
||||
# (currently 2), so a gap of dozens of rows is far beyond ordinary
|
||||
# flush lag. Older palaces may lack the metadata row; keep the
|
||||
# historical floor for fresh lag there, but do not let a stale pickle
|
||||
# sit below the floor forever (#1816).
|
||||
if has_explicit_sync_threshold:
|
||||
threshold = max(0, 2 * sync_threshold)
|
||||
else:
|
||||
divergence_floor = max(_HNSW_DIVERGENCE_FALLBACK_FLOOR, 2 * sync_threshold)
|
||||
threshold = max(
|
||||
divergence_floor,
|
||||
int(sqlite_count * _HNSW_DIVERGENCE_FRACTION),
|
||||
)
|
||||
|
||||
out["threshold"] = threshold
|
||||
stale_below_threshold = (
|
||||
not has_explicit_sync_threshold
|
||||
and divergence > 0
|
||||
and metadata_age_seconds is not None
|
||||
and metadata_age_seconds >= _HNSW_PERSISTENT_DIVERGENCE_GRACE_SECONDS
|
||||
)
|
||||
|
||||
if divergence > threshold or stale_below_threshold:
|
||||
out["status"] = "diverged"
|
||||
out["diverged"] = True
|
||||
pct = 100.0 * divergence / max(sqlite_count, 1)
|
||||
if divergence > threshold:
|
||||
reason = f"exceeds threshold {threshold:,}"
|
||||
else:
|
||||
age = metadata_age_seconds or 0.0
|
||||
reason = f"persisted below the old flush-lag floor for {age:.0f}s"
|
||||
out["message"] = (
|
||||
f"HNSW index holds {hnsw_count:,} elements but sqlite has "
|
||||
f"{sqlite_count:,} embeddings — {divergence:,} drawers ({pct:.0f}%) "
|
||||
"are invisible to vector search. Run `mempalace repair` to rebuild."
|
||||
f"{sqlite_count:,} embeddings - {divergence:,} drawers "
|
||||
f"({pct:.0f}%) are missing from the flushed HNSW index "
|
||||
f"({reason}). Vector reads are disabled until "
|
||||
"`mempalace repair` rebuilds it."
|
||||
)
|
||||
else:
|
||||
out["status"] = "ok"
|
||||
out["message"] = (
|
||||
f"HNSW {hnsw_count:,} / sqlite {sqlite_count:,} (within flush-lag tolerance)"
|
||||
)
|
||||
if divergence < 0:
|
||||
out["message"] += " (HNSW has extra flushed elements; treating as safe)"
|
||||
|
||||
except Exception:
|
||||
logger.debug("hnsw_capacity_status failed", exc_info=True)
|
||||
out["message"] = "HNSW capacity probe raised; skipping"
|
||||
@@ -746,7 +860,7 @@ def _sqlite_embedding_count(palace_path: str, collection_name: str) -> Optional[
|
||||
if not os.path.isfile(db_path):
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
@@ -807,7 +921,7 @@ def _sqlite_wing_room_counts(
|
||||
if not os.path.isfile(db_path):
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
|
||||
try:
|
||||
# Wait out a transient writer/checkpoint lock rather than falling
|
||||
# straight back to the expensive vector-index path (#1681).
|
||||
@@ -1261,8 +1375,8 @@ class ChromaCollection(BaseCollection):
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_documents_for_chromadb(documents):
|
||||
"""Strip lone UTF-16 surrogates from every document before it reaches
|
||||
the chromadb client.
|
||||
"""Strip lone UTF-16 surrogates and embedded NUL characters from every
|
||||
document before it reaches the chromadb client.
|
||||
|
||||
A single lone surrogate (U+D800–U+DFFF) raises ``UnicodeEncodeError``
|
||||
inside chromadb's encode path and aborts the *entire* add/upsert batch
|
||||
@@ -1275,18 +1389,29 @@ class ChromaCollection(BaseCollection):
|
||||
directly. Sanitising here makes the chokepoint catch-all complete: the
|
||||
sibling :meth:`_sanitize_metadatas_for_chromadb` already guarantees this
|
||||
for metadata one method over; documents get the same guarantee.
|
||||
|
||||
A document containing an embedded NUL (U+0000) — routine in mined
|
||||
Bash tool output — is well-formed UTF-8, unlike a lone surrogate, but
|
||||
can corrupt the FTS5 inverted index for the whole collection rather
|
||||
than just failing to store that one row (a ChromaDB-side bug; tracked
|
||||
upstream). Stripping it here is the same defense-in-depth already
|
||||
applied to surrogates: sanitize input we don't control before it
|
||||
reaches a datastore we don't control.
|
||||
"""
|
||||
if documents is None:
|
||||
return None
|
||||
from ..config import strip_lone_surrogates
|
||||
from ..config import strip_lone_surrogates, strip_nul_bytes
|
||||
|
||||
def _sanitize(d):
|
||||
return strip_nul_bytes(strip_lone_surrogates(d))
|
||||
|
||||
# chromadb accepts OneOrMany[Document]: a bare str is a single document,
|
||||
# not an iterable of characters. Handle it explicitly so we don't split
|
||||
# it into per-character documents — that would be exactly the kind of
|
||||
# silent corruption this method exists to prevent.
|
||||
if isinstance(documents, str):
|
||||
return strip_lone_surrogates(documents)
|
||||
return [strip_lone_surrogates(d) if isinstance(d, str) else d for d in documents]
|
||||
return _sanitize(documents)
|
||||
return [_sanitize(d) if isinstance(d, str) else d for d in documents]
|
||||
|
||||
def add(self, *, documents, ids, metadatas=None, embeddings=None):
|
||||
kwargs: dict[str, Any] = {
|
||||
@@ -1570,7 +1695,7 @@ class ChromaCollection(BaseCollection):
|
||||
# rowid, embedding_id is the user-facing drawer id.
|
||||
public_ids: dict[int, str] = {}
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
except sqlite3.Error:
|
||||
logger.debug("Chroma lexical sqlite open failed", exc_info=True)
|
||||
@@ -1846,6 +1971,7 @@ class ChromaBackend(BaseBackend):
|
||||
current_model = MempalaceConfig().embedding_model
|
||||
except Exception:
|
||||
current_model = "unknown"
|
||||
rebuild_cmd = f"mempalace --palace {shlex.quote(palace_path)} repair rebuild-index"
|
||||
return (
|
||||
f"Embedding model mismatch reading palace at {palace_path!r}.\n"
|
||||
f" Underlying ChromaDB error: {msg}\n"
|
||||
@@ -1853,8 +1979,8 @@ class ChromaBackend(BaseBackend):
|
||||
f" The palace was built with a different embedding model. Either:\n"
|
||||
f" (a) revert the model: unset MEMPALACE_EMBEDDING_MODEL (or set "
|
||||
f"the previous value), or\n"
|
||||
f" (b) re-embed in place: `mempalace repair rebuild-index "
|
||||
f"--palace {palace_path}` (writes new vectors with the current model)."
|
||||
f" (b) re-embed in place: `{rebuild_cmd}` "
|
||||
f"(writes new vectors with the current model)."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -2113,7 +2239,26 @@ class ChromaBackend(BaseBackend):
|
||||
|
||||
@classmethod
|
||||
def detect(cls, path: str) -> bool:
|
||||
return os.path.isfile(os.path.join(path, "chroma.sqlite3"))
|
||||
"""Return True when ``path`` looks like a chroma palace.
|
||||
|
||||
Verifies the SQLite magic header rather than file presence alone.
|
||||
Bare ``sqlite3.connect()`` against a missing path leaves a 0-byte
|
||||
file behind (the SQLite header is written on the first statement,
|
||||
not on connection), so file-presence alone treats those artifacts
|
||||
as real chroma palaces and breaks multi-backend resolution. The
|
||||
16-byte ``SQLite format 3\\x00`` magic prefix is written as soon
|
||||
as chromadb's ``PersistentClient`` does any work, so this check
|
||||
accepts every real chroma palace while rejecting empty / garbage
|
||||
files. See #1893.
|
||||
"""
|
||||
db_path = os.path.join(path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return False
|
||||
try:
|
||||
with open(db_path, "rb") as f:
|
||||
return f.read(16) == b"SQLite format 3\x00"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Legacy (pre-RFC 001) surface — retained while callers migrate.
|
||||
|
||||
@@ -155,6 +155,30 @@ class EmbeddingCollection(BaseCollection):
|
||||
def lexical_search(self, *, query: str, n_results: int = 10, where: Optional[dict] = None):
|
||||
return self._inner.lexical_search(query=query, n_results=n_results, where=where)
|
||||
|
||||
def facet_counts(
|
||||
self, field: str, where: Optional[dict] = None, limit: int = 1000
|
||||
) -> dict[str, int]:
|
||||
# ``BaseCollection.facet_counts`` is a concrete method that raises
|
||||
# ``UnsupportedCapabilityError`` as its default. MRO resolves it on
|
||||
# this subclass before ``__getattr__`` ever fires, so without an
|
||||
# explicit forwarder every facet call against a wrapped backend
|
||||
# (qdrant, pgvector, sqlite_exact) raises and silently degrades to
|
||||
# client-side counting in mcp_server's try/except.
|
||||
return self._inner.facet_counts(field, where=where, limit=limit)
|
||||
|
||||
def get_all_metadata(self, where: Optional[dict] = None) -> list[dict]:
|
||||
# ``BaseCollection.get_all_metadata`` ships a concrete default that
|
||||
# pages through ``self.get(include=["metadatas"])``. Without this
|
||||
# forwarder, MRO resolves the call here on the subclass and runs the
|
||||
# base default — which routes back through ``self.get()`` (the
|
||||
# wrapper's get, then ``__getattr__`` to the inner's get). Result:
|
||||
# the inner's overridden ``get_all_metadata`` (e.g. pgvector's
|
||||
# ``with_document=False`` fast path from #1892) is never reached,
|
||||
# and every metadata-only fetch transfers the full document column
|
||||
# over the wire. Same MRO-shadow pattern as ``facet_counts`` /
|
||||
# ``lexical_search`` above.
|
||||
return self._inner.get_all_metadata(where=where)
|
||||
|
||||
def update(self, *, ids, documents=None, metadatas=None, embeddings=None):
|
||||
ids = _as_list(ids)
|
||||
if documents is not None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+151
-15
@@ -37,6 +37,7 @@ from urllib import parse as urlparse
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..config import strip_lone_surrogates
|
||||
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
|
||||
from .base import (
|
||||
BackendClosedError,
|
||||
@@ -80,6 +81,42 @@ def _json_dumps(obj: Any) -> str:
|
||||
return json.dumps(obj or {}, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
|
||||
def _strip_nul(value: Any) -> Any:
|
||||
"""Recursively strip NUL (0x00) from strings, list/tuple items, and dict keys
|
||||
and values so pgvector can store the result.
|
||||
|
||||
PostgreSQL cannot store NUL in ``text`` or ``jsonb``: psycopg rejects a raw
|
||||
NUL in a text column ("PostgreSQL text fields cannot contain NUL (0x00)
|
||||
bytes"), and a NUL in metadata serializes to a JSON unicode escape that the
|
||||
``jsonb`` cast rejects ("unsupported Unicode escape sequence"). A single
|
||||
transcript that captured NUL in tool output would otherwise abort the whole
|
||||
mine run (#1829). ChromaDB and the SQLite backend store the byte verbatim,
|
||||
so stripping only here keeps the same inputs ingestible.
|
||||
|
||||
Applied to id, document, and metadata in :meth:`_PgVectorClient.upsert_rows`
|
||||
so the write path never carries a NUL into Postgres. Only ``str`` values are
|
||||
rewritten; the ``int``/``float``/``bool``/``None`` scalars JSON metadata
|
||||
normalizes to pass through unchanged. Stripping is not injective, so two keys
|
||||
(or ids)
|
||||
differing only by a NUL collapse to one (last wins); this does not occur in
|
||||
practice because drawer ids are SHA-256 hashes and metadata keys are fixed
|
||||
field names, so only transcript-derived values are ever actually changed.
|
||||
Unlike ``config.sanitize_content`` (which rejects NUL in user-supplied
|
||||
content), the bulk-mine path strips so one stray byte cannot abort a whole
|
||||
backfill. ``str.replace`` returns the original string when it holds no NUL,
|
||||
so a clean document is not reallocated.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return value.replace("\x00", "")
|
||||
if isinstance(value, dict):
|
||||
return {_strip_nul(key): _strip_nul(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_strip_nul(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_strip_nul(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def _tokenize(text: str) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
@@ -581,9 +618,21 @@ class _PgVectorClient:
|
||||
)
|
||||
params = [
|
||||
(
|
||||
row["id"],
|
||||
row["document"],
|
||||
_json_dumps(row.get("metadata")),
|
||||
# Strip both unstorable byte classes Postgres rejects before
|
||||
# binding, so one stray byte in a transcript cannot abort the
|
||||
# whole mine (#1829 NUL, #1833 lone surrogate).
|
||||
#
|
||||
# Order matters for metadata: NUL must be stripped *before*
|
||||
# serialization (json escapes it to \\u0000, which the jsonb cast
|
||||
# rejects), while a lone surrogate must be stripped *after*
|
||||
# serialization (json.dumps(ensure_ascii=False) leaves it raw, so
|
||||
# one pass over the serialized string cleans it without walking
|
||||
# the dict). id/document are plain strings, so the two passes
|
||||
# commute there. ids are NUL- and surrogate-free in practice, so
|
||||
# those passes are defensive no-ops on the ON CONFLICT key.
|
||||
strip_lone_surrogates(_strip_nul(row["id"])),
|
||||
strip_lone_surrogates(_strip_nul(row["document"])),
|
||||
strip_lone_surrogates(_json_dumps(_strip_nul(row.get("metadata")))),
|
||||
_vector_literal(row["embedding"]),
|
||||
row.get("updated_at") or _utcnow(),
|
||||
)
|
||||
@@ -625,14 +674,34 @@ class _PgVectorClient:
|
||||
*,
|
||||
where: Optional[dict] = None,
|
||||
with_embedding: bool = False,
|
||||
with_document: bool = True,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> list[dict]:
|
||||
qi = _quote_identifier(table)
|
||||
params: list = []
|
||||
where_sql = _where_to_sql(where, params) if where else "TRUE"
|
||||
cols = "id, document, metadata"
|
||||
# Project NULL into the document slot when the caller only needs
|
||||
# metadata (e.g. mempalace_status's wing/room tally). Keeps the
|
||||
# positional _row parser unchanged — document remains record[1] —
|
||||
# while avoiding O(n × document_size) bytes over the wire on remote
|
||||
# pgvector deployments. Follow-up to #1840.
|
||||
cols = "id, document, metadata" if with_document else "id, NULL::text, metadata"
|
||||
if with_embedding:
|
||||
cols += ", embedding"
|
||||
sql = f"SELECT {cols} FROM {qi} WHERE {where_sql}"
|
||||
# Push pagination into SQL when a page is requested. ORDER BY the
|
||||
# primary key gives OFFSET a stable order (an unordered scan may skip
|
||||
# or repeat rows across pages); callers that scroll the whole table
|
||||
# pass neither bound, leaving their SQL unchanged.
|
||||
if limit is not None or offset:
|
||||
sql += " ORDER BY id"
|
||||
if limit is not None:
|
||||
params.append(int(limit))
|
||||
sql += " LIMIT %s"
|
||||
if offset:
|
||||
params.append(int(offset))
|
||||
sql += " OFFSET %s"
|
||||
rows = self._execute(sql, params, fetch=True)
|
||||
return [
|
||||
self._row(record, with_embedding=with_embedding, with_distance=False)
|
||||
@@ -792,13 +861,56 @@ class PgVectorCollection(BaseCollection):
|
||||
)
|
||||
self._known_dimension = existing_dim or dimension
|
||||
|
||||
def _scroll(self, *, where=None, with_embedding=False) -> list[dict]:
|
||||
def _scroll(
|
||||
self,
|
||||
*,
|
||||
where=None,
|
||||
with_embedding=False,
|
||||
with_document=True,
|
||||
limit=None,
|
||||
offset=None,
|
||||
) -> list[dict]:
|
||||
self._ensure_open()
|
||||
if not self._table_exists():
|
||||
if self._marker_exists():
|
||||
raise CollectionNotInitializedError(self._collection_name)
|
||||
return []
|
||||
return self._client.scroll_rows(self._table, where=where, with_embedding=with_embedding)
|
||||
return self._client.scroll_rows(
|
||||
self._table,
|
||||
where=where,
|
||||
with_embedding=with_embedding,
|
||||
with_document=with_document,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
def get_all_metadata(self, where=None) -> list[dict]:
|
||||
"""Single-pass metadata-only fetch — projects out the document column.
|
||||
|
||||
The base implementation pages through ``get(include=["metadatas"])``,
|
||||
which routes here via ``_scroll`` and (pre-this-override) always sent
|
||||
the ``document`` text over the wire even when nothing consumed it.
|
||||
For pgvector deployments where the client is remote (TLS over WAN),
|
||||
that meant ``mempalace_status`` transferred O(n × document_size)
|
||||
bytes per call, dominating wall time. With ``with_document=False``
|
||||
the SELECT replaces document with NULL, dropping the per-row payload
|
||||
to id + metadata for every caller of this method.
|
||||
|
||||
Filtered fetches still need the ``_matches_where`` post-filter for
|
||||
non-pushdown semantics (array/object values where ``metadata @> ...``
|
||||
is broader than the exact match the caller asked for — same
|
||||
correctness contract as #1840's filtered ``get`` path). Since that
|
||||
post-filter only reads ``metadata``, we keep the single-scroll +
|
||||
``with_document=False`` fast path and just apply the filter locally
|
||||
on the metadata dicts before returning. This extends the wire-byte
|
||||
win to filtered callers as well.
|
||||
"""
|
||||
_validate_where(where)
|
||||
pushdown = None if _requires_local_filter(where) else where
|
||||
rows = self._scroll(where=pushdown, with_document=False)
|
||||
if where is None:
|
||||
return [row["metadata"] for row in rows]
|
||||
return [row["metadata"] for row in rows if _matches_where(row["metadata"], where)]
|
||||
|
||||
def _rows(
|
||||
self,
|
||||
@@ -1017,16 +1129,40 @@ class PgVectorCollection(BaseCollection):
|
||||
include=None,
|
||||
) -> GetResult:
|
||||
spec = _IncludeSpec.resolve(include, default_distances=False)
|
||||
rows = self._rows(
|
||||
ids=ids, where=where, where_document=where_document, with_embedding=spec.embeddings
|
||||
# Fast path for the common unfiltered page fetch (e.g.
|
||||
# prefetch_mined_set's sweep): push LIMIT/OFFSET into the scan instead
|
||||
# of fetching the whole table and slicing in Python, which is the
|
||||
# O(rows x pages) cost this avoids. Only the no-filter case is pushed:
|
||||
# the "metadata @> ..." pushdown is broader than the exact
|
||||
# _matches_where re-filter for array/object values, so any filtered get
|
||||
# keeps the full-scan path where that re-filter still runs. ids, where,
|
||||
# where_document and negative bounds all fall through to the unchanged
|
||||
# path below. (The document column is still selected for metadata-only
|
||||
# pages; projecting it out needs the positional _row parser to change,
|
||||
# so it stays a separate follow-up.)
|
||||
push_page = (
|
||||
ids is None
|
||||
and not where
|
||||
and not where_document
|
||||
and (limit is None or limit >= 0)
|
||||
and (offset is None or offset >= 0)
|
||||
and (limit is not None or offset)
|
||||
)
|
||||
if ids is not None:
|
||||
by_id = {row["id"]: row for row in rows}
|
||||
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
|
||||
if offset:
|
||||
rows = rows[offset:]
|
||||
if limit is not None:
|
||||
rows = rows[:limit]
|
||||
if push_page:
|
||||
rows = self._scroll(
|
||||
where=None, with_embedding=spec.embeddings, limit=limit, offset=offset
|
||||
)
|
||||
else:
|
||||
rows = self._rows(
|
||||
ids=ids, where=where, where_document=where_document, with_embedding=spec.embeddings
|
||||
)
|
||||
if ids is not None:
|
||||
by_id = {row["id"]: row for row in rows}
|
||||
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
|
||||
if offset:
|
||||
rows = rows[offset:]
|
||||
if limit is not None:
|
||||
rows = rows[:limit]
|
||||
return GetResult(
|
||||
ids=[row["id"] for row in rows],
|
||||
documents=[row["document"] for row in rows] if spec.documents else [],
|
||||
|
||||
@@ -40,6 +40,7 @@ from .base import (
|
||||
PalaceNotFoundError,
|
||||
PalaceRef,
|
||||
QueryResult,
|
||||
UnsupportedCapabilityError,
|
||||
UnsupportedFilterError,
|
||||
_IncludeSpec,
|
||||
)
|
||||
@@ -53,6 +54,20 @@ _PAYLOAD_DOCUMENT = "document"
|
||||
_PAYLOAD_METADATA = "metadata"
|
||||
_POINT_NAMESPACE = uuid.UUID("c06c3fc7-5c14-4dc4-84c2-24a5f72d8dc1")
|
||||
_TOKEN_RE = re.compile(r"\w{2,}", re.UNICODE)
|
||||
# Page size for Qdrant's /points/scroll cursor. 4096 (up from the original
|
||||
# 256) cuts REST round-trips ~16x for any full-collection walk (#1796).
|
||||
# Qdrant's own docs suggest larger scroll batches are safe, and this is well
|
||||
# below typical REST payload-size limits for metadata-only (with_vector=False)
|
||||
# scrolls such as get_all_metadata().
|
||||
#
|
||||
# This constant also governs vector-bearing scrolls (with_vector=True), used
|
||||
# by _rows()/get() when embeddings are requested and by _query_local_exact()
|
||||
# for the $or/$contains local-filter query fallback. At 4096 rows per page,
|
||||
# high-dimensional embeddings make those particular responses tens of MB --
|
||||
# Qdrant handles it and round-trips still drop overall, but this is a real
|
||||
# trade-off, not a metadata-only optimization. (Noted in maintainer review
|
||||
# on #1832.)
|
||||
_SCROLL_PAGE_SIZE = 4096
|
||||
_SUPPORTED_OPERATORS = frozenset(
|
||||
{"$eq", "$ne", "$in", "$nin", "$and", "$or", "$contains", "$gt", "$gte", "$lt", "$lte"}
|
||||
)
|
||||
@@ -480,7 +495,7 @@ class _QdrantRESTClient:
|
||||
collection: str,
|
||||
*,
|
||||
qdrant_filter: Optional[dict] = None,
|
||||
limit: int = 256,
|
||||
limit: int = _SCROLL_PAGE_SIZE,
|
||||
offset: Any = None,
|
||||
with_vector: bool = False,
|
||||
) -> tuple[list[dict], Any]:
|
||||
@@ -529,6 +544,38 @@ class _QdrantRESTClient:
|
||||
result = response.get("result") or {}
|
||||
return int(result.get("count") or 0)
|
||||
|
||||
def facet_counts(
|
||||
self,
|
||||
collection: str,
|
||||
*,
|
||||
field: str,
|
||||
qdrant_filter: Optional[dict] = None,
|
||||
limit: int = 1000,
|
||||
) -> dict[str, int]:
|
||||
body: dict[str, Any] = {
|
||||
"key": field,
|
||||
"exact": True,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
if qdrant_filter:
|
||||
body["filter"] = qdrant_filter
|
||||
|
||||
response = self.request(
|
||||
"POST",
|
||||
f"/collections/{urlparse.quote(collection, safe='')}/facet",
|
||||
body=body,
|
||||
)
|
||||
|
||||
result = response.get("result") or {}
|
||||
hits = result.get("hits") or []
|
||||
|
||||
return {
|
||||
str(hit["value"]): int(hit.get("count") or 0)
|
||||
for hit in hits
|
||||
if hit.get("value") is not None
|
||||
}
|
||||
|
||||
def delete_collection(self, collection: str) -> None:
|
||||
self.request("DELETE", f"/collections/{urlparse.quote(collection, safe='')}")
|
||||
|
||||
@@ -732,7 +779,7 @@ class QdrantCollection(BaseCollection):
|
||||
points, offset = self._client.scroll_points(
|
||||
self._remote_collection,
|
||||
qdrant_filter=qdrant_filter,
|
||||
limit=256,
|
||||
limit=_SCROLL_PAGE_SIZE,
|
||||
offset=offset,
|
||||
with_vector=with_vector,
|
||||
)
|
||||
@@ -1000,6 +1047,55 @@ class QdrantCollection(BaseCollection):
|
||||
embeddings=[row["embedding"] or [] for row in rows] if spec.embeddings else None,
|
||||
)
|
||||
|
||||
def get_all_metadata(self, where: Optional[dict] = None) -> list[dict]:
|
||||
"""Return every matching record's metadata in one cursor pass (#1796).
|
||||
|
||||
Overrides the default offset-paginated implementation, which would
|
||||
call self.get(limit=, offset=) in a loop -- and since self.get() is
|
||||
backed by a full _scroll_all() materialization, each page of that
|
||||
loop would re-walk the entire collection from the start just to
|
||||
discard everything outside its slice (O(n^2) over collection size).
|
||||
|
||||
Delegates to self._rows(), the same single-scroll-plus-local-filter
|
||||
helper that backs get()/delete(). With ids=None and
|
||||
where_document=None, _rows() reduces to exactly one _scroll_all()
|
||||
pass followed by an unconditional _matches_where() re-check on every
|
||||
row -- the same filter logic get(), delete(), and lexical_search()
|
||||
already use, so this can't independently drift from those call
|
||||
sites. (Maintainer review on #1832: avoid duplicating the filter
|
||||
dance inline.)
|
||||
"""
|
||||
rows = self._rows(where=where)
|
||||
return [row["metadata"] for row in rows]
|
||||
|
||||
def facet_counts(
|
||||
self,
|
||||
field: str,
|
||||
where: Optional[dict] = None,
|
||||
limit: int = 1000,
|
||||
) -> dict[str, int]:
|
||||
self._ensure_open()
|
||||
# Validate the filter before the existence short-circuit so an
|
||||
# unsupported local-only filter raises regardless of whether the
|
||||
# collection has been materialized yet — matching the order used by
|
||||
# get()/lexical_search() above (#1835 review).
|
||||
_validate_where(where)
|
||||
if _requires_local_filter(where):
|
||||
raise UnsupportedCapabilityError("facet_counts does not support local-only filters")
|
||||
if not self._remote_exists():
|
||||
if self._marker_exists():
|
||||
raise CollectionNotInitializedError(self._collection_name)
|
||||
return {}
|
||||
|
||||
q_filter = _qdrant_filter(where)
|
||||
|
||||
return self._client.facet_counts(
|
||||
self._remote_collection,
|
||||
field=f"{_PAYLOAD_METADATA}.{field}",
|
||||
qdrant_filter=q_filter,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def delete(self, *, ids=None, where=None):
|
||||
_validate_where(where)
|
||||
if not self._remote_exists():
|
||||
@@ -1090,6 +1186,7 @@ class QdrantBackend(BaseBackend):
|
||||
"supports_embeddings_out",
|
||||
"supports_metadata_filters",
|
||||
"supports_lexical_search",
|
||||
"supports_metadata_facets",
|
||||
"supports_namespace_isolation",
|
||||
"server_mode",
|
||||
}
|
||||
|
||||
@@ -207,6 +207,7 @@ def resolve_backend_for_palace(
|
||||
def _register_builtins() -> None:
|
||||
"""Register chroma as the in-tree default."""
|
||||
from .chroma import ChromaBackend
|
||||
from .milvus import MilvusBackend
|
||||
from .pgvector import PgVectorBackend
|
||||
from .qdrant import QdrantBackend
|
||||
from .sqlite_exact import SQLiteExactBackend
|
||||
@@ -214,6 +215,8 @@ def _register_builtins() -> None:
|
||||
# Use setdefault semantics so a caller that pre-registered for tests wins.
|
||||
if "chroma" not in _registry:
|
||||
_registry["chroma"] = ChromaBackend
|
||||
if "milvus" not in _registry:
|
||||
_registry["milvus"] = MilvusBackend
|
||||
if "qdrant" not in _registry:
|
||||
_registry["qdrant"] = QdrantBackend
|
||||
if "sqlite_exact" not in _registry:
|
||||
|
||||
@@ -496,19 +496,32 @@ class SQLiteExactCollection(BaseCollection):
|
||||
)
|
||||
self._replace_fts(cur, collection_id, doc_id, doc)
|
||||
|
||||
def _rows(self, cur, *, where=None, where_document=None) -> list[dict]:
|
||||
def _rows(self, cur, *, where=None, where_document=None, limit=None, offset=None) -> list[dict]:
|
||||
_validate_where(where)
|
||||
_validate_where(where_document)
|
||||
collection_id = self._collection_id(cur)
|
||||
rows = cur.execute(
|
||||
"""
|
||||
SELECT id, document, metadata_json, embedding
|
||||
FROM documents
|
||||
WHERE collection_id = ?
|
||||
ORDER BY rowid
|
||||
""",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
sql = (
|
||||
"SELECT id, document, metadata_json, embedding\n"
|
||||
"FROM documents\n"
|
||||
"WHERE collection_id = ?\n"
|
||||
"ORDER BY rowid"
|
||||
)
|
||||
params = [collection_id]
|
||||
# Emit SQL LIMIT/OFFSET only on an unfiltered page. With a
|
||||
# where/where_document the post-filter loop below drops rows *after*
|
||||
# this scan, so a SQL LIMIT/OFFSET would cut the wrong rows; those
|
||||
# callers scan in full and paginate in Python. SQLite requires a LIMIT
|
||||
# before OFFSET, so an offset-only page uses "LIMIT -1" (unbounded).
|
||||
if where is None and where_document is None and (limit is not None or offset):
|
||||
if limit is not None:
|
||||
sql += "\nLIMIT ?"
|
||||
params.append(int(limit))
|
||||
elif offset:
|
||||
sql += "\nLIMIT -1"
|
||||
if offset:
|
||||
sql += "\nOFFSET ?"
|
||||
params.append(int(offset))
|
||||
rows = cur.execute(sql, params).fetchall()
|
||||
out = []
|
||||
for doc_id, doc, meta_json, emb_blob in rows:
|
||||
meta = _json_loads(meta_json)
|
||||
@@ -603,15 +616,33 @@ class SQLiteExactCollection(BaseCollection):
|
||||
include=None,
|
||||
) -> GetResult:
|
||||
spec = _IncludeSpec.resolve(include, default_distances=False)
|
||||
# Fast path for the common unfiltered page (e.g. the prefetch_mined_set
|
||||
# and status sweeps): push LIMIT/OFFSET into the scan instead of
|
||||
# materializing the whole collection and slicing in Python. Safe only
|
||||
# with no post-filter (ids/where/where_document drop rows after the
|
||||
# scan) and non-negative bounds: SQLite does not honor a negative LIMIT
|
||||
# or OFFSET the way a Python slice does, so those keep the slice path.
|
||||
push_page = (
|
||||
ids is None
|
||||
and where is None
|
||||
and where_document is None
|
||||
and (limit is None or limit >= 0)
|
||||
and (offset is None or offset >= 0)
|
||||
and (limit is not None or offset)
|
||||
)
|
||||
with self._cursor() as cur:
|
||||
rows = self._rows(cur, where=where, where_document=where_document)
|
||||
if ids is not None:
|
||||
by_id = {row["id"]: row for row in rows}
|
||||
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
|
||||
if offset:
|
||||
rows = rows[offset:]
|
||||
if limit is not None:
|
||||
rows = rows[:limit]
|
||||
if push_page:
|
||||
rows = self._rows(cur, limit=limit, offset=offset)
|
||||
else:
|
||||
rows = self._rows(cur, where=where, where_document=where_document)
|
||||
if not push_page:
|
||||
if ids is not None:
|
||||
by_id = {row["id"]: row for row in rows}
|
||||
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
|
||||
if offset:
|
||||
rows = rows[offset:]
|
||||
if limit is not None:
|
||||
rows = rows[:limit]
|
||||
return GetResult(
|
||||
ids=[row["id"] for row in rows],
|
||||
documents=[row["document"] for row in rows] if spec.documents else [],
|
||||
@@ -1014,7 +1045,23 @@ class SQLiteExactBackend(BaseBackend):
|
||||
|
||||
@classmethod
|
||||
def detect(cls, path: str) -> bool:
|
||||
return os.path.isfile(os.path.join(path, _DB_FILENAME))
|
||||
"""Return True when ``path`` looks like a sqlite_exact palace.
|
||||
|
||||
Verifies the SQLite magic header rather than file presence alone, for
|
||||
the same reason as :py:meth:`mempalace.backends.chroma.ChromaBackend.detect`:
|
||||
bare ``sqlite3.connect()`` against a missing path leaves a 0-byte file
|
||||
behind because the SQLite header is written on the first statement,
|
||||
not on connection. The 16-byte ``SQLite format 3\\x00`` magic prefix
|
||||
accepts every real palace while rejecting empty / garbage files. See #1893.
|
||||
"""
|
||||
db_path = os.path.join(path, _DB_FILENAME)
|
||||
if not os.path.isfile(db_path):
|
||||
return False
|
||||
try:
|
||||
with open(db_path, "rb") as f:
|
||||
return f.read(16) == b"SQLite format 3\x00"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def create_collection(self, palace_path: str, collection_name: str) -> SQLiteExactCollection:
|
||||
return self.get_collection(palace_path, collection_name, create=True)
|
||||
|
||||
+469
-11
@@ -53,6 +53,12 @@ _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
|
||||
_EXPLICIT_BACKEND_ENV = "MEMPALACE_BACKEND_EXPLICIT"
|
||||
|
||||
# Keep parser construction lightweight for --version and hook commands.
|
||||
# This mirrors miner.MAX_CHUNKS_PER_FILE without importing miner here;
|
||||
# importing miner pulls in Chroma dependencies before argparse can handle
|
||||
# lightweight exits such as --version.
|
||||
_CLI_MAX_CHUNKS_PER_FILE_DEFAULT = 50_000
|
||||
|
||||
|
||||
def _backend_arg(args):
|
||||
"""Return a CLI-selected backend from subcommand or global flags."""
|
||||
@@ -253,14 +259,16 @@ def _ensure_mempalace_files_gitignored(project_dir) -> bool:
|
||||
if not (project_path / ".git").exists():
|
||||
return False
|
||||
gitignore = project_path / ".gitignore"
|
||||
existing = gitignore.read_text() if gitignore.exists() else ""
|
||||
# Force UTF-8: Windows defaults to GBK and chokes on non-ASCII .gitignore
|
||||
# comments, killing auto-init even though the file is valid UTF-8.
|
||||
existing = gitignore.read_text(encoding="utf-8", errors="replace") if gitignore.exists() else ""
|
||||
existing_lines = {line.strip() for line in existing.splitlines()}
|
||||
missing = [p for p in _MEMPALACE_PROJECT_FILES if p not in existing_lines]
|
||||
if not missing:
|
||||
return False
|
||||
prefix = "" if not existing or existing.endswith("\n") else "\n"
|
||||
block = prefix + "\n# MemPalace per-project files (issue #185)\n" + "\n".join(missing) + "\n"
|
||||
with open(gitignore, "a") as f:
|
||||
with open(gitignore, "a", encoding="utf-8") as f:
|
||||
f.write(block)
|
||||
print(f" Added {', '.join(missing)} to {gitignore.name}")
|
||||
return True
|
||||
@@ -307,7 +315,16 @@ def cmd_init(args):
|
||||
endpoint=getattr(args, "llm_endpoint", None),
|
||||
api_key=getattr(args, "llm_api_key", None),
|
||||
)
|
||||
ok, msg = candidate.check_available()
|
||||
if (
|
||||
provider_name == "openai-compat"
|
||||
and getattr(candidate, "api_key_source", None) == "env"
|
||||
and candidate.is_external_service
|
||||
):
|
||||
ok = False
|
||||
msg = "external openai-compat init requires explicit --llm-api-key"
|
||||
print(f" LLM skipped: {msg}")
|
||||
else:
|
||||
ok, msg = candidate.check_available()
|
||||
if ok:
|
||||
llm_provider = candidate
|
||||
print(f" LLM enabled: {provider_name}/{provider_model}")
|
||||
@@ -534,6 +551,27 @@ def cmd_mine(args):
|
||||
for raw in args.include_ignored or []:
|
||||
include_ignored.extend(part.strip() for part in raw.split(",") if part.strip())
|
||||
|
||||
if getattr(args, "background", False) and not getattr(args, "daemon", False):
|
||||
print("mempalace: --background requires --daemon", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
if getattr(args, "daemon", False):
|
||||
payload = {
|
||||
"source": args.dir,
|
||||
"mode": args.mode,
|
||||
"wing": args.wing,
|
||||
"agent": args.agent,
|
||||
"limit": args.limit,
|
||||
"dry_run": args.dry_run,
|
||||
"extract": args.extract,
|
||||
"no_gitignore": args.no_gitignore,
|
||||
"include_ignored": include_ignored,
|
||||
"max_chunks_per_file": getattr(args, "max_chunks_per_file", None),
|
||||
"redetect_origin": getattr(args, "redetect_origin", False),
|
||||
}
|
||||
_submit_daemon_cli_job("mine", payload, args, background=getattr(args, "background", False))
|
||||
return
|
||||
|
||||
# --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`.
|
||||
@@ -655,14 +693,28 @@ def cmd_sweep(args):
|
||||
|
||||
def cmd_sync(args):
|
||||
"""Prune drawers whose source files are gitignored, deleted, or moved (#1252)."""
|
||||
from .mcp_server import _wal_log
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
|
||||
if getattr(args, "background", False) and not getattr(args, "daemon", False):
|
||||
print("mempalace: --background requires --daemon", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
if getattr(args, "daemon", False):
|
||||
payload = {
|
||||
"dir": args.dir,
|
||||
"root": list(args.root or []),
|
||||
"wing": args.wing,
|
||||
"dry_run": args.dry_run,
|
||||
}
|
||||
_submit_daemon_cli_job("sync", payload, args, background=getattr(args, "background", False))
|
||||
return
|
||||
|
||||
from .palace import MineAlreadyRunning
|
||||
from .wal import _wal_log
|
||||
from .backends import detect_backend_for_path
|
||||
from .palace import _backend_artifact_label, resolve_backend_name
|
||||
from .sync import sync_palace
|
||||
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
|
||||
if not os.path.isdir(palace_path):
|
||||
print(f"\n No palace found at {palace_path}")
|
||||
return
|
||||
@@ -745,6 +797,133 @@ def cmd_sync(args):
|
||||
print(f"\n{'=' * 55}\n")
|
||||
|
||||
|
||||
def _submit_daemon_cli_job(kind: str, payload: dict, args, *, background: bool) -> None:
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
backend = _backend_arg(args)
|
||||
from .daemon import DaemonError, submit_job
|
||||
|
||||
try:
|
||||
job = submit_job(
|
||||
kind,
|
||||
payload,
|
||||
palace_path=palace_path,
|
||||
backend=backend,
|
||||
wait=not background,
|
||||
auto_start=True,
|
||||
)
|
||||
except DaemonError as exc:
|
||||
print(f"mempalace: daemon submission failed: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if background:
|
||||
print(f"Submitted daemon job {job['id']} ({kind})")
|
||||
return
|
||||
|
||||
result = job.get("result") or {}
|
||||
from .service import print_job_result
|
||||
|
||||
exit_code = print_job_result(result)
|
||||
if job.get("state") != "succeeded" and exit_code == 0:
|
||||
error = job.get("error") or {}
|
||||
print(
|
||||
f"mempalace: daemon job failed: {error.get('message', 'unknown error')}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
exit_code = 1
|
||||
if exit_code:
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def cmd_daemon(args):
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
backend = _backend_arg(args)
|
||||
from .daemon import (
|
||||
TERMINAL_STATES,
|
||||
DaemonError,
|
||||
QueueStore,
|
||||
get_client_if_running,
|
||||
job_to_dict,
|
||||
queue_path,
|
||||
start_daemon,
|
||||
stop_daemon,
|
||||
)
|
||||
|
||||
action = getattr(args, "daemon_action", None)
|
||||
try:
|
||||
if action == "start":
|
||||
if args.foreground:
|
||||
start_daemon(palace_path, backend=backend, foreground=True)
|
||||
return
|
||||
client = start_daemon(palace_path, backend=backend, foreground=False)
|
||||
health = client.health()
|
||||
print(f"MemPalace daemon running on 127.0.0.1:{client.port}")
|
||||
print(f" Palace: {health.get('palace_path')}")
|
||||
print(f" PID: {health.get('pid')}")
|
||||
return
|
||||
|
||||
if action == "stop":
|
||||
if stop_daemon(palace_path):
|
||||
print("MemPalace daemon stopping")
|
||||
else:
|
||||
print("MemPalace daemon is not running")
|
||||
return
|
||||
|
||||
if action == "status":
|
||||
client = get_client_if_running(palace_path)
|
||||
if client is None:
|
||||
print("MemPalace daemon is not running")
|
||||
sys.exit(1)
|
||||
health = client.health()
|
||||
print("MemPalace daemon is running")
|
||||
print(f" Palace: {health.get('palace_path')}")
|
||||
print(f" PID: {health.get('pid')}")
|
||||
print(f" Active: {health.get('active_job_id') or '-'}")
|
||||
print(f" Jobs: {health.get('counts') or {}}")
|
||||
return
|
||||
|
||||
if action == "jobs":
|
||||
client = get_client_if_running(palace_path)
|
||||
if client is not None:
|
||||
jobs = client.list_jobs(limit=args.limit)
|
||||
else:
|
||||
qpath = queue_path(palace_path)
|
||||
if not qpath.exists():
|
||||
jobs = []
|
||||
else:
|
||||
jobs = [
|
||||
job_to_dict(job, include_payload=False)
|
||||
for job in QueueStore(qpath).list(args.limit)
|
||||
]
|
||||
for job in jobs:
|
||||
print(f"{job['id']} {job['state']:<9} {job['kind']:<10} {job['created_at']}")
|
||||
return
|
||||
|
||||
if action == "wait":
|
||||
client = get_client_if_running(palace_path)
|
||||
if client is not None:
|
||||
job = client.wait(args.job_id)
|
||||
else:
|
||||
qpath = queue_path(palace_path)
|
||||
if not qpath.exists():
|
||||
raise DaemonError("daemon is not running")
|
||||
job = job_to_dict(QueueStore(qpath).get(args.job_id))
|
||||
if job.get("state") not in TERMINAL_STATES:
|
||||
raise DaemonError(f"daemon is not running; job {args.job_id} is {job['state']}")
|
||||
result = job.get("result") or {}
|
||||
from .service import print_job_result
|
||||
|
||||
exit_code = print_job_result(result)
|
||||
if job.get("state") != "succeeded" and exit_code == 0:
|
||||
print(f"mempalace: daemon job failed: {job.get('error')}", file=sys.stderr)
|
||||
exit_code = 1
|
||||
if exit_code:
|
||||
sys.exit(exit_code)
|
||||
return
|
||||
except DaemonError as exc:
|
||||
print(f"mempalace: daemon error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_search(args):
|
||||
from .searcher import search, SearchError
|
||||
|
||||
@@ -824,6 +1003,29 @@ def cmd_migrate_wings(args):
|
||||
)
|
||||
|
||||
|
||||
def cmd_hallways(args):
|
||||
"""List within-wing entity hallways (the auto-built associative graph)."""
|
||||
from .hallways import list_hallways
|
||||
|
||||
palace_path = (
|
||||
os.path.expanduser(args.palace)
|
||||
if getattr(args, "palace", None)
|
||||
else MempalaceConfig().palace_path
|
||||
)
|
||||
rows = list_hallways(
|
||||
getattr(args, "wing", None),
|
||||
config=MempalaceConfig(palace_path=palace_path),
|
||||
)
|
||||
if not rows:
|
||||
print("No hallways yet — they are built from drawer entities when you mine.")
|
||||
return
|
||||
rows.sort(key=lambda h: h.get("co_occurrence_count", 0), reverse=True)
|
||||
print(f" {len(rows)} hallway(s):")
|
||||
for h in rows[: max(0, args.limit)]:
|
||||
label = h.get("label") or f"{h.get('entity_a', '?')} <-> {h.get('entity_b', '?')}"
|
||||
print(f" {label}")
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
from .miner import status
|
||||
|
||||
@@ -914,11 +1116,17 @@ def cmd_repair(args):
|
||||
_post_rebuild_cleanup,
|
||||
_rebuild_collection_via_temp,
|
||||
check_extraction_safety,
|
||||
index_read_recovery_guidance,
|
||||
maybe_autoheal_fts5_index,
|
||||
maybe_repair_poisoned_max_seq_id_before_rebuild,
|
||||
print_sqlite_integrity_abort,
|
||||
sqlite_integrity_errors,
|
||||
)
|
||||
|
||||
if getattr(args, "repair_action", None) == "rebuild-index":
|
||||
args.mode = "from-sqlite"
|
||||
args.archive_existing = True
|
||||
|
||||
if getattr(args, "mode", "legacy") == "max-seq-id":
|
||||
from .repair import repair_max_seq_id
|
||||
|
||||
@@ -934,7 +1142,7 @@ def cmd_repair(args):
|
||||
|
||||
if getattr(args, "mode", "legacy") == "from-sqlite":
|
||||
from .migrate import confirm_destructive_action
|
||||
from .repair import RebuildPartialError, rebuild_from_sqlite
|
||||
from .repair import RebuildCleanupError, RebuildPartialError, rebuild_from_sqlite
|
||||
|
||||
source_path = getattr(args, "source", None)
|
||||
source_path = (
|
||||
@@ -972,6 +1180,13 @@ def cmd_repair(args):
|
||||
f"Failed in collection: {exc.failed_collection}"
|
||||
)
|
||||
sys.exit(1)
|
||||
except RebuildCleanupError:
|
||||
# All rows may have landed, but rebuild_from_sqlite deliberately
|
||||
# withholds success until FTS5 rebuild, VACUUM, and quick_check are
|
||||
# clean. Its exception already includes the retained destination
|
||||
# and archive/source recovery paths.
|
||||
print("\n Rebuild cleanup failed — see recovery details above.")
|
||||
sys.exit(1)
|
||||
# An empty counts dict is rebuild_from_sqlite's documented signal
|
||||
# for a validation refusal (missing source, existing dest,
|
||||
# in-place without --archive-existing). The library already
|
||||
@@ -1000,6 +1215,8 @@ def cmd_repair(args):
|
||||
# here so we can surface the clear recovery instructions and exit
|
||||
# cleanly before chromadb's compactor touches the disk.
|
||||
sqlite_errors = sqlite_integrity_errors(palace_path)
|
||||
if sqlite_errors:
|
||||
sqlite_errors = maybe_autoheal_fts5_index(palace_path, sqlite_errors)
|
||||
if sqlite_errors:
|
||||
print_sqlite_integrity_abort(palace_path, sqlite_errors)
|
||||
sys.exit(1)
|
||||
@@ -1027,7 +1244,7 @@ def cmd_repair(args):
|
||||
print(f" Drawers found: {total}")
|
||||
except Exception as e:
|
||||
print(f" Error reading palace: {e}")
|
||||
print(" Cannot recover — palace may need to be re-mined from source files.")
|
||||
print(index_read_recovery_guidance())
|
||||
return
|
||||
|
||||
if total == 0:
|
||||
@@ -1154,6 +1371,154 @@ def cmd_mcp(args):
|
||||
print(f" {base_server_cmd} --palace /path/to/palace")
|
||||
|
||||
|
||||
_SERVER_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]"}
|
||||
_SERVER_BIND_ALL_HOSTS = {"0.0.0.0", "::", "[::]"}
|
||||
|
||||
|
||||
def _server_is_loopback(host: str) -> bool:
|
||||
return (host or "").strip().lower() in _SERVER_LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def _server_token_path(palace_path: str) -> Path:
|
||||
"""Per-palace location for the auto-generated server bearer token.
|
||||
|
||||
Distinct from the daemon's token dir; keyed by the canonical palace path so
|
||||
one server per palace reuses a stable token across restarts.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
canonical = os.path.abspath(os.path.realpath(os.path.expanduser(palace_path)))
|
||||
key = hashlib.sha256(os.path.normcase(canonical).encode("utf-8")).hexdigest()[:24]
|
||||
return Path.home() / ".mempalace" / "server" / key / "token"
|
||||
|
||||
|
||||
def _load_or_create_server_token(palace_path: str) -> tuple[str, bool]:
|
||||
"""Return (token, created). Reuse an existing 0600 token or mint a new one."""
|
||||
import secrets
|
||||
|
||||
token_path = _server_token_path(palace_path)
|
||||
if token_path.exists():
|
||||
existing = token_path.read_text(encoding="utf-8").strip()
|
||||
if existing:
|
||||
return existing, False
|
||||
token = secrets.token_urlsafe(32)
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(str(token_path.parent), 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
# O_CREAT with 0600 so the token is never briefly world-readable on disk.
|
||||
fd = os.open(str(token_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(token + "\n")
|
||||
return token, True
|
||||
|
||||
|
||||
def cmd_serve(args):
|
||||
"""Run a secure remote HTTP MCP server for a team to share one palace (#1877).
|
||||
|
||||
A turnkey wrapper over ``mempalace-mcp --transport http``: it resolves a
|
||||
bearer token (auto-generating a strong one for non-loopback binds), prints a
|
||||
ready-to-paste client config, then execs the real server in the foreground so
|
||||
Docker/systemd own the process lifecycle. The token is passed via the
|
||||
environment, never argv, so it can't leak through ``ps``.
|
||||
"""
|
||||
host = args.host
|
||||
port = int(args.port)
|
||||
loopback = _server_is_loopback(host)
|
||||
palace_path = (
|
||||
os.path.abspath(os.path.expanduser(args.palace))
|
||||
if args.palace
|
||||
else MempalaceConfig().palace_path
|
||||
)
|
||||
backend = _backend_arg(args)
|
||||
|
||||
tls_cert = os.path.expanduser(args.tls_cert) if args.tls_cert else None
|
||||
tls_key = os.path.expanduser(args.tls_key) if args.tls_key else None
|
||||
if bool(tls_cert) != bool(tls_key):
|
||||
print("mempalace: --tls-cert and --tls-key must be given together", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
for label, path in (("--tls-cert", tls_cert), ("--tls-key", tls_key)):
|
||||
if path and not os.path.isfile(path):
|
||||
print(f"mempalace: {label} file not found: {path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
scheme = "https" if tls_cert else "http"
|
||||
|
||||
# Token resolution. Explicit flag > existing env > (non-loopback) auto-generated.
|
||||
token = (args.token or os.environ.get("MEMPALACE_MCP_HTTP_TOKEN", "")).strip()
|
||||
token_created = False
|
||||
if not token and not loopback and not args.allow_insecure:
|
||||
token, token_created = _load_or_create_server_token(palace_path)
|
||||
|
||||
# Build the child environment. Token rides in the env (never argv) so it
|
||||
# stays out of the process table.
|
||||
env = dict(os.environ)
|
||||
env["MEMPALACE_PALACE_PATH"] = palace_path
|
||||
if backend:
|
||||
env["MEMPALACE_BACKEND"] = str(backend).strip().lower()
|
||||
if token:
|
||||
env["MEMPALACE_MCP_HTTP_TOKEN"] = token
|
||||
if args.allow_insecure:
|
||||
env["MEMPALACE_MCP_HTTP_ALLOW_INSECURE_NO_TOKEN"] = "1"
|
||||
|
||||
child = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"mempalace.mcp_server",
|
||||
"--transport",
|
||||
"http",
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
str(port),
|
||||
]
|
||||
if backend:
|
||||
child += ["--backend", str(backend).strip().lower()]
|
||||
child += ["--palace", palace_path]
|
||||
if tls_cert:
|
||||
child += ["--tls-cert", tls_cert, "--tls-key", tls_key]
|
||||
if args.read_only:
|
||||
child.append("--read-only")
|
||||
|
||||
# Client-facing address: 0.0.0.0/:: means "all interfaces" — clients dial a
|
||||
# real reachable host, so show a placeholder rather than the bind wildcard.
|
||||
client_host = "YOUR_SERVER_HOST" if host.strip().lower() in _SERVER_BIND_ALL_HOSTS else host
|
||||
url = f"{scheme}://{client_host}:{port}/mcp"
|
||||
|
||||
print("Starting MemPalace remote MCP server")
|
||||
print(f" palace : {palace_path}")
|
||||
print(f" backend : {(backend or 'default').strip().lower() if backend else 'default'}")
|
||||
print(f" bind : {host}:{port} ({'loopback' if loopback else 'network-exposed'})")
|
||||
print(f" tls : {'on' if tls_cert else 'off (plaintext — terminate TLS at a proxy)'}")
|
||||
print(f" read-only: {'yes' if args.read_only else 'no'}")
|
||||
if token_created:
|
||||
print("\n A new bearer token was generated and stored 0600 at:")
|
||||
print(f" {_server_token_path(palace_path)}")
|
||||
print(" Store it securely — clients need it to connect:")
|
||||
print(f" {token}")
|
||||
print("\nConnect a client:")
|
||||
if token:
|
||||
print(
|
||||
f" claude mcp add --transport http mempalace {url} "
|
||||
f'--header "Authorization: Bearer {token if token_created else "$MEMPALACE_MCP_HTTP_TOKEN"}"'
|
||||
)
|
||||
else:
|
||||
print(f" claude mcp add --transport http mempalace {url}")
|
||||
print(f" curl {scheme}://{client_host}:{port}/healthz # liveness (no auth)\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Foreground: hand the process to the real server so signals (SIGTERM from
|
||||
# Docker/systemd) reach it directly. exec on POSIX; subprocess on Windows
|
||||
# (no exec semantics) propagating the exit code.
|
||||
if os.name == "posix":
|
||||
os.execve(sys.executable, child, env)
|
||||
else:
|
||||
import subprocess
|
||||
|
||||
completed = subprocess.run(child, env=env)
|
||||
sys.exit(completed.returncode)
|
||||
|
||||
|
||||
def cmd_compress(args):
|
||||
"""Compress drawers in a wing using AAAK Dialect."""
|
||||
from .dialect import Dialect
|
||||
@@ -1480,13 +1845,22 @@ def main():
|
||||
p_mine.add_argument(
|
||||
"--dry-run", action="store_true", help="Show what would be filed without filing"
|
||||
)
|
||||
p_mine.add_argument(
|
||||
"--daemon",
|
||||
action="store_true",
|
||||
help="Submit this mine to the opt-in local daemon queue",
|
||||
)
|
||||
p_mine.add_argument(
|
||||
"--background",
|
||||
action="store_true",
|
||||
help="With --daemon, return a job id immediately instead of waiting",
|
||||
)
|
||||
p_mine.add_argument(
|
||||
"--extract",
|
||||
choices=["exchange", "general"],
|
||||
default="exchange",
|
||||
help="Extraction strategy for convos mode: 'exchange' (default) or 'general' (5 memory types)",
|
||||
)
|
||||
from . import miner as _miner_for_default
|
||||
|
||||
p_mine.add_argument(
|
||||
"--max-chunks-per-file",
|
||||
@@ -1495,7 +1869,7 @@ def main():
|
||||
metavar="N",
|
||||
help=(
|
||||
f"Per-file chunk cap; files producing more chunks are skipped with a "
|
||||
f"summary counter. Default {_miner_for_default.MAX_CHUNKS_PER_FILE} "
|
||||
f"summary counter. Default {_CLI_MAX_CHUNKS_PER_FILE_DEFAULT} "
|
||||
f"(or MEMPALACE_MAX_CHUNKS_PER_FILE). Set 0 to disable. Lower this on "
|
||||
f"Windows if you hit ONNX bad_alloc (#1455)."
|
||||
),
|
||||
@@ -1543,6 +1917,16 @@ def main():
|
||||
action="store_false",
|
||||
help="Actually delete drawers (overrides --dry-run; requires --wing or a project root)",
|
||||
)
|
||||
p_sync.add_argument(
|
||||
"--daemon",
|
||||
action="store_true",
|
||||
help="Submit this sync to the opt-in local daemon queue",
|
||||
)
|
||||
p_sync.add_argument(
|
||||
"--background",
|
||||
action="store_true",
|
||||
help="With --daemon, return a job id immediately instead of waiting",
|
||||
)
|
||||
|
||||
# search
|
||||
p_search = sub.add_parser("search", help="Find anything, exact words")
|
||||
@@ -1605,7 +1989,7 @@ def main():
|
||||
p_hook_run.add_argument(
|
||||
"--hook",
|
||||
required=True,
|
||||
choices=["session-start", "stop", "precompact"],
|
||||
choices=["session-start", "stop", "session-end", "precompact"],
|
||||
help="Hook name to run",
|
||||
)
|
||||
p_hook_run.add_argument(
|
||||
@@ -1635,6 +2019,15 @@ def main():
|
||||
p_repair.add_argument(
|
||||
"--yes", action="store_true", help="Skip confirmation for destructive changes"
|
||||
)
|
||||
p_repair.add_argument(
|
||||
"repair_action",
|
||||
nargs="?",
|
||||
choices=["rebuild-index"],
|
||||
help=(
|
||||
"Re-embed the palace from SQLite using the current embedding model "
|
||||
"(alias for --mode from-sqlite --archive-existing)."
|
||||
),
|
||||
)
|
||||
p_repair.add_argument(
|
||||
"--confirm-truncation-ok",
|
||||
action="store_true",
|
||||
@@ -1705,6 +2098,27 @@ def main():
|
||||
help="Compare sqlite vs HNSW element counts (read-only; never opens a chromadb client)",
|
||||
)
|
||||
|
||||
# daemon
|
||||
p_daemon = sub.add_parser("daemon", help="Manage the opt-in long-lived daemon")
|
||||
daemon_sub = p_daemon.add_subparsers(dest="daemon_action")
|
||||
p_daemon_start = daemon_sub.add_parser("start", help="Start the daemon")
|
||||
p_daemon_start.add_argument(
|
||||
"--foreground",
|
||||
action="store_true",
|
||||
help="Run in the foreground for debugging or process supervisors",
|
||||
)
|
||||
p_daemon_start.add_argument(
|
||||
"--backend",
|
||||
default=None,
|
||||
help="Storage backend for this daemon (default: config/env/detected/chroma)",
|
||||
)
|
||||
daemon_sub.add_parser("stop", help="Stop the daemon")
|
||||
daemon_sub.add_parser("status", help="Show daemon status")
|
||||
p_daemon_jobs = daemon_sub.add_parser("jobs", help="List recent daemon jobs")
|
||||
p_daemon_jobs.add_argument("--limit", type=int, default=20, help="Max jobs to show")
|
||||
p_daemon_wait = daemon_sub.add_parser("wait", help="Wait for a daemon job")
|
||||
p_daemon_wait.add_argument("job_id", help="Job id returned by --background")
|
||||
|
||||
# mcp
|
||||
p_mcp = sub.add_parser(
|
||||
"mcp",
|
||||
@@ -1716,6 +2130,38 @@ def main():
|
||||
help="Storage backend to include in the MCP startup command",
|
||||
)
|
||||
|
||||
# serve — turnkey remote HTTP MCP server (#1877)
|
||||
p_serve = sub.add_parser(
|
||||
"serve",
|
||||
help="Run a secure remote HTTP MCP server for a team to share one palace",
|
||||
)
|
||||
p_serve.add_argument(
|
||||
"--host", default="127.0.0.1", help="Bind address (use 0.0.0.0 for remote clients)"
|
||||
)
|
||||
p_serve.add_argument("--port", type=int, default=8765, help="Bind port (default: 8765)")
|
||||
p_serve.add_argument(
|
||||
"--backend", default=None, help="Storage backend (default: config/env/detected)"
|
||||
)
|
||||
p_serve.add_argument("--palace", default=None, help="Palace path (overrides config/env)")
|
||||
p_serve.add_argument(
|
||||
"--token",
|
||||
default=None,
|
||||
help="Bearer token clients must present. Default: reuse/auto-generate one for "
|
||||
"non-loopback binds (stored 0600 under ~/.mempalace/server/).",
|
||||
)
|
||||
p_serve.add_argument("--tls-cert", default=None, help="PEM certificate to enable TLS")
|
||||
p_serve.add_argument("--tls-key", default=None, help="PEM private key matching --tls-cert")
|
||||
p_serve.add_argument(
|
||||
"--read-only",
|
||||
action="store_true",
|
||||
help="Expose recall only: mutating tools are hidden and refused",
|
||||
)
|
||||
p_serve.add_argument(
|
||||
"--allow-insecure",
|
||||
action="store_true",
|
||||
help="Permit a non-loopback bind with no token (only behind a trusted proxy)",
|
||||
)
|
||||
|
||||
# status
|
||||
# migrate
|
||||
p_migrate = sub.add_parser(
|
||||
@@ -1743,6 +2189,9 @@ def main():
|
||||
)
|
||||
p_migrate_wings.add_argument("--yes", action="store_true", help="Skip the confirmation prompt")
|
||||
|
||||
p_hallways = sub.add_parser("hallways", help="List entity hallways (associative graph)")
|
||||
p_hallways.add_argument("--wing", default=None, help="Filter to one wing")
|
||||
p_hallways.add_argument("--limit", type=int, default=50, help="Max hallways to show")
|
||||
p_status = sub.add_parser("status", help="Show what's been filed")
|
||||
p_status.add_argument(
|
||||
"--backend",
|
||||
@@ -1806,6 +2255,13 @@ def main():
|
||||
p_palace.print_help()
|
||||
return
|
||||
|
||||
if args.command == "daemon":
|
||||
if not getattr(args, "daemon_action", None):
|
||||
p_daemon.print_help()
|
||||
return
|
||||
cmd_daemon(args)
|
||||
return
|
||||
|
||||
dispatch = {
|
||||
"init": cmd_init,
|
||||
"mine": cmd_mine,
|
||||
@@ -1814,12 +2270,14 @@ def main():
|
||||
"sweep": cmd_sweep,
|
||||
"sync": cmd_sync,
|
||||
"mcp": cmd_mcp,
|
||||
"serve": cmd_serve,
|
||||
"compress": cmd_compress,
|
||||
"wake-up": cmd_wakeup,
|
||||
"repair": cmd_repair,
|
||||
"repair-status": cmd_repair_status,
|
||||
"migrate": cmd_migrate,
|
||||
"migrate-wings": cmd_migrate_wings,
|
||||
"hallways": cmd_hallways,
|
||||
"status": cmd_status,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
+257
-2
@@ -11,6 +11,14 @@ from datetime import date, datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from .write_routing import (
|
||||
ResolvedWriteRoutingPolicy,
|
||||
RoutingPolicyCandidate,
|
||||
WriteRoutingError,
|
||||
WriteRoutingPolicy,
|
||||
resolve_write_routing_policy,
|
||||
)
|
||||
|
||||
# ── Input validation ──────────────────────────────────────────────────────────
|
||||
# Shared sanitizers for wing/room/entity names. Prevents path traversal,
|
||||
# excessively long strings, and special characters that could cause issues
|
||||
@@ -31,6 +39,22 @@ def strip_lone_surrogates(text: str) -> str:
|
||||
return _LONE_SURROGATE_RE.sub("�", text)
|
||||
|
||||
|
||||
# Tool output mined from real transcripts routinely embeds a NUL character
|
||||
# (U+0000) — e.g. captured Bash output where a reader raced a background
|
||||
# writer, or genuine binary/NUL-delimited command output. A document
|
||||
# containing one is otherwise valid, well-formed text (unlike a lone
|
||||
# surrogate, which is invalid UTF-8), but handing it to ChromaDB's
|
||||
# SQLite/FTS5 layer can corrupt the FTS5 inverted index for the *whole*
|
||||
# collection (``PRAGMA quick_check`` reports "malformed inverted index for
|
||||
# FTS5 table"), not just fail to store that one document. Stripping it
|
||||
# before it reaches the chromadb client is the same defense-in-depth this
|
||||
# module already applies to lone surrogates (#1235) — sanitize input we
|
||||
# don't control before it reaches a datastore we don't control.
|
||||
def strip_nul_bytes(text: str) -> str:
|
||||
"""Replace embedded NUL characters with U+FFFD before ChromaDB storage."""
|
||||
return text.replace("\x00", "�")
|
||||
|
||||
|
||||
def normalize_wing_name(name: str) -> str:
|
||||
"""Lower-case + collapse separators (`-`, ` `) to `_` for wing slugs.
|
||||
|
||||
@@ -197,6 +221,13 @@ def sanitize_content(value: str, max_length: int = 100_000) -> str:
|
||||
DEFAULT_PALACE_PATH = os.path.expanduser("~/.mempalace/palace")
|
||||
DEFAULT_COLLECTION_NAME = "mempalace_drawers"
|
||||
DEFAULT_BACKEND = "chroma"
|
||||
DEFAULT_MILVUS_CONSISTENCY_LEVEL = "Strong"
|
||||
_MILVUS_CONSISTENCY_LEVELS = {
|
||||
"strong": "Strong",
|
||||
"session": "Session",
|
||||
"bounded": "Bounded",
|
||||
"eventually": "Eventually",
|
||||
}
|
||||
|
||||
# How many timestamped palace backups to retain before the oldest are
|
||||
# pruned. Applies to the accumulating backups written by ``mempalace
|
||||
@@ -205,6 +236,30 @@ DEFAULT_BACKEND = "chroma"
|
||||
DEFAULT_MAX_BACKUPS = 10
|
||||
|
||||
|
||||
def normalize_milvus_consistency_level(value) -> str:
|
||||
raw = str(value).strip() if value else DEFAULT_MILVUS_CONSISTENCY_LEVEL
|
||||
normalized = _MILVUS_CONSISTENCY_LEVELS.get(raw.lower())
|
||||
if normalized:
|
||||
return normalized
|
||||
allowed = ", ".join(_MILVUS_CONSISTENCY_LEVELS.values())
|
||||
raise ValueError(f"milvus_consistency_level must be one of: {allowed}")
|
||||
|
||||
|
||||
def sqlite_read_uri(db_path: str) -> str:
|
||||
"""Return a read-only ``file:`` URI for ``sqlite3.connect(..., uri=True)``.
|
||||
|
||||
A bare ``f"file:{db_path}?mode=ro"`` mis-parses paths containing spaces or
|
||||
other URI-reserved characters — common in real home directories (a Windows
|
||||
user folder like ``First Last``, many macOS paths). ``pathname2url``
|
||||
percent-encodes the path and normalizes separators so the database opens on
|
||||
every platform.
|
||||
"""
|
||||
from urllib.request import pathname2url
|
||||
|
||||
db_path = os.fspath(db_path)
|
||||
return f"file:{pathname2url(db_path)}?mode=ro"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_configured_collection_name() -> str:
|
||||
"""Return the configured drawer collection name without repeated config-file reads."""
|
||||
@@ -295,18 +350,26 @@ class MempalaceConfig:
|
||||
Load order: env vars > config file > defaults.
|
||||
"""
|
||||
|
||||
def __init__(self, config_dir=None):
|
||||
def __init__(self, config_dir=None, palace_path=None):
|
||||
"""Initialize config.
|
||||
|
||||
Args:
|
||||
config_dir: Override config directory (useful for testing).
|
||||
Defaults to ~/.mempalace.
|
||||
palace_path: Explicit palace data directory. This is primarily
|
||||
used by CLI operations that received ``--palace``;
|
||||
it takes precedence over environment and file config.
|
||||
"""
|
||||
self._config_dir = (
|
||||
Path(config_dir) if config_dir else Path(os.path.expanduser("~/.mempalace"))
|
||||
)
|
||||
self._config_file = self._config_dir / "config.json"
|
||||
self._people_map_file = self._config_dir / "people_map.json"
|
||||
self._palace_path_override = (
|
||||
os.path.abspath(os.path.expanduser(str(palace_path)))
|
||||
if palace_path is not None
|
||||
else None
|
||||
)
|
||||
self._file_config = {}
|
||||
|
||||
if self._config_file.exists():
|
||||
@@ -319,13 +382,15 @@ class MempalaceConfig:
|
||||
@property
|
||||
def palace_path(self):
|
||||
"""Path to the memory palace data directory."""
|
||||
if self._palace_path_override is not None:
|
||||
return self._palace_path_override
|
||||
env_val = os.environ.get("MEMPALACE_PALACE_PATH") or os.environ.get("MEMPAL_PALACE_PATH")
|
||||
if env_val:
|
||||
# Normalize: expand ~ and collapse .. to match the CLI --palace
|
||||
# code path (mcp_server.py:62) and prevent surprise redirection
|
||||
# when the env var contains unresolved components.
|
||||
return os.path.abspath(os.path.expanduser(env_val))
|
||||
return self._file_config.get("palace_path", DEFAULT_PALACE_PATH)
|
||||
return os.path.expanduser(self._file_config.get("palace_path", DEFAULT_PALACE_PATH))
|
||||
|
||||
@property
|
||||
def tunnel_file(self):
|
||||
@@ -406,6 +471,56 @@ class MempalaceConfig:
|
||||
timeout = 10.0
|
||||
return timeout if timeout > 0 else 10.0
|
||||
|
||||
@property
|
||||
def milvus_uri(self):
|
||||
"""Milvus endpoint for the opt-in ``milvus`` backend.
|
||||
|
||||
Defaults to ``None`` so selecting Milvus uses per-palace Milvus Lite at
|
||||
``<palace>/milvus.db``. Set this only to deliberately use a shared
|
||||
Milvus server, Zilliz Cloud, or a custom local Lite file.
|
||||
"""
|
||||
env_val = os.environ.get("MEMPALACE_MILVUS_URI")
|
||||
if env_val:
|
||||
return env_val.strip()
|
||||
value = self._file_config.get("milvus_uri")
|
||||
return str(value).strip() if value else None
|
||||
|
||||
@property
|
||||
def milvus_token(self):
|
||||
"""Token for the opt-in ``milvus`` backend, if configured."""
|
||||
env_val = os.environ.get("MEMPALACE_MILVUS_TOKEN")
|
||||
if env_val:
|
||||
return env_val
|
||||
value = self._file_config.get("milvus_token")
|
||||
return str(value) if value else None
|
||||
|
||||
@property
|
||||
def milvus_db_name(self):
|
||||
"""Optional Milvus database name for the opt-in ``milvus`` backend."""
|
||||
env_val = os.environ.get("MEMPALACE_MILVUS_DB_NAME")
|
||||
if env_val:
|
||||
return env_val.strip()
|
||||
value = self._file_config.get("milvus_db_name")
|
||||
return str(value).strip() if value else None
|
||||
|
||||
@property
|
||||
def milvus_namespace(self):
|
||||
"""Optional Milvus collection namespace/prefix."""
|
||||
env_val = os.environ.get("MEMPALACE_MILVUS_NAMESPACE")
|
||||
if env_val:
|
||||
return env_val.strip()
|
||||
value = self._file_config.get("milvus_namespace")
|
||||
return str(value).strip() if value else None
|
||||
|
||||
@property
|
||||
def milvus_consistency_level(self):
|
||||
"""Milvus read consistency level for the opt-in ``milvus`` backend."""
|
||||
env_val = os.environ.get("MEMPALACE_MILVUS_CONSISTENCY_LEVEL")
|
||||
if env_val:
|
||||
return normalize_milvus_consistency_level(env_val)
|
||||
value = self._file_config.get("milvus_consistency_level")
|
||||
return normalize_milvus_consistency_level(value)
|
||||
|
||||
@property
|
||||
def pgvector_dsn(self):
|
||||
"""Postgres DSN for the opt-in ``pgvector`` backend.
|
||||
@@ -645,6 +760,37 @@ class MempalaceConfig:
|
||||
return env_val.strip().lower()
|
||||
return str(self._file_config.get("embedding_model", "minilm")).strip().lower()
|
||||
|
||||
@property
|
||||
def embedding_threads(self) -> int:
|
||||
"""Cap on the embedder's ONNX Runtime intra-op thread pool (#1068).
|
||||
|
||||
ChromaDB's ONNX embedder builds its ``InferenceSession`` with no thread
|
||||
cap, so the intra-op pool defaults to the physical core count and a
|
||||
background ``mine`` pins every core — stacked Stop-hook fires turn into
|
||||
thermal events. ``OMP_NUM_THREADS`` is inert here (ORT owns its own
|
||||
pool), so the cap is applied via ``SessionOptions`` in
|
||||
:mod:`mempalace.embedding`.
|
||||
|
||||
Read from env ``MEMPALACE_EMBEDDING_THREADS`` first, then
|
||||
``embedding_threads`` in ``config.json``. Semantics:
|
||||
|
||||
- unset / ``"auto"`` → half the logical CPUs (min 1), so a background
|
||||
mine leaves the machine usable out of the box.
|
||||
- a positive integer → exactly that many intra-op threads.
|
||||
- ``0`` or negative → uncapped: ORT's default (physical core count),
|
||||
for users who want maximum indexing throughput.
|
||||
"""
|
||||
raw = os.environ.get("MEMPALACE_EMBEDDING_THREADS")
|
||||
if raw is None:
|
||||
raw = self._file_config.get("embedding_threads")
|
||||
if raw is None or str(raw).strip().lower() in ("", "auto"):
|
||||
return max(1, (os.cpu_count() or 2) // 2)
|
||||
try:
|
||||
val = int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return max(1, (os.cpu_count() or 2) // 2)
|
||||
return val if val > 0 else 0
|
||||
|
||||
def set_embedding_model(self, model: str) -> None:
|
||||
"""Persist the embedding-model choice to ``config.json``.
|
||||
|
||||
@@ -749,6 +895,115 @@ class MempalaceConfig:
|
||||
"""Whether the stop hook shows a desktop notification via notify-send."""
|
||||
return self._file_config.get("hooks", {}).get("desktop_toast", False)
|
||||
|
||||
def resolve_write_routing(self, scope: str) -> ResolvedWriteRoutingPolicy:
|
||||
"""Resolve the configured write policy for ``hooks`` or ``cli``.
|
||||
|
||||
Precedence is:
|
||||
|
||||
1. scope-specific environment variable;
|
||||
2. global environment variable;
|
||||
3. legacy hook environment variable;
|
||||
4. scope-specific config value;
|
||||
5. global config value;
|
||||
6. legacy hook config value;
|
||||
7. ``direct``.
|
||||
|
||||
This foundation does not change current hook or CLI behavior. The
|
||||
policy-aware consumers are introduced by follow-up PRs.
|
||||
"""
|
||||
|
||||
normalized_scope = str(scope).strip().lower()
|
||||
env_names = {
|
||||
"hooks": "MEMPALACE_HOOK_WRITE_ROUTING",
|
||||
"cli": "MEMPALACE_CLI_WRITE_ROUTING",
|
||||
}
|
||||
|
||||
if normalized_scope not in env_names:
|
||||
raise WriteRoutingError("write routing scope must be 'hooks' or 'cli'")
|
||||
|
||||
routing_config = self._file_config.get("write_routing", {})
|
||||
if routing_config is None:
|
||||
routing_config = {}
|
||||
|
||||
if not isinstance(routing_config, dict):
|
||||
raise WriteRoutingError("config write_routing must be an object")
|
||||
|
||||
candidates = [
|
||||
RoutingPolicyCandidate(
|
||||
env_names[normalized_scope],
|
||||
os.environ.get(env_names[normalized_scope]),
|
||||
),
|
||||
RoutingPolicyCandidate(
|
||||
"MEMPALACE_WRITE_ROUTING",
|
||||
os.environ.get("MEMPALACE_WRITE_ROUTING"),
|
||||
),
|
||||
]
|
||||
|
||||
if normalized_scope == "hooks":
|
||||
candidates.append(
|
||||
RoutingPolicyCandidate(
|
||||
"MEMPALACE_HOOKS_DAEMON (legacy)",
|
||||
os.environ.get("MEMPALACE_HOOKS_DAEMON"),
|
||||
legacy_boolean=True,
|
||||
)
|
||||
)
|
||||
|
||||
candidates.extend(
|
||||
[
|
||||
RoutingPolicyCandidate(
|
||||
f"config write_routing.{normalized_scope}",
|
||||
routing_config.get(normalized_scope),
|
||||
),
|
||||
RoutingPolicyCandidate(
|
||||
"config write_routing.default",
|
||||
routing_config.get("default"),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if normalized_scope == "hooks":
|
||||
hooks_config = self._file_config.get("hooks", {})
|
||||
if hooks_config is None:
|
||||
hooks_config = {}
|
||||
|
||||
if not isinstance(hooks_config, dict):
|
||||
raise WriteRoutingError("config hooks must be an object")
|
||||
|
||||
candidates.append(
|
||||
RoutingPolicyCandidate(
|
||||
"config hooks.daemon (legacy)",
|
||||
hooks_config.get("daemon"),
|
||||
legacy_boolean=True,
|
||||
)
|
||||
)
|
||||
|
||||
return resolve_write_routing_policy(candidates)
|
||||
|
||||
@property
|
||||
def hook_write_routing(self) -> WriteRoutingPolicy:
|
||||
"""Resolved future routing policy for hook-triggered writes."""
|
||||
|
||||
return self.resolve_write_routing("hooks").policy
|
||||
|
||||
@property
|
||||
def cli_write_routing(self) -> WriteRoutingPolicy:
|
||||
"""Resolved future routing policy for routine CLI writes."""
|
||||
|
||||
return self.resolve_write_routing("cli").policy
|
||||
|
||||
@property
|
||||
def hook_use_daemon(self):
|
||||
"""Whether hooks should submit save/mine work to the opt-in daemon."""
|
||||
env_val = os.environ.get("MEMPALACE_HOOKS_DAEMON")
|
||||
if env_val is not None:
|
||||
return env_val.lower() in ("true", "1", "yes", "on")
|
||||
value = self._file_config.get("hooks", {}).get("daemon", False)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value == 1
|
||||
|
||||
def set_hook_setting(self, key: str, value: bool):
|
||||
"""Update a hook setting and write config to disk."""
|
||||
if "hooks" not in self._file_config:
|
||||
|
||||
+228
-50
@@ -10,7 +10,9 @@ Same palace as project mining. Different ingest strategy.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
@@ -19,6 +21,7 @@ from typing import Optional
|
||||
from .collision_scan import assert_no_collisions
|
||||
from .ids import ID_RECIPE, make_convo_drawer_id, make_convo_sentinel_id
|
||||
from .normalize import normalize
|
||||
from .entities import entities_metadata
|
||||
from .palace import (
|
||||
NORMALIZE_VERSION,
|
||||
SKIP_DIRS,
|
||||
@@ -62,6 +65,16 @@ CONVO_EXTENSIONS = {
|
||||
".jsonl",
|
||||
}
|
||||
|
||||
# Directories inside conversation sources that never hold conversations.
|
||||
# ``tool-results``: Claude Code pages large tool outputs to
|
||||
# ``<session>/tool-results/*.txt`` inside ``~/.claude/projects/<slug>/``.
|
||||
# They are raw machine dumps referenced from the transcript JSONL — mining
|
||||
# them stores megabytes of command output as "memories" (field measurement:
|
||||
# 12.8k drawers from tool-results files on one palace; a single file
|
||||
# produced 3.6k). Extends the generic SKIP_DIRS set for the convo scanner
|
||||
# only — project mining semantics are unchanged.
|
||||
CONVO_SKIP_DIRS = SKIP_DIRS | {"tool-results"}
|
||||
|
||||
MIN_CHUNK_SIZE = 30
|
||||
CHUNK_SIZE = 800 # chars per drawer — align with miner.py
|
||||
_LINE_GROUP_SIZE = 25 # lines per fallback group when no paragraph breaks
|
||||
@@ -77,30 +90,67 @@ MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB — skip files larger than this.
|
||||
# use also scales with source size.
|
||||
|
||||
|
||||
def _path_within_root(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.expanduser().resolve().relative_to(root.expanduser().resolve())
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _is_regular_source_file(filepath: Path, root: Path) -> bool:
|
||||
if not _path_within_root(filepath, root):
|
||||
return False
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = -1
|
||||
try:
|
||||
fd = os.open(filepath, flags)
|
||||
st = os.fstat(fd)
|
||||
return stat.S_ISREG(st.st_mode) and st.st_size <= MAX_FILE_SIZE
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
if fd != -1:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _register_file(collection, source_file: str, wing: str, agent: str, extract_mode: str):
|
||||
"""Write a sentinel so file_already_mined() returns True for 0-chunk files.
|
||||
|
||||
Without this, files that normalize to nothing or produce zero chunks are
|
||||
re-read and re-processed on every mine run because nothing was written to
|
||||
ChromaDB on the first pass.
|
||||
|
||||
Stamps source_mtime like every real drawer does, so a file that later
|
||||
grows past the min-chunk-size floor (e.g. a short session that gets
|
||||
extended) is correctly detected as changed on the next mine instead of
|
||||
being skipped forever by this sentinel.
|
||||
"""
|
||||
try:
|
||||
source_mtime = os.path.getmtime(source_file)
|
||||
except OSError:
|
||||
source_mtime = None
|
||||
sentinel_id = make_convo_sentinel_id(source_file, extract_mode)
|
||||
meta = {
|
||||
"wing": wing,
|
||||
"room": "_registry",
|
||||
"source_file": source_file,
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"ingest_mode": "registry",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
if source_mtime is not None:
|
||||
meta["source_mtime"] = source_mtime
|
||||
collection.upsert(
|
||||
documents=[f"[registry] {source_file}"],
|
||||
ids=[sentinel_id],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": wing,
|
||||
"room": "_registry",
|
||||
"source_file": source_file,
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"ingest_mode": "registry",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
],
|
||||
metadatas=[meta],
|
||||
)
|
||||
|
||||
|
||||
@@ -355,7 +405,7 @@ def scan_convos(convo_dir: str) -> list:
|
||||
convo_path = Path(convo_dir).expanduser().resolve()
|
||||
files = []
|
||||
for root, dirs, filenames in os.walk(convo_path):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||
dirs[:] = [d for d in dirs if d not in CONVO_SKIP_DIRS]
|
||||
for filename in filenames:
|
||||
if filename.endswith(".meta.json"):
|
||||
continue
|
||||
@@ -369,10 +419,30 @@ def scan_convos(convo_dir: str) -> list:
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
# Skip files exceeding size limit, or those whose stat() raises
|
||||
# (permission denied, racing delete, broken symlink that
|
||||
# survived the earlier is_symlink check). Both branches log
|
||||
# to stderr to match the SKIP: (symlink) line above; silent
|
||||
# drops at this gate were the original #923 complaint.
|
||||
try:
|
||||
if filepath.stat().st_size > MAX_FILE_SIZE:
|
||||
file_size = filepath.stat().st_size
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
print(
|
||||
f" SKIP: {filepath.name} ({file_size / (1024 * 1024):.1f} MB)"
|
||||
f" exceeds {MAX_FILE_SIZE // (1024 * 1024)} MB limit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
# Prefer ``exc.strerror`` so the path isn't duplicated in
|
||||
# the output (see the matching comment in
|
||||
# ``miner.scan_project``).
|
||||
print(
|
||||
f" SKIP: {filepath.name} (stat error: {exc.strerror or exc})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
if not _is_regular_source_file(filepath, convo_path):
|
||||
continue
|
||||
files.append(filepath)
|
||||
return files
|
||||
@@ -383,12 +453,51 @@ def scan_convos(convo_dir: str) -> list:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extract_mode):
|
||||
def _extract_authored_at(filepath):
|
||||
"""Most-recent message timestamp in a transcript, used as the drawer's authored date.
|
||||
|
||||
Both Claude Code and Codex JSONL transcripts carry a top-level ISO-8601
|
||||
``timestamp`` on each line. We take the max so ``authored_at`` reflects when the
|
||||
content was actually written, independent of when it was mined (``filed_at``).
|
||||
This restores chronology: a session from days ago keeps its real date even when
|
||||
re-mined today, instead of every drawer collapsing to ingest time. Returns None
|
||||
for formats without per-line timestamps (e.g. plain ``.md``).
|
||||
"""
|
||||
path = Path(filepath)
|
||||
if path.suffix != ".jsonl":
|
||||
return None
|
||||
latest = None
|
||||
try:
|
||||
with path.open(encoding="utf-8", errors="ignore") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
ts = json.loads(line).get("timestamp")
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
continue
|
||||
# ISO-8601 timestamps are strings; guard against a non-string
|
||||
# ``timestamp`` so a malformed line can't raise TypeError on compare.
|
||||
if isinstance(ts, str) and (latest is None or ts > latest):
|
||||
latest = ts
|
||||
except OSError:
|
||||
return None
|
||||
return latest
|
||||
|
||||
|
||||
def _file_chunks_locked(
|
||||
collection, source_file, chunks, wing, room, agent, extract_mode, authored_at=None
|
||||
):
|
||||
"""Lock the source file, purge stale drawers, and upsert fresh chunks.
|
||||
|
||||
Combines the per-file serialization that prevents concurrent agents from
|
||||
duplicating work (via mine_lock) with the normalize-version rebuild
|
||||
contract (purge-before-insert so pre-v2 drawers don't survive).
|
||||
duplicating work (via mine_lock) with the rebuild contract
|
||||
(purge-before-insert so stale drawers never survive) that fires on
|
||||
either a normalize-version bump OR a changed/grown source file (mtime
|
||||
differs from what's stored) -- transcripts are not assumed immutable,
|
||||
since a Claude Code session keeps appending to its own file while
|
||||
active and /compact or /clear can rewrite one in place.
|
||||
|
||||
Returns (drawers_added, room_counts_delta, skipped).
|
||||
"""
|
||||
@@ -396,14 +505,15 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
|
||||
drawers_added = 0
|
||||
with mine_lock(source_file):
|
||||
# Re-check after lock — another agent may have just finished this file
|
||||
# at the current schema. A stale-version hit here returns False, so we
|
||||
# at the current schema/mtime. A stale hit here returns False, so we
|
||||
# still fall through to the purge+rebuild path below.
|
||||
if file_already_mined(collection, source_file, extract_mode=extract_mode):
|
||||
if file_already_mined(collection, source_file, check_mtime=True, extract_mode=extract_mode):
|
||||
return 0, room_counts_delta, True
|
||||
|
||||
# Purge stale drawers first. When the normalize schema bumps,
|
||||
# file_already_mined() returned False for pre-v2 drawers — clean
|
||||
# them out so the source doesn't end up with mixed old/new drawers.
|
||||
# Purge stale drawers first. Fires both on a normalize-schema bump
|
||||
# (file_already_mined() returned False for pre-v2 drawers) and on a
|
||||
# changed/grown transcript (mtime differs) — clean them out so the
|
||||
# source doesn't end up with mixed old/new drawers.
|
||||
try:
|
||||
delete_ids = _source_file_delete_ids(collection, source_file, extract_mode)
|
||||
if delete_ids:
|
||||
@@ -416,6 +526,10 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
|
||||
# one filed_at per source file so all transcript drawers share an
|
||||
# ingest timestamp.
|
||||
filed_at = datetime.now().isoformat()
|
||||
try:
|
||||
source_mtime = os.path.getmtime(source_file)
|
||||
except OSError:
|
||||
source_mtime = None
|
||||
for batch_start in range(0, len(chunks), DRAWER_UPSERT_BATCH_SIZE):
|
||||
batch_docs: list = []
|
||||
batch_ids: list = []
|
||||
@@ -429,21 +543,24 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
|
||||
)
|
||||
batch_docs.append(chunk["content"])
|
||||
batch_ids.append(drawer_id)
|
||||
batch_metas.append(
|
||||
{
|
||||
"wing": wing,
|
||||
"room": chunk_room,
|
||||
"hall": _detect_hall_cached(chunk["content"]),
|
||||
"source_file": source_file,
|
||||
"chunk_index": chunk["chunk_index"],
|
||||
"added_by": agent,
|
||||
"filed_at": filed_at,
|
||||
"ingest_mode": "convos",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
)
|
||||
meta = {
|
||||
"wing": wing,
|
||||
"room": chunk_room,
|
||||
"hall": _detect_hall_cached(chunk["content"]),
|
||||
"source_file": source_file,
|
||||
"chunk_index": chunk["chunk_index"],
|
||||
"added_by": agent,
|
||||
"filed_at": filed_at,
|
||||
"entities": entities_metadata(chunk["content"]),
|
||||
"authored_at": authored_at if authored_at is not None else filed_at,
|
||||
"ingest_mode": "convos",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
if source_mtime is not None:
|
||||
meta["source_mtime"] = source_mtime
|
||||
batch_metas.append(meta)
|
||||
assert_no_collisions(list(zip(batch_ids, batch_metas)), collection)
|
||||
try:
|
||||
collection.upsert(
|
||||
@@ -487,6 +604,28 @@ def _is_ai_tool_path(path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _is_unchanged_since_last_mine(source_file: str, mined_mtimes: dict) -> bool:
|
||||
"""True iff source_file was mined at the current schema AND its on-disk
|
||||
mtime still matches what was stored -- the mtime-aware replacement for
|
||||
"we've seen this source_file before" (transcripts are not immutable).
|
||||
|
||||
False (re-mine) whenever the file isn't in mined_mtimes at all, its
|
||||
stored mtime is None (never recorded -- pre-mtime-tracking drawer, or
|
||||
getmtime failed when it was written), or getmtime fails right now
|
||||
(treat as changed rather than silently trusting stale data).
|
||||
"""
|
||||
if source_file not in mined_mtimes:
|
||||
return False
|
||||
stored_mtime = mined_mtimes[source_file]
|
||||
if stored_mtime is None:
|
||||
return False
|
||||
try:
|
||||
current_mtime = os.path.getmtime(source_file)
|
||||
except OSError:
|
||||
return False
|
||||
return abs(stored_mtime - current_mtime) < 0.001
|
||||
|
||||
|
||||
def _resolve_wing(convo_path: Path, wing: Optional[str]) -> str:
|
||||
"""Determine the destination wing for ``mine_convos``.
|
||||
|
||||
@@ -566,6 +705,22 @@ def mine_convos(
|
||||
)
|
||||
|
||||
|
||||
def _compute_hallways_for_wing_safe(wing, collection, drawers_filed, config=None):
|
||||
"""Auto-populate the associative graph from the entities just mined.
|
||||
|
||||
Best-effort: hallway computation must never fail an otherwise-good mine, and is
|
||||
skipped when nothing new was filed.
|
||||
"""
|
||||
if drawers_filed <= 0:
|
||||
return
|
||||
try:
|
||||
from .hallways import compute_hallways_for_wing
|
||||
|
||||
compute_hallways_for_wing(wing, col=collection, config=config)
|
||||
except Exception as exc:
|
||||
print(f" (hallways skipped: {exc})")
|
||||
|
||||
|
||||
def _mine_convos_impl(
|
||||
convo_dir: str,
|
||||
palace_path: str,
|
||||
@@ -577,7 +732,7 @@ def _mine_convos_impl(
|
||||
):
|
||||
from .config import MempalaceConfig
|
||||
|
||||
palace_config = MempalaceConfig()
|
||||
palace_config = MempalaceConfig(palace_path=palace_path)
|
||||
cfg_chunk_size = palace_config.chunk_size
|
||||
# Only override convo_miner's MIN_CHUNK_SIZE when the user has set
|
||||
# min_chunk_size explicitly. min_chunk_size_explicit returns the
|
||||
@@ -609,13 +764,14 @@ def _mine_convos_impl(
|
||||
|
||||
collection = get_collection(palace_path) if not dry_run else None
|
||||
|
||||
# Bulk pre-fetch already-mined set in one paginated pass instead of
|
||||
# `len(files)` separate WHERE-source_file queries. On a 150k-drawer
|
||||
# palace each per-file query costs ~2s, so a 2000-file sweep used to
|
||||
# spend >1h just deciding to skip. prefetch_mined_set() does the same
|
||||
# decisions in a single scan; loop body becomes an O(1) set check.
|
||||
mined_set: set[str] = (
|
||||
prefetch_mined_set(collection, extract_mode=extract_mode) if not dry_run else set()
|
||||
# Bulk pre-fetch already-mined source_file -> stored mtime in one
|
||||
# paginated pass instead of `len(files)` separate WHERE-source_file
|
||||
# queries. On a 150k-drawer palace each per-file query costs ~2s, so a
|
||||
# 2000-file sweep used to spend >1h just deciding to skip.
|
||||
# prefetch_mined_set() does the same decisions in a single scan; loop
|
||||
# body becomes an O(1) dict lookup + a cheap local mtime comparison.
|
||||
mined_mtimes: dict = (
|
||||
prefetch_mined_set(collection, extract_mode=extract_mode) if not dry_run else {}
|
||||
)
|
||||
|
||||
total_drawers = 0
|
||||
@@ -628,8 +784,19 @@ def _mine_convos_impl(
|
||||
files_processed = i
|
||||
source_file = str(filepath)
|
||||
|
||||
# Skip if already filed at current NORMALIZE_VERSION
|
||||
if not dry_run and source_file in mined_set:
|
||||
# Skip only if already filed at the current NORMALIZE_VERSION AND
|
||||
# unchanged on disk since. Transcripts are NOT assumed immutable:
|
||||
# a Claude Code session keeps appending to the same file while
|
||||
# active, and /compact or /clear can rewrite one in place -- so
|
||||
# "we've seen this source_file before" alone is not sufficient.
|
||||
# Falling through re-mines: _file_chunks_locked purges this
|
||||
# source_file's stale drawers before inserting fresh ones, so this
|
||||
# never leaves duplicates behind.
|
||||
if not dry_run and _is_unchanged_since_last_mine(source_file, mined_mtimes):
|
||||
files_skipped += 1
|
||||
continue
|
||||
|
||||
if not _is_regular_source_file(filepath, Path(convo_dir).expanduser().resolve()):
|
||||
files_skipped += 1
|
||||
continue
|
||||
|
||||
@@ -697,7 +864,14 @@ def _mine_convos_impl(
|
||||
# Lock + purge stale + file fresh chunks. Lock serializes concurrent
|
||||
# agents; purge removes pre-v2 drawers so the schema bump applies.
|
||||
drawers_added, room_delta, skipped = _file_chunks_locked(
|
||||
collection, source_file, chunks, wing, room, agent, extract_mode
|
||||
collection,
|
||||
source_file,
|
||||
chunks,
|
||||
wing,
|
||||
room,
|
||||
agent,
|
||||
extract_mode,
|
||||
authored_at=_extract_authored_at(filepath),
|
||||
)
|
||||
if skipped:
|
||||
files_skipped += 1
|
||||
@@ -712,6 +886,10 @@ def _mine_convos_impl(
|
||||
break
|
||||
|
||||
if not dry_run:
|
||||
# Compute hallways before the FTS5 validation: the latter opens a direct sqlite
|
||||
# connection to the Chroma DB, which can invalidate the live collection handle on
|
||||
# some Chroma builds and make the hallway fetch fail.
|
||||
_compute_hallways_for_wing_safe(wing, collection, total_drawers, config=palace_config)
|
||||
_validate_palace_fts5_after_mine(palace_path)
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
|
||||
+1164
File diff suppressed because it is too large
Load Diff
+83
-4
@@ -32,6 +32,7 @@ rather than hard-failing — mining must still work on a laptop without CUDA.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
@@ -112,6 +113,35 @@ def _resolve_providers(device: str) -> tuple[list, str]:
|
||||
return (requested, device)
|
||||
|
||||
|
||||
def _intra_op_session_options(intra_op_num_threads: int):
|
||||
"""Build ORT ``SessionOptions`` capping the intra-op thread pool (#1068).
|
||||
|
||||
Returns ``None`` when ``intra_op_num_threads <= 0`` so the caller leaves
|
||||
ORT at its default (≈ physical core count). ChromaDB's embedder ignores
|
||||
``OMP_NUM_THREADS`` — ORT owns its own intra-op pool, settable only via
|
||||
``SessionOptions`` at session construction — so a cap has to be threaded
|
||||
through here rather than via the environment.
|
||||
"""
|
||||
if not intra_op_num_threads or intra_op_num_threads <= 0:
|
||||
return None
|
||||
import onnxruntime as ort
|
||||
|
||||
so = ort.SessionOptions()
|
||||
so.intra_op_num_threads = intra_op_num_threads
|
||||
return so
|
||||
|
||||
|
||||
def _resolve_intra_op_threads() -> int:
|
||||
"""Read the configured ORT intra-op thread cap (``0`` = uncapped, #1068)."""
|
||||
try:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
return MempalaceConfig().embedding_threads
|
||||
except Exception:
|
||||
logger.debug("embedding_threads resolution failed; leaving ORT default", exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def _build_ef_class():
|
||||
"""Subclass ``ONNXMiniLM_L6_V2`` with name ``"default"``.
|
||||
|
||||
@@ -122,13 +152,51 @@ def _build_ef_class():
|
||||
palaces created with ``DefaultEmbeddingFunction`` *and* palaces we
|
||||
create ourselves, with the same GPU-capable ``preferred_providers``.
|
||||
"""
|
||||
from functools import cached_property
|
||||
|
||||
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2
|
||||
|
||||
class _MempalaceONNX(ONNXMiniLM_L6_V2):
|
||||
def __init__(self, preferred_providers=None, intra_op_num_threads=0):
|
||||
super().__init__(preferred_providers=preferred_providers)
|
||||
self._intra_op_num_threads = intra_op_num_threads
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "default"
|
||||
|
||||
@cached_property
|
||||
def model(self):
|
||||
# Upstream builds the InferenceSession with no intra-op thread cap,
|
||||
# so ORT defaults its pool to the physical core count and a
|
||||
# background mine pins every core (#1068). Rebuild the session the
|
||||
# same way upstream does (same SessionOptions, same CoreML pruning,
|
||||
# same model path) but with our cap applied. If upstream's
|
||||
# internals shift, fall back to its uncapped build so embedding
|
||||
# still works.
|
||||
cap = getattr(self, "_intra_op_num_threads", 0)
|
||||
if not cap or cap <= 0:
|
||||
return super().model
|
||||
try:
|
||||
ort = self.ort
|
||||
providers = self._preferred_providers or ort.get_available_providers()
|
||||
providers = [p for p in providers if p != "CoreMLExecutionProvider"]
|
||||
so = ort.SessionOptions()
|
||||
so.log_severity_level = 3
|
||||
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
so.intra_op_num_threads = cap
|
||||
return ort.InferenceSession(
|
||||
os.path.join(self.DOWNLOAD_PATH, self.EXTRACTED_FOLDER_NAME, "model.onnx"),
|
||||
providers=providers,
|
||||
sess_options=so,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"thread-capped ORT session build failed; using ORT defaults",
|
||||
exc_info=True,
|
||||
)
|
||||
return super().model
|
||||
|
||||
return _MempalaceONNX
|
||||
|
||||
|
||||
@@ -173,13 +241,19 @@ class EmbeddinggemmaONNX:
|
||||
# when switching models. Keep it stable.
|
||||
return "embeddinggemma_300m"
|
||||
|
||||
def __init__(self, preferred_providers=None, batch_size: int = _EMBEDDINGGEMMA_BATCH_SIZE):
|
||||
def __init__(
|
||||
self,
|
||||
preferred_providers=None,
|
||||
batch_size: int = _EMBEDDINGGEMMA_BATCH_SIZE,
|
||||
intra_op_num_threads: int = 0,
|
||||
):
|
||||
if batch_size < 1:
|
||||
raise ValueError(f"batch_size must be >= 1, got {batch_size}")
|
||||
self._providers = (
|
||||
list(preferred_providers) if preferred_providers else ["CPUExecutionProvider"]
|
||||
)
|
||||
self._batch_size = batch_size
|
||||
self._intra_op_num_threads = intra_op_num_threads
|
||||
self._session = None
|
||||
self._tokenizer = None
|
||||
self._np = None
|
||||
@@ -221,7 +295,11 @@ class EmbeddinggemmaONNX:
|
||||
)
|
||||
tok_path = hf_hub_download(_EMBEDDINGGEMMA_REPO, filename="tokenizer.json")
|
||||
|
||||
session = ort.InferenceSession(model_path, providers=self._providers)
|
||||
session = ort.InferenceSession(
|
||||
model_path,
|
||||
sess_options=_intra_op_session_options(self._intra_op_num_threads),
|
||||
providers=self._providers,
|
||||
)
|
||||
out_names = [o.name for o in session.get_outputs()]
|
||||
# Model card: sentence_embedding is the pooled output (last_hidden_state
|
||||
# is the per-token output we don't want).
|
||||
@@ -309,12 +387,13 @@ def get_embedding_function(device: Optional[str] = None, model: Optional[str] =
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
threads = _resolve_intra_op_threads()
|
||||
if model == "embeddinggemma":
|
||||
ef = EmbeddinggemmaONNX(preferred_providers=providers)
|
||||
ef = EmbeddinggemmaONNX(preferred_providers=providers, intra_op_num_threads=threads)
|
||||
else:
|
||||
# Default: minilm (or anything we don't recognize — back-compat win).
|
||||
ef_cls = _build_ef_class()
|
||||
ef = ef_cls(preferred_providers=providers)
|
||||
ef = ef_cls(preferred_providers=providers, intra_op_num_threads=threads)
|
||||
|
||||
_EF_CACHE[cache_key] = ef
|
||||
logger.info(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""No-LLM structural entity extraction for the associative graph.
|
||||
|
||||
Pulls deterministic, *structural* tokens from text — author-quoted code spans, URLs,
|
||||
file paths, qualified identifiers, and CamelCase symbols — to populate the ``entities``
|
||||
drawer-metadata field that hallways/tunnels consume. Structural-only by design: no
|
||||
wordlists, no NLP models, no domain vocabulary, so it stays language-neutral and
|
||||
predictable, and biases to precision (only tokens that are unambiguously "a thing being
|
||||
referred to") over recall.
|
||||
|
||||
The output format matches what ``hallways._parse_entities`` expects: a ``;``-joined string.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Author-quoted code spans are the highest-signal structural marker: `foo`, `obj.method()`.
|
||||
_BACKTICK = re.compile(r"`([^`\n]{2,64})`")
|
||||
# URLs.
|
||||
_URL = re.compile(r"https?://[^\s)>\]}\"']+")
|
||||
# Paths with a separator and a short extension: rag/foo.py, a/b/c.tsx.
|
||||
_PATH = re.compile(r"\b[\w.-]+/[\w./-]*\.[A-Za-z][A-Za-z0-9]{0,4}\b")
|
||||
# Qualified dotted identifiers; each segment starts with a letter and is >=2 chars, so
|
||||
# "1.2.3", "e.g", and "i.e" are excluded: module.func, pkg.Class.method.
|
||||
_QUALIFIED = re.compile(r"\b[A-Za-z][A-Za-z0-9_]+(?:\.[A-Za-z][A-Za-z0-9_]+)+\b")
|
||||
# CamelCase with >=2 humps — strongly code-specific: ChromaBackend, MemoryStack.
|
||||
_CAMEL = re.compile(r"\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+\b")
|
||||
# snake_case (must contain an underscore, so it can't match plain English): do_thing,
|
||||
# _extract_authored_at. The optional leading/trailing `_?` matches dunder-style names
|
||||
# whose underscore would otherwise fall outside the `\b` boundary (`_` is a word char).
|
||||
_SNAKE = re.compile(r"\b_?[a-z][a-z0-9]*(?:_[a-z0-9]+)+_?\b")
|
||||
|
||||
_MAX_ENTITIES = 24
|
||||
_MIN_LEN = 2
|
||||
_MAX_LEN = 64
|
||||
|
||||
|
||||
def _clean(token):
|
||||
# `;` is the entities-metadata separator, so it must never survive inside an entity
|
||||
# (e.g. a URL query string or a backtick span) or it would split the field.
|
||||
return token.replace(";", " ").strip().strip("`.,:()[]{}<>\"'").strip()
|
||||
|
||||
|
||||
def extract_structural_entities(text, max_entities=_MAX_ENTITIES):
|
||||
"""Return up to ``max_entities`` structural entities from ``text``.
|
||||
|
||||
Deterministic and order-stable: entities are ranked by occurrence count (ties broken
|
||||
by first appearance), deduplicated case-insensitively, preserving the first-seen
|
||||
surface form.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
counts = {}
|
||||
order = {}
|
||||
seq = 0
|
||||
for pattern in (_BACKTICK, _URL, _PATH, _QUALIFIED, _CAMEL, _SNAKE):
|
||||
for match in pattern.finditer(text):
|
||||
token = _clean(match.group(1) if pattern is _BACKTICK else match.group(0))
|
||||
if not (_MIN_LEN <= len(token) <= _MAX_LEN):
|
||||
continue
|
||||
key = token.lower()
|
||||
if key not in counts:
|
||||
counts[key] = 0
|
||||
order[key] = (seq, token)
|
||||
seq += 1
|
||||
counts[key] += 1
|
||||
ranked = sorted(order, key=lambda k: (-counts[k], order[k][0]))
|
||||
return [order[k][1] for k in ranked[:max_entities]]
|
||||
|
||||
|
||||
def entities_metadata(text, max_entities=_MAX_ENTITIES):
|
||||
"""``;``-joined entity string for drawer metadata, or ``""`` when none are found."""
|
||||
return ";".join(extract_structural_entities(text, max_entities=max_entities))
|
||||
@@ -215,6 +215,8 @@ PROSE_EXTENSIONS = {
|
||||
".md",
|
||||
".rst",
|
||||
".csv",
|
||||
".tex",
|
||||
".bib",
|
||||
}
|
||||
|
||||
READABLE_EXTENSIONS = {
|
||||
|
||||
@@ -750,7 +750,7 @@ def mine_formats(
|
||||
# min_chunk_size) are now threaded through chunk_text below, so users
|
||||
# who customized their config see the effect in format-mode mining.
|
||||
# Per PR #1555 review (Gemini #3).
|
||||
palace_config = MempalaceConfig()
|
||||
palace_config = MempalaceConfig(palace_path=palace_path)
|
||||
|
||||
format_path = Path(format_dir).expanduser().resolve()
|
||||
if not wing:
|
||||
@@ -945,7 +945,7 @@ def mine_formats(
|
||||
# skipped quietly.
|
||||
if not dry_run:
|
||||
try:
|
||||
tunnels_added = _compute_topic_tunnels_for_wing(wing)
|
||||
tunnels_added = _compute_topic_tunnels_for_wing(wing, config=palace_config)
|
||||
if tunnels_added:
|
||||
print(f"\n Topic tunnels: +{tunnels_added} cross-wing link(s)")
|
||||
except Exception as exc:
|
||||
|
||||
+12
-7
@@ -199,6 +199,7 @@ def compute_hallways_for_wing(
|
||||
wing: str,
|
||||
col=None,
|
||||
min_count: int = 2,
|
||||
config=None,
|
||||
) -> list[dict]:
|
||||
"""Compute entity-pair hallways for one wing.
|
||||
|
||||
@@ -229,6 +230,10 @@ def compute_hallways_for_wing(
|
||||
hallway between two entities. Default 2 — single co-occurrences
|
||||
are noise (entities mentioned together once in one drawer);
|
||||
two or more is a real signal. Clamped to ``>=1``.
|
||||
config: Optional ``MempalaceConfig`` selecting the palace-scoped
|
||||
hallway sidecar. Callers using an explicit palace path must pass
|
||||
the matching config so derived graph state cannot leak into the
|
||||
default palace.
|
||||
|
||||
Returns:
|
||||
List of hallway dicts created for this wing. Records for other
|
||||
@@ -310,7 +315,7 @@ def compute_hallways_for_wing(
|
||||
# across recomputes. Without this preservation, every mine wipes
|
||||
# the connection weights accumulated through use — defeating the
|
||||
# living-connection layer entirely.
|
||||
existing = _load_hallways()
|
||||
existing = _load_hallways(config)
|
||||
existing_dynamics_lookup: dict = {}
|
||||
for h in existing:
|
||||
if h.get("wing") != wing:
|
||||
@@ -360,7 +365,7 @@ def compute_hallways_for_wing(
|
||||
|
||||
# 4. Persist — preserve other-wing records, replace this wing's records.
|
||||
preserved_other_wings = [h for h in existing if h.get("wing") != wing]
|
||||
_save_hallways(preserved_other_wings + created)
|
||||
_save_hallways(preserved_other_wings + created, config)
|
||||
|
||||
return created
|
||||
|
||||
@@ -370,19 +375,19 @@ def compute_hallways_for_wing(
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def list_hallways(wing: Optional[str] = None) -> list[dict]:
|
||||
def list_hallways(wing: Optional[str] = None, config=None) -> list[dict]:
|
||||
"""List hallway records. Filter by ``wing`` if specified."""
|
||||
all_hallways = _load_hallways()
|
||||
all_hallways = _load_hallways(config)
|
||||
if wing is None:
|
||||
return list(all_hallways)
|
||||
return [h for h in all_hallways if h.get("wing") == wing]
|
||||
|
||||
|
||||
def delete_hallway(hallway_id: str) -> bool:
|
||||
def delete_hallway(hallway_id: str, config=None) -> bool:
|
||||
"""Remove one hallway record by id. Returns True if a record was removed."""
|
||||
hallways = _load_hallways()
|
||||
hallways = _load_hallways(config)
|
||||
filtered = [h for h in hallways if h.get("id") != hallway_id]
|
||||
if len(filtered) == len(hallways):
|
||||
return False
|
||||
_save_hallways(filtered)
|
||||
_save_hallways(filtered, config)
|
||||
return True
|
||||
|
||||
+314
-13
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Hook logic for MemPalace — Python implementation of session-start, stop, and precompact hooks.
|
||||
Hook logic for MemPalace — Python implementation of session-start, stop, session-end, and precompact hooks.
|
||||
|
||||
Reads JSON from stdin, outputs JSON to stdout.
|
||||
Supported hooks: session-start, stop, precompact
|
||||
Supported hooks: session-start, stop, session-end, precompact
|
||||
Supported harnesses: claude-code, codex (extensible to cursor, gemini, etc.)
|
||||
"""
|
||||
|
||||
@@ -25,7 +25,7 @@ PALACE_ROOT = Path.home() / ".mempalace"
|
||||
|
||||
|
||||
def _detached_popen_kwargs() -> dict:
|
||||
"""Kwargs that fully detach a Popen child so the hook process can exit.
|
||||
"""Kwargs that give a Popen child a hidden console so the hook can exit.
|
||||
|
||||
Without these, Windows holds the parent open until the child closes the
|
||||
inherited stdout/stderr handles — manifesting as "Stop hook hangs" at
|
||||
@@ -36,7 +36,7 @@ def _detached_popen_kwargs() -> dict:
|
||||
kwargs: dict = {"stdin": subprocess.DEVNULL, "close_fds": True}
|
||||
if os.name == "nt":
|
||||
flags = 0
|
||||
for name in ("DETACHED_PROCESS", "CREATE_NEW_PROCESS_GROUP", "CREATE_BREAKAWAY_FROM_JOB"):
|
||||
for name in ("CREATE_NO_WINDOW", "CREATE_NEW_PROCESS_GROUP", "CREATE_BREAKAWAY_FROM_JOB"):
|
||||
flags |= getattr(subprocess, name, 0)
|
||||
if flags:
|
||||
kwargs["creationflags"] = flags
|
||||
@@ -509,6 +509,73 @@ def _spawn_mine(cmd: list) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _hooks_daemon_enabled() -> bool:
|
||||
try:
|
||||
return MempalaceConfig().hook_use_daemon is True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _daemon_mine_dedupe_key(source: str, mode: str) -> str:
|
||||
try:
|
||||
source_key = str(Path(source).expanduser().resolve())
|
||||
except OSError:
|
||||
source_key = str(Path(source).expanduser())
|
||||
return f"hook:mine:{mode}:{source_key}"
|
||||
|
||||
|
||||
def _daemon_available() -> bool:
|
||||
"""True iff a daemon is already running for the configured palace.
|
||||
|
||||
This is a fast localhost health check, not a spawn: the 500ms hook budget
|
||||
forbids auto-starting a python subprocess from a hook (cold start is
|
||||
~15s). Daemon mode for hooks requires the user to have started the daemon
|
||||
explicitly via `mempalace daemon start`; when it isn't up, hooks fall back
|
||||
to the existing direct (in-process / spawn) path instead of blocking.
|
||||
"""
|
||||
from .daemon import HOOK_PROBE_TIMEOUT, get_client_if_running
|
||||
|
||||
try:
|
||||
return (
|
||||
get_client_if_running(MempalaceConfig().palace_path, health_timeout=HOOK_PROBE_TIMEOUT)
|
||||
is not None
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _submit_daemon_job(
|
||||
kind: str,
|
||||
payload: dict,
|
||||
*,
|
||||
dedupe_key: str = None,
|
||||
priority: int = 0,
|
||||
wait: bool = False,
|
||||
timeout: float = 60.0,
|
||||
):
|
||||
"""Submit to an already-running daemon. Never auto-starts (see _daemon_available).
|
||||
|
||||
Raises DaemonError on a real failure (job rejected, timeout, daemon died
|
||||
mid-submit). Callers must NOT fall back to the direct path on such errors —
|
||||
the daemon may already have accepted the job, and re-running it would
|
||||
duplicate verbatim content. Only an absent daemon (handled by the caller's
|
||||
_daemon_available() precheck) should fall back.
|
||||
"""
|
||||
from .daemon import submit_job
|
||||
|
||||
palace_path = MempalaceConfig().palace_path
|
||||
return submit_job(
|
||||
kind,
|
||||
payload,
|
||||
palace_path=palace_path,
|
||||
dedupe_key=dedupe_key,
|
||||
priority=priority,
|
||||
wait=wait,
|
||||
auto_start=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _maybe_auto_ingest():
|
||||
"""Background-mine MEMPAL_DIR (project files) if set.
|
||||
|
||||
@@ -527,9 +594,26 @@ def _maybe_auto_ingest():
|
||||
return
|
||||
for mine_dir, mode in targets:
|
||||
try:
|
||||
if _hooks_daemon_enabled() and _daemon_available():
|
||||
try:
|
||||
_submit_daemon_job(
|
||||
"mine",
|
||||
{"source": mine_dir, "mode": mode, "agent": "mempalace"},
|
||||
dedupe_key=_daemon_mine_dedupe_key(mine_dir, mode),
|
||||
wait=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Daemon accepted context — don't fall back (would double-mine).
|
||||
_log(f"Daemon mine submission failed: {exc}")
|
||||
continue
|
||||
_spawn_mine([_mempalace_python(), "-m", "mempalace", "mine", mine_dir, "--mode", mode])
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
# Non-daemon spawn path failed. Hooks must never crash the user's
|
||||
# shell — log and continue. Do not label this a daemon failure: the
|
||||
# daemon block above handles its own errors with its own message.
|
||||
_log(f"mine hook failed: {exc}")
|
||||
|
||||
|
||||
def _mine_sync():
|
||||
@@ -546,6 +630,22 @@ def _mine_sync():
|
||||
log_path = STATE_DIR / "hook.log"
|
||||
for mine_dir, mode in targets:
|
||||
try:
|
||||
if _hooks_daemon_enabled() and _daemon_available():
|
||||
try:
|
||||
job = _submit_daemon_job(
|
||||
"mine",
|
||||
{"source": mine_dir, "mode": mode, "agent": "mempalace"},
|
||||
dedupe_key=_daemon_mine_dedupe_key(mine_dir, mode),
|
||||
wait=True,
|
||||
timeout=60,
|
||||
)
|
||||
result = job.get("result") or {}
|
||||
if job.get("state") != "succeeded" or not result.get("success", True):
|
||||
_log(f"Daemon sync mine failed: {result.get('error', job.get('error'))}")
|
||||
except Exception as exc:
|
||||
# Daemon accepted context — don't fall back (would double-mine).
|
||||
_log(f"Daemon sync mine submission failed: {exc}")
|
||||
continue
|
||||
with open(log_path, "a") as log_f:
|
||||
subprocess.run(
|
||||
[
|
||||
@@ -560,9 +660,19 @@ def _mine_sync():
|
||||
stdout=log_f,
|
||||
stderr=log_f,
|
||||
timeout=60,
|
||||
# Windows: hide the conhost window this sync mine would
|
||||
# otherwise flash on every fire. Mirrors the async paths
|
||||
# (_spawn_mine / _desktop_toast) via _detached_popen_kwargs().
|
||||
# 0 is a no-op off-Windows.
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
except Exception as exc:
|
||||
# Non-daemon sync spawn path failed. Hooks must never crash the
|
||||
# user's shell — log and continue (not a daemon failure; the daemon
|
||||
# block above handles its own errors).
|
||||
_log(f"mine hook failed: {exc}")
|
||||
|
||||
|
||||
def _desktop_toast(body: str, title: str = "MemPalace"):
|
||||
@@ -680,6 +790,41 @@ def _save_diary_direct(
|
||||
)
|
||||
|
||||
try:
|
||||
if _hooks_daemon_enabled() and _daemon_available():
|
||||
try:
|
||||
job = _submit_daemon_job(
|
||||
"diary_write",
|
||||
{
|
||||
"agent_name": agent_name,
|
||||
"entry": entry,
|
||||
"topic": "checkpoint",
|
||||
"wing": wing,
|
||||
},
|
||||
priority=10,
|
||||
wait=True,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Daemon accepted context — don't fall back (would double-write).
|
||||
_log(f"Daemon diary checkpoint failed: {exc}")
|
||||
return {"count": 0}
|
||||
result = job.get("result") or {}
|
||||
if job.get("state") == "succeeded" and result.get("success"):
|
||||
_log(f"Diary checkpoint saved: {result.get('entry_id', '?')}")
|
||||
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 - {len(messages)} messages archived")
|
||||
return {"count": len(messages), "themes": themes}
|
||||
_log(f"Daemon diary checkpoint failed: {result.get('error', job.get('error'))}")
|
||||
return {"count": 0}
|
||||
|
||||
from .mcp_server import tool_diary_write
|
||||
|
||||
result = tool_diary_write(
|
||||
@@ -711,8 +856,13 @@ def _save_diary_direct(
|
||||
|
||||
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:
|
||||
path = _validate_transcript_path(transcript_path)
|
||||
if path is None:
|
||||
return
|
||||
try:
|
||||
if not path.is_file() or path.stat().st_size < 100:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -721,6 +871,25 @@ def _ingest_transcript(transcript_path: str):
|
||||
return
|
||||
|
||||
try:
|
||||
if _hooks_daemon_enabled() and _daemon_available():
|
||||
try:
|
||||
_submit_daemon_job(
|
||||
"mine",
|
||||
{
|
||||
"source": str(path.parent),
|
||||
"mode": "convos",
|
||||
"wing": "sessions",
|
||||
"agent": "mempalace",
|
||||
},
|
||||
dedupe_key=_daemon_mine_dedupe_key(str(path.parent), "convos"),
|
||||
wait=False,
|
||||
)
|
||||
_log(f"Transcript ingest submitted to daemon: {path.name}")
|
||||
except Exception as exc:
|
||||
# Daemon accepted context — don't fall back (would double-mine).
|
||||
_log(f"Daemon transcript ingest failed: {exc}")
|
||||
return
|
||||
|
||||
# Route through ``_spawn_mine`` so the per-target PID guard kicks
|
||||
# in here too — repeated Stop/PreCompact fires for the same
|
||||
# transcript should not stack up parallel ingest mines.
|
||||
@@ -740,6 +909,11 @@ def _ingest_transcript(transcript_path: str):
|
||||
_log(f"Transcript ingest started: {path.name}")
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
# Non-daemon ingest spawn path failed. Hooks must never crash the
|
||||
# user's shell — log and continue (not a daemon failure; the daemon
|
||||
# block above handles its own errors).
|
||||
_log(f"transcript ingest hook failed: {exc}")
|
||||
|
||||
|
||||
SUPPORTED_HARNESSES = {"claude-code", "codex"}
|
||||
@@ -787,6 +961,26 @@ _ENCODED_PARENT_PREFIXES = (
|
||||
)
|
||||
|
||||
|
||||
def _safe_wing_slug(name: str) -> str:
|
||||
"""Normalize a project directory name into a wing slug ``sanitize_name`` accepts.
|
||||
|
||||
Builds on the historical space/hyphen handling: map characters outside
|
||||
``sanitize_name``'s set to ``_`` while keeping ``.`` and ``'`` so existing wings
|
||||
for names like ``my.app`` are preserved (renaming a wing would orphan diary
|
||||
entries already filed under it), collapse ``..`` which the validator rejects as
|
||||
path traversal, trim edge separators it won't accept, and cap the length so
|
||||
``wing_<slug>`` stays within ``sanitize_name``'s 128-character limit. Without
|
||||
this a folder containing e.g. a leading ``+`` produced ``wing_+project``, which
|
||||
``sanitize_name`` rejects — silently breaking diary auto-save. Falls back to
|
||||
``sessions`` when a name reduces to nothing (e.g. ``+``).
|
||||
"""
|
||||
slug = name.lower().replace(" ", "_").replace("-", "_")
|
||||
slug = re.sub(r"[^\w.']+", "_", slug)
|
||||
slug = re.sub(r"\.{2,}", ".", slug)
|
||||
slug = slug[:120].strip("_.'")
|
||||
return slug or "sessions"
|
||||
|
||||
|
||||
def _wing_from_jsonl_cwd(transcript_path: str) -> Optional[str]:
|
||||
"""Read ``cwd`` from the first JSONL line that records it.
|
||||
|
||||
@@ -820,8 +1014,7 @@ def _wing_from_jsonl_cwd(transcript_path: str) -> Optional[str]:
|
||||
continue
|
||||
project = cwd_norm.rsplit("/", 1)[-1]
|
||||
if project:
|
||||
slug = project.lower().replace(" ", "_").replace("-", "_")
|
||||
return f"wing_{slug}"
|
||||
return f"wing_{_safe_wing_slug(project)}"
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
@@ -878,15 +1071,12 @@ def _wing_from_transcript_path(transcript_path: str) -> str:
|
||||
if encoded.startswith(prefix):
|
||||
encoded = encoded[len(prefix) :]
|
||||
break
|
||||
project = encoded.lower().replace(" ", "_").replace("-", "_")
|
||||
if project:
|
||||
return f"wing_{project}"
|
||||
return f"wing_{_safe_wing_slug(encoded)}"
|
||||
|
||||
# 3. Legacy — explicit -Projects-<name> segment
|
||||
match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized)
|
||||
if match:
|
||||
project = match.group(1).lower().replace(" ", "_").replace("-", "_")
|
||||
return f"wing_{project}"
|
||||
return f"wing_{_safe_wing_slug(match.group(1))}"
|
||||
|
||||
# 4. Default
|
||||
return "wing_sessions"
|
||||
@@ -1021,6 +1211,116 @@ def hook_session_start(data: dict, harness: str):
|
||||
_output({})
|
||||
|
||||
|
||||
def _clear_session_last_save(session_id: str) -> None:
|
||||
"""Drop the per-session save marker once a session has ended.
|
||||
|
||||
``hook_stop`` writes ``{session_id}_last_save`` but never had a clean-exit
|
||||
cleanup path, so the marker lingered. The session is over by the time
|
||||
``hook_session_end`` runs, so removing it here keeps ``hook_state/`` from
|
||||
accumulating dead markers. OS errors (including a missing marker, since
|
||||
``FileNotFoundError`` is an ``OSError``) are swallowed — this is best-effort
|
||||
cleanup, never a reason to fail the hook.
|
||||
"""
|
||||
try:
|
||||
(STATE_DIR / f"{session_id}_last_save").unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def hook_session_end(data: dict, harness: str):
|
||||
"""Session end hook: one final flush when a session exits cleanly.
|
||||
|
||||
Closes the gap (#1341) where a session that never crosses ``SAVE_INTERVAL``
|
||||
on ``Stop`` and never triggers ``PreCompact`` exits with nothing saved —
|
||||
the common case for short, useful sessions.
|
||||
|
||||
Why background instead of mine inline: Claude Code's hooks reference
|
||||
documents a default SessionEnd timeout of 1.5 seconds, and "timeouts set on
|
||||
plugin-provided hooks do not raise the budget"
|
||||
(https://code.claude.com/docs/en/hooks). A cold ``mempalace`` start alone
|
||||
exceeds 1.5s, so this handler must never mine in the hook foreground. The
|
||||
shell wrapper backgrounds it and returns immediately; the heavy capture is
|
||||
spawned *detached* via ``_ingest_transcript`` / ``_maybe_auto_ingest`` (both
|
||||
route through ``_spawn_mine`` / ``_detached_popen_kwargs``). On POSIX that
|
||||
detached child reliably outlives the session (verified). On Windows only the
|
||||
mine grandchild (spawned with detached-process flags) is designed to break
|
||||
away from the session; the backgrounded hook process and the in-process
|
||||
diary write are best-effort there (no Windows CI coverage yet). This
|
||||
honors the "background everything / hooks under 500ms" budget. SessionEnd
|
||||
has no decision control, so this only ever saves; it never emits a block
|
||||
payload.
|
||||
"""
|
||||
if not _palace_root_exists():
|
||||
_output({})
|
||||
return
|
||||
|
||||
# Parse inside the try so a malformed payload (e.g. non-dict stdin that
|
||||
# makes _parse_harness_input raise) still runs the finally cleanup below.
|
||||
session_id = "unknown"
|
||||
try:
|
||||
parsed = _parse_harness_input(data, harness)
|
||||
session_id = parsed["session_id"]
|
||||
transcript_path = parsed["transcript_path"]
|
||||
|
||||
# Read config defensively (mirror hook_stop): a corrupt or unreadable
|
||||
# config must not lose the final save, so default to auto-save on and
|
||||
# toasts off rather than crashing the hook.
|
||||
try:
|
||||
config = MempalaceConfig()
|
||||
auto_save = config.hooks_auto_save
|
||||
toast = config.hook_desktop_toast
|
||||
except Exception:
|
||||
auto_save = True
|
||||
toast = False
|
||||
|
||||
# Respect auto_save config toggle (clean opt-out)
|
||||
if not auto_save:
|
||||
_output({})
|
||||
return
|
||||
|
||||
_log(f"SESSION END for session {session_id}")
|
||||
|
||||
# Validate the harness-provided transcript path before touching it
|
||||
# (extension + ".." traversal check), mirroring the read path that
|
||||
# already runs through _validate_transcript_path. A rejected path skips
|
||||
# the transcript captures but still lets the independent MEMPAL_DIR mine
|
||||
# run.
|
||||
valid_transcript = ""
|
||||
if transcript_path:
|
||||
try:
|
||||
validated = _validate_transcript_path(transcript_path)
|
||||
except OSError:
|
||||
validated = None
|
||||
if validated is None:
|
||||
_log(f"WARNING: transcript_path rejected by validator: {transcript_path!r}")
|
||||
else:
|
||||
valid_transcript = str(validated)
|
||||
|
||||
# Flush. The diary checkpoint (in-process ChromaDB write) runs FIRST,
|
||||
# before any detached mine is spawned, so it never contends for the
|
||||
# palace lock; this handler is already backgrounded by the wrapper, so it
|
||||
# is not under the SessionEnd budget and has time to finish. The detached
|
||||
# transcript ingest follows; re-mining a transcript ``Stop`` already
|
||||
# captured is a near no-op (deterministic convo IDs + ``file_already_mined``
|
||||
# short-circuit + upsert). ``reason`` is intentionally not branched on:
|
||||
# every clean-exit reason (incl. ``/clear`` / ``resume``) warrants the
|
||||
# flush. Order matches ``hook_stop``.
|
||||
if valid_transcript:
|
||||
_save_diary_direct(
|
||||
valid_transcript,
|
||||
session_id,
|
||||
wing=_wing_from_transcript_path(valid_transcript),
|
||||
toast=toast,
|
||||
agent_name=_diary_agent_for_harness(harness),
|
||||
)
|
||||
_ingest_transcript(valid_transcript)
|
||||
_maybe_auto_ingest()
|
||||
|
||||
_output({})
|
||||
finally:
|
||||
_clear_session_last_save(session_id)
|
||||
|
||||
|
||||
def hook_precompact(data: dict, harness: str):
|
||||
"""Precompact hook: mine transcript synchronously, then allow compaction.
|
||||
|
||||
@@ -1064,6 +1364,7 @@ def run_hook(hook_name: str, harness: str):
|
||||
hooks = {
|
||||
"session-start": hook_session_start,
|
||||
"stop": hook_stop,
|
||||
"session-end": hook_session_end,
|
||||
"precompact": hook_precompact,
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ AI memory system. Store everything, find anything. Local, free, no API key.
|
||||
|
||||
### Palace (write)
|
||||
- mempalace_add_drawer -- Add a new memory (drawer)
|
||||
- mempalace_checkpoint -- Save a whole session in one call (dedup + file + diary)
|
||||
- mempalace_delete_drawer -- Delete a memory (drawer)
|
||||
|
||||
### Knowledge Graph
|
||||
|
||||
@@ -39,7 +39,7 @@ import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .config import sanitize_iso_temporal
|
||||
@@ -113,6 +113,14 @@ def _temporal_filter_sql(as_of: str) -> tuple[str, list[str]]:
|
||||
|
||||
This keeps legacy date-only facts working when callers query with
|
||||
canonical UTC datetimes such as '2026-05-06T15:00:00Z'.
|
||||
|
||||
The upper bound is *strict* (``valid_to > as_of``): a fact whose
|
||||
``valid_to`` equals the query instant has already ended at that instant,
|
||||
so the interval is treated as half-open ``[valid_from, valid_to)``. This
|
||||
is what lets a fact and its successor share a boundary instant without an
|
||||
as-of query returning both. Date-only ``valid_to`` still expands to the
|
||||
end of that day (``T23:59:59Z``), so a standalone date-only fact stays
|
||||
valid through its whole final day exactly as before.
|
||||
"""
|
||||
|
||||
as_of_key = _temporal_start_key(as_of)
|
||||
@@ -121,7 +129,7 @@ def _temporal_filter_sql(as_of: str) -> tuple[str, list[str]]:
|
||||
|
||||
return (
|
||||
f" AND (t.valid_from IS NULL OR {valid_from_expr} <= ?) "
|
||||
f"AND (t.valid_to IS NULL OR {valid_to_expr} >= ?)",
|
||||
f"AND (t.valid_to IS NULL OR {valid_to_expr} > ?)",
|
||||
[as_of_key, as_of_key],
|
||||
)
|
||||
|
||||
@@ -359,6 +367,125 @@ class KnowledgeGraph:
|
||||
(ended, sub_id, pred, obj_id),
|
||||
)
|
||||
|
||||
def supersede(
|
||||
self,
|
||||
subject: str,
|
||||
predicate: str,
|
||||
old_obj: str,
|
||||
new_obj: str,
|
||||
at: str = None,
|
||||
confidence: float = 1.0,
|
||||
source_closet: str = None,
|
||||
source_file: str = None,
|
||||
source_drawer_id: str = None,
|
||||
adapter_name: str = None,
|
||||
):
|
||||
"""Atomically replace one fact with another at a single shared boundary.
|
||||
|
||||
Closes the currently-open ``(subject, predicate, old_obj)`` triple with
|
||||
``valid_to = at`` and opens ``(subject, predicate, new_obj)`` with
|
||||
``valid_from = at`` in one transaction, at a single shared instant.
|
||||
Paired with the half-open upper bound in ``_temporal_filter_sql``, an
|
||||
as-of query at that instant returns only the successor.
|
||||
|
||||
This is the primitive for a value change. Hand-rolling a handover as
|
||||
``invalidate(ended=D)`` + ``add_triple(valid_from=D)`` with date-only
|
||||
``D`` leaves two facts sharing the whole day ``D`` (``valid_to`` expands
|
||||
to ``T23:59:59Z`` while ``valid_from`` expands to ``T00:00:00Z``), so an
|
||||
as-of query on ``D`` returns both. ``supersede`` avoids this by writing
|
||||
one identical precise instant to both sides.
|
||||
|
||||
``at`` defaults to the current UTC instant. A date-only ``at`` is
|
||||
normalized to ``<date>T00:00:00Z`` so both sides carry the same precise
|
||||
value rather than the asymmetric whole-day expansion.
|
||||
|
||||
Returns the new triple's id. If no open ``old_obj`` triple exists the
|
||||
successor is still opened, so ``supersede`` degrades to ``add_triple``.
|
||||
"""
|
||||
if at is None:
|
||||
boundary = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
elif _is_date_only_temporal(at):
|
||||
boundary = f"{at}T00:00:00Z"
|
||||
else:
|
||||
boundary = at
|
||||
boundary = sanitize_iso_temporal(boundary, "at")
|
||||
|
||||
sub_id = self._entity_id(subject)
|
||||
old_id = self._entity_id(old_obj)
|
||||
new_id = self._entity_id(new_obj)
|
||||
pred = predicate.lower().replace(" ", "_")
|
||||
|
||||
with self._lock:
|
||||
conn = self._conn()
|
||||
with conn:
|
||||
# Only create entities we actually open a fact for. old_obj is
|
||||
# matched by id in the UPDATE below whether or not its row
|
||||
# exists, so inserting it would just orphan an entity when no
|
||||
# open old fact is present (the degrade-to-add path).
|
||||
for name, eid in ((subject, sub_id), (new_obj, new_id)):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)",
|
||||
(eid, name),
|
||||
)
|
||||
|
||||
# Reject a boundary that precedes the old fact's start — an
|
||||
# inverted interval would be invisible to every KG query.
|
||||
rows = conn.execute(
|
||||
"SELECT valid_from FROM triples "
|
||||
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
|
||||
(sub_id, pred, old_id),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
valid_from = row["valid_from"]
|
||||
if valid_from is not None and _temporal_end_key(boundary) < _temporal_start_key(
|
||||
valid_from
|
||||
):
|
||||
raise ValueError(
|
||||
f"at={boundary!r} is before valid_from={valid_from!r}; "
|
||||
"an inverted interval would be invisible to every KG query"
|
||||
)
|
||||
|
||||
# Close the open old fact at the shared boundary.
|
||||
conn.execute(
|
||||
"UPDATE triples SET valid_to=? "
|
||||
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
|
||||
(boundary, sub_id, pred, old_id),
|
||||
)
|
||||
|
||||
# Open the successor at the same instant (idempotent if already open).
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM triples "
|
||||
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
|
||||
(sub_id, pred, new_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return existing["id"]
|
||||
|
||||
triple_id = make_triple_id(
|
||||
sub_id, pred, new_id, boundary, datetime.now().isoformat()
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO triples (
|
||||
id, subject, predicate, object, valid_from, valid_to,
|
||||
confidence, source_closet, source_file,
|
||||
source_drawer_id, adapter_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
triple_id,
|
||||
sub_id,
|
||||
pred,
|
||||
new_id,
|
||||
boundary,
|
||||
None,
|
||||
confidence,
|
||||
source_closet,
|
||||
source_file,
|
||||
source_drawer_id,
|
||||
adapter_name,
|
||||
),
|
||||
)
|
||||
return triple_id
|
||||
|
||||
# ── Query operations ──────────────────────────────────────────────────
|
||||
|
||||
def query_entity(self, name: str, as_of: str = None, direction: str = "outgoing"):
|
||||
|
||||
+22
-6
@@ -126,12 +126,21 @@ class Layer1:
|
||||
if not docs:
|
||||
return "## L1 — No memories yet."
|
||||
|
||||
# Score each drawer: prefer high importance, recent filing
|
||||
# Score each drawer: prefer high importance, then most-recent filing.
|
||||
# NOTE: the ingest pipeline (miner, convo_miner, diary, add_drawer)
|
||||
# records provenance metadata — wing/room/source/chunk/filed_at — but
|
||||
# never an evaluative importance/weight field. So `importance` is
|
||||
# absent on virtually every drawer and ties at the default, which used
|
||||
# to collapse the sort to insertion order (oldest first). `filed_at`
|
||||
# is present on every drawer, so it is the *effective* ordering signal:
|
||||
# newest first. This keeps importance as the primary key for the day a
|
||||
# scoring pass populates it, while making the "recent filing" half of
|
||||
# the promise true today with data we already have.
|
||||
scored = []
|
||||
for doc, meta in zip(docs, metas):
|
||||
meta = meta or {}
|
||||
doc = doc or ""
|
||||
importance = 3
|
||||
importance = 3.0
|
||||
# Try multiple metadata keys that might carry weight info
|
||||
for key in ("importance", "emotional_weight", "weight"):
|
||||
val = meta.get(key)
|
||||
@@ -141,11 +150,15 @@ class Layer1:
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
scored.append((importance, meta, doc))
|
||||
# filed_at is an ISO-8601 string; ISO strings sort lexicographically
|
||||
# in chronological order. Coerce to str so a missing/odd value sorts
|
||||
# oldest rather than raising during the comparison.
|
||||
recency = str(meta.get("filed_at") or "")
|
||||
scored.append((importance, recency, meta, doc))
|
||||
|
||||
# Sort by importance descending, take top N
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
top = scored[: self.MAX_DRAWERS]
|
||||
# Sort by importance desc, then recency (filed_at) desc; take top N.
|
||||
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||
top = [(imp, meta, doc) for imp, _recency, meta, doc in scored[: self.MAX_DRAWERS]]
|
||||
|
||||
# Group by room for readability
|
||||
by_room = defaultdict(list)
|
||||
@@ -306,6 +319,9 @@ class Layer3:
|
||||
lines.append(f" {snippet}")
|
||||
if source:
|
||||
lines.append(f" src: {source}")
|
||||
authored = (meta.get("authored_at") or "")[:10]
|
||||
if authored:
|
||||
lines.append(f" authored: {authored}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -323,7 +323,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
base = base.removesuffix("/chat/completions").removesuffix("/v1")
|
||||
try:
|
||||
req = Request(f"{base}/v1/models")
|
||||
if self.api_key:
|
||||
if self.api_key and (self.api_key_source != "env" or not self.is_external_service):
|
||||
req.add_header("Authorization", f"Bearer {self.api_key}")
|
||||
with urlopen(req, timeout=5):
|
||||
pass
|
||||
|
||||
+1909
-164
File diff suppressed because it is too large
Load Diff
@@ -295,7 +295,7 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = f"{palace_path}.pre-migrate.{timestamp}"
|
||||
print(f"\n Backing up to {backup_path}...")
|
||||
shutil.copytree(palace_path, backup_path)
|
||||
shutil.copytree(palace_path, backup_path, symlinks=True)
|
||||
|
||||
# Enforce backup retention so repeated migrations cannot fill the disk
|
||||
# with full-palace copies. The backup we just created is the newest, so
|
||||
|
||||
+201
-28
@@ -14,6 +14,7 @@ import shlex
|
||||
import hashlib
|
||||
import fnmatch
|
||||
import logging
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
@@ -47,6 +48,66 @@ from .ids import ID_RECIPE, make_drawer_id_from_chunk
|
||||
|
||||
logger = logging.getLogger("mempalace_mcp")
|
||||
|
||||
|
||||
def _path_within_root(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.expanduser().resolve().relative_to(root.expanduser().resolve())
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _read_text_no_follow(filepath: Path, root: Path) -> Optional[str]:
|
||||
if not _path_within_root(filepath, root):
|
||||
return None
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = -1
|
||||
try:
|
||||
fd = os.open(filepath, flags)
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_size > MAX_FILE_SIZE:
|
||||
return None
|
||||
with os.fdopen(fd, "r", encoding="utf-8", errors="replace") as f:
|
||||
fd = -1
|
||||
return f.read()
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
if fd != -1:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
PHP_EXTENSIONS = {
|
||||
# Compound Blade templates such as ``view.blade.php`` are covered by the
|
||||
# final ``.php`` suffix.
|
||||
".php",
|
||||
".php3",
|
||||
".php4",
|
||||
".php5",
|
||||
".php7",
|
||||
".php8",
|
||||
".phtml",
|
||||
".phps",
|
||||
".phpt",
|
||||
".inc",
|
||||
".aw",
|
||||
".fcgi",
|
||||
".ctp",
|
||||
".module",
|
||||
".install",
|
||||
".profile",
|
||||
".theme",
|
||||
".engine",
|
||||
".twig",
|
||||
".blade",
|
||||
".tpl",
|
||||
".latte",
|
||||
".volt",
|
||||
}
|
||||
|
||||
READABLE_EXTENSIONS = {
|
||||
".txt",
|
||||
".md",
|
||||
@@ -64,12 +125,23 @@ READABLE_EXTENSIONS = {
|
||||
".java",
|
||||
".go",
|
||||
".rs",
|
||||
".swift",
|
||||
".kt",
|
||||
".kts",
|
||||
".rb",
|
||||
".sh",
|
||||
".csv",
|
||||
".sql",
|
||||
".toml",
|
||||
}
|
||||
".tex",
|
||||
".bib",
|
||||
# C# / .NET
|
||||
".cs",
|
||||
".csproj",
|
||||
".sln",
|
||||
".razor",
|
||||
".cshtml",
|
||||
} | PHP_EXTENSIONS
|
||||
|
||||
SKIP_FILENAMES = {
|
||||
"entities.json",
|
||||
@@ -172,20 +244,16 @@ class GitignoreMatcher:
|
||||
self.base_dir = base_dir
|
||||
self.rules = rules
|
||||
|
||||
@classmethod
|
||||
def from_dir(cls, dir_path: Path):
|
||||
gitignore_path = dir_path / ".gitignore"
|
||||
if not gitignore_path.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
lines = gitignore_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except Exception:
|
||||
return None
|
||||
@staticmethod
|
||||
def _parse_rules(lines):
|
||||
"""Parse an iterable of raw pattern strings into a list of rule dicts.
|
||||
|
||||
Shared by :meth:`from_dir` and :meth:`from_patterns` so both paths
|
||||
stay in sync with the gitignore parsing semantics.
|
||||
"""
|
||||
rules = []
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
line = str(raw_line).strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
@@ -217,12 +285,46 @@ class GitignoreMatcher:
|
||||
"negated": negated,
|
||||
}
|
||||
)
|
||||
return rules
|
||||
|
||||
if not rules:
|
||||
@classmethod
|
||||
def from_dir(cls, dir_path: Path):
|
||||
gitignore_path = dir_path / ".gitignore"
|
||||
if not gitignore_path.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
lines = gitignore_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
rules = cls._parse_rules(lines)
|
||||
if not rules:
|
||||
return None
|
||||
return cls(dir_path, rules)
|
||||
|
||||
@classmethod
|
||||
def from_patterns(cls, base_dir: Path, patterns):
|
||||
"""Create a matcher from an explicit list of gitignore-inspired pattern strings.
|
||||
|
||||
Supports a gitignore-inspired subset of pattern syntax for
|
||||
``exclude_patterns`` in ``mempalace.yaml``. Patterns are parsed by
|
||||
the same rule parser used for ``.gitignore`` files, so familiar
|
||||
constructs (trailing ``/`` for dir-only, leading ``/`` for anchoring,
|
||||
``!`` negation, ``**`` globs) work as expected. Full gitignore
|
||||
semantics are not guaranteed for every edge case.
|
||||
|
||||
``patterns`` must be a list of strings. A single string is coerced
|
||||
to a one-element list for convenience. Non-string entries are
|
||||
converted via ``str()``.
|
||||
"""
|
||||
if isinstance(patterns, str):
|
||||
patterns = [patterns]
|
||||
rules = cls._parse_rules(patterns)
|
||||
if not rules:
|
||||
return None
|
||||
return cls(base_dir, rules)
|
||||
|
||||
def matches(self, path: Path, is_dir: bool = None):
|
||||
try:
|
||||
relative = path.relative_to(self.base_dir).as_posix().strip("/")
|
||||
@@ -350,6 +452,29 @@ def is_force_included(path: Path, project_path: Path, include_paths: set) -> boo
|
||||
return False
|
||||
|
||||
|
||||
def _apply_exclude_patterns_to_prescanned_files(
|
||||
files: list, project_path: Path, exclude_patterns: list, include_ignored: list
|
||||
) -> list:
|
||||
"""Filter a caller-provided (already-scanned) file list by exclude_patterns.
|
||||
|
||||
scan_project() applies exclude_patterns itself, but a caller that passes
|
||||
its own pre-scanned ``files`` list bypasses that -- so callers reusing a
|
||||
scan (e.g. the init double-scan) still need per-project exclusions
|
||||
applied here. force_include paths are preserved, matching scan_project's
|
||||
own precedence.
|
||||
"""
|
||||
include_paths = normalize_include_paths(include_ignored)
|
||||
exclude_matcher = GitignoreMatcher.from_patterns(project_path, exclude_patterns)
|
||||
if exclude_matcher is None:
|
||||
return files
|
||||
return [
|
||||
file_path
|
||||
for file_path in files
|
||||
if is_force_included(file_path, project_path, include_paths)
|
||||
or exclude_matcher.matches(file_path, is_dir=False) is not True
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONFIG
|
||||
# =============================================================================
|
||||
@@ -1304,9 +1429,8 @@ def process_file(
|
||||
if not dry_run and file_already_mined(collection, source_file, check_mtime=True):
|
||||
return 0, "general", None
|
||||
|
||||
try:
|
||||
content = filepath.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
content = _read_text_no_follow(filepath, project_path)
|
||||
if content is None:
|
||||
return 0, "general", None
|
||||
|
||||
content = content.strip()
|
||||
@@ -1460,6 +1584,7 @@ def scan_project(
|
||||
project_dir: str,
|
||||
respect_gitignore: bool = True,
|
||||
include_ignored: list = None,
|
||||
exclude_patterns: list = None,
|
||||
) -> list:
|
||||
"""Return list of all readable file paths under ``project_dir``.
|
||||
|
||||
@@ -1472,6 +1597,7 @@ def scan_project(
|
||||
active_matchers = []
|
||||
matcher_cache = {}
|
||||
include_paths = normalize_include_paths(include_ignored)
|
||||
exclude_matcher = GitignoreMatcher.from_patterns(project_path, exclude_patterns or [])
|
||||
|
||||
for root, dirs, filenames in os.walk(project_path):
|
||||
root_path = Path(root)
|
||||
@@ -1499,6 +1625,13 @@ def scan_project(
|
||||
if is_force_included(root_path / d, project_path, include_paths)
|
||||
or not is_gitignored(root_path / d, active_matchers, is_dir=True)
|
||||
]
|
||||
if exclude_matcher:
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if is_force_included(root_path / d, project_path, include_paths)
|
||||
or exclude_matcher.matches(root_path / d, is_dir=True) is not True
|
||||
]
|
||||
|
||||
for filename in filenames:
|
||||
filepath = root_path / filename
|
||||
@@ -1512,6 +1645,15 @@ def scan_project(
|
||||
if respect_gitignore and active_matchers and not force_include:
|
||||
if is_gitignored(filepath, active_matchers, is_dir=False):
|
||||
continue
|
||||
# Skip files matching project-level exclude_patterns (mempalace.yaml).
|
||||
# force_include paths bypass this check so callers can selectively
|
||||
# re-include files that would otherwise be excluded.
|
||||
if (
|
||||
not force_include
|
||||
and exclude_matcher
|
||||
and exclude_matcher.matches(filepath, is_dir=False)
|
||||
):
|
||||
continue
|
||||
# Skip symlinks — prevents following links to /dev/urandom, etc.
|
||||
if filepath.is_symlink():
|
||||
rel = filepath.relative_to(project_path).as_posix()
|
||||
@@ -1520,11 +1662,30 @@ def scan_project(
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
# Skip files exceeding size limit
|
||||
# Skip files exceeding size limit, or those whose stat() raises
|
||||
# (permission denied, racing delete, broken symlink that survived
|
||||
# the earlier is_symlink check). Both branches log to stderr to
|
||||
# match the SKIP: (symlink) line above; silent drops at this
|
||||
# gate were the original #923 complaint.
|
||||
try:
|
||||
if filepath.stat().st_size > MAX_FILE_SIZE:
|
||||
file_size = filepath.stat().st_size
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
print(
|
||||
f" SKIP: {filepath.name} ({file_size / (1024 * 1024):.1f} MB)"
|
||||
f" exceeds {MAX_FILE_SIZE // (1024 * 1024)} MB limit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
# Prefer ``exc.strerror`` so the path isn't duplicated in the
|
||||
# output (PermissionError stringifies to
|
||||
# ``[Errno 13] Permission denied: '<path>'`` and the path is
|
||||
# already in the SKIP prefix). Falls back to the default repr
|
||||
# when strerror is unset.
|
||||
print(
|
||||
f" SKIP: {filepath.name} (stat error: {exc.strerror or exc})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
files.append(filepath)
|
||||
return files
|
||||
@@ -1616,13 +1777,20 @@ def _mine_impl(
|
||||
|
||||
wing = wing_override or config["wing"]
|
||||
rooms = config.get("rooms", [{"name": "general", "description": "All project files"}])
|
||||
exclude_patterns = config.get("exclude_patterns", [])
|
||||
|
||||
if files is None:
|
||||
files = scan_project(
|
||||
project_dir,
|
||||
respect_gitignore=respect_gitignore,
|
||||
include_ignored=include_ignored,
|
||||
exclude_patterns=exclude_patterns,
|
||||
)
|
||||
elif exclude_patterns:
|
||||
files = _apply_exclude_patterns_to_prescanned_files(
|
||||
files, project_path, exclude_patterns, include_ignored
|
||||
)
|
||||
|
||||
from .embedding import describe_device
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
@@ -1705,12 +1873,15 @@ def _mine_impl(
|
||||
break
|
||||
|
||||
if not dry_run:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
graph_config = MempalaceConfig(palace_path=palace_path)
|
||||
# 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)
|
||||
tunnels_added = _compute_topic_tunnels_for_wing(wing, config=graph_config)
|
||||
if tunnels_added:
|
||||
print(f"\n Topic tunnels: +{tunnels_added} cross-wing link(s)")
|
||||
except Exception as e:
|
||||
@@ -1726,7 +1897,9 @@ def _mine_impl(
|
||||
# must never fail a mine; it's a derived analytic, not load-bearing
|
||||
# for the drawer write that already committed above.
|
||||
try:
|
||||
hallways_created = compute_hallways_for_wing(wing, col=collection)
|
||||
hallways_created = compute_hallways_for_wing(
|
||||
wing, col=collection, config=graph_config
|
||||
)
|
||||
if hallways_created:
|
||||
print(f"\n Hallways: +{len(hallways_created)} within-wing entity link(s)")
|
||||
except Exception as e:
|
||||
@@ -1742,7 +1915,7 @@ def _mine_impl(
|
||||
# ``kind="entity"`` / ``kind="topic"``. Same fault-tolerance
|
||||
# pattern: never fail a mine over a derived analytic.
|
||||
try:
|
||||
entity_tunnels_added = _compute_entity_tunnels_for_wing(wing)
|
||||
entity_tunnels_added = _compute_entity_tunnels_for_wing(wing, config=graph_config)
|
||||
if entity_tunnels_added:
|
||||
print(f"\n Entity tunnels: +{entity_tunnels_added} cross-wing entity link(s)")
|
||||
except Exception as e:
|
||||
@@ -1866,7 +2039,7 @@ def _cleanup_mine_pid_file() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _compute_topic_tunnels_for_wing(wing: str) -> int:
|
||||
def _compute_topic_tunnels_for_wing(wing: str, config=None) -> int:
|
||||
"""Drop tunnels between ``wing`` and every other wing that shares
|
||||
confirmed topics, honoring the ``topic_tunnel_min_count`` config knob.
|
||||
|
||||
@@ -1879,13 +2052,13 @@ def _compute_topic_tunnels_for_wing(wing: str) -> int:
|
||||
topics_map = get_topics_by_wing()
|
||||
if not topics_map or wing not in topics_map:
|
||||
return 0
|
||||
cfg = MempalaceConfig()
|
||||
cfg = config or MempalaceConfig()
|
||||
min_count = cfg.topic_tunnel_min_count
|
||||
created = topic_tunnels_for_wing(wing, topics_map, min_count=min_count)
|
||||
created = topic_tunnels_for_wing(wing, topics_map, min_count=min_count, config=cfg)
|
||||
return len(created)
|
||||
|
||||
|
||||
def _compute_entity_tunnels_for_wing(wing: str) -> int:
|
||||
def _compute_entity_tunnels_for_wing(wing: str, config=None) -> int:
|
||||
"""Drop tunnels between ``wing`` and every other wing that shares an
|
||||
entity via the within-wing hallway primitive.
|
||||
|
||||
@@ -1903,10 +2076,10 @@ def _compute_entity_tunnels_for_wing(wing: str) -> int:
|
||||
from .hallways import list_hallways
|
||||
from .palace_graph import entity_tunnels_for_wing
|
||||
|
||||
hallways = list_hallways()
|
||||
hallways = list_hallways(config=config)
|
||||
if not hallways:
|
||||
return 0
|
||||
created = entity_tunnels_for_wing(wing, hallways)
|
||||
created = entity_tunnels_for_wing(wing, hallways, config=config)
|
||||
return len(created)
|
||||
|
||||
|
||||
|
||||
+217
-8
@@ -9,6 +9,9 @@ Supported:
|
||||
- Claude Code JSONL (with tool_use/tool_result block capture)
|
||||
- OpenAI Codex CLI JSONL
|
||||
- Gemini CLI JSONL (~/.gemini/tmp/<project_hash>/chats/session-*.jsonl)
|
||||
- Pi agent JSONL
|
||||
- Gemini CLI / Google AI Studio JSON sessions (contents / messages / flat list)
|
||||
- Continue.dev session JSON (~/.continue/sessions/*.json)
|
||||
- Slack JSON export
|
||||
- Plain text (pass through for paragraph chunking)
|
||||
|
||||
@@ -18,6 +21,7 @@ No API key. No internet. Everything local.
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -115,17 +119,28 @@ def normalize(filepath: str) -> str:
|
||||
Load a file and normalize to transcript format if it's a chat export.
|
||||
Plain text files pass through unchanged.
|
||||
"""
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
if os.path.islink(filepath):
|
||||
raise IOError(f"Could not read {filepath}: symlinked files are skipped")
|
||||
fd = -1
|
||||
try:
|
||||
file_size = os.path.getsize(filepath)
|
||||
except OSError as e:
|
||||
raise IOError(f"Could not read {filepath}: {e}") from e
|
||||
if file_size > 500 * 1024 * 1024: # 500 MB safety limit
|
||||
raise IOError(f"File too large ({file_size // (1024 * 1024)} MB): {filepath}")
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8-sig", errors="replace") as f:
|
||||
fd = os.open(filepath, flags)
|
||||
file_stat = os.fstat(fd)
|
||||
if not stat.S_ISREG(file_stat.st_mode):
|
||||
raise IOError(f"Could not read {filepath}: not a regular file")
|
||||
if file_stat.st_size > 500 * 1024 * 1024: # 500 MB safety limit
|
||||
raise IOError(f"File too large ({file_stat.st_size // (1024 * 1024)} MB): {filepath}")
|
||||
with os.fdopen(fd, "r", encoding="utf-8-sig", errors="replace") as f:
|
||||
fd = -1
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
raise IOError(f"Could not read {filepath}: {e}") from e
|
||||
finally:
|
||||
if fd != -1:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not content.strip():
|
||||
return content
|
||||
@@ -162,12 +177,22 @@ def _try_normalize_json(content: str) -> Optional[str]:
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
normalized = _try_pi_jsonl(content)
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
for parser in (_try_claude_ai_json, _try_chatgpt_json, _try_slack_json):
|
||||
for parser in (
|
||||
_try_gemini_json,
|
||||
_try_claude_ai_json,
|
||||
_try_chatgpt_json,
|
||||
_try_continue_json,
|
||||
_try_slack_json,
|
||||
):
|
||||
normalized = parser(data)
|
||||
if normalized:
|
||||
return normalized
|
||||
@@ -353,6 +378,135 @@ def _try_gemini_jsonl(content: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _try_pi_jsonl(content: str) -> Optional[str]:
|
||||
"""Pi agent sessions (~/.config/pi/agent/sessions/{cwd}/{timestamp}_{uuid}.jsonl).
|
||||
|
||||
Pi stores sessions as JSONL with a tree-structured message history.
|
||||
User messages have role "user" with content as string or [{type, text}] blocks.
|
||||
Assistant messages have role "assistant" with content as [{type, text}] blocks
|
||||
(may also include "thinking" blocks which are skipped by _extract_content).
|
||||
Tool results (role "toolResult") are skipped — operational, not conversation.
|
||||
|
||||
Format documented at github.com/badlogic/pi-mono session.md.
|
||||
"""
|
||||
lines = [line.strip() for line in content.strip().split("\n") if line.strip()]
|
||||
messages = []
|
||||
has_session_header = 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" and "version" in entry:
|
||||
has_session_header = True
|
||||
continue
|
||||
|
||||
if entry_type != "message":
|
||||
continue
|
||||
|
||||
message = entry.get("message", {})
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
role = message.get("role", "")
|
||||
text = _extract_content(message.get("content", ""))
|
||||
|
||||
if role == "user" and text:
|
||||
messages.append(("user", text))
|
||||
elif role == "assistant" and text:
|
||||
messages.append(("assistant", text))
|
||||
|
||||
if len(messages) >= 2 and has_session_header:
|
||||
return _messages_to_transcript(messages)
|
||||
return None
|
||||
|
||||
|
||||
def _try_gemini_json(data) -> Optional[str]:
|
||||
"""Gemini CLI / Google AI Studio JSON sessions.
|
||||
|
||||
Handles three layouts:
|
||||
|
||||
1. **Gemini API contents format** — used by Gemini CLI session files
|
||||
(``~/.gemini/sessions/*.json``):
|
||||
``{"contents": [{"role": "user", "parts": [{"text": "..."}]}, ...]}``
|
||||
|
||||
2. **Messages wrapper** — exports that wrap the conversation under a
|
||||
``messages`` key:
|
||||
``{"messages": [{"role": "user", "content": "..."}, {"role": "model", "content": "..."}]}``
|
||||
|
||||
3. **Flat messages list** — top-level array form:
|
||||
``[{"role": "user", "content": "..."}, {"role": "model", "content": "..."}]``
|
||||
|
||||
Gemini uses ``"model"`` as the assistant role (not ``"assistant"``).
|
||||
Detection requires at least one ``role="model"`` entry to disambiguate
|
||||
from Claude/ChatGPT exports that use ``"assistant"``. This parser is
|
||||
placed *before* ``_try_claude_ai_json`` in the dispatch chain so that
|
||||
the layout-2 ``{"messages": [...]}`` wrapper does not get silently
|
||||
claimed by the Claude parser, which would drop the model turns.
|
||||
"""
|
||||
contents = None
|
||||
|
||||
# Layout 1: {"contents": [...]}
|
||||
if isinstance(data, dict) and "contents" in data:
|
||||
contents = data["contents"]
|
||||
# Layout 2a: {"messages": [...]}
|
||||
elif isinstance(data, dict) and "messages" in data:
|
||||
contents = data["messages"]
|
||||
# Layout 2b: top-level list
|
||||
elif isinstance(data, list):
|
||||
contents = data
|
||||
|
||||
if not isinstance(contents, list) or len(contents) < 2:
|
||||
return None
|
||||
|
||||
messages = []
|
||||
has_model_role = False
|
||||
for item in contents:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
role = item.get("role", "")
|
||||
|
||||
# Extract text — try "parts" first (Gemini API), then "content" (flat).
|
||||
text = ""
|
||||
parts = item.get("parts")
|
||||
if isinstance(parts, list):
|
||||
text_parts = []
|
||||
for p in parts:
|
||||
if isinstance(p, str):
|
||||
text_parts.append(p)
|
||||
elif isinstance(p, dict) and "text" in p:
|
||||
text_parts.append(p["text"])
|
||||
text = " ".join(text_parts).strip()
|
||||
else:
|
||||
text = _extract_content(item.get("content", ""))
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
messages.append(("user", text))
|
||||
elif role == "model":
|
||||
messages.append(("assistant", text))
|
||||
has_model_role = True
|
||||
elif role == "assistant":
|
||||
# Defensive: some hand-crafted exports use "assistant" even
|
||||
# for Gemini sessions. Accept but don't flip has_model_role.
|
||||
messages.append(("assistant", text))
|
||||
|
||||
# Disambiguator: must have seen at least one role="model" entry.
|
||||
# This prevents the Gemini parser from claiming Claude/ChatGPT data.
|
||||
if not has_model_role:
|
||||
return None
|
||||
|
||||
if len(messages) >= 2:
|
||||
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):
|
||||
@@ -485,6 +639,61 @@ def _try_slack_json(data) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _try_continue_json(data) -> Optional[str]:
|
||||
"""Continue.dev session JSON (~/.continue/sessions/*.json).
|
||||
|
||||
Sessions contain a ``history`` array of ``{role, content}`` message objects,
|
||||
plus optional metadata (``title``, ``sessionId``, ``dateCreated``).
|
||||
System messages are skipped. Tool-call messages (role ``tool``) are
|
||||
formatted inline when they contain text content.
|
||||
"""
|
||||
if not isinstance(data, dict) or "history" not in data:
|
||||
return None
|
||||
history = data["history"]
|
||||
if not isinstance(history, list):
|
||||
return None
|
||||
|
||||
messages = []
|
||||
for item in history:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
role = item.get("role", "")
|
||||
content = item.get("content", "")
|
||||
|
||||
# Extract text from string or list-of-blocks content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
elif isinstance(block, str):
|
||||
parts.append(block)
|
||||
text = "\n".join(p for p in parts if p).strip()
|
||||
elif isinstance(content, str):
|
||||
text = content.strip()
|
||||
else:
|
||||
continue
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
messages.append(("user", text))
|
||||
elif role == "assistant":
|
||||
messages.append(("assistant", text))
|
||||
elif role == "tool":
|
||||
# Append tool output to the previous assistant turn if possible
|
||||
if messages and messages[-1][0] == "assistant":
|
||||
prev_role, prev_text = messages[-1]
|
||||
messages[-1] = (prev_role, prev_text + "\n" + f"[tool] {text}")
|
||||
# Skip system and other roles
|
||||
|
||||
if len(messages) >= 2:
|
||||
return _messages_to_transcript(messages)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_content(content, tool_use_map: dict = None) -> str:
|
||||
"""Pull text from content — handles str, list of blocks, or dict.
|
||||
|
||||
|
||||
+116
-71
@@ -928,12 +928,19 @@ def _validate_palace_fts5_after_mine(palace_path: str) -> None:
|
||||
Reuses the same primitive that `cmd_repair` already runs as preflight so the
|
||||
operator sees the same recovery banner regardless of which command surfaces
|
||||
the bug.
|
||||
|
||||
An isolated FTS5 inverted-index corruption (the common case after a
|
||||
killed-mid-write mine, #1596) is auto-healed in place first via the same
|
||||
`maybe_autoheal_fts5_index` rebuild `cmd_repair` already runs as its own
|
||||
preflight step — so a normal `mine` self-heals the recoverable case
|
||||
instead of forcing the operator to run `mempalace repair` by hand for a
|
||||
derived index that regenerates from intact content.
|
||||
"""
|
||||
if resolve_backend_name(palace_path) != "chroma":
|
||||
return
|
||||
|
||||
# Defer-import: keeps the repair module graph out of mine's hot import path.
|
||||
from .repair import _close_chroma_handles, sqlite_integrity_errors
|
||||
from .repair import _close_chroma_handles, maybe_autoheal_fts5_index, sqlite_integrity_errors
|
||||
|
||||
# Pass the live singleton so the writer's cached PersistentClient actually
|
||||
# gets closed and WAL flushes before the read-only sqlite3 re-open.
|
||||
@@ -943,49 +950,89 @@ def _validate_palace_fts5_after_mine(palace_path: str) -> None:
|
||||
_close_chroma_handles(palace_path, backend=_DEFAULT_BACKEND)
|
||||
|
||||
errors = sqlite_integrity_errors(palace_path)
|
||||
if errors:
|
||||
# progress=logger.info, not the default print: this runs inside the
|
||||
# MCP server process too (mcp_server.tool_mine -> miner.mine), where
|
||||
# stdout is the JSON-RPC transport -- a stray print() here would
|
||||
# corrupt the protocol stream and crash the connection.
|
||||
errors = maybe_autoheal_fts5_index(palace_path, errors, progress=logger.info)
|
||||
if errors:
|
||||
raise MineValidationError(palace_path, errors)
|
||||
|
||||
|
||||
# Per-thread record of palaces this thread already holds the lock for. Used by
|
||||
# `mine_palace_lock` to short-circuit re-entrant acquisition from the same
|
||||
# thread (e.g. miner.mine() acquires the outer lock then calls
|
||||
# Process-wide record of palaces this PROCESS already holds the lock for. Used
|
||||
# by `mine_palace_lock` to short-circuit re-entrant acquisition from the same
|
||||
# process (e.g. miner.mine() acquires the outer lock then calls
|
||||
# ChromaCollection.upsert which now also tries to acquire). Without this guard
|
||||
# the inner call would block on its own outer flock (Linux fcntl locks are per
|
||||
# open file description, so a same-thread second open of the lock file is a
|
||||
# distinct lock and self-deadlocks).
|
||||
# open file description, so a second open of the lock file from the same process
|
||||
# is a distinct lock and self-conflicts / EWOULDBLOCKs).
|
||||
#
|
||||
# The holder set is tagged with ``pid`` so that a forked child does NOT
|
||||
# inherit re-entrant credit from its parent: the OS-level flock IS NOT
|
||||
# inherited as a "we hold it" semantically — the child must reacquire — but
|
||||
# Python's ``threading.local`` IS inherited across fork. The pid check
|
||||
# clears stale state so a forked child correctly hits the fcntl path.
|
||||
_palace_lock_holders = threading.local()
|
||||
# This MUST be process-wide, not thread-local: the MCP HTTP transport
|
||||
# (ThreadingHTTPServer) acquires the long-lived writer-lease on one thread
|
||||
# (`mcp_server._acquire_mcp_writer_lock`) but dispatches each write request on a
|
||||
# different worker thread. A thread-local guard makes those handlers fail to see
|
||||
# the process-held lease, re-acquire the flock, and self-conflict
|
||||
# ("palace ... is held by PID <self>"). flock is per-process and HTTP writes are
|
||||
# serialized by `_HTTP_REQUEST_LOCK`, so the process is the correct re-entrancy
|
||||
# boundary.
|
||||
#
|
||||
# The holder set is tagged with ``pid`` so that a forked child does NOT inherit
|
||||
# re-entrant credit from its parent: the OS-level flock IS NOT inherited as a
|
||||
# "we hold it" semantically — the child must reacquire. The pid check clears
|
||||
# stale state so a forked child correctly hits the fcntl path. Access is guarded
|
||||
# by ``_palace_lock_guard`` because the set is now shared across threads.
|
||||
#
|
||||
# Fork safety: ``_palace_lock_guard`` is a real ``threading.Lock``, so a child
|
||||
# forked while another thread held it would inherit it locked (the holder thread
|
||||
# does not exist in the child) and deadlock on the next acquire. An at-fork
|
||||
# handler (registered below) replaces the guard with a fresh unlocked lock and
|
||||
# clears state in the child, which must reacquire the flock anyway.
|
||||
_palace_lock_guard = threading.Lock()
|
||||
_palace_lock_pid = None
|
||||
_palace_lock_keys = set()
|
||||
|
||||
|
||||
def _holder_state():
|
||||
"""Return the per-thread (pid, keys) record, refreshing after fork."""
|
||||
keys = getattr(_palace_lock_holders, "keys", None)
|
||||
pid = getattr(_palace_lock_holders, "pid", None)
|
||||
def _reset_palace_lock_state_after_fork() -> None:
|
||||
"""Reset lock state in a forked child to avoid an inherited-locked deadlock."""
|
||||
global _palace_lock_guard, _palace_lock_pid, _palace_lock_keys
|
||||
_palace_lock_guard = threading.Lock()
|
||||
_palace_lock_keys = set()
|
||||
_palace_lock_pid = os.getpid()
|
||||
|
||||
|
||||
# Availability: Unix (no-op elsewhere — Windows has no fork()).
|
||||
if hasattr(os, "register_at_fork"):
|
||||
os.register_at_fork(after_in_child=_reset_palace_lock_state_after_fork)
|
||||
|
||||
|
||||
def _holder_keys_locked():
|
||||
"""Return the process-wide held-key set, refreshing after fork.
|
||||
|
||||
Caller MUST hold ``_palace_lock_guard``.
|
||||
"""
|
||||
global _palace_lock_pid, _palace_lock_keys
|
||||
current_pid = os.getpid()
|
||||
if keys is None or pid != current_pid:
|
||||
keys = set()
|
||||
_palace_lock_holders.keys = keys
|
||||
_palace_lock_holders.pid = current_pid
|
||||
return keys
|
||||
if _palace_lock_pid != current_pid:
|
||||
_palace_lock_keys = set()
|
||||
_palace_lock_pid = current_pid
|
||||
return _palace_lock_keys
|
||||
|
||||
|
||||
def _held_by_this_thread(lock_key: str) -> bool:
|
||||
"""Return True if this thread already holds ``mine_palace_lock`` for ``lock_key``."""
|
||||
return lock_key in _holder_state()
|
||||
def _held_by_this_process(lock_key: str) -> bool:
|
||||
"""Return True if this process already holds ``mine_palace_lock`` for ``lock_key``."""
|
||||
with _palace_lock_guard:
|
||||
return lock_key in _holder_keys_locked()
|
||||
|
||||
|
||||
def _mark_held(lock_key: str) -> None:
|
||||
_holder_state().add(lock_key)
|
||||
with _palace_lock_guard:
|
||||
_holder_keys_locked().add(lock_key)
|
||||
|
||||
|
||||
def _mark_released(lock_key: str) -> None:
|
||||
_holder_state().discard(lock_key)
|
||||
with _palace_lock_guard:
|
||||
_holder_keys_locked().discard(lock_key)
|
||||
|
||||
|
||||
def _format_lock_holder(content: str) -> str:
|
||||
@@ -1064,11 +1111,13 @@ def mine_palace_lock(palace_path: str):
|
||||
raise MineAlreadyRunning so the caller can exit cleanly instead of
|
||||
piling up as a waiting worker.
|
||||
|
||||
Re-entrant: if the current thread already holds the lock for the same
|
||||
Re-entrant: if the current process already holds the lock for the same
|
||||
palace, the context manager passes through without re-acquiring. This
|
||||
lets ChromaCollection write methods (which acquire the lock themselves
|
||||
to protect MCP/direct callers) compose with miner.mine() (which holds
|
||||
the outer lock for the entire mine pipeline) without self-deadlock.
|
||||
the outer lock for the entire mine pipeline) without self-deadlock, and
|
||||
lets the threaded MCP HTTP transport write from a worker thread while the
|
||||
long-lived writer-lease is held on another thread of the same process.
|
||||
"""
|
||||
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
|
||||
os.makedirs(lock_dir, exist_ok=True)
|
||||
@@ -1077,8 +1126,8 @@ def mine_palace_lock(palace_path: str):
|
||||
palace_key = hashlib.sha256(lock_key_source.encode()).hexdigest()[:16]
|
||||
lock_path = os.path.join(lock_dir, f"mine_palace_{palace_key}.lock")
|
||||
|
||||
if _held_by_this_thread(palace_key):
|
||||
# Same thread already holds the lock for this palace — pass through.
|
||||
if _held_by_this_process(palace_key):
|
||||
# This process already holds the lock for this palace — pass through.
|
||||
yield
|
||||
return
|
||||
|
||||
@@ -1129,8 +1178,14 @@ def mine_palace_lock(palace_path: str):
|
||||
) from exc
|
||||
# Record our own identity for any later contender's diagnostic message.
|
||||
_write_lock_holder(lf)
|
||||
_mark_held(palace_key)
|
||||
# Mark the hold from inside the try so it always pairs with
|
||||
# _mark_released. If it sat before the try, an async exception
|
||||
# (a SIGINT/KeyboardInterrupt) landing in the gap would orphan
|
||||
# palace_key in the holder set while the outer finally frees the
|
||||
# flock, so the in-memory hold would outlive the OS lock and a later
|
||||
# re-entrant acquire would pass through and write without the flock.
|
||||
try:
|
||||
_mark_held(palace_key)
|
||||
yield
|
||||
finally:
|
||||
_mark_released(palace_key)
|
||||
@@ -1179,9 +1234,15 @@ def file_already_mined(
|
||||
schema (triggers silent rebuild after a normalization upgrade)
|
||||
- `check_mtime=True` and the file's mtime differs from the stored one
|
||||
|
||||
When check_mtime=True (used by project miner), also re-mines on content
|
||||
change. When check_mtime=False (used by convo miner), transcripts are
|
||||
assumed immutable, so only the version gate triggers a rebuild.
|
||||
When check_mtime=True (used by the project miner, and by the convo
|
||||
miner's in-lock recheck), also re-mines on content change. Conversation
|
||||
transcripts are NOT assumed immutable: a Claude Code session keeps
|
||||
appending to its own file while active, and /compact or /clear can
|
||||
rewrite one in place. The convo miner's bulk skip-check uses
|
||||
prefetch_mined_set()'s stored mtimes instead of calling this function
|
||||
per file (same mtime-aware decision, without the O(n) per-file query
|
||||
cost); this function's check_mtime=True path remains its per-file,
|
||||
lock-held race-condition recheck.
|
||||
|
||||
When extract_mode is set (used by convo miner), idempotency is scoped to
|
||||
that extraction mode so exchange-mode and general-mode drawers can coexist
|
||||
@@ -1238,42 +1299,25 @@ def file_already_mined(
|
||||
return False
|
||||
|
||||
|
||||
def bulk_check_mined(collection) -> dict[str, float]:
|
||||
"""Pre-fetch source_file/source_mtime pairs for all documents in the collection.
|
||||
def prefetch_mined_set(
|
||||
collection, extract_mode: Optional[str] = None
|
||||
) -> dict[str, Optional[float]]:
|
||||
"""Pre-fetch source_file -> stored source_mtime for files already mined
|
||||
at the current NORMALIZE_VERSION, in one bulk pass instead of one
|
||||
ChromaDB query per file.
|
||||
|
||||
Returns a dict mapping source_file -> source_mtime (as float) for every
|
||||
document that has both fields. Callers can check membership and compare
|
||||
mtimes locally instead of issuing one ChromaDB query per file.
|
||||
|
||||
Fetches the full collection in paginated batches (like palace_graph.py)
|
||||
since a WHERE-IN filter on thousands of paths is not supported by ChromaDB.
|
||||
"""
|
||||
mined: dict[str, float] = {}
|
||||
try:
|
||||
total = collection.count()
|
||||
offset = 0
|
||||
while offset < total:
|
||||
batch = collection.get(limit=1000, offset=offset, include=["metadatas"])
|
||||
for meta in batch["metadatas"]:
|
||||
src = meta.get("source_file")
|
||||
mtime = meta.get("source_mtime")
|
||||
if src and mtime is not None:
|
||||
mined[src] = float(mtime)
|
||||
if not batch["ids"]:
|
||||
break
|
||||
offset += len(batch["ids"])
|
||||
except Exception:
|
||||
logger.warning("bulk_check_mined: partial fetch, %d files loaded", len(mined))
|
||||
return mined
|
||||
|
||||
|
||||
def prefetch_mined_set(collection, extract_mode: Optional[str] = None) -> set[str]:
|
||||
"""Pre-fetch the set of source_files already mined at the current NORMALIZE_VERSION.
|
||||
|
||||
Mirrors file_already_mined()'s version-gate semantics (check_mtime=False
|
||||
branch) but in one bulk pass instead of one ChromaDB query per file.
|
||||
Returns a set of source_file paths whose stored drawers are at or above
|
||||
NORMALIZE_VERSION; callers do `if path in result_set: skip`.
|
||||
Return type is a dict rather than a bare set so callers get mtime
|
||||
awareness "for free": conversation transcripts are not immutable once
|
||||
mined (a Claude Code session keeps appending to the same file while
|
||||
active, and /compact or /clear can rewrite one in place), so "we've
|
||||
seen this source_file before" is not suffient to skip it -- the caller
|
||||
must also confirm its current on-disk mtime still matches what was
|
||||
stored. `if src in mined_set` still means the same thing as the old
|
||||
set-based return (dict `in` checks keys); a caller that wants staleness
|
||||
detection reads `mined_set[src]` and compares against
|
||||
os.path.getmtime(src) itself. `None` means either no mtime was ever
|
||||
stored (drawers written before this field existed) or getmtime failed
|
||||
when the drawer was written -- both should be treated as stale.
|
||||
|
||||
When extract_mode is set, mirrors file_already_mined(..., extract_mode=...)
|
||||
so conversation mines skip per extraction mode rather than per source file.
|
||||
@@ -1283,7 +1327,7 @@ def prefetch_mined_set(collection, extract_mode: Optional[str] = None) -> set[st
|
||||
palace, making a 2000-file sweep take >1h of pure skip-checking. This
|
||||
helper drops that to a single paginated scan plus O(1) lookups.
|
||||
"""
|
||||
mined: set[str] = set()
|
||||
mined: dict[str, Optional[float]] = {}
|
||||
try:
|
||||
total = collection.count()
|
||||
offset = 0
|
||||
@@ -1299,7 +1343,8 @@ def prefetch_mined_set(collection, extract_mode: Optional[str] = None) -> set[st
|
||||
# Same default as file_already_mined: missing version == 1
|
||||
version = meta.get("normalize_version", 1)
|
||||
if version >= NORMALIZE_VERSION:
|
||||
mined.add(src)
|
||||
stored_mtime = meta.get("source_mtime")
|
||||
mined[src] = float(stored_mtime) if stored_mtime is not None else None
|
||||
if not batch["ids"]:
|
||||
break
|
||||
offset += len(batch["ids"])
|
||||
|
||||
@@ -494,6 +494,7 @@ def create_tunnel(
|
||||
source_drawer_id: str = None,
|
||||
target_drawer_id: str = None,
|
||||
kind: str = "explicit",
|
||||
config=None,
|
||||
):
|
||||
"""Create an explicit (symmetric) tunnel between two locations in the palace.
|
||||
|
||||
@@ -520,6 +521,9 @@ def create_tunnel(
|
||||
topical link where rooms are synthetic ``topic:<name>``
|
||||
identifiers). Preserved on the stored dict so readers can
|
||||
distinguish real-room traversals from topic connections.
|
||||
config: Optional ``MempalaceConfig`` selecting the palace and its
|
||||
tunnel sidecar. Explicit-path callers must pass the matching
|
||||
config instead of falling back to the ambient default palace.
|
||||
|
||||
Returns:
|
||||
The stored tunnel dict.
|
||||
@@ -545,7 +549,7 @@ def create_tunnel(
|
||||
# mempalace.yaml from disk; before this change the helpers each
|
||||
# instantiated their own, triggering several redundant disk reads per
|
||||
# create_tunnel call (flagged by gemini-code-assist on #1469).
|
||||
config = MempalaceConfig()
|
||||
config = config or MempalaceConfig()
|
||||
|
||||
# Validate room existence for explicit tunnels only. Use the verbatim wing
|
||||
# slugs here so #1504's hyphen-preserving write path remains intact.
|
||||
@@ -742,6 +746,7 @@ def compute_topic_tunnels(
|
||||
topics_by_wing: dict,
|
||||
min_count: int = 1,
|
||||
label_prefix: str = "shared topic",
|
||||
config=None,
|
||||
) -> list[dict]:
|
||||
"""Create tunnels for every pair of wings that share >= ``min_count`` topics.
|
||||
|
||||
@@ -817,6 +822,7 @@ def compute_topic_tunnels(
|
||||
target_room=room,
|
||||
label=f"{label_prefix}: {topic_name}",
|
||||
kind="topic",
|
||||
config=config,
|
||||
)
|
||||
created.append(tunnel)
|
||||
return created
|
||||
@@ -827,6 +833,7 @@ def topic_tunnels_for_wing(
|
||||
topics_by_wing: dict,
|
||||
min_count: int = 1,
|
||||
label_prefix: str = "shared topic",
|
||||
config=None,
|
||||
) -> list[dict]:
|
||||
"""Compute topic tunnels involving a single wing.
|
||||
|
||||
@@ -871,6 +878,7 @@ def topic_tunnels_for_wing(
|
||||
slice_map,
|
||||
min_count=min_count,
|
||||
label_prefix=label_prefix,
|
||||
config=config,
|
||||
)
|
||||
)
|
||||
return created
|
||||
@@ -880,6 +888,7 @@ def entity_tunnels_for_wing(
|
||||
wing: str,
|
||||
hallways: list,
|
||||
label_prefix: str = "shared entity",
|
||||
config=None,
|
||||
) -> list:
|
||||
"""Compute entity tunnels involving a single wing.
|
||||
|
||||
@@ -946,6 +955,7 @@ def entity_tunnels_for_wing(
|
||||
target_room=room,
|
||||
label=f"{label_prefix}: {entity}",
|
||||
kind="entity",
|
||||
config=config,
|
||||
)
|
||||
created.append(tunnel)
|
||||
return created
|
||||
|
||||
@@ -3,8 +3,8 @@ 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`.
|
||||
down in package.json / pyproject.toml / Cargo.toml / go.mod / pom.xml /
|
||||
Gradle manifests, 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
|
||||
@@ -21,6 +21,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -164,11 +165,63 @@ def _parse_gomod(path: Path) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _xml_local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
||||
|
||||
|
||||
def _parse_pom(path: Path) -> Optional[str]:
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except (ET.ParseError, OSError):
|
||||
return None
|
||||
for child in root:
|
||||
if isinstance(child.tag, str) and _xml_local_name(child.tag) == "artifactId":
|
||||
name = (child.text or "").strip()
|
||||
return name or None
|
||||
return None
|
||||
|
||||
|
||||
_GRADLE_ROOT_PROJECT_NAME_PATTERNS = [
|
||||
re.compile(r"""(?m)^\s*rootProject\.name\s*=\s*(["'])(?P<name>[^"']+)\1"""),
|
||||
re.compile(r"""(?m)^\s*rootProject\.name\.set\(\s*(["'])(?P<name>[^"']+)\1\s*\)"""),
|
||||
]
|
||||
|
||||
|
||||
def _parse_gradle_root_project_name(path: Path) -> Optional[str]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
for pattern in _GRADLE_ROOT_PROJECT_NAME_PATTERNS:
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
name = match.group("name").strip()
|
||||
return name or None
|
||||
return None
|
||||
|
||||
|
||||
def _parse_gradle(path: Path) -> Optional[str]:
|
||||
if path.name.startswith("build.gradle"):
|
||||
for settings_name in ("settings.gradle.kts", "settings.gradle"):
|
||||
name = _parse_gradle_root_project_name(path.with_name(settings_name))
|
||||
if name:
|
||||
return name
|
||||
name = _parse_gradle_root_project_name(path)
|
||||
if name:
|
||||
return name
|
||||
return path.parent.name or None
|
||||
|
||||
|
||||
MANIFEST_PRIORITY = {
|
||||
"pyproject.toml": 0,
|
||||
"package.json": 1,
|
||||
"Cargo.toml": 2,
|
||||
"go.mod": 3,
|
||||
"pom.xml": 4,
|
||||
"settings.gradle": 5,
|
||||
"settings.gradle.kts": 6,
|
||||
"build.gradle": 7,
|
||||
"build.gradle.kts": 8,
|
||||
}
|
||||
# Sentinel so unknown manifests always sort after the known manifest types above.
|
||||
UNKNOWN_MANIFEST_PRIORITY = max(MANIFEST_PRIORITY.values()) + 1
|
||||
@@ -177,6 +230,18 @@ MANIFEST_PARSERS = {
|
||||
"pyproject.toml": _parse_pyproject,
|
||||
"Cargo.toml": _parse_cargo,
|
||||
"go.mod": _parse_gomod,
|
||||
"pom.xml": _parse_pom,
|
||||
"settings.gradle": _parse_gradle,
|
||||
"settings.gradle.kts": _parse_gradle,
|
||||
"build.gradle": _parse_gradle,
|
||||
"build.gradle.kts": _parse_gradle,
|
||||
}
|
||||
JAVA_MANIFESTS = {
|
||||
"pom.xml",
|
||||
"settings.gradle",
|
||||
"settings.gradle.kts",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
}
|
||||
|
||||
|
||||
@@ -461,10 +526,12 @@ def scan(root: str | os.PathLike) -> tuple[list[ProjectInfo], list[PersonInfo]]:
|
||||
|
||||
for repo in repos:
|
||||
manifests = _collect_manifest_names(repo)
|
||||
if manifests:
|
||||
manifest_file, proj_name, _ = manifests[0]
|
||||
root_manifest = next((entry for entry in manifests if entry[2] == repo), None)
|
||||
if root_manifest:
|
||||
manifest_file, proj_name, _ = root_manifest
|
||||
else:
|
||||
manifest_file, proj_name = None, repo.name
|
||||
extra_manifests = [entry for entry in manifests if entry != root_manifest]
|
||||
|
||||
authors = _git_authors(repo)
|
||||
non_bot_authors = [(name, email) for name, email in authors if not _is_bot(name, email)]
|
||||
@@ -501,17 +568,35 @@ def scan(root: str | os.PathLike) -> tuple[list[ProjectInfo], list[PersonInfo]]:
|
||||
if existing is None or proj.user_commits > existing.user_commits:
|
||||
projects[proj_name] = proj
|
||||
|
||||
for extra_manifest, extra_name, extra_dir in extra_manifests:
|
||||
if extra_manifest not in JAVA_MANIFESTS:
|
||||
continue
|
||||
existing = projects.get(extra_name)
|
||||
if existing is not None and (
|
||||
existing.manifest is not None or existing.repo_root != repo
|
||||
):
|
||||
continue
|
||||
projects[extra_name] = ProjectInfo(
|
||||
name=extra_name,
|
||||
repo_root=extra_dir,
|
||||
manifest=extra_manifest,
|
||||
has_git=True,
|
||||
total_commits=total_commits,
|
||||
user_commits=user_commits,
|
||||
is_mine=is_mine,
|
||||
)
|
||||
|
||||
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:
|
||||
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,
|
||||
repo_root=dirpath,
|
||||
manifest=manifest_file,
|
||||
has_git=False,
|
||||
)
|
||||
@@ -605,8 +690,8 @@ def discover_entities(
|
||||
plugs into ``confirm_entities`` unchanged.
|
||||
|
||||
Order of signal preference:
|
||||
1. Package manifests (package.json, pyproject.toml, Cargo.toml, go.mod)
|
||||
→ canonical project names
|
||||
1. Package manifests (package.json, pyproject.toml, Cargo.toml, go.mod,
|
||||
pom.xml, Gradle manifests) → 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)
|
||||
|
||||
+371
-22
@@ -33,6 +33,7 @@ import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import closing
|
||||
@@ -43,6 +44,7 @@ from typing import Callable, Iterator, Optional
|
||||
from chromadb.errors import NotFoundError as ChromaNotFoundError
|
||||
|
||||
from .backends.chroma import ChromaBackend, hnsw_capacity_status
|
||||
from .config import sqlite_read_uri
|
||||
|
||||
|
||||
COLLECTION_NAME = "mempalace_drawers"
|
||||
@@ -56,6 +58,78 @@ REPAIR_TEMP_COLLECTION = f"{COLLECTION_NAME}__repair_tmp"
|
||||
CLOSETS_COLLECTION_NAME = "mempalace_closets"
|
||||
|
||||
|
||||
def _no_follow_flag() -> int:
|
||||
return getattr(os, "O_NOFOLLOW", 0)
|
||||
|
||||
|
||||
def _open_regular_file_no_follow(path: str) -> int:
|
||||
if os.path.islink(path):
|
||||
raise RuntimeError(f"Refusing symlinked file: {path}")
|
||||
fd = os.open(path, os.O_RDONLY | _no_follow_flag())
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
raise RuntimeError(f"Refusing non-regular file: {path}")
|
||||
return fd
|
||||
except Exception:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def _write_text_replace_no_follow(path: str, text: str) -> None:
|
||||
directory = os.path.dirname(path) or "."
|
||||
basename = os.path.basename(path)
|
||||
tmp_path = os.path.join(
|
||||
directory,
|
||||
f".{basename}.{os.getpid()}.{int(time.time() * 1_000_000)}.tmp",
|
||||
)
|
||||
fd = os.open(
|
||||
tmp_path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | _no_follow_flag(),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _copy_file_no_follow(src: str, dst: str, *, replace: bool = False) -> None:
|
||||
src_fd = _open_regular_file_no_follow(src)
|
||||
try:
|
||||
src_f = os.fdopen(src_fd, "rb")
|
||||
except Exception:
|
||||
os.close(src_fd)
|
||||
raise
|
||||
# ``src_f`` now owns ``src_fd`` and closes it on exit.
|
||||
with src_f:
|
||||
flags = os.O_WRONLY | os.O_CREAT | _no_follow_flag()
|
||||
flags |= os.O_TRUNC if replace else os.O_EXCL
|
||||
dst_fd = os.open(dst, flags, 0o600)
|
||||
try:
|
||||
dst_f = os.fdopen(dst_fd, "wb")
|
||||
except Exception:
|
||||
os.close(dst_fd)
|
||||
raise
|
||||
with dst_f:
|
||||
shutil.copyfileobj(src_f, dst_f)
|
||||
try:
|
||||
shutil.copystat(src, dst, follow_symlinks=False)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _unique_backup_path(path: str, label: str) -> str:
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"{path}.{label}.{stamp}.{os.getpid()}"
|
||||
|
||||
|
||||
def _drawers_collection_name() -> str:
|
||||
"""Resolve the drawers collection name from user config, falling back
|
||||
to the module default ``COLLECTION_NAME`` if config is unreadable.
|
||||
@@ -315,9 +389,10 @@ def scan_palace(palace_path=None, only_wing=None, collection_name: Optional[str]
|
||||
print(f" BAD: {len(bad_set):,} ({len(bad_set) / max(len(all_ids), 1) * 100:.1f}%)")
|
||||
|
||||
bad_file = os.path.join(palace_path, "corrupt_ids.txt")
|
||||
with open(bad_file, "w") as f:
|
||||
for bid in sorted(bad_set):
|
||||
f.write(bid + "\n")
|
||||
_write_text_replace_no_follow(
|
||||
bad_file,
|
||||
"".join(f"{bid}\n" for bid in sorted(bad_set)),
|
||||
)
|
||||
print(f"\n Bad IDs written to: {bad_file}")
|
||||
return good_set, bad_set
|
||||
|
||||
@@ -476,7 +551,7 @@ def sqlite_drawer_count(palace_path: str, collection_name: Optional[str] = None)
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(sqlite_path), uri=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
@@ -498,6 +573,9 @@ def sqlite_drawer_count(palace_path: str, collection_name: Optional[str] = None)
|
||||
return None
|
||||
|
||||
|
||||
_SQLITE_INTEGRITY_BUSY_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
|
||||
def sqlite_integrity_errors(palace_path: str) -> list[str]:
|
||||
"""Return SQLite quick_check errors for chroma.sqlite3.
|
||||
|
||||
@@ -516,7 +594,16 @@ def sqlite_integrity_errors(palace_path: str) -> list[str]:
|
||||
return []
|
||||
|
||||
try:
|
||||
with sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True) as conn:
|
||||
# A writer holding SQLite's lock is contention, not corruption. The
|
||||
# sqlite3 module defaults to five seconds, which is shorter than
|
||||
# routine batch mines and curator writes on rollback-journal palaces.
|
||||
# Give those writers a bounded grace period before surfacing BUSY to
|
||||
# callers; genuine corruption still comes from PRAGMA quick_check.
|
||||
with sqlite3.connect(
|
||||
sqlite_read_uri(sqlite_path),
|
||||
uri=True,
|
||||
timeout=_SQLITE_INTEGRITY_BUSY_TIMEOUT_SECONDS,
|
||||
) as conn:
|
||||
rows = conn.execute("PRAGMA quick_check").fetchall()
|
||||
except sqlite3.Error as e:
|
||||
return [f"PRAGMA quick_check failed: {e}"]
|
||||
@@ -563,6 +650,127 @@ def print_sqlite_integrity_abort(palace_path: str, errors: list[str]) -> None:
|
||||
print(" 6. Re-run `mempalace repair --yes`.")
|
||||
|
||||
|
||||
# quick_check's wording for a corrupt FTS5 inverted index differs by SQLite
|
||||
# version — both forms describe the same recoverable condition:
|
||||
# "malformed inverted index for FTS5 table main.embedding_fulltext_search"
|
||||
# (older SQLite)
|
||||
# "fts5: corruption found reading blob N from table \"embedding_fulltext_search\""
|
||||
# (SQLite >= ~3.5x, confirmed on 3.53.2 / Python 3.13.7 — the exact
|
||||
# message this repo's own test fixture produces on that build)
|
||||
# Either failure is recoverable in place: the index is derived from the
|
||||
# intact ``embedding_fulltext_search_content`` shadow table, so rebuilding it
|
||||
# restores full-text search without touching any drawer rows. Concurrent
|
||||
# killed-mid-write mines are the usual cause (#1596). A regex matching only
|
||||
# the older phrasing would silently decline to auto-heal on newer SQLite —
|
||||
# the exact failure this repo's own test suite caught (test_repair.py's two
|
||||
# auto-heal tests failed on this machine until this pattern was widened).
|
||||
_FTS5_MALFORMED_RE = re.compile(
|
||||
r"malformed inverted index for fts5 table|fts5:\s*corruption found",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _errors_are_isolated_fts5(errors: list[str]) -> bool:
|
||||
"""True when every quick_check error is a malformed FTS5 inverted index.
|
||||
|
||||
Only an isolated FTS5 failure is safe to auto-heal: the inverted index is
|
||||
derived data that ``rebuild`` regenerates from the content shadow table. If
|
||||
quick_check also reports page/row corruption, the data itself may be damaged
|
||||
and rebuilding the index over it would mask real loss — that still aborts.
|
||||
"""
|
||||
return bool(errors) and all(_FTS5_MALFORMED_RE.search(e) for e in errors)
|
||||
|
||||
|
||||
def maybe_autoheal_fts5_index(palace_path: str, errors: list[str], *, progress=print) -> list[str]:
|
||||
"""Rebuild a malformed FTS5 inverted index in place; return remaining errors.
|
||||
|
||||
The repair preflight aborts when ``PRAGMA quick_check`` reports SQLite-layer
|
||||
corruption. After concurrent killed-mid-write mines (#1596) the common
|
||||
failure is an isolated ``malformed inverted index for FTS5 table``, which is
|
||||
fully recoverable: the index rebuilds from the intact
|
||||
``embedding_fulltext_search_content`` table without touching drawer rows.
|
||||
|
||||
When the errors are isolated to FTS5, rebuild the index under the palace
|
||||
write lock (so a live mine cannot race the rebuild) and re-run quick_check.
|
||||
Returns the remaining quick_check errors — empty when the heal succeeded.
|
||||
Broader corruption, a lock held by another writer, or a rebuild failure
|
||||
leaves ``errors`` unchanged so the caller still aborts with the banner.
|
||||
"""
|
||||
if not _errors_are_isolated_fts5(errors):
|
||||
return errors
|
||||
|
||||
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.exists(sqlite_path):
|
||||
return errors
|
||||
|
||||
# Lazy import: palace.py is heavier and importing it at module load would
|
||||
# widen repair.py's import graph for callers that never hit this path.
|
||||
from .palace import MineAlreadyRunning, mine_palace_lock
|
||||
|
||||
progress(
|
||||
"\n Isolated FTS5 inverted-index corruption detected; attempting an\n"
|
||||
" in-place rebuild from the intact content table before aborting."
|
||||
)
|
||||
try:
|
||||
with mine_palace_lock(palace_path):
|
||||
with closing(sqlite3.connect(sqlite_path, isolation_level=None)) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO embedding_fulltext_search"
|
||||
"(embedding_fulltext_search) VALUES('rebuild')"
|
||||
)
|
||||
conn.commit()
|
||||
except MineAlreadyRunning as exc:
|
||||
progress(
|
||||
f" Skipped FTS5 rebuild: palace is being written by another process ({exc}). "
|
||||
"Stop it and re-run."
|
||||
)
|
||||
return errors
|
||||
except Exception as exc:
|
||||
progress(f" FTS5 rebuild failed (leaving palace untouched): {exc}")
|
||||
return errors
|
||||
|
||||
remaining = sqlite_integrity_errors(palace_path)
|
||||
if remaining:
|
||||
progress(" FTS5 rebuild did not clear quick_check; aborting for safety.")
|
||||
else:
|
||||
progress(" FTS5 index rebuilt from intact content; quick_check is clean.")
|
||||
return remaining
|
||||
|
||||
|
||||
def index_read_recovery_guidance() -> str:
|
||||
"""Recovery guidance for a failed drawer-index read in the legacy paths.
|
||||
|
||||
Both ``cmd_repair`` (cli.py) and :func:`rebuild_index` read the drawers
|
||||
collection via ``Collection.count()`` as their first step. The common
|
||||
reason that read raises is the chromadb compactor failing to apply the
|
||||
WAL into the HNSW segment (``InternalError: Failed to apply logs to the
|
||||
hnsw segment writer``, issues #1308 / #1843): the on-disk HNSW index is
|
||||
corrupt while the rows stay intact in ``chroma.sqlite3``, so
|
||||
:func:`rebuild_from_sqlite` (``repair --mode from-sqlite``) recovers them
|
||||
and re-mining would needlessly drop drawers added through the MCP server
|
||||
and diary entries that have no source file.
|
||||
|
||||
The other thing that strands this read is a live MemPalace server or
|
||||
mine still holding the palace open, so the guidance says to stop it and
|
||||
retry before assuming corruption. Worded conditionally because the bare
|
||||
``except Exception`` cannot prove which case it caught. Returned as a
|
||||
pre-indented block so the ``print``-based CLI path and the
|
||||
``progress``-callable rebuild path emit it unchanged.
|
||||
"""
|
||||
return (
|
||||
" If a MemPalace server or mine is still running against this palace,\n"
|
||||
" stop it and retry. Otherwise the drawer index is likely corrupt\n"
|
||||
" (for example a failed chromadb HNSW compaction) while your drawer\n"
|
||||
" rows remain in chroma.sqlite3. Rebuild the index from SQLite rather\n"
|
||||
" than re-mining:\n"
|
||||
"\n"
|
||||
" mempalace repair --mode from-sqlite --archive-existing\n"
|
||||
"\n"
|
||||
" (Re-mining from source files would drop drawers added via the MCP\n"
|
||||
" server and diary entries, which have no source file.)"
|
||||
)
|
||||
|
||||
|
||||
def maybe_repair_poisoned_max_seq_id_before_rebuild(
|
||||
palace_path: str,
|
||||
*,
|
||||
@@ -681,7 +889,12 @@ class _DefaultProgress:
|
||||
return f" (elapsed {_format_eta(elapsed)}, rate {rate:.1f}/s, ETA {_format_eta(eta)})"
|
||||
|
||||
|
||||
def _vacuum_and_rebuild_fts5(palace_path: str, progress=print) -> None:
|
||||
def _vacuum_and_rebuild_fts5(
|
||||
palace_path: str,
|
||||
progress=print,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> None:
|
||||
"""VACUUM the palace SQLite file and rebuild the FTS5 index if present.
|
||||
|
||||
Repeated ``repair --yes`` runs delete and recreate the drawers collection,
|
||||
@@ -690,12 +903,16 @@ def _vacuum_and_rebuild_fts5(palace_path: str, progress=print) -> None:
|
||||
internally inconsistent after multiple collection deletes; the rebuild
|
||||
command fixes it atomically without touching any row data.
|
||||
|
||||
Failures are non-fatal: a warning is printed and the caller continues.
|
||||
The repair itself succeeded at this point — VACUUM/FTS5 are best-effort
|
||||
cleanup, not correctness requirements.
|
||||
Existing repair paths use the default best-effort behavior: failures log a
|
||||
warning and return because their primary rebuild has already succeeded.
|
||||
SQLite recovery passes ``strict=True`` because its bulk upserts can leave
|
||||
this derived index malformed; that path must not report success until the
|
||||
rebuild, VACUUM, and a final quick_check all complete.
|
||||
"""
|
||||
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.exists(sqlite_path):
|
||||
if strict:
|
||||
raise FileNotFoundError(f"recovered palace has no SQLite database: {sqlite_path}")
|
||||
return
|
||||
try:
|
||||
with closing(sqlite3.connect(sqlite_path, isolation_level=None)) as conn:
|
||||
@@ -711,7 +928,21 @@ def _vacuum_and_rebuild_fts5(palace_path: str, progress=print) -> None:
|
||||
progress(" FTS5 index rebuilt.")
|
||||
conn.execute("VACUUM")
|
||||
progress(" SQLite VACUUM complete.")
|
||||
if strict:
|
||||
rows = conn.execute("PRAGMA quick_check").fetchall()
|
||||
errors = [str(row[0]) for row in rows if row and str(row[0]).lower() != "ok"]
|
||||
if errors:
|
||||
raise sqlite3.DatabaseError(
|
||||
"post-recovery quick_check failed: " + "; ".join(errors[:3])
|
||||
)
|
||||
progress(" SQLite quick_check clean.")
|
||||
except Exception as exc:
|
||||
if strict:
|
||||
# Preserve the concrete SQLite/filesystem exception for callers
|
||||
# that need to classify the failure. The strict recovery caller
|
||||
# owns the user-facing error message, so logging here would also
|
||||
# print the same failure twice.
|
||||
raise
|
||||
progress(f" Warning: post-repair cleanup failed (non-fatal): {exc}")
|
||||
|
||||
|
||||
@@ -774,6 +1005,8 @@ def rebuild_index(
|
||||
# corruption here lets us surface the clear recovery instructions and
|
||||
# exit cleanly before chromadb's compactor touches the disk.
|
||||
sqlite_errors = sqlite_integrity_errors(palace_path)
|
||||
if sqlite_errors:
|
||||
sqlite_errors = maybe_autoheal_fts5_index(palace_path, sqlite_errors, progress=progress)
|
||||
if sqlite_errors:
|
||||
print_sqlite_integrity_abort(palace_path, sqlite_errors)
|
||||
return
|
||||
@@ -791,7 +1024,7 @@ def rebuild_index(
|
||||
total = col.count()
|
||||
except Exception as e:
|
||||
progress(f" Error reading palace: {e}")
|
||||
progress(" Palace may need to be re-mined from source files.")
|
||||
progress(index_read_recovery_guidance())
|
||||
return
|
||||
|
||||
progress(f" Drawers found: {total}")
|
||||
@@ -823,10 +1056,10 @@ def rebuild_index(
|
||||
|
||||
# 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"
|
||||
backup_path = _unique_backup_path(sqlite_path, "backup")
|
||||
if os.path.exists(sqlite_path):
|
||||
progress(f" Backing up chroma.sqlite3 ({os.path.getsize(sqlite_path) / 1e6:.0f} MB)...")
|
||||
shutil.copy2(sqlite_path, backup_path)
|
||||
_copy_file_no_follow(sqlite_path, backup_path)
|
||||
progress(f" Backup: {backup_path}")
|
||||
|
||||
# Rebuild with correct HNSW settings
|
||||
@@ -850,7 +1083,7 @@ def rebuild_index(
|
||||
try:
|
||||
_close_chroma_handles(palace_path, backend=backend)
|
||||
_delete_collection_if_exists(backend, palace_path, collection_name)
|
||||
shutil.copy2(backup_path, sqlite_path)
|
||||
_copy_file_no_follow(backup_path, sqlite_path, replace=True)
|
||||
progress(" Backup restored. Palace is back to pre-repair state.")
|
||||
except Exception as restore_error:
|
||||
progress(f" Backup restore failed: {restore_error}")
|
||||
@@ -895,6 +1128,29 @@ class RebuildPartialError(Exception):
|
||||
self.archive_path = archive_path
|
||||
|
||||
|
||||
class RebuildCleanupError(Exception):
|
||||
"""Raised when all recoverable rows landed but final cleanup failed.
|
||||
|
||||
The destination is intentionally retained for inspection, and an in-place
|
||||
rebuild's original archive remains untouched. Callers must not treat this
|
||||
as success because the derived FTS5 index has not been verified clean.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
counts: dict[str, int],
|
||||
dest_palace: str,
|
||||
archive_path: Optional[str],
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.counts = counts
|
||||
self.dest_palace = dest_palace
|
||||
self.archive_path = archive_path
|
||||
|
||||
|
||||
def _rebuild_one_collection(
|
||||
*,
|
||||
backend: ChromaBackend,
|
||||
@@ -1013,7 +1269,7 @@ def extract_via_sqlite(palace_path: str, collection_name: str) -> Iterator[tuple
|
||||
if not os.path.isfile(sqlite_path):
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(sqlite_path), uri=True)
|
||||
try:
|
||||
seg_row = conn.execute(
|
||||
"""
|
||||
@@ -1059,6 +1315,40 @@ def extract_via_sqlite(palace_path: str, collection_name: str) -> Iterator[tuple
|
||||
conn.close()
|
||||
|
||||
|
||||
def _preserve_knowledge_graph_sqlite(source_palace: str, dest_palace: str) -> list[str]:
|
||||
"""Copy KG SQLite sidecars when rebuilding a palace from chroma.sqlite3.
|
||||
|
||||
rebuild_from_sqlite reconstructs Chroma collections into a fresh
|
||||
destination directory. The knowledge graph is a separate SQLite database,
|
||||
so it must be copied explicitly or the repair succeeds while silently
|
||||
dropping KG state (#1816).
|
||||
"""
|
||||
|
||||
copied: list[str] = []
|
||||
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
filename = f"knowledge_graph.sqlite3{suffix}"
|
||||
src = os.path.join(source_palace, filename)
|
||||
dst = os.path.join(dest_palace, filename)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
continue
|
||||
if os.path.abspath(src) == os.path.abspath(dst):
|
||||
continue
|
||||
|
||||
os.makedirs(dest_palace, exist_ok=True)
|
||||
try:
|
||||
_copy_file_no_follow(src, dst, replace=True)
|
||||
except RuntimeError:
|
||||
continue
|
||||
copied.append(filename)
|
||||
|
||||
if copied:
|
||||
print(" Preserved knowledge graph: " + ", ".join(copied))
|
||||
|
||||
return copied
|
||||
|
||||
|
||||
def rebuild_from_sqlite(
|
||||
source_palace: str,
|
||||
dest_palace: str,
|
||||
@@ -1110,7 +1400,9 @@ def rebuild_from_sqlite(
|
||||
chromadb upsert fails partway through; the dest palace is left in
|
||||
place so the user can inspect what landed, and the in-place archive
|
||||
(when applicable) is reported in the error so the user can re-run
|
||||
against it.
|
||||
against it. Raises :class:`RebuildCleanupError` if all rows land but the
|
||||
required FTS5 rebuild, VACUUM, or final quick_check fails; this prevents a
|
||||
structurally unverified recovery from being reported as complete.
|
||||
|
||||
.. warning::
|
||||
|
||||
@@ -1184,7 +1476,30 @@ def rebuild_from_sqlite(
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
archive_path = f"{dest_palace}.pre-rebuild-{ts}"
|
||||
print(f" Archiving {dest_palace} → {archive_path}")
|
||||
shutil.move(dest_palace, archive_path)
|
||||
# os.rename, NOT shutil.move. When any file inside the palace is
|
||||
# held open by another process (MCP server, a running mine, another
|
||||
# harness), renaming the directory fails atomically UP FRONT on
|
||||
# Windows and the palace is left untouched. shutil.move's fallback
|
||||
# for a failed rename is copytree + rmtree — and that rmtree
|
||||
# deletes the live palace file-by-file until it hits the first
|
||||
# locked file, leaving the palace partially gutted next to a
|
||||
# partial archive copy. Observed live (Windows 11, 2026-07-05/06):
|
||||
# two interrupted in-place repairs; the palace survived only
|
||||
# because the locked chroma.sqlite3/*.bin themselves could not be
|
||||
# unlinked. Same lesson as the source-validation comment above:
|
||||
# fail cleanly before touching anything.
|
||||
try:
|
||||
os.rename(dest_palace, archive_path)
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"\n Cannot archive the existing palace: a file inside it "
|
||||
f"is held open by another process (MCP server, running "
|
||||
f"mine, another harness?).\n"
|
||||
f" Close those processes and retry. The palace was left "
|
||||
f"untouched.\n"
|
||||
f" ({exc})"
|
||||
)
|
||||
return {}
|
||||
source_palace = archive_path
|
||||
src_db = os.path.join(source_palace, "chroma.sqlite3")
|
||||
|
||||
@@ -1205,6 +1520,7 @@ def rebuild_from_sqlite(
|
||||
)
|
||||
|
||||
os.makedirs(dest_palace, exist_ok=True)
|
||||
_preserve_knowledge_graph_sqlite(source_palace, dest_palace)
|
||||
|
||||
# Backend lifetime is wrapped in try/finally so the dest palace's
|
||||
# PersistentClient handle (opened lazily inside ``create_collection``
|
||||
@@ -1235,14 +1551,39 @@ def rebuild_from_sqlite(
|
||||
else:
|
||||
print(f" done: {upserted} rows in {cname}")
|
||||
|
||||
print(f"\n Rebuild complete. {sum(counts.values())} total rows.")
|
||||
if archive_path is not None:
|
||||
print(f" Original palace archived at: {archive_path}")
|
||||
print(f"{'=' * 55}\n")
|
||||
return counts
|
||||
finally:
|
||||
backend.close()
|
||||
|
||||
# Bulk Chroma upserts can leave the derived FTS5 index internally
|
||||
# inconsistent even when all source rows landed. Rebuild it only after
|
||||
# the backend releases its SQLite handle; otherwise VACUUM cannot obtain
|
||||
# the exclusive lock it needs on Windows.
|
||||
try:
|
||||
_vacuum_and_rebuild_fts5(dest_palace, strict=True)
|
||||
except Exception as exc:
|
||||
message_parts = [
|
||||
f"Post-recovery cleanup failed after {sum(counts.values())} rows were rebuilt: {exc}",
|
||||
f"Recovered palace retained at: {dest_palace}",
|
||||
]
|
||||
if archive_path is not None:
|
||||
message_parts.append(f"Original palace remains archived at: {archive_path}")
|
||||
else:
|
||||
message_parts.append(f"Source palace is unchanged at: {source_palace}")
|
||||
message = "\n ".join(message_parts)
|
||||
print(f"\n ERROR: {message}")
|
||||
raise RebuildCleanupError(
|
||||
message,
|
||||
counts=dict(counts),
|
||||
dest_palace=dest_palace,
|
||||
archive_path=archive_path,
|
||||
) from exc
|
||||
|
||||
print(f"\n Rebuild complete. {sum(counts.values())} total rows.")
|
||||
if archive_path is not None:
|
||||
print(f" Original palace archived at: {archive_path}")
|
||||
print(f"{'=' * 55}\n")
|
||||
return counts
|
||||
|
||||
|
||||
def status(palace_path=None, collection_name: Optional[str] = None) -> dict:
|
||||
"""Read-only health check: compare sqlite vs HNSW element counts.
|
||||
@@ -1308,7 +1649,15 @@ def status(palace_path=None, collection_name: Optional[str] = None) -> dict:
|
||||
print(f" note: {info['message']}")
|
||||
|
||||
if drawers["diverged"] or closets["diverged"]:
|
||||
print("\n Recommended: run `mempalace repair` to rebuild the index.")
|
||||
print(
|
||||
"\n Recommended: rebuild the index from SQLite rather than re-mining:\n"
|
||||
"\n mempalace repair --mode from-sqlite --archive-existing\n"
|
||||
"\n A diverged index usually means the HNSW segment is out of sync with\n"
|
||||
" chroma.sqlite3 (for example a failed chromadb HNSW compaction). The\n"
|
||||
" drawer rows are intact in SQLite, so --mode from-sqlite recovers them.\n"
|
||||
" Do not re-mine from source files: that would drop drawers added via\n"
|
||||
" the MCP server and diary entries, which have no source file (#1843)."
|
||||
)
|
||||
print()
|
||||
return {"drawers": drawers, "closets": closets}
|
||||
|
||||
|
||||
+175
-21
@@ -23,6 +23,7 @@ from .backends import (
|
||||
PalaceNotFoundError,
|
||||
UnsupportedCapabilityError,
|
||||
)
|
||||
from .config import sqlite_read_uri
|
||||
from .palace import (
|
||||
_open_collection_or_explain,
|
||||
get_closets_collection,
|
||||
@@ -220,20 +221,40 @@ def _hybrid_rank(
|
||||
r["bm25_score"] = round(raw, 3)
|
||||
scored.append((vector_weight * vec_sim + bm25_weight * norm, r))
|
||||
|
||||
scored.sort(key=lambda pair: pair[0], reverse=True)
|
||||
# Break exact score ties toward the more recently authored drawer so equal-score
|
||||
# candidates rank chronologically instead of in arbitrary backend order. ISO-8601
|
||||
# ``authored_at`` strings sort chronologically; missing dates sort oldest.
|
||||
# authored_at lives at the top level on the search_memories path and nested under
|
||||
# "metadata" on the candidate-union path; check both so the tie-break works for each.
|
||||
scored.sort(
|
||||
key=lambda pair: (
|
||||
pair[0],
|
||||
pair[1].get("authored_at") or pair[1].get("metadata", {}).get("authored_at") or "",
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
results[:] = [r for _, r in scored]
|
||||
return results
|
||||
|
||||
|
||||
def build_where_filter(wing: str = None, room: str = None) -> dict:
|
||||
"""Build ChromaDB where filter for wing/room filtering."""
|
||||
if wing and room:
|
||||
return {"$and": [{"wing": wing}, {"room": room}]}
|
||||
elif wing:
|
||||
return {"wing": wing}
|
||||
elif room:
|
||||
return {"room": room}
|
||||
return {}
|
||||
def build_where_filter(wing: str = None, room: str = None, source_file: str = None) -> dict:
|
||||
"""Build a ChromaDB where filter from optional wing/room/source_file.
|
||||
|
||||
ChromaDB needs a ``$and`` only when ≥2 clauses are present; a single
|
||||
clause is returned bare and zero clauses yield an empty filter (#1815).
|
||||
"""
|
||||
clauses = []
|
||||
if wing:
|
||||
clauses.append({"wing": wing})
|
||||
if room:
|
||||
clauses.append({"room": room})
|
||||
if source_file:
|
||||
clauses.append({"source_file": source_file})
|
||||
if not clauses:
|
||||
return {}
|
||||
if len(clauses) == 1:
|
||||
return clauses[0]
|
||||
return {"$and": clauses}
|
||||
|
||||
|
||||
def _extract_drawer_ids_from_closet(closet_doc: str) -> list:
|
||||
@@ -386,11 +407,109 @@ def _warn_if_legacy_metric(col) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _hnsw_capacity_diverged(palace_path: str) -> bool:
|
||||
"""Return True if HNSW divergence is severe enough to crash ChromaDB.
|
||||
|
||||
Thin, exception-safe wrapper around
|
||||
:func:`mempalace.backends.chroma.hnsw_capacity_status`. Used by the
|
||||
CLI search path to short-circuit to the BM25-only fallback before
|
||||
opening a Chroma client. Client construction and collection identity
|
||||
checks can themselves touch the damaged index, so guarding only
|
||||
``col.query()`` is too late (#1222 covers the MCP path via the module-level
|
||||
``_vector_disabled`` flag; this covers the CLI path).
|
||||
|
||||
A probe that raises falls through to ``False`` so the caller proceeds
|
||||
to the normal vector path — the underlying query then either succeeds
|
||||
(probe was a false negative) or raises its own diagnostic error. The
|
||||
probe itself must never be the thing that crashes search.
|
||||
"""
|
||||
try:
|
||||
from .backends.chroma import hnsw_capacity_status
|
||||
from .config import get_configured_collection_name
|
||||
|
||||
info = hnsw_capacity_status(palace_path, get_configured_collection_name())
|
||||
return bool(info.get("diverged"))
|
||||
except Exception:
|
||||
logger.debug("HNSW capacity probe raised; proceeding to vector path", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _print_search_results_bm25_only(
|
||||
query: str, palace_path: str, wing: str, room: str, n_results: int
|
||||
) -> None:
|
||||
"""CLI fallback printer for when HNSW divergence fences off vector search.
|
||||
|
||||
Mirrors the vector-path output shape so users get lexical matches in
|
||||
the format they expect, plus a clear notice pointing at
|
||||
``mempalace repair``. Replaces the silent SIGBUS users otherwise hit
|
||||
when the CLI called ``col.query()`` against a diverged segment.
|
||||
"""
|
||||
result = _bm25_only_via_sqlite(
|
||||
query=query,
|
||||
palace_path=palace_path,
|
||||
wing=wing,
|
||||
room=room,
|
||||
n_results=n_results,
|
||||
)
|
||||
hits = result.get("results", [])
|
||||
|
||||
print(
|
||||
"\n NOTICE: vector search disabled — HNSW index has diverged from SQLite.\n"
|
||||
" Showing BM25-only results. Run `mempalace repair` to restore "
|
||||
"vector search.\n"
|
||||
)
|
||||
print(f"{'=' * 60}")
|
||||
print(f' Results for: "{query}"')
|
||||
if wing:
|
||||
print(f" Wing: {wing}")
|
||||
if room:
|
||||
print(f" Room: {room}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
if not hits:
|
||||
print(f' No results found for: "{query}"')
|
||||
return
|
||||
|
||||
for i, hit in enumerate(hits, 1):
|
||||
bm25 = hit.get("bm25_score", 0.0)
|
||||
wing_name = hit.get("wing", "?")
|
||||
room_name = hit.get("room", "?")
|
||||
source = Path(hit.get("source_file", "?")).name
|
||||
|
||||
print(f" [{i}] {wing_name} / {room_name}")
|
||||
print(f" Source: {source}")
|
||||
print(f" Match: bm25={bm25} (vector disabled)")
|
||||
print()
|
||||
for line in (hit.get("text", "") or "").strip().split("\n"):
|
||||
print(f" {line}")
|
||||
print()
|
||||
print(f" {'─' * 56}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def search(query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5):
|
||||
"""
|
||||
Search the palace. Returns verbatim drawer content.
|
||||
Optionally filter by wing (project) or room (aspect).
|
||||
"""
|
||||
# Probe a Chroma palace before get_collection(). Opening the client can
|
||||
# load native index state, and embedder-identity enforcement may call
|
||||
# collection.count(); both happen before the old query-only guard and can
|
||||
# hit the same native crash. Non-Chroma backends never use Chroma's HNSW
|
||||
# files or sqlite-specific fallback and proceed normally.
|
||||
try:
|
||||
backend_name = resolve_backend_name(palace_path)
|
||||
except (BackendMismatchError, KeyError):
|
||||
# Preserve _open_collection_or_explain's state-specific diagnostics
|
||||
# for mixed artifacts and unknown backend selections. This probe is
|
||||
# only an early Chroma safety fence; it must not become a second,
|
||||
# less-helpful backend validation path.
|
||||
backend_name = None
|
||||
|
||||
if backend_name == "chroma" and _hnsw_capacity_diverged(palace_path):
|
||||
return _print_search_results_bm25_only(query, palace_path, wing, room, n_results)
|
||||
|
||||
col = _open_collection_or_explain(palace_path, opener=get_collection)
|
||||
if col is None:
|
||||
if not os.path.isdir(palace_path):
|
||||
@@ -475,6 +594,7 @@ def _bm25_only_via_sqlite(
|
||||
palace_path: str,
|
||||
wing: str = None,
|
||||
room: str = None,
|
||||
source_file: str = None,
|
||||
n_results: int = 5,
|
||||
max_candidates: int = 500,
|
||||
_include_internal: bool = False,
|
||||
@@ -510,7 +630,7 @@ def _bm25_only_via_sqlite(
|
||||
def _metadata_filter_sql(row_id_expr: str) -> tuple[str, list[str]]:
|
||||
clauses = []
|
||||
params = []
|
||||
for key, value in (("wing", wing), ("room", room)):
|
||||
for key, value in (("wing", wing), ("room", room), ("source_file", source_file)):
|
||||
if not value:
|
||||
continue
|
||||
clauses.append(
|
||||
@@ -533,7 +653,7 @@ def _bm25_only_via_sqlite(
|
||||
return "".join(clauses), params
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
|
||||
except sqlite3.Error as e:
|
||||
return {"error": f"sqlite open failed: {e}"}
|
||||
|
||||
@@ -623,7 +743,7 @@ def _bm25_only_via_sqlite(
|
||||
if not candidate_ids:
|
||||
return {
|
||||
"query": query,
|
||||
"filters": {"wing": wing, "room": room},
|
||||
"filters": {"wing": wing, "room": room, "source_file": source_file},
|
||||
"total_before_filter": 0,
|
||||
"results": [],
|
||||
"fallback": "bm25_only_via_sqlite",
|
||||
@@ -659,6 +779,8 @@ def _bm25_only_via_sqlite(
|
||||
continue
|
||||
if room and meta.get("room") != room:
|
||||
continue
|
||||
if source_file and meta.get("source_file") != source_file:
|
||||
continue
|
||||
full_source = meta.get("source_file", "") or ""
|
||||
candidates.append(
|
||||
{
|
||||
@@ -666,7 +788,9 @@ def _bm25_only_via_sqlite(
|
||||
"wing": meta.get("wing", "unknown"),
|
||||
"room": meta.get("room", "unknown"),
|
||||
"source_file": Path(full_source).name if full_source else "?",
|
||||
"source_path": full_source,
|
||||
"created_at": meta.get("filed_at", "unknown"),
|
||||
"authored_at": meta.get("authored_at", meta.get("filed_at", "unknown")),
|
||||
# No vector distance available in BM25-only mode.
|
||||
"similarity": None,
|
||||
"distance": None,
|
||||
@@ -701,7 +825,7 @@ def _bm25_only_via_sqlite(
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"filters": {"wing": wing, "room": room},
|
||||
"filters": {"wing": wing, "room": room, "source_file": source_file},
|
||||
"total_before_filter": len(candidates),
|
||||
"results": hits,
|
||||
"fallback": "bm25_only_via_sqlite",
|
||||
@@ -717,6 +841,7 @@ def _merge_bm25_union_candidates(
|
||||
room: str,
|
||||
n_results: int,
|
||||
max_distance: float = 0.0,
|
||||
source_file: str = None,
|
||||
) -> None:
|
||||
"""Append top-K backend lexical candidates into ``hits`` in place.
|
||||
|
||||
@@ -743,7 +868,7 @@ def _merge_bm25_union_candidates(
|
||||
if max_distance > 0.0:
|
||||
return
|
||||
|
||||
where = build_where_filter(wing, room)
|
||||
where = build_where_filter(wing, room, source_file)
|
||||
try:
|
||||
lexical = drawers_col.lexical_search(
|
||||
query=query,
|
||||
@@ -766,7 +891,9 @@ def _merge_bm25_union_candidates(
|
||||
"wing": meta.get("wing", "unknown"),
|
||||
"room": meta.get("room", "unknown"),
|
||||
"source_file": Path(full_source).name if full_source else "?",
|
||||
"source_path": full_source,
|
||||
"created_at": meta.get("filed_at", "unknown"),
|
||||
"authored_at": meta.get("authored_at", meta.get("filed_at", "unknown")),
|
||||
"similarity": None,
|
||||
"distance": None,
|
||||
"effective_distance": None,
|
||||
@@ -831,6 +958,7 @@ def _apply_candidate_strategy(
|
||||
room: str,
|
||||
n_results: int,
|
||||
max_distance: float = 0.0,
|
||||
source_file: str = None,
|
||||
) -> None:
|
||||
"""Dispatch to the registered merger for ``strategy``.
|
||||
|
||||
@@ -839,7 +967,16 @@ def _apply_candidate_strategy(
|
||||
"""
|
||||
merger = _CANDIDATE_MERGERS[strategy]
|
||||
if merger is not None:
|
||||
merger(hits, drawers_col, query, wing, room, n_results, max_distance=max_distance)
|
||||
merger(
|
||||
hits,
|
||||
drawers_col,
|
||||
query,
|
||||
wing,
|
||||
room,
|
||||
n_results,
|
||||
max_distance=max_distance,
|
||||
source_file=source_file,
|
||||
)
|
||||
|
||||
|
||||
def _finalize_candidate_hits(
|
||||
@@ -852,6 +989,7 @@ def _finalize_candidate_hits(
|
||||
room: str,
|
||||
n_results: int,
|
||||
max_distance: float,
|
||||
source_file: str = None,
|
||||
) -> tuple:
|
||||
try:
|
||||
_apply_candidate_strategy(
|
||||
@@ -863,6 +1001,7 @@ def _finalize_candidate_hits(
|
||||
room,
|
||||
n_results,
|
||||
max_distance=max_distance,
|
||||
source_file=source_file,
|
||||
)
|
||||
except UnsupportedCapabilityError:
|
||||
return [], {
|
||||
@@ -904,6 +1043,7 @@ def _vector_disabled_search(
|
||||
room: str,
|
||||
n_results: int,
|
||||
collection_name: str,
|
||||
source_file: str = None,
|
||||
) -> dict:
|
||||
try:
|
||||
backend_name = resolve_backend_name(palace_path)
|
||||
@@ -923,6 +1063,7 @@ def _vector_disabled_search(
|
||||
palace_path,
|
||||
wing=wing,
|
||||
room=room,
|
||||
source_file=source_file,
|
||||
n_results=n_results,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
@@ -956,7 +1097,9 @@ def _open_search_collection(palace_path: str, collection_name: str):
|
||||
}
|
||||
|
||||
|
||||
def _query_drawers_with_filter_fallback(drawers_col, dkwargs, query, n_results, wing, room):
|
||||
def _query_drawers_with_filter_fallback(
|
||||
drawers_col, dkwargs, query, n_results, wing, room, source_file=None
|
||||
):
|
||||
"""Run the filtered drawer query, falling back to an unfiltered query plus a
|
||||
Python-side post-filter when ChromaDB raises on the filtered query.
|
||||
|
||||
@@ -964,7 +1107,7 @@ def _query_drawers_with_filter_fallback(drawers_col, dkwargs, query, n_results,
|
||||
"Error finding id" even when unfiltered search works fine — it happens when
|
||||
drawers are ingested via two different paths (e.g. bulk import vs MCP tool
|
||||
calls), leaving the vector index inconsistent with the metadata store. We
|
||||
retry unfiltered (over-fetching) and re-apply the wing/room filter in Python.
|
||||
retry unfiltered (over-fetching) and re-apply the wing/room/source_file filter in Python.
|
||||
See #1245 / #1035.
|
||||
"""
|
||||
where = dkwargs.get("where")
|
||||
@@ -993,6 +1136,8 @@ def _query_drawers_with_filter_fallback(drawers_col, dkwargs, query, n_results,
|
||||
continue
|
||||
if room and meta.get("room") != room:
|
||||
continue
|
||||
if source_file and meta.get("source_file") != source_file:
|
||||
continue
|
||||
fdocs.append(doc)
|
||||
fmetas.append(meta)
|
||||
fdists.append(dist)
|
||||
@@ -1004,6 +1149,7 @@ def search_memories(
|
||||
palace_path: str,
|
||||
wing: str = None,
|
||||
room: str = None,
|
||||
source_file: str = None,
|
||||
n_results: int = 5,
|
||||
max_distance: float = 0.0,
|
||||
vector_disabled: bool = False,
|
||||
@@ -1019,6 +1165,8 @@ def search_memories(
|
||||
palace_path: Path to the ChromaDB palace directory.
|
||||
wing: Optional wing filter.
|
||||
room: Optional room filter.
|
||||
source_file: Optional exact source_file filter. Matches the full
|
||||
stored source_file value verbatim (#1815).
|
||||
n_results: Max results to return.
|
||||
max_distance: Max cosine distance threshold. The palace collection uses
|
||||
cosine distance (hnsw:space=cosine) — 0 = identical, 2 = opposite.
|
||||
@@ -1058,6 +1206,7 @@ def search_memories(
|
||||
room=room,
|
||||
n_results=n_results,
|
||||
collection_name=collection_name,
|
||||
source_file=source_file,
|
||||
)
|
||||
|
||||
drawers_col, open_error = _open_search_collection(palace_path, collection_name)
|
||||
@@ -1065,7 +1214,7 @@ def search_memories(
|
||||
return open_error
|
||||
|
||||
metric = _metric_for_collection(drawers_col)
|
||||
where = build_where_filter(wing, room)
|
||||
where = build_where_filter(wing, room, source_file)
|
||||
|
||||
# Hybrid retrieval: always query drawers directly (the floor), then use
|
||||
# closet hits to boost rankings. Closets are a ranking SIGNAL, never a
|
||||
@@ -1083,7 +1232,7 @@ def search_memories(
|
||||
if where:
|
||||
dkwargs["where"] = where
|
||||
drawer_results = _query_drawers_with_filter_fallback(
|
||||
drawers_col, dkwargs, query, n_results, wing, room
|
||||
drawers_col, dkwargs, query, n_results, wing, room, source_file
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": f"Search error: {e}"}
|
||||
@@ -1155,8 +1304,12 @@ def search_memories(
|
||||
"text": doc,
|
||||
"wing": meta.get("wing", "unknown"),
|
||||
"room": meta.get("room", "unknown"),
|
||||
# source_file is the basename (display); source_path is the full
|
||||
# stored value, the round-trippable key for the source_file filter.
|
||||
"source_file": Path(source).name if source else "?",
|
||||
"source_path": source,
|
||||
"created_at": meta.get("filed_at", "unknown"),
|
||||
"authored_at": meta.get("authored_at", meta.get("filed_at", "unknown")),
|
||||
"similarity": round(_distance_to_similarity(effective_dist, metric), 3),
|
||||
"distance": round(dist, 4),
|
||||
"effective_distance": round(effective_dist, 4),
|
||||
@@ -1253,13 +1406,14 @@ def search_memories(
|
||||
room=room,
|
||||
n_results=n_results,
|
||||
max_distance=max_distance,
|
||||
source_file=source_file,
|
||||
)
|
||||
if strategy_error:
|
||||
return strategy_error
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"filters": {"wing": wing, "room": room},
|
||||
"filters": {"wing": wing, "room": room, "source_file": source_file},
|
||||
"total_before_filter": len(_first_or_empty(drawer_results, "documents")),
|
||||
"results": hits,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Shared service operations used by daemon-backed entry points.
|
||||
|
||||
The MCP server remains the owner of MCP transport details. This module owns the
|
||||
small, transport-neutral execution surface the daemon needs: classify known
|
||||
tools and execute durable background jobs without printing directly to the
|
||||
caller's terminal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .config import MempalaceConfig
|
||||
|
||||
_EXPLICIT_BACKEND_ENV = "MEMPALACE_BACKEND_EXPLICIT"
|
||||
_PALACE_PATH_ENV = "MEMPALACE_PALACE_PATH"
|
||||
_BACKEND_ENV = "MEMPALACE_BACKEND"
|
||||
# Env vars a job may mutate via _apply_backend / palace_path injection. They are
|
||||
# snapshotted per job and restored afterward so a job that switches the backend
|
||||
# (e.g. qdrant) cannot poison every later job in the same daemon process —
|
||||
# including mcp_tool jobs, which read MempalaceConfig (and thus the leaked env).
|
||||
_PER_JOB_ENV = (_PALACE_PATH_ENV, _BACKEND_ENV, _EXPLICIT_BACKEND_ENV)
|
||||
|
||||
|
||||
READ_TOOLS = frozenset(
|
||||
{
|
||||
"mempalace_status",
|
||||
"mempalace_list_wings",
|
||||
"mempalace_list_rooms",
|
||||
"mempalace_get_taxonomy",
|
||||
"mempalace_get_aaak_spec",
|
||||
"mempalace_traverse",
|
||||
"mempalace_find_tunnels",
|
||||
"mempalace_graph_stats",
|
||||
"mempalace_list_tunnels",
|
||||
"mempalace_list_hallways",
|
||||
"mempalace_follow_tunnels",
|
||||
"mempalace_search",
|
||||
"mempalace_check_duplicate",
|
||||
"mempalace_get_drawer",
|
||||
"mempalace_list_drawers",
|
||||
"mempalace_diary_read",
|
||||
"mempalace_memories_filed_away",
|
||||
"mempalace_kg_query",
|
||||
"mempalace_kg_stats",
|
||||
"mempalace_kg_timeline",
|
||||
}
|
||||
)
|
||||
|
||||
WRITE_TOOLS = frozenset(
|
||||
{
|
||||
"mempalace_add_drawer",
|
||||
"mempalace_checkpoint",
|
||||
"mempalace_delete_by_source",
|
||||
"mempalace_delete_drawer",
|
||||
"mempalace_update_drawer",
|
||||
"mempalace_diary_write",
|
||||
"mempalace_kg_add",
|
||||
"mempalace_kg_invalidate",
|
||||
"mempalace_create_tunnel",
|
||||
"mempalace_delete_tunnel",
|
||||
"mempalace_delete_hallway",
|
||||
"mempalace_hook_settings",
|
||||
}
|
||||
)
|
||||
|
||||
MAINTENANCE_TOOLS = frozenset({"mempalace_mine", "mempalace_sync", "mempalace_reconnect"})
|
||||
|
||||
|
||||
def classify_tool(name: str) -> str:
|
||||
"""Return ``read``, ``write``, ``maintenance``, or ``unknown`` for an MCP tool."""
|
||||
if name in READ_TOOLS:
|
||||
return "read"
|
||||
if name in WRITE_TOOLS:
|
||||
return "write"
|
||||
if name in MAINTENANCE_TOOLS:
|
||||
return "maintenance"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _apply_backend(backend: str | None) -> None:
|
||||
if not backend:
|
||||
return
|
||||
backend_name = str(backend).strip().lower()
|
||||
from .backends import get_backend_class
|
||||
|
||||
get_backend_class(backend_name)
|
||||
os.environ[_EXPLICIT_BACKEND_ENV] = backend_name
|
||||
os.environ[_BACKEND_ENV] = backend_name
|
||||
|
||||
|
||||
def _capture(fn):
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
result = fn()
|
||||
return result, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
|
||||
def execute_job(kind: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Execute one daemon job and return a JSON-serializable result."""
|
||||
|
||||
def _run():
|
||||
if kind == "mine":
|
||||
return run_mine(payload)
|
||||
if kind == "sync":
|
||||
return run_sync(payload)
|
||||
if kind == "diary_write":
|
||||
return run_diary_write(payload)
|
||||
if kind == "mcp_tool":
|
||||
return run_mcp_tool(payload)
|
||||
return {"success": False, "error": f"unknown daemon job kind: {kind}", "exit_code": 2}
|
||||
|
||||
# Per-job env isolation: snapshot the backend/palace env vars and restore
|
||||
# them after the job so one job's _apply_backend / palace_path injection
|
||||
# can't leak into the next job in the same long-lived process.
|
||||
saved_env = {key: os.environ.get(key) for key in _PER_JOB_ENV}
|
||||
try:
|
||||
result, stdout, stderr = _capture(_run)
|
||||
finally:
|
||||
for key, value in saved_env.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
if result is None:
|
||||
result = {}
|
||||
if not isinstance(result, dict):
|
||||
result = {"success": True, "value": result}
|
||||
result.setdefault("success", True)
|
||||
result.setdefault("exit_code", 0 if result.get("success") else 1)
|
||||
if stdout:
|
||||
result["stdout"] = stdout
|
||||
if stderr:
|
||||
result["stderr"] = stderr
|
||||
return result
|
||||
|
||||
|
||||
def run_mine(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Run the same mine operation as the CLI, without daemon transport concerns."""
|
||||
palace_path = os.path.abspath(
|
||||
os.path.expanduser(payload.get("palace_path") or MempalaceConfig().palace_path)
|
||||
)
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = palace_path
|
||||
_apply_backend(payload.get("backend"))
|
||||
|
||||
source = payload.get("source") or payload.get("dir")
|
||||
mode = payload.get("mode") or "projects"
|
||||
wing = payload.get("wing")
|
||||
agent = payload.get("agent") or "mempalace"
|
||||
limit = int(payload.get("limit") or 0)
|
||||
dry_run = bool(payload.get("dry_run"))
|
||||
|
||||
if payload.get("redetect_origin"):
|
||||
from .cli import _run_pass_zero
|
||||
|
||||
_run_pass_zero(project_dir=source, palace_dir=palace_path, llm_provider=None)
|
||||
|
||||
from .palace import MineAlreadyRunning, MineValidationError
|
||||
|
||||
try:
|
||||
if mode == "convos":
|
||||
from .convo_miner import mine_convos
|
||||
|
||||
mine_convos(
|
||||
convo_dir=source,
|
||||
palace_path=palace_path,
|
||||
wing=wing,
|
||||
agent=agent,
|
||||
limit=limit,
|
||||
dry_run=dry_run,
|
||||
extract_mode=payload.get("extract") or "exchange",
|
||||
)
|
||||
elif mode == "extract":
|
||||
from .format_miner import mine_formats
|
||||
|
||||
mine_formats(
|
||||
format_dir=source,
|
||||
palace_path=palace_path,
|
||||
wing=wing,
|
||||
agent=agent,
|
||||
limit=limit,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
elif mode == "projects":
|
||||
include_ignored = payload.get("include_ignored") or []
|
||||
from .miner import mine
|
||||
|
||||
mine(
|
||||
project_dir=source,
|
||||
palace_path=palace_path,
|
||||
wing_override=wing,
|
||||
agent=agent,
|
||||
limit=limit,
|
||||
dry_run=dry_run,
|
||||
respect_gitignore=not bool(payload.get("no_gitignore")),
|
||||
include_ignored=include_ignored,
|
||||
max_chunks_per_file=payload.get("max_chunks_per_file"),
|
||||
)
|
||||
else:
|
||||
return {"success": False, "error": f"invalid mine mode: {mode}", "exit_code": 2}
|
||||
except MineAlreadyRunning as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"error_class": "LockHeldByOtherProcess",
|
||||
"exit_code": 1,
|
||||
}
|
||||
except MineValidationError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"error_class": "MineValidationError",
|
||||
"exit_code": 1,
|
||||
}
|
||||
except SystemExit as exc:
|
||||
code = exc.code if isinstance(exc.code, int) else 1
|
||||
return {
|
||||
"success": code == 0,
|
||||
"error": str(exc),
|
||||
"error_class": "SystemExit",
|
||||
"exit_code": code,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": f"mine failed: {exc}", "exit_code": 1}
|
||||
|
||||
return {"success": True, "kind": "mine", "mode": mode, "dry_run": dry_run, "exit_code": 0}
|
||||
|
||||
|
||||
def run_sync(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Run sync and render the same operator-facing summary shape as the CLI."""
|
||||
palace_path = os.path.abspath(
|
||||
os.path.expanduser(payload.get("palace_path") or MempalaceConfig().palace_path)
|
||||
)
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = palace_path
|
||||
_apply_backend(payload.get("backend"))
|
||||
|
||||
from .backends import detect_backend_for_path
|
||||
from .palace import MineAlreadyRunning, _backend_artifact_label, resolve_backend_name
|
||||
|
||||
if not os.path.isdir(palace_path):
|
||||
print(f"\n No palace found at {palace_path}")
|
||||
return {"success": True, "exit_code": 0}
|
||||
|
||||
try:
|
||||
backend_name = resolve_backend_name(palace_path)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not resolve palace backend: {exc}",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
if detect_backend_for_path(palace_path) is None:
|
||||
print(
|
||||
f"\n Palace dir at {palace_path} exists but has no "
|
||||
f"{_backend_artifact_label(backend_name)} yet."
|
||||
)
|
||||
print(" Run: mempalace mine <dir>")
|
||||
return {"success": True, "exit_code": 0}
|
||||
|
||||
project_dirs = []
|
||||
if payload.get("dir"):
|
||||
project_dirs.append(os.path.expanduser(str(payload["dir"])))
|
||||
project_dirs.extend(os.path.expanduser(str(root)) for root in payload.get("root") or [])
|
||||
project_dirs = project_dirs or None
|
||||
dry_run = bool(payload.get("dry_run", True))
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" MemPalace Sync — Gitignore-aware drawer prune")
|
||||
print(f"{'=' * 55}")
|
||||
print(f" Palace: {palace_path}")
|
||||
if payload.get("wing"):
|
||||
print(f" Wing: {payload['wing']}")
|
||||
if project_dirs:
|
||||
for project_dir in project_dirs:
|
||||
print(f" Project: {project_dir}")
|
||||
print(
|
||||
" Mode: DRY RUN (no deletions)" if dry_run else " Mode: APPLY (deleting drawers)"
|
||||
)
|
||||
print(f"{'-' * 55}\n")
|
||||
|
||||
try:
|
||||
from .sync import sync_palace
|
||||
from .wal import _wal_log
|
||||
|
||||
report = sync_palace(
|
||||
palace_path=palace_path,
|
||||
project_dirs=project_dirs,
|
||||
wing=payload.get("wing"),
|
||||
dry_run=dry_run,
|
||||
wal_log=_wal_log,
|
||||
)
|
||||
except MineAlreadyRunning as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"error_class": "LockHeldByOtherProcess",
|
||||
"exit_code": 1,
|
||||
}
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc), "exit_code": 2}
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": f"sync failed: {exc}", "exit_code": 1}
|
||||
|
||||
removed_suffix = "(would remove)" if dry_run else "(removed)"
|
||||
print(f" Scanned: {report['scanned']}")
|
||||
print(f" Kept: {report['kept']}")
|
||||
print(f" Gitignored: {report['gitignored']} {removed_suffix}")
|
||||
print(f" Missing: {report['missing']} {removed_suffix}")
|
||||
print(f" No source: {report['no_source']} (kept)")
|
||||
print(f" Out of scope: {report['out_of_scope']} (kept)")
|
||||
|
||||
by_source = report.get("by_source") or {}
|
||||
if by_source:
|
||||
top = sorted(by_source.items(), key=lambda kv: -kv[1])[:5]
|
||||
label = "Top sources to remove" if dry_run else "Top sources removed"
|
||||
print(f"\n {label}:")
|
||||
for src, n in top:
|
||||
print(f" {src} ({n})")
|
||||
|
||||
if dry_run:
|
||||
if report["gitignored"] + report["missing"] > 0:
|
||||
print("\n Re-run with --apply to commit these deletions.")
|
||||
else:
|
||||
print(
|
||||
f"\n Removed {report['removed_drawers']} drawers, {report['removed_closets']} closets."
|
||||
)
|
||||
|
||||
print(f"\n{'=' * 55}\n")
|
||||
return {"success": True, "report": report, "exit_code": 0}
|
||||
|
||||
|
||||
def run_diary_write(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
palace_path = payload.get("palace_path")
|
||||
if palace_path:
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = os.path.abspath(os.path.expanduser(palace_path))
|
||||
_apply_backend(payload.get("backend"))
|
||||
|
||||
from .mcp_server import tool_diary_write
|
||||
|
||||
result = tool_diary_write(
|
||||
agent_name=payload.get("agent_name") or "mempalace",
|
||||
entry=payload.get("entry") or "",
|
||||
topic=payload.get("topic") or "general",
|
||||
wing=payload.get("wing") or "",
|
||||
)
|
||||
result.setdefault("exit_code", 0 if result.get("success") else 1)
|
||||
return result
|
||||
|
||||
|
||||
def run_mcp_tool(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Execute an MCP tool by name over the daemon queue.
|
||||
|
||||
The daemon is a durable, retried write surface — not a general MCP transport.
|
||||
Restrict ``mcp_tool`` to write-classified tools only: read tools would
|
||||
exfiltrate verbatim palace content into the queue DB and the job result
|
||||
(stored world-readable-by-default without the perms fix, and returned over
|
||||
/jobs), and maintenance tools already have their own dedicated kinds
|
||||
(mine/sync). No internal caller currently uses ``mcp_tool``; this allowlist
|
||||
bounds the blast radius of the generic escape hatch.
|
||||
"""
|
||||
name = payload.get("name")
|
||||
arguments = payload.get("arguments") or {}
|
||||
if not isinstance(arguments, dict):
|
||||
return {"success": False, "error": "arguments must be an object", "exit_code": 2}
|
||||
classification = classify_tool(name) if name else "unknown"
|
||||
if classification != "write":
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"daemon mcp_tool only accepts write tools; {name!r} is {classification}",
|
||||
"exit_code": 2,
|
||||
}
|
||||
from .mcp_server import TOOLS
|
||||
|
||||
if name not in TOOLS:
|
||||
return {"success": False, "error": f"unknown MCP tool: {name}", "exit_code": 2}
|
||||
result = TOOLS[name]["handler"](**arguments)
|
||||
if isinstance(result, dict):
|
||||
# Several write tools signal failure with a bare {"error": ...} and no
|
||||
# explicit success flag (e.g. tool_create_tunnel / tool_delete_tunnel
|
||||
# validation paths). Infer failure from the "error" key so the daemon
|
||||
# does not persist a failed write as succeeded with exit_code 0.
|
||||
if "success" not in result:
|
||||
result["success"] = "error" not in result
|
||||
result.setdefault("exit_code", 0 if result.get("success") else 1)
|
||||
return result
|
||||
return {"success": True, "value": result, "exit_code": 0}
|
||||
|
||||
|
||||
def print_job_result(result: dict[str, Any]) -> int:
|
||||
"""Replay captured daemon job output and return the intended process exit code."""
|
||||
stdout = result.get("stdout")
|
||||
stderr = result.get("stderr")
|
||||
if stdout:
|
||||
print(stdout, end="")
|
||||
if stderr:
|
||||
print(stderr, end="", file=sys.stderr)
|
||||
if not result.get("success", True) and result.get("error") and not stderr:
|
||||
print(f"mempalace: {result['error']}", file=sys.stderr)
|
||||
return int(result.get("exit_code", 0 if result.get("success", True) else 1) or 0)
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the MemPalace package version."""
|
||||
|
||||
__version__ = "3.4.1"
|
||||
__version__ = "3.6.0"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Side-effect-free write-ahead log for MemPalace write operations.
|
||||
|
||||
This lives in its own module so callers that only need WAL audit logging — the
|
||||
CLI ``sync`` path and the daemon's ``service`` layer — can obtain ``_wal_log``
|
||||
without importing :mod:`mempalace.mcp_server`. Importing ``mcp_server`` runs its
|
||||
module-level stdio protection (``os.dup2(2, 1)`` and ``sys.stdout = sys.stderr``,
|
||||
required so the MCP stdio JSON stream isn't corrupted by C-level library
|
||||
banners). In a non-MCP process — e.g. the daemon worker or ``mempalace sync`` —
|
||||
that redirect is an unwanted import side effect that misroutes operator output,
|
||||
so the WAL machinery is kept here, free of any such side effects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WAL_FILE = Path(os.path.expanduser("~/.mempalace/wal")) / "write_log.jsonl"
|
||||
_WAL_INITIALIZED_DIR = None
|
||||
|
||||
# Keys whose values should be redacted in WAL entries to avoid logging sensitive content
|
||||
_WAL_REDACT_KEYS = frozenset(
|
||||
{"content", "content_preview", "document", "entry", "entry_preview", "query", "text"}
|
||||
)
|
||||
|
||||
|
||||
def _ensure_wal() -> None:
|
||||
"""Create (and re-harden) the WAL directory lazily, on the first write.
|
||||
|
||||
This must NOT run at import time: a user who removed ``~/.mempalace`` has
|
||||
engaged the documented kill-switch (``hooks_cli._palace_root_exists()``,
|
||||
#1305), and recreating the directory just by importing this module would
|
||||
silently re-arm the autosave/mining hooks they disabled (#1676). Creating
|
||||
it on the first real write keeps the kill-switch contract intact.
|
||||
|
||||
It is deliberately not gated on ``_palace_root_exists()``: by the time a
|
||||
write reaches here the palace is already being recreated by the ChromaDB/KG
|
||||
layer regardless, so gating would only drop audit records, not prevent
|
||||
recreation. Runtime kill-switch enforcement for MCP writes is the broader
|
||||
question tracked in #504.
|
||||
|
||||
Hardening is attempted once per directory and the path cached in
|
||||
``_WAL_INITIALIZED_DIR`` regardless of outcome (keyed on the path, so a
|
||||
test repointing ``_WAL_FILE`` re-initialises), so a persistent failure on a
|
||||
restricted filesystem does not retry on every write. ``mkdir`` runs only
|
||||
when the initial ``chmod`` raises ``FileNotFoundError`` (EAFP). The parent
|
||||
``~/.mempalace`` keeps its umask mode, like the other palace directories;
|
||||
the WAL file is created atomically with mode 0o600 by ``_wal_log``.
|
||||
"""
|
||||
global _WAL_INITIALIZED_DIR
|
||||
wal_dir = _WAL_FILE.parent
|
||||
if _WAL_INITIALIZED_DIR == wal_dir:
|
||||
return
|
||||
try:
|
||||
wal_dir.chmod(0o700)
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
wal_dir.mkdir(parents=True, exist_ok=True)
|
||||
wal_dir.chmod(0o700)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
# Cache regardless of outcome: one attempt per directory, so a persistent
|
||||
# chmod/mkdir failure (restricted FS) is not retried on every write.
|
||||
_WAL_INITIALIZED_DIR = wal_dir
|
||||
|
||||
|
||||
def _wal_log(operation: str, params: dict, result: dict = None):
|
||||
"""Append a write operation to the write-ahead log."""
|
||||
# Redact sensitive content from params before logging
|
||||
safe_params = {}
|
||||
for k, v in params.items():
|
||||
if k in _WAL_REDACT_KEYS:
|
||||
safe_params[k] = f"[REDACTED {len(v)} chars]" if isinstance(v, str) else "[REDACTED]"
|
||||
else:
|
||||
safe_params[k] = v
|
||||
entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"operation": operation,
|
||||
"params": safe_params,
|
||||
"result": result,
|
||||
}
|
||||
try:
|
||||
# Dir setup shares the append's exception handler below: any WAL
|
||||
# failure is logged and non-fatal, never crashing the tool call.
|
||||
_ensure_wal()
|
||||
fd = os.open(str(_WAL_FILE), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
||||
with os.fdopen(fd, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, default=str) + "\n")
|
||||
except Exception as e:
|
||||
logger.error(f"WAL write failed: {e}")
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Shared daemon-routing policy for MemPalace write callers.
|
||||
|
||||
The policy is deliberately transport-agnostic. Hook and CLI consumers decide
|
||||
whether a daemon is already available and whether they are allowed to start
|
||||
one; this module turns those facts plus a policy into one explicit route.
|
||||
|
||||
This module changes no caller defaults by itself. Hook and CLI adoption are
|
||||
separate follow-up PRs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
class WriteRoutingError(ValueError):
|
||||
"""Raised when a write-routing policy is invalid or cannot be applied."""
|
||||
|
||||
|
||||
class WriteRoutingPolicy(str, Enum):
|
||||
"""User-selected policy for routine write operations."""
|
||||
|
||||
DIRECT = "direct"
|
||||
PREFER = "prefer"
|
||||
REQUIRE = "require"
|
||||
|
||||
|
||||
class WriteRoutingTarget(str, Enum):
|
||||
"""Concrete route selected for one write operation."""
|
||||
|
||||
DIRECT = "direct"
|
||||
DAEMON = "daemon"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutingPolicyCandidate:
|
||||
"""One precedence-ordered source for a routing policy."""
|
||||
|
||||
source: str
|
||||
value: Any
|
||||
legacy_boolean: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedWriteRoutingPolicy:
|
||||
"""A normalized policy plus the source that selected it."""
|
||||
|
||||
policy: WriteRoutingPolicy
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriteRoutingDecision:
|
||||
"""Concrete routing decision for a single operation."""
|
||||
|
||||
policy: WriteRoutingPolicy
|
||||
target: WriteRoutingTarget
|
||||
auto_start_daemon: bool
|
||||
reason: str
|
||||
|
||||
@property
|
||||
def use_daemon(self) -> bool:
|
||||
"""Whether this operation should use the daemon."""
|
||||
return self.target is WriteRoutingTarget.DAEMON
|
||||
|
||||
@property
|
||||
def blocked(self) -> bool:
|
||||
"""Whether the operation must stop instead of writing directly."""
|
||||
return self.target is WriteRoutingTarget.BLOCKED
|
||||
|
||||
|
||||
_LEGACY_TRUE = {"1", "true", "yes", "on", "daemon"}
|
||||
_LEGACY_FALSE = {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def parse_write_routing_policy(
|
||||
value: Any,
|
||||
*,
|
||||
legacy_boolean: bool = False,
|
||||
) -> WriteRoutingPolicy:
|
||||
"""Normalize one routing-policy value.
|
||||
|
||||
New policy settings accept only ``direct``, ``prefer``, and ``require``.
|
||||
|
||||
Legacy boolean settings additionally map truthy values to ``prefer`` and
|
||||
falsy values to ``direct`` so existing ``hooks.daemon`` configurations
|
||||
retain their historical behavior.
|
||||
"""
|
||||
|
||||
if isinstance(value, WriteRoutingPolicy):
|
||||
return value
|
||||
|
||||
if legacy_boolean and isinstance(value, bool):
|
||||
return WriteRoutingPolicy.PREFER if value else WriteRoutingPolicy.DIRECT
|
||||
|
||||
if legacy_boolean and isinstance(value, int) and not isinstance(value, bool):
|
||||
if value == 1:
|
||||
return WriteRoutingPolicy.PREFER
|
||||
if value == 0:
|
||||
return WriteRoutingPolicy.DIRECT
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise WriteRoutingError("write routing policy must be one of: direct, prefer, require")
|
||||
|
||||
normalized = value.strip().lower()
|
||||
|
||||
try:
|
||||
return WriteRoutingPolicy(normalized)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if legacy_boolean:
|
||||
if normalized in _LEGACY_TRUE:
|
||||
return WriteRoutingPolicy.PREFER
|
||||
if normalized in _LEGACY_FALSE:
|
||||
return WriteRoutingPolicy.DIRECT
|
||||
|
||||
raise WriteRoutingError(
|
||||
f"invalid write routing policy {value!r}; expected direct, prefer, or require"
|
||||
)
|
||||
|
||||
|
||||
def resolve_write_routing_policy(
|
||||
candidates: Iterable[RoutingPolicyCandidate],
|
||||
*,
|
||||
default: WriteRoutingPolicy = WriteRoutingPolicy.DIRECT,
|
||||
) -> ResolvedWriteRoutingPolicy:
|
||||
"""Return the first configured policy from ordered policy sources."""
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.value is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
policy = parse_write_routing_policy(
|
||||
candidate.value,
|
||||
legacy_boolean=candidate.legacy_boolean,
|
||||
)
|
||||
except WriteRoutingError as exc:
|
||||
raise WriteRoutingError(f"{candidate.source}: {exc}") from exc
|
||||
|
||||
return ResolvedWriteRoutingPolicy(
|
||||
policy=policy,
|
||||
source=candidate.source,
|
||||
)
|
||||
|
||||
return ResolvedWriteRoutingPolicy(
|
||||
policy=default,
|
||||
source="default",
|
||||
)
|
||||
|
||||
|
||||
def choose_write_route(
|
||||
policy: WriteRoutingPolicy,
|
||||
*,
|
||||
daemon_available: bool,
|
||||
daemon_can_start: bool,
|
||||
) -> WriteRoutingDecision:
|
||||
"""Choose direct, daemon, or blocked for one routine write.
|
||||
|
||||
``daemon_can_start`` is normally false for latency-sensitive hooks and
|
||||
true for interactive CLI commands.
|
||||
|
||||
The key safety guarantee is that ``require`` never degrades to a direct
|
||||
write when the daemon is unavailable.
|
||||
"""
|
||||
|
||||
policy = parse_write_routing_policy(policy)
|
||||
|
||||
if policy is WriteRoutingPolicy.DIRECT:
|
||||
return WriteRoutingDecision(
|
||||
policy=policy,
|
||||
target=WriteRoutingTarget.DIRECT,
|
||||
auto_start_daemon=False,
|
||||
reason="policy-direct",
|
||||
)
|
||||
|
||||
if daemon_available:
|
||||
return WriteRoutingDecision(
|
||||
policy=policy,
|
||||
target=WriteRoutingTarget.DAEMON,
|
||||
auto_start_daemon=False,
|
||||
reason="daemon-available",
|
||||
)
|
||||
|
||||
if daemon_can_start:
|
||||
return WriteRoutingDecision(
|
||||
policy=policy,
|
||||
target=WriteRoutingTarget.DAEMON,
|
||||
auto_start_daemon=True,
|
||||
reason="daemon-auto-start",
|
||||
)
|
||||
|
||||
if policy is WriteRoutingPolicy.PREFER:
|
||||
return WriteRoutingDecision(
|
||||
policy=policy,
|
||||
target=WriteRoutingTarget.DIRECT,
|
||||
auto_start_daemon=False,
|
||||
reason="daemon-unavailable-fallback",
|
||||
)
|
||||
|
||||
return WriteRoutingDecision(
|
||||
policy=policy,
|
||||
target=WriteRoutingTarget.BLOCKED,
|
||||
auto_start_daemon=False,
|
||||
reason="daemon-required-unavailable",
|
||||
)
|
||||
+19
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mempalace"
|
||||
version = "3.4.1"
|
||||
version = "3.6.0"
|
||||
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"
|
||||
@@ -60,6 +60,7 @@ mempalace-mcp = "mempalace.mcp_server:main"
|
||||
|
||||
[project.entry-points."mempalace.backends"]
|
||||
chroma = "mempalace.backends.chroma:ChromaBackend"
|
||||
milvus = "mempalace.backends.milvus:MilvusBackend"
|
||||
pgvector = "mempalace.backends.pgvector:PgVectorBackend"
|
||||
qdrant = "mempalace.backends.qdrant:QdrantBackend"
|
||||
sqlite_exact = "mempalace.backends.sqlite_exact:SQLiteExactBackend"
|
||||
@@ -74,7 +75,11 @@ sqlite_exact = "mempalace.backends.sqlite_exact:SQLiteExactBackend"
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
"ruff==0.15.15",
|
||||
# Retries known-transient ChromaDB-on-Windows HNSW compaction failures in CI
|
||||
# (wired only into the test-windows job in ci.yml, scoped to the specific
|
||||
# error). Local/Linux/macOS runs never rerun, so real failures stay loud.
|
||||
"pytest-rerunfailures>=12.0",
|
||||
"ruff==0.15.20",
|
||||
"psutil>=5.9",
|
||||
# Property-based testing — generates hundreds of random inputs per
|
||||
# test to find counterexamples the hand-written positive tests miss.
|
||||
@@ -95,6 +100,13 @@ dev = [
|
||||
"mypy>=1.0",
|
||||
]
|
||||
spellcheck = ["autocorrect>=2.0"]
|
||||
# Opt-in Milvus backend. Defaults to per-palace Milvus Lite
|
||||
# (`<palace>/milvus.db`); set MEMPALACE_MILVUS_URI and
|
||||
# MEMPALACE_MILVUS_TOKEN to point at Milvus server or Zilliz Cloud.
|
||||
milvus = [
|
||||
"pymilvus>=3.0.0",
|
||||
"milvus-lite>=3.0; python_version >= '3.10' and sys_platform != 'win32'",
|
||||
]
|
||||
# Opt-in Postgres + pgvector backend. Only the psycopg driver is needed on the
|
||||
# client; the server must have the `vector` extension available. Selected via
|
||||
# MEMPALACE_BACKEND=pgvector / --backend pgvector; never required for the
|
||||
@@ -131,7 +143,11 @@ extract = [
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
"ruff==0.15.15",
|
||||
# Retries known-transient ChromaDB-on-Windows HNSW compaction failures in CI
|
||||
# (wired only into the test-windows job in ci.yml, scoped to the specific
|
||||
# error). Local/Linux/macOS runs never rerun, so real failures stay loud.
|
||||
"pytest-rerunfailures>=12.0",
|
||||
"ruff==0.15.20",
|
||||
"psutil>=5.9",
|
||||
"hypothesis>=6.0",
|
||||
"pre-commit>=3.0",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill ``authored_at`` onto existing conversation drawers.
|
||||
|
||||
New mines stamp ``authored_at`` automatically (see ``convo_miner._extract_authored_at``),
|
||||
but drawers mined before that change only have ``filed_at`` (ingest time). Re-mining does
|
||||
NOT fix them: the scanner skips files already mined at the current ``NORMALIZE_VERSION``.
|
||||
|
||||
This migration updates the affected drawers IN PLACE — metadata only, embeddings are left
|
||||
untouched, so there is no re-embedding cost. It is idempotent (drawers already correct are
|
||||
skipped) and safe to re-run. It only touches ``ingest_mode == "convos"`` drawers; markdown
|
||||
drawers have no per-line timestamps and keep their ``filed_at`` fallback.
|
||||
|
||||
Drawers whose source transcript is no longer on disk are left as-is (they keep falling back
|
||||
to ``filed_at``), so point ``--sessions`` at the directories that still hold your ``.jsonl``
|
||||
transcripts (e.g. ``~/.claude`` and ``~/.codex``).
|
||||
|
||||
Usage (dry-run prints what would change; pass --apply to write):
|
||||
|
||||
python scripts/backfill_authored_at.py \
|
||||
--palace ~/.mempalace/palace \
|
||||
--sessions ~/.claude --sessions ~/.codex [--apply]
|
||||
|
||||
In Docker (the MCP image), mount the volume and your session dirs read-only:
|
||||
|
||||
docker run --rm \
|
||||
-v mempalace-data:/data \
|
||||
-v ~/.claude:/sessions/claude:ro -v ~/.codex:/sessions/codex:ro \
|
||||
-v "$PWD/scripts/backfill_authored_at.py:/tmp/backfill.py:ro" \
|
||||
--entrypoint /app/.venv/bin/python mempalace:local \
|
||||
/tmp/backfill.py --palace /data/.mempalace/palace \
|
||||
--sessions /sessions/claude --sessions /sessions/codex --apply
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
|
||||
import chromadb
|
||||
|
||||
from mempalace.convo_miner import _extract_authored_at
|
||||
|
||||
COLLECTION = "mempalace_drawers"
|
||||
PAGE = 2000
|
||||
BATCH = 1000
|
||||
|
||||
|
||||
def _index_sessions(session_dirs):
|
||||
"""Map ``basename.jsonl -> realpath`` for every transcript under the given dirs."""
|
||||
index = {}
|
||||
for root in session_dirs:
|
||||
for f in glob.glob(os.path.join(os.path.expanduser(root), "**", "*.jsonl"), recursive=True):
|
||||
index.setdefault(os.path.basename(f), f)
|
||||
return index
|
||||
|
||||
|
||||
def backfill_authored_at(collection, session_dirs, apply=False):
|
||||
"""Stamp ``authored_at`` on convos drawers from their source transcript timestamps.
|
||||
|
||||
Returns a stats dict: ``scanned``, ``updated``, ``resolved_files``, ``unresolved_files``.
|
||||
"""
|
||||
index = _index_sessions(session_dirs)
|
||||
cache = {}
|
||||
unresolved = set()
|
||||
pending_ids, pending_metas = [], []
|
||||
scanned = updated = 0
|
||||
|
||||
def flush():
|
||||
nonlocal pending_ids, pending_metas, updated
|
||||
if pending_ids and apply:
|
||||
collection.update(ids=pending_ids, metadatas=pending_metas)
|
||||
updated += len(pending_ids)
|
||||
pending_ids, pending_metas = [], []
|
||||
|
||||
offset = 0
|
||||
while True:
|
||||
res = collection.get(
|
||||
where={"ingest_mode": "convos"}, include=["metadatas"], limit=PAGE, offset=offset
|
||||
)
|
||||
ids = res["ids"]
|
||||
if not ids:
|
||||
break
|
||||
for drawer_id, meta in zip(ids, res["metadatas"]):
|
||||
scanned += 1
|
||||
basename = os.path.basename(meta.get("source_file") or "")
|
||||
if basename in cache:
|
||||
authored = cache[basename]
|
||||
else:
|
||||
path = index.get(basename)
|
||||
authored = _extract_authored_at(path) if path else None
|
||||
cache[basename] = authored
|
||||
if path is None and basename:
|
||||
unresolved.add(basename)
|
||||
if authored and meta.get("authored_at") != authored:
|
||||
new_meta = dict(meta)
|
||||
new_meta["authored_at"] = authored
|
||||
pending_ids.append(drawer_id)
|
||||
pending_metas.append(new_meta)
|
||||
if len(pending_ids) >= BATCH:
|
||||
flush()
|
||||
offset += len(ids)
|
||||
flush()
|
||||
return {
|
||||
"scanned": scanned,
|
||||
"updated": updated,
|
||||
"resolved_files": sum(1 for v in cache.values() if v),
|
||||
"unresolved_files": len(unresolved),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--palace", required=True, help="Path to the ChromaDB palace dir")
|
||||
parser.add_argument(
|
||||
"--sessions",
|
||||
action="append",
|
||||
default=[],
|
||||
required=True,
|
||||
help="Directory holding .jsonl transcripts (repeatable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Write changes (default is a dry run that only reports counts)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
client = chromadb.PersistentClient(path=os.path.expanduser(args.palace))
|
||||
collection = client.get_collection(COLLECTION)
|
||||
stats = backfill_authored_at(collection, args.sessions, apply=args.apply)
|
||||
mode = "APPLIED" if args.apply else "DRY-RUN (use --apply to write)"
|
||||
print(
|
||||
f"{mode}: scanned={stats['scanned']} updated={stats['updated']} "
|
||||
f"resolved_files={stats['resolved_files']} unresolved_files={stats['unresolved_files']}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -90,6 +90,20 @@ question — not a system prompt or pasted conversation) plus optional
|
||||
- **MCP error / server down.** Surface the error and suggest the user
|
||||
run `mempalace status` or re-run `/mempalace-init`. Never fall back to
|
||||
guessing.
|
||||
- **Palace index corrupt / compactor error.** If the server reports an
|
||||
HNSW segment-writer error, a ChromaDB compaction failure, or stays
|
||||
"Not connected" after a write, the vector index is out of sync with
|
||||
`chroma.sqlite3` while the drawer rows remain intact. Tell the user to
|
||||
stop the server and rebuild from SQLite — do not re-mine, which drops
|
||||
MCP-added drawers and diary entries (#1843):
|
||||
|
||||
```bash
|
||||
mempalace repair --mode from-sqlite --archive-existing --yes
|
||||
mempalace repair-status
|
||||
```
|
||||
|
||||
Do not attempt an in-process repair from the agent. Full steps are in
|
||||
the shared protocol's "Recovering a corrupt index" section.
|
||||
- **Conflicting facts.** Trust the knowledge graph's time-valid answer;
|
||||
invalidate-then-add rather than overwriting silently.
|
||||
|
||||
|
||||
@@ -42,6 +42,6 @@ search-before-answer so the agent reads the palace instead of guessing.
|
||||
|
||||
## Cursor-specific notes
|
||||
|
||||
- The `mempalace-mcp` server is auto-registered by this plugin. Once installed, all 33 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, etc.) are available to the agent without any further configuration.
|
||||
- The `mempalace-mcp` server is auto-registered by this plugin. Once installed, all 36 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, etc.) are available to the agent without any further configuration.
|
||||
- For automatic background saving every N agent turns plus session-start memory recall, also install the Cursor hooks separately by running `hooks/cursor/install.sh --scope user` from a cloned MemPalace repo. See [`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) for the full walkthrough.
|
||||
- The recommended `agent_name` when calling `mempalace_diary_write` from a Cursor session is `cursor-ide` (matches the precedent of `claude-code` and `codex`).
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Shared helpers: create minimal valid palace-marker SQLite files for tests.
|
||||
|
||||
Many tests want to stand up "a chroma / sqlite_exact palace" cheaply —
|
||||
historically they did this with ``(path / "<marker>.sqlite3").touch()`` or
|
||||
``write_bytes(b"")``, relying on the backend ``detect()`` methods' old
|
||||
``os.path.isfile()`` semantics. Post-#1893, both ``ChromaBackend.detect()``
|
||||
and ``SQLiteExactBackend.detect()`` require a valid SQLite magic header, so
|
||||
the empty stand-in no longer registers. These helpers create the minimum
|
||||
required to make detection fire without standing up a full palace.
|
||||
|
||||
This module is intentionally not a ``test_*`` file: it ships utilities, not
|
||||
tests.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
|
||||
def _write_minimal_sqlite_file(db_path: Path) -> None:
|
||||
"""Write a valid SQLite magic header at ``db_path``.
|
||||
|
||||
Writing any statement is sufficient to land the 16-byte
|
||||
``SQLite format 3\\x00`` magic prefix that the backend ``detect()``
|
||||
methods check.
|
||||
"""
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute("CREATE TABLE _detect_smoke(x)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def make_minimal_chroma_sqlite(palace_path: Union[Path, str]) -> Path:
|
||||
"""Create ``<palace_path>/chroma.sqlite3`` with a valid SQLite header.
|
||||
|
||||
Returns the path to the file. Backs
|
||||
:py:meth:`mempalace.backends.chroma.ChromaBackend.detect`.
|
||||
"""
|
||||
|
||||
db_path = Path(palace_path) / "chroma.sqlite3"
|
||||
_write_minimal_sqlite_file(db_path)
|
||||
return db_path
|
||||
|
||||
|
||||
def make_minimal_sqlite_exact_sqlite(palace_path: Union[Path, str]) -> Path:
|
||||
"""Create ``<palace_path>/sqlite_exact.sqlite3`` with a valid SQLite header.
|
||||
|
||||
Returns the path to the file. Backs
|
||||
:py:meth:`mempalace.backends.sqlite_exact.SQLiteExactBackend.detect`.
|
||||
"""
|
||||
|
||||
db_path = Path(palace_path) / "sqlite_exact.sqlite3"
|
||||
_write_minimal_sqlite_file(db_path)
|
||||
return db_path
|
||||
+39
-1
@@ -77,6 +77,17 @@ def _reset_mcp_cache():
|
||||
if hasattr(mcp_server, "_kg_by_path"):
|
||||
mcp_server._kg_by_path.clear()
|
||||
|
||||
# Close (not just dereference) the cached chromadb client so its
|
||||
# rust-side file handles are released; on Windows a bare deref
|
||||
# leaves them locked and leaks across the session (#1128).
|
||||
cached_client = getattr(mcp_server, "_client_cache", None)
|
||||
if cached_client is not None:
|
||||
close = getattr(cached_client, "close", None)
|
||||
if callable(close):
|
||||
try:
|
||||
close()
|
||||
except Exception:
|
||||
pass
|
||||
mcp_server._client_cache = None
|
||||
mcp_server._collection_cache = None
|
||||
if hasattr(mcp_server, "_collection_cache_backend"):
|
||||
@@ -97,6 +108,29 @@ def _reset_mcp_cache():
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
# Release chromadb clients opened through the backend layer. Many tests
|
||||
# reach the store via palace.get_collection() (sweep, repair, CLI, ...),
|
||||
# which caches one PersistentClient per palace_path on the long-lived
|
||||
# backend singleton and never closes it. chromadb frees the rust-side
|
||||
# SQLite/HNSW file handles only on client.close(); on POSIX the open
|
||||
# handles are harmless, but on Windows they stay locked and accumulate
|
||||
# across the session until a later test's HNSW segment write fails
|
||||
# (#1128 Windows CI). close_palace() closes the client and drops the
|
||||
# handle without marking the backend closed, so it stays reusable.
|
||||
try:
|
||||
from mempalace import palace as _palace
|
||||
|
||||
backend = getattr(_palace, "_DEFAULT_BACKEND", None)
|
||||
clients = getattr(backend, "_clients", None)
|
||||
if clients:
|
||||
for path in list(clients):
|
||||
try:
|
||||
backend.close_palace(path)
|
||||
except Exception:
|
||||
pass
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
_clear_cache()
|
||||
yield
|
||||
_clear_cache()
|
||||
@@ -154,7 +188,11 @@ def collection(palace_path):
|
||||
col = client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
|
||||
yield col
|
||||
client.delete_collection("mempalace_drawers")
|
||||
del client
|
||||
# close() (not a bare dereference) releases chromadb's rust-side SQLite/HNSW
|
||||
# file handles. On Windows a mere `del` leaves them locked, so the temp
|
||||
# palace cannot be removed and handles leak across the whole test session
|
||||
# until a later test's HNSW write fails (#1128 Windows CI).
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
+35
-3
@@ -178,12 +178,44 @@ def test_resolve_backend_priority_order(tmp_path):
|
||||
assert resolve_backend_for_palace() == "chroma"
|
||||
|
||||
|
||||
def test_chroma_detect_matches_palace_with_chroma_sqlite(tmp_path):
|
||||
(tmp_path / "chroma.sqlite3").write_bytes(b"")
|
||||
def test_chroma_detect_matches_palace_with_sqlite_header(tmp_path):
|
||||
"""A real SQLite database at ``<path>/chroma.sqlite3`` registers as chroma.
|
||||
|
||||
Uses ``sqlite3.connect`` + a write so the SQLite magic header is actually
|
||||
on disk — the only thing detection looks at.
|
||||
"""
|
||||
db_path = tmp_path / "chroma.sqlite3"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("CREATE TABLE detect_smoke(x)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
assert ChromaBackend.detect(str(tmp_path)) is True
|
||||
assert ChromaBackend.detect(str(tmp_path.parent)) is False
|
||||
|
||||
|
||||
def test_chroma_detect_rejects_empty_chroma_sqlite(tmp_path):
|
||||
"""A 0-byte ``chroma.sqlite3`` is not a chroma palace (closes #1893).
|
||||
|
||||
Bare ``sqlite3.connect()`` against a missing path leaves a 0-byte file
|
||||
behind because the SQLite header is written on the first statement, not
|
||||
on connect. Detection must reject that artifact so it cannot trip
|
||||
``BackendMismatchError`` against a real non-chroma backend marker in the
|
||||
same directory.
|
||||
"""
|
||||
(tmp_path / "chroma.sqlite3").write_bytes(b"")
|
||||
assert ChromaBackend.detect(str(tmp_path)) is False
|
||||
|
||||
|
||||
def test_chroma_detect_rejects_non_sqlite_file(tmp_path):
|
||||
"""A non-SQLite file at the ``chroma.sqlite3`` path is not chroma.
|
||||
|
||||
Defends against partial writes / garbage content / anything that lands at
|
||||
the canonical path but isn't actually a SQLite database.
|
||||
"""
|
||||
(tmp_path / "chroma.sqlite3").write_bytes(b"not a sqlite file" * 4)
|
||||
assert ChromaBackend.detect(str(tmp_path)) is False
|
||||
|
||||
|
||||
def test_chroma_lexical_search_uses_sqlite_fts_not_full_collection_scan(tmp_path):
|
||||
db_path = tmp_path / "chroma.sqlite3"
|
||||
conn = sqlite3.connect(db_path)
|
||||
@@ -1810,7 +1842,7 @@ def test_explain_ef_mismatch_recognizes_chromadb_conflict():
|
||||
assert msg is not None
|
||||
assert "/tmp/palace.db" in msg
|
||||
assert "MEMPALACE_EMBEDDING_MODEL" in msg
|
||||
assert "rebuild-index" in msg
|
||||
assert "mempalace --palace /tmp/palace.db repair rebuild-index" in msg
|
||||
|
||||
|
||||
def test_explain_ef_mismatch_returns_none_for_unrelated_errors():
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Integration tests for the authored_at backfill migration (scripts/)."""
|
||||
|
||||
import importlib.util
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import chromadb
|
||||
|
||||
# The migration ships as a script, not a package module; load it directly.
|
||||
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "backfill_authored_at.py"
|
||||
_spec = importlib.util.spec_from_file_location("backfill_authored_at", _SCRIPT)
|
||||
backfill_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(backfill_mod)
|
||||
|
||||
|
||||
def _collection():
|
||||
# Unique name per call: EphemeralClient shares one in-memory instance across the
|
||||
# process, so a fixed collection name would leak drawers between tests.
|
||||
client = chromadb.EphemeralClient()
|
||||
return client.create_collection(f"drawers_{uuid.uuid4().hex}")
|
||||
|
||||
|
||||
def _add(col, drawer_id, source_file, authored_at=None):
|
||||
meta = {"ingest_mode": "convos", "source_file": source_file, "filed_at": "2026-06-27T00:00:00"}
|
||||
if authored_at is not None:
|
||||
meta["authored_at"] = authored_at
|
||||
col.add(ids=[drawer_id], documents=["hello"], metadatas=[meta], embeddings=[[0.1, 0.2, 0.3]])
|
||||
|
||||
|
||||
def _transcript(dir_path, name, *timestamps):
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
f = dir_path / name
|
||||
f.write_text("".join(f'{{"timestamp": "{ts}"}}\n' for ts in timestamps))
|
||||
return f
|
||||
|
||||
|
||||
def test_backfill_sets_latest_timestamp(tmp_path):
|
||||
sessions = tmp_path / "claude"
|
||||
_transcript(sessions, "abc.jsonl", "2026-06-10T08:00:00.000Z", "2026-06-12T09:00:00.000Z")
|
||||
col = _collection()
|
||||
# Stored source_file uses an old mount prefix; resolution is by basename.
|
||||
_add(col, "d1", "/old/mount/abc.jsonl")
|
||||
|
||||
stats = backfill_mod.backfill_authored_at(col, [str(sessions)], apply=True)
|
||||
|
||||
assert stats["scanned"] == 1
|
||||
assert stats["updated"] == 1
|
||||
got = col.get(ids=["d1"], include=["metadatas"])["metadatas"][0]
|
||||
assert got["authored_at"] == "2026-06-12T09:00:00.000Z"
|
||||
|
||||
|
||||
def test_dry_run_writes_nothing(tmp_path):
|
||||
sessions = tmp_path / "claude"
|
||||
_transcript(sessions, "abc.jsonl", "2026-06-12T09:00:00.000Z")
|
||||
col = _collection()
|
||||
_add(col, "d1", "/old/mount/abc.jsonl")
|
||||
|
||||
stats = backfill_mod.backfill_authored_at(col, [str(sessions)], apply=False)
|
||||
|
||||
assert stats["updated"] == 1 # would update
|
||||
assert "authored_at" not in col.get(ids=["d1"], include=["metadatas"])["metadatas"][0]
|
||||
|
||||
|
||||
def test_idempotent_second_run_updates_nothing(tmp_path):
|
||||
sessions = tmp_path / "claude"
|
||||
_transcript(sessions, "abc.jsonl", "2026-06-12T09:00:00.000Z")
|
||||
col = _collection()
|
||||
_add(col, "d1", "/old/mount/abc.jsonl")
|
||||
|
||||
backfill_mod.backfill_authored_at(col, [str(sessions)], apply=True)
|
||||
stats2 = backfill_mod.backfill_authored_at(col, [str(sessions)], apply=True)
|
||||
|
||||
assert stats2["updated"] == 0
|
||||
|
||||
|
||||
def test_unresolved_transcript_is_left_alone(tmp_path):
|
||||
sessions = tmp_path / "claude"
|
||||
sessions.mkdir()
|
||||
col = _collection()
|
||||
_add(col, "d1", "/old/mount/missing.jsonl") # no file on disk
|
||||
|
||||
stats = backfill_mod.backfill_authored_at(col, [str(sessions)], apply=True)
|
||||
|
||||
assert stats["updated"] == 0
|
||||
assert stats["unresolved_files"] == 1
|
||||
assert "authored_at" not in col.get(ids=["d1"], include=["metadatas"])["metadatas"][0]
|
||||
@@ -19,8 +19,14 @@ HOOK_CONFIG = REPO_ROOT / ".claude-plugin" / "hooks" / "hooks.json"
|
||||
# timeout of 60s in mempalace/hooks_cli.py. The hook-level floor of 60
|
||||
# keeps the inner bound from being truncated, and the ceiling of 90
|
||||
# bounds the worst case at ~30s above that.
|
||||
# SessionEnd backgrounds all of its work in the shell wrapper — the foreground
|
||||
# only forks the detached child and returns in milliseconds — so its timeout is
|
||||
# a generous backstop on a near-instant operation, not a synchronous-work bound
|
||||
# like Stop/PreCompact. A bound is still required (#1465) so a wedged fork can
|
||||
# never fall back to the 600s command default.
|
||||
EVENT_TIMEOUT_BOUNDS: dict[str, tuple[int, int]] = {
|
||||
"Stop": (10, 30),
|
||||
"SessionEnd": (5, 30),
|
||||
"PreCompact": (60, 90),
|
||||
}
|
||||
|
||||
@@ -86,3 +92,22 @@ def test_no_unbounded_events_in_plugin_config(hook_config: dict) -> None:
|
||||
"Add a (floor, ceiling) entry to EVENT_TIMEOUT_BOUNDS in this test "
|
||||
"after deciding the worst-case freeze the event can tolerate."
|
||||
)
|
||||
|
||||
|
||||
def test_session_end_hook_uses_background_wrapper(hook_config: dict) -> None:
|
||||
"""Claude SessionEnd should use the backgrounding wrapper, not PreCompact."""
|
||||
events = hook_config.get("hooks", {})
|
||||
|
||||
assert "SessionEnd" in events
|
||||
assert "PreCompact" in events
|
||||
assert events["SessionEnd"] != events["PreCompact"]
|
||||
|
||||
commands = [
|
||||
hook["command"]
|
||||
for entry in events["SessionEnd"]
|
||||
for hook in entry.get("hooks", [])
|
||||
if hook.get("type") == "command"
|
||||
]
|
||||
|
||||
assert any("mempal-session-end-hook.sh" in command for command in commands)
|
||||
assert not any("mempal-precompact-hook.sh" in command for command in commands)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests for embedded-NUL sanitisation in the bulk-ingest write path.
|
||||
|
||||
Mirrors ``test_clean_lone_surrogates.py`` (issue #1235) for a sibling class of
|
||||
bad input: a document containing an embedded NUL character (U+0000).
|
||||
|
||||
Unlike a lone surrogate, a NUL-containing string is well-formed UTF-8 — it
|
||||
doesn't raise on ``.encode()``. But handing it to ChromaDB's SQLite/FTS5
|
||||
layer can corrupt the FTS5 inverted index for the *whole* collection
|
||||
(``PRAGMA quick_check`` reports "malformed inverted index for FTS5 table"),
|
||||
not just fail to store that one document. This is routine in mined Bash
|
||||
tool output — e.g. a reader racing a background writer, or genuine
|
||||
NUL-delimited/binary command output captured verbatim.
|
||||
|
||||
``sanitize_content`` (the MCP write-tool path) already rejects NUL bytes
|
||||
outright (``ValueError``) — that behaviour is unchanged and still tested
|
||||
here for completeness. The bulk ingest paths (miner, convo_miner, sweeper,
|
||||
diary_ingest) build documents without routing through that helper and reach
|
||||
``ChromaCollection`` directly, exactly as #1235 found for surrogates. This
|
||||
file covers the chokepoint fix that closes that gap: replace, not reject, so
|
||||
a NUL-containing chunk doesn't abort or corrupt an otherwise-good mine.
|
||||
"""
|
||||
|
||||
from mempalace.config import strip_nul_bytes
|
||||
|
||||
|
||||
# ── Unit tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestStripNulBytes:
|
||||
def test_passthrough_normal(self):
|
||||
assert strip_nul_bytes("hello world") == "hello world"
|
||||
assert strip_nul_bytes("你好世界") == "你好世界"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert strip_nul_bytes("") == ""
|
||||
|
||||
def test_replaces_single_nul(self):
|
||||
assert strip_nul_bytes("hello\x00world") == "hello�world"
|
||||
|
||||
def test_replaces_multiple_nuls(self):
|
||||
assert strip_nul_bytes("\x00\x00\x00") == "���"
|
||||
assert strip_nul_bytes("a\x00b\x00c") == "a�b�c"
|
||||
|
||||
def test_replaces_large_nul_run(self):
|
||||
"""The reporter-shaped case: a large contiguous NUL run inside
|
||||
otherwise-ordinary captured tool output."""
|
||||
dirty = "before-nuls " + ("\x00" * 10291) + " after-nuls"
|
||||
clean = strip_nul_bytes(dirty)
|
||||
assert "\x00" not in clean
|
||||
assert clean.startswith("before-nuls ")
|
||||
assert clean.endswith(" after-nuls")
|
||||
assert clean.count("�") == 10291
|
||||
|
||||
def test_preserves_real_content_around_nuls(self):
|
||||
assert (
|
||||
strip_nul_bytes("ordinary log line one\x00\x00\x00ordinary log line two")
|
||||
== "ordinary log line one���ordinary log line two"
|
||||
)
|
||||
|
||||
|
||||
# ── Existing sanitizer behavior (unchanged) ─────────────────────────────────
|
||||
|
||||
|
||||
class TestSanitizeContentStillRejectsNuls:
|
||||
"""sanitize_content (the MCP write-tool path) already guards against NUL
|
||||
bytes by rejecting them outright — that's a reasonable choice for an
|
||||
interactive tool call and is untouched by this fix. Verifies it still
|
||||
holds now that a sibling path (the backend chokepoint) takes the
|
||||
replace-not-reject approach instead, for the bulk-ingest paths where
|
||||
rejecting one bad chunk would otherwise abort or corrupt a whole mine.
|
||||
"""
|
||||
|
||||
def test_sanitize_content_rejects_nul(self):
|
||||
from mempalace.config import sanitize_content
|
||||
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="null bytes"):
|
||||
sanitize_content("hello\x00world")
|
||||
|
||||
|
||||
# ── Backend chokepoint (bulk ingest paths) ──────────────────────────────────
|
||||
|
||||
|
||||
class _CapturingCollection:
|
||||
"""Minimal chromadb.Collection stand-in that records the kwargs it receives,
|
||||
so we can assert what actually reaches the chromadb client."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def add(self, **kwargs):
|
||||
self.calls.append(("add", kwargs))
|
||||
|
||||
def upsert(self, **kwargs):
|
||||
self.calls.append(("upsert", kwargs))
|
||||
|
||||
def update(self, **kwargs):
|
||||
self.calls.append(("update", kwargs))
|
||||
|
||||
|
||||
class TestBackendChokepointStripsNulBytes:
|
||||
"""The bulk ingest paths (miner, convo_miner, sweeper, diary_ingest) build
|
||||
documents without routing through ``sanitize_content`` and reach
|
||||
``ChromaCollection`` directly — the same gap #1235 found for surrogates.
|
||||
A NUL byte in *document* text can corrupt the FTS5 inverted index for the
|
||||
whole collection. The backend chokepoint must strip it, mirroring the
|
||||
existing surrogate handling in the same method."""
|
||||
|
||||
@staticmethod
|
||||
def _collection():
|
||||
from mempalace.backends.chroma import ChromaCollection
|
||||
|
||||
fake = _CapturingCollection()
|
||||
return fake, ChromaCollection(fake)
|
||||
|
||||
def test_add_strips_nul_in_document(self):
|
||||
fake, col = self._collection()
|
||||
col.add(documents=["clean\x00doc"], ids=["1"])
|
||||
_, kwargs = fake.calls[0]
|
||||
assert kwargs["documents"] == ["clean�doc"]
|
||||
|
||||
def test_upsert_strips_nul_in_document(self):
|
||||
fake, col = self._collection()
|
||||
col.upsert(documents=["a\x00b"], ids=["1"], metadatas=[{"wing": "w"}])
|
||||
_, kwargs = fake.calls[0]
|
||||
assert kwargs["documents"] == ["a�b"]
|
||||
|
||||
def test_update_strips_nul_in_document(self):
|
||||
fake, col = self._collection()
|
||||
col.update(ids=["1"], documents=["x\x00y"])
|
||||
_, kwargs = fake.calls[0]
|
||||
assert kwargs["documents"] == ["x�y"]
|
||||
|
||||
def test_one_nul_message_does_not_corrupt_the_batch(self):
|
||||
"""The reporter's shape: a single NUL-heavy row alongside ordinary
|
||||
ones in the same batch. All rows survive, sanitised."""
|
||||
fake, col = self._collection()
|
||||
col.upsert(
|
||||
documents=["ok one", "poison\x00row", "ok three"],
|
||||
ids=["1", "2", "3"],
|
||||
)
|
||||
_, kwargs = fake.calls[0]
|
||||
assert kwargs["documents"] == ["ok one", "poison�row", "ok three"]
|
||||
assert len(kwargs["ids"]) == 3
|
||||
|
||||
def test_both_surrogates_and_nuls_in_same_document(self):
|
||||
"""Both sanitizers apply to the same document — order doesn't
|
||||
leave either kind of bad character behind."""
|
||||
fake, col = self._collection()
|
||||
col.upsert(documents=["a\udc95b\x00c"], ids=["1"])
|
||||
_, kwargs = fake.calls[0]
|
||||
assert kwargs["documents"] == ["a�b�c"]
|
||||
|
||||
def test_single_string_document_is_not_split_into_chars(self):
|
||||
"""chromadb accepts a bare str as one document (OneOrMany[Document]).
|
||||
The sanitiser must keep it whole and clean, not split it into
|
||||
per-character documents."""
|
||||
fake, col = self._collection()
|
||||
col.upsert(documents="one\x00document", ids=["1"])
|
||||
_, kwargs = fake.calls[0]
|
||||
assert kwargs["documents"] == "one�document"
|
||||
assert isinstance(kwargs["documents"], str)
|
||||
@@ -17,6 +17,7 @@ from mempalace.cli import (
|
||||
cmd_hook,
|
||||
cmd_init,
|
||||
cmd_instructions,
|
||||
cmd_daemon,
|
||||
cmd_mine,
|
||||
cmd_repair,
|
||||
cmd_search,
|
||||
@@ -166,6 +167,13 @@ def test_cmd_hook_calls_run_hook():
|
||||
mock_run.assert_called_once_with(hook_name="session-start", harness="claude-code")
|
||||
|
||||
|
||||
def test_cmd_hook_session_end_calls_run_hook():
|
||||
args = argparse.Namespace(hook="session-end", harness="claude-code")
|
||||
with patch("mempalace.hooks_cli.run_hook") as mock_run:
|
||||
cmd_hook(args)
|
||||
mock_run.assert_called_once_with(hook_name="session-end", harness="claude-code")
|
||||
|
||||
|
||||
# ── cmd_init ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -621,6 +629,64 @@ def test_cmd_mine_include_ignored_comma_split(mock_config_cls):
|
||||
assert call_kwargs["include_ignored"] == ["a.txt", "b.txt", "c.txt"]
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_mine_daemon_background_submits_job(mock_config_cls, capsys):
|
||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||
args = argparse.Namespace(
|
||||
dir="/src",
|
||||
palace=None,
|
||||
mode="projects",
|
||||
wing=None,
|
||||
agent="mempalace",
|
||||
limit=0,
|
||||
dry_run=False,
|
||||
no_gitignore=False,
|
||||
include_ignored=["a.txt,b.txt"],
|
||||
extract="exchange",
|
||||
daemon=True,
|
||||
background=True,
|
||||
backend=None,
|
||||
global_backend=None,
|
||||
max_chunks_per_file=None,
|
||||
redetect_origin=False,
|
||||
)
|
||||
with patch("mempalace.daemon.submit_job", return_value={"id": "job-1"}) as mock_submit:
|
||||
with patch("mempalace.miner.mine") as mock_mine:
|
||||
cmd_mine(args)
|
||||
|
||||
mock_mine.assert_not_called()
|
||||
mock_submit.assert_called_once()
|
||||
call_kwargs = mock_submit.call_args.kwargs
|
||||
assert call_kwargs["palace_path"] == "/fake/palace"
|
||||
assert call_kwargs["wait"] is False
|
||||
payload = mock_submit.call_args.args[1]
|
||||
assert payload["include_ignored"] == ["a.txt", "b.txt"]
|
||||
assert "job-1" in capsys.readouterr().out
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_mine_background_requires_daemon(mock_config_cls, capsys):
|
||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||
args = argparse.Namespace(
|
||||
dir="/src",
|
||||
palace=None,
|
||||
mode="projects",
|
||||
wing=None,
|
||||
agent="mempalace",
|
||||
limit=0,
|
||||
dry_run=False,
|
||||
no_gitignore=False,
|
||||
include_ignored=[],
|
||||
extract="exchange",
|
||||
daemon=False,
|
||||
background=True,
|
||||
)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cmd_mine(args)
|
||||
assert excinfo.value.code == 2
|
||||
assert "--background requires --daemon" in capsys.readouterr().err
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_mine_exits_nonzero_on_lock_holder(mock_config_cls, capsys):
|
||||
"""Regression #1264: lock contention must exit non-zero with a clear message.
|
||||
@@ -872,6 +938,18 @@ def test_main_hook_run_dispatches():
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
|
||||
def test_main_hook_run_dispatches_session_end():
|
||||
with (
|
||||
patch(
|
||||
"sys.argv",
|
||||
["mempalace", "hook", "run", "--hook", "session-end", "--harness", "claude-code"],
|
||||
),
|
||||
patch("mempalace.cli.cmd_hook") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
|
||||
def test_main_instructions_no_subcommand_prints_help(capsys):
|
||||
with patch("sys.argv", ["mempalace", "instructions"]):
|
||||
main()
|
||||
@@ -897,6 +975,16 @@ def test_main_repair_dispatches():
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
|
||||
def test_main_repair_rebuild_index_dispatches():
|
||||
with (
|
||||
patch("sys.argv", ["mempalace", "repair", "rebuild-index"]),
|
||||
patch("mempalace.cli.cmd_repair") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
args = mock_cmd.call_args.args[0]
|
||||
assert args.repair_action == "rebuild-index"
|
||||
|
||||
|
||||
def test_main_compress_dispatches():
|
||||
with (
|
||||
patch("sys.argv", ["mempalace", "compress"]),
|
||||
@@ -957,6 +1045,35 @@ def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys):
|
||||
assert "Error reading palace" in out
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_repair_error_reading_points_to_from_sqlite_not_remine(
|
||||
mock_config_cls, tmp_path, capsys
|
||||
):
|
||||
"""When the drawer-index read fails (the chromadb HNSW compactor cannot
|
||||
apply WAL logs to the segment), legacy repair must point the user at
|
||||
``repair --mode from-sqlite`` — the rows are intact in chroma.sqlite3 —
|
||||
and must NOT advise re-mining from source files, which silently drops
|
||||
drawers added via the MCP server and diary entries (#1843)."""
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
sqlite3.connect(str(palace_dir / "chroma.sqlite3")).close()
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
mock_config_cls.return_value.collection_name = "mempalace_drawers"
|
||||
args = argparse.Namespace(palace=None)
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.side_effect = Exception(
|
||||
"Error executing plan: Error sending backfill request to compactor: "
|
||||
"Failed to apply logs to the hnsw segment writer"
|
||||
)
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.get_collection.return_value = mock_col
|
||||
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
|
||||
cmd_repair(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "mempalace repair --mode from-sqlite --archive-existing" in out
|
||||
assert "may need to be re-mined" not in out
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_repair_zero_drawers(mock_config_cls, tmp_path, capsys):
|
||||
palace_dir = tmp_path / "palace"
|
||||
@@ -1260,6 +1377,94 @@ def test_cmd_sync_palace_dir_no_db(mock_config_cls, tmp_path, capsys):
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_sync_daemon_background_submits_job(mock_config_cls, capsys):
|
||||
from mempalace.cli import cmd_sync
|
||||
|
||||
mock_config_cls.return_value.palace_path = "/fake/palace"
|
||||
args = argparse.Namespace(
|
||||
palace=None,
|
||||
dir="/project",
|
||||
root=["/extra"],
|
||||
wing="wing_a",
|
||||
dry_run=False,
|
||||
daemon=True,
|
||||
background=True,
|
||||
backend=None,
|
||||
global_backend=None,
|
||||
)
|
||||
with patch("mempalace.daemon.submit_job", return_value={"id": "sync-job"}) as mock_submit:
|
||||
cmd_sync(args)
|
||||
|
||||
mock_submit.assert_called_once()
|
||||
assert mock_submit.call_args.args[0] == "sync"
|
||||
payload = mock_submit.call_args.args[1]
|
||||
assert payload == {"dir": "/project", "root": ["/extra"], "wing": "wing_a", "dry_run": False}
|
||||
assert mock_submit.call_args.kwargs["wait"] is False
|
||||
assert "sync-job" in capsys.readouterr().out
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_daemon_jobs_reads_durable_queue_when_stopped(
|
||||
mock_config_cls, tmp_path, monkeypatch, capsys
|
||||
):
|
||||
from mempalace.daemon import QueueStore, queue_path
|
||||
|
||||
palace_dir = tmp_path / "palace"
|
||||
state_root = tmp_path / "state"
|
||||
palace_dir.mkdir()
|
||||
monkeypatch.setenv("MEMPALACE_DAEMON_STATE_ROOT", str(state_root))
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
job = QueueStore(queue_path(str(palace_dir))).enqueue("mine", {"source": "/src"})
|
||||
|
||||
args = argparse.Namespace(
|
||||
palace=None,
|
||||
backend=None,
|
||||
global_backend=None,
|
||||
daemon_action="jobs",
|
||||
limit=20,
|
||||
)
|
||||
with patch("mempalace.daemon.get_client_if_running", return_value=None):
|
||||
cmd_daemon(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert job.id in out
|
||||
assert "queued" in out
|
||||
assert "mine" in out
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_daemon_wait_reads_finished_job_when_stopped(
|
||||
mock_config_cls, tmp_path, monkeypatch, capsys
|
||||
):
|
||||
from mempalace.daemon import QueueStore, queue_path
|
||||
|
||||
palace_dir = tmp_path / "palace"
|
||||
state_root = tmp_path / "state"
|
||||
palace_dir.mkdir()
|
||||
monkeypatch.setenv("MEMPALACE_DAEMON_STATE_ROOT", str(state_root))
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
store = QueueStore(queue_path(str(palace_dir)))
|
||||
queued = store.enqueue("mine", {"source": "/src"})
|
||||
store.finish(
|
||||
queued.id,
|
||||
state="succeeded",
|
||||
result={"success": True, "stdout": "done\n", "exit_code": 0},
|
||||
)
|
||||
|
||||
args = argparse.Namespace(
|
||||
palace=None,
|
||||
backend=None,
|
||||
global_backend=None,
|
||||
daemon_action="wait",
|
||||
job_id=queued.id,
|
||||
)
|
||||
with patch("mempalace.daemon.get_client_if_running", return_value=None):
|
||||
cmd_daemon(args)
|
||||
|
||||
assert "done" in capsys.readouterr().out
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_compress_no_palace(mock_config_cls, tmp_path, capsys):
|
||||
"""cmd_compress exits non-zero with a 'No palace found' message on a missing dir.
|
||||
@@ -1572,3 +1777,57 @@ def test_cmd_repair_from_sqlite_success_does_not_exit(mock_config_cls, tmp_path)
|
||||
with patch("mempalace.repair.rebuild_from_sqlite", return_value=fake_counts):
|
||||
# Should return cleanly; no SystemExit raised.
|
||||
cmd_repair(args)
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_repair_from_sqlite_cleanup_failure_exits_nonzero(mock_config_cls, tmp_path, capsys):
|
||||
from mempalace.repair import RebuildCleanupError
|
||||
|
||||
palace_dir = tmp_path / "palace"
|
||||
source_dir = tmp_path / "source"
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
args = argparse.Namespace(
|
||||
palace=str(palace_dir),
|
||||
mode="from-sqlite",
|
||||
source=str(source_dir),
|
||||
archive_existing=False,
|
||||
yes=True,
|
||||
)
|
||||
failure = RebuildCleanupError(
|
||||
"cleanup failed",
|
||||
counts={"mempalace_drawers": 1},
|
||||
dest_palace=str(palace_dir),
|
||||
archive_path=None,
|
||||
)
|
||||
with patch("mempalace.repair.rebuild_from_sqlite", side_effect=failure):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cmd_repair(args)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert "Rebuild cleanup failed" in capsys.readouterr().out
|
||||
|
||||
|
||||
@patch("mempalace.cli.MempalaceConfig")
|
||||
def test_cmd_repair_rebuild_index_alias_uses_sqlite_archive(mock_config_cls, tmp_path):
|
||||
"""``repair rebuild-index`` must bypass Chroma reads and rebuild from SQLite."""
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
mock_config_cls.return_value.palace_path = str(palace_dir)
|
||||
|
||||
args = argparse.Namespace(
|
||||
palace=str(palace_dir),
|
||||
repair_action="rebuild-index",
|
||||
mode="legacy",
|
||||
source=None,
|
||||
archive_existing=False,
|
||||
yes=True,
|
||||
)
|
||||
fake_counts = {"mempalace_drawers": 1, "mempalace_closets": 0}
|
||||
with patch("mempalace.repair.rebuild_from_sqlite", return_value=fake_counts) as rebuild:
|
||||
cmd_repair(args)
|
||||
|
||||
rebuild.assert_called_once_with(
|
||||
source_palace=str(palace_dir),
|
||||
dest_palace=str(palace_dir),
|
||||
archive_existing_dest=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for the `hallways` CLI command."""
|
||||
|
||||
from argparse import Namespace
|
||||
|
||||
import mempalace.hallways as hallways_mod
|
||||
from mempalace.cli import cmd_hallways
|
||||
|
||||
|
||||
def test_lists_sorted_by_count(monkeypatch, capsys):
|
||||
rows = [
|
||||
{
|
||||
"entity_a": "C",
|
||||
"entity_b": "D",
|
||||
"co_occurrence_count": 1,
|
||||
"wing": "w",
|
||||
"label": "C <-> D (x1)",
|
||||
},
|
||||
{
|
||||
"entity_a": "A",
|
||||
"entity_b": "B",
|
||||
"co_occurrence_count": 3,
|
||||
"wing": "w",
|
||||
"label": "A <-> B (x3)",
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: list(rows))
|
||||
cmd_hallways(Namespace(wing=None, limit=50))
|
||||
out = capsys.readouterr().out
|
||||
assert "2 hallway(s)" in out
|
||||
assert "A <-> B (x3)" in out
|
||||
# Highest co-occurrence first.
|
||||
assert out.index("A <-> B") < out.index("C <-> D")
|
||||
|
||||
|
||||
def test_respects_limit(monkeypatch, capsys):
|
||||
rows = [
|
||||
{"entity_a": f"E{i}", "entity_b": "X", "co_occurrence_count": i, "label": f"E{i} <-> X"}
|
||||
for i in range(5)
|
||||
]
|
||||
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: list(rows))
|
||||
cmd_hallways(Namespace(wing=None, limit=2))
|
||||
assert capsys.readouterr().out.count("<->") == 2
|
||||
|
||||
|
||||
def test_negative_limit_shows_nothing_not_tail(monkeypatch, capsys):
|
||||
rows = [
|
||||
{"entity_a": f"E{i}", "entity_b": "X", "co_occurrence_count": i, "label": f"E{i} <-> X"}
|
||||
for i in range(5)
|
||||
]
|
||||
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: list(rows))
|
||||
cmd_hallways(Namespace(wing=None, limit=-2))
|
||||
# A negative limit must not slice from the end (which would print all-but-2).
|
||||
assert capsys.readouterr().out.count("<->") == 0
|
||||
|
||||
|
||||
def test_empty_message(monkeypatch, capsys):
|
||||
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: [])
|
||||
cmd_hallways(Namespace(wing="x", limit=50))
|
||||
assert "No hallways yet" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_explicit_palace_scopes_hallway_listing(monkeypatch, tmp_path):
|
||||
calls = []
|
||||
|
||||
def fake_list(wing=None, config=None):
|
||||
calls.append((wing, config.palace_path))
|
||||
return []
|
||||
|
||||
selected = tmp_path / "selected" / "palace"
|
||||
monkeypatch.setattr(hallways_mod, "list_hallways", fake_list)
|
||||
|
||||
cmd_hallways(Namespace(wing="wing_aya", limit=50, palace=str(selected)))
|
||||
|
||||
assert calls == [("wing_aya", str(selected))]
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
@@ -10,6 +11,7 @@ from mempalace.config import (
|
||||
sanitize_iso_temporal,
|
||||
sanitize_kg_value,
|
||||
sanitize_name,
|
||||
sqlite_read_uri,
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +70,41 @@ def test_qdrant_config_from_env_and_file(tmp_path, monkeypatch):
|
||||
assert cfg.qdrant_timeout == 3.5
|
||||
|
||||
|
||||
def test_milvus_config_from_env_and_file(tmp_path, monkeypatch):
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"milvus_uri": "https://config.example",
|
||||
"milvus_token": "config-token",
|
||||
"milvus_db_name": "config-db",
|
||||
"milvus_namespace": "config-ns",
|
||||
"milvus_consistency_level": "bounded",
|
||||
},
|
||||
f,
|
||||
)
|
||||
monkeypatch.setenv("MEMPALACE_MILVUS_URI", "https://env.example")
|
||||
monkeypatch.setenv("MEMPALACE_MILVUS_TOKEN", "env-token")
|
||||
monkeypatch.setenv("MEMPALACE_MILVUS_DB_NAME", "env-db")
|
||||
monkeypatch.setenv("MEMPALACE_MILVUS_NAMESPACE", "env-ns")
|
||||
monkeypatch.setenv("MEMPALACE_MILVUS_CONSISTENCY_LEVEL", "eventually")
|
||||
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
|
||||
assert cfg.milvus_uri == "https://env.example"
|
||||
assert cfg.milvus_token == "env-token"
|
||||
assert cfg.milvus_db_name == "env-db"
|
||||
assert cfg.milvus_namespace == "env-ns"
|
||||
assert cfg.milvus_consistency_level == "Eventually"
|
||||
|
||||
|
||||
def test_milvus_config_rejects_invalid_consistency_level(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MEMPALACE_MILVUS_CONSISTENCY_LEVEL", "linearizable")
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
|
||||
with pytest.raises(ValueError, match="milvus_consistency_level"):
|
||||
cfg.milvus_consistency_level
|
||||
|
||||
|
||||
def test_set_backend_persists_choice(tmp_path):
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
cfg.set_backend("sqlite_exact")
|
||||
@@ -100,6 +137,79 @@ def test_embedding_device_env_overrides_config(tmp_path, monkeypatch):
|
||||
assert cfg.embedding_device == "coreml"
|
||||
|
||||
|
||||
def test_embedding_threads_defaults_to_half_cpus(monkeypatch):
|
||||
monkeypatch.delenv("MEMPALACE_EMBEDDING_THREADS", raising=False)
|
||||
monkeypatch.setattr("os.cpu_count", lambda: 10)
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
# unset / "auto" → half the logical CPUs so a background mine stays tame
|
||||
assert cfg.embedding_threads == 5
|
||||
|
||||
|
||||
def test_embedding_threads_auto_keyword(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MEMPALACE_EMBEDDING_THREADS", raising=False)
|
||||
monkeypatch.setattr("os.cpu_count", lambda: 8)
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"embedding_threads": "auto"}, f)
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.embedding_threads == 4
|
||||
|
||||
|
||||
def test_embedding_threads_positive_value_from_config(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MEMPALACE_EMBEDDING_THREADS", raising=False)
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"embedding_threads": 3}, f)
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.embedding_threads == 3
|
||||
|
||||
|
||||
def test_embedding_threads_zero_means_uncapped(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MEMPALACE_EMBEDDING_THREADS", raising=False)
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"embedding_threads": 0}, f)
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.embedding_threads == 0
|
||||
|
||||
|
||||
def test_embedding_threads_env_overrides_config(tmp_path, monkeypatch):
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"embedding_threads": 2}, f)
|
||||
monkeypatch.setenv("MEMPALACE_EMBEDDING_THREADS", "6")
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.embedding_threads == 6
|
||||
|
||||
|
||||
def test_embedding_threads_invalid_falls_back_to_auto(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("os.cpu_count", lambda: 4)
|
||||
monkeypatch.setenv("MEMPALACE_EMBEDDING_THREADS", "not-a-number")
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.embedding_threads == 2
|
||||
|
||||
|
||||
def test_sqlite_read_uri_opens_path_with_spaces(tmp_path):
|
||||
"""sqlite_read_uri must open a read-only DB whose path contains spaces,
|
||||
which a bare f"file:{path}?mode=ro" mis-parses (especially on Windows)."""
|
||||
db_dir = tmp_path / "palace with spaces"
|
||||
db_dir.mkdir()
|
||||
db_path = db_dir / "chroma.sqlite3"
|
||||
setup = sqlite3.connect(str(db_path))
|
||||
setup.execute("CREATE TABLE t (x INTEGER)")
|
||||
setup.execute("INSERT INTO t VALUES (42)")
|
||||
setup.commit()
|
||||
setup.close()
|
||||
|
||||
uri = sqlite_read_uri(str(db_path))
|
||||
assert "%20" in uri # the space is percent-encoded, not left raw
|
||||
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
assert conn.execute("SELECT x FROM t").fetchone()[0] == 42
|
||||
# mode=ro is still honored through the encoded URI
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
conn.execute("INSERT INTO t VALUES (1)")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_env_override():
|
||||
raw = "/env/palace"
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = raw
|
||||
@@ -690,6 +800,36 @@ def test_hooks_auto_save_env_override_true():
|
||||
del os.environ["MEMPALACE_HOOKS_AUTO_SAVE"]
|
||||
|
||||
|
||||
def test_hook_use_daemon_default_false(monkeypatch):
|
||||
monkeypatch.delenv("MEMPALACE_HOOKS_DAEMON", raising=False)
|
||||
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
|
||||
assert cfg.hook_use_daemon is False
|
||||
|
||||
|
||||
def test_hook_use_daemon_from_config(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("MEMPALACE_HOOKS_DAEMON", raising=False)
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"hooks": {"daemon": True}}, f)
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.hook_use_daemon is True
|
||||
|
||||
|
||||
def test_hook_use_daemon_string_config(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("MEMPALACE_HOOKS_DAEMON", raising=False)
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"hooks": {"daemon": "yes"}}, f)
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.hook_use_daemon is True
|
||||
|
||||
|
||||
def test_hook_use_daemon_env_override(monkeypatch, tmp_path):
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"hooks": {"daemon": False}}, f)
|
||||
monkeypatch.setenv("MEMPALACE_HOOKS_DAEMON", "yes")
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.hook_use_daemon is True
|
||||
|
||||
|
||||
# --- max_backups (backup retention) ---
|
||||
|
||||
|
||||
@@ -748,3 +888,18 @@ def test_max_backups_bad_env_falls_back_to_config(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("MEMPALACE_MAX_BACKUPS", "garbage")
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path))
|
||||
assert cfg.max_backups == 4
|
||||
|
||||
|
||||
def test_explicit_palace_path_overrides_env_and_file_config(monkeypatch, tmp_path):
|
||||
configured = tmp_path / "configured" / "palace"
|
||||
explicit = tmp_path / "explicit" / "../explicit" / "palace"
|
||||
with open(tmp_path / "config.json", "w") as f:
|
||||
json.dump({"palace_path": str(configured)}, f)
|
||||
monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(tmp_path / "environment" / "palace"))
|
||||
|
||||
cfg = MempalaceConfig(config_dir=str(tmp_path), palace_path=str(explicit))
|
||||
|
||||
expected = os.path.abspath(os.path.expanduser(str(explicit)))
|
||||
assert cfg.palace_path == expected
|
||||
assert cfg.hallway_file == os.path.join(os.path.dirname(expected), "hallways.json")
|
||||
assert cfg.tunnel_file == os.path.join(os.path.dirname(expected), "tunnels.json")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Tests for palace_path tilde expansion in MempalaceConfig."""
|
||||
|
||||
import os
|
||||
from mempalace.config import MempalaceConfig
|
||||
|
||||
|
||||
def test_palace_path_expands_tilde_from_config_file():
|
||||
"""palace_path must expand ~ even when read from config.json, not env."""
|
||||
cfg = MempalaceConfig()
|
||||
cfg._file_config["palace_path"] = "~/.mempalace/palace"
|
||||
result = cfg.palace_path
|
||||
assert not result.startswith("~"), (
|
||||
f"palace_path returned unexpanded tilde: {result!r}. "
|
||||
"This causes mempalace mine to create a literal '~' directory "
|
||||
"relative to CWD instead of writing to the home directory."
|
||||
)
|
||||
assert result == os.path.expanduser("~/.mempalace/palace")
|
||||
|
||||
|
||||
def test_palace_path_expands_tilde_nested():
|
||||
"""Nested tilde paths (e.g. ~/custom/palace) are also expanded."""
|
||||
cfg = MempalaceConfig()
|
||||
cfg._file_config["palace_path"] = "~/custom/mempalace"
|
||||
result = cfg.palace_path
|
||||
assert not result.startswith("~")
|
||||
assert result == os.path.expanduser("~/custom/mempalace")
|
||||
|
||||
|
||||
def test_palace_path_absolute_unchanged():
|
||||
"""Absolute paths pass through without modification."""
|
||||
cfg = MempalaceConfig()
|
||||
cfg._file_config["palace_path"] = "/tmp/test_palace"
|
||||
assert cfg.palace_path == "/tmp/test_palace"
|
||||
+243
-1
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import chromadb
|
||||
@@ -8,10 +9,11 @@ import pytest
|
||||
|
||||
from mempalace.convo_miner import (
|
||||
_is_ai_tool_path,
|
||||
_register_file,
|
||||
_resolve_wing,
|
||||
mine_convos,
|
||||
)
|
||||
from mempalace.palace import MineAlreadyRunning, file_already_mined
|
||||
from mempalace.palace import MineAlreadyRunning, file_already_mined, prefetch_mined_set
|
||||
|
||||
|
||||
def test_convo_mining():
|
||||
@@ -454,3 +456,243 @@ def test_mine_convos_limit_skips_already_mined(capsys):
|
||||
break
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
# ── mtime-aware re-mining ────────────────────────────────────────────
|
||||
#
|
||||
# Conversation transcripts are NOT immutable: a Claude Code session keeps
|
||||
# appending to its own file while active, and /compact or /clear can
|
||||
# rewrite one in place. These tests cover the fix -- convo mining used to
|
||||
# treat "we've seen this source_file before" as sufficient to skip it
|
||||
# forever (transcripts were assumed immutable), silently missing content
|
||||
# appended after the first mine.
|
||||
|
||||
|
||||
def test_mine_convos_reprocesses_when_file_grows(capsys):
|
||||
"""A session file that grows after being mined must be picked up on
|
||||
the next mine, not skipped forever."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
convo_path = Path(tmpdir) / "session.txt"
|
||||
convo_path.write_text(
|
||||
"> What is the plan?\nStart with the schema, then the API.\n\n"
|
||||
"> Any risks?\nMigration ordering is the main one.\n"
|
||||
)
|
||||
palace_path = os.path.join(tmpdir, "palace")
|
||||
|
||||
mine_convos(tmpdir, palace_path, wing="test")
|
||||
capsys.readouterr()
|
||||
|
||||
# Simulate the session being extended: real content added, mtime
|
||||
# bumped forward (avoids same-second mtime resolution flakiness).
|
||||
convo_path.write_text(
|
||||
"> What is the plan?\nStart with the schema, then the API.\n\n"
|
||||
"> Any risks?\nMigration ordering is the main one.\n\n"
|
||||
"> UNIQUE_GROWN_SESSION_MARKER, did we resolve it?\n"
|
||||
"Yes, resolved by locking the migration order explicitly.\n"
|
||||
)
|
||||
future = time.time() + 60
|
||||
os.utime(convo_path, (future, future))
|
||||
|
||||
mine_convos(tmpdir, palace_path, wing="test")
|
||||
out = capsys.readouterr().out
|
||||
assert "Files skipped (already filed): 1" not in out
|
||||
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
docs = col.get(include=["documents"])["documents"]
|
||||
assert any("UNIQUE_GROWN_SESSION_MARKER" in d for d in docs), (
|
||||
"grown session content was not picked up on re-mine"
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_mine_convos_unchanged_file_still_skipped(capsys):
|
||||
"""A file whose content and mtime are unchanged must still be skipped
|
||||
-- the mtime check must not defeat the existing skip-on-unchanged
|
||||
optimization."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
convo_path = Path(tmpdir) / "session.txt"
|
||||
convo_path.write_text(
|
||||
"> What is the plan?\nStart with the schema, then the API.\n\n"
|
||||
"> Any risks?\nMigration ordering is the main one.\n"
|
||||
)
|
||||
palace_path = os.path.join(tmpdir, "palace")
|
||||
|
||||
mine_convos(tmpdir, palace_path, wing="test")
|
||||
capsys.readouterr()
|
||||
|
||||
mine_convos(tmpdir, palace_path, wing="test")
|
||||
out = capsys.readouterr().out
|
||||
assert "Files skipped (already filed): 1" in out
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_mine_convos_grown_file_purges_stale_drawers_not_additive(capsys):
|
||||
"""Re-mining a grown file must not leave duplicate/stale drawers behind
|
||||
-- purge-then-insert, not additive accumulation. Checks content
|
||||
directly (a drawer count comparison is fragile: ChromaDB collections
|
||||
can carry non-drawer bookkeeping rows unrelated to this behavior)."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
convo_path = Path(tmpdir) / "session.txt"
|
||||
convo_path.write_text(
|
||||
"> What is the plan?\nUNIQUE_ORIGINAL_EXCHANGE_MARKER here.\n\n"
|
||||
"> Any risks?\nMigration ordering is the main one.\n"
|
||||
)
|
||||
palace_path = os.path.join(tmpdir, "palace")
|
||||
|
||||
mine_convos(tmpdir, palace_path, wing="test")
|
||||
capsys.readouterr()
|
||||
|
||||
convo_path.write_text(
|
||||
"> What is the plan?\nUNIQUE_ORIGINAL_EXCHANGE_MARKER here.\n\n"
|
||||
"> Any risks?\nMigration ordering is the main one.\n\n"
|
||||
"> One more exchange?\nUNIQUE_NEW_EXCHANGE_MARKER here.\n"
|
||||
)
|
||||
future = time.time() + 60
|
||||
os.utime(convo_path, (future, future))
|
||||
mine_convos(tmpdir, palace_path, wing="test")
|
||||
capsys.readouterr()
|
||||
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
docs = col.get(include=["documents"])["documents"]
|
||||
|
||||
original_hits = sum(1 for d in docs if "UNIQUE_ORIGINAL_EXCHANGE_MARKER" in d)
|
||||
new_hits = sum(1 for d in docs if "UNIQUE_NEW_EXCHANGE_MARKER" in d)
|
||||
assert original_hits == 1, (
|
||||
f"original exchange duplicated across re-mine: {original_hits} copies"
|
||||
)
|
||||
assert new_hits == 1, f"new exchange should appear exactly once, got {new_hits}"
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_prefetch_mined_set_returns_stored_mtime():
|
||||
"""prefetch_mined_set's dict carries each source_file's stored mtime,
|
||||
not just membership."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
convo_path = Path(tmpdir) / "session.txt"
|
||||
convo_path.write_text(
|
||||
"> What is the plan?\nStart with the schema, then the API.\n\n"
|
||||
"> Any risks?\nMigration ordering is the main one.\n"
|
||||
)
|
||||
palace_path = os.path.join(tmpdir, "palace")
|
||||
mine_convos(tmpdir, palace_path, wing="test")
|
||||
|
||||
resolved_file = str(convo_path.resolve())
|
||||
actual_mtime = os.path.getmtime(resolved_file)
|
||||
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
mined = prefetch_mined_set(col, extract_mode="exchange")
|
||||
|
||||
assert resolved_file in mined
|
||||
assert mined[resolved_file] is not None
|
||||
assert abs(mined[resolved_file] - actual_mtime) < 0.001
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_prefetch_mined_set_none_for_drawer_without_stored_mtime():
|
||||
"""A drawer written before source_mtime existed (or with getmtime
|
||||
failure at write time) must surface as None, not be silently absent --
|
||||
None must be treated as stale by callers, not as 'unknown, assume ok'."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
palace_path = os.path.join(tmpdir, "palace")
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_or_create_collection("mempalace_drawers")
|
||||
col.upsert(
|
||||
ids=["drawer_legacy_1"],
|
||||
documents=["legacy content with no source_mtime field"],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": "test",
|
||||
"room": "general",
|
||||
"source_file": "/fake/legacy/file.txt",
|
||||
"chunk_index": 0,
|
||||
"extract_mode": "exchange",
|
||||
"normalize_version": 999, # force >= current version
|
||||
}
|
||||
],
|
||||
)
|
||||
mined = prefetch_mined_set(col, extract_mode="exchange")
|
||||
assert "/fake/legacy/file.txt" in mined
|
||||
assert mined["/fake/legacy/file.txt"] is None
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_mine_convos_reprocesses_legacy_drawer_without_stored_mtime(capsys):
|
||||
"""A file mined before source_mtime was tracked (simulated: drawer
|
||||
written directly, no source_mtime field) must be re-mined on the next
|
||||
run, not skipped forever -- this is the one-time backfill behavior."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
convo_path = Path(tmpdir) / "session.txt"
|
||||
convo_path.write_text(
|
||||
"> What is the plan?\nUNIQUE_LEGACY_BACKFILL_MARKER here.\n\n"
|
||||
"> Any risks?\nMigration ordering is the main one.\n"
|
||||
)
|
||||
resolved_file = str(convo_path.resolve())
|
||||
palace_path = os.path.join(tmpdir, "palace")
|
||||
|
||||
# Simulate a pre-existing drawer from before source_mtime existed.
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_or_create_collection("mempalace_drawers")
|
||||
from mempalace.palace import NORMALIZE_VERSION
|
||||
|
||||
col.upsert(
|
||||
ids=["drawer_legacy_session_1"],
|
||||
documents=["stale legacy content, no mtime field"],
|
||||
metadatas=[
|
||||
{
|
||||
"wing": "test",
|
||||
"room": "general",
|
||||
"source_file": resolved_file,
|
||||
"chunk_index": 0,
|
||||
"extract_mode": "exchange",
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
}
|
||||
],
|
||||
)
|
||||
del col, client
|
||||
|
||||
mine_convos(tmpdir, palace_path, wing="test")
|
||||
out = capsys.readouterr().out
|
||||
assert "Files skipped (already filed): 1" not in out
|
||||
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_collection("mempalace_drawers")
|
||||
docs = col.get(include=["documents"])["documents"]
|
||||
assert any("UNIQUE_LEGACY_BACKFILL_MARKER" in d for d in docs)
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_register_file_sentinel_includes_source_mtime():
|
||||
"""The 0-chunk sentinel must stamp source_mtime too, so a file that
|
||||
later grows past the min-chunk-size floor is detected as changed
|
||||
instead of being skipped forever by the sentinel."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
tiny_file = Path(tmpdir) / "tiny.txt"
|
||||
tiny_file.write_text("hi")
|
||||
palace_path = os.path.join(tmpdir, "palace")
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
col = client.get_or_create_collection("mempalace_drawers")
|
||||
|
||||
_register_file(col, str(tiny_file), "test", "mempalace", "exchange")
|
||||
|
||||
mined = prefetch_mined_set(col, extract_mode="exchange")
|
||||
assert str(tiny_file) in mined
|
||||
assert mined[str(tiny_file)] is not None
|
||||
assert abs(mined[str(tiny_file)] - os.path.getmtime(tiny_file)) < 0.001
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
from mempalace.convo_miner import (
|
||||
CHUNK_SIZE,
|
||||
_emit_bounded,
|
||||
_extract_authored_at,
|
||||
_file_chunks_locked,
|
||||
chunk_exchanges,
|
||||
detect_convo_room,
|
||||
@@ -362,6 +363,30 @@ class TestScanConvos:
|
||||
files = scan_convos(str(tmp_path))
|
||||
assert files == []
|
||||
|
||||
def test_scan_skips_tool_results_dirs(self, tmp_path):
|
||||
# Claude Code pages large tool outputs to <session>/tool-results/*.txt
|
||||
# inside ~/.claude/projects/<slug>/. These are raw machine dumps
|
||||
# referenced from the transcript JSONL, not conversations — mining
|
||||
# them floods the palace (12.8k drawers measured in the field, one
|
||||
# single file produced 3.6k). The scanner must not descend into them.
|
||||
session_dir = tmp_path / "1234-5678-session"
|
||||
tool_results = session_dir / "tool-results"
|
||||
tool_results.mkdir(parents=True)
|
||||
(tool_results / "bipc8jdx0.txt").write_text("raw tool dump " * 100, encoding="utf-8")
|
||||
(tmp_path / "session.jsonl").write_text('{"type": "user"}', encoding="utf-8")
|
||||
files = scan_convos(str(tmp_path))
|
||||
names = [f.name for f in files]
|
||||
assert "session.jsonl" in names
|
||||
assert "bipc8jdx0.txt" not in names
|
||||
|
||||
def test_scan_keeps_regular_nested_dirs(self, tmp_path):
|
||||
# The tool-results skip must not turn into a blanket nested-dir skip.
|
||||
nested = tmp_path / "archive"
|
||||
nested.mkdir()
|
||||
(nested / "old-chat.md").write_text("> q\na\n> q2\na2\n> q3\na3", encoding="utf-8")
|
||||
files = scan_convos(str(tmp_path))
|
||||
assert [f.name for f in files] == ["old-chat.md"]
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="symlink creation requires elevated privileges on Windows",
|
||||
@@ -431,6 +456,59 @@ class TestScanConvos:
|
||||
assert "deep/subdir/nested.jsonl" in err
|
||||
assert "(symlink)" in err
|
||||
|
||||
def test_scan_skips_oversized_files(self, tmp_path, capsys, monkeypatch):
|
||||
import re
|
||||
|
||||
import mempalace.convo_miner as convo_mod
|
||||
|
||||
monkeypatch.setattr(convo_mod, "MAX_FILE_SIZE", 100)
|
||||
|
||||
(tmp_path / "small.txt").write_text("hello " * 5, encoding="utf-8")
|
||||
(tmp_path / "big.txt").write_text("hello " * 100, encoding="utf-8")
|
||||
|
||||
files = scan_convos(str(tmp_path))
|
||||
names = [f.name for f in files]
|
||||
assert "small.txt" in names
|
||||
assert "big.txt" not in names
|
||||
|
||||
err = capsys.readouterr().err
|
||||
# SKIP message goes to stderr, matching the existing
|
||||
# `SKIP: <rel> (symlink)` line in the same function.
|
||||
assert "SKIP: big.txt" in err
|
||||
# Validate the full template so a drop of the MB suffix or a
|
||||
# regression to bare-substring output trips the test.
|
||||
assert re.search(r"SKIP: big\.txt \(\d+\.\d+ MB\) exceeds \d+ MB limit", err), err
|
||||
|
||||
def test_scan_skips_unreadable_files(self, tmp_path, capsys, monkeypatch):
|
||||
from pathlib import Path
|
||||
|
||||
# .txt is in CONVO_EXTENSIONS so it reaches the size-check gate.
|
||||
(tmp_path / "readable.txt").write_text("hi", encoding="utf-8")
|
||||
unreadable = tmp_path / "unreadable.txt"
|
||||
unreadable.write_text("hi", encoding="utf-8")
|
||||
|
||||
real_stat = Path.stat
|
||||
|
||||
def selective_stat(self, *args, **kwargs):
|
||||
# On Py 3.10+, Path.is_symlink() routes through lstat ->
|
||||
# stat(follow_symlinks=False). Only raise for the follow-symlinks
|
||||
# call that the actual size-check makes, otherwise the test
|
||||
# never reaches the size-check arm we want to exercise.
|
||||
if self.name == "unreadable.txt" and kwargs.get("follow_symlinks", True):
|
||||
raise PermissionError(13, "Permission denied", str(self))
|
||||
return real_stat(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "stat", selective_stat)
|
||||
|
||||
files = scan_convos(str(tmp_path))
|
||||
names = [f.name for f in files]
|
||||
assert "readable.txt" in names
|
||||
assert "unreadable.txt" not in names
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "SKIP: unreadable.txt" in err
|
||||
assert "stat error" in err
|
||||
|
||||
|
||||
class TestFileChunksLocked:
|
||||
def test_uses_bounded_upsert_batches(self, monkeypatch):
|
||||
@@ -468,3 +546,100 @@ class TestFileChunksLocked:
|
||||
assert dict(room_counts) == {}
|
||||
assert skipped is False
|
||||
assert col.batch_sizes == [2, 2, 1]
|
||||
|
||||
def test_populates_entities_metadata(self, monkeypatch):
|
||||
import mempalace.convo_miner as convo_miner
|
||||
|
||||
class FakeCol:
|
||||
def __init__(self):
|
||||
self.metas = []
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def get(self, ids=None, include=None, **kwargs):
|
||||
return {"ids": [], "metadatas": []}
|
||||
|
||||
def upsert(self, documents, ids, metadatas):
|
||||
self.metas.extend(metadatas)
|
||||
|
||||
chunks = [
|
||||
{
|
||||
"content": "We changed `MemoryStack` in rag/foo.py via do_thing_now().",
|
||||
"chunk_index": 0,
|
||||
}
|
||||
]
|
||||
col = FakeCol()
|
||||
monkeypatch.setattr(
|
||||
convo_miner, "file_already_mined", lambda collection, source_file, **kwargs: False
|
||||
)
|
||||
monkeypatch.setattr(convo_miner, "mine_lock", lambda source_file: contextlib.nullcontext())
|
||||
monkeypatch.setattr(convo_miner, "_detect_hall_cached", lambda content: "conversations")
|
||||
|
||||
_file_chunks_locked(col, "chat.txt", chunks, "wing", "general", "agent", "exchange")
|
||||
|
||||
entities = col.metas[0]["entities"].split(";")
|
||||
assert "MemoryStack" in entities
|
||||
assert "rag/foo.py" in entities
|
||||
assert "do_thing_now" in entities
|
||||
|
||||
|
||||
class TestExtractAuthoredAt:
|
||||
"""authored_at = max per-line ``timestamp`` in a transcript (real authored date,
|
||||
independent of mine time). Both Claude Code and Codex JSONL carry a top-level
|
||||
ISO-8601 ``timestamp`` per line."""
|
||||
|
||||
def test_returns_latest_timestamp(self, tmp_path):
|
||||
f = tmp_path / "session.jsonl"
|
||||
f.write_text(
|
||||
'{"type": "user", "timestamp": "2026-06-21T10:00:00.000Z"}\n'
|
||||
'{"type": "assistant", "timestamp": "2026-06-23T14:30:00.000Z"}\n'
|
||||
'{"type": "user", "timestamp": "2026-06-22T09:00:00.000Z"}\n'
|
||||
)
|
||||
assert _extract_authored_at(f) == "2026-06-23T14:30:00.000Z"
|
||||
|
||||
def test_ignores_lines_without_timestamp(self, tmp_path):
|
||||
f = tmp_path / "session.jsonl"
|
||||
f.write_text(
|
||||
'{"type": "summary", "summary": "x"}\n'
|
||||
'{"type": "assistant", "timestamp": "2026-06-23T14:30:00.000Z"}\n'
|
||||
)
|
||||
assert _extract_authored_at(f) == "2026-06-23T14:30:00.000Z"
|
||||
|
||||
def test_tolerates_blank_and_malformed_lines(self, tmp_path):
|
||||
f = tmp_path / "session.jsonl"
|
||||
f.write_text(
|
||||
"\n"
|
||||
"not json\n"
|
||||
"[1, 2, 3]\n" # valid JSON, but no .get()
|
||||
'{"timestamp": "2026-06-25T00:00:00.000Z"}\n'
|
||||
)
|
||||
assert _extract_authored_at(f) == "2026-06-25T00:00:00.000Z"
|
||||
|
||||
def test_none_for_non_jsonl(self, tmp_path):
|
||||
f = tmp_path / "notes.md"
|
||||
f.write_text("# heading\n")
|
||||
assert _extract_authored_at(f) is None
|
||||
|
||||
def test_none_when_no_timestamps(self, tmp_path):
|
||||
f = tmp_path / "session.jsonl"
|
||||
f.write_text('{"type": "user", "content": "hi"}\n')
|
||||
assert _extract_authored_at(f) is None
|
||||
|
||||
def test_none_for_missing_file(self, tmp_path):
|
||||
assert _extract_authored_at(tmp_path / "absent.jsonl") is None
|
||||
|
||||
def test_non_string_timestamp_does_not_crash(self, tmp_path):
|
||||
# A non-string timestamp must be skipped, not raise TypeError on compare.
|
||||
f = tmp_path / "session.jsonl"
|
||||
f.write_text(
|
||||
'{"type": "user", "timestamp": 1234567890}\n'
|
||||
'{"type": "assistant", "timestamp": {"nested": true}}\n'
|
||||
'{"type": "user", "timestamp": "2026-06-24T00:00:00.000Z"}\n'
|
||||
)
|
||||
assert _extract_authored_at(f) == "2026-06-24T00:00:00.000Z"
|
||||
|
||||
def test_only_non_string_timestamps_returns_none(self, tmp_path):
|
||||
f = tmp_path / "session.jsonl"
|
||||
f.write_text('{"timestamp": 1}\n{"timestamp": false}\n')
|
||||
assert _extract_authored_at(f) is None
|
||||
|
||||
@@ -349,11 +349,10 @@ class TestSaveHookCounter:
|
||||
f"third invocation must emit a followup_message; got {response!r}"
|
||||
)
|
||||
msg = response["followup_message"]
|
||||
# Followup must reference the real MCP tool names (regression
|
||||
# guard against future typos that would silently fail).
|
||||
assert "mempalace_add_drawer" in msg
|
||||
assert "mempalace_check_duplicate" in msg
|
||||
assert "mempalace_diary_write" in msg
|
||||
# Followup must reference the real MCP tool name (regression
|
||||
# guard against future typos that would silently fail). The save
|
||||
# is driven by a single batch checkpoint call.
|
||||
assert "mempalace_checkpoint" in msg
|
||||
assert "cursor-ide" in msg, "diary entries must be tagged agent_name=cursor-ide"
|
||||
|
||||
def test_threshold_followup_references_inferred_wing(self, tmp_path):
|
||||
|
||||
@@ -0,0 +1,908 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from _chroma_palace_helper import make_minimal_chroma_sqlite
|
||||
|
||||
from mempalace import daemon
|
||||
from mempalace import service
|
||||
|
||||
# POSIX file-mode bits (0600/0700) are not representable on Windows: os.chmod
|
||||
# can only toggle the read-only attribute, so a "private" file still reports
|
||||
# 0o666. The daemon relies on the user-profile directory ACLs for privacy
|
||||
# there, so the owner-only assertions only make sense on POSIX.
|
||||
_posix_only_perms = pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason="POSIX 0600/0700 file-mode bits are not representable on Windows (ACL-based privacy)",
|
||||
)
|
||||
|
||||
# Env keys run_server mutates from its background thread, plus umask. If a
|
||||
# lifecycle test times out before the server comes up, run_server's finally
|
||||
# never runs and those mutations leak into the rest of the suite — every later
|
||||
# test that reads MempalaceConfig().palace_path sees a stale deleted tmp path and
|
||||
# fails (the 60+ test cascade seen on slow CI runners). The fixtures below force a
|
||||
# clean baseline around every daemon test so a leaked thread can't poison the
|
||||
# process for tests/test_mcp_server.py and friends (which have no such guard).
|
||||
_LEAK_ENV_KEYS = ("MEMPALACE_PALACE_PATH", "MEMPALACE_BACKEND", "MEMPALACE_BACKEND_EXPLICIT")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def _clean_env_snapshot():
|
||||
"""Capture the true pre-suite values once, before any daemon test runs."""
|
||||
return {key: os.environ.get(key) for key in _LEAK_ENV_KEYS}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_process_global_state(_clean_env_snapshot):
|
||||
"""Restore the process-global env + umask to the pre-suite baseline after every
|
||||
daemon test, even if a leaked run_server thread is still holding them mutated.
|
||||
"""
|
||||
prev_umask = os.umask(0o022)
|
||||
os.umask(prev_umask) # read current umask without changing it
|
||||
yield
|
||||
for key, value in _clean_env_snapshot.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
os.umask(prev_umask)
|
||||
|
||||
|
||||
def _raise_not_ready(*a, **kw):
|
||||
"""Stand-in for DaemonClient when the spawned daemon must never come up."""
|
||||
raise daemon.DaemonError("not ready")
|
||||
|
||||
|
||||
def test_prune_terminal_drops_old_terminal_jobs_keeps_active(tmp_path, monkeypatch):
|
||||
"""Terminal jobs older than the retention window are pruned; queued/running
|
||||
and fresh terminal jobs are untouched. Bounded queue growth for the DB that
|
||||
holds verbatim payloads."""
|
||||
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
||||
|
||||
old_term = store.enqueue("mine", {"source": "old"})
|
||||
store.finish(old_term.id, state="succeeded", result={"success": True})
|
||||
fresh_term = store.enqueue("mine", {"source": "fresh"})
|
||||
store.finish(fresh_term.id, state="succeeded", result={"success": True})
|
||||
queued = store.enqueue("mine", {"source": "queued"})
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
|
||||
with store._lock, store._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET finished_at = ? WHERE id = ?",
|
||||
(cutoff, old_term.id),
|
||||
)
|
||||
|
||||
pruned = store.prune_terminal(older_than_days=7)
|
||||
assert pruned == 1
|
||||
# The old terminal job is gone; the fresh terminal and queued jobs survive.
|
||||
with pytest.raises(daemon.DaemonError):
|
||||
store.get(old_term.id)
|
||||
assert store.get(fresh_term.id).state == "succeeded"
|
||||
assert store.get(queued.id).state == "queued"
|
||||
|
||||
|
||||
def test_queue_dedupes_and_recovers_running_jobs(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
|
||||
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
||||
first = store.enqueue("mine", {"source": "a"}, dedupe_key="same")
|
||||
second = store.enqueue("mine", {"source": "a"}, dedupe_key="same")
|
||||
|
||||
assert second.id == first.id
|
||||
|
||||
claimed = store.claim_next()
|
||||
assert claimed.id == first.id
|
||||
assert claimed.state == "running"
|
||||
|
||||
recovered = store.recover_running()
|
||||
assert recovered == 1
|
||||
assert store.get(first.id).state == "queued"
|
||||
|
||||
|
||||
def test_daemon_http_lifecycle_executes_job(tmp_path, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_execute(kind, payload):
|
||||
calls.append((kind, payload))
|
||||
return {"success": True, "exit_code": 0, "stdout": "done\n"}
|
||||
|
||||
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
||||
|
||||
health = client.health()
|
||||
assert health["ok"] is True
|
||||
assert health["palace_path"] == daemon.canonical_palace_path(str(palace))
|
||||
|
||||
job = client.submit("mine", {"source": "src"}, dedupe_key="job")
|
||||
finished = client.wait(job["id"], timeout=5)
|
||||
|
||||
assert finished["state"] == "succeeded"
|
||||
assert finished["result"]["stdout"] == "done\n"
|
||||
assert calls == [("mine", {"source": "src", "palace_path": str(palace.resolve())})]
|
||||
|
||||
_stop_server(client, thread, holders)
|
||||
|
||||
|
||||
def test_submit_job_uses_client_and_waits(monkeypatch, tmp_path):
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
|
||||
class DummyClient:
|
||||
def __init__(self):
|
||||
self.submitted = None
|
||||
|
||||
def submit(self, kind, payload, dedupe_key=None, priority=0):
|
||||
self.submitted = (kind, payload, dedupe_key, priority)
|
||||
return {"id": "job-1", "state": "queued"}
|
||||
|
||||
def wait(self, job_id, timeout=daemon.DEFAULT_WAIT_TIMEOUT):
|
||||
assert job_id == "job-1"
|
||||
return {
|
||||
"id": "job-1",
|
||||
"state": "succeeded",
|
||||
"result": {"success": True, "exit_code": 0},
|
||||
}
|
||||
|
||||
dummy = DummyClient()
|
||||
monkeypatch.setattr(daemon, "ensure_client", lambda *a, **kw: dummy)
|
||||
|
||||
job = daemon.submit_job(
|
||||
"mine",
|
||||
{"source": "src"},
|
||||
palace_path=str(palace),
|
||||
dedupe_key="dedupe",
|
||||
wait=True,
|
||||
)
|
||||
|
||||
assert job["state"] == "succeeded"
|
||||
assert dummy.submitted[0] == "mine"
|
||||
# palace_path is overridden (not trusted from the payload), never appended.
|
||||
assert dummy.submitted[1]["palace_path"] == daemon.canonical_palace_path(str(palace))
|
||||
assert dummy.submitted[2] == "dedupe"
|
||||
|
||||
|
||||
def test_service_tool_classification():
|
||||
assert service.classify_tool("mempalace_search") == "read"
|
||||
assert service.classify_tool("mempalace_add_drawer") == "write"
|
||||
assert service.classify_tool("mempalace_checkpoint") == "write"
|
||||
assert service.classify_tool("mempalace_delete_by_source") == "write"
|
||||
assert service.classify_tool("mempalace_mine") == "maintenance"
|
||||
assert service.classify_tool("unknown") == "unknown"
|
||||
|
||||
|
||||
# --- helpers for HTTP-lifecycle tests ---
|
||||
|
||||
|
||||
def _capture_httpd(monkeypatch):
|
||||
"""Capture the httpd instance run_server creates.
|
||||
|
||||
run_server defines a local ``class _Server(ThreadingHTTPServer)``; by
|
||||
monkeypatching ``daemon.ThreadingHTTPServer`` before run_server runs, that
|
||||
subclass inherits from a capturing base that records each instance. The
|
||||
httpd can then be force-stopped from the test thread (see _stop_server) so a
|
||||
slow/failed ``client.shutdown()`` POST can never leave the server thread
|
||||
alive — which on Windows hangs the interpreter at process exit on an open
|
||||
listening socket.
|
||||
"""
|
||||
holders: list = []
|
||||
base = daemon.ThreadingHTTPServer
|
||||
|
||||
class _CapturingServer(base):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
holders.append(self)
|
||||
|
||||
monkeypatch.setattr(daemon, "ThreadingHTTPServer", _CapturingServer)
|
||||
return holders
|
||||
|
||||
|
||||
def _stop_server(client, thread, holders, *, join_timeout=5.0):
|
||||
"""Shut the daemon down deterministically and assert the thread died.
|
||||
|
||||
First try the normal path (POST /shutdown). If the server thread is still
|
||||
alive afterwards — the POST was slow, lost, or the drain overran the join —
|
||||
call httpd.shutdown() directly from this thread (stdlib-safe: it is a
|
||||
different thread than serve_forever) to force serve_forever to return, then
|
||||
re-join. The assert turns a leak into a visible failure instead of a silent
|
||||
interpreter-exit hang.
|
||||
"""
|
||||
try:
|
||||
client.shutdown()
|
||||
except Exception: # noqa: BLE001 - best-effort; we force-shutdown below
|
||||
pass
|
||||
thread.join(timeout=join_timeout)
|
||||
if thread.is_alive() and holders:
|
||||
httpd = holders[-1]
|
||||
try:
|
||||
httpd.shutdown()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
httpd.server_close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
thread.join(timeout=join_timeout)
|
||||
assert not thread.is_alive(), "daemon server thread did not shut down"
|
||||
|
||||
|
||||
def _start_server(tmp_path, monkeypatch, execute_fn):
|
||||
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
monkeypatch.setattr(service, "execute_job", execute_fn)
|
||||
holders = _capture_httpd(monkeypatch)
|
||||
|
||||
# Capture any exception run_server raises in its thread. Without this a
|
||||
# startup crash is invisible: the poll below would just spin for 30s and
|
||||
# fail with a bare ``assert client is not None`` giving no cause.
|
||||
server_error: list = []
|
||||
|
||||
def _serve():
|
||||
try:
|
||||
daemon.run_server(palace_path=str(palace), port=0)
|
||||
except BaseException as exc: # noqa: BLE001 - re-surfaced to the test thread
|
||||
server_error.append(exc)
|
||||
|
||||
thread = threading.Thread(target=_serve, name="test-daemon-server", daemon=True)
|
||||
thread.start()
|
||||
client = None
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
if server_error:
|
||||
raise AssertionError(f"run_server crashed during startup: {server_error[0]!r}")
|
||||
client = daemon.get_client_if_running(str(palace))
|
||||
if client is not None:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if client is None:
|
||||
raise AssertionError(
|
||||
"daemon did not become ready within 30s "
|
||||
f"(thread_alive={thread.is_alive()}, httpd_bound={bool(holders)}, "
|
||||
f"endpoint_exists={daemon.endpoint_path(str(palace)).exists()})"
|
||||
)
|
||||
return client, thread, palace, holders
|
||||
|
||||
|
||||
# --- ship-blocker regressions ---
|
||||
|
||||
|
||||
def test_systemexit_in_job_does_not_kill_worker(tmp_path, monkeypatch):
|
||||
"""A SystemExit (BaseException, not Exception) must be caught, the job
|
||||
marked failed, and the worker kept alive for the next job. Regression for
|
||||
the critical worker-death bug."""
|
||||
state = {"first": True}
|
||||
|
||||
def fake_execute(kind, payload):
|
||||
if state["first"]:
|
||||
state["first"] = False
|
||||
raise SystemExit("boom")
|
||||
return {"success": True, "exit_code": 0}
|
||||
|
||||
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
||||
try:
|
||||
first = client.submit("mine", {"source": "src"})
|
||||
finished_first = client.wait(first["id"], timeout=5)
|
||||
assert finished_first["state"] == "failed"
|
||||
assert finished_first["error"]["error_class"] == "SystemExit"
|
||||
|
||||
# Worker must still be alive — health reports it and a second job runs.
|
||||
assert client.health()["worker_alive"] is True
|
||||
second = client.submit("mine", {"source": "src2"})
|
||||
finished_second = client.wait(second["id"], timeout=5)
|
||||
assert finished_second["state"] == "succeeded"
|
||||
finally:
|
||||
_stop_server(client, thread, holders)
|
||||
|
||||
|
||||
def test_shutdown_cancels_active_job(tmp_path, monkeypatch):
|
||||
"""POST /shutdown must not leave an in-flight job 'running' for blind
|
||||
re-queue on next start. The worker is drained (bounded), then the active
|
||||
job is marked 'cancelled' so recover_running won't re-run it.
|
||||
|
||||
In production the serve process exits immediately after run_server returns,
|
||||
killing the daemon worker thread before it can overwrite the cancelled
|
||||
state. The test mirrors that by asserting the cancelled state *before*
|
||||
releasing the blocked worker.
|
||||
"""
|
||||
block = threading.Event()
|
||||
|
||||
def fake_execute(kind, payload):
|
||||
# Simulate a long-running job that never finishes on its own.
|
||||
block.wait(30)
|
||||
return {"success": True, "exit_code": 0}
|
||||
|
||||
monkeypatch.setattr(daemon, "SHUTDOWN_DRAIN_SECONDS", 0.2)
|
||||
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
||||
job = client.submit("mine", {"source": "src"}, dedupe_key="x")
|
||||
# Wait until the worker has claimed it (state flips to running).
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if client.get_job(job["id"])["state"] == "running":
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert client.get_job(job["id"])["state"] == "running"
|
||||
|
||||
_stop_server(client, thread, holders)
|
||||
|
||||
# The interrupted job must be cancelled (terminal), not left running.
|
||||
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
||||
final = store.get(job["id"])
|
||||
assert final.state == "cancelled"
|
||||
# And recover_running must not re-queue a cancelled job.
|
||||
assert store.recover_running() == 0
|
||||
|
||||
# Release the blocked worker so it (and the daemon thread) can exit.
|
||||
block.set()
|
||||
|
||||
|
||||
def test_recover_running_dead_letters_exhausted_jobs(tmp_path, monkeypatch):
|
||||
"""A job that has crashed MAX_ATTEMPTS times must be dead-lettered to
|
||||
'failed', not re-queued — non-idempotent kinds (diary_write) would
|
||||
otherwise duplicate verbatim content on every restart."""
|
||||
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
||||
job = store.enqueue("diary_write", {"entry": "x"})
|
||||
# Simulate MAX_ATTEMPTS claims that each crashed (running, attempts=MAX).
|
||||
with store._lock, store._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state='running', attempts=? WHERE id=?",
|
||||
(daemon.MAX_ATTEMPTS, job.id),
|
||||
)
|
||||
|
||||
recovered = store.recover_running()
|
||||
assert recovered == 0 # not re-queued
|
||||
final = store.get(job.id)
|
||||
assert final.state == "failed"
|
||||
assert final.attempts == daemon.MAX_ATTEMPTS
|
||||
|
||||
|
||||
def test_claim_next_does_not_reclaim_running_job(tmp_path, monkeypatch):
|
||||
"""The conditional UPDATE (WHERE state='queued') means a job already
|
||||
flipped to 'running' cannot be claimed again — the cross-process
|
||||
double-execution guard."""
|
||||
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
||||
job = store.enqueue("mine", {"source": "src"})
|
||||
first = store.claim_next()
|
||||
assert first.id == job.id
|
||||
# Manually re-mark it queued but leave a second claim attempt: claim_next
|
||||
# should still only ever return one running job per claim. After finishing
|
||||
# the first, the next claim returns None (queue empty).
|
||||
store.finish(first.id, state="succeeded", result={"success": True})
|
||||
assert store.claim_next() is None
|
||||
|
||||
|
||||
@_posix_only_perms
|
||||
def test_queue_db_file_is_owner_only(tmp_path, monkeypatch):
|
||||
"""The queue DB holds verbatim payloads — it must be 0600, not the sqlite
|
||||
default 0644. Regression for the privacy-principle violation."""
|
||||
import os as _os
|
||||
|
||||
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
||||
store.enqueue("diary_write", {"entry": "secret verbatim content"})
|
||||
mode = _os.stat(str(store.path)).st_mode & 0o777
|
||||
assert mode == 0o600, f"queue.sqlite3 is {oct(mode)}, expected 0600"
|
||||
|
||||
|
||||
@_posix_only_perms
|
||||
def test_token_file_is_owner_only(tmp_path, monkeypatch):
|
||||
import os as _os
|
||||
|
||||
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
daemon.ensure_token(str(palace))
|
||||
token_path = daemon.state_dir(str(palace)) / "token"
|
||||
assert (_os.stat(str(token_path)).st_mode & 0o777) == 0o600
|
||||
|
||||
|
||||
def test_health_rejects_missing_and_wrong_token(tmp_path, monkeypatch):
|
||||
from urllib import error as urlerror
|
||||
from urllib import request as urlrequest
|
||||
|
||||
client, thread, palace, holders = _start_server(
|
||||
tmp_path, monkeypatch, lambda k, p: {"success": True}
|
||||
)
|
||||
try:
|
||||
base = f"http://127.0.0.1:{client.port}"
|
||||
# No Authorization header → 401.
|
||||
with pytest.raises(urlerror.HTTPError):
|
||||
urlrequest.urlopen(urlrequest.Request(base + "/health"), timeout=3)
|
||||
# Wrong token → 401.
|
||||
with pytest.raises(urlerror.HTTPError):
|
||||
urlrequest.urlopen(
|
||||
urlrequest.Request(base + "/health", headers={"Authorization": "Bearer wrong"}),
|
||||
timeout=3,
|
||||
)
|
||||
finally:
|
||||
_stop_server(client, thread, holders)
|
||||
|
||||
|
||||
def test_worker_overrides_client_palace_path(tmp_path, monkeypatch):
|
||||
"""An authenticated client must not be able to retarget the daemon at a
|
||||
different palace by stuffing palace_path into the payload."""
|
||||
seen = {}
|
||||
|
||||
def fake_execute(kind, payload):
|
||||
seen["palace_path"] = payload.get("palace_path")
|
||||
return {"success": True, "exit_code": 0}
|
||||
|
||||
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
||||
try:
|
||||
job = client.submit(
|
||||
"mine", {"source": "src", "palace_path": "/tmp/other-palace"}, dedupe_key="p"
|
||||
)
|
||||
client.wait(job["id"], timeout=5)
|
||||
finally:
|
||||
_stop_server(client, thread, holders)
|
||||
assert seen["palace_path"] == daemon.canonical_palace_path(str(palace))
|
||||
assert seen["palace_path"] != "/tmp/other-palace"
|
||||
|
||||
|
||||
def test_mcp_tool_allowlist_rejects_non_write_tools(tmp_path, monkeypatch):
|
||||
"""The daemon queue is a durable write surface; read/maintenance/unknown
|
||||
tools must be rejected so verbatim content can't be exfiltrated into the
|
||||
queue or retried destructively."""
|
||||
# read tool → rejected
|
||||
out = service.run_mcp_tool({"name": "mempalace_search", "arguments": {}})
|
||||
assert out["success"] is False
|
||||
assert "only accepts write tools" in out["error"]
|
||||
# maintenance tool → rejected (has its own kinds: mine/sync)
|
||||
out = service.run_mcp_tool({"name": "mempalace_mine", "arguments": {}})
|
||||
assert out["success"] is False
|
||||
# unknown tool → rejected
|
||||
out = service.run_mcp_tool({"name": "mempalace_bogus", "arguments": {}})
|
||||
assert out["success"] is False
|
||||
# write tool → passes the allowlist (handler not called here since TOOLS
|
||||
# won't have it under the test name; but classification must let it through)
|
||||
assert service.classify_tool("mempalace_add_drawer") == "write"
|
||||
|
||||
|
||||
def test_execute_job_isolates_env_per_job(monkeypatch):
|
||||
"""A job that mutates MEMPALACE_BACKEND must not leak into the next job's
|
||||
env. Regression for the per-job isolation bug (_apply_backend poisoning)."""
|
||||
import os as _os
|
||||
|
||||
monkeypatch.delenv("MEMPALACE_BACKEND", raising=False)
|
||||
monkeypatch.delenv("MEMPALACE_PALACE_PATH", raising=False)
|
||||
|
||||
def fake_mine(payload):
|
||||
_os.environ["MEMPALACE_BACKEND"] = "leaked-backend"
|
||||
return {"success": True, "exit_code": 0}
|
||||
|
||||
monkeypatch.setattr(service, "run_mine", fake_mine)
|
||||
service.execute_job("mine", {"palace_path": "/tmp/p", "source": "s"})
|
||||
assert _os.environ.get("MEMPALACE_BACKEND") is None
|
||||
|
||||
|
||||
def test_daemon_client_raises_on_endpoint_missing_port(tmp_path, monkeypatch):
|
||||
"""A malformed endpoint.json must raise DaemonError, not a bare KeyError."""
|
||||
import json as _json
|
||||
|
||||
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
daemon.ensure_token(str(palace))
|
||||
# endpoint with no port
|
||||
daemon.state_dir(str(palace)).mkdir(parents=True, exist_ok=True)
|
||||
(daemon.state_dir(str(palace)) / "endpoint.json").write_text(
|
||||
_json.dumps({"host": "127.0.0.1", "pid": 1}) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(daemon.DaemonError):
|
||||
daemon.DaemonClient(str(palace))
|
||||
|
||||
|
||||
def test_pid_alive_probe_is_signal_free_and_correct():
|
||||
"""``_pid_alive`` must be a pure liveness probe.
|
||||
|
||||
On Windows ``os.kill(pid, 0)`` is NOT harmless — signal 0 is
|
||||
``CTRL_C_EVENT``, so it emits a console Ctrl-C to the target's process
|
||||
group. The daemon client polls a same-process endpoint, so that Ctrl-C was
|
||||
delivered back to the interpreter and surfaced as a spurious
|
||||
``KeyboardInterrupt`` that hung the whole test session on CI runners (which,
|
||||
unlike a detached dev shell, have an attached console). Assert the probe is
|
||||
both correct and emits no SIGINT even when hammered like the poll loop.
|
||||
"""
|
||||
import signal
|
||||
|
||||
assert daemon._pid_alive(os.getpid()) is True
|
||||
assert daemon._pid_alive(0) is False
|
||||
assert daemon._pid_alive(-1) is False
|
||||
# A pid that is almost certainly not running.
|
||||
assert daemon._pid_alive(2_000_000_000) is False
|
||||
|
||||
# pytest runs tests on the main thread, so installing a SIGINT handler is
|
||||
# allowed. If the probe regresses to os.kill(pid, 0) on Windows, the repeated
|
||||
# calls below deliver CTRL_C_EVENT and this handler fires.
|
||||
fired = []
|
||||
previous = signal.getsignal(signal.SIGINT)
|
||||
signal.signal(signal.SIGINT, lambda *_: fired.append(1))
|
||||
try:
|
||||
for _ in range(25):
|
||||
daemon._pid_alive(os.getpid())
|
||||
time.sleep(0.25)
|
||||
finally:
|
||||
signal.signal(signal.SIGINT, previous)
|
||||
assert fired == [], "_pid_alive delivered a console control event (CTRL_C_EVENT)"
|
||||
|
||||
|
||||
def test_start_daemon_kills_orphan_on_readiness_timeout(tmp_path, monkeypatch):
|
||||
"""If the spawned daemon never becomes ready, start_daemon must kill and
|
||||
reap the orphaned subprocess rather than leaking it with the port/token."""
|
||||
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
daemon.ensure_token(str(palace))
|
||||
|
||||
monkeypatch.setattr(daemon, "get_client_if_running", lambda *a, **kw: None)
|
||||
|
||||
class FakeProc:
|
||||
def __init__(self):
|
||||
self.killed = False
|
||||
self.returncode = None
|
||||
|
||||
def poll(self):
|
||||
return self.returncode # None == still alive
|
||||
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
|
||||
def wait(self):
|
||||
self.returncode = -9
|
||||
return self.returncode
|
||||
|
||||
fake = FakeProc()
|
||||
|
||||
def fake_popen(*a, **kw):
|
||||
return fake
|
||||
|
||||
monkeypatch.setattr(daemon.subprocess, "Popen", fake_popen)
|
||||
monkeypatch.setattr(daemon, "DaemonClient", _raise_not_ready)
|
||||
monkeypatch.setattr(daemon.time, "sleep", lambda *a, **kw: None)
|
||||
|
||||
with pytest.raises(daemon.DaemonError):
|
||||
daemon.start_daemon(str(palace), timeout=0.05)
|
||||
assert fake.killed is True
|
||||
|
||||
|
||||
# --- service.run_* happy-path coverage ---
|
||||
# These close the draft PR's follow-up ("Add focused happy-path tests for
|
||||
# service.run_mine / run_diary_write / run_mcp_tool") and, now that the daemon
|
||||
# tests complete reliably, keep service.py's coverage above the CI gate. The
|
||||
# capsys-using tests come first; the two that import mempalace.mcp_server (which
|
||||
# rebinds sys.stdout) come last and do not use capsys, so the rebind can't break
|
||||
# capture in this file or later files (capsys activates after the rebind).
|
||||
|
||||
|
||||
def test_print_job_result_replays_stdout_stderr_and_returns_exit_code(capsys):
|
||||
from mempalace import service
|
||||
|
||||
code = service.print_job_result(
|
||||
{"success": False, "error": "boom", "stdout": "out\n", "stderr": "err\n", "exit_code": 3}
|
||||
)
|
||||
assert code == 3
|
||||
captured = capsys.readouterr()
|
||||
assert "out" in captured.out
|
||||
assert "err" in captured.err
|
||||
|
||||
|
||||
def test_print_job_result_prints_error_to_stderr_when_no_stderr(capsys):
|
||||
from mempalace import service
|
||||
|
||||
code = service.print_job_result({"success": False, "error": "boom", "exit_code": 1})
|
||||
assert code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "mempalace: boom" in captured.err
|
||||
|
||||
|
||||
def test_run_sync_returns_success_when_palace_dir_missing(tmp_path):
|
||||
from mempalace import service
|
||||
|
||||
result = service.run_sync({"palace_path": str(tmp_path / "nope"), "dry_run": True})
|
||||
assert result["success"] is True
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
|
||||
def test_run_sync_returns_success_when_palace_has_no_backend_artifact(tmp_path):
|
||||
from mempalace import service
|
||||
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
result = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
||||
assert result["success"] is True
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
|
||||
def test_run_mine_invalid_mode_returns_structured_error(tmp_path):
|
||||
from mempalace import service
|
||||
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
out = service.run_mine({"palace_path": str(palace), "mode": "bogus"})
|
||||
assert out["success"] is False
|
||||
assert "invalid mine mode" in out["error"]
|
||||
assert out["exit_code"] == 2
|
||||
|
||||
|
||||
def test_run_mcp_tool_rejects_non_dict_arguments():
|
||||
from mempalace import service
|
||||
|
||||
out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": "nope"})
|
||||
assert out["success"] is False
|
||||
assert "must be an object" in out["error"]
|
||||
assert out["exit_code"] == 2
|
||||
|
||||
|
||||
def test_run_mcp_tool_dispatches_write_tool(monkeypatch):
|
||||
import mempalace.mcp_server as mcp
|
||||
from mempalace import service
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_handler(**arguments):
|
||||
captured["arguments"] = arguments
|
||||
return {"success": True, "written": True}
|
||||
|
||||
monkeypatch.setattr(mcp, "TOOLS", {"mempalace_add_drawer": {"handler": fake_handler}})
|
||||
out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": {"x": 1}})
|
||||
assert out["success"] is True
|
||||
assert out["written"] is True
|
||||
assert out["exit_code"] == 0
|
||||
assert captured["arguments"] == {"x": 1}
|
||||
|
||||
|
||||
def test_run_diary_write_forwards_args_and_sets_exit_code(monkeypatch):
|
||||
import mempalace.mcp_server as mcp
|
||||
from mempalace import service
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_diary(agent_name, entry, topic, wing):
|
||||
captured.update(agent_name=agent_name, entry=entry, topic=topic, wing=wing)
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(mcp, "tool_diary_write", fake_diary)
|
||||
out = service.run_diary_write(
|
||||
{"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}
|
||||
)
|
||||
assert out["success"] is True
|
||||
assert out["exit_code"] == 0
|
||||
assert captured == {"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}
|
||||
|
||||
|
||||
def test_run_mine_applies_backend_before_mode_validation(tmp_path):
|
||||
"""Covers _apply_backend (env set + get_backend_class validation) on the daemon
|
||||
path; the invalid mode short-circuits before any mining runs."""
|
||||
from mempalace import service
|
||||
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
out = service.run_mine({"palace_path": str(palace), "mode": "bogus", "backend": "chroma"})
|
||||
assert out["success"] is False
|
||||
assert out["exit_code"] == 2
|
||||
|
||||
|
||||
def test_execute_job_dispatches_diary_write_mcp_tool_and_unknown(monkeypatch):
|
||||
"""Covers execute_job's kind dispatch for diary_write, mcp_tool, and the
|
||||
unknown-kind fallback."""
|
||||
import mempalace.mcp_server as mcp
|
||||
from mempalace import service
|
||||
|
||||
monkeypatch.setattr(mcp, "tool_diary_write", lambda **kw: {"success": True})
|
||||
monkeypatch.setattr(
|
||||
mcp, "TOOLS", {"mempalace_add_drawer": {"handler": lambda **kw: {"success": True}}}
|
||||
)
|
||||
assert service.execute_job("diary_write", {"entry": "x"})["success"] is True
|
||||
assert (
|
||||
service.execute_job("mcp_tool", {"name": "mempalace_add_drawer", "arguments": {}})[
|
||||
"success"
|
||||
]
|
||||
is True
|
||||
)
|
||||
unknown = service.execute_job("bogus_kind", {})
|
||||
assert unknown["success"] is False
|
||||
assert unknown["exit_code"] == 2
|
||||
|
||||
|
||||
def test_run_sync_structured_errors_on_sync_failures(tmp_path, monkeypatch):
|
||||
"""Covers run_sync's three exception handlers (MineAlreadyRunning, ValueError,
|
||||
generic Exception) so a failing sync_palace returns a structured error instead
|
||||
of propagating."""
|
||||
import mempalace.sync as sync_module
|
||||
from mempalace import service
|
||||
from mempalace.palace import MineAlreadyRunning
|
||||
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
make_minimal_chroma_sqlite(palace)
|
||||
|
||||
def _raise(exc):
|
||||
def fn(**kw):
|
||||
raise exc
|
||||
|
||||
return fn
|
||||
|
||||
monkeypatch.setattr(sync_module, "sync_palace", _raise(MineAlreadyRunning("locked")))
|
||||
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
||||
assert r["success"] is False
|
||||
assert r["error_class"] == "LockHeldByOtherProcess"
|
||||
|
||||
monkeypatch.setattr(sync_module, "sync_palace", _raise(ValueError("bad scope")))
|
||||
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
||||
assert r["success"] is False
|
||||
assert r["exit_code"] == 2
|
||||
|
||||
monkeypatch.setattr(sync_module, "sync_palace", _raise(RuntimeError("boom")))
|
||||
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
||||
assert r["success"] is False
|
||||
assert "sync failed" in r["error"]
|
||||
|
||||
|
||||
# --- post-merge review follow-ups (Copilot review on #1826) ---
|
||||
|
||||
|
||||
@_posix_only_perms
|
||||
def test_run_server_tightens_umask_before_building_queue(tmp_path, monkeypatch):
|
||||
"""The owner-only umask must be active BEFORE the queue DB is built.
|
||||
|
||||
SQLite's WAL/SHM sidecars hold un-checkpointed verbatim payloads and are
|
||||
created with the process umask, so a loose umask at DaemonRuntime/QueueStore
|
||||
construction time would leave them world-readable. Capture the umask at the
|
||||
moment DaemonRuntime is constructed and assert it is already 0o077.
|
||||
"""
|
||||
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
|
||||
captured = {}
|
||||
|
||||
def _spy_runtime(*args, **kwargs):
|
||||
current = os.umask(0o022)
|
||||
os.umask(current) # restore without changing
|
||||
captured["umask"] = current
|
||||
raise RuntimeError("stop before binding a real socket")
|
||||
|
||||
monkeypatch.setattr(daemon, "DaemonRuntime", _spy_runtime)
|
||||
with pytest.raises(RuntimeError):
|
||||
daemon.run_server(str(palace), port=0)
|
||||
assert captured["umask"] == 0o077
|
||||
|
||||
|
||||
def test_negative_content_length_is_rejected_without_blocking(tmp_path, monkeypatch):
|
||||
"""A POST with Content-Length: -1 must get a prompt 400, not hang the worker.
|
||||
|
||||
rfile.read(-1) would read until the client closes the socket (an auth-gated
|
||||
DoS) and bypass the MAX_BODY_BYTES cap. The recv timeout below turns a
|
||||
regression into a failure instead of a hang.
|
||||
"""
|
||||
import socket
|
||||
|
||||
client, thread, palace, holders = _start_server(
|
||||
tmp_path, monkeypatch, lambda k, p: {"success": True}
|
||||
)
|
||||
try:
|
||||
sock = socket.create_connection((client.host, client.port), timeout=5)
|
||||
sock.settimeout(5)
|
||||
request = (
|
||||
"POST /jobs HTTP/1.1\r\n"
|
||||
"Host: daemon\r\n"
|
||||
f"Authorization: Bearer {client.token}\r\n"
|
||||
"Content-Length: -1\r\n"
|
||||
"Connection: close\r\n\r\n"
|
||||
)
|
||||
sock.sendall(request.encode("ascii"))
|
||||
status_line = sock.recv(4096).decode("latin-1").split("\r\n", 1)[0]
|
||||
sock.close()
|
||||
assert "400" in status_line, f"expected 400, got {status_line!r}"
|
||||
finally:
|
||||
_stop_server(client, thread, holders)
|
||||
|
||||
|
||||
def test_run_mcp_tool_marks_bare_error_dict_as_failure(monkeypatch):
|
||||
"""A write tool that returns {"error": ...} with no success flag must be
|
||||
recorded as a failed job, not succeeded (Copilot review)."""
|
||||
import mempalace.mcp_server as mcp
|
||||
from mempalace import service
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp,
|
||||
"TOOLS",
|
||||
{"mempalace_create_tunnel": {"handler": lambda **kw: {"error": "bad endpoint"}}},
|
||||
)
|
||||
out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
|
||||
assert out["success"] is False
|
||||
assert out["exit_code"] == 1
|
||||
assert out["error"] == "bad endpoint"
|
||||
|
||||
# A result with neither an explicit success flag nor an error is a success.
|
||||
monkeypatch.setattr(
|
||||
mcp, "TOOLS", {"mempalace_create_tunnel": {"handler": lambda **kw: {"tunnel_id": "t1"}}}
|
||||
)
|
||||
out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
|
||||
assert out["success"] is True
|
||||
assert out["exit_code"] == 0
|
||||
|
||||
|
||||
def test_get_client_if_running_uses_short_probe_timeout(monkeypatch):
|
||||
"""The hook liveness precheck must pass a short health timeout so a wedged
|
||||
daemon can't stall the hook past its budget (Copilot review)."""
|
||||
captured = {}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, palace_path):
|
||||
pass
|
||||
|
||||
def health(self, *, timeout):
|
||||
captured["timeout"] = timeout
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(daemon, "DaemonClient", _FakeClient)
|
||||
|
||||
assert daemon.HOOK_PROBE_TIMEOUT <= 0.5
|
||||
client = daemon.get_client_if_running("/p", health_timeout=daemon.HOOK_PROBE_TIMEOUT)
|
||||
assert client is not None
|
||||
assert captured["timeout"] == daemon.HOOK_PROBE_TIMEOUT
|
||||
|
||||
|
||||
# --- _detached_kwargs ---
|
||||
|
||||
|
||||
def test_detached_kwargs_posix(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("mempalace.daemon.os.name", "posix")
|
||||
kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
|
||||
fh = kwargs["stdout"]
|
||||
try:
|
||||
assert kwargs.get("start_new_session") is True
|
||||
assert kwargs.get("stdin") is subprocess.DEVNULL
|
||||
assert kwargs.get("close_fds") is True
|
||||
assert "creationflags" not in kwargs
|
||||
finally:
|
||||
fh.close()
|
||||
|
||||
|
||||
def test_detached_kwargs_windows(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("mempalace.daemon.os.name", "nt")
|
||||
monkeypatch.setattr("mempalace.daemon.subprocess.CREATE_NO_WINDOW", 0x08000000, raising=False)
|
||||
monkeypatch.setattr("mempalace.daemon.subprocess.DETACHED_PROCESS", 0x00000008, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"mempalace.daemon.subprocess.CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"mempalace.daemon.subprocess.CREATE_BREAKAWAY_FROM_JOB", 0x01000000, raising=False
|
||||
)
|
||||
kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
|
||||
fh = kwargs["stdout"]
|
||||
try:
|
||||
flags = kwargs.get("creationflags", 0)
|
||||
assert flags & 0x08000000, "CREATE_NO_WINDOW must be set"
|
||||
assert not (flags & 0x00000008), (
|
||||
"DETACHED_PROCESS must NOT be set (it suppresses CREATE_NO_WINDOW)"
|
||||
)
|
||||
assert flags & 0x00000200, "CREATE_NEW_PROCESS_GROUP preserved (Ctrl-Break group isolation)"
|
||||
assert flags & 0x01000000, (
|
||||
"CREATE_BREAKAWAY_FROM_JOB preserved (survive parent Job-Object close)"
|
||||
)
|
||||
assert kwargs.get("stdin") is subprocess.DEVNULL
|
||||
assert kwargs.get("close_fds") is True
|
||||
# creationflags + start_new_session are mutually exclusive on Windows
|
||||
# (Popen raises ValueError); the nt branch must not set the latter.
|
||||
assert "start_new_session" not in kwargs
|
||||
finally:
|
||||
fh.close()
|
||||
+99
-3
@@ -73,7 +73,7 @@ def test_onnxruntime_import_error_falls_back_to_cpu(monkeypatch):
|
||||
|
||||
def test_get_embedding_function_caches_by_resolved_provider_tuple(monkeypatch):
|
||||
class DummyEF:
|
||||
def __init__(self, preferred_providers):
|
||||
def __init__(self, preferred_providers, intra_op_num_threads=0):
|
||||
self.preferred_providers = preferred_providers
|
||||
|
||||
monkeypatch.setattr(embedding, "_build_ef_class", lambda: DummyEF)
|
||||
@@ -81,13 +81,109 @@ def test_get_embedding_function_caches_by_resolved_provider_tuple(monkeypatch):
|
||||
embedding, "_resolve_providers", lambda device: (["CPUExecutionProvider"], "cpu")
|
||||
)
|
||||
|
||||
first = embedding.get_embedding_function("cpu")
|
||||
second = embedding.get_embedding_function("auto")
|
||||
first = embedding.get_embedding_function("cpu", "minilm")
|
||||
second = embedding.get_embedding_function("auto", "minilm")
|
||||
|
||||
assert first is second
|
||||
assert first.preferred_providers == ["CPUExecutionProvider"]
|
||||
|
||||
|
||||
def test_intra_op_session_options_caps_threads():
|
||||
so = embedding._intra_op_session_options(3)
|
||||
assert so is not None
|
||||
assert so.intra_op_num_threads == 3
|
||||
|
||||
|
||||
def test_intra_op_session_options_uncapped_returns_none():
|
||||
assert embedding._intra_op_session_options(0) is None
|
||||
assert embedding._intra_op_session_options(-1) is None
|
||||
|
||||
|
||||
def test_get_embedding_function_threads_cap_passed_to_minilm_ef(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class DummyEF:
|
||||
def __init__(self, preferred_providers, intra_op_num_threads=0):
|
||||
captured["threads"] = intra_op_num_threads
|
||||
|
||||
monkeypatch.setattr(embedding, "_build_ef_class", lambda: DummyEF)
|
||||
monkeypatch.setattr(
|
||||
embedding, "_resolve_providers", lambda device: (["CPUExecutionProvider"], "cpu")
|
||||
)
|
||||
monkeypatch.setattr(embedding, "_resolve_intra_op_threads", lambda: 2)
|
||||
|
||||
embedding.get_embedding_function("cpu", "minilm")
|
||||
|
||||
assert captured["threads"] == 2
|
||||
|
||||
|
||||
def test_get_embedding_function_threads_cap_passed_to_embeddinggemma(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class DummyGemma:
|
||||
def __init__(self, preferred_providers=None, intra_op_num_threads=0):
|
||||
captured["threads"] = intra_op_num_threads
|
||||
|
||||
monkeypatch.setattr(embedding, "EmbeddinggemmaONNX", DummyGemma)
|
||||
monkeypatch.setattr(
|
||||
embedding, "_resolve_providers", lambda device: (["CPUExecutionProvider"], "cpu")
|
||||
)
|
||||
monkeypatch.setattr(embedding, "_resolve_intra_op_threads", lambda: 4)
|
||||
|
||||
embedding.get_embedding_function("cpu", "embeddinggemma")
|
||||
|
||||
assert captured["threads"] == 4
|
||||
|
||||
|
||||
def test_minilm_ef_model_override_applies_thread_cap(monkeypatch):
|
||||
"""The ``_MempalaceONNX.model`` override must construct the ORT session
|
||||
with the configured ``intra_op_num_threads`` (#1068). We stub
|
||||
``InferenceSession`` to capture the ``SessionOptions`` it receives, so the
|
||||
test never downloads or loads the real model."""
|
||||
import onnxruntime as ort
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_session(model_path, providers=None, sess_options=None):
|
||||
captured["sess_options"] = sess_options
|
||||
captured["providers"] = providers
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(ort, "InferenceSession", fake_session)
|
||||
|
||||
ef_cls = embedding._build_ef_class()
|
||||
ef = ef_cls(preferred_providers=["CPUExecutionProvider"], intra_op_num_threads=2)
|
||||
_ = ef.model # triggers the cached_property build
|
||||
|
||||
assert captured["sess_options"] is not None
|
||||
assert captured["sess_options"].intra_op_num_threads == 2
|
||||
assert "CoreMLExecutionProvider" not in captured["providers"]
|
||||
|
||||
|
||||
def test_minilm_ef_model_override_falls_back_when_uncapped(monkeypatch):
|
||||
"""With no cap (0), the override must defer to the parent build via
|
||||
``super().model`` — not reach into ``cached_property`` internals (#1068
|
||||
review). Proves super() resolves the parent descriptor without error."""
|
||||
import onnxruntime as ort
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_session(model_path, providers=None, sess_options=None):
|
||||
captured["sess_options"] = sess_options
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(ort, "InferenceSession", fake_session)
|
||||
|
||||
ef_cls = embedding._build_ef_class()
|
||||
ef = ef_cls(preferred_providers=["CPUExecutionProvider"], intra_op_num_threads=0)
|
||||
session = ef.model # cap <= 0 → super().model (upstream builder)
|
||||
|
||||
assert session is not None
|
||||
# Upstream leaves intra_op at ORT's default (0 = unset), confirming we
|
||||
# deferred to it rather than applying our cap.
|
||||
assert captured["sess_options"].intra_op_num_threads == 0
|
||||
|
||||
|
||||
def test_describe_device_uses_resolved_effective_device(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
embedding,
|
||||
|
||||
@@ -124,3 +124,98 @@ def test_add_wraps_bare_string_ids_and_dict_metadatas(monkeypatch):
|
||||
# documents / embeddings / ids / metadatas all length-aligned at 1
|
||||
assert call["documents"] == ["solo"]
|
||||
assert len(call["embeddings"]) == 1
|
||||
|
||||
|
||||
def test_facet_counts_forwards_to_inner():
|
||||
"""``BaseCollection.facet_counts`` is a concrete method (raises) — Python
|
||||
MRO resolves it on the wrapper subclass before ``__getattr__`` ever fires,
|
||||
so without an explicit forwarder the wrapper would raise
|
||||
``UnsupportedCapabilityError`` and silently degrade every wrapped backend
|
||||
(qdrant/pgvector/sqlite_exact) to client-side counting in mcp_server."""
|
||||
|
||||
class _Inner:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def facet_counts(self, field, where=None, limit=1000):
|
||||
self.calls.append((field, where, limit))
|
||||
return {"alpha": 2, "beta": 1}
|
||||
|
||||
inner = _Inner()
|
||||
out = ew.EmbeddingCollection(inner).facet_counts("wing", where={"wing": "alpha"}, limit=50)
|
||||
assert out == {"alpha": 2, "beta": 1}
|
||||
assert inner.calls == [("wing", {"wing": "alpha"}, 50)]
|
||||
|
||||
|
||||
def test_get_all_metadata_forwards_to_inner():
|
||||
"""Same MRO-shadow pattern as ``facet_counts``: ``BaseCollection.get_all_
|
||||
metadata`` ships a concrete default that pages through ``self.get()``.
|
||||
Without this forwarder the wrapper runs the base default, the inner
|
||||
backend's overridden ``get_all_metadata`` (e.g. pgvector's
|
||||
``with_document=False`` fast path from #1892) is unreachable, and every
|
||||
metadata-only fetch transfers the full document column over the wire."""
|
||||
|
||||
class _Inner:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def get_all_metadata(self, where=None):
|
||||
self.calls.append(where)
|
||||
return [{"wing": "alpha"}, {"wing": "beta"}]
|
||||
|
||||
# Provide a ``get`` so a missing forwarder would silently succeed via
|
||||
# the BaseCollection default rather than crash — this is exactly the
|
||||
# shape that hid the bug in production. Returning a sentinel here
|
||||
# makes a wrong delegation observable: the test would receive
|
||||
# ``[{"WRONG"}]``, not the inner's real list.
|
||||
def get(self, **_kw):
|
||||
from mempalace.backends.base import GetResult
|
||||
|
||||
return GetResult(ids=["bad"], documents=[""], metadatas=[{"WRONG": True}])
|
||||
|
||||
inner = _Inner()
|
||||
out = ew.EmbeddingCollection(inner).get_all_metadata(where={"wing": "alpha"})
|
||||
assert out == [{"wing": "alpha"}, {"wing": "beta"}]
|
||||
assert inner.calls == [{"wing": "alpha"}]
|
||||
|
||||
|
||||
def test_wrapper_forwards_all_concrete_basecollection_methods():
|
||||
"""Every concrete (non-abstract) public method on ``BaseCollection`` must
|
||||
be explicitly defined on ``EmbeddingCollection``. Without an override,
|
||||
Python MRO resolves the call to ``BaseCollection``'s default before
|
||||
``__getattr__`` ever fires, silently shadowing the wrapped backend's real
|
||||
implementation.
|
||||
|
||||
The bug class this catches: any new ``BaseCollection`` method with a
|
||||
concrete default body (raises, returns ``{}``, returns a no-op, pages
|
||||
through ``self.get()``) becomes a silent regression for every wrapped
|
||||
backend the moment a backend overrides it. ``facet_counts`` (#1868) and
|
||||
``get_all_metadata`` (#1796 / #1892) both hit this; ``lexical_search``
|
||||
would have hit it earlier if the wrapper hadn't been updated by hand.
|
||||
|
||||
If this test fails, add an explicit forwarder on ``EmbeddingCollection``
|
||||
that calls ``self._inner.<name>(...)``."""
|
||||
import inspect
|
||||
|
||||
from mempalace.backends.base import BaseCollection
|
||||
from mempalace.backends.embedding_wrapper import EmbeddingCollection
|
||||
|
||||
concrete: set[str] = set()
|
||||
for name, member in inspect.getmembers(BaseCollection):
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
if isinstance(member, property):
|
||||
if member.fget and not getattr(member.fget, "__isabstractmethod__", False):
|
||||
concrete.add(name)
|
||||
continue
|
||||
if callable(member) and not getattr(member, "__isabstractmethod__", False):
|
||||
concrete.add(name)
|
||||
|
||||
forwarded = set(vars(EmbeddingCollection).keys())
|
||||
missing = sorted(concrete - forwarded)
|
||||
assert not missing, (
|
||||
"EmbeddingCollection must explicitly forward these concrete "
|
||||
"BaseCollection methods (otherwise MRO resolves the call here and "
|
||||
"shadows the inner backend's implementation): "
|
||||
f"{missing}"
|
||||
)
|
||||
|
||||
@@ -347,7 +347,7 @@ def test_cache_key_separates_models(monkeypatch):
|
||||
"""
|
||||
|
||||
class DummyMiniLM:
|
||||
def __init__(self, preferred_providers=None):
|
||||
def __init__(self, preferred_providers=None, intra_op_num_threads=0):
|
||||
self.kind = "minilm"
|
||||
|
||||
monkeypatch.setattr(embedding, "_build_ef_class", lambda: DummyMiniLM)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for no-LLM structural entity extraction."""
|
||||
|
||||
from mempalace.entities import entities_metadata, extract_structural_entities
|
||||
|
||||
|
||||
def test_extracts_code_symbols_paths_urls():
|
||||
text = (
|
||||
"We patched `_extract_authored_at` in rag/convo_miner.py so MemoryStack and "
|
||||
"ChromaBackend agree. See module.func and pkg.Class.method, plus do_thing_now. "
|
||||
"Ref https://github.com/MemPalace/mempalace/pull/1890 for details."
|
||||
)
|
||||
ents = set(extract_structural_entities(text))
|
||||
assert "_extract_authored_at" in ents
|
||||
assert "rag/convo_miner.py" in ents
|
||||
assert "MemoryStack" in ents
|
||||
assert "ChromaBackend" in ents
|
||||
assert "module.func" in ents
|
||||
assert "pkg.Class.method" in ents
|
||||
assert "do_thing_now" in ents
|
||||
assert any(e.startswith("https://github.com/MemPalace") for e in ents)
|
||||
|
||||
|
||||
def test_excludes_prose_noise():
|
||||
text = "This is a normal sentence, e.g. with i.e. abbreviations and version 1.2.3 here."
|
||||
ents = extract_structural_entities(text)
|
||||
# No plain prose words, no "e.g"/"i.e", no bare version numbers.
|
||||
assert ents == []
|
||||
|
||||
|
||||
def test_ranked_by_frequency_then_order():
|
||||
text = "alpha_one alpha_one alpha_one beta_two beta_two gamma_three"
|
||||
ents = extract_structural_entities(text)
|
||||
assert ents[:3] == ["alpha_one", "beta_two", "gamma_three"]
|
||||
|
||||
|
||||
def test_dedup_case_insensitive_keeps_first_form():
|
||||
text = "`MemoryStack` and memorystack and MEMORYSTACK"
|
||||
ents = extract_structural_entities(text)
|
||||
assert ents.count("MemoryStack") == 1
|
||||
assert ents == ["MemoryStack"]
|
||||
|
||||
|
||||
def test_respects_max_entities():
|
||||
text = " ".join(f"sym_{i}_x" for i in range(50))
|
||||
assert len(extract_structural_entities(text, max_entities=10)) == 10
|
||||
|
||||
|
||||
def test_extracts_leading_underscore_snake_in_plain_text():
|
||||
# Not in backticks — must still be caught by the snake-case pattern.
|
||||
ents = extract_structural_entities("we called _extract_authored_at and _do_thing here")
|
||||
assert "_extract_authored_at" in ents
|
||||
assert "_do_thing" in ents
|
||||
|
||||
|
||||
def test_semicolon_in_entity_does_not_corrupt_metadata():
|
||||
# A backtick span containing ';' must not split the ;-joined metadata field.
|
||||
md = entities_metadata("see `a(); b()` and TwoThing")
|
||||
parts = md.split(";")
|
||||
# Every part is a whole entity — no fragment is a bare separator artifact.
|
||||
assert all(p.strip() for p in parts)
|
||||
assert "TwoThing" in parts
|
||||
|
||||
|
||||
def test_metadata_is_semicolon_joined():
|
||||
text = "`one_thing` and TwoThing"
|
||||
md = entities_metadata(text)
|
||||
assert md == "one_thing;TwoThing"
|
||||
assert entities_metadata("") == ""
|
||||
assert entities_metadata("just plain prose with nothing structural") == ""
|
||||
@@ -531,6 +531,24 @@ def test_scan_for_detection_skips_git_dir(tmp_path):
|
||||
assert not any(".git" in f for f in file_strs)
|
||||
|
||||
|
||||
def test_scan_for_detection_includes_latex_prose(tmp_path):
|
||||
# .tex and .bib are prose-heavy (author names, abstracts, citations) and
|
||||
# belong in the preferred PROSE_EXTENSIONS bucket alongside .md / .rst,
|
||||
# not the code-file fallback. .bib in particular is almost entirely
|
||||
# author names — high entity density per byte.
|
||||
(tmp_path / "paper.tex").write_text(
|
||||
"\\documentclass{article}\\author{Leslie Lamport}\\begin{document}Body.\\end{document}"
|
||||
)
|
||||
(tmp_path / "refs.bib").write_text(
|
||||
"@article{l86, author={Leslie Lamport}, title={LaTeX}, year={1986}}"
|
||||
)
|
||||
(tmp_path / "code.py").write_text("import os")
|
||||
files = scan_for_detection(str(tmp_path))
|
||||
extensions = {os.path.splitext(str(f))[1] for f in files}
|
||||
assert ".tex" in extensions
|
||||
assert ".bib" in extensions
|
||||
|
||||
|
||||
# ── module-level constants ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -543,6 +561,8 @@ def test_stopwords_contains_common_words():
|
||||
def test_prose_extensions():
|
||||
assert ".txt" in PROSE_EXTENSIONS
|
||||
assert ".md" in PROSE_EXTENSIONS
|
||||
assert ".tex" in PROSE_EXTENSIONS
|
||||
assert ".bib" in PROSE_EXTENSIONS
|
||||
|
||||
|
||||
# ── _print_entity_list ─────────────────────────────────────────────────
|
||||
|
||||
+37
-21
@@ -19,6 +19,9 @@ Also pins the three feature contracts:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -260,32 +263,45 @@ class TestCheckTextContract:
|
||||
|
||||
|
||||
class TestCLI:
|
||||
def test_exits_nonzero_when_issues_found(self, tmp_path, monkeypatch, capsys):
|
||||
def test_exits_nonzero_when_issues_found(self, tmp_path):
|
||||
"""The CLI exit code is how shell scripts / hooks know to act —
|
||||
pin it explicitly."""
|
||||
registry = tmp_path / "known_entities.json"
|
||||
registry.write_text(json.dumps({"people": ["Milla", "Mila"]}))
|
||||
from mempalace import fact_checker, miner
|
||||
pin it explicitly.
|
||||
|
||||
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
|
||||
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
|
||||
|
||||
# Simulate argv: "Mila said hi"
|
||||
monkeypatch.setattr(
|
||||
"sys.argv",
|
||||
["fact_checker", "Mila said hi", "--palace", str(tmp_path / "palace")],
|
||||
Uses a fresh subprocess so that the already-imported
|
||||
``mempalace.fact_checker`` module in the test process does not
|
||||
collide with runpy re-executing it as ``__main__``, which produced
|
||||
a spurious RuntimeWarning from <frozen runpy>.
|
||||
"""
|
||||
# Place the registry where the subprocess's miner will find it:
|
||||
# $HOME/.mempalace/known_entities.json. We give the subprocess a
|
||||
# private HOME so we don't touch the developer's real registry.
|
||||
fake_home = tmp_path / "home"
|
||||
mempalace_dir = fake_home / ".mempalace"
|
||||
mempalace_dir.mkdir(parents=True)
|
||||
(mempalace_dir / "known_entities.json").write_text(
|
||||
json.dumps({"people": ["Milla", "Mila"]})
|
||||
)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
# Re-exec the __main__ block via runpy.
|
||||
import runpy
|
||||
|
||||
runpy.run_module("mempalace.fact_checker", run_name="__main__")
|
||||
env = {**os.environ, "HOME": str(fake_home), "USERPROFILE": str(fake_home)}
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"mempalace.fact_checker",
|
||||
"Mila said hi",
|
||||
"--palace",
|
||||
str(tmp_path / "palace"),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
# Issues found → exit code 1.
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "similar_name" in out
|
||||
# Silence unused import warning.
|
||||
_ = (MagicMock, patch, fact_checker)
|
||||
assert result.returncode == 1
|
||||
assert "similar_name" in result.stdout
|
||||
# Silence unused import warning (MagicMock, patch still used by
|
||||
# other tests in the class).
|
||||
_ = (MagicMock, patch)
|
||||
|
||||
def test_reconfigures_stdio_to_utf8_on_windows(self):
|
||||
"""Windows fact_checker --stdin must decode payload as UTF-8.
|
||||
|
||||
@@ -1158,7 +1158,9 @@ def test_mine_formats_calls_compute_topic_tunnels_after_loop(_mine_formats_mocks
|
||||
patch("mempalace.format_miner._compute_topic_tunnels_for_wing", return_value=0) as p_tun,
|
||||
):
|
||||
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), wing="wing_aya")
|
||||
p_tun.assert_called_once_with("wing_aya")
|
||||
p_tun.assert_called_once()
|
||||
assert p_tun.call_args.args == ("wing_aya",)
|
||||
assert p_tun.call_args.kwargs["config"].palace_path == str(tmp / "palace")
|
||||
|
||||
|
||||
def test_mine_formats_tunnel_failure_does_not_crash_mine(_mine_formats_mocks):
|
||||
@@ -1518,13 +1520,16 @@ def test_mine_formats_threads_chunk_size_from_user_config(monkeypatch, tmp_path:
|
||||
|
||||
# Inject custom config values via a fake MempalaceConfig.
|
||||
class _FakeMempalaceConfig:
|
||||
def __init__(self, **kwargs):
|
||||
self._palace_path = kwargs.get("palace_path", str(tmp_path / "palace"))
|
||||
|
||||
chunk_size = 1234
|
||||
chunk_overlap = 56
|
||||
min_chunk_size = 78
|
||||
|
||||
@property
|
||||
def palace_path(self):
|
||||
return str(tmp_path / "palace")
|
||||
return self._palace_path
|
||||
|
||||
monkeypatch.setattr(format_miner, "MempalaceConfig", _FakeMempalaceConfig)
|
||||
|
||||
|
||||
@@ -92,6 +92,24 @@ class TestHallwayStorage:
|
||||
|
||||
|
||||
class TestComputeHallways:
|
||||
def test_explicit_config_scopes_persistence_to_selected_palace(self, tmp_path):
|
||||
from mempalace.config import MempalaceConfig
|
||||
|
||||
default_cfg = MempalaceConfig(palace_path=tmp_path / "default" / "palace")
|
||||
selected_cfg = MempalaceConfig(palace_path=tmp_path / "selected" / "palace")
|
||||
col = _fake_collection(
|
||||
[
|
||||
{"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
|
||||
{"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
|
||||
]
|
||||
)
|
||||
|
||||
created = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, config=selected_cfg)
|
||||
|
||||
assert len(created) == 1
|
||||
assert hallways_mod.list_hallways(config=selected_cfg) == created
|
||||
assert hallways_mod.list_hallways(config=default_cfg) == []
|
||||
|
||||
def test_returns_empty_for_unknown_wing(self, tmp_path, monkeypatch):
|
||||
"""Wing with no drawers → no hallways, no crash."""
|
||||
_use_tmp_hallway_file(monkeypatch, tmp_path)
|
||||
@@ -300,6 +318,19 @@ class TestHallwayQuery:
|
||||
hallways_mod._save_hallways([{"id": "h1", "wing": "wing_aya"}])
|
||||
assert hallways_mod.delete_hallway("nonexistent") is False
|
||||
|
||||
def test_delete_hallway_uses_selected_palace_config(self, tmp_path):
|
||||
from mempalace.config import MempalaceConfig
|
||||
|
||||
default_cfg = MempalaceConfig(palace_path=tmp_path / "default" / "palace")
|
||||
selected_cfg = MempalaceConfig(palace_path=tmp_path / "selected" / "palace")
|
||||
record = {"id": "h1", "wing": "wing_aya"}
|
||||
hallways_mod._save_hallways([record], default_cfg)
|
||||
hallways_mod._save_hallways([record], selected_cfg)
|
||||
|
||||
assert hallways_mod.delete_hallway("h1", config=selected_cfg) is True
|
||||
assert hallways_mod.list_hallways(config=selected_cfg) == []
|
||||
assert hallways_mod.list_hallways(config=default_cfg) == [record]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# L7 dynamics integration — hallway records carry strength/stability/etc
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import pickle
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -588,7 +589,9 @@ def test_bm25_fallback_handles_short_query(palace_with_drawers):
|
||||
|
||||
|
||||
def test_repair_status_reports_diverged(tmp_path, capsys):
|
||||
"""The status command prints DIVERGED and recommends rebuild."""
|
||||
"""The status command prints DIVERGED and recommends the from-sqlite
|
||||
rebuild (not a re-mine), since a diverged index means the rows are
|
||||
intact in sqlite but the HNSW segment is out of sync (#1843)."""
|
||||
from mempalace.repair import status as repair_status
|
||||
|
||||
seg = "seg-status"
|
||||
@@ -597,7 +600,8 @@ def test_repair_status_reports_diverged(tmp_path, capsys):
|
||||
out = repair_status(palace_path=str(tmp_path))
|
||||
captured = capsys.readouterr().out
|
||||
assert "DIVERGED" in captured
|
||||
assert "mempalace repair`" in captured
|
||||
assert "mempalace repair --mode from-sqlite --archive-existing" in captured
|
||||
assert "Do not re-mine" in captured
|
||||
assert out["drawers"]["diverged"] is True
|
||||
|
||||
|
||||
@@ -640,3 +644,54 @@ def test_tool_status_via_sqlite_returns_breakdown(palace_with_drawers, monkeypat
|
||||
# ops×2 (incident + repair runbook), design×1 (metaphor).
|
||||
assert out["wings"].get("ops") == 2
|
||||
assert out["wings"].get("design") == 1
|
||||
|
||||
|
||||
def test_capacity_status_flags_small_gap_with_explicit_low_sync_threshold(tmp_path):
|
||||
"""New palaces use a low explicit sync threshold, so 57 missing rows is unsafe."""
|
||||
seg = "seg-1816-explicit-low-sync"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=1768, segment_id=seg, sync_threshold=2)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=1711)
|
||||
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
|
||||
assert info["divergence"] == 57
|
||||
assert info["threshold"] == 4
|
||||
assert info["status"] == "diverged"
|
||||
assert info["diverged"] is True
|
||||
assert "repair" in info["message"].lower()
|
||||
|
||||
|
||||
def test_capacity_status_flags_stale_below_floor_divergence(tmp_path):
|
||||
"""A persistent below-floor sqlite>HNSW gap must not be treated as fresh lag."""
|
||||
from mempalace.backends import chroma
|
||||
|
||||
seg = "seg-1816-stale-below-floor"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=1768, segment_id=seg)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=1711)
|
||||
|
||||
pickle_path = tmp_path / seg / "index_metadata.pickle"
|
||||
old = time.time() - chroma._HNSW_PERSISTENT_DIVERGENCE_GRACE_SECONDS - 10
|
||||
os.utime(pickle_path, (old, old))
|
||||
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
|
||||
assert info["divergence"] == 57
|
||||
assert info["threshold"] >= 2000
|
||||
assert info["status"] == "diverged"
|
||||
assert info["diverged"] is True
|
||||
assert "persisted below" in info["message"]
|
||||
|
||||
|
||||
def test_capacity_status_ok_with_stale_metadata_under_explicit_threshold(tmp_path):
|
||||
"""An idle database with an explicit sync threshold and a gap within tolerance must remain OK."""
|
||||
seg = "seg-1816-stale-ok"
|
||||
_seed_chroma_db(str(tmp_path), sqlite_count=1712, segment_id=seg, sync_threshold=2)
|
||||
_write_pickle(str(tmp_path), seg, hnsw_count=1711)
|
||||
pickle_path = tmp_path / seg / "index_metadata.pickle"
|
||||
old = time.time() - 400.0
|
||||
os.utime(pickle_path, (old, old))
|
||||
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
|
||||
assert info["divergence"] == 1
|
||||
assert info["threshold"] == 4
|
||||
assert info["status"] == "ok"
|
||||
assert info["diverged"] is False
|
||||
|
||||
@@ -113,8 +113,11 @@ def test_quarantine_leaves_reasonable_payload_in_place(tmp_path):
|
||||
assert seg_dir.exists()
|
||||
|
||||
|
||||
def test_segment_health_rejects_zero_byte_link_lists_with_payload(tmp_path):
|
||||
"""Regression #1457: real HNSW payload with empty link_lists.bin is corrupt."""
|
||||
def test_segment_health_accepts_zero_byte_link_lists_with_valid_pickle(tmp_path):
|
||||
"""Regression #1716: an all-layer-0 HNSW index serializes an empty
|
||||
link_lists.bin (level-0 links live in data_level0.bin). With a complete
|
||||
index_metadata.pickle the persist finished, so the segment is healthy —
|
||||
not the partial-flush corruption a missing marker would imply."""
|
||||
seg_dir = tmp_path / "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
_write_segment(
|
||||
@@ -124,11 +127,32 @@ def test_segment_health_rejects_zero_byte_link_lists_with_payload(tmp_path):
|
||||
write_metadata=True,
|
||||
)
|
||||
|
||||
assert _segment_appears_healthy(str(seg_dir))
|
||||
|
||||
|
||||
def test_segment_health_rejects_zero_byte_link_lists_with_truncated_pickle(tmp_path):
|
||||
"""An empty link_lists.bin with real payload but a truncated metadata
|
||||
envelope is a partial flush, not an all-layer-0 index — still rejected.
|
||||
The completion marker (intact pickle), not link_lists content, is what
|
||||
distinguishes the two."""
|
||||
seg_dir = tmp_path / "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
_write_segment(
|
||||
seg_dir,
|
||||
data_size=2_000,
|
||||
link_size=0,
|
||||
write_metadata=False,
|
||||
)
|
||||
# Persist started (0x80 head) but never wrote the STOP terminator.
|
||||
(seg_dir / "index_metadata.pickle").write_bytes(b"\x80" + b"x" * 20)
|
||||
|
||||
assert not _segment_appears_healthy(str(seg_dir))
|
||||
|
||||
|
||||
def test_quarantine_catches_zero_byte_link_lists_when_stale(tmp_path):
|
||||
"""Regression #1457: stale segments with empty link_lists.bin are quarantined."""
|
||||
def test_quarantine_leaves_zero_byte_link_lists_with_valid_pickle(tmp_path):
|
||||
"""Regression #1716: a stale all-layer-0 segment with a complete pickle is
|
||||
left in place. Quarantining it spawned the self-perpetuating loop — repair
|
||||
rebuilds the byte-identical empty-link_lists shape and it re-quarantines."""
|
||||
palace = tmp_path / "palace"
|
||||
palace.mkdir()
|
||||
|
||||
@@ -150,9 +174,5 @@ def test_quarantine_catches_zero_byte_link_lists_when_stale(tmp_path):
|
||||
|
||||
moved = quarantine_stale_hnsw(str(palace), stale_seconds=300)
|
||||
|
||||
assert len(moved) == 1
|
||||
assert not seg_dir.exists()
|
||||
|
||||
moved_path = Path(moved[0])
|
||||
assert moved_path.exists()
|
||||
assert moved_path.name.startswith("11111111-2222-3333-4444-555555555555.drift-")
|
||||
assert moved == []
|
||||
assert seg_dir.exists()
|
||||
|
||||
@@ -27,6 +27,14 @@ 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"
|
||||
SESSION_END_HOOK = REPO_ROOT / "hooks" / "mempal_session_end_hook.sh"
|
||||
PLUGIN_SESSION_END_HOOK = REPO_ROOT / ".claude-plugin" / "hooks" / "mempal-session-end-hook.sh"
|
||||
|
||||
_SESSION_END_HOOKS = pytest.mark.parametrize(
|
||||
"hook",
|
||||
[SESSION_END_HOOK, PLUGIN_SESSION_END_HOOK],
|
||||
ids=["user_hook", "plugin_hook"],
|
||||
)
|
||||
|
||||
# Re-used by every parametrize decorator that runs the same test against
|
||||
# both hooks. ``ids=`` keeps pytest output readable (`...[save_hook]`
|
||||
@@ -322,3 +330,40 @@ class TestFailLoudGuard:
|
||||
err_log = tmp_path / ".mempalace" / "hook_state" / "last_python_err.log"
|
||||
mode = stat.S_IMODE(err_log.stat().st_mode)
|
||||
assert mode == 0o600, f"last_python_err.log mode should be 0600 on failure, got {oct(mode)}"
|
||||
|
||||
|
||||
class TestSessionEndWrappers:
|
||||
"""The SessionEnd wrappers must background their work — so the foreground
|
||||
beats Claude Code's ~1.5s SessionEnd budget (a plugin-provided per-hook
|
||||
timeout cannot raise it) — and stay bash 3.2-safe like the other hooks."""
|
||||
|
||||
@_SESSION_END_HOOKS
|
||||
def test_bash_syntax_clean(self, hook):
|
||||
p = subprocess.run(["bash", "-n", str(hook)], capture_output=True, text=True)
|
||||
assert p.returncode == 0, f"{hook.name} syntax error: {p.stderr}"
|
||||
|
||||
@_SESSION_END_HOOKS
|
||||
def test_dispatches_session_end_through_cli(self, hook):
|
||||
src = _hook_src_no_comments(hook)
|
||||
# The dispatcher runs ``mempalace hook run "$@"`` in run_mempalace_hook,
|
||||
# and the bottom call supplies the ``--hook session-end --harness`` args
|
||||
# — so the two halves are asserted separately, not as one contiguous string.
|
||||
assert "hook run" in src
|
||||
assert "--hook session-end --harness" in src
|
||||
assert "MEMPALACE_HOOK_HARNESS" in src
|
||||
assert "MEMPAL_PYTHON" in src
|
||||
|
||||
@_SESSION_END_HOOKS
|
||||
def test_backgrounds_then_returns_empty(self, hook):
|
||||
src = _hook_src_no_comments(hook)
|
||||
assert "</dev/null &" in src, f"{hook.name} does not background the worker"
|
||||
assert "disown" in src, f"{hook.name} does not disown the backgrounded worker"
|
||||
assert src.rstrip().endswith("printf '{}'"), (
|
||||
f"{hook.name} must return the empty payload immediately"
|
||||
)
|
||||
|
||||
@_SESSION_END_HOOKS
|
||||
def test_no_bash4_only_builtins(self, hook):
|
||||
src = _hook_src_no_comments(hook)
|
||||
assert "mapfile" not in src, f"{hook.name} uses mapfile (bash 4 only)"
|
||||
assert "readarray" not in src, f"{hook.name} uses readarray (bash 4 only)"
|
||||
|
||||
+472
-7
@@ -8,26 +8,32 @@ from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
import mempalace.hooks_cli as hooks_cli_mod
|
||||
from mempalace.config import sanitize_name
|
||||
from mempalace.hooks_cli import (
|
||||
SAVE_INTERVAL,
|
||||
_count_human_messages,
|
||||
_diary_agent_for_harness,
|
||||
_extract_recent_messages,
|
||||
_get_mine_targets,
|
||||
_hooks_daemon_enabled,
|
||||
_log,
|
||||
_maybe_auto_ingest,
|
||||
_mempalace_python,
|
||||
_mine_already_running,
|
||||
_mine_sync,
|
||||
_parse_harness_input,
|
||||
_safe_wing_slug,
|
||||
_sanitize_session_id,
|
||||
_save_diary_direct,
|
||||
_validate_transcript_path,
|
||||
_wing_from_transcript_path,
|
||||
hook_stop,
|
||||
hook_session_start,
|
||||
hook_session_end,
|
||||
hook_precompact,
|
||||
run_hook,
|
||||
_claim_mine_slot,
|
||||
@@ -467,6 +473,45 @@ def test_stop_hook_checkpoint_visible_to_diary_read(monkeypatch, config, palace_
|
||||
assert legacy.get("entries") == []
|
||||
|
||||
|
||||
def test_save_diary_direct_daemon_opt_in_submits_job(tmp_path):
|
||||
transcript = tmp_path / "session.jsonl"
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
_write_transcript(
|
||||
transcript,
|
||||
[{"message": {"role": "user", "content": f"message {i}"}} for i in range(3)],
|
||||
)
|
||||
env = {"MEMPALACE_HOOKS_DAEMON": "yes", "MEMPALACE_PALACE_PATH": str(palace_dir)}
|
||||
job = {"id": "job", "state": "succeeded", "result": {"success": True, "entry_id": "e1"}}
|
||||
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._daemon_available", return_value=True):
|
||||
with patch("mempalace.daemon.submit_job", return_value=job) as mock_submit:
|
||||
result = _save_diary_direct(
|
||||
str(transcript),
|
||||
"sess1",
|
||||
wing="wing_project",
|
||||
agent_name="claude",
|
||||
)
|
||||
|
||||
assert result["count"] == 3
|
||||
mock_submit.assert_called_once()
|
||||
assert mock_submit.call_args.args[0] == "diary_write"
|
||||
payload = mock_submit.call_args.args[1]
|
||||
assert payload["agent_name"] == "claude"
|
||||
assert payload["wing"] == "wing_project"
|
||||
assert payload["topic"] == "checkpoint"
|
||||
assert (tmp_path / "last_checkpoint").exists()
|
||||
|
||||
|
||||
def test_hooks_daemon_enabled_requires_explicit_true():
|
||||
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
|
||||
assert _hooks_daemon_enabled() is False
|
||||
mock_cfg_cls.return_value.hook_use_daemon = True
|
||||
assert _hooks_daemon_enabled() is True
|
||||
|
||||
|
||||
# --- hook_session_start ---
|
||||
|
||||
|
||||
@@ -681,6 +726,84 @@ def test_wing_from_transcript_path_cwd_handles_non_string_cwd(tmp_path):
|
||||
assert _wing_from_transcript_path(str(transcript)) == "wing_proper_name"
|
||||
|
||||
|
||||
# --- _safe_wing_slug: special-character project dirs (e.g. +project) ---
|
||||
|
||||
|
||||
def test_safe_wing_slug_strips_leading_plus():
|
||||
"""Regression: a ``+``-prefixed folder (e.g. ``+project``) leaked ``+`` into the
|
||||
slug, producing ``wing_+project`` which ``sanitize_name`` rejects — silently
|
||||
breaking diary auto-save for that project."""
|
||||
assert _safe_wing_slug("+project") == "project"
|
||||
|
||||
|
||||
def test_safe_wing_slug_replaces_inner_special_chars():
|
||||
assert _safe_wing_slug("foo+bar") == "foo_bar"
|
||||
|
||||
|
||||
def test_safe_wing_slug_preserves_space_and_hyphen_behavior():
|
||||
# spaces and hyphens still collapse to underscores (no regression)
|
||||
assert _safe_wing_slug("React Native") == "react_native"
|
||||
assert _safe_wing_slug("claude-code") == "claude_code"
|
||||
|
||||
|
||||
def test_safe_wing_slug_preserves_existing_valid_names():
|
||||
"""Backward compatibility: a name that already produced a valid wing keeps the
|
||||
same slug, so previously-filed diary entries aren't orphaned (AGENTS.md: never
|
||||
destroy existing data). ``.`` and ``'`` are both accepted by sanitize_name and
|
||||
must survive."""
|
||||
assert _safe_wing_slug("myapp") == "myapp"
|
||||
assert _safe_wing_slug("my.app") == "my.app"
|
||||
assert _safe_wing_slug("v1.2.3") == "v1.2.3"
|
||||
assert _safe_wing_slug("o'brien") == "o'brien"
|
||||
|
||||
|
||||
def test_safe_wing_slug_collapses_double_dots():
|
||||
"""``..`` must collapse — sanitize_name rejects it as path traversal."""
|
||||
assert _safe_wing_slug("my..app") == "my.app"
|
||||
assert _safe_wing_slug("..hidden..") == "hidden"
|
||||
|
||||
|
||||
def test_safe_wing_slug_caps_length_for_sanitize_name():
|
||||
"""sanitize_name rejects names over 128 chars; the slug stays short enough that
|
||||
wing_<slug> never trips that limit, even for very long directory names."""
|
||||
slug = _safe_wing_slug("a" * 500)
|
||||
assert len(slug) <= 120
|
||||
sanitize_name(f"wing_{slug}") # must not raise
|
||||
|
||||
|
||||
def test_safe_wing_slug_falls_back_to_sessions_when_empty():
|
||||
# names made entirely of disallowed characters reduce to nothing
|
||||
assert _safe_wing_slug("+") == "sessions"
|
||||
assert _safe_wing_slug("@#$") == "sessions"
|
||||
|
||||
|
||||
def test_wing_from_transcript_path_cwd_plus_prefixed_dir(tmp_path):
|
||||
"""Reporter's case: a ``+``-prefixed working directory must yield a sanitizable
|
||||
wing (``wing_project``), not the rejected ``wing_+project``."""
|
||||
project_dir = tmp_path / "encoded-dir"
|
||||
project_dir.mkdir()
|
||||
transcript = project_dir / "session.jsonl"
|
||||
transcript.write_text(
|
||||
'{"type":"user","cwd":"/Users/me/code/+project","content":"hi"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _wing_from_transcript_path(str(transcript)) == "wing_project"
|
||||
|
||||
|
||||
def test_wing_from_transcript_path_legacy_plus_prefixed_project():
|
||||
"""Legacy ``-Projects-<name>`` path with a ``+``-prefixed project folder."""
|
||||
path = "/Users/me/foo/-Projects-+app/session.jsonl"
|
||||
assert _wing_from_transcript_path(path) == "wing_app"
|
||||
|
||||
|
||||
@given(st.text(min_size=1, max_size=300))
|
||||
def test_safe_wing_slug_always_yields_sanitizable_wing(name):
|
||||
"""Property: for ANY non-empty input, ``wing_<slug>`` must pass sanitize_name —
|
||||
the entire contract of the helper (a rejected wing silently breaks auto-save)."""
|
||||
wing = f"wing_{_safe_wing_slug(name)}"
|
||||
assert sanitize_name(wing) == wing
|
||||
|
||||
|
||||
# --- _log ---
|
||||
|
||||
|
||||
@@ -788,6 +911,33 @@ def test_maybe_auto_ingest_with_env(tmp_path):
|
||||
assert cmd[cmd.index("--mode") + 1] == "projects"
|
||||
|
||||
|
||||
def test_maybe_auto_ingest_daemon_opt_in_submits_job(tmp_path):
|
||||
"""Daemon-enabled hooks submit a background mine instead of spawning one."""
|
||||
mempal_dir = tmp_path / "project"
|
||||
palace_dir = tmp_path / "palace"
|
||||
mempal_dir.mkdir()
|
||||
palace_dir.mkdir()
|
||||
env = {
|
||||
"MEMPAL_DIR": str(mempal_dir),
|
||||
"MEMPALACE_HOOKS_DAEMON": "yes",
|
||||
"MEMPALACE_PALACE_PATH": str(palace_dir),
|
||||
}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._daemon_available", return_value=True):
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
with patch(
|
||||
"mempalace.daemon.submit_job", return_value={"id": "job"}
|
||||
) as mock_submit:
|
||||
_maybe_auto_ingest()
|
||||
|
||||
mock_popen.assert_not_called()
|
||||
mock_submit.assert_called_once()
|
||||
assert mock_submit.call_args.args[0] == "mine"
|
||||
assert mock_submit.call_args.args[1]["source"] == str(mempal_dir.resolve())
|
||||
assert mock_submit.call_args.kwargs["wait"] is False
|
||||
|
||||
|
||||
def test_maybe_auto_ingest_uses_mempalace_python(tmp_path):
|
||||
"""Spawned mine command uses _mempalace_python(), not bare sys.executable.
|
||||
|
||||
@@ -968,17 +1118,26 @@ def test_detached_popen_kwargs_posix(monkeypatch):
|
||||
|
||||
|
||||
def test_detached_popen_kwargs_windows(monkeypatch):
|
||||
"""On Windows, kwargs include creationflags that fully detach the child.
|
||||
"""On Windows, the miner child gets a hidden console (CREATE_NO_WINDOW),
|
||||
not a detached/no-console child (DETACHED_PROCESS).
|
||||
|
||||
Without these, the parent hook hangs at session end on Windows because
|
||||
the child's inherited stdout/stderr handles keep the parent's exit
|
||||
blocked (#1268 root cause for the Python hook path).
|
||||
DETACHED_PROCESS gave the child no console at all, which caused any
|
||||
console grandchild it spawned to allocate a fresh *visible* window
|
||||
(#1783). CREATE_NO_WINDOW gives a real-but-invisible console that all
|
||||
descendants inherit, so nothing flashes — while still fixing the
|
||||
#1268 hang (stdin=DEVNULL, close_fds, explicit stdout/stderr redirect,
|
||||
and CREATE_NEW_PROCESS_GROUP for the signal boundary are unchanged).
|
||||
Per the Win32 CreateProcess docs CREATE_NO_WINDOW is ignored when OR'd
|
||||
with DETACHED_PROCESS, so the two must be mutually exclusive.
|
||||
"""
|
||||
from mempalace.hooks_cli import _detached_popen_kwargs
|
||||
|
||||
monkeypatch.setattr("mempalace.hooks_cli.os.name", "nt")
|
||||
# Simulate Windows-only Popen flag constants. Patch on the imported
|
||||
# subprocess module within hooks_cli so getattr() picks them up.
|
||||
# Simulate Windows-only Popen flag constants on the imported subprocess
|
||||
# module so getattr() picks them up cross-platform.
|
||||
monkeypatch.setattr(
|
||||
"mempalace.hooks_cli.subprocess.CREATE_NO_WINDOW", 0x08000000, raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"mempalace.hooks_cli.subprocess.DETACHED_PROCESS", 0x00000008, raising=False
|
||||
)
|
||||
@@ -989,7 +1148,10 @@ def test_detached_popen_kwargs_windows(monkeypatch):
|
||||
assert kwargs.get("stdin") is subprocess.DEVNULL
|
||||
assert kwargs.get("close_fds") is True
|
||||
flags = kwargs.get("creationflags", 0)
|
||||
assert flags & 0x00000008, "DETACHED_PROCESS must be set"
|
||||
assert flags & 0x08000000, "CREATE_NO_WINDOW must be set"
|
||||
assert not (flags & 0x00000008), (
|
||||
"DETACHED_PROCESS must NOT be set (it suppresses CREATE_NO_WINDOW)"
|
||||
)
|
||||
assert flags & 0x00000200, "CREATE_NEW_PROCESS_GROUP must be set"
|
||||
|
||||
|
||||
@@ -1114,6 +1276,31 @@ def test_ingest_transcript_uses_detached_kwargs(tmp_path):
|
||||
assert kwargs.get("close_fds") is True
|
||||
|
||||
|
||||
def test_ingest_transcript_daemon_opt_in_submits_job(tmp_path):
|
||||
transcript = tmp_path / "session.jsonl"
|
||||
palace_dir = tmp_path / "palace"
|
||||
palace_dir.mkdir()
|
||||
transcript.write_text("x" * 200)
|
||||
env = {"MEMPALACE_HOOKS_DAEMON": "yes", "MEMPALACE_PALACE_PATH": str(palace_dir)}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
|
||||
with patch("mempalace.hooks_cli._daemon_available", return_value=True):
|
||||
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
|
||||
with patch(
|
||||
"mempalace.daemon.submit_job", return_value={"id": "job"}
|
||||
) as mock_submit:
|
||||
from mempalace.hooks_cli import _ingest_transcript
|
||||
|
||||
_ingest_transcript(str(transcript))
|
||||
|
||||
mock_popen.assert_not_called()
|
||||
mock_submit.assert_called_once()
|
||||
payload = mock_submit.call_args.args[1]
|
||||
assert payload["source"] == str(tmp_path)
|
||||
assert payload["mode"] == "convos"
|
||||
assert payload["wing"] == "sessions"
|
||||
|
||||
|
||||
def test_ingest_transcript_skips_when_target_running(tmp_path):
|
||||
"""Repeated transcript ingests for the same transcript should dedup."""
|
||||
transcript = tmp_path / "session.jsonl"
|
||||
@@ -1781,6 +1968,284 @@ def test_hook_stop_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch)
|
||||
assert not fake_root.exists()
|
||||
|
||||
|
||||
# ── session-end hook (#1341) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_run_hook_dispatches_session_end():
|
||||
stdin_data = json.dumps({"session_id": "run-test"})
|
||||
with patch("sys.stdin", io.StringIO(stdin_data)):
|
||||
with patch("mempalace.hooks_cli.hook_session_end") as mock_hook:
|
||||
run_hook("session-end", "claude-code")
|
||||
mock_hook.assert_called_once_with({"session_id": "run-test"}, "claude-code")
|
||||
|
||||
|
||||
def test_session_end_uses_detached_paths_not_sync_mine(tmp_path):
|
||||
"""SessionEnd must spawn the detached ingest/auto-ingest and NEVER call the
|
||||
synchronous ``_mine_sync``: the foreground SessionEnd budget (~1.5s, which a
|
||||
plugin-provided per-hook timeout cannot raise) would kill a synchronous mine
|
||||
before it saved anything.
|
||||
"""
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
_write_transcript(
|
||||
transcript,
|
||||
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(3)],
|
||||
)
|
||||
last_save_file = tmp_path / "sess_last_save"
|
||||
last_save_file.write_text("2", encoding="utf-8")
|
||||
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
|
||||
mock_cfg_cls.return_value.hooks_auto_save = True
|
||||
mock_cfg_cls.return_value.hook_desktop_toast = False
|
||||
with (
|
||||
patch(
|
||||
"mempalace.hooks_cli._save_diary_direct",
|
||||
return_value={"count": 3, "themes": ["exit"]},
|
||||
) as mock_save,
|
||||
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
|
||||
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
|
||||
patch("mempalace.hooks_cli._mine_sync") as mock_sync,
|
||||
):
|
||||
result = _capture_hook_output(
|
||||
hook_session_end,
|
||||
{"session_id": "sess", "transcript_path": str(transcript)},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
assert result == {}
|
||||
expected_path = str(transcript.resolve())
|
||||
mock_ingest.assert_called_once_with(expected_path)
|
||||
mock_auto.assert_called_once()
|
||||
mock_sync.assert_not_called()
|
||||
mock_save.assert_called_once_with(
|
||||
expected_path, "sess", wing="wing_sessions", toast=False, agent_name="claude"
|
||||
)
|
||||
# The session is over; its per-session save marker is cleared.
|
||||
assert not last_save_file.exists()
|
||||
|
||||
|
||||
def test_session_end_disabled_by_config_clears_marker(tmp_path):
|
||||
last_save_file = tmp_path / "sess_last_save"
|
||||
last_save_file.write_text("15", encoding="utf-8")
|
||||
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
|
||||
mock_cfg_cls.return_value.hooks_auto_save = False
|
||||
with (
|
||||
patch("mempalace.hooks_cli._save_diary_direct") as mock_save,
|
||||
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
|
||||
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
|
||||
):
|
||||
result = _capture_hook_output(
|
||||
hook_session_end,
|
||||
{"session_id": "sess", "transcript_path": ""},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
assert result == {}
|
||||
mock_save.assert_not_called()
|
||||
mock_ingest.assert_not_called()
|
||||
mock_auto.assert_not_called()
|
||||
assert not last_save_file.exists()
|
||||
|
||||
|
||||
def test_session_end_defaults_to_saving_when_config_unreadable(tmp_path):
|
||||
"""A corrupt/unreadable config must not lose the final save: the handler
|
||||
defaults to auto-save on (toasts off) instead of crashing the hook."""
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
_write_transcript(transcript, [{"message": {"role": "user", "content": "hi"}}])
|
||||
with patch("mempalace.hooks_cli.MempalaceConfig", side_effect=RuntimeError("corrupt config")):
|
||||
with (
|
||||
patch(
|
||||
"mempalace.hooks_cli._save_diary_direct",
|
||||
return_value={"count": 1, "themes": []},
|
||||
) as mock_save,
|
||||
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
|
||||
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
|
||||
):
|
||||
result = _capture_hook_output(
|
||||
hook_session_end,
|
||||
{"session_id": "cfg", "transcript_path": str(transcript)},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
assert result == {}
|
||||
mock_save.assert_called_once()
|
||||
mock_ingest.assert_called_once()
|
||||
mock_auto.assert_called_once()
|
||||
|
||||
|
||||
def test_session_end_clears_marker_even_if_capture_raises(tmp_path):
|
||||
"""Marker cleanup runs in a ``finally`` so a failing ingest still cleans up
|
||||
and never wedges the per-session marker on."""
|
||||
last_save_file = tmp_path / "boom_last_save"
|
||||
last_save_file.write_text("5", encoding="utf-8")
|
||||
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
|
||||
mock_cfg_cls.return_value.hooks_auto_save = True
|
||||
mock_cfg_cls.return_value.hook_desktop_toast = False
|
||||
with (
|
||||
patch("mempalace.hooks_cli.STATE_DIR", tmp_path),
|
||||
patch(
|
||||
"mempalace.hooks_cli._ingest_transcript",
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
patch("mempalace.hooks_cli._output"),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
hook_session_end(
|
||||
{"session_id": "boom", "transcript_path": str(tmp_path / "x.jsonl")},
|
||||
"claude-code",
|
||||
)
|
||||
assert not last_save_file.exists()
|
||||
|
||||
|
||||
def test_session_end_clears_marker_on_parse_failure(tmp_path):
|
||||
"""A non-dict payload makes _parse_harness_input raise, but parsing now runs
|
||||
inside the try, so the finally still clears the default-session marker
|
||||
instead of skipping cleanup entirely."""
|
||||
last_save_file = tmp_path / "unknown_last_save"
|
||||
last_save_file.write_text("5", encoding="utf-8")
|
||||
with (
|
||||
patch("mempalace.hooks_cli.STATE_DIR", tmp_path),
|
||||
patch("mempalace.hooks_cli._output"),
|
||||
):
|
||||
with pytest.raises(AttributeError):
|
||||
hook_session_end(["not", "a", "dict"], "claude-code")
|
||||
assert not last_save_file.exists()
|
||||
|
||||
|
||||
def test_hook_session_end_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
|
||||
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
hook_session_end({"session_id": "absent", "transcript_path": ""}, "claude-code")
|
||||
assert json.loads(buf.getvalue() or "{}") == {}
|
||||
assert not fake_root.exists()
|
||||
|
||||
|
||||
def test_session_end_auto_ingest_only_when_no_transcript(tmp_path):
|
||||
"""auto_save on but no transcript: project mine still fires, transcript-only
|
||||
paths are skipped, and the marker is still cleared. Guards against wrapping
|
||||
``_maybe_auto_ingest`` inside the ``if transcript_path`` block by mistake."""
|
||||
last_save_file = tmp_path / "sess_last_save"
|
||||
last_save_file.write_text("3", encoding="utf-8")
|
||||
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
|
||||
mock_cfg_cls.return_value.hooks_auto_save = True
|
||||
mock_cfg_cls.return_value.hook_desktop_toast = False
|
||||
with (
|
||||
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
|
||||
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
|
||||
patch("mempalace.hooks_cli._save_diary_direct") as mock_save,
|
||||
patch("mempalace.hooks_cli._mine_sync") as mock_sync,
|
||||
):
|
||||
result = _capture_hook_output(
|
||||
hook_session_end,
|
||||
{"session_id": "sess", "transcript_path": ""},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
assert result == {}
|
||||
mock_auto.assert_called_once()
|
||||
mock_ingest.assert_not_called()
|
||||
mock_save.assert_not_called()
|
||||
mock_sync.assert_not_called()
|
||||
assert not last_save_file.exists()
|
||||
|
||||
|
||||
def test_session_end_rejects_invalid_transcript_path(tmp_path):
|
||||
"""A traversal / wrong-suffix transcript_path is rejected by the validator:
|
||||
no transcript ingest or diary write fires (the independent project mine
|
||||
still runs), and the per-session marker is still cleared."""
|
||||
last_save_file = tmp_path / "bad_last_save"
|
||||
last_save_file.write_text("5", encoding="utf-8")
|
||||
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
|
||||
mock_cfg_cls.return_value.hooks_auto_save = True
|
||||
mock_cfg_cls.return_value.hook_desktop_toast = False
|
||||
with (
|
||||
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
|
||||
patch("mempalace.hooks_cli._save_diary_direct") as mock_save,
|
||||
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
|
||||
):
|
||||
result = _capture_hook_output(
|
||||
hook_session_end,
|
||||
{"session_id": "bad", "transcript_path": "../../etc/passwd"},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
assert result == {}
|
||||
mock_ingest.assert_not_called()
|
||||
mock_save.assert_not_called()
|
||||
mock_auto.assert_called_once() # project mine is independent of the transcript
|
||||
assert not last_save_file.exists()
|
||||
|
||||
|
||||
def test_session_end_accepts_full_sessionend_payload(tmp_path):
|
||||
"""The handler tolerates the real Claude Code SessionEnd stdin (reason,
|
||||
hook_event_name, cwd) — extra keys ignored, behavior unchanged. Pins the
|
||||
contract so a future strict-parse or reason-branching regression is caught."""
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
_write_transcript(transcript, [{"message": {"role": "user", "content": "hi"}}])
|
||||
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
|
||||
mock_cfg_cls.return_value.hooks_auto_save = True
|
||||
mock_cfg_cls.return_value.hook_desktop_toast = False
|
||||
with (
|
||||
patch(
|
||||
"mempalace.hooks_cli._save_diary_direct",
|
||||
return_value={"count": 1, "themes": []},
|
||||
),
|
||||
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
|
||||
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
|
||||
patch("mempalace.hooks_cli._mine_sync") as mock_sync,
|
||||
):
|
||||
result = _capture_hook_output(
|
||||
hook_session_end,
|
||||
{
|
||||
"session_id": "sess",
|
||||
"transcript_path": str(transcript),
|
||||
"hook_event_name": "SessionEnd",
|
||||
"reason": "logout",
|
||||
"cwd": str(tmp_path),
|
||||
},
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
assert result == {}
|
||||
mock_ingest.assert_called_once_with(str(transcript.resolve()))
|
||||
mock_auto.assert_called_once()
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
def test_session_end_checkpoint_visible_to_diary_read(
|
||||
monkeypatch, config, palace_path, kg, tmp_path
|
||||
):
|
||||
"""The SessionEnd in-process diary checkpoint is real and discoverable via
|
||||
diary_read (not mocked) — the diary half of #1341's clean-exit capture,
|
||||
mirroring test_stop_hook_checkpoint_visible_to_diary_read."""
|
||||
import chromadb
|
||||
|
||||
from mempalace import mcp_server
|
||||
from mempalace.mcp_server import tool_diary_read
|
||||
|
||||
monkeypatch.setattr(mcp_server, "_config", config)
|
||||
monkeypatch.setattr(mcp_server, "_get_kg", lambda *a, **kw: kg)
|
||||
client = chromadb.PersistentClient(path=palace_path)
|
||||
client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
|
||||
del client
|
||||
|
||||
transcript = tmp_path / "session.jsonl"
|
||||
_write_transcript(
|
||||
transcript,
|
||||
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(5)],
|
||||
)
|
||||
|
||||
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
|
||||
mock_cfg_cls.return_value.hooks_auto_save = True
|
||||
mock_cfg_cls.return_value.hook_desktop_toast = False
|
||||
# Mock only the detached subprocess mines; run the REAL diary write.
|
||||
with (
|
||||
patch("mempalace.hooks_cli._ingest_transcript"),
|
||||
patch("mempalace.hooks_cli._maybe_auto_ingest"),
|
||||
):
|
||||
hook_session_end(
|
||||
{"session_id": "sessX", "transcript_path": str(transcript)},
|
||||
"claude-code",
|
||||
)
|
||||
|
||||
visible = tool_diary_read(agent_name="claude")
|
||||
assert visible.get("total", 0) >= 1
|
||||
assert "CHECKPOINT" in visible["entries"][0]["content"]
|
||||
|
||||
|
||||
def test_hook_precompact_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
|
||||
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
|
||||
transcript = tmp_path / "t.jsonl"
|
||||
|
||||
@@ -24,6 +24,7 @@ import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -31,6 +32,8 @@ 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"
|
||||
SESSION_END_HOOK = REPO_ROOT / "hooks" / "mempal_session_end_hook.sh"
|
||||
PLUGIN_SESSION_END_HOOK = REPO_ROOT / ".claude-plugin" / "hooks" / "mempal-session-end-hook.sh"
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.name == "nt", reason="bash hook scripts are POSIX-only")
|
||||
@@ -168,3 +171,131 @@ class TestMempalPythonOverride:
|
||||
assert "python3" in invocations, (
|
||||
f"fallback-to-PATH did not use the shimmed python3. Marker log: {invocations!r}"
|
||||
)
|
||||
|
||||
|
||||
# ── session-end wrapper: must background so the foreground beats the budget ──
|
||||
|
||||
|
||||
def _write_recording_mempalace(
|
||||
path: Path, args_file: Path, *, sleep_secs: float = 0.0, done_file: Path | None = None
|
||||
) -> Path:
|
||||
"""A fake ``mempalace`` that consumes stdin, optionally sleeps, then records
|
||||
its argv to ``args_file`` (and touches ``done_file``). Lets a test observe a
|
||||
*backgrounded* dispatch after the wrapper's foreground has already returned.
|
||||
"""
|
||||
done_line = f'printf done > "{done_file}"' if done_file is not None else ":"
|
||||
src = f"""#!/bin/bash
|
||||
cat >/dev/null
|
||||
sleep {sleep_secs}
|
||||
printf '%s' "$*" > "{args_file}"
|
||||
{done_line}
|
||||
printf '{{}}'
|
||||
"""
|
||||
path.write_text(src)
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return path
|
||||
|
||||
|
||||
def _wait_for(path: Path, timeout: float = 15.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if path.exists() and path.read_text():
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
class TestSessionEndWrapper:
|
||||
def test_foreground_returns_before_worker_finishes(self, tmp_path):
|
||||
"""Budget contract: the foreground must return well before the (slow)
|
||||
worker completes, otherwise SessionEnd's ~1.5s budget would kill the
|
||||
mine. Proven with a worker that sleeps 2s before recording."""
|
||||
args_file = tmp_path / "args.log"
|
||||
done_file = tmp_path / "worker.done"
|
||||
fake = _write_recording_mempalace(
|
||||
tmp_path / "mempalace", args_file, sleep_secs=2.0, done_file=done_file
|
||||
)
|
||||
t0 = time.monotonic()
|
||||
result = _run_hook(
|
||||
SESSION_END_HOOK,
|
||||
{"session_id": "abc", "transcript_path": ""},
|
||||
env_overrides={"HOME": str(tmp_path)},
|
||||
path_prefix=[fake.parent],
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
assert result.returncode == 0, f"stderr={result.stderr!r}"
|
||||
assert result.stdout == "{}"
|
||||
assert elapsed < 1.5, f"foreground blocked {elapsed:.2f}s; the budget would kill it"
|
||||
assert not done_file.exists(), (
|
||||
"worker finished before the foreground returned — wrapper is not backgrounding"
|
||||
)
|
||||
assert _wait_for(done_file), "detached worker never completed"
|
||||
assert args_file.read_text() == "hook run --hook session-end --harness claude-code"
|
||||
|
||||
def test_dispatches_via_mempal_python_override(self, tmp_path):
|
||||
args_file = tmp_path / "args.log"
|
||||
shim = tmp_path / "python3"
|
||||
shim.write_text(
|
||||
f"""#!/bin/bash
|
||||
if [ "$1" = "-c" ]; then exit 0; fi
|
||||
if [ "$1" = "-m" ] && [ "$2" = "mempalace" ]; then
|
||||
shift 2
|
||||
cat >/dev/null
|
||||
printf '%s' "$*" > "{args_file}"
|
||||
printf '{{}}'
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
shim.chmod(shim.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
result = _run_hook(
|
||||
SESSION_END_HOOK,
|
||||
{"session_id": "abc", "transcript_path": ""},
|
||||
env_overrides={
|
||||
"HOME": str(tmp_path),
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"MEMPAL_PYTHON": str(shim),
|
||||
},
|
||||
)
|
||||
assert result.returncode == 0, f"stderr={result.stderr!r}"
|
||||
assert result.stdout == "{}"
|
||||
assert _wait_for(args_file), "backgrounded worker never ran"
|
||||
assert args_file.read_text() == "hook run --hook session-end --harness claude-code"
|
||||
|
||||
def test_harness_override_is_forwarded(self, tmp_path):
|
||||
args_file = tmp_path / "args.log"
|
||||
fake = _write_recording_mempalace(tmp_path / "mempalace", args_file)
|
||||
result = _run_hook(
|
||||
SESSION_END_HOOK,
|
||||
{"session_id": "abc", "transcript_path": ""},
|
||||
env_overrides={"HOME": str(tmp_path), "MEMPALACE_HOOK_HARNESS": "codex"},
|
||||
path_prefix=[fake.parent],
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert _wait_for(args_file)
|
||||
assert args_file.read_text() == "hook run --hook session-end --harness codex"
|
||||
|
||||
|
||||
class TestPluginSessionEndWrapper:
|
||||
def test_foreground_returns_before_worker_finishes(self, tmp_path):
|
||||
args_file = tmp_path / "args.log"
|
||||
done_file = tmp_path / "worker.done"
|
||||
fake = _write_recording_mempalace(
|
||||
tmp_path / "mempalace", args_file, sleep_secs=2.0, done_file=done_file
|
||||
)
|
||||
t0 = time.monotonic()
|
||||
result = _run_hook(
|
||||
PLUGIN_SESSION_END_HOOK,
|
||||
{"session_id": "abc", "transcript_path": ""},
|
||||
env_overrides={"HOME": str(tmp_path)},
|
||||
path_prefix=[fake.parent],
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
assert result.returncode == 0, f"stderr={result.stderr!r}"
|
||||
assert result.stdout == "{}"
|
||||
assert elapsed < 1.5, f"plugin foreground blocked {elapsed:.2f}s"
|
||||
assert not done_file.exists()
|
||||
assert _wait_for(done_file), "detached plugin worker never completed"
|
||||
assert args_file.read_text() == "hook run --hook session-end --harness claude-code"
|
||||
|
||||
@@ -213,6 +213,26 @@ class TestCandidateUnion:
|
||||
f"(basename collision would drop one); got sources={sources}"
|
||||
)
|
||||
|
||||
def test_union_respects_source_file_filter(self, tmp_path):
|
||||
"""Union pulls BM25 candidates from sqlite FTS5 directly; the
|
||||
source_file filter must constrain that pool too, not just the vector
|
||||
path — otherwise union silently re-injects other sources (#1815)."""
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
result = search_memories(
|
||||
_NARRATIVE_QUERY,
|
||||
palace,
|
||||
n_results=5,
|
||||
candidate_strategy="union",
|
||||
source_file="ticket_D2.md",
|
||||
)
|
||||
sources = {h["source_file"] for h in result["results"]}
|
||||
assert sources <= {"ticket_D2.md"}, (
|
||||
f"union must honor source_file on the BM25 pool; got {sources}"
|
||||
)
|
||||
# The BM25-strong brand-voice doc must NOT leak past the filter.
|
||||
assert "brand_voice_D4.md" not in sources
|
||||
|
||||
|
||||
class TestHybridRankTolerantOfMissingDistance:
|
||||
"""``_hybrid_rank`` accepts ``distance=None`` — required for BM25-only
|
||||
|
||||
@@ -12,7 +12,7 @@ from mempalace.palace import (
|
||||
get_collection,
|
||||
upsert_closet_lines,
|
||||
)
|
||||
from mempalace.searcher import search_memories
|
||||
from mempalace.searcher import _hybrid_rank, search_memories
|
||||
|
||||
|
||||
def _seed_drawers(palace_path):
|
||||
@@ -133,3 +133,74 @@ class TestClosetMetadata:
|
||||
assert h["matched_via"] == "drawer"
|
||||
assert "closet_preview" not in h
|
||||
assert h["closet_boost"] == 0.0
|
||||
|
||||
|
||||
# ── source_file filter scopes both drawer and closet queries (#1815) ──────
|
||||
|
||||
|
||||
class TestSourceFileFilter:
|
||||
def test_source_file_filter_excludes_other_sources(self, tmp_path):
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
result = search_memories(
|
||||
"Kafka consumer rebalance timeout",
|
||||
palace,
|
||||
n_results=5,
|
||||
source_file="fixture_D4.md",
|
||||
)
|
||||
ids = [h["source_file"] for h in result["results"]]
|
||||
assert ids, "the matching source_file drawer should be returned"
|
||||
assert set(ids) == {"fixture_D4.md"}
|
||||
|
||||
def test_source_file_filter_overrides_closet_boost_for_other_source(self, tmp_path):
|
||||
# A strong closet pointing at D1 must NOT leak D1 in when the search
|
||||
# is scoped to a different source_file — the where clause is applied
|
||||
# to the closet query too, not just the drawer query.
|
||||
palace = str(tmp_path / "palace")
|
||||
_seed_drawers(palace)
|
||||
_seed_strong_closet_for(
|
||||
palace,
|
||||
drawer_id="D1",
|
||||
source_file="fixture_D1.md",
|
||||
topics=["Kafka queue tuning", "consumer rebalance config"],
|
||||
)
|
||||
result = search_memories(
|
||||
"Kafka consumer rebalance",
|
||||
palace,
|
||||
n_results=5,
|
||||
source_file="fixture_D4.md",
|
||||
)
|
||||
ids = [h["source_file"] for h in result["results"]]
|
||||
assert "fixture_D1.md" not in ids
|
||||
assert set(ids) <= {"fixture_D4.md"}
|
||||
|
||||
|
||||
def test_hybrid_rank_breaks_score_ties_by_authored_at():
|
||||
"""Identical-content hits get identical vector + BM25 scores; the tie must break
|
||||
toward the more recently authored drawer, not arbitrary backend order."""
|
||||
older = {
|
||||
"text": "alpha beta gamma",
|
||||
"distance": 0.2,
|
||||
"metadata": {"authored_at": "2026-06-21T10:00:00.000Z"},
|
||||
}
|
||||
newer = {
|
||||
"text": "alpha beta gamma",
|
||||
"distance": 0.2,
|
||||
"metadata": {"authored_at": "2026-06-27T10:00:00.000Z"},
|
||||
}
|
||||
# Input order puts the older drawer first; the tiebreak should reorder it.
|
||||
results = [older, newer]
|
||||
_hybrid_rank(results, "alpha beta gamma")
|
||||
assert results[0]["metadata"]["authored_at"] == "2026-06-27T10:00:00.000Z"
|
||||
assert results[1]["metadata"]["authored_at"] == "2026-06-21T10:00:00.000Z"
|
||||
|
||||
|
||||
def test_hybrid_rank_tiebreak_handles_top_level_authored_at():
|
||||
"""The search_memories path puts authored_at at the top level (no `metadata`
|
||||
nesting); the tie-break must read it there too."""
|
||||
older = {"text": "alpha beta gamma", "distance": 0.2, "authored_at": "2026-06-21T10:00:00.000Z"}
|
||||
newer = {"text": "alpha beta gamma", "distance": 0.2, "authored_at": "2026-06-27T10:00:00.000Z"}
|
||||
results = [older, newer]
|
||||
_hybrid_rank(results, "alpha beta gamma")
|
||||
assert results[0]["authored_at"] == "2026-06-27T10:00:00.000Z"
|
||||
assert results[1]["authored_at"] == "2026-06-21T10:00:00.000Z"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user