memory-crystal 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.env.example +20 -0
  2. package/CHANGELOG.md +6 -0
  3. package/LETTERS.md +22 -0
  4. package/LICENSE +21 -0
  5. package/README-ENTERPRISE.md +162 -0
  6. package/README-old.md +275 -0
  7. package/README.md +91 -0
  8. package/RELAY.md +88 -0
  9. package/TECHNICAL.md +379 -0
  10. package/ai/dev-updates/2026-02-25--cc-air--phase2-architecture-pivot.md +70 -0
  11. package/ai/dev-updates/2026-02-25--cc-air--phase2-worker-build.md +72 -0
  12. package/ai/dev-updates/2026-02-26--10-25-16--cc-mini--phase2-implementation.md +49 -0
  13. package/ai/dev-updates/2026-02-27--20-30-00--cc-mini--readme-overhaul-and-public-deploy.md +69 -0
  14. package/ai/notes/2026-02-26--cc-air--notes.md +412 -0
  15. package/ai/notes/2026-02-27--cc-mini--grok-feedback.md +44 -0
  16. package/ai/notes/2026-02-27--cc-mini--lesa-feedback.md +45 -0
  17. package/ai/notes/RESEARCH.md +1185 -0
  18. package/ai/notes/salience-research/README.md +29 -0
  19. package/ai/notes/salience-research/eurosla-salience-review.md +64 -0
  20. package/ai/notes/salience-research/full-research-summary.md +269 -0
  21. package/ai/notes/salience-research/salience-levels-diagram.png +0 -0
  22. package/ai/plan/2026-02-27--cc-mini--qr-pairing-spec.md +203 -0
  23. package/ai/plan/_archive/PLAN.md +194 -0
  24. package/ai/plan/_archive/PRD.md +1014 -0
  25. package/ai/plan/cc-plans-duplicates-from-dot-claude/2026-02-26--cc-mini--phase2-implementation-plan.md +245 -0
  26. package/ai/plan/dev-conventions-note.md +70 -0
  27. package/ai/plan/ldm-os-install-and-boot-architecture.md +285 -0
  28. package/ai/plan/memory-crystal-phase2-plan.md +192 -0
  29. package/ai/plan/memory-system-lay-of-the-land.md +214 -0
  30. package/ai/plan/phase2-ephemeral-relay.md +238 -0
  31. package/ai/plan/readme-first.md +68 -0
  32. package/ai/plan/roadmap.md +159 -0
  33. package/ai/todos/PUNCHLIST.md +44 -0
  34. package/ai/todos/README.md +31 -0
  35. package/ai/todos/inboxes/cc-air/2026-02-26--cc-air--post-relay-todos.md +85 -0
  36. package/ai/todos/inboxes/cc-mini/2026-02-26--cc-mini--phase2-status.md +100 -0
  37. package/ai/todos/inboxes/cc-mini/_archive/TODO.md +25 -0
  38. package/ai/todos/inboxes/parker/2026-02-25--cc-air--setup-checklist.md +139 -0
  39. package/ai/todos/inboxes/parker/2026-02-26--cc-mini--phase2-your-moves.md +72 -0
  40. package/dist/cc-hook.d.ts +1 -0
  41. package/dist/cc-hook.js +349 -0
  42. package/dist/chunk-3VFIJYS4.js +818 -0
  43. package/dist/chunk-52QE3YI3.js +1169 -0
  44. package/dist/chunk-AA3OPP4Z.js +432 -0
  45. package/dist/chunk-D3I3ZSE2.js +411 -0
  46. package/dist/chunk-EKSACBTJ.js +1070 -0
  47. package/dist/chunk-F3Y7EL7K.js +83 -0
  48. package/dist/chunk-JWZXYVET.js +1068 -0
  49. package/dist/chunk-KYVWO6ZM.js +1069 -0
  50. package/dist/chunk-L3VHARQH.js +413 -0
  51. package/dist/chunk-LOVAHSQV.js +411 -0
  52. package/dist/chunk-LQOYCAGG.js +446 -0
  53. package/dist/chunk-MK42FMEG.js +147 -0
  54. package/dist/chunk-NIJCVN3O.js +147 -0
  55. package/dist/chunk-O2UITJGH.js +465 -0
  56. package/dist/chunk-PEK6JH65.js +432 -0
  57. package/dist/chunk-PJ6FFKEX.js +77 -0
  58. package/dist/chunk-PLUBBZYR.js +800 -0
  59. package/dist/chunk-SGL6ISBJ.js +1061 -0
  60. package/dist/chunk-UNHVZB5G.js +411 -0
  61. package/dist/chunk-VAFTWSTE.js +1061 -0
  62. package/dist/chunk-XZ3S56RQ.js +1061 -0
  63. package/dist/chunk-Y72C7F6O.js +148 -0
  64. package/dist/cli.d.ts +1 -0
  65. package/dist/cli.js +325 -0
  66. package/dist/core.d.ts +188 -0
  67. package/dist/core.js +12 -0
  68. package/dist/crypto.d.ts +16 -0
  69. package/dist/crypto.js +18 -0
  70. package/dist/dev-update-SZ2Z4WCQ.js +6 -0
  71. package/dist/ldm.d.ts +17 -0
  72. package/dist/ldm.js +12 -0
  73. package/dist/mcp-server.d.ts +1 -0
  74. package/dist/mcp-server.js +250 -0
  75. package/dist/migrate.d.ts +1 -0
  76. package/dist/migrate.js +89 -0
  77. package/dist/mirror-sync.d.ts +1 -0
  78. package/dist/mirror-sync.js +130 -0
  79. package/dist/openclaw.d.ts +5 -0
  80. package/dist/openclaw.js +349 -0
  81. package/dist/poller.d.ts +1 -0
  82. package/dist/poller.js +272 -0
  83. package/dist/summarize.d.ts +19 -0
  84. package/dist/summarize.js +10 -0
  85. package/dist/worker.js +137 -0
  86. package/openclaw.plugin.json +11 -0
  87. package/package.json +40 -0
  88. package/scripts/migrate-lance-to-sqlite.mjs +217 -0
  89. package/skills/memory/SKILL.md +61 -0
  90. package/src/cc-hook.ts +447 -0
  91. package/src/cli.ts +356 -0
  92. package/src/core.ts +1472 -0
  93. package/src/crypto.ts +113 -0
  94. package/src/dev-update.ts +178 -0
  95. package/src/ldm.ts +117 -0
  96. package/src/mcp-server.ts +274 -0
  97. package/src/migrate.ts +104 -0
  98. package/src/mirror-sync.ts +175 -0
  99. package/src/openclaw.ts +250 -0
  100. package/src/poller.ts +345 -0
  101. package/src/summarize.ts +210 -0
  102. package/src/worker.ts +208 -0
  103. package/tsconfig.json +18 -0
  104. package/wrangler.toml +20 -0
package/src/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
+ }
@@ -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
+ }