@cspeach/cli 1.1.25 → 1.1.27

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 (55) hide show
  1. package/dist/agent/loop.js +28 -9
  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/commands/whoami.js +1 -1
  15. package/dist/cost/cost-log.js +76 -4
  16. package/dist/cost/credits-wire.js +9 -0
  17. package/dist/cost/credits.js +24 -6
  18. package/dist/cost/router-spend.js +24 -0
  19. package/dist/cost/session-cost.js +16 -2
  20. package/dist/doctor/checks/skill.js +60 -11
  21. package/dist/doctor/run.js +0 -2
  22. package/dist/lane/app-chain.js +28 -0
  23. package/dist/models/server-config.js +21 -1
  24. package/dist/one-shot.js +12 -2
  25. package/dist/projects/extract-design.js +4 -3
  26. package/dist/projects/extract-estimate.js +4 -3
  27. package/dist/projects/extract-spec-gap.js +28 -5
  28. package/dist/projects/plan-lane.js +47 -0
  29. package/dist/projects/plan-run.js +8 -0
  30. package/dist/projects/plan-schema.js +2 -0
  31. package/dist/projects/save-command.js +7 -3
  32. package/dist/projects/title-heading.js +36 -0
  33. package/dist/renderer/footer-line.js +10 -4
  34. package/dist/renderer/status-footer.js +5 -4
  35. package/dist/renderer/thinking-heartbeat.js +2 -1
  36. package/dist/renderer/tool-labels.js +2 -1
  37. package/dist/repl.js +41 -8
  38. package/dist/router/byok-classifier.js +159 -0
  39. package/dist/router/classifier.js +130 -6
  40. package/dist/router/router-core.js +307 -0
  41. package/dist/router/router-prompt.js +121 -0
  42. package/dist/router/routing-failure.js +65 -3
  43. package/dist/sap/unreachable.js +4 -1
  44. package/dist/skills/bundled-skills.js +45 -45
  45. package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
  46. package/dist/tools/fiori/deploy/run.js +608 -0
  47. package/dist/tools/fiori/deploy/scope.js +8 -0
  48. package/dist/tools/fiori/deploy/yaml-read.js +236 -0
  49. package/dist/tools/fiori/deploy-config.js +24 -6
  50. package/dist/tools/fiori/tools.js +117 -5
  51. package/dist/tools/local-build.js +3 -0
  52. package/dist/tools/subagent/read_agent.js +4 -2
  53. package/dist/tools/tool-loading.js +1 -1
  54. package/package.json +121 -121
  55. package/dist/doctor/checks/zcspeach.js +0 -96
@@ -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
+ }
@@ -1,17 +1,69 @@
1
+ /**
2
+ * Skill-routing failure: what happened, in true words (live fix 2026-09-26).
3
+ *
4
+ * Fix 9: the REPL used to print "Skill routing is offline · CSPeach service
5
+ * unreachable: This operation was aborted" when the classify call merely
6
+ * timed out (the proxy was healthy). The wording now comes from the
7
+ * classifier's machine-readable kind — never "offline"/"unreachable" for a
8
+ * timeout. Used by the Ink REPL, the classic REPL and one-shot.
9
+ */
10
+ import { localRulesResult } from './classifier.js';
1
11
  /** The failure behind an offline (fallback) classify result, or null when routing worked. */
2
12
  export function routingFailureFromResult(c) {
3
13
  if (!c.offline)
4
14
  return null;
5
- return c.transport_kind === 'timeout' ? { kind: 'timeout' } : { kind: 'network' };
15
+ if (c.transport_kind === 'timeout')
16
+ return { kind: 'timeout' };
17
+ if (c.transport_kind === 'http' && typeof c.http_status === 'number')
18
+ return { kind: 'http', status: c.http_status };
19
+ if (c.transport_kind === 'failed')
20
+ return { kind: 'failed' };
21
+ return { kind: 'network' };
6
22
  }
7
- /** The failure behind a thrown classify error (HTTP), or null for any other error. */
23
+ /** The failure behind a thrown classify error (HTTP / unreadable body), or null for any other error. */
8
24
  export function routingFailureFromError(err) {
9
25
  // Duck-typed (not instanceof) so a re-loaded module instance still matches.
10
26
  const e = err;
11
- if (e && e.transport_kind === 'http' && typeof e.status === 'number')
27
+ if (e && e.transport_kind === 'http' && typeof e.status === 'number') {
12
28
  return { kind: 'http', status: e.status };
29
+ }
30
+ if (e && e.transport_kind === 'failed')
31
+ return { kind: 'failed' };
13
32
  return null;
14
33
  }
34
+ /**
35
+ * Live fix 2026-10-02 — a classify failure (the proxy answered HTTP 500 for a
36
+ * prompt its router answered in prose; an unreadable body; anything else
37
+ * classifyPrompt throws) no longer ends the turn or escapes one-shot as
38
+ * "unhandled error". Every routing path (Ink REPL, classic REPL, one-shot)
39
+ * takes this result instead: the bundled local-rules pick at low confidence,
40
+ * marked offline with the failure kind, so the existing offline handling
41
+ * runs — the low-confidence path (picker / one-shot top-1), one notice, and
42
+ * fix 10's "stay in the active skill" mid-skill.
43
+ *
44
+ * Returns null (caller keeps its own handling) for a missing API key, a user
45
+ * cancel (AbortError), and the slash-guard CLI bug, which must stay loud.
46
+ */
47
+ export function routingFallbackFromError(err, prompt) {
48
+ const e = err;
49
+ if (e?.message === 'no_api_key')
50
+ return null;
51
+ if (e?.name === 'AbortError')
52
+ return null;
53
+ if (typeof e?.message === 'string' && e.message.startsWith('slash command reached the classifier'))
54
+ return null;
55
+ const f = routingFailureFromError(err) ?? { kind: 'failed' };
56
+ return {
57
+ ...localRulesResult(prompt),
58
+ offline: true,
59
+ transport_kind: f.kind === 'http' ? 'http' : 'failed',
60
+ ...(f.kind === 'http' ? { http_status: f.status } : {}),
61
+ };
62
+ }
63
+ /** "Couldn't pick a skill automatically (HTTP 500)" — the 2026-10-02 fallback headline. */
64
+ function couldNotPick(f) {
65
+ return `Couldn't pick a skill automatically${f.kind === 'http' ? ` (HTTP ${f.status})` : ''}`;
66
+ }
15
67
  /** Headline for the notice: "Skill routing timed out" etc. */
16
68
  export function routingFailureText(f) {
17
69
  switch (f.kind) {
@@ -20,15 +72,24 @@ export function routingFailureText(f) {
20
72
  case 'timeout': return 'Skill routing timed out';
21
73
  case 'network': return "Skill routing couldn't reach the CSPeach service";
22
74
  case 'http': return `Skill routing failed (HTTP ${f.status})`;
75
+ case 'failed': return 'Skill routing failed';
23
76
  }
24
77
  }
25
78
  /** The attention notice: headline, plus the real cause for network failures. */
26
79
  export function routingFailureNotice(f, transportError) {
80
+ // 2026-10-02: an HTTP / other classify failure now continues on local
81
+ // rules (routingFallbackFromError) — say so, and how to choose.
82
+ if (f.kind === 'http' || f.kind === 'failed') {
83
+ return { text: couldNotPick(f), fact: 'routed by local rules — start with /skill to choose' };
84
+ }
27
85
  const text = routingFailureText(f);
28
86
  return f.kind === 'network' && transportError ? { text, fact: transportError } : { text };
29
87
  }
30
88
  /** One-shot's dim stderr line (the turn continues on the bundled local rules). */
31
89
  export function routingFailureOneShotLine(f, transportError) {
90
+ if (f.kind === 'http' || f.kind === 'failed') {
91
+ return `[${couldNotPick(f)} — routed by local rules. Start with /skill to choose.]`;
92
+ }
32
93
  const n = routingFailureNotice(f, transportError);
33
94
  return `[${n.text}${n.fact ? `: ${n.fact}` : ''} — routed by local rules]`;
34
95
  }
@@ -38,6 +99,7 @@ function routingFailureShort(f) {
38
99
  case 'timeout': return 'Routing timed out';
39
100
  case 'network': return "Routing couldn't reach the CSPeach service";
40
101
  case 'http': return `Routing failed (HTTP ${f.status})`;
102
+ case 'failed': return 'Routing failed';
41
103
  }
42
104
  }
43
105
  /**
@@ -23,6 +23,9 @@ export function unreachableCode(err) {
23
23
  return undefined;
24
24
  }
25
25
  export function unreachableMessage(alias, host, port, code) {
26
- return (`Can't reach SAP system ${alias} (${host}:${port}) — ${code}. Check the VPN / network, ` +
26
+ return (
27
+ // 2026-10-04: never assume a VPN — many users sit on the company network.
28
+ `Can't reach SAP system ${alias} (${host}:${port}) from this machine — ${code}. ` +
29
+ 'Check network access to the SAP system (company network or VPN), ' +
27
30
  'or run with --no-sap for work that does not need SAP.');
28
31
  }