pi-read-chunks 1.0.6 → 2.0.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
@@ -2,13 +2,13 @@
2
2
 
3
3
  A Pi Agent extension that enhances read functionality for large text files to reduce context bloat, rot and lost in the middle issues. Pi Agent's built in 'read()' tool truncates results over a certain size. 'read-chunks()' instead uses a sub-agent that splits larger files into overlapping chunks snapped to natural boundaries (function endings for code, paragraph breaks for prose), and each chunk is summarised by the active model, chaining the running summary forward as the file is consumed. Files under a configurable size threshold have contents returned verbatim. This keeps the full file content out of the main/orchestrator context.
4
4
 
5
- Replaces the built-in `read()` for text files. Images and other binaries still pass through to the built-in `read`. A precise query stops the scan early at the first chunk that answers it.
5
+ Replaces the built-in `read()` for text files via `registerTool`. Images and other binaries are detected via MIME-type sniffing and delegated to the built-in `read`. A precise query stops the scan early at the first chunk that answers it.
6
6
 
7
7
  ## Features
8
8
 
9
- **Built-in `read()` routing** — A `tool_call` listener intercepts the model's `read` calls. Image files (`png`, `jpg`, `gif`, `webp`, `svg`, `tiff`, `ico`, `heic`, `heif`) and other binaries (`pdf`, archives, Office docs, executables, media) pass through to `read()` unchanged. Text files and unknown extensions are blocked and the model is rerouted to `read-chunks` with the reason surfaced as the block message **except** when the path carries a trailing numeric line-range suffix (`:N` or `:START-END`): those bypass the block entirely and reach the built-in `read()` verbatim, since native read already serves them with no summarisation needed.
9
+ **MIME-sniffing routing** — The extension replaces the built-in `read` tool via `registerTool`. On each invocation, the first 16 bytes of the file are sniffed for known magic-number signatures (PNG: `89 50 4E 47`, JPEG: `FF D8 FF`, GIF: `GIF87a/GIF89a`, WebP: `RIFF....WEBP`, BMP: `BM`, TIFF: `II*`/`MM*`, ICO: `00 00 01 00`, SVG: `<svg`, PDF: `%PDF-`, ZIP-based formats: `PK`, MP4/MOV: `ftyp`, FLAC: `fLaC`, Ogg: `OggS`, WAV: `RIFF....WAVE`, TAR: `ustar`, GZIP: `1F 8B`, BZ2: `BZ`, XZ: `FD 37 7A 58 5A 00`, 7z: `37 7A BC AF 27 1C`, EXE/DLL: `MZ`, ELF: `7F E4 4C`, ISO: `CD001`). Recognised MIME types delegate to the native `read` tool for image rendering or byte delivery. Text files and unknown extensions proceed to our own logic.
10
10
 
11
- **Three read modes, one tool** — `read-chunks` selects automatically based on args:
11
+ **Three read modes, one tool** — `read-chunks` selects automatically based on file type and args:
12
12
  - *Full* — file is at or below `thresholdKB`. Returned verbatim. Matches built-in `read` semantics.
13
13
  - *Chunked scan* — file exceeds the threshold. Split into overlapping chunks at natural boundaries, each summarised by the active LLM with the running summary carried forward. With a `query`, scan stops at the first chunk whose summary contains the answer; without a query, the full file is summarised chunk-by-chunk and the running summary is returned.
14
14
  - *Line range* — `offset`/`limit` args, or the `path:N` / `path:START-END` suffix. Returns those lines verbatim and bypasses all summarisation.
@@ -23,6 +23,8 @@ Replaces the built-in `read()` for text files. Images and other binaries still p
23
23
 
24
24
  **Optional per-invocation debug dump** — `/read-chunks debug` (toggles on/off) writes each invocation's LLM requests/responses and the final tool return to `/tmp/read-chunks_<YYMMDD-hhmmss>.json`. Disabled by default.
25
25
 
26
+ **Summary budget** — `thresholdKB` also acts as a soft cap on total summary size. Each chunk's LLM prompt includes a budget hint: `thresholdKB / numChunks` KB per chunk. For a 160KB file with 50KB threshold (4 chunks), each chunk gets ~12KB of summary budget. A 500KB file (10 chunks) → 5KB per chunk. This keeps the total summary proportional to `thresholdKB` regardless of file size, preventing small files from wasting context while still allowing large files enough room for useful summaries.
27
+
26
28
 
27
29
  ## Installation
28
30
 
@@ -58,17 +60,15 @@ Copy read-chunks.example.json to:
58
60
 
59
61
  ```json
60
62
  {
61
- "thresholdKB": 10,
62
- "chunkChars": 10000,
63
+ "thresholdKB": 50,
63
64
  "chunkOverlapChars": 800
64
65
  }
65
66
  ```
66
67
 
67
- | Key | Default | Notes |
68
- | ------------------ | ------- | ---------------------------------------------------------------------------------------------- |
69
- | `thresholdKB` | `10` | Files ≤ this size are returned verbatim; larger files are chunked. |
70
- | `chunkChars` | `10000` | Target chunk size in characters. Size to the active model's context window with prompt overhead in mind — there is no internal cap on what is sent to the summariser. |
71
- | `chunkOverlapChars`| `800` | Backward overlap between consecutive chunks, in characters. |
68
+ | Key | Default | Notes |
69
+ | ------------------- | ------- | ---------------------------------------------------------------------------------------------- |
70
+ | `thresholdKB` | `50` | Files ≤ this size are returned verbatim; larger files are chunked using this same value as the target chunk size (KB). Also serves as the soft limit for total summary size — the per-chunk summary budget is `thresholdKB / numChunks`, so the total stays bounded to roughly one chunk's worth. |
71
+ | `chunkOverlapChars` | `800` | Backward overlap between consecutive chunks, in characters. |
72
72
 
73
73
 
74
74
  ## Usage
@@ -89,8 +89,8 @@ Examples:
89
89
  - `read-chunks({ path: "src/big.ts" })` — full file summarised chunk-by-chunk; running summary returned.
90
90
  - `read-chunks({ path: "src/big.ts:2000-2089" })` — exact line range, no summarisation.
91
91
  - `read-chunks({ path: "src/big.ts:300" })` — start at line 300, read to EOF.
92
- - `read-chunks({ path: "diagram.png" })` — blocked at `read()` level; the model is rerouted to use the built-in `read` for images.
93
- - `read("src/file.ts:388-437")` — passes through the extension unblocked; native `read` returns those lines verbatim (line-range suffixes are never routed to `read-chunks`).
92
+ - `read-chunks({ path: "diagram.png" })` — MIME sniffing detects the image type; delegating to the built-in `read` for native image rendering.
93
+ - `read("src/file.ts:388-437")` — line-range suffix parsed and translated to `offset`/`limit`; native `read` returns those lines verbatim (no summarisation needed).
94
94
 
95
95
  ### Slash command
96
96
 
@@ -106,7 +106,7 @@ Examples:
106
106
 
107
107
  - No relevance ranking. Every chunk receives exactly one LLM summary; chunks aren't scored or re-ordered.
108
108
  - No persistence. Summaries aren't cached between invocations; each call re-summarises from scratch.
109
- - No support for binary files. Image/binary pass-through to `read()` is the only mechanism for non-text inspection.
109
+ - No support for binary files. Image/binary detection via MIME sniffing — recognised types delegate to the built-in `read` for native rendering or byte delivery.
110
110
 
111
111
  ## Links
112
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-read-chunks",
3
- "version": "1.0.6",
3
+ "version": "2.0.0",
4
4
  "description": "A Pi Agent extension that uses a subagent to enhance `read` functionality for large text files to reduce context bloat, rot and lost in the middle issues.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,5 +1,4 @@
1
1
  {
2
- "thresholdKB": 40,
3
- "chunkChars": 24000,
2
+ "thresholdKB": 50,
4
3
  "chunkOverlapChars": 800
5
4
  }
package/read-chunks.ts CHANGED
@@ -1,48 +1,146 @@
1
1
  /**
2
2
  * read-chunks — chunked read tool for large TEXT files.
3
3
  *
4
- * TEXT-ONLY. Binary files (PDF, archives, executables, images already handled
5
- * by read, etc.) are NOT supported by this tool pass them through to read()
6
- * or another specialised tool. Boundary snapping, chunking, and LLM summarisation
7
- * all assume UTF-8 text.
4
+ * Replaces the built-in `read` tool. Files under the configured threshold
5
+ * are passed through to native read. Larger text files are split into
6
+ * overlapping chunks snapped to natural boundaries and summarised by the
7
+ * active model.
8
8
  *
9
- * Overrides the built-in `read` tool for text files. Files under the configured
10
- * size threshold are read in full. Larger files are split into overlapping chunks
11
- * snapped to natural boundaries (function endings for code, paragraph breaks
12
- * for prose), and every chunk is summarised by the active model, chaining the
13
- * running summary forward as chunks are consumed.
9
+ * Routing:
10
+ * 1. Image/binary (by MIME sniffing) delegate to native read
11
+ * 2. Line-range suffix (:N or :START-END) or explicit offset/limit
12
+ * delegate to native read (strips suffix, translates to offset/limit)
13
+ * 3. File thresholdKB delegate to native read
14
+ * 4. File > thresholdKB → chunked summarisation
14
15
  *
15
- * Modes:
16
- * - No query: walk every chunk, accumulate one running summary, return it.
17
- * - With query: walk chunks until one contains the answer; return the answer
18
- * and per-chunk summaries. Stops early when found.
19
- *
20
- * There is no relevance ranking. Every chunk receives exactly one LLM summary.
21
- *
22
- * Chunk size is bounded by the `chunkChars` config value only — there is no
23
- * internal character cap on what is sent to the summariser. Size the chunk to
24
- * the active model's context window with prompt overhead in mind.
25
- *
26
- * A tool_call listener routes the model away from the built-in read() for
27
- * text/unknown files and toward read-chunks with a query. Image and binary
28
- * files are passed through to read() unchanged.
29
- * Chunk labels are line ranges (start-end), with char offsets in parentheses for
30
- * diagnostics; this tool's `offset` and `limit` arguments remain line-based like
31
- * the built-in read tool.
32
- *
33
- * Config is read from <cwd>/.pi/read-chunks.json merged over hard-coded defaults.
34
- * Unknown or invalid config values are ignored. Missing or malformed config
35
- * silently uses defaults. Config is loaded for each tool invocation, so changes
36
- * apply without restarting the extension.
16
+ * Config is read from <cwd>/.pi/read-chunks.json merged over hard-coded
17
+ * defaults. Unknown or invalid config values are ignored.
37
18
  */
38
19
 
39
- import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } from "node:fs";
20
+ import { existsSync, readFileSync, statSync, appendFileSync } from "node:fs";
40
21
  import { extname, join, resolve } from "node:path";
41
22
  import * as os from "node:os";
42
23
  import { pathToFileURL } from "node:url";
43
24
  import { Text, hyperlink, getCapabilities } from "@earendil-works/pi-tui";
44
25
  import { Type } from "typebox";
45
26
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+ import { createReadTool } from "@earendil-works/pi-coding-agent";
28
+
29
+ // ---------- MIME type sniffing ----------
30
+
31
+ /**
32
+ * Read the first 16 bytes and check for known magic-number signatures.
33
+ * Returns the MIME type if recognised, null otherwise.
34
+ */
35
+ function sniffMimeType(buf: Buffer): string | null {
36
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
37
+ if (buf.length >= 8 &&
38
+ buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47 &&
39
+ buf[4] === 0x0D && buf[5] === 0x0A && buf[6] === 0x1A && buf[7] === 0x0A) {
40
+ return "image/png";
41
+ }
42
+ // JPEG: FF D8 FF
43
+ if (buf.length >= 3 && buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) {
44
+ return "image/jpeg";
45
+ }
46
+ // GIF87a / GIF89a
47
+ if (buf.length >= 6 &&
48
+ buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38 &&
49
+ (buf[5] === 0x61 || buf[5] === 0x39)) {
50
+ return "image/gif";
51
+ }
52
+ // WebP: RIFF....WEBP (bytes 0-3: RIFF, bytes 8-11: WEBP)
53
+ if (buf.length >= 12 &&
54
+ buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
55
+ buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) {
56
+ return "image/webp";
57
+ }
58
+ // BMP: BM
59
+ if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4D) {
60
+ return "image/bmp";
61
+ }
62
+ // TIFF (little-endian): II*
63
+ if (buf.length >= 4 && buf[0] === 0x49 && buf[1] === 0x49 && buf[2] === 0x2A && buf[3] === 0x00) {
64
+ return "image/tiff";
65
+ }
66
+ // TIFF (big-endian): MM*
67
+ if (buf.length >= 4 && buf[0] === 0x4D && buf[1] === 0x4D && buf[2] === 0x00 && buf[3] === 0x2A) {
68
+ return "image/tiff";
69
+ }
70
+ // ICO: 00 00 01 00
71
+ if (buf.length >= 4 && buf[0] === 0x00 && buf[1] === 0x00 && buf[2] === 0x01 && buf[3] === 0x00) {
72
+ return "image/x-icon";
73
+ }
74
+ // SVG (XML with svg element — check for <svg within first 16 bytes)
75
+ if (buf.length >= 16) {
76
+ const text = buf.toString("ascii", 0, 16).toLowerCase();
77
+ if (text.includes("<svg")) {
78
+ return "image/svg+xml";
79
+ }
80
+ }
81
+ // PDF: %PDF-
82
+ if (buf.length >= 5 && buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46 && buf[4] === 0x2D) {
83
+ return "application/pdf";
84
+ }
85
+ // ZIP-based: PK (DOCX, XLSX, PPTX, ODT, etc.)
86
+ if (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4B && buf[2] === 0x03 && buf[3] === 0x04) {
87
+ return "application/zip";
88
+ }
89
+ // MP4/MOV: ftyp box (bytes 4-7: "ftyp")
90
+ if (buf.length >= 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
91
+ return "video/mp4";
92
+ }
93
+ // FLAC: fLaC
94
+ if (buf.length >= 4 && buf[0] === 0x66 && buf[1] === 0x4C && buf[2] === 0x61 && buf[3] === 0x43) {
95
+ return "audio/flac";
96
+ }
97
+ // Ogg: OggS
98
+ if (buf.length >= 4 && buf[0] === 0x4F && buf[1] === 0x67 && buf[2] === 0x67 && buf[3] === 0x53) {
99
+ return "audio/ogg";
100
+ }
101
+ // WAV: RIFF....WAVE
102
+ if (buf.length >= 12 &&
103
+ buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
104
+ buf[8] === 0x57 && buf[9] === 0x41 && buf[10] === 0x56 && buf[11] === 0x45) {
105
+ return "audio/wav";
106
+ }
107
+ // TAR: offset 257-262 "ustar"
108
+ if (buf.length >= 263 && buf[257] === 0x75 && buf[258] === 0x73 && buf[259] === 0x74 && buf[260] === 0x61 && buf[261] === 0x72) {
109
+ return "application/x-tar";
110
+ }
111
+ // GZIP: 1F 8B
112
+ if (buf.length >= 2 && buf[0] === 0x1F && buf[1] === 0x8B) {
113
+ return "application/gzip";
114
+ }
115
+ // BZ2: 42 5A (BZ)
116
+ if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x5A) {
117
+ return "application/x-bzip2";
118
+ }
119
+ // XZ: FD 37 7A 58 5A 00
120
+ if (buf.length >= 6 &&
121
+ buf[0] === 0xFD && buf[1] === 0x37 && buf[2] === 0x7A && buf[3] === 0x58 && buf[4] === 0x5A && buf[5] === 0x00) {
122
+ return "application/x-xz";
123
+ }
124
+ // 7z: 37 7A BC AF 27 1C
125
+ if (buf.length >= 6 &&
126
+ buf[0] === 0x37 && buf[1] === 0x7A && buf[2] === 0xBC && buf[3] === 0xAF && buf[4] === 0x27 && buf[5] === 0x1C) {
127
+ return "application/x-7z-compressed";
128
+ }
129
+ // EXE/DLL: MZ
130
+ if (buf.length >= 2 && buf[0] === 0x4D && buf[1] === 0x5A) {
131
+ return "application/x-dosexec";
132
+ }
133
+ // ELF: 7F E4 4C 01 (Linux i386) or 7F E4 4C 02 (Linux x86-64)
134
+ if (buf.length >= 4 && buf[0] === 0x7F && buf[1] === 0xE4 && buf[2] === 0x4C && (buf[3] === 0x01 || buf[3] === 0x02)) {
135
+ return "application/x-executable";
136
+ }
137
+ // ISO: at offset 0x8001 "CD001"
138
+ if (buf.length >= 0x8006 &&
139
+ buf[0x8001] === 0x43 && buf[0x8002] === 0x44 && buf[0x8003] === 0x30 && buf[0x8004] === 0x30 && buf[0x8005] === 0x31) {
140
+ return "application/x-iso9660-image";
141
+ }
142
+ return null;
143
+ }
46
144
 
47
145
  // ---------- Config ----------
48
146
 
@@ -54,11 +152,8 @@ function debugTimestamp(): string {
54
152
  }
55
153
 
56
154
  const DEFAULT_CONFIG = {
57
- thresholdKB: 10,
58
- chunkChars: 10_000,
155
+ thresholdKB: 50,
59
156
  chunkOverlapChars: 800,
60
-
61
-
62
157
  codeExtensions: [
63
158
  ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
64
159
  ".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift",
@@ -71,7 +166,6 @@ const DEFAULT_CONFIG = {
71
166
 
72
167
  type ReadSafeConfig = typeof DEFAULT_CONFIG;
73
168
 
74
- /** Load per-project settings. Deliberate degraded behavior: missing or malformed config preserves working defaults. */
75
169
  function loadConfig(cwd: string): ReadSafeConfig {
76
170
  const cfgPath = join(cwd, ".pi", "read-chunks.json");
77
171
  if (!existsSync(cfgPath)) return { ...DEFAULT_CONFIG };
@@ -83,44 +177,6 @@ function loadConfig(cwd: string): ReadSafeConfig {
83
177
  }
84
178
  }
85
179
 
86
- // ---------- Image / binary pass-through ----------
87
-
88
- /**
89
- * read-chunks is text-only. The built-in `read` tool handles images natively and
90
- * can also stream other binaries; this extension must not block those calls, or
91
- * the model loses its only way to inspect non-text files. The two lists below
92
- * are the pass-through set: any path whose extension matches either is left
93
- * alone and `read()` runs as if the extension were not installed.
94
- */
95
-
96
- /** Raster + vector image formats the read tool renders natively. */
97
- const IMAGE_EXTENSIONS = [
98
- ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
99
- ".svg", ".tiff", ".tif", ".ico", ".heic", ".heif",
100
- ];
101
-
102
- /** Non-text binary formats read() can deliver as bytes. Add to this list to allow. */
103
- const BINARY_EXTENSIONS = [
104
- // Documents
105
- ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".rtf",
106
- // Archives
107
- ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".tbz2", ".txz",
108
- // Executables / objects
109
- ".exe", ".dll", ".so", ".dylib", ".o", ".a", ".obj", ".lib", ".class", ".jar",
110
- // Media (audio/video — read will likely refuse, but no harm in passing through)
111
- ".mp3", ".mp4", ".m4a", ".m4v", ".mov", ".avi", ".mkv", ".webm", ".ogg", ".wav", ".flac",
112
- // Other binary blobs
113
- ".bin", ".dat", ".iso", ".img", ".dmg",
114
- ];
115
-
116
- function isImagePath(path: string): boolean {
117
- return IMAGE_EXTENSIONS.includes(extname(path).toLowerCase());
118
- }
119
-
120
- function isBinaryPath(path: string): boolean {
121
- return BINARY_EXTENSIONS.includes(extname(path).toLowerCase());
122
- }
123
-
124
180
  // ---------- Language detection ----------
125
181
 
126
182
  function isCode(path: string, codeExtensions: string[]): boolean {
@@ -134,7 +190,6 @@ interface ChunkRange {
134
190
  end: number;
135
191
  }
136
192
 
137
- /** Last natural boundary at or before `target`. Code → `}` line / blank; text → `\r?\n\r?\n` / `\r?\n`. */
138
193
  function snapBackward(text: string, target: number, codeMode: boolean): number {
139
194
  if (target >= text.length) return text.length;
140
195
  if (target <= 0) return 0;
@@ -174,7 +229,6 @@ function snapBackward(text: string, target: number, codeMode: boolean): number {
174
229
  return boundary >= 0 ? boundary : target;
175
230
  }
176
231
 
177
- /** Next natural boundary at or after `target`. */
178
232
  function snapForward(text: string, target: number, codeMode: boolean): number {
179
233
  if (target <= 0) return 0;
180
234
  if (target >= text.length) return text.length;
@@ -203,22 +257,18 @@ function snapForward(text: string, target: number, codeMode: boolean): number {
203
257
 
204
258
  // ---------- Chunking ----------
205
259
 
206
- /**
207
- * Build ordered, non-empty chunks with a best-effort overlap.
208
- * If a natural boundary cannot provide safe forward progress, the next chunk
209
- * begins at the previous end rather than stalling or creating an empty range.
210
- */
211
260
  function buildChunks(
212
261
  text: string,
213
- chunkSize: number,
262
+ chunkKB: number,
214
263
  overlap: number,
215
264
  codeMode: boolean,
216
265
  ): ChunkRange[] {
266
+ const chunkChars = chunkKB * 1024;
217
267
  const chunks: ChunkRange[] = [];
218
268
  let cursor = 0;
219
269
 
220
270
  while (cursor < text.length) {
221
- const endTarget = Math.min(text.length, cursor + chunkSize);
271
+ const endTarget = Math.min(text.length, cursor + chunkChars);
222
272
  let end = snapBackward(text, endTarget, codeMode);
223
273
  if (end <= cursor) end = endTarget;
224
274
  chunks.push({ start: cursor, end });
@@ -238,20 +288,13 @@ const UNSUMMARISED = "<summarisation unavailable>";
238
288
  const ANSWER_MARKER = "| ANSWER:";
239
289
  const FACT_GUARD = "Do not invent facts; base everything on the chunk.";
240
290
 
241
- /** Append prior-summary and chunk blocks to the prompt. Shared by query and summary modes. */
242
291
  function pushChunkBlock(parts: string[], range: ChunkRange, chunkText: string, priorSummary: string, hasPriorSummary: boolean): void {
243
292
  if (hasPriorSummary) {
244
293
  parts.push("", "Prior summary (for context):", '"""', priorSummary, '"""');
245
294
  }
246
- // Send the whole chunk; size is governed by config.chunkChars, not capped here.
247
295
  parts.push("", `Current chunk (chars ${range.start}-${range.end}):`, '"""', chunkText, '"""', "", "Summary of current chunk:");
248
296
  }
249
297
 
250
- /**
251
- * Build the prompt for one summarisation call.
252
- * Two modes share most of their structure: query mode adds an ANSWER-marker
253
- * instruction and includes the query; summary mode omits both.
254
- */
255
298
  function buildSummarisePrompt(
256
299
  chunkText: string,
257
300
  priorSummary: string,
@@ -260,23 +303,22 @@ function buildSummarisePrompt(
260
303
  ): string {
261
304
  const parts: string[] = [];
262
305
  const hasPriorSummary = priorSummary.trim().length > 0;
306
+ const detailHint = "Preserve important information in the summary:\n - For Code: variables/functions, modules/packages, function calls, design patterns\n - For Prose: key factual information as bullet points";
263
307
 
264
308
  if (query) {
265
309
  parts.push(
266
310
  `You are scanning a large file one chunk at a time, looking for information relevant to this query: "${query}".`,
267
311
  "Read the NEXT chunk and produce a NEW summary of THIS CHUNK.",
268
312
  hasPriorSummary
269
- ? "Include any relevant context from the prior summary below, but the summary should be focused on the new chunk. Do NOT repeat or echo the prior summary. Write a fresh summary of the CURRENT chunk."
270
- : `Write a fresh summary of the CURRENT chunk. Be concise and factual. ${FACT_GUARD}`,
313
+ ? `${detailHint}\nInclude any relevant context from the prior summary below, but the summary should be focused on the new chunk. Do NOT repeat or echo the prior summary. Write a fresh summary of the CURRENT chunk. ${FACT_GUARD}`
314
+ : `${detailHint}\nWrite a fresh summary of the CURRENT chunk. ${FACT_GUARD}`,
271
315
  'If this chunk contains information that FULLY answers the query, begin your reply with exactly "| ANSWER:" ' +
272
316
  'followed by the precise answer passage, then a newline, then "---", then your NEW summary of this chunk.',
273
317
  "Otherwise reply with ONLY your NEW summary of this chunk (no marker, no separator).",
274
-
275
318
  "CRITICAL: Only emit the \"| ANSWER:\" marker if the chunk alone provides the COMPLETE answer to the query.",
276
319
  "If the query requires information from multiple parts of the file (e.g., a synopsis, comparison, timeline),",
277
320
  "do NOT mark individual chunks as answers. Continue reading until either the complete answer is found",
278
321
  "or the file has been fully read.",
279
- FACT_GUARD,
280
322
  );
281
323
  parts.push("", `Query: "${query}`);
282
324
  } else {
@@ -284,11 +326,10 @@ function buildSummarisePrompt(
284
326
  if (hasPriorSummary) {
285
327
  parts.push(
286
328
  "The following is a running summary of the parts already seen. It is for CONTEXT ONLY.",
287
- "Read the NEXT chunk and produce a NEW summary of THIS CHUNK. Include any relevant context from the prior summary, but the summary should be focused on the new chunk.",
288
- "Do NOT repeat or echo the prior summary. Write a fresh summary of the CURRENT chunk.",
329
+ `${detailHint}\nInclude any relevant context from the prior summary, but the summary should be focused on the new chunk. Do NOT repeat or echo the prior summary. Write a fresh summary of the CURRENT chunk. ${FACT_GUARD}`,
289
330
  );
290
331
  } else {
291
- parts.push(`Read the NEXT chunk and produce a NEW summary of THIS CHUNK. Be concise and factual. ${FACT_GUARD}`);
332
+ parts.push(`${detailHint}\nRead the NEXT chunk and produce a NEW summary of THIS CHUNK. ${FACT_GUARD}`);
292
333
  }
293
334
  }
294
335
 
@@ -296,15 +337,6 @@ function buildSummarisePrompt(
296
337
  return parts.join("\n");
297
338
  }
298
339
 
299
- /**
300
- * Summarise one chunk against the running summary.
301
- *
302
- * Size contract: `chunkText` is sent verbatim, bounded only by config.chunkChars.
303
- * Returns null when the model can't be reached/authed (caller keeps prior summary).
304
- *
305
- * `priorSummary` accumulates across calls in the caller; in query mode the reply
306
- * is parsed by `parseAnswerResponse` for the ANSWER marker and separator.
307
- */
308
340
  async function summariseChunk(
309
341
  chunkText: string,
310
342
  priorSummary: string,
@@ -312,7 +344,7 @@ async function summariseChunk(
312
344
  range: ChunkRange,
313
345
  filePath: string,
314
346
  modelRegistry: any,
315
- model: any, // Model<any> — passed directly to modelRegistry.complete
347
+ model: any,
316
348
  debugPath: string | null,
317
349
  debugEnabled: boolean,
318
350
  ): Promise<string | null> {
@@ -323,8 +355,6 @@ async function summariseChunk(
323
355
  return null;
324
356
  }
325
357
 
326
- // File content and query are untrusted prompt text. Delimiters improve structure
327
- // but do not neutralize instructions embedded in their contents.
328
358
  const prompt = buildSummarisePrompt(chunkText, priorSummary, query, range);
329
359
 
330
360
  const requestPayload = {
@@ -338,10 +368,7 @@ async function summariseChunk(
338
368
 
339
369
  let response;
340
370
  try {
341
- response = await modelRegistry.complete(
342
- model,
343
- requestPayload,
344
- );
371
+ response = await modelRegistry.complete(model, requestPayload);
345
372
  } catch {
346
373
  return null;
347
374
  }
@@ -357,7 +384,6 @@ async function summariseChunk(
357
384
  return summary || null;
358
385
  }
359
386
 
360
- /** Parse the query-mode contract out of an LLM reply. */
361
387
  function parseAnswerResponse(text: string): { answer?: string; summary: string } {
362
388
  const idx = text.indexOf(ANSWER_MARKER);
363
389
  if (idx < 0) return { summary: text.trim() };
@@ -375,6 +401,101 @@ function parseAnswerResponse(text: string): { answer?: string; summary: string }
375
401
  return { answer, summary: rest };
376
402
  }
377
403
 
404
+ // ---------- Line-range suffix parsing ----------
405
+
406
+ /**
407
+ * Parse :N or :START-END suffix from path. Returns { newPath, offset, limit }
408
+ * or null if no suffix found.
409
+ */
410
+ function parseLineRangeSuffix(path: string): { newPath: string; offset: number; limit?: number } | null {
411
+ const m = path.match(/^(.+):(\d+)(?:-(\d+))?$/);
412
+ if (!m) return null;
413
+ const newPath = m[1];
414
+ const startLine = Number(m[2]);
415
+ const endLine = m[3] !== undefined ? Number(m[3]) : undefined;
416
+ return {
417
+ newPath,
418
+ offset: startLine,
419
+ limit: endLine !== undefined ? Math.max(1, endLine - startLine + 1) : undefined,
420
+ };
421
+ }
422
+
423
+ // ---------- Call-header formatting ----------
424
+
425
+ function shortenPath(p: string): string {
426
+ const home = os.homedir();
427
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
428
+ }
429
+
430
+ function renderToolPath(rawPath: string | null, theme: any, cwd: string): string {
431
+ if (rawPath === null) return theme.fg("error", "[invalid arg]");
432
+ const value = rawPath || "";
433
+ if (!value) return theme.fg("toolOutput", "...");
434
+ const styled = theme.fg("accent", shortenPath(value));
435
+ if (!getCapabilities().hyperlinks) return styled;
436
+ return hyperlink(styled, pathToFileURL(resolve(cwd, value)).href);
437
+ }
438
+
439
+ function formatReadChunksCall(args: any, theme: any, cwd: string): string {
440
+ const rawPath = typeof args?.path === "string" ? args.path : "";
441
+
442
+ let pathPart = rawPath;
443
+ let rangeSuffix = "";
444
+ const m = rawPath.match(/^(.*?):(\d+)(?:-(\d+))?$/);
445
+ if (m && m.index !== undefined) {
446
+ rangeSuffix = `:${m[2]}${m[3] !== undefined ? `-${m[3]}` : ""}`;
447
+ pathPart = m[1];
448
+ } else {
449
+ const off = typeof args?.offset === "number" ? args.offset : undefined;
450
+ const lim = typeof args?.limit === "number" ? args.limit : undefined;
451
+ if (off !== undefined) {
452
+ rangeSuffix = lim !== undefined ? `:${off}-${off + lim - 1}` : `:${off}`;
453
+ }
454
+ }
455
+
456
+ const pathDisplay = renderToolPath(pathPart || null, theme, cwd);
457
+ let text = `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${theme.fg("warning", rangeSuffix)}`;
458
+
459
+ if (typeof args?.query === "string" && args.query.trim()) {
460
+ text += theme.fg("muted", ` [query: ${args.query.trim()}]`);
461
+ }
462
+ return text;
463
+ }
464
+
465
+ // ---------- Summary builder ----------
466
+
467
+ function buildSummary(parsed: any): string {
468
+ const lines: string[] = [];
469
+ const kbTotal = parsed.kb_total ?? 0;
470
+ const scanned = parsed.chunks_scanned ?? 0;
471
+ const readCount = parsed.chunks_read ?? 0;
472
+ const mode = parsed.mode;
473
+ const stopReason = parsed.stop_reason ?? "completed";
474
+ const chunks = parsed.chunks ?? [];
475
+
476
+ lines.push(`[read-chunks] File ~${kbTotal}KB, ${scanned} chunks, ${readCount} summarised (${stopReason}).`);
477
+
478
+ if (mode === "query" && parsed.answer) {
479
+ lines.push("", `Answer: ${parsed.answer}`);
480
+ }
481
+
482
+ const CAP = 25;
483
+ for (let i = 0; i < chunks.length && i < CAP; i++) {
484
+ const s = chunks[i].summary ?? "";
485
+ lines.push(`${chunks[i].chunk}: ${s}`);
486
+ }
487
+ if (chunks.length > CAP) {
488
+ lines.push(` ...and ${chunks.length - CAP} more chunk summaries.`);
489
+ }
490
+
491
+ lines.push(
492
+ "",
493
+ "Chunk labels are line ranges (start-end), with char offsets in parentheses. To inspect the original file, use read() with line-based offset/limit, or grep/find it.",
494
+ );
495
+
496
+ return lines.join("\n");
497
+ }
498
+
378
499
  // ---------- Main tool ----------
379
500
 
380
501
  const ReadParams = Type.Object({
@@ -385,12 +506,14 @@ const ReadParams = Type.Object({
385
506
  });
386
507
 
387
508
  export default function (pi: ExtensionAPI) {
388
- // Session-only flags — defaults ON / OFF respectively. /read-chunks [debug]
389
509
  let summaryCompressionEnabled = true;
390
510
  let debugReturnEnabled = false;
391
511
 
392
- pi.registerCommand("read-chunks", {
393
- description: "Toggle summary (default: on) when file size > configKB. 'debug' to toggle per-invocation /tmp/read-chunks_<YYMMDD-hhmmss>.json (default: off).",
512
+ // One native read instance for delegating images/binaries
513
+ const nativeRead = createReadTool(process.cwd());
514
+
515
+ pi.registerCommand("read-chunks", {
516
+ description: "Toggle summary compression (default: on). 'debug' to toggle per-invocation /tmp/read-chunks_<YYMMDD-hhmmss>.json (default: off).",
394
517
  handler: async (args, ctx) => {
395
518
  const tokens = (args ?? "").trim().toLowerCase().split(/\s+/);
396
519
  const wantDebug = tokens.includes("debug");
@@ -411,8 +534,8 @@ export default function (pi: ExtensionAPI) {
411
534
  });
412
535
 
413
536
  pi.registerTool({
414
- name: "read-chunks",
415
- label: "read-chunks (chunked scan)",
537
+ name: "read",
538
+ label: "read (chunked)",
416
539
  description:
417
540
  "TEXT-ONLY. Use instead of the built-in read() for text files.",
418
541
  parameters: ReadParams,
@@ -420,28 +543,21 @@ export default function (pi: ExtensionAPI) {
420
543
  async execute(_toolCallId, params, _signal, onUpdate, ctx) {
421
544
  let { path: rawPath, offset, limit } = params;
422
545
 
423
- // Accept the `file.txt:X-Y` line-range shorthand (inclusive 1-indexed
424
- // line span) and `file.txt:N` (start at line N, read to EOF) alongside
425
- // explicit offset/limit. Strip the suffix and convert it to
426
- // offset/start-line + limit/count.
427
- if (typeof rawPath === "string") {
428
- const m = rawPath.match(/:(\d+)(?:-(\d+))?$/);
429
- if (m && !offset && !limit) {
430
- const startLine = Number(m[1]);
431
- const endLine = m[2] !== undefined ? Number(m[2]) : undefined;
432
- rawPath = rawPath.slice(0, m.index);
433
- offset = startLine;
434
- if (endLine !== undefined) {
435
- limit = Math.max(1, endLine - startLine + 1);
436
- }
437
- }
546
+ // Step 1: Parse line-range suffix (:N or :START-END)
547
+ const suffixResult = parseLineRangeSuffix(rawPath);
548
+ if (suffixResult) {
549
+ rawPath = suffixResult.newPath;
550
+ offset = suffixResult.offset;
551
+ limit = suffixResult.limit;
438
552
  }
553
+
439
554
  const absolutePath = resolve(ctx.cwd, rawPath);
440
555
  const config = loadConfig(ctx.cwd);
441
556
 
442
- // Per-invocation timestamp so each run appends to its own file.
557
+ // Per-invocation timestamp for debug output
443
558
  const debugReturnPath = debugReturnEnabled ? `/tmp/read-chunks_${debugTimestamp()}.json` : null;
444
559
 
560
+ // Stat the file
445
561
  let stat;
446
562
  try {
447
563
  stat = statSync(absolutePath);
@@ -459,6 +575,24 @@ export default function (pi: ExtensionAPI) {
459
575
  };
460
576
  }
461
577
 
578
+ // Step 2: Sniff MIME type — image/binary → delegate to native read
579
+ let mime: string | null = null;
580
+ try {
581
+ const buf = readFileSync(absolutePath, { encoding: "binary", flag: "r" });
582
+ // Convert binary buffer to Buffer for sniffing
583
+ const bufAsBuffer = Buffer.from(buf, "binary");
584
+ mime = sniffMimeType(bufAsBuffer);
585
+ } catch {
586
+ // Can't read — fall through to text handling
587
+ }
588
+
589
+ if (mime) {
590
+ // Delegate to native read for images/binaries
591
+ const cleanParams = { ...params, path: rawPath };
592
+ return nativeRead.execute(_toolCallId, cleanParams, _signal, onUpdate, ctx);
593
+ }
594
+
595
+ // Step 3: Read content
462
596
  let content: string;
463
597
  try {
464
598
  content = readFileSync(absolutePath, "utf-8");
@@ -469,8 +603,7 @@ export default function (pi: ExtensionAPI) {
469
603
  };
470
604
  }
471
605
 
472
- // Explicit offset/limit request → return those lines verbatim.
473
- // Bypasses threshold/chunk/query logic; matches built-in read semantics.
606
+ // Step 4: Has explicit range (offset/limit or suffix) → return verbatim
474
607
  if (offset !== undefined || limit !== undefined) {
475
608
  const lines = content.split("\n");
476
609
  const startLine = Math.max(0, (offset ?? 1) - 1);
@@ -487,10 +620,10 @@ export default function (pi: ExtensionAPI) {
487
620
  };
488
621
  }
489
622
 
623
+ // Step 5: Small file → full read
490
624
  const sizeKB = stat.size / 1024;
491
625
  const codeMode = isCode(rawPath, config.codeExtensions);
492
626
 
493
- // Small file → full read
494
627
  if (sizeKB <= config.thresholdKB) {
495
628
  return {
496
629
  content: [{ type: "text", text: content }],
@@ -503,13 +636,11 @@ export default function (pi: ExtensionAPI) {
503
636
  };
504
637
  }
505
638
 
506
- // Large file → chunked summarisation
507
- const chunks = buildChunks(content, config.chunkChars, config.chunkOverlapChars, codeMode);
639
+ // Step 6: Large file → chunked summarisation
640
+ const chunks = buildChunks(content, config.thresholdKB, config.chunkOverlapChars, codeMode);
508
641
  const totalKB = Math.round((stat.size / 1024) * 10) / 10;
509
642
 
510
- // Map char offsets → 1-indexed line numbers so chunk labels match how the
511
- // agent reasons (built-in read uses line#, not char#). Precomputed once,
512
- // binary-searched per chunk (O(n log n)).
643
+ // Map char offsets → 1-indexed line numbers
513
644
  const lineStarts = [0];
514
645
  for (let i = 0; i < content.length; i++) {
515
646
  if (content[i] === "\n") lineStarts.push(i + 1);
@@ -525,7 +656,6 @@ export default function (pi: ExtensionAPI) {
525
656
  };
526
657
  const lineSpan = (range: ChunkRange): string => {
527
658
  const s = lineOfChar(range.start);
528
- // End is an exclusive offset: last included char is range.end - 1.
529
659
  const e = range.end >= content.length ? lineStarts.length : lineOfChar(range.end - 1);
530
660
  return `${s}-${e}`;
531
661
  };
@@ -534,15 +664,13 @@ export default function (pi: ExtensionAPI) {
534
664
  ? params.query.trim()
535
665
  : undefined;
536
666
 
537
- // Find a model: try ctx.model first (set during conversation), then fall back to any available model.
667
+ // Find a model
538
668
  let activeModel = ctx.model;
539
669
  if (!activeModel) {
540
670
  try {
541
671
  const available = await ctx.modelRegistry.getAvailable();
542
672
  if (available.length > 0) activeModel = available[0];
543
- } catch {
544
- // no model available
545
- }
673
+ } catch {}
546
674
  }
547
675
  if (!activeModel) {
548
676
  return {
@@ -572,7 +700,6 @@ export default function (pi: ExtensionAPI) {
572
700
  if (noteRaw) runningSummary = noteRaw;
573
701
  let displayNote = noteRaw ?? UNSUMMARISED;
574
702
 
575
- // In query mode, extract answer and use parsed summary for display.
576
703
  if (query && noteRaw) {
577
704
  const parsed = parseAnswerResponse(noteRaw);
578
705
  if (parsed.answer !== undefined) {
@@ -603,7 +730,6 @@ export default function (pi: ExtensionAPI) {
603
730
  chunks: perChunk,
604
731
  };
605
732
 
606
- // Write final tool return to debug file
607
733
  if (debugReturnEnabled) {
608
734
  try {
609
735
  appendFileSync(debugReturnPath, JSON.stringify({ phase: "tool_return", result: toolReturn }, null, 2) + "\n");
@@ -621,83 +747,17 @@ export default function (pi: ExtensionAPI) {
621
747
  };
622
748
  },
623
749
 
624
- // Mirror the built-in read() call header so the UI shows
625
- // `read-chunks <path>:<line-start-line-end>` (+ optional `[query: ...]`)
626
- // instead of just the bare tool name. Built-in tools render their header via
627
- // a custom renderCall; custom tools fall back to the plain tool-name fallback,
628
- // so we supply one here.
629
750
  renderCall(args, theme, context) {
630
751
  const text = context.lastComponent ?? new Text("", 0, 0);
631
752
  text.setText(formatReadChunksCall(args, theme, context.cwd));
632
753
  return text;
633
754
  },
634
-
635
- // Full-file results keep built-in read presentation behavior. Chunked results
636
- // are emitted as JSON because the model needs structured diagnostic metadata.
637
- });
638
-
639
- // Calls to the built-in read() are routed as follows:
640
- // - Image files (png/jpg/gif/...) → pass through; read() returns image content.
641
- // - Other binary files (pdf/zip/docx/...) → pass through; read() delivers bytes.
642
- // - Targeted line-range reads (offset and/or limit set) → pass through;
643
- // native read() serves them. These are scoped, summarisation-free.
644
- // - Line-range suffixes (:N or :START-END) on `path` → strip suffix,
645
- // translate to native read()'s offset/limit by mutating event.input,
646
- // and let native read() execute. Native read() does not understand the
647
- // suffix form, so the rewrite is required.
648
- // - Text files (and unknown extensions) without a range → block, route
649
- // model to read-chunks.
650
- // The returned reason is surfaced to the model for its continuation; omitting
651
- // terminate keeps the turn alive so the model reroutes to read-chunks.
652
- pi.on("tool_call", (event) => {
653
- if (event.toolName !== "read") return;
654
- const input = event.input as { path?: unknown; offset?: number; limit?: number } | undefined;
655
- const path = typeof input?.path === "string" ? input.path : "";
656
- if (path && (isImagePath(path) || isBinaryPath(path))) return;
657
-
658
- // Targeted line-range read: native read() handles it cheaply and the model
659
- // already uses this form (offset/limit) because the read tool's schema
660
- // documents it. Bypass the block — these are scoped, summarisation-free
661
- // reads, exactly the kind we want read() to serve directly.
662
- const hasExplicitRange = typeof input?.offset === "number" || typeof input?.limit === "number";
663
- if (hasExplicitRange) return;
664
-
665
- // Numeric line-range suffix (:N or :START-END): strip it from `path` and
666
- // translate to native read()'s offset/limit, mutating event.input in place.
667
- // Per the extension API contract, in-place mutation patches the args that
668
- // the tool will execute with — no re-validation occurs after.
669
- //
670
- // Requires at least one char before the `:digits` (`.+`, not `.*?`) so
671
- // that bare `:50` is rejected (a path cannot be `:50`) and so the engine
672
- // picks the *last* colon-separator when the path itself contains colons
673
- // (e.g. Windows `C:\Users\x\file.txt:10` or any URL-like prefix).
674
- const m = path.match(/^(.+):(\d+)(?:-(\d+))?$/);
675
- if (m) {
676
- const newPath = m[1];
677
- const startLine = Number(m[2]);
678
- const endLine = m[3] !== undefined ? Number(m[3]) : undefined;
679
- input!.path = newPath;
680
- input!.offset = startLine;
681
- if (endLine !== undefined) {
682
- input!.limit = Math.max(1, endLine - startLine + 1);
683
- } else {
684
- delete input!.limit;
685
- }
686
- return;
687
- }
688
-
689
- return {
690
- block: true,
691
- reason: "For text files: use read-chunks(path/file) or read-chunks(path/file, query) with a concise query describing what you are looking for or read-chunks(path/file):linestart-lineend for a bounded file read.",
692
- };
693
755
  });
694
756
 
695
757
  // Compress chunked-mode results so raw JSON does not bloat model context.
696
- // This hook changes model-visible content after execution; it does not replace
697
- // the original tool execution result. Skipped when /read-chunks toggles it off.
698
758
  pi.on("tool_result", async (event, _ctx) => {
699
759
  if (!summaryCompressionEnabled) return;
700
- if (event.toolName !== "read-chunks") return;
760
+ if (event.toolName !== "read") return;
701
761
  if (event.isError) return;
702
762
  const details = event.details as { mode?: string } | undefined;
703
763
  if (details?.mode !== "chunked") return;
@@ -720,94 +780,3 @@ export default function (pi: ExtensionAPI) {
720
780
  };
721
781
  });
722
782
  }
723
-
724
- // ---------- Call-header formatting ----------
725
-
726
- /** Shorten a path by replacing the home dir prefix with ~ (matches pi's built-in read header). */
727
- function shortenPath(p: string): string {
728
- const home = os.homedir();
729
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
730
- }
731
-
732
- /** Render a path accent-colored and hyperlinked when the terminal supports it. Mirrors pi's renderToolPath(). */
733
- function renderToolPath(rawPath: string | null, theme: any, cwd: string): string {
734
- if (rawPath === null) return theme.fg("error", "[invalid arg]");
735
- const value = rawPath || "";
736
- if (!value) return theme.fg("toolOutput", "...");
737
- const styled = theme.fg("accent", shortenPath(value));
738
- if (!getCapabilities().hyperlinks) return styled;
739
- return hyperlink(styled, pathToFileURL(resolve(cwd, value)).href);
740
- }
741
-
742
- /**
743
- * Build the read-chunks call header: `read-chunks <path>:<range>` plus an optional
744
- * `[query: ...]` suffix. Derives the line-range from the same `:N` / `:START-END`
745
- * shorthand execute() parses, so the header matches what was actually requested.
746
- */
747
- function formatReadChunksCall(args: any, theme: any, cwd: string): string {
748
- const rawPath = typeof args?.path === "string" ? args.path : "";
749
-
750
- let pathPart = rawPath;
751
- let rangeSuffix = "";
752
- const m = rawPath.match(/^(.*?):(\d+)(?:-(\d+))?$/);
753
- if (m && m.index !== undefined) {
754
- rangeSuffix = `:${m[2]}${m[3] !== undefined ? `-${m[3]}` : ""}`;
755
- pathPart = m[1];
756
- } else {
757
- // No `:suffix` on path: derive the same `:START[-END]` form from explicit
758
- // offset/limit args so the header reflects what was actually requested.
759
- // Matches execute()'s semantics: offset alone = start at line N, read to EOF.
760
- const off = typeof args?.offset === "number" ? args.offset : undefined;
761
- const lim = typeof args?.limit === "number" ? args.limit : undefined;
762
- if (off !== undefined) {
763
- rangeSuffix = lim !== undefined ? `:${off}-${off + lim - 1}` : `:${off}`;
764
- }
765
- }
766
-
767
- const pathDisplay = renderToolPath(pathPart || null, theme, cwd);
768
- let text = `${theme.fg("toolTitle", theme.bold("read-chunks"))} ${pathDisplay}${theme.fg("warning", rangeSuffix)}`;
769
-
770
- if (typeof args?.query === "string" && args.query.trim()) {
771
- text += theme.fg("muted", ` [query: ${args.query.trim()}]`);
772
- }
773
- return text;
774
- }
775
-
776
- // ---------- Summary builder ----------
777
-
778
- /** Compact the raw chunked payload for in-context consumption. */
779
- function buildSummary(parsed: any): string {
780
- const lines: string[] = [];
781
- const kbTotal = parsed.kb_total ?? 0;
782
- const scanned = parsed.chunks_scanned ?? 0;
783
- const readCount = parsed.chunks_read ?? 0;
784
- const mode = parsed.mode;
785
- const stopReason = parsed.stop_reason ?? "completed";
786
- const chunks = parsed.chunks ?? [];
787
-
788
- lines.push(`[read-chunks] File ~${kbTotal}KB, ${scanned} chunks, ${readCount} summarised (${stopReason}).`);
789
-
790
- if (mode === "query" && parsed.answer) {
791
- const ans = String(parsed.answer).length > 300
792
- ? String(parsed.answer).slice(0, 297) + "..."
793
- : String(parsed.answer);
794
- lines.push("", `Answer: ${ans}`);
795
- }
796
-
797
- const CAP = 25;
798
- for (let i = 0; i < chunks.length && i < CAP; i++) {
799
- const s = chunks[i].summary ?? "";
800
- const trimmed = s.length > 120 ? s.slice(0, 117) + "..." : s;
801
- lines.push(`${chunks[i].chunk}: ${trimmed}`);
802
- }
803
- if (chunks.length > CAP) {
804
- lines.push(` ...and ${chunks.length - CAP} more chunk summaries.`);
805
- }
806
-
807
- lines.push(
808
- "",
809
- "Chunk labels are line ranges (start-end), with char offsets in parentheses. To inspect the original file, use read() with line-based offset/limit, or grep/find it.",
810
- );
811
-
812
- return lines.join("\n");
813
- }