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/crypto.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// memory-crystal/crypto.ts — Client-side encryption for ephemeral relay.
|
|
2
|
+
// AES-256-GCM encryption, HMAC-SHA256 signing, HKDF key derivation.
|
|
3
|
+
// Key never leaves trusted machines. Worker sees only ciphertext.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { createCipheriv, createDecipheriv, createHmac, randomBytes, hkdfSync } from 'node:crypto';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const HOME = process.env.HOME || '';
|
|
10
|
+
const KEY_PATH = process.env.CRYSTAL_RELAY_KEY_PATH || join(HOME, '.openclaw', 'secrets', 'crystal-relay-key');
|
|
11
|
+
|
|
12
|
+
// ── Key Management ──
|
|
13
|
+
|
|
14
|
+
export function loadRelayKey(): Buffer {
|
|
15
|
+
if (!existsSync(KEY_PATH)) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Relay key not found at ${KEY_PATH}\n` +
|
|
18
|
+
`Generate one: openssl rand -base64 32 > ${KEY_PATH} && chmod 600 ${KEY_PATH}\n` +
|
|
19
|
+
`Copy the same key to all trusted machines.`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const raw = readFileSync(KEY_PATH, 'utf-8').trim();
|
|
23
|
+
const key = Buffer.from(raw, 'base64');
|
|
24
|
+
if (key.length !== 32) {
|
|
25
|
+
throw new Error(`Relay key must be 32 bytes (256 bits). Got ${key.length} bytes. Regenerate with: openssl rand -base64 32`);
|
|
26
|
+
}
|
|
27
|
+
return key;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function deriveSigningKey(masterKey: Buffer): Buffer {
|
|
31
|
+
return Buffer.from(hkdfSync('sha256', masterKey, '', 'crystal-relay-sign', 32));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── AES-256-GCM Encryption ──
|
|
35
|
+
|
|
36
|
+
export interface EncryptedPayload {
|
|
37
|
+
v: 1; // version
|
|
38
|
+
nonce: string; // 12 bytes, base64
|
|
39
|
+
ciphertext: string; // base64
|
|
40
|
+
tag: string; // 16 bytes, base64 (GCM auth tag)
|
|
41
|
+
hmac: string; // 32 bytes, hex (HMAC-SHA256 over nonce+ciphertext+tag)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function encrypt(plaintext: Buffer, masterKey: Buffer): EncryptedPayload {
|
|
45
|
+
// Random 96-bit nonce — never reuse with same key
|
|
46
|
+
const nonce = randomBytes(12);
|
|
47
|
+
|
|
48
|
+
const cipher = createCipheriv('aes-256-gcm', masterKey, nonce);
|
|
49
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
50
|
+
const tag = cipher.getAuthTag();
|
|
51
|
+
|
|
52
|
+
// HMAC over ciphertext proves origin (sender had the signing key)
|
|
53
|
+
const signingKey = deriveSigningKey(masterKey);
|
|
54
|
+
const hmacData = Buffer.concat([nonce, ciphertext, tag]);
|
|
55
|
+
const hmac = createHmac('sha256', signingKey).update(hmacData).digest('hex');
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
v: 1,
|
|
59
|
+
nonce: nonce.toString('base64'),
|
|
60
|
+
ciphertext: ciphertext.toString('base64'),
|
|
61
|
+
tag: tag.toString('base64'),
|
|
62
|
+
hmac,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function decrypt(payload: EncryptedPayload, masterKey: Buffer): Buffer {
|
|
67
|
+
if (payload.v !== 1) {
|
|
68
|
+
throw new Error(`Unknown payload version: ${payload.v}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const nonce = Buffer.from(payload.nonce, 'base64');
|
|
72
|
+
const ciphertext = Buffer.from(payload.ciphertext, 'base64');
|
|
73
|
+
const tag = Buffer.from(payload.tag, 'base64');
|
|
74
|
+
|
|
75
|
+
// Verify HMAC first — confirms origin before decrypting
|
|
76
|
+
const signingKey = deriveSigningKey(masterKey);
|
|
77
|
+
const hmacData = Buffer.concat([nonce, ciphertext, tag]);
|
|
78
|
+
const expectedHmac = createHmac('sha256', signingKey).update(hmacData).digest('hex');
|
|
79
|
+
|
|
80
|
+
if (payload.hmac !== expectedHmac) {
|
|
81
|
+
throw new Error('HMAC verification failed — blob rejected (tampered or wrong key)');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Decrypt
|
|
85
|
+
const decipher = createDecipheriv('aes-256-gcm', masterKey, nonce);
|
|
86
|
+
decipher.setAuthTag(tag);
|
|
87
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Convenience wrappers ──
|
|
91
|
+
|
|
92
|
+
export function encryptJSON(data: unknown, masterKey: Buffer): EncryptedPayload {
|
|
93
|
+
const plaintext = Buffer.from(JSON.stringify(data), 'utf-8');
|
|
94
|
+
return encrypt(plaintext, masterKey);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function decryptJSON<T = unknown>(payload: EncryptedPayload, masterKey: Buffer): T {
|
|
98
|
+
const plaintext = decrypt(payload, masterKey);
|
|
99
|
+
return JSON.parse(plaintext.toString('utf-8')) as T;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function encryptFile(filePath: string, masterKey: Buffer): EncryptedPayload {
|
|
103
|
+
const plaintext = readFileSync(filePath);
|
|
104
|
+
return encrypt(plaintext, masterKey);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Integrity hash for mirror snapshots ──
|
|
108
|
+
|
|
109
|
+
import { createHash } from 'node:crypto';
|
|
110
|
+
|
|
111
|
+
export function hashBuffer(data: Buffer): string {
|
|
112
|
+
return createHash('sha256').update(data).digest('hex');
|
|
113
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// memory-crystal/dev-update.ts — Auto-generate dev updates for changed repos.
|
|
2
|
+
// Called before compaction (Lēsa) or at session end when context is high (CC).
|
|
3
|
+
// Scans all repos for recent git activity, writes dated updates to wip-dev-updates.
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join, basename } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const HOME = process.env.HOME || '';
|
|
10
|
+
const STAFF_DIR = join(HOME, 'Documents', 'wipcomputer--mac-mini-01', 'staff');
|
|
11
|
+
const CC_REPOS = join(STAFF_DIR, 'Parker', 'Claude Code - Mini', 'repos');
|
|
12
|
+
const LESA_REPOS = join(STAFF_DIR, 'Lēsa', 'repos');
|
|
13
|
+
const DEV_UPDATES_DIR = join(CC_REPOS, 'wip-dev-updates'); // Legacy, kept for fallback
|
|
14
|
+
const LAST_RUN_PATH = join(HOME, '.openclaw', 'memory', 'dev-update-last-run.json');
|
|
15
|
+
|
|
16
|
+
interface LastRun {
|
|
17
|
+
timestamp: string;
|
|
18
|
+
author: string;
|
|
19
|
+
reposUpdated: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function loadLastRun(): LastRun | null {
|
|
23
|
+
try {
|
|
24
|
+
if (existsSync(LAST_RUN_PATH)) {
|
|
25
|
+
return JSON.parse(readFileSync(LAST_RUN_PATH, 'utf-8'));
|
|
26
|
+
}
|
|
27
|
+
} catch {}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function saveLastRun(run: LastRun): void {
|
|
32
|
+
const dir = join(HOME, '.openclaw', 'memory');
|
|
33
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
34
|
+
writeFileSync(LAST_RUN_PATH, JSON.stringify(run, null, 2));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function git(repoPath: string, cmd: string): string {
|
|
38
|
+
try {
|
|
39
|
+
return execSync(`git -C "${repoPath}" ${cmd}`, {
|
|
40
|
+
encoding: 'utf-8',
|
|
41
|
+
timeout: 10000,
|
|
42
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
43
|
+
}).trim();
|
|
44
|
+
} catch {
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scanRepo(repoPath: string, since: string): string | null {
|
|
50
|
+
if (!existsSync(join(repoPath, '.git'))) return null;
|
|
51
|
+
|
|
52
|
+
const name = basename(repoPath);
|
|
53
|
+
if (name === '_third-party-repos' || name === 'wip-dev-updates') return null;
|
|
54
|
+
|
|
55
|
+
const recentCommits = git(repoPath, `log --oneline --since="${since}"`);
|
|
56
|
+
const uncommitted = git(repoPath, 'status --porcelain');
|
|
57
|
+
|
|
58
|
+
if (!recentCommits && !uncommitted) return null;
|
|
59
|
+
|
|
60
|
+
const lines: string[] = [];
|
|
61
|
+
lines.push(`# ${name}`);
|
|
62
|
+
lines.push('');
|
|
63
|
+
|
|
64
|
+
if (recentCommits) {
|
|
65
|
+
lines.push('## Recent Commits');
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push('```');
|
|
68
|
+
lines.push(...recentCommits.split('\n').slice(0, 10));
|
|
69
|
+
lines.push('```');
|
|
70
|
+
lines.push('');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (uncommitted) {
|
|
74
|
+
lines.push('## Uncommitted Changes');
|
|
75
|
+
lines.push('');
|
|
76
|
+
lines.push('```');
|
|
77
|
+
lines.push(...uncommitted.split('\n').slice(0, 20));
|
|
78
|
+
lines.push('```');
|
|
79
|
+
lines.push('');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (recentCommits) {
|
|
83
|
+
const diffStat = git(repoPath, `diff --stat "HEAD@{${since}}" HEAD`);
|
|
84
|
+
if (diffStat) {
|
|
85
|
+
lines.push('## Files Changed');
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push('```');
|
|
88
|
+
lines.push(...diffStat.split('\n').slice(-15));
|
|
89
|
+
lines.push('```');
|
|
90
|
+
lines.push('');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const branch = git(repoPath, 'branch --show-current') || 'unknown';
|
|
95
|
+
lines.push(`**Branch:** ${branch}`);
|
|
96
|
+
lines.push('');
|
|
97
|
+
|
|
98
|
+
return lines.join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function runDevUpdate(author: 'cc' | 'lesa'): { reposUpdated: number; files: string[] } {
|
|
102
|
+
// Throttle: don't run more than once per hour
|
|
103
|
+
const lastRun = loadLastRun();
|
|
104
|
+
if (lastRun) {
|
|
105
|
+
const elapsed = Date.now() - new Date(lastRun.timestamp).getTime();
|
|
106
|
+
if (elapsed < 60 * 60 * 1000) {
|
|
107
|
+
return { reposUpdated: 0, files: [] };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Determine "since" window: since last run, or 6 hours
|
|
112
|
+
let since = '6 hours ago';
|
|
113
|
+
if (lastRun?.timestamp) {
|
|
114
|
+
const lastDate = new Date(lastRun.timestamp);
|
|
115
|
+
const hoursAgo = Math.ceil((Date.now() - lastDate.getTime()) / (1000 * 60 * 60));
|
|
116
|
+
since = `${Math.max(hoursAgo, 1)} hours ago`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const now = new Date();
|
|
120
|
+
const ts = [
|
|
121
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
122
|
+
String(now.getDate()).padStart(2, '0'),
|
|
123
|
+
String(now.getFullYear()),
|
|
124
|
+
].join('-') + '--' + [
|
|
125
|
+
String(now.getHours()).padStart(2, '0'),
|
|
126
|
+
String(now.getMinutes()).padStart(2, '0'),
|
|
127
|
+
String(now.getSeconds()).padStart(2, '0'),
|
|
128
|
+
].join('-');
|
|
129
|
+
|
|
130
|
+
const files: string[] = [];
|
|
131
|
+
|
|
132
|
+
// Scan all repo directories
|
|
133
|
+
const repoDirs = [CC_REPOS, LESA_REPOS];
|
|
134
|
+
for (const parentDir of repoDirs) {
|
|
135
|
+
if (!existsSync(parentDir)) continue;
|
|
136
|
+
let entries: string[];
|
|
137
|
+
try {
|
|
138
|
+
entries = execSync(`ls "${parentDir}"`, { encoding: 'utf-8' }).trim().split('\n');
|
|
139
|
+
} catch { continue; }
|
|
140
|
+
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
const repoPath = join(parentDir, entry);
|
|
143
|
+
const content = scanRepo(repoPath, since);
|
|
144
|
+
if (!content) continue;
|
|
145
|
+
|
|
146
|
+
const repoName = basename(repoPath);
|
|
147
|
+
// Write to repo's own ai/ folder (decentralized)
|
|
148
|
+
const outDir = join(repoPath, 'ai');
|
|
149
|
+
const outFile = join(outDir, `${now.toISOString().slice(0, 10)}--${now.toISOString().slice(11, 19).replace(/:/g, '-')}--${author}--dev-update-${repoName}.md`);
|
|
150
|
+
|
|
151
|
+
mkdirSync(outDir, { recursive: true });
|
|
152
|
+
|
|
153
|
+
const header = `*Auto-generated dev update by ${author} at ${now.toISOString().slice(0, 16).replace('T', ' ')}*\n\n`;
|
|
154
|
+
writeFileSync(outFile, content.replace(/^# .+\n/, `$&\n${header}`));
|
|
155
|
+
files.push(`${repoName}/ai/${basename(outFile)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Dev updates now in each repo's ai/ folder. Skip centralized commit.
|
|
160
|
+
if (false && files.length > 0 && existsSync(join(DEV_UPDATES_DIR, '.git'))) {
|
|
161
|
+
try {
|
|
162
|
+
execSync(
|
|
163
|
+
`cd "${DEV_UPDATES_DIR}" && git add -A && git commit -m "${author} auto-dev-update ${ts}: ${files.length} repo(s)" --no-verify && git push --quiet`,
|
|
164
|
+
{ encoding: 'utf-8', timeout: 30000, stdio: 'pipe' }
|
|
165
|
+
);
|
|
166
|
+
} catch {
|
|
167
|
+
// best-effort
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
saveLastRun({
|
|
172
|
+
timestamp: now.toISOString(),
|
|
173
|
+
author,
|
|
174
|
+
reposUpdated: files.length,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return { reposUpdated: files.length, files };
|
|
178
|
+
}
|
package/src/ldm.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// memory-crystal/ldm.ts — LDM directory scaffolding and path resolution.
|
|
2
|
+
// Central module for all LDM directory knowledge. Every other file imports paths from here.
|
|
3
|
+
// LDM = Learning Dreaming Machines. ~/.ldm/ is the universal agent home.
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const HOME = process.env.HOME || '';
|
|
9
|
+
const LDM_ROOT = join(HOME, '.ldm');
|
|
10
|
+
|
|
11
|
+
// ── Agent ID resolution ──
|
|
12
|
+
|
|
13
|
+
export function getAgentId(): string {
|
|
14
|
+
return process.env.CRYSTAL_AGENT_ID || 'cc-mini';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Path resolution ──
|
|
18
|
+
|
|
19
|
+
export interface LdmPaths {
|
|
20
|
+
root: string; // ~/.ldm
|
|
21
|
+
config: string; // ~/.ldm/config.json
|
|
22
|
+
crystalDb: string; // ~/.ldm/memory/crystal.db
|
|
23
|
+
crystalLance: string; // ~/.ldm/memory/lance/
|
|
24
|
+
agentRoot: string; // ~/.ldm/agents/{agent_id}
|
|
25
|
+
transcripts: string; // ~/.ldm/agents/{agent_id}/memory/transcripts/
|
|
26
|
+
sessions: string; // ~/.ldm/agents/{agent_id}/memory/sessions/
|
|
27
|
+
daily: string; // ~/.ldm/agents/{agent_id}/memory/daily/
|
|
28
|
+
journals: string; // ~/.ldm/agents/{agent_id}/memory/journals/
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ldmPaths(agentId?: string): LdmPaths {
|
|
32
|
+
const id = agentId || getAgentId();
|
|
33
|
+
const agentRoot = join(LDM_ROOT, 'agents', id);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
root: LDM_ROOT,
|
|
37
|
+
config: join(LDM_ROOT, 'config.json'),
|
|
38
|
+
crystalDb: join(LDM_ROOT, 'memory', 'crystal.db'),
|
|
39
|
+
crystalLance: join(LDM_ROOT, 'memory', 'lance'),
|
|
40
|
+
agentRoot,
|
|
41
|
+
transcripts: join(agentRoot, 'memory', 'transcripts'),
|
|
42
|
+
sessions: join(agentRoot, 'memory', 'sessions'),
|
|
43
|
+
daily: join(agentRoot, 'memory', 'daily'),
|
|
44
|
+
journals: join(agentRoot, 'memory', 'journals'),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Config file ──
|
|
49
|
+
|
|
50
|
+
interface LdmConfig {
|
|
51
|
+
version: string;
|
|
52
|
+
agents: string[];
|
|
53
|
+
createdAt: string;
|
|
54
|
+
updatedAt: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadConfig(): LdmConfig | null {
|
|
58
|
+
const configPath = join(LDM_ROOT, 'config.json');
|
|
59
|
+
try {
|
|
60
|
+
if (existsSync(configPath)) {
|
|
61
|
+
return JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function saveConfig(config: LdmConfig): void {
|
|
68
|
+
const configPath = join(LDM_ROOT, 'config.json');
|
|
69
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Scaffolding ──
|
|
73
|
+
|
|
74
|
+
export function scaffoldLdm(agentId?: string): LdmPaths {
|
|
75
|
+
const paths = ldmPaths(agentId);
|
|
76
|
+
|
|
77
|
+
// Create shared memory directories
|
|
78
|
+
mkdirSync(join(paths.root, 'memory'), { recursive: true });
|
|
79
|
+
mkdirSync(paths.crystalLance, { recursive: true });
|
|
80
|
+
|
|
81
|
+
// Create agent-specific directories
|
|
82
|
+
mkdirSync(paths.transcripts, { recursive: true });
|
|
83
|
+
mkdirSync(paths.sessions, { recursive: true });
|
|
84
|
+
mkdirSync(paths.daily, { recursive: true });
|
|
85
|
+
mkdirSync(paths.journals, { recursive: true });
|
|
86
|
+
|
|
87
|
+
// Update config.json
|
|
88
|
+
const id = agentId || getAgentId();
|
|
89
|
+
let config = loadConfig();
|
|
90
|
+
if (!config) {
|
|
91
|
+
config = {
|
|
92
|
+
version: '1.0.0',
|
|
93
|
+
agents: [id],
|
|
94
|
+
createdAt: new Date().toISOString(),
|
|
95
|
+
updatedAt: new Date().toISOString(),
|
|
96
|
+
};
|
|
97
|
+
} else {
|
|
98
|
+
if (!config.agents.includes(id)) {
|
|
99
|
+
config.agents.push(id);
|
|
100
|
+
}
|
|
101
|
+
config.updatedAt = new Date().toISOString();
|
|
102
|
+
}
|
|
103
|
+
saveConfig(config);
|
|
104
|
+
|
|
105
|
+
return paths;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function ensureLdm(agentId?: string): LdmPaths {
|
|
109
|
+
const paths = ldmPaths(agentId);
|
|
110
|
+
|
|
111
|
+
// Quick check: if agent transcripts dir exists, everything is scaffolded
|
|
112
|
+
if (existsSync(paths.transcripts) && existsSync(paths.config)) {
|
|
113
|
+
return paths;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return scaffoldLdm(agentId);
|
|
117
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// memory-crystal/mcp-server.ts — MCP tools for Claude Code.
|
|
3
|
+
// Wraps core.ts. Registered via .mcp.json.
|
|
4
|
+
|
|
5
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
import { Crystal, RemoteCrystal, resolveConfig, createCrystal } from './core.js';
|
|
9
|
+
import { existsSync, readFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const CONFIG_DIR = join(process.env.HOME || '', '.openclaw');
|
|
13
|
+
const PRIVATE_MODE_PATH = join(CONFIG_DIR, 'memory', 'memory-capture-state.json');
|
|
14
|
+
|
|
15
|
+
function isPrivateMode(): boolean {
|
|
16
|
+
try {
|
|
17
|
+
if (existsSync(PRIVATE_MODE_PATH)) {
|
|
18
|
+
const state = JSON.parse(readFileSync(PRIVATE_MODE_PATH, 'utf-8'));
|
|
19
|
+
return state.enabled === false;
|
|
20
|
+
}
|
|
21
|
+
} catch {}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const METRICS_PATH = join(CONFIG_DIR, 'memory', 'search-metrics.jsonl');
|
|
26
|
+
|
|
27
|
+
function logSearchMetric(tool: string, query: string, resultCount: number) {
|
|
28
|
+
try {
|
|
29
|
+
mkdirSync(join(CONFIG_DIR, 'memory'), { recursive: true });
|
|
30
|
+
const entry = JSON.stringify({
|
|
31
|
+
ts: new Date().toISOString(),
|
|
32
|
+
tool,
|
|
33
|
+
query,
|
|
34
|
+
results: resultCount,
|
|
35
|
+
});
|
|
36
|
+
appendFileSync(METRICS_PATH, entry + '\n');
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const config = resolveConfig();
|
|
41
|
+
const crystal = createCrystal(config);
|
|
42
|
+
const isRemote = crystal instanceof RemoteCrystal;
|
|
43
|
+
if (isRemote) {
|
|
44
|
+
process.stderr.write('[memory-crystal] Remote mode: ' + config.remoteUrl + '\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const server = new Server(
|
|
48
|
+
{ name: 'memory-crystal', version: '0.1.0' },
|
|
49
|
+
{ capabilities: { tools: {} } }
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// ── Tool Definitions ──
|
|
53
|
+
|
|
54
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
55
|
+
tools: [
|
|
56
|
+
{
|
|
57
|
+
name: 'crystal_search',
|
|
58
|
+
description: 'Search memory crystal — semantic search across all agent conversations, files, and stored memories. Returns ranked results with similarity scores.',
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: 'object' as const,
|
|
61
|
+
properties: {
|
|
62
|
+
query: { type: 'string', description: 'What to search for' },
|
|
63
|
+
limit: { type: 'number', description: 'Max results (default: 5)' },
|
|
64
|
+
agent_id: { type: 'string', description: 'Filter by agent (e.g. "main", "claude-code")' },
|
|
65
|
+
},
|
|
66
|
+
required: ['query'],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'crystal_remember',
|
|
71
|
+
description: 'Store a fact, preference, or observation in memory crystal. Persists across sessions and compaction.',
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: 'object' as const,
|
|
74
|
+
properties: {
|
|
75
|
+
text: { type: 'string', description: 'The fact or observation to remember' },
|
|
76
|
+
category: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
enum: ['fact', 'preference', 'event', 'opinion', 'skill'],
|
|
79
|
+
description: 'Category of memory (default: fact)',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ['text'],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'crystal_forget',
|
|
87
|
+
description: 'Deprecate a memory by ID. Does not delete — marks as deprecated.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object' as const,
|
|
90
|
+
properties: {
|
|
91
|
+
id: { type: 'number', description: 'Memory ID to deprecate' },
|
|
92
|
+
},
|
|
93
|
+
required: ['id'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'crystal_status',
|
|
98
|
+
description: 'Show memory crystal status — chunk count, memory count, agents, embedding provider.',
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: 'object' as const,
|
|
101
|
+
properties: {},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'crystal_sources_add',
|
|
106
|
+
description: 'Add a directory for source file indexing. Files are chunked, embedded, and searchable via crystal_search. Optional feature... does not affect existing memory capture.',
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: 'object' as const,
|
|
109
|
+
properties: {
|
|
110
|
+
path: { type: 'string', description: 'Absolute path to the directory to index' },
|
|
111
|
+
name: { type: 'string', description: 'Short name for this collection (e.g. "wipcomputer")' },
|
|
112
|
+
},
|
|
113
|
+
required: ['path', 'name'],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'crystal_sources_sync',
|
|
118
|
+
description: 'Sync a source collection: scan for new/changed/deleted files and re-index. Run after adding a collection or when files change.',
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: 'object' as const,
|
|
121
|
+
properties: {
|
|
122
|
+
name: { type: 'string', description: 'Collection name to sync' },
|
|
123
|
+
dry_run: { type: 'boolean', description: 'If true, report what would change without actually indexing' },
|
|
124
|
+
},
|
|
125
|
+
required: ['name'],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'crystal_sources_status',
|
|
130
|
+
description: 'Show status of all source file collections: file counts, chunk counts, last sync time.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: 'object' as const,
|
|
133
|
+
properties: {},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
// ── Tool Handlers ──
|
|
140
|
+
|
|
141
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
142
|
+
const { name, arguments: args } = request.params;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await crystal.init();
|
|
146
|
+
|
|
147
|
+
switch (name) {
|
|
148
|
+
case 'crystal_search': {
|
|
149
|
+
const query = args?.query as string;
|
|
150
|
+
const limit = (args?.limit as number) || 5;
|
|
151
|
+
const filter: any = {};
|
|
152
|
+
if (args?.agent_id) filter.agent_id = args.agent_id;
|
|
153
|
+
|
|
154
|
+
const results = await crystal.search(query, limit, filter);
|
|
155
|
+
logSearchMetric('crystal_search', query, results.length);
|
|
156
|
+
|
|
157
|
+
if (results.length === 0) {
|
|
158
|
+
return { content: [{ type: 'text', text: 'No results found.' }] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const freshnessIcon: Record<string, string> = { fresh: "🟢", recent: "🟡", aging: "🟠", stale: "🔴" };
|
|
162
|
+
const formatted = results.map((r, i) => {
|
|
163
|
+
const score = (r.score * 100).toFixed(1);
|
|
164
|
+
const date = r.created_at?.slice(0, 10) || 'unknown';
|
|
165
|
+
const fresh = r.freshness ? `${freshnessIcon[r.freshness]} ${r.freshness}, ` : '';
|
|
166
|
+
return `[${i + 1}] (${fresh}${score}% match, ${r.agent_id}, ${date}, ${r.role})\n${r.text}`;
|
|
167
|
+
}).join('\n\n---\n\n');
|
|
168
|
+
|
|
169
|
+
const header = '(Recency-weighted. 🟢 fresh <3d, 🟡 recent <7d, 🟠 aging <14d, 🔴 stale 14d+)\n\n';
|
|
170
|
+
return { content: [{ type: 'text', text: header + formatted }] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
case 'crystal_remember': {
|
|
174
|
+
if (isPrivateMode()) {
|
|
175
|
+
return { content: [{ type: 'text', text: 'Private mode is on. No memories are being stored. Toggle off to resume.' }] };
|
|
176
|
+
}
|
|
177
|
+
const text = args?.text as string;
|
|
178
|
+
const category = (args?.category || 'fact') as any;
|
|
179
|
+
const id = await crystal.remember(text, category);
|
|
180
|
+
return { content: [{ type: 'text', text: `Remembered (id: ${id}, category: ${category}): ${text}` }] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case 'crystal_forget': {
|
|
184
|
+
const id = args?.id as number;
|
|
185
|
+
const ok = crystal.forget(id);
|
|
186
|
+
return {
|
|
187
|
+
content: [{ type: 'text', text: ok ? `Forgot memory ${id}` : `Memory ${id} not found or already deprecated` }],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case 'crystal_status': {
|
|
192
|
+
const status = await crystal.status();
|
|
193
|
+
const text = [
|
|
194
|
+
`Memory Crystal Status${isRemote ? ' (REMOTE)' : ''}`,
|
|
195
|
+
` Data dir: ${status.dataDir}`,
|
|
196
|
+
` Provider: ${status.embeddingProvider}`,
|
|
197
|
+
` Chunks: ${status.chunks.toLocaleString()}`,
|
|
198
|
+
` Memories: ${status.memories}`,
|
|
199
|
+
` Sources: ${status.sources}`,
|
|
200
|
+
` Agents: ${status.agents.length > 0 ? status.agents.join(', ') : 'none yet'}`,
|
|
201
|
+
` Sessions: ${status.capturedSessions} captured`,
|
|
202
|
+
` Latest: ${status.latestCapture || 'never'}`,
|
|
203
|
+
].join('\n');
|
|
204
|
+
return { content: [{ type: 'text', text }] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
case 'crystal_sources_add': {
|
|
208
|
+
if (isRemote) {
|
|
209
|
+
return { content: [{ type: 'text', text: 'Source indexing not available in remote mode. Index files on the Mac Mini.' }] };
|
|
210
|
+
}
|
|
211
|
+
const path = args?.path as string;
|
|
212
|
+
const collectionName = args?.name as string;
|
|
213
|
+
const col = await (crystal as Crystal).sourcesAdd(path, collectionName);
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: 'text', text: `Added collection "${col.name}" at ${col.root_path}\nRun crystal_sources_sync with name "${collectionName}" to index files.` }],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
case 'crystal_sources_sync': {
|
|
220
|
+
if (isRemote) {
|
|
221
|
+
return { content: [{ type: 'text', text: 'Source indexing not available in remote mode. Sync files on the Mac Mini.' }] };
|
|
222
|
+
}
|
|
223
|
+
const collectionName = args?.name as string;
|
|
224
|
+
const dryRun = args?.dry_run as boolean;
|
|
225
|
+
const result = await (crystal as Crystal).sourcesSync(collectionName, { dryRun });
|
|
226
|
+
const lines = [
|
|
227
|
+
dryRun ? `Dry run for "${result.collection}":` : `Synced "${result.collection}":`,
|
|
228
|
+
` Added: ${result.added} files`,
|
|
229
|
+
` Updated: ${result.updated} files`,
|
|
230
|
+
` Removed: ${result.removed} files`,
|
|
231
|
+
` Chunks: ${result.chunks_added} embedded`,
|
|
232
|
+
` Time: ${(result.duration_ms / 1000).toFixed(1)}s`,
|
|
233
|
+
];
|
|
234
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case 'crystal_sources_status': {
|
|
238
|
+
if (isRemote) {
|
|
239
|
+
return { content: [{ type: 'text', text: 'Source indexing not available in remote mode.' }] };
|
|
240
|
+
}
|
|
241
|
+
const sourcesStatus = (crystal as Crystal).sourcesStatus();
|
|
242
|
+
if (sourcesStatus.collections.length === 0) {
|
|
243
|
+
return { content: [{ type: 'text', text: 'No source collections. Use crystal_sources_add to add a directory.' }] };
|
|
244
|
+
}
|
|
245
|
+
const lines = ['Source Collections:'];
|
|
246
|
+
for (const col of sourcesStatus.collections) {
|
|
247
|
+
const syncAgo = col.last_sync_at
|
|
248
|
+
? `${Math.round((Date.now() - new Date(col.last_sync_at).getTime()) / 60000)}m ago`
|
|
249
|
+
: 'never';
|
|
250
|
+
lines.push(` ${col.name}: ${col.file_count.toLocaleString()} files, ${col.chunk_count.toLocaleString()} chunks, last sync ${syncAgo}`);
|
|
251
|
+
}
|
|
252
|
+
lines.push(` Total: ${sourcesStatus.total_files.toLocaleString()} files, ${sourcesStatus.total_chunks.toLocaleString()} chunks`);
|
|
253
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
default:
|
|
257
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
258
|
+
}
|
|
259
|
+
} catch (err: any) {
|
|
260
|
+
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ── Start ──
|
|
265
|
+
|
|
266
|
+
async function main() {
|
|
267
|
+
const transport = new StdioServerTransport();
|
|
268
|
+
await server.connect(transport);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
main().catch(err => {
|
|
272
|
+
console.error(`MCP server failed: ${err.message}`);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
});
|