Skip to content

Instantly share code, notes, and snippets.

@IgorWarzocha
Created June 11, 2026 09:56
Show Gist options
  • Select an option

  • Save IgorWarzocha/2514353bf3a5c6af503465cbfa756ca7 to your computer and use it in GitHub Desktop.

Select an option

Save IgorWarzocha/2514353bf3a5c6af503465cbfa756ca7 to your computer and use it in GitHub Desktop.
pi-hash read tool with search mode

pi-hash read tool

Most up-to-date local version found at: /home/igorw/Work/pi/pi-extensions-complete/pi-hash/src/read/

Includes in-file search mode via search, contextBefore, contextAfter, and maxMatches.

import { access, readFile } from "node:fs/promises";
import { constants } from "node:fs";
import path from "node:path";
import type { TextContent, ImageContent } from "@mariozechner/pi-ai";
import type { ReadFileInput, ReadDetail } from "./types.js";
const IMAGE_SIGNATURES: Array<{ bytes: number[]; mime: string }> = [
{ bytes: [0x89, 0x50, 0x4e, 0x47], mime: "image/png" },
{ bytes: [0xff, 0xd8, 0xff], mime: "image/jpeg" },
{ bytes: [0x47, 0x49, 0x46, 0x38], mime: "image/gif" },
{ bytes: [0x52, 0x49, 0x46, 0x46], mime: "image/webp" },
];
const MAX_LINES = 2000;
const MAX_BYTES = 50 * 1024;
const MAX_MATCHES = 1000;
function detectImage(buffer: Buffer): string | null {
if (buffer.length < 12) return null;
for (const sig of IMAGE_SIGNATURES) {
if (!sig.bytes.every((byte, index) => buffer[index] === byte)) continue;
if (sig.mime === "image/webp" && (buffer[8] !== 0x57 || buffer[9] !== 0x45 || buffer[10] !== 0x42 || buffer[11] !== 0x50)) continue;
return sig.mime;
}
return null;
}
function renderLine(content: string): string {
return content;
}
function createMatcher(file: ReadFileInput): (line: string) => boolean {
if (!file.search) throw new Error("Invalid input: search query MUST be provided when search mode is used.");
const needle = file.search.toLowerCase();
return (line: string) => line.toLowerCase().includes(needle);
}
function searchFile(lines: string[], file: ReadFileInput): { output: string[]; matches: number } {
const matcher = createMatcher(file);
const cap = Math.min(Math.max(1, file.maxMatches ?? 200), MAX_MATCHES);
const before = Math.max(0, file.contextBefore ?? 0);
const after = Math.max(0, file.contextAfter ?? 0);
const matches: number[] = [];
for (let index = 0; index < lines.length; index += 1) {
if (!matcher(lines[index])) continue;
matches.push(index);
if (matches.length >= cap) break;
}
const include = new Set<number>();
for (const match of matches) {
for (let index = Math.max(0, match - before); index <= Math.min(lines.length - 1, match + after); index += 1) {
include.add(index);
}
}
const sorted = [...include].sort((left, right) => left - right);
const output = [`Search: '${file.search}' | Matches: ${matches.length}`];
if (sorted.length === 0) return { output, matches: 0 };
let previous = -2;
for (const index of sorted) {
if (previous !== -2 && index > previous + 1) output.push("...");
output.push(renderLine(lines[index]));
previous = index;
}
return { output, matches: matches.length };
}
function readRange(lines: string[], file: ReadFileInput): { output: string[]; truncated: boolean } {
const start = Math.max(0, (file.offset ?? 1) - 1);
if (start >= lines.length) throw new Error(`Invalid input: offset ${file.offset} is beyond end of file (${lines.length} lines).`);
const implicit = file.limit === undefined && lines.length > 1000 ? 400 : undefined;
const count = file.limit ?? implicit;
const end = count !== undefined ? Math.min(lines.length, start + count) : lines.length;
const output: string[] = [];
let bytes = 0;
let truncated = false;
for (let index = start; index < end; index += 1) {
const line = renderLine(lines[index]);
if (output.length >= MAX_LINES || bytes + line.length > MAX_BYTES) {
truncated = true;
output.push(`\n[Showing lines ${start + 1}-${index} of ${lines.length}. Use offset=${index + 1} to continue.]`);
break;
}
output.push(line);
bytes += line.length + 1;
}
if (!truncated && count !== undefined && start + count < lines.length) {
const mode = file.limit === undefined ? "implicit safety limit" : "requested limit";
output.push(`\n[${lines.length - (start + count)} more lines (${mode}). Use offset=${end + 1} to continue.]`);
}
return { output, truncated };
}
export async function executeRead(cwd: string, files: ReadFileInput[]) {
const content: (TextContent | ImageContent)[] = [];
const details: ReadDetail[] = [];
const batch = files.length > 1;
if (batch) {
content.push({ type: "text", text: "*** Begin Read" });
}
for (const file of files) {
if (batch) {
content.push({ type: "text", text: `*** Read File: ${file.path}` });
}
try {
const absolute = path.resolve(cwd, file.path.replace(/^@/, "").trim());
await access(absolute, constants.R_OK);
const buffer = await readFile(absolute);
const mime = detectImage(buffer);
if (mime) {
if (file.search) throw new Error("Invalid input: search MUST NOT be used for image files.");
content.push({ type: "text", text: `Read image file [${mime}]` });
content.push({ type: "image", data: buffer.toString("base64"), mimeType: mime });
details.push({ path: file.path });
continue;
}
const lines = buffer.toString("utf-8").split("\n");
if (file.search) {
const result = searchFile(lines, file);
content.push({ type: "text", text: result.output.join("\n") });
details.push({ path: file.path, search: file.search, matches: result.matches });
continue;
}
const result = readRange(lines, file);
content.push({ type: "text", text: result.output.join("\n") });
details.push({ path: file.path, offset: file.offset, limit: file.limit, truncated: result.truncated });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
content.push({ type: "text", text: `ERROR: ${message}` });
details.push({ path: file.path, error: message });
}
}
if (batch) {
content.push({ type: "text", text: "*** End Read" });
}
return { content, details: { files: details } };
}
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import type { TextContent, ImageContent } from "@mariozechner/pi-ai";
import { detectBashWriteViolation } from "../bash-guard.js";
const BASH_READ_PATTERNS = [
/^(?:\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*)?(?:cat|head|tail|less|more|nl)\b/,
/^(?:\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*)?sed\b(?=.*(?:^|\s)-n(?:\s|$))(?=.*\bp(?:\s|$|'|"))/,
];
const BASH_NUDGE = "Note: You SHOULD use read for file inspection because it provides multi-file reads, offset/limit, and in-file search. You SHOULD NOT use bash for file inspection when read can access the target files.";
const WRITE_NUDGE = "Note: You SHOULD use apply_patch for file modifications. Bash write/destructive patterns are discouraged for reliability.";
const BATCH_NUDGE = "Note: You SHOULD batch related file inspections into one read call (array input) instead of one-file-at-a-time reads.";
function matchesBashRead(command: string): boolean {
const chains = command.trim().split(/&&|\|\||;/g).map((value) => value.trim()).filter(Boolean);
for (const chain of chains) {
const first = chain.split("|")[0].trim();
if (BASH_READ_PATTERNS.some((pattern) => pattern.test(first))) return true;
}
return false;
}
function isSingleReadResult(details: unknown): boolean {
if (typeof details !== "object" || details === null) return false;
const record = details as Record<string, unknown>;
if (!Array.isArray(record.files)) return false;
return record.files.length === 1;
}
export function setupReadGuard(pi: ExtensionAPI) {
pi.on("tool_call", (event, ctx) => {
if ((event.toolName === "read" || event.toolName === "apply_patch") && ctx.hasUI) {
ctx.ui.setToolsExpanded(false);
}
});
pi.on("tool_result", (event, ctx) => {
if ((event.toolName === "read" || event.toolName === "apply_patch") && ctx.hasUI) {
ctx.ui.setToolsExpanded(false);
}
if (event.toolName === "bash" && !event.isError && event.input) {
const command = (event.input.command as string) ?? "";
const additions: string[] = [];
if (matchesBashRead(command)) additions.push(BASH_NUDGE);
if (detectBashWriteViolation(command)) additions.push(WRITE_NUDGE);
if (additions.length > 0) {
const existing: (TextContent | ImageContent)[] = Array.isArray(event.content) ? event.content : [];
return {
content: [...existing, { type: "text" as const, text: `\n${additions.join("\n")}` }],
};
}
}
if (event.toolName === "read" && !event.isError && isSingleReadResult(event.details)) {
const existing: (TextContent | ImageContent)[] = Array.isArray(event.content) ? event.content : [];
return {
content: [...existing, { type: "text" as const, text: `\n${BATCH_NUDGE}` }],
};
}
});
}
import type { ReadFileInput } from "./types.js";
function parseLoose(input: string): unknown {
const trimmed = input.trim();
if (!(trimmed.startsWith("[") || trimmed.startsWith("{"))) return undefined;
try {
return JSON.parse(trimmed);
} catch {}
try {
return JSON.parse(trimmed.replace(/'/g, '"'));
} catch {}
const paths: ReadFileInput[] = [];
const pathField = /["']path["']\s*:\s*["']([^"']+)["']/g;
for (const match of trimmed.matchAll(pathField)) {
const path = match[1]?.trim();
if (!path) continue;
paths.push({ path });
}
if (paths.length > 0) return paths;
const quoted = /["']([^"']+)["']/g;
for (const match of trimmed.matchAll(quoted)) {
const path = match[1]?.trim();
if (!path) continue;
paths.push({ path });
}
if (paths.length > 0) return paths;
return undefined;
}
function text(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (trimmed.length === 0) return undefined;
return trimmed;
}
function number(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (trimmed.length === 0) return undefined;
const parsed = Number.parseFloat(trimmed);
if (!Number.isFinite(parsed)) return undefined;
return parsed;
}
function item(value: unknown): ReadFileInput | undefined {
if (typeof value === "string") {
const path = text(value);
if (!path) return undefined;
return { path };
}
if (typeof value !== "object" || value === null) return undefined;
const record = value as Record<string, unknown>;
const path = text(record.path);
if (!path) return undefined;
const next: ReadFileInput = { path };
const offset = number(record.offset);
if (offset !== undefined) next.offset = offset;
const limit = number(record.limit);
if (limit !== undefined) next.limit = limit;
const search = text(record.search);
if (search !== undefined) next.search = search;
const contextBefore = number(record.contextBefore);
if (contextBefore !== undefined) next.contextBefore = contextBefore;
const contextAfter = number(record.contextAfter);
if (contextAfter !== undefined) next.contextAfter = contextAfter;
const maxMatches = number(record.maxMatches);
if (maxMatches !== undefined) next.maxMatches = maxMatches;
return next;
}
function list(value: unknown): ReadFileInput[] {
if (!Array.isArray(value)) {
if (typeof value === "string") {
const loose = parseLoose(value);
if (loose !== undefined) {
return list(loose);
}
}
const one = item(value);
if (!one) return [];
return [one];
}
const out: ReadFileInput[] = [];
for (const entry of value) {
const next = item(entry);
if (!next) continue;
out.push(next);
}
return out;
}
export function normalizeInput(input: unknown): ReadFileInput[] {
let normalized = input;
if (typeof normalized === "string") {
const trimmed = normalized.trim();
if (trimmed.length === 0) return [];
const loose = parseLoose(trimmed);
if (loose !== undefined) {
normalized = loose;
} else {
return [{ path: trimmed }];
}
}
if (Array.isArray(normalized)) {
return list(normalized);
}
if (typeof normalized !== "object" || normalized === null) {
return [];
}
const record = normalized as Record<string, unknown>;
if ("files" in record) {
const files = list(record.files);
if (files.length > 0) return files;
}
if ("paths" in record) {
const paths = list(record.paths);
if (paths.length > 0) return paths;
}
if ("file" in record) {
const file = list(record.file);
if (file.length > 0) return file;
}
if ("path" in record) {
const direct = item(record);
if (direct) {
const nested = parseLoose(direct.path);
if (nested !== undefined) {
const parsed = list(nested);
if (parsed.length > 0) return parsed;
}
return [direct];
}
}
return [];
}
import { keyHint, type Theme } from "@mariozechner/pi-coding-agent";
import { Container, Text } from "@mariozechner/pi-tui";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ReadDetail } from "./types.js";
export function renderRead(result: AgentToolResult<unknown>, options: { expanded?: boolean }, theme: Theme) {
const container = new Container();
const details = result.details as Record<string, unknown> | undefined;
const files = (details?.files ?? []) as ReadDetail[];
if (!options.expanded) {
for (const detail of files) {
if (detail.error) {
container.addChild(new Text(theme.fg("error", `read ${detail.path}\nERROR: ${detail.error}`), 0, 0));
continue;
}
const range = detail.offset !== undefined || detail.limit !== undefined
? `:${detail.offset ?? 1}${detail.limit !== undefined ? `-${(detail.offset ?? 1) + detail.limit - 1}` : ""}`
: "";
const search = detail.search
? theme.fg("muted", ` search="${detail.search}"${typeof detail.matches === "number" ? ` matches=${detail.matches}` : ""}`)
: "";
container.addChild(new Text(`${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", detail.path)}${theme.fg("warning", range)}${search}`, 0, 0));
}
if (files.length > 0) container.addChild(new Text(theme.fg("muted", `(${keyHint("expandTools", "to expand output")})`), 0, 0));
return container;
}
const items = result.content as Array<{ type: string; text?: string }>;
for (const item of items) {
if (item.type !== "text") continue;
container.addChild(new Text(theme.fg("toolOutput", item.text ?? ""), 0, 0));
}
return container;
}
import { Type } from "@sinclair/typebox";
import type { ExtensionAPI, Theme, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { normalizeInput } from "./normalizer.js";
import { executeRead } from "./executor.js";
import { renderRead } from "./renderer.js";
import { ReadFileSchema } from "./types.js";
const entry = Type.Union([
Type.String({
description: "MAY be a file path string.",
}),
ReadFileSchema,
]);
const batch = Type.Array(entry, {
minItems: 1,
description: "Batch input. Each entry MAY be a path string or an object with path, offset, limit, search, contextBefore, contextAfter, maxMatches.",
});
const payload = Type.Union([
entry,
batch,
]);
export function registerReadTool(pi: ExtensionAPI) {
pi.registerTool({
name: "read",
label: "Read File(s)",
description:
"Read one or more text or image files. You MUST batch related files in one call. You MUST provide at least one of: files, path, paths, or file. Preferred call: read({ files: [\"a.ts\", \"b.ts\"] }). You MUST NOT call read with an empty object.",
parameters: Type.Object({
files: Type.Optional(payload),
path: Type.Optional(Type.String({
description: "Compat key. Single file path.",
})),
paths: Type.Optional(payload),
file: Type.Optional(payload),
}, { additionalProperties: true }),
renderResult(result: AgentToolResult<unknown>, options: ToolRenderResultOptions, theme: Theme) {
return renderRead(result, options, theme);
},
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const files = normalizeInput(params);
if (files.length === 0) {
return {
content: [{ type: "text", text: "Invalid input: You MUST provide files, path, paths, or file with at least one readable entry." }],
isError: true,
details: { files: [] },
};
}
return executeRead(ctx.cwd, files);
},
});
}
import { Type, type Static } from "@sinclair/typebox";
export const ReadFileSchema = Type.Object({
path: Type.String({
description: "REQUIRED. Path to file. Relative paths are resolved from cwd. Absolute paths are allowed.",
}),
offset: Type.Optional(
Type.Number({
description: "OPTIONAL. 1-indexed start line. MUST be >= 1 when provided.",
}),
),
limit: Type.Optional(
Type.Number({
description: "OPTIONAL. Maximum number of lines to read. SHOULD be set for large files.",
}),
),
search: Type.Optional(
Type.String({
description: "OPTIONAL. Search query. When set, tool SHALL return matches and optional context.",
}),
),
contextBefore: Type.Optional(
Type.Number({
description: "OPTIONAL. Context lines before each match.",
}),
),
contextAfter: Type.Optional(
Type.Number({
description: "OPTIONAL. Context lines after each match.",
}),
),
maxMatches: Type.Optional(
Type.Number({
description: "OPTIONAL. Max matched lines to return.",
}),
),
});
export type ReadFileInput = Static<typeof ReadFileSchema>;
export type ReadDetail = {
path: string;
offset?: number;
limit?: number;
search?: string;
matches?: number;
truncated?: boolean;
error?: string;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment