@cspeach/cli 1.1.25 → 1.1.26
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 +20 -8
- package/dist/agent/tool-dispatch.js +6 -0
- package/dist/auth/auth-file.js +173 -12
- package/dist/classifier/client.js +55 -30
- package/dist/cli.js +11 -0
- package/dist/commands/config-set.js +13 -0
- package/dist/commands/config-show.js +5 -1
- package/dist/commands/cost.js +24 -22
- package/dist/commands/deploy-card.js +135 -0
- package/dist/commands/login.js +31 -13
- package/dist/commands/plan-chain.js +31 -0
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +49 -2
- package/dist/cost/cost-log.js +76 -4
- package/dist/cost/credits-wire.js +9 -0
- package/dist/cost/credits.js +24 -6
- package/dist/cost/router-spend.js +24 -0
- package/dist/cost/session-cost.js +16 -2
- package/dist/models/server-config.js +21 -1
- package/dist/one-shot.js +12 -2
- package/dist/projects/plan-lane.js +47 -0
- package/dist/projects/plan-run.js +8 -0
- package/dist/projects/plan-schema.js +2 -0
- package/dist/projects/save-command.js +3 -2
- package/dist/renderer/footer-line.js +10 -4
- package/dist/renderer/status-footer.js +5 -4
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl.js +41 -8
- package/dist/router/byok-classifier.js +159 -0
- package/dist/router/classifier.js +130 -6
- package/dist/router/router-core.js +307 -0
- package/dist/router/router-prompt.js +121 -0
- package/dist/router/routing-failure.js +65 -3
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
- package/dist/tools/fiori/deploy/run.js +608 -0
- package/dist/tools/fiori/deploy/scope.js +8 -0
- package/dist/tools/fiori/deploy/yaml-read.js +236 -0
- package/dist/tools/fiori/deploy-config.js +24 -6
- package/dist/tools/fiori/tools.js +117 -5
- package/dist/tools/local-build.js +3 -0
- package/dist/tools/subagent/read_agent.js +4 -2
- package/dist/tools/tool-loading.js +1 -1
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -27,10 +27,10 @@ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
|
|
|
27
27
|
// (progress-chatter import removed 2026-05-01 — superseded by CC-style
|
|
28
28
|
// two-line dispatch renderer; re-add if a future in-place spinner returns)
|
|
29
29
|
import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
|
|
30
|
-
import { appendCostLine, buildEntry as buildCostEntry, summariseInputTransformations } from '../cost/cost-log.js';
|
|
30
|
+
import { appendCostLine, buildEntry as buildCostEntry, perCallTokens, summariseInputTransformations } from '../cost/cost-log.js';
|
|
31
31
|
import { modelAcceptsBinding, sessionMaxTokens, clampMaxTokens } from './provider-shape.js';
|
|
32
32
|
import { computeSessionCost } from '../cost/session-cost.js';
|
|
33
|
-
import { refreshCreditsAfterTurn, creditsForCostLog } from '../cost/credits-wire.js';
|
|
33
|
+
import { refreshCreditsAfterTurn, creditsForCostLog, beginCreditsRequest } from '../cost/credits-wire.js';
|
|
34
34
|
import { globalStore } from '../ui/sap-state-store.js';
|
|
35
35
|
import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
|
|
36
36
|
import { enrichUserMessage } from '../router/intent-extractor.js';
|
|
@@ -835,6 +835,15 @@ ${imageExpansion.error}`));
|
|
|
835
835
|
cacheRead: session.usage?.cache_read_input_tokens ?? 0,
|
|
836
836
|
cacheCreate: session.usage?.cache_creation_input_tokens ?? 0,
|
|
837
837
|
};
|
|
838
|
+
// 2026-10-02 — the per-turn receipt shows what this whole user request cost
|
|
839
|
+
// (every model round below), not just its last round. A reader child never
|
|
840
|
+
// marks a request: its spend belongs to the parent's.
|
|
841
|
+
if (!readerChild)
|
|
842
|
+
beginCreditsRequest();
|
|
843
|
+
// 2026-10-03 — each cost line records ONLY its own model call: tokens since
|
|
844
|
+
// the previous line, not since turn start (Task 3 wrote running totals, so
|
|
845
|
+
// /cost, the audit export and the context meter over-counted).
|
|
846
|
+
let lastLoggedTokens = { ...turnStartTokens };
|
|
838
847
|
// Turn number for the cost log — count of prior user messages + this one.
|
|
839
848
|
const turnNumber = session.messages.filter((m) => m.role === 'user').length + 1;
|
|
840
849
|
// Final review I4 — the monotonic per-session turn counter the auto-compact
|
|
@@ -1438,12 +1447,14 @@ ${imageExpansion.error}`));
|
|
|
1438
1447
|
// race on the shared sample.
|
|
1439
1448
|
if (!readerChild)
|
|
1440
1449
|
await refreshCreditsAfterTurn();
|
|
1441
|
-
const
|
|
1442
|
-
input:
|
|
1443
|
-
output:
|
|
1444
|
-
cacheRead:
|
|
1445
|
-
cacheCreate:
|
|
1450
|
+
const nowTokens = {
|
|
1451
|
+
input: session.usage?.input_tokens ?? 0,
|
|
1452
|
+
output: session.usage?.output_tokens ?? 0,
|
|
1453
|
+
cacheRead: session.usage?.cache_read_input_tokens ?? 0,
|
|
1454
|
+
cacheCreate: session.usage?.cache_creation_input_tokens ?? 0,
|
|
1446
1455
|
};
|
|
1456
|
+
const callTokens = perCallTokens(nowTokens, lastLoggedTokens);
|
|
1457
|
+
lastLoggedTokens = nowTokens;
|
|
1447
1458
|
const turnDurationMs = Date.now() - turnStartedAt;
|
|
1448
1459
|
const entry = buildCostEntry({
|
|
1449
1460
|
turn: turnNumber,
|
|
@@ -1451,7 +1462,8 @@ ${imageExpansion.error}`));
|
|
|
1451
1462
|
// (plan model tiering), the cost line must record the ACTUAL model
|
|
1452
1463
|
// or the Sonnet-priced turn would be billed at session-model rates.
|
|
1453
1464
|
model: params.modelOverride ?? session.model,
|
|
1454
|
-
tokens:
|
|
1465
|
+
tokens: callTokens,
|
|
1466
|
+
scope: 'call',
|
|
1455
1467
|
duration_ms: turnDurationMs,
|
|
1456
1468
|
// Additive: present only in credits mode (undefined in USD mode, so
|
|
1457
1469
|
// the emitted line is byte-identical to a pre-credits one).
|
|
@@ -146,6 +146,12 @@ function declined(r) {
|
|
|
146
146
|
export function isLocalProjectWork(tool, args) {
|
|
147
147
|
if (tool.category === 'filesystem')
|
|
148
148
|
return true;
|
|
149
|
+
// 2026-10-02 — fiori_deploy mode "test" (the default) writes nothing to SAP:
|
|
150
|
+
// the backend validates the upload and keeps nothing. Not a batch write, so
|
|
151
|
+
// a deploy phase shows ONE approval (the real deploy's request_approval),
|
|
152
|
+
// not a batch card before it. Mode "deploy" is gated exactly as before.
|
|
153
|
+
if (tool.name === 'fiori_deploy')
|
|
154
|
+
return (args?.mode ?? 'test') === 'test';
|
|
149
155
|
if (tool.category !== 'shell')
|
|
150
156
|
return false;
|
|
151
157
|
const argv = [args?.command, ...(Array.isArray(args?.args) ? args.args : [])].map((a) => String(a ?? ''));
|
package/dist/auth/auth-file.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Read/write ~/.cspeach/auth.json — the customer's CSPeach API key
|
|
3
|
-
*
|
|
2
|
+
* Read/write ~/.cspeach/auth.json — the customer's CSPeach API key.
|
|
3
|
+
*
|
|
4
|
+
* 2026-10-02: auth.json no longer holds an Anthropic key. `cspeach login` used
|
|
5
|
+
* to write a hand-pasted BYOK key here in PLAINTEXT (`byokAnthropicKey`), and
|
|
6
|
+
* nothing read it — BYOK reads the OS keychain (cspeach.anthropic/api-key).
|
|
7
|
+
* A pasted key now goes to the keychain, and migrateLegacyByokKey (run at
|
|
8
|
+
* startup) moves an old plaintext copy there and deletes it from this file.
|
|
4
9
|
*
|
|
5
10
|
* Permissions: 0600 on POSIX. Windows uses NTFS ACLs which Node's
|
|
6
11
|
* fs mode flag doesn't translate; we rely on the user-dir default
|
|
@@ -29,11 +34,7 @@ export async function readAuthFile() {
|
|
|
29
34
|
const parsed = JSON.parse(raw);
|
|
30
35
|
if (typeof parsed.apiKey !== 'string' || typeof parsed.customerId !== 'string')
|
|
31
36
|
return null;
|
|
32
|
-
return {
|
|
33
|
-
apiKey: parsed.apiKey,
|
|
34
|
-
customerId: parsed.customerId,
|
|
35
|
-
byokAnthropicKey: typeof parsed.byokAnthropicKey === 'string' ? parsed.byokAnthropicKey : null,
|
|
36
|
-
};
|
|
37
|
+
return { apiKey: parsed.apiKey, customerId: parsed.customerId };
|
|
37
38
|
}
|
|
38
39
|
catch {
|
|
39
40
|
return null;
|
|
@@ -42,18 +43,178 @@ export async function readAuthFile() {
|
|
|
42
43
|
export async function writeAuthFile(auth) {
|
|
43
44
|
const p = authFilePath();
|
|
44
45
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// Only the two known fields — never a secret a caller passes along by mistake.
|
|
47
|
+
const body = { apiKey: auth.apiKey, customerId: auth.customerId };
|
|
48
|
+
await writeAtomic0600(p, JSON.stringify(body, null, 2));
|
|
49
|
+
}
|
|
50
|
+
/** Rename errors that mean "the target is open elsewhere" (Windows), worth retrying. */
|
|
51
|
+
const LOCK_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
52
|
+
/** Backoff between rename attempts (5 tries in all). */
|
|
53
|
+
const RENAME_BACKOFF_MS = [50, 100, 150, 200];
|
|
54
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
55
|
+
/**
|
|
56
|
+
* 2026-10-02 (review nits): write via a temp file + rename, so a crash
|
|
57
|
+
* mid-write never leaves a truncated auth.json (losing the sign-in). 0600 on
|
|
58
|
+
* POSIX.
|
|
59
|
+
*
|
|
60
|
+
* Review S3: on Windows a rename over an auth.json another process holds open
|
|
61
|
+
* (antivirus, a second cspeach, an editor) fails with EPERM / EACCES / EBUSY.
|
|
62
|
+
* Retry 5 times with a short backoff, then fall back to an in-place write.
|
|
63
|
+
* The temp file holds the API key, so it is always removed.
|
|
64
|
+
*/
|
|
65
|
+
async function writeAtomic0600(p, text) {
|
|
66
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
67
|
+
try {
|
|
68
|
+
await fs.writeFile(tmp, text, { encoding: 'utf-8', mode: 0o600 });
|
|
69
|
+
// Defensive: re-chmod in case the create-time mode was ignored (e.g., umask 022).
|
|
70
|
+
if (process.platform !== 'win32') {
|
|
71
|
+
try {
|
|
72
|
+
await fs.chmod(tmp, 0o600);
|
|
73
|
+
}
|
|
74
|
+
catch { /* ignore */ }
|
|
75
|
+
}
|
|
76
|
+
for (let attempt = 0;; attempt++) {
|
|
77
|
+
try {
|
|
78
|
+
await fs.rename(tmp, p);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const code = err.code ?? '';
|
|
83
|
+
if (!LOCK_CODES.has(code))
|
|
84
|
+
throw err;
|
|
85
|
+
if (attempt >= RENAME_BACKOFF_MS.length)
|
|
86
|
+
break;
|
|
87
|
+
await sleep(RENAME_BACKOFF_MS[attempt]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Still locked: write in place (not atomic, but the sign-in is saved).
|
|
91
|
+
await fs.writeFile(p, text, { encoding: 'utf-8', mode: 0o600 });
|
|
92
|
+
if (process.platform !== 'win32') {
|
|
93
|
+
try {
|
|
94
|
+
await fs.chmod(p, 0o600);
|
|
95
|
+
}
|
|
96
|
+
catch { /* ignore */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
48
100
|
try {
|
|
49
|
-
await fs.
|
|
101
|
+
await fs.unlink(tmp);
|
|
50
102
|
}
|
|
51
|
-
catch { /*
|
|
103
|
+
catch { /* already renamed or never written */ }
|
|
52
104
|
}
|
|
53
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Review S3: remove auth.json.*.tmp files a crashed write left behind — they
|
|
108
|
+
* hold the API key in plain text. Run at startup. Only files older than a
|
|
109
|
+
* minute, so another cspeach's in-progress write is never touched. Never throws.
|
|
110
|
+
*/
|
|
111
|
+
const STALE_TEMP_MS = 60_000;
|
|
112
|
+
export async function cleanStaleAuthTemps() {
|
|
113
|
+
try {
|
|
114
|
+
const p = authFilePath();
|
|
115
|
+
const dir = path.dirname(p);
|
|
116
|
+
const base = path.basename(p);
|
|
117
|
+
for (const name of await fs.readdir(dir)) {
|
|
118
|
+
if (!(name.startsWith(`${base}.`) && name.endsWith('.tmp')))
|
|
119
|
+
continue;
|
|
120
|
+
const f = path.join(dir, name);
|
|
121
|
+
try {
|
|
122
|
+
// Older than a minute: not another cspeach's write in progress.
|
|
123
|
+
if (Date.now() - (await fs.stat(f)).mtimeMs < STALE_TEMP_MS)
|
|
124
|
+
continue;
|
|
125
|
+
await fs.unlink(f);
|
|
126
|
+
}
|
|
127
|
+
catch { /* ignore */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch { /* no ~/.cspeach yet */ }
|
|
131
|
+
}
|
|
54
132
|
export async function deleteAuthFile() {
|
|
55
133
|
try {
|
|
56
134
|
await fs.unlink(authFilePath());
|
|
57
135
|
}
|
|
58
136
|
catch { /* ignore */ }
|
|
59
137
|
}
|
|
138
|
+
/** The BYOK key's keychain entry — the same one byok-provider.ts reads and login writes. */
|
|
139
|
+
const BYOK_KEYCHAIN_SERVICE = 'cspeach.anthropic';
|
|
140
|
+
const BYOK_KEYCHAIN_ACCOUNT = 'api-key';
|
|
141
|
+
/**
|
|
142
|
+
* 2026-10-02 — move a plaintext `byokAnthropicKey` left in auth.json by an
|
|
143
|
+
* older `cspeach login` into the OS keychain, then delete it from the file.
|
|
144
|
+
*
|
|
145
|
+
* 'none' nothing to do (no file, or no string key in it) — the keychain
|
|
146
|
+
* is not touched;
|
|
147
|
+
* 'migrated' the keychain had no key: the file's key was stored there and
|
|
148
|
+
* deleted from the file;
|
|
149
|
+
* 'removed' the keychain already held the SAME key: only the plaintext
|
|
150
|
+
* copy is deleted;
|
|
151
|
+
* 'conflict' the keychain holds a DIFFERENT key (the one BYOK uses). The
|
|
152
|
+
* pasted key may be the only copy, so nothing is deleted — the
|
|
153
|
+
* caller shows one notice (review nit);
|
|
154
|
+
* 'keychain-unavailable' the keychain could not be read or written (e.g.
|
|
155
|
+
* Linux without a Secret Service): the file is left as-is so the
|
|
156
|
+
* key is not lost — the caller shows one notice;
|
|
157
|
+
* 'failed' the file could not be rewritten (retried on the next start).
|
|
158
|
+
* Never throws.
|
|
159
|
+
*/
|
|
160
|
+
export async function migrateLegacyByokKey(deps) {
|
|
161
|
+
let raw;
|
|
162
|
+
try {
|
|
163
|
+
const parsed = JSON.parse(await fs.readFile(authFilePath(), 'utf-8'));
|
|
164
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
165
|
+
return 'none';
|
|
166
|
+
raw = parsed;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return 'none';
|
|
170
|
+
}
|
|
171
|
+
const legacy = raw.byokAnthropicKey;
|
|
172
|
+
if (typeof legacy !== 'string' || legacy.trim() === '')
|
|
173
|
+
return 'none';
|
|
174
|
+
const pasted = legacy.trim();
|
|
175
|
+
let outcome;
|
|
176
|
+
try {
|
|
177
|
+
const d = deps ?? (await defaultKeychainDeps());
|
|
178
|
+
const existing = await d.getKey(BYOK_KEYCHAIN_SERVICE, BYOK_KEYCHAIN_ACCOUNT);
|
|
179
|
+
if (existing) {
|
|
180
|
+
if (existing.trim() !== pasted)
|
|
181
|
+
return 'conflict';
|
|
182
|
+
outcome = 'removed';
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
await d.setKey(BYOK_KEYCHAIN_SERVICE, BYOK_KEYCHAIN_ACCOUNT, pasted);
|
|
186
|
+
outcome = 'migrated';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return 'keychain-unavailable';
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const { byokAnthropicKey: _dropped, ...rest } = raw;
|
|
194
|
+
await writeAtomic0600(authFilePath(), JSON.stringify(rest, null, 2));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return 'failed';
|
|
198
|
+
}
|
|
199
|
+
return outcome;
|
|
200
|
+
}
|
|
201
|
+
/** The one-line startup notice for an outcome the user must know about, else null. */
|
|
202
|
+
export function legacyByokKeyNotice(outcome) {
|
|
203
|
+
switch (outcome) {
|
|
204
|
+
case 'conflict':
|
|
205
|
+
return 'cspeach: ~/.cspeach/auth.json still holds a pasted Anthropic API key in plain text that is different from the one in your OS keychain (the one CSPeach uses). It was kept in case it is your only copy — remove the "byokAnthropicKey" line from auth.json once you no longer need it.';
|
|
206
|
+
case 'keychain-unavailable':
|
|
207
|
+
return 'cspeach: ~/.cspeach/auth.json still holds your Anthropic API key in plain text — the OS keychain is not available, so it could not be moved there (on Linux: install and unlock a Secret Service keyring such as gnome-keyring). CSPeach will move it on the next start once the keychain works.';
|
|
208
|
+
default:
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function defaultKeychainDeps() {
|
|
213
|
+
// Loaded only when there is something to migrate — most starts never pay
|
|
214
|
+
// for the native keychain module here.
|
|
215
|
+
const { default: keychain } = await import('./keychain.js');
|
|
216
|
+
return {
|
|
217
|
+
getKey: (s, a) => keychain.getPassword(s, a),
|
|
218
|
+
setKey: (s, a, k) => keychain.setPassword(s, a, k),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** Confidence for a `strong` match (≥ 0.85 ⇒ silent routing). */
|
|
2
|
+
export const STRONG_CONFIDENCE = 0.85;
|
|
3
|
+
/** Confidence for any other match (coach: preselected, the user confirms). */
|
|
4
|
+
export const RULE_CONFIDENCE = 0.6;
|
|
1
5
|
const RULES = [
|
|
2
6
|
// abap-app (app lane routing slice 1, 2026-10-01) — building an app FROM a
|
|
3
7
|
// spec / requirements document. FIRST so a spec-document build beats every
|
|
@@ -10,9 +14,11 @@ const RULES = [
|
|
|
10
14
|
/^(?![\s\S]*\bspec\s+gaps?\b)[\s\S]*?\b(build|create|make|develop)\b[^.?!\n]{0,60}\b(app|application)\b[^.?!\n]{0,60}(\bspec(s|ification)?\b|\brequirements?\s+(doc|document|file|sheet)\b|\.(docx|pdf|xlsx)\b)/i,
|
|
11
15
|
/^(?![\s\S]*\bspec\s+gaps?\b)[\s\S]*?(\bspec(s|ification)?\b|\brequirements?\s+(doc|document|file|sheet)\b|\.(docx|pdf|xlsx)\b)[^.?!\n]{0,60}\b(build|create|make|develop)\b[^.?!\n]{0,30}\b(app|application)\b/i,
|
|
12
16
|
] },
|
|
13
|
-
{ skill: 'abap-atc-fix', patterns: [/atc\s+(fix|auto\s+fix)|abap\s+test\s+cockpit.*fix/i] },
|
|
17
|
+
{ skill: 'abap-atc-fix', patterns: [/atc\s+(fix|auto\s+fix)|abap\s+test\s+cockpit.*fix|\brun\s+(the\s+)?atc\b|\batc\s+findings\b|\bfix\s+(the|all|these|those)\s+findings\b/i] },
|
|
14
18
|
{ skill: 'abap-cca', patterns: [/custom\s+code\s+analysis|\bcca\b|clean\s+core\s+(assessment|analysis)/i] },
|
|
15
|
-
|
|
19
|
+
// abap-clean-core (2026-10-02) — took over the retired abap-cloud-check's
|
|
20
|
+
// "clean core" phrasing; "cloud ready" grading went to abap-review (cloud lens).
|
|
21
|
+
{ skill: 'abap-clean-core', patterns: [/clean.?core(?!\s+(assessment|analysis))|\breleased\s+apis?\b/i] },
|
|
16
22
|
// abap-cap (2026-10-01) MUST precede abap-data-model ("CDS domain model"
|
|
17
23
|
// would hit its bare `domain`) and abap-fiori-build / abap-rap (a CAP app
|
|
18
24
|
// ships a Fiori Elements UI). Side-by-side / BTP framing → CAP.
|
|
@@ -22,31 +28,48 @@ const RULES = [
|
|
|
22
28
|
// A bare BTP mention counts only when no RAP/ABAP/Fiori/UI5/Z-object word
|
|
23
29
|
// appears anywhere in the prompt.
|
|
24
30
|
{ skill: 'abap-cap', patterns: [/\b(sap\s+)?cap\b(?=\s+(app|application|project|service|model|backend|extension|handler)s?\b)|\b(in|with|using|use)\s+(sap\s+)?cap\b|cloud\s+application\s+programming|\bcds\s+(model|service)\b(?!\s+(definition|[zy]\w*))|side.?by.?side\s+(app|application|extension)|^(?![\s\S]*\b(rap|abap|fiori|ui5|[zy]\w*_\w+)\b)[\s\S]*\bbtp\b/i] },
|
|
25
|
-
{ skill: 'abap-data-model', patterns: [/data\s+model|ddic\s+(design|structure)|domain|data\s+element|create\s+(a\s+)?z[_\w]*\s+(table|structure)|new\s+(z[_\w]*\s+)?(table|structure|domain)/i] },
|
|
31
|
+
{ skill: 'abap-data-model', patterns: [/data\s+model|ddic\s+(design|structure)|domain|data\s+element|create\s+(a\s+)?z[_\w]*\s+(table|structure)|new\s+(z[_\w]*\s+)?(table|structure|domain)|\bcreate\s+(a\s+|an\s+|new\s+)?(z\w*\s+)?(table|structure)\b/i] },
|
|
26
32
|
{ skill: 'abap-design', patterns: [/solution\s+architecture|object\s+list|design\s+the\s+solution/i] },
|
|
27
|
-
{ skill: 'abap-handover', patterns: [/technical\s+documentation|object\s+catalog|doc(ument)?\s+package|handover/i] },
|
|
28
|
-
{ skill: 'abap-document', patterns: [/developer\s+documentation|document\s+this\s+(class|program|object)/i] },
|
|
29
33
|
// abap-incident MUST precede dump rules — pipeline framing beats keyword-only matches
|
|
30
34
|
{ skill: 'abap-incident', patterns: [/incident|resolve.*dump.*to.*fix|pipeline.*(short\s+dump|st22|dump)|(dump|st22).*to.*transport|formal\s+(incident|dump)\s+report|end.to.end.*dump/i] },
|
|
31
35
|
// abap-dump (2026-06-12) — merger of abap-dump-analyze + abap-dump-triage.
|
|
32
36
|
// Triage-vs-fix is an intent decision INSIDE the skill now, so both
|
|
33
37
|
// pattern sets route here.
|
|
34
|
-
{ skill: 'abap-dump',
|
|
38
|
+
{ skill: 'abap-dump', strong: [/short\s+dump|\bst22\b|\bcx_sy_\w+/i],
|
|
39
|
+
patterns: [/runtime\s+error|dump\s+(analys|triage|forensic)|structured\s+investigation.*dump|\bdump(s|ed|ing)?\b/i] },
|
|
35
40
|
{ skill: 'abap-eml', patterns: [/\beml\b|entity\s+manipulation|behavior\s+handler/i] },
|
|
36
|
-
{ skill: 'abap-enhance',
|
|
37
|
-
|
|
38
|
-
{ skill: 'abap-
|
|
41
|
+
{ skill: 'abap-enhance', strong: [/\bbadis?\b|enhancement\s+spot|customer\s+exit|user\s+exit/i],
|
|
42
|
+
patterns: [/implicit\s+enhancement|(hook\s+into|extend)\s+(sap|standard|me\d+|migo|miro|va\d+|vf\d+|vl\d+|fb\d+)/i] },
|
|
43
|
+
{ skill: 'abap-estimate', patterns: [/\bestimat(e|es|ion)\b|effort\s+estimat|size\s+this\s+(work|task|ticket|story)|how\s+(many|long).*(hours|days).*(build|develop|implement)|how\s+long\s+(will|would)\s+it\s+take|how\s+long\s+to\s+(build|develop|implement|do)\b/i] },
|
|
44
|
+
// abap-extend-model (2026-10-02) MUST precede abap-generate ("add … to Z…")
|
|
45
|
+
// and abap-rap ("behavior"). Only CDS / RAP targets — Z?_ entity names
|
|
46
|
+
// (ZR_/ZI_/ZC_), a CDS view, the app or list — so "add a validation to
|
|
47
|
+
// ZCL_ORDER_SERVICE" (a class) and "add a column to ZSD_REPORT" (a classic
|
|
48
|
+
// report) stay on the ABAP code skills.
|
|
49
|
+
{ skill: 'abap-extend-model', patterns: [
|
|
50
|
+
/\badd\b[^.?!\n]{0,40}\b(actions?|validations?|determinations?)\b[^.?!\n]{0,40}\bto\s+(the\s+|my\s+)?((z|y)[ric]_\w+|(rap\s+)?(bo|business\s+object|bdef|behaviou?r(\s+definition)?)\b)/i,
|
|
51
|
+
/\badd\b[^.?!\n]{0,40}\b(columns?|fields?)\b[^.?!\n]{0,40}\b(to|in)\s+(the\s+|my\s+)?((z|y)[ric]_\w+|cds\b|projection\b|ddlx\b|metadata\s+extension\b|list(\s+report)?\b|(fiori\s+)?app\b)/i,
|
|
52
|
+
/\bshow\b[^.?!\n]{0,40}\bin\s+(the\s+|my\s+)(list(\s+report)?|(fiori\s+)?app|object\s+page)\b/i,
|
|
53
|
+
/\bexpose\b[^.?!\n]{0,40}\baction\b[^.?!\n]{0,40}\bprojection\b|\btoolbar\s+button\b/i,
|
|
54
|
+
] },
|
|
55
|
+
// abap-explain also takes the retired abap-radar's phrasing (technical orientation).
|
|
56
|
+
{ skill: 'abap-explain', patterns: [/\bexplain\b|what\s+does\s+this\s+(do|code)|walk.*through|what\s+does\s+(z|y)\w+\s+do\b|\bradar\b|quick\s+look|overview\s+of\s+(this\s+)?(class|program|object)/i] },
|
|
39
57
|
// abap-test MUST precede abap-generate so "generate tests" routes to the test skill
|
|
40
58
|
// (test-gen merged into abap-test on 2026-05-02 — phrases preserved here).
|
|
41
|
-
{ skill: 'abap-test',
|
|
59
|
+
{ skill: 'abap-test', strong: [/unit\s+tests?|abap\s+unit/i],
|
|
60
|
+
patterns: [/test\s+(class|doubles)|generate\s+tests|test\s+generation|improve\s+test\s+coverage/i] },
|
|
42
61
|
// abap-modernize MUST precede abap-rap so "rebuild as RAP" / "convert to RAP" stays
|
|
43
62
|
// on modernize (rebuild intent) instead of falling to abap-rap's bare \brap\b match.
|
|
44
|
-
{ skill: 'abap-modernize', patterns: [/modernise|modernize|classic.*to.*modern|convert
|
|
63
|
+
{ skill: 'abap-modernize', patterns: [/modernise|modernize|classic.*(to|into).*modern|(convert|turn)\b.*\b(to|into)\s+(an?\s+)?(rap|fiori|odata)|rebuild.*as\s+(rap|fiori)/i] },
|
|
45
64
|
// abap-fiori-build (D12, 2026-06-12) MUST follow abap-modernize ("convert
|
|
46
65
|
// this WRITE report to Fiori" = rebuild intent) and precede abap-rap so
|
|
47
66
|
// "fiori app for my RAP/OData service" lands on the UI skill, not the
|
|
48
67
|
// backend scaffolder.
|
|
49
68
|
{ skill: 'abap-fiori-build', patterns: [/\bfiori\b|\b(sap)?ui5\b|freestyle\s+app|\bui\s+app\b|front.?end\s+(for|on)\s/i] },
|
|
69
|
+
// abap-impact (2026-10-02) MUST precede abap-generate: "before I change
|
|
70
|
+
// ZSD_X, what depends on it?" is an impact question, not an edit.
|
|
71
|
+
{ skill: 'abap-impact', strong: [/where[\s-]?used/i],
|
|
72
|
+
patterns: [/impact\s+analysis|where.*used|what.*affected\s+by|what\s+(breaks|would\s+break|depends\s+on)|before\s+i\s+(change|touch|modify)/i] },
|
|
50
73
|
// abap-rap MUST precede abap-generate so RAP-stack prompts route to rap, not the
|
|
51
74
|
// generic generator (cluster I, 2026-05-03). Also captures "scaffold a CDS view +
|
|
52
75
|
// BDEF + service binding" type prompts which collide with abap-generate.
|
|
@@ -60,43 +83,45 @@ const RULES = [
|
|
|
60
83
|
/\badd\s+(a\s+|an\s+)?[\w\s-]{1,40}\bto\s+z[_a-z0-9]+/i,
|
|
61
84
|
/\b(change|fix|update|modify|adjust)\s+(the\s+)?z[_a-z0-9]+\b/i,
|
|
62
85
|
] },
|
|
63
|
-
{ skill: 'abap-impact', patterns: [/impact\s+analysis|where.*used|what.*affected\s+by/i] },
|
|
64
86
|
// abap-upgrade-* must precede abap-migrate
|
|
65
87
|
{ skill: 'abap-upgrade-verify', patterns: [/upgrade\s+verify|remediation\s+report|verify\s+upgrade/i] },
|
|
66
88
|
{ skill: 'abap-upgrade-fix', patterns: [/upgrade\s+fix|atc\s+remediation|fix\s+upgrade\s+finding/i] },
|
|
67
89
|
{ skill: 'abap-upgrade-scan', patterns: [/upgrade\s+scan|scan.*s\/4|readiness\s+variant/i] },
|
|
68
90
|
{ skill: 'abap-migrate', patterns: [/ecc.*migration|migrate.*s\/4|api\s+successor/i] },
|
|
69
|
-
{ skill: 'abap-performance', patterns: [/performance|\bn\+1\b|select\s+inside\s+loop|slow\s+(query|select)/i] },
|
|
70
91
|
// abap-plan (B4, 2026-06-06): deliberately NO bare \bplan\b — "I plan to
|
|
71
92
|
// refactor X" must keep falling through to the refactor/explain rules.
|
|
72
93
|
// Multi-session / project-plan framing only; refine after real prompts.
|
|
73
94
|
{ skill: 'abap-plan', patterns: [/project\s+plan|implementation\s+plan|plan\s+(this|the|my)\s+(project|build|development)|multi.session\s+(project|plan|build)|resume\s+(the\s+|my\s+)?plan|step.by.step\s+plan/i] },
|
|
74
95
|
{ skill: 'abap-preflight', patterns: [/preflight|pre.release|go\/no.go|release\s+readiness/i] },
|
|
75
|
-
|
|
76
|
-
{ skill: 'abap-refactor', patterns: [/refactor|clean\s+up\s+(code|abap)|procedural.*to\s+oo
|
|
77
|
-
|
|
96
|
+
// abap-refactor also fixes syntax errors in existing Z code (an in-place change).
|
|
97
|
+
{ skill: 'abap-refactor', patterns: [/refactor|clean\s+up\s+(code|abap)|procedural.*to\s+oo|syntax\s+errors?/i] },
|
|
98
|
+
// abap-review also takes the retired abap-performance (performance lens) and
|
|
99
|
+
// abap-cloud-check "cloud ready" grading (cloud lens).
|
|
100
|
+
{ skill: 'abap-review', patterns: [/^review|code\s+review|code\s+quality|clean\s+abap(?!\s+conformity)|performance|\bn\+1\b|select\s+inside\s+loop|\bslow\b|takes?\s+(too|so)\s+long|cloud\s+read(y|iness)|s\/4hana\s+cloud\s+check/i] },
|
|
78
101
|
{ skill: 'abap-segw', patterns: [/segw|dpc_ext|mpc_ext|gateway\s+(project|service)|build.*odata.*(ecc|gateway)/i] },
|
|
79
102
|
{ skill: 'abap-spec-gap', patterns: [/spec\s+gap|missing\s+(spec|requirements)|business\s+questions|clarif(y|ying)\s+(question|requirement)|gaps?\s+in\s+the\s+(spec|story|requirement)/i] },
|
|
103
|
+
// abap-handover (moved here 2026-10-02) also takes the retired
|
|
104
|
+
// abap-document's phrasing; late, so "document" in a dump / spec / app
|
|
105
|
+
// prompt keeps the earlier, more specific skill.
|
|
106
|
+
{ skill: 'abap-handover', patterns: [/technical\s+documentation|object\s+catalog|doc(ument)?\s+package|handover|developer\s+documentation|\bdocument(ation)?\b/i] },
|
|
80
107
|
// abap-transport-analysis MUST precede abap-transport
|
|
81
108
|
{ skill: 'abap-transport-analysis', patterns: [/transport\s+analysis|pre.release\s+transport|transport\s+readiness/i] },
|
|
82
|
-
{ skill: 'abap-transport', patterns: [/transport\s+(create|release|list)|\bcts\b|release.*request/i] },
|
|
109
|
+
{ skill: 'abap-transport', patterns: [/transport\s+(create|release|list)|\bcts\b|release.*request|\btransport\b/i] },
|
|
83
110
|
];
|
|
84
111
|
/** The skill each offline rule routes to, in rule order (one rule per skill). */
|
|
85
112
|
export const RULE_SKILLS = Object.freeze(RULES.map((r) => r.skill));
|
|
86
|
-
// 2026-10-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// Warn-not-throw — managed-mode users never exercise classify() (the proxy
|
|
91
|
-
// does server-side classification), so a module-load crash would break
|
|
92
|
-
// them for a local-only consistency check. Unit tests assert the exact
|
|
93
|
-
// count.
|
|
94
|
-
console.warn(`[classifier] RULES drift: expected 35, got ${RULES.length} — reconcile with .claude/skills/`);
|
|
95
|
-
}
|
|
96
|
-
export function classify(prompt) {
|
|
113
|
+
// 2026-10-02: one rule per routed skill, every one in SKILL_CATALOG (the
|
|
114
|
+
// four retired donors folded into their successors). Unit tests pin the set.
|
|
115
|
+
/** First matching rule → its skill and confidence (strong 0.85, else 0.6). */
|
|
116
|
+
export function classifyWithConfidence(prompt) {
|
|
97
117
|
for (const rule of RULES) {
|
|
98
|
-
if (rule.
|
|
99
|
-
return rule.skill;
|
|
118
|
+
if (rule.strong?.some((p) => p.test(prompt)))
|
|
119
|
+
return { skill: rule.skill, confidence: STRONG_CONFIDENCE };
|
|
120
|
+
if (rule.patterns.some((p) => p.test(prompt)))
|
|
121
|
+
return { skill: rule.skill, confidence: RULE_CONFIDENCE };
|
|
100
122
|
}
|
|
101
123
|
return undefined;
|
|
102
124
|
}
|
|
125
|
+
export function classify(prompt) {
|
|
126
|
+
return classifyWithConfidence(prompt)?.skill;
|
|
127
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { runGcOnStartup } from './session/gc.js';
|
|
|
5
5
|
import { checkForUpgrade } from './upgrade-check.js';
|
|
6
6
|
import { extractSapAliasFlag, extractNoSapFlag } from './cli-args.js';
|
|
7
7
|
import { applyColorMode } from './renderer/color-mode.js';
|
|
8
|
+
import { migrateLegacyByokKey, legacyByokKeyNotice, cleanStaleAuthTemps } from './auth/auth-file.js';
|
|
8
9
|
/**
|
|
9
10
|
* The verbs that make `cspeach project …` a mistyped team command rather than
|
|
10
11
|
* a prompt somebody meant to send. See the check in `main`.
|
|
@@ -16,6 +17,16 @@ export async function main(argv) {
|
|
|
16
17
|
applyColorMode();
|
|
17
18
|
runGcOnStartup().catch(() => { });
|
|
18
19
|
checkForUpgrade().catch(() => { });
|
|
20
|
+
// 2026-10-02 — an older `cspeach login` left a pasted BYOK key in plaintext
|
|
21
|
+
// in ~/.cspeach/auth.json; move it to the OS keychain and delete it from
|
|
22
|
+
// the file. A file read on most starts; never throws.
|
|
23
|
+
// A key that could not be moved (keychain unavailable) or that differs from
|
|
24
|
+
// the keychain's gets one plain notice on stderr (one-shot stdout stays clean).
|
|
25
|
+
// Review S3: a crashed atomic write can leave auth.json.*.tmp (API key inside).
|
|
26
|
+
await cleanStaleAuthTemps();
|
|
27
|
+
const legacyNotice = legacyByokKeyNotice(await migrateLegacyByokKey().catch(() => 'failed'));
|
|
28
|
+
if (legacyNotice)
|
|
29
|
+
console.error(legacyNotice);
|
|
19
30
|
let args = argv.slice(2);
|
|
20
31
|
// Standalone mode (2026-09-16) — `--no-sap` is accepted ANYWHERE in the args
|
|
21
32
|
// for both the REPL and one-shot, and stripped like `--sap-alias` below.
|
|
@@ -24,6 +24,7 @@ const MUTABLE_KEYS = new Set([
|
|
|
24
24
|
'llm.mode',
|
|
25
25
|
'llm.ai_hub_base_url',
|
|
26
26
|
'llm.ai_hub_model_alias',
|
|
27
|
+
'llm.ai_hub_router_model',
|
|
27
28
|
'llm.local_base_url',
|
|
28
29
|
'llm.local_model',
|
|
29
30
|
// Virtual keys that route to keychain (not saved to config.toml):
|
|
@@ -574,6 +575,12 @@ export async function runConfigSet(args) {
|
|
|
574
575
|
err('ai_hub_model_alias cannot be empty.');
|
|
575
576
|
cfg.llm.ai_hub_model_alias = value;
|
|
576
577
|
break;
|
|
578
|
+
case 'llm.ai_hub_router_model':
|
|
579
|
+
if (!isModelAlias(value)) {
|
|
580
|
+
err(`Invalid llm.ai_hub_router_model "${value}". Use your AI Hub deployment / model alias (letters, digits, . _ - : /; no spaces).`);
|
|
581
|
+
}
|
|
582
|
+
cfg.llm.ai_hub_router_model = value;
|
|
583
|
+
break;
|
|
577
584
|
case 'llm.local_base_url':
|
|
578
585
|
try {
|
|
579
586
|
new URL(value);
|
|
@@ -606,6 +613,10 @@ export async function runConfigSet(args) {
|
|
|
606
613
|
* Does not load or mutate any config — safe to call in unit tests.
|
|
607
614
|
* Keep in sync with the switch statement in `runConfigSet`.
|
|
608
615
|
*/
|
|
616
|
+
/** A model / deployment alias: 1–128 chars, no spaces or control characters (2026-10-02). */
|
|
617
|
+
function isModelAlias(value) {
|
|
618
|
+
return /^[A-Za-z0-9._:\/-]{1,128}$/.test(value);
|
|
619
|
+
}
|
|
609
620
|
export function validateValue(key, value) {
|
|
610
621
|
// SAP-scoped key: sap.<alias>.auto_approve
|
|
611
622
|
if (SAP_AUTO_APPROVE_RE.test(key)) {
|
|
@@ -684,6 +695,8 @@ export function validateValue(key, value) {
|
|
|
684
695
|
case 'llm.ai_hub_model_alias':
|
|
685
696
|
case 'llm.local_model':
|
|
686
697
|
return value.length > 0;
|
|
698
|
+
case 'llm.ai_hub_router_model':
|
|
699
|
+
return isModelAlias(value);
|
|
687
700
|
case 'llm.byok_key':
|
|
688
701
|
case 'llm.ai_hub_token':
|
|
689
702
|
return value.length > 0; // Secrets pass through if non-empty; no format check
|
|
@@ -59,7 +59,7 @@ function printLlmMissingHints(llm) {
|
|
|
59
59
|
if (typeof mode !== 'string')
|
|
60
60
|
return;
|
|
61
61
|
const required = LLM_REQUIRED_KEYS_BY_MODE[mode];
|
|
62
|
-
if (!required
|
|
62
|
+
if (!required)
|
|
63
63
|
return;
|
|
64
64
|
for (const key of required) {
|
|
65
65
|
const value = llm[key];
|
|
@@ -67,6 +67,10 @@ function printLlmMissingHints(llm) {
|
|
|
67
67
|
console.log(`${key} = ${paint('bad', '<not set>')}`);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
// 2026-10-02: the optional routing alias — say what routing uses instead.
|
|
71
|
+
if (mode === 'ai-hub' && !llm['ai_hub_router_model']) {
|
|
72
|
+
console.log(`ai_hub_router_model = ${paint('dim', '<not set — skill routing uses ai_hub_model_alias>')}`);
|
|
73
|
+
}
|
|
70
74
|
}
|
|
71
75
|
function printConfigAsToml(cfg, prefix = '') {
|
|
72
76
|
// Emit scalars before nested objects — otherwise a top-level scalar that
|
package/dist/commands/cost.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* No mutation, no network. Safe to run anytime.
|
|
10
10
|
*/
|
|
11
11
|
import chalk from 'chalk';
|
|
12
|
-
import { readCostLog, summarise } from '../cost/cost-log.js';
|
|
12
|
+
import { readCostLog, summarise, turnRows } 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
15
|
import { panelHeading, panelRow } from '../renderer/panel-rows.js';
|
|
@@ -22,14 +22,12 @@ import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
|
22
22
|
* number — they are skipped, not counted as zero, and the caller says so.
|
|
23
23
|
*/
|
|
24
24
|
function summariseCredits(entries) {
|
|
25
|
+
// 2026-10-03: counted per TURN (turnRows) — a turn writes one line per call.
|
|
25
26
|
let total = 0;
|
|
26
27
|
let unknown = 0;
|
|
27
|
-
for (const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
continue;
|
|
31
|
-
if (typeof e.credits_charged === 'number')
|
|
32
|
-
total += e.credits_charged;
|
|
28
|
+
for (const r of turnRows(entries)) {
|
|
29
|
+
if (typeof r.credits === 'number')
|
|
30
|
+
total += r.credits;
|
|
33
31
|
else
|
|
34
32
|
unknown++;
|
|
35
33
|
}
|
|
@@ -52,8 +50,9 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
|
|
|
52
50
|
const s = summarise(entries);
|
|
53
51
|
// Credits mode: the customer is billed in credits at a multiplier we do not
|
|
54
52
|
// disclose, so NOTHING derived from cost/pricing.ts may be printed here —
|
|
55
|
-
// not the total, not the per-model split, not the per-turn figures.
|
|
56
|
-
//
|
|
53
|
+
// not the total, not the per-model split, not the per-turn figures.
|
|
54
|
+
// 2026-10-02 (owner): tokens go too. Not a COGS leak on their own, but beside
|
|
55
|
+
// credits they invite "credits per token" maths — credits only for managed.
|
|
57
56
|
const inCredits = creditsActive();
|
|
58
57
|
const perTurn = summariseCredits(entries);
|
|
59
58
|
const turnsNote = `${s.turns} turn${s.turns === 1 ? '' : 's'}`;
|
|
@@ -70,12 +69,14 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
|
|
|
70
69
|
else {
|
|
71
70
|
emit(panelHeading(`Session cost so far: ${formatCost(s.totalCost)}`, turnsNote));
|
|
72
71
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
if (!inCredits) {
|
|
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
|
+
}
|
|
79
80
|
// Task 18 fix M3 — reader subagent spend (already inside the total above).
|
|
80
81
|
// USD only: in credits mode the balance delta already carries it.
|
|
81
82
|
if (s.readerCalls > 0 && !inCredits) {
|
|
@@ -99,22 +100,23 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
|
|
|
99
100
|
// Recent turns — last 5, most recent first. Piece 2: one count format, "N of M" (P1's countLabel).
|
|
100
101
|
// Final review I6 — reader rollup lines (interrupted turns) are not turns:
|
|
101
102
|
// they stay out of the list and the count (summarise() already skips them).
|
|
102
|
-
|
|
103
|
-
const
|
|
103
|
+
// 2026-10-03: one row per TURN (a turn writes one line per model call).
|
|
104
|
+
const rows = turnRows(entries);
|
|
105
|
+
const recent = rows.slice(-5).reverse();
|
|
104
106
|
emit('');
|
|
105
|
-
emit(chalk.bold(`Recent turns · ${countLabel(recent.length,
|
|
107
|
+
emit(chalk.bold(`Recent turns · ${countLabel(recent.length, rows.length)}`));
|
|
106
108
|
for (const e of recent) {
|
|
107
|
-
const tok =
|
|
109
|
+
const tok = e.tokens.toLocaleString();
|
|
108
110
|
// Credits mode: the per-turn figure is what the LEDGER charged (a balance
|
|
109
111
|
// delta recorded at turn end), never our computed USD. A turn whose
|
|
110
112
|
// balance sample failed shows the pending marker rather than a number we
|
|
111
113
|
// would have to invent.
|
|
112
114
|
const tail = inCredits
|
|
113
|
-
? (typeof e.
|
|
114
|
-
? formatCredits(e.
|
|
115
|
+
? (typeof e.credits === 'number'
|
|
116
|
+
? formatCredits(e.credits, credits?.unitLabel ?? 'credits')
|
|
115
117
|
: CREDITS_PENDING)
|
|
116
118
|
: formatCost(e.cost);
|
|
117
|
-
emit(panelRow(`turn ${String(e.turn).padStart(3)}`, `${tok.padStart(10)} tok ${tail}`, 8));
|
|
119
|
+
emit(panelRow(`turn ${String(e.turn).padStart(3)}`, inCredits ? tail : `${tok.padStart(10)} tok ${tail}`, 8));
|
|
118
120
|
}
|
|
119
121
|
if (inCredits && perTurn.unknown > 0) {
|
|
120
122
|
emit('');
|