Sign In

jamgate

Package Overview
Dependencies
Maintainers
1
Versions
38
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

jamgate - npm Package Compare versions

Comparing version
0.19.0
to
0.20.0
+196
dist/gate/translit.js
// The cross-script bridge: a name written in Arabic/Persian script reaching a record that
// spells it in Latin, and back (DESIGN-v2 §7.1, the D-075 fix).
//
// WHY THIS EXISTS. D-075 measured the only recall failure in this project that is **zero**
// rather than merely imperfect: `رحمان` cannot reach a record spelling it `Rahman`, in either
// direction. Persian recall works (Persian query, Persian record) and Latin recall works (Latin
// query, Latin record); a name transliterated ACROSS the two has no bridge at all. For a user
// who talks to his agents in Persian about people whose names those agents wrote down in Latin,
// the memory is present, correct, and unreachable in half the languages he actually types.
//
// WHY D-075 REFUSED TO BUILD IT IN PASSING, AND WHAT CHANGED. Its objection was precise:
// Persian romanisation is many-to-many — `ر` is always `r`, but `و` is `v`/`u`/`o`/`w` and short
// vowels are not written at all — so a bridge "generates candidate keys rather than one key, and
// every extra key widens the lexical net" on a store that already ranks imperfectly. Doing it
// badly is worse than the gap.
//
// THAT OBJECTION IS ANSWERED BY INVERTING THE DIRECTION OF THE MAPPING, and it is the one place
// this module departs from the design. DESIGN-v2 §7.1 says to "generate a set of Latin
// candidates" per Persian token. This module generates NO candidates. It folds BOTH scripts into
// one **consonant skeleton** — a single key per token on each side — so the many-to-many problem
// disappears into the fold instead of exploding out of it:
//
// رحمان → r · h · m · (ا dropped) · n → "rhmn"
// Rahman → r · (a) · h · m · (a) · n → "rhmn" ← same key, one lookup
//
// ایرج → (ا) · (ی) · r · j → "rj"
// Iraj → (i) · r · (a) · j → "rj"
//
// رضوان → r · z · (و dropped) · (ا) · n → "rzn"
// Rezvan → r · (e) · z · (v dropped) · (a) · n → "rzn"
//
// The letters that are ambiguous in romanisation are exactly the letters that carry no
// information about a name's identity — the short vowels nobody writes, and `و`/`ی`, which are
// vowels as often as they are consonants. Dropping them on BOTH sides is what makes one key
// enough. One key is a `Map` lookup; a candidate set is a scan, and a scan is what D-075 refused.
//
// FOUR GUARDS, because a lexical net that widens is exactly the failure D-075 warned about:
//
// 1. **The bridge only ever fires BETWEEN SCRIPTS.** Two Latin tokens are never compared by
// skeleton, and neither are two Arabic-script tokens. Latin→Latin and Persian→Persian recall
// are therefore untouched by construction, not by measurement — the two paths that already
// work cannot regress.
// 2. **Reduced weight** (§7.1): a bridged match is worth {@link BRIDGE_WEIGHT} of a direct one,
// so it can pull a record into the candidate set but rarely outranks a direct hit.
// 3. **Length floors**: the source token must be at least {@link MIN_TOKEN} characters and the
// skeleton at least {@link MIN_SKELETON}, so one- and two-letter noise never bridges.
// 4. **Vowel-only skeletons are refused.** A token that folds to nothing (`او`, `اي`) produces
// no key at all rather than an empty one that matches everything.
//
// It is a LEXICAL bridge and nothing more. Cross-script SEMANTIC recall stays out of scope for
// exactly the reason DESIGN-v2 §12.1 gives: the bundled model is English-only and degenerates
// into "same script" on anything else (D-065). This module makes a name findable; it does not
// make the embedder multilingual.
/** A bridged match is worth this fraction of a direct one (§7.1's reduced weight). Chosen so a
* bridged hit clears the relevance floor on its own but loses to any direct hit of the same
* token — the bridge widens what is REACHABLE without reordering what already works. */
export const BRIDGE_WEIGHT = 0.6;
/** Shortest source token that may bridge. Below this a "name" is not a name. */
const MIN_TOKEN = 3;
/** Shortest skeleton that may bridge. Two consonants is enough for `ایرج`/`Iraj` (`rj`), which
* is one of the two names D-075 measured at zero, and short enough to be worth the guard that
* the two tokens must be in different scripts. */
const MIN_SKELETON = 2;
/** Arabic-script letters, in the block Persian actually uses (plus the Arabic-block forms that
* arrive from copy-paste). Deliberately not `\p{Script=Arabic}` alone — that includes digits
* and punctuation we do not want to reason about. */
const ARABIC_LETTER = /[ؠ-يٮ-ۓۺ-ۿ]/u;
/** True when the token is written in Arabic/Persian script. */
export function isArabicScript(token) {
return ARABIC_LETTER.test(token);
}
/** True when the token is written in Latin script. */
export function isLatinScript(token) {
return /[a-z]/i.test(token);
}
/**
* Arabic-script letter → skeleton consonant.
*
* Letters that romanise to a digraph get a single uppercase code (`sh` → `S`, `kh` → `X`) so a
* skeleton is always one character per consonant and the Latin side can produce the same code
* from the digraph. Letters that are vowels, vowel-carriers or ambiguous between vowel and
* consonant map to the empty string and disappear from the key on both sides.
*/
const ARABIC_SKELETON = {
// vowels, vowel carriers, hamza seats, and the two ambiguous semivowels
"ا": "", "آ": "", "أ": "", "إ": "", "ٱ": "", "ء": "", "ؤ": "", "ئ": "", "ى": "", "ة": "",
"و": "", "ۇ": "", "ۆ": "", "ی": "", "ي": "", "ې": "", "ۍ": "", "ع": "",
// consonants
"ب": "b", "پ": "p",
"ت": "t", "ط": "t",
"ث": "s", "س": "s", "ص": "s",
"ج": "j",
"چ": "C",
"ح": "h", "ه": "h", "ھ": "h",
"خ": "X",
"د": "d", "ذ": "z",
"ر": "r",
"ز": "z", "ض": "z", "ظ": "z", "ژ": "Z",
"ش": "S",
"ف": "f",
"ق": "Q", "غ": "Q",
"ک": "k", "ك": "k",
"گ": "g",
"ل": "l",
"م": "m",
"ن": "n",
};
/** Latin digraphs that stand for one Arabic-script letter. Order matters: longest first, and
* `kh`/`gh`/`sh`/`ch`/`zh` before the bare letters they start with. */
const LATIN_DIGRAPHS = [
["kh", "X"],
["gh", "Q"],
["sh", "S"],
["ch", "C"],
["zh", "Z"],
["ph", "f"],
["th", "t"],
["dj", "j"],
];
/**
* Latin letter → skeleton consonant. The vowels go, and so do `w`, `y` and `v`, because those
* are precisely the letters romanisation uses for `و` and `ی` — the two Arabic-script letters
* this module drops. Dropping them on both sides is what makes `Rezvan`/`رضوان` and
* `Davood`/`داوود` fold to the same key.
*/
const LATIN_SKELETON = {
a: "", e: "", i: "", o: "", u: "", w: "", y: "", v: "",
b: "b", c: "k", d: "d", f: "f", g: "g", h: "h", j: "j", k: "k", l: "l", m: "m",
n: "n", p: "p", q: "Q", r: "r", s: "s", t: "t", x: "X", z: "z",
};
/** Collapse a doubled consonant: `Sajjad` writes the gemination Persian leaves implicit. */
function collapseDoubles(skeleton) {
let out = "";
for (const ch of skeleton)
if (ch !== out[out.length - 1])
out += ch;
return out;
}
/**
* The cross-script key for one token, or `undefined` when the token may not bridge.
*
* `undefined` is returned for a token that is too short, folds to fewer than
* {@link MIN_SKELETON} consonants, or is in neither script — every one of which is a case where
* a key would match more than it should.
*/
export function skeletonKey(token) {
if (token.length < MIN_TOKEN)
return undefined;
const arabic = isArabicScript(token);
const latin = isLatinScript(token);
// A token mixing both scripts is not a transliteration of anything; refuse rather than guess.
if (arabic === latin)
return undefined;
const skeleton = collapseDoubles(arabic ? foldArabic(token) : foldLatin(token));
return skeleton.length >= MIN_SKELETON ? skeleton : undefined;
}
function foldArabic(token) {
let out = "";
for (const ch of token)
out += ARABIC_SKELETON[ch] ?? "";
return out;
}
function foldLatin(token) {
// The caller has already folded diacritics and lowercased (`relevance.fold`), but this is
// exported and cheap, so do not depend on it.
let s = token.normalize("NFKD").replace(/\p{M}+/gu, "").toLowerCase();
for (const [digraph, code] of LATIN_DIGRAPHS)
s = s.split(digraph).join(`${code}`);
let out = "";
let literal = false;
for (const ch of s) {
if (ch === "") {
literal = !literal;
continue;
}
out += literal ? ch : (LATIN_SKELETON[ch] ?? "");
}
return out;
}
/**
* Whether two tokens bridge: same skeleton, different scripts.
*
* The different-scripts requirement is guard 1 and it is the whole safety argument. Without it,
* `mother` and `matter` would fold together in ordinary English text and the bridge would start
* rewriting recall for users who have never typed a non-Latin character. With it, the bridge is
* unreachable unless the query and the record are in different scripts — so the two paths that
* already work cannot regress, by construction rather than by measurement.
*/
export function bridges(a, b) {
if (isArabicScript(a) === isArabicScript(b))
return false;
const ka = skeletonKey(a);
if (ka === undefined)
return false;
return ka === skeletonKey(b);
}
// The read path, as one subsystem (DESIGN-v2 §7).
//
// FLAW 4 WAS THAT THIS WAS NEVER DESIGNED: thirteen checks on write, and on read a whole-store
// scan followed by one sort. To the user, retrieval IS the product — a memory that cannot be
// found does not exist — and every failure that cost this project trust was a retrieval failure,
// not a storage failure. The memories were there the whole time.
//
// So the read path is five named stages with a named failure mode each, in one module, rather
// than four patches sitting next to each other in `fileStore.recall`:
//
// 1. QUERY KEY EXPANSION (§7.1) folded tokens, stems, and the cross-script skeleton that
// lets `رحمان` reach a record spelling it `Rahman`.
// Fails by: widening the net and pulling in noise.
// 2. CANDIDATE RETRIEVAL (§7.2) postings ∪ semantic top-K ∪ the query's own topic, with a
// completeness scan while the store is small enough that the
// scan is free. Fails by: missing a record entirely.
// 3. SCORING (§7.3) the measured lexical/semantic blend, unchanged.
// Fails by: dilution — a long record beating a precise one.
// 4. FRESHNESS ANNOTATION (§7.4) age, current-ness, past-window, contradiction, all from
// data already in hand. Fails by: annotating the wrong thing.
// 5. TIERING + COLLAPSE (§7.5, §7.6) freshness is a tier, never a filter; one assertion
// per topic unless the topic holds a real contradiction.
// Fails by: hiding — which RULES §10 now forbids outright.
//
// THE ONE CONSTRAINT THE FOUNDATION PLACED ON THIS PHASE (DESIGN-v2 §15.8): the read path may not
// reintroduce hiding. Every stage below either RANKS or ANNOTATES. `collapse` is the single stage
// that removes a line from the default reply, and it is required to say what it removed and how
// to get it back — which is why {@link RecallReport.collapsed} exists and why the reply prints it.
import { isPastWindow, resolveWindowPolicy, windowEndsAt } from "./volatility.js";
import { contentStems, memoryRelevance, MIN_RELEVANCE } from "../gate/relevance.js";
import { isArabicScript, skeletonKey } from "../gate/translit.js";
import { blendRelevance, cosineSimilarity, DEFAULT_SEMANTIC_MIN } from "../embeddings/vector.js";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Below this many active records a recall ALSO scans everything, on top of what the index found.
*
* Stated plainly because the alternative is a claim this code cannot keep: an inverted index
* bounds candidate generation to records that share a key, and that is strictly NARROWER than
* what the scorer admits — the trigram layer matches `berln` to `berlin`, which shares no key
* with anything. Bounding the candidate set therefore trades recall for latency, and at the
* measured size of a real store there is nothing to buy: 58 active records score in under a
* millisecond. So while the store is small the scan runs and completeness is exact; past this
* point the index bounds the work and the trigram tail is the price.
*
* The threshold is set from the audit's own measurement — recall degrades to unpleasant around
* 6,000 records — with an order of magnitude of headroom.
*/
export const SCAN_COMPLETE_BELOW = 2_000;
// ---------------------------------------------------------------------------------------------
// Stage 1 — query key expansion (§7.1)
// ---------------------------------------------------------------------------------------------
/**
* The keys a stored record CONTRIBUTES to the postings index. Stems carry the ordinary lexical
* signal; skeletons carry the cross-script bridge (see `translit.ts`).
*
* A skeleton key is namespaced twice over, and both halves are load-bearing:
*
* - by `~`, so it can never collide with a real stem — `rhmn` as a fold and `rhmn` as
* somebody's actual token are different claims about a record;
* - **by the SCRIPT the token was written in** (`~L:` / `~A:`), so that {@link queryKeys} can
* look up only the OPPOSITE script. That mirrors `bridges()`' first guard exactly, and
* without it the index quietly loses its bound: measured on a 20,000-record synthetic store,
* same-script skeleton collisions made the postings return the ENTIRE store for an ordinary
* query, because English words collapse onto shared consonant skeletons constantly. The
* results were still correct — the scorer applies its own guard — but the index had stopped
* bounding anything, which is the one thing it exists to do.
*/
export function indexKeys(text, topic) {
const keys = new Set();
const surface = topic ? `${text} ${topic.replace(/[-_]+/g, " ")}` : text;
for (const s of contentStems(surface)) {
keys.add(s);
const skeleton = skeletonKey(s);
if (skeleton)
keys.add(`${isArabicScript(s) ? "~A:" : "~L:"}${skeleton}`);
}
return keys;
}
/** The keys a QUERY looks up. Same stems, and the skeleton of each token pointed at the other
* script's namespace — which is what makes a bridged lookup a `Map` hit and a same-script
* collision a miss. */
export function queryKeys(query) {
const keys = new Set();
for (const s of contentStems(query)) {
keys.add(s);
const skeleton = skeletonKey(s);
if (skeleton)
keys.add(`${isArabicScript(s) ? "~L:" : "~A:"}${skeleton}`);
}
return keys;
}
export function buildPostings(active) {
const ids = active.map((m) => m.id);
const map = new Map();
active.forEach((m, i) => {
for (const k of indexKeys(m.text, m.topic)) {
const list = map.get(k);
if (list)
list.push(i);
else
map.set(k, [i]);
}
});
// Sorted so the file is stable byte-for-byte between rebuilds of the same store, which is what
// lets `caches.test.ts` assert a rebuild is identical rather than merely equivalent.
return { ids, keys: Object.fromEntries([...map].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) };
}
/** Ids the index proposes for a query. Empty when there are no postings (a store that has never
* been indexed), which the caller distinguishes from "the index found nothing". */
export function lookupPostings(query, postings) {
const out = new Set();
if (!postings)
return out;
for (const k of queryKeys(query)) {
for (const i of postings.keys[k] ?? []) {
const id = postings.ids[i];
if (id !== undefined)
out.add(id);
}
}
return out;
}
export function rankRecall(input) {
const { query, pool, active, topics, limit, policy, now } = input;
const namespace = topics.filter((t) => t.live.length > 0).map((t) => t.id);
// An empty query is "show me the most recent", not a search. It skips every stage below
// because there is nothing to match — and it must still be dated and annotated, so it goes
// through the same annotation and collapse.
const trimmed = query.trim();
if (!trimmed) {
const recent = [...pool]
.sort((a, b) => b.assertedAt.localeCompare(a.assertedAt))
.slice(0, limit)
.map((m) => ({
memory: m,
score: 0,
lexical: 0,
semantic: 0,
admittedBy: ["scan"],
freshness: freshnessOf(m, active, topics, policy, now, []),
collapsed: [],
}));
return report(query, recent, topics, namespace, 0, pool.length, 0, input.includeArchived);
}
// ---- Stage 2: candidate retrieval ----
const byId = new Map(pool.map((m) => [m.id, m]));
const fromIndex = lookupPostings(trimmed, input.postings);
const admission = new Map();
const admit = (id, how) => {
const s = admission.get(id);
if (s)
s.add(how);
else
admission.set(id, new Set([how]));
};
for (const id of fromIndex)
if (byId.has(id))
admit(id, "lexical");
// Semantic candidates: every record with a vector is a candidate for the cosine to judge. The
// guard that matters is upstream — `isModelLanguage` refuses to embed a query the bundled
// English-only model would answer with "same script" (D-065) — so if there is a query vector
// at all, the comparison is meaningful.
if (input.queryVector) {
for (const m of pool)
if (Array.isArray(m.embedding))
admit(m.id, "semantic");
}
// The query's own topic: a record whose text never says the word "project" but whose TOPIC is
// `jamgate-project` is exactly the miss the old `TYPE_BOOST` was bolted on to patch (§7.2).
const queryStems = new Set(contentStems(trimmed));
for (const t of topics) {
const words = t.id.split(/[-_]+/).filter((w) => w.length >= 3);
if (words.length > 0 && words.every((w) => queryStems.has(w))) {
for (const id of t.live)
if (byId.has(id))
admit(id, "topic");
}
}
// The completeness scan (see SCAN_COMPLETE_BELOW). Deliberately last, so `admittedBy` still
// records that the index would have found the record on its own.
const scanning = (input.completenessScan ?? true) && pool.length <= SCAN_COMPLETE_BELOW;
if (scanning)
for (const m of pool)
admit(m.id, "scan");
const candidates = [...admission.keys()].map((id) => byId.get(id)).filter(Boolean);
// ---- Stage 3: scoring. The measured blend, unchanged (§7.3). ----
const scored = candidates
.map((m) => {
const lexical = memoryRelevance(trimmed, m);
const semantic = input.queryVector && Array.isArray(m.embedding)
? cosineSimilarity(input.queryVector, m.embedding)
: 0;
return {
memory: m,
lexical,
semantic,
score: input.queryVector ? blendRelevance(lexical, semantic) : lexical,
qualifies: lexical >= MIN_RELEVANCE || semantic >= DEFAULT_SEMANTIC_MIN,
};
})
.filter((x) => x.qualifies);
// ---- Stage 4: freshness annotation (§7.4) ----
const annotated = scored.map((x) => ({
...x,
admittedBy: [...(admission.get(x.memory.id) ?? [])],
freshness: freshnessOf(x.memory, active, topics, policy, now, scored.map((s) => s.memory)),
}));
// ---- Stage 5: tiering, then collapse (§7.5, §7.6) ----
const byTier = (a, b) => {
const ta = tierOf(a.freshness);
const tb = tierOf(b.freshness);
if (ta !== tb)
return ta - tb;
return b.score - a.score;
};
annotated.sort(byTier);
const { kept, collapsedCount } = collapseByTopic(annotated, topics);
/**
* SELECTION IS BY RELEVANCE; ORDER IS BY FRESHNESS. The two are separated here, and doing
* them with one sort was a real defect measured on the real store rather than a tidiness
* point.
*
* D-078 says freshness is "a demotion, never an exclusion". That is true of a two-record
* fixture and false of a store: when twenty records qualify and the reply shows five, a
* demotion to the bottom of twenty IS an exclusion, and the record vanishes from the answer
* exactly as if it had been hidden. Measured on the maintainer's own store on 2026-08-11, the
* record carrying his CURRENT motorbike balance came back at rank 20 of 20, 28 of 28 and
* 27 of 27 across fourteen phrasings — never once inside a five-result reply — while the
* superseded figure led every one of them. The freshness fix had become the freshness bug.
*
* So the tier decides WHERE a record sits among the results, and relevance decides WHICH
* records there are. A stale record that relevance would have shown is still shown, still
* marked, still ranked below every fresh match — which is what the sentence in D-078 always
* meant and what DESIGN-v2 §15.8 requires of this phase: the read path may not reintroduce
* hiding.
*/
const hits = [...kept]
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.sort(byTier);
return report(query, hits, topics, namespace, collapsedCount, candidates.length, fromIndex.size, input.includeArchived);
}
/**
* §7.5's tier. Relevance decides WITHIN a tier; the tier decides between them.
*
* It is not "newest wins" and never was: a `stable` fact from a year ago has no window, is
* therefore not stale, and competes on relevance exactly as it always did (D-078 asserts this
* directly, and it stays asserted).
*/
function tierOf(f) {
if (!f.current && f.topicLive > 0)
return 2; // its topic has a newer live assertion
return f.demoted ? 1 : 0;
}
/**
* WHETHER A RECORD PAST ITS WINDOW IS ACTUALLY DEMOTED — the correction this phase made to
* D-078, and it was found by measurement rather than by reading the code.
*
* D-078 demoted every record past its freshness window, absolutely. Measured against the real
* store three days later, that rule produced the D-077 failure with the roles reversed. The
* current motorbike figure lives in a record asserted 8 August with `volatility: "fast"`, so it
* is past its two-day window; the superseded figure lives in a record asserted 6 August with
* `volatility: "stable"`, so it never goes stale. The absolute tier ranked the OLDER, WRONG
* figure above the NEWER, RIGHT one, in every phrasing — and buried the right one at the bottom
* of the result set, which is how a record that is safe on disk becomes unreachable in practice.
*
* The window is a guess about how fast a KIND of fact changes. A newer assertion is EVIDENCE.
* Evidence outranks a guess, so a record is demoted only when the result set actually contains
* something both fresher AND newer than it — which is exactly the population D-078 measured and
* exactly the case its tests assert. When nothing newer is fresh, the record past its window is
* the best thing anybody has, and burying it serves nobody.
*
* Computed once per record against the whole candidate set, so it stays a scalar key and the
* sort stays a total order. A pairwise version of the same rule is not transitive and would sort
* differently depending on input order.
*/
function demotes(m, pool, policy, now) {
if (!isPastWindow(m.volatility, m.assertedAt, policy, now))
return false;
return pool.some((o) => o.id !== m.id &&
o.assertedAt > m.assertedAt &&
!isPastWindow(o.volatility, o.assertedAt, policy, now));
}
/** Everything §7.4 asks for, all of it from the active set and the topic registry. */
export function freshnessOf(m, active, topics, policy, now, rivals) {
const topic = m.topic ? topics.find((t) => t.id === m.topic) : undefined;
const live = topic?.live ?? [];
return {
ageDays: Math.max(0, Math.floor((now - new Date(m.assertedAt).getTime()) / DAY_MS)),
pastWindow: isPastWindow(m.volatility, m.assertedAt, policy, now),
// An archive entry the caller asked for is never "current" — it is not live at all.
current: live.length > 0 ? topic.current === m.id : !("archiveReason" in m),
topicLive: live.length,
unresolved: topic?.unresolved ?? false,
demoted: demotes(m, rivals, policy, now),
};
}
/**
* §7.6 — at most one assertion per topic, EXCEPT a topic that holds a real contradiction.
*
* The exception is the important half. When the trust ladder blocked a supersession, a topic has
* two live assertions that disagree, and collapsing there would recreate the D-077 failure with
* better technology: the user would be handed one of two contradictory facts with no sign the
* other existed. Those come back together, adjacent, and the reply says why.
*
* A record with no topic is never collapsed with anything — an absent topic is not a shared one.
*/
function collapseByTopic(ranked, topics) {
const leaders = new Map();
const kept = [];
let collapsedCount = 0;
for (const r of ranked) {
const topic = r.memory.topic;
const registry = topic ? topics.find((t) => t.id === topic) : undefined;
const contested = registry?.unresolved === true;
if (!topic || contested) {
kept.push({ ...r, collapsed: [] });
continue;
}
const leader = leaders.get(topic);
if (leader) {
leader.collapsed.push(r.memory);
collapsedCount++;
continue;
}
const entry = { ...r, collapsed: [] };
leaders.set(topic, entry);
kept.push(entry);
}
return { kept, collapsedCount };
}
function report(query, hits, topics, namespace, collapsedCount, considered, fromIndex, includedArchived) {
const present = [...new Set(hits.map((h) => h.memory.topic).filter((t) => !!t))];
const related = new Set();
for (const id of present) {
const t = topics.find((x) => x.id === id);
for (const r of t?.relatedTopics ?? [])
if (!present.includes(r))
related.add(r);
}
return {
query,
hits,
topics: present,
relatedTopics: [...related].sort(),
collapsedCount,
considered,
fromIndex,
namespace,
includedArchived,
};
}
/**
* A report built from nothing but a ranked list of memories, for a `MemoryStore` adapter that
* implements the required core and not the optional `recallReport` (§10.5).
*
* The date, the age, the topic and the freshness window are all properties of the RECORD, so
* they survive intact. What does not is everything that is a property of the SET: `current` is
* asserted only when the adapter's own ordering implies nothing to the contrary, contradiction
* detection is off because `topicLive` is unknowable from a slice, and `collapsedCount` is zero
* because this path collapses nothing. That is the honest degrade the project already uses for
* embeddings, sampling and elicitation: the capability is absent, the reply says less, and
* nothing is invented to fill the gap.
*/
export function buildReportFromHits(query, hits, policy, now, includeArchived) {
return {
query,
hits: hits.map((m) => ({
memory: m,
score: 0,
lexical: 0,
semantic: 0,
admittedBy: ["scan"],
freshness: {
ageDays: Math.max(0, Math.floor((now - new Date(m.assertedAt).getTime()) / DAY_MS)),
pastWindow: isPastWindow(m.volatility, m.assertedAt, policy, now),
current: false,
topicLive: 0,
unresolved: false,
demoted: false,
},
collapsed: [],
})),
topics: [...new Set(hits.map((m) => m.topic).filter((t) => !!t))],
relatedTopics: [],
collapsedCount: 0,
considered: hits.length,
fromIndex: 0,
namespace: [],
includedArchived: includeArchived,
};
}
// ---------------------------------------------------------------------------------------------
// The reply (§7.7) — an artifact of the design, not a detail of a transport
// ---------------------------------------------------------------------------------------------
/**
* The recall reply, rendered once and shared by both transports.
*
* D-078's lesson, stated once and then obeyed: *test the message, not the exception; the rank,
* not the marker.* Both the MCP tool and the REST endpoint render THIS function, so the format
* is asserted in one place and cannot drift between the surface the agent reads and the surface
* the browser reads.
*
* WHERE THIS DEPARTS FROM §7.7, AND WHY. The design's sample line ends `· current · id cd0dd487`
* — the id inline, at the end of a metadata line. D-041 is this project's entry on exactly that:
* an id with punctuation next to it comes back from an LLM trimmed, quoted or comma-suffixed,
* and `forget_memory` answers "no memory with that id". The id therefore keeps its own line,
* last, unpunctuated, and in full. Everything else in §7.7's format is reproduced as written.
*/
export function formatRecallReply(report, opts = {}) {
if (report.hits.length === 0) {
const lines = [
`No memories match "${report.query}".`,
report.includedArchived
? "The archive was searched too."
: "Nothing is ever deleted, so try again with `includeArchived: true` if you expected something.",
];
// The moment an agent is most likely to INVENT a topic name is the moment it found nothing
// and is about to save instead. That is the contradiction class no local rule can detect
// (§4.3), so this is where the whole namespace is worth its ~700 bytes (§4.4).
if (report.namespace.length > 0) {
lines.push("", `Topics already in this memory (${report.namespace.length}): ${report.namespace.join(", ")}.`, "If you are about to save something that belongs to one of these, pass it as `subject` " +
"rather than inventing a new name — that is how the gate knows it is an update.");
}
return lines.join("\n");
}
const n = report.hits.length;
const out = [`${n} memor${n === 1 ? "y" : "ies"} about "${report.query}"`, ""];
report.hits.forEach((hit, i) => {
const m = hit.memory;
out.push(`${i + 1}. ${m.text}`);
out.push(` ${metaLine(hit)}`);
out.push(` id: ${m.id}`);
for (const note of annotations(hit, opts.windowEnd))
out.push(` ${note}`);
if (i < n - 1)
out.push("");
});
const footer = footerLines(report);
if (footer.length > 0)
out.push("", ...footer);
return out.join("\n");
}
/** `asserted 8 Aug 2026 (3 days ago) · topic debts · current · user-explicit` */
function metaLine(hit) {
const m = hit.memory;
const f = hit.freshness;
const bits = [
`asserted ${formatDate(m.assertedAt)} (${f.ageDays === 0 ? "today" : `${f.ageDays} day${f.ageDays === 1 ? "" : "s"} ago`})`,
`topic ${m.topic ?? "(none)"}`,
];
const archived = m.archiveReason;
if (archived)
bits.push(`ARCHIVED — ${archived}, not a current fact`);
else if (f.current && f.topicLive === 1)
bits.push("current — no newer record on this topic");
else if (f.current)
bits.push("current");
else if (f.topicLive > 0)
bits.push("SUPERSEDED — a newer record exists on this topic");
bits.push(m.source);
return bits.join(" · ");
}
/** The sentences §5.2 requires: staleness in D-077's own wording, and the two-live-assertions
* block that a collapse must never swallow. */
function annotations(hit, windowEnd) {
const notes = [];
const f = hit.freshness;
const m = hit.memory;
if (f.pastWindow) {
const ends = windowEnd?.(m);
notes.push(`⚠ Past its freshness window${ends ? ` (since ${formatDate(ends)})` : ""} — this is ` +
`${describeVolatility(m.volatility)}. It is still returned and nothing is hidden; check ` +
`for a newer record on this topic before relying on it.` +
(f.demoted ? "" : " Nothing newer and fresher matched, so it is still the best answer here."));
}
if (f.unresolved && f.topicLive > 1) {
// Deliberately states the FACT and offers the usual cause rather than asserting it. The
// trust ladder blocking a supersession is how this normally happens, but a topic can also
// reach two live members through an import or a hand-edited store, and a reply that
// confidently names the wrong cause is the D-076 failure mode: a message that misleads.
notes.push(`⚠ This topic has ${f.topicLive} live records that were never reconciled — usually ` +
`because a newer assertion came from a less-trusted source and the gate refused to let ` +
`it overwrite. All ${f.topicLive} are returned together, none is hidden, and the gate ` +
`does not know which is right. Ask the user.`);
}
if (hit.collapsed.length > 0) {
notes.push(`+${hit.collapsed.length} more live record${hit.collapsed.length === 1 ? "" : "s"} on this ` +
`topic: ${hit.collapsed.map((c) => c.id.slice(0, 8)).join(", ")}`);
}
return notes;
}
function footerLines(report) {
const lines = [];
if (report.topics.length > 0) {
lines.push(`Topics in these results: ${report.topics.join(", ")}` +
(report.relatedTopics.length > 0
? ` — related to ${report.relatedTopics.join(", ")}.`
: "."));
}
if (report.collapsedCount > 0) {
lines.push(`${report.collapsedCount} earlier record${report.collapsedCount === 1 ? "" : "s"} on these ` +
`topics ${report.collapsedCount === 1 ? "was" : "were"} folded under the current one — ` +
`their ids are listed above; recall one by its topic to see them all.`);
}
if (!report.includedArchived) {
lines.push("Nothing is deleted in Jamgate. Recall with `includeArchived: true` to see superseded and " +
"forgotten records too.");
}
return lines;
}
/** `8 Aug 2026` — short, unambiguous, and not an ISO string the model has to parse. */
export function formatDate(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime()))
return iso;
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return `${d.getUTCDate()} ${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
}
/** How recall describes a volatility, in the words the reply actually uses. */
export function describeVolatility(v) {
return v === "stable"
? "the kind of fact that does not go stale"
: v === "slow"
? "the kind of fact that changes over months"
: v === "fast"
? "the kind of fact that changes within days"
: "a fact with no declared freshness window";
}
export { windowEndsAt };
/** Run the read path through whichever capability the store has, so both transports call one
* function and an adapter without `recallReport` still renders §7.7's format (§10.5). */
export async function recallWithReport(store, query, limit, includeArchived, scope, policy = resolveWindowPolicy()) {
if (typeof store.recallReport === "function") {
return store.recallReport(query, limit, includeArchived, scope);
}
const hits = await store.recall(query, limit, includeArchived, scope);
return buildReportFromHits(query, hits, policy, Date.now(), includeArchived);
}
+48
-7

@@ -21,2 +21,3 @@ // Fuzzy, dependency-free relevance scoring for recall (Phase 3, item 2).

// tie-breaker that mainly helps on typos and word-boundary noise.
import { BRIDGE_WEIGHT, bridges } from "./translit.js";
/** Blend weight: token overlap is the primary signal, trigram a secondary tie-breaker. */

@@ -147,7 +148,17 @@ const TOKEN_WEIGHT = 0.75;

}
/** How well a single query stem matches a single text stem: exact = 1, else trigram
* partial credit (so "berln" still scores against "berlin"). */
/**
* How well a single query stem matches a single text stem: exact = 1, a CROSS-SCRIPT match
* {@link BRIDGE_WEIGHT}, else trigram partial credit (so "berln" still scores against "berlin").
*
* The bridge sits between the two because it is stronger evidence than a trigram near-miss and
* weaker than a direct hit — `رحمان` and `Rahman` are the same name, but the reader typed one of
* them and the record says the other, and §7.1 requires that a bridged match rarely outrank a
* direct one. Trigram similarity across scripts is always 0 (no shared characters), so without
* this line the two never compare at all, which is D-075's measured zero.
*/
function tokenSimilarity(q, t) {
if (q === t)
return 1;
if (bridges(q, t))
return BRIDGE_WEIGHT;
const sim = trigramSimilarity(q, t);

@@ -211,8 +222,38 @@ return sim >= FUZZY_TOKEN_FLOOR ? sim : 0;

const field = topicWords ? `${memory.text} ${topicWords}` : memory.text;
const score = relevanceScore(query, field);
if (memory.category && queryMentions(query, memory.category)) {
return Math.min(1, score + CATEGORY_BOOST);
}
return score;
let score = relevanceScore(query, field);
if (namesTopic(query, memory.topic))
score += TOPIC_BOOST;
if (memory.category && queryMentions(query, memory.category))
score += CATEGORY_BOOST;
return Math.min(1, score);
}
/**
* How much it is worth that the query NAMES a memory's whole topic.
*
* This is the measured half of §7.3's "raise the top-1 floor", and it is not a new signal — it
* is DESIGN-v2 §7.2's third candidate source, applied consistently. Retrieval already admits a
* record whose topic the query resolves to "regardless of score"; admitting a record and then
* scoring it as though the topic evidence did not exist is the inconsistency, not the boost.
*
* Measured on `ranking.test.ts`'s 17 labelled queries: **14/17 → 15/17**, with `equipment budget
* consumables` moving from the long billing record (which mentions equipment, budget AND
* consumables in one clause among many) to the short record whose entire subject is the
* equipment budget. On the maintainer's real store, across 43 non-money labelled queries, it
* changed no result at all — which is what a conservative condition is supposed to look like.
*
* The condition is deliberately all-or-nothing: EVERY word of the topic must be named by the
* query. A partial match is what `field` above already credits, at ordinary token weight. This
* only fires when the caller has effectively typed the topic's name, which is not a guess.
*/
export const TOPIC_BOOST = 0.25;
/** True when every word of `topic` (of 3+ characters) appears among the query's content stems. */
function namesTopic(query, topic) {
if (!topic)
return false;
const words = topic.split(/[-_]+/).filter((w) => w.length >= 3).map(stem);
if (words.length === 0)
return false;
const asked = new Set(contentStems(query));
return words.every((w) => asked.has(w));
}
/** True when the query names `word` (stem-equal, so "projects" matches "project"). */

@@ -219,0 +260,0 @@ export function queryMentions(query, word) {

@@ -11,2 +11,4 @@ import { createServer as createHttpServer, } from "node:http";

import { normalizeScope } from "./store/scope.js";
import { formatRecallReply, recallWithReport } from "./store/readPath.js";
import { resolveWindowPolicy, windowEndsAt } from "./store/volatility.js";
import { handleOAuth, resourceMetadataUrl } from "./oauth/handlers.js";

@@ -589,3 +591,3 @@ /**

const includeArchived = isTruthyParam(url.searchParams.get("includeArchived"));
const memories = await ctx.store.recall(query, limit, includeArchived, scope);
const report = await recallWithReport(ctx.store, query, limit, includeArchived, scope);
// Report how many records are past their window, so a REST client reading a stale figure

@@ -597,3 +599,16 @@ // has the same warning the MCP footer carries (D-055, D-077).

sendJson(res, 200, {
memories: memories.map(publicMemory),
// The array keeps its name and its shape, so every existing REST client is unaffected.
memories: report.hits.map((h) => publicMemory(h.memory)),
// …and the read path's annotations ride alongside it, keyed by id. A REST caller now has
// exactly what the MCP caller has — the date, the topic, whether this is the current
// assertion, whether it is past its window, and what a topic collapse folded away — and
// `reply` is §7.7's rendered text, so a script that just wants to print something prints
// the SAME thing an agent reads. One format, asserted once (D-078).
freshness: Object.fromEntries(report.hits.map((h) => [h.memory.id, { ...h.freshness, collapsed: h.collapsed.map((c) => c.id) }])),
topics: report.topics,
...(report.relatedTopics.length > 0 ? { relatedTopics: report.relatedTopics } : {}),
...(report.collapsedCount > 0 ? { collapsedCount: report.collapsedCount } : {}),
reply: formatRecallReply(report, {
windowEnd: (m) => windowEndsAt(m.volatility, m.assertedAt, resolveWindowPolicy()),
}),
...(stale > 0 ? { staleCount: stale } : {}),

@@ -600,0 +615,0 @@ });

+23
-67

@@ -16,5 +16,6 @@ #!/usr/bin/env node

import { expiredCommand } from "./store/expiredCli.js";
import { archiveCommand, eventsCommand, purgeCommand, restoreCommand, topicsCommand, } from "./store/archiveCli.js";
import { archiveCommand, eventsCommand, indexCommand, purgeCommand, restoreCommand, topicsCommand, } from "./store/archiveCli.js";
import { migrateCommand } from "./store/migrateCli.js";
import { retireCommand } from "./store/retireCli.js";
import { describeVolatility, formatRecallReply, recallWithReport } from "./store/readPath.js";
import { resolveWindowPolicy, windowEndsAt } from "./store/volatility.js";

@@ -375,29 +376,12 @@ import { resolveGateLogConfig } from "./gate/log.js";

const includeArchived = args.includeArchived === true;
const hits = await store.recall(String(args.query ?? ""), Number(args.limit ?? 5), includeArchived, scope);
if (hits.length === 0) {
return {
content: [
{
type: "text",
text: `No matching memories.` +
(includeArchived ? "" : " Nothing is ever deleted, so try again with `includeArchived: true` if you expected something.") +
(await staleFooter(store, scope)),
},
],
};
}
// The id goes on its own line, last, with nothing punctuating it (D-041). Inline
// `(id …, <date>)` put a comma against the id and buried it after a memory that can
// run for paragraphs — agents copied a truncated or comma-suffixed id into
// forget_memory and got "No memory with that id".
//
// Every line carries the TOPIC now, which is §4.4's prevention working on the read side:
// an agent following the "recall before you answer" rule sees real topic names and can
// reuse one instead of inventing a second name for a fact that already has one.
const body = hits
.map((m) => `- [${m.category ?? m.volatility}]${archivedTag(m)}${stalenessTag(m)} ${m.text}\n` +
` asserted ${m.assertedAt} · topic ${m.topic ?? "(none)"}\n` +
` id: ${m.id}`)
.join("\n");
return { content: [{ type: "text", text: body + (await staleFooter(store, scope)) }] };
// THE REPLY FORMAT IS AN ARTIFACT OF THE DESIGN (§7.7), not a detail of this transport, so
// it is rendered by `readPath.formatRecallReply` and shared with the HTTP layer. Every
// line carries a date, a topic and a state; nothing is silently omitted; the footer says
// what was collapsed and how to get it. That is the whole difference between v1's undated
// line holding a superseded figure and an agent that can say "as of 8 August".
const report = await recallWithReport(store, String(args.query ?? ""), Number(args.limit ?? 5), includeArchived, scope);
const text = formatRecallReply(report, {
windowEnd: (m) => windowEndsAt(m.volatility, m.assertedAt, resolveWindowPolicy()),
}) + (await staleFooter(store, scope));
return { content: [{ type: "text", text }] };
}

@@ -486,36 +470,2 @@ if (name === "forget_memory") {

/**
* A visible marker on a record that has passed its freshness window but is still recallable
* (D-077).
*
* A human-sourced memory no longer disappears at its TTL, so recall can now return a figure
* that was true a week ago next to one that is current. Silence there is exactly how a wrong
* answer gets given confidently: an agent asked what he still owes read a 6 August balance and
* reported it as the figure, while a newer one sat in the same store. The age goes in the line
* the agent reads, so it can say "as of 8 August" instead of just "€2,750".
*/
function stalenessTag(m) {
const ends = windowEndsAt(m.volatility, m.assertedAt, resolveWindowPolicy());
if (!ends || new Date(ends).getTime() > Date.now())
return "";
const days = Math.max(0, Math.floor((Date.now() - new Date(m.assertedAt).getTime()) / 86_400_000));
return ` [STALE — asserted ${days} day${days === 1 ? "" : "s"} ago, past its freshness window; check for a newer record on this topic before relying on it]`;
}
/** Mark an archive entry that a recall reached through `includeArchived`, so nobody reads a
* superseded figure as the current one. The reason is included because "superseded" and
* "forgotten" mean very different things to whoever is deciding what to trust. */
function archivedTag(m) {
const reason = m.archiveReason;
return reason ? ` [ARCHIVED — ${reason}, not a current fact]` : "";
}
/** How recall will describe a volatility, in the words the reply actually uses. */
function describeVolatility(v) {
return v === "stable"
? "never going stale"
: v === "slow"
? "the kind of fact that changes over months"
: v === "fast"
? "the kind of fact that changes within days"
: "having no freshness window at all";
}
/**
* A one-line footer naming how many memories in this scope are past their freshness window.

@@ -541,6 +491,6 @@ *

return (`\n\n(${n} memor${n === 1 ? "y is" : "ies are"} past ${n === 1 ? "its" : "their"} ` +
`freshness window. ${n === 1 ? "It is" : "They are"} still returned by recall, marked ` +
`STALE and ranked BELOW every fresh memory that matched — nothing is hidden and nothing ` +
`is deleted. Re-save any that are still true under the SAME topic with a durable ` +
`volatility to clear the marker, or run \`jamgate expired\` to read them.)`);
`freshness window in this scope. ${n === 1 ? "It is" : "They are"} still returned by ` +
`recall and marked, and ranked below the fresh memories in the same reply — nothing is ` +
`hidden and nothing is deleted. Re-save any that are still true under the SAME topic with ` +
`a durable volatility to clear the marker, or run \`jamgate expired\` to read them.)`);
}

@@ -595,2 +545,4 @@ catch {

jamgate topics List the topic namespace this memory is organised by.
jamgate index Report or rebuild the derivable caches (topics, postings,
vectors). Deleting them can never lose a memory.
jamgate archive Read what has left recall, and why. Nothing is ever deleted.

@@ -673,2 +625,6 @@ jamgate restore <id> Bring an archived memory back.

// The topic namespace (§4.4) and the operation log (§9).
if (argv[0] === "index") {
process.exitCode = await indexCommand(argv.slice(1));
return;
}
if (argv[0] === "topics") {

@@ -700,3 +656,3 @@ process.exitCode = await topicsCommand(argv.slice(1));

" Commands: setup, status, export, import, expired, archive, restore, purge,\n" +
" topics, events, migrate, retire.\n" +
" topics, index, events, migrate, retire.\n" +
" Run `jamgate --help` for usage, or plain `jamgate` to start the MCP server on stdio.");

@@ -703,0 +659,0 @@ process.exitCode = 1;

@@ -7,2 +7,3 @@ // Terminal front-ends for the "nothing is destroyed" half of v2 (DESIGN-v2 §2.2, §5.3).

// jamgate topics the topic namespace
// jamgate index rebuild the derivable caches, and prove they were derivable
// jamgate events the operation log

@@ -298,1 +299,46 @@ //

}
// ---------------------------------------------------------------------------------------------
const INDEX_USAGE = `Usage: jamgate index [--rebuild] [--json]
Reports, or rebuilds, the two DERIVABLE CACHES beside your memory:
index.json topics, text hashes, and the inverted postings the read path searches
vectors.f32 the embeddings, as fixed-stride binary
Neither is data. Both carry a fingerprint of the active file and are rebuilt from
scratch on any mismatch, so a stale or corrupt cache can never lose a memory — the
recovery for any index bug is to delete a file.
--rebuild Rebuild both now. Safe at any time; takes the store lock.
Recomputing vectors needs an embedder installed; without one the
semantic layer waits and nothing else is affected.
Run this after upgrading if you want the new read-path index immediately. You do not
have to: the next save rebuilds it, and until then recall simply scans, which is what
it did before the index existed.`;
export async function indexCommand(argv, env = process.env) {
if (argv.includes("--help") || argv.includes("-h")) {
console.log(INDEX_USAGE);
return 0;
}
const store = storeFrom(env);
const paths = await store.cachePaths();
if (!argv.includes("--rebuild")) {
console.log(`store: ${store.storePath}`);
console.log(`layout: ${await store.layoutKind()}`);
console.log(paths.length === 0
? "\nThis store is in the legacy one-file layout, which has no caches to rebuild.\nRun `jamgate migrate` for the split layout."
: `\nCaches:\n${paths.map((p) => ` ${p}`).join("\n")}\n\nRebuild them with: jamgate index --rebuild`);
return 0;
}
const report = await store.rebuildCaches();
if (argv.includes("--json")) {
console.log(JSON.stringify({ store: store.storePath, ...report }, null, 2));
return 0;
}
console.log(`store: ${store.storePath}`);
console.log(` topics: ${report.topics}`);
console.log(` vectors: ${report.vectors}${report.embedded > 0 ? ` (${report.embedded} recomputed)` : ""}`);
console.log("\nCaches rebuilt. Nothing in your memory was changed — these files are derived from it.");
return 0;
}

@@ -11,6 +11,6 @@ import { promises as fs } from "node:fs";

import { withFileLock } from "./lock.js";
import { rankRecall } from "./readPath.js";
import { JamgateError } from "../errors.js";
import { memoryRelevance, MIN_RELEVANCE } from "../gate/relevance.js";
import { isModelLanguage } from "../embeddings/embedder.js";
import { DEFAULT_DUP_THRESHOLD, DEFAULT_RELATED_MIN, DEFAULT_SEMANTIC_MIN, blendRelevance, cosineSimilarity, } from "../embeddings/vector.js";
import { DEFAULT_DUP_THRESHOLD, DEFAULT_RELATED_MIN, cosineSimilarity, } from "../embeddings/vector.js";
import { detectSecret } from "../gate/secrets.js";

@@ -505,17 +505,40 @@ export { StoreUnreadableError } from "./layout.js";

*
* The RANKING is D-078's, unchanged and deliberately so: this phase rebuilds the foundation
* and the read path is redesigned next. What changed underneath it is that staleness is now
* DERIVED from `volatility` + `assertedAt` rather than read from a stored `expiresAt`. The
* windows are the same to the day, so the behaviour the 17 labelled queries measure is the
* same behaviour — which the migration's retrieval-parity gate checks rather than assumes.
* The `MemoryStore` interface's shape: a list of memories, ranked. {@link recallReport} is the
* same operation with the annotations the reply needs, and it is what both transports call —
* this method exists so an adapter that implements only the required core still satisfies the
* interface, and so every internal caller (the archive search, the migration's parity gate)
* keeps working unchanged.
*/
async recall(query, limit = 5, includeArchived = false, scope) {
return (await this.recallReport(query, limit, includeArchived, scope)).hits.map((h) => h.memory);
}
/**
* The read path (DESIGN-v2 §7), as one subsystem — see `readPath.ts` for the five stages and
* the failure mode of each.
*
* This method's whole job is to gather what those stages need and hand it over: the active set
* in scope, the archive when it was asked for, the topic registry, the postings the index
* serves, and the query's own vector when the bundled model can honestly produce one. The
* judgement lives in `readPath.ts`, where it can be tested without a disk.
*/
async recallReport(query, limit = 5, includeArchived = false, scope) {
const started = Date.now();
const now = Date.now();
const wantScope = normalizeScope(scope);
const layout = await this.layout();
const active = (await this.loadActive()).filter((m) => memScope(m) === wantScope);
const pool = includeArchived
? [...active, ...(await (await this.layout()).readArchive()).filter((m) => memScope(m) === wantScope)]
? [...active, ...(await layout.readArchive()).filter((m) => memScope(m) === wantScope)]
: active;
const hits = await this.rank(query, pool, limit, now);
const report = rankRecall({
query,
pool,
active,
topics: await this.topics(wantScope),
postings: await layout.postings(),
queryVector: query.trim() ? await this.embed(query.trim()) : undefined,
limit,
policy: this.policy,
now: Date.now(),
includeArchived,
});
// Rule 4: log the QUERY, never the returned texts. They are already in the store; copying

@@ -527,52 +550,7 @@ // them into a log doubles the blast radius of a leak for no information gain.

query,
hits: hits.length,
hits: report.hits.length,
latencyMs: Date.now() - started,
});
return hits;
return report;
}
async rank(query, pool, limit, now) {
const q = query.trim();
if (!q)
return pool.slice(-limit).reverse();
const qVec = await this.embed(q);
const scored = pool.map((m) => {
// Score the whole memory — text, topic and category — not just the text (D-036).
const lexical = memoryRelevance(q, m);
const semantic = qVec && Array.isArray(m.embedding) ? cosineSimilarity(qVec, m.embedding) : 0;
const qualifies = lexical >= MIN_RELEVANCE || semantic >= DEFAULT_SEMANTIC_MIN;
const score = qVec ? blendRelevance(lexical, semantic) : lexical;
return { m, score, qualifies };
});
return scored
.filter((x) => x.qualifies)
/**
* A STALE RECORD MAY NEVER OUTRANK A FRESH ONE (D-078).
*
* D-077 stopped hiding a human-sourced record once it passed its freshness window and
* started returning it marked STALE, which was right: a record that can never be destroyed
* and can never be recalled is not being kept. But it put those records back into a
* ranking that had no idea they were stale, and relevance alone does not know that a
* superseded figure is the wrong answer. Measured on the maintainer's own store, four of
* six natural phrasings of "how much do I still owe" returned a stale amount ahead of the
* correction — about money. The marker asked the reader to notice; the ordering told them
* not to bother.
*
* So freshness is a TIER, applied before score. IT IS NOT "NEWEST WINS", and that
* distinction is the whole design: age is not the signal, the WINDOW is. A `stable` fact
* from a year ago has no window, is therefore not stale, and competes on relevance exactly
* as it always did. Only a record that has outlived the window its own volatility declared
* is demoted — the one population we have positive evidence about.
*
* A demotion, never an exclusion.
*/
.sort((a, b) => {
const staleA = this.isStale(a.m, now) ? 1 : 0;
const staleB = this.isStale(b.m, now) ? 1 : 0;
if (staleA !== staleB)
return staleA - staleB;
return b.score - a.score;
})
.slice(0, limit)
.map((x) => x.m);
}
/** Has this assertion passed its freshness window? Derived, never stored (§5.1). */

@@ -643,4 +621,17 @@ isStale(m, nowMs = Date.now()) {

}
const ranked = await this.rank(query, entries, limit, Date.now());
return ranked;
const report = rankRecall({
query,
pool: entries,
// An archive search has no live set to reason about, so nothing here is "current" and
// nothing is collapsed by topic: the archive is history, and history's whole value is that
// it holds several assertions about the same thing.
active: [],
topics: [],
queryVector: await this.embed(query.trim()),
limit,
policy: this.policy,
now: Date.now(),
includeArchived: true,
});
return report.hits.map((h) => h.memory);
}

@@ -647,0 +638,0 @@ /**

@@ -11,3 +11,3 @@ // The on-disk layouts (DESIGN-v2 §8.2). Two shapes, one interface.

// vectors.f32 one float32 row per embedding binary, fixed stride startup
// index.json topic registry + text hashes JSON startup, rebuildable
// index.json topics + text hashes + postings JSON startup, rebuildable
//

@@ -38,2 +38,3 @@ // Only the first three are DATA. The last two are CACHES, fully derivable, carrying a

import { buildTopicRegistry, normalizeTopicId } from "./topics.js";
import { buildPostings } from "./readPath.js";
import { readVectorFile, vectorFor, writeVectorFile } from "./vectors.js";

@@ -209,2 +210,9 @@ import { JamgateError } from "../errors.js";

}
/** A legacy store has no index to serve postings from, and building them on every recall would
* cost more than the scan they exist to avoid. The read path's completeness scan makes this
* identical in RESULT to an indexed store; only the bound on the work is missing, and a
* legacy store is by definition one nobody has migrated, so it is small. */
async postings() {
return undefined;
}
async topics() {

@@ -216,6 +224,5 @@ const { all } = await this.records();

/** Bumped whenever the cache's shape changes, so an old cache is rebuilt rather than misread.
* Version 1 holds the topic registry and the text-hash map. The INVERTED POSTINGS of
* DESIGN-v2 §8.2 are deliberately absent: nothing queries them until the read path is
* redesigned, and a cache nothing reads is a stub, not a feature. */
const INDEX_FORMAT_VERSION = 1;
* Version 1 held the topic registry and the text-hash map. Version 2 adds the inverted postings
* of DESIGN-v2 §8.2, which the read path reads — a v1 index on disk is simply rebuilt. */
const INDEX_FORMAT_VERSION = 2;
class SplitLayout {

@@ -367,4 +374,10 @@ storePath;

topics: buildTopicRegistry(active, await this.readArchive()),
postings: buildPostings(active),
};
await writeAtomic(this.indexPath, JSON.stringify(index, null, 2), this.persist);
// NOT pretty-printed, and that is the one deliberate readability trade in the v2 layout.
// `memory.json` is `cat`-able because it is DATA and the user owns it; `index.json` is a
// derived cache nobody reads by hand, and indenting it cost 4× its size in newlines and
// spaces on the real store — the same whitespace-inside-a-machine-structure problem §8.1
// measured as 26.5% of the v1 file. `jamgate index` reports it; `--rebuild` recreates it.
await writeAtomic(this.indexPath, JSON.stringify(index), this.persist);
}

@@ -380,2 +393,8 @@ /** The topic namespace, from the cache when it is fresh and from the store when it is not.

}
/** The postings, from the index when it is fresh. A stale or missing index yields `undefined`
* rather than a rebuild on the read path: rebuilding is the WRITE path's job, and a recall
* that silently rewrote a cache would make a read operation take the store lock. */
async postings() {
return (await this.readFreshIndex())?.postings;
}
/** The index, or null when it is absent, stale or in an older format. Never "trust it anyway". */

@@ -382,0 +401,0 @@ async readFreshIndex() {

@@ -9,2 +9,2 @@ /**

*/
export const VERSION = "0.19.0";
export const VERSION = "0.20.0";
{
"name": "jamgate",
"version": "0.19.0",
"version": "0.20.0",
"mcpName": "io.github.amirj4m/jamgate",

@@ -5,0 +5,0 @@ "description": "A neutral, cross-agent memory quality gate for AI agents, delivered as an MCP server \u2014 a gate, not a store.",

Sorry, the diff of this file is too big to display