@iowarp/clio-coder 0.3.4 → 0.3.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 (265) hide show
  1. package/CHANGELOG.md +37 -2
  2. package/CONTRIBUTING.md +6 -6
  3. package/README.md +2 -2
  4. package/dist/{acp-S5R4RR5B.js → acp-2BEHC4DL.js} +4 -4
  5. package/dist/{agents-P6DMMVZY.js → agents-LNNFTM53.js} +13 -11
  6. package/dist/assets/codewiki.json +1 -1
  7. package/dist/{auth-2XCZLPKS.js → auth-KXXFI2VS.js} +6 -6
  8. package/dist/{chunk-YCWGATWI.js → chunk-24I7BN55.js} +2 -2
  9. package/dist/{chunk-EKMEHE4H.js → chunk-33YXPOE3.js} +2 -3
  10. package/dist/chunk-3BPUFZDL.js +37 -0
  11. package/dist/{chunk-WPQLXFOZ.js → chunk-43AOLP7E.js} +2 -2
  12. package/dist/{chunk-N4CZJQRK.js → chunk-5JGRAMKL.js} +4 -4
  13. package/dist/{chunk-BRXQQJFP.js → chunk-6US73PDB.js} +568 -47
  14. package/dist/{chunk-K6WL7QZT.js → chunk-6XXKFVSN.js} +2 -2
  15. package/dist/{chunk-QQK64KLB.js → chunk-CJUB2JJ2.js} +138 -20
  16. package/dist/{chunk-HV5X7OR2.js → chunk-CKXWIANG.js} +12 -12
  17. package/dist/{chunk-UZHIZC5S.js → chunk-CYQKWTG3.js} +61 -76
  18. package/dist/{chunk-QWU7ZBO7.js → chunk-DJVECN66.js} +204 -45
  19. package/dist/{chunk-ZWMF7253.js → chunk-E2ER4LJF.js} +304 -9
  20. package/dist/{chunk-7RXG6QRZ.js → chunk-EKY57CSP.js} +2 -75
  21. package/dist/{chunk-EDRHSCIE.js → chunk-EYPA3EGJ.js} +10 -2
  22. package/dist/{chunk-TTNYS3EA.js → chunk-G7MUEIGA.js} +1 -1
  23. package/dist/{chunk-BPGS2WCQ.js → chunk-GEYXPTRF.js} +2 -1
  24. package/dist/{chunk-BEY543CS.js → chunk-GOXNB3AO.js} +5 -2
  25. package/dist/{chunk-G4BMMOKF.js → chunk-HVDIIIQW.js} +2 -2
  26. package/dist/chunk-HWUFFB6L.js +83 -0
  27. package/dist/{chunk-35MKKU5R.js → chunk-K7T3E2SR.js} +15 -8
  28. package/dist/{chunk-VAWWTKDP.js → chunk-KHSFENX2.js} +2 -2
  29. package/dist/chunk-LCGCVYZ4.js +57 -0
  30. package/dist/{chunk-X6COSD2O.js → chunk-LYF7OHWH.js} +41 -14
  31. package/dist/{chunk-POHLU5DW.js → chunk-M6L6IDJG.js} +3 -3
  32. package/dist/{chunk-X4RCMKVQ.js → chunk-NDINPTJ4.js} +2 -2
  33. package/dist/{chunk-5M54SPOL.js → chunk-ODFEOB4F.js} +161 -5
  34. package/dist/{chunk-3JLKSKD7.js → chunk-OH3TOQTB.js} +5 -1
  35. package/dist/{chunk-MEQ45TQ4.js → chunk-PBTHKCPN.js} +18 -4
  36. package/dist/{chunk-ED4KHGC3.js → chunk-PPAMZ32Z.js} +9 -2
  37. package/dist/{chunk-QQL5RT5M.js → chunk-QM3F2GKX.js} +94 -36
  38. package/dist/{chunk-A2GZF7DC.js → chunk-QNQHSOLF.js} +4 -4
  39. package/dist/{chunk-KRPY7NTG.js → chunk-R46L2BIR.js} +3 -3
  40. package/dist/{chunk-BP4OYD6A.js → chunk-RY3LY4J5.js} +20 -2
  41. package/dist/{chunk-34475P3I.js → chunk-TSHXZTOQ.js} +5 -4
  42. package/dist/{chunk-VJWL6YS5.js → chunk-UUVG37B4.js} +2 -2
  43. package/dist/{chunk-2TZWSW76.js → chunk-WHGPSPT5.js} +2 -2
  44. package/dist/{chunk-TW3WDMVS.js → chunk-WHJYKASB.js} +2 -2
  45. package/dist/{chunk-YHZX5GEU.js → chunk-XAKHZX5N.js} +2 -2
  46. package/dist/{chunk-HXG4IURW.js → chunk-XE2VEJHX.js} +2 -2
  47. package/dist/{chunk-3HZ5RWN2.js → chunk-XF5N4U5A.js} +7 -6
  48. package/dist/{chunk-ZYKPLLNQ.js → chunk-XXQNGV4M.js} +590 -32
  49. package/dist/{chunk-4JUF2NNX.js → chunk-XYDYPRZI.js} +4 -4
  50. package/dist/{chunk-VMNQ6OZA.js → chunk-ZRGEBJ4T.js} +971 -794
  51. package/dist/{chunk-2LZI5CAG.js → chunk-ZXF4XRKW.js} +75 -33
  52. package/dist/{chunk-VSNATDE6.js → chunk-ZZMN5OM4.js} +2 -2
  53. package/dist/cli/index.js +31 -31
  54. package/dist/{clio-J5JIOIDS.js → clio-M2KGYUFZ.js} +2 -2
  55. package/dist/{code-nav-AXCXSBHX.js → code-nav-GQNL7XA6.js} +5 -5
  56. package/dist/codewiki/build-worker.js +4 -4
  57. package/dist/{components-KELWS457.js → components-5TTYYX6G.js} +3 -3
  58. package/dist/{config-OEBMIN2U.js → config-XUUYQIWO.js} +27 -25
  59. package/dist/{configure-PUQOSIXQ.js → configure-IHJ7YOMV.js} +7 -7
  60. package/dist/{context-URSXPBCK.js → context-74JLXAWD.js} +12 -12
  61. package/dist/{context-MGSE4Z2T.js → context-75MIWW3U.js} +24 -22
  62. package/dist/{context-EKDCKUUZ.js → context-ZQ7SIFJV.js} +8 -7
  63. package/dist/{context-clear-KDAJRNUK.js → context-clear-GYKWNUML.js} +24 -22
  64. package/dist/{context-index-BZ4UYMTC.js → context-index-SSR5ECNE.js} +3 -3
  65. package/dist/{context-working-set-SBKMPPI2.js → context-working-set-UX5KEP4J.js} +11 -10
  66. package/dist/{dispatch-runner-MSWN72NK.js → dispatch-runner-GIJBHNFL.js} +21 -20
  67. package/dist/{docs-2C2LTVT2.js → docs-6FZSCG5B.js} +3 -3
  68. package/dist/{doctor-7BSE27PJ.js → doctor-SVJ5BZCW.js} +4 -4
  69. package/dist/{eval-IZGDOO4H.js → eval-CG6LLBLD.js} +47 -232
  70. package/dist/{evidence-SR7WXB5B.js → evidence-ZYFIEN42.js} +19 -18
  71. package/dist/{evolve-K7VE2CBX.js → evolve-QGEXEMDW.js} +19 -18
  72. package/dist/{extensions-QVDOHDGJ.js → extensions-ADGNCJJD.js} +3 -3
  73. package/dist/{fleet-7XMJNQNF.js → fleet-S5R4ZOQY.js} +49 -30
  74. package/dist/{fleet-preflight-AQNAH644.js → fleet-preflight-BHSNPBMH.js} +2 -2
  75. package/dist/{init-JGNPAYXT.js → init-5DRU55YR.js} +31 -29
  76. package/dist/memory-7YKKR6UC.js +467 -0
  77. package/dist/{models-ZMMLFJNN.js → models-ZPOLRU2C.js} +10 -10
  78. package/dist/{monitor-2F3T5KHP.js → monitor-US5F5YGZ.js} +33 -18
  79. package/dist/{orchestrator-ORHT43JB.js → orchestrator-E2AL4T5N.js} +1092 -659
  80. package/dist/{paths-UXLN5YYZ.js → paths-E7KYAQWE.js} +3 -3
  81. package/dist/{reset-NXGTYNUO.js → reset-KZ652EK6.js} +3 -3
  82. package/dist/{run-RF4WJGMT.js → run-SRNBKDWD.js} +52 -40
  83. package/dist/{share-UT3W6E4M.js → share-CGZE33UP.js} +3 -3
  84. package/dist/{skills-PSACKC5Q.js → skills-S2X4DLY5.js} +4 -4
  85. package/dist/{skills-eval-WJSI55RZ.js → skills-eval-W2GGIC4R.js} +19 -18
  86. package/dist/{targets-PIIRAOYS.js → targets-54SWINWB.js} +14 -12
  87. package/dist/{terminal-lease-ULWXWNVY.js → terminal-lease-SAIF2OGY.js} +5 -4
  88. package/dist/{uninstall-FZCQCDKC.js → uninstall-BVLWXKBT.js} +3 -3
  89. package/dist/{upgrade-346TZ6AV.js → upgrade-JKAR27XC.js} +8 -8
  90. package/dist/{usage-6KKXR32N.js → usage-MSAWCLX4.js} +60 -27
  91. package/dist/{verifiers-4UUM6TEE.js → verifiers-NCBTHHN2.js} +60 -54
  92. package/dist/{wiki-generate-7STOCIFZ.js → wiki-generate-GUSOQ6ZP.js} +30 -28
  93. package/dist/worker/entry.js +69 -58
  94. package/dist/{workspace-G4ZWUIPR.js → workspace-ZJ6BFM3Q.js} +4 -4
  95. package/docs/README.md +3 -3
  96. package/docs/acp.md +1 -1
  97. package/docs/alcf-provider.md +1 -1
  98. package/docs/architecture.md +2 -2
  99. package/docs/artifact-placement.md +1 -2
  100. package/docs/artifact-versions.md +1 -1
  101. package/docs/built-in-agents.md +1 -1
  102. package/docs/capacity-and-scheduling.md +1 -1
  103. package/docs/commands-and-modes.md +9 -7
  104. package/docs/configuration-and-targets.md +12 -1
  105. package/docs/context-engine.md +4 -2
  106. package/docs/context-working-set.md +4 -4
  107. package/docs/development-pipeline.md +1 -1
  108. package/docs/documentation-coverage.md +3 -3
  109. package/docs/documentation-guide.md +2 -2
  110. package/docs/eval-runner.md +1 -1
  111. package/docs/evals-internal.md +4 -45
  112. package/docs/evidence-and-memory.md +67 -7
  113. package/docs/evolution.md +1 -1
  114. package/docs/exit-codes-and-output.md +1 -1
  115. package/docs/extensions-and-sharing.md +2 -2
  116. package/docs/fleet-dispatch.md +28 -2
  117. package/docs/installation-and-lifecycle.md +2 -2
  118. package/docs/middleware-and-components.md +19 -2
  119. package/docs/model-catalog.md +1 -1
  120. package/docs/observability.md +3 -3
  121. package/docs/proactive-memory.md +26 -16
  122. package/docs/prompt-envelope-and-tools.md +4 -2
  123. package/docs/provider-adapter-cookbook.md +1 -1
  124. package/docs/release-cut-checklist.md +38 -35
  125. package/docs/safety-model.md +29 -7
  126. package/docs/scientific-validation.md +3 -3
  127. package/docs/session-lifecycle.md +1 -1
  128. package/docs/skills-marketplace.md +1 -1
  129. package/docs/tool-usage.md +2 -2
  130. package/docs/trace-store.md +1 -1
  131. package/docs/troubleshooting.md +1 -1
  132. package/docs/tui-design.md +38 -4
  133. package/docs/worker-dispatch-mechanics.md +1 -1
  134. package/package.json +7 -4
  135. package/src/cli/agents.ts +2 -3
  136. package/src/cli/argv.ts +14 -1
  137. package/src/cli/fleet.ts +15 -0
  138. package/src/cli/index.ts +1 -1
  139. package/src/cli/memory.ts +272 -10
  140. package/src/cli/modes/json-stream.ts +2 -2
  141. package/src/cli/modes/print.ts +12 -1
  142. package/src/cli/run.ts +22 -2
  143. package/src/cli/targets.ts +12 -3
  144. package/src/cli/usage.ts +55 -7
  145. package/src/core/bus-events.ts +3 -0
  146. package/src/core/response-model-id.ts +134 -0
  147. package/src/core/toml.ts +62 -0
  148. package/src/core/workspace-files.ts +0 -1
  149. package/src/domains/agents/builtins/architect.md +1 -1
  150. package/src/domains/agents/catalog.ts +5 -4
  151. package/src/domains/agents/recipe.ts +54 -14
  152. package/src/domains/agents/result-contract.ts +7 -4
  153. package/src/domains/context/bootstrap.ts +36 -27
  154. package/src/domains/context/project-metadata.ts +19 -63
  155. package/src/domains/context/prompt-context.ts +8 -0
  156. package/src/domains/context/working-set/policies/index.ts +3 -4
  157. package/src/domains/dispatch/budget-envelope.ts +396 -0
  158. package/src/domains/dispatch/contract.ts +2 -0
  159. package/src/domains/dispatch/extension.ts +81 -27
  160. package/src/domains/dispatch/orphan-recovery.ts +1 -0
  161. package/src/domains/dispatch/receipt-integrity.ts +4 -0
  162. package/src/domains/dispatch/state.ts +1 -0
  163. package/src/domains/dispatch/types.ts +10 -3
  164. package/src/domains/dispatch/validation.ts +14 -0
  165. package/src/domains/dispatch/worker-spawn.ts +14 -3
  166. package/src/domains/eval/metrics/evidence.ts +0 -116
  167. package/src/domains/eval/metrics/invariants.ts +1 -1
  168. package/src/domains/eval/runners/clio-run.ts +1 -10
  169. package/src/domains/eval/runners/external-command.ts +2 -29
  170. package/src/domains/eval/schema/suite.ts +0 -7
  171. package/src/domains/eval/suites/run.ts +1 -7
  172. package/src/domains/memory/index.ts +22 -0
  173. package/src/domains/memory/operations.ts +58 -1
  174. package/src/domains/memory/promotion.ts +281 -0
  175. package/src/domains/memory/prompt-section.ts +25 -5
  176. package/src/domains/memory/proposal.ts +51 -7
  177. package/src/domains/memory/task-bank.ts +3 -2
  178. package/src/domains/memory/task-memory-handoff.ts +181 -24
  179. package/src/domains/memory/task-memory-policy.ts +3 -1
  180. package/src/domains/memory/types.ts +37 -0
  181. package/src/domains/memory/validate.ts +178 -0
  182. package/src/domains/middleware/memory-intervention.ts +35 -25
  183. package/src/domains/middleware/runtime.ts +6 -0
  184. package/src/domains/middleware/skills-reminder.ts +19 -4
  185. package/src/domains/middleware/stalled-turn.ts +43 -1
  186. package/src/domains/middleware/types.ts +10 -0
  187. package/src/domains/observability/contract.ts +6 -1
  188. package/src/domains/observability/cost.ts +20 -4
  189. package/src/domains/observability/extension.ts +2 -2
  190. package/src/domains/providers/index.ts +3 -0
  191. package/src/domains/providers/model-discovery.ts +9 -0
  192. package/src/domains/providers/runtime-resolution.ts +38 -1
  193. package/src/domains/providers/runtimes/common/probe-helpers.ts +97 -16
  194. package/src/domains/providers/types/context-window-slots.ts +18 -0
  195. package/src/domains/providers/types/runtime-descriptor.ts +3 -1
  196. package/src/domains/safety/call-target.ts +211 -14
  197. package/src/domains/safety/decision-presentation.ts +268 -0
  198. package/src/domains/safety/redaction.ts +73 -0
  199. package/src/domains/session/context-ledger.ts +10 -1
  200. package/src/domains/session/decision-board.ts +4 -0
  201. package/src/domains/session/entries.ts +3 -0
  202. package/src/domains/session/history.ts +68 -19
  203. package/src/domains/session/usage.ts +24 -7
  204. package/src/engine/acp/event-mapper.ts +7 -0
  205. package/src/engine/acp/server.ts +29 -2
  206. package/src/engine/apis/lmstudio.ts +25 -4
  207. package/src/engine/apis/openai-completions.ts +147 -22
  208. package/src/engine/claude/sdk-runtime.ts +8 -2
  209. package/src/engine/claude/tool-safety.ts +13 -0
  210. package/src/engine/loop-guard.ts +27 -3
  211. package/src/engine/worker-events.ts +4 -3
  212. package/src/engine/worker-runtime.ts +59 -54
  213. package/src/entry/orchestrator.ts +18 -1
  214. package/src/interactive/chat-loop-messages.ts +22 -0
  215. package/src/interactive/chat-loop.ts +13 -0
  216. package/src/interactive/chat-renderer.ts +19 -3
  217. package/src/interactive/clio-editor.ts +44 -7
  218. package/src/interactive/context-overlay.ts +43 -5
  219. package/src/interactive/cost-overlay.ts +39 -8
  220. package/src/interactive/dispatch-board.ts +212 -35
  221. package/src/interactive/footer/widgets.ts +13 -0
  222. package/src/interactive/interactive-application.ts +6 -1
  223. package/src/interactive/interactive-input-runtime.ts +11 -1
  224. package/src/interactive/interactive-presentation.ts +11 -1
  225. package/src/interactive/memory-overlay.ts +89 -4
  226. package/src/interactive/overlay-ask-user-lifecycle.ts +1 -1
  227. package/src/interactive/overlay-frame.ts +5 -2
  228. package/src/interactive/overlay-general-openers.ts +40 -1
  229. package/src/interactive/overlay-key-routing.ts +41 -1
  230. package/src/interactive/overlay-lifecycle.ts +11 -4
  231. package/src/interactive/overlay-permission-lifecycle.ts +23 -8
  232. package/src/interactive/overlay-transitions.ts +11 -0
  233. package/src/interactive/overlays/ask-user.ts +74 -30
  234. package/src/interactive/overlays/decisions.ts +3 -1
  235. package/src/interactive/permission-hint.ts +35 -0
  236. package/src/interactive/permission-overlay.ts +95 -45
  237. package/src/interactive/renderers/tool-execution.ts +19 -49
  238. package/src/interactive/session-last-turn.ts +8 -1
  239. package/src/interactive/session-usage-reseed.ts +36 -10
  240. package/src/interactive/slash-commands.ts +2 -2
  241. package/src/interactive/status/summary.ts +5 -0
  242. package/src/interactive/status/types.ts +5 -0
  243. package/src/interactive/terminal-lease.ts +1 -0
  244. package/src/interactive/turn-context.ts +96 -23
  245. package/src/interactive/turn-middleware.ts +1 -0
  246. package/src/interactive/turn-runtime.ts +37 -8
  247. package/src/interactive/turn-state.ts +3 -0
  248. package/src/interactive/worker-progress.ts +440 -0
  249. package/src/interactive/worker-stream.ts +51 -110
  250. package/src/tools/agent-tools.ts +28 -3
  251. package/src/tools/ask-user.ts +21 -1
  252. package/src/tools/context/index.ts +2 -2
  253. package/src/tools/dispatch-arguments.ts +8 -0
  254. package/src/tools/dispatch-event-text.ts +19 -0
  255. package/src/tools/dispatch.ts +24 -1
  256. package/src/tools/monitor.ts +15 -0
  257. package/src/tools/registry.ts +15 -5
  258. package/src/tools/result-disposition.ts +156 -0
  259. package/src/tools/result-shaping.ts +59 -1
  260. package/src/tools/verify/authoring.ts +55 -54
  261. package/src/tools/worker-evidence.ts +19 -0
  262. package/src/worker/spec-contract.ts +43 -3
  263. package/dist/chunk-EFADSJET.js +0 -18
  264. package/dist/memory-4ALKDJ4Q.js +0 -246
  265. package/src/domains/eval/metrics/chaos-stream.ts +0 -93
@@ -32,6 +32,7 @@ export type { ModelCapabilityPatchTarget } from "./model-capabilities.js";
32
32
  export { applyModelCapabilityPatch, resolveModelCapabilities } from "./model-capabilities.js";
33
33
  export {
34
34
  canonicalizeWireModelId,
35
+ contextSlotsForModel,
35
36
  hasLiveModelCatalog,
36
37
  loadedContextWindowForModel,
37
38
  type ModelResidency,
@@ -96,6 +97,7 @@ export {
96
97
  refineRuntimeTargetWithModelHints,
97
98
  resolveRuntimeTarget,
98
99
  runtimeResolutionWarnings,
100
+ runtimeResolutionWarningsBesideThinkingNotice,
99
101
  runtimeTargetSnapshot,
100
102
  } from "./runtime-resolution.js";
101
103
  export {
@@ -126,6 +128,7 @@ export {
126
128
  EMPTY_CAPABILITIES,
127
129
  VALID_THINKING_LEVELS,
128
130
  } from "./types/capability-flags.js";
131
+ export { type ContextWindowSlots, formatContextWindowSlots } from "./types/context-window-slots.js";
129
132
  export { type CostProvenance, normalizeCostProvenance } from "./types/cost-provenance.js";
130
133
  export type { KnowledgeBase, KnowledgeBaseEntry, KnowledgeBaseHit } from "./types/knowledge-base.js";
131
134
  export type {
@@ -1,5 +1,6 @@
1
1
  import type { TargetStatus } from "./contract.js";
2
2
  import { listKnownModelsForRuntime } from "./support.js";
3
+ import type { ContextWindowSlots } from "./types/context-window-slots.js";
3
4
 
4
5
  export type ProviderModelSource = "configured" | "live" | "catalog" | "default";
5
6
 
@@ -45,6 +46,14 @@ export function loadedContextWindowForModel(
45
46
  return typeof reported === "number" && Number.isFinite(reported) && reported > 0 ? reported : null;
46
47
  }
47
48
 
49
+ /** How the server splits its KV budget for this model, when the probe saw it split. */
50
+ export function contextSlotsForModel(
51
+ status: DiscoveryStatus | null | undefined,
52
+ modelId: string,
53
+ ): ContextWindowSlots | null {
54
+ return status?.discoveredModelStates?.[modelId]?.contextSlots ?? null;
55
+ }
56
+
48
57
  /**
49
58
  * Residency as one view, so the planner and the "not resident" notice cannot
50
59
  * disagree about the same model. A reported loaded window settles it whatever
@@ -5,7 +5,7 @@ import { getCatalogModelForRuntime, resolveCostProvenance } from "./catalog.js";
5
5
  import type { ProvidersContract, TargetStatus } from "./contract.js";
6
6
  import { isDispatchEligibleRuntime, isOrchestratorEligibleRuntime, isTargetEligibleRuntime } from "./eligibility.js";
7
7
  import { probeCapabilitiesForModel, resolveModelCapabilities } from "./model-capabilities.js";
8
- import { hasLiveModelCatalog, loadedContextWindowForModel } from "./model-discovery.js";
8
+ import { contextSlotsForModel, hasLiveModelCatalog, loadedContextWindowForModel } from "./model-discovery.js";
9
9
  import {
10
10
  type ReasoningClass,
11
11
  type ResolvedModelRuntimeCapabilities,
@@ -14,6 +14,7 @@ import {
14
14
  resolveTargetRuntimeCapabilities,
15
15
  } from "./model-runtime-capabilities.js";
16
16
  import type { CapabilityFlags, ThinkingLevel } from "./types/capability-flags.js";
17
+ import type { ContextWindowSlots } from "./types/context-window-slots.js";
17
18
  import type { CostProvenance } from "./types/cost-provenance.js";
18
19
  import type { KnowledgeBase } from "./types/knowledge-base.js";
19
20
  import type {
@@ -52,6 +53,12 @@ export interface ContextWindowDetails {
52
53
  effectiveContextWindow: number;
53
54
  /** Where `effectiveContextWindow` came from. */
54
55
  contextWindowSource: ContextWindowSource;
56
+ /**
57
+ * Present when the probed window is a per-request share of the server's
58
+ * KV budget (llama.cpp `--ctx-size` over `--parallel` slots), so the
59
+ * operator surfaces can render `196,608 (786,432 / 4 slots)`.
60
+ */
61
+ contextWindowSlots: ContextWindowSlots | null;
55
62
  /** The window is below what this kind of work wants. An actionable degradation. */
56
63
  warning: string | null;
57
64
  /** The window is a placeholder rather than something the target reported. */
@@ -406,6 +413,8 @@ export function resolveRuntimeTarget(
406
413
  providers.knowledgeBase,
407
414
  probedContextWindow,
408
415
  loadedContextWindow,
416
+ undefined,
417
+ contextSlotsForModel(status, wireModelId),
409
418
  );
410
419
  capabilities.contextWindow = contextWindowDetails.effectiveContextWindow;
411
420
  if (contextWindowDetails.warning) {
@@ -513,6 +522,7 @@ export function refineRuntimeTargetWithModelHints(
513
522
  // hand the planner the declared window back on the first refinement.
514
523
  target.contextWindowDetails.loadedContextWindow,
515
524
  modelHintContextWindow,
525
+ target.contextWindowDetails.contextWindowSlots,
516
526
  );
517
527
  capabilities.contextWindow = contextWindowDetails.effectiveContextWindow;
518
528
 
@@ -582,6 +592,21 @@ export function runtimeResolutionWarnings(diagnostics: ReadonlyArray<RuntimeReso
582
592
  return diagnostics.filter((entry) => entry.severity === "warning").map((entry) => entry.message);
583
593
  }
584
594
 
595
+ /**
596
+ * The warnings a surface that prints its own thinking-clamp line should
597
+ * announce. `thinking-coerced` and `thinking-<kind>` are the two halves of
598
+ * that one line, so when the resolved thinking carries a notice they are
599
+ * dropped here: an always-on model printed three lines saying one thing
600
+ * (issue #191). With no notice, a bare coercion is still worth a line.
601
+ */
602
+ export function runtimeResolutionWarningsBesideThinkingNotice(
603
+ diagnostics: ReadonlyArray<RuntimeResolutionDiagnostic>,
604
+ thinkingNotice: string,
605
+ ): string[] {
606
+ if (thinkingNotice.trim().length === 0) return runtimeResolutionWarnings(diagnostics);
607
+ return runtimeResolutionWarnings(diagnostics.filter((entry) => !entry.code.startsWith("thinking-")));
608
+ }
609
+
585
610
  /**
586
611
  * Minimum context Clio is built for, applied to every tier rather than only to
587
612
  * local-native. A hosted target that reports less than this is as unable to
@@ -623,6 +648,7 @@ export function resolveContextWindowDetails(
623
648
  probedContextWindow: number | null,
624
649
  loadedContextWindow: number | null = null,
625
650
  modelHintContextWindow?: number,
651
+ probedContextSlots: ContextWindowSlots | null = null,
626
652
  ): ContextWindowDetails {
627
653
  const catalogModel = getCatalogModelForRuntime(runtime.id, wireModelId);
628
654
  const kbHit = knowledgeBase?.lookup(wireModelId) ?? null;
@@ -707,6 +733,16 @@ export function resolveContextWindowDetails(
707
733
  `Run 'clio-coder targets --probe' to read the real one.`;
708
734
  }
709
735
 
736
+ // The split explains the probed number and nothing else: once an override
737
+ // or a loaded window decides the figure, `786,432 / 4 slots` no longer
738
+ // describes it.
739
+ const contextWindowSlots =
740
+ source === "probe" &&
741
+ probedContextSlots !== null &&
742
+ Math.floor(probedContextSlots.totalContextSize / probedContextSlots.slots) === effective
743
+ ? probedContextSlots
744
+ : null;
745
+
710
746
  return {
711
747
  declaredContextWindow,
712
748
  probedContextWindow,
@@ -714,6 +750,7 @@ export function resolveContextWindowDetails(
714
750
  desiredContextWindow: desired,
715
751
  effectiveContextWindow: effective,
716
752
  contextWindowSource: source,
753
+ contextWindowSlots,
717
754
  warning,
718
755
  provenanceNotice,
719
756
  };
@@ -1,5 +1,6 @@
1
1
  import { probeHttp, probeJson } from "../../probe/http.js";
2
2
  import type { CapabilityFlags } from "../../types/capability-flags.js";
3
+ import { type ContextWindowSlots, formatContextWindowSlots } from "../../types/context-window-slots.js";
3
4
  import type { ProbeContext, ProbeModelStatus, ProbeResult } from "../../types/runtime-descriptor.js";
4
5
  import type { TargetDescriptor } from "../../types/target-descriptor.js";
5
6
 
@@ -88,7 +89,15 @@ export async function probeOpenAIModelCatalog(
88
89
  // A reported loaded context is itself the residency answer: nothing
89
90
  // serves a window for a model it has not loaded.
90
91
  (loadedContext !== undefined ? { state: "loaded" as const } : undefined);
91
- if (state) modelStates[row.id] = loadedContext === undefined ? state : { ...state, contextLength: loadedContext };
92
+ // The slot split rides on the load-state record because it is the same
93
+ // kind of fact: how this server is serving this model. A row with no
94
+ // recognized state still gets one, as `unknown`, so the split is kept
95
+ // without claiming residency the server did not report.
96
+ const contextSlots = contextSlotsFromEntry(row) ?? (detailRow ? contextSlotsFromEntry(detailRow) : undefined);
97
+ const withSlots = contextSlots ? { ...(state ?? { state: "unknown" as const }), contextSlots } : state;
98
+ if (withSlots) {
99
+ modelStates[row.id] = loadedContext === undefined ? withSlots : { ...withSlots, contextLength: loadedContext };
100
+ }
92
101
  }
93
102
  return { models, modelCapabilities, modelStates };
94
103
  }
@@ -159,7 +168,15 @@ function normalizeModelState(raw: string | undefined): ProbeModelStatus["state"]
159
168
  if (!value) return undefined;
160
169
  if (value === "loaded" || value === "ready" || value === "running" || value === "active") return "loaded";
161
170
  if (value === "loading" || value === "pending" || value === "queued" || value === "starting") return "loading";
162
- if (value === "unloaded" || value === "not-loaded" || value === "idle" || value === "stopped") return "unloaded";
171
+ if (
172
+ value === "unloaded" ||
173
+ value === "not-loaded" ||
174
+ value === "idle" ||
175
+ value === "sleeping" ||
176
+ value === "stopped"
177
+ ) {
178
+ return "unloaded";
179
+ }
163
180
  if (value === "failed" || value === "error" || value === "errored") return "failed";
164
181
  if (value === "unknown") return "unknown";
165
182
  return undefined;
@@ -203,12 +220,16 @@ function statusArgsFromEntry(row: Record<string, unknown>): string[] {
203
220
  return argsFromStatus(status);
204
221
  }
205
222
 
223
+ function contextSlotsFromEntry(row: Record<string, unknown>): ContextWindowSlots | undefined {
224
+ return llamaCppRequestContextWindow(parseLlamaCppServerFlags(statusArgsFromEntry(row)))?.slots;
225
+ }
226
+
206
227
  function capabilitiesFromOpenAIModelEntry(row: Record<string, unknown>): Partial<CapabilityFlags> {
207
228
  const caps: Partial<CapabilityFlags> = {};
208
229
  const meta = nestedRecord(row, "meta");
209
230
  const flags = parseLlamaCppServerFlags(statusArgsFromEntry(row));
210
231
  const contextWindow =
211
- positiveNumber(flags.contextSize) ??
232
+ llamaCppRequestContextWindow(flags)?.contextWindow ??
212
233
  firstPositiveNumber(row, [
213
234
  // What is actually loaded outranks what the model could support: a
214
235
  // model served at 8k out of a possible 262k has an 8k window today,
@@ -310,10 +331,43 @@ export interface LlamaCppServerFlags {
310
331
  topK?: number;
311
332
  nGpuLayers?: number;
312
333
  parallel?: number;
334
+ /** `--kv-unified` / `-kvu` true, `--no-kv-unified` false, absent when neither was given. */
335
+ kvUnified?: boolean;
313
336
  mmproj?: string;
314
337
  chatTemplateKwargs?: string;
315
338
  }
316
339
 
340
+ export interface LlamaCppRequestContextWindow {
341
+ /** What one request can use. */
342
+ contextWindow: number;
343
+ /** Present when `contextWindow` is a quotient of the server's total. */
344
+ slots?: ContextWindowSlots;
345
+ }
346
+
347
+ /**
348
+ * The window one request actually gets from a llama.cpp server.
349
+ *
350
+ * `--ctx-size` is the total KV budget of the process. Without `--kv-unified`
351
+ * the server splits it evenly across `--parallel` slots, so a router started
352
+ * with `--ctx-size 786432 --parallel 4 --no-kv-unified` admits 196,608 tokens
353
+ * per request while reporting 786,432 as its context size. Reading the total
354
+ * as the window armed autocompact at a number the server would never admit
355
+ * and walked a long session into a hard context failure with the meter at
356
+ * 25% (issue #187). With `--kv-unified` every slot shares one sequence and
357
+ * the total is the window.
358
+ */
359
+ export function llamaCppRequestContextWindow(flags: LlamaCppServerFlags): LlamaCppRequestContextWindow | undefined {
360
+ const total = positiveNumber(flags.contextSize);
361
+ if (total === undefined) return undefined;
362
+ const parallel = positiveNumber(flags.parallel);
363
+ if (parallel === undefined || parallel <= 1 || flags.kvUnified === true) return { contextWindow: Math.floor(total) };
364
+ const slots = Math.floor(parallel);
365
+ return {
366
+ contextWindow: Math.floor(total / slots),
367
+ slots: { totalContextSize: Math.floor(total), slots },
368
+ };
369
+ }
370
+
317
371
  export interface LlamaCppStatusEnrichment {
318
372
  discoveredCapabilities?: Partial<CapabilityFlags>;
319
373
  modelId?: string;
@@ -329,22 +383,37 @@ function argsFromStatus(status: unknown): string[] {
329
383
  return [];
330
384
  }
331
385
 
332
- function valueAfter(args: ReadonlyArray<string>, flag: string): string | undefined {
333
- const index = args.indexOf(flag);
334
- if (index < 0) return undefined;
335
- const value = args[index + 1];
336
- return value && !value.startsWith("--") ? value : undefined;
386
+ /** `-1` is a value (`--reasoning-budget -1`); `-np` and `--jinja` are flags. */
387
+ function looksLikeFlag(token: string): boolean {
388
+ return token.startsWith("-") && Number.isNaN(Number(token));
337
389
  }
338
390
 
339
- function numberFlag(args: ReadonlyArray<string>, flag: string): number | undefined {
340
- const value = valueAfter(args, flag);
391
+ /**
392
+ * The value after the first of `flags` present, or undefined. A token that
393
+ * reads as the next flag is not a value, which is how boolean flags read as
394
+ * present-without-value. Short spellings (`-c`, `-np`) come after the long
395
+ * one so the long form wins when both are given.
396
+ */
397
+ function valueAfter(args: ReadonlyArray<string>, ...flags: ReadonlyArray<string>): string | undefined {
398
+ for (const flag of flags) {
399
+ const index = args.indexOf(flag);
400
+ if (index < 0) continue;
401
+ const value = args[index + 1];
402
+ return value && !looksLikeFlag(value) ? value : undefined;
403
+ }
404
+ return undefined;
405
+ }
406
+
407
+ function numberFlag(args: ReadonlyArray<string>, ...flags: ReadonlyArray<string>): number | undefined {
408
+ const value = valueAfter(args, ...flags);
341
409
  if (value === undefined) return undefined;
342
410
  const parsed = Number(value);
343
411
  return Number.isFinite(parsed) ? parsed : undefined;
344
412
  }
345
413
 
346
- function booleanFlag(args: ReadonlyArray<string>, flag: string): boolean | undefined {
347
- if (!args.includes(flag)) return undefined;
414
+ function booleanFlag(args: ReadonlyArray<string>, ...flags: ReadonlyArray<string>): boolean | undefined {
415
+ const flag = flags.find((candidate) => args.includes(candidate));
416
+ if (flag === undefined) return undefined;
348
417
  const value = valueAfter(args, flag);
349
418
  if (value === undefined) return true;
350
419
  const normalized = value.toLowerCase();
@@ -353,9 +422,9 @@ function booleanFlag(args: ReadonlyArray<string>, flag: string): boolean | undef
353
422
  return undefined;
354
423
  }
355
424
 
356
- function parseLlamaCppServerFlags(args: ReadonlyArray<string>): LlamaCppServerFlags {
425
+ export function parseLlamaCppServerFlags(args: ReadonlyArray<string>): LlamaCppServerFlags {
357
426
  const flags: LlamaCppServerFlags = {};
358
- const ctxSize = numberFlag(args, "--ctx-size");
427
+ const ctxSize = numberFlag(args, "--ctx-size", "-c");
359
428
  if (ctxSize !== undefined) flags.contextSize = ctxSize;
360
429
  const maxTokens = numberFlag(args, "--n-predict");
361
430
  if (maxTokens !== undefined) flags.maxTokens = maxTokens;
@@ -375,8 +444,14 @@ function parseLlamaCppServerFlags(args: ReadonlyArray<string>): LlamaCppServerFl
375
444
  if (topK !== undefined) flags.topK = topK;
376
445
  const nGpuLayers = numberFlag(args, "--n-gpu-layers");
377
446
  if (nGpuLayers !== undefined) flags.nGpuLayers = nGpuLayers;
378
- const parallel = numberFlag(args, "--parallel");
447
+ const parallel = numberFlag(args, "--parallel", "-np");
379
448
  if (parallel !== undefined) flags.parallel = parallel;
449
+ // The negative spelling is its own flag, and the last one given wins, which
450
+ // is how llama.cpp itself resolves a repeated boolean option.
451
+ const kvUnifiedAt = Math.max(args.lastIndexOf("--kv-unified"), args.lastIndexOf("-kvu"));
452
+ const noKvUnifiedAt = args.lastIndexOf("--no-kv-unified");
453
+ if (noKvUnifiedAt > kvUnifiedAt) flags.kvUnified = false;
454
+ else if (kvUnifiedAt >= 0) flags.kvUnified = booleanFlag(args, "--kv-unified", "-kvu") ?? true;
380
455
  const cacheTypeK = valueAfter(args, "--cache-type-k");
381
456
  if (cacheTypeK) flags.cacheTypeK = cacheTypeK;
382
457
  const cacheTypeV = valueAfter(args, "--cache-type-v");
@@ -418,7 +493,8 @@ export async function probeLlamaCppModelStatus(
418
493
  if (args.length === 0) return { notes: statusNotes(selected.id, selected.status) };
419
494
  const flags = parseLlamaCppServerFlags(args);
420
495
  const caps: Partial<CapabilityFlags> = {};
421
- if (flags.contextSize !== undefined && flags.contextSize > 0) caps.contextWindow = flags.contextSize;
496
+ const window = llamaCppRequestContextWindow(flags);
497
+ if (window !== undefined) caps.contextWindow = window.contextWindow;
422
498
  if (flags.maxTokens !== undefined && flags.maxTokens > 0) caps.maxTokens = flags.maxTokens;
423
499
  if (flags.reasoning === true || flags.reasoningBudget !== undefined) caps.reasoning = true;
424
500
  if (flags.mmproj) caps.vision = true;
@@ -426,6 +502,11 @@ export async function probeLlamaCppModelStatus(
426
502
  const enrichment: LlamaCppStatusEnrichment = { modelId: selected.id, serverFlags: flags };
427
503
  if (Object.keys(caps).length > 0) enrichment.discoveredCapabilities = caps;
428
504
  const notes = statusNotes(selected.id, selected.status);
505
+ if (window?.slots) {
506
+ notes.push(
507
+ `${selected.id} context window ${formatContextWindowSlots(window.contextWindow, window.slots)}: --ctx-size is split across --parallel slots without --kv-unified`,
508
+ );
509
+ }
429
510
  if (notes.length > 0) enrichment.notes = notes;
430
511
  return enrichment;
431
512
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A server that shares one KV budget across request slots serves each request
3
+ * a quotient of it. llama.cpp splits `--ctx-size` evenly across `--parallel`
4
+ * slots unless `--kv-unified`, so a router started with `--ctx-size 786432
5
+ * --parallel 4 --no-kv-unified` admits 196,608 tokens per request. The total
6
+ * and the slot count are kept beside the quotient so the operator surfaces can
7
+ * say where the number came from.
8
+ */
9
+ export interface ContextWindowSlots {
10
+ totalContextSize: number;
11
+ slots: number;
12
+ }
13
+
14
+ /** `196,608 (786,432 / 4 slots)`: the per-request window with its derivation. */
15
+ export function formatContextWindowSlots(contextWindow: number, slots: ContextWindowSlots): string {
16
+ const format = (n: number): string => Math.round(n).toLocaleString("en-US");
17
+ return `${format(contextWindow)} (${format(slots.totalContextSize)} / ${slots.slots} slots)`;
18
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Api, Model } from "../../../engine/types.js";
2
-
3
2
  import type { CapabilityFlags } from "./capability-flags.js";
3
+ import type { ContextWindowSlots } from "./context-window-slots.js";
4
4
  import type { CompleteOptions, CompletionChunk, EmbedResult, InfillOptions, RerankResult } from "./inference.js";
5
5
  import type { KnowledgeBaseHit } from "./knowledge-base.js";
6
6
  import type { TargetDescriptor } from "./target-descriptor.js";
@@ -61,6 +61,8 @@ export type ProbeModelLoadState = "loaded" | "loading" | "unloaded" | "failed" |
61
61
  export interface ProbeModelStatus {
62
62
  state: ProbeModelLoadState;
63
63
  detail?: string;
64
+ /** The per-request window is `totalContextSize / slots`; absent when the server does not split. */
65
+ contextSlots?: ContextWindowSlots;
64
66
  /**
65
67
  * Context the runtime has this model loaded at, when it reports one. LM
66
68
  * Studio serves a loaded instance at whatever window it was opened with,
@@ -7,6 +7,8 @@
7
7
  * or spoof the UI that approves it.
8
8
  */
9
9
 
10
+ import { isSecretArgKey, redactSecretString } from "./redaction.js";
11
+
10
12
  const ESC_CHAR = String.fromCharCode(27);
11
13
  const BEL_CHAR = String.fromCharCode(7);
12
14
  // Built through the constructor so no control character appears in a regex
@@ -39,21 +41,216 @@ export function sanitizeCallTargetText(value: string): string {
39
41
  }
40
42
 
41
43
  /**
42
- * Derive the operator-facing object of a call for the approval overlay: the
43
- * command for bash, a path for file tools, else a compact args preview.
44
- * Returns an empty string when nothing meaningful is derivable, so callers
45
- * can omit the Target row instead of rendering a blank.
44
+ * What a tool call is doing, in the bounded form that may cross the worker
45
+ * stdout seam. The verb comes from a fixed vocabulary and the object from a
46
+ * fixed allowlist of argument fields, so an argument the table does not name
47
+ * cannot reach an operator surface however it is spelled.
48
+ */
49
+ export interface CallActionDescriptor {
50
+ /** One word from the vocabulary below naming what the call does. */
51
+ verb: string;
52
+ /** The redacted, bounded thing the call acts on. Absent when nothing safe is derivable. */
53
+ object?: string;
54
+ /** Whether the object was cut to {@link CALL_ACTION_OBJECT_MAX_CHARS}. */
55
+ truncated?: boolean;
56
+ }
57
+
58
+ /**
59
+ * Characters of object text a descriptor carries. Bounded here rather than at
60
+ * the renderer: this string crosses a process boundary, so a hostile argument
61
+ * must be small before it is transported, not after.
46
62
  */
47
- export function describeCallTarget(args: Record<string, unknown> | undefined): string {
63
+ export const CALL_ACTION_OBJECT_MAX_CHARS = 64;
64
+
65
+ /** Maximum characters carried by the approval overlay's one-line call target. */
66
+ export const CALL_TARGET_MAX_CHARS = 120;
67
+
68
+ /**
69
+ * The verb and object field for each tool Clio ships, plus the ACP tool kinds
70
+ * a delegated peer reports. A tool absent from this table gets the neutral
71
+ * `calling` verb, never a verb guessed from its name.
72
+ */
73
+ const CALL_ACTION_VOCABULARY: Readonly<Record<string, { verb: string; field: string }>> = {
74
+ read: { verb: "reading", field: "path" },
75
+ edit: { verb: "editing", field: "path" },
76
+ write: { verb: "writing", field: "path" },
77
+ ls: { verb: "listing", field: "path" },
78
+ bash: { verb: "running", field: "command" },
79
+ grep: { verb: "searching", field: "pattern" },
80
+ find: { verb: "finding", field: "pattern" },
81
+ web_fetch: { verb: "fetching", field: "url" },
82
+ git: { verb: "git", field: "op" },
83
+ verify: { verb: "verifying", field: "check" },
84
+ code_nav: { verb: "navigating", field: "query" },
85
+ context: { verb: "context", field: "scope" },
86
+ artifact: { verb: "writing", field: "kind" },
87
+ monitor: { verb: "monitoring", field: "run_id" },
88
+ steer: { verb: "steering", field: "run_id" },
89
+ tasks: { verb: "tasks", field: "action" },
90
+ dispatch: { verb: "dispatching", field: "agent" },
91
+ // ACP tool kinds. A peer names its own argument fields, so these rely on
92
+ // the shared allowlist below rather than on a field this side can predict.
93
+ execute: { verb: "running", field: "command" },
94
+ search: { verb: "searching", field: "query" },
95
+ fetch: { verb: "fetching", field: "url" },
96
+ delete: { verb: "deleting", field: "path" },
97
+ move: { verb: "moving", field: "path" },
98
+ think: { verb: "thinking", field: "" },
99
+ };
100
+
101
+ /**
102
+ * Argument fields any tool may surface as its object. A field outside this
103
+ * list is never read, so a tool that hides a credential in `body`, `headers`,
104
+ * or `env` cannot leak it through a descriptor.
105
+ */
106
+ const CALL_ACTION_OBJECT_FIELDS = ["path", "file_path", "command", "pattern", "query", "url"] as const;
107
+
108
+ /**
109
+ * Argument fields whose values may reach the approval overlay for each tool.
110
+ * Fields are ordered by decision value: the first present field is the plain
111
+ * target, and any later present fields are labeled facts. Everything outside
112
+ * the tool's list is described only by type and size.
113
+ */
114
+ const CALL_TARGET_FIELDS: Readonly<Record<string, ReadonlyArray<string>>> = {
115
+ read: ["path", "offset", "limit", "tail"],
116
+ grep: ["pattern", "path", "mode", "glob", "ignore_case", "literal", "context", "limit", "include_ignored"],
117
+ find: ["pattern", "path", "order", "limit", "include_ignored"],
118
+ ls: ["path", "limit"],
119
+ code_nav: ["mode", "query", "limit"],
120
+ context: ["scope", "query", "name", "limit", "ref", "include_tree"],
121
+ credential_present: ["name", "source", "file"],
122
+ write: ["path"],
123
+ edit: ["path"],
124
+ bash: ["command", "cwd", "timeout_ms", "output_policy"],
125
+ git: ["op", "path", "cached", "stat", "name_only", "limit", "cwd", "timeout_ms", "max_output_bytes"],
126
+ verify: ["check", "path", "browser", "cwd", "timeout_ms", "max_output_bytes"],
127
+ dispatch: [
128
+ "list",
129
+ "mode",
130
+ "agent",
131
+ "target",
132
+ "model",
133
+ "node",
134
+ "autonomy",
135
+ "tool_profile",
136
+ "thinking_level",
137
+ "detach",
138
+ "timeout_ms",
139
+ ],
140
+ monitor: ["run_id", "mode"],
141
+ steer: ["run_id", "action"],
142
+ tasks: ["action", "id"],
143
+ ledger: ["action", "kind", "path", "line", "target", "passed", "since"],
144
+ web_fetch: ["url", "method", "timeout_ms", "max_bytes", "format"],
145
+ ask_user: ["action", "mode", "max_rounds", "exposure"],
146
+ artifact: ["kind", "path", "title"],
147
+ // ACP tool kinds use only fields whose meaning the protocol defines.
148
+ execute: ["command", "cwd"],
149
+ search: ["query", "path"],
150
+ fetch: ["url", "method"],
151
+ delete: ["path"],
152
+ move: ["path", "target"],
153
+ };
154
+
155
+ /** Sanitize, scrub, and bound one candidate object string. Null when nothing is left. */
156
+ function boundActionObject(value: unknown): { object: string; truncated: boolean } | null {
157
+ if (typeof value !== "string") return null;
158
+ const clean = sanitizeCallTargetText(redactSecretString(value));
159
+ if (clean.length === 0) return null;
160
+ if (clean.length <= CALL_ACTION_OBJECT_MAX_CHARS) return { object: clean, truncated: false };
161
+ return { object: clean.slice(0, CALL_ACTION_OBJECT_MAX_CHARS), truncated: true };
162
+ }
163
+
164
+ /**
165
+ * Compose the redacted action descriptor for one tool call. Called at the
166
+ * trusted seams that hold the arguments (the tool registry's admission path,
167
+ * the Claude tool mapper, and the ACP update mapper) so the descriptor, and
168
+ * never the arguments, is what crosses into an operator surface.
169
+ *
170
+ * Returns null when the tool is unknown and no allowlisted field is present:
171
+ * a bare `calling` with nothing after it says less than the tool name already
172
+ * on the row.
173
+ */
174
+ export function describeCallAction(
175
+ tool: string,
176
+ args: Record<string, unknown> | undefined,
177
+ ): CallActionDescriptor | null {
178
+ const known = CALL_ACTION_VOCABULARY[tool];
179
+ const candidates = known?.field ? [known.field, ...CALL_ACTION_OBJECT_FIELDS] : [...CALL_ACTION_OBJECT_FIELDS];
180
+ let bounded: { object: string; truncated: boolean } | null = null;
181
+ if (args) {
182
+ for (const field of candidates) {
183
+ if (isSecretArgKey(field)) continue;
184
+ bounded = boundActionObject(args[field]);
185
+ if (bounded !== null) break;
186
+ }
187
+ }
188
+ if (known === undefined && bounded === null) return null;
189
+ const verb = known?.verb ?? "calling";
190
+ if (bounded === null) return { verb };
191
+ return { verb, object: bounded.object, ...(bounded.truncated ? { truncated: true } : {}) };
192
+ }
193
+
194
+ function renderAllowedTargetValue(value: unknown): string | null {
195
+ if (typeof value === "string") {
196
+ const rendered = sanitizeCallTargetText(redactSecretString(value));
197
+ return rendered.length > 0 ? rendered : null;
198
+ }
199
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
200
+ if (typeof value === "boolean") return String(value);
201
+ return null;
202
+ }
203
+
204
+ function countLabel(count: number, singular: string, plural: string): string {
205
+ return `${count} ${count === 1 ? singular : plural}`;
206
+ }
207
+
208
+ /** Describe an argument without copying any part of its value into display text. */
209
+ function summarizeUnlistedTargetValue(value: unknown): string {
210
+ if (typeof value === "string") {
211
+ return `<string ${countLabel(Buffer.byteLength(value, "utf8"), "byte", "bytes")}>`;
212
+ }
213
+ if (Array.isArray(value)) return `<array ${countLabel(value.length, "item", "items")}>`;
214
+ if (value !== null && typeof value === "object") {
215
+ return `<object ${countLabel(Object.keys(value).length, "field", "fields")}>`;
216
+ }
217
+ if (value === null) return "<null 0 values>";
218
+ if (typeof value === "undefined") return "<undefined 0 values>";
219
+ return `<${typeof value} 1 value>`;
220
+ }
221
+
222
+ function targetFieldName(value: string): string {
223
+ return sanitizeCallTargetText(value).slice(0, 32) || "field";
224
+ }
225
+
226
+ /**
227
+ * Derive the operator-facing object of a call for the approval overlay. Only
228
+ * values in the named tool's allowlist may render. Every other argument is
229
+ * summarized by its field name, type, and size, so an unexpected credential
230
+ * or pasted document still informs the decision without disclosing content.
231
+ * Returns an empty string when the call carries no arguments.
232
+ */
233
+ export function describeCallTarget(tool: string, args: Record<string, unknown> | undefined): string {
48
234
  if (!args) return "";
49
- const str = (value: unknown): string | null =>
50
- typeof value === "string" && value.trim().length > 0 ? oneLine(sanitizeForDisplay(value)).trim() || null : null;
51
- const candidate = str(args.command) ?? str(args.path) ?? str(args.file_path) ?? str(args.name) ?? str(args.pattern);
52
- if (candidate) return candidate;
53
- try {
54
- const json = JSON.stringify(args);
55
- return json === "{}" || json === undefined ? "" : oneLine(sanitizeForDisplay(json)).slice(0, 120);
56
- } catch {
57
- return "";
235
+ const allowedFields = CALL_TARGET_FIELDS[tool] ?? [];
236
+ const allowed = new Set(allowedFields);
237
+ const parts: string[] = [];
238
+ for (const field of allowedFields) {
239
+ if (!(field in args)) continue;
240
+ if (isSecretArgKey(field)) {
241
+ parts.push(`${targetFieldName(field)}=${summarizeUnlistedTargetValue(args[field])}`);
242
+ continue;
243
+ }
244
+ const rendered = renderAllowedTargetValue(args[field]);
245
+ if (rendered === null) {
246
+ parts.push(`${targetFieldName(field)}=${summarizeUnlistedTargetValue(args[field])}`);
247
+ continue;
248
+ }
249
+ parts.push(parts.length === 0 ? rendered : `${targetFieldName(field)}=${rendered}`);
250
+ }
251
+ for (const [field, value] of Object.entries(args)) {
252
+ if (allowed.has(field)) continue;
253
+ parts.push(`${targetFieldName(field)}=${summarizeUnlistedTargetValue(value)}`);
58
254
  }
255
+ return sanitizeCallTargetText(parts.join(" · ")).slice(0, CALL_TARGET_MAX_CHARS);
59
256
  }