pi-readseek 0.4.26 → 0.4.28

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/README.md CHANGED
@@ -24,8 +24,9 @@ npm install --save-dev @jarkkojs/readseek
24
24
  ## Tools
25
25
 
26
26
  - **read** — reads text files with `LINE:HASH` anchors for later `edit` calls;
27
- images are returned as attachments. Supports `symbol`, `map`, and `bundle`
28
- options powered by `@jarkkojs/readseek`.
27
+ images are returned as attachments and may include local OCR, caption, and
28
+ object text. Supports `symbol`, `map`, and `bundle` options powered by
29
+ `@jarkkojs/readseek`.
29
30
  - **edit** — changes existing text files using fresh anchors from `read`,
30
31
  `grep`, `search`, or `write`. Variants: `set_line`, `replace_lines`,
31
32
  `insert_after`, `replace_symbol`, `replace`. Set `new_text` to `""` to
@@ -40,6 +41,38 @@ npm install --save-dev @jarkkojs/readseek
40
41
  - **write** — creates or overwrites whole files and returns anchors for
41
42
  immediate follow-up edits.
42
43
 
44
+ ## Settings
45
+
46
+ `pi-readseek` reads optional JSON settings from:
47
+
48
+ | Location | Scope |
49
+ | --- | --- |
50
+ | `~/.pi/agent/readseek/settings.json` | Global |
51
+ | `.pi/readseek/settings.json` | Project |
52
+
53
+ Project settings override global settings. Image OCR behavior is controlled by
54
+ `read.ocrMode`:
55
+
56
+ ```json
57
+ {
58
+ "read": {
59
+ "ocrMode": "on"
60
+ }
61
+ }
62
+ ```
63
+
64
+ Modes:
65
+
66
+ - `"on"` — always run local image OCR/caption/object analysis. This is the
67
+ default.
68
+ - `"off"` — return only the image attachment. Use this as a workaround if the
69
+ local readseek image-analysis path crashes.
70
+ - `"auto"` — run local image analysis only when the active model does not
71
+ support native image input.
72
+
73
+ `READSEEK_READ_OCR_MODE=on|off|auto` overrides the JSON setting for one
74
+ process.
75
+
43
76
  ## Related
44
77
 
45
78
  - [readseek.vim](https://github.com/jarkkojs/readseek.vim) — Vim 9 plugin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
4
4
  "description": "Pi extension for readseek-backed hash-anchored read/edit/grep, structural code maps, structural search, and file exploration",
5
5
  "type": "module",
6
6
  "exports": {
package/prompts/read.md CHANGED
@@ -1,4 +1,4 @@
1
- Read files through readseek. Text output uses `LINE:HASH|content` anchors that can be copied directly into `edit`; supported images return attachments instead of anchors. Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}.
1
+ Read files through readseek. Text output uses `LINE:HASH|content` anchors that can be copied directly into `edit`; supported images return attachments and may append OCR/caption/object text depending on `read.ocrMode`. Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}.
2
2
 
3
3
  ## Choose the right read
4
4
 
package/src/edit.ts CHANGED
@@ -12,7 +12,7 @@ import { HashlineMismatchError, applyHashlineEdits, computeLineHash, ensureHashI
12
12
  import { resolveToCwd } from "./path-utils.js";
13
13
  import { looksLikeBinary } from "./binary-detect.js";
14
14
  import { throwIfAborted } from "./runtime.js";
15
- import { classifyFsError } from "./fs-error.js";
15
+ import { formatFsError } from "./fs-error.js";
16
16
  import { buildEditOutput } from "./edit-output.js";
17
17
  import { classifyEdit, isDifftAvailable, runDifftastic } from "./edit-classify.js";
18
18
  import type { SemanticSummary } from "./readseek-value.js";
@@ -104,19 +104,9 @@ function buildEditError(
104
104
  };
105
105
  }
106
106
 
107
- function mapEditFileError(err: any, filePath: string, displayPath: string, phase: "read" | "write"): ReturnType<typeof buildEditError> {
108
- const code = err?.code;
109
- if (code === "ENOENT" && phase === "write") {
110
- return buildEditError(filePath, "file-not-found", `Failed to write file: ${displayPath}`);
111
- }
112
- const fsError = classifyFsError(err, displayPath);
113
- if (fsError) {
114
- return buildEditError(filePath, fsError.code, fsError.message, fsError.hint);
115
- }
116
- const prefix = phase === "write" ? "Failed to write file" : "File not readable";
117
- const message = `${prefix}: ${displayPath}${err?.message ? ` — ${err.message}` : ""}`;
118
- return buildEditError(filePath, "fs-error", message, undefined,
119
- { fsCode: code, fsMessage: err?.message });
107
+ function mapEditFileError(err: any, filePath: string, _displayPath: string, _phase: "read" | "write"): ReturnType<typeof buildEditError> {
108
+ const { code, message } = formatFsError(err, "edit-error");
109
+ return buildEditError(filePath, code, message);
120
110
  }
121
111
 
122
112
  export interface EditToolOptions {
package/src/fs-error.ts CHANGED
@@ -1,30 +1,33 @@
1
- export type FsErrorCode = "path-is-directory" | "permission-denied" | "file-not-found";
1
+ const SYSLOG_PATH_SUFFIX = /,\s+[a-z]+(\s+'.*')?$/;
2
2
 
3
- export interface FsErrorInfo {
4
- code: FsErrorCode;
5
- message: string;
6
- hint?: string;
3
+ /**
4
+ * Derive the canonical OS strerror from a Node {@link err.message} by
5
+ * stripping the trailing syscall and optional path suffix so the result
6
+ * is portable and never duplicates information already carried in the
7
+ * error-envelope `path` field.
8
+ */
9
+ function strerror(err: { message?: unknown }): string {
10
+ const raw = String(err?.message ?? String(err));
11
+ return raw.replace(SYSLOG_PATH_SUFFIX, "");
7
12
  }
8
13
 
9
14
  /**
10
- * Translate a Node `fs` errno into the shared readseek error taxonomy with a
11
- * canonical message. Returns null for errno values without a dedicated code so
12
- * callers fall back to their own `fs-error` handling.
15
+ * Build a readseek error from a Node `fs` errno exception.
16
+ *
17
+ * Every errno maps to its own canonical code automatically — no
18
+ * hand-curated switch, no `"fs-error"` catch-all.
19
+ *
20
+ * @param err The caught exception (carries `err.code` and `err.message`).
21
+ * @param domain Tool-level prefix for the message: `"read-error"`,
22
+ * `"edit-error"`, `"write-error"`, `"stat-error"`.
13
23
  */
14
- export function classifyFsError(err: { code?: unknown }, path: string): FsErrorInfo | null {
15
- switch (err?.code) {
16
- case "EISDIR":
17
- return {
18
- code: "path-is-directory",
19
- message: `Path is a directory: ${path}`,
20
- hint: `Use ls(${JSON.stringify(path)}) to inspect directories.`,
21
- };
22
- case "EACCES":
23
- case "EPERM":
24
- return { code: "permission-denied", message: `Permission denied: ${path}` };
25
- case "ENOENT":
26
- return { code: "file-not-found", message: `File not found: ${path}` };
27
- default:
28
- return null;
29
- }
24
+ export function formatFsError(
25
+ err: { code?: unknown; message?: string },
26
+ domain: string,
27
+ ): { code: string; message: string } {
28
+ const errno = typeof err.code === "string" ? err.code : "EIO";
29
+ return {
30
+ code: errno,
31
+ message: `${domain}: ${errno}: ${strerror(err)}`,
32
+ };
30
33
  }
package/src/hover.ts CHANGED
@@ -7,6 +7,7 @@ import { Text } from "@earendil-works/pi-tui";
7
7
  import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
8
8
  import { buildToolErrorResult } from "./readseek-value.js";
9
9
  import { resolveToCwd } from "./path-utils.js";
10
+ import { formatFsError } from "./fs-error.js";
10
11
  import { classifyReadSeekFailure, readseekIdentify } from "./readseek-client.js";
11
12
  import { filePathParam, registerReadSeekTool } from "./register-tool.js";
12
13
 
@@ -48,8 +49,9 @@ export async function executeHover(opts: ExecuteHoverOptions): Promise<any> {
48
49
  let content: string;
49
50
  try {
50
51
  content = await readFile(filePath, "utf-8");
51
- } catch {
52
- return buildToolErrorResult("hover", "file-not-found", `hover could not read ${p.path}`);
52
+ } catch (err: any) {
53
+ const { code, message } = formatFsError(err, "hover-error");
54
+ return buildToolErrorResult("hover", code, message, { path: p.path });
53
55
  }
54
56
 
55
57
  try {
package/src/read.ts CHANGED
@@ -17,7 +17,7 @@ import { buildReadSeekWarning, buildToolErrorResult, renderReadSeekLines, type R
17
17
  import { looksLikeBinary } from "./binary-detect.js";
18
18
  import { resolveToCwd } from "./path-utils.js";
19
19
  import { throwIfAborted } from "./runtime.js";
20
- import { classifyFsError } from "./fs-error.js";
20
+ import { formatFsError } from "./fs-error.js";
21
21
  import { getOrGenerateMap } from "./file-map.js";
22
22
  import { formatFileMapWithBudget } from "./readseek/formatter.js";
23
23
  import { findSymbol, type SymbolMatch } from "./readseek/symbol-lookup.js";
@@ -28,7 +28,8 @@ import { buildLocalBundle } from "./read-local-bundle.js";
28
28
  import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
29
29
  import { readseekRead, readseekDetect, type ReadSeekDetection } from "./readseek-client.js";
30
30
  import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
31
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
31
+ import { resolveReadSeekOcrMode } from "./readseek-settings.js";
32
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidthCached } from "./tui-render-utils.js";
32
33
  import type { FileAnchoredCallback } from "./tool-types.js";
33
34
  import { filePathParam, mapParam, optionalIntOrString, registerReadSeekTool } from "./register-tool.js";
34
35
 
@@ -57,6 +58,8 @@ export interface ExecuteReadOptions {
57
58
  onUpdate: any;
58
59
  cwd: string;
59
60
  onSuccessfulRead?: FileAnchoredCallback;
61
+ /** Whether the active model accepts image input natively. Used when OCR mode is auto. */
62
+ modelSupportsImages?: boolean;
60
63
  }
61
64
 
62
65
  function hasReadAnchors(result: AgentToolResult<any>): boolean {
@@ -68,6 +71,20 @@ function hasReadAnchors(result: AgentToolResult<any>): boolean {
68
71
  return Array.isArray(lines) && lines.length > 0;
69
72
  }
70
73
 
74
+ function formatImageAnalysis(detection: ReadSeekDetection): string | undefined {
75
+ if (detection.kind !== "image") return undefined;
76
+ const sections: string[] = [];
77
+ const transcript = detection.transcribe?.text?.trim();
78
+ if (transcript) sections.push(`Transcribed text from image:\n${transcript}`);
79
+ const caption = detection.caption?.trim();
80
+ if (caption) sections.push(`Image caption:\n${caption}`);
81
+ if (detection.objects?.length) {
82
+ const lines = detection.objects.map((object) => `- ${object.label} [${object.bbox.join(", ")}]`);
83
+ sections.push(`Detected objects:\n${lines.join("\n")}`);
84
+ }
85
+ return sections.length > 0 ? sections.join("\n\n") : undefined;
86
+ }
87
+
71
88
  export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolResult<any>> {
72
89
  const { toolCallId, params, signal, onUpdate, cwd, onSuccessfulRead } = opts;
73
90
  await ensureHashInit();
@@ -144,37 +161,53 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
144
161
  try {
145
162
  rawBuffer = await fsReadFile(absolutePath);
146
163
  } catch (err: any) {
147
- const code = err?.code;
148
- const fsError = classifyFsError(err, rawPath);
149
- if (fsError) {
150
- return buildToolErrorResult("read", fsError.code, fsError.message, {
151
- path: rawParams.path,
152
- ...(fsError.hint ? { hint: fsError.hint } : {}),
153
- });
154
- }
155
- const message = `File not readable: ${rawPath}${err?.message ? ` — ${err.message}` : ""}`;
156
- return buildToolErrorResult("read", "fs-error", message, { path: rawParams.path, details: { fsCode: code, fsMessage: err?.message } });
164
+ const { code, message } = formatFsError(err, "read-error");
165
+ return buildToolErrorResult("read", code, message, {
166
+ path: rawParams.path,
167
+ });
157
168
  }
158
169
 
159
170
  const hasBinaryContent = looksLikeBinary(rawBuffer);
160
171
  if (hasBinaryContent) {
161
- // Images are always binary; classify and transcribe in a single readseek detect call.
162
172
  let detection: ReadSeekDetection | undefined;
163
173
  try {
164
- detection = await readseekDetect(absolutePath, { transcribe: true, signal });
174
+ detection = await readseekDetect(absolutePath, { signal });
165
175
  } catch {
166
- // detect unavailable — fall through to binary-as-text handling below
167
176
  }
168
177
  if (detection?.kind === "image") {
169
178
  const builtinRead = createReadTool(cwd);
170
179
  const builtinResult = await builtinRead.execute(toolCallId, p, signal, onUpdate);
171
- const transcript = detection.transcribe?.text?.trim();
172
- if (transcript) {
180
+ const ocrMode = resolveReadSeekOcrMode();
181
+ const shouldTranscribe = ocrMode === "on" || (ocrMode === "auto" && !opts.modelSupportsImages);
182
+ if (!shouldTranscribe) return succeed(builtinResult);
183
+
184
+ let ocrFailed = false;
185
+ try {
186
+ const ocrDetection = await readseekDetect(absolutePath, {
187
+ transcribe: true,
188
+ caption: true,
189
+ objects: true,
190
+ signal,
191
+ });
192
+ const imageAnalysis = formatImageAnalysis(ocrDetection);
193
+ if (imageAnalysis) {
194
+ return succeed({
195
+ ...builtinResult,
196
+ content: [
197
+ ...(builtinResult.content ?? []),
198
+ { type: "text" as const, text: imageAnalysis },
199
+ ],
200
+ });
201
+ }
202
+ } catch {
203
+ ocrFailed = true;
204
+ }
205
+ if (ocrFailed) {
173
206
  return succeed({
174
207
  ...builtinResult,
175
208
  content: [
176
209
  ...(builtinResult.content ?? []),
177
- { type: "text" as const, text: `Transcribed text from image:\n${transcript}` },
210
+ { type: "text" as const, text: "[Warning: local image analysis (OCR) unavailable — showing image attachment only]" },
178
211
  ],
179
212
  });
180
213
  }
@@ -465,6 +498,7 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
465
498
  onUpdate,
466
499
  cwd: ctx.cwd,
467
500
  onSuccessfulRead: options.onSuccessfulRead,
501
+ modelSupportsImages: ctx.model?.input.includes("image") ?? false,
468
502
  });
469
503
  },
470
504
  renderCall(args: any, theme: any, ...rest: any[]) {
@@ -509,7 +543,7 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
509
543
  for (const badge of info.badges) summaryParts.push(badge);
510
544
  const summary = summaryParts.join(" • ");
511
545
  let text = summaryLine(summary, { hidden: !!textContent && !expanded });
512
- if (expanded && textContent) text += "\n" + wrapReadHashlinesForWidth(textContent, width);
546
+ if (expanded && textContent) text += "\n" + wrapReadHashlinesForWidthCached(content, textContent, width);
513
547
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
514
548
  },
515
549
  });
@@ -118,6 +118,11 @@ export interface ReadSeekTranscript {
118
118
  regions: ReadSeekTranscriptRegion[];
119
119
  }
120
120
 
121
+ export interface ReadSeekDetectedObject {
122
+ label: string;
123
+ bbox: [number, number, number, number];
124
+ }
125
+
121
126
  export type ReadSeekDetection =
122
127
  | {
123
128
  kind: "source";
@@ -139,6 +144,8 @@ export type ReadSeekDetection =
139
144
  height: number;
140
145
  animated: boolean;
141
146
  transcribe?: ReadSeekTranscript;
147
+ caption?: string;
148
+ objects?: ReadSeekDetectedObject[];
142
149
  }
143
150
  | {
144
151
  kind: "text";
@@ -155,6 +162,13 @@ interface ReadSeekSearchOptions {
155
162
  signal?: AbortSignal;
156
163
  }
157
164
 
165
+ interface ReadSeekDetectOptions {
166
+ transcribe?: boolean;
167
+ caption?: boolean;
168
+ objects?: boolean;
169
+ signal?: AbortSignal;
170
+ }
171
+
158
172
  function normalizeLanguage(language: string): string {
159
173
  return language === "java" ? "Java" : language;
160
174
  }
@@ -272,23 +286,36 @@ function directoryExists(dirPath: string): boolean {
272
286
  }
273
287
  }
274
288
 
289
+ const ownRepositoryByDir = new Map<string, boolean>();
290
+
275
291
  function isOwnReadSeekRepository(cwd = process.cwd()): boolean {
276
- let dir = path.resolve(cwd);
292
+ const start = path.resolve(cwd);
293
+ const cached = ownRepositoryByDir.get(start);
294
+ if (cached !== undefined) return cached;
295
+
296
+ let dir = start;
297
+ let result = false;
277
298
  while (true) {
278
299
  const packageJsonPath = path.join(dir, "package.json");
279
300
  if (existsSync(packageJsonPath)) {
280
301
  try {
281
302
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
282
- if (typeof packageJson.name === "string" && READSEEK_REPO_PACKAGE_NAMES.has(packageJson.name)) return true;
303
+ if (typeof packageJson.name === "string" && READSEEK_REPO_PACKAGE_NAMES.has(packageJson.name)) {
304
+ result = true;
305
+ break;
306
+ }
283
307
  } catch {
284
308
  // Ignore unreadable or invalid package manifests while walking up.
285
309
  }
286
310
  }
287
311
 
288
312
  const parent = path.dirname(dir);
289
- if (parent === dir) return false;
313
+ if (parent === dir) break;
290
314
  dir = parent;
291
315
  }
316
+
317
+ ownRepositoryByDir.set(start, result);
318
+ return result;
292
319
  }
293
320
 
294
321
  function defaultReadSeekDir(): string | null {
@@ -296,23 +323,43 @@ function defaultReadSeekDir(): string | null {
296
323
  return home ? path.join(home, ".pi", "readseek") : null;
297
324
  }
298
325
 
326
+ const DEFAULT_READSEEK_TIMEOUT_MS = 120_000;
327
+
328
+ function readseekTimeoutMs(): number {
329
+ const raw = process.env.READSEEK_TIMEOUT_MS;
330
+ if (raw !== undefined && raw !== "") {
331
+ const parsed = Number(raw);
332
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
333
+ }
334
+ return DEFAULT_READSEEK_TIMEOUT_MS;
335
+ }
336
+
299
337
  async function spawnReadSeekRaw(args: string[], options: RunReadSeekOptions = {}): Promise<string> {
300
338
  return new Promise<string>((resolve, reject) => {
301
339
  let settled = false;
340
+ let timeout: ReturnType<typeof setTimeout> | undefined;
302
341
  const fail = (error: Error): void => {
303
342
  if (settled) return;
304
343
  settled = true;
344
+ if (timeout !== undefined) clearTimeout(timeout);
305
345
  reject(error);
306
346
  };
307
347
  const succeed = (value: string): void => {
308
348
  if (settled) return;
309
349
  settled = true;
350
+ if (timeout !== undefined) clearTimeout(timeout);
310
351
  resolve(value);
311
352
  };
312
353
 
313
354
  const stdin = options.stdin;
314
355
  const stdio: StdioOptions = [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"];
315
356
  const child = spawn(readseekBinaryPath(), args, { stdio, signal: options.signal });
357
+ const timeoutMs = options.timeoutMs ?? readseekTimeoutMs();
358
+ timeout = setTimeout(() => {
359
+ child.kill("SIGKILL");
360
+ fail(new Error(`readseek timed out after ${timeoutMs} ms`));
361
+ }, timeoutMs);
362
+ timeout.unref?.();
316
363
  const childStdout = child.stdout;
317
364
  const childStderr = child.stderr;
318
365
  const childStdin = child.stdin;
@@ -348,10 +395,11 @@ async function spawnReadSeekRaw(args: string[], options: RunReadSeekOptions = {}
348
395
  });
349
396
  childStdin.end(stdin, "utf-8");
350
397
  }
351
- child.on("close", (code) => {
398
+ child.on("close", (code, signal) => {
352
399
  const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
353
400
  const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
354
401
  if (code === 0) succeed(stdout);
402
+ else if (signal) fail(new Error((stderr || `readseek killed by signal ${signal}`).replace(/^error:\s*/i, "")));
355
403
  else fail(new Error((stderr || `readseek exited with status ${code}`).replace(/^error:\s*/i, "")));
356
404
  });
357
405
  });
@@ -381,6 +429,7 @@ async function readseekInvocationArgs(args: string[]): Promise<string[]> {
381
429
  interface RunReadSeekOptions {
382
430
  signal?: AbortSignal;
383
431
  stdin?: string;
432
+ timeoutMs?: number;
384
433
  }
385
434
 
386
435
  async function runReadSeekRaw(args: string[], options: RunReadSeekOptions = {}): Promise<string> {
@@ -659,6 +708,21 @@ function parseTranscript(value: unknown): ReadSeekTranscript | undefined {
659
708
  };
660
709
  }
661
710
 
711
+ function parseDetectedObjects(value: unknown): ReadSeekDetectedObject[] | undefined {
712
+ if (value === undefined || value === null) return undefined;
713
+ if (!Array.isArray(value)) throw new Error("invalid readseek detect objects");
714
+ return value.map((object) => {
715
+ if (!object || typeof object !== "object") throw new Error("invalid readseek detect object");
716
+ const item = object as Record<string, unknown>;
717
+ const bbox = item.bbox;
718
+ if (!Array.isArray(bbox) || bbox.length !== 4) throw new Error("invalid readseek detect object.bbox");
719
+ return {
720
+ label: requireString(item.label, "object.label"),
721
+ bbox: bbox.map((n, i) => requireNumber(n, `object.bbox[${i}]`)) as ReadSeekDetectedObject["bbox"],
722
+ };
723
+ });
724
+ }
725
+
662
726
  function parseDetectOutput(value: unknown): ReadSeekDetection {
663
727
  if (!value || typeof value !== "object") throw new Error("invalid readseek detect output");
664
728
  const output = value as Record<string, unknown>;
@@ -682,6 +746,8 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
682
746
  height: requireNumber(output.height, "height"),
683
747
  animated: requireBoolean(output.animated, "animated"),
684
748
  transcribe: parseTranscript(output.transcribe),
749
+ caption: optionalString(output.caption, "caption"),
750
+ objects: parseDetectedObjects(output.objects),
685
751
  };
686
752
  }
687
753
  if (output.language !== undefined) {
@@ -701,10 +767,12 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
701
767
 
702
768
  export async function readseekDetect(
703
769
  filePath: string,
704
- options: { transcribe?: boolean; signal?: AbortSignal } = {},
770
+ options: ReadSeekDetectOptions = {},
705
771
  ): Promise<ReadSeekDetection> {
706
772
  const args = ["detect"];
707
773
  if (options.transcribe) args.push("--transcribe");
774
+ if (options.caption) args.push("--caption");
775
+ if (options.objects) args.push("--objects");
708
776
  args.push(filePath);
709
777
  return parseDetectOutput(await runReadSeek(args, { signal: options.signal }));
710
778
  }
@@ -1,10 +1,11 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { readFileSync, statSync, type Stats } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
5
  interface ReadSeekJsonSettings {
6
6
  grep?: { maxLines?: number; maxBytes?: number };
7
7
  edit?: { diffDisplay?: "collapsed" | "expanded" };
8
+ read?: { ocrMode?: "on" | "off" | "auto" };
8
9
  }
9
10
 
10
11
  interface ReadSeekSettingsWarning {
@@ -75,19 +76,55 @@ function validateSettings(raw: unknown, source: string): ReadSeekSettingsResult
75
76
  if (Object.keys(edit).length > 0) settings.edit = edit;
76
77
  }
77
78
 
79
+ if (isRecord(raw.read)) {
80
+ const read: NonNullable<ReadSeekJsonSettings["read"]> = {};
81
+ if ("ocrMode" in raw.read) {
82
+ const value = raw.read.ocrMode;
83
+ if (value === "on" || value === "off" || value === "auto") read.ocrMode = value;
84
+ else warnings.push(invalid(source, "read.ocrMode"));
85
+ }
86
+ if (Object.keys(read).length > 0) settings.read = read;
87
+ }
88
+
78
89
  return { settings, warnings };
79
90
  }
80
91
 
92
+ interface SettingsFileCacheEntry {
93
+ mtimeMs: number;
94
+ size: number;
95
+ result: ReadSeekSettingsResult;
96
+ }
97
+
98
+ const settingsFileCache = new Map<string, SettingsFileCacheEntry>();
99
+
100
+ function statFileOrNull(path: string): Stats | null {
101
+ try {
102
+ return statSync(path);
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
81
108
  function readSettingsFile(path: string): ReadSeekSettingsResult {
82
- if (!existsSync(path)) return { settings: {}, warnings: [] };
109
+ const stats = statFileOrNull(path);
110
+ if (!stats) {
111
+ settingsFileCache.delete(path);
112
+ return { settings: {}, warnings: [] };
113
+ }
83
114
 
115
+ const cached = settingsFileCache.get(path);
116
+ if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) return cached.result;
117
+
118
+ let result: ReadSeekSettingsResult;
84
119
  try {
85
120
  const text = readFileSync(path, "utf8");
86
- return validateSettings(JSON.parse(text) as unknown, path);
121
+ result = validateSettings(JSON.parse(text) as unknown, path);
87
122
  } catch (error) {
88
123
  const message = error instanceof Error ? error.message : String(error);
89
- return { settings: {}, warnings: [{ source: path, message: `Invalid JSON: ${message}` }] };
124
+ result = { settings: {}, warnings: [{ source: path, message: `Invalid JSON: ${message}` }] };
90
125
  }
126
+ settingsFileCache.set(path, { mtimeMs: stats.mtimeMs, size: stats.size, result });
127
+ return result;
91
128
  }
92
129
 
93
130
  function mergeSettings(base: ReadSeekJsonSettings, override: ReadSeekJsonSettings): ReadSeekJsonSettings {
@@ -96,6 +133,8 @@ function mergeSettings(base: ReadSeekJsonSettings, override: ReadSeekJsonSetting
96
133
  if (Object.keys(grep).length > 0) merged.grep = grep;
97
134
  const edit = { ...(base.edit ?? {}), ...(override.edit ?? {}) };
98
135
  if (Object.keys(edit).length > 0) merged.edit = edit;
136
+ const read = { ...(base.read ?? {}), ...(override.read ?? {}) };
137
+ if (Object.keys(read).length > 0) merged.read = read;
99
138
  return merged;
100
139
  }
101
140
 
@@ -118,3 +157,14 @@ export function resolveEditDiffDisplay(env: NodeJS.ProcessEnv = process.env): "c
118
157
  if (json === "expanded" || json === "collapsed") return json;
119
158
  return "collapsed";
120
159
  }
160
+
161
+ export function resolveReadSeekOcrMode(env: NodeJS.ProcessEnv = process.env): "on" | "off" | "auto" {
162
+ const raw = env.READSEEK_READ_OCR_MODE;
163
+ if (typeof raw === "string") {
164
+ const normalized = raw.trim().toLowerCase();
165
+ if (normalized === "on" || normalized === "off" || normalized === "auto") return normalized;
166
+ }
167
+ const json = resolveReadSeekJsonSettings().settings.read?.ocrMode;
168
+ if (json === "on" || json === "off" || json === "auto") return json;
169
+ return "on";
170
+ }
@@ -2,6 +2,7 @@ import { stat as fsStat } from "node:fs/promises";
2
2
  import type { Stats } from "node:fs";
3
3
 
4
4
  import { buildToolErrorResult, type ToolErrorResult } from "./readseek-value.js";
5
+ import { formatFsError } from "./fs-error.js";
5
6
 
6
7
  export type StatSearchPathResult =
7
8
  | { ok: true; stats: Stats }
@@ -22,13 +23,7 @@ export async function statSearchPathOrError(
22
23
  } catch (err: any) {
23
24
  const display = rawPath ?? ".";
24
25
  const path = rawPath ?? searchPath;
25
- if (err?.code === "ENOENT") {
26
- return { ok: false, error: buildToolErrorResult(tool, "path-not-found", `Path '${display}' does not exist`, { path }) };
27
- }
28
- if (err?.code === "EACCES" || err?.code === "EPERM") {
29
- return { ok: false, error: buildToolErrorResult(tool, "permission-denied", `Permission denied for path '${display}'`, { path }) };
30
- }
31
- const message = `Could not access path '${display}': ${err?.message ?? String(err)}`;
32
- return { ok: false, error: buildToolErrorResult(tool, "fs-error", message, { path, details: { fsCode: err?.code, fsMessage: err?.message } }) };
26
+ const { code, message } = formatFsError(err, "stat-error");
27
+ return { ok: false, error: buildToolErrorResult(tool, code, message, { path }) };
33
28
  }
34
29
  }
@@ -149,6 +149,26 @@ export function wrapReadHashlinesForWidth(text: string, width: number | undefine
149
149
  return output.join("\n");
150
150
  }
151
151
 
152
+ const wrappedHashlinesCache = new WeakMap<object, { text: string; width: number | undefined; wrapped: string }>();
153
+
154
+ /**
155
+ * Memoized {@link wrapReadHashlinesForWidth} for render paths that re-run on
156
+ * every TUI frame: the wrapped output is cached per result content object so
157
+ * an expanded read result is only re-wrapped when its text or the terminal
158
+ * width changes.
159
+ */
160
+ export function wrapReadHashlinesForWidthCached(
161
+ cacheKey: object,
162
+ text: string,
163
+ width: number | undefined,
164
+ ): string {
165
+ const cached = wrappedHashlinesCache.get(cacheKey);
166
+ if (cached && cached.text === text && cached.width === width) return cached.wrapped;
167
+ const wrapped = wrapReadHashlinesForWidth(text, width);
168
+ wrappedHashlinesCache.set(cacheKey, { text, width, wrapped });
169
+ return wrapped;
170
+ }
171
+
152
172
  /**
153
173
  * Render the `isPartial` placeholder line shared by every tool's `renderResult`.
154
174
  */
package/src/write.ts CHANGED
@@ -9,7 +9,7 @@ import { resolveToCwd } from "./path-utils.js";
9
9
  import { ensureHashInit, formatHashlineDisplay } from "./hashline.js";
10
10
  import { buildReadSeekError, buildReadSeekLine, buildReadSeekWarning, buildToolErrorResult, type ReadSeekLine, type ReadSeekWarning } from "./readseek-value.js";
11
11
  import { looksLikeBinary } from "./binary-detect.js";
12
- import { classifyFsError, type FsErrorCode } from "./fs-error.js";
12
+ import { formatFsError } from "./fs-error.js";
13
13
  import { getOrGenerateMap } from "./file-map.js";
14
14
  import { formatFileMapWithBudget } from "./readseek/formatter.js";
15
15
 
@@ -120,57 +120,12 @@ export interface WriteToolOptions {
120
120
  onFileAnchored?: FileAnchoredCallback;
121
121
  }
122
122
 
123
- type MappedFsError = {
124
- code: FsErrorCode | "fs-error";
125
- message: string;
126
- hint?: string;
127
- includeMeta: boolean;
128
- };
129
-
130
- function mapFsWriteError(err: any, path: string): MappedFsError {
131
- const phase: "mkdir" | "write" | undefined = err?.__phase;
132
- const fsCode = err?.code as string | undefined;
133
-
134
- if (fsCode === "ENOENT" && phase === "mkdir") {
135
- return {
136
- code: "fs-error",
137
- message: `Cannot create parent directories for ${path}: ${err?.message ?? String(err)}`,
138
- includeMeta: true,
139
- };
140
- }
141
- if (fsCode === "ENOSPC") {
142
- return {
143
- code: "fs-error",
144
- message: `No space left on device — cannot write: ${path}`,
145
- includeMeta: true,
146
- };
147
- }
148
- if (fsCode === "EROFS") {
149
- return {
150
- code: "fs-error",
151
- message: `Read-only filesystem — cannot write: ${path}`,
152
- includeMeta: true,
153
- };
154
- }
155
- const fsError = classifyFsError(err, path);
156
- if (fsError) {
157
- return { code: fsError.code, message: fsError.message, hint: fsError.hint, includeMeta: false };
158
- }
159
- return {
160
- code: "fs-error",
161
- message: `Error writing ${path}: ${err?.message ?? String(err)}`,
162
- includeMeta: true,
163
- };
164
- }
165
-
166
123
  function buildWriteFsErrorResult(err: any, absolutePath: string) {
167
- const mapped = mapFsWriteError(err, absolutePath);
168
- return buildToolErrorResult("write", mapped.code, mapped.message, {
169
- path: absolutePath,
170
- hint: mapped.hint,
171
- extra: { lines: [], warnings: [] },
172
- details: mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
173
- });
124
+ const { code, message } = formatFsError(err, "write-error");
125
+ return buildToolErrorResult("write", code, message, {
126
+ path: absolutePath,
127
+ extra: { lines: [], warnings: [] },
128
+ });
174
129
  }
175
130
 
176
131
  export async function executeWrite(opts: {
@@ -218,21 +173,10 @@ export async function executeWrite(opts: {
218
173
  const previousContent = await readPreviousTextForDiff(filePath);
219
174
  const existedBeforeWrite = await access(filePath).then(() => true, () => false);
220
175
 
221
- // Create parent directories
222
- try {
223
- await mkdir(dirname(filePath), { recursive: true });
224
- } catch (err: any) {
225
- err.__phase = "mkdir";
226
- throw err;
227
- }
228
- // Write file
229
- try {
230
- await writeFile(filePath, content, "utf-8");
231
- } catch (err: any) {
232
- err.__phase = "write";
233
- throw err;
234
- }
235
-
176
+ // Create parent directories
177
+ await mkdir(dirname(filePath), { recursive: true });
178
+ // Write file
179
+ await writeFile(filePath, content, "utf-8");
236
180
  // Compute hashlines
237
181
  const rawLines = content.split("\n");
238
182
  const readseekLines: ReadSeekLine[] = [];