@matrixorigin/thememoria 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1030 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { createInterface, type Interface as ReadlineInterface } from "node:readline";
3
+ import type {
4
+ MemoriaMemoryType,
5
+ MemoriaPluginConfig,
6
+ MemoriaTrustTier,
7
+ } from "./config.js";
8
+ import { MEMORIA_MEMORY_TYPES } from "./config.js";
9
+
10
+ export type MemoriaMemoryRecord = {
11
+ memory_id: string;
12
+ content: string;
13
+ memory_type?: string;
14
+ trust_tier?: string | null;
15
+ confidence?: number | null;
16
+ session_id?: string | null;
17
+ is_active?: boolean;
18
+ observed_at?: string | null;
19
+ updated_at?: string | null;
20
+ };
21
+
22
+ export type MemoriaProfileResponse = {
23
+ user_id: string;
24
+ profile: string | null;
25
+ stats?: Record<string, unknown>;
26
+ };
27
+
28
+ export type MemoriaBranchRecord = {
29
+ name: string;
30
+ branch_db?: string;
31
+ active?: boolean;
32
+ };
33
+
34
+ export type MemoriaSnapshotSummary = {
35
+ name: string;
36
+ snapshot_name: string;
37
+ description?: string | null;
38
+ timestamp: string;
39
+ };
40
+
41
+ export type MemoriaListMemoriesResponse = {
42
+ items: MemoriaMemoryRecord[];
43
+ count: number;
44
+ user_id: string;
45
+ backend: string;
46
+ partial?: boolean;
47
+ include_inactive?: boolean;
48
+ limitations?: string[];
49
+ };
50
+
51
+ export type MemoriaStatsResponse = {
52
+ backend: string;
53
+ user_id: string;
54
+ activeMemoryCount: number;
55
+ inactiveMemoryCount: number | null;
56
+ byType: Record<string, number>;
57
+ entityCount: number | null;
58
+ snapshotCount: number | null;
59
+ branchCount: number | null;
60
+ healthWarnings: string[];
61
+ partial?: boolean;
62
+ limitations?: string[];
63
+ };
64
+
65
+ type JsonRpcError = {
66
+ code?: number;
67
+ message?: string;
68
+ };
69
+
70
+ type JsonRpcResponse = {
71
+ id?: number | null;
72
+ result?: unknown;
73
+ error?: JsonRpcError;
74
+ };
75
+
76
+ type PendingRequest = {
77
+ reject: (error: Error) => void;
78
+ resolve: (value: unknown) => void;
79
+ timer: NodeJS.Timeout;
80
+ };
81
+
82
+ type McpContentBlock = {
83
+ type?: string;
84
+ text?: string;
85
+ };
86
+
87
+ const MCP_PROTOCOL_VERSION = "2024-11-05";
88
+ const PLUGIN_VERSION = "0.3.0";
89
+ const MEMORY_LINE_RE = /^\[([^\]]+)\] \(([^)]+)\) ?([\s\S]*)$/;
90
+
91
+ function asRecord(value: unknown): Record<string, unknown> | null {
92
+ return value && typeof value === "object" && !Array.isArray(value)
93
+ ? (value as Record<string, unknown>)
94
+ : null;
95
+ }
96
+
97
+ function tryParseJson(raw: string): unknown {
98
+ try {
99
+ return JSON.parse(raw);
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ function normalizeMemoryRecord(value: Partial<MemoriaMemoryRecord> & Record<string, unknown>) {
106
+ return {
107
+ memory_id: typeof value.memory_id === "string" ? value.memory_id : "",
108
+ content: typeof value.content === "string" ? value.content : "",
109
+ memory_type:
110
+ typeof value.memory_type === "string"
111
+ ? value.memory_type
112
+ : typeof value.type === "string"
113
+ ? value.type
114
+ : undefined,
115
+ trust_tier:
116
+ typeof value.trust_tier === "string" || value.trust_tier === null
117
+ ? (value.trust_tier as string | null)
118
+ : undefined,
119
+ confidence:
120
+ typeof value.confidence === "number" && Number.isFinite(value.confidence)
121
+ ? value.confidence
122
+ : null,
123
+ session_id:
124
+ typeof value.session_id === "string" || value.session_id === null
125
+ ? (value.session_id as string | null)
126
+ : undefined,
127
+ is_active: typeof value.is_active === "boolean" ? value.is_active : true,
128
+ observed_at:
129
+ typeof value.observed_at === "string" || value.observed_at === null
130
+ ? (value.observed_at as string | null)
131
+ : undefined,
132
+ updated_at:
133
+ typeof value.updated_at === "string" || value.updated_at === null
134
+ ? (value.updated_at as string | null)
135
+ : undefined,
136
+ };
137
+ }
138
+
139
+ function normalizeTypeCounts(value: unknown): Record<string, number> {
140
+ const counts = Object.fromEntries(MEMORIA_MEMORY_TYPES.map((type) => [type, 0])) as Record<
141
+ string,
142
+ number
143
+ >;
144
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
145
+ return counts;
146
+ }
147
+ for (const [key, raw] of Object.entries(value)) {
148
+ if (typeof raw === "number" && Number.isFinite(raw)) {
149
+ counts[key] = raw;
150
+ }
151
+ }
152
+ return counts;
153
+ }
154
+
155
+ function extractToolText(result: unknown): string {
156
+ const record = asRecord(result);
157
+ if (!record) {
158
+ return typeof result === "string" ? result.trim() : "";
159
+ }
160
+
161
+ const content = record.content;
162
+ if (!Array.isArray(content)) {
163
+ return typeof record.text === "string" ? record.text.trim() : "";
164
+ }
165
+
166
+ const parts: string[] = [];
167
+ for (const entry of content) {
168
+ const block = entry as McpContentBlock;
169
+ if (block?.type === "text" && typeof block.text === "string") {
170
+ parts.push(block.text);
171
+ }
172
+ }
173
+ return parts.join("\n").trim();
174
+ }
175
+
176
+ function parseMemoryTextList(text: string): MemoriaMemoryRecord[] {
177
+ const trimmed = text.trim();
178
+ if (!trimmed || trimmed.startsWith("No relevant memories found.") || trimmed === "No memories found.") {
179
+ return [];
180
+ }
181
+
182
+ const memories: MemoriaMemoryRecord[] = [];
183
+ let current: MemoriaMemoryRecord | null = null;
184
+
185
+ for (const line of trimmed.split(/\r?\n/)) {
186
+ const match = line.match(MEMORY_LINE_RE);
187
+ if (match) {
188
+ if (current) {
189
+ memories.push(normalizeMemoryRecord(current));
190
+ }
191
+ current = {
192
+ memory_id: match[1].trim(),
193
+ memory_type: match[2].trim(),
194
+ content: match[3] ?? "",
195
+ };
196
+ continue;
197
+ }
198
+
199
+ if (current) {
200
+ current.content = current.content ? `${current.content}\n${line}` : line;
201
+ }
202
+ }
203
+
204
+ if (current) {
205
+ memories.push(normalizeMemoryRecord(current));
206
+ }
207
+
208
+ return memories;
209
+ }
210
+
211
+ function parseStoredMemory(text: string, fallback: {
212
+ content: string;
213
+ memoryType: MemoriaMemoryType;
214
+ trustTier?: MemoriaTrustTier;
215
+ sessionId?: string;
216
+ }): MemoriaMemoryRecord {
217
+ const match = text.match(/^Stored memory ([^:]+):\s*([\s\S]*)$/);
218
+ return normalizeMemoryRecord({
219
+ memory_id: match?.[1]?.trim() ?? "",
220
+ content: match?.[2] ?? fallback.content,
221
+ memory_type: fallback.memoryType,
222
+ trust_tier: fallback.trustTier ?? null,
223
+ session_id: fallback.sessionId ?? null,
224
+ });
225
+ }
226
+
227
+ function parseCorrectedMemory(text: string, fallbackContent: string) {
228
+ const match = text.match(/^Corrected memory ([^:]+):\s*([\s\S]*)$/);
229
+ if (!match) {
230
+ return null;
231
+ }
232
+ return normalizeMemoryRecord({
233
+ memory_id: match[1].trim(),
234
+ content: match[2] || fallbackContent,
235
+ });
236
+ }
237
+
238
+ function parsePurgedCount(text: string): number {
239
+ const match = text.match(/Purged (\d+) memory/);
240
+ return match ? Number.parseInt(match[1], 10) : 0;
241
+ }
242
+
243
+ function parseSnapshotList(text: string): MemoriaSnapshotSummary[] {
244
+ const lines = text.trim().split(/\r?\n/);
245
+ if (lines.length === 0 || !lines[0].startsWith("Snapshots (")) {
246
+ return [];
247
+ }
248
+
249
+ const snapshots: MemoriaSnapshotSummary[] = [];
250
+ for (const line of lines.slice(1)) {
251
+ const trimmed = line.trim();
252
+ if (!trimmed) {
253
+ continue;
254
+ }
255
+ const splitIndex = trimmed.lastIndexOf(" (");
256
+ if (splitIndex <= 0 || !trimmed.endsWith(")")) {
257
+ continue;
258
+ }
259
+ const name = trimmed.slice(0, splitIndex);
260
+ const timestamp = trimmed.slice(splitIndex + 2, -1);
261
+ snapshots.push({
262
+ name,
263
+ snapshot_name: name,
264
+ timestamp,
265
+ });
266
+ }
267
+ return snapshots;
268
+ }
269
+
270
+ function parseSnapshotCreated(text: string, name: string): MemoriaSnapshotSummary {
271
+ const match = text.match(/^Snapshot '(.+)' created at (.+)$/);
272
+ return {
273
+ name: match?.[1] ?? name,
274
+ snapshot_name: match?.[1] ?? name,
275
+ timestamp: match?.[2] ?? "",
276
+ };
277
+ }
278
+
279
+ function parseBranches(text: string): MemoriaBranchRecord[] {
280
+ const lines = text.trim().split(/\r?\n/);
281
+ if (lines.length === 0 || lines[0] !== "Branches:") {
282
+ return [];
283
+ }
284
+
285
+ const branches: MemoriaBranchRecord[] = [];
286
+ let explicitActive = false;
287
+
288
+ for (const line of lines.slice(1)) {
289
+ const trimmed = line.trim();
290
+ if (!trimmed) {
291
+ continue;
292
+ }
293
+ const active = trimmed.endsWith(" ← active");
294
+ const name = active ? trimmed.slice(0, -" ← active".length).trim() : trimmed;
295
+ explicitActive ||= active;
296
+ branches.push({ name, active });
297
+ }
298
+
299
+ if (!explicitActive) {
300
+ const main = branches.find((branch) => branch.name === "main");
301
+ if (main) {
302
+ main.active = true;
303
+ }
304
+ }
305
+
306
+ return branches;
307
+ }
308
+
309
+ function parseJsonText(text: string): Record<string, unknown> | null {
310
+ const parsed = tryParseJson(text);
311
+ return asRecord(parsed);
312
+ }
313
+
314
+ function parseGenericResult(text: string): Record<string, unknown> {
315
+ return parseJsonText(text) ?? { message: text };
316
+ }
317
+
318
+ class MemoriaMcpSession {
319
+ private child: ChildProcessWithoutNullStreams | null = null;
320
+ private stdoutReader: ReadlineInterface | null = null;
321
+ private initialized: Promise<void> | null = null;
322
+ private nextId = 1;
323
+ private readonly pending = new Map<number, PendingRequest>();
324
+ private readonly stderrLines: string[] = [];
325
+
326
+ constructor(
327
+ private readonly config: MemoriaPluginConfig,
328
+ private readonly userId: string,
329
+ ) {}
330
+
331
+ isAlive(): boolean {
332
+ return Boolean(this.child && this.child.exitCode === null && !this.child.killed);
333
+ }
334
+
335
+ async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
336
+ await this.ensureInitialized();
337
+ return this.request("tools/call", {
338
+ name,
339
+ arguments: args,
340
+ });
341
+ }
342
+
343
+ close() {
344
+ this.stdoutReader?.close();
345
+ this.stdoutReader = null;
346
+ if (this.child && this.child.exitCode === null && !this.child.killed) {
347
+ this.child.kill("SIGTERM");
348
+ }
349
+ this.failPending(new Error("Memoria MCP session closed."));
350
+ this.child = null;
351
+ this.initialized = null;
352
+ }
353
+
354
+ private async ensureInitialized(): Promise<void> {
355
+ if (this.isAlive() && this.initialized) {
356
+ return this.initialized;
357
+ }
358
+ this.initialized = this.start();
359
+ try {
360
+ await this.initialized;
361
+ } catch (error) {
362
+ this.initialized = null;
363
+ throw error;
364
+ }
365
+ }
366
+
367
+ private async start(): Promise<void> {
368
+ const child = spawn(this.config.memoriaExecutable, this.buildArgs(), {
369
+ cwd: process.cwd(),
370
+ env: this.buildEnv(),
371
+ stdio: ["pipe", "pipe", "pipe"],
372
+ });
373
+ this.child = child;
374
+
375
+ this.stdoutReader = createInterface({ input: child.stdout });
376
+ this.stdoutReader.on("line", (line) => {
377
+ this.handleStdout(line);
378
+ });
379
+
380
+ child.stderr.setEncoding("utf8");
381
+ child.stderr.on("data", (chunk: string) => {
382
+ for (const line of chunk.split(/\r?\n/)) {
383
+ const trimmed = line.trim();
384
+ if (!trimmed) {
385
+ continue;
386
+ }
387
+ this.stderrLines.push(trimmed);
388
+ if (this.stderrLines.length > 20) {
389
+ this.stderrLines.shift();
390
+ }
391
+ }
392
+ });
393
+
394
+ child.on("error", (error) => {
395
+ this.failPending(
396
+ new Error(`Failed to start memoria executable '${this.config.memoriaExecutable}': ${error.message}`),
397
+ );
398
+ this.child = null;
399
+ this.initialized = null;
400
+ });
401
+
402
+ child.on("close", (code, signal) => {
403
+ this.stdoutReader?.close();
404
+ this.stdoutReader = null;
405
+ const tail = this.stderrLines.length > 0 ? ` stderr: ${this.stderrLines.join(" | ")}` : "";
406
+ this.failPending(
407
+ new Error(
408
+ `Memoria MCP exited for user '${this.userId}' (code=${String(code)} signal=${String(signal)}).${tail}`,
409
+ ),
410
+ );
411
+ this.child = null;
412
+ this.initialized = null;
413
+ });
414
+
415
+ await this.request("initialize", {
416
+ protocolVersion: MCP_PROTOCOL_VERSION,
417
+ capabilities: {},
418
+ clientInfo: {
419
+ name: "openclaw-memoria",
420
+ version: PLUGIN_VERSION,
421
+ },
422
+ });
423
+ this.notify("notifications/initialized");
424
+ }
425
+
426
+ private buildArgs(): string[] {
427
+ const args = ["mcp"];
428
+ if (this.config.backend === "http") {
429
+ args.push("--api-url", this.config.apiUrl!);
430
+ args.push("--token", this.config.apiKey!);
431
+ args.push("--user", this.userId);
432
+ return args;
433
+ }
434
+
435
+ args.push("--db-url", this.config.dbUrl);
436
+ args.push("--user", this.userId);
437
+ return args;
438
+ }
439
+
440
+ private buildEnv(): NodeJS.ProcessEnv {
441
+ const env: NodeJS.ProcessEnv = { ...process.env };
442
+ if (this.config.backend === "embedded") {
443
+ // Embedded mode must not inherit remote-mode overrides from the shell.
444
+ delete env.MEMORIA_API_URL;
445
+ delete env.MEMORIA_TOKEN;
446
+ env.EMBEDDING_PROVIDER = this.config.embeddingProvider;
447
+ env.EMBEDDING_MODEL = this.config.embeddingModel;
448
+ if (this.config.embeddingBaseUrl) {
449
+ env.EMBEDDING_BASE_URL = this.config.embeddingBaseUrl;
450
+ }
451
+ if (this.config.embeddingApiKey) {
452
+ env.EMBEDDING_API_KEY = this.config.embeddingApiKey;
453
+ }
454
+ if (typeof this.config.embeddingDim === "number") {
455
+ env.EMBEDDING_DIM = String(this.config.embeddingDim);
456
+ }
457
+ if (this.config.llmApiKey) {
458
+ env.LLM_API_KEY = this.config.llmApiKey;
459
+ }
460
+ if (this.config.llmBaseUrl) {
461
+ env.LLM_BASE_URL = this.config.llmBaseUrl;
462
+ }
463
+ if (this.config.llmModel) {
464
+ env.LLM_MODEL = this.config.llmModel;
465
+ }
466
+ }
467
+ return env;
468
+ }
469
+
470
+ private handleStdout(rawLine: string) {
471
+ const line = rawLine.trim();
472
+ if (!line) {
473
+ return;
474
+ }
475
+
476
+ const parsed = tryParseJson(line);
477
+ const response = asRecord(parsed) as JsonRpcResponse | null;
478
+ if (!response || typeof response.id !== "number") {
479
+ return;
480
+ }
481
+
482
+ const pending = this.pending.get(response.id);
483
+ if (!pending) {
484
+ return;
485
+ }
486
+
487
+ this.pending.delete(response.id);
488
+ clearTimeout(pending.timer);
489
+
490
+ if (response.error?.message) {
491
+ pending.reject(new Error(response.error.message));
492
+ return;
493
+ }
494
+
495
+ pending.resolve(response.result);
496
+ }
497
+
498
+ private notify(method: string, params?: Record<string, unknown>) {
499
+ if (!this.child) {
500
+ return;
501
+ }
502
+ this.child.stdin.write(
503
+ `${JSON.stringify({
504
+ jsonrpc: "2.0",
505
+ method,
506
+ ...(params ? { params } : {}),
507
+ })}\n`,
508
+ );
509
+ }
510
+
511
+ private request(method: string, params?: Record<string, unknown>): Promise<unknown> {
512
+ if (!this.child) {
513
+ return Promise.reject(new Error("Memoria MCP process is not running."));
514
+ }
515
+
516
+ const id = this.nextId++;
517
+ return new Promise((resolve, reject) => {
518
+ const timer = setTimeout(() => {
519
+ this.pending.delete(id);
520
+ reject(new Error(`Memoria MCP request timed out after ${this.config.timeoutMs}ms: ${method}`));
521
+ this.close();
522
+ }, this.config.timeoutMs);
523
+
524
+ this.pending.set(id, { resolve, reject, timer });
525
+ this.child!.stdin.write(
526
+ `${JSON.stringify({
527
+ jsonrpc: "2.0",
528
+ id,
529
+ method,
530
+ ...(params ? { params } : {}),
531
+ })}\n`,
532
+ );
533
+ });
534
+ }
535
+
536
+ private failPending(error: Error) {
537
+ for (const pending of this.pending.values()) {
538
+ clearTimeout(pending.timer);
539
+ pending.reject(error);
540
+ }
541
+ this.pending.clear();
542
+ }
543
+ }
544
+
545
+ export class MemoriaClient {
546
+ private readonly sessions = new Map<string, MemoriaMcpSession>();
547
+ private readonly memoryCache = new Map<string, MemoriaMemoryRecord>();
548
+
549
+ constructor(private readonly config: MemoriaPluginConfig) {}
550
+
551
+ close() {
552
+ for (const session of this.sessions.values()) {
553
+ session.close();
554
+ }
555
+ this.sessions.clear();
556
+ }
557
+
558
+ async health(userId: string) {
559
+ await this.callToolText(userId, "memory_list", { limit: 1 });
560
+ return {
561
+ status: "ok",
562
+ mode: this.config.backend,
563
+ warnings: [],
564
+ };
565
+ }
566
+
567
+ async storeMemory(params: {
568
+ userId: string;
569
+ content: string;
570
+ memoryType: MemoriaMemoryType;
571
+ trustTier?: MemoriaTrustTier;
572
+ sessionId?: string;
573
+ source?: string;
574
+ }) {
575
+ const text = await this.callToolText(params.userId, "memory_store", {
576
+ content: params.content,
577
+ memory_type: params.memoryType,
578
+ session_id: params.sessionId,
579
+ trust_tier: params.trustTier,
580
+ });
581
+ const record = parseStoredMemory(text, params);
582
+ this.cacheMemories(params.userId, [record]);
583
+ return record;
584
+ }
585
+
586
+ async retrieve(params: {
587
+ userId: string;
588
+ query: string;
589
+ topK: number;
590
+ memoryTypes?: MemoriaMemoryType[];
591
+ sessionId?: string;
592
+ includeCrossSession?: boolean;
593
+ }) {
594
+ const text = await this.callToolText(params.userId, "memory_retrieve", {
595
+ query: params.query,
596
+ top_k: params.topK,
597
+ session_id: params.sessionId,
598
+ });
599
+ const memories = parseMemoryTextList(text);
600
+ this.cacheMemories(params.userId, memories);
601
+ return memories;
602
+ }
603
+
604
+ async search(params: {
605
+ userId: string;
606
+ query: string;
607
+ topK: number;
608
+ }) {
609
+ const text = await this.callToolText(params.userId, "memory_search", {
610
+ query: params.query,
611
+ top_k: params.topK,
612
+ });
613
+ const memories = parseMemoryTextList(text);
614
+ this.cacheMemories(params.userId, memories);
615
+ return memories;
616
+ }
617
+
618
+ async getMemory(params: {
619
+ userId: string;
620
+ memoryId: string;
621
+ }) {
622
+ const cached = this.memoryCache.get(this.memoryCacheKey(params.userId, params.memoryId));
623
+ if (cached) {
624
+ return cached;
625
+ }
626
+
627
+ const scanLimit = Math.min(2000, Math.max(200, this.config.maxListPages * 50));
628
+ const listed = await this.listMemories({
629
+ userId: params.userId,
630
+ limit: scanLimit,
631
+ });
632
+ return (
633
+ listed.items.find((memory) => memory.memory_id === params.memoryId) ?? null
634
+ );
635
+ }
636
+
637
+ async listMemories(params: {
638
+ userId: string;
639
+ memoryType?: MemoriaMemoryType;
640
+ limit: number;
641
+ sessionId?: string;
642
+ includeInactive?: boolean;
643
+ }): Promise<MemoriaListMemoriesResponse> {
644
+ const needsClientFiltering = Boolean(params.memoryType);
645
+ const scanLimit = needsClientFiltering
646
+ ? Math.min(2000, Math.max(params.limit, params.limit * this.config.maxListPages))
647
+ : params.limit;
648
+
649
+ const text = await this.callToolText(params.userId, "memory_list", {
650
+ limit: scanLimit,
651
+ });
652
+ let items = parseMemoryTextList(text);
653
+ this.cacheMemories(params.userId, items);
654
+
655
+ const limitations: string[] = [];
656
+ if (params.memoryType) {
657
+ items = items.filter((memory) => memory.memory_type === params.memoryType);
658
+ limitations.push("memoryType filtering is applied client-side from a bounded Rust MCP scan.");
659
+ }
660
+ if (params.sessionId) {
661
+ limitations.push("Rust Memoria MCP does not expose session_id on memory_list; sessionId was ignored.");
662
+ }
663
+ if (params.includeInactive) {
664
+ limitations.push("Rust Memoria MCP only lists active memories.");
665
+ }
666
+
667
+ const partial = limitations.length > 0 || items.length >= scanLimit;
668
+
669
+ return {
670
+ items: items.slice(0, params.limit),
671
+ count: Math.min(items.length, params.limit),
672
+ user_id: params.userId,
673
+ backend: this.config.backend,
674
+ partial,
675
+ include_inactive: params.includeInactive ?? false,
676
+ ...(limitations.length > 0 ? { limitations } : {}),
677
+ };
678
+ }
679
+
680
+ async stats(userId: string): Promise<MemoriaStatsResponse> {
681
+ const scanLimit = Math.min(2000, Math.max(200, this.config.maxListPages * 50));
682
+ const list = await this.listMemories({ userId, limit: scanLimit });
683
+ const byType = normalizeTypeCounts(undefined);
684
+ for (const item of list.items) {
685
+ const type = item.memory_type ?? "semantic";
686
+ byType[type] = (byType[type] ?? 0) + 1;
687
+ }
688
+
689
+ const limitations = [
690
+ ...(list.limitations ?? []),
691
+ "Statistics are derived from Rust MCP text output. Inactive-memory and entity totals are unavailable.",
692
+ ];
693
+
694
+ let snapshotCount: number | null = null;
695
+ try {
696
+ snapshotCount = (await this.listSnapshots(userId)).length;
697
+ } catch (error) {
698
+ limitations.push(
699
+ `Snapshot statistics unavailable: ${error instanceof Error ? error.message : String(error)}`,
700
+ );
701
+ }
702
+
703
+ let branchCount: number | null = null;
704
+ try {
705
+ branchCount = (await this.branchList(userId)).length;
706
+ } catch (error) {
707
+ limitations.push(
708
+ `Branch statistics unavailable: ${error instanceof Error ? error.message : String(error)}`,
709
+ );
710
+ }
711
+
712
+ return {
713
+ backend: this.config.backend,
714
+ user_id: userId,
715
+ activeMemoryCount: list.items.length,
716
+ inactiveMemoryCount: null,
717
+ byType,
718
+ entityCount: null,
719
+ snapshotCount,
720
+ branchCount,
721
+ healthWarnings: [],
722
+ partial: true,
723
+ limitations,
724
+ };
725
+ }
726
+
727
+ async correctById(params: {
728
+ userId: string;
729
+ memoryId: string;
730
+ newContent: string;
731
+ reason?: string;
732
+ }) {
733
+ const text = await this.callToolText(params.userId, "memory_correct", {
734
+ memory_id: params.memoryId,
735
+ new_content: params.newContent,
736
+ reason: params.reason ?? "",
737
+ });
738
+ const corrected = parseCorrectedMemory(text, params.newContent);
739
+ if (corrected) {
740
+ this.cacheMemories(params.userId, [corrected]);
741
+ return corrected;
742
+ }
743
+ return { error: true, message: text };
744
+ }
745
+
746
+ async correctByQuery(params: {
747
+ userId: string;
748
+ query: string;
749
+ newContent: string;
750
+ reason?: string;
751
+ }) {
752
+ const text = await this.callToolText(params.userId, "memory_correct", {
753
+ query: params.query,
754
+ new_content: params.newContent,
755
+ reason: params.reason ?? "",
756
+ });
757
+ const corrected = parseCorrectedMemory(text, params.newContent);
758
+ if (corrected) {
759
+ this.cacheMemories(params.userId, [corrected]);
760
+ return corrected;
761
+ }
762
+ return { error: true, message: text };
763
+ }
764
+
765
+ async deleteMemory(params: {
766
+ userId: string;
767
+ memoryId: string;
768
+ reason?: string;
769
+ }) {
770
+ const text = await this.callToolText(params.userId, "memory_purge", {
771
+ memory_id: params.memoryId,
772
+ reason: params.reason ?? "",
773
+ });
774
+ this.memoryCache.delete(this.memoryCacheKey(params.userId, params.memoryId));
775
+ return { purged: parsePurgedCount(text) };
776
+ }
777
+
778
+ async purgeMemory(params: {
779
+ userId: string;
780
+ memoryId?: string;
781
+ topic?: string;
782
+ reason?: string;
783
+ }) {
784
+ const text = await this.callToolText(params.userId, "memory_purge", {
785
+ memory_id: params.memoryId,
786
+ topic: params.topic,
787
+ reason: params.reason ?? "",
788
+ });
789
+ if (params.memoryId) {
790
+ for (const memoryId of params.memoryId.split(",").map((entry) => entry.trim())) {
791
+ if (memoryId) {
792
+ this.memoryCache.delete(this.memoryCacheKey(params.userId, memoryId));
793
+ }
794
+ }
795
+ }
796
+ return { purged: parsePurgedCount(text), message: text };
797
+ }
798
+
799
+ async profile(userId: string): Promise<MemoriaProfileResponse> {
800
+ const text = await this.callToolText(userId, "memory_profile", {});
801
+ const profile = text === "No profile memories found." ? null : text;
802
+ return {
803
+ user_id: userId,
804
+ profile,
805
+ };
806
+ }
807
+
808
+ async governance(params: {
809
+ userId: string;
810
+ force?: boolean;
811
+ }) {
812
+ return parseGenericResult(
813
+ await this.callToolText(params.userId, "memory_governance", {
814
+ force: params.force ?? false,
815
+ }),
816
+ );
817
+ }
818
+
819
+ async consolidate(params: {
820
+ userId: string;
821
+ force?: boolean;
822
+ }) {
823
+ return parseGenericResult(
824
+ await this.callToolText(params.userId, "memory_consolidate", {
825
+ force: params.force ?? false,
826
+ }),
827
+ );
828
+ }
829
+
830
+ async reflect(params: {
831
+ userId: string;
832
+ force?: boolean;
833
+ mode?: string;
834
+ }) {
835
+ return parseGenericResult(
836
+ await this.callToolText(params.userId, "memory_reflect", {
837
+ force: params.force ?? false,
838
+ mode: params.mode ?? "auto",
839
+ }),
840
+ );
841
+ }
842
+
843
+ async extractEntities(params: {
844
+ userId: string;
845
+ force?: boolean;
846
+ mode?: string;
847
+ }) {
848
+ return parseGenericResult(
849
+ await this.callToolText(params.userId, "memory_extract_entities", {
850
+ force: params.force ?? false,
851
+ mode: params.mode ?? "auto",
852
+ }),
853
+ );
854
+ }
855
+
856
+ async linkEntities(params: {
857
+ userId: string;
858
+ entities: Array<Record<string, unknown>>;
859
+ }) {
860
+ return parseGenericResult(
861
+ await this.callToolText(params.userId, "memory_link_entities", {
862
+ entities: JSON.stringify(params.entities),
863
+ }),
864
+ );
865
+ }
866
+
867
+ async rebuildIndex(table: string) {
868
+ return parseGenericResult(
869
+ await this.callToolText(this.config.defaultUserId, "memory_rebuild_index", {
870
+ table,
871
+ }),
872
+ );
873
+ }
874
+
875
+ async observe(params: {
876
+ userId: string;
877
+ messages: Array<{ role: string; content: string }>;
878
+ sourceEventIds?: string[];
879
+ sessionId?: string;
880
+ }) {
881
+ const text = await this.callToolText(params.userId, "memory_observe", {
882
+ messages: params.messages,
883
+ session_id: params.sessionId,
884
+ });
885
+ const payload = parseJsonText(text);
886
+ const memories = Array.isArray(payload?.memories)
887
+ ? payload.memories
888
+ .map((entry) => asRecord(entry))
889
+ .filter((entry): entry is Record<string, unknown> => Boolean(entry))
890
+ .map((entry) => normalizeMemoryRecord(entry))
891
+ : [];
892
+ this.cacheMemories(params.userId, memories);
893
+ return memories;
894
+ }
895
+
896
+ async createSnapshot(params: {
897
+ userId: string;
898
+ name: string;
899
+ description?: string;
900
+ }) {
901
+ const text = await this.callToolText(params.userId, "memory_snapshot", {
902
+ name: params.name,
903
+ description: params.description ?? "",
904
+ });
905
+ return parseSnapshotCreated(text, params.name);
906
+ }
907
+
908
+ async listSnapshots(userId: string) {
909
+ const text = await this.callToolText(userId, "memory_snapshots", {});
910
+ return parseSnapshotList(text);
911
+ }
912
+
913
+ async rollbackSnapshot(params: {
914
+ userId: string;
915
+ name: string;
916
+ }) {
917
+ return parseGenericResult(
918
+ await this.callToolText(params.userId, "memory_rollback", {
919
+ name: params.name,
920
+ }),
921
+ );
922
+ }
923
+
924
+ async branchCreate(params: {
925
+ userId: string;
926
+ name: string;
927
+ fromSnapshot?: string;
928
+ fromTimestamp?: string;
929
+ }) {
930
+ return parseGenericResult(
931
+ await this.callToolText(params.userId, "memory_branch", {
932
+ name: params.name,
933
+ from_snapshot: params.fromSnapshot,
934
+ from_timestamp: params.fromTimestamp,
935
+ }),
936
+ );
937
+ }
938
+
939
+ async branchList(userId: string) {
940
+ const text = await this.callToolText(userId, "memory_branches", {});
941
+ return parseBranches(text);
942
+ }
943
+
944
+ async branchCheckout(params: {
945
+ userId: string;
946
+ name: string;
947
+ }) {
948
+ return parseGenericResult(
949
+ await this.callToolText(params.userId, "memory_checkout", {
950
+ name: params.name,
951
+ }),
952
+ );
953
+ }
954
+
955
+ async branchDelete(params: {
956
+ userId: string;
957
+ name: string;
958
+ }) {
959
+ return parseGenericResult(
960
+ await this.callToolText(params.userId, "memory_branch_delete", {
961
+ name: params.name,
962
+ }),
963
+ );
964
+ }
965
+
966
+ async branchMerge(params: {
967
+ userId: string;
968
+ source: string;
969
+ strategy: string;
970
+ }) {
971
+ return parseGenericResult(
972
+ await this.callToolText(params.userId, "memory_merge", {
973
+ source: params.source,
974
+ strategy: params.strategy,
975
+ }),
976
+ );
977
+ }
978
+
979
+ async branchDiff(params: {
980
+ userId: string;
981
+ source: string;
982
+ limit: number;
983
+ }) {
984
+ return parseGenericResult(
985
+ await this.callToolText(params.userId, "memory_diff", {
986
+ source: params.source,
987
+ limit: params.limit,
988
+ }),
989
+ );
990
+ }
991
+
992
+ private cacheMemories(userId: string, memories: MemoriaMemoryRecord[]) {
993
+ for (const memory of memories) {
994
+ if (!memory.memory_id) {
995
+ continue;
996
+ }
997
+ this.memoryCache.set(this.memoryCacheKey(userId, memory.memory_id), memory);
998
+ }
999
+ }
1000
+
1001
+ private memoryCacheKey(userId: string, memoryId: string) {
1002
+ return `${userId}::${memoryId}`;
1003
+ }
1004
+
1005
+ private async callToolText(
1006
+ userId: string,
1007
+ name: string,
1008
+ args: Record<string, unknown>,
1009
+ ): Promise<string> {
1010
+ const session = this.getSession(userId);
1011
+ const result = await session.callTool(name, args);
1012
+ const text = extractToolText(result);
1013
+ if (text) {
1014
+ return text;
1015
+ }
1016
+ throw new Error(`Memoria tool '${name}' returned no text content.`);
1017
+ }
1018
+
1019
+ private getSession(userId: string): MemoriaMcpSession {
1020
+ const key = `${this.config.backend}:${userId}`;
1021
+ const existing = this.sessions.get(key);
1022
+ if (existing?.isAlive()) {
1023
+ return existing;
1024
+ }
1025
+ existing?.close();
1026
+ const created = new MemoriaMcpSession(this.config, userId);
1027
+ this.sessions.set(key, created);
1028
+ return created;
1029
+ }
1030
+ }