@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/LICENSE +21 -0
- package/README.md +117 -0
- package/capture-hook.js +166 -0
- package/context-budget.js +71 -0
- package/context-budget.test.js +92 -0
- package/handler.js +247 -0
- package/index.js +1342 -0
- package/index.test.js +458 -0
- package/openclaw-hook.json +84 -0
- package/openclaw.plugin.json +61 -0
- package/package.json +37 -0
- package/recall-hook.js +456 -0
- package/reinforcement.test.js +105 -0
package/index.test.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// index.test.js — Integration tests for the crystal-memory plugin (Phase 2)
|
|
2
|
+
// Uses node:test. Run: node --test plugin/index.test.js
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const { test, describe, before, after } = require("node:test");
|
|
6
|
+
const assert = require("node:assert/strict");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
const os = require("node:os");
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const { getPeerId, getChannelKey } = require("./utils/crystal-utils");
|
|
11
|
+
|
|
12
|
+
// ── Fetch mock ─────────────────────────────────────────────────────────────────
|
|
13
|
+
// Intercepts all HTTP calls made by the plugin (Convex endpoints).
|
|
14
|
+
// Returns { ok: true } for everything so no real network calls go out.
|
|
15
|
+
const fetchResponses = new Map();
|
|
16
|
+
const fetchCalls = [];
|
|
17
|
+
global.fetch = async (url, _opts = {}) => {
|
|
18
|
+
fetchCalls.push({ url, opts: _opts });
|
|
19
|
+
const override = fetchResponses.get(url) || { ok: true, json: async () => ({ ok: true, memories: [], messages: [], briefing: "" }) };
|
|
20
|
+
return {
|
|
21
|
+
ok: override.ok ?? true,
|
|
22
|
+
status: override.status ?? 200,
|
|
23
|
+
statusText: override.statusText ?? "OK",
|
|
24
|
+
text: async () => JSON.stringify(override.body || {}),
|
|
25
|
+
json: override.json ?? (async () => override.body ?? { ok: true, memories: [], messages: [] }),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ── Minimal api mock ───────────────────────────────────────────────────────────
|
|
30
|
+
function makeApi(config = {}) {
|
|
31
|
+
const tools = new Map();
|
|
32
|
+
const hooks = new Map();
|
|
33
|
+
let contextEngine = null;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
id: "crystal-memory",
|
|
37
|
+
pluginConfig: { apiKey: "test-key-abc", convexUrl: "https://example.convex.site", ...config },
|
|
38
|
+
logger: {
|
|
39
|
+
info: () => {},
|
|
40
|
+
warn: () => {},
|
|
41
|
+
error: () => {},
|
|
42
|
+
},
|
|
43
|
+
on(event, handler, _meta) {
|
|
44
|
+
hooks.set(event, handler);
|
|
45
|
+
},
|
|
46
|
+
registerHook(event, handler, _meta) {
|
|
47
|
+
hooks.set(event, handler);
|
|
48
|
+
},
|
|
49
|
+
registerTool(tool) {
|
|
50
|
+
tools.set(tool.name, tool);
|
|
51
|
+
},
|
|
52
|
+
registerContextEngine(engine) {
|
|
53
|
+
contextEngine = engine;
|
|
54
|
+
},
|
|
55
|
+
// Test helpers
|
|
56
|
+
_tools: tools,
|
|
57
|
+
_hooks: hooks,
|
|
58
|
+
_getEngine: () => contextEngine,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeCtx(overrides = {}) {
|
|
63
|
+
return { sessionKey: "test-session-abc", channelId: "ch-1", ...overrides };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeEvent(overrides = {}) {
|
|
67
|
+
return { content: "hello world", prompt: "hello world", sessionKey: "test-session-abc", ...overrides };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function makeTmpDbPath() {
|
|
71
|
+
return path.join(os.tmpdir(), `crystal-plugin-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Load plugin ────────────────────────────────────────────────────────────────
|
|
75
|
+
// Re-require fresh for each test suite to avoid shared state contamination.
|
|
76
|
+
function loadPlugin(config) {
|
|
77
|
+
// Bust require cache so each test gets a fresh module state
|
|
78
|
+
const pluginPath = path.resolve(__dirname, "index.js");
|
|
79
|
+
delete require.cache[pluginPath];
|
|
80
|
+
const utilsPath = path.resolve(__dirname, "utils/crystal-utils.js");
|
|
81
|
+
delete require.cache[utilsPath];
|
|
82
|
+
const assemblerPath = path.resolve(__dirname, "compaction/crystal-assembler.js");
|
|
83
|
+
delete require.cache[assemblerPath];
|
|
84
|
+
const pluginFactory = require(pluginPath);
|
|
85
|
+
const api = makeApi(config);
|
|
86
|
+
pluginFactory(api);
|
|
87
|
+
return api;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Tests ──────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe("crystal-memory plugin — Phase 2 integration", () => {
|
|
93
|
+
test("1. Plugin loads and registers context engine without crashing", () => {
|
|
94
|
+
const api = loadPlugin();
|
|
95
|
+
const engine = api._getEngine();
|
|
96
|
+
assert.ok(engine, "context engine should be registered");
|
|
97
|
+
assert.equal(engine.info.name, "crystal-memory");
|
|
98
|
+
assert.equal(engine.info.ownsCompaction, true);
|
|
99
|
+
// Core hooks should be registered
|
|
100
|
+
assert.ok(api._hooks.has("before_agent_start"), "before_agent_start hook");
|
|
101
|
+
assert.ok(api._hooks.has("message_received"), "message_received hook");
|
|
102
|
+
assert.ok(api._hooks.has("llm_output"), "llm_output hook");
|
|
103
|
+
assert.ok(api._hooks.has("message_sent"), "message_sent hook");
|
|
104
|
+
// Convex tools should be registered
|
|
105
|
+
const toolNames = [...api._tools.keys()];
|
|
106
|
+
assert.ok(toolNames.includes("memory_search"), "memory_search tool");
|
|
107
|
+
assert.ok(toolNames.includes("crystal_recall"), "crystal_recall tool");
|
|
108
|
+
assert.ok(toolNames.includes("crystal_remember"), "crystal_remember tool");
|
|
109
|
+
assert.ok(toolNames.includes("crystal_set_scope"), "crystal_set_scope tool");
|
|
110
|
+
assert.ok(toolNames.includes("crystal_checkpoint"), "crystal_checkpoint tool");
|
|
111
|
+
assert.ok(toolNames.includes("memory_get"), "memory_get tool");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("2. ingestBatch hook: messages are queued and flushed to Convex", async () => {
|
|
115
|
+
const api = loadPlugin();
|
|
116
|
+
const engine = api._getEngine();
|
|
117
|
+
const ctx = makeCtx();
|
|
118
|
+
const payload = {
|
|
119
|
+
sessionKey: "test-session-abc",
|
|
120
|
+
messages: [
|
|
121
|
+
{ role: "user", content: "What is the capital of France?" },
|
|
122
|
+
{ role: "assistant", content: "Paris is the capital of France." },
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
// Should not throw; returns flushed count
|
|
126
|
+
const result = await engine.ingestBatch(payload, ctx);
|
|
127
|
+
assert.ok(result === undefined || typeof result === "object", "ingestBatch returns undefined or object");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("3. assemble hook: returns messages array (with or without system prepend)", async () => {
|
|
131
|
+
const api = loadPlugin();
|
|
132
|
+
const engine = api._getEngine();
|
|
133
|
+
const ctx = makeCtx();
|
|
134
|
+
const payload = {
|
|
135
|
+
sessionKey: "test-session-abc",
|
|
136
|
+
budget: 100000,
|
|
137
|
+
messages: [
|
|
138
|
+
{ role: "user", content: "What is the capital of France?" },
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
const result = await engine.assemble(payload, ctx);
|
|
142
|
+
assert.ok(result && typeof result === "object", "assemble returns object");
|
|
143
|
+
assert.ok(Array.isArray(result.messages), "result.messages is array");
|
|
144
|
+
assert.ok(result.messages.length >= 1, "at least original message preserved");
|
|
145
|
+
assert.ok(typeof result.used === "number", "result.used is number");
|
|
146
|
+
// If a system message is prepended, it must come first
|
|
147
|
+
if (result.messages.length > 1 && result.messages[0].role === "system") {
|
|
148
|
+
assert.ok(typeof result.messages[0].content === "string", "system message has string content");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("4. compact hook: returns status string, does not throw", async () => {
|
|
153
|
+
const api = loadPlugin();
|
|
154
|
+
const engine = api._getEngine();
|
|
155
|
+
const ctx = makeCtx();
|
|
156
|
+
const payload = {
|
|
157
|
+
sessionKey: "test-session-abc",
|
|
158
|
+
reason: "context_window_full",
|
|
159
|
+
messages: [{ role: "user", content: "Lots of old messages" }],
|
|
160
|
+
};
|
|
161
|
+
const result = await engine.compact(payload, ctx);
|
|
162
|
+
// Should return a string or null — never throw
|
|
163
|
+
assert.ok(result === null || typeof result === "string", `compact returned: ${typeof result}`);
|
|
164
|
+
if (typeof result === "string") {
|
|
165
|
+
assert.ok(result.includes("Memory Crystal") || result.includes("compaction"), "result mentions compaction");
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("4b. compact hook sends sourceSnapshotId as a top-level capture field", async () => {
|
|
170
|
+
const api = loadPlugin();
|
|
171
|
+
const engine = api._getEngine();
|
|
172
|
+
const ctx = makeCtx();
|
|
173
|
+
fetchCalls.length = 0;
|
|
174
|
+
|
|
175
|
+
const snapshotUrl = "https://example.convex.site/api/mcp/snapshot";
|
|
176
|
+
const captureUrl = "https://example.convex.site/api/mcp/capture";
|
|
177
|
+
|
|
178
|
+
fetchResponses.set(snapshotUrl, {
|
|
179
|
+
ok: true,
|
|
180
|
+
json: async () => ({ id: "snapshot-123" }),
|
|
181
|
+
});
|
|
182
|
+
fetchResponses.set(captureUrl, {
|
|
183
|
+
ok: true,
|
|
184
|
+
json: async () => ({ id: "capture-456" }),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await engine.compact({
|
|
188
|
+
sessionKey: "test-session-abc",
|
|
189
|
+
reason: "context_window_full",
|
|
190
|
+
messages: [{ role: "user", content: "Lots of old messages" }],
|
|
191
|
+
}, ctx);
|
|
192
|
+
|
|
193
|
+
const captureCall = fetchCalls.find((call) => call.url === captureUrl);
|
|
194
|
+
assert.ok(captureCall, "capture request should be sent");
|
|
195
|
+
const payload = JSON.parse(captureCall.opts.body);
|
|
196
|
+
assert.equal(payload.sourceSnapshotId, "snapshot-123");
|
|
197
|
+
assert.equal("metadata" in payload, false);
|
|
198
|
+
|
|
199
|
+
fetchResponses.delete(snapshotUrl);
|
|
200
|
+
fetchResponses.delete(captureUrl);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("5. afterTurn hook: completes without error", async () => {
|
|
204
|
+
const api = loadPlugin();
|
|
205
|
+
const engine = api._getEngine();
|
|
206
|
+
const ctx = makeCtx();
|
|
207
|
+
const payload = { sessionKey: "test-session-abc" };
|
|
208
|
+
// afterTurn returns undefined — just must not throw
|
|
209
|
+
await assert.doesNotReject(async () => {
|
|
210
|
+
await engine.afterTurn(payload, ctx);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("6. before_agent_start hook: returns prependContext or undefined", async () => {
|
|
215
|
+
const api = loadPlugin();
|
|
216
|
+
const hook = api._hooks.get("before_agent_start");
|
|
217
|
+
assert.ok(typeof hook === "function", "before_agent_start is a function");
|
|
218
|
+
const event = makeEvent({ prompt: "test query" });
|
|
219
|
+
const ctx = makeCtx();
|
|
220
|
+
// Returns object with prependContext or undefined
|
|
221
|
+
await assert.doesNotReject(async () => {
|
|
222
|
+
const result = await hook(event, ctx);
|
|
223
|
+
if (result !== undefined) {
|
|
224
|
+
assert.ok(typeof result.prependContext === "string", "prependContext is string");
|
|
225
|
+
assert.ok(result.prependContext.length > 0, "prependContext is non-empty");
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("7. message_received hook: logs user message without throwing", async () => {
|
|
231
|
+
const api = loadPlugin();
|
|
232
|
+
const hook = api._hooks.get("message_received");
|
|
233
|
+
const event = makeEvent({ content: "What is 2+2?", prompt: "What is 2+2?" });
|
|
234
|
+
const ctx = makeCtx();
|
|
235
|
+
await assert.doesNotReject(async () => { await hook(event, ctx); });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("8. dispose: cleans up without throwing", () => {
|
|
239
|
+
const api = loadPlugin();
|
|
240
|
+
const engine = api._getEngine();
|
|
241
|
+
assert.doesNotThrow(() => engine.dispose());
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("9. Plugin works with no apiKey (graceful degradation)", async () => {
|
|
245
|
+
const api = loadPlugin({ apiKey: undefined });
|
|
246
|
+
const engine = api._getEngine();
|
|
247
|
+
// assemble with no apiKey should return original messages unchanged
|
|
248
|
+
const payload = { sessionKey: "s1", budget: 1000, messages: [{ role: "user", content: "hi" }] };
|
|
249
|
+
const result = await engine.assemble(payload, makeCtx());
|
|
250
|
+
assert.ok(Array.isArray(result.messages));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("11. assemble hook: messages do not contain [object Object] when localStore has messages", async () => {
|
|
254
|
+
// This tests the bug where assembleContext returns an array of message objects
|
|
255
|
+
// and they were being joined as strings, producing "[object Object]" in content.
|
|
256
|
+
const api = loadPlugin();
|
|
257
|
+
const engine = api._getEngine();
|
|
258
|
+
const ctx = makeCtx();
|
|
259
|
+
const payload = {
|
|
260
|
+
sessionKey: "test-session-abc",
|
|
261
|
+
budget: 100000,
|
|
262
|
+
messages: [
|
|
263
|
+
{ role: "user", content: "Hello from test" },
|
|
264
|
+
],
|
|
265
|
+
};
|
|
266
|
+
const result = await engine.assemble(payload, ctx);
|
|
267
|
+
assert.ok(Array.isArray(result.messages), "result.messages is array");
|
|
268
|
+
// None of the messages should have content containing "[object Object]"
|
|
269
|
+
for (const msg of result.messages) {
|
|
270
|
+
assert.ok(typeof msg === "object" && msg !== null, "each message is an object");
|
|
271
|
+
assert.ok("role" in msg, "each message has role");
|
|
272
|
+
assert.ok("content" in msg, "each message has content");
|
|
273
|
+
assert.ok(typeof msg.content === "string", "message content is a string");
|
|
274
|
+
assert.ok(!msg.content.includes("[object Object]"), `message content must not contain "[object Object]": got "${msg.content.slice(0, 80)}"`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("11b. assemble hook respects localSummaryInjection and localSummaryMaxTokens config", async () => {
|
|
279
|
+
const { checkSqliteAvailability, CrystalLocalStore } = await import(path.resolve(__dirname, "store/crystal-local-store.js"));
|
|
280
|
+
const avail = checkSqliteAvailability();
|
|
281
|
+
if (!avail.available) return;
|
|
282
|
+
|
|
283
|
+
const dbPath = makeTmpDbPath();
|
|
284
|
+
try {
|
|
285
|
+
const seedStore = new CrystalLocalStore();
|
|
286
|
+
seedStore.init(dbPath);
|
|
287
|
+
seedStore.insertSummary({
|
|
288
|
+
summaryId: "sum_cfg_1",
|
|
289
|
+
sessionKey: "cfg-session",
|
|
290
|
+
kind: "leaf",
|
|
291
|
+
depth: 0,
|
|
292
|
+
content: "Important deployment migration history dashboard",
|
|
293
|
+
tokenCount: 120,
|
|
294
|
+
});
|
|
295
|
+
seedStore.insertSummary({
|
|
296
|
+
summaryId: "sum_cfg_2",
|
|
297
|
+
sessionKey: "cfg-session",
|
|
298
|
+
kind: "leaf",
|
|
299
|
+
depth: 0,
|
|
300
|
+
content: "Backup detail about rollback procedures and infra cleanup",
|
|
301
|
+
tokenCount: 120,
|
|
302
|
+
});
|
|
303
|
+
seedStore.addMessage("cfg-session", "user", "deployment migration history dashboard");
|
|
304
|
+
seedStore.addMessage("cfg-session", "assistant", "Previous deployment migration context was summarized.");
|
|
305
|
+
seedStore.close();
|
|
306
|
+
|
|
307
|
+
let api = loadPlugin({ apiKey: "local", dbPath, localSummaryInjection: false });
|
|
308
|
+
let engine = api._getEngine();
|
|
309
|
+
let result = await engine.assemble({
|
|
310
|
+
sessionKey: "cfg-session",
|
|
311
|
+
messages: [{ role: "user", content: "deployment migration history dashboard" }],
|
|
312
|
+
}, makeCtx({ sessionKey: "cfg-session" }));
|
|
313
|
+
assert.equal(result.messages.some((m) => m.role === "system" && m.content.includes("Relevant context from earlier")), false);
|
|
314
|
+
|
|
315
|
+
api = loadPlugin({ apiKey: "local", dbPath, localSummaryInjection: true, localSummaryMaxTokens: 150 });
|
|
316
|
+
engine = api._getEngine();
|
|
317
|
+
result = await engine.assemble({
|
|
318
|
+
sessionKey: "cfg-session",
|
|
319
|
+
messages: [{ role: "user", content: "deployment migration history dashboard" }],
|
|
320
|
+
}, makeCtx({ sessionKey: "cfg-session" }));
|
|
321
|
+
const injected = result.messages.find((m) => m.role === "system" && m.content.includes("Relevant context from earlier"));
|
|
322
|
+
assert.ok(injected, "config-enabled injection should add a system message");
|
|
323
|
+
assert.ok(injected.content.includes("Important deployment migration history"), "first relevant summary should be injected");
|
|
324
|
+
assert.ok(!injected.content.includes("Backup detail about rollback"), "token cap should exclude the second summary");
|
|
325
|
+
} finally {
|
|
326
|
+
fs.rmSync(dbPath, { force: true });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("9d. crystal_preflight does not classify a lesson as both a rule and a lesson", async () => {
|
|
331
|
+
const api = loadPlugin();
|
|
332
|
+
const tool = api._tools.get("crystal_preflight");
|
|
333
|
+
const url = "https://example.convex.site/api/mcp/recall";
|
|
334
|
+
fetchResponses.set(url, {
|
|
335
|
+
ok: true,
|
|
336
|
+
json: async () => ({
|
|
337
|
+
memories: [
|
|
338
|
+
{ title: "Lesson from procedural memory", store: "procedural", category: "lesson" },
|
|
339
|
+
{ title: "Rule memory", store: "semantic", category: "rule" },
|
|
340
|
+
],
|
|
341
|
+
}),
|
|
342
|
+
});
|
|
343
|
+
const result = await tool.execute("id", { action: "deploy config" }, null, null, makeCtx());
|
|
344
|
+
const text = result.content[0].text;
|
|
345
|
+
assert.match(text, /"rules": \[\n\s+"Rule memory"\n\s+\]/);
|
|
346
|
+
assert.match(text, /"lessons": \[\n\s+"Lesson from procedural memory"\n\s+\]/);
|
|
347
|
+
fetchResponses.delete(url);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("10. Tools: crystal_grep/describe/expand registered when store initializes (lazy)", async () => {
|
|
351
|
+
// These tools are registered lazily after the local store initializes.
|
|
352
|
+
// Since better-sqlite3 may not be installed in the test env, local store init
|
|
353
|
+
// may fail gracefully — so we check that no crash occurred and that Convex tools
|
|
354
|
+
// are still registered.
|
|
355
|
+
const api = loadPlugin();
|
|
356
|
+
// Simulate afterTurn which tries to register local tools
|
|
357
|
+
const engine = api._getEngine();
|
|
358
|
+
await engine.afterTurn({ sessionKey: "s1" }, makeCtx()).catch(() => {});
|
|
359
|
+
// Convex tools must always be present regardless of local store
|
|
360
|
+
assert.ok(api._tools.has("crystal_recall"), "crystal_recall always present");
|
|
361
|
+
assert.ok(api._tools.has("memory_search"), "memory_search always present");
|
|
362
|
+
// Note: crystal_grep etc. may or may not be present depending on whether
|
|
363
|
+
// better-sqlite3 loaded. Either way no crash should occur.
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("crystal-utils channel scoping", () => {
|
|
367
|
+
test("getPeerId returns Telegram sender ID from event context", () => {
|
|
368
|
+
const ctx = {};
|
|
369
|
+
const event = { metadata: { from: { id: 12345 }, senderId: "ignored" }, context: { from: { id: 999 }, sender_id: "ctx" } };
|
|
370
|
+
assert.equal(getPeerId(ctx, event), "12345");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("getPeerId falls back to Discord authorId", () => {
|
|
374
|
+
const ctx = {};
|
|
375
|
+
const event = { metadata: { guild: { id: 1 } }, context: { authorId: "discord-9" } };
|
|
376
|
+
assert.equal(getPeerId(ctx, event), "discord-9");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("getPeerId falls back to session key last segment", () => {
|
|
380
|
+
const ctx = { sessionKey: "agent:openclaw:session:12345" };
|
|
381
|
+
assert.equal(getPeerId(ctx, {}), "12345");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("getChannelKey with channelScope uses peer namespace", () => {
|
|
385
|
+
const event = { metadata: { from: { id: 12345 } } };
|
|
386
|
+
assert.equal(getChannelKey({}, event, "coach"), "coach:12345");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("getChannelKey without channelScope preserves existing channel logic", () => {
|
|
390
|
+
const event = { context: { chat_id: "channel:coach" } };
|
|
391
|
+
assert.equal(getChannelKey({}, event), "openclaw:coach");
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe("channelScope for recall tools", () => {
|
|
396
|
+
const recallToolCases = [
|
|
397
|
+
{ name: "memory_search", args: { query: "search memory" } },
|
|
398
|
+
{ name: "crystal_recall", args: { query: "decision notes", limit: 4 } },
|
|
399
|
+
{ name: "crystal_what_do_i_know", args: { topic: "project memory", limit: 4 } },
|
|
400
|
+
{ name: "crystal_why_did_we", args: { decision: "deploy plan", limit: 4 } },
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
test("4 recall tools include channel when channelScope is configured", async () => {
|
|
404
|
+
const api = loadPlugin({ channelScope: "coach" });
|
|
405
|
+
const ctx = makeCtx({ peerId: "12345" });
|
|
406
|
+
for (const { name, args } of recallToolCases) {
|
|
407
|
+
fetchCalls.length = 0;
|
|
408
|
+
const tool = api._tools.get(name);
|
|
409
|
+
await tool.execute("id", args, null, null, ctx);
|
|
410
|
+
const payload = JSON.parse(fetchCalls.at(-1).opts.body);
|
|
411
|
+
assert.equal(payload.channel, "coach:12345");
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("4 recall tools omit channel when channelScope is not configured", async () => {
|
|
416
|
+
const api = loadPlugin();
|
|
417
|
+
const ctx = makeCtx({ peerId: "12345" });
|
|
418
|
+
for (const { name, args } of recallToolCases) {
|
|
419
|
+
fetchCalls.length = 0;
|
|
420
|
+
const tool = api._tools.get(name);
|
|
421
|
+
await tool.execute("id", args, null, null, ctx);
|
|
422
|
+
const payload = JSON.parse(fetchCalls.at(-1).opts.body);
|
|
423
|
+
assert.equal("channel" in payload, false);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("crystal_set_scope overrides channel scope for the active session and session_end clears it", async () => {
|
|
428
|
+
const api = loadPlugin({ channelScope: "default-scope" });
|
|
429
|
+
const ctx = makeCtx({ peerId: "12345", sessionKey: "session-override-1" });
|
|
430
|
+
const setScopeTool = api._tools.get("crystal_set_scope");
|
|
431
|
+
const recallTool = api._tools.get("crystal_recall");
|
|
432
|
+
const wakeTool = api._tools.get("crystal_wake");
|
|
433
|
+
const sessionEndHook = api._hooks.get("session_end");
|
|
434
|
+
|
|
435
|
+
assert.ok(setScopeTool, "crystal_set_scope should be registered");
|
|
436
|
+
assert.ok(typeof sessionEndHook === "function", "session_end hook should be registered");
|
|
437
|
+
|
|
438
|
+
await setScopeTool.execute("id", { scope: "morrow-coach" }, null, null, ctx);
|
|
439
|
+
|
|
440
|
+
fetchCalls.length = 0;
|
|
441
|
+
await recallTool.execute("id", { query: "project memory" }, null, null, ctx);
|
|
442
|
+
let payload = JSON.parse(fetchCalls.at(-1).opts.body);
|
|
443
|
+
assert.equal(payload.channel, "morrow-coach:12345");
|
|
444
|
+
|
|
445
|
+
fetchCalls.length = 0;
|
|
446
|
+
await wakeTool.execute("id", {}, null, null, ctx);
|
|
447
|
+
payload = JSON.parse(fetchCalls.at(-1).opts.body);
|
|
448
|
+
assert.equal(payload.channel, "morrow-coach:12345");
|
|
449
|
+
|
|
450
|
+
await sessionEndHook({ sessionKey: "session-override-1" }, ctx);
|
|
451
|
+
|
|
452
|
+
fetchCalls.length = 0;
|
|
453
|
+
await recallTool.execute("id", { query: "project memory" }, null, null, ctx);
|
|
454
|
+
payload = JSON.parse(fetchCalls.at(-1).opts.body);
|
|
455
|
+
assert.equal(payload.channel, "default-scope:12345");
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "crystal",
|
|
4
|
+
"name": "Memory Crystal",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"description": "Drop-in OpenClaw memory plugin using Convex + MCP tools.",
|
|
7
|
+
"entry": "./handler.js",
|
|
8
|
+
"hooks": {
|
|
9
|
+
"postTurn": {
|
|
10
|
+
"enabled": false,
|
|
11
|
+
"description": "Reserved for future memory auto-write flows."
|
|
12
|
+
},
|
|
13
|
+
"startup": {
|
|
14
|
+
"enabled": true,
|
|
15
|
+
"description": "Load plugin metadata on OpenClaw startup."
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"capabilities": {
|
|
19
|
+
"tools": [
|
|
20
|
+
"crystal_remember",
|
|
21
|
+
"crystal_recall",
|
|
22
|
+
"crystal_what_do_i_know",
|
|
23
|
+
"crystal_why_did_we",
|
|
24
|
+
"crystal_forget",
|
|
25
|
+
"crystal_stats",
|
|
26
|
+
"crystal_checkpoint",
|
|
27
|
+
"crystal_wake"
|
|
28
|
+
],
|
|
29
|
+
"mcpCommand": "/path/to/node",
|
|
30
|
+
"mcpArgs": [
|
|
31
|
+
"/path/to/openclaw-crystal/mcp-server/dist/index.js"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"commands": {
|
|
35
|
+
"crystal-capture": {
|
|
36
|
+
"command": "/path/to/node",
|
|
37
|
+
"args": [
|
|
38
|
+
"/path/to/openclaw-crystal/plugin/capture-hook.js"
|
|
39
|
+
],
|
|
40
|
+
"env": {
|
|
41
|
+
"CONVEX_URL": "https://rightful-mockingbird-389.convex.site",
|
|
42
|
+
"OPENAI_API_KEY": "${OPENAI_API_KEY}",
|
|
43
|
+
"EMBEDDING_PROVIDER": "${EMBEDDING_PROVIDER}",
|
|
44
|
+
"GEMINI_API_KEY": "${GEMINI_API_KEY}",
|
|
45
|
+
"GEMINI_EMBEDDING_MODEL": "${GEMINI_EMBEDDING_MODEL}",
|
|
46
|
+
"OBSIDIAN_VAULT_PATH": "/path/to/obsidian-vault",
|
|
47
|
+
"CRYSTAL_MCP_HOST": "127.0.0.1",
|
|
48
|
+
"CRYSTAL_MCP_PORT": "8788",
|
|
49
|
+
"CRYSTAL_ENV_FILE": "/path/to/openclaw-crystal/mcp-server/.env",
|
|
50
|
+
"CRYSTAL_NODE": "/path/to/node",
|
|
51
|
+
"CRYSTAL_PLUGIN_DIR": "/path/to/openclaw-crystal/plugin",
|
|
52
|
+
"CRYSTAL_ROOT": "/path/to/openclaw-crystal",
|
|
53
|
+
"CRYSTAL_MCP_MODE": "stdio"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"crystal-recall": {
|
|
57
|
+
"command": "/path/to/node",
|
|
58
|
+
"args": [
|
|
59
|
+
"/path/to/openclaw-crystal/plugin/recall-hook.js"
|
|
60
|
+
],
|
|
61
|
+
"env": {
|
|
62
|
+
"CONVEX_URL": "https://rightful-mockingbird-389.convex.site",
|
|
63
|
+
"OPENAI_API_KEY": "${OPENAI_API_KEY}",
|
|
64
|
+
"EMBEDDING_PROVIDER": "${EMBEDDING_PROVIDER}",
|
|
65
|
+
"GEMINI_API_KEY": "${GEMINI_API_KEY}",
|
|
66
|
+
"GEMINI_EMBEDDING_MODEL": "${GEMINI_EMBEDDING_MODEL}",
|
|
67
|
+
"OBSIDIAN_VAULT_PATH": "/path/to/obsidian-vault",
|
|
68
|
+
"CRYSTAL_MCP_HOST": "127.0.0.1",
|
|
69
|
+
"CRYSTAL_MCP_PORT": "8788",
|
|
70
|
+
"CRYSTAL_ENV_FILE": "/path/to/openclaw-crystal/mcp-server/.env",
|
|
71
|
+
"CRYSTAL_NODE": "/path/to/node",
|
|
72
|
+
"CRYSTAL_PLUGIN_DIR": "/path/to/openclaw-crystal/plugin",
|
|
73
|
+
"CRYSTAL_ROOT": "/path/to/openclaw-crystal",
|
|
74
|
+
"CRYSTAL_MCP_MODE": "stdio"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"env": {
|
|
79
|
+
"CRYSTAL_MCP_MODE": "sse",
|
|
80
|
+
"CRYSTAL_MCP_HOST": "127.0.0.1",
|
|
81
|
+
"CRYSTAL_MCP_PORT": "8788",
|
|
82
|
+
"CRYSTAL_ENV_FILE": "/path/to/openclaw-crystal/mcp-server/.env"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "crystal-memory",
|
|
3
|
+
"kind": [
|
|
4
|
+
"memory",
|
|
5
|
+
"context-engine"
|
|
6
|
+
],
|
|
7
|
+
"name": "Memory Crystal",
|
|
8
|
+
"description": "Persistent memory for AI agents via Memory Crystal",
|
|
9
|
+
"version": "0.7.4",
|
|
10
|
+
"entry": "index.js",
|
|
11
|
+
"configSchema": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"required": [],
|
|
15
|
+
"properties": {
|
|
16
|
+
"apiKey": {
|
|
17
|
+
"type": "string"
|
|
18
|
+
},
|
|
19
|
+
"convexUrl": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"default": "https://rightful-mockingbird-389.convex.site"
|
|
22
|
+
},
|
|
23
|
+
"defaultRecallMode": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"enum": [
|
|
26
|
+
"general",
|
|
27
|
+
"decision",
|
|
28
|
+
"project",
|
|
29
|
+
"people",
|
|
30
|
+
"workflow",
|
|
31
|
+
"conversation"
|
|
32
|
+
],
|
|
33
|
+
"description": "Default recall mode for this agent. Controls which memory stores and categories are prioritized.",
|
|
34
|
+
"default": "general"
|
|
35
|
+
},
|
|
36
|
+
"defaultRecallLimit": {
|
|
37
|
+
"type": "number",
|
|
38
|
+
"description": "Default number of memories to recall per query.",
|
|
39
|
+
"default": 8,
|
|
40
|
+
"minimum": 1,
|
|
41
|
+
"maximum": 20
|
|
42
|
+
},
|
|
43
|
+
"channelScope": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Optional. When set, all API calls are namespaced as {channelScope}:{peerId}. Use for multi-tenant or per-agent isolation. Example: 'coach'"
|
|
46
|
+
},
|
|
47
|
+
"localSummaryInjection": {
|
|
48
|
+
"type": "boolean",
|
|
49
|
+
"description": "Enable prompt-aware local summary injection. When true, relevant summaries from the local store are injected into the context window based on the current user query.",
|
|
50
|
+
"default": true
|
|
51
|
+
},
|
|
52
|
+
"localSummaryMaxTokens": {
|
|
53
|
+
"type": "number",
|
|
54
|
+
"description": "Maximum tokens to spend on injected relevant local summaries.",
|
|
55
|
+
"default": 2000,
|
|
56
|
+
"minimum": 0,
|
|
57
|
+
"maximum": 8000
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@memorycrystal/crystal-memory",
|
|
3
|
+
"version": "0.7.4",
|
|
4
|
+
"description": "Memory Crystal OpenClaw plugin",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"homepage": "https://memorycrystal.ai",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/memorycrystal/memorycrystal"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/memorycrystal/memorycrystal/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"openclaw",
|
|
17
|
+
"memory",
|
|
18
|
+
"ai",
|
|
19
|
+
"plugin",
|
|
20
|
+
"crystal"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"*.js",
|
|
24
|
+
"*.mjs",
|
|
25
|
+
"*.cjs",
|
|
26
|
+
"*.json",
|
|
27
|
+
"!node_modules"
|
|
28
|
+
],
|
|
29
|
+
"openclaw": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./index.js"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"optionalDependencies": {
|
|
35
|
+
"better-sqlite3": "^12.8.0"
|
|
36
|
+
}
|
|
37
|
+
}
|