import type { SessionEntry } from "@earendil-works/pi-coding-agent"; import { getEpochState, visibleText } from "./epoch.ts"; export interface HistoryItem { entryId: string; epochIndex: number; kind: "user" | "assistant" | "tool_call" | "tool_result" | "custom" | "other"; toolName?: string; text: string; /** Position on the scanned list; higher = more recent. */ position: number; } export interface HistoryMatch { entryId: string; epochIndex: number; kind: HistoryItem["kind"]; toolName?: string; /** Bounded preview around the first match occurrence. */ preview: string; score: number; } const TOOL_RESULT_CHAR_CAP = 4_000; function toolResultText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const block of content) { if ( block && typeof block === "object" && (block as { type?: string }).type === "text" && typeof (block as { text?: unknown }).text === "string" ) { parts.push((block as { text: string }).text); } } const joined = parts.join("\n"); if (joined.length > TOOL_RESULT_CHAR_CAP) { return `${joined.slice(0, TOOL_RESULT_CHAR_CAP)}…[truncated ${joined.length - TOOL_RESULT_CHAR_CAP} chars]`; } return joined; } /** * Flatten session entries into searchable history items. * - visible user / assistant text only; reasoning (thinking) blocks are never included * - tool calls: name + JSON arguments * - tool results: bounded text */ export function collectHistoryItems(entries: SessionEntry[]): HistoryItem[] { const { epochIndex } = getEpochState(entries); // Epoch attribution: count boundaries at or before each entry position. let runningEpoch = 0; const items: HistoryItem[] = []; entries.forEach((entry, position) => { if (entry.type === "compaction") { const details = entry.details as { kind?: string } | undefined; if (details?.kind === "pi-context-epochs/epoch") { runningEpoch += 1; } return; } if (entry.type === "message") { const message = entry.message; if (message.role === "user") { const text = visibleText(message.content).trim(); if (text) items.push({ entryId: entry.id, epochIndex: runningEpoch, kind: "user", text, position }); return; } if (message.role === "assistant") { const textParts: string[] = []; for (const block of message.content) { if (block.type === "text") textParts.push(block.text); if (block.type === "toolCall") { let args = ""; try { args = JSON.stringify(block.arguments); } catch { args = String(block.arguments); } if (args.length > 800) args = `${args.slice(0, 800)}…`; items.push({ entryId: entry.id, epochIndex: runningEpoch, kind: "tool_call", toolName: block.name, text: `${block.name}(${args})`, position, }); } // thinking blocks are deliberately skipped (never exposed) } const text = textParts.join("\n").trim(); if (text) items.push({ entryId: entry.id, epochIndex: runningEpoch, kind: "assistant", text, position }); return; } if (message.role === "toolResult") { const text = toolResultText(message.content).trim(); if (text) { items.push({ entryId: entry.id, epochIndex: runningEpoch, kind: "tool_result", toolName: message.toolName, text, position, }); } return; } if (message.role === "custom") { const text = visibleText(message.content).trim(); if (text) { items.push({ entryId: entry.id, epochIndex: runningEpoch, kind: "custom", text: `[${message.customType}] ${text}`, position, }); } return; } return; } if (entry.type === "custom_message") { const text = visibleText(entry.content).trim(); if (text) { items.push({ entryId: entry.id, epochIndex: runningEpoch, kind: "custom", text: `[${entry.customType}] ${text}`, position, }); } } }); return items; } function countOccurrences(haystack: string, needle: string): number { let count = 0; let index = haystack.indexOf(needle); while (index !== -1) { count += 1; index = haystack.indexOf(needle, index + needle.length); } return count; } function buildPreview(text: string, lowerText: string, needle: string, maxChars: number): string { const index = lowerText.indexOf(needle); if (index === -1) return text.slice(0, maxChars); const start = Math.max(0, index - Math.floor(maxChars / 3)); const end = Math.min(text.length, start + maxChars); const prefix = start > 0 ? "…" : ""; const suffix = end < text.length ? "…" : ""; return `${prefix}${text.slice(start, end)}${suffix}`; } export interface SearchOptions { query: string; limit: number; /** "branch" (default) searches the current branch only; "session" searches all entries in the file. */ scope?: "branch" | "session"; } /** * Lexical history search over the durable transcript: case-insensitive * substring matching with a simple score (occurrences + recency + role bias). * No embeddings, no background index - the session entries are the source. */ export function searchHistory(entries: SessionEntry[], options: SearchOptions): HistoryMatch[] { const query = options.query.trim().toLowerCase(); if (!query) return []; const items = collectHistoryItems(entries); const total = items.length; const matches: HistoryMatch[] = []; for (const item of items) { const lowerText = item.text.toLowerCase(); const occurrences = countOccurrences(lowerText, query); if (occurrences === 0) continue; const recency = total > 1 ? item.position / (total - 1) : 1; const roleBias = item.kind === "user" ? 0.5 : item.kind === "assistant" ? 0.25 : 0; const score = occurrences * 2 + recency * 3 + roleBias; matches.push({ entryId: item.entryId, epochIndex: item.epochIndex, kind: item.kind, toolName: item.toolName, preview: buildPreview(item.text, lowerText, query, 280), score, }); } matches.sort((a, b) => b.score - a.score || b.epochIndex - a.epochIndex); return matches.slice(0, Math.max(1, options.limit)); } export interface ReadOptions { ref: string; around: number; maxChars: number; } export interface ReadBlock { entryId: string; epochIndex: number; kind: HistoryItem["kind"] | "other"; toolName?: string; text: string; truncated: boolean; } function renderEntry(entry: SessionEntry, epochIndex: number): ReadBlock | null { if (entry.type === "message") { const message = entry.message; if (message.role === "user") { const text = visibleText(message.content).trim(); if (!text) return null; return { entryId: entry.id, epochIndex, kind: "user", text, truncated: false }; } if (message.role === "assistant") { const parts: string[] = []; for (const block of message.content) { if (block.type === "text") parts.push(block.text); if (block.type === "toolCall") { let args = ""; try { args = JSON.stringify(block.arguments); } catch { args = String(block.arguments); } if (args.length > 800) args = `${args.slice(0, 800)}…`; parts.push(`[tool call: ${block.name}(${args})]`); } // thinking blocks are deliberately skipped (never exposed) } const text = parts.join("\n").trim(); if (!text) return null; return { entryId: entry.id, epochIndex, kind: "assistant", text, truncated: false }; } if (message.role === "toolResult") { const full = toolResultText(message.content); const text = full.trim(); if (!text) return null; return { entryId: entry.id, epochIndex, kind: "tool_result", toolName: message.toolName, text, truncated: full.length > TOOL_RESULT_CHAR_CAP, }; } if (message.role === "custom") { const text = visibleText(message.content).trim(); if (!text) return null; return { entryId: entry.id, epochIndex, kind: "custom", text: `[${message.customType}] ${text}`, truncated: false }; } return null; } if (entry.type === "custom_message") { const text = visibleText(entry.content).trim(); if (!text) return null; return { entryId: entry.id, epochIndex, kind: "custom", text: `[${entry.customType}] ${text}`, truncated: false }; } return null; } const PER_BLOCK_CAP = 6_000; /** * Read one entry by ref (stable entry id) plus up to `around` neighboring * entries on each side. Applies strict per-block and total char budgets so a * single history read can never drag hundreds of thousands of tokens back * into the context. */ export function readHistory(entries: SessionEntry[], options: ReadOptions): { blocks: ReadBlock[]; refFound: boolean; } { const ref = options.ref; const index = entries.findIndex((entry) => entry.id === ref); if (index === -1) return { blocks: [], refFound: false }; const { epochIndex } = getEpochState(entries); let runningEpoch = 0; const epochAt: number[] = []; entries.forEach((entry, i) => { if (entry.type === "compaction") { const details = entry.details as { kind?: string } | undefined; if (details?.kind === "pi-context-epochs/epoch") runningEpoch += 1; } epochAt[i] = runningEpoch; }); const start = Math.max(0, index - options.around); const end = Math.min(entries.length - 1, index + options.around); const blocks: ReadBlock[] = []; for (let i = start; i <= end; i++) { const block = renderEntry(entries[i], epochAt[i]); if (block) blocks.push(block); } // Enforce budgets: per-block cap first, then global cap (newest-first trim). for (const block of blocks) { if (block.text.length > PER_BLOCK_CAP) { block.text = `${block.text.slice(0, PER_BLOCK_CAP)}…[truncated ${block.text.length - PER_BLOCK_CAP} chars]`; block.truncated = true; } } let total = blocks.reduce((sum, block) => sum + block.text.length, 0); if (total > options.maxChars) { for (let i = 0; i < blocks.length && total > options.maxChars; i++) { const block = blocks[i]; const overflow = total - options.maxChars; const keep = Math.max(200, block.text.length - overflow); if (keep < block.text.length) { block.text = `${block.text.slice(0, keep)}…[truncated ${block.text.length - keep} chars]`; block.truncated = true; total = blocks.reduce((sum, b) => sum + b.text.length, 0); } } } void epochIndex; return { blocks, refFound: true }; } export function formatHistoryRead(blocks: ReadBlock[]): string { const lines: string[] = []; for (const block of blocks) { const label = block.kind === "tool_call" ? `tool_call ${block.toolName ?? ""}`.trim() : block.kind === "tool_result" ? `tool_result ${block.toolName ?? ""}`.trim() : block.kind; lines.push(`[epoch ${block.epochIndex} · entry ${block.entryId} · ${label}]`); lines.push(block.text); lines.push(""); } return lines.join("\n").trimEnd(); }