@cspeach/cli 0.9.0 → 1.1.0
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 +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
package/dist/config/loader.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
3
|
import toml from '@iarna/toml';
|
|
3
4
|
import { configFile, cspeachRoot } from './paths.js';
|
|
5
|
+
import { DEFAULT_SESSION_MODEL } from './model-defaults.js';
|
|
4
6
|
/**
|
|
5
7
|
* Whitelist guard for WriteMode — rejects typos / stale values from disk so
|
|
6
8
|
* downstream code that trusts the WriteMode type doesn't see garbage.
|
|
@@ -9,9 +11,9 @@ import { configFile, cspeachRoot } from './paths.js';
|
|
|
9
11
|
export function isValidWriteMode(v) {
|
|
10
12
|
return v === 'auto' || v === 'approval-gated' || v === 'advisory-only';
|
|
11
13
|
}
|
|
12
|
-
const DEFAULT_CONFIG = {
|
|
14
|
+
export const DEFAULT_CONFIG = {
|
|
13
15
|
proxy_url: 'https://api.cspeach.dev',
|
|
14
|
-
default_model:
|
|
16
|
+
default_model: DEFAULT_SESSION_MODEL,
|
|
15
17
|
effort: 'xhigh',
|
|
16
18
|
telemetry: 'minimal',
|
|
17
19
|
sap: {},
|
|
@@ -26,6 +28,13 @@ const DEFAULT_CONFIG = {
|
|
|
26
28
|
write_mode: 'approval-gated',
|
|
27
29
|
// Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
|
|
28
30
|
llm: { mode: 'managed' },
|
|
31
|
+
// local_build is intentionally NOT defaulted here. If it were set to `false`,
|
|
32
|
+
// the `...DEFAULT_CONFIG` spread would leave merged.local_build defined even
|
|
33
|
+
// when the file omits the key — which permanently defeats resolveLocalBuild's
|
|
34
|
+
// `local_build ?? local_files` fallback (the legacy `local_files = on` case
|
|
35
|
+
// could never fire). Absence must stay `undefined` so the fallback can read
|
|
36
|
+
// the legacy key. The effective default-off is enforced by resolveLocalBuild
|
|
37
|
+
// (returns false when both are undefined). See loadConfig coercion below.
|
|
29
38
|
// Defaults tuned 2026-05-16 from session 0618a42d evidence — see CompactConfig doc.
|
|
30
39
|
compact: {
|
|
31
40
|
keep_recent_turns: 5,
|
|
@@ -62,6 +71,25 @@ function sanitiseCompact(raw) {
|
|
|
62
71
|
min_turns_between_auto: intInRange(r.min_turns_between_auto, d.min_turns_between_auto, 1, 100),
|
|
63
72
|
};
|
|
64
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the effective TLS options for a SAP system, applying the legacy
|
|
76
|
+
* `sslVerify: false` → insecure mapping. A configured `ca_cert_path` always
|
|
77
|
+
* wins (verification on). Pure — no I/O. Shared by the connection-manager and
|
|
78
|
+
* doctor checks so they agree on the posture.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveSapTls(sys) {
|
|
81
|
+
if (sys.ca_cert_path) {
|
|
82
|
+
return { mode: 'ca-trusted', caCertPath: sys.ca_cert_path, insecureSkipTlsVerify: false };
|
|
83
|
+
}
|
|
84
|
+
// Explicit insecure opt-in, OR the legacy sslVerify:false mapping.
|
|
85
|
+
const insecure = sys.insecure_skip_tls_verify === true || sys.sslVerify === false;
|
|
86
|
+
if (insecure) {
|
|
87
|
+
return { mode: 'insecure', insecureSkipTlsVerify: true };
|
|
88
|
+
}
|
|
89
|
+
return { mode: 'verify', insecureSkipTlsVerify: false };
|
|
90
|
+
}
|
|
91
|
+
/** Tracks whether the one-time legacy-sslVerify deprecation note has printed. */
|
|
92
|
+
let legacySslVerifyNoticeShown = false;
|
|
65
93
|
export async function loadConfig() {
|
|
66
94
|
try {
|
|
67
95
|
const raw = await fs.readFile(configFile(), 'utf-8');
|
|
@@ -87,10 +115,75 @@ export async function loadConfig() {
|
|
|
87
115
|
else {
|
|
88
116
|
allow = [];
|
|
89
117
|
}
|
|
90
|
-
|
|
118
|
+
const merged = {
|
|
91
119
|
...DEFAULT_CONFIG,
|
|
92
120
|
...parsed,
|
|
121
|
+
// Coerce defensively, but PRESERVE the absent→undefined distinction so the
|
|
122
|
+
// legacy fallback in resolveLocalBuild can fire:
|
|
123
|
+
// - explicit `true` → true
|
|
124
|
+
// - explicit `false`/off → false (defined; overrides legacy)
|
|
125
|
+
// - present-but-invalid (string) → false (defined; fails safe, can't
|
|
126
|
+
// grant access via a typo, but DOES override a stale legacy on)
|
|
127
|
+
// - absent / undefined → undefined (legacy fallback applies)
|
|
128
|
+
// A defined `false` (whether explicit or typo) must win the
|
|
129
|
+
// `local_build ?? local_files` resolution so an explicit off is honoured.
|
|
130
|
+
local_build: parsed.local_build === true
|
|
131
|
+
? true
|
|
132
|
+
: parsed.local_build === undefined
|
|
133
|
+
? undefined
|
|
134
|
+
: false,
|
|
135
|
+
// Preserve a legacy `local_files` value (read-only backward compat) ONLY
|
|
136
|
+
// when it's strictly true — a string/typo collapses to "absent" so it
|
|
137
|
+
// can't win the `local_build ?? local_files` fallback.
|
|
138
|
+
local_files: parsed.local_files === true ? true : undefined,
|
|
139
|
+
// render_smoke: plain boolean, DEFAULT true (no legacy alias). Preserve
|
|
140
|
+
// only a genuine boolean; a typo/wrong-type collapses to undefined which
|
|
141
|
+
// resolveRenderSmoke reads as the default ON (fail-safe: a config typo can
|
|
142
|
+
// never silently disable the smoke).
|
|
143
|
+
render_smoke: typeof parsed.render_smoke === 'boolean' ? parsed.render_smoke : undefined,
|
|
93
144
|
write_mode: isValidWriteMode(parsed.write_mode) ? parsed.write_mode : DEFAULT_CONFIG.write_mode,
|
|
145
|
+
// plan_mode: whitelist coercion — anything but the two valid literals
|
|
146
|
+
// (typos, wrong types) collapses to undefined, which callers treat as
|
|
147
|
+
// 'step' (fail-safe: a config typo can only make chaining MORE manual).
|
|
148
|
+
plan_mode: parsed.plan_mode === 'step' || parsed.plan_mode === 'guarded'
|
|
149
|
+
? parsed.plan_mode
|
|
150
|
+
: undefined,
|
|
151
|
+
// plan_audit: whitelist coercion — anything but the two valid literals
|
|
152
|
+
// (typos, wrong types) collapses to undefined, which callers treat as
|
|
153
|
+
// 'on' (fail-safe: a config typo can only leave audits ON, never off).
|
|
154
|
+
plan_audit: parsed.plan_audit === 'on' || parsed.plan_audit === 'off'
|
|
155
|
+
? parsed.plan_audit
|
|
156
|
+
: undefined,
|
|
157
|
+
// audit_model: preserve only a non-empty string (trimmed); a wrong-type /
|
|
158
|
+
// empty value collapses to undefined so resolveAuditModel reads the safe
|
|
159
|
+
// Sonnet default. Mirrors the optional-key hygiene of plan_mode/render_smoke.
|
|
160
|
+
audit_model: typeof parsed.audit_model === 'string' && parsed.audit_model.trim().length > 0
|
|
161
|
+
? parsed.audit_model.trim()
|
|
162
|
+
: undefined,
|
|
163
|
+
// compact_model: mirrors audit_model — preserve only a non-empty trimmed
|
|
164
|
+
// string; a wrong-type / empty value collapses to undefined so
|
|
165
|
+
// resolveModelRole reads the safe Haiku default.
|
|
166
|
+
compact_model: typeof parsed.compact_model === 'string' && parsed.compact_model.trim().length > 0
|
|
167
|
+
? parsed.compact_model.trim()
|
|
168
|
+
: undefined,
|
|
169
|
+
// default_model_set: INTERNAL flag — true iff the FILE carried the
|
|
170
|
+
// `default_model` key (file presence, not the value). Lets an explicit user
|
|
171
|
+
// choice beat a served roles.session_default. Stripped by saveConfig.
|
|
172
|
+
default_model_set: Object.prototype.hasOwnProperty.call(parsed, 'default_model'),
|
|
173
|
+
// sap: ALWAYS a fresh top-level object, never the shared DEFAULT_CONFIG.sap
|
|
174
|
+
// reference. Without this, a file lacking `[sap.*]` would leave
|
|
175
|
+
// `merged.sap === DEFAULT_CONFIG.sap`; the onboarding wizard then mutating
|
|
176
|
+
// `cfg.sap[alias] = {...}` would poison the module constant, and saveConfig's
|
|
177
|
+
// `isDeepStrictEqual(writable.sap, DEFAULT_CONFIG.sap)` would compare the
|
|
178
|
+
// object to itself → true → drop the just-added system. Alias sub-objects
|
|
179
|
+
// come fresh from toml.parse (or are whole-object assignments in onboarding),
|
|
180
|
+
// so a fresh container suffices — no deep clone of the aliases needed.
|
|
181
|
+
sap: { ...(parsed.sap ?? {}) },
|
|
182
|
+
// file_keys: INTERNAL — the top-level keys the FILE carried, snapshotted
|
|
183
|
+
// from `parsed` BEFORE the merge (so the absent-key `delete merged.x`
|
|
184
|
+
// hygiene below never affects it). Drives sparse serialisation in
|
|
185
|
+
// saveConfig; stripped there so it never reaches config.toml.
|
|
186
|
+
file_keys: Object.keys(parsed),
|
|
94
187
|
llm: { ...DEFAULT_CONFIG.llm, ...(parsed.llm ?? {}) },
|
|
95
188
|
classifier: { ...DEFAULT_CONFIG.classifier, ...(parsed.classifier ?? {}) },
|
|
96
189
|
ui: { ...DEFAULT_CONFIG.ui, ...(parsed.ui ?? {}) },
|
|
@@ -100,14 +193,139 @@ export async function loadConfig() {
|
|
|
100
193
|
compact: sanitiseCompact(parsed.compact),
|
|
101
194
|
shell_exec: parsed.shell_exec === undefined ? undefined : { allow },
|
|
102
195
|
};
|
|
196
|
+
// Drop the legacy `local_files` key entirely when it isn't a genuine on
|
|
197
|
+
// (the `...parsed` spread above can leave a `local_files = false` behind,
|
|
198
|
+
// and our explicit coercion sets it to `undefined`). Removing the key keeps
|
|
199
|
+
// `config show` and saveConfig output clean — no `local_files = undefined`.
|
|
200
|
+
if (merged.local_files === undefined)
|
|
201
|
+
delete merged.local_files;
|
|
202
|
+
// Same for `local_build`: when the key is absent from disk we coerce it to
|
|
203
|
+
// `undefined` (so resolveLocalBuild's legacy fallback can fire). Delete the
|
|
204
|
+
// key so it doesn't serialise as `local_build = undefined` and so callers
|
|
205
|
+
// see a clean "absent" shape. resolveLocalBuild treats missing === undefined.
|
|
206
|
+
if (merged.local_build === undefined)
|
|
207
|
+
delete merged.local_build;
|
|
208
|
+
// Same for `render_smoke`: absent-or-invalid must not serialise as
|
|
209
|
+
// `render_smoke = undefined` — drop the key so `config show` stays clean and
|
|
210
|
+
// callers see a genuine "absent" shape (resolveRenderSmoke treats it as ON).
|
|
211
|
+
if (merged.render_smoke === undefined)
|
|
212
|
+
delete merged.render_smoke;
|
|
213
|
+
// Same for `plan_mode`: absent-or-invalid must not serialise as
|
|
214
|
+
// `plan_mode = undefined` — drop the key so `config show` stays clean and
|
|
215
|
+
// callers see a genuine "absent" shape (treated as 'step').
|
|
216
|
+
if (merged.plan_mode === undefined)
|
|
217
|
+
delete merged.plan_mode;
|
|
218
|
+
// Same for `plan_audit`: absent-or-invalid must not serialise as
|
|
219
|
+
// `plan_audit = undefined` — drop the key so `config show` stays clean and
|
|
220
|
+
// callers see a genuine "absent" shape (treated as 'on').
|
|
221
|
+
if (merged.plan_audit === undefined)
|
|
222
|
+
delete merged.plan_audit;
|
|
223
|
+
// Same for `audit_model`: absent-or-invalid must not serialise as
|
|
224
|
+
// `audit_model = undefined` — drop the key so `config show` stays clean and
|
|
225
|
+
// callers see a genuine "absent" shape (resolveAuditModel reads the default).
|
|
226
|
+
if (merged.audit_model === undefined)
|
|
227
|
+
delete merged.audit_model;
|
|
228
|
+
// Same for `compact_model`: absent-or-invalid must not serialise as
|
|
229
|
+
// `compact_model = undefined` — drop the key so `config show` stays clean and
|
|
230
|
+
// callers see a genuine "absent" shape (resolveModelRole reads the default).
|
|
231
|
+
if (merged.compact_model === undefined)
|
|
232
|
+
delete merged.compact_model;
|
|
233
|
+
// Per-alias `role`: whitelist coercion — anything but the three valid
|
|
234
|
+
// literals (typos like "production", wrong types) collapses to absent, so
|
|
235
|
+
// downstream ceiling logic sees a clean `undefined` (= no ceiling, same
|
|
236
|
+
// behavior as before roles existed). Mirrors the plan_mode pattern.
|
|
237
|
+
for (const sys of Object.values(merged.sap ?? {})) {
|
|
238
|
+
if (sys.role !== 'dev' && sys.role !== 'qas' && sys.role !== 'prd') {
|
|
239
|
+
delete sys.role;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// One-time deprecation note: a SAP system still relies on the legacy
|
|
243
|
+
// `sslVerify` toggle and has not adopted either new key. It KEEPS WORKING
|
|
244
|
+
// (resolveSapTls maps sslVerify:false → insecure), but we nudge the user
|
|
245
|
+
// once per process toward `ca_cert_path` (preferred) or the explicit
|
|
246
|
+
// insecure flag. Only fires when sslVerify is present AND no new key is set.
|
|
247
|
+
if (!legacySslVerifyNoticeShown) {
|
|
248
|
+
const legacyAlias = Object.entries(merged.sap ?? {}).find(([, s]) => typeof s?.sslVerify === 'boolean' &&
|
|
249
|
+
s.ca_cert_path === undefined &&
|
|
250
|
+
s.insecure_skip_tls_verify === undefined);
|
|
251
|
+
if (legacyAlias) {
|
|
252
|
+
legacySslVerifyNoticeShown = true;
|
|
253
|
+
const [alias, sys] = legacyAlias;
|
|
254
|
+
const suggestion = sys.sslVerify === false
|
|
255
|
+
? `set 'ca_cert_path' to your corporate CA (keeps verification ON), or set 'insecure_skip_tls_verify = true' to keep verification off (dev only, logs a warning)`
|
|
256
|
+
: `the default is now verify-ON; you can drop 'sslVerify' or set 'ca_cert_path' for internal-CA systems`;
|
|
257
|
+
process.stderr.write(`[cspeach] note: [sap.${alias}] uses the deprecated 'sslVerify' key — ${suggestion}.\n`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return merged;
|
|
103
261
|
}
|
|
104
262
|
catch (err) {
|
|
263
|
+
// Absent file → defaults with an empty file_keys snapshot (nothing was
|
|
264
|
+
// user-set), so saveConfig(loadConfig()) on a missing file writes a minimal
|
|
265
|
+
// file rather than baking every default in. DEEP-copy so a first-ever nested
|
|
266
|
+
// mutation on a fresh install (onboarding `cfg.sap[alias] = …`, `cfg.llm.mode
|
|
267
|
+
// = …`, `cfg.ui.rendering = …`) can never poison the shared DEFAULT_CONFIG
|
|
268
|
+
// constant — which would make saveConfig's deep-equal check compare a table to
|
|
269
|
+
// itself and silently drop the user's data. A shallow spread shares every
|
|
270
|
+
// nested table by reference and is NOT safe here.
|
|
105
271
|
if (err?.code === 'ENOENT')
|
|
106
|
-
return DEFAULT_CONFIG;
|
|
272
|
+
return { ...structuredClone(DEFAULT_CONFIG), file_keys: [] };
|
|
107
273
|
throw err;
|
|
108
274
|
}
|
|
109
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* Resolve the effective "local build tools" toggle from a loaded config.
|
|
278
|
+
*
|
|
279
|
+
* Backward compat (2026-06-13 rename): the new key is `local_build`; the legacy
|
|
280
|
+
* key is `local_files`. A user who set `local_files = on` before the rename
|
|
281
|
+
* must keep working without re-running `config set`. The rule is:
|
|
282
|
+
*
|
|
283
|
+
* effective = local_build ?? local_files (default false)
|
|
284
|
+
*
|
|
285
|
+
* `local_build` always wins when present (so `config set local_build off`
|
|
286
|
+
* overrides a stale legacy `local_files = on`). Only when `local_build` is
|
|
287
|
+
* undefined does the legacy value apply. Both keys are already strictly coerced
|
|
288
|
+
* to `true | undefined` by loadConfig, so this is a pure read.
|
|
289
|
+
*/
|
|
290
|
+
export function resolveLocalBuild(cfg) {
|
|
291
|
+
if (cfg.local_build !== undefined)
|
|
292
|
+
return cfg.local_build === true;
|
|
293
|
+
return cfg.local_files === true;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Resolve the effective `render_smoke` toggle (Track 2 D-4). Unlike
|
|
297
|
+
* `local_build`, this has NO legacy alias, so the rule is a plain default-true:
|
|
298
|
+
*
|
|
299
|
+
* effective = render_smoke ?? true
|
|
300
|
+
*
|
|
301
|
+
* `render_smoke` is already coerced to `true | false | undefined` by loadConfig
|
|
302
|
+
* (a typo collapses to undefined), so absent/typo → ON and only an explicit
|
|
303
|
+
* `false` turns the browser-driven smoke into a skipped manual result.
|
|
304
|
+
*/
|
|
305
|
+
export function resolveRenderSmoke(cfg) {
|
|
306
|
+
return cfg.render_smoke ?? true;
|
|
307
|
+
}
|
|
110
308
|
export async function saveConfig(config) {
|
|
111
309
|
await fs.mkdir(cspeachRoot(), { recursive: true });
|
|
112
|
-
|
|
310
|
+
// The set of top-level keys the FILE carried (empty when it was absent).
|
|
311
|
+
// A key stays in the file if the user had it there OR its value now differs
|
|
312
|
+
// from the built-in default — see the sparse rule below.
|
|
313
|
+
const fileKeys = config.file_keys ?? [];
|
|
314
|
+
// Strip the INTERNAL flags — both are derived on load (file presence /
|
|
315
|
+
// file-key snapshot) and must never be written back into config.toml.
|
|
316
|
+
const { default_model_set: _dropSet, file_keys: _dropKeys, ...toWrite } = config;
|
|
317
|
+
// Sparse serialisation: drop any key that the file did NOT carry AND whose
|
|
318
|
+
// value is deep-equal to the built-in default. Keys absent from DEFAULT_CONFIG
|
|
319
|
+
// (optional keys like audit_model) never deep-equal a defined value, so any
|
|
320
|
+
// set value is written. This keeps `config set <one key>` from baking every
|
|
321
|
+
// default (notably default_model) into config.toml and opting the user out of
|
|
322
|
+
// the model-governance server steering. All four saveConfig callers set
|
|
323
|
+
// non-default values, so the value-diff side keeps them working unchanged.
|
|
324
|
+
const writable = toWrite;
|
|
325
|
+
for (const k of Object.keys(writable)) {
|
|
326
|
+
if (!fileKeys.includes(k) && isDeepStrictEqual(writable[k], DEFAULT_CONFIG[k])) {
|
|
327
|
+
delete writable[k];
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
await fs.writeFile(configFile(), toml.stringify(writable), 'utf-8');
|
|
113
331
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// model-governance step 2d (2026-07-10) — the session-default built-in model id,
|
|
2
|
+
// factored into a leaf module so it has ONE source of truth without forcing
|
|
3
|
+
// resolve.ts to import it from loader.ts.
|
|
4
|
+
//
|
|
5
|
+
// Why not import DEFAULT_CONFIG from loader.ts directly: several tests fully
|
|
6
|
+
// mock '../config/loader.js' (e.g. classifier-confidence.integration), and a
|
|
7
|
+
// total mock does not re-export DEFAULT_CONFIG. resolveModelRole runs inside
|
|
8
|
+
// those test paths (via one-shot.ts / repl.tsx), so reading DEFAULT_CONFIG from
|
|
9
|
+
// the mocked module would throw "No DEFAULT_CONFIG export defined on the mock".
|
|
10
|
+
// This leaf module is never mocked, so both loader.ts (which builds
|
|
11
|
+
// DEFAULT_CONFIG from it) and resolve.ts read the identical constant — no
|
|
12
|
+
// duplication, no drift, no mock fragility.
|
|
13
|
+
/** The built-in session-default model id — today's DEFAULT_CONFIG.default_model. */
|
|
14
|
+
export const DEFAULT_SESSION_MODEL = 'claude-opus-4-8';
|
package/dist/cost/pricing.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* Unknown models → returns null + records the model name to a process-level
|
|
23
23
|
* Set so we surface them as an obvious warning rather than silently zeroing.
|
|
24
24
|
*/
|
|
25
|
+
import { getServerModelConfig } from '../models/server-config.js';
|
|
25
26
|
const PRICING = {
|
|
26
27
|
// Claude Opus 4.5+ — input 5, output 25, cache_read 0.50, cache_write 6.25 (1.25x input).
|
|
27
28
|
// 2026-06-07 correction: 4.5/4.6/4.7 were carried at the LEGACY $15/$75; actual
|
|
@@ -42,6 +43,20 @@ const PRICING = {
|
|
|
42
43
|
'claude-haiku-4': { inputPer1M: 1.00, outputPer1M: 5.00, cacheReadPer1M: 0.10, cacheCreatePer1M: 1.25 },
|
|
43
44
|
};
|
|
44
45
|
const seenUnknownModels = new Set();
|
|
46
|
+
/**
|
|
47
|
+
* Normalise a served rate (whose `cacheCreatePer1M` is optional in the proxy
|
|
48
|
+
* shape) to the CLI's full ModelRate. When the served entry omits the cache
|
|
49
|
+
* write rate we derive it as 1.25× input — the same convention this file's
|
|
50
|
+
* built-in table applies per Anthropic's docs.
|
|
51
|
+
*/
|
|
52
|
+
function fromServerRate(r) {
|
|
53
|
+
return {
|
|
54
|
+
inputPer1M: r.inputPer1M,
|
|
55
|
+
outputPer1M: r.outputPer1M,
|
|
56
|
+
cacheReadPer1M: r.cacheReadPer1M,
|
|
57
|
+
cacheCreatePer1M: r.cacheCreatePer1M ?? r.inputPer1M * 1.25,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
45
60
|
/**
|
|
46
61
|
* Look up a per-1M-token rate for a model. Normalises to lowercase and
|
|
47
62
|
* strips Anthropic's optional `-YYYYMMDD` date suffix so e.g.
|
|
@@ -53,9 +68,20 @@ const seenUnknownModels = new Set();
|
|
|
53
68
|
*/
|
|
54
69
|
export function getRate(model) {
|
|
55
70
|
const normalized = model.toLowerCase();
|
|
71
|
+
const stripDate = normalized.replace(/-\d{8}$/, '');
|
|
72
|
+
// Served model-governance map wins over the built-in table (model-governance
|
|
73
|
+
// step 4). Same normalization as the built-in path: lowercase + strip the
|
|
74
|
+
// optional `-YYYYMMDD` date suffix. The built-in PRICING below stays as the
|
|
75
|
+
// offline fallback (locked decision Q4). The snapshot is sync (server-config
|
|
76
|
+
// module snapshot), so getRate stays sync.
|
|
77
|
+
const served = getServerModelConfig()?.pricing;
|
|
78
|
+
if (served) {
|
|
79
|
+
const hit = served[normalized] ?? served[stripDate];
|
|
80
|
+
if (hit)
|
|
81
|
+
return fromServerRate(hit);
|
|
82
|
+
}
|
|
56
83
|
if (PRICING[normalized])
|
|
57
84
|
return PRICING[normalized];
|
|
58
|
-
const stripDate = normalized.replace(/-\d{8}$/, '');
|
|
59
85
|
if (PRICING[stripDate])
|
|
60
86
|
return PRICING[stripDate];
|
|
61
87
|
if (!seenUnknownModels.has(normalized)) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import tls from 'node:tls';
|
|
2
|
-
import { loadConfig } from '../../config/loader.js';
|
|
2
|
+
import { loadConfig, resolveSapTls } from '../../config/loader.js';
|
|
3
3
|
export async function checkCert() {
|
|
4
4
|
const cfg = await loadConfig();
|
|
5
5
|
const systems = Object.entries(cfg.sap).filter(([_, s]) => s.useSsl);
|
|
@@ -7,14 +7,25 @@ export async function checkCert() {
|
|
|
7
7
|
return { name: 'cert', ok: true, message: 'n/a (no TLS SAP systems configured)' };
|
|
8
8
|
const results = [];
|
|
9
9
|
for (const [alias, sys] of systems) {
|
|
10
|
+
const tlsMode = resolveSapTls(sys);
|
|
10
11
|
const info = await new Promise((resolve) => {
|
|
12
|
+
// Probe with rejectUnauthorized:false so we can always read the cert and
|
|
13
|
+
// report the OS-trust chain status, then describe the configured posture
|
|
14
|
+
// separately (verify / CA-trusted / INSECURE).
|
|
11
15
|
const socket = tls.connect({ host: sys.host, port: sys.port, rejectUnauthorized: false, timeout: 5000 }, () => {
|
|
12
16
|
const cert = socket.getPeerCertificate();
|
|
13
17
|
const authorized = socket.authorized;
|
|
14
18
|
socket.end();
|
|
15
19
|
const chainValid = authorized ? 'yes' : 'no';
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
// What the configured client mode means for acceptance:
|
|
21
|
+
// verify → accepts only if the OS/Node trust chain validates
|
|
22
|
+
// CA-trusted → accepts via the configured CA (verification ON)
|
|
23
|
+
// INSECURE → accepts anything (verification OFF — dev only)
|
|
24
|
+
const clientAccepted = tlsMode.mode === 'insecure' ? 'yes (INSECURE — verification disabled, dev only)'
|
|
25
|
+
: tlsMode.mode === 'ca-trusted' ? 'yes (CA-trusted — verification ON via configured CA)'
|
|
26
|
+
: (authorized ? 'yes' : 'no (verify ON — configure ca_cert_path to trust an internal CA)');
|
|
27
|
+
resolve(`${alias}: mode: ${tlsMode.mode} / chain valid: ${chainValid} / ` +
|
|
28
|
+
`client accepted: ${clientAccepted} / subject: ${cert.subject?.CN ?? '?'}`);
|
|
18
29
|
});
|
|
19
30
|
socket.on('error', (err) => resolve(`${alias}: ${err.message}`));
|
|
20
31
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { loadConfig, resolveSapTls } from '../../config/loader.js';
|
|
2
3
|
import { probeGet } from './_http-probe.js';
|
|
3
4
|
export async function checkSap() {
|
|
4
5
|
const cfg = await loadConfig();
|
|
@@ -9,23 +10,44 @@ export async function checkSap() {
|
|
|
9
10
|
let anyFailed = false;
|
|
10
11
|
for (const [alias, sys] of systems) {
|
|
11
12
|
const url = `${sys.useSsl ? 'https' : 'http'}://${sys.host}:${sys.port}/sap/bc/adt/discovery`;
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
// TLS posture (2026-06-14): verification ON by default. resolveSapTls maps
|
|
14
|
+
// the per-alias config (ca_cert_path / insecure_skip_tls_verify, legacy
|
|
15
|
+
// sslVerify:false fallback). Node's built-in fetch() doesn't expose a
|
|
16
|
+
// per-request TLS-verify toggle, so we use node's https directly — see
|
|
17
|
+
// checks/_http-probe.ts.
|
|
18
|
+
const tls = resolveSapTls(sys);
|
|
19
|
+
// Tag the connectivity result with the cert mode so doctor surfaces it.
|
|
20
|
+
const modeTag = tls.mode === 'verify' ? 'verify'
|
|
21
|
+
: tls.mode === 'ca-trusted' ? 'CA-trusted'
|
|
22
|
+
: 'INSECURE';
|
|
23
|
+
let ca;
|
|
24
|
+
if (tls.mode === 'ca-trusted' && tls.caCertPath) {
|
|
25
|
+
try {
|
|
26
|
+
ca = await readFile(tls.caCertPath);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
// Fail closed, like the connection path: a configured-but-unreadable CA
|
|
30
|
+
// is an error, not a reason to drop verification.
|
|
31
|
+
results.push(`${alias}: CA unreadable at ${tls.caCertPath} (${e.message})`);
|
|
32
|
+
anyFailed = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
15
36
|
const r = await probeGet(url, {
|
|
16
|
-
rejectUnauthorized:
|
|
37
|
+
rejectUnauthorized: tls.mode !== 'insecure',
|
|
38
|
+
ca,
|
|
17
39
|
timeoutMs: 8000,
|
|
18
40
|
});
|
|
19
41
|
if (r.error) {
|
|
20
|
-
results.push(`${alias}: ${r.error}`);
|
|
42
|
+
results.push(`${alias}: ${r.error} [${modeTag}]`);
|
|
21
43
|
anyFailed = true;
|
|
22
44
|
}
|
|
23
45
|
else if (r.status === 200 || r.status === 401) {
|
|
24
46
|
// 200 = anonymous discovery allowed; 401 = reachable + auth-required (expected, fine)
|
|
25
|
-
results.push(`${alias}: reachable`);
|
|
47
|
+
results.push(`${alias}: reachable [${modeTag}]`);
|
|
26
48
|
}
|
|
27
49
|
else {
|
|
28
|
-
results.push(`${alias}: HTTP ${r.status}`);
|
|
50
|
+
results.push(`${alias}: HTTP ${r.status} [${modeTag}]`);
|
|
29
51
|
anyFailed = true;
|
|
30
52
|
}
|
|
31
53
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor check: system-roles (UX Wave 1, Task 1).
|
|
3
|
+
*
|
|
4
|
+
* Surfaces the per-alias `role` (dev | qas | prd) that feeds the write-mode
|
|
5
|
+
* ceiling (repl/mode-ceiling.ts): prd → advisory-only, qas → approval-gated,
|
|
6
|
+
* dev/unset → no ceiling.
|
|
7
|
+
*
|
|
8
|
+
* An alias WITHOUT a role is a warn, not a failure — the ceiling only ADDS
|
|
9
|
+
* restriction, so "unset" is a valid (if risky-on-prd-boxes) configuration.
|
|
10
|
+
* Warn renders via the yellow skipped state (ok:false + skipped:true), which
|
|
11
|
+
* never counts toward the doctor's failed total. Pure — no I/O beyond cfg.
|
|
12
|
+
*/
|
|
13
|
+
export async function checkSystemRoles(cfg) {
|
|
14
|
+
const entries = Object.entries(cfg.sap ?? {});
|
|
15
|
+
if (entries.length === 0) {
|
|
16
|
+
return {
|
|
17
|
+
name: 'system-roles',
|
|
18
|
+
ok: false,
|
|
19
|
+
skipped: true,
|
|
20
|
+
message: 'no SAP system configured',
|
|
21
|
+
hint: 'Run: cspeach config add-sap',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const unset = entries.filter(([, sys]) => sys.role === undefined).map(([alias]) => alias);
|
|
25
|
+
const set = entries.filter(([, sys]) => sys.role !== undefined);
|
|
26
|
+
if (unset.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
name: 'system-roles',
|
|
29
|
+
ok: true,
|
|
30
|
+
message: `roles set: ${set.map(([alias, sys]) => `${alias}=${sys.role}`).join(', ')}`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// Warn-level: yellow, never fails doctor.
|
|
34
|
+
return {
|
|
35
|
+
name: 'system-roles',
|
|
36
|
+
ok: false,
|
|
37
|
+
skipped: true,
|
|
38
|
+
message: `${unset.join(', ')}: role unset: treated as dev (no ceiling)`,
|
|
39
|
+
hint: `set with cspeach config set sap.${unset[0]}.role prd|qas|dev`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import keytar from 'keytar';
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { loadConfig, resolveSapTls } from '../../config/loader.js';
|
|
3
4
|
import { probeGet } from './_http-probe.js';
|
|
4
5
|
const SAP_SERVICE = 'cspeach-sap';
|
|
5
6
|
export async function checkZcspeach() {
|
|
@@ -29,10 +30,24 @@ export async function checkZcspeach() {
|
|
|
29
30
|
results.push(`${alias}: skipped (no password in keychain)`);
|
|
30
31
|
continue;
|
|
31
32
|
}
|
|
32
|
-
//
|
|
33
|
-
//
|
|
33
|
+
// TLS posture (2026-06-14): verification ON by default; resolveSapTls maps
|
|
34
|
+
// the per-alias config (ca_cert_path / insecure_skip_tls_verify, legacy
|
|
35
|
+
// sslVerify:false fallback). See checks/_http-probe.ts.
|
|
36
|
+
const tls = resolveSapTls(sys);
|
|
37
|
+
let ca;
|
|
38
|
+
if (tls.mode === 'ca-trusted' && tls.caCertPath) {
|
|
39
|
+
try {
|
|
40
|
+
ca = await readFile(tls.caCertPath);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
results.push(`${alias}: CA unreadable at ${tls.caCertPath} (${e.message})`);
|
|
44
|
+
anyFailed = true;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
34
48
|
const r = await probeGet(url, {
|
|
35
|
-
rejectUnauthorized:
|
|
49
|
+
rejectUnauthorized: tls.mode !== 'insecure',
|
|
50
|
+
ca,
|
|
36
51
|
auth: `${sys.username}:${pwd}`,
|
|
37
52
|
accept: 'application/json',
|
|
38
53
|
timeoutMs: 5000,
|
package/dist/doctor/run.js
CHANGED
|
@@ -11,6 +11,7 @@ import { checkKeychainFallback } from './checks/keychain-fallback.js';
|
|
|
11
11
|
import { checkWriteMode } from './checks/write-mode.js';
|
|
12
12
|
import { checkLlmMode } from './checks/llm-mode.js';
|
|
13
13
|
import { checkForgeRules } from './checks/forge-rules.js';
|
|
14
|
+
import { checkSystemRoles } from './checks/system-roles.js';
|
|
14
15
|
export async function runDoctor() {
|
|
15
16
|
console.log(chalk.cyan.bold('\nCSPeach — doctor\n'));
|
|
16
17
|
const cfg = await loadConfig();
|
|
@@ -19,6 +20,7 @@ export async function runDoctor() {
|
|
|
19
20
|
checks.push(await checkAuth());
|
|
20
21
|
checks.push(await checkLlmMode(cfg));
|
|
21
22
|
checks.push(await checkWriteMode(cfg));
|
|
23
|
+
checks.push(await checkSystemRoles(cfg));
|
|
22
24
|
checks.push(await checkKeychain());
|
|
23
25
|
checks.push(await checkSap());
|
|
24
26
|
checks.push(await checkZcspeach());
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// model-governance step 2d (2026-07-10) — the generalized role→model resolver.
|
|
2
|
+
//
|
|
3
|
+
// resolveAuditModel (commands/plan-audit.ts) was the prototype: a pure function
|
|
4
|
+
// that layered env > local config > built-in for ONE role. This generalizes it
|
|
5
|
+
// to every CLI pipeline role and inserts the SERVED config (server-config.ts's
|
|
6
|
+
// module snapshot) as a new layer BELOW local config and ABOVE the built-in
|
|
7
|
+
// constant:
|
|
8
|
+
//
|
|
9
|
+
// env > local config > server > built-in constant
|
|
10
|
+
//
|
|
11
|
+
// Locked invariant: with NOTHING set (no env, default_model_set false, no server
|
|
12
|
+
// snapshot) every role resolves to today's constant — byte-identical to before
|
|
13
|
+
// this feature.
|
|
14
|
+
//
|
|
15
|
+
// Pure in the resolveAuditModel sense: no async I/O, no fetch. The only external
|
|
16
|
+
// read is getServerModelConfig(), a synchronous in-memory snapshot the caller
|
|
17
|
+
// (or the startup fetch) has already populated. `router` is proxy-side only and
|
|
18
|
+
// is deliberately NOT a CLI role.
|
|
19
|
+
import { DEFAULT_SESSION_MODEL } from '../config/model-defaults.js';
|
|
20
|
+
import { PLAN_TIER_SONNET_MODEL } from '../commands/plan-model-tier.js';
|
|
21
|
+
import { COMPACTION_MODEL } from '../commands/compact.js';
|
|
22
|
+
import { getServerModelConfig } from './server-config.js';
|
|
23
|
+
/** First non-empty (trimmed) candidate in precedence order, else the built-in. */
|
|
24
|
+
function firstSet(candidates, builtin) {
|
|
25
|
+
for (const c of candidates) {
|
|
26
|
+
const t = c?.trim();
|
|
27
|
+
if (t)
|
|
28
|
+
return t;
|
|
29
|
+
}
|
|
30
|
+
return builtin;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the model for a pipeline role. Precedence: env > local config >
|
|
34
|
+
* server > built-in constant.
|
|
35
|
+
*
|
|
36
|
+
* `session_default` reads the local `default_model` ONLY when `default_model_set`
|
|
37
|
+
* is true — i.e. the user's config file explicitly carried the key. The value
|
|
38
|
+
* that the DEFAULT_CONFIG merge supplies (when the file omits the key) must NOT
|
|
39
|
+
* beat the server, which is what lets an admin-served default apply while an
|
|
40
|
+
* explicit user choice (even one equal to the built-in) still wins.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveModelRole(role, cfg, env = process.env) {
|
|
43
|
+
const server = getServerModelConfig();
|
|
44
|
+
switch (role) {
|
|
45
|
+
case 'session_default':
|
|
46
|
+
return firstSet([
|
|
47
|
+
env.CSPEACH_DEFAULT_MODEL,
|
|
48
|
+
cfg.default_model_set ? cfg.default_model : undefined,
|
|
49
|
+
server?.roles.session_default,
|
|
50
|
+
], DEFAULT_SESSION_MODEL);
|
|
51
|
+
case 'audit':
|
|
52
|
+
return firstSet([env.CSPEACH_AUDIT_MODEL, cfg.audit_model, server?.roles.audit], PLAN_TIER_SONNET_MODEL);
|
|
53
|
+
case 'compact':
|
|
54
|
+
return firstSet([env.CSPEACH_COMPACT_MODEL, cfg.compact_model, server?.roles.compact], COMPACTION_MODEL);
|
|
55
|
+
default: {
|
|
56
|
+
// Exhaustiveness guard — a new ModelRole must extend the switch.
|
|
57
|
+
const _never = role;
|
|
58
|
+
return _never;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|