pi-readseek 0.4.17 → 0.4.19

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.17",
3
+ "version": "0.4.19",
4
4
  "description": "Pi extension for readseek-backed hash-anchored read/edit/grep, structural code maps, structural search, and file exploration",
5
5
  "type": "module",
6
6
  "exports": {
package/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 {
@@ -121,7 +121,11 @@ export function formatHashlineDisplay(lineNumber: number, content: string): stri
121
121
  export function parseLineRef(ref: string): { line: number; hash: string; content?: string } {
122
122
  const contentMatch = ref.match(/^[^|]*\|(.*)$/);
123
123
  const contentAfterPipe = contentMatch ? contentMatch[1] : undefined;
124
- const cleaned = ref.replace(/\|.*$/, "").replace(/ {2}.*$/, "").trim();
124
+ const cleaned = ref
125
+ .replace(/\|.*$/, "") // drop |content suffix
126
+ .replace(/^[\s>]+/, "") // drop leading gutter: >> / >>> / indentation
127
+ .replace(/ {2}.*$/, "") // drop trailing " comment"
128
+ .trim();
125
129
  const normalized = cleaned.replace(/\s*:\s*/, ":");
126
130
  const match = normalized.match(new RegExp(`^(\\d+):([0-9a-fA-F]{${HASH_LEN}})$`));
127
131
  if (!match) throw new Error(`Invalid line reference "${ref}". Expected "LINE:HASH" (e.g. "5:abc").`);
@@ -382,7 +386,7 @@ function parseHashlineEditItem(edit: HashlineEditItem): ParsedEdit {
382
386
  if ("insert_after" in edit) {
383
387
  return {
384
388
  spec: { kind: "insertAfter", after: parseLineRef(edit.insert_after.anchor) },
385
- dstLines: stripNewLinePrefixes(splitDst(edit.insert_after.new_text ?? edit.insert_after.text ?? "")),
389
+ dstLines: stripNewLinePrefixes(splitDst(edit.insert_after.new_text ?? "")),
386
390
  };
387
391
  }
388
392
  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 } });
@@ -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
+ }
@@ -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();