pi-agent-flow 2.0.1 → 2.0.5
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/README.md +126 -489
- package/agents/audit.md +24 -15
- package/agents/build.md +4 -3
- package/agents/craft.md +4 -4
- package/agents/debug.md +5 -4
- package/agents/ideas.md +6 -5
- package/agents/scout.md +10 -8
- package/agents/trace.md +23 -0
- package/dist/batch/apply-patch.d.ts +60 -0
- package/dist/batch/apply-patch.d.ts.map +1 -0
- package/dist/batch/apply-patch.js +477 -0
- package/dist/batch/apply-patch.js.map +1 -0
- package/dist/batch/batch-bash.d.ts +10 -6
- package/dist/batch/batch-bash.d.ts.map +1 -1
- package/dist/batch/batch-bash.js +59 -11
- package/dist/batch/batch-bash.js.map +1 -1
- package/dist/batch/constants.d.ts +38 -6
- package/dist/batch/constants.d.ts.map +1 -1
- package/dist/batch/constants.js +26 -4
- package/dist/batch/constants.js.map +1 -1
- package/dist/batch/execute.d.ts +8 -2
- package/dist/batch/execute.d.ts.map +1 -1
- package/dist/batch/execute.js +222 -67
- package/dist/batch/execute.js.map +1 -1
- package/dist/batch/fuzzy-edit.d.ts +4 -1
- package/dist/batch/fuzzy-edit.d.ts.map +1 -1
- package/dist/batch/fuzzy-edit.js +7 -2
- package/dist/batch/fuzzy-edit.js.map +1 -1
- package/dist/batch/index.d.ts +10 -24
- package/dist/batch/index.d.ts.map +1 -1
- package/dist/batch/index.js +120 -120
- package/dist/batch/index.js.map +1 -1
- package/dist/batch/render.d.ts +22 -5
- package/dist/batch/render.d.ts.map +1 -1
- package/dist/batch/render.js +353 -15
- package/dist/batch/render.js.map +1 -1
- package/dist/batch/summary.d.ts.map +1 -1
- package/dist/batch/summary.js +26 -4
- package/dist/batch/summary.js.map +1 -1
- package/dist/config/config.d.ts +5 -4
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +15 -5
- package/dist/config/config.js.map +1 -1
- package/dist/config/models.d.ts.map +1 -1
- package/dist/config/models.js +7 -1
- package/dist/config/models.js.map +1 -1
- package/dist/config/settings-resolver.d.ts +5 -4
- package/dist/config/settings-resolver.d.ts.map +1 -1
- package/dist/config/settings-resolver.js +50 -21
- package/dist/config/settings-resolver.js.map +1 -1
- package/dist/core2/snapshot.d.ts +21 -0
- package/dist/core2/snapshot.d.ts.map +1 -0
- package/dist/core2/snapshot.js +214 -0
- package/dist/core2/snapshot.js.map +1 -0
- package/dist/{core → flow}/agents.d.ts.map +1 -1
- package/dist/{core → flow}/agents.js +5 -2
- package/dist/{core → flow}/agents.js.map +1 -1
- package/dist/flow/auto-warp.d.ts +1 -1
- package/dist/flow/auto-warp.js +1 -1
- package/dist/flow/command.d.ts +1 -1
- package/dist/flow/command.d.ts.map +1 -1
- package/dist/flow/complexity.d.ts +20 -0
- package/dist/flow/complexity.d.ts.map +1 -0
- package/dist/flow/complexity.js +34 -0
- package/dist/flow/complexity.js.map +1 -0
- package/dist/flow/continuation.d.ts +1 -1
- package/dist/flow/continuation.d.ts.map +1 -1
- package/dist/flow/continuation.js +4 -3
- package/dist/flow/continuation.js.map +1 -1
- package/dist/{core → flow}/depth.d.ts +4 -4
- package/dist/{core → flow}/depth.d.ts.map +1 -1
- package/dist/{core → flow}/depth.js +5 -5
- package/dist/{core → flow}/depth.js.map +1 -1
- package/dist/{core → flow}/executor.d.ts +42 -22
- package/dist/flow/executor.d.ts.map +1 -0
- package/dist/flow/executor.js +727 -0
- package/dist/flow/executor.js.map +1 -0
- package/dist/flow/index.d.ts +4 -4
- package/dist/flow/index.d.ts.map +1 -1
- package/dist/flow/index.js +4 -4
- package/dist/flow/index.js.map +1 -1
- package/dist/flow/loop-command.d.ts +1 -1
- package/dist/flow/loop-command.d.ts.map +1 -1
- package/dist/flow/loop-command.js +3 -0
- package/dist/flow/loop-command.js.map +1 -1
- package/dist/{core/flow.d.ts → flow/runner.d.ts} +20 -16
- package/dist/flow/runner.d.ts.map +1 -0
- package/dist/{core/flow.js → flow/runner.js} +105 -61
- package/dist/flow/runner.js.map +1 -0
- package/dist/{core → flow}/session-registry.d.ts.map +1 -1
- package/dist/{core → flow}/session-registry.js.map +1 -1
- package/dist/flow/settings-command.d.ts +3 -3
- package/dist/flow/settings-command.d.ts.map +1 -1
- package/dist/flow/settings-command.js +43 -22
- package/dist/flow/settings-command.js.map +1 -1
- package/dist/{core/delegation.d.ts → flow/transition.d.ts} +8 -8
- package/dist/{core/delegation.d.ts.map → flow/transition.d.ts.map} +1 -1
- package/dist/{core/delegation.js → flow/transition.js} +12 -12
- package/dist/{core/delegation.js.map → flow/transition.js.map} +1 -1
- package/dist/flow/types.d.ts +4 -0
- package/dist/flow/types.d.ts.map +1 -1
- package/dist/flow/warp.d.ts +15 -0
- package/dist/flow/warp.d.ts.map +1 -0
- package/dist/flow/warp.js +207 -0
- package/dist/flow/warp.js.map +1 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +237 -89
- package/dist/index.js.map +1 -1
- package/dist/notify/notify.d.ts +1 -1
- package/dist/notify/notify.d.ts.map +1 -1
- package/dist/notify/notify.js +1 -1
- package/dist/snapshot/cli-args.d.ts +2 -2
- package/dist/snapshot/cli-args.d.ts.map +1 -1
- package/dist/snapshot/cli-args.js +21 -5
- package/dist/snapshot/cli-args.js.map +1 -1
- package/dist/snapshot/runner-events.d.ts +7 -2
- package/dist/snapshot/runner-events.d.ts.map +1 -1
- package/dist/snapshot/runner-events.js +137 -19
- package/dist/snapshot/runner-events.js.map +1 -1
- package/dist/snapshot/structured-output.d.ts +1 -1
- package/dist/snapshot/structured-output.d.ts.map +1 -1
- package/dist/snapshot/structured-output.js +20 -2
- package/dist/snapshot/structured-output.js.map +1 -1
- package/dist/steering/flow-prompt.d.ts +4 -4
- package/dist/steering/flow-prompt.d.ts.map +1 -1
- package/dist/steering/flow-prompt.js +18 -37
- package/dist/steering/flow-prompt.js.map +1 -1
- package/dist/steering/sliding-prompt.d.ts.map +1 -1
- package/dist/steering/sliding-prompt.js +8 -7
- package/dist/steering/sliding-prompt.js.map +1 -1
- package/dist/steering/tool-utils.d.ts +31 -8
- package/dist/steering/tool-utils.d.ts.map +1 -1
- package/dist/steering/tool-utils.js +63 -30
- package/dist/steering/tool-utils.js.map +1 -1
- package/dist/tools/ask-user.d.ts +2 -19
- package/dist/tools/ask-user.d.ts.map +1 -1
- package/dist/tools/ask-user.js +14 -38
- package/dist/tools/ask-user.js.map +1 -1
- package/dist/tools/timed-bash.d.ts +1 -1
- package/dist/tools/timed-bash.d.ts.map +1 -1
- package/dist/tools/timed-bash.js +11 -9
- package/dist/tools/timed-bash.js.map +1 -1
- package/dist/tools/trace.d.ts +34 -0
- package/dist/tools/trace.d.ts.map +1 -0
- package/dist/tools/trace.js +180 -0
- package/dist/tools/trace.js.map +1 -0
- package/dist/tools/web-ops.d.ts +85 -0
- package/dist/tools/web-ops.d.ts.map +1 -0
- package/dist/tools/{web-tool.js → web-ops.js} +51 -127
- package/dist/tools/web-ops.js.map +1 -0
- package/dist/tui/flow-colors.d.ts +1 -0
- package/dist/tui/flow-colors.d.ts.map +1 -1
- package/dist/tui/flow-colors.js +2 -2
- package/dist/tui/flow-colors.js.map +1 -1
- package/dist/tui/render-utils.d.ts.map +1 -1
- package/dist/tui/render-utils.js +2 -4
- package/dist/tui/render-utils.js.map +1 -1
- package/dist/tui/render.d.ts +41 -1
- package/dist/tui/render.d.ts.map +1 -1
- package/dist/tui/render.js +724 -189
- package/dist/tui/render.js.map +1 -1
- package/dist/tui/scramble/algorithm.d.ts +4 -2
- package/dist/tui/scramble/algorithm.d.ts.map +1 -1
- package/dist/tui/scramble/algorithm.js +44 -12
- package/dist/tui/scramble/algorithm.js.map +1 -1
- package/dist/tui/scramble/constants.d.ts +3 -0
- package/dist/tui/scramble/constants.d.ts.map +1 -1
- package/dist/tui/scramble/constants.js +4 -1
- package/dist/tui/scramble/constants.js.map +1 -1
- package/dist/tui/scramble/index.d.ts +3 -2
- package/dist/tui/scramble/index.d.ts.map +1 -1
- package/dist/tui/scramble/index.js +2 -2
- package/dist/tui/scramble/index.js.map +1 -1
- package/dist/tui/scramble/manager.d.ts +2 -2
- package/dist/tui/scramble/manager.d.ts.map +1 -1
- package/dist/tui/scramble/manager.js +37 -20
- package/dist/tui/scramble/manager.js.map +1 -1
- package/dist/tui/scramble/utils.js +1 -1
- package/dist/tui/scramble/utils.js.map +1 -1
- package/dist/types/flow.d.ts +17 -1
- package/dist/types/flow.d.ts.map +1 -1
- package/dist/types/flow.js +11 -2
- package/dist/types/flow.js.map +1 -1
- package/dist/types/output.d.ts +11 -36
- package/dist/types/output.d.ts.map +1 -1
- package/dist/types/output.js +1 -1
- package/dist/types/ui.d.ts +1 -1
- package/dist/types/ui.d.ts.map +1 -1
- package/package.json +10 -10
- package/dist/core/executor.d.ts.map +0 -1
- package/dist/core/executor.js +0 -378
- package/dist/core/executor.js.map +0 -1
- package/dist/core/flow.d.ts.map +0 -1
- package/dist/core/flow.js.map +0 -1
- package/dist/core/session-mode.d.ts +0 -11
- package/dist/core/session-mode.d.ts.map +0 -1
- package/dist/core/session-mode.js +0 -26
- package/dist/core/session-mode.js.map +0 -1
- package/dist/core/transitions.d.ts +0 -39
- package/dist/core/transitions.d.ts.map +0 -1
- package/dist/core/transitions.js +0 -59
- package/dist/core/transitions.js.map +0 -1
- package/dist/flow/perform-warp.d.ts +0 -28
- package/dist/flow/perform-warp.d.ts.map +0 -1
- package/dist/flow/perform-warp.js +0 -127
- package/dist/flow/perform-warp.js.map +0 -1
- package/dist/flow/warp-command.d.ts +0 -8
- package/dist/flow/warp-command.d.ts.map +0 -1
- package/dist/flow/warp-command.js +0 -144
- package/dist/flow/warp-command.js.map +0 -1
- package/dist/flow/warp-utils.d.ts +0 -11
- package/dist/flow/warp-utils.d.ts.map +0 -1
- package/dist/flow/warp-utils.js +0 -187
- package/dist/flow/warp-utils.js.map +0 -1
- package/dist/snapshot/index.d.ts +0 -2
- package/dist/snapshot/index.d.ts.map +0 -1
- package/dist/snapshot/index.js +0 -2
- package/dist/snapshot/index.js.map +0 -1
- package/dist/snapshot/reasoning-strip.d.ts +0 -22
- package/dist/snapshot/reasoning-strip.d.ts.map +0 -1
- package/dist/snapshot/reasoning-strip.js +0 -58
- package/dist/snapshot/reasoning-strip.js.map +0 -1
- package/dist/snapshot/snapshot.d.ts +0 -77
- package/dist/snapshot/snapshot.d.ts.map +0 -1
- package/dist/snapshot/snapshot.js +0 -1791
- package/dist/snapshot/snapshot.js.map +0 -1
- package/dist/tools/web-tool.d.ts +0 -46
- package/dist/tools/web-tool.d.ts.map +0 -1
- package/dist/tools/web-tool.js.map +0 -1
- /package/dist/{core → flow}/agents.d.ts +0 -0
- /package/dist/{core → flow}/session-registry.d.ts +0 -0
- /package/dist/{core → flow}/session-registry.js +0 -0
|
@@ -1,1791 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Two JSONL protocols are used in this codebase:
|
|
3
|
-
*
|
|
4
|
-
* 1. Fork Snapshot Protocol (snapshot.ts):
|
|
5
|
-
* Types: session, model_change, thinking_level_change, message
|
|
6
|
-
* Purpose: Serialized session state passed to child flows via --session.
|
|
7
|
-
* Emitted by buildForkSessionSnapshotJsonl() and consumed by
|
|
8
|
-
* sanitizeForkSnapshot() before forking.
|
|
9
|
-
*
|
|
10
|
-
* 2. Streaming Stdout Protocol (runner-events.ts):
|
|
11
|
-
* Types: session, agent_start, turn_start, message_start, message_end,
|
|
12
|
-
* message_update
|
|
13
|
-
* Sub-events under message_update: thinking_start, thinking_delta, text_delta
|
|
14
|
-
* Purpose: Real-time events emitted by the pi process stdout during flow
|
|
15
|
-
* execution. Parsed by processFlowJsonLine().
|
|
16
|
-
*/
|
|
17
|
-
import { stripReasoningFromAssistantMessage } from "./reasoning-strip.js";
|
|
18
|
-
import { stripSteeringHintFromContent, contentContainsSteeringHintTag, isJsonEqual, } from "../steering/sliding-prompt.js";
|
|
19
|
-
import { stripStrategicHintsFromContent } from "../steering/tool-utils.js";
|
|
20
|
-
import { logError } from "../config/log.js";
|
|
21
|
-
import * as path from "node:path";
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
// Session snapshot serialization
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
export function buildForkSessionSnapshotJsonl(sessionManager) {
|
|
26
|
-
const header = sessionManager.getHeader();
|
|
27
|
-
if (!header || typeof header !== "object")
|
|
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
|
-
}
|
|
50
|
-
const branchEntries = sessionManager.getBranch();
|
|
51
|
-
const lines = [];
|
|
52
|
-
// Emit session header once, unless getBranch() already includes it as the
|
|
53
|
-
// first entry (some session managers include the header in the branch).
|
|
54
|
-
const firstBranch = branchEntries[0];
|
|
55
|
-
const headerId = header?.id;
|
|
56
|
-
const firstId = firstBranch && typeof firstBranch === "object" ? firstBranch?.id : undefined;
|
|
57
|
-
const firstType = firstBranch && typeof firstBranch === "object" ? firstBranch?.type : undefined;
|
|
58
|
-
if (!firstBranch ||
|
|
59
|
-
typeof firstBranch !== "object" ||
|
|
60
|
-
(firstType !== "session" && firstType !== "header") ||
|
|
61
|
-
firstId !== headerId) {
|
|
62
|
-
lines.push(JSON.stringify(compressedHeader));
|
|
63
|
-
}
|
|
64
|
-
for (const entry of branchEntries)
|
|
65
|
-
lines.push(JSON.stringify(entry));
|
|
66
|
-
return `${lines.join("\n")}\n`;
|
|
67
|
-
}
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
// Flow result compression
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
/**
|
|
72
|
-
* Render a compressed flow result as compact text for child context.
|
|
73
|
-
*/
|
|
74
|
-
export function renderCompressedFlowResult(r) {
|
|
75
|
-
const parts = [`[Flow: ${r.type} ${r.status}]`];
|
|
76
|
-
if (r.intent)
|
|
77
|
-
parts.push(`Intent: ${r.intent}`);
|
|
78
|
-
if (r.aim)
|
|
79
|
-
parts.push(`Aim: ${r.aim}`);
|
|
80
|
-
if (r.summary)
|
|
81
|
-
parts.push(`Summary: ${r.summary}`);
|
|
82
|
-
if (r.files?.length) {
|
|
83
|
-
const fileLines = r.files
|
|
84
|
-
.map((f) => {
|
|
85
|
-
if (!f.path)
|
|
86
|
-
return undefined;
|
|
87
|
-
const role = f.role ? ` (${f.role})` : "";
|
|
88
|
-
const desc = f.description ? ` — ${f.description}` : "";
|
|
89
|
-
return ` ${f.path}${role}${desc}`;
|
|
90
|
-
})
|
|
91
|
-
.filter((line) => line !== undefined);
|
|
92
|
-
// Safety net: if >50% of file entries were invalid (no path), compression is
|
|
93
|
-
// producing garbage. Return undefined so caller falls back to truncated raw.
|
|
94
|
-
if (fileLines.length === 0 || fileLines.length < r.files.length / 2) {
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
parts.push(`Files:\n${fileLines.join("\n")}`);
|
|
98
|
-
}
|
|
99
|
-
if (r.actions?.length) {
|
|
100
|
-
const actionLines = r.actions.map((a) => {
|
|
101
|
-
const result = a.result ? ` → ${a.result}` : "";
|
|
102
|
-
const target = a.target ? ` (${a.target})` : "";
|
|
103
|
-
return ` [${a.type}] ${a.description}${target}${result}`;
|
|
104
|
-
});
|
|
105
|
-
parts.push(`Actions:\n${actionLines.join("\n")}`);
|
|
106
|
-
}
|
|
107
|
-
if (r.commands?.length) {
|
|
108
|
-
const cmdLines = r.commands.map((c) => ` ${c.tool ?? "cmd"}: ${c.command}`);
|
|
109
|
-
parts.push(`Commands:\n${cmdLines.join("\n")}`);
|
|
110
|
-
}
|
|
111
|
-
if (r.notDone?.length) {
|
|
112
|
-
const ndLines = r.notDone.map((n) => {
|
|
113
|
-
const reason = n.reason ? ` — ${n.reason}` : "";
|
|
114
|
-
return ` ${n.item}${reason}`;
|
|
115
|
-
});
|
|
116
|
-
parts.push(`Not done:\n${ndLines.join("\n")}`);
|
|
117
|
-
}
|
|
118
|
-
if (r.nextSteps?.length) {
|
|
119
|
-
parts.push(`Next steps:\n${r.nextSteps.map((s) => ` ${s}`).join("\n")}`);
|
|
120
|
-
}
|
|
121
|
-
if (r.reasoning?.length) {
|
|
122
|
-
parts.push(`Reasoning:\n${r.reasoning.map((s) => ` ${s}`).join("\n")}`);
|
|
123
|
-
}
|
|
124
|
-
if (r.notes?.length) {
|
|
125
|
-
parts.push(`Notes:\n${r.notes.map((s) => ` ${s}`).join("\n")}`);
|
|
126
|
-
}
|
|
127
|
-
if (r.error)
|
|
128
|
-
parts.push(`Error: ${r.error}`);
|
|
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)
|
|
133
|
-
return undefined;
|
|
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");
|
|
141
|
-
}
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
// Additional tool result compressors
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
const DEBUG_CONTEXT = typeof process !== "undefined" && process.env.PI_FLOW_DEBUG_CONTEXT === "1";
|
|
146
|
-
function logCompress(toolName, before, after) {
|
|
147
|
-
if (!DEBUG_CONTEXT)
|
|
148
|
-
return;
|
|
149
|
-
const reduction = before > 0 ? ((1 - after / before) * 100).toFixed(0) : "0";
|
|
150
|
-
logError(`[context-compress] ${toolName}: ${before} → ${after} bytes (${reduction}% reduction)`);
|
|
151
|
-
}
|
|
152
|
-
const KNOWN_SECTION_HEADERS = [
|
|
153
|
-
/^--- (.+) \((\d+) lines\) ---$/,
|
|
154
|
-
/^--- (.+) (context map|file summary) ---$/,
|
|
155
|
-
/^--- bash \[.+\] exit (\d+) ---$/,
|
|
156
|
-
/^--- bash \[.+\] pending ---$/,
|
|
157
|
-
/^--- bash \[.+\] error ---$/,
|
|
158
|
-
/^--- edit: .+ ---$/,
|
|
159
|
-
/^--- write: .+ ---$/,
|
|
160
|
-
/^--- delete: .+ ---$/,
|
|
161
|
-
/^--- read: .+ ---$/,
|
|
162
|
-
/^--- rg: .+ ---$/,
|
|
163
|
-
/^--- (?!bash \[|edit:|write:|delete:|read:|rg:)(.+) ---$/,
|
|
164
|
-
];
|
|
165
|
-
function isKnownSectionHeader(line) {
|
|
166
|
-
return KNOWN_SECTION_HEADERS.some((re) => re.test(line));
|
|
167
|
-
}
|
|
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();
|
|
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
|
-
}
|
|
424
|
-
const out = [];
|
|
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
|
-
};
|
|
450
|
-
while (i < lines.length) {
|
|
451
|
-
const line = lines[i];
|
|
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();
|
|
526
|
-
i++;
|
|
527
|
-
const rgLines = [];
|
|
528
|
-
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
529
|
-
rgLines.push(lines[i]);
|
|
530
|
-
i++;
|
|
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
|
-
}
|
|
595
|
-
continue;
|
|
596
|
-
}
|
|
597
|
-
// Context map / file summary section — truncate
|
|
598
|
-
const ctxMapMatch = line.match(/^--- (.+) (context map|file summary) ---$/);
|
|
599
|
-
if (ctxMapMatch) {
|
|
600
|
-
out.push(`--- ${ctxMapMatch[1]} (${ctxMapMatch[2]}, truncated) ---`);
|
|
601
|
-
i++;
|
|
602
|
-
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
603
|
-
i++;
|
|
604
|
-
}
|
|
605
|
-
continue;
|
|
606
|
-
}
|
|
607
|
-
// File read without line count — preview or truncate
|
|
608
|
-
// Negative lookahead excludes bash/edit/write/delete/read-error sections that should be kept verbatim
|
|
609
|
-
const fallbackReadMatch = line.match(/^--- (?!bash \[|edit:|write:|delete:|read:)(.+) ---$/);
|
|
610
|
-
if (fallbackReadMatch) {
|
|
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);
|
|
733
|
-
i++;
|
|
734
|
-
while (i < lines.length && !isKnownSectionHeader(lines[i])) {
|
|
735
|
-
out.push(lines[i]);
|
|
736
|
-
i++;
|
|
737
|
-
}
|
|
738
|
-
continue;
|
|
739
|
-
}
|
|
740
|
-
// Everything else (summary, error generic, etc.) — keep as-is
|
|
741
|
-
out.push(line);
|
|
742
|
-
i++;
|
|
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
|
-
}
|
|
761
|
-
return out.join("\n");
|
|
762
|
-
}
|
|
763
|
-
/** Compress web tool result into compact metadata. */
|
|
764
|
-
function compressWebResult(text, args) {
|
|
765
|
-
// Try to extract query/url from args
|
|
766
|
-
let query;
|
|
767
|
-
let url;
|
|
768
|
-
if (args && typeof args === "object") {
|
|
769
|
-
const a = args;
|
|
770
|
-
const ops = Array.isArray(a.o) ? a.o : Array.isArray(a.op) ? a.op : undefined;
|
|
771
|
-
if (ops && ops.length > 0) {
|
|
772
|
-
const firstOp = ops[0];
|
|
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
|
-
}
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
// Search result format: numbered list
|
|
780
|
-
if (text.match(/^\d+\. .+\n https?:\/\//m)) {
|
|
781
|
-
const lines = text.split("\n\n");
|
|
782
|
-
const count = lines.length;
|
|
783
|
-
const firstTitle = lines[0]?.match(/^\d+\. (.+)\n/)?.[1] ?? "unknown";
|
|
784
|
-
const q = query ? ` "${query}"` : "";
|
|
785
|
-
return `[web:search]${q} · ${count} results · first: ${firstTitle}`;
|
|
786
|
-
}
|
|
787
|
-
// Fetch result format: File/Title/Content length/Preview
|
|
788
|
-
const fileMatch = text.match(/^File: (.+)\n/m);
|
|
789
|
-
const titleMatch = text.match(/^Title: (.+)\n/m);
|
|
790
|
-
const lengthMatch = text.match(/^Content length: (\d+) chars\n/m);
|
|
791
|
-
if (fileMatch || titleMatch || lengthMatch || url) {
|
|
792
|
-
const file = url ?? fileMatch?.[1] ?? "";
|
|
793
|
-
const title = titleMatch?.[1] ?? "";
|
|
794
|
-
const length = lengthMatch?.[1] ?? "0";
|
|
795
|
-
return `[web:fetch] ${file} · "${title}" · ${length} chars`;
|
|
796
|
-
}
|
|
797
|
-
return `[web] result truncated (${text.length} chars)`;
|
|
798
|
-
}
|
|
799
|
-
/** Compress ask_user tool result into compact metadata. */
|
|
800
|
-
function compressAskUserResult(text, args) {
|
|
801
|
-
let question = "";
|
|
802
|
-
if (args && typeof args === "object") {
|
|
803
|
-
const q = args.question;
|
|
804
|
-
if (typeof q === "string") {
|
|
805
|
-
question = q.length > 80 ? q.slice(0, 77) + "..." : q;
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
const answeredMatch = text.match(/^User answered: (.+)$/ms);
|
|
809
|
-
if (answeredMatch) {
|
|
810
|
-
const q = question ? ` "${question}"` : "";
|
|
811
|
-
return `[ask_user]${q} → "${answeredMatch[1]}"`;
|
|
812
|
-
}
|
|
813
|
-
if (text.match(/^User cancelled/m)) {
|
|
814
|
-
const q = question ? ` "${question}"` : "";
|
|
815
|
-
return `[ask_user]${q} → cancelled`;
|
|
816
|
-
}
|
|
817
|
-
return `[ask_user] · ${text.length} chars`;
|
|
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
|
-
}
|
|
926
|
-
// ---------------------------------------------------------------------------
|
|
927
|
-
// Shared: toolCallId → toolName mapping
|
|
928
|
-
// ---------------------------------------------------------------------------
|
|
929
|
-
/**
|
|
930
|
-
* Build a map from toolCallId → toolName by scanning assistant messages.
|
|
931
|
-
*/
|
|
932
|
-
function buildToolCallIdToNameMap(lines) {
|
|
933
|
-
const map = new Map();
|
|
934
|
-
for (const line of lines) {
|
|
935
|
-
let entry;
|
|
936
|
-
try {
|
|
937
|
-
entry = JSON.parse(line);
|
|
938
|
-
}
|
|
939
|
-
catch {
|
|
940
|
-
continue;
|
|
941
|
-
}
|
|
942
|
-
if (entry?.type !== "message" || entry.message?.role !== "assistant")
|
|
943
|
-
continue;
|
|
944
|
-
const content = entry.message.content;
|
|
945
|
-
if (!Array.isArray(content))
|
|
946
|
-
continue;
|
|
947
|
-
for (const part of content) {
|
|
948
|
-
if (part.type === "toolCall" && part.name) {
|
|
949
|
-
const tcId = part.id ?? part.toolCallId;
|
|
950
|
-
if (typeof tcId === "string" && tcId.trim()) {
|
|
951
|
-
map.set(tcId, part.name);
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
return map;
|
|
957
|
-
}
|
|
958
|
-
// ---------------------------------------------------------------------------
|
|
959
|
-
// Tool result compression (flow + batch_read)
|
|
960
|
-
// ---------------------------------------------------------------------------
|
|
961
|
-
/**
|
|
962
|
-
* Compress tool results in a sanitized session snapshot.
|
|
963
|
-
*
|
|
964
|
-
* Handles two tool types:
|
|
965
|
-
* - `flow` results: replaced with compact CompressedFlowResult output from cache.
|
|
966
|
-
* - `batch_read` results: replaced with compact metadata (paths + op count)
|
|
967
|
-
* since children have `batch` and can re-read files themselves.
|
|
968
|
-
*/
|
|
969
|
-
export function compressToolResults(snapshot, cache, depthPolicy) {
|
|
970
|
-
const policy = depthPolicy ?? depthToPolicy(1);
|
|
971
|
-
const lines = snapshot.trimEnd().split("\n");
|
|
972
|
-
// Quick check: if there are no flow cache entries and no compressible tool calls,
|
|
973
|
-
// nothing to compress — return early.
|
|
974
|
-
if (cache.size === 0) {
|
|
975
|
-
const hasCompressible = lines.some((line) => {
|
|
976
|
-
try {
|
|
977
|
-
const entry = JSON.parse(line);
|
|
978
|
-
return entry?.type === "message" && entry.message?.role === "assistant" &&
|
|
979
|
-
Array.isArray(entry.message.content) &&
|
|
980
|
-
entry.message.content.some((p) => p.type === "toolCall" &&
|
|
981
|
-
["batch_read", "batch", "web", "ask_user", "batch_bash_poll"].includes(p.name));
|
|
982
|
-
}
|
|
983
|
-
catch {
|
|
984
|
-
return false;
|
|
985
|
-
}
|
|
986
|
-
});
|
|
987
|
-
const hasToolResultMessages = lines.some((line) => {
|
|
988
|
-
try {
|
|
989
|
-
const entry = JSON.parse(line);
|
|
990
|
-
return entry?.type === "message" &&
|
|
991
|
-
(entry.message?.role === "tool" || entry.message?.role === "toolResult");
|
|
992
|
-
}
|
|
993
|
-
catch {
|
|
994
|
-
return false;
|
|
995
|
-
}
|
|
996
|
-
});
|
|
997
|
-
// Must run the pass whenever tool results exist: we drop empty/whitespace
|
|
998
|
-
// toolCallIds and pass through bash/flow/etc. even when the cache is empty.
|
|
999
|
-
if (!hasCompressible && !hasToolResultMessages)
|
|
1000
|
-
return snapshot;
|
|
1001
|
-
}
|
|
1002
|
-
// Build toolCallId → toolName mapping
|
|
1003
|
-
const toolCallIdToName = buildToolCallIdToNameMap(lines);
|
|
1004
|
-
// Build toolCallId → arguments mapping for all tools (needed for batch/web/ask_user metadata)
|
|
1005
|
-
const toolCallIdToArgs = new Map();
|
|
1006
|
-
for (const line of lines) {
|
|
1007
|
-
let entry;
|
|
1008
|
-
try {
|
|
1009
|
-
entry = JSON.parse(line);
|
|
1010
|
-
}
|
|
1011
|
-
catch {
|
|
1012
|
-
continue;
|
|
1013
|
-
}
|
|
1014
|
-
if (entry?.type !== "message" || entry.message?.role !== "assistant")
|
|
1015
|
-
continue;
|
|
1016
|
-
const content = entry.message.content;
|
|
1017
|
-
if (!Array.isArray(content))
|
|
1018
|
-
continue;
|
|
1019
|
-
for (const part of content) {
|
|
1020
|
-
if (part.type === "toolCall" && (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;
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
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);
|
|
1044
|
-
const result = [];
|
|
1045
|
-
let webSummaryEmitted = false;
|
|
1046
|
-
// Second pass: compress matching tool results
|
|
1047
|
-
for (const line of lines) {
|
|
1048
|
-
let entry;
|
|
1049
|
-
try {
|
|
1050
|
-
entry = JSON.parse(line);
|
|
1051
|
-
}
|
|
1052
|
-
catch {
|
|
1053
|
-
result.push(line);
|
|
1054
|
-
continue;
|
|
1055
|
-
}
|
|
1056
|
-
if (entry?.type !== "message" || (entry.message?.role !== "tool" && entry.message?.role !== "toolResult")) {
|
|
1057
|
-
result.push(line);
|
|
1058
|
-
continue;
|
|
1059
|
-
}
|
|
1060
|
-
// Extract toolCallId — message-level or content-level toolResult.
|
|
1061
|
-
// Drop only *explicit* empty/whitespace IDs (APIs reject those). Missing
|
|
1062
|
-
// toolCallId is treated as legacy shape and passes through unchanged.
|
|
1063
|
-
let toolCallId;
|
|
1064
|
-
let invalidEmptyId = false;
|
|
1065
|
-
if (typeof entry.message.toolCallId === "string") {
|
|
1066
|
-
const v = entry.message.toolCallId;
|
|
1067
|
-
if (!v.trim())
|
|
1068
|
-
invalidEmptyId = true;
|
|
1069
|
-
else
|
|
1070
|
-
toolCallId = v;
|
|
1071
|
-
}
|
|
1072
|
-
else if (Array.isArray(entry.message.content)) {
|
|
1073
|
-
for (const part of entry.message.content) {
|
|
1074
|
-
if (part.type === "toolResult" && typeof part.toolCallId === "string") {
|
|
1075
|
-
if (!part.toolCallId.trim()) {
|
|
1076
|
-
invalidEmptyId = true;
|
|
1077
|
-
break;
|
|
1078
|
-
}
|
|
1079
|
-
toolCallId = part.toolCallId;
|
|
1080
|
-
break;
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
if (invalidEmptyId)
|
|
1085
|
-
continue;
|
|
1086
|
-
if (!toolCallId) {
|
|
1087
|
-
result.push(line);
|
|
1088
|
-
continue;
|
|
1089
|
-
}
|
|
1090
|
-
const toolName = toolCallIdToName.get(toolCallId);
|
|
1091
|
-
let rendered;
|
|
1092
|
-
let originalText = "";
|
|
1093
|
-
// --- Compress flow tool results ---
|
|
1094
|
-
if (toolName === "flow") {
|
|
1095
|
-
const compressed = cache.get(toolCallId);
|
|
1096
|
-
if (!compressed || compressed.length === 0) {
|
|
1097
|
-
// Cache miss (never populated or evicted) — do NOT pass megabytes of raw
|
|
1098
|
-
// flow output verbatim into child context. Render a minimal placeholder.
|
|
1099
|
-
originalText = extractToolResultText(entry) ?? "";
|
|
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`;
|
|
1115
|
-
}
|
|
1116
|
-
else {
|
|
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
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
rendered = renderedParts.join("\n\n");
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
// --- Compress batch tool results (selective: compress bash, truncate reads, dedup writes/edits/deletes) ---
|
|
1135
|
-
else if (toolName === "batch") {
|
|
1136
|
-
originalText = extractToolResultText(entry) ?? "";
|
|
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
|
-
});
|
|
1155
|
-
}
|
|
1156
|
-
// --- Compress web tool results (Q1 dedup) ---
|
|
1157
|
-
else if (toolName === "web") {
|
|
1158
|
-
originalText = extractToolResultText(entry) ?? "";
|
|
1159
|
-
const args = toolCallIdToArgs.get(toolCallId);
|
|
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
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
// --- Compress ask_user tool results (A1 dedup) ---
|
|
1185
|
-
else if (toolName === "ask_user") {
|
|
1186
|
-
originalText = extractToolResultText(entry) ?? "";
|
|
1187
|
-
const args = toolCallIdToArgs.get(toolCallId);
|
|
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
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
if (rendered !== undefined) {
|
|
1211
|
-
logCompress(toolName ?? "unknown", originalText.length || line.length, rendered.length);
|
|
1212
|
-
// Strip the 'details' field which carries UI metadata that children don't need.
|
|
1213
|
-
// This eliminates ~98% of payload bloat from flow tool results.
|
|
1214
|
-
const { details, ...messageWithoutDetails } = entry.message;
|
|
1215
|
-
if (typeof entry.message.toolCallId === "string") {
|
|
1216
|
-
entry = {
|
|
1217
|
-
...entry,
|
|
1218
|
-
message: {
|
|
1219
|
-
...messageWithoutDetails,
|
|
1220
|
-
content: [{ type: "text", text: rendered }],
|
|
1221
|
-
},
|
|
1222
|
-
};
|
|
1223
|
-
}
|
|
1224
|
-
else {
|
|
1225
|
-
entry = {
|
|
1226
|
-
...entry,
|
|
1227
|
-
message: {
|
|
1228
|
-
...messageWithoutDetails,
|
|
1229
|
-
content: entry.message.content.map((part) => part.type === "toolResult" && part.toolCallId === toolCallId
|
|
1230
|
-
? { ...part, content: rendered }
|
|
1231
|
-
: part),
|
|
1232
|
-
},
|
|
1233
|
-
};
|
|
1234
|
-
}
|
|
1235
|
-
result.push(JSON.stringify(entry));
|
|
1236
|
-
continue;
|
|
1237
|
-
}
|
|
1238
|
-
// Other tool results pass through unchanged
|
|
1239
|
-
result.push(line);
|
|
1240
|
-
}
|
|
1241
|
-
return `${result.join("\n")}\n`;
|
|
1242
|
-
}
|
|
1243
|
-
/** Extract text content from a tool result entry for compression analysis. */
|
|
1244
|
-
function extractToolResultText(entry) {
|
|
1245
|
-
if (typeof entry.message?.content === "string") {
|
|
1246
|
-
return entry.message.content;
|
|
1247
|
-
}
|
|
1248
|
-
if (Array.isArray(entry.message?.content)) {
|
|
1249
|
-
for (const part of entry.message.content) {
|
|
1250
|
-
if (part.type === "text" && typeof part.text === "string") {
|
|
1251
|
-
return part.text;
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
return undefined;
|
|
1256
|
-
}
|
|
1257
|
-
// ---------------------------------------------------------------------------
|
|
1258
|
-
// batch_read tool call stripping
|
|
1259
|
-
// ---------------------------------------------------------------------------
|
|
1260
|
-
/**
|
|
1261
|
-
* Strip batch_read tool calls from assistant messages in a session snapshot.
|
|
1262
|
-
*
|
|
1263
|
-
* Children don't have batch_read in their active tools, so seeing calls to it
|
|
1264
|
-
* could confuse the model. This removes toolCall parts where name === "batch_read"
|
|
1265
|
-
* from assistant messages AND drops the corresponding toolResult messages
|
|
1266
|
-
* whose toolCallId references a stripped batch_read call. Keeping orphaned tool
|
|
1267
|
-
* results causes strict API providers (e.g. kimi-coding, DeepSeek) to reject
|
|
1268
|
-
* the request with `tool_call_id is not found`.
|
|
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
|
-
}
|
|
1308
|
-
export function stripBatchReadToolCalls(snapshot) {
|
|
1309
|
-
const lines = snapshot.trimEnd().split("\n");
|
|
1310
|
-
// Pass 1: Collect all batch_read toolCallIds from assistant messages.
|
|
1311
|
-
const batchReadToolCallIds = new Set();
|
|
1312
|
-
for (const line of lines) {
|
|
1313
|
-
let entry;
|
|
1314
|
-
try {
|
|
1315
|
-
entry = JSON.parse(line);
|
|
1316
|
-
}
|
|
1317
|
-
catch {
|
|
1318
|
-
continue;
|
|
1319
|
-
}
|
|
1320
|
-
if (entry?.type !== "message" || entry.message?.role !== "assistant")
|
|
1321
|
-
continue;
|
|
1322
|
-
const content = entry.message.content;
|
|
1323
|
-
if (!Array.isArray(content))
|
|
1324
|
-
continue;
|
|
1325
|
-
for (const part of content) {
|
|
1326
|
-
if (part.type === "toolCall" && part.name === "batch_read" && (part.id || part.toolCallId)) {
|
|
1327
|
-
batchReadToolCallIds.add((part.id ?? part.toolCallId));
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
// Pass 2: Strip batch_read toolCall parts from assistant messages,
|
|
1332
|
-
// and remove orphaned tool result messages.
|
|
1333
|
-
const result = [];
|
|
1334
|
-
for (const line of lines) {
|
|
1335
|
-
let entry;
|
|
1336
|
-
try {
|
|
1337
|
-
entry = JSON.parse(line);
|
|
1338
|
-
}
|
|
1339
|
-
catch {
|
|
1340
|
-
result.push(line);
|
|
1341
|
-
continue;
|
|
1342
|
-
}
|
|
1343
|
-
if (entry?.type !== "message") {
|
|
1344
|
-
result.push(line);
|
|
1345
|
-
continue;
|
|
1346
|
-
}
|
|
1347
|
-
// Tool result message — skip if it's a batch_read result
|
|
1348
|
-
if (entry.message.role === "tool" || entry.message.role === "toolResult") {
|
|
1349
|
-
const toolCallId = entry.message.toolCallId ??
|
|
1350
|
-
(Array.isArray(entry.message.content) ? entry.message.content.find((p) => p.type === "toolResult")?.toolCallId : undefined);
|
|
1351
|
-
if (toolCallId && batchReadToolCallIds.has(toolCallId))
|
|
1352
|
-
continue;
|
|
1353
|
-
result.push(line);
|
|
1354
|
-
continue;
|
|
1355
|
-
}
|
|
1356
|
-
if (entry.message.role !== "assistant") {
|
|
1357
|
-
result.push(line);
|
|
1358
|
-
continue;
|
|
1359
|
-
}
|
|
1360
|
-
const content = entry.message.content;
|
|
1361
|
-
if (!Array.isArray(content)) {
|
|
1362
|
-
result.push(line);
|
|
1363
|
-
continue;
|
|
1364
|
-
}
|
|
1365
|
-
const hasBatchReadCall = content.some((part) => part.type === "toolCall" && part.name === "batch_read");
|
|
1366
|
-
if (!hasBatchReadCall) {
|
|
1367
|
-
result.push(line);
|
|
1368
|
-
continue;
|
|
1369
|
-
}
|
|
1370
|
-
const filteredContent = content.filter((part) => !(part.type === "toolCall" && part.name === "batch_read"));
|
|
1371
|
-
if (filteredContent.length === 0) {
|
|
1372
|
-
// Skip assistant messages that have no content after stripping batch_read
|
|
1373
|
-
// — an empty text placeholder wastes tokens and conveys nothing.
|
|
1374
|
-
continue;
|
|
1375
|
-
}
|
|
1376
|
-
result.push(JSON.stringify({
|
|
1377
|
-
...entry,
|
|
1378
|
-
message: {
|
|
1379
|
-
...entry.message,
|
|
1380
|
-
content: filteredContent,
|
|
1381
|
-
},
|
|
1382
|
-
}));
|
|
1383
|
-
}
|
|
1384
|
-
return `${result.join("\n")}\n`;
|
|
1385
|
-
}
|
|
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
|
-
// ---------------------------------------------------------------------------
|
|
1456
|
-
// Reparent orphans
|
|
1457
|
-
// ---------------------------------------------------------------------------
|
|
1458
|
-
/**
|
|
1459
|
-
* Fix parentId references that point to messages which no longer exist.
|
|
1460
|
-
* Call this after any pass that drops messages.
|
|
1461
|
-
*/
|
|
1462
|
-
function reparentOrphans(snapshot) {
|
|
1463
|
-
const lines = snapshot.trimEnd().split("\n");
|
|
1464
|
-
const survivingIds = new Set();
|
|
1465
|
-
for (const line of lines) {
|
|
1466
|
-
try {
|
|
1467
|
-
const entry = JSON.parse(line);
|
|
1468
|
-
const id = entry?.message?.id ?? entry?.message?.messageId ?? entry?.id;
|
|
1469
|
-
if (typeof id === "string" && id)
|
|
1470
|
-
survivingIds.add(id);
|
|
1471
|
-
// Only ids of actual entries should be in survivingIds; parentId refs
|
|
1472
|
-
// are checked in the second pass, not added here.
|
|
1473
|
-
}
|
|
1474
|
-
catch { /* ignore */ }
|
|
1475
|
-
}
|
|
1476
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1477
|
-
try {
|
|
1478
|
-
let entry = JSON.parse(lines[i]);
|
|
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;
|
|
1490
|
-
entry = restEntry;
|
|
1491
|
-
modified = true;
|
|
1492
|
-
}
|
|
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;
|
|
1499
|
-
entry = { ...entry, message: restMessage };
|
|
1500
|
-
modified = true;
|
|
1501
|
-
}
|
|
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;
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
if (modified) {
|
|
1509
|
-
lines[i] = JSON.stringify(entry);
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
catch { /* ignore */ }
|
|
1513
|
-
}
|
|
1514
|
-
return `${lines.join("\n")}\n`;
|
|
1515
|
-
}
|
|
1516
|
-
export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
|
|
1517
|
-
if (!snapshot)
|
|
1518
|
-
return { result: snapshot, passesApplied: [], stats: null };
|
|
1519
|
-
const preBytes = snapshot.length;
|
|
1520
|
-
const lines = snapshot.trimEnd().split("\n");
|
|
1521
|
-
const sanitizedLines = [];
|
|
1522
|
-
const subPasses = new Set();
|
|
1523
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1524
|
-
const line = lines[i];
|
|
1525
|
-
let entry;
|
|
1526
|
-
try {
|
|
1527
|
-
entry = JSON.parse(line);
|
|
1528
|
-
}
|
|
1529
|
-
catch {
|
|
1530
|
-
sanitizedLines.push(line);
|
|
1531
|
-
continue;
|
|
1532
|
-
}
|
|
1533
|
-
let changed = false;
|
|
1534
|
-
// Header (first line): replace parent system prompt.
|
|
1535
|
-
if (i === 0 && entry && typeof entry === "object") {
|
|
1536
|
-
// Replace the parent orchestrator system prompt with a brief note.
|
|
1537
|
-
// Children receive their own directive in the <activation> block.
|
|
1538
|
-
if (entry.systemPrompt && typeof entry.systemPrompt === "string") {
|
|
1539
|
-
entry = { ...entry, systemPrompt: "[parent orchestrator system prompt stripped — child receives its own directive]" };
|
|
1540
|
-
changed = true;
|
|
1541
|
-
subPasses.add("stripSystemPrompt");
|
|
1542
|
-
}
|
|
1543
|
-
// Prevent child from inheriting parent's session identity.
|
|
1544
|
-
// Rename id → parentId so lineage is preserved but the child
|
|
1545
|
-
// generates its own session identifier.
|
|
1546
|
-
if ('id' in entry) {
|
|
1547
|
-
entry = { ...entry, parentId: entry.id };
|
|
1548
|
-
delete entry.id;
|
|
1549
|
-
changed = true;
|
|
1550
|
-
subPasses.add('stripSessionId');
|
|
1551
|
-
}
|
|
1552
|
-
}
|
|
1553
|
-
// Drop type: "system" entries — the parent orchestrator system prompt was already
|
|
1554
|
-
// stripped from the header above. Standalone system events leak the full prompt.
|
|
1555
|
-
// Children receive their own directive in the <activation> block.
|
|
1556
|
-
if (entry?.type === "system") {
|
|
1557
|
-
subPasses.add("dropSystemEvents");
|
|
1558
|
-
continue;
|
|
1559
|
-
}
|
|
1560
|
-
// Drop custom_message entries — hidden orchestrator instructions (e.g.
|
|
1561
|
-
// flow continuation hook messages with display:false) that children
|
|
1562
|
-
// should never see.
|
|
1563
|
-
if (entry?.type === "custom_message") {
|
|
1564
|
-
subPasses.add("dropCustomMessages");
|
|
1565
|
-
continue;
|
|
1566
|
-
}
|
|
1567
|
-
// Drop parent-specific configuration events; child receives its own
|
|
1568
|
-
// model/tier via the <activation> block and CLI args.
|
|
1569
|
-
if (entry?.type === "model_change" || entry?.type === "thinking_level_change") {
|
|
1570
|
-
subPasses.add("dropConfigEvents");
|
|
1571
|
-
continue;
|
|
1572
|
-
}
|
|
1573
|
-
// Defense-in-depth: drop entries with an explicit unknown type that do not
|
|
1574
|
-
// belong in the fork snapshot protocol. Entries without a type field (e.g. bare
|
|
1575
|
-
// session headers from getHeader) pass through unchanged.
|
|
1576
|
-
if (entry?.type !== undefined &&
|
|
1577
|
-
entry?.type !== "session" &&
|
|
1578
|
-
entry?.type !== "message") {
|
|
1579
|
-
subPasses.add("dropUnknownTypes");
|
|
1580
|
-
continue;
|
|
1581
|
-
}
|
|
1582
|
-
// Drop malformed message entries that lack a message payload.
|
|
1583
|
-
if (entry?.type === "message" && !entry.message) {
|
|
1584
|
-
subPasses.add("dropMalformedMessages");
|
|
1585
|
-
continue;
|
|
1586
|
-
}
|
|
1587
|
-
// Drop sliding system prompt messages entirely.
|
|
1588
|
-
if (entry?.type === "message" &&
|
|
1589
|
-
entry.message?.role === "system" &&
|
|
1590
|
-
contentContainsSteeringHintTag(entry.message?.content)) {
|
|
1591
|
-
subPasses.add("dropSlidingSystemPrompts");
|
|
1592
|
-
continue;
|
|
1593
|
-
}
|
|
1594
|
-
if (entry?.type === "message" && entry.message) {
|
|
1595
|
-
let message = entry.message;
|
|
1596
|
-
// Normalize internal "toolResult" role to "tool" for API compatibility.
|
|
1597
|
-
if (message.role === "toolResult") {
|
|
1598
|
-
message = { ...message, role: "tool" };
|
|
1599
|
-
changed = true;
|
|
1600
|
-
subPasses.add("normalizeToolResultRole");
|
|
1601
|
-
}
|
|
1602
|
-
// Strip reasoning/thinking from assistant messages.
|
|
1603
|
-
// (Reasoning typically only appears in assistant messages, but we
|
|
1604
|
-
// also check system/tool roles as a safety net for provider-specific
|
|
1605
|
-
// formats. stripReasoningFromAssistantMessage is a no-op on non-assistant
|
|
1606
|
-
// shapes, so calling it universally is safe.)
|
|
1607
|
-
if (message.role === "assistant" || message.role === "system" || message.role === "tool") {
|
|
1608
|
-
const stripped = stripReasoningFromAssistantMessage(message);
|
|
1609
|
-
message = stripped.message;
|
|
1610
|
-
if (stripped.changed) {
|
|
1611
|
-
changed = true;
|
|
1612
|
-
subPasses.add("stripReasoning");
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
// Strip inner `message.timestamp` — the outer event-level timestamp (ISO string)
|
|
1616
|
-
// is sufficient for ordering. The inner epoch-ms timestamp is redundant.
|
|
1617
|
-
if ("timestamp" in message) {
|
|
1618
|
-
const { timestamp, ...restMessage } = message;
|
|
1619
|
-
message = restMessage;
|
|
1620
|
-
changed = true;
|
|
1621
|
-
subPasses.add("stripTimestamps");
|
|
1622
|
-
}
|
|
1623
|
-
// Strip API metadata fields that children don't need (~5-7 KB per assistant message).
|
|
1624
|
-
// IMPORTANT: keep `usage.totalTokens` ONLY. The child `pi` process replays
|
|
1625
|
-
// this JSONL and core/session code reads `message.usage.totalTokens`; stripping
|
|
1626
|
-
// `usage` causes: Cannot read properties of undefined (reading 'totalTokens').
|
|
1627
|
-
// Other fields (input, output, cacheRead, cacheWrite) are consumed only from
|
|
1628
|
-
// live child stdout events (runner-events.ts), never from fork snapshot replay.
|
|
1629
|
-
if (message.role === "assistant") {
|
|
1630
|
-
const { api, provider, model, stopReason, responseId, responseModel, usage, ...rest } = message;
|
|
1631
|
-
let stripped = false;
|
|
1632
|
-
if (api !== undefined || provider !== undefined || model !== undefined ||
|
|
1633
|
-
stopReason !== undefined || responseId !== undefined || responseModel !== undefined) {
|
|
1634
|
-
stripped = true;
|
|
1635
|
-
}
|
|
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
|
-
}
|
|
1646
|
-
}
|
|
1647
|
-
if (stripped) {
|
|
1648
|
-
message = { ...rest, ...(cleanedUsage !== undefined ? { usage: cleanedUsage } : {}) };
|
|
1649
|
-
changed = true;
|
|
1650
|
-
subPasses.add("stripApiMetadata");
|
|
1651
|
-
}
|
|
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
|
-
}
|
|
1667
|
-
// Strip `details` from tool/toolResult messages — carries FlowDetails UI metadata
|
|
1668
|
-
// (mode, flowStyle, projectAgentsDir, results) that children never need.
|
|
1669
|
-
if (message.role === "tool" || message.role === "toolResult") {
|
|
1670
|
-
if ("details" in message) {
|
|
1671
|
-
const { details, ...restMessage } = message;
|
|
1672
|
-
message = restMessage;
|
|
1673
|
-
changed = true;
|
|
1674
|
-
subPasses.add("stripDetails");
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
if ("content" in message) {
|
|
1678
|
-
let modifiedContent = message.content;
|
|
1679
|
-
// Strip sliding prompts
|
|
1680
|
-
const afterSliding = stripSteeringHintFromContent(modifiedContent);
|
|
1681
|
-
if (!isJsonEqual(afterSliding, modifiedContent)) {
|
|
1682
|
-
modifiedContent = afterSliding;
|
|
1683
|
-
changed = true;
|
|
1684
|
-
subPasses.add("stripSteeringHints");
|
|
1685
|
-
}
|
|
1686
|
-
// Strip strategic hints from tool results
|
|
1687
|
-
if (message.role === "tool" || message.role === "toolResult") {
|
|
1688
|
-
const afterHints = stripStrategicHintsFromContent(modifiedContent);
|
|
1689
|
-
if (!isJsonEqual(afterHints, modifiedContent)) {
|
|
1690
|
-
modifiedContent = afterHints;
|
|
1691
|
-
changed = true;
|
|
1692
|
-
subPasses.add("stripStrategicHints");
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
// Compress parent activation prompts in nested snapshot JSONL
|
|
1696
|
-
// (detect user messages containing <context-seal> at depth >= 2).
|
|
1697
|
-
if (message.role === "user" && options?.depth !== undefined && options.depth >= 2) {
|
|
1698
|
-
let hasParentActivation = false;
|
|
1699
|
-
let previewText = "";
|
|
1700
|
-
const parentActivationRegex = /<context-seal>[\s\S]*?<\/context-seal>/;
|
|
1701
|
-
let fullText = "";
|
|
1702
|
-
if (typeof modifiedContent === "string") {
|
|
1703
|
-
fullText = modifiedContent;
|
|
1704
|
-
}
|
|
1705
|
-
else if (Array.isArray(modifiedContent)) {
|
|
1706
|
-
fullText = modifiedContent
|
|
1707
|
-
.filter((p) => p.type === "text" && typeof p.text === "string")
|
|
1708
|
-
.map((p) => p.text)
|
|
1709
|
-
.join("");
|
|
1710
|
-
}
|
|
1711
|
-
if (parentActivationRegex.test(fullText)) {
|
|
1712
|
-
hasParentActivation = true;
|
|
1713
|
-
// Extract mission content for preview; fall back to content after </context-seal>
|
|
1714
|
-
const missionMatch = fullText.match(/<mission>([\s\S]*?)<\/mission>/);
|
|
1715
|
-
if (missionMatch) {
|
|
1716
|
-
previewText = missionMatch[1].trim().replace(/\s+/g, " ").slice(0, 200).trim();
|
|
1717
|
-
}
|
|
1718
|
-
else {
|
|
1719
|
-
const afterSeal = fullText.split(/<\/context-seal>/).pop() ?? fullText;
|
|
1720
|
-
previewText = afterSeal.trim().slice(0, 200).trim();
|
|
1721
|
-
}
|
|
1722
|
-
}
|
|
1723
|
-
if (hasParentActivation) {
|
|
1724
|
-
const compact = `[Parent flow activation stripped] Mission preview: ${previewText}`;
|
|
1725
|
-
if (typeof modifiedContent === "string") {
|
|
1726
|
-
modifiedContent = compact;
|
|
1727
|
-
}
|
|
1728
|
-
else {
|
|
1729
|
-
modifiedContent = [{ type: "text", text: compact }];
|
|
1730
|
-
}
|
|
1731
|
-
changed = true;
|
|
1732
|
-
subPasses.add("compressParentActivation");
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
if (changed) {
|
|
1736
|
-
message = { ...message, content: modifiedContent };
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
if (changed) {
|
|
1740
|
-
entry = { ...entry, message };
|
|
1741
|
-
}
|
|
1742
|
-
}
|
|
1743
|
-
const outLine = changed ? JSON.stringify(entry) : line;
|
|
1744
|
-
sanitizedLines.push(outLine);
|
|
1745
|
-
}
|
|
1746
|
-
const passesApplied = [];
|
|
1747
|
-
const passDeltas = {};
|
|
1748
|
-
const measureBytes = (s) => new TextEncoder().encode(s).length;
|
|
1749
|
-
let sanitized = `${sanitizedLines.join("\n")}\n`;
|
|
1750
|
-
passesApplied.push(...subPasses);
|
|
1751
|
-
passDeltas["mainLoop"] = measureBytes(sanitized);
|
|
1752
|
-
// Reparent orphaned parentIds after steering-hint messages were dropped.
|
|
1753
|
-
sanitized = reparentOrphans(sanitized);
|
|
1754
|
-
passesApplied.push("reparentOrphans");
|
|
1755
|
-
passDeltas["reparentOrphans1"] = measureBytes(sanitized);
|
|
1756
|
-
// Strip batch_read tool calls from assistant messages.
|
|
1757
|
-
// Children don't have batch_read in their active tools.
|
|
1758
|
-
sanitized = stripBatchReadToolCalls(sanitized);
|
|
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);
|
|
1765
|
-
// Compress tool results (flow, batch, web, ask_user).
|
|
1766
|
-
sanitized = compressToolResults(sanitized, cache, depthToPolicy(options?.depth ?? 1));
|
|
1767
|
-
passesApplied.push("compressToolResults");
|
|
1768
|
-
passDeltas["compressToolResults"] = measureBytes(sanitized);
|
|
1769
|
-
// Reparent again after stripBatchRead and compressToolResults may have
|
|
1770
|
-
// dropped additional messages, leaving new orphaned parentIds.
|
|
1771
|
-
sanitized = reparentOrphans(sanitized);
|
|
1772
|
-
passesApplied.push("reparentOrphans");
|
|
1773
|
-
passDeltas["reparentOrphans2"] = measureBytes(sanitized);
|
|
1774
|
-
// Telemetry: measure total delta across sanitization, stripping, and compression.
|
|
1775
|
-
const postBytes = sanitized.length;
|
|
1776
|
-
const reduction = preBytes > 0 ? ((1 - postBytes / preBytes) * 100).toFixed(0) : "0";
|
|
1777
|
-
if (DEBUG_CONTEXT) {
|
|
1778
|
-
logError(`[context-snapshot] pre: ${preBytes} → post: ${postBytes} bytes (${reduction}% reduction)`);
|
|
1779
|
-
}
|
|
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 = {
|
|
1783
|
-
preBytes,
|
|
1784
|
-
postBytes,
|
|
1785
|
-
reductionPercent: Number(reduction),
|
|
1786
|
-
passesApplied,
|
|
1787
|
-
passDeltas,
|
|
1788
|
-
};
|
|
1789
|
-
return { result: sanitized, passesApplied, stats };
|
|
1790
|
-
}
|
|
1791
|
-
//# sourceMappingURL=snapshot.js.map
|