@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
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Auto-compact orchestration — Phase 2 #9 (2026-05-16).
3
3
  *
4
- * Calls `runCompact` end-of-turn when the session has crossed
5
- * `compact.auto_threshold_tokens` non-cached input tokens AND enough turns
6
- * have passed since the last auto-compact run.
4
+ * Calls `runCompact` end-of-turn when the last model call's full context
5
+ * (input + cache read + cache write — Task 19, ruling F12) has reached
6
+ * `compact.auto_threshold_tokens` AND enough turns have passed since the last
7
+ * auto-compact run. The caller passes the RESOLVED threshold (file > served
8
+ * `auto_compact_tokens` > 150 000; see models/resolve.ts).
7
9
  *
8
10
  * Why end-of-turn (not start-of-turn): firing at the end means the cost
9
11
  * (one Haiku call to summarise) is amortised over the user's natural
@@ -21,7 +23,11 @@
21
23
  */
22
24
  import chalk from 'chalk';
23
25
  import { saveSession } from '../session/store.js';
24
- import { runCompact, formatCompactResult, } from './compact.js';
26
+ import { runCompact, planCompaction, canCompact } from './compact.js';
27
+ /** The number the gate compares: the context size, else the legacy counter. */
28
+ function gateTokens(input) {
29
+ return input.lastContextTokens ?? input.sessionInputTokens ?? 0;
30
+ }
25
31
  /**
26
32
  * Pure decision function — no IO. Decides whether auto-compact should
27
33
  * fire given the current session state and config. Extracted so the
@@ -29,11 +35,11 @@ import { runCompact, formatCompactResult, } from './compact.js';
29
35
  * Summariser.
30
36
  */
31
37
  export function shouldAutoCompact(input) {
32
- const { sessionInputTokens, turnNumber, lastAutoCompactTurn, config } = input;
38
+ const { turnNumber, lastAutoCompactTurn, config } = input;
33
39
  if (!config.auto_enabled) {
34
40
  return { fire: false, reason: 'disabled' };
35
41
  }
36
- if (sessionInputTokens < config.auto_threshold_tokens) {
42
+ if (gateTokens(input) < config.auto_threshold_tokens) {
37
43
  return { fire: false, reason: 'below-threshold' };
38
44
  }
39
45
  // Throttle: don't bounce. If we just ran auto-compact a few turns ago
@@ -43,7 +49,10 @@ export function shouldAutoCompact(input) {
43
49
  const turnsSince = lastAutoCompactTurn === undefined
44
50
  ? Number.POSITIVE_INFINITY
45
51
  : turnNumber - lastAutoCompactTurn;
46
- if (turnsSince < config.min_turns_between_auto) {
52
+ // Final review I4 — a marker ahead of the turn number (a legacy marker from
53
+ // the old user-message count, which shrinks after a compaction) never
54
+ // throttles; the loop now passes a monotonic per-session counter.
55
+ if (turnsSince >= 0 && turnsSince < config.min_turns_between_auto) {
47
56
  return { fire: false, reason: 'throttled' };
48
57
  }
49
58
  return { fire: true };
@@ -58,19 +67,28 @@ export function shouldAutoCompact(input) {
58
67
  export async function maybeAutoCompact(params) {
59
68
  const { session, config, turnNumber, summarise } = params;
60
69
  const emit = params.emit ?? ((line) => console.log(line));
61
- const decision = shouldAutoCompact({
70
+ const gate = {
71
+ lastContextTokens: session.last_context_tokens,
62
72
  sessionInputTokens: session.usage?.input_tokens ?? 0,
73
+ };
74
+ const decision = shouldAutoCompact({
75
+ ...gate,
63
76
  turnNumber,
64
77
  lastAutoCompactTurn: session.lastAutoCompactTurn,
65
78
  config,
66
79
  });
67
80
  if (!decision.fire)
68
81
  return;
69
- // Banner BEFORE the work so the user understands the pause they're about
70
- // to see. ~3s for a Haiku call on a long history.
71
- emit('');
72
- emit(chalk.yellow(`⚙ auto-compact: session crossed ${config.auto_threshold_tokens.toLocaleString()} input tokens`
73
- + ` — summarising older turns to keep the next turn cheap.`));
82
+ // Task 19 — at 150k a session can cross the gate with too few user turns
83
+ // for runCompact to cut anything (it would return "nothing to do"). Stay
84
+ // silent then, instead of printing the line on every turn (canCompact is
85
+ // the predicate runCompact's own bail-outs use).
86
+ if (!canCompact(planCompaction(session.messages, config.keep_recent_turns), config.keep_recent_turns))
87
+ return;
88
+ // Task 19 — ONE short line, printed when the work is done so it can carry
89
+ // the backup path (the one-step rollback). A failure prints its own line.
90
+ const head = `⚙ auto-compact: context ${gateTokens(gate).toLocaleString('en-US')} tokens ≥ `
91
+ + `${config.auto_threshold_tokens.toLocaleString('en-US')}`;
74
92
  try {
75
93
  const result = await runCompact({
76
94
  session,
@@ -82,13 +100,12 @@ export async function maybeAutoCompact(params) {
82
100
  // runCompact already saved the session inside its happy path, but we
83
101
  // mutate one more field (the throttle marker) so re-save to capture it.
84
102
  await saveSession(session);
103
+ emit(chalk.yellow(`${head} — older turns summarised · backup: ${result.backupPath}`));
85
104
  }
86
- emit(formatCompactResult(result, config.keep_recent_turns));
87
105
  }
88
106
  catch (err) {
89
107
  // Defensive: never let a summariser blow take down the turn.
90
108
  const msg = err instanceof Error ? err.message : String(err);
91
- emit(chalk.yellow(`⚙ auto-compact bailed: ${msg}`));
92
- emit(chalk.dim(' session left intact; /compact still available manually.'));
109
+ emit(chalk.yellow(`⚙ auto-compact bailed: ${msg} — session left intact; /compact still works.`));
93
110
  }
94
111
  }
@@ -26,8 +26,11 @@
26
26
  */
27
27
  import fs from 'node:fs/promises';
28
28
  import path from 'node:path';
29
- import chalk from 'chalk';
30
29
  import { sessionsDir } from '../config/paths.js';
30
+ import { panelHeading, panelRow } from '../renderer/panel-rows.js';
31
+ import { formatInfo } from '../renderer/notice-shape.js';
32
+ import { paint } from '../renderer/theme.js';
33
+ import { getGlyphs } from '../renderer/glyphs.js';
31
34
  import { saveSession } from '../session/store.js';
32
35
  import { isUserPrompt } from '../session/user-prompt.js';
33
36
  /** Keep this many recent user-prompt anchored turns verbatim. */
@@ -213,6 +216,32 @@ export function buildSummaryMessage(summary, droppedTurns, todos) {
213
216
  ].join('\n'),
214
217
  };
215
218
  }
219
+ const THINKING_BLOCK_TYPES = new Set(['thinking', 'redacted_thinking']);
220
+ /** Text left in an assistant message that held only thinking (an interrupted turn): the API rejects empty content. */
221
+ export const THINKING_ONLY_PLACEHOLDER = '(thinking removed by /compact)';
222
+ /**
223
+ * Task 7 — drop `thinking` / `redacted_thinking` blocks from an assistant
224
+ * message. User messages and string content are returned unchanged (the same
225
+ * object); every other block is kept as-is and in order. Never mutates the
226
+ * input. Idempotent.
227
+ */
228
+ export function stripThinking(m) {
229
+ if (m.role !== 'assistant' || !Array.isArray(m.content))
230
+ return m;
231
+ const blocks = m.content;
232
+ if (!blocks.some((b) => THINKING_BLOCK_TYPES.has(b?.type ?? '')))
233
+ return m;
234
+ const rest = blocks.filter((b) => !THINKING_BLOCK_TYPES.has(b?.type ?? ''));
235
+ return { ...m, content: rest.length > 0 ? rest : [{ type: 'text', text: THINKING_ONLY_PLACEHOLDER }] };
236
+ }
237
+ /**
238
+ * Task 19 — true when `plan` leaves enough older history for runCompact to
239
+ * summarise. The one predicate runCompact's bail-outs and the auto-compact
240
+ * silent check share.
241
+ */
242
+ export function canCompact(plan, keepRecentTurns) {
243
+ return plan.userPromptCount >= keepRecentTurns && plan.toSummarise.length >= MIN_COMPACT_THRESHOLD;
244
+ }
216
245
  /**
217
246
  * Top-level compact orchestration. Pure async function — caller wires it
218
247
  * into the REPL slash dispatcher. Throws only on catastrophic IO failure
@@ -226,7 +255,8 @@ export async function runCompact(opts) {
226
255
  const { session, summarise } = opts;
227
256
  const keep = opts.keepRecentTurns ?? KEEP_RECENT_TURNS;
228
257
  const plan = planCompaction(session.messages, keep);
229
- // Bail if there's not enough older history to be worth compacting.
258
+ // Bail if there's not enough older history to be worth compacting
259
+ // (the two checks canCompact combines; kept apart for their messages).
230
260
  if (plan.userPromptCount < keep) {
231
261
  return {
232
262
  performed: false,
@@ -259,7 +289,15 @@ export async function runCompact(opts) {
259
289
  // we accept losing the original first prompt because the summary should
260
290
  // mention what the goal was.)
261
291
  const summaryMsg = buildSummaryMessage(summary, plan.toSummarise.length, session.todos);
262
- session.messages = [summaryMsg, ...plan.toKeep];
292
+ // Task 7 — the API's conversation check rejects a replayed thinking block
293
+ // whose earlier turns changed (Opus 5.5 binds each block to the exact prefix
294
+ // that produced it). The summary changed them, so the kept tail goes out
295
+ // without thinking; text and tool calls stay. The backup above keeps all.
296
+ const kept = plan.toKeep.map(stripThinking);
297
+ session.messages = [summaryMsg, ...kept];
298
+ // Task 19 — the recorded context size described the old history; the next
299
+ // call records the new one. Clearing it keeps the auto-compact gate honest.
300
+ delete session.last_context_tokens;
263
301
  await saveSession(session);
264
302
  return {
265
303
  performed: true,
@@ -277,18 +315,18 @@ export function formatCompactResult(result,
277
315
  /** Optional — falls back to the legacy constant for back-compat. */
278
316
  keepRecentTurns = KEEP_RECENT_TURNS) {
279
317
  if (!result.performed) {
280
- return chalk.dim(`/compact: ${result.reason ?? 'nothing to do'}`);
318
+ return formatInfo(`/compact: ${result.reason ?? 'nothing to do'}`).trimEnd();
281
319
  }
282
320
  const lines = [
283
321
  '',
284
- chalk.bold('/compact complete'),
285
- chalk.dim(` dropped: ${result.droppedTurns} messages`),
286
- chalk.dim(` kept: ${result.keptTurns} messages (most recent ${keepRecentTurns} user turns intact)`),
287
- chalk.dim(` summary: ~${result.summaryTokens?.toLocaleString()} tokens`),
288
- chalk.dim(` backup: ${result.backupPath}`),
322
+ panelHeading('Compacted', 'older turns summarised'),
323
+ panelRow('Dropped', `${result.droppedTurns} messages`),
324
+ panelRow('Kept', `${result.keptTurns} messages (most recent ${keepRecentTurns} user turns intact)`),
325
+ panelRow('Summary', `~${result.summaryTokens?.toLocaleString()} tokens`),
326
+ panelRow('Backup', `${result.backupPath}`),
289
327
  '',
290
- chalk.green('Next turn\'s context is now roughly summary-size instead of full history.'),
291
- chalk.dim('Heads-up: the very next turn rebuilds the prompt cache (one-off cost), then turns after that are cheap again.'),
328
+ `${paint('ok', getGlyphs().done)} The next turn's context is roughly summary-size instead of the full history.`,
329
+ formatInfo('The very next turn rebuilds the prompt cache once; turns after that are cheap again.').trimEnd(),
292
330
  '',
293
331
  ];
294
332
  return lines.join('\n');
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { formatAttention } from '../renderer/notice-shape.js';
2
3
  import { loadConfig, saveConfig, isValidWriteMode } from '../config/loader.js';
3
4
  import { STANDALONE_PLATFORMS, STANDALONE_DATABASES, STANDALONE_DEPLOYMENTS, STANDALONE_IDES, isStandalonePlatform, deriveDatabase, deriveDeployment, defaultAbapCloudOnly, defaultRelease, } from '../sap/standalone-profile.js';
4
5
  /**
@@ -95,7 +96,7 @@ function errUnset(msg) {
95
96
  * Internal, loader-derived flags that are never serialised (stripped by
96
97
  * saveConfig). They must never be targets of `config set`/`config unset`.
97
98
  */
98
- const INTERNAL_KEYS = new Set(['default_model_set', 'file_keys']);
99
+ const INTERNAL_KEYS = new Set(['default_model_set', 'effort_set', 'file_keys']);
99
100
  /**
100
101
  * Virtual keys whose values live in the OS keychain, not config.toml. `config
101
102
  * unset` cannot remove them — that is a keychain operation — so they are
@@ -265,6 +266,10 @@ export async function runConfigUnset(args) {
265
266
  }
266
267
  const cfg = await loadConfig();
267
268
  const removed = unsetConfigKey(cfg, key);
269
+ // Task 4 — `effort` and its explicit marker go together: removing the value
270
+ // without the marker would leave a stale marker for a later bare effort.
271
+ if (key === 'effort')
272
+ unsetConfigKey(cfg, 'effort_explicit');
268
273
  if (!removed) {
269
274
  console.log(chalk.dim(`${key} was not set — nothing to unset.`));
270
275
  return;
@@ -350,8 +355,11 @@ export async function runConfigSet(args) {
350
355
  }
351
356
  cfg.sap[alias].insecure_skip_tls_verify = b;
352
357
  if (b) {
353
- console.log(chalk.yellow(` ⚠ TLS verification will be DISABLED for ${alias} — dev only, NEVER use in production. ` +
354
- `Prefer: cspeach config set sap.${alias}.ca_cert_path <path-to-corporate-CA>`));
358
+ console.log(formatAttention({
359
+ text: `TLS verification is off for ${alias}`,
360
+ fact: 'dev only, never in production',
361
+ next: `cspeach config set sap.${alias}.ca_cert_path <path-to-corporate-CA>`,
362
+ }).trimEnd());
355
363
  }
356
364
  await saveConfig(cfg);
357
365
  console.log(chalk.green(`Set ${key} = ${b}`));
@@ -455,6 +463,11 @@ export async function runConfigSet(args) {
455
463
  err(`Invalid effort value "${value}". Allowed: ${EFFORT_VALUES.join(', ')}`);
456
464
  }
457
465
  cfg.effort = value;
466
+ // Task 4 (ruling F2) — the marker makes this an explicit act: only then
467
+ // does resolveEffort send output_config.effort. Kept on save even when
468
+ // the value equals the built-in default (xhigh).
469
+ cfg.effort_explicit = true;
470
+ markExplicit(cfg, 'effort_explicit');
458
471
  break;
459
472
  case 'telemetry':
460
473
  if (!TELEMETRY_VALUES.includes(value)) {
@@ -1,5 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadConfig } from '../config/loader.js';
3
+ import { paint } from '../renderer/theme.js';
4
+ import { formatAttention } from '../renderer/notice-shape.js';
5
+ import { getCustomerModelConfig } from '../models/server-config.js';
3
6
  /**
4
7
  * Dotted paths of keys that must never appear in plaintext config output.
5
8
  * SAP passwords live in the keychain and should never land in config.toml,
@@ -23,6 +26,10 @@ const REDACTED_KEYS = new Set([
23
26
  const INTERNAL_KEYS = new Set([
24
27
  'default_model_set',
25
28
  'file_keys',
29
+ // Task 4 — effort_set is loader-derived; effort_explicit is the stored
30
+ // marker behind it (plumbing, not a user-facing setting).
31
+ 'effort_set',
32
+ 'effort_explicit',
26
33
  ]);
27
34
  function formatTomlValue(value) {
28
35
  if (typeof value === 'string')
@@ -57,7 +64,7 @@ function printLlmMissingHints(llm) {
57
64
  for (const key of required) {
58
65
  const value = llm[key];
59
66
  if (value === undefined || value === null || value === '') {
60
- console.log(`${key} = ${chalk.red('<not set>')}`);
67
+ console.log(`${key} = ${paint('bad', '<not set>')}`);
61
68
  }
62
69
  }
63
70
  }
@@ -83,14 +90,14 @@ function printConfigAsToml(cfg, prefix = '') {
83
90
  // the implication (writes proposed, never executed) without having to
84
91
  // read the docs.
85
92
  if (fullKey === 'write_mode' && v === 'advisory-only') {
86
- console.log(chalk.yellow(' → Advisory mode: writes will be proposed, never executed.'));
93
+ console.log(' ' + formatAttention({ text: 'Advisory mode', fact: 'writes are proposed, never executed' }).trimEnd());
87
94
  }
88
95
  }
89
96
  }
90
97
  for (const [k, v] of tables) {
91
98
  const fullKey = prefix ? `${prefix}.${k}` : k;
92
99
  console.log('');
93
- console.log(chalk.cyan(`[${fullKey}]`));
100
+ console.log(chalk.bold(`[${fullKey}]`));
94
101
  printConfigAsToml(v, fullKey);
95
102
  // Mode-aware extras for the `[llm]` section — print required keys that
96
103
  // are empty as `<not set>` so a user who ran `cspeach config set llm.mode
@@ -136,6 +143,12 @@ export async function runConfigShow(args) {
136
143
  console.log(chalk.gray('# ~/.cspeach/config.toml'));
137
144
  printConfigAsToml(cfg);
138
145
  console.log('');
146
+ // Task 12 — one trailing line when the account admin's model is enforced
147
+ // by the proxy (/v1/me/model-config). No snapshot ⇒ output unchanged.
148
+ const customer = getCustomerModelConfig();
149
+ if (customer?.model && customer.enforced) {
150
+ console.log(chalk.gray(`# model set by your account admin: ${customer.model} (enforced)`));
151
+ }
139
152
  return;
140
153
  }
141
154
  const keyPath = args[0];
@@ -158,7 +171,7 @@ export async function runConfigShow(args) {
158
171
  if (typeof resolved === 'object' && resolved !== null) {
159
172
  // Sub-object (e.g. sap.S4H-DEV) — print as TOML section
160
173
  console.log('');
161
- console.log(chalk.cyan(`[${keyPath}]`));
174
+ console.log(chalk.bold(`[${keyPath}]`));
162
175
  for (const [k, v] of Object.entries(resolved)) {
163
176
  const subKey = `${keyPath}.${k}`;
164
177
  if (REDACTED_KEYS.has(subKey)) {
@@ -173,7 +186,7 @@ export async function runConfigShow(args) {
173
186
  else {
174
187
  console.log(String(resolved));
175
188
  if (keyPath === 'write_mode' && resolved === 'advisory-only') {
176
- console.log(chalk.yellow(' → Advisory mode: writes will be proposed, never executed.'));
189
+ console.log(' ' + formatAttention({ text: 'Advisory mode', fact: 'writes are proposed, never executed' }).trimEnd());
177
190
  }
178
191
  }
179
192
  }
@@ -12,6 +12,10 @@ import chalk from 'chalk';
12
12
  import { readCostLog, summarise } from '../cost/cost-log.js';
13
13
  import { formatCost } from '../cost/pricing.js';
14
14
  import { creditsActive, formatCredits, globalCredits, CREDITS_PENDING, } from '../cost/credits.js';
15
+ import { panelHeading, panelRow } from '../renderer/panel-rows.js';
16
+ import { formatInfo } from '../renderer/notice-shape.js';
17
+ import { countLabel } from '../renderer/checklist-format.js';
18
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
15
19
  /**
16
20
  * Sum the credits the proxy charged across the logged turns. Entries written
17
21
  * before credits existed (or on turns whose balance fetch failed) carry no
@@ -21,6 +25,9 @@ function summariseCredits(entries) {
21
25
  let total = 0;
22
26
  let unknown = 0;
23
27
  for (const e of entries) {
28
+ // Final review I6 — a zero-token reader rollup line is not a turn.
29
+ if (e.kind === 'reader_rollup')
30
+ continue;
24
31
  if (typeof e.credits_charged === 'number')
25
32
  total += e.credits_charged;
26
33
  else
@@ -28,12 +35,18 @@ function summariseCredits(entries) {
28
35
  }
29
36
  return { total, unknown };
30
37
  }
31
- export async function runCostCommand(sessionId, credits = globalCredits.snapshot()) {
38
+ /** Model ids can come from server-side model governance: one line, no control chars. */
39
+ const modelText = (m) => sanitizeControlChars(m).replace(/\s*\r?\n\s*/g, ' ').trim();
40
+ /**
41
+ * `emit` (piece 2) — Ink passes the chunkEmitter so the panel lands in the
42
+ * transcript, not above the React frame. Default: console.log (classic).
43
+ */
44
+ export async function runCostCommand(sessionId, credits = globalCredits.snapshot(), emit = (l) => console.log(l)) {
32
45
  const entries = await readCostLog(sessionId);
33
46
  if (entries.length === 0) {
34
- console.log('');
35
- console.log(chalk.dim('No cost data yet for this session. Run a turn first.'));
36
- console.log('');
47
+ emit('');
48
+ emit(formatInfo('No cost data yet for this session. Run a turn first.').trimEnd());
49
+ emit('');
37
50
  return;
38
51
  }
39
52
  const s = summarise(entries);
@@ -43,47 +56,53 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
43
56
  // stay: they are a usage fact, not a COGS leak.
44
57
  const inCredits = creditsActive();
45
58
  const perTurn = summariseCredits(entries);
46
- // Header
47
- console.log('');
59
+ const turnsNote = `${s.turns} turn${s.turns === 1 ? '' : 's'}`;
60
+ emit('');
48
61
  if (inCredits) {
49
62
  const unit = credits?.unitLabel ?? 'credits';
50
63
  // Prefer the live session tracker (balance deltas observed this run);
51
64
  // fall back to the logged per-turn charges when it has nothing yet.
52
65
  const total = credits && credits.sessionTotal > 0 ? credits.sessionTotal : perTurn.total;
53
- console.log(chalk.bold(`Session usage so far: ${formatCredits(total, unit)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
54
- if (credits?.balance != null) {
55
- console.log(chalk.dim(`Remaining balance: ${formatCredits(credits.balance, unit)}`));
56
- }
66
+ emit(panelHeading(`Session usage so far: ${formatCredits(total, unit)}`, turnsNote));
67
+ if (credits?.balance != null)
68
+ emit(panelRow('Balance', formatCredits(credits.balance, unit)));
57
69
  }
58
70
  else {
59
- console.log(chalk.bold(`Session cost so far: ${formatCost(s.totalCost)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
71
+ emit(panelHeading(`Session cost so far: ${formatCost(s.totalCost)}`, turnsNote));
72
+ }
73
+ emit('');
74
+ emit(chalk.bold('Tokens'));
75
+ emit(panelRow('input', s.totalTokens.input.toLocaleString().padStart(12)));
76
+ emit(panelRow('output', s.totalTokens.output.toLocaleString().padStart(12)));
77
+ emit(panelRow('cache read', s.totalTokens.cacheRead.toLocaleString().padStart(12)));
78
+ emit(panelRow('cache create', s.totalTokens.cacheCreate.toLocaleString().padStart(12)));
79
+ // Task 18 fix M3 — reader subagent spend (already inside the total above).
80
+ // USD only: in credits mode the balance delta already carries it.
81
+ if (s.readerCalls > 0 && !inCredits) {
82
+ emit('');
83
+ emit(panelRow('Readers', `${s.readerCalls} run${s.readerCalls === 1 ? '' : 's'} ${formatCost(s.readerCost)}`));
60
84
  }
61
- // Token breakdown
62
- console.log('');
63
- console.log(chalk.dim('Tokens:'));
64
- console.log(` input ${s.totalTokens.input.toLocaleString().padStart(12)}`);
65
- console.log(` output ${s.totalTokens.output.toLocaleString().padStart(12)}`);
66
- console.log(` cache read ${s.totalTokens.cacheRead.toLocaleString().padStart(12)}`);
67
- console.log(` cache create ${s.totalTokens.cacheCreate.toLocaleString().padStart(12)}`);
68
- // Per-model split (Phase 2 will make this interesting)
69
85
  if (s.byModel.length > 1) {
70
- console.log('');
71
- console.log(chalk.dim('By model:'));
86
+ emit('');
87
+ emit(chalk.bold('By model:'));
72
88
  for (const m of s.byModel) {
73
89
  // Credits mode: turn counts only. A per-model dollar split is a direct
74
90
  // read on our per-model COGS.
75
91
  const tail = inCredits ? '' : ` ${formatCost(m.cost)}`;
76
- console.log(` ${m.model.padEnd(24)} ${m.turns} turn${m.turns === 1 ? '' : 's'}${tail}`);
92
+ emit(panelRow(modelText(m.model), `${m.turns} turn${m.turns === 1 ? '' : 's'}${tail}`, 24));
77
93
  }
78
94
  }
79
95
  else if (s.byModel[0]) {
80
- console.log('');
81
- console.log(chalk.dim(`Model: ${s.byModel[0].model}`));
96
+ emit('');
97
+ emit(panelRow('Model', modelText(s.byModel[0].model)));
82
98
  }
83
- // Recent turns — last 5 lines, most recent first
84
- const recent = entries.slice(-5).reverse();
85
- console.log('');
86
- console.log(chalk.dim(`Recent turns (last ${recent.length}):`));
99
+ // Recent turns — last 5, most recent first. Piece 2: one count format, "N of M" (P1's countLabel).
100
+ // Final review I6 — reader rollup lines (interrupted turns) are not turns:
101
+ // they stay out of the list and the count (summarise() already skips them).
102
+ const turnEntries = entries.filter((e) => e.kind !== 'reader_rollup');
103
+ const recent = turnEntries.slice(-5).reverse();
104
+ emit('');
105
+ emit(chalk.bold(`Recent turns · ${countLabel(recent.length, turnEntries.length)}`));
87
106
  for (const e of recent) {
88
107
  const tok = (e.tokens.input + e.tokens.output).toLocaleString();
89
108
  // Credits mode: the per-turn figure is what the LEDGER charged (a balance
@@ -95,14 +114,14 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
95
114
  ? formatCredits(e.credits_charged, credits?.unitLabel ?? 'credits')
96
115
  : CREDITS_PENDING)
97
116
  : formatCost(e.cost);
98
- console.log(` turn ${String(e.turn).padStart(3)} ${tok.padStart(10)} tok ${tail}`);
117
+ emit(panelRow(`turn ${String(e.turn).padStart(3)}`, `${tok.padStart(10)} tok ${tail}`, 8));
99
118
  }
100
119
  if (inCredits && perTurn.unknown > 0) {
101
- console.log('');
102
- console.log(chalk.dim(`${perTurn.unknown} turn${perTurn.unknown === 1 ? '' : 's'} had no balance reading — `
103
- + `their usage is included in a later turn's figure.`));
120
+ emit('');
121
+ emit(formatInfo(`${perTurn.unknown} turn${perTurn.unknown === 1 ? '' : 's'} had no balance reading — `
122
+ + `their usage is included in a later turn's figure.`).trimEnd());
104
123
  }
105
- console.log('');
106
- console.log(chalk.dim(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`));
107
- console.log('');
124
+ emit('');
125
+ emit(formatInfo(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`).trimEnd());
126
+ emit('');
108
127
  }