memory-crystal 0.2.0

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.
Files changed (104) hide show
  1. package/.env.example +20 -0
  2. package/CHANGELOG.md +6 -0
  3. package/LETTERS.md +22 -0
  4. package/LICENSE +21 -0
  5. package/README-ENTERPRISE.md +162 -0
  6. package/README-old.md +275 -0
  7. package/README.md +91 -0
  8. package/RELAY.md +88 -0
  9. package/TECHNICAL.md +379 -0
  10. package/ai/dev-updates/2026-02-25--cc-air--phase2-architecture-pivot.md +70 -0
  11. package/ai/dev-updates/2026-02-25--cc-air--phase2-worker-build.md +72 -0
  12. package/ai/dev-updates/2026-02-26--10-25-16--cc-mini--phase2-implementation.md +49 -0
  13. package/ai/dev-updates/2026-02-27--20-30-00--cc-mini--readme-overhaul-and-public-deploy.md +69 -0
  14. package/ai/notes/2026-02-26--cc-air--notes.md +412 -0
  15. package/ai/notes/2026-02-27--cc-mini--grok-feedback.md +44 -0
  16. package/ai/notes/2026-02-27--cc-mini--lesa-feedback.md +45 -0
  17. package/ai/notes/RESEARCH.md +1185 -0
  18. package/ai/notes/salience-research/README.md +29 -0
  19. package/ai/notes/salience-research/eurosla-salience-review.md +64 -0
  20. package/ai/notes/salience-research/full-research-summary.md +269 -0
  21. package/ai/notes/salience-research/salience-levels-diagram.png +0 -0
  22. package/ai/plan/2026-02-27--cc-mini--qr-pairing-spec.md +203 -0
  23. package/ai/plan/_archive/PLAN.md +194 -0
  24. package/ai/plan/_archive/PRD.md +1014 -0
  25. package/ai/plan/cc-plans-duplicates-from-dot-claude/2026-02-26--cc-mini--phase2-implementation-plan.md +245 -0
  26. package/ai/plan/dev-conventions-note.md +70 -0
  27. package/ai/plan/ldm-os-install-and-boot-architecture.md +285 -0
  28. package/ai/plan/memory-crystal-phase2-plan.md +192 -0
  29. package/ai/plan/memory-system-lay-of-the-land.md +214 -0
  30. package/ai/plan/phase2-ephemeral-relay.md +238 -0
  31. package/ai/plan/readme-first.md +68 -0
  32. package/ai/plan/roadmap.md +159 -0
  33. package/ai/todos/PUNCHLIST.md +44 -0
  34. package/ai/todos/README.md +31 -0
  35. package/ai/todos/inboxes/cc-air/2026-02-26--cc-air--post-relay-todos.md +85 -0
  36. package/ai/todos/inboxes/cc-mini/2026-02-26--cc-mini--phase2-status.md +100 -0
  37. package/ai/todos/inboxes/cc-mini/_archive/TODO.md +25 -0
  38. package/ai/todos/inboxes/parker/2026-02-25--cc-air--setup-checklist.md +139 -0
  39. package/ai/todos/inboxes/parker/2026-02-26--cc-mini--phase2-your-moves.md +72 -0
  40. package/dist/cc-hook.d.ts +1 -0
  41. package/dist/cc-hook.js +349 -0
  42. package/dist/chunk-3VFIJYS4.js +818 -0
  43. package/dist/chunk-52QE3YI3.js +1169 -0
  44. package/dist/chunk-AA3OPP4Z.js +432 -0
  45. package/dist/chunk-D3I3ZSE2.js +411 -0
  46. package/dist/chunk-EKSACBTJ.js +1070 -0
  47. package/dist/chunk-F3Y7EL7K.js +83 -0
  48. package/dist/chunk-JWZXYVET.js +1068 -0
  49. package/dist/chunk-KYVWO6ZM.js +1069 -0
  50. package/dist/chunk-L3VHARQH.js +413 -0
  51. package/dist/chunk-LOVAHSQV.js +411 -0
  52. package/dist/chunk-LQOYCAGG.js +446 -0
  53. package/dist/chunk-MK42FMEG.js +147 -0
  54. package/dist/chunk-NIJCVN3O.js +147 -0
  55. package/dist/chunk-O2UITJGH.js +465 -0
  56. package/dist/chunk-PEK6JH65.js +432 -0
  57. package/dist/chunk-PJ6FFKEX.js +77 -0
  58. package/dist/chunk-PLUBBZYR.js +800 -0
  59. package/dist/chunk-SGL6ISBJ.js +1061 -0
  60. package/dist/chunk-UNHVZB5G.js +411 -0
  61. package/dist/chunk-VAFTWSTE.js +1061 -0
  62. package/dist/chunk-XZ3S56RQ.js +1061 -0
  63. package/dist/chunk-Y72C7F6O.js +148 -0
  64. package/dist/cli.d.ts +1 -0
  65. package/dist/cli.js +325 -0
  66. package/dist/core.d.ts +188 -0
  67. package/dist/core.js +12 -0
  68. package/dist/crypto.d.ts +16 -0
  69. package/dist/crypto.js +18 -0
  70. package/dist/dev-update-SZ2Z4WCQ.js +6 -0
  71. package/dist/ldm.d.ts +17 -0
  72. package/dist/ldm.js +12 -0
  73. package/dist/mcp-server.d.ts +1 -0
  74. package/dist/mcp-server.js +250 -0
  75. package/dist/migrate.d.ts +1 -0
  76. package/dist/migrate.js +89 -0
  77. package/dist/mirror-sync.d.ts +1 -0
  78. package/dist/mirror-sync.js +130 -0
  79. package/dist/openclaw.d.ts +5 -0
  80. package/dist/openclaw.js +349 -0
  81. package/dist/poller.d.ts +1 -0
  82. package/dist/poller.js +272 -0
  83. package/dist/summarize.d.ts +19 -0
  84. package/dist/summarize.js +10 -0
  85. package/dist/worker.js +137 -0
  86. package/openclaw.plugin.json +11 -0
  87. package/package.json +40 -0
  88. package/scripts/migrate-lance-to-sqlite.mjs +217 -0
  89. package/skills/memory/SKILL.md +61 -0
  90. package/src/cc-hook.ts +447 -0
  91. package/src/cli.ts +356 -0
  92. package/src/core.ts +1472 -0
  93. package/src/crypto.ts +113 -0
  94. package/src/dev-update.ts +178 -0
  95. package/src/ldm.ts +117 -0
  96. package/src/mcp-server.ts +274 -0
  97. package/src/migrate.ts +104 -0
  98. package/src/mirror-sync.ts +175 -0
  99. package/src/openclaw.ts +250 -0
  100. package/src/poller.ts +345 -0
  101. package/src/summarize.ts +210 -0
  102. package/src/worker.ts +208 -0
  103. package/tsconfig.json +18 -0
  104. package/wrangler.toml +20 -0
package/src/migrate.ts ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // memory-crystal/migrate.ts — Import chunks from context-embeddings.sqlite
3
+ // Re-embeds with configured provider (OpenAI/Ollama/Google).
4
+
5
+ import { Crystal, resolveConfig } from './core.js';
6
+ import Database from 'better-sqlite3';
7
+ import { existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ const BATCH_SIZE = 50;
11
+
12
+ async function main() {
13
+ const args = process.argv.slice(2);
14
+ const dryRun = args.includes('--dry-run');
15
+ const providerFlag = args.find((_, i) => args[i - 1] === '--provider');
16
+
17
+ const openclawHome = process.env.OPENCLAW_HOME || join(process.env.HOME || '', '.openclaw');
18
+ const sourcePath = join(openclawHome, 'memory', 'context-embeddings.sqlite');
19
+
20
+ if (!existsSync(sourcePath)) {
21
+ console.error(`Source not found: ${sourcePath}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const sourceDb = new Database(sourcePath, { readonly: true });
26
+ sourceDb.pragma('journal_mode = WAL');
27
+
28
+ // Count existing chunks
29
+ const total = (sourceDb.prepare('SELECT COUNT(*) as count FROM conversation_chunks').get() as any).count;
30
+ console.log(`Found ${total} chunks in context-embeddings.sqlite`);
31
+
32
+ if (dryRun) {
33
+ // Show sample
34
+ const samples = sourceDb.prepare('SELECT chunk_text, role, session_key, timestamp FROM conversation_chunks ORDER BY timestamp DESC LIMIT 5').all() as any[];
35
+ console.log('\nSample (5 most recent):');
36
+ for (const s of samples) {
37
+ const date = s.timestamp ? new Date(s.timestamp).toISOString().slice(0, 10) : 'unknown';
38
+ console.log(` [${date}] [${s.role}] ${s.chunk_text.slice(0, 80)}...`);
39
+ }
40
+ console.log(`\nRun without --dry-run to import all ${total} chunks.`);
41
+ sourceDb.close();
42
+ return;
43
+ }
44
+
45
+ // Initialize crystal
46
+ const overrides: any = {};
47
+ if (providerFlag) overrides.embeddingProvider = providerFlag;
48
+ const config = resolveConfig(overrides);
49
+ const crystal = new Crystal(config);
50
+ await crystal.init();
51
+
52
+ console.log(`Embedding provider: ${config.embeddingProvider}`);
53
+ console.log(`Target: ${config.dataDir}`);
54
+ console.log(`Migrating ${total} chunks in batches of ${BATCH_SIZE}...`);
55
+
56
+ // Fetch all chunks ordered by timestamp
57
+ const rows = sourceDb.prepare(`
58
+ SELECT chunk_text, role, agent_id, session_key, timestamp, compaction_number
59
+ FROM conversation_chunks
60
+ ORDER BY timestamp ASC
61
+ `).all() as any[];
62
+
63
+ let imported = 0;
64
+ let failed = 0;
65
+
66
+ for (let i = 0; i < rows.length; i += BATCH_SIZE) {
67
+ const batch = rows.slice(i, i + BATCH_SIZE);
68
+ const chunks = batch.map(row => ({
69
+ text: row.chunk_text,
70
+ role: (row.role || 'assistant') as 'user' | 'assistant' | 'system',
71
+ source_type: 'conversation' as const,
72
+ source_id: row.session_key || 'unknown',
73
+ agent_id: row.agent_id || 'main',
74
+ token_count: Math.ceil((row.chunk_text?.length || 0) / 4),
75
+ created_at: row.timestamp ? new Date(row.timestamp).toISOString() : new Date().toISOString(),
76
+ }));
77
+
78
+ try {
79
+ const count = await crystal.ingest(chunks);
80
+ imported += count;
81
+ const pct = Math.round((imported / total) * 100);
82
+ process.stdout.write(`\r ${imported}/${total} (${pct}%)`);
83
+ } catch (err: any) {
84
+ failed += batch.length;
85
+ console.error(`\n Batch error at ${i}: ${err.message}`);
86
+ }
87
+ }
88
+
89
+ console.log(`\n\nMigration complete:`);
90
+ console.log(` Imported: ${imported}`);
91
+ console.log(` Failed: ${failed}`);
92
+ console.log(` Provider: ${config.embeddingProvider}`);
93
+
94
+ const status = await crystal.status();
95
+ console.log(` Total chunks in crystal: ${status.chunks}`);
96
+
97
+ crystal.close();
98
+ sourceDb.close();
99
+ }
100
+
101
+ main().catch(err => {
102
+ console.error(`Migration failed: ${err.message}`);
103
+ process.exit(1);
104
+ });
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ // memory-crystal/mirror-sync.ts — Device-side mirror pull.
3
+ // Pulls encrypted DB snapshot from relay Worker, verifies integrity,
4
+ // decrypts, and replaces local read-only crystal mirror.
5
+ //
6
+ // Usage:
7
+ // node mirror-sync.js Pull latest mirror (if available)
8
+ // node mirror-sync.js --status Show mirror state
9
+ // node mirror-sync.js --force Pull even if current mirror is recent
10
+
11
+ import { loadRelayKey, decrypt, decryptJSON, hashBuffer, type EncryptedPayload } from './crypto.js';
12
+ import { ldmPaths } from './ldm.js';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
+
16
+ const HOME = process.env.HOME || '';
17
+ const RELAY_URL = process.env.CRYSTAL_RELAY_URL || '';
18
+ const RELAY_TOKEN = process.env.CRYSTAL_RELAY_TOKEN || '';
19
+ const OC_DIR = join(HOME, '.openclaw');
20
+ const _ldmPaths = ldmPaths();
21
+ const MIRROR_DIR = join(_ldmPaths.root, 'memory');
22
+ const MIRROR_DB_PATH = _ldmPaths.crystalDb;
23
+ const MIRROR_STATE_PATH = join(OC_DIR, 'memory', 'mirror-sync-state.json');
24
+
25
+ interface MirrorState {
26
+ lastSync: string | null;
27
+ lastHash: string | null;
28
+ lastSize: number | null;
29
+ }
30
+
31
+ function loadState(): MirrorState {
32
+ try {
33
+ if (existsSync(MIRROR_STATE_PATH)) {
34
+ return JSON.parse(readFileSync(MIRROR_STATE_PATH, 'utf-8'));
35
+ }
36
+ } catch {}
37
+ return { lastSync: null, lastHash: null, lastSize: null };
38
+ }
39
+
40
+ function saveState(state: MirrorState): void {
41
+ const dir = dirname(MIRROR_STATE_PATH);
42
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
43
+ writeFileSync(MIRROR_STATE_PATH, JSON.stringify(state, null, 2));
44
+ }
45
+
46
+ // ── Pull mirror ──
47
+
48
+ async function pullMirror(force: boolean): Promise<boolean> {
49
+ if (!RELAY_URL || !RELAY_TOKEN) {
50
+ throw new Error('CRYSTAL_RELAY_URL and CRYSTAL_RELAY_TOKEN must be set');
51
+ }
52
+
53
+ const relayKey = loadRelayKey();
54
+
55
+ // List available mirror blobs
56
+ const listResp = await fetch(`${RELAY_URL}/pickup/mirror`, {
57
+ headers: { 'Authorization': `Bearer ${RELAY_TOKEN}` },
58
+ });
59
+
60
+ if (!listResp.ok) {
61
+ throw new Error(`Relay list failed: ${listResp.status} ${await listResp.text()}`);
62
+ }
63
+
64
+ const listData = await listResp.json() as { count: number; blobs: Array<{ id: string; size: number; dropped_at: string }> };
65
+
66
+ if (listData.count === 0) {
67
+ process.stderr.write('[mirror-sync] no mirror available\n');
68
+ return false;
69
+ }
70
+
71
+ // Take the latest blob (last in list by drop time)
72
+ const latestBlob = listData.blobs[listData.blobs.length - 1];
73
+
74
+ // Fetch the encrypted mirror
75
+ const blobResp = await fetch(`${RELAY_URL}/pickup/mirror/${latestBlob.id}`, {
76
+ headers: { 'Authorization': `Bearer ${RELAY_TOKEN}` },
77
+ });
78
+
79
+ if (!blobResp.ok) {
80
+ throw new Error(`Mirror fetch failed: ${blobResp.status}`);
81
+ }
82
+
83
+ const encryptedText = await blobResp.text();
84
+ const mirrorPayload = JSON.parse(encryptedText) as { meta: EncryptedPayload; db: EncryptedPayload };
85
+
86
+ // Decrypt metadata
87
+ const meta = decryptJSON<{ hash: string; size: number; pushed_at: string }>(mirrorPayload.meta, relayKey);
88
+
89
+ // Check if we already have this version
90
+ const state = loadState();
91
+ if (!force && state.lastHash === meta.hash) {
92
+ process.stderr.write('[mirror-sync] mirror is already up to date\n');
93
+ return false;
94
+ }
95
+
96
+ // Decrypt the DB
97
+ const dbData = decrypt(mirrorPayload.db, relayKey);
98
+
99
+ // Verify integrity
100
+ const actualHash = hashBuffer(dbData);
101
+ if (actualHash !== meta.hash) {
102
+ throw new Error(
103
+ `Mirror integrity check failed!\n` +
104
+ ` Expected: ${meta.hash}\n` +
105
+ ` Got: ${actualHash}\n` +
106
+ `Mirror REJECTED — keeping existing local mirror.`
107
+ );
108
+ }
109
+
110
+ // Atomic replace: write to temp, then rename
111
+ if (!existsSync(MIRROR_DIR)) mkdirSync(MIRROR_DIR, { recursive: true });
112
+ const tmpPath = MIRROR_DB_PATH + '.tmp';
113
+ writeFileSync(tmpPath, dbData);
114
+
115
+ // Backup existing mirror
116
+ if (existsSync(MIRROR_DB_PATH)) {
117
+ const backupPath = MIRROR_DB_PATH + '.bak';
118
+ try { renameSync(MIRROR_DB_PATH, backupPath); } catch {}
119
+ }
120
+
121
+ renameSync(tmpPath, MIRROR_DB_PATH);
122
+
123
+ // Update state
124
+ state.lastSync = new Date().toISOString();
125
+ state.lastHash = meta.hash;
126
+ state.lastSize = dbData.length;
127
+ saveState(state);
128
+
129
+ process.stderr.write(
130
+ `[mirror-sync] updated: ${(dbData.length / 1024 / 1024).toFixed(1)}MB, ` +
131
+ `hash=${meta.hash.slice(0, 12)}..., pushed=${meta.pushed_at}\n`
132
+ );
133
+
134
+ // Confirm receipt — Worker deletes all mirror blobs
135
+ for (const blob of listData.blobs) {
136
+ try {
137
+ await fetch(`${RELAY_URL}/confirm/mirror/${blob.id}`, {
138
+ method: 'DELETE',
139
+ headers: { 'Authorization': `Bearer ${RELAY_TOKEN}` },
140
+ });
141
+ } catch {} // Best effort cleanup
142
+ }
143
+
144
+ return true;
145
+ }
146
+
147
+ // ── CLI ──
148
+
149
+ const args = process.argv.slice(2);
150
+
151
+ if (args.includes('--status')) {
152
+ const state = loadState();
153
+ const hasDb = existsSync(MIRROR_DB_PATH);
154
+ console.log('Mirror sync status:');
155
+ console.log(` Relay URL: ${RELAY_URL || '(not set)'}`);
156
+ console.log(` Local mirror: ${hasDb ? MIRROR_DB_PATH : '(none)'}`);
157
+ console.log(` Last sync: ${state.lastSync || 'never'}`);
158
+ console.log(` Last hash: ${state.lastHash ? state.lastHash.slice(0, 16) + '...' : '(none)'}`);
159
+ console.log(` Last size: ${state.lastSize ? (state.lastSize / 1024 / 1024).toFixed(1) + 'MB' : '(none)'}`);
160
+ process.exit(0);
161
+ }
162
+
163
+ const force = args.includes('--force');
164
+
165
+ pullMirror(force)
166
+ .then(updated => {
167
+ if (updated) {
168
+ process.stderr.write('[mirror-sync] done\n');
169
+ }
170
+ process.exit(0);
171
+ })
172
+ .catch(err => {
173
+ process.stderr.write(`[mirror-sync] error: ${err.message}\n`);
174
+ process.exit(1);
175
+ });
@@ -0,0 +1,250 @@
1
+ // memory-crystal/openclaw.ts — OpenClaw plugin wrapper.
2
+ // Thin layer calling core.ts via api.registerTool() and api.on().
3
+ // Replaces context-embeddings plugin.
4
+
5
+ import { Crystal, resolveConfig, type Chunk } from './core.js';
6
+ import { runDevUpdate } from './dev-update.js';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ const CONFIG_DIR = join(process.env.HOME || '', '.openclaw');
11
+ const PRIVATE_MODE_PATH = join(CONFIG_DIR, 'memory', 'memory-capture-state.json');
12
+
13
+ function isPrivateMode(): boolean {
14
+ try {
15
+ if (existsSync(PRIVATE_MODE_PATH)) {
16
+ const state = JSON.parse(readFileSync(PRIVATE_MODE_PATH, 'utf-8'));
17
+ return state.enabled === false;
18
+ }
19
+ } catch {
20
+ // corrupted file = default to enabled (capture on)
21
+ }
22
+ return false;
23
+ }
24
+
25
+ // getPrivateState and setPrivateMode moved to lesa-private-mode plugin.
26
+ // Only isPrivateMode() is needed here for agent_end and crystal_remember checks.
27
+
28
+ export default {
29
+ register(api: any) {
30
+ const crystal = new Crystal(resolveConfig());
31
+ let initialized = false;
32
+
33
+ async function ensureInit() {
34
+ if (!initialized) {
35
+ await crystal.init();
36
+ initialized = true;
37
+ }
38
+ }
39
+
40
+ // ── Hook: agent_end (continuous conversation ingestion) ──
41
+
42
+ api.on('agent_end', async (event: any, ctx: any) => {
43
+ // Private mode check
44
+ if (isPrivateMode()) return;
45
+
46
+ await ensureInit();
47
+
48
+ const messages = event.messages;
49
+ if (!messages || messages.length === 0) return;
50
+
51
+ const agentId = ctx.agentId || 'main';
52
+ const sessionKey = ctx.sessionKey || 'unknown';
53
+
54
+ // Check capture state
55
+ const state = crystal.getCaptureState(agentId, sessionKey);
56
+ const storedCount = state.lastMessageCount;
57
+
58
+ // Detect compaction: messages array shrank below stored counter
59
+ let startIndex = storedCount;
60
+ if (messages.length < storedCount) {
61
+ api.logger.info(`memory-crystal: compaction detected (${storedCount} → ${messages.length} messages), resetting capture position`);
62
+ startIndex = 0;
63
+ }
64
+
65
+ if (messages.length <= startIndex) return; // Nothing new
66
+
67
+ // Extract new conversation turns
68
+ const newTurns: Chunk[] = [];
69
+ for (let i = startIndex; i < messages.length; i++) {
70
+ const msg = messages[i];
71
+ if (!msg.content) continue;
72
+
73
+ const role = msg.role;
74
+ if (role !== 'user' && role !== 'assistant') continue;
75
+
76
+ // Extract text from content (string or array)
77
+ let text = '';
78
+ if (typeof msg.content === 'string') {
79
+ text = msg.content;
80
+ } else if (Array.isArray(msg.content)) {
81
+ text = msg.content
82
+ .filter((b: any) => b.type === 'text')
83
+ .map((b: any) => b.text)
84
+ .join('\n');
85
+ }
86
+
87
+ if (!text || text.length < 50) continue; // Skip tiny messages
88
+
89
+ // Turn-boundary chunking: one message = one chunk.
90
+ // Only fall back to chunkText() for very long messages (>2000 tokens).
91
+ const maxSingleChunkChars = 2000 * 4;
92
+ if (text.length <= maxSingleChunkChars) {
93
+ newTurns.push({
94
+ text,
95
+ role: role as 'user' | 'assistant',
96
+ source_type: 'conversation',
97
+ source_id: sessionKey,
98
+ agent_id: agentId,
99
+ token_count: Math.ceil(text.length / 4),
100
+ created_at: new Date().toISOString(),
101
+ });
102
+ } else {
103
+ // Very long message: chunk it, but preserve turn context
104
+ const chunks = crystal.chunkText(text);
105
+ for (const chunkText of chunks) {
106
+ newTurns.push({
107
+ text: chunkText,
108
+ role: role as 'user' | 'assistant',
109
+ source_type: 'conversation',
110
+ source_id: sessionKey,
111
+ agent_id: agentId,
112
+ token_count: Math.ceil(chunkText.length / 4),
113
+ created_at: new Date().toISOString(),
114
+ });
115
+ }
116
+ }
117
+ }
118
+
119
+ // Skip if not enough new content
120
+ const totalTokens = newTurns.reduce((sum, c) => sum + c.token_count, 0);
121
+ if (totalTokens < 500) return;
122
+
123
+ // Ingest
124
+ try {
125
+ const count = await crystal.ingest(newTurns);
126
+ crystal.setCaptureState(agentId, sessionKey, messages.length, state.captureCount + 1);
127
+ api.logger.info(`memory-crystal: ingested ${count} chunks from ${sessionKey} (cycle ${state.captureCount + 1})`);
128
+ } catch (err: any) {
129
+ api.logger.error(`memory-crystal: ingest error: ${err.message}`);
130
+ }
131
+ });
132
+
133
+ // ── Tools ──
134
+ // OpenClaw expects { content: [{ type: "text", text }] } return format
135
+
136
+ function toolResult(text: string, isError = false) {
137
+ return {
138
+ content: [{ type: 'text' as const, text }],
139
+ ...(isError ? { isError: true } : {}),
140
+ };
141
+ }
142
+
143
+ api.registerTool(
144
+ {
145
+ name: 'crystal_search',
146
+ label: 'Search Memory Crystal',
147
+ description: 'Search memory crystal — semantic search across all conversations and stored memories.',
148
+ parameters: {
149
+ type: 'object',
150
+ properties: {
151
+ query: { type: 'string', description: 'What to search for' },
152
+ limit: { type: 'number', description: 'Max results (default: 5)' },
153
+ agent_id: { type: 'string', description: 'Filter by agent' },
154
+ },
155
+ required: ['query'],
156
+ },
157
+ async execute(_id: string, params: any) {
158
+ try {
159
+ await ensureInit();
160
+ const results = await crystal.search(
161
+ params.query,
162
+ params.limit || 5,
163
+ params.agent_id ? { agent_id: params.agent_id } : undefined
164
+ );
165
+ if (results.length === 0) return toolResult('No results found.');
166
+ const formatted = results.map((r, i) => {
167
+ const score = (r.score * 100).toFixed(1);
168
+ const date = r.created_at?.slice(0, 10) || 'unknown';
169
+ return `[${i + 1}] (${score}%, ${r.agent_id}, ${date}, ${r.role})\n${r.text}`;
170
+ }).join('\n\n---\n\n');
171
+ return toolResult(formatted);
172
+ } catch (err: any) {
173
+ return toolResult(`crystal_search error: ${err.message}`, true);
174
+ }
175
+ },
176
+ },
177
+ { optional: true }
178
+ );
179
+
180
+ api.registerTool(
181
+ {
182
+ name: 'crystal_remember',
183
+ label: 'Remember in Crystal',
184
+ description: 'Store a fact, preference, or observation in memory crystal.',
185
+ parameters: {
186
+ type: 'object',
187
+ properties: {
188
+ text: { type: 'string', description: 'The fact to remember' },
189
+ category: { type: 'string', enum: ['fact', 'preference', 'event', 'opinion', 'skill'] },
190
+ },
191
+ required: ['text'],
192
+ },
193
+ async execute(_id: string, params: any) {
194
+ // Private mode blocks explicit memory writes too
195
+ if (isPrivateMode()) {
196
+ return toolResult('Private mode is on. No memories are being stored. Use /private-mode off to resume.');
197
+ }
198
+ try {
199
+ await ensureInit();
200
+ const id = await crystal.remember(params.text, params.category || 'fact');
201
+ return toolResult(`Remembered (id: ${id}): ${params.text}`);
202
+ } catch (err: any) {
203
+ return toolResult(`crystal_remember error: ${err.message}`, true);
204
+ }
205
+ },
206
+ },
207
+ { optional: true }
208
+ );
209
+
210
+ api.registerTool(
211
+ {
212
+ name: 'crystal_forget',
213
+ label: 'Forget Memory',
214
+ description: 'Deprecate a memory by ID.',
215
+ parameters: {
216
+ type: 'object',
217
+ properties: {
218
+ id: { type: 'number', description: 'Memory ID to deprecate' },
219
+ },
220
+ required: ['id'],
221
+ },
222
+ async execute(_id: string, params: any) {
223
+ try {
224
+ await ensureInit();
225
+ const ok = crystal.forget(params.id);
226
+ return toolResult(ok ? `Forgot memory ${params.id}` : `Memory ${params.id} not found`);
227
+ } catch (err: any) {
228
+ return toolResult(`crystal_forget error: ${err.message}`, true);
229
+ }
230
+ },
231
+ },
232
+ { optional: true }
233
+ );
234
+
235
+ // ── Hook: before_compaction (auto dev updates) ──
236
+
237
+ api.on('before_compaction', (_event: any, _ctx: any) => {
238
+ try {
239
+ const result = runDevUpdate('lesa');
240
+ if (result.reposUpdated > 0) {
241
+ api.logger.info(`memory-crystal: auto-dev-update wrote ${result.reposUpdated} updates before compaction`);
242
+ }
243
+ } catch (err: any) {
244
+ api.logger.warn(`memory-crystal: auto-dev-update failed: ${err.message}`);
245
+ }
246
+ });
247
+
248
+ api.logger.info('memory-crystal plugin registered');
249
+ }
250
+ };