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

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,6 +31,18 @@ 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";
34
46
 
35
47
 
36
48
  /** Remove near-duplicate hits based on summary word overlap (>70%). Keeps first (highest-scored) hit. */
@@ -53,45 +65,6 @@ function deduplicateHits<T extends { summary: string }>(hits: T[]): T[] {
53
65
  return kept;
54
66
  }
55
67
 
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
- function normalizeAutoRecallQuery(rawPrompt: string): string {
61
- let query = rawPrompt.trim();
62
-
63
- const senderTag = "Sender (untrusted metadata):";
64
- const senderPos = query.indexOf(senderTag);
65
- if (senderPos !== -1) {
66
- const afterSender = query.slice(senderPos);
67
- const fenceStart = afterSender.indexOf("```json");
68
- const fenceEnd = fenceStart >= 0 ? afterSender.indexOf("```\n", fenceStart + 7) : -1;
69
- if (fenceEnd > 0) {
70
- query = afterSender.slice(fenceEnd + 4).replace(/^\s*\n/, "").trim();
71
- } else {
72
- const firstDblNl = afterSender.indexOf("\n\n");
73
- if (firstDblNl > 0) {
74
- query = afterSender.slice(firstDblNl + 2).trim();
75
- }
76
- }
77
- }
78
-
79
- query = stripInboundMetadata(query);
80
- query = query.replace(/<[^>]+>/g, "").trim();
81
-
82
- if (NEW_SESSION_PROMPT_RE.test(query)) {
83
- query = query.replace(NEW_SESSION_PROMPT_RE, "").trim();
84
- query = query.replace(/^(Execute|Run) your Session Startup sequence[^\n]*\n?/im, "").trim();
85
- query = query.replace(/^Current time:[^\n]*(\n|$)/im, "").trim();
86
- }
87
-
88
- query = query.replace(INTERNAL_CONTEXT_RE, "").trim();
89
- query = query.replace(CONTINUE_PROMPT_RE, "").trim();
90
-
91
- return query;
92
- }
93
-
94
-
95
68
  const pluginConfigSchema = {
96
69
  type: "object" as const,
97
70
  additionalProperties: true,
@@ -317,7 +290,7 @@ const memosLocalPlugin = {
317
290
  const raw = fs.readFileSync(openclawJsonPath, "utf-8");
318
291
  const cfg = JSON.parse(raw);
319
292
  const allow: string[] | undefined = cfg?.tools?.allow;
320
- if (Array.isArray(allow) && allow.length > 0 && !allow.includes("group:plugins") && !allow.includes("*")) {
293
+ if (Array.isArray(allow) && allow.length > 0 && !allow.includes("group:plugins")) {
321
294
  const lastEntry = JSON.stringify(allow[allow.length - 1]);
322
295
  const patched = raw.replace(
323
296
  new RegExp(`(${lastEntry})(\\s*\\])`),
@@ -346,7 +319,6 @@ const memosLocalPlugin = {
346
319
  // Current agent ID — updated by hooks, read by tools for owner isolation.
347
320
  // Falls back to "main" when no hook has fired yet (single-agent setups).
348
321
  let currentAgentId = "main";
349
- const getCurrentOwner = () => `agent:${currentAgentId}`;
350
322
 
351
323
  // ─── Check allowPromptInjection policy ───
352
324
  // When allowPromptInjection=false, the prompt mutation fields (such as prependContext) in the hook return value
@@ -360,6 +332,214 @@ const memosLocalPlugin = {
360
332
  api.logger.info("memos-local: allowPromptInjection=true, auto-recall enabled");
361
333
  }
362
334
 
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
+
363
543
  const trackTool = (toolName: string, fn: (...args: any[]) => Promise<any>) =>
364
544
  async (...args: any[]) => {
365
545
  const t0 = performance.now();
@@ -394,6 +574,7 @@ const memosLocalPlugin = {
394
574
  }
395
575
  };
396
576
 
577
+ const getCurrentOwner = () => `agent:${currentAgentId}`;
397
578
  const resolveMemorySearchScope = (scope?: string): "local" | "group" | "all" =>
398
579
  scope === "group" || scope === "all" ? scope : "local";
399
580
  const resolveMemoryShareTarget = (target?: string): "agents" | "hub" | "both" =>
@@ -427,7 +608,6 @@ const memosLocalPlugin = {
427
608
  body: JSON.stringify({
428
609
  memory: {
429
610
  sourceChunkId: chunk.id,
430
- sourceAgent: chunk.owner || "",
431
611
  role: chunk.role,
432
612
  content: chunk.content,
433
613
  summary: chunk.summary,
@@ -448,7 +628,6 @@ const memosLocalPlugin = {
448
628
  id: memoryId,
449
629
  sourceChunkId: chunk.id,
450
630
  sourceUserId: hubClient.userId,
451
- sourceAgent: chunk.owner || "",
452
631
  role: chunk.role,
453
632
  content: chunk.content,
454
633
  summary: chunk.summary ?? "",
@@ -486,7 +665,7 @@ const memosLocalPlugin = {
486
665
  // ─── Tool: memory_search ───
487
666
 
488
667
  api.registerTool(
489
- (context) => ({
668
+ {
490
669
  name: "memory_search",
491
670
  label: "Memory Search",
492
671
  description:
@@ -502,7 +681,7 @@ const memosLocalPlugin = {
502
681
  hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for group/all search." })),
503
682
  userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for group/all search." })),
504
683
  }),
505
- execute: trackTool("memory_search", async (_toolCallId: any, params: any) => {
684
+ execute: trackTool("memory_search", async (_toolCallId: any, params: any, context?: any) => {
506
685
  const {
507
686
  query,
508
687
  scope: rawScope,
@@ -523,6 +702,9 @@ const memosLocalPlugin = {
523
702
  const role = rawRole === "user" || rawRole === "assistant" || rawRole === "tool" || rawRole === "system" ? rawRole : undefined;
524
703
  const minScore = typeof rawMinScore === "number" ? Math.max(0.35, Math.min(1, rawMinScore)) : undefined;
525
704
  let searchScope = resolveMemorySearchScope(rawScope);
705
+ if (searchScope === "local" && ctx.config?.sharing?.enabled) {
706
+ searchScope = "all";
707
+ }
526
708
  const searchLimit = typeof maxResults === "number" ? Math.max(1, Math.min(20, Math.round(maxResults))) : 10;
527
709
 
528
710
  const agentId = context?.agentId ?? currentAgentId;
@@ -542,7 +724,7 @@ const memosLocalPlugin = {
542
724
 
543
725
  // Split local results: pure-local vs hub-memory (Hub role's hub_memories mixed in by RecallEngine)
544
726
  const localHits = result.hits.filter((h) => h.origin !== "hub-memory");
545
- const hubLocalHits = searchScope !== "local" ? result.hits.filter((h) => h.origin === "hub-memory") : [];
727
+ const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory");
546
728
 
547
729
  const rawLocalCandidates = localHits.map((h) => ({
548
730
  chunkId: h.ref.chunkId,
@@ -551,7 +733,6 @@ const memosLocalPlugin = {
551
733
  summary: h.summary,
552
734
  original_excerpt: (h.original_excerpt ?? "").slice(0, 200),
553
735
  origin: h.origin || "local",
554
- owner: h.owner || "",
555
736
  }));
556
737
 
557
738
  // Hub remote candidates (from HTTP call) + hub-memory candidates (from RecallEngine for Hub role)
@@ -688,7 +869,6 @@ const memosLocalPlugin = {
688
869
  chunkId: h.ref.chunkId, taskId: effectiveTaskId, skillId: h.skillId,
689
870
  role: h.source.role, score: h.score, summary: h.summary,
690
871
  original_excerpt: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local",
691
- owner: h.owner || "",
692
872
  };
693
873
  }),
694
874
  ...filteredHubRemoteHits.map((h: any) => ({
@@ -696,7 +876,6 @@ const memosLocalPlugin = {
696
876
  role: h.source?.role ?? h.role ?? "assistant", score: h.score ?? 0,
697
877
  summary: h.summary ?? "", original_excerpt: (h.excerpt ?? h.summary ?? "").slice(0, 200),
698
878
  origin: "hub-remote", ownerName: h.ownerName ?? "", groupName: h.groupName ?? "",
699
- sourceAgent: h.sourceAgent ?? "",
700
879
  })),
701
880
  ];
702
881
 
@@ -710,14 +889,14 @@ const memosLocalPlugin = {
710
889
  },
711
890
  };
712
891
  }),
713
- }),
892
+ },
714
893
  { name: "memory_search" },
715
894
  );
716
895
 
717
896
  // ─── Tool: memory_timeline ───
718
897
 
719
898
  api.registerTool(
720
- (context) => ({
899
+ {
721
900
  name: "memory_timeline",
722
901
  label: "Memory Timeline",
723
902
  description:
@@ -727,7 +906,7 @@ const memosLocalPlugin = {
727
906
  chunkId: Type.String({ description: "The chunkId from a memory_search hit" }),
728
907
  window: Type.Optional(Type.Number({ description: "Context window ±N (default 2)" })),
729
908
  }),
730
- execute: trackTool("memory_timeline", async (_toolCallId: any, params: any) => {
909
+ execute: trackTool("memory_timeline", async (_toolCallId: any, params: any, context?: any) => {
731
910
  const agentId = context?.agentId ?? currentAgentId;
732
911
  ctx.log.debug(`memory_timeline called (agent=${agentId})`);
733
912
  const { chunkId, window: win } = params as {
@@ -771,14 +950,14 @@ const memosLocalPlugin = {
771
950
  details: { entries, anchorRef: { sessionKey: anchorChunk.sessionKey, chunkId, turnId: anchorChunk.turnId, seq: anchorChunk.seq } },
772
951
  };
773
952
  }),
774
- }),
953
+ },
775
954
  { name: "memory_timeline" },
776
955
  );
777
956
 
778
957
  // ─── Tool: memory_get ───
779
958
 
780
959
  api.registerTool(
781
- (context) => ({
960
+ {
782
961
  name: "memory_get",
783
962
  label: "Memory Get",
784
963
  description:
@@ -789,7 +968,7 @@ const memosLocalPlugin = {
789
968
  Type.Number({ description: `Max chars (default ${DEFAULTS.getMaxCharsDefault}, max ${DEFAULTS.getMaxCharsMax})` }),
790
969
  ),
791
970
  }),
792
- execute: trackTool("memory_get", async (_toolCallId: any, params: any) => {
971
+ execute: trackTool("memory_get", async (_toolCallId: any, params: any, context?: any) => {
793
972
  const { chunkId, maxChars } = params as { chunkId: string; maxChars?: number };
794
973
  const limit = Math.min(maxChars ?? DEFAULTS.getMaxCharsDefault, DEFAULTS.getMaxCharsMax);
795
974
 
@@ -815,7 +994,7 @@ const memosLocalPlugin = {
815
994
  },
816
995
  };
817
996
  }),
818
- }),
997
+ },
819
998
  { name: "memory_get" },
820
999
  );
821
1000
 
@@ -1156,10 +1335,6 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1156
1335
  };
1157
1336
  }
1158
1337
 
1159
- const disabledWarning = skill.status === "archived"
1160
- ? "\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"
1161
- : "";
1162
-
1163
1338
  const manifest = skillInstaller.getCompanionManifest(resolvedSkillId);
1164
1339
  let footer = "\n\n---\n";
1165
1340
 
@@ -1184,7 +1359,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1184
1359
  return {
1185
1360
  content: [{
1186
1361
  type: "text",
1187
- text: `## Skill: ${skill.name} (v${skill.version})${disabledWarning}\n\n${sv.content}${footer}`,
1362
+ text: `## Skill: ${skill.name} (v${skill.version})\n\n${sv.content}${footer}`,
1188
1363
  }],
1189
1364
  details: {
1190
1365
  skillId: skill.id,
@@ -1341,7 +1516,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1341
1516
  const viewerPort = (pluginCfg as any).viewerPort ?? (gatewayPort + 10);
1342
1517
 
1343
1518
  api.registerTool(
1344
- (context) => ({
1519
+ {
1345
1520
  name: "memory_viewer",
1346
1521
  label: "Open Memory Viewer",
1347
1522
  description:
@@ -1349,11 +1524,10 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1349
1524
  "or access their stored memories, or asks where the memory dashboard is. " +
1350
1525
  "Returns the URL the user can open in their browser.",
1351
1526
  parameters: Type.Object({}),
1352
- execute: trackTool("memory_viewer", async (_toolCallId: any, params: any) => {
1527
+ execute: trackTool("memory_viewer", async () => {
1353
1528
  ctx.log.debug(`memory_viewer called`);
1354
1529
  telemetry.trackViewerOpened();
1355
- const agentId = context?.agentId ?? context?.profileId ?? currentAgentId;
1356
- const url = `http://127.0.0.1:${viewerPort}?agentId=${encodeURIComponent(agentId)}`;
1530
+ const url = `http://127.0.0.1:${viewerPort}`;
1357
1531
  return {
1358
1532
  content: [
1359
1533
  {
@@ -1374,7 +1548,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1374
1548
  details: { viewerUrl: url },
1375
1549
  };
1376
1550
  }),
1377
- }),
1551
+ },
1378
1552
  { name: "memory_viewer" },
1379
1553
  );
1380
1554
 
@@ -1605,7 +1779,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1605
1779
  // ─── Tool: skill_search ───
1606
1780
 
1607
1781
  api.registerTool(
1608
- (context) => ({
1782
+ {
1609
1783
  name: "skill_search",
1610
1784
  label: "Skill Search",
1611
1785
  description:
@@ -1615,11 +1789,10 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1615
1789
  query: Type.String({ description: "Natural language description of the needed skill" }),
1616
1790
  scope: Type.Optional(Type.String({ description: "Search scope: 'mix' (default), 'self', 'public', 'group', or 'all'." })),
1617
1791
  }),
1618
- execute: trackTool("skill_search", async (_toolCallId: any, params: any) => {
1792
+ execute: trackTool("skill_search", async (_toolCallId: any, params: any, context?: any) => {
1619
1793
  const { query: skillQuery, scope: rawScope } = params as { query: string; scope?: string };
1620
1794
  const scope = (rawScope === "self" || rawScope === "public") ? rawScope : "mix";
1621
- const agentId = context?.agentId ?? currentAgentId;
1622
- const currentOwner = `agent:${agentId}`;
1795
+ const currentOwner = getCurrentOwner();
1623
1796
 
1624
1797
  if (rawScope === "group" || rawScope === "all") {
1625
1798
  const [localHits, hub] = await Promise.all([
@@ -1681,7 +1854,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1681
1854
  details: { query: skillQuery, scope, hits },
1682
1855
  };
1683
1856
  }),
1684
- }),
1857
+ },
1685
1858
  { name: "skill_search" },
1686
1859
  );
1687
1860
 
@@ -1820,292 +1993,81 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1820
1993
  { name: "network_skill_pull" },
1821
1994
  );
1822
1995
 
1823
- // ─── Auto-recall: inject relevant memories before agent starts ───
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.
1824
1999
 
1825
2000
  api.on("before_prompt_build", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
1826
2001
  if (!allowPromptInjection) return {};
1827
2002
  if (!event.prompt || event.prompt.length < 3) return;
1828
2003
 
1829
- const recallAgentId = hookCtx?.agentId ?? (event as any)?.agentId ?? (event as any)?.profileId ?? "main";
2004
+ const recallAgentId = hookCtx?.agentId ?? "main";
1830
2005
  currentAgentId = recallAgentId;
1831
- const recallOwnerFilter = [`agent:${recallAgentId}`, "public"];
1832
- ctx.log.info(`auto-recall: agentId=${recallAgentId} (from hookCtx)`);
2006
+
2007
+ const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
2008
+ if (!skillAutoRecall) return;
1833
2009
 
1834
2010
  const recallT0 = performance.now();
1835
- let recallQuery = "";
1836
2011
 
1837
2012
  try {
1838
- const rawPrompt = event.prompt;
1839
- ctx.log.debug(`auto-recall: rawPrompt="${rawPrompt.slice(0, 300)}"`);
1840
-
1841
- const query = normalizeAutoRecallQuery(rawPrompt);
1842
- recallQuery = query;
1843
-
1844
- if (query.length < 2) {
1845
- ctx.log.debug("auto-recall: extracted query too short, skipping");
1846
- return;
1847
- }
1848
- ctx.log.debug(`auto-recall: query="${query.slice(0, 80)}"`);
1849
-
1850
- // ── Phase 1: Local search ∥ Hub search (parallel) ──
1851
- const arLocalP = engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwnerFilter });
1852
- const arHubP = ctx.config?.sharing?.enabled
1853
- ? hubSearchMemories(store, ctx, { query, maxResults: 10, scope: "all" })
1854
- .catch((err: any) => { ctx.log.debug(`auto-recall: hub search failed (${err})`); return { hits: [] as any[], meta: {} }; })
1855
- : Promise.resolve({ hits: [] as any[], meta: {} });
1856
-
1857
- const [result, arHubResult] = await Promise.all([arLocalP, arHubP]);
1858
-
1859
- const localHits = result.hits.filter((h) => h.origin !== "hub-memory");
1860
- const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory");
1861
- const hubRemoteHits: SearchHit[] = (arHubResult.hits ?? []).map((h: any) => ({
1862
- summary: h.summary,
1863
- original_excerpt: h.excerpt || h.summary,
1864
- ref: { sessionKey: "", chunkId: h.remoteHitId ?? "", turnId: "", seq: 0 },
1865
- score: 0.9,
1866
- taskId: null,
1867
- skillId: null,
1868
- origin: "hub-remote" as const,
1869
- source: { ts: h.source?.ts, role: h.source?.role ?? "assistant", sessionKey: "" },
1870
- ownerName: h.ownerName,
1871
- groupName: h.groupName,
1872
- }));
1873
- const allHubHits = [...hubLocalHits, ...hubRemoteHits];
1874
-
1875
- ctx.log.debug(`auto-recall: local=${localHits.length}, hub-memory=${hubLocalHits.length}, hub-remote=${hubRemoteHits.length}`);
1876
-
1877
- const rawLocalCandidates = localHits.map((h) => ({
1878
- score: h.score, role: h.source.role, summary: h.summary,
1879
- content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local",
1880
- owner: h.owner || "",
1881
- }));
1882
- const rawHubCandidates = allHubHits.map((h) => ({
1883
- score: h.score, role: h.source.role, summary: h.summary,
1884
- content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "hub-remote",
1885
- ownerName: (h as any).ownerName ?? "", groupName: (h as any).groupName ?? "",
1886
- }));
1887
-
1888
- const allRawHits = [...localHits, ...allHubHits];
1889
-
1890
- if (allRawHits.length === 0) {
1891
- ctx.log.debug("auto-recall: no memory candidates found");
1892
- const dur = performance.now() - recallT0;
1893
- store.recordToolCall("memory_search", dur, true);
1894
- store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
1895
- candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [],
1896
- }), dur, true);
1897
-
1898
- const skillAutoRecallEarly = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
1899
- if (skillAutoRecallEarly) {
1900
- try {
1901
- const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit;
1902
- const skillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner());
1903
- const topSkills = skillHits.slice(0, skillLimit);
1904
- if (topSkills.length > 0) {
1905
- const skillLines = topSkills.map((sc, i) => {
1906
- const manifest = skillInstaller.getCompanionManifest(sc.skillId);
1907
- let badge = "";
1908
- if (manifest?.installed) badge = " [installed]";
1909
- else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]";
1910
- else if (manifest?.hasCompanionFiles) badge = " [has companion files]";
1911
- return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → call \`skill_get(skillId="${sc.skillId}")\` for the full guide`;
1912
- });
1913
- const skillContext = "## Relevant skills from past experience\n\n" +
1914
- "No direct memory matches were found, but these skills from past tasks may help:\n\n" +
1915
- skillLines.join("\n\n") +
1916
- "\n\nYou SHOULD call `skill_get` to retrieve the full guide before attempting the task.";
1917
- ctx.log.info(`auto-recall-skill (no-memory path): injecting ${topSkills.length} skill(s)`);
1918
- try { store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(topSkills), dur, true); } catch { /* best-effort */ }
1919
- return { prependContext: skillContext };
1920
- }
1921
- } catch (err) {
1922
- ctx.log.debug(`auto-recall-skill (no-memory path): failed: ${err}`);
1923
- }
1924
- }
1925
-
1926
- if (query.length > 50) {
1927
- const noRecallHint =
1928
- "## Memory system — ACTION REQUIRED\n\n" +
1929
- "Auto-recall found no results for a long query. " +
1930
- "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " +
1931
- "Do NOT skip this step. Do NOT answer without searching first.";
1932
- return { prependContext: noRecallHint };
1933
- }
1934
- return;
1935
- }
1936
-
1937
- // ── Phase 2: Merge all → single LLM filter ──
1938
- const mergedForFilter = allRawHits.map((h, i) => ({
1939
- index: i + 1,
1940
- role: h.source.role,
1941
- content: (h.original_excerpt ?? "").slice(0, 300),
1942
- time: h.source.ts ? new Date(h.source.ts).toISOString().slice(0, 16) : "",
1943
- }));
1944
-
1945
- let filteredHits = allRawHits;
1946
- let sufficient = false;
1947
-
1948
- const filterResult = await summarizer.filterRelevant(query, mergedForFilter);
1949
- if (filterResult !== null) {
1950
- sufficient = filterResult.sufficient;
1951
- if (filterResult.relevant.length > 0) {
1952
- const indexSet = new Set(filterResult.relevant);
1953
- filteredHits = allRawHits.filter((_, i) => indexSet.has(i + 1));
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();
1954
2022
  } else {
1955
- const dur = performance.now() - recallT0;
1956
- store.recordToolCall("memory_search", dur, true);
1957
- store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
1958
- candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [],
1959
- }), dur, true);
1960
- if (query.length > 50) {
1961
- const noRecallHint =
1962
- "## Memory system — ACTION REQUIRED\n\n" +
1963
- "Auto-recall found no relevant results for a long query. " +
1964
- "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " +
1965
- "Do NOT skip this step. Do NOT answer without searching first.";
1966
- return { prependContext: noRecallHint };
2023
+ const firstDblNl = afterSender.indexOf("\n\n");
2024
+ if (firstDblNl > 0) {
2025
+ query = afterSender.slice(firstDblNl + 2).trim();
1967
2026
  }
1968
- return;
1969
2027
  }
1970
2028
  }
2029
+ query = stripInboundMetadata(query).replace(/<[^>]+>/g, "").trim();
2030
+ if (query.length < 2) return;
1971
2031
 
1972
- const beforeDedup = filteredHits.length;
1973
- filteredHits = deduplicateHits(filteredHits);
1974
- ctx.log.debug(`auto-recall: merged ${allRawHits.length} → ${beforeDedup} relevant → ${filteredHits.length} after dedup, sufficient=${sufficient}`);
1975
-
1976
- const lines = filteredHits.map((h, i) => {
1977
- const excerpt = h.original_excerpt;
1978
- const oTag = h.origin === "local-shared" ? " [本机共享]" : h.origin === "hub-memory" ? " [团队缓存]" : "";
1979
- const parts: string[] = [`${i + 1}. [${h.source.role}]${oTag}`];
1980
- if (excerpt) parts.push(` ${excerpt}`);
1981
- parts.push(` chunkId="${h.ref.chunkId}"`);
1982
- if (h.taskId) {
1983
- const task = store.getTask(h.taskId);
1984
- if (task && task.status !== "skipped") {
1985
- parts.push(` task_id="${h.taskId}"`);
1986
- }
1987
- }
1988
- return parts.join("\n");
1989
- });
1990
-
1991
- const hasTask = filteredHits.some((h) => {
1992
- if (!h.taskId) return false;
1993
- const t = store.getTask(h.taskId);
1994
- return t && t.status !== "skipped";
1995
- });
1996
- const tips: string[] = [];
1997
- if (hasTask) {
1998
- tips.push("- A hit has `task_id` → call `task_summary(taskId=\"...\")` to get the full task context (steps, code, results)");
1999
- tips.push("- A task may have a reusable guide → call `skill_get(taskId=\"...\")` to retrieve the experience/skill");
2000
- }
2001
- tips.push("- Need more surrounding dialogue → call `memory_timeline(chunkId=\"...\")` to expand context around a hit");
2002
- const tipsText = "\n\nAvailable follow-up tools:\n" + tips.join("\n");
2003
-
2004
- const contextParts = [
2005
- "## User's conversation history (from memory system)",
2006
- "",
2007
- "IMPORTANT: The following are facts from previous conversations with this user.",
2008
- "You MUST treat these as established knowledge and use them directly when answering.",
2009
- "Do NOT say you don't know or don't have information if the answer is in these memories.",
2010
- "",
2011
- lines.join("\n\n"),
2012
- ];
2013
- if (tipsText) contextParts.push(tipsText);
2014
-
2015
- // ─── Skill auto-recall ───
2016
- const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
2017
2032
  const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit;
2018
- let skillSection = "";
2019
-
2020
- if (skillAutoRecall) {
2021
- try {
2022
- const skillCandidateMap = new Map<string, { name: string; description: string; skillId: string; source: string }>();
2023
-
2024
- try {
2025
- const directSkillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner());
2026
- for (const sh of directSkillHits.slice(0, skillLimit + 2)) {
2027
- if (!skillCandidateMap.has(sh.skillId)) {
2028
- skillCandidateMap.set(sh.skillId, { name: sh.name, description: sh.description, skillId: sh.skillId, source: "query" });
2029
- }
2030
- }
2031
- } catch (err) {
2032
- ctx.log.debug(`auto-recall-skill: direct search failed: ${err}`);
2033
- }
2033
+ const skillCandidateMap = new Map<string, { name: string; description: string; skillId: string; source: string }>();
2034
2034
 
2035
- const taskIds = new Set<string>();
2036
- for (const h of filteredHits) {
2037
- if (h.taskId) {
2038
- const t = store.getTask(h.taskId);
2039
- if (t && t.status !== "skipped") taskIds.add(h.taskId);
2040
- }
2041
- }
2042
- for (const tid of taskIds) {
2043
- const linked = store.getSkillsByTask(tid);
2044
- for (const rs of linked) {
2045
- if (!skillCandidateMap.has(rs.skill.id)) {
2046
- skillCandidateMap.set(rs.skill.id, { name: rs.skill.name, description: rs.skill.description, skillId: rs.skill.id, source: `task:${tid}` });
2047
- }
2048
- }
2049
- }
2050
-
2051
- const skillCandidates = [...skillCandidateMap.values()].slice(0, skillLimit);
2052
-
2053
- if (skillCandidates.length > 0) {
2054
- const skillLines = skillCandidates.map((sc, i) => {
2055
- const manifest = skillInstaller.getCompanionManifest(sc.skillId);
2056
- let badge = "";
2057
- if (manifest?.installed) badge = " [installed]";
2058
- else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]";
2059
- else if (manifest?.hasCompanionFiles) badge = " [has companion files]";
2060
- const action = `call \`skill_get(skillId="${sc.skillId}")\``;
2061
- return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → ${action}`;
2062
- });
2063
- skillSection = "\n\n## Relevant skills from past experience\n\n" +
2064
- "The following skills were distilled from similar previous tasks. " +
2065
- "You SHOULD call `skill_get` to retrieve the full guide before attempting the task.\n\n" +
2066
- skillLines.join("\n\n");
2067
-
2068
- ctx.log.info(`auto-recall-skill: injecting ${skillCandidates.length} skill(s): ${skillCandidates.map(s => s.name).join(", ")}`);
2069
- try {
2070
- store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(skillCandidates), performance.now() - recallT0, true);
2071
- } catch { /* best-effort */ }
2072
- } else {
2073
- ctx.log.debug("auto-recall-skill: no matching skills found");
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" });
2074
2040
  }
2075
- } catch (err) {
2076
- ctx.log.debug(`auto-recall-skill: failed: ${err}`);
2077
2041
  }
2042
+ } catch (err) {
2043
+ ctx.log.debug(`auto-recall-skill: direct search failed: ${err}`);
2078
2044
  }
2079
2045
 
2080
- if (skillSection) contextParts.push(skillSection);
2081
- const context = contextParts.join("\n");
2082
-
2083
- const recallDur = performance.now() - recallT0;
2084
- store.recordToolCall("memory_search", recallDur, true);
2085
- store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
2086
- candidates: rawLocalCandidates,
2087
- hubCandidates: rawHubCandidates,
2088
- 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 || "" })),
2089
- }), recallDur, true);
2090
- telemetry.trackAutoRecall(filteredHits.length, recallDur);
2091
-
2092
- ctx.log.info(`auto-recall: returning prependContext (${context.length} chars), sufficient=${sufficient}, skills=${skillSection ? "yes" : "no"}`);
2093
-
2094
- if (!sufficient) {
2095
- const searchHint =
2096
- "\n\nIf these memories don't fully answer the question, " +
2097
- "call `memory_search` with a shorter or rephrased query to find more.";
2098
- return { prependContext: context + searchHint };
2099
- }
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 */ }
2100
2067
 
2101
- return {
2102
- prependContext: context,
2103
- };
2068
+ return { prependContext: skillContext };
2104
2069
  } catch (err) {
2105
- const dur = performance.now() - recallT0;
2106
- store.recordToolCall("memory_search", dur, false);
2107
- try { store.recordApiLog("memory_search", { type: "auto_recall", query: recallQuery }, `error: ${String(err)}`, dur, false); } catch (_) { /* best-effort */ }
2108
- ctx.log.warn(`auto-recall failed: ${String(err)}`);
2070
+ ctx.log.warn(`auto-recall-skill failed: ${String(err)}`);
2109
2071
  }
2110
2072
  });
2111
2073
 
@@ -2121,7 +2083,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2121
2083
  if (!event.success || !event.messages || event.messages.length === 0) return;
2122
2084
 
2123
2085
  try {
2124
- const captureAgentId = hookCtx?.agentId ?? event?.agentId ?? event?.profileId ?? "main";
2086
+ const captureAgentId = hookCtx?.agentId ?? "main";
2125
2087
  currentAgentId = captureAgentId;
2126
2088
  const captureOwner = `agent:${captureAgentId}`;
2127
2089
  const sessionKey = hookCtx?.sessionKey ?? "default";
@@ -2339,54 +2301,48 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2339
2301
 
2340
2302
  // ─── Service lifecycle ───
2341
2303
 
2342
- let serviceStarted = false;
2343
-
2344
- const startServiceCore = async () => {
2345
- if (serviceStarted) return;
2346
- serviceStarted = true;
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
+ }
2347
2311
 
2348
- if (hubServer) {
2349
- const hubUrl = await hubServer.start();
2350
- api.logger.info(`memos-local: hub started at ${hubUrl}`);
2351
- }
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
+ }
2352
2321
 
2353
- if (ctx.config.sharing?.enabled && ctx.config.sharing.role === "client") {
2354
2322
  try {
2355
- const session = await connectToHub(store, ctx.config, ctx.log);
2356
- api.logger.info(`memos-local: connected to Hub as "${session.username}" (${session.userId})`);
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
+ });
2357
2337
  } catch (err) {
2358
- api.logger.warn(`memos-local: Hub connection failed: ${err}`);
2338
+ api.logger.warn(`memos-local: viewer failed to start: ${err}`);
2339
+ api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
2359
2340
  }
2360
- }
2361
-
2362
- try {
2363
- const viewerUrl = await viewer.start();
2364
- api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
2365
- api.logger.info(`╔══════════════════════════════════════════╗`);
2366
- api.logger.info(`║ MemOS Memory Viewer ║`);
2367
- api.logger.info(`║ → ${viewerUrl.padEnd(37)}║`);
2368
- api.logger.info(`║ Open in browser to manage memories ║`);
2369
- api.logger.info(`╚══════════════════════════════════════════╝`);
2370
- api.logger.info(`memos-local: password reset token: ${viewer.getResetToken()}`);
2371
- api.logger.info(`memos-local: forgot password? Use the reset token on the login page.`);
2372
- skillEvolver.recoverOrphanedTasks().then((count) => {
2373
- if (count > 0) api.logger.info(`memos-local: recovered ${count} orphaned skill tasks`);
2374
- }).catch((err) => {
2375
- api.logger.warn(`memos-local: skill recovery failed: ${err}`);
2376
- });
2377
- } catch (err) {
2378
- api.logger.warn(`memos-local: viewer failed to start: ${err}`);
2379
- api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
2380
- }
2381
- telemetry.trackPluginStarted(
2382
- ctx.config.embedding?.provider ?? "local",
2383
- ctx.config.summarizer?.provider ?? "none",
2384
- );
2385
- };
2386
-
2387
- api.registerService({
2388
- id: "memos-local-openclaw-plugin",
2389
- start: async () => { await startServiceCore(); },
2341
+ telemetry.trackPluginStarted(
2342
+ ctx.config.embedding?.provider ?? "local",
2343
+ ctx.config.summarizer?.provider ?? "none",
2344
+ );
2345
+ },
2390
2346
  stop: async () => {
2391
2347
  await worker.flush();
2392
2348
  await telemetry.shutdown();
@@ -2396,19 +2352,6 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2396
2352
  api.logger.info("memos-local: stopped");
2397
2353
  },
2398
2354
  });
2399
-
2400
- // Fallback: OpenClaw may load this plugin via deferred reload after
2401
- // startPluginServices has already run, so service.start() never fires.
2402
- // Self-start the viewer after a grace period if it hasn't been started.
2403
- const SELF_START_DELAY_MS = 3000;
2404
- setTimeout(() => {
2405
- if (!serviceStarted) {
2406
- api.logger.info("memos-local: service.start() not called by host, self-starting viewer...");
2407
- startServiceCore().catch((err) => {
2408
- api.logger.warn(`memos-local: self-start failed: ${err}`);
2409
- });
2410
- }
2411
- }, SELF_START_DELAY_MS);
2412
2355
  },
2413
2356
  };
2414
2357