feat: agent-facing tooling, safe namespace deletion, and working semantic search after restart (v1.0.0) - #6
Merged
Merged
Conversation
`Fixture` was private to `graph::explicit_links`'s test module, but it is the only thing in the tree that stands up a real `RedbStorageEngine` over a temp directory with a graph and a cache beside it. Namespace-statistics tests need exactly that plus a way to delete a memory, and copying ~100 lines per consumer would guarantee the copies drift. - move `Fixture` verbatim to `src/test_support.rs`, a `cfg(test)` crate-private module; `graph::explicit_links` now imports it and its tests are unchanged - add `MemorySpec`, so a test states only the field it is about and inherits live/Full/full-strength/untagged for the rest - add `Fixture::insert_memory_with`, `create_namespace`, `path` - add `Fixture::tombstone`, which calls `RedbStorageEngine::tombstone_memory` rather than `MetadataStore::tombstone` -- the engine method is the one `McpStorageAdapter::delete_memory` uses, so it is the only one that reproduces the state the running system actually reaches `insert_memory()` delegates to `insert_memory_with(MemorySpec::new(..))`, so it is the same write it always was. 306 tests, unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`list_memories` pulled its arguments out of the raw JSON with
`.and_then(|v| v.as_u64())` and friends, so every wrong-typed argument
became a silent default -- the exact pattern `args` was built to remove
from the store tools. Three of those defaults were wrong in ways a
caller could not detect:
- a stringly-typed `limit` fell back to 50
- a `limit` above the maximum was clamped to 200, so a caller asking
for 1000 was told it had seen the whole namespace
- a wrong-typed `timeRangeStart` was dropped, silently widening the
query from a range to the entire namespace
New in `mcp::args`: `opt_u64_in_range`, `opt_usize_in_range`,
`opt_bool`, `namespace_or`, the private `opt_time_bound`, and the
composite `parse_list_memories_input` / `parse_list_tags_input`.
`parse_store_input` now uses `namespace_or`, so the namespace fallback
rule has one definition.
BEHAVIOR CHANGE: an out-of-range `limit` is now REJECTED, not clamped.
The tool schema already declares `"maximum": 200`, so only a
schema-violating client is affected, and it gets one clean error
instead of a quietly short page.
Also retrofits `recall_memories` and `find_similar_memories` (scan
mode) to `namespace_or`: leaving `{"namespace": ["work"]}` silently
reading the default partition in those two while `list_memories`
rejects it is a worse end state than either uniform choice.
`model::constants` gains DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT,
DEFAULT_TAG_LIMIT and MAX_TAG_LIMIT so the published schema bound and
the enforced bound cannot drift. `bridge::ListTagsInput` lands here
rather than with the tool, because it is what `parse_list_tags_input`
returns.
306 -> 332 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`McpNamespaceAdapter::list_namespaces` hardcoded `memory_count: 0`, so the `recalld://namespaces` resource -- the only existing way to see what namespaces exist -- reported every one of them as empty. There was also no tool for it at all, so an agent that did not already know a namespace name had nowhere to start. - `MetadataStore::count_memories_in_namespace`: walks NAMESPACE_INDEX and applies the same predicate as `list_memories_filtered` with no filters, so `list_namespaces[].memoryCount`, `namespace_stats.memoryCount` and `list_memories.total` agree by construction rather than by invariant. One point lookup per member, not a scan. - `list_namespaces` tool: no parameters, returns the bare `Vec<NamespaceInfo>` so the tool and the resource are provably the same payload. - `create_namespace` keeps `memory_count: 0`, now with a comment saying why: a namespace created one line ago really is empty. Adds `DISPATCHED` next to the dispatch match, so a tool definition without a dispatch arm fails a test instead of answering "Unknown tool" to a caller who read tools/list. `Fixture` gains `namespace_adapter()` and `storage_adapter()` builders. 332 -> 342 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
An agent had no way to see what labels already exist, so it invented near-duplicates (`type/decision` beside `decisions`) and filtered `list_memories` on tags that were never there. `list_tags` returns the vocabulary with per-label counts, most common first. Four buckets, not one flat list: in a real corpus every memory carries several `entity/` tags, so a single top-50 would be nearly all entities and useless for finding the plain tags a caller actually filters on. The derived buckets have their prefix STRIPPED, because the bare name is what `store_memory` and `list_memories` accept -- the prefixed form would make this tool's output invalid input to the tools it exists to feed. - `MetadataStore::tag_counts_in_namespace`: TAG_INDEX has no namespace dimension, so this walks NAMESPACE_INDEX with one point lookup per member -- the same work as one unfiltered list_memories, not a scan_all. Summary and Ghost are counted; a phase transition strips text, not tags. - `model::sort_tag_counts`: the shared ordering rule. `list_tags` reads a redb B-tree keyed by tag, so it arrives lexicographic and every "top tags" caller has to re-sort. - `build_list_tags_response`: pure, so the bucketing rules are tested without storage. `project/recalld` stays a plain tag despite the slash; a bare `entity/` stays a plain tag rather than becoming an entity with no name. - `allNamespaces` and an explicit `namespace` are mutually exclusive rather than one silently winning; global scope returns explicit `null`s so the response always states its own scope. - daemon: dispatch arm, remote adapter, and `list_tags` added to `is_retry_safe` (both paths are pure redb reads, neither records an access). COMPAT: a new client against an ALREADY-RUNNING daemon gets `List tags failed: Invalid input: unknown method: list_tags` -- one clean permanent error, no retry. Restart `recalld daemon` after deploying. `list_namespaces` is unaffected. 343 -> 360 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`namespace_stats` counted a tombstoned record as a memory, so `memoryCount` disagreed with what `list_memories` would actually return for the same namespace, and there was no way for an agent auditing a namespace to tell a deleted memory from a lost one. Skipping tombstones before the tally fixes every derived figure at once, because `MetadataStore::tombstone` zeroes summary/tags/strength/ decay_strength but LEAVES is_permastore and edge_count intact: - memoryCount now equals full + summary + ghost -- assertable, which it was not before - avgStrength stops averaging in zeroed decay_strength (one live memory at 0.8 beside two tombstones reported ~0.27, now 0.8) - permastoreCount and edgeCount stop counting deleted rows - vectorBytes reflects only live slots, whose freed capacity vectors.dat retains until compaction -- the doc now says so New `tombstoneCount` field, reported rather than silently dropped: a namespace showing one memory where eleven were stored has ten tombstones, and the operator needs to see that. It carries `#[serde(default)]` because NamespaceStats round-trips the daemon socket in both directions -- without it a new client decoding an old daemon's response would fail outright, strictly worse than the wrong number. Mirrors the same fix into the HTTP path (`api::adapters` namespace_stats and list_all, `api::models::NamespaceStatsResponse`) in the same commit. Split across two commits, MCP and HTTP would report different numbers for the same namespace, which is worse than the current uniform wrongness. Knowingly deferred: both paths still use `scan_all()`. Switching to `memories_in_namespace` trades one sequential scan for N point lookups and is a separate change. BEHAVIOR CHANGE: `namespace_stats.memoryCount` now means live memories only. That is the fix, and it is what makes it agree with `list_memories.total` and `list_namespaces[].memoryCount`. 360 -> 369 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
…ew tools `compute_metadata_stats` took the first ten entries of `list_tags` under the heading "top tags". `list_tags` reads a redb B-tree keyed by tag, so those were the ten alphabetically first tags -- the comment above it claimed the input was already sorted by count, which is why the bug survived. Now applies the shared `model::sort_tag_counts`. No new test here, stated as a decision rather than an omission: `compute_metadata_stats` takes a `&dyn api::state::StorageEngine`, an ~18-method trait, and stubbing it for a two-line change is not worth it. The extracted `sort_tag_counts` is covered directly. Docs: - README, docs/guide.md: 10 tools -> 12, both names appended - README, docs/mcp.md: `list_namespaces` and `list_tags` added to the permissions allowlist - docs/mcp.md, docs/guide.md: new `list_namespaces` and `list_tags` sections - docs/mcp.md: the namespace_stats example has NEVER matched the code -- it showed namespace/totalMemories/phases/averageStrength/ vectorStorageBytes where the code serializes name/memoryCount/ phaseCounts/avgStrength/vectorBytes. Corrected, plus tombstoneCount. Any client written against that example was already broken. - docs/mcp.md: the "Argument validation" section now covers the listing tools, notes that an out-of-range `limit` is rejected rather than clamped, and lists the new messages. 369 tests, unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Adds the detection half of the store-time near-duplicate advisory: a similarity threshold, the wire types a report is made of, and the scan itself. Nothing calls it yet. `find_near_duplicates` is deliberately not `QueryEngine::similar`. That path requires the memory to already exist and records an associative access on everything it returns, which at store time would age every neighbour's FSRS schedule on every single write. It also never errors. An empty index, a dimension mismatch, or a query vector that is not L2-normalized each yield no matches, because the only consumer is an advisory field that must never fail a store. The normalization case is a skip rather than a fix-up: the index scores raw dot products, so an un-normalized query returns numbers above 1.0 that look like similarities and are not, and normalizing here would report a number the stored vector disagrees with. That case logs at debug every time and warns exactly once per process, so an operator gets one visible signal the feature is inert rather than silence forever. `DUPLICATE_SIMILARITY_THRESHOLD` is `f64`, not `f32`, and that is load-bearing: it is embedded in the published `find_similar_memories` schema via `json!`, and serde_json widens f32 to f64 — `0.85f32` would publish as `0.8500000238418579`. The two existing 0.85 literals (MCP scan mode, REST duplicate scan) now reference the constant, so the three surfaces cannot disagree about what "duplicate" means. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Wires near-duplicate detection into the MCP store path. `store_memory` now returns a `nearDuplicates` report naming up to three existing memories at cosine similarity 0.85 or higher, each with its summary, so the caller can supersede or reinforce instead of accumulating restatements. Callers opt out per item with `checkDuplicates: false`. The check runs BEFORE the write, and that ordering is the whole feature. The embedding is already in hand, so there is no second embed round trip, and the new memory is not in the vector index yet — `index.add` is ~70 lines further down — so it cannot match itself and needs no self-filter. Below the write, every store would report itself at score 1.0. `f26_a_store_does_not_report_itself_as_its_own_duplicate` is what catches that regression. Summaries are loaded only when candidates were found: cache first, then one batched blocking hop for the misses. A summary that will not load yields an empty string rather than dropping the match. `StoreInput.check_duplicates` carries `#[serde(default)]`, which is load-bearing in the opposite direction from the one on `StoredMemory`: `StoreInput` is deserialized straight off the daemon socket, so without it every store from a client that predates the field becomes a hard parse failure rather than a store with the default behaviour. It is `Option<bool>` and not `bool`, because a bare default on a `bool` is `false` — which would silently disable the feature for exactly those clients. Deviation from plan: `NearDuplicateReport.threshold` is `f64`, not the `f32` the sketch had. It is the published constant echoed onto the wire, so an `f32` would send `0.8500000238418579` — the same widening trap the constant itself is `f64` to avoid. `score` stays `f32`; it is a real `f32` computation rather than a round number in a narrow type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Publishes the advisory: `checkDuplicates` appears in both store schemas defaulting to true, and `store_memory`'s description now names the recovery rather than only the warning. That matters because the check is post-hoc by construction — the memory is already written when the report arrives — so an agent told "these look similar" and nothing else has no action available and will skip it. The description spells out forget the new id, then reinforce or re-store with `supersedes`. `store_memories` documents the property that falls out of storing items sequentially: each is indexed before the next is checked, so intra-batch duplicates are caught too. The batch entry is extracted into `store_result_entry`. `json!` ignores `skip_serializing_if`, so a hand-built entry silently omits every optional field somebody forgets to insert conditionally — `supersedes` was the first instance, `nearDuplicates` would have been the second. `e24_batch_entry_carries_every_field_the_single_store_serializes` compares the hand-built entry key-by-key against `StoredMemory`'s own serialization, which turns a recurring trap into a permanent guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`MemoryResponse` already carries `supersedes` precisely so the two create doors do not drift, and the repo has fixed that class of drift once before (see the explicit-links parity note). So the advisory lands on `POST /memories` on the same terms as the MCP path: `checkDuplicates` on the request, `nearDuplicates` on the response, absent unless something crossed the threshold. `SearchPipeline` gains `near_duplicates`, which delegates to the same `find_near_duplicates` the MCP adapter uses — one implementation of what "duplicate" means, not two that agree today. Beyond the plan: `POST /memories/batch` gets it as well. It deserializes the same `CreateMemoryApiRequest`, so leaving it out would mean `checkDuplicates` parses there and does nothing — a parameter that silently no-ops is worse than one that does not exist. The batch handler shares `near_duplicate_report` with the single create, so the two cannot drift either. Summaries are hydrated through `cache.get_or_load`, which reads through without recording an access: the endpoints that count a read call `record_access` separately afterwards, and loading a summary to warn about a duplicate is not a retrieval of that memory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Adds `checkDuplicates` to the parameter tables in both the MCP reference and the guide, shows a `nearDuplicates` response, and gives the feature its own section beside "supersedes semantics". The section leads with the recovery, because a warning that arrives after the write is only useful if the reader knows it means forget, then reinforce or re-store. It also states the two things that are not obvious from the schema: the cost is proportional to the TOTAL number of memories rather than the size of the target namespace (the index is one flat array), and the check is inert in a passthrough namespace whose vectors are not L2-normalized. No README change: no new tool, so the twelve-tool count and the permission allowlists stay correct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`forget_memory` never performed a permanent delete. The record survives in meta.db with its content stripped and its graph edges intact, and no code path ever hard-deletes a tombstone: the decay sweep's transition selector returns None for DecayPhase::Tombstone, and the only hard delete is reachable from Ghost alone. Tombstones therefore accumulate for the life of the database. "Permanently delete" was wrong in both directions, and the direction that matters is the one that misleads: an agent told the delete is permanent assumes the row and its disk space are gone, so it never reaches for the operation that would actually reclaim them. The description now names the Tombstone phase, says what survives, and says plainly that tombstones are never reclaimed. Same wording into docs/mcp.md and docs/guide.md, and a test pins that the word "permanent" does not come back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Four new primitives, plus the boot sweep that cleans up after an interrupted one. `MetadataStore::purge_namespace_records` replaces `drain_namespace_ memories`, which is deleted rather than fixed. The old one could not be fixed in place: it was driven by `memories_in_namespace`, which reads NAMESPACE_INDEX, and `tombstone` unlinks records from that index — so it structurally could not see the tombstones, which are exactly the rows nothing else in the system ever reclaims. Its `Vec<DiskRecord>` return also dropped the ids the caller needs to clean the FTS, vector and entity indexes. Changing its index source, its return type and its per-record primitive is a rewrite, and leaving both versions in place would only invite the wrong one being called. `MetadataStore::batch_tombstone` tombstones many ids in one transaction and reports one outcome per input id in input order. Only the `Tombstoned` outcome carries a record, and a duplicate id in one batch reads back the first occurrence's uncommitted write and reports `AlreadyTombstoned` — so a caller that frees one vector slot per `Tombstoned` outcome cannot double-free, which would cycle the on-disk free list and underflow `live_count`. `VectorManager::remove` hands the store back rather than dropping it, so the drop lands at the call site next to the unlink it has to precede: a `VectorStore` holds an fs2 exclusive lock and a live mmap with no close method, and unlinking underneath it leaves both held against an inode nobody can see. `RedbStorageEngine::purge_namespace` runs the order that makes a crash survivable — records, edges, config row, then directory. The config row is the point of no return, so an interruption before it leaves a live namespace holding fewer memories and re-running finishes the job. It refuses to follow a symlinked namespace directory, since it joins a name straight onto db_path rather than going through read_dir. `sweep_orphan_namespace_dirs` runs as step 4 of startup validation and reclaims directories left by a crash after the config-row delete. It deletes directories unattended on every open, so its guards are the point of the change: read_dir file types (which do not follow symlinks), a UTF-8 name, no dotfiles, not a live namespace, and the directory must contain vectors.dat and nothing else. What makes "not a live namespace" trustworthy is an ordering guarantee — open step 6 creates a directory for every namespace in NAMESPACE_TABLE before step 7 runs the sweep. It can never fail startup; a sweep error is logged and the open continues. 19 tests. w18 (every live namespace directory survives a plain reopen) and w17 (a directory that is not namespace-shaped is left alone) were both checked to fail with their respective guard removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`FtsIndex::remove_namespace` is a correctness tool, not a speed-up. Removing per id would only reach ids that meta.db still knows about, and the decay sweep hard-deletes a Ghost record without touching this index — so a namespace can hold FTS rows whose meta.db record is already gone, ids that no caller can enumerate. Filtering on namespace_id is the only handle on them. The order inside the transaction is the part that matters. `fts_content` is declared contentless_delete=1, so its rows are reachable only by rowid: a bare `DELETE FROM id_map WHERE namespace_id = ?` would orphan every content row permanently — invisible to search, holding space forever, and poisoning any later re-index of the same memory_id against the UNIQUE constraint. So the rowids are collected first, the content rows deleted by rowid, and the id_map rows deleted last. Three tests. The first re-adds the same memory_id after the removal, which fails if either half of the pair survives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`remove_memory_with_bridging` did two things in one body: decide whether to bridge, then tear the node down. `remove_memory` is the second half, extracted verbatim, with the bridging path now delegating to it. The distinction is not cosmetic. Bridging exists to preserve a chain through a memory that decayed out from under it — the relationship was real and only the waypoint is gone. When a memory is going away because its whole namespace is going away, the relationship is exactly what the user asked to erase, so bridging would manufacture a new edge between two survivors on the strength of it, resurrecting in summary form the thing that was just destroyed. The test builds the precise shape bridging bridges across (one in, one out, matching type), asserts that the bridging path does bridge it, then asserts the new path does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Deleting a memory is two jobs. Storage owns the meta.db record, the vector slot and the edge rows, and returns. The other four — the FTS5 database, the in-memory FlatVectorIndex, the entity index and the relationship graph — live outside storage, and every delete path has had to do that half by hand. There are three such paths and they had already drifted: the HTTP delete stops after the cache invalidation (fixed in the next commit), while the MCP one does all four. `index_cleanup::purge_from_indexes` is the fix for that drift, in a way a comment asking future authors to keep three copies in step is not. It takes one lock per subsystem for the whole slice, and every step is best-effort — the storage-side removal has already committed, so a wedged FTS connection must not be the reason the vector index keeps serving a deleted memory. Two axes, because the three callers genuinely differ. `GraphAction` chooses between marking the node Tombstone (forget_memory keeps the record, so chains stay traversable) and removing it outright (namespace deletion, where the record is gone and a node would dangle). `FtsAction` chooses between per-record removal and a whole-namespace delete, which is the only way to reach rows whose meta.db record is already gone. `McpStorageAdapter::delete_memory` now calls it. Behaviour-preserving; the new test asserts all four removals still happen, which is what stops the shared helper quietly losing a step. Also documents the leak at the decay sweep's hard delete — the one place that removes a record without touching any of these four indexes, whose FTS row then survives restart forever. Documented, not fixed: reaching it means threading three handles through DecaySweepRunner and into code that runs unattended on a background timer, which deserves its own review rather than a ride along with a delete-path change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`DELETE /memories/{id}` tombstoned the record, freed the vector slot,
invalidated the cache, and stopped. The four indexes that live outside
storage were never touched, so the memory kept:
- its `FlatVectorIndex` entry. The index filters on the decay phase
stored in the *entry*, not on the record, so the entry stays live:
it burns a top-k slot in every semantic search, and both
`find_similar_memories` and the autolinker read it as a valid
candidate and can create edges to a dead memory.
- its FTS5 row, which burns a top-k slot in keyword search and
**survives restart** — the index is only migrated when it is empty.
- its entity-index entry, until restart.
- its graph node, still reporting Full phase at strength 1.0. This is
the most substantive: an HTTP-deleted memory goes on propping up its
neighbours' decay resistance through `calculate_connection_bonus`,
and goes on feeding spreading activation.
Not "the memory is still searchable" — the pipeline drops Tombstone
candidates after hydration and `load_batch` skips ids with no META row,
so no deleted content is ever returned. The damage is degraded recall
and a graph that lies about who is still alive.
`StorageEngineAdapter` gains the four handles and calls the shared
`index_cleanup::purge_from_indexes`, so this path and the MCP one are now
literally the same code. The blocking section returns the pre-tombstone
DiskRecord instead of a bool, because `tombstone` erases the tag list and
the tag list is where the entity names live.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Forgetting 100 memories one at a time cost 100 redb commits — 100 fsyncs — and roughly 700 lock acquisitions, because each delete took the storage write lock and then all four index locks in turn. `forget_memories` does it as one redb transaction inside one `spawn_blocking` holding one `storage.write()`, followed by a single pass over the indexes: 1 fsync and about 6 lock acquisitions. What the batch deliberately does NOT do is batch the vector-slot free. `VectorStore::free_slot` reads the header through the mmap and writes through the file descriptor, and the remap at the end is what keeps the next iteration's header read coherent with the write it just made. A batched version has to hoist the free-list head into a local and remap once — correct and not hard, but getting it wrong yields a free list that silently hands the same slot to two memories and underflows `live_count` on the double free. 100 remaps of a small file cost about a millisecond. The win here comes from the single transaction, not from remap batching. Duplicate ids are handled by construction rather than by a dedup pass: `batch_tombstone` reports the second occurrence as AlreadyTombstoned, which carries no record, so no slot is freed twice. The test proves it the only way that means anything — by allocating two memories afterwards and asserting they land on different slots. UUIDs are parsed in the handler, so a malformed id becomes a per-item error rather than failing the batch, and valid ids keep their original index for the merge. Same guard order, cap and response envelope as store_memories. Not retry-safe over the daemon socket, and deliberately not on `records_accesses_only` either: the per-id flags all flip to false on a replay, so a caller who lost the connection cannot tell "nothing was deleted" from "everything was deleted, twice". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Until now nothing could destroy a namespace, and nothing could reclaim a tombstone. `forget_memory` leaves a META_TABLE row, a graph node and every edge, and the decay sweep's transition selector returns None for Tombstone — so those rows accumulate for the life of the database with no way to get rid of them. This is now the only operation that does. The order inside `purge_namespace` is what makes a crash survivable, and the refusal rules are what make the operation safe to expose to an agent. **The default namespace is refused outright** — with force, and when empty — at two layers. Startup recreates a missing default under the SAME directory name with a NEW namespace id, so a deleted default leaves a stale vectors.dat that the recreated namespace opens and reuses, handing unrelated memories somebody else's embeddings. The boot sweep would usually remove that directory first, which makes the failure unlikely rather than impossible, and "unlikely" is the wrong guarantee for silently wrong vectors. The tool-layer check saves a daemon round trip; the adapter-layer check is the load-bearing one, because the socket is its own entry point. **Lookup, refuse-check and purge happen inside one `storage.write()` in one `spawn_blocking`.** Splitting them across two blocking hops would open a window in which a memory stored between the check and the purge is destroyed by a check that said "empty". w29 asserts that a refused non-force delete leaves the records, the directory, the FTS rows and the config row all intact — a refusal that has already destroyed something is worse than no refusal, because the caller reads an error and believes nothing happened. **A namespace holding only tombstones deletes without force.** It reports memoryCount 0 in list_namespaces and namespace_stats, so refusing it would be unexplainable, and its tombstones hold no content the user has not already asked to forget. The purge still reaps them and the result reports `tombstonesPurged` separately from `memoriesDeleted`, so nothing happens silently. Annotated destructive but deliberately NOT idempotent, unlike the forget tools: this removes a directory from disk and the namespace id is never reused, so a blind retry after an unknown outcome could destroy a same-named namespace the user recreated in between. Not retry-safe over the daemon socket for the same reason. `McpNamespaceAdapter` gains the four index handles. Namespace lifecycle already lives on `NamespaceRegistry` — create, list and stats are all there — so widening it beats splitting deletion onto `StorageEngine` because that is where the handles happened to be. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
README, docs/mcp.md and docs/guide.md all repeat the tool count and the tool list, so all three move together. 12 -> 14. `forget_memories` goes into the permission allowlists alongside `forget_memory`. `delete_namespace` deliberately does not: it destroys every memory in a namespace and removes its vector file from disk with no undo and no backup, which is the one tool worth a confirmation prompt every time. The README says so above the block rather than leaving the omission to look like an oversight. Both docs also gain a short note under "available tools" that a forgotten memory is never reclaimed and that delete_namespace is the only thing that reclaims one — the fact an agent needs in order to reach for the right tool, and the fact the old "permanently delete" wording actively hid. A new test drives the real `dispatch_tool` for every declared tool through a bridge whose methods all fail, and asserts none of them answers "Unknown tool". The existing check compares definitions against a hand-maintained DISPATCHED array, which can itself drift from the match it sits next to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`delete_namespace` resolved a namespace name to a directory with a bare
`db_path.join(name)` and checked only "not a symlink, is a directory".
`Path::join` does not normalize, so two names escaped:
- `".."` resolved to the PARENT of the data directory. Reachable because
`create_namespace` committed the NAMESPACE_TABLE row and only THEN
asked `open_or_create` whether the name was usable; the rejection
propagated to the caller and the row survived.
- `""` resolved to the data directory itself, needed no daemon frame,
and named a namespace that was live and healthy — `open_or_create`
accepts it. A plain `delete_namespace {"name": "", "force": true}`
through the MCP tool destroyed the whole database.
`validate_namespace_name` would have caught both, but it ran only at the
tool layer and the HTTP layer, and the daemon socket dispatches
`create_namespace` straight into the adapter — the same "socket is its
own entry point" hazard the file's own comment already identifies for the
`default` guard. `delete_namespace` never validated at all, at any layer.
Fixed at both layers:
- `validate_namespace_name` now runs in `McpNamespaceAdapter`'s
`create_namespace` and `delete_namespace`, and in the
`handle_delete_namespace` tool.
- `remove_namespace_dir` resolves through a new `namespace_dir`, which
requires exactly one ordinary path component, and then requires the
canonicalized target to be a strict child of the canonicalized
`db_path` — the proof `VectorManager::open_or_create` already demanded
before it would CREATE a directory, which the destructive path had no
business doing without.
Also fixes the ordering that made the row reachable: `create_namespace`
validates before committing and rolls the row back if the vector store
still refuses to open. An unopenable row was not merely untidy —
`RedbStorageEngine::open` step 6 `?`-propagates that same failure for
every row, so one rejected create wedged the next startup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
macOS APFS is case-insensitive by default, `validate_namespace_name`
permits ASCII case, and every namespace-identity comparison was an exact
string compare. So "notes" and "Notes" were two NAMESPACE_TABLE rows
sharing ONE directory, and two data-loss chains followed:
- delete "notes" (row gone, remove_dir_all fails, warn-only), create
"Notes" — which adopts the surviving notes/vectors.dat — then restart.
The boot sweep sees the directory `notes`, `live` holds `Notes`, the
exact compare says dead and the shape guard passes because it IS a
namespace directory. remove_dir_all destroys the live namespace's
vectors, unattended.
- `create_namespace("Default")` passed the exact-match uniqueness check,
committed its row and only then failed on the default namespace's
exclusive flock, so the caller saw an error and the row survived. Then
`delete_namespace("Default", force: true)` walked past the
`!= "default"` guard and unlinked the real default's vectors.dat. On
the next boot every record's vector_slot is past EOF: all embeddings
permanently gone.
Namespace-name identity is now ASCII case-insensitive at the four points
that decide it: the `create_namespace` uniqueness check,
`get_namespace_by_name`, the `default` guard (both copies), and the boot
sweep's live-set comparison.
Case-folding at the comparison points rather than lowercasing names at
creation, deliberately: folding needs no migration, so an existing
mixed-case namespace keeps its row and its directory and goes on
working, and a database that already holds both `notes` and `Notes`
keeps each row individually addressable because `get_namespace_by_name`
prefers an exact match before folding. Lowercasing at creation would
rename the caller's namespace behind their back and would still have to
fold on lookup anyway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Zero callers, and the one field it most obviously existed to change was the one it must never change. `name` is the directory name: a rename would leave the namespace's memories reading `<old>/vectors.dat` until the next boot, where `RedbStorageEngine::open` step 6 creates a fresh empty `<new>/` and `sweep_orphan_namespace_dirs` then finds `<old>/` matching no live namespace and removes it. Every embedding in the namespace, deleted unattended, one restart after an operation that reported success. Deleted rather than documented with the invariant. A rename that is correct has to move the directory in the same operation, and that is a different function from the one this was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`purge_namespace` used `?` at every step, so an ERROR — not a crash —
after the first chunk committed threw away the only record of what it
had destroyed. The consequences compounded:
- the caller was told "failed", which reads as "nothing happened", when
in fact those memories were permanently gone;
- `purge_from_indexes` was never reached, so their vector-index,
entity-index, FTS and graph entries survived FOREVER — a retry finds
their META_TABLE rows already gone and returns an empty vec, so
nothing can ever reach them again.
`purge_namespace_records` now returns `PartialPurge { destroyed, source }`
and `purge_namespace` returns `NamespacePurgeError` carrying the same,
for every step after the first record is committed. The MCP adapter
runs `purge_from_indexes` over `destroyed` before returning — per-record
FTS removal, not whole-namespace, because the records the purge did not
reach are still live and their rows must stay — and reports the new
`BridgeError::PartiallyApplied`, which `handle_delete_namespace` renders
without the "Failed to delete namespace" prefix so the agent is told to
re-run rather than that nothing happened.
Also drops the phase-index bitmaps slot by slot on the partial path;
`remove_namespace` would have taken the surviving records' entries too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`delete_namespace`'s "is this namespace empty?" check called `count_memories_in_namespace`, which walks NAMESPACE_INDEX and confirms each hit against META_TABLE. The destruction it guards scans META_TABLE and filters on `record.namespace_id`. Two sources of truth: a META_TABLE row whose index entry is missing — the divergence `list_memories_filtered` explicitly tolerates — was invisible to the check and destroyed by the effect. A namespace reporting "empty" could take live memories with it on a delete with no force flag at all. The guard now calls `count_live_records_in_namespace`, which applies the purge's own predicate: scan META_TABLE, match `namespace_id`, skip tombstones. It costs a full scan, on the non-force path only, immediately before a purge that scans the same table anyway. The tombstone rule is unchanged and deliberate: a namespace holding only tombstones is still empty, because it reports zero everywhere else and those records hold nothing the user has not already asked to forget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`DiskRecord.edge_count` counts OUTGOING edges, and only the source's count ever moves (see `explicit_links::bump_edge_count`). Nothing decrements it, and until a memory is destroyed nothing needs to: `tombstone` keeps the record and its edges, so the count stays true. Destroying one breaks that. `purge_namespace` and `delete_memory` both call `remove_all_edges`, which reaches edges in BOTH directions — so the edge `survivor -> destroyed` disappears from edges.db while the survivor's record goes on counting it. Permanently, in a number that feeds degree centrality and cache weight. A namespace purge makes it visible: the survivor is in another namespace, so nothing else will ever touch its record. Both paths now go through `remove_edges_and_repair_peers`, which reads each doomed memory's incoming edges before removing them and decrements the outgoing count of every source that is not itself being destroyed. `PurgedNamespace` reports the repaired peers so the MCP adapter can invalidate their cached records, which otherwise keep serving the old number until eviction. Fixed on `delete_memory` as well as `purge_namespace`: it is the same bug through the same call, and a shared helper applied to one of two callers is the duplication this codebase already refused once in `index_cleanup`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
The boot sweep's safety argument is "every live namespace has a row in NAMESPACE_TABLE, so a directory with no row is dead". That argument has no lower bound. If `meta.db` is absent, or restored from an older backup, `live` is EMPTY and every namespace directory in the data directory is a candidate — and every guard passes, because they really are namespace directories. First boot then `remove_dir_all`s all of them, unattended, with no undo. Swept directories are now MOVED to `<db_path>/.orphaned/<name>-<stamp>/`. The whole hygiene benefit is kept — they stop occupying the data directory and stop being reopened — with none of the blast radius: whatever the sweep got wrong is still on disk, named, timestamped, and can be moved back. The leading dot means the quarantine can never become the next sweep's candidate. Refusing to sweep when `live` is empty was the alternative and is strictly weaker: it does nothing for a `live` that is STALE rather than empty — the same restored-backup scenario one namespace later — and it would break the legitimate case of a fresh database with a leftover directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
The implementation is correct; the reasons given for it were not. The docstring claimed that orphaning `fts_content` rows would collide with the `memory_id` UNIQUE constraint on re-index. It cannot: `id_map.rowid` is AUTOINCREMENT and `add` deletes the old `id_map` row first, so a re-indexed memory takes a fresh rowid and the constraint is never in play. The real cost is the space, unreclaimable for the life of the database, plus corpus-wide ranking drift — `bm25()` scores against the whole `fts_content` table, so orphaned rows go on contributing to the document count and per-term document frequencies for every query in every namespace, even though `search`'s inner join means they can never be returned. That last property is also why `w20` proved nothing: it PASSED with the guard removed. An orphaned content row is invisible to `search`, does not collide on re-index, and is not counted by `is_empty` (which counts `id_map`). The test now queries `fts_content` directly, which is the only observable that moves, and `w61` does the same for the per-record `remove`. Both fail with their guard removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`find_near_duplicates` passes `decay_phases: [Full, Summary]` to the vector index, and the docs promise that scope. `FlatVectorIndex` writes each entry's `decay_phase` once, at `add` time, always Full, and `VectorIndex::update_metadata` has zero callers — so the stored phase is a snapshot of the moment the memory was indexed and the filter matches everything. A Ghost-phase memory (no summary left) and a hard-deleted one (no record at all) were both reported as near-duplicates with an EMPTY summary, and `store_memory`'s own description then told the agent to reinforce them. The guarantee is now kept where it can be: both hydration paths — the MCP adapter's `hydrate_near_duplicates` and the HTTP `near_duplicate_report` — already load each candidate's record, so they now read its phase and drop anything missing or past Summary. The index-level filter stays as a cheap prefilter and is documented as exactly that. Made real rather than documented away: the guarantee is what makes the advisory worth acting on, and the hydration step was already paying for the record read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
…ents
`docs/mcp.md` says both validate strictly. Only their `namespace` had
been retrofitted onto `args.rs`; every other field was still read with
the `.get(k).and_then(..)` chain that module exists to replace. So:
- `{"tags": "topic/rust"}` became `[]` and the recall ran UNFILTERED
over the whole namespace — a wider answer than was asked for, returned
as a success. This is the doc's own flagship example.
- `limit: 1000` was clamped to 100, which tells a paginating agent it
has enumerated the namespace.
- `compact: "false"` became `true`; `depth: 5` sailed past a schema that
says 3; `minScore: 5` and `threshold: 5` were accepted whole.
- `{"mode": 2}` silently ran "single" — a different operation from the
one asked for.
Both handlers now parse through `args`, via new `parse_recall_input`,
`parse_find_similar_args` and `parse_duplicate_scan_args`, plus two new
extractors the search tools needed: `opt_f32_in_range` (a JSON integer
is a legitimate float; a numeric string is not) and `opt_enum` (an
unknown or wrong-typed value names the alternatives).
Retrofitted rather than narrowing the sentence in the docs: the sentence
describes the behaviour these tools should have had, and the extractors
were already written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`list_memories {"entities": ["José"]}` derived the tag `entity/josé`,
which fails `Tag`'s alphabet. The `if let Ok` around it swallowed the
error, the filter was dropped, and the call returned EVERY memory in the
namespace — presented as the result of filtering by entity. An agent
acting on "these are the memories about José" got the whole namespace,
with nothing in the response to distinguish it from a real match.
Three call sites had the pattern: `list_memories` and `recall_memories`
in the MCP adapter, and the HTTP search adapter.
Filters now go through a new `parse_filter_tags`, which fails on the
first label that will not validate and names the field and the offending
value. `parse_tags_lossy` stays for stores, where the trade-off is the
opposite one and already documented: dropping a label from a store keeps
the memory, dropping one from a filter changes the answer. `excludeTags`
also keeps the lossy parse — a dropped exclusion never masquerades as a
match.
Adds `SearchError::InvalidFilter`, mapped to HTTP 400, so the HTTP door
reports the caller's mistake as the caller's mistake rather than a 500.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Several `Option` fields carried a comment saying `#[serde(default)]` was "load-bearing" and that without it an older client's payload would be a "hard parse failure". Serde already decodes a missing `Option<T>` as `None`; the un-attributed `embedding`, `initialStability`, `parentId` and `supersedes` in the same struct decode fine, which is the proof. The attributes are kept — churning them buys nothing — but the comments now say what is actually true, and say where the attribute IS required: `DeleteNamespaceInput::force` and `NamespaceStats::tombstone_count` are a `bool` and a `u64`, which have no "missing" representation, and there the attribute is the only thing between an old payload and a real parse failure. That distinction is the point. As written, the comments would have told the next author that a non-`Option` field is equally safe without one. `w68` makes the rule executable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Two stale claims about the same shipped change. `namespace_stats`'s TOOL DESCRIPTION still promised a "total memory count" and never mentioned `tombstoneCount`, even though the counts had already been changed to exclude tombstones and `list_namespaces` and `list_tags` both got an "excludes deleted" sentence at the time. The tool description is what the agent reads — the docs are not in its context — so the stale one is the version that governs. `w69` keeps it honest. `docs/mcp.md`'s `list_tags` example was internally impossible: `limit: 20` against `"total": 41` with `"truncated": false`. `truncated` is `total > limit`, so it is `true`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Two halves of the same assumption on `delete_memories`. `handle_forget_memories` assembled its results with `parsed.iter().zip(flags)` and no length check. `zip` stops at the shorter side, so a response with fewer flags than ids — this decodes a bare `Vec<bool>` off the daemon socket, and nothing structural prevents it — left `Value::Null` entries in `results`, under-counted `deleted`, and reported all of it as a success. A mismatch is now an explicit per-id error telling the caller to re-read the ids, and it logs. The daemon's `delete_memories` dispatch applied no `MAX_BATCH_MEMORIES` cap. The tool layer's cap is bypassed by a direct frame, and the socket is its own entry point, so an unbounded id list became one write transaction of unbounded size. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`remove_memory` and `remove_memory_with_bridging` both look the memory up in `id_index` and then fetch the node. When the index held a key whose `nodes` slot was gone, they returned early and left the index entry in place — so `contains` went on reporting the memory as present, every subsequent removal took the same branch and did nothing, and the entry outlived the process. Dropping the entry is the whole job that is left to do in that case, so both paths now do it and log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Not 1.0.0. The breaking changes in this branch would justify a major version on semver grounds alone, but 1.0 signals stability and `find_similar_memories` currently returns "no embedding found" on any process that has not stored a memory itself: the vector index is built empty at startup and nothing ever loads vectors.dat into it, so semantic search silently degrades to keyword matching after every restart. Verified against a copy of a real store. That is fixed before 1.0.0. Breaking since 0.1.10: - MCP serves the 2026-07-28 revision alongside the legacy handshake; mcp_router takes a config argument; /mcp enforces a body limit, validates Origin, and expires idle sessions - the daemon negotiates an 8 MiB frame limit and reconnects rather than dying on an oversized request - a supersedes or parentId naming a missing or cross-namespace memory fails the store instead of silently doing nothing - content limits are measured and reported in bytes; a listing limit above the maximum is rejected rather than clamped - namespace names are ASCII-only and compared case-insensitively - namespace_stats.memoryCount counts live memories only Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`w51` planted a lowercase `notes/` alongside a live `Notes` namespace to give the boot sweep something to choose between. On a case-insensitive filesystem those are one directory, so the plant wrote its stub over the live namespace's real vectors.dat and the reopen then failed to parse it. The assertion was right; the setup was only valid on a case-sensitive filesystem. It now probes the filesystem and plants only where planting creates a second directory -- on macOS, creating `Notes` has already produced the directory `notes` resolves to. Worth noting the test guards a macOS-specific bug and was only ever run on Linux, which is how it passed locally and in the ubuntu job while failing the macos one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
`FlatVectorIndex` is RAM-only. It was built empty at startup step 6 and filled by exactly one thing: the store path. So every process came up semantically blind and stayed that way until it stored a memory of its own — `find_similar_memories` failed outright with "no embedding found for memory <id>" (pipeline `similar()` resolves the source embedding through `vector_indexes.get_vector(id)`, which returned None), and `recall_memories` silently degraded to FTS, entity and graph matching. That last part is the likely source of the long-standing "accurate but irrelevant" complaint: the results were real, they just weren't semantic. Verified against a copy of a real store, where find_similar_memories errored while recall_memories still returned 3 hits. Startup now fills the index from the `scan_all()` it already does for the graph and the FTS backfill — one vector read per live record, no second scan. Two things fall out of doing it at all: `decay_phase` comes from the record's real phase, not the hardcoded `Full` the store path writes. `VectorIndex::update_metadata` has no callers, so until now every index-level phase filter matched everything. Loading the true phase makes those filters mean something again — as of the last restart. A phase transition inside a running process still does not update the entry, because the decay sweep holds no handle on the vector index; that needs a separate change and the code says so rather than implying the gap is closed. The index has a single width and namespaces have their own, so a stored vector can be a width the index cannot take. Those are skipped, counted, and warned about once with the count and the namespaces involved. Both routes there are reachable today: `create_namespace` accepts an arbitrary `embeddingDim` with no check against `embedding.dimensions` and the REST store path validates a caller-supplied embedding against the namespace's width, and separately an operator changing `embedding.dimensions` between runs invalidates every vector already written. Skipping matches what the store path already does for such a namespace, where `index.add` fails into a discarded `let _`. Nothing here fails startup. A missing slot, an unreadable namespace, a width mismatch — all warn and continue, because the result of skipping is a memory that is not vector-searchable, which is the state every memory was in before this existed. One INFO line reports loaded, skipped by reason, and elapsed. Measured: 10k memories at 768d loads in 7ms, 100k in 132ms, 10k at 1536d in 20ms. It is not worth making lazy or batched, and there is no flag, because an empty vector index is not a mode anyone wants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
Supersedes the v0.2.0 bump earlier in this branch. That commit withheld 1.0.0 for one reason: `find_similar_memories` returned "no embedding found" on any process that had not stored a memory itself, because the vector index was built empty at startup and nothing ever loaded vectors.dat into it, so semantic search silently degraded to keyword matching after every restart. d5a8566 fixes that, and it is verified against a copy of a real store: the same probe that returned "no embedding found" before now returns scored results after a restart. That was the gate, so this is 1.0.0. Breaking since 0.1.10: - MCP serves the 2026-07-28 revision alongside the legacy handshake; mcp_router takes a config argument; /mcp enforces a body limit, validates Origin, and expires idle sessions - the daemon negotiates an 8 MiB frame limit and reconnects rather than dying on an oversized request - a supersedes or parentId naming a missing or cross-namespace memory fails the store instead of silently doing nothing - content limits are measured and reported in bytes; a listing limit above the maximum is rejected rather than clamped - namespace names are ASCII-only and compared case-insensitively - namespace_stats.memoryCount counts live memories only Known and deliberately not fixed here: a vector index entry's phase is now correct at boot but still not updated by decay during a running process; tombstoned memories are never reclaimed; and this store has seven records present in META_TABLE but missing from NAMESPACE_INDEX, which predates this work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces #4, which was based on
bug-fixesand therefore treated as a stacked PR. GitHub forces stacked PRs onto the asynchronous merge endpoint, and that endpoint ignores the admin bypass, so it could not be merged. Same commits, based onmain.Follows #3 (now merged). These came from using the MCP tools heavily during that work and hitting the friction directly.
What's new
Discovery.
list_namespacesandlist_tags. Agents previously had no way to learn which namespaces existed or what tag vocabulary was in use, so every session invented its own —type/findingvstype/observation— silently fragmenting recall in a store shared with other agents.list_tagsreturns entity/topic/emotion labels with prefixes stripped, so its output can be pasted straight back intostore_memory.Near-duplicate warnings. Every store reports existing memories above 0.85 similarity, reusing the embedding already computed and scanning before the write so a memory cannot match itself. Advisory only — it can never fail a store — and the tool description teaches the recovery, since the warning necessarily arrives after the write.
Safe deletion.
forget_memoriesfor batches, anddelete_namespacewhich refuses a non-empty namespace unless forced and refusesdefaultunconditionally.Honest numbers.
namespace_statscounted tombstones inmemoryCount, which also deflatedavgStrengthand inflatedvectorBytes,edgeCountandpermastoreCount.list_namespacesreportedmemoryCounthardcoded to0. The health report's "top tags" were the first ten alphabetically.The 1.0.0 blocker, fixed
FlatVectorIndexwas built empty at startup and only ever populated by the store path, so on any process that had not itself stored a memory,find_similar_memoriesreturned "no embedding found" andrecall_memoriessilently fell back to keyword and graph matching. Semantic search did not survive a restart.Confirmed against a copy of a real store, and confirmed fixed the same way — the probe that errored before now returns scored results. Regression tests were verified to fail without the fix. Boot cost was measured, not guessed: 10k memories @ 768d → 7.2ms, 100k @ 768d → 132ms, against a
scan_allthat was already happening.This is also the likely cause of a long-standing complaint that retrieval returned "accurate but irrelevant" results, previously attributed to tag dilution.
Two critical bugs caught in review
Both were introduced in this branch and caught before merge:
delete_namespacecouldremove_dir_allan arbitrary directory.create_namespacenever validated the name at the adapter layer, andremove_namespace_dirjoined the raw name ontodb_pathwith no path-component check.name: ""resolves todb_pathitself — the whole database, destroyed through the normal MCP tool.defaultguard.create_namespace("Default")committed a row then failed on the vector-file lock;delete_namespace("Default", force: true)sailed past!= "default"and unlinked the realdefault/vectors.dat.Both fixed, with regression tests verified to fail without their fix, and confirmed by running the attacks against a throwaway database: every one refused, database and canary memory intact.
Scope deliberately declined
update_memorywas dropped — editing tags stales the embedding, andFtsIndexhas only a full-rowaddwhilestrip_full_textzeroes the text pointer on decay, so a tag edit on a decayed memory would have permanently destroyed searchable text. Supersede already does corrections properly. Idempotency was deferred: the store path is not atomic, so a key row must commit insideMetadataStore::insert's transaction, and there are two independent id mint sites.Still open
A vector entry's phase is correct at boot but not updated by decay while the process runs; tombstoned memories are never reclaimed; and this store has seven records in
META_TABLEmissing fromNAMESPACE_INDEX, predating all of this work.Verification
306 → 487 tests. Every commit verified to build and pass independently.
🤖 Generated with Claude Code
https://claude.ai/code/session_01JNhUehmChiQKJUjv3QPnkH