|
memory: a fact written under a new spelling of its key merges into the one that survives
The extractor invents a key every time it meets the same fact — `language`,
`preferred_language`, `ui_language` — and the upsert keys on the exact string, so
the base grew several spellings of each durable fact. On prod, 24 facts across 11
clusters share a key once noise prefixes are dropped, 10 of them under the exact
same value.
upsert_fact(dedupe=True) now compares the *tokens* of the normalized key — set
equality, not overlap, and never the value: `ui_language` and `language` are one
key while `dark` and `dark_ui` are two. The row already holding the writer's exact
key wins; otherwise the incoming one does. Losers are marked, not deleted: each
keeps its key and its value and gains `merged_into`, so the merge is reversible
with one UPDATE. Off by default, so the memory tool still writes exactly the row
it asked for.
Every read path filters `merged_into IS NULL`, and the mark is reachable only
where it is meant to be — get_all_facts/fact_count gained include_merged, and
GET /admin/memory?include_merged=true exposes it. search_facts deliberately has
no such flag: the injection path must not be able to see a hidden row. Forgetting
a key drops its hidden aliases first, so none is left pointing at nothing.
Three fixes that fell out of the same read:
- expires_at was overwritten with EXCLUDED.expires_at on every upsert, and the
extractor always passes None — so it silently cleared any expiry the agent had
set. Both DO UPDATE clauses now COALESCE.
- The ILIKE fallback was the one read path that served expired facts.
- The `list` action promised "all stored facts" and returned categories. The code
was right and the description was wrong; the description now says so.
The extraction prompt stopped allowing infrastructure — servers, hosts, addresses,
running services — while the agent and the planner already forbade it; that
loophole is where 423 technical facts came from. It also forbids secrets outright,
and asks for stable snake_case keys. Expiry is now documented as being for facts
with a known end, not for facts you merely doubt.
Facts reach the prompt with their age ("updated 34d ago") alongside confidence:
nothing expires on a clock, so the injection says how old a fact is and lets the
agent weigh it.
|
|---|
|
|
| docs/memory.md |
|---|
| manuals/memory.md |
|---|
| navi/api/routes/admin.py |
|---|
| navi/core/context_builder.py |
|---|
| navi/memory/_ddl.py |
|---|
| navi/memory/_embeddings.py |
|---|
| navi/memory/_facts.py |
|---|
| navi/memory/extractor.py |
|---|
| navi/memory/migrate_pgvector.py |
|---|
| navi/tools/memory.py |
|---|
| tests/unit/core/test_context_builder.py |
|---|
| tests/unit/memory/test_extractor.py |
|---|
| tests/unit/memory/test_memory_ddl.py 0 → 100644 |
|---|
| tests/unit/memory/test_store.py |
|---|