UNPKG

open-memex

Version:

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

131 lines (130 loc) • 5.22 kB
/** * D81: user-defined alias vocabulary as query-time expansion. * * An alias memory carries `alias:` (the nickname, e.g. 香蕉计划) and * `target:` (what it refers to, e.g. 支付系统重构项目) frontmatter on any * memory type. Unlike D80's index-time curated map (static, shared by all * users), alias memories are dynamic user vocabulary: * - defining one takes effect on the next query — no reindex; * - aliases are scope-aware (a project codename stays in its project, * a personal nickname stays personal). * * Search flow (search.ts): * 1. Round 1 searches the query as-is. * 2. Query terms are looked up against the alias registry (trigger A: * an exact registry hit, not a count heuristic — the D79 trigger that * fired 0/470 is not repeated). * 3. Each expansion round rewrites the query with the other side of the * relation — bidirectional (alias→target and target→alias), depth < 3, * cycle-safe. * 4. Rounds merge by normalized-max (mergeRounds): each round's scores are * divided by that round's best, so an alias-side champion competes * evenly with a literal-side champion — alias matches are never * down-weighted for coming from a later round. */ import { db } from "../store/db.js"; import { latinTerms } from "./query.js"; /** * All alias definitions visible to these scopes. One cheap query per * search() call — no cache, so a newly saved alias memory applies on the * very next query. */ export function loadAliasEntries(scopeKeys) { const filter = scopeKeys && scopeKeys.length > 0 ? `AND scope_key IN (${scopeKeys.map(() => "?").join(",")})` : ""; return db() .prepare(`SELECT id, scope_key, alias, target FROM memories WHERE alias <> '' AND target <> '' AND status = 'active' ${filter}`) .all(...(scopeKeys ?? [])); } const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/; function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** CJK: substring (no tokenizer). Latin: whole-token match. */ function termInQuery(query, term) { if (CJK_RE.test(term)) return query.includes(term); const tokens = new Set(latinTerms(query)); return tokens.has(term.toLowerCase()); } function replaceTerm(query, term, replacement) { if (CJK_RE.test(term)) return query.replace(term, replacement); return query.replace(new RegExp(`\\b${escapeRegExp(term)}\\b`, "i"), replacement); } /** * Rewrite the query once: every alias entry with a matching side swaps in * the other side. All matching entries apply in one pass (a chain A→B→C * can resolve within a single round). Returns null when nothing matched. */ export function rewriteQueryOnce(query, entries) { let out = query; let changed = false; for (const e of entries) { if (termInQuery(out, e.alias)) { out = replaceTerm(out, e.alias, e.target); changed = true; } else if (termInQuery(out, e.target)) { out = replaceTerm(out, e.target, e.alias); changed = true; } } return changed ? out : null; } /** * Plan the expansion rounds for a query. Pure — takes the registry entries * explicitly so tests don't need a database. Returns the rewritten queries * only (rounds 2+); the caller prepends the original query. Depth < 3: * at most two expansion rounds. Cycle-safe via the seen set. */ export function planAliasExpansions(query, entries) { const out = []; const seen = new Set([query]); let current = query; for (let depth = 0; depth < 2; depth++) { const next = rewriteQueryOnce(current, entries); if (!next || seen.has(next)) break; seen.add(next); out.push(next); current = next; } return out; } /** * D81 merge: per-round score normalization + max. * * BM25 scores are query-dependent (different terms → different IDFs and * query lengths), so raw scores from two rounds are incomparable — like * comparing °C with °F. Each round is first divided by its own best * (n=1.0 means "this round's champion"), then final = max across rounds. * An alias-side champion (n=1.0) outranks a mediocre literal match * (n=0.4): the two sides' champions start even, which is exactly "no * down-weighting for being a synonym/alias". The kept row (and its * snippet) comes from the round where the doc scored its best. */ export function mergeRounds(rounds) { const acc = new Map(); rounds.forEach((docs) => { const mx = docs.reduce((m, d) => Math.max(m, d.score), 0); for (const d of docs) { const n = mx > 0 ? d.score / mx : 0; const cur = acc.get(d.id); if (!cur) acc.set(d.id, { doc: d, best: n, rounds: 1 }); else { cur.rounds += 1; if (n > cur.best) { cur.best = n; cur.doc = d; } } } }); return [...acc.values()] .map((v) => ({ doc: v.doc, score: v.best, rounds: v.rounds })) .sort((a, b) => b.score - a.score || b.rounds - a.rounds); }