@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.
Files changed (57) hide show
  1. package/dist/agent/anthropic-provider.js +30 -10
  2. package/dist/agent/cache-keepalive.js +162 -0
  3. package/dist/agent/cold-prune.js +116 -0
  4. package/dist/agent/loop.js +675 -148
  5. package/dist/agent/provider-shape.js +263 -0
  6. package/dist/agent/providers/ai-hub-provider.js +17 -2
  7. package/dist/agent/providers/byok-provider.js +33 -3
  8. package/dist/agent/providers/local-provider.js +8 -1
  9. package/dist/agent/repair-partial.js +66 -5
  10. package/dist/agent/summarise-via-provider.js +6 -1
  11. package/dist/agent/system-prompt.js +38 -0
  12. package/dist/agent/tool-dispatch.js +8 -0
  13. package/dist/agent/tool-loading-pin.js +100 -0
  14. package/dist/cli.js +11 -0
  15. package/dist/commands/auto-compact.js +33 -16
  16. package/dist/commands/compact.js +37 -2
  17. package/dist/commands/config-set.js +10 -1
  18. package/dist/commands/config-show.js +11 -0
  19. package/dist/commands/cost.js +14 -2
  20. package/dist/commands/plan-audit.js +1 -0
  21. package/dist/config/loader.js +55 -2
  22. package/dist/cost/cost-log.js +62 -2
  23. package/dist/cost/pricing.js +6 -2
  24. package/dist/lib/spill-labels.js +13 -0
  25. package/dist/models/resolve.js +93 -2
  26. package/dist/models/server-config.js +158 -3
  27. package/dist/one-shot.js +15 -5
  28. package/dist/projects/image-attachments.js +15 -2
  29. package/dist/renderer/footer-line.js +6 -2
  30. package/dist/renderer/startup-lines.js +5 -3
  31. package/dist/renderer/tool-labels.js +33 -2
  32. package/dist/renderer/ui-width.js +13 -0
  33. package/dist/repl/current-transport.js +13 -0
  34. package/dist/repl/post-turn-status.js +8 -1
  35. package/dist/repl.js +71 -10
  36. package/dist/session/repin-model.js +18 -0
  37. package/dist/session/store.js +16 -2
  38. package/dist/skills/bundled-skills.js +1 -1
  39. package/dist/skills/preamble.js +75 -0
  40. package/dist/skills/source-manifest.js +11 -1
  41. package/dist/tools/filesystem/file-read.js +11 -1
  42. package/dist/tools/result-spill.js +238 -0
  43. package/dist/tools/sap-read.js +58 -14
  44. package/dist/tools/shell/shell_exec.js +9 -0
  45. package/dist/tools/subagent/adt-serial.js +33 -0
  46. package/dist/tools/subagent/agent_run.js +2 -0
  47. package/dist/tools/subagent/read_agent.js +178 -0
  48. package/dist/tools/subagent/reader-prompt.js +48 -0
  49. package/dist/tools/todo.js +3 -1
  50. package/dist/tools/tool-loading.js +255 -0
  51. package/dist/tools/tool-output-read.js +117 -0
  52. package/dist/tools/transport.js +6 -1
  53. package/dist/ui/footer.js +5 -5
  54. package/dist/ui/sap-state-store.js +1 -1
  55. package/dist/ui/turn-status-emitter.js +37 -0
  56. package/dist/ui/turn-status.js +1 -1
  57. 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
+ }