@cspeach/cli 1.1.18 → 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/README.md +3 -4
- 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 +804 -159
- 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 +12 -2
- package/dist/agent/repair-partial.js +125 -7
- package/dist/agent/summarise-via-provider.js +6 -1
- package/dist/agent/system-prompt.js +38 -0
- package/dist/agent/tool-dispatch.js +66 -21
- package/dist/agent/tool-loading-pin.js +100 -0
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +36 -6
- package/dist/commands/auto-compact.js +33 -16
- package/dist/commands/compact.js +49 -11
- package/dist/commands/config-set.js +16 -3
- package/dist/commands/config-show.js +18 -5
- package/dist/commands/cost.js +54 -35
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-audit.js +1 -0
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +70 -5
- package/dist/cost/cost-log.js +62 -2
- package/dist/cost/pricing.js +11 -6
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lib/spill-labels.js +13 -0
- package/dist/lock-contention.js +2 -2
- package/dist/models/resolve.js +93 -2
- package/dist/models/server-config.js +158 -3
- package/dist/one-shot.js +150 -45
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/image-attachments.js +15 -2
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +100 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +154 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +269 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +55 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/current-transport.js +13 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +43 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +863 -339
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/repin-model.js +18 -0
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +84 -28
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/skills/preamble.js +75 -0
- package/dist/skills/source-manifest.js +11 -1
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-read.js +11 -1
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/result-spill.js +238 -0
- package/dist/tools/sap-read.js +74 -15
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +39 -12
- 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/syntax-state.js +67 -0
- package/dist/tools/todo.js +26 -19
- package/dist/tools/tool-loading.js +255 -0
- package/dist/tools/tool-output-read.js +117 -0
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +39 -8
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +2 -1
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +65 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +12 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { TOOL_CHANGES_BETA, isToolSearchEntry } from '../tools/tool-loading.js';
|
|
2
|
+
/** Betas the proxy sends on every managed turn (messages.ts `betas: [...]`). */
|
|
3
|
+
export const BASE_BETAS = ['compact-2026-01-12'];
|
|
4
|
+
/** Task 6 — beta that enables `fallbacks: 'default'` (server-side fallback). */
|
|
5
|
+
export const FALLBACK_BETA = 'server-side-fallback-2026-07-01';
|
|
6
|
+
/**
|
|
7
|
+
* Controller ruling (Task 6): an ALLOW-list of the families probe P3 proved
|
|
8
|
+
* accept `fallbacks: 'default'`. Exact match after lowercasing and stripping a
|
|
9
|
+
* trailing -YYYYMMDD — so claude-opus-4-8, a future claude-opus-5-9, or a local
|
|
10
|
+
* alias get no fallbacks and no fallback beta. Mirrors the proxy's
|
|
11
|
+
* models/config.ts modelAcceptsFallbacks.
|
|
12
|
+
*/
|
|
13
|
+
export const FALLBACK_MODELS = new Set([
|
|
14
|
+
'claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-4-6', 'claude-haiku-4-5',
|
|
15
|
+
]);
|
|
16
|
+
export function modelAcceptsFallbacks(model) {
|
|
17
|
+
return FALLBACK_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Task 7 — beta that enables `thinking.block_binding` (preserved thinking:
|
|
21
|
+
* `prefix_mismatch_behavior: 'drop_block'` drops a thinking block whose prefix
|
|
22
|
+
* changed instead of rejecting the request).
|
|
23
|
+
*/
|
|
24
|
+
export const BINDING_BETA = 'thinking-binding-controls-2026-08-01';
|
|
25
|
+
/**
|
|
26
|
+
* Task 7 — ALLOW-list of the families probe P4 proved accept
|
|
27
|
+
* `thinking.block_binding` with BINDING_BETA. Same matching as
|
|
28
|
+
* FALLBACK_MODELS (lowercased, trailing -YYYYMMDD stripped): claude-haiku-4-5*
|
|
29
|
+
* and claude-opus-4-8 were not probed for it, so they get neither the field
|
|
30
|
+
* nor the beta. Mirrors the proxy's models/config.ts modelAcceptsBinding.
|
|
31
|
+
*/
|
|
32
|
+
export const BINDING_MODELS = new Set([
|
|
33
|
+
'claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-4-6',
|
|
34
|
+
]);
|
|
35
|
+
export function modelAcceptsBinding(model) {
|
|
36
|
+
return BINDING_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Task 8 (ruling F5, spec D9) — ALLOW-list of the families probe P2 proved
|
|
40
|
+
* accept `max_tokens: 65536` (claude-opus-5, claude-opus-5-5). Same matching
|
|
41
|
+
* as FALLBACK_MODELS (lowercased, trailing -YYYYMMDD stripped), so a future
|
|
42
|
+
* claude-opus-5-9 keeps the 32768 default until it is probed.
|
|
43
|
+
*/
|
|
44
|
+
export const MAX_TOKENS_65K_MODELS = new Set([
|
|
45
|
+
'claude-opus-5-5', 'claude-opus-5',
|
|
46
|
+
]);
|
|
47
|
+
export const SESSION_MAX_TOKENS_DEFAULT = 32768;
|
|
48
|
+
export const SESSION_MAX_TOKENS_OPUS5 = 65536;
|
|
49
|
+
export function sessionMaxTokens(model) {
|
|
50
|
+
return MAX_TOKENS_65K_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''))
|
|
51
|
+
? SESSION_MAX_TOKENS_OPUS5
|
|
52
|
+
: SESSION_MAX_TOKENS_DEFAULT;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Integration finding I1 (2026-09-28) — the per-model OUTPUT CEILING every
|
|
56
|
+
* request's max_tokens is clamped to, maxTokensOverride included (agent_run
|
|
57
|
+
* children default to 100000). Only the 65k allow-list above is proven;
|
|
58
|
+
* every other model (claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5,
|
|
59
|
+
* unknown ids) is capped at 32768. Dated ids are stripped. Without this an
|
|
60
|
+
* enforced sonnet/haiku child sent 100000 and the API answered 400.
|
|
61
|
+
*/
|
|
62
|
+
export function modelOutputCeiling(model) {
|
|
63
|
+
return sessionMaxTokens(model);
|
|
64
|
+
}
|
|
65
|
+
export function clampMaxTokens(model, requested) {
|
|
66
|
+
return Math.min(requested, modelOutputCeiling(model));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Task 16 — the beta for `role: 'system'` `tool_addition` messages (sent
|
|
70
|
+
* whenever a request declares a deferred tool or carries a system message)
|
|
71
|
+
* and the tool search predicate: one definition each, in tools/tool-loading.
|
|
72
|
+
*/
|
|
73
|
+
export { TOOL_CHANGES_BETA, isToolSearchEntry };
|
|
74
|
+
function isDeferredEntry(t) {
|
|
75
|
+
return !!t && typeof t === 'object' && t.defer_loading === true;
|
|
76
|
+
}
|
|
77
|
+
/** Task 16 — a tool the request loads in full (neither deferred nor the search tool). */
|
|
78
|
+
export function isLoadedToolEntry(t) {
|
|
79
|
+
return !isDeferredEntry(t) && !isToolSearchEntry(t);
|
|
80
|
+
}
|
|
81
|
+
function hasSystemMessage(messages) {
|
|
82
|
+
return (messages ?? []).some((m) => m?.role === 'system');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Betas matching the fields present in a shaped request. The beta follows the
|
|
86
|
+
* field: the model is gated where the field is set (`fallbacks` in BYOK
|
|
87
|
+
* shaping, `thinking.block_binding` in the loop), not here.
|
|
88
|
+
*/
|
|
89
|
+
export function requiredBetas(params) {
|
|
90
|
+
const betas = [...BASE_BETAS];
|
|
91
|
+
if (params.fallbacks)
|
|
92
|
+
betas.push(FALLBACK_BETA);
|
|
93
|
+
if (params.thinking?.block_binding)
|
|
94
|
+
betas.push(BINDING_BETA);
|
|
95
|
+
if ((params.tools ?? []).some(isDeferredEntry) || hasSystemMessage(params.messages))
|
|
96
|
+
betas.push(TOOL_CHANGES_BETA);
|
|
97
|
+
return betas;
|
|
98
|
+
}
|
|
99
|
+
const EPHEMERAL = { type: 'ephemeral' };
|
|
100
|
+
/**
|
|
101
|
+
* Add the system prompt and (optionally) the proxy's two remaining cache
|
|
102
|
+
* breakpoints: the last tool and the last block of the last message. Never
|
|
103
|
+
* mutates the input — the loop's session messages are reused next turn.
|
|
104
|
+
* A caller-supplied `system` is replaced, as the proxy ignores body.system.
|
|
105
|
+
*/
|
|
106
|
+
export function withSkillPrompt(params, system, opts) {
|
|
107
|
+
const out = { ...params, system };
|
|
108
|
+
if (!opts.cacheMarkers)
|
|
109
|
+
return out;
|
|
110
|
+
// Task 16 — the marker goes on the last LOADED tool (loaded entries come
|
|
111
|
+
// first, deferred ones and the search tool after); with no deferred entries
|
|
112
|
+
// that is the last tool, as before.
|
|
113
|
+
if (Array.isArray(params.tools) && params.tools.length > 0) {
|
|
114
|
+
const tools = [...params.tools];
|
|
115
|
+
const markIdx = lastIndexWhere(tools, isLoadedToolEntry);
|
|
116
|
+
if (markIdx >= 0) {
|
|
117
|
+
tools[markIdx] = { ...tools[markIdx], cache_control: EPHEMERAL };
|
|
118
|
+
out.tools = tools;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Task 16 — the marker goes on the last NON-system message: a trailing
|
|
122
|
+
// `role: 'system'` tool_addition is never marked (probe P7 proved this
|
|
123
|
+
// shape; a marker on a tool_addition block is unproven).
|
|
124
|
+
const lastIdx = lastIndexWhere(params.messages, (m) => m?.role !== 'system');
|
|
125
|
+
if (lastIdx >= 0) {
|
|
126
|
+
const messages = [...params.messages];
|
|
127
|
+
const last = { ...messages[lastIdx] };
|
|
128
|
+
if (typeof last.content === 'string') {
|
|
129
|
+
last.content = [{ type: 'text', text: last.content, cache_control: EPHEMERAL }];
|
|
130
|
+
}
|
|
131
|
+
else if (Array.isArray(last.content) && last.content.length > 0) {
|
|
132
|
+
const blocks = [...last.content];
|
|
133
|
+
blocks[blocks.length - 1] = { ...blocks[blocks.length - 1], cache_control: EPHEMERAL };
|
|
134
|
+
last.content = blocks;
|
|
135
|
+
}
|
|
136
|
+
messages[lastIdx] = last;
|
|
137
|
+
out.messages = messages;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
function lastIndexWhere(arr, pred) {
|
|
142
|
+
for (let i = arr.length - 1; i >= 0; i--)
|
|
143
|
+
if (pred(arr[i]))
|
|
144
|
+
return i;
|
|
145
|
+
return -1;
|
|
146
|
+
}
|
|
147
|
+
const LEGACY_TOP_LEVEL = ['model', 'max_tokens', 'messages', 'system', 'tools', 'tool_choice', 'metadata'];
|
|
148
|
+
const LEGACY_THINKING = ['type', 'display'];
|
|
149
|
+
/** Task 16 — server tool blocks (tool search) the legacy wires never saw. */
|
|
150
|
+
const SERVER_TOOL_BLOCKS = new Set(['server_tool_use', 'tool_search_tool_result']);
|
|
151
|
+
/** A shallow copy of an object block without `cache_control`. */
|
|
152
|
+
function withoutCacheControl(b) {
|
|
153
|
+
if (!b || typeof b !== 'object' || !('cache_control' in b))
|
|
154
|
+
return b;
|
|
155
|
+
const { cache_control: _drop, ...rest } = b;
|
|
156
|
+
return rest;
|
|
157
|
+
}
|
|
158
|
+
/** Task 16 — legacy tools: every tool loads (no defer_loading), no search entry, no markers. */
|
|
159
|
+
function legacyTools(tools) {
|
|
160
|
+
return tools
|
|
161
|
+
.filter((t) => !isToolSearchEntry(t))
|
|
162
|
+
.map((t) => {
|
|
163
|
+
const copy = withoutCacheControl(t);
|
|
164
|
+
if (!isDeferredEntry(copy))
|
|
165
|
+
return copy;
|
|
166
|
+
const { defer_loading: _drop, ...rest } = copy;
|
|
167
|
+
return rest;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Task 16 — legacy messages: no `role: 'system'` messages, no server tool
|
|
172
|
+
* blocks (an assistant message left empty by that is dropped), no nested
|
|
173
|
+
* `cache_control` (Task 5 carry).
|
|
174
|
+
*/
|
|
175
|
+
function legacyMessages(messages) {
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const m of messages) {
|
|
178
|
+
if (m.role === 'system')
|
|
179
|
+
continue;
|
|
180
|
+
if (!Array.isArray(m.content)) {
|
|
181
|
+
out.push(m);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const content = m.content
|
|
185
|
+
.filter((b) => !SERVER_TOOL_BLOCKS.has(b?.type ?? ''))
|
|
186
|
+
.map(withoutCacheControl);
|
|
187
|
+
if (content.length === 0)
|
|
188
|
+
continue;
|
|
189
|
+
out.push({ ...m, content });
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Whitelist copy of the fields AI-hub and local accepted before this plan.
|
|
195
|
+
* Every newer field (output_config, fallbacks, betas, thinking.block_binding,
|
|
196
|
+
* …) is dropped, and so are the Task 16 shapes (deferred tools load in full,
|
|
197
|
+
* the search tool, system messages, server tool blocks) and any nested
|
|
198
|
+
* cache_control. Never deletes from the input.
|
|
199
|
+
*/
|
|
200
|
+
export function toLegacyShape(params) {
|
|
201
|
+
const src = params;
|
|
202
|
+
const out = {};
|
|
203
|
+
for (const k of LEGACY_TOP_LEVEL) {
|
|
204
|
+
if (src[k] !== undefined)
|
|
205
|
+
out[k] = src[k];
|
|
206
|
+
}
|
|
207
|
+
if (Array.isArray(params.tools))
|
|
208
|
+
out.tools = legacyTools(params.tools);
|
|
209
|
+
if (Array.isArray(params.messages))
|
|
210
|
+
out.messages = legacyMessages(params.messages);
|
|
211
|
+
if (Array.isArray(params.system))
|
|
212
|
+
out.system = params.system.map(withoutCacheControl);
|
|
213
|
+
if (params.thinking !== undefined) {
|
|
214
|
+
const t = params.thinking;
|
|
215
|
+
const thinking = {};
|
|
216
|
+
for (const k of LEGACY_THINKING) {
|
|
217
|
+
if (t[k] !== undefined)
|
|
218
|
+
thinking[k] = t[k];
|
|
219
|
+
}
|
|
220
|
+
out.thinking = thinking;
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Task 9 (Task 5 carry) — headers only our proxy understands. On a direct
|
|
226
|
+
* path (BYOK talks to the public API) they would leak CSPeach plumbing to a
|
|
227
|
+
* third party, so they are removed before the SDK sees them. Matching is
|
|
228
|
+
* case-insensitive: X-CSForge-* and X-CSPeach-*.
|
|
229
|
+
*/
|
|
230
|
+
export function withoutInternalHeaders(headers) {
|
|
231
|
+
const out = {};
|
|
232
|
+
for (const [k, v] of Object.entries(headers ?? {})) {
|
|
233
|
+
if (/^x-cs(forge|peach)-/i.test(k))
|
|
234
|
+
continue;
|
|
235
|
+
out[k] = v;
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Task 9 — the role header is new proxy plumbing; AI-hub keeps today's header
|
|
241
|
+
* set, so only X-CSPeach-Model-Role is removed there.
|
|
242
|
+
*/
|
|
243
|
+
export function withoutRoleHeader(headers) {
|
|
244
|
+
const out = {};
|
|
245
|
+
for (const [k, v] of Object.entries(headers ?? {})) {
|
|
246
|
+
if (k.toLowerCase() === 'x-cspeach-model-role')
|
|
247
|
+
continue;
|
|
248
|
+
out[k] = v;
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The active skill travels as the X-CSForge-Skill header (coordination §1).
|
|
254
|
+
* Direct providers need it to fetch the skill body; without it the model
|
|
255
|
+
* would run with no skill prompt, so fail loudly instead.
|
|
256
|
+
*/
|
|
257
|
+
export function requireSkillName(options, mode) {
|
|
258
|
+
const name = options.headers?.['X-CSForge-Skill'];
|
|
259
|
+
if (!name) {
|
|
260
|
+
throw new Error(`${mode} request is missing the X-CSForge-Skill header — cannot load the skill prompt for this turn.`);
|
|
261
|
+
}
|
|
262
|
+
return name;
|
|
263
|
+
}
|
|
@@ -2,6 +2,8 @@ import Anthropic from '@anthropic-ai/sdk';
|
|
|
2
2
|
import keychain from '../../auth/keychain.js';
|
|
3
3
|
import { createManifestSkillSource } from '../../skills/source-manifest.js';
|
|
4
4
|
import { DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
|
|
5
|
+
import { requireSkillName, toLegacyShape, withoutRoleHeader } from '../provider-shape.js';
|
|
6
|
+
import { systemPromptForSkill } from '../system-prompt.js';
|
|
5
7
|
const KEYCHAIN_SERVICE = 'cspeach.ai-hub';
|
|
6
8
|
const KEYCHAIN_ACCOUNT = 'token';
|
|
7
9
|
export function createAiHubProvider(cfg) {
|
|
@@ -16,14 +18,27 @@ export function createAiHubProvider(cfg) {
|
|
|
16
18
|
mode: 'ai-hub',
|
|
17
19
|
skillSource,
|
|
18
20
|
async createStream(params, options = {}) {
|
|
21
|
+
// Task 5 (ruling F3) — AI-hub keeps today's field set only (newer
|
|
22
|
+
// fields such as output_config are stripped) and gains the preamble +
|
|
23
|
+
// skill body as `system`. No cache markers: the hub has no flag saying
|
|
24
|
+
// it accepts cache_control, so none is sent.
|
|
25
|
+
const skillName = requireSkillName(options, 'AI Hub');
|
|
19
26
|
const token = await getToken();
|
|
27
|
+
// Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
|
|
28
|
+
const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: false });
|
|
29
|
+
const request = {
|
|
30
|
+
...toLegacyShape(params),
|
|
31
|
+
system,
|
|
32
|
+
model: modelAlias,
|
|
33
|
+
};
|
|
20
34
|
const sdk = new Anthropic({
|
|
21
35
|
apiKey: token,
|
|
22
36
|
baseURL,
|
|
23
|
-
|
|
37
|
+
// Task 9 — the proxy-only role header is not forwarded to the hub.
|
|
38
|
+
defaultHeaders: withoutRoleHeader(options.headers),
|
|
24
39
|
});
|
|
25
40
|
// v0.6 — pass AbortSignal so hard-interrupt tears down the stream.
|
|
26
|
-
return sdk.messages.stream(
|
|
41
|
+
return sdk.messages.stream(request, options.signal ? { signal: options.signal } : undefined);
|
|
27
42
|
},
|
|
28
43
|
async healthCheck() {
|
|
29
44
|
const token = await keychain.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
|
@@ -2,23 +2,53 @@ import Anthropic from '@anthropic-ai/sdk';
|
|
|
2
2
|
import keychain from '../../auth/keychain.js';
|
|
3
3
|
import { createManifestSkillSource } from '../../skills/source-manifest.js';
|
|
4
4
|
import { DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
|
|
5
|
+
import { modelAcceptsFallbacks, requiredBetas, requireSkillName, withoutInternalHeaders, withSkillPrompt } from '../provider-shape.js';
|
|
6
|
+
import { systemPromptForSkill } from '../system-prompt.js';
|
|
5
7
|
const KEYCHAIN_SERVICE = 'cspeach.anthropic';
|
|
6
8
|
const KEYCHAIN_ACCOUNT = 'api-key';
|
|
7
9
|
// Cheapest Anthropic model available at doctor-probe time.
|
|
8
10
|
const HEALTH_PROBE_MODEL = 'claude-haiku-4-6-latest';
|
|
9
11
|
export function createByokProvider(_cfg) {
|
|
10
12
|
const skillSource = createManifestSkillSource(DEFAULT_MANIFEST_URL);
|
|
13
|
+
// Task 5 (ruling F3) — the loop's request is provider-neutral; BYOK talks to
|
|
14
|
+
// the model directly, so it adds what the proxy adds for managed: preamble +
|
|
15
|
+
// skill body as `system`, cache markers, and the betas matching the fields
|
|
16
|
+
// present. Shared by createStream and keepalive (Task 15) so a ping warms
|
|
17
|
+
// exactly the prefix the real call wrote.
|
|
18
|
+
async function shapeRequest(params, options) {
|
|
19
|
+
const skillName = requireSkillName(options, 'BYOK');
|
|
20
|
+
const key = await getKey();
|
|
21
|
+
// Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
|
|
22
|
+
const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: true });
|
|
23
|
+
// Task 6 — opt into server-side fallback, only for the families probe
|
|
24
|
+
// P3 proved (allow-list); requiredBetas adds its beta.
|
|
25
|
+
const prompted = withSkillPrompt(params, system, { cacheMarkers: true });
|
|
26
|
+
const shaped = modelAcceptsFallbacks(params.model)
|
|
27
|
+
? { ...prompted, fallbacks: 'default' }
|
|
28
|
+
: prompted;
|
|
29
|
+
const betas = requiredBetas(shaped);
|
|
30
|
+
// Task 9 — CSPeach-internal headers never reach the public API.
|
|
31
|
+
const sdk = new Anthropic({ apiKey: key, defaultHeaders: withoutInternalHeaders(options.headers) });
|
|
32
|
+
return { sdk, body: { ...shaped, betas } };
|
|
33
|
+
}
|
|
11
34
|
return {
|
|
12
35
|
name: 'anthropic-byok',
|
|
13
36
|
mode: 'byok',
|
|
14
37
|
skillSource,
|
|
15
38
|
async createStream(params, options = {}) {
|
|
16
|
-
const
|
|
17
|
-
const sdk = new Anthropic({ apiKey: key, defaultHeaders: { ...options.headers } });
|
|
39
|
+
const { sdk, body } = await shapeRequest(params, options);
|
|
18
40
|
// v0.6 — pass AbortSignal so the agent loop's per-turn AbortController
|
|
19
41
|
// can tear down the stream on Esc / SIGINT-during-turn. The Anthropic
|
|
20
42
|
// SDK's `signal` option propagates to the underlying fetch.
|
|
21
|
-
return sdk.messages.stream(
|
|
43
|
+
return sdk.beta.messages.stream(body, options.signal ? { signal: options.signal } : undefined);
|
|
44
|
+
},
|
|
45
|
+
// Task 15 — keepalive ping, direct to the API: the SAME shaped body as
|
|
46
|
+
// createStream (system, cache markers, betas, fallbacks) with
|
|
47
|
+
// max_tokens 0 and no stream (probe P5: accepted, reads the cache). No
|
|
48
|
+
// SDK retries — a failed ping stops that wait, it must never storm.
|
|
49
|
+
async keepalive(params, options = {}) {
|
|
50
|
+
const { sdk, body } = await shapeRequest(params, options);
|
|
51
|
+
await sdk.beta.messages.create({ ...body, max_tokens: 0 }, { maxRetries: 0, timeout: 20_000 });
|
|
22
52
|
},
|
|
23
53
|
async healthCheck() {
|
|
24
54
|
const key = await keychain.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
// Loaded on use (fix round 3, item 11e): only local mode needs the OpenAI SDK.
|
|
2
|
+
const loadOpenAI = async () => (await import('openai')).default;
|
|
2
3
|
import crypto from 'node:crypto';
|
|
3
4
|
import { sha256 } from '@noble/hashes/sha256';
|
|
4
5
|
import { bytesToHex } from '@noble/hashes/utils';
|
|
5
6
|
import { createBundledSkillSource } from '../../skills/source-bundled.js';
|
|
6
7
|
import { BUNDLED_SKILLS } from '../../skills/bundled-skills.js';
|
|
8
|
+
import { requireSkillName } from '../provider-shape.js';
|
|
9
|
+
import { buildSystemPrompt } from '../system-prompt.js';
|
|
7
10
|
export function createLocalProvider(cfg) {
|
|
8
11
|
if (!cfg.llm.local_base_url) {
|
|
9
12
|
throw new Error('local mode requires llm.local_base_url — run: cspeach config set llm.local_base_url http://localhost:11434/v1');
|
|
@@ -19,8 +22,14 @@ export function createLocalProvider(cfg) {
|
|
|
19
22
|
if (params.tools && params.tools.length > 0) {
|
|
20
23
|
throw new Error('Tool calling not supported in local mode (Plan A). Switch to managed/byok/ai-hub, or wait for the local-mode tool-calling follow-up PR.');
|
|
21
24
|
}
|
|
25
|
+
// Task 5 (ruling F3) — preamble + bundled skill body as the system
|
|
26
|
+
// message (no cache markers). anthropicParamsToOpenAI copies only
|
|
27
|
+
// max_tokens / messages / system, so newer fields never reach the endpoint.
|
|
28
|
+
const skillName = requireSkillName(options, 'Local');
|
|
29
|
+
const skill = await skillSource.getSkillBody(skillName);
|
|
30
|
+
const OpenAI = await loadOpenAI();
|
|
22
31
|
const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
|
|
23
|
-
const openAiParams = anthropicParamsToOpenAI(params, model);
|
|
32
|
+
const openAiParams = anthropicParamsToOpenAI({ ...params, system: buildSystemPrompt(skill.body, { cacheMarkers: false }) }, model);
|
|
24
33
|
// stream: true widens via object spread; the literal-true overload cannot be
|
|
25
34
|
// selected by structural inference, so cast to the streaming return shape.
|
|
26
35
|
// v0.6 — pass AbortSignal through OpenAI SDK request options.
|
|
@@ -29,6 +38,7 @@ export function createLocalProvider(cfg) {
|
|
|
29
38
|
},
|
|
30
39
|
async healthCheck() {
|
|
31
40
|
try {
|
|
41
|
+
const OpenAI = await loadOpenAI();
|
|
32
42
|
const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
|
|
33
43
|
await sdk.chat.completions.create({
|
|
34
44
|
model,
|
|
@@ -13,15 +13,38 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Drop incomplete tool_use blocks. The model will re-emit them on the next
|
|
15
15
|
* round (the prior tool round's results are already on session.messages,
|
|
16
|
-
* giving the model the same context). Text
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* giving the model the same context). Text blocks are kept even if
|
|
17
|
+
* truncated — partial prose is still useful for the user to read, and a
|
|
18
|
+
* short paragraph fragment is a valid assistant message. Thinking blocks are
|
|
19
|
+
* kept only when complete and signed (see isUnsignedThinking).
|
|
19
20
|
*/
|
|
20
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Block types whose input streams as `input_json_delta` (and so may carry a
|
|
23
|
+
* residual `partial_json`). Task 16 (F6): the tool search tool's
|
|
24
|
+
* `server_tool_use` streams its input the same way as a client `tool_use`
|
|
25
|
+
* (probe P6: 5 and 8 input_json_delta events).
|
|
26
|
+
*/
|
|
27
|
+
export const STREAMED_INPUT_BLOCKS = new Set(['tool_use', 'server_tool_use']);
|
|
28
|
+
/**
|
|
29
|
+
* 2026-09-27 — a thinking block is only valid to send back to the API with its
|
|
30
|
+
* complete signature. The signature arrives last (`signature_delta`, right
|
|
31
|
+
* before `content_block_stop`), so a turn cancelled mid-thinking leaves a block
|
|
32
|
+
* with `signature: ''` — and every later request 400s with
|
|
33
|
+
* messages.N.content.M: Invalid `signature` in `thinking` block
|
|
34
|
+
* A redacted_thinking block is likewise only valid with its `data`.
|
|
35
|
+
*/
|
|
36
|
+
export function isUnsignedThinking(b) {
|
|
37
|
+
if (b?.type === 'thinking')
|
|
38
|
+
return typeof b.signature !== 'string' || b.signature.length === 0;
|
|
39
|
+
if (b?.type === 'redacted_thinking')
|
|
40
|
+
return typeof b.data !== 'string' || b.data.length === 0;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
export function repairPartialBlocks(blocks, opts = {}) {
|
|
21
44
|
return blocks.filter((b) => {
|
|
22
45
|
if (b == null || typeof b !== 'object')
|
|
23
46
|
return false;
|
|
24
|
-
if (b.type
|
|
47
|
+
if (STREAMED_INPUT_BLOCKS.has(b.type)) {
|
|
25
48
|
// Complete only when input was successfully parsed (object, not undefined).
|
|
26
49
|
if (b.input === undefined)
|
|
27
50
|
return false;
|
|
@@ -35,10 +58,72 @@ export function repairPartialBlocks(blocks) {
|
|
|
35
58
|
delete b.partial_json;
|
|
36
59
|
return true;
|
|
37
60
|
}
|
|
38
|
-
|
|
61
|
+
if (b.type === 'thinking' || b.type === 'redacted_thinking') {
|
|
62
|
+
// Never persist a partial thinking block: no signature, or cut before
|
|
63
|
+
// its content_block_stop. The model re-thinks on the next round.
|
|
64
|
+
if (isUnsignedThinking(b))
|
|
65
|
+
return false;
|
|
66
|
+
if (opts.completed && !opts.completed.has(b))
|
|
67
|
+
return false;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
// text etc. — keep as-is (partial prose is still useful and valid).
|
|
39
71
|
return true;
|
|
40
72
|
});
|
|
41
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Task 6 (2026-09-27) — server-side fallback. When the first model hands off
|
|
76
|
+
* to a fallback model mid-output, the message carries a `fallback` block. The
|
|
77
|
+
* API rule for echoing it back: omit the `thinking`, `redacted_thinking` and
|
|
78
|
+
* `tool_use` blocks that precede the LAST `fallback` block (they belong to
|
|
79
|
+
* the model that did not finish). Everything else is kept in order.
|
|
80
|
+
*
|
|
81
|
+
* No `fallback` block ⇒ the same array reference is returned (no copy).
|
|
82
|
+
*/
|
|
83
|
+
// Task 16 review — also the tool search tool's server blocks (conservative,
|
|
84
|
+
// same rule as tool_use: a server tool call of the model that did not finish).
|
|
85
|
+
const PRE_FALLBACK_DROP = new Set([
|
|
86
|
+
'thinking', 'redacted_thinking', 'tool_use', 'server_tool_use', 'tool_search_tool_result',
|
|
87
|
+
]);
|
|
88
|
+
export function stripPreFallbackBlocks(blocks) {
|
|
89
|
+
let last = -1;
|
|
90
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
91
|
+
if (blocks[i]?.type === 'fallback') {
|
|
92
|
+
last = i;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (last < 0)
|
|
97
|
+
return blocks;
|
|
98
|
+
return blocks.filter((b, i) => i >= last || !PRE_FALLBACK_DROP.has(b?.type));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Heal a session already poisoned by an unsigned thinking block (saved by a
|
|
102
|
+
* CLI before 2026-09-27): drop every thinking / redacted_thinking block that
|
|
103
|
+
* lacks its signature, and drop a message left with no content (the API
|
|
104
|
+
* rejects empty content; consecutive user turns are merged by the API).
|
|
105
|
+
* Mutates `messages`; returns the number of blocks removed. Idempotent.
|
|
106
|
+
*/
|
|
107
|
+
export function stripUnsignedThinking(messages) {
|
|
108
|
+
if (!messages)
|
|
109
|
+
return 0;
|
|
110
|
+
let removed = 0;
|
|
111
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
112
|
+
const msg = messages[i];
|
|
113
|
+
if (!Array.isArray(msg?.content))
|
|
114
|
+
continue;
|
|
115
|
+
const kept = msg.content.filter((b) => !isUnsignedThinking(b));
|
|
116
|
+
const dropped = msg.content.length - kept.length;
|
|
117
|
+
if (dropped === 0)
|
|
118
|
+
continue;
|
|
119
|
+
removed += dropped;
|
|
120
|
+
if (kept.length === 0)
|
|
121
|
+
messages.splice(i, 1);
|
|
122
|
+
else
|
|
123
|
+
msg.content = kept;
|
|
124
|
+
}
|
|
125
|
+
return removed;
|
|
126
|
+
}
|
|
42
127
|
/**
|
|
43
128
|
* In-memory heal for a poisoned session.messages array.
|
|
44
129
|
*
|
|
@@ -64,7 +149,7 @@ export function healSessionMessagesInPlace(messages) {
|
|
|
64
149
|
if (!Array.isArray(msg.content))
|
|
65
150
|
continue;
|
|
66
151
|
for (const block of msg.content) {
|
|
67
|
-
if (block?.type
|
|
152
|
+
if (STREAMED_INPUT_BLOCKS.has(block?.type) && typeof block.partial_json === 'string') {
|
|
68
153
|
if (block.input === undefined) {
|
|
69
154
|
try {
|
|
70
155
|
block.input = JSON.parse(block.partial_json);
|
|
@@ -167,3 +252,36 @@ export function healOrphanToolUses(messages) {
|
|
|
167
252
|
}
|
|
168
253
|
return added;
|
|
169
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Review M2 — true when the message holds at least one thinking /
|
|
257
|
+
* redacted_thinking block and nothing else readable. Integration M1 — a
|
|
258
|
+
* `fallback` block (Task 6) is neutral: [fallback, thinking] is still
|
|
259
|
+
* thinking-only; [fallback] alone is not.
|
|
260
|
+
*/
|
|
261
|
+
export function isThinkingOnly(blocks) {
|
|
262
|
+
if (!Array.isArray(blocks))
|
|
263
|
+
return false;
|
|
264
|
+
const rest = blocks.filter((b) => b?.type !== 'fallback');
|
|
265
|
+
return rest.length > 0
|
|
266
|
+
&& rest.every((b) => b?.type === 'thinking' || b?.type === 'redacted_thinking');
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Review M2 — a turn cut right after a signed thinking block (before any
|
|
270
|
+
* text or tool call) left an assistant message holding only thinking. Drop
|
|
271
|
+
* it when it is the LAST assistant message; earlier turns are never touched
|
|
272
|
+
* (removing a block mid-transcript would invalidate later signed blocks).
|
|
273
|
+
* Mutates `messages`; returns the number of messages removed. Idempotent.
|
|
274
|
+
*/
|
|
275
|
+
export function dropTrailingThinkingOnly(messages) {
|
|
276
|
+
if (!messages)
|
|
277
|
+
return 0;
|
|
278
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
279
|
+
if (messages[i]?.role !== 'assistant')
|
|
280
|
+
continue;
|
|
281
|
+
if (!isThinkingOnly(messages[i].content))
|
|
282
|
+
return 0;
|
|
283
|
+
messages.splice(i, 1);
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
@@ -29,7 +29,12 @@ export async function summariseViaProvider(provider, system, user, opts) {
|
|
|
29
29
|
system,
|
|
30
30
|
messages: [{ role: 'user', content: user }],
|
|
31
31
|
}, {
|
|
32
|
-
|
|
32
|
+
// Task 9 — every caller is /compact, so the role defaults to 'compact'
|
|
33
|
+
// (the proxy never enforces the session model on it). A caller-set
|
|
34
|
+
// X-CSPeach-Model-Role wins. No headers ⇒ unchanged (undefined).
|
|
35
|
+
headers: opts.headers
|
|
36
|
+
? { 'X-CSPeach-Model-Role': 'compact', ...opts.headers }
|
|
37
|
+
: opts.headers,
|
|
33
38
|
signal: opts.signal,
|
|
34
39
|
});
|
|
35
40
|
let accumulated = '';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side system prompt for providers that talk to a model directly
|
|
3
|
+
* (BYOK, AI-hub, local). Task 5 (2026-09-27, ruling F3).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the proxy's managed system prompt (cspeach-proxy/src/routes/
|
|
6
|
+
* messages.ts): two text blocks — the CSPeach principles preamble, then the
|
|
7
|
+
* active skill's SKILL.md body — each carrying an ephemeral cache marker when
|
|
8
|
+
* the provider supports them.
|
|
9
|
+
*/
|
|
10
|
+
import { FORGE_PRINCIPLES_PREAMBLE } from '../skills/preamble.js';
|
|
11
|
+
import { READER_SKILL, READER_SYSTEM_PROMPT } from '../tools/subagent/reader-prompt.js';
|
|
12
|
+
export function buildSystemPrompt(skillBody, opts) {
|
|
13
|
+
const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
|
|
14
|
+
return [
|
|
15
|
+
{ type: 'text', text: FORGE_PRINCIPLES_PREAMBLE, ...marker },
|
|
16
|
+
{ type: 'text', text: skillBody, ...marker },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Task 18 (ruling F8) — a reader subagent's system prompt: the pinned
|
|
21
|
+
* READER_SYSTEM_PROMPT alone (no preamble, no skill body), as the proxy sends
|
|
22
|
+
* it for the `_reader` skill header.
|
|
23
|
+
*/
|
|
24
|
+
export function buildReaderSystemPrompt(opts) {
|
|
25
|
+
const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
|
|
26
|
+
return [{ type: 'text', text: READER_SYSTEM_PROMPT, ...marker }];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The system prompt for the skill named in the X-CSForge-Skill header. The
|
|
30
|
+
* internal `_reader` skill has no SKILL.md, so it never reaches the skill
|
|
31
|
+
* source (a lookup would fail).
|
|
32
|
+
*/
|
|
33
|
+
export async function systemPromptForSkill(skillName, skillSource, opts) {
|
|
34
|
+
if (skillName === READER_SKILL)
|
|
35
|
+
return buildReaderSystemPrompt(opts);
|
|
36
|
+
const skill = await skillSource.getSkillBody(skillName);
|
|
37
|
+
return buildSystemPrompt(skill.body, opts);
|
|
38
|
+
}
|