pi-agent-flow 2.0.0 → 2.0.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/agents/audit.md +3 -0
- package/agents/build.md +3 -0
- package/agents/craft.md +3 -0
- package/agents/debug.md +3 -0
- package/agents/ideas.md +3 -0
- package/agents/scout.md +5 -0
- package/dist/batch/batch-bash.d.ts.map +1 -1
- package/dist/batch/batch-bash.js +27 -4
- package/dist/batch/batch-bash.js.map +1 -1
- package/dist/batch/constants.d.ts +12 -0
- package/dist/batch/constants.d.ts.map +1 -1
- package/dist/batch/constants.js +46 -0
- package/dist/batch/constants.js.map +1 -1
- package/dist/batch/execute.d.ts.map +1 -1
- package/dist/batch/execute.js +117 -4
- package/dist/batch/execute.js.map +1 -1
- package/dist/batch/shell-compress.d.ts +25 -0
- package/dist/batch/shell-compress.d.ts.map +1 -0
- package/dist/batch/shell-compress.js +602 -0
- package/dist/batch/shell-compress.js.map +1 -0
- package/dist/batch/symbols.d.ts.map +1 -1
- package/dist/batch/symbols.js +12 -7
- package/dist/batch/symbols.js.map +1 -1
- package/dist/config/config.d.ts +5 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +63 -0
- package/dist/config/config.js.map +1 -1
- package/dist/config/models.d.ts +2 -0
- package/dist/config/models.d.ts.map +1 -0
- package/dist/config/models.js +49 -0
- package/dist/config/models.js.map +1 -0
- package/dist/config/settings-resolver.js +2 -2
- package/dist/config/settings-resolver.js.map +1 -1
- package/dist/core/agents.js +2 -2
- package/dist/core/agents.js.map +1 -1
- package/dist/core/delegation.d.ts +1 -1
- package/dist/core/delegation.d.ts.map +1 -1
- package/dist/core/delegation.js +6 -13
- package/dist/core/delegation.js.map +1 -1
- package/dist/core/executor.d.ts +7 -0
- package/dist/core/executor.d.ts.map +1 -1
- package/dist/core/executor.js +5 -1
- package/dist/core/executor.js.map +1 -1
- package/dist/core/flow.d.ts +9 -0
- package/dist/core/flow.d.ts.map +1 -1
- package/dist/core/flow.js +24 -28
- package/dist/core/flow.js.map +1 -1
- package/dist/core/session-mode.d.ts +1 -1
- package/dist/core/session-mode.d.ts.map +1 -1
- package/dist/core/session-mode.js +2 -1
- package/dist/core/session-mode.js.map +1 -1
- package/dist/flow/auto-warp.d.ts +12 -0
- package/dist/flow/auto-warp.d.ts.map +1 -0
- package/dist/flow/auto-warp.js +29 -0
- package/dist/flow/auto-warp.js.map +1 -0
- package/dist/flow/command.d.ts.map +1 -1
- package/dist/flow/command.js +7 -2
- package/dist/flow/command.js.map +1 -1
- package/dist/flow/continuation.d.ts.map +1 -1
- package/dist/flow/continuation.js +50 -13
- package/dist/flow/continuation.js.map +1 -1
- package/dist/flow/index.d.ts +4 -1
- package/dist/flow/index.d.ts.map +1 -1
- package/dist/flow/index.js +4 -1
- package/dist/flow/index.js.map +1 -1
- package/dist/flow/loop-command.d.ts +8 -0
- package/dist/flow/loop-command.d.ts.map +1 -0
- package/dist/flow/loop-command.js +99 -0
- package/dist/flow/loop-command.js.map +1 -0
- package/dist/flow/loop-templates.d.ts +7 -0
- package/dist/flow/loop-templates.d.ts.map +1 -0
- package/dist/flow/loop-templates.js +38 -0
- package/dist/flow/loop-templates.js.map +1 -0
- package/dist/flow/loop.d.ts +19 -0
- package/dist/flow/loop.d.ts.map +1 -0
- package/dist/flow/loop.js +95 -0
- package/dist/flow/loop.js.map +1 -0
- package/dist/flow/perform-warp.d.ts +28 -0
- package/dist/flow/perform-warp.d.ts.map +1 -0
- package/dist/flow/perform-warp.js +127 -0
- package/dist/flow/perform-warp.js.map +1 -0
- package/dist/flow/settings-command.d.ts.map +1 -1
- package/dist/flow/settings-command.js +89 -3
- package/dist/flow/settings-command.js.map +1 -1
- package/dist/flow/store.d.ts +3 -3
- package/dist/flow/store.d.ts.map +1 -1
- package/dist/flow/store.js +24 -16
- package/dist/flow/store.js.map +1 -1
- package/dist/flow/template-shared.d.ts +9 -0
- package/dist/flow/template-shared.d.ts.map +1 -0
- package/dist/flow/template-shared.js +13 -0
- package/dist/flow/template-shared.js.map +1 -0
- package/dist/flow/template-strings.d.ts.map +1 -1
- package/dist/flow/template-strings.js +2 -5
- package/dist/flow/template-strings.js.map +1 -1
- package/dist/flow/types.d.ts +15 -9
- package/dist/flow/types.d.ts.map +1 -1
- package/dist/flow/warp-command.d.ts +0 -1
- package/dist/flow/warp-command.d.ts.map +1 -1
- package/dist/flow/warp-command.js +21 -282
- package/dist/flow/warp-command.js.map +1 -1
- package/dist/flow/warp-utils.d.ts +11 -0
- package/dist/flow/warp-utils.d.ts.map +1 -0
- package/dist/flow/warp-utils.js +187 -0
- package/dist/flow/warp-utils.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -7
- package/dist/index.js.map +1 -1
- package/dist/snapshot/cli-args.d.ts +1 -0
- package/dist/snapshot/cli-args.d.ts.map +1 -1
- package/dist/snapshot/cli-args.js +12 -0
- package/dist/snapshot/cli-args.js.map +1 -1
- package/dist/snapshot/snapshot.d.ts +22 -19
- package/dist/snapshot/snapshot.d.ts.map +1 -1
- package/dist/snapshot/snapshot.js +1012 -149
- package/dist/snapshot/snapshot.js.map +1 -1
- package/dist/steering/sliding-prompt.d.ts.map +1 -1
- package/dist/steering/sliding-prompt.js +3 -0
- package/dist/steering/sliding-prompt.js.map +1 -1
- package/dist/tools/timed-bash.d.ts.map +1 -1
- package/dist/tools/timed-bash.js +9 -0
- package/dist/tools/timed-bash.js.map +1 -1
- package/dist/tui/render-utils.d.ts +5 -1
- package/dist/tui/render-utils.d.ts.map +1 -1
- package/dist/tui/render-utils.js +38 -10
- package/dist/tui/render-utils.js.map +1 -1
- package/dist/tui/render.d.ts.map +1 -1
- package/dist/tui/render.js +55 -82
- package/dist/tui/render.js.map +1 -1
- package/dist/tui/scramble/constants.d.ts +8 -8
- package/dist/tui/scramble/constants.d.ts.map +1 -1
- package/dist/tui/scramble/constants.js +7 -7
- package/dist/tui/scramble/constants.js.map +1 -1
- package/dist/tui/scramble/manager.d.ts +0 -4
- package/dist/tui/scramble/manager.d.ts.map +1 -1
- package/dist/tui/scramble/manager.js +2 -62
- package/dist/tui/scramble/manager.js.map +1 -1
- package/dist/types/flow.d.ts +2 -0
- package/dist/types/flow.d.ts.map +1 -1
- package/dist/types/flow.js.map +1 -1
- package/dist/types/output.d.ts +6 -0
- package/dist/types/output.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Two JSONL protocols are used in this codebase:
|
|
3
3
|
*
|
|
4
4
|
* 1. Fork Snapshot Protocol (snapshot.ts):
|
|
5
|
-
* Types: session, model_change, thinking_level_change,
|
|
6
|
-
* compression-stats
|
|
5
|
+
* Types: session, model_change, thinking_level_change, message
|
|
7
6
|
* Purpose: Serialized session state passed to child flows via --session.
|
|
8
7
|
* Emitted by buildForkSessionSnapshotJsonl() and consumed by
|
|
9
8
|
* sanitizeForkSnapshot() before forking.
|
|
@@ -19,6 +18,7 @@ import { stripReasoningFromAssistantMessage } from "./reasoning-strip.js";
|
|
|
19
18
|
import { stripSteeringHintFromContent, contentContainsSteeringHintTag, isJsonEqual, } from "../steering/sliding-prompt.js";
|
|
20
19
|
import { stripStrategicHintsFromContent } from "../steering/tool-utils.js";
|
|
21
20
|
import { logError } from "../config/log.js";
|
|
21
|
+
import * as path from "node:path";
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
23
|
// Session snapshot serialization
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
@@ -26,6 +26,27 @@ export function buildForkSessionSnapshotJsonl(sessionManager) {
|
|
|
26
26
|
const header = sessionManager.getHeader();
|
|
27
27
|
if (!header || typeof header !== "object")
|
|
28
28
|
return null;
|
|
29
|
+
// Compress cwd in session header: relative to repo root if under it,
|
|
30
|
+
// otherwise basename only. Saves ~50-100 bytes per snapshot.
|
|
31
|
+
const repoRoot = process.cwd();
|
|
32
|
+
let compressedHeader = header;
|
|
33
|
+
if (typeof compressedHeader.cwd === "string") {
|
|
34
|
+
const cwd = compressedHeader.cwd;
|
|
35
|
+
let compressedCwd;
|
|
36
|
+
if (cwd === repoRoot) {
|
|
37
|
+
compressedCwd = ".";
|
|
38
|
+
}
|
|
39
|
+
else if (cwd.startsWith(repoRoot + "/") || cwd.startsWith(repoRoot + "\\")) {
|
|
40
|
+
compressedCwd = cwd.slice(repoRoot.length + 1);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const lastSep = Math.max(cwd.lastIndexOf("/"), cwd.lastIndexOf("\\"));
|
|
44
|
+
compressedCwd = lastSep >= 0 ? cwd.slice(lastSep + 1) : cwd;
|
|
45
|
+
}
|
|
46
|
+
if (compressedCwd !== cwd) {
|
|
47
|
+
compressedHeader = { ...compressedHeader, cwd: compressedCwd };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
29
50
|
const branchEntries = sessionManager.getBranch();
|
|
30
51
|
const lines = [];
|
|
31
52
|
// Emit session header once, unless getBranch() already includes it as the
|
|
@@ -38,13 +59,7 @@ export function buildForkSessionSnapshotJsonl(sessionManager) {
|
|
|
38
59
|
typeof firstBranch !== "object" ||
|
|
39
60
|
(firstType !== "session" && firstType !== "header") ||
|
|
40
61
|
firstId !== headerId) {
|
|
41
|
-
lines.push(JSON.stringify(
|
|
42
|
-
}
|
|
43
|
-
// Emit system event so the JSONL is self-contained — parsers can reconstruct
|
|
44
|
-
// full context without needing the markdown section.
|
|
45
|
-
const systemPrompt = header.systemPrompt;
|
|
46
|
-
if (typeof systemPrompt === "string" && systemPrompt) {
|
|
47
|
-
lines.push(JSON.stringify({ type: "system", content: systemPrompt }));
|
|
62
|
+
lines.push(JSON.stringify(compressedHeader));
|
|
48
63
|
}
|
|
49
64
|
for (const entry of branchEntries)
|
|
50
65
|
lines.push(JSON.stringify(entry));
|
|
@@ -111,50 +126,18 @@ export function renderCompressedFlowResult(r) {
|
|
|
111
126
|
}
|
|
112
127
|
if (r.error)
|
|
113
128
|
parts.push(`Error: ${r.error}`);
|
|
114
|
-
|
|
115
|
-
|
|
129
|
+
// Safety net: reject malformed runtime data where required fields are missing.
|
|
130
|
+
// This is more precise than a substring search that would false-positive on
|
|
131
|
+
// legitimate content containing the word "undefined".
|
|
132
|
+
if (!r.type || !r.status)
|
|
116
133
|
return undefined;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
* Handles both { o: [...] } and bare array argument formats.
|
|
125
|
-
*/
|
|
126
|
-
function extractBatchReadPaths(args) {
|
|
127
|
-
if (!args || typeof args !== "object")
|
|
128
|
-
return [];
|
|
129
|
-
let ops;
|
|
130
|
-
if (Array.isArray(args)) {
|
|
131
|
-
ops = args;
|
|
132
|
-
}
|
|
133
|
-
else if (Array.isArray(args.o)) {
|
|
134
|
-
ops = args.o;
|
|
135
|
-
}
|
|
136
|
-
else {
|
|
137
|
-
return [];
|
|
138
|
-
}
|
|
139
|
-
const paths = [];
|
|
140
|
-
for (const op of ops) {
|
|
141
|
-
if (!op || typeof op !== "object")
|
|
142
|
-
continue;
|
|
143
|
-
const p = op.p;
|
|
144
|
-
if (typeof p === "string" && p)
|
|
145
|
-
paths.push(p);
|
|
146
|
-
}
|
|
147
|
-
return paths;
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* Render a compressed batch_read result as compact metadata for child context.
|
|
151
|
-
* Format: [batch_read] N ops → paths: file1.ts, file2.ts, …
|
|
152
|
-
*/
|
|
153
|
-
function renderCompressedBatchReadResult(paths) {
|
|
154
|
-
const MAX_PATHS_DISPLAY = 10;
|
|
155
|
-
const display = paths.slice(0, MAX_PATHS_DISPLAY);
|
|
156
|
-
const suffix = paths.length > MAX_PATHS_DISPLAY ? `, … +${paths.length - MAX_PATHS_DISPLAY} more` : "";
|
|
157
|
-
return `[batch_read] ${paths.length} ops → paths: ${display.join(", ")}${suffix}`;
|
|
134
|
+
if (r.actions?.some((a) => !a.type || !a.description))
|
|
135
|
+
return undefined;
|
|
136
|
+
if (r.commands?.some((c) => !c.command))
|
|
137
|
+
return undefined;
|
|
138
|
+
if (r.notDone?.some((n) => !n.item))
|
|
139
|
+
return undefined;
|
|
140
|
+
return parts.join("\n");
|
|
158
141
|
}
|
|
159
142
|
// ---------------------------------------------------------------------------
|
|
160
143
|
// Additional tool result compressors
|
|
@@ -170,30 +153,445 @@ const KNOWN_SECTION_HEADERS = [
|
|
|
170
153
|
/^--- (.+) \((\d+) lines\) ---$/,
|
|
171
154
|
/^--- (.+) (context map|file summary) ---$/,
|
|
172
155
|
/^--- bash \[.+\] exit (\d+) ---$/,
|
|
156
|
+
/^--- bash \[.+\] pending ---$/,
|
|
157
|
+
/^--- bash \[.+\] error ---$/,
|
|
173
158
|
/^--- edit: .+ ---$/,
|
|
174
159
|
/^--- write: .+ ---$/,
|
|
175
160
|
/^--- delete: .+ ---$/,
|
|
176
161
|
/^--- read: .+ ---$/,
|
|
177
|
-
/^---
|
|
162
|
+
/^--- rg: .+ ---$/,
|
|
163
|
+
/^--- (?!bash \[|edit:|write:|delete:|read:|rg:)(.+) ---$/,
|
|
178
164
|
];
|
|
179
165
|
function isKnownSectionHeader(line) {
|
|
180
166
|
return KNOWN_SECTION_HEADERS.some((re) => re.test(line));
|
|
181
167
|
}
|
|
182
|
-
/** Compress
|
|
183
|
-
|
|
168
|
+
/** Compress a single bash block into the X1 compact format. */
|
|
169
|
+
/** Convert legacy depth number to DepthPolicy. */
|
|
170
|
+
export function depthToPolicy(depth) {
|
|
171
|
+
const isDepth1 = depth < 2;
|
|
172
|
+
return { showPreviews: isDepth1, showBytes: isDepth1, showSupersededBreadcrumbs: isDepth1, showEditBlocks: isDepth1 };
|
|
173
|
+
}
|
|
174
|
+
function compressBashSection(bashId, status, exitCode, timingTier, stdoutLines, stderrLines, policy) {
|
|
175
|
+
const isDepth1 = policy.showPreviews;
|
|
176
|
+
const tier = timingTier ? ` · ${timingTier}` : "";
|
|
177
|
+
// Trim trailing empty lines inserted by multi-bash formatting
|
|
178
|
+
while (stdoutLines.length > 0 && stdoutLines[stdoutLines.length - 1] === "")
|
|
179
|
+
stdoutLines.pop();
|
|
180
|
+
while (stderrLines.length > 0 && stderrLines[stderrLines.length - 1] === "")
|
|
181
|
+
stderrLines.pop();
|
|
182
|
+
if (status === "ok") {
|
|
183
|
+
const lineCount = stdoutLines.length;
|
|
184
|
+
const linesLabel = lineCount === 1 ? "1 line" : `${lineCount} lines`;
|
|
185
|
+
if (isDepth1) {
|
|
186
|
+
if (lineCount === 0) {
|
|
187
|
+
return `[bash:ok] ${bashId} · exit ${exitCode}${tier} · 0 lines`;
|
|
188
|
+
}
|
|
189
|
+
const head = stdoutLines.slice(0, 3).join("\n");
|
|
190
|
+
return `[bash:ok] ${bashId} · exit ${exitCode}${tier} · ${linesLabel}\n> head:\n${head}`;
|
|
191
|
+
}
|
|
192
|
+
return `[bash:ok] ${bashId} · exit ${exitCode}`;
|
|
193
|
+
}
|
|
194
|
+
if (status === "pending") {
|
|
195
|
+
const lineCount = stdoutLines.length;
|
|
196
|
+
const linesLabel = lineCount === 1 ? "1 line partial" : `${lineCount} lines partial`;
|
|
197
|
+
if (isDepth1) {
|
|
198
|
+
if (lineCount === 0) {
|
|
199
|
+
return `[bash:pending] ${bashId} · still running · 0 lines partial`;
|
|
200
|
+
}
|
|
201
|
+
const head = stdoutLines.slice(0, 3).join("\n");
|
|
202
|
+
return `[bash:pending] ${bashId} · still running · ${linesLabel}\n> head:\n${head}`;
|
|
203
|
+
}
|
|
204
|
+
return `[bash:pending] ${bashId} · still running`;
|
|
205
|
+
}
|
|
206
|
+
if (status === "error") {
|
|
207
|
+
const lineCount = stderrLines.length;
|
|
208
|
+
const linesLabel = lineCount === 1 ? "1 line stderr" : `${lineCount} lines stderr`;
|
|
209
|
+
const exit = exitCode !== undefined ? ` · exit ${exitCode}` : "";
|
|
210
|
+
if (isDepth1) {
|
|
211
|
+
if (lineCount === 0) {
|
|
212
|
+
return `[bash:err] ${bashId}${exit}${tier} · 0 lines`;
|
|
213
|
+
}
|
|
214
|
+
const head = stderrLines.slice(0, 3).join("\n");
|
|
215
|
+
return `[bash:err] ${bashId}${exit}${tier} · ${linesLabel}\n> stderr:\n${head}`;
|
|
216
|
+
}
|
|
217
|
+
return `[bash:err] ${bashId}${exit}`;
|
|
218
|
+
}
|
|
219
|
+
return `[bash] ${bashId} · status unknown`;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Normalize a bash command string for use as a dedup key.
|
|
223
|
+
*/
|
|
224
|
+
function normalizeBashCommand(cmd) {
|
|
225
|
+
return cmd.trim().replace(/\s+/g, " ");
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Normalize a file path for use as a dedup key.
|
|
229
|
+
*/
|
|
230
|
+
function normalizeDedupPath(rawPath, cwd) {
|
|
231
|
+
let p = rawPath.replace(/\\/g, "/");
|
|
232
|
+
if (p.startsWith("./")) {
|
|
233
|
+
p = p.slice(2);
|
|
234
|
+
}
|
|
235
|
+
p = path.resolve(cwd, p);
|
|
236
|
+
p = path.normalize(p);
|
|
237
|
+
return p;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Scan all batch tool results in the snapshot and build a DedupIndex.
|
|
241
|
+
* Only successful writes/edits/deletes are tracked; error operations are exempt.
|
|
242
|
+
*/
|
|
243
|
+
function buildDedupIndex(lines, toolCallIdToName, toolCallIdToArgs, sessionCwd) {
|
|
244
|
+
const cwd = sessionCwd ?? process.cwd();
|
|
245
|
+
const latestWrite = new Map();
|
|
246
|
+
const latestEdit = new Map();
|
|
247
|
+
const latestDelete = new Map();
|
|
248
|
+
const latestWebSearch = new Map();
|
|
249
|
+
const latestWebFetch = new Map();
|
|
250
|
+
const latestAskUser = new Map();
|
|
251
|
+
const bashIdToCommand = new Map();
|
|
252
|
+
const latestBash = new Map();
|
|
253
|
+
for (const line of lines) {
|
|
254
|
+
let entry;
|
|
255
|
+
try {
|
|
256
|
+
entry = JSON.parse(line);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (entry?.type !== "message" || (entry.message?.role !== "tool" && entry.message?.role !== "toolResult"))
|
|
262
|
+
continue;
|
|
263
|
+
const toolCallId = entry.message?.toolCallId;
|
|
264
|
+
if (typeof toolCallId !== "string")
|
|
265
|
+
continue;
|
|
266
|
+
const toolName = toolCallIdToName.get(toolCallId);
|
|
267
|
+
if (toolName === "batch") {
|
|
268
|
+
const text = extractToolResultText(entry) ?? "";
|
|
269
|
+
const textLines = text.replace(/\r\n/g, "\n").split("\n");
|
|
270
|
+
for (let i = 0; i < textLines.length; i++) {
|
|
271
|
+
const l = textLines[i];
|
|
272
|
+
// Successful write
|
|
273
|
+
const writeMatch = l.match(/^--- write: (.+) \((\d+) bytes\) ---$/);
|
|
274
|
+
if (writeMatch) {
|
|
275
|
+
const normPath = normalizeDedupPath(writeMatch[1].trim(), cwd);
|
|
276
|
+
latestWrite.set(normPath, toolCallId);
|
|
277
|
+
latestEdit.delete(normPath); // write supersedes earlier edits
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// Error write — exempt from dedup
|
|
281
|
+
const errorWriteMatch = l.match(/^--- write: (.+) ---$/);
|
|
282
|
+
if (errorWriteMatch) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// Successful edit
|
|
286
|
+
const editMatch = l.match(/^--- edit: (.+) \(([^)]*)\) ---$/);
|
|
287
|
+
if (editMatch) {
|
|
288
|
+
const normPath = normalizeDedupPath(editMatch[1].trim(), cwd);
|
|
289
|
+
latestEdit.set(normPath, toolCallId);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
// Error edit — exempt from dedup
|
|
293
|
+
const errorEditMatch = l.match(/^--- edit: (.+) ---$/);
|
|
294
|
+
if (errorEditMatch) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
// Delete
|
|
298
|
+
const deleteMatch = l.match(/^--- delete: (.+) ---$/);
|
|
299
|
+
if (deleteMatch) {
|
|
300
|
+
const normPath = normalizeDedupPath(deleteMatch[1].trim(), cwd);
|
|
301
|
+
latestDelete.set(normPath, toolCallId);
|
|
302
|
+
latestWrite.delete(normPath); // delete supersedes earlier writes
|
|
303
|
+
latestEdit.delete(normPath); // delete supersedes earlier edits
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// B1: Extract bash commands from batch args for cross-turn dedup
|
|
307
|
+
const args = toolCallIdToArgs.get(toolCallId);
|
|
308
|
+
if (args && typeof args === "object") {
|
|
309
|
+
const a = args;
|
|
310
|
+
const ops = Array.isArray(a.o) ? a.o : Array.isArray(a.op) ? a.op : undefined;
|
|
311
|
+
if (ops) {
|
|
312
|
+
for (const op of ops) {
|
|
313
|
+
if (op && typeof op === "object") {
|
|
314
|
+
const opObj = op;
|
|
315
|
+
if (opObj.o === "bash" || opObj.op === "bash") {
|
|
316
|
+
const cmd = typeof opObj.c === "string" ? opObj.c : "";
|
|
317
|
+
const id = typeof opObj.i === "string" ? opObj.i : "";
|
|
318
|
+
if (cmd && id) {
|
|
319
|
+
const normCmd = normalizeBashCommand(cmd);
|
|
320
|
+
bashIdToCommand.set(id, normCmd);
|
|
321
|
+
latestBash.set(normCmd, id);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Ask_user tool results — build A1 dedup index
|
|
330
|
+
if (toolName === "ask_user") {
|
|
331
|
+
const args = toolCallIdToArgs.get(toolCallId);
|
|
332
|
+
if (args && typeof args === "object") {
|
|
333
|
+
const question = args.question;
|
|
334
|
+
if (typeof question === "string") {
|
|
335
|
+
const norm = question.trim().toLowerCase().slice(0, 120);
|
|
336
|
+
if (norm) {
|
|
337
|
+
latestAskUser.set(norm, toolCallId);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Web tool results — build Q1 dedup index
|
|
343
|
+
if (toolName === "web") {
|
|
344
|
+
const args = toolCallIdToArgs.get(toolCallId);
|
|
345
|
+
if (args && typeof args === "object") {
|
|
346
|
+
const a = args;
|
|
347
|
+
const ops = Array.isArray(a.o) ? a.o : Array.isArray(a.op) ? a.op : undefined;
|
|
348
|
+
if (ops && ops.length > 0) {
|
|
349
|
+
const firstOp = ops[0];
|
|
350
|
+
if (firstOp && typeof firstOp === "object") {
|
|
351
|
+
const query = typeof firstOp.q === "string" ? firstOp.q.trim().toLowerCase() : undefined;
|
|
352
|
+
const url = typeof firstOp.u === "string" ? firstOp.u.trim().replace(/\/$/, "") : undefined;
|
|
353
|
+
if (query)
|
|
354
|
+
latestWebSearch.set(query, toolCallId);
|
|
355
|
+
if (url)
|
|
356
|
+
latestWebFetch.set(url, toolCallId);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { latestWrite, latestEdit, latestDelete, latestWebSearch, latestWebFetch, latestAskUser, bashIdToCommand, latestBash };
|
|
363
|
+
}
|
|
364
|
+
/** Check if a web tool result is superseded by a later result with the same query or URL. */
|
|
365
|
+
function checkWebDedup(args, toolCallId, dedupIndex) {
|
|
366
|
+
if (!args || typeof args !== "object")
|
|
367
|
+
return { isSuperseded: false };
|
|
368
|
+
const a = args;
|
|
369
|
+
const ops = Array.isArray(a.o) ? a.o : Array.isArray(a.op) ? a.op : undefined;
|
|
370
|
+
if (!ops || ops.length === 0)
|
|
371
|
+
return { isSuperseded: false };
|
|
372
|
+
const firstOp = ops[0];
|
|
373
|
+
if (!firstOp || typeof firstOp !== "object")
|
|
374
|
+
return { isSuperseded: false };
|
|
375
|
+
if (typeof firstOp.q === "string") {
|
|
376
|
+
const normQuery = firstOp.q.trim().toLowerCase();
|
|
377
|
+
if (normQuery) {
|
|
378
|
+
const latestTc = dedupIndex.latestWebSearch.get(normQuery);
|
|
379
|
+
if (latestTc !== toolCallId) {
|
|
380
|
+
return {
|
|
381
|
+
isSuperseded: true,
|
|
382
|
+
marker: `[web:search] "${firstOp.q}" (superseded by later search)`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (typeof firstOp.u === "string") {
|
|
388
|
+
const normUrl = firstOp.u.trim().replace(/\/$/, "");
|
|
389
|
+
if (normUrl) {
|
|
390
|
+
const latestTc = dedupIndex.latestWebFetch.get(normUrl);
|
|
391
|
+
if (latestTc !== toolCallId) {
|
|
392
|
+
return {
|
|
393
|
+
isSuperseded: true,
|
|
394
|
+
marker: `[web:fetch] ${firstOp.u} (superseded by later fetch)`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return { isSuperseded: false };
|
|
400
|
+
}
|
|
401
|
+
/** Compress batch tool result: compress bash sections, truncate read content, dedup writes/edits/deletes (W1 + E1). */
|
|
402
|
+
function compressBatchResult(text, options = {}) {
|
|
403
|
+
const policy = options.depthPolicy ?? depthToPolicy(1);
|
|
404
|
+
const { toolCallId, latestWrite, latestEdit, latestDelete, bashIdToCommand, latestBash } = options;
|
|
405
|
+
const cwd = options.cwd ?? process.cwd();
|
|
184
406
|
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
407
|
+
// Pre-scan to find the last occurrence of each operation within this result.
|
|
408
|
+
// This handles the edge case where a single batch result contains multiple
|
|
409
|
+
// writes/edits/deletes to the same path.
|
|
410
|
+
const lastWriteIndex = new Map();
|
|
411
|
+
const lastEditIndex = new Map();
|
|
412
|
+
const lastDeleteIndex = new Map();
|
|
413
|
+
for (let j = 0; j < lines.length; j++) {
|
|
414
|
+
const w = lines[j].match(/^--- write: (.+) \((\d+) bytes\) ---$/);
|
|
415
|
+
if (w)
|
|
416
|
+
lastWriteIndex.set(normalizeDedupPath(w[1].trim(), cwd), j);
|
|
417
|
+
const e = lines[j].match(/^--- edit: (.+) \(([^)]*)\) ---$/);
|
|
418
|
+
if (e)
|
|
419
|
+
lastEditIndex.set(normalizeDedupPath(e[1].trim(), cwd), j);
|
|
420
|
+
const d = lines[j].match(/^--- delete: (.+) ---$/);
|
|
421
|
+
if (d)
|
|
422
|
+
lastDeleteIndex.set(normalizeDedupPath(d[1].trim(), cwd), j);
|
|
423
|
+
}
|
|
185
424
|
const out = [];
|
|
186
425
|
let i = 0;
|
|
426
|
+
const isSupersededWrite = (normPath, index) => {
|
|
427
|
+
if (!toolCallId)
|
|
428
|
+
return false;
|
|
429
|
+
const latestTc = latestWrite?.get(normPath);
|
|
430
|
+
if (latestTc !== toolCallId)
|
|
431
|
+
return true;
|
|
432
|
+
return lastWriteIndex.get(normPath) !== index;
|
|
433
|
+
};
|
|
434
|
+
const isSupersededEdit = (normPath, index) => {
|
|
435
|
+
if (!toolCallId)
|
|
436
|
+
return false;
|
|
437
|
+
const latestTc = latestEdit?.get(normPath);
|
|
438
|
+
if (latestTc !== toolCallId)
|
|
439
|
+
return true;
|
|
440
|
+
return lastEditIndex.get(normPath) !== index;
|
|
441
|
+
};
|
|
442
|
+
const isSupersededDelete = (normPath, index) => {
|
|
443
|
+
if (!toolCallId)
|
|
444
|
+
return false;
|
|
445
|
+
const latestTc = latestDelete?.get(normPath);
|
|
446
|
+
if (latestTc !== toolCallId)
|
|
447
|
+
return true;
|
|
448
|
+
return lastDeleteIndex.get(normPath) !== index;
|
|
449
|
+
};
|
|
187
450
|
while (i < lines.length) {
|
|
188
451
|
const line = lines[i];
|
|
189
|
-
//
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
192
|
-
|
|
452
|
+
// Bash section — compress with X1 protocol
|
|
453
|
+
const bashMatch = line.match(/^--- bash \[([^\]]+)\] (exit (\d+)|pending|error) ---$/);
|
|
454
|
+
if (bashMatch) {
|
|
455
|
+
const bashId = bashMatch[1];
|
|
456
|
+
const rawStatus = bashMatch[2];
|
|
457
|
+
const status = rawStatus.startsWith("exit") ? "ok" : rawStatus;
|
|
458
|
+
const exitCode = bashMatch[3] !== undefined ? Number(bashMatch[3]) : undefined;
|
|
459
|
+
// Stricter section-end check for bash content: don't treat generic
|
|
460
|
+
// `--- text ---` lines as section headers (they could be bash output).
|
|
461
|
+
const isBashSectionEnd = (l) => /^--- bash \[.+\]/.test(l) ||
|
|
462
|
+
/^--- (.+) \((\d+) lines\) ---$/.test(l) ||
|
|
463
|
+
/^--- (.+) (context map|file summary) ---$/.test(l) ||
|
|
464
|
+
/^--- edit: .+ ---$/.test(l) ||
|
|
465
|
+
/^--- write: .+ ---$/.test(l) ||
|
|
466
|
+
/^--- delete: .+ ---$/.test(l) ||
|
|
467
|
+
/^--- read: .+ ---$/.test(l) ||
|
|
468
|
+
/^--- rg: .+ ---$/.test(l);
|
|
469
|
+
// B1 cross-turn bash dedup
|
|
470
|
+
const normCmd = bashIdToCommand?.get(bashId);
|
|
471
|
+
const isSupersededBash = normCmd ? latestBash?.get(normCmd) !== bashId : false;
|
|
472
|
+
if (isSupersededBash) {
|
|
473
|
+
if (policy.showSupersededBreadcrumbs) {
|
|
474
|
+
const statusTag = status === "ok" ? "ok" : status === "pending" ? "pending" : "err";
|
|
475
|
+
out.push(`[bash:${statusTag}] ${bashId} (superseded)`);
|
|
476
|
+
}
|
|
477
|
+
i++;
|
|
478
|
+
while (i < lines.length && !isBashSectionEnd(lines[i])) {
|
|
479
|
+
i++;
|
|
480
|
+
}
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
i++;
|
|
484
|
+
let timingTier;
|
|
485
|
+
const stdoutLines = [];
|
|
486
|
+
let stderrLines = [];
|
|
487
|
+
let inStderr = false;
|
|
488
|
+
while (i < lines.length && !isBashSectionEnd(lines[i])) {
|
|
489
|
+
const contentLine = lines[i];
|
|
490
|
+
const timingMatch = contentLine.match(/^\[Execution time: (.+)\]$/);
|
|
491
|
+
if (timingMatch) {
|
|
492
|
+
timingTier = timingMatch[1];
|
|
493
|
+
}
|
|
494
|
+
else if (contentLine === "[stderr]") {
|
|
495
|
+
inStderr = true;
|
|
496
|
+
}
|
|
497
|
+
else if (contentLine === "[partial output]") {
|
|
498
|
+
// pending partial output marker — stdout follows
|
|
499
|
+
inStderr = false;
|
|
500
|
+
}
|
|
501
|
+
else if (contentLine.startsWith("[Use batch_bash_poll")) {
|
|
502
|
+
// skip poll hint lines
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
if (inStderr) {
|
|
506
|
+
stderrLines.push(contentLine);
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
stdoutLines.push(contentLine);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
i++;
|
|
513
|
+
}
|
|
514
|
+
// If error bash has no stderr but produced stdout, preserve the stdout
|
|
515
|
+
// as the error output so it isn't silently lost.
|
|
516
|
+
if (status === "error" && stderrLines.length === 0 && stdoutLines.length > 0) {
|
|
517
|
+
stderrLines = stdoutLines;
|
|
518
|
+
}
|
|
519
|
+
out.push(compressBashSection(bashId, status, exitCode, timingTier, stdoutLines, stderrLines, policy));
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
// R1: rg output compression
|
|
523
|
+
const rgMatch = line.match(/^--- rg: (.+) ---$/);
|
|
524
|
+
if (rgMatch) {
|
|
525
|
+
const rgPath = rgMatch[1].trim();
|
|
193
526
|
i++;
|
|
527
|
+
const rgLines = [];
|
|
194
528
|
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
529
|
+
rgLines.push(lines[i]);
|
|
195
530
|
i++;
|
|
196
531
|
}
|
|
532
|
+
// Trim trailing empty lines
|
|
533
|
+
while (rgLines.length > 0 && rgLines[rgLines.length - 1] === "")
|
|
534
|
+
rgLines.pop();
|
|
535
|
+
const matchCount = rgLines.length;
|
|
536
|
+
// Detect error: batch tool outputs "Error: <msg>" for failed rg ops
|
|
537
|
+
const firstNonEmpty = rgLines.find((l) => l.trim() !== "");
|
|
538
|
+
const isError = firstNonEmpty?.startsWith("Error:") ?? false;
|
|
539
|
+
if (isError) {
|
|
540
|
+
const lineCount = rgLines.filter((l) => l.trim() !== "").length;
|
|
541
|
+
const linesLabel = lineCount === 1 ? "1 line" : `${lineCount} lines`;
|
|
542
|
+
out.push(`[rg:err] ${rgPath} · ${linesLabel}`);
|
|
543
|
+
}
|
|
544
|
+
else if (matchCount === 0) {
|
|
545
|
+
out.push(`[rg:ok] ${rgPath} · 0 matches`);
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
// Extract unique file paths from rg output (format: path:line:content)
|
|
549
|
+
const fileSet = new Set(rgLines
|
|
550
|
+
.map((l) => {
|
|
551
|
+
const colonIdx = l.indexOf(":");
|
|
552
|
+
return colonIdx > 0 ? l.slice(0, colonIdx) : "";
|
|
553
|
+
})
|
|
554
|
+
.filter(Boolean));
|
|
555
|
+
const fileCount = fileSet.size;
|
|
556
|
+
if (policy.showPreviews) {
|
|
557
|
+
const head = rgLines.slice(0, 3).join("\n");
|
|
558
|
+
out.push(`[rg:ok] ${rgPath} · ${matchCount} matches · ${fileCount} files\n> head:\n${head}`);
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
out.push(`[rg:ok] ${rgPath} · ${matchCount} matches · ${fileCount} files`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
i--; // will be incremented by loop
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
// File read section with content — preview or truncate
|
|
568
|
+
const readMatch = line.match(/^--- (.+) \((\d+) lines\) ---$/);
|
|
569
|
+
if (readMatch) {
|
|
570
|
+
if (policy.showPreviews) {
|
|
571
|
+
i++;
|
|
572
|
+
const contentLines = [];
|
|
573
|
+
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
574
|
+
contentLines.push(lines[i]);
|
|
575
|
+
i++;
|
|
576
|
+
}
|
|
577
|
+
const head = contentLines.slice(0, 2).join("\n");
|
|
578
|
+
const tail = contentLines.slice(-2).join("\n");
|
|
579
|
+
let previewText;
|
|
580
|
+
if (contentLines.length > 4) {
|
|
581
|
+
previewText = `${head}\n[...${contentLines.length - 4} lines truncated...]\n${tail}`;
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
previewText = contentLines.join("\n");
|
|
585
|
+
}
|
|
586
|
+
out.push(`--- ${readMatch[1]} (${readMatch[2]} lines, preview) ---\n${previewText}`);
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
out.push(`--- ${readMatch[1]} (${readMatch[2]} lines, content truncated) ---`);
|
|
590
|
+
i++;
|
|
591
|
+
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
592
|
+
i++;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
197
595
|
continue;
|
|
198
596
|
}
|
|
199
597
|
// Context map / file summary section — truncate
|
|
@@ -206,21 +604,160 @@ function compressBatchResult(text) {
|
|
|
206
604
|
}
|
|
207
605
|
continue;
|
|
208
606
|
}
|
|
209
|
-
// File read without line count — truncate
|
|
607
|
+
// File read without line count — preview or truncate
|
|
210
608
|
// Negative lookahead excludes bash/edit/write/delete/read-error sections that should be kept verbatim
|
|
211
609
|
const fallbackReadMatch = line.match(/^--- (?!bash \[|edit:|write:|delete:|read:)(.+) ---$/);
|
|
212
610
|
if (fallbackReadMatch) {
|
|
213
|
-
|
|
611
|
+
if (policy.showPreviews) {
|
|
612
|
+
i++;
|
|
613
|
+
const contentLines = [];
|
|
614
|
+
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
615
|
+
contentLines.push(lines[i]);
|
|
616
|
+
i++;
|
|
617
|
+
}
|
|
618
|
+
const head = contentLines.slice(0, 2).join("\n");
|
|
619
|
+
const tail = contentLines.slice(-2).join("\n");
|
|
620
|
+
let previewText;
|
|
621
|
+
if (contentLines.length > 4) {
|
|
622
|
+
previewText = `${head}\n[...${contentLines.length - 4} lines truncated...]\n${tail}`;
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
previewText = contentLines.join("\n");
|
|
626
|
+
}
|
|
627
|
+
out.push(`--- ${fallbackReadMatch[1]} (preview) ---\n${previewText}`);
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
out.push(`--- ${fallbackReadMatch[1]} (content truncated) ---`);
|
|
631
|
+
i++;
|
|
632
|
+
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
633
|
+
i++;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
// Write section — W1 dedup and compression
|
|
639
|
+
const writeMatch = line.match(/^--- write: (.+) \((\d+) bytes\) ---$/);
|
|
640
|
+
if (writeMatch) {
|
|
641
|
+
const rawPath = writeMatch[1].trim();
|
|
642
|
+
const normPath = normalizeDedupPath(rawPath, cwd);
|
|
643
|
+
const bytes = writeMatch[2];
|
|
644
|
+
if (isSupersededWrite(normPath, i)) {
|
|
645
|
+
if (policy.showSupersededBreadcrumbs) {
|
|
646
|
+
out.push(`[batch:write] ${rawPath} (superseded)`);
|
|
647
|
+
}
|
|
648
|
+
i++;
|
|
649
|
+
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
650
|
+
i++;
|
|
651
|
+
}
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (policy.showBytes) {
|
|
655
|
+
out.push(`[batch:write] ${rawPath} (${bytes} bytes)`);
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
out.push(`[batch:write] ${rawPath}`);
|
|
659
|
+
}
|
|
660
|
+
i++;
|
|
661
|
+
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
662
|
+
i++;
|
|
663
|
+
}
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
// Error write — exempt from dedup, keep verbatim
|
|
667
|
+
const errorWriteMatch = line.match(/^--- write: (.+) ---$/);
|
|
668
|
+
if (errorWriteMatch) {
|
|
669
|
+
out.push(line);
|
|
670
|
+
i++;
|
|
671
|
+
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
672
|
+
out.push(lines[i]);
|
|
673
|
+
i++;
|
|
674
|
+
}
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
// Edit section — E1 dedup and compression
|
|
678
|
+
const editMatch = line.match(/^--- edit: (.+) \(([^)]*)\) ---$/);
|
|
679
|
+
if (editMatch) {
|
|
680
|
+
const rawPath = editMatch[1].trim();
|
|
681
|
+
const normPath = normalizeDedupPath(rawPath, cwd);
|
|
682
|
+
const blockInfo = editMatch[2];
|
|
683
|
+
if (isSupersededEdit(normPath, i)) {
|
|
684
|
+
if (policy.showSupersededBreadcrumbs) {
|
|
685
|
+
out.push(`[batch:edit] ${rawPath} (superseded)`);
|
|
686
|
+
}
|
|
687
|
+
i++;
|
|
688
|
+
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
689
|
+
i++;
|
|
690
|
+
}
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (policy.showEditBlocks) {
|
|
694
|
+
const blocksLabel = blockInfo ? ` (${blockInfo})` : "";
|
|
695
|
+
out.push(`[batch:edit] ${rawPath}${blocksLabel}`);
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
out.push(`[batch:edit] ${rawPath}`);
|
|
699
|
+
}
|
|
700
|
+
i++;
|
|
701
|
+
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
702
|
+
i++;
|
|
703
|
+
}
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
// Error edit — exempt from dedup, keep verbatim
|
|
707
|
+
const errorEditMatch = line.match(/^--- edit: (.+) ---$/);
|
|
708
|
+
if (errorEditMatch) {
|
|
709
|
+
out.push(line);
|
|
710
|
+
i++;
|
|
711
|
+
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
712
|
+
out.push(lines[i]);
|
|
713
|
+
i++;
|
|
714
|
+
}
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
// Delete section — keep existing format for non-superseded, skip superseded
|
|
718
|
+
const deleteMatch = line.match(/^--- delete: (.+) ---$/);
|
|
719
|
+
if (deleteMatch) {
|
|
720
|
+
const rawPath = deleteMatch[1].trim();
|
|
721
|
+
const normPath = normalizeDedupPath(rawPath, cwd);
|
|
722
|
+
if (isSupersededDelete(normPath, i)) {
|
|
723
|
+
if (policy.showSupersededBreadcrumbs) {
|
|
724
|
+
out.push(`[batch:delete] ${rawPath} (superseded)`);
|
|
725
|
+
}
|
|
726
|
+
i++;
|
|
727
|
+
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
728
|
+
i++;
|
|
729
|
+
}
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
out.push(line);
|
|
214
733
|
i++;
|
|
215
734
|
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
735
|
+
out.push(lines[i]);
|
|
216
736
|
i++;
|
|
217
737
|
}
|
|
218
738
|
continue;
|
|
219
739
|
}
|
|
220
|
-
// Everything else (
|
|
740
|
+
// Everything else (summary, error generic, etc.) — keep as-is
|
|
221
741
|
out.push(line);
|
|
222
742
|
i++;
|
|
223
743
|
}
|
|
744
|
+
// B1: If every line in out is superseded or truncated noise, collapse to single summary
|
|
745
|
+
const meaningfulOut = out.filter((l) => l.trim() !== "");
|
|
746
|
+
// Single-pass rollup check: matches superseded breadcrumbs, truncated reads,
|
|
747
|
+
// and compact bash/rg lines. Equivalent to the previous multi-check logic.
|
|
748
|
+
const SUPERSEDED_OR_TRUNCATED_RE = /\(superseded\)|\(content truncated\)|\(context map, truncated\)|^\[bash:(ok|pending|err)\] |^\[bash:poll\] |^\[rg:(ok|err)\] /;
|
|
749
|
+
const isAllSupersededOrTruncated = meaningfulOut.length > 0 && meaningfulOut.every((l) => SUPERSEDED_OR_TRUNCATED_RE.test(l));
|
|
750
|
+
// At depth 2+, superseded writes/edits are dropped entirely (no breadcrumbs),
|
|
751
|
+
// leaving out empty. If the original text had only section headers and no
|
|
752
|
+
// summary or other kept content, rollup to a single line.
|
|
753
|
+
const meaningfulLines = lines.filter((l) => l.trim() !== "");
|
|
754
|
+
const allLinesWereSectionHeaders = meaningfulLines.length > 0 && meaningfulLines.every((l) => isKnownSectionHeader(l));
|
|
755
|
+
if (isAllSupersededOrTruncated || (allLinesWereSectionHeaders && meaningfulOut.length === 0)) {
|
|
756
|
+
const opCount = meaningfulLines.length > 0 ? String(meaningfulLines.length) : "0";
|
|
757
|
+
return policy.showSupersededBreadcrumbs
|
|
758
|
+
? `[batch] ${opCount} ops (all superseded or truncated by later operations)`
|
|
759
|
+
: `[batch] ${opCount} ops (superseded)`;
|
|
760
|
+
}
|
|
224
761
|
return out.join("\n");
|
|
225
762
|
}
|
|
226
763
|
/** Compress web tool result into compact metadata. */
|
|
@@ -233,8 +770,10 @@ function compressWebResult(text, args) {
|
|
|
233
770
|
const ops = Array.isArray(a.o) ? a.o : Array.isArray(a.op) ? a.op : undefined;
|
|
234
771
|
if (ops && ops.length > 0) {
|
|
235
772
|
const firstOp = ops[0];
|
|
236
|
-
|
|
237
|
-
|
|
773
|
+
if (firstOp && typeof firstOp === "object") {
|
|
774
|
+
query = typeof firstOp.q === "string" ? firstOp.q : undefined;
|
|
775
|
+
url = typeof firstOp.u === "string" ? firstOp.u : undefined;
|
|
776
|
+
}
|
|
238
777
|
}
|
|
239
778
|
}
|
|
240
779
|
// Search result format: numbered list
|
|
@@ -277,6 +816,113 @@ function compressAskUserResult(text, args) {
|
|
|
277
816
|
}
|
|
278
817
|
return `[ask_user] · ${text.length} chars`;
|
|
279
818
|
}
|
|
819
|
+
/** Compress batch_bash_poll tool result into compact metadata (S4 + B1). */
|
|
820
|
+
function compressBatchBashPollResult(text, policy, options) {
|
|
821
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
822
|
+
const out = [];
|
|
823
|
+
let i = 0;
|
|
824
|
+
const isDepth1 = policy.showPreviews;
|
|
825
|
+
const isPollSectionEnd = (l) => /^--- \[.+\]/.test(l);
|
|
826
|
+
while (i < lines.length) {
|
|
827
|
+
const line = lines[i];
|
|
828
|
+
const completedMatch = line.match(/^--- \[([^\]]+)\] (exit (\d+)|interrupted) ---$/);
|
|
829
|
+
const pendingMatch = line.match(/^--- \[([^\]]+)\] still running ---$/);
|
|
830
|
+
if (completedMatch || pendingMatch) {
|
|
831
|
+
const id = (completedMatch ?? pendingMatch)[1];
|
|
832
|
+
const isCompleted = !!completedMatch;
|
|
833
|
+
const exitCode = completedMatch?.[3] !== undefined ? Number(completedMatch[3]) : undefined;
|
|
834
|
+
// B1 cross-turn bash dedup for poll results
|
|
835
|
+
const normCmd = options?.bashIdToCommand?.get(id);
|
|
836
|
+
const isSuperseded = normCmd ? options?.latestBash?.get(normCmd) !== id : false;
|
|
837
|
+
if (isSuperseded) {
|
|
838
|
+
if (policy.showSupersededBreadcrumbs) {
|
|
839
|
+
out.push(`[bash:poll] ${id} (superseded)`);
|
|
840
|
+
}
|
|
841
|
+
i++;
|
|
842
|
+
while (i < lines.length && !isPollSectionEnd(lines[i])) {
|
|
843
|
+
i++;
|
|
844
|
+
}
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
i++;
|
|
848
|
+
let timingTier;
|
|
849
|
+
const stdoutLines = [];
|
|
850
|
+
let stderrLines = [];
|
|
851
|
+
let inStderr = false;
|
|
852
|
+
while (i < lines.length && !isPollSectionEnd(lines[i])) {
|
|
853
|
+
const contentLine = lines[i];
|
|
854
|
+
const timingMatch = contentLine.match(/^\[Execution time: (.+)\]$/);
|
|
855
|
+
if (timingMatch) {
|
|
856
|
+
timingTier = timingMatch[1];
|
|
857
|
+
}
|
|
858
|
+
else if (contentLine === "[stderr]") {
|
|
859
|
+
inStderr = true;
|
|
860
|
+
}
|
|
861
|
+
else if (contentLine === "[output so far]") {
|
|
862
|
+
inStderr = false;
|
|
863
|
+
}
|
|
864
|
+
else if (contentLine.trim() === "") {
|
|
865
|
+
// skip empty lines between sections
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
if (inStderr) {
|
|
869
|
+
stderrLines.push(contentLine);
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
stdoutLines.push(contentLine);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
i++;
|
|
876
|
+
}
|
|
877
|
+
const tier = timingTier ? ` · ${timingTier}` : "";
|
|
878
|
+
if (isCompleted) {
|
|
879
|
+
const statusTag = exitCode !== undefined ? `exit ${exitCode}` : "interrupted";
|
|
880
|
+
const statusLabel = exitCode === 0 ? "ok" : "error";
|
|
881
|
+
if (statusLabel === "error" && stderrLines.length === 0 && stdoutLines.length > 0) {
|
|
882
|
+
stderrLines = stdoutLines;
|
|
883
|
+
}
|
|
884
|
+
const targetLines = statusLabel === "error" ? stderrLines : stdoutLines;
|
|
885
|
+
const lineCount = targetLines.length;
|
|
886
|
+
if (isDepth1) {
|
|
887
|
+
if (lineCount === 0) {
|
|
888
|
+
out.push(`[bash:poll] ${id} · ${statusTag}${tier} · 0 lines`);
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
const linesLabel = statusLabel === "error"
|
|
892
|
+
? (lineCount === 1 ? "1 line stderr" : `${lineCount} lines stderr`)
|
|
893
|
+
: (lineCount === 1 ? "1 line" : `${lineCount} lines`);
|
|
894
|
+
const headPrefix = statusLabel === "error" ? "> stderr:" : "> head:";
|
|
895
|
+
const head = targetLines.slice(0, 3).join("\n");
|
|
896
|
+
out.push(`[bash:poll] ${id} · ${statusTag}${tier} · ${linesLabel}\n${headPrefix}\n${head}`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
out.push(`[bash:poll] ${id} · ${statusTag}${tier}`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
const lineCount = stdoutLines.length;
|
|
905
|
+
const linesLabel = lineCount === 1 ? "1 line partial" : `${lineCount} lines partial`;
|
|
906
|
+
if (isDepth1) {
|
|
907
|
+
if (lineCount === 0) {
|
|
908
|
+
out.push(`[bash:poll] ${id} · still running · 0 lines partial`);
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
const head = stdoutLines.slice(0, 3).join("\n");
|
|
912
|
+
out.push(`[bash:poll] ${id} · still running · ${linesLabel}\n> head:\n${head}`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
out.push(`[bash:poll] ${id} · still running`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
out.push(line);
|
|
922
|
+
i++;
|
|
923
|
+
}
|
|
924
|
+
return out.join("\n");
|
|
925
|
+
}
|
|
280
926
|
// ---------------------------------------------------------------------------
|
|
281
927
|
// Shared: toolCallId → toolName mapping
|
|
282
928
|
// ---------------------------------------------------------------------------
|
|
@@ -320,7 +966,8 @@ function buildToolCallIdToNameMap(lines) {
|
|
|
320
966
|
* - `batch_read` results: replaced with compact metadata (paths + op count)
|
|
321
967
|
* since children have `batch` and can re-read files themselves.
|
|
322
968
|
*/
|
|
323
|
-
export function compressToolResults(snapshot, cache) {
|
|
969
|
+
export function compressToolResults(snapshot, cache, depthPolicy) {
|
|
970
|
+
const policy = depthPolicy ?? depthToPolicy(1);
|
|
324
971
|
const lines = snapshot.trimEnd().split("\n");
|
|
325
972
|
// Quick check: if there are no flow cache entries and no compressible tool calls,
|
|
326
973
|
// nothing to compress — return early.
|
|
@@ -331,7 +978,7 @@ export function compressToolResults(snapshot, cache) {
|
|
|
331
978
|
return entry?.type === "message" && entry.message?.role === "assistant" &&
|
|
332
979
|
Array.isArray(entry.message.content) &&
|
|
333
980
|
entry.message.content.some((p) => p.type === "toolCall" &&
|
|
334
|
-
["batch_read", "batch", "web", "ask_user"].includes(p.name));
|
|
981
|
+
["batch_read", "batch", "web", "ask_user", "batch_bash_poll"].includes(p.name));
|
|
335
982
|
}
|
|
336
983
|
catch {
|
|
337
984
|
return false;
|
|
@@ -371,11 +1018,31 @@ export function compressToolResults(snapshot, cache) {
|
|
|
371
1018
|
continue;
|
|
372
1019
|
for (const part of content) {
|
|
373
1020
|
if (part.type === "toolCall" && (part.id || part.toolCallId) && part.arguments) {
|
|
374
|
-
toolCallIdToArgs.set(part.id ?? part.toolCallId, part.arguments);
|
|
1021
|
+
toolCallIdToArgs.set((part.id ?? part.toolCallId), part.arguments);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
// Extract session cwd from header for path normalization
|
|
1026
|
+
let sessionCwd = process.cwd();
|
|
1027
|
+
for (const line of lines) {
|
|
1028
|
+
let entry;
|
|
1029
|
+
try {
|
|
1030
|
+
entry = JSON.parse(line);
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
if (entry?.type === "session" || entry?.type === "header") {
|
|
1036
|
+
if (typeof entry.cwd === "string") {
|
|
1037
|
+
sessionCwd = entry.cwd;
|
|
1038
|
+
break;
|
|
375
1039
|
}
|
|
376
1040
|
}
|
|
377
1041
|
}
|
|
1042
|
+
// === PASS 1 (pre-scan): Build DedupIndex for batch and web tool results (W1 + E1 + Q1) ===
|
|
1043
|
+
const dedupIndex = buildDedupIndex(lines, toolCallIdToName, toolCallIdToArgs, sessionCwd);
|
|
378
1044
|
const result = [];
|
|
1045
|
+
let webSummaryEmitted = false;
|
|
379
1046
|
// Second pass: compress matching tool results
|
|
380
1047
|
for (const line of lines) {
|
|
381
1048
|
let entry;
|
|
@@ -430,51 +1097,115 @@ export function compressToolResults(snapshot, cache) {
|
|
|
430
1097
|
// Cache miss (never populated or evicted) — do NOT pass megabytes of raw
|
|
431
1098
|
// flow output verbatim into child context. Render a minimal placeholder.
|
|
432
1099
|
originalText = extractToolResultText(entry) ?? "";
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
1100
|
+
const flowArgs = toolCallIdToArgs.get(toolCallId);
|
|
1101
|
+
let flowTypeSuffix = '';
|
|
1102
|
+
if (flowArgs && typeof flowArgs === 'object') {
|
|
1103
|
+
const flowArr = Array.isArray(flowArgs.flow)
|
|
1104
|
+
? flowArgs.flow
|
|
1105
|
+
: undefined;
|
|
1106
|
+
if (flowArr && flowArr.length > 0 && typeof flowArr[0].type === 'string') {
|
|
1107
|
+
flowTypeSuffix = `:${flowArr[0].type}`;
|
|
1108
|
+
}
|
|
1109
|
+
else if (typeof flowArgs.type === 'string') {
|
|
1110
|
+
flowTypeSuffix = `:${flowArgs.type}`;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
const statusLabel = entry.message.isError ? 'failed' : 'completed';
|
|
1114
|
+
rendered = `[flow${flowTypeSuffix}] ${statusLabel} · see prior session`;
|
|
439
1115
|
}
|
|
440
1116
|
else {
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
1117
|
+
const renderedParts = [];
|
|
1118
|
+
for (const r of compressed) {
|
|
1119
|
+
const renderedResult = renderCompressedFlowResult(r);
|
|
1120
|
+
if (renderedResult === undefined) {
|
|
1121
|
+
// Granular fallback: only this element is malformed, don't waste
|
|
1122
|
+
// valid siblings by falling back the entire array to raw text.
|
|
1123
|
+
const flowType = r.type ?? "unknown";
|
|
1124
|
+
const status = r.status ?? "unknown";
|
|
1125
|
+
renderedParts.push(`[flow:${flowType}] ${status} (cache miss)`);
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
renderedParts.push(renderedResult);
|
|
1129
|
+
}
|
|
453
1130
|
}
|
|
1131
|
+
rendered = renderedParts.join("\n\n");
|
|
454
1132
|
}
|
|
455
1133
|
}
|
|
456
|
-
//
|
|
457
|
-
// before compressToolResults runs, so this branch is no longer needed.
|
|
458
|
-
// Kept as a no-op safety net for any edge cases.
|
|
459
|
-
else if (toolName === "batch_read") {
|
|
460
|
-
rendered = undefined; // handled upstream
|
|
461
|
-
}
|
|
462
|
-
// --- Compress batch tool results (selective: keep bash, truncate reads) ---
|
|
1134
|
+
// --- Compress batch tool results (selective: compress bash, truncate reads, dedup writes/edits/deletes) ---
|
|
463
1135
|
else if (toolName === "batch") {
|
|
464
1136
|
originalText = extractToolResultText(entry) ?? "";
|
|
465
|
-
rendered = compressBatchResult(originalText
|
|
1137
|
+
rendered = compressBatchResult(originalText, {
|
|
1138
|
+
depthPolicy: policy,
|
|
1139
|
+
toolCallId,
|
|
1140
|
+
latestWrite: dedupIndex.latestWrite,
|
|
1141
|
+
latestEdit: dedupIndex.latestEdit,
|
|
1142
|
+
latestDelete: dedupIndex.latestDelete,
|
|
1143
|
+
bashIdToCommand: dedupIndex.bashIdToCommand,
|
|
1144
|
+
latestBash: dedupIndex.latestBash,
|
|
1145
|
+
cwd: sessionCwd,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
// --- Compress batch_bash_poll tool results (S4 + B1) ---
|
|
1149
|
+
else if (toolName === "batch_bash_poll") {
|
|
1150
|
+
originalText = extractToolResultText(entry) ?? "";
|
|
1151
|
+
rendered = compressBatchBashPollResult(originalText, policy, {
|
|
1152
|
+
bashIdToCommand: dedupIndex.bashIdToCommand,
|
|
1153
|
+
latestBash: dedupIndex.latestBash,
|
|
1154
|
+
});
|
|
466
1155
|
}
|
|
467
|
-
// --- Compress web tool results ---
|
|
1156
|
+
// --- Compress web tool results (Q1 dedup) ---
|
|
468
1157
|
else if (toolName === "web") {
|
|
469
1158
|
originalText = extractToolResultText(entry) ?? "";
|
|
470
1159
|
const args = toolCallIdToArgs.get(toolCallId);
|
|
471
|
-
|
|
1160
|
+
const { isSuperseded, marker } = checkWebDedup(args, toolCallId, dedupIndex);
|
|
1161
|
+
if (isSuperseded) {
|
|
1162
|
+
if (policy.showSupersededBreadcrumbs) {
|
|
1163
|
+
rendered = marker;
|
|
1164
|
+
}
|
|
1165
|
+
else {
|
|
1166
|
+
// At depth 2+, drop superseded web results entirely
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
else {
|
|
1171
|
+
rendered = compressWebResult(originalText, args);
|
|
1172
|
+
if (!policy.showSupersededBreadcrumbs && !webSummaryEmitted) {
|
|
1173
|
+
const searchCount = dedupIndex.latestWebSearch.size;
|
|
1174
|
+
const fetchCount = dedupIndex.latestWebFetch.size;
|
|
1175
|
+
const total = searchCount + fetchCount;
|
|
1176
|
+
if (total > 0) {
|
|
1177
|
+
const fetchLabel = fetchCount === 1 ? "fetch" : "fetches";
|
|
1178
|
+
rendered = `[web] ${total} unique queries (${searchCount} searches, ${fetchCount} ${fetchLabel}) · latest per query below\n${rendered}`;
|
|
1179
|
+
webSummaryEmitted = true;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
472
1183
|
}
|
|
473
|
-
// --- Compress ask_user tool results ---
|
|
1184
|
+
// --- Compress ask_user tool results (A1 dedup) ---
|
|
474
1185
|
else if (toolName === "ask_user") {
|
|
475
1186
|
originalText = extractToolResultText(entry) ?? "";
|
|
476
1187
|
const args = toolCallIdToArgs.get(toolCallId);
|
|
477
|
-
|
|
1188
|
+
let question = "";
|
|
1189
|
+
if (args && typeof args === "object") {
|
|
1190
|
+
const q = args.question;
|
|
1191
|
+
if (typeof q === "string") {
|
|
1192
|
+
question = q;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
const normQuestion = question.trim().toLowerCase().slice(0, 120);
|
|
1196
|
+
const latestTc = normQuestion ? dedupIndex.latestAskUser.get(normQuestion) : undefined;
|
|
1197
|
+
if (latestTc && latestTc !== toolCallId) {
|
|
1198
|
+
if (policy.showSupersededBreadcrumbs) {
|
|
1199
|
+
rendered = `[ask_user] "${question}" (superseded by later ask_user)`;
|
|
1200
|
+
}
|
|
1201
|
+
else {
|
|
1202
|
+
// At depth 2+, drop superseded ask_user results entirely
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
else {
|
|
1207
|
+
rendered = compressAskUserResult(originalText, args);
|
|
1208
|
+
}
|
|
478
1209
|
}
|
|
479
1210
|
if (rendered !== undefined) {
|
|
480
1211
|
logCompress(toolName ?? "unknown", originalText.length || line.length, rendered.length);
|
|
@@ -523,13 +1254,6 @@ function extractToolResultText(entry) {
|
|
|
523
1254
|
}
|
|
524
1255
|
return undefined;
|
|
525
1256
|
}
|
|
526
|
-
/**
|
|
527
|
-
* Backward-compatible alias for compressToolResults.
|
|
528
|
-
* @deprecated Use compressToolResults instead.
|
|
529
|
-
*/
|
|
530
|
-
export function compressFlowToolResults(snapshot, cache) {
|
|
531
|
-
return compressToolResults(snapshot, cache);
|
|
532
|
-
}
|
|
533
1257
|
// ---------------------------------------------------------------------------
|
|
534
1258
|
// batch_read tool call stripping
|
|
535
1259
|
// ---------------------------------------------------------------------------
|
|
@@ -543,6 +1267,44 @@ export function compressFlowToolResults(snapshot, cache) {
|
|
|
543
1267
|
* results causes strict API providers (e.g. kimi-coding, DeepSeek) to reject
|
|
544
1268
|
* the request with `tool_call_id is not found`.
|
|
545
1269
|
*/
|
|
1270
|
+
/**
|
|
1271
|
+
* Check if an assistant message is empty (continuation marker with no semantic value).
|
|
1272
|
+
* Empty means: no substantive text, no tool calls.
|
|
1273
|
+
*/
|
|
1274
|
+
function isEmptyAssistantMessage(message) {
|
|
1275
|
+
if (message.role !== "assistant")
|
|
1276
|
+
return false;
|
|
1277
|
+
const content = message.content;
|
|
1278
|
+
// Null/undefined/empty string
|
|
1279
|
+
if (content === null || content === undefined || content === "")
|
|
1280
|
+
return true;
|
|
1281
|
+
// Whitespace-only string
|
|
1282
|
+
if (typeof content === "string" && content.trim() === "")
|
|
1283
|
+
return true;
|
|
1284
|
+
// Array content: check for no text parts or only whitespace text parts, and NO tool calls
|
|
1285
|
+
if (Array.isArray(content)) {
|
|
1286
|
+
const hasToolCall = content.some((p) => p.type === "toolCall");
|
|
1287
|
+
if (hasToolCall)
|
|
1288
|
+
return false;
|
|
1289
|
+
const textParts = content.filter((p) => p.type === "text" && typeof p.text === "string");
|
|
1290
|
+
if (textParts.length === 0)
|
|
1291
|
+
return true;
|
|
1292
|
+
const allWhitespace = textParts.every((p) => p.text.trim() === "");
|
|
1293
|
+
if (allWhitespace)
|
|
1294
|
+
return true;
|
|
1295
|
+
// Low-signal detection: short text with no actionable markers
|
|
1296
|
+
const fullText = textParts.map((p) => p.text).join("");
|
|
1297
|
+
if (fullText.length < 300) {
|
|
1298
|
+
const hasFilePath = /\w+\.\w+/.test(fullText);
|
|
1299
|
+
const hasToolReference = /\[[a-z_]+[^\]]*\]/.test(fullText);
|
|
1300
|
+
const hasCodeBlock = fullText.includes("```");
|
|
1301
|
+
const hasActionableMarkers = hasFilePath || hasToolReference || hasCodeBlock;
|
|
1302
|
+
if (!hasActionableMarkers)
|
|
1303
|
+
return true;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return false;
|
|
1307
|
+
}
|
|
546
1308
|
export function stripBatchReadToolCalls(snapshot) {
|
|
547
1309
|
const lines = snapshot.trimEnd().split("\n");
|
|
548
1310
|
// Pass 1: Collect all batch_read toolCallIds from assistant messages.
|
|
@@ -562,7 +1324,7 @@ export function stripBatchReadToolCalls(snapshot) {
|
|
|
562
1324
|
continue;
|
|
563
1325
|
for (const part of content) {
|
|
564
1326
|
if (part.type === "toolCall" && part.name === "batch_read" && (part.id || part.toolCallId)) {
|
|
565
|
-
batchReadToolCallIds.add(part.id ?? part.toolCallId);
|
|
1327
|
+
batchReadToolCallIds.add((part.id ?? part.toolCallId));
|
|
566
1328
|
}
|
|
567
1329
|
}
|
|
568
1330
|
}
|
|
@@ -607,7 +1369,9 @@ export function stripBatchReadToolCalls(snapshot) {
|
|
|
607
1369
|
}
|
|
608
1370
|
const filteredContent = content.filter((part) => !(part.type === "toolCall" && part.name === "batch_read"));
|
|
609
1371
|
if (filteredContent.length === 0) {
|
|
610
|
-
|
|
1372
|
+
// Skip assistant messages that have no content after stripping batch_read
|
|
1373
|
+
// — an empty text placeholder wastes tokens and conveys nothing.
|
|
1374
|
+
continue;
|
|
611
1375
|
}
|
|
612
1376
|
result.push(JSON.stringify({
|
|
613
1377
|
...entry,
|
|
@@ -620,6 +1384,75 @@ export function stripBatchReadToolCalls(snapshot) {
|
|
|
620
1384
|
return `${result.join("\n")}\n`;
|
|
621
1385
|
}
|
|
622
1386
|
// ---------------------------------------------------------------------------
|
|
1387
|
+
// Flow tool call argument compression
|
|
1388
|
+
// ---------------------------------------------------------------------------
|
|
1389
|
+
/**
|
|
1390
|
+
* Compress verbose `flow` tool call arguments in assistant messages.
|
|
1391
|
+
*
|
|
1392
|
+
* The child flow already receives its own `-p` activation prompt, so the full
|
|
1393
|
+
* mission text inside the JSONL assistant message is pure duplication.
|
|
1394
|
+
* Replaces the arguments with a compact summary `{type, aim, steps}`.
|
|
1395
|
+
*/
|
|
1396
|
+
export function compressFlowToolCallArgs(snapshot) {
|
|
1397
|
+
const lines = snapshot.trimEnd().split("\n");
|
|
1398
|
+
const result = [];
|
|
1399
|
+
for (const line of lines) {
|
|
1400
|
+
let entry;
|
|
1401
|
+
try {
|
|
1402
|
+
entry = JSON.parse(line);
|
|
1403
|
+
}
|
|
1404
|
+
catch {
|
|
1405
|
+
result.push(line);
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
if (entry?.type !== "message" || entry.message?.role !== "assistant") {
|
|
1409
|
+
result.push(line);
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
const content = entry.message.content;
|
|
1413
|
+
if (!Array.isArray(content)) {
|
|
1414
|
+
result.push(line);
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
let modified = false;
|
|
1418
|
+
const newContent = content.map((part) => {
|
|
1419
|
+
if (part.type !== "toolCall" || part.name !== "flow")
|
|
1420
|
+
return part;
|
|
1421
|
+
const args = part.arguments;
|
|
1422
|
+
if (!args || typeof args !== "object")
|
|
1423
|
+
return part;
|
|
1424
|
+
const flowArr = Array.isArray(args.flow)
|
|
1425
|
+
? args.flow
|
|
1426
|
+
: undefined;
|
|
1427
|
+
if (!flowArr || flowArr.length === 0)
|
|
1428
|
+
return part;
|
|
1429
|
+
const firstFlow = flowArr[0];
|
|
1430
|
+
const type = typeof firstFlow?.type === "string" ? firstFlow.type : undefined;
|
|
1431
|
+
const aim = typeof firstFlow?.aim === "string" ? firstFlow.aim : undefined;
|
|
1432
|
+
const steps = Array.isArray(firstFlow?.steps) ? firstFlow.steps.length : undefined;
|
|
1433
|
+
if (!type && !aim && steps === undefined)
|
|
1434
|
+
return part;
|
|
1435
|
+
modified = true;
|
|
1436
|
+
return {
|
|
1437
|
+
...part,
|
|
1438
|
+
arguments: { type, aim, steps },
|
|
1439
|
+
};
|
|
1440
|
+
});
|
|
1441
|
+
if (!modified) {
|
|
1442
|
+
result.push(line);
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
result.push(JSON.stringify({
|
|
1446
|
+
...entry,
|
|
1447
|
+
message: {
|
|
1448
|
+
...entry.message,
|
|
1449
|
+
content: newContent,
|
|
1450
|
+
},
|
|
1451
|
+
}));
|
|
1452
|
+
}
|
|
1453
|
+
return `${result.join("\n")}\n`;
|
|
1454
|
+
}
|
|
1455
|
+
// ---------------------------------------------------------------------------
|
|
623
1456
|
// Reparent orphans
|
|
624
1457
|
// ---------------------------------------------------------------------------
|
|
625
1458
|
/**
|
|
@@ -635,34 +1468,46 @@ function reparentOrphans(snapshot) {
|
|
|
635
1468
|
const id = entry?.message?.id ?? entry?.message?.messageId ?? entry?.id;
|
|
636
1469
|
if (typeof id === "string" && id)
|
|
637
1470
|
survivingIds.add(id);
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
survivingIds.add(parentId);
|
|
1471
|
+
// Only ids of actual entries should be in survivingIds; parentId refs
|
|
1472
|
+
// are checked in the second pass, not added here.
|
|
641
1473
|
}
|
|
642
1474
|
catch { /* ignore */ }
|
|
643
1475
|
}
|
|
644
1476
|
for (let i = 0; i < lines.length; i++) {
|
|
645
1477
|
try {
|
|
646
1478
|
let entry = JSON.parse(lines[i]);
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
if (
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
1479
|
+
let modified = false;
|
|
1480
|
+
const isMessageEntry = entry?.type === "message";
|
|
1481
|
+
// Fix top-level parentId only for message entries (not session headers).
|
|
1482
|
+
if (isMessageEntry) {
|
|
1483
|
+
if (typeof entry.parentId === "string" && entry.parentId && !survivingIds.has(entry.parentId)) {
|
|
1484
|
+
const { parentId: _pid, ...restEntry } = entry;
|
|
1485
|
+
entry = restEntry;
|
|
1486
|
+
modified = true;
|
|
1487
|
+
}
|
|
1488
|
+
if (typeof entry.parentMessageId === "string" && entry.parentMessageId && !survivingIds.has(entry.parentMessageId)) {
|
|
1489
|
+
const { parentMessageId: _pmid, ...restEntry } = entry;
|
|
654
1490
|
entry = restEntry;
|
|
655
1491
|
modified = true;
|
|
656
1492
|
}
|
|
657
|
-
|
|
658
|
-
|
|
1493
|
+
}
|
|
1494
|
+
// Fix message-level parentId for all entries.
|
|
1495
|
+
const msg = entry.message;
|
|
1496
|
+
if (msg) {
|
|
1497
|
+
if (typeof msg.parentId === "string" && msg.parentId && !survivingIds.has(msg.parentId)) {
|
|
1498
|
+
const { parentId: _pid, ...restMessage } = msg;
|
|
659
1499
|
entry = { ...entry, message: restMessage };
|
|
660
1500
|
modified = true;
|
|
661
1501
|
}
|
|
662
|
-
if (
|
|
663
|
-
|
|
1502
|
+
if (typeof msg.parentMessageId === "string" && msg.parentMessageId && !survivingIds.has(msg.parentMessageId)) {
|
|
1503
|
+
const { parentMessageId: _pmid, ...restMessage } = msg;
|
|
1504
|
+
entry = { ...entry, message: restMessage };
|
|
1505
|
+
modified = true;
|
|
664
1506
|
}
|
|
665
1507
|
}
|
|
1508
|
+
if (modified) {
|
|
1509
|
+
lines[i] = JSON.stringify(entry);
|
|
1510
|
+
}
|
|
666
1511
|
}
|
|
667
1512
|
catch { /* ignore */ }
|
|
668
1513
|
}
|
|
@@ -670,7 +1515,7 @@ function reparentOrphans(snapshot) {
|
|
|
670
1515
|
}
|
|
671
1516
|
export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
|
|
672
1517
|
if (!snapshot)
|
|
673
|
-
return { result: snapshot, passesApplied: [] };
|
|
1518
|
+
return { result: snapshot, passesApplied: [], stats: null };
|
|
674
1519
|
const preBytes = snapshot.length;
|
|
675
1520
|
const lines = snapshot.trimEnd().split("\n");
|
|
676
1521
|
const sanitizedLines = [];
|
|
@@ -686,20 +1531,8 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
|
|
|
686
1531
|
continue;
|
|
687
1532
|
}
|
|
688
1533
|
let changed = false;
|
|
689
|
-
// Header (first line):
|
|
1534
|
+
// Header (first line): replace parent system prompt.
|
|
690
1535
|
if (i === 0 && entry && typeof entry === "object") {
|
|
691
|
-
// Inject fork metadata so children know their lineage.
|
|
692
|
-
if (options && (options.forkedFrom || options.forkedAt || options.parentFlow || options.depth !== undefined)) {
|
|
693
|
-
entry = {
|
|
694
|
-
...entry,
|
|
695
|
-
...(options.forkedFrom !== undefined ? { forkedFrom: options.forkedFrom } : {}),
|
|
696
|
-
...(options.forkedAt !== undefined ? { forkedAt: options.forkedAt } : {}),
|
|
697
|
-
...(options.parentFlow !== undefined ? { parentFlow: options.parentFlow } : {}),
|
|
698
|
-
...(options.depth !== undefined ? { depth: options.depth } : {}),
|
|
699
|
-
};
|
|
700
|
-
changed = true;
|
|
701
|
-
subPasses.add("forkMetadataInjection");
|
|
702
|
-
}
|
|
703
1536
|
// Replace the parent orchestrator system prompt with a brief note.
|
|
704
1537
|
// Children receive their own directive in the <activation> block.
|
|
705
1538
|
if (entry.systemPrompt && typeof entry.systemPrompt === "string") {
|
|
@@ -788,10 +1621,11 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
|
|
|
788
1621
|
subPasses.add("stripTimestamps");
|
|
789
1622
|
}
|
|
790
1623
|
// Strip API metadata fields that children don't need (~5-7 KB per assistant message).
|
|
791
|
-
// IMPORTANT: keep `usage`
|
|
1624
|
+
// IMPORTANT: keep `usage.totalTokens` ONLY. The child `pi` process replays
|
|
792
1625
|
// this JSONL and core/session code reads `message.usage.totalTokens`; stripping
|
|
793
1626
|
// `usage` causes: Cannot read properties of undefined (reading 'totalTokens').
|
|
794
|
-
//
|
|
1627
|
+
// Other fields (input, output, cacheRead, cacheWrite) are consumed only from
|
|
1628
|
+
// live child stdout events (runner-events.ts), never from fork snapshot replay.
|
|
795
1629
|
if (message.role === "assistant") {
|
|
796
1630
|
const { api, provider, model, stopReason, responseId, responseModel, usage, ...rest } = message;
|
|
797
1631
|
let stripped = false;
|
|
@@ -799,12 +1633,16 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
|
|
|
799
1633
|
stopReason !== undefined || responseId !== undefined || responseModel !== undefined) {
|
|
800
1634
|
stripped = true;
|
|
801
1635
|
}
|
|
802
|
-
//
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1636
|
+
// Compress usage to totalTokens only — child pi replay requires totalTokens.
|
|
1637
|
+
// Other fields (input, output, cacheRead, cacheWrite) are consumed only from
|
|
1638
|
+
// live child stdout events (runner-events.ts), never from fork snapshot replay.
|
|
1639
|
+
let cleanedUsage;
|
|
1640
|
+
if (usage && typeof usage === "object") {
|
|
1641
|
+
const ttl = usage.totalTokens;
|
|
1642
|
+
if (typeof ttl === "number") {
|
|
1643
|
+
cleanedUsage = { totalTokens: ttl };
|
|
1644
|
+
stripped = true;
|
|
1645
|
+
}
|
|
808
1646
|
}
|
|
809
1647
|
if (stripped) {
|
|
810
1648
|
message = { ...rest, ...(cleanedUsage !== undefined ? { usage: cleanedUsage } : {}) };
|
|
@@ -812,6 +1650,20 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
|
|
|
812
1650
|
subPasses.add("stripApiMetadata");
|
|
813
1651
|
}
|
|
814
1652
|
}
|
|
1653
|
+
// Collapse empty/low-signal assistant messages to a minimal continuation marker.
|
|
1654
|
+
if (message.role === "assistant" && isEmptyAssistantMessage(message)) {
|
|
1655
|
+
const totalTokens = message.usage?.totalTokens;
|
|
1656
|
+
const { usage: _usage, ...rest } = message;
|
|
1657
|
+
message = {
|
|
1658
|
+
...rest,
|
|
1659
|
+
...(totalTokens !== undefined ? { usage: { totalTokens } } : {}),
|
|
1660
|
+
content: totalTokens !== undefined
|
|
1661
|
+
? `[assistant: ${totalTokens} tokens, no action]`
|
|
1662
|
+
: "[assistant:continuation]",
|
|
1663
|
+
};
|
|
1664
|
+
changed = true;
|
|
1665
|
+
subPasses.add("collapseEmptyAssistantMessages");
|
|
1666
|
+
}
|
|
815
1667
|
// Strip `details` from tool/toolResult messages — carries FlowDetails UI metadata
|
|
816
1668
|
// (mode, flowStyle, projectAgentsDir, results) that children never need.
|
|
817
1669
|
if (message.role === "tool" || message.role === "toolResult") {
|
|
@@ -892,37 +1744,48 @@ export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
|
|
|
892
1744
|
sanitizedLines.push(outLine);
|
|
893
1745
|
}
|
|
894
1746
|
const passesApplied = [];
|
|
1747
|
+
const passDeltas = {};
|
|
1748
|
+
const measureBytes = (s) => new TextEncoder().encode(s).length;
|
|
895
1749
|
let sanitized = `${sanitizedLines.join("\n")}\n`;
|
|
896
1750
|
passesApplied.push(...subPasses);
|
|
1751
|
+
passDeltas["mainLoop"] = measureBytes(sanitized);
|
|
897
1752
|
// Reparent orphaned parentIds after steering-hint messages were dropped.
|
|
898
1753
|
sanitized = reparentOrphans(sanitized);
|
|
899
1754
|
passesApplied.push("reparentOrphans");
|
|
1755
|
+
passDeltas["reparentOrphans1"] = measureBytes(sanitized);
|
|
900
1756
|
// Strip batch_read tool calls from assistant messages.
|
|
901
1757
|
// Children don't have batch_read in their active tools.
|
|
902
1758
|
sanitized = stripBatchReadToolCalls(sanitized);
|
|
903
1759
|
passesApplied.push("stripBatchRead");
|
|
1760
|
+
passDeltas["stripBatchRead"] = measureBytes(sanitized);
|
|
1761
|
+
// Compress verbose flow tool call arguments in assistant messages.
|
|
1762
|
+
sanitized = compressFlowToolCallArgs(sanitized);
|
|
1763
|
+
passesApplied.push("compressFlowToolCallArgs");
|
|
1764
|
+
passDeltas["compressFlowToolCallArgs"] = measureBytes(sanitized);
|
|
904
1765
|
// Compress tool results (flow, batch, web, ask_user).
|
|
905
|
-
sanitized = compressToolResults(sanitized, cache);
|
|
1766
|
+
sanitized = compressToolResults(sanitized, cache, depthToPolicy(options?.depth ?? 1));
|
|
906
1767
|
passesApplied.push("compressToolResults");
|
|
1768
|
+
passDeltas["compressToolResults"] = measureBytes(sanitized);
|
|
907
1769
|
// Reparent again after stripBatchRead and compressToolResults may have
|
|
908
1770
|
// dropped additional messages, leaving new orphaned parentIds.
|
|
909
1771
|
sanitized = reparentOrphans(sanitized);
|
|
910
1772
|
passesApplied.push("reparentOrphans");
|
|
1773
|
+
passDeltas["reparentOrphans2"] = measureBytes(sanitized);
|
|
911
1774
|
// Telemetry: measure total delta across sanitization, stripping, and compression.
|
|
912
1775
|
const postBytes = sanitized.length;
|
|
913
1776
|
const reduction = preBytes > 0 ? ((1 - postBytes / preBytes) * 100).toFixed(0) : "0";
|
|
914
1777
|
if (DEBUG_CONTEXT) {
|
|
915
1778
|
logError(`[context-snapshot] pre: ${preBytes} → post: ${postBytes} bytes (${reduction}% reduction)`);
|
|
916
1779
|
}
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
|
|
920
|
-
type: "compression-stats",
|
|
1780
|
+
// Stats are returned out-of-band for dump consumers only.
|
|
1781
|
+
// Do NOT append to child-visible JSONL — it's telemetry noise for the model.
|
|
1782
|
+
const stats = {
|
|
921
1783
|
preBytes,
|
|
922
1784
|
postBytes,
|
|
923
1785
|
reductionPercent: Number(reduction),
|
|
924
1786
|
passesApplied,
|
|
925
|
-
|
|
926
|
-
|
|
1787
|
+
passDeltas,
|
|
1788
|
+
};
|
|
1789
|
+
return { result: sanitized, passesApplied, stats };
|
|
927
1790
|
}
|
|
928
1791
|
//# sourceMappingURL=snapshot.js.map
|