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.12.0
to
0.13.0
+95
dist/gate/lifespan.js
// Write-time lifespan confirmation over MCP elicitation (D-072).
//
// THE FAILURE THIS EXISTS TO STOP. A memory's lifespan comes from its `type`, and `type` is
// guessed by the calling agent. When that guess is wrong in the `state` direction the fact is
// hidden from recall two days later, and every consequence after that is silent. It happened
// to the maintainer's own store: two `user-confirmed` facts — a closed €1,600 debt and a
// settled legal fee — expired unnoticed and were four weeks from permanent deletion. The real
// gate log says it is not an edge case: **18 of 23 `state` saves carried a human source**, on
// subjects like `efood-payment-structure`, `insurance-efka-registration` and `location`.
//
// WHY ASK RATHER THAN DECIDE. D-055 already refused to auto-promote these saves, and that
// refusal still holds: "jam is between apartments this week" is a legitimate `user-explicit`
// state, and a gate that silently rewrites the caller's `type` starts lying about what it
// stored. But D-055's answer — warn in the reply and move on — is advisory. It lands in the
// agent's context once and nothing surfaces it again. Elicitation is the missing middle: it
// gets the correctness of an override with the honesty of an ask, and RULES §3 and §5.4
// prescribe exactly this ("when uncertain, do NOT auto-save — ask the user").
//
// This is NOT asking the user to audit their store, which RULES forbids and which the
// maintainer rejected in his own words. It is one question, about one memory, at the moment
// that memory is being written, while the user is still in the conversation that produced it.
//
// FAIL-OPEN, ALWAYS. No elicitation capability, a decline, a cancel, a timeout, a malformed
// answer, a thrown transport — every one of them ends with the save proceeding exactly as the
// caller asked, plus the deterministic D-055 warning. The gate must never lose a memory
// because a question could not be delivered.
import { isMemoryType } from "../store/types.js";
/** Lifespans at or below this are "short enough that a mis-type loses the fact within days".
* Same threshold D-055 warns on, so the two layers can never disagree about what is risky. */
export const SHORT_LIFESPAN_MS = 7 * 24 * 60 * 60 * 1000;
/** Sources that represent a human having asked for something to be remembered. */
const HUMAN_SOURCES = ["user-explicit", "user-confirmed"];
/** The durable alternatives offered when a short-lived save looks like it should last. */
export const DURABLE_CHOICES = [
{ type: "state", label: "Passing state — expires in days (keep as-is)" },
{ type: "project", label: "Project or ongoing work — keeps for months" },
{ type: "preference", label: "A lasting preference — never expires" },
{ type: "identity", label: "Who the user is — never expires" },
];
/** The field name the elicitation form asks for. */
export const LIFESPAN_FIELD = "lifespan";
/**
* Does this save need a lifespan confirmation before it is stored?
*
* True only for the narrow, measured shape: a human-sourced save landing a lifespan of a week
* or less. Agent-inferred state notes never ask — they are the legitimate bulk of short-lived
* memory, and asking about them would train the user to dismiss the question, which is the
* failure mode that makes a prompt worse than nothing.
*/
export function needsLifespanConfirmation(input) {
if (!input.type || !isMemoryType(input.type))
return false;
if (!HUMAN_SOURCES.includes(input.source))
return false;
if (input.lifespanMs === null)
return false;
return Number.isFinite(input.lifespanMs) && input.lifespanMs <= SHORT_LIFESPAN_MS;
}
/** Build the question. One field, one enum, plain language — the user is not a maintainer. */
export function buildLifespanQuestion(input) {
const preview = input.text.length > 160 ? `${input.text.slice(0, 157)}…` : input.text;
return {
message: `This is about to be remembered as "${input.type}", which means it disappears from ` +
`recall on ${input.expiresAt.slice(0, 10)}:\n\n"${preview}"\n\n` +
`How long should it last?`,
requestedSchema: {
type: "object",
properties: {
[LIFESPAN_FIELD]: {
type: "string",
title: "How long should this be remembered?",
enum: DURABLE_CHOICES.map((c) => c.type),
enumNames: DURABLE_CHOICES.map((c) => c.label),
default: input.type,
},
},
required: [LIFESPAN_FIELD],
},
};
}
/**
* Interpret an elicitation result. Anything that is not an explicit accept carrying a valid,
* DIFFERENT memory type is a `keep` — including a value the client invented, which is why the
* answer is re-validated here rather than trusted from the wire.
*/
export function readLifespanAnswer(result, askedType) {
if (!result || result.action !== "accept")
return { outcome: "keep" };
const chosen = result.content?.[LIFESPAN_FIELD];
if (typeof chosen !== "string" || !isMemoryType(chosen))
return { outcome: "keep" };
if (chosen === askedType)
return { outcome: "keep" };
return { outcome: "corrected", type: chosen };
}
+41
-3

@@ -11,2 +11,4 @@ // The shared save pipeline (D-049).

import { triage } from "./ambiguity.js";
import { buildLifespanQuestion, needsLifespanConfirmation, readLifespanAnswer, SHORT_LIFESPAN_MS, } from "./lifespan.js";
import { computeExpiresAt, resolveTtlPolicy } from "../store/ttl.js";
import { appendGateLog, resolveGateLogConfig } from "./log.js";

@@ -26,3 +28,3 @@ import { isMemorySource, isMemoryType, MEMORY_SOURCES, MEMORY_TYPES, } from "../store/types.js";

*/
export async function saveThroughGate(store, input, gateLog = resolveGateLogConfig(), classifier) {
export async function saveThroughGate(store, input, gateLog = resolveGateLogConfig(), classifier, confirmLifespan) {
// Validate the enums BEFORE judging the content (D-037, D-054). The `type` the caller sends

@@ -133,2 +135,39 @@ // decides the memory's lifespan, so an unrecognized value is not a harmless typo — it used

}
// Gate layer 4 — confirm the LIFESPAN before the clock starts (D-072).
//
// This is the only check that runs before `store.save` and can change what is written,
// because it is the only one whose mistake is unrecoverable by the caller: once a durable
// fact is filed as `state`, it is hidden two days later and nothing surfaces it again. The
// question is asked only for the narrow measured shape (human source + lifespan ≤ a week),
// and every failure path leaves the save exactly as the caller asked it.
let effectiveType = input.type;
const prospectiveExpiry = computeExpiresAt(effectiveType, new Date().toISOString(), resolveTtlPolicy());
const lifespanMs = prospectiveExpiry ? new Date(prospectiveExpiry).getTime() - Date.now() : null;
if (confirmLifespan &&
effectiveType &&
prospectiveExpiry &&
needsLifespanConfirmation({ type: effectiveType, source: input.source, lifespanMs })) {
let answer = null;
try {
if (confirmLifespan.isAvailable()) {
answer = await confirmLifespan.ask(buildLifespanQuestion({
text: input.text,
type: effectiveType,
expiresAt: prospectiveExpiry,
}));
}
}
catch {
// A client that errors on elicitation, a dropped transport, a timeout — all the same
// as never having asked. Never let a question cost the caller a memory.
answer = null;
}
const decided = readLifespanAnswer(answer, effectiveType);
if (decided.outcome === "corrected") {
notices.push(`saved as "${decided.type}" rather than "${effectiveType}" — the user was asked how ` +
`long this should last and chose that. The gate does not change a lifespan on its ` +
`own; it asked because a "${effectiveType}" memory disappears from recall within days.`);
effectiveType = decided.type;
}
}
// Use the caller's subject when given; else derive one conservatively (D-027).

@@ -138,3 +177,3 @@ const subject = input.subject && input.subject.trim() !== "" ? input.subject : deriveSubject(input.text);

text: input.text,
type: input.type,
type: effectiveType,
source: input.source ?? "agent-inferred",

@@ -189,3 +228,2 @@ subject,

/** A human-sourced memory must never go dark silently (D-055). */
const SHORT_LIFESPAN_MS = 7 * 24 * 60 * 60 * 1000;
/**

@@ -192,0 +230,0 @@ * Warnings the transport must show the caller alongside a successful save.

@@ -15,4 +15,4 @@ #!/usr/bin/env node

import { expiredCommand } from "./store/expiredCli.js";
import { resolveGateLogConfig } from "./gate/log.js";
import { forgetThroughGate, saveThroughGate } from "./gate/pipeline.js";
import { appendGateLog, resolveGateLogConfig } from "./gate/log.js";
import { forgetThroughGate, saveThroughGate, } from "./gate/pipeline.js";
import { classifierDisabled, createSamplingClassifier, } from "./gate/classifier.js";

@@ -37,2 +37,5 @@ /**

const classifier = classifierFromServer(server);
// The write-time lifespan question (D-072). Like the classifier, its absence is a supported
// first-class mode: a client without `elicitation` falls back to the D-055 warning.
const lifespanConfirmer = lifespanConfirmerFromServer(server);
server.setRequestHandler(ListToolsRequestSchema, async () => ({

@@ -189,3 +192,3 @@ tools: [

client,
}, gateLog, classifier);
}, gateLog, classifier, lifespanConfirmer);
if (!outcome.ok) {

@@ -359,3 +362,50 @@ // An unknown `type`/`source` is a usage error, not a verdict (D-037, D-054): answer

}
/** `JAMGATE_LIFESPAN_PROMPT=off` switches the write-time lifespan question off entirely, for
* a user who would rather never be interrupted mid-save than be asked about a durable fact. */
function lifespanPromptDisabled() {
return (process.env.JAMGATE_LIFESPAN_PROMPT ?? "").trim().toLowerCase() === "off";
}
/**
* Wire the write-time lifespan question (D-072) to the CALLING CLIENT via MCP elicitation.
*
* Elicitation is the one MCP capability that can put a real question in front of the human
* who is actually in the conversation, and until now nothing in Jamgate used it — MEMORY.md
* called it "the cheapest real win left". This is the case it was made for: the gate has
* detected the exact shape that silently ate the maintainer's own memories, it must not decide
* the answer itself (D-055), and the person who can answer in one click is right there.
*
* Same two guards as the classifier, for the same reasons. The capability is checked before
* every call, because elicitation is optional in MCP and calling it on a client that does not
* declare it makes the SDK throw on every save. And it is switchable off outright.
*
* Claude Code DOES declare `elicitation` (it declares no `sampling`), so unlike the classifier
* this layer is live on the client that wrote most of the corpus.
*/
function lifespanConfirmerFromServer(server) {
if (lifespanPromptDisabled())
return undefined;
let announced = false;
return {
isAvailable() {
const supported = Boolean(server.getClientCapabilities()?.elicitation);
if (!announced) {
announced = true;
console.error(supported
? "jamgate: lifespan confirmation active (MCP elicitation on the calling client)"
: "jamgate: lifespan confirmation unavailable (this client does not support MCP elicitation) — short-lived saves are warned about instead");
}
return supported;
},
async ask(question) {
// The SDK's form-params type is a deep Zod inference; the question is built and
// validated in `lifespan.ts`, so cast at this one boundary rather than mirror it.
const result = await server.elicitInput({
message: question.message,
requestedSchema: question.requestedSchema,
});
return { action: result.action, content: result.content };
},
};
}
/**
* A one-line footer naming how many memories in this scope have expired out of recall (D-055).

@@ -376,7 +426,25 @@ *

return "";
const soonest = expired[0].compactableAt.slice(0, 10);
// Name the human-sourced ones (D-072). A bare count is a number the agent can read and do
// nothing with; it cannot tell whether what went dark was a passing mood or the user's pay
// structure. The subjects are what make the footer actionable, and these are precisely the
// records the user personally asked to be remembered.
const human = expired.filter((e) => e.memory.source === "user-explicit" || e.memory.source === "user-confirmed");
const named = human
.slice(0, 5)
.map((e) => `"${e.memory.subject ?? "(no subject)"}"`)
.join(", ");
const more = human.length > 5 ? `, +${human.length - 5} more` : "";
const destructible = expired.filter((e) => e.compactableAt !== null);
const deadline = destructible.length
? ` ${destructible.length} can be deleted by compaction from ` +
`${destructible[0].compactableAt.slice(0, 10)}.`
: ` None can be deleted by compaction — everything here was asked for by the user, and ` +
`TTL never destroys those.`;
return (`\n\n(${expired.length} memor${expired.length === 1 ? "y has" : "ies have"} expired in ` +
`this scope and ${expired.length === 1 ? "is" : "are"} hidden from recall — still on ` +
`disk, deletable by compaction from ${soonest}. Run \`jamgate expired\` to see them, ` +
`and re-save anything that should have lasted with a durable type.)`);
`this scope and ${expired.length === 1 ? "is" : "are"} hidden from recall.` +
(human.length
? ` ${human.length} of them the user asked for directly: ${named}${more}.`
: "") +
`${deadline} Run \`jamgate expired\` to read them, and re-save anything that should ` +
`have lasted under the SAME subject with a durable type.)`);
}

@@ -403,2 +471,18 @@ catch {

dupThreshold: resolveDupThreshold(),
// Compaction is the only path that destroys a memory permanently, and it used to do it
// in complete silence — no reply, no log line, nothing to reconcile against (D-072).
onCompact: (dropped) => {
for (const m of dropped) {
void appendGateLog({
decision: "compacted",
reason: `expired ${(m.expiresAt ?? "").slice(0, 10)} and past the grace window`,
type: m.type,
subject: m.subject,
source: m.source,
scope: m.scope,
client: m.client?.name,
text: m.text,
}, resolveGateLogConfig());
}
},
});

@@ -405,0 +489,0 @@ }

+4
-2

@@ -60,4 +60,6 @@ // Terminal front-end for `jamgate expired` (D-055).

console.log(` source ${memory.source} · saved ${memory.createdAt.slice(0, 10)}`);
console.log(` expired ${(memory.expiresAt ?? "").slice(0, 10)} · deletable by compaction from ` +
`${compactableAt.slice(0, 10)}`);
console.log(` expired ${(memory.expiresAt ?? "").slice(0, 10)} · ` +
(compactableAt === null
? "NEVER deleted by compaction — the user asked for this one (D-072)"
: `deletable by compaction from ${compactableAt.slice(0, 10)}`));
console.log(` id: ${memory.id}`);

@@ -64,0 +66,0 @@ }

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

import { normalizeSubject } from "../gate/subject.js";
import { computeExpiresAt, isCompactable, isExpired, resolveGraceMs, resolveTtlPolicy, } from "./ttl.js";
import { compactableAt, computeExpiresAt, isCompactable, isExpired, resolveGraceMs, resolveTtlPolicy, } from "./ttl.js";
import { withFileLock } from "./lock.js";

@@ -75,2 +75,3 @@ import { memoryRelevance, MIN_RELEVANCE } from "../gate/relevance.js";

dupThreshold;
onCompact;
constructor(path = process.env.JAMGATE_STORE ?? DEFAULT_PATH, opts = {}) {

@@ -85,2 +86,3 @@ this.path = path;

this.dupThreshold = opts.dupThreshold ?? DEFAULT_DUP_THRESHOLD;
this.onCompact = opts.onCompact;
}

@@ -464,9 +466,18 @@ /** The resolved on-disk path of this store, for reporting in the backup CLI (D-033). */

const wantScope = normalizeScope(scope);
return (await this.readAll())
return ((await this.readAll())
.filter((m) => memScope(m) === wantScope && m.status === "active" && isExpired(m.expiresAt, now))
.map((m) => ({
// `null` means TTL may never destroy this one — a human asked for it (D-072).
compactableAt: compactableAt(m.expiresAt, this.graceMs, m.source),
memory: m,
compactableAt: new Date(new Date(m.expiresAt).getTime() + this.graceMs).toISOString(),
}))
.sort((a, b) => a.compactableAt.localeCompare(b.compactableAt));
// Soonest-destroyed first, because that is the real deadline; the records that can
// never be destroyed sort last, since they carry no deadline at all.
.sort((a, b) => a.compactableAt === b.compactableAt
? 0
: a.compactableAt === null
? 1
: b.compactableAt === null
? -1
: a.compactableAt.localeCompare(b.compactableAt)));
}

@@ -490,4 +501,34 @@ /**

}
/**
* Split `memories` into what survives and what TTL may destroy (D-072).
*
* Two things changed here and both are deliberate. Compaction is now **source-aware**: a
* `user-explicit` / `user-confirmed` record is never destructible, so a human-sourced fact
* can be hidden by expiry but never deleted by it. And the destroyed records are RETURNED
* rather than silently dropped, so the caller can announce them — compaction used to be the
* one operation in the system that permanently destroyed data and wrote nothing anywhere.
*/
splitCompactable(memories, nowMs) {
const kept = [];
const dropped = [];
for (const m of memories) {
if (isCompactable(m.expiresAt, nowMs, this.graceMs, m.source))
dropped.push(m);
else
kept.push(m);
}
return { kept, dropped };
}
/** Compact and announce. The announcement is best-effort and must never break a write. */
dropCompactable(memories, nowMs) {
return memories.filter((m) => !isCompactable(m.expiresAt, nowMs, this.graceMs));
const { kept, dropped } = this.splitCompactable(memories, nowMs);
if (dropped.length > 0 && this.onCompact) {
try {
this.onCompact(dropped);
}
catch {
/* announcing a deletion must never fail the write that triggered it */
}
}
return kept;
}

@@ -494,0 +535,0 @@ /** Active memories in `scope` whose embedding is at/above `floor` similarity to `vec`, most

@@ -79,8 +79,38 @@ // Type-based TTL / expiry policy (Phase 2, item 2; RULES §2.5, §4).

}
/** A memory is compactable once it has been expired for longer than the grace window.
* Soft-expired-but-within-grace records are kept (hidden from recall, still auditable). */
export function isCompactable(expiresAt, nowMs, graceMs) {
/**
* Sources whose memories are NEVER physically destroyed by TTL (D-072).
*
* A human said "remember this". The gate is allowed to decide that such a memory has gone
* STALE — that is what expiry is for, and hiding it from recall is correct. It is not allowed
* to decide the memory should cease to exist, because the only evidence it has for destroying
* it is a `type` the calling AGENT guessed, and the real store shows that guess is wrong most
* of the time it matters: 18 of 23 `state` saves in the production gate log carried one of
* these two sources, on subjects like `efood-payment-structure` and `insurance-efka-registration`.
*
* So expiry stays reversible for anything a human asked for. These records are hidden from
* recall on schedule, remain listed by `listExpired` forever, and can always be revived by
* re-saving under the same subject. Only `agent-inferred` records are ever compacted away.
*/
export const NEVER_COMPACTED_SOURCES = ["user-explicit", "user-confirmed"];
/** Whether TTL is allowed to physically destroy a memory from this source (D-072). */
export function isDestructibleSource(source) {
return !NEVER_COMPACTED_SOURCES.includes(source);
}
/**
* A memory is compactable once it has been expired for longer than the grace window —
* UNLESS a human asked for it, in which case it is never compactable at all (D-072).
* Soft-expired-but-within-grace records are kept (hidden from recall, still auditable).
*/
export function isCompactable(expiresAt, nowMs, graceMs, source) {
if (!expiresAt)
return false;
if (!isDestructibleSource(source))
return false;
return new Date(expiresAt).getTime() + graceMs <= nowMs;
}
/** When TTL may physically destroy this memory, or `null` if it never may (D-072). */
export function compactableAt(expiresAt, graceMs, source) {
if (!expiresAt || !isDestructibleSource(source))
return null;
return new Date(new Date(expiresAt).getTime() + graceMs).toISOString();
}

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

*/
export const VERSION = "0.12.0";
export const VERSION = "0.13.0";
{
"name": "jamgate",
"version": "0.12.0",
"version": "0.13.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 — a gate, not a store.",

@@ -91,3 +91,4 @@ # Jamgate

| **Related-memory hint** *(optional)* | Below the duplicate bar but clearly on the same topic, the memory is **stored** and the look-alike is named, so the agent can re-save with a shared `subject` if it was really an update. A hint never retires anything. |
| **Type-based expiry** | Volatile state ages out (~2 days) while identity never does, so recall stays current automatically. |
| **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`. |

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

565 tests on Node 20 and 22, run against a real MCP handshake on both transports. The full
582 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

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