Skip to content

feat: agent-facing tooling, safe namespace deletion, and working semantic search after restart (v1.0.0) - #6

Merged
calebevans merged 39 commits into
mainfrom
feat/agent-ergonomics
Aug 12, 2026
Merged

feat: agent-facing tooling, safe namespace deletion, and working semantic search after restart (v1.0.0)#6
calebevans merged 39 commits into
mainfrom
feat/agent-ergonomics

Conversation

@calebevans

Copy link
Copy Markdown
Owner

Replaces #4, which was based on bug-fixes and 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 on main.

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_namespaces and list_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/finding vs type/observation — silently fragmenting recall in a store shared with other agents. list_tags returns entity/topic/emotion labels with prefixes stripped, so its output can be pasted straight back into store_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_memories for batches, and delete_namespace which refuses a non-empty namespace unless forced and refuses default unconditionally.

Honest numbers. namespace_stats counted tombstones in memoryCount, which also deflated avgStrength and inflated vectorBytes, edgeCount and permastoreCount. list_namespaces reported memoryCount hardcoded to 0. The health report's "top tags" were the first ten alphabetically.

The 1.0.0 blocker, fixed

FlatVectorIndex was 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_memories returned "no embedding found" and recall_memories silently 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_all that 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:

  1. delete_namespace could remove_dir_all an arbitrary directory. create_namespace never validated the name at the adapter layer, and remove_namespace_dir joined the raw name onto db_path with no path-component check. name: "" resolves to db_path itself — the whole database, destroyed through the normal MCP tool.
  2. A case-insensitive filesystem defeated the default guard. create_namespace("Default") committed a row then failed on the vector-file lock; delete_namespace("Default", force: true) sailed past != "default" and unlinked the real default/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_memory was dropped — editing tags stales the embedding, and FtsIndex has only a full-row add while strip_full_text zeroes 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 inside MetadataStore::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_TABLE missing from NAMESPACE_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

calebevans-ab and others added 30 commits August 11, 2026 14:44
`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
calebevans-ab and others added 9 commits August 11, 2026 14:44
`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
@calebevans
calebevans merged commit 065cbeb into main Aug 12, 2026
18 checks passed
@calebevans
calebevans deleted the feat/agent-ergonomics branch August 12, 2026 06:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants