@cspeach/cli 1.1.18 → 1.1.20

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 (252) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/anthropic-provider.js +30 -10
  3. package/dist/agent/cache-keepalive.js +162 -0
  4. package/dist/agent/cold-prune.js +116 -0
  5. package/dist/agent/loop.js +804 -159
  6. package/dist/agent/provider-shape.js +263 -0
  7. package/dist/agent/providers/ai-hub-provider.js +17 -2
  8. package/dist/agent/providers/byok-provider.js +33 -3
  9. package/dist/agent/providers/local-provider.js +12 -2
  10. package/dist/agent/repair-partial.js +125 -7
  11. package/dist/agent/summarise-via-provider.js +6 -1
  12. package/dist/agent/system-prompt.js +38 -0
  13. package/dist/agent/tool-dispatch.js +66 -21
  14. package/dist/agent/tool-loading-pin.js +100 -0
  15. package/dist/agent/turn-error-ux.js +77 -26
  16. package/dist/agent/turn-stream.js +25 -3
  17. package/dist/approvals/adt-type.js +27 -0
  18. package/dist/approvals/advisory-prompt.js +48 -0
  19. package/dist/approvals/advisory-render.js +25 -11
  20. package/dist/approvals/approval-prompt.js +74 -24
  21. package/dist/approvals/jwt.js +2 -1
  22. package/dist/approvals/render.js +64 -36
  23. package/dist/approvals/risk-floor.js +53 -2
  24. package/dist/auth/api-key.js +30 -16
  25. package/dist/auth/keychain.js +0 -0
  26. package/dist/auth/me.js +25 -7
  27. package/dist/cli.js +36 -6
  28. package/dist/commands/auto-compact.js +33 -16
  29. package/dist/commands/compact.js +49 -11
  30. package/dist/commands/config-set.js +16 -3
  31. package/dist/commands/config-show.js +18 -5
  32. package/dist/commands/cost.js +54 -35
  33. package/dist/commands/help.js +88 -50
  34. package/dist/commands/login.js +28 -19
  35. package/dist/commands/logout.js +7 -3
  36. package/dist/commands/plan-audit.js +1 -0
  37. package/dist/commands/plan-chain.js +71 -77
  38. package/dist/commands/plan-continue.js +1 -0
  39. package/dist/commands/plan-resume.js +20 -12
  40. package/dist/commands/whoami.js +23 -9
  41. package/dist/config/loader.js +70 -5
  42. package/dist/cost/cost-log.js +62 -2
  43. package/dist/cost/pricing.js +11 -6
  44. package/dist/doctor/checks/forge-rules.js +13 -3
  45. package/dist/doctor/checks/keychain.js +6 -4
  46. package/dist/doctor/run.js +101 -20
  47. package/dist/index.js +5 -2
  48. package/dist/lib/graceful-exit.js +56 -0
  49. package/dist/lib/piped-prompt.js +65 -0
  50. package/dist/lib/spill-labels.js +13 -0
  51. package/dist/lock-contention.js +2 -2
  52. package/dist/models/resolve.js +93 -2
  53. package/dist/models/server-config.js +158 -3
  54. package/dist/one-shot.js +150 -45
  55. package/dist/projects/answer-blockers.js +6 -1
  56. package/dist/projects/handover-md.js +15 -14
  57. package/dist/projects/image-attachments.js +15 -2
  58. package/dist/projects/plan-run.js +97 -54
  59. package/dist/projects/promote-command.js +11 -2
  60. package/dist/projects/save-command.js +3 -2
  61. package/dist/projects/status.js +2 -1
  62. package/dist/projects/yes-no.js +12 -0
  63. package/dist/renderer/abap-inline.js +18 -14
  64. package/dist/renderer/answer-trim.js +69 -0
  65. package/dist/renderer/banners.js +8 -8
  66. package/dist/renderer/brand-settled.js +19 -0
  67. package/dist/renderer/change-card.js +156 -0
  68. package/dist/renderer/checklist-format.js +130 -0
  69. package/dist/renderer/color-mode.js +45 -0
  70. package/dist/renderer/error-detail.js +104 -0
  71. package/dist/renderer/fence-state.js +46 -0
  72. package/dist/renderer/footer-line.js +100 -0
  73. package/dist/renderer/glyphs.js +25 -0
  74. package/dist/renderer/goodbye.js +38 -0
  75. package/dist/renderer/legacy-palette.js +41 -0
  76. package/dist/renderer/look.js +30 -0
  77. package/dist/renderer/markdown.js +299 -92
  78. package/dist/renderer/notice-log.js +72 -0
  79. package/dist/renderer/notice-shape.js +48 -0
  80. package/dist/renderer/notices.js +40 -8
  81. package/dist/renderer/panel-rows.js +16 -0
  82. package/dist/renderer/pipeline.js +66 -0
  83. package/dist/renderer/progress-chatter.js +23 -17
  84. package/dist/renderer/rendering-mode.js +34 -0
  85. package/dist/renderer/routing-line.js +14 -0
  86. package/dist/renderer/sanitize.js +12 -0
  87. package/dist/renderer/severity.js +2 -7
  88. package/dist/renderer/startup-lines.js +154 -0
  89. package/dist/renderer/status-footer.js +52 -34
  90. package/dist/renderer/steering-echo.js +41 -0
  91. package/dist/renderer/syntax.js +31 -12
  92. package/dist/renderer/tables.js +5 -1
  93. package/dist/renderer/theme.js +44 -0
  94. package/dist/renderer/thinking-heartbeat.js +33 -2
  95. package/dist/renderer/todo-block.js +10 -49
  96. package/dist/renderer/tool-labels.js +269 -0
  97. package/dist/renderer/tool-widget.js +245 -51
  98. package/dist/renderer/trace.js +42 -0
  99. package/dist/renderer/transcript-flow.js +132 -0
  100. package/dist/renderer/tty.js +34 -1
  101. package/dist/renderer/ui-width.js +55 -0
  102. package/dist/renderer/verify-chain.js +4 -2
  103. package/dist/renderer/widget-fallback.js +58 -65
  104. package/dist/repl/bracketed-paste.js +7 -1
  105. package/dist/repl/builtin-commands.js +19 -8
  106. package/dist/repl/credential-handover-gate.js +28 -0
  107. package/dist/repl/current-transport.js +13 -0
  108. package/dist/repl/early-line-buffer.js +5 -2
  109. package/dist/repl/file-picker.js +54 -12
  110. package/dist/repl/ink-stdin-guard.js +66 -0
  111. package/dist/repl/inquirer-guard.js +59 -16
  112. package/dist/repl/inquirer-theme.js +27 -33
  113. package/dist/repl/paste-marker.js +19 -0
  114. package/dist/repl/plan-turn-end.js +20 -0
  115. package/dist/repl/post-turn-status.js +43 -11
  116. package/dist/repl/reroute-turn.js +21 -0
  117. package/dist/repl/reset-tty-stdin.js +44 -0
  118. package/dist/repl/restore-guard.js +22 -0
  119. package/dist/repl/resume-standalone.js +63 -0
  120. package/dist/repl/rule8-detector.js +11 -3
  121. package/dist/repl/safety-confirm.js +161 -93
  122. package/dist/repl/session-spend-line.js +8 -4
  123. package/dist/repl/themed-prompts.js +19 -0
  124. package/dist/repl/ui-look-command.js +75 -0
  125. package/dist/repl/update-method-preview-hook.js +48 -5
  126. package/dist/repl.js +863 -339
  127. package/dist/rewind/cli.js +3 -2
  128. package/dist/rewind/format.js +14 -9
  129. package/dist/rewind/restore.js +11 -0
  130. package/dist/router/classifier.js +47 -4
  131. package/dist/router/intent-extractor.js +24 -8
  132. package/dist/router/piped-routing.js +23 -0
  133. package/dist/router/resume-routing.js +16 -0
  134. package/dist/router/routing-failure.js +57 -0
  135. package/dist/sap/first-run-choice.js +1 -1
  136. package/dist/sap/onboarding.js +3 -2
  137. package/dist/sap/standalone-onboarding.js +1 -1
  138. package/dist/sap/system-info.js +4 -2
  139. package/dist/sap/unreachable.js +28 -0
  140. package/dist/sap-errors/clean-error-text.js +182 -0
  141. package/dist/session/interrupt-reason.js +39 -0
  142. package/dist/session/recap.js +36 -24
  143. package/dist/session/repin-model.js +18 -0
  144. package/dist/session/resume.js +104 -36
  145. package/dist/session/store.js +84 -28
  146. package/dist/session/user-prompt.js +21 -0
  147. package/dist/skill-catalog.js +7 -0
  148. package/dist/skills/bundled-skills.js +76 -69
  149. package/dist/skills/preamble.js +75 -0
  150. package/dist/skills/source-manifest.js +11 -1
  151. package/dist/standards/standards-init.js +1 -1
  152. package/dist/test-helpers/answer-any.js +46 -0
  153. package/dist/tools/approval.js +27 -11
  154. package/dist/tools/ask-question.js +47 -5
  155. package/dist/tools/filesystem/file-read.js +11 -1
  156. package/dist/tools/filesystem/file-write.js +19 -2
  157. package/dist/tools/fiori/fe-extend.js +16 -1
  158. package/dist/tools/fiori/fe-scaffold.js +105 -3
  159. package/dist/tools/fiori/html-escapes.js +28 -0
  160. package/dist/tools/fiori/metadata/audit.js +605 -0
  161. package/dist/tools/fiori/metadata/references.js +104 -0
  162. package/dist/tools/fiori/metadata/types.js +8 -0
  163. package/dist/tools/fiori/preview/app-guard.js +215 -0
  164. package/dist/tools/fiori/preview/env.js +193 -0
  165. package/dist/tools/fiori/preview/readiness.js +127 -0
  166. package/dist/tools/fiori/preview/registry.js +412 -0
  167. package/dist/tools/fiori/preview/start.js +469 -0
  168. package/dist/tools/fiori/samples/loader.js +20 -5
  169. package/dist/tools/fiori/scaffold.js +18 -6
  170. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  171. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  172. package/dist/tools/fiori/smoke/assertions.js +161 -24
  173. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  174. package/dist/tools/fiori/smoke/batch.js +273 -0
  175. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  176. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  177. package/dist/tools/fiori/smoke/driver.js +171 -4
  178. package/dist/tools/fiori/smoke/evidence.js +35 -0
  179. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  180. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  181. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  182. package/dist/tools/fiori/tools.js +651 -7
  183. package/dist/tools/fiori/ui5-version.js +16 -0
  184. package/dist/tools/index.js +8 -0
  185. package/dist/tools/local-build.js +6 -0
  186. package/dist/tools/result-spill.js +238 -0
  187. package/dist/tools/sap-read.js +74 -15
  188. package/dist/tools/sap-write.js +122 -10
  189. package/dist/tools/shell/shell_exec.js +39 -12
  190. package/dist/tools/subagent/adt-serial.js +33 -0
  191. package/dist/tools/subagent/agent_run.js +2 -0
  192. package/dist/tools/subagent/read_agent.js +178 -0
  193. package/dist/tools/subagent/reader-prompt.js +48 -0
  194. package/dist/tools/syntax-state.js +67 -0
  195. package/dist/tools/todo.js +26 -19
  196. package/dist/tools/tool-loading.js +255 -0
  197. package/dist/tools/tool-output-read.js +117 -0
  198. package/dist/tools/transport-fit.js +462 -0
  199. package/dist/tools/transport-resolution.js +3 -1
  200. package/dist/tools/transport.js +39 -8
  201. package/dist/tools/verify.js +184 -8
  202. package/dist/ui/app.js +284 -146
  203. package/dist/ui/approval-modal.js +41 -15
  204. package/dist/ui/ask-answer-text.js +17 -0
  205. package/dist/ui/body.js +25 -6
  206. package/dist/ui/card-slot.js +98 -0
  207. package/dist/ui/checklist.js +9 -0
  208. package/dist/ui/coaching-picker-classic.js +8 -5
  209. package/dist/ui/command-palette.js +6 -4
  210. package/dist/ui/confirm-request.js +18 -0
  211. package/dist/ui/context-grid.js +2 -1
  212. package/dist/ui/file-palette.js +6 -4
  213. package/dist/ui/files-card-emitter.js +19 -0
  214. package/dist/ui/footer-line.js +16 -0
  215. package/dist/ui/footer.js +105 -104
  216. package/dist/ui/input-wrap.js +122 -0
  217. package/dist/ui/interrupt.js +103 -0
  218. package/dist/ui/key-burst.js +117 -0
  219. package/dist/ui/line-resolution.js +4 -3
  220. package/dist/ui/live-area.js +71 -0
  221. package/dist/ui/login-banner.js +54 -43
  222. package/dist/ui/rewind-panel.js +76 -24
  223. package/dist/ui/role-props.js +47 -0
  224. package/dist/ui/sap-state-store.js +2 -1
  225. package/dist/ui/session-timeline.js +49 -3
  226. package/dist/ui/skill-picker.js +5 -47
  227. package/dist/ui/text-input.js +128 -24
  228. package/dist/ui/todo-emitter.js +19 -0
  229. package/dist/ui/turn-status-emitter.js +65 -2
  230. package/dist/ui/turn-status.js +16 -19
  231. package/dist/ui/use-card-request.js +44 -0
  232. package/dist/ui/widgets/ask-card.js +521 -0
  233. package/dist/ui/widgets/bar-chart.js +11 -5
  234. package/dist/ui/widgets/coaching-picker.js +50 -55
  235. package/dist/ui/widgets/confirm-card.js +193 -0
  236. package/dist/ui/widgets/dep-graph.js +3 -2
  237. package/dist/ui/widgets/diff-viewer.js +6 -5
  238. package/dist/ui/widgets/files-card.js +97 -0
  239. package/dist/ui/widgets/question-card.js +2 -1
  240. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  241. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  242. package/dist/ui/widgets/stack-frames.js +2 -1
  243. package/dist/upgrade-check.js +39 -8
  244. package/package.json +12 -2
  245. package/test-harness/fe-draft/app.ts +188 -0
  246. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  247. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  248. package/test-harness/fe-draft/routes.ts +224 -0
  249. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  250. package/test-harness/fe-draft/server.ts +225 -0
  251. package/test-harness/fe-draft/variants.ts +196 -0
  252. package/vitest.smoke-e2e.config.ts +20 -0
@@ -4,6 +4,9 @@
4
4
  // The proxy serves `GET {proxy_url}/v1/model-config` (Task 3):
5
5
  // { roles: { session_default, audit, compact, router },
6
6
  // pricing: Record<model, { inputPer1M, outputPer1M, cacheReadPer1M, cacheCreatePer1M? }>,
7
+ // effort: { session_default?, audit?, compact? }, (Task 4; {} = none)
8
+ // tool_loading: 'deferred' | 'static', (Task 4)
9
+ // auto_compact_tokens: number | null, (Task 4; null = CLI default)
7
10
  // updated_at }
8
11
  // top-level keys snake_case, pricing entries camelCase, Cache-Control 60s, no auth.
9
12
  //
@@ -24,6 +27,11 @@
24
27
  import fs from 'node:fs/promises';
25
28
  import path from 'node:path';
26
29
  import { cspeachRoot } from '../config/paths.js';
30
+ export const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
31
+ const EFFORT_ROLE_KEYS = ['session_default', 'audit', 'compact'];
32
+ export function isEffort(v) {
33
+ return typeof v === 'string' && EFFORT_VALUES.includes(v);
34
+ }
27
35
  // ── Synchronous module snapshot ───────────────────────────────────────────────
28
36
  let snapshot = null;
29
37
  /** The current served-config snapshot, or null (⇒ built-ins win). Pure sync read. */
@@ -60,6 +68,27 @@ function coercePricing(raw) {
60
68
  ? raw
61
69
  : {};
62
70
  }
71
+ /** Keep only valid effort levels for the known effort roles. */
72
+ function coerceEffort(raw) {
73
+ const out = {};
74
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
75
+ return out;
76
+ const r = raw;
77
+ for (const k of EFFORT_ROLE_KEYS) {
78
+ if (isEffort(r[k]))
79
+ out[k] = r[k];
80
+ }
81
+ return out;
82
+ }
83
+ function coerceToolLoading(raw) {
84
+ return raw === 'deferred' || raw === 'static' ? raw : undefined;
85
+ }
86
+ /** Same bounds as the proxy column CHECK (migration 021). */
87
+ function coerceAutoCompactTokens(raw) {
88
+ return typeof raw === 'number' && Number.isInteger(raw) && raw >= 10_000 && raw <= 10_000_000
89
+ ? raw
90
+ : undefined;
91
+ }
63
92
  /**
64
93
  * Map a served (or cached) document to a ServerModelConfig, or null when the
65
94
  * shape is unusable. `fetchedAt`, when provided, is preserved (disk-cache read);
@@ -73,9 +102,14 @@ function coerceDocument(body, fetchedAt) {
73
102
  const roles = coerceRoles(b.roles);
74
103
  if (Object.keys(roles).length === 0)
75
104
  return null;
105
+ const toolLoading = coerceToolLoading(b.tool_loading);
106
+ const autoCompactTokens = coerceAutoCompactTokens(b.auto_compact_tokens);
76
107
  return {
77
108
  roles,
78
109
  pricing: coercePricing(b.pricing),
110
+ effort: coerceEffort(b.effort),
111
+ ...(toolLoading ? { tool_loading: toolLoading } : {}),
112
+ ...(autoCompactTokens !== undefined ? { auto_compact_tokens: autoCompactTokens } : {}),
79
113
  fetchedAt: typeof fetchedAt === 'string' && fetchedAt.length > 0 ? fetchedAt : new Date().toISOString(),
80
114
  };
81
115
  }
@@ -112,9 +146,11 @@ async function readFreshDiskCache() {
112
146
  // ── Public entrypoint ─────────────────────────────────────────────────────────
113
147
  /**
114
148
  * Fetch the served model-config and update the module snapshot. Managed mode
115
- * ONLY. Fire-and-forget from startup; NEVER throws.
149
+ * ONLY. NEVER throws. Startup awaits it with `timeoutMs: 1500` (Task 3) so the
150
+ * session model is pinned from the served config before call 0; a slower
151
+ * fetch aborts and falls back to a fresh disk cache. Default cap: 5 s.
116
152
  */
117
- export async function loadServerModelConfig(cfg) {
153
+ export async function loadServerModelConfig(cfg, opts) {
118
154
  // Non-managed modes bypass the proxy for inference and must never fetch.
119
155
  if (cfg.llm?.mode !== 'managed')
120
156
  return;
@@ -125,7 +161,7 @@ export async function loadServerModelConfig(cfg) {
125
161
  // rejection that crashes startup. Keep it caught like every other failure.
126
162
  const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/model-config`;
127
163
  const controller = new AbortController();
128
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
164
+ const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
129
165
  timer.unref?.();
130
166
  let body;
131
167
  try {
@@ -153,3 +189,122 @@ export async function loadServerModelConfig(cfg) {
153
189
  snapshot = cached;
154
190
  }
155
191
  }
192
+ let customerSnapshot = null;
193
+ /** The current customer snapshot, or null (⇒ layer skipped). Pure sync read. */
194
+ export function getCustomerModelConfig() {
195
+ return customerSnapshot;
196
+ }
197
+ /** Test-only setter for the customer snapshot. */
198
+ export function __setCustomerModelConfigForTests(c) {
199
+ customerSnapshot = c;
200
+ }
201
+ function coerceCustomer(body) {
202
+ if (!body || typeof body !== 'object' || Array.isArray(body))
203
+ return null;
204
+ const b = body;
205
+ const model = typeof b.model === 'string' && b.model.trim().length > 0 ? b.model.trim() : null;
206
+ return {
207
+ model,
208
+ effort: isEffort(b.effort) ? b.effort : null,
209
+ enforced: b.enforced === true,
210
+ };
211
+ }
212
+ /**
213
+ * Fetch the caller's customer model-config and update the customer snapshot.
214
+ * NEVER throws; on any failure the snapshot is null.
215
+ */
216
+ export async function loadCustomerModelConfig(cfg, getBearer, opts) {
217
+ customerSnapshot = null;
218
+ const controller = new AbortController();
219
+ const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
220
+ timer.unref?.();
221
+ try {
222
+ // The keychain read shares the cap: a hanging getBearer loses the race to
223
+ // the same timer that aborts the fetch (review M1).
224
+ const aborted = new Promise((_resolve, reject) => {
225
+ controller.signal.addEventListener('abort', () => reject(new Error('timeout')), { once: true });
226
+ });
227
+ aborted.catch(() => { });
228
+ const key = await Promise.race([getBearer(), aborted]);
229
+ if (!key)
230
+ return;
231
+ const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/me/model-config`;
232
+ const r = await fetch(url, {
233
+ headers: { Authorization: `Bearer ${key}` },
234
+ signal: controller.signal,
235
+ });
236
+ if (!r.ok)
237
+ return;
238
+ customerSnapshot = coerceCustomer(await r.json());
239
+ }
240
+ catch {
241
+ customerSnapshot = null;
242
+ }
243
+ finally {
244
+ clearTimeout(timer);
245
+ }
246
+ }
247
+ // ── Tool-loading kill switch (Task 16, ruling F13) ───────────────────────────
248
+ //
249
+ // Managed mode reads `tool_loading` from the served snapshot above. BYOK never
250
+ // loads that snapshot (its roles must not steer a BYOK session), so it reads
251
+ // ONLY `tool_loading` from the same public endpoint — and only when signed in
252
+ // (a CSPeach bearer exists). No bearer, any failure, or an invalid value ⇒
253
+ // undefined, which resolveToolLoading turns into 'static' for BYOK. No disk
254
+ // cache. NEVER throws. Startup runs it alongside the other loads under the
255
+ // same 1.5 s cap.
256
+ let byokToolLoading;
257
+ /** Test-only setter for the BYOK tool-loading value. */
258
+ export function __setByokToolLoadingForTests(v) {
259
+ byokToolLoading = v;
260
+ }
261
+ /**
262
+ * The served tool-loading mode: the managed snapshot's `tool_loading`, else
263
+ * the BYOK fetch result, else undefined. Pure sync read.
264
+ */
265
+ export function servedToolLoading() {
266
+ return snapshot?.tool_loading ?? byokToolLoading;
267
+ }
268
+ /**
269
+ * Task 21 fix round (controller ruling) — the managed proxy's capability
270
+ * signal: its served config carries `tool_loading` (migration 021 and later).
271
+ * An older proxy serves no such field and adds none of the new betas, so the
272
+ * loop sends the new wire shapes (deferred tools, tool search, tool_addition,
273
+ * `thinking.block_binding`) on the managed path only when this is true.
274
+ * Managed snapshot only; BYOK never reads it. Pure sync read.
275
+ */
276
+ export function managedProxyServesNewShapes() {
277
+ return snapshot?.tool_loading !== undefined;
278
+ }
279
+ /** BYOK only: fetch `/v1/model-config` and keep its `tool_loading`. */
280
+ export async function fetchToolLoadingMode(cfg, getBearer, opts) {
281
+ byokToolLoading = undefined;
282
+ if (cfg.llm?.mode !== 'byok')
283
+ return undefined;
284
+ const controller = new AbortController();
285
+ const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
286
+ timer.unref?.();
287
+ try {
288
+ const aborted = new Promise((_resolve, reject) => {
289
+ controller.signal.addEventListener('abort', () => reject(new Error('timeout')), { once: true });
290
+ });
291
+ aborted.catch(() => { });
292
+ const key = await Promise.race([getBearer(), aborted]);
293
+ if (!key)
294
+ return undefined;
295
+ const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/model-config`;
296
+ const r = await fetch(url, { signal: controller.signal });
297
+ if (!r.ok)
298
+ return undefined;
299
+ const body = (await r.json());
300
+ byokToolLoading = coerceToolLoading(body && typeof body === 'object' ? body.tool_loading : undefined);
301
+ return byokToolLoading;
302
+ }
303
+ catch {
304
+ byokToolLoading = undefined;
305
+ return undefined;
306
+ }
307
+ finally {
308
+ clearTimeout(timer);
309
+ }
310
+ }
package/dist/one-shot.js CHANGED
@@ -1,25 +1,39 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadConfig, resolveLocalBuild, resolveStandalone } from './config/loader.js';
3
+ import { applyColorMode } from './renderer/color-mode.js';
4
+ import { formatAttention, formatInfo } from './renderer/notice-shape.js';
5
+ import { formatRoutingLine } from './renderer/routing-line.js';
3
6
  import { createOfflineAdtClient } from './sap/offline-adt-client.js';
4
7
  import { STANDALONE_ALIAS } from './tools/index.js';
5
8
  import { getStore } from './auth/api-key.js';
6
9
  import { getConnection } from './sap/connection-manager.js';
10
+ import { unreachableCode, unreachableMessage } from './sap/unreachable.js';
7
11
  import { AdtClient } from '@cspeach/sap-client';
8
12
  import { makeSessionId, saveSession } from './session/store.js';
9
13
  import { newSession } from './session/schema.js';
10
14
  import { resolveModelRole } from './models/resolve.js';
11
- import { loadServerModelConfig } from './models/server-config.js';
15
+ import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
12
16
  import { createProviderForMode } from './agent/providers/factory.js';
13
17
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
14
18
  import { runTurn } from './agent/loop.js';
15
19
  import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
16
- import { setRenderingOverride, setHeadlessOverride } from './renderer/tty.js';
20
+ import { routingFailureFromResult, routingFailureOneShotLine } from './router/routing-failure.js';
21
+ import { startThinkingHeartbeat, ROUTING_HEARTBEAT_THRESHOLDS } from './renderer/thinking-heartbeat.js';
22
+ import { setRenderingOverride, setHeadlessOverride, setOneShotOverride } from './renderer/tty.js';
23
+ import { resolveRenderingMode } from './renderer/rendering-mode.js';
24
+ import { setLook, setBackground } from './renderer/look.js';
17
25
  import { alwaysDeclinePreviewHook } from './repl/update-method-preview-hook.js';
18
26
  import { createClassicOutputEmitter } from './renderer/notices.js';
27
+ import { AnswerTrim } from './renderer/answer-trim.js';
28
+ import { writeStdout } from './renderer/transcript-flow.js';
29
+ import { writeAboveSpinner, aboveSpinnerAddsNewline } from './renderer/tool-widget.js';
19
30
  import { todoEmitter } from './ui/todo-emitter.js';
20
31
  import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
21
32
  import { getEffectiveWriteMode } from './repl/mode-cycle.js';
22
33
  import { initCredits } from './cost/credits-wire.js';
34
+ import { formatTurnReceipt, turnSpendText } from './renderer/footer-line.js';
35
+ import { snapshotTurnStart, computeTurnFacts } from './repl/post-turn-status.js';
36
+ import { globalCredits } from './cost/credits.js';
23
37
  // Side-effect imports for tool registration.
24
38
  import './tools/sap-read.js';
25
39
  import './tools/sap-write.js';
@@ -30,6 +44,7 @@ import './tools/ask-question.js';
30
44
  import './tools/dispatch-skill.js';
31
45
  import './tools/todo.js';
32
46
  import './tools/filesystem/file-read.js';
47
+ import './tools/tool-output-read.js';
33
48
  import './tools/filesystem/read-document.js';
34
49
  import './tools/filesystem/file-edit.js';
35
50
  import './tools/filesystem/file-write.js';
@@ -51,16 +66,32 @@ import './tools/subagent/background_run.js';
51
66
  import './tools/subagent/monitor_emit.js';
52
67
  import './tools/subagent/schedule_draft_create.js';
53
68
  import './tools/subagent/agent_run.js';
69
+ import './tools/subagent/read_agent.js';
54
70
  export async function runOneShot(prompt, opts) {
71
+ applyColorMode(); // NO_COLOR (piece 2 §1.2)
55
72
  const cfg = await loadConfig();
56
73
  // Standalone (2026-09-16) — run with no SAP system at all: no connection, no
57
74
  // AdtClient, SAP tools hidden. `--no-sap` for this run, or `no_sap = true`.
58
75
  const standalone = resolveStandalone(cfg, opts?.noSap === true);
59
- setRenderingOverride(cfg.ui.rendering);
60
- // model-governance step 2d — fire-and-forget fetch of the served model-config
61
- // (managed mode only, never blocks). A turn that starts before it lands uses
62
- // the built-ins (fail-safe).
63
- void loadServerModelConfig(cfg);
76
+ // One-shot output is unchanged by the classic retirement: `auto` never
77
+ // mounts Ink for a one-shot run. CSPEACH_UI=classic is honoured here too;
78
+ // the stored-value rewrite happens only on an interactive start.
79
+ setOneShotOverride(true);
80
+ setRenderingOverride(resolveRenderingMode(cfg.ui.rendering).mode);
81
+ setLook(cfg.ui.look);
82
+ setBackground(cfg.ui.background);
83
+ // model-governance step 2d — fetch the served model-config (managed mode
84
+ // only). Task 3: awaited, but startup waits at most 1.5 s (then a fresh disk
85
+ // cache, else the built-ins), so the session model pinned at newSession
86
+ // below comes from the served config. Never throws.
87
+ // Task 12 — the customer layer (/v1/me/model-config, every mode with a
88
+ // key) loads in parallel under the SAME 1.5 s cap. Fails open.
89
+ await Promise.all([
90
+ loadServerModelConfig(cfg, { timeoutMs: 1500 }),
91
+ loadCustomerModelConfig(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
92
+ // Task 16 (F13) — BYOK reads only the served tool_loading (signed in only).
93
+ fetchToolLoadingMode(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
94
+ ]);
64
95
  // B5 (2026-06-11) — one-shot is headless by design: a single turn, no
65
96
  // REPL, often piped (CI / battery). Any stdin prompt would hang forever
66
97
  // (defect D1). The headless answer policy (ask_question auto-answers,
@@ -80,8 +111,10 @@ export async function runOneShot(prompt, opts) {
80
111
  // write_mode stands on its own.
81
112
  const oneShotAlias = standalone ? undefined : (opts?.sapAlias ?? Object.keys(cfg.sap)[0]);
82
113
  const oneShotRole = oneShotAlias !== undefined ? cfg.sap[oneShotAlias]?.role : undefined;
114
+ // Fix round 3 (item 7): every startup notice below goes to STDERR — piped
115
+ // stdout stays the answer only (spec §8).
83
116
  if (getEffectiveWriteMode(cfg.write_mode, oneShotRole) === 'advisory-only') {
84
- console.log(chalk.yellow('⚠ Advisory mode: writes will be proposed, not executed.'));
117
+ console.error(formatAttention({ text: 'Write mode: advisory', fact: 'writes are proposed, not executed' }).trimEnd());
85
118
  }
86
119
  // local_build toggle — force-enable the flag-gated filesystem tools AND
87
120
  // shell_exec BEFORE the (single) turn when persisted-on. Resolve with backward
@@ -91,11 +124,11 @@ export async function runOneShot(prompt, opts) {
91
124
  {
92
125
  const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
93
126
  if (announce)
94
- console.log(chalk.dim(announce));
127
+ console.error(chalk.dim(announce));
95
128
  // 2026-09-17 — standalone writes files by contract; enable the file tools
96
129
  // without the separate local_build step (see repl.tsx for the live case).
97
130
  else if (standalone)
98
- console.log(chalk.dim(applyStandaloneFileTools(process.cwd())));
131
+ console.error(chalk.dim(applyStandaloneFileTools(process.cwd())));
99
132
  }
100
133
  const store = await getStore();
101
134
  if (!(await store.get())) {
@@ -104,9 +137,9 @@ export async function runOneShot(prompt, opts) {
104
137
  }
105
138
  // Credits (2026-09-16) — resolve the display mode before the turn runs, so
106
139
  // the per-turn cost JSONL records the ledger charge and nothing downstream
107
- // can print a dollar to a managed customer. One-shot prints no cost figure
108
- // of its own today, but the agent loop's cost-log path is shared with the
109
- // REPL and must not diverge. Capped + fail-safe; BYOK/local skip the call.
140
+ // can print a dollar to a managed customer — including the one-line stderr
141
+ // receipt after the turn (piece 2 I3a), and the agent loop's cost-log path,
142
+ // which is shared with the REPL. Capped + fail-safe; BYOK/local skip the call.
110
143
  await initCredits(cfg, async () => (await store.get()) ?? '');
111
144
  const aliases = Object.keys(cfg.sap);
112
145
  let alias;
@@ -117,12 +150,12 @@ export async function runOneShot(prompt, opts) {
117
150
  // filter + dispatch guard.
118
151
  alias = STANDALONE_ALIAS;
119
152
  adt = createOfflineAdtClient();
120
- console.log(chalk.dim('Standalone mode: no SAP system connected — skills work from pasted code. ' +
153
+ console.error(chalk.dim('Standalone mode: no SAP system connected — skills work from pasted code. ' +
121
154
  'Connect later with: cspeach config add-sap'));
122
155
  // One-shot is headless by design (no prompts), so an unanswered standalone
123
156
  // profile can't be filled in here — say how, and carry on with "unknown".
124
157
  if (!cfg.standalone) {
125
- console.log(chalk.dim('Standalone profile not set — run: cspeach config set standalone.platform ...'));
158
+ console.error(chalk.dim('Standalone profile not set — run: cspeach config set standalone.platform ...'));
126
159
  }
127
160
  }
128
161
  else {
@@ -133,9 +166,19 @@ export async function runOneShot(prompt, opts) {
133
166
  // C.1 — --sap-alias flag override
134
167
  alias = opts?.sapAlias ?? aliases[0];
135
168
  if (opts?.sapAlias) {
136
- console.log(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
169
+ console.error(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
170
+ }
171
+ let conn;
172
+ try {
173
+ conn = await getConnection(alias, cfg.sap[alias]);
174
+ }
175
+ catch (err) {
176
+ const code = unreachableCode(err);
177
+ if (!code)
178
+ throw err;
179
+ console.error(unreachableMessage(alias, cfg.sap[alias].host, cfg.sap[alias].port, code));
180
+ return 1;
137
181
  }
138
- const conn = await getConnection(alias, cfg.sap[alias]);
139
182
  adt = new AdtClient(conn);
140
183
  }
141
184
  const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg));
@@ -155,30 +198,51 @@ export async function runOneShot(prompt, opts) {
155
198
  skill = parsed.skill;
156
199
  }
157
200
  else {
158
- // One-shot has no interactive picker — always uses top-1 and prints the
159
- // confidence band on a dim prelude line. If < 0.6, emit a stderr warning
160
- // with top-3 alternates so the user can rerun with an explicit /skill.
161
- const c = await classifyPrompt(body, {
162
- sap_system: alias,
163
- last_skill: session.last_skill ?? null,
164
- last_object: parsed.object ?? session.last_object ?? null,
165
- });
201
+ // One-shot has no interactive picker — always uses top-1 and names it on
202
+ // a dim stderr line. If < 0.6, add a stderr `!` notice with the top-3
203
+ // candidates so the user can rerun with an explicit /skill.
204
+ // Fix round 3 (item 8) — say we are routing while the classifier runs
205
+ // (stderr; nothing prints when it answers within a second).
206
+ const routingBeat = startThinkingHeartbeat({ thresholds: ROUTING_HEARTBEAT_THRESHOLDS });
207
+ let c;
208
+ try {
209
+ c = await classifyPrompt(body, {
210
+ sap_system: alias,
211
+ last_skill: session.last_skill ?? null,
212
+ last_object: parsed.object ?? session.last_object ?? null,
213
+ });
214
+ }
215
+ catch (err) {
216
+ // Round 4 item 4 — a missing key here was an uncaught `no_api_key`
217
+ // with a stack (and a libuv assertion on exit). One clean line, exit 1.
218
+ if (err instanceof Error && err.message === 'no_api_key') {
219
+ console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
220
+ return 1;
221
+ }
222
+ throw err;
223
+ }
224
+ finally {
225
+ routingBeat.stop();
226
+ }
166
227
  if (c.offline) {
167
228
  // Remote classify failed twice — local-rules fallback fired. Print
168
229
  // to stderr so it doesn't pollute stdout for scripted callers, but
169
230
  // is still visible.
170
- console.error(chalk.dim(`[offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
231
+ // Fix 9 (2026-09-26): same true wording as the REPL — a timeout is not "offline".
232
+ console.error(chalk.dim(routingFailureOneShotLine(routingFailureFromResult(c), c.transport_error)));
171
233
  }
172
- const pct = Math.round(c.confidence * 100);
234
+ // Piece 2 (spec §8/§9) — routing chatter goes to STDERR so piped stdout
235
+ // stays the answer only. Confidence percentages are dropped everywhere.
173
236
  if (c.screenshot)
174
- console.log(chalk.dim(`┄ Screenshot: ${c.screenshot}`));
237
+ process.stderr.write(formatInfo(`Screenshot: ${c.screenshot}`));
238
+ process.stderr.write(formatRoutingLine({ kind: 'using', skill: c.skill }));
175
239
  if (c.confidence < 0.6) {
176
240
  const alternates = [c.skill, ...c.alternates].slice(0, 3).map((s) => `/${s}`).join(', ');
177
- console.error(chalk.yellow(`[classifier] Low confidence (${pct}%). Using /${c.skill}. ` +
178
- `Candidates: ${alternates}. Rerun with an explicit /skill to override.`));
179
- }
180
- else {
181
- console.log(chalk.dim(`┄ Routed to /${c.skill} (${pct}%)`));
241
+ process.stderr.write(formatAttention({
242
+ text: 'Low confidence',
243
+ fact: `candidates: ${alternates}`,
244
+ next: 'rerun with an explicit /skill to override',
245
+ }));
182
246
  }
183
247
  skill = c.skill;
184
248
  }
@@ -201,18 +265,59 @@ export async function runOneShot(prompt, opts) {
201
265
  throw new Error('No API key configured');
202
266
  return key;
203
267
  });
204
- await runTurn({
205
- provider,
206
- userMessage: body,
207
- skill,
208
- // B2 fix (2026-06-12): one-shot runs render tool notices ('warn'/'info'
209
- // — Rule 9 transport overrides, snapshot lines) to stdout instead of
210
- // dropping them. Headless transcripts need these for auditability.
211
- // Task 5 (ux-wave2): todoEmitter wired for the uniform top-level-ctx
212
- // contract — no listener exists in one-shot, so emits are no-ops, but
213
- // "top-level ctxs carry it / subagent ctxs omit it" stays the single
214
- // rule (see ui/todo-emitter.ts).
215
- ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook, chunkEmitter: createClassicOutputEmitter(), todoEmitter },
268
+ const turnSnapshot = snapshotTurnStart(session);
269
+ // Round 6 item 4f — stdout is the answer, trimmed: no blank lines before
270
+ // it, exactly one newline after it.
271
+ const answerTrim = new AnswerTrim();
272
+ const answerEmitter = createClassicOutputEmitter((s) => {
273
+ const out = answerTrim.feed(s);
274
+ if (!out)
275
+ return;
276
+ // Review M3 — a painting spinner ends the line itself; tell the trim so
277
+ // its held newline does not become a stray blank line.
278
+ const forced = aboveSpinnerAddsNewline(out);
279
+ writeAboveSpinner(out, writeStdout);
280
+ if (forced)
281
+ answerTrim.noteForcedNewline();
216
282
  });
283
+ // Owner ruling (r3 M11) — stdout is the answer only, everywhere: tool
284
+ // notices (Rule 9 transport overrides, snapshot lines, the write chain)
285
+ // and tool rows go to STDERR in one-shot.
286
+ const noticeEmitter = createClassicOutputEmitter((s) => { process.stderr.write(s); });
287
+ try {
288
+ await runTurn({
289
+ provider,
290
+ userMessage: body,
291
+ skill,
292
+ // Fix round 7 item 2 — runTurn reads params.chunkEmitter (NOT
293
+ // ctx.chunkEmitter) for the answer stream; without this the trim never
294
+ // ran. Liveness stays off stdout (see loop.ts livenessEmitter).
295
+ chunkEmitter: answerEmitter,
296
+ // B2 fix (2026-06-12): one-shot runs render tool notices ('warn'/'info'
297
+ // — Rule 9 transport overrides, snapshot lines) instead of dropping them;
298
+ // headless transcripts need them for auditability. Owner ruling (r3 M11):
299
+ // they go to STDERR (noticeEmitter) — stdout is the answer only.
300
+ // Task 5 (ux-wave2): todoEmitter wired for the uniform top-level-ctx
301
+ // contract — no listener exists in one-shot, so emits are no-ops, but
302
+ // "top-level ctxs carry it / subagent ctxs omit it" stays the single
303
+ // rule (see ui/todo-emitter.ts).
304
+ ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook, chunkEmitter: noticeEmitter, todoEmitter },
305
+ });
306
+ }
307
+ finally {
308
+ // Review M2 — even when the turn throws, the answer so far ends with its
309
+ // newline (and any held colour reset) instead of being cut mid-line.
310
+ const answerTail = answerTrim.end();
311
+ if (answerTail)
312
+ writeStdout(answerTail);
313
+ }
314
+ // Piece 2 (spec §8) — the one-line receipt, on STDERR (stdout = the answer).
315
+ const facts = computeTurnFacts(session, turnSnapshot);
316
+ process.stderr.write(formatTurnReceipt({
317
+ skill,
318
+ tokens: facts.turnTokens,
319
+ spend: turnSpendText({ costThisTurn: facts.turnCost, credits: globalCredits.snapshot() }),
320
+ ms: facts.elapsedMs,
321
+ }) + '\n');
217
322
  return 0;
218
323
  }
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import { readProjectFile } from './status.js';
3
3
  import { validateEnvelope } from './validate.js';
4
+ import { askYesNo } from './yes-no.js';
4
5
  /**
5
6
  * The still-open BLOCKER-severity questions of a spec-gap envelope, in item
6
7
  * order. Returns [] for any non-spec-gap artefact (only spec-gap has gaps with
@@ -81,7 +82,11 @@ export async function offerAnswerBlockers(args) {
81
82
  const blockers = openBlockers(env);
82
83
  if (blockers.length === 0)
83
84
  return null;
84
- const reply = await args.prompt(`Answer the ${blockers.length} unanswered blocker(s) now? [y/N]: `);
85
+ const reply = await askYesNo({
86
+ question: `Answer the ${blockers.length} unanswered blocker(s) now?`,
87
+ confirm: args.confirm,
88
+ prompt: args.prompt,
89
+ });
85
90
  if (!affirmative(reply))
86
91
  return null;
87
92
  const answers = new Map();
@@ -17,32 +17,33 @@ import { basename, dirname, join } from 'node:path';
17
17
  import chalk from 'chalk';
18
18
  import { statusesFromItems, computeNextPhase, isPhaseSatisfied, planCompletionLines, } from './plan-run.js';
19
19
  import { projectFilename } from './filename.js';
20
+ import { UNICODE_GLYPHS } from '../renderer/glyphs.js';
20
21
  import { readProjectFile } from './status.js';
21
22
  /**
22
- * Plain-text mirror of the tracker's auditGlyph wording (plan-run.ts) — same
23
- * words, no chalk. Empty-audit phases render NO segment, matching the
24
- * tracker's legacy-envelope behaviour. The `default` arm is deliberate
25
- * forward-compat: an unknown future PlanAuditState reads "audit not run"
26
- * instead of crashing the projection.
23
+ * Plain-text mirror of the tracker's audit words (plan-run.ts, piece 2 §4) —
24
+ * same words, no chalk, LITERAL unicode glyphs: this is file content, so it
25
+ * never follows a terminal's ASCII fallback. Empty-audit phases render NO
26
+ * segment, matching the tracker's legacy-envelope behaviour. The `default`
27
+ * arm is deliberate forward-compat: an unknown future PlanAuditState reads
28
+ * "audit not run" instead of crashing the projection.
27
29
  */
28
30
  function auditWords(audit) {
29
31
  if (!audit)
30
32
  return null;
31
33
  switch (audit.state) {
32
- case 'passed': return '✔ audited';
34
+ case 'passed': return '✓ audited';
33
35
  case 'warn': {
34
- // Non-blocking tier — passed WITH notes. Plain-text mirror of the
35
- // tracker's warn glyph; count comes from findings.
36
+ // Non-blocking tier — passed WITH notes; count comes from findings.
36
37
  const n = audit.findings?.length ?? 0;
37
- return `⚠ passed with ${n} note${n === 1 ? '' : 's'}`;
38
+ return `! ${n} note${n === 1 ? '' : 's'}`;
38
39
  }
39
- case 'waived': return `✎ waived${audit.waiveCategory ? ` (${audit.waiveCategory})` : ''}`;
40
- case 'failed': return '✖ audit failed';
41
- case 'not_audited_hand_edited': return '✋ hand-edited';
40
+ case 'waived': return `· waived${audit.waiveCategory ? ` (${audit.waiveCategory})` : ''}`;
41
+ case 'failed': return '✗ audit failed';
42
+ case 'not_audited_hand_edited': return '· hand-edited';
42
43
  case 'pending':
43
44
  case 'infra_failed':
44
45
  default:
45
- return '⚠ audit not run';
46
+ return '! audit not run';
46
47
  }
47
48
  }
48
49
  /**
@@ -117,7 +118,7 @@ export function renderPlanHandover(envelope) {
117
118
  // resolves — version-less/newest-version resolution in plan-resume.ts
118
119
  // matches the whole family.
119
120
  const nextAction = allSatisfied
120
- ? planCompletionLines(content.phases).join(' ')
121
+ ? planCompletionLines(content.phases, UNICODE_GLYPHS).join(' ')
121
122
  : `/abap-plan --resume @${projectFilename({
122
123
  title: envelope.title, artefactType: 'plan', id: envelope.id, version: envelope.version,
123
124
  })}`;
@@ -135,6 +135,14 @@ export function buildUserContent(text, images) {
135
135
  * hits the 32 MB request cap (and stops paying for stale screenshots).
136
136
  */
137
137
  export const MAX_HISTORY_IMAGES = 10;
138
+ /**
139
+ * Task 7 — stub images in batches, not one per turn. Each stub edits an
140
+ * earlier user message, and Opus 5.5 binds every later thinking block to the
141
+ * exact prefix that produced it, so each trim invalidates them (and the prompt
142
+ * cache). Once over `max`, the live count drops to `max - IMAGE_TRIM_BATCH + 1`
143
+ * (6 at max 10), so the next trim is IMAGE_TRIM_BATCH images away.
144
+ */
145
+ export const IMAGE_TRIM_BATCH = 5;
138
146
  /** Mutates `messages`; returns how many image blocks were removed. */
139
147
  export function trimHistoryImages(messages, max = MAX_HISTORY_IMAGES) {
140
148
  const slots = [];
@@ -146,10 +154,15 @@ export function trimHistoryImages(messages, max = MAX_HISTORY_IMAGES) {
146
154
  slots.push({ blocks: m.content, i });
147
155
  });
148
156
  }
149
- const excess = slots.length - max;
157
+ if (slots.length <= max)
158
+ return 0;
159
+ // Ruling F11: stub slots.length − (max − batch + 1) ⇒ 11 images at max 10
160
+ // stub the oldest 5. Clamped for a tiny `max` (never keep fewer than 1).
161
+ const keep = Math.max(1, max - IMAGE_TRIM_BATCH + 1);
162
+ const excess = slots.length - keep;
150
163
  for (let k = 0; k < excess; k++) {
151
164
  const { blocks, i } = slots[k];
152
165
  blocks[i] = { type: 'text', text: '[image removed from history]' };
153
166
  }
154
- return Math.max(0, excess);
167
+ return excess;
155
168
  }