mixdog 0.9.150 → 0.9.151
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/README.md +28 -11
- package/package.json +1 -1
- package/src/headless-exec.mjs +16 -3
- package/src/headless-exec.test.mjs +44 -1
- package/src/rules/shared/30-exploration.md +2 -1
- package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -20
- package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -16
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +28 -0
- package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +33 -0
- package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +26 -6
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +7 -66
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +11 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
- package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
- package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
- package/src/runtime/memory/index.mjs +2 -4
- package/src/runtime/memory/lib/pg/process.mjs +29 -2
- package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
- package/src/runtime/memory/lib/query-handlers.mjs +7 -6
- package/src/runtime/memory/lib/recall-format.mjs +13 -31
- package/src/runtime/shared/pristine-execution-contract.json +3 -0
- package/src/runtime/shared/pristine-execution.mjs +5 -2
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
- package/src/standalone/channel-worker.mjs +31 -1
- package/src/standalone/memory-runtime-proxy.mjs +5 -2
- package/src/standalone/session-client.mjs +23 -3
- package/src/tui/session/turn.mjs +12 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Single-FILE grep rescue: scan the one explicitly targeted file in JS when
|
|
2
|
+
// the native search server cannot answer it.
|
|
3
|
+
//
|
|
4
|
+
// A file operand has no tree to walk — the server scans it directly — so a
|
|
5
|
+
// deadline reached on a file scope is never about this file. It is queue
|
|
6
|
+
// starvation: the resident server is shared, and one scope rooted in a virtual
|
|
7
|
+
// filesystem (/proc, /sys) walks a tree that cannot finish inside any budget
|
|
8
|
+
// and holds the queue while it burns. Measured in the wild: three /sys glob
|
|
9
|
+
// reads spent 17.5s each, the grep behind them failed after 17.7s on a
|
|
10
|
+
// six-line /proc/self/status, and a plain read of that same file answered in
|
|
11
|
+
// 7ms three seconds later.
|
|
12
|
+
//
|
|
13
|
+
// Reading one file is bounded work, so the call answers instead of spending a
|
|
14
|
+
// whole turn on someone else's walk. Anything this scanner cannot reproduce
|
|
15
|
+
// exactly — multiline patterns, type filters, a PCRE-only regex, a binary or
|
|
16
|
+
// oversized file — declines (returns null) and the caller keeps the original
|
|
17
|
+
// native error.
|
|
18
|
+
import { readFile } from 'node:fs/promises';
|
|
19
|
+
|
|
20
|
+
import { formatGrepContextOutput, formatGrepOutput } from './grep-output.mjs';
|
|
21
|
+
|
|
22
|
+
/** Reading is the whole point of the rescue, so the file must stay small
|
|
23
|
+
* enough that reading it is cheaper than the failed search. */
|
|
24
|
+
export const MAX_RESCUE_BYTES = 8 * 1024 * 1024;
|
|
25
|
+
/** Bounds memory on a pathological match rate; a truncated window is reported
|
|
26
|
+
* as incomplete, exactly like a capped native result. */
|
|
27
|
+
const MAX_RESCUE_LINES = 5_000;
|
|
28
|
+
|
|
29
|
+
function compilePatterns(patterns, caseInsensitive) {
|
|
30
|
+
const flags = caseInsensitive ? 'i' : '';
|
|
31
|
+
const compiled = [];
|
|
32
|
+
for (const pattern of patterns) {
|
|
33
|
+
try {
|
|
34
|
+
compiled.push(new RegExp(String(pattern), flags));
|
|
35
|
+
} catch {
|
|
36
|
+
// A PCRE-only construct (lookbehind variants, \p classes, inline
|
|
37
|
+
// flags) has no faithful JS twin: decline rather than answer with
|
|
38
|
+
// different semantics than the caller asked for.
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return compiled.length > 0 ? compiled : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function runGrepSingleFileRescue({
|
|
46
|
+
filePath,
|
|
47
|
+
searchPath,
|
|
48
|
+
patterns,
|
|
49
|
+
caseInsensitive,
|
|
50
|
+
multilineMode,
|
|
51
|
+
onlyMatching,
|
|
52
|
+
fileType,
|
|
53
|
+
outputMode,
|
|
54
|
+
showLineNumbers,
|
|
55
|
+
withFilename,
|
|
56
|
+
filenameOmitted,
|
|
57
|
+
beforeN,
|
|
58
|
+
afterN,
|
|
59
|
+
contextN,
|
|
60
|
+
headLimit,
|
|
61
|
+
offset,
|
|
62
|
+
workDir,
|
|
63
|
+
grepResolvedPath,
|
|
64
|
+
patternCapNote = '',
|
|
65
|
+
globPatterns = [],
|
|
66
|
+
}) {
|
|
67
|
+
if (multilineMode || fileType) return null;
|
|
68
|
+
const list = (Array.isArray(patterns) ? patterns : [patterns]).filter(Boolean);
|
|
69
|
+
const compiled = compilePatterns(list, caseInsensitive);
|
|
70
|
+
if (!compiled) return null;
|
|
71
|
+
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = await readFile(filePath);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (raw.length > MAX_RESCUE_BYTES || raw.includes(0)) return null;
|
|
79
|
+
const text = raw.toString('utf8');
|
|
80
|
+
const fileLines = text.split(/\r?\n/);
|
|
81
|
+
if (fileLines.length > 0 && fileLines[fileLines.length - 1] === '') fileLines.pop();
|
|
82
|
+
|
|
83
|
+
const matched = [];
|
|
84
|
+
for (let index = 0; index < fileLines.length; index += 1) {
|
|
85
|
+
if (compiled.some((re) => re.test(fileLines[index]))) matched.push(index);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const pathPrefix = (separator) => (withFilename ? `${searchPath}${separator}` : '');
|
|
89
|
+
const numberPrefix = (index, separator) => (showLineNumbers ? `${index + 1}${separator}` : '');
|
|
90
|
+
|
|
91
|
+
if (outputMode === 'files_with_matches') {
|
|
92
|
+
const windowed = matched.length > 0 ? [String(searchPath)] : [];
|
|
93
|
+
return renderPlain({
|
|
94
|
+
windowed, patternCapNote, outputMode, headLimit, offset, searchPath,
|
|
95
|
+
grepResolvedPath, workDir, globPatterns, filenameOmitted: false,
|
|
96
|
+
beforeN, afterN, contextN, patterns: list,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (outputMode === 'count') {
|
|
100
|
+
const windowed = matched.length > 0
|
|
101
|
+
? [`${pathPrefix(':')}${matched.length}`]
|
|
102
|
+
: [];
|
|
103
|
+
return renderPlain({
|
|
104
|
+
windowed, patternCapNote, outputMode, headLimit, offset, searchPath,
|
|
105
|
+
grepResolvedPath, workDir, globPatterns, filenameOmitted,
|
|
106
|
+
beforeN, afterN, contextN, patterns: list,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const before = Math.max(Number(beforeN) || 0, Number(contextN) || 0);
|
|
111
|
+
const after = Math.max(Number(afterN) || 0, Number(contextN) || 0);
|
|
112
|
+
const hasContext = before > 0 || after > 0;
|
|
113
|
+
const isMatch = new Set(matched);
|
|
114
|
+
const emit = new Set();
|
|
115
|
+
for (const index of matched) {
|
|
116
|
+
for (let i = Math.max(0, index - before); i <= Math.min(fileLines.length - 1, index + after); i += 1) {
|
|
117
|
+
emit.add(i);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const ordered = [...emit].sort((left, right) => left - right);
|
|
121
|
+
const lines = [];
|
|
122
|
+
let previous = -1;
|
|
123
|
+
let truncated = false;
|
|
124
|
+
for (const index of ordered) {
|
|
125
|
+
if (lines.length >= MAX_RESCUE_LINES) {
|
|
126
|
+
truncated = true;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
// rg separates non-adjacent context blocks; the context formatter reads
|
|
130
|
+
// the same marker.
|
|
131
|
+
if (hasContext && previous >= 0 && index > previous + 1) lines.push('--');
|
|
132
|
+
previous = index;
|
|
133
|
+
const line = fileLines[index];
|
|
134
|
+
if (isMatch.has(index)) {
|
|
135
|
+
if (onlyMatching) {
|
|
136
|
+
for (const re of compiled) {
|
|
137
|
+
const global = new RegExp(re.source, `${re.flags}g`);
|
|
138
|
+
for (const found of line.matchAll(global)) {
|
|
139
|
+
lines.push(`${pathPrefix(':')}${numberPrefix(index, ':')}${found[0]}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
lines.push(`${pathPrefix(':')}${numberPrefix(index, ':')}${line}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
lines.push(`${pathPrefix('-')}${numberPrefix(index, '-')}${line}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (hasContext) {
|
|
151
|
+
const body = formatGrepContextOutput({
|
|
152
|
+
allLines: lines,
|
|
153
|
+
workDir,
|
|
154
|
+
outputMode,
|
|
155
|
+
filenameOmitted,
|
|
156
|
+
headLimit,
|
|
157
|
+
offset,
|
|
158
|
+
searchPath,
|
|
159
|
+
totalKnown: !truncated,
|
|
160
|
+
});
|
|
161
|
+
return withRescueNotice(patternCapNote + (body.text || noMatchBody({
|
|
162
|
+
patterns: list, searchPath, globPatterns,
|
|
163
|
+
})));
|
|
164
|
+
}
|
|
165
|
+
return renderPlain({
|
|
166
|
+
windowed: offset > 0 ? lines.slice(offset) : lines,
|
|
167
|
+
patternCapNote,
|
|
168
|
+
outputMode,
|
|
169
|
+
headLimit,
|
|
170
|
+
offset,
|
|
171
|
+
searchPath,
|
|
172
|
+
grepResolvedPath,
|
|
173
|
+
workDir,
|
|
174
|
+
globPatterns,
|
|
175
|
+
filenameOmitted,
|
|
176
|
+
beforeN,
|
|
177
|
+
afterN,
|
|
178
|
+
contextN,
|
|
179
|
+
patterns: list,
|
|
180
|
+
totalKnown: !truncated,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function noMatchBody({ patterns, searchPath, globPatterns }) {
|
|
185
|
+
const patternStr = patterns.length === 1 ? JSON.stringify(patterns[0]) : JSON.stringify(patterns);
|
|
186
|
+
const globStr = globPatterns.length > 0 ? ` glob=${JSON.stringify(globPatterns)}` : '';
|
|
187
|
+
return `(no matches) pattern=${patternStr} path=${searchPath}${globStr}; path exists (file)`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function withRescueNotice(body) {
|
|
191
|
+
return `${body}\n[notice] native search could not serve this file scope; the file was scanned directly.`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderPlain({
|
|
195
|
+
windowed, patternCapNote, outputMode, headLimit, offset, searchPath, grepResolvedPath,
|
|
196
|
+
workDir, globPatterns, filenameOmitted, beforeN, afterN, contextN, patterns,
|
|
197
|
+
totalKnown = true,
|
|
198
|
+
}) {
|
|
199
|
+
const body = formatGrepOutput({
|
|
200
|
+
windowed,
|
|
201
|
+
totalWindowed: windowed.length,
|
|
202
|
+
totalKnown,
|
|
203
|
+
headLimit,
|
|
204
|
+
offset,
|
|
205
|
+
outputMode,
|
|
206
|
+
patterns,
|
|
207
|
+
beforeN,
|
|
208
|
+
afterN,
|
|
209
|
+
contextN,
|
|
210
|
+
searchPath,
|
|
211
|
+
grepResolvedPath,
|
|
212
|
+
workDir,
|
|
213
|
+
globPatterns,
|
|
214
|
+
filenameOmitted,
|
|
215
|
+
includeMatchCount: false,
|
|
216
|
+
}) || noMatchBody({ patterns, searchPath, globPatterns });
|
|
217
|
+
return withRescueNotice(patternCapNote + body);
|
|
218
|
+
}
|
|
@@ -1038,7 +1038,40 @@ async function getTargetedFindEnumeration({
|
|
|
1038
1038
|
// list via `rg --files`, then rank by subsequence score. `list.fuzzy` still
|
|
1039
1039
|
// routes here for hidden backward compatibility, but the model-facing tool is
|
|
1040
1040
|
// `find`.
|
|
1041
|
+
// A pruned tree cannot report what it never enumerated. Dependency and cache
|
|
1042
|
+
// directories are skipped by DEFAULT, so a file that exists only inside one —
|
|
1043
|
+
// a __pycache__ artifact, a vendored source, a build cache — came back as
|
|
1044
|
+
// "(no fuzzy match for X)", which reads as PROVEN ABSENCE and ends the search.
|
|
1045
|
+
// A second, noise-including pass turns that into the truth, but the default
|
|
1046
|
+
// answer still may not list dependency paths (that is the point of the
|
|
1047
|
+
// default), so the miss keeps its body and gains the one fact it was missing:
|
|
1048
|
+
// a match exists, and which flag surfaces it. Only a CLEAN miss retries — a
|
|
1049
|
+
// partial or timed-out pass says "no fuzzy match YET" and is already telling
|
|
1050
|
+
// the caller to narrow, so it never pays for a second walk.
|
|
1041
1051
|
export async function executeFuzzyFindTool(args, workDir, options = {}) {
|
|
1052
|
+
const startedAt = performance.now();
|
|
1053
|
+
const result = await runFuzzyFindPass(args, workDir, options);
|
|
1054
|
+
const text = String(result ?? '');
|
|
1055
|
+
if (!/^\(no fuzzy match for /.test(text)) return result;
|
|
1056
|
+
if (args?.include_noise === true || options?._findNoiseWidened === true) return result;
|
|
1057
|
+
// The retry walks the same tree again. A miss that was already expensive
|
|
1058
|
+
// pays for itself twice, so only a cheap pass earns the second look.
|
|
1059
|
+
if (performance.now() - startedAt > FIND_NOISE_WIDEN_BUDGET_MS) return result;
|
|
1060
|
+
const widened = String(await runFuzzyFindPass(
|
|
1061
|
+
{ ...args, include_noise: true },
|
|
1062
|
+
workDir,
|
|
1063
|
+
{ ...options, _findNoiseWidened: true },
|
|
1064
|
+
) ?? '');
|
|
1065
|
+
if (!widened || /^\(no fuzzy match/.test(widened)) return result;
|
|
1066
|
+
const hits = widened.split('\n').filter((line) => line && !line.startsWith('... [') && !line.startsWith('[')).length;
|
|
1067
|
+
return `${text}\n[notice] ${hits} match${hits === 1 ? '' : 'es'} exist inside dependency/cache trees the default scan skips`
|
|
1068
|
+
+ ' — pass include_noise:true to list them.';
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** A miss cheaper than this earns the one widening retry below. */
|
|
1072
|
+
const FIND_NOISE_WIDEN_BUDGET_MS = 2_000;
|
|
1073
|
+
|
|
1074
|
+
async function runFuzzyFindPass(args, workDir, options = {}) {
|
|
1042
1075
|
const query = String(args.query ?? args.fuzzy ?? '').trim();
|
|
1043
1076
|
if (!query) return 'Error: find requires query.';
|
|
1044
1077
|
const inputPath = normalizeInputPath(args.path) || '.';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Dependency/cache trees stay hidden by default, but a caller who NAMES one is
|
|
2
|
+
// asking for it, and a clean fuzzy miss must not report a file that exists as
|
|
3
|
+
// absent.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
|
|
10
|
+
import { executeBuiltinTool } from '../builtin.mjs';
|
|
11
|
+
|
|
12
|
+
async function withNoiseTree(run) {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), 'mixdog-noise-vis-'));
|
|
14
|
+
try {
|
|
15
|
+
await mkdir(join(root, '__pycache__'), { recursive: true });
|
|
16
|
+
await mkdir(join(root, 'src'), { recursive: true });
|
|
17
|
+
await writeFile(join(root, '__pycache__', 'convert_masks.cpython-311.pyc'), 'cached MARKER blob\n');
|
|
18
|
+
await writeFile(join(root, 'src', 'app.py'), 'print("hello")\n');
|
|
19
|
+
return await run(root);
|
|
20
|
+
} finally {
|
|
21
|
+
await rm(root, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('a glob that names a pruned directory searches inside it', async () => {
|
|
26
|
+
await withNoiseTree(async (root) => {
|
|
27
|
+
const named = String(await executeBuiltinTool('glob', { pattern: '__pycache__/*.pyc' }, root));
|
|
28
|
+
assert.match(named, /convert_masks\.cpython-311\.pyc/);
|
|
29
|
+
|
|
30
|
+
// Unnamed: the default prune still applies, so noise stays out of a
|
|
31
|
+
// broad sweep.
|
|
32
|
+
const broad = String(await executeBuiltinTool('glob', { pattern: '**/*.pyc' }, root));
|
|
33
|
+
assert.match(broad, /no files found/);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a grep glob filter that names a pruned directory searches inside it', async () => {
|
|
38
|
+
await withNoiseTree(async (root) => {
|
|
39
|
+
const out = String(await executeBuiltinTool('grep', {
|
|
40
|
+
pattern: 'MARKER',
|
|
41
|
+
path: '.',
|
|
42
|
+
glob: '__pycache__/*.pyc',
|
|
43
|
+
mode: 'files',
|
|
44
|
+
}, root));
|
|
45
|
+
assert.match(out, /convert_masks/);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a clean fuzzy miss reports that the hit exists in a skipped tree', async () => {
|
|
50
|
+
await withNoiseTree(async (root) => {
|
|
51
|
+
const out = String(await executeBuiltinTool('find', { query: 'convert_masks' }, root));
|
|
52
|
+
assert.match(out, /no fuzzy match/);
|
|
53
|
+
assert.match(out, /include_noise:true/);
|
|
54
|
+
// The default answer still lists no dependency path.
|
|
55
|
+
assert.doesNotMatch(out, /convert_masks\.cpython-311\.pyc/);
|
|
56
|
+
|
|
57
|
+
// The flag it names actually produces the file.
|
|
58
|
+
const noisy = String(await executeBuiltinTool('find', {
|
|
59
|
+
query: 'convert_masks',
|
|
60
|
+
include_noise: true,
|
|
61
|
+
}, root));
|
|
62
|
+
assert.match(noisy, /convert_masks\.cpython-311\.pyc/);
|
|
63
|
+
|
|
64
|
+
// A query with no file behind it stays a plain miss.
|
|
65
|
+
const missing = String(await executeBuiltinTool('find', { query: 'no_such_artifact_here' }, root));
|
|
66
|
+
assert.match(missing, /no fuzzy match/);
|
|
67
|
+
assert.doesNotMatch(missing, /include_noise:true/);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -61,20 +61,36 @@ export async function listSiblingsAsync(dir, limit = 12) {
|
|
|
61
61
|
// and hard-capped on directories scanned, and returns up to `limit` real
|
|
62
62
|
// locations so the error can name them directly. Pure best-effort; any error
|
|
63
63
|
// returns [].
|
|
64
|
+
// A directory-count cap is not a time cap: on a slow or synthetic tree each
|
|
65
|
+
// readdir costs milliseconds, and one missing path burned 17.5s of a turn in
|
|
66
|
+
// this BFS — three times in a row — before the caller ever saw its answer.
|
|
67
|
+
// The hint is an optional courtesy, so it gets a wall-clock budget and reports
|
|
68
|
+
// an out-of-time walk as inconclusive rather than as a proven absence.
|
|
69
|
+
const BASENAME_SCAN_DEADLINE_MS = 750;
|
|
64
70
|
const BASENAME_SCAN_SKIP_DIRS = new Set([
|
|
65
71
|
'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out',
|
|
66
72
|
'coverage', '.next', '.nuxt', '.turbo', '.cache', 'vendor',
|
|
67
73
|
'target', '.venv', 'venv', '__pycache__', '.idea', '.vscode',
|
|
68
74
|
]);
|
|
69
|
-
export function findFileByBasename(
|
|
75
|
+
export function findFileByBasename(
|
|
76
|
+
searchRoot,
|
|
77
|
+
fullPath,
|
|
78
|
+
{ limit = 3, maxDirs = 6000, deadlineMs = BASENAME_SCAN_DEADLINE_MS } = {},
|
|
79
|
+
) {
|
|
70
80
|
try {
|
|
71
81
|
if (typeof searchRoot !== 'string' || !searchRoot) return [];
|
|
72
82
|
const target = basename(fullPath).toLowerCase();
|
|
73
83
|
if (!target) return [];
|
|
74
84
|
const matches = [];
|
|
75
85
|
const queue = [searchRoot];
|
|
86
|
+
const stopAt = Date.now() + Math.max(1, deadlineMs);
|
|
76
87
|
let scanned = 0;
|
|
88
|
+
let expired = false;
|
|
77
89
|
while (queue.length && matches.length < limit && scanned < maxDirs) {
|
|
90
|
+
if (Date.now() >= stopAt) {
|
|
91
|
+
expired = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
78
94
|
const dir = queue.shift();
|
|
79
95
|
scanned++;
|
|
80
96
|
let entries;
|
|
@@ -96,9 +112,10 @@ export function findFileByBasename(searchRoot, fullPath, { limit = 3, maxDirs =
|
|
|
96
112
|
}
|
|
97
113
|
}
|
|
98
114
|
}
|
|
99
|
-
// Queue drained without hitting maxDirs/limit → the walk
|
|
100
|
-
// whole (non-vendor, non-hidden) tree; a miss is
|
|
101
|
-
|
|
115
|
+
// Queue drained without hitting maxDirs/limit/deadline → the walk
|
|
116
|
+
// covered the whole (non-vendor, non-hidden) tree; a miss is
|
|
117
|
+
// conclusive. A walk that ran out of time proves nothing.
|
|
118
|
+
matches.exhaustive = queue.length === 0 && !expired;
|
|
102
119
|
return matches;
|
|
103
120
|
} catch { return []; }
|
|
104
121
|
}
|
|
@@ -146,15 +163,25 @@ export function findBySuffixStrip(searchRoot, fullPath, { maxIterations = 12 } =
|
|
|
146
163
|
|
|
147
164
|
// Same as findFileByBasename but for directories sharing the missing path's
|
|
148
165
|
// final segment name (e.g. guessed `src/shared` → the real `lib/shared`).
|
|
149
|
-
export function findDirectoryByBasename(
|
|
166
|
+
export function findDirectoryByBasename(
|
|
167
|
+
searchRoot,
|
|
168
|
+
fullPath,
|
|
169
|
+
{ limit = 3, maxDirs = 6000, deadlineMs = BASENAME_SCAN_DEADLINE_MS } = {},
|
|
170
|
+
) {
|
|
150
171
|
try {
|
|
151
172
|
if (typeof searchRoot !== 'string' || !searchRoot) return [];
|
|
152
173
|
const target = basename(fullPath).toLowerCase();
|
|
153
174
|
if (!target) return [];
|
|
154
175
|
const matches = [];
|
|
155
176
|
const queue = [searchRoot];
|
|
177
|
+
const stopAt = Date.now() + Math.max(1, deadlineMs);
|
|
156
178
|
let scanned = 0;
|
|
179
|
+
let expired = false;
|
|
157
180
|
while (queue.length && matches.length < limit && scanned < maxDirs) {
|
|
181
|
+
if (Date.now() >= stopAt) {
|
|
182
|
+
expired = true;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
158
185
|
const dir = queue.shift();
|
|
159
186
|
scanned++;
|
|
160
187
|
let entries;
|
|
@@ -176,7 +203,7 @@ export function findDirectoryByBasename(searchRoot, fullPath, { limit = 3, maxDi
|
|
|
176
203
|
}
|
|
177
204
|
}
|
|
178
205
|
}
|
|
179
|
-
matches.exhaustive = queue.length === 0;
|
|
206
|
+
matches.exhaustive = queue.length === 0 && !expired;
|
|
180
207
|
return matches;
|
|
181
208
|
} catch { return []; }
|
|
182
209
|
}
|
|
@@ -10,6 +10,16 @@ import { coerceReadFamilyPathArg, hasGlobMagic } from './path-utils.mjs';
|
|
|
10
10
|
import { readIoAdmission } from '../../../../shared/tool-workload-gates.mjs';
|
|
11
11
|
import { currentToolExecutionOwner } from '../../../../shared/tool-execution-owner.mjs';
|
|
12
12
|
|
|
13
|
+
// Per-entry status in a batch. A conclusively missing file is not a failed
|
|
14
|
+
// read — that is why its body is `[path absent]` and not `Error:` — but it is
|
|
15
|
+
// not `ok` either: the header said `missing.png [ok]` above a body saying the
|
|
16
|
+
// file does not exist, which is the one line a skimming reader trusts. Absence
|
|
17
|
+
// gets its own tag so the header and the body agree.
|
|
18
|
+
function batchEntryStatus(body, failed, textBody) {
|
|
19
|
+
if (failed) return 'error';
|
|
20
|
+
return /^\s*\[path absent\]/.test(String(textBody ?? body ?? '')) ? 'absent' : 'ok';
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
function hasLineCoordinate(path) {
|
|
14
24
|
return typeof path === 'string' && /(?:#L\d+|:\d+(?:-\d+)?(?::|$))/i.test(path);
|
|
15
25
|
}
|
|
@@ -538,12 +548,18 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
538
548
|
// reject_partial:true — when the caller asked for all-or-none,
|
|
539
549
|
// refuse to return a mixed payload that downstream parsers
|
|
540
550
|
// would have to disambiguate per-entry.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
551
|
+
// All-or-none is about DELIVERY: an entry that returned no content did
|
|
552
|
+
// not deliver, whether it failed outright or was conclusively absent.
|
|
553
|
+
// Counting only hard failures let a batch with a missing file through
|
|
554
|
+
// as a mixed payload — exactly what the caller asked not to receive.
|
|
555
|
+
const undelivered = orderedResults.filter(
|
|
556
|
+
(r) => batchEntryStatus(r.body, bodyFailed(r.body), bodyTextFor(r.body)) !== 'ok',
|
|
557
|
+
);
|
|
558
|
+
if (undelivered.length > 0 && args.reject_partial === true) {
|
|
559
|
+
const reasons = undelivered
|
|
544
560
|
.map((r) => `${normalizeOutputPath(r.path)}: ${bodyTextFor(r.body).split('\n')[0] || 'structured media read failed'}`)
|
|
545
561
|
.join('; ');
|
|
546
|
-
return `Error: batch read rejected (${
|
|
562
|
+
return `Error: batch read rejected (${undelivered.length} of ${orderedResults.length} failed; reject_partial:true) — ${reasons}`;
|
|
547
563
|
}
|
|
548
564
|
// Default: surface per-entry status tags ([ok]/[error]) so a
|
|
549
565
|
// downstream classifyResultKind treats the aggregate as a
|
|
@@ -564,8 +580,8 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
564
580
|
const mode = r.n !== undefined ? `${r.mode} n=${r.n}` : r.mode;
|
|
565
581
|
// Default full mode carries no information — tag only non-default modes.
|
|
566
582
|
const modeTag = mode && mode !== 'full' ? ` [${mode}]` : '';
|
|
567
|
-
const status = bodyFailed(r.body) ? 'error' : 'ok';
|
|
568
583
|
const textBody = bodyTextFor(r.body);
|
|
584
|
+
const status = batchEntryStatus(r.body, bodyFailed(r.body), textBody);
|
|
569
585
|
const match = /\[TRUNCATED (?:—|-) file is (\d+) lines \/ (\d+) KB\./.exec(textBody);
|
|
570
586
|
const suffix = match ? ` (truncated ${match[1]}L/${match[2]}KB)` : '';
|
|
571
587
|
const entryHeader = `${path}${modeTag} [${status}]${suffix}`;
|
|
@@ -608,7 +624,11 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
608
624
|
const path = normalizeOutputPath(r.path);
|
|
609
625
|
const mode = r.n !== undefined ? `${r.mode} n=${r.n}` : r.mode;
|
|
610
626
|
const modeTag = mode && mode !== 'full' ? ` [${mode}]` : '';
|
|
611
|
-
const status =
|
|
627
|
+
const status = batchEntryStatus(
|
|
628
|
+
r.body,
|
|
629
|
+
classifyResultKind(String(r.body || '')) === 'error',
|
|
630
|
+
String(r.body || ''),
|
|
631
|
+
);
|
|
612
632
|
const dupKey = JSON.stringify([path, mode, r.body || '']);
|
|
613
633
|
const priorIdx = _seenEntryBody.get(dupKey);
|
|
614
634
|
if (priorIdx !== undefined) {
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { accessSync, constants as fsConstants, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
3
|
import {
|
|
4
|
-
basename,
|
|
5
4
|
delimiter as pathDelimiter,
|
|
6
5
|
dirname as pathDirname,
|
|
7
6
|
join as pathJoin,
|
|
8
7
|
resolve as pathResolveAbsolute,
|
|
9
8
|
} from 'node:path';
|
|
10
|
-
import { resolveShellFor } from './shell-runtime.mjs';
|
|
11
9
|
|
|
12
10
|
export const SHELL_RUNTIME_CANDIDATES = Object.freeze([
|
|
13
11
|
'node',
|
|
@@ -30,31 +28,6 @@ export const SHELL_RUNTIME_CANDIDATES = Object.freeze([
|
|
|
30
28
|
'bash',
|
|
31
29
|
]);
|
|
32
30
|
|
|
33
|
-
export const SHELL_DEPENDENCY_CANDIDATES = Object.freeze([
|
|
34
|
-
'git',
|
|
35
|
-
'rg',
|
|
36
|
-
'jq',
|
|
37
|
-
'curl',
|
|
38
|
-
'wget',
|
|
39
|
-
'gcc',
|
|
40
|
-
'clang',
|
|
41
|
-
'make',
|
|
42
|
-
'cmake',
|
|
43
|
-
'ninja',
|
|
44
|
-
'cargo',
|
|
45
|
-
'sqlite3',
|
|
46
|
-
'psql',
|
|
47
|
-
'docker',
|
|
48
|
-
'kubectl',
|
|
49
|
-
'openssl',
|
|
50
|
-
'tar',
|
|
51
|
-
]);
|
|
52
|
-
|
|
53
|
-
export const SHELL_CAPABILITY_CANDIDATES = Object.freeze([
|
|
54
|
-
...SHELL_RUNTIME_CANDIDATES,
|
|
55
|
-
...SHELL_DEPENDENCY_CANDIDATES,
|
|
56
|
-
]);
|
|
57
|
-
|
|
58
31
|
function _pathDirectory(value) {
|
|
59
32
|
const text = String(value || '').trim();
|
|
60
33
|
return text.length >= 2 && text.startsWith('"') && text.endsWith('"')
|
|
@@ -96,35 +69,12 @@ export function findPathExecutable(name, {
|
|
|
96
69
|
return null;
|
|
97
70
|
}
|
|
98
71
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
for (const name of candidates) {
|
|
106
|
-
(findPathExecutable(name, pathOptions) ? available : unavailable).push(name);
|
|
107
|
-
}
|
|
108
|
-
return { available, unavailable };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function describeShellStartupPolicy({
|
|
112
|
-
os = process.platform,
|
|
113
|
-
shell = null,
|
|
114
|
-
// Accept a pre-computed scan so a caller emitting several startup lines
|
|
115
|
-
// walks PATH once instead of once per line.
|
|
116
|
-
capabilities = null,
|
|
117
|
-
...pathOptions
|
|
118
|
-
} = {}) {
|
|
119
|
-
const { available, unavailable } = capabilities ?? detectPathCapabilities(pathOptions);
|
|
120
|
-
const resolvedShell = shell || basename(String(resolveShellFor('default')?.shell || 'unknown'));
|
|
121
|
-
// Shell networking is independent from the web-search tool surface:
|
|
122
|
-
// applyShellEgressPolicy leaves the spawn env untouched, so package
|
|
123
|
-
// managers, source-control clients, and proxies keep working even with
|
|
124
|
-
// MIXDOG_FEATURE_WEB_SEARCH=0. This line therefore never claims an
|
|
125
|
-
// egress block the runtime does not enforce.
|
|
126
|
-
return `- Shell startup environment: OS=${os}; shell=${resolvedShell}; available=${available.join(', ') || 'none'}; unavailable=${unavailable.join(', ') || 'none'}. For shell commands, treat every unavailable entry as absent. Invoke one only if the same command first installs it or exposes it on PATH.`;
|
|
127
|
-
}
|
|
72
|
+
// A startup PATH inventory is deliberately not rendered into the prompt: it
|
|
73
|
+
// would be measured in this process's PATH while commands run under the
|
|
74
|
+
// login-shell snapshot (shell-snapshot.mjs), which does not exist until the
|
|
75
|
+
// first shell call, and it would freeze at session start while an install
|
|
76
|
+
// during the session changes the answer. bash-tool's _exitClassDiagnostic
|
|
77
|
+
// reports a missing command after it has run, from the environment that ran it.
|
|
128
78
|
|
|
129
79
|
// Walk up for a `.git` marker instead of shelling out to `git rev-parse`: it
|
|
130
80
|
// costs no process spawn, and it still answers on images where the git binary
|
|
@@ -190,12 +140,9 @@ function _repositoryChangeState(root) {
|
|
|
190
140
|
// rather than forbidding calls.
|
|
191
141
|
export function describeGitStartupState({
|
|
192
142
|
cwd = process.cwd(),
|
|
193
|
-
capabilities = null,
|
|
194
143
|
...pathOptions
|
|
195
144
|
} = {}) {
|
|
196
|
-
const installed =
|
|
197
|
-
? capabilities.available.includes('git')
|
|
198
|
-
: Boolean(findPathExecutable('git', pathOptions));
|
|
145
|
+
const installed = Boolean(findPathExecutable('git', pathOptions));
|
|
199
146
|
if (!installed) {
|
|
200
147
|
return '- Git startup state: git is not installed here; git tool calls fail until something installs it.';
|
|
201
148
|
}
|
|
@@ -213,9 +160,3 @@ export function appendGitStartupState(rules, tools, options = {}) {
|
|
|
213
160
|
if (!text || !Array.isArray(tools) || !tools.some((tool) => tool?.name === 'git')) return text;
|
|
214
161
|
return `${text}\n${describeGitStartupState(options)}`;
|
|
215
162
|
}
|
|
216
|
-
|
|
217
|
-
export function appendShellStartupPolicy(rules, tools, options = {}) {
|
|
218
|
-
const text = String(rules || '').trimEnd();
|
|
219
|
-
if (!text || !Array.isArray(tools) || !tools.some((tool) => tool?.name === 'shell')) return text;
|
|
220
|
-
return `${text}\n${describeShellStartupPolicy(options)}`;
|
|
221
|
-
}
|
|
@@ -137,11 +137,22 @@ export function buildGrepRgArgs(parts) {
|
|
|
137
137
|
// globs (no trailing /**) always apply. searchPath is normalized to forward
|
|
138
138
|
// slashes and de-trailing-slashed for the comparison.
|
|
139
139
|
const _sp = String(searchPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
140
|
+
// A positive --glob filter that NAMES a pruned directory is asking to
|
|
141
|
+
// search inside it; only the path operand used to count, so
|
|
142
|
+
// `glob:"__pycache__/*.pyc"` matched nothing while the files were there.
|
|
143
|
+
// Negative filters never re-admit their own target.
|
|
144
|
+
const _namedByGlobs = new Set(
|
|
145
|
+
(Array.isArray(globPatterns) ? globPatterns : [])
|
|
146
|
+
.filter((g) => typeof g === 'string' && g && !g.startsWith('!'))
|
|
147
|
+
.flatMap((g) => g.replace(/\\/g, '/').split('/'))
|
|
148
|
+
.filter((segment) => segment && !/[*?[\]{}]/.test(segment)),
|
|
149
|
+
);
|
|
140
150
|
for (const ex of DEFAULT_IGNORE_GLOBS) {
|
|
141
151
|
const m = /^!\*\*\/([^/]+)\/\*\*$/.exec(ex);
|
|
142
152
|
if (m) {
|
|
143
153
|
const name = m[1];
|
|
144
154
|
if (_sp === name || _sp.endsWith(`/${name}`) || _sp.includes(`/${name}/`) || _sp.startsWith(`${name}/`)) continue;
|
|
155
|
+
if (_namedByGlobs.has(name)) continue;
|
|
145
156
|
}
|
|
146
157
|
rgArgs.push('--glob', ex);
|
|
147
158
|
}
|
|
@@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises';
|
|
|
3
3
|
import { createHash } from 'crypto';
|
|
4
4
|
import { isAbsolute, resolve } from 'path';
|
|
5
5
|
import { trueCasePath } from './path-utils.mjs';
|
|
6
|
+
import { expandAbsoluteGlobs } from './lib/absolute-glob-expand.mjs';
|
|
6
7
|
import {
|
|
7
8
|
canonicalizeGlobSlashes,
|
|
8
9
|
coerceReadFamilyPathArg,
|
|
@@ -409,8 +410,19 @@ export async function executeGlobTool(args, workDir, options = {}) {
|
|
|
409
410
|
const base = String(rel).split('/').filter(Boolean).pop() || '';
|
|
410
411
|
return base !== '' && !hasGlobMagic(base);
|
|
411
412
|
});
|
|
413
|
+
// A pattern that NAMES a pruned directory is asking for it: only the
|
|
414
|
+
// wildcard basename decided this before, so `node_modules/**/index.js`
|
|
415
|
+
// was answered while `__pycache__/*.pyc` returned "(no files found)"
|
|
416
|
+
// for a file sitting right there. The yield is per-directory — every
|
|
417
|
+
// other prune stays on — and a negative pattern never counts as a
|
|
418
|
+
// request for its own target.
|
|
419
|
+
const namedDirs = new Set([
|
|
420
|
+
...rels.flatMap((rel) => String(rel).replace(/^!/, '').split('/')),
|
|
421
|
+
...String(root || '').replace(/\\/g, '/').split('/'),
|
|
422
|
+
].filter((segment) => segment && !hasGlobMagic(segment)));
|
|
412
423
|
for (const ex of DEFAULT_IGNORE_GLOBS) {
|
|
413
|
-
|
|
424
|
+
const pruned = /^!\*\*\/([^/]+)\/\*\*$/.exec(ex);
|
|
425
|
+
if (pruned && (explicitBasenames || namedDirs.has(pruned[1]))) continue;
|
|
414
426
|
rgArgs.push('--glob', ex);
|
|
415
427
|
}
|
|
416
428
|
for (const ex of extraIgnoreGlobs) rgArgs.push('--glob', ex);
|
|
@@ -594,6 +606,18 @@ export async function executeGlobTool(args, workDir, options = {}) {
|
|
|
594
606
|
: (rgStdoutTruncated ? '\n... [truncated at rg stdout cap (20MB); results incomplete]' : '')
|
|
595
607
|
+ (rgStdoutPartial ? '\n... [warning] rg exit 2 (partial results); listing may be incomplete' : '');
|
|
596
608
|
const errSuffix = (rgErrors.length > 0 ? `\n... [warning] ${rgErrors.join(' | ')}` : '') + truncSuffix;
|
|
609
|
+
// An absolute pattern that enumerated nothing may still have real matches
|
|
610
|
+
// the inventory walker cannot see: it does not follow symlinked
|
|
611
|
+
// directories, and /sys, /proc and friends are made of them. Expand those
|
|
612
|
+
// patterns straight off the filesystem before declaring the miss.
|
|
613
|
+
if (capped.length === 0 && rgErrors.length === 0) {
|
|
614
|
+
const direct = await expandAbsoluteGlobs(patterns, {
|
|
615
|
+
limit: headLimit === Infinity ? 50 : offset + headLimit,
|
|
616
|
+
});
|
|
617
|
+
const window = offset > 0 ? direct.slice(offset) : direct;
|
|
618
|
+
const shown = headLimit === Infinity ? window : window.slice(0, headLimit);
|
|
619
|
+
if (shown.length > 0) return globPatternCapNote + shown.join('\n');
|
|
620
|
+
}
|
|
597
621
|
let emptyDiag = '';
|
|
598
622
|
if (capped.length === 0 && rgErrors.length === 0) {
|
|
599
623
|
const patternStr = patterns.length === 1 ? JSON.stringify(patterns[0]) : JSON.stringify(patterns);
|