@matrixorigin/thememoria 0.4.0 → 0.4.2
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 +296 -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 +454 -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,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoriaHttpTransport — direct HTTP client for the Memoria REST API.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the MCP stdio bridge (MemoriaMcpSession) when backend === "api".
|
|
5
|
+
* No Rust binary needed. Uses native fetch().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { MemoriaPluginConfig } from "./config.js";
|
|
9
|
+
|
|
10
|
+
export class MemoriaHttpTransport {
|
|
11
|
+
private readonly apiUrl: string;
|
|
12
|
+
private readonly apiKey: string;
|
|
13
|
+
private readonly timeoutMs: number;
|
|
14
|
+
|
|
15
|
+
constructor(config: MemoriaPluginConfig, private readonly userId: string) {
|
|
16
|
+
if (!config.apiUrl) {
|
|
17
|
+
throw new Error("apiUrl is required for api backend mode");
|
|
18
|
+
}
|
|
19
|
+
if (!config.apiKey) {
|
|
20
|
+
throw new Error("apiKey is required for api backend mode");
|
|
21
|
+
}
|
|
22
|
+
this.apiUrl = config.apiUrl.replace(/\/+$/, "");
|
|
23
|
+
this.apiKey = config.apiKey;
|
|
24
|
+
this.timeoutMs = config.timeoutMs;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Matches MemoriaMcpSession.isAlive() — HTTP transport is always "alive". */
|
|
28
|
+
isAlive(): boolean {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** No-op for HTTP transport (no child process to kill). */
|
|
33
|
+
close(): void {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Unified entry point that mirrors MemoriaMcpSession.callTool().
|
|
37
|
+
* Maps MCP tool names to REST API calls and returns the same
|
|
38
|
+
* text-content structure the MCP session would return.
|
|
39
|
+
*/
|
|
40
|
+
async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
41
|
+
const result = await this.dispatch(name, args);
|
|
42
|
+
// Wrap in MCP-compatible content block so extractToolText() works unchanged
|
|
43
|
+
return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── REST dispatch ──────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
private async dispatch(tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
49
|
+
switch (tool) {
|
|
50
|
+
case "memory_store":
|
|
51
|
+
return this.store(args);
|
|
52
|
+
case "memory_retrieve":
|
|
53
|
+
return this.retrieve(args);
|
|
54
|
+
case "memory_search":
|
|
55
|
+
return this.search(args);
|
|
56
|
+
case "memory_list":
|
|
57
|
+
return this.list(args);
|
|
58
|
+
case "memory_profile":
|
|
59
|
+
return this.profile();
|
|
60
|
+
case "memory_correct":
|
|
61
|
+
return this.correct(args);
|
|
62
|
+
case "memory_purge":
|
|
63
|
+
return this.purge(args);
|
|
64
|
+
case "memory_observe":
|
|
65
|
+
return this.observe(args);
|
|
66
|
+
case "memory_governance":
|
|
67
|
+
return this.governance(args);
|
|
68
|
+
case "memory_consolidate":
|
|
69
|
+
return this.consolidate(args);
|
|
70
|
+
case "memory_reflect":
|
|
71
|
+
return this.reflect(args);
|
|
72
|
+
case "memory_extract_entities":
|
|
73
|
+
return this.extractEntities(args);
|
|
74
|
+
case "memory_link_entities":
|
|
75
|
+
return this.linkEntities(args);
|
|
76
|
+
case "memory_rebuild_index":
|
|
77
|
+
return { message: "rebuild_index is managed by the cloud service and not available via API." };
|
|
78
|
+
case "memory_snapshot":
|
|
79
|
+
return this.createSnapshot(args);
|
|
80
|
+
case "memory_snapshots":
|
|
81
|
+
return this.listSnapshots(args);
|
|
82
|
+
case "memory_rollback":
|
|
83
|
+
return this.rollbackSnapshot(args);
|
|
84
|
+
case "memory_branch":
|
|
85
|
+
return this.branchCreate(args);
|
|
86
|
+
case "memory_branches":
|
|
87
|
+
return this.branchList();
|
|
88
|
+
case "memory_checkout":
|
|
89
|
+
return this.branchCheckout(args);
|
|
90
|
+
case "memory_branch_delete":
|
|
91
|
+
return this.branchDelete(args);
|
|
92
|
+
case "memory_merge":
|
|
93
|
+
return this.branchMerge(args);
|
|
94
|
+
case "memory_diff":
|
|
95
|
+
return this.branchDiff(args);
|
|
96
|
+
default:
|
|
97
|
+
throw new Error(`Unknown Memoria tool: ${tool}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Memory CRUD ────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
private async store(args: Record<string, unknown>) {
|
|
104
|
+
const body: Record<string, unknown> = { content: args.content };
|
|
105
|
+
if (args.memory_type) body.memory_type = args.memory_type;
|
|
106
|
+
if (args.session_id) body.session_id = args.session_id;
|
|
107
|
+
if (args.trust_tier) body.trust_tier = args.trust_tier;
|
|
108
|
+
const data = await this.post("/v1/memories", body);
|
|
109
|
+
const rec = this.asRecord(data);
|
|
110
|
+
const id = rec?.memory_id ?? "";
|
|
111
|
+
const content = rec?.content ?? args.content ?? "";
|
|
112
|
+
return `Stored memory ${id}: ${content}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async retrieve(args: Record<string, unknown>) {
|
|
116
|
+
const body: Record<string, unknown> = {
|
|
117
|
+
query: args.query,
|
|
118
|
+
top_k: args.top_k ?? 5,
|
|
119
|
+
};
|
|
120
|
+
if (args.session_id) body.session_id = args.session_id;
|
|
121
|
+
const data = await this.post("/v1/memories/retrieve", body);
|
|
122
|
+
return this.formatMemoryList(data);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async search(args: Record<string, unknown>) {
|
|
126
|
+
const body: Record<string, unknown> = {
|
|
127
|
+
query: args.query,
|
|
128
|
+
top_k: args.top_k ?? 10,
|
|
129
|
+
};
|
|
130
|
+
const data = await this.post("/v1/memories/search", body);
|
|
131
|
+
return this.formatMemoryList(data);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async list(args: Record<string, unknown>) {
|
|
135
|
+
const limit = typeof args.limit === "number" ? args.limit : 100;
|
|
136
|
+
const data = await this.get(`/v1/memories?limit=${limit}`);
|
|
137
|
+
const rec = this.asRecord(data);
|
|
138
|
+
const items = Array.isArray(rec?.items) ? rec!.items : Array.isArray(data) ? data : [];
|
|
139
|
+
return this.formatMemoryItems(items);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async profile() {
|
|
143
|
+
const data = await this.get("/v1/profiles/me");
|
|
144
|
+
const rec = this.asRecord(data);
|
|
145
|
+
if (rec?.profile && typeof rec.profile === "string") {
|
|
146
|
+
return rec.profile;
|
|
147
|
+
}
|
|
148
|
+
return "No profile memories found.";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async correct(args: Record<string, unknown>) {
|
|
152
|
+
// Correct by ID or by query
|
|
153
|
+
if (args.memory_id && typeof args.memory_id === "string") {
|
|
154
|
+
const body: Record<string, unknown> = {
|
|
155
|
+
new_content: args.new_content,
|
|
156
|
+
};
|
|
157
|
+
if (args.reason) body.reason = args.reason;
|
|
158
|
+
const data = await this.put(`/v1/memories/${args.memory_id}/correct`, body);
|
|
159
|
+
const rec = this.asRecord(data);
|
|
160
|
+
return `Corrected memory ${rec?.memory_id ?? args.memory_id}: ${rec?.content ?? args.new_content}`;
|
|
161
|
+
}
|
|
162
|
+
// Correct by query
|
|
163
|
+
const body: Record<string, unknown> = {
|
|
164
|
+
query: args.query,
|
|
165
|
+
new_content: args.new_content,
|
|
166
|
+
};
|
|
167
|
+
if (args.reason) body.reason = args.reason;
|
|
168
|
+
const data = await this.post("/v1/memories/correct", body);
|
|
169
|
+
const rec = this.asRecord(data);
|
|
170
|
+
return `Corrected memory ${rec?.memory_id ?? ""}: ${rec?.content ?? args.new_content}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async purge(args: Record<string, unknown>) {
|
|
174
|
+
const body: Record<string, unknown> = {};
|
|
175
|
+
if (args.memory_id) body.memory_ids = [args.memory_id];
|
|
176
|
+
if (args.topic) body.topic = args.topic;
|
|
177
|
+
if (args.reason) body.reason = args.reason;
|
|
178
|
+
const data = await this.post("/v1/memories/purge", body);
|
|
179
|
+
const rec = this.asRecord(data);
|
|
180
|
+
const count = typeof rec?.purged === "number" ? rec.purged : 0;
|
|
181
|
+
return `Purged ${count} memory(ies).`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async observe(args: Record<string, unknown>) {
|
|
185
|
+
const body: Record<string, unknown> = {
|
|
186
|
+
messages: args.messages,
|
|
187
|
+
};
|
|
188
|
+
if (args.session_id) body.session_id = args.session_id;
|
|
189
|
+
const data = await this.post("/v1/observe", body);
|
|
190
|
+
return JSON.stringify(data);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Governance / Graph ──────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
private async governance(args: Record<string, unknown>) {
|
|
196
|
+
return this.post("/v1/governance", { force: args.force ?? false });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async consolidate(args: Record<string, unknown>) {
|
|
200
|
+
return this.post("/v1/consolidate", { force: args.force ?? false });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async reflect(args: Record<string, unknown>) {
|
|
204
|
+
return this.post("/v1/reflect", {
|
|
205
|
+
force: args.force ?? false,
|
|
206
|
+
mode: args.mode ?? "auto",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async extractEntities(args: Record<string, unknown>) {
|
|
211
|
+
return this.post("/v1/extract-entities", { mode: args.mode ?? "auto" });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async linkEntities(args: Record<string, unknown>) {
|
|
215
|
+
let entities = args.entities;
|
|
216
|
+
if (typeof entities === "string") {
|
|
217
|
+
try { entities = JSON.parse(entities); } catch { /* keep as-is */ }
|
|
218
|
+
}
|
|
219
|
+
return this.post("/v1/extract-entities/link", { entities });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Snapshots ─────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
private async createSnapshot(args: Record<string, unknown>) {
|
|
225
|
+
const body: Record<string, unknown> = { name: args.name };
|
|
226
|
+
if (args.description) body.description = args.description;
|
|
227
|
+
const data = await this.post("/v1/snapshots", body);
|
|
228
|
+
const rec = this.asRecord(data);
|
|
229
|
+
const name = rec?.name ?? args.name ?? "";
|
|
230
|
+
const ts = rec?.timestamp ?? rec?.created_at ?? "";
|
|
231
|
+
return `Snapshot '${name}' created at ${ts}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async listSnapshots(args: Record<string, unknown>) {
|
|
235
|
+
const limit = typeof args.limit === "number" ? args.limit : 20;
|
|
236
|
+
const offset = typeof args.offset === "number" ? args.offset : 0;
|
|
237
|
+
const data = await this.get(`/v1/snapshots?limit=${limit}&offset=${offset}`);
|
|
238
|
+
// Format as MCP-compatible text so parseSnapshotList() in client.ts works
|
|
239
|
+
return this.formatSnapshotList(data);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async rollbackSnapshot(args: Record<string, unknown>) {
|
|
243
|
+
return this.post(`/v1/snapshots/${encodeURIComponent(String(args.name))}/rollback`, {});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Branches ──────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
private async branchCreate(args: Record<string, unknown>) {
|
|
249
|
+
const body: Record<string, unknown> = { name: args.name };
|
|
250
|
+
if (args.from_snapshot) body.from_snapshot = args.from_snapshot;
|
|
251
|
+
if (args.from_timestamp) body.from_timestamp = args.from_timestamp;
|
|
252
|
+
return this.post("/v1/branches", body);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async branchList() {
|
|
256
|
+
const data = await this.get("/v1/branches");
|
|
257
|
+
// Format as MCP-compatible text so parseBranches() in client.ts works
|
|
258
|
+
return this.formatBranchList(data);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async branchCheckout(args: Record<string, unknown>) {
|
|
262
|
+
return this.post(`/v1/branches/${encodeURIComponent(String(args.name))}/checkout`, {});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private async branchDelete(args: Record<string, unknown>) {
|
|
266
|
+
return this.delete(`/v1/branches/${encodeURIComponent(String(args.name))}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private async branchMerge(args: Record<string, unknown>) {
|
|
270
|
+
const body: Record<string, unknown> = {};
|
|
271
|
+
if (args.strategy) body.strategy = args.strategy;
|
|
272
|
+
return this.post(`/v1/branches/${encodeURIComponent(String(args.source))}/merge`, body);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async branchDiff(args: Record<string, unknown>) {
|
|
276
|
+
const limit = typeof args.limit === "number" ? args.limit : 50;
|
|
277
|
+
return this.get(`/v1/branches/${encodeURIComponent(String(args.source))}/diff?limit=${limit}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Health ──────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
async healthCheck(): Promise<{ status: string; instance_id?: string; db?: boolean }> {
|
|
283
|
+
const data = await this.get("/health/instance");
|
|
284
|
+
const rec = this.asRecord(data);
|
|
285
|
+
return {
|
|
286
|
+
status: typeof rec?.status === "string" ? rec.status : "ok",
|
|
287
|
+
instance_id: typeof rec?.instance_id === "string" ? rec.instance_id : undefined,
|
|
288
|
+
db: typeof rec?.db === "boolean" ? rec.db : undefined,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── HTTP primitives ───────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
private headers(): Record<string, string> {
|
|
295
|
+
// In api mode, the API key (sk-...) already scopes to its owning user.
|
|
296
|
+
// Sending X-User-Id is unnecessary and can cause cross-user leakage
|
|
297
|
+
// in open-auth deployments. Let the API key determine identity.
|
|
298
|
+
return {
|
|
299
|
+
"Authorization": `Bearer ${this.apiKey}`,
|
|
300
|
+
"Content-Type": "application/json",
|
|
301
|
+
"X-Memoria-Tool": "openclaw",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private async request(method: string, path: string, body?: unknown): Promise<unknown> {
|
|
306
|
+
const url = `${this.apiUrl}${path}`;
|
|
307
|
+
const controller = new AbortController();
|
|
308
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const response = await fetch(url, {
|
|
312
|
+
method,
|
|
313
|
+
headers: this.headers(),
|
|
314
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
315
|
+
signal: controller.signal,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const text = await response.text();
|
|
319
|
+
|
|
320
|
+
if (!response.ok) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`Memoria API ${method} ${path} returned ${response.status}: ${text.slice(0, 500)}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!text.trim()) {
|
|
327
|
+
return { ok: true };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
return JSON.parse(text);
|
|
332
|
+
} catch {
|
|
333
|
+
return text;
|
|
334
|
+
}
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
337
|
+
throw new Error(`Memoria API request timed out after ${this.timeoutMs}ms: ${method} ${path}`);
|
|
338
|
+
}
|
|
339
|
+
throw error;
|
|
340
|
+
} finally {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private get(path: string) {
|
|
346
|
+
return this.request("GET", path);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private post(path: string, body: unknown) {
|
|
350
|
+
return this.request("POST", path, body);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private put(path: string, body: unknown) {
|
|
354
|
+
return this.request("PUT", path, body);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private delete(path: string) {
|
|
358
|
+
return this.request("DELETE", path);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Formatting helpers ────────────────────────────────────────
|
|
362
|
+
// Produce text output matching the Rust MCP binary's format so that
|
|
363
|
+
// the existing parsers in client.ts (parseMemoryTextList, etc.) work.
|
|
364
|
+
|
|
365
|
+
private formatMemoryList(data: unknown): string {
|
|
366
|
+
// The retrieve/search endpoints return either an array or { results: [...] }
|
|
367
|
+
const rec = this.asRecord(data);
|
|
368
|
+
const items = Array.isArray(data)
|
|
369
|
+
? data
|
|
370
|
+
: Array.isArray(rec?.results)
|
|
371
|
+
? rec!.results
|
|
372
|
+
: Array.isArray(rec?.items)
|
|
373
|
+
? rec!.items
|
|
374
|
+
: [];
|
|
375
|
+
return this.formatMemoryItems(items);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private formatMemoryItems(items: unknown[]): string {
|
|
379
|
+
if (items.length === 0) {
|
|
380
|
+
return "No relevant memories found.";
|
|
381
|
+
}
|
|
382
|
+
const lines: string[] = [];
|
|
383
|
+
for (const item of items) {
|
|
384
|
+
const rec = this.asRecord(item);
|
|
385
|
+
if (!rec) continue;
|
|
386
|
+
const id = rec.memory_id ?? "";
|
|
387
|
+
const type = rec.memory_type ?? "semantic";
|
|
388
|
+
const content = typeof rec.content === "string" ? rec.content : "";
|
|
389
|
+
lines.push(`[${id}] (${type}) ${content}`);
|
|
390
|
+
}
|
|
391
|
+
return lines.join("\n");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private formatSnapshotList(data: unknown): string {
|
|
395
|
+
// Produce text matching Rust MCP format: "Snapshots (N):\n name (timestamp)\n ..."
|
|
396
|
+
const rec = this.asRecord(data);
|
|
397
|
+
const items = rec?.result ? this.asRecord(rec.result) : rec;
|
|
398
|
+
// The API may return an array or an object with items/snapshots
|
|
399
|
+
let snapshots: unknown[] = [];
|
|
400
|
+
if (Array.isArray(data)) {
|
|
401
|
+
snapshots = data;
|
|
402
|
+
} else if (items && Array.isArray(items)) {
|
|
403
|
+
snapshots = items;
|
|
404
|
+
} else if (rec) {
|
|
405
|
+
// Try common response shapes
|
|
406
|
+
if (Array.isArray(rec.snapshots)) snapshots = rec.snapshots;
|
|
407
|
+
else if (Array.isArray(rec.items)) snapshots = rec.items;
|
|
408
|
+
else if (typeof rec.result === "string") return rec.result as string;
|
|
409
|
+
}
|
|
410
|
+
if (snapshots.length === 0) {
|
|
411
|
+
return "Snapshots (0):";
|
|
412
|
+
}
|
|
413
|
+
const lines = [`Snapshots (${snapshots.length}):`];
|
|
414
|
+
for (const snap of snapshots) {
|
|
415
|
+
const s = this.asRecord(snap);
|
|
416
|
+
if (!s) continue;
|
|
417
|
+
const name = s.name ?? s.snapshot_name ?? "";
|
|
418
|
+
const ts = s.timestamp ?? s.created_at ?? "";
|
|
419
|
+
lines.push(` ${name} (${ts})`);
|
|
420
|
+
}
|
|
421
|
+
return lines.join("\n");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private formatBranchList(data: unknown): string {
|
|
425
|
+
// Produce text matching Rust MCP format: "Branches:\n name\n name ← active"
|
|
426
|
+
const rec = this.asRecord(data);
|
|
427
|
+
let branches: unknown[] = [];
|
|
428
|
+
if (Array.isArray(data)) {
|
|
429
|
+
branches = data;
|
|
430
|
+
} else if (rec) {
|
|
431
|
+
if (Array.isArray(rec.branches)) branches = rec.branches;
|
|
432
|
+
else if (Array.isArray(rec.items)) branches = rec.items;
|
|
433
|
+
else if (typeof rec.result === "string") return rec.result as string;
|
|
434
|
+
}
|
|
435
|
+
if (branches.length === 0) {
|
|
436
|
+
return "Branches:\n main ← active";
|
|
437
|
+
}
|
|
438
|
+
const lines = ["Branches:"];
|
|
439
|
+
for (const branch of branches) {
|
|
440
|
+
const b = this.asRecord(branch);
|
|
441
|
+
if (!b) continue;
|
|
442
|
+
const name = typeof b.name === "string" ? b.name : "";
|
|
443
|
+
const active = b.active === true;
|
|
444
|
+
lines.push(` ${name}${active ? " ← active" : ""}`);
|
|
445
|
+
}
|
|
446
|
+
return lines.join("\n");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private asRecord(value: unknown): Record<string, unknown> | null {
|
|
450
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
451
|
+
? (value as Record<string, unknown>)
|
|
452
|
+
: null;
|
|
453
|
+
}
|
|
454
|
+
}
|
package/openclaw/index.ts
CHANGED
|
@@ -432,7 +432,7 @@ function buildCapabilitiesPayload(config: MemoriaPluginConfig): Record<string, u
|
|
|
432
432
|
userIdStrategy: config.userIdStrategy,
|
|
433
433
|
autoRecall: config.autoRecall,
|
|
434
434
|
autoObserve: config.autoObserve,
|
|
435
|
-
llmConfigured: Boolean(config.llmApiKey || config.backend === "
|
|
435
|
+
llmConfigured: Boolean(config.llmApiKey || config.backend === "api"),
|
|
436
436
|
tools: supportedToolNames(),
|
|
437
437
|
embeddedOnly: [...EMBEDDED_ONLY_TOOL_NAMES],
|
|
438
438
|
cliCommands: [...CLI_COMMAND_NAMES],
|
|
@@ -551,7 +551,7 @@ function shouldShowOnboardingHint(rawPluginConfig: unknown): boolean {
|
|
|
551
551
|
const hasCloudConfig = hasNonEmptyString(raw.apiUrl) || hasNonEmptyString(raw.apiKey);
|
|
552
552
|
const hasLocalConfig = hasNonEmptyString(raw.dbUrl);
|
|
553
553
|
|
|
554
|
-
return !(backend === "
|
|
554
|
+
return !(backend === "api" || hasCloudConfig || hasLocalConfig);
|
|
555
555
|
}
|
|
556
556
|
|
|
557
557
|
const ONBOARDING_HINT_ONCE_KEY = "__memory_memoria_onboarding_hint_logged__";
|
|
@@ -566,9 +566,9 @@ function shouldLogOnboardingHintOnce(): boolean {
|
|
|
566
566
|
}
|
|
567
567
|
|
|
568
568
|
const plugin = {
|
|
569
|
-
id: "
|
|
569
|
+
id: "thememoria",
|
|
570
570
|
name: "Memory (Memoria)",
|
|
571
|
-
description: "Memoria-backed long-term memory plugin for OpenClaw
|
|
571
|
+
description: "Memoria-backed long-term memory plugin for OpenClaw. Supports direct HTTP API mode (no binary) and embedded mode (local Rust CLI).",
|
|
572
572
|
kind: "memory" as const,
|
|
573
573
|
configSchema: memoriaPluginConfigSchema,
|
|
574
574
|
|
|
@@ -581,7 +581,7 @@ const plugin = {
|
|
|
581
581
|
|
|
582
582
|
if (isFirstRegister) {
|
|
583
583
|
api.logger.info(
|
|
584
|
-
`
|
|
584
|
+
`thememoria: registered (${needsSetup ? "pending setup" : config.backend})`,
|
|
585
585
|
);
|
|
586
586
|
|
|
587
587
|
const isEnableCommand =
|
|
@@ -589,7 +589,7 @@ const plugin = {
|
|
|
589
589
|
process.argv.some((arg) => arg === "plugins");
|
|
590
590
|
if (needsSetup && isEnableCommand) {
|
|
591
591
|
api.logger.info(
|
|
592
|
-
"🧠 Memoria next step (Cloud, recommended): openclaw memoria setup --mode cloud --api-url <MEMORIA_API_URL> --api-key <MEMORIA_API_KEY>
|
|
592
|
+
"🧠 Memoria next step (Cloud, recommended): openclaw memoria setup --mode cloud --api-url <MEMORIA_API_URL> --api-key <MEMORIA_API_KEY>",
|
|
593
593
|
);
|
|
594
594
|
api.logger.info(
|
|
595
595
|
"🧩 Local quick start: openclaw memoria setup --mode local --install-memoria --embedding-api-key <EMBEDDING_API_KEY>",
|
|
@@ -1891,7 +1891,7 @@ const plugin = {
|
|
|
1891
1891
|
};
|
|
1892
1892
|
};
|
|
1893
1893
|
|
|
1894
|
-
const applyConnectOptions = (normalized: NormalizedConnectOptions) => {
|
|
1894
|
+
const applyConnectOptions = async (normalized: NormalizedConnectOptions) => {
|
|
1895
1895
|
const resolvedConfigFile = resolveOpenClawConfigFile();
|
|
1896
1896
|
let memoriaBinForConfig = normalized.memoriaBin;
|
|
1897
1897
|
const installDirFallback =
|
|
@@ -1901,7 +1901,7 @@ const plugin = {
|
|
|
1901
1901
|
: path.join(process.env.HOME ?? "", ".local", "bin"));
|
|
1902
1902
|
let effectiveMemoriaExecutable = memoriaBinForConfig ?? config.memoriaExecutable;
|
|
1903
1903
|
|
|
1904
|
-
if (normalized.installMemoria && !isExecutableAvailable(effectiveMemoriaExecutable)) {
|
|
1904
|
+
if (normalized.mode === "local" && normalized.installMemoria && !isExecutableAvailable(effectiveMemoriaExecutable)) {
|
|
1905
1905
|
runMemoriaInstaller({
|
|
1906
1906
|
memoriaVersion: normalized.memoriaVersion,
|
|
1907
1907
|
memoriaInstallDir: installDirFallback,
|
|
@@ -1941,12 +1941,39 @@ const plugin = {
|
|
|
1941
1941
|
}
|
|
1942
1942
|
|
|
1943
1943
|
if (normalized.healthCheck) {
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1944
|
+
if (normalized.mode === "cloud") {
|
|
1945
|
+
// API mode: direct HTTP health check, no binary needed
|
|
1946
|
+
const healthUrl = `${normalized.apiUrl}/health/instance`;
|
|
1947
|
+
const controller = new AbortController();
|
|
1948
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
1949
|
+
try {
|
|
1950
|
+
const resp = await fetch(healthUrl, {
|
|
1951
|
+
headers: {
|
|
1952
|
+
"Authorization": `Bearer ${normalized.apiKey}`,
|
|
1953
|
+
"Content-Type": "application/json",
|
|
1954
|
+
},
|
|
1955
|
+
signal: controller.signal,
|
|
1956
|
+
});
|
|
1957
|
+
if (!resp.ok) {
|
|
1958
|
+
throw new Error(`Health check returned ${resp.status}: ${await resp.text()}`);
|
|
1959
|
+
}
|
|
1960
|
+
const data = await resp.json();
|
|
1961
|
+
printJson({ healthCheck: "ok", ...data });
|
|
1962
|
+
} catch (error) {
|
|
1963
|
+
throw new Error(
|
|
1964
|
+
`Health check failed against ${healthUrl}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1965
|
+
);
|
|
1966
|
+
} finally {
|
|
1967
|
+
clearTimeout(timer);
|
|
1968
|
+
}
|
|
1969
|
+
} else {
|
|
1970
|
+
assertMemoriaExecutableAvailable(effectiveMemoriaExecutable, normalized.mode);
|
|
1971
|
+
const healthArgs = ["memoria", "health"];
|
|
1972
|
+
if (normalized.userId) {
|
|
1973
|
+
healthArgs.push("--user-id", normalized.userId);
|
|
1974
|
+
}
|
|
1975
|
+
runLocalCommand(openclawBin, healthArgs, { env: openclawEnv });
|
|
1948
1976
|
}
|
|
1949
|
-
runLocalCommand(openclawBin, healthArgs, { env: openclawEnv });
|
|
1950
1977
|
}
|
|
1951
1978
|
|
|
1952
1979
|
printJson({
|
|
@@ -2124,7 +2151,7 @@ const plugin = {
|
|
|
2124
2151
|
.option("--skip-health-check", "Skip `openclaw memoria health`", false)
|
|
2125
2152
|
.action(withCliClient(async (opts) => {
|
|
2126
2153
|
const normalized = normalizeConnectOptions(opts as RawConnectCliOptions, "cloud");
|
|
2127
|
-
applyConnectOptions(normalized);
|
|
2154
|
+
await applyConnectOptions(normalized);
|
|
2128
2155
|
}));
|
|
2129
2156
|
|
|
2130
2157
|
memoria
|
|
@@ -2145,7 +2172,7 @@ const plugin = {
|
|
|
2145
2172
|
.option("--skip-health-check", "Skip `openclaw memoria health`", false)
|
|
2146
2173
|
.action(withCliClient(async (opts) => {
|
|
2147
2174
|
const normalized = normalizeConnectOptions(opts as RawConnectCliOptions, "cloud");
|
|
2148
|
-
applyConnectOptions(normalized);
|
|
2175
|
+
await applyConnectOptions(normalized);
|
|
2149
2176
|
}));
|
|
2150
2177
|
|
|
2151
2178
|
ltm
|
|
@@ -2231,7 +2258,7 @@ const plugin = {
|
|
|
2231
2258
|
const handleAutoRecall = async (
|
|
2232
2259
|
prompt: string,
|
|
2233
2260
|
ctx: PluginIdentityContext,
|
|
2234
|
-
): Promise<{
|
|
2261
|
+
): Promise<{ appendSystemContext?: string } | void> => {
|
|
2235
2262
|
const trimmed = prompt.trim();
|
|
2236
2263
|
if (trimmed.length < config.recallMinPromptLength) {
|
|
2237
2264
|
return;
|
|
@@ -2251,12 +2278,12 @@ const plugin = {
|
|
|
2251
2278
|
if (memories.length === 0) {
|
|
2252
2279
|
return;
|
|
2253
2280
|
}
|
|
2254
|
-
api.logger.info(`
|
|
2281
|
+
api.logger.info(`thememoria: recalled ${memories.length} memories`);
|
|
2255
2282
|
return {
|
|
2256
|
-
|
|
2283
|
+
appendSystemContext: formatRelevantMemoriesContext(memories),
|
|
2257
2284
|
};
|
|
2258
2285
|
} catch (error) {
|
|
2259
|
-
api.logger.warn(`
|
|
2286
|
+
api.logger.warn(`thememoria: auto-recall failed: ${String(error)}`);
|
|
2260
2287
|
}
|
|
2261
2288
|
};
|
|
2262
2289
|
|
|
@@ -2264,10 +2291,6 @@ const plugin = {
|
|
|
2264
2291
|
api.on("before_prompt_build", async (event, ctx) => {
|
|
2265
2292
|
return await handleAutoRecall(event.prompt, ctx);
|
|
2266
2293
|
});
|
|
2267
|
-
|
|
2268
|
-
api.on("before_agent_start", async (event, ctx) => {
|
|
2269
|
-
return await handleAutoRecall(event.prompt, ctx);
|
|
2270
|
-
});
|
|
2271
2294
|
}
|
|
2272
2295
|
|
|
2273
2296
|
if (config.autoObserve) {
|
|
@@ -2293,10 +2316,10 @@ const plugin = {
|
|
|
2293
2316
|
sessionId: ctx.sessionId,
|
|
2294
2317
|
});
|
|
2295
2318
|
if (created.length > 0) {
|
|
2296
|
-
api.logger.info(`
|
|
2319
|
+
api.logger.info(`thememoria: observed ${created.length} new memories`);
|
|
2297
2320
|
}
|
|
2298
2321
|
} catch (error) {
|
|
2299
|
-
api.logger.warn(`
|
|
2322
|
+
api.logger.warn(`thememoria: auto-observe failed: ${String(error)}`);
|
|
2300
2323
|
}
|
|
2301
2324
|
});
|
|
2302
2325
|
|
|
@@ -2324,34 +2347,34 @@ const plugin = {
|
|
|
2324
2347
|
});
|
|
2325
2348
|
if (created.length > 0) {
|
|
2326
2349
|
api.logger.info(
|
|
2327
|
-
`
|
|
2350
|
+
`thememoria: observed ${created.length} new memories before reset`,
|
|
2328
2351
|
);
|
|
2329
2352
|
}
|
|
2330
2353
|
} catch (error) {
|
|
2331
|
-
api.logger.warn(`
|
|
2354
|
+
api.logger.warn(`thememoria: before_reset observe failed: ${String(error)}`);
|
|
2332
2355
|
}
|
|
2333
2356
|
});
|
|
2334
2357
|
}
|
|
2335
2358
|
|
|
2336
2359
|
api.on("after_compaction", async () => {
|
|
2337
2360
|
api.logger.info(
|
|
2338
|
-
"
|
|
2361
|
+
"thememoria: compaction finished; next prompt will use live Memoria recall",
|
|
2339
2362
|
);
|
|
2340
2363
|
});
|
|
2341
2364
|
|
|
2342
2365
|
api.registerService({
|
|
2343
|
-
id: "
|
|
2366
|
+
id: "thememoria",
|
|
2344
2367
|
async start() {
|
|
2345
2368
|
try {
|
|
2346
2369
|
const result = await client.health(config.defaultUserId);
|
|
2347
|
-
api.logger.info(`
|
|
2370
|
+
api.logger.info(`thememoria: connected (${String(result.status ?? "ok")})`);
|
|
2348
2371
|
} catch (error) {
|
|
2349
|
-
api.logger.warn(`
|
|
2372
|
+
api.logger.warn(`thememoria: health check failed: ${String(error)}`);
|
|
2350
2373
|
}
|
|
2351
2374
|
},
|
|
2352
2375
|
stop() {
|
|
2353
2376
|
client.close();
|
|
2354
|
-
api.logger.info("
|
|
2377
|
+
api.logger.info("thememoria: stopped");
|
|
2355
2378
|
},
|
|
2356
2379
|
});
|
|
2357
2380
|
},
|