@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
package/dist/repl.js
CHANGED
|
@@ -36,7 +36,8 @@ import { AdtClient, snapshots } from '@cspeach/sap-client';
|
|
|
36
36
|
import { makeSessionId, saveSession, sessionSaved } from './session/store.js';
|
|
37
37
|
import { newSession } from './session/schema.js';
|
|
38
38
|
import { resolveModelRole } from './models/resolve.js';
|
|
39
|
-
import { loadServerModelConfig } from './models/server-config.js';
|
|
39
|
+
import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
|
|
40
|
+
import { repinResumedSessionModel } from './session/repin-model.js';
|
|
40
41
|
import { computeCost } from './cost/pricing.js';
|
|
41
42
|
import { formatSessionSpendValue } from './repl/session-spend-line.js';
|
|
42
43
|
import { formatGoodbye } from './renderer/goodbye.js';
|
|
@@ -45,6 +46,7 @@ import { initCredits } from './cost/credits-wire.js';
|
|
|
45
46
|
import { createProviderForMode } from './agent/providers/factory.js';
|
|
46
47
|
import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
|
|
47
48
|
import { runTurn } from './agent/loop.js';
|
|
49
|
+
import { IdleKeepalive } from './agent/cache-keepalive.js';
|
|
48
50
|
import { renderTurnError } from './agent/turn-error-ux.js';
|
|
49
51
|
import { runRerouteTurn } from './repl/reroute-turn.js';
|
|
50
52
|
// BUG-2b (2026-07-05 live smoke) — pure (no-Ink) modules, safe to import
|
|
@@ -105,7 +107,7 @@ function prefillReadline(rl, text) {
|
|
|
105
107
|
import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
|
|
106
108
|
import { InkStdinPasteGuard } from './repl/ink-stdin-guard.js';
|
|
107
109
|
import { printHelp } from './commands/help.js';
|
|
108
|
-
import { shouldUseInk, setOneShotOverride
|
|
110
|
+
import { shouldUseInk, setOneShotOverride } from './renderer/tty.js';
|
|
109
111
|
import { applyStartupRendering } from './renderer/rendering-mode.js';
|
|
110
112
|
import { createClassicOutputEmitter } from './renderer/notices.js';
|
|
111
113
|
import { saveConfig } from './config/loader.js';
|
|
@@ -137,6 +139,8 @@ import './tools/ask-question.js';
|
|
|
137
139
|
import './tools/dispatch-skill.js';
|
|
138
140
|
import './tools/todo.js';
|
|
139
141
|
import './tools/filesystem/file-read.js';
|
|
142
|
+
import './tools/tool-output-read.js';
|
|
143
|
+
import { pruneSpillFiles } from './tools/result-spill.js';
|
|
140
144
|
import './tools/filesystem/read-document.js';
|
|
141
145
|
import './tools/filesystem/file-edit.js';
|
|
142
146
|
import './tools/filesystem/file-write.js';
|
|
@@ -158,6 +162,7 @@ import './tools/subagent/background_run.js';
|
|
|
158
162
|
import './tools/subagent/monitor_emit.js';
|
|
159
163
|
import './tools/subagent/schedule_draft_create.js';
|
|
160
164
|
import './tools/subagent/agent_run.js';
|
|
165
|
+
import './tools/subagent/read_agent.js';
|
|
161
166
|
const HISTORY_SIZE = 500;
|
|
162
167
|
// Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
|
|
163
168
|
// the single writer shared with the Ink Footer (Task 8, ux-wave1).
|
|
@@ -527,11 +532,22 @@ export async function runRepl(opts) {
|
|
|
527
532
|
cfg = await loadConfig();
|
|
528
533
|
}
|
|
529
534
|
}
|
|
530
|
-
// model-governance step 2d —
|
|
531
|
-
//
|
|
532
|
-
// fetch
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
+
// model-governance step 2d — fetch the served model-config (managed mode
|
|
536
|
+
// only). Task 3 (2026-09-27): AWAITED, but startup waits at most 1.5 s — a
|
|
537
|
+
// slower fetch aborts and falls back to a fresh disk cache, else the
|
|
538
|
+
// built-ins. The session model is resolved ONCE at newSession below and
|
|
539
|
+
// pinned, so call 0 and call 1 never run on different models (a switch
|
|
540
|
+
// forces a full cache rewrite). Precedence: env > local config > server >
|
|
541
|
+
// built-in. Never throws.
|
|
542
|
+
// Task 12 — the customer layer (/v1/me/model-config, every mode with a
|
|
543
|
+
// key) loads in parallel under the SAME 1.5 s cap: no extra wait. Final
|
|
544
|
+
// precedence: env > local > customer > server > built-in. Fails open.
|
|
545
|
+
await Promise.all([
|
|
546
|
+
loadServerModelConfig(cfg, { timeoutMs: 1500 }),
|
|
547
|
+
loadCustomerModelConfig(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
|
|
548
|
+
// Task 16 (F13) — BYOK reads only the served tool_loading (signed in only).
|
|
549
|
+
fetchToolLoadingMode(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
|
|
550
|
+
]);
|
|
535
551
|
// Credits (2026-09-16) — resolve ONCE, before any cost figure can render.
|
|
536
552
|
// A managed customer is billed in credits at a multiplier we do not
|
|
537
553
|
// disclose, so the dollar figures (our Anthropic COGS) must never reach
|
|
@@ -688,6 +704,10 @@ export async function runRepl(opts) {
|
|
|
688
704
|
// preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
|
|
689
705
|
// turnInterrupted flag from the prior run. Skipping newSession() avoids
|
|
690
706
|
// overwriting the saved file with an empty fresh one.
|
|
707
|
+
// Task 3 fix I1 — except the model: a resumed session re-resolves it ONCE
|
|
708
|
+
// through the same role resolution new sessions use (after the awaited
|
|
709
|
+
// served config), marks it 'predicted', saves, then stays pinned. Every
|
|
710
|
+
// resume entry (picker, --resume <id>) lands here via session/resume.ts.
|
|
691
711
|
const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg));
|
|
692
712
|
// Round 6 item 4c — a resumed session keeps its skill for the first plain prompt.
|
|
693
713
|
let resumeKeepsSkill = Boolean(opts?.resumedSession?.skill);
|
|
@@ -710,9 +730,16 @@ export async function runRepl(opts) {
|
|
|
710
730
|
console.log(line.trimEnd());
|
|
711
731
|
},
|
|
712
732
|
});
|
|
733
|
+
// Merge (cost branch Task 3 + UI round 6) — a resumed session re-pins its
|
|
734
|
+
// model from the current config (after any --sap-alias switch above).
|
|
735
|
+
if (opts?.resumedSession) {
|
|
736
|
+
await repinResumedSessionModel(session, cfg);
|
|
737
|
+
}
|
|
713
738
|
// Live fix 2026-09-26 — notices cut to one screen line keep their full text here.
|
|
714
739
|
// Review M6: sanitised id; notice logs older than 14 days are collected.
|
|
715
740
|
pruneNoticeLogs(logsDir());
|
|
741
|
+
// Task 17 — saved large tool outputs (tool-*.txt) older than 14 days go too.
|
|
742
|
+
pruneSpillFiles();
|
|
716
743
|
setNoticeLogPath(noticeLogPathFor(logsDir(), session.id));
|
|
717
744
|
// License gate: non-managed modes (byok/local/ai-hub) bypass our proxy for
|
|
718
745
|
// inference, so without this anyone who never logged in could use the bundled
|
|
@@ -733,6 +760,13 @@ export async function runRepl(opts) {
|
|
|
733
760
|
throw new Error('No API key configured');
|
|
734
761
|
return key;
|
|
735
762
|
});
|
|
763
|
+
// Task 15 — keep the prompt cache warm while the user is idle between
|
|
764
|
+
// turns: armed after a turn, disarmed on the next submit and on exit;
|
|
765
|
+
// `[keepalive] idle_pings` per idle period (managed / BYOK only).
|
|
766
|
+
const idleKeepalive = new IdleKeepalive({
|
|
767
|
+
provider,
|
|
768
|
+
maxPings: () => (cfg.keepalive?.enabled === false ? 0 : (cfg.keepalive?.idle_pings ?? 4)),
|
|
769
|
+
});
|
|
736
770
|
// Panels status line — writeMode is the same EFFECTIVE (role-clamped) mode
|
|
737
771
|
// the legacy line prints.
|
|
738
772
|
const panelsStatus = {
|
|
@@ -941,7 +975,7 @@ export async function runRepl(opts) {
|
|
|
941
975
|
// new turn's content touched with no separator and looked like
|
|
942
976
|
// garbled overlap).
|
|
943
977
|
if (trimmed !== '/exit' && trimmed !== '/quit') {
|
|
944
|
-
chunkEmitter.emit('chunk', formatPromptEcho(trimmed,
|
|
978
|
+
chunkEmitter.emit('chunk', formatPromptEcho(trimmed, process.stdout.columns || 80));
|
|
945
979
|
}
|
|
946
980
|
if (trimmed === '/exit' || trimmed === '/quit') {
|
|
947
981
|
done = true;
|
|
@@ -1042,7 +1076,7 @@ export async function runRepl(opts) {
|
|
|
1042
1076
|
return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
|
|
1043
1077
|
// model-governance step 2d — compact model resolves env > local >
|
|
1044
1078
|
// server > built-in (== COMPACTION_MODEL when nothing set).
|
|
1045
|
-
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
|
|
1079
|
+
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' } });
|
|
1046
1080
|
};
|
|
1047
1081
|
try {
|
|
1048
1082
|
chunkEmitter.emit('chunk', '\n' + formatInfo('Compacting older turns into a summary…'));
|
|
@@ -1123,6 +1157,7 @@ export async function runRepl(opts) {
|
|
|
1123
1157
|
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
1124
1158
|
chunkEmitter,
|
|
1125
1159
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
1160
|
+
autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
|
|
1126
1161
|
signal: rerouteAbort.signal,
|
|
1127
1162
|
}),
|
|
1128
1163
|
session,
|
|
@@ -1638,8 +1673,13 @@ export async function runRepl(opts) {
|
|
|
1638
1673
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
1639
1674
|
signal: currentTurnAbort.signal,
|
|
1640
1675
|
suppressSaveHook: planResume !== null || planContinue !== null,
|
|
1676
|
+
// Task 19 — auto-compact at turn end, except plan resume/continue
|
|
1677
|
+
// turns (their post-turn persist indexes messages from turn start).
|
|
1678
|
+
autoCompact: planResume === null && planContinue === null,
|
|
1641
1679
|
// A4 — undefined on every non-plan turn (and when tiering is off).
|
|
1642
1680
|
modelOverride: planModelOverride,
|
|
1681
|
+
// Task 9 — a tier-down, never the session role (proxy must not enforce it).
|
|
1682
|
+
modelRole: planModelOverride ? 'audit' : undefined,
|
|
1643
1683
|
// Persist the plan revision the instant its manifest is finalised
|
|
1644
1684
|
// (before the continuation modal) so a Ctrl+C can't lose the phase.
|
|
1645
1685
|
onPlanManifest: planResumeForHook
|
|
@@ -1948,6 +1988,7 @@ export async function runRepl(opts) {
|
|
|
1948
1988
|
}
|
|
1949
1989
|
return;
|
|
1950
1990
|
}
|
|
1991
|
+
idleKeepalive.disarm(); // Task 15 — a submit ends the idle period
|
|
1951
1992
|
turnStatusEmitter.reserve();
|
|
1952
1993
|
postTurn = false;
|
|
1953
1994
|
postTurnMayChain = false;
|
|
@@ -1972,6 +2013,11 @@ export async function runRepl(opts) {
|
|
|
1972
2013
|
if (resolveDone)
|
|
1973
2014
|
resolveDone();
|
|
1974
2015
|
}
|
|
2016
|
+
// Task 15 — back at the prompt: warm the cache while the user reads
|
|
2017
|
+
// (no-op unless this submit made a new cacheable request). Not when
|
|
2018
|
+
// the submit ended in an exit (merge: UI round-3 /exit mid-turn).
|
|
2019
|
+
if (!done)
|
|
2020
|
+
idleKeepalive.armAfterTurn(session);
|
|
1975
2021
|
}
|
|
1976
2022
|
};
|
|
1977
2023
|
// Fix round 3 (item 5) — `/exit` / `/quit` typed while a turn runs:
|
|
@@ -2160,6 +2206,7 @@ export async function runRepl(opts) {
|
|
|
2160
2206
|
const exitTail = await settleWithin(dispatchTail, EXIT_TAIL_CAP_MS);
|
|
2161
2207
|
unmount();
|
|
2162
2208
|
setTraceEmitter(undefined);
|
|
2209
|
+
idleKeepalive.disarm(); // Task 15 — no pings after /exit
|
|
2163
2210
|
// Bug 2 (wave1 live-fix) — undo the paste plumbing so the user's
|
|
2164
2211
|
// shell doesn't keep receiving marker-wrapped pastes after exit.
|
|
2165
2212
|
process.stdin.unpipe(inkStdin);
|
|
@@ -2514,8 +2561,12 @@ export async function runRepl(opts) {
|
|
|
2514
2561
|
}
|
|
2515
2562
|
else {
|
|
2516
2563
|
const effectivePrompt = pendingPrefill ? promptStr + pendingPrefill : promptStr;
|
|
2564
|
+
// Task 15 — idle at the prompt: warm the cache (no-op unless the last
|
|
2565
|
+
// turn made a new cacheable request); disarmed once the line arrives.
|
|
2566
|
+
idleKeepalive.armAfterTurn(session);
|
|
2517
2567
|
try {
|
|
2518
2568
|
line = await rl.question(effectivePrompt);
|
|
2569
|
+
idleKeepalive.disarm();
|
|
2519
2570
|
}
|
|
2520
2571
|
catch {
|
|
2521
2572
|
// rl.question rejects for two distinct reasons:
|
|
@@ -2529,6 +2580,7 @@ export async function runRepl(opts) {
|
|
|
2529
2580
|
// discriminates the two cases reliably.
|
|
2530
2581
|
const rlAny = rl;
|
|
2531
2582
|
if (rlAny.closed) {
|
|
2583
|
+
idleKeepalive.disarm();
|
|
2532
2584
|
// UX-5 #5 — piped stdin: a multi-line chunk can resolve the
|
|
2533
2585
|
// pending question with line 1 and QUEUE line 2+ before EOF
|
|
2534
2586
|
// closes the readline. Drain the queue before exiting.
|
|
@@ -2686,7 +2738,7 @@ export async function runRepl(opts) {
|
|
|
2686
2738
|
return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
|
|
2687
2739
|
// model-governance step 2d — compact model resolves env > local >
|
|
2688
2740
|
// server > built-in (== COMPACTION_MODEL when nothing set).
|
|
2689
|
-
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
|
|
2741
|
+
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' } });
|
|
2690
2742
|
};
|
|
2691
2743
|
try {
|
|
2692
2744
|
console.log('\n' + formatInfo('Compacting older turns into a summary…').trimEnd());
|
|
@@ -2766,6 +2818,7 @@ export async function runRepl(opts) {
|
|
|
2766
2818
|
skill: rr.skill,
|
|
2767
2819
|
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
2768
2820
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
2821
|
+
autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
|
|
2769
2822
|
}),
|
|
2770
2823
|
session,
|
|
2771
2824
|
print: (line) => console.error(line),
|
|
@@ -3264,6 +3317,8 @@ export async function runRepl(opts) {
|
|
|
3264
3317
|
let planSubagentDispatches;
|
|
3265
3318
|
const planResumeForHook = planResume;
|
|
3266
3319
|
const turnStart = Date.now();
|
|
3320
|
+
// UI fix round (1.1.20) — card waits are excluded from the receipt's time.
|
|
3321
|
+
const turnStartPausedMs = turnStatusEmitter.pausedMs();
|
|
3267
3322
|
// H1 — `session.usage` is session-LIFETIME (initialised once in
|
|
3268
3323
|
// newSession, saved across turns). The footer's "this turn" label was
|
|
3269
3324
|
// misleading — it actually showed cumulative lifetime tokens. Snapshot
|
|
@@ -3346,8 +3401,13 @@ export async function runRepl(opts) {
|
|
|
3346
3401
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
3347
3402
|
signal: turnAbort.signal,
|
|
3348
3403
|
suppressSaveHook: planResume !== null || planContinue !== null,
|
|
3404
|
+
// Task 19 — auto-compact at turn end, except plan resume/continue
|
|
3405
|
+
// turns (their post-turn persist indexes messages from turn start).
|
|
3406
|
+
autoCompact: planResume === null && planContinue === null,
|
|
3349
3407
|
// A4 — undefined on every non-plan turn (and when tiering is off).
|
|
3350
3408
|
modelOverride: planModelOverride,
|
|
3409
|
+
// Task 9 — a tier-down, never the session role (proxy must not enforce it).
|
|
3410
|
+
modelRole: planModelOverride ? 'audit' : undefined,
|
|
3351
3411
|
onPlanManifest: planResumeForHook
|
|
3352
3412
|
? async (assistantText) => {
|
|
3353
3413
|
try {
|
|
@@ -3579,6 +3639,7 @@ export async function runRepl(opts) {
|
|
|
3579
3639
|
cacheRead: turnStartCostSnapshot.cacheRead,
|
|
3580
3640
|
cacheCreate: turnStartCostSnapshot.cacheCreate,
|
|
3581
3641
|
startedAt: turnStart,
|
|
3642
|
+
pausedMs: turnStartPausedMs,
|
|
3582
3643
|
},
|
|
3583
3644
|
// A4 — price the turn at the model that actually ran it (classic-path
|
|
3584
3645
|
// mirror of the Ink call site above).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 3 fix round 1 (I1, 2026-09-27) — re-pin a resumed session's model.
|
|
3
|
+
*
|
|
4
|
+
* The agent loop sends the session's PINNED `session.model` on every call.
|
|
5
|
+
* A resumed session carries the id saved at creation (possibly retired, or
|
|
6
|
+
* an id the served config has since moved away from), so on resume the
|
|
7
|
+
* model is re-resolved ONCE through the same path new sessions use —
|
|
8
|
+
* resolveModelRole('session_default', cfg), after the awaited served config —
|
|
9
|
+
* marked 'predicted', and saved. It then stays pinned for the rest of the
|
|
10
|
+
* session (managed-mode adoption in the loop may still update it).
|
|
11
|
+
*/
|
|
12
|
+
import { resolveModelRole } from '../models/resolve.js';
|
|
13
|
+
import { saveSession } from './store.js';
|
|
14
|
+
export async function repinResumedSessionModel(session, cfg) {
|
|
15
|
+
session.model = resolveModelRole('session_default', cfg);
|
|
16
|
+
session.served_model_source = 'predicted';
|
|
17
|
+
await saveSession(session);
|
|
18
|
+
}
|
package/dist/session/store.js
CHANGED
|
@@ -87,7 +87,8 @@ function validateSchema(parsed) {
|
|
|
87
87
|
if (!Array.isArray(msg.content))
|
|
88
88
|
continue;
|
|
89
89
|
for (const block of msg.content) {
|
|
90
|
-
|
|
90
|
+
// Task 16 (F6): server_tool_use (tool search) streams input the same way.
|
|
91
|
+
if ((block?.type === 'tool_use' || block?.type === 'server_tool_use') && typeof block.partial_json === 'string') {
|
|
91
92
|
if (block.input === undefined) {
|
|
92
93
|
try {
|
|
93
94
|
block.input = JSON.parse(block.partial_json);
|
|
@@ -108,6 +109,14 @@ function validateSchema(parsed) {
|
|
|
108
109
|
dropTrailingThinkingOnly(parsed.messages);
|
|
109
110
|
return parsed;
|
|
110
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Task 18 — a reader subagent (tools/subagent/read_agent.ts) saves its own
|
|
114
|
+
* `reader-*` session file; it is an internal child of a turn, not something
|
|
115
|
+
* the user resumes.
|
|
116
|
+
*/
|
|
117
|
+
export function isReaderSessionId(id) {
|
|
118
|
+
return typeof id === 'string' && id.startsWith('reader-');
|
|
119
|
+
}
|
|
111
120
|
/** How many sessions `cspeach --resume` lists — the unfinished count uses the same window. */
|
|
112
121
|
export const RESUME_PICKER_LIMIT = 20;
|
|
113
122
|
/**
|
|
@@ -119,7 +128,10 @@ export const RESUME_PICKER_LIMIT = 20;
|
|
|
119
128
|
* full pass cost ~1.3 s at startup with 4,410 files).
|
|
120
129
|
*/
|
|
121
130
|
async function recentSessionFiles(dir, limit) {
|
|
122
|
-
|
|
131
|
+
// Merge note — Task 18 reader-* child sessions are skipped by NAME here, so a
|
|
132
|
+
// burst of reader files can never crowd real sessions out of the window.
|
|
133
|
+
const files = (await fs.readdir(dir).catch(() => []))
|
|
134
|
+
.filter((f) => f.endsWith('.json') && !f.includes('-reader-'));
|
|
123
135
|
const cap = Math.max(limit * 3, 50);
|
|
124
136
|
if (files.length <= cap)
|
|
125
137
|
return files;
|
|
@@ -146,6 +158,8 @@ export async function listSessions(limit = 10, opts = {}) {
|
|
|
146
158
|
try {
|
|
147
159
|
const raw = await fs.readFile(path.join(dir, f), 'utf-8');
|
|
148
160
|
const parsed = JSON.parse(raw);
|
|
161
|
+
if (isReaderSessionId(parsed.id))
|
|
162
|
+
continue; // Task 18 — internal child, never resumed
|
|
149
163
|
const messages = parsed.messages ?? [];
|
|
150
164
|
// First user message that's a plain string (not a tool_result array) — the
|
|
151
165
|
// most recent prompt text that initiated a turn. We pick the LAST such
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED FILE — bundled skills for local mode.
|
|
2
2
|
// Source: https://manifest.cspeach.dev/v1.json
|
|
3
|
-
// Generated: 2026-09-
|
|
3
|
+
// Generated: 2026-09-28T21:15:10.654Z
|
|
4
4
|
// Manifest signature verified at build time.
|
|
5
5
|
export const BUNDLED_SKILLS = {
|
|
6
6
|
"abap-atc-fix": {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI copy of cspeach-proxy/src/skills/preamble.ts FORGE_PRINCIPLES_PREAMBLE.
|
|
3
|
+
*
|
|
4
|
+
* Task 5 (2026-09-27, ruling F3): BYOK, AI-hub and local build the system
|
|
5
|
+
* prompt client-side, so they need the same preamble the proxy prepends for
|
|
6
|
+
* managed turns. src/skills/__tests__/preamble-pin.test.ts asserts the two
|
|
7
|
+
* template literals stay byte-equal — edit both files together.
|
|
8
|
+
*/
|
|
9
|
+
export const FORGE_PRINCIPLES_PREAMBLE = `# CSPeach Principles
|
|
10
|
+
|
|
11
|
+
You are operating inside CSPeach, an AI-assisted ABAP development tool with
|
|
12
|
+
direct access to the user's SAP system via ADT. Follow these principles on
|
|
13
|
+
every turn. The active skill may specialize your behavior; these principles
|
|
14
|
+
take precedence if a skill's instruction conflicts with them.
|
|
15
|
+
|
|
16
|
+
## Correctness over speed
|
|
17
|
+
- Read before writing. Use sap_get_source and sap_object_structure to ground
|
|
18
|
+
your understanding in the real code before proposing changes.
|
|
19
|
+
- Never hallucinate method names, field names, parameter types, or table
|
|
20
|
+
structures. If you're not sure, look it up.
|
|
21
|
+
- When modifying method logic without changing the class definition
|
|
22
|
+
(no new/removed methods, no signature changes), use sap_update_method
|
|
23
|
+
per method. Do NOT rewrite the full class with sap_set_source.
|
|
24
|
+
- Batch independent reads in one response.
|
|
25
|
+
- When a result says it was saved to a file, read the slice you need with
|
|
26
|
+
tool_output_read(path, offset, limit); do not re-run the command.
|
|
27
|
+
|
|
28
|
+
## Heavy reading
|
|
29
|
+
- Send more than two source reads, any where-used scan, ATC-finding triage or
|
|
30
|
+
a package inventory to a reader: call read_agent with a precise brief (what
|
|
31
|
+
to read, what to report) and work from its report. Do not read those sources
|
|
32
|
+
yourself when the turn will keep working afterwards.
|
|
33
|
+
- Do not use a reader for one or two reads, a small edit, or to check your own
|
|
34
|
+
work. Never more than three readers for one task unless the user asks. Send
|
|
35
|
+
independent readers in one response so they run at the same time.
|
|
36
|
+
|
|
37
|
+
## Safety gates are mandatory
|
|
38
|
+
- Mutating tools (sap_set_source, sap_update_method, sap_delete_object,
|
|
39
|
+
sap_activate, sap_create_object, sap_transport_*, sap_message_maintain,
|
|
40
|
+
sap_number_range_intervals, sap_service_binding_publish) require a valid
|
|
41
|
+
approval_id from request_approval. You cannot bypass this.
|
|
42
|
+
- Snapshots are taken automatically before every write — do not ask the
|
|
43
|
+
user to confirm snapshots.
|
|
44
|
+
- After every write, syntax is checked and activation is verified. You
|
|
45
|
+
will see results as tool_results. Do not activate if syntax errors exist.
|
|
46
|
+
|
|
47
|
+
## Plan before batch
|
|
48
|
+
- If you will modify more than one object, call request_approval with all
|
|
49
|
+
changes in a single changes[] array first. The user sees a holistic plan
|
|
50
|
+
before per-change prompts.
|
|
51
|
+
- If a plan is rejected, ask the user what to change. Do not silently
|
|
52
|
+
revise and re-submit.
|
|
53
|
+
|
|
54
|
+
## Transport discipline
|
|
55
|
+
- All writes go into the active session transport. Do not ask the user
|
|
56
|
+
for a transport number per change.
|
|
57
|
+
- Never use \$TMP unless the user explicitly requests local-only work.
|
|
58
|
+
|
|
59
|
+
## Escalate, don't fail silently
|
|
60
|
+
- If a tool returns an error you don't know how to recover from, surface
|
|
61
|
+
the error to the user with a clear explanation and ask how to proceed.
|
|
62
|
+
- If ATC or syntax errors block activation, fix them or ask — never
|
|
63
|
+
suppress or hide.
|
|
64
|
+
- Stop and ask the user before a 4th attempt on the same operation. Do not keep retrying silently.
|
|
65
|
+
|
|
66
|
+
## Ask when unsure
|
|
67
|
+
- For ambiguous requests, ask one clarifying question before acting.
|
|
68
|
+
- For destructive operations on production-namespaced objects, confirm
|
|
69
|
+
intent in text even if approval_id is granted.
|
|
70
|
+
|
|
71
|
+
## One skill per turn
|
|
72
|
+
- If the user's request is better served by a different skill, call
|
|
73
|
+
switch_skill with a clear reason. Do not try to do the work of another
|
|
74
|
+
skill yourself.
|
|
75
|
+
`;
|
|
@@ -2,6 +2,11 @@ import { ManifestClient } from './manifest-client.js';
|
|
|
2
2
|
export function createManifestSkillSource(manifestUrl) {
|
|
3
3
|
const client = new ManifestClient(manifestUrl);
|
|
4
4
|
let cachedManifest = null;
|
|
5
|
+
// Task 5 (2026-09-27) — BYOK / AI-hub fetch the skill body on every model
|
|
6
|
+
// call (the system prompt is built client-side). Cache the sha-verified body
|
|
7
|
+
// for the source's lifetime, like the manifest, so each tool-loop round does
|
|
8
|
+
// not hit the network. Failures are not cached.
|
|
9
|
+
const bodyCache = new Map();
|
|
5
10
|
async function getManifest() {
|
|
6
11
|
if (!cachedManifest)
|
|
7
12
|
cachedManifest = await client.fetchAndVerifyManifest();
|
|
@@ -13,7 +18,12 @@ export function createManifestSkillSource(manifestUrl) {
|
|
|
13
18
|
const entry = m.skills.find(s => s.name === name);
|
|
14
19
|
if (!entry)
|
|
15
20
|
throw new Error(`skill '${name}' not found in manifest (available: ${m.skills.map(s => s.name).join(', ')})`);
|
|
16
|
-
|
|
21
|
+
const cached = bodyCache.get(name);
|
|
22
|
+
if (cached && cached.sha256 === entry.sha256)
|
|
23
|
+
return cached;
|
|
24
|
+
const body = await client.fetchSkillBody(name, entry.body_url, { expectedSha256: entry.sha256 });
|
|
25
|
+
bodyCache.set(name, body);
|
|
26
|
+
return body;
|
|
17
27
|
},
|
|
18
28
|
async listAvailableSkills() {
|
|
19
29
|
const m = await getManifest();
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { promises as fs } from 'node:fs';
|
|
12
12
|
import { registerTool } from '../index.js';
|
|
13
13
|
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath } from '../_filesystem-shared.js';
|
|
14
|
+
import { spillToolResult, isUnderSpillRoot } from '../result-spill.js';
|
|
15
|
+
import { basename } from 'node:path';
|
|
14
16
|
const MAX_LINES = 2000;
|
|
15
17
|
export async function fileReadHandler(args, ctx) {
|
|
16
18
|
// Validate offset and limit BEFORE clamping
|
|
@@ -68,7 +70,15 @@ export async function fileReadHandler(args, ctx) {
|
|
|
68
70
|
const trailer = truncated
|
|
69
71
|
? `\n... (truncated, ${allLines.length - (offset + window.length)} more lines — re-read with offset=${offset + window.length})`
|
|
70
72
|
: '';
|
|
71
|
-
|
|
73
|
+
// Task 17 (spec D12) — a window above 8 000 chars is saved to the session
|
|
74
|
+
// spill file and previewed; today's trailer stays last, byte-identical. A
|
|
75
|
+
// file already under the spill root is a slice already: never re-spilled.
|
|
76
|
+
// The spill keeps the FILE's line numbers (a `.n.txt` spill, which
|
|
77
|
+
// tool_output_read shows unnumbered-again) — one numbering space.
|
|
78
|
+
const body = isUnderSpillRoot(realAbs)
|
|
79
|
+
? numbered
|
|
80
|
+
: spillToolResult({ content: numbered }, ctx, 'file', { numbered: true, label: basename(realAbs) }).content;
|
|
81
|
+
return { content: body + trailer };
|
|
72
82
|
}
|
|
73
83
|
registerTool({
|
|
74
84
|
name: 'file_read',
|