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 +35 -2
- package/package.json +1 -1
- package/prompts/read.md +1 -1
- package/src/edit.ts +4 -14
- package/src/fs-error.ts +27 -24
- package/src/hover.ts +4 -2
- package/src/read.ts +53 -19
- package/src/readseek-client.ts +73 -5
- package/src/readseek-settings.ts +54 -4
- package/src/stat-search-path.ts +3 -8
- package/src/tui-render-utils.ts +20 -0
- package/src/write.ts +10 -66
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
|
|
28
|
-
options powered by
|
|
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
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
|
|
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 {
|
|
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,
|
|
108
|
-
const code = err
|
|
109
|
-
|
|
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
|
-
|
|
1
|
+
const SYSLOG_PATH_SUFFIX = /,\s+[a-z]+(\s+'.*')?$/;
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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, {
|
|
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
|
|
172
|
-
|
|
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:
|
|
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" +
|
|
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
|
});
|
package/src/readseek-client.ts
CHANGED
|
@@ -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
|
-
|
|
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))
|
|
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)
|
|
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:
|
|
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
|
}
|
package/src/readseek-settings.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/stat-search-path.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
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/tui-render-utils.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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[] = [];
|