pi-readseek 0.3.23 → 0.4.0

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.3.23",
3
+ "version": "0.4.0",
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.3.20",
42
+ "@jarkkojs/readseek": "^0.4.3",
43
43
  "diff": "^8.0.3",
44
44
  "ignore": "^7.0.5",
45
45
  "picomatch": "^4.0.4",
package/src/edit.ts CHANGED
@@ -20,9 +20,10 @@ import { resolveSyntaxValidateMode, type SyntaxValidateOptions } from "./syntax-
20
20
  import { replaceSymbol } from "./replace-symbol.js";
21
21
  import { buildEditPreviewKey, buildPendingEditPreviewData, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
22
22
  import { buildDiffData, type DiffBlockRange } from "./diff-data.js";
23
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
23
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
24
24
  import { DiffPreviewComponent } from "./tui-diff-component.js";
25
25
  import type { FreshAnchorsPredicate } from "./tool-types.js";
26
+ import { registerReadseekTool } from "./register-tool.js";
26
27
 
27
28
  import { resolveEditDiffDisplay } from "./readseek-settings.js";
28
29
 
@@ -35,14 +36,6 @@ function pendingPreviewLines(summary: string, preview: PendingDiffPreviewResult
35
36
  return { lines: [summary, headerLine], diffData: expanded ? diffData : undefined, headerLabel: preview.data.headerLabel };
36
37
  }
37
38
 
38
- export function wrapWriteError(err: any, path: string): Error {
39
- const code = err?.code;
40
- if (code === "EACCES" || code === "EPERM") {
41
- return new Error(`Permission denied: ${path}`);
42
- }
43
- return new Error(`Failed to write file: ${path}`);
44
- }
45
-
46
39
 
47
40
  const hashlineEditItemSchema = Type.Union([
48
41
  Type.Object({ set_line: Type.Object({ anchor: Type.String(), new_text: Type.String() }) }, { additionalProperties: true }),
@@ -268,16 +261,16 @@ export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
268
261
  }
269
262
  }
270
263
  let preAnchorContent = originalNormalized;
271
- // AC 26: reject anchored edits that target a line inside any replace_symbol
264
+ // reject anchored edits that target a line inside any replace_symbol
272
265
  // pre-replace range. Resolve each target against the ORIGINAL content so the
273
266
  // user-provided anchor line numbers (which reference the file as read) are
274
267
  // compared against the pre-replace coordinates.
275
268
  //
276
- // F2: surface replace_symbol symbol-resolution errors (not-found, ambiguous)
277
- // BEFORE the AC 26 overlap check and before any write (C1 preserved).
269
+ // surface replace_symbol symbol-resolution errors (not-found, ambiguous)
270
+ // before the overlap check and before any write.
278
271
  // Error-precedence order: replace_symbol resolution > anchor-overlap > anchored-edit.
279
272
  //
280
- // AC 4: store successful probe results and reuse them in the apply loop so
273
+ // store successful probe results and reuse them in the apply loop so
281
274
  // readseekMapContent is invoked at most once per replace_symbol edit.
282
275
  const replaceSymbolRanges: { start: number; end: number }[] = [];
283
276
  const rsProbeResults: { type: "ok"; content: string; replacement: string; warnings: string[]; range: { start: number; end: number } }[] = [];
@@ -289,7 +282,7 @@ export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
289
282
  newBody: rs.replace_symbol.new_body,
290
283
  });
291
284
  if (probe.type !== "ok") {
292
- // F2: symbol-resolution errors surface before AC 26 overlap check.
285
+ // symbol-resolution errors surface before the overlap check.
293
286
  return buildEditError(absolutePath, "invalid-edit-variant", probe.message);
294
287
  }
295
288
  rsProbeResults.push(probe);
@@ -348,7 +341,7 @@ export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
348
341
  }
349
342
  }
350
343
  }
351
- // Apply pass: reuse all probe results (AC 4). The probe pass resolved every
344
+ // Apply pass: reuse all probe results. The probe pass resolved every
352
345
  // replace_symbol against originalNormalized; apply those replacements in
353
346
  // reverse source order so original line ranges stay valid and no second
354
347
  // replaceSymbol/readseekMapContent call is needed.
@@ -444,7 +437,7 @@ export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
444
437
  if (regression) {
445
438
  const lines = regression.errorLines.join(", ");
446
439
  const message = `syntax-regression: lines ${lines}`;
447
- // Task 7 (AC 12): block mode aborts with syntax-regression code; file is left untouched.
440
+ // block mode aborts with syntax-regression code; file is left untouched.
448
441
  if (syntaxMode === "block") {
449
442
  return buildEditError(absolutePath, "syntax-regression", message);
450
443
  }
@@ -566,22 +559,17 @@ export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
566
559
 
567
560
 
568
561
  export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}) {
569
- const toolConfig = {
570
- callable: true,
571
- enabled: true,
572
- policy: "mutating" as const,
573
- readOnly: false,
562
+ const tool = registerReadseekTool(pi, {
563
+ policy: "mutating",
574
564
  pythonName: "edit",
575
- defaultExposure: "not-safe-by-default" as const,
576
- };
577
- const tool = {
565
+ defaultExposure: "not-safe-by-default",
566
+ }, {
578
567
  name: "edit",
579
568
  label: "Edit",
580
569
  description: EDIT_PROMPT_METADATA.description,
581
570
  promptSnippet: EDIT_PROMPT_METADATA.promptSnippet,
582
571
  promptGuidelines: EDIT_PROMPT_METADATA.promptGuidelines,
583
572
  parameters: hashlineEditSchema,
584
- ptc: toolConfig,
585
573
  renderShell: "default" as const,
586
574
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
587
575
  return executeEdit({
@@ -645,7 +633,7 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
645
633
  const { isPartial, isError, expanded: baseExpanded, width, context } = resolveRenderResultContext(options, rest);
646
634
 
647
635
  if (isPartial) {
648
- return new Text(clampLinesToWidth([summaryLine("pending edit")], width).join("\n"), 0, 0);
636
+ return renderPendingResult("pending edit", width);
649
637
  }
650
638
 
651
639
  // Extract data from result
@@ -698,8 +686,6 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
698
686
  }
699
687
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
700
688
  },
701
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
702
-
703
- pi.registerTool(tool);
689
+ });
704
690
  return tool;
705
691
  }
package/src/grep.ts CHANGED
@@ -17,8 +17,9 @@ import { throwIfAborted } from "./runtime.js";
17
17
  import { Text } from "@earendil-works/pi-tui";
18
18
  import { formatGrepCallText, formatGrepResultText } from "./grep-render-helpers.js";
19
19
  import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
20
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
20
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
21
21
  import type { FileAnchoredCallback } from "./tool-types.js";
22
+ import { registerReadseekTool } from "./register-tool.js";
22
23
 
23
24
  const GREP_PROMPT_METADATA = defineToolPromptMetadata({
24
25
  promptUrl: new URL("../prompts/grep.md", import.meta.url),
@@ -508,21 +509,15 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
508
509
  }
509
510
 
510
511
  export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}) {
511
- const toolConfig = {
512
- callable: true,
513
- enabled: true,
514
- policy: "read-only" as const,
515
- readOnly: true,
512
+ const tool = registerReadseekTool(pi, {
513
+ policy: "read-only",
516
514
  pythonName: "grep",
517
- defaultExposure: "safe-by-default" as const,
518
- };
519
-
520
- const tool = {
515
+ defaultExposure: "safe-by-default",
516
+ }, {
521
517
  name: "grep",
522
518
  label: "grep",
523
519
  description: GREP_PROMPT_METADATA.description,
524
520
  parameters: grepSchema,
525
- ptc: toolConfig,
526
521
  promptSnippet: GREP_PROMPT_METADATA.promptSnippet,
527
522
  promptGuidelines: options.searchGuideline
528
523
  ? [GREP_PROMPT_METADATA.promptGuidelines[0], options.searchGuideline]
@@ -558,16 +553,12 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
558
553
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
559
554
  const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
560
555
 
561
- if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending search")], width).join("\n"), 0, 0);
556
+ if (isPartial) return renderPendingResult("pending search", width);
562
557
 
563
558
  const content = result.content?.[0];
564
559
  const textContent = content?.type === "text" ? content.text : "";
565
560
 
566
- if (isError || result.isError) {
567
- const firstLine = textContent.split("\n")[0] || "Error";
568
- const body = expanded && textContent ? textContent : firstLine;
569
- return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
570
- }
561
+ if (isError || result.isError) return renderErrorResult(textContent, { expanded, width });
571
562
 
572
563
  const readseekValue = (result.details as any)?.readseekValue as {
573
564
  tool: "grep";
@@ -607,8 +598,6 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
607
598
  }
608
599
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
609
600
  },
610
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
611
-
612
- pi.registerTool(tool);
601
+ });
613
602
  return tool;
614
603
  }
package/src/read.ts CHANGED
@@ -26,8 +26,9 @@ import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
26
26
  import { readseekRead } from "./readseek-client.js";
27
27
  import { Text } from "@earendil-works/pi-tui";
28
28
  import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
29
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
29
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
30
30
  import type { FileAnchoredCallback } from "./tool-types.js";
31
+ import { registerReadseekTool } from "./register-tool.js";
31
32
 
32
33
  const READ_PROMPT_METADATA = defineToolPromptMetadata({
33
34
  promptUrl: new URL("../prompts/read.md", import.meta.url),
@@ -423,16 +424,11 @@ function splitReadseekLines(text: string): string[] {
423
424
  }
424
425
 
425
426
  export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}) {
426
- const toolConfig = {
427
- callable: true,
428
- enabled: true,
429
- policy: "read-only" as const,
430
- readOnly: true,
427
+ const tool = registerReadseekTool(pi, {
428
+ policy: "read-only",
431
429
  pythonName: "read",
432
- defaultExposure: "safe-by-default" as const,
433
- };
434
-
435
- const tool = {
430
+ defaultExposure: "safe-by-default",
431
+ }, {
436
432
  name: "read",
437
433
  label: "Read",
438
434
  description: READ_PROMPT_METADATA.description,
@@ -460,7 +456,6 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
460
456
  }),
461
457
  ),
462
458
  }),
463
- ptc: toolConfig,
464
459
  async execute(toolCallId, params, signal, onUpdate, ctx) {
465
460
  return executeRead({
466
461
  toolCallId,
@@ -489,7 +484,7 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
489
484
  },
490
485
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
491
486
  const { isPartial, isError, expanded, width } = resolveRenderResultContext(options, rest);
492
- if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending read")], width).join("\n"), 0, 0);
487
+ if (isPartial) return renderPendingResult("pending read", width);
493
488
 
494
489
  const content = result.content?.[0];
495
490
  const textContent = content?.type === "text" ? content.text : "";
@@ -516,8 +511,6 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
516
511
  if (expanded && textContent) text += "\n" + wrapReadHashlinesForWidth(textContent, width);
517
512
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
518
513
  },
519
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
520
-
521
- pi.registerTool(tool);
514
+ });
522
515
  return tool;
523
516
  }
@@ -233,22 +233,6 @@ export function formatFileMap(map: FileMap, level?: DetailLevel): string {
233
233
  return lines.join("\n");
234
234
  }
235
235
 
236
- /**
237
- * Get the appropriate detail level for a map based on size.
238
- */
239
- export function getDetailLevelForSize(currentSize: number): DetailLevel {
240
- if (currentSize <= THRESHOLDS.FULL_TARGET_BYTES) {
241
- return DetailLevel.Full;
242
- }
243
- if (currentSize <= THRESHOLDS.COMPACT_TARGET_BYTES) {
244
- return DetailLevel.Compact;
245
- }
246
- if (currentSize <= THRESHOLDS.MAX_MAP_BYTES) {
247
- return DetailLevel.Minimal;
248
- }
249
- return DetailLevel.Outline;
250
- }
251
-
252
236
  /**
253
237
  * Reduce detail level of a file map.
254
238
  */
@@ -136,7 +136,7 @@ export function findSymbol(map: FileMap, query: string): SymbolLookupResult {
136
136
  } else {
137
137
  pool = allSymbols.filter((c) => c.symbol.name === namePart);
138
138
  }
139
- // AC 16 + AC 17: drop candidates without usable startLine. If that empties
139
+ // drop candidates without usable startLine. If that empties
140
140
  // the pool entirely, surface any same-leaf-name decls elsewhere in the file
141
141
  // so the user can see real candidate lines.
142
142
  pool = pool.filter((c) => Number.isFinite(c.symbol.startLine) && c.symbol.startLine > 0);
@@ -203,6 +203,28 @@ export function isReadseekAvailable(): boolean {
203
203
  }
204
204
  }
205
205
 
206
+ export interface ReadseekFailure {
207
+ code: "readseek-not-installed" | "readseek-execution-error";
208
+ message: string;
209
+ hint?: string;
210
+ }
211
+
212
+ /**
213
+ * Classify an error thrown while invoking readseek into the shared failure
214
+ * taxonomy: a missing binary or package (`readseek-not-installed`, with an
215
+ * install hint) versus any other execution error.
216
+ */
217
+ export function classifyReadseekFailure(err: unknown): ReadseekFailure {
218
+ const message = String((err as { message?: unknown } | null)?.message || err);
219
+ const missing =
220
+ (err as { code?: unknown } | null)?.code === "ENOENT" ||
221
+ /Cannot find package|Cannot find module|no such file/i.test(message);
222
+ if (missing) {
223
+ return { code: "readseek-not-installed", message, hint: "Run npm install to install @jarkkojs/readseek." };
224
+ }
225
+ return { code: "readseek-execution-error", message };
226
+ }
227
+
206
228
  function directoryExists(dirPath: string): boolean {
207
229
  try {
208
230
  return statSync(dirPath).isDirectory();
@@ -353,15 +375,7 @@ function parseReadOutput(value: unknown): ReadseekReadOutput {
353
375
  file_hash: requireString(output.file_hash, "file_hash"),
354
376
  start_line: requireNumber(output.start_line, "start_line"),
355
377
  end_line: requireNumber(output.end_line, "end_line"),
356
- hashlines: hashlines.map((line) => {
357
- if (!line || typeof line !== "object") throw new Error("invalid readseek hashline");
358
- const item = line as Record<string, unknown>;
359
- return {
360
- line: requireNumber(item.line, "hashline.line"),
361
- hash: requireString(item.hash, "hashline.hash"),
362
- text: requireString(item.text, "hashline.text"),
363
- };
364
- }),
378
+ hashlines: hashlines.map((line) => parseHashline(line, "hashline")),
365
379
  };
366
380
  }
367
381
 
@@ -391,17 +405,19 @@ function parseMapOutput(value: unknown): ReadseekMapOutput {
391
405
  };
392
406
  }
393
407
 
408
+ function parseHashline(value: unknown, field: string): ReadseekHashline {
409
+ if (!value || typeof value !== "object") throw new Error(`invalid readseek ${field}`);
410
+ const item = value as Record<string, unknown>;
411
+ return {
412
+ line: requireNumber(item.line, `${field}.line`),
413
+ hash: requireString(item.hash, `${field}.hash`),
414
+ text: requireString(item.text, `${field}.text`),
415
+ };
416
+ }
417
+
394
418
  function parseSearchHashlines(value: unknown, field: string): ReadseekHashline[] {
395
419
  if (!Array.isArray(value)) throw new Error(`invalid readseek ${field}`);
396
- return value.map((line) => {
397
- if (!line || typeof line !== "object") throw new Error(`invalid readseek ${field}`);
398
- const item = line as Record<string, unknown>;
399
- return {
400
- line: requireNumber(item.line, `${field}.line`),
401
- hash: requireString(item.hash, `${field}.hash`),
402
- text: requireString(item.text, `${field}.text`),
403
- };
404
- });
420
+ return value.map((line) => parseHashline(line, field));
405
421
  }
406
422
 
407
423
  function parseSearchOutput(value: unknown): ReadseekSearchOutput {
@@ -422,7 +438,7 @@ function parseSearchOutput(value: unknown): ReadseekSearchOutput {
422
438
  const item = match as Record<string, unknown>;
423
439
  if (!Array.isArray(item.captures)) throw new Error("invalid readseek search captures");
424
440
  return {
425
- pattern_index: requireNumber(item.pattern_index, "search.match.pattern_index"),
441
+ pattern_index: item.pattern_index === undefined ? 0 : requireNumber(item.pattern_index, "search.match.pattern_index"),
426
442
  start_line: requireNumber(item.start_line, "search.match.start_line"),
427
443
  end_line: requireNumber(item.end_line, "search.match.end_line"),
428
444
  start_hash: requireString(item.start_hash, "search.match.start_hash"),
@@ -449,7 +465,7 @@ function parseSearchOutput(value: unknown): ReadseekSearchOutput {
449
465
 
450
466
  export async function readseekRead(filePath: string, startLine?: number, endLine?: number): Promise<ReadseekReadOutput> {
451
467
  const args = ["read", filePath];
452
- if (startLine !== undefined) args.push("--offset", String(startLine));
468
+ if (startLine !== undefined) args.push("--start", String(startLine));
453
469
  if (endLine !== undefined) args.push("--end", String(endLine));
454
470
  return parseReadOutput(await runReadseek(args));
455
471
  }
package/src/refs.ts CHANGED
@@ -6,9 +6,10 @@ import { stat as fsStat } from "node:fs/promises";
6
6
  import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
7
7
  import { buildReadseekLineWithHash, buildToolErrorResult } from "./readseek-value.js";
8
8
  import { resolveToCwd } from "./path-utils.js";
9
- import { isReadseekAvailable, readseekRefs, type ReadseekReference } from "./readseek-client.js";
9
+ import { classifyReadseekFailure, readseekRefs, type ReadseekReference } from "./readseek-client.js";
10
10
  import { buildRefsOutput, type RefsOutputFile, type RefsOutputLine } from "./refs-output.js";
11
11
  import type { FileAnchoredCallback } from "./tool-types.js";
12
+ import { registerReadseekTool } from "./register-tool.js";
12
13
 
13
14
  import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
14
15
 
@@ -29,10 +30,6 @@ const REFS_PROMPT_METADATA = defineToolPromptMetadata({
29
30
  promptSnippet: "Find references to an identifier with readseek and return edit-ready anchors",
30
31
  });
31
32
 
32
- export function isRefsAvailable(): boolean {
33
- return isReadseekAvailable();
34
- }
35
-
36
33
  interface RefsToolOptions {
37
34
  onFileAnchored?: FileAnchoredCallback;
38
35
  }
@@ -72,6 +69,14 @@ function groupReferences(references: ReadseekReference[], cwd: string): RefsOutp
72
69
  return [...files.values()];
73
70
  }
74
71
 
72
+ function isReadseekCursorValidationFailure(message: string): boolean {
73
+ return (
74
+ /line and column must be greater than zero/i.test(message) ||
75
+ /line \d+ not found/i.test(message) ||
76
+ /column \d+ exceeds maximum column \d+ for line \d+/i.test(message)
77
+ );
78
+ }
79
+
75
80
  /**
76
81
  * Executes identifier reference lookup and returns readseek-anchored matches.
77
82
  */
@@ -127,28 +132,20 @@ export async function executeRefs(opts: ExecuteRefsOptions): Promise<any> {
127
132
  details: { readseekValue: builtOutput.readseekValue },
128
133
  };
129
134
  } catch (err: any) {
130
- const message = String(err?.message || err);
131
- const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
132
- return buildToolErrorResult(
133
- "refs",
134
- missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
135
- message,
136
- missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
137
- );
135
+ const failure = classifyReadseekFailure(err);
136
+ if (p.scope && isReadseekCursorValidationFailure(failure.message)) {
137
+ return buildToolErrorResult("refs", "invalid-parameter", failure.message);
138
+ }
139
+ return buildToolErrorResult("refs", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
138
140
  }
139
141
  }
140
142
 
141
143
  export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}) {
142
- const toolConfig = {
143
- callable: true,
144
- enabled: true,
145
- policy: "read-only" as const,
146
- readOnly: true,
144
+ const tool = registerReadseekTool(pi, {
145
+ policy: "read-only",
147
146
  pythonName: "refs",
148
- defaultExposure: "opt-in" as const,
149
- };
150
-
151
- const tool = {
147
+ defaultExposure: "opt-in",
148
+ }, {
152
149
  name: "refs",
153
150
  label: "References",
154
151
  description: REFS_PROMPT_METADATA.description,
@@ -165,7 +162,6 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
165
162
  others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
166
163
  ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
167
164
  }),
168
- ptc: toolConfig,
169
165
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
170
166
  return executeRefs({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
171
167
  },
@@ -186,8 +182,6 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
186
182
  unitPlural: "references",
187
183
  });
188
184
  },
189
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
190
-
191
- pi.registerTool(tool);
185
+ });
192
186
  return tool;
193
187
  }
@@ -0,0 +1,47 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type ReadseekToolPolicy = "read-only" | "mutating";
4
+
5
+ export type ReadseekToolExposure = "safe-by-default" | "opt-in" | "not-safe-by-default";
6
+
7
+ /**
8
+ * Per-tool fields of the pi tool config (`ptc`) that genuinely differ between
9
+ * readseek tools. The remaining fields are constant or derived.
10
+ */
11
+ export interface ReadseekToolConfig {
12
+ policy: ReadseekToolPolicy;
13
+ pythonName: string;
14
+ defaultExposure: ReadseekToolExposure;
15
+ }
16
+
17
+ export interface ReadseekToolPtc extends ReadseekToolConfig {
18
+ callable: true;
19
+ enabled: true;
20
+ readOnly: boolean;
21
+ }
22
+
23
+ type ToolSpec = Parameters<ExtensionAPI["registerTool"]>[0];
24
+
25
+ /**
26
+ * Attach the standard `ptc` envelope to a tool definition and register it.
27
+ *
28
+ * `callable` and `enabled` are always true and `readOnly` is derived from
29
+ * `policy`, so callers supply only the fields that vary between tools.
30
+ */
31
+ export function registerReadseekTool<T extends ToolSpec>(
32
+ pi: ExtensionAPI,
33
+ config: ReadseekToolConfig,
34
+ tool: T,
35
+ ): T & { ptc: ReadseekToolPtc } {
36
+ const ptc: ReadseekToolPtc = {
37
+ callable: true,
38
+ enabled: true,
39
+ policy: config.policy,
40
+ readOnly: config.policy === "read-only",
41
+ pythonName: config.pythonName,
42
+ defaultExposure: config.defaultExposure,
43
+ };
44
+ const registered = { ...tool, ptc };
45
+ pi.registerTool(registered);
46
+ return registered;
47
+ }
package/src/sg.ts CHANGED
@@ -6,9 +6,10 @@ import { stat as fsStat } from "node:fs/promises";
6
6
  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
- import { isReadseekAvailable, readseekSearch, type ReadseekHashline, type ReadseekSearchFileOutput } from "./readseek-client.js";
9
+ import { classifyReadseekFailure, isReadseekAvailable, readseekSearch, type ReadseekHashline, type ReadseekSearchFileOutput } from "./readseek-client.js";
10
10
  import { buildSgOutput } from "./sg-output.js";
11
11
  import type { FileAnchoredCallback } from "./tool-types.js";
12
+ import { registerReadseekTool } from "./register-tool.js";
12
13
 
13
14
  import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
14
15
 
@@ -193,28 +194,17 @@ export async function executeSg(opts: ExecuteSgOptions): Promise<any> {
193
194
  },
194
195
  };
195
196
  } catch (err: any) {
196
- const message = String(err?.message || err);
197
- const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
198
- return buildToolErrorResult(
199
- "search",
200
- missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
201
- message,
202
- missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
203
- );
197
+ const failure = classifyReadseekFailure(err);
198
+ return buildToolErrorResult("search", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
204
199
  }
205
200
  }
206
201
 
207
202
  export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
208
- const toolConfig = {
209
- callable: true,
210
- enabled: true,
211
- policy: "read-only" as const,
212
- readOnly: true,
203
+ const tool = registerReadseekTool(pi, {
204
+ policy: "read-only",
213
205
  pythonName: "search",
214
- defaultExposure: "opt-in" as const,
215
- };
216
-
217
- const tool = {
206
+ defaultExposure: "opt-in",
207
+ }, {
218
208
  name: "search",
219
209
  label: "Structural Search",
220
210
  description: SG_PROMPT_METADATA.description,
@@ -228,7 +218,6 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
228
218
  others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
229
219
  ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
230
220
  }),
231
- ptc: toolConfig,
232
221
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
233
222
  return executeSg({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
234
223
  },
@@ -249,8 +238,6 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
249
238
  unitPlural: "matches",
250
239
  });
251
240
  },
252
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
253
-
254
- pi.registerTool(tool);
241
+ });
255
242
  return tool;
256
243
  }
@@ -80,15 +80,6 @@ export function clampLinesToWidth(lines: string[], width: number | undefined): s
80
80
  return lines.map((line) => clampLineToWidth(line, width));
81
81
  }
82
82
 
83
- export function wrapLinesToWidth(lines: string[], width: number | undefined): string[] {
84
- if (width === undefined || width === null) return lines;
85
- const normalized = normalizeWidth(width);
86
- return lines.flatMap((line) => {
87
- if (visibleWidth(line) <= normalized) return [line];
88
- return wrapTextWithAnsi(line, normalized).map((wrapped) => clampLineToWidth(wrapped, normalized));
89
- });
90
- }
91
-
92
83
  export interface WrapWithHangingIndentOptions {
93
84
  /** Optional transform applied to each produced line (e.g. theme tinting). */
94
85
  tint?: (text: string) => string;
@@ -156,6 +147,27 @@ export function wrapReadHashlinesForWidth(text: string, width: number | undefine
156
147
  return output.join("\n");
157
148
  }
158
149
 
150
+ /**
151
+ * Render the `isPartial` placeholder line shared by every tool's `renderResult`.
152
+ */
153
+ export function renderPendingResult(pendingLabel: string, width: number | undefined): Text {
154
+ return new Text(clampLinesToWidth([summaryLine(pendingLabel)], width).join("\n"), 0, 0);
155
+ }
156
+
157
+ /**
158
+ * Render a tool result error: the first line of `textContent`, or its full body
159
+ * when expanded, prefixed and clamped to width. Tools pass their own `fallback`
160
+ * for the empty-content case.
161
+ */
162
+ export function renderErrorResult(
163
+ textContent: string,
164
+ options: { expanded: boolean; width: number | undefined; fallback?: string },
165
+ ): Text {
166
+ const firstLine = textContent.split("\n")[0] || (options.fallback ?? "Error");
167
+ const body = options.expanded && textContent ? textContent : firstLine;
168
+ return new Text(clampLinesToWidth(summaryLine(body).split("\n"), options.width).join("\n"), 0, 0);
169
+ }
170
+
159
171
  export interface AnchoredFilesLabels {
160
172
  pendingLabel: string;
161
173
  emptyLabel: string;
@@ -178,15 +190,11 @@ export function renderAnchoredFilesResult(
178
190
  ): Text {
179
191
  const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
180
192
 
181
- if (isPartial) return new Text(clampLinesToWidth([summaryLine(labels.pendingLabel)], width).join("\n"), 0, 0);
193
+ if (isPartial) return renderPendingResult(labels.pendingLabel, width);
182
194
 
183
195
  const content = result.content?.[0];
184
196
  const textContent = content?.type === "text" ? content.text : "";
185
- if (isError || result.isError) {
186
- const firstLine = textContent.split("\n")[0] || "Error";
187
- const body = expanded && textContent ? textContent : firstLine;
188
- return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
189
- }
197
+ if (isError || result.isError) return renderErrorResult(textContent, { expanded, width });
190
198
 
191
199
  const readseekValue = (result.details as any)?.readseekValue as
192
200
  | { files: Array<{ path: string; lines: any[] }> }
package/src/write.ts CHANGED
@@ -14,9 +14,10 @@ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
14
14
  import { buildPendingWritePreviewData, buildWritePreviewKey, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
15
15
  import { generateCompactOrFullDiff, normalizeToLF, hasBareCarriageReturn } from "./edit-diff.js";
16
16
  import { buildDiffData, type DiffData } from "./diff-data.js";
17
- import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderToolLabel, summaryLine } from "./tui-render-utils.js";
17
+ import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderErrorResult, renderToolLabel, summaryLine } from "./tui-render-utils.js";
18
18
  import { DiffPreviewComponent } from "./tui-diff-component.js";
19
19
  import type { FileAnchoredCallback } from "./tool-types.js";
20
+ import { registerReadseekTool } from "./register-tool.js";
20
21
 
21
22
  const WRITE_PENDING_PREVIEW_STATE_KEY = "hashline-write-pending-preview";
22
23
 
@@ -169,6 +170,30 @@ function mapFsWriteError(err: any, path: string): MappedFsError {
169
170
  };
170
171
  }
171
172
 
173
+ function buildWriteFsErrorResult(err: any, absolutePath: string) {
174
+ const mapped = mapFsWriteError(err, absolutePath);
175
+ return {
176
+ content: [{ type: "text" as const, text: mapped.message }],
177
+ isError: true,
178
+ details: {
179
+ readseekValue: {
180
+ tool: "write" as const,
181
+ path: absolutePath,
182
+ lines: [] as ReadseekLine[],
183
+ warnings: [] as ReadseekWarning[],
184
+ ok: false,
185
+ error: buildReadseekError(
186
+ mapped.code,
187
+ mapped.message,
188
+ undefined,
189
+ mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
190
+ ),
191
+ },
192
+ warnings: [] as string[],
193
+ },
194
+ };
195
+ }
196
+
172
197
  export async function executeWrite(opts: {
173
198
  path: string;
174
199
  content: string;
@@ -301,21 +326,16 @@ export async function executeWrite(opts: {
301
326
  }
302
327
 
303
328
  export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions = {}) {
304
- const toolConfig = {
305
- callable: true,
306
- enabled: true,
307
- policy: "mutating" as const,
308
- readOnly: false,
329
+ const tool = registerReadseekTool(pi, {
330
+ policy: "mutating",
309
331
  pythonName: "write",
310
- defaultExposure: "not-safe-by-default" as const,
311
- };
312
- const tool = {
332
+ defaultExposure: "not-safe-by-default",
333
+ }, {
313
334
  name: "write",
314
335
  label: "write",
315
336
  description: WRITE_PROMPT_METADATA.description,
316
337
  promptSnippet: WRITE_PROMPT_METADATA.promptSnippet,
317
338
  promptGuidelines: WRITE_PROMPT_METADATA.promptGuidelines,
318
- ptc: toolConfig,
319
339
  parameters: Type.Object({
320
340
  path: Type.String({ description: "File path" }),
321
341
  content: Type.String({ description: "File content" }),
@@ -325,112 +345,58 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
325
345
  const cwd = ctx?.cwd ?? process.cwd();
326
346
  const absolutePath = resolveToCwd(params.path, cwd);
327
347
  try {
328
- return await withFileMutationQueue(absolutePath, async () => {
329
- let result: WriteResult;
330
- try {
331
- result = await executeWrite({
332
- path: absolutePath,
333
- content: params.content,
334
- map: params.map,
335
- cwd,
336
- });
337
- } catch (err: any) {
338
- const mapped = mapFsWriteError(err, absolutePath);
339
- return {
340
- content: [{ type: "text" as const, text: mapped.message }],
341
- isError: true,
342
- details: {
343
- readseekValue: {
344
- tool: "write" as const,
348
+ return await withFileMutationQueue(absolutePath, async () => {
349
+ let result: WriteResult;
350
+ try {
351
+ result = await executeWrite({
345
352
  path: absolutePath,
346
- lines: [] as ReadseekLine[],
347
- warnings: [] as ReadseekWarning[],
348
- ok: false,
349
- error: buildReadseekError(
350
- mapped.code,
351
- mapped.message,
352
- undefined,
353
- mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
354
- ),
355
- },
356
- warnings: [] as string[],
357
- },
358
- };
359
- }
353
+ content: params.content,
354
+ map: params.map,
355
+ cwd,
356
+ });
357
+ } catch (err: any) {
358
+ return buildWriteFsErrorResult(err, absolutePath);
359
+ }
360
360
 
361
- if (result.readseekValue.lines.length > 0) {
362
- options.onFileAnchored?.(absolutePath);
363
- }
361
+ if (result.readseekValue.lines.length > 0) {
362
+ options.onFileAnchored?.(absolutePath);
363
+ }
364
364
 
365
- // Lift binary-content signal into a fatal readseekValue.error envelope so
366
- // downstream consumers get the same taxonomy shape as every other tool.
367
- // The existing ReadseekWarning entry is preserved on readseekValue.warnings for
368
- // backward compatibility (see AC 12 — warnings namespace alignment).
369
- const binaryWarning = result.readseekValue.warnings.find((w) => w.code === "binary-content");
370
- if (binaryWarning) {
371
- return {
372
- content: [{ type: "text" as const, text: result.text }],
373
- isError: true,
374
- details: {
375
- readseekValue: {
376
- ...result.readseekValue,
377
- ok: false,
378
- error: buildReadseekError("binary-content", binaryWarning.message),
379
- },
380
- warnings: result.warnings,
381
- },
382
- };
383
- }
365
+ // Lift binary-content signal into a fatal readseekValue.error envelope so
366
+ // downstream consumers get the same taxonomy shape as every other tool.
367
+ // The existing ReadseekWarning entry is preserved on readseekValue.warnings for
368
+ // backward compatibility.
369
+ for (const code of ["binary-content", "bare-cr"] as const) {
370
+ const warning = result.readseekValue.warnings.find((w) => w.code === code);
371
+ if (warning) {
372
+ return {
373
+ content: [{ type: "text" as const, text: result.text }],
374
+ isError: true,
375
+ details: {
376
+ readseekValue: {
377
+ ...result.readseekValue,
378
+ ok: false,
379
+ error: buildReadseekError(code, warning.message),
380
+ },
381
+ warnings: result.warnings,
382
+ },
383
+ };
384
+ }
385
+ }
384
386
 
385
- const bareCrWarning = result.readseekValue.warnings.find((w) => w.code === "bare-cr");
386
- if (bareCrWarning) {
387
- return {
388
- content: [{ type: "text" as const, text: result.text }],
389
- isError: true,
390
- details: {
391
- readseekValue: {
392
- ...result.readseekValue,
393
- ok: false,
394
- error: buildReadseekError("bare-cr", bareCrWarning.message),
387
+ return {
388
+ content: [{ type: "text" as const, text: result.text }],
389
+ details: {
390
+ ...(result.diff !== undefined ? { diff: result.diff } : {}),
391
+ ...(result.diffData !== undefined ? { diffData: result.diffData } : {}),
392
+ ...(result.writeState ? { writeState: result.writeState } : {}),
393
+ readseekValue: result.readseekValue,
394
+ warnings: result.warnings,
395
395
  },
396
- warnings: result.warnings,
397
- },
398
- };
399
- }
400
-
401
- return {
402
- content: [{ type: "text" as const, text: result.text }],
403
- details: {
404
- ...(result.diff !== undefined ? { diff: result.diff } : {}),
405
- ...(result.diffData !== undefined ? { diffData: result.diffData } : {}),
406
- ...(result.writeState ? { writeState: result.writeState } : {}),
407
- readseekValue: result.readseekValue,
408
- warnings: result.warnings,
409
- },
410
- };
411
- });
396
+ };
397
+ });
412
398
  } catch (err: any) {
413
- const mapped = mapFsWriteError(err, absolutePath);
414
- return {
415
- content: [{ type: "text" as const, text: mapped.message }],
416
- isError: true,
417
- details: {
418
- readseekValue: {
419
- tool: "write" as const,
420
- path: absolutePath,
421
- lines: [] as ReadseekLine[],
422
- warnings: [] as ReadseekWarning[],
423
- ok: false,
424
- error: buildReadseekError(
425
- mapped.code,
426
- mapped.message,
427
- undefined,
428
- mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
429
- ),
430
- },
431
- warnings: [] as string[],
432
- },
433
- };
399
+ return buildWriteFsErrorResult(err, absolutePath);
434
400
  }
435
401
  },
436
402
  renderCall(args: any, theme: any, context: any = {}) {
@@ -478,9 +444,7 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
478
444
  const details = result.details ?? {};
479
445
  const output = result.content?.[0]?.type === "text" ? result.content[0].text : "";
480
446
  if (result.isError || details.readseekValue?.ok === false) {
481
- const firstLine = output.split("\n")[0] || "write failed";
482
- const body = expanded && output ? output : firstLine;
483
- return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
447
+ return renderErrorResult(output, { expanded, width, fallback: "write failed" });
484
448
  }
485
449
  const diffData = details.diffData;
486
450
  const state = details.writeState === "overwritten" ? "overwritten" : "created";
@@ -510,7 +474,6 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
510
474
  }
511
475
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
512
476
  },
513
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
514
- pi.registerTool(tool);
477
+ });
515
478
  return tool;
516
479
  }