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
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
|
|
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
|
-
//
|
|
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.
|
package/dist/rollback.js
ADDED
|
@@ -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
|
+
}
|