🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

pi-read-map

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

pi-read-map - npm Package Compare versions

Comparing version
1.2.5
to
1.3.0
+13
-0
CHANGELOG.md

@@ -5,2 +5,15 @@ # Changelog

## [1.3.0] - 2026-02-20
### Changed
- **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).
- Removed the `read-recovery` mechanism since parallel reads are no longer skipped by map generation.
- Removed `@mariozechner/pi-tui` dependency since custom message rendering is no longer needed.
### Fixed
- 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.
- npm tarballs now exclude local scratch artifacts (`*.patch`, `*.orig`, `fix-*.js`, etc.) via `.npmignore`, preventing accidental publication of local debug/review files.
## [1.2.5] - 2026-02-15

@@ -7,0 +20,0 @@

+1
-1
{
"name": "pi-read-map",
"version": "1.2.5",
"version": "1.3.0",
"description": "Pi extension that adds structural file maps for large files",

@@ -5,0 +5,0 @@ "type": "module",

@@ -163,3 +163,4 @@ # pi-read-map

3. **Targeted reads** (offset or limit provided): Delegate to built-in read tool
4. **Large files:**
4. **Directory paths**: Run built-in `ls` and throw an `EISDIR` error that includes inline fallback directory output.
5. **Large files:**
- Call built-in read for the first chunk

@@ -170,4 +171,6 @@ - Detect language from file extension

- Cache the map
- Send as a separate `file-map` message after `tool_result`
- Append the map text directly to the read tool's result block
*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.
## Dependencies

@@ -174,0 +177,0 @@

@@ -1,2 +0,2 @@

import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";

@@ -9,16 +9,11 @@ import {

} from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import { exec } from "node:child_process";
import { stat } from "node:fs/promises";
import { basename, extname, resolve } from "node:path";
import { extname, resolve } from "node:path";
import { promisify } from "node:util";
import type { FileMapMessageDetails } from "./types.js";
import { formatFileMapWithBudget } from "./formatter.js";
import { generateMap, shouldGenerateMap } from "./mapper.js";
export type { FileMapMessageDetails } from "./types.js";
const execAsync = promisify(exec);

@@ -83,15 +78,2 @@

// Pending maps waiting to be sent after tool_result
const pendingMaps = new Map<
string,
{
path: string;
map: string;
details: FileMapMessageDetails;
}
>();
// Pending directory listings waiting to be sent after a read-on-directory error
const pendingDirectoryLs = new Map<string, { path: string; listing: string }>();
/**

@@ -104,20 +86,2 @@ * Reset the map cache. Exported for testing purposes only.

/**
* Reset the pending maps. Exported for testing purposes only.
*/
export function resetPendingMaps(): void {
pendingMaps.clear();
pendingDirectoryLs.clear();
}
/**
* Get pending maps for testing inspection.
*/
export function getPendingMaps(): Map<
string,
{ path: string; map: string; details: FileMapMessageDetails }
> {
return pendingMaps;
}
export default function piReadMapExtension(pi: ExtensionAPI): void {

@@ -133,162 +97,2 @@ // Get the current working directory

// Register tool_result handler to send pending maps and directory listings
pi.on("tool_result", (event, _ctx) => {
if (event.toolName !== "read") {
return;
}
// Send pending directory listing after read-on-directory error
const pendingLs = pendingDirectoryLs.get(event.toolCallId);
if (pendingLs) {
pi.sendMessage(
{
customType: "directory-listing",
content: `${pendingLs.path} is a directory. Here is ls:\n${pendingLs.listing}`,
display: true,
},
{ deliverAs: "followUp" }
);
pendingDirectoryLs.delete(event.toolCallId);
}
const pending = pendingMaps.get(event.toolCallId);
if (!pending) {
return;
}
// Send the map as a custom message
pi.sendMessage(
{
customType: "file-map",
content: pending.map,
display: true,
details: pending.details,
},
{ deliverAs: "followUp" }
);
// Clean up
pendingMaps.delete(event.toolCallId);
});
// Register custom message renderer for file-map type
pi.registerMessageRenderer<FileMapMessageDetails>(
"file-map",
(message, options, theme: Theme) => {
const { expanded } = options;
const { details } = message;
if (expanded) {
// Expanded: show full formatted map
// message.content can be string or array of content blocks
const content =
typeof message.content === "string"
? message.content
: message.content
.filter((c) => c.type === "text")
.map((c) => (c as { type: "text"; text: string }).text)
.join("\n");
return new Text(content, 0, 0);
}
// Collapsed: show summary
const fileName = details ? basename(details.filePath) : "file";
const symbolCount = details?.symbolCount ?? 0;
const totalLines = details?.totalLines ?? 0;
const detailLanguage = details?.language ?? "unknown";
let summary = theme.fg("accent", "📄 File Map: ");
summary += theme.fg("toolTitle", theme.bold(fileName));
summary += theme.fg("muted", ` │ `);
summary += theme.fg("dim", `${symbolCount} symbols`);
summary += theme.fg("muted", ` │ `);
summary += theme.fg("dim", `${totalLines.toLocaleString()} lines`);
summary += theme.fg("muted", ` │ `);
summary += theme.fg("dim", detailLanguage);
summary += theme.fg("muted", ` │ `);
summary += theme.fg("dim", "Ctrl+O to expand");
return new Text(summary, 0, 0);
}
);
// Recover from skipped reads caused by map steering
pi.on("turn_end", (event) => {
const SKIPPED_TEXT = "Skipped due to queued user message.";
// Find skipped read results
const skippedReads = event.toolResults.filter(
(r) =>
r.toolName === "read" &&
!r.isError &&
r.content.some((c) => c.type === "text" && c.text === SKIPPED_TEXT)
);
if (skippedReads.length === 0) {
return;
}
// Extract paths from the assistant message's tool calls.
// The message is AgentMessage (union); narrow to AssistantMessage.
const msg = event.message;
if (!("role" in msg) || msg.role !== "assistant") {
return;
}
const skippedPaths: string[] = [];
for (const skipped of skippedReads) {
const tc = msg.content.find(
(c) =>
c.type === "toolCall" &&
c.name === "read" &&
c.id === skipped.toolCallId
);
if (tc && tc.type === "toolCall" && tc.arguments["path"]) {
skippedPaths.push(String(tc.arguments["path"]));
}
}
if (skippedPaths.length === 0) {
return;
}
const pathList = skippedPaths.map((p) => `- read("${p}")`).join("\n");
pi.sendMessage(
{
customType: "read-recovery",
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.`,
display: true,
},
{ deliverAs: "followUp" }
);
});
// Register custom message renderer for read-recovery type
pi.registerMessageRenderer(
"read-recovery",
(message, options, theme: Theme) => {
const content =
typeof message.content === "string"
? message.content
: message.content
.filter((c) => c.type === "text")
.map((c) => (c as { type: "text"; text: string }).text)
.join("\n");
if (options.expanded) {
return new Text(content, 0, 0);
}
const pathCount = (content.match(/^- read\(/gm) || []).length;
let summary = theme.fg("warning", "Recovery: ");
summary += theme.fg(
"dim",
`${pathCount} interrupted read(s) being re-issued`
);
return new Text(summary, 0, 0);
}
);
// Register our enhanced read tool

@@ -341,3 +145,6 @@ pi.registerTool({

if (stats.isDirectory()) {
// Get ls output before letting the error propagate
// Instead of letting EISDIR propagate and sending a custom steer message,
// we run ls and embed the listing directly into the thrown error.
// This prevents steer from breaking parallel executions.
let lsText: string | null = null;
try {

@@ -349,3 +156,3 @@ const lsResult = await builtInLs.execute(

);
const lsText = lsResult.content
lsText = lsResult.content
.filter(

@@ -356,10 +163,14 @@ (c): c is { type: "text"; text: string } => c.type === "text"

.join("\n");
pendingDirectoryLs.set(toolCallId, {
path: absPath,
listing: lsText,
});
} catch {
// best-effort: if ls fails, just let the error through without listing
}
// Delegate to built-in read which will throw EISDIR
if (lsText !== null) {
// eslint-disable-next-line @factory/structured-logging
throw new Error(
`EISDIR: illegal operation on a directory, read '${absPath}'\n\nFallback ls output for this directory:\n${lsText}`
);
}
// Fallback if ls fails for some reason
return builtInRead.execute(toolCallId, params, signal, onUpdate);

@@ -390,4 +201,3 @@ }

// File exceeds threshold - generate map
// First, get the built-in result
// File exceeds threshold - generate map and inline it in the tool result
const result = await builtInRead.execute(

@@ -400,53 +210,24 @@ toolCallId,

// Check cache
// Generate or retrieve cached map
let mapText: string;
const cached = mapCache.get(absPath);
let mapText: string;
let symbolCount: number;
let language: string;
if (cached && cached.mtime === stats.mtimeMs) {
// Cache hit - we need to regenerate map for metadata
// (alternatively, cache could store metadata too)
const fileMap = await generateMap(absPath, { signal });
if (fileMap) {
mapText = cached.map;
({ language } = fileMap);
symbolCount = fileMap.symbols.length;
} else {
return result;
}
mapText = cached.map;
} else {
// Generate new map
const fileMap = await generateMap(absPath, { signal });
if (fileMap) {
mapText = formatFileMapWithBudget(fileMap);
({ language } = fileMap);
symbolCount = fileMap.symbols.length;
// Cache it
mapCache.set(absPath, { mtime: stats.mtimeMs, map: mapText });
} else {
if (!fileMap) {
// Map generation failed, return original result
return result;
}
mapText = formatFileMapWithBudget(fileMap);
mapCache.set(absPath, { mtime: stats.mtimeMs, map: mapText });
}
// Store map in pendingMaps for delivery after tool_result event
pendingMaps.set(toolCallId, {
path: absPath,
map: mapText,
details: {
filePath: absPath,
totalLines,
totalBytes: stats.size,
symbolCount,
language,
},
});
// Return the built-in result unmodified (with cleared truncation details
// since we're providing a map separately)
// Append map to the tool result content
return {
...result,
details: undefined,
content: [...result.content, { type: "text" as const, text: mapText }],
};

@@ -453,0 +234,0 @@ },

@@ -84,13 +84,1 @@ import type { DetailLevel, SymbolKind } from "./enums.js";

*/
export interface FileMapMessageDetails {
/** Absolute file path */
filePath: string;
/** Total lines in file */
totalLines: number;
/** Total bytes in file */
totalBytes: number;
/** Number of symbols in map */
symbolCount: number;
/** Detected language */
language: string;
}