| // 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; | ||
| } |
@@ -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({ |
+20
-4
@@ -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 @@ }); |
+59
-5
@@ -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) { |
+146
-37
@@ -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 @@ /** |
+1
-1
@@ -9,2 +9,2 @@ /** | ||
| */ | ||
| export const VERSION = "0.17.0"; | ||
| export const VERSION = "0.18.0"; |
+1
-1
| { | ||
| "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.", |
+32
-9
@@ -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). |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
442202
3.93%38
2.7%7340
3.26%1072
2.19%