pi-read-chunks 1.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/LICENSE +21 -0
- package/README.md +117 -0
- package/package.json +28 -0
- package/read-chunks.example.json +5 -0
- package/read-chunks.ts +703 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# pi-read-chunks
|
|
2
|
+
|
|
3
|
+
A chunked, summarising `read` tool for large text files. Files under a size threshold are returned verbatim; larger files are split 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.
|
|
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; a line-range suffix (`file.txt:2000-2089` or `file.txt:N`) returns those lines verbatim with no summarisation.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
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.
|
|
10
|
+
|
|
11
|
+
**Three read modes, one tool** — `read-chunks` selects automatically based on args:
|
|
12
|
+
- *Full* — file is at or below `thresholdKB`. Returned verbatim. Matches built-in `read` semantics.
|
|
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
|
+
- *Line range* — `offset`/`limit` args, or the `path:N` / `path:START-END` suffix. Returns those lines verbatim and bypasses all summarisation.
|
|
15
|
+
|
|
16
|
+
**Query-driven early stop** — Pass a precise `query` and the model is instructed to begin its reply with `| ANSWER:` followed by the passage that answers it. As soon as a chunk's summary returns the marker, scanning halts and the answer is surfaced directly; the rest of the file is never loaded.
|
|
17
|
+
|
|
18
|
+
**Natural-boundary snapping** — Chunk edges snap backward to the nearest `}` line or blank line for code, paragraph break or line break for prose; forward snaps to the next top-level declaration (function/class/etc.) or paragraph. Hard fallback to the raw character target if no boundary exists within the 2000-char search window, so chunking never stalls on edge cases.
|
|
19
|
+
|
|
20
|
+
**Context-aware chunk labels** — Chunk labels are line ranges (`start-end`) with char offsets in parentheses, derived by binary-searching the file's `\n` positions. Labels match how the agent reasons (line numbers, not raw char offsets).
|
|
21
|
+
|
|
22
|
+
**Per-tool-result compression** — The raw chunked-mode payload is JSON; a `tool_result` hook rewrites it into a compact, line-budgeted summary (~25 chunk entries × 120 chars each) before the model sees it, so a multi-chunk scan doesn't blow the context budget. Toggle with `/read-chunks`.
|
|
23
|
+
|
|
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
|
+
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Install from npm:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi install npm:pi-read-chunks
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Install into the current project only:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pi install npm:pi-read-chunks -l
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or install from GitHub:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pi install git:github.com/ashLatham/pi-read-chunks
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Try it without permanently installing:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pi -e npm:pi-read-chunks
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
All config is optional. Defaults are used when the file is absent or malformed.
|
|
56
|
+
Copy read-chunks.example.json to:
|
|
57
|
+
`<cwd>/.pi/read-chunks.json`:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"thresholdKB": 10,
|
|
62
|
+
"chunkChars": 10000,
|
|
63
|
+
"chunkOverlapChars": 800
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
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. |
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
### Tool arguments
|
|
77
|
+
|
|
78
|
+
| Arg | Type | Description |
|
|
79
|
+
| -------- | ------ | ----------- |
|
|
80
|
+
| `path` | string | Path to the file (relative or absolute). Append `:START-END` (inclusive 1-indexed line span, e.g. `file.txt:2000-2089`) or `:N` (start at line N, read to EOF) to bypass summarisation. |
|
|
81
|
+
| `offset` | number | Optional. 1-indexed start line. Bypasses summarisation. |
|
|
82
|
+
| `limit` | number | Optional. Max lines to return. Bypasses summarisation. |
|
|
83
|
+
| `query` | string | Optional. Precise search query; scan stops at the first chunk whose summary contains the answer. |
|
|
84
|
+
|
|
85
|
+
Always pass a `query` when scanning a large file — without one, the tool walks every chunk to produce a running summary.
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
- `read-chunks({ path: "src/big.ts", query: "where is the retry backoff configured" })` — scans until found.
|
|
89
|
+
- `read-chunks({ path: "src/big.ts" })` — full file summarised chunk-by-chunk; running summary returned.
|
|
90
|
+
- `read-chunks({ path: "src/big.ts:2000-2089" })` — exact line range, no summarisation.
|
|
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
|
+
|
|
94
|
+
### Slash command
|
|
95
|
+
|
|
96
|
+
`/read-chunks` — toggle summary compression of chunked-mode results (default: ON).
|
|
97
|
+
`/read-chunks debug` — toggle the per-invocation `/tmp/read-chunks_<timestamp>.json` dump (default: OFF).
|
|
98
|
+
|
|
99
|
+
### Notification levels
|
|
100
|
+
|
|
101
|
+
- `info` — chunk progress (`read-chunks: 1200-1450 (chunk 3/12)`), toggle state changes
|
|
102
|
+
- `error` — no model available for summarisation, file not found, not a regular file, read error
|
|
103
|
+
|
|
104
|
+
### What `read-chunks` does NOT do
|
|
105
|
+
|
|
106
|
+
- No relevance ranking. Every chunk receives exactly one LLM summary; chunks aren't scored or re-ordered.
|
|
107
|
+
- No persistence. Summaries aren't cached between invocations; each call re-summarises from scratch.
|
|
108
|
+
- No support for binary files. Image/binary pass-through to `read()` is the only mechanism for non-text inspection.
|
|
109
|
+
|
|
110
|
+
## Links
|
|
111
|
+
|
|
112
|
+
- npm: https://www.npmjs.com/package/pi-read-chunks
|
|
113
|
+
- GitHub: https://github.com/ashLatham/pi-read-chunks
|
|
114
|
+
- Pi Agent: https://github.com/earendil-works/pi
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-read-chunks",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pi extension that replaces the built-in read() for text files with a chunked, summarising reader. Large files are split at natural boundaries, each chunk is summarised by the active model, and a query stops the scan early at the first chunk that answers it. Line ranges and binary files pass through unchanged.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension"
|
|
8
|
+
],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/ashLatham/pi-read-chunks.git"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/ashLatham/pi-read-chunks",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/ashLatham/pi-read-chunks/issues"
|
|
17
|
+
},
|
|
18
|
+
"pi": {
|
|
19
|
+
"extensions": [
|
|
20
|
+
"./read-chunks.ts"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"read-chunks.ts",
|
|
25
|
+
"read-chunks.example.json",
|
|
26
|
+
"README.md"
|
|
27
|
+
]
|
|
28
|
+
}
|
package/read-chunks.ts
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read-chunks — chunked read tool for large TEXT files.
|
|
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.
|
|
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.
|
|
14
|
+
*
|
|
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.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } from "node:fs";
|
|
40
|
+
import { extname, join, resolve } from "node:path";
|
|
41
|
+
import { Type } from "typebox";
|
|
42
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
43
|
+
|
|
44
|
+
// ---------- Config ----------
|
|
45
|
+
|
|
46
|
+
/** Timestamp suffix for per-invocation debug output: YYMMDD-hhmmss. */
|
|
47
|
+
function debugTimestamp(): string {
|
|
48
|
+
const d = new Date();
|
|
49
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
50
|
+
return `${p(d.getFullYear() % 100)}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_CONFIG = {
|
|
54
|
+
thresholdKB: 10,
|
|
55
|
+
chunkChars: 10_000,
|
|
56
|
+
chunkOverlapChars: 800,
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
codeExtensions: [
|
|
60
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
61
|
+
".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift",
|
|
62
|
+
".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx",
|
|
63
|
+
".m", ".mm", ".sh", ".bash", ".zsh", ".fish",
|
|
64
|
+
".lua", ".pl", ".php", ".scala", ".clj", ".ex", ".exs", ".elm",
|
|
65
|
+
".hs", ".ml", ".fs", ".dart", ".zig", ".sql", ".vim",
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type ReadSafeConfig = typeof DEFAULT_CONFIG;
|
|
70
|
+
|
|
71
|
+
/** Load per-project settings. Deliberate degraded behavior: missing or malformed config preserves working defaults. */
|
|
72
|
+
function loadConfig(cwd: string): ReadSafeConfig {
|
|
73
|
+
const cfgPath = join(cwd, ".pi", "read-chunks.json");
|
|
74
|
+
if (!existsSync(cfgPath)) return { ...DEFAULT_CONFIG };
|
|
75
|
+
try {
|
|
76
|
+
const raw = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
77
|
+
return { ...DEFAULT_CONFIG, ...raw };
|
|
78
|
+
} catch {
|
|
79
|
+
return { ...DEFAULT_CONFIG };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------- Image / binary pass-through ----------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* read-chunks is text-only. The built-in `read` tool handles images natively and
|
|
87
|
+
* can also stream other binaries; this extension must not block those calls, or
|
|
88
|
+
* the model loses its only way to inspect non-text files. The two lists below
|
|
89
|
+
* are the pass-through set: any path whose extension matches either is left
|
|
90
|
+
* alone and `read()` runs as if the extension were not installed.
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/** Raster + vector image formats the read tool renders natively. */
|
|
94
|
+
const IMAGE_EXTENSIONS = [
|
|
95
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
|
|
96
|
+
".svg", ".tiff", ".tif", ".ico", ".heic", ".heif",
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/** Non-text binary formats read() can deliver as bytes. Add to this list to allow. */
|
|
100
|
+
const BINARY_EXTENSIONS = [
|
|
101
|
+
// Documents
|
|
102
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".rtf",
|
|
103
|
+
// Archives
|
|
104
|
+
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".tbz2", ".txz",
|
|
105
|
+
// Executables / objects
|
|
106
|
+
".exe", ".dll", ".so", ".dylib", ".o", ".a", ".obj", ".lib", ".class", ".jar",
|
|
107
|
+
// Media (audio/video — read will likely refuse, but no harm in passing through)
|
|
108
|
+
".mp3", ".mp4", ".m4a", ".m4v", ".mov", ".avi", ".mkv", ".webm", ".ogg", ".wav", ".flac",
|
|
109
|
+
// Other binary blobs
|
|
110
|
+
".bin", ".dat", ".iso", ".img", ".dmg",
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
function isImagePath(path: string): boolean {
|
|
114
|
+
return IMAGE_EXTENSIONS.includes(extname(path).toLowerCase());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isBinaryPath(path: string): boolean {
|
|
118
|
+
return BINARY_EXTENSIONS.includes(extname(path).toLowerCase());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------- Language detection ----------
|
|
122
|
+
|
|
123
|
+
function isCode(path: string, codeExtensions: string[]): boolean {
|
|
124
|
+
return codeExtensions.includes(extname(path).toLowerCase());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------- Boundary snapping ----------
|
|
128
|
+
|
|
129
|
+
interface ChunkRange {
|
|
130
|
+
start: number;
|
|
131
|
+
end: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Last natural boundary at or before `target`. Code → `}` line / blank; text → `\r?\n\r?\n` / `\r?\n`. */
|
|
135
|
+
function snapBackward(text: string, target: number, codeMode: boolean): number {
|
|
136
|
+
if (target >= text.length) return text.length;
|
|
137
|
+
if (target <= 0) return 0;
|
|
138
|
+
|
|
139
|
+
const windowStart = Math.max(0, target - 2000);
|
|
140
|
+
const slice = text.slice(windowStart, target);
|
|
141
|
+
let boundary = -1;
|
|
142
|
+
|
|
143
|
+
if (codeMode) {
|
|
144
|
+
const braceRe = /^[ \t]*\}[ \t]*$/gm;
|
|
145
|
+
let m: RegExpExecArray | null;
|
|
146
|
+
let lastMatch: RegExpExecArray | null = null;
|
|
147
|
+
while ((m = braceRe.exec(slice)) !== null) lastMatch = m;
|
|
148
|
+
if (lastMatch) {
|
|
149
|
+
boundary = windowStart + lastMatch.index + lastMatch[0].length;
|
|
150
|
+
} else {
|
|
151
|
+
const blankRe = /\r?\n[ \t]*\r?\n/g;
|
|
152
|
+
let lastBlank: RegExpExecArray | null = null;
|
|
153
|
+
while ((m = blankRe.exec(slice)) !== null) lastBlank = m;
|
|
154
|
+
if (lastBlank) boundary = windowStart + lastBlank.index;
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
const paraRe = /\r?\n[ \t]*\r?\n/g;
|
|
158
|
+
let m: RegExpExecArray | null;
|
|
159
|
+
let lastPara: RegExpExecArray | null = null;
|
|
160
|
+
while ((m = paraRe.exec(slice)) !== null) lastPara = m;
|
|
161
|
+
if (lastPara) {
|
|
162
|
+
boundary = windowStart + lastPara.index;
|
|
163
|
+
} else {
|
|
164
|
+
const lineRe = /\r?\n/g;
|
|
165
|
+
let lastLine: RegExpExecArray | null = null;
|
|
166
|
+
while ((m = lineRe.exec(slice)) !== null) lastLine = m;
|
|
167
|
+
if (lastLine) boundary = windowStart + lastLine.index;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return boundary >= 0 ? boundary : target;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Next natural boundary at or after `target`. */
|
|
175
|
+
function snapForward(text: string, target: number, codeMode: boolean): number {
|
|
176
|
+
if (target <= 0) return 0;
|
|
177
|
+
if (target >= text.length) return text.length;
|
|
178
|
+
|
|
179
|
+
const windowEnd = Math.min(text.length, target + 2000);
|
|
180
|
+
const slice = text.slice(target, windowEnd);
|
|
181
|
+
|
|
182
|
+
if (codeMode) {
|
|
183
|
+
const declRe = /^[ \t]*(?:function |def |fn |pub fn |async fn |class |struct |interface |trait |impl |module |package |export |async function )/gm;
|
|
184
|
+
const m = declRe.exec(slice);
|
|
185
|
+
if (m) return target + m.index;
|
|
186
|
+
const blankRe = /\r?\n[ \t]*\r?\n/g;
|
|
187
|
+
const mb = blankRe.exec(slice);
|
|
188
|
+
if (mb) return target + mb.index + 1;
|
|
189
|
+
} else {
|
|
190
|
+
const paraRe = /\r?\n[ \t]*\r?\n/g;
|
|
191
|
+
const m = paraRe.exec(slice);
|
|
192
|
+
if (m) return target + m.index;
|
|
193
|
+
const lineRe = /\r?\n/g;
|
|
194
|
+
const ml = lineRe.exec(slice);
|
|
195
|
+
if (ml) return target + ml.index + 1;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return target;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------- Chunking ----------
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build ordered, non-empty chunks with a best-effort overlap.
|
|
205
|
+
* If a natural boundary cannot provide safe forward progress, the next chunk
|
|
206
|
+
* begins at the previous end rather than stalling or creating an empty range.
|
|
207
|
+
*/
|
|
208
|
+
function buildChunks(
|
|
209
|
+
text: string,
|
|
210
|
+
chunkSize: number,
|
|
211
|
+
overlap: number,
|
|
212
|
+
codeMode: boolean,
|
|
213
|
+
): ChunkRange[] {
|
|
214
|
+
const chunks: ChunkRange[] = [];
|
|
215
|
+
let cursor = 0;
|
|
216
|
+
|
|
217
|
+
while (cursor < text.length) {
|
|
218
|
+
const endTarget = Math.min(text.length, cursor + chunkSize);
|
|
219
|
+
let end = snapBackward(text, endTarget, codeMode);
|
|
220
|
+
if (end <= cursor) end = endTarget;
|
|
221
|
+
chunks.push({ start: cursor, end });
|
|
222
|
+
if (end >= text.length) break;
|
|
223
|
+
|
|
224
|
+
const nextStart = Math.max(0, end - overlap);
|
|
225
|
+
const snapped = snapForward(text, nextStart, codeMode);
|
|
226
|
+
cursor = snapped <= cursor ? end : snapped;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return chunks;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------- LLM summarisation ----------
|
|
233
|
+
|
|
234
|
+
const UNSUMMARISED = "<summarisation unavailable>";
|
|
235
|
+
const ANSWER_MARKER = "| ANSWER:";
|
|
236
|
+
const FACT_GUARD = "Do not invent facts; base everything on the chunk.";
|
|
237
|
+
|
|
238
|
+
/** Append prior-summary and chunk blocks to the prompt. Shared by query and summary modes. */
|
|
239
|
+
function pushChunkBlock(parts: string[], range: ChunkRange, chunkText: string, priorSummary: string, hasPriorSummary: boolean): void {
|
|
240
|
+
if (hasPriorSummary) {
|
|
241
|
+
parts.push("", "Prior summary (for context):", '"""', priorSummary, '"""');
|
|
242
|
+
}
|
|
243
|
+
// Send the whole chunk; size is governed by config.chunkChars, not capped here.
|
|
244
|
+
parts.push("", `Current chunk (chars ${range.start}-${range.end}):`, '"""', chunkText, '"""', "", "Summary of current chunk:");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Build the prompt for one summarisation call.
|
|
249
|
+
* Two modes share most of their structure: query mode adds an ANSWER-marker
|
|
250
|
+
* instruction and includes the query; summary mode omits both.
|
|
251
|
+
*/
|
|
252
|
+
function buildSummarisePrompt(
|
|
253
|
+
chunkText: string,
|
|
254
|
+
priorSummary: string,
|
|
255
|
+
query: string | undefined,
|
|
256
|
+
range: ChunkRange,
|
|
257
|
+
): string {
|
|
258
|
+
const parts: string[] = [];
|
|
259
|
+
const hasPriorSummary = priorSummary.trim().length > 0;
|
|
260
|
+
|
|
261
|
+
if (query) {
|
|
262
|
+
parts.push(
|
|
263
|
+
`You are scanning a large file one chunk at a time, looking for information relevant to this query: "${query}".`,
|
|
264
|
+
"Read the NEXT chunk and produce a NEW summary of THIS CHUNK.",
|
|
265
|
+
hasPriorSummary
|
|
266
|
+
? "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."
|
|
267
|
+
: `Write a fresh summary of the CURRENT chunk. Be concise and factual. ${FACT_GUARD}`,
|
|
268
|
+
'If this chunk contains information that answers the query, begin your reply with exactly "| ANSWER:" ' +
|
|
269
|
+
'followed by the precise answer passage, then a newline, then "---", then your NEW summary of this chunk.',
|
|
270
|
+
"Otherwise reply with ONLY your NEW summary of this chunk (no marker, no separator).",
|
|
271
|
+
FACT_GUARD,
|
|
272
|
+
);
|
|
273
|
+
parts.push("", `Query: "${query}`);
|
|
274
|
+
} else {
|
|
275
|
+
parts.push("You are reading a large file one chunk at a time.");
|
|
276
|
+
if (hasPriorSummary) {
|
|
277
|
+
parts.push(
|
|
278
|
+
"The following is a running summary of the parts already seen. It is for CONTEXT ONLY.",
|
|
279
|
+
"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.",
|
|
280
|
+
"Do NOT repeat or echo the prior summary. Write a fresh summary of the CURRENT chunk.",
|
|
281
|
+
);
|
|
282
|
+
} else {
|
|
283
|
+
parts.push(`Read the NEXT chunk and produce a NEW summary of THIS CHUNK. Be concise and factual. ${FACT_GUARD}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
pushChunkBlock(parts, range, chunkText, priorSummary, hasPriorSummary);
|
|
288
|
+
return parts.join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Summarise one chunk against the running summary.
|
|
293
|
+
*
|
|
294
|
+
* Size contract: `chunkText` is sent verbatim, bounded only by config.chunkChars.
|
|
295
|
+
* Returns null when the model can't be reached/authed (caller keeps prior summary).
|
|
296
|
+
*
|
|
297
|
+
* `priorSummary` accumulates across calls in the caller; in query mode the reply
|
|
298
|
+
* is parsed by `parseAnswerResponse` for the ANSWER marker and separator.
|
|
299
|
+
*/
|
|
300
|
+
async function summariseChunk(
|
|
301
|
+
chunkText: string,
|
|
302
|
+
priorSummary: string,
|
|
303
|
+
query: string | undefined,
|
|
304
|
+
range: ChunkRange,
|
|
305
|
+
filePath: string,
|
|
306
|
+
modelRegistry: any,
|
|
307
|
+
model: any, // Model<any> — passed directly to modelRegistry.complete
|
|
308
|
+
debugPath: string | null,
|
|
309
|
+
debugEnabled: boolean,
|
|
310
|
+
): Promise<string | null> {
|
|
311
|
+
if (!model) return null;
|
|
312
|
+
try {
|
|
313
|
+
if (!modelRegistry.hasConfiguredAuth(model)) return null;
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// File content and query are untrusted prompt text. Delimiters improve structure
|
|
319
|
+
// but do not neutralize instructions embedded in their contents.
|
|
320
|
+
const prompt = buildSummarisePrompt(chunkText, priorSummary, query, range);
|
|
321
|
+
|
|
322
|
+
const requestPayload = {
|
|
323
|
+
model: model.id,
|
|
324
|
+
messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
if (debugEnabled) {
|
|
328
|
+
appendFileSync(debugPath, JSON.stringify({ phase: "llm_request", chunk: `${range.start}-${range.end}`, payload: requestPayload }, null, 2) + "\n");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let response;
|
|
332
|
+
try {
|
|
333
|
+
response = await modelRegistry.complete(
|
|
334
|
+
model,
|
|
335
|
+
requestPayload,
|
|
336
|
+
);
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (debugEnabled) {
|
|
342
|
+
appendFileSync(debugPath, JSON.stringify({ phase: "llm_response", chunk: `${range.start}-${range.end}`, response: response }, null, 2) + "\n");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const contentBlocks = response?.content || [];
|
|
346
|
+
const textBlocks = contentBlocks.filter((c: any) => c.type === "text");
|
|
347
|
+
if (textBlocks.length === 0) return null;
|
|
348
|
+
const summary = textBlocks.map((c: any) => c.text).join(" ").trim();
|
|
349
|
+
return summary || null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Parse the query-mode contract out of an LLM reply. */
|
|
353
|
+
function parseAnswerResponse(text: string): { answer?: string; summary: string } {
|
|
354
|
+
const idx = text.indexOf(ANSWER_MARKER);
|
|
355
|
+
if (idx < 0) return { summary: text.trim() };
|
|
356
|
+
const after = text.slice(idx + ANSWER_MARKER.length);
|
|
357
|
+
const sep = after.indexOf("\n---\n");
|
|
358
|
+
let answer: string;
|
|
359
|
+
let rest: string;
|
|
360
|
+
if (sep >= 0) {
|
|
361
|
+
answer = after.slice(0, sep).trim();
|
|
362
|
+
rest = after.slice(sep + "\n---\n".length).trim();
|
|
363
|
+
} else {
|
|
364
|
+
answer = after.trim();
|
|
365
|
+
rest = "";
|
|
366
|
+
}
|
|
367
|
+
return { answer, summary: rest };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------- Main tool ----------
|
|
371
|
+
|
|
372
|
+
const ReadParams = Type.Object({
|
|
373
|
+
path: Type.String({ description: "Path to the file to read (relative or absolute). Optional `:START-END` (inclusive 1-indexed line span) or `:N` (start at line N, read to EOF) suffix selects a line range and bypasses summarisation." }),
|
|
374
|
+
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
|
|
375
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
|
376
|
+
query: Type.Optional(Type.String({ description: "Optional search query. When provided, scan stops at the first chunk that answers it." })),
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
export default function (pi: ExtensionAPI) {
|
|
380
|
+
// Session-only flags — defaults ON / OFF respectively. /read-chunks [debug]
|
|
381
|
+
let summaryCompressionEnabled = true;
|
|
382
|
+
let debugReturnEnabled = false;
|
|
383
|
+
|
|
384
|
+
pi.registerCommand("read-chunks", {
|
|
385
|
+
description: "Toggle summary (default: on) when file size > configKB. 'debug' to toggle per-invocation /tmp/read-chunks_<YYMMDD-hhmmss>.json (default: off).",
|
|
386
|
+
handler: async (args, ctx) => {
|
|
387
|
+
const tokens = (args ?? "").trim().toLowerCase().split(/\s+/);
|
|
388
|
+
const wantDebug = tokens.includes("debug");
|
|
389
|
+
if (tokens.length === 0 || (tokens.length === 1 && tokens[0] === "")) {
|
|
390
|
+
summaryCompressionEnabled = !summaryCompressionEnabled;
|
|
391
|
+
if (ctx.hasUI) {
|
|
392
|
+
ctx.ui.notify(`read-chunks summary compression: ${summaryCompressionEnabled ? "ON" : "OFF"}`, "info");
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (wantDebug) {
|
|
397
|
+
debugReturnEnabled = !debugReturnEnabled;
|
|
398
|
+
if (ctx.hasUI) {
|
|
399
|
+
ctx.ui.notify(`read-chunks debug: ${debugReturnEnabled ? "ON" : "OFF"}`, "info");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
pi.registerTool({
|
|
406
|
+
name: "read-chunks",
|
|
407
|
+
label: "read-chunks (chunked scan)",
|
|
408
|
+
description:
|
|
409
|
+
"TEXT-ONLY. Use instead of the built-in read() for text files. Binary files (PDF, archives, images, etc.) are not supported — use read() or another specialised tool for those. Two modes: (1) Scan a text file by splitting it into overlapping chunks snapped to natural boundaries (function endings for code, paragraph breaks for prose) and summarising each chunk, chaining the running summary forward. ALWAYS pass a precise query describing exactly what you are looking for; scanning stops early once a chunk answers it. (2) Line-range read: append `:START-END` (inclusive 1-indexed line span, e.g. `file.txt:2000-2089`) to return those lines verbatim; or append `:N` to start at line N and read to EOF. Both bypass all summarisation. This ignores the query.",
|
|
410
|
+
parameters: ReadParams,
|
|
411
|
+
|
|
412
|
+
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
|
|
413
|
+
let { path: rawPath, offset, limit } = params;
|
|
414
|
+
|
|
415
|
+
// Accept the `file.txt:X-Y` line-range shorthand (inclusive 1-indexed
|
|
416
|
+
// line span) and `file.txt:N` (start at line N, read to EOF) alongside
|
|
417
|
+
// explicit offset/limit. Strip the suffix and convert it to
|
|
418
|
+
// offset/start-line + limit/count.
|
|
419
|
+
if (typeof rawPath === "string") {
|
|
420
|
+
const m = rawPath.match(/:(\d+)(?:-(\d+))?$/);
|
|
421
|
+
if (m && !offset && !limit) {
|
|
422
|
+
const startLine = Number(m[1]);
|
|
423
|
+
const endLine = m[2] !== undefined ? Number(m[2]) : undefined;
|
|
424
|
+
rawPath = rawPath.slice(0, m.index);
|
|
425
|
+
offset = startLine;
|
|
426
|
+
if (endLine !== undefined) {
|
|
427
|
+
limit = Math.max(1, endLine - startLine + 1);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const absolutePath = resolve(ctx.cwd, rawPath);
|
|
432
|
+
const config = loadConfig(ctx.cwd);
|
|
433
|
+
|
|
434
|
+
// Per-invocation timestamp so each run appends to its own file.
|
|
435
|
+
const debugReturnPath = debugReturnEnabled ? `/tmp/read-chunks_${debugTimestamp()}.json` : null;
|
|
436
|
+
|
|
437
|
+
let stat;
|
|
438
|
+
try {
|
|
439
|
+
stat = statSync(absolutePath);
|
|
440
|
+
} catch (e: any) {
|
|
441
|
+
return {
|
|
442
|
+
content: [{ type: "text", text: `Error: cannot stat "${rawPath}": ${e.message}` }],
|
|
443
|
+
details: { error: true },
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!stat.isFile()) {
|
|
448
|
+
return {
|
|
449
|
+
content: [{ type: "text", text: `Error: "${rawPath}" is not a regular file` }],
|
|
450
|
+
details: { error: true },
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let content: string;
|
|
455
|
+
try {
|
|
456
|
+
content = readFileSync(absolutePath, "utf-8");
|
|
457
|
+
} catch (e: any) {
|
|
458
|
+
return {
|
|
459
|
+
content: [{ type: "text", text: `Error reading "${rawPath}": ${e.message}` }],
|
|
460
|
+
details: { error: true },
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Explicit offset/limit request → return those lines verbatim.
|
|
465
|
+
// Bypasses threshold/chunk/query logic; matches built-in read semantics.
|
|
466
|
+
if (offset !== undefined || limit !== undefined) {
|
|
467
|
+
const lines = content.split("\n");
|
|
468
|
+
const startLine = Math.max(0, (offset ?? 1) - 1);
|
|
469
|
+
const endLine = limit ? Math.min(startLine + limit, lines.length) : lines.length;
|
|
470
|
+
const sliced = lines.slice(startLine, endLine).join("\n");
|
|
471
|
+
return {
|
|
472
|
+
content: [{ type: "text", text: sliced }],
|
|
473
|
+
details: {
|
|
474
|
+
mode: "range",
|
|
475
|
+
path: absolutePath,
|
|
476
|
+
lines: `${startLine + 1}-${endLine}`,
|
|
477
|
+
chars: sliced.length,
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const sizeKB = stat.size / 1024;
|
|
483
|
+
const codeMode = isCode(rawPath, config.codeExtensions);
|
|
484
|
+
|
|
485
|
+
// Small file → full read
|
|
486
|
+
if (sizeKB <= config.thresholdKB) {
|
|
487
|
+
return {
|
|
488
|
+
content: [{ type: "text", text: content }],
|
|
489
|
+
details: {
|
|
490
|
+
mode: "full",
|
|
491
|
+
path: absolutePath,
|
|
492
|
+
kbTotal: Math.round(sizeKB * 10) / 10,
|
|
493
|
+
chars: content.length,
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Large file → chunked summarisation
|
|
499
|
+
const chunks = buildChunks(content, config.chunkChars, config.chunkOverlapChars, codeMode);
|
|
500
|
+
const totalKB = Math.round((stat.size / 1024) * 10) / 10;
|
|
501
|
+
|
|
502
|
+
// Map char offsets → 1-indexed line numbers so chunk labels match how the
|
|
503
|
+
// agent reasons (built-in read uses line#, not char#). Precomputed once,
|
|
504
|
+
// binary-searched per chunk (O(n log n)).
|
|
505
|
+
const lineStarts = [0];
|
|
506
|
+
for (let i = 0; i < content.length; i++) {
|
|
507
|
+
if (content[i] === "\n") lineStarts.push(i + 1);
|
|
508
|
+
}
|
|
509
|
+
const lineOfChar = (pos: number): number => {
|
|
510
|
+
let lo = 0, hi = lineStarts.length - 1, ans = -1;
|
|
511
|
+
while (lo <= hi) {
|
|
512
|
+
const mid = (lo + hi) >> 1;
|
|
513
|
+
if (lineStarts[mid] <= pos) { ans = mid; lo = mid + 1; }
|
|
514
|
+
else hi = mid - 1;
|
|
515
|
+
}
|
|
516
|
+
return ans + 1;
|
|
517
|
+
};
|
|
518
|
+
const lineSpan = (range: ChunkRange): string => {
|
|
519
|
+
const s = lineOfChar(range.start);
|
|
520
|
+
// End is an exclusive offset: last included char is range.end - 1.
|
|
521
|
+
const e = range.end >= content.length ? lineStarts.length : lineOfChar(range.end - 1);
|
|
522
|
+
return `${s}-${e}`;
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const query = typeof params.query === "string" && params.query.trim().length > 0
|
|
526
|
+
? params.query.trim()
|
|
527
|
+
: undefined;
|
|
528
|
+
|
|
529
|
+
// Find a model: try ctx.model first (set during conversation), then fall back to any available model.
|
|
530
|
+
let activeModel = ctx.model;
|
|
531
|
+
if (!activeModel) {
|
|
532
|
+
try {
|
|
533
|
+
const available = await ctx.modelRegistry.getAvailable();
|
|
534
|
+
if (available.length > 0) activeModel = available[0];
|
|
535
|
+
} catch {
|
|
536
|
+
// no model available
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (!activeModel) {
|
|
540
|
+
return {
|
|
541
|
+
content: [{ type: "text", text: `Error: no model available for summarisation` }],
|
|
542
|
+
details: { error: true },
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let runningSummary = "";
|
|
547
|
+
const perChunk: Array<{ chunk: string; summary: string }> = [];
|
|
548
|
+
let stopReason = "completed" as const;
|
|
549
|
+
let answer: string | undefined = undefined;
|
|
550
|
+
|
|
551
|
+
for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) {
|
|
552
|
+
const range = chunks[chunkIdx];
|
|
553
|
+
const text = content.slice(range.start, range.end);
|
|
554
|
+
const label = `${lineSpan(range)} (chars ${range.start}-${range.end})`;
|
|
555
|
+
|
|
556
|
+
ctx.ui.notify(`read-chunks: ${label} (chunk ${chunkIdx + 1}/${chunks.length})`, "info");
|
|
557
|
+
|
|
558
|
+
const noteRaw = await summariseChunk(
|
|
559
|
+
text, runningSummary, query, range, rawPath,
|
|
560
|
+
ctx.modelRegistry, activeModel,
|
|
561
|
+
debugReturnPath, debugReturnEnabled,
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
if (noteRaw) runningSummary = noteRaw;
|
|
565
|
+
let displayNote = noteRaw ?? UNSUMMARISED;
|
|
566
|
+
|
|
567
|
+
// In query mode, extract answer and use parsed summary for display.
|
|
568
|
+
if (query && noteRaw) {
|
|
569
|
+
const parsed = parseAnswerResponse(noteRaw);
|
|
570
|
+
if (parsed.answer !== undefined) {
|
|
571
|
+
answer = parsed.answer;
|
|
572
|
+
runningSummary = parsed.summary;
|
|
573
|
+
displayNote = parsed.summary;
|
|
574
|
+
stopReason = "answer_found";
|
|
575
|
+
}
|
|
576
|
+
runningSummary = parsed.summary;
|
|
577
|
+
displayNote = parsed.summary;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
perChunk.push({ chunk: label, summary: displayNote });
|
|
581
|
+
|
|
582
|
+
if (stopReason === "answer_found") break;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const toolReturn = {
|
|
586
|
+
mode: query ? "query" : "summary",
|
|
587
|
+
file: absolutePath,
|
|
588
|
+
kb_total: totalKB,
|
|
589
|
+
chunks_scanned: chunks.length,
|
|
590
|
+
chunks_read: perChunk.length,
|
|
591
|
+
stop_reason: stopReason,
|
|
592
|
+
query: query ?? null,
|
|
593
|
+
answer: query ? (answer ?? null) : null,
|
|
594
|
+
summary: !query ? runningSummary : null,
|
|
595
|
+
chunks: perChunk,
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// Write final tool return to debug file
|
|
599
|
+
if (debugReturnEnabled) {
|
|
600
|
+
try {
|
|
601
|
+
appendFileSync(debugReturnPath, JSON.stringify({ phase: "tool_return", result: toolReturn }, null, 2) + "\n");
|
|
602
|
+
} catch {}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
content: [{ type: "text", text: `[read-chunks:chunked]\n${JSON.stringify(toolReturn, null, 2)}` }],
|
|
607
|
+
details: {
|
|
608
|
+
mode: "chunked",
|
|
609
|
+
path: absolutePath,
|
|
610
|
+
kbTotal: totalKB,
|
|
611
|
+
chunksScanned: chunks.length,
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
// Full-file results keep built-in read presentation behavior. Chunked results
|
|
617
|
+
// are emitted as JSON because the model needs structured diagnostic metadata.
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// Calls to the built-in read() are routed as follows:
|
|
621
|
+
// - Image files (png/jpg/gif/...) → pass through; read() returns image content.
|
|
622
|
+
// - Other binary files (pdf/zip/docx/...) → pass through; read() delivers bytes.
|
|
623
|
+
// - Text files (and unknown extensions) → block, route model to read-chunks.
|
|
624
|
+
// The returned reason is surfaced to the model for its continuation; omitting
|
|
625
|
+
// terminate keeps the turn alive so the model reroutes to read-chunks.
|
|
626
|
+
pi.on("tool_call", (event) => {
|
|
627
|
+
if (event.toolName !== "read") return;
|
|
628
|
+
const input = event.input as { path?: unknown } | undefined;
|
|
629
|
+
const path = typeof input?.path === "string" ? input.path : "";
|
|
630
|
+
if (path && (isImagePath(path) || isBinaryPath(path))) return;
|
|
631
|
+
return {
|
|
632
|
+
block: true,
|
|
633
|
+
reason: "Built-in read() is disabled for text files. Use read-chunks(path, query) with a concise query describing what you are looking for. For a line range, append `:START-END` to the path (e.g. file.txt:2000-2089), which returns those lines verbatim with no summarisation. read-chunks is TEXT-ONLY: for binary files (PDF, archives, images, office docs, etc.) continue to use read().",
|
|
634
|
+
};
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// Compress chunked-mode results so raw JSON does not bloat model context.
|
|
638
|
+
// This hook changes model-visible content after execution; it does not replace
|
|
639
|
+
// the original tool execution result. Skipped when /read-chunks toggles it off.
|
|
640
|
+
pi.on("tool_result", async (event, _ctx) => {
|
|
641
|
+
if (!summaryCompressionEnabled) return;
|
|
642
|
+
if (event.toolName !== "read-chunks") return;
|
|
643
|
+
if (event.isError) return;
|
|
644
|
+
const details = event.details as { mode?: string } | undefined;
|
|
645
|
+
if (details?.mode !== "chunked") return;
|
|
646
|
+
|
|
647
|
+
const block = event.content[0];
|
|
648
|
+
if (block?.type !== "text") return;
|
|
649
|
+
const raw = block.text;
|
|
650
|
+
if (!raw.startsWith("[read-chunks:chunked]")) return;
|
|
651
|
+
|
|
652
|
+
let parsed: any;
|
|
653
|
+
try {
|
|
654
|
+
parsed = JSON.parse(raw.slice(raw.indexOf("\n") + 1));
|
|
655
|
+
} catch {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
content: [{ type: "text", text: buildSummary(parsed) }],
|
|
661
|
+
details: event.details,
|
|
662
|
+
};
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ---------- Summary builder ----------
|
|
667
|
+
|
|
668
|
+
/** Compact the raw chunked payload for in-context consumption. */
|
|
669
|
+
function buildSummary(parsed: any): string {
|
|
670
|
+
const lines: string[] = [];
|
|
671
|
+
const kbTotal = parsed.kb_total ?? 0;
|
|
672
|
+
const scanned = parsed.chunks_scanned ?? 0;
|
|
673
|
+
const readCount = parsed.chunks_read ?? 0;
|
|
674
|
+
const mode = parsed.mode;
|
|
675
|
+
const stopReason = parsed.stop_reason ?? "completed";
|
|
676
|
+
const chunks = parsed.chunks ?? [];
|
|
677
|
+
|
|
678
|
+
lines.push(`[read-chunks] File ~${kbTotal}KB, ${scanned} chunks, ${readCount} summarised (${stopReason}).`);
|
|
679
|
+
|
|
680
|
+
if (mode === "query" && parsed.answer) {
|
|
681
|
+
const ans = String(parsed.answer).length > 300
|
|
682
|
+
? String(parsed.answer).slice(0, 297) + "..."
|
|
683
|
+
: String(parsed.answer);
|
|
684
|
+
lines.push("", `Answer: ${ans}`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const CAP = 25;
|
|
688
|
+
for (let i = 0; i < chunks.length && i < CAP; i++) {
|
|
689
|
+
const s = chunks[i].summary ?? "";
|
|
690
|
+
const trimmed = s.length > 120 ? s.slice(0, 117) + "..." : s;
|
|
691
|
+
lines.push(`${chunks[i].chunk}: ${trimmed}`);
|
|
692
|
+
}
|
|
693
|
+
if (chunks.length > CAP) {
|
|
694
|
+
lines.push(` ...and ${chunks.length - CAP} more chunk summaries.`);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
lines.push(
|
|
698
|
+
"",
|
|
699
|
+
"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.",
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
return lines.join("\n");
|
|
703
|
+
}
|