@cspeach/cli 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bench/README.md +78 -0
- package/bench/prompts/abap-document-cds.md +44 -0
- package/bench/prompts/abap-explain-bdef-handler.md +57 -0
- package/bench/prompts/abap-test-method.md +42 -0
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +189 -0
- package/bench/results/abap-document-cds/claude-opus-4-7.md +120 -0
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +151 -0
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +112 -0
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +101 -0
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +101 -0
- package/bench/results/abap-test-method/claude-haiku-4-5.md +186 -0
- package/bench/results/abap-test-method/claude-opus-4-7.md +193 -0
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +234 -0
- package/dist/agent/loop.js +21 -8
- package/dist/config/loader.js +6 -4
- package/dist/one-shot.js +6 -0
- package/dist/renderer/question-normalizer.js +16 -6
- package/dist/repl.js +21 -4
- package/dist/router/classifier.js +74 -8
- package/dist/tools/subagent/agent_run.js +12 -0
- package/dist/ui/body.js +61 -2
- package/package.json +2 -1
|
@@ -74,26 +74,36 @@ function findBalancedJson(text, from) {
|
|
|
74
74
|
* Returns the offset where the block begins in `text`.
|
|
75
75
|
*/
|
|
76
76
|
function findBlockStart(text, jsonStart) {
|
|
77
|
-
let pos = jsonStart;
|
|
78
77
|
// Walk backward over preceding lines that are empty, the sentinel, or
|
|
79
78
|
// a fence-open marker. Stop on any line that has other content.
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
//
|
|
80
|
+
// 2026-06-06 — rewritten to guarantee forward progress. The previous
|
|
81
|
+
// version set `pos = lineStart` and looped; when the `{` sat at the
|
|
82
|
+
// start of a line (lineStart === pos — the CANONICAL widget shape),
|
|
83
|
+
// pos never moved and the loop spun forever, pegging a core. The scan
|
|
84
|
+
// position now steps over the preceding newline each iteration
|
|
85
|
+
// (strictly decreasing), and the accepted block start is tracked
|
|
86
|
+
// separately so the return value is still a line-start offset.
|
|
87
|
+
let blockStart = jsonStart;
|
|
88
|
+
let scanEnd = jsonStart; // exclusive end of the line-chunk under examination
|
|
89
|
+
while (scanEnd > 0) {
|
|
90
|
+
const prevNewline = text.lastIndexOf('\n', scanEnd - 1);
|
|
82
91
|
const lineStart = prevNewline === -1 ? 0 : prevNewline + 1;
|
|
83
|
-
const line = text.slice(lineStart,
|
|
92
|
+
const line = text.slice(lineStart, scanEnd).trim();
|
|
84
93
|
if (line === '' ||
|
|
85
94
|
line === SENTINEL ||
|
|
86
95
|
/^```(?:cspeach-widget:\S+)?$/.test(line) ||
|
|
87
96
|
/^````(?:cspeach-widget:\S+)?$/.test(line)) {
|
|
88
|
-
|
|
97
|
+
blockStart = lineStart;
|
|
89
98
|
if (prevNewline === -1)
|
|
90
99
|
break;
|
|
100
|
+
scanEnd = prevNewline; // step over the newline — strictly decreasing
|
|
91
101
|
}
|
|
92
102
|
else {
|
|
93
103
|
break;
|
|
94
104
|
}
|
|
95
105
|
}
|
|
96
|
-
return
|
|
106
|
+
return blockStart;
|
|
97
107
|
}
|
|
98
108
|
/**
|
|
99
109
|
* Walk forward from `jsonEnd` (one past the closing `}`) to include a
|
package/dist/repl.js
CHANGED
|
@@ -715,6 +715,12 @@ export async function runRepl(opts) {
|
|
|
715
715
|
last_skill: session.last_skill ?? null,
|
|
716
716
|
last_object: extractedObject ?? session.last_object ?? null,
|
|
717
717
|
});
|
|
718
|
+
if (c.offline) {
|
|
719
|
+
// Remote classify failed twice — local-rules fallback fired.
|
|
720
|
+
// Tell the user so they know the picker preselection came
|
|
721
|
+
// from regex, not the LLM, and surface the real cause.
|
|
722
|
+
chunkEmitter.emit('chunk', chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]\n`));
|
|
723
|
+
}
|
|
718
724
|
const decision = decideRouting(c);
|
|
719
725
|
// 2026-05-17 — pass the just-used skill so the picker can
|
|
720
726
|
// default cursor to it (one-Enter "continue" UX for follow-up
|
|
@@ -868,11 +874,19 @@ export async function runRepl(opts) {
|
|
|
868
874
|
writeMode: cfg.write_mode,
|
|
869
875
|
onInterrupt,
|
|
870
876
|
}), {
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
// vs exit at idle).
|
|
877
|
+
// exitOnCtrlC: false — Ink would otherwise call process.exit()
|
|
878
|
+
// on Ctrl+C before onInterrupt can decide whether to abort the
|
|
879
|
+
// in-flight turn or exit cleanly.
|
|
875
880
|
exitOnCtrlC: false,
|
|
881
|
+
// W2.1 (2026-05-18) — patchConsole intercepts console.log /
|
|
882
|
+
// console.error / console.warn from inside ANY code that runs
|
|
883
|
+
// while Ink owns the frame, routes them above the Ink render
|
|
884
|
+
// region as Static-committed scrollback rather than corrupting
|
|
885
|
+
// the live frame. CC uses this (12 hits in v0.2.9 source).
|
|
886
|
+
// Removes a whole class of "console.log ate my frame" bugs and
|
|
887
|
+
// lets us delete the 6 manual chunkEmitter routings we added
|
|
888
|
+
// in commit 349ff75.
|
|
889
|
+
patchConsole: true,
|
|
876
890
|
});
|
|
877
891
|
await donePromise;
|
|
878
892
|
unmount();
|
|
@@ -1536,6 +1550,9 @@ export async function runRepl(opts) {
|
|
|
1536
1550
|
last_skill: session.last_skill ?? null,
|
|
1537
1551
|
last_object: extractedObject ?? session.last_object ?? null,
|
|
1538
1552
|
});
|
|
1553
|
+
if (c.offline) {
|
|
1554
|
+
console.log(chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
|
|
1555
|
+
}
|
|
1539
1556
|
const decision = decideRouting(c);
|
|
1540
1557
|
// 2026-05-17 — pass the just-used skill so the picker can
|
|
1541
1558
|
// default cursor to it. session.skill = immediately-previous
|
|
@@ -1,19 +1,85 @@
|
|
|
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';
|
|
4
|
+
// 8s — sized to cover the observed Fly cold-start (~3.2s on the 2026-05-20
|
|
5
|
+
// probe) plus headroom for a slow network. Tighter values risk timing out
|
|
6
|
+
// legitimate cold-start hits and burning the retry budget on the same.
|
|
7
|
+
const FETCH_TIMEOUT_MS = 8000;
|
|
8
|
+
// 250ms — long enough for a half-open TCP/keep-alive socket to be fully
|
|
9
|
+
// torn down before we try again, short enough that the user doesn't feel
|
|
10
|
+
// the retry as a hang.
|
|
11
|
+
const RETRY_DELAY_MS = 250;
|
|
12
|
+
/**
|
|
13
|
+
* Extract the real failure cause from a Node fetch / undici error. Node's
|
|
14
|
+
* native fetch surfaces `TypeError: fetch failed` and hides the actual
|
|
15
|
+
* layer on `err.cause` (UND_ERR_*, ENOTFOUND, ECONNRESET, ETIMEDOUT, TLS
|
|
16
|
+
* handshake aborts, etc.). This lets future failure messages name the
|
|
17
|
+
* actual layer so we can tell DNS vs TLS vs socket-reset vs Fly cold start.
|
|
18
|
+
*/
|
|
19
|
+
function extractCause(err) {
|
|
20
|
+
const e = err;
|
|
21
|
+
if (e.cause) {
|
|
22
|
+
return `${e.cause.code ?? e.cause.errno ?? 'unknown'}${e.cause.message ? `: ${e.cause.message}` : ''}`;
|
|
23
|
+
}
|
|
24
|
+
return e.message;
|
|
25
|
+
}
|
|
26
|
+
async function tryFetchOnce(proxyUrl, key, prompt, context) {
|
|
27
|
+
const ctrl = new AbortController();
|
|
28
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
29
|
+
try {
|
|
30
|
+
return await fetch(`${proxyUrl}/v1/classify`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: {
|
|
33
|
+
'Authorization': `Bearer ${key}`,
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify({ prompt, context }),
|
|
37
|
+
signal: ctrl.signal,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
3
44
|
export async function classifyPrompt(prompt, context) {
|
|
4
45
|
const cfg = await loadConfig();
|
|
5
46
|
const store = await getStore();
|
|
6
47
|
const key = await store.get();
|
|
7
48
|
if (!key)
|
|
8
49
|
throw new Error('no_api_key');
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
50
|
+
let r;
|
|
51
|
+
let lastErr;
|
|
52
|
+
// Two attempts total: the second masks the common case of a stale
|
|
53
|
+
// keep-alive socket reused against a just-cold-suspended Fly machine
|
|
54
|
+
// (2026-05-20 probe showed first-hit cold = 3.2s, warm = 460ms — the
|
|
55
|
+
// half-open socket fails fast at the transport layer, then a fresh
|
|
56
|
+
// connection succeeds). HTTP-level failures (non-2xx) do NOT retry —
|
|
57
|
+
// they bubble through as before.
|
|
58
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
59
|
+
try {
|
|
60
|
+
r = await tryFetchOnce(cfg.proxy_url, key, prompt, context);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
lastErr = err;
|
|
65
|
+
if (attempt === 0)
|
|
66
|
+
await new Promise((res) => setTimeout(res, RETRY_DELAY_MS));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!r) {
|
|
70
|
+
// Both attempts failed at the transport layer. Degrade to the bundled
|
|
71
|
+
// local rules so the user's turn is NOT dropped — they still get a
|
|
72
|
+
// coaching picker (confidence 0.3 < 0.60 = picker decision), and the
|
|
73
|
+
// REPL surfaces an offline hint with the real cause.
|
|
74
|
+
const skill = classifyLocal(prompt) ?? 'abap-radar';
|
|
75
|
+
return {
|
|
76
|
+
skill,
|
|
77
|
+
confidence: 0.3,
|
|
78
|
+
alternates: [],
|
|
79
|
+
offline: true,
|
|
80
|
+
transport_error: lastErr ? extractCause(lastErr) : 'unknown',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
17
83
|
if (!r.ok)
|
|
18
84
|
throw new Error(`classifier HTTP ${r.status}`);
|
|
19
85
|
return (await r.json());
|
|
@@ -124,6 +124,13 @@ export async function agentRunHandler(args, ctx) {
|
|
|
124
124
|
// Increment depth env var BEFORE the child runs; restore after.
|
|
125
125
|
const priorDepth = process.env.CSPEACH_AGENT_DEPTH;
|
|
126
126
|
process.env.CSPEACH_AGENT_DEPTH = String(depth + 1);
|
|
127
|
+
// W2.5 — when read_only is set, build a tool filter that drops every
|
|
128
|
+
// mutating tool from the subagent's tool list. Saves tokens on the
|
|
129
|
+
// sub-agent invocation (smaller tools array = less input cost) AND
|
|
130
|
+
// hard-prevents accidental writes from the subagent.
|
|
131
|
+
const toolFilter = args.read_only
|
|
132
|
+
? (t) => !t.isMutating
|
|
133
|
+
: undefined;
|
|
127
134
|
try {
|
|
128
135
|
await runTurn({
|
|
129
136
|
provider: ctx.provider,
|
|
@@ -132,6 +139,7 @@ export async function agentRunHandler(args, ctx) {
|
|
|
132
139
|
ctx: childCtx,
|
|
133
140
|
chunkEmitter: nullSinkEmitter,
|
|
134
141
|
maxTokensOverride,
|
|
142
|
+
toolFilter,
|
|
135
143
|
});
|
|
136
144
|
}
|
|
137
145
|
catch (err) {
|
|
@@ -179,6 +187,10 @@ registerTool({
|
|
|
179
187
|
type: 'number',
|
|
180
188
|
description: 'Optional per-call cap. Default 100000, hard cap 200000.',
|
|
181
189
|
},
|
|
190
|
+
read_only: {
|
|
191
|
+
type: 'boolean',
|
|
192
|
+
description: 'When true, the subagent only sees read-only tools (no sap_set_source, no file-write, no shell_exec, etc.). Use this for research / analysis subagents that should not make changes — e.g. spawning a subagent to read code, run greps, or summarise findings. Smaller tools array = lower per-call cost; also a hard guard against accidental writes.',
|
|
193
|
+
},
|
|
182
194
|
},
|
|
183
195
|
required: ['skill', 'task'],
|
|
184
196
|
},
|
package/dist/ui/body.js
CHANGED
|
@@ -3,6 +3,56 @@ import React, { useReducer, useEffect } from 'react';
|
|
|
3
3
|
import { Text, Static } from 'ink';
|
|
4
4
|
import { WIDGET_COMPONENTS } from './widgets/component-registry.js';
|
|
5
5
|
import { WIDGET_CONTROL } from './widget-control.js';
|
|
6
|
+
/**
|
|
7
|
+
* Frame interval for batching streaming chunks before dispatching to the
|
|
8
|
+
* reducer (W2.2 — 2026-05-18). Ink already throttles its stdout writes to
|
|
9
|
+
* 32 ms via `es-toolkit/compat throttle` at the renderer level — but the
|
|
10
|
+
* React **reducer** still runs once per chunk in our useReducer setup.
|
|
11
|
+
* For a long code-gen turn (5000+ chunks of 1-3 bytes each), the cumulative
|
|
12
|
+
* regex-split + string-concat cost in `bodyReducer` becomes the hotspot.
|
|
13
|
+
*
|
|
14
|
+
* Batching here gives one reducer call per ~16 ms regardless of chunk rate,
|
|
15
|
+
* preserving the perceived "real-time stream" feel (still 60Hz) while
|
|
16
|
+
* collapsing reducer work by 1-2 orders of magnitude on dense streams.
|
|
17
|
+
*
|
|
18
|
+
* `requestAnimationFrame` is unavailable in Node, so we use `setTimeout(16)` —
|
|
19
|
+
* same effective cadence, cross-platform safe. We re-arm the timer only when
|
|
20
|
+
* a chunk arrives and no flush is pending, so an idle stream pays nothing.
|
|
21
|
+
*/
|
|
22
|
+
export const RENDER_BATCH_MS = 16;
|
|
23
|
+
export function createChunkBatcher(onFlush, intervalMs = RENDER_BATCH_MS) {
|
|
24
|
+
let buffered = '';
|
|
25
|
+
let timer = null;
|
|
26
|
+
const fire = () => {
|
|
27
|
+
timer = null;
|
|
28
|
+
if (buffered.length === 0)
|
|
29
|
+
return;
|
|
30
|
+
const out = buffered;
|
|
31
|
+
buffered = '';
|
|
32
|
+
onFlush(out);
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
push(chunk) {
|
|
36
|
+
if (chunk.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
buffered += chunk;
|
|
39
|
+
if (timer === null) {
|
|
40
|
+
timer = setTimeout(fire, intervalMs);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
dispose() {
|
|
44
|
+
if (timer !== null) {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
timer = null;
|
|
47
|
+
}
|
|
48
|
+
if (buffered.length > 0) {
|
|
49
|
+
const out = buffered;
|
|
50
|
+
buffered = '';
|
|
51
|
+
onFlush(out);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
6
56
|
// WIDGET_CONTROL is the longer sentinel (`\x00CSPEACH-WIDGET-V3\x00`).
|
|
7
57
|
// RegExp escaping: `\x00` works in JS regex source directly.
|
|
8
58
|
const WIDGET_RE = new RegExp(WIDGET_CONTROL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
@@ -105,9 +155,18 @@ function renderNode(n) {
|
|
|
105
155
|
export const Body = React.memo(function Body({ chunkEmitter }) {
|
|
106
156
|
const [state, dispatch] = useReducer(bodyReducer, INITIAL_BODY_STATE);
|
|
107
157
|
useEffect(() => {
|
|
108
|
-
|
|
158
|
+
// W2.2 — chunk batching. createChunkBatcher buffers incoming chunks
|
|
159
|
+
// and dispatches at most once per RENDER_BATCH_MS window. Idle stream
|
|
160
|
+
// pays nothing; dense stream (e.g. 5000 chunks during code-gen) sees
|
|
161
|
+
// ~60 reducer calls instead of 5000. dispose() flushes pending text
|
|
162
|
+
// synchronously on unmount so end-of-stream output isn't dropped.
|
|
163
|
+
const batcher = createChunkBatcher((buffered) => dispatch({ chunk: buffered }));
|
|
164
|
+
const handler = (chunk) => batcher.push(chunk);
|
|
109
165
|
chunkEmitter.on('chunk', handler);
|
|
110
|
-
return () => {
|
|
166
|
+
return () => {
|
|
167
|
+
chunkEmitter.off('chunk', handler);
|
|
168
|
+
batcher.dispose();
|
|
169
|
+
};
|
|
111
170
|
}, [chunkEmitter]);
|
|
112
171
|
// <Static> takes the nodes array; on each render it appends ONLY new
|
|
113
172
|
// items since the last render. Already-rendered items are not
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cspeach/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abap",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"adt:smoke": "tsx scripts/adt-smoke.ts",
|
|
41
41
|
"smoke:all": "pnpm zcspeach:smoke && pnpm adt:smoke",
|
|
42
42
|
"zcspeach:smoke": "tsx scripts/zcspeach-smoke.ts",
|
|
43
|
+
"bench:models": "tsx scripts/bench-models.ts",
|
|
43
44
|
"prepare": "husky"
|
|
44
45
|
},
|
|
45
46
|
"engines": {
|