@semalt-ai/code 1.8.5 → 1.20.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/.claude/settings.local.json +7 -1
- package/.github/workflows/ci.yml +69 -0
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -316
- package/README.md +148 -4
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +856 -120
- package/lib/api.js +239 -50
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +489 -0
- package/lib/commands/chat-slash.js +415 -0
- package/lib/commands/chat-turn.js +669 -0
- package/lib/commands/chat.js +407 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +360 -11
- package/lib/constants.js +401 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +202 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +270 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +123 -26
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +99 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2862 -0
- package/lib/tool_specs.js +263 -9
- package/lib/tools.js +352 -1039
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +236 -0
- package/lib/ui/format.js +195 -29
- package/lib/ui/input-field.js +21 -11
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +146 -36
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -44
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +270 -0
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/background.test.js +414 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +599 -0
- package/test/extract-tool-calls.test.js +349 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/file-activity.test.js +522 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +143 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +348 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +218 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/md-stream.test.js +183 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +409 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +362 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/result-cap.test.js +233 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +171 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/theme-palette.test.js +166 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +203 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
- package/path +0 -1
package/lib/config.js
CHANGED
|
@@ -4,7 +4,11 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { URL } = require('url');
|
|
6
6
|
|
|
7
|
-
const { CONFIG_PATH, DEFAULT_CONFIG } = require('./constants');
|
|
7
|
+
const { CONFIG_PATH, DEFAULT_CONFIG, DEFAULT_MAX_ITERATIONS, DEFAULT_WEB_MAX_CONTENT_TOKENS, DEFAULT_USER_AGENT, DEFAULT_MCP_MAX_RESULT_TOKENS, DEFAULT_SUBAGENT_MAX_RESULT_TOKENS } = require('./constants');
|
|
8
|
+
const { normalizeHooks, loadHookLayers } = require('./hooks');
|
|
9
|
+
const { normalizeVerify, loadVerifyLayers } = require('./verify');
|
|
10
|
+
const { normalizeCheckpoints } = require('./checkpoints');
|
|
11
|
+
const { normalizeSandbox } = require('./sandbox');
|
|
8
12
|
|
|
9
13
|
let _apiKeyAnyWarned = false;
|
|
10
14
|
const _LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
@@ -12,6 +16,13 @@ const _LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
|
12
16
|
function _maybeWarnApiKeyAny(cfg) {
|
|
13
17
|
if (_apiKeyAnyWarned) return;
|
|
14
18
|
if (cfg.api_key !== 'any') return;
|
|
19
|
+
// A real key from SEMALT_API_KEY or the OS keychain overrides config.api_key,
|
|
20
|
+
// so the 'any' placeholder here is harmless — don't warn in that case.
|
|
21
|
+
try {
|
|
22
|
+
const { apiKeySource } = require('./secrets');
|
|
23
|
+
const src = apiKeySource(cfg);
|
|
24
|
+
if (src === 'env' || src === 'keychain') return;
|
|
25
|
+
} catch {}
|
|
15
26
|
let host = '';
|
|
16
27
|
try {
|
|
17
28
|
host = new URL(cfg.api_base).hostname;
|
|
@@ -27,6 +38,25 @@ function _maybeWarnApiKeyAny(cfg) {
|
|
|
27
38
|
);
|
|
28
39
|
}
|
|
29
40
|
|
|
41
|
+
// Canonicalize a raw max_iterations value (from config or a flag layer) to the
|
|
42
|
+
// stored form: a positive integer, or 0 for "unlimited". Invalid input falls
|
|
43
|
+
// back to the default. Kept JSON-serializable — Infinity is never stored.
|
|
44
|
+
function normalizeMaxIterations(raw) {
|
|
45
|
+
if (raw === 'unlimited') return 0;
|
|
46
|
+
const n = typeof raw === 'number' ? raw : parseInt(raw, 10);
|
|
47
|
+
if (n === 0) return 0;
|
|
48
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
49
|
+
return DEFAULT_MAX_ITERATIONS;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Convert a normalized config max_iterations value into the numeric cap the
|
|
53
|
+
// agent loop consumes. The 0 sentinel maps to Infinity (deliberately unbounded);
|
|
54
|
+
// a positive integer is the cap; anything unexpected falls back to the default.
|
|
55
|
+
function resolveMaxIterations(value) {
|
|
56
|
+
const n = normalizeMaxIterations(value);
|
|
57
|
+
return n === 0 ? Infinity : n;
|
|
58
|
+
}
|
|
59
|
+
|
|
30
60
|
function normalizeConfig(cfg = {}) {
|
|
31
61
|
const merged = { ...DEFAULT_CONFIG, ...cfg };
|
|
32
62
|
// Ensure every DEFAULT_CONFIG key is present without overwriting existing values
|
|
@@ -56,6 +86,103 @@ function normalizeConfig(cfg = {}) {
|
|
|
56
86
|
? cfg.dashboard_model_id
|
|
57
87
|
: null;
|
|
58
88
|
merged.repair_malformed_tool_xml = cfg.repair_malformed_tool_xml === true;
|
|
89
|
+
// Max agent-loop iterations per turn (Pre-Task 4.0a). Canonical form is a
|
|
90
|
+
// number: a positive integer caps the loop; 0 is the "unlimited" sentinel
|
|
91
|
+
// (config.json cannot store Infinity). Accept 'unlimited'/'0' strings and
|
|
92
|
+
// numeric strings; anything else (negative, fractional, garbage) falls back to
|
|
93
|
+
// the documented default.
|
|
94
|
+
merged.max_iterations = normalizeMaxIterations(cfg.max_iterations);
|
|
95
|
+
// Proxy intent (Task 2.2): carried through as strings; sourced from the env
|
|
96
|
+
// config layer (HTTPS_PROXY/HTTP_PROXY). '' means none.
|
|
97
|
+
merged.https_proxy = typeof merged.https_proxy === 'string' ? merged.https_proxy : '';
|
|
98
|
+
merged.http_proxy = typeof merged.http_proxy === 'string' ? merged.http_proxy : '';
|
|
99
|
+
// Cost price overrides (Task 2.6): a plain object map of model → { input, output }.
|
|
100
|
+
merged.pricing = (cfg.pricing && typeof cfg.pricing === 'object' && !Array.isArray(cfg.pricing))
|
|
101
|
+
? cfg.pricing
|
|
102
|
+
: {};
|
|
103
|
+
// Multimodal image input (Task 5.4). `image_max_bytes` is a positive integer
|
|
104
|
+
// byte cap (raw bytes, pre-base64); anything invalid falls back to the default.
|
|
105
|
+
// `image_format` forces the provider content-part shape; only 'anthropic' /
|
|
106
|
+
// 'openai' are honored, else '' (heuristic selection).
|
|
107
|
+
merged.image_max_bytes = (Number.isInteger(cfg.image_max_bytes) && cfg.image_max_bytes > 0)
|
|
108
|
+
? cfg.image_max_bytes
|
|
109
|
+
: DEFAULT_CONFIG.image_max_bytes;
|
|
110
|
+
merged.image_format = (cfg.image_format === 'anthropic' || cfg.image_format === 'openai')
|
|
111
|
+
? cfg.image_format
|
|
112
|
+
: '';
|
|
113
|
+
// Task 2.7 payload flags.
|
|
114
|
+
merged.prompt_caching = cfg.prompt_caching === true;
|
|
115
|
+
merged.reasoning_effort_force = cfg.reasoning_effort_force === true;
|
|
116
|
+
merged.reasoning_effort = ['minimal', 'low', 'medium', 'high'].includes(cfg.reasoning_effort)
|
|
117
|
+
? cfg.reasoning_effort
|
|
118
|
+
: '';
|
|
119
|
+
// MCP config scaffold (Task 3.2). Always normalized to an object with a
|
|
120
|
+
// `servers` object map, so Task 3.3 can read `cfg.mcp.servers` unconditionally.
|
|
121
|
+
const mcpIn = (cfg.mcp && typeof cfg.mcp === 'object' && !Array.isArray(cfg.mcp)) ? cfg.mcp : {};
|
|
122
|
+
// max_result_tokens (Task W.8): the STRICTER token cap applied to an MCP tool
|
|
123
|
+
// result before it enters context (third-party / untrusted). A positive integer
|
|
124
|
+
// wins; anything else falls back to the default.
|
|
125
|
+
let _mcpResultTok = parseInt(mcpIn.max_result_tokens, 10);
|
|
126
|
+
if (!Number.isInteger(_mcpResultTok) || _mcpResultTok < 1) _mcpResultTok = DEFAULT_MCP_MAX_RESULT_TOKENS;
|
|
127
|
+
merged.mcp = {
|
|
128
|
+
servers: (mcpIn.servers && typeof mcpIn.servers === 'object' && !Array.isArray(mcpIn.servers))
|
|
129
|
+
? mcpIn.servers
|
|
130
|
+
: {},
|
|
131
|
+
max_result_tokens: _mcpResultTok,
|
|
132
|
+
};
|
|
133
|
+
// Lifecycle hooks (Task 3.4). Always normalized to a map with one array per
|
|
134
|
+
// known event, so dispatch in lib/agent.js / lib/hooks.js can read
|
|
135
|
+
// cfg.hooks[event] unconditionally. Empty by default.
|
|
136
|
+
merged.hooks = normalizeHooks(cfg.hooks);
|
|
137
|
+
// Subagents (Task 3.6). Always normalized to an object carrying the bounded
|
|
138
|
+
// parallel-execution cap, so lib/subagents.js can read it unconditionally.
|
|
139
|
+
const subIn = (cfg.subagents && typeof cfg.subagents === 'object' && !Array.isArray(cfg.subagents)) ? cfg.subagents : {};
|
|
140
|
+
let _maxConc = parseInt(subIn.max_concurrency, 10);
|
|
141
|
+
if (!Number.isInteger(_maxConc) || _maxConc < 1) _maxConc = 3;
|
|
142
|
+
if (_maxConc > 16) _maxConc = 16;
|
|
143
|
+
// max_result_tokens (Task W.8): the GENEROUS token cap on a subagent's final
|
|
144
|
+
// text before it enters the parent context (our own child's synthesized answer
|
|
145
|
+
// — a safety net against a verbose child, strictly larger than the MCP cap).
|
|
146
|
+
let _subResultTok = parseInt(subIn.max_result_tokens, 10);
|
|
147
|
+
if (!Number.isInteger(_subResultTok) || _subResultTok < 1) _subResultTok = DEFAULT_SUBAGENT_MAX_RESULT_TOKENS;
|
|
148
|
+
merged.subagents = { max_concurrency: _maxConc, max_result_tokens: _subResultTok };
|
|
149
|
+
// Per-pattern permission rules (Task 4.1). Normalized to a shape-only
|
|
150
|
+
// `{ rules: [...] }` here — the security-critical per-LAYER validation and
|
|
151
|
+
// compilation lives in lib/permission-rules.js (loadRuleLayers), which reads
|
|
152
|
+
// the user/project files independently so the project layer can only narrow.
|
|
153
|
+
const permIn = (cfg.permissions && typeof cfg.permissions === 'object' && !Array.isArray(cfg.permissions)) ? cfg.permissions : {};
|
|
154
|
+
merged.permissions = { rules: Array.isArray(permIn.rules) ? permIn.rules : [] };
|
|
155
|
+
// Self-verification (Task 4.2). Always normalized to a complete verify spec so
|
|
156
|
+
// lib/verify.js / lib/agent.js can read cfg.verify unconditionally. Empty
|
|
157
|
+
// command by default → the feature is a no-op until the user configures one.
|
|
158
|
+
merged.verify = normalizeVerify(cfg.verify);
|
|
159
|
+
// Checkpoints & rewind (Task 4.3). Always normalized to a complete spec so
|
|
160
|
+
// lib/checkpoints.js can read cfg.checkpoints unconditionally. Enabled by
|
|
161
|
+
// default; bounded by a per-file size cap and a per-session retention cap.
|
|
162
|
+
merged.checkpoints = normalizeCheckpoints(cfg.checkpoints);
|
|
163
|
+
// OS sandbox (Task 4.4). Always normalized so lib/sandbox.js / lib/tools.js can
|
|
164
|
+
// read cfg.sandbox unconditionally. `auto` by default; `off` is a human-only
|
|
165
|
+
// opt-out (no model-reachable path can set it — config files are human-edited).
|
|
166
|
+
merged.sandbox = normalizeSandbox(cfg.sandbox);
|
|
167
|
+
// Web-fetch pipeline (Task W.1). Always normalized so http_get can read
|
|
168
|
+
// cfg.web unconditionally. `summarize` defaults ON (the big token win); only
|
|
169
|
+
// an explicit `false` opts out. `summary_model` is a trimmed string ('' →
|
|
170
|
+
// current model). `max_content_tokens` is a sane positive integer (clamped),
|
|
171
|
+
// the cap on extracted content fed to the summarizer / context.
|
|
172
|
+
const webIn = (cfg.web && typeof cfg.web === 'object' && !Array.isArray(cfg.web)) ? cfg.web : {};
|
|
173
|
+
let _webMaxTok = parseInt(webIn.max_content_tokens, 10);
|
|
174
|
+
if (!Number.isInteger(_webMaxTok) || _webMaxTok < 256) _webMaxTok = DEFAULT_WEB_MAX_CONTENT_TOKENS;
|
|
175
|
+
if (_webMaxTok > 200000) _webMaxTok = 200000;
|
|
176
|
+
merged.web = {
|
|
177
|
+
summarize: webIn.summarize === false ? false : true,
|
|
178
|
+
summary_model: typeof webIn.summary_model === 'string' ? webIn.summary_model.trim() : '',
|
|
179
|
+
max_content_tokens: _webMaxTok,
|
|
180
|
+
// User-Agent for http_get/download (Task W.3 Part 2). A trimmed operator
|
|
181
|
+
// override; empty/missing → the fixed DEFAULT_USER_AGENT. Human-only — the
|
|
182
|
+
// agent has no way to set it (no UA parameter in the tool spec).
|
|
183
|
+
user_agent: typeof webIn.user_agent === 'string' && webIn.user_agent.trim()
|
|
184
|
+
? webIn.user_agent.trim() : DEFAULT_USER_AGENT,
|
|
185
|
+
};
|
|
59
186
|
merged.models = Array.isArray(cfg.models)
|
|
60
187
|
? cfg.models
|
|
61
188
|
.filter((entry) => entry &&
|
|
@@ -79,22 +206,198 @@ function normalizeConfig(cfg = {}) {
|
|
|
79
206
|
// native_tools defaults to true; only explicit false/0/"false"/"0" opts out.
|
|
80
207
|
const nt = entry.native_tools;
|
|
81
208
|
normalized.native_tools = !(nt === false || nt === 0 || nt === '0' || nt === 'false');
|
|
209
|
+
// inline_reasoning (live-narration safety signal): an OPTIONAL explicit
|
|
210
|
+
// boolean assertion about whether this model inlines its reasoning into
|
|
211
|
+
// delta.content (Qwen3-style bare text + orphan </think>). Left unset by
|
|
212
|
+
// default → assume it MIGHT inline (safe default: keep buffering until a
|
|
213
|
+
// boundary). Only an explicit `false` asserts "never inlines" → the agent
|
|
214
|
+
// loop may stream narration live from token 1 on the native rail. Only an
|
|
215
|
+
// explicit boolean is persisted; any other value is dropped (stays unset).
|
|
216
|
+
if (typeof entry.inline_reasoning === 'boolean') {
|
|
217
|
+
normalized.inline_reasoning = entry.inline_reasoning;
|
|
218
|
+
}
|
|
219
|
+
// Multimodal image input (Task 5.4). `vision` (only when an explicit
|
|
220
|
+
// boolean) marks the profile vision-capable or text-only; a text-only
|
|
221
|
+
// profile makes an image attach fail LOUD rather than silently drop.
|
|
222
|
+
// `image_format` forces this profile's provider content-part shape.
|
|
223
|
+
if (typeof entry.vision === 'boolean') normalized.vision = entry.vision;
|
|
224
|
+
if (entry.image_format === 'anthropic' || entry.image_format === 'openai') {
|
|
225
|
+
normalized.image_format = entry.image_format;
|
|
226
|
+
}
|
|
82
227
|
return normalized;
|
|
83
228
|
})
|
|
84
229
|
: [];
|
|
85
230
|
return merged;
|
|
86
231
|
}
|
|
87
232
|
|
|
88
|
-
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Layered configuration (Task 2.2)
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
//
|
|
237
|
+
// Precedence, lowest to highest:
|
|
238
|
+
// 1. user config ~/.semalt-ai/config.json
|
|
239
|
+
// 2. project config .semalt/config.json (nearest, found by walking up to the
|
|
240
|
+
// repo root from the current working directory)
|
|
241
|
+
// 3. environment SEMALT_API_BASE, SEMALT_MODEL, HTTPS_PROXY/HTTP_PROXY
|
|
242
|
+
// 4. CLI flags --api-base, --api-key, --dashboard-url, --default-model
|
|
243
|
+
//
|
|
244
|
+
// The merge itself is a pure function (mergeConfigLayers) so each combination is
|
|
245
|
+
// unit-testable. API-key sourcing is intentionally NOT part of this merge — it
|
|
246
|
+
// stays in lib/secrets.js (SEMALT_API_KEY env → OS keychain → config.api_key),
|
|
247
|
+
// preserving the Phase 0 precedence.
|
|
248
|
+
|
|
249
|
+
// Raw read of the user config file (or null). Ensures the config dir exists so
|
|
250
|
+
// first-run save paths keep working.
|
|
251
|
+
function readUserConfig() {
|
|
89
252
|
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
253
|
+
if (!fs.existsSync(CONFIG_PATH)) return null;
|
|
254
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch { return null; }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The user-file layer ONLY (no project/env/flags overlay). Persistence is based
|
|
258
|
+
// on this so transient overrides are never written back to config.json.
|
|
259
|
+
function loadUserConfig() {
|
|
260
|
+
return normalizeConfig(readUserConfig() || {});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Walk up from startDir looking for .semalt/config.json. Bounded by the repo
|
|
264
|
+
// root: the directory holding .git is the last one checked. Returns the path of
|
|
265
|
+
// the nearest project config, or null.
|
|
266
|
+
function findProjectConfigPath(startDir) {
|
|
267
|
+
let dir = startDir;
|
|
268
|
+
while (true) {
|
|
269
|
+
const candidate = path.join(dir, '.semalt', 'config.json');
|
|
270
|
+
try { if (fs.existsSync(candidate)) return candidate; } catch {}
|
|
271
|
+
let atRepoRoot = false;
|
|
272
|
+
try { atRepoRoot = fs.existsSync(path.join(dir, '.git')); } catch {}
|
|
273
|
+
if (atRepoRoot) break;
|
|
274
|
+
const parent = path.dirname(dir);
|
|
275
|
+
if (parent === dir) break; // filesystem root
|
|
276
|
+
dir = parent;
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function loadProjectConfig(startDir) {
|
|
282
|
+
const p = findProjectConfigPath(startDir);
|
|
283
|
+
if (!p) return null;
|
|
284
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Environment → partial config layer. Pure: takes the env object. SEMALT_API_KEY
|
|
288
|
+
// is deliberately absent (owned by secrets.js).
|
|
289
|
+
function envConfigLayer(env = process.env) {
|
|
290
|
+
const layer = {};
|
|
291
|
+
if (typeof env.SEMALT_API_BASE === 'string' && env.SEMALT_API_BASE.trim()) {
|
|
292
|
+
layer.api_base = env.SEMALT_API_BASE.trim();
|
|
293
|
+
}
|
|
294
|
+
if (typeof env.SEMALT_MODEL === 'string' && env.SEMALT_MODEL.trim()) {
|
|
295
|
+
layer.default_model = env.SEMALT_MODEL.trim();
|
|
296
|
+
}
|
|
297
|
+
const httpsProxy = env.HTTPS_PROXY || env.https_proxy;
|
|
298
|
+
const httpProxy = env.HTTP_PROXY || env.http_proxy;
|
|
299
|
+
if (typeof httpsProxy === 'string' && httpsProxy.trim()) layer.https_proxy = httpsProxy.trim();
|
|
300
|
+
if (typeof httpProxy === 'string' && httpProxy.trim()) layer.http_proxy = httpProxy.trim();
|
|
301
|
+
return layer;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// CLI flags → partial config layer. Pure: takes argv (the args after the
|
|
305
|
+
// subcommand). Only config-bearing flags are mapped; everything else is ignored.
|
|
306
|
+
function flagsConfigLayer(argv = []) {
|
|
307
|
+
const layer = {};
|
|
308
|
+
const MAP = {
|
|
309
|
+
'--api-base': 'api_base',
|
|
310
|
+
'--api-key': 'api_key',
|
|
311
|
+
'--dashboard-url': 'dashboard_url',
|
|
312
|
+
'--default-model': 'default_model',
|
|
313
|
+
'--reasoning-effort': 'reasoning_effort', // Task 2.7
|
|
314
|
+
'--max-iterations': 'max_iterations', // Pre-Task 4.0a
|
|
315
|
+
};
|
|
316
|
+
const BOOL = {
|
|
317
|
+
'--prompt-caching': 'prompt_caching', // Task 2.7
|
|
318
|
+
};
|
|
319
|
+
for (let i = 0; i < argv.length; i++) {
|
|
320
|
+
if (BOOL[argv[i]]) { layer[BOOL[argv[i]]] = true; continue; }
|
|
321
|
+
const key = MAP[argv[i]];
|
|
322
|
+
if (!key) continue;
|
|
323
|
+
const val = argv[i + 1];
|
|
324
|
+
if (val === undefined || val.startsWith('-')) continue;
|
|
325
|
+
layer[key] = val;
|
|
326
|
+
i++;
|
|
96
327
|
}
|
|
97
|
-
|
|
328
|
+
return layer;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Pure precedence engine: shallow-merge the given partial layers (lowest first),
|
|
332
|
+
// skipping nullish layers and undefined values, then normalize. Exported and
|
|
333
|
+
// unit-tested across every layer combination.
|
|
334
|
+
function mergeConfigLayers(layers) {
|
|
335
|
+
const merged = {};
|
|
336
|
+
for (const layer of layers || []) {
|
|
337
|
+
if (!layer || typeof layer !== 'object') continue;
|
|
338
|
+
for (const [k, v] of Object.entries(layer)) {
|
|
339
|
+
if (v === undefined) continue;
|
|
340
|
+
merged[k] = v;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return normalizeConfig(merged);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Pure: given the merged config a caller wants to persist, the previous merged
|
|
347
|
+
// view, and the current user-file object, return the object to write to the user
|
|
348
|
+
// config file. Only keys the caller actually CHANGED (vs the previous merged
|
|
349
|
+
// view) are layered onto the user file — so env/project/flag overrides merely
|
|
350
|
+
// carried along in `next` are never baked into config.json.
|
|
351
|
+
function userLayerForPersist(next, prevMerged, userFile) {
|
|
352
|
+
const out = { ...(userFile || {}) };
|
|
353
|
+
const prev = prevMerged || {};
|
|
354
|
+
for (const k of Object.keys(next || {})) {
|
|
355
|
+
if (JSON.stringify(next[k]) !== JSON.stringify(prev[k])) out[k] = next[k];
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// SECURITY (Pre-Task 5.0a): hooks/verify are EXECUTABLE surfaces, and a project
|
|
361
|
+
// .semalt/config.json is attacker-controllable in a cloned repo. The shallow
|
|
362
|
+
// merge above lets a project layer introduce a command-type hook or a
|
|
363
|
+
// verify.command that would then run with host privileges. Mirroring how
|
|
364
|
+
// permission rules are loaded (loadRuleLayers reads the layers SEPARATELY so the
|
|
365
|
+
// project layer can only narrow), we re-resolve hooks/verify from the RAW user
|
|
366
|
+
// and project layers and quarantine project-introduced executables — keeping
|
|
367
|
+
// project PROMPT hooks (text injection only, already untrusted). The warning
|
|
368
|
+
// fires once so the user knows a project executable was ignored.
|
|
369
|
+
let _quarantineWarned = false;
|
|
370
|
+
function applyExecutableQuarantine(cfg, userRaw, projectRaw) {
|
|
371
|
+
const hookLayers = loadHookLayers(userRaw && userRaw.hooks, projectRaw && projectRaw.hooks);
|
|
372
|
+
cfg.hooks = hookLayers.hooks;
|
|
373
|
+
const verifyLayers = loadVerifyLayers(userRaw && userRaw.verify, projectRaw && projectRaw.verify);
|
|
374
|
+
cfg.verify = verifyLayers.verify;
|
|
375
|
+
if (!_quarantineWarned && (hookLayers.quarantined.length || verifyLayers.quarantinedCommand)) {
|
|
376
|
+
_quarantineWarned = true;
|
|
377
|
+
for (const q of hookLayers.quarantined) {
|
|
378
|
+
// audit: allowed — pre-UI startup warning, fires once before the TUI initialises.
|
|
379
|
+
process.stderr.write(`⚠ Ignored project-layer ${q.event} command hook (executable hooks from .semalt/config.json are quarantined): ${q.command}\n`);
|
|
380
|
+
}
|
|
381
|
+
if (verifyLayers.quarantinedCommand) {
|
|
382
|
+
process.stderr.write(`⚠ Ignored project-layer verify.command (quarantined; executable verify from .semalt/config.json is not honored): ${verifyLayers.quarantinedCommand}\n`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return cfg;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// The merged runtime config. `argv` defaults to the process args after the node
|
|
389
|
+
// binary + script; callers that already parsed args may pass the relevant slice.
|
|
390
|
+
function loadConfig(argv) {
|
|
391
|
+
const flagsArgv = Array.isArray(argv) ? argv : process.argv.slice(2);
|
|
392
|
+
const userRaw = readUserConfig();
|
|
393
|
+
const projectRaw = loadProjectConfig(process.cwd());
|
|
394
|
+
const cfg = mergeConfigLayers([
|
|
395
|
+
userRaw,
|
|
396
|
+
projectRaw,
|
|
397
|
+
envConfigLayer(process.env),
|
|
398
|
+
flagsConfigLayer(flagsArgv),
|
|
399
|
+
]);
|
|
400
|
+
applyExecutableQuarantine(cfg, userRaw, projectRaw);
|
|
98
401
|
_maybeWarnApiKeyAny(cfg);
|
|
99
402
|
return cfg;
|
|
100
403
|
}
|
|
@@ -105,7 +408,9 @@ function saveConfig(cfg) {
|
|
|
105
408
|
}
|
|
106
409
|
|
|
107
410
|
function configSet(key, value) {
|
|
108
|
-
|
|
411
|
+
// Operate on the USER file only — never the merged view — so a `config set`
|
|
412
|
+
// never bakes a project/env/flag override into config.json.
|
|
413
|
+
const cfg = loadUserConfig();
|
|
109
414
|
cfg[key] = value;
|
|
110
415
|
saveConfig(cfg);
|
|
111
416
|
return cfg;
|
|
@@ -123,15 +428,48 @@ function isNativeToolsActive(model) {
|
|
|
123
428
|
return !(profile && profile.native_tools === false);
|
|
124
429
|
}
|
|
125
430
|
|
|
431
|
+
// Resolves the active profile's `inline_reasoning` assertion (live-narration
|
|
432
|
+
// safety signal b). Returns the explicit boolean if the profile sets one, else
|
|
433
|
+
// `undefined` ("unknown — assume it might inline reasoning"). Mirrors the
|
|
434
|
+
// profile lookup used by isNativeToolsActive. The agent loop treats only an
|
|
435
|
+
// explicit `false` as the safe-to-stream-live signal.
|
|
436
|
+
function getInlineReasoning(model) {
|
|
437
|
+
const cfg = loadConfig();
|
|
438
|
+
if (!Array.isArray(cfg.models)) return undefined;
|
|
439
|
+
const profile = cfg.models.find(
|
|
440
|
+
(p) => p && p.api_base === cfg.api_base && p.model === model
|
|
441
|
+
);
|
|
442
|
+
return profile && typeof profile.inline_reasoning === 'boolean'
|
|
443
|
+
? profile.inline_reasoning
|
|
444
|
+
: undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
126
447
|
const REDACTED_KEYS = new Set(['api_key', 'auth_token']);
|
|
127
448
|
|
|
128
449
|
function configShow(systemPromptOverride = null) {
|
|
129
450
|
const cfg = loadConfig();
|
|
130
451
|
const lines = ['Config:'];
|
|
131
452
|
for (const [key, val] of Object.entries(cfg)) {
|
|
132
|
-
|
|
453
|
+
let display;
|
|
454
|
+
if (REDACTED_KEYS.has(key) && val) {
|
|
455
|
+
display = '****';
|
|
456
|
+
} else if (key === 'models' && Array.isArray(val)) {
|
|
457
|
+
// Per-profile api_key values are secrets too — redact them before display.
|
|
458
|
+
display = JSON.stringify(val.map((m) => (
|
|
459
|
+
m && typeof m === 'object' && 'api_key' in m ? { ...m, api_key: m.api_key ? '****' : m.api_key } : m
|
|
460
|
+
)));
|
|
461
|
+
} else {
|
|
462
|
+
display = JSON.stringify(val);
|
|
463
|
+
}
|
|
133
464
|
lines.push(` ${key}: ${display}`);
|
|
134
465
|
}
|
|
466
|
+
// Surface WHERE the active API key resolves from (env / keychain / config /
|
|
467
|
+
// none) without ever printing the key itself. Keys from env/keychain are not
|
|
468
|
+
// stored in config.json, so this is the only signal the user gets.
|
|
469
|
+
try {
|
|
470
|
+
const { apiKeySource } = require('./secrets');
|
|
471
|
+
lines.push(` api_key_source: ${apiKeySource(cfg)}`);
|
|
472
|
+
} catch {}
|
|
135
473
|
if (systemPromptOverride) {
|
|
136
474
|
lines.push(` system_prompt: [override from ${systemPromptOverride}]`);
|
|
137
475
|
} else {
|
|
@@ -145,7 +483,18 @@ module.exports = {
|
|
|
145
483
|
configSet,
|
|
146
484
|
configShow,
|
|
147
485
|
isNativeToolsActive,
|
|
486
|
+
getInlineReasoning,
|
|
148
487
|
loadConfig,
|
|
488
|
+
loadUserConfig,
|
|
149
489
|
normalizeConfig,
|
|
490
|
+
resolveMaxIterations,
|
|
150
491
|
saveConfig,
|
|
492
|
+
// Layered-config building blocks (Task 2.2) — pure and individually testable.
|
|
493
|
+
mergeConfigLayers,
|
|
494
|
+
envConfigLayer,
|
|
495
|
+
flagsConfigLayer,
|
|
496
|
+
findProjectConfigPath,
|
|
497
|
+
loadProjectConfig,
|
|
498
|
+
readUserConfig,
|
|
499
|
+
userLayerForPersist,
|
|
151
500
|
};
|