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
package/dist/policy.js ADDED
@@ -0,0 +1,286 @@
1
+ // Policy / Security layer: explicit trust boundaries for a powerful local
2
+ // coding agent. Filesystem access and shell execution are INTENTIONAL
3
+ // capabilities (never silently removed); this module makes the decisions
4
+ // governing them explicit, testable, and centralized:
5
+ //
6
+ // Tool request → decidePolicy() → approval if needed → execution
7
+ //
8
+ // Pure module (no I/O except injected resolvers, no UI): the App owns prompts
9
+ // and side effects, tools own execution. Covers three surfaces:
10
+ //
11
+ // 1. Approval decisions — one ordered rule (deny → plan → allow → yolo →
12
+ // trust → always → skill grants → prompt) replacing the previously inline
13
+ // if-chain in App.approve. Same order, same outcomes, now unit-tested.
14
+ // 2. Skill-grant trust — project-local skill content is untrusted: only
15
+ // global (user-controlled) skills may arm turn-scoped tool grants.
16
+ // Project skills never silently escalate to shell/filesystem/network.
17
+ // 3. Network zones + policy — SSRF surface for webfetch: every URL (initial
18
+ // and every redirect hop) classifies into public/localhost/private/
19
+ // link-local/blocked, gated by an explicit configurable policy.
20
+ // 4. Secret scrubbing — shell outputs pass through scrubSecrets so a pasted
21
+ // or env-provided provider key echoed by a command never rides back to
22
+ // the model (and into saved transcripts) verbatim.
23
+ import { checkRules } from "./permissions.js";
24
+ export function decidePolicy(name, args, ctx) {
25
+ const verdict = checkRules(ctx.rules, name, args);
26
+ // Deny wins over everything, including plan mode.
27
+ if (verdict === "deny")
28
+ return { kind: "deny" };
29
+ // Plan mode is read-only: mutations skip the prompt and flow to the
30
+ // execute gate, which refuses them pre-execution with a replan note.
31
+ if (ctx.mode === "plan" && ctx.approvalGated) {
32
+ return { kind: "allow", via: "plan-passthrough" };
33
+ }
34
+ if (verdict === "allow")
35
+ return { kind: "allow", via: "allow-rule" };
36
+ if (ctx.mode === "yolo")
37
+ return { kind: "allow", via: "yolo" };
38
+ if (ctx.trustAll)
39
+ return { kind: "allow", via: "trust" };
40
+ if (ctx.alwaysAllowed.has(name))
41
+ return { kind: "allow", via: "always" };
42
+ if (ctx.skillGrants.has(name))
43
+ return { kind: "allow", via: "skill-grant" };
44
+ return { kind: "prompt" };
45
+ }
46
+ // ---- 2. Skill-grant trust boundary ----
47
+ // Approval-gated tools: the dangerous capabilities a grant can unlock
48
+ // (shell, filesystem mutation; network/process execution ride bash).
49
+ export const SKILL_GRANT_SENSITIVE_TOOLS = new Set([
50
+ "write",
51
+ "edit",
52
+ "bash",
53
+ ]);
54
+ // Explicit trust policy (not a scattered special case):
55
+ // - global skills live under the user's own ~/.claude|~/.agents: user
56
+ // controlled, so allowed-tools arm turn-scoped grants per existing policy.
57
+ // - project skills live in repo content, which may be untrusted (a cloned
58
+ // repo's SKILL.md can declare anything): they NEVER arm grants. Approval
59
+ // tools invoked under them prompt normally (or via /trust); read-only
60
+ // tools auto-run regardless, so nothing functional is lost.
61
+ export function skillGrantsFor(source, allowedTools) {
62
+ if (source === "global")
63
+ return { grants: [...allowedTools], blocked: [] };
64
+ return {
65
+ grants: [],
66
+ blocked: allowedTools.filter((t) => SKILL_GRANT_SENSITIVE_TOOLS.has(t)),
67
+ };
68
+ }
69
+ export function defaultNetworkPolicy() {
70
+ return {
71
+ // ATOM is a local agent: public docs plus localhost dev servers stay
72
+ // reachable; private LAN and link-local (cloud metadata) stay closed
73
+ // until the owner opens them in atom.json.
74
+ allowPublic: true,
75
+ allowLocalhost: true,
76
+ allowPrivate: false,
77
+ allowLinkLocal: false,
78
+ };
79
+ }
80
+ export function parseNetworkPolicy(raw) {
81
+ const policy = defaultNetworkPolicy();
82
+ const warnings = [];
83
+ if (raw === undefined)
84
+ return { policy, warnings };
85
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
86
+ warnings.push(`network: must be an object — ignored`);
87
+ return { policy, warnings };
88
+ }
89
+ const o = raw;
90
+ Object.keys(policy).forEach((key) => {
91
+ const v = o[key];
92
+ if (v === undefined)
93
+ return;
94
+ if (typeof v !== "boolean") {
95
+ warnings.push(`network: ignoring invalid "${key}" (must be a boolean)`);
96
+ return;
97
+ }
98
+ policy[key] = v;
99
+ });
100
+ return { policy, warnings };
101
+ }
102
+ export function zoneAllows(zone, policy) {
103
+ switch (zone) {
104
+ case "public":
105
+ return policy.allowPublic;
106
+ case "localhost":
107
+ return policy.allowLocalhost;
108
+ case "private":
109
+ return policy.allowPrivate;
110
+ case "link-local":
111
+ return policy.allowLinkLocal;
112
+ case "blocked":
113
+ return false;
114
+ }
115
+ }
116
+ // Redirect statuses followed manually (GET-only client, so 303's method
117
+ // rewrite is moot). Anything else with a Location is left alone.
118
+ export function isRedirectStatus(status) {
119
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
120
+ }
121
+ // Fail-closed IP classifier (pure, no DNS). Returns "invalid" for anything
122
+ // unparseable — callers treat invalid as blocked.
123
+ export function classifyIp(raw) {
124
+ let ip = raw.trim().toLowerCase();
125
+ // Bracketed IPv6 literals as carried by URL.hostname.
126
+ if (ip.startsWith("[") && ip.endsWith("]"))
127
+ ip = ip.slice(1, -1);
128
+ // Zone IDs (fe80::1%eth0, encoded %25eth0) describe the interface, not the
129
+ // address: classify the address part.
130
+ const pct = ip.indexOf("%");
131
+ if (pct !== -1)
132
+ ip = ip.slice(0, pct);
133
+ if (ip.includes(":"))
134
+ return classifyIpv6(ip);
135
+ return classifyIpv4(ip);
136
+ }
137
+ function parseDecOctet(part) {
138
+ if (!/^\d+$/.test(part))
139
+ return null;
140
+ // Leading zeros would be octal to getaddrinfo ("010" = 8, "0177" = 127):
141
+ // a strict decimal read could call 0177.0.0.1 public while the resolver
142
+ // reaches localhost. Fail closed instead.
143
+ if (part.length > 1 && part.startsWith("0"))
144
+ return null;
145
+ const n = Number(part);
146
+ return Number.isInteger(n) && n >= 0 && n <= 255 ? n : null;
147
+ }
148
+ function classifyIpv4(ip) {
149
+ const parts = ip.split(".");
150
+ if (parts.length !== 4)
151
+ return "invalid";
152
+ const octets = [];
153
+ for (const p of parts) {
154
+ const n = parseDecOctet(p);
155
+ if (n === null)
156
+ return "invalid";
157
+ octets.push(n);
158
+ }
159
+ const [o1 = 0, o2 = 0, o3 = 0] = octets;
160
+ if (o1 === 127)
161
+ return "localhost"; // 127.0.0.0/8 loopback
162
+ if (o1 === 0 && o2 === 0 && o3 === 0)
163
+ return "localhost"; // 0.0.0.0 unspecified
164
+ if (o1 === 10)
165
+ return "private"; // 10.0.0.0/8 RFC1918
166
+ if (o1 === 172 && o2 >= 16 && o2 <= 31)
167
+ return "private"; // 172.16.0.0/12
168
+ if (o1 === 192 && o2 === 168)
169
+ return "private"; // 192.168.0.0/16
170
+ if (o1 === 169 && o2 === 254)
171
+ return "link-local"; // 169.254.0.0/16 (cloud metadata lives here)
172
+ if (o1 === 100 && o2 >= 64 && o2 <= 127)
173
+ return "private"; // 100.64.0.0/10 CGNAT shared space
174
+ if ((o1 === 192 && o2 === 0 && o3 === 2) ||
175
+ (o1 === 198 && o2 === 51 && o3 === 100) ||
176
+ (o1 === 203 && o2 === 0 && o3 === 113)) {
177
+ return "private"; // TEST-NET documentation ranges: unroutable, fail closed
178
+ }
179
+ if (o1 >= 224)
180
+ return "invalid"; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved
181
+ return "public";
182
+ }
183
+ function expandIpv6(ip) {
184
+ // Embedded IPv4 tail (::ffff:1.2.3.4) is handled by the caller.
185
+ const halves = ip.split("::");
186
+ if (halves.length > 2)
187
+ return null;
188
+ const parseGroup = (s) => {
189
+ if (s === "")
190
+ return [];
191
+ const out = [];
192
+ for (const g of s.split(":")) {
193
+ if (!/^[0-9a-f]{1,4}$/.test(g))
194
+ return null;
195
+ out.push(parseInt(g, 16));
196
+ }
197
+ return out;
198
+ };
199
+ if (halves.length === 1) {
200
+ const groups = parseGroup(halves[0]);
201
+ return groups && groups.length === 8 ? groups : null;
202
+ }
203
+ const head = parseGroup(halves[0]);
204
+ const tail = parseGroup(halves[1]);
205
+ if (!head || !tail || head.length + tail.length > 7)
206
+ return null;
207
+ const zeros = new Array(8 - head.length - tail.length).fill(0);
208
+ return [...head, ...zeros, ...tail];
209
+ }
210
+ function classifyIpv6(ip) {
211
+ // Dotted mapped tail (::ffff:1.2.3.4): expandIpv6 cannot parse dots, so
212
+ // unwrap before expansion. (Real URLs never reach this branch — the URL
213
+ // parser normalizes dotted tails to hex — but direct callers might.)
214
+ const dotted = ip.match(/^(?:::ffff:)(.+)$/);
215
+ if (dotted && dotted[1].includes("."))
216
+ return classifyIpv4(dotted[1]);
217
+ // Embedded dotted tail in other positions is not a valid literal here.
218
+ if (ip.includes("."))
219
+ return "invalid";
220
+ const g = expandIpv6(ip);
221
+ if (!g || g.length !== 8)
222
+ return "invalid";
223
+ // IPv4-mapped ::ffff:0:0/96 in hex form (::ffff:7f00:1 — the shape the URL
224
+ // parser produces): classify the embedded IPv4 address, so mapped
225
+ // loopback/private cannot slip past the v4 rules as "public".
226
+ if (g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 &&
227
+ g[5] === 0xffff) {
228
+ const hi = g[6];
229
+ const lo = g[7];
230
+ return classifyIpv4(`${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`);
231
+ }
232
+ if (g.every((x) => x === 0)) {
233
+ // :: unspecified — like 0.0.0.0, connections stay on the machine.
234
+ return "localhost";
235
+ }
236
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1)
237
+ return "localhost"; // ::1
238
+ if ((g[0] & 0xffc0) === 0xfe80)
239
+ return "link-local"; // fe80::/10
240
+ if ((g[0] & 0xfe00) === 0xfc00)
241
+ return "private"; // fc00::/7 unique-local
242
+ if (g[0] === 0x2001 && g[1] === 0x0db8)
243
+ return "private"; // 2001:db8::/32 documentation
244
+ if ((g[0] & 0xff00) === 0xff00)
245
+ return "invalid"; // ff00::/8 multicast
246
+ return "public";
247
+ }
248
+ // Classify a hostname's resolved addresses, worst zone wins (fail closed):
249
+ // link-local > localhost > private > public. A hostname straddling zones is
250
+ // gated by its most sensitive address, which also blunts DNS-rebinding
251
+ // races (at most one lookup happens per check; see webfetchTool).
252
+ const ZONE_RANK = {
253
+ blocked: 4,
254
+ "link-local": 3,
255
+ localhost: 2,
256
+ private: 1,
257
+ public: 0,
258
+ };
259
+ export function zoneForAddresses(addresses) {
260
+ let worst = "public";
261
+ for (const addr of addresses) {
262
+ const zone = classifyIp(addr);
263
+ const effective = zone === "invalid" ? "blocked" : zone;
264
+ if (ZONE_RANK[effective] > ZONE_RANK[worst])
265
+ worst = effective;
266
+ }
267
+ return worst;
268
+ }
269
+ // ---- 4. Secret scrubbing ----
270
+ // Minimum secret length worth redacting: shorter values would nuke ordinary
271
+ // words (a 1-char "secret" redacts every matching letter).
272
+ export const SECRET_MIN_LENGTH = 8;
273
+ // Redact every occurrence of each known secret. Longest-first so overlapping
274
+ // values mask fully; split/join (never regex) so values cannot inject
275
+ // patterns. Idempotent. Pure.
276
+ export function scrubSecrets(text, secrets) {
277
+ const ordered = [...new Set(secrets)]
278
+ .filter((s) => typeof s === "string" && s.length >= SECRET_MIN_LENGTH)
279
+ .sort((a, b) => b.length - a.length);
280
+ if (ordered.length === 0)
281
+ return text;
282
+ let out = text;
283
+ for (const s of ordered)
284
+ out = out.split(s).join("[redacted]");
285
+ return out;
286
+ }
@@ -0,0 +1,99 @@
1
+ // Prompt-cache architecture: ATOM intentionally constructs a cache-friendly
2
+ // prompt, and each provider decides how that cache is actually realized.
3
+ //
4
+ // Conceptual split (every POST):
5
+ // STABLE PREFIX (byte-identical across POSTs, cacheable)
6
+ // - system instructions (src/system.ts one-liner)
7
+ // - project instructions (AGENTS.md overlay)
8
+ // - tool definitions (source order, stable serialization)
9
+ // DYNAMIC SUFFIX (changes constantly, never cached as prefix)
10
+ // - environment block (timestamps, git status — refreshed per turn)
11
+ // - current conversation, tool calls/results, skill injections,
12
+ // todo state, task state
13
+ //
14
+ // Deliberate non-goals (documented, not oversights):
15
+ // - No Tier-1 skill catalog is injected: the deterministic local matcher
16
+ // owns skill discovery, so skill metadata never enters the prefix
17
+ // unsolicited. Loaded skill bodies ride the dynamic suffix (they change
18
+ // turn to turn); their references stay on demand.
19
+ // - The cwd/node tail of the env block rides the dynamic suffix with the
20
+ // timestamp — splitting that one line further is not worth the fragility;
21
+ // it is ~200 chars.
22
+ // - Gemini explicit cache objects (a separate resource lifecycle with its own
23
+ // create/reference/TTL calls) are out of scope; Gemini uses implicit
24
+ // prefix stability like everyone else.
25
+ //
26
+ // The split reuses the env-block boundary history[0] already carries:
27
+ // `stripEnvBlock` separates the stable base from the trailing `[env ...]`
28
+ // block, so NO storage format changes — histories with no env block (tests,
29
+ // old saves) assemble to a single stable system message, byte-identical to
30
+ // the pre-cache wire shape.
31
+ //
32
+ // Layering (AgentRuntime → ContextManager → assembled context →
33
+ // ProviderAdapter): this module owns the neutral assembly + policy + caps.
34
+ // The agent loop never sees caching (no loop changes); adapters translate
35
+ // the assembly into kind-specific wire shapes (Anthropic blocks+breakpoints,
36
+ // OpenAI-shape message split, Gemini parts split).
37
+ //
38
+ // Prompt caching itself is NEVER implemented here — this is the foundation:
39
+ // deterministic assembly, capability declarations, usage-field plumbing, and
40
+ // instrumentation. Cost/latency wins come from providers honoring it.
41
+ import { createHash } from "node:crypto";
42
+ import { stripEnvBlock } from "./env-block.js";
43
+ import { estimateTokensForChars } from "./context-manager.js";
44
+ import { getProvider } from "./providers.js";
45
+ // Declared caching support lives on the provider registry itself
46
+ // (ProviderDef.cache in providers.ts — the single declaration point).
47
+ // Unknown ids get the conservative all-false default (compatibility first —
48
+ // never assume).
49
+ export function providerCacheSupport(id) {
50
+ return (getProvider(id)?.cache ?? {
51
+ explicitBreakpoints: false,
52
+ implicitPrefix: false,
53
+ usageCacheFields: false,
54
+ notes: "unknown provider — no caching assumed",
55
+ });
56
+ }
57
+ // Fresh `{type:"ephemeral"}` breakpoint per call (never a shared object —
58
+ // callers embed it into request bodies they own).
59
+ export function ephemeralBreakpoint() {
60
+ return { type: "ephemeral" };
61
+ }
62
+ // OpenAI-shape head split: history[0]'s env tail becomes its own system
63
+ // message ([stable, dynamic, ...rest]). Returns the input array UNTOUCHED
64
+ // when there is nothing to split (no system head, or no env tail) — callers
65
+ // then send the legacy shape byte-identically. Always returns a NEW array
66
+ // when splitting (never mutates the loop's history; rollback indices and the
67
+ // ledger keep pointing at the original).
68
+ export function splitSystemHead(history) {
69
+ const first = history[0];
70
+ if (!first || first.role !== "system" || typeof first.content !== "string") {
71
+ return history;
72
+ }
73
+ const prefix = assemblePrefix({ systemContent: first.content });
74
+ if (prefix.dynamicSystem === null || prefix.stableSystem.trim().length === 0) {
75
+ return history;
76
+ }
77
+ return [
78
+ { role: "system", content: prefix.stableSystem },
79
+ { role: "system", content: prefix.dynamicSystem },
80
+ ...history.slice(1),
81
+ ];
82
+ }
83
+ function sha1Hex(text) {
84
+ return createHash("sha1").update(text, "utf8").digest("hex");
85
+ }
86
+ export function assemblePrefix(args) {
87
+ const stableSystem = stripEnvBlock(args.systemContent);
88
+ const tail = args.systemContent.slice(stableSystem.length);
89
+ const dynamicSystem = tail.trim().length > 0 ? tail.trim() : null;
90
+ const toolsPart = args.toolsJson ?? "<no-tools>";
91
+ // Length-prefixed so contents can never alias across the boundary.
92
+ const fingerprint = sha1Hex(`${stableSystem.length}:${stableSystem}\n${toolsPart.length}:${toolsPart}`);
93
+ return {
94
+ stableSystem,
95
+ dynamicSystem,
96
+ fingerprint,
97
+ stableTokens: estimateTokensForChars(stableSystem.length + (args.toolsJson?.length ?? 0)),
98
+ };
99
+ }
package/dist/providers.js CHANGED
@@ -19,8 +19,36 @@
19
19
  // Gemini 2.5/2.0/1.5 Flash/Pro with function calling.
20
20
  // - openai-compatible: generic OpenAI-shape ids (custom baseURL); the live
21
21
  // /models list is authoritative, these are just offline placeholders.
22
- export const DEFAULT_PROVIDER = "opencode-zen";
22
+ export const LOCAL_PROVIDER_IDS = [
23
+ "ollama",
24
+ "lmstudio",
25
+ "llamacpp",
26
+ ];
27
+ export function isLocalProviderId(id) {
28
+ return LOCAL_PROVIDER_IDS.includes(id);
29
+ }
30
+ export const DEFAULT_PROVIDER = "kilo";
23
31
  export const PROVIDERS = [
32
+ {
33
+ id: "kilo",
34
+ name: "Kilo",
35
+ kind: "openai-chat",
36
+ chatEndpoint: "https://api.kilo.ai/api/gateway/chat/completions",
37
+ consoleURL: "https://kilo.ai",
38
+ envVars: ["KILO_API_KEY"],
39
+ // Offline placeholder only: the live /models catalog is authoritative.
40
+ // kilo-auto/free is Kilo's dynamic free routing model, preferred when no
41
+ // API key is configured (see preferFreeKiloModel in src/kilo.ts).
42
+ defaultModel: "kilo-auto/free",
43
+ fallbackModels: ["kilo-auto/free"],
44
+ notes: "Kilo Gateway (OpenAI-compatible). Free :free models work without a key; key unlocks the full catalog.",
45
+ cache: {
46
+ explicitBreakpoints: false,
47
+ implicitPrefix: true,
48
+ usageCacheFields: false,
49
+ notes: "server-dependent; stable serialization only, usage passes through when reported",
50
+ },
51
+ },
24
52
  {
25
53
  id: "opencode-zen",
26
54
  name: "OpenCode Zen",
@@ -41,6 +69,12 @@ export const PROVIDERS = [
41
69
  "big-pickle",
42
70
  ],
43
71
  notes: "OpenAI-compatible chat/completions. reasoning_effort only here.",
72
+ cache: {
73
+ explicitBreakpoints: false,
74
+ implicitPrefix: true,
75
+ usageCacheFields: true,
76
+ notes: "upstream-dependent prefix behavior; usage cache fields pass through when reported",
77
+ },
44
78
  },
45
79
  {
46
80
  id: "openai",
@@ -57,6 +91,12 @@ export const PROVIDERS = [
57
91
  "gpt-5.6-luna",
58
92
  ],
59
93
  notes: "OpenAI-compatible chat/completions. reasoning_effort never sent.",
94
+ cache: {
95
+ explicitBreakpoints: false,
96
+ implicitPrefix: true,
97
+ usageCacheFields: true,
98
+ notes: "automatic prefix caching; usage.prompt_tokens_details.cached_tokens",
99
+ },
60
100
  },
61
101
  {
62
102
  id: "anthropic",
@@ -73,6 +113,12 @@ export const PROVIDERS = [
73
113
  "claude-3-5-haiku-20241022",
74
114
  ],
75
115
  notes: "Messages API with tool_use blocks. max_tokens 4096.",
116
+ cache: {
117
+ explicitBreakpoints: true,
118
+ implicitPrefix: true,
119
+ usageCacheFields: true,
120
+ notes: "cache_control {type:ephemeral} on stable system block + last tool (5m default TTL); usage.cache_read/cache_creation_input_tokens",
121
+ },
76
122
  },
77
123
  {
78
124
  id: "deepseek",
@@ -89,6 +135,12 @@ export const PROVIDERS = [
89
135
  "deepseek-v4-pro",
90
136
  ],
91
137
  notes: "OpenAI-compatible (no /v1 prefix). reasoning_effort never sent.",
138
+ cache: {
139
+ explicitBreakpoints: false,
140
+ implicitPrefix: true,
141
+ usageCacheFields: true,
142
+ notes: "automatic on-disk context caching; usage.prompt_cache_hit_tokens (+miss informational)",
143
+ },
92
144
  },
93
145
  {
94
146
  id: "mistral",
@@ -105,6 +157,12 @@ export const PROVIDERS = [
105
157
  "open-mistral-nemo",
106
158
  ],
107
159
  notes: "OpenAI-compatible chat/completions. reasoning_effort never sent.",
160
+ cache: {
161
+ explicitBreakpoints: false,
162
+ implicitPrefix: false,
163
+ usageCacheFields: false,
164
+ notes: "no verified caching contract — stable serialization only",
165
+ },
108
166
  },
109
167
  {
110
168
  id: "google-gemini",
@@ -122,6 +180,12 @@ export const PROVIDERS = [
122
180
  "gemini-1.5-flash",
123
181
  ],
124
182
  notes: "streamGenerateContent SSE; :generateContent fallback.",
183
+ cache: {
184
+ explicitBreakpoints: false,
185
+ implicitPrefix: true,
186
+ usageCacheFields: true,
187
+ notes: "implicit caching by default (stable content first); usageMetadata.cachedContentTokenCount",
188
+ },
125
189
  },
126
190
  {
127
191
  id: "openai-compatible",
@@ -138,6 +202,81 @@ export const PROVIDERS = [
138
202
  "mixtral-8x7b-32768",
139
203
  ],
140
204
  notes: "Stored baseURL + stored key only. Live /models authoritative.",
205
+ cache: {
206
+ explicitBreakpoints: false,
207
+ implicitPrefix: true,
208
+ usageCacheFields: false,
209
+ notes: "server-dependent; stable serialization only, nothing reported",
210
+ },
211
+ },
212
+ // Local runtimes (auto-discovered; see src/local-discovery.ts). All three
213
+ // serve the OpenAI-compatible /v1/* surface ATOM chats through
214
+ // (Ollama natively documents /v1/chat/completions with streaming, tools,
215
+ // vision, and reasoning support). No API key: the servers ignore bearer
216
+ // auth. fallbackModels stay empty — nothing is listed until discovery
217
+ // reports it, so no model names are fabricated.
218
+ {
219
+ id: "ollama",
220
+ name: "Ollama",
221
+ kind: "openai-chat",
222
+ chatEndpoint: undefined,
223
+ consoleURL: "https://ollama.com",
224
+ envVars: [],
225
+ defaultModel: "",
226
+ fallbackModels: [],
227
+ notes: "Local Ollama server (auto-discovered). Discovery reads native /api/tags, chat uses OpenAI-compatible /v1.",
228
+ cache: {
229
+ explicitBreakpoints: false,
230
+ implicitPrefix: true,
231
+ usageCacheFields: false,
232
+ notes: "server-dependent; stable serialization only, nothing reported",
233
+ },
234
+ local: {
235
+ defaultBaseURL: "http://127.0.0.1:11434",
236
+ baseURLEnvVar: "ATOM_OLLAMA_URL",
237
+ },
238
+ },
239
+ {
240
+ id: "lmstudio",
241
+ name: "LM Studio",
242
+ kind: "openai-chat",
243
+ chatEndpoint: undefined,
244
+ consoleURL: "https://lmstudio.ai",
245
+ envVars: [],
246
+ defaultModel: "",
247
+ fallbackModels: [],
248
+ notes: "Local LM Studio server (auto-discovered, OpenAI-compatible /v1).",
249
+ cache: {
250
+ explicitBreakpoints: false,
251
+ implicitPrefix: true,
252
+ usageCacheFields: false,
253
+ notes: "server-dependent; stable serialization only, nothing reported",
254
+ },
255
+ local: {
256
+ defaultBaseURL: "http://127.0.0.1:1234",
257
+ baseURLEnvVar: "ATOM_LMSTUDIO_URL",
258
+ },
259
+ },
260
+ {
261
+ id: "llamacpp",
262
+ name: "llama.cpp",
263
+ kind: "openai-chat",
264
+ chatEndpoint: undefined,
265
+ consoleURL: "https://github.com/ggml-org/llama.cpp",
266
+ envVars: [],
267
+ defaultModel: "",
268
+ fallbackModels: [],
269
+ notes: "Local llama-server (auto-discovered, OpenAI-compatible /v1; exposes only the loaded model).",
270
+ cache: {
271
+ explicitBreakpoints: false,
272
+ implicitPrefix: true,
273
+ usageCacheFields: false,
274
+ notes: "server-dependent; stable serialization only, nothing reported",
275
+ },
276
+ local: {
277
+ defaultBaseURL: "http://127.0.0.1:8080",
278
+ baseURLEnvVar: "ATOM_LLAMACPP_URL",
279
+ },
141
280
  },
142
281
  ];
143
282
  const BY_ID = Object.fromEntries(PROVIDERS.map((p) => [p.id, p]));
@@ -161,6 +300,28 @@ export function providerLabel(id) {
161
300
  return "Provider";
162
301
  return def.name;
163
302
  }
303
+ // Local runtimes need no API key (loopback servers ignore bearer auth),
304
+ // and Kilo serves anonymous free (`:free`) models, so picker/submit key
305
+ // gates must let both through keyless.
306
+ export function providerNeedsKey(id) {
307
+ if (id === "kilo")
308
+ return false;
309
+ return !isLocalProviderId(id);
310
+ }
311
+ // Resolve a local server baseURL: explicit override wins, then the
312
+ // ATOM_*_URL env var, then the loopback default. Env-before-stored matches
313
+ // key resolution (env wins); stored stays reserved for future UI.
314
+ export function localBaseURLFor(id, storedBaseURL) {
315
+ const def = getProvider(id);
316
+ const envVar = def?.local?.baseURLEnvVar;
317
+ const fromEnv = envVar ? (process.env[envVar] ?? "").trim() : "";
318
+ if (fromEnv)
319
+ return normalizeBaseURL(fromEnv);
320
+ const stored = (storedBaseURL ?? "").trim();
321
+ if (stored)
322
+ return normalizeBaseURL(stored);
323
+ return def?.local?.defaultBaseURL ?? "";
324
+ }
164
325
  // Normalize a custom baseURL: trim whitespace/trailing slashes.
165
326
  export function normalizeBaseURL(raw) {
166
327
  return raw.trim().replace(/\/+$/, "");
@@ -173,12 +334,29 @@ export function openaiCompatibleChatEndpoint(baseURL) {
173
334
  ? base
174
335
  : `${base}/chat/completions`;
175
336
  }
176
- // Chat endpoint for a provider (openai-compatible needs stored baseURL).
337
+ // Local chat endpoint: server base + OpenAI-compatible path, appended iff
338
+ // missing (a custom baseURL may already include /v1).
339
+ export function localChatEndpoint(baseURL) {
340
+ const base = normalizeBaseURL(baseURL);
341
+ return base.endsWith("/v1/chat/completions")
342
+ ? base
343
+ : `${base}/v1/chat/completions`;
344
+ }
345
+ // Local models URL: server base + OpenAI-compatible listing path.
346
+ export function localModelsURL(baseURL) {
347
+ const base = normalizeBaseURL(baseURL);
348
+ return base.endsWith("/v1/models") ? base : `${base}/v1/models`;
349
+ }
350
+ // Chat endpoint for a provider (openai-compatible needs stored baseURL;
351
+ // local runtimes resolve env/default loopback baseURLs).
177
352
  export function chatEndpointFor(id, storedBaseURL) {
178
353
  const def = getProvider(id);
179
354
  if (id === "openai-compatible") {
180
355
  return openaiCompatibleChatEndpoint(storedBaseURL ?? "");
181
356
  }
357
+ if (isLocalProviderId(id)) {
358
+ return localChatEndpoint(localBaseURLFor(id, storedBaseURL));
359
+ }
182
360
  return def.chatEndpoint ?? "";
183
361
  }
184
362
  // Derive the models URL from a chat/completions endpoint
@@ -196,6 +374,9 @@ export function modelsUrlForProvider(id, storedBaseURL) {
196
374
  return "https://api.anthropic.com/v1/models";
197
375
  if (id === "google-gemini")
198
376
  return "https://generativelanguage.googleapis.com/v1beta/models";
377
+ if (isLocalProviderId(id)) {
378
+ return localModelsURL(localBaseURLFor(id, storedBaseURL));
379
+ }
199
380
  return modelsUrlForEndpoint(chatEndpointFor(id, storedBaseURL));
200
381
  }
201
382
  // Mask a key for display: "…1234". Never the full key.
@@ -0,0 +1,21 @@
1
+ /** How each scope rolls back: automatic, explicit-only, or never. */
2
+ export const ROLLBACK_MODE = {
3
+ conversation: "automatic",
4
+ filesystem: "explicit",
5
+ process: "never",
6
+ };
7
+ /** One-line runtime truth per scope, reused by UX text and docs. */
8
+ export const ROLLBACK_NOTES = {
9
+ conversation: "history/turns splice back to the turn start (cancel/failure) or a checkpoint mark (/rewind); rolled-back turns never reach the session file",
10
+ filesystem: "only an explicit /rewind restore reverts bytes (hash-verified); cancel and failure never touch disk",
11
+ process: "never rolled back: foreground bash runs to completion, background tasks survive cancel, nothing is killed",
12
+ };
13
+ /**
14
+ * The cancelled-turn transcript line. Keeps the historical "(cancelled)"
15
+ * marker byte-identical (tests and muscle memory match on the substring)
16
+ * and states the no-revert truth in the same breath, so a cancel can never
17
+ * read as "undone".
18
+ */
19
+ export function cancelledTurnLine() {
20
+ return "(cancelled) conversation rolled back; files and processes were NOT reverted — /rewind restores file snapshots";
21
+ }