@cspeach/cli 1.1.19 → 1.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/anthropic-provider.js +30 -10
- package/dist/agent/cache-keepalive.js +162 -0
- package/dist/agent/cold-prune.js +116 -0
- package/dist/agent/loop.js +675 -148
- package/dist/agent/provider-shape.js +263 -0
- package/dist/agent/providers/ai-hub-provider.js +17 -2
- package/dist/agent/providers/byok-provider.js +33 -3
- package/dist/agent/providers/local-provider.js +8 -1
- package/dist/agent/repair-partial.js +66 -5
- package/dist/agent/summarise-via-provider.js +6 -1
- package/dist/agent/system-prompt.js +38 -0
- package/dist/agent/tool-dispatch.js +8 -0
- package/dist/agent/tool-loading-pin.js +100 -0
- package/dist/cli.js +11 -0
- package/dist/commands/auto-compact.js +33 -16
- package/dist/commands/compact.js +37 -2
- package/dist/commands/config-set.js +10 -1
- package/dist/commands/config-show.js +11 -0
- package/dist/commands/cost.js +14 -2
- package/dist/commands/plan-audit.js +1 -0
- package/dist/config/loader.js +55 -2
- package/dist/cost/cost-log.js +62 -2
- package/dist/cost/pricing.js +6 -2
- package/dist/lib/spill-labels.js +13 -0
- package/dist/models/resolve.js +93 -2
- package/dist/models/server-config.js +158 -3
- package/dist/one-shot.js +15 -5
- package/dist/projects/image-attachments.js +15 -2
- package/dist/renderer/footer-line.js +6 -2
- package/dist/renderer/startup-lines.js +5 -3
- package/dist/renderer/tool-labels.js +33 -2
- package/dist/renderer/ui-width.js +13 -0
- package/dist/repl/current-transport.js +13 -0
- package/dist/repl/post-turn-status.js +8 -1
- package/dist/repl.js +71 -10
- package/dist/session/repin-model.js +18 -0
- package/dist/session/store.js +16 -2
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/skills/preamble.js +75 -0
- package/dist/skills/source-manifest.js +11 -1
- package/dist/tools/filesystem/file-read.js +11 -1
- package/dist/tools/result-spill.js +238 -0
- package/dist/tools/sap-read.js +58 -14
- package/dist/tools/shell/shell_exec.js +9 -0
- package/dist/tools/subagent/adt-serial.js +33 -0
- package/dist/tools/subagent/agent_run.js +2 -0
- package/dist/tools/subagent/read_agent.js +178 -0
- package/dist/tools/subagent/reader-prompt.js +48 -0
- package/dist/tools/todo.js +3 -1
- package/dist/tools/tool-loading.js +255 -0
- package/dist/tools/tool-output-read.js +117 -0
- package/dist/tools/transport.js +6 -1
- package/dist/ui/footer.js +5 -5
- package/dist/ui/sap-state-store.js +1 -1
- package/dist/ui/turn-status-emitter.js +37 -0
- package/dist/ui/turn-status.js +1 -1
- package/package.json +2 -1
|
@@ -12,24 +12,28 @@ import { createManagedSkillSource } from '../skills/source-managed.js';
|
|
|
12
12
|
import { CLI_VERSION } from '../lib/version.js';
|
|
13
13
|
export function createManagedProvider(cfg, getBearer) {
|
|
14
14
|
const skillSource = createManagedSkillSource(cfg, getBearer);
|
|
15
|
+
// Headers every managed request carries (the real call and the keepalive
|
|
16
|
+
// ping alike, so the proxy's enforcement / version gate pick the same model).
|
|
17
|
+
const defaultHeaders = async () => {
|
|
18
|
+
const bearer = await getBearer();
|
|
19
|
+
const device = await getDeviceFingerprint();
|
|
20
|
+
return {
|
|
21
|
+
'authorization': `Bearer ${bearer}`,
|
|
22
|
+
'anthropic-beta': 'compact-2026-01-12',
|
|
23
|
+
'x-device-machine-id': device.machineId,
|
|
24
|
+
'x-device-hostname': device.hostname,
|
|
25
|
+
'User-Agent': `cspeach/${CLI_VERSION}`,
|
|
26
|
+
};
|
|
27
|
+
};
|
|
15
28
|
return {
|
|
16
29
|
name: 'anthropic-managed',
|
|
17
30
|
mode: 'managed',
|
|
18
31
|
skillSource,
|
|
19
32
|
async createStream(params, options = {}) {
|
|
20
|
-
const bearer = await getBearer();
|
|
21
|
-
const device = await getDeviceFingerprint();
|
|
22
|
-
const cliVersion = CLI_VERSION;
|
|
23
33
|
const sdk = new Anthropic({
|
|
24
34
|
apiKey: 'proxy-bearer-auth', // ignored by our proxy; SDK requires non-empty
|
|
25
35
|
baseURL: cfg.proxy_url.replace(/\/$/, '') + '/',
|
|
26
|
-
defaultHeaders:
|
|
27
|
-
'authorization': `Bearer ${bearer}`,
|
|
28
|
-
'anthropic-beta': 'compact-2026-01-12',
|
|
29
|
-
'x-device-machine-id': device.machineId,
|
|
30
|
-
'x-device-hostname': device.hostname,
|
|
31
|
-
'User-Agent': `cspeach/${cliVersion}`,
|
|
32
|
-
},
|
|
36
|
+
defaultHeaders: await defaultHeaders(),
|
|
33
37
|
});
|
|
34
38
|
// Per-call headers (e.g. X-CSForge-Skill) passed as second argument to the SDK.
|
|
35
39
|
// The SDK forwards them alongside defaultHeaders on this specific request.
|
|
@@ -41,6 +45,22 @@ export function createManagedProvider(cfg, getBearer) {
|
|
|
41
45
|
sdkOptions.signal = options.signal;
|
|
42
46
|
return sdk.messages.stream(params, Object.keys(sdkOptions).length > 0 ? sdkOptions : undefined);
|
|
43
47
|
},
|
|
48
|
+
// Task 15 — POST /v1/keepalive (Task 14 route): the proxy rebuilds the
|
|
49
|
+
// upstream request with the same code as /v1/messages and sends it with
|
|
50
|
+
// max_tokens 0. Same headers as the real call; the proxy mints its own
|
|
51
|
+
// request id. Any non-2xx rejects (the keepalive timer stops this wait).
|
|
52
|
+
async keepalive(params, options = {}) {
|
|
53
|
+
const r = await fetch(`${cfg.proxy_url.replace(/\/$/, '')}/v1/keepalive`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { ...(await defaultHeaders()), ...(options.headers ?? {}), 'content-type': 'application/json' },
|
|
56
|
+
body: JSON.stringify(params),
|
|
57
|
+
signal: AbortSignal.timeout(20_000),
|
|
58
|
+
});
|
|
59
|
+
// Drain the small JSON body so the connection is released.
|
|
60
|
+
await r.text().catch(() => '');
|
|
61
|
+
if (!r.ok)
|
|
62
|
+
throw Object.assign(new Error(`keepalive HTTP ${r.status}`), { status: r.status });
|
|
63
|
+
},
|
|
44
64
|
async healthCheck() {
|
|
45
65
|
try {
|
|
46
66
|
const bearer = await getBearer();
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** Ping cadence: under the 300 s cache TTL with 30 s of slack. */
|
|
2
|
+
export const KEEPALIVE_INTERVAL_MS = 270_000;
|
|
3
|
+
/**
|
|
4
|
+
* Deep copy of a request, so neither the loop's later pushes nor any in-place
|
|
5
|
+
* edit of an earlier message can change what a ping re-sends.
|
|
6
|
+
*/
|
|
7
|
+
export function snapshotParams(params) {
|
|
8
|
+
try {
|
|
9
|
+
return structuredClone(params);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return JSON.parse(JSON.stringify(params));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* True when the API cached part of the prompt (read or wrote). A prompt under
|
|
17
|
+
* the model's minimum cacheable size reports zero for both, and pinging it
|
|
18
|
+
* would buy nothing.
|
|
19
|
+
*/
|
|
20
|
+
export function isCacheablePrompt(usage) {
|
|
21
|
+
if (!usage)
|
|
22
|
+
return false;
|
|
23
|
+
return (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) > 0;
|
|
24
|
+
}
|
|
25
|
+
export class CacheKeepalive {
|
|
26
|
+
opts;
|
|
27
|
+
timer = null;
|
|
28
|
+
generation = 0;
|
|
29
|
+
sent = 0;
|
|
30
|
+
constructor(opts) {
|
|
31
|
+
this.opts = opts;
|
|
32
|
+
}
|
|
33
|
+
/** Pings sent so far by this instance. */
|
|
34
|
+
get pings() {
|
|
35
|
+
return this.sent;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Start (or restart) the timer for one wait. `params` must already be a
|
|
39
|
+
* snapshot. Only the headers of `options` are forwarded — never the turn's
|
|
40
|
+
* abort signal (a ping outlives nothing and is cancelled by disarm).
|
|
41
|
+
*/
|
|
42
|
+
arm(params, options = {}) {
|
|
43
|
+
this.disarm();
|
|
44
|
+
if (typeof this.opts.provider.keepalive !== 'function')
|
|
45
|
+
return;
|
|
46
|
+
if (this.sent >= this.opts.maxPings)
|
|
47
|
+
return;
|
|
48
|
+
const callOptions = options.headers ? { headers: { ...options.headers } } : {};
|
|
49
|
+
this.schedule(this.generation, params, callOptions);
|
|
50
|
+
}
|
|
51
|
+
/** Cancel the pending ping (an in-flight one finishes but does not re-arm). */
|
|
52
|
+
disarm() {
|
|
53
|
+
this.generation++;
|
|
54
|
+
if (this.timer) {
|
|
55
|
+
clearTimeout(this.timer);
|
|
56
|
+
this.timer = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
schedule(gen, params, options) {
|
|
60
|
+
const t = setTimeout(() => { void this.fire(gen, params, options); }, this.opts.intervalMs ?? KEEPALIVE_INTERVAL_MS);
|
|
61
|
+
// Never keep the process alive for a ping.
|
|
62
|
+
t.unref?.();
|
|
63
|
+
this.timer = t;
|
|
64
|
+
}
|
|
65
|
+
async fire(gen, params, options) {
|
|
66
|
+
this.timer = null;
|
|
67
|
+
if (gen !== this.generation)
|
|
68
|
+
return;
|
|
69
|
+
const n = ++this.sent;
|
|
70
|
+
let failed;
|
|
71
|
+
let ok = true;
|
|
72
|
+
try {
|
|
73
|
+
await this.opts.provider.keepalive(params, options);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
ok = false;
|
|
77
|
+
failed = err;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
this.opts.onPing?.(n, ok ? undefined : failed);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// an observer must never break the timer
|
|
84
|
+
}
|
|
85
|
+
if (!ok)
|
|
86
|
+
return; // stop pinging this wait
|
|
87
|
+
if (gen !== this.generation)
|
|
88
|
+
return; // disarmed while the ping was in flight
|
|
89
|
+
if (this.sent >= this.opts.maxPings)
|
|
90
|
+
return;
|
|
91
|
+
this.schedule(gen, params, options);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Keyed by the session object: never persisted with the session, and a
|
|
95
|
+
// subagent's child session keeps its own record.
|
|
96
|
+
const lastRequests = new WeakMap();
|
|
97
|
+
export function recordLastRequest(session, rec) {
|
|
98
|
+
const stored = { ...rec, firstMessage: session.messages[0], length: session.messages.length };
|
|
99
|
+
lastRequests.set(session, stored);
|
|
100
|
+
return stored;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The session's last request, or undefined when there was none or the history
|
|
104
|
+
* no longer extends it (compaction, a plan-resume reset): pinging a prefix the
|
|
105
|
+
* next call will not send would warm the wrong cache.
|
|
106
|
+
*/
|
|
107
|
+
export function lastRequestFor(session) {
|
|
108
|
+
const rec = lastRequests.get(session);
|
|
109
|
+
if (!rec)
|
|
110
|
+
return undefined;
|
|
111
|
+
if (session.messages.length < rec.length || session.messages[0] !== rec.firstMessage)
|
|
112
|
+
return undefined;
|
|
113
|
+
return rec;
|
|
114
|
+
}
|
|
115
|
+
// Task 19 — when a keepalive ping last kept this session's prompt cache warm.
|
|
116
|
+
// `last_request_at` is stamped only by real model calls, so without this an
|
|
117
|
+
// idle session kept warm by pings would look cold to the cold-cache prune and
|
|
118
|
+
// have its (still cached) history rewritten. In-process only: no ping runs
|
|
119
|
+
// while the CLI is closed.
|
|
120
|
+
const cacheTouched = new WeakMap();
|
|
121
|
+
/** Record a successful keepalive ping for `session` (default: now). */
|
|
122
|
+
export function noteCacheTouched(session, at = Date.now()) {
|
|
123
|
+
cacheTouched.set(session, at);
|
|
124
|
+
}
|
|
125
|
+
/** Epoch ms of the last successful keepalive ping for `session`, if any. */
|
|
126
|
+
export function cacheTouchedAt(session) {
|
|
127
|
+
return cacheTouched.get(session);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The REPL's between-turns keepalive: armed after a turn ends, disarmed on the
|
|
131
|
+
* next submit and on exit. Each idle period gets its own `idle_pings` budget,
|
|
132
|
+
* but only for a NEW request — a slash command that made no model call does
|
|
133
|
+
* not restart the budget, so an abandoned session stops after ~18 min
|
|
134
|
+
* (4 × 270 s) and never pings forever.
|
|
135
|
+
*/
|
|
136
|
+
export class IdleKeepalive {
|
|
137
|
+
opts;
|
|
138
|
+
current = null;
|
|
139
|
+
lastArmed;
|
|
140
|
+
constructor(opts) {
|
|
141
|
+
this.opts = opts;
|
|
142
|
+
}
|
|
143
|
+
armAfterTurn(session) {
|
|
144
|
+
const rec = lastRequestFor(session);
|
|
145
|
+
if (!rec || !rec.cacheable || rec === this.lastArmed)
|
|
146
|
+
return;
|
|
147
|
+
this.disarm();
|
|
148
|
+
this.lastArmed = rec;
|
|
149
|
+
this.current = new CacheKeepalive({
|
|
150
|
+
provider: this.opts.provider,
|
|
151
|
+
maxPings: this.opts.maxPings(),
|
|
152
|
+
// Task 19 — a successful idle ping keeps the cache warm (cold prune).
|
|
153
|
+
onPing: (_n, err) => { if (err === undefined)
|
|
154
|
+
noteCacheTouched(session); },
|
|
155
|
+
});
|
|
156
|
+
this.current.arm(rec.params, rec.options);
|
|
157
|
+
}
|
|
158
|
+
disarm() {
|
|
159
|
+
this.current?.disarm();
|
|
160
|
+
this.current = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 19 (model control, 2026-09-27, owner decision 7) — cold-cache prune.
|
|
3
|
+
*
|
|
4
|
+
* The prompt cache lives 300 s. Once it has expired, the next call re-writes
|
|
5
|
+
* the WHOLE history into the cache anyway, so rewriting old history at that
|
|
6
|
+
* moment costs no cache it would otherwise have read. This elides the content
|
|
7
|
+
* of old `tool_result` blocks (source dumps, SQL rows, file reads — the bulk of
|
|
8
|
+
* a long session) and so shrinks that cache write and every later read.
|
|
9
|
+
*
|
|
10
|
+
* Default OFF: runs only with `CSPEACH_COLD_PRUNE=on` (the owner first). Runs
|
|
11
|
+
* once at turn start, before the first createStream, and only when the cache
|
|
12
|
+
* is cold: `last_request_at` (the last real model call) and the last
|
|
13
|
+
* successful keepalive ping are both more than 300 s old.
|
|
14
|
+
*
|
|
15
|
+
* Valid history is kept: only `tool_result.content` changes; the block, its
|
|
16
|
+
* `tool_use_id` pairing and `is_error` stay. The last `keepRecentUserTurns`
|
|
17
|
+
* user turns are never touched. Idempotent: an elided result is recognised
|
|
18
|
+
* and left alone.
|
|
19
|
+
*
|
|
20
|
+
* Thinking (fix round 1): the API binds each thinking block to the exact
|
|
21
|
+
* prefix that produced it (Opus 5.5 block binding), so every assistant message
|
|
22
|
+
* AFTER the first elided result loses its thinking blocks — the same
|
|
23
|
+
* stripThinking /compact applies (a thinking-only message keeps a placeholder
|
|
24
|
+
* text, never empty content). Messages before that point keep theirs.
|
|
25
|
+
*/
|
|
26
|
+
import { isUserPrompt } from '../session/user-prompt.js';
|
|
27
|
+
import { cacheTouchedAt } from './cache-keepalive.js';
|
|
28
|
+
import { stripThinking } from '../commands/compact.js';
|
|
29
|
+
/** The cache TTL: older than this since the last call ⇒ the cache is cold. */
|
|
30
|
+
export const COLD_PRUNE_AFTER_MS = 300_000;
|
|
31
|
+
/** User turns (typed prompts) kept intact at the end of the history. */
|
|
32
|
+
export const COLD_PRUNE_KEEP_USER_TURNS = 2;
|
|
33
|
+
const ELIDED_RE = /^\[result elided at cold start — \d+ chars\]$/;
|
|
34
|
+
function elidedMarker(chars) {
|
|
35
|
+
return `[result elided at cold start — ${chars} chars]`;
|
|
36
|
+
}
|
|
37
|
+
/** Characters a tool_result's content carries (text, else its JSON). */
|
|
38
|
+
function contentChars(content) {
|
|
39
|
+
if (typeof content === 'string')
|
|
40
|
+
return content.length;
|
|
41
|
+
if (!Array.isArray(content))
|
|
42
|
+
return 0;
|
|
43
|
+
let n = 0;
|
|
44
|
+
for (const b of content) {
|
|
45
|
+
if (b?.type === 'text' && typeof b.text === 'string')
|
|
46
|
+
n += b.text.length;
|
|
47
|
+
else
|
|
48
|
+
n += JSON.stringify(b ?? null).length;
|
|
49
|
+
}
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Replace the content of every `tool_result` that sits before the last
|
|
54
|
+
* `keepRecentUserTurns` user prompts with a one-line marker. Mutates
|
|
55
|
+
* `messages` in place; returns how many results were elided (0 = untouched).
|
|
56
|
+
*/
|
|
57
|
+
export function pruneStaleToolResults(messages, opts) {
|
|
58
|
+
const keep = Math.max(0, Math.floor(opts.keepRecentUserTurns));
|
|
59
|
+
const promptIdx = [];
|
|
60
|
+
messages.forEach((m, i) => { if (isUserPrompt(m))
|
|
61
|
+
promptIdx.push(i); });
|
|
62
|
+
if (promptIdx.length <= keep)
|
|
63
|
+
return 0;
|
|
64
|
+
const cut = keep === 0 ? messages.length : promptIdx[promptIdx.length - keep];
|
|
65
|
+
let elided = 0;
|
|
66
|
+
let firstElided = -1;
|
|
67
|
+
for (let i = 0; i < cut; i++) {
|
|
68
|
+
const m = messages[i];
|
|
69
|
+
if (m.role !== 'user' || !Array.isArray(m.content))
|
|
70
|
+
continue;
|
|
71
|
+
for (const block of m.content) {
|
|
72
|
+
if (block?.type !== 'tool_result')
|
|
73
|
+
continue;
|
|
74
|
+
const content = block.content;
|
|
75
|
+
if (typeof content === 'string' && ELIDED_RE.test(content))
|
|
76
|
+
continue;
|
|
77
|
+
const chars = contentChars(content);
|
|
78
|
+
const marker = elidedMarker(chars);
|
|
79
|
+
if (chars <= marker.length)
|
|
80
|
+
continue; // nothing to save
|
|
81
|
+
block.content = marker;
|
|
82
|
+
elided++;
|
|
83
|
+
if (firstElided < 0)
|
|
84
|
+
firstElided = i;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (firstElided >= 0) {
|
|
88
|
+
for (let i = firstElided + 1; i < messages.length; i++) {
|
|
89
|
+
messages[i] = stripThinking(messages[i]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return elided;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The turn-start gate: prune only with `CSPEACH_COLD_PRUNE=on` and a cold
|
|
96
|
+
* cache. Returns how many results were elided (0 when the gate refused).
|
|
97
|
+
*/
|
|
98
|
+
export function maybeColdPrune(session, opts = {}) {
|
|
99
|
+
const env = opts.env ?? process.env;
|
|
100
|
+
if (env.CSPEACH_COLD_PRUNE?.trim().toLowerCase() !== 'on')
|
|
101
|
+
return 0;
|
|
102
|
+
if (typeof session.last_request_at !== 'string')
|
|
103
|
+
return 0;
|
|
104
|
+
const lastCall = Date.parse(session.last_request_at);
|
|
105
|
+
if (!Number.isFinite(lastCall))
|
|
106
|
+
return 0;
|
|
107
|
+
const lastWarm = Math.max(lastCall, cacheTouchedAt(session) ?? 0);
|
|
108
|
+
const now = opts.now ?? Date.now();
|
|
109
|
+
if (now - lastWarm <= COLD_PRUNE_AFTER_MS)
|
|
110
|
+
return 0;
|
|
111
|
+
const pruned = pruneStaleToolResults(session.messages, { keepRecentUserTurns: COLD_PRUNE_KEEP_USER_TURNS });
|
|
112
|
+
// The recorded context size described the unpruned history.
|
|
113
|
+
if (pruned > 0)
|
|
114
|
+
delete session.last_context_tokens;
|
|
115
|
+
return pruned;
|
|
116
|
+
}
|