pi-readseek 0.4.13 → 0.4.14

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.14",
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.19",
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?.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,35 @@ export interface ReadSeekCheckOutput {
108
108
  diagnostics: ReadSeekDiagnostic[];
109
109
  }
110
110
 
111
+ export interface ReadSeekImageInfo {
112
+ format: string;
113
+ width: number;
114
+ height: number;
115
+ animated: boolean;
116
+ }
117
+
118
+ export interface ReadSeekOcrLine {
119
+ text: string;
120
+ bbox: [number, number, number, number];
121
+ }
122
+
123
+ export interface ReadSeekOcrText {
124
+ text: string;
125
+ lines: ReadSeekOcrLine[];
126
+ }
127
+
128
+ export interface ReadSeekDetection {
129
+ file: string;
130
+ language: string;
131
+ engine: string | null;
132
+ supported: boolean;
133
+ binary: boolean;
134
+ mime: string | null;
135
+ syntax: string | null;
136
+ image: ReadSeekImageInfo | null;
137
+ ocr?: ReadSeekOcrText;
138
+ }
139
+
111
140
  interface ReadSeekSearchOptions {
112
141
  language?: string;
113
142
  cached?: boolean;
@@ -363,6 +392,11 @@ function requireString(value: unknown, field: string): string {
363
392
  return value;
364
393
  }
365
394
 
395
+ function requireBoolean(value: unknown, field: string): boolean {
396
+ if (typeof value !== "boolean") throw new Error(`invalid readseek ${field}: expected boolean`);
397
+ return value;
398
+ }
399
+
366
400
  function parseReadOutput(value: unknown): ReadSeekReadOutput {
367
401
  if (!value || typeof value !== "object") throw new Error("invalid readseek read output");
368
402
  const output = value as Record<string, unknown>;
@@ -520,6 +554,11 @@ function optionalString(value: unknown, field: string): string | undefined {
520
554
  return requireString(value, field);
521
555
  }
522
556
 
557
+ function nullableString(value: unknown, field: string): string | null {
558
+ if (value === undefined || value === null) return null;
559
+ return requireString(value, field);
560
+ }
561
+
523
562
  function parseRefsOutput(value: unknown): ReadSeekRefsOutput {
524
563
  if (!value || typeof value !== "object") throw new Error("invalid readseek refs output");
525
564
  const output = value as Record<string, unknown>;
@@ -594,3 +633,61 @@ export async function readseekCheck(
594
633
  await runReadSeek(["check", "--stdin", filePath], { signal: options.signal, stdin: content }),
595
634
  );
596
635
  }
636
+
637
+ function parseImageInfo(value: unknown): ReadSeekImageInfo | null {
638
+ if (value === undefined || value === null) return null;
639
+ if (typeof value !== "object") throw new Error("invalid readseek detect image");
640
+ const image = value as Record<string, unknown>;
641
+ return {
642
+ format: requireString(image.format, "image.format"),
643
+ width: requireNumber(image.width, "image.width"),
644
+ height: requireNumber(image.height, "image.height"),
645
+ animated: requireBoolean(image.animated, "image.animated"),
646
+ };
647
+ }
648
+
649
+ function parseOcrText(value: unknown): ReadSeekOcrText | undefined {
650
+ if (value === undefined || value === null) return undefined;
651
+ if (typeof value !== "object") throw new Error("invalid readseek detect ocr");
652
+ const ocr = value as Record<string, unknown>;
653
+ if (!Array.isArray(ocr.lines)) throw new Error("invalid readseek detect ocr.lines");
654
+ return {
655
+ text: requireString(ocr.text, "ocr.text"),
656
+ lines: ocr.lines.map((line) => {
657
+ if (!line || typeof line !== "object") throw new Error("invalid readseek detect ocr line");
658
+ const item = line as Record<string, unknown>;
659
+ const bbox = item.bbox;
660
+ if (!Array.isArray(bbox) || bbox.length !== 4) throw new Error("invalid readseek detect ocr bbox");
661
+ return {
662
+ text: requireString(item.text, "ocr.line.text"),
663
+ bbox: bbox.map((n, i) => requireNumber(n, `ocr.line.bbox[${i}]`)) as [number, number, number, number],
664
+ };
665
+ }),
666
+ };
667
+ }
668
+
669
+ function parseDetectOutput(value: unknown): ReadSeekDetection {
670
+ if (!value || typeof value !== "object") throw new Error("invalid readseek detect output");
671
+ const output = value as Record<string, unknown>;
672
+ return {
673
+ file: requireString(output.file, "file"),
674
+ language: requireString(output.language, "language"),
675
+ engine: nullableString(output.engine, "engine"),
676
+ supported: requireBoolean(output.supported, "supported"),
677
+ binary: requireBoolean(output.binary, "binary"),
678
+ mime: nullableString(output.mime, "mime"),
679
+ syntax: nullableString(output.syntax, "syntax"),
680
+ image: parseImageInfo(output.image),
681
+ ocr: parseOcrText(output.ocr),
682
+ };
683
+ }
684
+
685
+ export async function readseekDetect(
686
+ filePath: string,
687
+ options: { ocr?: boolean; signal?: AbortSignal } = {},
688
+ ): Promise<ReadSeekDetection> {
689
+ const args = ["detect"];
690
+ if (options.ocr) args.push("--ocr");
691
+ args.push(filePath);
692
+ return parseDetectOutput(await runReadSeek(args, { signal: options.signal }));
693
+ }
@@ -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
- }