pi-agent-flow 2.2.17 → 2.2.19

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 (95) hide show
  1. package/agents/audit.md +1 -0
  2. package/agents/build.md +1 -0
  3. package/agents/craft.md +1 -0
  4. package/agents/debug.md +1 -0
  5. package/agents/ideas.md +1 -0
  6. package/agents/scout.md +1 -0
  7. package/agents/trace.md +1 -0
  8. package/dist/batch/batch-bash.d.ts.map +1 -1
  9. package/dist/batch/batch-bash.js +1 -4
  10. package/dist/batch/batch-bash.js.map +1 -1
  11. package/dist/batch/execute.js +2 -2
  12. package/dist/batch/execute.js.map +1 -1
  13. package/dist/batch/index.js +5 -5
  14. package/dist/batch/index.js.map +1 -1
  15. package/dist/config/config.d.ts +2 -2
  16. package/dist/config/config.d.ts.map +1 -1
  17. package/dist/config/config.js +5 -3
  18. package/dist/config/config.js.map +1 -1
  19. package/dist/config/settings-resolver.d.ts +2 -1
  20. package/dist/config/settings-resolver.d.ts.map +1 -1
  21. package/dist/config/settings-resolver.js +6 -3
  22. package/dist/config/settings-resolver.js.map +1 -1
  23. package/dist/core2/snapshot.d.ts +25 -1
  24. package/dist/core2/snapshot.d.ts.map +1 -1
  25. package/dist/core2/snapshot.js +655 -23
  26. package/dist/core2/snapshot.js.map +1 -1
  27. package/dist/flow/agents.d.ts +7 -0
  28. package/dist/flow/agents.d.ts.map +1 -1
  29. package/dist/flow/agents.js +54 -1
  30. package/dist/flow/agents.js.map +1 -1
  31. package/dist/flow/execute-single.d.ts.map +1 -1
  32. package/dist/flow/execute-single.js +1 -0
  33. package/dist/flow/execute-single.js.map +1 -1
  34. package/dist/flow/executor.d.ts +1 -0
  35. package/dist/flow/executor.d.ts.map +1 -1
  36. package/dist/flow/executor.js.map +1 -1
  37. package/dist/flow/runner.d.ts +2 -0
  38. package/dist/flow/runner.d.ts.map +1 -1
  39. package/dist/flow/runner.js +1 -0
  40. package/dist/flow/runner.js.map +1 -1
  41. package/dist/flow/settings-command.d.ts +1 -1
  42. package/dist/flow/settings-command.d.ts.map +1 -1
  43. package/dist/flow/settings-command.js +7 -6
  44. package/dist/flow/settings-command.js.map +1 -1
  45. package/dist/flow/settings-handler.d.ts.map +1 -1
  46. package/dist/flow/settings-handler.js +21 -19
  47. package/dist/flow/settings-handler.js.map +1 -1
  48. package/dist/flow/settings-items.d.ts +2 -1
  49. package/dist/flow/settings-items.d.ts.map +1 -1
  50. package/dist/flow/settings-items.js +18 -8
  51. package/dist/flow/settings-items.js.map +1 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +45 -38
  54. package/dist/index.js.map +1 -1
  55. package/dist/snapshot/cli-args.d.ts.map +1 -1
  56. package/dist/snapshot/cli-args.js +0 -1
  57. package/dist/snapshot/cli-args.js.map +1 -1
  58. package/dist/steering/tool-utils.d.ts +2 -20
  59. package/dist/steering/tool-utils.d.ts.map +1 -1
  60. package/dist/steering/tool-utils.js +2 -48
  61. package/dist/steering/tool-utils.js.map +1 -1
  62. package/dist/tools/ask-user.d.ts.map +1 -1
  63. package/dist/tools/ask-user.js +1 -4
  64. package/dist/tools/ask-user.js.map +1 -1
  65. package/dist/tools/loop-guard.d.ts +11 -0
  66. package/dist/tools/loop-guard.d.ts.map +1 -0
  67. package/dist/tools/loop-guard.js +40 -0
  68. package/dist/tools/loop-guard.js.map +1 -0
  69. package/dist/tools/timed-bash.d.ts.map +1 -1
  70. package/dist/tools/timed-bash.js +5 -3
  71. package/dist/tools/timed-bash.js.map +1 -1
  72. package/dist/tools/trace.d.ts +1 -0
  73. package/dist/tools/trace.d.ts.map +1 -1
  74. package/dist/tools/trace.js +12 -4
  75. package/dist/tools/trace.js.map +1 -1
  76. package/dist/tui/body-render.js +3 -3
  77. package/dist/tui/body-render.js.map +1 -1
  78. package/dist/tui/context-display.d.ts +2 -0
  79. package/dist/tui/context-display.d.ts.map +1 -1
  80. package/dist/tui/context-display.js +11 -0
  81. package/dist/tui/context-display.js.map +1 -1
  82. package/dist/tui/flow-colors.js +1 -1
  83. package/dist/tui/flow-colors.js.map +1 -1
  84. package/dist/tui/render-utils.d.ts +1 -0
  85. package/dist/tui/render-utils.d.ts.map +1 -1
  86. package/dist/tui/render-utils.js +13 -3
  87. package/dist/tui/render-utils.js.map +1 -1
  88. package/dist/tui/render.d.ts +1 -1
  89. package/dist/tui/render.d.ts.map +1 -1
  90. package/dist/tui/render.js +9 -64
  91. package/dist/tui/render.js.map +1 -1
  92. package/dist/types/flow.d.ts +3 -2
  93. package/dist/types/flow.d.ts.map +1 -1
  94. package/dist/types/flow.js.map +1 -1
  95. package/package.json +1 -1
@@ -7,6 +7,102 @@
7
7
  */
8
8
  import { stripDirectives } from "../steering/tool-utils.js";
9
9
  import { logWarn } from "../config/log.js";
10
+ /**
11
+ * Build a snapshot with optional context compression. If the estimated token
12
+ * count exceeds the threshold (or an override is set), a second pass applies
13
+ * the resolved compression level and profile.
14
+ */
15
+ export function buildSnapshotWithCompression(sessionManager, options, maxContextTokens) {
16
+ const rawSnapshot = buildCore2Snapshot(sessionManager, options);
17
+ if (!rawSnapshot)
18
+ return { snapshot: null };
19
+ const estimatedTokens = estimateTotalContextTokens(rawSnapshot);
20
+ const thresholdEnv = process.env.PI_FLOW_CONTEXT_THRESHOLD;
21
+ const threshold = thresholdEnv ? Number(thresholdEnv) : 70_000;
22
+ const overrideRaw = process.env.PI_FLOW_CONTEXT_COMPRESSION?.trim().toLowerCase();
23
+ const envOverride = overrideRaw === "none" || overrideRaw === "light" || overrideRaw === "medium" || overrideRaw === "aggressive"
24
+ ? overrideRaw
25
+ : undefined;
26
+ const override = options?.compressionLevel ?? envOverride;
27
+ const effectiveThreshold = maxContextTokens && maxContextTokens > 0 && Number.isFinite(maxContextTokens)
28
+ ? Math.min(threshold, Math.floor(maxContextTokens * 0.6))
29
+ : threshold;
30
+ const lightThreshold = effectiveThreshold;
31
+ const mediumThreshold = Math.floor(effectiveThreshold * 1.21);
32
+ const aggressiveThreshold = Math.floor(effectiveThreshold * 1.43);
33
+ let level;
34
+ if (override) {
35
+ level = override;
36
+ }
37
+ else if (estimatedTokens < lightThreshold) {
38
+ return { snapshot: rawSnapshot };
39
+ }
40
+ else if (estimatedTokens < mediumThreshold) {
41
+ level = "light";
42
+ }
43
+ else if (estimatedTokens < aggressiveThreshold) {
44
+ level = "medium";
45
+ }
46
+ else {
47
+ level = "aggressive";
48
+ }
49
+ if (level === "none")
50
+ return { snapshot: rawSnapshot };
51
+ const stats = {
52
+ preBytes: rawSnapshot.length,
53
+ postBytes: 0,
54
+ level,
55
+ messagesDropped: 0,
56
+ };
57
+ const compressedSnapshot = buildCore2Snapshot(sessionManager, {
58
+ ...options,
59
+ compressionLevel: level,
60
+ compressionProfile: options?.compressionProfile,
61
+ compressionStats: stats,
62
+ });
63
+ if (stats && compressedSnapshot) {
64
+ stats.preBytes = rawSnapshot.length;
65
+ stats.postBytes = compressedSnapshot.length;
66
+ }
67
+ // Emergency warp fallback: if aggressive compression is still too large,
68
+ // and PI_FLOW_EMERGENCY_WARP is enabled, distill to a minimal snapshot.
69
+ if (level === "aggressive" &&
70
+ compressedSnapshot &&
71
+ estimateTotalContextTokens(compressedSnapshot) > (maxContextTokens ?? 100_000) * 0.6 &&
72
+ (process.env.PI_FLOW_EMERGENCY_WARP === "1" || process.env.PI_FLOW_EMERGENCY_WARP === "true")) {
73
+ const allEntries = compressedSnapshot
74
+ .split("\n")
75
+ .filter((l) => l.length > 0)
76
+ .map((l) => {
77
+ try {
78
+ return JSON.parse(l);
79
+ }
80
+ catch (e) {
81
+ logWarn(`[pi-agent-flow] Failed to parse JSONL line in emergency warp: ${e}`);
82
+ return null;
83
+ }
84
+ })
85
+ .filter((e) => e !== null);
86
+ const summary = generateSyntheticSummary(allEntries);
87
+ const lastMessages = allEntries.slice(-2);
88
+ const distilled = [
89
+ allEntries[0],
90
+ CONTEXT_MAP_ENTRY,
91
+ {
92
+ type: "message",
93
+ message: {
94
+ role: "system",
95
+ content: `[Emergency Warp] Context exceeds safe limits after aggressive compression. Distilled summary:\n${summary ?? "(no summary generated)"}`,
96
+ },
97
+ },
98
+ ...lastMessages,
99
+ ];
100
+ const emergencySnapshot = distilled.map((e) => JSON.stringify(e)).join("\n") + "\n";
101
+ stats.postBytes = emergencySnapshot.length;
102
+ return { snapshot: emergencySnapshot, stats };
103
+ }
104
+ return { snapshot: compressedSnapshot, stats };
105
+ }
10
106
  /** Normalize toolCalls field from various input shapes. */
11
107
  function normalizeToolCalls(msg) {
12
108
  if (!msg || typeof msg !== "object")
@@ -26,6 +122,89 @@ function normalizeToolCallId(tc) {
26
122
  const t = tc;
27
123
  return t.id || t.toolCallId || t[SNAKE_TOOL_CALL_ID];
28
124
  }
125
+ /** Rough token estimate for a snapshot string (1 token ≈ 4 chars). */
126
+ export function estimateTotalContextTokens(snapshotJsonl) {
127
+ return Math.ceil(snapshotJsonl.length / 4);
128
+ }
129
+ /** Extract plain text from a message entry for scoring / classification. */
130
+ function extractMessageText(msg) {
131
+ if (typeof msg.content === "string") {
132
+ return msg.content;
133
+ }
134
+ if (Array.isArray(msg.content)) {
135
+ return msg.content
136
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
137
+ .map((b) => b.text)
138
+ .join("\n");
139
+ }
140
+ return "";
141
+ }
142
+ /** Classify a tool/toolResult message into a category for profile-aware compression. */
143
+ function classifyToolResult(entry) {
144
+ if (!entry || typeof entry !== "object")
145
+ return "other";
146
+ const e = entry;
147
+ if (e.type !== "message" || !e.message || typeof e.message !== "object")
148
+ return "other";
149
+ const msg = e.message;
150
+ if (msg.role !== "tool" && msg.role !== "toolResult")
151
+ return "other";
152
+ const text = extractMessageText(msg);
153
+ const toolName = typeof msg.toolName === "string" ? msg.toolName : typeof msg.name === "string" ? msg.name : "";
154
+ // Error / stack-trace heuristics
155
+ if (text.includes("Error:") ||
156
+ text.includes("error:") ||
157
+ text.includes("FAIL") ||
158
+ text.includes("failed") ||
159
+ text.includes("Exception") ||
160
+ text.includes("exception") ||
161
+ text.includes("AssertionError") ||
162
+ text.includes("TypeError") ||
163
+ text.includes("ReferenceError")) {
164
+ // Stack trace detection: lines starting with " at " or " at "
165
+ const stackPattern = /^\s+at\s+\S+/gm;
166
+ if (stackPattern.test(text))
167
+ return "stackTrace";
168
+ return "error";
169
+ }
170
+ // Test failure
171
+ if (text.includes("Test failed") ||
172
+ text.includes("test failure") ||
173
+ text.includes("Assertion failed") ||
174
+ text.includes("expected") && text.includes("received") ||
175
+ text.includes("✕") ||
176
+ text.includes("FAIL") && text.includes("test")) {
177
+ return "testFailure";
178
+ }
179
+ // Git diff
180
+ if (text.includes("diff --git") ||
181
+ text.includes("--- a/") ||
182
+ text.includes("+++ b/") ||
183
+ text.includes("@@ -")) {
184
+ return "gitDiff";
185
+ }
186
+ // Batch file operations (read / write / edit)
187
+ if (text.includes("--- read:") ||
188
+ text.includes("--- write:") ||
189
+ text.includes("--- edit:") ||
190
+ text.includes("--- delete:") ||
191
+ (text.includes("✔") && (text.includes("read") || text.includes("write") || text.includes("edit")))) {
192
+ return "fileContent";
193
+ }
194
+ // Grep / find / ls results
195
+ if (text.includes("--- rg:") ||
196
+ text.includes("--- find:") ||
197
+ text.includes("--- ls:") ||
198
+ text.includes("grep results") ||
199
+ /^[^\n]+:\d+:.+/m.test(text)) {
200
+ return "grepResult";
201
+ }
202
+ // Bash success
203
+ if (toolName === "bash" || text.includes("--- bash [") || text.includes("--- [") && text.includes("exit")) {
204
+ return "bashSuccess";
205
+ }
206
+ return "other";
207
+ }
29
208
  /** Synthetic system message providing the context architecture map. */
30
209
  const CONTEXT_MAP_ENTRY = {
31
210
  type: "message",
@@ -102,8 +281,8 @@ export function buildCore2Snapshot(sessionManager, options) {
102
281
  processedEntry = maybeStripCompaction(processedEntry);
103
282
  if (!processedEntry)
104
283
  continue;
105
- processedEntry = compressSnapshotEntry(processedEntry, options?.tier);
106
- processedEntry = truncateStandaloneBashResult(processedEntry);
284
+ processedEntry = compressSnapshotEntry(processedEntry, options?.tier, options?.compressionLevel, options?.compressionProfile);
285
+ processedEntry = truncateStandaloneBashResult(processedEntry, options?.compressionLevel, options?.compressionProfile);
107
286
  if (options?.activeToolCallId) {
108
287
  processedEntry = stripActiveToolCall(processedEntry, options.activeToolCallId);
109
288
  if (!processedEntry)
@@ -112,14 +291,26 @@ export function buildCore2Snapshot(sessionManager, options) {
112
291
  processedEntries.push(processedEntry);
113
292
  }
114
293
  const finalEntries = applyMessageLimit(processedEntries, options?.tier);
115
- const entriesWithMap = insertContextMap(finalEntries);
294
+ const compressedResult = applyContextCompression(finalEntries, options?.compressionLevel, options?.compressionProfile);
295
+ const entriesWithMap = insertContextMap(compressedResult.entries);
116
296
  for (const entry of entriesWithMap) {
117
297
  const line = JSON.stringify(entry);
118
298
  // Strip batch read/write/edit bodies from tool result messages
119
- const processed = maybeStripBatchBodies(line);
299
+ const processed = maybeStripBatchBodies(line, options?.compressionLevel);
120
300
  lines.push(processed);
121
301
  }
122
- return `${lines.join("\n")}\n`;
302
+ const snapshot = `${lines.join("\n")}\n`;
303
+ if (options?.compressionStats) {
304
+ const preBytes = processedEntries.reduce((sum, e) => sum + JSON.stringify(e).length, 0);
305
+ const postBytes = snapshot.length;
306
+ options.compressionStats.preBytes = preBytes;
307
+ options.compressionStats.postBytes = postBytes;
308
+ options.compressionStats.level = options.compressionLevel ?? "none";
309
+ options.compressionStats.profileName = options.compressionProfile?.name;
310
+ options.compressionStats.messagesDropped = compressedResult.droppedCount;
311
+ options.compressionStats.syntheticSummary = compressedResult.syntheticSummary;
312
+ }
313
+ return snapshot;
123
314
  }
124
315
  /**
125
316
  * Keep only the usage fields pi-coding-agent needs for context accounting.
@@ -355,7 +546,7 @@ function optimizeSharedContext(branchEntries) {
355
546
  // ---------------------------------------------------------------------------
356
547
  // Standalone bash result truncation
357
548
  // ---------------------------------------------------------------------------
358
- function truncateStandaloneBashResult(entry) {
549
+ function truncateStandaloneBashResult(entry, level, profile) {
359
550
  if (!entry || typeof entry !== "object")
360
551
  return entry;
361
552
  const e = entry;
@@ -367,6 +558,9 @@ function truncateStandaloneBashResult(entry) {
367
558
  const toolName = typeof msg.toolName === "string" ? msg.toolName : typeof msg.name === "string" ? msg.name : undefined;
368
559
  if (toolName !== "bash")
369
560
  return entry;
561
+ // Respect profile: if bashSuccess is in keepCategories, skip truncation
562
+ if (profile?.keepCategories?.includes("bashSuccess"))
563
+ return entry;
370
564
  let text;
371
565
  let textIndex;
372
566
  if (typeof msg.content === "string") {
@@ -385,8 +579,24 @@ function truncateStandaloneBashResult(entry) {
385
579
  if (!text)
386
580
  return entry;
387
581
  const lines = text.split("\n");
388
- const head = 30;
389
- const tail = 20;
582
+ let head;
583
+ let tail;
584
+ if (level === "light") {
585
+ head = 15;
586
+ tail = 10;
587
+ }
588
+ else if (level === "medium") {
589
+ head = 10;
590
+ tail = 5;
591
+ }
592
+ else if (level === "aggressive") {
593
+ head = 5;
594
+ tail = 3;
595
+ }
596
+ else {
597
+ head = 30;
598
+ tail = 20;
599
+ }
390
600
  if (lines.length <= head + tail)
391
601
  return entry;
392
602
  const kept = [...lines.slice(0, head), `[...${lines.length - head - tail} lines of bash output truncated...]`, ...lines.slice(-tail)];
@@ -433,13 +643,16 @@ function getTierMaxMessages(tier) {
433
643
  return Number.isFinite(n) && n > 0 ? n : defaultVal;
434
644
  }
435
645
  /**
436
- * Compress a single snapshot entry based on tier.
646
+ * Compress a single snapshot entry based on tier and optional compression level.
647
+ *
648
+ * When no compression level is set, all tiers strip tool/toolResult content to
649
+ * placeholders (e.g. [toolResult: bash]) — the legacy behavior.
437
650
  *
438
- * All tiers strip tool/toolResult content to placeholders (e.g. [toolResult: bash])
439
- * so the child flow sees what tools were used without receiving full verbatim
440
- * output, keeping the snapshot compact and focused on conversation history.
651
+ * When a compression level is active, the profile determines which categories
652
+ * are essential (kept verbatim) vs non-essential (compressed to placeholder).
653
+ * Aggressive always compresses everything regardless of profile.
441
654
  */
442
- function compressSnapshotEntry(entry, tier) {
655
+ function compressSnapshotEntry(entry, tier, level, profile) {
443
656
  if (!tier)
444
657
  return entry;
445
658
  if (!entry || typeof entry !== "object")
@@ -453,6 +666,29 @@ function compressSnapshotEntry(entry, tier) {
453
666
  if (role !== "tool" && role !== "toolResult") {
454
667
  return entry;
455
668
  }
669
+ // Legacy behavior when no compression level is active
670
+ if (!level || level === "none") {
671
+ const toolName = typeof msg.toolName === "string" ? msg.toolName : typeof msg.name === "string" ? msg.name : undefined;
672
+ const placeholder = toolName ? `[${role}: ${toolName}]` : `[${role} result omitted]`;
673
+ msg.content = [{ type: "text", text: placeholder }];
674
+ return { ...e, message: msg };
675
+ }
676
+ // Aggressive compresses all tool results regardless of profile
677
+ if (level === "aggressive") {
678
+ const toolName = typeof msg.toolName === "string" ? msg.toolName : typeof msg.name === "string" ? msg.name : undefined;
679
+ const placeholder = toolName ? `[${role}: ${toolName}]` : `[${role} result omitted]`;
680
+ msg.content = [{ type: "text", text: placeholder }];
681
+ return { ...e, message: msg };
682
+ }
683
+ // Profile-aware selective compression for light / medium
684
+ const category = classifyToolResult(entry);
685
+ const keep = profile?.keepCategories ?? [];
686
+ const compress = profile?.compressCategories ?? [];
687
+ if (keep.includes(category)) {
688
+ // Keep this tool result verbatim
689
+ return entry;
690
+ }
691
+ // Compress to placeholder (default for unknown categories too)
456
692
  const toolName = typeof msg.toolName === "string" ? msg.toolName : typeof msg.name === "string" ? msg.name : undefined;
457
693
  const placeholder = toolName ? `[${role}: ${toolName}]` : `[${role} result omitted]`;
458
694
  msg.content = [{ type: "text", text: placeholder }];
@@ -510,6 +746,368 @@ function applyMessageLimit(entries, tier) {
510
746
  return entries;
511
747
  }
512
748
  // ---------------------------------------------------------------------------
749
+ // Context compression (token-gate progressive reduction)
750
+ // ---------------------------------------------------------------------------
751
+ function scoreMessageForProfile(entry, profile) {
752
+ if (!entry || typeof entry !== "object")
753
+ return 1;
754
+ const e = entry;
755
+ if (e.type !== "message" || !e.message || typeof e.message !== "object")
756
+ return 1;
757
+ const msg = e.message;
758
+ // Base score: newer messages win ties via caller sorting
759
+ let score = 10;
760
+ if (profile?.name === "intent-first") {
761
+ if (msg.role === "user")
762
+ return 100;
763
+ if (msg.role === "assistant") {
764
+ const text = extractMessageText(msg);
765
+ if (/\b(decision|design|plan|architecture|goal|objective)\b/i.test(text))
766
+ return 90;
767
+ return 50;
768
+ }
769
+ return 5;
770
+ }
771
+ if (profile?.name === "files-first") {
772
+ if (msg.role === "toolResult" || msg.role === "tool") {
773
+ const cat = classifyToolResult(entry);
774
+ if (cat === "fileContent")
775
+ return 90;
776
+ if (cat === "error")
777
+ return 80;
778
+ return 20;
779
+ }
780
+ if (msg.role === "assistant") {
781
+ const content = msg.content;
782
+ if (Array.isArray(content)) {
783
+ const hasFileOps = content.some((block) => {
784
+ if (block?.type === "toolCall") {
785
+ const name = block.name || block.toolCall?.name;
786
+ return name === "batch" || name === "batch_read";
787
+ }
788
+ return false;
789
+ });
790
+ if (hasFileOps)
791
+ return 85;
792
+ }
793
+ }
794
+ return 40;
795
+ }
796
+ if (profile?.name === "errors-first") {
797
+ if (msg.role === "toolResult" || msg.role === "tool") {
798
+ const cat = classifyToolResult(entry);
799
+ if (cat === "error" || cat === "stackTrace" || cat === "testFailure")
800
+ return 100;
801
+ return 15;
802
+ }
803
+ return 40;
804
+ }
805
+ if (profile?.name === "edits-first") {
806
+ if (msg.role === "toolResult" || msg.role === "tool") {
807
+ const cat = classifyToolResult(entry);
808
+ if (cat === "fileContent" || cat === "gitDiff")
809
+ return 95;
810
+ if (cat === "error")
811
+ return 80;
812
+ return 20;
813
+ }
814
+ return 40;
815
+ }
816
+ if (profile?.name === "discovery-first") {
817
+ if (msg.role === "toolResult" || msg.role === "tool") {
818
+ const cat = classifyToolResult(entry);
819
+ if (cat === "grepResult")
820
+ return 95;
821
+ if (cat === "fileContent")
822
+ return 80;
823
+ if (cat === "error")
824
+ return 70;
825
+ return 20;
826
+ }
827
+ return 40;
828
+ }
829
+ if (profile?.name === "code-first") {
830
+ if (msg.role === "toolResult" || msg.role === "tool") {
831
+ const cat = classifyToolResult(entry);
832
+ if (cat === "fileContent")
833
+ return 100;
834
+ if (cat === "error")
835
+ return 80;
836
+ return 15;
837
+ }
838
+ return 40;
839
+ }
840
+ // Default scoring
841
+ if (msg.role === "user")
842
+ return 60;
843
+ if (msg.role === "assistant")
844
+ return 50;
845
+ if (msg.role === "toolResult" || msg.role === "tool") {
846
+ const cat = classifyToolResult(entry);
847
+ if (cat === "error")
848
+ return 45;
849
+ return 30;
850
+ }
851
+ return 20;
852
+ }
853
+ function stripOldSystemMessages(entries, _profile) {
854
+ return entries.filter((entry) => {
855
+ if (!entry || typeof entry !== "object")
856
+ return true;
857
+ const e = entry;
858
+ if (e.type !== "message" || !e.message || typeof e.message !== "object")
859
+ return true;
860
+ const msg = e.message;
861
+ if (msg.role !== "system")
862
+ return true;
863
+ // Keep the context map entry (it hasn't been inserted yet here, but be defensive)
864
+ const text = extractMessageText(msg);
865
+ if (text.includes("[SHARED CONTEXT]") || text.includes("<context-seal>"))
866
+ return true;
867
+ // Drop old system messages
868
+ return false;
869
+ });
870
+ }
871
+ function generateSyntheticSummary(droppedEntries) {
872
+ if (droppedEntries.length === 0)
873
+ return undefined;
874
+ const files = new Set();
875
+ const commands = new Set();
876
+ const errors = new Set();
877
+ const decisions = new Set();
878
+ for (const entry of droppedEntries) {
879
+ if (!entry || typeof entry !== "object")
880
+ continue;
881
+ const e = entry;
882
+ if (e.type !== "message" || !e.message || typeof e.message !== "object")
883
+ continue;
884
+ const msg = e.message;
885
+ const text = extractMessageText(msg);
886
+ // Extract file paths from read/write/edit headers
887
+ const fileMatches = text.match(/--- (?:read|write|edit|delete): ([^\s()]+)/g);
888
+ if (fileMatches) {
889
+ for (const m of fileMatches) {
890
+ const path = m.replace(/^--- (?:read|write|edit|delete): /, "").trim();
891
+ if (path)
892
+ files.add(path);
893
+ }
894
+ }
895
+ // Extract bash commands — permissive header matching + fallback scan
896
+ let foundBash = false;
897
+ const bashHeaderPattern = /--- bash(?:\s*\[.*?\])?\s*(?:exit\s*\d+|pending|error)?\s*---\s*([\s\S]*?)(?=\n---|\n###\s|$)/g;
898
+ let bashMatch;
899
+ while ((bashMatch = bashHeaderPattern.exec(text)) !== null) {
900
+ const block = bashMatch[1].trim();
901
+ const firstLine = block.split("\n").find((l) => l.trim().length > 0);
902
+ if (firstLine) {
903
+ commands.add(firstLine.trim().slice(0, 120));
904
+ foundBash = true;
905
+ }
906
+ }
907
+ if (!foundBash) {
908
+ // Fallback: scan for bash-like lines or JSONL tool call blocks
909
+ const fallbackBash = [];
910
+ const bashLikeRe = /\bbash\b.*?:\s*(.+)/gim;
911
+ let m;
912
+ while ((m = bashLikeRe.exec(text)) !== null) {
913
+ fallbackBash.push(m[1].trim());
914
+ }
915
+ const jsonCmdRe = /"command"\s*:\s*"([^"]+)"/g;
916
+ while ((m = jsonCmdRe.exec(text)) !== null) {
917
+ fallbackBash.push(m[1].trim());
918
+ }
919
+ if (fallbackBash.length > 0) {
920
+ for (const cmd of fallbackBash) {
921
+ if (cmd)
922
+ commands.add(cmd.slice(0, 120));
923
+ }
924
+ }
925
+ }
926
+ // Also extract from tool call arguments if present
927
+ const tcs = (msg.toolCalls ?? msg.tool_calls ?? []);
928
+ for (const tc of tcs) {
929
+ if (!tc || typeof tc !== "object")
930
+ continue;
931
+ const t = tc;
932
+ const args = t.arguments ?? t.function?.arguments;
933
+ if (args && typeof args === "object") {
934
+ const a = args;
935
+ if (typeof a.command === "string")
936
+ commands.add(a.command);
937
+ if (typeof a.c === "string")
938
+ commands.add(a.c);
939
+ }
940
+ }
941
+ if (Array.isArray(msg.content)) {
942
+ for (const block of msg.content) {
943
+ if (block && typeof block === "object" && block.type === "toolCall" && block.arguments) {
944
+ const args = block.arguments;
945
+ if (typeof args.command === "string")
946
+ commands.add(args.command);
947
+ if (typeof args.c === "string")
948
+ commands.add(args.c);
949
+ }
950
+ }
951
+ }
952
+ // Extract errors (first line only, capped)
953
+ const errorPattern = /\b(Error|FAIL|failed|Exception|AssertionError)\b/i;
954
+ if (errorPattern.test(text)) {
955
+ const firstErrorLine = text.split("\n").find((l) => errorPattern.test(l));
956
+ if (firstErrorLine) {
957
+ const truncated = firstErrorLine.trim().slice(0, 120);
958
+ if (truncated)
959
+ errors.add(truncated);
960
+ }
961
+ }
962
+ // Extract decisions with word boundaries
963
+ const decisionPattern = /\b(decision|design|plan|agreed|chose|selected|will use)\b/i;
964
+ if (decisionPattern.test(text)) {
965
+ const decisionLine = text.split("\n").find((l) => decisionPattern.test(l));
966
+ if (decisionLine) {
967
+ const truncated = decisionLine.trim().slice(0, 120);
968
+ if (truncated)
969
+ decisions.add(truncated);
970
+ }
971
+ }
972
+ }
973
+ // Fallback: if regex-based extraction found nothing, do a simple keyword scan
974
+ if (files.size === 0 && commands.size === 0 && errors.size === 0 && decisions.size === 0) {
975
+ for (const entry of droppedEntries) {
976
+ if (!entry || typeof entry !== "object")
977
+ continue;
978
+ const e = entry;
979
+ if (e.type !== "message" || !e.message || typeof e.message !== "object")
980
+ continue;
981
+ const msg = e.message;
982
+ const text = extractMessageText(msg);
983
+ const lines = text.split("\n");
984
+ for (const line of lines) {
985
+ const trimmed = line.trim();
986
+ if (!trimmed)
987
+ continue;
988
+ if (/\b(?:src\/|lib\/|tests\/|\.ts|\.js|\.json|\.md)\b/.test(trimmed)) {
989
+ const path = trimmed.split(/\s+/)[0];
990
+ if (path && path.includes("/"))
991
+ files.add(path.slice(0, 120));
992
+ }
993
+ if (/^(?:npm|yarn|pnpm|node|git|cargo|go|python|pytest|eslint|tsc|vitest|docker|kubectl|make|curl|wget)\b/.test(trimmed)) {
994
+ commands.add(trimmed.slice(0, 120));
995
+ }
996
+ if (/\b(error|fail|exception|panic|timeout)\b/i.test(trimmed)) {
997
+ errors.add(trimmed.slice(0, 120));
998
+ }
999
+ if (/\b(decided|decision|plan|design|agreed|selected|will|should|recommend)\b/i.test(trimmed)) {
1000
+ decisions.add(trimmed.slice(0, 120));
1001
+ }
1002
+ }
1003
+ }
1004
+ }
1005
+ if (files.size === 0 && commands.size === 0 && errors.size === 0 && decisions.size === 0) {
1006
+ return undefined;
1007
+ }
1008
+ const parts = ["[Context summary — earlier messages omitted]"];
1009
+ if (files.size > 0) {
1010
+ parts.push(`Files: ${Array.from(files).slice(0, 10).join(", ")}${files.size > 10 ? "…" : ""}`);
1011
+ }
1012
+ if (commands.size > 0) {
1013
+ parts.push(`Commands: ${Array.from(commands).slice(0, 5).join(", ")}${commands.size > 5 ? "…" : ""}`);
1014
+ }
1015
+ if (errors.size > 0) {
1016
+ parts.push(`Errors: ${Array.from(errors).slice(0, 3).join("; ")}${errors.size > 3 ? "…" : ""}`);
1017
+ }
1018
+ if (decisions.size > 0) {
1019
+ parts.push(`Decisions: ${Array.from(decisions).slice(0, 3).join("; ")}${decisions.size > 3 ? "…" : ""}`);
1020
+ }
1021
+ return parts.join("\n");
1022
+ }
1023
+ function applyContextCompression(entries, level, profile) {
1024
+ if (!level || level === "none") {
1025
+ return { entries, droppedCount: 0 };
1026
+ }
1027
+ // Count current messages
1028
+ const messageIndices = [];
1029
+ for (let i = 0; i < entries.length; i++) {
1030
+ const e = entries[i];
1031
+ if (e && typeof e === "object" && e.type === "message") {
1032
+ messageIndices.push(i);
1033
+ }
1034
+ }
1035
+ const currentMessageCount = messageIndices.length;
1036
+ let targetMessageCount;
1037
+ if (level === "light") {
1038
+ targetMessageCount = Math.max(5, Math.floor(currentMessageCount * 0.6));
1039
+ }
1040
+ else if (level === "medium") {
1041
+ targetMessageCount = Math.max(5, Math.floor(currentMessageCount * 0.4));
1042
+ }
1043
+ else {
1044
+ targetMessageCount = Math.max(3, 15);
1045
+ }
1046
+ if (currentMessageCount <= targetMessageCount) {
1047
+ let result = entries;
1048
+ if (level === "medium" || level === "aggressive") {
1049
+ result = stripOldSystemMessages(result, profile);
1050
+ }
1051
+ return { entries: result, droppedCount: 0 };
1052
+ }
1053
+ // Need to drop messages — prioritize based on profile score
1054
+ let headerEnd = 0;
1055
+ for (; headerEnd < entries.length; headerEnd++) {
1056
+ const e = entries[headerEnd];
1057
+ if (e && typeof e === "object") {
1058
+ const type = e.type;
1059
+ if (type === "session" || type === "header")
1060
+ continue;
1061
+ }
1062
+ break;
1063
+ }
1064
+ const headers = entries.slice(0, headerEnd);
1065
+ const rest = entries.slice(headerEnd);
1066
+ const scored = rest.map((entry, idx) => ({
1067
+ entry,
1068
+ originalIndex: headerEnd + idx,
1069
+ score: scoreMessageForProfile(entry, profile),
1070
+ isMessage: entry && typeof entry === "object" && entry.type === "message",
1071
+ }));
1072
+ // Separate messages from non-messages (e.g. compaction events, etc.)
1073
+ const messageScored = scored.filter((s) => s.isMessage);
1074
+ const nonMessageScored = scored.filter((s) => !s.isMessage);
1075
+ // Sort messages by score desc, then originalIndex desc (prefer newer)
1076
+ messageScored.sort((a, b) => {
1077
+ if (b.score !== a.score)
1078
+ return b.score - a.score;
1079
+ return b.originalIndex - a.originalIndex;
1080
+ });
1081
+ // Keep top N messages
1082
+ const keptMessages = messageScored.slice(0, targetMessageCount);
1083
+ const droppedMessages = messageScored.slice(targetMessageCount);
1084
+ // Combine kept messages with all non-messages, sort by original index
1085
+ const combined = [...nonMessageScored, ...keptMessages];
1086
+ combined.sort((a, b) => a.originalIndex - b.originalIndex);
1087
+ const droppedEntries = droppedMessages.map((d) => d.entry);
1088
+ const syntheticSummaryText = generateSyntheticSummary(droppedEntries);
1089
+ let result = [...headers, ...combined.map((c) => c.entry)];
1090
+ if (level === "medium" || level === "aggressive") {
1091
+ result = stripOldSystemMessages(result, profile);
1092
+ }
1093
+ if (syntheticSummaryText) {
1094
+ // Insert synthetic summary after headers but before other entries
1095
+ const syntheticEntry = {
1096
+ type: "message",
1097
+ message: {
1098
+ role: "system",
1099
+ content: [{ type: "text", text: syntheticSummaryText }],
1100
+ },
1101
+ };
1102
+ result.splice(headerEnd, 0, syntheticEntry);
1103
+ }
1104
+ return {
1105
+ entries: result,
1106
+ droppedCount: droppedMessages.length,
1107
+ syntheticSummary: syntheticSummaryText,
1108
+ };
1109
+ }
1110
+ // ---------------------------------------------------------------------------
513
1111
  // Batch body stripping
514
1112
  // ---------------------------------------------------------------------------
515
1113
  /** Headers that delimit the end of any batch tool section. */
@@ -542,8 +1140,8 @@ function isBatchSectionHeader(line) {
542
1140
  /^--- \[.+\] (exit (\d+)|interrupted) ---$/.test(line) ||
543
1141
  /^--- \[.+\] still running ---$/.test(line));
544
1142
  }
545
- /** Replace batch section bodies with first 3 + last 3 lines as orientation. */
546
- function stripBatchBodies(text) {
1143
+ /** Replace batch section bodies with first N + last N lines as orientation. */
1144
+ function stripBatchBodies(text, level) {
547
1145
  const lines = text.replace(/\r\n/g, "\n").split("\n");
548
1146
  const out = [];
549
1147
  let i = 0;
@@ -559,8 +1157,24 @@ function stripBatchBodies(text) {
559
1157
  }
560
1158
  const isBash = line.includes("--- bash [") || line.includes("--- [");
561
1159
  if (isBash) {
562
- const head = 30;
563
- const tail = 20;
1160
+ let head;
1161
+ let tail;
1162
+ if (level === "light") {
1163
+ head = 15;
1164
+ tail = 10;
1165
+ }
1166
+ else if (level === "medium") {
1167
+ head = 10;
1168
+ tail = 5;
1169
+ }
1170
+ else if (level === "aggressive") {
1171
+ head = 5;
1172
+ tail = 3;
1173
+ }
1174
+ else {
1175
+ head = 30;
1176
+ tail = 20;
1177
+ }
564
1178
  if (body.length > head + tail) {
565
1179
  out.push(...body.slice(0, head));
566
1180
  out.push(`[...${body.length - head - tail} lines of bash output truncated...]`);
@@ -571,10 +1185,28 @@ function stripBatchBodies(text) {
571
1185
  }
572
1186
  }
573
1187
  else {
574
- if (body.length > 6) {
575
- out.push(...body.slice(0, 3));
576
- out.push(`[...${body.length - 6} lines truncated...]`);
577
- out.push(...body.slice(-3));
1188
+ let head;
1189
+ let tail;
1190
+ if (level === "light") {
1191
+ head = 2;
1192
+ tail = 2;
1193
+ }
1194
+ else if (level === "medium") {
1195
+ head = 1;
1196
+ tail = 1;
1197
+ }
1198
+ else if (level === "aggressive") {
1199
+ head = 0;
1200
+ tail = 0;
1201
+ }
1202
+ else {
1203
+ head = 3;
1204
+ tail = 3;
1205
+ }
1206
+ if (body.length > head + tail) {
1207
+ out.push(...body.slice(0, head));
1208
+ out.push(`[...${body.length - head - tail} lines truncated...]`);
1209
+ out.push(...body.slice(-tail));
578
1210
  }
579
1211
  else {
580
1212
  out.push(...body);
@@ -589,7 +1221,7 @@ function stripBatchBodies(text) {
589
1221
  return out.join("\n");
590
1222
  }
591
1223
  /** If the JSONL line is a tool/toolResult message, strip batch bodies from its text. */
592
- function maybeStripBatchBodies(line) {
1224
+ function maybeStripBatchBodies(line, level) {
593
1225
  // Fast path: skip non-tool messages without parsing JSON.
594
1226
  if (!line.includes('"role":"tool"') && !line.includes('"role":"toolResult"')) {
595
1227
  return line;
@@ -629,7 +1261,7 @@ function maybeStripBatchBodies(line) {
629
1261
  if (!text || (!text.includes("\n--- ") && !text.includes("[Directive:") && !text.includes("[Hint:"))) {
630
1262
  return line;
631
1263
  }
632
- const stripped = stripBatchBodies(text);
1264
+ const stripped = stripBatchBodies(text, level);
633
1265
  const cleaned = stripDirectives(stripped);
634
1266
  if (cleaned === text) {
635
1267
  return line;