@arnilo/prism-coding-agent 0.0.96 → 0.1.0

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +139 -3
  2. package/README.md +48 -19
  3. package/dist/ask-user-decision.d.ts +160 -0
  4. package/dist/ask-user-decision.js +495 -0
  5. package/dist/atomic-write.d.ts +3 -0
  6. package/dist/atomic-write.js +24 -0
  7. package/dist/checks.js +5 -0
  8. package/dist/coding-checkpoint.js +6 -15
  9. package/dist/delete.d.ts +29 -0
  10. package/dist/delete.js +119 -0
  11. package/dist/edit-diff.js +1 -4
  12. package/dist/edit.d.ts +5 -1
  13. package/dist/edit.js +20 -9
  14. package/dist/effects.d.ts +33 -0
  15. package/dist/effects.js +89 -0
  16. package/dist/execution-policy.d.ts +8 -3
  17. package/dist/execution-policy.js +5 -2
  18. package/dist/file-mutation-queue.js +1 -2
  19. package/dist/forge/github.d.ts +2 -0
  20. package/dist/forge/github.js +554 -0
  21. package/dist/forge/index.d.ts +3 -0
  22. package/dist/forge/index.js +3 -0
  23. package/dist/forge/types.d.ts +150 -0
  24. package/dist/forge/types.js +19 -0
  25. package/dist/git-aware-repository.d.ts +25 -0
  26. package/dist/git-aware-repository.js +268 -0
  27. package/dist/git-exec.js +1 -1
  28. package/dist/git-tools.d.ts +4 -1
  29. package/dist/git-tools.js +15 -7
  30. package/dist/git.d.ts +3 -3
  31. package/dist/git.js +14 -14
  32. package/dist/glob-match.d.ts +6 -0
  33. package/dist/glob-match.js +81 -0
  34. package/dist/glob.d.ts +14 -0
  35. package/dist/glob.js +147 -0
  36. package/dist/goal-verify.d.ts +66 -0
  37. package/dist/goal-verify.js +280 -0
  38. package/dist/index.d.ts +63 -30
  39. package/dist/index.js +40 -16
  40. package/dist/language/client.d.ts +44 -0
  41. package/dist/language/client.js +290 -0
  42. package/dist/language/framing.d.ts +23 -0
  43. package/dist/language/framing.js +112 -0
  44. package/dist/language/index.d.ts +4 -0
  45. package/dist/language/index.js +4 -0
  46. package/dist/language/intelligence.d.ts +10 -0
  47. package/dist/language/intelligence.js +526 -0
  48. package/dist/language/types.d.ts +106 -0
  49. package/dist/language/types.js +21 -0
  50. package/dist/lifecycle.d.ts +75 -0
  51. package/dist/lifecycle.js +102 -0
  52. package/dist/limits.d.ts +41 -0
  53. package/dist/limits.js +41 -0
  54. package/dist/list.js +6 -10
  55. package/dist/move.d.ts +24 -0
  56. package/dist/move.js +150 -0
  57. package/dist/mutation-path.d.ts +7 -0
  58. package/dist/mutation-path.js +51 -0
  59. package/dist/output-accumulator.d.ts +8 -0
  60. package/dist/output-accumulator.js +45 -1
  61. package/dist/path-utils.js +1 -1
  62. package/dist/process/index.d.ts +3 -0
  63. package/dist/process/index.js +3 -0
  64. package/dist/process/sessions.d.ts +2 -0
  65. package/dist/process/sessions.js +592 -0
  66. package/dist/process/types.d.ts +146 -0
  67. package/dist/process/types.js +19 -0
  68. package/dist/read-path-set.d.ts +14 -0
  69. package/dist/read-path-set.js +26 -0
  70. package/dist/read.d.ts +3 -0
  71. package/dist/read.js +11 -17
  72. package/dist/repository.d.ts +54 -3
  73. package/dist/repository.js +144 -38
  74. package/dist/search.d.ts +1 -1
  75. package/dist/search.js +91 -27
  76. package/dist/shell.d.ts +3 -0
  77. package/dist/shell.js +23 -8
  78. package/dist/truncate.js +1 -1
  79. package/dist/write.d.ts +5 -1
  80. package/dist/write.js +19 -6
  81. package/package.json +6 -4
@@ -0,0 +1,146 @@
1
+ import type { AgentIdentity, ExecutionPolicy, OwnershipScope } from "@arnilo/prism";
2
+ /** Duck-typed long-running sandbox handle (mirrors coding-security SandboxProcessHandle). */
3
+ export interface ProcessSandboxHandle {
4
+ write(data: Uint8Array): Promise<void>;
5
+ signal(name: string): Promise<void>;
6
+ kill(): Promise<void>;
7
+ release(): Promise<void>;
8
+ wait(options?: {
9
+ timeoutMs?: number;
10
+ signal?: AbortSignal;
11
+ }): Promise<{
12
+ exitCode: number | null;
13
+ }>;
14
+ }
15
+ export interface ProcessSandboxStartRequest {
16
+ readonly file: string;
17
+ readonly args: readonly string[];
18
+ readonly cwd?: string;
19
+ readonly env?: Readonly<Record<string, string>>;
20
+ readonly onData?: (data: Buffer) => void;
21
+ readonly signal?: AbortSignal;
22
+ readonly timeout?: number;
23
+ }
24
+ /**
25
+ * Optional sandbox backend for ProcessSessions.
26
+ * Presence of `startProcess` = long-running capable; absence fails closed.
27
+ */
28
+ export interface ProcessSandboxBackend {
29
+ startProcess?(request: ProcessSandboxStartRequest): Promise<ProcessSandboxHandle>;
30
+ status?(): Promise<{
31
+ readonly state: string;
32
+ }>;
33
+ }
34
+ export type ProcessSessionState = "starting" | "running" | "exited" | "killed" | "released" | "expired" | "unknown";
35
+ export type ProcessSessionErrorCode = "ERR_PRISM_PROCESS_POLICY" | "ERR_PRISM_PROCESS_OWNERSHIP" | "ERR_PRISM_PROCESS_STATE" | "ERR_PRISM_PROCESS_LIMIT" | "ERR_PRISM_PROCESS_PTY_UNSUPPORTED" | "ERR_PRISM_PROCESS_UNSUPPORTED";
36
+ export declare class ProcessSessionError extends Error {
37
+ readonly code: ProcessSessionErrorCode;
38
+ constructor(code: ProcessSessionErrorCode, message: string);
39
+ }
40
+ export interface ProcessSessionLimits {
41
+ readonly maxSessions?: number;
42
+ readonly maxInputBytes?: number;
43
+ readonly maxLifetimeMs?: number;
44
+ readonly maxOutputChunkBytes?: number;
45
+ readonly maxTotalOutputBytes?: number;
46
+ }
47
+ export interface ResolvedProcessSessionLimits {
48
+ readonly maxSessions: number;
49
+ readonly maxInputBytes: number;
50
+ readonly maxLifetimeMs: number;
51
+ readonly maxOutputChunkBytes: number;
52
+ readonly maxTotalOutputBytes: number;
53
+ }
54
+ export declare function resolveProcessSessionLimits(limits?: ProcessSessionLimits): ResolvedProcessSessionLimits;
55
+ export interface ProcessStartRequest {
56
+ readonly command: string;
57
+ readonly args?: readonly string[];
58
+ readonly cwd?: string;
59
+ readonly env?: Readonly<Record<string, string>>;
60
+ /** Default false. true on unsupported platform → ERR_PRISM_PROCESS_PTY_UNSUPPORTED. */
61
+ readonly pty?: boolean;
62
+ readonly lifetimeMs?: number;
63
+ /** Owner string for this session; defaults to registry ownership key. */
64
+ readonly owner?: string;
65
+ /** When true, run cancel releases instead of killing (default kill). */
66
+ readonly releaseOnCancel?: boolean;
67
+ }
68
+ export interface ProcessOutputChunk {
69
+ readonly data: string;
70
+ /** Next byte cursor for paging. */
71
+ readonly cursor: number;
72
+ readonly eof: boolean;
73
+ }
74
+ export interface ProcessExitResult {
75
+ readonly exitCode: number | null;
76
+ readonly state: ProcessSessionState;
77
+ }
78
+ export interface ProcessSessionMetadata {
79
+ readonly id: string;
80
+ readonly commandFingerprint: string;
81
+ readonly owner: string;
82
+ readonly workspace: string;
83
+ readonly policyDecision: string;
84
+ readonly startedAt: string;
85
+ readonly exitedAt?: string;
86
+ readonly state: ProcessSessionState;
87
+ readonly releaseOnCancel: boolean;
88
+ }
89
+ export interface ProcessSession {
90
+ readonly id: string;
91
+ readonly state: ProcessSessionState;
92
+ readonly owner: string;
93
+ metadata(): ProcessSessionMetadata;
94
+ output(request?: {
95
+ cursor?: number;
96
+ maxBytes?: number;
97
+ }): Promise<ProcessOutputChunk>;
98
+ input(data: string | Uint8Array): Promise<void>;
99
+ wait(options?: {
100
+ timeoutMs?: number;
101
+ signal?: AbortSignal;
102
+ }): Promise<ProcessExitResult>;
103
+ signal(name: "SIGTERM" | "SIGINT" | "SIGHUP"): Promise<void>;
104
+ kill(): Promise<void>;
105
+ release(): Promise<void>;
106
+ }
107
+ export type CodingProcessEvent = {
108
+ readonly type: "process_started" | "process_exited" | "process_killed" | "process_released" | "process_expired" | "process_unknown";
109
+ readonly sessionId: string;
110
+ readonly processId: string;
111
+ readonly owner: string;
112
+ readonly exitCode?: number | null;
113
+ readonly at: string;
114
+ };
115
+ export interface ProcessSessions {
116
+ start(request: ProcessStartRequest): Promise<ProcessSession>;
117
+ get(sessionId: string, owner?: string): ProcessSession;
118
+ /** Kill (default) or release all sessions for `owner`. */
119
+ cancelOwned(owner: string, options?: {
120
+ release?: boolean;
121
+ }): Promise<void>;
122
+ /** Mark a running session unknown (backend loss); never fabricates exitCode. */
123
+ markUnknown(sessionId: string, owner?: string): Promise<void>;
124
+ /**
125
+ * Host resume / sandbox-loss reconciliation: mark every running/starting session unknown.
126
+ * Never fabricates exitCode. O(owned sessions).
127
+ */
128
+ reconcile(): Promise<{
129
+ readonly markedUnknown: number;
130
+ }>;
131
+ dispose(): Promise<void>;
132
+ }
133
+ export interface CreateProcessSessionsOptions {
134
+ readonly cwd: string;
135
+ readonly policy?: ExecutionPolicy;
136
+ readonly limits?: ProcessSessionLimits;
137
+ readonly onEvent?: (event: CodingProcessEvent) => void;
138
+ readonly ownership?: OwnershipScope;
139
+ /** Host-verified identity; projects onto default owner when ownership omitted. */
140
+ readonly identity?: AgentIdentity;
141
+ /**
142
+ * When set: use `startProcess` if present; otherwise start() fails closed
143
+ * (`ERR_PRISM_PROCESS_UNSUPPORTED`). Native spawn only when sandbox omitted.
144
+ */
145
+ readonly sandbox?: ProcessSandboxBackend;
146
+ }
@@ -0,0 +1,19 @@
1
+ import { DEFAULT_MAX_PROCESS_INPUT_BYTES, DEFAULT_MAX_PROCESS_LIFETIME_MS, DEFAULT_MAX_PROCESS_OUTPUT_CHUNK_BYTES, DEFAULT_MAX_PROCESS_SESSIONS, DEFAULT_MAX_PROCESS_TOTAL_OUTPUT_BYTES, HARD_MAX_PROCESS_INPUT_BYTES, HARD_MAX_PROCESS_LIFETIME_MS, HARD_MAX_PROCESS_OUTPUT_CHUNK_BYTES, HARD_MAX_PROCESS_SESSIONS, HARD_MAX_PROCESS_TOTAL_OUTPUT_BYTES, validateCodingLimit, } from "../limits.js";
2
+ export class ProcessSessionError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = "ProcessSessionError";
7
+ this.code = code;
8
+ }
9
+ }
10
+ export function resolveProcessSessionLimits(limits) {
11
+ return {
12
+ maxSessions: validateCodingLimit("maxSessions", limits?.maxSessions ?? DEFAULT_MAX_PROCESS_SESSIONS, HARD_MAX_PROCESS_SESSIONS),
13
+ maxInputBytes: validateCodingLimit("maxInputBytes", limits?.maxInputBytes ?? DEFAULT_MAX_PROCESS_INPUT_BYTES, HARD_MAX_PROCESS_INPUT_BYTES),
14
+ maxLifetimeMs: validateCodingLimit("maxLifetimeMs", limits?.maxLifetimeMs ?? DEFAULT_MAX_PROCESS_LIFETIME_MS, HARD_MAX_PROCESS_LIFETIME_MS),
15
+ maxOutputChunkBytes: validateCodingLimit("maxOutputChunkBytes", limits?.maxOutputChunkBytes ?? DEFAULT_MAX_PROCESS_OUTPUT_CHUNK_BYTES, HARD_MAX_PROCESS_OUTPUT_CHUNK_BYTES),
16
+ maxTotalOutputBytes: validateCodingLimit("maxTotalOutputBytes", limits?.maxTotalOutputBytes ?? DEFAULT_MAX_PROCESS_TOTAL_OUTPUT_BYTES, HARD_MAX_PROCESS_TOTAL_OUTPUT_BYTES),
17
+ };
18
+ }
19
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,14 @@
1
+ /** Session-scoped paths successfully read via the read tool (host-owned, not checkpointed). */
2
+ export interface ReadPathSet {
3
+ has(path: string): boolean;
4
+ add(path: string): void;
5
+ list(): readonly string[];
6
+ clear(): void;
7
+ }
8
+ export declare function createReadPathSet(): ReadPathSet;
9
+ export interface ReadBeforeWriteOptions {
10
+ requireReadBeforeWrite?: boolean;
11
+ readPathSet?: ReadPathSet;
12
+ }
13
+ /** Returns refusal message, or null when the mutation may proceed. */
14
+ export declare function refuseReadBeforeWrite(operation: "write" | "edit", displayPath: string, absolutePath: string, options: ReadBeforeWriteOptions | undefined, force: boolean): string | null;
@@ -0,0 +1,26 @@
1
+ export function createReadPathSet() {
2
+ const paths = new Set();
3
+ return {
4
+ has(path) {
5
+ return paths.has(path);
6
+ },
7
+ add(path) {
8
+ paths.add(path);
9
+ },
10
+ list() {
11
+ return [...paths];
12
+ },
13
+ clear() {
14
+ paths.clear();
15
+ },
16
+ };
17
+ }
18
+ /** Returns refusal message, or null when the mutation may proceed. */
19
+ export function refuseReadBeforeWrite(operation, displayPath, absolutePath, options, force) {
20
+ if (!options?.requireReadBeforeWrite || force)
21
+ return null;
22
+ if (options.readPathSet?.has(absolutePath))
23
+ return null;
24
+ return `Refusing ${operation} to ${displayPath}: not read in this session. Read first or pass force=true.`;
25
+ }
26
+ //# sourceMappingURL=read-path-set.js.map
package/dist/read.d.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { Buffer } from "node:buffer";
23
23
  import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
24
+ import type { ReadPathSet } from "./read-path-set.js";
24
25
  import { type TruncationResult } from "./truncate.js";
25
26
  /** Detect a supported image MIME type from a buffer's leading bytes. Returns null for non-images. */
26
27
  export declare function detectSupportedImageMimeType(buffer: Buffer): string | null;
@@ -100,6 +101,8 @@ export interface ReadToolOptions {
100
101
  maxLines?: number;
101
102
  /** Max bytes kept from the head (default 50KB). */
102
103
  maxBytes?: number;
104
+ /** When set, successful reads record the resolved absolute path for read-before-write guards. */
105
+ readPathSet?: ReadPathSet;
103
106
  }
104
107
  export declare function createReadTool(cwd: string, options?: ReadToolOptions): ToolDefinition;
105
108
  /** Re-exported for hosts building custom read tools or analyzing truncation metadata. */
package/dist/read.js CHANGED
@@ -21,11 +21,12 @@
21
21
  */
22
22
  import { Buffer } from "node:buffer";
23
23
  import { constants } from "node:fs";
24
- import { access as fsAccess, open, stat as fsStat } from "node:fs/promises";
24
+ import { access as fsAccess, stat as fsStat, open } from "node:fs/promises";
25
25
  import { readFileBounded } from "./bounded-file.js";
26
+ import { CODING_OBSERVATION_EFFECT } from "./effects.js";
26
27
  import { enforceExecutionPolicy } from "./execution-policy.js";
27
- import { resolveReadPathAsync } from "./path-utils.js";
28
28
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_IMAGE_BYTES, DEFAULT_MAX_LINES, DEFAULT_MAX_TEXT_SCAN_BYTES, HARD_MAX_BYTES, HARD_MAX_IMAGE_BYTES, HARD_MAX_LINES, HARD_MAX_TEXT_SCAN_BYTES, validateCodingLimit, } from "./limits.js";
29
+ import { resolveReadPathAsync } from "./path-utils.js";
29
30
  import { formatSize } from "./truncate.js";
30
31
  // --- magic-byte image MIME detection (faithful port of pi utils/mime.js, pure JS, no deps) ---
31
32
  const IMAGE_TYPE_SNIFF_BYTES = 4100;
@@ -64,9 +65,7 @@ export async function detectSupportedImageMimeTypeFromFile(filePath) {
64
65
  }
65
66
  function isPng(buffer) {
66
67
  // First chunk after the 8-byte signature must be a 13-byte IHDR.
67
- return (buffer.length >= 16 &&
68
- readUint32BE(buffer, PNG_SIGNATURE.length) === 13 &&
69
- startsWithAscii(buffer, 12, "IHDR"));
68
+ return buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR");
70
69
  }
71
70
  function isAnimatedPng(buffer) {
72
71
  // Walk PNG chunks; an acTL chunk before the first IDAT marks an animated (APNG) image.
@@ -118,16 +117,10 @@ function readUint16LE(buffer, offset) {
118
117
  return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
119
118
  }
120
119
  function readUint32BE(buffer, offset) {
121
- return ((buffer[offset] ?? 0) * 0x1000000 +
122
- ((buffer[offset + 1] ?? 0) << 16) +
123
- ((buffer[offset + 2] ?? 0) << 8) +
124
- (buffer[offset + 3] ?? 0));
120
+ return ((buffer[offset] ?? 0) * 0x1000000 + ((buffer[offset + 1] ?? 0) << 16) + ((buffer[offset + 2] ?? 0) << 8) + (buffer[offset + 3] ?? 0));
125
121
  }
126
122
  function readUint32LE(buffer, offset) {
127
- return ((buffer[offset] ?? 0) +
128
- ((buffer[offset + 1] ?? 0) << 8) +
129
- ((buffer[offset + 2] ?? 0) << 16) +
130
- (buffer[offset + 3] ?? 0) * 0x1000000);
123
+ return ((buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8) + ((buffer[offset + 2] ?? 0) << 16) + (buffer[offset + 3] ?? 0) * 0x1000000);
131
124
  }
132
125
  function startsWith(buffer, bytes) {
133
126
  if (buffer.length < bytes.length)
@@ -315,7 +308,8 @@ export function createReadTool(cwd, options) {
315
308
  const maxScanBytes = validateCodingLimit("maxScanBytes", options?.maxScanBytes ?? DEFAULT_MAX_TEXT_SCAN_BYTES, HARD_MAX_TEXT_SCAN_BYTES);
316
309
  return {
317
310
  name: "read",
318
- description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp); images are returned as image content. For text files, output is truncated to ${maxLines} lines or ${maxBytes / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
311
+ effect: CODING_OBSERVATION_EFFECT,
312
+ description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp); images are returned as image content. For text files, output is truncated to ${maxLines} lines or ${maxBytes / 1024}KB (whichever is hit first). When truncated, continue with the suggested offset until complete. Prefer repo_search to find text across many files.`,
319
313
  parameters: {
320
314
  type: "object",
321
315
  properties: {
@@ -339,9 +333,7 @@ export function createReadTool(cwd, options) {
339
333
  }
340
334
  try {
341
335
  const startLine = validateCodingLimit("offset", offset ?? 1, Number.MAX_SAFE_INTEGER);
342
- const requestedLines = limit === undefined
343
- ? undefined
344
- : validateCodingLimit("limit", limit, HARD_MAX_LINES);
336
+ const requestedLines = limit === undefined ? undefined : validateCodingLimit("limit", limit, HARD_MAX_LINES);
345
337
  const absolutePath = await resolveReadPathAsync(path, cwd);
346
338
  const policyCheck = await enforceExecutionPolicy(options?.executionPolicy, {
347
339
  kind: "read",
@@ -362,6 +354,7 @@ export function createReadTool(cwd, options) {
362
354
  autoResizeImages: options?.autoResizeImages,
363
355
  signal: context.signal,
364
356
  });
357
+ options?.readPathSet?.add(allowedPath);
365
358
  return {
366
359
  toolCallId,
367
360
  name: "read",
@@ -416,6 +409,7 @@ export function createReadTool(cwd, options) {
416
409
  outputText += `\n\n[Showing lines ${page.startLine}-${endLine}${page.truncatedBy === "bytes" ? ` (${formatSize(maxBytes)} limit)` : ""}. Use offset=${page.nextOffset} to continue.]`;
417
410
  }
418
411
  }
412
+ options?.readPathSet?.add(allowedPath);
419
413
  return {
420
414
  toolCallId,
421
415
  name: "read",
@@ -1,3 +1,10 @@
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
+ */
1
8
  export type RepoEntryKind = "file" | "directory" | "symlink" | "other";
2
9
  export interface RepoListEntry {
3
10
  readonly path: string;
@@ -13,6 +20,16 @@ export interface RepositoryListResult {
13
20
  readonly nextOffset?: number;
14
21
  readonly offset: number;
15
22
  }
23
+ export interface RepositoryGlobResult {
24
+ readonly paths: readonly string[];
25
+ readonly truncated: boolean;
26
+ readonly truncatedBy: RepositoryListResult["truncatedBy"];
27
+ readonly scannedEntries: number;
28
+ readonly scannedFiles: number;
29
+ readonly nextOffset?: number;
30
+ readonly offset: number;
31
+ }
32
+ export type RepoSearchOutputMode = "content" | "files_with_matches" | "count";
16
33
  export interface RepositorySearchMatch {
17
34
  readonly path: string;
18
35
  readonly line: number;
@@ -78,7 +95,8 @@ export interface RepositorySearchRequest {
78
95
  readonly root: string;
79
96
  readonly query: string;
80
97
  readonly path?: string;
81
- readonly mode?: "literal" | "regex";
98
+ readonly mode?: "literal";
99
+ readonly outputMode?: RepoSearchOutputMode;
82
100
  readonly caseSensitive?: boolean;
83
101
  readonly includeHidden?: boolean;
84
102
  readonly exclude?: readonly string[];
@@ -87,9 +105,22 @@ export interface RepositorySearchRequest {
87
105
  readonly signal?: AbortSignal;
88
106
  readonly deadlineMs?: number;
89
107
  }
108
+ export interface RepositoryGlobRequest {
109
+ readonly root: string;
110
+ readonly pattern: string;
111
+ readonly path?: string;
112
+ readonly includeHidden?: boolean;
113
+ readonly exclude?: readonly string[];
114
+ readonly maxDepth?: number;
115
+ readonly maxResults?: number;
116
+ readonly offset?: number;
117
+ readonly signal?: AbortSignal;
118
+ readonly deadlineMs?: number;
119
+ }
90
120
  export interface RepositoryOperations {
91
121
  list(request: RepositoryListRequest): Promise<RepositoryListResult>;
92
122
  search(request: RepositorySearchRequest): Promise<RepositorySearchResult>;
123
+ glob(request: RepositoryGlobRequest): Promise<RepositoryGlobResult>;
93
124
  }
94
125
  export declare const DEFAULT_REPO_EXCLUDE: readonly string[];
95
126
  export declare class RepositoryError extends Error {
@@ -109,11 +140,31 @@ export declare function resolveRepoPath(root: string, inputPath: string | undefi
109
140
  rootReal: string;
110
141
  }>;
111
142
  export declare function isBinaryBuffer(buffer: Buffer): boolean;
112
- export declare function compileSearchPattern(query: string, mode: "literal" | "regex", caseSensitive: boolean, maxPatternBytes: number): {
143
+ export declare function compileSearchPattern(query: string, caseSensitive: boolean, maxPatternBytes: number): {
113
144
  testLine: (line: string) => {
114
145
  column: number;
115
146
  } | null;
116
147
  patternBytes: number;
117
148
  };
149
+ export interface RepositoryWalkLimits {
150
+ maxDepth: number;
151
+ maxEntries: number;
152
+ maxFiles: number;
153
+ exclude: ReadonlySet<string>;
154
+ includeHidden: boolean;
155
+ signal?: AbortSignal;
156
+ deadlineAt?: number;
157
+ }
158
+ export type RepositoryWalkEvent = {
159
+ type: "entry";
160
+ entry: RepoListEntry;
161
+ absolutePath: string;
162
+ depth: number;
163
+ } | {
164
+ type: "limit";
165
+ truncatedBy: "entries" | "files" | "depth";
166
+ };
167
+ /** Injectable enumerator for list/search/glob. Default is the native opendir walker. */
168
+ export type RepositoryWalk = (rootReal: string, startAbsolute: string, limits: RepositoryWalkLimits) => AsyncGenerator<RepositoryWalkEvent>;
118
169
  /** Local filesystem repository operations (default backend). */
119
- export declare function createLocalRepositoryOperations(limits?: RepositoryLimitOptions): RepositoryOperations;
170
+ export declare function createLocalRepositoryOperations(limits?: RepositoryLimitOptions, walk?: RepositoryWalk): RepositoryOperations;
@@ -5,9 +5,10 @@
5
5
  * rejects devices/FIFOs/sockets for descent, and charges finite depth/entry/file
6
6
  * limits before retaining the next result. No glob/index/watcher dependency.
7
7
  */
8
- import { open, opendir, lstat, realpath } from "node:fs/promises";
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 {
@@ -119,46 +120,28 @@ export function isBinaryBuffer(buffer) {
119
120
  }
120
121
  return false;
121
122
  }
122
- export function compileSearchPattern(query, mode, caseSensitive, maxPatternBytes) {
123
+ export function compileSearchPattern(query, caseSensitive, maxPatternBytes) {
123
124
  const patternBytes = Buffer.byteLength(query, "utf8");
124
125
  if (patternBytes < 1)
125
126
  throw new RepositoryError("query must be non-empty");
126
127
  if (patternBytes > maxPatternBytes) {
127
128
  throw new RepositoryError(`query exceeds ${maxPatternBytes} byte pattern limit`);
128
129
  }
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();
130
+ if (caseSensitive) {
140
131
  return {
141
132
  patternBytes,
142
133
  testLine: (line) => {
143
- const column = line.toLowerCase().indexOf(needle);
134
+ const column = line.indexOf(query);
144
135
  return column >= 0 ? { column: column + 1 } : null;
145
136
  },
146
137
  };
147
138
  }
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
- }
139
+ const needle = query.toLowerCase();
156
140
  return {
157
141
  patternBytes,
158
142
  testLine: (line) => {
159
- regex.lastIndex = 0;
160
- const match = regex.exec(line);
161
- return match && match.index !== undefined ? { column: match.index + 1 } : null;
143
+ const column = line.toLowerCase().indexOf(needle);
144
+ return column >= 0 ? { column: column + 1 } : null;
162
145
  },
163
146
  };
164
147
  }
@@ -256,7 +239,7 @@ async function* walkRepository(rootReal, startAbsolute, limits) {
256
239
  }
257
240
  }
258
241
  }
259
- async function listLocal(request, defaults) {
242
+ async function listLocal(request, defaults, walk) {
260
243
  const resolved = await resolveRepoPath(request.root, request.path);
261
244
  const maxResults = validateCodingLimit("maxResults", request.maxResults ?? defaults.maxResults, HARD_MAX_REPO_RESULTS);
262
245
  const offset = validateCodingLimitAllowZero("offset", request.offset ?? 0, HARD_MAX_REPO_ENTRIES);
@@ -278,9 +261,7 @@ async function listLocal(request, defaults) {
278
261
  kind = "symlink";
279
262
  else if (startStat.isFile())
280
263
  kind = "file";
281
- const entry = kind === "file"
282
- ? { path: resolved.relative, kind, size: startStat.size }
283
- : { path: resolved.relative, kind };
264
+ const entry = kind === "file" ? { path: resolved.relative, kind, size: startStat.size } : { path: resolved.relative, kind };
284
265
  scannedEntries = 1;
285
266
  scannedFiles = kind === "file" ? 1 : 0;
286
267
  if (offset === 0 && maxResults > 0)
@@ -305,7 +286,7 @@ async function listLocal(request, defaults) {
305
286
  throw new RepositoryError(`cannot open path: ${message}`);
306
287
  }
307
288
  try {
308
- for await (const event of walkRepository(resolved.rootReal, resolved.absolute, {
289
+ for await (const event of walk(resolved.rootReal, resolved.absolute, {
309
290
  maxDepth,
310
291
  maxEntries: defaults.maxEntries,
311
292
  maxFiles: defaults.maxFiles,
@@ -477,13 +458,13 @@ async function searchFileLines(absolutePath, relativePath, testLine, options) {
477
458
  await handle.close();
478
459
  }
479
460
  }
480
- async function searchLocal(request, defaults) {
461
+ async function searchLocal(request, defaults, walk) {
481
462
  const mode = request.mode ?? "literal";
482
- if (mode !== "literal" && mode !== "regex") {
483
- throw new RepositoryError(`unsupported search mode: ${String(mode)}`);
463
+ if (mode !== "literal") {
464
+ throw new RepositoryError(`unsupported search mode: ${String(mode)} (literal only)`);
484
465
  }
485
466
  const caseSensitive = request.caseSensitive === true;
486
- const { testLine } = compileSearchPattern(request.query, mode, caseSensitive, defaults.maxPatternBytes);
467
+ const { testLine } = compileSearchPattern(request.query, caseSensitive, defaults.maxPatternBytes);
487
468
  const resolved = await resolveRepoPath(request.root, request.path);
488
469
  const maxMatches = validateCodingLimit("maxMatches", request.maxMatches ?? defaults.maxMatches, HARD_MAX_SEARCH_MATCHES);
489
470
  const context = validateCodingLimitAllowZero("context", request.context ?? defaults.maxContextLines, HARD_MAX_SEARCH_CONTEXT_LINES);
@@ -538,7 +519,7 @@ async function searchLocal(request, defaults) {
538
519
  await runFile(resolved.absolute, resolved.relative);
539
520
  }
540
521
  else if (startStat.isDirectory()) {
541
- for await (const event of walkRepository(resolved.rootReal, resolved.absolute, {
522
+ for await (const event of walk(resolved.rootReal, resolved.absolute, {
542
523
  maxDepth: defaults.maxDepth,
543
524
  maxEntries: defaults.maxEntries,
544
525
  maxFiles: defaults.maxFiles,
@@ -622,12 +603,137 @@ async function searchLocal(request, defaults) {
622
603
  filesSkippedOversize,
623
604
  };
624
605
  }
606
+ async function globLocal(request, defaults, walk) {
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 walk(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
+ }
625
730
  /** Local filesystem repository operations (default backend). */
626
- export function createLocalRepositoryOperations(limits) {
731
+ export function createLocalRepositoryOperations(limits, walk = walkRepository) {
627
732
  const resolved = resolveRepositoryLimits(limits);
628
733
  return {
629
- list: (request) => listLocal(request, resolved),
630
- search: (request) => searchLocal(request, resolved),
734
+ list: (request) => listLocal(request, resolved, walk),
735
+ search: (request) => searchLocal(request, resolved, walk),
736
+ glob: (request) => globLocal(request, resolved, walk),
631
737
  };
632
738
  }
633
739
  //# sourceMappingURL=repository.js.map
package/dist/search.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `repo_search` tool: bounded native literal/regex repository text search.
2
+ * `repo_search` tool: bounded native literal repository text search.
3
3
  */
4
4
  import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
5
5
  import { type RepositoryLimitOptions, type RepositoryOperations } from "./repository.js";