Files
my-pi/pi-condense/doc/specs/2026-07-04-per-bullet-summary-refs.md
T

358 lines
19 KiB
Markdown

# 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.toolCalls` order; 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's
`allocateSummaryRefs` are 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 }` in
`index.ts` *before* both serialize and allocate run, so both observe the
identical filtered array.
- **agent-message merge** (`groupBatchesByMode`, `src/batch-capture.ts`)
concatenates `toolCalls` in order into one `CapturedBatch`; 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`:
```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:
```ts
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):
1. **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+`.
2. **Line-start anchor** (`^` with the `m` flag, allowing leading whitespace and
an optional `-`/`*` bullet marker) means a mid-line `[[x]]`-lookalike is left
untouched.
3. **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 `read`s) 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:name` shape + 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.ts` flush pipeline
(`allocateSummaryRefs` -> `substituteInlineRefs` -> footer) has no isolated
unit harness, so the positional alignment between `refs` and `toolNames` is
covered by the pure-function + composition unit tests, not an end-to-end
stubbed-summarizer test. Accepted: building a `flushPending` harness is
disproportionate to a five-line wiring change whose invariant is verified by
reading `allocateSummaryRefs` (both arrays map the same `batch.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, no `tt1`);
- **name mismatch** (`[[2:read]]` where `toolNames[1] === "bash"`) stripped, no
ref emitted (the core mis-copy-mitigation case);
- name-match is trimmed + case-insensitive (`[[1:Read]]` vs `read` -> 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 each `Tool:` 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
`toolCalls` via `groupBatchesByMode`).
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 and
`formatSummaryToolCallRefs(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` - the `context_tree_query` paragraph (line 195) currently
describes only the footer ("Pruned summaries end with short refs like
`Summarized 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 in `README.md`; no code-block sample to
update.)
- Derived / memory docs invalidated: none (no router, AGENTS.md section, or
index changes; the `context-prune-summary` customType row in `AGENTS.md`
still accurately describes what the entry is).
## Acceptance criteria (from issue #2)
1. 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.
2. The flat footer ref list is preserved. **Met** - `formatSummaryToolCallRefs`
output is appended unchanged.
3. 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.
4. 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.