atom-agent 1.1.0 → 1.3.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 (74) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +18 -8
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +1637 -255
  5. package/dist/adapters.js +112 -21
  6. package/dist/agent/gates.js +14 -1
  7. package/dist/agent/goal-evaluator.js +69 -0
  8. package/dist/agent/loop-guard.js +11 -13
  9. package/dist/agent/loop.js +716 -132
  10. package/dist/agent/normalize.js +9 -2
  11. package/dist/cli.js +25 -3
  12. package/dist/compact.js +169 -17
  13. package/dist/config.js +43 -7
  14. package/dist/context-manager.js +16 -198
  15. package/dist/context-windows.js +4 -2
  16. package/dist/env-block.js +46 -8
  17. package/dist/extension-commands.js +196 -0
  18. package/dist/extension-ui.js +153 -0
  19. package/dist/extensions.js +1571 -0
  20. package/dist/goal.js +583 -0
  21. package/dist/project-trust.js +96 -0
  22. package/dist/providers.js +6 -6
  23. package/dist/scheduler.js +159 -41
  24. package/dist/session.js +23 -5
  25. package/dist/sessions.js +543 -0
  26. package/dist/system.js +89 -13
  27. package/dist/telemetry-dashboard.js +28 -0
  28. package/dist/telemetry.js +39 -0
  29. package/dist/tools/compaction-hooks.js +165 -0
  30. package/dist/tools/custom.js +189 -0
  31. package/dist/tools/dir-cache.js +7 -0
  32. package/dist/tools/filesystem.js +3 -2
  33. package/dist/tools/intercept.js +145 -0
  34. package/dist/tools/overrides.js +105 -0
  35. package/dist/tools/provider-hooks.js +224 -0
  36. package/dist/tools/registry.js +247 -17
  37. package/dist/tools/ripgrep.js +256 -0
  38. package/dist/tools/search.js +119 -58
  39. package/dist/tools/shared.js +39 -0
  40. package/dist/tools/shell.js +7 -5
  41. package/dist/tools/web.js +6 -6
  42. package/dist/tools.js +45 -0
  43. package/dist/ui/diff-view.js +7 -2
  44. package/dist/ui/live-host.js +18 -0
  45. package/dist/ui/live-tail.js +9 -3
  46. package/dist/ui/markdown.js +26 -2
  47. package/dist/ui/palette.js +3 -1
  48. package/dist/ui/side-by-side.js +2 -2
  49. package/dist/ui/status-bar.js +80 -5
  50. package/dist/ui/status-host.js +22 -0
  51. package/dist/ui/stream-store.js +48 -0
  52. package/dist/ui/tool-inspector.js +7 -1
  53. package/dist/ui/transcript.js +92 -38
  54. package/dist/zen.js +370 -87
  55. package/documentation/architecture.md +114 -0
  56. package/documentation/cli.md +82 -0
  57. package/documentation/compaction.md +50 -0
  58. package/documentation/configuration.md +111 -0
  59. package/documentation/development.md +62 -0
  60. package/documentation/extensions.md +160 -0
  61. package/documentation/getting-started.md +63 -0
  62. package/documentation/goals.md +41 -0
  63. package/documentation/index.md +41 -0
  64. package/documentation/observability.md +70 -0
  65. package/documentation/permissions.md +66 -0
  66. package/documentation/providers.md +78 -0
  67. package/documentation/sessions.md +92 -0
  68. package/documentation/skills.md +57 -0
  69. package/documentation/tools.md +94 -0
  70. package/documentation/troubleshooting.md +54 -0
  71. package/examples/extensions/01-audit-gate.js +24 -0
  72. package/examples/extensions/02-notes-tool.js +32 -0
  73. package/examples/extensions/03-custom-command.js +32 -0
  74. package/package.json +6 -2
@@ -1,3 +1,4 @@
1
+ import { truncateHead } from "../tools/shared.js";
1
2
  // Safety net for custom executors (built-in tools already cap: read 64KB
2
3
  // head + truncation note + overflow pointer ≈ 66KB, bash 8KB, webfetch 64KB
3
4
  // + notes). The cap sits at 128KB so legitimate built-in outputs (overflow
@@ -27,8 +28,8 @@ export function normalizeToolResult(result) {
27
28
  }
28
29
  }
29
30
  if (text.length > TOOL_RESULT_CAP_CHARS) {
30
- return (text.slice(0, TOOL_RESULT_CAP_CHARS) +
31
- `\n[truncated: tool result exceeded ${TOOL_RESULT_CAP_CHARS} chars]`);
31
+ const t = truncateHead(text, TOOL_RESULT_CAP_CHARS, `\n[truncated: tool result exceeded ${TOOL_RESULT_CAP_CHARS} chars]`);
32
+ return t.head + t.note;
32
33
  }
33
34
  return text;
34
35
  }
@@ -92,6 +93,10 @@ export function normalizeChatResult(raw) {
92
93
  result["usage"] = m["usage"];
93
94
  if (m["reasoning"] !== undefined)
94
95
  result["reasoning"] = m["reasoning"];
96
+ // Length-truncation flag survives normalization (no calls or not — the
97
+ // loop decides; truncated-without-calls behaves as before).
98
+ if (m["truncated"] === true)
99
+ result.truncated = true;
95
100
  return { result: result, warnings };
96
101
  }
97
102
  if (!Array.isArray(callsRaw)) {
@@ -140,5 +145,7 @@ export function normalizeChatResult(raw) {
140
145
  out["usage"] = m["usage"];
141
146
  if (m["reasoning"] !== undefined)
142
147
  out["reasoning"] = m["reasoning"];
148
+ if (m["truncated"] === true)
149
+ out.truncated = true;
143
150
  return { result: out, warnings };
144
151
  }
package/dist/cli.js CHANGED
@@ -4,8 +4,13 @@ import { render } from "ink";
4
4
  import { App } from "./App.js";
5
5
  import { DEFAULT_ENDPOINT, DEFAULT_MODEL, endpointConfig, } from "./zen.js";
6
6
  import { loadAuth, resolveApiKey } from "./auth.js";
7
+ import { parseExtensionFlags } from "./extensions.js";
7
8
  import { writeTelemetryDashboard } from "./telemetry-dashboard.js";
8
9
  const args = process.argv.slice(2);
10
+ // Extension trust lockdown (ticket 07): --no-extensions (--lockdown alias)
11
+ // boots with zero third-party extensions; --enable/--disable-extension take
12
+ // repeatable `*`/`?` patterns over extension names (CLI wins over atom.json).
13
+ const extFlags = parseExtensionFlags(args);
9
14
  if (args.includes("--dashboard")) {
10
15
  // Local observability dashboard without starting the TUI: render every
11
16
  // stored session to ~/.atom/telemetry/dashboard.html and print the path.
@@ -54,15 +59,18 @@ else if (args.includes("--help") || args.includes("-h")) {
54
59
  Usage: npm start
55
60
  Flags: --dashboard (write ~/.atom/telemetry/dashboard.html and exit)
56
61
  --serve [--port <n>] (serve the live dashboard webUI on loopback and keep running)
62
+ --no-extensions (--lockdown alias: boot with zero third-party extensions; builtins unchanged)
63
+ --enable-extension <glob> (repeatable; only matching extensions load)
64
+ --disable-extension <glob> (repeatable; wins over --enable-extension)
57
65
  Env:
58
66
  KILO_API_KEY optional (Kilo free models work anonymously; get a key at https://kilo.ai) — env wins over ~/.atom/auth.json
59
67
  OPENCODE_ZEN_API_KEY optional when ~/.atom/auth.json has a zen key (get one at https://opencode.ai/auth)
60
68
  OPENAI_API_KEY / ANTHROPIC_API_KEY / DEEPSEEK_API_KEY / MISTRAL_API_KEY / GEMINI_API_KEY (GOOGLE_API_KEY alias) optional per provider (env wins over stored)
61
69
  OPENCODE_ZEN_MODEL optional (default: ${DEFAULT_MODEL}; when set, wins over the saved /model)
62
70
  OPENCODE_ZEN_ENDPOINT optional (default: ${DEFAULT_ENDPOINT})
63
- Commands: /model (model picker) | /models [refresh] (local discovery refresh; Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /tools | /skills (list installed skills) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /autoscroll on|off (follow new output) | /thinking (toggle reasoning visibility) | /mode | /clear | /resume (restore last saved session) | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan
71
+ Commands: /model (model picker) | /models [refresh] (local discovery refresh; Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /goal <objective> (pin one session objective; bare shows it, pause/resume/clear manage it) | /compact [focus] (summarize older turns) | /tools | /skills (list installed skills) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /autoscroll (toggle follow new output) | /thinking (toggle reasoning visibility) | /mode | /trust | /allow | /deny | /rules | /clear | /new (fresh conversation, previous kept) | /rename <name> (rename current session) | /session (switch session picker) | /resume (restore last saved session) | /telemetry | /dashboard | /rewind | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan → normal (extension slash commands appear in the / menu and palette, not in this static list)
64
72
  Providers: kilo (default; anonymous free models, key optional)/opencode-zen/openai/anthropic/deepseek/mistral/google-gemini/openai-compatible (keys in ~/.atom/auth.json, 0600 POSIX; use /provider to paste one) + local auto-discovery: ollama (:11434), lmstudio (:1234), llamacpp (:8080) — no keys needed, overrides via ATOM_OLLAMA_URL/ATOM_LMSTUDIO_URL/ATOM_LLAMACPP_URL.
65
- Note: reasoning_effort is sent only for opencode-zen supported models.`);
73
+ Effort (Auto/Low/Medium/High/Max) applies on every provider: reasoning_effort for OpenAI-chat kinds, thinking budgets for Anthropic, thinking levels for Gemini. Auto omits the knob.`);
66
74
  process.exit(0);
67
75
  }
68
76
  const { endpoint, apiKey: envKey } = endpointConfig();
@@ -86,5 +94,19 @@ const envModel = process.env.OPENCODE_ZEN_MODEL?.trim() || undefined;
86
94
  // --serve parks above (the server holds the event loop), so the TUI must
87
95
  // never start alongside it: serve is a standalone mode like --dashboard.
88
96
  if (!args.includes("--serve")) {
89
- render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: envModel, restorePrefs: true }));
97
+ // Production frame policy (Ink 7.1.1):
98
+ // - incrementalRendering: only changed terminal lines rewrite per frame.
99
+ // Streaming paints touch the live tail + status bar, not the scrollback,
100
+ // so this cuts flicker and stdout bytes on every token flush.
101
+ // Escape hatch: ATOM_INCREMENTAL=0 restores full-frame rendering.
102
+ // - maxFps: 30 keeps keystroke-to-paint latency low; token paints already
103
+ // coalesce to ~15fps via DRAFT_THROTTLE_MS, so Ink never does extra work.
104
+ // - concurrent: enables React concurrent features (useTransition /
105
+ // useDeferredValue) for future deferral of expensive subtrees.
106
+ // Tests are unaffected: they render via ink-testing-library, not here.
107
+ render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: envModel, restorePrefs: true, extensionsLockdown: extFlags.lockdown, enableExtensions: extFlags.enable, disableExtensions: extFlags.disable }), {
108
+ incrementalRendering: process.env.ATOM_INCREMENTAL !== "0",
109
+ maxFps: 30,
110
+ concurrent: true,
111
+ });
90
112
  }
package/dist/compact.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // by the summary; manual `/compact [focus]`; thrashing guard.
9
9
  // - opencode V2: preflight estimate = JSON-serialized request size at
10
10
  // 4 chars/token; summary via session model with TOOLS DISABLED, ≤4096
11
- // output tokens, structured template; newest tail retained (~8000 tokens,
11
+ // output tokens, structured template; newest tail retained (~20000 tokens,
12
12
  // tool outputs capped 2000 chars); overflow-recovery retry once.
13
13
  //
14
14
  // This module is pure + testable (mocked fetch only in tests, never live).
@@ -20,9 +20,10 @@ import { chatCompletionForProvider, } from "./zen.js";
20
20
  // ContextManager module; compact.ts imports what its splitter needs and
21
21
  // re-exports the stable surface so existing importers keep working untouched.
22
22
  import { estimateTokensForChars, messageChars } from "./context-manager.js";
23
+ import { truncateHead } from "./tools/shared.js";
23
24
  export { COMPACT_PCT_DEFAULT, compactPct, computeContextLoad, estimateTokensForChars, historyChars, shouldAutoCompact, } from "./context-manager.js";
24
25
  // ---- Constants ----
25
- export const COMPACT_KEEP_TOKENS = 8000;
26
+ export const COMPACT_KEEP_TOKENS = 20000;
26
27
  export const COMPACT_SUMMARY_MAX_TOKENS = 4096;
27
28
  export const COMPACT_TOOL_OUTPUT_CAP = 2000;
28
29
  // opencode's 4ch/token heuristic (V2 preflight estimate): chars/4 floors to
@@ -62,10 +63,12 @@ function turnChars(history, start, end) {
62
63
  export function capToolOutputsInTail(tail) {
63
64
  return tail.map((m) => {
64
65
  if (m.role === "tool" && typeof m.content === "string" && m.content.length > COMPACT_TOOL_OUTPUT_CAP) {
66
+ // Line-aware cap (issue 04): the retained head never ends mid-line;
67
+ // cap value and legacy note prefix are unchanged.
68
+ const t = truncateHead(m.content, COMPACT_TOOL_OUTPUT_CAP, "\n[truncated: tool output exceeded 2000 chars]");
65
69
  return {
66
70
  ...m,
67
- content: m.content.slice(0, COMPACT_TOOL_OUTPUT_CAP) +
68
- "\n[truncated: tool output exceeded 2000 chars]",
71
+ content: t.head + t.note,
69
72
  };
70
73
  }
71
74
  return { ...m };
@@ -100,7 +103,7 @@ export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOK
100
103
  // Everything fits but >1 turn: keep only the newest turn in the tail so
101
104
  // manual /compact still has an older turn to summarize (auto never
102
105
  // reaches here — its load would be far below threshold when everything
103
- // fits in 8000 tokens).
106
+ // fits in 20000 tokens).
104
107
  if (tailStart === starts[0] && starts.length > 1) {
105
108
  tailStart = starts[starts.length - 1];
106
109
  }
@@ -114,28 +117,38 @@ export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOK
114
117
  return { head, tail, olderTurnCount };
115
118
  }
116
119
  // ---- Instruction template ----
117
- export function buildCompactionInstruction(focusText) {
120
+ // goalObjective (ticket 08) is a prompt hint only: when a session goal is
121
+ // live, the summarizer is told to preserve goal-relevant progress, evidence,
122
+ // and next steps inside its prose (the canonical `Goal:` block is appended
123
+ // separately after the POST). Absent/blank reads exactly as before, so
124
+ // non-goal compaction output stays byte-identical.
125
+ export function buildCompactionInstruction(focusText, goalObjective) {
118
126
  const focus = typeof focusText === "string" && focusText.trim().length > 0
119
127
  ? `\nFocus for this summary: ${focusText.trim()}\n`
120
128
  : "";
129
+ const goal = typeof goalObjective === "string" && goalObjective.trim().length > 0
130
+ ? `\nSession goal to preserve: "${goalObjective.trim()}" — keep goal-relevant progress, ` +
131
+ `evidence, and next steps for it in the summary so the next turn can continue it ` +
132
+ `without re-exploring.\n`
133
+ : "";
121
134
  return (`Summarize the conversation so far for context compaction. Be concise but preserve all information needed to continue the work without re-reading the full history.` +
122
- `${focus}\n` +
135
+ `${focus}${goal}\n` +
123
136
  `Structure your summary with these headings (omit a section only when it has no content):\n` +
124
137
  `## Objective\n` +
125
- `## Requirements\n` +
126
- `## Decisions\n` +
127
- `## Completed work\n` +
128
- `## Active work\n` +
129
- `## Blockers\n` +
130
- `## Next moves\n` +
131
- `## Relevant files\n` +
138
+ `## Important Details\n` +
139
+ `## Work State\n` +
140
+ `### Completed\n` +
141
+ `### Active\n` +
142
+ `### Blocked\n` +
143
+ `## Next Move\n` +
144
+ `## Relevant Files\n` +
132
145
  `Rules: no tools are available for this request — answer with the summary text only, no tool calls, no preamble beyond the headings.`);
133
146
  }
134
- export function buildSummaryMessages(systemContent, head, focusText) {
147
+ export function buildSummaryMessages(systemContent, head, focusText, goalObjective) {
135
148
  return [
136
149
  { role: "system", content: systemContent },
137
150
  ...head.map((m) => ({ ...m })),
138
- { role: "user", content: buildCompactionInstruction(focusText) },
151
+ { role: "user", content: buildCompactionInstruction(focusText, goalObjective) },
139
152
  ];
140
153
  }
141
154
  export function buildCompactedHistory(systemMessage, summaryText, tail, olderTurnCount, nowISO) {
@@ -190,7 +203,7 @@ export function truncateHeadForRetry(head) {
190
203
  // throw immediately with history untouched (caller must not swap).
191
204
  export async function requestCompactSummary(req) {
192
205
  const attempt = async (head) => {
193
- const messages = buildSummaryMessages(req.systemContent, head, req.focusText);
206
+ const messages = buildSummaryMessages(req.systemContent, head, req.focusText, req.goalObjective);
194
207
  const res = await chatCompletionForProvider(req.provider, req.apiKey, req.model, messages, {
195
208
  baseURL: req.baseURL,
196
209
  endpointOverride: req.endpointOverride,
@@ -233,3 +246,142 @@ export async function requestCompactSummary(req) {
233
246
  }
234
247
  }
235
248
  }
249
+ function pushUniquePath(list, p) {
250
+ if (p.length === 0 || list.includes(p))
251
+ return;
252
+ list.push(p);
253
+ }
254
+ // Collect read/modified paths from committed tool_calls in head (insertion
255
+ // order, unique). Unparseable arguments are skipped — a bad payload must
256
+ // never break compaction. A path both read and written lands in modified
257
+ // only (the write implies the read).
258
+ export function collectTouchedFiles(head) {
259
+ const read = [];
260
+ const modified = [];
261
+ for (const m of head) {
262
+ if (m?.role !== "assistant")
263
+ continue;
264
+ const calls = m.tool_calls;
265
+ if (!Array.isArray(calls))
266
+ continue;
267
+ for (const c of calls) {
268
+ const fn = c?.function;
269
+ if (typeof fn?.name !== "string")
270
+ continue;
271
+ let p = "";
272
+ try {
273
+ const args = typeof fn.arguments === "string" ? JSON.parse(fn.arguments) : null;
274
+ const raw = args?.path;
275
+ if (typeof raw === "string")
276
+ p = raw.trim();
277
+ }
278
+ catch {
279
+ continue; // unparseable args pin nothing
280
+ }
281
+ if (p.length === 0)
282
+ continue;
283
+ if (fn.name === "write" || fn.name === "edit")
284
+ pushUniquePath(modified, p);
285
+ else if (fn.name === "read")
286
+ pushUniquePath(read, p);
287
+ }
288
+ }
289
+ // Modified implies read: keep modified entries out of the read list.
290
+ const modifiedSet = new Set(modified);
291
+ return { read: read.filter((p) => !modifiedSet.has(p)), modified };
292
+ }
293
+ // Canonical on-disk + on-resume format. Empty sections are omitted; both
294
+ // empty renders "" (the caller then appends nothing).
295
+ export function formatTouchedFiles(t) {
296
+ const lines = ["Touched files:"];
297
+ if (t.read.length > 0)
298
+ lines.push(`Read: ${t.read.join(", ")}`);
299
+ if (t.modified.length > 0)
300
+ lines.push(`Modified: ${t.modified.join(", ")}`);
301
+ return lines.length > 1 ? lines.join("\n") : "";
302
+ }
303
+ export function appendTouchedFiles(summaryText, touched) {
304
+ const block = formatTouchedFiles(touched);
305
+ if (!block)
306
+ return summaryText;
307
+ return `${summaryText}\n\n${block}`;
308
+ }
309
+ // ---- Size budget for the summary message ----
310
+ // The summary text itself is never cut — only the file lists shrink to fit,
311
+ // so an over-budget summary degrades gracefully instead of failing
312
+ // compaction. Budget = the summary output cap via the 4ch/token estimator.
313
+ export const COMPACT_SUMMARY_MAX_CHARS = COMPACT_SUMMARY_MAX_TOKENS * COMPACT_CHARS_PER_TOKEN;
314
+ // Shrink the lists until the formatted block fits maxChars. Drops the oldest
315
+ // entry from the longer list (ties: read first — modifications are the
316
+ // higher-signal list). Never throws; an empty result formats to "".
317
+ export function truncateTouchedFiles(touched, maxChars) {
318
+ const read = [...touched.read];
319
+ const modified = [...touched.modified];
320
+ while ((read.length > 0 || modified.length > 0) &&
321
+ formatTouchedFiles({ read, modified }).length > maxChars) {
322
+ if (read.length >= modified.length)
323
+ read.shift();
324
+ else
325
+ modified.shift();
326
+ }
327
+ return { read, modified };
328
+ }
329
+ // Append file lists to the summary within budget: shrink the lists (never
330
+ // the model text) until summary + block fits; when nothing fits, the summary
331
+ // stands alone and compaction still succeeds.
332
+ export function fitSummaryWithFiles(summaryText, touched, maxChars = COMPACT_SUMMARY_MAX_CHARS) {
333
+ const before = touched.read.length + touched.modified.length;
334
+ const block = formatTouchedFiles(touched);
335
+ if (!block)
336
+ return { text: summaryText, truncated: false };
337
+ if (summaryText.length + 2 + block.length <= maxChars) {
338
+ return { text: `${summaryText}\n\n${block}`, truncated: false };
339
+ }
340
+ const room = Math.max(0, maxChars - summaryText.length - 2);
341
+ const shrunk = truncateTouchedFiles(touched, room);
342
+ const shrunkBlock = formatTouchedFiles(shrunk);
343
+ const truncated = shrunk.read.length + shrunk.modified.length < before;
344
+ if (!shrunkBlock)
345
+ return { text: summaryText, truncated };
346
+ return { text: `${summaryText}\n\n${shrunkBlock}`, truncated };
347
+ }
348
+ // ---- Goal block fitting (ticket 08) ----
349
+ // Append the canonical `Goal:` block plus the touched-files lists within one
350
+ // shared budget: the goal block rides with the model text (never shrunk — it
351
+ // is one short line plus a capped checklist tail, see formatGoalForCompact),
352
+ // so only the touched-files lists shrink via the same rule above. The goal
353
+ // block lands BEFORE the files block, keeping `Touched files:` last so its
354
+ // verbatim extractor (lastIndexOf below) still finds the appended block. An
355
+ // empty goal block degrades exactly to fitSummaryWithFiles (non-goal output
356
+ // byte-identical); an over-budget summary still stands alone and compaction
357
+ // still succeeds.
358
+ export function fitSummaryWithFilesAndGoal(summaryText, touched, goalBlock, maxChars = COMPACT_SUMMARY_MAX_CHARS) {
359
+ const base = typeof goalBlock === "string" && goalBlock.length > 0
360
+ ? `${summaryText}\n\n${goalBlock}`
361
+ : summaryText;
362
+ return fitSummaryWithFiles(base, touched, maxChars);
363
+ }
364
+ // ---- Resume surfacing ----
365
+ // Pull the stored block(s) verbatim out of compacted summary messages — the
366
+ // same format as stored, no reformatting. lastIndexOf prefers the appended
367
+ // block (ours is always last; model prose comes first).
368
+ export function extractTouchedFilesSection(text) {
369
+ const idx = text.lastIndexOf("Touched files:");
370
+ if (idx < 0)
371
+ return null;
372
+ const section = text.slice(idx).trimEnd();
373
+ return section.length > 0 ? section : null;
374
+ }
375
+ export function collectStoredTouchedFiles(history) {
376
+ const out = [];
377
+ for (const m of history) {
378
+ if (m?.role !== "user" || typeof m.content !== "string")
379
+ continue;
380
+ if (!m.content.includes("[Compacted context"))
381
+ continue;
382
+ const section = extractTouchedFilesSection(m.content);
383
+ if (section)
384
+ out.push(section);
385
+ }
386
+ return out;
387
+ }
package/dist/config.js CHANGED
@@ -19,13 +19,15 @@
19
19
  // Keys:
20
20
  // - provider: ProviderId for first-run default (needs its key, else zen)
21
21
  // - model: default model id (non-empty string)
22
- // - reasoningEffort: default/low/medium/high/max
23
- // - maxHistoryMessages: 10–1000 (message-count safety ceiling)
24
- // - maxHistoryChars: 10_000–2_000_000 (char safety ceiling — caps the
25
- // window-derived budget, never the primary limit)
22
+ // - reasoningEffort: auto/low/medium/high/max ("default" is accepted as a
23
+ // deprecated alias for "auto")
26
24
  // - maxToolSteps: 5–100 (tool rounds per turn)
27
25
  // - compactPct: 50–95 (auto-compact percent of verified window)
28
26
  // - telemetry: {enabled?: boolean} (local observability recording, default on)
27
+ // - extensions: {enabled?: string[], disabled?: string[]} (per-extension
28
+ // enable/disable patterns over the extension name, `*`/`?` globs; disabled
29
+ // wins over enabled, non-empty enabled is an allowlist — see extensions.ts
30
+ // precedence. CLI --enable/--disable-extension wins over this when set.)
29
31
  import { existsSync, readFileSync } from "node:fs";
30
32
  import * as path from "node:path";
31
33
  import { homeDir } from "./auth.js";
@@ -35,7 +37,11 @@ export const ATOM_CONFIG_FILENAME = "atom.json";
35
37
  // Kept local (not imported from zen.js) so config.ts has no runtime import
36
38
  // of zen.js — zen.js imports loadAtomConfig for budget fallbacks, and a
37
39
  // runtime cycle would be fragile. Mirrors EFFORT_OPTIONS exactly.
38
- const EFFORT_VALUES = ["default", "low", "medium", "high", "max"];
40
+ const EFFORT_VALUES = ["auto", "low", "medium", "high", "max"];
41
+ // Pre-auto name for the same level (old atom.json files keep working).
42
+ const LEGACY_EFFORT_VALUES = {
43
+ default: "auto",
44
+ };
39
45
  export function projectConfigPath(projectDir) {
40
46
  return path.join(projectDir ?? process.cwd(), ATOM_CONFIG_FILENAME);
41
47
  }
@@ -106,13 +112,14 @@ function parseLevel(filePath, label) {
106
112
  if (typeof effort === "string" && EFFORT_VALUES.includes(effort)) {
107
113
  config.reasoningEffort = effort;
108
114
  }
115
+ else if (typeof effort === "string" && effort in LEGACY_EFFORT_VALUES) {
116
+ config.reasoningEffort = LEGACY_EFFORT_VALUES[effort];
117
+ }
109
118
  else {
110
119
  bad("reasoningEffort", `must be one of ${EFFORT_VALUES.join("/")}`);
111
120
  }
112
121
  }
113
122
  const ranged = [
114
- { key: "maxHistoryMessages", min: 10, max: 1000 },
115
- { key: "maxHistoryChars", min: 10_000, max: 2_000_000 },
116
123
  { key: "maxToolSteps", min: 5, max: 100 },
117
124
  { key: "compactPct", min: 50, max: 95 },
118
125
  ];
@@ -158,6 +165,35 @@ function parseLevel(filePath, label) {
158
165
  }
159
166
  }
160
167
  }
168
+ // Per-extension patterns (ticket 07): validated arrays of non-empty
169
+ // strings; a non-array key is ignored wholesale, bad entries are dropped
170
+ // with a warning (never throw, like every other key here).
171
+ const extensions = data["extensions"];
172
+ if (extensions !== undefined) {
173
+ if (!isRecord(extensions)) {
174
+ warnings.push(`${label} atom.json: ignoring invalid "extensions" (must be an object)`);
175
+ }
176
+ else {
177
+ const parsed = {};
178
+ for (const key of ["enabled", "disabled"]) {
179
+ const v = extensions[key];
180
+ if (v === undefined)
181
+ continue;
182
+ if (!Array.isArray(v)) {
183
+ warnings.push(`${label} atom.json: ignoring invalid "extensions.${key}" (must be an array of patterns)`);
184
+ continue;
185
+ }
186
+ const kept = v.filter((e) => typeof e === "string" && e.length > 0);
187
+ if (kept.length !== v.length) {
188
+ warnings.push(`${label} atom.json: "extensions.${key}" dropped ${v.length - kept.length} empty/non-string pattern(s)`);
189
+ }
190
+ parsed[key] = kept;
191
+ }
192
+ if (parsed.enabled !== undefined || parsed.disabled !== undefined) {
193
+ config.extensions = parsed;
194
+ }
195
+ }
196
+ }
161
197
  return { config, warnings, present: true };
162
198
  }
163
199
  export function loadAtomConfig(projectDir, homeDir) {