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/poller.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// memory-crystal/poller.ts — Mini-side relay poller.
|
|
3
|
+
// Polls the ephemeral relay Worker for new conversation drops from remote devices.
|
|
4
|
+
// Verifies HMAC, decrypts, ingests into master crystal.
|
|
5
|
+
// Also pushes encrypted mirror snapshots for remote devices.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// node poller.js Poll once (cron mode)
|
|
9
|
+
// node poller.js --watch Poll continuously (every 2 min)
|
|
10
|
+
// node poller.js --push-mirror Export + encrypt + push mirror snapshot
|
|
11
|
+
// node poller.js --status Show relay status
|
|
12
|
+
|
|
13
|
+
import { Crystal, resolveConfig, type Chunk } from './core.js';
|
|
14
|
+
import { loadRelayKey, decryptJSON, encrypt, hashBuffer, type EncryptedPayload } from './crypto.js';
|
|
15
|
+
import { ensureLdm, ldmPaths } from './ldm.js';
|
|
16
|
+
import { generateSessionSummary, writeSummaryFile, type SummaryMessage } from './summarize.js';
|
|
17
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const HOME = process.env.HOME || '';
|
|
21
|
+
const RELAY_URL = process.env.CRYSTAL_RELAY_URL || '';
|
|
22
|
+
const RELAY_TOKEN = process.env.CRYSTAL_RELAY_TOKEN || '';
|
|
23
|
+
const OC_DIR = join(HOME, '.openclaw');
|
|
24
|
+
const POLLER_STATE_PATH = join(OC_DIR, 'memory', 'relay-poller-state.json');
|
|
25
|
+
|
|
26
|
+
interface PollerState {
|
|
27
|
+
lastPoll: string | null;
|
|
28
|
+
totalIngested: number;
|
|
29
|
+
lastMirrorPush: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadState(): PollerState {
|
|
33
|
+
try {
|
|
34
|
+
if (existsSync(POLLER_STATE_PATH)) {
|
|
35
|
+
return JSON.parse(readFileSync(POLLER_STATE_PATH, 'utf-8'));
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
return { lastPoll: null, totalIngested: 0, lastMirrorPush: null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function saveState(state: PollerState): void {
|
|
42
|
+
const dir = dirname(POLLER_STATE_PATH);
|
|
43
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
44
|
+
writeFileSync(POLLER_STATE_PATH, JSON.stringify(state, null, 2));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Relay message types ──
|
|
48
|
+
|
|
49
|
+
interface RelayDrop {
|
|
50
|
+
agent_id: string;
|
|
51
|
+
dropped_at: string;
|
|
52
|
+
messages: Array<{
|
|
53
|
+
text: string;
|
|
54
|
+
role: string;
|
|
55
|
+
timestamp: string;
|
|
56
|
+
sessionId: string;
|
|
57
|
+
}>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface BlobInfo {
|
|
61
|
+
id: string;
|
|
62
|
+
size: number;
|
|
63
|
+
dropped_at: string;
|
|
64
|
+
agent_id: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Poll and ingest ──
|
|
68
|
+
|
|
69
|
+
async function pollOnce(): Promise<{ ingested: number; errors: number }> {
|
|
70
|
+
if (!RELAY_URL || !RELAY_TOKEN) {
|
|
71
|
+
throw new Error('CRYSTAL_RELAY_URL and CRYSTAL_RELAY_TOKEN must be set');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const relayKey = loadRelayKey();
|
|
75
|
+
let ingested = 0;
|
|
76
|
+
let errors = 0;
|
|
77
|
+
|
|
78
|
+
// List available conversation blobs
|
|
79
|
+
const listResp = await fetch(`${RELAY_URL}/pickup/conversations`, {
|
|
80
|
+
headers: { 'Authorization': `Bearer ${RELAY_TOKEN}` },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!listResp.ok) {
|
|
84
|
+
throw new Error(`Relay list failed: ${listResp.status} ${await listResp.text()}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const listData = await listResp.json() as { count: number; blobs: BlobInfo[] };
|
|
88
|
+
|
|
89
|
+
if (listData.count === 0) {
|
|
90
|
+
return { ingested: 0, errors: 0 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
process.stderr.write(`[relay-poller] ${listData.count} blob(s) waiting\n`);
|
|
94
|
+
|
|
95
|
+
// Initialize crystal for ingestion
|
|
96
|
+
const config = resolveConfig();
|
|
97
|
+
const crystal = new Crystal(config);
|
|
98
|
+
await crystal.init();
|
|
99
|
+
|
|
100
|
+
// Process each blob
|
|
101
|
+
for (const blob of listData.blobs) {
|
|
102
|
+
try {
|
|
103
|
+
// Fetch the encrypted blob
|
|
104
|
+
const blobResp = await fetch(`${RELAY_URL}/pickup/conversations/${blob.id}`, {
|
|
105
|
+
headers: { 'Authorization': `Bearer ${RELAY_TOKEN}` },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (!blobResp.ok) {
|
|
109
|
+
process.stderr.write(`[relay-poller] failed to fetch blob ${blob.id}: ${blobResp.status}\n`);
|
|
110
|
+
errors++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const encryptedText = await blobResp.text();
|
|
115
|
+
const encrypted = JSON.parse(encryptedText) as EncryptedPayload;
|
|
116
|
+
|
|
117
|
+
// Verify HMAC + decrypt
|
|
118
|
+
let drop: RelayDrop;
|
|
119
|
+
try {
|
|
120
|
+
drop = decryptJSON<RelayDrop>(encrypted, relayKey);
|
|
121
|
+
} catch (err: any) {
|
|
122
|
+
process.stderr.write(`[relay-poller] blob ${blob.id} failed verification: ${err.message} — DISCARDED\n`);
|
|
123
|
+
// Delete the bad blob so it doesn't block future polls
|
|
124
|
+
await fetch(`${RELAY_URL}/confirm/conversations/${blob.id}`, {
|
|
125
|
+
method: 'DELETE',
|
|
126
|
+
headers: { 'Authorization': `Bearer ${RELAY_TOKEN}` },
|
|
127
|
+
});
|
|
128
|
+
errors++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build chunks from decrypted messages
|
|
133
|
+
const maxSingleChunkChars = 2000 * 4;
|
|
134
|
+
const chunks: Chunk[] = [];
|
|
135
|
+
|
|
136
|
+
for (const msg of drop.messages) {
|
|
137
|
+
if (msg.text.length <= maxSingleChunkChars) {
|
|
138
|
+
chunks.push({
|
|
139
|
+
text: msg.text,
|
|
140
|
+
role: msg.role as 'user' | 'assistant',
|
|
141
|
+
source_type: 'conversation',
|
|
142
|
+
source_id: `cc:${msg.sessionId}`,
|
|
143
|
+
agent_id: drop.agent_id,
|
|
144
|
+
token_count: Math.ceil(msg.text.length / 4),
|
|
145
|
+
created_at: msg.timestamp,
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
for (const ct of crystal.chunkText(msg.text)) {
|
|
149
|
+
chunks.push({
|
|
150
|
+
text: ct,
|
|
151
|
+
role: msg.role as 'user' | 'assistant',
|
|
152
|
+
source_type: 'conversation',
|
|
153
|
+
source_id: `cc:${msg.sessionId}`,
|
|
154
|
+
agent_id: drop.agent_id,
|
|
155
|
+
token_count: Math.ceil(ct.length / 4),
|
|
156
|
+
created_at: msg.timestamp,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Ingest into master crystal
|
|
163
|
+
const count = await crystal.ingest(chunks);
|
|
164
|
+
ingested += count;
|
|
165
|
+
|
|
166
|
+
// Confirm receipt — Worker deletes the blob
|
|
167
|
+
await fetch(`${RELAY_URL}/confirm/conversations/${blob.id}`, {
|
|
168
|
+
method: 'DELETE',
|
|
169
|
+
headers: { 'Authorization': `Bearer ${RELAY_TOKEN}` },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
process.stderr.write(`[relay-poller] blob ${blob.id}: ${count} chunks ingested from ${drop.agent_id}\n`);
|
|
173
|
+
|
|
174
|
+
// Reconstruct remote agent's file tree on Mini
|
|
175
|
+
try {
|
|
176
|
+
const remotePaths = ensureLdm(drop.agent_id);
|
|
177
|
+
|
|
178
|
+
// 1. Write JSONL transcript
|
|
179
|
+
const jsonlPath = join(remotePaths.transcripts, `relay-${blob.id}.jsonl`);
|
|
180
|
+
const jsonlLines = drop.messages.map(m => JSON.stringify(m)).join('\n') + '\n';
|
|
181
|
+
writeFileSync(jsonlPath, jsonlLines);
|
|
182
|
+
|
|
183
|
+
// 2. Generate MD session summary
|
|
184
|
+
const summaryMsgs: SummaryMessage[] = drop.messages.map(m => ({
|
|
185
|
+
role: m.role,
|
|
186
|
+
text: m.text,
|
|
187
|
+
timestamp: m.timestamp,
|
|
188
|
+
sessionId: m.sessionId,
|
|
189
|
+
}));
|
|
190
|
+
const summary = await generateSessionSummary(summaryMsgs);
|
|
191
|
+
const sessionId = drop.messages[0]?.sessionId || 'unknown';
|
|
192
|
+
writeSummaryFile(remotePaths.sessions, summary, drop.agent_id, sessionId);
|
|
193
|
+
|
|
194
|
+
// 3. Append daily breadcrumb
|
|
195
|
+
const now = new Date();
|
|
196
|
+
const dateStr = now.toISOString().slice(0, 10);
|
|
197
|
+
const dailyPath = join(remotePaths.daily, `${dateStr}.md`);
|
|
198
|
+
if (!existsSync(dailyPath)) {
|
|
199
|
+
writeFileSync(dailyPath, `# ${dateStr} - ${drop.agent_id} Daily Log (via relay)\n\n`);
|
|
200
|
+
}
|
|
201
|
+
const firstUser = drop.messages.find(m => m.role === 'user');
|
|
202
|
+
if (firstUser) {
|
|
203
|
+
const snippet = firstUser.text.slice(0, 120).replace(/\n/g, ' ').trim();
|
|
204
|
+
appendFileSync(dailyPath, `- **${now.toISOString().slice(11, 16)}** [relay] ${snippet}\n`);
|
|
205
|
+
}
|
|
206
|
+
} catch (fileErr: any) {
|
|
207
|
+
process.stderr.write(`[relay-poller] file tree write failed (non-fatal): ${fileErr.message}\n`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
} catch (err: any) {
|
|
211
|
+
process.stderr.write(`[relay-poller] error processing blob ${blob.id}: ${err.message}\n`);
|
|
212
|
+
errors++;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { ingested, errors };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Push mirror snapshot ──
|
|
220
|
+
|
|
221
|
+
async function pushMirror(): Promise<void> {
|
|
222
|
+
if (!RELAY_URL || !RELAY_TOKEN) {
|
|
223
|
+
throw new Error('CRYSTAL_RELAY_URL and CRYSTAL_RELAY_TOKEN must be set');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const relayKey = loadRelayKey();
|
|
227
|
+
const config = resolveConfig();
|
|
228
|
+
const paths = ldmPaths();
|
|
229
|
+
const dbPath = existsSync(paths.crystalDb) ? paths.crystalDb : join(config.dataDir || join(OC_DIR, 'memory-crystal'), 'crystal.db');
|
|
230
|
+
|
|
231
|
+
if (!existsSync(dbPath)) {
|
|
232
|
+
throw new Error(`Crystal DB not found at ${dbPath}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Read the DB file
|
|
236
|
+
const dbData = readFileSync(dbPath);
|
|
237
|
+
const dbHash = hashBuffer(dbData);
|
|
238
|
+
|
|
239
|
+
// Build mirror payload: hash + encrypted DB
|
|
240
|
+
const mirrorMeta = JSON.stringify({ hash: dbHash, size: dbData.length, pushed_at: new Date().toISOString() });
|
|
241
|
+
const metaEncrypted = encrypt(Buffer.from(mirrorMeta, 'utf-8'), relayKey);
|
|
242
|
+
const dbEncrypted = encrypt(dbData, relayKey);
|
|
243
|
+
|
|
244
|
+
const payload = JSON.stringify({
|
|
245
|
+
meta: metaEncrypted,
|
|
246
|
+
db: dbEncrypted,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Drop at Worker
|
|
250
|
+
const resp = await fetch(`${RELAY_URL}/drop/mirror`, {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
headers: {
|
|
253
|
+
'Authorization': `Bearer ${RELAY_TOKEN}`,
|
|
254
|
+
'Content-Type': 'application/octet-stream',
|
|
255
|
+
},
|
|
256
|
+
body: payload,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (!resp.ok) {
|
|
260
|
+
throw new Error(`Mirror push failed: ${resp.status} ${await resp.text()}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const result = await resp.json() as any;
|
|
264
|
+
process.stderr.write(`[relay-poller] mirror pushed: ${(dbData.length / 1024 / 1024).toFixed(1)}MB, hash=${dbHash.slice(0, 12)}...\n`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── CLI ──
|
|
268
|
+
|
|
269
|
+
const args = process.argv.slice(2);
|
|
270
|
+
|
|
271
|
+
if (args.includes('--status')) {
|
|
272
|
+
const state = loadState();
|
|
273
|
+
const mode = (RELAY_URL && RELAY_TOKEN) ? 'configured' : 'not configured';
|
|
274
|
+
console.log(`Relay poller status:`);
|
|
275
|
+
console.log(` Relay URL: ${RELAY_URL || '(not set)'}`);
|
|
276
|
+
console.log(` Mode: ${mode}`);
|
|
277
|
+
console.log(` Last poll: ${state.lastPoll || 'never'}`);
|
|
278
|
+
console.log(` Total ingested: ${state.totalIngested}`);
|
|
279
|
+
console.log(` Last mirror: ${state.lastMirrorPush || 'never'}`);
|
|
280
|
+
process.exit(0);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (args.includes('--push-mirror')) {
|
|
284
|
+
pushMirror()
|
|
285
|
+
.then(() => {
|
|
286
|
+
const state = loadState();
|
|
287
|
+
state.lastMirrorPush = new Date().toISOString();
|
|
288
|
+
saveState(state);
|
|
289
|
+
process.exit(0);
|
|
290
|
+
})
|
|
291
|
+
.catch(err => {
|
|
292
|
+
process.stderr.write(`[relay-poller] mirror push error: ${err.message}\n`);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
});
|
|
295
|
+
} else if (args.includes('--watch')) {
|
|
296
|
+
// Continuous polling mode
|
|
297
|
+
const POLL_INTERVAL = 2 * 60 * 1000; // 2 minutes
|
|
298
|
+
|
|
299
|
+
async function loop() {
|
|
300
|
+
process.stderr.write(`[relay-poller] watching (every ${POLL_INTERVAL / 1000}s)...\n`);
|
|
301
|
+
while (true) {
|
|
302
|
+
try {
|
|
303
|
+
const { ingested, errors } = await pollOnce();
|
|
304
|
+
const state = loadState();
|
|
305
|
+
state.lastPoll = new Date().toISOString();
|
|
306
|
+
state.totalIngested += ingested;
|
|
307
|
+
saveState(state);
|
|
308
|
+
|
|
309
|
+
if (ingested > 0) {
|
|
310
|
+
process.stderr.write(`[relay-poller] poll complete: ${ingested} ingested, ${errors} errors\n`);
|
|
311
|
+
// Push mirror after successful ingestion
|
|
312
|
+
try {
|
|
313
|
+
await pushMirror();
|
|
314
|
+
state.lastMirrorPush = new Date().toISOString();
|
|
315
|
+
saveState(state);
|
|
316
|
+
} catch (mirrorErr: any) {
|
|
317
|
+
process.stderr.write(`[relay-poller] mirror push failed (non-fatal): ${mirrorErr.message}\n`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch (err: any) {
|
|
321
|
+
process.stderr.write(`[relay-poller] poll error: ${err.message}\n`);
|
|
322
|
+
}
|
|
323
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
loop();
|
|
327
|
+
} else {
|
|
328
|
+
// Single poll (cron mode)
|
|
329
|
+
pollOnce()
|
|
330
|
+
.then(({ ingested, errors }) => {
|
|
331
|
+
const state = loadState();
|
|
332
|
+
state.lastPoll = new Date().toISOString();
|
|
333
|
+
state.totalIngested += ingested;
|
|
334
|
+
saveState(state);
|
|
335
|
+
|
|
336
|
+
if (ingested > 0) {
|
|
337
|
+
process.stderr.write(`[relay-poller] ${ingested} chunks ingested, ${errors} errors\n`);
|
|
338
|
+
}
|
|
339
|
+
process.exit(errors > 0 ? 1 : 0);
|
|
340
|
+
})
|
|
341
|
+
.catch(err => {
|
|
342
|
+
process.stderr.write(`[relay-poller] error: ${err.message}\n`);
|
|
343
|
+
process.exit(1);
|
|
344
|
+
});
|
|
345
|
+
}
|
package/src/summarize.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// memory-crystal/summarize.ts — MD session summary generation.
|
|
2
|
+
// Two modes: LLM (calls gpt-4o-mini or configured provider) and simple (no API call).
|
|
3
|
+
// Controlled by CRYSTAL_SUMMARY_MODE env var.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
6
|
+
import { join, basename } from 'node:path';
|
|
7
|
+
import https from 'node:https';
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
|
|
10
|
+
// ── Types ──
|
|
11
|
+
|
|
12
|
+
export interface SessionSummary {
|
|
13
|
+
title: string;
|
|
14
|
+
slug: string;
|
|
15
|
+
summary: string;
|
|
16
|
+
topics: string[];
|
|
17
|
+
messageCount: number;
|
|
18
|
+
date: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SummaryMessage {
|
|
22
|
+
role: string;
|
|
23
|
+
text: string;
|
|
24
|
+
timestamp: string;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Config ──
|
|
29
|
+
|
|
30
|
+
const SUMMARY_MODE = process.env.CRYSTAL_SUMMARY_MODE || 'simple';
|
|
31
|
+
const SUMMARY_PROVIDER = process.env.CRYSTAL_SUMMARY_PROVIDER || 'openai';
|
|
32
|
+
const SUMMARY_MODEL = process.env.CRYSTAL_SUMMARY_MODEL || 'gpt-4o-mini';
|
|
33
|
+
|
|
34
|
+
// ── Simple mode: no API call ──
|
|
35
|
+
|
|
36
|
+
function generateSimpleSummary(messages: SummaryMessage[]): SessionSummary {
|
|
37
|
+
const firstUser = messages.find(m => m.role === 'user');
|
|
38
|
+
const title = firstUser
|
|
39
|
+
? firstUser.text.slice(0, 80).replace(/\n/g, ' ').trim()
|
|
40
|
+
: 'Untitled Session';
|
|
41
|
+
|
|
42
|
+
const slug = title
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
45
|
+
.replace(/^-|-$/g, '')
|
|
46
|
+
.slice(0, 50);
|
|
47
|
+
|
|
48
|
+
// Build preview from first 10 messages
|
|
49
|
+
const preview = messages.slice(0, 10).map(m => {
|
|
50
|
+
const roleLabel = m.role === 'user' ? 'User' : 'Assistant';
|
|
51
|
+
const snippet = m.text.slice(0, 200).replace(/\n/g, ' ').trim();
|
|
52
|
+
return `**${roleLabel}:** ${snippet}${m.text.length > 200 ? '...' : ''}`;
|
|
53
|
+
}).join('\n\n');
|
|
54
|
+
|
|
55
|
+
const date = messages[0]?.timestamp?.slice(0, 10) || new Date().toISOString().slice(0, 10);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
title,
|
|
59
|
+
slug,
|
|
60
|
+
summary: preview,
|
|
61
|
+
topics: [],
|
|
62
|
+
messageCount: messages.length,
|
|
63
|
+
date,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── LLM mode: call API for summary ──
|
|
68
|
+
|
|
69
|
+
async function generateLlmSummary(messages: SummaryMessage[]): Promise<SessionSummary> {
|
|
70
|
+
// Condense transcript for the LLM (keep it under ~4000 tokens)
|
|
71
|
+
const condensed = messages.slice(0, 30).map(m => {
|
|
72
|
+
const roleLabel = m.role === 'user' ? 'User' : 'Assistant';
|
|
73
|
+
const text = m.text.slice(0, 500);
|
|
74
|
+
return `${roleLabel}: ${text}`;
|
|
75
|
+
}).join('\n\n');
|
|
76
|
+
|
|
77
|
+
const prompt = `Summarize this conversation. Return JSON only, no markdown fences.
|
|
78
|
+
|
|
79
|
+
Format:
|
|
80
|
+
{"title": "short title", "slug": "url-safe-slug", "summary": "2-4 sentences", "topics": ["topic1", "topic2"]}
|
|
81
|
+
|
|
82
|
+
Conversation:
|
|
83
|
+
${condensed}`;
|
|
84
|
+
|
|
85
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
86
|
+
if (!apiKey) {
|
|
87
|
+
// Fall back to simple mode if no API key
|
|
88
|
+
return generateSimpleSummary(messages);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const body = JSON.stringify({
|
|
93
|
+
model: SUMMARY_MODEL,
|
|
94
|
+
messages: [{ role: 'user', content: prompt }],
|
|
95
|
+
temperature: 0.3,
|
|
96
|
+
max_tokens: 300,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = await httpPost('https://api.openai.com/v1/chat/completions', body, {
|
|
100
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const parsed = JSON.parse(result);
|
|
105
|
+
const content = parsed.choices?.[0]?.message?.content || '';
|
|
106
|
+
|
|
107
|
+
// Parse JSON from response (strip markdown fences if present)
|
|
108
|
+
const jsonStr = content.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
|
|
109
|
+
const data = JSON.parse(jsonStr);
|
|
110
|
+
|
|
111
|
+
const date = messages[0]?.timestamp?.slice(0, 10) || new Date().toISOString().slice(0, 10);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
title: data.title || 'Untitled',
|
|
115
|
+
slug: (data.slug || 'untitled').slice(0, 50),
|
|
116
|
+
summary: data.summary || '',
|
|
117
|
+
topics: data.topics || [],
|
|
118
|
+
messageCount: messages.length,
|
|
119
|
+
date,
|
|
120
|
+
};
|
|
121
|
+
} catch {
|
|
122
|
+
// LLM failed, fall back to simple
|
|
123
|
+
return generateSimpleSummary(messages);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── HTTP helper ──
|
|
128
|
+
|
|
129
|
+
function httpPost(url: string, body: string, headers: Record<string, string>): Promise<string> {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const parsed = new URL(url);
|
|
132
|
+
const client = parsed.protocol === 'https:' ? https : http;
|
|
133
|
+
|
|
134
|
+
const req = client.request({
|
|
135
|
+
hostname: parsed.hostname,
|
|
136
|
+
port: parsed.port,
|
|
137
|
+
path: parsed.pathname + parsed.search,
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { ...headers, 'Content-Length': Buffer.byteLength(body) },
|
|
140
|
+
timeout: 30000,
|
|
141
|
+
}, (res) => {
|
|
142
|
+
let data = '';
|
|
143
|
+
res.on('data', chunk => { data += chunk; });
|
|
144
|
+
res.on('end', () => {
|
|
145
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
146
|
+
resolve(data);
|
|
147
|
+
} else {
|
|
148
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
req.on('error', reject);
|
|
154
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
155
|
+
req.write(body);
|
|
156
|
+
req.end();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Public API ──
|
|
161
|
+
|
|
162
|
+
export async function generateSessionSummary(messages: SummaryMessage[]): Promise<SessionSummary> {
|
|
163
|
+
if (SUMMARY_MODE === 'llm') {
|
|
164
|
+
return generateLlmSummary(messages);
|
|
165
|
+
}
|
|
166
|
+
return generateSimpleSummary(messages);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function formatSummaryMarkdown(summary: SessionSummary, sessionId: string): string {
|
|
170
|
+
const lines: string[] = [];
|
|
171
|
+
lines.push(`# ${summary.title}`);
|
|
172
|
+
lines.push('');
|
|
173
|
+
lines.push(`**Session:** ${sessionId} **Date:** ${summary.date} **Messages:** ${summary.messageCount}`);
|
|
174
|
+
lines.push('');
|
|
175
|
+
lines.push('## Summary');
|
|
176
|
+
lines.push('');
|
|
177
|
+
lines.push(summary.summary);
|
|
178
|
+
|
|
179
|
+
if (summary.topics.length > 0) {
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push('## Key Topics');
|
|
182
|
+
lines.push('');
|
|
183
|
+
for (const topic of summary.topics) {
|
|
184
|
+
lines.push(`- ${topic}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
lines.push('');
|
|
189
|
+
return lines.join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function writeSummaryFile(
|
|
193
|
+
sessionsDir: string,
|
|
194
|
+
summary: SessionSummary,
|
|
195
|
+
agentId: string,
|
|
196
|
+
sessionId: string,
|
|
197
|
+
): string {
|
|
198
|
+
if (!existsSync(sessionsDir)) mkdirSync(sessionsDir, { recursive: true });
|
|
199
|
+
|
|
200
|
+
const now = new Date();
|
|
201
|
+
const dateStr = now.toISOString().slice(0, 10);
|
|
202
|
+
const timeStr = now.toISOString().slice(11, 19).replace(/:/g, '-');
|
|
203
|
+
const filename = `${dateStr}--${timeStr}--${agentId}--${summary.slug}.md`;
|
|
204
|
+
const filepath = join(sessionsDir, filename);
|
|
205
|
+
|
|
206
|
+
const content = formatSummaryMarkdown(summary, sessionId);
|
|
207
|
+
writeFileSync(filepath, content);
|
|
208
|
+
|
|
209
|
+
return filepath;
|
|
210
|
+
}
|