pi-read-map 1.2.5 → 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 CHANGED
@@ -2,6 +2,19 @@
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
+
5
18
  ## [1.2.5] - 2026-02-15
6
19
 
7
20
  ### 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. **Large files:**
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
- - Send as a separate `file-map` message after `tool_result`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-read-map",
3
- "version": "1.2.5",
3
+ "version": "1.3.0",
4
4
  "description": "Pi extension that adds structural file maps for large files",
5
5
  "type": "module",
6
6
  "pi": {
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
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 { basename, extname, resolve } from "node:path";
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,166 +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
- {
141
- customType: "directory-listing",
142
- content: `${pendingLs.path} is a directory. Here is ls:\n${pendingLs.listing}`,
143
- display: true,
144
- },
145
- { deliverAs: "followUp" }
146
- );
147
- pendingDirectoryLs.delete(event.toolCallId);
148
- }
149
-
150
- const pending = pendingMaps.get(event.toolCallId);
151
- if (!pending) {
152
- return;
153
- }
154
-
155
- // Send the map as a custom message
156
- pi.sendMessage(
157
- {
158
- customType: "file-map",
159
- content: pending.map,
160
- display: true,
161
- details: pending.details,
162
- },
163
- { deliverAs: "followUp" }
164
- );
165
-
166
- // Clean up
167
- pendingMaps.delete(event.toolCallId);
168
- });
169
-
170
- // Register custom message renderer for file-map type
171
- pi.registerMessageRenderer<FileMapMessageDetails>(
172
- "file-map",
173
- (message, options, theme: Theme) => {
174
- const { expanded } = options;
175
- const { details } = message;
176
-
177
- if (expanded) {
178
- // Expanded: show full formatted map
179
- // message.content can be string or array of content blocks
180
- const content =
181
- typeof message.content === "string"
182
- ? message.content
183
- : message.content
184
- .filter((c) => c.type === "text")
185
- .map((c) => (c as { type: "text"; text: string }).text)
186
- .join("\n");
187
- return new Text(content, 0, 0);
188
- }
189
-
190
- // Collapsed: show summary
191
- const fileName = details ? basename(details.filePath) : "file";
192
- const symbolCount = details?.symbolCount ?? 0;
193
- const totalLines = details?.totalLines ?? 0;
194
- const detailLanguage = details?.language ?? "unknown";
195
-
196
- let summary = theme.fg("accent", "📄 File Map: ");
197
- summary += theme.fg("toolTitle", theme.bold(fileName));
198
- summary += theme.fg("muted", ` │ `);
199
- summary += theme.fg("dim", `${symbolCount} symbols`);
200
- summary += theme.fg("muted", ` │ `);
201
- summary += theme.fg("dim", `${totalLines.toLocaleString()} lines`);
202
- summary += theme.fg("muted", ` │ `);
203
- summary += theme.fg("dim", detailLanguage);
204
- summary += theme.fg("muted", ` │ `);
205
- summary += theme.fg("dim", "Ctrl+O to expand");
206
-
207
- return new Text(summary, 0, 0);
208
- }
209
- );
210
-
211
- // Recover from skipped reads caused by map steering
212
- pi.on("turn_end", (event) => {
213
- const SKIPPED_TEXT = "Skipped due to queued user message.";
214
-
215
- // Find skipped read results
216
- const skippedReads = event.toolResults.filter(
217
- (r) =>
218
- r.toolName === "read" &&
219
- !r.isError &&
220
- r.content.some((c) => c.type === "text" && c.text === SKIPPED_TEXT)
221
- );
222
-
223
- if (skippedReads.length === 0) {
224
- return;
225
- }
226
-
227
- // Extract paths from the assistant message's tool calls.
228
- // The message is AgentMessage (union); narrow to AssistantMessage.
229
- const msg = event.message;
230
- if (!("role" in msg) || msg.role !== "assistant") {
231
- return;
232
- }
233
-
234
- const skippedPaths: string[] = [];
235
- for (const skipped of skippedReads) {
236
- const tc = msg.content.find(
237
- (c) =>
238
- c.type === "toolCall" &&
239
- c.name === "read" &&
240
- c.id === skipped.toolCallId
241
- );
242
- if (tc && tc.type === "toolCall" && tc.arguments["path"]) {
243
- skippedPaths.push(String(tc.arguments["path"]));
244
- }
245
- }
246
-
247
- if (skippedPaths.length === 0) {
248
- return;
249
- }
250
-
251
- const pathList = skippedPaths.map((p) => `- read("${p}")`).join("\n");
252
-
253
- pi.sendMessage(
254
- {
255
- customType: "read-recovery",
256
- 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.`,
257
- display: true,
258
- },
259
- { deliverAs: "followUp" }
260
- );
261
- });
262
-
263
- // Register custom message renderer for read-recovery type
264
- pi.registerMessageRenderer(
265
- "read-recovery",
266
- (message, options, theme: Theme) => {
267
- const content =
268
- typeof message.content === "string"
269
- ? message.content
270
- : message.content
271
- .filter((c) => c.type === "text")
272
- .map((c) => (c as { type: "text"; text: string }).text)
273
- .join("\n");
274
-
275
- if (options.expanded) {
276
- return new Text(content, 0, 0);
277
- }
278
-
279
- const pathCount = (content.match(/^- read\(/gm) || []).length;
280
- let summary = theme.fg("warning", "Recovery: ");
281
- summary += theme.fg(
282
- "dim",
283
- `${pathCount} interrupted read(s) being re-issued`
284
- );
285
-
286
- return new Text(summary, 0, 0);
287
- }
288
- );
289
-
290
94
  // Register our enhanced read tool
291
95
  pi.registerTool({
292
96
  name: "read",
@@ -334,27 +138,34 @@ export default function piReadMapExtension(pi: ExtensionAPI): void {
334
138
  // For non-regular files, handle appropriately
335
139
  if (!stats.isFile()) {
336
140
  if (stats.isDirectory()) {
337
- // Get ls output before letting the error propagate
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;
338
145
  try {
339
146
  const lsResult = await builtInLs.execute(
340
147
  toolCallId,
341
148
  { path: inputPath },
342
149
  signal
343
150
  );
344
- const lsText = lsResult.content
151
+ lsText = lsResult.content
345
152
  .filter(
346
153
  (c): c is { type: "text"; text: string } => c.type === "text"
347
154
  )
348
155
  .map((c) => c.text)
349
156
  .join("\n");
350
- pendingDirectoryLs.set(toolCallId, {
351
- path: absPath,
352
- listing: lsText,
353
- });
354
157
  } catch {
355
158
  // best-effort: if ls fails, just let the error through without listing
356
159
  }
357
- // Delegate to built-in read which will throw EISDIR
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
358
169
  return builtInRead.execute(toolCallId, params, signal, onUpdate);
359
170
  }
360
171
  return builtInRead.execute(toolCallId, params, signal, onUpdate);
@@ -380,8 +191,7 @@ export default function piReadMapExtension(pi: ExtensionAPI): void {
380
191
  return builtInRead.execute(toolCallId, params, signal, onUpdate);
381
192
  }
382
193
 
383
- // File exceeds threshold - generate map
384
- // First, get the built-in result
194
+ // File exceeds threshold - generate map and inline it in the tool result
385
195
  const result = await builtInRead.execute(
386
196
  toolCallId,
387
197
  params,
@@ -389,57 +199,28 @@ export default function piReadMapExtension(pi: ExtensionAPI): void {
389
199
  onUpdate
390
200
  );
391
201
 
392
- // Check cache
393
- const cached = mapCache.get(absPath);
202
+ // Generate or retrieve cached map
394
203
  let mapText: string;
395
- let symbolCount: number;
396
- let language: string;
204
+ const cached = mapCache.get(absPath);
397
205
 
398
206
  if (cached && cached.mtime === stats.mtimeMs) {
399
- // Cache hit - we need to regenerate map for metadata
400
- // (alternatively, cache could store metadata too)
401
- const fileMap = await generateMap(absPath, { signal });
402
- if (fileMap) {
403
- mapText = cached.map;
404
- ({ language } = fileMap);
405
- symbolCount = fileMap.symbols.length;
406
- } else {
407
- return result;
408
- }
207
+ mapText = cached.map;
409
208
  } else {
410
- // Generate new map
411
209
  const fileMap = await generateMap(absPath, { signal });
412
210
 
413
- if (fileMap) {
414
- mapText = formatFileMapWithBudget(fileMap);
415
- ({ language } = fileMap);
416
- symbolCount = fileMap.symbols.length;
417
- // Cache it
418
- mapCache.set(absPath, { mtime: stats.mtimeMs, map: mapText });
419
- } else {
211
+ if (!fileMap) {
420
212
  // Map generation failed, return original result
421
213
  return result;
422
214
  }
423
- }
424
215
 
425
- // Store map in pendingMaps for delivery after tool_result event
426
- pendingMaps.set(toolCallId, {
427
- path: absPath,
428
- map: mapText,
429
- details: {
430
- filePath: absPath,
431
- totalLines,
432
- totalBytes: stats.size,
433
- symbolCount,
434
- language,
435
- },
436
- });
216
+ mapText = formatFileMapWithBudget(fileMap);
217
+ mapCache.set(absPath, { mtime: stats.mtimeMs, map: mapText });
218
+ }
437
219
 
438
- // Return the built-in result unmodified (with cleared truncation details
439
- // since we're providing a map separately)
220
+ // Append map to the tool result content
440
221
  return {
441
222
  ...result,
442
- details: undefined,
223
+ content: [...result.content, { type: "text" as const, text: mapText }],
443
224
  };
444
225
  },
445
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
- }