/** * 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 (and ~/.pi/agent 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 } from "node:child_process"; 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"); // ── 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; } /** * 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. */ 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 }); }, ); }); } /** * 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 ───────────────────────────────────────────────────────── function extractMessageText(evt: unknown): { role: string; text: string } | null { if (typeof evt !== "object" || evt === null) return null; const e = evt as Record; if (e.type !== "message") return null; const m = e.message as Record | undefined; if (!m) return null; const role = String(m.role ?? "?"); const content = m.content; let text = ""; if (typeof content === "string") { text = content; } else if (Array.isArray(content)) { for (const block of content) { if (block && typeof block === "object" && (block as Record).type === "text") { text += `${String((block as Record).text ?? "")} `; } } } text = text.trim(); // Skip empty / tool-only turns and giant tool dumps we don't want to surface. if (!text || role === "toolResult") return null; return { role, text }; } 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 []; } const searchDirs = scope === "current" ? [join(SESSIONS_ROOT, sessionFolderForCwd(cwd))].filter((d) => existsSync(d)) : [SESSIONS_ROOT]; if (searchDirs.length === 0) return []; // Use ripgrep to find matching LINES fast (scans 8GB in ~1.5s vs ~40s in JS). // We OR the tokens as a fixed-string alternation, case-insensitive, and get // back `file:linetext`. Then JSON.parse only the matched lines. const pattern = tokens.map(escapeRe).join("|"); const res = await execFileAsync( "rg", [ "-i", "--no-heading", "--no-line-number", "--with-filename", "--glob", "*.jsonl", "--max-columns", "1000000", "-e", pattern, "--", ...searchDirs, ], 256 * 1024 * 1024, signal, ); // rg exits 1 when no matches — that's not an error. On any other non-zero // exit (e.g. 2 = permission-denied subdir) rg still PRINTED the matches it // found, so keep partial stdout instead of discarding it (B2 — match docs). if (res.status === 1) return []; const out = res.stdout; if (res.status !== 0) { if (!out) { // ENOENT (rg missing) / timeout with nothing printed — source is broken, // not empty. Surfacing this distinguishes 'no matches' from 'no scan'. status.sessions = "failed"; return []; } // Partial stdout survived (exit 2 / timeout / ENOBUFS kill) — label it. status.sessions = "partial"; } const mtimeCache = new Map(); const hits: MemoryHit[] = []; for (const row of out.split("\n")) { if (!row) continue; // rg output is `path:linecontent`; the path ends at the first `.jsonl:`. const sep = row.indexOf(".jsonl:"); if (sep === -1) continue; const full = row.slice(0, sep + 6); const line = row.slice(sep + 7); if (!line) continue; let mtime = mtimeCache.get(full); if (mtime === undefined) { try { mtime = statSync(full).mtimeMs; } catch { mtime = 0; } mtimeCache.set(full, mtime); } let evt: unknown; try { evt = JSON.parse(line); } catch { continue; } const msg = extractMessageText(evt); if (!msg) continue; const s = keywordScore(msg.text, tokens); if (s <= 0) continue; const ts = parseTimestamp((evt as Record).timestamp, mtime); if (sinceMs !== undefined && ts < now - sinceMs) continue; const rel = full.startsWith(SESSIONS_ROOT) ? full.slice(SESSIONS_ROOT.length + 1) : full; const folder = rel.split("/")[0] ?? ""; const project = projectFromFolder(folder); hits.push({ source: "sessions", label: msg.role, snippet: makeSnippet(msg.text, tokens), location: rel, 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 ─────────────────────────────────────────── 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};`; 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 []; } let rows: Array>; try { 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 = (scope === "current" ? [cwd] : [WORK_ROOT, PI_AGENT_ROOT]).filter((r) => existsSync(r)); 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( "rg", ["-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 = "memory" in the user's sense: chat transcripts + recall memories. // Markdown docs and git history are opt-in (only when the caller asks). const sources = opts.sources ?? ["sessions", "memory"]; 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 explicitly asks to include markdown documentation. * By default memory_search covers only chat + recall memories; docs are opt-in. */ export function wantsDocs(query: string): boolean { const q = query.toLowerCase(); return /(документац|в доках|доках|\bdocs?\b|documentation|\bmarkdown\b|\b\.md\b|md-файл|md файл)/.test(q); } /** * 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"); }