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
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// ripgrep-backed content search: matches from the `rg` binary when it is
|
|
2
|
+
// the faster choice, with the walker as the always-correct fallback.
|
|
3
|
+
//
|
|
4
|
+
// Routing (see grepTool): the caller already enumerated the file set, so the
|
|
5
|
+
// threshold decides on COUNT alone — enumeration cost is sunk. Below the
|
|
6
|
+
// threshold the walker wins (no ~80ms Windows spawn tax); above it rg's
|
|
7
|
+
// parallel scanner wins by multiples. `ATOM_RG_MIN_FILES` overrides the
|
|
8
|
+
// default; `ATOM_RG=0` forces the walker (degraded envs, tests).
|
|
9
|
+
//
|
|
10
|
+
// Exactness contract (parity-tested, never assumed):
|
|
11
|
+
// - File COVERAGE is the enumerated set, always: rg output is intersected
|
|
12
|
+
// with it, so git-ignored-untracked files, dotfiles, and SKIP_DIRS behave
|
|
13
|
+
// exactly like the walker path in both git and non-git trees.
|
|
14
|
+
// - `include` globs filter in JS (shared matcher), never via `-g`: our
|
|
15
|
+
// minimal glob dialect and gitignore semantics could otherwise drift.
|
|
16
|
+
// - Case sensitivity, binary skipping (rg skips NUL files like the walker),
|
|
17
|
+
// and CRLF handling (one trailing \r\n stripped, mirroring split("\n"))
|
|
18
|
+
// all match the walker.
|
|
19
|
+
// - Modes map 1:1: content via `--json --max-count` merged in alpha order
|
|
20
|
+
// with the same 100-hit cap + note; files_with_matches via
|
|
21
|
+
// `--files-with-matches` into the existing recency pipeline; count via
|
|
22
|
+
// `--count` into the existing totals pipeline.
|
|
23
|
+
// - ANY rg failure (missing binary, bad exit, unparseable output, invalid
|
|
24
|
+
// regex for Rust's engine such as lookahead) returns null and the caller
|
|
25
|
+
// runs the walker — search never fails when the walker could answer.
|
|
26
|
+
// - No timeout: the walker path is equally unbounded, and a timeout would
|
|
27
|
+
// invent a failure mode neither path had.
|
|
28
|
+
//
|
|
29
|
+
// Known untested edges (documented, not solved): symlink handling (no
|
|
30
|
+
// privilege to create them in this env — both sides skip them by
|
|
31
|
+
// construction: walker via isFile/isDirectory checks, rg via no-follow
|
|
32
|
+
// default), lone-\r line endings, non-ASCII case/boundary semantics.
|
|
33
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
34
|
+
import { rgRelToCwdRel } from "./dir-cache.js";
|
|
35
|
+
// Routing threshold (measured, Windows, 3000-file fixture): below it the
|
|
36
|
+
// walker wins outright (no ~100ms spawn tax); above it rg wins clearly
|
|
37
|
+
// (643ms vs 872ms end-to-end at 3000 files, and the gap widens with size
|
|
38
|
+
// since the walker pays per-file reads while rg does not). Conservative on
|
|
39
|
+
// purpose: small/medium scopes stay on the exact legacy path, and
|
|
40
|
+
// `ATOM_RG_MIN_FILES` overrides per environment.
|
|
41
|
+
export const RG_MIN_FILES_DEFAULT = 1000;
|
|
42
|
+
const RG_MAX_BUFFER = 64 * 1024 * 1024;
|
|
43
|
+
//rg --max-count per file for content mode: bounds output while keeping the
|
|
44
|
+
// merged take-100 exact. Rationale: take-100-alpha needs every file's FULL
|
|
45
|
+
// matching-line list UNLESS a file is capped — so any file hitting the cap
|
|
46
|
+
// forces a walker fallback (rare pathological case: 1000+ matches in one
|
|
47
|
+
// file). Without the cap a minified bundle could dump megabytes of JSON.
|
|
48
|
+
const RG_CONTENT_MAX_COUNT = 1000;
|
|
49
|
+
const stats = { uses: 0, fallbacks: 0 };
|
|
50
|
+
let availability = null;
|
|
51
|
+
export function rgEnabled() {
|
|
52
|
+
const raw = process.env.ATOM_RG;
|
|
53
|
+
if (raw === undefined)
|
|
54
|
+
return true;
|
|
55
|
+
const v = raw.trim().toLowerCase();
|
|
56
|
+
return !(v === "0" || v === "false" || v === "no" || v === "off");
|
|
57
|
+
}
|
|
58
|
+
export function rgMinFiles() {
|
|
59
|
+
const raw = process.env.ATOM_RG_MIN_FILES;
|
|
60
|
+
if (raw !== undefined) {
|
|
61
|
+
const n = Number(raw.trim());
|
|
62
|
+
if (Number.isFinite(n) && n > 0)
|
|
63
|
+
return Math.floor(n);
|
|
64
|
+
}
|
|
65
|
+
return RG_MIN_FILES_DEFAULT;
|
|
66
|
+
}
|
|
67
|
+
// One-time `rg --version` probe (lazy so small searches never pay the spawn).
|
|
68
|
+
// Never throws; false on any failure.
|
|
69
|
+
export function rgAvailable() {
|
|
70
|
+
if (!rgEnabled())
|
|
71
|
+
return false;
|
|
72
|
+
if (availability !== null)
|
|
73
|
+
return availability;
|
|
74
|
+
try {
|
|
75
|
+
const out = execFileSync("rg", ["--version"], {
|
|
76
|
+
timeout: 10000,
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
windowsHide: true,
|
|
79
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
80
|
+
});
|
|
81
|
+
availability = typeof out === "string" && out.includes("ripgrep");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
availability = false;
|
|
85
|
+
}
|
|
86
|
+
return availability;
|
|
87
|
+
}
|
|
88
|
+
// Test seam: reset the cached probe (production never calls this).
|
|
89
|
+
export function resetRgAvailable() {
|
|
90
|
+
availability = null;
|
|
91
|
+
}
|
|
92
|
+
export function getRipgrepStats() {
|
|
93
|
+
return { ...stats, available: rgAvailable() };
|
|
94
|
+
}
|
|
95
|
+
export function resetRipgrepStats() {
|
|
96
|
+
stats.uses = 0;
|
|
97
|
+
stats.fallbacks = 0;
|
|
98
|
+
}
|
|
99
|
+
function runRg(absDir, args) {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
execFile("rg", args, { cwd: absDir, timeout: 0, maxBuffer: RG_MAX_BUFFER, windowsHide: true }, (err, stdout, stderr) => {
|
|
102
|
+
const code = err == null ? 0 : typeof err.code === "number" ? err.code : 2;
|
|
103
|
+
resolve({
|
|
104
|
+
code,
|
|
105
|
+
stdout: typeof stdout === "string" ? stdout : String(stdout ?? ""),
|
|
106
|
+
stderr: typeof stderr === "string" ? stderr : String(stderr ?? ""),
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function isRegexError(stderr) {
|
|
112
|
+
return /regex parse error|unrecognized|error parsing regex/i.test(stderr);
|
|
113
|
+
}
|
|
114
|
+
// Shared runner: exit 0 with output wins; exit 1 + empty means no matches
|
|
115
|
+
// (caller renders the mode's empty shape); anything else is a fallback
|
|
116
|
+
// signal — with regex-parse errors being the EXPECTED fallback trigger
|
|
117
|
+
// (lookahead and friends are valid JS, invalid Rust).
|
|
118
|
+
async function runRgSearch(absDir, baseArgs, pattern) {
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await runRg(absDir, [...baseArgs, "-e", pattern, "--", "."]);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return { kind: "fallback" };
|
|
125
|
+
}
|
|
126
|
+
if (res.code === 0)
|
|
127
|
+
return { kind: "ok", stdout: res.stdout };
|
|
128
|
+
if (res.code === 1)
|
|
129
|
+
return { kind: "empty" };
|
|
130
|
+
if (isRegexError(res.stderr))
|
|
131
|
+
return { kind: "fallback" };
|
|
132
|
+
return { kind: "fallback" };
|
|
133
|
+
}
|
|
134
|
+
// Strip the leading "./" rg emits for cwd-relative paths + normalize to the
|
|
135
|
+
// posix rels the enumerated set uses. Returns null when unusable.
|
|
136
|
+
function toRel(rawPath) {
|
|
137
|
+
if (typeof rawPath !== "string" || rawPath.length === 0)
|
|
138
|
+
return null;
|
|
139
|
+
let p = rawPath.replace(/\\/g, "/");
|
|
140
|
+
if (p.startsWith("./"))
|
|
141
|
+
p = p.slice(2);
|
|
142
|
+
if (p.length === 0 || p === ".")
|
|
143
|
+
return null;
|
|
144
|
+
return p;
|
|
145
|
+
}
|
|
146
|
+
// Mirror of split("\n") line semantics: strip exactly one trailing \n and
|
|
147
|
+
// NOTHING else. In particular a CRLF line keeps its \r, exactly like the
|
|
148
|
+
// walker (parity-tested) — invisible on screen, byte-identical in output.
|
|
149
|
+
function splitLine(text) {
|
|
150
|
+
if (text.endsWith("\n"))
|
|
151
|
+
return text.slice(0, -1);
|
|
152
|
+
return text;
|
|
153
|
+
}
|
|
154
|
+
const BASE_ARGS = ["--hidden", "--no-ignore", "--glob", "!node_modules/**", "--glob", "!.git/**"];
|
|
155
|
+
function parseJsonEvents(stdout) {
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const line of stdout.split("\n")) {
|
|
158
|
+
const t = line.trim();
|
|
159
|
+
if (!t.startsWith("{"))
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
const v = JSON.parse(t);
|
|
163
|
+
if (typeof v === "object" && v !== null)
|
|
164
|
+
out.push(v);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// malformed event line: skip, never crash (caller falls back on empty)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
// Content scan: --json matches merged per file (line order), files in alpha
|
|
173
|
+
// order. Hits carry raw line text; the caller applies the shared 200-char
|
|
174
|
+
// trim + 100-hit cap + note (same code as the walker path would — see
|
|
175
|
+
// grepTool; this module only supplies ordered raw material).
|
|
176
|
+
export async function rgContentHits(absDir, cwd, pattern, allowed) {
|
|
177
|
+
const run = await runRgSearch(absDir, [...BASE_ARGS, "--json", "--max-count", String(RG_CONTENT_MAX_COUNT)], pattern);
|
|
178
|
+
if (run.kind !== "ok")
|
|
179
|
+
return run.kind === "empty" ? { counts: [], hits: [], cappedFile: false } : null;
|
|
180
|
+
const perFile = new Map();
|
|
181
|
+
let cappedFile = false;
|
|
182
|
+
for (const evt of parseJsonEvents(run.stdout)) {
|
|
183
|
+
if (evt["type"] !== "match")
|
|
184
|
+
continue;
|
|
185
|
+
const data = evt["data"];
|
|
186
|
+
if (typeof data !== "object" || data === null)
|
|
187
|
+
continue;
|
|
188
|
+
const rawPath = data["path"]?.text;
|
|
189
|
+
const dirRel = typeof rawPath === "string" ? toRel(rawPath) : null;
|
|
190
|
+
const rel = dirRel === null ? null : rgRelToCwdRel(absDir, cwd, dirRel);
|
|
191
|
+
if (!rel || !allowed.has(rel))
|
|
192
|
+
continue;
|
|
193
|
+
const lineNo = data["line_number"];
|
|
194
|
+
const lines = data["lines"];
|
|
195
|
+
if (typeof lineNo !== "number" || typeof lines?.text !== "string")
|
|
196
|
+
continue;
|
|
197
|
+
const list = perFile.get(rel) ?? [];
|
|
198
|
+
list.push({ line: Math.floor(lineNo), text: splitLine(lines.text) });
|
|
199
|
+
perFile.set(rel, list);
|
|
200
|
+
}
|
|
201
|
+
const counts = [];
|
|
202
|
+
const hits = [];
|
|
203
|
+
for (const rel of [...perFile.keys()].sort()) {
|
|
204
|
+
const list = perFile.get(rel);
|
|
205
|
+
// De-dupe multiple submatches on one line (walker counts LINES).
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
let n = 0;
|
|
208
|
+
for (const h of list) {
|
|
209
|
+
if (seen.has(h.line))
|
|
210
|
+
continue;
|
|
211
|
+
seen.add(h.line);
|
|
212
|
+
n += 1;
|
|
213
|
+
hits.push({ rel, line: h.line, text: h.text });
|
|
214
|
+
}
|
|
215
|
+
// max-count caps MATCHES per file: a full batch means that file may hold
|
|
216
|
+
// more (the walker's exactly-100 ambiguity behaves the same way).
|
|
217
|
+
if (list.length >= RG_CONTENT_MAX_COUNT)
|
|
218
|
+
cappedFile = true;
|
|
219
|
+
if (n > 0)
|
|
220
|
+
counts.push({ rel, n });
|
|
221
|
+
}
|
|
222
|
+
stats.uses += 1;
|
|
223
|
+
return { counts, hits, cappedFile };
|
|
224
|
+
}
|
|
225
|
+
// files_with_matches + count scan: per-file matching-LINE counts via
|
|
226
|
+
// `rg --count` (ripgrep --count semantics already match the walker's).
|
|
227
|
+
// files_with_matches derives its file set from these counts, so one spawn
|
|
228
|
+
// serves both modes. Caller applies totals/caps/recency.
|
|
229
|
+
export async function rgFileCounts(absDir, cwd, pattern, allowed) {
|
|
230
|
+
const run = await runRgSearch(absDir, [...BASE_ARGS, "--count"], pattern);
|
|
231
|
+
if (run.kind !== "ok")
|
|
232
|
+
return run.kind === "empty" ? { counts: [] } : null;
|
|
233
|
+
const counts = [];
|
|
234
|
+
for (const rawLine of run.stdout.split("\n")) {
|
|
235
|
+
const line = rawLine.trim();
|
|
236
|
+
if (!line)
|
|
237
|
+
continue;
|
|
238
|
+
// Right-split: Windows drive letters contain colons.
|
|
239
|
+
const idx = line.lastIndexOf(":");
|
|
240
|
+
if (idx <= 0)
|
|
241
|
+
continue;
|
|
242
|
+
const dirRel = toRel(line.slice(0, idx));
|
|
243
|
+
const rel = dirRel === null ? null : rgRelToCwdRel(absDir, cwd, dirRel);
|
|
244
|
+
const n = Number(line.slice(idx + 1));
|
|
245
|
+
if (!rel || !allowed.has(rel) || !Number.isFinite(n) || n <= 0)
|
|
246
|
+
continue;
|
|
247
|
+
counts.push({ rel, n: Math.floor(n) });
|
|
248
|
+
}
|
|
249
|
+
counts.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
250
|
+
stats.uses += 1;
|
|
251
|
+
return { counts };
|
|
252
|
+
}
|
|
253
|
+
// Record a fallback (rg answered nothing usable; walker takes over).
|
|
254
|
+
export function noteRgFallback() {
|
|
255
|
+
stats.fallbacks += 1;
|
|
256
|
+
}
|
package/dist/tools/search.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
// Read-only over the repo; node_modules/.git skipped by the walker.
|
|
3
3
|
import { promises as fsp } from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { listFiles } from "./dir-cache.js";
|
|
6
|
+
import { appendOverflow } from "./overflow.js";
|
|
7
|
+
import { noteRgFallback, rgAvailable, rgContentHits, rgFileCounts, rgMinFiles, } from "./ripgrep.js";
|
|
8
|
+
import { err, GLOB_MATCH_CAP, GREP_MATCH_CAP, invalidCall, READ_CHAR_CAP, resolveSandbox, truncateHead } from "./shared.js";
|
|
6
9
|
// Minimal glob matcher: supports **, **/, *, ?. Used for grep `include`
|
|
7
10
|
// and the glob tool. Patterns without a slash match the basename.
|
|
8
11
|
function globToRegExp(glob) {
|
|
@@ -46,25 +49,69 @@ function matchesGlob(pattern, relPosix) {
|
|
|
46
49
|
}
|
|
47
50
|
return globToRegExp(norm).test(relPosix);
|
|
48
51
|
}
|
|
49
|
-
async function walkFiles(absDir, cwd, out) {
|
|
50
|
-
const entries = await fsp.readdir(absDir, { withFileTypes: true });
|
|
51
|
-
for (const e of entries) {
|
|
52
|
-
if (SKIP_DIRS.has(e.name))
|
|
53
|
-
continue;
|
|
54
|
-
const full = path.join(absDir, e.name);
|
|
55
|
-
if (e.isDirectory()) {
|
|
56
|
-
await walkFiles(full, cwd, out);
|
|
57
|
-
}
|
|
58
|
-
else if (e.isFile()) {
|
|
59
|
-
out.push(path.relative(cwd, full).split(path.sep).join("/"));
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
52
|
export const GREP_OUTPUT_MODES = new Set([
|
|
64
53
|
"content",
|
|
65
54
|
"files_with_matches",
|
|
66
55
|
"count",
|
|
67
56
|
]);
|
|
57
|
+
// File-read fan-out for scans: bounded parallelism (32 in flight) over an
|
|
58
|
+
// ordered list, results in input order. Disk I/O overlaps instead of
|
|
59
|
+
// serializing one open/read/close at a time; the regex pass stays sequential
|
|
60
|
+
// afterwards so outputs and first-failure errors keep exact alpha order.
|
|
61
|
+
// 32 concurrent opens is far below EMFILE on every supported platform.
|
|
62
|
+
const SCAN_CONCURRENCY = 32;
|
|
63
|
+
async function mapLimit(items, limit, fn) {
|
|
64
|
+
const out = new Array(items.length);
|
|
65
|
+
let next = 0;
|
|
66
|
+
const workers = new Array(Math.min(Math.max(limit, 1), Math.max(items.length, 1)))
|
|
67
|
+
.fill(0)
|
|
68
|
+
.map(async () => {
|
|
69
|
+
for (;;) {
|
|
70
|
+
const i = next++;
|
|
71
|
+
if (i >= items.length)
|
|
72
|
+
return;
|
|
73
|
+
out[i] = await fn(items[i], i);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
await Promise.all(workers);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
// One hit line, shared by the walker and ripgrep scans so both paths stay
|
|
80
|
+
// byte-identical by construction (1-based line, 200-char trim).
|
|
81
|
+
export function formatGrepHit(rel, lineNo, line) {
|
|
82
|
+
return `${rel}:${lineNo}: ${line.length > 200 ? line.slice(0, 200) + "…" : line}`;
|
|
83
|
+
}
|
|
84
|
+
// ripgrep scan for one grep call: fills the same {counts, hits, hitsCapped}
|
|
85
|
+
// shape as the walker scan below (or null = run the walker). Content hits
|
|
86
|
+
// arrive merged in (file alpha, line) order; the 100-hit cap + note apply
|
|
87
|
+
// exactly like the walker path (including its take-100-blindly quirk).
|
|
88
|
+
async function scanWithRipgrep(absDir, cwd, pattern, mode, allowed) {
|
|
89
|
+
if (mode === "content") {
|
|
90
|
+
const r = await rgContentHits(absDir, cwd, pattern, allowed);
|
|
91
|
+
if (r === null)
|
|
92
|
+
return null;
|
|
93
|
+
if (r.cappedFile)
|
|
94
|
+
return null; // pathological volume: walker stays exact
|
|
95
|
+
const hits = r.hits.slice(0, GREP_MATCH_CAP).map((h) => formatGrepHit(h.rel, h.line, h.text));
|
|
96
|
+
return { counts: r.counts, hits, hitsCapped: r.hits.length >= GREP_MATCH_CAP };
|
|
97
|
+
}
|
|
98
|
+
const r = await rgFileCounts(absDir, cwd, pattern, allowed);
|
|
99
|
+
if (r === null)
|
|
100
|
+
return null;
|
|
101
|
+
return { counts: r.counts, hits: [], hitsCapped: false };
|
|
102
|
+
}
|
|
103
|
+
// Issue 04: every tool output passes the shared head-truncation contract.
|
|
104
|
+
// Search hits are already line-capped (100/200) far below the byte cap, so
|
|
105
|
+
// this is a byte-identical safety net that never fires on reachable outputs
|
|
106
|
+
// — grep/glob shape, ordering, modes, count notes, and the ripgrep/walker
|
|
107
|
+
// selection (issue 01) stay exactly as settled. If it ever fires, the head
|
|
108
|
+
// is line-aligned with total-vs-emitted counts plus the overflow pointer.
|
|
109
|
+
function capSearchOutput(out, label) {
|
|
110
|
+
if (out.length <= READ_CHAR_CAP)
|
|
111
|
+
return out;
|
|
112
|
+
const t = truncateHead(out, READ_CHAR_CAP, `\n[truncated: ${label} exceeded 64KB]`);
|
|
113
|
+
return appendOverflow(t.head, t.note, label, out);
|
|
114
|
+
}
|
|
68
115
|
// Best-effort mtime (ms) for recency sorting; 0 when the file cannot be
|
|
69
116
|
// stat'ed (keeps such entries last instead of failing the search).
|
|
70
117
|
async function mtimeMs(abs) {
|
|
@@ -75,6 +122,56 @@ async function mtimeMs(abs) {
|
|
|
75
122
|
return 0;
|
|
76
123
|
}
|
|
77
124
|
}
|
|
125
|
+
async function scanWithWalker(cwd, re, pattern, sorted, include, mode) {
|
|
126
|
+
const collectHits = mode === "content";
|
|
127
|
+
const bodies = await mapLimit(sorted, SCAN_CONCURRENCY, async (rel) => {
|
|
128
|
+
if (include && !matchesGlob(include, rel))
|
|
129
|
+
return null;
|
|
130
|
+
try {
|
|
131
|
+
const text = await fsp.readFile(path.resolve(cwd, rel), "utf8");
|
|
132
|
+
if (text.includes("\0"))
|
|
133
|
+
return null; // binary — skip
|
|
134
|
+
return { rel, text };
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null; // unreadable — skip
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
const counts = [];
|
|
141
|
+
const hits = [];
|
|
142
|
+
let hitsCapped = false;
|
|
143
|
+
for (const body of bodies) {
|
|
144
|
+
if (body === null)
|
|
145
|
+
continue;
|
|
146
|
+
if (collectHits && hitsCapped)
|
|
147
|
+
break;
|
|
148
|
+
const { rel, text } = body;
|
|
149
|
+
const lines = text.split("\n");
|
|
150
|
+
let n = 0;
|
|
151
|
+
for (let i = 0; i < lines.length; i++) {
|
|
152
|
+
let matched;
|
|
153
|
+
try {
|
|
154
|
+
matched = re.test(lines[i]);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return { error: err(`regex failed on input: ${pattern}`) };
|
|
158
|
+
}
|
|
159
|
+
// Reset lastIndex in case the pattern is global/sticky.
|
|
160
|
+
re.lastIndex = 0;
|
|
161
|
+
if (!matched)
|
|
162
|
+
continue;
|
|
163
|
+
n += 1;
|
|
164
|
+
if (collectHits && !hitsCapped) {
|
|
165
|
+
hits.push(formatGrepHit(rel, i + 1, lines[i]));
|
|
166
|
+
if (hits.length >= GREP_MATCH_CAP)
|
|
167
|
+
hitsCapped = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (n > 0)
|
|
171
|
+
counts.push({ rel, n });
|
|
172
|
+
}
|
|
173
|
+
return { counts, hits, hitsCapped };
|
|
174
|
+
}
|
|
78
175
|
// Line-regex search under dir (default "."). `include` is a glob like
|
|
79
176
|
// "*.ts". `outputMode` selects the shape (Claude-Code-style):
|
|
80
177
|
// - "content" (default): "file:line: text" lines, capped at 100 matches.
|
|
@@ -110,40 +207,36 @@ export async function grepTool(args, cwd = process.cwd()) {
|
|
|
110
207
|
}
|
|
111
208
|
if (!st.isDirectory())
|
|
112
209
|
return err(`not a directory: ${dir}`);
|
|
113
|
-
const files =
|
|
114
|
-
await walkFiles(r.abs, cwd, files);
|
|
210
|
+
const files = await listFiles(r.abs, cwd);
|
|
115
211
|
const include = typeof args.include === "string" && args.include.length > 0 ? args.include : null;
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
212
|
+
const sorted = files.sort();
|
|
213
|
+
// Allowed set shared by both scan paths (include filtering is identical
|
|
214
|
+
// either way, so ripgrep coverage matches the walker exactly).
|
|
215
|
+
const allowed = new Set(sorted.filter((rel) => !include || matchesGlob(include, rel)));
|
|
216
|
+
// ripgrep fast path: large scopes only (process-spawn cost), silent
|
|
217
|
+
// walker fallback on anything unusable (missing binary, bad exit,
|
|
218
|
+
// unparseable output, JS-only regex). See src/tools/ripgrep.ts.
|
|
219
|
+
let counts;
|
|
220
|
+
let hits;
|
|
221
|
+
let hitsCapped;
|
|
222
|
+
if (rgAvailable() && sorted.length >= rgMinFiles()) {
|
|
223
|
+
const fast = await scanWithRipgrep(r.abs, cwd, args.pattern, mode, allowed);
|
|
224
|
+
if (fast !== null) {
|
|
225
|
+
({ counts, hits, hitsCapped } = fast);
|
|
128
226
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!re.test(lines[i]))
|
|
136
|
-
continue;
|
|
137
|
-
n += 1;
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
return err(`regex failed on input: ${args.pattern}`);
|
|
141
|
-
}
|
|
142
|
-
// Reset lastIndex in case the pattern is global/sticky.
|
|
143
|
-
re.lastIndex = 0;
|
|
227
|
+
else {
|
|
228
|
+
noteRgFallback();
|
|
229
|
+
const walked = await scanWithWalker(cwd, re, args.pattern, sorted, include, mode);
|
|
230
|
+
if ("error" in walked)
|
|
231
|
+
return walked.error;
|
|
232
|
+
({ counts, hits, hitsCapped } = walked);
|
|
144
233
|
}
|
|
145
|
-
|
|
146
|
-
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const walked = await scanWithWalker(cwd, re, args.pattern, sorted, include, mode);
|
|
237
|
+
if ("error" in walked)
|
|
238
|
+
return walked.error;
|
|
239
|
+
({ counts, hits, hitsCapped } = walked);
|
|
147
240
|
}
|
|
148
241
|
if (counts.length === 0)
|
|
149
242
|
return "No matches.";
|
|
@@ -154,7 +247,7 @@ export async function grepTool(args, cwd = process.cwd()) {
|
|
|
154
247
|
let out = `Found ${withTime.length} file(s)\n${listed.join("\n")}`;
|
|
155
248
|
if (withTime.length > GREP_MATCH_CAP)
|
|
156
249
|
out += "\n[truncated: more than 100 matching files]";
|
|
157
|
-
return out;
|
|
250
|
+
return capSearchOutput(out, "grep results");
|
|
158
251
|
}
|
|
159
252
|
if (mode === "count") {
|
|
160
253
|
const listed = counts.slice(0, GREP_MATCH_CAP);
|
|
@@ -163,37 +256,12 @@ export async function grepTool(args, cwd = process.cwd()) {
|
|
|
163
256
|
`\nFound ${total} total match(es) across ${counts.length} file(s).`;
|
|
164
257
|
if (counts.length > GREP_MATCH_CAP)
|
|
165
258
|
out += "\n[truncated: more than 100 matching files]";
|
|
166
|
-
return out;
|
|
259
|
+
return capSearchOutput(out, "grep results");
|
|
167
260
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
text = await fsp.readFile(path.resolve(cwd, c.rel), "utf8");
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
continue; // vanished mid-search — skip
|
|
176
|
-
}
|
|
177
|
-
const lines = text.split("\n");
|
|
178
|
-
for (let i = 0; i < lines.length; i++) {
|
|
179
|
-
let line;
|
|
180
|
-
try {
|
|
181
|
-
if (!re.test(lines[i]))
|
|
182
|
-
continue;
|
|
183
|
-
line = lines[i];
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
return err(`regex failed on input: ${args.pattern}`);
|
|
187
|
-
}
|
|
188
|
-
// Reset lastIndex in case the pattern is global/sticky.
|
|
189
|
-
re.lastIndex = 0;
|
|
190
|
-
hits.push(`${c.rel}:${i + 1}: ${line.length > 200 ? line.slice(0, 200) + "…" : line}`);
|
|
191
|
-
if (hits.length >= GREP_MATCH_CAP) {
|
|
192
|
-
return hits.join("\n") + "\n[truncated: more than 100 matches]";
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
return hits.length > 0 ? hits.join("\n") : "No matches.";
|
|
261
|
+
// Content hits accumulated inline above (same order, same cap).
|
|
262
|
+
if (hitsCapped)
|
|
263
|
+
return capSearchOutput(hits.join("\n") + "\n[truncated: more than 100 matches]", "grep results");
|
|
264
|
+
return hits.length > 0 ? capSearchOutput(hits.join("\n"), "grep results") : "No matches.";
|
|
197
265
|
}
|
|
198
266
|
catch (e) {
|
|
199
267
|
return err(e instanceof Error ? e.message : String(e));
|
|
@@ -219,8 +287,7 @@ export async function globTool(args, cwd = process.cwd()) {
|
|
|
219
287
|
}
|
|
220
288
|
if (!st.isDirectory())
|
|
221
289
|
return err(`not a directory: ${dir}`);
|
|
222
|
-
const files =
|
|
223
|
-
await walkFiles(r.abs, cwd, files);
|
|
290
|
+
const files = await listFiles(r.abs, cwd);
|
|
224
291
|
const matched = files.filter((rel) => matchesGlob(args.pattern, rel));
|
|
225
292
|
const withTime = await Promise.all(matched.map(async (rel) => ({ rel, t: await mtimeMs(path.resolve(cwd, rel)) })));
|
|
226
293
|
withTime.sort((a, b) => b.t - a.t || (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
@@ -228,7 +295,7 @@ export async function globTool(args, cwd = process.cwd()) {
|
|
|
228
295
|
let out = capped.length > 0 ? capped.join("\n") : "No matches.";
|
|
229
296
|
if (withTime.length > GLOB_MATCH_CAP)
|
|
230
297
|
out += "\n[truncated: more than 200 matches]";
|
|
231
|
-
return out;
|
|
298
|
+
return capSearchOutput(out, "glob results");
|
|
232
299
|
}
|
|
233
300
|
catch (e) {
|
|
234
301
|
return err(e instanceof Error ? e.message : String(e));
|
package/dist/tools/shared.js
CHANGED
|
@@ -29,3 +29,42 @@ export function resolveSandbox(p, cwd = process.cwd()) {
|
|
|
29
29
|
return { error: "Error: invalid path" };
|
|
30
30
|
return { abs: path.resolve(cwd, p) };
|
|
31
31
|
}
|
|
32
|
+
export function truncateHead(full, maxChars, truncNote, maxLines = Number.MAX_SAFE_INTEGER) {
|
|
33
|
+
const text = typeof full === "string" ? full : "";
|
|
34
|
+
const cap = Math.max(1, Math.floor(maxChars));
|
|
35
|
+
const lineCap = Math.max(1, Math.floor(maxLines));
|
|
36
|
+
const totalChars = text.length;
|
|
37
|
+
const totalLines = text.length === 0 ? 0 : text.split("\n").length;
|
|
38
|
+
if (totalChars <= cap && totalLines <= lineCap) {
|
|
39
|
+
return {
|
|
40
|
+
head: text,
|
|
41
|
+
truncated: false,
|
|
42
|
+
totalChars,
|
|
43
|
+
emittedChars: totalChars,
|
|
44
|
+
totalLines,
|
|
45
|
+
emittedLines: totalLines,
|
|
46
|
+
note: "",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
let head = text;
|
|
50
|
+
if (totalChars > cap) {
|
|
51
|
+
const candidate = text.slice(0, cap);
|
|
52
|
+
const nl = candidate.lastIndexOf("\n");
|
|
53
|
+
// nl <= 0 means no usable boundary (single giant line, or the text
|
|
54
|
+
// starts with "\n"): keep the hard cut — the documented tail edge case.
|
|
55
|
+
head = nl > 0 ? candidate.slice(0, nl) : candidate;
|
|
56
|
+
}
|
|
57
|
+
if (totalLines > lineCap) {
|
|
58
|
+
const lineHead = text.split("\n").slice(0, lineCap).join("\n");
|
|
59
|
+
if (lineHead.length < head.length)
|
|
60
|
+
head = lineHead;
|
|
61
|
+
}
|
|
62
|
+
const emittedChars = head.length;
|
|
63
|
+
const emittedLines = head.length === 0 ? 0 : head.split("\n").length;
|
|
64
|
+
const detail = `showing ${emittedChars} of ${totalChars} chars ` +
|
|
65
|
+
`(${emittedLines} of ${totalLines} lines)`;
|
|
66
|
+
const note = truncNote.endsWith("]")
|
|
67
|
+
? `${truncNote.slice(0, -1)}; ${detail}]`
|
|
68
|
+
: `${truncNote} [${detail}]`;
|
|
69
|
+
return { head, truncated: true, totalChars, emittedChars, totalLines, emittedLines, note };
|
|
70
|
+
}
|
package/dist/tools/shell.js
CHANGED
|
@@ -9,8 +9,9 @@ import * as os from "node:os";
|
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { scrubSecrets } from "../policy.js";
|
|
11
11
|
import { PROVIDERS } from "../providers.js";
|
|
12
|
+
import { clearDirListingCache } from "./dir-cache.js";
|
|
12
13
|
import { appendOverflow } from "./overflow.js";
|
|
13
|
-
import { err, OUTPUT_CAP } from "./shared.js";
|
|
14
|
+
import { err, OUTPUT_CAP, truncateHead } from "./shared.js";
|
|
14
15
|
// ---- Background bash tasks (Claude-Code-style run_in_background) ----
|
|
15
16
|
const BG_TASK_CAP = 20;
|
|
16
17
|
const BG_POLL_MS = 100;
|
|
@@ -120,6 +121,16 @@ async function startBackgroundBash(command, cwd) {
|
|
|
120
121
|
rec.exitCode = typeof code === "number" ? code : 1;
|
|
121
122
|
});
|
|
122
123
|
child.unref();
|
|
124
|
+
// A spawned command can touch anything (files, trees, checkouts): the
|
|
125
|
+
// directory-listing cache cannot know what changed, so drop it all. Cheap
|
|
126
|
+
// (next search rescans once) and exactly correct for tool-driven flows;
|
|
127
|
+
// only out-of-process edits stay TTL-bound.
|
|
128
|
+
try {
|
|
129
|
+
clearDirListingCache();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// never break the tool
|
|
133
|
+
}
|
|
123
134
|
return JSON.stringify({ backgroundTaskId: id, status: "running", hint: "use bash_output to poll" });
|
|
124
135
|
}
|
|
125
136
|
catch (e) {
|
|
@@ -151,8 +162,8 @@ function capBgStream(s, which) {
|
|
|
151
162
|
// bash_output passes through here.
|
|
152
163
|
const clean = scrubSecrets(s, providerSecrets());
|
|
153
164
|
if (clean.length > OUTPUT_CAP) {
|
|
154
|
-
const
|
|
155
|
-
return appendOverflow(
|
|
165
|
+
const t = truncateHead(clean, OUTPUT_CAP, `\n[truncated: ${which} exceeded 8KB]`);
|
|
166
|
+
return appendOverflow(t.head, t.note, `background ${which}`, clean);
|
|
156
167
|
}
|
|
157
168
|
return clean;
|
|
158
169
|
}
|
|
@@ -213,6 +224,14 @@ export function bashTool(args, cwd = process.cwd()) {
|
|
|
213
224
|
const timeoutMs = Math.min(Math.max(Math.floor(args.timeoutMs ?? 60000), 1), 120000);
|
|
214
225
|
return new Promise((resolve) => {
|
|
215
226
|
exec(args.command, { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
227
|
+
// The command ran (whatever its exit): it may have mutated the tree,
|
|
228
|
+
// so the listing cache is dropped (see background path above).
|
|
229
|
+
try {
|
|
230
|
+
clearDirListingCache();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// never break the tool
|
|
234
|
+
}
|
|
216
235
|
try {
|
|
217
236
|
const e = error;
|
|
218
237
|
const exitCode = e ? (typeof e.code === "number" ? e.code : 1) : 0;
|
|
@@ -229,12 +248,14 @@ export function bashTool(args, cwd = process.cwd()) {
|
|
|
229
248
|
let stderrTruncated = false;
|
|
230
249
|
if (out.length > OUTPUT_CAP) {
|
|
231
250
|
const full = out;
|
|
232
|
-
|
|
251
|
+
const t = truncateHead(full, OUTPUT_CAP, "\n[truncated: stdout exceeded 8KB]");
|
|
252
|
+
out = appendOverflow(t.head, t.note, "command stdout", full);
|
|
233
253
|
stdoutTruncated = true;
|
|
234
254
|
}
|
|
235
255
|
if (errText.length > OUTPUT_CAP) {
|
|
236
256
|
const full = errText;
|
|
237
|
-
|
|
257
|
+
const t = truncateHead(full, OUTPUT_CAP, "\n[truncated: stderr exceeded 8KB]");
|
|
258
|
+
errText = appendOverflow(t.head, t.note, "command stderr", full);
|
|
238
259
|
stderrTruncated = true;
|
|
239
260
|
}
|
|
240
261
|
resolve(JSON.stringify({
|
package/dist/tools/todo.js
CHANGED
|
@@ -20,7 +20,7 @@ export function clearTodos() {
|
|
|
20
20
|
function renderTodos(items) {
|
|
21
21
|
if (items.length === 0)
|
|
22
22
|
return "Todo list is empty.";
|
|
23
|
-
const mark = (s) => s === "completed" ? "✅" : s === "in_progress" ? "🔧" : "
|
|
23
|
+
const mark = (s) => s === "completed" ? "✅" : s === "in_progress" ? "🔧" : "○";
|
|
24
24
|
return (`Todo list (${items.length}):\n` +
|
|
25
25
|
items
|
|
26
26
|
.map((t, i) => `${i + 1}. ${mark(t.status)} [${t.status}] ${t.content}${t.priority ? ` (${t.priority})` : ""}`)
|