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; | |
| }; |