atom-agent 1.4.0 → 1.5.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 (67) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +220 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +127 -14
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +211 -430
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/scheduler.js +38 -9
  21. package/dist/session-revert.js +125 -0
  22. package/dist/sessions.js +101 -0
  23. package/dist/snapshots.js +69 -0
  24. package/dist/system.js +2 -89
  25. package/dist/telemetry.js +26 -1
  26. package/dist/todos.js +241 -0
  27. package/dist/tools/filesystem.js +102 -22
  28. package/dist/tools/registry.js +184 -45
  29. package/dist/tools/ripgrep.js +7 -6
  30. package/dist/tools/search.js +172 -17
  31. package/dist/tools/shared.js +6 -0
  32. package/dist/tools.js +7 -39
  33. package/dist/ui/diff-panel.js +1 -1
  34. package/dist/ui/diff-view.js +13 -5
  35. package/dist/ui/diff.js +67 -0
  36. package/dist/ui/errors.js +20 -6
  37. package/dist/ui/input.js +24 -20
  38. package/dist/ui/live-tail.js +36 -1
  39. package/dist/ui/markdown.js +9 -4
  40. package/dist/ui/modals.js +7 -5
  41. package/dist/ui/paint-scheduler.js +120 -0
  42. package/dist/ui/palette.js +4 -2
  43. package/dist/ui/pickers.js +4 -1
  44. package/dist/ui/side-by-side.js +81 -22
  45. package/dist/ui/status-bar.js +63 -8
  46. package/dist/ui/stream-store.js +7 -0
  47. package/dist/ui/theme.js +23 -1
  48. package/dist/ui/todo-panel.js +5 -2
  49. package/dist/ui/tool-inspector.js +33 -4
  50. package/dist/ui/transcript.js +8 -5
  51. package/dist/web/events.js +93 -0
  52. package/dist/web/runtime.js +790 -0
  53. package/dist/web/server.js +570 -0
  54. package/dist/web/ui/app.js +1925 -0
  55. package/dist/web/ui/index.html +135 -0
  56. package/dist/web/ui/styles.css +515 -0
  57. package/dist/zen.js +115 -4
  58. package/documentation/cli.md +5 -5
  59. package/documentation/configuration.md +11 -6
  60. package/documentation/development.md +4 -3
  61. package/documentation/goals.md +1 -1
  62. package/documentation/index.md +4 -4
  63. package/documentation/providers.md +2 -3
  64. package/documentation/skills.md +3 -3
  65. package/documentation/tools.md +8 -3
  66. package/documentation/troubleshooting.md +1 -1
  67. package/package.json +3 -2
package/dist/config.js CHANGED
@@ -23,6 +23,13 @@
23
23
  // deprecated alias for "auto")
24
24
  // - maxToolSteps: 5–100 (tool rounds per turn)
25
25
  // - compactPct: 50–95 (auto-compact percent of verified window)
26
+ // - compactAuto: boolean (real-usage auto-compact master switch, default on;
27
+ // false disables AUTO-compaction only — manual /compact always works)
28
+ // - compactReserve: reserved output buffer in tokens for the usable-limit
29
+ // calculation (usable = verified window − reserve; clamped 4096–100000,
30
+ // mirroring src/overflow.ts OVERFLOW_RESERVE_MIN/MAX — kept literal here
31
+ // so config.ts has no runtime import of overflow.ts, which itself reads
32
+ // config at runtime)
26
33
  // - telemetry: {enabled?: boolean} (local observability recording, default on)
27
34
  // - extensions: {enabled?: string[], disabled?: string[]} (per-extension
28
35
  // enable/disable patterns over the extension name, `*`/`?` globs; disabled
@@ -122,6 +129,9 @@ function parseLevel(filePath, label) {
122
129
  const ranged = [
123
130
  { key: "maxToolSteps", min: 5, max: 100 },
124
131
  { key: "compactPct", min: 50, max: 95 },
132
+ // Reserve buffer bounds mirror overflow.ts (literals, not imports — see
133
+ // the header note on the import direction).
134
+ { key: "compactReserve", min: 4096, max: 100000 },
125
135
  ];
126
136
  for (const { key, min, max } of ranged) {
127
137
  const v = data[key];
@@ -140,6 +150,15 @@ function parseLevel(filePath, label) {
140
150
  }
141
151
  config[key] = clamped;
142
152
  }
153
+ const compactAuto = data["compactAuto"];
154
+ if (compactAuto !== undefined) {
155
+ if (typeof compactAuto === "boolean") {
156
+ config.compactAuto = compactAuto;
157
+ }
158
+ else {
159
+ bad("compactAuto", "must be a boolean");
160
+ }
161
+ }
143
162
  const network = data["network"];
144
163
  if (network !== undefined) {
145
164
  const parsed = parseNetworkPolicy(network);
@@ -30,6 +30,7 @@
30
30
  // re-architecting call sites. No cache state lives here yet by design.
31
31
  import { contextWindowFor } from "./context-windows.js";
32
32
  import { loadAtomConfig } from "./config.js";
33
+ import { mediaWireChars } from "./media.js";
33
34
  // ---- Units ----
34
35
  // Shared chars-per-token estimator (opencode's 4ch/token preflight
35
36
  // heuristic). Floors to whole tokens; never used for billed spend, only for
@@ -41,12 +42,15 @@ export function estimateTokensForChars(chars) {
41
42
  }
42
43
  // Deterministic size of one message: string content counts as-is, anything
43
44
  // else counts stringified; assistant tool_calls and tool ids count too (they
44
- // ride on every POST). History chars = the sum over all messages.
45
+ // ride on every POST). Media descriptor tokens (`[media:<id> <mime> <bytes>B]`,
46
+ // see src/media.ts) additionally count their deterministic base64 wire cost
47
+ // (ceil(bytes*4/3)) — history text stays small while the load stays honest.
48
+ // History chars = the sum over all messages.
45
49
  export function messageChars(m) {
46
50
  let n = 0;
47
51
  const content = m.content;
48
52
  if (typeof content === "string") {
49
- n += content.length;
53
+ n += content.length + mediaWireChars(content);
50
54
  }
51
55
  else if (content !== null && content !== undefined) {
52
56
  n += JSON.stringify(content).length;
@@ -792,6 +792,12 @@ export async function loadExtensions(opts = {}) {
792
792
  invalidate(message) {
793
793
  staleMessage = message;
794
794
  generation += 1;
795
+ // Staged notices belong to the dead lineage: drop them here, or the
796
+ // next render drain would print pre-switch notices into the NEW
797
+ // session's transcript (stale async content behind a newer commit).
798
+ // Fresh session_start handlers re-notify via their new API.
799
+ // (Mirrors disposeUI below, which drops them on teardown.)
800
+ notifications.length = 0;
795
801
  // A dialog awaiting input across a session switch resolves safely:
796
802
  // reject with the stale message (never hangs, never fulfills into
797
803
  // the wrong session). Visible segments/widgets persist keyed by
@@ -0,0 +1,108 @@
1
+ // Per-turn file-diff records (ticket 06) for the compaction summary's
2
+ // Relevant Files section.
3
+ //
4
+ // Each completed turn records which files it actually touched; the records
5
+ // accumulate across turns under the session's `metadata.filediffs` key and
6
+ // feed the summary's files section through compact.ts's existing
7
+ // format/fit helpers (read-only — this module never changes summary output).
8
+ //
9
+ // WIRING CONTRACT (for the conductor follow-up; App.tsx untouched here):
10
+ // - AFTER each completed turn commits (failed/cancelled turns are rolled
11
+ // back and never recorded), call `collectTurnFileDiffs(turnMessages)`
12
+ // with that turn's committed messages (passing the whole turn slice is
13
+ // safe — only assistant tool_calls are inspected; capture never changes
14
+ // tool behavior).
15
+ // - When the result is non-empty, accumulate and persist:
16
+ // const prev = readFileDiffs(session.metadata?.["filediffs"]);
17
+ // const merged = mergeFileDiffs(prev, turn);
18
+ // updateSession(id, {
19
+ // metadata: { ...session.metadata, [FILE_DIFFS_METADATA_KEY]: serializeFileDiffs(merged) },
20
+ // });
21
+ // - On session switch/restore, rehydrate with
22
+ // `readFileDiffs(next.metadata?.["filediffs"])` (missing key reads as
23
+ // empty; malformed values degrade to empty without failing the load).
24
+ // - At compaction time, pass the accumulated record straight into the
25
+ // existing `formatTouchedFiles` / `fitSummaryWithFiles` /
26
+ // `fitSummaryWithFilesAndGoal` helpers from compact.ts.
27
+ //
28
+ // Vocabulary: this module reuses compact.ts's TouchedFiles shape
29
+ // ({ read, modified }) — no competing file-tracking vocabulary. A path both
30
+ // read and written lands in modified only (the write implies the read).
31
+ // The `metadata.todos` key (ticket 05) is never read or written here.
32
+ import { collectTouchedFiles } from "./compact.js";
33
+ // Namespace inside the generic Session.metadata record (ticket 01).
34
+ export const FILE_DIFFS_METADATA_KEY = "filediffs";
35
+ export function emptyFileDiffs() {
36
+ return { read: [], modified: [] };
37
+ }
38
+ function isStringList(value) {
39
+ return (Array.isArray(value) && value.every((v) => typeof v === "string"));
40
+ }
41
+ // Collect one turn's change record from its committed messages. Write/edit-
42
+ // style tool outcomes land in modified, read-style outcomes in read
43
+ // (insertion order, unique, modified-wins); unparseable arguments are
44
+ // skipped. Turns with no file changes produce an empty record — never
45
+ // spurious entries. Pure observer: tool behavior is untouched.
46
+ export function collectTurnFileDiffs(turnMessages) {
47
+ if (!Array.isArray(turnMessages))
48
+ return emptyFileDiffs();
49
+ const collected = collectTouchedFiles(turnMessages);
50
+ // Defensive copy: callers must never alias compact.ts internals.
51
+ return { read: [...collected.read], modified: [...collected.modified] };
52
+ }
53
+ // Accumulate one turn's record into the session-scoped record. Insertion
54
+ // order is preserved, entries stay unique, and a path promoted from read to
55
+ // modified (read in an earlier turn, written later) ends in modified only.
56
+ // Neither input is mutated; the result is a fresh record.
57
+ export function mergeFileDiffs(accumulated, turn) {
58
+ const base = readFileDiffs(accumulated);
59
+ const next = readFileDiffs(turn);
60
+ const read = [...base.read];
61
+ const modified = [...base.modified];
62
+ for (const p of next.read) {
63
+ if (!modified.includes(p) && !read.includes(p))
64
+ read.push(p);
65
+ }
66
+ for (const p of next.modified) {
67
+ const at = read.indexOf(p);
68
+ if (at >= 0)
69
+ read.splice(at, 1);
70
+ if (!modified.includes(p))
71
+ modified.push(p);
72
+ }
73
+ return { read, modified };
74
+ }
75
+ // Tolerant read of the `metadata.filediffs` value: missing keys read as
76
+ // empty, and malformed values (wrong shape, non-string entries) degrade to
77
+ // the valid subset — or empty — WITHOUT failing the session load. Always
78
+ // returns a fresh record.
79
+ export function readFileDiffs(value) {
80
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
81
+ return emptyFileDiffs();
82
+ }
83
+ const record = value;
84
+ const rawRead = isStringList(record["read"]) ? record["read"] : [];
85
+ const rawModified = isStringList(record["modified"])
86
+ ? record["modified"]
87
+ : [];
88
+ const read = [];
89
+ const modified = [];
90
+ for (const raw of rawModified) {
91
+ const p = raw.trim();
92
+ if (p.length > 0 && !modified.includes(p))
93
+ modified.push(p);
94
+ }
95
+ const modifiedSet = new Set(modified);
96
+ for (const raw of rawRead) {
97
+ const p = raw.trim();
98
+ if (p.length === 0 || modifiedSet.has(p) || read.includes(p))
99
+ continue;
100
+ read.push(p);
101
+ }
102
+ return { read, modified };
103
+ }
104
+ // JSON-safe snapshot for `metadata.filediffs` persistence via the existing
105
+ // updateSession/getSession APIs (no schema edits). Fresh copy every call.
106
+ export function serializeFileDiffs(diffs) {
107
+ return readFileDiffs(diffs);
108
+ }
package/dist/kilo.js CHANGED
@@ -253,7 +253,7 @@ function readKiloCache(hasKey, now = Date.now()) {
253
253
  return slot.status;
254
254
  }
255
255
  // Manual refresh path: clears the discovery cache so the next fetch hits
256
- // `/models` again (used by `/models refresh` and provider switches).
256
+ // `/model refresh` again (used by the refresh path and provider switches).
257
257
  export function clearKiloModelsCache() {
258
258
  kiloModelsCache.clear();
259
259
  }
@@ -21,7 +21,7 @@
21
21
  // exposes them (llama-server meta.n_ctx_train -> contextLength). Nothing is
22
22
  // fabricated. Unreachable/malformed servers yield ok:false, never throw —
23
23
  // discovery failures are isolated per provider and silent by default (the
24
- // TUI surfaces them only via /models).
24
+ // TUI surfaces them only via /model).
25
25
  //
26
26
  // Concurrency: createLocalDiscovery() owns one in-flight promise per scope
27
27
  // ("all" or one provider id), so overlapping refresh calls share work and
@@ -59,7 +59,7 @@ export function emptyLocalSnapshot() {
59
59
  version: 0,
60
60
  };
61
61
  }
62
- // One-line human summary for /models output (counts only, no transcript spam).
62
+ // One-line human summary for /model refresh output (counts only, no transcript spam).
63
63
  export function summarizeLocalSnapshot(snap) {
64
64
  const parts = LOCAL_PROVIDER_IDS.map((id) => {
65
65
  const r = snap.results[id];
package/dist/media.js ADDED
@@ -0,0 +1,276 @@
1
+ // Vision input (image attachments) for the harness.
2
+ //
3
+ // Scope (deliberate): PNG, JPEG, GIF, WebP only — the same four OpenCode
4
+ // passes as image media. PDF, AVIF, BMP, audio, video, and other binaries
5
+ // are rejected with an actionable convert-first error, never silently.
6
+ // No resize/re-encode dependency (zero-dep policy): oversize images are
7
+ // rejected with guidance instead of downscaled.
8
+ //
9
+ // Design (blast-radius minimal):
10
+ // - History/transcript/session stay plain strings. The `read` tool stores
11
+ // image bytes under ~/.atom/media/ and returns a short descriptor token
12
+ // `[media:<id> <mime> <bytes>B]`. The TUI renders it as one text line;
13
+ // session.json stays small; /resume keeps working.
14
+ // - Expansion happens at exactly one point per POST kind (openai-chat in
15
+ // zen.ts, anthropic-messages + gemini-generate in adapters.ts), which
16
+ // resolve descriptor tokens into provider-native image blocks. Missing
17
+ // files (pruned, deleted home) degrade to a text placeholder — never a
18
+ // crash, never a dropped turn.
19
+ // - Compaction/summarization and text-only-model fallback use strip mode:
20
+ // descriptors become `[image: <name>]` prose markers, so the summarizer
21
+ // never pays for pixels.
22
+ // - Context accounting stays honest: messageChars adds the deterministic
23
+ // base64 wire cost (ceil(bytes*4/3)) per descriptor — see
24
+ // mediaWireChars Bash in context-manager.ts.
25
+ import { randomBytes } from "node:crypto";
26
+ import { promises as fsp } from "node:fs";
27
+ import * as fs from "node:fs";
28
+ import * as path from "node:path";
29
+ import { atomDir } from "./auth.js";
30
+ export const SUPPORTED_IMAGE_MIMES = new Set([
31
+ "image/png",
32
+ "image/jpeg",
33
+ "image/gif",
34
+ "image/webp",
35
+ ]);
36
+ // Ingest cap per image (raw bytes). Over it the read tool refuses with
37
+ // guidance instead of downscaling (no image codec dependency).
38
+ export const MEDIA_MAX_BYTES = 8 * 1024 * 1024;
39
+ // Stored media older than this is pruned best-effort on the next media
40
+ // write (fail-open — pruning never breaks reads).
41
+ export const MEDIA_PRUNE_AFTER_MS = 7 * 24 * 3600 * 1000;
42
+ export const MEDIA_DIRNAME = "media";
43
+ // Descriptor token embedded in tool results / history text:
44
+ // `[media:<id> <mime> <bytes>B]`, e.g. `[media:k3xq9z image/png 41204B]`.
45
+ export const MEDIA_RE = /\[media:([A-Za-z0-9_-]{1,64}) ([a-z]+\/[a-z0-9.+-]+) (\d+)B\]/g;
46
+ export function mediaDescriptor(id, mime, bytes) {
47
+ return `[media:${id} ${mime} ${bytes}B]`;
48
+ }
49
+ // Magic-byte sniff over the file head. Returns the image mime when the
50
+ // bytes are a supported image, else null (caller falls through to the
51
+ // text path or the unsupported-binary error).
52
+ export function sniffImageMime(head) {
53
+ const b = head;
54
+ const n = b.length;
55
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
56
+ if (n >= 8 &&
57
+ b[0] === 0x89 &&
58
+ b[1] === 0x50 &&
59
+ b[2] === 0x4e &&
60
+ b[3] === 0x47 &&
61
+ b[4] === 0x0d &&
62
+ b[5] === 0x0a &&
63
+ b[6] === 0x1a &&
64
+ b[7] === 0x0a) {
65
+ return "image/png";
66
+ }
67
+ // JPEG: FF D8 FF
68
+ if (n >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
69
+ return "image/jpeg";
70
+ }
71
+ // GIF: "GIF87a" / "GIF89a"
72
+ if (n >= 6 &&
73
+ b[0] === 0x47 &&
74
+ b[1] === 0x49 &&
75
+ b[2] === 0x46 &&
76
+ b[3] === 0x38 &&
77
+ (b[4] === 0x37 || b[4] === 0x39) &&
78
+ b[5] === 0x61) {
79
+ return "image/gif";
80
+ }
81
+ // WebP: "RIFF" + 4 size bytes + "WEBP"
82
+ if (n >= 12 &&
83
+ b[0] === 0x52 &&
84
+ b[1] === 0x49 &&
85
+ b[2] === 0x46 &&
86
+ b[3] === 0x46 &&
87
+ b[8] === 0x57 &&
88
+ b[9] === 0x45 &&
89
+ b[10] === 0x42 &&
90
+ b[11] === 0x50) {
91
+ return "image/webp";
92
+ }
93
+ return null;
94
+ }
95
+ // True when the head looks like a PDF (%PDF magic). PDFs are rejected as
96
+ // vision input with convert-first guidance (see unsupportedBinaryError).
97
+ export function sniffPdf(head) {
98
+ return (head.length >= 4 &&
99
+ head[0] === 0x25 &&
100
+ head[1] === 0x50 &&
101
+ head[2] === 0x44 &&
102
+ head[3] === 0x46);
103
+ }
104
+ // Actionable error for binaries the harness cannot send as vision input.
105
+ // Never throws; the model gets conversion guidance it can act on via bash.
106
+ export function unsupportedBinaryError(filePath, kind, sizeBytes) {
107
+ const size = typeof sizeBytes === "number" ? ` (${sizeBytes} bytes)` : "";
108
+ if (kind === "pdf") {
109
+ return (`Error: cannot read ${filePath}${size} as vision input — PDFs are not supported. ` +
110
+ `Convert it first: export pages as PNG (e.g. \`pdftoppm -png ${filePath} page\`) and read the PNG, ` +
111
+ `or extract text (e.g. \`pdftotext ${filePath} -\`) and read that instead.`);
112
+ }
113
+ return (`Error: cannot read ${filePath}${size} — unsupported binary (only PNG, JPEG, GIF, WebP images ` +
114
+ `are read as vision input). Convert it to a supported image or extract its text first, then read that.`);
115
+ }
116
+ export function oversizeImageError(filePath, sizeBytes) {
117
+ const mb = (MEDIA_MAX_BYTES / (1024 * 1024)).toFixed(0);
118
+ return (`Error: ${filePath} is ${sizeBytes} bytes (over the ${mb} MiB image cap) — downscale it ` +
119
+ `first (e.g. with Python PIL or ImageMagick) and read the smaller file.`);
120
+ }
121
+ export function mediaDir(home) {
122
+ return path.join(atomDir(home), MEDIA_DIRNAME);
123
+ }
124
+ function mediaId() {
125
+ return `${Date.now().toString(36)}${randomBytes(6).toString("hex")}`;
126
+ }
127
+ export async function saveMedia(raw, mime, name, home) {
128
+ const dir = mediaDir(home);
129
+ try {
130
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
131
+ }
132
+ catch {
133
+ // fail-open: write below still attempted
134
+ }
135
+ const id = mediaId();
136
+ try {
137
+ await fsp.writeFile(path.join(dir, `${id}.bin`), raw);
138
+ await fsp.writeFile(path.join(dir, `${id}.json`), JSON.stringify({ mime, name, bytes: raw.length }));
139
+ }
140
+ catch {
141
+ // fail-open: descriptor still returned; POST lowering degrades to a
142
+ // placeholder when the files are unreadable.
143
+ }
144
+ pruneMedia(dir);
145
+ return { id };
146
+ }
147
+ export function loadMedia(id, home) {
148
+ try {
149
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(id))
150
+ return null;
151
+ const dir = mediaDir(home);
152
+ const metaRaw = fs.readFileSync(path.join(dir, `${id}.json`), "utf8");
153
+ const meta = JSON.parse(metaRaw);
154
+ if (typeof meta.mime !== "string" || !SUPPORTED_IMAGE_MIMES.has(meta.mime))
155
+ return null;
156
+ const raw = fs.readFileSync(path.join(dir, `${id}.bin`));
157
+ return {
158
+ mime: meta.mime,
159
+ name: typeof meta.name === "string" ? meta.name : id,
160
+ bytes: raw.length,
161
+ base64: raw.toString("base64"),
162
+ };
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ // Delete stored media older than MEDIA_PRUNE_AFTER_MS. Fire-and-forget,
169
+ // best-effort, never throws (called without await).
170
+ function pruneMedia(dir) {
171
+ try {
172
+ const cutoff = Date.now() - MEDIA_PRUNE_AFTER_MS;
173
+ void fsp
174
+ .readdir(dir)
175
+ .then(async (entries) => {
176
+ for (const e of entries) {
177
+ if (!e.endsWith(".bin") && !e.endsWith(".json"))
178
+ continue;
179
+ const p = path.join(dir, e);
180
+ try {
181
+ const st = await fsp.stat(p);
182
+ if (st.mtimeMs < cutoff)
183
+ await fsp.rm(p, { force: true });
184
+ }
185
+ catch {
186
+ // per-file fail-open
187
+ }
188
+ }
189
+ })
190
+ .catch(() => { });
191
+ }
192
+ catch {
193
+ // fail-open
194
+ }
195
+ }
196
+ // ---- Descriptor helpers (pure, no I/O) -----------------------------------
197
+ // Deterministic wire cost of the descriptors in a string: base64 inflates
198
+ // raw bytes by exactly 4/3. Used by messageChars so context accounting
199
+ // stays honest.
200
+ export function mediaWireChars(content) {
201
+ let n = 0;
202
+ MEDIA_RE.lastIndex = 0;
203
+ let m;
204
+ while ((m = MEDIA_RE.exec(content)) !== null) {
205
+ const bytes = Number(m[3]);
206
+ if (Number.isFinite(bytes) && bytes > 0)
207
+ n += Math.ceil(bytes / 3) * 4;
208
+ }
209
+ MEDIA_RE.lastIndex = 0;
210
+ return n;
211
+ }
212
+ export function hasMediaRefs(content) {
213
+ MEDIA_RE.lastIndex = 0;
214
+ const hit = MEDIA_RE.test(content);
215
+ MEDIA_RE.lastIndex = 0;
216
+ return hit;
217
+ }
218
+ // Strip mode: descriptors become prose markers. Used for compaction /
219
+ // summarization POSTs and the text-only-model fallback retry.
220
+ export function stripMedia(content) {
221
+ MEDIA_RE.lastIndex = 0;
222
+ const out = content.replace(MEDIA_RE, (_tok, _id, mime) => `[image omitted: ${mime}]`);
223
+ MEDIA_RE.lastIndex = 0;
224
+ return out;
225
+ }
226
+ // Server-authoritative image rejection: a 400 naming image/vision input
227
+ // means this model/deployment takes no images — the caller retries once
228
+ // with media stripped (mirrors the reasoning-effort knob precedent).
229
+ export function isImageRejection(errText) {
230
+ if (!/image|vision|multimodal|media|picture/i.test(errText))
231
+ return false;
232
+ return /not supported|unsupported|does not support|do not support|cannot (read|see|view|process|accept)|invalid image|no.*vision|vision.*not|400/i.test(errText);
233
+ }
234
+ export function resolveMediaRefs(content, home) {
235
+ const media = [];
236
+ MEDIA_RE.lastIndex = 0;
237
+ const text = content.replace(MEDIA_RE, (_tok, id, _mime, _bytes) => {
238
+ const loaded = loadMedia(id, home);
239
+ if (!loaded) {
240
+ media.push({ ok: false, id });
241
+ return `[image unavailable: ${id}]`;
242
+ }
243
+ media.push({ ok: true, id, mime: loaded.mime, name: loaded.name, base64: loaded.base64 });
244
+ return `[image: ${loaded.name}]`;
245
+ });
246
+ MEDIA_RE.lastIndex = 0;
247
+ return { text, media };
248
+ }
249
+ export function historyHasMedia(history) {
250
+ for (const m of history) {
251
+ const c = m.content;
252
+ if (typeof c === "string" && hasMediaRefs(c))
253
+ return true;
254
+ }
255
+ return false;
256
+ }
257
+ // OpenAI-chat lowering for one message content: string in, string out when
258
+ // no descriptors are present (byte-identical — existing payload tests hold),
259
+ // else a text + image_url parts array. System role always strips.
260
+ export function lowerOpenAIContent(role, content, mode = "send", home) {
261
+ if (!hasMediaRefs(content))
262
+ return content;
263
+ if (mode === "strip" || role === "system")
264
+ return stripMedia(content);
265
+ const { text, media } = resolveMediaRefs(content, home);
266
+ const parts = [{ type: "text", text }];
267
+ for (const m of media) {
268
+ if (!m.ok)
269
+ continue; // placeholder already inline in text
270
+ parts.push({
271
+ type: "image_url",
272
+ image_url: { url: `data:${m.mime};base64,${m.base64}` },
273
+ });
274
+ }
275
+ return parts;
276
+ }
@@ -0,0 +1,140 @@
1
+ // Real-usage overflow trigger (ticket 02) — the auto-compaction decision
2
+ // based on the provider's REAL reported token usage against each model's
3
+ // usable limit (verified window minus a reserved output buffer), instead of
4
+ // a fixed percentage estimate.
5
+ //
6
+ // Formula: usable = verified window − reserved; overflow when the last
7
+ // POST's real total tokens (total_tokens, else prompt + completion + cache
8
+ // read + cache write) >= usable.
9
+ //
10
+ // Honesty rules (same as the footer segment in context-windows.ts):
11
+ // - Models with NO verified window never auto-fire (usable is undefined —
12
+ // a window is never invented, a percentage never fabricated).
13
+ // - No real usage reported yet → no fire (nothing estimated).
14
+ // - auto=false disables auto-compaction only; manual /compact is untouched
15
+ // (it never consults this module).
16
+ //
17
+ // This module is pure + testable: config/env reads go through the same
18
+ // precedence as compactPct (env > atom.json > default). It imports
19
+ // context-windows (metadata) and config (file fallback) at runtime only,
20
+ // plus the Usage TYPE (erased at compile — no zen.js runtime cycle).
21
+ // Compaction MECHANICS (summary, tail split, swap) stay in compact.ts,
22
+ // owned by later tickets — this module only decides WHEN auto fires.
23
+ import { loadAtomConfig } from "./config.js";
24
+ import { contextWindowFor } from "./context-windows.js";
25
+ // ---- Reserved output buffer ----
26
+ // Default buffer kept free for the next generation (~20k tokens: one
27
+ // summary-sized compaction output plus headroom for the reply that follows).
28
+ export const OVERFLOW_RESERVE_DEFAULT = 20_000;
29
+ // The buffer always fits at least one full summary-sized generation
30
+ // (mirrors COMPACT_SUMMARY_MAX_TOKENS in compact.ts) — a smaller buffer
31
+ // could not even emit the compaction summary it is reserving for.
32
+ export const OVERFLOW_RESERVE_MIN = 4096;
33
+ // Upper bound keeps the usable limit positive on the smallest verified
34
+ // window (200K); per-model, the reserve is additionally capped at
35
+ // window−1 so usable never drops below 1 token.
36
+ export const OVERFLOW_RESERVE_MAX = 100_000;
37
+ function clampReserve(n) {
38
+ const floored = Math.floor(n);
39
+ if (!Number.isFinite(floored))
40
+ return OVERFLOW_RESERVE_DEFAULT;
41
+ return Math.min(Math.max(floored, OVERFLOW_RESERVE_MIN), OVERFLOW_RESERVE_MAX);
42
+ }
43
+ // Reserved output buffer in tokens. Precedence: env ATOM_COMPACT_RESERVE
44
+ // (tokens, e.g. "20000", clamped to [MIN, MAX]) → atom.json compactReserve
45
+ // → default; invalid/unset falls through.
46
+ export function compactReserveTokens() {
47
+ const raw = process.env.ATOM_COMPACT_RESERVE;
48
+ if (raw !== undefined) {
49
+ const text = raw.trim();
50
+ if (/^\d+(\.\d+)?$/.test(text)) {
51
+ const n = Number(text);
52
+ if (Number.isFinite(n))
53
+ return clampReserve(n);
54
+ }
55
+ }
56
+ const file = loadAtomConfig().config.compactReserve;
57
+ if (file !== undefined)
58
+ return clampReserve(file);
59
+ return OVERFLOW_RESERVE_DEFAULT;
60
+ }
61
+ // Auto-compaction master switch. Precedence: env ATOM_COMPACT_AUTO
62
+ // (1/true/yes/on → on; 0/false/no/off → off) → atom.json compactAuto → on.
63
+ // False disables AUTO-compaction only; manual /compact never reads this.
64
+ export function compactAutoEnabled() {
65
+ const raw = process.env.ATOM_COMPACT_AUTO;
66
+ if (raw !== undefined) {
67
+ const text = raw.trim().toLowerCase();
68
+ if (["1", "true", "yes", "y", "on"].includes(text))
69
+ return true;
70
+ if (["0", "false", "no", "n", "off"].includes(text))
71
+ return false;
72
+ }
73
+ const file = loadAtomConfig().config.compactAuto;
74
+ if (file !== undefined)
75
+ return file;
76
+ return true;
77
+ }
78
+ // ---- Real total tokens ----
79
+ // The last POST's real reported total: total_tokens when the provider sent
80
+ // it, else the sum of the reported parts (prompt + completion + separately-
81
+ // reported cache read/write — prompt_tokens is already cache-inclusive for
82
+ // exclusive-cache providers, so providers that report cache separately need
83
+ // the extra terms to count cached context). Undefined when nothing usable
84
+ // was reported (absent fields mean "not reported", never zero).
85
+ export function realTotalTokens(usage) {
86
+ if (!usage)
87
+ return undefined;
88
+ const t = usage.total_tokens;
89
+ if (typeof t === "number" && Number.isFinite(t))
90
+ return Math.max(0, Math.floor(t));
91
+ let seen = false;
92
+ let sum = 0;
93
+ const parts = [usage.prompt_tokens, usage.completion_tokens, usage.cacheReadTokens, usage.cacheWriteTokens];
94
+ for (const v of parts) {
95
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
96
+ seen = true;
97
+ sum += Math.floor(v);
98
+ }
99
+ }
100
+ return seen ? sum : undefined;
101
+ }
102
+ // ---- Usable limit + decision ----
103
+ // Usable limit for a model: verified window minus the reserved buffer, or
104
+ // undefined when the model has no verified window (never invented). The
105
+ // reserve is capped per-model at window−1 so usable stays >= 1.
106
+ export function usableLimitFor(model, reserveOverride) {
107
+ const window = contextWindowFor(model);
108
+ if (window === undefined)
109
+ return undefined;
110
+ const reserve = typeof reserveOverride === "number" && Number.isFinite(reserveOverride)
111
+ ? clampReserve(reserveOverride)
112
+ : compactReserveTokens();
113
+ return Math.max(1, window - Math.min(reserve, window - 1));
114
+ }
115
+ // Honest percent of the verified window consumed by the last POST's real
116
+ // total, or undefined when the window is unknown or nothing was reported
117
+ // (never fabricated — same rule as formatTokenSegment's bare `token: NK`).
118
+ export function realUsagePct(usage, model) {
119
+ const window = contextWindowFor(model);
120
+ if (window === undefined)
121
+ return undefined;
122
+ const total = realTotalTokens(usage);
123
+ if (total === undefined)
124
+ return undefined;
125
+ return Math.round((100 * total) / window);
126
+ }
127
+ // Auto-compact trigger: true only when auto is on, the model has a verified
128
+ // window, real usage was reported, and real total >= usable limit.
129
+ export function shouldAutoCompactReal(model, usage, opts) {
130
+ const auto = opts?.auto ?? compactAutoEnabled();
131
+ if (!auto)
132
+ return false;
133
+ const usable = usableLimitFor(model, opts?.reserve);
134
+ if (usable === undefined)
135
+ return false;
136
+ const total = realTotalTokens(usage);
137
+ if (total === undefined)
138
+ return false;
139
+ return total >= usable;
140
+ }
package/dist/policy.js CHANGED
@@ -43,6 +43,14 @@ export function decidePolicy(name, args, ctx) {
43
43
  return { kind: "allow", via: "skill-grant" };
44
44
  return { kind: "prompt" };
45
45
  }
46
+ export function decideApproval(name, args, ctx, preview = null) {
47
+ const outcome = decidePolicy(name, args, ctx);
48
+ if (outcome.kind === "deny")
49
+ return { decision: "deny", via: "deny", preview };
50
+ if (outcome.kind === "allow")
51
+ return { decision: "allow", via: outcome.via, preview };
52
+ return { decision: "prompt", via: "prompt", preview };
53
+ }
46
54
  // ---- 2. Skill-grant trust boundary ----
47
55
  // Approval-gated tools: the dangerous capabilities a grant can unlock
48
56
  // (shell, filesystem mutation; network/process execution ride bash).