@memorycrystal/crystal-memory 0.7.5 → 0.7.6

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.js CHANGED
@@ -155,6 +155,7 @@ let localStore = null;
155
155
  let compactionEngine = null;
156
156
  let storeInitPromise = null;
157
157
  let localToolsRegistered = false;
158
+ const SHARED_AGENT_SESSION_RE = /^agent:[^:]+:main$/;
158
159
  function sanitizeForInjection(text) {
159
160
  let value = String(text || "");
160
161
  for (const pattern of INJECTION_TAG_PATTERNS) value = value.replace(pattern, "");
@@ -423,6 +424,36 @@ function formatMessageMatch(m) {
423
424
  function getSessionKey(ctx, event) {
424
425
  return ctx?.sessionKey || ctx?.sessionId || ctx?.conversationId || event?.sessionKey || event?.conversationId || event?.sessionId || "";
425
426
  }
427
+ function isSharedAgentSession(sessionKey) {
428
+ return typeof sessionKey === "string" && SHARED_AGENT_SESSION_RE.test(sessionKey);
429
+ }
430
+ function normalizeScopedChannelKey(channelKey, channelScope) {
431
+ const scope = typeof channelScope === "string" ? channelScope.trim() : "";
432
+ const value = typeof channelKey === "string" ? channelKey.trim() : "";
433
+ if (!scope || !value.startsWith(`${scope}:`)) return "";
434
+ const peerId = value.slice(scope.length + 1).trim();
435
+ if (!peerId || peerId === "main" || peerId === "default" || peerId === "unknown") return "";
436
+ return value;
437
+ }
438
+ function resolveEffectiveChannel(ctx, event, fallbackScope) {
439
+ const channelScope = getScopedChannelScope(ctx, event, fallbackScope);
440
+ const explicitChannel = firstString(
441
+ event?.channel,
442
+ event?.channelKey,
443
+ event?.context?.channel,
444
+ ctx?.channel,
445
+ ctx?.channelKey
446
+ );
447
+ const scopedExplicit = normalizeScopedChannelKey(explicitChannel, channelScope);
448
+ if (scopedExplicit) return scopedExplicit;
449
+ return resolveChannelKey(ctx, event, channelScope);
450
+ }
451
+ function resolveLocalContextKey(sessionKey, channelKey, channelScope) {
452
+ const scopedChannel = normalizeScopedChannelKey(channelKey, channelScope);
453
+ if (scopedChannel) return scopedChannel;
454
+ if (channelScope && isSharedAgentSession(sessionKey)) return "";
455
+ return sessionKey || "";
456
+ }
426
457
  function getScopedChannelScope(ctx, event, fallbackScope) {
427
458
  const sessionKey = getSessionKey(ctx, event);
428
459
  if (sessionKey && sessionChannelScopes.has(sessionKey)) return sessionChannelScopes.get(sessionKey);
@@ -446,11 +477,12 @@ async function logMessage(api, ctx, payload) {
446
477
  }
447
478
  async function captureTurn(api, event, ctx, userMessage, assistantText) {
448
479
  if (!shouldCapture(userMessage, assistantText)) return;
449
- await request(getPluginConfig(api, ctx), "POST", "/api/mcp/capture", {
480
+ const config = getPluginConfig(api, ctx);
481
+ await request(config, "POST", "/api/mcp/capture", {
450
482
  title: `OpenClaw — ${new Date().toISOString().slice(0, 16).replace("T", " ")}`,
451
483
  content: [userMessage ? `User: ${userMessage}` : null, `Assistant: ${assistantText}`].filter(Boolean).join("\n\n"),
452
484
  store: "sensory", category: "conversation", tags: ["openclaw", "auto-capture"],
453
- channel: resolveChannelKey(ctx, event, getPluginConfig(api, ctx)?.channelScope),
485
+ channel: resolveEffectiveChannel(ctx, event, config?.channelScope),
454
486
  }, api.logger);
455
487
  }
456
488
  async function flushContextEngineMessages(api, ctx, sessionKey, eventLike) {
@@ -459,7 +491,8 @@ async function flushContextEngineMessages(api, ctx, sessionKey, eventLike) {
459
491
  // Atomic swap-and-clear: grab the buffer and delete immediately before async I/O
460
492
  // to prevent concurrent calls from double-processing the same messages.
461
493
  pendingContextEngineMessages.delete(sessionKey);
462
- const channelKey = resolveChannelKey(ctx, eventLike || { sessionKey }, getPluginConfig(api, ctx)?.channelScope);
494
+ const config = getPluginConfig(api, ctx);
495
+ const channelKey = resolveEffectiveChannel(ctx, eventLike || { sessionKey }, config?.channelScope);
463
496
  for (const msg of buffered) {
464
497
  await logMessage(api, ctx, { role: msg.role, content: msg.content, channel: channelKey, sessionKey });
465
498
  }
@@ -471,7 +504,7 @@ async function flushContextEngineMessages(api, ctx, sessionKey, eventLike) {
471
504
  async function buildBeforeAgentContext(api, event, ctx) {
472
505
  const config = getPluginConfig(api, ctx);
473
506
  if (!config?.apiKey || config.apiKey === "local") return "";
474
- const channel = resolveChannelKey(ctx, event, getPluginConfig(api, ctx)?.channelScope);
507
+ const channel = resolveEffectiveChannel(ctx, event, config?.channelScope);
475
508
  const sessionKey = getSessionKey(ctx, event);
476
509
  const cronMode = isCronOrIsolated(ctx, event);
477
510
  const sections = cronMode ? [] : [PREAMBLE_BACKEND, PREAMBLE_TOOLS];
@@ -514,8 +547,20 @@ async function buildBeforeAgentContext(api, event, ctx) {
514
547
  const prompt = String(event?.prompt || "").trim();
515
548
  if (prompt.length >= 5) {
516
549
  const limit = Math.max(1, Math.min(Number.isFinite(Number(config?.defaultRecallLimit)) ? Number(config.defaultRecallLimit) : 5, 8));
517
- const recall = await request(config, "POST", "/api/mcp/recall", { query: prompt, limit, channel, mode: config?.defaultRecallMode || "general" }, api.logger);
518
- const mems = Array.isArray(recall?.memories) ? recall.memories.slice(0, 5) : [];
550
+ const recall = await request(config, "POST", "/api/mcp/recall", { query: prompt, limit: limit + 5, channel, mode: config?.defaultRecallMode || "general" }, api.logger);
551
+ let mems = Array.isArray(recall?.memories) ? recall.memories : [];
552
+ // --- Channel isolation: drop cross-client memories in peer-scoped sessions ---
553
+ // When channel is peer-specific (e.g. "morrow-coach:12345"), exclude non-KB
554
+ // memories with continuityScore===0 — they belong to other clients/sessions.
555
+ // KB memories (knowledgeBaseId set) are allowed through since they're curated content.
556
+ if (channel && channel.includes(":")) {
557
+ mems = mems.filter(m => {
558
+ if (m.knowledgeBaseId) return true; // KB content is always allowed
559
+ const cont = m.rankingSignals?.continuityScore ?? m.continuityScore;
560
+ return cont !== 0;
561
+ });
562
+ }
563
+ mems = mems.slice(0, 5);
519
564
  // --- Organic: log recall query (fire-and-forget) ---
520
565
  request(config, "POST", "/api/organic/recallLog", { query: prompt, resultCount: mems.length, source: "plugin" }, api.logger).catch(() => {});
521
566
  // Cache top recall results for reinforcement injection later in the conversation
@@ -531,34 +576,37 @@ async function buildBeforeAgentContext(api, event, ctx) {
531
576
  sections.push(["## Memory Crystal Relevant Recall", `Prompt: ${trimSnippet(prompt, 180)}`, ...mems.map(formatRecallMemory), "", "Use memory_search for broader lookup and memory_get on the returned crystal/<id>.md path for full detail.", recallDirective].join("\n"));
532
577
  }
533
578
  if (!cronMode) {
534
- const msgS = await request(config, "POST", "/api/mcp/search-messages", { query: prompt, limit: 5, channel }, api.logger);
535
- const msgs = Array.isArray(msgS?.messages) ? msgS.messages.slice(0, 5) : [];
536
- if (msgs.length) sections.push(["## Memory Crystal Recent Message Matches", `Prompt: ${trimSnippet(prompt, 180)}`, ...msgs.map(formatMessageMatch)].join("\n"));
579
+ const isPeerScopedChannel = channel && channel.includes(":");
580
+ if (!isPeerScopedChannel) {
581
+ const msgS = await request(config, "POST", "/api/mcp/search-messages", { query: prompt, limit: 5, channel }, api.logger);
582
+ const msgs = Array.isArray(msgS?.messages) ? msgS.messages.slice(0, 5) : [];
583
+ if (msgs.length) sections.push(["## Memory Crystal Recent Message Matches", `Prompt: ${trimSnippet(prompt, 180)}`, ...msgs.map(formatMessageMatch)].join("\n"));
537
584
 
538
- // Token-budgeted recent message window (~7k chars): fetch up to 30 recent
539
- // messages ordered by time, trim from oldest until we fit the budget.
540
- const RECENT_CHAR_BUDGET = 7000;
541
- const recentR = await request(config, "POST", "/api/mcp/recent-messages", { limit: 30, channel }, api.logger);
542
- const recentRaw = Array.isArray(recentR?.messages) ? recentR.messages : [];
543
- if (recentRaw.length) {
544
- // Build lines from the ascending list (oldest-first from backend)
545
- const lines = recentRaw.map(m => {
546
- const role = m.role === "assistant" ? "assistant" : "user";
547
- const ts = m.createdAt ? new Date(m.createdAt).toLocaleTimeString("en-CA", { hour: "2-digit", minute: "2-digit" }) : "";
548
- const snippet = sanitizeForInjection(String(m.content || m.text || "")).replace(/\n+/g, " ").trim().slice(0, 400);
549
- return `[${ts}] ${role}: ${snippet}`;
550
- });
551
- // Iterate from end (newest) to keep most recent messages on budget overflow
552
- const kept = [];
553
- let chars = 0;
554
- for (let i = lines.length - 1; i >= 0; i--) {
555
- if (chars + lines[i].length + 1 > RECENT_CHAR_BUDGET) break;
556
- kept.push(lines[i]);
557
- chars += lines[i].length + 1;
558
- }
559
- if (kept.length) {
560
- kept.reverse(); // restore chronological order for injection
561
- sections.push(["## Memory Crystal Recent Context (last messages)", ...kept].join("\n"));
585
+ // Token-budgeted recent message window (~7k chars): fetch up to 30 recent
586
+ // messages ordered by time, trim from oldest until we fit the budget.
587
+ const RECENT_CHAR_BUDGET = 7000;
588
+ const recentR = await request(config, "POST", "/api/mcp/recent-messages", { limit: 30, channel }, api.logger);
589
+ const recentRaw = Array.isArray(recentR?.messages) ? recentR.messages : [];
590
+ if (recentRaw.length) {
591
+ // Build lines from the ascending list (oldest-first from backend)
592
+ const lines = recentRaw.map(m => {
593
+ const role = m.role === "assistant" ? "assistant" : "user";
594
+ const ts = m.createdAt ? new Date(m.createdAt).toLocaleTimeString("en-CA", { hour: "2-digit", minute: "2-digit" }) : "";
595
+ const snippet = sanitizeForInjection(String(m.content || m.text || "")).replace(/\n+/g, " ").trim().slice(0, 400);
596
+ return `[${ts}] ${role}: ${snippet}`;
597
+ });
598
+ // Iterate from end (newest) to keep most recent messages on budget overflow
599
+ const kept = [];
600
+ let chars = 0;
601
+ for (let i = lines.length - 1; i >= 0; i--) {
602
+ if (chars + lines[i].length + 1 > RECENT_CHAR_BUDGET) break;
603
+ kept.push(lines[i]);
604
+ chars += lines[i].length + 1;
605
+ }
606
+ if (kept.length) {
607
+ kept.reverse(); // restore chronological order for injection
608
+ sections.push(["## Memory Crystal Recent Context (last messages)", ...kept].join("\n"));
609
+ }
562
610
  }
563
611
  }
564
612
  }
@@ -747,7 +795,10 @@ module.exports = (api) => {
747
795
  try {
748
796
  const text = extractUserText(event);
749
797
  const sessionKey = getSessionKey(ctx, event);
750
- const channelKey = resolveChannelKey(ctx, event, getPluginConfig(api, ctx)?.channelScope);
798
+ const config = getPluginConfig(api, ctx);
799
+ const channelScope = getScopedChannelScope(ctx, event, config?.channelScope);
800
+ const channelKey = resolveEffectiveChannel(ctx, event, config?.channelScope);
801
+ const localContextKey = resolveLocalContextKey(sessionKey, channelKey, channelScope);
751
802
  api.logger?.warn?.(`[crystal] message_received session=${sessionKey || "?"} channel=${channelKey || "?"} textLen=${String(text || "").length}`);
752
803
  if (!seenCaptureSessions.has(`msg:${sessionKey}`)) seenCaptureSessions.add(`msg:${sessionKey}`);
753
804
  if (text && sessionKey) pendingUserMessages.set(sessionKey, String(text));
@@ -757,10 +808,10 @@ module.exports = (api) => {
757
808
  intentCache.set(sessionKey, { intent, detectedAt: Date.now() });
758
809
  }
759
810
  if (text) await logMessage(api, ctx, { role: "user", content: String(text), channel: channelKey, sessionKey: sessionKey || undefined });
760
- const store = await getLocalStore(getPluginConfig(api, ctx), api.logger);
761
- if (store && text && sessionKey) { store.addMessage(sessionKey, "user", String(text)); _registerLocalTools(api); }
762
- if (sessionKey) sessionConfigs.set(sessionKey, { mode: getPluginConfig(api, ctx)?.defaultRecallMode || "general", limit: getPluginConfig(api, ctx)?.defaultRecallLimit || 8 });
763
- fireMediaCapture(event, getPluginConfig(api, ctx), channelKey, sessionKey);
811
+ const store = await getLocalStore(config, api.logger);
812
+ if (store && text && localContextKey) { store.addMessage(localContextKey, "user", String(text)); _registerLocalTools(api); }
813
+ if (sessionKey) sessionConfigs.set(sessionKey, { mode: config?.defaultRecallMode || "general", limit: config?.defaultRecallLimit || 8 });
814
+ fireMediaCapture(event, config, channelKey, sessionKey);
764
815
  if (text && sessionKey) triggerConversationPulse(api, ctx, sessionKey, String(text));
765
816
  } catch (err) { api.logger?.warn?.(`[crystal] message_received: ${getErrorMessage(err)}`); }
766
817
  }, { name: "crystal-memory.message-received", description: "Buffer + persist user turn" });
@@ -768,17 +819,20 @@ module.exports = (api) => {
768
819
  try {
769
820
  const assistantText = extractAssistantText(event);
770
821
  const sessionKey = getSessionKey(ctx, event);
771
- const channelKey = resolveChannelKey(ctx, event, getPluginConfig(api, ctx)?.channelScope);
822
+ const config = getPluginConfig(api, ctx);
823
+ const channelScope = getScopedChannelScope(ctx, event, config?.channelScope);
824
+ const channelKey = resolveEffectiveChannel(ctx, event, config?.channelScope);
825
+ const localContextKey = resolveLocalContextKey(sessionKey, channelKey, channelScope);
772
826
  if (!seenCaptureSessions.has(`out:${sessionKey}`)) { seenCaptureSessions.add(`out:${sessionKey}`); api.logger?.info?.(`[crystal] llm_output session=${sessionKey}`); }
773
827
  if (!assistantText) { api.logger?.warn?.("[crystal] llm_output missing assistant text"); return; }
774
828
  const userMessage = sessionKey ? pendingUserMessages.get(sessionKey) || "" : "";
775
829
  await logMessage(api, ctx, { role: "assistant", content: assistantText, channel: channelKey, sessionKey: sessionKey || undefined });
776
- const store = await getLocalStore(getPluginConfig(api, ctx), api.logger);
777
- if (store && sessionKey) { store.addMessage(sessionKey, "assistant", assistantText); _registerLocalTools(api); }
830
+ const store = await getLocalStore(config, api.logger);
831
+ if (store && localContextKey) { store.addMessage(localContextKey, "assistant", assistantText); _registerLocalTools(api); }
778
832
  if (sessionKey) appendConversationPulseMessage(sessionKey, "assistant", assistantText);
779
833
  if (sessionKey) pendingUserMessages.delete(sessionKey);
780
834
  await captureTurn(api, event, ctx, userMessage, assistantText);
781
- fireMediaCapture(event, getPluginConfig(api, ctx), channelKey, sessionKey);
835
+ fireMediaCapture(event, config, channelKey, sessionKey);
782
836
  } catch (err) { api.logger?.warn?.(`[crystal] llm_output: ${getErrorMessage(err)}`); }
783
837
  }, { name: "crystal-memory.llm-output", description: "Capture AI response" });
784
838
  hook("message_sent", async (event, ctx) => {
@@ -787,10 +841,14 @@ module.exports = (api) => {
787
841
  if (!sessionKey || !pendingUserMessages.has(sessionKey)) return;
788
842
  const assistantText = extractAssistantText(event);
789
843
  if (!assistantText) return;
844
+ const config = getPluginConfig(api, ctx);
845
+ const channelScope = getScopedChannelScope(ctx, event, config?.channelScope);
846
+ const channelKey = resolveEffectiveChannel(ctx, event, config?.channelScope);
847
+ const localContextKey = resolveLocalContextKey(sessionKey, channelKey, channelScope);
790
848
  const userMessage = pendingUserMessages.get(sessionKey) || "";
791
- await logMessage(api, ctx, { role: "assistant", content: assistantText, channel: resolveChannelKey(ctx, event, getPluginConfig(api, ctx)?.channelScope), sessionKey });
792
- const store = await getLocalStore(getPluginConfig(api, ctx), api.logger);
793
- if (store) { store.addMessage(sessionKey, "assistant", assistantText); _registerLocalTools(api); }
849
+ await logMessage(api, ctx, { role: "assistant", content: assistantText, channel: channelKey, sessionKey });
850
+ const store = await getLocalStore(config, api.logger);
851
+ if (store && localContextKey) { store.addMessage(localContextKey, "assistant", assistantText); _registerLocalTools(api); }
794
852
  appendConversationPulseMessage(sessionKey, "assistant", assistantText);
795
853
  pendingUserMessages.delete(sessionKey);
796
854
  await captureTurn(api, event, ctx, userMessage, assistantText);
@@ -818,12 +876,16 @@ module.exports = (api) => {
818
876
  const sessionKey = firstString(payload?.sessionKey, ctx?.sessionKey, ctx?.sessionId);
819
877
  const messages = Array.isArray(payload?.messages) ? payload.messages : [];
820
878
  queueContextEngineMessages(sessionKey, messages);
821
- const flushed = await flushContextEngineMessages(api, ctx, sessionKey, { sessionKey });
822
- const store = await getLocalStore(getPluginConfig(api, ctx), api.logger);
879
+ const pluginCfg = getPluginConfig(api, ctx);
880
+ const channelScope = getScopedChannelScope(ctx, { sessionKey }, pluginCfg?.channelScope);
881
+ const channelKey = firstString(payload?.channel, resolveEffectiveChannel(ctx, { sessionKey, channel: payload?.channel }, pluginCfg?.channelScope));
882
+ const flushed = await flushContextEngineMessages(api, ctx, sessionKey, { sessionKey, channel: channelKey });
883
+ const store = await getLocalStore(pluginCfg, api.logger);
823
884
  if (store && sessionKey) {
885
+ const localContextKey = resolveLocalContextKey(sessionKey, channelKey, channelScope);
824
886
  for (const msg of messages) {
825
887
  const nm = normalizeContextEngineMessage(msg);
826
- if (nm && (nm.role === "user" || nm.role === "assistant")) store.addMessage(sessionKey, nm.role, nm.content);
888
+ if (nm && localContextKey && (nm.role === "user" || nm.role === "assistant")) store.addMessage(localContextKey, nm.role, nm.content);
827
889
  }
828
890
  _registerLocalTools(api);
829
891
  }
@@ -836,17 +898,21 @@ module.exports = (api) => {
836
898
  const budget = Number.isFinite(Number(payload?.tokenBudget)) ? Number(payload.tokenBudget) : Infinity;
837
899
  const sessionKey = firstString(payload?.sessionKey, payload?.sessionId, ctx?.sessionKey, ctx?.sessionId) || "default";
838
900
  const pluginCfg = getPluginConfig(api, ctx);
901
+ const channelScope = getScopedChannelScope(ctx, { sessionKey }, pluginCfg?.channelScope);
902
+ const channelKey = resolveEffectiveChannel(ctx, { sessionKey, channel: payload?.channel }, pluginCfg?.channelScope);
903
+ const localContextKey = resolveLocalContextKey(sessionKey, channelKey, channelScope);
839
904
  const injectionEnabled = pluginCfg.localSummaryInjection !== false;
840
905
  const injectionBudget = pluginCfg.localSummaryMaxTokens || 2000;
841
906
  const syntheticEvent = {
842
907
  prompt: messages.map((m) => normalizeContextEngineMessage(m, m?.role || "user")?.content || "").filter(Boolean).slice(-6).join("\n\n"),
843
908
  sessionKey,
909
+ ...(channelKey ? { channel: channelKey } : {}),
844
910
  };
845
911
  let localMessages = [];
846
912
  const store = localStore || await getLocalStore(pluginCfg, api.logger);
847
- if (store && sessionKey) {
913
+ if (store && localContextKey) {
848
914
  try {
849
- const assembled = await assembleContext(store, sessionKey, budget, undefined, {
915
+ const assembled = await assembleContext(store, localContextKey, budget, undefined, {
850
916
  localSummaryInjection: injectionEnabled,
851
917
  localSummaryMaxTokens: injectionBudget,
852
918
  });
@@ -860,9 +926,9 @@ module.exports = (api) => {
860
926
  ? [...systemMsg, ...localMessages, ...messages.slice(-TAIL_KEEP)]
861
927
  : [...systemMsg, ...localMessages, ...messages];
862
928
  try {
863
- const store = localStore || await getLocalStore(getPluginConfig(api, ctx), api.logger);
864
- if (store && sessionKey) {
865
- const hotTopics = store.getLessonCountsForSession(sessionKey, 3);
929
+ const store = localStore || await getLocalStore(pluginCfg, api.logger);
930
+ if (store && localContextKey) {
931
+ const hotTopics = store.getLessonCountsForSession(localContextKey, 3);
866
932
  if (hotTopics.length > 0) {
867
933
  const warnings = hotTopics.map((r) => `CIRCUIT BREAKER: You have saved ${r.count} lessons about "${r.topic}" in this session. This suggests repeated failures. Stop and ask your human for guidance before continuing.`).join("\n");
868
934
  finalMessages.unshift({ role: "system", content: warnings });
@@ -883,12 +949,15 @@ module.exports = (api) => {
883
949
  const sessionKey = firstString(payload?.sessionKey, ctx?.sessionKey, ctx?.sessionId);
884
950
  const messages = Array.isArray(payload?.messages) ? payload.messages : [];
885
951
  queueContextEngineMessages(sessionKey, messages);
886
- const flushed = await flushContextEngineMessages(api, ctx, sessionKey, { sessionKey });
952
+ const pluginCfg = getPluginConfig(api, ctx);
953
+ const channelScope = getScopedChannelScope(ctx, { sessionKey }, pluginCfg?.channelScope);
954
+ const channel = firstString(payload?.channel, resolveEffectiveChannel(ctx, { sessionKey, channel: payload?.channel }, pluginCfg?.channelScope));
955
+ const localContextKey = resolveLocalContextKey(sessionKey, channel, channelScope);
956
+ const flushed = await flushContextEngineMessages(api, ctx, sessionKey, { sessionKey, channel });
887
957
  let summaryCount = 0;
888
- if (compactionEngine && sessionKey) { try { summaryCount = (await compactionEngine.compact(sessionKey, 32000, compactionEngine._summarizeFn, false))?.summariesCreated ?? 0; } catch (_) {} }
958
+ if (compactionEngine && localContextKey) { try { summaryCount = (await compactionEngine.compact(localContextKey, 32000, compactionEngine._summarizeFn, false))?.summariesCreated ?? 0; } catch (_) {} }
889
959
  const label = `OpenClaw compaction — ${new Date().toISOString()}`;
890
- const cfg = getPluginConfig(api, ctx);
891
- const channel = firstString(payload?.channel, resolveChannelKey(ctx, { sessionKey }, getPluginConfig(api, ctx)?.channelScope));
960
+ const cfg = pluginCfg;
892
961
  // Snapshot the full conversation before compaction (non-fatal — failure won't break compaction)
893
962
  let snapshotId = null;
894
963
  try {
@@ -914,8 +983,12 @@ module.exports = (api) => {
914
983
  async afterTurn(payload, ctx) {
915
984
  try {
916
985
  const sessionKey = firstString(payload?.sessionKey, ctx?.sessionKey, ctx?.sessionId);
917
- await flushContextEngineMessages(api, ctx, sessionKey, { sessionKey });
918
- if (compactionEngine && sessionKey) { try { await compactionEngine.compactLeaf(sessionKey, compactionEngine._summarizeFn); } catch (_) {} }
986
+ const pluginCfg = getPluginConfig(api, ctx);
987
+ const channelScope = getScopedChannelScope(ctx, { sessionKey }, pluginCfg?.channelScope);
988
+ const channel = resolveEffectiveChannel(ctx, { sessionKey, channel: payload?.channel }, pluginCfg?.channelScope);
989
+ const localContextKey = resolveLocalContextKey(sessionKey, channel, channelScope);
990
+ await flushContextEngineMessages(api, ctx, sessionKey, { sessionKey, channel });
991
+ if (compactionEngine && localContextKey) { try { await compactionEngine.compactLeaf(localContextKey, compactionEngine._summarizeFn); } catch (_) {} }
919
992
  if (localStore) _registerLocalTools(api);
920
993
  } catch (err) { api.logger?.warn?.(`[crystal] afterTurn: ${getErrorMessage(err)}`); }
921
994
  },
@@ -1055,14 +1128,16 @@ module.exports = (api) => {
1055
1128
  api.registerTool({
1056
1129
  name: "crystal_what_do_i_know", label: "Crystal What Do I Know",
1057
1130
  description: "Get a broad snapshot of what Memory Crystal contains about a topic.",
1058
- parameters: { type: "object", properties: { topic: { type: "string", minLength: 3 }, limit: { type: "number", minimum: 1, maximum: 20 } }, required: ["topic"], additionalProperties: false },
1131
+ parameters: { type: "object", properties: { topic: { type: "string", minLength: 3 }, stores: { type: "array", items: { type: "string", enum: MEMORY_STORES } }, tags: { type: "array", items: { type: "string" } }, limit: { type: "number", minimum: 1, maximum: 20 } }, required: ["topic"], additionalProperties: false },
1059
1132
  async execute(_id, params, _sig, _upd, ctx) {
1060
1133
  try {
1061
1134
  const topic = ensureString(params?.topic, "topic", 3);
1062
1135
  const limit = Number.isFinite(Number(params?.limit)) ? Number(params.limit) : 8;
1136
+ const stores = Array.isArray(params?.stores) ? params.stores : undefined;
1137
+ const tags = Array.isArray(params?.tags) ? params.tags.map(String).filter(Boolean) : undefined;
1063
1138
  const cfg = getPluginConfig(api, ctx);
1064
1139
  const resolvedChannel = resolveScopedChannelKey(ctx, ctx, cfg?.channelScope);
1065
- const payload = { query: topic, limit, ...(resolvedChannel ? { channel: resolvedChannel } : {}) };
1140
+ const payload = { query: topic, limit, ...(stores ? { stores } : {}), ...(tags ? { tags } : {}), ...(resolvedChannel ? { channel: resolvedChannel } : {}) };
1066
1141
  const data = await crystalRequest(cfg, "/api/mcp/recall", payload);
1067
1142
  const mems = Array.isArray(data?.memories) ? data.memories : [];
1068
1143
  return toToolResult({ topic, memoryCount: mems.length, summary: mems.slice(0, 3).map((m) => m.title).join("; ") || "No matching memories found.", topMemories: mems.slice(0, 10) });
@@ -1079,11 +1154,10 @@ module.exports = (api) => {
1079
1154
  const limit = Number.isFinite(Number(params?.limit)) ? Number(params.limit) : 8;
1080
1155
  const cfg = getPluginConfig(api, ctx);
1081
1156
  const resolvedChannel = resolveScopedChannelKey(ctx, ctx, cfg?.channelScope);
1082
- const payload = { query: decision, limit, ...(resolvedChannel ? { channel: resolvedChannel } : {}) };
1157
+ const payload = { query: decision, limit, mode: "decision", categories: ["decision"], stores: MEMORY_STORES, ...(resolvedChannel ? { channel: resolvedChannel } : {}) };
1083
1158
  const data = await crystalRequest(cfg, "/api/mcp/recall", payload);
1084
1159
  const mems = Array.isArray(data?.memories) ? data.memories : [];
1085
- const related = mems.filter((m) => m?.category === "decision").length > 0 ? mems.filter((m) => m?.category === "decision") : mems;
1086
- return toToolResult({ decision, reasoning: related.length > 0 ? `Primary threads around "${decision}"` : "No clear decision thread was surfaced.", relatedMemories: related.slice(0, 10) });
1160
+ return toToolResult({ decision, reasoning: mems.length > 0 ? `Primary threads around "${decision}"` : "No clear decision thread was surfaced.", relatedMemories: mems.slice(0, 10) });
1087
1161
  } catch (err) { return toToolError(err); }
1088
1162
  },
1089
1163
  });
package/index.test.js CHANGED
@@ -327,6 +327,76 @@ describe("crystal-memory plugin — Phase 2 integration", () => {
327
327
  }
328
328
  });
329
329
 
330
+ test("11c. assemble skips shared agent local summary injection when no concrete client channel is available", async () => {
331
+ const { checkSqliteAvailability, CrystalLocalStore } = await import(path.resolve(__dirname, "store/crystal-local-store.js"));
332
+ const avail = checkSqliteAvailability();
333
+ if (!avail.available) return;
334
+
335
+ const dbPath = makeTmpDbPath();
336
+ try {
337
+ const seedStore = new CrystalLocalStore();
338
+ seedStore.init(dbPath);
339
+ seedStore.insertSummary({
340
+ summaryId: "sum_shared_agent_1",
341
+ sessionKey: "agent:coach:main",
342
+ kind: "leaf",
343
+ depth: 0,
344
+ content: "BJ Moffatt private coaching history that must never bleed into another client.",
345
+ tokenCount: 60,
346
+ });
347
+ seedStore.addMessage("agent:coach:main", "user", "BJ Moffatt private coaching history");
348
+ seedStore.close();
349
+
350
+ const api = loadPlugin({ apiKey: "local", dbPath, channelScope: "morrow-coach", localSummaryInjection: true });
351
+ const engine = api._getEngine();
352
+ const result = await engine.assemble({
353
+ sessionKey: "agent:coach:main",
354
+ messages: [{ role: "user", content: "How should I respond to this client?" }],
355
+ }, makeCtx({ sessionKey: "agent:coach:main" }));
356
+
357
+ assert.equal(
358
+ result.messages.some((m) => m.role === "system" && m.content.includes("Relevant context from earlier in this conversation")),
359
+ false
360
+ );
361
+ assert.equal(
362
+ result.messages.some((m) => typeof m.content === "string" && m.content.includes("BJ Moffatt private coaching history")),
363
+ false
364
+ );
365
+ } finally {
366
+ fs.rmSync(dbPath, { force: true });
367
+ }
368
+ });
369
+
370
+ test("11d. ingestBatch stores shared coach local context under the concrete scoped channel instead of agent:coach:main", async () => {
371
+ const { checkSqliteAvailability } = await import(path.resolve(__dirname, "store/crystal-local-store.js"));
372
+ const avail = checkSqliteAvailability();
373
+ if (!avail.available) return;
374
+
375
+ const dbPath = makeTmpDbPath();
376
+ try {
377
+ const api = loadPlugin({ apiKey: "local", dbPath, channelScope: "morrow-coach" });
378
+ const engine = api._getEngine();
379
+ await engine.ingestBatch({
380
+ sessionKey: "agent:coach:main",
381
+ channel: "morrow-coach:12345",
382
+ messages: [
383
+ { role: "user", content: "Andy-specific note" },
384
+ { role: "assistant", content: "Coach response for Andy" },
385
+ ],
386
+ }, makeCtx({ sessionKey: "agent:coach:main" }));
387
+
388
+ const db = require("better-sqlite3")(dbPath, { readonly: true });
389
+ try {
390
+ const keys = db.prepare("SELECT session_key FROM conversations ORDER BY session_key ASC").all().map((row) => row.session_key);
391
+ assert.deepEqual(keys, ["morrow-coach:12345"]);
392
+ } finally {
393
+ db.close();
394
+ }
395
+ } finally {
396
+ fs.rmSync(dbPath, { force: true });
397
+ }
398
+ });
399
+
330
400
  test("9d. crystal_preflight does not classify a lesson as both a rule and a lesson", async () => {
331
401
  const api = loadPlugin();
332
402
  const tool = api._tools.get("crystal_preflight");
@@ -454,5 +524,38 @@ describe("crystal-memory plugin — Phase 2 integration", () => {
454
524
  payload = JSON.parse(fetchCalls.at(-1).opts.body);
455
525
  assert.equal(payload.channel, "default-scope:12345");
456
526
  });
527
+
528
+ test("crystal_set_scope also drives shared-session local context keying", async () => {
529
+ const { checkSqliteAvailability } = await import(path.resolve(__dirname, "store/crystal-local-store.js"));
530
+ const avail = checkSqliteAvailability();
531
+ if (!avail.available) return;
532
+
533
+ const dbPath = makeTmpDbPath();
534
+ try {
535
+ const api = loadPlugin({ apiKey: "local", dbPath, channelScope: "default-scope" });
536
+ const ctx = makeCtx({ peerId: "12345", sessionKey: "agent:coach:main" });
537
+ const setScopeTool = api._tools.get("crystal_set_scope");
538
+ const engine = api._getEngine();
539
+
540
+ await setScopeTool.execute("id", { scope: "morrow-coach" }, null, null, ctx);
541
+ await engine.ingestBatch({
542
+ sessionKey: "agent:coach:main",
543
+ messages: [
544
+ { role: "user", content: "Andy-specific note" },
545
+ { role: "assistant", content: "Coach response for Andy" },
546
+ ],
547
+ }, ctx);
548
+
549
+ const db = require("better-sqlite3")(dbPath, { readonly: true });
550
+ try {
551
+ const keys = db.prepare("SELECT session_key FROM conversations ORDER BY session_key ASC").all().map((row) => row.session_key);
552
+ assert.deepEqual(keys, ["morrow-coach:12345"]);
553
+ } finally {
554
+ db.close();
555
+ }
556
+ } finally {
557
+ fs.rmSync(dbPath, { force: true });
558
+ }
559
+ });
457
560
  });
458
561
  });
@@ -6,7 +6,7 @@
6
6
  ],
7
7
  "name": "Memory Crystal",
8
8
  "description": "Persistent memory for AI agents via Memory Crystal",
9
- "version": "0.7.4",
9
+ "version": "0.7.6",
10
10
  "entry": "index.js",
11
11
  "configSchema": {
12
12
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorycrystal/crystal-memory",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Memory Crystal OpenClaw plugin",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
package/recall-hook.js CHANGED
@@ -2,10 +2,9 @@
2
2
  const fs = require("node:fs");
3
3
  const path = require("node:path");
4
4
 
5
- const OPENAI_MODEL = "text-embedding-3-small";
6
- const OPENAI_URL = "https://api.openai.com/v1/embeddings";
7
5
  const GEMINI_MODEL = "gemini-embedding-2-preview";
8
6
  const GEMINI_URL_BASE = "https://generativelanguage.googleapis.com/v1beta";
7
+ const REQUIRED_EMBEDDING_DIMENSIONS = 3072;
9
8
  const CONVEX_ACTION = "/api/action";
10
9
  const CONVEX_QUERY = "/api/query";
11
10
  const DEFAULT_LIMIT = 8;
@@ -136,46 +135,26 @@ const getEmbedding = async (query, env) => {
136
135
  }
137
136
 
138
137
  const provider = String(env.EMBEDDING_PROVIDER || "gemini").toLowerCase();
139
-
140
- if (provider === "gemini") {
141
- const geminiKey = env.GEMINI_API_KEY;
142
- if (!geminiKey) {
143
- return null;
144
- }
145
- const model = env.GEMINI_EMBEDDING_MODEL || GEMINI_MODEL;
146
- const response = await fetch(`${GEMINI_URL_BASE}/models/${model}:embedContent?key=${encodeURIComponent(geminiKey)}`, {
147
- method: "POST",
148
- headers: {
149
- "content-type": "application/json",
150
- },
151
- body: JSON.stringify({
152
- model: `models/${model}`,
153
- content: { parts: [{ text: query }] },
154
- }),
155
- });
156
-
157
- if (!response.ok) {
158
- return null;
159
- }
160
-
161
- const payload = await response.json().catch(() => null);
162
- return Array.isArray(payload?.embedding?.values) ? payload.embedding.values : null;
138
+ if (provider !== "gemini") {
139
+ throw new Error(
140
+ `Only Gemini embeddings are supported. Got EMBEDDING_PROVIDER="${provider}". ` +
141
+ "Set EMBEDDING_PROVIDER=gemini or remove the variable."
142
+ );
163
143
  }
164
144
 
165
- const openaiKey = env.OPENAI_API_KEY;
166
- if (!openaiKey) {
145
+ const geminiKey = env.CRYSTAL_API_KEY || env.GEMINI_API_KEY;
146
+ if (!geminiKey) {
167
147
  return null;
168
148
  }
169
-
170
- const response = await fetch(OPENAI_URL, {
149
+ const model = env.GEMINI_EMBEDDING_MODEL || GEMINI_MODEL;
150
+ const response = await fetch(`${GEMINI_URL_BASE}/models/${model}:embedContent?key=${encodeURIComponent(geminiKey)}`, {
171
151
  method: "POST",
172
152
  headers: {
173
153
  "content-type": "application/json",
174
- Authorization: `Bearer ${openaiKey}`,
175
154
  },
176
155
  body: JSON.stringify({
177
- model: OPENAI_MODEL,
178
- input: query,
156
+ model: `models/${model}`,
157
+ content: { parts: [{ text: query }] },
179
158
  }),
180
159
  });
181
160
 
@@ -184,7 +163,15 @@ const getEmbedding = async (query, env) => {
184
163
  }
185
164
 
186
165
  const payload = await response.json().catch(() => null);
187
- return Array.isArray(payload?.data?.[0]?.embedding) ? payload.data[0].embedding : null;
166
+ const vector = Array.isArray(payload?.embedding?.values) ? payload.embedding.values : null;
167
+
168
+ if (vector && vector.length !== REQUIRED_EMBEDDING_DIMENSIONS) {
169
+ throw new Error(
170
+ `Embedding dimension mismatch in recall-hook: got ${vector.length}, expected ${REQUIRED_EMBEDDING_DIMENSIONS}`
171
+ );
172
+ }
173
+
174
+ return vector;
188
175
  };
189
176
 
190
177
  const formatBlock = (memories) => {