pi-readseek 0.4.26 → 0.4.27

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.27",
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,6 +28,7 @@ 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 { resolveReadSeekOcrMode } from "./readseek-settings.js";
31
32
  import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } 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";
@@ -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[]) {
@@ -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
  }
@@ -348,10 +362,11 @@ async function spawnReadSeekRaw(args: string[], options: RunReadSeekOptions = {}
348
362
  });
349
363
  childStdin.end(stdin, "utf-8");
350
364
  }
351
- child.on("close", (code) => {
365
+ child.on("close", (code, signal) => {
352
366
  const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
353
367
  const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
354
368
  if (code === 0) succeed(stdout);
369
+ else if (signal) fail(new Error((stderr || `readseek killed by signal ${signal}`).replace(/^error:\s*/i, "")));
355
370
  else fail(new Error((stderr || `readseek exited with status ${code}`).replace(/^error:\s*/i, "")));
356
371
  });
357
372
  });
@@ -659,6 +674,21 @@ function parseTranscript(value: unknown): ReadSeekTranscript | undefined {
659
674
  };
660
675
  }
661
676
 
677
+ function parseDetectedObjects(value: unknown): ReadSeekDetectedObject[] | undefined {
678
+ if (value === undefined || value === null) return undefined;
679
+ if (!Array.isArray(value)) throw new Error("invalid readseek detect objects");
680
+ return value.map((object) => {
681
+ if (!object || typeof object !== "object") throw new Error("invalid readseek detect object");
682
+ const item = object as Record<string, unknown>;
683
+ const bbox = item.bbox;
684
+ if (!Array.isArray(bbox) || bbox.length !== 4) throw new Error("invalid readseek detect object.bbox");
685
+ return {
686
+ label: requireString(item.label, "object.label"),
687
+ bbox: bbox.map((n, i) => requireNumber(n, `object.bbox[${i}]`)) as ReadSeekDetectedObject["bbox"],
688
+ };
689
+ });
690
+ }
691
+
662
692
  function parseDetectOutput(value: unknown): ReadSeekDetection {
663
693
  if (!value || typeof value !== "object") throw new Error("invalid readseek detect output");
664
694
  const output = value as Record<string, unknown>;
@@ -682,6 +712,8 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
682
712
  height: requireNumber(output.height, "height"),
683
713
  animated: requireBoolean(output.animated, "animated"),
684
714
  transcribe: parseTranscript(output.transcribe),
715
+ caption: optionalString(output.caption, "caption"),
716
+ objects: parseDetectedObjects(output.objects),
685
717
  };
686
718
  }
687
719
  if (output.language !== undefined) {
@@ -701,10 +733,12 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
701
733
 
702
734
  export async function readseekDetect(
703
735
  filePath: string,
704
- options: { transcribe?: boolean; signal?: AbortSignal } = {},
736
+ options: ReadSeekDetectOptions = {},
705
737
  ): Promise<ReadSeekDetection> {
706
738
  const args = ["detect"];
707
739
  if (options.transcribe) args.push("--transcribe");
740
+ if (options.caption) args.push("--caption");
741
+ if (options.objects) args.push("--objects");
708
742
  args.push(filePath);
709
743
  return parseDetectOutput(await runReadSeek(args, { signal: options.signal }));
710
744
  }
@@ -5,6 +5,7 @@ import { join } from "node:path";
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,6 +76,16 @@ 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
 
@@ -96,6 +107,8 @@ function mergeSettings(base: ReadSeekJsonSettings, override: ReadSeekJsonSetting
96
107
  if (Object.keys(grep).length > 0) merged.grep = grep;
97
108
  const edit = { ...(base.edit ?? {}), ...(override.edit ?? {}) };
98
109
  if (Object.keys(edit).length > 0) merged.edit = edit;
110
+ const read = { ...(base.read ?? {}), ...(override.read ?? {}) };
111
+ if (Object.keys(read).length > 0) merged.read = read;
99
112
  return merged;
100
113
  }
101
114
 
@@ -118,3 +131,14 @@ export function resolveEditDiffDisplay(env: NodeJS.ProcessEnv = process.env): "c
118
131
  if (json === "expanded" || json === "collapsed") return json;
119
132
  return "collapsed";
120
133
  }
134
+
135
+ export function resolveReadSeekOcrMode(env: NodeJS.ProcessEnv = process.env): "on" | "off" | "auto" {
136
+ const raw = env.READSEEK_READ_OCR_MODE;
137
+ if (typeof raw === "string") {
138
+ const normalized = raw.trim().toLowerCase();
139
+ if (normalized === "on" || normalized === "off" || normalized === "auto") return normalized;
140
+ }
141
+ const json = resolveReadSeekJsonSettings().settings.read?.ocrMode;
142
+ if (json === "on" || json === "off" || json === "auto") return json;
143
+ return "on";
144
+ }
@@ -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
  }
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[] = [];