atom-agent 0.3.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +83 -32
  3. package/dist/App.js +2178 -318
  4. package/dist/adapters.js +146 -15
  5. package/dist/agent/gates.js +153 -0
  6. package/dist/agent/loop-guard.js +184 -0
  7. package/dist/agent/loop.js +908 -0
  8. package/dist/agent/normalize.js +144 -0
  9. package/dist/agent/types.js +1 -0
  10. package/dist/auth.js +2 -1
  11. package/dist/cli.js +68 -6
  12. package/dist/compact.js +6 -48
  13. package/dist/config.js +171 -0
  14. package/dist/context-manager.js +564 -0
  15. package/dist/kilo.js +343 -0
  16. package/dist/local-discovery.js +308 -0
  17. package/dist/policy.js +286 -0
  18. package/dist/prompt-cache.js +99 -0
  19. package/dist/providers.js +183 -2
  20. package/dist/rollback.js +21 -0
  21. package/dist/scheduler.js +247 -0
  22. package/dist/session.js +35 -3
  23. package/dist/skills.js +214 -43
  24. package/dist/snapshots.js +57 -2
  25. package/dist/system.js +8 -1
  26. package/dist/telemetry-dashboard.js +589 -0
  27. package/dist/telemetry-server.js +301 -0
  28. package/dist/telemetry.js +1056 -0
  29. package/dist/tools/dir-cache.js +207 -0
  30. package/dist/tools/filesystem.js +149 -0
  31. package/dist/tools/fingerprints.js +33 -0
  32. package/dist/tools/overflow.js +76 -0
  33. package/dist/tools/read-cache.js +160 -0
  34. package/dist/tools/registry.js +802 -0
  35. package/dist/tools/search.js +242 -0
  36. package/dist/tools/shared.js +31 -0
  37. package/dist/tools/shell.js +273 -0
  38. package/dist/tools/todo.js +191 -0
  39. package/dist/tools/web.js +454 -0
  40. package/dist/tools.js +17 -1863
  41. package/dist/ui/activity.js +51 -0
  42. package/dist/ui/diff-panel.js +55 -0
  43. package/dist/ui/diff-view.js +112 -0
  44. package/dist/ui/diff.js +422 -0
  45. package/dist/ui/errors.js +129 -0
  46. package/dist/ui/highlight.js +120 -0
  47. package/dist/ui/input-model.js +115 -0
  48. package/dist/ui/input.js +40 -0
  49. package/dist/ui/live-tail.js +15 -0
  50. package/dist/ui/markdown.js +525 -0
  51. package/dist/ui/modals.js +47 -0
  52. package/dist/ui/palette.js +70 -0
  53. package/dist/ui/pickers.js +32 -0
  54. package/dist/ui/side-by-side.js +144 -0
  55. package/dist/ui/status-bar.js +75 -0
  56. package/dist/ui/theme.js +128 -0
  57. package/dist/ui/todo-panel.js +30 -0
  58. package/dist/ui/tool-inspector.js +59 -0
  59. package/dist/ui/transcript.js +128 -0
  60. package/dist/zen.js +145 -666
  61. package/package.json +1 -1
@@ -0,0 +1,144 @@
1
+ // Safety net for custom executors (built-in tools already cap: read 64KB
2
+ // head + truncation note + overflow pointer ≈ 66KB, bash 8KB, webfetch 64KB
3
+ // + notes). The cap sits at 128KB so legitimate built-in outputs (overflow
4
+ // pointers included) pass through byte-identical; only oversized custom
5
+ // results truncate.
6
+ export const TOOL_RESULT_CAP_CHARS = 128 * 1024;
7
+ export function normalizeToolResult(result) {
8
+ let text;
9
+ if (typeof result === "string") {
10
+ text = result;
11
+ }
12
+ else if (result === null || result === undefined) {
13
+ return "Error: tool returned no result";
14
+ }
15
+ else {
16
+ try {
17
+ const json = JSON.stringify(result);
18
+ text = typeof json === "string" ? json : String(result);
19
+ }
20
+ catch {
21
+ try {
22
+ text = String(result);
23
+ }
24
+ catch {
25
+ return "Error: tool returned an unreadable result";
26
+ }
27
+ }
28
+ }
29
+ if (text.length > TOOL_RESULT_CAP_CHARS) {
30
+ return (text.slice(0, TOOL_RESULT_CAP_CHARS) +
31
+ `\n[truncated: tool result exceeded ${TOOL_RESULT_CAP_CHARS} chars]`);
32
+ }
33
+ return text;
34
+ }
35
+ // Recursively key-sorted JSON for stable signatures. Falls back to a short
36
+ // type tag when unstringifiable (never throws, never aliases objects with
37
+ // strings: prefixes the tag).
38
+ function stableStringify(value) {
39
+ try {
40
+ return JSON.stringify(sortKeys(value)) ?? "undefined";
41
+ }
42
+ catch {
43
+ return `unstringifiable:${typeof value}`;
44
+ }
45
+ }
46
+ function sortKeys(value) {
47
+ if (Array.isArray(value))
48
+ return value.map(sortKeys);
49
+ if (typeof value === "object" && value !== null) {
50
+ const out = {};
51
+ for (const k of Object.keys(value).sort()) {
52
+ out[k] = sortKeys(value[k]);
53
+ }
54
+ return out;
55
+ }
56
+ return value;
57
+ }
58
+ // Stable repetition/cache key: `name` + canonical args. Parsed args come
59
+ // from JSON.parse (insertion-ordered), so sorting closes the alias where
60
+ // `{"a":1,"b":2}` and `{"b":2,"a":1}` would otherwise count as different.
61
+ export function toolSignature(name, parsed) {
62
+ return `${name} ${stableStringify(parsed ?? {})}`;
63
+ }
64
+ // Defensive validation of one assistant message. Never throws: malformed
65
+ // tool_calls entries are dropped with a warning (the caller surfaces them
66
+ // via onWarning so the transcript shows what the model attempted); a fully
67
+ // unusable message becomes empty final text (the loop's turn-end gates then
68
+ // decide, exactly as if the model sent empty content).
69
+ export function normalizeChatResult(raw) {
70
+ const warnings = [];
71
+ if (typeof raw !== "object" || raw === null) {
72
+ return { result: { content: null }, warnings: ["model returned a non-object message"] };
73
+ }
74
+ const m = raw;
75
+ const contentRaw = m["content"];
76
+ const content = typeof contentRaw === "string"
77
+ ? contentRaw
78
+ : contentRaw === null || contentRaw === undefined
79
+ ? null
80
+ : (() => {
81
+ try {
82
+ return JSON.stringify(contentRaw);
83
+ }
84
+ catch {
85
+ return String(contentRaw);
86
+ }
87
+ })();
88
+ const callsRaw = m["calls"] ?? m["tool_calls"];
89
+ if (callsRaw === undefined) {
90
+ const result = { content };
91
+ if (m["usage"] !== undefined)
92
+ result["usage"] = m["usage"];
93
+ if (m["reasoning"] !== undefined)
94
+ result["reasoning"] = m["reasoning"];
95
+ return { result: result, warnings };
96
+ }
97
+ if (!Array.isArray(callsRaw)) {
98
+ warnings.push("model tool_calls was not an array — ignored");
99
+ return { result: { content, tool_calls: undefined }, warnings };
100
+ }
101
+ const calls = [];
102
+ for (let i = 0; i < callsRaw.length; i++) {
103
+ const entry = callsRaw[i];
104
+ if (typeof entry !== "object" || entry === null) {
105
+ warnings.push(`dropped malformed tool call at index ${i} (not an object)`);
106
+ continue;
107
+ }
108
+ const fn = entry["function"];
109
+ const name = fn?.["name"];
110
+ if (typeof name !== "string" || name.length === 0) {
111
+ const id = typeof entry["id"] === "string" ? entry["id"] : `#${i}`;
112
+ warnings.push(`dropped tool call ${id} with no function name`);
113
+ continue;
114
+ }
115
+ const id = typeof entry["id"] === "string" && entry["id"].length > 0
116
+ ? entry["id"]
117
+ : `call-${i}`;
118
+ const argsRaw = fn?.["arguments"];
119
+ let args;
120
+ if (typeof argsRaw === "string")
121
+ args = argsRaw;
122
+ else if (argsRaw === undefined || argsRaw === null)
123
+ args = "{}";
124
+ else {
125
+ try {
126
+ args = JSON.stringify(argsRaw) ?? "{}";
127
+ }
128
+ catch {
129
+ warnings.push(`dropped tool call ${id} with unstringifiable arguments`);
130
+ continue;
131
+ }
132
+ }
133
+ const call = { id, function: { name, arguments: args } };
134
+ if (typeof entry["type"] === "string")
135
+ call.type = entry["type"];
136
+ calls.push(call);
137
+ }
138
+ const out = { content, tool_calls: calls.length > 0 ? calls : undefined };
139
+ if (m["usage"] !== undefined)
140
+ out["usage"] = m["usage"];
141
+ if (m["reasoning"] !== undefined)
142
+ out["reasoning"] = m["reasoning"];
143
+ return { result: out, warnings };
144
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/auth.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // {version:1, providers:{"<id>":{apiKey, baseURL?}}}
4
4
  // 0600 perms on POSIX via chmod; best-effort on Windows.
5
5
  // Key resolution per provider: standard env var wins when set, else stored.
6
- // Env names: OPENCODE_ZEN_API_KEY (zen), OPENAI_API_KEY, ANTHROPIC_API_KEY,
6
+ // Env names: KILO_API_KEY (kilo; optional — free models work anonymously),
7
+ // OPENCODE_ZEN_API_KEY (zen), OPENAI_API_KEY, ANTHROPIC_API_KEY,
7
8
  // DEEPSEEK_API_KEY, MISTRAL_API_KEY, GEMINI_API_KEY (also GOOGLE_API_KEY
8
9
  // alias). openai-compatible: stored key + stored baseURL only.
9
10
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
package/dist/cli.js CHANGED
@@ -4,25 +4,87 @@ import { render } from "ink";
4
4
  import { App } from "./App.js";
5
5
  import { DEFAULT_ENDPOINT, DEFAULT_MODEL, endpointConfig, } from "./zen.js";
6
6
  import { loadAuth, resolveApiKey } from "./auth.js";
7
+ import { writeTelemetryDashboard } from "./telemetry-dashboard.js";
7
8
  const args = process.argv.slice(2);
8
- if (args.includes("--help") || args.includes("-h")) {
9
+ if (args.includes("--dashboard")) {
10
+ // Local observability dashboard without starting the TUI: render every
11
+ // stored session to ~/.atom/telemetry/dashboard.html and print the path.
12
+ const out = writeTelemetryDashboard();
13
+ if (out) {
14
+ console.log(`Observability dashboard written to ${out} — open it in a browser. Local file, nothing uploaded.`);
15
+ process.exit(0);
16
+ }
17
+ console.error("Dashboard failed to write — telemetry store unavailable.");
18
+ process.exit(1);
19
+ }
20
+ if (args.includes("--serve")) {
21
+ // Local observability webUI: serve the live dashboard + read-only JSON API
22
+ // on loopback (Ctrl+C stops). The static --dashboard file is untouched.
23
+ const flagValue = (name) => {
24
+ const eq = args.find((a) => a.startsWith(`${name}=`));
25
+ if (eq !== undefined)
26
+ return eq.slice(name.length + 1);
27
+ const i = args.indexOf(name);
28
+ if (i !== -1 && i + 1 < args.length) {
29
+ const next = args[i + 1];
30
+ if (!next.startsWith("-"))
31
+ return next;
32
+ }
33
+ return undefined;
34
+ };
35
+ (async () => {
36
+ const { resolveTelemetryPort, startTelemetryServer } = await import("./telemetry-server.js");
37
+ const server = await startTelemetryServer({ port: resolveTelemetryPort(process.env, flagValue("--port")) });
38
+ console.log(`ATOM observability webUI at ${server.url} (loopback-only, read-only — Ctrl+C to stop).`);
39
+ console.log(`JSON API: ${server.url}api/health · ${server.url}api/aggregates · ${server.url}api/sessions`);
40
+ const stop = () => {
41
+ server.close().then(() => process.exit(0), () => process.exit(0));
42
+ };
43
+ process.on("SIGINT", stop);
44
+ process.on("SIGTERM", stop);
45
+ await new Promise(() => { });
46
+ })().catch((e) => {
47
+ const detail = e instanceof Error ? e.message : String(e);
48
+ console.error(`Observability webUI failed to start (${detail}). Is the port already in use? Try --port <n>.`);
49
+ process.exit(1);
50
+ });
51
+ }
52
+ else if (args.includes("--help") || args.includes("-h")) {
9
53
  console.log(`Atom chatbot (Ink TUI)
10
54
  Usage: npm start
55
+ Flags: --dashboard (write ~/.atom/telemetry/dashboard.html and exit)
56
+ --serve [--port <n>] (serve the live dashboard webUI on loopback and keep running)
11
57
  Env:
58
+ KILO_API_KEY optional (Kilo free models work anonymously; get a key at https://kilo.ai) — env wins over ~/.atom/auth.json
12
59
  OPENCODE_ZEN_API_KEY optional when ~/.atom/auth.json has a zen key (get one at https://opencode.ai/auth)
13
60
  OPENAI_API_KEY / ANTHROPIC_API_KEY / DEEPSEEK_API_KEY / MISTRAL_API_KEY / GEMINI_API_KEY (GOOGLE_API_KEY alias) optional per provider (env wins over stored)
14
- OPENCODE_ZEN_MODEL optional (default: ${DEFAULT_MODEL})
61
+ OPENCODE_ZEN_MODEL optional (default: ${DEFAULT_MODEL}; when set, wins over the saved /model)
15
62
  OPENCODE_ZEN_ENDPOINT optional (default: ${DEFAULT_ENDPOINT})
16
- Commands: /model (model picker) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /tools | /skills (list installed skills) | /mode | /yolo (toggle) | /plan (read-only plan mode) | /clear | /resume (restore last saved session) | /help | /exit | /quit
17
- Providers: opencode-zen/openai/anthropic/deepseek/mistral/google-gemini/openai-compatible (keys in ~/.atom/auth.json, 0600 POSIX; use /provider to paste one).
63
+ Commands: /model (model picker) | /models [refresh] (local discovery refresh; Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /tools | /skills (list installed skills) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /autoscroll on|off (follow new output) | /thinking (toggle reasoning visibility) | /mode | /clear | /resume (restore last saved session) | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan
64
+ Providers: kilo (default; anonymous free models, key optional)/opencode-zen/openai/anthropic/deepseek/mistral/google-gemini/openai-compatible (keys in ~/.atom/auth.json, 0600 POSIX; use /provider to paste one) + local auto-discovery: ollama (:11434), lmstudio (:1234), llamacpp (:8080) — no keys needed, overrides via ATOM_OLLAMA_URL/ATOM_LMSTUDIO_URL/ATOM_LLAMACPP_URL.
18
65
  Note: reasoning_effort is sent only for opencode-zen supported models.`);
19
66
  process.exit(0);
20
67
  }
21
- const { endpoint, apiKey: envKey, model } = endpointConfig();
68
+ const { endpoint, apiKey: envKey } = endpointConfig();
22
69
  // Stored zen key (from a previous /provider paste) applies when no env key.
23
70
  const storedZen = resolveApiKey("opencode-zen", loadAuth());
24
71
  const apiKey = envKey || storedZen;
72
+ // Explicit model only when OPENCODE_ZEN_MODEL is set: otherwise the saved
73
+ // provider/model/effort restore (restorePrefs), else the compiled default.
74
+ // Env wins over the save when set; the save wins over the default.
75
+ // The /model + /provider + /effort picks persist across restarts (saved on
76
+ // every completed turn and on clean exit); the conversation itself only ever
77
+ // restores via an explicit /resume.
78
+ // Explicit model only when OPENCODE_ZEN_MODEL is set and non-empty: a
79
+ // declared-but-empty entry (`OPENCODE_ZEN_MODEL=`, a common .env shape) must
80
+ // count as unset, or the empty string would win the model chain and every
81
+ // POST would carry an empty model id.
82
+ const envModel = process.env.OPENCODE_ZEN_MODEL?.trim() || undefined;
25
83
  // Always start the TUI (even without a key) so /provider can paste one.
26
84
  // Chatting without a key for the active provider errors inline with a
27
85
  // /provider pointer; nothing is POSTed.
28
- render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: model }));
86
+ // --serve parks above (the server holds the event loop), so the TUI must
87
+ // never start alongside it: serve is a standalone mode like --dashboard.
88
+ if (!args.includes("--serve")) {
89
+ render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: envModel, restorePrefs: true }));
90
+ }
package/dist/compact.js CHANGED
@@ -15,10 +15,13 @@
15
15
  // The App owns session refs (load/streak/disabled/pending) and the atomic
16
16
  // swap + save; this module owns math, splitting, instruction, and the
17
17
  // summary POST (tools disabled, 4096 cap).
18
- import { contextWindowFor } from "./context-windows.js";
19
- import { historyChars, messageChars, chatCompletionForProvider, } from "./zen.js";
18
+ import { chatCompletionForProvider, } from "./zen.js";
19
+ // Context math (estimator, load, threshold, measurement) lives in the
20
+ // ContextManager module; compact.ts imports what its splitter needs and
21
+ // re-exports the stable surface so existing importers keep working untouched.
22
+ import { estimateTokensForChars, messageChars } from "./context-manager.js";
23
+ export { COMPACT_PCT_DEFAULT, compactPct, computeContextLoad, estimateTokensForChars, historyChars, shouldAutoCompact, } from "./context-manager.js";
20
24
  // ---- Constants ----
21
- export const COMPACT_PCT_DEFAULT = 0.83;
22
25
  export const COMPACT_KEEP_TOKENS = 8000;
23
26
  export const COMPACT_SUMMARY_MAX_TOKENS = 4096;
24
27
  export const COMPACT_TOOL_OUTPUT_CAP = 2000;
@@ -27,49 +30,6 @@ export const COMPACT_TOOL_OUTPUT_CAP = 2000;
27
30
  // `token: n/a` honesty rule or the NK cumulative spend.
28
31
  export const COMPACT_CHARS_PER_TOKEN = 4;
29
32
  export const COMPACT_THRASH_LIMIT = 3;
30
- // ---- Threshold ----
31
- function clampPctPercent(n) {
32
- return Math.min(Math.max(n, 50), 95) / 100;
33
- }
34
- // Auto-compact threshold as a fraction (default 0.83). Env ATOM_COMPACT_PCT
35
- // is a percent (e.g. "83"), clamped 50–95; invalid/unset → default.
36
- export function compactPct() {
37
- const raw = process.env.ATOM_COMPACT_PCT;
38
- if (raw === undefined)
39
- return COMPACT_PCT_DEFAULT;
40
- const text = raw.trim();
41
- if (!/^\d+(\.\d+)?$/.test(text))
42
- return COMPACT_PCT_DEFAULT;
43
- const n = Number(text);
44
- if (!Number.isFinite(n))
45
- return COMPACT_PCT_DEFAULT;
46
- return clampPctPercent(n);
47
- }
48
- // ---- Load metric ----
49
- export function estimateTokensForChars(chars) {
50
- // opencode's 4ch/token heuristic.
51
- const c = Number.isFinite(chars) && chars > 0 ? Math.floor(chars) : 0;
52
- return Math.floor(c / COMPACT_CHARS_PER_TOKEN);
53
- }
54
- // Load = last POST's reported prompt_tokens when available, else the
55
- // 4ch/token estimate of the sent history chars.
56
- export function computeContextLoad(lastPromptTokens, sentHistoryChars) {
57
- if (typeof lastPromptTokens === "number" &&
58
- Number.isFinite(lastPromptTokens) &&
59
- lastPromptTokens >= 0) {
60
- return Math.floor(lastPromptTokens);
61
- }
62
- return estimateTokensForChars(sentHistoryChars);
63
- }
64
- export function shouldAutoCompact(load, model, pctOverride) {
65
- const window = contextWindowFor(model);
66
- if (window === undefined)
67
- return false; // never invent a window
68
- const pct = typeof pctOverride === "number" && Number.isFinite(pctOverride)
69
- ? pctOverride
70
- : compactPct();
71
- return load / window >= pct;
72
- }
73
33
  // ---- Turn helpers ----
74
34
  export function countUserTurns(history) {
75
35
  let n = 0;
@@ -273,5 +233,3 @@ export async function requestCompactSummary(req) {
273
233
  }
274
234
  }
275
235
  }
276
- // Re-export for callers that need the post-turn history size.
277
- export { historyChars };
package/dist/config.js ADDED
@@ -0,0 +1,171 @@
1
+ // atom.json — ATOM's config file (Claude-Code-settings/opencode.json-style
2
+ // durable configuration layer).
3
+ //
4
+ // Two levels, per-key merge (project wins over global):
5
+ // - project: <cwd>/atom.json
6
+ // - global: ~/.atom/atom.json (ATOM_HOME overrides home, like auth.json)
7
+ //
8
+ // Precedence overall: env vars > saved session prefs (/model, /provider,
9
+ // /effort picks) > project atom.json > global atom.json > compiled defaults.
10
+ // So atom.json sets first-run/project defaults; a later explicit pick (saved)
11
+ // still wins across restarts; env always wins.
12
+ //
13
+ // Everything is optional and validated: unknown keys are ignored
14
+ // (forward-compatible), invalid values fall back per-key with a warning
15
+ // string (surfaced in /context) — loading never throws, missing files are
16
+ // normal and silent. Reads are fresh per call (edits apply without restart,
17
+ // like skills); files are tiny JSON.
18
+ //
19
+ // Keys:
20
+ // - provider: ProviderId for first-run default (needs its key, else zen)
21
+ // - model: default model id (non-empty string)
22
+ // - reasoningEffort: default/low/medium/high/max
23
+ // - maxHistoryMessages: 10–1000 (message-count safety ceiling)
24
+ // - maxHistoryChars: 10_000–2_000_000 (char safety ceiling — caps the
25
+ // window-derived budget, never the primary limit)
26
+ // - maxToolSteps: 5–100 (tool rounds per turn)
27
+ // - compactPct: 50–95 (auto-compact percent of verified window)
28
+ // - telemetry: {enabled?: boolean} (local observability recording, default on)
29
+ import { existsSync, readFileSync } from "node:fs";
30
+ import * as path from "node:path";
31
+ import { homeDir } from "./auth.js";
32
+ import { isProviderId } from "./providers.js";
33
+ import { parseNetworkPolicy } from "./policy.js";
34
+ export const ATOM_CONFIG_FILENAME = "atom.json";
35
+ // Kept local (not imported from zen.js) so config.ts has no runtime import
36
+ // of zen.js — zen.js imports loadAtomConfig for budget fallbacks, and a
37
+ // runtime cycle would be fragile. Mirrors EFFORT_OPTIONS exactly.
38
+ const EFFORT_VALUES = ["default", "low", "medium", "high", "max"];
39
+ export function projectConfigPath(projectDir) {
40
+ return path.join(projectDir ?? process.cwd(), ATOM_CONFIG_FILENAME);
41
+ }
42
+ export function globalConfigPath(home) {
43
+ return path.join(home ?? homeDir(), ".atom", ATOM_CONFIG_FILENAME);
44
+ }
45
+ function isRecord(value) {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+ function asFiniteNumber(value) {
49
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
50
+ }
51
+ // Parse one level's file. Returns null when absent/unreadable/invalid
52
+ // (silent — a missing config is the normal case); otherwise the validated
53
+ // subset plus per-key warnings.
54
+ function parseLevel(filePath, label) {
55
+ const empty = { config: {}, warnings: [], present: false };
56
+ let raw;
57
+ try {
58
+ if (!existsSync(filePath))
59
+ return empty;
60
+ raw = readFileSync(filePath, "utf8");
61
+ }
62
+ catch {
63
+ return empty;
64
+ }
65
+ let data;
66
+ try {
67
+ data = JSON.parse(raw);
68
+ }
69
+ catch {
70
+ return {
71
+ config: {},
72
+ warnings: [`${label} atom.json is not valid JSON — ignored`],
73
+ present: true,
74
+ };
75
+ }
76
+ if (!isRecord(data)) {
77
+ return {
78
+ config: {},
79
+ warnings: [`${label} atom.json must be a JSON object — ignored`],
80
+ present: true,
81
+ };
82
+ }
83
+ const config = {};
84
+ const warnings = [];
85
+ const bad = (key, why) => warnings.push(`${label} atom.json: ignoring invalid "${key}" (${why})`);
86
+ const provider = data["provider"];
87
+ if (provider !== undefined) {
88
+ if (typeof provider === "string" && isProviderId(provider)) {
89
+ config.provider = provider;
90
+ }
91
+ else {
92
+ bad("provider", "must be a known provider id");
93
+ }
94
+ }
95
+ const model = data["model"];
96
+ if (model !== undefined) {
97
+ if (typeof model === "string" && model.length > 0) {
98
+ config.model = model;
99
+ }
100
+ else {
101
+ bad("model", "must be a non-empty string");
102
+ }
103
+ }
104
+ const effort = data["reasoningEffort"];
105
+ if (effort !== undefined) {
106
+ if (typeof effort === "string" && EFFORT_VALUES.includes(effort)) {
107
+ config.reasoningEffort = effort;
108
+ }
109
+ else {
110
+ bad("reasoningEffort", `must be one of ${EFFORT_VALUES.join("/")}`);
111
+ }
112
+ }
113
+ const ranged = [
114
+ { key: "maxHistoryMessages", min: 10, max: 1000 },
115
+ { key: "maxHistoryChars", min: 10_000, max: 2_000_000 },
116
+ { key: "maxToolSteps", min: 5, max: 100 },
117
+ { key: "compactPct", min: 50, max: 95 },
118
+ ];
119
+ for (const { key, min, max } of ranged) {
120
+ const v = data[key];
121
+ if (v === undefined)
122
+ continue;
123
+ const n = asFiniteNumber(v);
124
+ if (n === undefined) {
125
+ bad(key, "must be a number");
126
+ continue;
127
+ }
128
+ // Clamp like the env-var readers (same bounds, same forgiveness), but
129
+ // say so — a clamped value is usually a typo worth surfacing.
130
+ const clamped = Math.min(Math.max(Math.floor(n), min), max);
131
+ if (clamped !== n) {
132
+ warnings.push(`${label} atom.json: "${key}" clamped to ${clamped} (range ${min}–${max})`);
133
+ }
134
+ config[key] = clamped;
135
+ }
136
+ const network = data["network"];
137
+ if (network !== undefined) {
138
+ const parsed = parseNetworkPolicy(network);
139
+ config.network = parsed.policy;
140
+ for (const w of parsed.warnings)
141
+ warnings.push(`${label} atom.json: ${w}`);
142
+ }
143
+ const telemetry = data["telemetry"];
144
+ if (telemetry !== undefined) {
145
+ if (!isRecord(telemetry)) {
146
+ warnings.push(`${label} atom.json: ignoring invalid "telemetry" (must be an object)`);
147
+ }
148
+ else {
149
+ const enabled = telemetry["enabled"];
150
+ if (enabled === undefined) {
151
+ // `{}` is valid: all-telemetry keys are optional, nothing to set.
152
+ }
153
+ else if (typeof enabled === "boolean") {
154
+ config.telemetry = { enabled };
155
+ }
156
+ else {
157
+ warnings.push(`${label} atom.json: ignoring invalid "telemetry.enabled" (must be a boolean)`);
158
+ }
159
+ }
160
+ }
161
+ return { config, warnings, present: true };
162
+ }
163
+ export function loadAtomConfig(projectDir, homeDir) {
164
+ const project = parseLevel(projectConfigPath(projectDir), "project");
165
+ const global = parseLevel(globalConfigPath(homeDir), "global");
166
+ return {
167
+ config: { ...global.config, ...project.config },
168
+ warnings: [...global.warnings, ...project.warnings],
169
+ sources: { project: project.present, global: global.present },
170
+ };
171
+ }