@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Illumin8 Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # crystal-memory — OpenClaw Plugin
2
+
3
+ Persistent memory for AI agents. Captures conversations, extracts durable memories, and injects relevant context before every response.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ curl -fsSL https://memorycrystal.ai/crystal | bash
9
+ ```
10
+
11
+ Or install manually from this repo:
12
+
13
+ ```bash
14
+ mkdir -p ~/.openclaw/extensions/crystal-memory
15
+ rsync -a \
16
+ --exclude node_modules \
17
+ --exclude '*.test.js' \
18
+ plugin/ ~/.openclaw/extensions/crystal-memory/
19
+
20
+ cd ~/.openclaw/extensions/crystal-memory && npm install
21
+ ```
22
+
23
+ Then enable the plugin in `~/.openclaw/openclaw.json` under `plugins.slots.memory`.
24
+
25
+ ## Configuration
26
+
27
+ All schema-backed config is defined in `openclaw.plugin.json` under `configSchema.properties`:
28
+
29
+ | Key | Type | Default | Description |
30
+ |-----|------|---------|-------------|
31
+ | `apiKey` | string | — | Memory Crystal API key |
32
+ | `convexUrl` | string | `https://rightful-mockingbird-389.convex.site` | Convex backend URL |
33
+ | `defaultRecallMode` | string | `general` | Default recall mode (`general`, `decision`, `project`, `people`, `workflow`, `conversation`) |
34
+ | `defaultRecallLimit` | number | `8` | Memories to recall per query (`1`-`20`) |
35
+ | `channelScope` | string | — | Namespace prefix for tenant, client, or agent isolation |
36
+ | `localSummaryInjection` | boolean | `true` | Enable local summary injection |
37
+ | `localSummaryMaxTokens` | number | `2000` | Max tokens for local summaries |
38
+
39
+ ## Files
40
+
41
+ | File | Purpose |
42
+ |------|---------|
43
+ | `index.js` | Main plugin entry point for the modern OpenClaw plugin API |
44
+ | `context-budget.js` | Model-aware context budget calculator |
45
+ | `openclaw.plugin.json` | Plugin manifest and config schema |
46
+ | `package.json` | npm metadata and optional dependencies |
47
+ | `compaction/` | Context compaction and summarization helpers |
48
+ | `tools/` | Local tool implementations |
49
+ | `utils/` | Shared plugin utilities |
50
+ | `store/` | Local SQLite-backed storage files |
51
+
52
+ ## Hooks
53
+
54
+ The plugin registers hooks for these OpenClaw lifecycle events:
55
+
56
+ - `before_agent_start` — inject wake context and relevant recall
57
+ - `before_tool_call` — surface action-trigger warnings before risky tools
58
+ - `before_dispatch` — rate limiting, proactive recall, and reinforcement injection
59
+ - `message_received` — capture incoming user messages
60
+ - `llm_output` — capture assistant responses and extract durable memories
61
+ - `message_sent` — fallback assistant capture
62
+ - `session_end` — clear per-session state
63
+
64
+ It also watches `/new` and `/reset` command flows to trigger reflection behavior.
65
+
66
+ ## Knowledge Bases
67
+
68
+ The plugin benefits from Knowledge Bases automatically through the same Memory Crystal backend used for recall. Use KBs for stable reference material like runbooks, policies, docs, and imported datasets while conversational memory continues to capture learned context.
69
+
70
+ - Scoped knowledge bases respect the same tenant and channel boundaries as the rest of Memory Crystal.
71
+ - KB management and direct query/import flows live on the MCP and HTTP API surfaces.
72
+ - Plugin recall can combine durable memory with scoped KB-backed reference material when relevant.
73
+
74
+ ## Compaction Lifecycle
75
+
76
+ Memory Crystal owns the OpenClaw context-engine compaction path and preserves context across compaction boundaries:
77
+
78
+ - `before_compaction` — snapshot and checkpoint the source conversation before raw turns are condensed
79
+ - `after_compaction` — refresh local summary state so recall remains usable after compaction completes
80
+
81
+ ## Procedural vs Skills
82
+
83
+ - **Procedural memories** are quiet execution patterns: repeated workflows, troubleshooting loops, and operator habits that help recall without needing explicit approval.
84
+ - **Skills** are curated artifacts promoted for deliberate agent use. Treat them as reviewed playbooks, not just ambient pattern extraction.
85
+
86
+ ## Tools
87
+
88
+ `plugin/index.js` registers these tools directly via `api.registerTool()`:
89
+
90
+ - `crystal_set_scope` — override Memory Crystal channel scope for the current session
91
+ - `memory_search` — legacy compatibility search returning `crystal/<id>.md` paths
92
+ - `crystal_search_messages` — search short-term conversation logs
93
+ - `memory_get` — legacy compatibility read by memory ID or `crystal/<id>.md` path
94
+ - `crystal_recall` — semantic search across long-term memory
95
+ - `crystal_remember` — store a durable memory manually
96
+ - `crystal_what_do_i_know` — topic knowledge snapshot
97
+ - `crystal_why_did_we` — decision archaeology
98
+ - `crystal_checkpoint` — milestone memory snapshot
99
+ - `crystal_preflight` — pre-flight check returning relevant rules and lessons
100
+ - `crystal_recent` — fetch recent memory-backed messages
101
+ - `crystal_stats` — memory and usage statistics
102
+ - `crystal_forget` — archive or delete a memory
103
+ - `crystal_trace` — trace a memory back to its source conversation
104
+ - `crystal_wake` — session startup briefing
105
+ - `crystal_who_owns` — find ownership context for files, modules, or areas
106
+ - `crystal_explain_connection` — explain relationships between concepts
107
+ - `crystal_dependency_chain` — trace dependency chains
108
+
109
+ When the local store is available, the plugin also lazily registers:
110
+
111
+ - `crystal_grep` — search in-session local history and summaries
112
+ - `crystal_describe` — inspect a local summary node
113
+ - `crystal_expand` — expand a local summary into underlying context
114
+
115
+ ## Version
116
+
117
+ Current: `v0.7.1`
@@ -0,0 +1,166 @@
1
+ /**
2
+ * DEPRECATED / LEGACY
3
+ * -------------------
4
+ * This file is a legacy duplicate of capture logic now consolidated into `index.js`.
5
+ * It was previously used by handler.js (via child_process.spawnSync) and as a
6
+ * standalone hook in older OpenClaw configurations.
7
+ *
8
+ * Canonical capture logic now lives in: `index.js`
9
+ * Do NOT delete — may be referenced by legacy configurations or handler.js.
10
+ *
11
+ * Crystal Capture plugin — captures conversation turns via MCP API
12
+ * Writes to crystalMemories (sensory store) with proper userId via API key auth
13
+ */
14
+
15
+ const DEFAULT_CONVEX_URL = "https://rightful-mockingbird-389.convex.site";
16
+ const pendingUserMessages = new Map();
17
+
18
+ function firstString(...values) {
19
+ for (const value of values) {
20
+ if (typeof value === "string" && value.trim().length > 0) {
21
+ return value.trim();
22
+ }
23
+ }
24
+ return "";
25
+ }
26
+
27
+ function joinStringArray(values) {
28
+ if (!Array.isArray(values)) return "";
29
+ return values
30
+ .filter((value) => typeof value === "string" && value.trim().length > 0)
31
+ .join("\n")
32
+ .trim();
33
+ }
34
+
35
+ function extractAssistantText(event) {
36
+ const direct = firstString(
37
+ joinStringArray(event?.assistantTexts),
38
+ joinStringArray(event?.texts),
39
+ joinStringArray(event?.outputs),
40
+ event?.lastAssistant,
41
+ event?.outputText,
42
+ event?.content,
43
+ event?.text,
44
+ event?.message?.content,
45
+ event?.message?.text,
46
+ event?.response?.content,
47
+ event?.response?.text,
48
+ event?.result?.content,
49
+ event?.result?.text
50
+ );
51
+
52
+ if (direct) {
53
+ return direct;
54
+ }
55
+
56
+ const candidates = [
57
+ event?.response?.messages,
58
+ event?.result?.messages,
59
+ event?.messages,
60
+ event?.response?.parts,
61
+ event?.result?.parts,
62
+ event?.parts,
63
+ ];
64
+
65
+ for (const candidate of candidates) {
66
+ if (!Array.isArray(candidate)) continue;
67
+ const text = candidate
68
+ .map((item) =>
69
+ firstString(
70
+ item?.content,
71
+ item?.text,
72
+ item?.message?.content,
73
+ item?.message?.text
74
+ )
75
+ )
76
+ .filter(Boolean)
77
+ .join("\n")
78
+ .trim();
79
+ if (text) {
80
+ return text;
81
+ }
82
+ }
83
+
84
+ return "";
85
+ }
86
+
87
+ function extractUserText(event) {
88
+ return firstString(
89
+ event?.context?.content,
90
+ event?.content,
91
+ event?.text,
92
+ event?.message?.content,
93
+ event?.message?.text,
94
+ event?.input,
95
+ event?.prompt
96
+ );
97
+ }
98
+
99
+ async function captureToMCP(apiKey, convexUrl, payload) {
100
+ try {
101
+ const res = await fetch(`${convexUrl}/api/mcp/capture`, {
102
+ method: "POST",
103
+ headers: {
104
+ "Authorization": `Bearer ${apiKey}`,
105
+ "Content-Type": "application/json",
106
+ },
107
+ body: JSON.stringify(payload),
108
+ });
109
+ return res.ok;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ module.exports = (api) => {
116
+ // Get API key from config or env
117
+ const getConfig = (ctx) => {
118
+ const apiKey =
119
+ ctx?.config?.apiKey ||
120
+ process.env.CRYSTAL_API_KEY ||
121
+ api.config?.apiKey;
122
+ const convexUrl =
123
+ ctx?.config?.convexUrl ||
124
+ process.env.CRYSTAL_CONVEX_URL ||
125
+ DEFAULT_CONVEX_URL;
126
+ return { apiKey, convexUrl };
127
+ };
128
+
129
+ // Capture user message before each turn
130
+ api.on("message_received", (event, ctx) => {
131
+ const text = extractUserText(event);
132
+ if (text && ctx?.sessionKey) {
133
+ pendingUserMessages.set(ctx.sessionKey, String(text));
134
+ }
135
+ });
136
+
137
+ // Fire capture after each LLM response
138
+ api.on("llm_output", async (event, ctx) => {
139
+ const assistantText = extractAssistantText(event);
140
+ if (!assistantText) return;
141
+
142
+ const { apiKey, convexUrl } = getConfig(ctx);
143
+ if (!apiKey) return;
144
+
145
+ const sessionKey = ctx?.sessionKey || "";
146
+ const channel = ctx?.messageProvider || "openclaw";
147
+ const userMessage = sessionKey ? (pendingUserMessages.get(sessionKey) || "") : "";
148
+ if (sessionKey) pendingUserMessages.delete(sessionKey);
149
+
150
+ const content = [
151
+ userMessage ? `User: ${userMessage}` : null,
152
+ `Assistant: ${assistantText}`,
153
+ ].filter(Boolean).join("\n\n");
154
+
155
+ await captureToMCP(apiKey, convexUrl, {
156
+ title: `Conversation — ${new Date().toISOString().slice(0, 16).replace("T", " ")}`,
157
+ content,
158
+ store: "sensory",
159
+ category: "conversation",
160
+ tags: ["openclaw", "auto-capture", channel],
161
+ channel,
162
+ });
163
+ });
164
+
165
+ api.logger?.info?.("[crystal] capture hooks registered (message_received + llm_output)");
166
+ };
@@ -0,0 +1,71 @@
1
+ // context-budget.js — Model-aware injection budget calculator
2
+ //
3
+ // Memory Crystal injects context (recall results, recent messages, etc.) into
4
+ // the agent's system prompt. This module ensures we don't blow past the model's
5
+ // effective context capacity. Research shows effective capacity is ~60-70% of
6
+ // advertised max, and past that hallucination climbs.
7
+
8
+ const MODEL_EFFECTIVE_CAPACITY = {
9
+ "claude-opus": { maxTokens: 1000000, effectiveTokens: 600000, safeInjectionPct: 0.15 },
10
+ "claude-sonnet": { maxTokens: 1000000, effectiveTokens: 500000, safeInjectionPct: 0.15 },
11
+ "claude-haiku": { maxTokens: 200000, effectiveTokens: 120000, safeInjectionPct: 0.12 },
12
+ "gpt-5": { maxTokens: 1000000, effectiveTokens: 500000, safeInjectionPct: 0.15 },
13
+ "gpt-4.1": { maxTokens: 1000000, effectiveTokens: 500000, safeInjectionPct: 0.15 },
14
+ "gpt-4o": { maxTokens: 128000, effectiveTokens: 80000, safeInjectionPct: 0.12 },
15
+ "gemini-2.5-pro": { maxTokens: 1000000, effectiveTokens: 500000, safeInjectionPct: 0.15 },
16
+ "gemini-2.5-flash": { maxTokens: 1000000, effectiveTokens: 400000, safeInjectionPct: 0.12 },
17
+ "gemini-3-pro": { maxTokens: 2000000, effectiveTokens: 800000, safeInjectionPct: 0.15 },
18
+ "gemini-3-flash": { maxTokens: 1000000, effectiveTokens: 400000, safeInjectionPct: 0.12 },
19
+ codex: { maxTokens: 1000000, effectiveTokens: 500000, safeInjectionPct: 0.15 },
20
+ default: { maxTokens: 128000, effectiveTokens: 75000, safeInjectionPct: 0.10 },
21
+ };
22
+
23
+ function getModelCapacity(modelName) {
24
+ const normalized = String(modelName || "").toLowerCase();
25
+ for (const [key, capacity] of Object.entries(MODEL_EFFECTIVE_CAPACITY)) {
26
+ if (key === "default") continue;
27
+ if (normalized.includes(key)) return capacity;
28
+ }
29
+ return MODEL_EFFECTIVE_CAPACITY.default;
30
+ }
31
+
32
+ function getInjectionBudget(modelName) {
33
+ const cap = getModelCapacity(modelName);
34
+ const maxTokens = Math.floor(cap.effectiveTokens * cap.safeInjectionPct);
35
+ return {
36
+ maxChars: maxTokens * 4,
37
+ maxTokens,
38
+ model: modelName,
39
+ effectiveCapacity: cap.effectiveTokens,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Trims an array of labeled sections to fit within a character budget.
45
+ * Drops lowest-priority sections first.
46
+ *
47
+ * @param {Array<{label: string, text: string}>} sections - Sections to trim
48
+ * @param {number} maxChars - Maximum total characters
49
+ * @param {string[]} dropOrder - Labels ordered from lowest to highest priority
50
+ * @returns {Array<{label: string, text: string}>} Trimmed sections
51
+ */
52
+ function trimSections(sections, maxChars, dropOrder) {
53
+ let totalChars = sections.reduce((sum, s) => sum + s.text.length, 0);
54
+ if (totalChars <= maxChars) return sections;
55
+
56
+ const result = [...sections];
57
+ for (const label of dropOrder) {
58
+ // Drop ALL sections matching this label (handles duplicate labels)
59
+ for (let i = result.length - 1; i >= 0; i--) {
60
+ if (totalChars <= maxChars) break;
61
+ if (result[i].label === label) {
62
+ totalChars -= result[i].text.length;
63
+ result.splice(i, 1);
64
+ }
65
+ }
66
+ if (totalChars <= maxChars) break;
67
+ }
68
+ return result;
69
+ }
70
+
71
+ module.exports = { MODEL_EFFECTIVE_CAPACITY, getModelCapacity, getInjectionBudget, trimSections };
@@ -0,0 +1,92 @@
1
+ const { getModelCapacity, getInjectionBudget, trimSections } = require("./context-budget");
2
+
3
+ let passed = 0;
4
+ let failed = 0;
5
+
6
+ function test(name, fn) {
7
+ try {
8
+ fn();
9
+ passed++;
10
+ console.log(` PASS: ${name}`);
11
+ } catch (err) {
12
+ failed++;
13
+ console.error(` FAIL: ${name} — ${err.message}`);
14
+ }
15
+ }
16
+
17
+ function assert(condition, msg) {
18
+ if (!condition) throw new Error(msg || "assertion failed");
19
+ }
20
+
21
+ console.log("context-budget tests:");
22
+
23
+ test("getInjectionBudget for claude-opus returns opus capacity", () => {
24
+ const budget = getInjectionBudget("claude-opus-4-6");
25
+ assert(budget.maxTokens === Math.floor(600000 * 0.15), `expected ${Math.floor(600000 * 0.15)}, got ${budget.maxTokens}`);
26
+ assert(budget.effectiveCapacity === 600000, `expected 600000, got ${budget.effectiveCapacity}`);
27
+ });
28
+
29
+ test("getInjectionBudget for gpt-4o returns gpt-4o capacity", () => {
30
+ const budget = getInjectionBudget("gpt-4o-mini");
31
+ assert(budget.maxTokens === Math.floor(80000 * 0.12), `expected ${Math.floor(80000 * 0.12)}, got ${budget.maxTokens}`);
32
+ assert(budget.effectiveCapacity === 80000);
33
+ });
34
+
35
+ test("getInjectionBudget for unknown model returns default", () => {
36
+ const budget = getInjectionBudget("unknown-model-xyz");
37
+ assert(budget.effectiveCapacity === 75000, `expected 75000, got ${budget.effectiveCapacity}`);
38
+ assert(budget.maxTokens === Math.floor(75000 * 0.10));
39
+ });
40
+
41
+ test("getInjectionBudget for empty string returns default", () => {
42
+ const budget = getInjectionBudget("");
43
+ assert(budget.effectiveCapacity === 75000);
44
+ });
45
+
46
+ test("128K model budget is smaller than 1M model budget", () => {
47
+ const small = getInjectionBudget("gpt-4o");
48
+ const large = getInjectionBudget("claude-opus-4");
49
+ assert(small.maxChars < large.maxChars, `${small.maxChars} should be < ${large.maxChars}`);
50
+ });
51
+
52
+ test("getModelCapacity matches partial model names", () => {
53
+ const opus = getModelCapacity("anthropic/claude-opus-4-6");
54
+ assert(opus.effectiveTokens === 600000, `expected 600000, got ${opus.effectiveTokens}`);
55
+
56
+ const codex = getModelCapacity("openai-codex/gpt-5.3-codex");
57
+ // Should match gpt-5 or codex
58
+ assert(codex.effectiveTokens >= 500000, `expected >= 500000, got ${codex.effectiveTokens}`);
59
+ });
60
+
61
+ test("trimSections returns all sections when under budget", () => {
62
+ const sections = [
63
+ { label: "A", text: "hello" },
64
+ { label: "B", text: "world" },
65
+ ];
66
+ const result = trimSections(sections, 1000, ["A", "B"]);
67
+ assert(result.length === 2);
68
+ });
69
+
70
+ test("trimSections drops lowest-priority first", () => {
71
+ const sections = [
72
+ { label: "Recent Context", text: "x".repeat(500) },
73
+ { label: "Relevant Recall", text: "y".repeat(500) },
74
+ ];
75
+ const result = trimSections(sections, 600, ["Recent Context", "Relevant Recall"]);
76
+ assert(result.length === 1, `expected 1, got ${result.length}`);
77
+ assert(result[0].label === "Relevant Recall", `expected Relevant Recall, got ${result[0].label}`);
78
+ });
79
+
80
+ test("trimSections drops multiple sections if needed", () => {
81
+ const sections = [
82
+ { label: "A", text: "x".repeat(300) },
83
+ { label: "B", text: "y".repeat(300) },
84
+ { label: "C", text: "z".repeat(300) },
85
+ ];
86
+ const result = trimSections(sections, 350, ["A", "B", "C"]);
87
+ assert(result.length === 1, `expected 1, got ${result.length}`);
88
+ assert(result[0].label === "C");
89
+ });
90
+
91
+ console.log(`\n${passed} passed, ${failed} failed`);
92
+ if (failed > 0) process.exit(1);