pi-readseek 0.4.13 → 0.4.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
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": {
@@ -39,7 +39,7 @@
39
39
  "node": ">=20.0.0"
40
40
  },
41
41
  "dependencies": {
42
- "@jarkkojs/readseek": "^0.4.16",
42
+ "@jarkkojs/readseek": "^0.4.20",
43
43
  "diff": "^8.0.3",
44
44
  "ignore": "^7.0.5",
45
45
  "picomatch": "^4.0.4",
package/src/read.ts CHANGED
@@ -15,7 +15,6 @@ import { normalizeToLF, stripBom, hasBareCarriageReturn } from "./edit-diff.js";
15
15
  import { ensureHashInit, escapeControlCharsForDisplay } from "./hashline.js";
16
16
  import { buildReadSeekWarning, buildToolErrorResult, renderReadSeekLines, type ReadSeekLine, type ReadSeekWarning } from "./readseek-value.js";
17
17
  import { looksLikeBinary } from "./binary-detect.js";
18
- import { isSupportedImageBuffer } from "./image-detect.js";
19
18
  import { resolveToCwd } from "./path-utils.js";
20
19
  import { throwIfAborted } from "./runtime.js";
21
20
  import { getOrGenerateMap } from "./file-map.js";
@@ -26,7 +25,7 @@ import { buildReadOutput } from "./read-output.js";
26
25
 
27
26
  import { buildLocalBundle } from "./read-local-bundle.js";
28
27
  import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
29
- import { readseekRead } from "./readseek-client.js";
28
+ import { readseekRead, readseekDetect, type ReadSeekDetection } from "./readseek-client.js";
30
29
  import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
31
30
  import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
32
31
  import type { FileAnchoredCallback } from "./tool-types.js";
@@ -34,7 +33,7 @@ import { registerReadSeekTool } from "./register-tool.js";
34
33
 
35
34
  const READ_PROMPT_METADATA = defineToolPromptMetadata({
36
35
  promptUrl: new URL("../prompts/read.md", import.meta.url),
37
- promptSnippet: "Read text files or images; text reads include hashline anchors and optional maps/symbol lookup",
36
+ promptSnippet: "Read text files or images; text reads include hashline anchors and optional maps/symbol lookup, image reads include the attachment plus OCR text",
38
37
  });
39
38
 
40
39
  interface ReadParams {
@@ -138,15 +137,8 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
138
137
  const message = "Cannot combine map with symbol. Use one or the other.";
139
138
  return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
140
139
  }
141
- // Delegate images to the built-in read tool
142
140
  throwIfAborted(signal);
143
141
  const ext = rawPath.split(".").pop()?.toLowerCase() ?? "";
144
- if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
145
- const builtinRead = createReadTool(cwd);
146
- return succeed(await builtinRead.execute(toolCallId, p, signal, onUpdate));
147
- }
148
-
149
- throwIfAborted(signal);
150
142
  let rawBuffer: Buffer;
151
143
  try {
152
144
  rawBuffer = await fsReadFile(absolutePath);
@@ -168,11 +160,31 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
168
160
  return buildToolErrorResult("read", "fs-error", message, { path: rawParams.path, details: { fsCode: code, fsMessage: err?.message } });
169
161
  }
170
162
 
171
- if (isSupportedImageBuffer(rawBuffer)) {
172
- const builtinRead = createReadTool(cwd);
173
- return succeed(await builtinRead.execute(toolCallId, p, signal, onUpdate));
174
- }
175
163
  const hasBinaryContent = looksLikeBinary(rawBuffer);
164
+ if (hasBinaryContent) {
165
+ // Images are always binary; classify and OCR in a single readseek detect call.
166
+ let detection: ReadSeekDetection | undefined;
167
+ try {
168
+ detection = await readseekDetect(absolutePath, { ocr: true, signal });
169
+ } catch {
170
+ // detect unavailable — fall through to binary-as-text handling below
171
+ }
172
+ if (detection?.type === "image") {
173
+ const builtinRead = createReadTool(cwd);
174
+ const builtinResult = await builtinRead.execute(toolCallId, p, signal, onUpdate);
175
+ const ocrText = detection.ocr?.text?.trim();
176
+ if (ocrText) {
177
+ return succeed({
178
+ ...builtinResult,
179
+ content: [
180
+ ...(builtinResult.content ?? []),
181
+ { type: "text" as const, text: `OCR text extracted from image:\n${ocrText}` },
182
+ ],
183
+ });
184
+ }
185
+ return succeed(builtinResult);
186
+ }
187
+ }
176
188
  throwIfAborted(signal);
177
189
  const rawText = rawBuffer.toString("utf-8");
178
190
  const normalized = normalizeToLF(stripBom(rawText).text);
@@ -108,6 +108,47 @@ export interface ReadSeekCheckOutput {
108
108
  diagnostics: ReadSeekDiagnostic[];
109
109
  }
110
110
 
111
+ export interface ReadSeekOcrLine {
112
+ text: string;
113
+ bbox: [number, number, number, number];
114
+ }
115
+
116
+ export interface ReadSeekOcrText {
117
+ text: string;
118
+ lines: ReadSeekOcrLine[];
119
+ }
120
+
121
+ export type ReadSeekDetection =
122
+ | {
123
+ type: "source";
124
+ file: string;
125
+ language: string;
126
+ engine?: string;
127
+ supported: boolean;
128
+ mime?: string;
129
+ syntax?: string;
130
+ }
131
+ | {
132
+ type: "image";
133
+ file: string;
134
+ mime?: string;
135
+ format: string;
136
+ width: number;
137
+ height: number;
138
+ animated: boolean;
139
+ ocr?: ReadSeekOcrText;
140
+ }
141
+ | {
142
+ type: "binary";
143
+ file: string;
144
+ mime?: string;
145
+ }
146
+ | {
147
+ type: "text";
148
+ file: string;
149
+ mime?: string;
150
+ };
151
+
111
152
  interface ReadSeekSearchOptions {
112
153
  language?: string;
113
154
  cached?: boolean;
@@ -363,6 +404,11 @@ function requireString(value: unknown, field: string): string {
363
404
  return value;
364
405
  }
365
406
 
407
+ function requireBoolean(value: unknown, field: string): boolean {
408
+ if (typeof value !== "boolean") throw new Error(`invalid readseek ${field}: expected boolean`);
409
+ return value;
410
+ }
411
+
366
412
  function parseReadOutput(value: unknown): ReadSeekReadOutput {
367
413
  if (!value || typeof value !== "object") throw new Error("invalid readseek read output");
368
414
  const output = value as Record<string, unknown>;
@@ -594,3 +640,69 @@ export async function readseekCheck(
594
640
  await runReadSeek(["check", "--stdin", filePath], { signal: options.signal, stdin: content }),
595
641
  );
596
642
  }
643
+
644
+ function parseOcrText(value: unknown): ReadSeekOcrText | undefined {
645
+ if (value === undefined || value === null) return undefined;
646
+ if (typeof value !== "object") throw new Error("invalid readseek detect ocr");
647
+ const ocr = value as Record<string, unknown>;
648
+ if (!Array.isArray(ocr.lines)) throw new Error("invalid readseek detect ocr.lines");
649
+ return {
650
+ text: requireString(ocr.text, "ocr.text"),
651
+ lines: ocr.lines.map((line) => {
652
+ if (!line || typeof line !== "object") throw new Error("invalid readseek detect ocr line");
653
+ const item = line as Record<string, unknown>;
654
+ const bbox = item.bbox;
655
+ if (!Array.isArray(bbox) || bbox.length !== 4) throw new Error("invalid readseek detect ocr bbox");
656
+ return {
657
+ text: requireString(item.text, "ocr.line.text"),
658
+ bbox: bbox.map((n, i) => requireNumber(n, `ocr.line.bbox[${i}]`)) as [number, number, number, number],
659
+ };
660
+ }),
661
+ };
662
+ }
663
+
664
+ function parseDetectOutput(value: unknown): ReadSeekDetection {
665
+ if (!value || typeof value !== "object") throw new Error("invalid readseek detect output");
666
+ const output = value as Record<string, unknown>;
667
+ const type = requireString(output.type, "type");
668
+ const file = requireString(output.file, "file");
669
+ const mime = optionalString(output.mime, "mime");
670
+ switch (type) {
671
+ case "source":
672
+ return {
673
+ type,
674
+ file,
675
+ language: requireString(output.language, "language"),
676
+ engine: optionalString(output.engine, "engine"),
677
+ supported: requireBoolean(output.supported, "supported"),
678
+ mime,
679
+ syntax: optionalString(output.syntax, "syntax"),
680
+ };
681
+ case "image":
682
+ return {
683
+ type,
684
+ file,
685
+ mime,
686
+ format: requireString(output.format, "format"),
687
+ width: requireNumber(output.width, "width"),
688
+ height: requireNumber(output.height, "height"),
689
+ animated: requireBoolean(output.animated, "animated"),
690
+ ocr: parseOcrText(output.ocr),
691
+ };
692
+ case "binary":
693
+ case "text":
694
+ return { type, file, mime };
695
+ default:
696
+ throw new Error(`invalid readseek detect type: ${type}`);
697
+ }
698
+ }
699
+
700
+ export async function readseekDetect(
701
+ filePath: string,
702
+ options: { ocr?: boolean; signal?: AbortSignal } = {},
703
+ ): Promise<ReadSeekDetection> {
704
+ const args = ["detect"];
705
+ if (options.ocr) args.push("--ocr");
706
+ args.push(filePath);
707
+ return parseDetectOutput(await runReadSeek(args, { signal: options.signal }));
708
+ }
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  const COMPACT_DESCRIPTIONS: Record<string, string> = {
6
- "read.md": "Read text files/images by path; text has LINE:HASH anchors, images return attachments.",
6
+ "read.md": "Read text files/images by path; text has LINE:HASH anchors, images return the attachment plus OCR-extracted text.",
7
7
  "edit.md": "Edit existing text files using fresh LINE:HASH anchors from read, grep, search, or write.",
8
8
  "grep.md": "Search file contents; non-summary results include LINE:HASH anchors for edits.",
9
9
 
@@ -16,7 +16,7 @@ const COMPACT_GUIDELINES: Record<string, string[]> = {
16
16
  "read.md": [
17
17
  "Use read for file contents, images/screenshots, ranges, symbols, and edit anchors.",
18
18
  "Use map or symbol mode before pulling large code files into context.",
19
- "Use read for images; it returns attachments, so avoid OCR tools unless explicitly needed.",
19
+ "Use read for images; it returns the image attachment plus OCR-extracted text, so you don't need separate OCR tools.",
20
20
  ],
21
21
  "edit.md": [
22
22
  "Use edit with fresh LINE:HASH anchors for existing files.",
@@ -1,47 +0,0 @@
1
- const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
2
-
3
- function startsWithBytes(buffer: Buffer, bytes: number[]): boolean {
4
- return buffer.length >= bytes.length && bytes.every((byte, index) => buffer[index] === byte);
5
- }
6
-
7
- function startsWithAscii(buffer: Buffer, offset: number, text: string): boolean {
8
- if (buffer.length < offset + text.length) return false;
9
- for (let index = 0; index < text.length; index++) {
10
- if (buffer[offset + index] !== text.charCodeAt(index)) return false;
11
- }
12
- return true;
13
- }
14
-
15
- function readUint32BE(buffer: Buffer, offset: number): number {
16
- return (
17
- ((buffer[offset] ?? 0) * 0x1000000) +
18
- ((buffer[offset + 1] ?? 0) << 16) +
19
- ((buffer[offset + 2] ?? 0) << 8) +
20
- (buffer[offset + 3] ?? 0)
21
- );
22
- }
23
-
24
- function isPng(buffer: Buffer): boolean {
25
- return buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR");
26
- }
27
-
28
- function isAnimatedPng(buffer: Buffer): boolean {
29
- let offset = PNG_SIGNATURE.length;
30
- while (offset + 8 <= buffer.length) {
31
- const chunkLength = readUint32BE(buffer, offset);
32
- const chunkTypeOffset = offset + 4;
33
- if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) return true;
34
- if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) return false;
35
- const nextOffset = offset + 8 + chunkLength + 4;
36
- if (nextOffset <= offset || nextOffset > buffer.length) return false;
37
- offset = nextOffset;
38
- }
39
- return false;
40
- }
41
-
42
- export function isSupportedImageBuffer(buffer: Buffer): boolean {
43
- if (startsWithBytes(buffer, [0xff, 0xd8, 0xff])) return buffer[3] !== 0xf7;
44
- if (startsWithBytes(buffer, PNG_SIGNATURE)) return isPng(buffer) && !isAnimatedPng(buffer);
45
- if (startsWithAscii(buffer, 0, "GIF")) return true;
46
- return startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP");
47
- }