pi-condense 2.0.1 → 2.1.1
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 +8 -0
- package/PRUNING.md +16 -2
- package/README.md +2 -1
- package/index.ts +5 -3
- package/package.json +2 -1
- package/src/batch-capture.test.ts +51 -0
- package/src/batch-capture.ts +2 -2
- package/src/summarizer.ts +3 -1
- package/src/summary-refs.test.ts +122 -0
- package/src/summary-refs.ts +36 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,14 @@ publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.1.1] - 2026-07-04
|
|
13
|
+
|
|
14
|
+
- **`release.yml` posts GitHub Release notes.** A new `release-notes` job (`needs: publish`, `contents: write`) extracts the CHANGELOG section matching the pushed tag with `awk` (skipping `## [Unreleased]`) and publishes it as the GitHub Release body via `gh release create` (falling back to `gh release edit`). No LLM or API key; only `github.token`.
|
|
15
|
+
|
|
16
|
+
## [2.1.0] - 2026-07-04
|
|
17
|
+
|
|
18
|
+
- **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.
|
|
19
|
+
|
|
12
20
|
## [2.0.1] - 2026-07-03
|
|
13
21
|
|
|
14
22
|
- **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
|
|
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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "2.1.1",
|
|
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
|
+
});
|
package/src/batch-capture.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/summary-refs.ts
CHANGED
|
@@ -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
|
+
}
|