import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadActiveRecords, loadAllRecords } from "./store"; import { loadConfig } from "./config"; import { listCandidates } from "./inbox"; import { ensureMemoryDirs } from "./paths"; import { listScratchpadItems } from "./scratchpad"; import { readDailyLog } from "./daily"; import { runQmd, qmdSearchArgs } from "./qmd"; import { shouldInjectMemoryContext } from "./injection-filter"; import { renderHardRulesBlockWithCount } from "./rules"; import { MemoryFtsIndex } from "./search/fts"; import { mergeHybridResults, parseQmdMemoryIds } from "./search/hybrid"; import { resolveMemoryProfile } from "./profile"; import { runMemoryProcessorPipeline } from "./processors"; import { extractContestedMemory, renderContestedMemoryBlock } from "./contested-memory"; import { appendRuntimeEvent } from "./runtime-events"; import { appendRecallEvent, createRecallEventId, hashRecallQuery } from "./recall-events"; import type { MemoryRecord, ProcessorTrace, SessionContext } from "./types"; export interface RetrievalOptions { prompt: string; today: string; maxDailyChars?: number; maxRecords?: number; maxTotalChars?: number; useQmd?: boolean; qmdCollection?: string; ftsIndex?: MemoryFtsIndex; qmdRunner?: (args: string[], timeoutMs: number) => Promise<{ stdout: string }>; cwd?: string; threadId?: string; } export type InjectionMode = "scoped" | "policy_only" | "wakeup"; export interface InjectionStats { generated_at: string; injectionMode: InjectionMode; charCount: number; selectedMemoryCount: number; hardRuleCount: number; contestedMemoryCount: number; inquiryCount: number; dailyDigestChars: number; timings?: { loadRecordsMs: number; processorPipelineMs: number; ftsMs: number; qmdMs: number; dailyDigestMs: number; assemblyMs: number; runtimeWriteMs: number; totalMs: number; }; } export interface RetrievalContext { markdown: string; selectedMemory: MemoryRecord[]; processorTraces: ProcessorTrace[]; contestedMemory: MemoryRecord[]; } // ─── Staleness helpers ──────────────────────────────────────────────────────── function daysSince(dateStr: string): number { try { const then = new Date(dateStr).getTime(); return Math.max(0, Math.floor((Date.now() - then) / 86_400_000)); } catch { return 0; } } function stalenessTag(record: MemoryRecord): string { const days = daysSince(record.updated_at); if (days >= 90) return ` 🔴 ${days}d`; if (days >= 30) return ` ⚠️ ${days}d`; return ""; } function renderRecordBrief(record: MemoryRecord): string { const stale = stalenessTag(record); const ruleTag = record.ruleType ? ` [${record.ruleType}]` : ""; return `- ${record.id} [${record.layer}, conf ${record.confidence.toFixed(2)}${stale}${ruleTag}] ${record.statement}`; } // ─── Relevance selection ───────────────────────────────────────────────────── function promptTerms(prompt: string): Set { return new Set(prompt.toLowerCase().split(/[^a-z0-9-]+/).filter((t) => t.length > 2)); } function isRelevantByTerms(record: MemoryRecord, terms: Set): boolean { if (record.layer === "L1") return true; const haystack = `${record.tags.join(" ")} ${record.statement} ${record.ruleType ?? ""}`.toLowerCase(); return [...terms].some((t) => haystack.includes(t)); } /** Parse qmd JSON to extract record IDs */ function parseQmdIds(stdout: string): string[] { return parseQmdMemoryIds(stdout); } /** * Select relevant L2 records using hybrid FTS + qmd semantic search. * Falls back through: hybrid → FTS-only → term-matching. */ const INJECTION_QMD_BUDGET_MS = 800; function isSubstantialPrompt(prompt: string): boolean { return prompt.trim().split(/\s+/).filter(Boolean).length > 8; } interface SelectionBudget { maxRecords: number; maxL1: number; maxL2: number } interface SelectionTiming { ftsMs: number; qmdMs: number } function applyLayerBudgets(l1: MemoryRecord[], l2: MemoryRecord[], budget: SelectionBudget): MemoryRecord[] { const selectedL1 = l1.slice(0, budget.maxL1); const selectedL2 = l2.slice(0, budget.maxL2); return [...selectedL1, ...selectedL2].slice(0, budget.maxRecords); } /** * Select relevant L2 records using hybrid FTS + qmd semantic search. * Falls back through: hybrid → FTS-only → term-matching. */ async function selectMemoryHybrid( root: string, records: MemoryRecord[], prompt: string, budget: SelectionBudget, timing: SelectionTiming, ftsIndex?: MemoryFtsIndex, useQmd?: boolean, qmdCollection?: string, qmdRunner: (args: string[], timeoutMs: number) => Promise<{ stdout: string }> = runQmd, ): Promise { const l1 = records.filter((r) => r.layer === "L1"); const l2 = records.filter((r) => r.layer === "L2"); const eligibleIds = new Set(records.map((r) => r.id)); if (ftsIndex?.isAvailable) { const ftsStart = performance.now(); const ftsResults = ftsIndex.search(prompt, budget.maxRecords * 2).filter((result) => eligibleIds.has(result.id)); timing.ftsMs += performance.now() - ftsStart; let semanticIds: string[] = []; if (useQmd && qmdCollection && isSubstantialPrompt(prompt)) { const qmdStart = performance.now(); try { const result = await qmdRunner(qmdSearchArgs(prompt, "semantic", budget.maxRecords, qmdCollection), INJECTION_QMD_BUDGET_MS); semanticIds = parseQmdIds(result.stdout).filter((id) => eligibleIds.has(id)); } catch (err) { appendRuntimeEvent(root, { type: "warn", severity: "low", component: "retriever", message: `qmd unavailable during injection; using FTS fallback: ${err instanceof Error ? err.message : String(err)}` }); } finally { timing.qmdMs += performance.now() - qmdStart; } } const recordMap = new Map( l2.map((r) => [r.id, { statement: r.statement, layer: r.layer as "L1" | "L2", confidence: r.confidence, ruleType: r.ruleType }]), ); const merged = mergeHybridResults(ftsResults, semanticIds, recordMap, budget.maxL2); const selectedL2 = merged.flatMap((h) => { const rec = l2.find((r) => r.id === h.id); return rec ? [rec] : []; }); return applyLayerBudgets(l1, selectedL2, budget); } const terms = promptTerms(prompt); const relevantL2 = l2.filter((r) => isRelevantByTerms(r, terms)); return applyLayerBudgets(l1, relevantL2, budget); } // ─── Daily digest ───────────────────────────────────────────────────────────── export function buildDailyDigest(dailyContent: string, maxChars: number): string { if (!dailyContent.trim()) return ""; const sessionCount = (dailyContent.match(/## Session ended/g) ?? []).length; const notableLines: string[] = []; for (const line of dailyContent.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed) continue; if (trimmed.startsWith("