memory-braid 0.2.0 → 0.3.3
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 +250 -8
- package/openclaw.plugin.json +34 -11
- package/package.json +2 -1
- package/src/config.ts +58 -15
- package/src/entities.ts +354 -0
- package/src/extract.ts +155 -28
- package/src/index.ts +176 -2
- package/src/mem0-client.ts +32 -3
- package/src/state.ts +71 -2
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from "openclaw/plugin-sdk";
|
|
6
6
|
import { parseConfig, pluginConfigSchema } from "./config.js";
|
|
7
7
|
import { stagedDedupe } from "./dedupe.js";
|
|
8
|
+
import { EntityExtractionManager } from "./entities.js";
|
|
8
9
|
import { extractCandidates } from "./extract.js";
|
|
9
10
|
import { MemoryBraidLogger } from "./logger.js";
|
|
10
11
|
import { resolveLocalTools, runLocalGet, runLocalSearch } from "./local-memory.js";
|
|
@@ -75,6 +76,25 @@ function formatRelevantMemories(results: MemoryBraidResult[], maxChars = 600): s
|
|
|
75
76
|
].join("\n");
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
function formatEntityExtractionStatus(params: {
|
|
80
|
+
enabled: boolean;
|
|
81
|
+
provider: string;
|
|
82
|
+
model: string;
|
|
83
|
+
minScore: number;
|
|
84
|
+
maxEntitiesPerMemory: number;
|
|
85
|
+
cacheDir: string;
|
|
86
|
+
}): string {
|
|
87
|
+
return [
|
|
88
|
+
"Memory Braid entity extraction:",
|
|
89
|
+
`- enabled: ${params.enabled}`,
|
|
90
|
+
`- provider: ${params.provider}`,
|
|
91
|
+
`- model: ${params.model}`,
|
|
92
|
+
`- minScore: ${params.minScore}`,
|
|
93
|
+
`- maxEntitiesPerMemory: ${params.maxEntitiesPerMemory}`,
|
|
94
|
+
`- cacheDir: ${params.cacheDir}`,
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
78
98
|
async function runHybridRecall(params: {
|
|
79
99
|
api: OpenClawPluginApi;
|
|
80
100
|
cfg: ReturnType<typeof parseConfig>;
|
|
@@ -94,6 +114,13 @@ async function runHybridRecall(params: {
|
|
|
94
114
|
}> {
|
|
95
115
|
const local = resolveLocalTools(params.api, params.ctx);
|
|
96
116
|
if (!local.searchTool) {
|
|
117
|
+
params.log.warn("memory_braid.search.skip", {
|
|
118
|
+
runId: params.runId,
|
|
119
|
+
reason: "local_search_tool_unavailable",
|
|
120
|
+
agentId: params.ctx.agentId,
|
|
121
|
+
sessionKey: params.ctx.sessionKey,
|
|
122
|
+
workspaceHash: workspaceHashFromDir(params.ctx.workspaceDir),
|
|
123
|
+
});
|
|
97
124
|
return { local: [], mem0: [], merged: [] };
|
|
98
125
|
}
|
|
99
126
|
|
|
@@ -190,6 +217,9 @@ const memoryBraidPlugin = {
|
|
|
190
217
|
const log = new MemoryBraidLogger(api.logger, cfg.debug);
|
|
191
218
|
const initialStateDir = api.runtime.state.resolveStateDir();
|
|
192
219
|
const mem0 = new Mem0Adapter(cfg, log, { stateDir: initialStateDir });
|
|
220
|
+
const entityExtraction = new EntityExtractionManager(cfg.entityExtraction, log, {
|
|
221
|
+
stateDir: initialStateDir,
|
|
222
|
+
});
|
|
193
223
|
|
|
194
224
|
let serviceTimer: NodeJS.Timeout | null = null;
|
|
195
225
|
let statePaths: StatePaths | null = null;
|
|
@@ -288,6 +318,61 @@ const memoryBraidPlugin = {
|
|
|
288
318
|
{ names: ["memory_search", "memory_get"] },
|
|
289
319
|
);
|
|
290
320
|
|
|
321
|
+
api.registerCommand({
|
|
322
|
+
name: "memorybraid",
|
|
323
|
+
description: "Memory Braid status and entity extraction warmup.",
|
|
324
|
+
acceptsArgs: true,
|
|
325
|
+
handler: async (ctx) => {
|
|
326
|
+
const args = ctx.args?.trim() ?? "";
|
|
327
|
+
const tokens = args.split(/\s+/).filter(Boolean);
|
|
328
|
+
const action = (tokens[0] ?? "status").toLowerCase();
|
|
329
|
+
|
|
330
|
+
if (action === "status") {
|
|
331
|
+
return {
|
|
332
|
+
text: [
|
|
333
|
+
`capture.mode: ${cfg.capture.mode}`,
|
|
334
|
+
formatEntityExtractionStatus(entityExtraction.getStatus()),
|
|
335
|
+
].join("\n\n"),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (action === "warmup") {
|
|
340
|
+
const runId = log.newRunId();
|
|
341
|
+
const forceReload = tokens.some((token) => token === "--force");
|
|
342
|
+
const result = await entityExtraction.warmup({
|
|
343
|
+
runId,
|
|
344
|
+
reason: "command",
|
|
345
|
+
forceReload,
|
|
346
|
+
});
|
|
347
|
+
if (!result.ok) {
|
|
348
|
+
return {
|
|
349
|
+
text: [
|
|
350
|
+
"Entity extraction warmup failed.",
|
|
351
|
+
`- model: ${result.model}`,
|
|
352
|
+
`- cacheDir: ${result.cacheDir}`,
|
|
353
|
+
`- durMs: ${result.durMs}`,
|
|
354
|
+
`- error: ${result.error ?? "unknown"}`,
|
|
355
|
+
].join("\n"),
|
|
356
|
+
isError: true,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
text: [
|
|
361
|
+
"Entity extraction warmup complete.",
|
|
362
|
+
`- model: ${result.model}`,
|
|
363
|
+
`- cacheDir: ${result.cacheDir}`,
|
|
364
|
+
`- entities: ${result.entities}`,
|
|
365
|
+
`- durMs: ${result.durMs}`,
|
|
366
|
+
].join("\n"),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
text: "Usage: /memorybraid [status|warmup [--force]]",
|
|
372
|
+
};
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
291
376
|
api.on("before_agent_start", async (event, ctx) => {
|
|
292
377
|
const runId = log.newRunId();
|
|
293
378
|
const toolCtx: OpenClawPluginToolContext = {
|
|
@@ -375,14 +460,21 @@ const memoryBraidPlugin = {
|
|
|
375
460
|
}
|
|
376
461
|
|
|
377
462
|
let persisted = 0;
|
|
463
|
+
let dedupeSkipped = 0;
|
|
464
|
+
let entityAnnotatedCandidates = 0;
|
|
465
|
+
let totalEntitiesAttached = 0;
|
|
466
|
+
let mem0AddAttempts = 0;
|
|
467
|
+
let mem0AddWithId = 0;
|
|
468
|
+
let mem0AddWithoutId = 0;
|
|
378
469
|
for (const candidate of candidates) {
|
|
379
470
|
const hash = sha256(normalizeForHash(candidate.text));
|
|
380
471
|
if (dedupe.seen[hash]) {
|
|
472
|
+
dedupeSkipped += 1;
|
|
381
473
|
continue;
|
|
382
474
|
}
|
|
383
475
|
dedupe.seen[hash] = now;
|
|
384
476
|
|
|
385
|
-
const metadata = {
|
|
477
|
+
const metadata: Record<string, unknown> = {
|
|
386
478
|
sourceType: "capture",
|
|
387
479
|
workspaceHash: scope.workspaceHash,
|
|
388
480
|
agentId: scope.agentId,
|
|
@@ -394,23 +486,59 @@ const memoryBraidPlugin = {
|
|
|
394
486
|
indexedAt: new Date().toISOString(),
|
|
395
487
|
};
|
|
396
488
|
|
|
397
|
-
|
|
489
|
+
if (cfg.entityExtraction.enabled) {
|
|
490
|
+
const entities = await entityExtraction.extract({
|
|
491
|
+
text: candidate.text,
|
|
492
|
+
runId,
|
|
493
|
+
});
|
|
494
|
+
if (entities.length > 0) {
|
|
495
|
+
entityAnnotatedCandidates += 1;
|
|
496
|
+
totalEntitiesAttached += entities.length;
|
|
497
|
+
metadata.entityUris = entities.map((entity) => entity.canonicalUri);
|
|
498
|
+
metadata.entities = entities;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
mem0AddAttempts += 1;
|
|
503
|
+
const addResult = await mem0.addMemory({
|
|
398
504
|
text: candidate.text,
|
|
399
505
|
scope,
|
|
400
506
|
metadata,
|
|
401
507
|
runId,
|
|
402
508
|
});
|
|
509
|
+
if (addResult.id) {
|
|
510
|
+
mem0AddWithId += 1;
|
|
511
|
+
} else {
|
|
512
|
+
mem0AddWithoutId += 1;
|
|
513
|
+
log.warn("memory_braid.capture.persist", {
|
|
514
|
+
runId,
|
|
515
|
+
reason: "mem0_add_missing_id",
|
|
516
|
+
workspaceHash: scope.workspaceHash,
|
|
517
|
+
agentId: scope.agentId,
|
|
518
|
+
sessionKey: scope.sessionKey,
|
|
519
|
+
contentHashPrefix: hash.slice(0, 12),
|
|
520
|
+
category: candidate.category,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
403
523
|
persisted += 1;
|
|
404
524
|
}
|
|
405
525
|
|
|
406
526
|
await writeCaptureDedupeState(statePaths, dedupe);
|
|
407
527
|
log.debug("memory_braid.capture.persist", {
|
|
408
528
|
runId,
|
|
529
|
+
mode: cfg.capture.mode,
|
|
409
530
|
workspaceHash: scope.workspaceHash,
|
|
410
531
|
agentId: scope.agentId,
|
|
411
532
|
sessionKey: scope.sessionKey,
|
|
412
533
|
candidates: candidates.length,
|
|
534
|
+
dedupeSkipped,
|
|
413
535
|
persisted,
|
|
536
|
+
mem0AddAttempts,
|
|
537
|
+
mem0AddWithId,
|
|
538
|
+
mem0AddWithoutId,
|
|
539
|
+
entityExtractionEnabled: cfg.entityExtraction.enabled,
|
|
540
|
+
entityAnnotatedCandidates,
|
|
541
|
+
totalEntitiesAttached,
|
|
414
542
|
}, true);
|
|
415
543
|
});
|
|
416
544
|
|
|
@@ -418,6 +546,7 @@ const memoryBraidPlugin = {
|
|
|
418
546
|
id: "memory-braid-service",
|
|
419
547
|
start: async (ctx) => {
|
|
420
548
|
mem0.setStateDir(ctx.stateDir);
|
|
549
|
+
entityExtraction.setStateDir(ctx.stateDir);
|
|
421
550
|
statePaths = createStatePaths(ctx.stateDir);
|
|
422
551
|
await ensureStateDir(statePaths);
|
|
423
552
|
targets = await resolveTargets({
|
|
@@ -437,6 +566,24 @@ const memoryBraidPlugin = {
|
|
|
437
566
|
stateDir: ctx.stateDir,
|
|
438
567
|
targets: targets.length,
|
|
439
568
|
});
|
|
569
|
+
log.info("memory_braid.config", {
|
|
570
|
+
runId,
|
|
571
|
+
mem0Mode: cfg.mem0.mode,
|
|
572
|
+
captureEnabled: cfg.capture.enabled,
|
|
573
|
+
captureMode: cfg.capture.mode,
|
|
574
|
+
captureMaxItemsPerRun: cfg.capture.maxItemsPerRun,
|
|
575
|
+
captureMlProvider: cfg.capture.ml.provider ?? "unset",
|
|
576
|
+
captureMlModel: cfg.capture.ml.model ?? "unset",
|
|
577
|
+
entityExtractionEnabled: cfg.entityExtraction.enabled,
|
|
578
|
+
entityProvider: cfg.entityExtraction.provider,
|
|
579
|
+
entityModel: cfg.entityExtraction.model,
|
|
580
|
+
entityMinScore: cfg.entityExtraction.minScore,
|
|
581
|
+
entityMaxPerMemory: cfg.entityExtraction.maxEntitiesPerMemory,
|
|
582
|
+
entityWarmupOnStartup: cfg.entityExtraction.startup.downloadOnStartup,
|
|
583
|
+
debugEnabled: cfg.debug.enabled,
|
|
584
|
+
debugIncludePayloads: cfg.debug.includePayloads,
|
|
585
|
+
debugSamplingRate: cfg.debug.logSamplingRate,
|
|
586
|
+
});
|
|
440
587
|
|
|
441
588
|
// Bootstrap is async by design so tool availability is not blocked.
|
|
442
589
|
void runBootstrapIfNeeded({
|
|
@@ -446,6 +593,11 @@ const memoryBraidPlugin = {
|
|
|
446
593
|
log,
|
|
447
594
|
targets,
|
|
448
595
|
runId,
|
|
596
|
+
}).catch((err) => {
|
|
597
|
+
log.warn("memory_braid.bootstrap.error", {
|
|
598
|
+
runId,
|
|
599
|
+
error: err instanceof Error ? err.message : String(err),
|
|
600
|
+
});
|
|
449
601
|
});
|
|
450
602
|
|
|
451
603
|
// One startup reconcile pass (non-blocking).
|
|
@@ -456,8 +608,30 @@ const memoryBraidPlugin = {
|
|
|
456
608
|
log,
|
|
457
609
|
targets,
|
|
458
610
|
reason: "startup",
|
|
611
|
+
runId,
|
|
612
|
+
}).catch((err) => {
|
|
613
|
+
log.warn("memory_braid.reconcile.error", {
|
|
614
|
+
runId,
|
|
615
|
+
reason: "startup",
|
|
616
|
+
error: err instanceof Error ? err.message : String(err),
|
|
617
|
+
});
|
|
459
618
|
});
|
|
460
619
|
|
|
620
|
+
if (cfg.entityExtraction.enabled && cfg.entityExtraction.startup.downloadOnStartup) {
|
|
621
|
+
void entityExtraction
|
|
622
|
+
.warmup({
|
|
623
|
+
runId,
|
|
624
|
+
reason: "startup",
|
|
625
|
+
})
|
|
626
|
+
.catch((err) => {
|
|
627
|
+
log.warn("memory_braid.entity.warmup", {
|
|
628
|
+
runId,
|
|
629
|
+
reason: "startup",
|
|
630
|
+
error: err instanceof Error ? err.message : String(err),
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
461
635
|
if (cfg.reconcile.enabled) {
|
|
462
636
|
const intervalMs = cfg.reconcile.intervalMinutes * 60 * 1000;
|
|
463
637
|
serviceTimer = setInterval(() => {
|
package/src/mem0-client.ts
CHANGED
|
@@ -44,6 +44,8 @@ type OssClientLike = {
|
|
|
44
44
|
delete: (memoryId: string) => Promise<{ message: string }>;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
type OssMemoryCtor = new (config?: Record<string, unknown>) => OssClientLike;
|
|
48
|
+
|
|
47
49
|
function extractCloudText(memory: CloudRecord): string {
|
|
48
50
|
const byData = memory.data?.memory;
|
|
49
51
|
if (typeof byData === "string" && byData.trim()) {
|
|
@@ -102,6 +104,30 @@ function asNonEmptyString(value: unknown): string | undefined {
|
|
|
102
104
|
return trimmed ? trimmed : undefined;
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
function asOssMemoryCtor(value: unknown): OssMemoryCtor | undefined {
|
|
108
|
+
if (typeof value !== "function") {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
return value as OssMemoryCtor;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function resolveOssMemoryCtor(moduleValue: unknown): OssMemoryCtor | undefined {
|
|
115
|
+
if (!moduleValue) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const mod = asRecord(moduleValue);
|
|
120
|
+
const defaultMod = asRecord(mod.default);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
asOssMemoryCtor(mod.Memory) ??
|
|
124
|
+
asOssMemoryCtor(mod.MemoryClient) ??
|
|
125
|
+
asOssMemoryCtor(defaultMod.Memory) ??
|
|
126
|
+
asOssMemoryCtor(defaultMod.MemoryClient) ??
|
|
127
|
+
asOssMemoryCtor(mod.default)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
105
131
|
function resolveStateDir(explicitStateDir?: string): string {
|
|
106
132
|
const resolved =
|
|
107
133
|
explicitStateDir?.trim() ||
|
|
@@ -319,10 +345,13 @@ export class Mem0Adapter {
|
|
|
319
345
|
|
|
320
346
|
try {
|
|
321
347
|
const mod = await import("mem0ai/oss");
|
|
322
|
-
const Memory = (mod
|
|
323
|
-
.Memory;
|
|
348
|
+
const Memory = resolveOssMemoryCtor(mod);
|
|
324
349
|
if (!Memory) {
|
|
325
|
-
|
|
350
|
+
const exportKeys = Object.keys(asRecord(mod));
|
|
351
|
+
const defaultKeys = Object.keys(asRecord(asRecord(mod).default));
|
|
352
|
+
throw new Error(
|
|
353
|
+
`mem0ai/oss Memory export not found (exports=${exportKeys.join(",") || "none"}; default=${defaultKeys.join(",") || "none"})`,
|
|
354
|
+
);
|
|
326
355
|
}
|
|
327
356
|
|
|
328
357
|
const providedConfig = this.cfg.mem0.ossConfig;
|
package/src/state.ts
CHANGED
|
@@ -97,22 +97,38 @@ export async function writeCaptureDedupeState(
|
|
|
97
97
|
export async function withStateLock<T>(
|
|
98
98
|
lockFilePath: string,
|
|
99
99
|
fn: () => Promise<T>,
|
|
100
|
-
options?: { retries?: number; retryDelayMs?: number },
|
|
100
|
+
options?: { retries?: number; retryDelayMs?: number; staleLockMs?: number },
|
|
101
101
|
): Promise<T> {
|
|
102
102
|
const retries = options?.retries ?? 12;
|
|
103
103
|
const retryDelayMs = options?.retryDelayMs ?? 150;
|
|
104
|
+
const staleLockMs = options?.staleLockMs ?? 30_000;
|
|
104
105
|
await fs.mkdir(path.dirname(lockFilePath), { recursive: true });
|
|
105
106
|
|
|
106
107
|
let handle: fs.FileHandle | null = null;
|
|
107
108
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
108
109
|
try {
|
|
109
110
|
handle = await fs.open(lockFilePath, "wx");
|
|
111
|
+
await handle.writeFile(
|
|
112
|
+
`${JSON.stringify({
|
|
113
|
+
pid: process.pid,
|
|
114
|
+
startedAt: new Date().toISOString(),
|
|
115
|
+
})}\n`,
|
|
116
|
+
"utf8",
|
|
117
|
+
);
|
|
110
118
|
break;
|
|
111
119
|
} catch (err) {
|
|
112
120
|
const code = (err as { code?: string }).code;
|
|
113
|
-
if (code !== "EEXIST"
|
|
121
|
+
if (code !== "EEXIST") {
|
|
114
122
|
throw err;
|
|
115
123
|
}
|
|
124
|
+
const recovered = await recoverStaleLock(lockFilePath, staleLockMs);
|
|
125
|
+
if (recovered) {
|
|
126
|
+
attempt -= 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (attempt >= retries) {
|
|
130
|
+
throw new Error(`Failed to acquire lock for ${lockFilePath}: lock file already exists`);
|
|
131
|
+
}
|
|
116
132
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
117
133
|
}
|
|
118
134
|
}
|
|
@@ -128,3 +144,56 @@ export async function withStateLock<T>(
|
|
|
128
144
|
await fs.unlink(lockFilePath).catch(() => undefined);
|
|
129
145
|
}
|
|
130
146
|
}
|
|
147
|
+
|
|
148
|
+
function isProcessAlive(pid: number): boolean {
|
|
149
|
+
try {
|
|
150
|
+
process.kill(pid, 0);
|
|
151
|
+
return true;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const code = (err as { code?: string }).code;
|
|
154
|
+
if (code === "ESRCH") {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function recoverStaleLock(lockFilePath: string, staleLockMs: number): Promise<boolean> {
|
|
162
|
+
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
|
163
|
+
try {
|
|
164
|
+
stat = await fs.stat(lockFilePath);
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
170
|
+
|
|
171
|
+
let raw: string | null = null;
|
|
172
|
+
try {
|
|
173
|
+
raw = await fs.readFile(lockFilePath, "utf8");
|
|
174
|
+
} catch {
|
|
175
|
+
raw = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (raw) {
|
|
179
|
+
try {
|
|
180
|
+
const parsed = JSON.parse(raw) as { pid?: unknown };
|
|
181
|
+
if (typeof parsed.pid === "number" && Number.isFinite(parsed.pid)) {
|
|
182
|
+
if (isProcessAlive(parsed.pid)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
await fs.unlink(lockFilePath).catch(() => undefined);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// Legacy lock file format, handled by age-based fallback below.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (ageMs < staleLockMs) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await fs.unlink(lockFilePath).catch(() => undefined);
|
|
198
|
+
return true;
|
|
199
|
+
}
|