memory-braid 0.2.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,605 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { normalizeForHash } from "./chunking.js";
5
+ import type { MemoryBraidConfig } from "./config.js";
6
+ import { MemoryBraidLogger } from "./logger.js";
7
+ import type { MemoryBraidResult, ScopeKey } from "./types.js";
8
+
9
+ type CloudRecord = {
10
+ id?: string;
11
+ memory?: string;
12
+ data?: { memory?: string } | null;
13
+ score?: number;
14
+ metadata?: Record<string, unknown> | null;
15
+ };
16
+
17
+ type CloudClientLike = {
18
+ add: (
19
+ messages: Array<{ role: "user" | "assistant"; content: string }>,
20
+ options?: Record<string, unknown>,
21
+ ) => Promise<CloudRecord[]>;
22
+ search: (query: string, options?: Record<string, unknown>) => Promise<CloudRecord[]>;
23
+ delete: (memoryId: string) => Promise<unknown>;
24
+ };
25
+
26
+ type OssRecord = {
27
+ id?: string;
28
+ memory?: string;
29
+ score?: number;
30
+ metadata?: Record<string, unknown>;
31
+ };
32
+
33
+ type OssSearchResult = {
34
+ results?: OssRecord[];
35
+ relations?: unknown[];
36
+ };
37
+
38
+ type OssClientLike = {
39
+ add: (
40
+ messages: string | Array<{ role: string; content: string }>,
41
+ options: Record<string, unknown>,
42
+ ) => Promise<OssSearchResult>;
43
+ search: (query: string, options: Record<string, unknown>) => Promise<OssSearchResult>;
44
+ delete: (memoryId: string) => Promise<{ message: string }>;
45
+ };
46
+
47
+ function extractCloudText(memory: CloudRecord): string {
48
+ const byData = memory.data?.memory;
49
+ if (typeof byData === "string" && byData.trim()) {
50
+ return byData.trim();
51
+ }
52
+ if (typeof memory.memory === "string" && memory.memory.trim()) {
53
+ return memory.memory.trim();
54
+ }
55
+ return "";
56
+ }
57
+
58
+ function normalizeMetadata(value: unknown): Record<string, unknown> | undefined {
59
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
60
+ return undefined;
61
+ }
62
+ return value as Record<string, unknown>;
63
+ }
64
+
65
+ function buildCloudEntity(scope: ScopeKey): { user_id: string; agent_id: string; run_id?: string } {
66
+ const userId = `memory-braid:${scope.workspaceHash}`;
67
+ return {
68
+ user_id: userId,
69
+ agent_id: scope.agentId,
70
+ run_id: scope.sessionKey,
71
+ };
72
+ }
73
+
74
+ function buildOssEntity(scope: ScopeKey): { userId: string; agentId: string; runId?: string } {
75
+ const userId = `memory-braid:${scope.workspaceHash}`;
76
+ return {
77
+ userId,
78
+ agentId: scope.agentId,
79
+ runId: scope.sessionKey,
80
+ };
81
+ }
82
+
83
+ function isLikelyOssConfig(value: unknown): boolean {
84
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
85
+ return false;
86
+ }
87
+ return Object.keys(value as Record<string, unknown>).length > 0;
88
+ }
89
+
90
+ function asRecord(value: unknown): Record<string, unknown> {
91
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
92
+ return {};
93
+ }
94
+ return value as Record<string, unknown>;
95
+ }
96
+
97
+ function asNonEmptyString(value: unknown): string | undefined {
98
+ if (typeof value !== "string") {
99
+ return undefined;
100
+ }
101
+ const trimmed = value.trim();
102
+ return trimmed ? trimmed : undefined;
103
+ }
104
+
105
+ function resolveStateDir(explicitStateDir?: string): string {
106
+ const resolved =
107
+ explicitStateDir?.trim() ||
108
+ process.env.OPENCLAW_STATE_DIR?.trim() ||
109
+ path.join(os.homedir(), ".openclaw");
110
+ return path.resolve(resolved);
111
+ }
112
+
113
+ export function resolveDefaultOssStoragePaths(stateDir?: string): {
114
+ rootDir: string;
115
+ historyDbPath: string;
116
+ vectorDbPath: string;
117
+ } {
118
+ const rootDir = path.join(resolveStateDir(stateDir), "memory-braid");
119
+ return {
120
+ rootDir,
121
+ historyDbPath: path.join(rootDir, "mem0-history.db"),
122
+ vectorDbPath: path.join(rootDir, "mem0-vector-store.db"),
123
+ };
124
+ }
125
+
126
+ export function applyOssStorageDefaults(
127
+ source: Record<string, unknown>,
128
+ stateDir?: string,
129
+ ): Record<string, unknown> {
130
+ const { historyDbPath, vectorDbPath } = resolveDefaultOssStoragePaths(stateDir);
131
+ const merged: Record<string, unknown> = { ...source };
132
+
133
+ if (!asNonEmptyString(merged.historyDbPath)) {
134
+ merged.historyDbPath = historyDbPath;
135
+ }
136
+
137
+ const vectorStore = asRecord(merged.vectorStore);
138
+ const vectorProvider = asNonEmptyString(vectorStore.provider)?.toLowerCase();
139
+ if (!vectorProvider) {
140
+ merged.vectorStore = {
141
+ provider: "memory",
142
+ config: {
143
+ collectionName: "memories",
144
+ dimension: 1536,
145
+ dbPath: vectorDbPath,
146
+ },
147
+ };
148
+ } else if (vectorProvider === "memory") {
149
+ const vectorConfig = asRecord(vectorStore.config);
150
+ if (!asNonEmptyString(vectorConfig.dbPath)) {
151
+ merged.vectorStore = {
152
+ ...vectorStore,
153
+ config: {
154
+ ...vectorConfig,
155
+ dbPath: vectorDbPath,
156
+ },
157
+ };
158
+ }
159
+ }
160
+
161
+ const historyStore = asRecord(merged.historyStore);
162
+ const historyProvider = asNonEmptyString(historyStore.provider)?.toLowerCase();
163
+ if (!historyProvider) {
164
+ merged.historyStore = {
165
+ provider: "sqlite",
166
+ config: {
167
+ historyDbPath,
168
+ },
169
+ };
170
+ } else if (historyProvider === "sqlite") {
171
+ const historyConfig = asRecord(historyStore.config);
172
+ if (!asNonEmptyString(historyConfig.historyDbPath)) {
173
+ merged.historyStore = {
174
+ ...historyStore,
175
+ config: {
176
+ ...historyConfig,
177
+ historyDbPath,
178
+ },
179
+ };
180
+ }
181
+ }
182
+
183
+ return merged;
184
+ }
185
+
186
+ function collectSqliteDbPaths(config: Record<string, unknown>): string[] {
187
+ const paths: string[] = [];
188
+
189
+ const historyStore = asRecord(config.historyStore);
190
+ const historyProvider = asNonEmptyString(historyStore.provider)?.toLowerCase();
191
+ if (historyProvider === "sqlite") {
192
+ const historyPath = asNonEmptyString(asRecord(historyStore.config).historyDbPath);
193
+ if (historyPath && historyPath !== ":memory:") {
194
+ paths.push(historyPath);
195
+ }
196
+ } else {
197
+ const historyPath = asNonEmptyString(config.historyDbPath);
198
+ if (historyPath && historyPath !== ":memory:") {
199
+ paths.push(historyPath);
200
+ }
201
+ }
202
+
203
+ const vectorStore = asRecord(config.vectorStore);
204
+ const vectorProvider = asNonEmptyString(vectorStore.provider)?.toLowerCase();
205
+ if (vectorProvider === "memory") {
206
+ const vectorPath = asNonEmptyString(asRecord(vectorStore.config).dbPath);
207
+ if (vectorPath && vectorPath !== ":memory:") {
208
+ paths.push(vectorPath);
209
+ }
210
+ }
211
+
212
+ return Array.from(new Set(paths.map((entry) => path.resolve(entry))));
213
+ }
214
+
215
+ async function ensureSqliteParentDirs(config: Record<string, unknown>): Promise<void> {
216
+ for (const dbPath of collectSqliteDbPaths(config)) {
217
+ await fs.mkdir(path.dirname(dbPath), { recursive: true });
218
+ }
219
+ }
220
+
221
+ function buildDefaultOssConfig(cfg: MemoryBraidConfig, stateDir?: string): Record<string, unknown> {
222
+ const openAiKey = cfg.mem0.apiKey?.trim() || process.env.OPENAI_API_KEY || "";
223
+ return applyOssStorageDefaults({
224
+ version: "v1.1",
225
+ embedder: {
226
+ provider: "openai",
227
+ config: {
228
+ apiKey: openAiKey,
229
+ model: "text-embedding-3-small",
230
+ },
231
+ },
232
+ vectorStore: {
233
+ provider: "memory",
234
+ config: {
235
+ collectionName: "memories",
236
+ dimension: 1536,
237
+ },
238
+ },
239
+ llm: {
240
+ provider: "openai",
241
+ config: {
242
+ apiKey: openAiKey,
243
+ model: "gpt-4o-mini",
244
+ },
245
+ },
246
+ enableGraph: false,
247
+ }, stateDir);
248
+ }
249
+
250
+ type Mem0AdapterOptions = {
251
+ stateDir?: string;
252
+ };
253
+
254
+ export class Mem0Adapter {
255
+ private cloudClient: CloudClientLike | null = null;
256
+ private ossClient: OssClientLike | null = null;
257
+ private readonly cfg: MemoryBraidConfig;
258
+ private readonly log: MemoryBraidLogger;
259
+ private stateDir?: string;
260
+
261
+ constructor(cfg: MemoryBraidConfig, log: MemoryBraidLogger, options?: Mem0AdapterOptions) {
262
+ this.cfg = cfg;
263
+ this.log = log;
264
+ this.stateDir = options?.stateDir;
265
+ }
266
+
267
+ setStateDir(stateDir?: string): void {
268
+ const next = stateDir?.trim();
269
+ if (!next || next === this.stateDir) {
270
+ return;
271
+ }
272
+ this.stateDir = next;
273
+ this.ossClient = null;
274
+ }
275
+
276
+ private async ensureCloudClient(): Promise<CloudClientLike | null> {
277
+ if (this.cloudClient) {
278
+ return this.cloudClient;
279
+ }
280
+
281
+ const apiKey = this.cfg.mem0.apiKey?.trim() || process.env.MEM0_API_KEY;
282
+ if (!apiKey) {
283
+ this.log.warn("memory_braid.mem0.error", {
284
+ reason: "api_key_missing",
285
+ mode: "cloud",
286
+ });
287
+ return null;
288
+ }
289
+
290
+ try {
291
+ const mod = await import("mem0ai");
292
+ const MemoryClient = mod.MemoryClient ?? mod.default;
293
+ this.cloudClient = new MemoryClient({
294
+ apiKey,
295
+ host: this.cfg.mem0.host,
296
+ organizationId: this.cfg.mem0.organizationId,
297
+ projectId: this.cfg.mem0.projectId,
298
+ }) as CloudClientLike;
299
+ this.log.debug("memory_braid.mem0.response", {
300
+ action: "init",
301
+ mode: "cloud",
302
+ host: this.cfg.mem0.host,
303
+ }, true);
304
+ return this.cloudClient;
305
+ } catch (err) {
306
+ this.log.error("memory_braid.mem0.error", {
307
+ reason: "init_failed",
308
+ mode: "cloud",
309
+ error: err instanceof Error ? err.message : String(err),
310
+ });
311
+ return null;
312
+ }
313
+ }
314
+
315
+ private async ensureOssClient(): Promise<OssClientLike | null> {
316
+ if (this.ossClient) {
317
+ return this.ossClient;
318
+ }
319
+
320
+ try {
321
+ const mod = await import("mem0ai/oss");
322
+ const Memory = (mod as { Memory?: new (config?: Record<string, unknown>) => OssClientLike })
323
+ .Memory;
324
+ if (!Memory) {
325
+ throw new Error("mem0ai/oss Memory export not found");
326
+ }
327
+
328
+ const providedConfig = this.cfg.mem0.ossConfig;
329
+ const hasCustomConfig = isLikelyOssConfig(providedConfig);
330
+ const baseConfig = hasCustomConfig
331
+ ? { ...providedConfig }
332
+ : buildDefaultOssConfig(this.cfg, this.stateDir);
333
+ const configToUse = applyOssStorageDefaults(baseConfig, this.stateDir);
334
+ await ensureSqliteParentDirs(configToUse);
335
+
336
+ this.ossClient = new Memory(configToUse);
337
+ this.log.debug("memory_braid.mem0.response", {
338
+ action: "init",
339
+ mode: "oss",
340
+ hasCustomConfig,
341
+ sqliteDbPaths: collectSqliteDbPaths(configToUse),
342
+ }, true);
343
+ return this.ossClient;
344
+ } catch (err) {
345
+ this.log.error("memory_braid.mem0.error", {
346
+ reason: "init_failed",
347
+ mode: "oss",
348
+ error: err instanceof Error ? err.message : String(err),
349
+ });
350
+ return null;
351
+ }
352
+ }
353
+
354
+ async ensureClient(): Promise<{ mode: "cloud" | "oss"; client: CloudClientLike | OssClientLike } | null> {
355
+ if (this.cfg.mem0.mode === "oss") {
356
+ const client = await this.ensureOssClient();
357
+ if (!client) {
358
+ return null;
359
+ }
360
+ return { mode: "oss", client };
361
+ }
362
+
363
+ const client = await this.ensureCloudClient();
364
+ if (!client) {
365
+ return null;
366
+ }
367
+ return { mode: "cloud", client };
368
+ }
369
+
370
+ async addMemory(params: {
371
+ text: string;
372
+ scope: ScopeKey;
373
+ metadata: Record<string, unknown>;
374
+ runId?: string;
375
+ }): Promise<{ id?: string }> {
376
+ const prepared = await this.ensureClient();
377
+ if (!prepared) {
378
+ return {};
379
+ }
380
+
381
+ const startedAt = Date.now();
382
+ this.log.debug("memory_braid.mem0.request", {
383
+ runId: params.runId,
384
+ action: "add",
385
+ mode: prepared.mode,
386
+ workspaceHash: params.scope.workspaceHash,
387
+ agentId: params.scope.agentId,
388
+ });
389
+
390
+ try {
391
+ if (prepared.mode === "cloud") {
392
+ const entity = buildCloudEntity(params.scope);
393
+ const result = await prepared.client.add(
394
+ [{ role: "user", content: params.text }],
395
+ {
396
+ ...entity,
397
+ metadata: params.metadata,
398
+ infer: true,
399
+ },
400
+ );
401
+ const id = Array.isArray(result) ? result[0]?.id : undefined;
402
+ this.log.debug("memory_braid.mem0.response", {
403
+ runId: params.runId,
404
+ action: "add",
405
+ mode: prepared.mode,
406
+ workspaceHash: params.scope.workspaceHash,
407
+ agentId: params.scope.agentId,
408
+ durMs: Date.now() - startedAt,
409
+ hasId: Boolean(id),
410
+ });
411
+ return { id };
412
+ }
413
+
414
+ const entity = buildOssEntity(params.scope);
415
+ const result = await prepared.client.add([{ role: "user", content: params.text }], {
416
+ ...entity,
417
+ metadata: params.metadata,
418
+ infer: true,
419
+ });
420
+ const id = result.results?.[0]?.id;
421
+ this.log.debug("memory_braid.mem0.response", {
422
+ runId: params.runId,
423
+ action: "add",
424
+ mode: prepared.mode,
425
+ workspaceHash: params.scope.workspaceHash,
426
+ agentId: params.scope.agentId,
427
+ durMs: Date.now() - startedAt,
428
+ hasId: Boolean(id),
429
+ });
430
+ return { id };
431
+ } catch (err) {
432
+ this.log.warn("memory_braid.mem0.error", {
433
+ runId: params.runId,
434
+ action: "add",
435
+ mode: prepared.mode,
436
+ workspaceHash: params.scope.workspaceHash,
437
+ agentId: params.scope.agentId,
438
+ durMs: Date.now() - startedAt,
439
+ error: err instanceof Error ? err.message : String(err),
440
+ });
441
+ return {};
442
+ }
443
+ }
444
+
445
+ async searchMemories(params: {
446
+ query: string;
447
+ maxResults: number;
448
+ scope: ScopeKey;
449
+ runId?: string;
450
+ }): Promise<MemoryBraidResult[]> {
451
+ const prepared = await this.ensureClient();
452
+ if (!prepared) {
453
+ return [];
454
+ }
455
+
456
+ const startedAt = Date.now();
457
+ this.log.debug("memory_braid.mem0.request", {
458
+ runId: params.runId,
459
+ action: "search",
460
+ mode: prepared.mode,
461
+ workspaceHash: params.scope.workspaceHash,
462
+ agentId: params.scope.agentId,
463
+ maxResults: params.maxResults,
464
+ });
465
+
466
+ try {
467
+ let mapped: MemoryBraidResult[] = [];
468
+ if (prepared.mode === "cloud") {
469
+ const entity = buildCloudEntity(params.scope);
470
+ const records = await prepared.client.search(params.query, {
471
+ ...entity,
472
+ limit: params.maxResults,
473
+ });
474
+
475
+ mapped = records
476
+ .map((record) => {
477
+ const snippet = extractCloudText(record);
478
+ if (!snippet) {
479
+ return null;
480
+ }
481
+ const metadata = normalizeMetadata(record.metadata);
482
+ return {
483
+ id: record.id,
484
+ source: "mem0" as const,
485
+ path: typeof metadata?.path === "string" ? metadata.path : undefined,
486
+ snippet,
487
+ score: typeof record.score === "number" ? record.score : 0,
488
+ metadata,
489
+ chunkKey: typeof metadata?.chunkKey === "string" ? metadata.chunkKey : undefined,
490
+ contentHash:
491
+ typeof metadata?.contentHash === "string" ? metadata.contentHash : undefined,
492
+ };
493
+ })
494
+ .filter((entry): entry is MemoryBraidResult => Boolean(entry));
495
+ } else {
496
+ const entity = buildOssEntity(params.scope);
497
+ const result = await prepared.client.search(params.query, {
498
+ ...entity,
499
+ limit: params.maxResults,
500
+ });
501
+ const records = result.results ?? [];
502
+ mapped = records
503
+ .map((record) => {
504
+ const snippet = typeof record.memory === "string" ? record.memory.trim() : "";
505
+ if (!snippet) {
506
+ return null;
507
+ }
508
+ const metadata = normalizeMetadata(record.metadata);
509
+ return {
510
+ id: record.id,
511
+ source: "mem0" as const,
512
+ path: typeof metadata?.path === "string" ? metadata.path : undefined,
513
+ snippet,
514
+ score: typeof record.score === "number" ? record.score : 0,
515
+ metadata,
516
+ chunkKey: typeof metadata?.chunkKey === "string" ? metadata.chunkKey : undefined,
517
+ contentHash:
518
+ typeof metadata?.contentHash === "string" ? metadata.contentHash : undefined,
519
+ };
520
+ })
521
+ .filter((entry): entry is MemoryBraidResult => Boolean(entry));
522
+ }
523
+
524
+ this.log.debug("memory_braid.mem0.response", {
525
+ runId: params.runId,
526
+ action: "search",
527
+ mode: prepared.mode,
528
+ workspaceHash: params.scope.workspaceHash,
529
+ agentId: params.scope.agentId,
530
+ durMs: Date.now() - startedAt,
531
+ count: mapped.length,
532
+ });
533
+
534
+ return mapped;
535
+ } catch (err) {
536
+ this.log.warn("memory_braid.mem0.error", {
537
+ runId: params.runId,
538
+ action: "search",
539
+ mode: prepared.mode,
540
+ workspaceHash: params.scope.workspaceHash,
541
+ agentId: params.scope.agentId,
542
+ durMs: Date.now() - startedAt,
543
+ error: err instanceof Error ? err.message : String(err),
544
+ });
545
+ return [];
546
+ }
547
+ }
548
+
549
+ async deleteMemory(params: {
550
+ memoryId?: string;
551
+ scope: ScopeKey;
552
+ runId?: string;
553
+ }): Promise<boolean> {
554
+ const prepared = await this.ensureClient();
555
+ if (!prepared || !params.memoryId) {
556
+ return false;
557
+ }
558
+
559
+ const startedAt = Date.now();
560
+ try {
561
+ await prepared.client.delete(params.memoryId);
562
+ this.log.debug("memory_braid.mem0.response", {
563
+ runId: params.runId,
564
+ action: "delete",
565
+ mode: prepared.mode,
566
+ workspaceHash: params.scope.workspaceHash,
567
+ agentId: params.scope.agentId,
568
+ durMs: Date.now() - startedAt,
569
+ });
570
+ return true;
571
+ } catch (err) {
572
+ this.log.warn("memory_braid.mem0.error", {
573
+ runId: params.runId,
574
+ action: "delete",
575
+ mode: prepared.mode,
576
+ workspaceHash: params.scope.workspaceHash,
577
+ agentId: params.scope.agentId,
578
+ durMs: Date.now() - startedAt,
579
+ error: err instanceof Error ? err.message : String(err),
580
+ });
581
+ return false;
582
+ }
583
+ }
584
+
585
+ async semanticSimilarity(params: {
586
+ leftText: string;
587
+ rightText: string;
588
+ scope: ScopeKey;
589
+ runId?: string;
590
+ }): Promise<number | undefined> {
591
+ const rightHash = normalizeForHash(params.rightText);
592
+ const results = await this.searchMemories({
593
+ query: params.leftText,
594
+ maxResults: 5,
595
+ scope: params.scope,
596
+ runId: params.runId,
597
+ });
598
+ for (const result of results) {
599
+ if (normalizeForHash(result.snippet) === rightHash) {
600
+ return result.score;
601
+ }
602
+ }
603
+ return undefined;
604
+ }
605
+ }
package/src/merge.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { normalizeForHash, sha256 } from "./chunking.js";
2
+ import type { MemoryBraidResult } from "./types.js";
3
+
4
+ export type MergeOptions = {
5
+ rrfK: number;
6
+ localWeight: number;
7
+ mem0Weight: number;
8
+ };
9
+
10
+ function identityKey(item: MemoryBraidResult): string {
11
+ if (item.chunkKey) {
12
+ return `chunk:${item.chunkKey}`;
13
+ }
14
+ const path = item.path ?? "";
15
+ const startLine = item.startLine ?? 0;
16
+ const endLine = item.endLine ?? 0;
17
+ const textHash = sha256(normalizeForHash(item.snippet));
18
+ return `${item.source}|${path}|${startLine}|${endLine}|${textHash}`;
19
+ }
20
+
21
+ export function mergeWithRrf(params: {
22
+ local: MemoryBraidResult[];
23
+ mem0: MemoryBraidResult[];
24
+ options: MergeOptions;
25
+ }): MemoryBraidResult[] {
26
+ const table = new Map<string, MemoryBraidResult & { _rrf: number }>();
27
+
28
+ params.local.forEach((item, index) => {
29
+ const key = identityKey(item);
30
+ const prev = table.get(key);
31
+ const score = params.options.localWeight / (params.options.rrfK + index + 1);
32
+ if (!prev) {
33
+ table.set(key, { ...item, _rrf: score });
34
+ return;
35
+ }
36
+ prev._rrf += score;
37
+ });
38
+
39
+ params.mem0.forEach((item, index) => {
40
+ const key = identityKey(item);
41
+ const prev = table.get(key);
42
+ const score = params.options.mem0Weight / (params.options.rrfK + index + 1);
43
+ if (!prev) {
44
+ table.set(key, { ...item, _rrf: score });
45
+ return;
46
+ }
47
+ prev._rrf += score;
48
+ if (prev.source !== item.source) {
49
+ prev.source = "mem0";
50
+ }
51
+ });
52
+
53
+ return Array.from(table.values())
54
+ .sort((a, b) => b._rrf - a._rrf)
55
+ .map((item) => ({ ...item, mergedScore: item._rrf }));
56
+ }