@memorycrystal/crystal-memory 0.7.4

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.
package/recall-hook.js ADDED
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env node
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+
5
+ const OPENAI_MODEL = "text-embedding-3-small";
6
+ const OPENAI_URL = "https://api.openai.com/v1/embeddings";
7
+ const GEMINI_MODEL = "gemini-embedding-2-preview";
8
+ const GEMINI_URL_BASE = "https://generativelanguage.googleapis.com/v1beta";
9
+ const CONVEX_ACTION = "/api/action";
10
+ const CONVEX_QUERY = "/api/query";
11
+ const DEFAULT_LIMIT = 8;
12
+ const RECALL_PATHS = ["crystal/recall:recallMemories"];
13
+
14
+ // Session memory dedup cache: tracks memory IDs returned this session
15
+ // Key: sessionKey, Value: Set of memoryId strings
16
+ const sessionMemoryCache = new Map();
17
+ const SESSION_CACHE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // 4 hours
18
+ const sessionCacheTimestamps = new Map();
19
+
20
+ function getSessionMemoryIds(sessionKey) {
21
+ if (!sessionKey) return [];
22
+ // Expire old sessions
23
+ const ts = sessionCacheTimestamps.get(sessionKey);
24
+ if (ts && Date.now() - ts > SESSION_CACHE_MAX_AGE_MS) {
25
+ sessionMemoryCache.delete(sessionKey);
26
+ sessionCacheTimestamps.delete(sessionKey);
27
+ return [];
28
+ }
29
+ return Array.from(sessionMemoryCache.get(sessionKey) || []);
30
+ }
31
+
32
+ function addSessionMemoryIds(sessionKey, memoryIds) {
33
+ if (!sessionKey || !memoryIds.length) return;
34
+ if (!sessionMemoryCache.has(sessionKey)) {
35
+ sessionMemoryCache.set(sessionKey, new Set());
36
+ sessionCacheTimestamps.set(sessionKey, Date.now());
37
+ }
38
+ for (const id of memoryIds) sessionMemoryCache.get(sessionKey).add(id);
39
+ }
40
+
41
+ const readFileEnv = (filePath) => {
42
+ const values = {};
43
+ if (!fs.existsSync(filePath)) {
44
+ return values;
45
+ }
46
+ const raw = fs.readFileSync(filePath, "utf8");
47
+ for (const line of raw.split("\n")) {
48
+ const trimmed = line.trim();
49
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) {
50
+ continue;
51
+ }
52
+ const [key, value = ""] = trimmed.split("=", 2);
53
+ const normalizedKey = key.trim();
54
+ values[normalizedKey] = value.trim().replace(/^"+|"+$/g, "");
55
+ }
56
+ return values;
57
+ };
58
+
59
+ const loadRuntimeEnv = () => {
60
+ const envCandidates = [
61
+ process.env.CRYSTAL_ENV_FILE,
62
+ path.resolve(__dirname, "..", "mcp-server", ".env"),
63
+ process.env.CRYSTAL_ROOT ? path.resolve(process.env.CRYSTAL_ROOT, "mcp-server", ".env") : null,
64
+ ].filter((entry) => typeof entry === "string" && entry.trim().length > 0);
65
+
66
+ const envFile = envCandidates.find((entry) => fs.existsSync(entry));
67
+
68
+ return {
69
+ ...(envFile ? readFileEnv(envFile) : {}),
70
+ ...process.env,
71
+ };
72
+ };
73
+
74
+ const readInputFromStdin = async () => {
75
+ const chunks = [];
76
+ return await new Promise((resolve) => {
77
+ process.stdin.setEncoding("utf8");
78
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
79
+ process.stdin.on("end", () => resolve(chunks.join("")));
80
+ });
81
+ };
82
+
83
+ const safeGetString = (value) => (typeof value === "string" ? value : "");
84
+
85
+ const parseInput = async () => {
86
+ const env = loadRuntimeEnv();
87
+ const argQuery = safeGetString(process.argv[2]);
88
+ const rawPayload = (await readInputFromStdin()).trim();
89
+
90
+ if (!rawPayload) {
91
+ return {
92
+ query: argQuery,
93
+ channel: "",
94
+ sessionId: "",
95
+ sessionKey: "",
96
+ mode: "",
97
+ env,
98
+ };
99
+ }
100
+
101
+ try {
102
+ const parsed = JSON.parse(rawPayload);
103
+ return {
104
+ query: safeGetString(parsed?.query) || argQuery || "",
105
+ channel: safeGetString(parsed?.channel),
106
+ sessionId: safeGetString(parsed?.sessionId),
107
+ sessionKey: safeGetString(parsed?.sessionKey) || safeGetString(parsed?.sessionId),
108
+ mode: safeGetString(parsed?.mode),
109
+ env,
110
+ };
111
+ } catch {
112
+ return {
113
+ query: rawPayload || argQuery,
114
+ channel: "",
115
+ sessionId: "",
116
+ sessionKey: "",
117
+ mode: "",
118
+ env,
119
+ };
120
+ }
121
+ };
122
+
123
+ const toConvexUrl = (value) => {
124
+ if (typeof value !== "string" || value.trim().length === 0) {
125
+ return "";
126
+ }
127
+ if (/^https?:\/\//i.test(value)) {
128
+ return value.replace(/\/+$/, "");
129
+ }
130
+ return `https://${value.replace(/\/+$/, "")}`;
131
+ };
132
+
133
+ const getEmbedding = async (query, env) => {
134
+ if (typeof query !== "string" || query.trim().length === 0) {
135
+ return null;
136
+ }
137
+
138
+ const provider = String(env.EMBEDDING_PROVIDER || "gemini").toLowerCase();
139
+
140
+ if (provider === "gemini") {
141
+ const geminiKey = env.GEMINI_API_KEY;
142
+ if (!geminiKey) {
143
+ return null;
144
+ }
145
+ const model = env.GEMINI_EMBEDDING_MODEL || GEMINI_MODEL;
146
+ const response = await fetch(`${GEMINI_URL_BASE}/models/${model}:embedContent?key=${encodeURIComponent(geminiKey)}`, {
147
+ method: "POST",
148
+ headers: {
149
+ "content-type": "application/json",
150
+ },
151
+ body: JSON.stringify({
152
+ model: `models/${model}`,
153
+ content: { parts: [{ text: query }] },
154
+ }),
155
+ });
156
+
157
+ if (!response.ok) {
158
+ return null;
159
+ }
160
+
161
+ const payload = await response.json().catch(() => null);
162
+ return Array.isArray(payload?.embedding?.values) ? payload.embedding.values : null;
163
+ }
164
+
165
+ const openaiKey = env.OPENAI_API_KEY;
166
+ if (!openaiKey) {
167
+ return null;
168
+ }
169
+
170
+ const response = await fetch(OPENAI_URL, {
171
+ method: "POST",
172
+ headers: {
173
+ "content-type": "application/json",
174
+ Authorization: `Bearer ${openaiKey}`,
175
+ },
176
+ body: JSON.stringify({
177
+ model: OPENAI_MODEL,
178
+ input: query,
179
+ }),
180
+ });
181
+
182
+ if (!response.ok) {
183
+ return null;
184
+ }
185
+
186
+ const payload = await response.json().catch(() => null);
187
+ return Array.isArray(payload?.data?.[0]?.embedding) ? payload.data[0].embedding : null;
188
+ };
189
+
190
+ const formatBlock = (memories) => {
191
+ if (!Array.isArray(memories) || memories.length === 0) {
192
+ return "## 🧠 Memory Crystal Memory Recall\nNo matching memories found.";
193
+ }
194
+
195
+ const lines = ["## 🧠 Memory Crystal Memory Recall"];
196
+ for (const memory of memories) {
197
+ const store = typeof memory.store === "string" ? memory.store.toUpperCase() : "UNKNOWN";
198
+ const title = typeof memory.title === "string" ? memory.title : "";
199
+ const content = typeof memory.content === "string" ? memory.content : "";
200
+ const tags = Array.isArray(memory.tags)
201
+ ? memory.tags.filter((tag) => typeof tag === "string" && tag.trim().length > 0).join(", ")
202
+ : "";
203
+ const strength = typeof memory.strength === "number" ? memory.strength.toFixed(2) : "0.00";
204
+ const confidence = typeof memory.confidence === "number" ? memory.confidence.toFixed(2) : "0.00";
205
+ const score = typeof memory.score === "number" ? memory.score.toFixed(2) : "0.00";
206
+ lines.push(`### ${store}: ${title}`);
207
+ lines.push(content);
208
+ lines.push(`Tags: ${tags.length > 0 ? tags : "none"} | Strength: ${strength} | Confidence: ${confidence} | Score: ${score}`);
209
+ lines.push("");
210
+ }
211
+
212
+ return lines.join("\n").trimEnd();
213
+ };
214
+
215
+ const normalizeMemory = (memory) => {
216
+ if (!memory || typeof memory !== "object") {
217
+ return null;
218
+ }
219
+ return {
220
+ memoryId: safeGetString(memory.memoryId || memory._id),
221
+ store: safeGetString(memory.store),
222
+ category: safeGetString(memory.category),
223
+ title: safeGetString(memory.title),
224
+ content: safeGetString(memory.content),
225
+ strength: Number.isFinite(memory.strength) ? memory.strength : 0,
226
+ confidence: Number.isFinite(memory.confidence) ? memory.confidence : 0,
227
+ tags: Array.isArray(memory.tags) ? memory.tags : [],
228
+ score: Number.isFinite(memory.score) ? memory.score : Number.isFinite(memory._score) ? memory._score : 0,
229
+ scoreValue: Number.isFinite(memory.scoreValue) ? memory.scoreValue : 0,
230
+ };
231
+ };
232
+
233
+ const searchMemories = async ({ embedding, query, mode, channel, sessionKey, env }) => {
234
+ const normalizedMode = mode && mode.trim().length > 0 ? mode.trim() : undefined;
235
+ // Prefer authenticated HTTP endpoint (/api/mcp/recall) when CRYSTAL_SITE + CRYSTAL_API_KEY
236
+ // are available. This avoids the Convex action auth requirement (ctx.auth.getUserIdentity()).
237
+ const crystalSite = (env.CRYSTAL_SITE || "").replace(/\/+$/, "");
238
+ const crystalApiKey = env.CRYSTAL_API_KEY || "";
239
+
240
+ if (crystalSite && crystalApiKey) {
241
+ try {
242
+ const payload = {
243
+ query: query || "",
244
+ limit: DEFAULT_LIMIT,
245
+ ...(channel ? { channel } : {}),
246
+ ...(sessionKey ? { sessionKey } : {}),
247
+ ...(normalizedMode ? { mode: normalizedMode } : {}),
248
+ };
249
+ const mcpResponse = await fetch(`${crystalSite}/api/mcp/recall`, {
250
+ method: "POST",
251
+ headers: {
252
+ "content-type": "application/json",
253
+ Authorization: `Bearer ${crystalApiKey}`,
254
+ },
255
+ body: JSON.stringify(payload),
256
+ });
257
+ if (mcpResponse.ok) {
258
+ const mcpPayload = await mcpResponse.json().catch(() => null);
259
+ const mcpMemories = Array.isArray(mcpPayload?.memories) ? mcpPayload.memories : [];
260
+ if (mcpMemories.length > 0) {
261
+ return mcpMemories;
262
+ }
263
+ }
264
+ } catch (_) {
265
+ // fall through to Convex action path
266
+ }
267
+ }
268
+
269
+ // Fallback: raw Convex action endpoint (requires auth context — only works when
270
+ // called from within an authenticated Convex session or from the MCP server).
271
+ const convexUrl = toConvexUrl(env.CONVEX_URL);
272
+ if (!convexUrl || !Array.isArray(embedding) || embedding.length === 0) {
273
+ return [];
274
+ }
275
+
276
+ const args = {
277
+ embedding,
278
+ limit: DEFAULT_LIMIT,
279
+ query: query, // enables BM25 hybrid search
280
+ recentMemoryIds: getSessionMemoryIds(sessionKey), // session dedup
281
+ ...(channel ? { channel } : {}),
282
+ ...(normalizedMode ? { mode: normalizedMode } : {}),
283
+ };
284
+
285
+ for (const path of RECALL_PATHS) {
286
+ const actionResponse = await fetch(`${convexUrl}${CONVEX_ACTION}`, {
287
+ method: "POST",
288
+ headers: {
289
+ "content-type": "application/json",
290
+ },
291
+ body: JSON.stringify({
292
+ path,
293
+ args,
294
+ }),
295
+ });
296
+
297
+ if (!actionResponse.ok) {
298
+ continue;
299
+ }
300
+
301
+ const actionPayload = await actionResponse.json().catch(() => null);
302
+ const actionData = actionPayload?.value ?? actionPayload;
303
+ const actionMemories = Array.isArray(actionData?.memories) ? actionData.memories : [];
304
+ if (actionMemories.length > 0) {
305
+ return actionMemories;
306
+ }
307
+ }
308
+
309
+ return [];
310
+ };
311
+
312
+ const fetchRecentMessages = async (channel, sessionKey, limit = 20, env) => {
313
+ const crystalSite = (env.CRYSTAL_SITE || "").replace(/\/+$/, "");
314
+ const crystalApiKey = env.CRYSTAL_API_KEY || "";
315
+
316
+ if (crystalSite && crystalApiKey) {
317
+ try {
318
+ const response = await fetch(`${crystalSite}/api/mcp/recent-messages`, {
319
+ method: "POST",
320
+ headers: {
321
+ "content-type": "application/json",
322
+ Authorization: `Bearer ${crystalApiKey}`,
323
+ },
324
+ body: JSON.stringify({
325
+ limit,
326
+ channel,
327
+ sessionKey,
328
+ }),
329
+ });
330
+ if (response.ok) {
331
+ const payload = await response.json().catch(() => null);
332
+ const messages = Array.isArray(payload?.messages) ? payload.messages : [];
333
+ return messages.filter((message) => message && typeof message === "object");
334
+ }
335
+ } catch (_) {
336
+ // fall through to raw Convex query
337
+ }
338
+ }
339
+
340
+ const convexUrl = toConvexUrl(env.CONVEX_URL);
341
+ if (!convexUrl) {
342
+ return [];
343
+ }
344
+
345
+ const response = await fetch(`${convexUrl}${CONVEX_QUERY}`, {
346
+ method: "POST",
347
+ headers: {
348
+ "content-type": "application/json",
349
+ },
350
+ body: JSON.stringify({
351
+ path: "crystal/messages:getRecentMessages",
352
+ args: {
353
+ limit,
354
+ channel,
355
+ sessionKey,
356
+ },
357
+ }),
358
+ });
359
+
360
+ if (!response.ok) {
361
+ return [];
362
+ }
363
+
364
+ const payload = await response.json().catch(() => null);
365
+ const data = payload?.value ?? payload;
366
+ const messages = Array.isArray(data)
367
+ ? data
368
+ : Array.isArray(data?.messages)
369
+ ? data.messages
370
+ : [];
371
+ return messages.filter((message) => message && typeof message === "object");
372
+ };
373
+
374
+ const formatRecentMessages = (messages) => {
375
+ if (!Array.isArray(messages) || messages.length === 0) {
376
+ return "";
377
+ }
378
+
379
+ const lines = messages.map((message) => {
380
+ const timestamp = typeof message.timestamp === "number" ? new Date(message.timestamp).toLocaleTimeString() : "Invalid time";
381
+ const role = typeof message.role === "string" ? message.role : "unknown";
382
+ const content = typeof message.content === "string" ? message.content : "";
383
+ const trimmed = content.length > 150 ? content.slice(0, 150) : content;
384
+ return `[${timestamp}] ${role}: ${trimmed}`;
385
+ });
386
+
387
+ return ["## Short-Term Memory (recent messages)", ...lines].join("\n");
388
+ };
389
+
390
+ /**
391
+ * Returns true if this query warrants a memory recall lookup.
392
+ * Skips noisy/trivial queries; forces recall for explicit memory-seeking queries.
393
+ */
394
+ function shouldRecall(query) {
395
+ const q = (query || "").trim();
396
+
397
+ // Force recall if query explicitly seeks memory context
398
+ const forcePatterns = /\b(remember|recall|previously|last time|earlier|before|memory|forgot|what did we|when did we|history)\b/i;
399
+ if (forcePatterns.test(q)) return true;
400
+
401
+ // Skip empty or very short queries
402
+ if (q.length < 4) return false;
403
+
404
+ // Skip slash commands
405
+ if (q.startsWith("/")) return false;
406
+
407
+ // Skip pure greetings
408
+ if (/^(hi|hello|hey|good morning|good afternoon|good evening|howdy)[!.,\s]*$/i.test(q)) return false;
409
+
410
+ // Skip simple confirmations
411
+ if (/^(yes|no|ok|sure|thanks|thank you|nope|yep|yeah|nah)[!.,\s]*$/i.test(q)) return false;
412
+
413
+ // Skip pure emoji (no alphabetic characters)
414
+ if (!/[a-zA-Z]/.test(q)) return false;
415
+
416
+ // Skip heartbeat patterns
417
+ if (/HEARTBEAT|heartbeat poll/i.test(q)) return false;
418
+
419
+ return true;
420
+ }
421
+
422
+ const main = async () => {
423
+ const { query, mode, env, channel, sessionKey } = await parseInput();
424
+
425
+ if (!shouldRecall(query)) {
426
+ process.stdout.write(JSON.stringify({ injectionBlock: "", memories: [] }));
427
+ process.exit(0);
428
+ }
429
+ const embedding = await getEmbedding(query, env);
430
+ if (!embedding) {
431
+ process.stdout.write(JSON.stringify({ injectionBlock: "", memories: [] }));
432
+ return;
433
+ }
434
+
435
+ const memories = (await searchMemories({ embedding, query, mode, channel, sessionKey, env }))
436
+ .slice(0, DEFAULT_LIMIT)
437
+ .map(normalizeMemory)
438
+ .filter(Boolean);
439
+ addSessionMemoryIds(sessionKey, memories.map((m) => m.memoryId).filter(Boolean));
440
+ const recentMessages = await fetchRecentMessages(channel, sessionKey, 20, env);
441
+ const recentBlock = formatRecentMessages(recentMessages);
442
+ const longTermBlock = formatBlock(memories);
443
+ const injectionBlock = [recentBlock, longTermBlock].filter(Boolean).join("\n\n");
444
+
445
+ process.stdout.write(
446
+ JSON.stringify({
447
+ injectionBlock,
448
+ memories,
449
+ })
450
+ );
451
+ };
452
+
453
+ main().catch(() => {
454
+ process.stdout.write(JSON.stringify({ injectionBlock: "", memories: [] }));
455
+ process.exit(0);
456
+ });
@@ -0,0 +1,105 @@
1
+ // Tests for reinforcement injection logic.
2
+ // We test the constants and behavior expectations directly since the
3
+ // reinforcement logic lives inside index.js as a hook.
4
+
5
+ const { getInjectionBudget } = require("./context-budget");
6
+
7
+ let passed = 0;
8
+ let failed = 0;
9
+
10
+ function test(name, fn) {
11
+ try {
12
+ fn();
13
+ passed++;
14
+ console.log(` PASS: ${name}`);
15
+ } catch (err) {
16
+ failed++;
17
+ console.error(` FAIL: ${name} — ${err.message}`);
18
+ }
19
+ }
20
+
21
+ function assert(condition, msg) {
22
+ if (!condition) throw new Error(msg || "assertion failed");
23
+ }
24
+
25
+ console.log("reinforcement tests:");
26
+
27
+ test("reinforcement block stays under 800 chars with long memories", () => {
28
+ const REINFORCEMENT_MAX_CHARS = 800;
29
+ const cached = [
30
+ { title: "A".repeat(200), content: "B".repeat(1000) },
31
+ { title: "C".repeat(200), content: "D".repeat(1000) },
32
+ ];
33
+
34
+ let block = "## Memory Reinforcement\n";
35
+ let charCount = block.length;
36
+
37
+ for (const mem of cached.slice(0, 2)) {
38
+ const title = String(mem.title || "").slice(0, 80);
39
+ const content = String(mem.content || "").slice(0, 300);
40
+ const line = `[Recall: ${title}] ${content}\n`;
41
+ if (charCount + line.length > REINFORCEMENT_MAX_CHARS) break;
42
+ block += line;
43
+ charCount += line.length;
44
+ }
45
+
46
+ assert(block.length <= REINFORCEMENT_MAX_CHARS, `block is ${block.length} chars, expected <= ${REINFORCEMENT_MAX_CHARS}`);
47
+ });
48
+
49
+ test("reinforcement only fires when turn count >= 5", () => {
50
+ const REINFORCEMENT_TURN_THRESHOLD = 5;
51
+
52
+ // Simulate turn counts
53
+ assert(3 < REINFORCEMENT_TURN_THRESHOLD, "3 turns should NOT trigger reinforcement");
54
+ assert(5 >= REINFORCEMENT_TURN_THRESHOLD, "5 turns should trigger reinforcement");
55
+ assert(10 >= REINFORCEMENT_TURN_THRESHOLD, "10 turns should trigger reinforcement");
56
+ });
57
+
58
+ test("reinforcement returns nothing when no cached recall", () => {
59
+ const cache = new Map();
60
+ const sessionKey = "test-session";
61
+ const cached = cache.get(sessionKey);
62
+ assert(!cached || cached.length === 0, "empty cache should produce no reinforcement");
63
+ });
64
+
65
+ test("reinforcement returns nothing when cache is expired", () => {
66
+ const SESSION_RECALL_CACHE_MAX_AGE_MS = 4 * 60 * 60 * 1000;
67
+ const timestamps = new Map();
68
+ timestamps.set("test", Date.now() - SESSION_RECALL_CACHE_MAX_AGE_MS - 1);
69
+ const ts = timestamps.get("test");
70
+ assert(Date.now() - ts > SESSION_RECALL_CACHE_MAX_AGE_MS, "expired cache should not trigger reinforcement");
71
+ });
72
+
73
+ test("reinforcement budget is within model injection budget", () => {
74
+ const REINFORCEMENT_MAX_CHARS = 800;
75
+ const smallBudget = getInjectionBudget("gpt-4o");
76
+ assert(REINFORCEMENT_MAX_CHARS < smallBudget.maxChars,
77
+ `reinforcement (${REINFORCEMENT_MAX_CHARS}) should fit within even smallest model budget (${smallBudget.maxChars})`);
78
+ });
79
+
80
+ test("cache cleanup works with Map.delete", () => {
81
+ const cache = new Map();
82
+ const timestamps = new Map();
83
+ cache.set("s1", [{ title: "test", content: "test" }]);
84
+ timestamps.set("s1", Date.now());
85
+ cache.delete("s1");
86
+ timestamps.delete("s1");
87
+ assert(!cache.has("s1"), "cache should be empty after delete");
88
+ assert(!timestamps.has("s1"), "timestamps should be empty after delete");
89
+ });
90
+
91
+ test("cache cleanup works with Map.clear", () => {
92
+ const cache = new Map();
93
+ const timestamps = new Map();
94
+ cache.set("s1", []);
95
+ cache.set("s2", []);
96
+ timestamps.set("s1", Date.now());
97
+ timestamps.set("s2", Date.now());
98
+ cache.clear();
99
+ timestamps.clear();
100
+ assert(cache.size === 0, "cache should be empty after clear");
101
+ assert(timestamps.size === 0, "timestamps should be empty after clear");
102
+ });
103
+
104
+ console.log(`\n${passed} passed, ${failed} failed`);
105
+ if (failed > 0) process.exit(1);