@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.
Files changed (192) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/ARCHITECTURE.md +6 -95
  4. package/CLAUDE.md +196 -316
  5. package/README.md +148 -4
  6. package/docs/ARCHITECTURE.md +1321 -0
  7. package/docs/CONFIG.md +340 -0
  8. package/docs/HISTORY.md +245 -0
  9. package/examples/embed.js +74 -0
  10. package/index.js +251 -10
  11. package/lib/agent.js +856 -120
  12. package/lib/api.js +239 -50
  13. package/lib/args.js +74 -2
  14. package/lib/audit.js +23 -1
  15. package/lib/background.js +584 -0
  16. package/lib/checkpoints.js +757 -0
  17. package/lib/commands/auth.js +94 -0
  18. package/lib/commands/chat-session.js +489 -0
  19. package/lib/commands/chat-slash.js +415 -0
  20. package/lib/commands/chat-turn.js +669 -0
  21. package/lib/commands/chat.js +407 -0
  22. package/lib/commands/custom.js +157 -0
  23. package/lib/commands/history-utils.js +66 -0
  24. package/lib/commands/index.js +268 -0
  25. package/lib/commands/mcp.js +113 -0
  26. package/lib/commands/oneshot.js +193 -0
  27. package/lib/commands/registry.js +269 -0
  28. package/lib/commands/tasks.js +89 -0
  29. package/lib/compact.js +87 -0
  30. package/lib/config.js +360 -11
  31. package/lib/constants.js +401 -3
  32. package/lib/deny.js +199 -0
  33. package/lib/doctor.js +160 -0
  34. package/lib/headless.js +202 -0
  35. package/lib/hooks.js +286 -0
  36. package/lib/images.js +270 -0
  37. package/lib/internals.js +49 -0
  38. package/lib/mcp/boundary.js +131 -0
  39. package/lib/mcp/client.js +270 -0
  40. package/lib/mcp/oauth.js +134 -0
  41. package/lib/memory.js +209 -0
  42. package/lib/metrics.js +37 -2
  43. package/lib/payload.js +54 -0
  44. package/lib/permission-rules.js +401 -0
  45. package/lib/permissions.js +123 -26
  46. package/lib/pricing.js +67 -0
  47. package/lib/proc.js +62 -0
  48. package/lib/prompts.js +99 -8
  49. package/lib/sandbox.js +568 -0
  50. package/lib/sdk.js +328 -0
  51. package/lib/secrets.js +211 -0
  52. package/lib/skills.js +223 -0
  53. package/lib/subagents.js +516 -0
  54. package/lib/tool_registry.js +2862 -0
  55. package/lib/tool_specs.js +263 -9
  56. package/lib/tools.js +352 -1039
  57. package/lib/ui/anim.js +86 -0
  58. package/lib/ui/ansi.js +17 -27
  59. package/lib/ui/chat-history.js +253 -71
  60. package/lib/ui/create-ui.js +67 -24
  61. package/lib/ui/diff.js +90 -25
  62. package/lib/ui/file-activity.js +236 -0
  63. package/lib/ui/format.js +195 -29
  64. package/lib/ui/input-field.js +21 -11
  65. package/lib/ui/md-stream.js +234 -0
  66. package/lib/ui/render-operation.js +113 -0
  67. package/lib/ui/select.js +1 -4
  68. package/lib/ui/status-bar.js +146 -36
  69. package/lib/ui/stream.js +20 -13
  70. package/lib/ui/theme.js +190 -44
  71. package/lib/ui/tool-operation.js +190 -0
  72. package/lib/ui/utils.js +9 -5
  73. package/lib/ui/web-activity.js +270 -0
  74. package/lib/ui/writer.js +159 -45
  75. package/lib/ui.js +1 -1
  76. package/lib/verify.js +229 -0
  77. package/lib/web-extract.js +213 -0
  78. package/lib/web-summarize.js +68 -0
  79. package/package.json +19 -4
  80. package/scripts/lint.js +57 -0
  81. package/test/agent-loop.test.js +389 -0
  82. package/test/anim-driver.test.js +153 -0
  83. package/test/ask-user-display.test.js +226 -0
  84. package/test/ask-user-gate.test.js +231 -0
  85. package/test/background.test.js +414 -0
  86. package/test/chat-history-nocolor.test.js +155 -0
  87. package/test/chat-relogin.test.js +207 -0
  88. package/test/chat.test.js +114 -0
  89. package/test/checkpoints-agent.test.js +181 -0
  90. package/test/checkpoints.test.js +650 -0
  91. package/test/command-registry.test.js +160 -0
  92. package/test/compact.test.js +116 -0
  93. package/test/completion-lazy.test.js +52 -0
  94. package/test/config-merge.test.js +324 -0
  95. package/test/config-quarantine.test.js +128 -0
  96. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  97. package/test/config-write-guard-skip.test.js +46 -0
  98. package/test/config-write-guard.test.js +153 -0
  99. package/test/context-split.test.js +215 -0
  100. package/test/cost-doctor.test.js +142 -0
  101. package/test/custom-commands-chat.test.js +106 -0
  102. package/test/custom-commands.test.js +230 -0
  103. package/test/defer-detail-band.test.js +403 -0
  104. package/test/deny-windows.test.js +120 -0
  105. package/test/deny.test.js +83 -0
  106. package/test/detail-band-tab-flatten.test.js +242 -0
  107. package/test/download-allow-anywhere.test.js +66 -0
  108. package/test/download-confine.test.js +153 -0
  109. package/test/exec-diff.test.js +268 -0
  110. package/test/executors.test.js +599 -0
  111. package/test/extract-tool-calls.test.js +349 -0
  112. package/test/fetch-url-validation.test.js +219 -0
  113. package/test/file-activity.test.js +522 -0
  114. package/test/fixtures/tool-calls.js +57 -0
  115. package/test/fixtures/web-page.js +91 -0
  116. package/test/git-tools.test.js +384 -0
  117. package/test/grep-glob-serialize.test.js +242 -0
  118. package/test/grep-glob.test.js +268 -0
  119. package/test/grep-path-target.test.js +227 -0
  120. package/test/harness/README.md +57 -0
  121. package/test/harness/chat-harness.js +143 -0
  122. package/test/harness/memwarn-headless-child.js +65 -0
  123. package/test/harness/mock-llm.js +120 -0
  124. package/test/harness/mock-mcp-server.js +142 -0
  125. package/test/harness/sse-server.js +69 -0
  126. package/test/headless.test.js +348 -0
  127. package/test/history-utils.test.js +88 -0
  128. package/test/hooks-agent.test.js +238 -0
  129. package/test/hooks-verify-sandbox.test.js +232 -0
  130. package/test/hooks.test.js +216 -0
  131. package/test/http-get-user-agent.test.js +142 -0
  132. package/test/images-api.test.js +208 -0
  133. package/test/images.test.js +238 -0
  134. package/test/input-field-ctrl-o.test.js +37 -0
  135. package/test/live-height-physical.test.js +281 -0
  136. package/test/max-iterations.test.js +218 -0
  137. package/test/mcp-boundary.test.js +57 -0
  138. package/test/mcp-client.test.js +267 -0
  139. package/test/mcp-oauth.test.js +86 -0
  140. package/test/md-stream.test.js +183 -0
  141. package/test/memory-truncation-warning.test.js +222 -0
  142. package/test/memory.test.js +198 -0
  143. package/test/native-dispatch.test.js +409 -0
  144. package/test/native-live-narration.test.js +254 -0
  145. package/test/output-chokepoint.test.js +188 -0
  146. package/test/output-heredoc-leak.test.js +195 -0
  147. package/test/output-preview.test.js +245 -0
  148. package/test/path-guards.test.js +134 -0
  149. package/test/payload.test.js +99 -0
  150. package/test/permission-rules-agent.test.js +210 -0
  151. package/test/permission-rules.test.js +297 -0
  152. package/test/permissions.test.js +362 -0
  153. package/test/plan-mode.test.js +167 -0
  154. package/test/read-paginate.test.js +275 -0
  155. package/test/readonly-tools.test.js +177 -0
  156. package/test/render-operation.test.js +317 -0
  157. package/test/replay-descriptor-xml.test.js +216 -0
  158. package/test/replay-descriptor.test.js +189 -0
  159. package/test/replay-web-aggregate.test.js +291 -0
  160. package/test/replay-web-persist.test.js +241 -0
  161. package/test/result-cap.test.js +233 -0
  162. package/test/running-glyph-anim.test.js +111 -0
  163. package/test/sandbox-agent.test.js +147 -0
  164. package/test/sandbox-integration.test.js +216 -0
  165. package/test/sandbox.test.js +408 -0
  166. package/test/sdk.test.js +234 -0
  167. package/test/shell-output-cap.test.js +181 -0
  168. package/test/skills-chat.test.js +110 -0
  169. package/test/skills.test.js +295 -0
  170. package/test/smoke.test.js +68 -0
  171. package/test/status-bar-driver.test.js +93 -0
  172. package/test/status-bar-pause.test.js +164 -0
  173. package/test/status-bar-resync.test.js +188 -0
  174. package/test/stream-parser.test.js +171 -0
  175. package/test/subagents-agent.test.js +178 -0
  176. package/test/subagents.test.js +222 -0
  177. package/test/theme-palette.test.js +166 -0
  178. package/test/tool-registry.test.js +85 -0
  179. package/test/trim-budget.test.js +101 -0
  180. package/test/truncate-visible.test.js +78 -0
  181. package/test/verify-agent.test.js +317 -0
  182. package/test/verify.test.js +141 -0
  183. package/test/view-image.test.js +199 -0
  184. package/test/web-activity-ordering.test.js +203 -0
  185. package/test/web-activity.test.js +207 -0
  186. package/test/web-data-extraction-guidance.test.js +71 -0
  187. package/test/web-extract.test.js +185 -0
  188. package/test/web-fetch-agent.test.js +291 -0
  189. package/test/web-fetch-mode.test.js +193 -0
  190. package/test/web-search.test.js +380 -0
  191. package/lib/commands.js +0 -1438
  192. 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
- function loadConfig() {
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
- let cfg;
91
- if (fs.existsSync(CONFIG_PATH)) {
92
- try {
93
- const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
94
- cfg = normalizeConfig(data);
95
- } catch {}
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
- if (!cfg) cfg = normalizeConfig();
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
- const cfg = loadConfig();
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
- const display = REDACTED_KEYS.has(key) && val ? '****' : JSON.stringify(val);
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
  };