@arnilo/prism-coding-agent 0.0.19 → 0.0.22

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.
@@ -8,6 +8,7 @@
8
8
  import { lstat, open, opendir, realpath } from "node:fs/promises";
9
9
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { DEFAULT_BINARY_SNIFF_BYTES, DEFAULT_MAX_REPO_CONCURRENCY, DEFAULT_MAX_REPO_DEPTH, DEFAULT_MAX_REPO_ENTRIES, DEFAULT_MAX_REPO_FILES, DEFAULT_MAX_REPO_RESULTS, DEFAULT_MAX_SEARCH_CONTEXT_LINES, DEFAULT_MAX_SEARCH_FILE_BYTES, DEFAULT_MAX_SEARCH_LINE_BYTES, DEFAULT_MAX_SEARCH_MATCHES, DEFAULT_MAX_SEARCH_PATTERN_BYTES, DEFAULT_MAX_SEARCH_SCAN_BYTES, DEFAULT_MAX_SEARCH_TIME_MS, HARD_MAX_REPO_CONCURRENCY, HARD_MAX_REPO_DEPTH, HARD_MAX_REPO_ENTRIES, HARD_MAX_REPO_FILES, HARD_MAX_REPO_RESULTS, HARD_MAX_SEARCH_CONTEXT_LINES, HARD_MAX_SEARCH_FILE_BYTES, HARD_MAX_SEARCH_LINE_BYTES, HARD_MAX_SEARCH_MATCHES, HARD_MAX_SEARCH_PATTERN_BYTES, HARD_MAX_SEARCH_SCAN_BYTES, HARD_MAX_SEARCH_TIME_MS, validateCodingLimit, validateCodingLimitAllowZero, } from "./limits.js";
11
+ import { matchGlobPattern, validateGlobPattern } from "./glob-match.js";
11
12
  import { resolveToCwd } from "./path-utils.js";
12
13
  export const DEFAULT_REPO_EXCLUDE = Object.freeze([".git", "node_modules", "dist"]);
13
14
  export class RepositoryError extends Error {
@@ -602,12 +603,137 @@ async function searchLocal(request, defaults) {
602
603
  filesSkippedOversize,
603
604
  };
604
605
  }
606
+ async function globLocal(request, defaults) {
607
+ try {
608
+ validateGlobPattern(request.pattern, defaults.maxPatternBytes);
609
+ }
610
+ catch (error) {
611
+ throw new RepositoryError(error instanceof Error ? error.message : String(error));
612
+ }
613
+ const resolved = await resolveRepoPath(request.root, request.path);
614
+ const maxResults = validateCodingLimit("maxResults", request.maxResults ?? defaults.maxResults, HARD_MAX_REPO_RESULTS);
615
+ const offset = validateCodingLimitAllowZero("offset", request.offset ?? 0, HARD_MAX_REPO_ENTRIES);
616
+ const maxDepth = validateCodingLimit("maxDepth", request.maxDepth ?? defaults.maxDepth, HARD_MAX_REPO_DEPTH);
617
+ const exclude = new Set(request.exclude ?? defaults.exclude);
618
+ const deadlineAt = request.deadlineMs !== undefined ? Date.now() + request.deadlineMs : Date.now() + defaults.maxTimeMs;
619
+ const collected = [];
620
+ let scannedEntries = 0;
621
+ let scannedFiles = 0;
622
+ let seen = 0;
623
+ let truncated = false;
624
+ let truncatedBy = null;
625
+ const maybeCollect = (relativePath) => {
626
+ if (!matchGlobPattern(request.pattern, relativePath))
627
+ return false;
628
+ if (seen < offset) {
629
+ seen++;
630
+ return false;
631
+ }
632
+ if (collected.length >= maxResults) {
633
+ truncated = true;
634
+ truncatedBy = "results";
635
+ return true;
636
+ }
637
+ collected.push(relativePath);
638
+ seen++;
639
+ return truncated;
640
+ };
641
+ try {
642
+ const startStat = await lstat(resolved.absolute);
643
+ if (!startStat.isDirectory()) {
644
+ scannedEntries = 1;
645
+ if (startStat.isFile()) {
646
+ scannedFiles = 1;
647
+ if (matchGlobPattern(request.pattern, resolved.relative)) {
648
+ if (offset === 0 && maxResults > 0)
649
+ collected.push(resolved.relative);
650
+ else if (offset === 0 && maxResults === 0) {
651
+ truncated = true;
652
+ truncatedBy = "results";
653
+ }
654
+ }
655
+ }
656
+ return {
657
+ paths: collected,
658
+ truncated,
659
+ truncatedBy,
660
+ scannedEntries,
661
+ scannedFiles,
662
+ offset,
663
+ nextOffset: undefined,
664
+ };
665
+ }
666
+ }
667
+ catch (error) {
668
+ const message = error instanceof Error ? error.message : String(error);
669
+ throw new RepositoryError(`cannot open path: ${message}`);
670
+ }
671
+ try {
672
+ for await (const event of walkRepository(resolved.rootReal, resolved.absolute, {
673
+ maxDepth,
674
+ maxEntries: defaults.maxEntries,
675
+ maxFiles: defaults.maxFiles,
676
+ exclude,
677
+ includeHidden: request.includeHidden === true,
678
+ signal: request.signal,
679
+ deadlineAt,
680
+ })) {
681
+ if (event.type === "limit") {
682
+ truncated = true;
683
+ truncatedBy = event.truncatedBy;
684
+ break;
685
+ }
686
+ scannedEntries++;
687
+ if (event.entry.kind === "file")
688
+ scannedFiles++;
689
+ if (event.entry.kind !== "file")
690
+ continue;
691
+ if (maybeCollect(event.entry.path))
692
+ break;
693
+ }
694
+ }
695
+ catch (error) {
696
+ if (error instanceof RepositoryError && error.message === "Operation aborted") {
697
+ return {
698
+ paths: collected,
699
+ truncated: true,
700
+ truncatedBy: "abort",
701
+ scannedEntries,
702
+ scannedFiles,
703
+ offset,
704
+ nextOffset: collected.length > 0 || offset > 0 ? offset + collected.length : undefined,
705
+ };
706
+ }
707
+ if (error instanceof RepositoryError && error.message === "Repository operation exceeded time limit") {
708
+ return {
709
+ paths: collected,
710
+ truncated: true,
711
+ truncatedBy: "time",
712
+ scannedEntries,
713
+ scannedFiles,
714
+ offset,
715
+ nextOffset: offset + collected.length,
716
+ };
717
+ }
718
+ throw error;
719
+ }
720
+ return {
721
+ paths: collected,
722
+ truncated,
723
+ truncatedBy,
724
+ scannedEntries,
725
+ scannedFiles,
726
+ offset,
727
+ nextOffset: truncated ? offset + collected.length : undefined,
728
+ };
729
+ }
605
730
  /** Local filesystem repository operations (default backend). */
606
731
  export function createLocalRepositoryOperations(limits) {
607
732
  const resolved = resolveRepositoryLimits(limits);
608
733
  return {
609
734
  list: (request) => listLocal(request, resolved),
610
735
  search: (request) => searchLocal(request, resolved),
736
+ glob: (request) => globLocal(request, resolved),
611
737
  };
612
738
  }
613
739
  //# sourceMappingURL=repository.js.map
package/dist/search.js CHANGED
@@ -33,6 +33,64 @@ function formatSearchText(result) {
33
33
  return body;
34
34
  return `${body}\n[truncated by ${result.truncatedBy ?? "limit"}]`;
35
35
  }
36
+ function uniqueMatchPaths(matches) {
37
+ return [...new Set(matches.map((m) => m.path))].sort();
38
+ }
39
+ function formatFilesWithMatches(result) {
40
+ if (result.matches.length === 0) {
41
+ return result.truncated ? `[truncated by ${result.truncatedBy ?? "limit"} before any matches]` : "(no matches)";
42
+ }
43
+ const body = uniqueMatchPaths(result.matches).join("\n");
44
+ if (!result.truncated)
45
+ return body;
46
+ return `${body}\n[truncated by ${result.truncatedBy ?? "limit"}]`;
47
+ }
48
+ function formatCount(result) {
49
+ const matchCount = result.matches.length;
50
+ const fileCount = uniqueMatchPaths(result.matches).length;
51
+ if (matchCount === 0) {
52
+ return result.truncated ? `[truncated by ${result.truncatedBy ?? "limit"} before any matches]` : "0 matches in 0 files";
53
+ }
54
+ const noun = fileCount === 1 ? "file" : "files";
55
+ const body = `${matchCount} matches in ${fileCount} ${noun}`;
56
+ if (!result.truncated)
57
+ return body;
58
+ return `${body}\n[truncated by ${result.truncatedBy ?? "limit"}]`;
59
+ }
60
+ function formatSearchResult(result, outputMode) {
61
+ switch (outputMode) {
62
+ case "files_with_matches":
63
+ return formatFilesWithMatches(result);
64
+ case "count":
65
+ return formatCount(result);
66
+ default:
67
+ return formatSearchText(result);
68
+ }
69
+ }
70
+ function parseOutputMode(value) {
71
+ if (value === undefined || value === "content")
72
+ return "content";
73
+ if (value === "files_with_matches" || value === "count")
74
+ return value;
75
+ throw new Error(`unsupported outputMode: ${String(value)}`);
76
+ }
77
+ function buildSearchMetadata(result, outputMode) {
78
+ const base = {
79
+ outputMode,
80
+ truncated: result.truncated,
81
+ truncatedBy: result.truncatedBy,
82
+ matchCount: result.matches.length,
83
+ scannedBytes: result.scannedBytes,
84
+ scannedFiles: result.scannedFiles,
85
+ scannedEntries: result.scannedEntries,
86
+ filesSkippedBinary: result.filesSkippedBinary,
87
+ filesSkippedOversize: result.filesSkippedOversize,
88
+ };
89
+ if (outputMode === "content") {
90
+ return { ...base, matches: result.matches };
91
+ }
92
+ return { ...base, fileCount: uniqueMatchPaths(result.matches).length };
93
+ }
36
94
  export function createRepoSearchTool(cwd, options) {
37
95
  const limits = resolveRepositoryLimits({
38
96
  ...options?.repository,
@@ -43,7 +101,7 @@ export function createRepoSearchTool(cwd, options) {
43
101
  const ops = options?.operations ?? createLocalRepositoryOperations(limits);
44
102
  return {
45
103
  name: "repo_search",
46
- description: `Search text files under the workspace using literal substring match. Skips binary files, excluded basenames (default: ${limits.exclude.join(", ")}), and hidden names unless includeHidden is true. Does not follow symlinks. Caps matches/scanned bytes/time.`,
104
+ description: `Search text files under the workspace using literal substring match. Use outputMode "files_with_matches" for paths only or "count" for totals without line bodies. Skips binary files, excluded basenames (default: ${limits.exclude.join(", ")}), and hidden names unless includeHidden is true. Does not follow symlinks. Caps matches/scanned bytes/time.`,
47
105
  parameters: {
48
106
  type: "object",
49
107
  properties: {
@@ -73,6 +131,11 @@ export function createRepoSearchTool(cwd, options) {
73
131
  type: "number",
74
132
  description: `Maximum matches to retain (default ${limits.maxMatches}, hard ${HARD_MAX_SEARCH_MATCHES})`,
75
133
  },
134
+ outputMode: {
135
+ type: "string",
136
+ description: 'Result shape: "content" (default, line bodies), "files_with_matches" (unique paths), or "count" (match/file totals)',
137
+ enum: ["content", "files_with_matches", "count"],
138
+ },
76
139
  },
77
140
  required: ["query"],
78
141
  additionalProperties: false,
@@ -92,6 +155,13 @@ export function createRepoSearchTool(cwd, options) {
92
155
  return errorResult(toolCallId, `unsupported search mode: ${String(args.mode)}`);
93
156
  }
94
157
  const mode = "literal";
158
+ let outputMode;
159
+ try {
160
+ outputMode = parseOutputMode(args.outputMode);
161
+ }
162
+ catch (error) {
163
+ return errorResult(toolCallId, error instanceof Error ? error.message : String(error));
164
+ }
95
165
  const caseSensitive = args.caseSensitive === true;
96
166
  const includeHidden = args.includeHidden === true;
97
167
  let contextLines;
@@ -114,6 +184,7 @@ export function createRepoSearchTool(cwd, options) {
114
184
  risk: "low",
115
185
  metadata: {
116
186
  mode,
187
+ outputMode,
117
188
  caseSensitive,
118
189
  includeHidden,
119
190
  context: contextLines,
@@ -131,6 +202,7 @@ export function createRepoSearchTool(cwd, options) {
131
202
  query,
132
203
  path,
133
204
  mode,
205
+ outputMode,
134
206
  caseSensitive,
135
207
  includeHidden,
136
208
  exclude: limits.exclude,
@@ -142,18 +214,8 @@ export function createRepoSearchTool(cwd, options) {
142
214
  return {
143
215
  toolCallId,
144
216
  name: "repo_search",
145
- content: [{ type: "text", text: formatSearchText(result) }],
146
- metadata: {
147
- truncated: result.truncated,
148
- truncatedBy: result.truncatedBy,
149
- matchCount: result.matches.length,
150
- scannedBytes: result.scannedBytes,
151
- scannedFiles: result.scannedFiles,
152
- scannedEntries: result.scannedEntries,
153
- filesSkippedBinary: result.filesSkippedBinary,
154
- filesSkippedOversize: result.filesSkippedOversize,
155
- matches: result.matches,
156
- },
217
+ content: [{ type: "text", text: formatSearchResult(result, outputMode) }],
218
+ metadata: buildSearchMetadata(result, outputMode),
157
219
  };
158
220
  }
159
221
  catch (error) {
package/dist/shell.js CHANGED
@@ -265,7 +265,7 @@ export function createShellTool(cwd, options) {
265
265
  return {
266
266
  name: "shell",
267
267
  exclusive: true,
268
- description: `Execute a shell command in the current working directory. Returns combined stdout and stderr. Output is truncated to the last ${maxLines} lines or ${maxBytes / 1024}KB and capped at ${formatSize(maxTotalOutputBytes)} total. Truncated successful output is saved to a temp file. Timeout defaults to ${defaultTimeout} seconds.`,
268
+ description: `Execute a shell command in the current working directory. Returns combined stdout and stderr. Prefer repo_list, repo_search, glob, read, write, edit, delete, or move for those jobs — use shell only when no dedicated tool fits. Output is truncated to the last ${maxLines} lines or ${maxBytes / 1024}KB and capped at ${formatSize(maxTotalOutputBytes)} total. Truncated successful output is saved to a temp file. Timeout defaults to ${defaultTimeout} seconds.`,
269
269
  parameters: {
270
270
  type: "object",
271
271
  properties: {
package/dist/write.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
2
+ import { type ReadBeforeWriteOptions } from "./read-path-set.js";
2
3
  /**
3
4
  * Pluggable operations for the write tool. Override to delegate file writing to remote systems
4
5
  * (e.g. SSH) while keeping the tool's directory-creation + per-path serialization behavior.
@@ -14,7 +15,7 @@ export interface WriteOperations {
14
15
  signal?: AbortSignal;
15
16
  }) => Promise<void>;
16
17
  }
17
- export interface WriteToolOptions {
18
+ export interface WriteToolOptions extends ReadBeforeWriteOptions {
18
19
  /** Structured pre-execution policy checked before filesystem writes. */
19
20
  executionPolicy?: ExecutionPolicy;
20
21
  /** Custom operations backend (default: local filesystem). */
package/dist/write.js CHANGED
@@ -22,6 +22,7 @@ import { enforceExecutionPolicy } from "./execution-policy.js";
22
22
  import { withFileMutationQueue } from "./file-mutation-queue.js";
23
23
  import { DEFAULT_MAX_WRITE_BYTES, HARD_MAX_WRITE_BYTES, validateCodingLimit } from "./limits.js";
24
24
  import { resolveToCwd } from "./path-utils.js";
25
+ import { refuseReadBeforeWrite } from "./read-path-set.js";
25
26
  const defaultWriteOperations = {
26
27
  writeFile: (path, content, options) => atomicWriteUtf8File(path, content, { signal: options?.signal }),
27
28
  mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => { }),
@@ -44,12 +45,16 @@ export function createWriteTool(cwd, options) {
44
45
  const maxInputBytes = validateCodingLimit("maxInputBytes", options?.maxInputBytes ?? DEFAULT_MAX_WRITE_BYTES, HARD_MAX_WRITE_BYTES);
45
46
  return {
46
47
  name: "write",
47
- description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
48
+ description: "Create or overwrite a file (full replace). Creates parent directories. Prefer edit for targeted changes. When the host enabled requireReadBeforeWrite, read the path first or pass force=true to override.",
48
49
  parameters: {
49
50
  type: "object",
50
51
  properties: {
51
52
  path: { type: "string", description: "Path to the file to write (relative or absolute)" },
52
53
  content: { type: "string", description: "Content to write to the file" },
54
+ force: {
55
+ type: "boolean",
56
+ description: "Bypass read-before-write guard when the host enabled requireReadBeforeWrite.",
57
+ },
53
58
  },
54
59
  required: ["path", "content"],
55
60
  additionalProperties: false,
@@ -58,6 +63,7 @@ export function createWriteTool(cwd, options) {
58
63
  const toolCallId = context.toolCallId;
59
64
  const path = typeof args.path === "string" ? args.path : "";
60
65
  const content = typeof args.content === "string" ? args.content : undefined;
66
+ const force = args.force === true;
61
67
  if (path.length === 0) {
62
68
  return errorResult(toolCallId, "path is required and must be a non-empty string.");
63
69
  }
@@ -80,6 +86,9 @@ export function createWriteTool(cwd, options) {
80
86
  if (!policyCheck.allowed)
81
87
  return policyCheck.result;
82
88
  const allowedPath = policyCheck.action.paths?.[0] ?? absolutePath;
89
+ const rbwRefusal = refuseReadBeforeWrite("write", path, allowedPath, options, force);
90
+ if (rbwRefusal)
91
+ return errorResult(toolCallId, rbwRefusal);
83
92
  const dir = dirname(allowedPath);
84
93
  return await withFileMutationQueue(allowedPath, async () => {
85
94
  // Check abort before each fs op — do not start a new operation once aborted. We intentionally
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arnilo/prism-coding-agent",
3
- "version": "0.0.19",
4
- "description": "Optional coding-agent tools (shell, read, write, edit, repo_list, repo_search, opt-in Git/check/ask-user-decision, and durable plan/checkpoint helpers) package for Prism.",
3
+ "version": "0.0.22",
4
+ "description": "Optional coding-agent tools (shell, read, write, edit, repo_list, repo_search, glob, delete, move, opt-in Git/check/ask-user-decision, and durable plan/checkpoint helpers) package for Prism.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -28,8 +28,8 @@
28
28
  "diff": "^9.0.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@arnilo/prism": "0.0.19",
32
- "@arnilo/prism-workflows": "0.0.19"
31
+ "@arnilo/prism": "0.0.22",
32
+ "@arnilo/prism-workflows": "0.0.22"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@arnilo/prism": "file:../..",