pi-read-chunks 1.0.5 → 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 +13 -13
- package/package.json +1 -1
- package/read-chunks.example.json +1 -2
- package/read-chunks.ts +278 -300
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
|
|
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
|
-
**
|
|
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":
|
|
62
|
-
"chunkChars": 10000,
|
|
63
|
+
"thresholdKB": 50,
|
|
63
64
|
"chunkOverlapChars": 800
|
|
64
65
|
}
|
|
65
66
|
```
|
|
66
67
|
|
|
67
|
-
| Key
|
|
68
|
-
|
|
|
69
|
-
| `thresholdKB`
|
|
70
|
-
| `
|
|
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" })` —
|
|
93
|
-
- `read("src/file.ts:388-437")` —
|
|
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
|
|
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": "
|
|
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",
|
package/read-chunks.example.json
CHANGED
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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 +
|
|
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
|
-
?
|
|
270
|
-
:
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
393
|
-
|
|
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
|
|
415
|
-
label: "read
|
|
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
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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
|
|
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: "Built-in read() is disabled for text files. Use read-chunks(path/file, query) with a concise query describing what you are looking for.",
|
|
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
|
|
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,85 +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
|
-
}
|
|
757
|
-
|
|
758
|
-
const pathDisplay = renderToolPath(pathPart || null, theme, cwd);
|
|
759
|
-
let text = `${theme.fg("toolTitle", theme.bold("read-chunks"))} ${pathDisplay}${theme.fg("warning", rangeSuffix)}`;
|
|
760
|
-
|
|
761
|
-
if (typeof args?.query === "string" && args.query.trim()) {
|
|
762
|
-
text += theme.fg("muted", ` [query: ${args.query.trim()}]`);
|
|
763
|
-
}
|
|
764
|
-
return text;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
// ---------- Summary builder ----------
|
|
768
|
-
|
|
769
|
-
/** Compact the raw chunked payload for in-context consumption. */
|
|
770
|
-
function buildSummary(parsed: any): string {
|
|
771
|
-
const lines: string[] = [];
|
|
772
|
-
const kbTotal = parsed.kb_total ?? 0;
|
|
773
|
-
const scanned = parsed.chunks_scanned ?? 0;
|
|
774
|
-
const readCount = parsed.chunks_read ?? 0;
|
|
775
|
-
const mode = parsed.mode;
|
|
776
|
-
const stopReason = parsed.stop_reason ?? "completed";
|
|
777
|
-
const chunks = parsed.chunks ?? [];
|
|
778
|
-
|
|
779
|
-
lines.push(`[read-chunks] File ~${kbTotal}KB, ${scanned} chunks, ${readCount} summarised (${stopReason}).`);
|
|
780
|
-
|
|
781
|
-
if (mode === "query" && parsed.answer) {
|
|
782
|
-
const ans = String(parsed.answer).length > 300
|
|
783
|
-
? String(parsed.answer).slice(0, 297) + "..."
|
|
784
|
-
: String(parsed.answer);
|
|
785
|
-
lines.push("", `Answer: ${ans}`);
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
const CAP = 25;
|
|
789
|
-
for (let i = 0; i < chunks.length && i < CAP; i++) {
|
|
790
|
-
const s = chunks[i].summary ?? "";
|
|
791
|
-
const trimmed = s.length > 120 ? s.slice(0, 117) + "..." : s;
|
|
792
|
-
lines.push(`${chunks[i].chunk}: ${trimmed}`);
|
|
793
|
-
}
|
|
794
|
-
if (chunks.length > CAP) {
|
|
795
|
-
lines.push(` ...and ${chunks.length - CAP} more chunk summaries.`);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
lines.push(
|
|
799
|
-
"",
|
|
800
|
-
"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.",
|
|
801
|
-
);
|
|
802
|
-
|
|
803
|
-
return lines.join("\n");
|
|
804
|
-
}
|