UNPKG

open-memex

Version:

Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.

287 lines (286 loc) • 12.1 kB
import { db } from "../store/db.js"; import { toFtsQuery } from "./query.js"; import { BM25_WEIGHTS } from "./weights.js"; import { loadAliasEntries, planAliasExpansions, mergeRounds, } from "./alias-memory.js"; function toHit(r) { const aliases = r.aliases ? r.aliases.split(",").map((a) => a.trim()).filter(Boolean) : []; let snippet = r.snippet ?? ""; // D83: attribution is per-column now. The aliases column holds only // user/authored phrasings (D61); the expansions column holds D80 derived // variants. Say which one matched — the hit otherwise looks unrelated to // the query when the body shows no highlight. const hlTerms = (s) => { if (!s) return []; const m = s.match(/\[([^\]]+)\]/g); return m ? [...new Set(m.map((x) => x.slice(1, -1)))] : []; }; const aliasTerms = hlTerms(r.alias_hl); const expTerms = hlTerms(r.exp_hl); // D83: attribute the hit to the column that matched, so `--explain` can // trace an unexpected rank. Order matters: a body highlight is the most // direct evidence, then an authored alias, then a derived expansion. const matchedVia = /\[[^\]]+\]/.test(snippet) ? "body" : aliasTerms.length > 0 ? "alias" : expTerms.length > 0 ? "expansion" : "other"; if (aliasTerms.length > 0 && aliases.length > 0) { snippet += ` (aka: ${aliases.join(", ")})`; } else if (expTerms.length > 0) { snippet += ` (synonym: ${expTerms.join(", ")})`; } else if (aliases.length > 0 && !/\[[^\]]+\]/.test(snippet)) { // Match came through another column (tags/type/cjk) — the authored // aliases are still honest context for the hit. snippet += ` (aka: ${aliases.join(", ")})`; } return { id: r.id, scope_key: r.scope_key, project_name: r.project_name, type: r.type, tags: r.tags ? r.tags.split(",").filter(Boolean) : [], aliases, matchedVia, snippet, score: r.score, updated_at: r.updated_at, status: r.status, review_state: r.review_state ?? "draft", source: r.source ?? "", created_at: r.created_at ?? r.updated_at ?? 0, }; } /** * D30: review-state ranking tiers for shared (project) memories. * Reviewed knowledge (approved/published) outranks unreviewed outbox drafts; * personal memories are always `draft` by design and are NOT demoted. * Stable: order within a tier keeps the incoming score/recency order. */ function reviewTier(r) { if (r.scope_key === "personal") return 1; if (r.review_state === "approved" || r.review_state === "published") return 0; if (r.review_state === "draft" || r.review_state === "rejected") return 2; return 1; // proposed and anything unexpected } /** D30: `[draft]` / `[proposed]` / … marker for project memories. * Personal memories stay unmarked (always draft — the marker would be noise). */ export function hitStateLabel(h) { if (h.scope_key === "personal") return ""; return ` [${h.review_state || "draft"}]`; } /** * Lifecycle-aware post-processing (§3.3): * - retracted / archived are excluded from retrieval (kept for audit); * - a superseded memory resolves to the newest of its chain (cycle-safe); * - D30: approved/published project memories rank first, project * drafts/rejected rank after unreviewed content; * - deprecated stays visible as a warning but ranks after active. */ function resolveVisible(rows, limit, stats) { const byId = new Map(rows.map((r) => [r.id, r])); const fullRow = (id) => { const cached = byId.get(id); if (cached) return cached; const r = db() .prepare(`SELECT id, scope_key, project_name, type, tags, updated_at, status, superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet FROM memories WHERE id = ?`) .get(id); if (!r) return undefined; const full = { ...r, score: 0 }; byId.set(id, full); return full; }; const seen = new Set(); const tiers = [[], [], [], []]; const deprecated = []; for (const r of rows) { if (r.status === "retracted" || r.status === "archived") { if (stats) stats.hiddenExcluded++; continue; } let target = r; if (r.status === "superseded") { if (stats) stats.hiddenSuperseded++; let cur = r; const chain = new Set([r.id]); while (cur.status === "superseded" && cur.superseded_by) { if (chain.has(cur.superseded_by)) break; // cycle guard chain.add(cur.superseded_by); const nxt = fullRow(cur.superseded_by); if (!nxt) break; cur = nxt; } if (cur.status === "retracted" || cur.status === "archived") continue; target = cur; } if (seen.has(target.id)) continue; seen.add(target.id); const hit = toHit(target); if (target.status === "deprecated") deprecated.push(hit); else tiers[reviewTier(target)].push(hit); } return [...tiers[0], ...tiers[1], ...tiers[2], ...deprecated].slice(0, limit); } /** * FTS5 query construction lives in query.ts (pure, unit-tested): free-text * questions are split into content terms (OR-ed; bm25 ranks), with * function/question words filtered so they can't dilute the ranking. */ export function search(query, opts = {}) { const qflags = { stopwords: opts.stopwords, orJoin: opts.orJoin, prefix: opts.prefix }; const q = toFtsQuery(query, qflags); if (opts.stats) { opts.stats.ftsQuery = q; opts.stats.candidates = 0; opts.stats.hiddenSuperseded = 0; opts.stats.hiddenExcluded = 0; opts.stats.aliasExpansions = []; } if (!q) return []; const limit = Math.max(1, Math.min(opts.limit ?? 8, 50)); // Over-fetch: lifecycle filtering (chain resolution, exclusions) happens // after the FTS query, so candidates must survive it. const fetchLimit = Math.min(limit * 3 + 10, 150); const scopeFilter = opts.scopeKeys && opts.scopeKeys.length > 0 ? ` AND m.scope_key IN (${opts.scopeKeys.map(() => "?").join(",")})` : ""; const typeFilter = opts.type ? ` AND m.type = ?` : ""; const sql = ` SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.aliases, m.expansions, m.updated_at, m.status, m.superseded_by, m.review_state, m.source, m.created_at, snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet, highlight(memories_fts, 2, '[', ']') AS alias_hl, highlight(memories_fts, 3, '[', ']') AS exp_hl, bm25(memories_fts, ?, ?, ?, ?, ?, ?) AS score FROM memories_fts JOIN memories m ON m.rowid = memories_fts.rowid WHERE memories_fts MATCH ?${scopeFilter}${typeFilter} ORDER BY score ASC LIMIT ? `; // D83: the weight vector is bound per statement, so it leads the parameter // list (it appears in the SELECT list, before the WHERE's MATCH). const weights = opts.expansionsWeight === undefined ? BM25_WEIGHTS : BM25_WEIGHTS.map((w, i) => (i === 3 ? opts.expansionsWeight : w)); const fetchFts = (matchExpr) => { const params = [...weights]; params.push(matchExpr); if (opts.scopeKeys && opts.scopeKeys.length > 0) params.push(...opts.scopeKeys); if (opts.type) params.push(opts.type); params.push(fetchLimit); const rows = db() .prepare(sql) .all(...params); // FTS5 bm25: lower = better; invert for intuition. return rows.map((r) => ({ ...r, score: -r.score })); }; const raw = fetchFts(q); // D81: user-defined alias vocabulary — query-time expansion rounds. // Round 1 is the query as-is; each further round rewrites alias terms to // the other side of the relation (bidirectional, depth < 3). The trigger // is an exact registry hit (loadAliasEntries), not a count heuristic. const aliasEntries = loadAliasEntries(opts.scopeKeys); const expansions = planAliasExpansions(query, aliasEntries); let merged = raw; if (expansions.length > 0) { const rounds = [raw]; for (const eq of expansions) { const mq = toFtsQuery(eq, qflags); if (mq) rounds.push(fetchFts(mq)); } // Normalized-max merge (alias-memory.ts): each round's scores are // divided by that round's best, so an alias-side champion competes // evenly with a literal-side champion — no down-weighting for being // from a later round. // // The merged pool is deliberately wider than `limit` (each round already // over-fetches by fetchLimit) so round-2 hits are never starved the way // D79's slice-to-K starved them. #53: that headroom is for RANKING only — // the return is still cut to the caller's `limit`, since `limit` is a cap // on what every caller (tools, MCP, cli) receives, not a hint. merged = mergeRounds(rounds).map((m) => ({ ...m.doc, score: m.score })); if (opts.stats) opts.stats.aliasExpansions = expansions; } // D80: the curated static map is solved at index time (synonyms.ts // expands variants into the indexed aliases column at sync time), so // without a user alias the query side stays a single FTS round. if (opts.stats) opts.stats.candidates = merged.length; return resolveVisible(merged, limit, opts.stats); } export function list(scopeKey, opts = {}) { const limit = Math.max(1, Math.min(opts.limit ?? 20, 100)); const fetchLimit = Math.min(limit * 3 + 10, 150); const typeFilter = opts.type ? ` AND type = ?` : ""; const sql = ` SELECT id, scope_key, project_name, type, tags, updated_at, status, superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet FROM memories WHERE scope_key = ?${typeFilter} ORDER BY updated_at DESC LIMIT ? `; const params = [scopeKey]; if (opts.type) params.push(opts.type); params.push(fetchLimit); const rows = db() .prepare(sql) .all(...params); const raw = rows.map((r) => ({ ...r, score: 1 })); if (opts.include === "all") { // D68: the audit view — every row as stored (superseded versions, // retracted, archived), no chain collapsing, capped at limit. return raw.slice(0, limit).map(toHit); } return resolveVisible(raw, limit); } /** * True totals for the inventory's truncation disclosure (D68). Under * `active` the visible set is the newest of each chain, which in a * consistent store is exactly the rows whose status is active/deprecated. * * D70: one deliberate exception — a `superseded` row whose chain target is * missing (a dangling pointer, which the read path self-heals with a * warning) is returned as a visible hit by resolveVisible but is not * counted here, so the total can read one low in that state. Not worth * widening the query for a self-healing case; the disclosure stays honest * in every state a user can reach deliberately. */ export function countList(scopeKey, opts = {}) { const typeFilter = opts.type ? ` AND type = ?` : ""; const statusFilter = opts.include === "all" ? "" : ` AND status IN ('active', 'deprecated')`; const params = [scopeKey]; if (opts.type) params.push(opts.type); const row = db() .prepare(`SELECT COUNT(*) AS n FROM memories WHERE scope_key = ?${statusFilter}${typeFilter}`) .get(...params); return row.n; }