@arnilo/prism-coding-agent 0.0.8 → 0.0.10

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.
@@ -0,0 +1,633 @@
1
+ /**
2
+ * Bounded repository walk primitives for list/search tools.
3
+ *
4
+ * Streams the tree with Node `opendir` / `lstat`; never follows symlink escapes,
5
+ * rejects devices/FIFOs/sockets for descent, and charges finite depth/entry/file
6
+ * limits before retaining the next result. No glob/index/watcher dependency.
7
+ */
8
+ import { open, opendir, lstat, realpath } from "node:fs/promises";
9
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
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 { resolveToCwd } from "./path-utils.js";
12
+ export const DEFAULT_REPO_EXCLUDE = Object.freeze([".git", "node_modules", "dist"]);
13
+ export class RepositoryError extends Error {
14
+ code = "ERR_PRISM_REPOSITORY";
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "RepositoryError";
18
+ }
19
+ }
20
+ export function resolveRepositoryLimits(options) {
21
+ return {
22
+ maxDepth: validateCodingLimit("maxDepth", options?.maxDepth ?? DEFAULT_MAX_REPO_DEPTH, HARD_MAX_REPO_DEPTH),
23
+ maxEntries: validateCodingLimit("maxEntries", options?.maxEntries ?? DEFAULT_MAX_REPO_ENTRIES, HARD_MAX_REPO_ENTRIES),
24
+ maxFiles: validateCodingLimit("maxFiles", options?.maxFiles ?? DEFAULT_MAX_REPO_FILES, HARD_MAX_REPO_FILES),
25
+ maxResults: validateCodingLimit("maxResults", options?.maxResults ?? DEFAULT_MAX_REPO_RESULTS, HARD_MAX_REPO_RESULTS),
26
+ maxConcurrency: validateCodingLimit("maxConcurrency", options?.maxConcurrency ?? DEFAULT_MAX_REPO_CONCURRENCY, HARD_MAX_REPO_CONCURRENCY),
27
+ maxScanBytes: validateCodingLimit("maxScanBytes", options?.maxScanBytes ?? DEFAULT_MAX_SEARCH_SCAN_BYTES, HARD_MAX_SEARCH_SCAN_BYTES),
28
+ maxFileBytes: validateCodingLimit("maxFileBytes", options?.maxFileBytes ?? DEFAULT_MAX_SEARCH_FILE_BYTES, HARD_MAX_SEARCH_FILE_BYTES),
29
+ maxMatches: validateCodingLimit("maxMatches", options?.maxMatches ?? DEFAULT_MAX_SEARCH_MATCHES, HARD_MAX_SEARCH_MATCHES),
30
+ maxPatternBytes: validateCodingLimit("maxPatternBytes", options?.maxPatternBytes ?? DEFAULT_MAX_SEARCH_PATTERN_BYTES, HARD_MAX_SEARCH_PATTERN_BYTES),
31
+ maxLineBytes: validateCodingLimit("maxLineBytes", options?.maxLineBytes ?? DEFAULT_MAX_SEARCH_LINE_BYTES, HARD_MAX_SEARCH_LINE_BYTES),
32
+ maxContextLines: validateCodingLimitAllowZero("maxContextLines", options?.maxContextLines ?? DEFAULT_MAX_SEARCH_CONTEXT_LINES, HARD_MAX_SEARCH_CONTEXT_LINES),
33
+ maxTimeMs: validateCodingLimit("maxTimeMs", options?.maxTimeMs ?? DEFAULT_MAX_SEARCH_TIME_MS, HARD_MAX_SEARCH_TIME_MS),
34
+ binarySniffBytes: DEFAULT_BINARY_SNIFF_BYTES,
35
+ exclude: Object.freeze([...(options?.exclude ?? DEFAULT_REPO_EXCLUDE)]),
36
+ };
37
+ }
38
+ /** Normalize a workspace-relative path to stable forward-slash form. */
39
+ export function toRepoRelative(root, absolutePath) {
40
+ const rel = relative(root, absolutePath);
41
+ if (rel === "")
42
+ return ".";
43
+ return rel.split(sep).join("/");
44
+ }
45
+ function isPathInsideRoot(root, target) {
46
+ const from = resolve(root);
47
+ const to = resolve(target);
48
+ if (to === from)
49
+ return true;
50
+ const rel = relative(from, to);
51
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
52
+ }
53
+ /**
54
+ * Resolve a list/search start path under the workspace root.
55
+ * Symlink escapes fail closed after realpath when the path exists.
56
+ */
57
+ export async function resolveRepoPath(root, inputPath) {
58
+ const rootResolved = resolve(root);
59
+ let rootReal;
60
+ try {
61
+ rootReal = await realpath(rootResolved);
62
+ }
63
+ catch {
64
+ throw new RepositoryError(`workspace root is missing or unreadable: ${rootResolved}`);
65
+ }
66
+ if (!inputPath || inputPath === "." || inputPath === "./") {
67
+ return { absolute: rootReal, relative: ".", rootReal };
68
+ }
69
+ const candidate = resolveToCwd(inputPath, rootReal);
70
+ if (!isPathInsideRoot(rootReal, candidate)) {
71
+ throw new RepositoryError(`path escapes workspace root: ${inputPath}`);
72
+ }
73
+ try {
74
+ const real = await realpath(candidate);
75
+ if (!isPathInsideRoot(rootReal, real)) {
76
+ throw new RepositoryError(`path resolves outside workspace root: ${inputPath}`);
77
+ }
78
+ return { absolute: real, relative: toRepoRelative(rootReal, real), rootReal };
79
+ }
80
+ catch (error) {
81
+ if (error instanceof RepositoryError)
82
+ throw error;
83
+ // ENOENT: allow listing a missing path to fail later with a clear error.
84
+ return { absolute: candidate, relative: toRepoRelative(rootReal, candidate), rootReal };
85
+ }
86
+ }
87
+ function shouldSkipName(name, includeHidden, exclude) {
88
+ if (name === "." || name === "..")
89
+ return true;
90
+ if (exclude.has(name))
91
+ return true;
92
+ if (!includeHidden && name.startsWith("."))
93
+ return true;
94
+ return false;
95
+ }
96
+ function kindFromDirent(dirent) {
97
+ if (dirent.isSymbolicLink())
98
+ return "symlink";
99
+ if (dirent.isDirectory())
100
+ return "directory";
101
+ if (dirent.isFile())
102
+ return "file";
103
+ return "other";
104
+ }
105
+ function assertNotAborted(signal) {
106
+ if (signal?.aborted)
107
+ throw new RepositoryError("Operation aborted");
108
+ }
109
+ function assertDeadline(deadlineAt) {
110
+ if (deadlineAt !== undefined && Date.now() >= deadlineAt) {
111
+ throw new RepositoryError("Repository operation exceeded time limit");
112
+ }
113
+ }
114
+ export function isBinaryBuffer(buffer) {
115
+ const length = Math.min(buffer.length, DEFAULT_BINARY_SNIFF_BYTES);
116
+ for (let i = 0; i < length; i++) {
117
+ if (buffer[i] === 0)
118
+ return true;
119
+ }
120
+ return false;
121
+ }
122
+ export function compileSearchPattern(query, mode, caseSensitive, maxPatternBytes) {
123
+ const patternBytes = Buffer.byteLength(query, "utf8");
124
+ if (patternBytes < 1)
125
+ throw new RepositoryError("query must be non-empty");
126
+ if (patternBytes > maxPatternBytes) {
127
+ throw new RepositoryError(`query exceeds ${maxPatternBytes} byte pattern limit`);
128
+ }
129
+ if (mode === "literal") {
130
+ if (caseSensitive) {
131
+ return {
132
+ patternBytes,
133
+ testLine: (line) => {
134
+ const column = line.indexOf(query);
135
+ return column >= 0 ? { column: column + 1 } : null;
136
+ },
137
+ };
138
+ }
139
+ const needle = query.toLowerCase();
140
+ return {
141
+ patternBytes,
142
+ testLine: (line) => {
143
+ const column = line.toLowerCase().indexOf(needle);
144
+ return column >= 0 ? { column: column + 1 } : null;
145
+ },
146
+ };
147
+ }
148
+ let regex;
149
+ try {
150
+ regex = new RegExp(query, caseSensitive ? "u" : "iu");
151
+ }
152
+ catch (error) {
153
+ const message = error instanceof Error ? error.message : String(error);
154
+ throw new RepositoryError(`invalid regular expression: ${message}`);
155
+ }
156
+ return {
157
+ patternBytes,
158
+ testLine: (line) => {
159
+ regex.lastIndex = 0;
160
+ const match = regex.exec(line);
161
+ return match && match.index !== undefined ? { column: match.index + 1 } : null;
162
+ },
163
+ };
164
+ }
165
+ async function* walkRepository(rootReal, startAbsolute, limits) {
166
+ const queue = [
167
+ {
168
+ absolute: startAbsolute,
169
+ relative: toRepoRelative(rootReal, startAbsolute),
170
+ depth: 0,
171
+ },
172
+ ];
173
+ let scannedEntries = 0;
174
+ let scannedFiles = 0;
175
+ while (queue.length > 0) {
176
+ assertNotAborted(limits.signal);
177
+ assertDeadline(limits.deadlineAt);
178
+ const current = queue.shift();
179
+ if (current.depth > limits.maxDepth) {
180
+ yield { type: "limit", truncatedBy: "depth" };
181
+ return;
182
+ }
183
+ let dir;
184
+ try {
185
+ dir = await opendir(current.absolute);
186
+ }
187
+ catch (error) {
188
+ if (current.relative === "." || current.depth === 0) {
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ throw new RepositoryError(`cannot open directory: ${message}`);
191
+ }
192
+ continue;
193
+ }
194
+ const names = [];
195
+ try {
196
+ for await (const dirent of dir) {
197
+ assertNotAborted(limits.signal);
198
+ assertDeadline(limits.deadlineAt);
199
+ names.push(dirent);
200
+ }
201
+ }
202
+ finally {
203
+ await dir.close().catch(() => undefined);
204
+ }
205
+ names.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
206
+ for (const dirent of names) {
207
+ assertNotAborted(limits.signal);
208
+ assertDeadline(limits.deadlineAt);
209
+ if (shouldSkipName(dirent.name, limits.includeHidden, limits.exclude))
210
+ continue;
211
+ if (scannedEntries >= limits.maxEntries) {
212
+ yield { type: "limit", truncatedBy: "entries" };
213
+ return;
214
+ }
215
+ scannedEntries++;
216
+ const absolutePath = join(current.absolute, dirent.name);
217
+ if (!isPathInsideRoot(rootReal, absolutePath))
218
+ continue;
219
+ let kind = kindFromDirent(dirent);
220
+ let size;
221
+ // Re-check with lstat so we never follow symlinks for type/size.
222
+ try {
223
+ const st = await lstat(absolutePath);
224
+ if (st.isSymbolicLink())
225
+ kind = "symlink";
226
+ else if (st.isDirectory())
227
+ kind = "directory";
228
+ else if (st.isFile())
229
+ kind = "file";
230
+ else
231
+ kind = "other";
232
+ if (kind === "file")
233
+ size = st.size;
234
+ }
235
+ catch {
236
+ continue;
237
+ }
238
+ if (kind === "file") {
239
+ if (scannedFiles >= limits.maxFiles) {
240
+ yield { type: "limit", truncatedBy: "files" };
241
+ return;
242
+ }
243
+ scannedFiles++;
244
+ }
245
+ const relativePath = current.relative === "." ? dirent.name : `${current.relative}/${dirent.name}`;
246
+ const entry = size === undefined ? { path: relativePath, kind } : { path: relativePath, kind, size };
247
+ yield { type: "entry", entry, absolutePath, depth: current.depth };
248
+ if (kind === "directory") {
249
+ const nextDepth = current.depth + 1;
250
+ if (nextDepth > limits.maxDepth) {
251
+ yield { type: "limit", truncatedBy: "depth" };
252
+ return;
253
+ }
254
+ queue.push({ absolute: absolutePath, relative: relativePath, depth: nextDepth });
255
+ }
256
+ }
257
+ }
258
+ }
259
+ async function listLocal(request, defaults) {
260
+ const resolved = await resolveRepoPath(request.root, request.path);
261
+ const maxResults = validateCodingLimit("maxResults", request.maxResults ?? defaults.maxResults, HARD_MAX_REPO_RESULTS);
262
+ const offset = validateCodingLimitAllowZero("offset", request.offset ?? 0, HARD_MAX_REPO_ENTRIES);
263
+ const maxDepth = validateCodingLimit("maxDepth", request.maxDepth ?? defaults.maxDepth, HARD_MAX_REPO_DEPTH);
264
+ const exclude = new Set(request.exclude ?? defaults.exclude);
265
+ const deadlineAt = request.deadlineMs !== undefined ? Date.now() + request.deadlineMs : Date.now() + defaults.maxTimeMs;
266
+ const collected = [];
267
+ let scannedEntries = 0;
268
+ let scannedFiles = 0;
269
+ let seen = 0;
270
+ let truncated = false;
271
+ let truncatedBy = null;
272
+ // Single-file start: return that entry when it falls within the page window.
273
+ try {
274
+ const startStat = await lstat(resolved.absolute);
275
+ if (!startStat.isDirectory()) {
276
+ let kind = "other";
277
+ if (startStat.isSymbolicLink())
278
+ kind = "symlink";
279
+ else if (startStat.isFile())
280
+ kind = "file";
281
+ const entry = kind === "file"
282
+ ? { path: resolved.relative, kind, size: startStat.size }
283
+ : { path: resolved.relative, kind };
284
+ scannedEntries = 1;
285
+ scannedFiles = kind === "file" ? 1 : 0;
286
+ if (offset === 0 && maxResults > 0)
287
+ collected.push(entry);
288
+ else if (offset === 0 && maxResults === 0) {
289
+ truncated = true;
290
+ truncatedBy = "results";
291
+ }
292
+ return {
293
+ entries: collected,
294
+ truncated,
295
+ truncatedBy,
296
+ scannedEntries,
297
+ scannedFiles,
298
+ offset,
299
+ nextOffset: undefined,
300
+ };
301
+ }
302
+ }
303
+ catch (error) {
304
+ const message = error instanceof Error ? error.message : String(error);
305
+ throw new RepositoryError(`cannot open path: ${message}`);
306
+ }
307
+ try {
308
+ for await (const event of walkRepository(resolved.rootReal, resolved.absolute, {
309
+ maxDepth,
310
+ maxEntries: defaults.maxEntries,
311
+ maxFiles: defaults.maxFiles,
312
+ exclude,
313
+ includeHidden: request.includeHidden === true,
314
+ signal: request.signal,
315
+ deadlineAt,
316
+ })) {
317
+ if (event.type === "limit") {
318
+ truncated = true;
319
+ truncatedBy = event.truncatedBy;
320
+ break;
321
+ }
322
+ scannedEntries++;
323
+ if (event.entry.kind === "file")
324
+ scannedFiles++;
325
+ if (seen < offset) {
326
+ seen++;
327
+ continue;
328
+ }
329
+ if (collected.length >= maxResults) {
330
+ truncated = true;
331
+ truncatedBy = "results";
332
+ break;
333
+ }
334
+ collected.push(event.entry);
335
+ seen++;
336
+ }
337
+ }
338
+ catch (error) {
339
+ if (error instanceof RepositoryError && error.message === "Operation aborted") {
340
+ return {
341
+ entries: collected,
342
+ truncated: true,
343
+ truncatedBy: "abort",
344
+ scannedEntries,
345
+ scannedFiles,
346
+ offset,
347
+ nextOffset: collected.length > 0 || offset > 0 ? offset + collected.length : undefined,
348
+ };
349
+ }
350
+ if (error instanceof RepositoryError && error.message === "Repository operation exceeded time limit") {
351
+ return {
352
+ entries: collected,
353
+ truncated: true,
354
+ truncatedBy: "time",
355
+ scannedEntries,
356
+ scannedFiles,
357
+ offset,
358
+ nextOffset: offset + collected.length,
359
+ };
360
+ }
361
+ throw error;
362
+ }
363
+ return {
364
+ entries: collected,
365
+ truncated,
366
+ truncatedBy,
367
+ scannedEntries,
368
+ scannedFiles,
369
+ offset,
370
+ nextOffset: truncated ? offset + collected.length : undefined,
371
+ };
372
+ }
373
+ async function searchFileLines(absolutePath, relativePath, testLine, options) {
374
+ assertNotAborted(options.signal);
375
+ assertDeadline(options.deadlineAt);
376
+ const handle = await open(absolutePath, "r");
377
+ try {
378
+ const st = await handle.stat();
379
+ if (st.size > options.maxFileBytes)
380
+ return "oversize";
381
+ const sniff = Buffer.allocUnsafe(Math.min(options.binarySniffBytes, st.size));
382
+ const { bytesRead: sniffed } = await handle.read(sniff, 0, sniff.length, 0);
383
+ if (isBinaryBuffer(sniff.subarray(0, sniffed)))
384
+ return "binary";
385
+ // Rewind and stream the whole file (already size-capped).
386
+ let offset = 0;
387
+ let lineStart = 0;
388
+ let lineNumber = 1;
389
+ let pending = Buffer.alloc(0);
390
+ const before = [];
391
+ const pendingAfter = [];
392
+ const readBuf = Buffer.allocUnsafe(64 * 1024);
393
+ const emitLine = (raw) => {
394
+ assertNotAborted(options.signal);
395
+ assertDeadline(options.deadlineAt);
396
+ const lineBytes = raw.length;
397
+ if (lineBytes > options.maxLineBytes) {
398
+ // Skip oversized lines but still charge scan budget for the bytes seen.
399
+ options.chargeScan(lineBytes);
400
+ if (options.maxScanBytesRemaining() < 0)
401
+ return "scan";
402
+ lineNumber++;
403
+ return "ok";
404
+ }
405
+ options.chargeScan(lineBytes);
406
+ if (options.maxScanBytesRemaining() < 0)
407
+ return "scan";
408
+ const text = raw.toString("utf8");
409
+ // Drain after-context for previous matches.
410
+ for (let i = pendingAfter.length - 1; i >= 0; i--) {
411
+ const item = pendingAfter[i];
412
+ if (item.remaining > 0) {
413
+ item.match.after.push(text);
414
+ item.remaining--;
415
+ }
416
+ if (item.remaining <= 0)
417
+ pendingAfter.splice(i, 1);
418
+ }
419
+ const hit = testLine(text);
420
+ if (hit) {
421
+ if (options.maxMatchesRemaining() <= 0)
422
+ return "matches";
423
+ const match = {
424
+ path: relativePath,
425
+ line: lineNumber,
426
+ column: hit.column,
427
+ text,
428
+ before: before.slice(-options.context),
429
+ after: [],
430
+ };
431
+ options.pushMatch(match);
432
+ if (options.context > 0)
433
+ pendingAfter.push({ match, remaining: options.context });
434
+ }
435
+ if (options.context > 0) {
436
+ before.push(text);
437
+ if (before.length > options.context)
438
+ before.shift();
439
+ }
440
+ lineNumber++;
441
+ return "ok";
442
+ };
443
+ while (offset < st.size) {
444
+ assertNotAborted(options.signal);
445
+ assertDeadline(options.deadlineAt);
446
+ if (options.maxScanBytesRemaining() <= 0)
447
+ return "scan";
448
+ if (options.maxMatchesRemaining() <= 0)
449
+ return "matches";
450
+ const { bytesRead } = await handle.read(readBuf, 0, readBuf.length, offset);
451
+ if (bytesRead === 0)
452
+ break;
453
+ offset += bytesRead;
454
+ pending = Buffer.concat([pending, readBuf.subarray(0, bytesRead)]);
455
+ let start = 0;
456
+ for (let i = 0; i < pending.length; i++) {
457
+ if (pending[i] === 0x0a) {
458
+ const end = i > start && pending[i - 1] === 0x0d ? i - 1 : i;
459
+ const status = emitLine(pending.subarray(start, end));
460
+ if (status !== "ok")
461
+ return status;
462
+ start = i + 1;
463
+ lineStart = offset - (pending.length - start);
464
+ }
465
+ }
466
+ pending = pending.subarray(start);
467
+ void lineStart;
468
+ }
469
+ if (pending.length > 0) {
470
+ const status = emitLine(pending);
471
+ if (status !== "ok")
472
+ return status;
473
+ }
474
+ return "ok";
475
+ }
476
+ finally {
477
+ await handle.close();
478
+ }
479
+ }
480
+ async function searchLocal(request, defaults) {
481
+ const mode = request.mode ?? "literal";
482
+ if (mode !== "literal" && mode !== "regex") {
483
+ throw new RepositoryError(`unsupported search mode: ${String(mode)}`);
484
+ }
485
+ const caseSensitive = request.caseSensitive === true;
486
+ const { testLine } = compileSearchPattern(request.query, mode, caseSensitive, defaults.maxPatternBytes);
487
+ const resolved = await resolveRepoPath(request.root, request.path);
488
+ const maxMatches = validateCodingLimit("maxMatches", request.maxMatches ?? defaults.maxMatches, HARD_MAX_SEARCH_MATCHES);
489
+ const context = validateCodingLimitAllowZero("context", request.context ?? defaults.maxContextLines, HARD_MAX_SEARCH_CONTEXT_LINES);
490
+ const exclude = new Set(request.exclude ?? defaults.exclude);
491
+ const deadlineAt = request.deadlineMs !== undefined ? Date.now() + request.deadlineMs : Date.now() + defaults.maxTimeMs;
492
+ const matches = [];
493
+ let scannedBytes = 0;
494
+ let scannedFiles = 0;
495
+ let scannedEntries = 0;
496
+ let filesSkippedBinary = 0;
497
+ let filesSkippedOversize = 0;
498
+ let truncated = false;
499
+ let truncatedBy = null;
500
+ const runFile = async (absolutePath, relativePath) => {
501
+ if (truncated)
502
+ return;
503
+ const status = await searchFileLines(absolutePath, relativePath, testLine, {
504
+ maxFileBytes: defaults.maxFileBytes,
505
+ maxLineBytes: defaults.maxLineBytes,
506
+ maxScanBytesRemaining: () => defaults.maxScanBytes - scannedBytes,
507
+ chargeScan: (n) => {
508
+ scannedBytes += n;
509
+ },
510
+ context,
511
+ maxMatchesRemaining: () => maxMatches - matches.length,
512
+ pushMatch: (match) => {
513
+ if (matches.length < maxMatches)
514
+ matches.push(match);
515
+ },
516
+ signal: request.signal,
517
+ deadlineAt,
518
+ binarySniffBytes: defaults.binarySniffBytes,
519
+ });
520
+ if (status === "binary")
521
+ filesSkippedBinary++;
522
+ else if (status === "oversize")
523
+ filesSkippedOversize++;
524
+ else if (status === "scan") {
525
+ truncated = true;
526
+ truncatedBy = "scan";
527
+ }
528
+ else if (status === "matches") {
529
+ truncated = true;
530
+ truncatedBy = "matches";
531
+ }
532
+ };
533
+ try {
534
+ const startStat = await lstat(resolved.absolute);
535
+ if (startStat.isFile()) {
536
+ scannedEntries = 1;
537
+ scannedFiles = 1;
538
+ await runFile(resolved.absolute, resolved.relative);
539
+ }
540
+ else if (startStat.isDirectory()) {
541
+ for await (const event of walkRepository(resolved.rootReal, resolved.absolute, {
542
+ maxDepth: defaults.maxDepth,
543
+ maxEntries: defaults.maxEntries,
544
+ maxFiles: defaults.maxFiles,
545
+ exclude,
546
+ includeHidden: request.includeHidden === true,
547
+ signal: request.signal,
548
+ deadlineAt,
549
+ })) {
550
+ if (truncated)
551
+ break;
552
+ if (event.type === "limit") {
553
+ truncated = true;
554
+ truncatedBy = event.truncatedBy;
555
+ break;
556
+ }
557
+ scannedEntries++;
558
+ if (event.entry.kind !== "file")
559
+ continue;
560
+ scannedFiles++;
561
+ try {
562
+ await runFile(event.absolutePath, event.entry.path);
563
+ }
564
+ catch (error) {
565
+ if (error instanceof RepositoryError) {
566
+ if (error.message === "Operation aborted") {
567
+ truncated = true;
568
+ truncatedBy = "abort";
569
+ break;
570
+ }
571
+ if (error.message === "Repository operation exceeded time limit") {
572
+ truncated = true;
573
+ truncatedBy = "time";
574
+ break;
575
+ }
576
+ }
577
+ // Unreadable files are skipped; walk continues.
578
+ }
579
+ }
580
+ }
581
+ else if (startStat.isSymbolicLink()) {
582
+ // Symlink starts are not followed for search content.
583
+ scannedEntries = 1;
584
+ }
585
+ }
586
+ catch (error) {
587
+ if (error instanceof RepositoryError && error.message === "Operation aborted") {
588
+ truncated = true;
589
+ truncatedBy = "abort";
590
+ }
591
+ else if (error instanceof RepositoryError && error.message === "Repository operation exceeded time limit") {
592
+ truncated = true;
593
+ truncatedBy = "time";
594
+ }
595
+ else if (error instanceof RepositoryError) {
596
+ throw error;
597
+ }
598
+ else {
599
+ const message = error instanceof Error ? error.message : String(error);
600
+ throw new RepositoryError(`cannot search path: ${message}`);
601
+ }
602
+ }
603
+ if (!truncated && matches.length >= maxMatches) {
604
+ truncated = true;
605
+ truncatedBy = "matches";
606
+ }
607
+ matches.sort((a, b) => {
608
+ if (a.path !== b.path)
609
+ return a.path < b.path ? -1 : 1;
610
+ if (a.line !== b.line)
611
+ return a.line - b.line;
612
+ return a.column - b.column;
613
+ });
614
+ return {
615
+ matches: matches.slice(0, maxMatches),
616
+ truncated,
617
+ truncatedBy,
618
+ scannedBytes,
619
+ scannedFiles,
620
+ scannedEntries,
621
+ filesSkippedBinary,
622
+ filesSkippedOversize,
623
+ };
624
+ }
625
+ /** Local filesystem repository operations (default backend). */
626
+ export function createLocalRepositoryOperations(limits) {
627
+ const resolved = resolveRepositoryLimits(limits);
628
+ return {
629
+ list: (request) => listLocal(request, resolved),
630
+ search: (request) => searchLocal(request, resolved),
631
+ };
632
+ }
633
+ //# sourceMappingURL=repository.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `repo_search` tool: bounded native literal/regex repository text search.
3
+ */
4
+ import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
5
+ import { type RepositoryLimitOptions, type RepositoryOperations } from "./repository.js";
6
+ export interface SearchToolOptions {
7
+ executionPolicy?: ExecutionPolicy;
8
+ operations?: RepositoryOperations;
9
+ repository?: RepositoryLimitOptions;
10
+ maxMatches?: number;
11
+ maxContextLines?: number;
12
+ exclude?: readonly string[];
13
+ }
14
+ export declare function createRepoSearchTool(cwd: string, options?: SearchToolOptions): ToolDefinition;