pi-readseek 0.6.13 → 0.7.1

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/README.md CHANGED
@@ -5,6 +5,10 @@ editing, anchored grep, structural maps, symbol navigation, and structural searc
5
5
  Pi's built-in tools remain unchanged by default. `replacedTools` can register the
6
6
  corresponding ReadSeek implementations under built-in names.
7
7
 
8
+ When the ReadSeek binary is available, the extension adds model guidance that
9
+ prefers its anchored read, edit, write, rename, and syntax-check workflow while
10
+ leaving Pi's built-ins available as fallbacks.
11
+
8
12
  ## Installation
9
13
 
10
14
  ```sh
@@ -18,15 +22,19 @@ binary automatically on supported platforms.
18
22
 
19
23
  - `readSeek_read`: reads text with `LINE:HASH` anchors; when image modes are
20
24
  enabled, images and PDFs can be returned as base64 or analyzed locally.
25
+ PDF reads select one page by default and accept an explicit page.
21
26
  - `readSeek_edit`: edits existing text files using fresh `LINE:HASH` anchors.
22
27
  - `readSeek_grep`: searches text and returns edit-ready anchors.
23
28
  - `readSeek_search`: searches code by structural AST pattern.
24
29
  - `readSeek_refs`: finds identifier references with enclosing symbols.
25
- - `readSeek_rename`: plans or applies binding-aware renames; workspace matches are
26
- name-based where binding support is unavailable.
30
+ - `readSeek_rename`: applies a binding-aware verified rename by default;
31
+ `apply: false` returns a dry-run plan. Workspace matches are name-based where
32
+ binding support is unavailable.
27
33
  - `readSeek_hover`: identifies the cursor token and enclosing symbol.
28
34
  - `readSeek_def`: finds structural symbol definitions.
29
35
  - `readSeek_check`: checks a source file for parser errors and missing syntax.
36
+ - `readSeek_view`: creates or reuses a PDF index, returns its overview, or
37
+ narrows it by page, node, kind, or depth.
30
38
  - `readSeek_write`: creates or overwrites whole files and returns anchors.
31
39
 
32
40
  ## Settings
package/index.ts CHANGED
@@ -9,6 +9,7 @@ import { registerHoverTool } from "./src/hover.js";
9
9
  import { registerWriteTool } from "./src/write.js";
10
10
  import { registerDefTool } from "./src/def.js";
11
11
  import { registerCheckTool } from "./src/check.js";
12
+ import { registerViewTool } from "./src/view.js";
12
13
  import { SessionAnchors } from "./src/session-anchors.js";
13
14
  import { readSeekBinaryAvailability } from "./src/readseek-client.js";
14
15
  import { resolveReadSeekJsonSettings, type ReadSeekSettingsWarning } from "./src/readseek-settings.js";
@@ -39,12 +40,23 @@ const READSEEK_TOOL_ENTRIES: ReadonlyArray<{ builtIn: ReplacedBuiltIn | null; re
39
40
  { builtIn: "write", readSeekName: "readSeek_write" },
40
41
  { builtIn: null, readSeekName: "readSeek_def" },
41
42
  { builtIn: null, readSeekName: "readSeek_check" },
43
+ { builtIn: null, readSeekName: "readSeek_view" },
42
44
  ];
43
45
 
44
46
  function formatSettingsWarning(warning: ReadSeekSettingsWarning): string {
45
47
  return `${warning.message} (${warning.source})`;
46
48
  }
47
49
 
50
+ function editingPolicy(readName: string, editName: string, writeName: string): string {
51
+ return [
52
+ "ReadSeek editing policy:",
53
+ `- Prefer ${readName} when preparing to edit existing text; its LINE:HASH anchors are required by ${editName}.`,
54
+ `- Prefer ${editName} for existing text files, ${writeName} for whole-file creation or replacement, and readSeek_rename for symbol renames.`,
55
+ "- Do not use Pi's built-in edit or write when the corresponding ReadSeek tool is available.",
56
+ "- Use readSeek_check after source edits for a quick syntax check.",
57
+ ].join("\n");
58
+ }
59
+
48
60
  export default function piReadSeekExtension(pi: ExtensionAPI): void {
49
61
  const sessionAnchors = new SessionAnchors();
50
62
  const markAnchored = (absolutePath: string) => sessionAnchors.markAnchored(absolutePath);
@@ -87,8 +99,14 @@ export default function piReadSeekExtension(pi: ExtensionAPI): void {
87
99
  registerHoverTool(pi);
88
100
  registerDefTool(pi, { onFileAnchored: markAnchored });
89
101
  registerCheckTool(pi);
102
+ registerViewTool(pi);
90
103
  registerWriteTool(pi, { onFileAnchored: markAnchored, name: writeName });
91
104
 
105
+ pi.on("before_agent_start", (event) => {
106
+ if (!binaryAvailable) return;
107
+ return { systemPrompt: `${event.systemPrompt}\n\n${editingPolicy(readName, editName, writeName)}` };
108
+ });
109
+
92
110
  pi.on("session_start", (_event, ctx) => {
93
111
  sessionAnchors.clear();
94
112
  const { warnings } = resolveReadSeekJsonSettings();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.6.13",
3
+ "version": "0.7.1",
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.6.13",
42
+ "@jarkkojs/readseek": "^0.7.1",
43
43
  "diff": "^8.0.3",
44
44
  "xxhash-wasm": "^1.1.0"
45
45
  },
package/prompts/read.md CHANGED
@@ -15,8 +15,9 @@ Read anchored text by range, map, or symbol, and process images or PDFs with an
15
15
  - `symbol` — `Name`, `Class.method`, or `Name@<line>`; incompatible with `offset` / `limit`.
16
16
  - `bundle` — only `"local"`; requires `symbol` and excludes `map`.
17
17
  - `image` — an exposed image/PDF mode; unavailable when `imageMode` is `"off"`.
18
+ - `page` — one-based PDF page; defaults to page 1 and cannot be combined with `offset` / `limit`.
18
19
 
19
- Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}. Omitting `image` skips images and PDFs; when `imageMode` is `"off"`, visual files are always skipped.
20
+ Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}. PDF reads return one page by default. Omitting `image` skips images and PDFs; when `imageMode` is `"off"`, visual files are always skipped.
20
21
 
21
22
  Truncated full-file reads append a map when available. Use its ranges for follow-up reads.
22
23
 
@@ -0,0 +1,14 @@
1
+ View the structure or selected content of an indexed PDF. Start with the default overview, then narrow by page or node instead of reading the whole document.
2
+
3
+ ## Parameters
4
+
5
+ - `path` — PDF document to view.
6
+ - `node` — optional node ID to use as the view root.
7
+ - `page` — optional one-based source page.
8
+ - `kind` — optional node kind filter.
9
+ - `depth` — optional maximum depth below selected roots.
10
+ - `outline` — return outline nodes only.
11
+
12
+ ## Output
13
+
14
+ Returns a bounded text projection with stable node IDs and source page references.
package/src/read.ts CHANGED
@@ -32,11 +32,11 @@ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult,
32
32
  import type { FileAnchoredCallback } from "./tool-types.js";
33
33
  import { filePathParam, mapParam, optionalIntOrString, registerReadSeekTool } from "./register-tool.js";
34
34
 
35
-
36
35
  interface ReadParams {
37
36
  path: string;
38
37
  offset?: number | string;
39
38
  limit?: number | string;
39
+ page?: number | string;
40
40
  symbol?: string;
41
41
  map?: boolean;
42
42
  bundle?: string;
@@ -101,6 +101,12 @@ function formatPdfAnalysis(pdf: ReadSeekPdfOutput): string {
101
101
  return sections.filter(Boolean).join("\n\n");
102
102
  }
103
103
 
104
+ function truncateDocumentText(text: string): string {
105
+ const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
106
+ if (!truncation.truncated) return truncation.content;
107
+ return `${truncation.content}\n[… document output truncated; use readSeek_view with page or node selectors]`;
108
+ }
109
+
104
110
  function skippedVisualFile(path: string): AgentToolResult<any> {
105
111
  return {
106
112
  content: [{ type: "text", text: `[Skipped image/PDF: ${path}; no image mode selected]` }],
@@ -128,6 +134,15 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
128
134
  const message = `Invalid offset: expected a positive integer, received ${offset.value}.`;
129
135
  return buildToolErrorResult("read", "invalid-offset", message, { path: rawParams.path });
130
136
  }
137
+
138
+ const page = coerceObviousBase10Int(rawParams.page, "page");
139
+ if (!page.ok) {
140
+ return buildToolErrorResult("read", "invalid-page", page.message, { path: rawParams.path });
141
+ }
142
+ if (page.value !== undefined && page.value < 1) {
143
+ const message = `Invalid page: expected a positive integer, received ${page.value}.`;
144
+ return buildToolErrorResult("read", "invalid-page", message, { path: rawParams.path });
145
+ }
131
146
  const rawBundle = typeof rawParams.bundle === "string" ? rawParams.bundle.trim() : undefined;
132
147
  const requestedMapViaBundle =
133
148
  rawBundle === "map" ||
@@ -136,6 +151,7 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
136
151
  ...rawParams,
137
152
  offset: offset.value,
138
153
  limit: limit.value,
154
+ page: page.value,
139
155
  map: rawParams.map === true || requestedMapViaBundle,
140
156
  bundle: requestedMapViaBundle ? undefined : rawBundle,
141
157
  };
@@ -207,6 +223,15 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
207
223
  });
208
224
  }
209
225
  if (detection.kind === "image" || detection.type === "application/pdf") {
226
+ if (p.offset !== undefined || p.limit !== undefined) {
227
+ const message = "Cannot combine offset/limit with image or PDF reads.";
228
+ return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
229
+ }
230
+ if (detection.kind !== "pdf" && p.page !== undefined) {
231
+ const message = "The page parameter applies to PDFs only.";
232
+ return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
233
+ }
234
+ const pdfPage = detection.kind === "pdf" ? (p.page ?? 1) : undefined;
210
235
  const imageMode = resolveReadSeekImageMode();
211
236
  if (imageMode === "off" || p.image === undefined) {
212
237
  return skippedVisualFile(rawParams.path);
@@ -219,8 +244,8 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
219
244
  if (p.image === "none") {
220
245
  try {
221
246
  if (detection.kind === "pdf") {
222
- const pdf = await readSeekPdf(absolutePath, p.image, { signal });
223
- const content: AgentToolResult<any>["content"] = [{ type: "text", text: pdf.markdown }];
247
+ const pdf = await readSeekPdf(absolutePath, p.image, { page: pdfPage, signal });
248
+ const content: AgentToolResult<any>["content"] = [{ type: "text", text: truncateDocumentText(pdf.markdown) }];
224
249
  for (const image of pdf.images) {
225
250
  if (image.encoding !== "base64" || image.data === undefined) continue;
226
251
  content.push({ type: "text", text: `[PDF page ${image.page} image]` });
@@ -243,9 +268,9 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
243
268
 
244
269
  try {
245
270
  if (detection.kind === "pdf") {
246
- const pdf = await readSeekPdf(absolutePath, p.image, { signal });
271
+ const pdf = await readSeekPdf(absolutePath, p.image, { page: pdfPage, signal });
247
272
  return succeed({
248
- content: [{ type: "text" as const, text: formatPdfAnalysis(pdf) }],
273
+ content: [{ type: "text" as const, text: truncateDocumentText(formatPdfAnalysis(pdf)) }],
249
274
  details: {},
250
275
  });
251
276
  }
@@ -269,6 +294,10 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
269
294
  });
270
295
  }
271
296
  }
297
+ if (p.page !== undefined) {
298
+ const message = "The page parameter applies to PDFs only.";
299
+ return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
300
+ }
272
301
  throwIfAborted(signal);
273
302
  const rawText = rawBuffer.toString("utf-8");
274
303
  const normalized = normalizeToLF(stripBom(rawText).text);
@@ -548,6 +577,7 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
548
577
  path: filePathParam(),
549
578
  offset: optionalIntOrString("Start line (1-indexed)"),
550
579
  limit: optionalIntOrString("Maximum lines to return"),
580
+ page: optionalIntOrString("One-based PDF page; defaults to 1"),
551
581
  symbol: Type.Optional(Type.String({ description: "Symbol name to read" })),
552
582
  map: mapParam(),
553
583
  bundle: Type.Optional(
@@ -481,6 +481,25 @@ async function runReadSeek(args: string[], options: RunReadSeekOptions = {}): Pr
481
481
  return JSON.parse(stdout) as unknown;
482
482
  }
483
483
 
484
+ export interface ReadSeekViewOptions {
485
+ node?: string;
486
+ page?: number;
487
+ kind?: string;
488
+ depth?: number;
489
+ outline?: boolean;
490
+ signal?: AbortSignal;
491
+ }
492
+
493
+ export async function readSeekView(filePath: string, options: ReadSeekViewOptions = {}): Promise<string> {
494
+ const args = ["view", filePath];
495
+ if (options.node !== undefined) args.push("--node", options.node);
496
+ if (options.page !== undefined) args.push("--page", String(options.page));
497
+ if (options.kind !== undefined) args.push("--kind", options.kind);
498
+ if (options.depth !== undefined) args.push("--depth", String(options.depth));
499
+ if (options.outline) args.push("--outline");
500
+ return runReadSeekRaw(args, { signal: options.signal });
501
+ }
502
+
484
503
  let visionInvocationTail = Promise.resolve();
485
504
 
486
505
  /**
@@ -951,10 +970,12 @@ function parsePdfOutput(value: unknown): ReadSeekPdfOutput {
951
970
  export async function readSeekPdf(
952
971
  filePath: string,
953
972
  mode: "none" | ReadSeekImageMode,
954
- options: { signal?: AbortSignal } = {},
973
+ options: { page?: number; signal?: AbortSignal } = {},
955
974
  ): Promise<ReadSeekPdfOutput> {
956
975
  const run = mode === "none" ? runReadSeek : runReadSeekVision;
957
- return parsePdfOutput(await run(["read", "--image", mode, filePath], { signal: options.signal }));
976
+ const args = ["read", "--image", mode, filePath];
977
+ if (options.page !== undefined) args.push("--page", String(options.page));
978
+ return parsePdfOutput(await run(args, { signal: options.signal }));
958
979
  }
959
980
 
960
981
  // --- Rename ---
package/src/view.ts ADDED
@@ -0,0 +1,158 @@
1
+ import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ DEFAULT_MAX_BYTES,
4
+ DEFAULT_MAX_LINES,
5
+ truncateHead,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { Text } from "@earendil-works/pi-tui";
8
+ import { Type } from "@sinclair/typebox";
9
+
10
+ import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
11
+ import { resolveToCwd } from "./path-utils.js";
12
+ import { classifyReadSeekFailure, readSeekView } from "./readseek-client.js";
13
+ import { buildToolErrorResult } from "./readseek-value.js";
14
+ import { filePathParam, optionalIntOrString, registerReadSeekTool } from "./register-tool.js";
15
+ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
16
+ import {
17
+ clampLineToWidth,
18
+ clampLinesToWidth,
19
+ linkToolPath,
20
+ renderErrorResult,
21
+ renderPendingResult,
22
+ resolveRenderResultContext,
23
+ summaryLine,
24
+ } from "./tui-render-utils.js";
25
+
26
+ const NODE_KINDS = [
27
+ "artifact",
28
+ "footer",
29
+ "header",
30
+ "heading",
31
+ "marginal_label",
32
+ "page",
33
+ "page_number",
34
+ "paragraph",
35
+ "section",
36
+ "structural_section",
37
+ ] as const;
38
+
39
+ const VIEW_PROMPT_METADATA = defineToolPromptMetadata({
40
+ promptUrl: new URL("../prompts/view.md", import.meta.url),
41
+ promptSnippet: "View the structure or selected content of an indexed PDF",
42
+ });
43
+
44
+ interface ViewParams {
45
+ path: string;
46
+ node?: string;
47
+ page?: number | string;
48
+ kind?: (typeof NODE_KINDS)[number];
49
+ depth?: number | string;
50
+ outline?: boolean;
51
+ }
52
+
53
+ export async function executeView(opts: {
54
+ params: unknown;
55
+ signal: AbortSignal | undefined;
56
+ cwd: string;
57
+ }): Promise<any> {
58
+ const { params, signal, cwd } = opts;
59
+ const input = params as ViewParams;
60
+ const page = coerceObviousBase10Int(input.page, "page");
61
+ if (!page.ok || (page.value !== undefined && page.value < 1)) {
62
+ const message = page.ok
63
+ ? `Invalid page: expected a positive integer, received ${page.value}.`
64
+ : page.message;
65
+ return buildToolErrorResult("view", "invalid-page", message, { path: input.path });
66
+ }
67
+ const depth = coerceObviousBase10Int(input.depth, "depth");
68
+ if (!depth.ok || (depth.value !== undefined && depth.value < 0)) {
69
+ const message = depth.ok
70
+ ? `Invalid depth: expected a non-negative integer, received ${depth.value}.`
71
+ : depth.message;
72
+ return buildToolErrorResult("view", "invalid-depth", message, { path: input.path });
73
+ }
74
+ const node = input.node?.trim();
75
+ if (input.node !== undefined && !node) {
76
+ return buildToolErrorResult("view", "invalid-node", "Invalid node: expected a non-empty ID.", {
77
+ path: input.path,
78
+ });
79
+ }
80
+
81
+ const filePath = resolveToCwd(input.path, cwd);
82
+ try {
83
+ const output = await readSeekView(filePath, {
84
+ node,
85
+ page: page.value,
86
+ kind: input.kind,
87
+ depth: depth.value,
88
+ outline: input.outline,
89
+ signal,
90
+ });
91
+ const truncation = truncateHead(output, {
92
+ maxLines: DEFAULT_MAX_LINES,
93
+ maxBytes: DEFAULT_MAX_BYTES,
94
+ });
95
+ const notice = truncation.truncated
96
+ ? "\n[… document view truncated; narrow it with page, node, kind, or depth]"
97
+ : "";
98
+ return {
99
+ content: [{ type: "text", text: `${truncation.content}${notice}` }],
100
+ details: {
101
+ readSeekValue: {
102
+ tool: "view",
103
+ path: filePath,
104
+ truncated: truncation.truncated,
105
+ },
106
+ },
107
+ };
108
+ } catch (error) {
109
+ const failure = classifyReadSeekFailure(error);
110
+ return buildToolErrorResult("view", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
111
+ }
112
+ }
113
+
114
+ export function registerViewTool(pi: ExtensionAPI) {
115
+ return registerReadSeekTool(pi, {
116
+ name: "readSeek_view",
117
+ label: "View",
118
+ description: VIEW_PROMPT_METADATA.description,
119
+ promptSnippet: VIEW_PROMPT_METADATA.promptSnippet,
120
+ promptGuidelines: VIEW_PROMPT_METADATA.promptGuidelines,
121
+ parameters: Type.Object({
122
+ path: filePathParam(),
123
+ node: Type.Optional(Type.String({ description: "Node ID to use as the view root" })),
124
+ page: optionalIntOrString("One-based source page"),
125
+ kind: Type.Optional(Type.Union(NODE_KINDS.map((kind) => Type.Literal(kind)), {
126
+ description: "Node kind filter",
127
+ })),
128
+ depth: optionalIntOrString("Maximum depth below the selected roots"),
129
+ outline: Type.Optional(Type.Boolean({ description: "Return outline nodes only" })),
130
+ }),
131
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
132
+ return executeView({ params, signal, cwd: ctx.cwd });
133
+ },
134
+ renderCall(args: any, theme: any, ...rest: any[]) {
135
+ const context = rest[0] ?? {};
136
+ const cwd = context.cwd ?? process.cwd();
137
+ const displayPath = typeof args?.path === "string" ? args.path : "?";
138
+ const text = `${theme.fg("toolTitle", theme.bold("view"))} ${linkToolPath(theme.fg("accent", displayPath), displayPath, cwd)}`;
139
+ return new Text(clampLineToWidth(text, context.width), 0, 0);
140
+ },
141
+ renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
142
+ const { isPartial, isError, expanded, width } = resolveRenderResultContext(options, rest);
143
+ if (isPartial) return renderPendingResult("pending view", width, theme);
144
+ const textContent = result.content?.[0]?.type === "text" ? result.content[0].text : "";
145
+ if (isError || result.isError) {
146
+ return renderErrorResult(textContent, { expanded, width, fallback: "view failed", theme });
147
+ }
148
+ const lines = textContent.split("\n").filter(Boolean).length;
149
+ let text = summaryLine(`loaded ${lines} document ${lines === 1 ? "line" : "lines"}`, {
150
+ hidden: !!textContent && !expanded,
151
+ theme,
152
+ style: "success",
153
+ });
154
+ if (expanded && textContent) text += `\n${textContent}`;
155
+ return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
156
+ },
157
+ });
158
+ }