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/cc-hook.ts
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// memory-crystal/cc-hook.ts — Claude Code Stop hook handler.
|
|
3
|
+
// Triggered after every Claude Code response. Reads the session JSONL,
|
|
4
|
+
// extracts new turns since last watermark.
|
|
5
|
+
//
|
|
6
|
+
// Two modes:
|
|
7
|
+
// LOCAL: Ingests directly into local crystal (Mini)
|
|
8
|
+
// RELAY: Encrypts and drops at ephemeral relay Worker (Air/remote devices)
|
|
9
|
+
//
|
|
10
|
+
// Usage (Stop hook):
|
|
11
|
+
// Receives JSON on stdin: { transcript_path, session_id, ... }
|
|
12
|
+
//
|
|
13
|
+
// Usage (CLI):
|
|
14
|
+
// node cc-hook.js --on Enable capture
|
|
15
|
+
// node cc-hook.js --off Disable capture
|
|
16
|
+
// node cc-hook.js --status Check state
|
|
17
|
+
|
|
18
|
+
import { Crystal, RemoteCrystal, resolveConfig, createCrystal, type Chunk } from './core.js';
|
|
19
|
+
import { loadRelayKey, encryptJSON } from './crypto.js';
|
|
20
|
+
import { ensureLdm, ldmPaths } from './ldm.js';
|
|
21
|
+
import {
|
|
22
|
+
readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync,
|
|
23
|
+
statSync, openSync, readSync, closeSync, copyFileSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { join, basename, dirname } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const HOME = process.env.HOME || '';
|
|
28
|
+
const CC_AGENT_ID = process.env.CRYSTAL_AGENT_ID || 'cc-mini';
|
|
29
|
+
const RELAY_URL = process.env.CRYSTAL_RELAY_URL || '';
|
|
30
|
+
const RELAY_TOKEN = process.env.CRYSTAL_RELAY_TOKEN || '';
|
|
31
|
+
const OC_DIR = join(HOME, '.openclaw');
|
|
32
|
+
const LDM_DAILY = join(HOME, '.ldm', 'agents', CC_AGENT_ID, 'memory', 'daily');
|
|
33
|
+
const PRIVATE_MODE_PATH = join(OC_DIR, 'memory', 'memory-capture-state.json');
|
|
34
|
+
const WATERMARK_PATH = join(OC_DIR, 'memory', 'cc-capture-watermark.json');
|
|
35
|
+
const CC_ENABLED_PATH = join(OC_DIR, 'memory', 'cc-capture-enabled.json');
|
|
36
|
+
|
|
37
|
+
// ── Mode detection ──
|
|
38
|
+
|
|
39
|
+
type CaptureMode = 'local' | 'relay';
|
|
40
|
+
|
|
41
|
+
function getCaptureMode(): CaptureMode {
|
|
42
|
+
if (RELAY_URL && RELAY_TOKEN) return 'relay';
|
|
43
|
+
return 'local';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Private mode (shared with Lēsa's system) ──
|
|
47
|
+
|
|
48
|
+
function isPrivateMode(): boolean {
|
|
49
|
+
try {
|
|
50
|
+
if (existsSync(PRIVATE_MODE_PATH)) {
|
|
51
|
+
const state = JSON.parse(readFileSync(PRIVATE_MODE_PATH, 'utf-8'));
|
|
52
|
+
return state.enabled === false;
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── CC capture on/off switch ──
|
|
59
|
+
|
|
60
|
+
function isCaptureEnabled(): boolean {
|
|
61
|
+
try {
|
|
62
|
+
if (existsSync(CC_ENABLED_PATH)) {
|
|
63
|
+
const state = JSON.parse(readFileSync(CC_ENABLED_PATH, 'utf-8'));
|
|
64
|
+
return state.enabled !== false;
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
return true; // Default: on
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function setCaptureEnabled(enabled: boolean): void {
|
|
71
|
+
const dir = dirname(CC_ENABLED_PATH);
|
|
72
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
73
|
+
writeFileSync(CC_ENABLED_PATH, JSON.stringify({
|
|
74
|
+
enabled,
|
|
75
|
+
updatedAt: new Date().toISOString(),
|
|
76
|
+
}, null, 2));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Watermark ──
|
|
80
|
+
|
|
81
|
+
interface Watermark {
|
|
82
|
+
files: Record<string, { lastByteOffset: number; lastTimestamp: string }>;
|
|
83
|
+
lastRun: string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function loadWatermark(): Watermark {
|
|
87
|
+
try {
|
|
88
|
+
if (existsSync(WATERMARK_PATH)) {
|
|
89
|
+
return JSON.parse(readFileSync(WATERMARK_PATH, 'utf-8'));
|
|
90
|
+
}
|
|
91
|
+
} catch {}
|
|
92
|
+
return { files: {}, lastRun: null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function saveWatermark(wm: Watermark): void {
|
|
96
|
+
const dir = dirname(WATERMARK_PATH);
|
|
97
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
98
|
+
wm.lastRun = new Date().toISOString();
|
|
99
|
+
writeFileSync(WATERMARK_PATH, JSON.stringify(wm, null, 2));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── JSONL parsing ──
|
|
103
|
+
|
|
104
|
+
interface ExtractedMessage {
|
|
105
|
+
role: string;
|
|
106
|
+
text: string;
|
|
107
|
+
timestamp: string;
|
|
108
|
+
sessionId: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function extractMessages(filePath: string, lastByteOffset: number): {
|
|
112
|
+
messages: ExtractedMessage[];
|
|
113
|
+
newByteOffset: number;
|
|
114
|
+
} {
|
|
115
|
+
const fileSize = statSync(filePath).size;
|
|
116
|
+
if (lastByteOffset >= fileSize) {
|
|
117
|
+
return { messages: [], newByteOffset: fileSize };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const fd = openSync(filePath, 'r');
|
|
121
|
+
const bufSize = fileSize - lastByteOffset;
|
|
122
|
+
const buf = Buffer.alloc(bufSize);
|
|
123
|
+
readSync(fd, buf, 0, bufSize, lastByteOffset);
|
|
124
|
+
closeSync(fd);
|
|
125
|
+
|
|
126
|
+
const lines = buf.toString('utf-8').split('\n').filter(Boolean);
|
|
127
|
+
const messages: ExtractedMessage[] = [];
|
|
128
|
+
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
try {
|
|
131
|
+
const obj = JSON.parse(line);
|
|
132
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') continue;
|
|
133
|
+
|
|
134
|
+
const msg = obj.message;
|
|
135
|
+
if (!msg) continue;
|
|
136
|
+
|
|
137
|
+
let text = '';
|
|
138
|
+
if (typeof msg.content === 'string') {
|
|
139
|
+
text = msg.content;
|
|
140
|
+
} else if (Array.isArray(msg.content)) {
|
|
141
|
+
const parts: string[] = [];
|
|
142
|
+
for (const block of msg.content) {
|
|
143
|
+
if (block.type === 'text' && block.text) parts.push(block.text);
|
|
144
|
+
if (block.type === 'thinking' && block.thinking) parts.push(`[thinking] ${block.thinking}`);
|
|
145
|
+
}
|
|
146
|
+
text = parts.join('\n\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (text.length < 20) continue;
|
|
150
|
+
|
|
151
|
+
messages.push({
|
|
152
|
+
role: msg.role || obj.type,
|
|
153
|
+
text,
|
|
154
|
+
timestamp: obj.timestamp || new Date().toISOString(),
|
|
155
|
+
sessionId: obj.sessionId || 'unknown',
|
|
156
|
+
});
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { messages, newByteOffset: fileSize };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── JSONL transcript archive ──
|
|
164
|
+
|
|
165
|
+
function archiveTranscript(transcriptPath: string, agentId?: string): void {
|
|
166
|
+
try {
|
|
167
|
+
if (isPrivateMode()) return;
|
|
168
|
+
const paths = ensureLdm(agentId);
|
|
169
|
+
const dest = join(paths.transcripts, basename(transcriptPath));
|
|
170
|
+
// Only copy if source is newer than destination (mtime check)
|
|
171
|
+
if (existsSync(dest)) {
|
|
172
|
+
const srcMtime = statSync(transcriptPath).mtimeMs;
|
|
173
|
+
const dstMtime = statSync(dest).mtimeMs;
|
|
174
|
+
if (srcMtime <= dstMtime) return;
|
|
175
|
+
}
|
|
176
|
+
// copyFileSync imported at top of file
|
|
177
|
+
copyFileSync(transcriptPath, dest);
|
|
178
|
+
} catch {} // Non-fatal
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// archiveTranscript: copies JSONL to ~/.ldm/agents/{id}/transcripts/
|
|
182
|
+
// Called early in main(), after kill-switch checks, before watermark logic.
|
|
183
|
+
|
|
184
|
+
// ── Daily log breadcrumb ──
|
|
185
|
+
|
|
186
|
+
function appendDailyLog(messages: ExtractedMessage[], agentId?: string): void {
|
|
187
|
+
try {
|
|
188
|
+
const paths = ldmPaths(agentId);
|
|
189
|
+
if (!existsSync(paths.root)) return; // LDM not scaffolded
|
|
190
|
+
if (!existsSync(paths.daily)) mkdirSync(paths.daily, { recursive: true });
|
|
191
|
+
|
|
192
|
+
const now = new Date();
|
|
193
|
+
const dateStr = now.toISOString().slice(0, 10);
|
|
194
|
+
const timeStr = now.toLocaleTimeString('en-US', {
|
|
195
|
+
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
196
|
+
timeZone: 'America/Los_Angeles',
|
|
197
|
+
});
|
|
198
|
+
const logPath = join(paths.daily, `${dateStr}.md`);
|
|
199
|
+
|
|
200
|
+
// Extract first user message as snippet
|
|
201
|
+
const userMsg = messages.find(m => m.role === 'user');
|
|
202
|
+
if (!userMsg) return;
|
|
203
|
+
const snippet = userMsg.text.slice(0, 120).replace(/\n/g, ' ').trim();
|
|
204
|
+
|
|
205
|
+
const line = `- **${timeStr}** ${snippet}${userMsg.text.length > 120 ? '...' : ''}\n`;
|
|
206
|
+
|
|
207
|
+
// Create file with header if new
|
|
208
|
+
if (!existsSync(logPath)) {
|
|
209
|
+
writeFileSync(logPath, `# ${dateStr} - CC Daily Log\n\n`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
appendFileSync(logPath, line);
|
|
213
|
+
} catch {} // Fail silently
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Relay mode: encrypt and drop at Worker ──
|
|
217
|
+
|
|
218
|
+
async function dropAtRelay(messages: ExtractedMessage[]): Promise<number> {
|
|
219
|
+
const relayKey = loadRelayKey();
|
|
220
|
+
|
|
221
|
+
// Package messages for relay
|
|
222
|
+
const payload = {
|
|
223
|
+
agent_id: CC_AGENT_ID,
|
|
224
|
+
dropped_at: new Date().toISOString(),
|
|
225
|
+
messages: messages.map(m => ({
|
|
226
|
+
text: m.text,
|
|
227
|
+
role: m.role,
|
|
228
|
+
timestamp: m.timestamp,
|
|
229
|
+
sessionId: m.sessionId,
|
|
230
|
+
})),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Encrypt
|
|
234
|
+
const encrypted = encryptJSON(payload, relayKey);
|
|
235
|
+
const body = JSON.stringify(encrypted);
|
|
236
|
+
|
|
237
|
+
// Drop at Worker with retry
|
|
238
|
+
let retries = 0;
|
|
239
|
+
while (retries < 4) {
|
|
240
|
+
try {
|
|
241
|
+
const resp = await fetch(`${RELAY_URL}/drop/conversations`, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: {
|
|
244
|
+
'Authorization': `Bearer ${RELAY_TOKEN}`,
|
|
245
|
+
'Content-Type': 'application/octet-stream',
|
|
246
|
+
},
|
|
247
|
+
body,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (!resp.ok) {
|
|
251
|
+
const err = await resp.text();
|
|
252
|
+
throw new Error(`Relay drop failed: ${resp.status} ${err}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const result = await resp.json() as any;
|
|
256
|
+
return messages.length;
|
|
257
|
+
} catch (err: any) {
|
|
258
|
+
retries++;
|
|
259
|
+
if (retries >= 4) throw err;
|
|
260
|
+
const delay = Math.min(1000 * 2 ** retries, 30000);
|
|
261
|
+
process.stderr.write(` [relay retry ${retries}] ${err.message}, waiting ${delay}ms\n`);
|
|
262
|
+
await new Promise(r => setTimeout(r, delay));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Local mode: direct ingest with batched retry ──
|
|
269
|
+
|
|
270
|
+
const BATCH_SIZE = 200;
|
|
271
|
+
|
|
272
|
+
async function ingestLocal(messages: ExtractedMessage[]): Promise<number> {
|
|
273
|
+
const config = resolveConfig();
|
|
274
|
+
const crystal = createCrystal(config);
|
|
275
|
+
await crystal.init();
|
|
276
|
+
|
|
277
|
+
// Turn-boundary chunking: one message = one chunk.
|
|
278
|
+
// Only fall back to chunkText() for very long messages (>2000 tokens).
|
|
279
|
+
const maxSingleChunkChars = 2000 * 4;
|
|
280
|
+
const chunks: Chunk[] = [];
|
|
281
|
+
for (const msg of messages) {
|
|
282
|
+
if (msg.text.length <= maxSingleChunkChars) {
|
|
283
|
+
chunks.push({
|
|
284
|
+
text: msg.text,
|
|
285
|
+
role: msg.role as 'user' | 'assistant',
|
|
286
|
+
source_type: 'conversation',
|
|
287
|
+
source_id: `cc:${msg.sessionId}`,
|
|
288
|
+
agent_id: CC_AGENT_ID,
|
|
289
|
+
token_count: Math.ceil(msg.text.length / 4),
|
|
290
|
+
created_at: msg.timestamp,
|
|
291
|
+
});
|
|
292
|
+
} else {
|
|
293
|
+
for (const ct of crystal.chunkText(msg.text)) {
|
|
294
|
+
chunks.push({
|
|
295
|
+
text: ct,
|
|
296
|
+
role: msg.role as 'user' | 'assistant',
|
|
297
|
+
source_type: 'conversation',
|
|
298
|
+
source_id: `cc:${msg.sessionId}`,
|
|
299
|
+
agent_id: CC_AGENT_ID,
|
|
300
|
+
token_count: Math.ceil(ct.length / 4),
|
|
301
|
+
created_at: msg.timestamp,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Batched ingest with retry
|
|
308
|
+
let total = 0;
|
|
309
|
+
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
|
|
310
|
+
const batch = chunks.slice(i, i + BATCH_SIZE);
|
|
311
|
+
let retries = 0;
|
|
312
|
+
while (retries < 4) {
|
|
313
|
+
try {
|
|
314
|
+
total += await crystal.ingest(batch);
|
|
315
|
+
break;
|
|
316
|
+
} catch (err: any) {
|
|
317
|
+
retries++;
|
|
318
|
+
if (retries >= 4) throw err;
|
|
319
|
+
const delay = Math.min(1000 * 2 ** retries, 30000);
|
|
320
|
+
process.stderr.write(` [retry ${retries}] ${err.message}, waiting ${delay}ms\n`);
|
|
321
|
+
await new Promise(r => setTimeout(r, delay));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return total;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── CLI commands ──
|
|
329
|
+
|
|
330
|
+
const args = process.argv.slice(2);
|
|
331
|
+
|
|
332
|
+
if (args.includes('--on')) {
|
|
333
|
+
setCaptureEnabled(true);
|
|
334
|
+
console.log('(*) Claude Code memory capture ON');
|
|
335
|
+
process.exit(0);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (args.includes('--off')) {
|
|
339
|
+
setCaptureEnabled(false);
|
|
340
|
+
console.log('( ) Claude Code memory capture OFF');
|
|
341
|
+
process.exit(0);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (args.includes('--status')) {
|
|
345
|
+
const mode = getCaptureMode();
|
|
346
|
+
console.log(isCaptureEnabled() ? '(*) CC capture: ON' : '( ) CC capture: OFF');
|
|
347
|
+
console.log(isPrivateMode() ? '( ) Private mode: ON (blocks all capture)' : '(*) Private mode: OFF');
|
|
348
|
+
console.log(` Mode: ${mode}${mode === 'relay' ? ` (${RELAY_URL})` : ''}`);
|
|
349
|
+
console.log(` Agent ID: ${CC_AGENT_ID}`);
|
|
350
|
+
process.exit(0);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Stop hook handler ──
|
|
354
|
+
|
|
355
|
+
async function main(): Promise<void> {
|
|
356
|
+
// Read hook JSON from stdin
|
|
357
|
+
let input = '';
|
|
358
|
+
for await (const chunk of process.stdin) {
|
|
359
|
+
input += chunk;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let hookData: any;
|
|
363
|
+
try {
|
|
364
|
+
hookData = JSON.parse(input);
|
|
365
|
+
} catch {
|
|
366
|
+
process.exit(0);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const transcriptPath = hookData.transcript_path;
|
|
370
|
+
if (!transcriptPath || !existsSync(transcriptPath)) process.exit(0);
|
|
371
|
+
|
|
372
|
+
// Kill switches
|
|
373
|
+
if (isPrivateMode() || !isCaptureEnabled()) process.exit(0);
|
|
374
|
+
|
|
375
|
+
// Archive JSONL transcript to LDM (copy if newer)
|
|
376
|
+
archiveTranscript(transcriptPath);
|
|
377
|
+
|
|
378
|
+
const wm = loadWatermark();
|
|
379
|
+
const fileKey = transcriptPath;
|
|
380
|
+
|
|
381
|
+
// First time: seed watermark at current size (skip old history)
|
|
382
|
+
if (!wm.files[fileKey]) {
|
|
383
|
+
const size = statSync(transcriptPath).size;
|
|
384
|
+
wm.files[fileKey] = { lastByteOffset: size, lastTimestamp: new Date().toISOString() };
|
|
385
|
+
saveWatermark(wm);
|
|
386
|
+
process.stderr.write(`[cc-memory-capture] seeded ${basename(transcriptPath)} at ${size} bytes\n`);
|
|
387
|
+
process.exit(0);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const lastOffset = wm.files[fileKey].lastByteOffset || 0;
|
|
391
|
+
const { messages, newByteOffset } = extractMessages(transcriptPath, lastOffset);
|
|
392
|
+
|
|
393
|
+
if (messages.length === 0) {
|
|
394
|
+
wm.files[fileKey] = { lastByteOffset: newByteOffset, lastTimestamp: new Date().toISOString() };
|
|
395
|
+
saveWatermark(wm);
|
|
396
|
+
process.exit(0);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const totalTokens = messages.reduce((sum, m) => sum + Math.ceil(m.text.length / 4), 0);
|
|
400
|
+
|
|
401
|
+
// Min threshold
|
|
402
|
+
if (totalTokens < 500) {
|
|
403
|
+
wm.files[fileKey] = { lastByteOffset: newByteOffset, lastTimestamp: new Date().toISOString() };
|
|
404
|
+
saveWatermark(wm);
|
|
405
|
+
process.exit(0);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const mode = getCaptureMode();
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
if (mode === 'relay') {
|
|
412
|
+
// Relay mode: encrypt and drop at Worker
|
|
413
|
+
const count = await dropAtRelay(messages);
|
|
414
|
+
process.stderr.write(`[cc-memory-capture] relayed ${count} messages (${totalTokens} tokens) from ${basename(transcriptPath)}\n`);
|
|
415
|
+
} else {
|
|
416
|
+
// Local mode: direct ingest into crystal
|
|
417
|
+
const count = await ingestLocal(messages);
|
|
418
|
+
process.stderr.write(`[cc-memory-capture] ${count} chunks (${totalTokens} tokens) from ${basename(transcriptPath)}\n`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
wm.files[fileKey] = { lastByteOffset: newByteOffset, lastTimestamp: new Date().toISOString() };
|
|
422
|
+
saveWatermark(wm);
|
|
423
|
+
|
|
424
|
+
// Append breadcrumb to LDM daily log
|
|
425
|
+
appendDailyLog(messages);
|
|
426
|
+
|
|
427
|
+
// Generate MD session summary (non-fatal)
|
|
428
|
+
try {
|
|
429
|
+
const { generateSessionSummary, writeSummaryFile } = await import('./summarize.js');
|
|
430
|
+
const paths = ldmPaths();
|
|
431
|
+
const summaryMsgs = messages.map(m => ({ role: m.role, text: m.text, timestamp: m.timestamp, sessionId: m.sessionId }));
|
|
432
|
+
const summary = await generateSessionSummary(summaryMsgs);
|
|
433
|
+
const sessionId = messages[0]?.sessionId || 'unknown';
|
|
434
|
+
const agentId = process.env.CRYSTAL_AGENT_ID || 'cc-mini';
|
|
435
|
+
writeSummaryFile(paths.sessions, summary, agentId, sessionId);
|
|
436
|
+
} catch {} // Summary failure is non-fatal
|
|
437
|
+
|
|
438
|
+
// Dev updates disabled (2026-02-28). Was auto-generating files in every repo's
|
|
439
|
+
// ai/ folder after each session. Created noise, not signal. If we bring this back,
|
|
440
|
+
// it should be opt-in per repo, not a blanket scan.
|
|
441
|
+
} catch (err: any) {
|
|
442
|
+
process.stderr.write(`[cc-memory-capture] error: ${err.message}\n`);
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
main();
|