atom-agent 1.0.0 → 1.2.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 +62 -2
- package/README.md +17 -16
- package/dist/App.js +1010 -77
- package/dist/adapters.js +108 -8
- package/dist/agent/gates.js +14 -1
- package/dist/agent/loop-guard.js +182 -0
- package/dist/agent/loop.js +781 -329
- package/dist/agent/normalize.js +151 -0
- package/dist/cli.js +16 -2
- package/dist/compact.js +128 -2
- package/dist/env-block.js +43 -5
- package/dist/scheduler.js +101 -21
- package/dist/sessions.js +524 -0
- package/dist/system.js +89 -12
- package/dist/telemetry-dashboard.js +19 -1
- package/dist/telemetry.js +55 -0
- package/dist/tools/dir-cache.js +214 -0
- package/dist/tools/filesystem.js +43 -3
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +80 -0
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +147 -80
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +26 -5
- package/dist/tools/todo.js +1 -1
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +3 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +117 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/highlight.js +120 -0
- 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/modals.js +22 -5
- package/dist/ui/palette.js +12 -2
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +20 -4
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/theme.js +6 -0
- package/dist/ui/todo-panel.js +10 -2
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +105 -39
- package/dist/zen.js +97 -20
- package/package.json +1 -1
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,214 @@
|
|
|
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
|
+
// Public for the ripgrep adapter: rg emits dir-relative paths, but the
|
|
80
|
+
// enumerated contract (and therefore outputs) is cwd-relative — which may
|
|
81
|
+
// climb out of the tree (`../../..`) when the search dir sits outside cwd.
|
|
82
|
+
// Exported so both paths share the one mapping (never duplicated logic).
|
|
83
|
+
export function rgRelToCwdRel(absDir, cwd, dirRel) {
|
|
84
|
+
return toCwdRel(absDir, cwd, dirRel);
|
|
85
|
+
}
|
|
86
|
+
function filterSkipped(relPaths) {
|
|
87
|
+
return relPaths.filter((rel) => {
|
|
88
|
+
if (!rel)
|
|
89
|
+
return false;
|
|
90
|
+
for (const seg of rel.split("/")) {
|
|
91
|
+
if (SKIP_DIRS.has(seg))
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// Git enumeration: tracked + untracked-non-ignored in ONE invocation, as
|
|
98
|
+
// cwd-relative posix paths. Verified: both spellings emit paths relative to
|
|
99
|
+
// the directory git runs in. Outside a repo (or no git binary) the command
|
|
100
|
+
// fails and this returns null — the caller falls back to the walker.
|
|
101
|
+
async function gitListFiles(absDir, cwd) {
|
|
102
|
+
let raw;
|
|
103
|
+
try {
|
|
104
|
+
raw = await gitFile(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], absDir);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const entry of raw.split("\0")) {
|
|
113
|
+
if (!entry)
|
|
114
|
+
continue;
|
|
115
|
+
const rel = toCwdRel(absDir, cwd, entry.split(path.sep).join("/"));
|
|
116
|
+
if (!seen.has(rel)) {
|
|
117
|
+
seen.add(rel);
|
|
118
|
+
out.push(rel);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return filterSkipped(out);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function dirMtimeMs(absDir) {
|
|
128
|
+
try {
|
|
129
|
+
return (await fsp.stat(absDir)).mtimeMs;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// List files under absDir as cwd-relative posix paths (walkFiles contract).
|
|
136
|
+
// Fast path: mtime-validated cache → git enumeration → walker fallback.
|
|
137
|
+
// Walker results cache too (the mtime check is equally valid for them).
|
|
138
|
+
export async function listFiles(absDir, cwd) {
|
|
139
|
+
const key = `${cwd}\n${absDir}`;
|
|
140
|
+
if (fastListEnabled()) {
|
|
141
|
+
const mtime = await dirMtimeMs(absDir);
|
|
142
|
+
const hit = listingCache.get(key);
|
|
143
|
+
if (hit && mtime !== null && hit.mtimeMs === mtime && Date.now() - hit.storedAt < LISTING_TTL_MS) {
|
|
144
|
+
// LRU refresh.
|
|
145
|
+
listingCache.delete(key);
|
|
146
|
+
listingCache.set(key, hit);
|
|
147
|
+
cacheStats.hits += 1;
|
|
148
|
+
return [...hit.entries];
|
|
149
|
+
}
|
|
150
|
+
cacheStats.misses += 1;
|
|
151
|
+
const git = await gitListFiles(absDir, cwd);
|
|
152
|
+
if (git !== null) {
|
|
153
|
+
cacheStats.gitUses += 1;
|
|
154
|
+
storeListing(key, git, mtime);
|
|
155
|
+
return [...git];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
cacheStats.walkerUses += 1;
|
|
159
|
+
const out = [];
|
|
160
|
+
await walkFiles(absDir, cwd, out);
|
|
161
|
+
if (fastListEnabled()) {
|
|
162
|
+
storeListing(key, out, await dirMtimeMs(absDir));
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
function storeListing(key, entries, mtimeMs) {
|
|
167
|
+
try {
|
|
168
|
+
listingCache.delete(key);
|
|
169
|
+
while (listingCache.size >= LISTING_MAX_ENTRIES) {
|
|
170
|
+
const oldest = listingCache.keys().next();
|
|
171
|
+
if (oldest.done)
|
|
172
|
+
break;
|
|
173
|
+
listingCache.delete(oldest.value);
|
|
174
|
+
}
|
|
175
|
+
listingCache.set(key, { entries: [...entries], mtimeMs: mtimeMs ?? -1, storedAt: Date.now() });
|
|
176
|
+
cacheStats.stores += 1;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// cache failures never break search
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export function clearDirListingCache() {
|
|
183
|
+
listingCache.clear();
|
|
184
|
+
}
|
|
185
|
+
// Drop every listing whose directory is the file itself or an ancestor of it
|
|
186
|
+
// (a mutation inside the tree can change the listing). Called by write/edit
|
|
187
|
+
// next to the read-cache invalidation. Never throws.
|
|
188
|
+
export function invalidateListingsForFile(absFilePath) {
|
|
189
|
+
try {
|
|
190
|
+
if (typeof absFilePath !== "string" || absFilePath.length === 0)
|
|
191
|
+
return;
|
|
192
|
+
const target = path.resolve(absFilePath);
|
|
193
|
+
for (const key of [...listingCache.keys()]) {
|
|
194
|
+
const splitAt = key.indexOf("\n");
|
|
195
|
+
const absDir = splitAt >= 0 ? key.slice(splitAt + 1) : key;
|
|
196
|
+
if (target === absDir || target.startsWith(absDir + path.sep)) {
|
|
197
|
+
listingCache.delete(key);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// never throw across the tool boundary
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
export function getDirListingStats() {
|
|
206
|
+
return { ...cacheStats, size: listingCache.size };
|
|
207
|
+
}
|
|
208
|
+
export function resetDirListingStats() {
|
|
209
|
+
cacheStats.hits = 0;
|
|
210
|
+
cacheStats.misses = 0;
|
|
211
|
+
cacheStats.stores = 0;
|
|
212
|
+
cacheStats.gitUses = 0;
|
|
213
|
+
cacheStats.walkerUses = 0;
|
|
214
|
+
}
|
package/dist/tools/filesystem.js
CHANGED
|
@@ -5,7 +5,9 @@ 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 {
|
|
8
|
+
import { getCachedRead, invalidatePath, normalizeReadWindow, setCachedRead } from "./read-cache.js";
|
|
9
|
+
import { invalidateListingsForFile } from "./dir-cache.js";
|
|
10
|
+
import { err, invalidCall, READ_CHAR_CAP, resolveSandbox, truncateHead } 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()) {
|
|
11
13
|
try {
|
|
@@ -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));
|
|
@@ -40,7 +58,15 @@ export async function readTool(args, cwd = process.cwd()) {
|
|
|
40
58
|
let out = window.map((line, i) => `${offset + i}: ${line}`).join("\n");
|
|
41
59
|
if (out.length > READ_CHAR_CAP) {
|
|
42
60
|
const full = out;
|
|
43
|
-
|
|
61
|
+
const t = truncateHead(full, READ_CHAR_CAP, "\n[truncated: output exceeded 64KB]");
|
|
62
|
+
out = appendOverflow(t.head, t.note, "file output", full);
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const statInfo = { mtimeMs: st.mtimeMs ?? 0, size: st.size ?? 0 };
|
|
66
|
+
setCachedRead(r.abs, normOffset, normLimit, out, statInfo, hash);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// cache store never breaks reads
|
|
44
70
|
}
|
|
45
71
|
return out;
|
|
46
72
|
}
|
|
@@ -61,6 +87,13 @@ export async function writeTool(args, cwd = process.cwd()) {
|
|
|
61
87
|
await fsp.mkdir(path.dirname(r.abs), { recursive: true });
|
|
62
88
|
await fsp.writeFile(r.abs, args.content, "utf8");
|
|
63
89
|
readFingerprints.set(fingerprintKey(r.abs), contentHash(args.content));
|
|
90
|
+
try {
|
|
91
|
+
invalidatePath(r.abs);
|
|
92
|
+
invalidateListingsForFile(r.abs);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// cache invalidation never breaks writes
|
|
96
|
+
}
|
|
64
97
|
return `Wrote ${Buffer.byteLength(args.content, "utf8")} bytes to ${args.path}`;
|
|
65
98
|
}
|
|
66
99
|
catch (e) {
|
|
@@ -102,6 +135,13 @@ export async function editTool(args, cwd = process.cwd()) {
|
|
|
102
135
|
await capturePriorBytes(r.abs, `edit ${args.path}`);
|
|
103
136
|
await fsp.writeFile(r.abs, next, "utf8");
|
|
104
137
|
readFingerprints.set(key, contentHash(next));
|
|
138
|
+
try {
|
|
139
|
+
invalidatePath(r.abs);
|
|
140
|
+
invalidateListingsForFile(r.abs);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// cache invalidation never breaks edits
|
|
144
|
+
}
|
|
105
145
|
return `Edited ${args.path}: replaced ${args.replaceAll ? count : 1} occurrence(s)`;
|
|
106
146
|
}
|
|
107
147
|
catch (e) {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Read-through cache for file reads: repeated `read` calls for the same
|
|
2
|
+
// path+window skip disk I/O when the file hasn't changed. Correctness first:
|
|
3
|
+
//
|
|
4
|
+
// - Key: absolute path + offset + limit (different windows are different keys).
|
|
5
|
+
// - Validation: file mtimeMs + size checked on every hit (one stat, cheap).
|
|
6
|
+
// A mismatch → miss → re-read + re-store. External edits can never serve
|
|
7
|
+
// stale bytes beyond a stat race.
|
|
8
|
+
// - Invalidation: write/edit/delete paths call invalidatePath(abs) — the
|
|
9
|
+
// write/edit executors do this, so read→write→read chains never go stale.
|
|
10
|
+
// A global version bump (invalidateAll) covers tree-wide mutations.
|
|
11
|
+
// - Scope: successes only (errors never cache — a transient ENOENT must not
|
|
12
|
+
// poison later reads). Directory listings never cache (readdir is already
|
|
13
|
+
// cheap and highly mutable).
|
|
14
|
+
// - Bounds: LRU cap (default 100 entries) + TTL (default 30s). Disable with
|
|
15
|
+
// ATOM_READ_CACHE=0. All best-effort, never throws.
|
|
16
|
+
//
|
|
17
|
+
// Measurable win: explore loops re-read the same files (read after grep,
|
|
18
|
+
// re-read before edit, parallel batches reading shared context). Each hit
|
|
19
|
+
// saves a full readFile + split + 64KB-format pass.
|
|
20
|
+
//
|
|
21
|
+
// Stats (for loop instrumentation + tests): hits, misses, stores, invalidations.
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
const DEFAULT_MAX_ENTRIES = 100;
|
|
24
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
const stats = { hits: 0, misses: 0, stores: 0, invalidations: 0, size: 0 };
|
|
27
|
+
function cacheEnabled() {
|
|
28
|
+
const raw = process.env.ATOM_READ_CACHE;
|
|
29
|
+
if (raw === undefined)
|
|
30
|
+
return true;
|
|
31
|
+
const v = raw.trim().toLowerCase();
|
|
32
|
+
return !(v === "0" || v === "false" || v === "no" || v === "off");
|
|
33
|
+
}
|
|
34
|
+
function ttlMs() {
|
|
35
|
+
const raw = process.env.ATOM_READ_CACHE_TTL_MS;
|
|
36
|
+
if (raw !== undefined) {
|
|
37
|
+
const n = Number(raw.trim());
|
|
38
|
+
if (Number.isFinite(n) && n > 0)
|
|
39
|
+
return Math.min(Math.floor(n), 300_000);
|
|
40
|
+
}
|
|
41
|
+
return DEFAULT_TTL_MS;
|
|
42
|
+
}
|
|
43
|
+
function maxEntries() {
|
|
44
|
+
const raw = process.env.ATOM_READ_CACHE_MAX;
|
|
45
|
+
if (raw !== undefined) {
|
|
46
|
+
const n = Number(raw.trim());
|
|
47
|
+
if (Number.isFinite(n) && n > 0)
|
|
48
|
+
return Math.min(Math.floor(n), 1000);
|
|
49
|
+
}
|
|
50
|
+
return DEFAULT_MAX_ENTRIES;
|
|
51
|
+
}
|
|
52
|
+
export function readCacheKey(abs, offset, limit) {
|
|
53
|
+
return `${abs}\n${offset}\n${limit}`;
|
|
54
|
+
}
|
|
55
|
+
export function normalizeReadWindow(offset, limit) {
|
|
56
|
+
const o = typeof offset === "number" && Number.isFinite(offset) ? Math.max(1, Math.floor(offset)) : 1;
|
|
57
|
+
const l = typeof limit === "number" && Number.isFinite(limit)
|
|
58
|
+
? Math.max(1, Math.floor(limit))
|
|
59
|
+
: Number.MAX_SAFE_INTEGER;
|
|
60
|
+
return { offset: o, limit: l };
|
|
61
|
+
}
|
|
62
|
+
// Lookup by absolute path + window. `stat` must be the fresh
|
|
63
|
+
// {mtimeMs, size} of the file (the caller already stats before reading).
|
|
64
|
+
// Returns the cached {result, hash} on hit, null on miss. Never throws.
|
|
65
|
+
export function getCachedRead(abs, offset, limit, stat) {
|
|
66
|
+
if (!cacheEnabled()) {
|
|
67
|
+
stats.misses += 1;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const key = readCacheKey(abs, offset, limit);
|
|
71
|
+
const entry = cache.get(key);
|
|
72
|
+
if (!entry) {
|
|
73
|
+
stats.misses += 1;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (Date.now() - entry.storedAt > ttlMs()) {
|
|
77
|
+
cache.delete(key);
|
|
78
|
+
stats.size = cache.size;
|
|
79
|
+
stats.misses += 1;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (entry.mtimeMs !== stat.mtimeMs || entry.size !== stat.size) {
|
|
83
|
+
cache.delete(key);
|
|
84
|
+
stats.size = cache.size;
|
|
85
|
+
stats.misses += 1;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
// LRU refresh: re-insert so the oldest key stays evictable first.
|
|
89
|
+
cache.delete(key);
|
|
90
|
+
cache.set(key, entry);
|
|
91
|
+
stats.hits += 1;
|
|
92
|
+
return { result: entry.result, hash: entry.hash };
|
|
93
|
+
}
|
|
94
|
+
// Store a successful file-read result. Never throws; evicts oldest first.
|
|
95
|
+
export function setCachedRead(abs, offset, limit, result, stat, hash) {
|
|
96
|
+
try {
|
|
97
|
+
if (!cacheEnabled() || typeof result !== "string")
|
|
98
|
+
return;
|
|
99
|
+
const key = readCacheKey(abs, offset, limit);
|
|
100
|
+
cache.delete(key);
|
|
101
|
+
while (cache.size >= maxEntries()) {
|
|
102
|
+
const oldest = cache.keys().next();
|
|
103
|
+
if (oldest.done)
|
|
104
|
+
break;
|
|
105
|
+
cache.delete(oldest.value);
|
|
106
|
+
}
|
|
107
|
+
cache.set(key, { result, hash, mtimeMs: stat.mtimeMs, size: stat.size, storedAt: Date.now() });
|
|
108
|
+
stats.size = cache.size;
|
|
109
|
+
stats.stores += 1;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// cache failures never break reads
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Drop every entry for one absolute path (all windows). Called by
|
|
116
|
+
// write/edit/delete paths. Never throws.
|
|
117
|
+
export function invalidatePath(abs) {
|
|
118
|
+
try {
|
|
119
|
+
const prefix = `${abs}\n`;
|
|
120
|
+
let dropped = 0;
|
|
121
|
+
for (const key of [...cache.keys()]) {
|
|
122
|
+
if (key === abs || key.startsWith(prefix)) {
|
|
123
|
+
cache.delete(key);
|
|
124
|
+
dropped += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Also match callers that pass a relative display path: compare resolved
|
|
128
|
+
// basenames as a fallback so nothing obviously stale survives.
|
|
129
|
+
if (dropped === 0 && typeof abs === "string") {
|
|
130
|
+
const resolved = path.resolve(abs);
|
|
131
|
+
const resolvedPrefix = `${resolved}\n`;
|
|
132
|
+
for (const key of [...cache.keys()]) {
|
|
133
|
+
if (key === resolved || key.startsWith(resolvedPrefix)) {
|
|
134
|
+
cache.delete(key);
|
|
135
|
+
dropped += 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (dropped > 0)
|
|
140
|
+
stats.invalidations += dropped;
|
|
141
|
+
stats.size = cache.size;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// never throw across the tool boundary
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export function clearReadCache() {
|
|
148
|
+
cache.clear();
|
|
149
|
+
stats.size = 0;
|
|
150
|
+
}
|
|
151
|
+
export function getReadCacheStats() {
|
|
152
|
+
return { ...stats, size: cache.size };
|
|
153
|
+
}
|
|
154
|
+
export function resetReadCacheStats() {
|
|
155
|
+
stats.hits = 0;
|
|
156
|
+
stats.misses = 0;
|
|
157
|
+
stats.stores = 0;
|
|
158
|
+
stats.invalidations = 0;
|
|
159
|
+
stats.size = cache.size;
|
|
160
|
+
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -375,6 +375,85 @@ export function describeToolCall(name, args) {
|
|
|
375
375
|
return `⚙ ${name}`;
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
|
+
// Shared byte cap for approve-time file reads (preview + BEFORE capture).
|
|
379
|
+
export const APPROVAL_PREVIEW_MAX_BYTES = 1_000_000;
|
|
380
|
+
// Extension → highlight family (see ui/highlight.ts). Conservative: only
|
|
381
|
+
// extensions we are confident about; everything else stays null (plain).
|
|
382
|
+
const PREVIEW_LANG_BY_EXT = {
|
|
383
|
+
ts: "c",
|
|
384
|
+
tsx: "c",
|
|
385
|
+
mts: "c",
|
|
386
|
+
cts: "c",
|
|
387
|
+
js: "c",
|
|
388
|
+
jsx: "c",
|
|
389
|
+
mjs: "c",
|
|
390
|
+
cjs: "c",
|
|
391
|
+
go: "c",
|
|
392
|
+
rs: "c",
|
|
393
|
+
java: "c",
|
|
394
|
+
c: "c",
|
|
395
|
+
h: "c",
|
|
396
|
+
hh: "c",
|
|
397
|
+
cc: "c",
|
|
398
|
+
cpp: "c",
|
|
399
|
+
hpp: "c",
|
|
400
|
+
cs: "c",
|
|
401
|
+
swift: "c",
|
|
402
|
+
kt: "c",
|
|
403
|
+
kts: "c",
|
|
404
|
+
php: "c",
|
|
405
|
+
py: "py",
|
|
406
|
+
pyi: "py",
|
|
407
|
+
rb: "py",
|
|
408
|
+
sh: "sh",
|
|
409
|
+
bash: "sh",
|
|
410
|
+
zsh: "sh",
|
|
411
|
+
json: "data",
|
|
412
|
+
jsonc: "data",
|
|
413
|
+
yaml: "data",
|
|
414
|
+
yml: "data",
|
|
415
|
+
toml: "data",
|
|
416
|
+
};
|
|
417
|
+
export function previewLangFromPath(p) {
|
|
418
|
+
const base = p.split(/[\\/]/).pop() ?? p;
|
|
419
|
+
const dot = base.lastIndexOf(".");
|
|
420
|
+
if (dot <= 0 || dot === base.length - 1)
|
|
421
|
+
return null;
|
|
422
|
+
return PREVIEW_LANG_BY_EXT[base.slice(dot + 1).toLowerCase()] ?? null;
|
|
423
|
+
}
|
|
424
|
+
export function previewDiffForApproval(name, args, cwd = process.cwd()) {
|
|
425
|
+
try {
|
|
426
|
+
if (name === "edit") {
|
|
427
|
+
const a = args;
|
|
428
|
+
if (typeof a.oldString !== "string" || typeof a.newString !== "string")
|
|
429
|
+
return null;
|
|
430
|
+
const p = typeof a.path === "string" ? a.path : null;
|
|
431
|
+
return { oldText: a.oldString, newText: a.newString, lang: p ? previewLangFromPath(p) : null, path: p };
|
|
432
|
+
}
|
|
433
|
+
if (name === "write") {
|
|
434
|
+
const a = args;
|
|
435
|
+
if (typeof a.content !== "string" || typeof a.path !== "string" || a.path.length === 0) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
let oldText = null;
|
|
439
|
+
try {
|
|
440
|
+
const abs = path.resolve(cwd, a.path);
|
|
441
|
+
const st = fs.statSync(abs);
|
|
442
|
+
if (st.isFile() && st.size <= APPROVAL_PREVIEW_MAX_BYTES) {
|
|
443
|
+
oldText = fs.readFileSync(abs, "utf8");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
oldText = null;
|
|
448
|
+
}
|
|
449
|
+
return { oldText, newText: a.content, lang: previewLangFromPath(a.path), path: a.path };
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
378
457
|
// OpenAI-style function schemas sent as `tools` on the chat POST.
|
|
379
458
|
export const TOOL_DEFINITIONS = [
|
|
380
459
|
{
|
|
@@ -495,6 +574,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
495
574
|
"runInBackground=true for servers/watchers/slow builds, then poll with bash_output. " +
|
|
496
575
|
"WHEN NOT to use: never for reading/writing/searching files; never destructive or exfiltrating without explicit user approval; " +
|
|
497
576
|
"don't assume a TTY. " +
|
|
577
|
+
"Shell is cmd.exe on Windows (use dir; quote paths containing spaces) and POSIX sh elsewhere — the env block names it; never probe with ls/pwd/whoami. " +
|
|
498
578
|
"Foreground returns JSON {exitCode, stdout, stderr, timedOut, ...} (streams truncate with pointers). " +
|
|
499
579
|
"Background returns {backgroundTaskId, ...} immediately; the process keeps running detached. " +
|
|
500
580
|
"PRIVILEGED: no sandbox beyond cwd+timeout.",
|