@arnilo/prism-coding-agent 0.2.9 → 0.3.1

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] - 2026-08-29
4
+
5
+ ### Changed
6
+ - Plan 035-039 changed-package cut: additive runtime performance, tooling, and documentation deltas; peer window refresh.
7
+
8
+ ## [0.3.0] - 2026-08-20
9
+
10
+ ### Added
11
+
12
+ - `read.findText` performs bounded literal/case-insensitive paging without extending `ReadOperations`.
13
+ - Edit misses include bounded nearby line context; Unicode-normalized fuzzy matches are reported in confirmation text and `metadata.fuzzy`.
14
+ - `createAcpFilesystemOperations` adapts ACP text-file methods to read/write/edit without touching host disk; image/document paths fail closed.
15
+
3
16
  ## [0.2.6] - unreleased
4
17
 
5
18
  ### Added
@@ -0,0 +1,22 @@
1
+ import type { EditOperations } from "./edit.js";
2
+ import type { ReadOperations } from "./read.js";
3
+ import type { WriteOperations } from "./write.js";
4
+ /** Duck-typed subset of the ACP client filesystem adapter. */
5
+ export interface TextFileClient {
6
+ readTextFile(input: {
7
+ path: string;
8
+ line?: number;
9
+ limit?: number;
10
+ }): Promise<{
11
+ text: string;
12
+ }>;
13
+ writeTextFile(input: {
14
+ path: string;
15
+ content: string;
16
+ }): Promise<void>;
17
+ }
18
+ export declare function createAcpFilesystemOperations(client: TextFileClient): {
19
+ read: ReadOperations;
20
+ write: WriteOperations;
21
+ edit: EditOperations;
22
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * ACP text-file client adapter for the coding read/write/edit operations.
3
+ *
4
+ * ACP exposes text reads and writes, not stat, mkdir, binary reads, or image sniffing. This adapter
5
+ * keeps those limits explicit: all reads go through the client, mkdir is inert, and binary/image
6
+ * handling is unavailable rather than falling back to the host filesystem.
7
+ */
8
+ import { Buffer } from "node:buffer";
9
+ function checkAbort(signal) {
10
+ if (signal?.aborted)
11
+ throw new Error("Operation aborted");
12
+ }
13
+ async function readTextFile(client, path, input, signal) {
14
+ checkAbort(signal);
15
+ const request = input === undefined ? { path } : { path, line: input.line, limit: input.limit };
16
+ const response = await client.readTextFile(request);
17
+ checkAbort(signal);
18
+ if (!response || typeof response.text !== "string") {
19
+ throw new Error("TextFileClient.readTextFile must return { text: string }");
20
+ }
21
+ return response.text;
22
+ }
23
+ function splitTextLines(text) {
24
+ const lines = text.split("\n");
25
+ if (lines.length > 1 && lines[lines.length - 1] === "")
26
+ lines.pop();
27
+ return lines;
28
+ }
29
+ function readPage(text, options) {
30
+ const requestedLines = options.limit ?? options.maxLines;
31
+ const lines = splitTextLines(text);
32
+ const totalBytes = Buffer.byteLength(text, "utf8");
33
+ if (totalBytes > options.maxScanBytes) {
34
+ throw new Error(`Text read exceeded ${options.maxScanBytes} byte scan limit`);
35
+ }
36
+ const output = [];
37
+ let outputBytes = 0;
38
+ let truncatedBy = null;
39
+ let firstLineExceedsLimit = false;
40
+ for (const line of lines) {
41
+ if (output.length >= requestedLines) {
42
+ truncatedBy = "lines";
43
+ break;
44
+ }
45
+ const lineBytes = Buffer.byteLength(line, "utf8");
46
+ if (output.length === 0 && lineBytes > options.maxBytes) {
47
+ firstLineExceedsLimit = true;
48
+ truncatedBy = "bytes";
49
+ break;
50
+ }
51
+ const withSeparator = output.length === 0 ? lineBytes : lineBytes + 1;
52
+ if (outputBytes + withSeparator > options.maxBytes) {
53
+ truncatedBy = "bytes";
54
+ break;
55
+ }
56
+ output.push(line);
57
+ outputBytes += withSeparator;
58
+ }
59
+ const clientReturnedPage = lines.length >= requestedLines && requestedLines > 0;
60
+ const hasMore = !firstLineExceedsLimit && (output.length < lines.length || clientReturnedPage);
61
+ const nextOffset = hasMore && output.length > 0 ? options.offset + output.length : undefined;
62
+ const totalLines = clientReturnedPage ? undefined : lines.length;
63
+ return {
64
+ content: firstLineExceedsLimit ? "" : output.join("\n"),
65
+ startLine: options.offset,
66
+ outputLines: firstLineExceedsLimit ? 0 : output.length,
67
+ hasMore,
68
+ nextOffset,
69
+ truncatedBy,
70
+ firstLineExceedsLimit,
71
+ scannedBytes: totalBytes,
72
+ totalLines,
73
+ totalBytes,
74
+ };
75
+ }
76
+ export function createAcpFilesystemOperations(client) {
77
+ const readFile = async (path, options) => {
78
+ const text = await readTextFile(client, path, undefined, options.signal);
79
+ const buffer = Buffer.from(text, "utf8");
80
+ if (buffer.byteLength > options.maxBytes) {
81
+ throw new Error(`File is ${buffer.byteLength} bytes, exceeds ${options.maxBytes} byte limit`);
82
+ }
83
+ return buffer;
84
+ };
85
+ const writeFile = async (path, content, options) => {
86
+ checkAbort(options?.signal);
87
+ const bytes = Buffer.byteLength(content, "utf8");
88
+ if (options?.maxBytes !== undefined && bytes > options.maxBytes) {
89
+ throw new Error(`Write input is ${bytes} bytes, exceeds ${options.maxBytes} byte limit`);
90
+ }
91
+ await client.writeTextFile({ path, content });
92
+ };
93
+ const access = (path, options) => readTextFile(client, path, { line: 1, limit: 1 }, options?.signal).then(() => undefined);
94
+ const statFile = async (path, options) => ({
95
+ size: Buffer.byteLength(await readTextFile(client, path, undefined, options?.signal), "utf8"),
96
+ });
97
+ return {
98
+ read: {
99
+ readFile,
100
+ readText: async (path, options) => {
101
+ const text = await readTextFile(client, path, { line: options.offset, limit: options.limit ?? options.maxLines }, options.signal);
102
+ return readPage(text, options);
103
+ },
104
+ access,
105
+ statFile,
106
+ detectImageMimeType: async () => null,
107
+ },
108
+ write: {
109
+ writeFile,
110
+ mkdir: async (_path, options) => {
111
+ checkAbort(options?.signal);
112
+ },
113
+ },
114
+ edit: {
115
+ readFile,
116
+ writeFile,
117
+ access,
118
+ statFile,
119
+ },
120
+ };
121
+ }
122
+ //# sourceMappingURL=acp-operations.js.map
@@ -16,7 +16,35 @@ export const CODING_STATE_KEY = "coding";
16
16
  const SHA256_HEX = /^[a-f0-9]{64}$/;
17
17
  const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
18
18
  const BRANCH = /^[^\s]{1,255}$/;
19
- const TODO_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/;
19
+ // Linear todo-line scanner replaces `/^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/` (CodeQL js/polynomial-redos, alert 4)
20
+ function parseTodoLine(line) {
21
+ let i = 0;
22
+ while (i < line.length && /\s/.test(line[i]))
23
+ i += 1;
24
+ if (line[i] !== "-" && line[i] !== "*")
25
+ return undefined;
26
+ i += 1;
27
+ while (i < line.length && /\s/.test(line[i]))
28
+ i += 1;
29
+ if (line[i] !== "[")
30
+ return undefined;
31
+ i += 1;
32
+ const doneChar = line[i];
33
+ if (doneChar !== "x" && doneChar !== "X" && doneChar !== " ")
34
+ return undefined;
35
+ i += 1;
36
+ if (line[i] !== "]")
37
+ return undefined;
38
+ i += 1;
39
+ while (i < line.length && /\s/.test(line[i]))
40
+ i += 1;
41
+ let end = line.length;
42
+ while (end > i && /\s/.test(line[end - 1]))
43
+ end -= 1;
44
+ if (end <= i)
45
+ return undefined;
46
+ return { done: doneChar.toLowerCase() === "x", raw: line.slice(i, end) };
47
+ }
20
48
  const FORBIDDEN_METADATA_KEYS = new Set([
21
49
  "credentials",
22
50
  "credential",
@@ -123,17 +151,32 @@ export function parseCodingPlanTodos(markdown, limits) {
123
151
  assertByteLimit("plan", markdown, resolved.maxPlanBytes);
124
152
  const todos = [];
125
153
  for (const line of markdown.split(/\r?\n/)) {
126
- const match = TODO_LINE.exec(line);
154
+ const match = parseTodoLine(line);
127
155
  if (!match)
128
156
  continue;
129
- const done = match[1].toLowerCase() === "x";
130
- const raw = match[2].trim();
157
+ const done = match.done;
158
+ const raw = match.raw;
131
159
  assertTodoText(raw, resolved.maxTodoTextBytes);
132
- const idMatch = /^\[([A-Za-z0-9._-]{1,64})\]\s+(.+)$/.exec(raw);
133
- const id = idMatch?.[1] ?? `todo-${todos.length + 1}`;
134
- const text = idMatch?.[2] ?? raw;
160
+ // Linear id-prefix parse replaces `/^\[([A-Za-z0-9._-]{1,64})\]\s+(.+)$/` (CodeQL js/polynomial-redos, alert 5)
161
+ let id;
162
+ let text = raw;
163
+ if (raw.startsWith("[")) {
164
+ const close = raw.indexOf("]", 1);
165
+ if (close !== -1) {
166
+ const candidate = raw.slice(1, close);
167
+ const after = raw.slice(close + 1);
168
+ if (candidate.length >= 1 &&
169
+ candidate.length <= 64 &&
170
+ /^[A-Za-z0-9._-]+$/u.test(candidate) &&
171
+ /^\s/u.test(after) &&
172
+ after.trim().length > 0) {
173
+ id = candidate;
174
+ text = after.trim();
175
+ }
176
+ }
177
+ }
135
178
  assertTodoText(text, resolved.maxTodoTextBytes);
136
- todos.push({ id, text, done });
179
+ todos.push({ id: id ?? `todo-${todos.length + 1}`, text, done });
137
180
  if (todos.length > resolved.maxTodos) {
138
181
  throw new CodingCheckpointError(`Plan exceeds ${resolved.maxTodos} todo limit`);
139
182
  }
@@ -58,6 +58,7 @@ export declare function stripBom(content: string): {
58
58
  export interface AppliedEditsResult {
59
59
  baseContent: string;
60
60
  newContent: string;
61
+ usedFuzzyMatch: boolean;
61
62
  }
62
63
  /**
63
64
  * Apply one or more exact-text replacements to LF-normalized content.
package/dist/edit-diff.js CHANGED
@@ -179,11 +179,37 @@ function countOccurrences(content, oldText) {
179
179
  const fuzzyOldText = normalizeForFuzzyMatch(oldText);
180
180
  return fuzzyContent.split(fuzzyOldText).length - 1;
181
181
  }
182
- function getNotFoundError(path, editIndex, totalEdits) {
183
- if (totalEdits === 1) {
184
- return new Error(`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`);
182
+ function getNotFoundError(path, content, oldText, editIndex, totalEdits) {
183
+ const base = totalEdits === 1
184
+ ? `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`
185
+ : `Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`;
186
+ // Cheap first-line substring scan: collect up to 3 unique nearby lines so the model can correct
187
+ // its oldText. No extra file read — `content` is the already-loaded target. Needle is clipped to
188
+ // 16 chars (min 4) to avoid noise from tiny/short oldText.
189
+ const firstLine = oldText
190
+ .split("\n")
191
+ .map((l) => l.trim())
192
+ .find((l) => l.length > 0);
193
+ if (!firstLine)
194
+ return new Error(base);
195
+ const needle = firstLine.slice(0, 16);
196
+ if (needle.length < 4)
197
+ return new Error(base);
198
+ const lines = content.split("\n");
199
+ const seen = new Set();
200
+ const nearby = [];
201
+ for (let i = 0; i < lines.length && nearby.length < 3; i++) {
202
+ if (lines[i].includes(needle)) {
203
+ const snippet = lines[i].slice(0, 120);
204
+ if (seen.has(snippet))
205
+ continue;
206
+ seen.add(snippet);
207
+ nearby.push(` L${i + 1}: ${snippet}`);
208
+ }
185
209
  }
186
- return new Error(`Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`);
210
+ if (nearby.length === 0)
211
+ return new Error(base);
212
+ return new Error(`${base}\nNearby:\n${nearby.join("\n")}`);
187
213
  }
188
214
  function getDuplicateError(path, editIndex, totalEdits, occurrences) {
189
215
  if (totalEdits === 1) {
@@ -230,7 +256,7 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
230
256
  const edit = normalizedEdits[i];
231
257
  const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
232
258
  if (!matchResult.found) {
233
- throw getNotFoundError(path, i, normalizedEdits.length);
259
+ throw getNotFoundError(path, normalizedContent, edit.oldText, i, normalizedEdits.length);
234
260
  }
235
261
  const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
236
262
  if (occurrences > 1) {
@@ -258,7 +284,7 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
258
284
  if (baseContent === newContent) {
259
285
  throw getNoChangeError(path, normalizedEdits.length);
260
286
  }
261
- return { baseContent, newContent };
287
+ return { baseContent, newContent, usedFuzzyMatch };
262
288
  }
263
289
  /** Generate a standard unified patch. */
264
290
  export function generateUnifiedPatch(path, oldContent, newContent, contextLines = 4) {
package/dist/edit.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface EditToolDetails {
37
37
  patch: string;
38
38
  /** Line number of the first change in the new file (for editor navigation). */
39
39
  firstChangedLine?: number;
40
+ /** `true` when the replacement applied via fuzzy (not exact) matching. Absent for exact. */
41
+ fuzzy?: boolean;
40
42
  }
41
43
  /**
42
44
  * Pluggable operations for the edit tool. Override to delegate file editing to remote systems
package/dist/edit.js CHANGED
@@ -98,7 +98,7 @@ export function createEditTool(cwd, options) {
98
98
  name: "edit",
99
99
  kind: "edit",
100
100
  effect: CODING_LOCAL_EFFECT,
101
- description: "Edit a single file using exact-then-fuzzy text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. Exact match is tried first; if it fails, fuzzy match (unicode normalize + whitespace collapse) may still succeed silently — prefer exact oldText to avoid wrong-region edits. Duplicate/ambiguous matches fail closed and leave the file unchanged. If two changes affect the same block or nearby lines, merge them into one edit. Do not include large unchanged regions just to connect distant changes. When the host enabled requireReadBeforeWrite, read the path first or pass force=true.",
101
+ description: "Edit a single file using exact-then-fuzzy text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. Exact match is tried first; if it fails, fuzzy match (unicode normalize + whitespace collapse) may still succeed and is reported as `(fuzzy match)` — prefer exact `oldText` copied from a fresh `read` to avoid wrong-region edits. Duplicate/ambiguous matches fail closed and leave the file unchanged. If two changes affect the same block or nearby lines, merge them into one edit. Do not include large unchanged regions just to connect distant changes. When the host enabled requireReadBeforeWrite, read the path first or pass force=true.",
102
102
  parameters: {
103
103
  type: "object",
104
104
  properties: {
@@ -193,8 +193,9 @@ export function createEditTool(cwd, options) {
193
193
  // Apply exact-then-fuzzy matching. Throws on no-match / duplicate / overlap / empty / no-change.
194
194
  let baseContent;
195
195
  let newContent;
196
+ let usedFuzzyMatch = false;
196
197
  try {
197
- ({ baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, prepared.path));
198
+ ({ baseContent, newContent, usedFuzzyMatch } = applyEditsToNormalizedContent(normalizedContent, edits, prepared.path));
198
199
  }
199
200
  catch (error) {
200
201
  const message = error instanceof Error ? error.message : String(error);
@@ -207,15 +208,17 @@ export function createEditTool(cwd, options) {
207
208
  options?.onEvent?.({ type: "file_changed", path: allowedPath, op: "edit", toolCallId });
208
209
  const diffResult = generateDiffString(baseContent, newContent);
209
210
  const patch = generateUnifiedPatch(prepared.path, baseContent, newContent);
211
+ const confirmation = `Successfully replaced ${edits.length} block(s) in ${prepared.path}${usedFuzzyMatch ? " (fuzzy match)" : ""}.`;
210
212
  return {
211
213
  toolCallId,
212
214
  name: "edit",
213
- content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${prepared.path}.` }],
215
+ content: [{ type: "text", text: confirmation }],
214
216
  metadata: {
215
217
  path: allowedPath,
216
218
  diff: diffResult.diff,
217
219
  patch,
218
220
  firstChangedLine: diffResult.firstChangedLine,
221
+ ...(usedFuzzyMatch ? { fuzzy: true } : {}),
219
222
  },
220
223
  };
221
224
  });
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export type { TextFileClient } from "./acp-operations.js";
2
+ export { createAcpFilesystemOperations } from "./acp-operations.js";
1
3
  export { createDirectoryArtifactWriter, createTempArtifactWriter, sha256Hex } from "./artifacts.js";
2
4
  export type { AskUserDecisionAnswer, AskUserDecisionHandler, AskUserDecisionOption, AskUserDecisionRequest, AskUserDecisionSelectionMode, AskUserDecisionSuspendData, AskUserDecisionToolOptions, ResolvedAskUserDecisionAnswer, ResolvedAskUserDecisionLimits, SuspendAskUserDecisionOptions, } from "./ask-user-decision.js";
3
5
  export { ASK_USER_DECISION_RATIONALE_COUNT, ASK_USER_DECISION_SUSPEND_REASON, ASK_USER_DECISION_TOOL_NAME, askUserDecisionResumeSchema, createAskUserDecisionResumeValidator, createAskUserDecisionTool, DEFAULT_MAX_ASK_USER_DECISION_BULLET_BYTES, DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES, DEFAULT_MAX_ASK_USER_DECISION_LABEL_BYTES, DEFAULT_MAX_ASK_USER_DECISION_OPTIONS, DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES, HARD_MAX_ASK_USER_DECISION_BULLET_BYTES, HARD_MAX_ASK_USER_DECISION_CUSTOM_BYTES, HARD_MAX_ASK_USER_DECISION_LABEL_BYTES, HARD_MAX_ASK_USER_DECISION_OPTIONS, HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES, parseAskUserDecisionArgs, resolveAskUserDecisionAnswer, resolveAskUserDecisionLimits, suspendAskUserDecision, toAskUserDecisionSuspendData, validateAskUserDecisionAgentResume, validateAskUserDecisionResume, } from "./ask-user-decision.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // First-party coding tools for the Prism agent harness. Factory functions return Prism
4
4
  // `ToolDefinition`s that hosts register into a `ToolRegistry` (e.g.
5
5
  // `createToolRegistry(createCodingTools(cwd))`). No tools are auto-registered — import what you need.
6
- // --- per-tool factories & types ---
6
+ export { createAcpFilesystemOperations } from "./acp-operations.js";
7
7
  export { createDirectoryArtifactWriter, createTempArtifactWriter, sha256Hex } from "./artifacts.js";
8
8
  export { ASK_USER_DECISION_RATIONALE_COUNT, ASK_USER_DECISION_SUSPEND_REASON, ASK_USER_DECISION_TOOL_NAME, askUserDecisionResumeSchema, createAskUserDecisionResumeValidator, createAskUserDecisionTool, DEFAULT_MAX_ASK_USER_DECISION_BULLET_BYTES, DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES, DEFAULT_MAX_ASK_USER_DECISION_LABEL_BYTES, DEFAULT_MAX_ASK_USER_DECISION_OPTIONS, DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES, HARD_MAX_ASK_USER_DECISION_BULLET_BYTES, HARD_MAX_ASK_USER_DECISION_CUSTOM_BYTES, HARD_MAX_ASK_USER_DECISION_LABEL_BYTES, HARD_MAX_ASK_USER_DECISION_OPTIONS, HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES, parseAskUserDecisionArgs, resolveAskUserDecisionAnswer, resolveAskUserDecisionLimits, suspendAskUserDecision, toAskUserDecisionSuspendData, validateAskUserDecisionAgentResume, validateAskUserDecisionResume, } from "./ask-user-decision.js";
9
9
  export { createCodingCheckTool } from "./checks.js";
package/dist/read.js CHANGED
@@ -25,7 +25,7 @@ import { readFileBounded } from "./bounded-file.js";
25
25
  import { CODING_OBSERVATION_EFFECT } from "./effects.js";
26
26
  import { enforceExecutionPolicy } from "./execution-policy.js";
27
27
  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";
28
- import { resolveReadPathAsync } from "./path-utils.js";
28
+ import { resolveReadPathAsync, resolveToCwd } from "./path-utils.js";
29
29
  import { formatSize } from "./truncate.js";
30
30
  // --- magic-byte image MIME detection (faithful port of pi utils/mime.js, pure JS, no deps) ---
31
31
  const IMAGE_TYPE_SNIFF_BYTES = 4100;
@@ -296,6 +296,41 @@ function errorResult(toolCallId, message) {
296
296
  error: { message },
297
297
  };
298
298
  }
299
+ /**
300
+ * Scan paginated `readText` output for the first line containing `findText` (literal substring,
301
+ * no regex). Starts at `startLine` and advances via `nextOffset` until a hit, EOF, or the scan
302
+ * cap. Returns the 1-indexed hit line, or `undefined` when the needle is not found before EOF.
303
+ * Throws on scan-limit / abort / no-progress so the caller's error path surfaces a clean result.
304
+ * Pages with `limit` unset so each backend page is `maxLines`-sized (fewer round trips).
305
+ */
306
+ async function findMatchLine(ops, absolutePath, findText, findMode, startLine, maxLines, maxBytes, maxScanBytes, signal) {
307
+ const caseInsensitive = findMode === "case-insensitive";
308
+ const needle = caseInsensitive ? findText.toLowerCase() : findText;
309
+ let offset = startLine;
310
+ let guard = 0; // ponytail: safety cap for backends that report hasMore without advancing
311
+ while (true) {
312
+ if (signal?.aborted)
313
+ throw new Error("Operation aborted");
314
+ const page = await ops.readText(absolutePath, { offset, maxLines, maxBytes, maxScanBytes, signal });
315
+ if (!page.firstLineExceedsLimit && page.content.length > 0) {
316
+ const lines = page.content.split("\n");
317
+ for (let index = 0; index < lines.length; index++) {
318
+ const line = caseInsensitive ? lines[index].toLowerCase() : lines[index];
319
+ if (line.includes(needle))
320
+ return page.startLine + index;
321
+ }
322
+ }
323
+ if (!page.hasMore || page.nextOffset === undefined)
324
+ return undefined;
325
+ if (page.scannedBytes >= maxScanBytes)
326
+ throw new Error(`findText did not match within ${formatSize(maxScanBytes)} scan limit`);
327
+ if (page.nextOffset <= offset)
328
+ throw new Error(`findText could not advance past line ${offset}`);
329
+ offset = page.nextOffset;
330
+ if (++guard > 100_000)
331
+ throw new Error("findText exceeded iteration guard");
332
+ }
333
+ }
299
334
  export function createReadTool(cwd, options) {
300
335
  if (options && "autoResizeImages" in options) {
301
336
  throw new TypeError('Read tool: "autoResizeImages" was removed in 0.1.5; use "transformImage" instead.');
@@ -316,6 +351,11 @@ export function createReadTool(cwd, options) {
316
351
  path: { type: "string", description: "Path to the file to read (relative or absolute)" },
317
352
  offset: { type: "number", description: "Line number to start reading from (1-indexed)" },
318
353
  limit: { type: "number", description: "Maximum number of lines to read" },
354
+ findText: {
355
+ type: "string",
356
+ description: "Literal substring to search for (no regex). Returns the page starting at the first matching line at/after offset. No match is an error result.",
357
+ },
358
+ findMode: { type: "string", enum: ["exact", "case-insensitive"], description: "Match mode for findText (default 'exact')." },
319
359
  },
320
360
  required: ["path"],
321
361
  additionalProperties: false,
@@ -328,13 +368,23 @@ export function createReadTool(cwd, options) {
328
368
  const path = typeof args.path === "string" ? args.path : "";
329
369
  const offset = typeof args.offset === "number" ? args.offset : undefined;
330
370
  const limit = typeof args.limit === "number" ? args.limit : undefined;
371
+ const findText = typeof args.findText === "string" ? args.findText : undefined;
372
+ const findModeRaw = typeof args.findMode === "string" ? args.findMode : "exact";
331
373
  if (path.length === 0) {
332
374
  return errorResult(toolCallId, "path is required and must be a non-empty string.");
333
375
  }
376
+ if (findText !== undefined) {
377
+ if (findText.length === 0) {
378
+ return errorResult(toolCallId, "findText must be a non-empty string.");
379
+ }
380
+ if (findModeRaw !== "exact" && findModeRaw !== "case-insensitive") {
381
+ return errorResult(toolCallId, "findMode must be 'exact' or 'case-insensitive'.");
382
+ }
383
+ }
334
384
  try {
335
- const startLine = validateCodingLimit("offset", offset ?? 1, Number.MAX_SAFE_INTEGER);
385
+ let startLine = validateCodingLimit("offset", offset ?? 1, Number.MAX_SAFE_INTEGER);
336
386
  const requestedLines = limit === undefined ? undefined : validateCodingLimit("limit", limit, HARD_MAX_LINES);
337
- const absolutePath = await resolveReadPathAsync(path, cwd);
387
+ const absolutePath = options?.operations ? resolveToCwd(path, cwd) : await resolveReadPathAsync(path, cwd);
338
388
  const policyCheck = await enforceExecutionPolicy(options?.executionPolicy, {
339
389
  kind: "read",
340
390
  operation: "read",
@@ -393,6 +443,13 @@ export function createReadTool(cwd, options) {
393
443
  };
394
444
  }
395
445
  }
446
+ if (findText !== undefined) {
447
+ const hit = await findMatchLine(ops, allowedPath, findText, findModeRaw, startLine, maxLines, maxBytes, maxScanBytes, context.signal);
448
+ if (hit === undefined) {
449
+ return errorResult(toolCallId, `Could not find ${JSON.stringify(findText)} in ${path}.`);
450
+ }
451
+ startLine = hit;
452
+ }
396
453
  const page = await ops.readText(allowedPath, {
397
454
  offset: startLine,
398
455
  limit: requestedLines,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism-coding-agent",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
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.2.9",
32
- "@arnilo/prism-workflows": "0.2.9"
31
+ "@arnilo/prism": "^0.3.1",
32
+ "@arnilo/prism-workflows": "^0.3.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@arnilo/prism": "file:../..",