/** * search-history extension * * Search through all of your chat histories and jump to a session. * * Usage: * /search-history Search all sessions across all projects * /search-history --here Search only sessions for the current cwd * /search-history --re Treat query as a regex (case-insensitive) * * Behavior: * - Lists every session whose conversation text matches the query * - Shows the matching line as the selection label so you can disambiguate * - Selecting a session switches to it at its existing leaf (latest message) */ import type { ExtensionAPI, SessionInfo } from "@earendil-works/pi-coding-agent"; import { SessionManager } from "@earendil-works/pi-coding-agent"; interface Match { info: SessionInfo; line: string; // matched line (or windowed snippet) lineNumber: number; } const MAX_RESULTS = 200; const SNIPPET_RADIUS = 60; // chars around match if line is too long const MAX_LINE_DISPLAY = 160; function compactWhitespace(s: string): string { return s.replace(/\s+/g, " ").trim(); } function snippetAround(line: string, idx: number, qlen: number): string { if (line.length <= MAX_LINE_DISPLAY) return line; const start = Math.max(0, idx - SNIPPET_RADIUS); const end = Math.min(line.length, idx + qlen + SNIPPET_RADIUS); const prefix = start > 0 ? "…" : ""; const suffix = end < line.length ? "…" : ""; return prefix + line.slice(start, end) + suffix; } function findMatch( haystack: string, pattern: RegExp, ): { line: string; lineNumber: number } | undefined { if (!haystack) return undefined; const lines = haystack.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; pattern.lastIndex = 0; const m = pattern.exec(line); if (m) { const cleaned = compactWhitespace(line); // Re-locate the match in the cleaned string for snippet windowing pattern.lastIndex = 0; const m2 = pattern.exec(cleaned); const idx = m2 ? m2.index : 0; const qlen = m2 ? m2[0].length : m[0].length; return { line: snippetAround(cleaned, idx, qlen), lineNumber: i + 1 }; } } return undefined; } function buildPattern(query: string, isRegex: boolean): RegExp | undefined { try { if (isRegex) return new RegExp(query, "i"); const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(escaped, "i"); } catch { return undefined; } } function relTime(date: Date): string { const diffMs = Date.now() - date.getTime(); const sec = Math.floor(diffMs / 1000); if (sec < 60) return `${sec}s ago`; const min = Math.floor(sec / 60); if (min < 60) return `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h ago`; const d = Math.floor(hr / 24); if (d < 30) return `${d}d ago`; const mo = Math.floor(d / 30); if (mo < 12) return `${mo}mo ago`; return `${Math.floor(mo / 12)}y ago`; } function projectLabel(cwd: string): string { if (!cwd) return "(unknown)"; const parts = cwd.split("/").filter(Boolean); return parts[parts.length - 1] ?? cwd; } function formatItem(m: Match): string { const when = relTime(m.info.modified); const proj = projectLabel(m.info.cwd); const title = m.info.name?.trim() || compactWhitespace(m.info.firstMessage) || "(empty)"; const titleTrim = title.length > 60 ? title.slice(0, 59) + "…" : title; const lineTrim = m.line.length > MAX_LINE_DISPLAY ? m.line.slice(0, MAX_LINE_DISPLAY - 1) + "…" : m.line; return `[${when}] ${proj} — ${titleTrim}\n ↳ ${lineTrim}`; } function parseArgs(raw: string): { query: string; here: boolean; regex: boolean } { const tokens = raw.trim().split(/\s+/).filter(Boolean); let here = false; let regex = false; const rest: string[] = []; for (const t of tokens) { if (t === "--here" || t === "-h") here = true; else if (t === "--re" || t === "--regex" || t === "-r") regex = true; else rest.push(t); } return { query: rest.join(" "), here, regex }; } export default function (pi: ExtensionAPI) { pi.registerCommand("search-history", { description: "Search all chat histories and jump to a session", handler: async (rawArgs, ctx) => { if (!ctx.hasUI) { ctx.ui.notify("search-history requires interactive mode", "error"); return; } const { query, here, regex } = parseArgs(rawArgs); if (!query) { ctx.ui.notify( "Usage: /search-history [--here] [--re] ", "error", ); return; } const pattern = buildPattern(query, regex); if (!pattern) { ctx.ui.notify(`Invalid regex: ${query}`, "error"); return; } ctx.ui.setStatus("search-history", `searching: ${query}`); let sessions: SessionInfo[]; try { sessions = here ? await SessionManager.list(ctx.cwd) : await SessionManager.listAll(); } catch (err) { ctx.ui.setStatus("search-history", undefined); ctx.ui.notify(`Failed to list sessions: ${(err as Error).message}`, "error"); return; } const currentFile = ctx.sessionManager.getSessionFile(); const matches: Match[] = []; for (const info of sessions) { if (info.path === currentFile) continue; // skip self const hit = findMatch(info.allMessagesText ?? "", pattern); if (hit) { matches.push({ info, line: hit.line, lineNumber: hit.lineNumber }); if (matches.length >= MAX_RESULTS) break; } } ctx.ui.setStatus("search-history", undefined); if (matches.length === 0) { ctx.ui.notify(`No sessions match "${query}"`, "info"); return; } // Most recent first matches.sort((a, b) => b.info.modified.getTime() - a.info.modified.getTime()); const items = matches.map(formatItem); const selected = await ctx.ui.select( `History matches for "${query}" (${matches.length})`, items, ); if (!selected) return; const chosen = matches[items.indexOf(selected)]; if (!chosen) return; const result = await ctx.switchSession(chosen.info.path, { withSession: async (newCtx) => { newCtx.ui.notify( `Resumed: ${projectLabel(chosen.info.cwd)} — ${ chosen.info.name?.trim() || compactWhitespace(chosen.info.firstMessage) || "session" }`, "info", ); }, }); if (result.cancelled) { ctx.ui.notify("Switch cancelled", "info"); } }, }); }