@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.
Files changed (44) hide show
  1. package/dist/agent/loop.js +20 -8
  2. package/dist/agent/tool-dispatch.js +6 -0
  3. package/dist/auth/auth-file.js +173 -12
  4. package/dist/classifier/client.js +55 -30
  5. package/dist/cli.js +11 -0
  6. package/dist/commands/config-set.js +13 -0
  7. package/dist/commands/config-show.js +5 -1
  8. package/dist/commands/cost.js +24 -22
  9. package/dist/commands/deploy-card.js +135 -0
  10. package/dist/commands/login.js +31 -13
  11. package/dist/commands/plan-chain.js +31 -0
  12. package/dist/commands/plan-continue.js +1 -0
  13. package/dist/commands/plan-resume.js +49 -2
  14. package/dist/cost/cost-log.js +76 -4
  15. package/dist/cost/credits-wire.js +9 -0
  16. package/dist/cost/credits.js +24 -6
  17. package/dist/cost/router-spend.js +24 -0
  18. package/dist/cost/session-cost.js +16 -2
  19. package/dist/models/server-config.js +21 -1
  20. package/dist/one-shot.js +12 -2
  21. package/dist/projects/plan-lane.js +47 -0
  22. package/dist/projects/plan-run.js +8 -0
  23. package/dist/projects/plan-schema.js +2 -0
  24. package/dist/projects/save-command.js +3 -2
  25. package/dist/renderer/footer-line.js +10 -4
  26. package/dist/renderer/status-footer.js +5 -4
  27. package/dist/renderer/tool-labels.js +2 -1
  28. package/dist/repl.js +41 -8
  29. package/dist/router/byok-classifier.js +159 -0
  30. package/dist/router/classifier.js +130 -6
  31. package/dist/router/router-core.js +307 -0
  32. package/dist/router/router-prompt.js +121 -0
  33. package/dist/router/routing-failure.js +65 -3
  34. package/dist/skills/bundled-skills.js +45 -45
  35. package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
  36. package/dist/tools/fiori/deploy/run.js +608 -0
  37. package/dist/tools/fiori/deploy/scope.js +8 -0
  38. package/dist/tools/fiori/deploy/yaml-read.js +236 -0
  39. package/dist/tools/fiori/deploy-config.js +24 -6
  40. package/dist/tools/fiori/tools.js +117 -5
  41. package/dist/tools/local-build.js +3 -0
  42. package/dist/tools/subagent/read_agent.js +4 -2
  43. package/dist/tools/tool-loading.js +1 -1
  44. package/package.json +1 -1
@@ -1,10 +1,11 @@
1
1
  import { loadConfig } from '../config/loader.js';
2
2
  import { getStore } from '../auth/api-key.js';
3
- import { classify as classifyLocal } from '../classifier/client.js';
3
+ import { classify as classifyLocal, classifyWithConfidence } from '../classifier/client.js';
4
4
  import { SKILL_CATALOG, SKILL_ALIASES, resolveSkillAlias } from '../skill-catalog.js';
5
5
  import { isBuiltinCommandHead } from '../repl/builtin-commands.js';
6
6
  import { expandImageAttachments } from '../projects/image-attachments.js';
7
7
  import { expandImageChips } from '../ui/clipboard-image.js';
8
+ import { classifyByok, ByokRouterFailure } from './byok-classifier.js';
8
9
  /** A non-2xx answer from the classify endpoint (fix 9: carries the kind). */
9
10
  export class ClassifyHttpError extends Error {
10
11
  status;
@@ -15,6 +16,51 @@ export class ClassifyHttpError extends Error {
15
16
  this.name = 'ClassifyHttpError';
16
17
  }
17
18
  }
19
+ /**
20
+ * Live fix 2026-10-02 — the classify endpoint answered 2xx but the body was
21
+ * not a classification (not JSON, or no skill). Used to escape as a raw
22
+ * SyntaxError; now a routing failure the callers degrade from.
23
+ */
24
+ export class ClassifyBodyError extends Error {
25
+ transport_kind = 'failed';
26
+ constructor() {
27
+ super('classifier returned an unreadable answer');
28
+ this.name = 'ClassifyBodyError';
29
+ }
30
+ }
31
+ /**
32
+ * The bundled local-rules pick used whenever the remote classifier gives no
33
+ * answer (transport failure, and since 2026-10-02 any classify failure).
34
+ * Confidence 0.3 < 0.60 ⇒ the low-confidence path (picker / one-shot notice).
35
+ *
36
+ * 2026-10-02: the no-rule-matched default is abap-explain (read-only,
37
+ * generic — the same default the router uses for an unparseable answer). It
38
+ * was abap-radar, retired 2026-06-28 (merged into abap-explain's technical
39
+ * mode).
40
+ */
41
+ export function localRulesResult(prompt) {
42
+ return { skill: classifyLocal(prompt) ?? 'abap-explain', confidence: 0.3, alternates: [] };
43
+ }
44
+ /**
45
+ * Local mode's routing (2026-10-02, review items 3 + S2): the bundled rules
46
+ * only — the prompt never leaves the laptop. A matching rule is a real pick,
47
+ * not a failure fallback, so there is no "couldn't route" notice: an
48
+ * unambiguous (strong) rule routes silently (0.85), any other match opens the
49
+ * picker preselected (0.6); no match is the low-confidence local-rules result
50
+ * (picker, abap-explain).
51
+ *
52
+ * Why not the local model: local endpoints (Ollama / vLLM, OpenAI-compatible)
53
+ * have no structured outputs, small local models are unreliable at strict
54
+ * JSON routing, and a large one adds seconds per prompt on a laptop — on top
55
+ * of the turn itself. Local mode is text-only today; instant, offline,
56
+ * deterministic rules with a one-key confirm fit it better.
57
+ */
58
+ function localModeResult(prompt) {
59
+ const hit = classifyWithConfidence(prompt);
60
+ return hit
61
+ ? { skill: resolveSkillAlias(hit.skill), confidence: hit.confidence, alternates: [] }
62
+ : localRulesResult(prompt);
63
+ }
18
64
  // 8s — sized to cover the observed Fly cold-start (~3.2s on the 2026-05-20
19
65
  // probe) plus headroom for a slow network. Tighter values risk timing out
20
66
  // legitimate cold-start hits and burning the retry budget on the same.
@@ -111,6 +157,31 @@ export async function classifyPrompt(prompt, context, opts = {}) {
111
157
  throw new Error('slash command reached the classifier — explicit /commands must be routed before classification (CLI bug, please report)');
112
158
  }
113
159
  const cfg = await loadConfig();
160
+ // 2026-10-02 (local routing): outside managed mode the prompt never goes to
161
+ // a CSPeach host. BYOK classifies on the laptop, direct to the model API
162
+ // with the customer's own key; ai-hub through the customer's own hub;
163
+ // local by the bundled rules (byok-classifier.ts / localModeResult).
164
+ // Managed keeps /v1/classify below, byte-identical.
165
+ const mode = cfg.llm?.mode;
166
+ if (mode === 'local') {
167
+ if (opts.signal?.aborted)
168
+ throw routingCancelled();
169
+ return localModeResult(prompt);
170
+ }
171
+ if (mode === 'byok')
172
+ return classifyPromptByok(prompt, context, opts, { mode: 'byok' });
173
+ if (mode === 'ai-hub') {
174
+ const baseURL = cfg.llm.ai_hub_base_url;
175
+ if (!baseURL)
176
+ throw new Error('ai-hub mode requires llm.ai_hub_base_url — run: cspeach config set llm.ai_hub_base_url <url>');
177
+ // llm.ai_hub_router_model (optional, 2026-10-02) routes on a cheaper /
178
+ // faster deployment; unset ⇒ the session alias.
179
+ return classifyPromptByok(prompt, context, opts, {
180
+ mode: 'ai-hub',
181
+ baseURL,
182
+ modelAlias: cfg.llm.ai_hub_router_model ?? cfg.llm.ai_hub_model_alias,
183
+ });
184
+ }
114
185
  const store = await getStore();
115
186
  const key = await store.get();
116
187
  if (!key)
@@ -147,11 +218,8 @@ export async function classifyPrompt(prompt, context, opts = {}) {
147
218
  // local rules so the user's turn is NOT dropped — they still get a
148
219
  // coaching picker (confidence 0.3 < 0.60 = picker decision), and the
149
220
  // REPL surfaces an offline hint with the real cause.
150
- const skill = classifyLocal(prompt) ?? 'abap-radar';
151
221
  return {
152
- skill,
153
- confidence: 0.3,
154
- alternates: [],
222
+ ...localRulesResult(prompt),
155
223
  offline: true,
156
224
  transport_error: lastErr ? extractCause(lastErr) : 'unknown',
157
225
  transport_kind: transportKindOf(lastErr),
@@ -159,7 +227,28 @@ export async function classifyPrompt(prompt, context, opts = {}) {
159
227
  }
160
228
  if (!r.ok)
161
229
  throw new ClassifyHttpError(r.status);
162
- const result = (await r.json());
230
+ // Live fix 2026-10-02: an unreadable 2xx body is a routing failure, not a
231
+ // raw SyntaxError escaping to the caller.
232
+ let result;
233
+ try {
234
+ result = (await r.json());
235
+ }
236
+ catch {
237
+ throw new ClassifyBodyError();
238
+ }
239
+ return normaliseResult(result);
240
+ }
241
+ /**
242
+ * The checks every classification gets, wherever it came from (the proxy, or
243
+ * since 2026-10-02 the BYOK router on the laptop).
244
+ */
245
+ function normaliseResult(result) {
246
+ if (!result || typeof result.skill !== 'string' || result.skill === '')
247
+ throw new ClassifyBodyError();
248
+ if (typeof result.confidence !== 'number')
249
+ result.confidence = 0;
250
+ if (!Array.isArray(result.alternates))
251
+ result.alternates = [];
163
252
  // Portfolio-merge resilience (2026-06-12): the proxy classifier's skill
164
253
  // list can lag a rename (it still knows 'abap-dump-analyze' until its
165
254
  // SKILL_LIST is redeployed). Map retired names — primary and alternates —
@@ -171,6 +260,41 @@ export async function classifyPrompt(prompt, context, opts = {}) {
171
260
  }
172
261
  return result;
173
262
  }
263
+ /**
264
+ * BYOK / ai-hub branch of classifyPrompt (2026-10-02). Same contract as the managed
265
+ * path — a result, a low-confidence local-rules result when the router could
266
+ * not be reached, ClassifyHttpError for an HTTP failure, AbortError on cancel
267
+ * — so every caller (Ink REPL, classic REPL, one-shot) is unchanged.
268
+ */
269
+ async function classifyPromptByok(prompt, context, opts, target) {
270
+ if (opts.signal?.aborted)
271
+ throw routingCancelled();
272
+ const images = await promptImages(prompt, opts.cwd ?? process.cwd());
273
+ if (opts.signal?.aborted)
274
+ throw routingCancelled();
275
+ try {
276
+ const result = await classifyByok(prompt, { sap_system: context.sap_system, last_skill: context.last_skill }, images, { signal: opts.signal, target });
277
+ return normaliseResult({ ...result });
278
+ }
279
+ catch (err) {
280
+ if (opts.signal?.aborted)
281
+ throw routingCancelled();
282
+ if (err instanceof ByokRouterFailure) {
283
+ const f = err.failure;
284
+ if (f.kind === 'http')
285
+ throw new ClassifyHttpError(f.status);
286
+ // The model API was not reached. 'failed', not 'network': the network
287
+ // notice names the CSPeach service, which BYOK routing never calls.
288
+ return {
289
+ ...localRulesResult(prompt),
290
+ offline: true,
291
+ transport_error: f.detail,
292
+ transport_kind: f.kind === 'timeout' ? 'timeout' : 'failed',
293
+ };
294
+ }
295
+ throw err;
296
+ }
297
+ }
174
298
  export function parseShortcut(input) {
175
299
  // `/skill-name rest` → { skill: 'abap-skill-name', prompt: 'rest' }
176
300
  // abap- prefix auto-prepended if missing (all skills in KV use abap-* namespace).
@@ -0,0 +1,307 @@
1
+ // GENERATED COPY — do not edit here. Edit cspeach-proxy/src/classifier/router-core.ts,
2
+ // then run `npx tsx scripts/sync-router-core.ts` (prebuild runs it too).
3
+ // src/router/__tests__/router-core-pin.test.ts fails when this copy drifts.
4
+ // ---- verbatim copy of cspeach-proxy/src/classifier/router-core.ts below ----
5
+ // ROUTER CORE — the single source of the skill router's instructions and call
6
+ // behaviour (2026-10-02, BYOK local routing).
7
+ //
8
+ // Why this file exists: in BYOK mode the customer's prompt must never reach a
9
+ // CSPeach server, so the CLI classifies on the laptop, direct to the model
10
+ // with the customer's own key. To keep that routing exactly as good as the
11
+ // managed /v1/classify route, both sides run THIS code:
12
+ // - the proxy imports it (classifier/haiku.ts, and skills/skill-manifest.ts,
13
+ // which publishes ROUTER_PROMPT inside the signed skill manifest);
14
+ // - the CLI carries a byte-identical copy at
15
+ // cspeach-cli/src/router/router-core.ts (scripts/sync-router-core.ts
16
+ // writes it; a CLI test fails on any drift).
17
+ //
18
+ // RULES for this file: NO imports (it must compile, unchanged, in both
19
+ // packages); keep it plain TypeScript that passes both tsconfigs. Editing the
20
+ // skill list or routing notes here changes managed routing (after a proxy
21
+ // deploy), the signed manifest (after publish-skill-manifest) and the CLI's
22
+ // bundled fallback (after sync-router-core + a CLI release).
23
+ /**
24
+ * Version of the router entry's SHAPE in the signed manifest
25
+ * ({ schema_version, system, screenshot_note }). A CLI uses a manifest router
26
+ * entry only when it knows this version; otherwise it keeps its bundled copy.
27
+ * Bump it only when the shape or the request contract changes — not for text
28
+ * edits.
29
+ */
30
+ export const ROUTER_SCHEMA_VERSION = 1;
31
+ /**
32
+ * Revision of the router TEXT and request (2026-10-02, review item 2). Bump
33
+ * it on EVERY change to SKILL_DESCRIPTIONS, the routing notes, ROUTER_SYSTEM,
34
+ * SCREENSHOT_NOTE or buildRouterRequest. A BYOK CLI prefers the signed
35
+ * manifest's router entry only when its revision is the same or newer than
36
+ * its own bundled copy — so a stale published manifest never rolls a newer
37
+ * CLI back to older routing. router-revision-pin.test.ts fails until bumped.
38
+ */
39
+ export const ROUTER_REVISION = 1;
40
+ // 2026-06-12: keep in lockstep with .claude/skills (35 abap skills).
41
+ // abap-dump replaced abap-dump-analyze + abap-dump-triage (portfolio merge);
42
+ // added previously-missing abap-plan, abap-fiori-build, abap-cca-merge, abap-upgrade-merge.
43
+ // 2026-06-28 (M1-M4 portfolio merges): removed 4 donor skills — abap-radar → abap-explain
44
+ // (technical orientation mode); abap-document → abap-handover (object scope); abap-performance
45
+ // → abap-review (performance lens); abap-cloud-check → abap-review (cloud lens). Skill count
46
+ // drops from 35 to 31. abap-preflight is NOT removed (chained by abap-transport-analysis).
47
+ // 2026-06-15 (Bug #1): pair every skill with a one-line description. Haiku was
48
+ // routing on bare NAMES — "color the lines on a custom Z report" matched the word
49
+ // "enhance" (82%) when it should have been abap-refactor; Haiku never saw that
50
+ // abap-enhance = extend SAP STANDARD only. The one-liners + routing notes below
51
+ // encode the key disambiguations directly. These keys ARE the skill list — there
52
+ // is no separate name array to drift out of sync.
53
+ // 2026-09-25: added abap-cap (CAP phase 1) — "build a CAP app" had no home and
54
+ // fell back to abap-generate at low confidence.
55
+ // 2026-10-01: added abap-app (app lane routing slice 1) — one entry from a spec
56
+ // document to a running app; the CAP routing note below is unchanged.
57
+ export const SKILL_DESCRIPTIONS = {
58
+ 'abap-app': 'Build an app FROM a spec or requirements document (Word, PDF, Excel or text) — finds the spec gaps, picks the app lane (backend + UI) and confirms it with the developer, then plans and builds the app.',
59
+ 'abap-atc-fix': 'Run ATC checks on an object and auto-fix findings where safe.',
60
+ 'abap-cap': 'Build a CAP (SAP Cloud Application Programming Model, Node.js / CDS) app on the laptop — domain model, service, handlers, Fiori Elements app — run it locally and prove it with the Fiori checks. Side-by-side (BTP) extensions, not the ABAP core.',
61
+ 'abap-cca': 'Custom Code Analysis — inventory + classify a whole custom-code estate (Keep/Fix/Retire) for S/4HANA migration / Clean Core.',
62
+ 'abap-cca-merge': 'Merge multiple parallel CCA assessment files into one consolidated assessment.',
63
+ 'abap-data-model': 'Design and create DDIC objects — domains, data elements, tables, structures.',
64
+ 'abap-design': 'Design solution architecture — object list, dependency sequence, pattern selection. After spec-gap, before coding.',
65
+ 'abap-dump': 'Diagnose ABAP short dumps (ST22 runtime errors) and fix them.',
66
+ 'abap-eml': 'Write and test EML (Entity Manipulation Language) for RAP business objects.',
67
+ 'abap-enhance': 'Extend SAP-delivered STANDARD objects via BAdI / enhancement spots / exits. ONLY for standard objects — never for editing your own custom Z/Y code.',
68
+ 'abap-estimate': 'Effort estimation for ABAP tickets — component breakdown, hour ranges, risk.',
69
+ 'abap-explain': 'Understand an existing object — plain-language explanation (default) or a `technical` developer orientation (deps, tables, risks, modernization notes).',
70
+ 'abap-fiori-build': 'Build a freestyle SAPUI5 app on the laptop from already-published OData services.',
71
+ 'abap-generate': 'Create NEW ABAP code/objects from a requirement (greenfield).',
72
+ 'abap-handover': 'Documentation for an object (`object` scope) or a whole package (`package` scope) — purpose, interfaces, deps, catalog, architecture, config.',
73
+ 'abap-impact': 'Analyze the impact of changing an object before touching it — where-used, dependents, risk.',
74
+ 'abap-incident': 'End-to-end incident pipeline — from short dump to fix deployed and documented.',
75
+ 'abap-migrate': 'ECC→S/4HANA migration assessment for custom objects — API analysis, successor lookup, per-object effort.',
76
+ 'abap-modernize': 'Transform a CLASSIC program into a modern architecture (e.g. report → Fiori Elements / RAP) for the release level.',
77
+ 'abap-plan': 'Plan and run a multi-session SAP project — phase envelope carries state across sessions, one phase per session.',
78
+ 'abap-preflight': 'Pre-release checklist for a change set — dependencies, tests, rollback, readiness.',
79
+ 'abap-rap': 'Scaffold a complete RAP stack from scratch — table, CDS interface+projection, BDEF, behavior class, service definition+binding.',
80
+ 'abap-refactor': 'Modify / improve / add a feature to EXISTING custom ABAP code in place — restructure, clean up, or change a Z/Y program/class (incl. small feature changes like adding output coloring).',
81
+ 'abap-review': 'Read-only code review across lenses: `quality` (default, 7 criteria), `performance` (N+1/SELECT*/indexes), `cloud` (clean-core / ABAP Cloud readiness).',
82
+ 'abap-segw': 'Implement SEGW OData service methods (MPC/DPC) for an existing gateway project.',
83
+ 'abap-spec-gap': "Find what's missing before coding — business questions, technical gaps, edge cases, test scenarios.",
84
+ 'abap-test': 'Generate ABAP Unit test classes for existing code.',
85
+ 'abap-transport': 'Transport management — create requests, validate object lists, release transports.',
86
+ 'abap-transport-analysis': 'Pre-release transport analysis — list objects, ATC each, cross-dependency + conflict checks.',
87
+ 'abap-upgrade-fix': 'Interactive upgrade remediation — fix ATC findings object-by-object with successor-API lookup and approval.',
88
+ 'abap-upgrade-merge': 'Merge multiple parallel upgrade-fix progress files into one consolidated progress.',
89
+ 'abap-upgrade-scan': 'Scan custom code for S/4HANA upgrade findings — ATC readiness variant, categorize, remediation plan.',
90
+ 'abap-upgrade-verify': 'Verify upgrade remediation — re-run ATC, compare against baseline, flag regressions.',
91
+ };
92
+ export const SKILL_LIST = Object.keys(SKILL_DESCRIPTIONS);
93
+ // Screenshot-aware routing (2026-09-23) — appended ONLY when images are sent,
94
+ // so text-only routing keeps the exact pre-feature instructions.
95
+ export const SCREENSHOT_NOTE = `
96
+
97
+ A screenshot is attached. Read it (error text, transaction code such as ST22/ATC/SE80, object names) and classify on the prompt AND the screenshot together — a vague prompt ("can you see this?") with an ST22 dump is abap-dump. Also add "screenshot": a one-line reading of it (e.g. "ST22 dump: CX_SY_ZERODIVIDE in ZCL_X, method CALC").`;
98
+ export const ROUTER_SYSTEM = `You classify user prompts into ABAP-development skills. Given a prompt and context, return JSON: {"skill": "<name>", "confidence": 0-1, "alternates": ["<name>"], "reasoning": "..."}.
99
+
100
+ Skills (name — what it is for):
101
+ ${SKILL_LIST.map((n) => `- ${n} — ${SKILL_DESCRIPTIONS[n]}`).join('\n')}
102
+
103
+ Routing notes:
104
+ - Editing, changing, or adding a feature to an EXISTING custom (Z*/Y*) object → abap-refactor (in-place change) or abap-generate (new objects). abap-enhance is ONLY for SAP-delivered STANDARD objects — never for custom code.
105
+ - "Make a classic report modern / turn it into Fiori" → abap-modernize. "Build a full RAP stack from scratch" → abap-rap.
106
+ - CAP / cds / Node.js / a side-by-side BTP app or extension → abap-cap. On-stack RAP inside the ABAP system → abap-rap. A UI on an already-published OData service → abap-fiori-build.
107
+ - Building an app FROM a spec or requirements document (Word / PDF / Excel, 'build the app in this spec') → abap-app. A direct request without a spec document keeps its own skill.
108
+ - Quick cold-read / orientation of an object → abap-explain (technical audience).
109
+ - Performance issues / N+1 / SELECT * → abap-review (performance lens). Clean-core / ABAP Cloud readiness of one object → abap-review (cloud lens); estate-wide clean-core → abap-cca.
110
+ - Developer documentation of an object → abap-handover (object scope).
111
+
112
+ Pick the single best skill. Confidence is 0.75+ if the intent clearly matches one skill, 0.6-0.75 if two skills are plausible, <0.6 if none are clearly right.`;
113
+ export const ROUTER_PROMPT = {
114
+ schema_version: ROUTER_SCHEMA_VERSION,
115
+ revision: ROUTER_REVISION,
116
+ system: ROUTER_SYSTEM,
117
+ screenshot_note: SCREENSHOT_NOTE,
118
+ };
119
+ /**
120
+ * A router entry read from outside (the signed manifest), or null when it is
121
+ * not one this code can use — unknown schema version, missing revision,
122
+ * missing or empty text.
123
+ * Callers then keep ROUTER_PROMPT. Never throws.
124
+ */
125
+ export function asRouterPrompt(raw) {
126
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
127
+ return null;
128
+ const r = raw;
129
+ if (r.schema_version !== ROUTER_SCHEMA_VERSION)
130
+ return null;
131
+ if (typeof r.revision !== 'number' || !Number.isInteger(r.revision) || r.revision < 0)
132
+ return null;
133
+ if (typeof r.system !== 'string' || r.system.trim() === '')
134
+ return null;
135
+ if (typeof r.screenshot_note !== 'string')
136
+ return null;
137
+ return { schema_version: ROUTER_SCHEMA_VERSION, revision: r.revision, system: r.system, screenshot_note: r.screenshot_note };
138
+ }
139
+ const tok = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
140
+ // Live fix 2026-10-02: some prompts made the router answer in prose instead of
141
+ // JSON (e.g. a read-only question ending "Answer in a short list." — the router
142
+ // followed the user's format request). classifyWithUsage threw 'Classifier
143
+ // produced non-JSON output' and /v1/classify returned HTTP 500, which the CLI
144
+ // one-shot surfaced as "unhandled error". Three layers now:
145
+ // 1. Structured outputs (output_config.format, json_schema) ask the API for
146
+ // JSON. Chosen over an assistant prefill of "{" — the router role is
147
+ // admin-tunable and prefill is rejected (400) on every 4.6+ model — and
148
+ // over forced tool use — tool_choice "tool" is rejected on Opus 5.5 /
149
+ // Sonnet 5.5 / Fable 5.1. Structured outputs run on the default router
150
+ // (claude-haiku-4-5) and the current 5.x models. SYSTEM is unchanged, so
151
+ // routing for normal prompts is the same.
152
+ // 2. A strict parse; an unparseable answer is retried ONCE.
153
+ // 3. Still unparseable → ROUTER_UNPARSEABLE_RESULT (confidence 0 ⇒ the CLI's
154
+ // low-confidence path: picker in the REPL, top-1 + notice in one-shot),
155
+ // flagged `unparseable` so the route does not cache it. Never a throw.
156
+ // Upstream API errors (network / 4xx / 5xx from the provider) still throw; the
157
+ // route turns them into a JSON 502 and the CLI degrades to local rules.
158
+ /** abap-explain: read-only and generic — the safest default when the router gave no answer. */
159
+ export const ROUTER_UNPARSEABLE_RESULT = Object.freeze({
160
+ skill: 'abap-explain',
161
+ confidence: 0,
162
+ alternates: [],
163
+ reasoning: 'router_unparseable',
164
+ });
165
+ /** JSON schema for the router answer — the same fields SYSTEM asks for. */
166
+ export function resultSchema(withScreenshot) {
167
+ return {
168
+ type: 'object',
169
+ properties: {
170
+ skill: { type: 'string' },
171
+ confidence: { type: 'number' },
172
+ alternates: { type: 'array', items: { type: 'string' } },
173
+ reasoning: { type: 'string' },
174
+ // Only offered when a screenshot was sent (SCREENSHOT_NOTE asks for it).
175
+ ...(withScreenshot ? { screenshot: { type: 'string' } } : {}),
176
+ },
177
+ required: ['skill', 'confidence', 'alternates', 'reasoning'],
178
+ additionalProperties: false,
179
+ };
180
+ }
181
+ /** The router's text → a RouterResult, or null when it is not one. Never throws. */
182
+ export function parseRouterText(text) {
183
+ const candidates = [text.trim()];
184
+ const m = text.match(/\{[\s\S]*\}/);
185
+ if (m && m[0] !== candidates[0])
186
+ candidates.push(m[0]);
187
+ for (const c of candidates) {
188
+ let v;
189
+ try {
190
+ v = JSON.parse(c);
191
+ }
192
+ catch {
193
+ continue;
194
+ }
195
+ if (!v || typeof v !== 'object' || Array.isArray(v))
196
+ continue;
197
+ const r = v;
198
+ if (typeof r.skill !== 'string' || r.skill.trim() === '')
199
+ continue;
200
+ return {
201
+ ...r,
202
+ confidence: typeof r.confidence === 'number' && Number.isFinite(r.confidence) ? r.confidence : 0,
203
+ alternates: Array.isArray(r.alternates) ? r.alternates.filter((a) => typeof a === 'string') : [],
204
+ };
205
+ }
206
+ return null;
207
+ }
208
+ /** The user turn the router sees: the prompt plus the routing context. */
209
+ export function routerUserMessage(prompt, context) {
210
+ return `Prompt: ${prompt}\n\nContext: sap_system=${context.sap_system ?? 'unknown'}, last_skill=${context.last_skill ?? 'none'}`;
211
+ }
212
+ /** The router request body (Messages API), exactly as the proxy has sent it since 2026-10-02. */
213
+ export function buildRouterRequest(model, prompt, context, images = [], routerPrompt = ROUTER_PROMPT) {
214
+ const userMsg = routerUserMessage(prompt, context);
215
+ return {
216
+ model,
217
+ // Root cause found live 2026-10-02 (v54): the router model (claude-sonnet-5)
218
+ // thinks by default and, on harder prompts, spent all 300 tokens on a
219
+ // thinking block — stop_reason max_tokens, no text, unparseable. Routing is
220
+ // a quick pick: thinking off, and headroom for the JSON answer.
221
+ max_tokens: 600,
222
+ thinking: { type: 'disabled' },
223
+ system: images.length === 0 ? routerPrompt.system : routerPrompt.system + routerPrompt.screenshot_note,
224
+ // 2026-10-02: JSON via structured outputs (see the note above).
225
+ output_config: { format: { type: 'json_schema', schema: resultSchema(images.length > 0) } },
226
+ // Screenshot-aware routing (2026-09-23): images ride ahead of the text in
227
+ // the same message; with none, the plain string is sent as before.
228
+ messages: [{
229
+ role: 'user',
230
+ content: images.length === 0
231
+ ? userMsg
232
+ : [
233
+ ...images.map((im) => ({
234
+ type: 'image',
235
+ source: { type: 'base64', media_type: im.media_type, data: im.data },
236
+ })),
237
+ { type: 'text', text: userMsg },
238
+ ],
239
+ }],
240
+ };
241
+ }
242
+ /**
243
+ * Send the router request: the self-heal, parse, one retry. `create` is the
244
+ * caller's Messages API call (the proxy's org-key client, or the CLI's BYOK
245
+ * client) — injected so this file stays import-free. Provider errors other
246
+ * than the self-heal case are rethrown for the caller to map.
247
+ */
248
+ export async function runRouterCalls(create, request, opts = {}) {
249
+ const now = opts.now ?? Date.now;
250
+ // 2026-10-02 (self-healing): the router role is admin-tunable, and not every
251
+ // model accepts structured outputs (e.g. Sonnet 4.6 / Opus 4.6–4.7 are not on
252
+ // the supported list). Sending output_config to one of those is a 400 on
253
+ // EVERY classify — all customers would drop to the CLI's local rules. So a
254
+ // 400 while output_config is sent re-sends the same call ONCE without it;
255
+ // the parse / retry / fallback below then works as before (SYSTEM still
256
+ // asks for JSON). A 400 without output_config, and any non-400, surface.
257
+ let req = request;
258
+ const send = async () => {
259
+ try {
260
+ return await create(req);
261
+ }
262
+ catch (err) {
263
+ if ('output_config' in req && err?.status === 400) {
264
+ opts.warn?.(`[classify] router model ${request.model} rejected output_config (400) — retrying without structured outputs:`, err instanceof Error ? err.message : String(err));
265
+ const { output_config: _dropped, ...rest } = request;
266
+ req = rest;
267
+ return await create(req);
268
+ }
269
+ throw err;
270
+ }
271
+ };
272
+ const calls = [];
273
+ let result = null;
274
+ // At most two parsed answers: the first, and ONE retry when it is unparseable.
275
+ for (let attempt = 0; attempt < 2 && result === null; attempt++) {
276
+ const startedAt = now();
277
+ const res = await send();
278
+ const u = res?.usage ?? {};
279
+ const call = {
280
+ usage: {
281
+ input: tok(u.input_tokens),
282
+ output: tok(u.output_tokens),
283
+ cacheRead: tok(u.cache_read_input_tokens),
284
+ cacheCreate: tok(u.cache_creation_input_tokens),
285
+ },
286
+ latencyMs: now() - startedAt,
287
+ };
288
+ calls.push(call);
289
+ opts.onCall?.(call);
290
+ const content = Array.isArray(res?.content) ? res.content : [];
291
+ const text = content
292
+ .filter((b) => b.type === 'text')
293
+ .map((b) => b.text)
294
+ .join('\n');
295
+ result = parseRouterText(text);
296
+ }
297
+ return { result, calls };
298
+ }
299
+ /** Token counts summed over every call made. */
300
+ export function sumRouterUsage(calls) {
301
+ return calls.reduce((s, c) => ({
302
+ input: s.input + c.usage.input,
303
+ output: s.output + c.usage.output,
304
+ cacheRead: s.cacheRead + c.usage.cacheRead,
305
+ cacheCreate: s.cacheCreate + c.usage.cacheCreate,
306
+ }), { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 });
307
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Where BYOK (and ai-hub) get the skill router's instructions (2026-10-02,
3
+ * local routing).
4
+ *
5
+ * In BYOK the CLI classifies prompts on the laptop (router/byok-classifier.ts)
6
+ * — the prompt never goes to a CSPeach host. The instructions it classifies
7
+ * with must not drift from the proxy's and must be tamper-evident, so they
8
+ * come from:
9
+ * 1. the `router` entry of the SIGNED skill manifest
10
+ * (manifest.cspeach.dev/v1.json), verified with the same ed25519 check as
11
+ * skill bodies — the proxy publishes it from the same source
12
+ * /v1/classify runs on (cspeach-proxy/src/classifier/router-core.ts) —
13
+ * used only when its revision is the same or NEWER than the bundled copy
14
+ * (review item 2: a stale published manifest never rolls back routing);
15
+ * 2. otherwise (older server without the entry, older revision, unknown
16
+ * schema version, bad signature, manifest unreachable or slow) the copy
17
+ * compiled into this CLI from that same source (router-core.ts) — never
18
+ * the proxy.
19
+ * The manifest is public and carries no prompt data.
20
+ *
21
+ * Latency (review items 1 + N2): the manifest is prefetched at startup
22
+ * (capped, alongside the model-config fetch). Only the session's FIRST prompt
23
+ * waits for the read, at most `waitMs`; every later prompt uses the verified
24
+ * entry if the read has landed, else the bundled copy at once — even while a
25
+ * slow read is still in flight. After a failed read, a refresh runs in the
26
+ * background once the cool-down has passed; no prompt waits on it. Ctrl+C
27
+ * ends a wait at once.
28
+ */
29
+ import { ManifestClient, DEFAULT_MANIFEST_URL } from '../skills/manifest-client.js';
30
+ import { ROUTER_PROMPT, asRouterPrompt } from './router-core.js';
31
+ /** After a failed manifest read, refresh in the background no sooner than this. */
32
+ const RETRY_AFTER_MS = 5 * 60 * 1000;
33
+ /** Longest a prompt waits for the session's first manifest read. */
34
+ export const PROMPT_WAIT_MS = 1500;
35
+ let cached = null;
36
+ let failedAt = null;
37
+ let inflight = null;
38
+ /** True once a prompt has waited on the manifest this session (review N2). */
39
+ let promptWaited = false;
40
+ /** Test-only reset of the session cache. */
41
+ export function __resetRouterPromptForTests() {
42
+ cached = null;
43
+ failedAt = null;
44
+ inflight = null;
45
+ promptWaited = false;
46
+ }
47
+ const bundled = () => ({ prompt: ROUTER_PROMPT, source: 'bundled' });
48
+ function cancelled() {
49
+ const e = new Error('routing cancelled');
50
+ e.name = 'AbortError';
51
+ return e;
52
+ }
53
+ /** Start (or join) the one manifest read. Resolves, never rejects. */
54
+ function startFetch(manifestUrl, now) {
55
+ if (!inflight) {
56
+ inflight = (async () => {
57
+ try {
58
+ const m = await new ManifestClient(manifestUrl).fetchAndVerifyManifest();
59
+ const entry = asRouterPrompt(m.router);
60
+ // Same-or-newer than the bundled copy, else the bundled copy (a
61
+ // definite answer for this manifest: cached, no refetch).
62
+ cached = entry && entry.revision >= ROUTER_PROMPT.revision ? { prompt: entry, source: 'manifest' } : bundled();
63
+ failedAt = null;
64
+ return cached;
65
+ }
66
+ catch {
67
+ // Unreachable, not yet published, or a signature failure (tampered).
68
+ failedAt = now();
69
+ return bundled();
70
+ }
71
+ finally {
72
+ inflight = null;
73
+ }
74
+ })();
75
+ }
76
+ return inflight;
77
+ }
78
+ /** Wait for `p` at most `ms`, or until `signal` aborts (rejects AbortError). */
79
+ function waitFor(p, ms, signal) {
80
+ return new Promise((resolve, reject) => {
81
+ const timer = setTimeout(() => { cleanup(); resolve(bundled()); }, Math.max(0, ms));
82
+ timer.unref?.();
83
+ const onAbort = () => { cleanup(); reject(cancelled()); };
84
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); };
85
+ signal?.addEventListener('abort', onAbort, { once: true });
86
+ p.then((v) => { cleanup(); resolve(v); }, () => { cleanup(); resolve(bundled()); });
87
+ });
88
+ }
89
+ /**
90
+ * Startup prefetch (byok / ai-hub). Waits at most `timeoutMs`; the read keeps
91
+ * going in the background if slower. Never throws.
92
+ */
93
+ export async function prefetchRouterPrompt(opts) {
94
+ if (cached || failedAt !== null)
95
+ return;
96
+ await waitFor(startFetch(opts.manifestUrl ?? DEFAULT_MANIFEST_URL, Date.now), opts.timeoutMs);
97
+ }
98
+ /**
99
+ * The router instructions for one classification. Never throws, except an
100
+ * AbortError when `signal` aborts while waiting.
101
+ */
102
+ export async function loadRouterPrompt(opts = {}) {
103
+ if (cached)
104
+ return cached;
105
+ if (opts.signal?.aborted)
106
+ throw cancelled();
107
+ const now = opts.now ?? Date.now;
108
+ const url = opts.manifestUrl ?? DEFAULT_MANIFEST_URL;
109
+ if (failedAt !== null) {
110
+ // A read already failed this session: never make a prompt wait again.
111
+ if (now() - failedAt >= RETRY_AFTER_MS)
112
+ void startFetch(url, now);
113
+ return bundled();
114
+ }
115
+ const read = startFetch(url, now);
116
+ // Only the first prompt of the session waits; later ones never do.
117
+ if (promptWaited)
118
+ return bundled();
119
+ promptWaited = true;
120
+ return waitFor(read, opts.waitMs ?? PROMPT_WAIT_MS, opts.signal);
121
+ }