@arnilo/prism-coding-agent 0.1.5 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.6] - 2026-08-11
4
+
5
+ ### Changed
6
+ - **Bounded PDF/Office document reader** (plan 018 closeout `doc-reader`): additive `documentReader` slot on `createReadTool` — `DocumentReader.extract` runs after image sniffing, magic-byte format gating (never extension sniffing), null fall-through to the 0.1.5 text path, input/page/text caps re-checked in the read flow; the optional peer package `@arnilo/prism-document-reader` supplies the concrete parsers.
7
+ - **Recursive delete + brace-expanding glob** (plan 018 closeout `delete-glob`): `delete` gains per-call opt-in `recursive: true` (iterative post-order walk, symlink children unlinked never followed, fan-out cap 10,000 default / 100,000 hard, partial deletion reported never silent, `maxEntries` bound); `glob` gains host-selected and per-call `braceExpansion` (`{a,b}` textual expansion, max 128 alternatives / 4096 expanded bytes, unbalanced/nested/empty braces and overflow fail closed; default matcher semantics unchanged).
8
+ - **Checkpoint persistence for loaded-skill bodies** (plan 018 closeout `checkpoint-bodies`): durable runs may set `includeSkillBodies: true` on BOTH run and resume options — the exact loaded-skill instructions ride the checkpoint so resume re-renders them registry-independently (no `load_skill` round-trip); names-only stays the default, 0.1.3 checkpoint shapes byte-identical, `maxStateBytes` refuses oversize bodies.
9
+
3
10
  ## [0.1.5] - 2026-08-11
4
11
 
5
12
  ### Changed
package/dist/delete.js CHANGED
@@ -3,6 +3,11 @@ import { CODING_LOCAL_EFFECT } from "./effects.js";
3
3
  import { enforceExecutionPolicy } from "./execution-policy.js";
4
4
  import { withFileMutationQueue } from "./file-mutation-queue.js";
5
5
  import { resolveContainedMutationPath } from "./mutation-path.js";
6
+ // Per-call recursive-delete fan-out cap: default 10,000 entries, hard 100,000.
7
+ // ponytail: single global constants (not tunable options) — the recursive flag is
8
+ // per-call opt-in; hosts that need a different ceiling can deny via executionPolicy.
9
+ const DEFAULT_MAX_RECURSIVE_DELETE_ENTRIES = 10_000;
10
+ const HARD_MAX_RECURSIVE_DELETE_ENTRIES = 100_000;
6
11
  const defaultDeleteOperations = {
7
12
  lstat: async (path) => {
8
13
  const st = await lstat(path);
@@ -30,11 +35,22 @@ export function createDeleteTool(cwd, options) {
30
35
  return {
31
36
  name: "delete",
32
37
  effect: CODING_LOCAL_EFFECT,
33
- description: "High-risk: permanently delete a single file or empty directory in the workspace. Non-empty directories are rejected (no recursive delete). No trash/recycle — host undo is not automatic. Prefer edit/write when content can be fixed in place.",
38
+ description: "High-risk: permanently delete a single file or empty directory in the workspace. Non-empty directories are rejected unless recursive: true (opt-in per call; recursive refuses symlinked-directory traversal and enforces a per-call fan-out cap, default 10,000 entries). No trash/recycle — host undo is not automatic. Prefer edit/write when content can be fixed in place.",
34
39
  parameters: {
35
40
  type: "object",
36
41
  properties: {
37
- path: { type: "string", description: "Path to the file or empty directory to delete (relative or absolute)" },
42
+ path: {
43
+ type: "string",
44
+ description: "Path to the file, empty directory, or (with recursive: true) directory tree to delete (relative or absolute)",
45
+ },
46
+ recursive: {
47
+ type: "boolean",
48
+ description: "Opt-in per-call recursive directory delete (default false). Symlink children are unlinked as links, never followed; fan-out capped.",
49
+ },
50
+ maxEntries: {
51
+ type: "number",
52
+ description: `Per-call recursive fan-out cap (default ${DEFAULT_MAX_RECURSIVE_DELETE_ENTRIES}, hard ${HARD_MAX_RECURSIVE_DELETE_ENTRIES})`,
53
+ },
38
54
  },
39
55
  required: ["path"],
40
56
  additionalProperties: false,
@@ -42,6 +58,15 @@ export function createDeleteTool(cwd, options) {
42
58
  async execute(args, context) {
43
59
  const toolCallId = context.toolCallId;
44
60
  const path = typeof args.path === "string" ? args.path : "";
61
+ const recursive = args.recursive === true;
62
+ let maxEntries = DEFAULT_MAX_RECURSIVE_DELETE_ENTRIES;
63
+ if (args.maxEntries !== undefined) {
64
+ const raw = args.maxEntries;
65
+ if (!Number.isInteger(raw) || raw < 1 || raw > HARD_MAX_RECURSIVE_DELETE_ENTRIES) {
66
+ return errorResult(toolCallId, `maxEntries must be an integer between 1 and ${HARD_MAX_RECURSIVE_DELETE_ENTRIES}`);
67
+ }
68
+ maxEntries = raw;
69
+ }
45
70
  if (path.length === 0) {
46
71
  return errorResult(toolCallId, "path is required and must be a non-empty string.");
47
72
  }
@@ -94,16 +119,63 @@ export function createDeleteTool(cwd, options) {
94
119
  }
95
120
  if (st.isDirectory()) {
96
121
  const entries = await ops.readdir(allowedPath, { signal: context.signal });
97
- if (entries.length > 0) {
98
- return errorResult(toolCallId, `Directory is not empty: ${path}. Recursive delete is not supported.`);
122
+ if (entries.length > 0 && !recursive) {
123
+ return errorResult(toolCallId, `Directory is not empty: ${path}. Recursive delete is not supported (set recursive: true to opt in per call).`);
124
+ }
125
+ if (entries.length === 0) {
126
+ await ops.rmdir(allowedPath, { signal: context.signal });
127
+ options?.onEvent?.({ type: "file_changed", path: allowedPath, op: "delete", toolCallId });
128
+ return {
129
+ toolCallId,
130
+ name: "delete",
131
+ content: [{ type: "text", text: `Successfully deleted empty directory ${allowedPath}` }],
132
+ metadata: { path: allowedPath, kind: "directory" },
133
+ };
134
+ }
135
+ // Recursive delete: iterative post-order walk. Symlink children are
136
+ // UNLINKED as links and never followed — a symlinked directory can
137
+ // never drag the deletion outside the workspace root. The walk counts
138
+ // every entry against the per-call fan-out cap and checks the abort
139
+ // signal per entry; exceeding the cap stops with an error naming it.
140
+ const stack = [{ dir: allowedPath }];
141
+ const dirs = [allowedPath];
142
+ let count = 0;
143
+ while (stack.length > 0) {
144
+ if (context.signal?.aborted) {
145
+ return errorResult(toolCallId, `Operation aborted after deleting ${count} entries`);
146
+ }
147
+ const { dir } = stack.pop();
148
+ const children = await ops.readdir(dir, { signal: context.signal });
149
+ for (const child of children) {
150
+ count++;
151
+ if (count > maxEntries) {
152
+ return errorResult(toolCallId, `Recursive delete exceeded the per-call fan-out cap of ${maxEntries} entries after deleting ${count - 1} entries; nothing beyond the cap was touched.`);
153
+ }
154
+ const childPath = `${dir}/${child}`;
155
+ const childStat = await ops.lstat(childPath, { signal: context.signal });
156
+ if (childStat.isDirectory()) {
157
+ dirs.push(childPath);
158
+ stack.push({ dir: childPath });
159
+ }
160
+ else {
161
+ await ops.unlink(childPath, { signal: context.signal });
162
+ options?.onEvent?.({ type: "file_changed", path: childPath, op: "delete", toolCallId });
163
+ }
164
+ }
165
+ }
166
+ // Remove directories deepest-first, then the root directory itself.
167
+ for (let i = dirs.length - 1; i >= 0; i--) {
168
+ if (context.signal?.aborted) {
169
+ return errorResult(toolCallId, `Operation aborted after deleting ${count} entries`);
170
+ }
171
+ await ops.rmdir(dirs[i], { signal: context.signal });
172
+ options?.onEvent?.({ type: "file_changed", path: dirs[i], op: "delete", toolCallId });
99
173
  }
100
- await ops.rmdir(allowedPath, { signal: context.signal });
101
- options?.onEvent?.({ type: "file_changed", path: allowedPath, op: "delete", toolCallId });
102
174
  return {
103
175
  toolCallId,
104
176
  name: "delete",
105
- content: [{ type: "text", text: `Successfully deleted empty directory ${allowedPath}` }],
106
- metadata: { path: allowedPath, kind: "directory" },
177
+ content: [{ type: "text", text: `Successfully deleted ${allowedPath} (${count} entries)` }],
178
+ metadata: { path: allowedPath, kind: "directory", recursive: true, entriesDeleted: count },
107
179
  };
108
180
  }
109
181
  return errorResult(toolCallId, `Unsupported file type: ${path}`);
@@ -1,6 +1,21 @@
1
1
  /**
2
- * Minimal glob matcher: `*`, `?`, and `**` only. No brace expansion, no regex.
2
+ * Minimal glob matcher: `*`, `?`, and `**` only. Brace expansion is an opt-in,
3
+ * bounded extension (see `expandGlobBraces`). No regex, no dependency.
3
4
  */
4
- export declare function validateGlobPattern(pattern: string, maxPatternBytes: number): void;
5
+ export interface BraceExpansionOptions {
6
+ /** Max alternatives produced by expansion (default 128). */
7
+ maxAlternatives?: number;
8
+ /** Max total bytes across all expanded alternatives (default 4096). */
9
+ maxExpandedBytes?: number;
10
+ }
11
+ /**
12
+ * Expand `{a,b}` groups in a pattern (cartesian across multiple groups).
13
+ * Bounded: max alternatives and max total expanded bytes; unbalanced or nested
14
+ * braces fail closed. With no braces (or empty text), returns `[pattern]`.
15
+ */
16
+ export declare function expandGlobBraces(pattern: string, options?: BraceExpansionOptions): string[];
17
+ export declare function validateGlobPattern(pattern: string, maxPatternBytes: number, options?: {
18
+ braceExpansion?: boolean;
19
+ }): void;
5
20
  /** Match a workspace-relative path against a glob pattern using `/` separators. */
6
21
  export declare function matchGlobPattern(pattern: string, path: string): boolean;
@@ -1,7 +1,63 @@
1
1
  /**
2
- * Minimal glob matcher: `*`, `?`, and `**` only. No brace expansion, no regex.
2
+ * Minimal glob matcher: `*`, `?`, and `**` only. Brace expansion is an opt-in,
3
+ * bounded extension (see `expandGlobBraces`). No regex, no dependency.
3
4
  */
4
- export function validateGlobPattern(pattern, maxPatternBytes) {
5
+ const DEFAULT_MAX_BRACE_ALTERNATIVES = 128;
6
+ const DEFAULT_MAX_BRACE_EXPANDED_BYTES = 4_096;
7
+ /**
8
+ * Expand `{a,b}` groups in a pattern (cartesian across multiple groups).
9
+ * Bounded: max alternatives and max total expanded bytes; unbalanced or nested
10
+ * braces fail closed. With no braces (or empty text), returns `[pattern]`.
11
+ */
12
+ export function expandGlobBraces(pattern, options) {
13
+ const maxAlternatives = options?.maxAlternatives ?? DEFAULT_MAX_BRACE_ALTERNATIVES;
14
+ const maxExpandedBytes = options?.maxExpandedBytes ?? DEFAULT_MAX_BRACE_EXPANDED_BYTES;
15
+ const results = [];
16
+ const go = (start, prefix) => {
17
+ if (results.length >= maxAlternatives) {
18
+ throw new Error(`brace expansion exceeds ${maxAlternatives} alternative limit`);
19
+ }
20
+ const open = pattern.indexOf("{", start);
21
+ if (open === -1) {
22
+ const out = prefix + pattern.slice(start);
23
+ if (Buffer.byteLength(out, "utf8") > maxExpandedBytes) {
24
+ throw new Error(`brace expansion exceeds ${maxExpandedBytes} byte limit`);
25
+ }
26
+ results.push(out);
27
+ return;
28
+ }
29
+ let depth = 1;
30
+ let close = -1;
31
+ for (let i = open + 1; i < pattern.length; i++) {
32
+ const ch = pattern[i];
33
+ if (ch === "{")
34
+ depth++;
35
+ else if (ch === "}") {
36
+ depth--;
37
+ if (depth === 0) {
38
+ close = i;
39
+ break;
40
+ }
41
+ }
42
+ }
43
+ if (close === -1)
44
+ throw new Error(`unbalanced brace expansion in pattern: ${pattern}`);
45
+ if (depth !== 0)
46
+ throw new Error(`unbalanced brace expansion in pattern: ${pattern}`);
47
+ const body = pattern.slice(open + 1, close);
48
+ if (body.includes("{") || body.includes("}")) {
49
+ throw new Error(`nested brace expansion is not supported in pattern: ${pattern}`);
50
+ }
51
+ if (body.length === 0)
52
+ throw new Error(`empty brace expansion in pattern: ${pattern}`);
53
+ for (const alt of body.split(",")) {
54
+ go(close + 1, prefix + pattern.slice(start, open) + alt);
55
+ }
56
+ };
57
+ go(0, "");
58
+ return results;
59
+ }
60
+ export function validateGlobPattern(pattern, maxPatternBytes, options) {
5
61
  const patternBytes = Buffer.byteLength(pattern, "utf8");
6
62
  if (patternBytes < 1)
7
63
  throw new Error("pattern must be non-empty");
@@ -9,7 +65,12 @@ export function validateGlobPattern(pattern, maxPatternBytes) {
9
65
  throw new Error(`pattern exceeds ${maxPatternBytes} byte pattern limit`);
10
66
  }
11
67
  if (pattern.includes("{") || pattern.includes("}")) {
12
- throw new Error("brace expansion is not supported in glob patterns");
68
+ if (options?.braceExpansion === true) {
69
+ // Bounded expansion is validated by expandGlobBraces (alternatives + byte caps).
70
+ expandGlobBraces(pattern);
71
+ return;
72
+ }
73
+ throw new Error("brace expansion is not supported in glob patterns (set braceExpansion: true to enable the bounded expansion)");
13
74
  }
14
75
  }
15
76
  function matchSegment(pattern, segment) {
package/dist/glob.d.ts CHANGED
@@ -10,5 +10,7 @@ export interface GlobToolOptions {
10
10
  maxDepth?: number;
11
11
  maxResults?: number;
12
12
  exclude?: readonly string[];
13
+ /** Host-selected default for bounded `{a,b}` brace expansion (default false). */
14
+ braceExpansion?: boolean;
13
15
  }
14
16
  export declare function createGlobTool(cwd: string, options?: GlobToolOptions): ToolDefinition;
package/dist/glob.js CHANGED
@@ -32,7 +32,7 @@ export function createGlobTool(cwd, options) {
32
32
  return {
33
33
  name: "glob",
34
34
  effect: CODING_OBSERVATION_EFFECT,
35
- description: `Find workspace files by glob pattern without shell find. Supports * (segment), ? (one char), and ** (directories). Brace expansion is rejected. Skips hidden names and excluded basenames (default: ${limits.exclude.join(", ")}) unless overridden. Does not follow symlinks. Results paginate with offset/maxResults (default ${limits.maxResults}). Depth default ${limits.maxDepth}. Prefer repo_list to enumerate directories and repo_search to find text inside files.`,
35
+ description: `Find workspace files by glob pattern without shell find. Supports * (segment), ? (one char), and ** (directories). Brace expansion is rejected unless braceExpansion: true (bounded: max 128 alternatives / 4096 expanded bytes; unbalanced or nested braces are errors). Skips hidden names and excluded basenames (default: ${limits.exclude.join(", ")}) unless overridden. Does not follow symlinks. Results paginate with offset/maxResults (default ${limits.maxResults}). Depth default ${limits.maxDepth}. Prefer repo_list to enumerate directories and repo_search to find text inside files.`,
36
36
  parameters: {
37
37
  type: "object",
38
38
  properties: {
@@ -48,6 +48,10 @@ export function createGlobTool(cwd, options) {
48
48
  type: "boolean",
49
49
  description: "Include dotfile/dotdir names (default false). Excluded basenames still apply.",
50
50
  },
51
+ braceExpansion: {
52
+ type: "boolean",
53
+ description: "Opt-in bounded {a,b} brace expansion (default: host option, else false)",
54
+ },
51
55
  maxDepth: {
52
56
  type: "number",
53
57
  description: `Maximum directory depth to descend (default ${limits.maxDepth}, hard ${HARD_MAX_REPO_DEPTH})`,
@@ -73,6 +77,7 @@ export function createGlobTool(cwd, options) {
73
77
  return errorResult(toolCallId, "pattern must be a non-empty string");
74
78
  const path = typeof args.path === "string" ? args.path : undefined;
75
79
  const includeHidden = args.includeHidden === true;
80
+ const braceExpansion = args.braceExpansion === true || (args.braceExpansion === undefined && options?.braceExpansion === true);
76
81
  let maxDepth;
77
82
  let maxResults;
78
83
  let offset = 0;
@@ -98,6 +103,7 @@ export function createGlobTool(cwd, options) {
98
103
  metadata: {
99
104
  pattern,
100
105
  includeHidden,
106
+ braceExpansion,
101
107
  maxDepth,
102
108
  maxResults,
103
109
  offset,
@@ -114,6 +120,7 @@ export function createGlobTool(cwd, options) {
114
120
  pattern,
115
121
  path,
116
122
  includeHidden,
123
+ braceExpansion,
117
124
  exclude: limits.exclude,
118
125
  maxDepth: maxDepth ?? limits.maxDepth,
119
126
  maxResults: maxResults ?? limits.maxResults,
package/dist/index.d.ts CHANGED
@@ -26,7 +26,7 @@ export type { ListToolOptions } from "./list.js";
26
26
  export { createRepoListTool } from "./list.js";
27
27
  export type { MoveOperations, MoveToolOptions } from "./move.js";
28
28
  export { createMoveTool } from "./move.js";
29
- export type { ReadOperations, ReadTextOptions, ReadTextResult, ReadToolOptions, TransformImage, TransformImageInput, } from "./read.js";
29
+ export type { ReadOperations, ReadTextOptions, ReadTextResult, ReadToolOptions, TransformImage, TransformImageInput, DocumentReader, DocumentReaderResult, } from "./read.js";
30
30
  export { createReadTool, DEFAULT_MAX_IMAGE_BYTES, detectSupportedImageMimeType, detectSupportedImageMimeTypeFromFile, } from "./read.js";
31
31
  export type { ReadPathSet } from "./read-path-set.js";
32
32
  export { createReadPathSet, createReadPathSetPersistence, DEFAULT_MAX_PERSISTED_READ_PATHS, DEFAULT_MAX_PERSISTED_READ_PATH_CHARS, READ_PATH_SET_NAMESPACE, } from "./read-path-set.js";
package/dist/read.d.ts CHANGED
@@ -59,6 +59,34 @@ export interface ReadTextResult {
59
59
  readonly totalLines?: number;
60
60
  readonly totalBytes?: number;
61
61
  }
62
+ /**
63
+ * Optional host-selected document parser adapter (plan 018 closeout `doc-reader`).
64
+ *
65
+ * When wired into {@link ReadToolOptions.documentReader}, the read tool tries
66
+ * the reader after the image sniff and before the text page. The reader owns
67
+ * its bounds (input bytes, pages, output text); the read tool re-checks the
68
+ * output cap before returning. An unsupported buffer must return `null` so
69
+ * reads fall through to the 0.1.5 text path. No file-extension sniffing ever
70
+ * activates parsing — activation is explicit via this option.
71
+ */
72
+ export interface DocumentReader {
73
+ /** Hard input size cap; reads refuse larger files before loading them. */
74
+ readonly maxInputBytes: number;
75
+ /** Hard cap on extracted literal text; the read tool refuses results beyond it. */
76
+ readonly maxTextBytes: number;
77
+ /** Extract literal text; return null when the buffer is not a supported document format. */
78
+ extract(input: {
79
+ readonly buffer: Buffer;
80
+ readonly path: string;
81
+ readonly signal?: AbortSignal;
82
+ }): Promise<DocumentReaderResult | null>;
83
+ }
84
+ export interface DocumentReaderResult {
85
+ readonly text: string;
86
+ readonly format: string;
87
+ readonly pages: number;
88
+ readonly truncatedBy: "pages" | "bytes" | null;
89
+ }
62
90
  export interface ReadOperations {
63
91
  /** Read a bounded binary file. Backends must honor `maxBytes` before retaining more data. */
64
92
  readFile: (absolutePath: string, options: {
@@ -97,6 +125,11 @@ export interface ReadToolOptions {
97
125
  maxBytes?: number;
98
126
  /** When set, successful reads record the resolved absolute path for read-before-write guards. */
99
127
  readPathSet?: ReadPathSet;
128
+ /**
129
+ * Optional host-selected document reader (PDF/Office literal-text extraction).
130
+ * Additive: absent reader means exactly the 0.1.5 text/image behavior.
131
+ */
132
+ documentReader?: DocumentReader;
100
133
  }
101
134
  export declare function createReadTool(cwd: string, options?: ReadToolOptions): ToolDefinition;
102
135
  /** Re-exported for hosts building custom read tools or analyzing truncation metadata. */
package/dist/read.js CHANGED
@@ -363,6 +363,35 @@ export function createReadTool(cwd, options) {
363
363
  metadata: { image: { mimeType, resized, bytes: buffer.length } },
364
364
  };
365
365
  }
366
+ const documentReader = options?.documentReader;
367
+ if (documentReader) {
368
+ const { size } = await ops.statFile(allowedPath, { signal: context.signal });
369
+ if (size > documentReader.maxInputBytes) {
370
+ return errorResult(toolCallId, `Document is ${formatSize(size)}, exceeds ${formatSize(documentReader.maxInputBytes)} limit.`);
371
+ }
372
+ const buffer = await ops.readFile(allowedPath, {
373
+ maxBytes: documentReader.maxInputBytes,
374
+ signal: context.signal,
375
+ });
376
+ if (buffer.length > documentReader.maxInputBytes) {
377
+ return errorResult(toolCallId, `Document is ${formatSize(buffer.length)}, exceeds ${formatSize(documentReader.maxInputBytes)} limit.`);
378
+ }
379
+ const extracted = await documentReader.extract({ buffer, path: allowedPath, signal: context.signal });
380
+ if (extracted) {
381
+ if (Buffer.byteLength(extracted.text, "utf8") > documentReader.maxTextBytes) {
382
+ throw new Error("DocumentReader.extract returned text beyond its maxTextBytes cap");
383
+ }
384
+ options?.readPathSet?.add(allowedPath);
385
+ return {
386
+ toolCallId,
387
+ name: "read",
388
+ content: [{ type: "text", text: extracted.text }],
389
+ metadata: {
390
+ document: { format: extracted.format, pages: extracted.pages, truncatedBy: extracted.truncatedBy },
391
+ },
392
+ };
393
+ }
394
+ }
366
395
  const page = await ops.readText(allowedPath, {
367
396
  offset: startLine,
368
397
  limit: requestedLines,
@@ -114,6 +114,8 @@ export interface RepositoryGlobRequest {
114
114
  readonly maxDepth?: number;
115
115
  readonly maxResults?: number;
116
116
  readonly offset?: number;
117
+ /** Opt-in bounded `{a,b}` expansion (default false; expansion bounds in glob-match.ts). */
118
+ readonly braceExpansion?: boolean;
117
119
  readonly signal?: AbortSignal;
118
120
  readonly deadlineMs?: number;
119
121
  }
@@ -8,7 +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
+ import { expandGlobBraces, matchGlobPattern, validateGlobPattern } from "./glob-match.js";
12
12
  import { resolveToCwd } from "./path-utils.js";
13
13
  export const DEFAULT_REPO_EXCLUDE = Object.freeze([".git", "node_modules", "dist"]);
14
14
  export class RepositoryError extends Error {
@@ -605,11 +605,14 @@ async function searchLocal(request, defaults, walk) {
605
605
  }
606
606
  async function globLocal(request, defaults, walk) {
607
607
  try {
608
- validateGlobPattern(request.pattern, defaults.maxPatternBytes);
608
+ validateGlobPattern(request.pattern, defaults.maxPatternBytes, { braceExpansion: request.braceExpansion === true });
609
609
  }
610
610
  catch (error) {
611
611
  throw new RepositoryError(error instanceof Error ? error.message : String(error));
612
612
  }
613
+ // Opt-in bounded brace expansion: textual alternatives only (never touches the
614
+ // filesystem); bounds enforced by expandGlobBraces (max alternatives / bytes).
615
+ const patterns = request.braceExpansion === true ? expandGlobBraces(request.pattern) : [request.pattern];
613
616
  const resolved = await resolveRepoPath(request.root, request.path);
614
617
  const maxResults = validateCodingLimit("maxResults", request.maxResults ?? defaults.maxResults, HARD_MAX_REPO_RESULTS);
615
618
  const offset = validateCodingLimitAllowZero("offset", request.offset ?? 0, HARD_MAX_REPO_ENTRIES);
@@ -622,8 +625,15 @@ async function globLocal(request, defaults, walk) {
622
625
  let seen = 0;
623
626
  let truncated = false;
624
627
  let truncatedBy = null;
628
+ const matchesAnyPattern = (relativePath) => {
629
+ for (const p of patterns) {
630
+ if (matchGlobPattern(p, relativePath))
631
+ return true;
632
+ }
633
+ return false;
634
+ };
625
635
  const maybeCollect = (relativePath) => {
626
- if (!matchGlobPattern(request.pattern, relativePath))
636
+ if (!matchesAnyPattern(relativePath))
627
637
  return false;
628
638
  if (seen < offset) {
629
639
  seen++;
@@ -644,7 +654,7 @@ async function globLocal(request, defaults, walk) {
644
654
  scannedEntries = 1;
645
655
  if (startStat.isFile()) {
646
656
  scannedFiles = 1;
647
- if (matchGlobPattern(request.pattern, resolved.relative)) {
657
+ if (matchesAnyPattern(resolved.relative)) {
648
658
  if (offset === 0 && maxResults > 0)
649
659
  collected.push(resolved.relative);
650
660
  else if (offset === 0 && maxResults === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism-coding-agent",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
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",
@@ -28,8 +28,8 @@
28
28
  "diff": "^9.0.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@arnilo/prism": "0.1.5",
32
- "@arnilo/prism-workflows": "0.1.5"
31
+ "@arnilo/prism": "0.1.7",
32
+ "@arnilo/prism-workflows": "0.1.7"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@arnilo/prism": "file:../..",