memory-mimir 1.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,905 @@
1
+ /**
2
+ * memory-mimir: OpenClaw plugin entry point.
3
+ *
4
+ * Replaces OpenClaw's built-in file-backed memory with Mimir
5
+ * as a full long-term memory backend (graph + vector + BM25).
6
+ */
7
+ import * as crypto from "node:crypto";
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as os from "node:os";
11
+ import { Type } from "@sinclair/typebox";
12
+ import { MimirClient, MimirError } from "./mimir-client.js";
13
+ import { formatSearchResults } from "./formatter.js";
14
+ import { migrate, hasExistingData, hasLocalMemories } from "./migration.js";
15
+ const CAPTURE_STATE_DIR = path.join(os.homedir(), ".openclaw");
16
+ const CAPTURE_STATE_FILE = path.join(CAPTURE_STATE_DIR, "memory-mimir-capture.json");
17
+ function loadCaptureState() {
18
+ try {
19
+ const data = fs.readFileSync(CAPTURE_STATE_FILE, "utf8");
20
+ const parsed = JSON.parse(data);
21
+ if (Array.isArray(parsed.ingestedHashes)) {
22
+ return { ingestedHashes: parsed.ingestedHashes };
23
+ }
24
+ }
25
+ catch {
26
+ // File doesn't exist or is corrupt — start fresh.
27
+ }
28
+ return { ingestedHashes: [] };
29
+ }
30
+ function saveCaptureState(state) {
31
+ try {
32
+ fs.mkdirSync(CAPTURE_STATE_DIR, { recursive: true });
33
+ fs.writeFileSync(CAPTURE_STATE_FILE, JSON.stringify(state));
34
+ }
35
+ catch {
36
+ // Best-effort — don't crash if write fails.
37
+ }
38
+ }
39
+ /** Stable hash for a parsed message — used to skip already-ingested messages. */
40
+ function hashMsg(role, content) {
41
+ return crypto
42
+ .createHash("sha256")
43
+ .update(role + "\x00" + content)
44
+ .digest("hex")
45
+ .slice(0, 16);
46
+ }
47
+ function resolveConfig(pluginConfig) {
48
+ return {
49
+ mimirUrl: pluginConfig.mimirUrl ??
50
+ process.env.MIMIR_URL ??
51
+ "https://api.allinmimir.com",
52
+ apiKey: pluginConfig.apiKey ?? process.env.MIMIR_API_KEY ?? "",
53
+ userId: pluginConfig.userId ?? process.env.MIMIR_USER_ID ?? "",
54
+ groupId: pluginConfig.groupId ?? process.env.MIMIR_GROUP_ID ?? "",
55
+ autoRecall: pluginConfig.autoRecall ??
56
+ process.env.MIMIR_AUTO_RECALL !== "false",
57
+ autoCapture: pluginConfig.autoCapture ??
58
+ process.env.MIMIR_AUTO_CAPTURE !== "false",
59
+ maxRecallTokens: parsePositiveInt(pluginConfig.maxRecallTokens, 500),
60
+ maxRecallItems: parsePositiveInt(pluginConfig.maxRecallItems, 8),
61
+ };
62
+ }
63
+ function parsePositiveInt(value, fallback) {
64
+ if (value === undefined || value === null)
65
+ return fallback;
66
+ const n = Math.floor(value);
67
+ return n > 0 ? n : fallback;
68
+ }
69
+ // ─── Keyword Extraction (no LLM) ───────────────────────────
70
+ const STOP_WORDS = new Set([
71
+ "the",
72
+ "a",
73
+ "an",
74
+ "is",
75
+ "are",
76
+ "was",
77
+ "were",
78
+ "be",
79
+ "been",
80
+ "being",
81
+ "have",
82
+ "has",
83
+ "had",
84
+ "do",
85
+ "does",
86
+ "did",
87
+ "will",
88
+ "would",
89
+ "could",
90
+ "should",
91
+ "may",
92
+ "might",
93
+ "can",
94
+ "shall",
95
+ "must",
96
+ "need",
97
+ "dare",
98
+ "to",
99
+ "of",
100
+ "in",
101
+ "for",
102
+ "on",
103
+ "with",
104
+ "at",
105
+ "by",
106
+ "from",
107
+ "as",
108
+ "into",
109
+ "through",
110
+ "during",
111
+ "before",
112
+ "after",
113
+ "above",
114
+ "below",
115
+ "and",
116
+ "but",
117
+ "or",
118
+ "nor",
119
+ "not",
120
+ "so",
121
+ "yet",
122
+ "both",
123
+ "either",
124
+ "neither",
125
+ "each",
126
+ "every",
127
+ "all",
128
+ "any",
129
+ "few",
130
+ "more",
131
+ "most",
132
+ "other",
133
+ "some",
134
+ "such",
135
+ "no",
136
+ "only",
137
+ "own",
138
+ "same",
139
+ "than",
140
+ "too",
141
+ "very",
142
+ "just",
143
+ "about",
144
+ "also",
145
+ "back",
146
+ "even",
147
+ "still",
148
+ "then",
149
+ "there",
150
+ "here",
151
+ "when",
152
+ "where",
153
+ "why",
154
+ "how",
155
+ "what",
156
+ "which",
157
+ "who",
158
+ "whom",
159
+ "this",
160
+ "that",
161
+ "these",
162
+ "those",
163
+ "it",
164
+ "its",
165
+ "i",
166
+ "me",
167
+ "my",
168
+ "we",
169
+ "our",
170
+ "you",
171
+ "your",
172
+ "he",
173
+ "she",
174
+ "him",
175
+ "her",
176
+ "they",
177
+ "them",
178
+ "their",
179
+ "if",
180
+ "up",
181
+ "out",
182
+ "please",
183
+ "tell",
184
+ "know",
185
+ "think",
186
+ "want",
187
+ "like",
188
+ "get",
189
+ "make",
190
+ "go",
191
+ "come",
192
+ "take",
193
+ "see",
194
+ "look",
195
+ "find",
196
+ "give",
197
+ "use",
198
+ "say",
199
+ "said",
200
+ "help",
201
+ "try",
202
+ "let",
203
+ "put",
204
+ "keep",
205
+ "start",
206
+ "remember",
207
+ "recall",
208
+ "mentioned",
209
+ "talked",
210
+ "discussed",
211
+ ]);
212
+ /** Detect CJK characters in text. */
213
+ function hasCJK(text) {
214
+ return /[\u4e00-\u9fff\u3400-\u4dbf\uF900-\uFAFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/.test(text);
215
+ }
216
+ /**
217
+ * Extract plain text from a message content block (string or array).
218
+ * Handles mixed content: text, tool_use, tool_result, image, document.
219
+ * - text blocks: included as-is
220
+ * - tool_use: "[工具: name(args)]" so we know what was invoked
221
+ * - tool_result: recursively extract text (web fetch results, file reads, etc.)
222
+ * - image: "[图片]" placeholder — OpenClaw's reply will describe it
223
+ * - document: "[文档]" placeholder
224
+ */
225
+ export function extractMessageText(content) {
226
+ if (typeof content === "string")
227
+ return content;
228
+ if (!Array.isArray(content))
229
+ return "";
230
+ const parts = [];
231
+ for (const block of content) {
232
+ if (!block || typeof block !== "object")
233
+ continue;
234
+ switch (block.type) {
235
+ case "text":
236
+ if (typeof block.text === "string")
237
+ parts.push(block.text);
238
+ break;
239
+ case "tool_result": {
240
+ // Recursively extract nested text (e.g. WebFetch, Read results)
241
+ const inner = extractMessageText(block.content);
242
+ if (inner)
243
+ parts.push(inner);
244
+ break;
245
+ }
246
+ case "tool_use":
247
+ // Record which tool was called so context isn't lost
248
+ if (typeof block.name === "string") {
249
+ const args = block.input ? JSON.stringify(block.input) : "";
250
+ parts.push(`[工具: ${block.name}(${args})]`);
251
+ }
252
+ break;
253
+ case "image":
254
+ parts.push("[图片]");
255
+ break;
256
+ case "document":
257
+ parts.push("[文档]");
258
+ break;
259
+ }
260
+ }
261
+ return parts.filter(Boolean).join("\n");
262
+ }
263
+ /** Extract searchable topics from user message — pure heuristic, no LLM.
264
+ * For CJK text: pass first 200 chars directly (server has LLMTranslator + RuleAnalyzer).
265
+ * For English: extract up to 8 non-stop-word tokens.
266
+ */
267
+ export function extractKeywords(message) {
268
+ // CJK: pass raw text to server — it handles segmentation and translation
269
+ if (hasCJK(message)) {
270
+ return message.slice(0, 200).trim();
271
+ }
272
+ const words = message
273
+ .toLowerCase()
274
+ .replace(/[^\w\s'-]/g, " ")
275
+ .split(/\s+/)
276
+ .filter((w) => w.length > 2 && !STOP_WORDS.has(w));
277
+ const unique = [...new Set(words)].slice(0, 8);
278
+ return unique.join(" ");
279
+ }
280
+ /** Extract time range from user message using keyword matching.
281
+ * Returns undefined if no temporal reference is found.
282
+ */
283
+ export function extractTimeRange(message) {
284
+ const lower = message.toLowerCase();
285
+ const now = new Date();
286
+ const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
287
+ const rules = [
288
+ {
289
+ keywords: ["yesterday", "昨天"],
290
+ start: addDays(today, -1),
291
+ end: today,
292
+ },
293
+ {
294
+ keywords: ["day before yesterday", "前天"],
295
+ start: addDays(today, -2),
296
+ end: addDays(today, -1),
297
+ },
298
+ {
299
+ keywords: ["last week", "上周", "上个星期"],
300
+ start: addDays(today, -7),
301
+ end: today,
302
+ },
303
+ {
304
+ keywords: ["this week", "这周", "这个星期"],
305
+ start: addDays(today, -today.getUTCDay()),
306
+ end: addDays(today, 1),
307
+ },
308
+ {
309
+ keywords: ["last month", "上个月"],
310
+ start: addMonths(today, -1),
311
+ end: today,
312
+ },
313
+ {
314
+ keywords: ["this month", "这个月"],
315
+ start: new Date(Date.UTC(now.getFullYear(), now.getMonth(), 1)),
316
+ end: addDays(today, 1),
317
+ },
318
+ {
319
+ keywords: ["last year", "去年"],
320
+ start: addYears(today, -1),
321
+ end: today,
322
+ },
323
+ {
324
+ keywords: ["this year", "今年"],
325
+ start: new Date(Date.UTC(now.getFullYear(), 0, 1)),
326
+ end: addDays(today, 1),
327
+ },
328
+ {
329
+ keywords: ["today", "今天"],
330
+ start: today,
331
+ end: addDays(today, 1),
332
+ },
333
+ ];
334
+ for (const rule of rules) {
335
+ for (const kw of rule.keywords) {
336
+ if (lower.includes(kw)) {
337
+ return {
338
+ start: rule.start.toISOString(),
339
+ end: rule.end.toISOString(),
340
+ };
341
+ }
342
+ }
343
+ }
344
+ return undefined;
345
+ }
346
+ function addDays(date, days) {
347
+ const d = new Date(date.getTime());
348
+ d.setUTCDate(d.getUTCDate() + days);
349
+ return d;
350
+ }
351
+ function addMonths(date, months) {
352
+ const d = new Date(date.getTime());
353
+ d.setUTCMonth(d.getUTCMonth() + months);
354
+ return d;
355
+ }
356
+ function addYears(date, years) {
357
+ const d = new Date(date.getTime());
358
+ d.setUTCFullYear(d.getUTCFullYear() + years);
359
+ return d;
360
+ }
361
+ // ─── Plugin Definition ──────────────────────────────────────
362
+ const memoryMimirPlugin = {
363
+ id: "memory-mimir",
364
+ name: "Memory (Mimir)",
365
+ description: "Mimir-powered long-term memory for OpenClaw",
366
+ kind: "memory",
367
+ configSchema: {
368
+ type: "object",
369
+ additionalProperties: false,
370
+ properties: {
371
+ mimirUrl: {
372
+ type: "string",
373
+ description: "Mimir server URL (e.g. http://localhost:8766)",
374
+ },
375
+ userId: {
376
+ type: "string",
377
+ description: "User ID for Mimir memory isolation",
378
+ },
379
+ groupId: { type: "string", description: "Group ID for memory scoping" },
380
+ autoRecall: {
381
+ type: "boolean",
382
+ description: "Auto-inject relevant memories before agent starts",
383
+ },
384
+ autoCapture: {
385
+ type: "boolean",
386
+ description: "Auto-capture conversations after agent ends",
387
+ },
388
+ maxRecallItems: {
389
+ type: "number",
390
+ description: "Maximum number of memory items to recall",
391
+ },
392
+ maxRecallTokens: {
393
+ type: "number",
394
+ description: "Maximum tokens for recalled memory context",
395
+ },
396
+ },
397
+ },
398
+ register(api) {
399
+ const rawCfg = resolveConfig(api.pluginConfig ?? {});
400
+ const client = new MimirClient({
401
+ url: rawCfg.mimirUrl,
402
+ apiKey: rawCfg.apiKey,
403
+ });
404
+ // Resolved config — userId/groupId may be filled in after /api/v1/me lookup
405
+ let cfg = rawCfg;
406
+ // If apiKey is set but userId is empty, auto-fetch from /api/v1/me
407
+ if (rawCfg.apiKey && !rawCfg.userId) {
408
+ client
409
+ .me()
410
+ .then((me) => {
411
+ cfg = { ...rawCfg, userId: me.user_id, groupId: me.group_id };
412
+ api.logger.info(`memory-mimir: authenticated as ${me.display_name} (user: ${me.user_id}, server: ${rawCfg.mimirUrl})`);
413
+ })
414
+ .catch((err) => {
415
+ api.logger.warn(`memory-mimir: failed to fetch identity from /api/v1/me: ${String(err)}`);
416
+ });
417
+ }
418
+ else {
419
+ api.logger.info(`memory-mimir: registered (user: ${rawCfg.userId}, server: ${rawCfg.mimirUrl})`);
420
+ }
421
+ // ════════════════════════════════════════════════════════
422
+ // Tools
423
+ // ════════════════════════════════════════════════════════
424
+ api.registerTool({
425
+ name: "mimir_search",
426
+ label: "Mimir Search",
427
+ description: "Search long-term memory for past conversations, facts, entities, and relationships. " +
428
+ "Use when the user asks about past events, people, or previously discussed topics.",
429
+ parameters: Type.Object({
430
+ query: Type.String({ description: "The search query" }),
431
+ types: Type.Optional(Type.String({
432
+ description: "Comma-separated memory types: episode,entity,relation,event_log,foresight. Default: all.",
433
+ })),
434
+ startTime: Type.Optional(Type.String({
435
+ description: "Filter results after this time (ISO 8601, e.g. 2026-02-25T00:00:00Z).",
436
+ })),
437
+ endTime: Type.Optional(Type.String({
438
+ description: "Filter results before this time (ISO 8601, e.g. 2026-03-04T00:00:00Z).",
439
+ })),
440
+ }),
441
+ async execute(_toolCallId, params) {
442
+ const query = params.query;
443
+ const typesStr = params.types;
444
+ const memoryTypes = typesStr?.split(",").map((t) => t.trim()) ?? undefined;
445
+ const startTime = params.startTime;
446
+ const endTime = params.endTime;
447
+ try {
448
+ const results = await client.search(cfg.userId, query, {
449
+ groupId: cfg.groupId,
450
+ memoryTypes,
451
+ topK: 10,
452
+ retrieveMethod: "agentic",
453
+ startTime,
454
+ endTime,
455
+ });
456
+ if (results.results.length === 0) {
457
+ return {
458
+ content: [
459
+ {
460
+ type: "text",
461
+ text: "No memories found matching your query.",
462
+ },
463
+ ],
464
+ details: { count: 0 },
465
+ };
466
+ }
467
+ const formatted = formatSearchResults(results, {
468
+ maxItems: 10,
469
+ maxChars: 4000,
470
+ });
471
+ return {
472
+ content: [{ type: "text", text: formatted }],
473
+ details: { count: results.results.length },
474
+ };
475
+ }
476
+ catch (err) {
477
+ const msg = err instanceof MimirError ? err.message : String(err);
478
+ return {
479
+ content: [{ type: "text", text: `Memory search failed: ${msg}` }],
480
+ details: { error: msg },
481
+ };
482
+ }
483
+ },
484
+ }, { name: "mimir_search" });
485
+ api.registerTool({
486
+ name: "mimir_store",
487
+ label: "Mimir Store",
488
+ description: "Store an important fact, preference, or note in long-term memory. " +
489
+ 'Use when the user says "remember this" or shares important information.',
490
+ parameters: Type.Object({
491
+ content: Type.String({
492
+ description: "The fact, preference, or note to remember",
493
+ }),
494
+ }),
495
+ async execute(_toolCallId, params) {
496
+ const content = params.content;
497
+ try {
498
+ const result = await client.ingestNote(cfg.userId, content, {
499
+ groupId: cfg.groupId,
500
+ });
501
+ const text = `Stored in memory. Extracted ${result.EpisodeCount} episode(s), ` +
502
+ `${result.EntityCount} entity(ies), ${result.RelationCount} relation(s).`;
503
+ return {
504
+ content: [{ type: "text", text }],
505
+ details: {
506
+ episodes: result.EpisodeCount,
507
+ entities: result.EntityCount,
508
+ },
509
+ };
510
+ }
511
+ catch (err) {
512
+ const msg = err instanceof MimirError ? err.message : String(err);
513
+ return {
514
+ content: [
515
+ { type: "text", text: `Failed to store memory: ${msg}` },
516
+ ],
517
+ details: { error: msg },
518
+ };
519
+ }
520
+ },
521
+ }, { name: "mimir_store" });
522
+ api.registerTool({
523
+ name: "mimir_forget",
524
+ label: "Mimir Forget",
525
+ description: "Look up information in memory the user wants to forget. " +
526
+ "NOTE: Deletion is not yet implemented — this shows what would be affected.",
527
+ parameters: Type.Object({
528
+ query: Type.String({ description: "Description of what to forget" }),
529
+ }),
530
+ async execute(_toolCallId, params) {
531
+ const query = params.query?.trim();
532
+ if (!query || query.length > 1000) {
533
+ return {
534
+ content: [
535
+ {
536
+ type: "text",
537
+ text: "Error: query must be 1-1000 characters.",
538
+ },
539
+ ],
540
+ details: { error: "invalid_query" },
541
+ };
542
+ }
543
+ try {
544
+ const results = await client.search(cfg.userId, query, {
545
+ groupId: cfg.groupId,
546
+ topK: 5,
547
+ });
548
+ if (results.results.length === 0) {
549
+ return {
550
+ content: [
551
+ { type: "text", text: "No matching memories found." },
552
+ ],
553
+ details: { count: 0 },
554
+ };
555
+ }
556
+ const preview = formatSearchResults(results, {
557
+ maxItems: 5,
558
+ maxChars: 2000,
559
+ });
560
+ const text = `Found ${results.results.length} matching item(s). ` +
561
+ `Deletion is not yet supported.\n\n${preview}`;
562
+ return {
563
+ content: [{ type: "text", text }],
564
+ details: { count: results.results.length },
565
+ };
566
+ }
567
+ catch (err) {
568
+ const msg = err instanceof MimirError ? err.message : String(err);
569
+ return {
570
+ content: [{ type: "text", text: `Forget lookup failed: ${msg}` }],
571
+ details: { error: msg },
572
+ };
573
+ }
574
+ },
575
+ }, { name: "mimir_forget" });
576
+ // ════════════════════════════════════════════════════════
577
+ // CLI Commands
578
+ // ════════════════════════════════════════════════════════
579
+ api.registerCli(({ program }) => {
580
+ const mimir = program
581
+ .command("mimir")
582
+ .description("Mimir memory plugin commands");
583
+ mimir
584
+ .command("setup")
585
+ .description("Configure memory-mimir with an API key from allinmimir.com")
586
+ .option("--api-key <key>", "Your Mimir API key (sk-mimir-...)")
587
+ .option("--url <url>", "Mimir server URL", "https://api.allinmimir.com")
588
+ .action(async (...args) => {
589
+ const opts = (args[0] ?? {});
590
+ const apiKey = opts["api-key"] || opts["apiKey"] || cfg.apiKey;
591
+ const mimirUrl = opts.url || cfg.mimirUrl;
592
+ if (!apiKey) {
593
+ console.error(`Error: --api-key is required.`);
594
+ console.error(` Get your API key at https://allinmimir.com/dashboard`);
595
+ console.error(` Usage: openclaw mimir setup --api-key sk-mimir-xxx`);
596
+ return;
597
+ }
598
+ console.log(`Validating API key...`);
599
+ const tempClient = new MimirClient({ url: mimirUrl, apiKey });
600
+ let identity;
601
+ try {
602
+ identity = await tempClient.me();
603
+ }
604
+ catch (err) {
605
+ const msg = err instanceof Error ? err.message : String(err);
606
+ console.error(`Error: ${msg}`);
607
+ console.error(` Check your API key or run: openclaw mimir setup --api-key sk-mimir-xxx`);
608
+ return;
609
+ }
610
+ // Write to OpenClaw plugin config
611
+ const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
612
+ let ocConfig = {};
613
+ try {
614
+ const data = fs.readFileSync(configPath, "utf8");
615
+ ocConfig = JSON.parse(data);
616
+ }
617
+ catch {
618
+ // Config doesn't exist yet — start fresh
619
+ }
620
+ const plugins = ocConfig.plugins ?? {};
621
+ const entries = plugins.entries ?? {};
622
+ const existing = entries["memory-mimir"] ?? {};
623
+ const pluginCfg = existing.config ?? {};
624
+ const updatedConfig = {
625
+ ...ocConfig,
626
+ plugins: {
627
+ ...plugins,
628
+ enabled: true,
629
+ entries: {
630
+ ...entries,
631
+ "memory-mimir": {
632
+ ...existing,
633
+ enabled: true,
634
+ config: {
635
+ ...pluginCfg,
636
+ apiKey,
637
+ mimirUrl,
638
+ userId: identity.user_id,
639
+ groupId: identity.group_id,
640
+ autoRecall: true,
641
+ autoCapture: true,
642
+ },
643
+ },
644
+ },
645
+ },
646
+ };
647
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
648
+ fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
649
+ console.log();
650
+ console.log(`✓ Authenticated as: ${identity.display_name}`);
651
+ console.log(` User ID: ${identity.user_id}`);
652
+ console.log(` Server: ${mimirUrl}`);
653
+ console.log(` Config: ${configPath}`);
654
+ console.log();
655
+ console.log(`Restart OpenClaw to activate memory-mimir.`);
656
+ });
657
+ mimir
658
+ .command("migrate")
659
+ .description("Import existing OpenClaw/Claude Code memories into Mimir")
660
+ .argument("[path]", "Override memory directory path")
661
+ .option("--force", "Migrate even if user already has data")
662
+ .option("--background", "Run migration in the background (non-blocking)")
663
+ .action(async (...args) => {
664
+ const memoryDir = args[0];
665
+ const opts = (args[1] ?? {});
666
+ const healthy = await client.health();
667
+ if (!healthy) {
668
+ console.error(`Cannot reach Mimir at ${cfg.mimirUrl}. Run "openclaw mimir setup" first.`);
669
+ return;
670
+ }
671
+ const existingData = await hasExistingData(client, cfg.userId);
672
+ if (existingData && !opts.force) {
673
+ console.log(`User "${cfg.userId}" already has data in Mimir. Use --force to migrate anyway.`);
674
+ return;
675
+ }
676
+ const runMigration = async () => {
677
+ const result = await migrate(client, {
678
+ userId: cfg.userId,
679
+ groupId: cfg.groupId,
680
+ memoryDir,
681
+ onProgress: (current, total, filename) => {
682
+ if (opts.background) {
683
+ api.logger.info(`memory-mimir: migrating ${current}/${total}: ${filename}`);
684
+ }
685
+ else {
686
+ console.log(`Migrating ${current}/${total}: ${filename}`);
687
+ }
688
+ },
689
+ });
690
+ const summary = `Migration: ${result.filesIngested}/${result.filesFound} files, ` +
691
+ `${result.totalEpisodes} episodes, ${result.totalEntities} entities`;
692
+ if (opts.background) {
693
+ api.logger.info(`memory-mimir: ${summary}`);
694
+ if (result.errors.length > 0) {
695
+ api.logger.warn(`memory-mimir: migration errors: ${result.errors.join(", ")}`);
696
+ }
697
+ }
698
+ else {
699
+ console.log(`\nMigration Complete`);
700
+ console.log(`─────────────────`);
701
+ console.log(`Files found: ${result.filesFound}`);
702
+ console.log(`Files ingested: ${result.filesIngested}`);
703
+ console.log(`Files failed: ${result.filesFailed}`);
704
+ console.log(`Episodes: ${result.totalEpisodes}`);
705
+ console.log(`Entities: ${result.totalEntities}`);
706
+ console.log(`Relations: ${result.totalRelations}`);
707
+ if (result.errors.length > 0) {
708
+ console.log(`\nErrors:`);
709
+ for (const err of result.errors) {
710
+ console.log(` - ${err}`);
711
+ }
712
+ }
713
+ }
714
+ return result;
715
+ };
716
+ if (opts.background) {
717
+ console.log("Migration started in background. Check logs for progress.");
718
+ // Fire and forget — don't await
719
+ runMigration().catch((err) => {
720
+ api.logger.error(`memory-mimir: background migration failed: ${String(err)}`);
721
+ });
722
+ }
723
+ else {
724
+ await runMigration();
725
+ }
726
+ });
727
+ mimir
728
+ .command("search")
729
+ .description("Search Mimir memory from the terminal")
730
+ .argument("<query>", "Search query")
731
+ .option("--limit <n>", "Max results", "10")
732
+ .action(async (...actionArgs) => {
733
+ const query = actionArgs[0];
734
+ const opts = (actionArgs[1] ?? {});
735
+ try {
736
+ const results = await client.search(cfg.userId, query, {
737
+ groupId: cfg.groupId,
738
+ topK: parseInt(opts.limit, 10) || 10,
739
+ });
740
+ if (results.results.length === 0) {
741
+ console.log("No results found.");
742
+ return;
743
+ }
744
+ console.log(formatSearchResults(results, { maxItems: 10, maxChars: 4000 }));
745
+ }
746
+ catch (err) {
747
+ const msg = err instanceof Error ? err.message : String(err);
748
+ console.error(`Search failed: ${msg}`);
749
+ }
750
+ });
751
+ mimir
752
+ .command("status")
753
+ .description("Show Mimir connection status and memory statistics")
754
+ .action(async () => {
755
+ const healthy = await client.health();
756
+ console.log(`Connection: ${healthy ? "OK" : "FAILED"}`);
757
+ if (!healthy) {
758
+ console.log(`Cannot reach ${cfg.mimirUrl}`);
759
+ return;
760
+ }
761
+ try {
762
+ const episodeSearch = await client.search(cfg.userId, "*", {
763
+ groupId: cfg.groupId,
764
+ memoryTypes: ["episode"],
765
+ topK: 1,
766
+ });
767
+ const entitySearch = await client.search(cfg.userId, "*", {
768
+ groupId: cfg.groupId,
769
+ memoryTypes: ["entity"],
770
+ topK: 1,
771
+ });
772
+ console.log(`User: ${cfg.userId}`);
773
+ console.log(`Episodes: ${episodeSearch.results.length > 0 ? "present" : "none"}`);
774
+ console.log(`Entities: ${entitySearch.results.length > 0 ? "present" : "none"}`);
775
+ const localMemories = await hasLocalMemories();
776
+ if (localMemories) {
777
+ console.log();
778
+ console.log(`Local memory files found. Run "openclaw mimir migrate" to import them.`);
779
+ }
780
+ }
781
+ catch (err) {
782
+ const msg = err instanceof Error ? err.message : String(err);
783
+ console.log(`Stats unavailable: ${msg}`);
784
+ }
785
+ });
786
+ }, { commands: ["mimir"] });
787
+ // ════════════════════════════════════════════════════════
788
+ // Lifecycle Hooks
789
+ // ════════════════════════════════════════════════════════
790
+ // Auto-recall: inject relevant memories before agent starts
791
+ if (cfg.autoRecall) {
792
+ api.on("before_agent_start", async (event) => {
793
+ const prompt = event.prompt;
794
+ if (!prompt || prompt.length < 5)
795
+ return;
796
+ try {
797
+ const query = extractKeywords(prompt);
798
+ if (!query)
799
+ return;
800
+ const timeRange = extractTimeRange(prompt);
801
+ const results = await client.search(cfg.userId, query, {
802
+ groupId: cfg.groupId,
803
+ topK: cfg.maxRecallItems,
804
+ retrieveMethod: "agentic",
805
+ startTime: timeRange?.start,
806
+ endTime: timeRange?.end,
807
+ });
808
+ if (results.results.length === 0) {
809
+ api.logger.info(`memory-mimir: no memories found (query: ${query.slice(0, 60)})`);
810
+ return;
811
+ }
812
+ api.logger.info(`memory-mimir: injecting ${results.results.length} memories into context`);
813
+ const formatted = formatSearchResults(results, {
814
+ maxItems: cfg.maxRecallItems,
815
+ maxChars: cfg.maxRecallTokens * 4,
816
+ });
817
+ return {
818
+ prependContext: `<memories>\n${formatted}\n</memories>`,
819
+ };
820
+ }
821
+ catch (err) {
822
+ api.logger.warn(`memory-mimir: recall failed: ${String(err)}`);
823
+ }
824
+ });
825
+ }
826
+ // Auto-capture: ingest conversation to Mimir after agent ends.
827
+ // Uses per-message content hashing to track what has already been ingested,
828
+ // so restarts and session compaction never cause re-ingestion or missed messages.
829
+ if (cfg.autoCapture) {
830
+ const CONTEXT_WINDOW = 4; // preceding (already-ingested) messages for LLM context
831
+ const savedState = loadCaptureState();
832
+ const ingestedHashes = new Set(savedState.ingestedHashes);
833
+ api.on("agent_end", async (event) => {
834
+ const messages = event.messages;
835
+ if (!messages || messages.length === 0)
836
+ return;
837
+ try {
838
+ const allParsed = [];
839
+ for (const msg of messages) {
840
+ if (!msg || typeof msg !== "object")
841
+ continue;
842
+ const role = msg.role;
843
+ if (role !== "user" && role !== "assistant")
844
+ continue;
845
+ const content = extractMessageText(msg.content);
846
+ if (!content || content.includes("<memories>"))
847
+ continue;
848
+ allParsed.push({
849
+ role: role,
850
+ sender_name: role === "user" ? cfg.userId : "assistant",
851
+ content,
852
+ hash: hashMsg(role, content),
853
+ });
854
+ }
855
+ if (allParsed.length === 0)
856
+ return;
857
+ api.logger.info(`memory-mimir: session-end parsed=${allParsed.length}`);
858
+ // Find the first new (not yet ingested) message.
859
+ const firstNewIdx = allParsed.findIndex((m) => !ingestedHashes.has(m.hash));
860
+ if (firstNewIdx === -1)
861
+ return; // all already ingested
862
+ // Include CONTEXT_WINDOW already-ingested messages for LLM context.
863
+ const contextStart = Math.max(0, firstNewIdx - CONTEXT_WINDOW);
864
+ const toSend = allParsed.slice(contextStart);
865
+ const newCount = allParsed.length - firstNewIdx;
866
+ // Mark only the new messages as ingested.
867
+ for (let i = firstNewIdx; i < allParsed.length; i++) {
868
+ ingestedHashes.add(allParsed[i].hash);
869
+ }
870
+ saveCaptureState({ ingestedHashes: [...ingestedHashes] });
871
+ api.logger.info(`memory-mimir: capturing ${newCount} new messages (+${toSend.length - newCount} context)`);
872
+ client
873
+ .ingestSession(cfg.userId, toSend.map(({ role, sender_name, content }) => ({
874
+ role,
875
+ sender_name,
876
+ content,
877
+ })), { groupId: cfg.groupId })
878
+ .then(() => {
879
+ api.logger.info(`memory-mimir: captured ${toSend.length} messages`);
880
+ })
881
+ .catch((err) => {
882
+ api.logger.warn(`memory-mimir: capture failed: ${String(err)}`);
883
+ });
884
+ }
885
+ catch (err) {
886
+ api.logger.warn(`memory-mimir: capture setup failed: ${String(err)}`);
887
+ }
888
+ });
889
+ }
890
+ // ════════════════════════════════════════════════════════
891
+ // Service
892
+ // ════════════════════════════════════════════════════════
893
+ api.registerService({
894
+ id: "memory-mimir",
895
+ start: () => {
896
+ api.logger.info(`memory-mimir: started (user: ${cfg.userId}, server: ${cfg.mimirUrl})`);
897
+ },
898
+ stop: () => {
899
+ api.logger.info("memory-mimir: stopped");
900
+ },
901
+ });
902
+ },
903
+ };
904
+ export default memoryMimirPlugin;
905
+ //# sourceMappingURL=index.js.map