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
JavaScript
/**
* 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);
}