@memorycrystal/crystal-memory 0.7.4 → 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 +138 -64
- package/index.test.js +103 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/recall-hook.js +21 -34
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
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
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(
|
|
761
|
-
if (store && text &&
|
|
762
|
-
if (sessionKey) sessionConfigs.set(sessionKey, { mode:
|
|
763
|
-
fireMediaCapture(event,
|
|
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
|
|
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(
|
|
777
|
-
if (store &&
|
|
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,
|
|
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:
|
|
792
|
-
const store = await getLocalStore(
|
|
793
|
-
if (store) { store.addMessage(
|
|
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
|
|
822
|
-
const
|
|
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(
|
|
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 &&
|
|
913
|
+
if (store && localContextKey) {
|
|
848
914
|
try {
|
|
849
|
-
const assembled = await assembleContext(store,
|
|
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(
|
|
864
|
-
if (store &&
|
|
865
|
-
const hotTopics = store.getLessonCountsForSession(
|
|
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
|
|
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 &&
|
|
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 =
|
|
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
|
-
|
|
918
|
-
|
|
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
|
-
|
|
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
|
});
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
166
|
-
if (!
|
|
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(
|
|
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:
|
|
178
|
-
|
|
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
|
-
|
|
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) => {
|