atom-agent 0.3.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 +82 -0
- package/README.md +83 -32
- package/dist/App.js +2178 -318
- package/dist/adapters.js +146 -15
- package/dist/agent/gates.js +153 -0
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +908 -0
- package/dist/agent/normalize.js +144 -0
- package/dist/agent/types.js +1 -0
- package/dist/auth.js +2 -1
- package/dist/cli.js +68 -6
- package/dist/compact.js +6 -48
- package/dist/config.js +171 -0
- package/dist/context-manager.js +564 -0
- package/dist/kilo.js +343 -0
- package/dist/local-discovery.js +308 -0
- package/dist/policy.js +286 -0
- package/dist/prompt-cache.js +99 -0
- package/dist/providers.js +183 -2
- package/dist/rollback.js +21 -0
- package/dist/scheduler.js +247 -0
- package/dist/session.js +35 -3
- package/dist/skills.js +214 -43
- package/dist/snapshots.js +57 -2
- package/dist/system.js +8 -1
- package/dist/telemetry-dashboard.js +589 -0
- package/dist/telemetry-server.js +301 -0
- package/dist/telemetry.js +1056 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +149 -0
- package/dist/tools/fingerprints.js +33 -0
- package/dist/tools/overflow.js +76 -0
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +802 -0
- package/dist/tools/search.js +242 -0
- package/dist/tools/shared.js +31 -0
- package/dist/tools/shell.js +273 -0
- package/dist/tools/todo.js +191 -0
- package/dist/tools/web.js +454 -0
- package/dist/tools.js +17 -1863
- package/dist/ui/activity.js +51 -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/errors.js +129 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/input-model.js +115 -0
- package/dist/ui/input.js +40 -0
- package/dist/ui/live-tail.js +15 -0
- package/dist/ui/markdown.js +525 -0
- package/dist/ui/modals.js +47 -0
- package/dist/ui/palette.js +70 -0
- package/dist/ui/pickers.js +32 -0
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +75 -0
- package/dist/ui/theme.js +128 -0
- package/dist/ui/todo-panel.js +30 -0
- package/dist/ui/tool-inspector.js +59 -0
- package/dist/ui/transcript.js +128 -0
- package/dist/zen.js +145 -666
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Filesystem executors: read/write/edit. Every mutation snapshots prior
|
|
2
|
+
// bytes first (see snapshots.ts); validation failures return before capture.
|
|
3
|
+
import { promises as fsp } from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { capturePriorBytes } from "../snapshots.js";
|
|
6
|
+
import { contentHash, fingerprintKey, readFingerprints } from "./fingerprints.js";
|
|
7
|
+
import { appendOverflow } from "./overflow.js";
|
|
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 } from "./shared.js";
|
|
11
|
+
// offset/limit are 1-based line numbers. Output capped at ~64KB.
|
|
12
|
+
export async function readTool(args, cwd = process.cwd()) {
|
|
13
|
+
try {
|
|
14
|
+
const r = resolveSandbox(args?.path, cwd);
|
|
15
|
+
if (r.error || !r.abs)
|
|
16
|
+
return r.error ?? err("bad path");
|
|
17
|
+
let st;
|
|
18
|
+
try {
|
|
19
|
+
st = await fsp.stat(r.abs);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return err(`no such file or directory: ${args.path}`);
|
|
23
|
+
}
|
|
24
|
+
if (st.isDirectory()) {
|
|
25
|
+
const entries = await fsp.readdir(r.abs, { withFileTypes: true });
|
|
26
|
+
const lines = entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
27
|
+
return `Directory listing for ${args.path}:\n${lines.join("\n")}`;
|
|
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
|
+
}
|
|
44
|
+
let text;
|
|
45
|
+
try {
|
|
46
|
+
text = await fsp.readFile(r.abs, "utf8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return err(`cannot read file: ${args.path}`);
|
|
50
|
+
}
|
|
51
|
+
const hash = contentHash(text);
|
|
52
|
+
readFingerprints.set(fingerprintKey(r.abs), hash);
|
|
53
|
+
if (text.length === 0)
|
|
54
|
+
return "";
|
|
55
|
+
const offset = Math.max(1, Math.floor(args.offset ?? 1));
|
|
56
|
+
const limit = Math.max(1, Math.floor(args.limit ?? Number.MAX_SAFE_INTEGER));
|
|
57
|
+
const window = text.split("\n").slice(offset - 1, offset - 1 + limit);
|
|
58
|
+
let out = window.map((line, i) => `${offset + i}: ${line}`).join("\n");
|
|
59
|
+
if (out.length > READ_CHAR_CAP) {
|
|
60
|
+
const full = out;
|
|
61
|
+
out = appendOverflow(full.slice(0, READ_CHAR_CAP), "\n[truncated: output exceeded 64KB]", "file output", full);
|
|
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
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function writeTool(args, cwd = process.cwd()) {
|
|
77
|
+
try {
|
|
78
|
+
const r = resolveSandbox(args?.path, cwd);
|
|
79
|
+
if (r.error || !r.abs)
|
|
80
|
+
return r.error ?? err("bad path");
|
|
81
|
+
if (typeof args.content !== "string")
|
|
82
|
+
return err("content must be a string");
|
|
83
|
+
// Ticket 01 (/rewind): silent pre-mutation snapshot — every write is
|
|
84
|
+
// covered regardless of caller, and capture never fails this call.
|
|
85
|
+
await capturePriorBytes(r.abs, `write ${args.path}`);
|
|
86
|
+
await fsp.mkdir(path.dirname(r.abs), { recursive: true });
|
|
87
|
+
await fsp.writeFile(r.abs, args.content, "utf8");
|
|
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
|
+
}
|
|
96
|
+
return `Wrote ${Buffer.byteLength(args.content, "utf8")} bytes to ${args.path}`;
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function editTool(args, cwd = process.cwd()) {
|
|
103
|
+
try {
|
|
104
|
+
const r = resolveSandbox(args?.path, cwd);
|
|
105
|
+
if (r.error || !r.abs)
|
|
106
|
+
return r.error ?? err("bad path");
|
|
107
|
+
if (typeof args.oldString !== "string" || args.oldString.length === 0) {
|
|
108
|
+
return err("oldString must be a non-empty string");
|
|
109
|
+
}
|
|
110
|
+
if (typeof args.newString !== "string")
|
|
111
|
+
return err("newString must be a string");
|
|
112
|
+
let text;
|
|
113
|
+
try {
|
|
114
|
+
text = await fsp.readFile(r.abs, "utf8");
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return err(`no such file or directory: ${args.path}`);
|
|
118
|
+
}
|
|
119
|
+
const key = fingerprintKey(r.abs);
|
|
120
|
+
const known = readFingerprints.get(key);
|
|
121
|
+
if (known !== undefined && contentHash(text) !== known) {
|
|
122
|
+
return invalidCall(`stale read — ${args.path} changed since you last read it. Read it again before editing`);
|
|
123
|
+
}
|
|
124
|
+
const count = text.split(args.oldString).length - 1;
|
|
125
|
+
if (count === 0)
|
|
126
|
+
return err(`no match for oldString in ${args.path}`);
|
|
127
|
+
if (count > 1 && !args.replaceAll) {
|
|
128
|
+
return err(`oldString matches ${count} times in ${args.path}; pass replaceAll=true to replace all`);
|
|
129
|
+
}
|
|
130
|
+
const next = args.replaceAll
|
|
131
|
+
? text.split(args.oldString).join(args.newString)
|
|
132
|
+
: text.replace(args.oldString, args.newString);
|
|
133
|
+
// Ticket 01 (/rewind): silent pre-mutation snapshot (see writeTool).
|
|
134
|
+
await capturePriorBytes(r.abs, `edit ${args.path}`);
|
|
135
|
+
await fsp.writeFile(r.abs, next, "utf8");
|
|
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
|
+
}
|
|
144
|
+
return `Edited ${args.path}: replaced ${args.replaceAll ? count : 1} occurrence(s)`;
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Stale-read fingerprints: readTool records a content hash per resolved
|
|
2
|
+
// path; editTool refuses when the file changed since the model last read
|
|
3
|
+
// it; /rewind restores refresh (or forget) the record per file.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
// Read-tracking guard: readTool records a sha1 of the full file content per
|
|
6
|
+
// resolved absolute path after each successful FILE read (directory listings
|
|
7
|
+
// are not tracked). editTool refuses when a record exists and the current
|
|
8
|
+
// content hash differs — the file changed since the model last read it
|
|
9
|
+
// (user's editor, git checkout, another tool). Limit: files never read this
|
|
10
|
+
// session have no record, so the guard cannot catch those (e.g. content
|
|
11
|
+
// learned via grep). Successful writeTool/editTool refresh the record so
|
|
12
|
+
// read→write→edit and edit→edit chains never false-refuse; identical
|
|
13
|
+
// rewrites (same hash) never trigger.
|
|
14
|
+
export const readFingerprints = new Map();
|
|
15
|
+
export function fingerprintKey(abs) {
|
|
16
|
+
return abs;
|
|
17
|
+
}
|
|
18
|
+
export function contentHash(text) {
|
|
19
|
+
return createHash("sha1").update(text, "utf8").digest("hex");
|
|
20
|
+
}
|
|
21
|
+
// Ticket 01 (/rewind): a restore writes bytes behind these executors, so the
|
|
22
|
+
// caller refreshes (or forgets, on deletion) the stale-read fingerprint per
|
|
23
|
+
// restored file — otherwise the next edit would false-refuse as a stale read.
|
|
24
|
+
export function refreshReadFingerprint(abs, text) {
|
|
25
|
+
if (typeof abs !== "string" || typeof text !== "string")
|
|
26
|
+
return;
|
|
27
|
+
readFingerprints.set(fingerprintKey(abs), contentHash(text));
|
|
28
|
+
}
|
|
29
|
+
export function forgetReadFingerprint(abs) {
|
|
30
|
+
if (typeof abs !== "string")
|
|
31
|
+
return;
|
|
32
|
+
readFingerprints.delete(fingerprintKey(abs));
|
|
33
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Overflow-to-temp spill for over-cap tool output. Best-effort, never
|
|
2
|
+
// throws; stale spills prune by age on each write.
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
// under the OS temp dir (<tmpdir>/atom-overflow/), every I/O step is
|
|
8
|
+
// best-effort and never throws (null/"" = keep the plain truncation note).
|
|
9
|
+
// Stale spills are pruned by age on each write; the OS reclaims the rest.
|
|
10
|
+
// Under-cap results never touch this path (byte-identical). Scope: byte-cap
|
|
11
|
+
// truncations where the full text is in hand (read, bash, bash_output,
|
|
12
|
+
// webfetch output). Count-cap notes (grep/glob "more than N matches") and
|
|
13
|
+
// prompt-assembly caps (skills, compact, AGENTS.md, history) are unchanged:
|
|
14
|
+
// their heads are already the most-relevant slice and re-query narrows them.
|
|
15
|
+
const OVERFLOW_DIR = "atom-overflow";
|
|
16
|
+
const OVERFLOW_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
let overflowSeq = 0;
|
|
18
|
+
export function overflowDir() {
|
|
19
|
+
return path.join(os.tmpdir(), OVERFLOW_DIR);
|
|
20
|
+
}
|
|
21
|
+
function pruneOverflowFiles() {
|
|
22
|
+
try {
|
|
23
|
+
const dir = overflowDir();
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = fs.readdirSync(dir);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return; // nothing spilled yet — nothing to prune
|
|
30
|
+
}
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
for (const name of entries) {
|
|
33
|
+
if (!name.startsWith("overflow-"))
|
|
34
|
+
continue;
|
|
35
|
+
try {
|
|
36
|
+
const p = path.join(dir, name);
|
|
37
|
+
if (now - fs.statSync(p).mtimeMs > OVERFLOW_MAX_AGE_MS)
|
|
38
|
+
fs.rmSync(p, { force: true });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// ignore per-file failures (a stale spill is harmless)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// never throw across the tool boundary
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Write the FULL over-cap text to a temp file; null when anything fails.
|
|
50
|
+
export function spillOverflow(fullText) {
|
|
51
|
+
try {
|
|
52
|
+
if (typeof fullText !== "string" || fullText.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
pruneOverflowFiles();
|
|
55
|
+
const dir = overflowDir();
|
|
56
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
57
|
+
overflowSeq += 1;
|
|
58
|
+
const name = `overflow-${process.pid}-${Date.now().toString(36)}-${overflowSeq}-${randomBytes(4).toString("hex")}.txt`;
|
|
59
|
+
const file = path.join(dir, name);
|
|
60
|
+
fs.writeFileSync(file, fullText, "utf8");
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Head + existing truncation note + followable overflow pointer (or just
|
|
68
|
+
// head + note when the spill fails — never throws, never empty-handed).
|
|
69
|
+
export function appendOverflow(head, truncNote, label, fullText) {
|
|
70
|
+
const file = spillOverflow(fullText);
|
|
71
|
+
if (!file)
|
|
72
|
+
return head + truncNote;
|
|
73
|
+
return (`${head}${truncNote}\n` +
|
|
74
|
+
`[overflow: full ${label} (${fullText.length} chars) spilled to ${file} — ` +
|
|
75
|
+
`use read with offset/limit to page through it]`);
|
|
76
|
+
}
|
|
@@ -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
|
+
}
|