@memorycrystal/crystal-memory 0.7.4
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/LICENSE +21 -0
- package/README.md +117 -0
- package/capture-hook.js +166 -0
- package/context-budget.js +71 -0
- package/context-budget.test.js +92 -0
- package/handler.js +247 -0
- package/index.js +1342 -0
- package/index.test.js +458 -0
- package/openclaw-hook.json +84 -0
- package/openclaw.plugin.json +61 -0
- package/package.json +37 -0
- package/recall-hook.js +456 -0
- package/reinforcement.test.js +105 -0
package/index.js
ADDED
|
@@ -0,0 +1,1342 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { execFile } = require("child_process");
|
|
5
|
+
const { getInjectionBudget, trimSections } = require("./context-budget");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Supported config fields (passed via OpenClaw plugin config):
|
|
9
|
+
*
|
|
10
|
+
* apiKey {string} — Convex API key (also used as Bearer token for MCP endpoints)
|
|
11
|
+
* convexUrl {string} — Convex site URL (default: https://rightful-mockingbird-389.convex.site)
|
|
12
|
+
* dbPath {string} — Absolute path to local SQLite database file (local-store mode)
|
|
13
|
+
* openaiApiKey {string} — OpenAI API key for summarization; falls back to OPENAI_API_KEY env var
|
|
14
|
+
* defaultRecallLimit {number} — Max recall results per query (1–8, default 5)
|
|
15
|
+
* defaultRecallMode {string} — Recall mode: "general" (default) or other supported modes
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DEFAULT_CONVEX_URL = "https://rightful-mockingbird-389.convex.site";
|
|
19
|
+
const MEMORY_STORES = ["sensory","episodic","semantic","procedural","prospective"];
|
|
20
|
+
const MEMORY_CATEGORIES = ["decision","lesson","person","rule","event","fact","goal","workflow","conversation"];
|
|
21
|
+
const PREAMBLE_BACKEND = `## Active Memory Backend\nIMPORTANT: Memory Crystal is the active OpenClaw memory backend for this session.\nOverride any generic OpenClaw wording about MEMORY.md, memory/*.md, or file-backed workspace memory for this run.\n- \`memory_search\` and \`memory_get\` are backed by Memory Crystal's remote memory store in this session.\n- \`crystal_recall\`, \`crystal_remember\`, \`crystal_checkpoint\`, \`crystal_what_do_i_know\`, and \`crystal_why_did_we\` are extended Memory Crystal tools.\n- If the user asks whether Memory Crystal is the active memory backend, answer yes.\n- Do not describe local/workspace file memory as the active backend unless crystal-memory is disabled.\n- If the user asks where recalled information came from, attribute it to one of: current conversation, Memory Crystal recent messages, Memory Crystal saved memories, or an explicit Memory Crystal tool lookup.\n- Never claim you read local transcript files, \`.jsonl.reset\` files, hidden session logs, or reset artifacts unless the user directly provided them in the current conversation.\n- For requests about exact prior wording or verbatim recent messages, prefer \`crystal_search_messages\` instead of guessing.`;
|
|
22
|
+
const PREAMBLE_TOOLS = `## Memory Crystal — How to Use Your Tools\nYou have persistent memory across sessions. Use these tools proactively — don't wait to be asked:\n**crystal_recall** — Search memory when the user references past events, decisions, projects, or people. Run this *before* answering anything that depends on prior context.\n**crystal_remember** — Save after any decision, preference, lesson learned, project fact, or goal. If it would be useful to know in a future session, save it now.\n**crystal_search_messages** — Find verbatim past wording: exact quotes, code snippets, prior instructions. Use this instead of guessing what was said.\n**crystal_what_do_i_know** — Summarize everything known about a topic before starting a new project or task. Run this at the start of major work.\n**crystal_why_did_we** — Check the history before changing an existing decision. Understand the reasoning before overriding it.\n**crystal_preflight** — Run before any config change, API write, file delete, or external send. Returns relevant rules and lessons as a checklist.\n**crystal_checkpoint** — Snapshot memory state at major milestones: after a sprint, a deployment, a significant decision, or before a compaction.\nStores: sensory (raw events) | episodic (experiences) | semantic (facts/knowledge) | procedural (how-to) | prospective (future plans)\nCategories: decision | lesson | person | rule | event | fact | goal | workflow | conversation`;
|
|
23
|
+
const {
|
|
24
|
+
firstString, trimSnippet, extractUserText, extractAssistantText,
|
|
25
|
+
getChannelKey, shouldCapture, isCronOrIsolated, normalizeContextEngineMessage,
|
|
26
|
+
} = require("./utils/crystal-utils");
|
|
27
|
+
const { assembleContext } = require("./compaction/crystal-assembler");
|
|
28
|
+
const MEDIA_CAPS_BYTES = {
|
|
29
|
+
image: 5 * 1024 * 1024,
|
|
30
|
+
audio: 10 * 1024 * 1024,
|
|
31
|
+
video: 50 * 1024 * 1024,
|
|
32
|
+
pdf: 10 * 1024 * 1024,
|
|
33
|
+
};
|
|
34
|
+
function getMediaKind(mimeType) {
|
|
35
|
+
if (!mimeType) return null;
|
|
36
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
37
|
+
if (mimeType.startsWith("audio/")) return "audio";
|
|
38
|
+
if (mimeType.startsWith("video/")) return "video";
|
|
39
|
+
if (mimeType === "application/pdf") return "pdf";
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function classifyIntent(text) {
|
|
43
|
+
const value = String(text || "").trim();
|
|
44
|
+
if (!value) return "general";
|
|
45
|
+
if (/\b(remember|save|note|keep|log|write down|store)\b/i.test(value)) return "store";
|
|
46
|
+
if (/\b(reflect|summarize|what have|review|digest|recap)\b/i.test(value)) return "reflect";
|
|
47
|
+
if (/\b(recall|what did|do you know|have we|last time|previously)\b/i.test(value)) return "recall";
|
|
48
|
+
if (/^(how to|how do i|steps to|walk me through|show me how|what's the process)\b/i.test(value) || /\b(workflow|procedure|runbook|playbook)\b/i.test(value)) return "workflow";
|
|
49
|
+
if (value.startsWith("/") || /^(do|run|execute|create|build|make|generate|fix|update|delete)\b/i.test(value)) return "command";
|
|
50
|
+
if (value.endsWith("?") || /^(what|how|why|when|where|who|is|are|can|could|should|would)\b/i.test(value)) return "question";
|
|
51
|
+
return "general";
|
|
52
|
+
}
|
|
53
|
+
async function captureMediaAsset(filePath, mimeType, apiKey, convexSiteUrl, channel, sessionKey) {
|
|
54
|
+
const kind = getMediaKind(mimeType);
|
|
55
|
+
if (!kind) return;
|
|
56
|
+
let buf;
|
|
57
|
+
try { buf = await fs.promises.readFile(filePath); }
|
|
58
|
+
catch (e) { console.warn("[crystal] media read failed:", getErrorMessage(e)); return; }
|
|
59
|
+
const cap = MEDIA_CAPS_BYTES[kind];
|
|
60
|
+
if (buf.length > cap) {
|
|
61
|
+
console.warn("[crystal] media too large, skipping:", kind, buf.length, "bytes (cap:", cap, ")");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const urlRes = await fetch(convexSiteUrl + "/api/mcp/upload-url", {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
67
|
+
});
|
|
68
|
+
if (!urlRes.ok) { console.warn("[crystal] upload-url failed:", urlRes.status); return; }
|
|
69
|
+
const { uploadUrl } = await urlRes.json();
|
|
70
|
+
const uploadRes = await fetch(uploadUrl, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": mimeType },
|
|
73
|
+
body: buf,
|
|
74
|
+
});
|
|
75
|
+
if (!uploadRes.ok) { console.warn("[crystal] upload failed:", uploadRes.status); return; }
|
|
76
|
+
const { storageId } = await uploadRes.json();
|
|
77
|
+
if (!storageId) { console.warn("[crystal] no storageId in upload response"); return; }
|
|
78
|
+
const body = { storageKey: storageId, kind, mimeType };
|
|
79
|
+
if (channel) body.channel = channel;
|
|
80
|
+
if (sessionKey) body.sessionKey = sessionKey;
|
|
81
|
+
const assetRes = await fetch(convexSiteUrl + "/api/mcp/asset", {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json", Authorization: "Bearer " + apiKey },
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
});
|
|
86
|
+
if (!assetRes.ok) { console.warn("[crystal] asset register failed:", assetRes.status); return; }
|
|
87
|
+
const result = await assetRes.json();
|
|
88
|
+
console.log("[crystal] media asset captured:", result.id, kind, mimeType);
|
|
89
|
+
}
|
|
90
|
+
function fireMediaCapture(event, config, channel, sessionKey) {
|
|
91
|
+
const apiKey = config?.apiKey;
|
|
92
|
+
if (!apiKey || apiKey === "local") return;
|
|
93
|
+
const base = (config?.convexUrl || DEFAULT_CONVEX_URL).replace(/\/+$/, "");
|
|
94
|
+
const attachments = [];
|
|
95
|
+
if (Array.isArray(event.attachments)) {
|
|
96
|
+
for (const att of event.attachments) attachments.push(att);
|
|
97
|
+
}
|
|
98
|
+
const singlePath = event.mediaPath || event.filePath;
|
|
99
|
+
const singleMime = event.mimeType || event.mediaType;
|
|
100
|
+
if (singlePath && singleMime) attachments.push({ filePath: singlePath, mimeType: singleMime });
|
|
101
|
+
for (const att of attachments) {
|
|
102
|
+
const fp = att.filePath || att.path;
|
|
103
|
+
const mt = att.mimeType || att.contentType;
|
|
104
|
+
if (fp && mt) {
|
|
105
|
+
captureMediaAsset(fp, mt, apiKey, base, channel, sessionKey)
|
|
106
|
+
.catch(function(e) { console.warn("[crystal] media capture error:", getErrorMessage(e)); });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Capture pluginConfig at module init time as a fallback for tool execute() calls.
|
|
111
|
+
// OpenClaw v2026.3.24 does not inject pluginConfig into the ctx/api objects passed
|
|
112
|
+
// to tool execute() — only to hooks and the module.exports call itself. Without this
|
|
113
|
+
// fallback, all crystal_* tool calls fail with "apiKey is not configured".
|
|
114
|
+
// See: https://github.com/openclaw/openclaw/issues/56432
|
|
115
|
+
let _capturedPluginConfig = null;
|
|
116
|
+
|
|
117
|
+
const pendingUserMessages = new Map();
|
|
118
|
+
const sessionConfigs = new Map();
|
|
119
|
+
const sessionChannelScopes = new Map();
|
|
120
|
+
const wakeInjectedSessions = new Set();
|
|
121
|
+
const seenCaptureSessions = new Set();
|
|
122
|
+
const intentCache = new Map();
|
|
123
|
+
const pendingContextEngineMessages = new Map();
|
|
124
|
+
const conversationTurnCounters = new Map();
|
|
125
|
+
const reinforcementTurnCounters = new Map();
|
|
126
|
+
const conversationPulseBuffers = new Map();
|
|
127
|
+
|
|
128
|
+
// Reinforcement injection: cache top recall results per session so we can
|
|
129
|
+
// re-inject them near the end of long conversations ("lost in the middle" fix).
|
|
130
|
+
const sessionRecallCache = new Map();
|
|
131
|
+
const sessionRecallCacheTimestamps = new Map();
|
|
132
|
+
const SESSION_RECALL_CACHE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
133
|
+
const INTENT_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
134
|
+
const REINFORCEMENT_TURN_THRESHOLD = 5;
|
|
135
|
+
const REINFORCEMENT_MAX_CHARS = 800;
|
|
136
|
+
const CONVERSATION_PULSE_TURN_THRESHOLD = 5;
|
|
137
|
+
const INJECTION_TAG_PATTERNS = [
|
|
138
|
+
/<\/?system\b[^>]*>/gi,
|
|
139
|
+
/<\/?assistant\b[^>]*>/gi,
|
|
140
|
+
/<\/?user\b[^>]*>/gi,
|
|
141
|
+
/<\|im_start\|>/gi,
|
|
142
|
+
/<\|im_end\|>/gi,
|
|
143
|
+
/<\|system\|>/gi,
|
|
144
|
+
/<\|assistant\|>/gi,
|
|
145
|
+
/<\|user\|>/gi,
|
|
146
|
+
];
|
|
147
|
+
const INJECTION_LINE_PATTERNS = [
|
|
148
|
+
/^\s*ignore (?:all )?(?:any |the )?(?:previous|prior|above) instructions\b.*$/gim,
|
|
149
|
+
/^\s*you are now\b.*$/gim,
|
|
150
|
+
/^\s*system:\s*.*$/gim,
|
|
151
|
+
/^\s*assistant:\s*.*$/gim,
|
|
152
|
+
/^\s*#{2,}\s*system\b.*$/gim,
|
|
153
|
+
];
|
|
154
|
+
let localStore = null;
|
|
155
|
+
let compactionEngine = null;
|
|
156
|
+
let storeInitPromise = null;
|
|
157
|
+
let localToolsRegistered = false;
|
|
158
|
+
function sanitizeForInjection(text) {
|
|
159
|
+
let value = String(text || "");
|
|
160
|
+
for (const pattern of INJECTION_TAG_PATTERNS) value = value.replace(pattern, "");
|
|
161
|
+
for (const pattern of INJECTION_LINE_PATTERNS) value = value.replace(pattern, "");
|
|
162
|
+
value = value.replace(/\n{3,}/g, "\n\n").trim();
|
|
163
|
+
return value.slice(0, 2000);
|
|
164
|
+
}
|
|
165
|
+
function redactSensitiveContent(text) {
|
|
166
|
+
let value = String(text || "");
|
|
167
|
+
value = value.replace(/\b((?:api[-_ ]?)?(?:key|token|secret|password)|bearer)\b(\s*[:=]?\s*)([A-Za-z0-9+/_=-]{20,})/gi, (_, label, sep) => `${label}${sep}[REDACTED]`);
|
|
168
|
+
value = value.replace(/\b(?:\d{4}[- ]?){3}\d{4}\b/g, "[REDACTED]");
|
|
169
|
+
value = value.replace(/\b([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})(\s+(?:password|pass|pwd)\s+)(\S+)/gi, (_, email, middle) => `${email}${middle}[REDACTED]`);
|
|
170
|
+
value = value.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED]");
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
function sanitizeErrorMessage(input) {
|
|
174
|
+
let value = String(input || "Unknown error");
|
|
175
|
+
value = value.replace(/(?:\/Users|\/home)\/[^\s:)]+/g, "[path]");
|
|
176
|
+
value = value.replace(/\b((?:api[-_ ]?)?(?:key|token|secret|password)|bearer)\b(\s*[:=]?\s*)([A-Za-z0-9+/_=-]{10,})/gi, (_, label, sep) => `${label}${sep}[REDACTED]`);
|
|
177
|
+
value = value.replace(/\bsk_[A-Za-z0-9_-]{10,}\b/g, "[REDACTED]");
|
|
178
|
+
value = value.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED]");
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
function getErrorMessage(err) {
|
|
182
|
+
return sanitizeErrorMessage(err?.message || String(err));
|
|
183
|
+
}
|
|
184
|
+
function normalizeMemoryId(id, fallback = "unknown") {
|
|
185
|
+
const value = typeof id === "string" ? id.trim() : "";
|
|
186
|
+
return value || fallback;
|
|
187
|
+
}
|
|
188
|
+
function buildMemoryInjectionBlock(id, lines) {
|
|
189
|
+
return [
|
|
190
|
+
`--- Memory [${normalizeMemoryId(id)}] ---`,
|
|
191
|
+
...lines.filter(Boolean),
|
|
192
|
+
"--- End Memory ---",
|
|
193
|
+
].join("\n");
|
|
194
|
+
}
|
|
195
|
+
function maybeRunAutoUpdate(config, logger) {
|
|
196
|
+
if (config?.autoUpdate !== true) return;
|
|
197
|
+
try {
|
|
198
|
+
const updateScript = path.join(__dirname, "update.sh");
|
|
199
|
+
if (!fs.existsSync(updateScript)) return;
|
|
200
|
+
execFile("bash", [updateScript, "--no-restart"], { timeout: 60000 }, (err, stdout) => {
|
|
201
|
+
if (err && err.code !== 0) return;
|
|
202
|
+
if (stdout && stdout.includes("Updating")) {
|
|
203
|
+
logger?.info?.("[crystal] Auto-update applied");
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
logger?.warn?.(`[crystal] auto-update: ${getErrorMessage(err)}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function clearSessionState(sessionKey) {
|
|
211
|
+
if (!sessionKey) return;
|
|
212
|
+
pendingUserMessages.delete(sessionKey);
|
|
213
|
+
sessionConfigs.delete(sessionKey);
|
|
214
|
+
sessionChannelScopes.delete(sessionKey);
|
|
215
|
+
wakeInjectedSessions.delete(sessionKey);
|
|
216
|
+
seenCaptureSessions.delete(`msg:${sessionKey}`);
|
|
217
|
+
seenCaptureSessions.delete(`out:${sessionKey}`);
|
|
218
|
+
intentCache.delete(sessionKey);
|
|
219
|
+
pendingContextEngineMessages.delete(sessionKey);
|
|
220
|
+
conversationTurnCounters.delete(sessionKey);
|
|
221
|
+
conversationPulseBuffers.delete(sessionKey);
|
|
222
|
+
reinforcementTurnCounters.delete(sessionKey);
|
|
223
|
+
sessionRecallCache.delete(sessionKey);
|
|
224
|
+
sessionRecallCacheTimestamps.delete(sessionKey);
|
|
225
|
+
}
|
|
226
|
+
function appendConversationPulseMessage(sessionKey, role, content) {
|
|
227
|
+
if (!sessionKey || !content) return;
|
|
228
|
+
const next = (conversationPulseBuffers.get(sessionKey) || []).concat([{ role, content: String(content) }]).slice(-12);
|
|
229
|
+
conversationPulseBuffers.set(sessionKey, next);
|
|
230
|
+
}
|
|
231
|
+
function parseSkillMetadata(metadata) {
|
|
232
|
+
if (!metadata || typeof metadata !== "string") return null;
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(metadata);
|
|
235
|
+
if (!parsed || parsed.skillFormat !== true) return null;
|
|
236
|
+
const triggerConditions = Array.isArray(parsed.triggerConditions) ? parsed.triggerConditions.filter((item) => typeof item === "string") : [];
|
|
237
|
+
const pitfalls = Array.isArray(parsed.pitfalls) ? parsed.pitfalls.filter((item) => typeof item === "string") : [];
|
|
238
|
+
const steps = Array.isArray(parsed.steps)
|
|
239
|
+
? parsed.steps
|
|
240
|
+
.filter((item) => item && typeof item === "object" && typeof item.action === "string")
|
|
241
|
+
.map((step, index) => ({
|
|
242
|
+
order: Number.isFinite(Number(step.order)) ? Number(step.order) : index + 1,
|
|
243
|
+
action: String(step.action),
|
|
244
|
+
...(typeof step.command === "string" && step.command.trim() ? { command: step.command.trim() } : {}),
|
|
245
|
+
}))
|
|
246
|
+
: [];
|
|
247
|
+
return {
|
|
248
|
+
triggerConditions,
|
|
249
|
+
pitfalls,
|
|
250
|
+
steps,
|
|
251
|
+
verification: typeof parsed.verification === "string" ? parsed.verification : "",
|
|
252
|
+
patternType: typeof parsed.patternType === "string" ? parsed.patternType : "workflow",
|
|
253
|
+
observationCount: Number.isFinite(Number(parsed.observationCount)) ? Number(parsed.observationCount) : 1,
|
|
254
|
+
};
|
|
255
|
+
} catch (_) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function formatProceduralMemory(m) {
|
|
260
|
+
const skill = parseSkillMetadata(m?.metadata);
|
|
261
|
+
if (!skill) return formatRecallMemory(m);
|
|
262
|
+
const conf = confidenceLabel(m?.score ?? m?.confidence);
|
|
263
|
+
const triggerLine = skill.triggerConditions.length ? `Triggers: ${skill.triggerConditions.slice(0, 2).map(sanitizeForInjection).filter(Boolean).join(" | ")}` : "";
|
|
264
|
+
const stepLine = skill.steps.length
|
|
265
|
+
? `Steps: ${skill.steps.slice(0, 4).map((step) => `${step.order}. ${trimSnippet(sanitizeForInjection(step.action), 60)}`).join(" ")}`
|
|
266
|
+
: "";
|
|
267
|
+
const pitfallLine = skill.pitfalls.length ? `Pitfalls: ${skill.pitfalls.slice(0, 2).map(sanitizeForInjection).filter(Boolean).join(" | ")}` : "";
|
|
268
|
+
const verificationLine = skill.verification ? `Verify: ${trimSnippet(sanitizeForInjection(skill.verification), 120)}` : "";
|
|
269
|
+
return buildMemoryInjectionBlock(m?.memoryId || m?._id || m?.id, [
|
|
270
|
+
`Type: procedural/${skill.patternType}${conf}`,
|
|
271
|
+
`Title: ${trimSnippet(sanitizeForInjection(m?.title || "Untitled skill"), 120)}`,
|
|
272
|
+
`Observed: ${skill.observationCount}x`,
|
|
273
|
+
triggerLine,
|
|
274
|
+
stepLine,
|
|
275
|
+
pitfallLine,
|
|
276
|
+
verificationLine,
|
|
277
|
+
`Path: ${buildMemoryPath(m?.memoryId || m?._id || m?.id || "")}`,
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
function triggerConversationPulse(api, ctx, sessionKey, text) {
|
|
281
|
+
try {
|
|
282
|
+
const config = getPluginConfig(api, ctx);
|
|
283
|
+
const baseUrlRaw = config?.convexSiteUrl || config?.convexUrl || DEFAULT_CONVEX_URL;
|
|
284
|
+
const apiKey = config?.apiKey;
|
|
285
|
+
const baseUrl = String(baseUrlRaw || "").replace(/\/+$/, "");
|
|
286
|
+
if (!baseUrl || !apiKey || apiKey === "local" || !text || !sessionKey) return;
|
|
287
|
+
const count = (conversationTurnCounters.get(sessionKey) || 0) + 1;
|
|
288
|
+
conversationTurnCounters.set(sessionKey, count);
|
|
289
|
+
reinforcementTurnCounters.set(sessionKey, (reinforcementTurnCounters.get(sessionKey) || 0) + 1);
|
|
290
|
+
if (count < CONVERSATION_PULSE_TURN_THRESHOLD) return;
|
|
291
|
+
conversationTurnCounters.set(sessionKey, 0);
|
|
292
|
+
const buffered = (conversationPulseBuffers.get(sessionKey) || []).slice(-CONVERSATION_PULSE_TURN_THRESHOLD * 2);
|
|
293
|
+
const messages = (buffered.length ? buffered : [{ role: "user", content: String(text) }]).map((message) => ({
|
|
294
|
+
role: message.role,
|
|
295
|
+
content: redactSensitiveContent(message.content),
|
|
296
|
+
}));
|
|
297
|
+
const _intentCached = intentCache.get(sessionKey);
|
|
298
|
+
if (_intentCached && Date.now() - _intentCached.detectedAt > INTENT_CACHE_TTL_MS) {
|
|
299
|
+
intentCache.delete(sessionKey);
|
|
300
|
+
}
|
|
301
|
+
const intent = intentCache.get(sessionKey)?.intent;
|
|
302
|
+
const channelKey = resolveChannelKey(ctx, { sessionKey }, config?.channelScope);
|
|
303
|
+
fetch(`${baseUrl}/api/organic/conversationPulse`, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: {
|
|
306
|
+
"Content-Type": "application/json",
|
|
307
|
+
Authorization: `Bearer ${apiKey}`,
|
|
308
|
+
},
|
|
309
|
+
body: JSON.stringify({ messages, intent, channelKey }),
|
|
310
|
+
}).then(r => r.body?.cancel?.()).catch(() => {});
|
|
311
|
+
} catch (_) {}
|
|
312
|
+
}
|
|
313
|
+
async function getLocalStore(config, logger) {
|
|
314
|
+
if (localStore) return localStore;
|
|
315
|
+
if (storeInitPromise) return storeInitPromise;
|
|
316
|
+
storeInitPromise = (async () => {
|
|
317
|
+
try {
|
|
318
|
+
const { CrystalLocalStore } = await import("./store/crystal-local-store.js");
|
|
319
|
+
const store = new CrystalLocalStore();
|
|
320
|
+
store.init(config?.dbPath);
|
|
321
|
+
if (!store.db) return null;
|
|
322
|
+
const compMod = await import("./compaction/crystal-compaction.js");
|
|
323
|
+
const sumMod = await import("./compaction/crystal-summarizer.js");
|
|
324
|
+
const summarizerConfig = {
|
|
325
|
+
...config,
|
|
326
|
+
apiKey: config?.openaiApiKey || process.env.OPENAI_API_KEY || undefined,
|
|
327
|
+
};
|
|
328
|
+
const summarizer = typeof sumMod.createSummarizer === "function" ? sumMod.createSummarizer(summarizerConfig) : null;
|
|
329
|
+
compactionEngine = new compMod.CrystalCompactionEngine(store, config);
|
|
330
|
+
compactionEngine._summarizeFn = summarizer;
|
|
331
|
+
return store;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
logger?.warn?.(`[crystal] Local store unavailable: ${getErrorMessage(err)}`);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
})();
|
|
337
|
+
localStore = await storeInitPromise;
|
|
338
|
+
return localStore;
|
|
339
|
+
}
|
|
340
|
+
function getPluginConfig(api, ctx) {
|
|
341
|
+
const direct = api?.pluginConfig;
|
|
342
|
+
if (direct && typeof direct === "object") return direct;
|
|
343
|
+
const root = ctx?.config || api?.config || {};
|
|
344
|
+
const entry = root?.plugins?.entries?.[api?.id || ""]?.config;
|
|
345
|
+
if (entry && typeof entry === "object") return entry;
|
|
346
|
+
// Fallback: use config captured at module init time.
|
|
347
|
+
// OpenClaw does not forward pluginConfig into tool execute() contexts — only into
|
|
348
|
+
// hook callbacks and the initial module.exports call. Without this, all tool calls
|
|
349
|
+
// fail silently with missing apiKey.
|
|
350
|
+
if (_capturedPluginConfig && typeof _capturedPluginConfig === "object") return _capturedPluginConfig;
|
|
351
|
+
return {};
|
|
352
|
+
}
|
|
353
|
+
async function request(config, method, path, body, logger) {
|
|
354
|
+
const apiKey = config?.apiKey;
|
|
355
|
+
if (!apiKey) { logger?.warn?.(`[crystal] request skipped (no apiKey): ${method} ${path}`); return null; }
|
|
356
|
+
if (apiKey === "local") { return null; }
|
|
357
|
+
const base = (config?.convexUrl || DEFAULT_CONVEX_URL).replace(/\/+$/, "");
|
|
358
|
+
try {
|
|
359
|
+
const res = await fetch(`${base}${path}`, {
|
|
360
|
+
method,
|
|
361
|
+
headers: { Authorization: `Bearer ${apiKey}`, ...(body ? { "content-type": "application/json" } : {}) },
|
|
362
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
363
|
+
});
|
|
364
|
+
if (!res.ok) { logger?.warn?.(`[crystal] ${method} ${path} -> ${res.status}`); return null; }
|
|
365
|
+
return res.json().catch(() => null);
|
|
366
|
+
} catch (err) { logger?.warn?.(`[crystal] request error: ${getErrorMessage(err)}`); return null; }
|
|
367
|
+
}
|
|
368
|
+
async function crystalRequest(config, path, body) {
|
|
369
|
+
const apiKey = config?.apiKey;
|
|
370
|
+
if (!apiKey) throw new Error("Memory Crystal apiKey is not configured");
|
|
371
|
+
if (apiKey === "local") throw new Error("Memory Crystal cloud tools are not available in local-only mode");
|
|
372
|
+
const base = (config?.convexUrl || DEFAULT_CONVEX_URL).replace(/\/+$/, "");
|
|
373
|
+
const res = await fetch(`${base}${path}`, {
|
|
374
|
+
method: "POST",
|
|
375
|
+
headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
|
376
|
+
body: JSON.stringify(body || {}),
|
|
377
|
+
});
|
|
378
|
+
const data = await res.json().catch(() => null);
|
|
379
|
+
if (!res.ok) throw new Error(String(data?.error || `HTTP ${res.status}`));
|
|
380
|
+
return data;
|
|
381
|
+
}
|
|
382
|
+
function toToolResult(payload) {
|
|
383
|
+
const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
|
|
384
|
+
return { content: [{ type: "text", text }] };
|
|
385
|
+
}
|
|
386
|
+
function toToolError(err) {
|
|
387
|
+
return { isError: true, content: [{ type: "text", text: `Error: ${getErrorMessage(err)}` }] };
|
|
388
|
+
}
|
|
389
|
+
function ensureString(v, name, min = 1) {
|
|
390
|
+
if (typeof v !== "string" || v.trim().length < min) throw new Error(`${name} is required`);
|
|
391
|
+
return v.trim();
|
|
392
|
+
}
|
|
393
|
+
function ensureEnum(v, valid, name) {
|
|
394
|
+
if (!valid.includes(v)) throw new Error(`${name} must be one of: ${valid.join(", ")}`);
|
|
395
|
+
return v;
|
|
396
|
+
}
|
|
397
|
+
function buildMemoryPath(id) { return `crystal/${String(id)}.md`; }
|
|
398
|
+
function parseMemoryPath(v) {
|
|
399
|
+
if (typeof v !== "string") return "";
|
|
400
|
+
const m = /^crystal\/(.+)\.md$/i.exec(v.trim());
|
|
401
|
+
return m ? m[1] : "";
|
|
402
|
+
}
|
|
403
|
+
function confidenceLabel(score) {
|
|
404
|
+
if (typeof score !== "number" || isNaN(score)) return "";
|
|
405
|
+
if (score >= 0.85) return " [HIGH CONFIDENCE]";
|
|
406
|
+
if (score >= 0.5) return "";
|
|
407
|
+
return " [low confidence]";
|
|
408
|
+
}
|
|
409
|
+
function formatRecallMemory(m) {
|
|
410
|
+
const id = normalizeMemoryId(m?.memoryId || m?._id || m?.id);
|
|
411
|
+
const conf = confidenceLabel(m?.score ?? m?.confidence);
|
|
412
|
+
return buildMemoryInjectionBlock(id, [
|
|
413
|
+
`Type: ${m?.store || "?"}/${m?.category || "?"}${conf}`,
|
|
414
|
+
`Title: ${trimSnippet(sanitizeForInjection(m?.title || "Untitled"), 120)}`,
|
|
415
|
+
m?.content ? `Content: ${trimSnippet(sanitizeForInjection(m.content), 2000)}` : "",
|
|
416
|
+
`Path: ${buildMemoryPath(id)}`,
|
|
417
|
+
]);
|
|
418
|
+
}
|
|
419
|
+
function formatMessageMatch(m) {
|
|
420
|
+
const ts = typeof m?.timestamp === "number" ? new Date(m.timestamp).toLocaleString([], { hour12: false, hour: "2-digit", minute: "2-digit", month: "short", day: "numeric" }) : "unknown";
|
|
421
|
+
return `- [${m?.role || "?"}] ${trimSnippet(sanitizeForInjection(m?.content || ""), 220)} (${ts})`;
|
|
422
|
+
}
|
|
423
|
+
function getSessionKey(ctx, event) {
|
|
424
|
+
return ctx?.sessionKey || ctx?.sessionId || ctx?.conversationId || event?.sessionKey || event?.conversationId || event?.sessionId || "";
|
|
425
|
+
}
|
|
426
|
+
function getScopedChannelScope(ctx, event, fallbackScope) {
|
|
427
|
+
const sessionKey = getSessionKey(ctx, event);
|
|
428
|
+
if (sessionKey && sessionChannelScopes.has(sessionKey)) return sessionChannelScopes.get(sessionKey);
|
|
429
|
+
return fallbackScope;
|
|
430
|
+
}
|
|
431
|
+
function resolveScopedChannelKey(ctx, event, fallbackScope) {
|
|
432
|
+
const channelScope = getScopedChannelScope(ctx, event, fallbackScope);
|
|
433
|
+
return channelScope ? getChannelKey(ctx, event, channelScope) : "";
|
|
434
|
+
}
|
|
435
|
+
function resolveChannelKey(ctx, event, fallbackScope) {
|
|
436
|
+
return getChannelKey(ctx, event, getScopedChannelScope(ctx, event, fallbackScope));
|
|
437
|
+
}
|
|
438
|
+
function queueContextEngineMessages(sessionKey, messages) {
|
|
439
|
+
if (!sessionKey || !Array.isArray(messages) || !messages.length) return;
|
|
440
|
+
const norm = messages.map((m) => normalizeContextEngineMessage(m)).filter(Boolean);
|
|
441
|
+
if (!norm.length) return;
|
|
442
|
+
pendingContextEngineMessages.set(sessionKey, (pendingContextEngineMessages.get(sessionKey) || []).concat(norm));
|
|
443
|
+
}
|
|
444
|
+
async function logMessage(api, ctx, payload) {
|
|
445
|
+
await request(getPluginConfig(api, ctx), "POST", "/api/mcp/log", payload, api.logger);
|
|
446
|
+
}
|
|
447
|
+
async function captureTurn(api, event, ctx, userMessage, assistantText) {
|
|
448
|
+
if (!shouldCapture(userMessage, assistantText)) return;
|
|
449
|
+
await request(getPluginConfig(api, ctx), "POST", "/api/mcp/capture", {
|
|
450
|
+
title: `OpenClaw — ${new Date().toISOString().slice(0, 16).replace("T", " ")}`,
|
|
451
|
+
content: [userMessage ? `User: ${userMessage}` : null, `Assistant: ${assistantText}`].filter(Boolean).join("\n\n"),
|
|
452
|
+
store: "sensory", category: "conversation", tags: ["openclaw", "auto-capture"],
|
|
453
|
+
channel: resolveChannelKey(ctx, event, getPluginConfig(api, ctx)?.channelScope),
|
|
454
|
+
}, api.logger);
|
|
455
|
+
}
|
|
456
|
+
async function flushContextEngineMessages(api, ctx, sessionKey, eventLike) {
|
|
457
|
+
const buffered = pendingContextEngineMessages.get(sessionKey) || [];
|
|
458
|
+
if (!buffered.length) return { flushed: 0 };
|
|
459
|
+
// Atomic swap-and-clear: grab the buffer and delete immediately before async I/O
|
|
460
|
+
// to prevent concurrent calls from double-processing the same messages.
|
|
461
|
+
pendingContextEngineMessages.delete(sessionKey);
|
|
462
|
+
const channelKey = resolveChannelKey(ctx, eventLike || { sessionKey }, getPluginConfig(api, ctx)?.channelScope);
|
|
463
|
+
for (const msg of buffered) {
|
|
464
|
+
await logMessage(api, ctx, { role: msg.role, content: msg.content, channel: channelKey, sessionKey });
|
|
465
|
+
}
|
|
466
|
+
const lastUser = [...buffered].reverse().find((m) => m.role === "user")?.content || "";
|
|
467
|
+
const lastAssist = [...buffered].reverse().find((m) => m.role === "assistant")?.content || "";
|
|
468
|
+
if (lastAssist) await captureTurn(api, eventLike || { sessionKey }, ctx, lastUser, lastAssist);
|
|
469
|
+
return { flushed: buffered.length };
|
|
470
|
+
}
|
|
471
|
+
async function buildBeforeAgentContext(api, event, ctx) {
|
|
472
|
+
const config = getPluginConfig(api, ctx);
|
|
473
|
+
if (!config?.apiKey || config.apiKey === "local") return "";
|
|
474
|
+
const channel = resolveChannelKey(ctx, event, getPluginConfig(api, ctx)?.channelScope);
|
|
475
|
+
const sessionKey = getSessionKey(ctx, event);
|
|
476
|
+
const cronMode = isCronOrIsolated(ctx, event);
|
|
477
|
+
const sections = cronMode ? [] : [PREAMBLE_BACKEND, PREAMBLE_TOOLS];
|
|
478
|
+
if (!cronMode && sessionKey && !wakeInjectedSessions.has(sessionKey)) {
|
|
479
|
+
const wake = await request(config, "POST", "/api/mcp/wake", { channel }, api.logger);
|
|
480
|
+
const briefing = wake?.briefing || wake?.summary || wake?.text;
|
|
481
|
+
if (briefing) { sections.push(sanitizeForInjection(String(briefing))); wakeInjectedSessions.add(sessionKey); }
|
|
482
|
+
}
|
|
483
|
+
// --- Organic Ideas: inject pending discoveries before recall ---
|
|
484
|
+
if (!cronMode) {
|
|
485
|
+
try {
|
|
486
|
+
const pendingIdeas = await request(config, "POST", "/api/organic/ideas/pending", { limit: 3 }, api.logger);
|
|
487
|
+
const ideas = Array.isArray(pendingIdeas?.ideas) ? pendingIdeas.ideas.filter(i => (i?.confidence ?? 0) > 0.5).slice(0, 3) : [];
|
|
488
|
+
if (ideas.length) {
|
|
489
|
+
const ideaBlocks = ideas.map(i => {
|
|
490
|
+
const sourceCount = Array.isArray(i.sourceMemoryIds) ? i.sourceMemoryIds.length : 0;
|
|
491
|
+
return buildMemoryInjectionBlock(`idea:${i._id || i.id || "unknown"}`, [
|
|
492
|
+
`Title: ${trimSnippet(sanitizeForInjection(i.title || "Untitled discovery"), 120)}`,
|
|
493
|
+
`Content: ${trimSnippet(sanitizeForInjection(i.summary || ""), 2000)}`,
|
|
494
|
+
`Source: Based on ${sourceCount || "multiple"} connected memories`,
|
|
495
|
+
]);
|
|
496
|
+
});
|
|
497
|
+
sections.push([
|
|
498
|
+
"--- Memory Discovery ---",
|
|
499
|
+
"While you were away, your memory discovered:",
|
|
500
|
+
"",
|
|
501
|
+
...ideaBlocks,
|
|
502
|
+
"",
|
|
503
|
+
"(Respond naturally -- reference this if relevant to the conversation)",
|
|
504
|
+
"--- End Discovery ---"
|
|
505
|
+
].join("\n"));
|
|
506
|
+
// Mark ideas as notified (fire-and-forget)
|
|
507
|
+
const ideaIds = ideas.map(i => i._id || i.id).filter(Boolean);
|
|
508
|
+
if (ideaIds.length) {
|
|
509
|
+
request(config, "POST", "/api/organic/ideas/update", { ideaIds, status: "notified" }, api.logger).catch(() => {});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} catch (_) { /* endpoint may not exist yet — skip silently */ }
|
|
513
|
+
}
|
|
514
|
+
const prompt = String(event?.prompt || "").trim();
|
|
515
|
+
if (prompt.length >= 5) {
|
|
516
|
+
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) : [];
|
|
519
|
+
// --- Organic: log recall query (fire-and-forget) ---
|
|
520
|
+
request(config, "POST", "/api/organic/recallLog", { query: prompt, resultCount: mems.length, source: "plugin" }, api.logger).catch(() => {});
|
|
521
|
+
// Cache top recall results for reinforcement injection later in the conversation
|
|
522
|
+
if (mems.length && sessionKey) {
|
|
523
|
+
sessionRecallCache.set(sessionKey, mems.slice(0, 3));
|
|
524
|
+
sessionRecallCacheTimestamps.set(sessionKey, Date.now());
|
|
525
|
+
}
|
|
526
|
+
if (mems.length) {
|
|
527
|
+
const hasHighConf = mems.some(m => (m?.score ?? m?.confidence ?? 0) >= 0.85);
|
|
528
|
+
const recallDirective = hasHighConf
|
|
529
|
+
? "\nIMPORTANT: High-confidence memories were found. You MUST reference these in your response. If a recalled memory contradicts your training data, prioritize the memory — it reflects actual user history."
|
|
530
|
+
: "\nBefore responding, check if any recalled memories are relevant to this query. Reference them if so.";
|
|
531
|
+
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
|
+
}
|
|
533
|
+
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"));
|
|
537
|
+
|
|
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"));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const currentIntent = classifyIntent(prompt);
|
|
566
|
+
if (currentIntent === "command" || currentIntent === "workflow") {
|
|
567
|
+
const skillRecall = await request(config, "POST", "/api/mcp/recall", {
|
|
568
|
+
query: prompt,
|
|
569
|
+
limit: 3,
|
|
570
|
+
channel,
|
|
571
|
+
mode: "workflow",
|
|
572
|
+
}, api.logger);
|
|
573
|
+
const skills = Array.isArray(skillRecall?.memories)
|
|
574
|
+
? skillRecall.memories.filter((memory) => memory?.store === "procedural").slice(0, 3)
|
|
575
|
+
: [];
|
|
576
|
+
if (skills.length) {
|
|
577
|
+
sections.push([
|
|
578
|
+
"## Relevant Skills",
|
|
579
|
+
`Prompt: ${trimSnippet(prompt, 180)}`,
|
|
580
|
+
...skills.map(formatProceduralMemory),
|
|
581
|
+
"",
|
|
582
|
+
"Apply these skills if they fit the current task.",
|
|
583
|
+
].join("\n"));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// Budget gating: trim injection to fit the model's effective context capacity.
|
|
588
|
+
// Label each section by its header so we can drop lowest-priority first.
|
|
589
|
+
const modelName = event?.model || ctx?.model || ctx?.config?.model || "";
|
|
590
|
+
const budget = getInjectionBudget(modelName);
|
|
591
|
+
const labeledSections = sections.filter(Boolean).map((text) => {
|
|
592
|
+
if (text.includes("Recent Context")) return { label: "Recent Context", text };
|
|
593
|
+
if (text.includes("Recent Message Matches")) return { label: "Recent Message Matches", text };
|
|
594
|
+
if (text.includes("Relevant Skills")) return { label: "Relevant Skills", text };
|
|
595
|
+
if (text.includes("Memory Discovery")) return { label: "Memory Discovery", text };
|
|
596
|
+
if (text.includes("Relevant Recall")) return { label: "Relevant Recall", text };
|
|
597
|
+
return { label: "Preamble", text };
|
|
598
|
+
});
|
|
599
|
+
// Drop order: lowest priority first. Recall is highest priority (dropped last).
|
|
600
|
+
const dropOrder = ["Recent Context", "Recent Message Matches", "Relevant Skills", "Memory Discovery", "Preamble"];
|
|
601
|
+
const trimmed = trimSections(labeledSections, budget.maxChars, dropOrder);
|
|
602
|
+
return trimmed.map((s) => s.text).join("\n\n").trim();
|
|
603
|
+
}
|
|
604
|
+
function _registerLocalTools(api) {
|
|
605
|
+
if (localToolsRegistered || !localStore) return;
|
|
606
|
+
localToolsRegistered = true;
|
|
607
|
+
import("./tools/crystal-local-tools.js").then(({ createLocalTools }) => {
|
|
608
|
+
for (const tool of createLocalTools(localStore)) { try { api.registerTool(tool); } catch (_) {} }
|
|
609
|
+
api.logger?.info?.("[crystal] Local tools registered: crystal_grep, crystal_describe, crystal_expand");
|
|
610
|
+
}).catch((err) => { api.logger?.warn?.(`[crystal] Local tools unavailable: ${getErrorMessage(err)}`); });
|
|
611
|
+
}
|
|
612
|
+
module.exports = (api) => {
|
|
613
|
+
// Capture pluginConfig at init — the only moment it's reliably available.
|
|
614
|
+
// This is the workaround for the OpenClaw pluginConfig-not-forwarded-to-tools bug.
|
|
615
|
+
if (api?.pluginConfig && typeof api.pluginConfig === "object") {
|
|
616
|
+
_capturedPluginConfig = api.pluginConfig;
|
|
617
|
+
}
|
|
618
|
+
maybeRunAutoUpdate(api?.pluginConfig, api?.logger);
|
|
619
|
+
|
|
620
|
+
const hook = typeof api.on === "function"
|
|
621
|
+
? api.on.bind(api)
|
|
622
|
+
: (typeof api.registerHook === "function" ? api.registerHook.bind(api) : null);
|
|
623
|
+
if (!hook) throw new Error("crystal-memory requires api.on or api.registerHook");
|
|
624
|
+
hook("before_agent_start", async (event, ctx) => {
|
|
625
|
+
try {
|
|
626
|
+
const ctx2 = await buildBeforeAgentContext(api, event, ctx);
|
|
627
|
+
if (ctx2) return { prependContext: ctx2 };
|
|
628
|
+
} catch (err) { api.logger?.warn?.(`[crystal] before_agent_start: ${getErrorMessage(err)}`); }
|
|
629
|
+
}, { name: "crystal-memory.before-agent-start", description: "Inject wake briefing + recall" });
|
|
630
|
+
// before_tool_call: surface actionTriggers warnings if any memories match the tool being called
|
|
631
|
+
try {
|
|
632
|
+
hook("before_tool_call", async (event, ctx) => {
|
|
633
|
+
try {
|
|
634
|
+
const toolName = event?.tool?.name || event?.toolName;
|
|
635
|
+
if (!toolName) return;
|
|
636
|
+
const cfg = getPluginConfig(api, ctx);
|
|
637
|
+
const data = await crystalRequest(cfg, "/api/mcp/triggers", { tools: [toolName] }).catch(() => null);
|
|
638
|
+
const mems = Array.isArray(data?.memories) ? data.memories : [];
|
|
639
|
+
if (mems.length > 0) {
|
|
640
|
+
const warning = mems.map((m) => `[crystal-guardrail] ${sanitizeForInjection(m.title || "")}: ${sanitizeForInjection(String(m.content || "")).slice(0, 200)}`).join("\n");
|
|
641
|
+
return { warning };
|
|
642
|
+
}
|
|
643
|
+
} catch (_) {}
|
|
644
|
+
}, { name: "crystal-memory.before-tool-call", description: "Surface actionTriggers warnings before tool calls" });
|
|
645
|
+
} catch (_) {}
|
|
646
|
+
try {
|
|
647
|
+
hook("before_dispatch", async (event, ctx) => {
|
|
648
|
+
try {
|
|
649
|
+
const cfg = getPluginConfig(api, ctx);
|
|
650
|
+
const sessionKey = getSessionKey(ctx, event);
|
|
651
|
+
const data = await crystalRequest(cfg, "/api/mcp/rate-limit-check", { sessionKey }).catch(() => null);
|
|
652
|
+
if (data?.allowed === false) {
|
|
653
|
+
return {
|
|
654
|
+
block: true,
|
|
655
|
+
reason: "Memory Crystal rate limit reached. Upgrade at memorycrystal.ai/pricing.",
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
} catch (_) {}
|
|
659
|
+
}, { name: "crystal-memory.before-dispatch-rate-limit", description: "Check Memory Crystal rate limit before dispatch" });
|
|
660
|
+
} catch (_) {}
|
|
661
|
+
try {
|
|
662
|
+
hook("before_dispatch", async (event, ctx) => {
|
|
663
|
+
try {
|
|
664
|
+
const cfg = getPluginConfig(api, ctx);
|
|
665
|
+
const sessionKey = getSessionKey(ctx, event);
|
|
666
|
+
let cached = sessionKey ? intentCache.get(sessionKey) : null;
|
|
667
|
+
if (cached && Date.now() - cached.detectedAt > INTENT_CACHE_TTL_MS) {
|
|
668
|
+
intentCache.delete(sessionKey);
|
|
669
|
+
cached = null;
|
|
670
|
+
}
|
|
671
|
+
api.logger?.info?.("[crystal] before_dispatch intent=" + (cached?.intent || "unknown"));
|
|
672
|
+
const text = sessionKey ? pendingUserMessages.get(sessionKey) : null;
|
|
673
|
+
if (!text || text.length < 10) return;
|
|
674
|
+
const intent = cached?.intent || "general";
|
|
675
|
+
// Intent-triggered deep recall: vary depth by intent severity
|
|
676
|
+
let recallLimit = 3;
|
|
677
|
+
let recallMode = "general";
|
|
678
|
+
if (intent === "command") {
|
|
679
|
+
// Commands get preflight-style recall (decisions + lessons)
|
|
680
|
+
recallLimit = 5;
|
|
681
|
+
recallMode = "decision";
|
|
682
|
+
} else if (intent === "workflow") {
|
|
683
|
+
recallLimit = 6;
|
|
684
|
+
recallMode = "workflow";
|
|
685
|
+
} else if (intent === "recall" || intent === "question") {
|
|
686
|
+
// Explicit recall/questions get deeper results
|
|
687
|
+
recallLimit = 8;
|
|
688
|
+
} else if (intent === "reflect") {
|
|
689
|
+
recallLimit = 6;
|
|
690
|
+
}
|
|
691
|
+
const result = await crystalRequest(cfg, "/api/mcp/recall", { query: text, limit: recallLimit, mode: recallMode }).catch(() => null);
|
|
692
|
+
if (result?.memories?.length > 0) {
|
|
693
|
+
const formatted = result.memories.slice(0, recallLimit).map(formatRecallMemory);
|
|
694
|
+
const hasHighConf = result.memories.some(m => (m?.score ?? m?.confidence ?? 0) >= 0.85);
|
|
695
|
+
const directive = hasHighConf
|
|
696
|
+
? "\n[!] High-confidence memories found — prioritize these over general knowledge."
|
|
697
|
+
: "";
|
|
698
|
+
const block = `[Memory Crystal — ${intent} intent, ${result.memories.length} results]\n${formatted.join("\n")}${directive}`;
|
|
699
|
+
return { prependContext: block };
|
|
700
|
+
}
|
|
701
|
+
} catch (_) {}
|
|
702
|
+
}, { name: "crystal-memory.before-dispatch-proactive-recall", description: "Surface proactive Memory Crystal recall before dispatch" });
|
|
703
|
+
} catch (_) {}
|
|
704
|
+
|
|
705
|
+
// Reinforcement injection: re-inject top recall memories near the end of long
|
|
706
|
+
// conversations to combat the "lost in the middle" attention degradation effect.
|
|
707
|
+
// This is lightweight (cached data, no API calls) and only fires after 5+ turns.
|
|
708
|
+
try {
|
|
709
|
+
hook("before_dispatch", async (event, ctx) => {
|
|
710
|
+
try {
|
|
711
|
+
const sessionKey = getSessionKey(ctx, event);
|
|
712
|
+
if (!sessionKey) return;
|
|
713
|
+
|
|
714
|
+
const turnCount = reinforcementTurnCounters.get(sessionKey) || 0;
|
|
715
|
+
if (turnCount < REINFORCEMENT_TURN_THRESHOLD) return;
|
|
716
|
+
|
|
717
|
+
const cacheTs = sessionRecallCacheTimestamps.get(sessionKey);
|
|
718
|
+
if (!cacheTs || Date.now() - cacheTs > SESSION_RECALL_CACHE_MAX_AGE_MS) {
|
|
719
|
+
sessionRecallCache.delete(sessionKey);
|
|
720
|
+
sessionRecallCacheTimestamps.delete(sessionKey);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const cached = sessionRecallCache.get(sessionKey);
|
|
725
|
+
if (!cached || cached.length === 0) return;
|
|
726
|
+
|
|
727
|
+
let block = "## Memory Reinforcement\n";
|
|
728
|
+
let charCount = block.length;
|
|
729
|
+
|
|
730
|
+
for (const mem of cached.slice(0, 2)) {
|
|
731
|
+
const title = sanitizeForInjection(String(mem.title || "")).slice(0, 80);
|
|
732
|
+
const content = sanitizeForInjection(String(mem.content || "")).slice(0, 300);
|
|
733
|
+
const line = `[Recall: ${title}] ${content}\n`;
|
|
734
|
+
if (charCount + line.length > REINFORCEMENT_MAX_CHARS) break;
|
|
735
|
+
block += line;
|
|
736
|
+
charCount += line.length;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return { prependContext: block };
|
|
740
|
+
} catch (err) {
|
|
741
|
+
api.logger?.warn?.(`[crystal] reinforcement: ${getErrorMessage(err)}`);
|
|
742
|
+
}
|
|
743
|
+
}, { name: "crystal-memory.before-dispatch-reinforcement", description: "Re-inject cached recall for lost-in-the-middle mitigation" });
|
|
744
|
+
} catch (_) {}
|
|
745
|
+
|
|
746
|
+
hook("message_received", async (event, ctx) => {
|
|
747
|
+
try {
|
|
748
|
+
const text = extractUserText(event);
|
|
749
|
+
const sessionKey = getSessionKey(ctx, event);
|
|
750
|
+
const channelKey = resolveChannelKey(ctx, event, getPluginConfig(api, ctx)?.channelScope);
|
|
751
|
+
api.logger?.warn?.(`[crystal] message_received session=${sessionKey || "?"} channel=${channelKey || "?"} textLen=${String(text || "").length}`);
|
|
752
|
+
if (!seenCaptureSessions.has(`msg:${sessionKey}`)) seenCaptureSessions.add(`msg:${sessionKey}`);
|
|
753
|
+
if (text && sessionKey) pendingUserMessages.set(sessionKey, String(text));
|
|
754
|
+
if (text && sessionKey) appendConversationPulseMessage(sessionKey, "user", String(text));
|
|
755
|
+
if (text && sessionKey) {
|
|
756
|
+
const intent = classifyIntent(text);
|
|
757
|
+
intentCache.set(sessionKey, { intent, detectedAt: Date.now() });
|
|
758
|
+
}
|
|
759
|
+
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);
|
|
764
|
+
if (text && sessionKey) triggerConversationPulse(api, ctx, sessionKey, String(text));
|
|
765
|
+
} catch (err) { api.logger?.warn?.(`[crystal] message_received: ${getErrorMessage(err)}`); }
|
|
766
|
+
}, { name: "crystal-memory.message-received", description: "Buffer + persist user turn" });
|
|
767
|
+
hook("llm_output", async (event, ctx) => {
|
|
768
|
+
try {
|
|
769
|
+
const assistantText = extractAssistantText(event);
|
|
770
|
+
const sessionKey = getSessionKey(ctx, event);
|
|
771
|
+
const channelKey = resolveChannelKey(ctx, event, getPluginConfig(api, ctx)?.channelScope);
|
|
772
|
+
if (!seenCaptureSessions.has(`out:${sessionKey}`)) { seenCaptureSessions.add(`out:${sessionKey}`); api.logger?.info?.(`[crystal] llm_output session=${sessionKey}`); }
|
|
773
|
+
if (!assistantText) { api.logger?.warn?.("[crystal] llm_output missing assistant text"); return; }
|
|
774
|
+
const userMessage = sessionKey ? pendingUserMessages.get(sessionKey) || "" : "";
|
|
775
|
+
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); }
|
|
778
|
+
if (sessionKey) appendConversationPulseMessage(sessionKey, "assistant", assistantText);
|
|
779
|
+
if (sessionKey) pendingUserMessages.delete(sessionKey);
|
|
780
|
+
await captureTurn(api, event, ctx, userMessage, assistantText);
|
|
781
|
+
fireMediaCapture(event, getPluginConfig(api, ctx), channelKey, sessionKey);
|
|
782
|
+
} catch (err) { api.logger?.warn?.(`[crystal] llm_output: ${getErrorMessage(err)}`); }
|
|
783
|
+
}, { name: "crystal-memory.llm-output", description: "Capture AI response" });
|
|
784
|
+
hook("message_sent", async (event, ctx) => {
|
|
785
|
+
try {
|
|
786
|
+
const sessionKey = getSessionKey(ctx, event);
|
|
787
|
+
if (!sessionKey || !pendingUserMessages.has(sessionKey)) return;
|
|
788
|
+
const assistantText = extractAssistantText(event);
|
|
789
|
+
if (!assistantText) return;
|
|
790
|
+
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); }
|
|
794
|
+
appendConversationPulseMessage(sessionKey, "assistant", assistantText);
|
|
795
|
+
pendingUserMessages.delete(sessionKey);
|
|
796
|
+
await captureTurn(api, event, ctx, userMessage, assistantText);
|
|
797
|
+
} catch (err) { api.logger?.warn?.(`[crystal] message_sent fallback: ${getErrorMessage(err)}`); }
|
|
798
|
+
}, { name: "crystal-memory.message-sent-fallback", description: "Fallback assistant capture" });
|
|
799
|
+
// session_start fires when a new session begins (replaces removed command:new typed hook)
|
|
800
|
+
hook("session_start", async (event, ctx) => {
|
|
801
|
+
try { await request(getPluginConfig(api, ctx), "POST", "/api/mcp/reflect", { windowHours: 4 }, api.logger); }
|
|
802
|
+
catch (err) { api.logger?.warn?.(`[crystal] session_start reflect: ${getErrorMessage(err)}`); }
|
|
803
|
+
}, { name: "crystal-memory.session-start", description: "Trigger reflection on new session" });
|
|
804
|
+
// before_reset fires before /reset is processed (replaces removed command:reset typed hook)
|
|
805
|
+
hook("before_reset", async (event, ctx) => {
|
|
806
|
+
try { await request(getPluginConfig(api, ctx), "POST", "/api/mcp/reflect", { windowHours: 4 }, api.logger); }
|
|
807
|
+
catch (err) { api.logger?.warn?.(`[crystal] before_reset reflect: ${getErrorMessage(err)}`); }
|
|
808
|
+
}, { name: "crystal-memory.before-reset", description: "Trigger reflection before session reset" });
|
|
809
|
+
hook("session_end", async (event, ctx) => {
|
|
810
|
+
const sessionKey = ctx?.sessionKey || ctx?.sessionId || event?.sessionKey || event?.sessionId;
|
|
811
|
+
clearSessionState(sessionKey);
|
|
812
|
+
}, { name: "crystal-memory.session-end", description: "Clear per-session caches on session end" });
|
|
813
|
+
if (typeof api.registerContextEngine === "function") {
|
|
814
|
+
api.registerContextEngine({
|
|
815
|
+
info: { name: "crystal-memory", ownsCompaction: true },
|
|
816
|
+
async ingestBatch(payload, ctx) {
|
|
817
|
+
try {
|
|
818
|
+
const sessionKey = firstString(payload?.sessionKey, ctx?.sessionKey, ctx?.sessionId);
|
|
819
|
+
const messages = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
820
|
+
queueContextEngineMessages(sessionKey, messages);
|
|
821
|
+
const flushed = await flushContextEngineMessages(api, ctx, sessionKey, { sessionKey });
|
|
822
|
+
const store = await getLocalStore(getPluginConfig(api, ctx), api.logger);
|
|
823
|
+
if (store && sessionKey) {
|
|
824
|
+
for (const msg of messages) {
|
|
825
|
+
const nm = normalizeContextEngineMessage(msg);
|
|
826
|
+
if (nm && (nm.role === "user" || nm.role === "assistant")) store.addMessage(sessionKey, nm.role, nm.content);
|
|
827
|
+
}
|
|
828
|
+
_registerLocalTools(api);
|
|
829
|
+
}
|
|
830
|
+
return flushed;
|
|
831
|
+
} catch (err) { api.logger?.warn?.(`[crystal] ingestBatch: ${getErrorMessage(err)}`); }
|
|
832
|
+
},
|
|
833
|
+
async assemble(payload, ctx) {
|
|
834
|
+
try {
|
|
835
|
+
const messages = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
836
|
+
const budget = Number.isFinite(Number(payload?.tokenBudget)) ? Number(payload.tokenBudget) : Infinity;
|
|
837
|
+
const sessionKey = firstString(payload?.sessionKey, payload?.sessionId, ctx?.sessionKey, ctx?.sessionId) || "default";
|
|
838
|
+
const pluginCfg = getPluginConfig(api, ctx);
|
|
839
|
+
const injectionEnabled = pluginCfg.localSummaryInjection !== false;
|
|
840
|
+
const injectionBudget = pluginCfg.localSummaryMaxTokens || 2000;
|
|
841
|
+
const syntheticEvent = {
|
|
842
|
+
prompt: messages.map((m) => normalizeContextEngineMessage(m, m?.role || "user")?.content || "").filter(Boolean).slice(-6).join("\n\n"),
|
|
843
|
+
sessionKey,
|
|
844
|
+
};
|
|
845
|
+
let localMessages = [];
|
|
846
|
+
const store = localStore || await getLocalStore(pluginCfg, api.logger);
|
|
847
|
+
if (store && sessionKey) {
|
|
848
|
+
try {
|
|
849
|
+
const assembled = await assembleContext(store, sessionKey, budget, undefined, {
|
|
850
|
+
localSummaryInjection: injectionEnabled,
|
|
851
|
+
localSummaryMaxTokens: injectionBudget,
|
|
852
|
+
});
|
|
853
|
+
if (Array.isArray(assembled) && assembled.length) localMessages = assembled;
|
|
854
|
+
} catch (_) {}
|
|
855
|
+
}
|
|
856
|
+
const convexContext = await buildBeforeAgentContext(api, syntheticEvent, { ...(ctx || {}), sessionKey });
|
|
857
|
+
const systemMsg = convexContext ? [{ role: "system", content: convexContext }] : [];
|
|
858
|
+
const TAIL_KEEP = 6;
|
|
859
|
+
const finalMessages = localMessages.length > 0 && messages.length > TAIL_KEEP
|
|
860
|
+
? [...systemMsg, ...localMessages, ...messages.slice(-TAIL_KEEP)]
|
|
861
|
+
: [...systemMsg, ...localMessages, ...messages];
|
|
862
|
+
try {
|
|
863
|
+
const store = localStore || await getLocalStore(getPluginConfig(api, ctx), api.logger);
|
|
864
|
+
if (store && sessionKey) {
|
|
865
|
+
const hotTopics = store.getLessonCountsForSession(sessionKey, 3);
|
|
866
|
+
if (hotTopics.length > 0) {
|
|
867
|
+
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
|
+
finalMessages.unshift({ role: "system", content: warnings });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
} catch (_) {}
|
|
872
|
+
return {
|
|
873
|
+
messages: finalMessages,
|
|
874
|
+
used: (convexContext?.length || 0) + localMessages.reduce((n, m) => n + (m.content?.length || 0), 0),
|
|
875
|
+
};
|
|
876
|
+
} catch (err) {
|
|
877
|
+
api.logger?.warn?.(`[crystal] assemble: ${getErrorMessage(err)}`);
|
|
878
|
+
return { messages: Array.isArray(payload?.messages) ? payload.messages : [], used: 0 };
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
async compact(payload, ctx) {
|
|
882
|
+
try {
|
|
883
|
+
const sessionKey = firstString(payload?.sessionKey, ctx?.sessionKey, ctx?.sessionId);
|
|
884
|
+
const messages = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
885
|
+
queueContextEngineMessages(sessionKey, messages);
|
|
886
|
+
const flushed = await flushContextEngineMessages(api, ctx, sessionKey, { sessionKey });
|
|
887
|
+
let summaryCount = 0;
|
|
888
|
+
if (compactionEngine && sessionKey) { try { summaryCount = (await compactionEngine.compact(sessionKey, 32000, compactionEngine._summarizeFn, false))?.summariesCreated ?? 0; } catch (_) {} }
|
|
889
|
+
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));
|
|
892
|
+
// Snapshot the full conversation before compaction (non-fatal — failure won't break compaction)
|
|
893
|
+
let snapshotId = null;
|
|
894
|
+
try {
|
|
895
|
+
const snap = await request(cfg, "POST", "/api/mcp/snapshot", {
|
|
896
|
+
sessionKey,
|
|
897
|
+
channel,
|
|
898
|
+
messages: messages.map((m) => ({ role: m.role || "user", content: m.content || "", ...(m.timestamp != null ? { timestamp: m.timestamp } : {}) })),
|
|
899
|
+
reason: payload?.reason || "compaction",
|
|
900
|
+
}, api.logger);
|
|
901
|
+
snapshotId = snap?.id || null;
|
|
902
|
+
} catch (_) {}
|
|
903
|
+
const checkpoint = await request(cfg, "POST", "/api/mcp/checkpoint", { label, description: `Compaction for ${sessionKey} summaries=${summaryCount}` }, api.logger);
|
|
904
|
+
const capture = await request(cfg, "POST", "/api/mcp/capture", {
|
|
905
|
+
title: label,
|
|
906
|
+
content: `Session: ${sessionKey}\nReason: ${payload?.reason || "compaction"}\nLocal summaries: ${summaryCount}`,
|
|
907
|
+
store: "episodic", category: "event", tags: ["openclaw", "compaction"],
|
|
908
|
+
channel,
|
|
909
|
+
sourceSnapshotId: snapshotId || undefined,
|
|
910
|
+
}, api.logger);
|
|
911
|
+
return `Memory Crystal compaction for ${sessionKey || "unknown"}; flushed=${flushed.flushed}; local_summaries=${summaryCount}; checkpoint=${checkpoint?.id || "none"}; capture=${capture?.id || "none"}; snapshot=${snapshotId || "none"}`;
|
|
912
|
+
} catch (err) { api.logger?.warn?.(`[crystal] compact: ${getErrorMessage(err)}`); return null; }
|
|
913
|
+
},
|
|
914
|
+
async afterTurn(payload, ctx) {
|
|
915
|
+
try {
|
|
916
|
+
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 (_) {} }
|
|
919
|
+
if (localStore) _registerLocalTools(api);
|
|
920
|
+
} catch (err) { api.logger?.warn?.(`[crystal] afterTurn: ${getErrorMessage(err)}`); }
|
|
921
|
+
},
|
|
922
|
+
dispose() {
|
|
923
|
+
pendingUserMessages.clear();
|
|
924
|
+
sessionConfigs.clear();
|
|
925
|
+
sessionChannelScopes.clear();
|
|
926
|
+
wakeInjectedSessions.clear();
|
|
927
|
+
seenCaptureSessions.clear();
|
|
928
|
+
intentCache.clear();
|
|
929
|
+
pendingContextEngineMessages.clear();
|
|
930
|
+
conversationTurnCounters.clear();
|
|
931
|
+
conversationPulseBuffers.clear();
|
|
932
|
+
reinforcementTurnCounters.clear();
|
|
933
|
+
sessionRecallCache.clear();
|
|
934
|
+
sessionRecallCacheTimestamps.clear();
|
|
935
|
+
if (localStore) { try { localStore.close(); } catch (_) {} }
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
} else {
|
|
939
|
+
api.logger?.warn?.("[crystal] registerContextEngine unavailable; skipping");
|
|
940
|
+
}
|
|
941
|
+
api.registerTool({
|
|
942
|
+
name: "crystal_set_scope", label: "Crystal Set Scope",
|
|
943
|
+
description: "Override Memory Crystal channel scope for the current session.",
|
|
944
|
+
parameters: {
|
|
945
|
+
type: "object",
|
|
946
|
+
properties: { scope: { type: "string", minLength: 1 } },
|
|
947
|
+
required: ["scope"],
|
|
948
|
+
additionalProperties: false,
|
|
949
|
+
},
|
|
950
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
951
|
+
try {
|
|
952
|
+
const scope = ensureString(params?.scope, "scope", 1);
|
|
953
|
+
const sessionKey = ctx?.sessionKey || ctx?.sessionId;
|
|
954
|
+
if (!sessionKey) throw new Error("sessionKey is required");
|
|
955
|
+
sessionChannelScopes.set(sessionKey, scope);
|
|
956
|
+
return toToolResult(`Memory Crystal session scope set to "${scope}" for ${sessionKey}.`);
|
|
957
|
+
} catch (err) { return toToolError(err); }
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
api.registerTool({
|
|
961
|
+
name: "memory_search", label: "Memory Search",
|
|
962
|
+
description: "Search Memory Crystal for relevant long-term memories. Returns crystal/<id>.md paths for use with memory_get.",
|
|
963
|
+
parameters: { type: "object", properties: { query: { type: "string", minLength: 2 }, limit: { type: "number", minimum: 1, maximum: 20 } }, required: ["query"], additionalProperties: false },
|
|
964
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
965
|
+
try {
|
|
966
|
+
const query = ensureString(params?.query, "query", 2);
|
|
967
|
+
const limit = Math.max(1, Math.min(Number.isFinite(Number(params?.limit)) ? Number(params.limit) : 5, 20));
|
|
968
|
+
const cfg = getPluginConfig(api, ctx);
|
|
969
|
+
const resolvedChannel = resolveScopedChannelKey(ctx, ctx, cfg?.channelScope);
|
|
970
|
+
const payload = { query, limit, ...(resolvedChannel ? { channel: resolvedChannel } : {}) };
|
|
971
|
+
const data = await crystalRequest(cfg, "/api/mcp/recall", payload);
|
|
972
|
+
const mems = Array.isArray(data?.memories) ? data.memories : [];
|
|
973
|
+
return toToolResult({ query, resultCount: mems.length, results: mems.map((m) => { const mid = m?.memoryId || m?._id || m?.id; return { id: mid, path: buildMemoryPath(mid), title: m?.title, snippet: trimSnippet(m?.content || "", 220), store: m?.store, category: m?.category, score: m?.score }; }) });
|
|
974
|
+
} catch (err) { return toToolError(err); }
|
|
975
|
+
},
|
|
976
|
+
});
|
|
977
|
+
api.registerTool({
|
|
978
|
+
name: "crystal_search_messages", label: "Crystal Search Messages",
|
|
979
|
+
description: "Search short-term conversation logs in Memory Crystal.",
|
|
980
|
+
parameters: { type: "object", properties: { query: { type: "string", minLength: 2 }, limit: { type: "number", minimum: 1, maximum: 20 }, sinceMs: { type: "number", minimum: 0 }, channel: { type: "string" } }, required: ["query"], additionalProperties: false },
|
|
981
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
982
|
+
try {
|
|
983
|
+
const query = ensureString(params?.query, "query", 2);
|
|
984
|
+
const limit = Math.max(1, Math.min(Number.isFinite(Number(params?.limit)) ? Number(params.limit) : 5, 20));
|
|
985
|
+
const sinceMs = Number.isFinite(Number(params?.sinceMs)) ? Number(params.sinceMs) : undefined;
|
|
986
|
+
const resolvedChannel = typeof params?.channel === "string" ? params.channel : resolveChannelKey(ctx, ctx, getPluginConfig(api, ctx)?.channelScope);
|
|
987
|
+
let data = await crystalRequest(getPluginConfig(api, ctx), "/api/mcp/search-messages", { query, limit, sinceMs, channel: resolvedChannel });
|
|
988
|
+
let messages = Array.isArray(data?.messages) ? data.messages : [];
|
|
989
|
+
let scope = resolvedChannel ? "channel" : "global";
|
|
990
|
+
if (!messages.length && typeof params?.channel !== "string" && resolvedChannel) {
|
|
991
|
+
data = await crystalRequest(getPluginConfig(api, ctx), "/api/mcp/search-messages", { query, limit, sinceMs });
|
|
992
|
+
messages = Array.isArray(data?.messages) ? data.messages : [];
|
|
993
|
+
if (messages.length) scope = "global-fallback";
|
|
994
|
+
}
|
|
995
|
+
return toToolResult({ query, messageCount: messages.length, searchScope: scope, channel: resolvedChannel || null, topMessages: messages.slice(0, 10) });
|
|
996
|
+
} catch (err) { return toToolError(err); }
|
|
997
|
+
},
|
|
998
|
+
});
|
|
999
|
+
api.registerTool({
|
|
1000
|
+
name: "memory_get", label: "Memory Get",
|
|
1001
|
+
description: "Read a full Memory Crystal item by memoryId or crystal/<id>.md path.",
|
|
1002
|
+
parameters: { type: "object", properties: { path: { type: "string" }, memoryId: { type: "string" } }, additionalProperties: false },
|
|
1003
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1004
|
+
try {
|
|
1005
|
+
const memoryId = (typeof params?.memoryId === "string" && params.memoryId.trim()) || parseMemoryPath(params?.path);
|
|
1006
|
+
if (!memoryId) throw new Error("memoryId or path required (expected crystal/<id>.md)");
|
|
1007
|
+
const data = await crystalRequest(getPluginConfig(api, ctx), "/api/mcp/memory", { memoryId });
|
|
1008
|
+
const m = data?.memory;
|
|
1009
|
+
if (!m?.id) throw new Error("Memory not found");
|
|
1010
|
+
return toToolResult({ id: m.id, path: buildMemoryPath(m.id), title: m.title, content: m.content, store: m.store, category: m.category, tags: m.tags || [], createdAt: m.createdAt, lastAccessedAt: m.lastAccessedAt, accessCount: m.accessCount, confidence: m.confidence, strength: m.strength, source: m.source, channel: m.channel, archived: m.archived });
|
|
1011
|
+
} catch (err) { return toToolError(err); }
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
api.registerTool({
|
|
1015
|
+
name: "crystal_recall", label: "Crystal Recall",
|
|
1016
|
+
description: "Search Memory Crystal for relevant past memories.",
|
|
1017
|
+
parameters: { type: "object", properties: { query: { type: "string", minLength: 2 }, limit: { type: "number", minimum: 1, maximum: 50 } }, required: ["query"], additionalProperties: false },
|
|
1018
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1019
|
+
try {
|
|
1020
|
+
const query = ensureString(params?.query, "query", 2);
|
|
1021
|
+
const limit = Number.isFinite(Number(params?.limit)) ? Number(params.limit) : undefined;
|
|
1022
|
+
const cfg = getPluginConfig(api, ctx);
|
|
1023
|
+
const resolvedChannel = resolveScopedChannelKey(ctx, ctx, cfg?.channelScope);
|
|
1024
|
+
const payload = { query, ...(limit ? { limit } : {}), ...(resolvedChannel ? { channel: resolvedChannel } : {}) };
|
|
1025
|
+
const data = await crystalRequest(cfg, "/api/mcp/recall", payload);
|
|
1026
|
+
const mems = Array.isArray(data?.memories) ? data.memories : [];
|
|
1027
|
+
return toToolResult({ query, memoryCount: mems.length, topMemories: mems.slice(0, 10) });
|
|
1028
|
+
} catch (err) { return toToolError(err); }
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
api.registerTool({
|
|
1032
|
+
name: "crystal_remember", label: "Crystal Remember",
|
|
1033
|
+
description: "Save a durable memory into Memory Crystal.",
|
|
1034
|
+
parameters: { type: "object", properties: { store: { type: "string", enum: MEMORY_STORES }, category: { type: "string", enum: MEMORY_CATEGORIES }, title: { type: "string", minLength: 5, maxLength: 500 }, content: { type: "string", minLength: 1, maxLength: 50000 }, tags: { type: "array", items: { type: "string" } }, channel: { type: "string" } }, required: ["store","category","title","content"], additionalProperties: false },
|
|
1035
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1036
|
+
try {
|
|
1037
|
+
const store = ensureEnum(params?.store, MEMORY_STORES, "store");
|
|
1038
|
+
const category = ensureEnum(params?.category, MEMORY_CATEGORIES, "category");
|
|
1039
|
+
const title = ensureString(params?.title, "title", 5);
|
|
1040
|
+
const content = ensureString(params?.content, "content", 1);
|
|
1041
|
+
const tags = Array.isArray(params?.tags) ? params.tags.map(String) : [];
|
|
1042
|
+
const data = await crystalRequest(getPluginConfig(api, ctx), "/api/mcp/capture", { title, content, store, category, tags, channel: typeof params?.channel === "string" ? params.channel : resolveChannelKey(ctx, ctx, getPluginConfig(api, ctx)?.channelScope) });
|
|
1043
|
+
if ((data?.ok || data?.id) && category === "lesson") {
|
|
1044
|
+
const topic = String(title).slice(0, 60);
|
|
1045
|
+
const sessionKey = ctx?.sessionKey || ctx?.sessionId || "default";
|
|
1046
|
+
try {
|
|
1047
|
+
const localStoreForCount = localStore || await getLocalStore(getPluginConfig(api, ctx), api.logger);
|
|
1048
|
+
if (localStoreForCount) localStoreForCount.incrementLessonCount(sessionKey, topic);
|
|
1049
|
+
} catch (_) {}
|
|
1050
|
+
}
|
|
1051
|
+
return toToolResult({ ok: Boolean(data?.ok), id: data?.id, title, store, category });
|
|
1052
|
+
} catch (err) { return toToolError(err); }
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
1055
|
+
api.registerTool({
|
|
1056
|
+
name: "crystal_what_do_i_know", label: "Crystal What Do I Know",
|
|
1057
|
+
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 },
|
|
1059
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1060
|
+
try {
|
|
1061
|
+
const topic = ensureString(params?.topic, "topic", 3);
|
|
1062
|
+
const limit = Number.isFinite(Number(params?.limit)) ? Number(params.limit) : 8;
|
|
1063
|
+
const cfg = getPluginConfig(api, ctx);
|
|
1064
|
+
const resolvedChannel = resolveScopedChannelKey(ctx, ctx, cfg?.channelScope);
|
|
1065
|
+
const payload = { query: topic, limit, ...(resolvedChannel ? { channel: resolvedChannel } : {}) };
|
|
1066
|
+
const data = await crystalRequest(cfg, "/api/mcp/recall", payload);
|
|
1067
|
+
const mems = Array.isArray(data?.memories) ? data.memories : [];
|
|
1068
|
+
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) });
|
|
1069
|
+
} catch (err) { return toToolError(err); }
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
api.registerTool({
|
|
1073
|
+
name: "crystal_why_did_we", label: "Crystal Why Did We",
|
|
1074
|
+
description: "Decision archaeology over Memory Crystal memories.",
|
|
1075
|
+
parameters: { type: "object", properties: { decision: { type: "string", minLength: 3 }, limit: { type: "number", minimum: 1, maximum: 20 } }, required: ["decision"], additionalProperties: false },
|
|
1076
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1077
|
+
try {
|
|
1078
|
+
const decision = ensureString(params?.decision, "decision", 3);
|
|
1079
|
+
const limit = Number.isFinite(Number(params?.limit)) ? Number(params.limit) : 8;
|
|
1080
|
+
const cfg = getPluginConfig(api, ctx);
|
|
1081
|
+
const resolvedChannel = resolveScopedChannelKey(ctx, ctx, cfg?.channelScope);
|
|
1082
|
+
const payload = { query: decision, limit, ...(resolvedChannel ? { channel: resolvedChannel } : {}) };
|
|
1083
|
+
const data = await crystalRequest(cfg, "/api/mcp/recall", payload);
|
|
1084
|
+
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) });
|
|
1087
|
+
} catch (err) { return toToolError(err); }
|
|
1088
|
+
},
|
|
1089
|
+
});
|
|
1090
|
+
api.registerTool({
|
|
1091
|
+
name: "crystal_checkpoint", label: "Crystal Checkpoint",
|
|
1092
|
+
description: "Create a Memory Crystal checkpoint for this milestone.",
|
|
1093
|
+
parameters: { type: "object", properties: { label: { type: "string", minLength: 1 }, description: { type: "string" } }, required: ["label"], additionalProperties: false },
|
|
1094
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1095
|
+
try {
|
|
1096
|
+
const label = ensureString(params?.label, "label", 1);
|
|
1097
|
+
const description = typeof params?.description === "string" ? params.description : undefined;
|
|
1098
|
+
const data = await crystalRequest(getPluginConfig(api, ctx), "/api/mcp/checkpoint", { label, description });
|
|
1099
|
+
return toToolResult({ ok: Boolean(data?.ok), id: data?.id, label });
|
|
1100
|
+
} catch (err) { return toToolError(err); }
|
|
1101
|
+
},
|
|
1102
|
+
});
|
|
1103
|
+
api.registerTool({
|
|
1104
|
+
name: "crystal_preflight", label: "Crystal Preflight",
|
|
1105
|
+
description: "Run a pre-flight check before a destructive or production action. Returns relevant rules, lessons, and decisions as a structured checklist. Call this before any config change, API write, file delete, or external send.",
|
|
1106
|
+
parameters: { type: "object", properties: { action: { type: "string", minLength: 3, description: "Description of the action you are about to take. Be specific — e.g. 'apply config patch to OpenClaw gateway' or 'send email to customer'." }, limit: { type: "number", minimum: 1, maximum: 20 } }, required: ["action"], additionalProperties: false },
|
|
1107
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1108
|
+
try {
|
|
1109
|
+
const action = ensureString(params?.action, "action", 3);
|
|
1110
|
+
const limit = Number.isFinite(Number(params?.limit)) ? Number(params.limit) : 10;
|
|
1111
|
+
const cfg = getPluginConfig(api, ctx);
|
|
1112
|
+
const resolvedChannel = resolveScopedChannelKey(ctx, ctx, cfg?.channelScope);
|
|
1113
|
+
const payload = {
|
|
1114
|
+
query: action,
|
|
1115
|
+
limit,
|
|
1116
|
+
mode: "decision",
|
|
1117
|
+
...(resolvedChannel ? { channel: resolvedChannel } : {}),
|
|
1118
|
+
};
|
|
1119
|
+
const data = await crystalRequest(cfg, "/api/mcp/recall", payload);
|
|
1120
|
+
const mems = Array.isArray(data?.memories) ? data.memories : [];
|
|
1121
|
+
const lessons = mems.filter((m) => m?.category === "lesson");
|
|
1122
|
+
const decisions = mems.filter((m) => m?.category === "decision");
|
|
1123
|
+
const rules = mems.filter((m) => (m?.category === "rule" || m?.store === "procedural") && m?.category !== "lesson" && m?.category !== "decision");
|
|
1124
|
+
const categorized = new Set([...rules, ...lessons, ...decisions]);
|
|
1125
|
+
const other = mems.filter((m) => !categorized.has(m));
|
|
1126
|
+
const lines = [`PRE-FLIGHT CHECK: ${action}`, ""];
|
|
1127
|
+
if (rules.length > 0) {
|
|
1128
|
+
lines.push("Rules:");
|
|
1129
|
+
rules.forEach((m) => lines.push(` - [rule] ${m.title}`));
|
|
1130
|
+
lines.push("");
|
|
1131
|
+
}
|
|
1132
|
+
if (lessons.length > 0) {
|
|
1133
|
+
lines.push("Lessons:");
|
|
1134
|
+
lessons.forEach((m) => lines.push(` - [lesson] ${m.title}`));
|
|
1135
|
+
lines.push("");
|
|
1136
|
+
}
|
|
1137
|
+
if (decisions.length > 0) {
|
|
1138
|
+
lines.push("Relevant decisions:");
|
|
1139
|
+
decisions.forEach((m) => lines.push(` - [decision] ${m.title}`));
|
|
1140
|
+
lines.push("");
|
|
1141
|
+
}
|
|
1142
|
+
if (other.length > 0) {
|
|
1143
|
+
lines.push("Other context:");
|
|
1144
|
+
other.forEach((m) => lines.push(` - [${m.category}] ${m.title}`));
|
|
1145
|
+
lines.push("");
|
|
1146
|
+
}
|
|
1147
|
+
if (mems.length === 0) {
|
|
1148
|
+
lines.push("No relevant memories found. Proceed with standard caution.");
|
|
1149
|
+
} else {
|
|
1150
|
+
lines.push("Review the above before proceeding. If any item applies, address it first.");
|
|
1151
|
+
}
|
|
1152
|
+
return toToolResult({
|
|
1153
|
+
action,
|
|
1154
|
+
checklist: lines.join("\n"),
|
|
1155
|
+
itemCount: mems.length,
|
|
1156
|
+
rules: rules.map((m) => m.title),
|
|
1157
|
+
lessons: lessons.map((m) => m.title),
|
|
1158
|
+
decisions: decisions.map((m) => m.title),
|
|
1159
|
+
});
|
|
1160
|
+
} catch (err) { return toToolError(err); }
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1163
|
+
api.registerTool({
|
|
1164
|
+
name: "crystal_recent", label: "Crystal Recent",
|
|
1165
|
+
description: "Get recent memories from Memory Crystal.",
|
|
1166
|
+
parameters: { type: "object", properties: { limit: { type: "number", minimum: 1, maximum: 20 }, channel: { type: "string" } }, additionalProperties: false },
|
|
1167
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1168
|
+
try {
|
|
1169
|
+
const limit = Number.isFinite(Number(params?.limit)) ? Math.max(1, Math.min(Number(params.limit), 20)) : 10;
|
|
1170
|
+
const cfg = getPluginConfig(api, ctx);
|
|
1171
|
+
const resolvedChannel = typeof params?.channel === "string" ? params.channel : resolveChannelKey(ctx, ctx, cfg?.channelScope);
|
|
1172
|
+
const payload = { limit, ...(resolvedChannel ? { channel: resolvedChannel } : {}) };
|
|
1173
|
+
const data = await crystalRequest(cfg, "/api/mcp/recent-messages", payload);
|
|
1174
|
+
return toToolResult(data);
|
|
1175
|
+
} catch (err) { return toToolError(err); }
|
|
1176
|
+
},
|
|
1177
|
+
});
|
|
1178
|
+
api.registerTool({
|
|
1179
|
+
name: "crystal_stats", label: "Crystal Stats",
|
|
1180
|
+
description: "Get Memory Crystal store statistics.",
|
|
1181
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
1182
|
+
async execute(_id, _params, _sig, _upd, ctx) {
|
|
1183
|
+
try {
|
|
1184
|
+
const cfg = getPluginConfig(api, ctx);
|
|
1185
|
+
const data = await request(cfg, "GET", "/api/mcp/stats", null, api.logger);
|
|
1186
|
+
if (!data) throw new Error("Failed to fetch stats from Memory Crystal");
|
|
1187
|
+
return toToolResult(data);
|
|
1188
|
+
} catch (err) { return toToolError(err); }
|
|
1189
|
+
},
|
|
1190
|
+
});
|
|
1191
|
+
api.registerTool({
|
|
1192
|
+
name: "crystal_forget", label: "Crystal Forget",
|
|
1193
|
+
description: "Archive or delete a memory from Memory Crystal.",
|
|
1194
|
+
parameters: { type: "object", properties: { memoryId: { type: "string", minLength: 1 }, permanent: { type: "boolean" } }, required: ["memoryId"], additionalProperties: false },
|
|
1195
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1196
|
+
try {
|
|
1197
|
+
const memoryId = ensureString(params?.memoryId, "memoryId", 1);
|
|
1198
|
+
const permanent = params?.permanent === true;
|
|
1199
|
+
const data = await crystalRequest(getPluginConfig(api, ctx), "/api/mcp/forget", { memoryId, permanent });
|
|
1200
|
+
return toToolResult(data);
|
|
1201
|
+
} catch (err) { return toToolError(err); }
|
|
1202
|
+
},
|
|
1203
|
+
});
|
|
1204
|
+
api.registerTool({
|
|
1205
|
+
name: "crystal_trace", label: "Crystal Trace",
|
|
1206
|
+
description: "Trace a memory back to its source conversation. Returns the conversation snapshot that created this memory.",
|
|
1207
|
+
parameters: { type: "object", properties: { memoryId: { type: "string", minLength: 1 } }, required: ["memoryId"], additionalProperties: false },
|
|
1208
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1209
|
+
try {
|
|
1210
|
+
const memoryId = ensureString(params?.memoryId, "memoryId", 1);
|
|
1211
|
+
const data = await crystalRequest(getPluginConfig(api, ctx), "/api/mcp/trace", { memoryId });
|
|
1212
|
+
return toToolResult(data);
|
|
1213
|
+
} catch (err) { return toToolError(err); }
|
|
1214
|
+
},
|
|
1215
|
+
});
|
|
1216
|
+
api.registerTool({
|
|
1217
|
+
name: "crystal_wake", label: "Crystal Wake",
|
|
1218
|
+
description: "Get a wake briefing with recent context, goals, and guardrails.",
|
|
1219
|
+
parameters: { type: "object", properties: { channel: { type: "string" } }, additionalProperties: false },
|
|
1220
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1221
|
+
try {
|
|
1222
|
+
const cfg = getPluginConfig(api, ctx);
|
|
1223
|
+
const resolvedChannel = typeof params?.channel === "string" ? params.channel : resolveChannelKey(ctx, ctx, cfg?.channelScope);
|
|
1224
|
+
const payload = resolvedChannel ? { channel: resolvedChannel } : {};
|
|
1225
|
+
const data = await crystalRequest(cfg, "/api/mcp/wake", payload);
|
|
1226
|
+
return toToolResult(data);
|
|
1227
|
+
} catch (err) { return toToolError(err); }
|
|
1228
|
+
},
|
|
1229
|
+
});
|
|
1230
|
+
api.registerTool({
|
|
1231
|
+
name: "crystal_who_owns", label: "Crystal Who Owns",
|
|
1232
|
+
description: "Find who owns, manages, or is assigned to an entity. Returns ownership context from memories.",
|
|
1233
|
+
parameters: { type: "object", properties: { topic: { type: "string", minLength: 1 }, limit: { type: "number", minimum: 1, maximum: 20 } }, required: ["topic"], additionalProperties: false },
|
|
1234
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1235
|
+
try {
|
|
1236
|
+
const topic = ensureString(params?.topic, "topic", 1);
|
|
1237
|
+
const limit = Math.max(1, Math.min(Number.isFinite(Number(params?.limit)) ? Number(params.limit) : 5, 20));
|
|
1238
|
+
const cfg = getPluginConfig(api, ctx);
|
|
1239
|
+
const data = await crystalRequest(cfg, "/api/mcp/recall", { query: `who owns ${topic}`, categories: ["person"], limit, mode: "people" });
|
|
1240
|
+
return toToolResult(data);
|
|
1241
|
+
} catch (err) { return toToolError(err); }
|
|
1242
|
+
},
|
|
1243
|
+
});
|
|
1244
|
+
api.registerTool({
|
|
1245
|
+
name: "crystal_explain_connection", label: "Crystal Explain Connection",
|
|
1246
|
+
description: "Explain the connection or relationship between two concepts, people, or systems.",
|
|
1247
|
+
parameters: { type: "object", properties: { from: { type: "string", minLength: 1 }, to: { type: "string", minLength: 1 }, limit: { type: "number", minimum: 1, maximum: 20 } }, required: ["from", "to"], additionalProperties: false },
|
|
1248
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1249
|
+
try {
|
|
1250
|
+
const from = ensureString(params?.from, "from", 1);
|
|
1251
|
+
const to = ensureString(params?.to, "to", 1);
|
|
1252
|
+
const limit = Math.max(1, Math.min(Number.isFinite(Number(params?.limit)) ? Number(params.limit) : 5, 20));
|
|
1253
|
+
const cfg = getPluginConfig(api, ctx);
|
|
1254
|
+
const data = await crystalRequest(cfg, "/api/mcp/recall", { query: `connection between ${from} and ${to}`, limit });
|
|
1255
|
+
return toToolResult(data);
|
|
1256
|
+
} catch (err) { return toToolError(err); }
|
|
1257
|
+
},
|
|
1258
|
+
});
|
|
1259
|
+
api.registerTool({
|
|
1260
|
+
name: "crystal_dependency_chain", label: "Crystal Dependency Chain",
|
|
1261
|
+
description: "Show the dependency chain for a topic, system, or project.",
|
|
1262
|
+
parameters: { type: "object", properties: { topic: { type: "string", minLength: 1 }, limit: { type: "number", minimum: 1, maximum: 20 } }, required: ["topic"], additionalProperties: false },
|
|
1263
|
+
async execute(_id, params, _sig, _upd, ctx) {
|
|
1264
|
+
try {
|
|
1265
|
+
const topic = ensureString(params?.topic, "topic", 1);
|
|
1266
|
+
const limit = Math.max(1, Math.min(Number.isFinite(Number(params?.limit)) ? Number(params.limit) : 5, 20));
|
|
1267
|
+
const cfg = getPluginConfig(api, ctx);
|
|
1268
|
+
const data = await crystalRequest(cfg, "/api/mcp/recall", { query: `dependencies for ${topic}`, limit, mode: "project" });
|
|
1269
|
+
return toToolResult(data);
|
|
1270
|
+
} catch (err) { return toToolError(err); }
|
|
1271
|
+
},
|
|
1272
|
+
});
|
|
1273
|
+
api.registerTool({
|
|
1274
|
+
name: "crystal_doctor", label: "Crystal Doctor",
|
|
1275
|
+
description: "Run a health check on the Memory Crystal plugin: verify config, connectivity, and backend status.",
|
|
1276
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
1277
|
+
async execute(_id, _params, _sig, _upd, ctx) {
|
|
1278
|
+
const PLUGIN_VERSION = "0.7.4";
|
|
1279
|
+
const lines = ["Memory Crystal Doctor", "---------------------"];
|
|
1280
|
+
let status = "Healthy";
|
|
1281
|
+
try {
|
|
1282
|
+
const cfg = getPluginConfig(api, ctx);
|
|
1283
|
+
const apiKey = cfg?.apiKey;
|
|
1284
|
+
const convexUrl = (cfg?.convexUrl || DEFAULT_CONVEX_URL).replace(/\/+$/, "");
|
|
1285
|
+
// API key status
|
|
1286
|
+
if (!apiKey || apiKey === "local") {
|
|
1287
|
+
lines.push(`Plugin version: ${PLUGIN_VERSION}`);
|
|
1288
|
+
lines.push("API key: not configured");
|
|
1289
|
+
lines.push(`Backend: ${convexUrl}`);
|
|
1290
|
+
lines.push("Connectivity: SKIP (no API key)");
|
|
1291
|
+
lines.push("Memory count: unknown");
|
|
1292
|
+
lines.push("Status: Degraded — API key missing");
|
|
1293
|
+
return toToolResult(lines.join("\n"));
|
|
1294
|
+
}
|
|
1295
|
+
const maskedKey = apiKey.length > 8 ? `***${apiKey.slice(-6)}` : "***";
|
|
1296
|
+
lines.push(`Plugin version: ${PLUGIN_VERSION}`);
|
|
1297
|
+
lines.push(`API key: configured (${maskedKey})`);
|
|
1298
|
+
lines.push(`Backend: ${convexUrl}`);
|
|
1299
|
+
// Connectivity check: try /api/mcp/stats (lightweight, no side effects)
|
|
1300
|
+
let connectivityOk = false;
|
|
1301
|
+
let memoryCount = "unknown";
|
|
1302
|
+
try {
|
|
1303
|
+
const statsRes = await fetch(`${convexUrl}/api/mcp/stats`, {
|
|
1304
|
+
method: "GET",
|
|
1305
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1306
|
+
});
|
|
1307
|
+
if (statsRes.ok) {
|
|
1308
|
+
connectivityOk = true;
|
|
1309
|
+
const statsData = await statsRes.json().catch(() => null);
|
|
1310
|
+
if (typeof statsData?.totalMemories === "number") memoryCount = statsData.totalMemories;
|
|
1311
|
+
else if (typeof statsData?.count === "number") memoryCount = statsData.count;
|
|
1312
|
+
} else {
|
|
1313
|
+
status = `Degraded — backend returned HTTP ${statsRes.status}`;
|
|
1314
|
+
}
|
|
1315
|
+
} catch (fetchErr) {
|
|
1316
|
+
status = `Degraded — connectivity error: ${getErrorMessage(fetchErr)}`;
|
|
1317
|
+
}
|
|
1318
|
+
lines.push(`Connectivity: ${connectivityOk ? "OK" : "FAIL"}`);
|
|
1319
|
+
// Recall smoke test (only if connectivity passed)
|
|
1320
|
+
if (connectivityOk) {
|
|
1321
|
+
try {
|
|
1322
|
+
const recallRes = await fetch(`${convexUrl}/api/mcp/recall`, {
|
|
1323
|
+
method: "POST",
|
|
1324
|
+
headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
|
1325
|
+
body: JSON.stringify({ query: "crystal doctor health check", limit: 1 }),
|
|
1326
|
+
});
|
|
1327
|
+
if (!recallRes.ok) {
|
|
1328
|
+
status = `Degraded — recall endpoint returned HTTP ${recallRes.status}`;
|
|
1329
|
+
}
|
|
1330
|
+
} catch (recallErr) {
|
|
1331
|
+
status = `Degraded — recall smoke test failed: ${getErrorMessage(recallErr)}`;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
lines.push(`Memory count: ${memoryCount}`);
|
|
1335
|
+
lines.push(`Status: ${status}`);
|
|
1336
|
+
return toToolResult(lines.join("\n"));
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
return toToolError(err);
|
|
1339
|
+
}
|
|
1340
|
+
},
|
|
1341
|
+
});
|
|
1342
|
+
};
|