prior-cli 1.7.10 → 1.7.12
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/bin/prior.js +169 -4
- package/lib/agent.js +16 -1
- package/lib/tools.js +142 -0
- package/package.json +1 -1
package/bin/prior.js
CHANGED
|
@@ -169,6 +169,16 @@ const TOOL_HINTS = [
|
|
|
169
169
|
],
|
|
170
170
|
hint: '[TOOL DIRECTIVE: You MUST call ip_lookup]',
|
|
171
171
|
},
|
|
172
|
+
{
|
|
173
|
+
tool: 'file_edit',
|
|
174
|
+
patterns: [
|
|
175
|
+
/\bedit\b/i, /\bmodify\b/i, /\bchange\b.*\b(file|function|line|code|return|value|variable)\b/i,
|
|
176
|
+
/\brefactor\b/i, /\brename\b.*\b(function|variable|method)\b/i,
|
|
177
|
+
/\bfix\b.*\b(bug|file|code|function|typo)\b/i, /\breplace\b.*\b(in|line|text|code)\b/i,
|
|
178
|
+
/\bupdate\b.*\b(file|function|code|line)\b/i,
|
|
179
|
+
],
|
|
180
|
+
hint: '[TOOL DIRECTIVE: To change part of an existing file, you MUST use the <edit path="..."> tag with SEARCH/REPLACE markers — do NOT rewrite the whole file with <write>, and never say you cannot edit files]',
|
|
181
|
+
},
|
|
172
182
|
{
|
|
173
183
|
tool: 'generate_image',
|
|
174
184
|
patterns: [
|
|
@@ -191,6 +201,59 @@ function injectToolHint(text) {
|
|
|
191
201
|
return text;
|
|
192
202
|
}
|
|
193
203
|
|
|
204
|
+
// ── @file context attachment ───────────────────────────────────
|
|
205
|
+
// Expands @path/to/file references in a prompt into inline file context,
|
|
206
|
+
// so the model sees the contents without a separate file_read round-trip.
|
|
207
|
+
const MAX_ATTACH_BYTES = 256 * 1024;
|
|
208
|
+
function expandFileRefs(input, cwd) {
|
|
209
|
+
const refRe = /(?:^|\s)@([^\s]+)/g;
|
|
210
|
+
const attached = [];
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
let m;
|
|
213
|
+
while ((m = refRe.exec(input)) !== null) {
|
|
214
|
+
let ref = m[1].replace(/[.,;:)\]]+$/, ''); // trim trailing punctuation
|
|
215
|
+
if (!ref || seen.has(ref)) continue;
|
|
216
|
+
const resolved = (/^[a-zA-Z]:[/\\]/.test(ref) || path.isAbsolute(ref)) ? ref : path.resolve(cwd, ref);
|
|
217
|
+
try {
|
|
218
|
+
const stat = fs.statSync(resolved);
|
|
219
|
+
if (!stat.isFile() || stat.size > MAX_ATTACH_BYTES) continue;
|
|
220
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
221
|
+
if (content.indexOf('\x00') !== -1) continue; // binary
|
|
222
|
+
attached.push({ ref, content, lines: content.split('\n').length });
|
|
223
|
+
seen.add(ref);
|
|
224
|
+
} catch { /* not a readable file — leave the @token as literal text */ }
|
|
225
|
+
}
|
|
226
|
+
if (!attached.length) return { message: input, attached };
|
|
227
|
+
const ctx = attached
|
|
228
|
+
.map(a => `--- Contents of ${a.ref} ---\n${a.content}`)
|
|
229
|
+
.join('\n\n');
|
|
230
|
+
return { message: `${input}\n\n[Attached file context]\n${ctx}`, attached };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Live completions for an @file token being typed. `partial` is the text
|
|
234
|
+
// after @ (e.g. "lib/to"). Returns up to `limit` matches, dirs first.
|
|
235
|
+
const REF_SKIP_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.cache', 'coverage', 'vendor']);
|
|
236
|
+
function fileRefSuggestions(partial, cwd, limit = 6) {
|
|
237
|
+
const slash = Math.max(partial.lastIndexOf('/'), partial.lastIndexOf('\\'));
|
|
238
|
+
const dirPart = slash >= 0 ? partial.slice(0, slash) : '';
|
|
239
|
+
const prefix = (slash >= 0 ? partial.slice(slash + 1) : partial).toLowerCase();
|
|
240
|
+
const baseDir = dirPart
|
|
241
|
+
? (path.isAbsolute(dirPart) ? dirPart : path.resolve(cwd, dirPart))
|
|
242
|
+
: cwd;
|
|
243
|
+
let entries;
|
|
244
|
+
try { entries = fs.readdirSync(baseDir, { withFileTypes: true }); } catch { return []; }
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const e of entries) {
|
|
247
|
+
if (e.name.startsWith('.') && !prefix.startsWith('.')) continue; // hide dotfiles unless asked
|
|
248
|
+
if (e.isDirectory() && REF_SKIP_DIRS.has(e.name)) continue;
|
|
249
|
+
if (!e.name.toLowerCase().startsWith(prefix)) continue;
|
|
250
|
+
const ref = (dirPart ? dirPart.replace(/\\/g, '/') + '/' : '') + e.name;
|
|
251
|
+
out.push({ ref, isDir: e.isDirectory() });
|
|
252
|
+
}
|
|
253
|
+
out.sort((a, b) => (a.isDir === b.isDir ? a.ref.localeCompare(b.ref) : a.isDir ? -1 : 1));
|
|
254
|
+
return out.slice(0, limit);
|
|
255
|
+
}
|
|
256
|
+
|
|
194
257
|
function fmtElapsed(ms) {
|
|
195
258
|
const s = Math.floor(ms / 1000);
|
|
196
259
|
if (s < 60) return `${s}s`;
|
|
@@ -824,6 +887,13 @@ async function startChat(opts = {}) {
|
|
|
824
887
|
terminal: true,
|
|
825
888
|
historySize: 100,
|
|
826
889
|
completer: line => {
|
|
890
|
+
// @file completion — complete the @token at the end of the line
|
|
891
|
+
const at = line.match(/(?:^|\s)@([^\s]*)$/);
|
|
892
|
+
if (at) {
|
|
893
|
+
const matches = fileRefSuggestions(at[1], process.cwd(), 20)
|
|
894
|
+
.map(s => '@' + (s.isDir ? s.ref + '/' : s.ref));
|
|
895
|
+
return [matches, '@' + at[1]];
|
|
896
|
+
}
|
|
827
897
|
const cmds = ['/help', '/clear', '/censored', '/uncensored', '/login', '/logout', '/exit'];
|
|
828
898
|
if (!line.startsWith('/')) return [[], line];
|
|
829
899
|
const hits = cmds.filter(cmd => cmd.startsWith(line));
|
|
@@ -852,7 +922,7 @@ async function startChat(opts = {}) {
|
|
|
852
922
|
);
|
|
853
923
|
console.log(accountBadge);
|
|
854
924
|
console.log(c.muted(` ${cwdShort}`));
|
|
855
|
-
console.log(c.ok(' ◉') + c.muted(' Agent mode ') + c.dim('·
|
|
925
|
+
console.log(c.ok(' ◉') + c.muted(' Agent mode ') + c.dim('· read edit search web shell image · @file to attach'));
|
|
856
926
|
|
|
857
927
|
console.log(DIVIDER);
|
|
858
928
|
console.log(c.muted(' /help /clear /update /compact /timer /save /load /saves /delete /exit'));
|
|
@@ -933,6 +1003,22 @@ async function startChat(opts = {}) {
|
|
|
933
1003
|
}
|
|
934
1004
|
}
|
|
935
1005
|
|
|
1006
|
+
// @file completions — when the token at the end of the line starts with @
|
|
1007
|
+
const atMatch = (line || '').match(/(?:^|\s)@([^\s]*)$/);
|
|
1008
|
+
if (atMatch) {
|
|
1009
|
+
const sugg = fileRefSuggestions(atMatch[1], process.cwd());
|
|
1010
|
+
for (const s of sugg) {
|
|
1011
|
+
const label = s.isDir ? s.ref + '/' : s.ref;
|
|
1012
|
+
const icon = s.isDir ? '📁' : '📄';
|
|
1013
|
+
process.stdout.write(`\x1b[B\r\x1b[2K ${c.brand('@')}${c.white(label.padEnd(30))}${c.dim(icon)}`);
|
|
1014
|
+
rows++;
|
|
1015
|
+
}
|
|
1016
|
+
if (sugg.length) {
|
|
1017
|
+
process.stdout.write(`\x1b[B\r\x1b[2K ${c.dim('tab to complete · attaches file contents as context')}`);
|
|
1018
|
+
rows++;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
936
1022
|
process.stdout.write('\x1b[u');
|
|
937
1023
|
_subRowCount = rows;
|
|
938
1024
|
}
|
|
@@ -1250,7 +1336,7 @@ async function startChat(opts = {}) {
|
|
|
1250
1336
|
banner();
|
|
1251
1337
|
console.log(DIVIDER);
|
|
1252
1338
|
console.log(c.brand(' Prior AI') + c.muted(' · ') + c.muted(`@${user}`));
|
|
1253
|
-
console.log(c.ok(' ◉') + c.muted(' Agent mode ') + c.dim('·
|
|
1339
|
+
console.log(c.ok(' ◉') + c.muted(' Agent mode ') + c.dim('· read edit search web shell image · @file to attach'));
|
|
1254
1340
|
console.log(DIVIDER);
|
|
1255
1341
|
console.log('');
|
|
1256
1342
|
return loop();
|
|
@@ -1640,6 +1726,13 @@ Be concise but thorough — this summary replaces the full history to save conte
|
|
|
1640
1726
|
console.log(c.brand(' ◈') + c.dim(` ${label} attached`));
|
|
1641
1727
|
}
|
|
1642
1728
|
|
|
1729
|
+
// Expand any @file references into inline context
|
|
1730
|
+
const { message: expandedInput, attached } = expandFileRefs(input, process.cwd());
|
|
1731
|
+
if (attached.length) {
|
|
1732
|
+
console.log(c.brand(' ◈') + c.muted(' attached: ' +
|
|
1733
|
+
attached.map(a => `${a.ref} (${a.lines} line${a.lines !== 1 ? 's' : ''})`).join(', ')));
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1643
1736
|
let responseText = '';
|
|
1644
1737
|
let _progressStarted = false;
|
|
1645
1738
|
const _thinkStart = Date.now();
|
|
@@ -1661,7 +1754,7 @@ Be concise but thorough — this summary replaces the full history to save conte
|
|
|
1661
1754
|
|
|
1662
1755
|
_currentAbortController = new AbortController();
|
|
1663
1756
|
await runAgent({
|
|
1664
|
-
messages: [...chatHistory, { role: 'user', content: injectToolHint(
|
|
1757
|
+
messages: [...chatHistory, { role: 'user', content: injectToolHint(expandedInput) }],
|
|
1665
1758
|
model: currentModel,
|
|
1666
1759
|
cwd: process.cwd(),
|
|
1667
1760
|
projectContext,
|
|
@@ -1761,7 +1854,7 @@ Be concise but thorough — this summary replaces the full history to save conte
|
|
|
1761
1854
|
_currentAbortController = null;
|
|
1762
1855
|
}
|
|
1763
1856
|
|
|
1764
|
-
chatHistory.push({ role: 'user', content:
|
|
1857
|
+
chatHistory.push({ role: 'user', content: expandedInput });
|
|
1765
1858
|
if (responseText) chatHistory.push({ role: 'assistant', content: responseText });
|
|
1766
1859
|
|
|
1767
1860
|
process.stdout.write('\n');
|
|
@@ -1828,6 +1921,78 @@ program
|
|
|
1828
1921
|
.option('-m, --model <model>', 'Model to use')
|
|
1829
1922
|
.action(opts => startChat(opts));
|
|
1830
1923
|
|
|
1924
|
+
// ── RUN (one-shot / non-interactive) ───────────────────────────
|
|
1925
|
+
program
|
|
1926
|
+
.command('run [prompt...]')
|
|
1927
|
+
.description('One-shot prompt — prints the answer and exits (scriptable, pipe-able)')
|
|
1928
|
+
.option('-m, --model <model>', 'Model to use')
|
|
1929
|
+
.option('-y, --yes', 'Auto-approve tool actions (run_command, file edits/writes)')
|
|
1930
|
+
.option('-q, --quiet', 'Print only the final answer (suppress tool activity)')
|
|
1931
|
+
.action(async (promptParts, opts) => {
|
|
1932
|
+
requireAuth();
|
|
1933
|
+
|
|
1934
|
+
// Gather prompt from args + piped stdin
|
|
1935
|
+
let prompt = (promptParts || []).join(' ').trim();
|
|
1936
|
+
if (!process.stdin.isTTY) {
|
|
1937
|
+
const piped = await new Promise(res => {
|
|
1938
|
+
let buf = ''; process.stdin.setEncoding('utf8');
|
|
1939
|
+
process.stdin.on('data', d => buf += d);
|
|
1940
|
+
process.stdin.on('end', () => res(buf.trim()));
|
|
1941
|
+
setTimeout(() => res(buf.trim()), 50); // no pipe → don't hang
|
|
1942
|
+
});
|
|
1943
|
+
if (piped) prompt = prompt ? `${prompt}\n\n${piped}` : piped;
|
|
1944
|
+
}
|
|
1945
|
+
if (!prompt) { console.error(c.err(' ✗ No prompt. Usage: prior run "your question" (or pipe via stdin)')); process.exit(1); }
|
|
1946
|
+
|
|
1947
|
+
const cwd = process.cwd();
|
|
1948
|
+
const { message, attached } = expandFileRefs(prompt, cwd);
|
|
1949
|
+
if (attached.length && !opts.quiet) {
|
|
1950
|
+
console.error(c.muted(' ◈ attached: ' + attached.map(a => a.ref).join(', ')));
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// Load prior.md if present
|
|
1954
|
+
let projectContext = null;
|
|
1955
|
+
try { projectContext = fs.readFileSync(path.join(cwd, 'prior.md'), 'utf8'); } catch {}
|
|
1956
|
+
|
|
1957
|
+
let responseText = '';
|
|
1958
|
+
let hadError = false;
|
|
1959
|
+
try {
|
|
1960
|
+
await runAgent({
|
|
1961
|
+
messages: [{ role: 'user', content: injectToolHint(message) }],
|
|
1962
|
+
model: opts.model || null,
|
|
1963
|
+
cwd,
|
|
1964
|
+
projectContext,
|
|
1965
|
+
confirm: async ({ name }) => {
|
|
1966
|
+
if (opts.yes) return true;
|
|
1967
|
+
console.error(c.warn(` ⚠ Skipping ${name} — re-run with --yes to allow tool actions in one-shot mode.`));
|
|
1968
|
+
return false;
|
|
1969
|
+
},
|
|
1970
|
+
send: ev => {
|
|
1971
|
+
switch (ev.type) {
|
|
1972
|
+
case 'tool_start':
|
|
1973
|
+
if (!opts.quiet) console.error(c.dim(` ◈ ${ev.name}`));
|
|
1974
|
+
break;
|
|
1975
|
+
case 'tool_error':
|
|
1976
|
+
if (!opts.quiet) console.error(c.err(` ✗ ${ev.name}: ${ev.error}`));
|
|
1977
|
+
break;
|
|
1978
|
+
case 'text':
|
|
1979
|
+
if (ev.content) { process.stdout.write(ev.content); responseText += ev.content; }
|
|
1980
|
+
break;
|
|
1981
|
+
case 'error':
|
|
1982
|
+
hadError = true;
|
|
1983
|
+
console.error(c.err(` ✗ ${ev.message}`));
|
|
1984
|
+
break;
|
|
1985
|
+
}
|
|
1986
|
+
},
|
|
1987
|
+
});
|
|
1988
|
+
} catch (err) {
|
|
1989
|
+
console.error(c.err(` ✗ ${err.message}`));
|
|
1990
|
+
process.exit(1);
|
|
1991
|
+
}
|
|
1992
|
+
if (responseText && !responseText.endsWith('\n')) process.stdout.write('\n');
|
|
1993
|
+
process.exit(hadError ? 1 : 0);
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1831
1996
|
// ── IMAGINE ────────────────────────────────────────────────────
|
|
1832
1997
|
program
|
|
1833
1998
|
.command('imagine <prompt>')
|
package/lib/agent.js
CHANGED
|
@@ -176,6 +176,19 @@ function parseWriteTags(text) {
|
|
|
176
176
|
while ((m = docxRe.exec(text)) !== null) {
|
|
177
177
|
calls.push({ raw: m[0], offset: m.index, name: 'file_write_docx', args: { path: m[1], title: m[2] || undefined, content: m[3] } });
|
|
178
178
|
}
|
|
179
|
+
// <edit path="..." [all="true"]> <<<<<<< SEARCH … ======= … >>>>>>> REPLACE </edit>
|
|
180
|
+
// Conflict-marker form so multiline code needs no JSON escaping.
|
|
181
|
+
const editRe = /<edit\s+path="([^"]+)"((?:\s+\w+="[^"]*")*)\s*>([\s\S]*?)<\/edit>/g;
|
|
182
|
+
while ((m = editRe.exec(text)) !== null) {
|
|
183
|
+
const body = m[3];
|
|
184
|
+
const split = body.match(/<{3,}\s*SEARCH\s*\r?\n([\s\S]*?)\r?\n={3,}[ \t]*\r?\n([\s\S]*?)\r?\n>{3,}\s*REPLACE/);
|
|
185
|
+
if (!split) continue;
|
|
186
|
+
const all = /\ball="?(true|1|yes)"?/i.test(m[2]);
|
|
187
|
+
calls.push({
|
|
188
|
+
raw: m[0], offset: m.index, name: 'file_edit',
|
|
189
|
+
args: { path: m[1], old_string: split[1], new_string: split[2], replace_all: all || undefined },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
179
192
|
return calls;
|
|
180
193
|
}
|
|
181
194
|
|
|
@@ -212,12 +225,14 @@ function stripToolTags(text) {
|
|
|
212
225
|
out = out.replace(new RegExp(`<(?:${namesPattern})[^>]*>[\\s\\S]*?<\\/(?:${namesPattern})>`, 'gi'), '');
|
|
213
226
|
// Self-closing or unclosed: <tool_name attr="val" /> or <tool_name attr="val">
|
|
214
227
|
out = out.replace(new RegExp(`<(?:${namesPattern})(?:\\s[^>]*)?\\s*/?>`, 'gi'), '');
|
|
228
|
+
// Tag-form file ops — never surface their bodies as chat text
|
|
229
|
+
out = out.replace(/<(write|append|docx|edit)\s+[^>]*>[\s\S]*?<\/\1>/gi, '');
|
|
215
230
|
return out.trim();
|
|
216
231
|
}
|
|
217
232
|
|
|
218
233
|
// ── Main agent loop ───────────────────────────────────────────
|
|
219
234
|
|
|
220
|
-
const CONFIRM_TOOLS = new Set(['run_command', 'file_delete', 'file_write']);
|
|
235
|
+
const CONFIRM_TOOLS = new Set(['run_command', 'file_delete', 'file_write', 'file_edit']);
|
|
221
236
|
|
|
222
237
|
async function runAgent({ messages, model, uncensored, cwd, projectContext, images, send, confirm, signal }) {
|
|
223
238
|
const token = getToken();
|
package/lib/tools.js
CHANGED
|
@@ -47,6 +47,51 @@ function formatSize(bytes) {
|
|
|
47
47
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Directories never worth walking for search/glob
|
|
51
|
+
const SKIP_DIRS = new Set([
|
|
52
|
+
'node_modules', '.git', '__pycache__', '.next', 'dist', 'build',
|
|
53
|
+
'.venv', 'venv', '.cache', 'coverage', '.nyc_output', 'vendor',
|
|
54
|
+
'.idea', '.vscode', 'bin/obj', 'obj', '.gradle', 'target',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
// Minimal glob → RegExp. Supports ** * ? matched against a
|
|
58
|
+
// forward-slash relative path. Anchored full-string.
|
|
59
|
+
function globToRegExp(glob) {
|
|
60
|
+
let re = '';
|
|
61
|
+
for (let i = 0; i < glob.length; i++) {
|
|
62
|
+
const ch = glob[i];
|
|
63
|
+
if (ch === '*') {
|
|
64
|
+
if (glob[i + 1] === '*') { // ** → any depth (incl. slashes)
|
|
65
|
+
re += '.*';
|
|
66
|
+
i++;
|
|
67
|
+
if (glob[i + 1] === '/') i++; // swallow the slash after **
|
|
68
|
+
} else {
|
|
69
|
+
re += '[^/]*'; // * → within one path segment
|
|
70
|
+
}
|
|
71
|
+
} else if (ch === '?') re += '[^/]';
|
|
72
|
+
else if ('.+^${}()|[]\\'.includes(ch)) re += '\\' + ch;
|
|
73
|
+
else re += ch;
|
|
74
|
+
}
|
|
75
|
+
return new RegExp('^' + re + '$', 'i');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Recursively collect files under `root`, skipping SKIP_DIRS and oversized files.
|
|
79
|
+
function walkFiles(root, onFile, budget = { left: 20000 }) {
|
|
80
|
+
let entries;
|
|
81
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
if (budget.left <= 0) return;
|
|
84
|
+
const full = path.join(root, e.name);
|
|
85
|
+
if (e.isDirectory()) {
|
|
86
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith('.git')) continue;
|
|
87
|
+
walkFiles(full, onFile, budget);
|
|
88
|
+
} else {
|
|
89
|
+
budget.left--;
|
|
90
|
+
onFile(full);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
50
95
|
// ── Tool implementations ──────────────────────────────────────
|
|
51
96
|
|
|
52
97
|
const TOOLS = {
|
|
@@ -91,6 +136,103 @@ const TOOLS = {
|
|
|
91
136
|
};
|
|
92
137
|
},
|
|
93
138
|
|
|
139
|
+
async file_edit({ path: filePath, old_string, new_string, replace_all }, { cwd }) {
|
|
140
|
+
if (!filePath) throw new Error('"path" is required');
|
|
141
|
+
if (old_string === undefined) throw new Error('"old_string" is required');
|
|
142
|
+
if (new_string === undefined) throw new Error('"new_string" is required');
|
|
143
|
+
if (old_string === new_string) throw new Error('old_string and new_string are identical — nothing to change');
|
|
144
|
+
const resolved = resolvePath(filePath, cwd);
|
|
145
|
+
if (!fs.existsSync(resolved)) throw new Error(`Not found: ${filePath} — use file_write to create a new file`);
|
|
146
|
+
const stat = fs.statSync(resolved);
|
|
147
|
+
if (stat.isDirectory()) throw new Error(`"${filePath}" is a directory`);
|
|
148
|
+
if (stat.size > MAX_FILE_SIZE) throw new Error(`File too large (${formatSize(stat.size)}, max 500KB)`);
|
|
149
|
+
|
|
150
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
151
|
+
const first = content.indexOf(old_string);
|
|
152
|
+
if (first === -1) throw new Error(`old_string not found in ${filePath} — it must match the file exactly, including whitespace and indentation`);
|
|
153
|
+
|
|
154
|
+
let updated, count;
|
|
155
|
+
if (replace_all) {
|
|
156
|
+
count = content.split(old_string).length - 1;
|
|
157
|
+
updated = content.split(old_string).join(new_string);
|
|
158
|
+
} else {
|
|
159
|
+
if (content.indexOf(old_string, first + old_string.length) !== -1) {
|
|
160
|
+
throw new Error(`old_string appears multiple times in ${filePath} — add more surrounding context to make it unique, or pass "replace_all": true`);
|
|
161
|
+
}
|
|
162
|
+
count = 1;
|
|
163
|
+
updated = content.slice(0, first) + new_string + content.slice(first + old_string.length);
|
|
164
|
+
}
|
|
165
|
+
fs.writeFileSync(resolved, updated, 'utf8');
|
|
166
|
+
const delta = (new_string.split('\n').length) - (old_string.split('\n').length);
|
|
167
|
+
return {
|
|
168
|
+
output: `Edited ${filePath} — ${count} replacement${count !== 1 ? 's' : ''}${delta ? ` (${delta > 0 ? '+' : ''}${delta} lines)` : ''}`,
|
|
169
|
+
summary: `${count}× in ${path.basename(filePath)}`,
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async file_search({ pattern, path: searchPath = '.', glob, ignore_case, max_results }, { cwd }) {
|
|
174
|
+
if (!pattern) throw new Error('"pattern" is required');
|
|
175
|
+
let re;
|
|
176
|
+
try { re = new RegExp(pattern, ignore_case ? 'i' : ''); }
|
|
177
|
+
catch (e) { throw new Error(`Invalid regex pattern: ${e.message}`); }
|
|
178
|
+
const root = resolvePath(searchPath, cwd);
|
|
179
|
+
if (!fs.existsSync(root)) throw new Error(`Not found: ${searchPath}`);
|
|
180
|
+
const cap = Math.min(max_results || 100, 300);
|
|
181
|
+
const globRe = glob ? globToRegExp(glob.includes('/') ? glob : '**/' + glob) : null;
|
|
182
|
+
const hits = [];
|
|
183
|
+
let filesMatched = new Set();
|
|
184
|
+
|
|
185
|
+
const scanFile = (full) => {
|
|
186
|
+
if (hits.length >= cap) return;
|
|
187
|
+
const rel = path.relative(cwd, full).replace(/\\/g, '/');
|
|
188
|
+
if (globRe && !globRe.test(rel) && !globRe.test(path.basename(full))) return;
|
|
189
|
+
let stat; try { stat = fs.statSync(full); } catch { return; }
|
|
190
|
+
if (stat.size > MAX_FILE_SIZE) return;
|
|
191
|
+
let text; try { text = fs.readFileSync(full, 'utf8'); } catch { return; }
|
|
192
|
+
if (text.indexOf('\x00') !== -1) return; // skip binary files
|
|
193
|
+
const lines = text.split('\n');
|
|
194
|
+
for (let i = 0; i < lines.length && hits.length < cap; i++) {
|
|
195
|
+
re.lastIndex = 0;
|
|
196
|
+
if (re.test(lines[i])) {
|
|
197
|
+
filesMatched.add(rel);
|
|
198
|
+
hits.push(`${rel}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
if (fs.statSync(root).isDirectory()) walkFiles(root, scanFile);
|
|
204
|
+
else scanFile(root);
|
|
205
|
+
|
|
206
|
+
if (!hits.length) return { output: `No matches for /${pattern}/${glob ? ` in ${glob}` : ''}`, summary: '0 matches' };
|
|
207
|
+
const more = hits.length >= cap ? `\n… (capped at ${cap} matches)` : '';
|
|
208
|
+
return {
|
|
209
|
+
output: hits.join('\n') + more,
|
|
210
|
+
summary: `${hits.length} match${hits.length !== 1 ? 'es' : ''} in ${filesMatched.size} file${filesMatched.size !== 1 ? 's' : ''}`,
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
async file_glob({ pattern, path: searchPath = '.' }, { cwd }) {
|
|
215
|
+
if (!pattern) throw new Error('"pattern" is required');
|
|
216
|
+
const root = resolvePath(searchPath, cwd);
|
|
217
|
+
if (!fs.existsSync(root)) throw new Error(`Not found: ${searchPath}`);
|
|
218
|
+
const globRe = globToRegExp(pattern.includes('/') ? pattern : '**/' + pattern);
|
|
219
|
+
const results = [];
|
|
220
|
+
walkFiles(root, (full) => {
|
|
221
|
+
const rel = path.relative(root, full).replace(/\\/g, '/');
|
|
222
|
+
if (globRe.test(rel) || globRe.test(path.basename(full))) {
|
|
223
|
+
let mtime = 0; try { mtime = fs.statSync(full).mtimeMs; } catch {}
|
|
224
|
+
results.push({ rel: path.relative(cwd, full).replace(/\\/g, '/'), mtime });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
results.sort((a, b) => b.mtime - a.mtime);
|
|
228
|
+
const top = results.slice(0, 200);
|
|
229
|
+
const more = results.length > top.length ? `\n… and ${results.length - top.length} more` : '';
|
|
230
|
+
return {
|
|
231
|
+
output: top.length ? top.map(r => ' ' + r.rel).join('\n') + more : `No files match "${pattern}"`,
|
|
232
|
+
summary: `${results.length} file${results.length !== 1 ? 's' : ''} match "${pattern}"`,
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
|
|
94
236
|
async file_list({ path: dirPath = '.' }, { cwd }) {
|
|
95
237
|
const resolved = resolvePath(dirPath, cwd);
|
|
96
238
|
if (!fs.existsSync(resolved)) throw new Error(`Not found: ${dirPath}`);
|