pi-readseek 0.4.25 → 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/index.ts CHANGED
@@ -4,7 +4,10 @@ import { registerEditTool } from "./src/edit.js";
4
4
  import { registerGrepTool } from "./src/grep.js";
5
5
  import { registerSgTool } from "./src/sg.js";
6
6
  import { registerRefsTool } from "./src/refs.js";
7
+ import { registerRenameTool } from "./src/rename.js";
8
+ import { registerHoverTool } from "./src/hover.js";
7
9
  import { registerWriteTool } from "./src/write.js";
10
+ import { registerDefTool } from "./src/def.js";
8
11
  import { SessionAnchors } from "./src/session-anchors.js";
9
12
  import { isReadSeekAvailable } from "./src/readseek-client.js";
10
13
 
@@ -23,5 +26,8 @@ export default function piReadSeekExtension(pi: ExtensionAPI): void {
23
26
  registerGrepTool(pi, { searchGuideline, onFileAnchored: markAnchored });
24
27
  registerSgTool(pi, { onFileAnchored: markAnchored });
25
28
  registerRefsTool(pi, { onFileAnchored: markAnchored });
29
+ registerRenameTool(pi);
30
+ registerHoverTool(pi);
31
+ registerDefTool(pi, { onFileAnchored: markAnchored });
26
32
  registerWriteTool(pi, { onFileAnchored: markAnchored });
27
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.4.25",
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/def.md ADDED
@@ -0,0 +1,24 @@
1
+ ---
2
+ tools: def
3
+ ---
4
+
5
+ Find structural symbol definitions. Calls `readseek def` which searches
6
+ for the definition of a named symbol across a file or directory.
7
+
8
+ ## Parameters
9
+
10
+ - `path` (optional): File or directory to search (default: ".").
11
+ - `name` (optional): Qualified or unqualified symbol name. Required unless
12
+ `fromIdentify` is true.
13
+ - `lang` (optional): Language override.
14
+ - `fromIdentify` (optional): When true, reads identify output from a
15
+ previous identify call to extract the symbol name automatically.
16
+ - `cached` (optional): Search tracked/indexed files in a Git repository.
17
+ - `others` (optional): Search untracked files.
18
+ - `ignored` (optional): Include ignored untracked files.
19
+
20
+ ## When to use
21
+
22
+ - After a hover call, to jump to where a symbol is defined.
23
+ - When the user asks "where is X defined?".
24
+ - To find a function/class/type definition by its qualified name.
@@ -0,0 +1,19 @@
1
+ ---
2
+ tools: hover
3
+ ---
4
+
5
+ Use hover to identify the identifier and enclosing symbol at a cursor
6
+ position. Calls `readseek identify` with the file content sent via stdin
7
+ so unsaved editor content is included.
8
+
9
+ ## Parameters
10
+
11
+ - `path` (required): File path.
12
+ - `line` (required): One-based cursor line.
13
+ - `column` (optional): One-based cursor byte column.
14
+
15
+ ## When to use
16
+
17
+ - Before a rename, to confirm the identifier under the cursor.
18
+ - Before a go-to-definition, to get the qualified symbol name.
19
+ - To inspect what symbol a specific line belongs to.
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
 
@@ -0,0 +1,31 @@
1
+ ---
2
+ tools: rename
3
+ ---
4
+
5
+ Use rename to rename an identifier (variable, function, type, etc.) with
6
+ binding accuracy. The tool calls `readseek rename` which resolves the
7
+ lexical binding under the cursor and renames every occurrence that binds
8
+ to the same declaration.
9
+
10
+ ## Parameters
11
+
12
+ - `path` (required): File holding the binding to rename (a single regular file).
13
+ - `line` (required): One-based cursor line of the binding to rename.
14
+ - `column` (optional): One-based cursor byte column of the binding.
15
+ - `to` (required): New name for the binding. Must be a plain identifier.
16
+ - `workspace` (optional): When true, expands the rename across the project
17
+ root. The cursor file remains binding-accurate; other files are matched
18
+ by name (free uses only, local shadows excluded).
19
+ - `apply` (optional, default true): When true, writes the edits to disk
20
+ after verifying line hashes. When false, returns the plan only.
21
+
22
+ ## When to use
23
+
24
+ - The user asks to rename a symbol (function, variable, class, etc.).
25
+ - The cursor position (line, optionally column) is known from an identify
26
+ call or from the user's editor context.
27
+
28
+ ## When not to use
29
+
30
+ - For search-and-replace across files, use grep or sg.
31
+ - For refactoring that requires adding/removing parameters, use edit.
package/src/def.ts ADDED
@@ -0,0 +1,148 @@
1
+ import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import path from "node:path";
4
+
5
+ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
6
+ import { buildReadSeekLineWithHash, buildToolErrorResult } from "./readseek-value.js";
7
+ import { resolveToCwd } from "./path-utils.js";
8
+ import { statSearchPathOrError } from "./stat-search-path.js";
9
+ import { classifyReadSeekFailure, readseekDef } from "./readseek-client.js";
10
+ import { searchPathParam, langParam, readseekGitSearchParams } from "./readseek-params.js";
11
+ import { registerReadSeekTool } from "./register-tool.js";
12
+
13
+ import { renderAnchoredFilesResult, renderReadSeekSearchCall } from "./tui-render-utils.js";
14
+ import type { FileAnchoredCallback } from "./tool-types.js";
15
+
16
+ const DEF_PROMPT_METADATA = defineToolPromptMetadata({
17
+ promptUrl: new URL("../prompts/def.md", import.meta.url),
18
+ promptSnippet: "Find structural symbol definitions with readseek",
19
+ });
20
+
21
+ type DefParams = {
22
+ name?: string;
23
+ path?: string;
24
+ lang?: string;
25
+ fromIdentify?: boolean;
26
+ cached?: boolean;
27
+ others?: boolean;
28
+ ignored?: boolean;
29
+ };
30
+
31
+ interface DefToolOptions {
32
+ onFileAnchored?: FileAnchoredCallback;
33
+ }
34
+
35
+ export interface ExecuteDefOptions {
36
+ params: unknown;
37
+ signal: AbortSignal | undefined;
38
+ cwd: string;
39
+ onFileAnchored?: FileAnchoredCallback;
40
+ }
41
+
42
+ export async function executeDef(opts: ExecuteDefOptions): Promise<any> {
43
+ const { params, signal, cwd, onFileAnchored } = opts;
44
+ const p = params as DefParams;
45
+
46
+ if (!p.fromIdentify && (!p.name || !p.name.trim())) {
47
+ return buildToolErrorResult("def", "invalid-parameter", "def requires 'name' or 'fromIdentify'");
48
+ }
49
+
50
+ const searchPath = resolveToCwd(p.path ?? ".", cwd);
51
+
52
+ const statResult = await statSearchPathOrError("def", p.path, searchPath);
53
+ if (!statResult.ok) return statResult.error;
54
+
55
+ try {
56
+ const definitions = await readseekDef(searchPath, {
57
+ name: p.name,
58
+ fromIdentify: p.fromIdentify,
59
+ language: p.lang,
60
+ cached: p.cached,
61
+ others: p.others,
62
+ ignored: p.ignored,
63
+ signal,
64
+ });
65
+
66
+ if (definitions.length === 0) {
67
+ return {
68
+ content: [{ type: "text", text: "no definitions found" }],
69
+ details: {
70
+ readseekValue: { tool: "def", ok: true, path: searchPath, definitions: [] },
71
+ },
72
+ };
73
+ }
74
+
75
+ const files = new Map<string, { displayPath: string; path: string; lines: ReturnType<typeof buildReadSeekLineWithHash>[] }>();
76
+ for (const def of definitions) {
77
+ const abs = path.isAbsolute(def.file) ? def.file : path.resolve(cwd, def.file);
78
+ let file = files.get(abs);
79
+ if (!file) {
80
+ file = { displayPath: path.relative(cwd, abs) || abs, path: abs, lines: [] };
81
+ files.set(abs, file);
82
+ }
83
+ file.lines.push(buildReadSeekLineWithHash(def.line, def.line_hash, def.text));
84
+ }
85
+
86
+ const fileList = [...files.values()];
87
+ for (const file of fileList) {
88
+ onFileAnchored?.(file.path);
89
+ }
90
+
91
+ const textParts: string[] = [];
92
+ for (const file of fileList) {
93
+ textParts.push(file.displayPath);
94
+ for (const line of file.lines) {
95
+ textParts.push(` ${line.line}:${line.hash} ${line.display}`);
96
+ }
97
+ }
98
+
99
+ return {
100
+ content: [{ type: "text", text: textParts.join("\n") }],
101
+ details: {
102
+ readseekValue: { tool: "def", ok: true, path: searchPath, definitions },
103
+ },
104
+ };
105
+ } catch (err: any) {
106
+ const failure = classifyReadSeekFailure(err);
107
+ return buildToolErrorResult("def", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
108
+ }
109
+ }
110
+
111
+ export function registerDefTool(pi: ExtensionAPI, options: DefToolOptions = {}) {
112
+ registerReadSeekTool(pi, {
113
+ policy: "read-only",
114
+ pythonName: "def",
115
+ defaultExposure: "opt-in",
116
+ }, {
117
+ name: "def",
118
+ label: "Definition",
119
+ description: DEF_PROMPT_METADATA.description,
120
+ promptSnippet: DEF_PROMPT_METADATA.promptSnippet,
121
+ promptGuidelines: DEF_PROMPT_METADATA.promptGuidelines,
122
+ parameters: Type.Object({
123
+ name: Type.Optional(Type.String({ description: "Qualified or unqualified symbol name" })),
124
+ path: searchPathParam(),
125
+ lang: langParam(),
126
+ fromIdentify: Type.Optional(Type.Boolean({ description: "Read identify output from stdin to choose the symbol name" })),
127
+ ...readseekGitSearchParams(),
128
+ }),
129
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
130
+ return executeDef({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
131
+ },
132
+ renderCall(args: any, theme: any, ...rest: any[]) {
133
+ return renderReadSeekSearchCall(args, theme, rest, {
134
+ label: "def",
135
+ accent: args.name,
136
+ flags: [args.fromIdentify && "from-identify", args.cached && "cached", args.others && "others", args.ignored && "ignored"],
137
+ });
138
+ },
139
+ renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
140
+ return renderAnchoredFilesResult(result, options, theme, rest, {
141
+ pendingLabel: "pending def",
142
+ emptyLabel: "no definitions",
143
+ unitSingular: "definition",
144
+ unitPlural: "definitions",
145
+ });
146
+ },
147
+ });
148
+ }
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 ADDED
@@ -0,0 +1,133 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "@sinclair/typebox";
5
+ import { Text } from "@earendil-works/pi-tui";
6
+
7
+ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
8
+ import { buildToolErrorResult } from "./readseek-value.js";
9
+ import { resolveToCwd } from "./path-utils.js";
10
+ import { formatFsError } from "./fs-error.js";
11
+ import { classifyReadSeekFailure, readseekIdentify } from "./readseek-client.js";
12
+ import { filePathParam, registerReadSeekTool } from "./register-tool.js";
13
+
14
+ import { clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
15
+
16
+ const HOVER_PROMPT_METADATA = defineToolPromptMetadata({
17
+ promptUrl: new URL("../prompts/hover.md", import.meta.url),
18
+ promptSnippet: "Identify the identifier and enclosing symbol at a cursor position",
19
+ });
20
+
21
+ const hoverSchema = Type.Object({
22
+ path: filePathParam(),
23
+ line: Type.Number({ description: "One-based cursor line" }),
24
+ column: Type.Optional(Type.Number({ description: "One-based cursor byte column" })),
25
+ });
26
+
27
+ interface HoverParams {
28
+ path: string;
29
+ line: number;
30
+ column?: number;
31
+ }
32
+
33
+ export interface ExecuteHoverOptions {
34
+ params: unknown;
35
+ signal: AbortSignal | undefined;
36
+ cwd: string;
37
+ }
38
+
39
+ export async function executeHover(opts: ExecuteHoverOptions): Promise<any> {
40
+ const { params, signal, cwd } = opts;
41
+ const p = params as HoverParams;
42
+
43
+ if (!Number.isSafeInteger(p.line) || p.line < 1) {
44
+ return buildToolErrorResult("hover", "invalid-parameter", "hover parameter 'line' must be a positive integer");
45
+ }
46
+
47
+ const filePath = resolveToCwd(p.path, cwd);
48
+
49
+ let content: string;
50
+ try {
51
+ content = await readFile(filePath, "utf-8");
52
+ } catch (err: any) {
53
+ const { code, message } = formatFsError(err, "hover-error");
54
+ return buildToolErrorResult("hover", code, message, { path: p.path });
55
+ }
56
+
57
+ try {
58
+ const output = await readseekIdentify(filePath, content, {
59
+ line: p.line,
60
+ column: p.column,
61
+ signal,
62
+ });
63
+
64
+ const lines: string[] = [];
65
+ if (output.identifier) {
66
+ lines.push(`identifier: ${output.identifier.text}`);
67
+ }
68
+ if (output.symbol) {
69
+ lines.push(`symbol: ${output.symbol.name}`);
70
+ lines.push(`kind: ${output.symbol.kind}`);
71
+ lines.push(`qualified: ${output.symbol.qualified_name}`);
72
+ }
73
+ lines.push(`file: ${output.file}`);
74
+ lines.push(`language: ${output.language}`);
75
+ lines.push(`location: ${output.line}:${output.column}`);
76
+
77
+ return {
78
+ content: [{ type: "text", text: lines.join("\n") }],
79
+ details: {
80
+ readseekValue: {
81
+ tool: "hover",
82
+ ok: true,
83
+ path: filePath,
84
+ output,
85
+ },
86
+ },
87
+ };
88
+ } catch (err: any) {
89
+ const failure = classifyReadSeekFailure(err);
90
+ return buildToolErrorResult("hover", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
91
+ }
92
+ }
93
+
94
+ export function registerHoverTool(pi: ExtensionAPI) {
95
+ registerReadSeekTool(pi, {
96
+ policy: "read-only",
97
+ pythonName: "hover",
98
+ defaultExposure: "opt-in",
99
+ }, {
100
+ name: "hover",
101
+ label: "Hover",
102
+ description: HOVER_PROMPT_METADATA.description,
103
+ promptSnippet: HOVER_PROMPT_METADATA.promptSnippet,
104
+ promptGuidelines: HOVER_PROMPT_METADATA.promptGuidelines,
105
+ parameters: hoverSchema,
106
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
107
+ return executeHover({ params, signal, cwd: ctx.cwd });
108
+ },
109
+ renderCall(args: any, theme: any, ...rest: any[]) {
110
+ const context = rest[0] ?? {};
111
+ const cwd = context.cwd ?? process.cwd();
112
+ const displayPath = typeof args?.path === "string" ? args.path : "?";
113
+ let text = theme.fg("toolTitle", theme.bold("hover"));
114
+ text += ` ${linkToolPath(theme.fg("accent", displayPath), displayPath, cwd)}`;
115
+ if (args?.line) text += theme.fg("dim", `:${args.line}`);
116
+ return text;
117
+ },
118
+ renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
119
+ const { isPartial, isError, width } = resolveRenderResultContext(options, rest);
120
+
121
+ if (isPartial) return renderPendingResult("pending hover", width);
122
+
123
+ const content = result.content?.[0];
124
+ const textContent = content?.type === "text" ? content.text : "";
125
+
126
+ if (isError || result.isError) {
127
+ return new Text(textContent || "hover failed", 0, 0);
128
+ }
129
+
130
+ return new Text(textContent.split("\n")[0] || "", 0, 0);
131
+ },
132
+ });
133
+ }
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[]) {