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.
- package/CHANGELOG.md +106 -0
- package/README.md +18 -8
- package/atom.example.json +11 -0
- package/dist/App.js +1637 -255
- package/dist/adapters.js +112 -21
- package/dist/agent/gates.js +14 -1
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop-guard.js +11 -13
- package/dist/agent/loop.js +716 -132
- package/dist/agent/normalize.js +9 -2
- package/dist/cli.js +25 -3
- package/dist/compact.js +169 -17
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +46 -8
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +159 -41
- package/dist/session.js +23 -5
- package/dist/sessions.js +543 -0
- package/dist/system.js +89 -13
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/dir-cache.js +7 -0
- package/dist/tools/filesystem.js +3 -2
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +247 -17
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +119 -58
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +7 -5
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +45 -0
- package/dist/ui/diff-view.js +7 -2
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/palette.js +3 -1
- package/dist/ui/side-by-side.js +2 -2
- package/dist/ui/status-bar.js +80 -5
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +92 -38
- package/dist/zen.js +370 -87
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- package/package.json +6 -2
package/dist/agent/normalize.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (~
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
`##
|
|
126
|
-
`##
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
`## Next
|
|
131
|
-
`## Relevant
|
|
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:
|
|
23
|
-
//
|
|
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 = ["
|
|
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) {
|