atom-agent 1.0.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 +31 -2
- package/README.md +12 -12
- package/dist/App.js +297 -23
- package/dist/adapters.js +84 -6
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +609 -300
- package/dist/agent/normalize.js +144 -0
- package/dist/cli.js +1 -1
- package/dist/system.js +1 -0
- package/dist/telemetry-dashboard.js +19 -1
- package/dist/telemetry.js +55 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +40 -1
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +79 -0
- package/dist/tools/search.js +66 -60
- package/dist/tools/shell.js +19 -0
- package/dist/tools/todo.js +1 -1
- package/dist/tools.js +2 -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/highlight.js +120 -0
- package/dist/ui/live-tail.js +2 -2
- package/dist/ui/modals.js +22 -5
- package/dist/ui/palette.js +10 -2
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +20 -4
- package/dist/ui/theme.js +6 -0
- package/dist/ui/todo-panel.js +10 -2
- package/dist/ui/transcript.js +16 -4
- package/dist/zen.js +33 -9
- package/package.json +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Safety net for custom executors (built-in tools already cap: read 64KB
|
|
2
|
+
// head + truncation note + overflow pointer ≈ 66KB, bash 8KB, webfetch 64KB
|
|
3
|
+
// + notes). The cap sits at 128KB so legitimate built-in outputs (overflow
|
|
4
|
+
// pointers included) pass through byte-identical; only oversized custom
|
|
5
|
+
// results truncate.
|
|
6
|
+
export const TOOL_RESULT_CAP_CHARS = 128 * 1024;
|
|
7
|
+
export function normalizeToolResult(result) {
|
|
8
|
+
let text;
|
|
9
|
+
if (typeof result === "string") {
|
|
10
|
+
text = result;
|
|
11
|
+
}
|
|
12
|
+
else if (result === null || result === undefined) {
|
|
13
|
+
return "Error: tool returned no result";
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
try {
|
|
17
|
+
const json = JSON.stringify(result);
|
|
18
|
+
text = typeof json === "string" ? json : String(result);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
try {
|
|
22
|
+
text = String(result);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return "Error: tool returned an unreadable result";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
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]`);
|
|
32
|
+
}
|
|
33
|
+
return text;
|
|
34
|
+
}
|
|
35
|
+
// Recursively key-sorted JSON for stable signatures. Falls back to a short
|
|
36
|
+
// type tag when unstringifiable (never throws, never aliases objects with
|
|
37
|
+
// strings: prefixes the tag).
|
|
38
|
+
function stableStringify(value) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.stringify(sortKeys(value)) ?? "undefined";
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return `unstringifiable:${typeof value}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function sortKeys(value) {
|
|
47
|
+
if (Array.isArray(value))
|
|
48
|
+
return value.map(sortKeys);
|
|
49
|
+
if (typeof value === "object" && value !== null) {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const k of Object.keys(value).sort()) {
|
|
52
|
+
out[k] = sortKeys(value[k]);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
// Stable repetition/cache key: `name` + canonical args. Parsed args come
|
|
59
|
+
// from JSON.parse (insertion-ordered), so sorting closes the alias where
|
|
60
|
+
// `{"a":1,"b":2}` and `{"b":2,"a":1}` would otherwise count as different.
|
|
61
|
+
export function toolSignature(name, parsed) {
|
|
62
|
+
return `${name} ${stableStringify(parsed ?? {})}`;
|
|
63
|
+
}
|
|
64
|
+
// Defensive validation of one assistant message. Never throws: malformed
|
|
65
|
+
// tool_calls entries are dropped with a warning (the caller surfaces them
|
|
66
|
+
// via onWarning so the transcript shows what the model attempted); a fully
|
|
67
|
+
// unusable message becomes empty final text (the loop's turn-end gates then
|
|
68
|
+
// decide, exactly as if the model sent empty content).
|
|
69
|
+
export function normalizeChatResult(raw) {
|
|
70
|
+
const warnings = [];
|
|
71
|
+
if (typeof raw !== "object" || raw === null) {
|
|
72
|
+
return { result: { content: null }, warnings: ["model returned a non-object message"] };
|
|
73
|
+
}
|
|
74
|
+
const m = raw;
|
|
75
|
+
const contentRaw = m["content"];
|
|
76
|
+
const content = typeof contentRaw === "string"
|
|
77
|
+
? contentRaw
|
|
78
|
+
: contentRaw === null || contentRaw === undefined
|
|
79
|
+
? null
|
|
80
|
+
: (() => {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.stringify(contentRaw);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return String(contentRaw);
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
const callsRaw = m["calls"] ?? m["tool_calls"];
|
|
89
|
+
if (callsRaw === undefined) {
|
|
90
|
+
const result = { content };
|
|
91
|
+
if (m["usage"] !== undefined)
|
|
92
|
+
result["usage"] = m["usage"];
|
|
93
|
+
if (m["reasoning"] !== undefined)
|
|
94
|
+
result["reasoning"] = m["reasoning"];
|
|
95
|
+
return { result: result, warnings };
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(callsRaw)) {
|
|
98
|
+
warnings.push("model tool_calls was not an array — ignored");
|
|
99
|
+
return { result: { content, tool_calls: undefined }, warnings };
|
|
100
|
+
}
|
|
101
|
+
const calls = [];
|
|
102
|
+
for (let i = 0; i < callsRaw.length; i++) {
|
|
103
|
+
const entry = callsRaw[i];
|
|
104
|
+
if (typeof entry !== "object" || entry === null) {
|
|
105
|
+
warnings.push(`dropped malformed tool call at index ${i} (not an object)`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const fn = entry["function"];
|
|
109
|
+
const name = fn?.["name"];
|
|
110
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
111
|
+
const id = typeof entry["id"] === "string" ? entry["id"] : `#${i}`;
|
|
112
|
+
warnings.push(`dropped tool call ${id} with no function name`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const id = typeof entry["id"] === "string" && entry["id"].length > 0
|
|
116
|
+
? entry["id"]
|
|
117
|
+
: `call-${i}`;
|
|
118
|
+
const argsRaw = fn?.["arguments"];
|
|
119
|
+
let args;
|
|
120
|
+
if (typeof argsRaw === "string")
|
|
121
|
+
args = argsRaw;
|
|
122
|
+
else if (argsRaw === undefined || argsRaw === null)
|
|
123
|
+
args = "{}";
|
|
124
|
+
else {
|
|
125
|
+
try {
|
|
126
|
+
args = JSON.stringify(argsRaw) ?? "{}";
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
warnings.push(`dropped tool call ${id} with unstringifiable arguments`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const call = { id, function: { name, arguments: args } };
|
|
134
|
+
if (typeof entry["type"] === "string")
|
|
135
|
+
call.type = entry["type"];
|
|
136
|
+
calls.push(call);
|
|
137
|
+
}
|
|
138
|
+
const out = { content, tool_calls: calls.length > 0 ? calls : undefined };
|
|
139
|
+
if (m["usage"] !== undefined)
|
|
140
|
+
out["usage"] = m["usage"];
|
|
141
|
+
if (m["reasoning"] !== undefined)
|
|
142
|
+
out["reasoning"] = m["reasoning"];
|
|
143
|
+
return { result: out, warnings };
|
|
144
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -60,7 +60,7 @@ Env:
|
|
|
60
60
|
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
61
|
OPENCODE_ZEN_MODEL optional (default: ${DEFAULT_MODEL}; when set, wins over the saved /model)
|
|
62
62
|
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) | /mode | /clear | /resume (restore last saved session) | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan
|
|
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
|
|
64
64
|
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
65
|
Note: reasoning_effort is sent only for opencode-zen supported models.`);
|
|
66
66
|
process.exit(0);
|
package/dist/system.js
CHANGED
|
@@ -21,6 +21,7 @@ export const SYSTEM_PROMPT = [
|
|
|
21
21
|
"Prefer the smallest correct change. Fix root causes. Handle errors and edge cases. Remove dead code.",
|
|
22
22
|
"Ground every claim in tool output, never in memory. Run commands to check facts.",
|
|
23
23
|
"After each tool result, reflect briefly, then take the best next action toward the goal.",
|
|
24
|
+
"Batch independent work in one block: parallel-safe reads and searches in the same response run concurrently and finish in a single round-trip — one call per response is the slow path.",
|
|
24
25
|
"Keep calling tools until verified done. Never end on an unverified summary or a guess.",
|
|
25
26
|
"Done means tests and typecheck pass, or the blocker is named with its evidence.",
|
|
26
27
|
"",
|
|
@@ -246,12 +246,28 @@ function iterationTimeline(s) {
|
|
|
246
246
|
function turnBlock(t) {
|
|
247
247
|
const outcomeCls = t.outcome === "completed" ? "ok" : t.outcome === "failed" ? "err" : "warn";
|
|
248
248
|
const total = turnTokensTotal(t);
|
|
249
|
+
// Loop-harness rollup (present only when the loop reported stats for this
|
|
250
|
+
// turn — older sessions render exactly as before).
|
|
251
|
+
const loopBits = [];
|
|
252
|
+
if (t.loop) {
|
|
253
|
+
if (t.loop.cacheHits > 0)
|
|
254
|
+
loopBits.push(`read-cache hits ${fmtCount(t.loop.cacheHits)}`);
|
|
255
|
+
if (t.loop.repetitionHits > 0)
|
|
256
|
+
loopBits.push(`loop-guard hits ${fmtCount(t.loop.repetitionHits)}`);
|
|
257
|
+
if (t.loop.bottleneckName) {
|
|
258
|
+
loopBits.push(`bottleneck ${escapeHtml(t.loop.bottleneckName)}` +
|
|
259
|
+
(t.loop.bottleneckMs !== undefined ? ` ${fmtMs(t.loop.bottleneckMs)}` : ""));
|
|
260
|
+
}
|
|
261
|
+
if (t.loop.truncations > 0)
|
|
262
|
+
loopBits.push(`truncated ${fmtCount(t.loop.truncations)} turn(s)`);
|
|
263
|
+
}
|
|
249
264
|
const head = `<span class="${outcomeCls}">${escapeHtml(t.outcome)}</span>` +
|
|
250
265
|
` · ${fmtMs(t.durationMs)}` +
|
|
251
266
|
` · ${t.modelCalls.length} model call(s)` +
|
|
252
267
|
` · ${t.toolCalls.length} tool call(s)` +
|
|
253
268
|
` · retries ${t.retryCount}` +
|
|
254
|
-
` · tokens ${total !== null ? fmtCount(total) : `<span class="na" title="No model call in this turn reported usage.">n/a</span>`}
|
|
269
|
+
` · tokens ${total !== null ? fmtCount(total) : `<span class="na" title="No model call in this turn reported usage.">n/a</span>`}` +
|
|
270
|
+
(loopBits.length > 0 ? ` · ${loopBits.join(" · ")}` : "");
|
|
255
271
|
return `<details class="turn" data-outcome="${escapeHtml(t.outcome)}">
|
|
256
272
|
<summary><span class="mono">${escapeHtml(t.id)}</span> · ${fmtTime(t.startedAt)} · ${escapeHtml(t.provider)} · ${escapeHtml(t.model)} · ${head}</summary>
|
|
257
273
|
<div class="turnbody">
|
|
@@ -488,6 +504,8 @@ ${corruptNote}
|
|
|
488
504
|
<div class="card"><div class="k">Completion tokens (reported)</div><div class="v">${fmtTokens(agg.usage.completion_tokens, agg.usageReported, "No model call reported completion_tokens.")}</div></div>
|
|
489
505
|
<div class="card"><div class="k">Cache read / write (reported)</div><div class="v">${fmtTokens(agg.usage.cacheReadTokens, agg.usageReported, "No provider reported cache-read counters.")} / ${fmtTokens(agg.usage.cacheWriteTokens, agg.usageReported, "No provider reported cache-write counters.")}</div></div>
|
|
490
506
|
<div class="card"><div class="k">Retries</div><div class="v">${fmtCount(agg.retries)}</div></div>
|
|
507
|
+
<div class="card"><div class="k">Read-cache hits (local)</div><div class="v">${fmtCount(agg.cacheHits)}</div></div>
|
|
508
|
+
<div class="card"><div class="k">Loop-guard hits</div><div class="v">${fmtCount(agg.repetitionHits)}</div></div>
|
|
491
509
|
<div class="card"><div class="k">Avg model latency</div><div class="v">${fmtMs(agg.avgModelLatencyMs)}</div></div>
|
|
492
510
|
<div class="card"><div class="k">Avg tool duration</div><div class="v">${fmtMs(agg.avgToolDurationMs)}</div></div>
|
|
493
511
|
<div class="card"><div class="k">Total cost</div><div class="v"><span class="na" title="${escapeHtml(agg.costNote)}">n/a</span></div></div>
|
package/dist/telemetry.js
CHANGED
|
@@ -436,6 +436,8 @@ export function summarizeTelemetry(sessions) {
|
|
|
436
436
|
compactionUsage: {},
|
|
437
437
|
compactionReported: false,
|
|
438
438
|
retries: 0,
|
|
439
|
+
cacheHits: 0,
|
|
440
|
+
repetitionHits: 0,
|
|
439
441
|
outcomes: emptyOutcomes(),
|
|
440
442
|
byTool: [],
|
|
441
443
|
avgModelLatencyMs: null,
|
|
@@ -465,6 +467,14 @@ export function summarizeTelemetry(sessions) {
|
|
|
465
467
|
agg.usageReported = true;
|
|
466
468
|
}
|
|
467
469
|
agg.retries += typeof t.retryCount === "number" ? t.retryCount : 0;
|
|
470
|
+
if (t.loop) {
|
|
471
|
+
if (typeof t.loop.cacheHits === "number" && t.loop.cacheHits > 0) {
|
|
472
|
+
agg.cacheHits += Math.floor(t.loop.cacheHits);
|
|
473
|
+
}
|
|
474
|
+
if (typeof t.loop.repetitionHits === "number" && t.loop.repetitionHits > 0) {
|
|
475
|
+
agg.repetitionHits += Math.floor(t.loop.repetitionHits);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
468
478
|
if (Array.isArray(t.modelCalls)) {
|
|
469
479
|
for (const m of t.modelCalls) {
|
|
470
480
|
agg.modelCalls += 1;
|
|
@@ -782,6 +792,51 @@ export class TelemetryRecorder {
|
|
|
782
792
|
// never throw
|
|
783
793
|
}
|
|
784
794
|
}
|
|
795
|
+
// Attach the loop-harness per-turn rollup (LoopStats from the agent loop,
|
|
796
|
+
// wired via AgenticOpts.onLoopStats in the App). Merges onto the open turn;
|
|
797
|
+
// later reports overwrite (the loop reports once, but a retry-safe merge
|
|
798
|
+
// keeps the newest). No-op when disabled, unknown turn, or bad input.
|
|
799
|
+
// Never throws. Safe to call before endTurn (success path) — endTurn keeps
|
|
800
|
+
// turn.loop intact; failed/cancelled turns keep it too (what was attempted).
|
|
801
|
+
recordLoopStats(turnId, summary) {
|
|
802
|
+
try {
|
|
803
|
+
if (!this.enabled || !turnId)
|
|
804
|
+
return;
|
|
805
|
+
const turn = this.openTurns.get(turnId);
|
|
806
|
+
if (!turn)
|
|
807
|
+
return;
|
|
808
|
+
if (typeof summary !== "object" || summary === null)
|
|
809
|
+
return;
|
|
810
|
+
const s = summary;
|
|
811
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
|
|
812
|
+
const signed = (v) => typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : undefined;
|
|
813
|
+
const loop = {
|
|
814
|
+
cacheHits: num(s["cacheHits"]) ?? 0,
|
|
815
|
+
repetitionHits: num(s["repetitionHits"]) ?? 0,
|
|
816
|
+
failures: num(s["failures"]) ?? 0,
|
|
817
|
+
truncations: num(s["truncationNotices"] ?? s["truncations"]) ?? 0,
|
|
818
|
+
contextGrowthChars: signed(s["contextGrowthChars"]) ?? 0,
|
|
819
|
+
loopDurationMs: num(s["durationMs"] ?? s["loopDurationMs"]) ?? 0,
|
|
820
|
+
};
|
|
821
|
+
const bottleneck = s["bottleneck"];
|
|
822
|
+
const bName = typeof bottleneck?.["name"] === "string" ? bottleneck["name"] : undefined;
|
|
823
|
+
const bMs = num(bottleneck?.["durationMs"] ?? bottleneck?.["ms"] ?? s["bottleneckMs"]) ?? undefined;
|
|
824
|
+
if (bName && bName.length > 0) {
|
|
825
|
+
loop.bottleneckName = bName.slice(0, 80);
|
|
826
|
+
if (bMs !== undefined)
|
|
827
|
+
loop.bottleneckMs = bMs;
|
|
828
|
+
}
|
|
829
|
+
else if (typeof s["bottleneckName"] === "string" && s["bottleneckName"].length > 0) {
|
|
830
|
+
loop.bottleneckName = s["bottleneckName"].slice(0, 80);
|
|
831
|
+
if (bMs !== undefined)
|
|
832
|
+
loop.bottleneckMs = bMs;
|
|
833
|
+
}
|
|
834
|
+
turn.loop = loop;
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
// never throw
|
|
838
|
+
}
|
|
839
|
+
}
|
|
785
840
|
upsertIteration(turn, step, modelCallId, toolCallId) {
|
|
786
841
|
try {
|
|
787
842
|
let iter = turn.iterations.find((i) => i.step === step);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Fast directory enumeration for search tools (grep/glob).
|
|
2
|
+
//
|
|
3
|
+
// Problem (measured): every grep/glob recursively walked the repo and
|
|
4
|
+
// stated every entry — ~360ms per glob and ~2s per grep on a 3000-file
|
|
5
|
+
// tree, paid on EVERY call. Two fixes, both behavior-preserving:
|
|
6
|
+
//
|
|
7
|
+
// 1. git fast path: `git ls-files` (tracked) + `--others --exclude-standard`
|
|
8
|
+
// (untracked, non-ignored) lists the same tree without recursion and
|
|
9
|
+
// skips ignored build output. Verified: both spellings emit paths
|
|
10
|
+
// relative to the working directory they run in. Untracked source files
|
|
11
|
+
// ARE included (no "new file invisible" regression); SKIP_DIRS segments
|
|
12
|
+
// (node_modules/.git) are filtered after, so the "never searched"
|
|
13
|
+
// contract holds even for tracked junk. Any failure (non-git dir, no git
|
|
14
|
+
// binary, timeout) falls back to the recursive walker byte-identically.
|
|
15
|
+
// 2. mtime-checked listing cache + exact invalidation: a cached listing is
|
|
16
|
+
// reused only while the directory mtime is unchanged, AND every
|
|
17
|
+
// in-process mutation path invalidates (write/edit drop ancestor listings
|
|
18
|
+
// — nested creates don't move the parent mtime, so mtime alone is NOT
|
|
19
|
+
// enough; any bash execution clears all — a command can touch anything).
|
|
20
|
+
// Only out-of-process edits (user's editor between calls) stay TTL-bound
|
|
21
|
+
// (15s), documented and accepted: all tool-driven flows are exact.
|
|
22
|
+
//
|
|
23
|
+
// Deliberate non-goal: no ripgrep binary dependency. Measured `rg --files`
|
|
24
|
+
// spawn alone costs ~80ms on Windows — slower than the walker on typical
|
|
25
|
+
// repos — with regex-dialect and hidden/ignore parity risks. Revisit only if
|
|
26
|
+
// walker numbers stay slow after this (they don't — see benchmarks).
|
|
27
|
+
//
|
|
28
|
+
// Kill switch: ATOM_FAST_LIST=0 forces the legacy walker every time.
|
|
29
|
+
// Best-effort throughout; never throws across the tool boundary.
|
|
30
|
+
import { execFile } from "node:child_process";
|
|
31
|
+
import { promises as fsp } from "node:fs";
|
|
32
|
+
import * as path from "node:path";
|
|
33
|
+
import { SKIP_DIRS } from "./shared.js";
|
|
34
|
+
const LISTING_TTL_MS = 15_000;
|
|
35
|
+
const LISTING_MAX_ENTRIES = 50;
|
|
36
|
+
const GIT_TIMEOUT_MS = 15_000;
|
|
37
|
+
const listingCache = new Map();
|
|
38
|
+
const cacheStats = { hits: 0, misses: 0, stores: 0, gitUses: 0, walkerUses: 0 };
|
|
39
|
+
export function fastListEnabled() {
|
|
40
|
+
const raw = process.env.ATOM_FAST_LIST;
|
|
41
|
+
if (raw === undefined)
|
|
42
|
+
return true;
|
|
43
|
+
const v = raw.trim().toLowerCase();
|
|
44
|
+
return !(v === "0" || v === "false" || v === "no" || v === "off");
|
|
45
|
+
}
|
|
46
|
+
// Recursive walker (legacy contract): cwd-relative posix paths, SKIP_DIRS
|
|
47
|
+
// pruned, files only. Used directly when the fast path is off/unavailable.
|
|
48
|
+
export async function walkFiles(absDir, cwd, out) {
|
|
49
|
+
const entries = await fsp.readdir(absDir, { withFileTypes: true });
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
if (SKIP_DIRS.has(e.name))
|
|
52
|
+
continue;
|
|
53
|
+
const full = path.join(absDir, e.name);
|
|
54
|
+
if (e.isDirectory()) {
|
|
55
|
+
await walkFiles(full, cwd, out);
|
|
56
|
+
}
|
|
57
|
+
else if (e.isFile()) {
|
|
58
|
+
out.push(path.relative(cwd, full).split(path.sep).join("/"));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function gitFile(args, cwd) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
execFile("git", args, { cwd, timeout: GIT_TIMEOUT_MS, windowsHide: true }, (err, stdout) => {
|
|
65
|
+
if (err) {
|
|
66
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
resolve(typeof stdout === "string" ? stdout : String(stdout ?? ""));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function toCwdRel(absDir, cwd, dirRel) {
|
|
74
|
+
const prefix = path.relative(cwd, absDir).split(path.sep).join("/");
|
|
75
|
+
if (!prefix || prefix === ".")
|
|
76
|
+
return dirRel;
|
|
77
|
+
return `${prefix}/${dirRel}`;
|
|
78
|
+
}
|
|
79
|
+
function filterSkipped(relPaths) {
|
|
80
|
+
return relPaths.filter((rel) => {
|
|
81
|
+
if (!rel)
|
|
82
|
+
return false;
|
|
83
|
+
for (const seg of rel.split("/")) {
|
|
84
|
+
if (SKIP_DIRS.has(seg))
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// Git enumeration: tracked + untracked-non-ignored in ONE invocation, as
|
|
91
|
+
// cwd-relative posix paths. Verified: both spellings emit paths relative to
|
|
92
|
+
// the directory git runs in. Outside a repo (or no git binary) the command
|
|
93
|
+
// fails and this returns null — the caller falls back to the walker.
|
|
94
|
+
async function gitListFiles(absDir, cwd) {
|
|
95
|
+
let raw;
|
|
96
|
+
try {
|
|
97
|
+
raw = await gitFile(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], absDir);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const entry of raw.split("\0")) {
|
|
106
|
+
if (!entry)
|
|
107
|
+
continue;
|
|
108
|
+
const rel = toCwdRel(absDir, cwd, entry.split(path.sep).join("/"));
|
|
109
|
+
if (!seen.has(rel)) {
|
|
110
|
+
seen.add(rel);
|
|
111
|
+
out.push(rel);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return filterSkipped(out);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function dirMtimeMs(absDir) {
|
|
121
|
+
try {
|
|
122
|
+
return (await fsp.stat(absDir)).mtimeMs;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// List files under absDir as cwd-relative posix paths (walkFiles contract).
|
|
129
|
+
// Fast path: mtime-validated cache → git enumeration → walker fallback.
|
|
130
|
+
// Walker results cache too (the mtime check is equally valid for them).
|
|
131
|
+
export async function listFiles(absDir, cwd) {
|
|
132
|
+
const key = `${cwd}\n${absDir}`;
|
|
133
|
+
if (fastListEnabled()) {
|
|
134
|
+
const mtime = await dirMtimeMs(absDir);
|
|
135
|
+
const hit = listingCache.get(key);
|
|
136
|
+
if (hit && mtime !== null && hit.mtimeMs === mtime && Date.now() - hit.storedAt < LISTING_TTL_MS) {
|
|
137
|
+
// LRU refresh.
|
|
138
|
+
listingCache.delete(key);
|
|
139
|
+
listingCache.set(key, hit);
|
|
140
|
+
cacheStats.hits += 1;
|
|
141
|
+
return [...hit.entries];
|
|
142
|
+
}
|
|
143
|
+
cacheStats.misses += 1;
|
|
144
|
+
const git = await gitListFiles(absDir, cwd);
|
|
145
|
+
if (git !== null) {
|
|
146
|
+
cacheStats.gitUses += 1;
|
|
147
|
+
storeListing(key, git, mtime);
|
|
148
|
+
return [...git];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
cacheStats.walkerUses += 1;
|
|
152
|
+
const out = [];
|
|
153
|
+
await walkFiles(absDir, cwd, out);
|
|
154
|
+
if (fastListEnabled()) {
|
|
155
|
+
storeListing(key, out, await dirMtimeMs(absDir));
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function storeListing(key, entries, mtimeMs) {
|
|
160
|
+
try {
|
|
161
|
+
listingCache.delete(key);
|
|
162
|
+
while (listingCache.size >= LISTING_MAX_ENTRIES) {
|
|
163
|
+
const oldest = listingCache.keys().next();
|
|
164
|
+
if (oldest.done)
|
|
165
|
+
break;
|
|
166
|
+
listingCache.delete(oldest.value);
|
|
167
|
+
}
|
|
168
|
+
listingCache.set(key, { entries: [...entries], mtimeMs: mtimeMs ?? -1, storedAt: Date.now() });
|
|
169
|
+
cacheStats.stores += 1;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// cache failures never break search
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export function clearDirListingCache() {
|
|
176
|
+
listingCache.clear();
|
|
177
|
+
}
|
|
178
|
+
// Drop every listing whose directory is the file itself or an ancestor of it
|
|
179
|
+
// (a mutation inside the tree can change the listing). Called by write/edit
|
|
180
|
+
// next to the read-cache invalidation. Never throws.
|
|
181
|
+
export function invalidateListingsForFile(absFilePath) {
|
|
182
|
+
try {
|
|
183
|
+
if (typeof absFilePath !== "string" || absFilePath.length === 0)
|
|
184
|
+
return;
|
|
185
|
+
const target = path.resolve(absFilePath);
|
|
186
|
+
for (const key of [...listingCache.keys()]) {
|
|
187
|
+
const splitAt = key.indexOf("\n");
|
|
188
|
+
const absDir = splitAt >= 0 ? key.slice(splitAt + 1) : key;
|
|
189
|
+
if (target === absDir || target.startsWith(absDir + path.sep)) {
|
|
190
|
+
listingCache.delete(key);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// never throw across the tool boundary
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export function getDirListingStats() {
|
|
199
|
+
return { ...cacheStats, size: listingCache.size };
|
|
200
|
+
}
|
|
201
|
+
export function resetDirListingStats() {
|
|
202
|
+
cacheStats.hits = 0;
|
|
203
|
+
cacheStats.misses = 0;
|
|
204
|
+
cacheStats.stores = 0;
|
|
205
|
+
cacheStats.gitUses = 0;
|
|
206
|
+
cacheStats.walkerUses = 0;
|
|
207
|
+
}
|
package/dist/tools/filesystem.js
CHANGED
|
@@ -5,6 +5,8 @@ import * as path from "node:path";
|
|
|
5
5
|
import { capturePriorBytes } from "../snapshots.js";
|
|
6
6
|
import { contentHash, fingerprintKey, readFingerprints } from "./fingerprints.js";
|
|
7
7
|
import { appendOverflow } from "./overflow.js";
|
|
8
|
+
import { getCachedRead, invalidatePath, normalizeReadWindow, setCachedRead } from "./read-cache.js";
|
|
9
|
+
import { invalidateListingsForFile } from "./dir-cache.js";
|
|
8
10
|
import { err, invalidCall, READ_CHAR_CAP, resolveSandbox } from "./shared.js";
|
|
9
11
|
// offset/limit are 1-based line numbers. Output capped at ~64KB.
|
|
10
12
|
export async function readTool(args, cwd = process.cwd()) {
|
|
@@ -24,6 +26,21 @@ export async function readTool(args, cwd = process.cwd()) {
|
|
|
24
26
|
const lines = entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
25
27
|
return `Directory listing for ${args.path}:\n${lines.join("\n")}`;
|
|
26
28
|
}
|
|
29
|
+
// Read-cache fast path: same abs + window + unchanged mtime/size skips
|
|
30
|
+
// disk I/O. The stored hash refreshes the stale-read fingerprint so
|
|
31
|
+
// read→read→edit chains keep working without re-hashing.
|
|
32
|
+
const { offset: normOffset, limit: normLimit } = normalizeReadWindow(args.offset, args.limit);
|
|
33
|
+
try {
|
|
34
|
+
const statInfo = { mtimeMs: st.mtimeMs ?? 0, size: st.size ?? 0 };
|
|
35
|
+
const hit = getCachedRead(r.abs, normOffset, normLimit, statInfo);
|
|
36
|
+
if (hit) {
|
|
37
|
+
readFingerprints.set(fingerprintKey(r.abs), hit.hash);
|
|
38
|
+
return hit.result;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// cache lookup never breaks reads
|
|
43
|
+
}
|
|
27
44
|
let text;
|
|
28
45
|
try {
|
|
29
46
|
text = await fsp.readFile(r.abs, "utf8");
|
|
@@ -31,7 +48,8 @@ export async function readTool(args, cwd = process.cwd()) {
|
|
|
31
48
|
catch {
|
|
32
49
|
return err(`cannot read file: ${args.path}`);
|
|
33
50
|
}
|
|
34
|
-
|
|
51
|
+
const hash = contentHash(text);
|
|
52
|
+
readFingerprints.set(fingerprintKey(r.abs), hash);
|
|
35
53
|
if (text.length === 0)
|
|
36
54
|
return "";
|
|
37
55
|
const offset = Math.max(1, Math.floor(args.offset ?? 1));
|
|
@@ -42,6 +60,13 @@ export async function readTool(args, cwd = process.cwd()) {
|
|
|
42
60
|
const full = out;
|
|
43
61
|
out = appendOverflow(full.slice(0, READ_CHAR_CAP), "\n[truncated: output exceeded 64KB]", "file output", full);
|
|
44
62
|
}
|
|
63
|
+
try {
|
|
64
|
+
const statInfo = { mtimeMs: st.mtimeMs ?? 0, size: st.size ?? 0 };
|
|
65
|
+
setCachedRead(r.abs, normOffset, normLimit, out, statInfo, hash);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// cache store never breaks reads
|
|
69
|
+
}
|
|
45
70
|
return out;
|
|
46
71
|
}
|
|
47
72
|
catch (e) {
|
|
@@ -61,6 +86,13 @@ export async function writeTool(args, cwd = process.cwd()) {
|
|
|
61
86
|
await fsp.mkdir(path.dirname(r.abs), { recursive: true });
|
|
62
87
|
await fsp.writeFile(r.abs, args.content, "utf8");
|
|
63
88
|
readFingerprints.set(fingerprintKey(r.abs), contentHash(args.content));
|
|
89
|
+
try {
|
|
90
|
+
invalidatePath(r.abs);
|
|
91
|
+
invalidateListingsForFile(r.abs);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// cache invalidation never breaks writes
|
|
95
|
+
}
|
|
64
96
|
return `Wrote ${Buffer.byteLength(args.content, "utf8")} bytes to ${args.path}`;
|
|
65
97
|
}
|
|
66
98
|
catch (e) {
|
|
@@ -102,6 +134,13 @@ export async function editTool(args, cwd = process.cwd()) {
|
|
|
102
134
|
await capturePriorBytes(r.abs, `edit ${args.path}`);
|
|
103
135
|
await fsp.writeFile(r.abs, next, "utf8");
|
|
104
136
|
readFingerprints.set(key, contentHash(next));
|
|
137
|
+
try {
|
|
138
|
+
invalidatePath(r.abs);
|
|
139
|
+
invalidateListingsForFile(r.abs);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// cache invalidation never breaks edits
|
|
143
|
+
}
|
|
105
144
|
return `Edited ${args.path}: replaced ${args.replaceAll ? count : 1} occurrence(s)`;
|
|
106
145
|
}
|
|
107
146
|
catch (e) {
|