pi-agent-flow 2.0.2 → 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.
Files changed (193) hide show
  1. package/README.md +1 -1
  2. package/agents/audit.md +24 -17
  3. package/agents/build.md +1 -1
  4. package/agents/craft.md +1 -1
  5. package/agents/debug.md +1 -1
  6. package/agents/ideas.md +1 -1
  7. package/agents/scout.md +8 -8
  8. package/agents/trace.md +23 -0
  9. package/dist/batch/batch-bash.d.ts +10 -0
  10. package/dist/batch/batch-bash.d.ts.map +1 -1
  11. package/dist/batch/batch-bash.js +35 -2
  12. package/dist/batch/batch-bash.js.map +1 -1
  13. package/dist/batch/constants.d.ts +6 -3
  14. package/dist/batch/constants.d.ts.map +1 -1
  15. package/dist/batch/execute.js +2 -2
  16. package/dist/batch/execute.js.map +1 -1
  17. package/dist/batch/index.d.ts +9 -11
  18. package/dist/batch/index.d.ts.map +1 -1
  19. package/dist/batch/index.js +88 -58
  20. package/dist/batch/index.js.map +1 -1
  21. package/dist/batch/render.d.ts +22 -5
  22. package/dist/batch/render.d.ts.map +1 -1
  23. package/dist/batch/render.js +332 -17
  24. package/dist/batch/render.js.map +1 -1
  25. package/dist/batch/summary.d.ts.map +1 -1
  26. package/dist/batch/summary.js +22 -4
  27. package/dist/batch/summary.js.map +1 -1
  28. package/dist/config/config.d.ts +5 -4
  29. package/dist/config/config.d.ts.map +1 -1
  30. package/dist/config/config.js +15 -5
  31. package/dist/config/config.js.map +1 -1
  32. package/dist/config/models.d.ts.map +1 -1
  33. package/dist/config/models.js +7 -1
  34. package/dist/config/models.js.map +1 -1
  35. package/dist/config/settings-resolver.d.ts +5 -4
  36. package/dist/config/settings-resolver.d.ts.map +1 -1
  37. package/dist/config/settings-resolver.js +50 -21
  38. package/dist/config/settings-resolver.js.map +1 -1
  39. package/dist/core2/snapshot.d.ts +21 -0
  40. package/dist/core2/snapshot.d.ts.map +1 -0
  41. package/dist/core2/snapshot.js +214 -0
  42. package/dist/core2/snapshot.js.map +1 -0
  43. package/dist/{core → flow}/agents.d.ts.map +1 -1
  44. package/dist/{core → flow}/agents.js +5 -2
  45. package/dist/{core → flow}/agents.js.map +1 -1
  46. package/dist/flow/command.d.ts +1 -1
  47. package/dist/flow/command.d.ts.map +1 -1
  48. package/dist/flow/complexity.d.ts +20 -0
  49. package/dist/flow/complexity.d.ts.map +1 -0
  50. package/dist/flow/complexity.js +34 -0
  51. package/dist/flow/complexity.js.map +1 -0
  52. package/dist/flow/continuation.d.ts +1 -1
  53. package/dist/flow/continuation.d.ts.map +1 -1
  54. package/dist/flow/continuation.js +2 -1
  55. package/dist/flow/continuation.js.map +1 -1
  56. package/dist/{core → flow}/depth.d.ts +1 -1
  57. package/dist/{core → flow}/depth.d.ts.map +1 -1
  58. package/dist/{core → flow}/depth.js.map +1 -1
  59. package/dist/{core → flow}/executor.d.ts +39 -19
  60. package/dist/flow/executor.d.ts.map +1 -0
  61. package/dist/flow/executor.js +727 -0
  62. package/dist/flow/executor.js.map +1 -0
  63. package/dist/flow/index.d.ts +2 -2
  64. package/dist/flow/index.d.ts.map +1 -1
  65. package/dist/flow/index.js +1 -1
  66. package/dist/flow/index.js.map +1 -1
  67. package/dist/flow/loop-command.d.ts +1 -1
  68. package/dist/flow/loop-command.d.ts.map +1 -1
  69. package/dist/{core/flow.d.ts → flow/runner.d.ts} +10 -13
  70. package/dist/flow/runner.d.ts.map +1 -0
  71. package/dist/{core/flow.js → flow/runner.js} +35 -34
  72. package/dist/flow/runner.js.map +1 -0
  73. package/dist/{core → flow}/session-registry.d.ts.map +1 -1
  74. package/dist/{core → flow}/session-registry.js.map +1 -1
  75. package/dist/flow/settings-command.d.ts +3 -3
  76. package/dist/flow/settings-command.d.ts.map +1 -1
  77. package/dist/flow/settings-command.js +39 -21
  78. package/dist/flow/settings-command.js.map +1 -1
  79. package/dist/{core → flow}/transition.d.ts.map +1 -1
  80. package/dist/{core → flow}/transition.js.map +1 -1
  81. package/dist/flow/types.d.ts +4 -0
  82. package/dist/flow/types.d.ts.map +1 -1
  83. package/dist/flow/warp.d.ts +1 -1
  84. package/dist/flow/warp.d.ts.map +1 -1
  85. package/dist/index.d.ts +1 -2
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +189 -188
  88. package/dist/index.js.map +1 -1
  89. package/dist/notify/notify.d.ts +1 -1
  90. package/dist/notify/notify.d.ts.map +1 -1
  91. package/dist/notify/notify.js +1 -1
  92. package/dist/snapshot/cli-args.d.ts +2 -2
  93. package/dist/snapshot/cli-args.d.ts.map +1 -1
  94. package/dist/snapshot/cli-args.js +21 -5
  95. package/dist/snapshot/cli-args.js.map +1 -1
  96. package/dist/snapshot/runner-events.d.ts +2 -2
  97. package/dist/snapshot/runner-events.d.ts.map +1 -1
  98. package/dist/snapshot/runner-events.js +48 -4
  99. package/dist/snapshot/runner-events.js.map +1 -1
  100. package/dist/snapshot/structured-output.d.ts +1 -1
  101. package/dist/snapshot/structured-output.d.ts.map +1 -1
  102. package/dist/snapshot/structured-output.js +20 -2
  103. package/dist/snapshot/structured-output.js.map +1 -1
  104. package/dist/steering/flow-prompt.d.ts +2 -2
  105. package/dist/steering/flow-prompt.d.ts.map +1 -1
  106. package/dist/steering/flow-prompt.js +14 -33
  107. package/dist/steering/flow-prompt.js.map +1 -1
  108. package/dist/steering/sliding-prompt.d.ts.map +1 -1
  109. package/dist/steering/sliding-prompt.js +3 -2
  110. package/dist/steering/sliding-prompt.js.map +1 -1
  111. package/dist/tools/ask-user.d.ts +2 -2
  112. package/dist/tools/ask-user.d.ts.map +1 -1
  113. package/dist/tools/ask-user.js +1 -1
  114. package/dist/tools/ask-user.js.map +1 -1
  115. package/dist/tools/timed-bash.js +1 -1
  116. package/dist/tools/timed-bash.js.map +1 -1
  117. package/dist/tools/trace.d.ts +34 -0
  118. package/dist/tools/trace.d.ts.map +1 -0
  119. package/dist/tools/trace.js +180 -0
  120. package/dist/tools/trace.js.map +1 -0
  121. package/dist/tools/web-ops.d.ts +85 -0
  122. package/dist/tools/web-ops.d.ts.map +1 -0
  123. package/dist/tools/{web-tool.js → web-ops.js} +51 -125
  124. package/dist/tools/web-ops.js.map +1 -0
  125. package/dist/tui/flow-colors.d.ts +1 -0
  126. package/dist/tui/flow-colors.d.ts.map +1 -1
  127. package/dist/tui/flow-colors.js +2 -2
  128. package/dist/tui/flow-colors.js.map +1 -1
  129. package/dist/tui/render-utils.js +1 -1
  130. package/dist/tui/render-utils.js.map +1 -1
  131. package/dist/tui/render.d.ts +32 -1
  132. package/dist/tui/render.d.ts.map +1 -1
  133. package/dist/tui/render.js +666 -170
  134. package/dist/tui/render.js.map +1 -1
  135. package/dist/tui/scramble/algorithm.d.ts +4 -2
  136. package/dist/tui/scramble/algorithm.d.ts.map +1 -1
  137. package/dist/tui/scramble/algorithm.js +44 -12
  138. package/dist/tui/scramble/algorithm.js.map +1 -1
  139. package/dist/tui/scramble/constants.d.ts +3 -0
  140. package/dist/tui/scramble/constants.d.ts.map +1 -1
  141. package/dist/tui/scramble/constants.js +4 -1
  142. package/dist/tui/scramble/constants.js.map +1 -1
  143. package/dist/tui/scramble/index.d.ts +2 -1
  144. package/dist/tui/scramble/index.d.ts.map +1 -1
  145. package/dist/tui/scramble/index.js +1 -1
  146. package/dist/tui/scramble/index.js.map +1 -1
  147. package/dist/tui/scramble/manager.d.ts +1 -1
  148. package/dist/tui/scramble/manager.d.ts.map +1 -1
  149. package/dist/tui/scramble/manager.js +24 -19
  150. package/dist/tui/scramble/manager.js.map +1 -1
  151. package/dist/types/flow.d.ts +17 -1
  152. package/dist/types/flow.d.ts.map +1 -1
  153. package/dist/types/flow.js.map +1 -1
  154. package/dist/types/output.d.ts +11 -36
  155. package/dist/types/output.d.ts.map +1 -1
  156. package/dist/types/output.js +1 -1
  157. package/dist/types/ui.d.ts +1 -1
  158. package/dist/types/ui.d.ts.map +1 -1
  159. package/package.json +9 -9
  160. package/dist/core/executor.d.ts.map +0 -1
  161. package/dist/core/executor.js +0 -378
  162. package/dist/core/executor.js.map +0 -1
  163. package/dist/core/flow.d.ts.map +0 -1
  164. package/dist/core/flow.js.map +0 -1
  165. package/dist/core/session-mode.d.ts +0 -11
  166. package/dist/core/session-mode.d.ts.map +0 -1
  167. package/dist/core/session-mode.js +0 -26
  168. package/dist/core/session-mode.js.map +0 -1
  169. package/dist/core/transitions.d.ts +0 -39
  170. package/dist/core/transitions.d.ts.map +0 -1
  171. package/dist/core/transitions.js +0 -59
  172. package/dist/core/transitions.js.map +0 -1
  173. package/dist/snapshot/index.d.ts +0 -2
  174. package/dist/snapshot/index.d.ts.map +0 -1
  175. package/dist/snapshot/index.js +0 -2
  176. package/dist/snapshot/index.js.map +0 -1
  177. package/dist/snapshot/reasoning-strip.d.ts +0 -22
  178. package/dist/snapshot/reasoning-strip.d.ts.map +0 -1
  179. package/dist/snapshot/reasoning-strip.js +0 -58
  180. package/dist/snapshot/reasoning-strip.js.map +0 -1
  181. package/dist/snapshot/snapshot.d.ts +0 -77
  182. package/dist/snapshot/snapshot.d.ts.map +0 -1
  183. package/dist/snapshot/snapshot.js +0 -1824
  184. package/dist/snapshot/snapshot.js.map +0 -1
  185. package/dist/tools/web-tool.d.ts +0 -46
  186. package/dist/tools/web-tool.d.ts.map +0 -1
  187. package/dist/tools/web-tool.js.map +0 -1
  188. /package/dist/{core → flow}/agents.d.ts +0 -0
  189. /package/dist/{core → flow}/depth.js +0 -0
  190. /package/dist/{core → flow}/session-registry.d.ts +0 -0
  191. /package/dist/{core → flow}/session-registry.js +0 -0
  192. /package/dist/{core → flow}/transition.d.ts +0 -0
  193. /package/dist/{core → flow}/transition.js +0 -0
@@ -1,1824 +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 { logWarn, 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 (err) {
1475
- logWarn(`[pi-agent-flow] reparentOrphans id scan failed: ${err}`);
1476
- }
1477
- }
1478
- for (let i = 0; i < lines.length; i++) {
1479
- try {
1480
- let entry = JSON.parse(lines[i]);
1481
- let modified = false;
1482
- const isMessageEntry = entry?.type === "message";
1483
- // Fix top-level parentId only for message entries (not session headers).
1484
- if (isMessageEntry) {
1485
- if (typeof entry.parentId === "string" && entry.parentId && !survivingIds.has(entry.parentId)) {
1486
- const { parentId: _pid, ...restEntry } = entry;
1487
- entry = restEntry;
1488
- modified = true;
1489
- }
1490
- if (typeof entry.parentMessageId === "string" && entry.parentMessageId && !survivingIds.has(entry.parentMessageId)) {
1491
- const { parentMessageId: _pmid, ...restEntry } = entry;
1492
- entry = restEntry;
1493
- modified = true;
1494
- }
1495
- }
1496
- // Fix message-level parentId for all entries.
1497
- const msg = entry.message;
1498
- if (msg) {
1499
- if (typeof msg.parentId === "string" && msg.parentId && !survivingIds.has(msg.parentId)) {
1500
- const { parentId: _pid, ...restMessage } = msg;
1501
- entry = { ...entry, message: restMessage };
1502
- modified = true;
1503
- }
1504
- if (typeof msg.parentMessageId === "string" && msg.parentMessageId && !survivingIds.has(msg.parentMessageId)) {
1505
- const { parentMessageId: _pmid, ...restMessage } = msg;
1506
- entry = { ...entry, message: restMessage };
1507
- modified = true;
1508
- }
1509
- }
1510
- if (modified) {
1511
- lines[i] = JSON.stringify(entry);
1512
- }
1513
- }
1514
- catch (err) {
1515
- logWarn(`[pi-agent-flow] reparentOrphans breadcrumb fix failed: ${err}`);
1516
- }
1517
- }
1518
- return `${lines.join("\n")}\n`;
1519
- }
1520
- export function sanitizeForkSnapshot(snapshot, cache = new Map(), options) {
1521
- if (!snapshot)
1522
- return { result: snapshot, passesApplied: [], stats: null };
1523
- const preBytes = snapshot.length;
1524
- const lines = snapshot.trimEnd().split("\n");
1525
- const sanitizedLines = [];
1526
- const subPasses = new Set();
1527
- for (let i = 0; i < lines.length; i++) {
1528
- const line = lines[i];
1529
- let entry;
1530
- try {
1531
- entry = JSON.parse(line);
1532
- }
1533
- catch (err) {
1534
- logWarn(`[pi-agent-flow] sanitizeForkSnapshot parse failed: ${err}`);
1535
- sanitizedLines.push(line);
1536
- continue;
1537
- }
1538
- let changed = false;
1539
- // Strip outer entry timestamp from all entries — child replay doesn't need it
1540
- // (JSONL line ordering is sufficient).
1541
- if ("timestamp" in entry) {
1542
- const { timestamp, ...restEntry } = entry;
1543
- entry = restEntry;
1544
- changed = true;
1545
- subPasses.add("stripTimestamps");
1546
- }
1547
- // Header (first line): replace parent system prompt.
1548
- if (i === 0 && entry && typeof entry === "object") {
1549
- // Replace the parent root state system prompt with a brief note.
1550
- // Children receive their own directive in the <activation> block.
1551
- if (entry.systemPrompt && typeof entry.systemPrompt === "string") {
1552
- entry = { ...entry, systemPrompt: "[parent root state system prompt stripped — child receives its own directive]" };
1553
- changed = true;
1554
- subPasses.add("stripSystemPrompt");
1555
- }
1556
- // Prevent child from inheriting parent's session identity.
1557
- // Rename id → parentId so lineage is preserved but the child
1558
- // generates its own session identifier.
1559
- if ('id' in entry) {
1560
- entry = { ...entry, parentId: entry.id };
1561
- delete entry.id;
1562
- changed = true;
1563
- subPasses.add('stripSessionId');
1564
- }
1565
- }
1566
- // Whitelist session entry fields to prevent unknown metadata leaks.
1567
- const isSessionHeader = i === 0 || entry?.type === "session";
1568
- if (isSessionHeader && entry && typeof entry === "object") {
1569
- const allowedHeaderKeys = new Set([
1570
- "type", "systemPrompt", "version", "cwd",
1571
- "forkedFrom", "forkedAt", "parentFlow", "depth", "parentId",
1572
- "meta",
1573
- ]);
1574
- const entryKeys = Object.keys(entry);
1575
- const hasUnknownHeaderField = entryKeys.some((k) => !allowedHeaderKeys.has(k));
1576
- if (hasUnknownHeaderField) {
1577
- const whitelisted = {};
1578
- for (const key of entryKeys) {
1579
- if (allowedHeaderKeys.has(key)) {
1580
- whitelisted[key] = entry[key];
1581
- }
1582
- }
1583
- entry = whitelisted;
1584
- changed = true;
1585
- subPasses.add("stripUnknownHeaderFields");
1586
- }
1587
- }
1588
- // Drop type: "system" entries — the parent root state system prompt was already
1589
- // stripped from the header above. Standalone system events leak the full prompt.
1590
- // Children receive their own directive in the <activation> block.
1591
- if (entry?.type === "system") {
1592
- subPasses.add("dropSystemEvents");
1593
- continue;
1594
- }
1595
- // Drop custom_message entries — hidden root state instructions (e.g.
1596
- // flow continuation hook messages with display:false) that children
1597
- // should never see.
1598
- if (entry?.type === "custom_message") {
1599
- subPasses.add("dropCustomMessages");
1600
- continue;
1601
- }
1602
- // Drop parent-specific configuration events; child receives its own
1603
- // model/tier via the <activation> block and CLI args.
1604
- if (entry?.type === "model_change" || entry?.type === "thinking_level_change") {
1605
- subPasses.add("dropConfigEvents");
1606
- continue;
1607
- }
1608
- // Defense-in-depth: drop entries with an explicit unknown type that do not
1609
- // belong in the fork snapshot protocol. Entries without a type field (e.g. bare
1610
- // session headers from getHeader) pass through unchanged.
1611
- if (entry?.type !== undefined &&
1612
- entry?.type !== "session" &&
1613
- entry?.type !== "message") {
1614
- subPasses.add("dropUnknownTypes");
1615
- continue;
1616
- }
1617
- // Drop malformed message entries that lack a message payload.
1618
- if (entry?.type === "message" && !entry.message) {
1619
- subPasses.add("dropMalformedMessages");
1620
- continue;
1621
- }
1622
- // Drop sliding system prompt messages entirely.
1623
- if (entry?.type === "message" &&
1624
- entry.message?.role === "system" &&
1625
- contentContainsSteeringHintTag(entry.message?.content)) {
1626
- subPasses.add("dropSlidingSystemPrompts");
1627
- continue;
1628
- }
1629
- if (entry?.type === "message" && entry.message) {
1630
- let message = entry.message;
1631
- // Normalize internal "toolResult" role to "tool" for API compatibility.
1632
- if (message.role === "toolResult") {
1633
- message = { ...message, role: "tool" };
1634
- changed = true;
1635
- subPasses.add("normalizeToolResultRole");
1636
- }
1637
- // Strip reasoning/thinking from assistant messages.
1638
- // (Reasoning typically only appears in assistant messages, but we
1639
- // also check system/tool roles as a safety net for provider-specific
1640
- // formats. stripReasoningFromAssistantMessage is a no-op on non-assistant
1641
- // shapes, so calling it universally is safe.)
1642
- if (message.role === "assistant" || message.role === "system" || message.role === "tool") {
1643
- const stripped = stripReasoningFromAssistantMessage(message);
1644
- message = stripped.message;
1645
- if (stripped.changed) {
1646
- changed = true;
1647
- subPasses.add("stripReasoning");
1648
- }
1649
- }
1650
- // Strip inner `message.timestamp` — the outer event-level timestamp (ISO string)
1651
- // is sufficient for ordering. The inner epoch-ms timestamp is redundant.
1652
- if ("timestamp" in message) {
1653
- const { timestamp, ...restMessage } = message;
1654
- message = restMessage;
1655
- changed = true;
1656
- subPasses.add("stripTimestamps");
1657
- }
1658
- // Strip API metadata fields that children don't need (~5-7 KB per assistant message).
1659
- // IMPORTANT: keep `usage.totalTokens` ONLY. The child `pi` process replays
1660
- // this JSONL and core/session code reads `message.usage.totalTokens`; stripping
1661
- // `usage` causes: Cannot read properties of undefined (reading 'totalTokens').
1662
- // Other fields (input, output, cacheRead, cacheWrite) are consumed only from
1663
- // live child stdout events (runner-events.ts), never from fork snapshot replay.
1664
- if (message.role === "assistant") {
1665
- const { api, provider, model, stopReason, responseId, responseModel, usage, ...rest } = message;
1666
- let stripped = false;
1667
- if (api !== undefined || provider !== undefined || model !== undefined ||
1668
- stopReason !== undefined || responseId !== undefined || responseModel !== undefined) {
1669
- stripped = true;
1670
- }
1671
- // Compress usage to totalTokens only — child pi replay requires totalTokens.
1672
- // Other fields (input, output, cacheRead, cacheWrite) are consumed only from
1673
- // live child stdout events (runner-events.ts), never from fork snapshot replay.
1674
- let cleanedUsage;
1675
- if (usage && typeof usage === "object") {
1676
- const ttl = usage.totalTokens;
1677
- if (typeof ttl === "number") {
1678
- cleanedUsage = { totalTokens: ttl };
1679
- stripped = true;
1680
- }
1681
- }
1682
- if (stripped) {
1683
- message = { ...rest, ...(cleanedUsage !== undefined ? { usage: cleanedUsage } : {}) };
1684
- changed = true;
1685
- subPasses.add("stripApiMetadata");
1686
- }
1687
- }
1688
- // Collapse empty/low-signal assistant messages to a minimal continuation marker.
1689
- if (message.role === "assistant" && isEmptyAssistantMessage(message)) {
1690
- const totalTokens = message.usage?.totalTokens;
1691
- const { usage: _usage, ...rest } = message;
1692
- message = {
1693
- ...rest,
1694
- ...(totalTokens !== undefined ? { usage: { totalTokens } } : {}),
1695
- content: totalTokens !== undefined
1696
- ? `[assistant: ${totalTokens} tokens, no action]`
1697
- : "[assistant:continuation]",
1698
- };
1699
- changed = true;
1700
- subPasses.add("collapseEmptyAssistantMessages");
1701
- }
1702
- // Strip `details` from tool/toolResult messages — carries FlowDetails UI metadata
1703
- // (mode, flowStyle, projectAgentsDir, results) that children never need.
1704
- if (message.role === "tool" || message.role === "toolResult") {
1705
- if ("details" in message) {
1706
- const { details, ...restMessage } = message;
1707
- message = restMessage;
1708
- changed = true;
1709
- subPasses.add("stripDetails");
1710
- }
1711
- }
1712
- if ("content" in message) {
1713
- let modifiedContent = message.content;
1714
- // Strip sliding prompts
1715
- const afterSliding = stripSteeringHintFromContent(modifiedContent);
1716
- if (!isJsonEqual(afterSliding, modifiedContent)) {
1717
- modifiedContent = afterSliding;
1718
- changed = true;
1719
- subPasses.add("stripSteeringHints");
1720
- }
1721
- // Strip strategic hints from all messages
1722
- const afterHints = stripStrategicHintsFromContent(modifiedContent);
1723
- if (!isJsonEqual(afterHints, modifiedContent)) {
1724
- modifiedContent = afterHints;
1725
- changed = true;
1726
- subPasses.add("stripStrategicHints");
1727
- }
1728
- // Compress parent activation prompts in nested snapshot JSONL
1729
- // (detect user messages containing <context-seal> at depth >= 2).
1730
- if (message.role === "user" && options?.depth !== undefined && options.depth >= 2) {
1731
- let hasParentActivation = false;
1732
- let previewText = "";
1733
- const parentActivationRegex = /<context-seal>[\s\S]*?<\/context-seal>/;
1734
- let fullText = "";
1735
- if (typeof modifiedContent === "string") {
1736
- fullText = modifiedContent;
1737
- }
1738
- else if (Array.isArray(modifiedContent)) {
1739
- fullText = modifiedContent
1740
- .filter((p) => p.type === "text" && typeof p.text === "string")
1741
- .map((p) => p.text)
1742
- .join("");
1743
- }
1744
- if (parentActivationRegex.test(fullText)) {
1745
- hasParentActivation = true;
1746
- // Extract mission content for preview; fall back to content after </context-seal>
1747
- const missionMatch = fullText.match(/<mission>([\s\S]*?)<\/mission>/);
1748
- if (missionMatch) {
1749
- previewText = missionMatch[1].trim().replace(/\s+/g, " ").slice(0, 200).trim();
1750
- }
1751
- else {
1752
- const afterSeal = fullText.split(/<\/context-seal>/).pop() ?? fullText;
1753
- previewText = afterSeal.trim().slice(0, 200).trim();
1754
- }
1755
- }
1756
- if (hasParentActivation) {
1757
- const compact = `[Parent flow activation stripped] Mission preview: ${previewText}`;
1758
- if (typeof modifiedContent === "string") {
1759
- modifiedContent = compact;
1760
- }
1761
- else {
1762
- modifiedContent = [{ type: "text", text: compact }];
1763
- }
1764
- changed = true;
1765
- subPasses.add("compressParentActivation");
1766
- }
1767
- }
1768
- if (changed) {
1769
- message = { ...message, content: modifiedContent };
1770
- }
1771
- }
1772
- if (changed) {
1773
- entry = { ...entry, message };
1774
- }
1775
- }
1776
- const outLine = changed ? JSON.stringify(entry) : line;
1777
- sanitizedLines.push(outLine);
1778
- }
1779
- const passesApplied = [];
1780
- const passDeltas = {};
1781
- const measureBytes = (s) => new TextEncoder().encode(s).length;
1782
- let sanitized = `${sanitizedLines.join("\n")}\n`;
1783
- passesApplied.push(...subPasses);
1784
- passDeltas["mainLoop"] = measureBytes(sanitized);
1785
- // Reparent orphaned parentIds after steering-hint messages were dropped.
1786
- sanitized = reparentOrphans(sanitized);
1787
- passesApplied.push("reparentOrphans");
1788
- passDeltas["reparentOrphans1"] = measureBytes(sanitized);
1789
- // Strip batch_read tool calls from assistant messages.
1790
- // Children don't have batch_read in their active tools.
1791
- sanitized = stripBatchReadToolCalls(sanitized);
1792
- passesApplied.push("stripBatchRead");
1793
- passDeltas["stripBatchRead"] = measureBytes(sanitized);
1794
- // Compress verbose flow tool call arguments in assistant messages.
1795
- sanitized = compressFlowToolCallArgs(sanitized);
1796
- passesApplied.push("compressFlowToolCallArgs");
1797
- passDeltas["compressFlowToolCallArgs"] = measureBytes(sanitized);
1798
- // Compress tool results (flow, batch, web, ask_user).
1799
- sanitized = compressToolResults(sanitized, cache, depthToPolicy(options?.depth ?? 1));
1800
- passesApplied.push("compressToolResults");
1801
- passDeltas["compressToolResults"] = measureBytes(sanitized);
1802
- // Reparent again after stripBatchRead and compressToolResults may have
1803
- // dropped additional messages, leaving new orphaned parentIds.
1804
- sanitized = reparentOrphans(sanitized);
1805
- passesApplied.push("reparentOrphans");
1806
- passDeltas["reparentOrphans2"] = measureBytes(sanitized);
1807
- // Telemetry: measure total delta across sanitization, stripping, and compression.
1808
- const postBytes = sanitized.length;
1809
- const reduction = preBytes > 0 ? Math.round((1 - postBytes / preBytes) * 1000) / 10 : 0;
1810
- if (DEBUG_CONTEXT) {
1811
- logError(`[context-snapshot] pre: ${preBytes} → post: ${postBytes} bytes (${reduction}% reduction)`);
1812
- }
1813
- // Stats are returned out-of-band for dump consumers only.
1814
- // Do NOT append to child-visible JSONL — it's telemetry noise for the model.
1815
- const stats = {
1816
- preBytes,
1817
- postBytes,
1818
- reductionPercent: reduction,
1819
- passesApplied,
1820
- passDeltas,
1821
- };
1822
- return { result: sanitized, passesApplied, stats };
1823
- }
1824
- //# sourceMappingURL=snapshot.js.map