@semalt-ai/code 1.19.0 → 1.20.1
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/.claude/settings.local.json +2 -1
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -1874
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/index.js +1 -1
- package/lib/agent.js +145 -16
- package/lib/api.js +28 -3
- package/lib/commands/chat-session.js +188 -4
- package/lib/commands/chat-slash.js +16 -0
- package/lib/commands/chat-turn.js +319 -52
- package/lib/commands/chat.js +12 -8
- package/lib/config.js +27 -0
- package/lib/constants.js +30 -1
- package/lib/headless.js +36 -1
- package/lib/images.js +8 -2
- package/lib/permissions.js +23 -16
- package/lib/prompts.js +15 -3
- package/lib/tool_registry.js +357 -53
- package/lib/tool_specs.js +42 -8
- package/lib/tools.js +80 -19
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +229 -0
- package/lib/ui/format.js +173 -28
- package/lib/ui/input-field.js +5 -4
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +99 -57
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -45
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +58 -6
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/package.json +1 -1
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +250 -13
- package/test/extract-tool-calls.test.js +37 -3
- package/test/file-activity.test.js +542 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/chat-harness.js +2 -1
- package/test/headless.test.js +146 -1
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +9 -7
- package/test/md-stream.test.js +183 -0
- package/test/narration-ordering.test.js +309 -0
- package/test/native-dispatch.test.js +53 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/permission-flush.test.js +302 -0
- package/test/permissions.test.js +199 -0
- package/test/read-paginate.test.js +1 -1
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +24 -0
- package/test/theme-palette.test.js +166 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +12 -3
- package/path +0 -1
package/lib/tool_registry.js
CHANGED
|
@@ -287,6 +287,22 @@ function _matchDual(text, template) {
|
|
|
287
287
|
return results;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
// 1-based starting line numbers of every literal occurrence of `needle` in
|
|
291
|
+
// `data`. Used by replace_in_file's uniqueness guard to tell the model WHERE the
|
|
292
|
+
// ambiguous matches are so it can add disambiguating context. Capped so a needle
|
|
293
|
+
// that matches hundreds of times doesn't produce a giant error string.
|
|
294
|
+
function _literalOccurrenceLines(data, needle, cap = 10) {
|
|
295
|
+
const lines = [];
|
|
296
|
+
let from = 0;
|
|
297
|
+
let idx;
|
|
298
|
+
while ((idx = data.indexOf(needle, from)) !== -1) {
|
|
299
|
+
lines.push(data.slice(0, idx).split('\n').length);
|
|
300
|
+
from = idx + needle.length;
|
|
301
|
+
if (lines.length >= cap) break;
|
|
302
|
+
}
|
|
303
|
+
return lines;
|
|
304
|
+
}
|
|
305
|
+
|
|
290
306
|
function _unwrapInnerTag(inner) {
|
|
291
307
|
if (inner == null) return inner;
|
|
292
308
|
const trimmed = String(inner).trim();
|
|
@@ -367,6 +383,12 @@ async function _execWriteAppend(ctx, action, args, options) {
|
|
|
367
383
|
}
|
|
368
384
|
|
|
369
385
|
try {
|
|
386
|
+
// Capture prior content so the diff can render at EXECUTION time (the agent
|
|
387
|
+
// loop hands _diffBefore/_diffAfter to onToolEnd). Non-existent file → '' →
|
|
388
|
+
// the diff renders as a new file. Cheap relative to the write itself.
|
|
389
|
+
let before = '';
|
|
390
|
+
try { before = await fsp.readFile(filePath, 'utf8'); } catch {}
|
|
391
|
+
const after = action === 'write' ? (content || '') : (before + (content || ''));
|
|
370
392
|
const dir = path.dirname(filePath);
|
|
371
393
|
if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
|
|
372
394
|
if (action === 'write') await fsp.writeFile(filePath, content || '');
|
|
@@ -374,7 +396,7 @@ async function _execWriteAppend(ctx, action, args, options) {
|
|
|
374
396
|
const verb = action === 'write' ? 'Wrote' : 'Appended to';
|
|
375
397
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}${verb} ${filePath}${RST}`);
|
|
376
398
|
logToolCall(tag, { path: filePath, content }, true, 'ok');
|
|
377
|
-
return { status: 'ok', path: filePath, bytes: (content || '').length };
|
|
399
|
+
return { status: 'ok', path: filePath, bytes: (content || '').length, _diffBefore: before, _diffAfter: after };
|
|
378
400
|
} catch (error) {
|
|
379
401
|
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
380
402
|
logToolCall(tag, { path: filePath, content }, true, 'error');
|
|
@@ -383,25 +405,29 @@ async function _execWriteAppend(ctx, action, args, options) {
|
|
|
383
405
|
}
|
|
384
406
|
|
|
385
407
|
async function _permWriteAppend(ctx, action, args) {
|
|
386
|
-
const { _dryRun, renderDiff,
|
|
408
|
+
const { _dryRun, renderDiff, writer } = ctx;
|
|
387
409
|
const _uiActive = ctx._uiActive;
|
|
388
410
|
const filePath = args[0];
|
|
389
411
|
const content = args[1];
|
|
390
412
|
const tag = action === 'write' ? 'write_file' : 'append_file';
|
|
391
413
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (!_uiActive)
|
|
414
|
+
// The full diff is rendered at EXECUTION time (the agent loop's onToolEnd),
|
|
415
|
+
// decoupled from this modal — so an auto-approved write shows its diff just
|
|
416
|
+
// like a manually-approved one, and the diff is shown exactly once. The modal
|
|
417
|
+
// therefore carries only a compact description, NOT the diff. The one path
|
|
418
|
+
// without an execution-time renderer (headless / oneshot, !_uiActive) still
|
|
419
|
+
// surfaces the diff here so a write is never silent there.
|
|
420
|
+
if (!_uiActive && !_dryRun) {
|
|
421
|
+
let existing = '';
|
|
422
|
+
try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
|
|
423
|
+
const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
|
|
424
|
+
writer.scrollback(renderDiff(existing, finalContent, filePath));
|
|
425
|
+
}
|
|
399
426
|
|
|
400
427
|
if (_dryRun) return null;
|
|
401
428
|
|
|
402
429
|
let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
|
|
403
430
|
if (content) desc += ` (${content.length} chars)`;
|
|
404
|
-
if (_uiActive) desc = `${desc}\n${diffOutput}`;
|
|
405
431
|
return { actionType: 'file', description: desc, tag };
|
|
406
432
|
}
|
|
407
433
|
|
|
@@ -620,10 +646,93 @@ function _finalizeGrep(raw, pattern) {
|
|
|
620
646
|
return out;
|
|
621
647
|
}
|
|
622
648
|
|
|
649
|
+
// Resolve the grep `path` argument into a concrete search plan. `path` is no
|
|
650
|
+
// longer a glob-only filter: it may denote an existing FILE (search just that
|
|
651
|
+
// file, like search_in_file), an existing DIRECTORY (use it as the walk root),
|
|
652
|
+
// or — when it is NOT an existing filesystem path — a GLOB filter applied to the
|
|
653
|
+
// cwd walk (the legacy behavior, preserved for callers like `path="*.js"`). A
|
|
654
|
+
// `path` that is a literal that does not exist falls into the glob branch and
|
|
655
|
+
// simply matches zero candidate files; the wrapper's safety net then turns that
|
|
656
|
+
// into a clear diagnostic instead of a silent {count:0}. Relative paths resolve
|
|
657
|
+
// against the process cwd (statSync semantics), matching read_file/search_in_file.
|
|
658
|
+
function _resolveGrepPath(pathArg) {
|
|
659
|
+
if (pathArg == null || pathArg === '') return { mode: 'none', baseDir: '.', pathGlob: null };
|
|
660
|
+
let st = null;
|
|
661
|
+
try { st = fs.statSync(pathArg); } catch { st = null; }
|
|
662
|
+
if (st && st.isFile()) return { mode: 'file', file: pathArg, display: pathArg };
|
|
663
|
+
if (st && st.isDirectory()) return { mode: 'dir', baseDir: pathArg, pathGlob: null };
|
|
664
|
+
return { mode: 'glob', baseDir: '.', pathGlob: pathArg };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Grep a single explicit file (FILE-target mode). Engine-independent — an
|
|
668
|
+
// explicitly named file is read directly (like search_in_file), which also means
|
|
669
|
+
// it is searched even if .gitignore'd, since the model asked for THIS file. Binary
|
|
670
|
+
// files yield no matches. Returns the same { matches:[{file,line,text}] } shape as
|
|
671
|
+
// _grepNode so it flows through _finalizeGrep identically.
|
|
672
|
+
function _grepFile({ pattern, ignoreCase, file, display, signal }) {
|
|
673
|
+
let re;
|
|
674
|
+
try { re = new RegExp(pattern, ignoreCase ? 'i' : ''); }
|
|
675
|
+
catch (err) { return { error: `Invalid regex pattern: ${err.message}` }; }
|
|
676
|
+
if (signal && signal.aborted) return { aborted: true };
|
|
677
|
+
let buf;
|
|
678
|
+
try { buf = fs.readFileSync(file); } catch (err) { return { error: err.message }; }
|
|
679
|
+
if (_isBinaryBuf(buf)) return { matches: [] };
|
|
680
|
+
const data = buf.toString('utf8');
|
|
681
|
+
const lines = data.split('\n');
|
|
682
|
+
if (data.endsWith('\n')) lines.pop();
|
|
683
|
+
const posix = _toPosix(display);
|
|
684
|
+
const matches = [];
|
|
685
|
+
for (let i = 0; i < lines.length; i++) {
|
|
686
|
+
if (re.test(lines[i])) matches.push({ file: posix, line: i + 1, text: lines[i] });
|
|
687
|
+
}
|
|
688
|
+
return { matches };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Does the walk root contain ANY file grep would consider searching (passing the
|
|
692
|
+
// gitignore + skip-dir + pathGlob filters)? Used ONLY by the safety net, and only
|
|
693
|
+
// when a result came back empty — it answers "was there anything to search at
|
|
694
|
+
// all?" so a glob that matched real files (true negative) is never demoted to an
|
|
695
|
+
// error. Deliberately does NOT read file contents (no binary sniff): over-counting
|
|
696
|
+
// errs toward returning {count:0}, the safe direction. Short-circuits on first hit.
|
|
697
|
+
function _grepHasCandidate({ baseDir, pathGlob, signal }) {
|
|
698
|
+
const rules = _loadGitignore(baseDir);
|
|
699
|
+
const pf = pathGlob ? _globToRegExp(pathGlob) : null;
|
|
700
|
+
const pfBasename = pathGlob && !pathGlob.includes('/');
|
|
701
|
+
let found = false;
|
|
702
|
+
_walkTree(baseDir, {
|
|
703
|
+
rules,
|
|
704
|
+
signal,
|
|
705
|
+
onFile: (rel, name) => {
|
|
706
|
+
if (found) return;
|
|
707
|
+
if (pf && !pf.test(pfBasename ? name : rel)) return;
|
|
708
|
+
found = true;
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
return found;
|
|
712
|
+
}
|
|
713
|
+
|
|
623
714
|
// engine: 'auto' (rg if available, else Node), 'rg', or 'node'. Exported for
|
|
624
715
|
// the parity tests, which drive both engines and assert deep equality.
|
|
625
|
-
|
|
716
|
+
//
|
|
717
|
+
// `path` (when supplied) is the path-aware target: it is resolved via
|
|
718
|
+
// _resolveGrepPath into a FILE read / DIRECTORY walk root / GLOB filter, and feeds
|
|
719
|
+
// BOTH engines identically (FILE mode is a direct read, trivially engine-agnostic).
|
|
720
|
+
// The legacy `pathGlob`/`baseDir` params are still honored directly for callers
|
|
721
|
+
// that pre-resolve (the parity tests). The safety net fires ONLY on the `path`
|
|
722
|
+
// pathway, so existing pathGlob/baseDir callers are byte-for-byte unaffected.
|
|
723
|
+
function _grepSearch({ pattern, path: pathArg = null, pathGlob = null, ignoreCase = false, baseDir = '.', engine = 'auto', signal = null }) {
|
|
626
724
|
if (typeof pattern !== 'string' || pattern === '') return { error: 'grep: pattern is required' };
|
|
725
|
+
const pathSupplied = pathArg != null && pathArg !== '';
|
|
726
|
+
if (pathSupplied) {
|
|
727
|
+
const plan = _resolveGrepPath(pathArg);
|
|
728
|
+
if (plan.mode === 'file') {
|
|
729
|
+
const raw = _grepFile({ pattern, ignoreCase, file: plan.file, display: plan.display, signal });
|
|
730
|
+
if (raw && (raw.error || raw.aborted)) return raw;
|
|
731
|
+
return _finalizeGrep(raw, pattern); // an existing file with 0 hits is a true negative, not an error
|
|
732
|
+
}
|
|
733
|
+
baseDir = plan.baseDir;
|
|
734
|
+
pathGlob = plan.pathGlob;
|
|
735
|
+
}
|
|
627
736
|
const useRg = engine === 'rg' || (engine === 'auto' && !!_detectRipgrep());
|
|
628
737
|
let raw;
|
|
629
738
|
if (useRg) {
|
|
@@ -635,7 +744,17 @@ function _grepSearch({ pattern, pathGlob = null, ignoreCase = false, baseDir = '
|
|
|
635
744
|
raw = _grepNode({ pattern, pathGlob, ignoreCase, baseDir, signal });
|
|
636
745
|
}
|
|
637
746
|
if (raw && (raw.error || raw.aborted)) return raw;
|
|
638
|
-
|
|
747
|
+
const out = _finalizeGrep(raw, pattern);
|
|
748
|
+
// Safety net (FIX 2): a `path` was supplied but there was NOTHING to search
|
|
749
|
+
// (path doesn't exist, or the glob/dir matched zero candidate files). Surface a
|
|
750
|
+
// diagnostic instead of a silent {count:0} that reads as "pattern absent". Gated
|
|
751
|
+
// strictly on zero CANDIDATE FILES — a glob that matched real files where the
|
|
752
|
+
// pattern just isn't present still returns {count:0} (a true negative).
|
|
753
|
+
if (out && !out.error && out.count === 0 && pathSupplied
|
|
754
|
+
&& !_grepHasCandidate({ baseDir, pathGlob, signal })) {
|
|
755
|
+
return { error: `grep: path "${pathArg}" did not resolve to any file within the search root ${baseDir}` };
|
|
756
|
+
}
|
|
757
|
+
return out;
|
|
639
758
|
}
|
|
640
759
|
|
|
641
760
|
function _globSearch({ pattern, baseDir = '.', signal = null }) {
|
|
@@ -1170,6 +1289,53 @@ const TOOL_REGISTRY = [
|
|
|
1170
1289
|
}
|
|
1171
1290
|
},
|
|
1172
1291
|
},
|
|
1292
|
+
{
|
|
1293
|
+
tool: 'view_image',
|
|
1294
|
+
specNames: ['view_image'],
|
|
1295
|
+
tags: ['view_image'],
|
|
1296
|
+
// Both XML forms accepted: the attribute form `<view_image path="…"/>` and
|
|
1297
|
+
// the inline form `<view_image>PATH</view_image>` (like read_file/download).
|
|
1298
|
+
parseXml: (text) => {
|
|
1299
|
+
const out = [];
|
|
1300
|
+
for (const m of _matchDual(text, '<view_image\\s+path=Q([^Q]+)Q\\s*(?:><\\/view_image>|\\/>)')) {
|
|
1301
|
+
out.push(['view_image', m[1]]);
|
|
1302
|
+
}
|
|
1303
|
+
for (const m of text.matchAll(/<view_image>([\s\S]*?)<\/view_image>/g)) {
|
|
1304
|
+
out.push(['view_image', _unwrapInnerTag(m[1]).trim()]);
|
|
1305
|
+
}
|
|
1306
|
+
return out;
|
|
1307
|
+
},
|
|
1308
|
+
fromParams: (p) => (p.path ? ['view_image', String(p.path)] : null),
|
|
1309
|
+
// Read-only: a local file read, so no permission gate (parity with read_file /
|
|
1310
|
+
// grep, permission: () => null). Path safety + the size cap are enforced by
|
|
1311
|
+
// readImage (via isPathSafe) inside execute, exactly as the /image command does.
|
|
1312
|
+
permission: () => null,
|
|
1313
|
+
// Stage a LOCAL image into vision context. Reuses readImage — the SAME encoder
|
|
1314
|
+
// the /image slash command uses (read through isPathSafe, size-capped, magic-byte
|
|
1315
|
+
// media-type detect, base64). The returned `image` record is collected by the
|
|
1316
|
+
// agent loop and attached to the tool-result message's `images[]`, so api.js
|
|
1317
|
+
// buildProviderMessages turns it into a provider image block on the next turn.
|
|
1318
|
+
execute: async (ctx, args) => {
|
|
1319
|
+
const [arg0 = null] = args;
|
|
1320
|
+
const { _log, logToolCall, isPathSafe, getConfig, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
|
|
1321
|
+
const filePath = arg0;
|
|
1322
|
+
try {
|
|
1323
|
+
const { readImage } = require('./images');
|
|
1324
|
+
const cfg = getConfig ? getConfig() : {};
|
|
1325
|
+
const img = readImage(filePath, { maxBytes: cfg.image_max_bytes, isPathSafe });
|
|
1326
|
+
const kb = Math.max(1, Math.round(img.bytes / 1024));
|
|
1327
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Viewing image ${filePath} (${img.media_type}, ${kb} KB)${RST}`);
|
|
1328
|
+
logToolCall('view_image', { path: filePath }, true, 'ok');
|
|
1329
|
+
// `image` carries the base64 bytes for staging; it never enters the
|
|
1330
|
+
// model-facing text (formatFileResult builds a short confirmation line).
|
|
1331
|
+
return { status: 'ok', path: filePath, media_type: img.media_type, bytes: img.bytes, image: img };
|
|
1332
|
+
} catch (error) {
|
|
1333
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
1334
|
+
logToolCall('view_image', { path: filePath }, true, 'error');
|
|
1335
|
+
return { error: error.message };
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
},
|
|
1173
1339
|
{
|
|
1174
1340
|
tool: 'write',
|
|
1175
1341
|
specNames: ['write_file', 'create_file'],
|
|
@@ -1294,9 +1460,16 @@ const TOOL_REGISTRY = [
|
|
|
1294
1460
|
permission: () => null,
|
|
1295
1461
|
execute: async (ctx, args, options) => {
|
|
1296
1462
|
const signal = (options && options.signal) || null;
|
|
1297
|
-
const [pattern = null,
|
|
1298
|
-
const { _log, logToolCall, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
|
|
1299
|
-
|
|
1463
|
+
const [pattern = null, pathArg = null, ignoreCase = false, outputMode = null, headLimit, offset] = args;
|
|
1464
|
+
const { _log, logToolCall, isProtectedSecretPath, _secretReadError, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
|
|
1465
|
+
// Path-as-target now reads files directly, so apply the SAME secret-file read
|
|
1466
|
+
// guard read_file/search_in_file use (the OS sandbox remains the outer
|
|
1467
|
+
// confinement; this just refuses the credential/history files by name).
|
|
1468
|
+
if (pathArg != null && pathArg !== '' && isProtectedSecretPath(pathArg)) {
|
|
1469
|
+
logToolCall('grep', { pattern, path: pathArg }, false, 'denied');
|
|
1470
|
+
return _secretReadError(pathArg);
|
|
1471
|
+
}
|
|
1472
|
+
const res = _grepSearch({ pattern, path: pathArg, ignoreCase, engine: 'auto', signal });
|
|
1300
1473
|
if (res.aborted) { logToolCall('grep', { pattern }, true, 'aborted'); return res; }
|
|
1301
1474
|
if (res.error) {
|
|
1302
1475
|
_log(` ${FG_RED}✗ ${res.error}${RST}`);
|
|
@@ -1310,7 +1483,7 @@ const TOOL_REGISTRY = [
|
|
|
1310
1483
|
res.head_limit = _normHeadLimit(headLimit, require('./constants').DEFAULT_GREP_HEAD_LIMIT);
|
|
1311
1484
|
res.offset = _normOffset(offset);
|
|
1312
1485
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}grep "${pattern}" — ${res.count} match(es)${RST}`);
|
|
1313
|
-
logToolCall('grep', { pattern, path:
|
|
1486
|
+
logToolCall('grep', { pattern, path: pathArg }, true, 'ok');
|
|
1314
1487
|
return res;
|
|
1315
1488
|
},
|
|
1316
1489
|
},
|
|
@@ -1548,15 +1721,30 @@ const TOOL_REGISTRY = [
|
|
|
1548
1721
|
tool: 'edit_file',
|
|
1549
1722
|
specNames: ['edit_file'],
|
|
1550
1723
|
tags: ['edit_file'],
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1724
|
+
// Optional `end_line` (W.5 trailing-arg discipline: absent → the 4-element
|
|
1725
|
+
// single-line tuple, byte-for-byte the prior behavior). When present, lines
|
|
1726
|
+
// `line..end_line` are replaced wholesale by the content — a regex-free way
|
|
1727
|
+
// to swap a block, pairing with read_file's start_line/end_line + line
|
|
1728
|
+
// numbers (read a numbered slice, then replace that exact range).
|
|
1729
|
+
parseXml: (text) => _matchDual(text, '<edit_file\\s+path=Q([^Q]+)Q\\s+line=Q(\\d+)Q(?:\\s+end_line=Q(\\d+)Q)?>([\\s\\S]*?)<\\/edit_file>').map((m) => {
|
|
1730
|
+
const call = ['edit_file', m[1], parseInt(m[2], 10), m[4]];
|
|
1731
|
+
if (m[3] != null) call.push(parseInt(m[3], 10));
|
|
1732
|
+
return call;
|
|
1733
|
+
}),
|
|
1734
|
+
fromParams: (p) => {
|
|
1735
|
+
if (!(p.path && p.line !== undefined)) return null;
|
|
1736
|
+
const call = ['edit_file', p.path, parseInt(p.line, 10), p.content != null ? p.content : ''];
|
|
1737
|
+
if (p.end_line != null) call.push(parseInt(p.end_line, 10));
|
|
1738
|
+
return call;
|
|
1739
|
+
},
|
|
1740
|
+
permission: (ctx, args) => ({ actionType: 'file', description: args[4] != null ? `Edit lines ${args[1]}-${args[4]} in ${args[0]}` : `Edit line ${args[1]} in ${args[0]}`, tag: 'edit_file' }),
|
|
1554
1741
|
execute: async (ctx, args) => {
|
|
1555
|
-
const [arg0 = null, arg1 = null, arg2 = null] = args;
|
|
1742
|
+
const [arg0 = null, arg1 = null, arg2 = null, arg3] = args;
|
|
1556
1743
|
const { _log, logToolCall, isProtectedConfigPath, _protectedConfigWriteError, permissionManager, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
|
|
1557
1744
|
const filePath = arg0;
|
|
1558
1745
|
const lineNum = arg1;
|
|
1559
1746
|
const newContent = arg2;
|
|
1747
|
+
const endLine = arg3; // undefined → single-line edit (unchanged)
|
|
1560
1748
|
const blocked = permissionManager.readonlyBlock('edit_file');
|
|
1561
1749
|
if (blocked) {
|
|
1562
1750
|
logToolCall('edit_file', { path: filePath, line: lineNum }, false, 'denied');
|
|
@@ -1573,11 +1761,28 @@ const TOOL_REGISTRY = [
|
|
|
1573
1761
|
logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'error');
|
|
1574
1762
|
return { error: `Line ${lineNum} out of range (file has ${lines.length} lines)` };
|
|
1575
1763
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1764
|
+
if (endLine == null) {
|
|
1765
|
+
// Single-line replace — exactly the prior behavior (no regression).
|
|
1766
|
+
lines[lineNum - 1] = newContent;
|
|
1767
|
+
const after = lines.join('\n');
|
|
1768
|
+
await fsp.writeFile(filePath, after);
|
|
1769
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Edited line ${lineNum} in ${filePath}${RST}`);
|
|
1770
|
+
logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'ok');
|
|
1771
|
+
// _diffBefore/_diffAfter feed the execution-time diff renderer (onToolEnd).
|
|
1772
|
+
return { status: 'ok', path: filePath, line: lineNum, _diffBefore: data, _diffAfter: after };
|
|
1773
|
+
}
|
|
1774
|
+
// Line-range replace: swap lines lineNum..endLine for newContent (which
|
|
1775
|
+
// may itself be multi-line). No regex involved → no ReDoS surface.
|
|
1776
|
+
if (endLine < lineNum || endLine > lines.length) {
|
|
1777
|
+
logToolCall('edit_file', { path: filePath, line: lineNum, end_line: endLine }, true, 'error');
|
|
1778
|
+
return { error: `Line range ${lineNum}-${endLine} out of range (file has ${lines.length} lines)` };
|
|
1779
|
+
}
|
|
1780
|
+
lines.splice(lineNum - 1, endLine - lineNum + 1, ...newContent.split('\n'));
|
|
1781
|
+
const afterRange = lines.join('\n');
|
|
1782
|
+
await fsp.writeFile(filePath, afterRange);
|
|
1783
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Edited lines ${lineNum}-${endLine} in ${filePath}${RST}`);
|
|
1784
|
+
logToolCall('edit_file', { path: filePath, line: lineNum, end_line: endLine }, true, 'ok');
|
|
1785
|
+
return { status: 'ok', path: filePath, line: lineNum, end_line: endLine, _diffBefore: data, _diffAfter: afterRange };
|
|
1581
1786
|
} catch (error) {
|
|
1582
1787
|
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
1583
1788
|
logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'error');
|
|
@@ -1594,7 +1799,7 @@ const TOOL_REGISTRY = [
|
|
|
1594
1799
|
permission: () => null,
|
|
1595
1800
|
execute: async (ctx, args) => {
|
|
1596
1801
|
const [arg0 = null, arg1 = null] = args;
|
|
1597
|
-
const { _log, logToolCall, isProtectedSecretPath, _secretReadError, _checkRegexSafety, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
|
|
1802
|
+
const { _log, logToolCall, isProtectedSecretPath, _secretReadError, _checkRegexSafety, _isLiteralPattern, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
|
|
1598
1803
|
const filePath = arg0;
|
|
1599
1804
|
const pattern = arg1;
|
|
1600
1805
|
if (isProtectedSecretPath(filePath)) {
|
|
@@ -1608,9 +1813,13 @@ const TOOL_REGISTRY = [
|
|
|
1608
1813
|
logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
|
|
1609
1814
|
return guardErr;
|
|
1610
1815
|
}
|
|
1611
|
-
|
|
1816
|
+
// A metacharacter-free pattern is matched literally (substring) — same
|
|
1817
|
+
// line results as a regex for plain text, but unbounded by length so a
|
|
1818
|
+
// long pasted block can be located. Genuine regexes still compile.
|
|
1819
|
+
const isLiteral = _isLiteralPattern(pattern);
|
|
1820
|
+
const test = isLiteral ? (content) => content.includes(pattern) : ((regex) => (content) => regex.test(content))(new RegExp(pattern));
|
|
1612
1821
|
const matches = data.split('\n')
|
|
1613
|
-
.map((content, idx) =>
|
|
1822
|
+
.map((content, idx) => test(content) ? { line: idx + 1, content } : null)
|
|
1614
1823
|
.filter(Boolean);
|
|
1615
1824
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${matches.length} match(es) in ${filePath}${RST}`);
|
|
1616
1825
|
logToolCall('search_in_file', { path: filePath, pattern }, true, 'ok');
|
|
@@ -1626,16 +1835,43 @@ const TOOL_REGISTRY = [
|
|
|
1626
1835
|
tool: 'replace_in_file',
|
|
1627
1836
|
specNames: ['replace_in_file'],
|
|
1628
1837
|
tags: ['replace_in_file'],
|
|
1629
|
-
|
|
1630
|
-
|
|
1838
|
+
// LITERAL by default (Claude Code Edit model): the search text is matched
|
|
1839
|
+
// VERBATIM, byte-for-byte, no matter what regex-special chars it contains —
|
|
1840
|
+
// so a copied code block with ( ) { } . [ ] just works. Regex is opt-in via
|
|
1841
|
+
// `regex="true"`. Matching must be UNIQUE: 0 matches → error (no-op masked as
|
|
1842
|
+
// success was the silent-corruption trap), >1 matches → error unless
|
|
1843
|
+
// `replace_all="true"`. Inline body = regex flags (only meaningful with
|
|
1844
|
+
// regex="true"). Attr-based parse (like read_file) so the optional flags
|
|
1845
|
+
// appear in any order.
|
|
1846
|
+
parseXml: (text) => {
|
|
1847
|
+
const out = [];
|
|
1848
|
+
const re = /<replace_in_file\b([^>]*?)>([\s\S]*?)<\/replace_in_file>/g;
|
|
1849
|
+
for (const m of text.matchAll(re)) {
|
|
1850
|
+
const attrStr = m[1] || '';
|
|
1851
|
+
const body = m[2] != null ? m[2] : '';
|
|
1852
|
+
const attr = (k) => {
|
|
1853
|
+
const mm = attrStr.match(new RegExp(`${k}="([^"]*)"`)) || attrStr.match(new RegExp(`${k}='([^']*)'`));
|
|
1854
|
+
return mm ? mm[1] : null;
|
|
1855
|
+
};
|
|
1856
|
+
const p = attr('path');
|
|
1857
|
+
const search = attr('search');
|
|
1858
|
+
if (p == null || search == null) continue;
|
|
1859
|
+
const replace = attr('replace') != null ? attr('replace') : '';
|
|
1860
|
+
out.push(['replace_in_file', p, search, replace, body.trim(), attr('regex') === 'true', attr('replace_all') === 'true']);
|
|
1861
|
+
}
|
|
1862
|
+
return out;
|
|
1863
|
+
},
|
|
1864
|
+
fromParams: (p) => (p.path && p.search !== undefined ? ['replace_in_file', p.path, p.search, p.replace != null ? p.replace : '', p.flags || '', p.regex === true || p.regex === 'true', p.replace_all === true || p.replace_all === 'true'] : null),
|
|
1631
1865
|
permission: (ctx, args) => ({ actionType: 'file', description: `Replace in ${args[0]}`, tag: 'replace_in_file' }),
|
|
1632
1866
|
execute: async (ctx, args) => {
|
|
1633
|
-
const [arg0 = null, arg1 = null, arg2 = null, arg3 = null] = args;
|
|
1867
|
+
const [arg0 = null, arg1 = null, arg2 = null, arg3 = null, arg4 = false, arg5 = false] = args;
|
|
1634
1868
|
const { _log, logToolCall, isProtectedConfigPath, _protectedConfigWriteError, _checkRegexSafety, permissionManager, FG_GREEN, FG_GRAY, FG_RED, RST } = ctx;
|
|
1635
1869
|
const filePath = arg0;
|
|
1636
1870
|
const searchStr = arg1;
|
|
1637
1871
|
const replaceStr = arg2;
|
|
1638
1872
|
const flags = arg3 || '';
|
|
1873
|
+
const useRegex = arg4 === true;
|
|
1874
|
+
const replaceAll = arg5 === true;
|
|
1639
1875
|
const blocked = permissionManager.readonlyBlock('replace_in_file');
|
|
1640
1876
|
if (blocked) {
|
|
1641
1877
|
logToolCall('replace_in_file', { path: filePath, search: searchStr }, false, 'denied');
|
|
@@ -1647,28 +1883,79 @@ const TOOL_REGISTRY = [
|
|
|
1647
1883
|
}
|
|
1648
1884
|
try {
|
|
1649
1885
|
const data = await fsp.readFile(filePath, 'utf8');
|
|
1650
|
-
|
|
1651
|
-
if (guardErr) {
|
|
1886
|
+
if (searchStr == null || searchStr === '') {
|
|
1652
1887
|
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
|
|
1653
|
-
return
|
|
1888
|
+
return { error: 'replace_in_file: search string is empty — nothing to match.' };
|
|
1889
|
+
}
|
|
1890
|
+
let newData;
|
|
1891
|
+
let count;
|
|
1892
|
+
let remaining; // post-replace occurrences of the search string in newData
|
|
1893
|
+
if (!useRegex) {
|
|
1894
|
+
// LITERAL path (default): O(dataLen) substring match — no regex
|
|
1895
|
+
// compiled, so a long block is matched verbatim with no ReDoS surface
|
|
1896
|
+
// and no length bound. The replacement is raw text (no $1/$& handling).
|
|
1897
|
+
const parts = data.split(searchStr);
|
|
1898
|
+
count = parts.length - 1;
|
|
1899
|
+
// ---- Uniqueness / occurrence guard (BEFORE writing) ----
|
|
1900
|
+
if (count === 0) {
|
|
1901
|
+
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
|
|
1902
|
+
return { error: `replace_in_file: search string not found in ${filePath} — file unchanged. Verify exact text including whitespace/indentation.` };
|
|
1903
|
+
}
|
|
1904
|
+
if (count > 1 && !replaceAll) {
|
|
1905
|
+
const lines = _literalOccurrenceLines(data, searchStr);
|
|
1906
|
+
const at = lines.length ? ` (matches start at line${lines.length > 1 ? 's' : ''} ${lines.join(', ')}${count > lines.length ? ', …' : ''})` : '';
|
|
1907
|
+
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
|
|
1908
|
+
return { error: `replace_in_file: found ${count} matches but replace_all is not set${at}. Add surrounding context to uniquely identify ONE occurrence, or set replace_all:true to replace all ${count}.` };
|
|
1909
|
+
}
|
|
1910
|
+
if (replaceAll) {
|
|
1911
|
+
newData = parts.join(replaceStr);
|
|
1912
|
+
} else {
|
|
1913
|
+
// count === 1: replace the single occurrence.
|
|
1914
|
+
const idx = data.indexOf(searchStr);
|
|
1915
|
+
newData = data.slice(0, idx) + replaceStr + data.slice(idx + searchStr.length);
|
|
1916
|
+
count = 1;
|
|
1917
|
+
}
|
|
1918
|
+
remaining = newData.split(searchStr).length - 1;
|
|
1919
|
+
} else {
|
|
1920
|
+
// REGEX path (opt-in via regex:true): ReDoS guard applies. `g` flag OR
|
|
1921
|
+
// replace_all replaces all; otherwise the match must be unique.
|
|
1922
|
+
const guardErr = _checkRegexSafety(searchStr, data, false);
|
|
1923
|
+
if (guardErr) {
|
|
1924
|
+
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
|
|
1925
|
+
return guardErr;
|
|
1926
|
+
}
|
|
1927
|
+
const safeFlags = flags.replace(/[^gimsuy]/g, '');
|
|
1928
|
+
const globalFlags = safeFlags.replace('g', '') + 'g';
|
|
1929
|
+
const isGlobal = safeFlags.includes('g') || replaceAll;
|
|
1930
|
+
count = (data.match(new RegExp(searchStr, globalFlags)) || []).length;
|
|
1931
|
+
// ---- Uniqueness / occurrence guard (BEFORE writing) ----
|
|
1932
|
+
if (count === 0) {
|
|
1933
|
+
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
|
|
1934
|
+
return { error: `replace_in_file: pattern not found in ${filePath} — file unchanged. Verify the regex (or drop regex:true to match literally).` };
|
|
1935
|
+
}
|
|
1936
|
+
if (count > 1 && !isGlobal) {
|
|
1937
|
+
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
|
|
1938
|
+
return { error: `replace_in_file: pattern matched ${count} times but neither the "g" flag nor replace_all is set. Refine the pattern to match ONE occurrence, or set replace_all:true to replace all ${count}.` };
|
|
1939
|
+
}
|
|
1940
|
+
const regex = new RegExp(searchStr, (isGlobal ? globalFlags : safeFlags.replace('g', '')) || undefined);
|
|
1941
|
+
newData = data.replace(regex, replaceStr);
|
|
1942
|
+
count = isGlobal ? count : 1;
|
|
1943
|
+
remaining = (newData.match(new RegExp(searchStr, globalFlags)) || []).length;
|
|
1654
1944
|
}
|
|
1655
|
-
const safeFlags = flags.replace(/[^gimsuy]/g, '');
|
|
1656
|
-
const regex = new RegExp(searchStr, safeFlags || undefined);
|
|
1657
|
-
// Semantics (intentional, unchanged): String.prototype.replace replaces
|
|
1658
|
-
// ALL matches only when the regex is global; without "g" it replaces just
|
|
1659
|
-
// the first match. The returned count must equal the replacements actually
|
|
1660
|
-
// performed — so count all matches when global, else 1 if there is a match
|
|
1661
|
-
// (else 0). (Task 1.4c: previously count was computed with an always-global
|
|
1662
|
-
// regex and overstated non-global replacements.)
|
|
1663
|
-
const isGlobal = safeFlags.includes('g');
|
|
1664
|
-
const count = isGlobal
|
|
1665
|
-
? (data.match(new RegExp(searchStr, safeFlags)) || []).length
|
|
1666
|
-
: (new RegExp(searchStr, safeFlags).test(data) ? 1 : 0);
|
|
1667
|
-
const newData = data.replace(regex, replaceStr);
|
|
1668
1945
|
await fsp.writeFile(filePath, newData);
|
|
1669
|
-
|
|
1946
|
+
const result = { status: 'ok', path: filePath, count, _diffBefore: data, _diffAfter: newData };
|
|
1947
|
+
// POST-REPLACE VERIFICATION: if the search string still appears (e.g. the
|
|
1948
|
+
// replacement contains it, or matches overlapped), surface a warning
|
|
1949
|
+
// instead of reporting clean success.
|
|
1950
|
+
if (remaining > 0) {
|
|
1951
|
+
result.warning = `replace_in_file: replaced ${count} occurrence(s), but the search string still appears ${remaining} time(s) in ${filePath} (the replacement may contain the search text, or matches overlapped).`;
|
|
1952
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Replaced ${count} occurrence(s) in ${filePath} (${remaining} still present)${RST}`);
|
|
1953
|
+
} else {
|
|
1954
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Replaced ${count} occurrence(s) in ${filePath}${RST}`);
|
|
1955
|
+
}
|
|
1670
1956
|
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'ok');
|
|
1671
|
-
|
|
1957
|
+
// _diffBefore/_diffAfter feed the execution-time diff renderer (onToolEnd).
|
|
1958
|
+
return result;
|
|
1672
1959
|
} catch (error) {
|
|
1673
1960
|
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
1674
1961
|
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
|
|
@@ -2269,14 +2556,26 @@ const TOOL_REGISTRY = [
|
|
|
2269
2556
|
tags: ['ask_user'],
|
|
2270
2557
|
parseXml: (text) => _matchDual(text, '<ask_user\\s+question=Q([^Q]+)Q\\s*(?:><\\/ask_user>|\\/>)').map((m) => ['ask_user', m[1]]),
|
|
2271
2558
|
fromParams: (p) => (p.question ? ['ask_user', p.question] : null),
|
|
2272
|
-
|
|
2559
|
+
// No permission descriptor: ask_user has no system side effects — it only
|
|
2560
|
+
// prompts the interactive user, which IS the interaction. A separate "may I
|
|
2561
|
+
// ask you?" gate would be pure friction (a double prompt before the real
|
|
2562
|
+
// question/menu). A null descriptor also makes it available during plan mode
|
|
2563
|
+
// (the plan-mode gate withholds only effectful tools, i.e. non-null
|
|
2564
|
+
// descriptors), so the agent can ask clarifying questions while planning.
|
|
2565
|
+
permission: () => null,
|
|
2273
2566
|
execute: async (ctx, args) => {
|
|
2274
2567
|
const [arg0 = null] = args;
|
|
2275
|
-
const { _log, logToolCall,
|
|
2568
|
+
const { _log, logToolCall, _parseAskMenu, permissionManager, writer, FG_YELLOW, FG_GRAY, RST, DIM } = ctx;
|
|
2276
2569
|
const question = arg0;
|
|
2277
|
-
|
|
2570
|
+
// Display-only split: the menu gets ONLY the numbered options; the modal
|
|
2571
|
+
// header gets ONLY the prose prompt (no duplication). The model-facing
|
|
2572
|
+
// result below still carries the FULL original `question` (agent.js builds
|
|
2573
|
+
// "User answered \"<question>\": <answer>" from it). Edge: a question with
|
|
2574
|
+
// no prose before the numbers yields an empty prompt — fall back to the raw
|
|
2575
|
+
// question so the modal never renders an empty header.
|
|
2576
|
+
const { prompt, options } = _parseAskMenu(question);
|
|
2278
2577
|
if (options.length >= 2) {
|
|
2279
|
-
const selected = await permissionManager.captureSelect({ options });
|
|
2578
|
+
const selected = await permissionManager.captureSelect({ prompt: prompt || question, options });
|
|
2280
2579
|
logToolCall('ask_user', { question }, true, 'ok');
|
|
2281
2580
|
return { question, answer: selected || options[0] };
|
|
2282
2581
|
}
|
|
@@ -2543,6 +2842,11 @@ module.exports = {
|
|
|
2543
2842
|
_grepSearch,
|
|
2544
2843
|
_globSearch,
|
|
2545
2844
|
_detectRipgrep,
|
|
2845
|
+
// Path-aware grep target resolution + single-file grep (file/dir/glob path
|
|
2846
|
+
// semantics + the unresolvable-path safety net). Exported for focused tests.
|
|
2847
|
+
_resolveGrepPath,
|
|
2848
|
+
_grepFile,
|
|
2849
|
+
_grepHasCandidate,
|
|
2546
2850
|
// grep output modes + bound normalizers (Task W.5).
|
|
2547
2851
|
GREP_OUTPUT_MODES,
|
|
2548
2852
|
_normGrepMode,
|