@memtensor/memos-local-openclaw-plugin 1.0.7-beta.1 → 1.0.7-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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. */
@@ -319,6 +307,7 @@ const memosLocalPlugin = {
319
307
  // Current agent ID — updated by hooks, read by tools for owner isolation.
320
308
  // Falls back to "main" when no hook has fired yet (single-agent setups).
321
309
  let currentAgentId = "main";
310
+ const getCurrentOwner = () => `agent:${currentAgentId}`;
322
311
 
323
312
  // ─── Check allowPromptInjection policy ───
324
313
  // When allowPromptInjection=false, the prompt mutation fields (such as prependContext) in the hook return value
@@ -332,214 +321,6 @@ const memosLocalPlugin = {
332
321
  api.logger.info("memos-local: allowPromptInjection=true, auto-recall enabled");
333
322
  }
334
323
 
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
324
  const trackTool = (toolName: string, fn: (...args: any[]) => Promise<any>) =>
544
325
  async (...args: any[]) => {
545
326
  const t0 = performance.now();
@@ -574,7 +355,6 @@ const memosLocalPlugin = {
574
355
  }
575
356
  };
576
357
 
577
- const getCurrentOwner = () => `agent:${currentAgentId}`;
578
358
  const resolveMemorySearchScope = (scope?: string): "local" | "group" | "all" =>
579
359
  scope === "group" || scope === "all" ? scope : "local";
580
360
  const resolveMemoryShareTarget = (target?: string): "agents" | "hub" | "both" =>
@@ -665,7 +445,7 @@ const memosLocalPlugin = {
665
445
  // ─── Tool: memory_search ───
666
446
 
667
447
  api.registerTool(
668
- {
448
+ (context) => ({
669
449
  name: "memory_search",
670
450
  label: "Memory Search",
671
451
  description:
@@ -681,7 +461,7 @@ const memosLocalPlugin = {
681
461
  hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for group/all search." })),
682
462
  userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for group/all search." })),
683
463
  }),
684
- execute: trackTool("memory_search", async (_toolCallId: any, params: any, context?: any) => {
464
+ execute: trackTool("memory_search", async (_toolCallId: any, params: any) => {
685
465
  const {
686
466
  query,
687
467
  scope: rawScope,
@@ -702,9 +482,6 @@ const memosLocalPlugin = {
702
482
  const role = rawRole === "user" || rawRole === "assistant" || rawRole === "tool" || rawRole === "system" ? rawRole : undefined;
703
483
  const minScore = typeof rawMinScore === "number" ? Math.max(0.35, Math.min(1, rawMinScore)) : undefined;
704
484
  let searchScope = resolveMemorySearchScope(rawScope);
705
- if (searchScope === "local" && ctx.config?.sharing?.enabled) {
706
- searchScope = "all";
707
- }
708
485
  const searchLimit = typeof maxResults === "number" ? Math.max(1, Math.min(20, Math.round(maxResults))) : 10;
709
486
 
710
487
  const agentId = context?.agentId ?? currentAgentId;
@@ -724,7 +501,7 @@ const memosLocalPlugin = {
724
501
 
725
502
  // Split local results: pure-local vs hub-memory (Hub role's hub_memories mixed in by RecallEngine)
726
503
  const localHits = result.hits.filter((h) => h.origin !== "hub-memory");
727
- const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory");
504
+ const hubLocalHits = searchScope !== "local" ? result.hits.filter((h) => h.origin === "hub-memory") : [];
728
505
 
729
506
  const rawLocalCandidates = localHits.map((h) => ({
730
507
  chunkId: h.ref.chunkId,
@@ -889,14 +666,14 @@ const memosLocalPlugin = {
889
666
  },
890
667
  };
891
668
  }),
892
- },
669
+ }),
893
670
  { name: "memory_search" },
894
671
  );
895
672
 
896
673
  // ─── Tool: memory_timeline ───
897
674
 
898
675
  api.registerTool(
899
- {
676
+ (context) => ({
900
677
  name: "memory_timeline",
901
678
  label: "Memory Timeline",
902
679
  description:
@@ -906,7 +683,7 @@ const memosLocalPlugin = {
906
683
  chunkId: Type.String({ description: "The chunkId from a memory_search hit" }),
907
684
  window: Type.Optional(Type.Number({ description: "Context window ±N (default 2)" })),
908
685
  }),
909
- execute: trackTool("memory_timeline", async (_toolCallId: any, params: any, context?: any) => {
686
+ execute: trackTool("memory_timeline", async (_toolCallId: any, params: any) => {
910
687
  const agentId = context?.agentId ?? currentAgentId;
911
688
  ctx.log.debug(`memory_timeline called (agent=${agentId})`);
912
689
  const { chunkId, window: win } = params as {
@@ -950,14 +727,14 @@ const memosLocalPlugin = {
950
727
  details: { entries, anchorRef: { sessionKey: anchorChunk.sessionKey, chunkId, turnId: anchorChunk.turnId, seq: anchorChunk.seq } },
951
728
  };
952
729
  }),
953
- },
730
+ }),
954
731
  { name: "memory_timeline" },
955
732
  );
956
733
 
957
734
  // ─── Tool: memory_get ───
958
735
 
959
736
  api.registerTool(
960
- {
737
+ (context) => ({
961
738
  name: "memory_get",
962
739
  label: "Memory Get",
963
740
  description:
@@ -968,7 +745,7 @@ const memosLocalPlugin = {
968
745
  Type.Number({ description: `Max chars (default ${DEFAULTS.getMaxCharsDefault}, max ${DEFAULTS.getMaxCharsMax})` }),
969
746
  ),
970
747
  }),
971
- execute: trackTool("memory_get", async (_toolCallId: any, params: any, context?: any) => {
748
+ execute: trackTool("memory_get", async (_toolCallId: any, params: any) => {
972
749
  const { chunkId, maxChars } = params as { chunkId: string; maxChars?: number };
973
750
  const limit = Math.min(maxChars ?? DEFAULTS.getMaxCharsDefault, DEFAULTS.getMaxCharsMax);
974
751
 
@@ -994,7 +771,7 @@ const memosLocalPlugin = {
994
771
  },
995
772
  };
996
773
  }),
997
- },
774
+ }),
998
775
  { name: "memory_get" },
999
776
  );
1000
777
 
@@ -1516,7 +1293,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1516
1293
  const viewerPort = (pluginCfg as any).viewerPort ?? (gatewayPort + 10);
1517
1294
 
1518
1295
  api.registerTool(
1519
- {
1296
+ (context) => ({
1520
1297
  name: "memory_viewer",
1521
1298
  label: "Open Memory Viewer",
1522
1299
  description:
@@ -1524,10 +1301,11 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1524
1301
  "or access their stored memories, or asks where the memory dashboard is. " +
1525
1302
  "Returns the URL the user can open in their browser.",
1526
1303
  parameters: Type.Object({}),
1527
- execute: trackTool("memory_viewer", async () => {
1304
+ execute: trackTool("memory_viewer", async (_toolCallId: any, params: any) => {
1528
1305
  ctx.log.debug(`memory_viewer called`);
1529
1306
  telemetry.trackViewerOpened();
1530
- const url = `http://127.0.0.1:${viewerPort}`;
1307
+ const agentId = context?.agentId ?? context?.profileId ?? currentAgentId;
1308
+ const url = `http://127.0.0.1:${viewerPort}?agentId=${encodeURIComponent(agentId)}`;
1531
1309
  return {
1532
1310
  content: [
1533
1311
  {
@@ -1548,7 +1326,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1548
1326
  details: { viewerUrl: url },
1549
1327
  };
1550
1328
  }),
1551
- },
1329
+ }),
1552
1330
  { name: "memory_viewer" },
1553
1331
  );
1554
1332
 
@@ -1779,7 +1557,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1779
1557
  // ─── Tool: skill_search ───
1780
1558
 
1781
1559
  api.registerTool(
1782
- {
1560
+ (context) => ({
1783
1561
  name: "skill_search",
1784
1562
  label: "Skill Search",
1785
1563
  description:
@@ -1789,10 +1567,11 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1789
1567
  query: Type.String({ description: "Natural language description of the needed skill" }),
1790
1568
  scope: Type.Optional(Type.String({ description: "Search scope: 'mix' (default), 'self', 'public', 'group', or 'all'." })),
1791
1569
  }),
1792
- execute: trackTool("skill_search", async (_toolCallId: any, params: any, context?: any) => {
1570
+ execute: trackTool("skill_search", async (_toolCallId: any, params: any) => {
1793
1571
  const { query: skillQuery, scope: rawScope } = params as { query: string; scope?: string };
1794
1572
  const scope = (rawScope === "self" || rawScope === "public") ? rawScope : "mix";
1795
- const currentOwner = getCurrentOwner();
1573
+ const agentId = context?.agentId ?? currentAgentId;
1574
+ const currentOwner = `agent:${agentId}`;
1796
1575
 
1797
1576
  if (rawScope === "group" || rawScope === "all") {
1798
1577
  const [localHits, hub] = await Promise.all([
@@ -1854,7 +1633,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1854
1633
  details: { query: skillQuery, scope, hits },
1855
1634
  };
1856
1635
  }),
1857
- },
1636
+ }),
1858
1637
  { name: "skill_search" },
1859
1638
  );
1860
1639
 
@@ -1993,28 +1772,29 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1993
1772
  { name: "network_skill_pull" },
1994
1773
  );
1995
1774
 
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.
1775
+ // ─── Auto-recall: inject relevant memories before agent starts ───
1999
1776
 
2000
1777
  api.on("before_prompt_build", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
2001
1778
  if (!allowPromptInjection) return {};
2002
1779
  if (!event.prompt || event.prompt.length < 3) return;
2003
1780
 
2004
- const recallAgentId = hookCtx?.agentId ?? "main";
1781
+ const recallAgentId = hookCtx?.agentId ?? (event as any)?.agentId ?? (event as any)?.profileId ?? "main";
2005
1782
  currentAgentId = recallAgentId;
2006
-
2007
- const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
2008
- if (!skillAutoRecall) return;
1783
+ const recallOwnerFilter = [`agent:${recallAgentId}`, "public"];
1784
+ ctx.log.info(`auto-recall: agentId=${recallAgentId} (from hookCtx)`);
2009
1785
 
2010
1786
  const recallT0 = performance.now();
1787
+ let recallQuery = "";
2011
1788
 
2012
1789
  try {
2013
- let query = event.prompt;
1790
+ const rawPrompt = event.prompt;
1791
+ ctx.log.debug(`auto-recall: rawPrompt="${rawPrompt.slice(0, 300)}"`);
1792
+
1793
+ let query = rawPrompt;
2014
1794
  const senderTag = "Sender (untrusted metadata):";
2015
- const senderPos = query.indexOf(senderTag);
1795
+ const senderPos = rawPrompt.indexOf(senderTag);
2016
1796
  if (senderPos !== -1) {
2017
- const afterSender = query.slice(senderPos);
1797
+ const afterSender = rawPrompt.slice(senderPos);
2018
1798
  const fenceStart = afterSender.indexOf("```json");
2019
1799
  const fenceEnd = fenceStart >= 0 ? afterSender.indexOf("```\n", fenceStart + 7) : -1;
2020
1800
  if (fenceEnd > 0) {
@@ -2026,48 +1806,274 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2026
1806
  }
2027
1807
  }
2028
1808
  }
2029
- query = stripInboundMetadata(query).replace(/<[^>]+>/g, "").trim();
2030
- if (query.length < 2) return;
1809
+ query = stripInboundMetadata(query);
1810
+ query = query.replace(/<[^>]+>/g, "").trim();
1811
+ recallQuery = query;
2031
1812
 
2032
- const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit;
2033
- const skillCandidateMap = new Map<string, { name: string; description: string; skillId: string; source: string }>();
1813
+ if (query.length < 2) {
1814
+ ctx.log.debug("auto-recall: extracted query too short, skipping");
1815
+ return;
1816
+ }
1817
+ ctx.log.debug(`auto-recall: query="${query.slice(0, 80)}"`);
1818
+
1819
+ // ── Phase 1: Local search ∥ Hub search (parallel) ──
1820
+ const arLocalP = engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwnerFilter });
1821
+ const arHubP = ctx.config?.sharing?.enabled
1822
+ ? hubSearchMemories(store, ctx, { query, maxResults: 10, scope: "all" })
1823
+ .catch((err: any) => { ctx.log.debug(`auto-recall: hub search failed (${err})`); return { hits: [] as any[], meta: {} }; })
1824
+ : Promise.resolve({ hits: [] as any[], meta: {} });
1825
+
1826
+ const [result, arHubResult] = await Promise.all([arLocalP, arHubP]);
1827
+
1828
+ const localHits = result.hits.filter((h) => h.origin !== "hub-memory");
1829
+ const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory");
1830
+ const hubRemoteHits: SearchHit[] = (arHubResult.hits ?? []).map((h: any) => ({
1831
+ summary: h.summary,
1832
+ original_excerpt: h.excerpt || h.summary,
1833
+ ref: { sessionKey: "", chunkId: h.remoteHitId ?? "", turnId: "", seq: 0 },
1834
+ score: 0.9,
1835
+ taskId: null,
1836
+ skillId: null,
1837
+ origin: "hub-remote" as const,
1838
+ source: { ts: h.source?.ts, role: h.source?.role ?? "assistant", sessionKey: "" },
1839
+ ownerName: h.ownerName,
1840
+ groupName: h.groupName,
1841
+ }));
1842
+ const allHubHits = [...hubLocalHits, ...hubRemoteHits];
1843
+
1844
+ ctx.log.debug(`auto-recall: local=${localHits.length}, hub-memory=${hubLocalHits.length}, hub-remote=${hubRemoteHits.length}`);
1845
+
1846
+ const rawLocalCandidates = localHits.map((h) => ({
1847
+ score: h.score, role: h.source.role, summary: h.summary,
1848
+ content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local",
1849
+ }));
1850
+ const rawHubCandidates = allHubHits.map((h) => ({
1851
+ score: h.score, role: h.source.role, summary: h.summary,
1852
+ content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "hub-remote",
1853
+ ownerName: (h as any).ownerName ?? "", groupName: (h as any).groupName ?? "",
1854
+ }));
1855
+
1856
+ const allRawHits = [...localHits, ...allHubHits];
1857
+
1858
+ if (allRawHits.length === 0) {
1859
+ ctx.log.debug("auto-recall: no memory candidates found");
1860
+ const dur = performance.now() - recallT0;
1861
+ store.recordToolCall("memory_search", dur, true);
1862
+ store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
1863
+ candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [],
1864
+ }), dur, true);
1865
+
1866
+ const skillAutoRecallEarly = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
1867
+ if (skillAutoRecallEarly) {
1868
+ try {
1869
+ const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit;
1870
+ const skillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner());
1871
+ const topSkills = skillHits.slice(0, skillLimit);
1872
+ if (topSkills.length > 0) {
1873
+ const skillLines = topSkills.map((sc, i) => {
1874
+ const manifest = skillInstaller.getCompanionManifest(sc.skillId);
1875
+ let badge = "";
1876
+ if (manifest?.installed) badge = " [installed]";
1877
+ else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]";
1878
+ else if (manifest?.hasCompanionFiles) badge = " [has companion files]";
1879
+ return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → call \`skill_get(skillId="${sc.skillId}")\` for the full guide`;
1880
+ });
1881
+ const skillContext = "## Relevant skills from past experience\n\n" +
1882
+ "No direct memory matches were found, but these skills from past tasks may help:\n\n" +
1883
+ skillLines.join("\n\n") +
1884
+ "\n\nYou SHOULD call `skill_get` to retrieve the full guide before attempting the task.";
1885
+ ctx.log.info(`auto-recall-skill (no-memory path): injecting ${topSkills.length} skill(s)`);
1886
+ try { store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(topSkills), dur, true); } catch { /* best-effort */ }
1887
+ return { prependContext: skillContext };
1888
+ }
1889
+ } catch (err) {
1890
+ ctx.log.debug(`auto-recall-skill (no-memory path): failed: ${err}`);
1891
+ }
1892
+ }
2034
1893
 
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" });
1894
+ if (query.length > 50) {
1895
+ const noRecallHint =
1896
+ "## Memory system ACTION REQUIRED\n\n" +
1897
+ "Auto-recall found no results for a long query. " +
1898
+ "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " +
1899
+ "Do NOT skip this step. Do NOT answer without searching first.";
1900
+ return { prependContext: noRecallHint };
1901
+ }
1902
+ return;
1903
+ }
1904
+
1905
+ // ── Phase 2: Merge all → single LLM filter ──
1906
+ const mergedForFilter = allRawHits.map((h, i) => ({
1907
+ index: i + 1,
1908
+ role: h.source.role,
1909
+ content: (h.original_excerpt ?? "").slice(0, 300),
1910
+ time: h.source.ts ? new Date(h.source.ts).toISOString().slice(0, 16) : "",
1911
+ }));
1912
+
1913
+ let filteredHits = allRawHits;
1914
+ let sufficient = false;
1915
+
1916
+ const filterResult = await summarizer.filterRelevant(query, mergedForFilter);
1917
+ if (filterResult !== null) {
1918
+ sufficient = filterResult.sufficient;
1919
+ if (filterResult.relevant.length > 0) {
1920
+ const indexSet = new Set(filterResult.relevant);
1921
+ filteredHits = allRawHits.filter((_, i) => indexSet.has(i + 1));
1922
+ } else {
1923
+ const dur = performance.now() - recallT0;
1924
+ store.recordToolCall("memory_search", dur, true);
1925
+ store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
1926
+ candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [],
1927
+ }), dur, true);
1928
+ if (query.length > 50) {
1929
+ const noRecallHint =
1930
+ "## Memory system — ACTION REQUIRED\n\n" +
1931
+ "Auto-recall found no relevant results for a long query. " +
1932
+ "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " +
1933
+ "Do NOT skip this step. Do NOT answer without searching first.";
1934
+ return { prependContext: noRecallHint };
2040
1935
  }
1936
+ return;
2041
1937
  }
2042
- } catch (err) {
2043
- ctx.log.debug(`auto-recall-skill: direct search failed: ${err}`);
2044
1938
  }
2045
1939
 
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}`;
1940
+ const beforeDedup = filteredHits.length;
1941
+ filteredHits = deduplicateHits(filteredHits);
1942
+ ctx.log.debug(`auto-recall: merged ${allRawHits.length} → ${beforeDedup} relevant → ${filteredHits.length} after dedup, sufficient=${sufficient}`);
1943
+
1944
+ const lines = filteredHits.map((h, i) => {
1945
+ const excerpt = h.original_excerpt;
1946
+ const oTag = h.origin === "local-shared" ? " [本机共享]" : h.origin === "hub-memory" ? " [团队缓存]" : "";
1947
+ const parts: string[] = [`${i + 1}. [${h.source.role}]${oTag}`];
1948
+ if (excerpt) parts.push(` ${excerpt}`);
1949
+ parts.push(` chunkId="${h.ref.chunkId}"`);
1950
+ if (h.taskId) {
1951
+ const task = store.getTask(h.taskId);
1952
+ if (task && task.status !== "skipped") {
1953
+ parts.push(` task_id="${h.taskId}"`);
1954
+ }
1955
+ }
1956
+ return parts.join("\n");
1957
+ });
1958
+
1959
+ const hasTask = filteredHits.some((h) => {
1960
+ if (!h.taskId) return false;
1961
+ const t = store.getTask(h.taskId);
1962
+ return t && t.status !== "skipped";
2057
1963
  });
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");
1964
+ const tips: string[] = [];
1965
+ if (hasTask) {
1966
+ tips.push("- A hit has `task_id` → call `task_summary(taskId=\"...\")` to get the full task context (steps, code, results)");
1967
+ tips.push("- A task may have a reusable guide → call `skill_get(taskId=\"...\")` to retrieve the experience/skill");
1968
+ }
1969
+ tips.push("- Need more surrounding dialogue → call `memory_timeline(chunkId=\"...\")` to expand context around a hit");
1970
+ const tipsText = "\n\nAvailable follow-up tools:\n" + tips.join("\n");
2062
1971
 
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 */ }
1972
+ const contextParts = [
1973
+ "## User's conversation history (from memory system)",
1974
+ "",
1975
+ "IMPORTANT: The following are facts from previous conversations with this user.",
1976
+ "You MUST treat these as established knowledge and use them directly when answering.",
1977
+ "Do NOT say you don't know or don't have information if the answer is in these memories.",
1978
+ "",
1979
+ lines.join("\n\n"),
1980
+ ];
1981
+ if (tipsText) contextParts.push(tipsText);
1982
+
1983
+ // ─── Skill auto-recall ───
1984
+ const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
1985
+ const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit;
1986
+ let skillSection = "";
1987
+
1988
+ if (skillAutoRecall) {
1989
+ try {
1990
+ const skillCandidateMap = new Map<string, { name: string; description: string; skillId: string; source: string }>();
1991
+
1992
+ try {
1993
+ const directSkillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner());
1994
+ for (const sh of directSkillHits.slice(0, skillLimit + 2)) {
1995
+ if (!skillCandidateMap.has(sh.skillId)) {
1996
+ skillCandidateMap.set(sh.skillId, { name: sh.name, description: sh.description, skillId: sh.skillId, source: "query" });
1997
+ }
1998
+ }
1999
+ } catch (err) {
2000
+ ctx.log.debug(`auto-recall-skill: direct search failed: ${err}`);
2001
+ }
2002
+
2003
+ const taskIds = new Set<string>();
2004
+ for (const h of filteredHits) {
2005
+ if (h.taskId) {
2006
+ const t = store.getTask(h.taskId);
2007
+ if (t && t.status !== "skipped") taskIds.add(h.taskId);
2008
+ }
2009
+ }
2010
+ for (const tid of taskIds) {
2011
+ const linked = store.getSkillsByTask(tid);
2012
+ for (const rs of linked) {
2013
+ if (!skillCandidateMap.has(rs.skill.id)) {
2014
+ skillCandidateMap.set(rs.skill.id, { name: rs.skill.name, description: rs.skill.description, skillId: rs.skill.id, source: `task:${tid}` });
2015
+ }
2016
+ }
2017
+ }
2018
+
2019
+ const skillCandidates = [...skillCandidateMap.values()].slice(0, skillLimit);
2020
+
2021
+ if (skillCandidates.length > 0) {
2022
+ const skillLines = skillCandidates.map((sc, i) => {
2023
+ const manifest = skillInstaller.getCompanionManifest(sc.skillId);
2024
+ let badge = "";
2025
+ if (manifest?.installed) badge = " [installed]";
2026
+ else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]";
2027
+ else if (manifest?.hasCompanionFiles) badge = " [has companion files]";
2028
+ const action = `call \`skill_get(skillId="${sc.skillId}")\``;
2029
+ return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → ${action}`;
2030
+ });
2031
+ skillSection = "\n\n## Relevant skills from past experience\n\n" +
2032
+ "The following skills were distilled from similar previous tasks. " +
2033
+ "You SHOULD call `skill_get` to retrieve the full guide before attempting the task.\n\n" +
2034
+ skillLines.join("\n\n");
2035
+
2036
+ ctx.log.info(`auto-recall-skill: injecting ${skillCandidates.length} skill(s): ${skillCandidates.map(s => s.name).join(", ")}`);
2037
+ try {
2038
+ store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(skillCandidates), performance.now() - recallT0, true);
2039
+ } catch { /* best-effort */ }
2040
+ } else {
2041
+ ctx.log.debug("auto-recall-skill: no matching skills found");
2042
+ }
2043
+ } catch (err) {
2044
+ ctx.log.debug(`auto-recall-skill: failed: ${err}`);
2045
+ }
2046
+ }
2047
+
2048
+ if (skillSection) contextParts.push(skillSection);
2049
+ const context = contextParts.join("\n");
2050
+
2051
+ const recallDur = performance.now() - recallT0;
2052
+ store.recordToolCall("memory_search", recallDur, true);
2053
+ store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
2054
+ candidates: rawLocalCandidates,
2055
+ hubCandidates: rawHubCandidates,
2056
+ filtered: filteredHits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt, origin: h.origin || "local" })),
2057
+ }), recallDur, true);
2058
+ telemetry.trackAutoRecall(filteredHits.length, recallDur);
2059
+
2060
+ ctx.log.info(`auto-recall: returning prependContext (${context.length} chars), sufficient=${sufficient}, skills=${skillSection ? "yes" : "no"}`);
2061
+
2062
+ if (!sufficient) {
2063
+ const searchHint =
2064
+ "\n\nIf these memories don't fully answer the question, " +
2065
+ "call `memory_search` with a shorter or rephrased query to find more.";
2066
+ return { prependContext: context + searchHint };
2067
+ }
2067
2068
 
2068
- return { prependContext: skillContext };
2069
+ return {
2070
+ prependContext: context,
2071
+ };
2069
2072
  } catch (err) {
2070
- ctx.log.warn(`auto-recall-skill failed: ${String(err)}`);
2073
+ const dur = performance.now() - recallT0;
2074
+ store.recordToolCall("memory_search", dur, false);
2075
+ try { store.recordApiLog("memory_search", { type: "auto_recall", query: recallQuery }, `error: ${String(err)}`, dur, false); } catch (_) { /* best-effort */ }
2076
+ ctx.log.warn(`auto-recall failed: ${String(err)}`);
2071
2077
  }
2072
2078
  });
2073
2079
 
@@ -2083,7 +2089,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2083
2089
  if (!event.success || !event.messages || event.messages.length === 0) return;
2084
2090
 
2085
2091
  try {
2086
- const captureAgentId = hookCtx?.agentId ?? "main";
2092
+ const captureAgentId = hookCtx?.agentId ?? event?.agentId ?? event?.profileId ?? "main";
2087
2093
  currentAgentId = captureAgentId;
2088
2094
  const captureOwner = `agent:${captureAgentId}`;
2089
2095
  const sessionKey = hookCtx?.sessionKey ?? "default";