@matrixorigin/thememoria 0.4.0 → 0.4.1
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/README.md +109 -263
- package/openclaw/__tests__/client.test.ts +69 -0
- package/openclaw/__tests__/config.test.ts +173 -0
- package/openclaw/__tests__/format.test.ts +149 -0
- package/openclaw/__tests__/helpers.ts +126 -0
- package/openclaw/__tests__/http-client.test.ts +290 -0
- package/openclaw/__tests__/parsers.test.ts +197 -0
- package/openclaw/client.ts +27 -11
- package/openclaw/config.ts +16 -8
- package/openclaw/http-client.ts +453 -0
- package/openclaw/index.ts +55 -32
- package/openclaw.plugin.json +8 -8
- package/package.json +10 -1
- package/scripts/connect_openclaw_memoria.mjs +51 -5
- package/scripts/install-openclaw-memoria.sh +13 -4
- package/scripts/uninstall-openclaw-memoria.sh +7 -7
- package/scripts/verify_plugin_install.mjs +3 -3
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group 3: Cloud API Communication
|
|
3
|
+
* "Does the plugin talk to Memoria API correctly?"
|
|
4
|
+
*
|
|
5
|
+
* Bug 2 regression: no X-User-Id header should be sent (API key determines identity).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
8
|
+
import { MemoriaHttpTransport } from "../http-client.js";
|
|
9
|
+
import { buildApiConfig, mockFetch, type MockFetchCall } from "./helpers.js";
|
|
10
|
+
|
|
11
|
+
let fetchMock: ReturnType<typeof mockFetch>;
|
|
12
|
+
let transport: MemoriaHttpTransport;
|
|
13
|
+
const originalFetch = globalThis.fetch;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
fetchMock = mockFetch();
|
|
17
|
+
transport = new MemoriaHttpTransport(buildApiConfig(), "test-user");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
globalThis.fetch = originalFetch;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("Group 3: Cloud API Communication", () => {
|
|
25
|
+
// ── Constructor & lifecycle ──────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe("Constructor & lifecycle", () => {
|
|
28
|
+
it("3.1 missing apiUrl throws", () => {
|
|
29
|
+
expect(() => new MemoriaHttpTransport(
|
|
30
|
+
buildApiConfig({ apiUrl: undefined }), "u",
|
|
31
|
+
)).toThrow(/apiUrl/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("3.2 missing apiKey throws", () => {
|
|
35
|
+
expect(() => new MemoriaHttpTransport(
|
|
36
|
+
buildApiConfig({ apiKey: undefined }), "u",
|
|
37
|
+
)).toThrow(/apiKey/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("3.3 trailing slash stripped from apiUrl", async () => {
|
|
41
|
+
const t = new MemoriaHttpTransport(
|
|
42
|
+
buildApiConfig({ apiUrl: "https://example.com///" }), "u",
|
|
43
|
+
);
|
|
44
|
+
fetchMock.respondWith(200, { status: "ok" });
|
|
45
|
+
await t.healthCheck();
|
|
46
|
+
expect(fetchMock.lastCall()!.url).toMatch(/^https:\/\/example\.com\/health/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("3.4 isAlive() always returns true", () => {
|
|
50
|
+
expect(transport.isAlive()).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("3.5 close() is a no-op", () => {
|
|
54
|
+
expect(() => transport.close()).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── Auth & headers (Bug 2 regression) ────────────────────
|
|
59
|
+
|
|
60
|
+
describe("Auth & headers", () => {
|
|
61
|
+
it("3.6 every request has Authorization: Bearer <key>", async () => {
|
|
62
|
+
fetchMock.respondWith(200, { results: [] });
|
|
63
|
+
await transport.callTool("memory_retrieve", { query: "test", top_k: 5 });
|
|
64
|
+
expect(fetchMock.lastCall()!.headers["Authorization"]).toBe("Bearer sk-test-key-123");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("3.7 every request has Content-Type: application/json", async () => {
|
|
68
|
+
fetchMock.respondWith(200, { results: [] });
|
|
69
|
+
await transport.callTool("memory_retrieve", { query: "test", top_k: 5 });
|
|
70
|
+
expect(fetchMock.lastCall()!.headers["Content-Type"]).toBe("application/json");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("3.8 NO X-User-Id header sent (Bug 2 fix)", async () => {
|
|
74
|
+
fetchMock.respondWith(200, { results: [] });
|
|
75
|
+
await transport.callTool("memory_retrieve", { query: "test", top_k: 5 });
|
|
76
|
+
expect(fetchMock.lastCall()!.headers["X-User-Id"]).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── Tool → endpoint mapping (table-driven) ──────────────
|
|
81
|
+
|
|
82
|
+
describe("Tool → endpoint mapping", () => {
|
|
83
|
+
const cases: Array<{
|
|
84
|
+
id: string;
|
|
85
|
+
tool: string;
|
|
86
|
+
args: Record<string, unknown>;
|
|
87
|
+
method: string;
|
|
88
|
+
pathPattern: RegExp;
|
|
89
|
+
}> = [
|
|
90
|
+
{ id: "3.9", tool: "memory_store", args: { content: "hi" }, method: "POST", pathPattern: /\/v1\/memories$/ },
|
|
91
|
+
{ id: "3.10", tool: "memory_retrieve", args: { query: "q" }, method: "POST", pathPattern: /\/v1\/memories\/retrieve$/ },
|
|
92
|
+
{ id: "3.11", tool: "memory_search", args: { query: "q" }, method: "POST", pathPattern: /\/v1\/memories\/search$/ },
|
|
93
|
+
{ id: "3.12", tool: "memory_list", args: { limit: 10 }, method: "GET", pathPattern: /\/v1\/memories\?limit=10/ },
|
|
94
|
+
{ id: "3.13", tool: "memory_profile", args: {}, method: "GET", pathPattern: /\/v1\/profiles\/me$/ },
|
|
95
|
+
{ id: "3.14", tool: "memory_correct", args: { memory_id: "abc", new_content: "x" }, method: "PUT", pathPattern: /\/v1\/memories\/abc\/correct$/ },
|
|
96
|
+
{ id: "3.15", tool: "memory_correct", args: { query: "q", new_content: "x" }, method: "POST", pathPattern: /\/v1\/memories\/correct$/ },
|
|
97
|
+
{ id: "3.16", tool: "memory_purge", args: { topic: "old" }, method: "POST", pathPattern: /\/v1\/memories\/purge$/ },
|
|
98
|
+
{ id: "3.17", tool: "memory_observe", args: { messages: [] }, method: "POST", pathPattern: /\/v1\/observe$/ },
|
|
99
|
+
{ id: "3.18", tool: "memory_governance", args: {}, method: "POST", pathPattern: /\/v1\/governance$/ },
|
|
100
|
+
{ id: "3.19", tool: "memory_snapshot", args: { name: "s1" }, method: "POST", pathPattern: /\/v1\/snapshots$/ },
|
|
101
|
+
{ id: "3.20", tool: "memory_snapshots", args: {}, method: "GET", pathPattern: /\/v1\/snapshots\?/ },
|
|
102
|
+
{ id: "3.21", tool: "memory_rollback", args: { name: "s1" }, method: "POST", pathPattern: /\/v1\/snapshots\/s1\/rollback$/ },
|
|
103
|
+
{ id: "3.22", tool: "memory_branch", args: { name: "b1" }, method: "POST", pathPattern: /\/v1\/branches$/ },
|
|
104
|
+
{ id: "3.23", tool: "memory_branches", args: {}, method: "GET", pathPattern: /\/v1\/branches$/ },
|
|
105
|
+
{ id: "3.24", tool: "memory_checkout", args: { name: "b1" }, method: "POST", pathPattern: /\/v1\/branches\/b1\/checkout$/ },
|
|
106
|
+
{ id: "3.25", tool: "memory_branch_delete", args: { name: "b1" }, method: "DELETE", pathPattern: /\/v1\/branches\/b1$/ },
|
|
107
|
+
{ id: "3.26", tool: "memory_merge", args: { source: "b1" }, method: "POST", pathPattern: /\/v1\/branches\/b1\/merge$/ },
|
|
108
|
+
{ id: "3.27", tool: "memory_diff", args: { source: "b1" }, method: "GET", pathPattern: /\/v1\/branches\/b1\/diff\?/ },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
for (const { id, tool, args, method, pathPattern } of cases) {
|
|
112
|
+
it(`${id} ${tool} → ${method} ${pathPattern.source}`, async () => {
|
|
113
|
+
fetchMock.respondWith(200, tool === "memory_store"
|
|
114
|
+
? { memory_id: "new-id", content: args.content ?? "" }
|
|
115
|
+
: tool.includes("retrieve") || tool.includes("search") || tool === "memory_list"
|
|
116
|
+
? { results: [] }
|
|
117
|
+
: tool === "memory_profile"
|
|
118
|
+
? { profile: "test" }
|
|
119
|
+
: tool === "memory_purge"
|
|
120
|
+
? { purged: 1 }
|
|
121
|
+
: tool === "memory_correct"
|
|
122
|
+
? { memory_id: "abc", content: "x" }
|
|
123
|
+
: tool === "memory_snapshots"
|
|
124
|
+
? { snapshots: [] }
|
|
125
|
+
: tool === "memory_branches"
|
|
126
|
+
? { branches: [] }
|
|
127
|
+
: { ok: true });
|
|
128
|
+
await transport.callTool(tool, args);
|
|
129
|
+
const call = fetchMock.lastCall()!;
|
|
130
|
+
expect(call.method).toBe(method);
|
|
131
|
+
expect(call.url).toMatch(pathPattern);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
it("3.28 memory_rebuild_index returns cloud message, no HTTP call", async () => {
|
|
136
|
+
fetchMock.reset();
|
|
137
|
+
const result = await transport.callTool("memory_rebuild_index", {});
|
|
138
|
+
expect(fetchMock.calls.length).toBe(0);
|
|
139
|
+
const text = JSON.parse((result as any).content[0].text);
|
|
140
|
+
expect(text.message).toMatch(/cloud/i);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("3.29 healthCheck → GET /health/instance", async () => {
|
|
144
|
+
fetchMock.respondWith(200, { status: "ok", instance_id: "i-1", db: true });
|
|
145
|
+
const result = await transport.healthCheck();
|
|
146
|
+
expect(fetchMock.lastCall()!.method).toBe("GET");
|
|
147
|
+
expect(fetchMock.lastCall()!.url).toMatch(/\/health\/instance$/);
|
|
148
|
+
expect(result.status).toBe("ok");
|
|
149
|
+
expect(result.instance_id).toBe("i-1");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("3.30 unknown tool throws", async () => {
|
|
153
|
+
await expect(transport.callTool("memory_nonexistent", {}))
|
|
154
|
+
.rejects.toThrow(/Unknown Memoria tool/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ── MCP envelope compatibility ───────────────────────────
|
|
159
|
+
|
|
160
|
+
describe("MCP envelope", () => {
|
|
161
|
+
it("3.31 callTool wraps result in MCP content block", async () => {
|
|
162
|
+
fetchMock.respondWith(200, { memory_id: "x", content: "hello" });
|
|
163
|
+
const result = await transport.callTool("memory_store", { content: "hello" }) as any;
|
|
164
|
+
expect(result.content).toBeInstanceOf(Array);
|
|
165
|
+
expect(result.content[0].type).toBe("text");
|
|
166
|
+
expect(typeof result.content[0].text).toBe("string");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("3.32 string result wrapped as-is", async () => {
|
|
170
|
+
fetchMock.respondWith(200, { profile: "User likes cats" });
|
|
171
|
+
const result = await transport.callTool("memory_profile", {}) as any;
|
|
172
|
+
expect(result.content[0].text).toBe("User likes cats");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── Response text formatting ─────────────────────────────
|
|
177
|
+
|
|
178
|
+
describe("Response text formatting", () => {
|
|
179
|
+
it("3.34 memory_store → 'Stored memory <id>: <content>'", async () => {
|
|
180
|
+
fetchMock.respondWith(200, { memory_id: "m1", content: "hello world" });
|
|
181
|
+
const result = await transport.callTool("memory_store", { content: "hello world" }) as any;
|
|
182
|
+
expect(result.content[0].text).toBe("Stored memory m1: hello world");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("3.35 memory_retrieve with results → '[id] (type) content' per line", async () => {
|
|
186
|
+
fetchMock.respondWith(200, {
|
|
187
|
+
results: [
|
|
188
|
+
{ memory_id: "m1", memory_type: "semantic", content: "First" },
|
|
189
|
+
{ memory_id: "m2", memory_type: "profile", content: "Second" },
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
const result = await transport.callTool("memory_retrieve", { query: "q" }) as any;
|
|
193
|
+
const text = result.content[0].text;
|
|
194
|
+
expect(text).toContain("[m1] (semantic) First");
|
|
195
|
+
expect(text).toContain("[m2] (profile) Second");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("3.36 memory_retrieve empty → 'No relevant memories found.'", async () => {
|
|
199
|
+
fetchMock.respondWith(200, { results: [] });
|
|
200
|
+
const result = await transport.callTool("memory_retrieve", { query: "q" }) as any;
|
|
201
|
+
expect(result.content[0].text).toBe("No relevant memories found.");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("3.38 memory_profile with profile → profile text", async () => {
|
|
205
|
+
fetchMock.respondWith(200, { profile: "Likes TypeScript" });
|
|
206
|
+
const result = await transport.callTool("memory_profile", {}) as any;
|
|
207
|
+
expect(result.content[0].text).toBe("Likes TypeScript");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("3.39 memory_profile no profile → 'No profile memories found.'", async () => {
|
|
211
|
+
fetchMock.respondWith(200, { profile: null });
|
|
212
|
+
const result = await transport.callTool("memory_profile", {}) as any;
|
|
213
|
+
expect(result.content[0].text).toBe("No profile memories found.");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("3.40 memory_correct → 'Corrected memory <id>: <content>'", async () => {
|
|
217
|
+
fetchMock.respondWith(200, { memory_id: "m1", content: "updated" });
|
|
218
|
+
const result = await transport.callTool("memory_correct", {
|
|
219
|
+
memory_id: "m1", new_content: "updated",
|
|
220
|
+
}) as any;
|
|
221
|
+
expect(result.content[0].text).toBe("Corrected memory m1: updated");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("3.41 memory_purge → 'Purged N memory(ies).'", async () => {
|
|
225
|
+
fetchMock.respondWith(200, { purged: 3 });
|
|
226
|
+
const result = await transport.callTool("memory_purge", { topic: "old" }) as any;
|
|
227
|
+
expect(result.content[0].text).toBe("Purged 3 memory(ies).");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("3.42 memory_snapshots with items → formatted list", async () => {
|
|
231
|
+
fetchMock.respondWith(200, {
|
|
232
|
+
snapshots: [
|
|
233
|
+
{ name: "snap1", timestamp: "2026-03-23T10:00:00Z" },
|
|
234
|
+
{ name: "snap2", timestamp: "2026-03-23T11:00:00Z" },
|
|
235
|
+
],
|
|
236
|
+
});
|
|
237
|
+
const result = await transport.callTool("memory_snapshots", {}) as any;
|
|
238
|
+
const text = result.content[0].text;
|
|
239
|
+
expect(text).toContain("Snapshots (2):");
|
|
240
|
+
expect(text).toContain("snap1 (2026-03-23T10:00:00Z)");
|
|
241
|
+
expect(text).toContain("snap2 (2026-03-23T11:00:00Z)");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("3.43 memory_snapshots empty → 'Snapshots (0):'", async () => {
|
|
245
|
+
fetchMock.respondWith(200, { snapshots: [] });
|
|
246
|
+
const result = await transport.callTool("memory_snapshots", {}) as any;
|
|
247
|
+
expect(result.content[0].text).toBe("Snapshots (0):");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("3.44 memory_branches with items → formatted list with active marker", async () => {
|
|
251
|
+
fetchMock.respondWith(200, {
|
|
252
|
+
branches: [
|
|
253
|
+
{ name: "main", active: true },
|
|
254
|
+
{ name: "experiment", active: false },
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
const result = await transport.callTool("memory_branches", {}) as any;
|
|
258
|
+
const text = result.content[0].text;
|
|
259
|
+
expect(text).toContain("Branches:");
|
|
260
|
+
expect(text).toContain("main ← active");
|
|
261
|
+
expect(text).toContain("experiment");
|
|
262
|
+
expect(text).not.toContain("experiment ← active");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("3.45 memory_branches empty → default main active", async () => {
|
|
266
|
+
fetchMock.respondWith(200, { branches: [] });
|
|
267
|
+
const result = await transport.callTool("memory_branches", {}) as any;
|
|
268
|
+
expect(result.content[0].text).toContain("main ← active");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ── Error handling ───────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
describe("Error handling", () => {
|
|
275
|
+
it("3.46 non-2xx HTTP → error with method, path, status", async () => {
|
|
276
|
+
fetchMock.respondWith(500, "Internal Server Error");
|
|
277
|
+
await expect(transport.callTool("memory_retrieve", { query: "q" }))
|
|
278
|
+
.rejects.toThrow(/POST.*\/v1\/memories\/retrieve.*500/);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("3.48 empty response body → { ok: true }", async () => {
|
|
282
|
+
// Override fetch to return empty body
|
|
283
|
+
globalThis.fetch = async () => new Response("", { status: 200 });
|
|
284
|
+
const t = new MemoriaHttpTransport(buildApiConfig(), "u");
|
|
285
|
+
const result = await t.callTool("memory_consolidate", {}) as any;
|
|
286
|
+
// Should not throw, result wraps { ok: true }
|
|
287
|
+
expect(result.content[0].text).toContain("ok");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group 4: Response Parsing
|
|
3
|
+
* "Can the plugin understand what Memoria returns?"
|
|
4
|
+
*
|
|
5
|
+
* Tests the text parsers that bridge Memoria's output to typed data structures.
|
|
6
|
+
* These parsers are used by both embedded (MCP binary) and api (HTTP) modes.
|
|
7
|
+
*
|
|
8
|
+
* Note: parsers are not exported from client.ts, so we test them indirectly
|
|
9
|
+
* through MemoriaClient methods with a mocked transport.
|
|
10
|
+
* For direct parser testing, we import the module and use the internal functions
|
|
11
|
+
* via a re-export helper or test the formatted text round-trip.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
14
|
+
import { MemoriaHttpTransport } from "../http-client.js";
|
|
15
|
+
import { buildApiConfig, mockFetch } from "./helpers.js";
|
|
16
|
+
|
|
17
|
+
// We test parsers indirectly: http-client produces formatted text that
|
|
18
|
+
// matches the Rust MCP binary format, and client.ts parsers consume it.
|
|
19
|
+
// Here we verify the http-client output IS parseable by checking the
|
|
20
|
+
// text format matches the expected patterns.
|
|
21
|
+
|
|
22
|
+
const originalFetch = globalThis.fetch;
|
|
23
|
+
let fetchHelper: ReturnType<typeof mockFetch>;
|
|
24
|
+
|
|
25
|
+
function setup() {
|
|
26
|
+
fetchHelper = mockFetch();
|
|
27
|
+
return new MemoriaHttpTransport(buildApiConfig(), "test-user");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function teardown() {
|
|
31
|
+
globalThis.fetch = originalFetch;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("Group 4: Response Parsing (format round-trip)", () => {
|
|
35
|
+
afterEach(teardown);
|
|
36
|
+
|
|
37
|
+
// ── Memory list parsing ──────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe("Memory list text format", () => {
|
|
40
|
+
it("4.1 single memory → [id] (type) content", async () => {
|
|
41
|
+
const t = setup();
|
|
42
|
+
fetchHelper.respondWith(200, {
|
|
43
|
+
results: [{ memory_id: "abc-123", memory_type: "semantic", content: "Hello world" }],
|
|
44
|
+
});
|
|
45
|
+
const result = await t.callTool("memory_retrieve", { query: "q" }) as any;
|
|
46
|
+
const text = result.content[0].text;
|
|
47
|
+
expect(text).toMatch(/^\[abc-123\] \(semantic\) Hello world$/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("4.2 multiple memories → one per line", async () => {
|
|
51
|
+
const t = setup();
|
|
52
|
+
fetchHelper.respondWith(200, {
|
|
53
|
+
results: [
|
|
54
|
+
{ memory_id: "m1", memory_type: "semantic", content: "First" },
|
|
55
|
+
{ memory_id: "m2", memory_type: "profile", content: "Second" },
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
const result = await t.callTool("memory_search", { query: "q" }) as any;
|
|
59
|
+
const lines = result.content[0].text.split("\n");
|
|
60
|
+
expect(lines).toHaveLength(2);
|
|
61
|
+
expect(lines[0]).toMatch(/^\[m1\] \(semantic\) First$/);
|
|
62
|
+
expect(lines[1]).toMatch(/^\[m2\] \(profile\) Second$/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("4.3 empty results → 'No relevant memories found.'", async () => {
|
|
66
|
+
const t = setup();
|
|
67
|
+
fetchHelper.respondWith(200, { results: [] });
|
|
68
|
+
const result = await t.callTool("memory_retrieve", { query: "q" }) as any;
|
|
69
|
+
expect(result.content[0].text).toBe("No relevant memories found.");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("4.4 array response shape (no results wrapper)", async () => {
|
|
73
|
+
const t = setup();
|
|
74
|
+
fetchHelper.respondWith(200, [
|
|
75
|
+
{ memory_id: "m1", memory_type: "semantic", content: "Direct array" },
|
|
76
|
+
]);
|
|
77
|
+
const result = await t.callTool("memory_retrieve", { query: "q" }) as any;
|
|
78
|
+
expect(result.content[0].text).toContain("[m1] (semantic) Direct array");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── Store/correct/purge parsing ──────────────────────────
|
|
83
|
+
|
|
84
|
+
describe("Store/correct/purge text format", () => {
|
|
85
|
+
it("4.5 store → 'Stored memory <id>: <content>'", async () => {
|
|
86
|
+
const t = setup();
|
|
87
|
+
fetchHelper.respondWith(200, { memory_id: "new-1", content: "Test memory" });
|
|
88
|
+
const result = await t.callTool("memory_store", { content: "Test memory" }) as any;
|
|
89
|
+
expect(result.content[0].text).toBe("Stored memory new-1: Test memory");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("4.6 store with missing id → uses empty string", async () => {
|
|
93
|
+
const t = setup();
|
|
94
|
+
fetchHelper.respondWith(200, { content: "No id returned" });
|
|
95
|
+
const result = await t.callTool("memory_store", { content: "No id returned" }) as any;
|
|
96
|
+
expect(result.content[0].text).toBe("Stored memory : No id returned");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("4.7 correct by id → 'Corrected memory <id>: <content>'", async () => {
|
|
100
|
+
const t = setup();
|
|
101
|
+
fetchHelper.respondWith(200, { memory_id: "m1", content: "Fixed" });
|
|
102
|
+
const result = await t.callTool("memory_correct", {
|
|
103
|
+
memory_id: "m1", new_content: "Fixed",
|
|
104
|
+
}) as any;
|
|
105
|
+
expect(result.content[0].text).toBe("Corrected memory m1: Fixed");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("4.8 correct by query → same format", async () => {
|
|
109
|
+
const t = setup();
|
|
110
|
+
fetchHelper.respondWith(200, { memory_id: "m2", content: "Updated" });
|
|
111
|
+
const result = await t.callTool("memory_correct", {
|
|
112
|
+
query: "old content", new_content: "Updated",
|
|
113
|
+
}) as any;
|
|
114
|
+
expect(result.content[0].text).toBe("Corrected memory m2: Updated");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("4.9 purge → 'Purged N memory(ies).'", async () => {
|
|
118
|
+
const t = setup();
|
|
119
|
+
fetchHelper.respondWith(200, { purged: 5 });
|
|
120
|
+
const result = await t.callTool("memory_purge", { topic: "test" }) as any;
|
|
121
|
+
expect(result.content[0].text).toBe("Purged 5 memory(ies).");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("4.10 purge with zero → 'Purged 0 memory(ies).'", async () => {
|
|
125
|
+
const t = setup();
|
|
126
|
+
fetchHelper.respondWith(200, { purged: 0 });
|
|
127
|
+
const result = await t.callTool("memory_purge", { topic: "nothing" }) as any;
|
|
128
|
+
expect(result.content[0].text).toBe("Purged 0 memory(ies).");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── Snapshot list parsing ────────────────────────────────
|
|
133
|
+
|
|
134
|
+
describe("Snapshot list text format", () => {
|
|
135
|
+
it("4.11 snapshots → 'Snapshots (N):\\n name (ts)'", async () => {
|
|
136
|
+
const t = setup();
|
|
137
|
+
fetchHelper.respondWith(200, {
|
|
138
|
+
snapshots: [
|
|
139
|
+
{ name: "before-refactor", timestamp: "2026-03-23T10:00:00Z" },
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
const result = await t.callTool("memory_snapshots", {}) as any;
|
|
143
|
+
const text = result.content[0].text;
|
|
144
|
+
expect(text).toMatch(/^Snapshots \(1\):/);
|
|
145
|
+
expect(text).toContain("before-refactor (2026-03-23T10:00:00Z)");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("4.12 empty snapshots → 'Snapshots (0):'", async () => {
|
|
149
|
+
const t = setup();
|
|
150
|
+
fetchHelper.respondWith(200, { snapshots: [] });
|
|
151
|
+
const result = await t.callTool("memory_snapshots", {}) as any;
|
|
152
|
+
expect(result.content[0].text).toBe("Snapshots (0):");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── Branch list parsing ──────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe("Branch list text format", () => {
|
|
159
|
+
it("4.14 branches with active marker", async () => {
|
|
160
|
+
const t = setup();
|
|
161
|
+
fetchHelper.respondWith(200, {
|
|
162
|
+
branches: [
|
|
163
|
+
{ name: "main", active: true },
|
|
164
|
+
{ name: "experiment", active: false },
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
const result = await t.callTool("memory_branches", {}) as any;
|
|
168
|
+
const text = result.content[0].text;
|
|
169
|
+
expect(text).toContain("main ← active");
|
|
170
|
+
expect(text).toMatch(/^\s*experiment$/m);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("4.15 branches with no active → main defaults active", async () => {
|
|
174
|
+
// When API returns no active flag, formatBranchList just passes through
|
|
175
|
+
// The client-side parseBranches defaults main to active
|
|
176
|
+
const t = setup();
|
|
177
|
+
fetchHelper.respondWith(200, {
|
|
178
|
+
branches: [
|
|
179
|
+
{ name: "main", active: false },
|
|
180
|
+
{ name: "dev", active: false },
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
const result = await t.callTool("memory_branches", {}) as any;
|
|
184
|
+
const text = result.content[0].text;
|
|
185
|
+
// formatBranchList respects the active flag from API
|
|
186
|
+
expect(text).toContain("Branches:");
|
|
187
|
+
expect(text).toContain("main");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("4.16 empty branches → default main active", async () => {
|
|
191
|
+
const t = setup();
|
|
192
|
+
fetchHelper.respondWith(200, { branches: [] });
|
|
193
|
+
const result = await t.callTool("memory_branches", {}) as any;
|
|
194
|
+
expect(result.content[0].text).toContain("main ← active");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
package/openclaw/client.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
MemoriaTrustTier,
|
|
7
7
|
} from "./config.js";
|
|
8
8
|
import { MEMORIA_MEMORY_TYPES } from "./config.js";
|
|
9
|
+
import { MemoriaHttpTransport } from "./http-client.js";
|
|
9
10
|
|
|
10
11
|
export type MemoriaMemoryRecord = {
|
|
11
12
|
memory_id: string;
|
|
@@ -85,7 +86,7 @@ type McpContentBlock = {
|
|
|
85
86
|
};
|
|
86
87
|
|
|
87
88
|
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
|
88
|
-
const PLUGIN_VERSION = "0.
|
|
89
|
+
const PLUGIN_VERSION = "0.4.0";
|
|
89
90
|
const MEMORY_LINE_RE = /^\[([^\]]+)\] \(([^)]+)\) ?([\s\S]*)$/;
|
|
90
91
|
|
|
91
92
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
@@ -425,13 +426,8 @@ class MemoriaMcpSession {
|
|
|
425
426
|
|
|
426
427
|
private buildArgs(): string[] {
|
|
427
428
|
const args = ["mcp"];
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
args.push("--token", this.config.apiKey!);
|
|
431
|
-
args.push("--user", this.userId);
|
|
432
|
-
return args;
|
|
433
|
-
}
|
|
434
|
-
|
|
429
|
+
// MemoriaMcpSession is only used for embedded mode now.
|
|
430
|
+
// API mode uses MemoriaHttpTransport directly.
|
|
435
431
|
args.push("--db-url", this.config.dbUrl);
|
|
436
432
|
args.push("--user", this.userId);
|
|
437
433
|
return args;
|
|
@@ -543,7 +539,7 @@ class MemoriaMcpSession {
|
|
|
543
539
|
}
|
|
544
540
|
|
|
545
541
|
export class MemoriaClient {
|
|
546
|
-
private readonly sessions = new Map<string, MemoriaMcpSession>();
|
|
542
|
+
private readonly sessions = new Map<string, MemoriaMcpSession | MemoriaHttpTransport>();
|
|
547
543
|
private readonly memoryCache = new Map<string, MemoriaMemoryRecord>();
|
|
548
544
|
|
|
549
545
|
constructor(private readonly config: MemoriaPluginConfig) {}
|
|
@@ -556,6 +552,17 @@ export class MemoriaClient {
|
|
|
556
552
|
}
|
|
557
553
|
|
|
558
554
|
async health(userId: string) {
|
|
555
|
+
if (this.config.backend === "api") {
|
|
556
|
+
const transport = this.getSession(userId) as MemoriaHttpTransport;
|
|
557
|
+
const result = await transport.healthCheck();
|
|
558
|
+
return {
|
|
559
|
+
status: result.status,
|
|
560
|
+
mode: this.config.backend,
|
|
561
|
+
instance_id: result.instance_id,
|
|
562
|
+
db: result.db,
|
|
563
|
+
warnings: [],
|
|
564
|
+
};
|
|
565
|
+
}
|
|
559
566
|
await this.callToolText(userId, "memory_list", { limit: 1 });
|
|
560
567
|
return {
|
|
561
568
|
status: "ok",
|
|
@@ -865,6 +872,9 @@ export class MemoriaClient {
|
|
|
865
872
|
}
|
|
866
873
|
|
|
867
874
|
async rebuildIndex(table: string) {
|
|
875
|
+
if (this.config.backend === "api") {
|
|
876
|
+
return { message: "rebuild_index is managed by the cloud service and not available via API." };
|
|
877
|
+
}
|
|
868
878
|
return parseGenericResult(
|
|
869
879
|
await this.callToolText(this.config.defaultUserId, "memory_rebuild_index", {
|
|
870
880
|
table,
|
|
@@ -1016,14 +1026,20 @@ export class MemoriaClient {
|
|
|
1016
1026
|
throw new Error(`Memoria tool '${name}' returned no text content.`);
|
|
1017
1027
|
}
|
|
1018
1028
|
|
|
1019
|
-
private getSession(userId: string): MemoriaMcpSession {
|
|
1029
|
+
private getSession(userId: string): MemoriaMcpSession | MemoriaHttpTransport {
|
|
1020
1030
|
const key = `${this.config.backend}:${userId}`;
|
|
1021
1031
|
const existing = this.sessions.get(key);
|
|
1022
1032
|
if (existing?.isAlive()) {
|
|
1023
1033
|
return existing;
|
|
1024
1034
|
}
|
|
1025
1035
|
existing?.close();
|
|
1026
|
-
|
|
1036
|
+
|
|
1037
|
+
let created: MemoriaMcpSession | MemoriaHttpTransport;
|
|
1038
|
+
if (this.config.backend === "api") {
|
|
1039
|
+
created = new MemoriaHttpTransport(this.config, userId);
|
|
1040
|
+
} else {
|
|
1041
|
+
created = new MemoriaMcpSession(this.config, userId);
|
|
1042
|
+
}
|
|
1027
1043
|
this.sessions.set(key, created);
|
|
1028
1044
|
return created;
|
|
1029
1045
|
}
|
package/openclaw/config.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const MEMORIA_MEMORY_TYPES = [
|
|
|
9
9
|
] as const;
|
|
10
10
|
|
|
11
11
|
export const MEMORIA_TRUST_TIERS = ["T1", "T2", "T3", "T4"] as const;
|
|
12
|
-
export const MEMORIA_BACKENDS = ["embedded", "
|
|
12
|
+
export const MEMORIA_BACKENDS = ["embedded", "api"] as const;
|
|
13
13
|
export const MEMORIA_USER_ID_STRATEGIES = ["config", "agentId", "sessionKey"] as const;
|
|
14
14
|
|
|
15
15
|
export type MemoriaMemoryType = (typeof MEMORIA_MEMORY_TYPES)[number];
|
|
@@ -84,7 +84,7 @@ const UI_HINTS: Record<
|
|
|
84
84
|
> = {
|
|
85
85
|
backend: {
|
|
86
86
|
label: "Backend Mode",
|
|
87
|
-
help: "embedded runs the Rust memoria CLI locally against MatrixOne;
|
|
87
|
+
help: "embedded runs the Rust memoria CLI locally against MatrixOne; api connects directly to the Memoria REST API over HTTP.",
|
|
88
88
|
placeholder: DEFAULTS.backend,
|
|
89
89
|
},
|
|
90
90
|
dbUrl: {
|
|
@@ -94,14 +94,14 @@ const UI_HINTS: Record<
|
|
|
94
94
|
},
|
|
95
95
|
apiUrl: {
|
|
96
96
|
label: "Memoria API URL",
|
|
97
|
-
help: "Only used when backend=
|
|
97
|
+
help: "Only used when backend=api.",
|
|
98
98
|
placeholder: DEFAULTS.apiUrl,
|
|
99
99
|
},
|
|
100
100
|
apiKey: {
|
|
101
101
|
label: "Memoria API Token",
|
|
102
|
-
help: "Bearer token for backend=
|
|
102
|
+
help: "Bearer token for backend=api.",
|
|
103
103
|
sensitive: true,
|
|
104
|
-
placeholder: "
|
|
104
|
+
placeholder: "sk-...",
|
|
105
105
|
},
|
|
106
106
|
memoriaExecutable: {
|
|
107
107
|
label: "Memoria Executable",
|
|
@@ -511,18 +511,26 @@ export function parseMemoriaPluginConfig(value: unknown): MemoriaPluginConfig {
|
|
|
511
511
|
const input = asObject(value ?? {});
|
|
512
512
|
assertNoUnknownKeys(input);
|
|
513
513
|
|
|
514
|
+
// Reject legacy "http" backend — it was removed in favor of "api"
|
|
515
|
+
if (typeof input.backend === "string" && input.backend.trim().toLowerCase() === "http") {
|
|
516
|
+
fail(
|
|
517
|
+
"backend 'http' is no longer supported. Use 'api' for cloud mode (direct HTTP to Memoria REST API, no binary needed).",
|
|
518
|
+
["backend"],
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
514
522
|
const backend = readEnum(input, "backend", MEMORIA_BACKENDS, DEFAULTS.backend);
|
|
515
523
|
const apiUrl = optional(readString(input, "apiUrl", { defaultValue: DEFAULTS.apiUrl }))?.replace(
|
|
516
524
|
/\/+$/,
|
|
517
525
|
"",
|
|
518
526
|
);
|
|
519
527
|
const apiKey = optional(readString(input, "apiKey"));
|
|
520
|
-
if (backend === "
|
|
528
|
+
if (backend === "api") {
|
|
521
529
|
if (!apiUrl) {
|
|
522
|
-
fail("apiUrl required when backend=
|
|
530
|
+
fail("apiUrl required when backend=api", ["apiUrl"]);
|
|
523
531
|
}
|
|
524
532
|
if (!apiKey) {
|
|
525
|
-
fail("apiKey required when backend=
|
|
533
|
+
fail("apiKey required when backend=api", ["apiKey"]);
|
|
526
534
|
}
|
|
527
535
|
}
|
|
528
536
|
|