@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
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// The proxy serves `GET {proxy_url}/v1/model-config` (Task 3):
|
|
5
5
|
// { roles: { session_default, audit, compact, router },
|
|
6
6
|
// pricing: Record<model, { inputPer1M, outputPer1M, cacheReadPer1M, cacheCreatePer1M? }>,
|
|
7
|
+
// effort: { session_default?, audit?, compact? }, (Task 4; {} = none)
|
|
8
|
+
// tool_loading: 'deferred' | 'static', (Task 4)
|
|
9
|
+
// auto_compact_tokens: number | null, (Task 4; null = CLI default)
|
|
7
10
|
// updated_at }
|
|
8
11
|
// top-level keys snake_case, pricing entries camelCase, Cache-Control 60s, no auth.
|
|
9
12
|
//
|
|
@@ -24,6 +27,11 @@
|
|
|
24
27
|
import fs from 'node:fs/promises';
|
|
25
28
|
import path from 'node:path';
|
|
26
29
|
import { cspeachRoot } from '../config/paths.js';
|
|
30
|
+
export const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
31
|
+
const EFFORT_ROLE_KEYS = ['session_default', 'audit', 'compact'];
|
|
32
|
+
export function isEffort(v) {
|
|
33
|
+
return typeof v === 'string' && EFFORT_VALUES.includes(v);
|
|
34
|
+
}
|
|
27
35
|
// ── Synchronous module snapshot ───────────────────────────────────────────────
|
|
28
36
|
let snapshot = null;
|
|
29
37
|
/** The current served-config snapshot, or null (⇒ built-ins win). Pure sync read. */
|
|
@@ -60,6 +68,27 @@ function coercePricing(raw) {
|
|
|
60
68
|
? raw
|
|
61
69
|
: {};
|
|
62
70
|
}
|
|
71
|
+
/** Keep only valid effort levels for the known effort roles. */
|
|
72
|
+
function coerceEffort(raw) {
|
|
73
|
+
const out = {};
|
|
74
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
75
|
+
return out;
|
|
76
|
+
const r = raw;
|
|
77
|
+
for (const k of EFFORT_ROLE_KEYS) {
|
|
78
|
+
if (isEffort(r[k]))
|
|
79
|
+
out[k] = r[k];
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
function coerceToolLoading(raw) {
|
|
84
|
+
return raw === 'deferred' || raw === 'static' ? raw : undefined;
|
|
85
|
+
}
|
|
86
|
+
/** Same bounds as the proxy column CHECK (migration 021). */
|
|
87
|
+
function coerceAutoCompactTokens(raw) {
|
|
88
|
+
return typeof raw === 'number' && Number.isInteger(raw) && raw >= 10_000 && raw <= 10_000_000
|
|
89
|
+
? raw
|
|
90
|
+
: undefined;
|
|
91
|
+
}
|
|
63
92
|
/**
|
|
64
93
|
* Map a served (or cached) document to a ServerModelConfig, or null when the
|
|
65
94
|
* shape is unusable. `fetchedAt`, when provided, is preserved (disk-cache read);
|
|
@@ -73,9 +102,14 @@ function coerceDocument(body, fetchedAt) {
|
|
|
73
102
|
const roles = coerceRoles(b.roles);
|
|
74
103
|
if (Object.keys(roles).length === 0)
|
|
75
104
|
return null;
|
|
105
|
+
const toolLoading = coerceToolLoading(b.tool_loading);
|
|
106
|
+
const autoCompactTokens = coerceAutoCompactTokens(b.auto_compact_tokens);
|
|
76
107
|
return {
|
|
77
108
|
roles,
|
|
78
109
|
pricing: coercePricing(b.pricing),
|
|
110
|
+
effort: coerceEffort(b.effort),
|
|
111
|
+
...(toolLoading ? { tool_loading: toolLoading } : {}),
|
|
112
|
+
...(autoCompactTokens !== undefined ? { auto_compact_tokens: autoCompactTokens } : {}),
|
|
79
113
|
fetchedAt: typeof fetchedAt === 'string' && fetchedAt.length > 0 ? fetchedAt : new Date().toISOString(),
|
|
80
114
|
};
|
|
81
115
|
}
|
|
@@ -112,9 +146,11 @@ async function readFreshDiskCache() {
|
|
|
112
146
|
// ── Public entrypoint ─────────────────────────────────────────────────────────
|
|
113
147
|
/**
|
|
114
148
|
* Fetch the served model-config and update the module snapshot. Managed mode
|
|
115
|
-
* ONLY.
|
|
149
|
+
* ONLY. NEVER throws. Startup awaits it with `timeoutMs: 1500` (Task 3) so the
|
|
150
|
+
* session model is pinned from the served config before call 0; a slower
|
|
151
|
+
* fetch aborts and falls back to a fresh disk cache. Default cap: 5 s.
|
|
116
152
|
*/
|
|
117
|
-
export async function loadServerModelConfig(cfg) {
|
|
153
|
+
export async function loadServerModelConfig(cfg, opts) {
|
|
118
154
|
// Non-managed modes bypass the proxy for inference and must never fetch.
|
|
119
155
|
if (cfg.llm?.mode !== 'managed')
|
|
120
156
|
return;
|
|
@@ -125,7 +161,7 @@ export async function loadServerModelConfig(cfg) {
|
|
|
125
161
|
// rejection that crashes startup. Keep it caught like every other failure.
|
|
126
162
|
const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/model-config`;
|
|
127
163
|
const controller = new AbortController();
|
|
128
|
-
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
164
|
+
const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
|
|
129
165
|
timer.unref?.();
|
|
130
166
|
let body;
|
|
131
167
|
try {
|
|
@@ -153,3 +189,122 @@ export async function loadServerModelConfig(cfg) {
|
|
|
153
189
|
snapshot = cached;
|
|
154
190
|
}
|
|
155
191
|
}
|
|
192
|
+
let customerSnapshot = null;
|
|
193
|
+
/** The current customer snapshot, or null (⇒ layer skipped). Pure sync read. */
|
|
194
|
+
export function getCustomerModelConfig() {
|
|
195
|
+
return customerSnapshot;
|
|
196
|
+
}
|
|
197
|
+
/** Test-only setter for the customer snapshot. */
|
|
198
|
+
export function __setCustomerModelConfigForTests(c) {
|
|
199
|
+
customerSnapshot = c;
|
|
200
|
+
}
|
|
201
|
+
function coerceCustomer(body) {
|
|
202
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
203
|
+
return null;
|
|
204
|
+
const b = body;
|
|
205
|
+
const model = typeof b.model === 'string' && b.model.trim().length > 0 ? b.model.trim() : null;
|
|
206
|
+
return {
|
|
207
|
+
model,
|
|
208
|
+
effort: isEffort(b.effort) ? b.effort : null,
|
|
209
|
+
enforced: b.enforced === true,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Fetch the caller's customer model-config and update the customer snapshot.
|
|
214
|
+
* NEVER throws; on any failure the snapshot is null.
|
|
215
|
+
*/
|
|
216
|
+
export async function loadCustomerModelConfig(cfg, getBearer, opts) {
|
|
217
|
+
customerSnapshot = null;
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
|
|
220
|
+
timer.unref?.();
|
|
221
|
+
try {
|
|
222
|
+
// The keychain read shares the cap: a hanging getBearer loses the race to
|
|
223
|
+
// the same timer that aborts the fetch (review M1).
|
|
224
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
225
|
+
controller.signal.addEventListener('abort', () => reject(new Error('timeout')), { once: true });
|
|
226
|
+
});
|
|
227
|
+
aborted.catch(() => { });
|
|
228
|
+
const key = await Promise.race([getBearer(), aborted]);
|
|
229
|
+
if (!key)
|
|
230
|
+
return;
|
|
231
|
+
const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/me/model-config`;
|
|
232
|
+
const r = await fetch(url, {
|
|
233
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
234
|
+
signal: controller.signal,
|
|
235
|
+
});
|
|
236
|
+
if (!r.ok)
|
|
237
|
+
return;
|
|
238
|
+
customerSnapshot = coerceCustomer(await r.json());
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
customerSnapshot = null;
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// ── Tool-loading kill switch (Task 16, ruling F13) ───────────────────────────
|
|
248
|
+
//
|
|
249
|
+
// Managed mode reads `tool_loading` from the served snapshot above. BYOK never
|
|
250
|
+
// loads that snapshot (its roles must not steer a BYOK session), so it reads
|
|
251
|
+
// ONLY `tool_loading` from the same public endpoint — and only when signed in
|
|
252
|
+
// (a CSPeach bearer exists). No bearer, any failure, or an invalid value ⇒
|
|
253
|
+
// undefined, which resolveToolLoading turns into 'static' for BYOK. No disk
|
|
254
|
+
// cache. NEVER throws. Startup runs it alongside the other loads under the
|
|
255
|
+
// same 1.5 s cap.
|
|
256
|
+
let byokToolLoading;
|
|
257
|
+
/** Test-only setter for the BYOK tool-loading value. */
|
|
258
|
+
export function __setByokToolLoadingForTests(v) {
|
|
259
|
+
byokToolLoading = v;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The served tool-loading mode: the managed snapshot's `tool_loading`, else
|
|
263
|
+
* the BYOK fetch result, else undefined. Pure sync read.
|
|
264
|
+
*/
|
|
265
|
+
export function servedToolLoading() {
|
|
266
|
+
return snapshot?.tool_loading ?? byokToolLoading;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Task 21 fix round (controller ruling) — the managed proxy's capability
|
|
270
|
+
* signal: its served config carries `tool_loading` (migration 021 and later).
|
|
271
|
+
* An older proxy serves no such field and adds none of the new betas, so the
|
|
272
|
+
* loop sends the new wire shapes (deferred tools, tool search, tool_addition,
|
|
273
|
+
* `thinking.block_binding`) on the managed path only when this is true.
|
|
274
|
+
* Managed snapshot only; BYOK never reads it. Pure sync read.
|
|
275
|
+
*/
|
|
276
|
+
export function managedProxyServesNewShapes() {
|
|
277
|
+
return snapshot?.tool_loading !== undefined;
|
|
278
|
+
}
|
|
279
|
+
/** BYOK only: fetch `/v1/model-config` and keep its `tool_loading`. */
|
|
280
|
+
export async function fetchToolLoadingMode(cfg, getBearer, opts) {
|
|
281
|
+
byokToolLoading = undefined;
|
|
282
|
+
if (cfg.llm?.mode !== 'byok')
|
|
283
|
+
return undefined;
|
|
284
|
+
const controller = new AbortController();
|
|
285
|
+
const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
|
|
286
|
+
timer.unref?.();
|
|
287
|
+
try {
|
|
288
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
289
|
+
controller.signal.addEventListener('abort', () => reject(new Error('timeout')), { once: true });
|
|
290
|
+
});
|
|
291
|
+
aborted.catch(() => { });
|
|
292
|
+
const key = await Promise.race([getBearer(), aborted]);
|
|
293
|
+
if (!key)
|
|
294
|
+
return undefined;
|
|
295
|
+
const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/model-config`;
|
|
296
|
+
const r = await fetch(url, { signal: controller.signal });
|
|
297
|
+
if (!r.ok)
|
|
298
|
+
return undefined;
|
|
299
|
+
const body = (await r.json());
|
|
300
|
+
byokToolLoading = coerceToolLoading(body && typeof body === 'object' ? body.tool_loading : undefined);
|
|
301
|
+
return byokToolLoading;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
byokToolLoading = undefined;
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
}
|
|
310
|
+
}
|
package/dist/one-shot.js
CHANGED
|
@@ -12,7 +12,7 @@ import { AdtClient } from '@cspeach/sap-client';
|
|
|
12
12
|
import { makeSessionId, saveSession } from './session/store.js';
|
|
13
13
|
import { newSession } from './session/schema.js';
|
|
14
14
|
import { resolveModelRole } from './models/resolve.js';
|
|
15
|
-
import { loadServerModelConfig } from './models/server-config.js';
|
|
15
|
+
import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
|
|
16
16
|
import { createProviderForMode } from './agent/providers/factory.js';
|
|
17
17
|
import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
|
|
18
18
|
import { runTurn } from './agent/loop.js';
|
|
@@ -44,6 +44,7 @@ import './tools/ask-question.js';
|
|
|
44
44
|
import './tools/dispatch-skill.js';
|
|
45
45
|
import './tools/todo.js';
|
|
46
46
|
import './tools/filesystem/file-read.js';
|
|
47
|
+
import './tools/tool-output-read.js';
|
|
47
48
|
import './tools/filesystem/read-document.js';
|
|
48
49
|
import './tools/filesystem/file-edit.js';
|
|
49
50
|
import './tools/filesystem/file-write.js';
|
|
@@ -65,6 +66,7 @@ import './tools/subagent/background_run.js';
|
|
|
65
66
|
import './tools/subagent/monitor_emit.js';
|
|
66
67
|
import './tools/subagent/schedule_draft_create.js';
|
|
67
68
|
import './tools/subagent/agent_run.js';
|
|
69
|
+
import './tools/subagent/read_agent.js';
|
|
68
70
|
export async function runOneShot(prompt, opts) {
|
|
69
71
|
applyColorMode(); // NO_COLOR (piece 2 §1.2)
|
|
70
72
|
const cfg = await loadConfig();
|
|
@@ -78,10 +80,18 @@ export async function runOneShot(prompt, opts) {
|
|
|
78
80
|
setRenderingOverride(resolveRenderingMode(cfg.ui.rendering).mode);
|
|
79
81
|
setLook(cfg.ui.look);
|
|
80
82
|
setBackground(cfg.ui.background);
|
|
81
|
-
// model-governance step 2d —
|
|
82
|
-
//
|
|
83
|
-
// the built-ins
|
|
84
|
-
|
|
83
|
+
// model-governance step 2d — fetch the served model-config (managed mode
|
|
84
|
+
// only). Task 3: awaited, but startup waits at most 1.5 s (then a fresh disk
|
|
85
|
+
// cache, else the built-ins), so the session model pinned at newSession
|
|
86
|
+
// below comes from the served config. Never throws.
|
|
87
|
+
// Task 12 — the customer layer (/v1/me/model-config, every mode with a
|
|
88
|
+
// key) loads in parallel under the SAME 1.5 s cap. Fails open.
|
|
89
|
+
await Promise.all([
|
|
90
|
+
loadServerModelConfig(cfg, { timeoutMs: 1500 }),
|
|
91
|
+
loadCustomerModelConfig(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
|
|
92
|
+
// Task 16 (F13) — BYOK reads only the served tool_loading (signed in only).
|
|
93
|
+
fetchToolLoadingMode(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
|
|
94
|
+
]);
|
|
85
95
|
// B5 (2026-06-11) — one-shot is headless by design: a single turn, no
|
|
86
96
|
// REPL, often piped (CI / battery). Any stdin prompt would hang forever
|
|
87
97
|
// (defect D1). The headless answer policy (ask_question auto-answers,
|
|
@@ -135,6 +135,14 @@ export function buildUserContent(text, images) {
|
|
|
135
135
|
* hits the 32 MB request cap (and stops paying for stale screenshots).
|
|
136
136
|
*/
|
|
137
137
|
export const MAX_HISTORY_IMAGES = 10;
|
|
138
|
+
/**
|
|
139
|
+
* Task 7 — stub images in batches, not one per turn. Each stub edits an
|
|
140
|
+
* earlier user message, and Opus 5.5 binds every later thinking block to the
|
|
141
|
+
* exact prefix that produced it, so each trim invalidates them (and the prompt
|
|
142
|
+
* cache). Once over `max`, the live count drops to `max - IMAGE_TRIM_BATCH + 1`
|
|
143
|
+
* (6 at max 10), so the next trim is IMAGE_TRIM_BATCH images away.
|
|
144
|
+
*/
|
|
145
|
+
export const IMAGE_TRIM_BATCH = 5;
|
|
138
146
|
/** Mutates `messages`; returns how many image blocks were removed. */
|
|
139
147
|
export function trimHistoryImages(messages, max = MAX_HISTORY_IMAGES) {
|
|
140
148
|
const slots = [];
|
|
@@ -146,10 +154,15 @@ export function trimHistoryImages(messages, max = MAX_HISTORY_IMAGES) {
|
|
|
146
154
|
slots.push({ blocks: m.content, i });
|
|
147
155
|
});
|
|
148
156
|
}
|
|
149
|
-
|
|
157
|
+
if (slots.length <= max)
|
|
158
|
+
return 0;
|
|
159
|
+
// Ruling F11: stub slots.length − (max − batch + 1) ⇒ 11 images at max 10
|
|
160
|
+
// stub the oldest 5. Clamped for a tiny `max` (never keep fewer than 1).
|
|
161
|
+
const keep = Math.max(1, max - IMAGE_TRIM_BATCH + 1);
|
|
162
|
+
const excess = slots.length - keep;
|
|
150
163
|
for (let k = 0; k < excess; k++) {
|
|
151
164
|
const { blocks, i } = slots[k];
|
|
152
165
|
blocks[i] = { type: 'text', text: '[image removed from history]' };
|
|
153
166
|
}
|
|
154
|
-
return
|
|
167
|
+
return excess;
|
|
155
168
|
}
|
|
@@ -23,12 +23,16 @@ export function formatSpendSegment(s) {
|
|
|
23
23
|
return CREDITS_PENDING;
|
|
24
24
|
return formatCredits(s.creditsSessionTotal, s.creditsUnitLabel);
|
|
25
25
|
}
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Store → footer facts. F-L2-1: the transport falls back to the one a write
|
|
28
|
+
* actually stamped; UI fix round (1.1.20): then to one a transport lookup
|
|
29
|
+
* confirmed. A transport the user only typed is never shown.
|
|
30
|
+
*/
|
|
27
31
|
export function footerStateFrom(s) {
|
|
28
32
|
return {
|
|
29
33
|
system: s.system || undefined,
|
|
30
34
|
user: s.user || undefined,
|
|
31
|
-
transport: (s.transport ?? s.lastWriteTransport) || undefined,
|
|
35
|
+
transport: (s.transport ?? s.lastWriteTransport ?? s.confirmedTransport) || undefined,
|
|
32
36
|
standalone: s.standalone === true,
|
|
33
37
|
mode: s.writeMode ?? '',
|
|
34
38
|
spend: formatSpendSegment(s),
|
|
@@ -9,7 +9,7 @@ import stringWidth from 'string-width';
|
|
|
9
9
|
import { getLook } from './look.js';
|
|
10
10
|
import { paint, BRAND_HEX } from './theme.js';
|
|
11
11
|
import { formatAttention } from './notice-shape.js';
|
|
12
|
-
import { truncateToWidth, uiWidth } from './ui-width.js';
|
|
12
|
+
import { truncateToWidth, uiWidth, frameWidth } from './ui-width.js';
|
|
13
13
|
import { sanitizeControlChars } from './sanitize.js';
|
|
14
14
|
import { holdBrand } from './brand-settled.js';
|
|
15
15
|
export { brandSettled } from './brand-settled.js';
|
|
@@ -144,9 +144,11 @@ export function panelsStartTail(p) {
|
|
|
144
144
|
const head = `${panelsStatusLine(p.status)}\n${p.third}\n`;
|
|
145
145
|
return p.notices.length === 0 ? head + '\n' : head + '\n' + p.notices.join('') + '\n';
|
|
146
146
|
}
|
|
147
|
-
export function formatPromptEcho(text,
|
|
147
|
+
export function formatPromptEcho(text, columns) {
|
|
148
148
|
if (getLook() !== 'panels') {
|
|
149
149
|
return chalk.dim('\n──────────────────────────────────\n') + chalk.cyan(`› ${text}\n`);
|
|
150
150
|
}
|
|
151
|
-
|
|
151
|
+
// UI fix round (1.1.20): the rule is the FRAME width — as wide as the input
|
|
152
|
+
// box (frameWidth), not the old fixed 60. `columns` is the terminal width.
|
|
153
|
+
return '\n' + paint('frame', '─'.repeat(frameWidth(columns))) + '\n' + paint('dim', 'You ›') + ` ${text}\n`;
|
|
152
154
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spillLabelFor } from '../lib/spill-labels.js';
|
|
1
2
|
const OBJECT_MAX = 60;
|
|
2
3
|
/**
|
|
3
4
|
* Every value shown in a label goes through here: ANSI escape sequences and
|
|
@@ -48,8 +49,25 @@ const activateObject = (a) => {
|
|
|
48
49
|
const shown = list.slice(0, list.length > 3 ? 2 : 3).join(', ');
|
|
49
50
|
return list.length > 3 ? `${shown} +${list.length - 2} more` : shown;
|
|
50
51
|
};
|
|
51
|
-
/**
|
|
52
|
+
/** One sap_search_object hit line (Task 17): NAME TYPE PACKAGE DESCRIPTION URI ("-" when empty). */
|
|
53
|
+
const SEARCH_HIT_RE = /^\S+ {2}\S+ {2}\S+ {2}.+ {2}(\/\S*|-)$/;
|
|
54
|
+
/** A spilled result's pointer line (tools/result-spill.ts): "[N lines / M chars — full output saved to …]". */
|
|
55
|
+
const SPILL_POINTER_RE = /^\[(\d+) lines \/ \d+ chars — full output saved to .*\]$/;
|
|
56
|
+
/**
|
|
57
|
+
* Hit count of a sap_search_object result: the Task 17 line format (a spilled
|
|
58
|
+
* preview counts from its pointer line), or a legacy JSON array. undefined
|
|
59
|
+
* when it is neither — never a guess.
|
|
60
|
+
*/
|
|
52
61
|
const arrayCount = (r) => {
|
|
62
|
+
if (r === '(no hits)')
|
|
63
|
+
return 0;
|
|
64
|
+
const lines = r.split('\n');
|
|
65
|
+
// Only the LAST line is the pointer — a hit description can't fake a count.
|
|
66
|
+
const spilled = SPILL_POINTER_RE.exec(lines[lines.length - 1] ?? '');
|
|
67
|
+
if (spilled)
|
|
68
|
+
return Number(spilled[1]);
|
|
69
|
+
if (lines.length > 0 && lines.every((l) => SEARCH_HIT_RE.test(l)))
|
|
70
|
+
return lines.length;
|
|
53
71
|
try {
|
|
54
72
|
const v = JSON.parse(r);
|
|
55
73
|
return Array.isArray(v) ? v.length : undefined;
|
|
@@ -62,7 +80,19 @@ const arrayCount = (r) => {
|
|
|
62
80
|
const SEARCH_DEFAULT_MAX = 100;
|
|
63
81
|
const fileCount = (v) => (Array.isArray(v) ? `${v.length} ${v.length === 1 ? 'file' : 'files'}` : '');
|
|
64
82
|
const commandLine = (a) => [str(a['command']) ?? '', ...(Array.isArray(a['args']) ? a['args'].map(str).filter((x) => x !== undefined) : [])].join(' ').trim();
|
|
83
|
+
/** Never the internal spill path: the object the output came from, else "saved output". */
|
|
84
|
+
const savedOutputLabel = (a) => {
|
|
85
|
+
const from = spillLabelFor(raw(a['path']) ?? ''); // lookup only — never displayed
|
|
86
|
+
const offset = typeof a['offset'] === 'number' ? a['offset'] : undefined;
|
|
87
|
+
const limit = typeof a['limit'] === 'number' ? a['limit'] : undefined;
|
|
88
|
+
const range = offset !== undefined && limit !== undefined ? `lines ${offset + 1}–${offset + limit}`
|
|
89
|
+
: offset !== undefined ? `from line ${offset + 1}` : undefined;
|
|
90
|
+
if (from === undefined)
|
|
91
|
+
return { verb: 'Read', object: 'saved output', ...(range ? { detail: range } : {}) };
|
|
92
|
+
return { verb: 'Read', object: from, detail: range ? `saved output · ${range}` : 'saved output' };
|
|
93
|
+
};
|
|
65
94
|
const RULES = {
|
|
95
|
+
tool_output_read: (a) => savedOutputLabel(a),
|
|
66
96
|
file_read: (a, r) => ({ verb: 'Read', object: shortLeft(str(a['path']) ?? ''), ...(r !== undefined ? { detail: `${lineCount(r)} lines` } : {}) }),
|
|
67
97
|
file_write: (a) => ({ verb: 'Wrote', object: shortLeft(str(a['path']) ?? ''), detail: `${lineCount(raw(a['content']) ?? '')} lines` }),
|
|
68
98
|
file_edit: (a) => ({
|
|
@@ -152,6 +182,7 @@ const RULES = {
|
|
|
152
182
|
upgrade_merge_progress: (a) => ({ verb: 'Merged progress', object: fileCount(a['inputs']) }),
|
|
153
183
|
monitor_emit: (a) => ({ verb: 'Checked background', object: shortRight(str(a['bg_id']) ?? '') }),
|
|
154
184
|
agent_run: (a) => ({ verb: 'Subagent', object: shortRight(str(a['skill']) ?? '') }),
|
|
185
|
+
read_agent: (a) => ({ verb: 'Reader', object: shortRight(str(a['brief']) ?? '') }),
|
|
155
186
|
schedule_draft_create: (a) => ({ verb: 'Drafted schedule', object: shortRight(str(a['when']) ?? '') }),
|
|
156
187
|
fiori_scaffold: (a) => ({ verb: 'Scaffolded app', object: shortLeft(str(a['basePath']) ?? '') }),
|
|
157
188
|
fiori_apply: (a) => ({ verb: 'Applied', object: shortRight([str(a['entry']), str(a['appDir'])].filter(Boolean).join(' · ')) }),
|
|
@@ -221,7 +252,7 @@ const PRESENT = {
|
|
|
221
252
|
'Searched web': 'Searching web', 'Read project context': 'Reading project context',
|
|
222
253
|
'Read playbook': 'Reading playbook', 'Read convention': 'Reading convention', 'Merged CCA': 'Merging CCA',
|
|
223
254
|
'Merged progress': 'Merging progress', 'Checked background': 'Checking background',
|
|
224
|
-
Subagent: 'Running subagent', 'Drafted schedule': 'Drafting schedule', Applied: 'Applying',
|
|
255
|
+
Subagent: 'Running subagent', Reader: 'Running reader', 'Drafted schedule': 'Drafting schedule', Applied: 'Applying',
|
|
225
256
|
'Listed patterns': 'Listing patterns', 'Deploy config': 'Writing deploy config', 'Extended app': 'Extending app',
|
|
226
257
|
Preview: 'Preview', 'Searched samples': 'Searching samples', 'Read sample': 'Reading sample',
|
|
227
258
|
'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
|
|
@@ -11,6 +11,19 @@ export function uiWidth(columns) {
|
|
|
11
11
|
const cols = columns ?? process.stdout.columns ?? 80;
|
|
12
12
|
return Math.min(UI_MAX_WIDTH, Math.max(UI_MIN_WIDTH, cols));
|
|
13
13
|
}
|
|
14
|
+
/** Narrowest the frame ever gets (the input box needs its chrome + a few cells). */
|
|
15
|
+
export const FRAME_MIN_WIDTH = 12;
|
|
16
|
+
/**
|
|
17
|
+
* The FRAME width (UI fix round, CLI 1.1.20): the input box, the footer line
|
|
18
|
+
* under it and the rule above each `You ›` echo span the whole terminal —
|
|
19
|
+
* no 100-column cap. The −1 keeps the frame off the exact-terminal-width
|
|
20
|
+
* deferred-wrap edge on Windows Terminal. Text (answers, tool rows, card
|
|
21
|
+
* bodies) still wraps at uiWidth() so it stays readable on wide screens.
|
|
22
|
+
*/
|
|
23
|
+
export function frameWidth(columns) {
|
|
24
|
+
const cols = columns ?? process.stdout.columns ?? 80;
|
|
25
|
+
return Math.max(FRAME_MIN_WIDTH, cols - 1);
|
|
26
|
+
}
|
|
14
27
|
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
15
28
|
/**
|
|
16
29
|
* Cut PLAIN text (no ANSI; paint after truncating) to `width` terminal cells.
|
|
@@ -57,6 +57,19 @@ export function noteWriteTransport(trkorr) {
|
|
|
57
57
|
return;
|
|
58
58
|
globalStore.update({ lastWriteTransport: trkorr });
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Footer-only wire (UI fix round, 1.1.20): record a transport a read-only
|
|
62
|
+
* lookup CONFIRMED (sap_transport_for_object found the object locked in one
|
|
63
|
+
* open transport the connected user may write to). Live incident: the user
|
|
64
|
+
* named S4HK903731, the lookup confirmed it, and the footer still read
|
|
65
|
+
* "no transport". Like noteWriteTransport it never touches the session write
|
|
66
|
+
* pointer — the lookup is read-only and must not change where writes go.
|
|
67
|
+
*/
|
|
68
|
+
export function noteConfirmedTransport(trkorr) {
|
|
69
|
+
if (!trkorr)
|
|
70
|
+
return;
|
|
71
|
+
globalStore.update({ confirmedTransport: trkorr });
|
|
72
|
+
}
|
|
60
73
|
/**
|
|
61
74
|
* Handles the `/transport [<trkorr>|clear]` slash command. Returns user-facing
|
|
62
75
|
* status text. Mirrors the shape of handleSafetyModeCommand in safety-mode-state.ts.
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { computeCost } from '../cost/pricing.js';
|
|
21
21
|
import { computeSessionCost } from '../cost/session-cost.js';
|
|
22
22
|
import { globalStore } from '../ui/sap-state-store.js';
|
|
23
|
+
import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
|
|
23
24
|
import { globalCredits } from '../cost/credits.js';
|
|
24
25
|
import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
|
|
25
26
|
import { shouldUseInk } from '../renderer/tty.js';
|
|
@@ -32,8 +33,14 @@ export function snapshotTurnStart(session) {
|
|
|
32
33
|
cacheRead: session.usage.cache_read_input_tokens,
|
|
33
34
|
cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
|
|
34
35
|
startedAt: Date.now(),
|
|
36
|
+
pausedMs: turnStatusEmitter.pausedMs(),
|
|
35
37
|
};
|
|
36
38
|
}
|
|
39
|
+
/** Wall-clock since the turn started, less the time it spent waiting on the user. */
|
|
40
|
+
export function turnElapsedMs(snapshot, now = Date.now()) {
|
|
41
|
+
const waited = snapshot.pausedMs === undefined ? 0 : turnStatusEmitter.pausedMs(now) - snapshot.pausedMs;
|
|
42
|
+
return Math.max(0, now - snapshot.startedAt - waited);
|
|
43
|
+
}
|
|
37
44
|
/**
|
|
38
45
|
* The per-turn numbers every receipt prints — extracted (piece 2 I3a) so the
|
|
39
46
|
* REPL receipt and the one-shot receipt compute them the same way.
|
|
@@ -54,7 +61,7 @@ export function computeTurnFacts(session, snapshot, turnModel) {
|
|
|
54
61
|
turnCost,
|
|
55
62
|
// F-L2-2: cost/session-cost.ts so the agent loop can push the SAME total mid-stream.
|
|
56
63
|
sessionCost: computeSessionCost(session),
|
|
57
|
-
elapsedMs:
|
|
64
|
+
elapsedMs: turnElapsedMs(snapshot),
|
|
58
65
|
};
|
|
59
66
|
}
|
|
60
67
|
export function pushPostTurnStatus(params) {
|