Skip to content

feat/rag-intake — stop storing a compacted block as one averaged vector - #28

Merged
Kurtisone merged 12 commits into
mainfrom
feat/rag-intake
Aug 23, 2026
Merged

feat/rag-intake — stop storing a compacted block as one averaged vector#28
Kurtisone merged 12 commits into
mainfrom
feat/rag-intake

Conversation

@Kurtisone

Copy link
Copy Markdown
Owner

Base aa39191 (main, after PR #27). Twelve patches, 1103 tests green (baseline 1045),
ruff check . and ruff format --check . clean on the whole tree, series re-verified
with git am on 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:

"Quels outils as-tu accès ?"                 0.9386   vs a history_summary that
                                                      contains the question almost
                                                      word for word
"Le serveur de test tourne sur quel port ?"  0.671    vs a short fact

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 past EMBEDDING_MAX_CHARS is 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's
rag_pointer strategy fed it the entire evicted window as a single string, so every
compaction pass produced exactly that.

What changes

One entry per exchange. forge/transcript.py holds a single definition of how a
conversation 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_many writes N units as 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 rather than raised on (the caller is archiving
a block it did not write, and one one-word message must not fail the whole compaction),
and an EmbeddingError propagates 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:

_POINTER_RE and _pointer() are two statements of the same string, so a test pins them
together. A pointer that stops matching its own detector gets indexed as conversation and
nothing anywhere fails.

deploy/rag_resplit.py re-slices what is already stored. Without it the fix applies
only 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, --backup is offered, and pieces are inserted before the block is
deleted: an interrupted run leaves a visible duplicate rather than a missing entry.

bench/rag_dilution.py measures the claim instead of asserting it. The same sentence
is 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
fact entries unless a human types !remember
, and that half of the intake problem is
untouched 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 same
sentence, the same question, only the storage shape moving:

alone      0.5109   rank 1/1
buried     0.9559   rank 1/1     (23 exchanges, 3987 chars, 3 chunks averaged)
split      0.5109   rank 1/23

cost of burial: +0.4450

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:

first run    16 entries -> 283      10 blocks -> 277 units
second run   33 entries -> 293      10 blocks -> 270 units

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:

before   hits 0.8934  0.671  0.9386  |  misses 0.9891  0.9356  1.098
         worst hit 0.9386 > best miss 0.9356   ->   NO GAP
after    hits 0.4498  0.671  0.7695  |  misses 1.0619  0.8337  1.0369
         worst hit 0.7695 < best miss 0.8337   ->   gap 0.0642

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_DISTANCE
from 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 error node and makes zero LLM
calls
— 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-duplicate
case 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.py now owns the whole sequence, and both callers are the same three
steps: units(messages) = blocks(indexable(messages)) and split(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_CHARS is still averaged: the migration
logged rag.embed_chunked chunks=5 chars=6082 on six of the 270 units. Cutting at
exchange 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 midpoint

The 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 not
an 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.dropped logs every cut
with 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 deployment
that has not measured its own store is not handed someone else's number — and two tests
pin the value against the reasoning in config.py and pin that the default is still off.

Patch 0008: a log line that could not answer its own question

compaction.indexed reported messages=35 indexed=17 entries=17, and that was read 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 tell a dropped pointer from an ordinary pair. It now reports
messages / kept / dropped / units / entries, and dropped > 0 is the filter and nothing
else. transcript.indexable also takes a source label, because a resplit run was printing
compaction: 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_many now skips exact
duplicates, 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 they
think it was forgotten.

0010 — say what was stored. Remembered (#305). is a receipt for a transaction nobody
can check; the entry that provoked this went in as pocresseur 5500U and the typo
surfaced 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:write learned in
v3.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, GBNF are precisely the tokens carrying the
information. 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 DNS was flagged on utilise and résolution, two words
written 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.remember commits the row before the check runs, so the entry
was its own dictionary. Both directions are pinned, since either bug alone makes the
feature silently do nothing or silently cry wolf.

0012 — !forget where the message that recommends it is displayed. The confirmation
ends with "!forget 312 puis réécris-la", and that command existed only in the REPL, so
the web UI answered "commande inconnue". DELETE /memory/{id} was already there; only the
binding 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, is
second 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 RAM ranks 109th at 1.0496 for "Tu peux me
lister mon matériel ?" — with the word matériel in it — while #138, which contains the
whole 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.

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.
@Kurtisone
Kurtisone merged commit 3d3ee82 into main Aug 23, 2026
2 checks passed
@Kurtisone
Kurtisone deleted the feat/rag-intake branch August 23, 2026 08:38
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.

1 participant