pi-readseek 0.6.12 → 0.7.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/README.md CHANGED
@@ -18,6 +18,7 @@ binary automatically on supported platforms.
18
18
 
19
19
  - `readSeek_read`: reads text with `LINE:HASH` anchors; when image modes are
20
20
  enabled, images and PDFs can be returned as base64 or analyzed locally.
21
+ PDF reads return one selected page at a time by default.
21
22
  - `readSeek_edit`: edits existing text files using fresh `LINE:HASH` anchors.
22
23
  - `readSeek_grep`: searches text and returns edit-ready anchors.
23
24
  - `readSeek_search`: searches code by structural AST pattern.
@@ -27,6 +28,7 @@ binary automatically on supported platforms.
27
28
  - `readSeek_hover`: identifies the cursor token and enclosing symbol.
28
29
  - `readSeek_def`: finds structural symbol definitions.
29
30
  - `readSeek_check`: checks a source file for parser errors and missing syntax.
31
+ - `readSeek_view`: views an indexed PDF overview or narrows it by page, node, kind, or depth.
30
32
  - `readSeek_write`: creates or overwrites whole files and returns anchors.
31
33
 
32
34
  ## 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,6 +40,7 @@ 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 {
@@ -87,6 +89,7 @@ export default function piReadSeekExtension(pi: ExtensionAPI): void {
87
89
  registerHoverTool(pi);
88
90
  registerDefTool(pi, { onFileAnchored: markAnchored });
89
91
  registerCheckTool(pi);
92
+ registerViewTool(pi);
90
93
  registerWriteTool(pi, { onFileAnchored: markAnchored, name: writeName });
91
94
 
92
95
  pi.on("session_start", (_event, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.6.12",
3
+ "version": "0.7.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.6.12",
42
+ "@jarkkojs/readseek": "^0.7.0",
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(
@@ -358,6 +358,7 @@ function defaultReadSeekDir(): string | null {
358
358
  }
359
359
 
360
360
  const DEFAULT_READSEEK_TIMEOUT_MS = 120_000;
361
+ const DEFAULT_READSEEK_VISION_TIMEOUT_MS = 30 * 60_000;
361
362
 
362
363
  function readSeekTimeoutMs(): number {
363
364
  return resolveReadSeekTimeoutMs() ?? DEFAULT_READSEEK_TIMEOUT_MS;
@@ -480,6 +481,25 @@ async function runReadSeek(args: string[], options: RunReadSeekOptions = {}): Pr
480
481
  return JSON.parse(stdout) as unknown;
481
482
  }
482
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
+
483
503
  let visionInvocationTail = Promise.resolve();
484
504
 
485
505
  /**
@@ -493,11 +513,36 @@ async function runReadSeekVision(args: string[], options: RunReadSeekOptions = {
493
513
  });
494
514
  const predecessor = visionInvocationTail;
495
515
  visionInvocationTail = predecessor.then(() => gate);
516
+ const signal = options.signal;
496
517
 
497
- await predecessor;
498
518
  try {
499
- options.signal?.throwIfAborted();
500
- return await runReadSeek(args, options);
519
+ if (signal) {
520
+ signal.throwIfAborted();
521
+ await new Promise<void>((resolve, reject) => {
522
+ const onAbort = (): void => {
523
+ signal.removeEventListener("abort", onAbort);
524
+ reject(signal.reason);
525
+ };
526
+ signal.addEventListener("abort", onAbort, { once: true });
527
+ predecessor.then(
528
+ () => {
529
+ signal.removeEventListener("abort", onAbort);
530
+ resolve();
531
+ },
532
+ (error) => {
533
+ signal.removeEventListener("abort", onAbort);
534
+ reject(error);
535
+ },
536
+ );
537
+ });
538
+ } else {
539
+ await predecessor;
540
+ }
541
+ signal?.throwIfAborted();
542
+ return await runReadSeek(args, {
543
+ ...options,
544
+ timeoutMs: options.timeoutMs ?? resolveReadSeekTimeoutMs() ?? DEFAULT_READSEEK_VISION_TIMEOUT_MS,
545
+ });
501
546
  } finally {
502
547
  release();
503
548
  }
@@ -925,10 +970,12 @@ function parsePdfOutput(value: unknown): ReadSeekPdfOutput {
925
970
  export async function readSeekPdf(
926
971
  filePath: string,
927
972
  mode: "none" | ReadSeekImageMode,
928
- options: { signal?: AbortSignal } = {},
973
+ options: { page?: number; signal?: AbortSignal } = {},
929
974
  ): Promise<ReadSeekPdfOutput> {
930
975
  const run = mode === "none" ? runReadSeek : runReadSeekVision;
931
- 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 }));
932
979
  }
933
980
 
934
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
+ }