@livx.cc/agentx 0.99.31 → 0.99.33

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/dist/cli.js CHANGED
@@ -302,7 +302,24 @@ var init_redact = __esm({
302
302
  function ckAbort(signal) {
303
303
  if (signal?.aborted) throw new Error("aborted");
304
304
  }
305
- async function walkFiles(fs, dir, signal, out = []) {
305
+ function realOf(fs, p) {
306
+ const rp = fs.canonicalPath;
307
+ if (typeof rp !== "function") return p;
308
+ try {
309
+ return rp.call(fs, p) || p;
310
+ } catch {
311
+ return p;
312
+ }
313
+ }
314
+ async function walkFiles(fs, dir, signal, out = [], st, depth = 0) {
315
+ const state = st ?? { skip: new Set(DEFAULT_SKIP_DIRS), maxDepth: DEFAULT_MAX_DEPTH, seen: /* @__PURE__ */ new Set(), prunedDirs: /* @__PURE__ */ new Set(), depthCuts: 0 };
316
+ if (depth > state.maxDepth) {
317
+ state.depthCuts++;
318
+ return out;
319
+ }
320
+ const real = realOf(fs, dir);
321
+ if (state.seen.has(real)) return out;
322
+ state.seen.add(real);
306
323
  let entries;
307
324
  try {
308
325
  entries = await fs.readDir(dir);
@@ -311,12 +328,57 @@ async function walkFiles(fs, dir, signal, out = []) {
311
328
  }
312
329
  for (const name of entries.sort()) {
313
330
  ckAbort(signal);
331
+ if (state.skip.has(name)) {
332
+ state.prunedDirs.add(name);
333
+ continue;
334
+ }
314
335
  const p = dir === "/" ? `/${name}` : `${dir}/${name}`;
315
- if (await fs.isDirectory(p)) await walkFiles(fs, p, signal, out);
336
+ if (await fs.isDirectory(p)) await walkFiles(fs, p, signal, out, state, depth + 1);
316
337
  else out.push(p);
317
338
  }
318
339
  return out;
319
340
  }
341
+ function scanRoots(fs, path, opts) {
342
+ const asked = path == null ? [] : (Array.isArray(path) ? path : [path]).map((p) => String(p)).filter(Boolean);
343
+ const roots = (asked.length ? asked : opts?.roots?.length ? opts.roots : [fsCwd(fs)]).map((r) => r.length > 1 && r.endsWith("/") ? r.slice(0, -1) : r);
344
+ return [...new Set(roots)].filter((r, _i, all) => !all.some((other) => other !== r && r.startsWith(`${other}/`)));
345
+ }
346
+ function scanNote(states) {
347
+ const pruned = [...new Set(states.flatMap((st) => [...st.prunedDirs]))].sort();
348
+ const cuts = states.reduce((n, st) => n + st.depthCuts, 0);
349
+ const parts = [];
350
+ if (pruned.length) parts.push(`skipped ${pruned.join(", ")} (pass skipDirs to include them)`);
351
+ if (cuts) parts.push(`stopped at depth ${states[0]?.maxDepth} in ${cuts} place${cuts === 1 ? "" : "s"} (pass maxDepth to go deeper)`);
352
+ return parts.length ? `
353
+ [scan: ${parts.join("; ")}]` : "";
354
+ }
355
+ async function walkRoots(fs, roots, signal, opts, call) {
356
+ const skip = new Set(call?.skipDirs ?? opts?.skipDirs ?? DEFAULT_SKIP_DIRS);
357
+ const maxDepth = call?.maxDepth ?? opts?.maxDepth ?? DEFAULT_MAX_DEPTH;
358
+ const out = [];
359
+ const states = [];
360
+ for (const root of roots) {
361
+ const state = { skip, maxDepth, seen: /* @__PURE__ */ new Set(), prunedDirs: /* @__PURE__ */ new Set(), depthCuts: 0 };
362
+ states.push(state);
363
+ await walkFiles(fs, root, signal, out, state, 0);
364
+ }
365
+ return { files: out, note: scanNote(states) };
366
+ }
367
+ function callScan(args) {
368
+ const sd = args.skipDirs;
369
+ return {
370
+ skipDirs: Array.isArray(sd) ? sd.map(String) : typeof sd === "string" ? sd.split(",").map((x) => x.trim()).filter(Boolean) : void 0,
371
+ maxDepth: args.maxDepth == null ? void 0 : Number(args.maxDepth)
372
+ };
373
+ }
374
+ function anchoredGlobAt(root, glob) {
375
+ if (glob.startsWith("/")) return globToRegExp(glob);
376
+ return globToRegExp(`${root === "/" ? "" : root}/${glob}`);
377
+ }
378
+ function globMatcher(roots, glob) {
379
+ const res = roots.map((r) => anchoredGlobAt(r, glob));
380
+ return (p) => res.some((re) => re.test(p));
381
+ }
320
382
  function globToRegExp(glob, caseInsensitive = false) {
321
383
  const g = glob.startsWith("/") ? glob : `/${glob}`;
322
384
  let re = "";
@@ -338,6 +400,88 @@ function anchoredGlob(fs, glob) {
338
400
  const base = cwd === "/" ? "" : cwd;
339
401
  return globToRegExp(glob.startsWith("/") ? glob : `${base}/${glob}`);
340
402
  }
403
+ function makeGlobTool(opts) {
404
+ return {
405
+ name: "Glob",
406
+ description: 'Find files by glob pattern (e.g. "**/*.ts", "src/**/*.test.ts"). Returns sorted paths, one per line. Space-separated patterns combine; `!`-prefix excludes (e.g. "**/*.ts !**/*.test.ts"). Optional `path` to search a specific directory. Prefer over `bash find` for locating files \u2014 one call, structured output.',
407
+ parameters: {
408
+ type: "object",
409
+ required: ["pattern"],
410
+ properties: {
411
+ pattern: { type: "string", description: "glob pattern(s); ** matches across directories; prefix a pattern with ! to exclude" },
412
+ ...SCAN_PARAMS
413
+ }
414
+ },
415
+ async run({ pattern, path, skipDirs, maxDepth }, ctx) {
416
+ const roots = scanRoots(ctx.fs, path, opts);
417
+ const pats = String(pattern ?? "").trim().split(/\s+/).filter(Boolean);
418
+ const include = pats.filter((p) => !p.startsWith("!")).map((p) => globMatcher(roots, p));
419
+ const exclude = pats.filter((p) => p.startsWith("!")).map((p) => globMatcher(roots, p.slice(1)));
420
+ const includes = include.length ? include : [globMatcher(roots, "**")];
421
+ const { files, note } = await walkRoots(ctx.fs, roots, ctx.signal, opts, callScan({ skipDirs, maxDepth }));
422
+ const hits = files.filter((p) => includes.some((m) => m(p)) && !exclude.some((m) => m(p)));
423
+ return (hits.length ? [...new Set(hits)].sort().join("\n") : "(no matches)") + note;
424
+ }
425
+ };
426
+ }
427
+ function makeGrepTool(opts) {
428
+ return {
429
+ name: "Grep",
430
+ description: "Search file contents by regex. Returns `path:line: text` hits. Optional `glob` to scope files, `path` to search a specific directory, `context` for surrounding lines, `filesOnly` for matching paths only. Prefer over `bash grep` for file content search \u2014 structured results, no re-parse needed. Use `bash` instead for running commands, tests, or piped workflows.",
431
+ parameters: {
432
+ type: "object",
433
+ required: ["pattern"],
434
+ properties: {
435
+ pattern: { type: "string", description: "JS regular expression" },
436
+ glob: { type: "string", description: "optional file glob to restrict the search" },
437
+ ...SCAN_PARAMS,
438
+ context: { type: "number", description: "lines of context before/after each hit" },
439
+ filesOnly: { type: "boolean", description: "only list matching file paths" }
440
+ }
441
+ },
442
+ async run({ pattern, glob, path, skipDirs, maxDepth, context, filesOnly }, ctx) {
443
+ let re;
444
+ try {
445
+ re = new RegExp(String(pattern ?? ""));
446
+ } catch (e) {
447
+ throw new Error(`invalid regex: ${String(e)}`);
448
+ }
449
+ const roots = scanRoots(ctx.fs, path, opts);
450
+ const scope = glob ? globMatcher(roots, String(glob)) : null;
451
+ const walked = await walkRoots(ctx.fs, roots, ctx.signal, opts, callScan({ skipDirs, maxDepth }));
452
+ const files = [...new Set(walked.files)].sort().filter((p) => !scope || scope(p));
453
+ const ctxN = Math.max(0, Number(context ?? 0));
454
+ const out = [];
455
+ const matched = [];
456
+ let skipped = 0;
457
+ for (const path2 of files) {
458
+ ckAbort(ctx.signal);
459
+ let content;
460
+ try {
461
+ content = await ctx.fs.readFile(path2);
462
+ } catch {
463
+ skipped++;
464
+ continue;
465
+ }
466
+ const lines = content.split("\n");
467
+ const mask = CONFIG_FILE_RE.test(path2);
468
+ let fileHit = false;
469
+ for (let i = 0; i < lines.length; i++) {
470
+ if (!re.test(lines[i])) continue;
471
+ fileHit = true;
472
+ if (filesOnly) break;
473
+ const lo = Math.max(0, i - ctxN), hi = Math.min(lines.length - 1, i + ctxN);
474
+ for (let j = lo; j <= hi; j++) out.push(`${path2}:${j + 1}: ${mask ? redactSecrets(lines[j]) : lines[j]}`);
475
+ }
476
+ if (fileHit) matched.push(path2);
477
+ }
478
+ const note = (skipped ? `
479
+ [skipped ${skipped} unreadable file${skipped === 1 ? "" : "s"}]` : "") + walked.note;
480
+ if (filesOnly) return (matched.length ? matched.join("\n") : "(no matches)") + note;
481
+ return (out.length ? out.join("\n") : "(no matches)") + note;
482
+ }
483
+ };
484
+ }
341
485
  function docOutlineOf(content, cap = 20) {
342
486
  const out = [];
343
487
  const lines = content.split("\n");
@@ -480,84 +624,21 @@ Reply with a short numbered list of concrete, actionable issues. If the changes
480
624
  }
481
625
  };
482
626
  }
483
- var fsCwd, globTool, grepTool, SIG_RE, isCode, isDoc, repoMapTool, writeTool, multiEditTool, applyEditsTool;
627
+ var DEFAULT_SKIP_DIRS, DEFAULT_MAX_DEPTH, SCAN_PARAMS, fsCwd, globTool, grepTool, SIG_RE, isCode, isDoc, repoMapTool, writeTool, multiEditTool, applyEditsTool;
484
628
  var init_tools_structured = __esm({
485
629
  "src/tools.structured.ts"() {
486
630
  "use strict";
487
631
  init_redact();
488
- fsCwd = (fs) => fs.getCwd();
489
- globTool = {
490
- name: "Glob",
491
- description: 'Find files by glob pattern (e.g. "**/*.ts", "src/**/*.test.ts"). Returns sorted paths, one per line. Space-separated patterns combine; `!`-prefix excludes (e.g. "**/*.ts !**/*.test.ts"). Prefer over `bash find` for locating files \u2014 one call, structured output.',
492
- parameters: {
493
- type: "object",
494
- required: ["pattern"],
495
- properties: { pattern: { type: "string", description: "glob pattern(s); ** matches across directories; prefix a pattern with ! to exclude" } }
496
- },
497
- async run({ pattern }, ctx) {
498
- const pats = String(pattern ?? "").trim().split(/\s+/).filter(Boolean);
499
- const include = pats.filter((p) => !p.startsWith("!")).map((p) => anchoredGlob(ctx.fs, p));
500
- const exclude = pats.filter((p) => p.startsWith("!")).map((p) => anchoredGlob(ctx.fs, p.slice(1)));
501
- const includes = include.length ? include : [anchoredGlob(ctx.fs, "**")];
502
- const hits = (await walkFiles(ctx.fs, fsCwd(ctx.fs), ctx.signal)).filter(
503
- (p) => includes.some((re) => re.test(p)) && !exclude.some((re) => re.test(p))
504
- );
505
- return hits.length ? hits.join("\n") : "(no matches)";
506
- }
507
- };
508
- grepTool = {
509
- name: "Grep",
510
- description: "Search file contents by regex. Returns `path:line: text` hits. Optional `glob` to scope files, `context` for surrounding lines, `filesOnly` for matching paths only. Prefer over `bash grep` for file content search \u2014 structured results, no re-parse needed. Use `bash` instead for running commands, tests, or piped workflows.",
511
- parameters: {
512
- type: "object",
513
- required: ["pattern"],
514
- properties: {
515
- pattern: { type: "string", description: "JS regular expression" },
516
- glob: { type: "string", description: "optional file glob to restrict the search" },
517
- context: { type: "number", description: "lines of context before/after each hit" },
518
- filesOnly: { type: "boolean", description: "only list matching file paths" }
519
- }
520
- },
521
- async run({ pattern, glob, context, filesOnly }, ctx) {
522
- let re;
523
- try {
524
- re = new RegExp(String(pattern ?? ""));
525
- } catch (e) {
526
- throw new Error(`invalid regex: ${String(e)}`);
527
- }
528
- const scope = glob ? anchoredGlob(ctx.fs, String(glob)) : null;
529
- const files = (await walkFiles(ctx.fs, fsCwd(ctx.fs), ctx.signal)).filter((p) => !scope || scope.test(p));
530
- const ctxN = Math.max(0, Number(context ?? 0));
531
- const out = [];
532
- const matched = [];
533
- let skipped = 0;
534
- for (const path of files) {
535
- ckAbort(ctx.signal);
536
- let content;
537
- try {
538
- content = await ctx.fs.readFile(path);
539
- } catch {
540
- skipped++;
541
- continue;
542
- }
543
- const lines = content.split("\n");
544
- const mask = CONFIG_FILE_RE.test(path);
545
- let fileHit = false;
546
- for (let i = 0; i < lines.length; i++) {
547
- if (!re.test(lines[i])) continue;
548
- fileHit = true;
549
- if (filesOnly) break;
550
- const lo = Math.max(0, i - ctxN), hi = Math.min(lines.length - 1, i + ctxN);
551
- for (let j = lo; j <= hi; j++) out.push(`${path}:${j + 1}: ${mask ? redactSecrets(lines[j]) : lines[j]}`);
552
- }
553
- if (fileHit) matched.push(path);
554
- }
555
- const note = skipped ? `
556
- [skipped ${skipped} unreadable file${skipped === 1 ? "" : "s"}]` : "";
557
- if (filesOnly) return (matched.length ? matched.join("\n") : "(no matches)") + note;
558
- return (out.length ? out.join("\n") : "(no matches)") + note;
559
- }
632
+ DEFAULT_SKIP_DIRS = ["node_modules", ".git", "dist", ".tmp"];
633
+ DEFAULT_MAX_DEPTH = 24;
634
+ SCAN_PARAMS = {
635
+ path: { type: "string", description: "optional directory (or list) to search instead of the default roots; absolute VFS path" },
636
+ skipDirs: { type: "string", description: `comma-separated directory names to prune (default: ${DEFAULT_SKIP_DIRS.join(", ")}); pass an empty string to prune nothing` },
637
+ maxDepth: { type: "number", description: `max directory depth below each root (default ${DEFAULT_MAX_DEPTH})` }
560
638
  };
639
+ fsCwd = (fs) => fs.getCwd();
640
+ globTool = makeGlobTool();
641
+ grepTool = makeGrepTool();
561
642
  SIG_RE = /^\s*(export\b|(?:export\s+)?(?:async\s+)?function\s+\*?\w|(?:export\s+)?(?:abstract\s+)?class\s+\w|(?:export\s+)?interface\s+\w|(?:export\s+)?type\s+\w|(?:export\s+)?enum\s+\w)/;
562
643
  isCode = (p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p);
563
644
  isDoc = (p) => /\.(md|mdx|txt)$/.test(p);
@@ -1587,7 +1668,7 @@ var NodeDiskFilesystem_exports = {};
1587
1668
  __export(NodeDiskFilesystem_exports, {
1588
1669
  NodeDiskFilesystem: () => NodeDiskFilesystem
1589
1670
  });
1590
- import { promises as fsp } from "fs";
1671
+ import { promises as fsp, realpathSync as realpathSync2 } from "fs";
1591
1672
  import * as np from "path";
1592
1673
  import { PathResolver } from "@livx.cc/wcli/core";
1593
1674
  var NodeDiskFilesystem;
@@ -1666,6 +1747,19 @@ var init_NodeDiskFilesystem = __esm({
1666
1747
  realPath(path) {
1667
1748
  return this.real(this.resolvePath(path));
1668
1749
  }
1750
+ /** The CANONICAL on-disk path — symlinks resolved. `realPath` is path arithmetic (baseDir + vpath)
1751
+ * and therefore reports a symlink and its target as different places, which is no use as an
1752
+ * identity: a tree walk that dedupes on it still re-enters a loop (`a/b/loop -> a` re-walked 11
1753
+ * times before a depth cap stopped it). Optional capability, duck-typed by the file-scanning
1754
+ * tools; falls back to the arithmetic path for anything unresolvable (a broken/racing link). */
1755
+ canonicalPath(path) {
1756
+ const p = this.realPath(path);
1757
+ try {
1758
+ return realpathSync2(p);
1759
+ } catch {
1760
+ return p;
1761
+ }
1762
+ }
1669
1763
  /** Read raw bytes (for binary files like images). Not on the base IFilesystem (which is utf8-only) — an
1670
1764
  * optional capability the Read tool duck-types to return image blocks. Same symlink/jail guards as readFile. */
1671
1765
  async readFileBytes(path) {
@@ -2041,20 +2135,23 @@ async function nodeSpawn() {
2041
2135
  return _spawn;
2042
2136
  }
2043
2137
  function makeRealShellTool(options) {
2044
- const timeoutMs = options.timeoutMs ?? 12e4;
2138
+ const defaultTimeoutMs = options.timeoutMs ?? 12e4;
2139
+ const maxTimeoutMs = Math.max(options.maxTimeoutMs ?? 6e5, defaultTimeoutMs);
2045
2140
  return {
2046
2141
  name: "Shell",
2047
- description: "Run a shell command via /bin/sh in the working directory. Executes any installed binary \u2014 ls, cat, grep, git, bun, node, curl, scripts, etc. Returns combined stdout+stderr; non-zero exits are prefixed `[exit N]`. Runs non-interactively with no terminal (stdin is /dev/null): commands that prompt for input fail fast rather than hang \u2014 for privileged actions use a non-interactive flag (e.g. `sudo -n`), or ask the user to run the command themselves. Set `background:true` for long-running processes (servers, watchers) \u2014 returns a job id immediately; poll with ShellOutput, stop with ShellKill.",
2142
+ description: `Run a shell command via /bin/sh in the working directory. Executes any installed binary \u2014 ls, cat, grep, git, bun, node, curl, scripts, etc. Returns combined stdout+stderr; non-zero exits are prefixed \`[exit N]\`. Runs non-interactively with no terminal (stdin is /dev/null): commands that prompt for input fail fast rather than hang \u2014 for privileged actions use a non-interactive flag (e.g. \`sudo -n\`), or ask the user to run the command themselves. Each command is killed after ${defaultTimeoutMs}ms (result \`[exit 124]\` with whatever output it produced) \u2014 pass \`timeoutMs\` (max ${maxTimeoutMs}) for a legitimately slower command, and always bound network commands yourself (e.g. \`curl -m 10\`). Set \`background:true\` for long-running processes (servers, watchers) \u2014 returns a job id immediately; poll with ShellOutput, stop with ShellKill.`,
2048
2143
  parameters: {
2049
2144
  type: "object",
2050
2145
  required: ["command"],
2051
2146
  properties: {
2052
2147
  command: { type: "string", description: "the shell command line to execute" },
2053
- background: { type: "boolean", description: "run detached and return a job id immediately (for servers/watchers/long builds)" }
2148
+ background: { type: "boolean", description: "run detached and return a job id immediately (for servers/watchers/long builds)" },
2149
+ timeoutMs: { type: "number", description: "wall-clock cap for THIS command, overriding the default (clamped to the tool max)" }
2054
2150
  }
2055
2151
  },
2056
- async run({ command, background }, ctx) {
2152
+ async run({ command, background, timeoutMs: askedTimeoutMs }, ctx) {
2057
2153
  const cmd = String(command ?? "");
2154
+ const timeoutMs = Number.isFinite(askedTimeoutMs) && Number(askedTimeoutMs) > 0 ? Math.min(Number(askedTimeoutMs), maxTimeoutMs) : defaultTimeoutMs;
2058
2155
  if (!cmd.trim()) return "[exit 1] empty command";
2059
2156
  if (background) {
2060
2157
  if (!options.registry) return "Error: background execution is not enabled in this host (no job registry).";