@memtensor/memos-local-openclaw-plugin 1.0.8-beta.9 → 1.0.9-beta.1

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/index.ts CHANGED
@@ -31,18 +31,6 @@ import { SkillInstaller } from "./src/skill/installer";
31
31
  import { Summarizer } from "./src/ingest/providers";
32
32
  import { MEMORY_GUIDE_SKILL_MD } from "./src/skill/bundled-memory-guide";
33
33
  import { Telemetry } from "./src/telemetry";
34
- import {
35
- type AgentMessage as CEAgentMessage,
36
- type PendingInjection,
37
- deduplicateHits as ceDeduplicateHits,
38
- formatMemoryBlock,
39
- appendMemoryToMessage,
40
- removeExistingMemoryBlock,
41
- messageHasMemoryBlock,
42
- getTextFromMessage,
43
- insertSyntheticAssistantEntry,
44
- findTargetAssistantEntry,
45
- } from "./src/context-engine";
46
34
 
47
35
 
48
36
  /** Remove near-duplicate hits based on summary word overlap (>70%). Keeps first (highest-scored) hit. */
@@ -65,6 +53,82 @@ function deduplicateHits<T extends { summary: string }>(hits: T[]): T[] {
65
53
  return kept;
66
54
  }
67
55
 
56
+ const NEW_SESSION_PROMPT_RE = /A new session was started via \/new or \/reset\./i;
57
+ const INTERNAL_CONTEXT_RE = /OpenClaw runtime context \(internal\):[\s\S]*/i;
58
+ const CONTINUE_PROMPT_RE = /^Continue where you left off\.[\s\S]*/i;
59
+
60
+ const buildMemoryPromptSection = ({ availableTools, citationsMode }: {
61
+ availableTools: Set<string>;
62
+ citationsMode?: string;
63
+ }) => {
64
+ const lines: string[] = [];
65
+ const hasMemorySearch = availableTools.has("memory_search");
66
+ const hasMemoryGet = availableTools.has("memory_get");
67
+
68
+ if (!hasMemorySearch && !hasMemoryGet) {
69
+ return lines;
70
+ }
71
+
72
+ lines.push("## Memory Recall");
73
+ lines.push(
74
+ "This workspace uses MemOS Local as the active memory slot. Prefer recalled memories and the memory tools before claiming prior context is unavailable.",
75
+ );
76
+
77
+ if (hasMemorySearch && hasMemoryGet) {
78
+ lines.push(
79
+ "Use `memory_search` to locate relevant memories, then `memory_get` or `memory_timeline` when you need the full source text or surrounding context.",
80
+ );
81
+ } else if (hasMemorySearch) {
82
+ lines.push("Use `memory_search` before answering questions about prior conversations, preferences, plans, or decisions.");
83
+ } else {
84
+ lines.push("Use `memory_get` or `memory_timeline` to inspect the referenced memory before answering.");
85
+ }
86
+
87
+ if (citationsMode === "off") {
88
+ lines.push("Citations are disabled, so avoid mentioning internal memory ids unless the user asks.");
89
+ } else {
90
+ lines.push("When it helps the user verify a memory-backed claim, mention the relevant memory identifier or tool result.");
91
+ }
92
+
93
+ lines.push("");
94
+ return lines;
95
+ };
96
+
97
+ function normalizeAutoRecallQuery(rawPrompt: string): string {
98
+ let query = rawPrompt.trim();
99
+
100
+ const senderTag = "Sender (untrusted metadata):";
101
+ const senderPos = query.indexOf(senderTag);
102
+ if (senderPos !== -1) {
103
+ const afterSender = query.slice(senderPos);
104
+ const fenceStart = afterSender.indexOf("```json");
105
+ const fenceEnd = fenceStart >= 0 ? afterSender.indexOf("```\n", fenceStart + 7) : -1;
106
+ if (fenceEnd > 0) {
107
+ query = afterSender.slice(fenceEnd + 4).replace(/^\s*\n/, "").trim();
108
+ } else {
109
+ const firstDblNl = afterSender.indexOf("\n\n");
110
+ if (firstDblNl > 0) {
111
+ query = afterSender.slice(firstDblNl + 2).trim();
112
+ }
113
+ }
114
+ }
115
+
116
+ query = stripInboundMetadata(query);
117
+ query = query.replace(/<[^>]+>/g, "").trim();
118
+
119
+ if (NEW_SESSION_PROMPT_RE.test(query)) {
120
+ query = query.replace(NEW_SESSION_PROMPT_RE, "").trim();
121
+ query = query.replace(/^(Execute|Run) your Session Startup sequence[^\n]*\n?/im, "").trim();
122
+ query = query.replace(/^Current time:[^\n]*(\n|$)/im, "").trim();
123
+ }
124
+
125
+ query = query.replace(INTERNAL_CONTEXT_RE, "").trim();
126
+ query = query.replace(CONTINUE_PROMPT_RE, "").trim();
127
+
128
+ return query;
129
+ }
130
+
131
+
68
132
  const pluginConfigSchema = {
69
133
  type: "object" as const,
70
134
  additionalProperties: true,
@@ -96,6 +160,10 @@ const memosLocalPlugin = {
96
160
  configSchema: pluginConfigSchema,
97
161
 
98
162
  register(api: OpenClawPluginApi) {
163
+ api.registerMemoryCapability({
164
+ promptBuilder: buildMemoryPromptSection,
165
+ });
166
+
99
167
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
100
168
  const localRequire = createRequire(import.meta.url);
101
169
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
@@ -290,7 +358,7 @@ const memosLocalPlugin = {
290
358
  const raw = fs.readFileSync(openclawJsonPath, "utf-8");
291
359
  const cfg = JSON.parse(raw);
292
360
  const allow: string[] | undefined = cfg?.tools?.allow;
293
- if (Array.isArray(allow) && allow.length > 0 && !allow.includes("group:plugins")) {
361
+ if (Array.isArray(allow) && allow.length > 0 && !allow.includes("group:plugins") && !allow.includes("*")) {
294
362
  const lastEntry = JSON.stringify(allow[allow.length - 1]);
295
363
  const patched = raw.replace(
296
364
  new RegExp(`(${lastEntry})(\\s*\\])`),
@@ -319,6 +387,7 @@ const memosLocalPlugin = {
319
387
  // Current agent ID — updated by hooks, read by tools for owner isolation.
320
388
  // Falls back to "main" when no hook has fired yet (single-agent setups).
321
389
  let currentAgentId = "main";
390
+ const getCurrentOwner = () => `agent:${currentAgentId}`;
322
391
 
323
392
  // ─── Check allowPromptInjection policy ───
324
393
  // When allowPromptInjection=false, the prompt mutation fields (such as prependContext) in the hook return value
@@ -332,214 +401,6 @@ const memosLocalPlugin = {
332
401
  api.logger.info("memos-local: allowPromptInjection=true, auto-recall enabled");
333
402
  }
334
403
 
335
- // ─── Context Engine: inject memories into assistant messages ───
336
- // Memories are wrapped in <relevant-memories> tags which OpenClaw's UI
337
- // automatically strips from assistant messages, keeping the chat clean.
338
- // Persisted to the session file so the prompt prefix stays stable for KV cache.
339
-
340
- let pendingInjection: PendingInjection | null = null;
341
-
342
- try {
343
- api.registerContextEngine("memos-local-openclaw-plugin", () => ({
344
- info: {
345
- id: "memos-local-openclaw-plugin",
346
- name: "MemOS Local Memory Context Engine",
347
- version: "1.0.0",
348
- },
349
-
350
- async ingest() {
351
- return { ingested: false };
352
- },
353
-
354
- async assemble(params: {
355
- sessionId: string;
356
- sessionKey?: string;
357
- messages: CEAgentMessage[];
358
- tokenBudget?: number;
359
- model?: string;
360
- prompt?: string;
361
- }) {
362
- const { messages, prompt, sessionId, sessionKey } = params;
363
-
364
- if (!allowPromptInjection || !prompt || prompt.length < 3) {
365
- return { messages, estimatedTokens: 0 };
366
- }
367
-
368
- const recallT0 = performance.now();
369
- try {
370
- let query = prompt;
371
- const senderTag = "Sender (untrusted metadata):";
372
- const senderPos = query.indexOf(senderTag);
373
- if (senderPos !== -1) {
374
- const afterSender = query.slice(senderPos);
375
- const fenceStart = afterSender.indexOf("```json");
376
- const fenceEnd = fenceStart >= 0 ? afterSender.indexOf("```\n", fenceStart + 7) : -1;
377
- if (fenceEnd > 0) {
378
- query = afterSender.slice(fenceEnd + 4).replace(/^\s*\n/, "").trim();
379
- } else {
380
- const firstDblNl = afterSender.indexOf("\n\n");
381
- if (firstDblNl > 0) {
382
- query = afterSender.slice(firstDblNl + 2).trim();
383
- }
384
- }
385
- }
386
- query = stripInboundMetadata(query).replace(/<[^>]+>/g, "").trim();
387
-
388
- if (query.length < 2) {
389
- return { messages, estimatedTokens: 0 };
390
- }
391
-
392
- ctx.log.debug(`context-engine assemble: query="${query.slice(0, 80)}"`);
393
-
394
- const recallOwner = [`agent:${currentAgentId}`, "public"];
395
- const result = await engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwner });
396
- const filteredHits = ceDeduplicateHits(
397
- result.hits.filter((h: SearchHit) => h.score >= 0.5),
398
- );
399
-
400
- if (filteredHits.length === 0) {
401
- ctx.log.debug("context-engine assemble: no memory hits");
402
- return { messages, estimatedTokens: 0 };
403
- }
404
-
405
- const memoryBlock = formatMemoryBlock(filteredHits);
406
- const cloned: CEAgentMessage[] = messages.map((m) => structuredClone(m));
407
-
408
- let lastAssistantIdx = -1;
409
- for (let i = cloned.length - 1; i >= 0; i--) {
410
- if (cloned[i].role === "assistant") {
411
- lastAssistantIdx = i;
412
- break;
413
- }
414
- }
415
-
416
- const sk = sessionKey ?? sessionId;
417
-
418
- if (lastAssistantIdx < 0) {
419
- const syntheticAssistant: CEAgentMessage = {
420
- role: "assistant",
421
- content: [{ type: "text", text: memoryBlock }],
422
- timestamp: Date.now(),
423
- stopReason: "end_turn",
424
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
425
- };
426
- pendingInjection = { sessionKey: sk, memoryBlock, isSynthetic: true };
427
- ctx.log.info(`context-engine assemble: first turn, injecting synthetic assistant (${filteredHits.length} memories)`);
428
- return { messages: [...cloned, syntheticAssistant], estimatedTokens: 0 };
429
- }
430
-
431
- removeExistingMemoryBlock(cloned[lastAssistantIdx]);
432
- appendMemoryToMessage(cloned[lastAssistantIdx], memoryBlock);
433
- pendingInjection = { sessionKey: sk, memoryBlock, isSynthetic: false };
434
-
435
- const dur = performance.now() - recallT0;
436
- ctx.log.info(`context-engine assemble: injected ${filteredHits.length} memories into assistant[${lastAssistantIdx}] (${dur.toFixed(0)}ms)`);
437
- return { messages: cloned, estimatedTokens: 0 };
438
- } catch (err) {
439
- ctx.log.warn(`context-engine assemble failed: ${err}`);
440
- return { messages, estimatedTokens: 0 };
441
- }
442
- },
443
-
444
- async afterTurn() {},
445
-
446
- async compact(params: any) {
447
- try {
448
- const { delegateCompactionToRuntime } = await import("openclaw/plugin-sdk");
449
- return await delegateCompactionToRuntime(params);
450
- } catch {
451
- return { ok: true, compacted: false, reason: "delegateCompactionToRuntime not available" };
452
- }
453
- },
454
-
455
- async maintain(params: {
456
- sessionId: string;
457
- sessionKey?: string;
458
- sessionFile: string;
459
- runtimeContext?: { rewriteTranscriptEntries?: (req: any) => Promise<any> };
460
- }) {
461
- const noChange = { changed: false, bytesFreed: 0, rewrittenEntries: 0 };
462
-
463
- if (!pendingInjection) return noChange;
464
-
465
- const sk = params.sessionKey ?? params.sessionId;
466
- if (pendingInjection.sessionKey !== sk) {
467
- pendingInjection = null;
468
- return { ...noChange, reason: "session mismatch" };
469
- }
470
-
471
- try {
472
- if (pendingInjection.isSynthetic) {
473
- // First turn: INSERT synthetic assistant before existing entries
474
- const { SessionManager } = await import("@mariozechner/pi-coding-agent");
475
- const sm = SessionManager.open(params.sessionFile);
476
- const ok = insertSyntheticAssistantEntry(sm, pendingInjection.memoryBlock);
477
- pendingInjection = null;
478
- if (ok) {
479
- ctx.log.info("context-engine maintain: persisted synthetic assistant message");
480
- return { changed: true, bytesFreed: 0, rewrittenEntries: 1 };
481
- }
482
- return { ...noChange, reason: "empty branch, could not insert synthetic" };
483
- }
484
-
485
- // Subsequent turns: REPLACE last assistant entry with memory-injected version
486
- if (!params.runtimeContext?.rewriteTranscriptEntries) {
487
- pendingInjection = null;
488
- return { ...noChange, reason: "rewriteTranscriptEntries not available" };
489
- }
490
-
491
- const { SessionManager } = await import("@mariozechner/pi-coding-agent");
492
- const sm = SessionManager.open(params.sessionFile);
493
- const branch = sm.getBranch();
494
- const targetEntry = findTargetAssistantEntry(branch);
495
-
496
- if (!targetEntry) {
497
- pendingInjection = null;
498
- return { ...noChange, reason: "no target assistant entry found" };
499
- }
500
-
501
- const modifiedMessage = structuredClone(targetEntry.message!);
502
- removeExistingMemoryBlock(modifiedMessage as CEAgentMessage);
503
- appendMemoryToMessage(modifiedMessage as CEAgentMessage, pendingInjection.memoryBlock);
504
-
505
- const result = await params.runtimeContext.rewriteTranscriptEntries({
506
- replacements: [{ entryId: targetEntry.id, message: modifiedMessage }],
507
- });
508
-
509
- ctx.log.info(`context-engine maintain: persisted memory to assistant entry ${targetEntry.id}`);
510
- pendingInjection = null;
511
- return result;
512
- } catch (err) {
513
- ctx.log.warn(`context-engine maintain failed: ${err}`);
514
- pendingInjection = null;
515
- return { ...noChange, reason: String(err) };
516
- }
517
- },
518
- }));
519
-
520
- ctx.log.info("memos-local: registered context engine 'memos-local-openclaw-plugin'");
521
- } catch (err) {
522
- ctx.log.warn(`memos-local: context engine registration failed (${err}), memory injection will use before_prompt_build fallback`);
523
- }
524
-
525
- // ─── Memory Prompt Section: static instructions for the LLM ───
526
- try {
527
- api.registerMemoryPromptSection(() => [
528
- "## Memory System",
529
- "",
530
- "Assistant messages in this conversation may contain <relevant-memories> blocks.",
531
- "These are NOT part of the assistant's original response.",
532
- "They contain background knowledge and memories relevant to the next user message,",
533
- "injected by the user's local memory system before each query.",
534
- "Use them as context to better understand and respond to the following user message.",
535
- "Do not mention, quote, or repeat these memory blocks in your replies.",
536
- "",
537
- ]);
538
- ctx.log.info("memos-local: registered memory prompt section");
539
- } catch (err) {
540
- ctx.log.warn(`memos-local: registerMemoryPromptSection failed: ${err}`);
541
- }
542
-
543
404
  const trackTool = (toolName: string, fn: (...args: any[]) => Promise<any>) =>
544
405
  async (...args: any[]) => {
545
406
  const t0 = performance.now();
@@ -574,7 +435,6 @@ const memosLocalPlugin = {
574
435
  }
575
436
  };
576
437
 
577
- const getCurrentOwner = () => `agent:${currentAgentId}`;
578
438
  const resolveMemorySearchScope = (scope?: string): "local" | "group" | "all" =>
579
439
  scope === "group" || scope === "all" ? scope : "local";
580
440
  const resolveMemoryShareTarget = (target?: string): "agents" | "hub" | "both" =>
@@ -608,6 +468,7 @@ const memosLocalPlugin = {
608
468
  body: JSON.stringify({
609
469
  memory: {
610
470
  sourceChunkId: chunk.id,
471
+ sourceAgent: chunk.owner || "",
611
472
  role: chunk.role,
612
473
  content: chunk.content,
613
474
  summary: chunk.summary,
@@ -628,6 +489,7 @@ const memosLocalPlugin = {
628
489
  id: memoryId,
629
490
  sourceChunkId: chunk.id,
630
491
  sourceUserId: hubClient.userId,
492
+ sourceAgent: chunk.owner || "",
631
493
  role: chunk.role,
632
494
  content: chunk.content,
633
495
  summary: chunk.summary ?? "",
@@ -665,7 +527,7 @@ const memosLocalPlugin = {
665
527
  // ─── Tool: memory_search ───
666
528
 
667
529
  api.registerTool(
668
- {
530
+ (context) => ({
669
531
  name: "memory_search",
670
532
  label: "Memory Search",
671
533
  description:
@@ -681,7 +543,7 @@ const memosLocalPlugin = {
681
543
  hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for group/all search." })),
682
544
  userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for group/all search." })),
683
545
  }),
684
- execute: trackTool("memory_search", async (_toolCallId: any, params: any, context?: any) => {
546
+ execute: trackTool("memory_search", async (_toolCallId: any, params: any) => {
685
547
  const {
686
548
  query,
687
549
  scope: rawScope,
@@ -702,9 +564,6 @@ const memosLocalPlugin = {
702
564
  const role = rawRole === "user" || rawRole === "assistant" || rawRole === "tool" || rawRole === "system" ? rawRole : undefined;
703
565
  const minScore = typeof rawMinScore === "number" ? Math.max(0.35, Math.min(1, rawMinScore)) : undefined;
704
566
  let searchScope = resolveMemorySearchScope(rawScope);
705
- if (searchScope === "local" && ctx.config?.sharing?.enabled) {
706
- searchScope = "all";
707
- }
708
567
  const searchLimit = typeof maxResults === "number" ? Math.max(1, Math.min(20, Math.round(maxResults))) : 10;
709
568
 
710
569
  const agentId = context?.agentId ?? currentAgentId;
@@ -724,7 +583,7 @@ const memosLocalPlugin = {
724
583
 
725
584
  // Split local results: pure-local vs hub-memory (Hub role's hub_memories mixed in by RecallEngine)
726
585
  const localHits = result.hits.filter((h) => h.origin !== "hub-memory");
727
- const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory");
586
+ const hubLocalHits = searchScope !== "local" ? result.hits.filter((h) => h.origin === "hub-memory") : [];
728
587
 
729
588
  const rawLocalCandidates = localHits.map((h) => ({
730
589
  chunkId: h.ref.chunkId,
@@ -733,6 +592,7 @@ const memosLocalPlugin = {
733
592
  summary: h.summary,
734
593
  original_excerpt: (h.original_excerpt ?? "").slice(0, 200),
735
594
  origin: h.origin || "local",
595
+ owner: h.owner || "",
736
596
  }));
737
597
 
738
598
  // Hub remote candidates (from HTTP call) + hub-memory candidates (from RecallEngine for Hub role)
@@ -869,6 +729,7 @@ const memosLocalPlugin = {
869
729
  chunkId: h.ref.chunkId, taskId: effectiveTaskId, skillId: h.skillId,
870
730
  role: h.source.role, score: h.score, summary: h.summary,
871
731
  original_excerpt: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local",
732
+ owner: h.owner || "",
872
733
  };
873
734
  }),
874
735
  ...filteredHubRemoteHits.map((h: any) => ({
@@ -876,6 +737,7 @@ const memosLocalPlugin = {
876
737
  role: h.source?.role ?? h.role ?? "assistant", score: h.score ?? 0,
877
738
  summary: h.summary ?? "", original_excerpt: (h.excerpt ?? h.summary ?? "").slice(0, 200),
878
739
  origin: "hub-remote", ownerName: h.ownerName ?? "", groupName: h.groupName ?? "",
740
+ sourceAgent: h.sourceAgent ?? "",
879
741
  })),
880
742
  ];
881
743
 
@@ -889,14 +751,14 @@ const memosLocalPlugin = {
889
751
  },
890
752
  };
891
753
  }),
892
- },
754
+ }),
893
755
  { name: "memory_search" },
894
756
  );
895
757
 
896
758
  // ─── Tool: memory_timeline ───
897
759
 
898
760
  api.registerTool(
899
- {
761
+ (context) => ({
900
762
  name: "memory_timeline",
901
763
  label: "Memory Timeline",
902
764
  description:
@@ -906,7 +768,7 @@ const memosLocalPlugin = {
906
768
  chunkId: Type.String({ description: "The chunkId from a memory_search hit" }),
907
769
  window: Type.Optional(Type.Number({ description: "Context window ±N (default 2)" })),
908
770
  }),
909
- execute: trackTool("memory_timeline", async (_toolCallId: any, params: any, context?: any) => {
771
+ execute: trackTool("memory_timeline", async (_toolCallId: any, params: any) => {
910
772
  const agentId = context?.agentId ?? currentAgentId;
911
773
  ctx.log.debug(`memory_timeline called (agent=${agentId})`);
912
774
  const { chunkId, window: win } = params as {
@@ -950,14 +812,14 @@ const memosLocalPlugin = {
950
812
  details: { entries, anchorRef: { sessionKey: anchorChunk.sessionKey, chunkId, turnId: anchorChunk.turnId, seq: anchorChunk.seq } },
951
813
  };
952
814
  }),
953
- },
815
+ }),
954
816
  { name: "memory_timeline" },
955
817
  );
956
818
 
957
819
  // ─── Tool: memory_get ───
958
820
 
959
821
  api.registerTool(
960
- {
822
+ (context) => ({
961
823
  name: "memory_get",
962
824
  label: "Memory Get",
963
825
  description:
@@ -968,7 +830,7 @@ const memosLocalPlugin = {
968
830
  Type.Number({ description: `Max chars (default ${DEFAULTS.getMaxCharsDefault}, max ${DEFAULTS.getMaxCharsMax})` }),
969
831
  ),
970
832
  }),
971
- execute: trackTool("memory_get", async (_toolCallId: any, params: any, context?: any) => {
833
+ execute: trackTool("memory_get", async (_toolCallId: any, params: any) => {
972
834
  const { chunkId, maxChars } = params as { chunkId: string; maxChars?: number };
973
835
  const limit = Math.min(maxChars ?? DEFAULTS.getMaxCharsDefault, DEFAULTS.getMaxCharsMax);
974
836
 
@@ -994,7 +856,7 @@ const memosLocalPlugin = {
994
856
  },
995
857
  };
996
858
  }),
997
- },
859
+ }),
998
860
  { name: "memory_get" },
999
861
  );
1000
862
 
@@ -1335,6 +1197,10 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1335
1197
  };
1336
1198
  }
1337
1199
 
1200
+ const disabledWarning = skill.status === "archived"
1201
+ ? "\n\n> **Warning:** This skill is currently **disabled** (archived). Its content is shown for reference only — it will not be used in search or auto-recall.\n\n"
1202
+ : "";
1203
+
1338
1204
  const manifest = skillInstaller.getCompanionManifest(resolvedSkillId);
1339
1205
  let footer = "\n\n---\n";
1340
1206
 
@@ -1359,7 +1225,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1359
1225
  return {
1360
1226
  content: [{
1361
1227
  type: "text",
1362
- text: `## Skill: ${skill.name} (v${skill.version})\n\n${sv.content}${footer}`,
1228
+ text: `## Skill: ${skill.name} (v${skill.version})${disabledWarning}\n\n${sv.content}${footer}`,
1363
1229
  }],
1364
1230
  details: {
1365
1231
  skillId: skill.id,
@@ -1516,7 +1382,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1516
1382
  const viewerPort = (pluginCfg as any).viewerPort ?? (gatewayPort + 10);
1517
1383
 
1518
1384
  api.registerTool(
1519
- {
1385
+ (context) => ({
1520
1386
  name: "memory_viewer",
1521
1387
  label: "Open Memory Viewer",
1522
1388
  description:
@@ -1524,10 +1390,11 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1524
1390
  "or access their stored memories, or asks where the memory dashboard is. " +
1525
1391
  "Returns the URL the user can open in their browser.",
1526
1392
  parameters: Type.Object({}),
1527
- execute: trackTool("memory_viewer", async () => {
1393
+ execute: trackTool("memory_viewer", async (_toolCallId: any, params: any) => {
1528
1394
  ctx.log.debug(`memory_viewer called`);
1529
1395
  telemetry.trackViewerOpened();
1530
- const url = `http://127.0.0.1:${viewerPort}`;
1396
+ const agentId = context?.agentId ?? context?.profileId ?? currentAgentId;
1397
+ const url = `http://127.0.0.1:${viewerPort}?agentId=${encodeURIComponent(agentId)}`;
1531
1398
  return {
1532
1399
  content: [
1533
1400
  {
@@ -1548,7 +1415,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1548
1415
  details: { viewerUrl: url },
1549
1416
  };
1550
1417
  }),
1551
- },
1418
+ }),
1552
1419
  { name: "memory_viewer" },
1553
1420
  );
1554
1421
 
@@ -1779,7 +1646,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1779
1646
  // ─── Tool: skill_search ───
1780
1647
 
1781
1648
  api.registerTool(
1782
- {
1649
+ (context) => ({
1783
1650
  name: "skill_search",
1784
1651
  label: "Skill Search",
1785
1652
  description:
@@ -1789,10 +1656,11 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1789
1656
  query: Type.String({ description: "Natural language description of the needed skill" }),
1790
1657
  scope: Type.Optional(Type.String({ description: "Search scope: 'mix' (default), 'self', 'public', 'group', or 'all'." })),
1791
1658
  }),
1792
- execute: trackTool("skill_search", async (_toolCallId: any, params: any, context?: any) => {
1659
+ execute: trackTool("skill_search", async (_toolCallId: any, params: any) => {
1793
1660
  const { query: skillQuery, scope: rawScope } = params as { query: string; scope?: string };
1794
1661
  const scope = (rawScope === "self" || rawScope === "public") ? rawScope : "mix";
1795
- const currentOwner = getCurrentOwner();
1662
+ const agentId = context?.agentId ?? currentAgentId;
1663
+ const currentOwner = `agent:${agentId}`;
1796
1664
 
1797
1665
  if (rawScope === "group" || rawScope === "all") {
1798
1666
  const [localHits, hub] = await Promise.all([
@@ -1854,7 +1722,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1854
1722
  details: { query: skillQuery, scope, hits },
1855
1723
  };
1856
1724
  }),
1857
- },
1725
+ }),
1858
1726
  { name: "skill_search" },
1859
1727
  );
1860
1728
 
@@ -1993,81 +1861,292 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1993
1861
  { name: "network_skill_pull" },
1994
1862
  );
1995
1863
 
1996
- // ─── Skill auto-recall: inject relevant skills before agent starts ───
1997
- // Memory injection is handled by the Context Engine above.
1998
- // This hook only handles skill auto-recall via prependContext.
1864
+ // ─── Auto-recall: inject relevant memories before agent starts ───
1999
1865
 
2000
1866
  api.on("before_prompt_build", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
2001
1867
  if (!allowPromptInjection) return {};
2002
1868
  if (!event.prompt || event.prompt.length < 3) return;
2003
1869
 
2004
- const recallAgentId = hookCtx?.agentId ?? "main";
1870
+ const recallAgentId = hookCtx?.agentId ?? (event as any)?.agentId ?? (event as any)?.profileId ?? "main";
2005
1871
  currentAgentId = recallAgentId;
2006
-
2007
- const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
2008
- if (!skillAutoRecall) return;
1872
+ const recallOwnerFilter = [`agent:${recallAgentId}`, "public"];
1873
+ ctx.log.info(`auto-recall: agentId=${recallAgentId} (from hookCtx)`);
2009
1874
 
2010
1875
  const recallT0 = performance.now();
1876
+ let recallQuery = "";
2011
1877
 
2012
1878
  try {
2013
- let query = event.prompt;
2014
- const senderTag = "Sender (untrusted metadata):";
2015
- const senderPos = query.indexOf(senderTag);
2016
- if (senderPos !== -1) {
2017
- const afterSender = query.slice(senderPos);
2018
- const fenceStart = afterSender.indexOf("```json");
2019
- const fenceEnd = fenceStart >= 0 ? afterSender.indexOf("```\n", fenceStart + 7) : -1;
2020
- if (fenceEnd > 0) {
2021
- query = afterSender.slice(fenceEnd + 4).replace(/^\s*\n/, "").trim();
1879
+ const rawPrompt = event.prompt;
1880
+ ctx.log.debug(`auto-recall: rawPrompt="${rawPrompt.slice(0, 300)}"`);
1881
+
1882
+ const query = normalizeAutoRecallQuery(rawPrompt);
1883
+ recallQuery = query;
1884
+
1885
+ if (query.length < 2) {
1886
+ ctx.log.debug("auto-recall: extracted query too short, skipping");
1887
+ return;
1888
+ }
1889
+ ctx.log.debug(`auto-recall: query="${query.slice(0, 80)}"`);
1890
+
1891
+ // ── Phase 1: Local search ∥ Hub search (parallel) ──
1892
+ const arLocalP = engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwnerFilter });
1893
+ const arHubP = ctx.config?.sharing?.enabled
1894
+ ? hubSearchMemories(store, ctx, { query, maxResults: 10, scope: "all" })
1895
+ .catch((err: any) => { ctx.log.debug(`auto-recall: hub search failed (${err})`); return { hits: [] as any[], meta: {} }; })
1896
+ : Promise.resolve({ hits: [] as any[], meta: {} });
1897
+
1898
+ const [result, arHubResult] = await Promise.all([arLocalP, arHubP]);
1899
+
1900
+ const localHits = result.hits.filter((h) => h.origin !== "hub-memory");
1901
+ const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory");
1902
+ const hubRemoteHits: SearchHit[] = (arHubResult.hits ?? []).map((h: any) => ({
1903
+ summary: h.summary,
1904
+ original_excerpt: h.excerpt || h.summary,
1905
+ ref: { sessionKey: "", chunkId: h.remoteHitId ?? "", turnId: "", seq: 0 },
1906
+ score: 0.9,
1907
+ taskId: null,
1908
+ skillId: null,
1909
+ origin: "hub-remote" as const,
1910
+ source: { ts: h.source?.ts, role: h.source?.role ?? "assistant", sessionKey: "" },
1911
+ ownerName: h.ownerName,
1912
+ groupName: h.groupName,
1913
+ }));
1914
+ const allHubHits = [...hubLocalHits, ...hubRemoteHits];
1915
+
1916
+ ctx.log.debug(`auto-recall: local=${localHits.length}, hub-memory=${hubLocalHits.length}, hub-remote=${hubRemoteHits.length}`);
1917
+
1918
+ const rawLocalCandidates = localHits.map((h) => ({
1919
+ score: h.score, role: h.source.role, summary: h.summary,
1920
+ content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local",
1921
+ owner: h.owner || "",
1922
+ }));
1923
+ const rawHubCandidates = allHubHits.map((h) => ({
1924
+ score: h.score, role: h.source.role, summary: h.summary,
1925
+ content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "hub-remote",
1926
+ ownerName: (h as any).ownerName ?? "", groupName: (h as any).groupName ?? "",
1927
+ }));
1928
+
1929
+ const allRawHits = [...localHits, ...allHubHits];
1930
+
1931
+ if (allRawHits.length === 0) {
1932
+ ctx.log.debug("auto-recall: no memory candidates found");
1933
+ const dur = performance.now() - recallT0;
1934
+ store.recordToolCall("memory_search", dur, true);
1935
+ store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
1936
+ candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [],
1937
+ }), dur, true);
1938
+
1939
+ const skillAutoRecallEarly = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
1940
+ if (skillAutoRecallEarly) {
1941
+ try {
1942
+ const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit;
1943
+ const skillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner());
1944
+ const topSkills = skillHits.slice(0, skillLimit);
1945
+ if (topSkills.length > 0) {
1946
+ const skillLines = topSkills.map((sc, i) => {
1947
+ const manifest = skillInstaller.getCompanionManifest(sc.skillId);
1948
+ let badge = "";
1949
+ if (manifest?.installed) badge = " [installed]";
1950
+ else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]";
1951
+ else if (manifest?.hasCompanionFiles) badge = " [has companion files]";
1952
+ return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → call \`skill_get(skillId="${sc.skillId}")\` for the full guide`;
1953
+ });
1954
+ const skillContext = "## Relevant skills from past experience\n\n" +
1955
+ "No direct memory matches were found, but these skills from past tasks may help:\n\n" +
1956
+ skillLines.join("\n\n") +
1957
+ "\n\nYou SHOULD call `skill_get` to retrieve the full guide before attempting the task.";
1958
+ ctx.log.info(`auto-recall-skill (no-memory path): injecting ${topSkills.length} skill(s)`);
1959
+ try { store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(topSkills), dur, true); } catch { /* best-effort */ }
1960
+ return { prependContext: skillContext };
1961
+ }
1962
+ } catch (err) {
1963
+ ctx.log.debug(`auto-recall-skill (no-memory path): failed: ${err}`);
1964
+ }
1965
+ }
1966
+
1967
+ if (query.length > 50) {
1968
+ const noRecallHint =
1969
+ "## Memory system — ACTION REQUIRED\n\n" +
1970
+ "Auto-recall found no results for a long query. " +
1971
+ "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " +
1972
+ "Do NOT skip this step. Do NOT answer without searching first.";
1973
+ return { prependContext: noRecallHint };
1974
+ }
1975
+ return;
1976
+ }
1977
+
1978
+ // ── Phase 2: Merge all → single LLM filter ──
1979
+ const mergedForFilter = allRawHits.map((h, i) => ({
1980
+ index: i + 1,
1981
+ role: h.source.role,
1982
+ content: (h.original_excerpt ?? "").slice(0, 300),
1983
+ time: h.source.ts ? new Date(h.source.ts).toISOString().slice(0, 16) : "",
1984
+ }));
1985
+
1986
+ let filteredHits = allRawHits;
1987
+ let sufficient = false;
1988
+
1989
+ const filterResult = await summarizer.filterRelevant(query, mergedForFilter);
1990
+ if (filterResult !== null) {
1991
+ sufficient = filterResult.sufficient;
1992
+ if (filterResult.relevant.length > 0) {
1993
+ const indexSet = new Set(filterResult.relevant);
1994
+ filteredHits = allRawHits.filter((_, i) => indexSet.has(i + 1));
2022
1995
  } else {
2023
- const firstDblNl = afterSender.indexOf("\n\n");
2024
- if (firstDblNl > 0) {
2025
- query = afterSender.slice(firstDblNl + 2).trim();
1996
+ const dur = performance.now() - recallT0;
1997
+ store.recordToolCall("memory_search", dur, true);
1998
+ store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
1999
+ candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [],
2000
+ }), dur, true);
2001
+ if (query.length > 50) {
2002
+ const noRecallHint =
2003
+ "## Memory system — ACTION REQUIRED\n\n" +
2004
+ "Auto-recall found no relevant results for a long query. " +
2005
+ "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " +
2006
+ "Do NOT skip this step. Do NOT answer without searching first.";
2007
+ return { prependContext: noRecallHint };
2026
2008
  }
2009
+ return;
2027
2010
  }
2028
2011
  }
2029
- query = stripInboundMetadata(query).replace(/<[^>]+>/g, "").trim();
2030
- if (query.length < 2) return;
2031
2012
 
2013
+ const beforeDedup = filteredHits.length;
2014
+ filteredHits = deduplicateHits(filteredHits);
2015
+ ctx.log.debug(`auto-recall: merged ${allRawHits.length} → ${beforeDedup} relevant → ${filteredHits.length} after dedup, sufficient=${sufficient}`);
2016
+
2017
+ const lines = filteredHits.map((h, i) => {
2018
+ const excerpt = h.original_excerpt;
2019
+ const oTag = h.origin === "local-shared" ? " [本机共享]" : h.origin === "hub-memory" ? " [团队缓存]" : "";
2020
+ const parts: string[] = [`${i + 1}. [${h.source.role}]${oTag}`];
2021
+ if (excerpt) parts.push(` ${excerpt}`);
2022
+ parts.push(` chunkId="${h.ref.chunkId}"`);
2023
+ if (h.taskId) {
2024
+ const task = store.getTask(h.taskId);
2025
+ if (task && task.status !== "skipped") {
2026
+ parts.push(` task_id="${h.taskId}"`);
2027
+ }
2028
+ }
2029
+ return parts.join("\n");
2030
+ });
2031
+
2032
+ const hasTask = filteredHits.some((h) => {
2033
+ if (!h.taskId) return false;
2034
+ const t = store.getTask(h.taskId);
2035
+ return t && t.status !== "skipped";
2036
+ });
2037
+ const tips: string[] = [];
2038
+ if (hasTask) {
2039
+ tips.push("- A hit has `task_id` → call `task_summary(taskId=\"...\")` to get the full task context (steps, code, results)");
2040
+ tips.push("- A task may have a reusable guide → call `skill_get(taskId=\"...\")` to retrieve the experience/skill");
2041
+ }
2042
+ tips.push("- Need more surrounding dialogue → call `memory_timeline(chunkId=\"...\")` to expand context around a hit");
2043
+ const tipsText = "\n\nAvailable follow-up tools:\n" + tips.join("\n");
2044
+
2045
+ const contextParts = [
2046
+ "## User's conversation history (from memory system)",
2047
+ "",
2048
+ "IMPORTANT: The following are facts from previous conversations with this user.",
2049
+ "You MUST treat these as established knowledge and use them directly when answering.",
2050
+ "Do NOT say you don't know or don't have information if the answer is in these memories.",
2051
+ "",
2052
+ lines.join("\n\n"),
2053
+ ];
2054
+ if (tipsText) contextParts.push(tipsText);
2055
+
2056
+ // ─── Skill auto-recall ───
2057
+ const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
2032
2058
  const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit;
2033
- const skillCandidateMap = new Map<string, { name: string; description: string; skillId: string; source: string }>();
2059
+ let skillSection = "";
2034
2060
 
2035
- try {
2036
- const directSkillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner());
2037
- for (const sh of directSkillHits.slice(0, skillLimit + 2)) {
2038
- if (!skillCandidateMap.has(sh.skillId)) {
2039
- skillCandidateMap.set(sh.skillId, { name: sh.name, description: sh.description, skillId: sh.skillId, source: "query" });
2061
+ if (skillAutoRecall) {
2062
+ try {
2063
+ const skillCandidateMap = new Map<string, { name: string; description: string; skillId: string; source: string }>();
2064
+
2065
+ try {
2066
+ const directSkillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner());
2067
+ for (const sh of directSkillHits.slice(0, skillLimit + 2)) {
2068
+ if (!skillCandidateMap.has(sh.skillId)) {
2069
+ skillCandidateMap.set(sh.skillId, { name: sh.name, description: sh.description, skillId: sh.skillId, source: "query" });
2070
+ }
2071
+ }
2072
+ } catch (err) {
2073
+ ctx.log.debug(`auto-recall-skill: direct search failed: ${err}`);
2074
+ }
2075
+
2076
+ const taskIds = new Set<string>();
2077
+ for (const h of filteredHits) {
2078
+ if (h.taskId) {
2079
+ const t = store.getTask(h.taskId);
2080
+ if (t && t.status !== "skipped") taskIds.add(h.taskId);
2081
+ }
2082
+ }
2083
+ for (const tid of taskIds) {
2084
+ const linked = store.getSkillsByTask(tid);
2085
+ for (const rs of linked) {
2086
+ if (!skillCandidateMap.has(rs.skill.id)) {
2087
+ skillCandidateMap.set(rs.skill.id, { name: rs.skill.name, description: rs.skill.description, skillId: rs.skill.id, source: `task:${tid}` });
2088
+ }
2089
+ }
2040
2090
  }
2091
+
2092
+ const skillCandidates = [...skillCandidateMap.values()].slice(0, skillLimit);
2093
+
2094
+ if (skillCandidates.length > 0) {
2095
+ const skillLines = skillCandidates.map((sc, i) => {
2096
+ const manifest = skillInstaller.getCompanionManifest(sc.skillId);
2097
+ let badge = "";
2098
+ if (manifest?.installed) badge = " [installed]";
2099
+ else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]";
2100
+ else if (manifest?.hasCompanionFiles) badge = " [has companion files]";
2101
+ const action = `call \`skill_get(skillId="${sc.skillId}")\``;
2102
+ return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → ${action}`;
2103
+ });
2104
+ skillSection = "\n\n## Relevant skills from past experience\n\n" +
2105
+ "The following skills were distilled from similar previous tasks. " +
2106
+ "You SHOULD call `skill_get` to retrieve the full guide before attempting the task.\n\n" +
2107
+ skillLines.join("\n\n");
2108
+
2109
+ ctx.log.info(`auto-recall-skill: injecting ${skillCandidates.length} skill(s): ${skillCandidates.map(s => s.name).join(", ")}`);
2110
+ try {
2111
+ store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(skillCandidates), performance.now() - recallT0, true);
2112
+ } catch { /* best-effort */ }
2113
+ } else {
2114
+ ctx.log.debug("auto-recall-skill: no matching skills found");
2115
+ }
2116
+ } catch (err) {
2117
+ ctx.log.debug(`auto-recall-skill: failed: ${err}`);
2041
2118
  }
2042
- } catch (err) {
2043
- ctx.log.debug(`auto-recall-skill: direct search failed: ${err}`);
2044
2119
  }
2045
2120
 
2046
- const skillCandidates = [...skillCandidateMap.values()].slice(0, skillLimit);
2047
- if (skillCandidates.length === 0) return;
2048
-
2049
- const skillLines = skillCandidates.map((sc, i) => {
2050
- const manifest = skillInstaller.getCompanionManifest(sc.skillId);
2051
- let badge = "";
2052
- if (manifest?.installed) badge = " [installed]";
2053
- else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]";
2054
- else if (manifest?.hasCompanionFiles) badge = " [has companion files]";
2055
- const action = `call \`skill_get(skillId="${sc.skillId}")\``;
2056
- return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → ${action}`;
2057
- });
2058
- const skillContext = "## Relevant skills from past experience\n\n" +
2059
- "The following skills were distilled from similar previous tasks. " +
2060
- "You SHOULD call `skill_get` to retrieve the full guide before attempting the task.\n\n" +
2061
- skillLines.join("\n\n");
2062
-
2063
- ctx.log.info(`auto-recall-skill: injecting ${skillCandidates.length} skill(s): ${skillCandidates.map(s => s.name).join(", ")}`);
2064
- try {
2065
- store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(skillCandidates), performance.now() - recallT0, true);
2066
- } catch { /* best-effort */ }
2121
+ if (skillSection) contextParts.push(skillSection);
2122
+ const context = contextParts.join("\n");
2123
+
2124
+ const recallDur = performance.now() - recallT0;
2125
+ store.recordToolCall("memory_search", recallDur, true);
2126
+ store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
2127
+ candidates: rawLocalCandidates,
2128
+ hubCandidates: rawHubCandidates,
2129
+ filtered: filteredHits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt, origin: h.origin || "local", owner: h.owner || "" })),
2130
+ }), recallDur, true);
2131
+ telemetry.trackAutoRecall(filteredHits.length, recallDur);
2132
+
2133
+ ctx.log.info(`auto-recall: returning prependContext (${context.length} chars), sufficient=${sufficient}, skills=${skillSection ? "yes" : "no"}`);
2134
+
2135
+ if (!sufficient) {
2136
+ const searchHint =
2137
+ "\n\nIf these memories don't fully answer the question, " +
2138
+ "call `memory_search` with a shorter or rephrased query to find more.";
2139
+ return { prependContext: context + searchHint };
2140
+ }
2067
2141
 
2068
- return { prependContext: skillContext };
2142
+ return {
2143
+ prependContext: context,
2144
+ };
2069
2145
  } catch (err) {
2070
- ctx.log.warn(`auto-recall-skill failed: ${String(err)}`);
2146
+ const dur = performance.now() - recallT0;
2147
+ store.recordToolCall("memory_search", dur, false);
2148
+ try { store.recordApiLog("memory_search", { type: "auto_recall", query: recallQuery }, `error: ${String(err)}`, dur, false); } catch (_) { /* best-effort */ }
2149
+ ctx.log.warn(`auto-recall failed: ${String(err)}`);
2071
2150
  }
2072
2151
  });
2073
2152
 
@@ -2083,7 +2162,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2083
2162
  if (!event.success || !event.messages || event.messages.length === 0) return;
2084
2163
 
2085
2164
  try {
2086
- const captureAgentId = hookCtx?.agentId ?? "main";
2165
+ const captureAgentId = hookCtx?.agentId ?? event?.agentId ?? event?.profileId ?? "main";
2087
2166
  currentAgentId = captureAgentId;
2088
2167
  const captureOwner = `agent:${captureAgentId}`;
2089
2168
  const sessionKey = hookCtx?.sessionKey ?? "default";
@@ -2301,48 +2380,54 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2301
2380
 
2302
2381
  // ─── Service lifecycle ───
2303
2382
 
2304
- api.registerService({
2305
- id: "memos-local-openclaw-plugin",
2306
- start: async () => {
2307
- if (hubServer) {
2308
- const hubUrl = await hubServer.start();
2309
- api.logger.info(`memos-local: hub started at ${hubUrl}`);
2310
- }
2383
+ let serviceStarted = false;
2311
2384
 
2312
- // Auto-connect to Hub in client mode (handles both existing token and auto-join via teamToken)
2313
- if (ctx.config.sharing?.enabled && ctx.config.sharing.role === "client") {
2314
- try {
2315
- const session = await connectToHub(store, ctx.config, ctx.log);
2316
- api.logger.info(`memos-local: connected to Hub as "${session.username}" (${session.userId})`);
2317
- } catch (err) {
2318
- api.logger.warn(`memos-local: Hub connection failed: ${err}`);
2319
- }
2320
- }
2385
+ const startServiceCore = async () => {
2386
+ if (serviceStarted) return;
2387
+ serviceStarted = true;
2388
+
2389
+ if (hubServer) {
2390
+ const hubUrl = await hubServer.start();
2391
+ api.logger.info(`memos-local: hub started at ${hubUrl}`);
2392
+ }
2321
2393
 
2394
+ if (ctx.config.sharing?.enabled && ctx.config.sharing.role === "client") {
2322
2395
  try {
2323
- const viewerUrl = await viewer.start();
2324
- api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
2325
- api.logger.info(`╔══════════════════════════════════════════╗`);
2326
- api.logger.info(`║ MemOS Memory Viewer ║`);
2327
- api.logger.info(`║ → ${viewerUrl.padEnd(37)}║`);
2328
- api.logger.info(`║ Open in browser to manage memories ║`);
2329
- api.logger.info(`╚══════════════════════════════════════════╝`);
2330
- api.logger.info(`memos-local: password reset token: ${viewer.getResetToken()}`);
2331
- api.logger.info(`memos-local: forgot password? Use the reset token on the login page.`);
2332
- skillEvolver.recoverOrphanedTasks().then((count) => {
2333
- if (count > 0) api.logger.info(`memos-local: recovered ${count} orphaned skill tasks`);
2334
- }).catch((err) => {
2335
- api.logger.warn(`memos-local: skill recovery failed: ${err}`);
2336
- });
2396
+ const session = await connectToHub(store, ctx.config, ctx.log);
2397
+ api.logger.info(`memos-local: connected to Hub as "${session.username}" (${session.userId})`);
2337
2398
  } catch (err) {
2338
- api.logger.warn(`memos-local: viewer failed to start: ${err}`);
2339
- api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
2399
+ api.logger.warn(`memos-local: Hub connection failed: ${err}`);
2340
2400
  }
2341
- telemetry.trackPluginStarted(
2342
- ctx.config.embedding?.provider ?? "local",
2343
- ctx.config.summarizer?.provider ?? "none",
2344
- );
2345
- },
2401
+ }
2402
+
2403
+ try {
2404
+ const viewerUrl = await viewer.start();
2405
+ api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
2406
+ api.logger.info(`╔══════════════════════════════════════════╗`);
2407
+ api.logger.info(`║ MemOS Memory Viewer ║`);
2408
+ api.logger.info(`║ → ${viewerUrl.padEnd(37)}║`);
2409
+ api.logger.info(`║ Open in browser to manage memories ║`);
2410
+ api.logger.info(`╚══════════════════════════════════════════╝`);
2411
+ api.logger.info(`memos-local: password reset token: ${viewer.getResetToken()}`);
2412
+ api.logger.info(`memos-local: forgot password? Use the reset token on the login page.`);
2413
+ skillEvolver.recoverOrphanedTasks().then((count) => {
2414
+ if (count > 0) api.logger.info(`memos-local: recovered ${count} orphaned skill tasks`);
2415
+ }).catch((err) => {
2416
+ api.logger.warn(`memos-local: skill recovery failed: ${err}`);
2417
+ });
2418
+ } catch (err) {
2419
+ api.logger.warn(`memos-local: viewer failed to start: ${err}`);
2420
+ api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
2421
+ }
2422
+ telemetry.trackPluginStarted(
2423
+ ctx.config.embedding?.provider ?? "local",
2424
+ ctx.config.summarizer?.provider ?? "none",
2425
+ );
2426
+ };
2427
+
2428
+ api.registerService({
2429
+ id: "memos-local-openclaw-plugin",
2430
+ start: async () => { await startServiceCore(); },
2346
2431
  stop: async () => {
2347
2432
  await worker.flush();
2348
2433
  await telemetry.shutdown();
@@ -2352,6 +2437,21 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2352
2437
  api.logger.info("memos-local: stopped");
2353
2438
  },
2354
2439
  });
2440
+
2441
+ // Fallback: OpenClaw may load this plugin via deferred reload after
2442
+ // startPluginServices has already run, so service.start() never fires.
2443
+ // Start on the next tick instead of waiting several seconds; the
2444
+ // serviceStarted guard still prevents duplicate startup if the host calls
2445
+ // service.start() immediately after registration.
2446
+ const SELF_START_DELAY_MS = 0;
2447
+ setTimeout(() => {
2448
+ if (!serviceStarted) {
2449
+ api.logger.info("memos-local: service.start() not called by host, self-starting viewer...");
2450
+ startServiceCore().catch((err) => {
2451
+ api.logger.warn(`memos-local: self-start failed: ${err}`);
2452
+ });
2453
+ }
2454
+ }, SELF_START_DELAY_MS);
2355
2455
  },
2356
2456
  };
2357
2457