@vincemakes/kiso-tools-node 0.16.6 → 0.16.8

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/index.d.ts CHANGED
@@ -105,6 +105,7 @@ export declare function listDirTool(opts: WorkspaceToolsOptions): Tool<{
105
105
  export declare function searchTextTool(opts: WorkspaceToolsOptions): Tool<{
106
106
  pattern: string;
107
107
  path?: string;
108
+ caseSensitive?: boolean;
108
109
  }>;
109
110
  export declare function writeFileTool(opts: WorkspaceToolsOptions): Tool<{
110
111
  path: string;
package/dist/index.js CHANGED
@@ -382,12 +382,13 @@ export function listDirTool(opts) {
382
382
  export function searchTextTool(opts) {
383
383
  return defineTool({
384
384
  name: "search_text",
385
- description: "Search files under a workspace directory (recursive) for a regular expression (the workspace grep — prefer it over shell grep/rg). Returns matching file:line excerpts, capped at 50 — an overflow note states the count of further matches (narrow the pattern to see them).",
385
+ description: "Search files under a workspace directory (recursive), or a single file, for a regular expression (the workspace grep — prefer it over shell grep/rg). Returns matching file:line excerpts, capped at 50 — an overflow note states the count of further matches (narrow the pattern to see them). Case-insensitive unless caseSensitive is true.",
386
386
  parameters: {
387
387
  type: "object",
388
388
  properties: {
389
389
  pattern: { type: "string", description: "Regular expression to search for" },
390
- path: { type: "string", description: "Workspace-relative root directory (default: workspace root)" },
390
+ path: { type: "string", description: "Workspace-relative directory OR file (default: workspace root)" },
391
+ caseSensitive: { type: "boolean", description: "Match case exactly (default: false)" },
391
392
  },
392
393
  required: ["pattern"],
393
394
  additionalProperties: false,
@@ -400,7 +401,7 @@ export function searchTextTool(opts) {
400
401
  effects: { precommitSafe: true, concurrency: "shared" },
401
402
  promptSnippet: "search_text — regex search over workspace files",
402
403
  promptGuidelines: ["narrow the pattern when the result caps — never re-run a broad search"],
403
- execute: async ({ pattern, path }) => {
404
+ execute: async ({ pattern, path, caseSensitive }) => {
404
405
  let root;
405
406
  try {
406
407
  root = resolveWithinRoot(opts.workspaceRoot, path ?? ".");
@@ -410,7 +411,32 @@ export function searchTextTool(opts) {
410
411
  return escapeResult(err.message);
411
412
  throw err;
412
413
  }
413
- const regex = new RegExp(pattern, "i");
414
+ // DC-23 (the 0.16.7 dogfood): an INVALID pattern threw raw out
415
+ // of execute — `new RegExp` sat outside every try in this
416
+ // function, so a bad regex was a crash rather than a result the
417
+ // model could act on. And the "i" flag was hardcoded, so a
418
+ // case-sensitive search was not expressible at all.
419
+ let regex;
420
+ try {
421
+ regex = new RegExp(pattern, caseSensitive === true ? "" : "i");
422
+ }
423
+ catch (err) {
424
+ return { content: `search_text failed: invalid pattern — ${err.message}`, isError: true, errorKind: "invalid_input" };
425
+ }
426
+ // DC-23: a FILE is a place text lives. The tool took only a
427
+ // directory and answered a file path with libuv's own words
428
+ // ("ENOTDIR: not a directory, scandir <path>"), which is the
429
+ // obvious thing to ask for — the file is already known and the
430
+ // question is where in it something is. The real dogfood model
431
+ // asked twice and learned nothing either time. One stat.
432
+ let single = null;
433
+ try {
434
+ if (statSync(root).isFile())
435
+ single = root;
436
+ }
437
+ catch (err) {
438
+ return { content: `search_text failed: ${path ?? "."} — ${err.message}`, isError: true, errorKind: "invalid_input" };
439
+ }
414
440
  // The walk NEVER early-aborts on the cap: the overflow note's count
415
441
  // must be the file-true total, not a bound (the red line). The
416
442
  // depth cap and the node_modules/dotfile skip stay.
@@ -436,6 +462,36 @@ export function searchTextTool(opts) {
436
462
  sinceYield = 0;
437
463
  await new Promise((r) => setImmediate(r));
438
464
  };
465
+ // DC-23: the per-file scan is its own function now, because the
466
+ // single-file path and the walk must scan a file the SAME way —
467
+ // same inode boundary, same cap accounting, same excerpt shape.
468
+ // Two copies would be two answers to "what does searching this
469
+ // file mean".
470
+ const scanFile = async (full) => {
471
+ await breathe();
472
+ try {
473
+ // round 8: same inode boundary as read_file — a hard link
474
+ // to an external inode is not searched. round 4 (adversarial):
475
+ // the link count is verified against the WORKSPACE
476
+ // root, not the search subroot — a link that lives
477
+ // inside the workspace but outside the search dir is
478
+ // legal and must not be silently skipped.
479
+ if (inodeReadPolicy(opts.workspaceRoot, full) !== null)
480
+ return;
481
+ const text = readFileSync(full, "utf8");
482
+ for (const [i, line] of text.split("\n").entries()) {
483
+ if (regex.test(line)) {
484
+ totalMatches += 1;
485
+ if (matches.length < MAX_SEARCH_MATCHES) {
486
+ matches.push(`${full}:${i + 1}: ${line.trim().slice(0, 160)}`);
487
+ }
488
+ }
489
+ }
490
+ }
491
+ catch {
492
+ // unreadable file — skip
493
+ }
494
+ };
439
495
  const walk = async (dir, depth) => {
440
496
  if (depth > 8)
441
497
  return;
@@ -443,38 +499,17 @@ export function searchTextTool(opts) {
443
499
  if (entry.name.startsWith(".") || entry.name === "node_modules")
444
500
  continue;
445
501
  const full = join(dir, entry.name);
446
- if (entry.isDirectory()) {
502
+ if (entry.isDirectory())
447
503
  await walk(full, depth + 1);
448
- }
449
- else if (entry.isFile()) {
450
- await breathe();
451
- try {
452
- // round 8: same inode boundary as read_file — a hard link
453
- // to an external inode is not searched. round 4 (adversarial):
454
- // the link count is verified against the WORKSPACE
455
- // root, not the search subroot — a link that lives
456
- // inside the workspace but outside the search dir is
457
- // legal and must not be silently skipped.
458
- if (inodeReadPolicy(opts.workspaceRoot, full) !== null)
459
- continue;
460
- const text = readFileSync(full, "utf8");
461
- for (const [i, line] of text.split("\n").entries()) {
462
- if (regex.test(line)) {
463
- totalMatches += 1;
464
- if (matches.length < MAX_SEARCH_MATCHES) {
465
- matches.push(`${full}:${i + 1}: ${line.trim().slice(0, 160)}`);
466
- }
467
- }
468
- }
469
- }
470
- catch {
471
- // unreadable file — skip
472
- }
473
- }
504
+ else if (entry.isFile())
505
+ await scanFile(full);
474
506
  }
475
507
  };
476
508
  try {
477
- await walk(root, 0);
509
+ if (single !== null)
510
+ await scanFile(single);
511
+ else
512
+ await walk(root, 0);
478
513
  }
479
514
  catch (err) {
480
515
  return { content: `search_text failed: ${err.message}`, isError: true, errorKind: "fatal" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.16.6",
3
+ "version": "0.16.8",
4
4
  "description": "kiso coding tools for Node hosts — read file, list directory, search text, write/edit file, shell command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "dependencies": {
24
- "@vincemakes/kiso-core": "0.16.6"
24
+ "@vincemakes/kiso-core": "0.16.8"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",