pi-readseek 0.4.16 → 0.4.18

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/index.ts CHANGED
@@ -2,10 +2,11 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { registerReadTool } from "./src/read.js";
3
3
  import { registerEditTool } from "./src/edit.js";
4
4
  import { registerGrepTool } from "./src/grep.js";
5
- import { registerSgTool, isSgAvailable } from "./src/sg.js";
5
+ import { registerSgTool } from "./src/sg.js";
6
6
  import { registerRefsTool } from "./src/refs.js";
7
7
  import { registerWriteTool } from "./src/write.js";
8
8
  import { SessionAnchors } from "./src/session-anchors.js";
9
+ import { isReadSeekAvailable } from "./src/readseek-client.js";
9
10
 
10
11
  export default function piReadSeekExtension(pi: ExtensionAPI): void {
11
12
  const sessionAnchors = new SessionAnchors();
@@ -14,10 +15,10 @@ export default function piReadSeekExtension(pi: ExtensionAPI): void {
14
15
 
15
16
  registerReadTool(pi, { onSuccessfulRead: markAnchored });
16
17
  registerEditTool(pi, { wasReadInSession: hasFreshAnchors });
17
- const sgAvailable = isSgAvailable();
18
- const searchGuideline = sgAvailable
18
+ const searchAvailable = isReadSeekAvailable();
19
+ const searchGuideline = searchAvailable
19
20
  ? "Use grep summary for counts; use search for structural code patterns."
20
- : "Use grep summary for counts; install @jarkkojs/readseek to enable search.";
21
+ : "Use grep summary for counts; search is unavailable (readseek native backend not loaded).";
21
22
 
22
23
  registerGrepTool(pi, { searchGuideline, onFileAnchored: markAnchored });
23
24
  registerSgTool(pi, { onFileAnchored: markAnchored });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
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.20",
42
+ "@jarkkojs/readseek": "^0.4.21",
43
43
  "diff": "^8.0.3",
44
44
  "xxhash-wasm": "^1.1.0"
45
45
  },
package/src/edit.ts CHANGED
@@ -12,6 +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
16
  import { buildEditOutput } from "./edit-output.js";
16
17
  import { classifyEdit, isDifftAvailable, runDifftastic } from "./edit-classify.js";
17
18
  import type { SemanticSummary } from "./readseek-value.js";
@@ -26,7 +27,7 @@ import { buildDiffData, type DiffBlockRange } from "./diff-data.js";
26
27
  import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
27
28
  import { upsertDiffComponent, upsertTextComponent } from "./tui-diff-component.js";
28
29
  import type { FreshAnchorsPredicate } from "./tool-types.js";
29
- import { registerReadSeekTool } from "./register-tool.js";
30
+ import { filePathParam, registerReadSeekTool } from "./register-tool.js";
30
31
 
31
32
  import { resolveEditDiffDisplay } from "./readseek-settings.js";
32
33
 
@@ -46,7 +47,7 @@ const hashlineEditItemSchema = Type.Union([
46
47
  { replace_lines: Type.Object({ start_anchor: Type.String(), end_anchor: Type.String(), new_text: Type.String() }) },
47
48
  { additionalProperties: true },
48
49
  ),
49
- Type.Object({ insert_after: Type.Object({ anchor: Type.String(), new_text: Type.String(), text: Type.Optional(Type.String()) }) }, { additionalProperties: true }),
50
+ Type.Object({ insert_after: Type.Object({ anchor: Type.String(), new_text: Type.String() }) }, { additionalProperties: true }),
50
51
  Type.Object(
51
52
  { replace: Type.Object({ old_text: Type.String(), new_text: Type.String(), all: Type.Optional(Type.Boolean()), fuzzy: Type.Optional(Type.Boolean()) }) },
52
53
  { additionalProperties: true },
@@ -55,18 +56,11 @@ const hashlineEditItemSchema = Type.Union([
55
56
  { replace_symbol: Type.Object({ symbol: Type.String(), new_body: Type.String() }) },
56
57
  { additionalProperties: true },
57
58
  ),
58
- Type.Object(
59
- { old_text: Type.String(), new_text: Type.String() },
60
- {
61
- additionalProperties: true,
62
- description: "Do not use — Wrap as { replace: {old_text, new_text} }.",
63
- },
64
- ),
65
59
  ]);
66
60
 
67
61
  const hashlineEditSchema = Type.Object(
68
62
  {
69
- path: Type.String({ description: "File path" }),
63
+ path: filePathParam(),
70
64
  edits: Type.Optional(Type.Array(hashlineEditItemSchema, { description: "Edit operations" })),
71
65
  postEditVerify: Type.Optional(Type.Boolean({
72
66
  description: "Verify persisted content after write",
@@ -112,17 +106,12 @@ function buildEditError(
112
106
 
113
107
  function mapEditFileError(err: any, filePath: string, displayPath: string, phase: "read" | "write"): ReturnType<typeof buildEditError> {
114
108
  const code = err?.code;
115
- if (code === "EISDIR") {
116
- return buildEditError(filePath, "path-is-directory",
117
- `Path is a directory: ${displayPath}`,
118
- `Use ls(${JSON.stringify(displayPath)}) to inspect directories.`);
119
- }
120
- if (code === "ENOENT") {
121
- return buildEditError(filePath, "file-not-found",
122
- `${phase === "write" ? "Failed to write file" : "File not found"}: ${displayPath}`);
109
+ if (code === "ENOENT" && phase === "write") {
110
+ return buildEditError(filePath, "file-not-found", `Failed to write file: ${displayPath}`);
123
111
  }
124
- if (code === "EACCES" || code === "EPERM") {
125
- return buildEditError(filePath, "permission-denied", `Permission denied: ${displayPath}`);
112
+ const fsError = classifyFsError(err, displayPath);
113
+ if (fsError) {
114
+ return buildEditError(filePath, fsError.code, fsError.message, fsError.hint);
126
115
  }
127
116
  const prefix = phase === "write" ? "Failed to write file" : "File not readable";
128
117
  const message = `${prefix}: ${displayPath}${err?.message ? ` — ${err.message}` : ""}`;
@@ -0,0 +1,30 @@
1
+ export type FsErrorCode = "path-is-directory" | "permission-denied" | "file-not-found";
2
+
3
+ export interface FsErrorInfo {
4
+ code: FsErrorCode;
5
+ message: string;
6
+ hint?: string;
7
+ }
8
+
9
+ /**
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.
13
+ */
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
+ }
30
+ }
@@ -22,7 +22,7 @@ export function formatGrepCallText(
22
22
  };
23
23
  }
24
24
 
25
- const GREP_TRUNCATION_THRESHOLD = 50;
25
+ export const GREP_TRUNCATION_THRESHOLD = 50;
26
26
 
27
27
  interface GrepResultTextInput {
28
28
  totalMatches: number;
@@ -56,7 +56,7 @@ export function formatGrepResultText(input: GrepResultTextInput): GrepResultText
56
56
 
57
57
  const { totalMatches, summary, fileCount } = input;
58
58
  const noMatches = totalMatches === 0;
59
- const truncated = totalMatches >= GREP_TRUNCATION_THRESHOLD;
59
+ const truncated = totalMatches > GREP_TRUNCATION_THRESHOLD;
60
60
 
61
61
  const matchWord = totalMatches === 1 ? "match" : "matches";
62
62
  const fileWord = fileCount === 1 ? "file" : "files";
@@ -1,38 +1,25 @@
1
1
  import { buildReadSeekLine } from "./readseek-value.js";
2
2
  import type { GrepOutputEntry, GrepOutputGroup, GrepOutputScopeSymbol, GrepScopeWarning } from "./grep-output.js";
3
- import type { FileMap, FileSymbol } from "./readseek/types.js";
4
-
5
- interface FlatSymbol extends GrepOutputScopeSymbol {
6
- rangeSize: number;
7
- }
8
-
9
- function flattenSymbols(symbols: FileSymbol[], parentName?: string): FlatSymbol[] {
10
- const flattened: FlatSymbol[] = [];
11
- for (const symbol of symbols) {
12
- flattened.push({
13
- name: symbol.name,
14
- kind: symbol.kind,
15
- startLine: symbol.startLine,
16
- endLine: symbol.endLine,
17
- parentName,
18
- rangeSize: symbol.endLine - symbol.startLine,
19
- });
20
- if (symbol.children?.length) flattened.push(...flattenSymbols(symbol.children, symbol.name));
21
- }
22
- return flattened;
23
- }
3
+ import type { FileMap } from "./readseek/types.js";
4
+ import { traverseSymbolTree } from "./readseek/symbol-tree.js";
24
5
 
25
6
  function findEnclosingSymbol(map: FileMap, lineNumber: number): GrepOutputScopeSymbol | null {
26
- const candidates = flattenSymbols(map.symbols)
7
+ const candidates = traverseSymbolTree(map.symbols, (symbol, parentName): GrepOutputScopeSymbol => ({
8
+ name: symbol.name,
9
+ kind: symbol.kind,
10
+ startLine: symbol.startLine,
11
+ endLine: symbol.endLine,
12
+ parentName,
13
+ }))
27
14
  .filter((s) => lineNumber >= s.startLine && lineNumber <= s.endLine)
28
15
  .sort((a, b) => {
29
- if (a.rangeSize !== b.rangeSize) return a.rangeSize - b.rangeSize;
16
+ const rangeA = a.endLine - a.startLine;
17
+ const rangeB = b.endLine - b.startLine;
18
+ if (rangeA !== rangeB) return rangeA - rangeB;
30
19
  if (a.startLine !== b.startLine) return a.startLine - b.startLine;
31
20
  return a.name.localeCompare(b.name);
32
21
  });
33
- if (!candidates.length) return null;
34
- const { rangeSize: _rangeSize, ...symbol } = candidates[0];
35
- return symbol;
22
+ return candidates.length ? candidates[0] : null;
36
23
  }
37
24
 
38
25
  function firstLineNumber(group: GrepOutputGroup): number {
package/src/grep.ts CHANGED
@@ -17,7 +17,7 @@ import { getOrGenerateMap } from "./file-map.js";
17
17
  import { scopeGrepGroupsToSymbols } from "./grep-symbol-scope.js";
18
18
  import { resolveToCwd } from "./path-utils.js";
19
19
  import { throwIfAborted } from "./runtime.js";
20
- import { formatGrepCallText, formatGrepResultText } from "./grep-render-helpers.js";
20
+ import { formatGrepCallText, formatGrepResultText, GREP_TRUNCATION_THRESHOLD } from "./grep-render-helpers.js";
21
21
  import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
22
22
  import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
23
23
  import type { FileAnchoredCallback } from "./tool-types.js";
@@ -89,7 +89,6 @@ function parseGrepOutputLine(line: string):
89
89
  return null;
90
90
  }
91
91
 
92
- const GREP_TRUNCATION_THRESHOLD = 50;
93
92
  const GREP_MAX_MATCHES_PER_FILE = 10;
94
93
 
95
94
  type GrepAnchoredEntry = Extract<GrepOutputEntry, { kind: "match" | "context" }>;
package/src/hashline.ts CHANGED
@@ -14,7 +14,7 @@ import { CONFUSABLE_HYPHENS_RE } from "./confusable-hyphens.js";
14
14
  export type HashlineEditItem =
15
15
  | { set_line: { anchor: string; new_text: string } }
16
16
  | { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
17
- | { insert_after: { anchor: string; new_text: string; text?: string } }
17
+ | { insert_after: { anchor: string; new_text: string } }
18
18
  | { replace: { old_text: string; new_text: string; all?: boolean } };
19
19
 
20
20
  interface HashMismatch {
@@ -382,7 +382,7 @@ function parseHashlineEditItem(edit: HashlineEditItem): ParsedEdit {
382
382
  if ("insert_after" in edit) {
383
383
  return {
384
384
  spec: { kind: "insertAfter", after: parseLineRef(edit.insert_after.anchor) },
385
- dstLines: stripNewLinePrefixes(splitDst(edit.insert_after.new_text ?? edit.insert_after.text ?? "")),
385
+ dstLines: stripNewLinePrefixes(splitDst(edit.insert_after.new_text ?? "")),
386
386
  };
387
387
  }
388
388
  throw new Error("replace edits are applied separately");
@@ -94,7 +94,7 @@ function isReplaceEdit(edit: unknown): edit is ReplaceEdit {
94
94
  type AnchorEdit =
95
95
  | { set_line: { anchor: string; new_text: string } }
96
96
  | { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
97
- | { insert_after: { anchor: string; new_text: string; text?: string } };
97
+ | { insert_after: { anchor: string; new_text: string } };
98
98
 
99
99
  function isAnchorEdit(edit: unknown): edit is AnchorEdit {
100
100
  return !!edit && typeof edit === "object" && ("set_line" in edit || "replace_lines" in edit || "insert_after" in edit);
@@ -1,6 +1,7 @@
1
1
  import { SymbolKind } from "./readseek/enums.js";
2
2
  import type { SymbolMatch } from "./readseek/symbol-lookup.js";
3
3
  import type { FileMap, FileSymbol } from "./readseek/types.js";
4
+ import { traverseSymbolTree } from "./readseek/symbol-tree.js";
4
5
 
5
6
  export interface LocalBundleSupport {
6
7
  symbol: SymbolMatch;
@@ -23,19 +24,6 @@ const CONTAINER_KINDS = new Set<SymbolKind>([
23
24
  SymbolKind.Namespace,
24
25
  ]);
25
26
 
26
- function flattenSymbols(symbols: FileSymbol[], parentName?: string): FlatSymbol[] {
27
- const flattened: FlatSymbol[] = [];
28
-
29
- for (const symbol of symbols) {
30
- flattened.push({ symbol, parentName });
31
- if (symbol.children?.length) {
32
- flattened.push(...flattenSymbols(symbol.children, symbol.name));
33
- }
34
- }
35
-
36
- return flattened;
37
- }
38
-
39
27
  export function buildLocalBundle(
40
28
  fileMap: FileMap,
41
29
  requested: SymbolMatch,
@@ -44,7 +32,7 @@ export function buildLocalBundle(
44
32
  const requestedText = allLines.slice(requested.startLine - 1, requested.endLine).join("\n");
45
33
  const identifiers = new Set(requestedText.match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) ?? []);
46
34
 
47
- const candidates = flattenSymbols(fileMap.symbols).filter(({ symbol }) => {
35
+ const candidates = traverseSymbolTree(fileMap.symbols, (symbol, parentName): FlatSymbol => ({ symbol, parentName })).filter(({ symbol }) => {
48
36
  if (CONTAINER_KINDS.has(symbol.kind as SymbolKind)) return false;
49
37
  return !(symbol.name === requested.name && symbol.startLine === requested.startLine && symbol.endLine === requested.endLine);
50
38
  });
package/src/read.ts CHANGED
@@ -17,6 +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
21
  import { getOrGenerateMap } from "./file-map.js";
21
22
  import { formatFileMapWithBudget } from "./readseek/formatter.js";
22
23
  import { findSymbol, type SymbolMatch } from "./readseek/symbol-lookup.js";
@@ -29,7 +30,7 @@ import { readseekRead, readseekDetect, type ReadSeekDetection } from "./readseek
29
30
  import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
30
31
  import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
31
32
  import type { FileAnchoredCallback } from "./tool-types.js";
32
- import { optionalIntOrString, registerReadSeekTool } from "./register-tool.js";
33
+ import { filePathParam, mapParam, optionalIntOrString, registerReadSeekTool } from "./register-tool.js";
33
34
 
34
35
  const READ_PROMPT_METADATA = defineToolPromptMetadata({
35
36
  promptUrl: new URL("../prompts/read.md", import.meta.url),
@@ -144,17 +145,12 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
144
145
  rawBuffer = await fsReadFile(absolutePath);
145
146
  } catch (err: any) {
146
147
  const code = err?.code;
147
- if (code === "EISDIR") {
148
- const message = `Path is a directory: ${rawPath}`;
149
- return buildToolErrorResult("read", "path-is-directory", message, { path: rawParams.path, hint: `Use ls(${JSON.stringify(rawPath)}) to inspect directories.` });
150
- }
151
- if (code === "EACCES" || code === "EPERM") {
152
- const message = `Permission denied — cannot access: ${rawPath}`;
153
- return buildToolErrorResult("read", "permission-denied", message, { path: rawParams.path });
154
- }
155
- if (code === "ENOENT") {
156
- const message = `File not found: ${rawPath}`;
157
- return buildToolErrorResult("read", "file-not-found", message, { path: rawParams.path });
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
+ });
158
154
  }
159
155
  const message = `File not readable: ${rawPath}${err?.message ? ` — ${err.message}` : ""}`;
160
156
  return buildToolErrorResult("read", "fs-error", message, { path: rawParams.path, details: { fsCode: code, fsMessage: err?.message } });
@@ -162,23 +158,23 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
162
158
 
163
159
  const hasBinaryContent = looksLikeBinary(rawBuffer);
164
160
  if (hasBinaryContent) {
165
- // Images are always binary; classify and OCR in a single readseek detect call.
161
+ // Images are always binary; classify and transcribe in a single readseek detect call.
166
162
  let detection: ReadSeekDetection | undefined;
167
163
  try {
168
- detection = await readseekDetect(absolutePath, { ocr: true, signal });
164
+ detection = await readseekDetect(absolutePath, { transcribe: true, signal });
169
165
  } catch {
170
166
  // detect unavailable — fall through to binary-as-text handling below
171
167
  }
172
168
  if (detection?.type === "image") {
173
169
  const builtinRead = createReadTool(cwd);
174
170
  const builtinResult = await builtinRead.execute(toolCallId, p, signal, onUpdate);
175
- const ocrText = detection.ocr?.text?.trim();
176
- if (ocrText) {
171
+ const transcript = detection.transcribe?.text?.trim();
172
+ if (transcript) {
177
173
  return succeed({
178
174
  ...builtinResult,
179
175
  content: [
180
176
  ...(builtinResult.content ?? []),
181
- { type: "text" as const, text: `OCR text extracted from image:\n${ocrText}` },
177
+ { type: "text" as const, text: `Transcribed text from image:\n${transcript}` },
182
178
  ],
183
179
  });
184
180
  }
@@ -450,11 +446,11 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
450
446
  promptSnippet: READ_PROMPT_METADATA.promptSnippet,
451
447
  promptGuidelines: READ_PROMPT_METADATA.promptGuidelines,
452
448
  parameters: Type.Object({
453
- path: Type.String({ description: "File path" }),
449
+ path: filePathParam(),
454
450
  offset: optionalIntOrString("Start line (1-indexed)"),
455
451
  limit: optionalIntOrString("Max lines"),
456
452
  symbol: Type.Optional(Type.String({ description: "Symbol name to read" })),
457
- map: Type.Optional(Type.Boolean({ description: "Append structural map" })),
453
+ map: mapParam(),
458
454
  bundle: Type.Optional(
459
455
  Type.Literal("local", {
460
456
  description: "Include same-file local support",
@@ -1,5 +1,6 @@
1
1
  import type { FileMap, FileSymbol } from "./types.js";
2
2
  import type { SymbolKind } from "./enums.js";
3
+ import { traverseSymbolTree } from "./symbol-tree.js";
3
4
 
4
5
  export interface SymbolMatch {
5
6
  name: string;
@@ -40,20 +41,7 @@ function toMatches(candidates: SymbolCandidate[]): SymbolMatch[] {
40
41
  }
41
42
 
42
43
  function flattenSymbols(symbols: FileSymbol[]): SymbolCandidate[] {
43
- const flattened: SymbolCandidate[] = [];
44
-
45
- const visit = (symbol: FileSymbol, parentName?: string): void => {
46
- flattened.push({ symbol, parentName });
47
- for (const child of symbol.children ?? []) {
48
- visit(child, symbol.name);
49
- }
50
- };
51
-
52
- for (const symbol of symbols) {
53
- visit(symbol);
54
- }
55
-
56
- return flattened;
44
+ return traverseSymbolTree(symbols, (symbol, parentName) => ({ symbol, parentName }));
57
45
  }
58
46
 
59
47
  function resolveDotPath(symbols: FileSymbol[], parts: string[]): SymbolCandidate[] {
@@ -0,0 +1,21 @@
1
+ import type { FileSymbol } from "./types.js";
2
+
3
+ /**
4
+ * Depth-first walk over a readseek symbol tree. {@link visit} is called for
5
+ * every symbol with the enclosing symbol's name as `parentName` (undefined at
6
+ * the top level); its results are returned flattened in pre-order.
7
+ */
8
+ export function traverseSymbolTree<T>(
9
+ symbols: FileSymbol[],
10
+ visit: (symbol: FileSymbol, parentName?: string) => T,
11
+ ): T[] {
12
+ const result: T[] = [];
13
+ const walk = (nodes: FileSymbol[], parentName?: string) => {
14
+ for (const symbol of nodes) {
15
+ result.push(visit(symbol, parentName));
16
+ if (symbol.children?.length) walk(symbol.children, symbol.name);
17
+ }
18
+ };
19
+ walk(symbols);
20
+ return result;
21
+ }
@@ -108,14 +108,14 @@ export interface ReadSeekCheckOutput {
108
108
  diagnostics: ReadSeekDiagnostic[];
109
109
  }
110
110
 
111
- export interface ReadSeekOcrLine {
111
+ export interface ReadSeekTranscriptRegion {
112
112
  text: string;
113
- bbox: [number, number, number, number];
113
+ quad: [number, number, number, number, number, number, number, number];
114
114
  }
115
115
 
116
- export interface ReadSeekOcrText {
116
+ export interface ReadSeekTranscript {
117
117
  text: string;
118
- lines: ReadSeekOcrLine[];
118
+ regions: ReadSeekTranscriptRegion[];
119
119
  }
120
120
 
121
121
  export type ReadSeekDetection =
@@ -136,7 +136,7 @@ export type ReadSeekDetection =
136
136
  width: number;
137
137
  height: number;
138
138
  animated: boolean;
139
- ocr?: ReadSeekOcrText;
139
+ transcribe?: ReadSeekTranscript;
140
140
  }
141
141
  | {
142
142
  type: "binary";
@@ -641,21 +641,21 @@ export async function readseekCheck(
641
641
  );
642
642
  }
643
643
 
644
- function parseOcrText(value: unknown): ReadSeekOcrText | undefined {
644
+ function parseTranscript(value: unknown): ReadSeekTranscript | undefined {
645
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");
646
+ if (typeof value !== "object") throw new Error("invalid readseek detect transcribe");
647
+ const transcribe = value as Record<string, unknown>;
648
+ if (!Array.isArray(transcribe.regions)) throw new Error("invalid readseek detect transcribe.regions");
649
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");
650
+ text: requireString(transcribe.text, "transcribe.text"),
651
+ regions: transcribe.regions.map((region) => {
652
+ if (!region || typeof region !== "object") throw new Error("invalid readseek detect transcribe region");
653
+ const item = region as Record<string, unknown>;
654
+ const quad = item.quad;
655
+ if (!Array.isArray(quad) || quad.length !== 8) throw new Error("invalid readseek detect transcribe quad");
656
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],
657
+ text: requireString(item.text, "transcribe.region.text"),
658
+ quad: quad.map((n, i) => requireNumber(n, `transcribe.region.quad[${i}]`)) as ReadSeekTranscriptRegion["quad"],
659
659
  };
660
660
  }),
661
661
  };
@@ -687,7 +687,7 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
687
687
  width: requireNumber(output.width, "width"),
688
688
  height: requireNumber(output.height, "height"),
689
689
  animated: requireBoolean(output.animated, "animated"),
690
- ocr: parseOcrText(output.ocr),
690
+ transcribe: parseTranscript(output.transcribe),
691
691
  };
692
692
  case "binary":
693
693
  case "text":
@@ -699,10 +699,10 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
699
699
 
700
700
  export async function readseekDetect(
701
701
  filePath: string,
702
- options: { ocr?: boolean; signal?: AbortSignal } = {},
702
+ options: { transcribe?: boolean; signal?: AbortSignal } = {},
703
703
  ): Promise<ReadSeekDetection> {
704
704
  const args = ["detect"];
705
- if (options.ocr) args.push("--ocr");
705
+ if (options.transcribe) args.push("--transcribe");
706
706
  args.push(filePath);
707
707
  return parseDetectOutput(await runReadSeek(args, { signal: options.signal }));
708
708
  }
@@ -33,7 +33,7 @@ export function validateIgnoredRequiresOthers(
33
33
  params: { others?: boolean; ignored?: boolean },
34
34
  ): ToolErrorResult | null {
35
35
  if (params.ignored && !params.others) {
36
- return buildToolErrorResult(tool, "invalid-parameter", `Error: ${tool} parameter 'ignored' requires 'others'`);
36
+ return buildToolErrorResult(tool, "invalid-parameter", `${tool} parameter 'ignored' requires 'others'`);
37
37
  }
38
38
  return null;
39
39
  }
@@ -86,6 +86,26 @@ export function renderReadSeekLines(lines: ReadSeekLine[]): string {
86
86
  return lines.map(renderReadSeekLine).join("\n");
87
87
  }
88
88
 
89
+ /**
90
+ * Render the anchored-file block format shared by the `search` and `refs`
91
+ * tools: a `--- <displayPath> ---` header per file followed by one
92
+ * `>><anchor>|<display>` line per match. {@link lineSuffix} appends tool
93
+ * specific trailers (e.g. refs' enclosing-symbol annotation).
94
+ */
95
+ export function formatAnchoredFileBlocks<T extends ReadSeekLine>(
96
+ files: Array<{ displayPath: string; lines: T[] }>,
97
+ lineSuffix?: (line: T) => string,
98
+ ): string {
99
+ const blocks: string[] = [];
100
+ for (const file of files) {
101
+ blocks.push(`--- ${file.displayPath} ---`);
102
+ for (const line of file.lines) {
103
+ blocks.push(`>>${line.anchor}|${line.display}${lineSuffix?.(line) ?? ""}`);
104
+ }
105
+ }
106
+ return blocks.join("\n");
107
+ }
108
+
89
109
  export function buildReadSeekWarning(
90
110
  code: string,
91
111
  message: string,
@@ -1,4 +1,4 @@
1
- import type { ReadSeekLine } from "./readseek-value.js";
1
+ import { formatAnchoredFileBlocks, type ReadSeekLine } from "./readseek-value.js";
2
2
 
3
3
  export interface RefsOutputLine extends ReadSeekLine {
4
4
  enclosingSymbol?: string;
@@ -34,17 +34,8 @@ export function buildRefsOutput(input: BuildRefsOutputInput): RefsOutputResult {
34
34
  };
35
35
  }
36
36
 
37
- const blocks: string[] = [];
38
- for (const file of input.files) {
39
- blocks.push(`--- ${file.displayPath} ---`);
40
- for (const line of file.lines) {
41
- const suffix = line.enclosingSymbol ? ` (in ${line.enclosingSymbol})` : "";
42
- blocks.push(`>>${line.anchor}|${line.display}${suffix}`);
43
- }
44
- }
45
-
46
37
  return {
47
- text: blocks.join("\n"),
38
+ text: formatAnchoredFileBlocks(input.files, (line) => (line.enclosingSymbol ? ` (in ${line.enclosingSymbol})` : "")),
48
39
  readseekValue: {
49
40
  tool: "refs",
50
41
  files: input.files.map((file) => ({
package/src/refs.ts CHANGED
@@ -86,7 +86,7 @@ export async function executeRefs(opts: ExecuteRefsOptions): Promise<any> {
86
86
  const ignoredError = validateIgnoredRequiresOthers("refs", p);
87
87
  if (ignoredError) return ignoredError;
88
88
  if (p.scope && p.line === undefined) {
89
- return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'scope' requires 'line'");
89
+ return buildToolErrorResult("refs", "invalid-parameter", "refs parameter 'scope' requires 'line'");
90
90
  }
91
91
  const searchPath = resolveToCwd(p.path ?? ".", cwd);
92
92
 
@@ -10,6 +10,16 @@ export function optionalIntOrString(description: string) {
10
10
  return Type.Optional(Type.Union([Type.Number({ description }), Type.String({ description })]));
11
11
  }
12
12
 
13
+ /** Required file-path parameter shared by the read, edit, and write tools. */
14
+ export function filePathParam() {
15
+ return Type.String({ description: "File path" });
16
+ }
17
+
18
+ /** Optional structural-map toggle shared by the read and write tools. */
19
+ export function mapParam() {
20
+ return Type.Optional(Type.Boolean({ description: "Append structural map" }));
21
+ }
22
+
13
23
  export type ReadSeekToolPolicy = "read-only" | "mutating";
14
24
 
15
25
  export type ReadSeekToolExposure = "safe-by-default" | "opt-in" | "not-safe-by-default";
package/src/sg-output.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReadSeekLine, ReadSeekRange } from "./readseek-value.js";
1
+ import { formatAnchoredFileBlocks, type ReadSeekLine, type ReadSeekRange } from "./readseek-value.js";
2
2
 
3
3
  export interface SgOutputFile {
4
4
  displayPath: string;
@@ -36,16 +36,8 @@ export function buildSgOutput(input: BuildSgOutputInput): SgOutputResult {
36
36
  };
37
37
  }
38
38
 
39
- const blocks: string[] = [];
40
- for (const file of input.files) {
41
- blocks.push(`--- ${file.displayPath} ---`);
42
- for (const line of file.lines) {
43
- blocks.push(`>>${line.anchor}|${line.display}`);
44
- }
45
- }
46
-
47
39
  return {
48
- text: blocks.join("\n"),
40
+ text: formatAnchoredFileBlocks(input.files),
49
41
  readseekValue: {
50
42
  tool: "search",
51
43
  files: input.files.map((file) => ({
package/src/sg.ts CHANGED
@@ -7,7 +7,7 @@ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
7
7
  import { buildReadSeekLineWithHash, buildToolErrorResult, type ReadSeekLine } from "./readseek-value.js";
8
8
  import { resolveToCwd } from "./path-utils.js";
9
9
  import { statSearchPathOrError } from "./stat-search-path.js";
10
- import { classifyReadSeekFailure, isReadSeekAvailable, readseekSearch, type ReadSeekHashline, type ReadSeekSearchFileOutput } from "./readseek-client.js";
10
+ import { classifyReadSeekFailure, readseekSearch, type ReadSeekHashline, type ReadSeekSearchFileOutput } from "./readseek-client.js";
11
11
  import { buildSgOutput } from "./sg-output.js";
12
12
  import type { FileAnchoredCallback } from "./tool-types.js";
13
13
  import { langParam, readseekGitSearchParams, searchPathParam, validateIgnoredRequiresOthers } from "./readseek-params.js";
@@ -52,10 +52,6 @@ const SG_PROMPT_METADATA = defineToolPromptMetadata({
52
52
  promptSnippet: "Search code structurally with readseek and return edit-ready anchors",
53
53
  });
54
54
 
55
- export function isSgAvailable(): boolean {
56
- return isReadSeekAvailable();
57
- }
58
-
59
55
  interface SgToolOptions {
60
56
  onFileAnchored?: FileAnchoredCallback;
61
57
  }
@@ -23,12 +23,12 @@ export async function statSearchPathOrError(
23
23
  const display = rawPath ?? ".";
24
24
  const path = rawPath ?? searchPath;
25
25
  if (err?.code === "ENOENT") {
26
- return { ok: false, error: buildToolErrorResult(tool, "path-not-found", `Error: path '${display}' does not exist`, { path }) };
26
+ return { ok: false, error: buildToolErrorResult(tool, "path-not-found", `Path '${display}' does not exist`, { path }) };
27
27
  }
28
28
  if (err?.code === "EACCES" || err?.code === "EPERM") {
29
- return { ok: false, error: buildToolErrorResult(tool, "permission-denied", `Error: permission denied for path '${display}'`, { path }) };
29
+ return { ok: false, error: buildToolErrorResult(tool, "permission-denied", `Permission denied for path '${display}'`, { path }) };
30
30
  }
31
- const message = `Error: could not access path '${display}': ${err?.message ?? String(err)}`;
31
+ const message = `Could not access path '${display}': ${err?.message ?? String(err)}`;
32
32
  return { ok: false, error: buildToolErrorResult(tool, "fs-error", message, { path, details: { fsCode: err?.code, fsMessage: err?.message } }) };
33
33
  }
34
34
  }
package/src/write.ts CHANGED
@@ -9,6 +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
13
  import { getOrGenerateMap } from "./file-map.js";
13
14
  import { formatFileMapWithBudget } from "./readseek/formatter.js";
14
15
 
@@ -19,7 +20,7 @@ import { buildDiffData, type DiffData } from "./diff-data.js";
19
20
  import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
20
21
  import { upsertDiffComponent, upsertTextComponent } from "./tui-diff-component.js";
21
22
  import type { FileAnchoredCallback } from "./tool-types.js";
22
- import { registerReadSeekTool } from "./register-tool.js";
23
+ import { filePathParam, mapParam, registerReadSeekTool } from "./register-tool.js";
23
24
 
24
25
  const WRITE_PENDING_PREVIEW_STATE_KEY = "hashline-write-pending-preview";
25
26
 
@@ -120,8 +121,9 @@ export interface WriteToolOptions {
120
121
  }
121
122
 
122
123
  type MappedFsError = {
123
- code: "permission-denied" | "path-is-directory" | "fs-error";
124
+ code: FsErrorCode | "fs-error";
124
125
  message: string;
126
+ hint?: string;
125
127
  includeMeta: boolean;
126
128
  };
127
129
 
@@ -129,20 +131,6 @@ function mapFsWriteError(err: any, path: string): MappedFsError {
129
131
  const phase: "mkdir" | "write" | undefined = err?.__phase;
130
132
  const fsCode = err?.code as string | undefined;
131
133
 
132
- if (fsCode === "EACCES" || fsCode === "EPERM") {
133
- return {
134
- code: "permission-denied",
135
- message: `Permission denied — cannot write: ${path}`,
136
- includeMeta: false,
137
- };
138
- }
139
- if (fsCode === "EISDIR") {
140
- return {
141
- code: "path-is-directory",
142
- message: `Path is a directory — cannot overwrite: ${path}`,
143
- includeMeta: false,
144
- };
145
- }
146
134
  if (fsCode === "ENOENT" && phase === "mkdir") {
147
135
  return {
148
136
  code: "fs-error",
@@ -164,6 +152,10 @@ function mapFsWriteError(err: any, path: string): MappedFsError {
164
152
  includeMeta: true,
165
153
  };
166
154
  }
155
+ const fsError = classifyFsError(err, path);
156
+ if (fsError) {
157
+ return { code: fsError.code, message: fsError.message, hint: fsError.hint, includeMeta: false };
158
+ }
167
159
  return {
168
160
  code: "fs-error",
169
161
  message: `Error writing ${path}: ${err?.message ?? String(err)}`,
@@ -175,6 +167,7 @@ function buildWriteFsErrorResult(err: any, absolutePath: string) {
175
167
  const mapped = mapFsWriteError(err, absolutePath);
176
168
  return buildToolErrorResult("write", mapped.code, mapped.message, {
177
169
  path: absolutePath,
170
+ hint: mapped.hint,
178
171
  extra: { lines: [], warnings: [] },
179
172
  details: mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
180
173
  });
@@ -321,9 +314,9 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
321
314
  promptSnippet: WRITE_PROMPT_METADATA.promptSnippet,
322
315
  promptGuidelines: WRITE_PROMPT_METADATA.promptGuidelines,
323
316
  parameters: Type.Object({
324
- path: Type.String({ description: "File path" }),
317
+ path: filePathParam(),
325
318
  content: Type.String({ description: "File content" }),
326
- map: Type.Optional(Type.Boolean({ description: "Append structural map" })),
319
+ map: mapParam(),
327
320
  }),
328
321
  async execute(_toolCallId: string, params: { path: string; content: string; map?: boolean }, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any): Promise<any> {
329
322
  const cwd = ctx?.cwd ?? process.cwd();