@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
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, getEffectiveBodyWidth } from './renderer/tty.js';
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 — fire-and-forget fetch of the served model-config
531
- // (managed mode only). NEVER blocks startup: a turn that starts before the
532
- // fetch lands simply uses the built-ins (fail-safe). Precedence when it does
533
- // land: env > local config > server > built-in.
534
- void loadServerModelConfig(cfg);
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, getEffectiveBodyWidth()));
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
+ }
@@ -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
- if (block?.type === 'tool_use' && typeof block.partial_json === 'string') {
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
- const files = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
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-28T17:36:32.510Z
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
- return client.fetchSkillBody(name, entry.body_url, { expectedSha256: entry.sha256 });
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
- return { content: numbered + trailer };
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',