Merge v6.7.x into main #36

Merged
ric merged 15 commits from v6.7.x into main 2026-09-15 07:59:58 +00:00
Owner

Brings main from v6.5.1 up to v6.7.1, covering the whole 6.6 and 6.7 lines:

  • 6.6.0: project work-type domains and cross-project recall(domain_filter=...); fixes _migrate_indexes(), which had silently failed on every upgrade
  • 6.6.1: licence (redistribution rights) on every memory, set at ingest
  • 6.6.2: provenance (retrieved / concluded / asserted) on every memory
  • 6.7.0: ONNX Runtime embeddings, PyTorch out of the images (same vectors, a third of the latency, 2.04 GB → 634 MB)
  • 6.7.1: relevance floors for recall() and find_skills(), weak_match flag, phantom index guard and drift reporting (#28–#34)

Tagged and released as v6.7.1 on the v6.7.x branch. The branch is 15 commits ahead of main with nothing on main it doesn't already have. Full suite passes locally: 1914 passed, 1 skipped.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HWqtyPdN5MHxqoVM3Sj9xD

Brings main from v6.5.1 up to v6.7.1, covering the whole 6.6 and 6.7 lines: - **6.6.0**: project work-type domains and cross-project `recall(domain_filter=...)`; fixes `_migrate_indexes()`, which had silently failed on every upgrade - **6.6.1**: `licence` (redistribution rights) on every memory, set at ingest - **6.6.2**: `provenance` (retrieved / concluded / asserted) on every memory - **6.7.0**: ONNX Runtime embeddings, PyTorch out of the images (same vectors, a third of the latency, 2.04 GB → 634 MB) - **6.7.1**: relevance floors for `recall()` and `find_skills()`, `weak_match` flag, phantom index guard and drift reporting (#28–#34) Tagged and released as v6.7.1 on the v6.7.x branch. The branch is 15 commits ahead of main with nothing on main it doesn't already have. Full suite passes locally: 1914 passed, 1 skipped. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HWqtyPdN5MHxqoVM3Sj9xD
ric added 15 commits 2026-09-15 07:59:27 +00:00
_migrate_indexes() drops any index whose field count is short of its
definition so it gets recreated with the new fields. The drop used
valkey-py's client.ft(name).dropindex(), which appends its
delete-documents flag positionally even when False — so the wire command
was `FT.DROPINDEX <index> ""`, three arguments where valkey-search
accepts two. RediSearch tolerates the trailing empty string;
valkey-search rejects it with "wrong number of arguments", and that
error was caught by the same `except valkey.ResponseError` that handles
the ordinary case of an index not existing yet.

So the migration failed silently on every upgrade since it was
introduced, leaving upgraded instances indexing an older field set than
a fresh install, with nothing in the logs. Any field added to an index
in a past release was only ever searchable on fresh installs.

The drop now issues the raw two-argument command and a genuine failure
is logged as an error rather than swallowed. reindex_namespace() had
already worked around this, though the comment there misdiagnosed the
cause; that comment is corrected.

The in-memory test fake accepted the broken call happily, which is why
the migration tests passed for as long as the bug existed. It now
raises the same error a real valkey-search returns, so this class of
failure cannot pass the suite again.

Also adds the domains TagField to idx:project for v6.6 — the field
whose absence after an upgrade is what surfaced this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A project context now declares the kinds of work inside it — python,
docker, wcag-accessibility — and recall(query, domain_filter="python")
searches every project declaring that domain at once instead of one
project at a time.

This is the gap compiled skills left open. A skill only exists once its
lessons clear the reinforcement gate, so everything below that
threshold — the gotcha you have hit twice and never written up — was
reachable only by searching the whole store unfiltered. The domain
filter reaches those raw memories.

One vocabulary, not two: project domains normalise through the same
resolve_domain/validate_domain as compiled skills, so `py` on a project
and `py` on a skill both become `python` and the two cannot drift into
parallel taxonomies. The auto skill scan now ranks a domain higher when
projects declare it, boosting candidates only — a domain with no lesson
pool would be dropped at the min-pool gate anyway, so it never spends
one of the capped candidate slots.

Design decisions worth keeping:

- Domains route, they never label memories. A project is Python and CSS
  and Docker at once, so inheriting its domains onto every memory would
  surface a CSS gotcha in a Python search. The domain narrows candidate
  projects; the vector search still decides relevance within them.
- An unmatched domain is always reported. Filtering on a domain nobody
  declares runs unscoped with a leading domain_filter_notice, because a
  global search presented as a scoped one is worse than no filter. An
  empty domain-and-project intersection returns nothing rather than
  falling through to a global search — the pipeline reads an empty
  project list as "search everything", so the tool layer short-circuits.
- domains is stored comma-separated, not JSON. A TAG field tokenises on
  commas, so the JSON-array form used by tags/topics indexes as garbage
  and @tags:{python} matches nothing. Verified live against
  valkey-search, including the JSON control case.
- project_filter now takes a list and composes with clause-level OR,
  never in-brace alternation, which returns an empty set on
  valkey-search. One unsafe value drops push-down for the whole clause
  rather than filtering on part of the set.

A startup migration seeds domains from each project's existing stack
string so the filter is not empty on day one after an upgrade. It only
writes where the field has never existed, never overwrites, and records
an empty marker when a stack yields nothing usable.

compile_project_domains() proposes domains from the stack field and the
tags that recur across a project's own memories, with the evidence for
each, writing only on auto_save=True. The web UI gains domain pills,
a filter chip bar, ?domain= filtering, a datalist of known domains, and
a Suggest from stack & memories button that fills the field without
saving.

1544 tests pass, 100% line coverage on every module touched. Verified
end to end against a real valkey-search instance, including a simulated
6.5.1 to 6.6.0 upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every writable namespace carries `licence` (own | open | restricted |
unknown) plus an optional `licence_note`, decided the moment a record is
written and never derived from ranking. The RSS worker stamps articles
from the feed's new `licence:` declaration (unknown if it declares
nothing; RSS_REQUIRE_LICENCE=true refuses such feeds before any fetch),
remember()/remember_document() take a licence argument with honest
namespace defaults, project context is own, and extracted facts inherit
their source's rights.

Recall reports the field on every classified result and appends a
licence_notice listing unknown ones so the human classifies them while
the content is on screen; set_licence records the answer (by key or by
feed), cascades to extracted facts, and never bumps updated_at. The web
UI filters on licence, flags unclassified rows, classifies from the
detail page, takes a licence on create, and declares one per feed.

A startup migration backfills honest unknowns (conversation namespaces
own, articles and imported memories unknown, facts inherit) and reruns
after a backup restore. licence is a TAG on all four writable indexes
and in every return whitelist from day one; the identifier alias table
is mirrored verbatim into the worker with a parity test. Verified the
index rebuild, backfill and tag filter against a real valkey-search.

1694 tests, every touched module at 100% line coverage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every writable namespace carries `provenance`: retrieved (an external
source), concluded (the system's own reasoning or write-up), or asserted
(stated by the human). Without it an inference made last month is
recalled today as independent corroboration for the reasoning that
produced it. Set at write time — remember()/remember_document() take the
argument with namespace defaults, set_project_context is asserted, the
ingester stamps retrieved, extracted facts inherit their source — and
reported on every recall surface without ever touching ranking.

The backfill makes the roadmap's deliberate call: articles retrieved,
preferences and context entries asserted, facts their source's class,
and every existing episodic memory concluded — honest about who wrote
it, reclassifiable with set_provenance(..., "asserted") when the human
vouches for one. Reads resolve a missing value with the same rules so a
lagging worker image can't leave an invisible third state.

set_licence and set_provenance (and the web detail forms) now share one
lineage engine: stamp the memories, cascade to extracted facts by
enriched_from and source_doc_id, refuse skills and non-memory keys, and
never bump updated_at. The write-time defaults live in
licence_for_write / provenance_for_write, used by the MCP tools and the
web create form alike. Verified the index rebuild, backfill and tag
filters against a real valkey-search.

1762 tests, memory/ and tools/ at 100% line coverage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Findings from a second review pass over 6.6.1 and 6.6.2 after the
first-round fixes had landed:

- The web licence form silently no-oped on any record with neither
  licence field yet: get_fields_multi returns None when none of the
  projected fields exist, not when the key is missing. It now proves
  existence by created_at, so an unstamped record is classifiable.
- A chunked document is one rights unit: classifying one chunk now
  reaches its sibling chunks as well as the document's facts, so the two
  can never disagree. Classifying only articles skips the fact scan.
- The write that queues enrichment carries its licence and provenance in
  the job payload, so facts inherit them even if the first chunk is gone
  by the time the worker runs.
- Read-time fallbacks (effective_licence / effective_provenance) now
  mirror the backfill exactly, and the suite asserts every record shape
  reads the same before and after the migration. The web memories filter
  uses them too, so ?licence=unknown is the complete queue before a
  restart.
- Skill import never keeps the exporter's "own"; open/restricted travel
  with their note, everything else is unknown. recall_detail no longer
  reports classification for skills.
- Dedup: one note_for_reclassification rule for both web forms, one
  classification_fields reporter for every read surface, LICENCE_CHOICES
  / PROVENANCE_CHOICES beside the vocabularies, and set_licence's feed
  path goes through the same engine as the key path.

1778 tests, memory/ and tools/ at 100% line coverage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- /memories?namespace=skill 500ed: the list view now only accepts the
  four memory namespaces (skills carry no classification and have their
  own pages).
- detail.py carried duplicate memory_licence/memory_provenance
  definitions and the dead _note_for_reclassification helper left by the
  previous edit; removed.
- migrate_licence stamps an imported extracted fact unknown, not own —
  its source lives on another instance.
- Enrichment lets the live record win over the payload snapshot, so a
  reclassification between enqueue and processing is inherited; the
  payload fills in only what the record lacks.
- The briefing projection includes imported_at; the effective_* docstrings
  state the one gap a store-free fallback has instead of claiming exact
  parity.

1781 tests, memory/ and tools/ at 100% line coverage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
scripts/embedding_bench.py runs ingest and recall end to end against a
real valkey-search using whatever memory/embedder.py is wired to, so the
same script measures before and after the swap. Reports model load time,
peak RSS, embed() p50/p95, embed_batch() throughput at 1/8/32,
remember(), remember_document(), recall() and recall_index() latency
over a seeded synthetic corpus or the text of a real backup.

It also saves the raw vectors for a sample of texts and the top-10 per
query; `compare` turns those into per-text cosine between the two
backends, top-k overlap and score drift — the check that says whether an
ONNX export is the same model or merely a faster one. Refuses a
populated store, deletes everything it wrote (recall logs included),
exits non-zero on a warning, and runs against the in-memory fakes in the
suite so it cannot rot between runs. Torch baseline recorded locally in
bench/before-torch.json (gitignored).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
memory/onnx_embedding.py runs the model maintainers' ONNX export of
all-MiniLM-L6-v2 with onnxruntime and the Rust tokenizers library, with
mean pooling and L2 normalisation in numpy. Same vectors as
sentence-transformers (cosine 1.0 on every sampled text, identical
top-10 on every benchmark query, reference-vector test in the suite), so
nothing stored needs re-embedding.

Measured on arm64 with scripts/embedding_bench.py: model load 7.8 s →
0.8 s, peak RSS 924 MB → 301 MB, embed() p50 22 ms → 8 ms, remember()
p50 25 ms → 11 ms, recall() p50 35 ms → 23 ms, MCP image 2.04 GB →
634 MB.

Embedder gains an EMBEDDING_BACKEND switch (onnx default, torch as an
optional rollback via requirements-torch.txt). The RSS worker image now
ships the shared memory/ package (built from the repo root like web_ui)
and calls the same build_model(), probed at boot. The engine reads the
model's pooling config (mean/cls/max, else refused), checks the output
shape and dimension against the index, bounds the token cap, looks in
the HF cache before the network, pins the default model's revision,
accepts a local directory for air-gapped hosts, and explains a missing
ONNX export. Images set HF_HOME=/app/hf-cache; compose mounts a shared
hf_cache volume.

Includes the release announcement (docs/announcements/omnimem-6.7-onnx.md)
and the benchmark harness doc. 1849 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
recall() and find_skills() both filled their result list regardless of
whether anything left was relevant, so top_k behaved as a quota rather
than a ceiling and an agent got handed noise it then had to reason about.

recall gains RECALL_MIN_SCORE (default 0.4), gated on the RAW similarity
rather than the adjusted score. Raw similarity is the only number in the
pipeline that measures subject match; the multipliers on top of it encode
policy (old, abandoned, derived fact), and gating on those would have
silently hidden the two classes deliberately scored down — enriched facts
at surface 0.5 and abandoned experiences at 0.1 — which are exactly what
should still come back when nothing better matches. Abandoned warnings and
reinstate candidates are exempt: neither earned its place on vector
similarity, so neither should be filtered on one.

find_skills gains SKILL_MIN_SCORE (default 0.25) and a per-entry
confidence. Skills need their own floor because their discovery vector is
long human-written prose and scores much lower than a memory's content.
Measured against three compiled skills on a live store: lowest true
positive 0.3042 ("modal dialog, keyboard + screen reader" -> wcag),
highest false positive 0.2341 ("python" -> preferences). 0.25 sits in that
gap with ~0.05 either side. Exact domain hits are identity matches and are
never gated.

Phantom guard (#28): an index entry whose backing hash is gone comes back
from FT.SEARCH as a doc id with no fields. remember() rejects empty
content, so empty content in a memory namespace means the record is gone —
recall now skips and logs it instead of returning a result with no text.
That answers the open question on #28: a phantom could reach a caller, so
this was a correctness bug, not only housekeeping.

#29: reindex documented three namespaces and handled four. It now handles
five (skill included — the SKILL.md body lives in the hash, so the index
rebuild is as safe there as anywhere) and says so. The same three-namespace
assumption turned up twice more: recall's tool description omitted
preference, and memory_audit built its unscoped prefix list by hand and so
silently skipped mem:preference: entirely despite accepting it as a filter.
Both now derive from the accepted set.

FakeEmbedder seeded its per-word RNG on hash(), which Python randomises per
process, so the same word embedded differently on every run. Harmless while
nothing compared against a threshold; instant flakiness once the floors did.
Seeded on crc32 instead. Tests about ranking mechanics rather than relevance
take a no_relevance_floor fixture, since the fake's word-overlap similarity
is far harsher than all-MiniLM-L6-v2 on the same pairs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#32: held_back rules were cut with rule.text[:80], a hard character slice
that landed mid-word — "the in-scope extraction..." arrived as "the in-s".
This list is the input to a bless/don't-bless decision and the substance of
a rule is reliably in the half that got cut, so it was consistently the
useless half that survived. Full text now. The 500-char cap that remains is
a safety valve for a pathological rule, not a display convention: it cuts on
a word boundary, appends an ellipsis and sets truncated: true so it can't be
mistaken for a complete sentence. The stored candidate was never clipped, so
nothing already blessed carries truncated text. The web UI rendered these
semicolon-joined into one paragraph, which full text makes unreadable — now
one per line.

#33: no schema change, which is the open question on that issue, but the
response now carries the evidence for answering it. insufficient_reinforcement
reports pool_concentration, and when every candidate comes from one project
it says so — that is the whole of reading 1 ("python and this project's
history are the same set of memories"), visible at the point of use instead
of requiring an experiment. The note no longer suggests lowering
min_reinforcement: when the candidates are narrative rather than
generalisable claims, a lower gate admits the noise instead of finding
signal, turning a domain that correctly compiled nothing into one that
compiles the wrong thing. It points at bless() and at narrower domains
instead.

#34: the staleness clock flagged the memories the system rates most durable.
A memory that compiled into a skill stops being touched precisely because it
graduated — it reaches sessions through the skill, not direct recall — so the
metric inverted and pointed maintenance at the best memories in the store.
Skill sources are now exempt, read live from each skill's source_manifest
rather than stamped onto the memories, so deleting or recompiling a skill
takes the exemption with it and no marker outlives what it refers to. To
answer the question the issue raised: nothing archives on the stale list
today (auto-archive covers duplicates and expired RSS only), so this was
misdirection rather than active erosion.

#28: drift was only ever a field in health(), and nothing prompts an
operator to call health() — which is how 762 orphans accumulated unseen. The
comparison moves to store.index_report() so health(), briefing() and a new
startup check share one definition, and it is now reported at boot
(INDEX_DRIFT_CHECK=false opts out) and in every briefing. Not self-healing:
dropping and recreating every index automatically on boot is a bigger hammer
than the problem, and reindex() is one call away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Documents RECALL_MIN_SCORE, SKILL_MIN_SCORE and INDEX_DRIFT_CHECK in
.env.example and the configuration reference, and records the calibration
behind both floor defaults in the changelog so a later retune starts from
the measurements rather than from scratch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The floor added for #30 applied to the web UI's search page too, which is
wrong for it. The floor exists because recall output is spent as an agent's
context and a plausible-looking irrelevant result can send the agent chasing
a connection that isn't there; a person reading a list on screen pays neither
cost, and an empty page for a memory they know is in the store is a worse
answer than a weak match they dismiss at a glance.

recall() takes a per-call min_score that overrides the env default, the
search route passes 0, and results below the configured floor wear a "weak
match" badge — the softer treatment issue #30 offered as an alternative,
applied where the reader can act on it rather than where they can't.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both found by review of the previous two commits, both reproduced before
fixing and both now covered by tests that fail without the fix.

The relevance floor could delete a result that matched perfectly. When an
extracted fact outranks the verbatim memory it came from, step 10b drops the
fact and promotes the source in its place — but it copied only
adjusted_score, and the floor reads `score`. A short fact matching at 1.0
against a long verbatim source at 0.39 therefore produced an empty recall:
the fact was discarded in favour of the source, then the source was
discarded for a weak similarity it was no longer standing on. The promotion
now carries the raw score too, which is what the two numbers describing one
result requires anyway. This was not a corner case — enrichment routinely
distils short facts out of long memories, which is exactly the shape that
triggers it.

The phantom guard tested the wrong thing. "Empty content means the record is
gone" holds for episodic, knowledge and preference, where remember() rejects
empty content, but not for project: compile_project_context(auto_save=True)
writes content="" whenever there is no description yet, and the real text
lives in current_state. So a fully populated project context — the one the
session-start bootstrap in CLAUDE.md creates — became permanently
unrecallable, with a log line telling the operator to run reindex(), which
cannot help. A phantom is now identified by carrying no fields at all beyond
the key and score, which is the actual signal and needs no per-namespace
knowledge.

Also: positive and negative drift are different faults with different
remedies, and one sentence was describing both as "index entries without a
backing record". A shared drift_note() now says which is which, and the
startup check no longer warns when the index is merely behind — that is the
expected state moments after _migrate_indexes() recreates one, and reindex()
is not the answer to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found the calibration was read off the wrong scale. The 0.4 proposed
on #30 came from recall()'s reported scores, which are adjusted — 1.133 is
not a reachable cosine — while the floor gates raw similarity. Transferring
the number between scales cost real results.

Measured with the shipped ONNX embedder over query/memory pairs written in
this repo's own style: true positives 0.189 ("why do we get a 421
misdirected request" against the FastMCP Host/Origin write-up) to 0.633,
false positives -0.06 to 0.242 ("search index drift" against the
responsive-tables note). The two distributions overlap, so no single cut is
both safe and useful — 0.4 kept 6 of 8 correct answers, and a user
experiences that as recall having forgotten something they know is stored,
with nothing to say a filter fired.

So the floor drops to 0.15, where it removes only what is unambiguously
noise, and RECALL_WEAK_SCORE (0.35) flags the band between as weak_match.
The agent instructions tell it to read a weak match but not build on it and
not to reach for a connection — which is what #30's harm actually was. The
row count was the symptom; an agent finding a connection to a WWDC Spotlight
note because nothing said not to was the failure. This is the softer
treatment the issue itself offered as an alternative, and on this evidence
it is the only honest one: a hard cut here would be a guess presented as a
judgement.

The skill floor stays at 0.25. That comparison is a query about the work
against a short deliberate summary of what a skill is for, it separates
cleanly on measurement, and a hard cut there is defensible.

Also from review: test_abandoned_warning_is_exempt used a 0.99 floor against
a warning constructed with score 1.0, so it passed with the exemption
removed — it now uses 1.5 and fails without it. Three of the four
no_relevance_floor fixtures come off, because at 0.15 those pairs clear the
floor in the fake too; the fourth is annotated with why it stays. Adds
coverage for the weak band, the reinstate exemption, the variant phantom
guard, briefing's index_drift block, and the startup check including its
opt-out — the last two being the parts of #28 that had none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: one definition of a weak match, shared by the pipeline and the web UI
Some checks failed
Security Scans / Bandit SAST (pull_request) Successful in 22s
Security Scans / Dependency Audit (mcp_server) (pull_request) Successful in 1m5s
Security Scans / Dependency Audit (rss_worker) (pull_request) Successful in 28s
Security Scans / Test Coverage (pull_request) Failing after 1m32s
Security Scans / Secret Scan (pull_request) Successful in 5s
Security Scans / Dependency Audit (web_ui) (pull_request) Successful in 26s
Docker Build & Push / Build mcp_server (push) Successful in 15m20s
Docker Build & Push / Build web_ui (push) Successful in 5m58s
Docker Build & Push / Build rss_worker (push) Successful in 6m48s
8d647cff2c
The search route was computing its own weak_match against the floor while
the pipeline set its own against RECALL_WEAK_SCORE, so the two disagreed
about which results were doubtful. The route now reports the pipeline's flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ric merged commit 0f365e1dab into main 2026-09-15 07:59:58 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
ric/omnimem!36
No description provided.