Repository navigation
Releases
v4.24.0 — Lighter edit context, architecture map, review prompts
Ideas from two neighbouring tools, rebuilt on Token Savior's own index:
Ponytail (write the smallest
complete change) and graphify
(rationale comments and graph communities as first-class context).
get_edit_contextis 29 % lighter. Measured on 40 random symbols of
this repository: 209 644 -> 148 190 characters. The sibling list sent every
function of the file (up to half of the response: 7 161 of 14 272 characters
for a method ofquery_api.py) and its line numbers were allnull, because
the field islines, notline. It now keeps the 12 nearest siblings, with
their real line, andsiblings_totalwhen it cut.whyinget_edit_context. The comment block glued above a definition
(Python#, JSDoc/** */,//) is returned aswhy: the intent the
source alone does not state, such as a counter-intuitive rule nobody should
"fix". Section banners are ignored; capped at 8 lines.- Architecture map in
get_project_summary. Two lines, from the graph the
index already holds and with no model call: the most depended-on symbols
(hubs) and the largest graph communities, named by their hub and by their
production directory. About 400 more characters, once per project. - Three MCP prompts:
review(the change, including breakage outside the
diff throughget_change_impactanddetect_breaking_changes),audit(the
whole project, ranked must / should / nice to have) andlean(a task as the
smallest complete change). Claude Code lists them as slash commands. Free
until called;TS_PROMPTS=0removes them. - Opt-in session instructions (
TS_DISCIPLINE=1, 977 characters): the
smallest complete change scoped byget_edit_context, a reuse ladder that
checks the codebase first (search_codebasewithsemantic=true), deletion
before addition, never cutting validation or security, one closing line on
what was skipped. Off by default because the benchmark below found a cost
and no gain. - Benchmark, old vs new server (tsbench, Sonnet,
autoprofile, real
repository): 28/28 sessions succeeded in both arms. With the instructions on,
cost per session was +3 % on editing (4 comparable sessions) and +4 % on
reading (7), the price of ~100 more cached tokens per session; code written
in editing fell 9 %, within noise. The first session of each arm was left
out: the arm that ran first paid the prompt-cache warm-up alone (11 851
cache-write tokens against ~2 200), which had produced a fake -18 %. These
tasks take 2 to 4 turns and barely callget_edit_context, so they cannot
show its 29 % reduction, measured directly instead. - Both SDK generations: tested with MCP 1.27 and 2.3 through a real stdio
handshake. Options a SDK does not know are not passed.
v4.23.2 — The launch directory is the active project
- Fix: a session could edit another project. Started in a project that was
not the firstWORKSPACE_ROOTSentry, the server registered the launch
directory but kept the first root as the active project. Calls by symbol name
or relative path then answered from that other tree, and
replace_symbol_sourcewrote into it while reporting success. Found by the
new editing benchmark, whose session in a copy of a repository rewrote the
original. The launch directory now becomes the active project unless
CLAUDE_PROJECT_ROOT/CLAUDE_PROJECT_DIRsays otherwise; a marker-less
folder under the home directory still restores its last active project first. - Editing benchmark on a real repository (3 tasks in files of 1 900 to
3 700 lines, graded by running code and the module's tests, 4 passes).
Every session of both arms succeeded. Cost is too noisy to call a gain
(passes from -54 % to +16 %). NativeEditonly needs a partialRead, so
Token Savior has little to remove there. edit_lines_in_symbolwas tried in theautoprofile and taken back out: on
a rename it led the model to one call per function (23 turns, $0.21) instead
of oneEditwithreplace_all(6 turns, $0.04). Its description is
clearer and it now has its own tests; it stays callable viats_execute.
v4.23.1 — Smaller session start, reachable tools
- SessionStart memory index trimmed by 57 % (2 990 -> 1 295 characters on
the author's base, 30 -> 12 observations). Only guardrails, warnings and
decisions; observations younger than 14 days first; automatic traces
(chmod,restart…) excluded; Continuity and Tool Capture on one short line.
Tunable:TS_SESSION_INDEX_MAX,TS_SESSION_INDEX_TYPES,
TS_SESSION_INDEX_RECENT_DAYS,TS_SESSION_INDEX_EXCLUDE,TS_SESSION_LINE_MAX. move_symbolandcheckpointcallable fromts_execute. Theauto
profile no longer lists them, and the sandbox allowlist did not have them
either, so they had become unreachable. A test now checks that every tool
dropped from the profile stays reachable throughts_execute.- Benchmark variance. Three passes of the v4.23.0 measurement: cost
-45 % ± 7 (range -39 % to -53 %), new tokens -59 % ± 6, wall time +40 %,
Token Savior used in 23 sessions of 24. On 3 editing tasks (one pass) both
arms succeed 3/3 with no measurable gain yet. - Ops note: the index cache key includes
EXCLUDE_EXTRA. Two processes sharing
a project with different values (a daemon and a Claude Code session, say)
invalidate each other's cache and rebuild the whole index on every switch.
Give them the same value.
v4.23.0 — Beat Grep where the model reaches for it
Same benchmark as v4.22.0 (tsbench --reel, Haiku, 8 tasks, same evening),
three arms:
| cost | new tokens | sessions using TS | wall time | |
|---|---|---|---|---|
| no Token Savior | 0.234 $ | 99 993 | — | 134 s |
v4.22.0 auto |
0.202 $ (-14 %) | 75 470 | 5/8 | 195 s |
v4.23.0 auto |
0.129 $ (-45 %) | 43 660 (-56 %) | 8/8 | 210 s |
One pass per task, variance not measured. Wall time is still higher than the
baseline: each benchmark session starts a fresh server that indexes the repo.
search_codebasenames the symbol containing each hit (in). "Who
calls X" is answered in one call; Grep cannot do that, and that is where
the model kept choosing Grep.read_linesnames the symbol holding the first line when none covers
the whole range. "Which function is line 145 in" took three reads: the
default 60-line window spilled into the next function and no name came out.- Descriptions of
search_codebase,find_symbol,get_function_source
andread_linesrewritten around what they do better than Grep and Read
(+37 tokens on the manifest). ts gainand the dashboard stop inflating savings. The native cost of
a call was a fraction of the whole project (15 % for a search, 30 % for
get_full_context), hence "98.7 % saved". It is now the real alternative:
the file holding the symbol, the whole file for structure tools, and as
much as the answer for a search or a line range. Sessions carry their
method (naive_method=2); only that method is summed, and the output
recalls the end-to-end measurement.- Memory hooks: no more
chmod/restarttraces, nothing injected off
topic. Over 7 days, 83 % of memories injected with each prompt were
automatic traces ("chmod on …", "restart …"), 13.5 % shared a meaningful
word with the prompt, and none was cited or recalled by the model.
memory-posttooluse.shno longer capturessystemctl start|restart|…or
chmod|chown;memory-userprompt.shinjects only non-trace memories that
share a 5+ letter word with the prompt, and nothing otherwise. - Discipline guard matcher now sends
get_function_source,
get_class_sourceandts_executeto the guard, so reading a symbol counts
as context as v4.22.0 intended; a test keeps matcher and rule aligned.
v4.22.0 — What real usage said
Every change below answers a failure counted in real transcripts (765 sessions
over 30 days, then a second pass), not a hypothesis. Measured on this
project's own A/B benchmark (tsbench --reel, Haiku, same evening): with the
auto profile, 8/8 tasks solved like the baseline, cost -14 %, new tokens
-25 %, wall time +45 %. One pass per task, variance not measured.
Second pass on real usage (07/10/2026)
ts_executeresults are capped at 60 000 characters
(TS_EXECUTE_MAX_CHARS). A script that read four whole files returned
57 545, which Claude Code refused and parked in a file: the script ran for
nothing. The returned value is cut, logs and counters kept, and the cut is
reported.- README:
"alwaysLoad": truein the Claude Code snippet. Without it every
tool sits behind ToolSearch and the model reaches forReadandGrep. get_function_source/get_class_sourcereturn the source again.
Without an explicitlevel, a Thompson-sampling bandit picked the detail
level and scored every non-empty answer a success, a[L2]summary
included; a re-request with an explicitlevelrecorded nothing, so no
failure was ever counted. It had drifted to 5 284 successes for[L2]
against 34 for the full source: over 30 days, 61 of 95 calls without
levelreturned a summary or a bare signature instead of the code asked
for. Full source is now the default; the bandit only runs with
TS_LEVEL_BANDIT=1.- The home directory is a container, never a project. A home that is
itself a git repository (a VPS under version control) made every
marker-less folder below it —/root/claude-chat,/root/gmgn-api… —
resolve to the whole home: a 10 000-file index, capped, answering
"not found". Such a path now belongs to its first-level subdirectory, and
a session launched from home no longer registers home as its project. - The active project survives a server restart. Each Telegram message
starts a freshclaude -p, hence a fresh MCP server whose active project
fell back to the first configured root. The last project made active is
now remembered per launch directory and restored when nothing more
reliable (environment hint, launch directory that is a project) chose. - The over-exploration hint stops repeating. Past 15 navigation calls in
a server's life (sub-agents included), ~340 identical characters were
appended to every answer. It now shows at the threshold, then every 25
calls. - Leaner
automanifest: 18 → 13 tools, ~4 160 → ~3 220 tokens.
move_symbol(0 calls in 30 days),ts_search(1) andget_git_status
(11) leave the always-on set;set_project_rootandget_functionsare
never promoted (switch_projectandget_structure_summaryalready do
their job). All stay callable by name and fromts_execute. Hot slots
10 → 8 (TS_AUTO_HOT_K). test_vector_distance_floorskips when the vector stack is half
installed (fastembed without sqlite-vec madevec_search_rowsreturn
[]silently, read as a recall defect).
What 30 days of real calls said (06/10/2026)
Audit of 765 transcripts (3 569 Token Savior calls) and of the discipline
guard's own log. Every change below answers a measured failure, counted in
the commit that fixes it.
.mjsand.cjsare indexed. Everyget_functionsmiss and most
read_linesmisses were on ES modules (launchpad/pump.mjs,
runtime/cycle-reconciler.mjs): the include patterns stopped at.js/.jsx.
Imports between them resolve, editing works. Index cache version bumped to 4.- Constants have a context.
find_symbolwith default kinds now falls
back to variables on a miss instead of answeringretry_with; the
TypeScript/JavaScript annotator records top-levelconst/let/var
bindings (it recorded none);get_full_contextandget_edit_context
return the binding's source (newget_variable_source, brackets balanced)
instead of "function 'X' not found". Half of theget_edit_context
failures wereexport consttables. read_linesreads files outside the index — absolute, or relative to
the active root; regular text files under 5 MB, no binaries — and says so.
17 of its 19 failures were files that existed on disk.search_codebase(max_results=0)is cut at 60 000 characters with a
_tronquenote. Five such calls returned 107-192 k characters, which
Claude Code refuses.ts_executeacceptscode(andbody,js) forscript. 8 of its 30
failures. The two homonymous_ARG_ALIASEStables inserver.pyhad
diverged (schema read one, dispatch the other); merged into one.- "Found in project" no longer sticks to successful reads. The footer
fired whenever the returned text contained "not found in index"; reading
the handler that emits it was enough, and the footer ended up pasted into
the source by areplace_symbol_source. - An empty
.gitdirectory is not a project, and the temp directory never
is. A stray/tmp/.git(empty, 27/09) made/tmpa repository, so every
scratch clone resolved to/tmp, which a nightly job indexed whole
(25 MB cache). - MCP SDK 2.x and protocol 2026-07-28. Claude Code negotiates the
2026-07-28 protocol on stdio by default since 2.1.292; a server that does
not answer it pays a slow connect, then seven days remembered in the old
mode. The 2.x SDK serves both eras in one loop but dropped the
list_tools()/call_tool()decorators, so the server crashed at start
under it.main()now builds the server with the 2.x callbacks when that
SDK is installed and keeps the 1.x path otherwise; the pin ismcp>=1.25,<3.
Checked over stdio: 2.x server answers 2026-07-28 to a modern client and
2025-11-25 to a 1.x one. - Discipline guard, recalibrated on its own log (30 days):
- native
Edit/Writeon indexed code is now advice, logged as
conseil, never denied: 303 of 305 refusals were re-issued identically
within five minutes; - native
Readis refused only whole-file and over 200 lines
(TS_GUARD_READ_FREE_LINES): 78 % of refused Reads were re-issued, and
Claude Code'sEditrequires a priorRead; insert_near_symbolno longer needs the anchor's context (26 of 29
refusals were on an anchor that is not modified);get_function_source,get_class_sourceandts_executescripts
calling the context tools count as context; a symbol edited once can be
edited again;- a marker at
/or in the temp directory no longer makes a project.
- native
Reading by line number
An audit of 1 047 real code reads done in Bash rather than through this server
found that 93,2 % never tried a tool at all (only 1,4 % were repairs after a
failure), and that among the reads the index could have served, the dominant
shape was sed -n '105,150p' path.py. The caller held a line number — from a
traceback, a grep -n hit, a compiler error — and every read tool on the
surface asked for a symbol name.
read_lines(file_path, start, end). 1-indexed, inclusive, numbered by
default (numbers=falseto get raw text back).endis optional: with only
astart, it returns a 60-line window, which is the shape a traceback
gives you. Capped at 400 lines by default (max_lines) so a hugeend
can't turn back intocat, with a marker naming thestartto resume from.
Names the enclosing function or class after the excerpt, so a range that
turns out to be the wrong unit routes toget_function_sourceinstead of a
second blind guess. The engine already hadget_lines— tested, reachable
from the Python API, exposed by no tool. Also whitelisted forts_execute
scripts, where it was equally missing.TS_STICKY_ACTIVEnow holds for unregistered directories. The freeze
lived inserver_state.noter_racine_active, at the dispatch layer, but
SlotManager.resolveassignedactive_rootitself on one branch: the real
path nobody had registered. That is exactly the /tmp clone, the export
folder, the scratch file — 23,4 % of those same Bash reads. So the one
guarantee meant to stop a parallel agent from repointing the shared default
failed precisely where the need is most common, and the next hint-less call
silently answered from the wrong repository. Default behaviour is unchanged
(promotion still happens with the flag off).
One server, many worktrees, no stolen calls
Parallel agents in sibling worktrees shared one server and one mutable
active_root, and the worktree a session actually worked in was often never
even registered. Verified against the hosts, not their folklore: Claude Code
exports CLAUDE_PROJECT_DIR (stable — always the main checkout, never the
worktree); CLAUDE_PROJECT_ROOT is set by no host at all; Codex exports no
path variable and whitelist-filters the MCP server environment, so only the
spawn cwd carries its signal.
- Per-call routing by absolute path. A call with no
projecthint but an
absolutefile_path/path/target_filenow routes to the tree that owns
the path — nearest marker root, so a worktree nested in a registered repo
(repo/.claude/worktrees/wt) wins over the parent checkout that contains
it. A linked worktree's.gitfile counts as a marker. The owning root
registers on the fly, and the sharedactive_rootis never touched. - Path hints resolve to their owner.
project=hints that are paths go
through the same nearest-root routing; a subdirectory of a registered
project no longer gets registered as a project of its own. Removed a
literally duplicated reverse-containment/register block inresolve(). - Boot signals, validated and ranked.
CLAUDE_PROJECT_ROOT(deliberate,
ours: may register, always wins) → launch-directory root (the only signal
that follows worktrees and the only one Codex has; when it is a linked
worktree it takes active) →CLAUDE_PROJECT_DIR(automatic: promotes amon...
v4.21.0 — The server stops keeping what it knows to itself
v4.21.0 — The server stops keeping what it knows to itself (2026-07-28)
Three things the server knew and never told anyone.
It knew which tools only read, and said the opposite. MCP defaults
readOnlyHint to false and destructiveHint to true, so every one of the 69
tools reached the client looking potentially destructive — get_function_source
included. Any consumer wanting a safe subset had to hard-code its own list and
keep it in sync by hand; that list drifts silently the day a tool is added. The
classification now lives in tool_annotations.py, list_tools() ships the four
hints, and read_only_tool_names() is exported so a judge loop derives the safe
set from the server instead of copying it.
A classification is worth what its guard is worth.
test_no_unclassified_writer_slips_through greps for the absence of
classification: any tool whose name carries a write verb and is missing from
MUTATING_TOOLS fails CI, with an explicit derogation list for the three
readers whose names look like writers. Verified by mutation — dropping
replace_symbol_source from the set breaks three tests.
It knew it had cut a result, and stayed quiet. 41 tools accept a bound and
honour it. None said it truncated, so a response holding exactly max_results
items was indistinguishable from a complete one. A caller that counts those
items believes it measured a total and actually measured the bound — two counts
that both saturate the limit read as two equal values when they are two
truncations. The notice is added at the single point where a raw handler result
is wrapped.
It also separates two things the schemas conflate: bounds that govern a number
of elements, comparable to a list length, and bounds that govern a size in
bytes or lines, comparable to nothing. max_symbols_per_file sits with the size
bounds for a third reason — it caps symbols within each file, so comparing it
to the overall total would be wrong in both directions. Multiple bounds in one
call are all considered: get_change_impact carries max_direct and
max_transitive, and keeping only the first would miss the truncation governed
by the other. The coverage test found fourteen bounds a hand inventory had
missed.
This is deliberately not pagination. The handler has already cut by the time we
reach that seam, so total_count and next_offset are not knowable there. It
closes the "I don't know that I don't have everything" class and leaves the
per-handler work for later.
It paid for its own bookkeeping on every call. record_tool_call re-reads
and re-writes the counter file under an inter-process flock; paid synchronously
inside the dispatch path, that cost sat on every single tool call. The obvious
fix — one daemon thread per call — is wrong: a daemon thread is killed abruptly
at interpreter exit and can die mid read-modify-write, leaving truncated JSON.
A single worker now drains the queue and aggregates, so everything enqueued
while the previous write was in flight lands in the same locked write and the
cost stops scaling with the number of calls. atexit flushes what is still
queued — without it, short sessions would be systematically under-counted, which
is a bias, not just a loss. record_tool_call stays synchronous and durable for
its direct callers. Measured on an uncontended path: 0.489 ms per call down to
0.005 ms.
v4.20.0 — What the tools promise, now measured
Fourteen defects, found by exercising all 69 tools on a real machine rather
than by running the suite again — the suite was green at 2369 tests while
every one of them was live. They shipped as four hurried patch releases the
same night; this single release replaces them, and 4.19.1 through 4.19.3 are
yanked.
Two of them made the product unusable for its own stated purpose.
- A
search_codebase(semantic=True)held a write lock on the shared memory
database for more than 25 minutes: the reindex runs inside the request,
embedding is sequential by design, and the commit only came at the end of
the loop. While it ran, nothing else could write, the WAL grew from 8 to
15 MB, and a second client could not start at all —run_migrations()
failed ondatabase is locked. The multi-client story shipped in v4.11
collapsed the moment a semantic search was running. A longer busy timeout
fixed nothing, verified at 30 s. Committing per batch does. detect_breaking_changes(ref=...)silently discarded the ref. The
schema exposesref, the CLI sendsref, the docs sayref="v1"— the
handler only readsince_ref, which nobody sends.HEAD~1,HEAD~3,
HEAD~6and a release tag all analysedHEAD~1. The project's own rule is
"run it before a commit or PR": every check against a tag was comparing the
last commit, and answering reassuringly.
Two silent data-integrity defects, both answering ok: true.
- A project copied after indexing had its edits written into the original.
cp -rcarries.token-savior-cache.json, which holds the original's
absolute paths; loaded from the copy it matched on the git ref, so
replace_symbol_source(project=<copy>)modified the original file and
left the copy untouched. A cache whose recorded root does not match where it
was loaded from is now ignored. replace_symbol_sourcedeleted decorators. A symbol's indexed range
starts at the first line of its block, decorators included, while
get_function_sourcenever shows them — so a caller who reads then replaces
cannot restore them. The same defect ate a@pytest.fixtureand a
@pytest.mark.parametrizewhile this release was being written, breaking 73
tests at once.
Four tools cost more than they saved. The claim this project rests on was
verified nowhere, so it was measured across 43 of the 69 tools:
get_function_sourceon a three-line function returned 131 characters where
reading the whole file cost 76 — the gap was exactly the
→ get_full_context(...)hint, appended to every response regardless of
size. Now proportionate: 54 characters.get_edit_contextshipped the source twice, in full undersourceand
again aslocation.source_preview. 459 characters against 216 for the chain
it replaces; now 375.- The cache acknowledgement was longer than the source it replaced on small
symbols — 317 against 232 — and the code knew it, computing a saving of zero
through amax(0, ...)without drawing the consequence. capture_listreturned 29 836 characters by default, twenty-four times
the entire source of the audited project. Rows are bounded and the default
limit is 20: 6 860 characters, down 77%.
And six smaller ones: ts_execute discarded every tool result already
served when a script timed out; capture_put with no arguments wrote an empty
capture attributed to "unknown"; capture_search without a query answered
{"count": 0}; a missing required argument returned a raw KeyError; an
abstract picked by the sampling bandit was cached as if it were the body; and
subprocess transports outlived their event loop, killing the process with
SIGSEGV at exit — 8 times out of 8 as soon as a second project was registered.
The discipline guard no longer blocks legitimate work. ls dist/index-*.js
splits into dist/index- and .js; that bare fragment resolved against the
current directory, walked up to the project root, and got the whole command
refused. A path must now have a stem and name a file that exists. The guard
still refuses native reads of indexed code — it is the noise that went, not
the rule. It remains opt-in via TS_DISCIPLINE_GUARD=1, so no install is
affected on upgrade.
Verification is now part of the suite. tests/maison/ holds 320 tests
that exercise all 69 tools the way an agent calls them, on a project built to
contain what the tools claim to find. A coverage test fails if any tool in
TOOL_SCHEMAS is never exercised, and an economy test fails if any tool
starts costing more than reading the file it answers about.
2728 tests, ruff clean, zero warnings.
v4.19.0 — A memory that always answers is not a memory
v4.19.0 — A memory that always answers is not a memory (2026-07-26)
Vector search had no distance floor. A k-NN always returns k results, however
far they are. Any query — including one with no relation to anything stored —
came back with the least-distant observations, presented as relevant memories
and injected into the model as if it had lived them.
Distances measured on French observations, as returned by sqlite-vec:
relevant neighbours 0.85 – 0.99
unrelated neighbours 0.97 – 1.07
The bands overlap almost entirely. Worse: redemarrer le serveur web ranked
Certificat SSL expire (0.928) ahead of Redemarrer nginx (0.989), and a
nonsense query scored better (0.973) than the correct answer to a legitimate
one. The vector layer only contributes when it is clearly confident; elsewhere
lexical search is the one that knows. The floor is a measured value, and a test
guards it against being relaxed without re-measuring.
Three more defects around the memory store, all found by trying to use it:
memory_deletereturnedTrueandmemory_getstill returned the
observation. Deletion archives, andobservation_getdid not filter archived
rows whileobservation_searchdid. A "deleted" memory could still reach the
model. Now excluded by default,include_archived=Trueto audit or undelete.capture_get(range="10-12")returned the entire capture instead of three
lines. Onlyline:10-12was recognised, and any unrecognised form silently
meant "everything" — in a tool whose purpose is to save tokens. Natural ranges
are accepted; an unknown one is refused with the accepted forms.- The memory database ignored
TOKEN_SAVIOR_DATA_DIRandXDG_DATA_HOME, the
convention every other data path follows. Moving your data left captures and
state in the new place and memory in the old one, silently.
And the test suite was writing into the user's real database. 284 test
observations were found in it. conftest.py now redirects the memory DB for the
whole suite: a per-file guarantee does not hold, since one future test forgetting
to patch is enough.
v4.18.1 — Two clients on one repository, now covered by tests
v4.18.1 — Two clients on one repository, now covered by tests (2026-07-26)
A Claude Code session and a Codex session open on the same repository is not an
exotic setup, and it was never tested. Each client keeps its own in-memory
index: the moment one writes, the other's line ranges are stale, and its next
edit targets lines that have moved. That is precisely the corruption seen
during development.
The pre-edit reindex shipped in v4.15.2 already handles it. This release adds
the tests that prove it and stop it regressing: crossed edits from two
independent clients leave one definition, the neighbours above and below
untouched, and a file that still compiles.
No behaviour change — a verification that was missing.
v4.18.0 — Recall found 4 queries out of 6
v4.18.0 — Recall found 4 queries out of 6 (2026-07-26)
"Recall" is in the product name and its quality had never been measured, only
its absence of errors. First measurement: 4 out of 6 queries returned the
right observation.
SQLite FTS5 applies an implicit AND across the words of a bare query. A
natural-language sentence — exactly how an agent phrases things — therefore
required every word to appear in the observation, function words included.
supprimer des donnees en prod returned nothing against an observation
containing supprimer, donnees and prod, because it did not contain des.
Three passes now, narrowest first, each attempted only if the previous returned
nothing — so a query that worked before returns byte-identical results:
- the query as written (unchanged behaviour);
- an
ORover the meaningful terms, function words dropped; - prefix matching for word families — FTS5 does not stem French, so
nommer
never joinedNommage, norbrancheits plural. Reserved to terms of four
letters or more; below that a prefix returns half the database.
Malformed queries (stray punctuation, misplaced operators) return a list rather
than raising OperationalError at the caller.
Measured on the same set after the change: 8 out of 8, and a genuinely
absent query still returns nothing. Widening a search must not turn it into a
noise generator, so tests/test_memory_recall_quality.py pins both directions.