unsloth/studio/backend/storage
Daniel Han 2affd53e1f
Studio: recall the latest version of a fact, not the most quotable one (#9161)
* Studio: add rolling context windows for local GGUF chat

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: return rolling context metadata for non-stream chats

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: keep original context when rolling fit fails

* Studio: keep instruction groups independently protected

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: preserve rolling context metadata across retries

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: count sanitized rolling context prompts

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: refit rolling context after respawn

* Studio: scope middle truncation to passthrough

* Studio: refit tool prompts after respawn

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: retain later choice truncation metadata

* Studio: report clipping-only context truncation

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: expose the turns a rolling fit evicts

Rolling context eviction currently drops turns with no way for a caller to
learn which ones went. Extract the turn grouping so the eviction unit is
reusable, add an identity-based diff of what a fit removed, and let a caller
reserve room for content it intends to add back after fitting.

The reserve deliberately does not affect whether trimming happens at all, only
how far it goes once it is already required, so a conversation that fits today
is still returned untouched.

* Studio: archive evicted turns into a per-thread rag scope

An evicted turn is currently gone for the rest of the session, so the model
will state the conversation began wherever its visible context begins. Keep the
turns the rolling window drops in a searchable scope built on the existing
store, chunker, embedder and hybrid retrieval.

The archive is cumulative: every compaction adds to it and nothing is cleared,
so a later compaction can still find what an earlier one evicted. It lives in
its own scope rather than the thread's document scope, because thread documents
are injected in full on every request and would re-inject the whole history.

Idempotent by content hash, since the same turns are evicted again on every
later request. Every entry point degrades to a no-op rather than raising.

* Studio: recall archived turns on the turn that evicted them

Given only a search tool, a model decides for itself whether to look, and
mostly does not. Measured on MRCR v2, a 35B declined on 56% of rows, scoring
0.099 when it skipped against 0.461 when it searched. Forcing one retrieval on
the compaction turn took tool-only 0.258 to 0.604, and the model then called
the tool on 0% of rows, so the common path costs nothing extra.

Recall fires at most once per request, since the tool loop refits on every
iteration. The tool loop renders it as an ordinary tool exchange through the
builder shared with document auto-inject; the plain path prefixes the latest
user message instead, because it sends no tools array and a tool role without
one breaks strict chat templates.

Only scalar counts join the context_truncated event, so no message content
reaches the wire.

* Studio: let the model search a compacted conversation

Forced recall answers the turn that evicted, but a later turn can refer to
something the forced pass had no reason to fetch. Add search_conversation so
the model can go looking, scoped to the thread's own archive and sharing the
admission slot with document search so the two cannot race for the embedder.

The tool is offered only once a thread has actually had turns archived, so an
ordinary short chat never pays for the schema, and it is classified read-only
so auto mode does not prompt on every call. A matching system-prompt note tells
the model the session was compacted, since otherwise it assumes the
conversation began where its visible context begins.

Deleting a thread now drops its archive rather than leaking a scope per chat.

* Studio: read thread_id defensively when selecting tools

_select_request_tools also serves the token-count request model, which has no
thread_id field, so the archive gate raised there.

* Studio: retrieve archived turns lexically first

Recalling your own conversation is mostly an exact-match problem: a name, a
number, an identifier someone pasted twenty turns ago. Those live or die on
rare-token matching, and hybrid fusion was losing them.

Measured on a 30-turn walkthrough of a 230k-character document at a 16k window,
where every turn shared the same wrapper text. The chunk holding the needle
ranked 3rd lexically at any k, was never returned by dense retrieval at all,
and RRF pushed it to 16th because it had 30 useless dense hits to fuse with.
End to end the model answered with the exact code once lexical leads, and could
not answer at all before.

Dense still fills whatever the lexical pass leaves, for paraphrased recall.

* Studio: keep recall on the branch the user is actually on

Editing an earlier message rewinds a thread and continues down a new branch,
but the archive is append-only and still holds everything the abandoned
continuation produced. Verified against a live build: after rewinding past a
turn, querying its distinctive text still returned it, so the model could be
handed a turn that on this branch never happened.

Recall now drops archived turns that are absent from the thread's saved
transcript. Threads with no saved transcript are left unfiltered, since an API
caller may pass a thread_id without persisting messages and an empty transcript
is absence of evidence rather than evidence the turns are gone.

Containment on a normalised prefix rather than a digest, because the archived
copy is rendered from the inference projection and the saved copy comes back
through the message store.

* Studio: show a persistent compaction notice on the turn that compacted

The only signal that a long chat had been compacted was a toast, which vanishes after
a few seconds and does not survive a reload. A user who scrolls back later has no way
to find out why the model seemed to forget the start of the conversation.

The notice renders from metadata.custom.contextTruncation inside the assistant
message's own container, so it is not part of the conversation sent to the model, is
not editable, and is not exported as content, but it stays attached to the turn it
describes. It reports how many messages were dropped and, when the conversation
archive is on, that they are still searchable and how many passages were recalled.

* Studio: show the compaction notice once, on the turn it started

A thread that has outgrown its window compacts on every turn from then on, not
just the first, so a notice per compacted turn was a notice on every reply for the
rest of the conversation. The user needs telling once.

The notice is now gated on being the first compacted assistant turn in the thread,
found by walking the thread rather than assuming the compacted turns are contiguous
or that this is the last one, so a rollback that removes the turn it was on moves it
to whichever turn now compacts first. The wording follows: it describes the state
the conversation is in from here on, and carries the counts from the turn it began
on in parentheses.

* Studio: make compaction an occasional event instead of every turn

The fit was stateless. The client re-sends the whole saved transcript on every
request, so "keep the newest N tokens" recomputes from scratch each time and slides
forward a turn or two at a time. Measured on a 40-turn thread against an 8k window,
the eviction boundary moved on 12 of 40 turns, which means every few replies quietly
lost a little more of the conversation, llama-server's prefix cache was thrown away
each time the head of the prompt moved, and there was no such thing as a compaction
event to tell the user about.

Two changes make it discrete. The fit now reads back the boundary the thread last
compacted to, from the newest assistant turn's own persisted truncation, and reapplies
it before deciding anything, so nothing new is stored and it survives a restart. And
when the boundary does have to move, the trim takes a further ROLLING_COMPACTION_
HEADROOM_RATIO of the budget out (default 0.25) rather than skimming to the brim, so
the new boundary has room to stay put. Same 40-turn thread, same window: 4 compactions
over 60 turns instead of 14, keeping about 82 percent of the usable budget.

Both are gated on the prompt not already fitting, exactly as the recall reserve is. A
conversation inside its window is never evicted to satisfy either, and a stale boundary
from a branch that was rolled back cannot evict a chat that now fits.

The notice follows: it is shown when dropped_messages rises above the last turn that
reported it, so it appears once per compaction and stays quiet in between.

* Studio: pin that the compaction notice can never become conversation

The notice is a sidecar rendered from metadata, not a message, and the ways that
could quietly stop being true are all one careless edit away: moving it inside
MessagePrimitive.Parts would make it a content part, and everything that walks parts
would then replay it to the model, copy it and export it.

Asserts it renders as a sibling of the content parts, that neither the outbound
message builder nor the assistant replay serialiser reads the key it renders from
(bounded to those function bodies, since the streaming handler reads the same key
legitimately on the way in), that the markdown export does not mention it, and that
it is suppressed while editing so it cannot be typed into the textarea and saved
back as text.

* Studio: say which part is too long when nothing can make a turn fit

If the message just sent is itself bigger than the window, no amount of eviction
helps. The fit already handled that correctly, returning the conversation untouched
so the request reaches llama-server's normal context-length error, but what the user
was then told was actively misleading: the error reports the size of the WHOLE
conversation and advises shortening it, when the history has already been evicted and
the single message is the part that does not fit. Measured at a 4096-token window: a
5000-token message produces 'Message too long: 10290 tokens ... shorten the
conversation', and shortening it cannot possibly work.

The fit now returns a fits:false diagnosis instead of a bare None, carrying what the
conversation could not be reduced below and how much of that is the latest turn.
Every consumer already gated on fits, so this is inert wherever a truncation is
treated as a compaction; the streaming paths now forward it so the client can use it.

The toast reads the diagnosis and, when the latest turn alone exceeds the window,
says so with the numbers instead of offering advice that leads nowhere. The merge
drops the diagnosis once a later refit succeeds, by delete rather than by assigning
undefined, so an ordinary response keeps exactly the shape it had before.

* Studio: fix the review findings on the conversation archive

Fifteen items, each reproduced against the code before changing anything.

Correctness in the request path. The fit reported a successful recall-capable fit
whenever the result was under the prompt budget, but protected messages can stop the
trim reaching the reserve target, and the recall then went in anyway: reproduced at
ctx 8000, the fit accepted at 6900 and recall took the request to 8948, past the
window it had just been made to fit. Recall is now sized from the room the fit
actually obtained, and skipped when there is none. Separately, the sticky boundary
describes the original transcript, so re-applying it on a later tool-loop fit evicted
another boundary-sized block of live history: measured, a second fit dropped 28 of the
30 surviving messages instead of 14, and the summed count persisted an inflated
boundary for the next request. It is now spent after the first fit of a request.

Availability and privacy. enabled() trusted RAG_AVAILABLE, which only records that
import sqlite_vec worked; rag_available() exists because the native vec0 library it
loads is a separate file a venv can lack. On such a machine the fit held a recall
reserve back, evicting extra history, and then both the archive write and the recall
failed, so the user paid for content they never got. And a temporary chat is never
written to studio.db, yet the frontend still sends its thread_id and the request
carries no incognito flag, so its turns were archived to a scope no deletion flow
could reach. Archival now requires the thread to be persisted, which is the same rule
that keeps every archive reachable by a delete. Clear-history and project deletion
drop archives too; only DELETE /threads did.

Archive integrity. The document was committed before its chunks, so a failed chunk
write left an empty row marked completed that document_by_hash then skipped forever;
both now go in one transaction. The live-branch filter accepted a turn on its first
matching line, so editing only the assistant half kept serving the old answer; the
whole turn must be present. Retrieval fetched exactly k before that filter, so stale
turns could starve live ones and recall returned nothing; it over-fetches first. Tool
turns archived only the tool name, which cannot answer what was actually run, so a
bounded rendering of the arguments and the assistant text goes in as well.

The tool surface. Studio always sends an explicit enabled_tools array and has no
reason to name an internal tool, so the allowlist filter removed search_conversation
before the archive gate ran and the tool, plus the compaction nudge gated on it, never
appeared in a Studio chat. It now follows the archive rather than the allowlist. The
forced recall rendered a tool exchange even when the tool was absent from the
catalogue, which is the strict-template hazard the plain path avoids; it picks inline
in that case, and always inline for the final-answer request, which sends no tools at
all. A model-supplied top_k reached a slice as out[:-1] and returned nearly the whole
candidate pool, so it is clamped. Both retrieval tools now share the per-turn search
cap; only the knowledge-base one was counted.

The UI. A fits:false diagnosis is the fitter reporting it could NOT fit, so toasting
that older turns were removed was untrue and burned the once-per-thread flag a later
real compaction needed. The too-long advice compared the latest turn against the raw
context length rather than the prompt budget, so a 3,500-token message in a
4,096-token window was still told to start a new chat, which fails identically.

* Studio: fix the follow-up findings on the archive fixes

Four items, three of them about last round's own fixes.

Archived tool turns had become permanently unrecallable. render_turn now writes
'assistant called X: args' and 'tool result: ...' lines, while assistant-ui persists a
tool call as a structured tool-call content part that the transcript flattener dropped;
with every archived line required to appear in that transcript, no tool turn could ever
match. The transcript now flattens toolName, args and result, and the probe strips the
'assistant called <name>:' label, which is ours rather than the stored message's.

The branch probe compared only the first 160 normalized characters of each line, so an
edit to the tail of a long answer left the stale copy eligible. Ordinary lines are now
compared whole; tool results keep a prefix, since render_turn deliberately truncates
those and the archived copy is not meant to equal the stored one.

The forced recall sized itself by dividing the remaining budget by CHUNK_TOKENS, which
is an embedding-token limit rather than the chat template's cost, and prices none of
the wrappers around the injection. It is now recounted with the same tokenizer the fit
used and dropped if it overshoots, so the estimate can no longer eat the reply reserve.

An omitted top_k on search_conversation defaulted to the clamp ceiling of eight rather
than the configured recall default, so an ordinary search could return eight archived
turns into the protected current exchange that rolling truncation cannot evict.

* Studio: filter conversation recall to the active branch

A thread's stored rows are the whole message DAG. Retry and regenerate keep the
replaced response as a sibling on purpose, so filtering recall against the whole
thread cannot tell a live turn from one the user replaced, and an archived copy of
the abandoned response could be recalled into a branch where it never happened.

Filter against the messages the request was actually sent with instead, which is
one branch by construction, and hand the same branch to search_conversation so the
model cannot ask for what the forced recall refused. Falls back to the thread-wide
blob for a caller with no branch to offer.

Also retunes two respawn-refit fixtures whose windows no longer produced two
compactions after compaction started trimming a headroom margin below the budget.

* Studio: survive a delete mid-archive, and read the boundary off the active branch

Deleting a chat cancels its generation, but cancellation is cooperative and the
chunk-and-embed pass between the archive's liveness check and its commit does not
observe it. A delete landing in that window drops the thread's rows and sweeps its
scope before the commit puts rows back, leaving content the user deleted in a scope
no later delete can reach. Re-check after the commit and drop the scope: the delete
route removes rows first and sweeps archives last, so either order converges.

The sticky compaction boundary had the same thread-wide read as recall did. The
stored rows are the whole DAG ordered by creation time, so after a Retry the newest
assistant turn can be the sibling the user switched away from, and its boundary is
sized for history the active branch does not have. Resolve it against the request's
own messages instead.

* Studio: give the safetensors loop the same conversation-search guards

search_conversation is advertised by thread, not by backend: the tool selector is
shared, so a chat compacted under a GGUF model still offers it after the user
switches to a safetensors one. The safetensors loop had neither guard the GGUF loop
applies to it. It passed no active branch, so a search there fell back to the
thread-wide rows and could answer from a branch Retry left behind, and it capped
only search_knowledge_base, so paraphrased conversation searches could append
archived passages into the protected current exchange on every iteration until the
window failed. The shared set of capped retrieval tools now lives beside the cap.

The forced recall also derived its query from the loop conversation, which on a
later iteration can end with an internal user-role re-prompt rather than anything
the user wrote. It reads the request branch's own latest user turn instead.

* Studio: widen recall past an abandoned branch, and name whose turn overflowed

One over-fetch is not enough for the live-branch filter. Rewinding or retrying a
continuation that had already been compacted leaves enough stale turns to fill any
fixed candidate window, and the whole page is then rejected while the live match
sitting just below it is never examined, so recall reports nothing although the
answer is in the archive. Widen and re-ask instead, stopping as soon as there are
enough live hits, when the archive stops yielding candidates, or at a bound.

The irreducible-fit diagnosis also carried the size of the last message without
saying whose it was. A tool loop refits with the tool result appended, so that turn
is often output the user never wrote and cannot edit, and the client told them to
shorten it. It now reports the role, and the advice splits on it.

* Studio: shrink an over-budget recall, and keep truncated tool turns on their branch

The exact recount is the right gate, but dropping the whole recall when it fails is
the wrong response: with the shipped defaults a full top-K of long turns lands just
over the reserve once the wrappers are priced, which would disable the forced
retrieval on exactly the long conversations it exists for. Halve the number of turns
and re-ask instead, down to one, before giving up.

The live-branch probe keyed its prefix rule off the tool-result label, but a long
tool result is one appended string containing many newlines, so only its first line
carries that label. Continuation lines were compared in full, including the last one,
which is the only line the truncation marker is on and can therefore never appear in
a transcript: every archived tool turn over the cap was rejected as rolled back.
Key off the marker instead, which also covers truncated tool arguments and compares
the full text everywhere else.

* Studio: retire an edited turn's whole archived copy, not just the edited chunk

A turn longer than CHUNK_TOKENS is stored as several chunks of one document, and the
live-branch filter ran per chunk. Editing the second half of a long answer therefore
retired only the chunks carrying the edit, and an untouched earlier chunk of the same
retired turn stayed eligible on its own. The unit that was archived is the turn, which
is what the filter already claimed to enforce.

Validate every chunk of the candidate's document before admitting any of it, cached
per call since candidates from one turn share a document. A query failure falls back
to the per-chunk answer rather than failing the recall.

* Studio: do not hold a recall reserve back for a chat that is never archived

archive_turns refuses a thread with no saved messages, because a temporary chat must
not be persisted into a scope no deletion flow can reach. The reserve did not follow
that rule: it was granted whenever the RAG stack was available, so an incognito chat,
or an API client sending a thread_id without saving anything, paid a full 2,048-token
reserve for content that can never arrive. The fit subtracts the reserve from its trim
target, so that room is bought with evicted history. Measured on a 4K window: 15
tokens of conversation survived a compaction instead of 1,615.

Both now ask one predicate, can_archive, backed by an existence probe rather than a
row load.

* Studio: match archived turns in order, and delete them without sqlite-vec

Independent line membership accepts a turn whose lines were merely rearranged: every
probe still occurs somewhere, so the pre-edit ordering stayed eligible and would be
served back as what happened. Match the probes in order instead.

That only works if both sides agree on the order, and they did not: render_turn writes
a tool call before any assistant text on the same message and the result after it,
while both transcript builders wrote the text first, so a tool turn carrying both was
rejected outright. One flattener now lays out a message the way render_turn does, for
the request shape and the stored shape alike, and it offers both JSON spacings for a
stored call's arguments, whose object form is not the string the model emitted.

Deletion no longer depends on the optional native extension. An archive is only written
while vec0 loads, but the library can stop loading afterwards, and a delete that quietly
did nothing left a deleted conversation's turns on disk to answer again once it loaded.
The fallback removes the text-bearing rows over a metadata connection; the embedding
rows it cannot reach carry no text and resolve through tables that are gone.

* Studio: give the provider loops the branch, and keep the thread across a respawn

The provider tool loops take their catalogue from the same selector as the local ones,
so search_conversation is advertised there too once a thread has an archive, but the
loop passed no active branch and its searches fell back to the whole stored DAG, where
Retry keeps the response it replaced. This is the third loop to need the same wiring.

The respawn retry also dropped the thread. It re-enables context_overflow on purpose,
to refit an already-compacted prompt for a replacement window, so it is the one path
that deliberately compacts a second time: without the thread those extra evictions were
archived nowhere, nothing was recalled in their place, and the fit held back no reserve
and re-applied no boundary.

* Studio: keep the reply that follows a recall, and archive respawn refits

group_turns keeps an assistant tool call, its result and the reply that follows in one
group, and the archiver rejected any group containing one of our own injections. So on
a turn that forced a recall, the model's actual answer was thrown away with it: the
question was archived from its own group and the answer was not, and a later search
could find what was asked and never what was said. The injections come out now and the
rest of the turn stays; retrieved passages are still kept out of the index they came
from.

The two respawn refits inside the tool loop also evicted without archiving. They run
against a smaller replacement window, so those turns are simply gone otherwise. They
archive only, deliberately: no reserve is held back on that path, so injecting a recall
there is what would push the retry back over the window, and the next request can still
recall what this one archived.

* Studio: bound the branch check to one turn, and anchor only what recall injected

The branch check searched one flattened transcript, so a line an edit removed could be
supplied by any later message that happened to repeat the words. Short answers repeat
constantly: an archived "Should I deploy? / No" survived its answer being edited to
"Yes" because a later turn said "No", and the stale pair stayed recallable. The
transcript is now one normalised string per message, and a turn matches only if its
lines appear in order within a run of adjacent messages no longer than the turn itself.

Recall anchoring took the last two messages of the conversation. That is right for the
tool style, which appends a synthetic pair, and wrong for the inline style, which
appends nothing and rewrites the latest user message in place: it also pinned the
assistant turn before it, and with it an eviction unit the fit was entitled to drop,
which can fail a later iteration that would otherwise have fit. The injection now
reports exactly what it added or rewrote, and only that is anchored.

* Studio: validate a turn's chunks against one run of the branch, not each on its own

The chunks of an archived turn are consecutive slices of a single rendering, and each was
checked for itself. That let a turn be reassembled out of parts that never sat together:
the head matching the question and the answer it has now, the tail matching some later
message that happens to repeat the passage an edit removed, so the stale association
stayed recallable. Reproduced before the change, with each chunk passing on its own.

All chunks of the document must now be found within one run of adjacent messages, bounded
by the document's own line count, which is at least the number of messages the turn was
rendered from, so a turn that really is still there always fits.

* Studio: budget conversation searches and disambiguate identical replies

search_conversation clamped the model's top_k only against a fixed ceiling of 8.
Eight chunks is roughly 4,000 tokens once wrapped, and the result lands in the
current tool exchange, which rolling truncation protects and cannot evict, so on
a small context the search itself made the turn unsendable. The GGUF loop now
passes the room the window actually has left, the tool clamps top_k by it, and a
search with no room says so instead of returning a result that cannot be sent.

The sticky compaction boundary took the newest on-branch assistant row, but the
branch check is textual, so two siblings whose replies read the same ("Done.")
are indistinguishable from there, and Retry is exactly what produces them.
Taking the first match applied a boundary measured on a different, deeper branch
and evicted live history. Where the text cannot separate them, the smallest
boundary is now used: too small costs one more compaction, too large loses turns
the branch still has.

* Studio: price a conversation search against the whole prompt

Three gaps in the budget the previous commit introduced.

The GGUF loop subtracted an estimate of the messages alone, leaving the tool
catalogue out of the prompt entirely. A large catalogue is thousands of tokens,
so the request could already be near its budget while the search was still told
there was room for several 500-token chunks. The preflight already prices the
request exactly, catalogue and template included, so the difference between that
count and the estimate of the same messages is carried forward and the estimate
only covers what the loop appends after it.

The safetensors loop advertises and executes search_conversation for a thread
compacted under a GGUF model, but forwarded no budget, so the clamp in the tool
was skipped there. It now passes the room this model has left. With no known
context length the argument is omitted rather than sent as zero, which would
refuse every search.

The sticky boundary filtered candidates with a substring test, which is right
for archived chunks and wrong for whole messages: an abandoned "Done" rode in on
a live "Not done yet", and having no live twin it then decided the boundary
alone. Where any candidate matches a live message exactly, only the exact ones
are considered; where none does, the old behaviour stands, since a stored row is
not always byte-identical to what the client re-sends.

* Studio: keep the overflow diagnosis on the tool path

The irreducible-overflow branch counted the newest message on its own, and a
tool loop reaches that branch with a tool result last. A tool result by itself
is not a conversation: templates that require it to follow its assistant tool
call refuse to render one, and the exception escaped the fit entirely, so the
caller fell back to the untrimmed request and the client was told nothing at
all. That is the one path this diagnosis was added for.

The count is now attempted and falls back to the estimator when the template
refuses. An approximate number is worth more here than a diagnosis that never
arrives.

* Studio: budget the conversation search in the provider loop

The third tool loop forwarded the active branch but no budget, so the clamp in
the tool was skipped there and a model-chosen top_k of 8 could append roughly 4K
tokens to a prompt this loop replays on its next call.

Studio knows no window for an external model: the request carries no context
length and there is no registry to look one up in, and a custom
OpenAI-compatible endpoint can be a small local server. So rather than a
measured budget, this path spends no more than one ordinary recall's worth,
which is the same amount the compaction turn itself is sized for.

* Studio: bound an archived turn to its own messages, and archive it once

Two problems in the archive.

The whole-document branch check bounded the run by the number of LINES the turn
produced, and gave each chunk its own start. A turn is two or three messages
however long it is, so a hundred-line answer got a hundred-message window, and
the tail of an edited answer could be satisfied by a message well outside the
turn. The run is now bounded by the messages the turn was rendered from, counted
from the labels render_turn writes, and the chunks are scanned as one pass: each
continues where the previous one stopped rather than restarting. The cursor
inside a message is deliberately not carried over, because chunks overlap and a
continuation chunk repeats the tail of the one before it.

The hash check that skips an already-archived turn ran long before the insert,
with the embedding pass in between, and the index on (scope, sha256) is not
unique. Two generations compacting the same thread both cleared it and both
wrote, so the turn was stored twice and its copies took two of the few recall
slots. The check is now repeated under a write lock immediately before the
insert. Reproduced with two concurrent archive passes: two documents before, one
after.

* Studio: record how many messages an archived turn came from

The run an archived turn is allowed to occupy on the branch was bounded by
counting the role labels in its rendered text. That counts lines the user wrote
as well as the ones the renderer did: a pasted chat log carries lines that look
exactly the same, and each one widens the run by a message, which is enough for
the message after an edited turn to supply the passage the edit removed.

The group's size is now recorded on the document when it is archived, in a
nullable column added the way the other lazy upgrades are. Archives written
before this have NULL and fall back to the label count, so nothing needs
backfilling to keep working.

* Studio: persist a compaction boundary the next request can use

The boundary was read back from dropped_messages, which counts what each fit
removed from the conversation in front of it. The tool loop refits on every
iteration and the client sums those counts, so a long agent run added the tool
exchanges the turn itself created, and the next request applied the total to its
saved transcript. Reproduced with six tool calls on a 4K window: three fits of 4
summed to 12, on a branch that only ever had 4 evictable messages.

The boundary is now carried separately, measured against the messages the
request was sent with, so it is absolute and re-sending it cannot advance it.
The client keeps the latest value rather than summing, and turns saved before
this fall back to the dropped count, which is the same number for a turn that
fit once.

The recall reserve is also dropped once archiving has failed. sqlite-vec can be
present and the thread saved while the embedder cannot start: archive_turns
swallows that and recall injects nothing, so the room the fit held back was pure
loss on every compaction, and the failure mode forgot more history than having
the feature off. A failed write marks the archive degraded and the next
successful one clears it.

* Studio: count the boundary past the system prompt, and notice it

Two faults in the boundary added in the previous commit.

It stopped at the first message still present, and a Studio request always
starts with a system prompt that a fit never evicts, so every compaction
recorded a boundary of zero. That is the same as having no boundary: the next
request would move the eviction point again and invalidate the prefix cache on
every turn. Instruction messages are now skipped rather than treated as the
front of the branch, and the newest turn is excluded because it is never evicted
and an inline recall rewrites it in place.

The compaction notice still keyed off dropped_messages, which is the
accumulated count. A tool-heavy turn reporting 12 while the boundary moved to 4
set a high-water mark that silenced the next two real advances. It now reads the
boundary, falling back to the dropped count only for turns saved before the
boundary was recorded.

* Studio: tighten comments in the conversation archive and rolling context window

* Studio: give the pasted-text import an extension on this branch too

The node test runner cannot resolve an extensionless relative import, so
delete-chat-files-preference fails to load the preferences store. main fixed
this in d43892ea7; this branch predates it, and the same line is on the branch
below it. The change is identical to main's, so it disappears on merge.

* Studio: size a conversation search by what it renders to

CHUNK_TOKENS is what the chunker aims at, not what a chunk weighs: chunks
overlap, the chunker's tokenizer is not the model's, and the rendered block adds
markup, source metadata and the tool framing around it. Dividing the budget by
CHUNK_TOKENS therefore permitted a result far larger than the room measured, and
it lands in the current exchange, which the rolling window cannot evict.
Measured on a 500-token budget: one chunk came back at 1,256 estimated tokens.
The count is now halved until the rendered result fits, the same backoff the
forced recall uses, and a single chunk that still does not fit is refused.

The late archive cleanup also spared a recreated thread. DELETE removes the rows,
awaits the sandbox pass, and only then sweeps the archive; another tab can POST
the same id in that window and its generation can archive turns under it. The
sandbox pass re-checks for exactly that, and this now does too, so the recreated
chat keeps its memory.

* Studio: keep the configured default when top_k is omitted

Budgeting an omitted top_k by dividing the whole budget treated the room as a
target rather than a cap: on a 128K chat with most of its window free it asked
the archive for 200 passages, past the configured default of 4 and past the
ceiling of 8 that the model's own value is held to. The default now applies as
before, with the room and the ceiling capping it.

* Studio: cut the boundary at the newest user turn

Excluding only the branch's last message assumed the newest user turn is last,
and a continued assistant message puts a prefill after it. The user turn then
stayed in the identity scan, and since inline recall rewrites it into a new dict
it read as evicted, inflating the boundary by one and costing the next request a
live message. The scan now stops at the newest user turn, which is what is
protected in any case.

* Studio: require an archived turn to end where the live message does

Editing a reply by keeping it and adding to it ("No" becoming "No, correction:
yes") left every probe matching, so the pre-edit copy stayed eligible and a
search could return "No" as the answer with the correction nowhere in it. The
scan now reports where it finished inside the message, and the turn is accepted
only if it reaches the end of it.

Tool results and tool arguments are exempt: render_turn cuts them, so their
probes are prefixes by design and demanding the end would retire every turn that
carried one.

* Studio: check both ends of an archived turn, and charge dense text properly

An edit that keeps the old text and adds to it leaves every probe matching
whichever side it adds on. The end anchor caught "No" becoming "No, correction:
yes"; it did not catch "Correction: no", which ends exactly where the archived
copy does. The scan now also reports where the run into the final message began,
and the turn is accepted only if it covers that message end to end. render_turn
only ever cuts the tail, so the start is required unconditionally.

A conversation search sized itself with the shared estimator, which charges four
characters per token. That is an English rule: CJK and emoji run closer to one
token per character, so a result could be accepted at a quarter of its real size
and then land in the current tool exchange, which the window cannot evict. No
exact counter is reachable there, the provider loop having no tokenizer at all,
so non-ASCII characters are charged one token each and the rest at the usual
rate. Measured on 320 CJK characters: 87 tokens claimed, 320 charged.

* Studio: earn the compaction headroom, and keep this turn's tool results

The headroom cut every overflowing prompt to about 75% of its budget, including
on paths that can never put the boundary back: an incognito chat, an API request
with no persisted thread, or one whose turns are not saved. There the deeper cut
buys nothing and cannot be recalled, so it is simply less history than plain
eviction would have kept, and turning the archive off did not restore the old
behaviour. The fit now takes the headroom only when the caller can restore the
boundary next request.

The branch the archive is filtered against was the messages the client sent. A
long agent run evicts, and archives, tool exchanges it created itself, and those
were then refused as an abandoned branch: the model could not search back a tool
result it still needed to answer. The branch is now accumulated across the
request, so this turn's own exchanges count as live, while a sibling response
from a Retry still does not.

* Studio: read the branch as replies when restoring a boundary

The rows the rolling window checks are assistant replies, but the branch they
were checked against was every message of it, flattened without roles. An
abandoned "Done" left by a Retry therefore rode in on a live user message that
merely contains it ("not done yet I think"), and with no exact match to prefer,
its much larger boundary was applied to a branch that never had that reply.

The branch representation now takes a role filter and the boundary asks for
assistant messages only. A branch with no reply of its own has no boundary to
restore, so it reports none rather than falling back to the unfiltered check.

* Studio: account for every message an archived turn claims

The anchors covered the final message only, so an edit to the question
underneath it left the archived copy eligible, whichever side the text was added
on. The scan now checks each message the run touches: it must be matched from
the first character, and nothing may be left over when the run moves on.

Tool calls are exempt, and the exemption covers the whole message. The store
keeps a call as a structured part, so the live text carries the tool name and
both spellings of the arguments, spaced and compact, while the archived copy has
one line of one of them. Nothing there lines up character for character, and
demanding it would retire every tool turn in the archive.

* Studio: re-embed archived turns after a model change, and batch the first pass

Deduplication was by hash alone, but dense search only reads documents whose
recorded embedder matches the query's. A turn archived under the previous model
was therefore skipped on every later compaction while being invisible to every
paraphrased search, permanently. The check now compares the identity too and
replaces the copy, which is what ingestion does with a re-uploaded file.

Embedding also ran once per evicted turn. A first compaction of a long chat sent
dozens of one-item jobs back to back, and both backends serialise them, so the
reply waited for all of them. The turns are chunked first and embedded in one
pass, with the per-turn write and its duplicate check unchanged. Measured on 40
turns: 40 embedding calls before, 1 after.

* Studio: recall the latest version of a fact, not the most quotable one

A long conversation makes its own subject the least discriminative word in its
archive. With eight assignments to one variable, the variable name sits in 8 of
17 chunks and scores 0.16 under BM25, while an incidental word from the question
("value") sits in 1 and scores 4.755, so the ranking is decided by the filler and
a chunk about a retry budget outranks every chunk that names the variable.
Measured over 5 seeds, the newest assignment was archived 8/8 and retrieved 0/5.

Three changes, each knob-gated with an off setting that reproduces today byte for
byte:

- Selection. conversation_match_queries ANDs identifier-like tokens first and
  falls back to a stopword-stripped OR, used by the archive only, so knowledge
  base, project and thread search produce identical bytes. Newest revision
  retrieved 5/5 at both 4 and 8 revisions, zero distractors.
- Order. documents carries a nullable archive_ordinal, allocated inside the
  existing write lock and preserved across the re-embed path, so recalled turns
  are presented oldest first with a header stating that a later turn supersedes
  an earlier one. Asking what the value was originally still returns the first
  assignment, 5/5.
- The governing instruction. A standing instruction is protected only while it is
  the newest user turn, so a short follow-up ("continue") makes it the oldest
  eviction candidate, and the forced recall then searches the archive for the word
  "continue". instruction_pin computes a bounded pin through the existing
  protected_message_ids seam, and a thin latest message adds the last substantive
  instruction as a second recall query. Pinning ships off; the anchor query ships
  on, since it changes only what is retrieved.

The pin is bounded newest-first over a fixed number of groups, so it protects an
instruction against filler rather than against a long conversation. That limit is
covered by a test rather than left to be found later.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: stop the focused archive query dropping the answer it was meant to find

Six fixes to this branch's own new code, each reproduced before it was written.

The focused query could crowd out the very turn it exists to retrieve. The
conjunctive identifier pass ran first and broke as soon as it filled `fetch`, so
the permissive content-word pass and hybrid search never ran. On an archive of 20
turns all naming one variable, with only the last stating its value, the
conjunction returned 16 and saturated the window, and the recall came back with
turns 1 to 4 and not the answer, which the pre-change OR query does return. The
root cause is ranking, not the threshold: SQLite floors the BM25 IDF of a term
appearing in more than half the index at 1e-6 (ext/fts5/fts5_aux.c), so a
ubiquitous identifier orders nothing and the cut is effectively arbitrary. The
conjunction is now a FILTER over a wide window and the content-word pass supplies
the ranking, which is what the two were always meant to do together.

Negation is no longer stripped. "What did I say not to delete?" became `say OR
delete`, terms a long archive has almost everywhere, and by the same IDF floor
they order nothing while the one word carrying the question was gone. `no` and
`not` cost nothing where they are common and are the whole query where they are
rare.

A legacy turn is no longer renumbered when it is re-embedded. Databases upgraded
into `archive_ordinal` carry nulls that are deliberately not backfilled, and null
sorts FIRST as "older than every numbered turn". Re-embedding after a model
change allocated MAX+1 for one of those, so a conversation's oldest turn was
rendered as its newest and read as superseding what actually came after it.

Merged recall now orders the way single-query recall does. The merge key sorted
null turns LAST while `_conversation_order` sorts them first, so a thin follow-up
that adds the anchor query produced a block contradicting its own oldest-first
header. The key also carries `chunkIndex` now: two queries hitting the same long
turn produce equal keys, and a stable sort then quoted chunk 1 ahead of chunk 0.

The instruction pin is charged for what it actually holds. The window protects by
GROUP and evicts a user-headed group together with its trailing tool groups, so
pinning a one-line instruction also holds its reply and its whole tool exchange.
Charging only the instruction let a pin exceed its ceiling by any amount:
measured at 28 tokens charged against 20037 held, 715 times the cap. An
over-budget instruction is now excluded outright, which is what the docstring
already promised.

One of this branch's own tests was passing on any implementation.
`test_re_embedding_a_turn_keeps_its_place` patched the encoder but not
`embedding_identity`, so `archive_turns` short-circuited before embedding and
returned 0, and its assertion held whatever the code did. That is why the
renumbering bug above was not caught here. Hardened to the pattern the
neighbouring re-embed test already uses.

* Studio: ask the index which chunks are eligible instead of reading it off a capped list

Two ways the focused archive query could still drop the answer it exists to find,
both of them this branch's own doing and both reproduced before being fixed.

Several identifiers are now ORed rather than ANDed. "What are the current values
of A123 and B456" is two questions in one envelope, and the turn answering either
one names only one of them, so requiring both kept exactly the turns that DISCUSS
the pair, which are the older comparisons, and dropped both current assignments.
Measured on six comparison turns plus one latest assignment each: the conjunction
returned the four oldest comparisons and neither value, while the plain OR path
this branch replaced returns both. The filter's job is to keep every slot on
something the question asked about, and one identifier out of two is still that.
A chunk naming both still outranks a chunk naming one, because it matches more.

Eligibility is now asked of the index rather than read off the top of a capped
ranked pass. The strict pass is bounded at 256 candidates, and its order inside
that bound is arbitrary for exactly the reason this branch already documents:
FTS5 floors the BM25 IDF of a term present in more than half the index at 1e-6
(ext/fts5/fts5_aux.c), so the identifier a whole thread is about orders nothing.
Past 256 identifier-bearing chunks the turn stating the current value could be
absent from that list, get appended behind all 256, and be cut by the fetch
window. Measured lost on a 301-chunk archive where the loose pass ranked it
first. A new lexical_matching_ids answers membership directly, restricted to the
candidates already in hand, which is exact however long the thread gets. It
degrades rather than fails: if the probe raises, eligibility is the old set and
behaviour is unchanged.

Neither existing guarantee was traded away. Both tests that pin the previous
behaviour use a single identifier, where the two expressions are identical, and
eligibility only ever grows, so identifier-bearing chunks still take every slot.
The same edit also removes a latent duplication, where a strict hit appended as
fill could appear a second time in the loose tail.

Two regression tests, both failing before the fix.

* Studio: order merged legacy recalls by when they were said, not by which query found them

The merge key matched _conversation_order on the ordinal but dropped its
created_at tie-break, and for pre-ordinal rows that tie-break is the whole
ordering: every legacy row has turn None, so two of them fell back to chunk index
and then to the order the two queries happened to return them, which is the
anchor's hits followed by the follow-up's. A later legacy turn could therefore
render before an earlier one under a header promising oldest first, on exactly
the upgraded databases the null ordinal exists for. The same tie is reachable
among numbered rows too, since the ordinal is deliberately not UNIQUE: the write
lock is best-effort and two concurrent archive passes can compute the same MAX+1.

createdAt is now carried on recall sources beside chunkIndex and the merge key is
the same four-part key the single-query path uses.

Two tests. One pins the ordering, and one pins the PRODUCER, because nothing
renders either field: an unused-looking key is precisely what a later cleanup
deletes, and the ordering test alone would keep passing after that.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: stop the archive query stalling on a pasted log, and three narrower fixes

The query shaper was quadratic in the length of the question. `_is_identifier`
re-scanned the whole question and rebuilt its token list once per distinct token,
so a 48 KB paste cost 6,005 full scans and 4.5 seconds, against 1.3 ms for the
`_match_query` every other RAG scope uses. At 96 KB it is 17.7 seconds. The
reachability is the sharp part: the query is the message the user just sent, and
a large paste is exactly what forces a compaction, so the stall lands on the one
turn this feature exists for, and again per widening iteration and per rung of
the top_k backoff. Purely alphabetic text is the worst case, because a token
mixing letters and digits short-circuits earlier and never reaches the scan, so a
pasted log or source file is the shape that pays most. Hoisting the tokenization
out of the loop is output-identical over 20,000 random inputs and takes 48 KB
from 4.56 s to 5.7 ms.

The ranking pass is now fetched to the same bound as the filter pass. With the
loose window full of content-only distractors, none of them eligible, the merge
fell back to the strict pass's order, and that order is worse than arbitrary: on
a 160-chunk archive it is a 100-way tie at the 1e-6 IDF floor in rowid order,
which is oldest first, systematically the wrong end. The turn stating the current
value did not even reach the tie. Loss begins at 20 distractors, 41 turns total.
Fetching the ranking pass wider is chunk-for-chunk identical wherever the window
was not saturated.

A re-embed that stops partway no longer reorders a legacy archive. `create_document`
hard-coded a fresh timestamp, so the delete-and-insert re-stamped rows whose null
ordinal makes created_at the whole of their ordering. The ordinary embedder
switch was already safe, since every compacted request re-presents the whole
evicted history and re-embeds it in one pass; the reachable case is a pass
interrupted mid-way and served in the same request, which archive_turns is
explicitly built to survive. Then the two oldest statements are quoted last,
under a header saying later turns supersede earlier ones.

A short self-contained request is no longer treated as a nudge. "review billing"
and "fix authentication" were classified thin purely for being two words, so an
unrelated older instruction was added as an anchor; at top_k 1, which both the
recall budget and the over-budget backoff reach, the anchor took the only slot
and the user's own words were never searched. Thin now means naming nothing at
all rather than being short.

One pre-existing assertion flipped with the timestamp fix: a test asserted
ordinals [0, None] under ORDER BY created_at, which was incidentally pinning the
re-stamp. Its own claim is unchanged.

Seven regression tests, each failing before its own fix.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: stop charging an instruction pin for a tool exchange it does not hold

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: stop a tied recall score handing back the oldest revision as the current one

FTS5 floors the IDF of a term appearing in more than half the index at 1e-6, which in a
per-thread archive is exactly the identifier the conversation is about. Measured on eight
revisions of one variable: ONE distinct bm25 across all eight, so the candidate list came
back in rowid order, top_k truncated it at the front, and the four OLDEST revisions took
every slot. The current value never reached the model.

Worse than a miss, because format_conversation_recall states that a later turn supersedes
an earlier one, so the stale value was handed over as the authoritative one.

Within a run of equal scores only, take from both ends: newest, oldest, next-newest, and
so on. Preferring the newest outright is the obvious version and it is wrong, measured:
it fails the guard that asks what the variable was set to at the very start, and it fails
the new tie test too. Both ends keeps "what is it now" and "what was it originally"
answerable out of the same tied run, and nothing moves past a chunk the ranking pass
actually separated. Legacy NULL ordinals still sort oldest, and a pass with no ties at all
skips the row fetch.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: stop the anchor query shrinking a recall, and make a shouted question still filter

Two ways the archive gave back less than it holds.

The two-query merge cut each query's slice to its share BEFORE deduplicating, and the
anchor is drawn from the same thread as the latest message, so overlap is the normal
case: every chunk the two agreed on spent a slot that was never refilled. Measured on six
turns matching both queries at top_k 4, either query alone returned 4 sources and the pair
returned 2, with four eligible chunks unread. The anchor exists to rescue a thin message
and was making the recall smaller than not having it. Over-fetch by what is already held
and keep the first `room` fresh rows, sorted by score: the inner call orders its own slice
chronologically, so a widened fetch arrives oldest-first and taking its head would put the
refilled slots on the oldest turns, which is the tie failure one level up.

And the capitals rule in the identifier test needs contrast to mean anything. With no
lower case anywhere in the line every word passes it, so the focused pass ORed in "what"
and "the" and filtered nothing. Measured: "WHAT IS THE CURRENT VALUE OF ZQXVARA123?" gave
'"what" OR "the" OR "current" OR "value" OR "zqxvara123"' where the ordinary spelling gave
'"zqxvara123"', and at top_k 1 the one slot went to a turn about a retry budget instead of
the variable. Shape still decides, so the identifier is recognised either way; a
pure-letter name inside a shouted question falls back to the permissive pass, exactly as
the lower-case spelling of the same question already does.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: say on _is_identifier that the capitals rule needs contrast

* Studio: keep numbers identifiable in a shouted question, and put the tie-break behind its knob

Two follow-ons from the previous commit, both measured.

Making the capitals rule need contrast cost purely numeric subjects their shape. "9134"
upper-cased is itself, so it qualified only through that rule, and the shape test demanded
a letter as well: shouted, the number stopped being an identifier, the focused pass was
dropped, and at top_k 1 the slot went to a turn about a retry budget instead of the number
asked about. Shape is now "contains a digit", which for any ordinary-case query is the
same answer the capitals rule already gave, so nothing else moves.

And the tie-break sat outside the rollback knob. RAG_CONVERSATION_QUERY_FOCUS=0 promises
the candidate set is identical to before, and reordering candidates is selection, not
presentation, so on a tied archive an operator who had turned the feature off still got
the new order: measured, the knobs-off recall returned the both-ends set where the
previous build returned the four oldest. Gated on that knob, not on the ordering one.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: number archived turns by where they were said, and keep a repeated turn

The ordinal was allocated as MAX+1 at archive time, which is only conversation order if
eviction is strictly oldest-first, and it is not. truncate_oldest_messages always protects
the newest user group, so an agent turn's tool groups are archived while the user message
that opened them is still held, and a pinned instruction is archived only once it stops
being pinned. Measured: a standing instruction came back as turn 3 of 3, under a header
that tells the model the higher number was said later and supersedes the earlier one. The
block asserted the reverse of what happened, on shipped defaults.

The position now comes from the persisted transcript, grouped with the evictor's own
grouper. From the transcript rather than the request because four of the five archive call
sites pass the tool loop's already-fitted messages, whose indices shift as earlier groups
drop out, while studio.db holds the whole thread and is never truncated.

The same read fixes the repeat. Idempotency was keyed on the content hash alone, so a user
who says the same thing twice had the second one discarded: "set X to 1", "set X to 2",
"set X to 1" stored two documents for three turns, and the turn holding the current value
was never indexed, so no query could reach it. A copy now counts as archived only once
every occurrence in the transcript has one, and the nth copy takes the nth occurrence's
position. Re-archiving an eviction is still free, which is what keeps every later request
cheap.

Existing archives converge without a migration pass: a turn already indexed under an
archive-time number, or under none at all, is re-stamped in place at the next compaction.
That is an UPDATE on the row that exists, so nothing is duplicated and no reindex is
needed. The re-embed path still keeps its ordinal and its timestamp.

Two test expectations move with the contract, both noted where they sit: a repeat that is
only a re-eviction now passes persist=False, since appending the turn to the thread a
second time describes a user saying it twice, and a legacy NULL row's neighbour is turn 1
rather than 0 because it is the second turn of the conversation.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: match a whole turn to its place, and let an old archive actually converge

Two holes in the positional numbering from the previous commit, both measured.

A turn was located by its FIRST message, so two different turns that merely start the same
claimed each other's seats: a repeated "continue", the same question re-asked, a
regenerated reply. Both were stamped with ordinal 0, and since each then believed it had a
second occurrence still to fill, the next compaction wrote both again. Measured at four
documents for two turns, four recall slots spent on two turns' content, and the older
answer quoted under the higher turn number, which the header presents as the one that
supersedes. Matched on the whole turn now, as a prefix, because the transcript legitimately
lags a turn archived mid-request before its reply is persisted. A turn that matches nothing
yields no seats and falls back to the previous allocator, so this can never do worse than
not looking.

And the re-stamp that was supposed to migrate old archives sat behind the write lock,
while the cheap pre-check ahead of the embedding pass skips on exactly the same condition.
It was unreachable outside a race, which makes the migration claim in the last commit
wrong as it stood: measured, an archive forced back to NULL ordinals still read NULL after
a full re-compaction, and one forced into archive-time order 1,0 stayed 1,0 with the recall
rendering the second turn first. Both paths now call one helper. The pre-check commits its
own update, because that path holds no transaction and the loop returns before the write
lock whenever every turn is already archived, which is the ordinary case on re-compaction
and therefore the only chance an upgraded archive gets.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: put a persisted tool exchange back into wire shape before reading it

The store keeps a tool call as one `tool-call` content PART carrying its own result, while
the request carries three messages: the call, the result, the reply. Nothing converted
between them, and both readers of the persisted transcript treated the rows as if they
were already wire messages, so an agent turn was invisible twice over.

`group_turns` splits on `tool_calls`, which a persisted row never has, so the exchange
folded into the preceding user group and the evicted tool group found no position of its
own. Measured: seats came back empty and the exchange took MAX + 1, the number its own
opening question already held, with created_at then ordering the answer ahead of the
question under a header saying the later turn supersedes.

Worse, `_live_transcript` probes the same rows, so the branch check compared an archived
call/result/reply render against a row reading call/reply/result. It matches in order, so
it failed: measured, the archived agent turn was filtered out of every recall and no query
could return it. Archived content the model can never get back, on every tool-using turn.

One reconstruction now feeds both readers. The result moves onto its own `tool` message so
each piece renders once, in the order render_turn wrote it, and only the call id goes into
`tool_calls` so the arguments still render from the content part in both JSON spellings
and `_is_injected` still sees the id it filters our injections by. Seats compare a tool
call as needle in haystack for that reason, since the store holds the arguments as an
object and the request holds the model's raw string; every other message still compares
exactly.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: name the encoding on this branch's test reads, and re-pin the permission read

Two CI failures in Repo tests (CPU), both in the test tree.

Three read_text() calls added by this branch take the platform default encoding, which the
repo's own guard refuses because they break on Windows the moment the file they read gains
a non-ASCII byte. Named utf-8, as the guard asks.

And the deep-research contract still pinned the literal `const permissionMode =
loadPermissionMode();`, which #8686 removed when it put a chat-scoped override in front of
that read. The branch already carries the refactored store, so the assertion could not
pass. Ported main's replacement rather than deleting the check: the read is still the
contract, scoped to the deep-research setter so it cannot be satisfied by the
initial-state constant, which is a different property.

Inherited rather than introduced here, and it fails the same way on the base branch, but
it is what is red on this PR.

* Studio: five fixes to the archive's ordering, matching and filtering

The refill in the two-query merge sorted by score, which is rounded for display and is
identical across a tied run, so it left the list in the chronological order imposed just
above and spent its slots on the oldest turns. Measured on eight revisions all scoring
1.6297: the single query returned the newest and the same query plus an anchor did not, so
the anchor made the recall worse than not having it. Retrieval rank is now carried on the
source and sorted on first.

A position SHORTER than the evicted turn could prefix-match anywhere, because zip stops at
the shorter side. A thread carrying an orphan user row -- a reply deleted from the thread,
or a reload before the reply was appended -- gave the later, answered turn two seats and a
second copy at the next compaction, with the recall quoting one turn twice. A short
position now matches only the trailing turn. Longer positions stay legal, since
_archivable strips injections the transcript keeps and _as_wire expands one tool row into
several.

list_chat_messages is an unfiltered select over what is really a tree: parent_id is a
column and Retry leaves the replaced reply as a sibling. Read flat, that sibling lands
between two live turns and the grouper glues it onto the one before it, so the regenerated
turn matched nothing and took MAX + 1 -- which the cumulative archive has already pushed
past every live turn. Measured: a regenerated turn 2 numbered 5 out of 4 live turns,
colliding with live turn 3. Positions now walk the active chain, newest leaf to root, the
shape the frontend already uses to decide what the model is shown.

A rewind that removes a repeated turn leaves more copies than the conversation has
occurrences. Both are byte-identical, so the branch filter passes each against the one
surviving occurrence and recall dedups on chunk id, which differs: a slot went on quoting
one turn twice, and the surplus kept an ordinal a later turn had since taken. The surplus
is retired where it is restamped.

And treating any digit-bearing token as an identifier was too wide. The capitals rule it
replaced carried a length bar, so only numbers of one or two characters actually changed
behaviour, and my previous commit message was wrong to say nothing moved: "answer in 2
sentences" began filtering the archive on "2". Measured at top_k 1 on an archive whose
filler mentions small numbers in prose, the focused pass returned the wrong turn where
both the previous build and the rollback knob returned the right one. The length bar is
back, and a token mixing letters and digits is still a name at any length.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: re-pin the permission read the deep-research contract lost to #8686

The contract still asserted the literal `const permissionMode = loadPermissionMode();`,
which #8686 removed when it put a chat-scoped override in front of that read. This branch
already carries the refactored store, so Repo tests (CPU) cannot pass here or on anything
stacked above it.

Ported main's replacement rather than dropping the check. The read is still the contract,
and it is scoped to the deep-research setter so it cannot be satisfied by the initial-state
constant, which is a different property and would keep passing if the read were deleted.

* Studio: retire a surplus copy on the re-embed path too

Surplus copies were retired only where a turn was found already archived, and the re-embed
path never reaches that branch: it replaces one copy's vectors and returns. So a repeat
archived twice and then rewound kept its extra copy as soon as the embedder changed.
Measured after a rewind plus an embedder change: three documents for two turns, the recall
quoting the repeated turn twice, and the surplus still holding a position a later turn had
taken.

Retire only, never restamp. Numbering the row here would break the thing the re-embed path
is careful about: a turn archived before the ordinal column existed must stay unnumbered,
or it moves to the end of its own conversation and the header presents it as the latest
word. Measured while writing this, by doing it wrong first: the legacy row came back as 0
and the two guards covering that fired.

The surplus rule is one helper now, shared by both callers, and it still does nothing
without seats, so a turn that failed to match its occurrences at all never loses a copy.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: re-derive the compaction boundary by position, not by count

`boundary_messages` is an absolute count against the transcript it was counted
on. Delete an already-evicted prompt from a compacted thread and the front of
that transcript gets shorter, but the count is replayed unchanged, so the cut
lands that many messages too deep and evicts turns that are still live.

Reproduced on `fit_rolling_context` with a 30-turn chat at ctx 2000: a fresh fit
keeps from turn 16; deleting one already-evicted pair and replaying the stored
count keeps from turn 17, one live turn lost. The loss is then baked in, since
the next turn records the boundary against the shortened transcript.

Stamp `boundary_anchor`, the text of the first message the fit KEPT, next to the
count at all five sites that write it, and look it up on the next request's own
branch. The anchor is only ever allowed to make the boundary SHALLOWER, so a
stale, ambiguous or repeated anchor costs one extra compaction and can never
evict a live turn. Rows written before the key exists behave exactly as today.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: say what the archive kill switch does and does not cover

RAG_CONVERSATION_ARCHIVE=0 stops indexing, recall and the recall reserve. It
does not turn the rolling window back into what it was before it learned to
compact in discrete events: the headroom and the sticky boundary belong to the
window, and the headroom has its own knob. "Off restores plain eviction" read
as though it covered all of it.

Comment only. Gating the window's own behaviour on this flag would make a host
with no sqlite-vec silently compact differently from one that has it.

* Studio: do not archive a repeat the model can still read

Seats come from the persisted transcript, so a turn said twice contributes two
of them even when only the older one has crossed the boundary. The same evicted
group is handed back on every later request while the sticky boundary holds, so
the second pass saw one stored copy against two seats, decided the archive was
short, and wrote a document for the occurrence still sitting in the prompt.

Measured: two documents for one evicted turn, both recallable, identical text
taking two of the four recall slots and one of them repeating what the model can
already read verbatim.

Bounded by what the fit KEPT. `seats` still allocates ordinals and still drives
retire and restamp, unchanged; a separate write budget counts only the seats
whose turn is no longer in the prompt. When that turn is evicted later the budget
rises on its own and the copy is written then, at its own ordinal. `live` is
optional, so every direct caller keeps the previous behaviour.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: charge dense text honestly, and reject an edit inside an archived turn

Two fixes.

The safetensors search budget priced what is already in the prompt at four
characters per token. That is about right for English and roughly half the truth
for CJK and emoji: measured on an 81-message Japanese chat, 1295 estimated
against 2737 real, reporting 1777 tokens of room where 335 remained. The tool
result is already charged a token per non-ASCII character; this is the same rule
applied to the spend, which was the missing half. That path runs no rolling fit,
so nothing downstream recovers once the exchange lands. The GGUF site takes it
only on its fallback leg, since the fit's exact tokenizer count needs no
correction, and eviction keeps the flat estimate: making eviction pessimistic
would drop history a request could have kept.

The probe scan anchored the start and the end of a run but not the middle, so a
correction inserted between two archived lines matched both probes with the new
line sitting unexamined in the gap, and the pre-edit turn stayed recallable.
Verified on "A\nB" becoming "A\ncorrection\nB". A gap is now allowed only where
it is a label render_turn wrote and the probe had therefore stripped, since a
pasted chat log legitimately carries its own "user:" lines, and the tool-call
exemption still covers the rest of its message.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: reach both ends of a tied run, and finish a re-embed that owed a copy

Two archive fixes.

Every hit on the identifier a per-thread conversation is about ties at the FTS5
IDF floor, and SQLite returns a fully tied run in rowid order, so a candidate cap
of 256 means "the oldest 256". Past that many chunks naming the identifier the
newest assignment never became a candidate at all, and the ends-first ordering
could only reorder the window it was handed. Measured on a 300-turn archive: the
window held ordinals 0-255 of 0-299 and the value set by the last turn was
unreachable at any k. Both the filter pass and the ranking pass now fetch half
from each end, ordered by archive_ordinal rather than rowid, and the merged run
is ordered once. Ordinals now come back 295, 0, 294, 1 instead of 255, 0, 254, 1,
so "what is it now" and "what was it originally" stay answerable from the same
tied run. Rowid was tried first and is not good enough: a re-embed deletes and
reinserts a chunk while keeping its ordinal, which scrambled exactly the rows
this leg exists to reach.

A replacement is not an addition. When the embedder identity changed between a
repeated turn's first copy being archived and its later occurrence being evicted,
the re-embed branch swapped that copy's vectors and left the count where it was,
so the newly evicted occurrence was never written: ordinals [0, 1] where
[0, 1, 2] was due, with an intervening contradiction then presented as the
conversation's last word on the response the eviction triggered. The copies are
now topped up to the write budget, reusing the vectors already in hand. Only
where the caller passed `live`, so the budget is a real count of evicted
occurrences rather than every seat in the transcript; every direct caller is
unchanged.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: end the tool-call anchor exemption where the call ends

A stored tool call cannot line up character for character with the live text,
because the store keeps arguments as an object and offers both JSON spellings,
so the cursor after one is not exact and the character anchors have to relax.
That relaxation was then held for the rest of the message.

An assistant turn carrying both a call and text therefore stayed matched after a
correction was appended to the text: "old answer" becoming "old answer,
correction: new answer" left the archived pre-edit turn eligible for recall.

Cleared once an ordinary text probe matches, since the cursor is exact again from
there. Verified on that exact pair: intact still eligible, edited no longer.

* Studio: count live repeats, order both ends by ordinal, restamp a rewound survivor

Three follow-ons to the previous archive round.

The write budget subtracted live occurrences by set membership, which cannot
count. A turn said three times with two evicted and one still in the prompt read
as entirely live, so one copy was written where two were due: ordinals [0, 1]
instead of [0, 1, 2]. Counted with `_occurrences` now, the same matcher that
finds the seats, so the live side is matched by the same rules as the stored one.

Only the newest half of a tied run was ordered by archive_ordinal; the oldest
half still went by rowid. A re-embed deletes and reinserts a chunk while keeping
its ordinal, so the oldest turn's rowid moved from 1 to 297 and it fell out of
the front window while the newest-first leg deliberately skipped it. It was in
neither half and stopped being recallable at all. Both halves of both passes now
take an explicit ordinal tie-break, NULLs first on the oldest side and last on
the newest.

A re-embed keeps the ordinal it found, which is right while every copy is still
there and wrong once one has been retired. Identical turns at ordinals 0 and 2
with the FIRST rewound away left the survivor on 0, so a contradiction at 1
rendered after it and the header, which says the higher turn number was said
later and supersedes, handed the model the superseded value. The survivors are
restamped against the seats that remain, skipping NULL ordinals so a legacy
archive is still never renumbered and `created_at` is still never touched.

* Studio: count a video part as media in the rolling preflight

`_inject_video_part` writes llama.cpp's own `input_video` part into the same
message list the fit is then handed, and the media predicate did not list it.

Video prompts therefore ran the rolling preflight, which is skipped for media
precisely because `/apply-template` token counting does not include the sampled
video tokens. The prompt could be certified as fitting when it does not, or lose
history it never needed to lose, and still fail with context_length_exceeded.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: keep which tool was called in the branch probe

`render_turn` writes "assistant called <name>: <args>", and the whole label was
stripped before probing, name included, so only the arguments decided. A retry
that kept the arguments and the result and changed only the tool left the
archived pre-edit turn matching: verified on the same arguments moving from
terminal to python, which stayed eligible and could be recalled as though the
old call had happened on this branch.

The name is now its own probe, ahead of the arguments, which is the order
`_probe_text` renders a live call in. The label itself still has to go, since it
exists only in the archived copy, but the name does not: the live text carries
it. `render_turn`'s "tool" fallback for a nameless call is excluded, because the
live text has no name there either.

Not an exact anchor: the name is matched as a substring of the message, so a
tool whose name appears inside the arguments of another can still match. This
closes the ordinary retry case rather than every construction of it.

* Studio: charge the pin budget for what dense text really costs

The pin ceiling exists so a protected instruction stays a bounded minority of
the prompt, and it was charged at four characters per token. That undercharges
CJK and emoji by roughly 2x or more, so a turn that really costs 1056 tokens was
charged 276 and cleared a 1024 ceiling it was nowhere near. The pin then held far
more than the budget allows and the fitter evicted recent turns to pay for it,
which is the failure the budget exists to prevent.

Uses `estimate_messages_tokens_dense`, which is already in the tree for the
search budget on the safetensors path. Over-charging here can only ever refuse
the pin, never inflate it, which is the safe direction: an unpinned instruction
is evicted normally, an over-large pin starves the window.

* Studio: do not archive a search the model asked for

`_is_injected` recognises the ids this feature and the RAG auto-inject generate,
and a search the MODEL issues carries neither: the parser gives it an ordinary
`call_N` id. Both the call and the passages it retrieved were therefore indexed
as fresh conversation, and a second search archived the first one's output inside
its own, one nesting level per distinct search, each copy competing for the four
recall slots.

Dropped by tool NAME instead, reusing `RAG_SEARCH_TOOLS`, which both loops
already share. Only the retrieval parts go: a reply that follows a search is real
conversation and is still archived, and an assistant message carrying a retrieval
call beside an ordinary one keeps the ordinary one and its result.

* Delete the archived conversation even when its thread id comes back

The late archive sweep skipped the whole scope when another tab had recreated
the id, which spared the recreated chat's memory but kept the deleted
conversation's too. The scope is keyed by thread id alone, so those turns stayed
recallable in the new chat with nothing left to sweep them, and the endpoint
reported success.

Take a cutoff before the rows go and bound the delete by created_at when the id
has come back: everything archived before the delete was accepted belongs to the
deleted conversation, everything after to the new one. Applied at DELETE
/threads, project delete and clear all, and on the no-vec0 path so it cannot
delete more than the vec0 path does.

* Keep pre-call text on the call message, and stop folding case when matching seats

Three archive-matching fixes.

A persisted assistant row holds the whole turn in generation order, so text
before the first tool-call part is what the model said on its way to calling and
the live wire form carries it on the call message. _as_wire emitted it after the
synthesized results instead, giving the archived copy three messages against the
live two, so _occurrences matched nothing and the turn took a fallback ordinal.
Split parts at the first call: the preamble rides on the call message, the
remainder still follows the results, which is what a reply after the tool result
needs.

A quoted word is the subject whatever the stopword list thinks of it. 'What did
I say about "this"?' reduced to a query for "say" alone, and an archived 'Use
this endpoint' was unreachable. Tokens inside a quoted span are now exempt. They
stay out of the identifier pass, so this widens the permissive expression only.

'Set key Foo' and 'Set key FOO' hash differently and keep a document each, but
lowercasing the transcript comparison handed both seats to both, so the next
compaction wrote both again. Seat matching now preserves case; the free-text
branch filter, which compares a query against saved text, still folds it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: tighten rolling context window comments

* Number evicted turns on the branch the request is on, not the newest stored row

The ancestry walk was seeded from the last saved message, which is not the
branch the request is on. Switching to a sibling branch, continuing there and
switching back leaves the abandoned branch holding the greatest created_at, and
the frontend says as much in refresh-context-usage.ts: after a retry the newest
stored leaf is a branch the user left.

Measured on a thread with one such switch, positions came back as the abandoned
branch's two turns and both of the request branch's evicted turns matched
nothing, so each took MAX + 1 over a cumulative archive the other branch had
already pushed up. The recall header presents the higher number as the one that
supersedes, so an older statement is quoted as the current one.

The request branch is already at the call site, so pass it down and seed the
walk from the stored leaf whose chain best covers its text. Matching is by text
because the wire carries no message ids, and scored rather than compared because
the newest branch message is usually not persisted yet. No match and no branch
both fall back to the newest row: an empty chain would empty every seat and send
every turn to MAX + 1, which is worse than reading the wrong branch.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Stop folding case when validating an archived turn against its branch

A turn corrected only in capitalisation still matched its archived copy, so the
branch filter kept the pre-edit document live and a recall could answer with the
spelling the user had just fixed. Nothing else retires it: the edit changes the
digest, so the corrected turn is written as a new document and the branch filter
is the only thing that could have dropped the old one.

Trim both ends rather than only the right. Keeping leading whitespace looks
tighter and is worse, since render_turn strips the whole message and a live turn
opening on a space or a newline then starts its run at a non-zero offset and is
retired outright. Measured: '   hello there' and a pasted block opening on a
newline both went from live to retired. Indentation-only edits stay tolerated in
both directions as a result, because probes are matched per line and as
substrings; closing that needs the splitter to carry offsets.

Also fixes an unrelated retirement found while measuring this. When
render_turn's 4000 character cut lands exactly on a newline the marker becomes a
line of its own, strips to empty, and was dropped along with the truncation flag,
so the last real probe read as complete and an unedited over-cap tool result was
retired. Measured on a 900 line result: no query could return it.

Note for anyone testing an upgrade in place: boundary anchors written by an
earlier build of this branch were stored folded, so they will not match until the
next compaction rewrites them. The anchor can only ever move the boundary
shallower, so the cost is one extra compaction.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Replay tool rounds the way the serializer does, and stop colliding fallback ordinals

Two fixes to how an agent turn is rebuilt and numbered.

_as_wire collected every tool-call part of a persisted row into one assistant
message and appended every result after it. The replay serializer flushes the
pending calls whenever text arrives, so a row holding two sequential rounds goes
out as call/result, then the text riding on the second call message, then its
result. Rebuilding a different order made group_turns glue exchanges that were
separate on the wire, and the later calls matched no position and took an
invented ordinal. Parts are now replayed in order with the same flush rule,
which also subsumes the earlier special case for text said before the first call.

Seats are transcript positions while next_archive_ordinal counts what has been
archived, and the newest user group is protected from eviction, so during a tool
loop it is in the transcript and not in the archive. A tool group evicted before
its assistant row is persisted matched no seat and took the archive's next
number, which the user turn then claimed from the transcript. Measured: both
documents on ordinal 0, and since created_at breaks the tie the tool answer
rendered ahead of the prompt that caused it, under the header saying a higher
number was said later. The fallback now takes the transcript length as a floor;
a gap in the numbering costs nothing, since ordinals only have to order.

* Match the case-preserving probe text in the tool round assertions

The archive branch stopped folding case in _normalise, so these assertions,
written against the lowercased form, no longer matched. Test-only.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Recognise a unicode ellipsis, fetch both ends for a bare identifier, keep empty tool results

Three fixes.

Every editor and phone keyboard autocorrects '...' to one ellipsis character,
and it survived the punctuation strip, so 'continue...' was called substantive,
the anchor was dropped, and recall searched the archive for the word 'continue'.

A query made only of an identifier shapes to a single expression, because its
focused and permissive spellings coincide, and the single-expression path took
the plain one-ended fetch. That is the query most likely to tie on the IDF floor,
so it got the oldest rows and the newest assignment was never a candidate.
Measured on an archive of cap plus 40 turns: the recall answered with old notes
instead of the current value. Only the kill switch takes the plain path now, and
one expression returns the filter pass directly rather than running it twice.

serializeToolResultPart skips exactly undefined and null and emits a tool message
for everything else, a {"result": ""} sentinel for an empty string since the
ChatMessage validator rejects empty tool content, and JSON for containers.
Treating empty results as absent dropped a message the wire carries, so the
reconstructed run was shorter than the archived one and branch validation could
filter the turn out of every recall.

* Let search_conversation through the Anthropic gate, and probe a tool call before its answer

Adding the schema to ALL_TOOLS made the Anthropic selector pick it while
_ANTHROPIC_UNPROMPTED_SAFE_TOOLS still listed only web_search and
search_knowledge_base, so the pre-switch guard classified a read-only tool as
confirmation-gated. Measured: enable_tools with enabled_tools
['search_conversation'] returned 400 on auto and on the omitted default, with
the terminal/python message, even though is_potentially_unsafe_tool_call marks
it always safe and that set is documented as mirroring it. Pre-PR the same
request was served, so this is a regression the PR introduced.

Separately, _probe_text bucketed a whole message as call, text, result, which
only holds while the text came before the call. A persisted row whose tool call
is followed by the model's final answer, the ordinary agent turn, renders as
call, result, answer, so _scan_probes advanced past the answer to find the
result and could not find it again. Measured end to end on the persisted-row
transcript: recall returned the user's question alone and the document holding
the answer was filtered out. The buckets now flush when text arrives after a
call, the same rule the replay serializer uses; text written before a call still
rides ahead of it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Write the empty tool result sentinel with JavaScript's separators

json.dumps puts a space after the colon and JSON.stringify does not, so the
reconstructed message read {"result": ""} where the archived one reads
{"result":""}. Every comparison downstream is exact, _occurrences with ==
and _scan_probes with find, and _normalise collapses whitespace runs rather
than a single space after a colon, so the message was emitted and the match
still failed. That is worse than not emitting it, because it looks fixed.

Containers take the same separators, which also closes the pre-existing
divergence on a non-empty object. The test now asserts the exact bytes and adds
an end to end check that the reconstructed row matches the document archived
from the request.

* Record the transcript span, and price the recall budget exactly when nothing was trimmed

archive_messages bounds the branch check's run, and it was recording the number
of messages ARCHIVED rather than the span of the turn. The two differ whenever
_archivable drops something: an assistant batch that called search_conversation
alongside an ordinary tool archives three messages while the live transcript
holds four. Measured, validation failed at three and passed at four, so a
perfectly valid ordinary-tool exchange and the answer that followed were
rejected as off-branch and could never be recalled.

fit_rolling_context returns None when it drops nothing, so a prompt that simply
fits, after a context-length increase or on a shorter branch, left the recall
budget to a character estimate that cannot see the template's own framing.
Measured on a request whose real prompt is about 2800 tokens of a 3584 budget:
the budget came back 3512, nearly the whole window, so the recall could append a
passage that does not fit and the next iteration cannot evict it again, since the
current tool exchange is protected. Priced exactly instead, once per request and
only when the model actually reaches for a retrieval tool on a request that did
not truncate. A failure falls back to the old estimate.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Replay builtin cards and sandbox wrappers the way the frontend does, and seed the branch in order

Three fixes to reconstructing a persisted row.

A provider-side builtin is not replayed as an ordinary call. Without a native
part the frontend omits it entirely, call and result; with one it replays the
call and no tool message, since the result travels in the provider's own part.
Reconstructing either as a local call inserted an exchange the request never
carried, so the turn matched nothing and took a fallback ordinal. Both signals
ride on the persisted args, so the same predicate is decidable here. The name
alone never decides, so a user function called web_search is untouched.

python and terminal results are wrapped in text, images, sessionId and files on
every call, and the replay adapter sends result.text alone rather than feeding
the model a session id and file metadata. Serialising the whole wrapper built a
tool message that can never equal the archived one. Both of the frontend's gates
are mirrored, the shape and the tool name, so a third-party result that merely
has text and a session keeps every field it returned.

_branch_seed scored leaves by set intersection, which loses repetition and
ordering, and leaves are tried newest-first. An abandoned sibling holding the
same distinct texts tied and won, so a turn took the seat belonging to its
earlier twin and two distinct turns claimed one seat. Scored as an in-order run
instead; a multiset would fix the repeat case and not the reordered one.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Price the recall result with the model tokenizer, recount every search, and anchor on tool calls

Count the search result with the caller's own token counter instead of the ASCII
heuristic, recompute the exact prompt count on every search rather than once per
turn, and take the boundary anchor from a message's tool calls when it has no text,
so a tool-call message no longer records an empty anchor and disables the rebase.

* Mirror the serializer's tool-round flushes and keep the transcript span on copies

Drop a tool call the replay serializer drops, flush a completed local pair and a new
local round the way it does, and pass the transcript span through to the top-up write
so a copy is not bounded by the shorter archived group.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Widen a reused turn's window to its longest span, and price every search exactly

A three-message tool exchange and a four-message batch holding the same exchange render
identically, so the second is skipped as a duplicate and inherited the shorter window,
which the branch check then used to reject it. Grow the stored span instead, upwards
only. Count the prompt exactly on every conversation search rather than only when the
fit dropped nothing.

* Give a tool turn with a preamble its transcript seat

The stored probe carries both JSON spellings of the call arguments, and the second one
lands between the arguments and any text that followed them, so the live render stops
being contiguous inside it. Match the needle in two pieces when it does not fit whole,
which tolerates exactly that one insertion.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Let an overlapping chunk restart far enough back to cover its carried messages

A chunk's leading overlap is the previous chunk's tail, and it can carry a whole short
message with it, so resuming the scan at the message the previous chunk finished in
could never match it and retired unedited turns as off-branch. The forward position is
still tried first and the walk back stops at the previous chunk's own opening.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Reconstruct a reasoning turn without its thinking, and serialise Unicode like JSON.stringify

Reasoning lives in reasoning_content on the wire and never in content, so forwarding
the stored part list rendered a reasoning model's thinking where the request sends only
the answer. And json.dumps escapes non-ASCII by default while JSON.stringify does not,
so a multilingual tool result reconstructed differently from the archived copy. Both
cost the turn its transcript seat.

* Widen the span on the locked duplicate path as well

Both turns can arrive in one compaction: the pre-check clears both before either is
written, the shorter is written first, and the longer then met the re-check under the
write lock and left the window at the shorter figure.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten comments across the conversation recency changes

* Bound the persisted anchor, and spend any room at all on one recall attempt

The anchor rides in every truncation event and every assistant turn's metadata while the
boundary stays sticky, so a large pasted message was copied across the thread; cap it at
a head on both sides, where the read side already clamps only shallower. And a budget
below one chunk is not no budget: CHUNK_TOKENS is a ceiling, not the size of a turn, so
try one and let the exact recount reject it.

* Retire repeats down to the live-aware budget on the already-archived path

A rewind or a bigger window can put an evicted occurrence back in the prompt, and
re-stamping a copy per transcript seat then left two byte-identical documents, so a
recall slot went on text the model could already read.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Break two-ended fetch ties on a legacy archive

Every ordinal is NULL there, so both halves ordered by nothing but the score and
returned the same rows, which the merge then deduplicated into one end's worth of
candidates. Order by created_at in each direction, with the chunk id as a stable last
resort.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: alkinun <alkinunl@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-19 17:50:25 -07:00
..
__init__.py feat(studio): training history persistence and past runs viewer (#4501) 2026-03-25 00:58:55 -07:00
credential_secrets.py Unsloth Studio: add ChatGPT subscription chat with Codex tools (#8511) 2026-08-12 14:26:33 +02:00
mcp_servers_db.py Reduce and tighten code comments and docstrings repo-wide (#6095) 2026-06-08 23:09:51 -07:00
profile_stats_db.py Studio: profile usage stats (#7593) 2026-07-29 08:02:14 -07:00
providers_db.py Studio: allow max output overrides for custom providers (#8512) 2026-08-13 04:22:31 -07:00
rag_db.py Studio: recall the latest version of a fact, not the most quotable one (#9161) 2026-08-19 17:50:25 -07:00
research_runs_db.py studio: fix deep research progress reporting, loopback auth and model retries (#8129) 2026-08-07 23:34:38 -07:00
studio_db.py Studio: keep and search the turns rolling context evicts (#9074) 2026-08-19 17:40:23 -07:00