@cspeach/cli 1.0.0 → 1.1.0

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 (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +59 -66
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
@@ -0,0 +1,459 @@
1
+ // cspeach-cli/src/session/audit-export.ts
2
+ //
3
+ // UX Wave 3 / Task 4 — `/export audit`: the signable session record.
4
+ //
5
+ // buildAuditReport turns a SessionState + its cost log into ONE markdown
6
+ // document an SAP team lead or auditor can read to know exactly who did what,
7
+ // where, under which approval, and at what cost. It is a PURE builder:
8
+ // - no Date.now / no clock reads — every timestamp is derived from session
9
+ // data (toolCall completed_at / started_at, session.last_turn_at). Two
10
+ // calls on the same input are byte-identical.
11
+ // - no network, no fs. The `/export audit` command (commands/export-audit.ts)
12
+ // wraps this with the cost-log read and the file write.
13
+ //
14
+ // ── Approval → write pairing rule (load-bearing; see the auditor promise) ──
15
+ // The `request_approval` tool mints one JWT per approved change and returns
16
+ // them in its result as `approval_ids: string[]`. A write tool (sap_set_source,
17
+ // sap_create_object, …) is called with `approval_id: <that exact JWT>`, and the
18
+ // loop records the FULL tool input in `toolCalls[].args` (agent/loop.ts
19
+ // recordCompletedToolCall — it upserts over the leaner WAL entry, so the final
20
+ // persisted args carry approval_id). We therefore pair a write to its approval
21
+ // by EXACT JWT STRING EQUALITY: the approval record whose `approval_ids`
22
+ // contains the write's `args.approval_id`. This is a reliable 1:1 linkage — the
23
+ // write literally echoes back the credential it was issued — so per-write
24
+ // approval columns are honest, not a heuristic guess. (A declined change never
25
+ // gets a JWT, so it can never appear in the Writes table; it surfaces only in
26
+ // the Declined section.) Fallback for a truncated approval result (the loop
27
+ // caps recorded result payloads at 4 kB, and many long JWTs could overflow):
28
+ // if the result JSON won't parse, we substring-scan the raw text for the JWT
29
+ // and regex out auto_approved / effective_risk. A write whose approval_id has
30
+ // no match at all (e.g. minted in a prior session) renders '—' — an honest gap,
31
+ // never an invented approval.
32
+ import { summarise } from '../cost/cost-log.js';
33
+ import { CLI_VERSION } from '../lib/version.js';
34
+ /**
35
+ * Object-mutating tools — one row per completed call in the Writes table.
36
+ * Kept as a literal name set (not the tool registry) so this pure builder
37
+ * never triggers tool registration side-effects. sap_lock / sap_unlock are
38
+ * deliberately excluded: they are transient edit-lock primitives, not object
39
+ * change records. Mirrors the `isMutating: true` tools in tools/sap-write.ts.
40
+ */
41
+ const WRITE_TOOLS = new Set([
42
+ 'sap_set_source',
43
+ 'sap_update_method',
44
+ 'sap_delete_object',
45
+ 'sap_create_object',
46
+ 'sap_set_class_include',
47
+ 'sap_create_domain',
48
+ 'sap_create_data_element',
49
+ 'sap_activate',
50
+ 'sap_service_binding_publish',
51
+ 'sap_service_binding_unpublish',
52
+ 'sap_message_maintain',
53
+ 'sap_number_range_intervals',
54
+ // CTS mutations (tools/transport.ts) — both isMutating + JWT-gated. A
55
+ // transport RELEASE never appearing in an audit document would be the
56
+ // wrong silence.
57
+ 'sap_transport_create',
58
+ 'sap_transport_release',
59
+ ]);
60
+ /**
61
+ * Non-mutating tools that are interaction/plumbing, not object reads — excluded
62
+ * from the Reads counts so the summary reflects genuine system reads. Approvals
63
+ * have their own section; ask_question is a user prompt.
64
+ */
65
+ const NON_READ_TOOLS = new Set(['request_approval', 'ask_question']);
66
+ function asRecord(v) {
67
+ return v && typeof v === 'object' ? v : {};
68
+ }
69
+ function str(v) {
70
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
71
+ }
72
+ /** Timestamp of a tool call — completed_at when finished, else the pending start. */
73
+ function tsOf(c) {
74
+ return c.completed ? c.completed_at : c.started_at;
75
+ }
76
+ /** "HH:MM:SS" slice of an ISO timestamp; '—' if unparseable. */
77
+ function timeCell(iso) {
78
+ return iso.length >= 19 ? iso.slice(11, 19) : '—';
79
+ }
80
+ function parseApprovals(toolCalls) {
81
+ const recs = [];
82
+ for (const c of toolCalls) {
83
+ if (!c.completed || c.tool !== 'request_approval')
84
+ continue;
85
+ const raw = c.result ?? '';
86
+ const args = asRecord(c.args);
87
+ let rec;
88
+ try {
89
+ const r = asRecord(JSON.parse(raw));
90
+ const skipped = Array.isArray(r.skipped)
91
+ ? r.skipped.map((s) => {
92
+ const so = asRecord(s);
93
+ return {
94
+ object: str(so.object) ?? '—',
95
+ op: str(so.op) ?? '—',
96
+ reason: str(so.reason) ?? 'skipped',
97
+ };
98
+ })
99
+ : [];
100
+ rec = {
101
+ ids: Array.isArray(r.approval_ids) ? r.approval_ids.filter((x) => typeof x === 'string') : [],
102
+ approved: r.approved === true,
103
+ autoApproved: r.auto_approved === true,
104
+ risk: str(r.effective_risk),
105
+ skipped,
106
+ declineReason: r.approved === false ? str(r.reason) : undefined,
107
+ summary: str(args.summary),
108
+ raw,
109
+ parseFailed: false,
110
+ };
111
+ }
112
+ catch {
113
+ // Truncated / malformed result — keep raw for substring pairing.
114
+ rec = {
115
+ ids: [],
116
+ approved: /"approved":\s*true/.test(raw),
117
+ autoApproved: /"auto_approved":\s*true/.test(raw),
118
+ risk: /"effective_risk":\s*"(low|medium|high)"/.exec(raw)?.[1],
119
+ skipped: [],
120
+ summary: str(args.summary),
121
+ raw,
122
+ parseFailed: true,
123
+ };
124
+ }
125
+ recs.push(rec);
126
+ }
127
+ return recs;
128
+ }
129
+ /** Pair a write's approval_id (a minted JWT) to its approval record. */
130
+ function findApproval(approvalId, recs) {
131
+ if (!approvalId)
132
+ return undefined;
133
+ for (const r of recs)
134
+ if (r.ids.includes(approvalId))
135
+ return r;
136
+ // Fallback: the approval result was truncated past its approval_ids array.
137
+ for (const r of recs)
138
+ if (r.parseFailed && r.raw.includes(approvalId))
139
+ return r;
140
+ return undefined;
141
+ }
142
+ function approvalCell(rec) {
143
+ if (!rec)
144
+ return '—';
145
+ const risk = rec.risk ? ` (${rec.risk})` : '';
146
+ if (rec.autoApproved)
147
+ return `auto ✓${risk}`;
148
+ if (rec.approved)
149
+ return `approved ✓${risk}`;
150
+ return `declined ✗${risk}`;
151
+ }
152
+ // ── Object / snapshot extraction ──────────────────────────────────────────
153
+ function writeObject(tool, args, result) {
154
+ switch (tool) {
155
+ case 'sap_transport_create': {
156
+ // The created transport number lives in the RESULT (transport_number),
157
+ // not the args — args only carry the description.
158
+ let r = {};
159
+ try {
160
+ r = asRecord(JSON.parse(result ?? ''));
161
+ }
162
+ catch { /* r stays {} */ }
163
+ const tr = str(r.transport_number);
164
+ if (tr)
165
+ return tr;
166
+ // already_locked (#8): SAP made NO new transport — it returned an
167
+ // existing owning request. Name that request and annotate it as a
168
+ // transport op; never the create description, which is not an object
169
+ // and misreads in the Object cell.
170
+ if (r.already_locked === true) {
171
+ const locked = str(r.transport);
172
+ return locked ? `${locked} (already locked)` : 'transport (already locked)';
173
+ }
174
+ // Last resort: name the operation, not the raw description.
175
+ return 'transport';
176
+ }
177
+ case 'sap_transport_release':
178
+ return str(args.transport) ?? '—';
179
+ case 'sap_update_method': {
180
+ const cls = str(args.class_name) ?? '—';
181
+ const m = str(args.method_name);
182
+ return m ? `${cls}~${m}` : cls;
183
+ }
184
+ case 'sap_set_class_include': {
185
+ const cls = str(args.className) ?? '—';
186
+ const inc = str(args.includeType);
187
+ return inc ? `${cls} (${inc})` : cls;
188
+ }
189
+ case 'sap_activate': {
190
+ if (Array.isArray(args.objects)) {
191
+ const names = args.objects.map((o) => str(asRecord(o).name) ?? '?');
192
+ return names.length ? names.join(', ') : '—';
193
+ }
194
+ return '—';
195
+ }
196
+ default:
197
+ return str(args.name) ?? '—';
198
+ }
199
+ }
200
+ /**
201
+ * The type/name key a write's snapshot would be filed under. UPPERCASED on
202
+ * both sides (M1): a write may echo its object in lowercase while the snapshot
203
+ * recorded it uppercase (or vice versa); a case-sensitive key would drop
204
+ * legitimate restore-point evidence to an '—'. Mirrors candidates.ts, which
205
+ * compares `s.type.toUpperCase()` / `s.name.toUpperCase()`.
206
+ */
207
+ function snapshotKey(tool, args) {
208
+ if (tool === 'sap_update_method' || tool === 'sap_set_class_include') {
209
+ const cls = str(args.class_name) ?? str(args.className);
210
+ return cls ? `CLAS:${cls.toUpperCase()}` : undefined;
211
+ }
212
+ const name = str(args.name);
213
+ const type = str(args.type);
214
+ return name && type ? `${type.toUpperCase()}:${name.toUpperCase()}` : undefined;
215
+ }
216
+ /**
217
+ * Index explicit sap_snapshot_take results by uppercased `TYPE:NAME` → the
218
+ * list of takes (id + completion epoch). Auto-snapshots fired by the write
219
+ * tools are NOT itemized in toolCalls (they run inside the handler and their
220
+ * id is never surfaced in the write result), so only on-demand
221
+ * sap_snapshot_take calls yield an id here — otherwise the column is an
222
+ * honest '—'.
223
+ *
224
+ * We keep every take (not just the newest) so a write can be paired with the
225
+ * newest take that completed strictly BEFORE it — see newestSnapshotBefore.
226
+ */
227
+ function indexSnapshots(toolCalls) {
228
+ const map = new Map();
229
+ for (const c of toolCalls) {
230
+ if (!c.completed || c.tool !== 'sap_snapshot_take' || c.is_error)
231
+ continue;
232
+ try {
233
+ const snap = asRecord(asRecord(JSON.parse(c.result)).snapshot);
234
+ const id = str(snap.id);
235
+ const type = str(snap.type);
236
+ const name = str(snap.name);
237
+ if (!id || !type || !name)
238
+ continue;
239
+ const epoch = Date.parse(tsOf(c));
240
+ if (!Number.isFinite(epoch))
241
+ continue;
242
+ const key = `${type.toUpperCase()}:${name.toUpperCase()}`;
243
+ const list = map.get(key) ?? [];
244
+ list.push({ id, epoch });
245
+ map.set(key, list);
246
+ }
247
+ catch {
248
+ /* ignore malformed snapshot result */
249
+ }
250
+ }
251
+ return map;
252
+ }
253
+ /**
254
+ * The id of the newest snapshot take that completed STRICTLY BEFORE
255
+ * `beforeEpoch` — the write's own completion time. A take made after a write
256
+ * is not a pre-write restore point for it, so pairing it onto that write's row
257
+ * would make the signable record assert a restore point that never existed
258
+ * (I1). Newest-before wins, mirroring newestSnapshotBefore in
259
+ * rewind/candidates.ts.
260
+ */
261
+ function newestSnapshotBefore(takes, beforeEpoch) {
262
+ if (!takes || !Number.isFinite(beforeEpoch))
263
+ return undefined;
264
+ let best;
265
+ for (const t of takes) {
266
+ if (t.epoch >= beforeEpoch)
267
+ continue; // must be strictly before the write
268
+ if (!best || t.epoch > best.epoch)
269
+ best = t;
270
+ }
271
+ return best?.id;
272
+ }
273
+ /** Success (✓) unless the loop marked the call is_error. */
274
+ function outcomeCell(c) {
275
+ return c.is_error ? '✗' : '✓';
276
+ }
277
+ // ── Operator identity ─────────────────────────────────────────────────────
278
+ //
279
+ // SessionState carries NO SAP logon user (only the sap_system alias). The one
280
+ // identity available is the local CSPeach operator, resolved the same way as
281
+ // agent/loop.ts getAuthorIdentity (CSPEACH_AUTHOR_NAME → USER → USERNAME). It
282
+ // is the person who RAN CSPeach, not the SAP account writes landed under — the
283
+ // header labels it "Operator" and shows the SAP system separately so the two
284
+ // are never conflated. (Inlined rather than importing agent/loop.js to keep
285
+ // this a dependency-light pure builder.)
286
+ function resolveOperator() {
287
+ return (process.env.CSPEACH_AUTHOR_NAME ||
288
+ process.env.USER ||
289
+ process.env.USERNAME ||
290
+ 'unknown');
291
+ }
292
+ // ── Transports seen ───────────────────────────────────────────────────────
293
+ function transportsSeen(toolCalls, sessionTransport) {
294
+ const set = new Set();
295
+ if (sessionTransport)
296
+ set.add(sessionTransport);
297
+ for (const c of toolCalls) {
298
+ if (!c.completed)
299
+ continue;
300
+ const args = asRecord(c.args);
301
+ const t = str(args.transport);
302
+ if (t && WRITE_TOOLS.has(c.tool))
303
+ set.add(t);
304
+ // Owning-transport override / created transport surfaced in the result.
305
+ try {
306
+ const r = asRecord(JSON.parse(c.result));
307
+ const used = str(r.transport_used);
308
+ if (used)
309
+ set.add(used);
310
+ const created = str(r.transport_number);
311
+ if (created && (c.tool === 'sap_transport_create' || c.tool === 'sap_transport_release'))
312
+ set.add(created);
313
+ }
314
+ catch {
315
+ /* ignore */
316
+ }
317
+ }
318
+ return [...set].sort();
319
+ }
320
+ // ── Main builder ──────────────────────────────────────────────────────────
321
+ export function buildAuditReport(input) {
322
+ const { session, costEntries, transport } = input;
323
+ const calls = session.toolCalls;
324
+ const completed = calls.filter((c) => c.completed === true);
325
+ const approvals = parseApprovals(calls);
326
+ const snapIndex = indexSnapshots(calls);
327
+ // ── Date range: first → last tool call (fallback to session turn stamps) ──
328
+ let firstTs;
329
+ let lastTs;
330
+ for (const c of calls) {
331
+ const t = tsOf(c);
332
+ if (!firstTs || t < firstTs)
333
+ firstTs = t;
334
+ if (!lastTs || t > lastTs)
335
+ lastTs = t;
336
+ }
337
+ const dateFrom = firstTs ?? session.started_at;
338
+ const dateTo = lastTs ?? session.last_turn_at;
339
+ const lines = [];
340
+ const L = (s = '') => void lines.push(s);
341
+ // ── Header ────────────────────────────────────────────────────────────────
342
+ L('# CSPeach Session Audit');
343
+ L();
344
+ L(`- **Session:** \`${session.id}\``);
345
+ L(`- **SAP system:** ${session.sap_system ?? '—'}`);
346
+ L(`- **Operator (local):** ${resolveOperator()}`);
347
+ L(`- **Period:** ${dateFrom} → ${dateTo}`);
348
+ const trs = transportsSeen(calls, transport);
349
+ L(`- **Transport(s):** ${trs.length ? trs.map((t) => `\`${t}\``).join(', ') : '—'}`);
350
+ L();
351
+ L('> Scope: this record covers SAP-system mutations only — local file and');
352
+ L('> shell operations are excluded by design. Operator is the local CSPeach');
353
+ L('> user (who ran the session); the SAP logon user is not recorded in');
354
+ L('> session state. Plan audit states, if any, live in the plan handover,');
355
+ L('> not this report.');
356
+ L();
357
+ // ── Writes table ────────────────────────────────────────────────────────────
358
+ L('## Writes');
359
+ L();
360
+ const writes = completed.filter((c) => WRITE_TOOLS.has(c.tool));
361
+ if (writes.length === 0) {
362
+ L('_No mutating operations in this session._');
363
+ }
364
+ else {
365
+ L('| Time (UTC) | Tool | Object | Outcome | Approval | Snapshot |');
366
+ L('| --- | --- | --- | :---: | --- | --- |');
367
+ for (const c of writes) {
368
+ const args = asRecord(c.args);
369
+ const approvalId = str(args.approval_id);
370
+ const rec = findApproval(approvalId, approvals);
371
+ const key = snapshotKey(c.tool, args);
372
+ const snap = key ? newestSnapshotBefore(snapIndex.get(key), Date.parse(tsOf(c))) : undefined;
373
+ L(`| ${timeCell(tsOf(c))} | ${c.tool} | ${writeObject(c.tool, args, c.result)} | ${outcomeCell(c)} | ${approvalCell(rec)} | ${snap ? `\`${snap}\`` : '—'} |`);
374
+ }
375
+ }
376
+ L();
377
+ // ── Declined approvals ──────────────────────────────────────────────────────
378
+ L('## Declined approvals');
379
+ L();
380
+ // Whole-batch decline reasons (tools/approval.ts result `reason` values)
381
+ // mapped to auditor-readable labels; unknown reasons pass through verbatim.
382
+ const DECLINE_LABELS = {
383
+ plan_rejected: 'plan rejected',
384
+ user_cancelled_turn: 'turn cancelled',
385
+ headless_no_approval: 'headless — no approval possible',
386
+ };
387
+ const declines = [];
388
+ let anyTruncated = false;
389
+ for (const r of approvals) {
390
+ if (r.parseFailed)
391
+ anyTruncated = true;
392
+ for (const s of r.skipped) {
393
+ declines.push(`- ✗ ${s.op} \`${s.object}\` — ${s.reason}`);
394
+ }
395
+ if (r.skipped.length === 0 && !r.approved && r.declineReason) {
396
+ const what = r.summary ? ` (${r.summary})` : '';
397
+ declines.push(`- ✗ ${DECLINE_LABELS[r.declineReason] ?? r.declineReason}${what}`);
398
+ }
399
+ }
400
+ if (declines.length === 0) {
401
+ L('_None._');
402
+ }
403
+ else {
404
+ for (const d of declines)
405
+ L(d);
406
+ }
407
+ if (anyTruncated) {
408
+ // The loop caps recorded results at 4 kB — a truncated approval record may
409
+ // have lost its skipped[] entries. Say so rather than staying silent.
410
+ L();
411
+ L('_(approval record truncated — declined entries may be incomplete)_');
412
+ }
413
+ L();
414
+ // ── Reads summary ───────────────────────────────────────────────────────────
415
+ L('## Reads');
416
+ L();
417
+ const readCounts = new Map();
418
+ for (const c of completed) {
419
+ if (WRITE_TOOLS.has(c.tool) || NON_READ_TOOLS.has(c.tool))
420
+ continue;
421
+ readCounts.set(c.tool, (readCounts.get(c.tool) ?? 0) + 1);
422
+ }
423
+ if (readCounts.size === 0) {
424
+ L('_No read operations._');
425
+ }
426
+ else {
427
+ L('| Tool | Count |');
428
+ L('| --- | ---: |');
429
+ for (const [tool, n] of [...readCounts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
430
+ L(`| ${tool} | ${n} |`);
431
+ }
432
+ }
433
+ L();
434
+ // ── Cost ────────────────────────────────────────────────────────────────────
435
+ L('## Cost');
436
+ L();
437
+ const cost = summarise(costEntries);
438
+ const auditEntries = costEntries.filter((e) => e.label);
439
+ const auditCost = Math.round(auditEntries.reduce((s, e) => s + e.cost, 0) * 10000) / 10000;
440
+ L(`- **Total:** $${cost.totalCost.toFixed(4)} over ${cost.turns} turn${cost.turns === 1 ? '' : 's'}`);
441
+ L(`- **Tokens:** ${cost.totalTokens.input.toLocaleString('en-US')} in · ${cost.totalTokens.output.toLocaleString('en-US')} out · ${cost.totalTokens.cacheRead.toLocaleString('en-US')} cache-read · ${cost.totalTokens.cacheCreate.toLocaleString('en-US')} cache-write`);
442
+ if (auditEntries.length > 0) {
443
+ L(`- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}: $${auditCost.toFixed(4)}`);
444
+ }
445
+ if (cost.byModel.length > 1) {
446
+ L('- **By model:**');
447
+ for (const m of cost.byModel) {
448
+ L(` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'} · $${m.cost.toFixed(4)}`);
449
+ }
450
+ }
451
+ else if (cost.byModel[0]) {
452
+ L(`- **Model:** ${cost.byModel[0].model}`);
453
+ }
454
+ L();
455
+ // ── Footer ──────────────────────────────────────────────────────────────────
456
+ L('---');
457
+ L(`generated by CSPeach ${CLI_VERSION} — session ${session.id}`);
458
+ return lines.join('\n') + '\n';
459
+ }
@@ -0,0 +1,163 @@
1
+ // cspeach-cli/src/session/context-report.ts
2
+ //
3
+ // UX Wave 3 / Task 5 — `/context`: the composition grid.
4
+ //
5
+ // buildContextReport turns a SessionState + its cost log into a breakdown of
6
+ // WHAT fills the model's context window right now — a summary block (if the
7
+ // history was compacted), the recent verbatim turns, tool-result payloads,
8
+ // the task list, and everything system-side (system prompt + skill + tool
9
+ // definitions). It is a PURE builder: no clock, no fs, no network.
10
+ //
11
+ // ── Honesty contract (load-bearing) ──────────────────────────────────────
12
+ // The ONLY exact token number available to the CLI is `lastTurnInput`: the
13
+ // prompt-token count the provider actually billed on the newest turn
14
+ // (input + cache_read + cache_creation, all three because a cached prompt is
15
+ // still prompt content occupying the window). EVERYTHING ELSE is a chars/4
16
+ // heuristic derived from the persisted message content — it can be wrong by a
17
+ // wide margin (tokenisation is not chars/4, and system/skill/tool text isn't
18
+ // even in `session.messages`). So every per-segment number is flagged `~`
19
+ // (approx) by the renderer, and the system-side segment is *derived* as
20
+ // `lastTurnInput − (heuristic message estimate)` — the residual that the
21
+ // message heuristic can't see. That residual is clamped to ≥ 0: a heuristic
22
+ // that over-counts the message side must never manufacture a negative
23
+ // system-side. The clamp is pinned by a test.
24
+ //
25
+ // Cost entries are appended in turn order by appendCostLine, but the raw tail
26
+ // is NOT always a real turn: `label` tags non-user-turn spend (cost-log.ts —
27
+ // currently only 'audit', the plan-phase auditor sub-call, which plan-chain.ts
28
+ // appends AFTER the phase turn's own line with turn: 0). So the "one exact
29
+ // number" is read from the newest UNLABELLED entry — the newest line that
30
+ // represents a real session turn — never an auditor sub-call's prompt.
31
+ import { planCompaction } from '../commands/compact.js';
32
+ /**
33
+ * The model context window, in tokens. SINGLE definition — nothing else in the
34
+ * codebase may hard-code 200_000. 200k is the standard Claude context window;
35
+ * this is the denominator for the fill percentage and the bar scale.
36
+ */
37
+ export const CONTEXT_WINDOW_TOKENS = 200_000;
38
+ /** Above this fraction of the window, offer /compact. */
39
+ const COMPACT_HINT_PCT = 0.6;
40
+ /** Tool results larger than this fraction of the window earn a "big reads" note. */
41
+ const HUGE_TOOL_RESULT_PCT = 0.3;
42
+ /** chars → tokens heuristic. Deliberately crude; every result of it is flagged ~. */
43
+ const CHARS_PER_TOKEN = 4;
44
+ /**
45
+ * Prefix stamped on the compact summary message (see compact.ts
46
+ * buildSummaryMessage). A user message whose string content starts with this
47
+ * IS the compacted-history block — not a real user prompt.
48
+ */
49
+ const SUMMARY_PREFIX = '[Context summary auto-generated by /compact';
50
+ function estTokens(chars) {
51
+ return Math.ceil(chars / CHARS_PER_TOKEN);
52
+ }
53
+ /** True when a message is the compact summary block, not a real user prompt. */
54
+ function isSummaryBlock(content) {
55
+ return content.startsWith(SUMMARY_PREFIX);
56
+ }
57
+ function tallyMessage(content, out) {
58
+ if (typeof content === 'string') {
59
+ if (isSummaryBlock(content))
60
+ out.summaryChars += content.length;
61
+ else
62
+ out.recentChars += content.length; // a verbatim user prompt
63
+ return;
64
+ }
65
+ if (!Array.isArray(content))
66
+ return;
67
+ for (const b of content) {
68
+ if (b == null || typeof b !== 'object')
69
+ continue;
70
+ const block = b;
71
+ switch (block.type) {
72
+ case 'text':
73
+ out.recentChars += String(block.text ?? '').length;
74
+ break;
75
+ case 'thinking':
76
+ out.recentChars += String(block.thinking ?? '').length;
77
+ break;
78
+ case 'tool_use':
79
+ out.toolChars += JSON.stringify(block.input ?? {}).length + String(block.name ?? '').length;
80
+ break;
81
+ case 'tool_result': {
82
+ const c = block.content;
83
+ out.toolChars += (typeof c === 'string' ? c : JSON.stringify(c ?? '')).length;
84
+ break;
85
+ }
86
+ default:
87
+ break;
88
+ }
89
+ }
90
+ }
91
+ /** Approx tokens a todo list occupies (text + a little per-item structure). */
92
+ function todoTokens(todos) {
93
+ if (!todos || todos.length === 0)
94
+ return 0;
95
+ const chars = todos.reduce((acc, t) => acc + t.text.length + 4, 0);
96
+ return estTokens(chars);
97
+ }
98
+ /**
99
+ * Estimate the tokens that /compact would reclaim: the chars/4 size of the
100
+ * exact message slice planCompaction would summarise away. Reusing the real
101
+ * planner means the "reclaim" number is genuinely the summary-eligible-turns
102
+ * estimate, not a made-up figure.
103
+ */
104
+ function reclaimableTokens(session) {
105
+ const plan = planCompaction(session.messages);
106
+ if (plan.toSummarise.length === 0)
107
+ return 0;
108
+ const tally = { summaryChars: 0, recentChars: 0, toolChars: 0 };
109
+ for (const m of plan.toSummarise)
110
+ tallyMessage(m.content, tally);
111
+ return estTokens(tally.summaryChars + tally.recentChars + tally.toolChars);
112
+ }
113
+ export function buildContextReport(session, costEntries) {
114
+ const windowTokens = CONTEXT_WINDOW_TOKENS;
115
+ // ── the one exact number: newest REAL turn's prompt tokens ──
116
+ // Skip labelled entries (non-user-turn spend, e.g. the plan auditor's
117
+ // turn-0 'audit' line appended after the phase turn) — presenting an audit
118
+ // sub-call's prompt as the session figure would break the honesty contract.
119
+ let newest;
120
+ for (let i = costEntries.length - 1; i >= 0; i--) {
121
+ if (!costEntries[i].label) {
122
+ newest = costEntries[i];
123
+ break;
124
+ }
125
+ }
126
+ const lastTurnInput = newest
127
+ ? newest.tokens.input + newest.tokens.cacheRead + newest.tokens.cacheCreate
128
+ : 0;
129
+ // ── heuristic message-side breakdown ──
130
+ const tally = { summaryChars: 0, recentChars: 0, toolChars: 0 };
131
+ for (const m of session.messages ?? [])
132
+ tallyMessage(m.content, tally);
133
+ const summaryTokens = estTokens(tally.summaryChars);
134
+ const recentTokens = estTokens(tally.recentChars);
135
+ const toolTokens = estTokens(tally.toolChars);
136
+ const todos = todoTokens(session.todos);
137
+ const messageEstimate = summaryTokens + recentTokens + toolTokens + todos;
138
+ // System side is the residual the message heuristic can't see (system
139
+ // prompt + active skill + tool schemas). Clamp to ≥ 0 — an over-counting
140
+ // heuristic must never produce a negative segment.
141
+ const systemSide = Math.max(0, lastTurnInput - messageEstimate);
142
+ const segments = [];
143
+ if (summaryTokens > 0)
144
+ segments.push({ label: 'summary block', approxTokens: summaryTokens });
145
+ segments.push({ label: 'recent turns', approxTokens: recentTokens });
146
+ segments.push({ label: 'tool results', approxTokens: toolTokens });
147
+ if (todos > 0)
148
+ segments.push({ label: 'todos', approxTokens: todos });
149
+ segments.push({ label: 'system + skill + tools', approxTokens: systemSide });
150
+ const pct = windowTokens > 0 ? lastTurnInput / windowTokens : 0;
151
+ // ── hints ──
152
+ const hints = [];
153
+ if (pct > COMPACT_HINT_PCT) {
154
+ const reclaim = reclaimableTokens(session);
155
+ if (reclaim > 0) {
156
+ hints.push(`context is ${Math.round(pct * 100)}% full — /compact reclaims ~${Math.round(reclaim / 1000)}k from older turns`);
157
+ }
158
+ }
159
+ if (toolTokens > windowTokens * HUGE_TOOL_RESULT_PCT) {
160
+ hints.push(`tool results fill ~${Math.round(toolTokens / 1000)}k — large reads dominate context; narrower reads keep it small`);
161
+ }
162
+ return { windowTokens, lastTurnInput, pct, segments, hints };
163
+ }