19 KiB
Spec: per-bullet ref mapping in prune summaries
Date: 2026-07-04
Branch: per-bullet-refs
Issue: #2 (Per-bullet ref mapping in prune summaries (recovery UX))
Problem
A prune summary lists its recovery refs as a flat footer appended by
formatSummaryToolCallRefs (src/summary-refs.ts:46):
---
**Summarized tool refs**: `t12`, `t3`, `t7`
Use `context_tree_query` with these refs to retrieve the original full outputs.
The refs are positional against the summarized batch's tool-call order, but no
per-bullet body carries its own ref. When the model wants to recover one tool's
raw output it must guess which footer ref maps to the bullet it just read, then
call context_tree_query. The mapping exists deterministically in the code
(both the serializer and the ref allocator iterate the same post-dedup
batch.toolCalls in the same order) but is never surfaced onto the bullet, so
the one datum that would make recovery a single hop is discarded at render time.
The roast on issue #2 (two reviewers) killed the originally-proposed
context_search FTS tool and converged on this 1:1 bullet->ref mapping as the
only real gap: "put the ref on the bullet, not build a search tool." The roast
rationale, inlined so an implementer needs no issue access: a search tool adds a
new tool surface, spill/sidecar coverage, and match-cap handling for payoff a
deterministic per-bullet ref already delivers; the model is not ref-blind (the
prompt already orders paths/ids/errors into the body and refs are in-context) -
the fix is to attach the ref to the bullet. No new tool, no index.
Goals
- Each per-tool block in a per-batch prune summary carries its own recovery ref
inline (
`t12`), so the model recovers a specific tool's raw output in one hop instead of eyeballing a flat footer. - The existing footer stays verbatim as an always-correct fallback (satisfies issue #2 AC "footer preserved").
- Deterministic mapping: number->shortId is pure index arithmetic on the shared
batch.toolCallsorder; the LLM only copies a label, it never invents a ref. - Graceful degradation: any missing, malformed, out-of-range, or tool-name-mismatched label leaves the affected bullet ref-less (footer still covers it) rather than emitting a broken or confidently wrong token.
Non-goals
- No
context_search/ FTS / recovery-search tool. Killed in the roast; explicitly out of scope. - No change to ref allocation, shortId format, or
context_tree_query.buildShortToolCallRefs(t${startIndex+offset}) and the indexer'sallocateSummaryRefsare untouched. - No new config knob. Decoration is unconditional; there is no opt-out. Rationale: it strictly improves recovery UX, degrades to today's footer when the LLM omits a label, so a knob gating an unconditional-improvement would be dead weight (YAGNI).
- No separate range-summary implementation. Range fusion inherits inline refs for free (see "Range-summary propagation"); we add no code there beyond a confirming test.
Mechanism
The change rests on an ordering invariant that already holds end-to-end:
serializeBatchForSummarizer (src/batch-capture.ts:147) and
allocateSummaryRefs (src/indexer.ts, via buildShortToolCallRefs) both
iterate the same post-dedup batch.toolCalls array in the same order.
So the serialized tool at 1-based position N maps deterministically to
refs[N-1].shortId. Two transforms that could have desynced it do not:
- Dedup rewrites
batches[i] = { ...batch, toolCalls: remaining }inindex.tsbefore both serialize and allocate run, so both observe the identical filtered array. - agent-message merge (
groupBatchesByMode,src/batch-capture.ts) concatenatestoolCallsin order into oneCapturedBatch; refs are allocated over that merged array. Numbering stays 1..N over the merged whole.
Touch point 1 - label the serialization (src/batch-capture.ts)
In serializeBatchForSummarizer, prepend [[N:toolname]] (double-bracket
label carrying position and tool name + one space) immediately before
Tool: at the start of each tool block, so the model can copy (not count) it.
N = index + 1 from the existing batch.toolCalls.map((tc, index) => ...) loop;
toolname is tc.toolName (already in scope). The name is carried so the
substitution step can validate the model's echoed label against the actual
tool at that position (see Touch point 3). Nothing else in the block changes.
Concrete before/after (block for the first tool):
Before:
Tool: read({"path":"a.ts"})
Result (OK): ...
After:
[[1:read]] Tool: read({"path":"a.ts"})
Result (OK): ...
The change to the returned block string is exactly:
return `[[${index + 1}:${tc.toolName}]] Tool: ${tc.toolName}(${argsJson})\nResult (${status}): ${resultText}`;
Touch point 2 - one instruction line (src/summarizer.ts)
SYSTEM_PROMPT is concatenated as the leading text of the single user message
(summarizeBatch: SYSTEM_PROMPT + "\n\n<tool-call-batch>\n" + serialized + ...;
there is no separate system role in this call path), so adding a line to the
constant lands it in the message the model reads. Append one paragraph to
SYSTEM_PROMPT after the existing final paragraph (Keep each tool call to 1-3 bullet points. ... Be concise.), and only there - RANGE_SYSTEM_PROMPT is
left untouched. Exact text to add:
Begin the first bullet of each tool call with that tool's
[[N:toolname]]label, copied verbatim (both the number and the name) from its line in the input, as the plain, first thing on the line (no bold, backticks, or list numbering around it). Do not renumber, rename, or invent labels; if you skip a tool, skip its label too.
"Copy the label" is a far more reliable ask than "count correctly": if the model skips a tool (the prompt already says "Skip calls that succeeded with nothing reusable to record"), copying keeps every emitted label pointing at the right source, whereas a running counter would silently shift.
Touch point 3 - substitute inline (src/summary-refs.ts + index.ts)
New pure function in src/summary-refs.ts:
export function substituteInlineRefs(
text: string,
refs: SummaryToolCallRef[],
toolNames: string[],
): string
It rewrites line-leading [[N:name]] labels to the backtick-wrapped shortId
using refs[N-1], after validating that the echoed name matches
toolNames[N-1] (comparison is trimmed + case-insensitive). refs and
toolNames are positionally aligned to the same batch.toolCalls order
(refs from buildShortToolCallRefs(batch.toolCalls.map(tc => tc.toolCallId)),
toolNames from batch.toolCalls.map(tc => tc.toolName) - the exact array
makeSummaryDetails already builds). SummaryToolCallRef carries no tool name,
so toolNames is passed as a third argument rather than read off refs.
Note SummaryToolCallRef.shortId already includes the t prefix
(buildShortToolCallRefs returns shortId: `${SHORT_ID_PREFIX}${...}`, e.g.
"t12"), so the replacement wraps shortId verbatim - it does not prepend
another t. The emitted token is `t12`, never `tt12`.
Applied at the single existing call site in index.ts (currently line 389-390),
between allocation and footer append. toolNames come from the same batch,
keeping the two arrays aligned:
const summaryRefs = indexer.allocateSummaryRefs(batch);
const toolNames = batch.toolCalls.map((tc) => tc.toolName);
const decorated = substituteInlineRefs(result.summaryText, summaryRefs, toolNames);
const summaryText = decorated + formatSummaryToolCallRefs(summaryRefs);
decorated is the body with inline refs; summaryText is decorated plus the
unchanged footer. shouldSkipOversized = summaryText.length > batchRawCharCount
is computed on summaryText; substituting [[9:read]] -> `t12` is roughly
length-neutral and does not meaningfully move the oversize guard.
Marker choice and substitution rules (load-bearing)
The marker is [[N:name]] (double square bracket, N = decimal digits, name
= the tool name), matched only as the first non-whitespace token of a line and
outside fenced code blocks, via:
/^(\s*(?:[-*]\s+)?)\[\[(\d+):([^\]\n]+)\]\]\s*/gm
(capture 3 = echoed tool name, validated against toolNames[N-1]). The name
capture is [^\]\n]+ (any run of non-], non-newline chars, stopping before
the closing ]]) rather than a restricted [A-Za-z0-9_-] class so the
degradation contract holds for every tool name: a namespaced/dotted tool
(server.tool, a:b) still produces a matching label that is then validated
and, on mismatch or out-of-range, stripped. A narrower class would fail to match
such a label at all - leaving raw [[N:name]] in the body (fail-open), which
contradicts "any malformed/mismatched label degrades to footer-only". Trailing
whitespace after ]] is consumed with \s* (not \s?) so the substitution
normalizes to exactly one separating space regardless of how many spaces the
model emitted.
processed line-by-line while tracking a fenced-code toggle (a line whose trimmed
form starts with ``` flips "inside fence"; lines inside a fence are
never substituted). Guards protecting the verbatim content the prompt is
explicitly told to copy (argv[1], items[0], file paths, error strings, quoted
tool output):
- Double brackets are rare in copied code/prose/errors; single
[1](the common array-index shape) never matches. Obsidian-style[[Page]]wikilinks are non-numeric and never match\d+. - Line-start anchor (
^with themflag, allowing leading whitespace and an optional-/*bullet marker) means a mid-line[[x]]-lookalike is left untouched. - Fenced-code exclusion shields the realistic false-positive vector: a
verbatim-copied log or serialized block whose line happens to start with
[[<digits>]].
Substitution semantics, per matched label with captured number N and echoed
name (capture 1 = leading whitespace + optional bullet prefix; capture 2 =
digits; capture 3 = echoed tool name). The name check runs first:
| Case | Action |
|---|---|
1 <= N <= refs.length and echoed name == toolNames[N-1] (trimmed, case-insensitive) |
Replace the whole match with $1`SHORTID` - the captured prefix, then the backtick-wrapped refs[N-1].shortId, then one space separator (so - [[1:read]] Read becomes - `t1` Read, never - `t1`Read). shortId is emitted as-is (no extra t) |
1 <= N <= refs.length but echoed name != toolNames[N-1] |
Mismatch -> strip the whole label (prefix re-emitted, no ref); the model desynced, so footer-only is the safe outcome instead of a confident wrong ref |
N out of range (< 1 or > refs.length) |
Strip the label, re-emitting the captured prefix only (no dangling token); footer still covers that tool |
N unparseable |
Cannot occur - regex captures \d+ only; non-numeric never matches and is left verbatim |
| Label absent on a line | No-op for that block; it renders ref-less, footer covers it |
Same [[N:name]] on multiple lines of one tool |
Each occurrence validated + substituted to the same `tN` (harmless, mildly helpful) |
Label wrapped/formatted or non-standard bullet (**[[1:read]]**, `[[1:read]]`, 1. [[1:read]], > [[1:read]]) |
The anchored substitution does not match, so no inline ref is produced. A second catch-all strip pass (below) then removes the raw [[N:name]] token from the line, leaving footer-only recovery with no internal syntax leaked into context. |
Catch-all strip (leak guard). After the anchored substitution, every
non-fenced line is swept once more with /\[\[\d+:[^\]\n]+\]\]\s*/g and any
surviving well-formed label token is deleted. This guarantees the invariant
"the feature never injects a raw [[N:name]] token into LLM context" even when
the model ignores the prompt and wraps/renumbers its label (the anchored pass
recovers the ref for compliant -/* bullets; the strip pass discards an
unrecoverable label rather than leaking it). It runs inside the same fence-aware
line loop, so labels inside fenced code blocks are still never touched. It
cannot hit mid-line lookalikes (argv[1] is a single bracket; [[Page]]
wikilinks have no :digits).
The footer from formatSummaryToolCallRefs is appended unchanged in every case,
so recovery is never worse than today.
Why the name tag (mis-copy mitigation)
The number->shortId map is deterministic, but the number->bullet association is
the model's; a bare number could be copied onto the wrong bullet (typically the
model renumbering after skipping a tool) and substitute a confidently wrong
`tX`. Echoing the tool name in the label and validating it against the
known tool at position N converts that failure into a detected mismatch that
degrades to footer-only. This catches the dominant desync - a skip-induced
renumber lands [[2:read]] where position 2 is a bash, mismatch, strip. It is
not the earlier-rejected prose cross-check (parsing the bullet's free text for
a tool name, which is unreliable); it validates the model's own echoed tag, a
machine-exact token.
Residual risk (accepted)
- Same-type swap. Two calls of the same tool (two
reads) with their labels swapped both pass the name check, so a swapped`tX`still points at a same-kind call (wrong file). Rarer than cross-type renumber and lower-harm; catching it would need a content discriminator in the label ([[1:read:a.ts]]), forcing the model to copy a path verbatim - more error-prone and heavier, so out of scope. Footer still lists every ref. - Non-fenced line-start lookalike. A verbatim-copied non-fenced line that
begins with
[[<digits>:<word>]]can be mis-handled (substituted if it happens to name-match, else stripped). Judged vanishingly rare given the double-bracket +N:nameshape + line-start + fenced guards; accepted rather than parsing summary prose further. - Non-fenced mid-line label. The catch-all strip also removes a well-formed
[[<digits>:<name>]]token appearing mid-line in non-fenced prose (not just at line start). This is the intended leak-guard behavior; a verbatim-copied prose sentence that contains that exact token shape mid-line is vanishingly rare and accepted. - No flush-path integration test. The
index.tsflush pipeline (allocateSummaryRefs->substituteInlineRefs-> footer) has no isolated unit harness, so the positional alignment betweenrefsandtoolNamesis covered by the pure-function + composition unit tests, not an end-to-end stubbed-summarizer test. Accepted: building aflushPendingharness is disproportionate to a five-line wiring change whose invariant is verified by readingallocateSummaryRefs(both arrays map the samebatch.toolCalls).
Range-summary propagation (inherited, not implemented)
RANGE_SYSTEM_PROMPT (src/summarizer.ts) already instructs: "Keep any
reference tokens like `t12` or `b3` intact." Chain range fusion
(summarizeRange) consumes the stored per-batch summary text, which by then
already has inline `tX` refs substituted in. So inline refs propagate
when present in the per-batch input with no additional code - subject to the
same LLM-compliance caveat as any ref token in the range prompt (the fusion
model is instructed to keep them but is not forced to). No production change in
the range path; covered by a confirming test asserting the fusion input text
carries the inline ref (the fusion output is mocked in existing tests, so
asserting against it would be circular).
Testing approach
Tests follow the repo convention (src/*.test.ts, run by bun test src/). Two
new files, since neither exists today:
New file src/summary-refs.test.ts - substituteInlineRefs(text, refs, toolNames):
- in-range + name-match replaces
[[1:read]]/[[2:bash]]with the right shortIds (asserting the exact`t1`token, nott1); - name mismatch (
[[2:read]]wheretoolNames[1] === "bash") stripped, no ref emitted (the core mis-copy-mitigation case); - name-match is trimmed + case-insensitive (
[[1:Read]]vsread-> match); - out-of-range
[[9:read]](only 2 refs) stripped, leaving the bullet prefix and no dangling token; - absent label -> text unchanged;
- duplicate
[[2:bash]]on two lines both substituted; - separator preserved:
- [[1:read]] Read->- `t1` Read(single space, not glued); - left untouched: mid-line
argv[1], a mid-line[[1:read]]-lookalike, a line-start[[1:read]]inside a fenced code block, and a wrapped**[[1:read]]**.
New file src/batch-capture.test.ts - serializeBatchForSummarizer:
- emits
[[1:name]]..[[N:name]](correct position and tool name) immediately before eachTool:in tool order; - numbering stays 1..N over a dedup-filtered batch (fewer tool calls, still contiguous from 1);
- numbering stays 1..N over an agent-message-merged batch (concatenated
toolCallsviagroupBatchesByMode).
Composition (unit, in src/summary-refs.test.ts) - no index.ts/flushPending
integration harness exists, so the no-drift check is a pure composition of the
two helpers over one SummaryToolCallRef[]:
substituteInlineRefs(body, refs, toolNames)inline refs andformatSummaryToolCallRefs(refs)footer refs reference the same shortIds for the same positions.- A per-batch body carrying an inline
`tX`still contains that token after passing through the range-fusion input assembly (asserted on the input, per the range-propagation note).
Documentation impact
- Feature / user-facing docs introduced: none (behavioral refinement of an existing output; no new command, setting, or surface).
- Materially amended existing docs:
PRUNING.md- the "How the Model Re-reads Raw Outputs" section (line ~284) and the recovery ASCII ("RECOVERING PRUNED DATA", line ~209) gain a short note that per-tool bullets now carry an inline`tX`ref (footer retained as always-correct fallback).README.md- thecontext_tree_queryparagraph (line 195) currently describes only the footer ("Pruned summaries end with short refs likeSummarized tool refs: ..."); amended in the same commit to add that each per-tool bullet also carries its own inline ref. (Checked now: this is the only summary-format description inREADME.md; no code-block sample to update.)
- Derived / memory docs invalidated: none (no router, AGENTS.md section, or
index changes; the
context-prune-summarycustomType row inAGENTS.mdstill accurately describes what the entry is).
Acceptance criteria (from issue #2)
- A multi-tool prune summary shows, per tool block, either an inline ref or a
stable positional number->ref mapping. Met by inline
`tX`substitution. - The flat footer ref list is preserved. Met -
formatSummaryToolCallRefsoutput is appended unchanged. - No new tool, config knob, or index is added. Met - three edits to existing functions plus one new pure helper; no config, no tool, no index.
- Degrades safely when the LLM omits/garbles a label. Met - name-mismatch and out-of-range stripped, absent label -> footer-only, mid-line lookalikes untouched. The name-tag validation additionally downgrades a confidently wrong in-range ref (the dominant desync) to footer-only.