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.
@@ -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
+ }
@@ -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
- readFingerprints.set(fingerprintKey(r.abs), contentHash(text));
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) {