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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +22 -0
- package/PRUNING.md +1028 -0
- package/README.md +243 -0
- package/index.ts +858 -0
- package/package.json +56 -0
- package/src/batch-capture.ts +226 -0
- package/src/block-refs.test.ts +42 -0
- package/src/block-refs.ts +16 -0
- package/src/budget.test.ts +66 -0
- package/src/budget.ts +39 -0
- package/src/chain-compressor.test.ts +283 -0
- package/src/chain-compressor.ts +132 -0
- package/src/chain-detector.test.ts +302 -0
- package/src/chain-detector.ts +128 -0
- package/src/chain-range-prune.test.ts +522 -0
- package/src/chain-range-prune.ts +128 -0
- package/src/commands.test.ts +67 -0
- package/src/commands.ts +1207 -0
- package/src/config.ts +126 -0
- package/src/content-hash.ts +35 -0
- package/src/error-purge.test.ts +186 -0
- package/src/error-purge.ts +71 -0
- package/src/frontier.ts +62 -0
- package/src/indexer.ts +393 -0
- package/src/nested-placeholders.test.ts +82 -0
- package/src/nested-placeholders.ts +20 -0
- package/src/oversized-spill.integration.test.ts +73 -0
- package/src/protected.test.ts +62 -0
- package/src/protected.ts +51 -0
- package/src/pruner.test.ts +508 -0
- package/src/pruner.ts +156 -0
- package/src/query-tool.ts +78 -0
- package/src/range-compression.integration.test.ts +252 -0
- package/src/spill.test.ts +102 -0
- package/src/spill.ts +90 -0
- package/src/stats.test.ts +114 -0
- package/src/stats.ts +190 -0
- package/src/summarizer.test.ts +17 -0
- package/src/summarizer.ts +262 -0
- package/src/summary-refs.ts +61 -0
- package/src/thinking-strip.test.ts +175 -0
- package/src/thinking-strip.ts +42 -0
- package/src/tree-browser.ts +382 -0
- 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
|
+
}
|