@cspeach/cli 1.1.17 → 1.1.19
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/README.md +3 -4
- package/dist/agent/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +80 -22
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +15 -3
- package/dist/cost/pricing.js +6 -5
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +96 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +152 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +238 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +42 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +36 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +795 -332
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +69 -27
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/skills/signing-public-key.js +1 -1
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +28 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +11 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
package/dist/rewind/cli.js
CHANGED
|
@@ -21,6 +21,7 @@ import { snapshots } from '@cspeach/sap-client';
|
|
|
21
21
|
import { collectRewindCandidates } from './candidates.js';
|
|
22
22
|
import { restoreSnapshot } from './restore.js';
|
|
23
23
|
import { hhmm, rewindRowLabel, packageUnknown } from './format.js';
|
|
24
|
+
import { formatInfo } from '../renderer/notice-shape.js';
|
|
24
25
|
// Pure singleton emitter (no Ink) — safe in the classic path. In classic mode
|
|
25
26
|
// nothing consults isBusy() during an awaited builtin, so the reserve is a
|
|
26
27
|
// no-op there; in Ink it is the whole point (see executeRestore).
|
|
@@ -62,7 +63,7 @@ export async function buildRestoreDeps(c, w) {
|
|
|
62
63
|
* awaits, but ANY residual throw (deps assembly, a future refactor) lands
|
|
63
64
|
* in the catch below and prints the partial receipt context + error instead
|
|
64
65
|
* of escaping to the process-level uncaughtException exit.
|
|
65
|
-
* - M4: a declined approval prints a calm '
|
|
66
|
+
* - M4: a declined approval prints a calm 'Rewind cancelled' line — the
|
|
66
67
|
* user said no; nothing failed.
|
|
67
68
|
*/
|
|
68
69
|
export async function executeRestore(c, w, print) {
|
|
@@ -74,7 +75,7 @@ export async function executeRestore(c, w, print) {
|
|
|
74
75
|
print(chalk.green(`✓ restored ${c.objType}/${c.object}`));
|
|
75
76
|
}
|
|
76
77
|
else if (r.error === 'declined') {
|
|
77
|
-
print(
|
|
78
|
+
print(formatInfo('Rewind cancelled · approval declined').trimEnd());
|
|
78
79
|
}
|
|
79
80
|
else {
|
|
80
81
|
print(chalk.red(`✗ rewind failed — ${r.error ?? 'unknown'}`));
|
package/dist/rewind/format.js
CHANGED
|
@@ -3,22 +3,27 @@
|
|
|
3
3
|
* inquirer list (rewind/cli.ts). Kept ink-free so importing it from the
|
|
4
4
|
* classic path does not drag React/Ink into a non-TTY process.
|
|
5
5
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import { toolLabel } from '../renderer/tool-labels.js';
|
|
7
|
+
import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
8
|
+
/** Local HH:MM from an ISO timestamp (piece 2 §10: the user's clock, not UTC). */
|
|
8
9
|
export function hhmm(iso) {
|
|
9
10
|
if (!iso)
|
|
10
11
|
return '??:??';
|
|
11
|
-
const
|
|
12
|
-
|
|
12
|
+
const d = new Date(iso);
|
|
13
|
+
if (Number.isNaN(d.getTime()))
|
|
14
|
+
return '??:??';
|
|
15
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
13
16
|
}
|
|
14
|
-
/** Row label without glyph / selection styling
|
|
15
|
-
*
|
|
17
|
+
/** Row label without glyph / selection styling: the piece 1 tool verb, the
|
|
18
|
+
* object, and the write time; restorable rows add the snapshot time,
|
|
19
|
+
* non-restorable rows the reason. Object, type and reason are sanitised so
|
|
20
|
+
* a control character can never reach the terminal. */
|
|
16
21
|
export function rewindRowLabel(c) {
|
|
17
|
-
const
|
|
18
|
-
const base = `${c.object} (${c.objType}) · ${
|
|
22
|
+
const verb = toolLabel(c.writeTool, {}).verb;
|
|
23
|
+
const base = `${verb} ${sanitizeControlChars(c.object)} (${sanitizeControlChars(c.objType)}) · ${hhmm(c.writtenAt)}`;
|
|
19
24
|
if (c.restorable)
|
|
20
25
|
return `${base} · snapshot ${hhmm(c.snapshotAt)}`;
|
|
21
|
-
return `${base} — ${c.reason ?? 'no restore point'}`;
|
|
26
|
+
return `${base} — ${sanitizeControlChars(c.reason ?? 'no restore point')}`;
|
|
22
27
|
}
|
|
23
28
|
/** True when a restorable delete has no recreate package to fall back on —
|
|
24
29
|
* the restore may fail on the real system's empty-package rejection. */
|
package/dist/rewind/restore.js
CHANGED
|
@@ -212,6 +212,17 @@ export async function restoreSnapshot(c, deps) {
|
|
|
212
212
|
+ `${c.objType}/${c.object} is NOT activated; verify it manually.`,
|
|
213
213
|
};
|
|
214
214
|
}
|
|
215
|
+
// Review r7 I-4 — verifySyntax now reports a check that did not run as
|
|
216
|
+
// ok:false + didNotRun instead of throwing: same honest wording as before.
|
|
217
|
+
if (syntax.didNotRun !== undefined) {
|
|
218
|
+
record(`③ syntax — check failed to run: ${shortError(new Error(syntax.didNotRun))}`);
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
steps,
|
|
222
|
+
error: `snapshot source was written, but the syntax check failed to run: ${shortError(new Error(syntax.didNotRun))} — `
|
|
223
|
+
+ `${c.objType}/${c.object} is NOT activated; verify it manually.`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
215
226
|
if (!syntax.ok) {
|
|
216
227
|
record('③ syntax ✗');
|
|
217
228
|
return {
|
|
@@ -5,10 +5,20 @@ import { SKILL_CATALOG, SKILL_ALIASES, resolveSkillAlias } from '../skill-catalo
|
|
|
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
|
+
/** A non-2xx answer from the classify endpoint (fix 9: carries the kind). */
|
|
9
|
+
export class ClassifyHttpError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
transport_kind = 'http';
|
|
12
|
+
constructor(status) {
|
|
13
|
+
super(`classifier HTTP ${status}`);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.name = 'ClassifyHttpError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
8
18
|
// 8s — sized to cover the observed Fly cold-start (~3.2s on the 2026-05-20
|
|
9
19
|
// probe) plus headroom for a slow network. Tighter values risk timing out
|
|
10
20
|
// legitimate cold-start hits and burning the retry budget on the same.
|
|
11
|
-
const FETCH_TIMEOUT_MS = 8000;
|
|
21
|
+
export const FETCH_TIMEOUT_MS = 8000;
|
|
12
22
|
// 250ms — long enough for a half-open TCP/keep-alive socket to be fully
|
|
13
23
|
// torn down before we try again, short enough that the user doesn't feel
|
|
14
24
|
// the retry as a hang.
|
|
@@ -27,9 +37,27 @@ function extractCause(err) {
|
|
|
27
37
|
}
|
|
28
38
|
return e.message;
|
|
29
39
|
}
|
|
30
|
-
|
|
40
|
+
/** Our own abort timer fired (or undici gave up waiting) → a timeout, not an outage. */
|
|
41
|
+
function transportKindOf(err) {
|
|
42
|
+
if (!err)
|
|
43
|
+
return 'network';
|
|
44
|
+
const code = err.cause?.code ?? '';
|
|
45
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError')
|
|
46
|
+
return 'timeout';
|
|
47
|
+
if (/HEADERS_TIMEOUT|BODY_TIMEOUT/.test(code))
|
|
48
|
+
return 'timeout';
|
|
49
|
+
return 'network';
|
|
50
|
+
}
|
|
51
|
+
async function tryFetchOnce(proxyUrl, key, prompt, context, images = [], signal) {
|
|
52
|
+
// Review I2 — a cancel that landed before this call (e.g. while prompt
|
|
53
|
+
// images were read) never fires the 'abort' event: honour it here.
|
|
54
|
+
if (signal?.aborted)
|
|
55
|
+
throw routingCancelled();
|
|
31
56
|
const ctrl = new AbortController();
|
|
32
57
|
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
58
|
+
// Round 4 item 5 — the caller's cancel (Ctrl+C) ends the request at once.
|
|
59
|
+
const onAbort = () => ctrl.abort();
|
|
60
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
33
61
|
try {
|
|
34
62
|
return await fetch(`${proxyUrl}/v1/classify`, {
|
|
35
63
|
method: 'POST',
|
|
@@ -43,8 +71,15 @@ async function tryFetchOnce(proxyUrl, key, prompt, context, images = []) {
|
|
|
43
71
|
}
|
|
44
72
|
finally {
|
|
45
73
|
clearTimeout(timer);
|
|
74
|
+
signal?.removeEventListener('abort', onAbort);
|
|
46
75
|
}
|
|
47
76
|
}
|
|
77
|
+
/** Round 4 item 5 — thrown when the caller cancelled routing (Ctrl+C). */
|
|
78
|
+
function routingCancelled() {
|
|
79
|
+
const e = new Error('routing cancelled');
|
|
80
|
+
e.name = 'AbortError';
|
|
81
|
+
return e;
|
|
82
|
+
}
|
|
48
83
|
/**
|
|
49
84
|
* Screenshot-aware routing (2026-09-23): images in the prompt (pasted
|
|
50
85
|
* [Image #N] chips or image paths) go to the router so it reads the picture
|
|
@@ -80,7 +115,11 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
80
115
|
const key = await store.get();
|
|
81
116
|
if (!key)
|
|
82
117
|
throw new Error('no_api_key');
|
|
118
|
+
if (opts.signal?.aborted)
|
|
119
|
+
throw routingCancelled();
|
|
83
120
|
const images = await promptImages(prompt, opts.cwd ?? process.cwd());
|
|
121
|
+
if (opts.signal?.aborted)
|
|
122
|
+
throw routingCancelled();
|
|
84
123
|
let r;
|
|
85
124
|
let lastErr;
|
|
86
125
|
// Two attempts total: the second masks the common case of a stale
|
|
@@ -91,10 +130,13 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
91
130
|
// they bubble through as before.
|
|
92
131
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
93
132
|
try {
|
|
94
|
-
r = await tryFetchOnce(cfg.proxy_url, key, prompt, context, images);
|
|
133
|
+
r = await tryFetchOnce(cfg.proxy_url, key, prompt, context, images, opts.signal);
|
|
95
134
|
break;
|
|
96
135
|
}
|
|
97
136
|
catch (err) {
|
|
137
|
+
// A user cancel is not a transport failure: no retry, no local fallback.
|
|
138
|
+
if (opts.signal?.aborted)
|
|
139
|
+
throw routingCancelled();
|
|
98
140
|
lastErr = err;
|
|
99
141
|
if (attempt === 0)
|
|
100
142
|
await new Promise((res) => setTimeout(res, RETRY_DELAY_MS));
|
|
@@ -112,10 +154,11 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
112
154
|
alternates: [],
|
|
113
155
|
offline: true,
|
|
114
156
|
transport_error: lastErr ? extractCause(lastErr) : 'unknown',
|
|
157
|
+
transport_kind: transportKindOf(lastErr),
|
|
115
158
|
};
|
|
116
159
|
}
|
|
117
160
|
if (!r.ok)
|
|
118
|
-
throw new
|
|
161
|
+
throw new ClassifyHttpError(r.status);
|
|
119
162
|
const result = (await r.json());
|
|
120
163
|
// Portfolio-merge resilience (2026-06-12): the proxy classifier's skill
|
|
121
164
|
// list can lag a rename (it still knows 'abap-dump-analyze' until its
|
|
@@ -28,11 +28,23 @@
|
|
|
28
28
|
*/
|
|
29
29
|
const PACKAGE_RE = /(?:^|\s)(?:package|pkg|in\s+package|into\s+package|dev\s*class)\s+([ZY][A-Z0-9_]{1,29})(?:\b|\s|[.,;])/i;
|
|
30
30
|
/**
|
|
31
|
-
* Transport pattern: SAP transport IDs
|
|
32
|
-
* e.g. S4HK903342).
|
|
33
|
-
*
|
|
31
|
+
* Transport pattern: SAP transport IDs are SID (3 chars) + K + 6 digits
|
|
32
|
+
* (e.g. S4HK903342, DEVK900123). Round 7 item 7 — only the FULL number
|
|
33
|
+
* counts: a shorter/longer digit run, or one glued to a SQL wildcard
|
|
34
|
+
* (`S4HK90373%`), is a pattern, not a transport.
|
|
34
35
|
*/
|
|
35
|
-
const TRANSPORT_RE =
|
|
36
|
+
const TRANSPORT_RE = /(?<![A-Za-z0-9_%*])([A-Z][A-Z0-9]{2}K[0-9]{6})(?![A-Za-z0-9_%*])/;
|
|
37
|
+
/** A SQL statement somewhere in the prompt (round 7 item 7): its literals are never facts. */
|
|
38
|
+
const SQL_PRESENT_RE = /\bSELECT\b[\s\S]*\bFROM\b/i;
|
|
39
|
+
/**
|
|
40
|
+
* Review r7 M-2 — the prompt IS a read-only query (it starts with SELECT,
|
|
41
|
+
* optionally after "run/execute … :" or a code fence). Only then is create
|
|
42
|
+
* intent suppressed; "create a report that does SELECT … FROM …, transport
|
|
43
|
+
* S4HK…" keeps it.
|
|
44
|
+
*/
|
|
45
|
+
const QUERY_PROMPT_RE = /^\s*(?:(?:please\s+)?(?:run|execute|exec)\b[^\n:]{0,40}:?\s*)?(?:`{1,3}\s*(?:sql)?\s*)?SELECT\b[\s\S]*\bFROM\b/i;
|
|
46
|
+
/** Single-quoted string literals — SQL values / LIKE patterns, never facts. */
|
|
47
|
+
const SQL_LITERAL_RE = /'[^'\n]*'/g;
|
|
36
48
|
/**
|
|
37
49
|
* Object name hints — keyword followed by a Z-prefixed or Y-prefixed name.
|
|
38
50
|
* Triggers: program, report, class, interface, table, structure, function
|
|
@@ -53,12 +65,16 @@ export function extractIntent(userPrompt) {
|
|
|
53
65
|
const pkg = PACKAGE_RE.exec(userPrompt);
|
|
54
66
|
if (pkg)
|
|
55
67
|
result.package = pkg[1].toUpperCase();
|
|
56
|
-
// Transport
|
|
57
|
-
|
|
68
|
+
// Transport — round 7 item 7: never from inside a SQL string literal
|
|
69
|
+
// (`WHERE trkorr LIKE 'S4HK90373%'` / `= 'S4HK903731'`).
|
|
70
|
+
const readOnlyQuery = QUERY_PROMPT_RE.test(userPrompt);
|
|
71
|
+
const transportText = SQL_PRESENT_RE.test(userPrompt) ? userPrompt.replace(SQL_LITERAL_RE, "''") : userPrompt;
|
|
72
|
+
const trp = TRANSPORT_RE.exec(transportText);
|
|
58
73
|
if (trp)
|
|
59
74
|
result.transport = trp[1].toUpperCase();
|
|
60
|
-
// Having a transport strongly implies the user wants SAP creation
|
|
61
|
-
|
|
75
|
+
// Having a transport strongly implies the user wants SAP creation — but a
|
|
76
|
+
// read-only query never carries create intent.
|
|
77
|
+
if (result.transport && !readOnlyQuery)
|
|
62
78
|
result.createIntent = true;
|
|
63
79
|
// Object names — only when explicitly keyword-prefixed, to avoid
|
|
64
80
|
// accidental matches on random Z* tokens in prose.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round 7 item 8a — `echo "prompt" | cspeach` (piped stdin, no --one-shot).
|
|
3
|
+
*
|
|
4
|
+
* Piped stdin has no keyboard, so the skill picker cannot be answered: the
|
|
5
|
+
* classic REPL opened it anyway and the run ended in "Could not pick a skill
|
|
6
|
+
* · readline was closed". With piped input the router's top pick is used
|
|
7
|
+
* directly — the same rule as one-shot — with the one-shot's low-confidence
|
|
8
|
+
* notice naming the candidates.
|
|
9
|
+
*/
|
|
10
|
+
import { formatAttention } from '../renderer/notice-shape.js';
|
|
11
|
+
import { formatRoutingLine } from '../renderer/routing-line.js';
|
|
12
|
+
export function pipedRoutingPick(c) {
|
|
13
|
+
const lines = [formatRoutingLine({ kind: 'using', skill: c.skill }).trimEnd()];
|
|
14
|
+
if (c.confidence < 0.6) {
|
|
15
|
+
const alternates = [c.skill, ...c.alternates].slice(0, 3).map((s) => `/${s}`).join(', ');
|
|
16
|
+
lines.push(formatAttention({
|
|
17
|
+
text: 'Low confidence',
|
|
18
|
+
fact: `candidates: ${alternates}`,
|
|
19
|
+
next: 'start the line with a skill, e.g. /abap-explain, to choose',
|
|
20
|
+
}).trimEnd());
|
|
21
|
+
}
|
|
22
|
+
return { skill: c.skill, lines };
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round 6 review I2 — the first prompt after a resume. The classifier always
|
|
3
|
+
* runs; the resumed session's skill is kept only where routing would
|
|
4
|
+
* otherwise open "Pick a skill" (coach / picker) or when the classifier's top
|
|
5
|
+
* pick IS that skill. A confident pick of another skill wins.
|
|
6
|
+
*/
|
|
7
|
+
export function applyResumedSkill(decision, resumedSkill) {
|
|
8
|
+
if (decision.kind === 'silent')
|
|
9
|
+
return decision;
|
|
10
|
+
return { kind: 'silent', skill: resumedSkill };
|
|
11
|
+
}
|
|
12
|
+
/** A short follow-up that continues the resumed skill without routing: "continue", "go on", a number. */
|
|
13
|
+
export function isShortFollowUp(text) {
|
|
14
|
+
const t = text.toLowerCase().trim().replace(/[.!?,]+$/, '');
|
|
15
|
+
return /^\d{1,2}$/.test(t) || /^(continue|go on|carry on|keep going|next)$/.test(t);
|
|
16
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** The failure behind an offline (fallback) classify result, or null when routing worked. */
|
|
2
|
+
export function routingFailureFromResult(c) {
|
|
3
|
+
if (!c.offline)
|
|
4
|
+
return null;
|
|
5
|
+
return c.transport_kind === 'timeout' ? { kind: 'timeout' } : { kind: 'network' };
|
|
6
|
+
}
|
|
7
|
+
/** The failure behind a thrown classify error (HTTP), or null for any other error. */
|
|
8
|
+
export function routingFailureFromError(err) {
|
|
9
|
+
// Duck-typed (not instanceof) so a re-loaded module instance still matches.
|
|
10
|
+
const e = err;
|
|
11
|
+
if (e && e.transport_kind === 'http' && typeof e.status === 'number')
|
|
12
|
+
return { kind: 'http', status: e.status };
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
/** Headline for the notice: "Skill routing timed out" etc. */
|
|
16
|
+
export function routingFailureText(f) {
|
|
17
|
+
switch (f.kind) {
|
|
18
|
+
// Review M4: no seconds figure — classifyPrompt retries once (two
|
|
19
|
+
// FETCH_TIMEOUT_MS attempts + a short delay), so "(8 s)" under-stated the wait.
|
|
20
|
+
case 'timeout': return 'Skill routing timed out';
|
|
21
|
+
case 'network': return "Skill routing couldn't reach the CSPeach service";
|
|
22
|
+
case 'http': return `Skill routing failed (HTTP ${f.status})`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** The attention notice: headline, plus the real cause for network failures. */
|
|
26
|
+
export function routingFailureNotice(f, transportError) {
|
|
27
|
+
const text = routingFailureText(f);
|
|
28
|
+
return f.kind === 'network' && transportError ? { text, fact: transportError } : { text };
|
|
29
|
+
}
|
|
30
|
+
/** One-shot's dim stderr line (the turn continues on the bundled local rules). */
|
|
31
|
+
export function routingFailureOneShotLine(f, transportError) {
|
|
32
|
+
const n = routingFailureNotice(f, transportError);
|
|
33
|
+
return `[${n.text}${n.fact ? `: ${n.fact}` : ''} — routed by local rules]`;
|
|
34
|
+
}
|
|
35
|
+
/** Short form for the stay line: "Routing timed out" etc. */
|
|
36
|
+
function routingFailureShort(f) {
|
|
37
|
+
switch (f.kind) {
|
|
38
|
+
case 'timeout': return 'Routing timed out';
|
|
39
|
+
case 'network': return "Routing couldn't reach the CSPeach service";
|
|
40
|
+
case 'http': return `Routing failed (HTTP ${f.status})`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Fix 10 (2026-09-26) — the ONE decision both REPL paths (Ink + classic) use
|
|
45
|
+
* when routing fails. A follow-up sent mid-skill (e.g. turn 4 of an
|
|
46
|
+
* /abap-rap session) must not open the "which skill fits?" picker just
|
|
47
|
+
* because the classify call failed: with a skill active, the turn continues
|
|
48
|
+
* in it and the REPL prints one dim line (`· Routing timed out · staying in
|
|
49
|
+
* /abap-rap`). With no active skill, today's behaviour stands (notice +
|
|
50
|
+
* picker, or the failure message for HTTP). A successful classify (no
|
|
51
|
+
* failure) never reaches this — routing is unchanged then.
|
|
52
|
+
*/
|
|
53
|
+
export function routingFailureOutcome(failure, activeSkill) {
|
|
54
|
+
if (!failure || !activeSkill)
|
|
55
|
+
return { kind: 'fallback' };
|
|
56
|
+
return { kind: 'stay', skill: activeSkill, line: `${routingFailureShort(failure)} · staying in /${activeSkill}` };
|
|
57
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* trial user is on. Non-interactive runs (piped stdin, CI) never see the
|
|
10
10
|
* prompt and keep the exact pre-existing behaviour (the SAP wizard branch).
|
|
11
11
|
*/
|
|
12
|
-
import { select } from '
|
|
12
|
+
import { select } from '../repl/themed-prompts.js';
|
|
13
13
|
export const FIRST_RUN_CHOICES = [
|
|
14
14
|
{
|
|
15
15
|
name: 'Work standalone — no SAP connection',
|
package/dist/sap/onboarding.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { input, select, password, confirm } from '
|
|
1
|
+
import { input, select, password, confirm } from '../repl/themed-prompts.js';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
+
import { formatAttention } from '../renderer/notice-shape.js';
|
|
3
4
|
import keychain from '../auth/keychain.js';
|
|
4
5
|
import { loadConfig, saveConfig } from '../config/loader.js';
|
|
5
6
|
const SAP_SERVICE = 'cspeach-sap';
|
|
@@ -58,7 +59,7 @@ export async function runAddSapWizard() {
|
|
|
58
59
|
});
|
|
59
60
|
}
|
|
60
61
|
else if (tlsChoice === 'insecure') {
|
|
61
|
-
console.log(
|
|
62
|
+
console.log(formatAttention({ text: 'TLS verification is off for this system', fact: 'dev only, never in production' }).trimEnd());
|
|
62
63
|
insecureSkipTlsVerify = true;
|
|
63
64
|
}
|
|
64
65
|
}
|
|
@@ -16,7 +16,7 @@ import { loadConfig, saveConfig } from '../config/loader.js';
|
|
|
16
16
|
import { runStandardsInit } from '../standards/standards-init.js';
|
|
17
17
|
import { STANDALONE_PLATFORMS, STANDALONE_DATABASES, STANDALONE_DEPLOYMENTS, STANDALONE_IDES, deriveDatabase, deriveDeployment, defaultAbapCloudOnly, defaultRelease, releaseExamples, } from './standalone-profile.js';
|
|
18
18
|
async function defaultPrompts() {
|
|
19
|
-
const { input, select, confirm } = await import('
|
|
19
|
+
const { input, select, confirm } = await import('../repl/themed-prompts.js');
|
|
20
20
|
return {
|
|
21
21
|
select: (o) => select({ message: o.message, choices: o.choices, default: o.default }),
|
|
22
22
|
input: (o) => input({ message: o.message, default: o.default }),
|
package/dist/sap/system-info.js
CHANGED
|
@@ -179,11 +179,13 @@ export function parseCvers(xml) {
|
|
|
179
179
|
const nameMatch = /<(?:[\w:]+:)?metadata\b[^>]*?\b(?:[\w:]+:)?name="([^"]+)"/i.exec(block);
|
|
180
180
|
if (!nameMatch)
|
|
181
181
|
continue;
|
|
182
|
-
|
|
182
|
+
// A self-closing `<data/>` is an EMPTY cell and must keep its row
|
|
183
|
+
// position (round 4 item 2) — the old pattern swallowed the next cell.
|
|
184
|
+
const dataRe = /<(?:[\w:]+:)?data\b(?:\s[^>]*?)?(?:\/>|>([\s\S]*?)<\/(?:[\w:]+:)?data\s*>)/g;
|
|
183
185
|
const values = [];
|
|
184
186
|
let d;
|
|
185
187
|
while ((d = dataRe.exec(block)) !== null)
|
|
186
|
-
values.push(d[1].trim());
|
|
188
|
+
values.push((d[1] ?? '').trim());
|
|
187
189
|
cols.push({ name: nameMatch[1], values });
|
|
188
190
|
}
|
|
189
191
|
const componentCol = cols.find((c) => c.name.toUpperCase() === 'COMPONENT');
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// cspeach-cli/src/sap/unreachable.ts
|
|
2
|
+
//
|
|
3
|
+
// Dry run 2026-09-25: with the VPN off and a SAP system configured, startup
|
|
4
|
+
// died with "cspeach: unhandled error: connect ETIMEDOUT" — even for skills
|
|
5
|
+
// that never touch SAP. A network failure to the SAP host gets one clear line
|
|
6
|
+
// (REPL and one-shot alike); every other connection error still throws.
|
|
7
|
+
const UNREACHABLE_CODES = new Set([
|
|
8
|
+
'ETIMEDOUT',
|
|
9
|
+
'ECONNREFUSED',
|
|
10
|
+
'ENOTFOUND',
|
|
11
|
+
'EHOSTUNREACH',
|
|
12
|
+
'ENETUNREACH',
|
|
13
|
+
'ECONNRESET',
|
|
14
|
+
'EAI_AGAIN',
|
|
15
|
+
]);
|
|
16
|
+
/** The network error code when `err` (or fetch's wrapped `cause`) means "host not reachable". */
|
|
17
|
+
export function unreachableCode(err) {
|
|
18
|
+
let e = err;
|
|
19
|
+
for (let depth = 0; e && depth < 3; depth++, e = e.cause) {
|
|
20
|
+
if (typeof e.code === 'string' && UNREACHABLE_CODES.has(e.code))
|
|
21
|
+
return e.code;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
export function unreachableMessage(alias, host, port, code) {
|
|
26
|
+
return (`Can't reach SAP system ${alias} (${host}:${port}) — ${code}. Check the VPN / network, ` +
|
|
27
|
+
'or run with --no-sap for work that does not need SAP.');
|
|
28
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readable SAP error text for the screen (live fix 2026-09-26).
|
|
3
|
+
*
|
|
4
|
+
* SAP's ICM answers some failures with an HTML page ("Application Server
|
|
5
|
+
* Error" + a <style> block). sap-client strips the tags but keeps the style
|
|
6
|
+
* text, so a notice read
|
|
7
|
+
* `SAP HTTP 500: sqlQuery — Application Server Error body { background: #ffffff; …`
|
|
8
|
+
* cleanErrorText drops <style>/<script> blocks, tags, entities and CSS rule
|
|
9
|
+
* remnants, collapses whitespace and cuts to a sensible length. Plain text
|
|
10
|
+
* is only length-capped. describeError prefers the SapError's full body
|
|
11
|
+
* (`sapBody`) when its message is markup-ish, so the real reason (the page's
|
|
12
|
+
* heading/paragraph) survives instead of the 160-char CSS prefix.
|
|
13
|
+
*
|
|
14
|
+
* Review I1 (2026-09-27): ABAP text is full of `<…>` (field symbols, even
|
|
15
|
+
* lowercase ones named like HTML tags: `<p>`, `<tr>`), comparisons (`a < b`)
|
|
16
|
+
* and braces (string templates `|{ lv_x }|`, CDS `{ key : abap.char(10); }`).
|
|
17
|
+
* So detection needs evidence only real HTML/CSS carries, and stripping only
|
|
18
|
+
* touches known HTML tags and CSS-shaped rules — never a generic `<[^>]*>`.
|
|
19
|
+
*/
|
|
20
|
+
import { parseAdtException } from './parse-adt-exception.js';
|
|
21
|
+
const DEFAULT_MAX = 160;
|
|
22
|
+
const TAG_NAMES = 'html|head|body|title|style|script|div|span|h[1-6]|p|br|hr|table|tr|td|th|pre|meta|link|a|b|i|em|strong|center|font|ul|ol|li';
|
|
23
|
+
const VOID_TAGS = new Set(['br', 'hr', 'meta', 'link']);
|
|
24
|
+
const DOCTYPE = /<!doctype\b[^>]*>/i;
|
|
25
|
+
// A closing tag, or an opening tag carrying attributes / a self-close slash.
|
|
26
|
+
// A field symbol (`<p>`, `<LS_ROW>`) has neither.
|
|
27
|
+
const HTML_EVIDENCE = new RegExp(String.raw `<\/(?:${TAG_NAMES})\s*>|<(?:${TAG_NAMES})\s+[a-z-]+\s*=|<(?:${TAG_NAMES})\s*\/>`, 'i');
|
|
28
|
+
// Known CSS property names — a CDS/ABAP `{ key : …; }` is not CSS.
|
|
29
|
+
const CSS_PROP = String.raw `(?:background|color|font|text-[a-z]+|width|height|(?:min|max)-(?:width|height)|margin|padding|border|display|overflow|vertical-align|line-height|position|float|cursor|white-space|z-index|box-sizing|list-style|opacity|letter-spacing)(?:-[a-z]+)*`;
|
|
30
|
+
// One `prop: value` declaration inside a brace body. The property must start
|
|
31
|
+
// the body or follow whitespace / `;` — JSON (`{"a":1}`) does not match.
|
|
32
|
+
const CSS_DECL = new RegExp(String.raw `(?:^|[\s;])${CSS_PROP}\s*:\s*([^;{}]*)(;?)`, 'gi');
|
|
33
|
+
// Re-review R1: CSS property names are also common CDS/DDIC field names
|
|
34
|
+
// (position, color, width, border…), so a declaration counts only when its
|
|
35
|
+
// VALUE is CSS-shaped: a #hex colour, a length/percentage, 0, rgb(), a quoted
|
|
36
|
+
// font name or a CSS keyword. A CDS type (`abap.numc(4)`) or a data element
|
|
37
|
+
// (`zde_pos`) is none of these.
|
|
38
|
+
const CSS_VALUE = new RegExp(String.raw `#[0-9a-f]{3,8}\b|\b\d+(?:\.\d+)?(?:px|em|rem|pt|vh|vw|%)|(?:^|\s)0(?:\s|$)|\brgba?\(|["']|` +
|
|
39
|
+
String.raw `(?:^|[\s,])(?:none|solid|dashed|dotted|bold|bolder|normal|italic|auto|inherit|initial|hidden|visible|` +
|
|
40
|
+
String.raw `block|inline|inline-block|flex|grid|table|table-cell|left|right|center|middle|top|bottom|absolute|relative|` +
|
|
41
|
+
String.raw `fixed|static|sticky|pointer|default|nowrap|transparent|underline|uppercase|lowercase|collapse|` +
|
|
42
|
+
String.raw `white|black|red|gray|grey|silver|blue|green|sans-serif|serif|monospace|arial|helvetica|verdana|tahoma)(?=[\s,;!]|$)`, 'i');
|
|
43
|
+
// A CDS/DDIC body — typed with `abap.` or carrying a `key` field — is never CSS.
|
|
44
|
+
const CDS_BODY = /\babap\.|(?:^|[\s;{])key\s/i;
|
|
45
|
+
/**
|
|
46
|
+
* True when a brace body reads as CSS: not a CDS/DDIC body, and at least one
|
|
47
|
+
* known-property declaration with a CSS-shaped value. `needSemicolon` (used
|
|
48
|
+
* for detection) additionally requires that declaration to be `;`-terminated.
|
|
49
|
+
*/
|
|
50
|
+
function isCssBody(body, needSemicolon = false) {
|
|
51
|
+
if (CDS_BODY.test(body))
|
|
52
|
+
return false;
|
|
53
|
+
for (const m of body.matchAll(CSS_DECL)) {
|
|
54
|
+
if (needSemicolon && m[2] !== ';')
|
|
55
|
+
continue;
|
|
56
|
+
if (CSS_VALUE.test(m[1].trim()))
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
/** True when any `{ … }` body (or one cut off at the end) is a CSS rule. */
|
|
62
|
+
function hasCssRule(s) {
|
|
63
|
+
for (const m of s.matchAll(/\{([^{}]*)(?:\}|$)/g)) {
|
|
64
|
+
if (isCssBody(m[1], true))
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
// `selector { … }` — selector token(s) required before the brace.
|
|
70
|
+
const CSS_BLOCK = /[^\s{}]+(?:\s*,\s*[^\s{}]+)*\s*\{([^{}]*)\}/g;
|
|
71
|
+
// …and a rule cut off mid-way at the end of an already-truncated message.
|
|
72
|
+
const CSS_TAIL = /[^\s{}]+\s*\{([^{}]*)$/;
|
|
73
|
+
function stripCss(s) {
|
|
74
|
+
return s
|
|
75
|
+
.replace(CSS_BLOCK, (whole, body) => (isCssBody(body) ? ' ' : whole))
|
|
76
|
+
.replace(CSS_TAIL, (whole, body) => (isCssBody(body) ? ' ' : whole));
|
|
77
|
+
}
|
|
78
|
+
// SAP ADT XML error envelopes (review M3): `<?xml …><exc:exception …>`.
|
|
79
|
+
const ADT_XML = /<\?xml\b|<exc:exception\b/i;
|
|
80
|
+
// Fix round 3 (item 10) — a piece of an ADT exception envelope cut out of its
|
|
81
|
+
// `<?xml` head (an error excerpt truncated mid-body): `<entry key="T100KEY-NO">`,
|
|
82
|
+
// `<type id="…"/>`, `</properties>`. Evidence needs the envelope's own
|
|
83
|
+
// attribute (key= / id= / lang=) or a closing tag, so an ABAP field symbol
|
|
84
|
+
// such as `<entry>` never matches.
|
|
85
|
+
const ADT_FRAGMENT_TAGS = '(?:exc:)?(?:entry|properties|localizedMessage|message|namespace|type)';
|
|
86
|
+
const ADT_FRAGMENT_EVIDENCE = new RegExp(String.raw `<${ADT_FRAGMENT_TAGS}\s+(?:key|id|lang)\s*=|<\/${ADT_FRAGMENT_TAGS}\s*>`, 'i');
|
|
87
|
+
const ADT_FRAGMENT_TAG = new RegExp(String.raw `<\/?${ADT_FRAGMENT_TAGS}(?:\s+[a-z]+\s*=\s*"[^"]*")*\s*\/?>`, 'gi');
|
|
88
|
+
/** Drop ADT envelope fragments, then a dangling " — …" they leave behind. */
|
|
89
|
+
function stripAdtFragments(s) {
|
|
90
|
+
return s
|
|
91
|
+
.replace(ADT_FRAGMENT_TAG, ' ')
|
|
92
|
+
.replace(/\s+/g, ' ')
|
|
93
|
+
.replace(/\s*[—:-]?\s*…?\s*$/, '')
|
|
94
|
+
.trim();
|
|
95
|
+
}
|
|
96
|
+
/** True when the text carries HTML tags, CSS rule remnants or an ADT XML error body. */
|
|
97
|
+
export function looksLikeMarkup(s) {
|
|
98
|
+
return DOCTYPE.test(s) || HTML_EVIDENCE.test(s) || hasCssRule(s) || ADT_XML.test(s) || ADT_FRAGMENT_EVIDENCE.test(s);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Text before an ADT XML envelope + the envelope's message (review M3).
|
|
102
|
+
* Uses parseAdtException for a whole envelope; for one cut off by an earlier
|
|
103
|
+
* truncation it takes whatever text follows the opening <message> tag.
|
|
104
|
+
* Returns null when the text has no envelope.
|
|
105
|
+
*/
|
|
106
|
+
function cleanAdtXml(text) {
|
|
107
|
+
const m = ADT_XML.exec(text);
|
|
108
|
+
if (!m)
|
|
109
|
+
return null;
|
|
110
|
+
const prefix = text.slice(0, m.index).replace(/[\s:(]+$/, '');
|
|
111
|
+
const xml = text.slice(m.index);
|
|
112
|
+
const partial = xml.match(/<(?:[\w]+:)?message\b[^>]*>([^<]+)/i)?.[1];
|
|
113
|
+
const msg = parseAdtException(xml) ?? (partial ? decodeEntities(partial).replace(/\s+/g, ' ').trim() : '');
|
|
114
|
+
// No message: an ADT envelope cut off early keeps just the prefix; any other
|
|
115
|
+
// `<?xml` document (e.g. an XHTML error page) falls through to HTML cleaning.
|
|
116
|
+
if (!msg)
|
|
117
|
+
return /<exc:exception\b/i.test(xml) ? prefix : null;
|
|
118
|
+
return prefix ? `${prefix}: ${msg}` : msg;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Strip known HTML tags only: closing tags, tags with attributes, void tags,
|
|
122
|
+
* and bare opening tags whose closing tag is present. A bare `<p>` in a page
|
|
123
|
+
* that also has `</p>` is inherently ambiguous and goes (accepted, re-review R2). A lone `<p>` (an ABAP
|
|
124
|
+
* field symbol) stays; `<LS_ROW>`, `<fs>` and `a < b` are never touched.
|
|
125
|
+
*/
|
|
126
|
+
const TAG_ATTRS = /^(?:\s+[a-z-]+\s*=[\s\S]*|\s*\/)$/i;
|
|
127
|
+
function stripHtmlTags(s) {
|
|
128
|
+
const tag = new RegExp(String.raw `<(\/?)(${TAG_NAMES})\b([^<>]*)>`, 'gi');
|
|
129
|
+
return s.replace(tag, (whole, slash, name, rest) => {
|
|
130
|
+
const lower = name.toLowerCase();
|
|
131
|
+
// Re-review R2: any text after the name is not enough — `<b AND lv_c>` is
|
|
132
|
+
// an ABAP comparison. Only real attributes (`name=`) or a self-close `/`.
|
|
133
|
+
if (rest.trim() && !TAG_ATTRS.test(rest))
|
|
134
|
+
return whole;
|
|
135
|
+
if (slash || rest.trim() || VOID_TAGS.has(lower))
|
|
136
|
+
return ' ';
|
|
137
|
+
return new RegExp(String.raw `<\/${lower}\s*>`, 'i').test(s) ? ' ' : whole;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function decodeEntities(s) {
|
|
141
|
+
return s
|
|
142
|
+
.replace(/ /gi, ' ')
|
|
143
|
+
.replace(/</gi, '<')
|
|
144
|
+
.replace(/>/gi, '>')
|
|
145
|
+
.replace(/"/gi, '"')
|
|
146
|
+
.replace(/'|'/gi, "'")
|
|
147
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
|
148
|
+
.replace(/&/gi, '&');
|
|
149
|
+
}
|
|
150
|
+
function cut(s, max) {
|
|
151
|
+
return s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s;
|
|
152
|
+
}
|
|
153
|
+
export function cleanErrorText(text, max = DEFAULT_MAX) {
|
|
154
|
+
if (!looksLikeMarkup(text))
|
|
155
|
+
return cut(text, max);
|
|
156
|
+
const adt = cleanAdtXml(text);
|
|
157
|
+
if (adt !== null)
|
|
158
|
+
return cut(adt, max);
|
|
159
|
+
if (!DOCTYPE.test(text) && !HTML_EVIDENCE.test(text) && ADT_FRAGMENT_EVIDENCE.test(text)) {
|
|
160
|
+
return cut(decodeEntities(stripAdtFragments(text)), max);
|
|
161
|
+
}
|
|
162
|
+
const out = stripCss(decodeEntities(stripHtmlTags(text
|
|
163
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
164
|
+
.replace(/<(style|script)\b[^>]*>[\s\S]*?(?:<\/\1\s*>|$)/gi, ' ')
|
|
165
|
+
.replace(/<!doctype\b[^>]*>/gi, ' ')
|
|
166
|
+
.replace(/<\?xml\b[^>]*\?>/gi, ' '))))
|
|
167
|
+
.replace(/\s+/g, ' ')
|
|
168
|
+
.trim();
|
|
169
|
+
return cut(out, max);
|
|
170
|
+
}
|
|
171
|
+
/** One readable line for a thrown error (SapError-aware). */
|
|
172
|
+
export function describeError(err, max = 200) {
|
|
173
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
174
|
+
const body = err?.sapBody;
|
|
175
|
+
if (looksLikeMarkup(msg) && typeof body === 'string' && body.trim()) {
|
|
176
|
+
// Review M2: the head can carry markup too (no " — " in the message).
|
|
177
|
+
const head = cleanErrorText(msg.split(' — ')[0].trim(), max);
|
|
178
|
+
const text = cleanErrorText(body, DEFAULT_MAX);
|
|
179
|
+
return cut(text ? `${head} — ${text}` : head, max);
|
|
180
|
+
}
|
|
181
|
+
return cleanErrorText(msg, max);
|
|
182
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describeError } from '../agent/turn-error-ux.js';
|
|
2
|
+
/**
|
|
3
|
+
* Round 6 item 3 — the stored reason a session's last turn was interrupted,
|
|
4
|
+
* as one readable line. Sessions poisoned before the round-4 fix stored the
|
|
5
|
+
* raw API 400 (unsigned thinking, leftover partial_json, orphan tool_use);
|
|
6
|
+
* loadSession / the send path now repair those, so they are not shown as a
|
|
7
|
+
* failure. Anything else goes through the same cleaner as the error block.
|
|
8
|
+
*/
|
|
9
|
+
const HEALED_POISON = [
|
|
10
|
+
/Invalid `?signature`? in `?thinking`? block/i,
|
|
11
|
+
/partial_json[\s\S]*(?:not permitted|Extra inputs)/i,
|
|
12
|
+
/`?tool_use`? ids were found without `?tool_result`?/i,
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Round 7 item 8d — saved when the USER stopped the turn (Ctrl+C, /exit
|
|
16
|
+
* mid-turn). Older sessions stored the raw abort message instead
|
|
17
|
+
* ("Request was aborted."), which read like an error in the picker.
|
|
18
|
+
*/
|
|
19
|
+
export const USER_STOP_REASON = 'stopped_by_user';
|
|
20
|
+
const USER_ABORT_MESSAGES = /^(?:stopped_by_user|Request was aborted\.?|(?:This|The) operation was aborted\.?)$/i;
|
|
21
|
+
export function isUserStopReason(reason) {
|
|
22
|
+
return USER_ABORT_MESSAGES.test((reason ?? '').trim());
|
|
23
|
+
}
|
|
24
|
+
/** The resume picker's line for an interrupted session. */
|
|
25
|
+
export function interruptedPickerLine(reason) {
|
|
26
|
+
return isUserStopReason(reason)
|
|
27
|
+
? 'Stopped by you — type "continue" after resume'
|
|
28
|
+
: `Interrupted (${readableInterruptReason(reason)}) — type "continue" after resume`;
|
|
29
|
+
}
|
|
30
|
+
export function readableInterruptReason(reason) {
|
|
31
|
+
const r = (reason ?? '').trim();
|
|
32
|
+
if (!r)
|
|
33
|
+
return 'unknown';
|
|
34
|
+
if (isUserStopReason(r))
|
|
35
|
+
return 'Stopped by you';
|
|
36
|
+
if (HEALED_POISON.some((re) => re.test(r)))
|
|
37
|
+
return 'the turn was cut off · the session has been repaired';
|
|
38
|
+
return describeError(new Error(r));
|
|
39
|
+
}
|