@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.
- package/dist/agent/loop.js +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +59 -66
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +17 -7
- 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
|
+
}
|