pi-condense 2.0.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +22 -0
  3. package/PRUNING.md +1028 -0
  4. package/README.md +243 -0
  5. package/index.ts +858 -0
  6. package/package.json +56 -0
  7. package/src/batch-capture.ts +226 -0
  8. package/src/block-refs.test.ts +42 -0
  9. package/src/block-refs.ts +16 -0
  10. package/src/budget.test.ts +66 -0
  11. package/src/budget.ts +39 -0
  12. package/src/chain-compressor.test.ts +283 -0
  13. package/src/chain-compressor.ts +132 -0
  14. package/src/chain-detector.test.ts +302 -0
  15. package/src/chain-detector.ts +128 -0
  16. package/src/chain-range-prune.test.ts +522 -0
  17. package/src/chain-range-prune.ts +128 -0
  18. package/src/commands.test.ts +67 -0
  19. package/src/commands.ts +1207 -0
  20. package/src/config.ts +126 -0
  21. package/src/content-hash.ts +35 -0
  22. package/src/error-purge.test.ts +186 -0
  23. package/src/error-purge.ts +71 -0
  24. package/src/frontier.ts +62 -0
  25. package/src/indexer.ts +393 -0
  26. package/src/nested-placeholders.test.ts +82 -0
  27. package/src/nested-placeholders.ts +20 -0
  28. package/src/oversized-spill.integration.test.ts +73 -0
  29. package/src/protected.test.ts +62 -0
  30. package/src/protected.ts +51 -0
  31. package/src/pruner.test.ts +508 -0
  32. package/src/pruner.ts +156 -0
  33. package/src/query-tool.ts +78 -0
  34. package/src/range-compression.integration.test.ts +252 -0
  35. package/src/spill.test.ts +102 -0
  36. package/src/spill.ts +90 -0
  37. package/src/stats.test.ts +114 -0
  38. package/src/stats.ts +190 -0
  39. package/src/summarizer.test.ts +17 -0
  40. package/src/summarizer.ts +262 -0
  41. package/src/summary-refs.ts +61 -0
  42. package/src/thinking-strip.test.ts +175 -0
  43. package/src/thinking-strip.ts +42 -0
  44. package/src/tree-browser.ts +382 -0
  45. package/src/types.ts +764 -0
package/src/stats.ts ADDED
@@ -0,0 +1,190 @@
1
+ import type { SummarizerStats, ExternalCostUpdate, LiveReclaim } from "./types.js";
2
+ import { CUSTOM_TYPE_STATS, EXTERNAL_COST_CHANNEL, EXTERNAL_COST_SOURCE } from "./types.js";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+
5
+ /**
6
+ * Usage shape returned by the LLM `complete()` call.
7
+ * Mirrors the `Usage` interface from `@earendil-works/pi-ai` but declared locally
8
+ * so we don't need a runtime import just for the type.
9
+ */
10
+ interface Usage {
11
+ input: number;
12
+ output: number;
13
+ cacheRead: number;
14
+ cacheWrite: number;
15
+ totalTokens: number;
16
+ cost: {
17
+ input: number;
18
+ output: number;
19
+ cacheRead: number;
20
+ cacheWrite: number;
21
+ total: number;
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Accumulates cumulative token/cost stats for summarizer LLM calls.
27
+ * Stats are persisted to the session via `pi.appendEntry(CUSTOM_TYPE_STATS, ...)`
28
+ * and reconstructed on `session_start` / `session_tree`.
29
+ */
30
+ export class StatsAccumulator {
31
+ private stats: SummarizerStats = {
32
+ totalInputTokens: 0,
33
+ totalOutputTokens: 0,
34
+ totalCost: 0,
35
+ callCount: 0,
36
+ chainsCompressed: 0,
37
+ rangesSummarized: 0,
38
+ };
39
+ private baseline = { totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 };
40
+ private liveReclaim: LiveReclaim | undefined = undefined;
41
+
42
+ /** Add usage data from one summarizer LLM call. */
43
+ add(usage: Usage): void {
44
+ this.stats.totalInputTokens += usage.input ?? 0;
45
+ this.stats.totalOutputTokens += usage.output ?? 0;
46
+ this.stats.totalCost += usage.cost?.total ?? 0;
47
+ this.stats.callCount += 1;
48
+ }
49
+
50
+ /** Return session-delta spend (current stats minus baseline set at reconstructFromSession). */
51
+ getSessionDelta(): { totalCost: number; inputTokens: number; outputTokens: number } {
52
+ return {
53
+ totalCost: this.stats.totalCost - this.baseline.totalCost,
54
+ inputTokens: this.stats.totalInputTokens - this.baseline.totalInputTokens,
55
+ outputTokens: this.stats.totalOutputTokens - this.baseline.totalOutputTokens,
56
+ };
57
+ }
58
+
59
+ /** Store the before/after context-char measurement from the last prune. */
60
+ setLiveReclaim(beforeChars: number, afterChars: number): void {
61
+ this.liveReclaim = { beforeChars, afterChars };
62
+ }
63
+
64
+ /** Return the last live-reclaim measurement, or undefined if none yet. */
65
+ getLiveReclaim(): LiveReclaim | undefined {
66
+ return this.liveReclaim;
67
+ }
68
+
69
+ /** Return a snapshot of the current cumulative stats. */
70
+ getStats(): SummarizerStats {
71
+ return { ...this.stats };
72
+ }
73
+
74
+ /** Increment the chain-compression counter. */
75
+ addChainsCompressed(n: number): void {
76
+ this.stats.chainsCompressed += n;
77
+ }
78
+
79
+ /** Increment the fused-range-summary counter. */
80
+ addRangesSummarized(n: number): void {
81
+ this.stats.rangesSummarized += n;
82
+ }
83
+
84
+ /** Reset all accumulated stats to zero. Produces the same state as a fresh accumulator. */
85
+ reset(): void {
86
+ this.stats = {
87
+ totalInputTokens: 0,
88
+ totalOutputTokens: 0,
89
+ totalCost: 0,
90
+ callCount: 0,
91
+ chainsCompressed: 0,
92
+ rangesSummarized: 0,
93
+ };
94
+ this.baseline = { totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 };
95
+ this.liveReclaim = undefined;
96
+ }
97
+
98
+ /** Serialize stats for session persistence. */
99
+ toJSON(): SummarizerStats {
100
+ return { ...this.stats };
101
+ }
102
+
103
+ /** Restore stats from a previously persisted snapshot. */
104
+ fromJSON(data: SummarizerStats): void {
105
+ this.stats = {
106
+ totalInputTokens: data.totalInputTokens ?? 0,
107
+ totalOutputTokens: data.totalOutputTokens ?? 0,
108
+ totalCost: data.totalCost ?? 0,
109
+ callCount: data.callCount ?? 0,
110
+ chainsCompressed: data.chainsCompressed ?? 0,
111
+ rangesSummarized: data.rangesSummarized ?? 0,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Reconstruct stats from session history by scanning all custom entries
117
+ * with customType === CUSTOM_TYPE_STATS.
118
+ */
119
+ reconstructFromSession(ctx: ExtensionContext): void {
120
+ this.reset();
121
+ const branch = ctx.sessionManager.getBranch();
122
+ for (const entry of branch) {
123
+ if (
124
+ entry.type === "custom" &&
125
+ (entry as any).customType === CUSTOM_TYPE_STATS
126
+ ) {
127
+ const data = (entry as any).data as SummarizerStats;
128
+ if (data) {
129
+ this.fromJSON(data);
130
+ }
131
+ }
132
+ }
133
+ this.baseline = {
134
+ totalInputTokens: this.stats.totalInputTokens,
135
+ totalOutputTokens: this.stats.totalOutputTokens,
136
+ totalCost: this.stats.totalCost,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Persist current stats to the session.
142
+ * Each call appends a new entry; on reconstructFromSession we scan
143
+ * all entries and apply the LAST one (since each entry is a full snapshot).
144
+ */
145
+ persist(pi: ExtensionAPI): void {
146
+ pi.appendEntry(CUSTOM_TYPE_STATS, this.toJSON());
147
+ }
148
+ }
149
+
150
+ // ── Formatting helpers ──────────────────────────────────────────────────────
151
+
152
+ /** Format compact counts like Pi's status line (e.g. "1.2k", "340") */
153
+ export function formatCompactCount(n: number): string {
154
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
155
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
156
+ return String(n);
157
+ }
158
+
159
+ /** Format token counts like Pi's status line (e.g. "1.2k", "340") */
160
+ export function formatTokens(n: number): string {
161
+ return formatCompactCount(n);
162
+ }
163
+
164
+ /** Format live char progress like "1.2k summary chars / 8.4k raw chars". */
165
+ export function formatCharProgress(receivedChars: number, rawChars?: number): string {
166
+ const receivedLabel = `${formatCompactCount(receivedChars)} summary char${receivedChars === 1 ? "" : "s"}`;
167
+ if (rawChars == null) return receivedLabel;
168
+ return `${receivedLabel} / ${formatCompactCount(rawChars)} raw char${rawChars === 1 ? "" : "s"}`;
169
+ }
170
+
171
+ /** Format cost like "$0.003" */
172
+ export function formatCost(n: number): string {
173
+ if (n < 0.001 && n > 0) return `<$0.001`;
174
+ return `$${n.toFixed(3)}`;
175
+ }
176
+
177
+ /**
178
+ * Emit the session-delta cost from `accumulator` on EXTERNAL_COST_CHANNEL.
179
+ * Idempotent from the aggregator's perspective: keyed by source, re-emitting overwrites.
180
+ */
181
+ export function emitExternalCost(pi: ExtensionAPI, accumulator: StatsAccumulator): void {
182
+ const delta = accumulator.getSessionDelta();
183
+ const payload: ExternalCostUpdate = {
184
+ source: EXTERNAL_COST_SOURCE,
185
+ totalCost: delta.totalCost,
186
+ inputTokens: delta.inputTokens,
187
+ outputTokens: delta.outputTokens,
188
+ };
189
+ pi.events.emit(EXTERNAL_COST_CHANNEL, payload);
190
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { isUsableSummary } from "./summarizer.js";
3
+
4
+ describe("isUsableSummary", () => {
5
+ it("accepts non-empty text that stopped normally", () => {
6
+ expect(isUsableSummary("- did a thing", "stop")).toBe(true);
7
+ });
8
+ it("rejects empty text", () => {
9
+ expect(isUsableSummary("", "stop")).toBe(false);
10
+ });
11
+ it("rejects whitespace-only text", () => {
12
+ expect(isUsableSummary(" \n\t ", "stop")).toBe(false);
13
+ });
14
+ it("rejects truncated output even with text", () => {
15
+ expect(isUsableSummary("- partial", "length")).toBe(false);
16
+ });
17
+ });
@@ -0,0 +1,262 @@
1
+ import { stream } from "@earendil-works/pi-ai";
2
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type {
5
+ CapturedBatch,
6
+ ContextPruneConfig,
7
+ SummarizerThinking,
8
+ SummarizeBatchOptions,
9
+ SummarizeBatchesOptions,
10
+ SummarizeResult,
11
+ } from "./types.js";
12
+ import { serializeBatchForSummarizer } from "./batch-capture.js";
13
+
14
+ const SYSTEM_PROMPT = `You are summarizing a batch of tool calls made by an AI coding assistant.
15
+ For each tool call provide:
16
+ - Tool name and a one-sentence description of what it did
17
+ - Key outcome: success/failure and the most important data returned
18
+ - Any findings the future conversation needs to remember
19
+
20
+ Keep each tool call to 1-3 bullet points. Be concise.`;
21
+
22
+ 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
+ - Merge overlapping or repeated information; do not restate each step separately.
24
+ - Preserve concrete outcomes, decisions, file paths, identifiers, and anything later work needs to remember.
25
+ - Keep any reference tokens like \`t12\` or \`b3\` intact.
26
+ - Be concise: a short narrative or a few grouped bullets, not one bullet per step.`;
27
+
28
+ export function summarizerThinkingOptions(config: ContextPruneConfig): Record<string, unknown> {
29
+ const level: SummarizerThinking = config.summarizerThinking;
30
+ if (level === "default") {
31
+ return {};
32
+ }
33
+
34
+ // stream()/complete() accept provider-level options. For reasoning-capable providers,
35
+ // pi-ai adapters translate reasoningEffort into the provider-specific field.
36
+ // "off" intentionally sends no effort; adapters that support explicit disable
37
+ // handle that the same way as an absent effort, while preserving compatibility.
38
+ return { reasoningEffort: level === "off" ? undefined : level };
39
+ }
40
+
41
+ /**
42
+ * Returns the model to use for summarization.
43
+ * config.summarizerModel === "default" => ctx.model
44
+ * "provider/model-id" => ctx.modelRegistry.find(provider, modelId), fallback to ctx.model with warning
45
+ */
46
+ export function resolveModel(config: ContextPruneConfig, ctx: ExtensionContext): any {
47
+ if (config.summarizerModel === "default") {
48
+ return ctx.model;
49
+ }
50
+
51
+ const slashIndex = config.summarizerModel.indexOf("/");
52
+ if (slashIndex === -1) {
53
+ ctx.ui.notify(
54
+ `pruner: invalid summarizerModel "${config.summarizerModel}", expected "provider/model-id". Falling back to default model.`,
55
+ "warning"
56
+ );
57
+ return ctx.model;
58
+ }
59
+
60
+ const provider = config.summarizerModel.slice(0, slashIndex);
61
+ const modelId = config.summarizerModel.slice(slashIndex + 1);
62
+
63
+ const found = ctx.modelRegistry.find(provider, modelId);
64
+ if (!found) {
65
+ ctx.ui.notify(
66
+ `pruner: model "${config.summarizerModel}" not found in registry. Falling back to default model.`,
67
+ "warning"
68
+ );
69
+ return ctx.model;
70
+ }
71
+
72
+ return found;
73
+ }
74
+
75
+ function receivedTextChars(message: AssistantMessage): number {
76
+ return message.content.reduce((sum, content) => {
77
+ return content.type === "text" ? sum + content.text.length : sum;
78
+ }, 0);
79
+ }
80
+
81
+ /** A summary is usable only if it has non-whitespace text and was not truncated. */
82
+ export function isUsableSummary(llmText: string, stopReason: string): boolean {
83
+ return llmText.trim().length > 0 && stopReason !== "length";
84
+ }
85
+
86
+ /**
87
+ * Shared LLM-call machinery for both per-batch and range summarization.
88
+ * `userMessage` already embeds the relevant system prompt as leading text
89
+ * (the summarizer is a single-user-message call). Returns the formatted text
90
+ * + usage, or null on failure. Abort errors are re-thrown so flushPending can
91
+ * detect options.signal.aborted and restore state without a UI error.
92
+ */
93
+ async function runSummarization(
94
+ userMessage: string,
95
+ config: ContextPruneConfig,
96
+ ctx: ExtensionContext,
97
+ options: SummarizeBatchOptions
98
+ ): Promise<SummarizeResult | null> {
99
+ // Fast-fail if already aborted before we even start.
100
+ if (options.signal?.aborted) throw new Error("summarize: aborted before start");
101
+
102
+ try {
103
+ const model = resolveModel(config, ctx);
104
+
105
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
106
+ if (!auth.ok) {
107
+ const authMessage = "error" in auth ? auth.error : "authentication failed";
108
+ ctx.ui.notify(`pruner: summarization failed: ${authMessage}`, "error");
109
+ return null;
110
+ }
111
+
112
+ // Pass the abort signal so the underlying fetch is cancelled immediately
113
+ // when the user presses Esc while the tool is running.
114
+ const responseStream = stream(
115
+ model,
116
+ {
117
+ messages: [
118
+ {
119
+ role: "user",
120
+ content: [{ type: "text", text: userMessage }],
121
+ timestamp: Date.now(),
122
+ },
123
+ ],
124
+ },
125
+ { apiKey: auth.apiKey, headers: auth.headers, signal: options.signal, ...summarizerThinkingOptions(config) }
126
+ );
127
+
128
+ let lastReportedChars = -1;
129
+ options.onTextProgress?.(0);
130
+ const reportTextProgress = (message: AssistantMessage) => {
131
+ const chars = receivedTextChars(message);
132
+ if (chars !== lastReportedChars) {
133
+ lastReportedChars = chars;
134
+ options.onTextProgress?.(chars);
135
+ }
136
+ };
137
+
138
+ for await (const event of responseStream) {
139
+ // Belt-and-suspenders: break early when signal fires mid-stream.
140
+ if (options.signal?.aborted) break;
141
+ if (event.type === "text_start" || event.type === "text_delta" || event.type === "text_end") {
142
+ reportTextProgress(event.partial);
143
+ }
144
+ }
145
+
146
+ // If signal fired while we were iterating, propagate the abort so
147
+ // flushPending can detect it and restore batches.
148
+ if (options.signal?.aborted) throw new Error("summarize: aborted during stream");
149
+
150
+ const response = await responseStream.result();
151
+ reportTextProgress(response);
152
+ // stopReason "aborted" means the provider cut the stream short (e.g. signal
153
+ // fired just before the final chunk). Treat identically to the signal check
154
+ // above — throw so flushPending's catch can detect options.signal.aborted.
155
+ if (response.stopReason === "aborted") {
156
+ throw new Error("summarize: stream stopped with reason aborted");
157
+ }
158
+ if (response.stopReason === "error") {
159
+ throw new Error(response.errorMessage ?? "Summarizer stopped with reason: error");
160
+ }
161
+
162
+ const llmText = response.content
163
+ .filter((c: any) => c.type === "text")
164
+ .map((c: any) => c.text)
165
+ .join("\n");
166
+
167
+ if (!isUsableSummary(llmText, response.stopReason)) return null;
168
+
169
+ return {
170
+ summaryText: llmText,
171
+ usage: response.usage,
172
+ };
173
+ } catch (err: any) {
174
+ // Propagate abort errors upward so flushPending can check signal.aborted
175
+ // and return { ok: false, reason: "aborted" } without showing a UI error.
176
+ if (options.signal?.aborted) throw err;
177
+ ctx.ui.notify(
178
+ `pruner: summarization failed: ${err.message}`,
179
+ "error"
180
+ );
181
+ return null;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Summarizes a captured batch. Returns formatted markdown string, or null on failure.
187
+ * Shows user-visible errors via ctx.ui.notify.
188
+ */
189
+ export async function summarizeBatch(
190
+ batch: CapturedBatch,
191
+ config: ContextPruneConfig,
192
+ ctx: ExtensionContext,
193
+ options: SummarizeBatchOptions = {}
194
+ ): Promise<SummarizeResult | null> {
195
+ const serialized = serializeBatchForSummarizer(batch);
196
+ const userMessage =
197
+ SYSTEM_PROMPT + "\n\n<tool-call-batch>\n" + serialized + "\n</tool-call-batch>";
198
+ return runSummarization(userMessage, config, ctx, options);
199
+ }
200
+
201
+ /**
202
+ * Fuses a closed chain's already-computed per-batch summaries into one cohesive
203
+ * range summary (recursive summarization). Input is the span's per-batch summary
204
+ * text — small and already pruned — so this never re-sends raw tool output.
205
+ * Returns the fused text + usage, or null on failure. Used by chain compression
206
+ * to replace the concatenated per-batch body with a single coherent summary.
207
+ */
208
+ export async function summarizeRange(
209
+ perBatchSummaryText: string,
210
+ config: ContextPruneConfig,
211
+ ctx: ExtensionContext,
212
+ options: SummarizeBatchOptions = {}
213
+ ): Promise<SummarizeResult | null> {
214
+ const userMessage =
215
+ RANGE_SYSTEM_PROMPT + "\n\n<sub-task-summaries>\n" + perBatchSummaryText + "\n</sub-task-summaries>";
216
+ return runSummarization(userMessage, config, ctx, options);
217
+ }
218
+
219
+ /**
220
+ * Summarizes multiple captured batches — one LLM call per batch, run in parallel.
221
+ *
222
+ * Returns an array of per-batch results. Each element is either a SummarizeResult
223
+ * (success) or null (that specific batch's call failed). The array length always
224
+ * equals batches.length so callers can zip by index.
225
+ *
226
+ * Rationale for parallel-per-batch instead of a single merged call:
227
+ * • Each batch becomes its own summary message (one per turn), so they can be
228
+ * rendered, browsed, and recovered independently via context_tree_query.
229
+ * • Parallel calls give similar end-to-end latency to a single merged call while
230
+ * keeping the summaries strictly separated.
231
+ */
232
+ export async function summarizeBatches(
233
+ batches: CapturedBatch[],
234
+ config: ContextPruneConfig,
235
+ ctx: ExtensionContext,
236
+ options: SummarizeBatchesOptions = {}
237
+ ): Promise<Array<SummarizeResult | null>> {
238
+ if (batches.length === 0) return [];
239
+ // Single batch — delegate to the single-batch path (no extra overhead)
240
+ if (batches.length === 1) {
241
+ return [
242
+ await summarizeBatch(batches[0], config, ctx, {
243
+ signal: options.signal,
244
+ onTextProgress: (receivedChars) => {
245
+ options.onBatchTextProgress?.(0, 1, batches[0], receivedChars);
246
+ },
247
+ }),
248
+ ];
249
+ }
250
+
251
+ // Multiple batches — run in parallel; each produces its own SummarizeResult
252
+ return Promise.all(
253
+ batches.map((batch, index) =>
254
+ summarizeBatch(batch, config, ctx, {
255
+ signal: options.signal,
256
+ onTextProgress: (receivedChars) => {
257
+ options.onBatchTextProgress?.(index, batches.length, batch, receivedChars);
258
+ },
259
+ })
260
+ )
261
+ );
262
+ }
@@ -0,0 +1,61 @@
1
+ import type { CapturedBatch } from "./types.js";
2
+
3
+ export interface SummaryToolCallRef {
4
+ shortId: string;
5
+ toolCallId: string;
6
+ }
7
+
8
+ export interface SummaryMessageDetailsLike {
9
+ toolCallRefs?: SummaryToolCallRef[];
10
+ toolCallIds?: string[];
11
+ }
12
+
13
+ const SHORT_ID_PREFIX = "t";
14
+
15
+ export function buildShortToolCallRefs(
16
+ toolCallIds: string[],
17
+ startIndex: number,
18
+ ): { refs: SummaryToolCallRef[]; nextIndex: number } {
19
+ const refs = toolCallIds.map((toolCallId, offset) => ({
20
+ shortId: `${SHORT_ID_PREFIX}${startIndex + offset}`,
21
+ toolCallId,
22
+ }));
23
+ return { refs, nextIndex: startIndex + refs.length };
24
+ }
25
+
26
+ export function normalizeSummaryToolCallRefs(details: unknown): SummaryToolCallRef[] {
27
+ if (!details || typeof details !== "object") return [];
28
+
29
+ const raw = details as SummaryMessageDetailsLike;
30
+ if (Array.isArray(raw.toolCallRefs)) {
31
+ return raw.toolCallRefs
32
+ .filter(
33
+ (ref): ref is SummaryToolCallRef =>
34
+ !!ref && typeof ref.shortId === "string" && typeof ref.toolCallId === "string",
35
+ )
36
+ .map((ref) => ({ shortId: ref.shortId, toolCallId: ref.toolCallId }));
37
+ }
38
+
39
+ if (Array.isArray(raw.toolCallIds)) {
40
+ return raw.toolCallIds.filter((id): id is string => typeof id === "string").map((id) => ({ shortId: id, toolCallId: id }));
41
+ }
42
+
43
+ return [];
44
+ }
45
+
46
+ export function formatSummaryToolCallRefs(refs: SummaryToolCallRef[]): string {
47
+ const refList = refs.map((ref) => `\`${ref.shortId}\``).join(", ");
48
+ return (
49
+ `\n\n---\n**Summarized tool refs**: ${refList}\n` +
50
+ `Use \`context_tree_query\` with these refs to retrieve the original full outputs.`
51
+ );
52
+ }
53
+
54
+ export function makeSummaryDetails(batch: CapturedBatch, refs: SummaryToolCallRef[]) {
55
+ return {
56
+ toolCallRefs: refs,
57
+ toolNames: batch.toolCalls.map((tc) => tc.toolName),
58
+ turnIndex: batch.turnIndex,
59
+ timestamp: batch.timestamp,
60
+ };
61
+ }