mixdog 0.9.149 → 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 -12
- package/package.json +1 -1
- package/src/headless-exec.mjs +40 -4
- package/src/headless-exec.test.mjs +116 -1
- package/src/rules/lead/01-general.md +3 -2
- package/src/rules/shared/00-general.md +1 -0
- package/src/rules/shared/10-tool-workflow.md +11 -12
- package/src/rules/shared/30-exploration.md +4 -3
- package/src/rules/shared/60-verification.md +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +0 -2
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +1 -4
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +0 -18
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +0 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +4 -72
- package/src/runtime/agent/orchestrator/session/approval-mode.mjs +0 -8
- 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 +50 -8
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +31 -1
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +27 -4
- package/src/runtime/agent/orchestrator/session/store/serialize.test.mjs +60 -0
- package/src/runtime/agent/orchestrator/session/task-wait-control.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/task-wait-control.test.mjs +78 -0
- package/src/runtime/agent/orchestrator/tools/builtin/absence-absorption.test.mjs +43 -0
- package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- 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 +50 -23
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +50 -1
- package/src/runtime/agent/orchestrator/tools/builtin/grep-output-budget.test.mjs +31 -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-context-expander.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +50 -2
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +7 -1
- 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-glob-survey.test.mjs +71 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +54 -8
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +90 -51
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.test.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +25 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.test.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +30 -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/task-tool.mjs +49 -24
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -1
- 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/env-scrub.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +33 -1
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +9 -1
- 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/agent/orchestrator/tools/shell-command.mjs +42 -23
- package/src/runtime/media/renditions.mjs +7 -1
- 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 +13 -19
- package/src/runtime/memory/lib/recall-format.mjs +13 -31
- package/src/runtime/memory/tool-defs.mjs +2 -2
- package/src/runtime/shared/memory-snapshot.mjs +57 -4
- package/src/runtime/shared/pristine-execution-contract.json +3 -0
- package/src/runtime/shared/pristine-execution.mjs +6 -3
- package/src/runtime/shared/tool-surface.mjs +6 -0
- package/src/runtime/shared/tool-surface.test.mjs +8 -0
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/session-runtime/session-turn-api.mjs +5 -0
- package/src/standalone/agent-dispatch-broker.mjs +25 -80
- package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
- package/src/standalone/channel-worker.mjs +31 -1
- package/src/standalone/daemon.mjs +9 -9
- package/src/standalone/memory-runtime-proxy.mjs +5 -2
- package/src/standalone/session-client.mjs +23 -3
- package/src/standalone/session-runtime-host-health.test.mjs +36 -0
- package/src/standalone/session-runtime-host.mjs +26 -76
- package/src/standalone/session-runtime-worker.mjs +97 -0
- package/src/tui/App.jsx +3 -2
- package/src/tui/app/use-global-key-input.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +77 -14
- package/src/tui/components/Spinner.jsx +1 -1
- package/src/tui/dist/index.mjs +83 -23
- package/src/tui/lib/voice-setup.mjs +3 -2
- package/src/tui/session/completion-card-restore.test.mjs +18 -0
- package/src/tui/session/session-api-ext.mjs +2 -1
- package/src/tui/session/session-api.mjs +8 -1
- package/src/tui/session/task-wait-submit.test.mjs +80 -0
- package/src/tui/session/turn.mjs +58 -6
- package/src/tui/spinner-verbs.mjs +1 -0
- package/src/workflows/default/WORKFLOW.md +12 -19
- package/src/workflows/headless/WORKFLOW.md +11 -0
- package/src/workflows/solo/WORKFLOW.md +6 -11
|
@@ -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
|
+
}
|
|
@@ -18,9 +18,16 @@ export async function readFamilyPathEnoentOrError(workDir, fullPath, inputPath,
|
|
|
18
18
|
rerun: (target, opts) => rerunTool({ ...args, path: target }, workDir, opts),
|
|
19
19
|
});
|
|
20
20
|
if (redirected) return redirected;
|
|
21
|
-
const
|
|
21
|
+
const safeMsg = normalizeErrorMessage(err instanceof Error ? err.message : String(err));
|
|
22
22
|
const hint = buildNotFoundHint(workDir, fullPath, 'List', err?.code);
|
|
23
|
-
|
|
23
|
+
const tail = finalizeReadFamilyEnoentTail(hint, inputPath, err?.code);
|
|
24
|
+
// Conclusive absence is the list's ANSWER, not a tool failure (matches
|
|
25
|
+
// read's `[path absent]` and git's `repo:false` policy). Genuine
|
|
26
|
+
// not-found only — EACCES/EPERM etc. keep failure semantics.
|
|
27
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
|
|
28
|
+
return `[path absent] ${safeMsg}${tail}`;
|
|
29
|
+
}
|
|
30
|
+
return `Error: ${safeMsg}${tail}`;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
export function normalizeListHeadLimit(raw, defaultCap) {
|
|
@@ -41,6 +41,12 @@ test('list defaults to a 100-entry page with an offset continuation', async () =
|
|
|
41
41
|
const out = await executeListTool({ path: root, hidden: true }, process.cwd());
|
|
42
42
|
assert.equal(out.split('\n').filter((line) => line.endsWith('\tfile')).length, 100);
|
|
43
43
|
assert.match(out, /\[entries 1-100 of 101; pass offset:100 to continue\]/);
|
|
44
|
+
const oversized = await executeListTool(
|
|
45
|
+
{ path: root, hidden: true, head_limit: 200 },
|
|
46
|
+
process.cwd(),
|
|
47
|
+
);
|
|
48
|
+
assert.equal(oversized.split('\n').filter((line) => line.endsWith('\tfile')).length, 100);
|
|
49
|
+
assert.match(oversized, /\[entries 1-100 of 101; pass offset:100 to continue\]/);
|
|
44
50
|
} finally {
|
|
45
51
|
await rm(root, { recursive: true, force: true });
|
|
46
52
|
}
|
|
@@ -13,6 +13,7 @@ import { normalizeErrorMessage } from './path-diagnostics.mjs';
|
|
|
13
13
|
import {
|
|
14
14
|
buildListCacheKey,
|
|
15
15
|
DEFAULT_IGNORE_GLOBS,
|
|
16
|
+
rootScanIgnoreGlobs,
|
|
16
17
|
} from './search-builders.mjs';
|
|
17
18
|
import { markScopedCacheIncomplete } from '../../session/cache/scoped-cache-outcome.mjs';
|
|
18
19
|
import {
|
|
@@ -256,7 +257,11 @@ export async function executeListTool(args, workDir, options = {}) {
|
|
|
256
257
|
const hidden = Boolean(args.hidden);
|
|
257
258
|
const sort = ['name', 'mtime', 'size'].includes(args.sort) ? args.sort : 'name';
|
|
258
259
|
const typeFilter = ['any', 'file', 'dir'].includes(args.type) ? args.type : 'any';
|
|
259
|
-
const
|
|
260
|
+
const listHeadLimitCap = _listDefaultHeadLimit(100);
|
|
261
|
+
const requestedHeadLimit = normalizeListHeadLimit(args.head_limit, listHeadLimitCap);
|
|
262
|
+
const headLimit = requestedHeadLimit === 0
|
|
263
|
+
? 0
|
|
264
|
+
: Math.min(requestedHeadLimit, listHeadLimitCap);
|
|
260
265
|
const offset = typeof args.offset === 'number' && args.offset > 0 ? args.offset : 0;
|
|
261
266
|
const needsGlobalStat = sort === 'mtime' || sort === 'size';
|
|
262
267
|
const includeNoise = Boolean(args.include_noise);
|
|
@@ -899,6 +904,7 @@ export async function prewarmFindEnumeration(root) {
|
|
|
899
904
|
for (const ex of DEFAULT_IGNORE_GLOBS) {
|
|
900
905
|
common.push('--glob', ex);
|
|
901
906
|
}
|
|
907
|
+
for (const ex of rootScanIgnoreGlobs(normalizeOutputPath(root))) common.push('--glob', ex);
|
|
902
908
|
common.push('.');
|
|
903
909
|
await getBroadEnumeration({
|
|
904
910
|
root: normalizeOutputPath(root),
|
|
@@ -971,6 +977,7 @@ async function getTargetedFindEnumeration({
|
|
|
971
977
|
if (!includeNoise) {
|
|
972
978
|
for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
|
|
973
979
|
}
|
|
980
|
+
for (const ex of rootScanIgnoreGlobs(root)) rgArgs.push('--glob', ex);
|
|
974
981
|
rgArgs.push('.');
|
|
975
982
|
try {
|
|
976
983
|
const stdout = await runRgImpl(rgArgs, {
|
|
@@ -1031,7 +1038,40 @@ async function getTargetedFindEnumeration({
|
|
|
1031
1038
|
// list via `rg --files`, then rank by subsequence score. `list.fuzzy` still
|
|
1032
1039
|
// routes here for hidden backward compatibility, but the model-facing tool is
|
|
1033
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.
|
|
1034
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 = {}) {
|
|
1035
1075
|
const query = String(args.query ?? args.fuzzy ?? '').trim();
|
|
1036
1076
|
if (!query) return 'Error: find requires query.';
|
|
1037
1077
|
const inputPath = normalizeInputPath(args.path) || '.';
|
|
@@ -1096,7 +1136,13 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
|
|
|
1096
1136
|
hidden,
|
|
1097
1137
|
includeNoise,
|
|
1098
1138
|
maxDepth: depth,
|
|
1099
|
-
exclude:
|
|
1139
|
+
exclude: [
|
|
1140
|
+
...(includeNoise ? [] : DEFAULT_IGNORE_GLOBS),
|
|
1141
|
+
// Kernel-virtual trees are pruned even under include_noise:
|
|
1142
|
+
// they are never dependency noise, and walking them starves
|
|
1143
|
+
// the fuzzy inventory budget on a full-root scan.
|
|
1144
|
+
...rootScanIgnoreGlobs(fullPath),
|
|
1145
|
+
],
|
|
1100
1146
|
}, {
|
|
1101
1147
|
cwd: fullPath,
|
|
1102
1148
|
timeout: FIND_FUZZY_TIMEOUT_MS,
|
|
@@ -1234,6 +1280,7 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
|
|
|
1234
1280
|
if (!includeNoise) {
|
|
1235
1281
|
for (const ex of DEFAULT_IGNORE_GLOBS) ignoreGlobs.push('--glob', ex);
|
|
1236
1282
|
}
|
|
1283
|
+
for (const ex of rootScanIgnoreGlobs(normalizeOutputPath(fullPath))) ignoreGlobs.push('--glob', ex);
|
|
1237
1284
|
// The narrowed pass must treat `query` as a LITERAL filename substring, not
|
|
1238
1285
|
// a glob. Wrap every globset metacharacter in a single-char character class
|
|
1239
1286
|
// (`[` → `[[]`, `*` → `[*]`, …): character-class quoting is the only form
|
|
@@ -1476,6 +1523,7 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
|
|
|
1476
1523
|
if (!includeNoise) {
|
|
1477
1524
|
for (const ex of DEFAULT_IGNORE_GLOBS) inventoryArgs.push('--glob', ex);
|
|
1478
1525
|
}
|
|
1526
|
+
for (const ex of rootScanIgnoreGlobs(fullPath)) inventoryArgs.push('--glob', ex);
|
|
1479
1527
|
if (namePattern) {
|
|
1480
1528
|
inventoryArgs.push('--iglob', nameIsGlob ? namePattern : `*${namePattern}*`);
|
|
1481
1529
|
}
|
|
@@ -15,7 +15,13 @@ import { ensureGraphBinary } from '../graph-binary-fetcher.mjs';
|
|
|
15
15
|
import { fuzzyRank, prepareFuzzyItems } from './fuzzy-match.mjs';
|
|
16
16
|
|
|
17
17
|
const RESTART_BACKOFF_MS = 30_000;
|
|
18
|
-
|
|
18
|
+
// Default request budget. Deliberately BELOW the read-only I/O watchdog in
|
|
19
|
+
// tool-exec.mjs (20s): with both at 20s the watchdog killed the call first on
|
|
20
|
+
// a cold whole-filesystem walk, so the soft-deadline partial the server hands
|
|
21
|
+
// back near its own budget never reached the model (observed as a bare
|
|
22
|
+
// "timed out with no partial output" on a full-root glob). The 2.5s margin
|
|
23
|
+
// covers caller preprocessing plus response serialization/ranking.
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 17_500;
|
|
19
25
|
const SERVER_READY_TIMEOUT_MS = 1_000;
|
|
20
26
|
const CANCEL_GRACE_MS = 1_000;
|
|
21
27
|
const PROCESS_FAILURES_BEFORE_BACKOFF = 2;
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { executeBuiltinTool } from '../builtin.mjs';
|
|
8
|
+
|
|
9
|
+
function lines(fileTag, count) {
|
|
10
|
+
return Array.from({ length: count }, (_, i) => `${fileTag} line-${i + 1}`).join('\n') + '\n';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('multi-file glob fan-out caps the survey window at 25 lines per file', async () => {
|
|
14
|
+
const root = await mkdtemp(join(tmpdir(), 'mixdog-read-glob-survey-'));
|
|
15
|
+
try {
|
|
16
|
+
await writeFile(join(root, 'a.log'), lines('alpha', 200));
|
|
17
|
+
await writeFile(join(root, 'b.log'), lines('beta', 200));
|
|
18
|
+
const out = await executeBuiltinTool('read', { file_path: join(root, '*.log') }, root);
|
|
19
|
+
assert.equal(typeof out, 'string');
|
|
20
|
+
// Head of each file is present…
|
|
21
|
+
assert.match(out, /alpha line-1\b/);
|
|
22
|
+
assert.match(out, /beta line-1\b/);
|
|
23
|
+
assert.match(out, /alpha line-25\b/);
|
|
24
|
+
assert.match(out, /beta line-25\b/);
|
|
25
|
+
assert.doesNotMatch(out, /alpha line-26\b/);
|
|
26
|
+
assert.doesNotMatch(out, /beta line-26\b/);
|
|
27
|
+
} finally {
|
|
28
|
+
await rm(root, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('explicit limit is clamped to 25 lines per glob-expanded file', async () => {
|
|
33
|
+
const root = await mkdtemp(join(tmpdir(), 'mixdog-read-glob-explicit-'));
|
|
34
|
+
try {
|
|
35
|
+
await writeFile(join(root, 'a.log'), lines('alpha', 200));
|
|
36
|
+
await writeFile(join(root, 'b.log'), lines('beta', 200));
|
|
37
|
+
const out = await executeBuiltinTool('read', { file_path: join(root, '*.log'), limit: 150 }, root);
|
|
38
|
+
assert.match(out, /alpha line-25\b/);
|
|
39
|
+
assert.doesNotMatch(out, /alpha line-26\b/);
|
|
40
|
+
} finally {
|
|
41
|
+
await rm(root, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('glob fan-out shares a 10 KB output budget across files', async () => {
|
|
46
|
+
const root = await mkdtemp(join(tmpdir(), 'mixdog-read-glob-budget-'));
|
|
47
|
+
try {
|
|
48
|
+
const payload = 'x'.repeat(300);
|
|
49
|
+
for (let i = 0; i < 10; i++) {
|
|
50
|
+
await writeFile(join(root, `${i}.log`), lines(`${i}-${payload}`, 25));
|
|
51
|
+
}
|
|
52
|
+
const out = await executeBuiltinTool('read', { file_path: join(root, '*.log'), limit: 25 }, root);
|
|
53
|
+
assert.ok(Buffer.byteLength(out, 'utf8') <= 10 * 1024);
|
|
54
|
+
assert.match(out, /0\.log/);
|
|
55
|
+
assert.match(out, /9\.log/);
|
|
56
|
+
} finally {
|
|
57
|
+
await rm(root, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('single-file glob match uses the same 25-line cap', async () => {
|
|
62
|
+
const root = await mkdtemp(join(tmpdir(), 'mixdog-read-glob-single-'));
|
|
63
|
+
try {
|
|
64
|
+
await writeFile(join(root, 'only.log'), lines('solo', 200));
|
|
65
|
+
const out = await executeBuiltinTool('read', { file_path: join(root, '*.log') }, root);
|
|
66
|
+
assert.match(out, /solo line-25\b/);
|
|
67
|
+
assert.doesNotMatch(out, /solo line-26\b/);
|
|
68
|
+
} finally {
|
|
69
|
+
await rm(root, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
@@ -105,7 +105,12 @@ async function loadSharp() {
|
|
|
105
105
|
_sharpPromise = (async () => {
|
|
106
106
|
try {
|
|
107
107
|
const mod = await import('sharp');
|
|
108
|
-
|
|
108
|
+
const sharp = mod?.default || mod || null;
|
|
109
|
+
// libvips' internal operation cache (default ~50MB per
|
|
110
|
+
// process) duplicates the JS-level resize cache above and the
|
|
111
|
+
// on-disk rendition cache; keep pixels out of native memory.
|
|
112
|
+
try { sharp?.cache(false); } catch { /* cache stays default */ }
|
|
113
|
+
return sharp;
|
|
109
114
|
} catch {
|
|
110
115
|
return null;
|
|
111
116
|
}
|
|
@@ -272,6 +272,13 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
272
272
|
hint = finalizeReadFamilyEnoentTail(hint, filePath, err?.code);
|
|
273
273
|
const _rawMsg = err instanceof Error ? err.message : String(err);
|
|
274
274
|
const _safeMsg = normalizeErrorMessage(_rawMsg, workDir);
|
|
275
|
+
// A conclusive "no such path" is the ANSWER to the read, not a tool
|
|
276
|
+
// failure (same policy as git's `repo:false`): the error envelope
|
|
277
|
+
// routed callers into recovery mode for an ordinary absence probe.
|
|
278
|
+
// Genuine not-found only — EACCES/EPERM etc. keep failure semantics.
|
|
279
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
|
|
280
|
+
return `[path absent] ${_safeMsg}${hint}`;
|
|
281
|
+
}
|
|
275
282
|
return `Error: ${_safeMsg}${hint}`;
|
|
276
283
|
}
|
|
277
284
|
// A successful edit/apply_patch leaves a session-scoped full-file snapshot.
|