/** * memory_search — search your own history instead of the web. * * Three sources, searched on the fly (no index yet — that comes later): * 1. pi sessions — ~/.pi/agent/sessions//*.jsonl (chat transcripts) * 2. claude-recall — ~/.claude-recall/claude-recall.db (stored memories) * 3. markdown docs — *.md under the project, plus ~/.claude/memory and * ~/.pi/agent (CLAUDE.md / AGENTS.md / notes); ~/work too for scope=all * * Scope: * - "current" (default): the current project only. Sessions are the folder * whose de-slugged path matches cwd; recall is project_id === basename(cwd) * (plus universal memories); md docs are under cwd. * - "all": every project on this machine. Sessions = all folders; recall = * all rows; md docs = ~/work + ~/.pi/agent. * * Ranking is deliberately simple for now: keyword-overlap score × recency * boost. FTS5/BM25 is a later upgrade. */ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import { execFile, execFileSync, spawn } from "node:child_process"; import { createRequire } from "node:module"; import { refreshSessionDigest, sessionDigestFiles, sessionSourceForDigest } from "./session-digest.ts"; import type { QueryResultData } from "./storage.ts"; export type MemoryScope = "current" | "all"; export type MemorySource = "sessions" | "memory" | "docs" | "git"; export interface MemoryHit { source: MemorySource; /** Short human label: session role / memory type / doc path. */ label: string; /** The matched text snippet (already trimmed around the match). */ snippet: string; /** Where it came from (session file, memory key, or doc path). */ location: string; /** Project this belongs to (basename), or "universal". */ project: string; /** Unix ms of the item (session event ts, memory ts, or file mtime). */ timestamp: number; /** Final rank score (higher = better). */ score: number; } /** Health of one source after a search (perf#3: silent partials are dishonest). */ export type SourceStatus = "ok" | "partial" | "failed" | "skipped"; export interface MemorySearchResult { hits: MemoryHit[]; /** Per-source health: partial = truncated/killed scan, failed = tool missing/crashed. */ sourceStatus: Partial>; } export interface MemorySearchOptions { scope?: MemoryScope; sources?: MemorySource[]; /** Only items newer than this many ms ago (e.g. "yesterday" → ~48h). */ sinceMs?: number; /** Max hits to return. */ limit?: number; /** cwd to resolve the current project from. */ cwd?: string; /** Abort signal — cancels in-flight rg/git/sqlite child processes. */ signal?: AbortSignal; } const SESSIONS_ROOT = join(homedir(), ".pi", "agent", "sessions"); const RECALL_DB = join(homedir(), ".claude-recall", "claude-recall.db"); const WORK_ROOT = join(homedir(), "work"); const PI_AGENT_ROOT = join(homedir(), ".pi", "agent"); /** Hand-written knowledge that is NOT a chat log: the claude memory tree. */ const CLAUDE_MEMORY_ROOT = join(homedir(), ".claude", "memory"); /** * Where markdown is searched. * * The project is the obvious root, but the durable notes live outside it: the * claude memory tree (MEMORY.md, entities/, decisions/, journal/) and the agent * instruction files under ~/.pi/agent. Those are small and always worth * scanning, so they are in for both scopes; `scope: "all"` adds every project. */ function docRoots(scope: MemoryScope, cwd: string): string[] { const roots = scope === "current" ? [cwd] : [WORK_ROOT]; return [...new Set([...roots, CLAUDE_MEMORY_ROOT, PI_AGENT_ROOT])].filter((r) => existsSync(r)); } // ── query + scoring ───────────────────────────────────────────────────────── // Filler words that carry no search signal in ANY source. Without this, // question-form queries ('what did we decide about X', 'что мы решили про X') // rank filler-dense boilerplate above documents that only contain X (round-2 // product#2). Kept small — only unambiguous fillers. const QUERY_STOPWORDS = new Set([ // en "what", "did", "we", "the", "a", "an", "about", "how", "was", "were", "is", "are", "do", "does", "our", "my", "me", "you", "it", "to", "of", "in", "on", "and", "or", "that", "this", "there", "decide", "decided", "discuss", "discussed", "say", "said", "tell", "remember", // ru "что", "мы", "про", "как", "был", "была", "было", "были", "это", "там", "тут", "наш", "наша", "наше", "мой", "мне", "ты", "он", "она", "оно", "они", "или", "решили", "решали", "обсуждали", "говорили", "сказал", "помнишь", "помню", "вспомни", "поищи", "найди", "покажи", "переписке", "памяти", "истории", "какой", "какая", "какое", "какие", "каком", "какую", "который", "которая", "которые", "когда", "где", "куда", "почему", "зачем", "чем", "кто", "кого", "нашей", "нашем", "наших", "нас", "нам", "вы", "вас", "вам", ]); function tokenize(q: string): string[] { const all = q .toLowerCase() .split(/[^\p{L}\p{N}_]+/u) .filter((t) => t.length >= 2); // Drop fillers, but never down to an empty list — a pure-filler query (rare // outside git window mode) keeps its tokens rather than matching nothing. const content = all.filter((t) => !QUERY_STOPWORDS.has(t)); return content.length > 0 ? content : all; } /** Escape a literal string for safe use inside a regex (rg -e, git -G). */ function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // Redact obvious secrets before a snippet reaches the model (review #1.3). This // tool searches recall memories + git diffs, which are exactly where API keys // and passwords live. Best-effort — not a substitute for not committing secrets. const SECRET_PATTERNS: RegExp[] = [ /\bsk-[A-Za-z0-9_-]{16,}/g, // OpenAI/proxy-style keys (sk-proxy-..., sk-...) /\bAIza[0-9A-Za-z_-]{20,}/g, // Google API keys /\bgh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub tokens /\bxox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens /-----BEGIN [A-Z ]*PRIVATE KEY-----/g, /\b(password|passwd|pwd|secret|token|api[_-]?key)\s*[:=]\s*\S+/gi, ]; function redactSecrets(text: string): string { let out = text; for (const re of SECRET_PATTERNS) out = out.replace(re, "[REDACTED]"); return out; } /** * Parse a session/event timestamp into unix-ms. pi writes message events with * ISO-8601 strings ('2026-07-10T16:40:23.774Z'); some events use numeric ms. * Returns `fallback` (file mtime) when unparseable. (B1: Number(ISO) is NaN, so * the old `Number(ts) || mtime` silently used mtime for every chat hit.) */ function parseTimestamp(raw: unknown, fallback: number): number { if (typeof raw === "number" && Number.isFinite(raw)) { return raw > 1e12 ? raw : raw * 1000; // seconds → ms } if (typeof raw === "string") { const n = Number(raw); if (Number.isFinite(n) && raw.trim() !== "") return n > 1e12 ? n : n * 1000; const parsed = Date.parse(raw); if (Number.isFinite(parsed)) return parsed; } return fallback; } // Timeout for every external command so a hung rg/git/sqlite can't block the // event loop (edge-case review #1.2). Partial/killed output degrades to []. const EXEC_TIMEOUT_MS = 20_000; interface ExecResult { stdout: string; /** Process exit code; undefined when the process failed to spawn/was killed. */ status: number | undefined; /** True when the process was killed (timeout/abort) or failed to spawn. */ broken: boolean; /** True when output was cut at the caller's byte budget (streaming exec only). */ budgetHit?: boolean; } /** * Async exec (round-2 arch#1/perf#1): execFileSync froze the pi event loop for * 8–24s measured. execFile keeps the loop free, lets sources run in parallel, * and honors the tool's AbortSignal so the user can cancel a runaway search. * Never throws — partial stdout is preserved with `broken` set. */ /** * Where ripgrep actually lives. * * Pi downloads its own `rg` into the agent bin directory and does not put that * directory on PATH, so a bare "rg" works on a developer box and silently fails * on a server — measured: two of three memory_search sources reported * "tool missing/crashed" on a host where pi had installed rg itself. Resolved * once per process: PATH first, then pi's own copy. */ let ripgrepPath: string | null = null; function ripgrep(): string { if (ripgrepPath) return ripgrepPath; const agentDir = process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent"); const own = join(agentDir, "bin", process.platform === "win32" ? "rg.exe" : "rg"); ripgrepPath = existsSync(own) ? own : "rg"; return ripgrepPath; } function execFileAsync( cmd: string, args: string[], maxBuffer: number, signal?: AbortSignal, ): Promise { return new Promise((resolve) => { execFile( cmd, args, { encoding: "utf-8", maxBuffer, timeout: EXEC_TIMEOUT_MS, signal }, (err, stdout) => { if (!err) { resolve({ stdout: stdout ?? "", status: 0, broken: false }); return; } const e = err as NodeJS.ErrnoException & { code?: number | string; killed?: boolean }; const status = typeof e.code === "number" ? e.code : undefined; resolve({ stdout: stdout ?? "", status, broken: status === undefined }); }, ); }); } /** * Like execFileAsync, but reads stdout incrementally and STOPS the child once * `budgetBytes` have arrived, keeping only whole lines. Callers order their * inputs newest-first, so hitting the budget drops the oldest data, never the * newest — the property a bounded history search needs. Never throws. */ function execFileStreaming( cmd: string, args: string[], budgetBytes: number, signal?: AbortSignal, ): Promise { return new Promise((resolve) => { let child: ReturnType; try { child = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"], signal }); } catch { resolve({ stdout: "", status: undefined, broken: true }); return; } const chunks: Buffer[] = []; let received = 0; let budgetHit = false; let settled = false; const timer = setTimeout(() => child.kill("SIGKILL"), EXEC_TIMEOUT_MS); const finish = (status: number | undefined, broken: boolean) => { if (settled) return; settled = true; clearTimeout(timer); let out = Buffer.concat(chunks).toString("utf-8"); if (budgetHit) { // Drop the trailing partial line so every kept row is parseable. const lastNl = out.lastIndexOf("\n"); out = lastNl === -1 ? "" : out.slice(0, lastNl + 1); } resolve({ stdout: out, status, broken, budgetHit }); }; child.stdout?.on("data", (chunk: Buffer) => { if (budgetHit) return; chunks.push(chunk); received += chunk.length; if (received >= budgetBytes) { budgetHit = true; child.kill("SIGKILL"); } }); child.on("error", () => finish(undefined, true)); child.on("close", (code, sig) => { // A kill WE issued for the budget is a successful, complete-enough read. if (budgetHit) finish(0, false); else finish(code ?? undefined, code === null && sig !== null); }); }); } /** * Keyword-overlap score for `text` against query `tokens`. Counts occurrences * (capped per token) so a doc mentioning the query many times ranks higher, * but one spammy token can't dominate. */ function keywordScore(text: string, tokens: string[]): number { if (tokens.length === 0) return 0; const lower = text.toLowerCase(); let score = 0; let matched = 0; for (const t of tokens) { let idx = lower.indexOf(t); if (idx === -1) continue; matched++; let count = 0; while (idx !== -1 && count < 5) { count++; idx = lower.indexOf(t, idx + t.length); } score += count; } if (matched === 0) return 0; // reward covering more distinct query tokens return score * (matched / tokens.length); } /** Recency multiplier: 1.0 now → ~0.5 at 30 days → asymptote 0.2. */ function recencyBoost(timestamp: number, now: number): number { const ageDays = Math.max(0, (now - timestamp) / 86_400_000); return 0.2 + 0.8 / (1 + ageDays / 30); } /** Trim a snippet around the first matched token, then redact secrets. */ function makeSnippet(text: string, tokens: string[], width = 240): string { return redactSecrets(makeSnippetRaw(text, tokens, width)); } function makeSnippetRaw(text: string, tokens: string[], width = 240): string { const lower = text.toLowerCase(); let pos = -1; for (const t of tokens) { const i = lower.indexOf(t); if (i !== -1 && (pos === -1 || i < pos)) pos = i; } const collapsed = text.replace(/\s+/g, " ").trim(); if (pos === -1) return collapsed.slice(0, width); // recompute pos on the collapsed string const cpos = collapsed.toLowerCase().indexOf(tokens.find((t) => lower.includes(t)) ?? ""); const start = Math.max(0, (cpos === -1 ? 0 : cpos) - width / 3); const end = Math.min(collapsed.length, start + width); return (start > 0 ? "…" : "") + collapsed.slice(start, end) + (end < collapsed.length ? "…" : ""); } // ── project resolution ────────────────────────────────────────────────────── /** Slugified session-folder name for a given absolute path (pi's scheme). */ function sessionFolderForCwd(cwd: string): string { // pi slugs the absolute path: leading/every "/" → "-", wrapped in "--…--". const slug = cwd.replace(/\//g, "-"); return `--${slug.replace(/^-+/, "").replace(/-+$/, "")}--`; } function currentProject(cwd: string): string { return basename(cwd) || "unknown"; } /** * Best-effort project label from a session folder slug. The slug replaced every * '/' AND pre-existing '-' with '-', so it's ambiguous; we take everything after * the last '-work-' segment when present (handles hyphenated repo names like * 'pi-web-access'), else the last segment (B5). */ function projectFromFolder(folder: string): string { const core = folder.replace(/^--/, "").replace(/--$/, ""); const m = core.match(/(?:^|-)work-(.+)$/); if (m) return m[1]; return core.split("-").pop() || core; } // ── source: sessions ───────────────────────────────────────────────────────── /** * Byte budget for the discriminator pass. Files are fed newest-first, so when * the budget is hit only OLD sessions are cut. 160 MB ≈ the measured worst case * for a 3 000-file discriminator on a 15 GB corpus with the line cap below. */ const SESSIONS_SCAN_BUDGET_BYTES = 192 * 1024 * 1024; /** * Per-call budget for bringing the digest up to date. After the cold start this * is a ~200 ms stat sweep plus the live session's delta; on a cold cache each * call digests newest-first for this long and searches what exists so far. */ const SESSIONS_DIGEST_BUDGET_MS = 6_000; /** Hard cap on files handed to the second pass (argv length). Work is bounded by bytes, not files. */ const SESSIONS_MAX_FILES = 6_000; interface SessionScanPlan { /** The query token on the fewest lines — the cheapest pass-2 filter. */ discriminator: string; /** Files containing the discriminator, newest first. */ files: string[]; } /** * Pass 1 of the sessions funnel: one `rg --files-with-matches` per token, in * parallel. Output is file names only, so it is cheap regardless of line size. * Returns null when no token matches any file. A token that fails to scan * (rg killed / missing) is skipped; if EVERY token fails the source is marked * failed, so "no matches" is never confused with "no scan". */ async function planSessionScan( tokens: string[], searchFiles: string[], status: Partial>, signal?: AbortSignal, ): Promise { const perToken = await Promise.all( tokens.map(async (t) => { // `-c` prints `file:count` for files with ≥1 match — same cost as // --files-with-matches, but the LINE count is what pass 2 will pay for // (each line is JSON.parsed + scored). A token in few files but many // lines per file ("proxy": 1 439 files / 15 446 lines) is a worse // filter than one in more files but fewer lines ("destination": 2 388 // files / 2 508 lines) — 6x less pass-2 work. const r = await execFileAsync(ripgrep(), ["-i", "-F", "-c", "--with-filename", "-e", t, "--", ...searchFiles], 64 * 1024 * 1024, signal); if (r.status === 1) return { token: t, files: [] as string[], lines: 0, ok: true }; if (r.status !== 0 && !r.stdout) return { token: t, files: [] as string[], lines: 0, ok: false }; const files: string[] = []; let lines = 0; for (const row of r.stdout.split("\n")) { if (!row) continue; const sep = row.lastIndexOf(":"); if (sep === -1) continue; files.push(row.slice(0, sep)); lines += Number(row.slice(sep + 1)) || 0; } return { token: t, files, lines, ok: true }; }), ); if (perToken.every((p) => !p.ok)) { status.sessions = "failed"; return null; } const present = perToken.filter((p) => p.ok && p.files.length > 0); if (present.length === 0) return null; // Fewest matching LINES wins (that is the pass-2 cost), but a bare number // ("27" from "v0.27.0") is never a good line filter even when rare — prefer // any word token over any numeric one. const isNumeric = (t: string) => /^\d+$/.test(t); present.sort((a, b) => Number(isNumeric(a.token)) - Number(isNumeric(b.token)) || a.lines - b.lines); const discriminator = present[0]; // Newest first: `searchFiles` arrives ordered by SOURCE mtime (digest file // mtimes are meaningless — they are all "just written"). Preserve that order // so a bounded second pass drops OLD data, never today's. const matched = new Set(discriminator.files); const files = searchFiles.filter((f) => matched.has(f)); if (files.length > SESSIONS_MAX_FILES) { status.sessions = "partial"; files.length = SESSIONS_MAX_FILES; } return { discriminator: discriminator.token, files }; } /** * Files per rg call. One call over every file would be faster, but rg searches * files in parallel and prints whichever finishes first: the byte budget then * cuts an ARBITRARY set, and on a CI runner the newest session was dropped * while old noise filled the budget. A batch is small enough that its files are * neighbours in recency, so a cut still drops old data and never today's. */ const SESSIONS_SCAN_BATCH = 8; /** * Second pass, newest-first under a shared byte budget. Returns the raw rg * output, or null when the source is broken (tool missing) rather than empty. */ async function scanNewestFirst( discriminator: string, files: string[], status: Partial>, signal?: AbortSignal, ): Promise { let budget = SESSIONS_SCAN_BUDGET_BYTES; const chunks: string[] = []; let scanned = 0; for (let i = 0; i < files.length && budget > 0; i += SESSIONS_SCAN_BATCH) { const batch = files.slice(i, i + SESSIONS_SCAN_BATCH); const res = await execFileStreaming( ripgrep(), ["-i", "-F", "--no-heading", "--no-line-number", "--with-filename", "-e", discriminator, "--", ...batch], budget, signal, ); // rg exits 1 when no matches — that is not an error. if (res.status !== 0 && res.status !== 1) { if (!res.stdout && chunks.length === 0) { // ENOENT (rg missing) / killed with nothing printed — the source is // broken, not empty. This is what tells "no matches" from "no scan". status.sessions = "failed"; return null; } status.sessions = "partial"; } if (res.stdout) chunks.push(res.stdout); budget -= res.stdout.length; scanned = Math.min(i + SESSIONS_SCAN_BATCH, files.length); if (res.budgetHit) { status.sessions = "partial"; break; } if (signal?.aborted) { status.sessions = "partial"; break; } } // Any file left unscanned means the answer is cut, whatever the exit codes said. if (scanned < files.length) status.sessions = "partial"; return chunks.join(""); } async function searchSessions( tokens: string[], scope: MemoryScope, cwd: string, sinceMs: number | undefined, now: number, perSourceCap: number, status: Partial>, signal?: AbortSignal, ): Promise { status.sessions = "ok"; if (!existsSync(SESSIONS_ROOT)) { status.sessions = "skipped"; return []; } // Search the text-only digest (session-digest.ts), not the raw 15 GB tree: // 98.8 % of raw transcript bytes are toolResult dumps this search never // surfaces. Refresh first — a no-op stat sweep when nothing changed; on a // cold cache it digests newest-first within the budget and we search what // exists so far, labelled partial. const refresh = await refreshSessionDigest({ budgetMs: SESSIONS_DIGEST_BUDGET_MS, signal }); if (!refresh.complete) status.sessions = "partial"; const wantFolder = scope === "current" ? sessionFolderForCwd(cwd) : undefined; const files = sessionDigestFiles().filter((f) => { if (!wantFolder) return true; const src = sessionSourceForDigest(f); return src !== undefined && src.startsWith(join(SESSIONS_ROOT, wantFolder) + "/"); }); if (files.length === 0) return []; // Two-pass funnel (see planSessionScan): the rarest content token filters // lines; its files are scanned newest-first under a byte budget so a cut // drops OLD data, never today's. const plan = await planSessionScan(tokens, files, status, signal); if (!plan) return []; const res = await scanNewestFirst(plan.discriminator, plan.files, status, signal); if (res === null) return []; const out = res; const sourceCache = new Map(); const hits: MemoryHit[] = []; for (const row of out.split("\n")) { if (!row) continue; // rg output is `digestpath:linecontent`; the path ends at the first `.jsonl:`. const sep = row.indexOf(".jsonl:"); if (sep === -1) continue; const digestPath = row.slice(0, sep + 6); const line = row.slice(sep + 7); if (!line) continue; let src = sourceCache.get(digestPath); if (!src) { const full = sessionSourceForDigest(digestPath) ?? digestPath; const rel = full.startsWith(SESSIONS_ROOT) ? full.slice(SESSIONS_ROOT.length + 1) : full; let mtime = 0; try { mtime = statSync(full).mtimeMs; } catch { /* source vanished since refresh */ } src = { rel, project: projectFromFolder(rel.split("/")[0] ?? ""), mtime }; sourceCache.set(digestPath, src); } let turn: { ts?: string; role?: string; text?: string }; try { turn = JSON.parse(line); } catch { continue; } if (!turn.text) continue; const s = keywordScore(turn.text, tokens); if (s <= 0) continue; const ts = parseTimestamp(turn.ts, src.mtime); if (sinceMs !== undefined && ts < now - sinceMs) continue; hits.push({ source: "sessions", label: turn.role ?? "?", snippet: makeSnippet(turn.text, tokens), location: src.rel, project: src.project, timestamp: ts, score: s * recencyBoost(ts, now), }); } hits.sort((a, b) => b.score - a.score); return hits.slice(0, perSourceCap); } // ── source: claude-recall memories ─────────────────────────────────────────── /** * Read the recall database without the sqlite3 CLI. * * Shelling out to `sqlite3` makes the memory source depend on a program nobody * installs: on a phone with no sqlite3 the source came back "crashed" and the * tool reported no matches at all. Node ships its own reader, so the CLI is now * only the fallback for runtimes without it. * * @returns rows, or null when this runtime has no built-in SQLite. */ function readRecallRows(dbPath: string, sql: string): Array> | null { let DatabaseSync: (new (path: string, options?: { readOnly?: boolean }) => { prepare: (sql: string) => { all: () => Array> }; close: () => void; }) | undefined; try { ({ DatabaseSync } = createRequire(import.meta.url)("node:sqlite")); } catch { return null; } if (!DatabaseSync) return null; const db = new DatabaseSync(dbPath, { readOnly: true }); try { return db.prepare(sql).all(); } finally { db.close(); } } async function searchRecall( tokens: string[], scope: MemoryScope, cwd: string, sinceMs: number | undefined, now: number, perSourceCap: number, status: Partial>, signal?: AbortSignal, ): Promise { status.memory = "ok"; if (!existsSync(RECALL_DB)) { status.memory = "skipped"; return []; } const proj = currentProject(cwd); // Pull active memories (optionally scoped) as TSV; parse value JSON for text. const where = scope === "current" ? `is_active=1 AND (project_id='${proj.replace(/'/g, "''")}' OR scope='universal')` : `is_active=1`; // Alias COALESCE so the -json column key is a stable 'project_id' (D4). const sql = `SELECT type, COALESCE(project_id,'') AS project_id, scope, timestamp, value FROM memories WHERE ${where};`; let rows: Array>; try { const builtIn = readRecallRows(RECALL_DB, sql); if (builtIn !== null) { rows = builtIn; } else { const res = await execFileAsync("sqlite3", ["-json", RECALL_DB, sql], 64 * 1024 * 1024, signal); if (res.status !== 0) { // sqlite3 missing, DB locked, or timed out — broken, not empty (perf#3). status.memory = "failed"; return []; } rows = JSON.parse(res.stdout || "[]"); } } catch { status.memory = "failed"; return []; } const hits: MemoryHit[] = []; for (const r of rows) { const type = String(r.type ?? "memory"); const project = String(r.project_id ?? "") || "universal"; const scopeVal = String(r.scope ?? ""); const tsRaw = Number(r.timestamp) || 0; // recall timestamps are unix ms already const ts = tsRaw > 1e12 ? tsRaw : tsRaw * 1000; if (sinceMs !== undefined && ts < now - sinceMs) continue; let text = String(r.value ?? ""); try { const v = JSON.parse(text); if (v && typeof v === "object" && typeof v.content === "string") text = v.content; } catch { // value wasn't JSON — use raw } const s = keywordScore(text, tokens); if (s <= 0) continue; hits.push({ source: "memory", label: type, snippet: makeSnippet(text, tokens), location: `recall:${type}`, project: scopeVal === "universal" ? "universal" : project, timestamp: ts, score: s * recencyBoost(ts, now) * 1.15, // slight boost: memories are curated }); } hits.sort((a, b) => b.score - a.score); return hits.slice(0, perSourceCap); } // ── source: markdown docs ───────────────────────────────────────────────────── async function searchDocs( tokens: string[], scope: MemoryScope, cwd: string, sinceMs: number | undefined, now: number, perSourceCap: number, status: Partial>, signal?: AbortSignal, ): Promise { status.docs = "ok"; const roots = docRoots(scope, cwd); if (roots.length === 0) { status.docs = "skipped"; return []; } // ripgrep finds the matching .md FILES fast (respects .gitignore, skips // node_modules/.git by default). We then read+score only those files. const pattern = tokens.map(escapeRe).join("|"); const res = await execFileAsync( ripgrep(), ["-l", "-i", "--glob", "*.md", "-e", pattern, "--", ...roots], 32 * 1024 * 1024, signal, ); if (res.status === 1) return []; const out = res.stdout; if (res.status !== 0) { if (!out) { status.docs = "failed"; return []; } status.docs = "partial"; } const hits: MemoryHit[] = []; for (const full of out.split("\n")) { if (!full) continue; let st: ReturnType; try { st = statSync(full); } catch { continue; } const ts = st.mtimeMs; if (sinceMs !== undefined && ts < now - sinceMs) continue; let raw: string; try { raw = readFileSync(full, "utf-8"); } catch { continue; } const s = keywordScore(raw, tokens); if (s <= 0) continue; hits.push({ source: "docs", label: "md", snippet: makeSnippet(raw, tokens), location: full.replace(homedir(), "~"), project: scope === "current" ? basename(cwd) : (full.split("/work/")[1]?.split("/")[0] ?? "doc"), timestamp: ts, score: s * recencyBoost(ts, now), }); } hits.sort((a, b) => b.score - a.score); return hits.slice(0, perSourceCap); } // ── source: git commit history ──────────────────────────────────────────────── const US = "\x1f"; // unit separator for --format parsing // Filler / recency / meta words that carry no search signal for git — if only // these remain, we switch to time-window mode (list all commits in the window). const GIT_STOPWORDS = new Set([ // ru — search/imperative verbs "поищи", "найди", "поиск", "покажи", "выведи", "дай", "список", "посмотри", "глянь", "погляди", "покажите", "вывести", // ru — git/time nouns "история", "истории", "гит", "гите", "коммит", "коммитов", "коммиты", "дифф", "дифы", "диффы", "за", "последний", "последнюю", "последние", "месяц", "месяца", "неделя", "неделю", "неделе", "день", "дней", "вчера", "сегодня", "все", "всех", "прошлый", "прошлом", "прошлой", "этот", "этой", "этом", "назад", // en "search", "find", "show", "list", "give", "display", "git", "history", "commit", "commits", "diff", "diffs", "log", "last", "past", "this", "month", "week", "day", "yesterday", "today", "all", "the", "in", "for", "me", "of", ]); const GIT_LOG_FMT = `%H${US}%at${US}%an${US}%s${US}%b`; // How many top commit hits get their FULL diff expanded (capped per commit). const GIT_EXPAND_TOP = 3; const GIT_DIFF_MAX_LINES = 200; // Max commits returned by a git time-window sweep ("all commits this month"). const GIT_WINDOW_MAX = 200; // In window mode only the newest N commits get expanded diffs; the rest stay // one-line subjects. Unbounded expansion measured 1.4MB (~358k tokens) — a // context bomb for any model (round-2 perf#2/product#6). const GIT_WINDOW_EXPAND = 10; function gitOut(repo: string, args: string[]): string { try { return execFileSync("git", ["-C", repo, ...args], { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024, timeout: EXEC_TIMEOUT_MS, }); } catch (e) { return String((e as { stdout?: Buffer | string }).stdout ?? ""); } } async function gitOutAsync(repo: string, args: string[], signal?: AbortSignal): Promise { const res = await execFileAsync("git", ["-C", repo, ...args], 64 * 1024 * 1024, signal); return res.stdout; } /** Repos to search: the cwd's repo (current) or every git repo under ~/work (all). */ function gitRepos(scope: MemoryScope, cwd: string): string[] { if (scope === "current") { const top = gitOut(cwd, ["rev-parse", "--show-toplevel"]).trim(); return top ? [top] : []; } if (!existsSync(WORK_ROOT)) return []; const repos: string[] = []; try { for (const name of readdirSync(WORK_ROOT)) { const dir = join(WORK_ROOT, name); if (existsSync(join(dir, ".git"))) repos.push(dir); } } catch { // ignore } return repos; } async function searchGit( query: string, tokens: string[], scope: MemoryScope, cwd: string, sinceMs: number | undefined, now: number, perSourceCap: number, status: Partial>, signal?: AbortSignal, ): Promise { status.git = "ok"; const repos = gitRepos(scope, cwd); if (repos.length === 0) { status.git = "skipped"; return []; } const since = sinceMs !== undefined ? [`--since=${new Date(now - sinceMs).toISOString()}`] : []; // TIME-WINDOW mode: when the query has no real keywords left after stripping // the recency phrase (e.g. "поищи в гит истории за последний месяц"), just list // ALL commits in the window (newest first) and expand their diffs — no // keyword filtering. const contentTokens = tokens.filter((t) => !GIT_STOPWORDS.has(t)); const windowMode = contentTokens.length === 0 && sinceMs !== undefined; // No keywords AND no time window → a git search has no meaningful target, and // running it would do a full `git log --all` + empty pickaxe over every repo // (edge-case review #1.1). Bail instead. if (contentTokens.length === 0 && !windowMode) return []; const hits: MemoryHit[] = []; for (const repo of repos) { const project = basename(repo); const seen = new Set(); if (windowMode) { const raw = await gitOutAsync(repo, ["log", ...since, `--format=${GIT_LOG_FMT}${US}%x00`], signal); for (const rec of raw.split("\x00")) { const r = rec.trim(); if (!r) continue; const [hash, at, , subject] = r.split(US); if (!hash || seen.has(hash)) continue; seen.add(hash); const ts = (Number(at) || 0) * 1000; hits.push({ source: "git", label: "commit", snippet: subject.slice(0, 240), location: `${project}@${hash.slice(0, 9)}`, project, timestamp: ts || now, // rank purely by recency in window mode score: recencyBoost(ts || now, now), ...({ _repo: repo, _hash: hash } as object), }); } continue; } // Two passes: commit MESSAGES (--grep, all tokens OR'd, case-insensitive) // and diff CONTENT (pickaxe -G on the joined phrase). Merge, dedupe. const grepArgs = contentTokens.flatMap((t) => ["--grep", t]); const passes: string[][] = [ ["log", "-i", "--all", "--regexp-ignore-case", ...grepArgs, ...since, `--format=${GIT_LOG_FMT}${US}%x00`], ["log", "-i", "--all", `-G${contentTokens.join("|")}`, ...since, `--format=${GIT_LOG_FMT}${US}%x00`], ]; for (let pass = 0; pass < passes.length; pass++) { const raw = await gitOutAsync(repo, passes[pass], signal); for (const rec of raw.split("\x00")) { const r = rec.trim(); if (!r) continue; const [hash, at, , subject, body = ""] = r.split(US); if (!hash || seen.has(hash)) continue; seen.add(hash); const ts = (Number(at) || 0) * 1000; const msg = `${subject}\n${body}`.trim(); // pass 0 scores on the message; pass 1 (diff match) gets a base score // since the hit is in code, not the message. const msgScore = keywordScore(msg, contentTokens); const s = pass === 0 ? Math.max(msgScore, 1) : Math.max(msgScore, 2); hits.push({ source: "git", label: pass === 0 ? "commit" : "commit·diff", snippet: subject.slice(0, 240), location: `${project}@${hash.slice(0, 9)}`, project, timestamp: ts || now, score: s * recencyBoost(ts || now, now) * 1.05, // stash repo+hash for on-demand diff expansion ...( { _repo: repo, _hash: hash } as object), }); } } } hits.sort((a, b) => b.score - a.score); // Window mode promises "ALL commits in the range", so raise the cap well above // the generic per-source limit (G3). Keyword mode keeps the normal cap. const cap = windowMode ? Math.max(perSourceCap, GIT_WINDOW_MAX) : perSourceCap; const top = hits.slice(0, cap); // Expand full diffs: a few for keyword search, the newest N for a window // sweep (the rest stay one-line subjects — expanding all 200 measured 1.4MB). const expandCount = windowMode ? Math.min(top.length, GIT_WINDOW_EXPAND) : GIT_EXPAND_TOP; for (let i = 0; i < expandCount && i < top.length; i++) { const h = top[i] as MemoryHit & { _repo?: string; _hash?: string }; if (!h._repo || !h._hash) continue; const diff = await gitOutAsync(h._repo, ["show", "--stat", "--patch", "--format=%s%n%b", h._hash], signal); const lines = diff.split("\n"); h.snippet = redactSecrets( lines.slice(0, GIT_DIFF_MAX_LINES).join("\n") + (lines.length > GIT_DIFF_MAX_LINES ? `\n… (+${lines.length - GIT_DIFF_MAX_LINES} more lines)` : ""), ); } return top; } // ── orchestrator ────────────────────────────────────────────────────────────── export async function searchMemory( query: string, opts: MemorySearchOptions = {}, ): Promise { const sourceStatus: Partial> = {}; const tokens = tokenize(query); const scope = opts.scope ?? "current"; // Default = everything a question about past work can legitimately answer // from: chat transcripts, recall memories, and the hand-written markdown // (project docs, CLAUDE.md/AGENTS.md, the claude memory tree). Git stays // opt-in: walking commits and expanding diffs is the expensive one. const sources = opts.sources ?? ["sessions", "memory", "docs"]; const cwd = opts.cwd ?? process.cwd(); const limit = opts.limit ?? 15; const now = Date.now(); const perSourceCap = Math.max(limit, 10); // git in time-window mode works with zero content tokens ("all diffs this // month"); every other source needs at least one keyword. const gitWindowOnly = sources.includes("git") && opts.sinceMs !== undefined && tokens.every((t) => GIT_STOPWORDS.has(t)); if (tokens.length === 0 && !gitWindowOnly) return { hits: [], sourceStatus }; // Run sources in PARALLEL (round-2 arch#1/perf#1): async execFile keeps the // event loop free and roughly halves multi-source latency. const sig = opts.signal; const jobs: Promise[] = []; if (sources.includes("sessions") && tokens.length > 0) jobs.push(searchSessions(tokens, scope, cwd, opts.sinceMs, now, perSourceCap, sourceStatus, sig)); if (sources.includes("memory") && tokens.length > 0) jobs.push(searchRecall(tokens, scope, cwd, opts.sinceMs, now, perSourceCap, sourceStatus, sig)); if (sources.includes("docs") && tokens.length > 0) jobs.push(searchDocs(tokens, scope, cwd, opts.sinceMs, now, perSourceCap, sourceStatus, sig)); // git time-window sweeps should return ALL commits in the range, so don't // clip them to the generic `limit`; other sources stay capped. const gitWindow = sources.includes("git") && opts.sinceMs !== undefined && tokens.filter((t) => !GIT_STOPWORDS.has(t)).length === 0; if (sources.includes("git")) jobs.push(searchGit(query, tokens, scope, cwd, opts.sinceMs, now, perSourceCap, sourceStatus, sig)); let hits: MemoryHit[] = (await Promise.all(jobs)).flat(); hits.sort((a, b) => b.score - a.score); // Dedup: pi copies history into forked/subagent session files, so one message // can appear verbatim in 5+ files and eat the whole result budget (round-2 // product#1: 15 slots = 3 distinct texts). Key on normalized snippet text; // keep the highest-scored (first after sort) copy. // Score floor (round-2 product#3): weak substring coincidences must not // masquerade as confident results. Drop hits below 20% of the top score // (skip in git window mode — there score is pure recency by design). if (!gitWindow && hits.length > 0) { const floor = hits[0].score * 0.2; hits = hits.filter((h) => h.score >= floor); } const seen = new Set(); const deduped: MemoryHit[] = []; for (const h of hits) { const key = h.source + "\x00" + h.snippet.replace(/\s+/g, " ").trim().toLowerCase(); if (seen.has(key)) continue; seen.add(key); deduped.push(h); } return { hits: deduped.slice(0, gitWindow ? Math.max(limit, GIT_WINDOW_MAX) : limit), sourceStatus, }; } /** * Detect whether the query asks to search git commit history / diffs. * Opt-in like docs. Matches 'гит/git', 'коммит(ы)', 'дифф(ы)/diff', 'история * коммитов', 'commit history'. */ export function wantsGit(query: string): boolean { // \b doesn't work around Cyrillic; bound the Latin stems so 'different'/ // 'commitment'/'difficult' don't false-trigger (review T1), and require the // fuller Cyrillic stems ('коммит'/'дифф') so 'digit'/'агитация' don't match. const q = query.toLowerCase(); // Unicode-aware word boundary for the Cyrillic 'гит' (JS \b is ASCII-only, so // \bгит\b never matches). Use lookarounds against any letter/digit. const gitRu = /(?>, ): string { // Honesty note first (perf#3): a broken/truncated source must not // masquerade as 'no matches' or a complete answer. const notes: string[] = []; for (const [src, st] of Object.entries(sourceStatus ?? {})) { if (st === "failed") notes.push(`⚠ source '${src}' FAILED (tool missing/crashed) — its results are absent`); else if (st === "partial") notes.push(`⚠ source '${src}' scan was TRUNCATED — results may be incomplete`); } if (hits.length === 0) { const base = `No matches in your history for "${query}".`; return notes.length > 0 ? `${notes.join("\n")}\n${base}` : base; } const lines: string[] = [`Found ${hits.length} match(es) for "${query}":`, ...notes, ""]; const tagOf = (s: MemorySource): string => s === "sessions" ? "chat" : s === "memory" ? "memory" : s === "docs" ? "doc" : "git"; let used = 0; let omitted = 0; for (const h of hits) { if (used > FORMAT_BYTE_BUDGET) { omitted++; continue; } const d = new Date(h.timestamp); const date = Number.isFinite(h.timestamp) && h.timestamp > 0 ? d.toISOString().slice(0, 10) : "?"; const chunk: string[] = [`[${tagOf(h.source)} ${date}] ${h.label} · ${h.project}`]; if (h.source === "git" && h.snippet.includes("\n")) { // expanded diff — 4-backtick fence so ``` inside the diff can't break it chunk.push(` ↳ ${h.location}`, "````diff", h.snippet, "````"); } else { chunk.push(` ${h.snippet}`, ` ↳ ${h.location}`); } chunk.push(""); for (const l of chunk) used += l.length + 1; lines.push(...chunk); } if (omitted > 0) { lines.push(`… ${omitted} more hit(s) omitted (output budget). Narrow the query or ask for a specific item.`); } return lines.join("\n"); } /** * Adapt history hits to the shape the shared summary pipeline consumes. * * `QueryResultData` is what `web_search` hands to `generateSummaryDraft`, so * reusing it is what keeps the two searches consistent: identical model * selection, deadline, guardrails, deterministic fallback and `summaryInstructions`. * The only history-specific part is the `kind: "history"` prompt wording. * * A hit has no URL, so `url` carries its stable reference — the session file, * memory key or doc/commit path — and `title` carries the human label the * formatter already prints (`source · date · project`). Nothing is invented. */ export function hitsToQueryResults(query: string, hits: MemoryHit[]): QueryResultData[] { return [ { query, answer: "", provider: "memory_search", error: null, results: hits.map((hit) => ({ title: `${hit.source} · ${new Date(hit.timestamp).toISOString().slice(0, 10)} · ${hit.project}${hit.label ? ` · ${hit.label}` : ""}`, url: hit.location, snippet: hit.snippet, })), }, ]; }