jamgate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ // Best-effort automatic subject derivation (Phase 3, item 5).
2
+ //
3
+ // `subject` is what a memory is *about* — the key that drives time-aware supersession
4
+ // (RULES §2.3, D-015): a newer memory with the same subject retires the older one. The
5
+ // calling agent is asked to supply it, but often won't. When it's missing, we try to derive
6
+ // one from the text with plain, deterministic rules — NO ML.
7
+ //
8
+ // This is deliberately CONSERVATIVE. A wrong subject would wrongly retire an unrelated
9
+ // memory, so the bar for auto-assigning is high: we only return a subject when a rule
10
+ // matches with confidence, and otherwise leave it unset (undefined). Missing a subject is
11
+ // safe (the memory simply isn't subject-supersedable); inventing a wrong one is not.
12
+ //
13
+ // Two layers, high-confidence first:
14
+ // 1. a curated keyword map for the most common, unambiguous subjects (location, OS, …);
15
+ // 2. a possessive/copula pattern — "<determiner> <noun phrase> is/are …" — that lifts the
16
+ // noun phrase as the subject ("my favorite color is blue" → "favorite-color").
17
+ // The output is a lowercase, hyphenated key, matching the convention used elsewhere.
18
+ const STOPWORDS = new Set([
19
+ "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "have",
20
+ "in", "is", "it", "its", "of", "on", "or", "that", "the", "their", "this", "to",
21
+ "was", "were", "with",
22
+ ]);
23
+ /** High-confidence keyword → subject rules, checked in order (first match wins). Each
24
+ * pattern is anchored on distinctive words so it rarely fires by accident. */
25
+ const KEYWORD_RULES = [
26
+ { subject: "location", pattern: /\b(lives?|living|located|resides?|based)\b/ },
27
+ {
28
+ subject: "operating-system",
29
+ pattern: /\boperating system\b|\b(linux|windows|macos|ubuntu|debian|fedora|arch)\b/,
30
+ },
31
+ { subject: "email", pattern: /\bemail\b|[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/ },
32
+ { subject: "timezone", pattern: /\btime ?zone\b|\butc[+-]?\d/ },
33
+ { subject: "name", pattern: /\b(name is|named|call me|goes by)\b/ },
34
+ {
35
+ subject: "programming-language",
36
+ pattern: /\b(programs?|coding|codes?|develops?|writes?) in\b|\bprogramming language\b/,
37
+ },
38
+ { subject: "current-project", pattern: /\b(working on|building|developing|current project)\b/ },
39
+ ];
40
+ /** Possessive/copula extractor: "<det> <noun phrase> is/are …". Captures the noun phrase. */
41
+ const COPULA = /\b(?:my|your|their|his|her|our|its|the|[a-z]+'s)\s+([a-z]+(?:\s+[a-z]+){0,2})\s+(?:is|are|was|were)\b/;
42
+ /**
43
+ * Derive a best-effort subject from `text`, or undefined when no rule fires confidently.
44
+ * Deterministic and dependency-free.
45
+ */
46
+ export function deriveSubject(text) {
47
+ const lower = text.toLowerCase();
48
+ for (const { subject, pattern } of KEYWORD_RULES) {
49
+ if (pattern.test(lower))
50
+ return subject;
51
+ }
52
+ const m = COPULA.exec(lower);
53
+ if (m) {
54
+ const phrase = normalizePhrase(m[1]);
55
+ if (phrase)
56
+ return phrase;
57
+ }
58
+ return undefined; // not confident → leave unset (safe)
59
+ }
60
+ /** Turn a captured noun phrase into a subject key: drop stopwords, keep 1–3 content tokens,
61
+ * hyphenate. Returns undefined if nothing meaningful remains. */
62
+ function normalizePhrase(phrase) {
63
+ const tokens = phrase
64
+ .split(/\s+/)
65
+ .map((t) => t.trim())
66
+ .filter((t) => t.length > 0 && !STOPWORDS.has(t));
67
+ if (tokens.length === 0 || tokens.length > 3)
68
+ return undefined;
69
+ // Guard against a lone ultra-generic token producing a useless subject.
70
+ if (tokens.length === 1 && tokens[0].length < 3)
71
+ return undefined;
72
+ return tokens.join("-");
73
+ }
package/dist/index.js ADDED
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { pathToFileURL } from "node:url";
6
+ import { FileStore } from "./store/fileStore.js";
7
+ import { loadTransformersEmbedder, resolveDupThreshold } from "./embeddings/embedder.js";
8
+ import { prefilter } from "./gate/prefilter.js";
9
+ import { deriveSubject } from "./gate/subject.js";
10
+ import { appendGateLog, resolveGateLogConfig, } from "./gate/log.js";
11
+ /**
12
+ * Build the Jamgate MCP server around a given store. Factored out of the stdio bootstrap
13
+ * so tests can drive the real handlers over an in-memory transport (e.g. to prove that
14
+ * client provenance is captured from the handshake, D-024) without spawning a process.
15
+ */
16
+ export function createServer(store, gateLog = resolveGateLogConfig()) {
17
+ const server = new Server({ name: "jamgate", version: "0.1.0" }, { capabilities: { tools: {} } });
18
+ /** The MCP client behind the current connection, taken from the `clientInfo` the client
19
+ * sent in the `initialize` handshake (D-024). This is server-observed provenance — the
20
+ * calling agent cannot spoof it through tool arguments. Undefined until the handshake
21
+ * completes or if the client sent no name. */
22
+ function clientInfoFromHandshake() {
23
+ const impl = server.getClientVersion();
24
+ if (!impl?.name)
25
+ return undefined;
26
+ return { name: impl.name, version: impl.version };
27
+ }
28
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
29
+ tools: [
30
+ {
31
+ name: "save_memory",
32
+ description: "Save a durable memory about the user through the Jamgate quality gate. " +
33
+ "Only call this for things worth remembering across sessions — identity, " +
34
+ "projects, preferences, lasting state — not chatter. Trivial or duplicate " +
35
+ "input is rejected by the gate.",
36
+ inputSchema: {
37
+ type: "object",
38
+ properties: {
39
+ text: { type: "string", description: "The memory, as a clear standalone statement." },
40
+ type: {
41
+ type: "string",
42
+ enum: ["identity", "project", "preference", "state"],
43
+ description: "Which memory layer this belongs to (RULES §4).",
44
+ },
45
+ subject: {
46
+ type: "string",
47
+ description: "What this memory is about, e.g. 'operating-system', 'location', " +
48
+ "'current-project'. If a newer memory shares a subject with an older one, " +
49
+ "the newer replaces it (time-aware supersession). Strongly recommended; " +
50
+ "if omitted, the gate derives a best-effort subject from the text.",
51
+ },
52
+ source: {
53
+ type: "string",
54
+ enum: ["agent-inferred", "user-confirmed", "user-explicit"],
55
+ description: "Where this memory came from. Defaults to agent-inferred.",
56
+ },
57
+ },
58
+ required: ["text"],
59
+ },
60
+ },
61
+ {
62
+ name: "recall_memory",
63
+ description: "Recall stored memories about the user relevant to a query.",
64
+ inputSchema: {
65
+ type: "object",
66
+ properties: {
67
+ query: { type: "string", description: "What to recall. Empty returns the most recent." },
68
+ limit: { type: "number", description: "Max results (default 5)." },
69
+ },
70
+ },
71
+ },
72
+ {
73
+ name: "forget_memory",
74
+ description: "Delete a stored memory by its id.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: { id: { type: "string", description: "The memory id to forget." } },
78
+ required: ["id"],
79
+ },
80
+ },
81
+ ],
82
+ }));
83
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
84
+ const { name } = req.params;
85
+ const args = (req.params.arguments ?? {});
86
+ if (name === "save_memory") {
87
+ const text = String(args.text ?? "");
88
+ const client = clientInfoFromHandshake();
89
+ const verdict = prefilter(text);
90
+ if (!verdict.ok) {
91
+ // Record the rejection too — the classifier learns from what the gate turns away.
92
+ await appendGateLog({
93
+ decision: "rejected",
94
+ reason: verdict.reason,
95
+ type: args.type ? String(args.type) : undefined,
96
+ subject: args.subject ? String(args.subject) : undefined,
97
+ source: args.source ? String(args.source) : undefined,
98
+ client: client?.name,
99
+ text,
100
+ }, gateLog);
101
+ return { content: [{ type: "text", text: `Rejected by gate: ${verdict.reason}.` }] };
102
+ }
103
+ // Use the agent's subject when given; otherwise try to derive one conservatively
104
+ // (D-027). A derived subject only fires on a confident rule match, else stays unset.
105
+ const subject = args.subject ? String(args.subject) : deriveSubject(text);
106
+ const result = await store.save({
107
+ text,
108
+ type: args.type,
109
+ source: args.source ?? "agent-inferred",
110
+ subject,
111
+ // Provenance from the MCP initialize handshake, not the agent's tool arguments (D-024).
112
+ client,
113
+ });
114
+ // Log the gate's decision to the local-only training buffer (D-025).
115
+ const decision = result.action === "created" ? "saved" : result.action;
116
+ await appendGateLog({
117
+ decision,
118
+ type: result.memory.type,
119
+ subject: result.memory.subject,
120
+ source: result.memory.source,
121
+ client: result.memory.client?.name,
122
+ text: result.memory.text,
123
+ }, gateLog);
124
+ let msg;
125
+ if (result.action === "duplicate") {
126
+ msg = `Already known (no duplicate added): "${result.memory.text}" [id ${result.memory.id}]`;
127
+ }
128
+ else if (result.action === "superseded") {
129
+ const old = (result.retired ?? []).map((m) => `"${m.text}"`).join(", ");
130
+ msg =
131
+ `Saved and superseded by recency — retired ${old} in favor of ` +
132
+ `"${result.memory.text}" [id ${result.memory.id}]`;
133
+ }
134
+ else if (result.action === "conflict") {
135
+ const conflicts = (result.conflictsWith ?? [])
136
+ .map((m) => `"${m.text}" (${m.source})`)
137
+ .join(", ");
138
+ msg =
139
+ `Not saved — conflict on subject "${result.memory.subject}". A more-trusted ` +
140
+ `memory already exists: ${conflicts}. The new fact "${result.memory.text}" came ` +
141
+ `from a less-trusted source (${result.memory.source}). Confirm with the user; to ` +
142
+ `apply it, re-save with source "user-confirmed" or "user-explicit".`;
143
+ }
144
+ else if (result.action === "possible_duplicate") {
145
+ const near = (result.possibleDuplicates ?? [])
146
+ .map((d) => `"${d.memory.text}" [id ${d.memory.id}] (~${d.similarity.toFixed(2)})`)
147
+ .join(", ");
148
+ msg =
149
+ `Not saved — "${result.memory.text}" looks like a semantic duplicate of an ` +
150
+ `existing memory: ${near}. If it is genuinely the same fact, nothing to do. If it ` +
151
+ `is a distinct fact or an update, re-save with a \`subject\` so the gate treats it ` +
152
+ `as its own memory (or a time-aware update of that subject).`;
153
+ }
154
+ else {
155
+ msg = `Saved: "${result.memory.text}" [id ${result.memory.id}]`;
156
+ }
157
+ return { content: [{ type: "text", text: msg }] };
158
+ }
159
+ if (name === "recall_memory") {
160
+ const hits = await store.recall(String(args.query ?? ""), Number(args.limit ?? 5));
161
+ if (hits.length === 0)
162
+ return { content: [{ type: "text", text: "No matching memories." }] };
163
+ const body = hits
164
+ .map((m) => `- [${m.type ?? "untyped"}] ${m.text} (id ${m.id}, ${m.createdAt})`)
165
+ .join("\n");
166
+ return { content: [{ type: "text", text: body }] };
167
+ }
168
+ if (name === "forget_memory") {
169
+ const ok = await store.forget(String(args.id ?? ""));
170
+ return { content: [{ type: "text", text: ok ? "Forgotten." : "No memory with that id." }] };
171
+ }
172
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
173
+ });
174
+ return server;
175
+ }
176
+ async function main() {
177
+ // Optional local embeddings (D-026): try to load the model; null → fuzzy recall only.
178
+ // Loading is lazy and best-effort so the base install and offline/CI runs work unchanged.
179
+ const embedder = await loadTransformersEmbedder();
180
+ if (embedder)
181
+ console.error(`jamgate: semantic embeddings active (${embedder.id})`);
182
+ else
183
+ console.error("jamgate: running on fuzzy recall (no embedding model loaded)");
184
+ // Depend on the adapter contract, not a concrete backend (D-019).
185
+ const store = new FileStore(undefined, {
186
+ embedder: embedder ?? undefined,
187
+ dupThreshold: resolveDupThreshold(),
188
+ });
189
+ const server = createServer(store);
190
+ const transport = new StdioServerTransport();
191
+ await server.connect(transport);
192
+ // stdout is the MCP channel; logs must go to stderr.
193
+ console.error("jamgate MCP server running on stdio");
194
+ }
195
+ // Only bootstrap stdio when run as the CLI entrypoint, not when imported by a test.
196
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
197
+ main().catch((err) => {
198
+ console.error("jamgate fatal:", err);
199
+ process.exit(1);
200
+ });
201
+ }
@@ -0,0 +1,274 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { CURRENT_SCHEMA_VERSION, migrate } from "./schema.js";
6
+ import { computeExpiresAt, isCompactable, isExpired, resolveGraceMs, resolveTtlPolicy, } from "./ttl.js";
7
+ import { withFileLock } from "./lock.js";
8
+ import { MIN_RELEVANCE, relevanceScore } from "../gate/relevance.js";
9
+ import { DEFAULT_DUP_THRESHOLD, DEFAULT_SEMANTIC_MIN, blendRelevance, cosineSimilarity, } from "../embeddings/vector.js";
10
+ /** How much we trust a memory by where it came from. A lower-trust source must not
11
+ * silently overwrite a higher-trust one — that's a contradiction to confirm, not an
12
+ * update to apply (RULES §2.3). */
13
+ const TRUST = {
14
+ "agent-inferred": 1,
15
+ "user-confirmed": 2,
16
+ "user-explicit": 3,
17
+ };
18
+ const DEFAULT_PATH = join(homedir(), ".jamgate", "memory.json");
19
+ /**
20
+ * The default store for the MVP (RULES §8: file/SQLite first, BYO stores later).
21
+ * Implements the `MemoryStore` adapter contract (D-019) so a hosted store can drop in
22
+ * later without touching the gate. Real, not a stub — save/recall/forget all persist.
23
+ *
24
+ * Phase 2 robustness (see DECISIONS D-020..D-023):
25
+ * - writes are atomic + durable (temp file + fsync + rename),
26
+ * - records expire by type and are compacted once long-dead,
27
+ * - concurrent writers on one host are serialized by a lock file,
28
+ * - the file carries a schemaVersion and old formats migrate automatically.
29
+ *
30
+ * Phase 3 (D-026): an OPTIONAL embedder can be injected. When present, saves carry a local
31
+ * semantic embedding, recall blends semantic similarity with the fuzzy lexical score, and a
32
+ * near-duplicate is flagged as "possible_duplicate". When absent, everything falls back to
33
+ * fuzzy-only recall — the base install needs no ML runtime, and CI runs this path.
34
+ */
35
+ export class FileStore {
36
+ path;
37
+ lockPath;
38
+ ttl;
39
+ graceMs;
40
+ embedder;
41
+ dupThreshold;
42
+ constructor(path = process.env.JAMGATE_STORE ?? DEFAULT_PATH, opts = {}) {
43
+ this.path = path;
44
+ this.lockPath = `${path}.lock`;
45
+ // Read policy once at construction so a single store instance is internally
46
+ // consistent; a fresh instance picks up any changed env overrides.
47
+ this.ttl = resolveTtlPolicy();
48
+ this.graceMs = resolveGraceMs();
49
+ this.embedder = opts.embedder;
50
+ this.dupThreshold = opts.dupThreshold ?? DEFAULT_DUP_THRESHOLD;
51
+ }
52
+ /** Best-effort embedding: returns the vector, or undefined if no embedder is configured
53
+ * or the embedder fails (recall/near-dup then degrade to fuzzy for this call). */
54
+ async embed(text) {
55
+ if (!this.embedder)
56
+ return undefined;
57
+ try {
58
+ return await this.embedder.embed(text);
59
+ }
60
+ catch (err) {
61
+ console.error("jamgate: embedding failed, using fuzzy recall for this op:", err);
62
+ return undefined;
63
+ }
64
+ }
65
+ /** Load the store, migrating any older on-disk format to the current schema in memory
66
+ * (the upgraded shape is persisted on the next write). */
67
+ async load() {
68
+ let raw;
69
+ try {
70
+ raw = await fs.readFile(this.path, "utf8");
71
+ }
72
+ catch (err) {
73
+ if (err.code === "ENOENT") {
74
+ return { schemaVersion: CURRENT_SCHEMA_VERSION, memories: [] };
75
+ }
76
+ throw err;
77
+ }
78
+ if (raw.trim() === "")
79
+ return { schemaVersion: CURRENT_SCHEMA_VERSION, memories: [] };
80
+ return migrate(JSON.parse(raw), this.ttl);
81
+ }
82
+ async readAll() {
83
+ return (await this.load()).memories;
84
+ }
85
+ /**
86
+ * Atomic, durable write (Phase 2, item 1). Serialize to a temp file in the SAME
87
+ * directory, fsync it, then rename over the target. `rename(2)` is atomic on a POSIX
88
+ * local filesystem, so a concurrent reader — or a crash mid-write — sees either the
89
+ * old file or the new one, never a half-written (torn) store. The temp lives in the
90
+ * same directory so the rename stays on one filesystem (a cross-device rename is not
91
+ * atomic).
92
+ */
93
+ async writeAll(memories) {
94
+ const dir = dirname(this.path);
95
+ await fs.mkdir(dir, { recursive: true });
96
+ const file = { schemaVersion: CURRENT_SCHEMA_VERSION, memories };
97
+ const data = JSON.stringify(file, null, 2);
98
+ const tmp = join(dir, `.${basename(this.path)}.${randomUUID()}.tmp`);
99
+ try {
100
+ await this.persist(tmp, data);
101
+ await fs.rename(tmp, this.path);
102
+ }
103
+ catch (err) {
104
+ await fs.rm(tmp, { force: true }); // never leave an orphaned temp behind
105
+ throw err;
106
+ }
107
+ }
108
+ /** Write `data` to `tmpPath` and fsync it to disk. Factored out as a seam so a test
109
+ * can simulate a crash mid-write and prove the committed store survives intact. */
110
+ async persist(tmpPath, data) {
111
+ const handle = await fs.open(tmpPath, "w");
112
+ try {
113
+ await handle.writeFile(data, "utf8");
114
+ await handle.sync(); // flush to disk before the rename commits the new file
115
+ }
116
+ finally {
117
+ await handle.close();
118
+ }
119
+ }
120
+ /**
121
+ * Write a memory through the stateful checks:
122
+ * - exact-duplicate dedup (RULES §2.2)
123
+ * - time-aware supersession (RULES §2.3, D-015): a newer memory with the same
124
+ * `subject` retires the old one by recency (kept, not deleted, for audit).
125
+ * - contradiction guard (RULES §2.3): a lower-trust source can't silently overwrite
126
+ * a higher-trust one on the same subject → returns "conflict" for confirmation.
127
+ *
128
+ * The whole read-modify-write runs under the store lock (re-reading the file fresh
129
+ * inside the lock), so a second process saving at the same time cannot lose this write.
130
+ */
131
+ async save(input) {
132
+ return this.withLock(() => this.saveLocked(input));
133
+ }
134
+ /** Ensure the store directory exists (the lock file lives there too), then run `fn`
135
+ * while holding the store lock. Every read-modify-write goes through here. */
136
+ async withLock(fn) {
137
+ await fs.mkdir(dirname(this.path), { recursive: true });
138
+ return withFileLock(this.lockPath, fn);
139
+ }
140
+ async saveLocked(input) {
141
+ const memories = await this.readAll();
142
+ const norm = input.text.trim().toLowerCase();
143
+ const existing = memories.find((m) => m.status === "active" && m.text.trim().toLowerCase() === norm);
144
+ if (existing)
145
+ return { action: "duplicate", memory: existing };
146
+ // Compute the semantic embedding once (best-effort). Stored on the record and reused
147
+ // for near-duplicate detection below. Undefined when no embedder is configured.
148
+ const embedding = await this.embed(input.text.trim());
149
+ const now = new Date().toISOString();
150
+ const memory = {
151
+ id: randomUUID(),
152
+ text: input.text.trim(),
153
+ type: input.type,
154
+ subject: input.subject?.trim().toLowerCase() || undefined,
155
+ source: input.source,
156
+ status: "active",
157
+ createdAt: now,
158
+ updatedAt: now,
159
+ // Assign a freshness window by type (RULES §2.5, §4). Undefined = never expires.
160
+ expiresAt: computeExpiresAt(input.type, now, this.ttl),
161
+ // Trusted provenance from the MCP handshake, not agent-claimed (D-024).
162
+ client: input.client,
163
+ // Local semantic vector (D-026); absent when embeddings are unavailable.
164
+ embedding,
165
+ };
166
+ let retired = [];
167
+ if (memory.subject) {
168
+ const matches = memories.filter((m) => m.status === "active" && m.subject === memory.subject);
169
+ if (matches.length > 0) {
170
+ const maxTrust = Math.max(...matches.map((m) => TRUST[m.source]));
171
+ if (TRUST[memory.source] < maxTrust) {
172
+ // Lower-trust fact can't silently overwrite a higher-trust one → flag, don't store.
173
+ return { action: "conflict", memory, conflictsWith: matches };
174
+ }
175
+ // Equal-or-higher trust + newer → supersede by recency (D-015).
176
+ retired = matches;
177
+ for (const old of retired) {
178
+ old.status = "superseded";
179
+ old.supersededBy = memory.id;
180
+ old.supersededAt = now;
181
+ old.updatedAt = now;
182
+ }
183
+ }
184
+ }
185
+ else if (embedding) {
186
+ // No subject to drive supersession, and this isn't an exact duplicate — check whether
187
+ // it is a SEMANTIC near-duplicate of something already on file (D-026). If so, don't
188
+ // store it; hand the existing record back so the agent decides (mirrors "conflict",
189
+ // never a silent drop). A subject-bearing save intentionally skips this: supplying a
190
+ // subject signals intent to update, handled by supersession above.
191
+ const near = this.findNearDuplicates(embedding, memories);
192
+ if (near.length > 0) {
193
+ return { action: "possible_duplicate", memory, possibleDuplicates: near };
194
+ }
195
+ }
196
+ memories.push(memory);
197
+ // Opportunistic compaction: drop long-dead records as part of this same write, so
198
+ // the file self-prunes without a background scheduler (Phase 2, item 2).
199
+ const next = this.dropCompactable(memories, Date.now());
200
+ await this.writeAll(next);
201
+ return retired.length > 0
202
+ ? { action: "superseded", memory, retired }
203
+ : { action: "created", memory };
204
+ }
205
+ /** Recall returns ACTIVE, unexpired memories only — retired/superseded and (soft-)
206
+ * expired states are never surfaced as current facts (D-015: don't confront the user
207
+ * with stale words). Pass `includeSuperseded` for the full audit history. */
208
+ async recall(query, limit = 5, includeSuperseded = false) {
209
+ const now = Date.now();
210
+ const memories = (await this.readAll()).filter((m) => includeSuperseded || (m.status === "active" && !isExpired(m.expiresAt, now)));
211
+ const q = query.trim();
212
+ if (!q)
213
+ return memories.slice(-limit).reverse();
214
+ // Fuzzy, deterministic lexical relevance (Phase 3, item 2). Always computed. Beats
215
+ // plain word-overlap on plurals/typos/word-boundary noise.
216
+ // When an embedder is available (D-026), also compute semantic similarity and blend it
217
+ // in — this is what earns synonym reach ("automobile" recalling a "car" memory) that
218
+ // the lexical scorer structurally cannot. A memory qualifies for recall if it is
219
+ // lexically relevant OR strongly semantically similar; results rank by the blended score.
220
+ const qVec = await this.embed(q);
221
+ const scored = memories.map((m) => {
222
+ const lexical = relevanceScore(q, m.text);
223
+ const semantic = qVec && Array.isArray(m.embedding) ? cosineSimilarity(qVec, m.embedding) : 0;
224
+ const qualifies = lexical >= MIN_RELEVANCE || semantic >= DEFAULT_SEMANTIC_MIN;
225
+ const score = qVec ? blendRelevance(lexical, semantic) : lexical;
226
+ return { m, score, qualifies };
227
+ });
228
+ return scored
229
+ .filter((x) => x.qualifies)
230
+ .sort((a, b) => b.score - a.score)
231
+ .slice(0, limit)
232
+ .map((x) => x.m);
233
+ }
234
+ async forget(id) {
235
+ return this.withLock(async () => {
236
+ const memories = await this.readAll();
237
+ const next = memories.filter((m) => m.id !== id);
238
+ if (next.length === memories.length)
239
+ return false;
240
+ await this.writeAll(next);
241
+ return true;
242
+ });
243
+ }
244
+ /**
245
+ * Maintenance: physically remove records that have been expired for longer than the
246
+ * grace window (Phase 2, item 2). Soft-expired records (expired but still within
247
+ * grace) are deliberately kept — hidden from recall yet available for audit/recovery.
248
+ * Returns the number removed. Runs opportunistically on every save, and is also
249
+ * exposed here so a host can trigger it explicitly.
250
+ */
251
+ async compact() {
252
+ return this.withLock(async () => {
253
+ const memories = await this.readAll();
254
+ const next = this.dropCompactable(memories, Date.now());
255
+ const removed = memories.length - next.length;
256
+ if (removed > 0)
257
+ await this.writeAll(next);
258
+ return removed;
259
+ });
260
+ }
261
+ dropCompactable(memories, nowMs) {
262
+ return memories.filter((m) => !isCompactable(m.expiresAt, nowMs, this.graceMs));
263
+ }
264
+ /** Active memories whose embedding is at/above the near-duplicate threshold to `vec`,
265
+ * most similar first. Records without an embedding (older/pre-embedding saves) are
266
+ * simply skipped — they can't be compared, and we never guess a near-match (D-026). */
267
+ findNearDuplicates(vec, memories) {
268
+ return memories
269
+ .filter((m) => m.status === "active" && Array.isArray(m.embedding))
270
+ .map((m) => ({ memory: m, similarity: cosineSimilarity(vec, m.embedding) }))
271
+ .filter((x) => x.similarity >= this.dupThreshold)
272
+ .sort((a, b) => b.similarity - a.similarity);
273
+ }
274
+ }
@@ -0,0 +1,92 @@
1
+ // Cross-process advisory file lock (Phase 2, item 3).
2
+ //
3
+ // Two MCP server processes may share one store file (e.g. Claude Code and Cursor both
4
+ // pointed at ~/.jamgate/memory.json). Without coordination, a read-modify-write in one
5
+ // process can clobber a concurrent one (both read the same base, both write, last
6
+ // rename wins → lost update). This lock serializes those writers.
7
+ import { promises as fs } from "node:fs";
8
+ // timeoutMs is aligned with staleMs on purpose: a waiter only gives up and proceeds
9
+ // WITHOUT the lock once it has waited long enough that the holder's lock is itself stale
10
+ // — and a stale lock is stolen (see the loop below) before the give-up branch is ever
11
+ // reached. So under contention a live holder's write is never clobbered: a waiter either
12
+ // acquires the lock or steals a provably-abandoned one. The give-up path survives only as
13
+ // a last-resort liveness guard against pathological starvation.
14
+ const DEFAULTS = { staleMs: 30_000, retryMs: 25, timeoutMs: 30_000 };
15
+ function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+ /**
19
+ * Run `fn` while holding an exclusive on-disk lock at `lockPath`.
20
+ *
21
+ * Mechanism: create the lock file with the `wx` flag — `open(2)` with `O_CREAT|O_EXCL`,
22
+ * an atomic "create only if it does not exist". If creation fails with EEXIST the lock
23
+ * is held: wait and retry, unless the existing lock is older than `staleMs`, in which
24
+ * case we assume the holder died without releasing and steal it. On success we always
25
+ * remove the lock in a `finally`.
26
+ *
27
+ * WHAT THIS GUARANTEES: mutual exclusion between writers that (a) run on the same host,
28
+ * (b) share a real local filesystem, and (c) all go through this function. That is
29
+ * exactly the target case — several MCP server processes on one machine sharing one
30
+ * ~/.jamgate/memory.json. Combined with the caller's re-read-before-write (each holder
31
+ * loads the file fresh inside the lock), no committed write is lost.
32
+ *
33
+ * WHAT THIS DOES NOT GUARANTEE: it is not safe over NFS/SMB or other network
34
+ * filesystems, whose `O_EXCL` semantics are unreliable. Stale-lock stealing has an
35
+ * inherent small race: a holder stalled past `staleMs` could resume just as another
36
+ * process steals its lock. Acquisition has a `timeoutMs` after which we proceed WITHOUT
37
+ * the lock rather than fail the user's save; because `timeoutMs` defaults to `staleMs`,
38
+ * that give-up only comes due once the current lock is old enough to be stolen as stale
39
+ * — which happens first — so a live holder's write is not clobbered under contention. For
40
+ * the single-machine, local-disk MVP (D-010) this is sufficient; a hosted backend (D-019)
41
+ * would rely on its database's own transactions instead of a file lock.
42
+ */
43
+ export async function withFileLock(lockPath, fn, options = {}) {
44
+ const { staleMs, retryMs, timeoutMs } = { ...DEFAULTS, ...options };
45
+ const deadline = Date.now() + timeoutMs;
46
+ let acquired = false;
47
+ for (;;) {
48
+ try {
49
+ const handle = await fs.open(lockPath, "wx");
50
+ await handle.writeFile(String(Date.now()), "utf8");
51
+ await handle.close();
52
+ acquired = true;
53
+ break;
54
+ }
55
+ catch (err) {
56
+ if (err.code !== "EEXIST")
57
+ throw err;
58
+ if (await isStale(lockPath, staleMs)) {
59
+ await fs.rm(lockPath, { force: true }); // steal an abandoned lock
60
+ continue;
61
+ }
62
+ if (Date.now() >= deadline)
63
+ break; // give up and proceed without the lock
64
+ await sleep(retryMs);
65
+ }
66
+ }
67
+ try {
68
+ return await fn();
69
+ }
70
+ finally {
71
+ // Only remove the lock if we actually own it — never delete someone else's lock
72
+ // (which is what we would be doing had acquisition timed out).
73
+ if (acquired)
74
+ await fs.rm(lockPath, { force: true });
75
+ }
76
+ }
77
+ /** Is the lock file old enough to be presumed abandoned? Unreadable/garbage → stale. */
78
+ async function isStale(lockPath, staleMs) {
79
+ try {
80
+ const raw = await fs.readFile(lockPath, "utf8");
81
+ const ts = Number(raw.trim());
82
+ if (!Number.isFinite(ts))
83
+ return true;
84
+ return Date.now() - ts > staleMs;
85
+ }
86
+ catch (err) {
87
+ // Vanished between EEXIST and now → not stale, just retry the create.
88
+ if (err.code === "ENOENT")
89
+ return false;
90
+ return false;
91
+ }
92
+ }