pi-read-map 1.2.4 → 1.3.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/CHANGELOG.md +19 -0
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/index.ts +26 -239
- package/src/types.ts +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.3.0] - 2026-02-20
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **Inline file maps**: Maps are now embedded directly in the `read` tool result text instead of being sent as separate `file-map` custom messages. This sacrifices the dedicated collapsible TUI widget but enables **true parallel tool execution**. Previously, custom messages interrupted parallel tool batches, causing skipped reads and forcing slow auto-recovery loops. Inlining guarantees the LLM receives the map immediately in the same turn, drastically speeding up parallel reads and preventing strict API conversation ordering errors (400 Bad Request).
|
|
10
|
+
- Removed the `read-recovery` mechanism since parallel reads are no longer skipped by map generation.
|
|
11
|
+
- Removed `@mariozechner/pi-tui` dependency since custom message rendering is no longer needed.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Directory reads now throw an `EISDIR` error with inline `ls` fallback text reliably. The fallback listing is preserved in thrown errors instead of being swallowed by the internal `try/catch` path.
|
|
16
|
+
- npm tarballs now exclude local scratch artifacts (`*.patch`, `*.orig`, `fix-*.js`, etc.) via `.npmignore`, preventing accidental publication of local debug/review files.
|
|
17
|
+
|
|
18
|
+
## [1.2.5] - 2026-02-15
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `sendMessage` calls in `tool_result` handler now use `deliverAs: "followUp"` instead of the default `"steer"` mode. The default `"steer"` mode interrupts streaming and skips remaining parallel tools — when multiple reads ran concurrently and one triggered a file-map or directory-listing message, the remaining tool calls were skipped, leaving `tool_use` blocks without matching `tool_result` blocks. This caused a 400 error from the Claude API on the next request.
|
|
23
|
+
|
|
5
24
|
## [1.2.4] - 2026-02-15
|
|
6
25
|
|
|
7
26
|
### Fixed
|
package/README.md
CHANGED
|
@@ -160,13 +160,16 @@ The extension intercepts `read` calls and decides:
|
|
|
160
160
|
1. **Binary files** (images, audio, video, archives, etc.): Delegate to built-in read tool
|
|
161
161
|
2. **Small files** (≤2,000 lines, ≤50 KB): Delegate to built-in read tool
|
|
162
162
|
3. **Targeted reads** (offset or limit provided): Delegate to built-in read tool
|
|
163
|
-
4. **
|
|
163
|
+
4. **Directory paths**: Run built-in `ls` and throw an `EISDIR` error that includes inline fallback directory output.
|
|
164
|
+
5. **Large files:**
|
|
164
165
|
- Call built-in read for the first chunk
|
|
165
166
|
- Detect language from file extension
|
|
166
167
|
- Dispatch to a mapper (language-specific → ctags → grep fallback)
|
|
167
168
|
- Format with budget enforcement
|
|
168
169
|
- Cache the map
|
|
169
|
-
-
|
|
170
|
+
- Append the map text directly to the read tool's result block
|
|
171
|
+
|
|
172
|
+
*Note on design:* Maps are inlined as raw text rather than sent as separate custom UI messages. While this sacrifices a dedicated TUI widget, it ensures true parallel tool execution. Custom messages interrupt parallel tool batches, causing skipped reads and forcing slow recovery loops. Inlining guarantees the LLM receives the map immediately in the same turn without breaking concurrency.
|
|
170
173
|
|
|
171
174
|
## Dependencies
|
|
172
175
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
createReadTool,
|
|
@@ -6,20 +6,15 @@ import {
|
|
|
6
6
|
DEFAULT_MAX_LINES,
|
|
7
7
|
DEFAULT_MAX_BYTES,
|
|
8
8
|
} from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import { Text } from "@mariozechner/pi-tui";
|
|
10
9
|
import { Type } from "@sinclair/typebox";
|
|
11
10
|
import { exec } from "node:child_process";
|
|
12
11
|
import { stat } from "node:fs/promises";
|
|
13
|
-
import {
|
|
12
|
+
import { extname, resolve } from "node:path";
|
|
14
13
|
import { promisify } from "node:util";
|
|
15
14
|
|
|
16
|
-
import type { FileMapMessageDetails } from "./types.js";
|
|
17
|
-
|
|
18
15
|
import { formatFileMapWithBudget } from "./formatter.js";
|
|
19
16
|
import { generateMap, shouldGenerateMap } from "./mapper.js";
|
|
20
17
|
|
|
21
|
-
export type { FileMapMessageDetails } from "./types.js";
|
|
22
|
-
|
|
23
18
|
const execAsync = promisify(exec);
|
|
24
19
|
|
|
25
20
|
/**
|
|
@@ -79,19 +74,6 @@ const BINARY_EXTENSIONS = new Set([
|
|
|
79
74
|
// In-memory cache for maps
|
|
80
75
|
const mapCache = new Map<string, { mtime: number; map: string }>();
|
|
81
76
|
|
|
82
|
-
// Pending maps waiting to be sent after tool_result
|
|
83
|
-
const pendingMaps = new Map<
|
|
84
|
-
string,
|
|
85
|
-
{
|
|
86
|
-
path: string;
|
|
87
|
-
map: string;
|
|
88
|
-
details: FileMapMessageDetails;
|
|
89
|
-
}
|
|
90
|
-
>();
|
|
91
|
-
|
|
92
|
-
// Pending directory listings waiting to be sent after a read-on-directory error
|
|
93
|
-
const pendingDirectoryLs = new Map<string, { path: string; listing: string }>();
|
|
94
|
-
|
|
95
77
|
/**
|
|
96
78
|
* Reset the map cache. Exported for testing purposes only.
|
|
97
79
|
*/
|
|
@@ -99,24 +81,6 @@ export function resetMapCache(): void {
|
|
|
99
81
|
mapCache.clear();
|
|
100
82
|
}
|
|
101
83
|
|
|
102
|
-
/**
|
|
103
|
-
* Reset the pending maps. Exported for testing purposes only.
|
|
104
|
-
*/
|
|
105
|
-
export function resetPendingMaps(): void {
|
|
106
|
-
pendingMaps.clear();
|
|
107
|
-
pendingDirectoryLs.clear();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Get pending maps for testing inspection.
|
|
112
|
-
*/
|
|
113
|
-
export function getPendingMaps(): Map<
|
|
114
|
-
string,
|
|
115
|
-
{ path: string; map: string; details: FileMapMessageDetails }
|
|
116
|
-
> {
|
|
117
|
-
return pendingMaps;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
84
|
export default function piReadMapExtension(pi: ExtensionAPI): void {
|
|
121
85
|
// Get the current working directory
|
|
122
86
|
const cwd = process.cwd();
|
|
@@ -127,160 +91,6 @@ export default function piReadMapExtension(pi: ExtensionAPI): void {
|
|
|
127
91
|
// Create the built-in ls tool for directory fallback
|
|
128
92
|
const builtInLs = createLsTool(cwd);
|
|
129
93
|
|
|
130
|
-
// Register tool_result handler to send pending maps and directory listings
|
|
131
|
-
pi.on("tool_result", (event, _ctx) => {
|
|
132
|
-
if (event.toolName !== "read") {
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Send pending directory listing after read-on-directory error
|
|
137
|
-
const pendingLs = pendingDirectoryLs.get(event.toolCallId);
|
|
138
|
-
if (pendingLs) {
|
|
139
|
-
pi.sendMessage({
|
|
140
|
-
customType: "directory-listing",
|
|
141
|
-
content: `${pendingLs.path} is a directory. Here is ls:\n${pendingLs.listing}`,
|
|
142
|
-
display: true,
|
|
143
|
-
});
|
|
144
|
-
pendingDirectoryLs.delete(event.toolCallId);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const pending = pendingMaps.get(event.toolCallId);
|
|
148
|
-
if (!pending) {
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Send the map as a custom message
|
|
153
|
-
pi.sendMessage({
|
|
154
|
-
customType: "file-map",
|
|
155
|
-
content: pending.map,
|
|
156
|
-
display: true,
|
|
157
|
-
details: pending.details,
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
// Clean up
|
|
161
|
-
pendingMaps.delete(event.toolCallId);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// Register custom message renderer for file-map type
|
|
165
|
-
pi.registerMessageRenderer<FileMapMessageDetails>(
|
|
166
|
-
"file-map",
|
|
167
|
-
(message, options, theme: Theme) => {
|
|
168
|
-
const { expanded } = options;
|
|
169
|
-
const { details } = message;
|
|
170
|
-
|
|
171
|
-
if (expanded) {
|
|
172
|
-
// Expanded: show full formatted map
|
|
173
|
-
// message.content can be string or array of content blocks
|
|
174
|
-
const content =
|
|
175
|
-
typeof message.content === "string"
|
|
176
|
-
? message.content
|
|
177
|
-
: message.content
|
|
178
|
-
.filter((c) => c.type === "text")
|
|
179
|
-
.map((c) => (c as { type: "text"; text: string }).text)
|
|
180
|
-
.join("\n");
|
|
181
|
-
return new Text(content, 0, 0);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Collapsed: show summary
|
|
185
|
-
const fileName = details ? basename(details.filePath) : "file";
|
|
186
|
-
const symbolCount = details?.symbolCount ?? 0;
|
|
187
|
-
const totalLines = details?.totalLines ?? 0;
|
|
188
|
-
const detailLanguage = details?.language ?? "unknown";
|
|
189
|
-
|
|
190
|
-
let summary = theme.fg("accent", "📄 File Map: ");
|
|
191
|
-
summary += theme.fg("toolTitle", theme.bold(fileName));
|
|
192
|
-
summary += theme.fg("muted", ` │ `);
|
|
193
|
-
summary += theme.fg("dim", `${symbolCount} symbols`);
|
|
194
|
-
summary += theme.fg("muted", ` │ `);
|
|
195
|
-
summary += theme.fg("dim", `${totalLines.toLocaleString()} lines`);
|
|
196
|
-
summary += theme.fg("muted", ` │ `);
|
|
197
|
-
summary += theme.fg("dim", detailLanguage);
|
|
198
|
-
summary += theme.fg("muted", ` │ `);
|
|
199
|
-
summary += theme.fg("dim", "Ctrl+O to expand");
|
|
200
|
-
|
|
201
|
-
return new Text(summary, 0, 0);
|
|
202
|
-
}
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
// Recover from skipped reads caused by map steering
|
|
206
|
-
pi.on("turn_end", (event) => {
|
|
207
|
-
const SKIPPED_TEXT = "Skipped due to queued user message.";
|
|
208
|
-
|
|
209
|
-
// Find skipped read results
|
|
210
|
-
const skippedReads = event.toolResults.filter(
|
|
211
|
-
(r) =>
|
|
212
|
-
r.toolName === "read" &&
|
|
213
|
-
!r.isError &&
|
|
214
|
-
r.content.some((c) => c.type === "text" && c.text === SKIPPED_TEXT)
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
if (skippedReads.length === 0) {
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Extract paths from the assistant message's tool calls.
|
|
222
|
-
// The message is AgentMessage (union); narrow to AssistantMessage.
|
|
223
|
-
const msg = event.message;
|
|
224
|
-
if (!("role" in msg) || msg.role !== "assistant") {
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const skippedPaths: string[] = [];
|
|
229
|
-
for (const skipped of skippedReads) {
|
|
230
|
-
const tc = msg.content.find(
|
|
231
|
-
(c) =>
|
|
232
|
-
c.type === "toolCall" &&
|
|
233
|
-
c.name === "read" &&
|
|
234
|
-
c.id === skipped.toolCallId
|
|
235
|
-
);
|
|
236
|
-
if (tc && tc.type === "toolCall" && tc.arguments["path"]) {
|
|
237
|
-
skippedPaths.push(String(tc.arguments["path"]));
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (skippedPaths.length === 0) {
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const pathList = skippedPaths.map((p) => `- read("${p}")`).join("\n");
|
|
246
|
-
|
|
247
|
-
pi.sendMessage(
|
|
248
|
-
{
|
|
249
|
-
customType: "read-recovery",
|
|
250
|
-
content: `The following read() calls were interrupted by a file map delivery and need to be completed:\n${pathList}\nPlease re-issue these reads now.`,
|
|
251
|
-
display: true,
|
|
252
|
-
},
|
|
253
|
-
{ deliverAs: "followUp" }
|
|
254
|
-
);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// Register custom message renderer for read-recovery type
|
|
258
|
-
pi.registerMessageRenderer(
|
|
259
|
-
"read-recovery",
|
|
260
|
-
(message, options, theme: Theme) => {
|
|
261
|
-
const content =
|
|
262
|
-
typeof message.content === "string"
|
|
263
|
-
? message.content
|
|
264
|
-
: message.content
|
|
265
|
-
.filter((c) => c.type === "text")
|
|
266
|
-
.map((c) => (c as { type: "text"; text: string }).text)
|
|
267
|
-
.join("\n");
|
|
268
|
-
|
|
269
|
-
if (options.expanded) {
|
|
270
|
-
return new Text(content, 0, 0);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const pathCount = (content.match(/^- read\(/gm) || []).length;
|
|
274
|
-
let summary = theme.fg("warning", "Recovery: ");
|
|
275
|
-
summary += theme.fg(
|
|
276
|
-
"dim",
|
|
277
|
-
`${pathCount} interrupted read(s) being re-issued`
|
|
278
|
-
);
|
|
279
|
-
|
|
280
|
-
return new Text(summary, 0, 0);
|
|
281
|
-
}
|
|
282
|
-
);
|
|
283
|
-
|
|
284
94
|
// Register our enhanced read tool
|
|
285
95
|
pi.registerTool({
|
|
286
96
|
name: "read",
|
|
@@ -328,27 +138,34 @@ export default function piReadMapExtension(pi: ExtensionAPI): void {
|
|
|
328
138
|
// For non-regular files, handle appropriately
|
|
329
139
|
if (!stats.isFile()) {
|
|
330
140
|
if (stats.isDirectory()) {
|
|
331
|
-
//
|
|
141
|
+
// Instead of letting EISDIR propagate and sending a custom steer message,
|
|
142
|
+
// we run ls and embed the listing directly into the thrown error.
|
|
143
|
+
// This prevents steer from breaking parallel executions.
|
|
144
|
+
let lsText: string | null = null;
|
|
332
145
|
try {
|
|
333
146
|
const lsResult = await builtInLs.execute(
|
|
334
147
|
toolCallId,
|
|
335
148
|
{ path: inputPath },
|
|
336
149
|
signal
|
|
337
150
|
);
|
|
338
|
-
|
|
151
|
+
lsText = lsResult.content
|
|
339
152
|
.filter(
|
|
340
153
|
(c): c is { type: "text"; text: string } => c.type === "text"
|
|
341
154
|
)
|
|
342
155
|
.map((c) => c.text)
|
|
343
156
|
.join("\n");
|
|
344
|
-
pendingDirectoryLs.set(toolCallId, {
|
|
345
|
-
path: absPath,
|
|
346
|
-
listing: lsText,
|
|
347
|
-
});
|
|
348
157
|
} catch {
|
|
349
158
|
// best-effort: if ls fails, just let the error through without listing
|
|
350
159
|
}
|
|
351
|
-
|
|
160
|
+
|
|
161
|
+
if (lsText !== null) {
|
|
162
|
+
// eslint-disable-next-line @factory/structured-logging
|
|
163
|
+
throw new Error(
|
|
164
|
+
`EISDIR: illegal operation on a directory, read '${absPath}'\n\nFallback ls output for this directory:\n${lsText}`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Fallback if ls fails for some reason
|
|
352
169
|
return builtInRead.execute(toolCallId, params, signal, onUpdate);
|
|
353
170
|
}
|
|
354
171
|
return builtInRead.execute(toolCallId, params, signal, onUpdate);
|
|
@@ -374,8 +191,7 @@ export default function piReadMapExtension(pi: ExtensionAPI): void {
|
|
|
374
191
|
return builtInRead.execute(toolCallId, params, signal, onUpdate);
|
|
375
192
|
}
|
|
376
193
|
|
|
377
|
-
// File exceeds threshold - generate map
|
|
378
|
-
// First, get the built-in result
|
|
194
|
+
// File exceeds threshold - generate map and inline it in the tool result
|
|
379
195
|
const result = await builtInRead.execute(
|
|
380
196
|
toolCallId,
|
|
381
197
|
params,
|
|
@@ -383,57 +199,28 @@ export default function piReadMapExtension(pi: ExtensionAPI): void {
|
|
|
383
199
|
onUpdate
|
|
384
200
|
);
|
|
385
201
|
|
|
386
|
-
//
|
|
387
|
-
const cached = mapCache.get(absPath);
|
|
202
|
+
// Generate or retrieve cached map
|
|
388
203
|
let mapText: string;
|
|
389
|
-
|
|
390
|
-
let language: string;
|
|
204
|
+
const cached = mapCache.get(absPath);
|
|
391
205
|
|
|
392
206
|
if (cached && cached.mtime === stats.mtimeMs) {
|
|
393
|
-
|
|
394
|
-
// (alternatively, cache could store metadata too)
|
|
395
|
-
const fileMap = await generateMap(absPath, { signal });
|
|
396
|
-
if (fileMap) {
|
|
397
|
-
mapText = cached.map;
|
|
398
|
-
({ language } = fileMap);
|
|
399
|
-
symbolCount = fileMap.symbols.length;
|
|
400
|
-
} else {
|
|
401
|
-
return result;
|
|
402
|
-
}
|
|
207
|
+
mapText = cached.map;
|
|
403
208
|
} else {
|
|
404
|
-
// Generate new map
|
|
405
209
|
const fileMap = await generateMap(absPath, { signal });
|
|
406
210
|
|
|
407
|
-
if (fileMap) {
|
|
408
|
-
mapText = formatFileMapWithBudget(fileMap);
|
|
409
|
-
({ language } = fileMap);
|
|
410
|
-
symbolCount = fileMap.symbols.length;
|
|
411
|
-
// Cache it
|
|
412
|
-
mapCache.set(absPath, { mtime: stats.mtimeMs, map: mapText });
|
|
413
|
-
} else {
|
|
211
|
+
if (!fileMap) {
|
|
414
212
|
// Map generation failed, return original result
|
|
415
213
|
return result;
|
|
416
214
|
}
|
|
417
|
-
}
|
|
418
215
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
map: mapText,
|
|
423
|
-
details: {
|
|
424
|
-
filePath: absPath,
|
|
425
|
-
totalLines,
|
|
426
|
-
totalBytes: stats.size,
|
|
427
|
-
symbolCount,
|
|
428
|
-
language,
|
|
429
|
-
},
|
|
430
|
-
});
|
|
216
|
+
mapText = formatFileMapWithBudget(fileMap);
|
|
217
|
+
mapCache.set(absPath, { mtime: stats.mtimeMs, map: mapText });
|
|
218
|
+
}
|
|
431
219
|
|
|
432
|
-
//
|
|
433
|
-
// since we're providing a map separately)
|
|
220
|
+
// Append map to the tool result content
|
|
434
221
|
return {
|
|
435
222
|
...result,
|
|
436
|
-
|
|
223
|
+
content: [...result.content, { type: "text" as const, text: mapText }],
|
|
437
224
|
};
|
|
438
225
|
},
|
|
439
226
|
});
|
package/src/types.ts
CHANGED
|
@@ -81,15 +81,3 @@ export interface LanguageInfo {
|
|
|
81
81
|
/**
|
|
82
82
|
* Details for file map custom messages.
|
|
83
83
|
*/
|
|
84
|
-
export interface FileMapMessageDetails {
|
|
85
|
-
/** Absolute file path */
|
|
86
|
-
filePath: string;
|
|
87
|
-
/** Total lines in file */
|
|
88
|
-
totalLines: number;
|
|
89
|
-
/** Total bytes in file */
|
|
90
|
-
totalBytes: number;
|
|
91
|
-
/** Number of symbols in map */
|
|
92
|
-
symbolCount: number;
|
|
93
|
-
/** Detected language */
|
|
94
|
-
language: string;
|
|
95
|
-
}
|