bosun 0.40.5 → 0.40.6

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 (38) hide show
  1. package/.env.example +13 -0
  2. package/agent/agent-pool.mjs +16 -7
  3. package/agent/agent-supervisor.mjs +81 -28
  4. package/agent/autofix.mjs +50 -36
  5. package/agent/fleet-coordinator.mjs +2 -1
  6. package/agent/hook-profiles.mjs +1 -1
  7. package/bosun.schema.json +51 -0
  8. package/config/context-shredding-config.mjs +143 -0
  9. package/infra/library-manager.mjs +717 -9
  10. package/infra/maintenance.mjs +9 -4
  11. package/infra/monitor.mjs +55 -8
  12. package/kanban/ve-kanban.mjs +12 -2
  13. package/kanban/vk-log-stream.mjs +4 -1
  14. package/package.json +2 -2
  15. package/server/setup-web-server.mjs +112 -10
  16. package/server/ui-server.mjs +146 -14
  17. package/setup.mjs +25 -16
  18. package/shell/codex-config.mjs +19 -8
  19. package/shell/copilot-shell.mjs +55 -7
  20. package/task/task-assessment.mjs +17 -9
  21. package/task/task-executor.mjs +37 -0
  22. package/telegram/telegram-bot.mjs +5 -4
  23. package/ui/demo-defaults.js +146 -24
  24. package/ui/demo.html +73 -2
  25. package/ui/modules/settings-schema.js +9 -0
  26. package/ui/setup.html +95 -0
  27. package/ui/tabs/library.js +31 -6
  28. package/ui/tabs/tasks.js +615 -85
  29. package/ui/tabs/telemetry.js +72 -22
  30. package/voice/voice-agents-sdk.mjs +5 -1
  31. package/workflow/workflow-engine.mjs +100 -16
  32. package/workflow/workflow-nodes.mjs +146 -9
  33. package/workflow-templates/github.mjs +11 -8
  34. package/workflow-templates/task-batch.mjs +1 -1
  35. package/workflow-templates/task-lifecycle.mjs +16 -3
  36. package/workspace/context-cache.mjs +399 -6
  37. package/workspace/workspace-manager.mjs +22 -16
  38. package/workspace/worktree-manager.mjs +7 -7
package/.env.example CHANGED
@@ -196,6 +196,19 @@ VOICE_FALLBACK_MODE=browser
196
196
  # Executor used by voice tool delegations for complex requests
197
197
  VOICE_DELEGATE_EXECUTOR=codex-sdk
198
198
 
199
+ # ─── Context Shredding / Live Tool Compaction ───────────────────────────────
200
+ # Traditional shredding trims older context turns. Live tool compaction runs
201
+ # earlier: it summarizes large, noisy command outputs before they ever land in
202
+ # the active turn, while preserving a `bosun --tool-log <id>` retrieval path.
203
+ # CONTEXT_SHREDDING_ENABLED=true
204
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_ENABLED=false
205
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MODE=auto
206
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MIN_CHARS=4000
207
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_TARGET_CHARS=1800
208
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MIN_SAVINGS_PCT=15
209
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MIN_RUNTIME_MS=2000
210
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_BLOCK_STRUCTURED_OUTPUT=true
211
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_ALLOW_COMMANDS=grep,rg,find,findstr,select-string,ag,ack,sift,fd,where,which,ls,dir,tree,git,go,npm,pnpm,yarn,npx,bun,node,python,python3,pytest,pip,pip3,poetry,docker,kubectl,helm,terraform,ansible,ansible-playbook,journalctl,tail,get-content,cargo,gradle,maven,mvn,javac,tsc,jest,vitest,deno,make,cmake,bazel,buck,nx,turbo,rush,composer,bundle
199
212
  # ─── Desktop Portal ────────────────────────────────────────────────────────
200
213
  # Auto-start bosun daemon when the desktop portal launches (default: true)
201
214
  # BOSUN_DESKTOP_AUTO_START_DAEMON=true
@@ -44,6 +44,7 @@ import { fileURLToPath } from "node:url";
44
44
  import { loadConfig } from "../config/config.mjs";
45
45
  import { resolveRepoRoot, resolveAgentRepoRoot } from "../config/repo-root.mjs";
46
46
  import { resolveCodexProfileRuntime } from "../shell/codex-model-profiles.mjs";
47
+ import { resolveCopilotCliLaunchConfig } from "../shell/copilot-shell.mjs";
47
48
  import { getGitHubToken } from "../github/github-auth-manager.mjs";
48
49
  import {
49
50
  isTransientStreamError,
@@ -489,6 +490,10 @@ function shouldFallbackForSdkError(error) {
489
490
  if (!error) return false;
490
491
  const message = String(error).toLowerCase();
491
492
  if (!message) return false;
493
+ if (message.includes("protocol version mismatch")) return true;
494
+ if (message.includes("sdk expects version") && message.includes("server reports version")) {
495
+ return true;
496
+ }
492
497
  // SDK not installed / not found
493
498
  if (message.includes("not available")) return true;
494
499
  // Missing finish_reason (incomplete response)
@@ -808,6 +813,10 @@ function shouldApplySdkCooldown(error) {
808
813
  if (!error) return false;
809
814
  const message = String(error).toLowerCase();
810
815
  if (!message) return false;
816
+ if (message.includes("protocol version mismatch")) return true;
817
+ if (message.includes("sdk expects version") && message.includes("server reports version")) {
818
+ return true;
819
+ }
811
820
  if (message.includes("timeout")) return true;
812
821
  if (message.includes("rate limit") || message.includes("429")) return true;
813
822
  if (message.includes("service unavailable") || message.includes("503")) {
@@ -1448,22 +1457,22 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1448
1457
  console.warn(`${TAG} copilot MCP config write failed (non-fatal): ${mcpErr.message}`);
1449
1458
  }
1450
1459
  }
1451
- const cliArgs = buildPoolCopilotCliArgs(mcpConfigPath);
1460
+ const cliLaunch = resolveCopilotCliLaunchConfig({
1461
+ env: runtimeEnv,
1462
+ repoRoot: REPO_ROOT,
1463
+ cliArgs: buildPoolCopilotCliArgs(mcpConfigPath),
1464
+ });
1452
1465
  clientOpts = {
1453
1466
  cwd,
1454
1467
  env: clientEnv,
1455
- cliArgs,
1468
+ cliArgs: cliLaunch.cliArgs,
1456
1469
  useStdio: true,
1457
1470
  };
1458
1471
  if (token) {
1459
1472
  clientOpts.githubToken = token;
1460
1473
  clientOpts.token = token;
1461
1474
  }
1462
- const cliPath =
1463
- process.env.COPILOT_CLI_PATH ||
1464
- process.env.GITHUB_COPILOT_CLI_PATH ||
1465
- undefined;
1466
- if (cliPath) clientOpts.cliPath = cliPath;
1475
+ if (cliLaunch.cliPath) clientOpts.cliPath = cliLaunch.cliPath;
1467
1476
  }
1468
1477
  client = new CopilotClientClass(clientOpts);
1469
1478
  await client.start();
@@ -901,6 +901,16 @@ export class AgentSupervisor {
901
901
  const progress = signals.sessionProgress;
902
902
  const analysis = signals.sessionAnalysis;
903
903
  const anomalies = signals.activeAnomalies || [];
904
+ const includesAny = (text, needles) => needles.some((needle) => text.includes(needle));
905
+ const includesOrdered = (text, parts) => {
906
+ let cursor = 0;
907
+ for (const part of parts) {
908
+ const idx = text.indexOf(part, cursor);
909
+ if (idx === -1) return false;
910
+ cursor = idx + part.length;
911
+ }
912
+ return true;
913
+ };
904
914
 
905
915
  // ── Agent completely dead ──
906
916
  if (
@@ -914,7 +924,13 @@ export class AgentSupervisor {
914
924
  // ── External errors (check first — these aren't the agent's fault) ──
915
925
  if (context.error || signals.error) {
916
926
  const errText = context.error || signals.error || "";
917
- if (/429|rate.?limit|too many requests|quota exceeded/i.test(errText)) {
927
+ const errLower = String(errText).toLowerCase();
928
+ if (
929
+ errLower.includes("429") ||
930
+ includesOrdered(errLower, ["rate", "limit"]) ||
931
+ errLower.includes("too many requests") ||
932
+ errLower.includes("quota exceeded")
933
+ ) {
918
934
  // Check for flood
919
935
  if (signals.errorPatterns?.length >= 3) {
920
936
  const recent = signals.errorPatterns.slice(-5);
@@ -923,47 +939,83 @@ export class AgentSupervisor {
923
939
  }
924
940
  return SITUATION.RATE_LIMITED;
925
941
  }
926
- if (/ECONNREFUSED|ETIMEDOUT|500|502|503|fetch failed/i.test(errText)) {
942
+ if (
943
+ includesAny(errLower, ["econnrefused", "etimedout", "fetch failed"]) ||
944
+ includesAny(errLower, [" 500", "500 ", "500internal", "500internalservererror"]) ||
945
+ includesAny(errLower, [" 502", "502 ", "502badgateway", "502bad gateway"]) ||
946
+ errLower.includes(" 503")
947
+ ) {
927
948
  return SITUATION.API_ERROR;
928
949
  }
929
- if (/context.*(too long|exceeded|overflow)|max.*token/i.test(errText) ||
930
- /context_length_exceeded|prompt_too_long|prompt.*too.*long/i.test(errText) ||
931
- /This model's maximum context length/i.test(errText) ||
932
- /token_budget.*exceeded|token.*budget/i.test(errText) ||
933
- /turn_limit_reached|conversation.*too.*long/i.test(errText) ||
934
- /string_above_max_length|maximum.*number.*tokens/i.test(errText)) {
950
+ if (
951
+ (errLower.includes("context") && includesAny(errLower, ["too long", "exceeded", "overflow"])) ||
952
+ includesOrdered(errLower, ["max", "token"]) ||
953
+ includesAny(errLower, ["context_length_exceeded", "prompt_too_long", "this model's maximum context length", "turn_limit_reached", "string_above_max_length"]) ||
954
+ includesOrdered(errLower, ["prompt", "too", "long"]) ||
955
+ includesOrdered(errLower, ["token_budget", "exceeded"]) ||
956
+ includesOrdered(errLower, ["token", "budget"]) ||
957
+ includesOrdered(errLower, ["conversation", "too", "long"]) ||
958
+ includesOrdered(errLower, ["maximum", "number", "tokens"])
959
+ ) {
935
960
  return SITUATION.TOKEN_OVERFLOW;
936
961
  }
937
- if (/model.*not.*supported|model.*error|invalid.*model|model.*not.*found|model.*deprecated/i.test(errText)) {
962
+ if (
963
+ includesOrdered(errLower, ["model", "not", "supported"]) ||
964
+ includesOrdered(errLower, ["model", "error"]) ||
965
+ includesOrdered(errLower, ["invalid", "model"]) ||
966
+ includesOrdered(errLower, ["model", "not", "found"]) ||
967
+ includesOrdered(errLower, ["model", "deprecated"])
968
+ ) {
938
969
  return SITUATION.MODEL_ERROR;
939
970
  }
940
971
  // ── Auth failures — MUST come BEFORE session_expired (which has 'unauthorized')
941
- if (/invalid.?api.?key|authentication_error|permission_error/i.test(errText) ||
942
- /401 Unauthorized|403 Forbidden/i.test(errText) ||
943
- /billing_hard_limit|insufficient_quota/i.test(errText) ||
944
- /invalid.*credentials|access.?denied|not.?authorized/i.test(errText)) {
972
+ if (
973
+ includesAny(errLower, ["invalid api key", "invalid-api-key", "invalid_api_key", "authentication_error", "permission_error", "401 unauthorized", "403 forbidden", "billing_hard_limit", "insufficient_quota"]) ||
974
+ includesOrdered(errLower, ["invalid", "credentials"]) ||
975
+ includesOrdered(errLower, ["access", "denied"]) ||
976
+ includesOrdered(errLower, ["not", "authorized"])
977
+ ) {
945
978
  return SITUATION.AUTH_FAILURE;
946
979
  }
947
980
  // ── Session expired (after auth check since 'unauthorized' overlaps)
948
- if (/session.*expired|thread.*not.*found/i.test(errText) ||
949
- /invalid.*session|invalid.*token/i.test(errText)) {
981
+ if (
982
+ includesOrdered(errLower, ["session", "expired"]) ||
983
+ includesOrdered(errLower, ["thread", "not", "found"]) ||
984
+ includesOrdered(errLower, ["invalid", "session"]) ||
985
+ includesOrdered(errLower, ["invalid", "token"])
986
+ ) {
950
987
  return SITUATION.SESSION_EXPIRED;
951
988
  }
952
989
  // ── Content policy (safety filter)
953
- if (/content_policy|content.?filter|safety_system|safety.*filter/i.test(errText) ||
954
- /flagged.*content|output.*blocked|response.*blocked/i.test(errText)) {
990
+ if (
991
+ includesAny(errLower, ["content_policy", "content filter", "content-filter", "content_filter", "safety_system"]) ||
992
+ includesOrdered(errLower, ["safety", "filter"]) ||
993
+ includesOrdered(errLower, ["flagged", "content"]) ||
994
+ includesOrdered(errLower, ["output", "blocked"]) ||
995
+ includesOrdered(errLower, ["response", "blocked"])
996
+ ) {
955
997
  return SITUATION.CONTENT_POLICY;
956
998
  }
957
999
  // ── Codex sandbox/CLI errors
958
- if (/sandbox.*fail|bwrap.*error|bubblewrap/i.test(errText) ||
959
- /EPERM.*operation.*not.*permitted/i.test(errText) ||
960
- /writable_roots|namespace.*error/i.test(errText) ||
961
- /codex.*(segfault|killed|crash)/i.test(errText)) {
1000
+ if (
1001
+ includesOrdered(errLower, ["sandbox", "fail"]) ||
1002
+ includesOrdered(errLower, ["bwrap", "error"]) ||
1003
+ errLower.includes("bubblewrap") ||
1004
+ includesOrdered(errLower, ["eperm", "operation", "not", "permitted"]) ||
1005
+ errLower.includes("writable_roots") ||
1006
+ includesOrdered(errLower, ["namespace", "error"]) ||
1007
+ (errLower.includes("codex") && includesAny(errLower, ["segfault", "killed", "crash"]))
1008
+ ) {
962
1009
  return SITUATION.CODEX_SANDBOX;
963
1010
  }
964
1011
  // ── Invalid config (catch-all for config-related errors)
965
- if (/config.*invalid|config.*missing|misconfigured/i.test(errText) ||
966
- /OPENAI_API_KEY.*not.*set|ANTHROPIC_API_KEY.*not.*set/i.test(errText)) {
1012
+ if (
1013
+ includesOrdered(errLower, ["config", "invalid"]) ||
1014
+ includesOrdered(errLower, ["config", "missing"]) ||
1015
+ errLower.includes("misconfigured") ||
1016
+ includesOrdered(errLower, ["openai_api_key", "not", "set"]) ||
1017
+ includesOrdered(errLower, ["anthropic_api_key", "not", "set"])
1018
+ ) {
967
1019
  return SITUATION.INVALID_CONFIG;
968
1020
  }
969
1021
  }
@@ -1009,11 +1061,11 @@ export class AgentSupervisor {
1009
1061
  // ── Build/test/lint failures from context ──
1010
1062
  if (context.error) {
1011
1063
  const err = context.error.toLowerCase();
1012
- if (/pre-push hook.*fail/i.test(err)) return SITUATION.PRE_PUSH_FAILURE;
1013
- if (/git push.*fail|rejected.*push/i.test(err)) return SITUATION.PUSH_FAILURE;
1014
- if (/go build.*fail|compilation error|cannot find/i.test(err)) return SITUATION.BUILD_FAILURE;
1015
- if (/FAIL\s+\S+|test.*fail/i.test(err)) return SITUATION.TEST_FAILURE;
1016
- if (/golangci-lint|lint.*error/i.test(err)) return SITUATION.LINT_FAILURE;
1064
+ if (includesOrdered(err, ["pre-push hook", "fail"])) return SITUATION.PRE_PUSH_FAILURE;
1065
+ if (includesOrdered(err, ["git push", "fail"]) || includesOrdered(err, ["rejected", "push"])) return SITUATION.PUSH_FAILURE;
1066
+ if (includesOrdered(err, ["go build", "fail"]) || err.includes("compilation error") || err.includes("cannot find")) return SITUATION.BUILD_FAILURE;
1067
+ if ((err.includes("fail") && err.includes("test")) || err.includes("fail ")) return SITUATION.TEST_FAILURE;
1068
+ if (err.includes("golangci-lint") || includesOrdered(err, ["lint", "error"])) return SITUATION.LINT_FAILURE;
1017
1069
  if (/merge conflict|CONFLICT/i.test(err)) return SITUATION.GIT_CONFLICT;
1018
1070
  }
1019
1071
 
@@ -1234,3 +1286,4 @@ export class AgentSupervisor {
1234
1286
  export function createAgentSupervisor(opts) {
1235
1287
  return new AgentSupervisor(opts);
1236
1288
  }
1289
+
package/agent/autofix.mjs CHANGED
@@ -93,7 +93,6 @@ export function extractErrors(logText) {
93
93
  const atLineHeader = /^At\s+([A-Za-z]:\\[^\n:]+\.ps1):(\d+)\s+char:(\d+)/;
94
94
 
95
95
  // Pattern C: TerminatingError(X): "message"
96
- const terminatingPattern = /TerminatingError\(([^)]+)\):\s*"(.+?)"/;
97
96
 
98
97
  for (let i = 0; i < lines.length; i++) {
99
98
  const line = lines[i];
@@ -147,16 +146,24 @@ export function extractErrors(logText) {
147
146
  }
148
147
 
149
148
  // ── Check Pattern C (TerminatingError) ──────────────────────────
150
- let matchD = line.match(terminatingPattern);
151
- if (matchD) {
152
- addError({
153
- errorType: "TerminatingError",
154
- file: "unknown",
155
- line: 0,
156
- column: null,
157
- message: `${matchD[1]}: ${matchD[2].trim()}`,
158
- signature: `TerminatingError:${matchD[1]}`,
159
- });
149
+ const marker = "TerminatingError(";
150
+ const markerPos = line.indexOf(marker);
151
+ if (markerPos !== -1) {
152
+ const typeStart = markerPos + marker.length;
153
+ const typeEnd = line.indexOf(")", typeStart);
154
+ const colonPos = typeEnd >= 0 ? line.indexOf(":", typeEnd + 1) : -1;
155
+ const errorType = typeEnd >= 0 ? line.slice(typeStart, typeEnd).trim() : "";
156
+ const detail = colonPos >= 0 ? line.slice(colonPos + 1).trim().replace(/^"|"$/g, "") : "";
157
+ if (errorType && detail) {
158
+ addError({
159
+ errorType: "TerminatingError",
160
+ file: "unknown",
161
+ line: 0,
162
+ column: null,
163
+ message: `${errorType}: ${detail}`,
164
+ signature: `TerminatingError:${errorType}`,
165
+ });
166
+ }
160
167
  }
161
168
  }
162
169
 
@@ -185,18 +192,22 @@ function parseLineBlock(lines, startIdx) {
185
192
  }
186
193
 
187
194
  // Capture code line: " NNN | code..."
188
- if (i < lines.length && /^\s*\d+\s*\|/.test(lines[i])) {
189
- const codeMatch = lines[i].match(/^\s*\d+\s*\|\s*(.*)$/);
190
- if (codeMatch) codeLine = codeMatch[1].trim();
191
- i++;
195
+ if (i < lines.length) {
196
+ const raw = String(lines[i] || "");
197
+ const sep = raw.indexOf("|");
198
+ if (sep !== -1 && /^\s*\d+\s*$/.test(raw.slice(0, sep))) {
199
+ codeLine = raw.slice(sep + 1).trim();
200
+ i++;
201
+ }
192
202
  }
193
203
 
194
204
  // Skip underline and intermediate "| ..." lines, capture last "| message" line
195
205
  let lastPipeMessage = "";
196
206
  while (i < lines.length) {
197
- const pipeMatch = lines[i].match(/^\s*\|\s*(.*)$/);
198
- if (!pipeMatch) break;
199
- const content = pipeMatch[1].trim();
207
+ const raw = String(lines[i] || "");
208
+ const sep = raw.indexOf("|");
209
+ if (sep === -1 || raw.slice(0, sep).trim().length > 0) break;
210
+ const content = raw.slice(sep + 1).trim();
200
211
  // Skip underline-only lines (~~~~) and empty lines
201
212
  if (content && !/^~+$/.test(content)) {
202
213
  lastPipeMessage = content;
@@ -225,29 +236,32 @@ function parsePlusBlock(lines, startIdx) {
225
236
  let i = startIdx;
226
237
 
227
238
  // First "+ " line is usually the code
228
- if (i < lines.length && /^\s*\+\s*/.test(lines[i])) {
229
- const codeMatch = lines[i].match(/^\s*\+\s*(.*)$/);
230
- if (codeMatch) {
231
- const content = codeMatch[1].trim();
239
+ if (i < lines.length) {
240
+ const raw = String(lines[i] || "").trimStart();
241
+ if (raw.startsWith("+")) {
242
+ const content = raw.slice(1).trim();
232
243
  if (!/^~+$/.test(content)) codeLine = content;
244
+ i++;
233
245
  }
234
- i++;
235
246
  }
236
247
 
237
248
  // Subsequent "+ " lines — skip underlines, capture error type + message
238
- while (i < lines.length && /^\s*\+\s*/.test(lines[i])) {
239
- const plusMatch = lines[i].match(/^\s*\+\s*(.*)$/);
240
- if (plusMatch) {
241
- const content = plusMatch[1].trim();
242
- if (/^~+$/.test(content)) {
243
- i++;
244
- continue;
245
- }
246
- // Check for "ErrorType: message"
247
- const errMatch = content.match(/^(\w[\w.-]+):\s*(.+)$/);
248
- if (errMatch) {
249
- errorType = errMatch[1];
250
- message = errMatch[2].trim();
249
+ while (i < lines.length) {
250
+ const raw = String(lines[i] || "").trimStart();
251
+ if (!raw.startsWith("+")) break;
252
+ const content = raw.slice(1).trim();
253
+ if (/^~+$/.test(content)) {
254
+ i++;
255
+ continue;
256
+ }
257
+ // Check for "ErrorType: message"
258
+ const colonPos = content.indexOf(":");
259
+ if (colonPos > 0 && colonPos < content.length - 1) {
260
+ const maybeType = content.slice(0, colonPos).trim();
261
+ const maybeMessage = content.slice(colonPos + 1).trim();
262
+ if (/^\w[\w.-]+$/.test(maybeType) && maybeMessage) {
263
+ errorType = maybeType;
264
+ message = maybeMessage;
251
265
  }
252
266
  }
253
267
  i++;
@@ -160,7 +160,7 @@ export function normalizeGitUrl(url) {
160
160
  s = s.replace(/\.git$/, "");
161
161
 
162
162
  // Strip trailing slashes
163
- s = s.replace(/\/+$/, "");
163
+ while (s.endsWith("/")) s = s.slice(0, -1);
164
164
 
165
165
  return s.toLowerCase();
166
166
  }
@@ -857,3 +857,4 @@ export function formatFleetSummary() {
857
857
 
858
858
  return lines.join("\n");
859
859
  }
860
+
@@ -187,7 +187,7 @@ function normalizeOverrideCommands(rawValue) {
187
187
  return [];
188
188
  }
189
189
  const commands = raw
190
- .split(/\s*;;\s*|\r?\n/)
190
+ .split(/\r?\n/).flatMap((part) => part.split(";;"))
191
191
  .map((part) => part.trim())
192
192
  .filter(Boolean);
193
193
  return commands;
package/bosun.schema.json CHANGED
@@ -1199,6 +1199,56 @@
1199
1199
  "default": 1,
1200
1200
  "description": "Turns during which the full user prompt is preserved."
1201
1201
  },
1202
+ "liveToolCompactionEnabled": {
1203
+ "type": "boolean",
1204
+ "default": false,
1205
+ "description": "Enable command-aware live compaction of large tool outputs before they are stored in the active turn."
1206
+ },
1207
+ "liveToolCompactionMode": {
1208
+ "type": "string",
1209
+ "enum": ["off", "auto", "aggressive"],
1210
+ "default": "auto",
1211
+ "description": "off disables live compaction, auto compacts only when pressure or signal justify it, and aggressive favors stronger reduction for noisy command families."
1212
+ },
1213
+ "liveToolCompactionMinChars": {
1214
+ "type": "integer",
1215
+ "minimum": 500,
1216
+ "maximum": 500000,
1217
+ "default": 4000,
1218
+ "description": "Minimum output size before live compaction is considered."
1219
+ },
1220
+ "liveToolCompactionTargetChars": {
1221
+ "type": "integer",
1222
+ "minimum": 200,
1223
+ "maximum": 50000,
1224
+ "default": 1800,
1225
+ "description": "Target upper bound for compacted live command output before retrieval hints and metadata."
1226
+ },
1227
+ "liveToolCompactionMinSavingsPct": {
1228
+ "type": "integer",
1229
+ "minimum": 0,
1230
+ "maximum": 95,
1231
+ "default": 15,
1232
+ "description": "Discard compacted output if it does not save at least this much space."
1233
+ },
1234
+ "liveToolCompactionMinRuntimeMs": {
1235
+ "type": "integer",
1236
+ "minimum": 0,
1237
+ "maximum": 3600000,
1238
+ "default": 2000,
1239
+ "description": "When command duration metadata is present, require at least this runtime before compacting in auto mode."
1240
+ },
1241
+ "liveToolCompactionBlockStructured": {
1242
+ "type": "boolean",
1243
+ "default": true,
1244
+ "description": "Avoid live compaction for likely JSON or other structured outputs where exact bytes matter more than size."
1245
+ },
1246
+ "liveToolCompactionAllowCommands": {
1247
+ "type": "array",
1248
+ "items": { "type": "string" },
1249
+ "default": ["grep", "rg", "find", "git", "go", "npm", "pnpm", "yarn", "node", "python", "python3", "pytest", "docker", "kubectl", "cargo", "gradle", "maven", "mvn", "javac", "tsc", "jest", "vitest", "deno"],
1250
+ "description": "Command or tool families eligible for live compaction."
1251
+ },
1202
1252
  "perType": {
1203
1253
  "type": "object",
1204
1254
  "description": "Per-interaction-type config overrides. Keys: task, manual, primary, chat, voice, flow.",
@@ -1237,3 +1287,4 @@
1237
1287
  }
1238
1288
  }
1239
1289
  }
1290
+
@@ -97,6 +97,14 @@ export const CONTENT_TYPES = Object.freeze([
97
97
  * @property {boolean} compressToolOutputs - Enable tool output compression
98
98
  * @property {boolean} compressAgentMessages - Enable agent message compression
99
99
  * @property {boolean} compressUserMessages - Enable user message compression
100
+ * @property {boolean} liveToolCompactionEnabled - Enable command-aware live compaction before history shredding
101
+ * @property {string} liveToolCompactionMode - off|auto|aggressive smart mode for live compaction
102
+ * @property {number} liveToolCompactionMinChars - Minimum output chars before live compaction is considered
103
+ * @property {number} liveToolCompactionTargetChars - Target maximum chars for live-compacted outputs
104
+ * @property {number} liveToolCompactionMinSavingsPct - Minimum savings percentage required to keep a compacted result
105
+ * @property {number} liveToolCompactionMinRuntimeMs - Minimum runtime before live compaction is allowed when duration metadata exists
106
+ * @property {boolean} liveToolCompactionBlockStructured - Disable live compaction for likely structured outputs
107
+ * @property {string[]} liveToolCompactionAllowCommands - Command/tool allowlist for live compaction
100
108
  * @property {Object} perType - Per interaction-type overrides (keyed by INTERACTION_TYPES)
101
109
  * @property {Object} perAgent - Per agent-type overrides (keyed by AGENT_TYPES)
102
110
  */
@@ -138,6 +146,25 @@ export const DEFAULT_SHREDDING_CONFIG = Object.freeze({
138
146
  msgMinCompressChars: 120,
139
147
  userMsgFullTurns: 1, // only the current turn keeps the full user prompt
140
148
 
149
+ // ── Live command/tool compaction ─────────────────────────────
150
+ liveToolCompactionEnabled: false,
151
+ liveToolCompactionMode: "auto",
152
+ liveToolCompactionMinChars: 4000,
153
+ liveToolCompactionTargetChars: 1800,
154
+ liveToolCompactionMinSavingsPct: 15,
155
+ liveToolCompactionMinRuntimeMs: 2000,
156
+ liveToolCompactionBlockStructured: true,
157
+ liveToolCompactionAllowCommands: [
158
+ "grep", "rg", "find", "findstr", "select-string", "ag", "ack", "sift",
159
+ "fd", "where", "which", "ls", "dir", "tree", "git", "go", "npm",
160
+ "pnpm", "yarn", "npx", "bun", "node", "python", "python3", "pytest",
161
+ "pip", "pip3", "poetry", "docker", "kubectl", "helm", "terraform",
162
+ "ansible", "ansible-playbook", "journalctl", "tail", "get-content",
163
+ "cargo", "gradle", "maven", "mvn", "javac", "tsc", "jest", "vitest",
164
+ "deno", "make", "cmake", "bazel", "buck", "nx", "turbo", "rush",
165
+ "composer", "bundle",
166
+ ],
167
+
141
168
  // ── Per-type overrides (empty = use base config) ─────────────
142
169
  /** @type {Record<string, Partial<ShreddingConfig>>} */
143
170
  perType: {},
@@ -301,6 +328,38 @@ export function loadContextShreddingConfig() {
301
328
  const userFull = parseEnvInt(env[`${ENV_PREFIX}USER_MSG_FULL_TURNS`], 0, 10);
302
329
  if (userFull != null) cfg.userMsgFullTurns = userFull;
303
330
 
331
+ // ── Live command/tool compaction ─────────────────────────────
332
+ const liveToolCompactionEnabled = parseEnvBool(env[`${ENV_PREFIX}LIVE_TOOL_COMPACTION_ENABLED`]);
333
+ if (liveToolCompactionEnabled != null) cfg.liveToolCompactionEnabled = liveToolCompactionEnabled;
334
+
335
+ const liveToolCompactionMode = String(env[`${ENV_PREFIX}LIVE_TOOL_COMPACTION_MODE`] || "").trim().toLowerCase();
336
+ if (["off", "auto", "aggressive"].includes(liveToolCompactionMode)) {
337
+ cfg.liveToolCompactionMode = liveToolCompactionMode;
338
+ }
339
+
340
+ const liveToolCompactionMinChars = parseEnvInt(env[`${ENV_PREFIX}LIVE_TOOL_COMPACTION_MIN_CHARS`], 500, 500000);
341
+ if (liveToolCompactionMinChars != null) cfg.liveToolCompactionMinChars = liveToolCompactionMinChars;
342
+
343
+ const liveToolCompactionTargetChars = parseEnvInt(env[`${ENV_PREFIX}LIVE_TOOL_COMPACTION_TARGET_CHARS`], 200, 50000);
344
+ if (liveToolCompactionTargetChars != null) cfg.liveToolCompactionTargetChars = liveToolCompactionTargetChars;
345
+
346
+ const liveToolCompactionMinSavingsPct = parseEnvInt(env[`${ENV_PREFIX}LIVE_TOOL_COMPACTION_MIN_SAVINGS_PCT`], 0, 95);
347
+ if (liveToolCompactionMinSavingsPct != null) cfg.liveToolCompactionMinSavingsPct = liveToolCompactionMinSavingsPct;
348
+
349
+ const liveToolCompactionMinRuntimeMs = parseEnvInt(env[`${ENV_PREFIX}LIVE_TOOL_COMPACTION_MIN_RUNTIME_MS`], 0, 60 * 60 * 1000);
350
+ if (liveToolCompactionMinRuntimeMs != null) cfg.liveToolCompactionMinRuntimeMs = liveToolCompactionMinRuntimeMs;
351
+
352
+ const liveToolCompactionBlockStructured = parseEnvBool(env[`${ENV_PREFIX}LIVE_TOOL_COMPACTION_BLOCK_STRUCTURED_OUTPUT`]);
353
+ if (liveToolCompactionBlockStructured != null) cfg.liveToolCompactionBlockStructured = liveToolCompactionBlockStructured;
354
+
355
+ const liveToolCompactionAllowCommands = String(env[`${ENV_PREFIX}LIVE_TOOL_COMPACTION_ALLOW_COMMANDS`] || "").trim();
356
+ if (liveToolCompactionAllowCommands) {
357
+ cfg.liveToolCompactionAllowCommands = liveToolCompactionAllowCommands
358
+ .split(",")
359
+ .map((value) => value.trim().toLowerCase())
360
+ .filter(Boolean);
361
+ }
362
+
304
363
  // ── Per-type JSON profiles ────────────────────────────────────
305
364
  const profilesEnv = env[`${ENV_PREFIX}PROFILES`];
306
365
  if (profilesEnv) {
@@ -450,6 +509,14 @@ function configToOptions(cfg) {
450
509
  msgTier1MaxAge: cfg.msgTier1MaxAge,
451
510
  msgMinCompressChars: cfg.msgMinCompressChars,
452
511
  userMsgFullTurns: cfg.userMsgFullTurns,
512
+ liveToolCompactionEnabled: cfg.liveToolCompactionEnabled,
513
+ liveToolCompactionMode: cfg.liveToolCompactionMode,
514
+ liveToolCompactionMinChars: cfg.liveToolCompactionMinChars,
515
+ liveToolCompactionTargetChars: cfg.liveToolCompactionTargetChars,
516
+ liveToolCompactionMinSavingsPct: cfg.liveToolCompactionMinSavingsPct,
517
+ liveToolCompactionMinRuntimeMs: cfg.liveToolCompactionMinRuntimeMs,
518
+ liveToolCompactionBlockStructured: cfg.liveToolCompactionBlockStructured,
519
+ liveToolCompactionAllowCommands: [...(cfg.liveToolCompactionAllowCommands || [])],
453
520
  };
454
521
  }
455
522
 
@@ -677,6 +744,82 @@ export const CONTEXT_SHREDDING_ENV_DEFS = [
677
744
  description: "Turns during which the full user prompt is preserved. After this, only a short summary is kept.",
678
745
  advanced: true,
679
746
  },
747
+ {
748
+ key: "CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_ENABLED",
749
+ label: "Live Tool Compaction",
750
+ type: "boolean",
751
+ default: false,
752
+ description: "Enable command-aware compaction of large tool outputs before they are stored in the active turn. Falls back to raw output on low confidence or unsafe shapes.",
753
+ },
754
+ {
755
+ key: "CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MODE",
756
+ label: "Live Compaction Mode",
757
+ type: "select",
758
+ default: "auto",
759
+ options: ["off", "auto", "aggressive"],
760
+ description: "off disables live compaction, auto compacts only when pressure or signal justify it, and aggressive favors stronger reduction for noisy command families.",
761
+ advanced: true,
762
+ },
763
+ {
764
+ key: "CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MIN_CHARS",
765
+ label: "Live Compaction Minimum Size",
766
+ type: "number",
767
+ default: 4000,
768
+ min: 500,
769
+ max: 500000,
770
+ unit: "chars",
771
+ description: "Minimum output size before live compaction is considered.",
772
+ advanced: true,
773
+ },
774
+ {
775
+ key: "CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_TARGET_CHARS",
776
+ label: "Live Compaction Target Size",
777
+ type: "number",
778
+ default: 1800,
779
+ min: 200,
780
+ max: 50000,
781
+ unit: "chars",
782
+ description: "Target upper bound for compacted live command output before retrieval hints and metadata.",
783
+ advanced: true,
784
+ },
785
+ {
786
+ key: "CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MIN_SAVINGS_PCT",
787
+ label: "Live Compaction Min Savings",
788
+ type: "number",
789
+ default: 15,
790
+ min: 0,
791
+ max: 95,
792
+ unit: "%",
793
+ description: "Discard compacted output if it does not save at least this much space.",
794
+ advanced: true,
795
+ },
796
+ {
797
+ key: "CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MIN_RUNTIME_MS",
798
+ label: "Live Compaction Min Runtime",
799
+ type: "number",
800
+ default: 2000,
801
+ min: 0,
802
+ max: 3600000,
803
+ unit: "ms",
804
+ description: "When command duration metadata is present, require at least this runtime before compacting in auto mode.",
805
+ advanced: true,
806
+ },
807
+ {
808
+ key: "CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_BLOCK_STRUCTURED_OUTPUT",
809
+ label: "Skip Structured Output",
810
+ type: "boolean",
811
+ default: true,
812
+ description: "Avoid live compaction for likely JSON or other structured outputs where exact bytes matter more than size.",
813
+ advanced: true,
814
+ },
815
+ {
816
+ key: "CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_ALLOW_COMMANDS",
817
+ label: "Live Compaction Allowlist",
818
+ type: "text",
819
+ default: "grep,rg,find,findstr,select-string,ag,ack,sift,fd,where,which,ls,dir,tree,git,go,npm,pnpm,yarn,npx,bun,node,python,python3,pytest,pip,pip3,poetry,docker,kubectl,helm,terraform,ansible,ansible-playbook,journalctl,tail,get-content,cargo,gradle,maven,mvn,javac,tsc,jest,vitest,deno,make,cmake,bazel,buck,nx,turbo,rush,composer,bundle",
820
+ description: "Comma-separated command or tool families eligible for live compaction. Commands outside the allowlist pass through untouched.",
821
+ advanced: true,
822
+ },
680
823
  {
681
824
  key: "CONTEXT_SHREDDING_PROFILES",
682
825
  label: "Per-Type Profiles (JSON)",