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.
- package/.env.example +20 -0
- package/CHANGELOG.md +6 -0
- package/LETTERS.md +22 -0
- package/LICENSE +21 -0
- package/README-ENTERPRISE.md +162 -0
- package/README-old.md +275 -0
- package/README.md +91 -0
- package/RELAY.md +88 -0
- package/TECHNICAL.md +379 -0
- package/ai/dev-updates/2026-02-25--cc-air--phase2-architecture-pivot.md +70 -0
- package/ai/dev-updates/2026-02-25--cc-air--phase2-worker-build.md +72 -0
- package/ai/dev-updates/2026-02-26--10-25-16--cc-mini--phase2-implementation.md +49 -0
- package/ai/dev-updates/2026-02-27--20-30-00--cc-mini--readme-overhaul-and-public-deploy.md +69 -0
- package/ai/notes/2026-02-26--cc-air--notes.md +412 -0
- package/ai/notes/2026-02-27--cc-mini--grok-feedback.md +44 -0
- package/ai/notes/2026-02-27--cc-mini--lesa-feedback.md +45 -0
- package/ai/notes/RESEARCH.md +1185 -0
- package/ai/notes/salience-research/README.md +29 -0
- package/ai/notes/salience-research/eurosla-salience-review.md +64 -0
- package/ai/notes/salience-research/full-research-summary.md +269 -0
- package/ai/notes/salience-research/salience-levels-diagram.png +0 -0
- package/ai/plan/2026-02-27--cc-mini--qr-pairing-spec.md +203 -0
- package/ai/plan/_archive/PLAN.md +194 -0
- package/ai/plan/_archive/PRD.md +1014 -0
- package/ai/plan/cc-plans-duplicates-from-dot-claude/2026-02-26--cc-mini--phase2-implementation-plan.md +245 -0
- package/ai/plan/dev-conventions-note.md +70 -0
- package/ai/plan/ldm-os-install-and-boot-architecture.md +285 -0
- package/ai/plan/memory-crystal-phase2-plan.md +192 -0
- package/ai/plan/memory-system-lay-of-the-land.md +214 -0
- package/ai/plan/phase2-ephemeral-relay.md +238 -0
- package/ai/plan/readme-first.md +68 -0
- package/ai/plan/roadmap.md +159 -0
- package/ai/todos/PUNCHLIST.md +44 -0
- package/ai/todos/README.md +31 -0
- package/ai/todos/inboxes/cc-air/2026-02-26--cc-air--post-relay-todos.md +85 -0
- package/ai/todos/inboxes/cc-mini/2026-02-26--cc-mini--phase2-status.md +100 -0
- package/ai/todos/inboxes/cc-mini/_archive/TODO.md +25 -0
- package/ai/todos/inboxes/parker/2026-02-25--cc-air--setup-checklist.md +139 -0
- package/ai/todos/inboxes/parker/2026-02-26--cc-mini--phase2-your-moves.md +72 -0
- package/dist/cc-hook.d.ts +1 -0
- package/dist/cc-hook.js +349 -0
- package/dist/chunk-3VFIJYS4.js +818 -0
- package/dist/chunk-52QE3YI3.js +1169 -0
- package/dist/chunk-AA3OPP4Z.js +432 -0
- package/dist/chunk-D3I3ZSE2.js +411 -0
- package/dist/chunk-EKSACBTJ.js +1070 -0
- package/dist/chunk-F3Y7EL7K.js +83 -0
- package/dist/chunk-JWZXYVET.js +1068 -0
- package/dist/chunk-KYVWO6ZM.js +1069 -0
- package/dist/chunk-L3VHARQH.js +413 -0
- package/dist/chunk-LOVAHSQV.js +411 -0
- package/dist/chunk-LQOYCAGG.js +446 -0
- package/dist/chunk-MK42FMEG.js +147 -0
- package/dist/chunk-NIJCVN3O.js +147 -0
- package/dist/chunk-O2UITJGH.js +465 -0
- package/dist/chunk-PEK6JH65.js +432 -0
- package/dist/chunk-PJ6FFKEX.js +77 -0
- package/dist/chunk-PLUBBZYR.js +800 -0
- package/dist/chunk-SGL6ISBJ.js +1061 -0
- package/dist/chunk-UNHVZB5G.js +411 -0
- package/dist/chunk-VAFTWSTE.js +1061 -0
- package/dist/chunk-XZ3S56RQ.js +1061 -0
- package/dist/chunk-Y72C7F6O.js +148 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +325 -0
- package/dist/core.d.ts +188 -0
- package/dist/core.js +12 -0
- package/dist/crypto.d.ts +16 -0
- package/dist/crypto.js +18 -0
- package/dist/dev-update-SZ2Z4WCQ.js +6 -0
- package/dist/ldm.d.ts +17 -0
- package/dist/ldm.js +12 -0
- package/dist/mcp-server.d.ts +1 -0
- package/dist/mcp-server.js +250 -0
- package/dist/migrate.d.ts +1 -0
- package/dist/migrate.js +89 -0
- package/dist/mirror-sync.d.ts +1 -0
- package/dist/mirror-sync.js +130 -0
- package/dist/openclaw.d.ts +5 -0
- package/dist/openclaw.js +349 -0
- package/dist/poller.d.ts +1 -0
- package/dist/poller.js +272 -0
- package/dist/summarize.d.ts +19 -0
- package/dist/summarize.js +10 -0
- package/dist/worker.js +137 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +40 -0
- package/scripts/migrate-lance-to-sqlite.mjs +217 -0
- package/skills/memory/SKILL.md +61 -0
- package/src/cc-hook.ts +447 -0
- package/src/cli.ts +356 -0
- package/src/core.ts +1472 -0
- package/src/crypto.ts +113 -0
- package/src/dev-update.ts +178 -0
- package/src/ldm.ts +117 -0
- package/src/mcp-server.ts +274 -0
- package/src/migrate.ts +104 -0
- package/src/mirror-sync.ts +175 -0
- package/src/openclaw.ts +250 -0
- package/src/poller.ts +345 -0
- package/src/summarize.ts +210 -0
- package/src/worker.ts +208 -0
- package/tsconfig.json +18 -0
- 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
|
+
});
|
package/src/openclaw.ts
ADDED
|
@@ -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
|
+
};
|