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.17.0
to
0.18.0
+48
dist/errors.js
// Domain errors — the ones whose message is written FOR A USER (D-078).
//
// THE FAILURE THIS FIXES. Jamgate had grown a set of genuinely good refusal messages — a
// retired store naming where memory now lives and the exact command to repoint the client, a
// corrupt store naming the file and promising nothing was destroyed. Every one of them was
// thrown as a bare `Error` from the store, and every transport treated it as a crash:
//
// REST → HTTP 500 {"error":"internal_error","message":"internal server error"}
// MCP → JSON-RPC -32603, outside the tool result the model reads
//
// The defence existed, the text existed, and the user got "internal server error". A message
// nobody can read is not a defence — the same lesson as D-055 (silence) and D-076 (a refusal
// that lies about itself), one layer further out.
//
// THE RULE. There are exactly two classes of failure and they must not be confused:
//
// DOMAIN — a condition the CALLER can understand and act on. Its message was written to
// be shown to a person, contains no internals, and travels intact to the caller
// on every transport.
// INTERNAL — everything else: a bug, a null deref, a disk failure we did not anticipate.
// Its message may contain anything at all, so it stays opaque and is logged
// server-side only.
//
// Membership is declared by TYPE, not by string matching or an allow-list of messages. A new
// refusal becomes user-visible by extending `JamgateError`, and gets the opaque treatment by
// default if it does not — which is the safe direction to fail in.
/**
* A failure the caller can understand and act on. `message` is user-facing text and is
* carried, verbatim, to whoever made the call — MCP tool result, REST body, or CLI.
*
* `code` is a stable machine-readable slug for REST clients; `httpStatus` is the status the
* REST layer answers with. Both live on the error rather than in a mapping table at the
* transport, so adding an error cannot leave a transport out of date.
*/
export class JamgateError extends Error {
code;
httpStatus;
constructor(message, code, httpStatus) {
super(message);
this.code = code;
this.httpStatus = httpStatus;
this.name = "JamgateError";
}
}
/** Is this a domain error, whose message is safe and useful to show the caller? */
export function isJamgateError(err) {
return err instanceof JamgateError;
}
+8
-3

@@ -182,3 +182,3 @@ // Terminal front-end for `jamgate export` and `jamgate import` (D-033).

if (!report.dryRun)
await logImportOutcomes(report.outcomes);
await logImportOutcomes(report.outcomes, env);
out(formatImportReport(report, { file, total: records.length, notes: vendorNotes }));

@@ -190,4 +190,9 @@ return 0;

* run logs nothing, because nothing was decided — it was a preview. */
async function logImportOutcomes(outcomes) {
const config = resolveGateLogConfig();
async function logImportOutcomes(outcomes, env) {
// Resolve from the env the COMMAND was given, not `process.env` (D-078). Taking the ambient
// environment here defeated the injection every other part of this CLI accepts: a caller
// that carefully passed `JAMGATE_GATE_LOG=off` still had its decisions appended to the real
// `~/.jamgate/gate.log`, which is how the test suite salted the training corpus through the
// import path specifically, and why containing it at the test alone never worked.
const config = resolveGateLogConfig(env);
for (const o of outcomes) {

@@ -194,0 +199,0 @@ await appendGateLog({

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

import { VERSION } from "./version.js";
import { isJamgateError } from "./errors.js";
import { createServer } from "./index.js";

@@ -131,12 +132,27 @@ import { resolveGateLogConfig } from "./gate/log.js";

handleRequest(req, res, { store: opts.store, token: opts.token, path, transports, gateLog, oauth: opts.oauth }).catch((err) => {
console.error("jamgate http: unhandled request error:", err);
// A DOMAIN error carries text written for the caller and travels intact; anything else
// is a bug and stays opaque (D-078). This boundary used to flatten both into
// "internal server error", which is how the retired-store refusal — a message naming
// where memory now lives and the command to repoint the client — reached a REST user
// as five words of nothing. Membership is by type, so a new refusal is user-visible
// only if it was declared as one, and the safe direction is the default.
const domain = isJamgateError(err) ? err : null;
if (domain)
console.error(`jamgate http: refused — ${domain.code}: ${domain.message}`);
else
console.error("jamgate http: unhandled request error:", err);
if (!res.headersSent) {
// Same envelope rule as every other error (D-051): a REST caller gets REST's shape.
if (isRestNamespace(pathnameOf(req))) {
sendJson(res, 500, { error: "internal_error", message: "internal server error" });
sendJson(res, domain?.httpStatus ?? 500, domain
? { error: domain.code, message: domain.message }
: { error: "internal_error", message: "internal server error" });
}
else {
sendJson(res, 500, {
sendJson(res, domain?.httpStatus ?? 500, {
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
error: {
code: -32603,
message: domain ? domain.message : "Internal server error",
},
id: null,

@@ -143,0 +159,0 @@ });

@@ -8,2 +8,3 @@ #!/usr/bin/env node

import { VERSION } from "./version.js";
import { isJamgateError } from "./errors.js";
import { FileStore } from "./store/fileStore.js";

@@ -132,3 +133,32 @@ import { loadTransformersEmbedder, resolveDupThreshold } from "./embeddings/embedder.js";

}));
/**
* Answer one tool call, turning a DOMAIN error into a tool result the model actually reads
* (D-078).
*
* An exception thrown out of an MCP request handler becomes a JSON-RPC error — `-32603
* Internal error` — which is a PROTOCOL-level failure, not a tool result. Clients present it
* as "the server broke", and the text may never reach the model at all. That is where the
* retired-store refusal and the corrupt-store message were going: both were written to tell
* a user exactly what to do, and both arrived as `Internal error`.
*
* A domain error is a verdict the caller must act on, so it comes back as `isError: true`
* content — the same channel this handler already uses for an unknown `type` (D-037), and
* the one the model can read and repeat. Anything else is a genuine bug: logged server-side
* and rethrown, so it stays opaque and the SDK's error path handles it unchanged.
*/
server.setRequestHandler(CallToolRequestSchema, async (req) => {
try {
return await handleToolCall(req);
}
catch (err) {
if (!isJamgateError(err))
throw err; // a real bug: opaque to the caller, loud in the log
console.error(`jamgate: ${req.params.name} refused — ${err.code}: ${err.message}`);
return {
isError: true,
content: [{ type: "text", text: `${req.params.name} failed: ${err.message}` }],
};
}
});
async function handleToolCall(req) {
const { name } = req.params;

@@ -212,2 +242,25 @@ const args = (req.params.arguments ?? {});

}
else if (result.action === "superseded" && result.correctedFields?.length) {
// A CORRECTION, where the text is identical on both sides and the changed field IS the
// news (D-078). The recency wording below described it as "retired <text> in favor of
// <the same text>" — a sentence that reads as a no-op for the one operation whose whole
// content is the field that moved, leaving the caller unable to tell whether its repair
// had landed. Say what changed, from what, to what.
const old = result.retired?.[0];
const changes = result.correctedFields
.map((f) => f === "type"
? `type "${old?.type ?? "untyped"}" → "${result.memory.type ?? "untyped"}"`
: `subject "${old?.subject ?? "none"}" → "${result.memory.subject ?? "none"}"`)
.join(", ");
msg =
`Corrected (${changes}) — same text, reclassified. The previous record is retired ` +
`and the corrected one is live: "${result.memory.text}" [id ${result.memory.id}]` +
// The lifespan is the reason `type` corrections exist at all, so state the outcome
// rather than making the caller derive it from the type name.
(result.correctedFields.includes("type")
? result.memory.expiresAt
? `. It now expires ${result.memory.expiresAt.slice(0, 10)}`
: `. It now never expires`
: "");
}
else if (result.action === "superseded") {

@@ -312,3 +365,3 @@ const old = (result.retired ?? []).map((m) => `"${m.text}"`).join(", ");

return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
});
}
return server;

@@ -450,6 +503,7 @@ }

if (staleButVisible > 0) {
parts.push(`${staleButVisible} memor${staleButVisible === 1 ? "y is" : "ies are"} past their ` +
`freshness window but still returned above, marked STALE — you asked for them, so ` +
`they are never hidden and never deleted. Re-save any that are still true under the ` +
`SAME subject with a durable type to clear the marker.`);
parts.push(`${staleButVisible} memor${staleButVisible === 1 ? "y is" : "ies are"} past ` +
`${staleButVisible === 1 ? "its" : "their"} freshness window but still returned ` +
`above, marked STALE and ranked BELOW every fresh memory that matched — you asked ` +
`for them, so they are never hidden and never deleted. Re-save any that are still ` +
`true under the SAME subject with a durable type to clear the marker.`);
}

@@ -456,0 +510,0 @@ if (hidden.length > 0) {

@@ -10,2 +10,3 @@ import { promises as fs } from "node:fs";

import { withFileLock } from "./lock.js";
import { JamgateError } from "../errors.js";
import { memoryRelevance, MIN_RELEVANCE } from "../gate/relevance.js";

@@ -69,2 +70,30 @@ import { isModelLanguage } from "../embeddings/embedder.js";

}
/**
* Which CLASSIFYING fields a re-save of identical text would change (D-078).
*
* A classifying field says something about the memory rather than being the memory: `type`
* decides how long it lives, `subject` decides what it supersedes. Getting one wrong is the
* most common repairable mistake in the system, and re-saving is the only repair the API
* offers — so "did the caller supply classifying information this record does not have?" is
* the single question that separates a correction from a duplicate.
*
* `source` is deliberately NOT here. It feeds the trust ladder, and treating a re-save as a
* licence to rewrite it would let any agent escalate its own trust by saving the same text
* again with a better source — the exact silent overwrite RULES §2.3 exists to prevent. A
* source change is judged by the trust ladder below, never waved through as a correction.
*
* One-directional by construction: a field the candidate does not declare produces no
* difference, so an import or an agent that omits a field can never strip it.
*/
function materialDifferences(candidate, existing) {
const diffs = [];
if (candidate.type !== undefined && candidate.type !== existing.type)
diffs.push("type");
// Compare canonical-to-canonical (D-052): `motorbike_debt` and `motorbike-debt` are the
// same subject, and a separator respelling must not read as a correction.
const wanted = memSubject(candidate);
if (wanted !== undefined && wanted !== memSubject(existing))
diffs.push("subject");
return diffs;
}
/** How much we trust a memory by where it came from. A lower-trust source must not

@@ -94,3 +123,3 @@ * silently overwrite a higher-trust one — that's a contradiction to confirm, not an

*/
export class RetiredStoreError extends Error {
export class RetiredStoreError extends JamgateError {
storePath;

@@ -104,3 +133,6 @@ retiredTo;

`instance — \`jamgate setup --remote ${retiredTo}\` — and save again. ` +
`Reads from this store still work, so nothing already in it is lost.`);
`Reads from this store still work, so nothing already in it is lost.`,
// 409: the store's own state is what forbids the write, and it is the caller's to
// resolve by repointing — not a server fault, and not something a retry will fix.
"store_retired", 409);
this.storePath = storePath;

@@ -113,2 +145,19 @@ this.retiredTo = retiredTo;

/**
* Thrown when the store file exists but cannot be turned into memories — unparseable JSON, or
* a permission the process does not have (D-078).
*
* A DOMAIN error, not an internal one. Every save and every recall fails from here on, so this
* is the message someone reads at the worst possible moment, and it is the one message that can
* tell them their data is still there. It used to reach a REST caller as "internal server
* error", which is the opposite of what it says.
*/
export class StoreUnreadableError extends JamgateError {
constructor(message) {
// 500: the server genuinely cannot serve from this store. The status is honest; what was
// wrong before was discarding the text that says which file and what to do about it.
super(message, "store_unreadable", 500);
this.name = "StoreUnreadableError";
}
}
/**
* The default store for the MVP (RULES §8: file/SQLite first, BYO stores later).

@@ -188,3 +237,3 @@ * Implements the `MemoryStore` adapter contract (D-019) so a hosted store can drop in

if (e.code === "EACCES" || e.code === "EPERM") {
throw new Error(`jamgate cannot read the memory store at ${this.path} — permission denied. ` +
throw new StoreUnreadableError(`jamgate cannot read the memory store at ${this.path} — permission denied. ` +
"Check the file's owner and mode (a service running as another user is the usual " +

@@ -208,4 +257,4 @@ "cause), or point JAMGATE_STORE at a path this process can read.");

// was destroyed (the writer refuses to overwrite what it could not read), and say what
// to do. (D-065)
throw new Error(`jamgate cannot read the memory store at ${this.path} — it is not valid JSON ` +
// to do. (D-065). It reaches the caller intact on every transport since D-078.
throw new StoreUnreadableError(`jamgate cannot read the memory store at ${this.path} — it is not valid JSON ` +
`(${err.message}). Your memories have NOT been modified or deleted: ` +

@@ -349,7 +398,3 @@ "Jamgate refuses to overwrite a store it could not parse. Restore the file from a " +

async save(input) {
return this.withLock(async () => {
await this.readAll(); // loads the retirement marker before anything is written
this.assertWritable(); // D-073: a retired store refuses writes, loudly
return this.saveLocked(input);
});
return this.withLock(() => this.saveLocked(input));
}

@@ -363,3 +408,9 @@ /** Ensure the store directory exists (the lock file lives there too), then run `fn`

async saveLocked(input) {
// `readAll` is what loads the retirement marker, so the check goes immediately after it.
// D-073 originally read the store a SECOND time in `save()` purely to populate the marker
// before calling this, which doubled the read+parse cost of every write on a design whose
// known weakness is reading the whole file each time (measured: 4 `readFile` calls per
// save against 2 per recall). Same guarantee, one read (D-078).
const memories = await this.readAll();
this.assertWritable(); // D-073: a retired store refuses writes, loudly
// Compute the semantic embedding once (best-effort). Stored on the record and reused

@@ -437,23 +488,38 @@ // for near-duplicate detection below. Undefined when no embedder is configured.

if (existing) {
// SAME TEXT, DIFFERENT TYPE, IS A CORRECTION — NOT A DUPLICATE (D-076).
// SAME TEXT, NEW CLASSIFYING INFORMATION, IS A CORRECTION — NOT A DUPLICATE (D-078).
//
// `type` sets the lifespan, so re-saving the same fact under a different type is the one
// and only way a caller can repair a mis-typed memory. This check used to run first and
// compare text alone, so it refused precisely that repair: a durable fact filed as
// `state` with a 2-day TTL, re-saved seconds later as `identity`, came back "Already
// known (no duplicate added)" and kept its expiry. The user got it stored only by
// rewording the text until it slipped past this line, which is not a workflow anyone
// should have to discover. D-072 made the mis-type visible and then this refused the fix.
// D-076 fixed this for `type` and only for `type`, by asking whether both records
// declared a type and the types differed. Two defects survived that shape:
//
// Deliberately narrow: identical text AND identical type is still a duplicate, so
// repeated identical saves cannot churn the store. Only a real change of lifespan class
// gets through.
// BOTH types must be present and different. "Untyped → typed" and "typed → untyped" are
// ambiguous: a bulk vendor import carries types the source never had, and treating that
// as a correction would churn every already-known record on every re-import. The incident
// this fixes was `state` → `identity`, both stated deliberately by the caller.
const isTypeCorrection = candidate.type !== undefined &&
existing.type !== undefined &&
existing.type !== candidate.type;
if (!isTypeCorrection)
// 1. A SUBJECT correction was still refused. Re-saving a fact under the right subject
// after it landed under the wrong one came back "Already known (no duplicate
// added)" and the record stayed where it was. The project's own recorded damage is
// four memories collapsed onto a bogus `location` subject — the documented repair
// for exactly that was the thing the gate refused.
// 2. A record with NO type could never be given one, because D-076 required both
// sides to declare it. Fourteen such records sit in the production store: no type,
// therefore no TTL, therefore never expiring and never compacting, and no path
// through this API to classify them. Immortal and immutable.
//
// So the rule is stated once, over a SET of fields, instead of once per field:
//
// A re-save of identical text is a CORRECTION when the caller declares a classifying
// field whose value the stored record does not already carry. It is a DUPLICATE only
// when the caller adds no classifying information the record lacks.
//
// What that buys, in the order the failures happened: `state` → `identity` (D-076),
// `location` → `motorbike-debt`, and untyped → `project`. The next classifying field is
// covered by adding it to {@link materialDifferences} rather than by a third patch here.
//
// WHY DROPPING D-076's "both must be present" IS SAFE. Its stated fear was bulk-import
// churn: a vendor export carries types the original never had, so every re-import would
// re-supersede every record forever. It converges instead. The first import supplies the
// type and supersedes once; on the second import the stored record already carries that
// type, so there is no material difference and it is a plain duplicate. One migration,
// not a treadmill — and `importBatch` runs this exact path, so that claim is tested.
//
// The rule is deliberately ONE-DIRECTIONAL: a caller that omits a field says nothing
// about it and can never strip it. That is what keeps a type-less re-import harmless.
const corrected = materialDifferences(candidate, existing);
if (corrected.length === 0)
return { action: "duplicate", memory: existing };

@@ -465,3 +531,13 @@ // The trust ladder still applies (RULES §2.3): a lower-trust source may not silently

}
// A correction REPLACES the fields it names and INHERITS the ones it does not. Omitting
// a field is not a request to clear it (the same one-directional rule that decides
// whether this is a correction at all), so correcting the subject must not quietly drop
// the type — which would delete the record's TTL and make it immortal, the exact damage
// being repaired. `expiresAt` is recomputed because it is derived from the type that
// actually lands, not the one the caller happened to send.
candidate.subject = normalizeSubject(candidate.subject) ?? existing.subject;
if (candidate.type === undefined && existing.type !== undefined) {
candidate.type = existing.type;
candidate.expiresAt = computeExpiresAt(candidate.type, candidate.createdAt, this.ttl);
}
existing.status = "superseded";

@@ -472,3 +548,7 @@ existing.supersededBy = candidate.id;

memories.push(candidate);
return { action: "superseded", memory: candidate, retired: [existing] };
// `corrected` names which classifying fields changed, so the transports can say what
// happened. Without it the reply for a type correction read "retired <text> in favor of
// <the same text>" — a sentence that describes a no-op for the one operation whose
// entire content is the field that changed (D-078).
return { action: "superseded", memory: candidate, retired: [existing], correctedFields: corrected };
}

@@ -556,8 +636,6 @@ // Same treatment for the subject as for the scope above (D-052): canonicalize once, stamp

return this.withLock(async () => {
const memories = await this.readAll(); // also loads the retirement marker
// A dry run reads only, so it stays legal on a retired store; a real import is a write.
if (!opts.dryRun) {
await this.readAll();
if (!opts.dryRun)
this.assertWritable(); // D-073
}
const memories = await this.readAll();
const now = new Date().toISOString();

@@ -607,7 +685,38 @@ // Only live facts go through the gate. Replay them oldest-first so recency-based

});
return scored
return (scored
.filter((x) => x.qualifies)
.sort((a, b) => b.score - a.score)
/**
* 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: everything inside its window ranks
* above everything past it, and relevance decides the order within each tier.
*
* IT IS NOT "NEWEST WINS", and that distinction is the whole design. Age is not the
* signal — the freshness WINDOW is. An `identity` fact from a year ago has no expiry,
* is therefore not stale, and competes on relevance exactly as it always did against a
* memory saved this morning. Only a record that has outlived the lifespan its own type
* declared is demoted, which is the one population we have positive evidence about.
*
* A demotion, never an exclusion: with nothing fresh to answer the query the stale
* record is still returned, still marked, and the recall footer still counts it. The
* user never loses an answer; they stop being handed the wrong one first.
*/
.sort((a, b) => {
const staleA = isStale(a.m, now) ? 1 : 0;
const staleB = isStale(b.m, now) ? 1 : 0;
if (staleA !== staleB)
return staleA - staleB;
return b.score - a.score;
})
.slice(0, limit)
.map((x) => x.m);
.map((x) => x.m));
}

@@ -614,0 +723,0 @@ /**

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

*/
export const VERSION = "0.17.0";
export const VERSION = "0.18.0";
{
"name": "jamgate",
"version": "0.17.0",
"version": "0.18.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.",

@@ -92,3 +92,3 @@ # Jamgate

| **Type-based expiry** | Volatile state ages out (~2 days) while identity never does, so recall stays current automatically. **Expiry hides a memory; it never destroys one you asked for** — compaction skips every `user-explicit` / `user-confirmed` record permanently, and `jamgate expired` lists them with no deletion deadline. |
| **Write-time lifespan check** *(needs MCP elicitation)* | If your agent files something *you* asked it to remember as short-lived state, the gate asks **you** — once, at the moment of saving, showing the memory and the date it would disappear — and stores your answer. This exists because it happened to my own store: two facts I confirmed were filed as 2-day state, went dark unannounced, and were four weeks from deletion. Replayed on my real gate log it fires on 17.5% of saves and stays silent on every agent-inferred state note. **Claude Code declares `elicitation`, so this one actually runs.** Off with `JAMGATE_LIFESPAN_PROMPT=off`. |
| **Write-time lifespan check** *(needs MCP elicitation)* | If your agent files something *you* asked it to remember as short-lived state, the gate asks **you** — once, at the moment of saving, showing the memory and the date it would disappear — and stores your answer. This exists because it happened to my own store: two facts I confirmed were filed as 2-day state, went dark unannounced, and were four weeks from deletion. Replayed on my real gate log it fires on **3 of 21 save decisions (14.3%)** and stays silent on every agent-inferred state note. (That figure was previously given as 17.5%, measured against a gate log that turned out to be 95.5% test fixtures; it is re-derived here against the 25 genuine decisions — see D-078.) **Claude Code declares `elicitation`, so this one actually runs.** Off with `JAMGATE_LIFESPAN_PROMPT=off`. |

@@ -112,3 +112,14 @@ Every rejection comes back with a reason the calling agent can act on. This matters more than

description of recall on a store of any size. This is the weakest part of the project and
the thing I'd fix next. For scale: Letta measured plain files plus `grep` at 74.0% on LoCoMo
the thing I'd fix next.
One specific failure inside that has been fixed, because it was worse than "imprecise": a
memory past its freshness window could outrank the memory that corrected it. Asking the same
question about money six ways, **four of six returned the superseded figure first**. Freshness
is now a tier applied before relevance, so nothing past its window can outrank anything inside
one — the same six phrasings now return the correct figure **six times out of six**, with the
ordinary top-1 baseline unchanged (14/17 before and after, identical misses). It is not "newest
wins": an `identity` fact never expires, so it is never stale and ranks on relevance exactly as
it did. What is *not* fixed is the general ordering problem above, or contradiction detection
across two different subjects — two live records can still assert different numbers for the same
thing and nothing notices. For scale: Letta measured plain files plus `grep` at 74.0% on LoCoMo
against Mem0's 68.5%, and I have no reason to think Jamgate's retrieval would beat either.

@@ -133,3 +144,3 @@ See [How it compares](#how-it-compares), where `grep` gets its own column.

**Nobody outside me has installed it.** Getting on for thirty releases, ten supported clients, one user.
**Nobody outside me has installed it.** Many releases, ten supported clients, one user.
I've simulated a cold install (fresh `HOME`, empty npm cache, published package rather than

@@ -169,6 +180,18 @@ my working copy) and it held up, but simulation is not a stranger on their own machine.

**One JSON file, read whole on every operation.** At my 66 records that is free. There is no
index and no pagination, so at some size it stops being free; I don't know what that size is
because I've never had a store big enough to find out.
**One JSON file, read whole on every operation.** At my ~60 records that is free. There is no
index and no pagination, so at some size it stops being free. It is now measured rather than
guessed at (D-078), with embeddings off, growing a store to 10,000 records:
| records | file | one save | one recall |
|---:|---:|---:|---:|
| 100 | 0.1 MB | 5 ms | 12 ms |
| 1,000 | 0.6 MB | 19 ms | 71 ms |
| 5,000 | 3.1 MB | 52 ms | 399 ms |
| 10,000 | 6.1 MB | 110 ms | 758 ms |
Nothing breaks — no crash, no corruption, no lock failure; it degrades linearly. Recall hurts
first, because it scores every record: perceptible past ~2,000 records and unpleasant past
~6,000. With embeddings on, every save also embeds and the near-duplicate scan compares all
vectors, so treat these as the optimistic bound.
**Semantic search is English-only.** The bundled model is `all-MiniLM-L6-v2`. On other

@@ -947,7 +970,7 @@ scripts its similarity degenerates into "is this the same language" (the Greek for *bicycle*

| **Entity / relationship reasoning** | **None.** Flat records with a `subject` string | Some | This is what it is for | **None** |
| **Scale** | **Untested past ~100 records.** One file, read whole, no index | Production deployments | Production deployments | **Millions of lines, fine** |
| **Scale** | **Measured to 10,000 records; recall slows past ~2,000** (758 ms at 10k). One file, read whole, no index | Production deployments | Production deployments | **Millions of lines, fine** |
| **Multi-user / teams** | **No.** One instance, one person, one token | Yes | Yes | **Whatever your filesystem does** |
| **Language support** | Lexical recall in any script; semantic is **English-only** | Multilingual models | Multilingual models | **Any bytes at all** |
| **SDKs** | MCP and a small REST API | Python, TS, and more | Python, TS, and more | **Every language ever written** |
| **Maturity** | **One developer, one user, 23 releases** | Funded team, wide adoption | Funded team, wide adoption | **Older than all of us** |
| **Maturity** | **One developer, one user; see the [releases](https://github.com/amirj4m/jamgate/releases)** | Funded team, wide adoption | Funded team, wide adoption | **Older than all of us** |
| Best for | One person's cross-agent memory, kept small and current, on their own disk | Application-scale memory with real retrieval | Relationship and temporal reasoning | Almost certainly your first thing to try |

@@ -1020,3 +1043,3 @@

624 tests on Node 20 and 22, run against a real MCP handshake on both transports. The full
644 tests on Node 20 and 22, run against a real MCP handshake on both transports. The full
history is in [`CHANGELOG.md`](./CHANGELOG.md), and every non-obvious decision, including the

@@ -1023,0 +1046,0 @@ ones I got wrong and reversed, is written up in [`DECISIONS.md`](./DECISIONS.md).