@pentoshi/clai 3.8.19 → 3.8.21

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 (176) hide show
  1. package/README.md +8 -5
  2. package/dist/agent/compaction-summary.d.ts +1 -1
  3. package/dist/agent/compaction-summary.js +3 -2
  4. package/dist/agent/compaction-summary.js.map +1 -1
  5. package/dist/agent/context-breakdown.d.ts +37 -0
  6. package/dist/agent/context-breakdown.js +145 -0
  7. package/dist/agent/context-breakdown.js.map +1 -0
  8. package/dist/agent/context-manager.d.ts +29 -2
  9. package/dist/agent/context-manager.js +125 -17
  10. package/dist/agent/context-manager.js.map +1 -1
  11. package/dist/agent/continue-orient.d.ts +39 -0
  12. package/dist/agent/continue-orient.js +152 -0
  13. package/dist/agent/continue-orient.js.map +1 -0
  14. package/dist/agent/reliability-policy.d.ts +85 -0
  15. package/dist/agent/reliability-policy.js +158 -0
  16. package/dist/agent/reliability-policy.js.map +1 -0
  17. package/dist/agent/runner.js +280 -141
  18. package/dist/agent/runner.js.map +1 -1
  19. package/dist/agent/session-state.d.ts +13 -1
  20. package/dist/agent/session-state.js +30 -17
  21. package/dist/agent/session-state.js.map +1 -1
  22. package/dist/agent/stop-summary.d.ts +2 -2
  23. package/dist/agent/stop-summary.js +23 -4
  24. package/dist/agent/stop-summary.js.map +1 -1
  25. package/dist/agent/task-evidence.js +13 -0
  26. package/dist/agent/task-evidence.js.map +1 -1
  27. package/dist/agent/tool-history.d.ts +12 -0
  28. package/dist/agent/tool-history.js +159 -0
  29. package/dist/agent/tool-history.js.map +1 -1
  30. package/dist/agent/tool-output-formatting.d.ts +4 -0
  31. package/dist/agent/tool-output-formatting.js +21 -8
  32. package/dist/agent/tool-output-formatting.js.map +1 -1
  33. package/dist/app/controllers/session-compact-helper.d.ts +2 -0
  34. package/dist/app/controllers/session-compact-helper.js +14 -5
  35. package/dist/app/controllers/session-compact-helper.js.map +1 -1
  36. package/dist/app/controllers/session-controller.d.ts +6 -0
  37. package/dist/app/controllers/session-controller.js +27 -2
  38. package/dist/app/controllers/session-controller.js.map +1 -1
  39. package/dist/commands/providers.js +57 -26
  40. package/dist/commands/providers.js.map +1 -1
  41. package/dist/index.js +3 -3
  42. package/dist/index.js.map +1 -1
  43. package/dist/llm/gemini.js +4 -1
  44. package/dist/llm/gemini.js.map +1 -1
  45. package/dist/llm/http.js +22 -12
  46. package/dist/llm/http.js.map +1 -1
  47. package/dist/llm/key-rotation.d.ts +52 -0
  48. package/dist/llm/key-rotation.js +122 -0
  49. package/dist/llm/key-rotation.js.map +1 -0
  50. package/dist/llm/provider.d.ts +2 -0
  51. package/dist/llm/provider.js +8 -0
  52. package/dist/llm/provider.js.map +1 -1
  53. package/dist/llm/router.d.ts +15 -2
  54. package/dist/llm/router.js +318 -125
  55. package/dist/llm/router.js.map +1 -1
  56. package/dist/prompts/embedded.js +1 -1
  57. package/dist/prompts/embedded.js.map +1 -1
  58. package/dist/prompts/index.d.ts +25 -0
  59. package/dist/prompts/index.js +55 -7
  60. package/dist/prompts/index.js.map +1 -1
  61. package/dist/prompts/system.agent.md +4 -3
  62. package/dist/repl/slash-commands.js +14 -8
  63. package/dist/repl/slash-commands.js.map +1 -1
  64. package/dist/repl.d.ts +4 -1
  65. package/dist/repl.js +12 -2
  66. package/dist/repl.js.map +1 -1
  67. package/dist/store/config.d.ts +18 -0
  68. package/dist/store/config.js +9 -0
  69. package/dist/store/config.js.map +1 -1
  70. package/dist/store/history.d.ts +8 -0
  71. package/dist/store/history.js +38 -0
  72. package/dist/store/history.js.map +1 -1
  73. package/dist/store/keys.d.ts +55 -0
  74. package/dist/store/keys.js +226 -21
  75. package/dist/store/keys.js.map +1 -1
  76. package/dist/store/logs.js +35 -12
  77. package/dist/store/logs.js.map +1 -1
  78. package/dist/store/paths.d.ts +13 -0
  79. package/dist/store/paths.js +23 -1
  80. package/dist/store/paths.js.map +1 -1
  81. package/dist/store/session-workspace.d.ts +79 -0
  82. package/dist/store/session-workspace.js +199 -0
  83. package/dist/store/session-workspace.js.map +1 -0
  84. package/dist/tools/definitions.js +17 -2
  85. package/dist/tools/definitions.js.map +1 -1
  86. package/dist/tools/http.d.ts +10 -0
  87. package/dist/tools/http.js +293 -58
  88. package/dist/tools/http.js.map +1 -1
  89. package/dist/tools/pentest-workflows.js +38 -19
  90. package/dist/tools/pentest-workflows.js.map +1 -1
  91. package/dist/tools/registry.js +26 -2
  92. package/dist/tools/registry.js.map +1 -1
  93. package/dist/tools/shell.d.ts +1 -1
  94. package/dist/tui/format-keys.js +29 -6
  95. package/dist/tui/format-keys.js.map +1 -1
  96. package/dist/tui-v2/app/App.js +39 -1
  97. package/dist/tui-v2/app/App.js.map +1 -1
  98. package/dist/tui-v2/app/command-handlers.js +1 -2
  99. package/dist/tui-v2/app/command-handlers.js.map +1 -1
  100. package/dist/tui-v2/app/commands/config-commands.d.ts +1 -2
  101. package/dist/tui-v2/app/commands/config-commands.js +1 -11
  102. package/dist/tui-v2/app/commands/config-commands.js.map +1 -1
  103. package/dist/tui-v2/app/commands/key-commands.d.ts +1 -1
  104. package/dist/tui-v2/app/commands/key-commands.js +148 -63
  105. package/dist/tui-v2/app/commands/key-commands.js.map +1 -1
  106. package/dist/tui-v2/app/commands/picker-commands.js +13 -7
  107. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  108. package/dist/tui-v2/bootstrap/composition-root.js +8 -5
  109. package/dist/tui-v2/bootstrap/composition-root.js.map +1 -1
  110. package/dist/tui-v2/bootstrap/start-tui-v2.js +10 -4
  111. package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
  112. package/dist/tui-v2/components/modal/keys-modal.d.ts +19 -0
  113. package/dist/tui-v2/components/modal/keys-modal.js +203 -0
  114. package/dist/tui-v2/components/modal/keys-modal.js.map +1 -0
  115. package/dist/tui-v2/components/modal/secret-modal.d.ts +3 -1
  116. package/dist/tui-v2/components/modal/secret-modal.js +110 -18
  117. package/dist/tui-v2/components/modal/secret-modal.js.map +1 -1
  118. package/dist/tui-v2/components/overlay/overlay-host.js +6 -2
  119. package/dist/tui-v2/components/overlay/overlay-host.js.map +1 -1
  120. package/dist/tui-v2/components/pager/pager-line.js +5 -2
  121. package/dist/tui-v2/components/pager/pager-line.js.map +1 -1
  122. package/dist/tui-v2/components/pager/pager.js +6 -3
  123. package/dist/tui-v2/components/pager/pager.js.map +1 -1
  124. package/dist/tui-v2/components/plan/use-pane-presence.d.ts +6 -4
  125. package/dist/tui-v2/components/plan/use-pane-presence.js +8 -45
  126. package/dist/tui-v2/components/plan/use-pane-presence.js.map +1 -1
  127. package/dist/tui-v2/components/toast/toast-anim.d.ts +5 -4
  128. package/dist/tui-v2/components/toast/toast-anim.js +10 -7
  129. package/dist/tui-v2/components/toast/toast-anim.js.map +1 -1
  130. package/dist/tui-v2/components/toast/toast-host.d.ts +9 -3
  131. package/dist/tui-v2/components/toast/toast-host.js +124 -26
  132. package/dist/tui-v2/components/toast/toast-host.js.map +1 -1
  133. package/dist/tui-v2/components/transcript/assistant-message.js +4 -1
  134. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  135. package/dist/tui-v2/components/transcript/compacted-row.js +1 -1
  136. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  137. package/dist/tui-v2/components/transcript/file-diff-card.js +3 -23
  138. package/dist/tui-v2/components/transcript/file-diff-card.js.map +1 -1
  139. package/dist/tui-v2/components/transcript/linkable-text.js +9 -2
  140. package/dist/tui-v2/components/transcript/linkable-text.js.map +1 -1
  141. package/dist/tui-v2/components/transcript/selectable-line.d.ts +22 -0
  142. package/dist/tui-v2/components/transcript/selectable-line.js +24 -0
  143. package/dist/tui-v2/components/transcript/selectable-line.js.map +1 -0
  144. package/dist/tui-v2/components/transcript/thinking-block.js +3 -1
  145. package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
  146. package/dist/tui-v2/components/transcript/tool-card.d.ts +5 -3
  147. package/dist/tui-v2/components/transcript/tool-card.js +39 -33
  148. package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
  149. package/dist/tui-v2/components/transcript/use-click-without-drag.js +4 -1
  150. package/dist/tui-v2/components/transcript/use-click-without-drag.js.map +1 -1
  151. package/dist/tui-v2/components/transcript/user-message.js +1 -1
  152. package/dist/tui-v2/components/transcript/user-message.js.map +1 -1
  153. package/dist/tui-v2/controllers/overlay-controller.d.ts +42 -0
  154. package/dist/tui-v2/controllers/overlay-controller.js +53 -0
  155. package/dist/tui-v2/controllers/overlay-controller.js.map +1 -1
  156. package/dist/tui-v2/rendering/batch-sections.d.ts +15 -4
  157. package/dist/tui-v2/rendering/batch-sections.js +157 -64
  158. package/dist/tui-v2/rendering/batch-sections.js.map +1 -1
  159. package/dist/tui-v2/rendering/format-help.js +1 -1
  160. package/dist/tui-v2/rendering/format-help.js.map +1 -1
  161. package/dist/tui-v2/rendering/open-tool-output.js +10 -7
  162. package/dist/tui-v2/rendering/open-tool-output.js.map +1 -1
  163. package/dist/tui-v2/rendering/pager-markdown.d.ts +9 -0
  164. package/dist/tui-v2/rendering/pager-markdown.js +46 -0
  165. package/dist/tui-v2/rendering/pager-markdown.js.map +1 -1
  166. package/dist/tui-v2/rendering/pager-view-policy.d.ts +6 -3
  167. package/dist/tui-v2/rendering/pager-view-policy.js +9 -13
  168. package/dist/tui-v2/rendering/pager-view-policy.js.map +1 -1
  169. package/dist/tui-v2/rendering/sanitize-display.js +3 -1
  170. package/dist/tui-v2/rendering/sanitize-display.js.map +1 -1
  171. package/dist/tui-v2/state/transcript-reducer.js +70 -30
  172. package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
  173. package/dist/types.d.ts +7 -0
  174. package/dist/version.generated.d.ts +2 -2
  175. package/dist/version.generated.js +2 -2
  176. package/package.json +1 -1
@@ -8,13 +8,16 @@ import { randomUUID } from "node:crypto";
8
8
  import { jobManager } from "../tools/jobs.js";
9
9
  import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
10
10
  import { getConfig } from "../store/config.js";
11
+ import { beginSessionWorkspace, getActiveSessionWorkspace, } from "../store/session-workspace.js";
11
12
  import { groqInputTokenBudget } from "../llm/groq.js";
12
13
  import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
13
14
  import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
14
15
  import { getToolDefinitions, getCompactToolDefinitions, PLAN_TOOL_NAMES, } from "../tools/definitions.js";
15
- import { appendAssistantWithTools, appendToolResult, assertValidToolProtocol, fillMissingToolResults, } from "./tool-history.js";
16
+ import { appendAssistantWithTools, appendToolResult, assertValidToolProtocol, fillMissingToolResults, repairToolProtocol, } from "./tool-history.js";
16
17
  import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
17
- import { compactMessagesWithSummary, estimateMessagesTokens, AUTO_COMPACT_TOKEN_BUDGET, COMPACTION_MEMORY_PREFIX, PLAN_IMPLEMENT_MEMORY_PREFIX, isCompactionMemoryMessage, } from "./context-manager.js";
18
+ import { compactMessagesWithSummary, estimateMessagesTokens, shouldApplyAutoCompact, COMPACTION_MEMORY_PREFIX, PLAN_IMPLEMENT_MEMORY_PREFIX, isCompactionMemoryMessage, } from "./context-manager.js";
19
+ import { buildContextBreakdown, contextBreakdownAuditPayload, } from "./context-breakdown.js";
20
+ import { autoCompactTriggerTokens, dedupeToolContextOutput, freeTierGuardNotices, getReliabilityPolicy, resolveStepMaxTokens, } from "./reliability-policy.js";
18
21
  import { auditLog } from "../store/logs.js";
19
22
  import { loadProjectContext } from "../store/project.js";
20
23
  import { loadScope, isScopeActive } from "../store/scope.js";
@@ -37,6 +40,7 @@ import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-outp
37
40
  import { renderPlanForTerminal, planContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
38
41
  import { absorbLooseWorkIntoLedger, applyDestinationCwd, canMarkTaskDone, hasLocalRuntimeProof, hasRemoteWorkProof, isDevServerCall, isEvidenceWorkTool, isFeatureImplementationCall, isPackageInstallCommand, isPlanPreflightTool, isPortListeningOutput, isReadOnlyReconTool, isRemoteActiveTestCall, isRemoteObservationTask, isRemoteReconToolCall, isRuntimeObservationTask, isScaffoldCreateCommand, isServerReadyOutput, ledgerFromTaskEvidence, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, taskEvidenceFromLedger, TOOL_ABORT_GRACE_MS, toolHardBudgetMs, toolStallBudgetMs, userAskedForFeatureApp, } from "./task-evidence.js";
39
42
  import { buildSessionStateBlock, inferNextHint, upsertSessionStateMessage, } from "./session-state.js";
43
+ import { buildContinueOrientation } from "./continue-orient.js";
40
44
  import { detectPackageManager } from "./workspace-orient.js";
41
45
  import { budgetRemaining, consumeBudget, createRecoveryBudgets, freestyleClaimsAppReady, looksLikeShallowPentestReport, recoveryForErrorDiagnosis, recoveryForFailedProbe, recoveryForFreshness, recoveryForMissingFeature, recoveryForMissingPlan, recoveryForNarration, recoveryForPrematureComplete, recoveryForRuntimeVerify, recoveryForShallowPentest, } from "./must-continue.js";
42
46
  import { scopeContextMessage } from "./scope-context.js";
@@ -84,9 +88,17 @@ export async function runAgentTurn(prompt, options = {}) {
84
88
  if (/\/output\b|open full output|Ctrl\+O or|\.clai\/outputs/i.test(cleaned)) {
85
89
  return;
86
90
  }
87
- if (cleaned.length > 64) {
88
- const short = cleaned.match(/^[\w./-]+/);
89
- cleaned = short ? short[0] : cleaned.slice(0, 61) + "…";
91
+ // API key rotation / retry lines need more room than tool-name chips.
92
+ const keyLine = /^(using |switching |⏳ |all .+ API keys)/i.test(cleaned);
93
+ const maxLen = keyLine ? 96 : 64;
94
+ if (cleaned.length > maxLen) {
95
+ if (keyLine) {
96
+ cleaned = cleaned.slice(0, maxLen - 1) + "…";
97
+ }
98
+ else {
99
+ const short = cleaned.match(/^[\w./-]+/);
100
+ cleaned = short ? short[0] : cleaned.slice(0, maxLen - 3) + "…";
101
+ }
90
102
  }
91
103
  emit({ type: "status", text: cleaned || "working" });
92
104
  if (writesDirectly)
@@ -277,6 +289,11 @@ export async function runAgentTurn(prompt, options = {}) {
277
289
  };
278
290
  let lastAnswer = "";
279
291
  const session = options.session ?? createSessionPolicy();
292
+ // One-shot CLI / tests that never entered TUI/REPL still need an isolated
293
+ // scratch+output workspace. No-op when a session already bound one.
294
+ if (!getActiveSessionWorkspace()) {
295
+ beginSessionWorkspace();
296
+ }
280
297
  // Active plan context
281
298
  // If this session already has a plan, inject it so the model keeps it in
282
299
  // context. When the user has approved it (via /implement) we instruct the
@@ -322,11 +339,16 @@ export async function runAgentTurn(prompt, options = {}) {
322
339
  setActiveProjectRootIfValid(discoveredProjects[0]);
323
340
  }
324
341
  const buildSystemContent = (native) => {
342
+ const reliability = getReliabilityPolicy();
325
343
  const sections = [
326
344
  (useCompactSystemPrompt
327
345
  ? renderCompactAgentSystemPrompt
328
346
  : renderAgentSystemPrompt)(toolNames.join(", "), {
329
347
  nativeTools: native,
348
+ // E6: slim native constitution when API tool schemas are attached.
349
+ ...(native
350
+ ? { slimNative: reliability.slimNativePrompt }
351
+ : {}),
330
352
  }),
331
353
  ];
332
354
  if (projectContext) {
@@ -380,6 +402,22 @@ export async function runAgentTurn(prompt, options = {}) {
380
402
  if (activePlan) {
381
403
  systemSections.push(planContextMessage(activePlan, session.planApproved.value));
382
404
  }
405
+ // Soft mid-work recovery (any domain): re-attach to jobs / open tasks /
406
+ // last tools after interrupt or "continue" — not a hard gate.
407
+ if (!informationalQuery && !idleOrSocialPrompt) {
408
+ const continueBrief = buildContinueOrientation({
409
+ prompt,
410
+ history: options.history,
411
+ plan: activePlan,
412
+ runningJobs: jobManager.getRunningJobs(),
413
+ recentJobs: jobManager.getRecentJobs(12),
414
+ informationalQuery,
415
+ idleOrSocial: idleOrSocialPrompt,
416
+ });
417
+ if (continueBrief) {
418
+ systemSections.push(continueBrief);
419
+ }
420
+ }
383
421
  if (isPlanMode) {
384
422
  systemSections.push(planModeDirective());
385
423
  }
@@ -699,9 +737,14 @@ export async function runAgentTurn(prompt, options = {}) {
699
737
  refreshSessionState = (plan) => {
700
738
  if (idleOrSocialPrompt || informationalQuery)
701
739
  return;
702
- if (!buildLikeTurn && !pentestLikeTurn && !plan && !activePlan)
703
- return;
740
+ const runningJobs = jobManager.getRunningJobs();
704
741
  const p = plan ?? activePlan;
742
+ if (!buildLikeTurn &&
743
+ !pentestLikeTurn &&
744
+ !p &&
745
+ runningJobs.length === 0) {
746
+ return;
747
+ }
705
748
  const root = getActiveProjectRoot() ?? p?.meta?.projectRoot;
706
749
  const pm = p?.meta?.packageManager ??
707
750
  (root ? detectPackageManager(root) : undefined);
@@ -712,6 +755,10 @@ export async function runAgentTurn(prompt, options = {}) {
712
755
  const done = p?.tasks
713
756
  .filter((t) => t.state === "done" || t.state === "skipped")
714
757
  .map((t) => t.id);
758
+ const jobBits = runningJobs.slice(0, 4).map((j) => {
759
+ const cmd = (j.commandDisplay || j.command).replace(/\s+/g, " ").trim();
760
+ return `${j.id} ${j.status} ${cmd.slice(0, 40)}`;
761
+ });
715
762
  const snap = {
716
763
  goal: p?.goal ?? prompt.slice(0, 160),
717
764
  projectRoot: root,
@@ -728,6 +775,9 @@ export async function runAgentTurn(prompt, options = {}) {
728
775
  serverProbedOk: sawLocalHttpProbe,
729
776
  lastProbeFailed: sawFailedLocalHttpProbe,
730
777
  lastOkTool: taskWorkLedger?.lastOkTool,
778
+ backgroundJobs: jobBits.length > 0
779
+ ? `${runningJobs.length} running: ${jobBits.join("; ")}`
780
+ : undefined,
731
781
  engagementNote: pentestSession
732
782
  ? "remote/security engagement — no local dev server as completion"
733
783
  : undefined,
@@ -1879,8 +1929,14 @@ export async function runAgentTurn(prompt, options = {}) {
1879
1929
  }
1880
1930
  return { ok: result.ok, call, result, contextOutput };
1881
1931
  }
1882
- const AUTO_COMPACT_KEEP_RECENT = 6;
1932
+ // Align with /compact default: small recency + dense memory (not keepRecent=6 fat tails).
1933
+ const AUTO_COMPACT_KEEP_RECENT = 2;
1883
1934
  let lastCompactionMsgCount = 0;
1935
+ /** E5: identical tool bodies within this turn → pointer instead of re-append. */
1936
+ const toolResultHashes = new Map();
1937
+ /** E4: consecutive free-tier stream failures this turn. */
1938
+ let freeTierConsecutiveFailures = 0;
1939
+ let freeTierLargeContextWarned = false;
1884
1940
  const summarizeForCompaction = async (summaryPrompt) => {
1885
1941
  const response = await completeWithProvider({
1886
1942
  provider,
@@ -1890,14 +1946,17 @@ export async function runAgentTurn(prompt, options = {}) {
1890
1946
  { role: "user", content: summaryPrompt },
1891
1947
  ],
1892
1948
  temperature: 0.1,
1893
- maxTokens: 4_096,
1949
+ // Prefer dense memory; enough room for findings without forcing a tiny stub.
1950
+ maxTokens: 2_048,
1894
1951
  signal: options.signal,
1895
1952
  });
1896
1953
  return response.text;
1897
1954
  };
1898
1955
  async function maybeAutoCompact(reason, force = false) {
1899
1956
  const beforeTokens = estimateMessagesTokens(messages);
1900
- if (!force && beforeTokens < AUTO_COMPACT_TOKEN_BUDGET)
1957
+ // E1: soft early compact (default 70k) while hard ceiling remains 100k.
1958
+ const compactTrigger = autoCompactTriggerTokens();
1959
+ if (!force && beforeTokens < compactTrigger)
1901
1960
  return;
1902
1961
  if (messages.length <= AUTO_COMPACT_KEEP_RECENT + 2)
1903
1962
  return;
@@ -1907,8 +1966,17 @@ export async function runAgentTurn(prompt, options = {}) {
1907
1966
  return;
1908
1967
  try {
1909
1968
  const result = await compactMessagesWithSummary(messages, summarizeForCompaction, { budgetTokens: 0, keepRecent: AUTO_COMPACT_KEEP_RECENT });
1910
- if (!result.summarized || result.afterTokens >= beforeTokens)
1969
+ const summaryBody = result.messages.find((m) => isCompactionMemoryMessage(m))?.content ??
1970
+ "";
1971
+ if (!shouldApplyAutoCompact({
1972
+ summarized: result.summarized,
1973
+ summaryBody,
1974
+ beforeTokens: result.beforeTokens,
1975
+ afterTokens: result.afterTokens,
1976
+ afterMessages: result.messages,
1977
+ })) {
1911
1978
  return;
1979
+ }
1912
1980
  messages.splice(0, messages.length, ...result.messages);
1913
1981
  loopGuard.resetReadOnly();
1914
1982
  // Token stats BEFORE plan re-injection so the reduction is accurate.
@@ -2017,159 +2085,222 @@ export async function runAgentTurn(prompt, options = {}) {
2017
2085
  ({ dialect: toolDialect, native: nativeToolsActive } =
2018
2086
  resolveNativeTools(provider, model));
2019
2087
  if (messages[0]?.role === "system") {
2020
- messages[0] = {
2021
- role: "system",
2022
- content: composeCurrentSystemPrompt(nativeToolsActive),
2023
- };
2088
+ // Recompose only when content actually changes (hour-stable env clock
2089
+ // keeps the constitution prefix identical across steps, which helps
2090
+ // provider prompt caching and avoids needless object churn).
2091
+ const nextSystem = composeCurrentSystemPrompt(nativeToolsActive);
2092
+ if (messages[0].content !== nextSystem) {
2093
+ messages[0] = {
2094
+ role: "system",
2095
+ content: nextSystem,
2096
+ };
2097
+ }
2024
2098
  }
2025
2099
  const turnTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
2026
2100
  toolsAttached = Boolean(turnTools?.length);
2101
+ const contextBreakdown = buildContextBreakdown(messages, toolsAttached ? turnTools : undefined);
2102
+ // E4: advisory only — never blocks free-tier users.
2103
+ if (!freeTierLargeContextWarned) {
2104
+ const notices = freeTierGuardNotices({
2105
+ provider,
2106
+ estimatedInputTokens: contextBreakdown.estimatedTotalTokens,
2107
+ consecutiveFailures: freeTierConsecutiveFailures,
2108
+ });
2109
+ for (const notice of notices) {
2110
+ if (notice.includes("Large context")) {
2111
+ freeTierLargeContextWarned = true;
2112
+ }
2113
+ writeNotice("info", notice, chalk.dim(` ℹ ${notice}\n`));
2114
+ }
2115
+ }
2027
2116
  await auditLog("agent.turn", {
2028
2117
  provider,
2029
2118
  model,
2030
2119
  tool_protocol: toolsAttached ? "native" : "text",
2031
2120
  dialect: toolDialect,
2032
2121
  step,
2122
+ // Metadata-only composition metrics (no prompt/tool text).
2123
+ ...contextBreakdownAuditPayload(contextBreakdown),
2124
+ compactTriggerTokens: autoCompactTriggerTokens(),
2125
+ maxTokensBudget: resolveStepMaxTokens({
2126
+ nativeToolsActive,
2127
+ toolsAttached,
2128
+ recoveryNudge: retryWithoutThinking,
2129
+ }),
2033
2130
  });
2131
+ // Resume / mid-turn abort can leave orphan tool rows or a user
2132
+ // "continue" before tool results. Heal first so multi-key retry and
2133
+ // history reloads don't hard-fail on protocol asserts.
2134
+ // Heal once per step if needed; silent (no toast) — placeholders are
2135
+ // ok=true so the model doesn't thrash on fake exit=130 failures.
2136
+ repairToolProtocol(messages);
2034
2137
  assertValidToolProtocol(messages);
2035
- completion = await streamWithProvider({
2036
- provider,
2037
- model,
2038
- allowModelFallback: false,
2039
- messages,
2040
- temperature: /minimax-m3/i.test(model) ? 1.0 : 0.2,
2041
- maxTokens: 32_768,
2042
- signal: options.signal,
2043
- thinking: retryWithoutThinking
2044
- ? { ...config.thinking, enabled: false, effort: "low" }
2045
- : config.thinking,
2046
- ...(toolsAttached
2047
- ? {
2048
- tools: turnTools,
2049
- toolChoice: freshWebSearchRequired && !sawFreshWebSearch
2050
- ? { type: "function", name: "web.search" }
2051
- : "auto",
2052
- parallelToolCalls: true,
2053
- // P2-3: emit tool cards as soon as the function name arrives.
2054
- onToolCallDelta: (delta) => {
2055
- if (!delta.name)
2056
- return;
2057
- const name = fromWireName(delta.name) ?? delta.name;
2058
- const existing = deferredToolCalls[delta.index];
2059
- if (existing) {
2060
- if (delta.argumentsBytes &&
2061
- delta.argumentsBytes >= 4096 &&
2062
- !writesDirectly) {
2063
- emit({
2064
- type: "status",
2065
- text: `${name} (${Math.round(delta.argumentsBytes / 1024)}KB args)`,
2138
+ // E3: adaptive completion budget (still large enough for writes).
2139
+ const stepMaxTokens = resolveStepMaxTokens({
2140
+ nativeToolsActive,
2141
+ toolsAttached,
2142
+ recoveryNudge: retryWithoutThinking,
2143
+ });
2144
+ try {
2145
+ completion = await streamWithProvider({
2146
+ provider,
2147
+ model,
2148
+ allowModelFallback: false,
2149
+ messages,
2150
+ temperature: /minimax-m3/i.test(model) ? 1.0 : 0.2,
2151
+ maxTokens: stepMaxTokens,
2152
+ signal: options.signal,
2153
+ thinking: retryWithoutThinking
2154
+ ? { ...config.thinking, enabled: false, effort: "low" }
2155
+ : config.thinking,
2156
+ ...(toolsAttached
2157
+ ? {
2158
+ tools: turnTools,
2159
+ toolChoice: freshWebSearchRequired && !sawFreshWebSearch
2160
+ ? { type: "function", name: "web.search" }
2161
+ : "auto",
2162
+ parallelToolCalls: true,
2163
+ // P2-3: emit tool cards as soon as the function name arrives.
2164
+ onToolCallDelta: (delta) => {
2165
+ if (!delta.name)
2166
+ return;
2167
+ const name = fromWireName(delta.name) ?? delta.name;
2168
+ const existing = deferredToolCalls[delta.index];
2169
+ if (existing) {
2170
+ if (delta.argumentsBytes &&
2171
+ delta.argumentsBytes >= 4096 &&
2172
+ !writesDirectly) {
2173
+ emit({
2174
+ type: "status",
2175
+ text: `${name} (${Math.round(delta.argumentsBytes / 1024)}KB args)`,
2176
+ });
2177
+ }
2178
+ return;
2179
+ }
2180
+ // Ensure slots are dense so index maps to deferredToolCalls[i].
2181
+ while (deferredToolCalls.length < delta.index) {
2182
+ deferredToolCalls.push({
2183
+ eventId: `tool-${++nextToolEventId}`,
2184
+ call: { name: "…", args: {} },
2185
+ rendered: "",
2066
2186
  });
2067
2187
  }
2068
- return;
2188
+ const call = normalizeToolCall({
2189
+ name,
2190
+ args: {},
2191
+ });
2192
+ const eventId = `tool-${++nextToolEventId}`;
2193
+ callIds.push(eventId);
2194
+ alreadyPrintedIds.add(eventId);
2195
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
2196
+ chalk.gray(` ${formatToolArgs(call)}`);
2197
+ const entry = {
2198
+ eventId,
2199
+ call,
2200
+ rendered: styleToolChatter(call, toolCallLine) + "\n",
2201
+ };
2202
+ if (deferredToolCalls.length === delta.index) {
2203
+ deferredToolCalls.push(entry);
2204
+ }
2205
+ else {
2206
+ deferredToolCalls[delta.index] = entry;
2207
+ }
2208
+ streamedCallsCount = Math.max(streamedCallsCount, deferredToolCalls.length);
2209
+ if (!writesDirectly) {
2210
+ emit({ type: "status", text: call.name });
2211
+ }
2212
+ else {
2213
+ spinner.stop();
2214
+ spinner = startThinkingSpinner(`tool ${call.name}…`, options.signal);
2215
+ }
2216
+ },
2217
+ }
2218
+ : {}),
2219
+ }, (token) => {
2220
+ deltaParser?.push(token);
2221
+ generatedTokens += 1;
2222
+ accumulatedText += token;
2223
+ // Early UI cards from text fences only when native tools are off
2224
+ // (native args stream as structured deltas, not prose).
2225
+ if (!toolsAttached) {
2226
+ const parsedCalls = parseAllToolCalls(accumulatedText);
2227
+ if (parsedCalls.length > streamedCallsCount) {
2228
+ if (writesDirectly) {
2229
+ spinner.stop();
2069
2230
  }
2070
- // Ensure slots are dense so index maps to deferredToolCalls[i].
2071
- while (deferredToolCalls.length < delta.index) {
2231
+ while (streamedCallsCount < parsedCalls.length) {
2232
+ const call = parsedCalls[streamedCallsCount];
2233
+ const eventId = `tool-${++nextToolEventId}`;
2234
+ callIds.push(eventId);
2235
+ alreadyPrintedIds.add(eventId);
2236
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
2237
+ chalk.gray(` ${formatToolArgs(call)}`);
2072
2238
  deferredToolCalls.push({
2073
- eventId: `tool-${++nextToolEventId}`,
2074
- call: { name: "…", args: {} },
2075
- rendered: "",
2239
+ eventId,
2240
+ call,
2241
+ rendered: styleToolChatter(call, toolCallLine) + "\n",
2076
2242
  });
2243
+ if (!writesDirectly) {
2244
+ emit({ type: "status", text: call.name });
2245
+ }
2246
+ streamedCallsCount += 1;
2077
2247
  }
2078
- const call = normalizeToolCall({
2079
- name,
2080
- args: {},
2081
- });
2082
- const eventId = `tool-${++nextToolEventId}`;
2083
- callIds.push(eventId);
2084
- alreadyPrintedIds.add(eventId);
2085
- const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
2086
- chalk.gray(` ${formatToolArgs(call)}`);
2087
- const entry = {
2088
- eventId,
2089
- call,
2090
- rendered: styleToolChatter(call, toolCallLine) + "\n",
2091
- };
2092
- if (deferredToolCalls.length === delta.index) {
2093
- deferredToolCalls.push(entry);
2094
- }
2095
- else {
2096
- deferredToolCalls[delta.index] = entry;
2097
- }
2098
- streamedCallsCount = Math.max(streamedCallsCount, deferredToolCalls.length);
2099
- if (!writesDirectly) {
2100
- emit({ type: "status", text: call.name });
2101
- }
2102
- else {
2103
- spinner.stop();
2104
- spinner = startThinkingSpinner(`tool ${call.name}…`, options.signal);
2105
- }
2106
- },
2107
- }
2108
- : {}),
2109
- }, (token) => {
2110
- deltaParser?.push(token);
2111
- generatedTokens += 1;
2112
- accumulatedText += token;
2113
- // Early UI cards from text fences only when native tools are off
2114
- // (native args stream as structured deltas, not prose).
2115
- if (!toolsAttached) {
2116
- const parsedCalls = parseAllToolCalls(accumulatedText);
2117
- if (parsedCalls.length > streamedCallsCount) {
2118
- if (writesDirectly) {
2119
- spinner.stop();
2120
- }
2121
- while (streamedCallsCount < parsedCalls.length) {
2122
- const call = parsedCalls[streamedCallsCount];
2123
- const eventId = `tool-${++nextToolEventId}`;
2124
- callIds.push(eventId);
2125
- alreadyPrintedIds.add(eventId);
2126
- const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
2127
- chalk.gray(` ${formatToolArgs(call)}`);
2128
- deferredToolCalls.push({
2129
- eventId,
2130
- call,
2131
- rendered: styleToolChatter(call, toolCallLine) + "\n",
2132
- });
2133
- if (!writesDirectly) {
2134
- emit({ type: "status", text: call.name });
2248
+ if (writesDirectly) {
2249
+ spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
2135
2250
  }
2136
- streamedCallsCount += 1;
2137
2251
  }
2138
- if (writesDirectly) {
2139
- spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
2252
+ }
2253
+ if (!sawReasoning && /<think/i.test(token)) {
2254
+ sawReasoning = true;
2255
+ inThinking = true;
2256
+ spinner.setLabel("thinking");
2257
+ if (!writesDirectly)
2258
+ emit({ type: "status", text: "thinking" });
2259
+ }
2260
+ if (/<\/think>/i.test(token)) {
2261
+ inThinking = false;
2262
+ spinner.setLabel("generating response (0 tokens)");
2263
+ generatedTokens = 0;
2264
+ }
2265
+ if (inThinking) {
2266
+ const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
2267
+ if (cleaned) {
2268
+ spinner.pushPreview(cleaned);
2269
+ const approx = cleaned.split(/\s+/).filter(Boolean).length;
2270
+ if (approx > 0)
2271
+ spinner.bumpReasoning(approx);
2140
2272
  }
2141
2273
  }
2142
- }
2143
- if (!sawReasoning && /<think/i.test(token)) {
2144
- sawReasoning = true;
2145
- inThinking = true;
2146
- spinner.setLabel("thinking");
2147
- if (!writesDirectly)
2148
- emit({ type: "status", text: "thinking" });
2149
- }
2150
- if (/<\/think>/i.test(token)) {
2151
- inThinking = false;
2152
- spinner.setLabel("generating response (0 tokens)");
2153
- generatedTokens = 0;
2154
- }
2155
- if (inThinking) {
2156
- const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
2157
- if (cleaned) {
2158
- spinner.pushPreview(cleaned);
2159
- const approx = cleaned.split(/\s+/).filter(Boolean).length;
2160
- if (approx > 0)
2161
- spinner.bumpReasoning(approx);
2274
+ else {
2275
+ if (generatedTokens % 10 === 0) {
2276
+ spinner.setLabel(`generating response (${generatedTokens} tokens)`);
2277
+ }
2162
2278
  }
2163
- }
2164
- else {
2165
- if (generatedTokens % 10 === 0) {
2166
- spinner.setLabel(`generating response (${generatedTokens} tokens)`);
2279
+ }, (status) => {
2280
+ spinner.stop();
2281
+ writeStatus(status, chalk.dim(status));
2282
+ // Toast only on key *switch* after a failure — never on sticky
2283
+ // "using" or retry countdown ticks (those stay in composer status).
2284
+ if (/^switching /i.test(status.trim())) {
2285
+ writeNotice("warn", status.trim(), chalk.yellow(` ${status.trim()}\n`));
2167
2286
  }
2287
+ });
2288
+ freeTierConsecutiveFailures = 0;
2289
+ }
2290
+ catch (streamError) {
2291
+ // E4: track free-tier failures for advisory notices (never blocks).
2292
+ freeTierConsecutiveFailures += 1;
2293
+ for (const notice of freeTierGuardNotices({
2294
+ provider,
2295
+ estimatedInputTokens: contextBreakdown.estimatedTotalTokens,
2296
+ consecutiveFailures: freeTierConsecutiveFailures,
2297
+ })) {
2298
+ if (notice.includes("Large context"))
2299
+ continue; // already shown above
2300
+ writeNotice("warn", notice, chalk.yellow(` ⚠ ${notice}\n`));
2168
2301
  }
2169
- }, (status) => {
2170
- spinner.stop();
2171
- writeStatus(status, chalk.dim(status));
2172
- });
2302
+ throw streamError;
2303
+ }
2173
2304
  }
2174
2305
  finally {
2175
2306
  // Always clear the spinner — abort, network error, or success.
@@ -2937,9 +3068,17 @@ export async function runAgentTurn(prompt, options = {}) {
2937
3068
  planCreatedThisTurn = true;
2938
3069
  }
2939
3070
  productiveSteps += 1;
3071
+ // E5: collapse identical large tool bodies within this turn to a pointer.
3072
+ const deduped = dedupeToolContextOutput({
3073
+ content: res.contextOutput,
3074
+ toolName: res.call.name,
3075
+ artifactPath: res.result.outputPath,
3076
+ seenHashes: toolResultHashes,
3077
+ });
3078
+ const contextForHistory = deduped.content;
2940
3079
  // Soft plan-mode note on tool payloads only (never a user message).
2941
3080
  // Stop once a plan with tasks exists so we don't nag after plan.create.
2942
- let toolContent = `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`;
3081
+ let toolContent = `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${contextForHistory}`;
2943
3082
  const reminded = maybeAppendPlanModeReminder(toolContent, {
2944
3083
  isPlanMode,
2945
3084
  planApproved: session.planApproved.value,