feat/rag-intake — stop storing a compacted block as one averaged vector - #28
Merged
Conversation
compaction.py and the migration that has to re-slice what it wrote weeks ago both need to answer "where does one exchange end?", and until now only compaction could -- inline, in the middle of a join. Two copies of that rule would put the live path and the migration out of step, and the store would end up with its old and new halves cut differently. That has no symptom other than a retrieval distance nobody can account for. forge/transcript.py holds the three operations: render(messages) is the format the store has always used, blocks(messages) groups messages into retrieval units, split(text) recovers the same units from text that was already rendered. The test that earns the module is the round trip -- split(render(msgs)) == blocks(msgs). An exchange, and not the whole evicted block, because of the numbers measured on the Deck on 2026-08-22: a question sits at 0.9386 from the compacted block that answers it nearly word for word, while a short fact answering a different question sits at 0.671. Burying a sentence in a long block costs about 0.27 of distance -- larger than the whole gap that separated a hit from a miss that day. rag._embed averages the chunk vectors of a long input, and the mean of a dozen unrelated chunks is near no question in particular. An exchange, and not a single message, because the other direction fails too: a user turn alone retrieves a question rather than an answer, and "oui, 8080" on its own has lost its subject. Known limit, accepted and documented: split finds boundaries by role prefix at start of line, so a message whose own content contains such a line splits in the wrong place. It splits -- it does not lose anything -- and the alternative would not be able to read the blocks already in the store, which is the case this exists to serve.
remember() writes one row and one vector. Compaction's only way to archive a block was therefore to join it into one string -- and that string is far past EMBEDDING_MAX_CHARS, so its single vector is the AVERAGE of a dozen chunk vectors produced by _embed. The mean of a dozen unrelated subjects is close to no question in particular. That is not a theory. Measured against the real store on 2026-08-22: "Quels outils as-tu accès ?" sits at 0.9386 from the compacted block that contains the question almost word for word, while a short fact answering a different question sits at 0.671. About 0.27 of distance, lost to the averaging, on a store where the whole hit/miss gap that day was 0.0422. remember_many() writes N rows in one transaction. It costs no extra embedding calls -- _embed already made one request per chunk. The change is that the chunks stay apart instead of collapsing into their mean. Two deliberate asymmetries with remember(): A degenerate item is skipped with a warning, not raised on. remember() raises because a caller asserting a one-word fact needs to hear that the value went missing; here the caller is archiving a block it did not write, and failing the whole compaction because one evicted message was a single word leaves the history uncompacted with no way out. An embedding failure propagates before the commit, so nothing is stored. A half-indexed block is worse than an unindexed one: the pointer written into the history claims a range that does not hold what it says it holds.
The rag_pointer strategy joined an entire evicted block into one string and stored it as one entry. That is how the store on the Deck came to hold 16 entries of which 11 were whole compacted blocks, and why a question sits 0.27 further from the block containing its answer than from a short fact. transcript.blocks() cuts the block at user turns and rag.remember_many() stores the pieces as rows. The pointer left in the history now names the range it created (#12-#27) instead of a single id, and says so plainly when nothing was indexable rather than pointing at an entry that does not exist. A new compaction.indexed log line records how many messages went in and how many entries came out, because those two numbers are no longer equal and the difference is the filtering below. Two things stop at the store boundary, both found by reading the real store on 2026-08-22 -- the first day anything could enumerate it without asking it a question: An earlier compaction pointer is not indexed again. It is a reference to another entry, and stored it becomes a memory whose entire content is "N messages were compacted, see #12": it answers no question and sits at middling distance from all of them. The block it points at stays reachable through search; only the textual chain is not rebuilt, which is the honest trade for not indexing a signpost as if it were the road. Raw router JSON is unwrapped. Entry #9 of that store was a {"tool": "code", ...} object swallowed from an assistant turn by a version that did not unwrap tool output yet. The envelope is the noise and the content inside it is a real answer, so it is unwrapped rather than dropped. _POINTER_RE and _pointer() are two statements of the same string and a test pins them together. A pointer that stops matching its own detector gets indexed as conversation, and nothing anywhere fails. llm_summary now renders through transcript.render for the same reason: one definition of the stored format.
Indexing per exchange fixes what compaction writes from here on and does nothing at all for what is already written. On 2026-08-22 the real store was 16 entries of which 11 were whole compacted blocks, so without a migration the store stays mostly made of the shape the change exists to remove -- and the next measurement against it measures the old problem. deploy/rag_resplit.py cuts every history_summary back through transcript.split() and stores the pieces. An entry that yields a single unit is left untouched: it is already the right shape, and rewriting it would spend an embedding call to produce the same row under a new id. Other kinds are not considered -- a fact, a decision or a todo is one statement by construction. Dry run is the DEFAULT and prints exactly what --apply would do, with --backup for the copy nobody takes in time. This rewrites rows in place and there is no undo. New entries are inserted before the old one is deleted. A crash in between leaves the block stored twice -- as a blob and as its pieces -- which is visible in !memory and fixable with !forget. The other order loses the block outright. Duplicated is recoverable, deleted is not. An embedding failure stops the run instead of skipping the entry: a server that just went away will fail every remaining entry too, and a half-migrated store with no record of where it stopped is worse than one that was never started. What was already committed stays. Cost, so it is not a surprise: one embedding request per new entry, so 11 blocks cutting into ~90 exchanges is ~90 requests. That is the same number _embed already made when it chunked those blocks to average them, paid once more.
The intake change rests on a number that had never been measured directly. It was inferred, correctly but with two variables moving at once, from two readings against the real store on 2026-08-22: one question at 0.9386 from a history_summary containing it nearly word for word, another at 0.671 from a short fact. Different questions, different entries. Here only one thing moves. The same sentence is planted three ways -- alone, buried in a transcript stored as ONE entry, and the same transcript stored one entry per exchange -- and the same question is asked of each. buried minus split is what the change buys. It also prints the mechanism it depends on: how many chunks the buried form is averaged from. And it says out loud what a null result means -- that the dilution comes from somewhere other than the averaging, and the change must not be defended with this number. A harness that can only confirm is not a measurement. The figure it produces is a LOWER bound. A real evicted block is an order of magnitude longer than the one planted here, so its single vector is the mean of proportionally more unrelated subjects. tests/test_rag_dilution_bench.py exercises the plumbing with a stub embedder and asserts nothing about the numbers -- asserting on them would be the same mistake in the other direction. It exists because four measurement harnesses on this repository have now failed or measured something slightly beside the point, each costing a real round trip on the Deck to discover. A crash two hundred lines in wastes that trip; this catches it here.
Two things the memory page did not say. The compaction section still described rag_pointer as storing the evicted window as one entry, which stopped being true in this branch; and nothing anywhere mentioned that a store written before the change is still full of blocks, or how to fix one. Adds the numbers behind the change rather than asserting it is better, and points at the two harnesses and the migration.
The previous patch shared the CUTTING between compaction and the migration and left the FILTERING behind, so the disagreement moved instead of disappearing. The real migration on 2026-08-22 proved it: seven of the ten re-sliced entries began with a compaction pointer, so each produced a unit whose entire content is "[59 messages précédents compactés -- voir mémoire vectorielle #12]", and the old entry #9 shed a few units of raw router JSON. Roughly a dozen noise entries out of 278 -- exactly the class of drift the shared module existed to remove. forge/transcript.py now owns the whole sequence and both callers are the same three steps: units(messages) = blocks(indexable(messages)) split(text) = units(parse(text)) A test asserts that equality on one input. parse() is the real inverse of render() (a leading chunk with no role prefix keeps role None, and render writes it back without inventing a speaker), and the pointer builder and its regex move here too, since a detector that lives apart from what it detects is the same drift one level down. The pointer check no longer looks at the role: a block re-parsed by the migration can hand a pointer back under whatever role preceded it in the stored text, so the shape is the evidence, not the speaker. rag_resplit rewrites an entry when the pipeline returns something other than what is stored -- which covers a block that needs cutting AND an entry that only needs unwrapping. An entry left with nothing at all is REPORTED, not deleted: removing a row nobody asked to remove is not a migration's job, and !forget is one command away. RECALL_MAX_DISTANCE=0.95 in .env.example, the first measured value it has ever had. Six questions against a copy of the real store, before and after re-slicing: before hits 0.8934 0.671 0.9386 | misses 0.9891 0.9356 1.098 worst hit > best miss -> NO GAP after hits 0.4498 0.671 0.7695 | misses 1.0619 0.8337 1.0369 gap 0.0642 The intake change is what made a threshold choosable at all, and the short fact at 0.671 not moving is the control. 0.95 rather than the harness's 0.81 because the three misses are not one family: two are "nothing here is relevant", which is what this setting is for, while "Comment s'appelle mon chat ?" at 0.8337 is near only because the store literally contains "user: Bonjour Forge, comment je m'appelle ?". No number separates that cleanly, and one set to try leaves 0.04 of headroom above the worst real hit. The errors are not symmetric: a bad answer gets argued with, "je n'ai rien en mémoire" gets believed. The code default stays unset, so a deployment that has not measured its own store is not handed someone else's number. Two tests pin the .env.example value against the reasoning in config.py, and pin that the default is still off.
…what was grouped compaction.indexed reported messages=35 indexed=17 entries=17, and that was read on 2026-08-22 as proof the filter had fired. It was not. Seventeen units out of thirty-five messages is what exchange grouping does on its own -- two messages at a time -- and the line carried no number that could distinguish a dropped pointer from an ordinary pair. A log line whose only job is to make an invisible step visible must not be ambiguous about which step it is showing. Filtering and grouping are now counted separately: messages, kept, dropped, units, entries. dropped > 0 is the filter, and nothing else is. transcript.indexable also takes a source label. The migration goes through the same function, so a real resplit run printed "compaction: model wrapped a substantive answer in router-style JSON" while no compaction was happening, about text written weeks earlier. It says "resplit" there now.
A real recall run on the migrated store returned five entries and three of them were the same text, at 0.8306 each: "user: workspace / assistant: À quoi verras-tu que c'est fait ?", stored as #205, #227 and #233. Compaction blocks overlap, so an exchange that sat in the tail of one evicted window and the head of the next was indexed twice, and re-slicing turned that into three rows. top_k is five. Three of them went to one answer. remember_many now skips exact duplicates, both against what is stored and within the batch. Nothing is lost: the string is identical, so the surviving row answers every question the copies would have. Exact match only. A near-duplicate is a judgement with a threshold to tune and a way to be wrong; an identical string is a fact. Scoped to the project, because that is the namespace -- the same sentence under two projects is two statements about two things -- and compared with SQL "IS" rather than "=" so a NULL project matches a NULL project, which is every entry compaction writes. Not applied to remember(). A human asserting the same fact twice is saying they think it was forgotten; an archive holding the same exchange twice is redundancy nobody chose. rag_resplit gains the case this creates: an entry whose every unit is already stored elsewhere. It is reported and LEFT IN PLACE. Deleting a row because its content is redundant is a judgement, and this script does not make those.
…r seen Two changes to the same three lines, because they are the same omission. "Remembered (#305)." is a receipt for a transaction nobody can check. The entry that provoked this went in as "NiPoGi AM06PRO, pocresseur 5500U, 32Go de RAM" and the typo was found days later, by reading the store with a debugging tool that did not exist a week ago. The confirmation now echoes the stored text, its kind and project, and how many entries of that kind exist -- so the person who wrote it sees it while !forget is still one line away. Exactly the lesson files:write learned in v3.11, when a created file answered with a byte count and had to be opened by hand to see what was in it. A write that reports only that it happened hides what happened. The spelling check has one design decision in it: THE DICTIONARY IS THE STORE. A French spellchecker on this corpus is a machine for breaking identifiers -- NiPoGi, sqlite-vec, busctl, aardvark-dns, GBNF are precisely the tokens that carry the information, and a general dictionary corrects them towards common words. Vocabulary drawn from what has already been written knows those words because they were already used, and it sharpens with every entry instead of needing a maintained allow-list. difflib.get_close_matches at 0.85, the same tool sysadmin's target_missed already uses. It only ever SUGGESTS. The entry is stored, unmodified, whatever the check thinks. Silently rewriting a memory entry is the one place in Forge where being approximately right is worse than being wrong: nobody re-reads an entry, so it comes back weeks later as a fact with no trace that it was altered. Everywhere else a mistake is visible -- a bad file, a red test, a diagnosis the logs contradict. And asking the model to fix the spelling would be the v3.9 bug again, where a 9B quietly "corrects" a file it was asked to reproduce. Words under five letters are not checked (SSD, RAM, Go, PC are the vocabulary, not the typos) and the vocabulary read is bounded at 500 entries, since this runs on every write and the store grows without limit.
…ow is not the dictionary Two bugs in the spelling check, both found on the first real run, both in the same three lines. "J'utilise aardvark-dns pour la résolution DNS" was flagged twice, on `utilise` and `résolution` -- two words written a dozen times in that store. The check subtracted the whole new text from the vocabulary before searching. That was meant to stop a word matching itself; it also deleted the exact hit proving the word was fine, so each one fell through to a near neighbour. The word is now looked up first: present in the store means familiar, full stop, and no neighbour of it is worth a line. Which then exposed the second: rag.remember commits the row before the check runs, so the entry is its own dictionary and every word in it is "already in the store" -- because we just put it there. The vocabulary now skips that id. Both directions are pinned by a test, since either one alone makes the feature silently do nothing or silently cry wolf. Noted from the same run, not fixed here: the router rewrote `pocresseur` to `processeur` on its way to the tool, so a typo typed into the chat may never reach the store at all. Only the paths that bypass the model (!remember, direct calls) carry one through -- and those do not go through this tool, so they get neither the check nor the echo. Whether to move both down into rag.remember is a separate decision: it would put UI strings in the storage layer.
…ayed
The confirmation printed after storing a fact ends with "Si c'est une
faute : `!forget 312` puis réécris-la". That command only existed in
the REPL, so typing it in the web UI went through the ! interception,
found nothing, and printed "commande inconnue". The one action the
message asks for was the one action the reader could not take.
DELETE /memory/{id} has existed since fix/dettes-ouvertes; only the UI
binding was missing. !forget is also the first UI command that takes
an argument, so runUiCommand now splits the rest of the line and
passes it to run() -- a dispatcher calling run() with nothing would
have left it answering "usage" forever.
changed:false deliberately. Forgetting a memory entry does not touch
the conversation, so refreshing the chat would only erase the command
and its own answer, which is the bug patch 0020 of
fix/dettes-ouvertes was written to fix.
A test pins the UI binding against the tool message: if the
confirmation stops suggesting !forget, or the UI stops offering it,
one of the two has moved without the other.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Base
aa39191(main, after PR #27). Twelve patches, 1103 tests green (baseline 1045),ruff check .andruff format --check .clean on the whole tree, series re-verifiedwith
git amon a fresh clone. Author and committer are Kurtisone on all twelve.Every number below was measured on the Deck against the real store, and the
before/after run was reproduced twice on two independently migrated copies.
The problem this closes
The recurring diagnosis on the vector store has been "it holds compaction pointers and
almost no facts". Reading it directly for the first time on 2026-08-22 turned that from
an inference into a count — 16 entries, 11 of them whole compacted blocks — and produced
a second number that nobody had asked for:
About 0.27 of distance lost to burying a sentence in a block, on a store where the
whole hit/miss gap that day was 0.0422. That is not a model limitation and not a
threshold problem — a cutoff adds honesty, it does not create proximity. It is a bug in
what Forge writes.
The mechanism is in
rag._embed: text pastEMBEDDING_MAX_CHARSis split into chunks,each chunk is embedded, and the chunk vectors are averaged into one. The mean of a
dozen unrelated subjects is close to no question in particular.
compaction'srag_pointerstrategy fed it the entire evicted window as a single string, so everycompaction pass produced exactly that.
What changes
One entry per exchange.
forge/transcript.pyholds a single definition of how aconversation is written down and where one exchange ends —
render,blocks,split—and the test that earns the module is the round trip
split(render(msgs)) == blocks(msgs).The unit is a user turn plus whatever answered it: a message on its own retrieves either
a question with no answer or an "oui, 8080" that has lost its subject.
rag.remember_manywrites N units as N rows in one transaction. It costs no extraembedding calls —
_embedalready made one request per chunk. The change is that thechunks stay apart instead of collapsing into their mean. Two deliberate asymmetries with
remember(): a degenerate item is skipped rather than raised on (the caller is archivinga block it did not write, and one one-word message must not fail the whole compaction),
and an
EmbeddingErrorpropagates before the commit, so a block is never half-indexed —a pointer claiming a range that does not hold what it says is worse than no pointer.
Two things stop at the store boundary, both found by reading the real store:
stored, it becomes a memory whose entire content is "N messages were compacted, see
security: audit lot 1 — XSS, unauth API, shell allowlist, git tool (+ example compose) #12" — it answers no question and sits at middling distance from all of them.
{"tool": "code", ...}object swallowed from an assistant turn by a version that did not unwrap tool output.
The envelope is the noise; the answer inside it is real.
_POINTER_REand_pointer()are two statements of the same string, so a test pins themtogether. A pointer that stops matching its own detector gets indexed as conversation and
nothing anywhere fails.
deploy/rag_resplit.pyre-slices what is already stored. Without it the fix appliesonly to future compactions and the store stays mostly made of the shape the change exists
to remove — which also means the next measurement against it measures the old problem.
Dry run is the default,
--backupis offered, and pieces are inserted before the block isdeleted: an interrupted run leaves a visible duplicate rather than a missing entry.
bench/rag_dilution.pymeasures the claim instead of asserting it. The same sentenceis planted three ways — alone, buried in a transcript stored as one entry, and the same
transcript stored per exchange — and the same question is asked of each, so only one thing
moves. It prints the mechanism it depends on (how many chunks the buried form is averaged
from) and states plainly what a null result would mean: the dilution comes from somewhere
other than the averaging, and the change must not be defended with this number. The figure
is a lower bound — a real evicted block is an order of magnitude longer than the planted one.
What this does not do
It does not create facts. The store is 69% compaction pointers because nothing writes
factentries unless a human types!remember, and that half of the intake problem isuntouched here. It needs an LLM pass under a grammar plus a grounding check, and it cannot
be evaluated while the store is still made of blocks — the granularity has to land, and be
measured, first.
Measured, not argued
All three checks were run on the Deck against the real store.
The premise, isolated on a planted sentence by
bench/rag_dilution.py— the samesentence, the same question, only the storage shape moving:
Splitting does not recover most of the gap between a block and the bare sentence — it
recovers all of it, to four decimal places.
The migration, run twice from the same backup — once before patch 0007 and once
after — on a store that had meanwhile taken a live compaction pass:
The seven-unit difference is 0007 doing its job: seven of the ten blocks opened on a
compaction pointer, so each had been producing one unit whose entire content was
"[59 messages précédents compactés — voir mémoire vectorielle #12]". After 0007 the
store holds zero bare pointers and zero raw router-JSON entries.
The consequence, six questions before and after:
The short fact at 0.671 did not move by a single digit — only the entries that had been
buried in a block did. That is the control, and it is what turns
RECALL_MAX_DISTANCEfrom a guess into a choice. Every distance recorded before this branch (the five rows of
2026-08-19 at 0.90–1.0015) measured blocks that no longer exist and is now stale.
The six figures came back identical to four decimals on both migrated copies, which
is worth stating plainly rather than claiming as a second win: removing the seven
pointer-only entries changed nothing measurable here, because those entries were never
the nearest for any of these six questions. 0007 is hygiene and a guarantee that the two
intake paths cannot diverge — it is not, on this evidence, a distance improvement.
Confirmed in real use, with the cutoff active. "Quel est le modèle exact de ma voiture ?"
drops all five candidates (1.0755–1.1620), reaches the
errornode and makes zero LLMcalls — 4 s instead of 17. "Comment s'appelle mon chat ?" is dropped too rather than
answered from
user: Bonjour Forge, comment je m'appelle ?, which was the near-duplicatecase this was expected to get wrong.
The first migration found two bugs in this branch
Patch 0007 exists because of the first. Patches 0001-0006 shared the cutting between
compaction and the migration and left the filtering behind, so seven of the ten
re-sliced entries began with a compaction pointer and each produced a unit whose entire
content is "[59 messages précédents compactés — voir mémoire vectorielle #12]". About a
dozen noise entries out of 278 — precisely the drift the shared module was written to
prevent, one level down.
forge/transcript.pynow owns the whole sequence, and both callers are the same threesteps:
units(messages) = blocks(indexable(messages))andsplit(text) = units(parse(text)),with a test asserting that equality on one input. The pointer builder and its regex moved
there too, and the pointer check no longer looks at the role — a re-parsed block can hand
a pointer back under whatever role preceded it, so the shape is the evidence, not the
speaker.
Known limit, named
A single message longer than
EMBEDDING_MAX_CHARSis still averaged: the migrationlogged
rag.embed_chunked chunks=5 chars=6082on six of the 270 units. Cutting atexchange boundaries cannot help there, and cutting inside a message would separate a
statement from its subject — the failure this branch exists to avoid, one level down.
Those six units are the shape the rest of the store no longer has.
RECALL_MAX_DISTANCE=0.95, and why not the midpointThe harness suggests 0.81, halfway-plus between 0.7695 and 0.8337. It is right about the
arithmetic and wrong for this store, because the three misses are not one family. Two are
"nothing here is relevant" (1.0619, 1.0369), which is what the setting is for. The third —
"Comment s'appelle mon chat ?" at 0.8337 — is close only because the store now literally
contains
user: Bonjour Forge, comment je m'appelle ?: lexically near, semantically notan answer. No threshold separates that cleanly, and one set at 0.81 leaves 0.04 of
headroom above the worst genuine hit.
The two errors are not symmetric. Too high lets a bad answer through, and a bad answer
gets argued with. Too low answers "je n'ai rien en mémoire" while the answer sits in the
store, and that gets believed. 0.95 cuts both real misses with ~0.18 of headroom and does
not pretend to solve a near-duplicate by picking a number.
recall.droppedlogs every cutwith its id and distance, so a value that bites in the wrong place is visible.
It is the one active line in
.env.example. The code default stays unset — a deploymentthat has not measured its own store is not handed someone else's number — and two tests
pin the value against the reasoning in
config.pyand pin that the default is still off.Patch 0008: a log line that could not answer its own question
compaction.indexedreportedmessages=35 indexed=17 entries=17, and that was read asproof the filter had fired. It was not. Seventeen units out of thirty-five messages is
what exchange grouping does on its own, two messages at a time, and the line carried no
number that could tell a dropped pointer from an ordinary pair. It now reports
messages / kept / dropped / units / entries, anddropped > 0is the filter and nothingelse.
transcript.indexablealso takes a source label, because a resplit run was printingcompaction: model wrapped a substantive answer…while no compaction was happening.Patches 0009-0012: found by using it
Everything below was discovered by running the migrated store, not by review.
0009 — the same exchange must not occupy three of five recall slots. A live recall
returned five entries and three were the same text at 0.8306 (
#205,#227,#233).Compaction windows overlap, so an exchange sitting in the tail of one and the head of the
next was indexed twice, and re-slicing made it three rows. A sweep of the real store found
72 duplicates out of 293 entries — a quarter of it.
remember_manynow skips exactduplicates, within the batch and against the store, scoped to the project. Exact match
only: a near-duplicate is a judgement with a threshold to tune, an identical string is a
fact. Not applied to
remember()— a human asserting the same fact twice is saying theythink it was forgotten.
0010 — say what was stored.
Remembered (#305).is a receipt for a transaction nobodycan check; the entry that provoked this went in as
pocresseur 5500Uand the typosurfaced days later through a debugging tool. The confirmation now echoes the text, its
kind, and how many entries of that kind exist — the same lesson
files:writelearned inv3.11, when a created file answered with a byte count and had to be opened by hand.
It also flags a word that appears nowhere else in the store but sits within 0.85 of one
that does. The dictionary is the store itself, and that is the design: a French
spellchecker on this corpus is a machine for breaking identifiers —
NiPoGi,sqlite-vec,busctl,aardvark-dns,GBNFare precisely the tokens carrying theinformation. Vocabulary drawn from what has already been written knows them because they
were used, and sharpens with every entry instead of needing an allow-list. It only ever
suggests: the entry is stored unmodified whatever the check thinks, because silently
rewriting a memory entry is the one place in Forge where being approximately right is
worse than being wrong — nobody re-reads an entry, so it returns weeks later as a fact
with no trace of the edit.
0011 — two bugs in that check, both found on its first real run.
J'utilise aardvark-dns pour la résolution DNSwas flagged onutiliseandrésolution, two wordswritten a dozen times in that store: the check subtracted the new text from the vocabulary
before searching, which deleted the exact hit proving each word was fine. Fixing that
exposed the second —
rag.remembercommits the row before the check runs, so the entrywas its own dictionary. Both directions are pinned, since either bug alone makes the
feature silently do nothing or silently cry wolf.
0012 —
!forgetwhere the message that recommends it is displayed. The confirmationends with "
!forget 312puis réécris-la", and that command existed only in the REPL, sothe web UI answered "commande inconnue".
DELETE /memory/{id}was already there; only thebinding was missing. It is also the first UI command taking an argument, so the dispatcher
now passes one.
Two findings this branch does not fix
A stored refusal outranks the answer. Asked "Tu peux me lister mon matériel ?", the
nearest entry at 0.4519 is
user: Tu peux lister mon matériel ?\nassistant: Je ne peux pas lister ton matériel…— an exchange in which Forge failed. The real answer,#138, issecond at 0.7891, and the model echoed the refusal. Indexing exchanges puts the question
text inside the entry, so an entry that is mostly the question and nothing else is the
closest possible neighbour of that question. These refusals were always in the store; the
granularity made them competitive.
The embedding matches on text overlap, not meaning. A fact reading
Matériel : NiPoGi AM06PRO, processeur Ryzen 5500U, 32 Go de RAMranks 109th at 1.0496 for "Tu peux melister mon matériel ?" — with the word
matérielin it — while#138, which contains thewhole question verbatim, sits at 0.7891. The same fact is rank 1 for "Quel processeur a
mon NiPoGi ?" and rank 2 for "Combien de RAM a le NiPoGi ?". Recall works when the question
is phrased in the words of the fact and fails otherwise. That is a property of the
embedding model on short French, not an intake defect, and it kills the rule this branch
was about to adopt for automatic extraction ("a fact must name its category" — measured,
falsified, dropped).
Both belong to the next branch, along with automatic fact extraction, which cannot be
evaluated until they are settled.