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.
- package/CHANGELOG.md +82 -0
- package/README.md +83 -32
- package/dist/App.js +2178 -318
- package/dist/adapters.js +146 -15
- package/dist/agent/gates.js +153 -0
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +908 -0
- package/dist/agent/normalize.js +144 -0
- package/dist/agent/types.js +1 -0
- package/dist/auth.js +2 -1
- package/dist/cli.js +68 -6
- package/dist/compact.js +6 -48
- package/dist/config.js +171 -0
- package/dist/context-manager.js +564 -0
- package/dist/kilo.js +343 -0
- package/dist/local-discovery.js +308 -0
- package/dist/policy.js +286 -0
- package/dist/prompt-cache.js +99 -0
- package/dist/providers.js +183 -2
- package/dist/rollback.js +21 -0
- package/dist/scheduler.js +247 -0
- package/dist/session.js +35 -3
- package/dist/skills.js +214 -43
- package/dist/snapshots.js +57 -2
- package/dist/system.js +8 -1
- package/dist/telemetry-dashboard.js +589 -0
- package/dist/telemetry-server.js +301 -0
- package/dist/telemetry.js +1056 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +149 -0
- package/dist/tools/fingerprints.js +33 -0
- package/dist/tools/overflow.js +76 -0
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +802 -0
- package/dist/tools/search.js +242 -0
- package/dist/tools/shared.js +31 -0
- package/dist/tools/shell.js +273 -0
- package/dist/tools/todo.js +191 -0
- package/dist/tools/web.js +454 -0
- package/dist/tools.js +17 -1863
- package/dist/ui/activity.js +51 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +112 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/errors.js +129 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/input-model.js +115 -0
- package/dist/ui/input.js +40 -0
- package/dist/ui/live-tail.js +15 -0
- package/dist/ui/markdown.js +525 -0
- package/dist/ui/modals.js +47 -0
- package/dist/ui/palette.js +70 -0
- package/dist/ui/pickers.js +32 -0
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +75 -0
- package/dist/ui/theme.js +128 -0
- package/dist/ui/todo-panel.js +30 -0
- package/dist/ui/tool-inspector.js +59 -0
- package/dist/ui/transcript.js +128 -0
- package/dist/zen.js +145 -666
- 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:
|
|
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("--
|
|
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) | /
|
|
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
|
|
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
|
-
|
|
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 {
|
|
19
|
-
|
|
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
|
+
}
|