My desktop companion remembers things about me. That is the whole point of it — until it remembers the same thing twice, and then remembers the opposite of it, and then confidently asserts whichever one it read last.
That is the failure mode I fixed in Ankita v2.7.5. The problem was not that the memory store lost facts. It was that it never questioned them. Every new fact went in as a new row, so "I use Postgres" and "Database is Postgres" sat side by side as two facts, and a later "I moved to SQLite" sat next to both of them, unchallenged. The companion would then answer from whichever row surfaced first and sound certain about it.
The decision: merge, flag, never silently delete
The new rule, in one sentence: a new fact is compared against what is already stored before it is written. If it is near-identical to an existing fact, it updates that fact instead of creating a second one. If it looks related but disagrees, both facts are kept and linked as conflicting, and the companion is told to ask which is current rather than assert either. Nothing is deleted or rewritten automatically, ever.
That last clause is the decision worth arguing about. The tempting design is auto-resolution: newest wins, old fact dropped. It is cleaner, and it is wrong for a personal store, because "newest" is not the same as "true". People change their minds and then change them back. A migration gets reverted. A preference stated in passing is not an instruction to erase the one before it. So the store keeps both and wears the uncertainty in public.
How the comparison works
The comparison lives in src/memory/memory-similarity.mjs, and it is deliberately two-layered. Embeddings are used when an embedder is configured — v2.7.5 also adds offline semantic memory, so you can point EMBED_LOCAL_BASE at an Ollama or LM Studio server instead of a Cloudflare account — but the module always has a deterministic lexical fallback, so memory quality never depends on a provider being configured.
The lexical path works on content words only. A stop-word list strips the filler ("I", "my", "use", "database", "project", "stuff"), so "I use Postgres" and "Database is Postgres" both reduce to {postgres}. Two words match when they are equal, or when the shorter is a prefix of the longer and at least four characters long, which catches postgres/postgresql and migrate/migrated without matching on stubs.
From there, three rules:
-
Merge above 0.85.
DEFAULT_DEDUP_THRESHOLD = 0.85— above this similarity, one fact, not two. The score is the best of Jaccard overlap and a harsher containment term that only fires when every content word of the shorter fact appears in the longer one, which is the common shape of a repeated preference. A single shared distinctive word can link two short facts at 0.6, but only when both sides are short, so long incidental sentences cannot match on one word. - Conflict between 0.55 and the merge line, with conditions. Lexically, a conflict needs at least three meaningful words, at least two agreements, and a Jaccard of 0.5 or more — two-word facts cannot contradict, because one shared word is ambiguous. A conflict is only claimed between facts of the same declared kind, so unrelated facts that happen to share vocabulary are not a disagreement. And when the winning score came from an embedder, the cosine threshold is only trusted if the score actually came from the embedder; a cold or partial embedding index mixing cosine and lexical scores is not allowed to manufacture conflicts.
- A provider failure never blocks a write. If the embedder throws, the score falls back to lexical. If the whole comparison finds nothing, the fact is stored plainly. The memory write is the thing that must not fail; the dedup is a quality gate on top of it.
What the flag looks like in practice
When a conflict is found, both facts get each other's id in a conflictsWith list (src/memory/profile.mjs links them both ways, and clearing the flag removes it from both sides). When either fact is later forgotten, the dangling pointers are cleaned up, so a resolved conflict cannot haunt later recalls. And when conflicting facts are recalled into context, the prompt carries a plain notice: "Some facts conflict with each other. Ask the user which is current instead of asserting either."
That notice is the whole user-facing behaviour. The companion does not pick. It asks. The cost is one clarifying question, occasionally, in exchange for never confidently stating something the store itself knows is disputed.
There are honest limits. The lexical fallback cannot see that "Postgres" and "PostgreSQL" are the same word unless the prefix rule catches it, and it cannot tell a genuine contradiction from two facts that merely share vocabulary until the same-kind and minimum-word gates filter them. The merge threshold is a single number, 0.85, applied to every kind of fact. And a conflict flag is only as good as the next conversation: if the user never answers the question, both facts keep sitting there, flagged, forever. Pins are exempt from staleness for the same reason — a deliberate fact should not quietly expire.
What else shipped in v2.7.5
The memory change is one part of the release. Also in 2.7.5: an editable personality card in Settings (caring attitude, teasing, language, example exchanges, shared by chat and voice); voice that keeps listening for a short follow-up window (25 seconds by default) instead of needing the wake word every time, with a quiet "Say Ankita first" hint in standby; the open_app launcher that actually opens apps by name from a discovered, validated index; voice wake/stop words and auto-mute; scheduled results that appear in Scheduled tasks as soon as they finish; heartbeats that retry instead of being dropped, with [SILENT] runs that record without posting; long-conversation compaction on by default in the desktop app; and shared Telegram, Discord and Slack channels on one local policy and delivery core. Fixes include a stuck voice process no longer freezing the queue, and npm test scoped to the project's own test/ directory.
Ankita is open source (MIT) and local-first: github.com/akyourowngames/A.N.K.I.T.A. The release is at github.com/akyourowngames/A.N.K.I.T.A/releases/tag/v2.7.5, and the site is ankita-sable.vercel.app.
Tell me I am wrong
Here is the claim to break: a personal memory store should never auto-resolve a contradiction, even when the newer fact looks obviously right — because "newest wins" silently destroys the one record that could have corrected it. Is that caution, or is it cowardice? If you would let the store auto-rewrite on a high enough similarity score, where would you draw that line — and what would you do about the user who changed their mind back? I would genuinely like to know where this breaks first.
Top comments (2)
Your closing question is about whether to auto-resolve a contradiction once it has been detected. The
same declared kindgate decides something earlier than that: which pairs are allowed to be contradictions at all. Kind is a label the writer puts on the fact. The comparison does not derive it from the content.That makes the two error directions unequal. A false conflict asks the user a question, so it surfaces and can be corrected. A real contradiction filed under two different kinds produces no
conflictsWithlink, and the store then looks exactly like a store with nothing to disagree about. No threshold between 0.55 and 0.85 reaches it, because the pair was dropped before any score mattered.Two numbers from an append-only signed ledger, on this exact shape of bug. A reader that took only the first matching label on a row silently retracted a claim that was correct; re-reading labels as a set, all 343 of the dropped rows carried the second label. Separately, dedup hashed the content together with one writer-supplied timestamp and so merged nothing at all: 4,010 of 120,810 rows were content duplicates, in 394 groups, across 18 of 64 keys, with 98.85% duplication in one row type. Every identifier was distinct and re-derived cleanly, so the checks all passed.
Neither says anything about Ankita's miss rate. They say the gate field is the ceiling.
For a cross-kind pair that lands inside the conflict band, would you keep the pair under a label meaning "not claimed, kinds differed", so the kind assignments themselves stay auditable?
You're right, and the asymmetry is sharper than the article lets on. A false conflict costs a question; a missed one costs the whole disagreement, silently.
The gate is in findSimilar() in src/memory/memory-similarity.mjs. If a pair is related but the kinds differ, it returns { fact: null, score, kind: null } — the pair is dropped and nothing is recorded anywhere. There is no audit trail of that decision, so a later recall looks exactly like a store with nothing to disagree about, which is your point.
It is also asymmetric in the other direction: the merge above 0.85 is not kind-gated at all, so cross-kind near-identical facts still merge, while cross-kind contradictions in the 0.55–0.85 band are thrown away.
And the kind itself is shakier than "declared" sounds. The consolidation prompt in src/memory/consolidate.mjs asks the extractor model for kind as a free-form category, and ProfileStore.add() in src/memory/profile.mjs defaults to 'fact'. So the same fact can easily get two different labels across sessions, and the gate then rests on a model-chosen string, not a stable taxonomy.
On your proposal: I would keep the pair, but not in conflictsWith. That list drives the prompt notice "ask the user which is current", and that notice would be wrong here — the pair may not disagree at all, only the labels may differ. What I would want is a separate, non-conflict record of the near-miss: related score, both kinds, and no prompt notice, visible when either fact is recalled or corrected. That keeps the kind assignments themselves auditable without crying conflict where none is claimed.
Caveat, honestly: that record does not exist yet. Today the cross-kind pair just vanishes at write time in tools/personal/remember.mjs, which only acts when findSimilar() returns kind 'conflict'. So your ledger examples land — the gate field is the ceiling, and right now it fails closed in the wrong direction, by hiding the miss.****