pi-condense 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,10 @@ publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.1.0] - 2026-07-04
13
+
14
+ - **Per-bullet recovery refs in prune summaries** (closes #2). Each per-tool block in a summary now carries its own inline `` `tN` `` ref, so the model recovers a specific tool's raw output in one hop instead of guessing which flat-footer ref maps to which bullet. The serializer labels each tool block `[[N:toolname]]` (`src/batch-capture.ts`), the summarizer prompt tells the model to copy that label onto its first bullet (`src/summarizer.ts`), and `substituteInlineRefs` (`src/summary-refs.ts`) validates the echoed tool name against the tool at position N before rewriting to `` `tN` ``. The flat footer is retained unchanged as the always-correct fallback. Deterministic number->shortId map over the shared post-dedup `batch.toolCalls` order; the tool-name tag downgrades a confident wrong-ref (skip-induced renumber) to footer-only, and mismatched / out-of-range / wrapped / mid-line labels are stripped (fence-aware leak guard) so no raw `[[N:name]]` token ever leaks into context. No new tool, config key, or index.
15
+
12
16
  ## [2.0.1] - 2026-07-03
13
17
 
14
18
  - **Prune summaries are now hidden from Pi's main window** (`display: false` at both injection sites in `index.ts`). They stay in LLM context and session history (recoverable via `context_tree_query`) but no longer print the full markdown block into the TUI. Mirrors upstream `pi-context-prune` `2fd6127`.
package/PRUNING.md CHANGED
@@ -181,7 +181,7 @@ graph TB
181
181
  **Key points:**
182
182
 
183
183
  - The `AssistantMessage` tool-call blocks are **kept** (they carry the `toolCallId`s the model uses to reference originals via `context_tree_query`).
184
- - `ToolResultMessage` entries for summarized tool calls are **replaced with a small stub** (`[Summarized in pruner summary, ref \`tN\`. Use context_tree_query to retrieve full output.]`) carrying `role: "toolResult"`, the original `toolCallId`/`toolName`/`timestamp`, and `isError: false`. The stub preserves role alternation, so pi-ai's `transformMessages.insertSyntheticToolResults` no longer injects a synthetic `{ isError: true, "No result provided" }` for the (no-longer-)orphaned tool call. See [Stub-replace instead of delete](#stub-replace-instead-of-delete).
184
+ - `ToolResultMessage` entries for summarized tool calls are **replaced with a small stub** (`[Summarized in pruner summary, ref \`tN\`. Use context_tree_query to retrieve full output.]`) carrying`role: "toolResult"`, the original`toolCallId`/`toolName`/`timestamp`, and`isError: false`. The stub preserves role alternation, so pi-ai's`transformMessages.insertSyntheticToolResults` no longer injects a synthetic `{ isError: true, "No result provided" }` for the (no-longer-)orphaned tool call. See [Stub-replace instead of delete](#stub-replace-instead-of-delete).
185
185
  - Every pruned tool call is also copied into the pruner's runtime/session index with its `toolCallId`, tool name, args, status, turn index, timestamp, and full `resultText`.
186
186
  - A summary message is injected as a `"steer"` (`pi.sendMessage` runtime path) or appended directly via `sessionManager.appendCustomMessageEntry` (session path, used when Pi may already be shutting down). Both deliver before the next LLM call.
187
187
  - The session JSONL file retains the original tool-result entries unchanged — pruning only affects what the *next* request sees in active context.
@@ -210,6 +210,7 @@ That distinction is the core idea:
210
210
  ├─────────────────────────────────────────────────────────────────────────┤
211
211
  │ │
212
212
  │ [summary] ... build failed: circular dependency ... │
213
+ │ - `t1` read config.ts -> 3 exports │
213
214
  │ Summarized tool refs: `t1` │
214
215
  │ Use `context_tree_query` with these refs │
215
216
  │ │
@@ -286,7 +287,7 @@ So after pruning, the model is working with a **two-layer memory**:
286
287
  The intended recovery flow is:
287
288
 
288
289
  1. The model reads a summary message.
289
- 2. The summary lists the short refs (`t1`, `t2`, …) that were summarized.
290
+ 2. The summary lists the short refs (`t1`, `t2`, …) that were summarized. Each per-tool bullet also carries its own inline `` `tN` `` ref (copied from a `[[N:toolname]]` label the summarizer emits, validated against the tool at that position), so the model can jump from a bullet straight to its ref; the flat footer still lists every ref as a fallback.
290
291
  3. The model decides the summary is not enough and wants exact raw output.
291
292
  4. The model calls `context_tree_query({ toolCallIds: ["t1", ...] })`. The tool accepts short refs and full `toolCallId`s interchangeably (`indexer.resolveToolCallId`).
292
293
  5. The tool looks up those IDs in the pruner index.
@@ -598,6 +599,7 @@ The pruner now keeps the toolResult message but replaces its content with a smal
598
599
  ```
599
600
 
600
601
  Properties:
602
+
601
603
  - `role: "toolResult"` and the original `toolCallId` / `toolName` / `timestamp` are preserved — role alternation is intact; no synthetic-result injection.
602
604
  - `isError: false`, so the model does not interpret the stub as a tool failure.
603
605
  - The stub references the **short ref** (`t1`, `t2`, …) the indexer assigned at summary time. Legacy entries from before short-refs landed fall back to the raw `toolCallId`.
@@ -652,6 +654,7 @@ Set `minBatchChars: 0` to disable. The default `1000` skips obvious trivial batc
652
654
  `dedupByContentHash: boolean` (default `true`) catches re-reads of already-pruned tool outputs at zero LLM cost.
653
655
 
654
656
  Mechanism:
657
+
655
658
  1. When a batch enters `flushPending`, each tool call is hashed by `SHA-1(toolName + "\0" + normalize(resultText))`.
656
659
  2. The indexer's `contentHashToOriginal` map (populated by every earlier `addBatch` / `reconstructFromSession`) is consulted.
657
660
  3. A hit means an earlier prune already covered identical content. The duplicate is registered as an alias of the original via `indexer.registerDuplicate(newId, originalId, appendEntry)`:
@@ -702,12 +705,14 @@ Summarizing tool-call history is not just a hack — it is an active research ar
702
705
  SUPO integrates summarization directly into the RL training pipeline for tool-using agents. Instead of treating context compression as an afterthought, the policy gradient is derived to optimize **both** tool-use behavior **and** summarization strategy end-to-end.
703
706
 
704
707
  **Method:**
708
+
705
709
  - Periodically compresses tool-using history via LLM-generated summaries
706
710
  - Retains task-relevant information in compact form
707
711
  - Derives a policy gradient that lets standard LLM RL infrastructure optimize both behaviors simultaneously
708
712
  - Enables training beyond fixed context limits
709
713
 
710
714
  **Key results:**
715
+
711
716
  - Significantly improved success rate on interactive function calling and search tasks
712
717
  - **Same or lower working context length** compared to baselines that don't summarize
713
718
  - Test-time scaling: increasing the maximum summarization rounds during evaluation further improves performance
@@ -726,12 +731,14 @@ SUPO proves that summarization is not just about saving tokens — it actively *
726
731
  ReSum addresses the fundamental conflict between **exploration** (needing many tool calls) and **context limits** (fixed window size). Current agents append every thought/action/observation to history until they crash into the context ceiling.
727
732
 
728
733
  **Method:**
734
+
729
735
  - Periodically invokes an external **summary tool** to condense interaction history
730
736
  - The agent restarts reasoning from the compressed summary
731
737
  - Introduces **ReSum-GRPO**: adapts Group Relative Policy Optimization with **advantage broadcasting** — propagates final trajectory rewards across all segments so early exploration steps get proper credit
732
738
  - Trained a specialized **ReSumTool-30B** to extract key evidence and propose next steps
733
739
 
734
740
  **Key results:**
741
+
735
742
  - **4.5% improvement** over ReAct in training-free settings
736
743
  - **Further 8.2% gain** with ReSum-GRPO training
737
744
  - A 30B ReSum-enhanced agent with only 1K training samples achieves competitive performance with leading open-source models
@@ -751,6 +758,7 @@ ReSum validates the exact architecture `pi-condense` uses: an external summarize
751
758
  ACON is a unified framework that compresses **both** environment observations and interaction histories into "concise yet informative condensations." It treats compression as an optimization problem: maximize task reward while minimizing context cost.
752
759
 
753
760
  **Method:**
761
+
754
762
  - **Gradient-free** — uses natural language space optimization (no model fine-tuning)
755
763
  - **Failure-driven guideline optimization:** runs the agent with and without compression, collects cases where compression caused failure, and uses an optimizer LLM to refine compression guidelines
756
764
  - Two-step alternation:
@@ -759,6 +767,7 @@ ACON is a unified framework that compresses **both** environment observations an
759
767
  - **Distillation:** optimized compressor can be distilled into smaller models (e.g., Qwen-14B) with >95% accuracy retention
760
768
 
761
769
  **Key results:**
770
+
762
771
  - **26–54% reduction in peak tokens** across AppWorld, OfficeBench, and Multi-objective QA
763
772
  - Preserves task performance with large models
764
773
  - **Smaller LMs improve 20–46%** as agents when context compression removes distracting noise
@@ -782,6 +791,7 @@ Lineage: simplified take on DCP's `maxContextLimit` nudging — a single thresho
782
791
  Use case: a single enormous tool result can jump context usage by 20–30 percentage points in one turn; `autoBudgetThreshold` misses this until the next turn. `budgetTurnDelta` catches the spike immediately.
783
792
 
784
793
  **`previousFraction` tracking:**
794
+
785
795
  - Reset to `null` on `session_start` and `session_tree` (session reload).
786
796
  - Left unchanged on a null-tokens turn immediately following a provider-side compaction (treating a post-compaction null as `0` would produce a spurious spike on the next real turn).
787
797
  - The post-restart first turn cannot fire a delta trigger (no prior fraction to compare) and falls back to `autoBudgetThreshold` alone.
@@ -838,6 +848,7 @@ raw messages from session
838
848
  ### Identification model
839
849
 
840
850
  Pi-ai's `Message` union (`UserMessage | AssistantMessage | ToolResultMessage`) has no `.id` field. Chain compression uses:
851
+
841
852
  - `timestamp: number` to identify user / final-assistant boundary messages
842
853
  - `toolCallId` sets to identify middle assistant turns and their tool results
843
854
 
@@ -920,11 +931,13 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
920
931
  ```
921
932
 
922
933
  **What triggers a purge:**
934
+
923
935
  - The matching `ToolResultMessage` has `isError: true`.
924
936
  - The error occurred at least `purgeErrors.cooldownTurns` assistant turns ago (default 2). The cooldown gives the model 1–2 turns to retry before context is mutated.
925
937
  - The JSON-stringified argument body is at least `purgeErrors.minArgChars` characters long (default 500). Small args are not worth the substitution.
926
938
 
927
939
  **What error purge does NOT touch:**
940
+
928
941
  - The `ToolResultMessage` content — the error message stays visible so the model can see what went wrong.
929
942
  - Non-errored `toolCall` argument bodies.
930
943
  - Argument bodies below `minArgChars`.
@@ -959,6 +972,7 @@ Thinking strip is a deterministic, zero-LLM transform (Phase 4) that keeps `thin
959
972
  ### Provider safety
960
973
 
961
974
  Anthropic's extended-thinking contract during tool use:
975
+
962
976
  - Only the **last assistant turn's** thinking is required; "you can omit thinking blocks from prior assistant role turns" and the API auto-filters them.
963
977
  - A message's thinking blocks must be dropped **all-or-nothing** ("the entire sequence of consecutive thinking blocks must match the outputs … you can't rearrange or modify the sequence"). The strip reuses `withoutThinkingBlocks`, which removes every thinking block (and its signature) from a message.
964
978
 
package/README.md CHANGED
@@ -192,7 +192,7 @@ Set it from the slash command (saves immediately):
192
192
 
193
193
  ## Tools surfaced to the LLM
194
194
 
195
- **`context_tree_query`** — always available when the extension is loaded. Pruned summaries end with short refs like `Summarized tool refs: \`t1\`, \`t2\`. Use \`context_tree_query\` with these refs to retrieve the original full outputs.` The model passes those refs (or full `toolCallId`s) and gets back the original tool result text from the session index. Content-hash-deduped duplicates resolve to the original's record automatically.
195
+ **`context_tree_query`** — always available when the extension is loaded. Pruned summaries end with short refs like `Summarized tool refs: \`t1\`, \`t2\`. Use \`context_tree_query\` with these refs to retrieve the original full outputs.` The model passes those refs (or full `toolCallId`s) and gets back the original tool result text from the session index. Each per-tool bullet in the summary also carries its own inline `` `tN` `` ref, so recovering a specific tool is a single hop; the footer still lists every ref as a fallback. Content-hash-deduped duplicates resolve to the original's record automatically.
196
196
 
197
197
  ## Footer status widget
198
198
 
@@ -224,6 +224,7 @@ interface ExternalCostUpdate {
224
224
  ```
225
225
 
226
226
  Semantics:
227
+
227
228
  - **Cumulative per session**, not all-time. Re-emitted on every update; aggregators key by `source` and replace the previous value.
228
229
  - **Live only.** Not persisted; not re-emitted on `session_start`. An aggregator that restarts mid-session sees cost from zero until the next summarizer call.
229
230
  - Designed for aggregators like pi-cohort that show a unified Σ$ total across extensions.
package/index.ts CHANGED
@@ -22,7 +22,7 @@ import { pruneMessages } from "./src/pruner.js";
22
22
  import { isProtected } from "./src/protected.js";
23
23
  import { registerQueryTool } from "./src/query-tool.js";
24
24
  import { registerCommands, setPruneStatusWidget } from "./src/commands.js";
25
- import { formatSummaryToolCallRefs, makeSummaryDetails } from "./src/summary-refs.js";
25
+ import { formatSummaryToolCallRefs, makeSummaryDetails, substituteInlineRefs } from "./src/summary-refs.js";
26
26
  import type { ContextPruneConfig, CapturedBatch, PruneFrontier, FlushOptions } from "./src/types.js";
27
27
  import {
28
28
  DEFAULT_CONFIG,
@@ -166,7 +166,7 @@ export default function (pi: ExtensionAPI) {
166
166
 
167
167
  // Use pre-captured batches if provided (avoids double-capture when the
168
168
  // caller previewed the queue before opening the progress overlay).
169
- let batches: CapturedBatch[] = options.previewedBatches ?? capturePendingBatches(ctx);
169
+ const batches: CapturedBatch[] = options.previewedBatches ?? capturePendingBatches(ctx);
170
170
 
171
171
  if (batches.length === 0) return { ok: false, reason: "empty" };
172
172
 
@@ -387,7 +387,9 @@ export default function (pi: ExtensionAPI) {
387
387
  }
388
388
 
389
389
  const summaryRefs = indexer.allocateSummaryRefs(batch);
390
- const summaryText = result.summaryText + formatSummaryToolCallRefs(summaryRefs);
390
+ const toolNames = batch.toolCalls.map((tc) => tc.toolName);
391
+ const decorated = substituteInlineRefs(result.summaryText, summaryRefs, toolNames);
392
+ const summaryText = decorated + formatSummaryToolCallRefs(summaryRefs);
391
393
  const shouldSkipOversized = summaryText.length > batchRawCharCount;
392
394
 
393
395
  statsAccum.add(result.usage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Pi extension that summarizes completed tool-call batches, replaces raw outputs with short stubs in future context, and recovers any original on demand via context_tree_query.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -34,6 +34,7 @@
34
34
  "PRUNING.md"
35
35
  ],
36
36
  "scripts": {
37
+ "check:agents-core": "node scripts/check-agents-core.mjs",
37
38
  "test": "bun test src/"
38
39
  },
39
40
  "pi": {
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { serializeBatchForSummarizer } from "./batch-capture.js";
3
+ import type { CapturedBatch, CapturedToolCall } from "./types.js";
4
+
5
+ function toolCall(overrides: Partial<CapturedToolCall> = {}): CapturedToolCall {
6
+ return {
7
+ toolCallId: "id",
8
+ toolName: "read",
9
+ args: {},
10
+ resultText: "ok",
11
+ isError: false,
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ function batch(toolCalls: CapturedToolCall[]): CapturedBatch {
17
+ return {
18
+ turnIndex: 0,
19
+ timestamp: 0,
20
+ assistantText: "",
21
+ toolCalls,
22
+ };
23
+ }
24
+
25
+ describe("serializeBatchForSummarizer", () => {
26
+ test("prefixes each tool block with [[N:toolname]] in order", () => {
27
+ const b = batch([
28
+ toolCall({ toolCallId: "a", toolName: "read" }),
29
+ toolCall({ toolCallId: "b", toolName: "bash" }),
30
+ ]);
31
+
32
+ const result = serializeBatchForSummarizer(b);
33
+
34
+ expect(result).toContain("[[1:read]] Tool: read(");
35
+ expect(result).toContain("[[2:bash]] Tool: bash(");
36
+ });
37
+
38
+ test("numbering is contiguous 1..N regardless of toolCallId values", () => {
39
+ const b = batch([
40
+ toolCall({ toolCallId: "zzz", toolName: "read" }),
41
+ toolCall({ toolCallId: "aaa", toolName: "read" }),
42
+ toolCall({ toolCallId: "mmm", toolName: "write" }),
43
+ ]);
44
+
45
+ const result = serializeBatchForSummarizer(b);
46
+
47
+ expect(result).toContain("[[1:read]] Tool:");
48
+ expect(result).toContain("[[2:read]] Tool:");
49
+ expect(result).toContain("[[3:write]] Tool:");
50
+ });
51
+ });
@@ -151,7 +151,7 @@ export function serializeBatchForSummarizer(batch: CapturedBatch): string {
151
151
  parts.push(`Assistant said: ${batch.assistantText}\n`);
152
152
  }
153
153
 
154
- const toolParts = batch.toolCalls.map((tc) => {
154
+ const toolParts = batch.toolCalls.map((tc, index) => {
155
155
  const status = tc.isError ? "ERROR" : "OK";
156
156
  const argsJson = JSON.stringify(tc.args, null, 2);
157
157
 
@@ -162,7 +162,7 @@ export function serializeBatchForSummarizer(batch: CapturedBatch): string {
162
162
  resultText = resultText.slice(0, MAX_CHARS) + ` ...[${remaining} chars truncated]`;
163
163
  }
164
164
 
165
- return `Tool: ${tc.toolName}(${argsJson})\nResult (${status}): ${resultText}`;
165
+ return `[[${index + 1}:${tc.toolName}]] Tool: ${tc.toolName}(${argsJson})\nResult (${status}): ${resultText}`;
166
166
  });
167
167
 
168
168
  parts.push(toolParts.join("\n---\n"));
package/src/summarizer.ts CHANGED
@@ -17,7 +17,9 @@ For each tool call provide:
17
17
  - Key outcome, plus any file paths, identifiers, signatures, or error strings copied verbatim - never reword these
18
18
  - Any findings the future conversation needs to remember
19
19
 
20
- Keep each tool call to 1-3 bullet points. Skip calls that succeeded with nothing reusable to record. Be concise.`;
20
+ Keep each tool call to 1-3 bullet points. Skip calls that succeeded with nothing reusable to record. Be concise.
21
+
22
+ 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.`;
21
23
 
22
24
  const RANGE_SYSTEM_PROMPT = `You are fusing several per-step summaries of one CLOSED sub-task from an AI coding assistant's history into a SINGLE cohesive summary.
23
25
  - Merge overlapping or repeated information; do not restate each step separately.
@@ -0,0 +1,122 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { substituteInlineRefs, formatSummaryToolCallRefs, type SummaryToolCallRef } from "./summary-refs.js";
3
+
4
+ describe("substituteInlineRefs", () => {
5
+ const refs: SummaryToolCallRef[] = [
6
+ { shortId: "t1", toolCallId: "a" },
7
+ { shortId: "t2", toolCallId: "b" },
8
+ ];
9
+ const names = ["read", "bash"];
10
+
11
+ test("in-range and matching name rewrites to inline ref", () => {
12
+ expect(substituteInlineRefs("- [[1:read]] Read a.ts", refs, names)).toBe("- `t1` Read a.ts");
13
+ expect(substituteInlineRefs("- [[2:bash]] ran ls", refs, names)).toBe("- `t2` ran ls");
14
+ });
15
+
16
+ test("does not add extra t prefix", () => {
17
+ const out = substituteInlineRefs("[[1:read]] x", refs, names);
18
+ expect(out).toBe("`t1` x");
19
+ expect(out).not.toContain("tt1");
20
+ });
21
+
22
+ test("name match is trimmed and case-insensitive", () => {
23
+ expect(substituteInlineRefs("- [[1:Read]] x", refs, names)).toBe("- `t1` x");
24
+ });
25
+
26
+ test("name mismatch strips label", () => {
27
+ expect(substituteInlineRefs("- [[2:read]] x", refs, names)).toBe("- x");
28
+ });
29
+
30
+ test("out of range strips label", () => {
31
+ expect(substituteInlineRefs("- [[9:read]] x", refs, names)).toBe("- x");
32
+ });
33
+
34
+ test("absent label leaves line unchanged", () => {
35
+ expect(substituteInlineRefs("- plain bullet", refs, names)).toBe("- plain bullet");
36
+ });
37
+
38
+ test("duplicate labels on separate lines both substituted", () => {
39
+ expect(substituteInlineRefs("- [[2:bash]] first\n- [[2:bash]] second", refs, names)).toBe(
40
+ "- `t2` first\n- `t2` second",
41
+ );
42
+ });
43
+
44
+ test("single-bracket lookalikes are untouched", () => {
45
+ const input = "used argv[1] and items[0] here";
46
+ expect(substituteInlineRefs(input, refs, names)).toBe(input);
47
+ });
48
+
49
+ test("mid-line well-formed label token is stripped (leak guard)", () => {
50
+ expect(substituteInlineRefs("used argv[1] and [[1:read]] mid", refs, names)).toBe(
51
+ "used argv[1] and mid",
52
+ );
53
+ });
54
+
55
+ test("label inside fenced code block is untouched", () => {
56
+ const input = "```\n[[1:read]] literal\n```";
57
+ expect(substituteInlineRefs(input, refs, names)).toBe(input);
58
+ });
59
+
60
+ test("wrapped label ([[1:read]] in **bold**) is stripped, no raw token leaked", () => {
61
+ const input = "- **[[1:read]]** x";
62
+ expect(substituteInlineRefs(input, refs, names)).toBe("- **** x");
63
+ });
64
+
65
+ test("numbered-list label leak guard: token stripped, no anchored match", () => {
66
+ expect(substituteInlineRefs("1. [[1:read]] did x", refs, names)).toBe("1. did x");
67
+ });
68
+
69
+ test("blockquote label leak guard: token stripped, no anchored match", () => {
70
+ expect(substituteInlineRefs("> [[1:read]] did x", refs, names)).toBe("> did x");
71
+ });
72
+
73
+ test("fenced non-first-line label is still exempt from catch-all strip", () => {
74
+ const input = "```\nfoo [[1:read]] bar\n```";
75
+ expect(substituteInlineRefs(input, refs, names)).toBe(input);
76
+ });
77
+
78
+ test("range-fusion: inline ref survives fusion-input concatenation across batches", () => {
79
+ const body = substituteInlineRefs("- [[1:read]] did a thing", refs, names);
80
+ expect(body).toBe("- `t1` did a thing");
81
+ expect(body).toContain("`t1`");
82
+ const fusionInput = [body, body].join("\n---\n");
83
+ expect(fusionInput).toContain("`t1`");
84
+ });
85
+
86
+ test("N=0 is out of range and strips label", () => {
87
+ expect(substituteInlineRefs("- [[0:read]] x", refs, names)).toBe("- x");
88
+ });
89
+
90
+ test("empty input returns empty string", () => {
91
+ expect(substituteInlineRefs("", refs, names)).toBe("");
92
+ });
93
+
94
+ test("collapses multiple trailing spaces to one", () => {
95
+ expect(substituteInlineRefs("- [[1:read]] x", refs, names)).toBe("- `t1` x");
96
+ });
97
+
98
+ test("label at end of line has single trailing space, no phantom double", () => {
99
+ expect(substituteInlineRefs("- [[1:read]]", refs, names)).toBe("- `t1` ");
100
+ });
101
+
102
+ test("dotted/namespaced tool name matches and validates", () => {
103
+ const nsRefs: SummaryToolCallRef[] = [{ shortId: "t1", toolCallId: "a" }];
104
+ const nsNames = ["server.tool"];
105
+ expect(substituteInlineRefs("- [[1:server.tool]] did x", nsRefs, nsNames)).toBe("- `t1` did x");
106
+ });
107
+
108
+ test("dotted/namespaced tool name mismatch strips label, does not leak", () => {
109
+ const nsRefs: SummaryToolCallRef[] = [{ shortId: "t1", toolCallId: "a" }];
110
+ const nsNames = ["server.tool"];
111
+ expect(substituteInlineRefs("- [[1:other.tool]] x", nsRefs, nsNames)).toBe("- x");
112
+ });
113
+
114
+ test("composition with formatSummaryToolCallRefs footer", () => {
115
+ const body = substituteInlineRefs("- [[1:read]] a\n- [[2:bash]] b", refs, names);
116
+ const footer = formatSummaryToolCallRefs(refs);
117
+ for (const ref of refs) {
118
+ expect(body).toContain(`\`${ref.shortId}\``);
119
+ expect(footer).toContain(`\`${ref.shortId}\``);
120
+ }
121
+ });
122
+ });
@@ -59,3 +59,39 @@ export function makeSummaryDetails(batch: CapturedBatch, refs: SummaryToolCallRe
59
59
  timestamp: batch.timestamp,
60
60
  };
61
61
  }
62
+
63
+ /**
64
+ * Rewrites line-leading `[[N:name]]` labels emitted by the summarizer into
65
+ * inline `` `tN` `` refs. `refs` and `toolNames` are positionally aligned to
66
+ * the batch's tool-call order. The echoed name is validated against the tool
67
+ * at position N; a mismatch or out-of-range N strips the label (footer-only).
68
+ * A catch-all strip pass on non-fenced lines removes any surviving well-formed
69
+ * label token (wrapped, numbered, or blockquoted) so no raw `[[N:name]]` token
70
+ * ever leaks into context; fenced code blocks remain exempt.
71
+ */
72
+ export function substituteInlineRefs(
73
+ text: string,
74
+ refs: SummaryToolCallRef[],
75
+ toolNames: string[],
76
+ ): string {
77
+ const LABEL = /^(\s*(?:[-*]\s+)?)\[\[(\d+):([^\]\n]+)\]\]\s*/;
78
+ const lines = text.split("\n");
79
+ let inFence = false;
80
+ for (let i = 0; i < lines.length; i++) {
81
+ if (lines[i].trimStart().startsWith("```")) {
82
+ inFence = !inFence;
83
+ continue;
84
+ }
85
+ if (inFence) continue;
86
+ lines[i] = lines[i].replace(LABEL, (_m, prefix: string, numStr: string, name: string) => {
87
+ const n = Number(numStr);
88
+ const ref = refs[n - 1];
89
+ const expected = toolNames[n - 1];
90
+ if (!ref || expected === undefined) return prefix;
91
+ if (name.trim().toLowerCase() !== expected.trim().toLowerCase()) return prefix;
92
+ return `${prefix}\`${ref.shortId}\` `;
93
+ });
94
+ lines[i] = lines[i].replace(/\[\[\d+:[^\]\n]+\]\]\s*/g, "");
95
+ }
96
+ return lines.join("\n");
97
+ }