openclaw-mem 1.0.4 → 1.2.1

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 (46) hide show
  1. package/HOOK.md +125 -0
  2. package/LICENSE +1 -1
  3. package/MCP.json +11 -0
  4. package/README.md +146 -168
  5. package/context-builder.js +703 -0
  6. package/database.js +520 -0
  7. package/debug-logger.js +280 -0
  8. package/extractor.js +211 -0
  9. package/gateway-llm.js +155 -0
  10. package/handler.js +1122 -0
  11. package/mcp-http-api.js +356 -0
  12. package/mcp-server.js +525 -0
  13. package/mem-get.sh +24 -0
  14. package/mem-search.sh +17 -0
  15. package/monitor.js +112 -0
  16. package/package.json +53 -29
  17. package/realtime-monitor.js +371 -0
  18. package/session-watcher.js +192 -0
  19. package/setup.js +114 -0
  20. package/sync-recent.js +63 -0
  21. package/README_CN.md +0 -201
  22. package/bin/openclaw-mem.js +0 -117
  23. package/docs/locales/README_AR.md +0 -35
  24. package/docs/locales/README_DE.md +0 -35
  25. package/docs/locales/README_ES.md +0 -35
  26. package/docs/locales/README_FR.md +0 -35
  27. package/docs/locales/README_HE.md +0 -35
  28. package/docs/locales/README_HI.md +0 -35
  29. package/docs/locales/README_ID.md +0 -35
  30. package/docs/locales/README_IT.md +0 -35
  31. package/docs/locales/README_JA.md +0 -57
  32. package/docs/locales/README_KO.md +0 -35
  33. package/docs/locales/README_NL.md +0 -35
  34. package/docs/locales/README_PL.md +0 -35
  35. package/docs/locales/README_PT.md +0 -35
  36. package/docs/locales/README_RU.md +0 -35
  37. package/docs/locales/README_TH.md +0 -35
  38. package/docs/locales/README_TR.md +0 -35
  39. package/docs/locales/README_UK.md +0 -35
  40. package/docs/locales/README_VI.md +0 -35
  41. package/docs/logo.svg +0 -32
  42. package/lib/context-builder.js +0 -415
  43. package/lib/database.js +0 -309
  44. package/lib/handler.js +0 -494
  45. package/scripts/commands.js +0 -141
  46. package/scripts/init.js +0 -248
package/handler.js ADDED
@@ -0,0 +1,1122 @@
1
+ /**
2
+ * OpenClaw-Mem Hook Handler
3
+ *
4
+ * Captures session content and provides memory context injection.
5
+ *
6
+ * Events handled:
7
+ * - command:new - Save session content before reset
8
+ * - gateway:startup - Initialize memory system
9
+ * - agent:bootstrap - Inject historical context
10
+ */
11
+
12
+ import fs from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { spawn } from 'node:child_process';
17
+ import { summarizeSession, INTERNAL_SUMMARY_PREFIX } from './gateway-llm.js';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ console.log('[openclaw-mem] >>> HANDLER LOADED AT', new Date().toISOString(), '<<<');
21
+ const USE_LLM_EXTRACTION = false;
22
+ const SUMMARY_MAX_MESSAGES = 200;
23
+ const MCP_API_PORT = 18790;
24
+
25
+ // Track API server process
26
+ let apiServerProcess = null;
27
+ let apiServerStarted = false;
28
+
29
+ // Avoid recursive memory capture for internal LLM runs
30
+ function isInternalSessionKey(sessionKey) {
31
+ if (!sessionKey || typeof sessionKey !== 'string') return false;
32
+ return sessionKey.startsWith(INTERNAL_SUMMARY_PREFIX);
33
+ }
34
+
35
+ // Lazy load modules
36
+ let database = null;
37
+ let contextBuilder = null;
38
+ let extractor = null;
39
+
40
+ async function loadModules() {
41
+ if (database && contextBuilder) return true;
42
+
43
+ try {
44
+ const dbModule = await import('./database.js');
45
+ const ctxModule = await import('./context-builder.js');
46
+ database = dbModule.default || dbModule.database;
47
+ contextBuilder = ctxModule.default || ctxModule;
48
+
49
+ if (USE_LLM_EXTRACTION) {
50
+ // Try to load extractor (optional, for LLM extraction)
51
+ try {
52
+ const extractorModule = await import('./extractor.js');
53
+ extractor = extractorModule.default || extractorModule;
54
+ } catch (e) {
55
+ console.log('[openclaw-mem] LLM extractor not available, using basic extraction');
56
+ }
57
+ }
58
+
59
+ return true;
60
+ } catch (err) {
61
+ console.error('[openclaw-mem] Failed to load modules:', err.message);
62
+ return false;
63
+ }
64
+ }
65
+
66
+ // Generate UUID
67
+ function generateId() {
68
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
69
+ const r = Math.random() * 16 | 0;
70
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
71
+ return v.toString(16);
72
+ });
73
+ }
74
+
75
+ // Estimate tokens
76
+ function estimateTokens(text) {
77
+ if (!text) return 0;
78
+ return Math.ceil(String(text).length / 4);
79
+ }
80
+
81
+ // Simple hash function for content deduplication
82
+ function hashContent(text) {
83
+ if (!text) return '';
84
+ let hash = 0;
85
+ for (let i = 0; i < text.length; i++) {
86
+ const char = text.charCodeAt(i);
87
+ hash = ((hash << 5) - hash) + char;
88
+ hash = hash & hash; // Convert to 32bit integer
89
+ }
90
+ return hash.toString(16);
91
+ }
92
+
93
+ /**
94
+ * Read session transcript and extract conversation
95
+ */
96
+ async function extractSessionContent(sessionFile, maxMessages = 20) {
97
+ try {
98
+ if (!sessionFile) return null;
99
+
100
+ const content = await fs.readFile(sessionFile, 'utf-8');
101
+ const lines = content.trim().split('\n');
102
+
103
+ const messages = [];
104
+ for (const line of lines) {
105
+ try {
106
+ const entry = JSON.parse(line);
107
+ if (entry.type === 'message' && entry.message) {
108
+ const msg = entry.message;
109
+ if ((msg.role === 'user' || msg.role === 'assistant') && msg.content) {
110
+ const text = Array.isArray(msg.content)
111
+ ? msg.content.find(c => c.type === 'text')?.text
112
+ : msg.content;
113
+
114
+ if (text && !text.startsWith('/')) {
115
+ messages.push({
116
+ role: msg.role,
117
+ content: text.slice(0, 500) // Truncate long messages
118
+ });
119
+ }
120
+ }
121
+ }
122
+ } catch {
123
+ // Skip invalid lines
124
+ }
125
+ }
126
+
127
+ return messages.slice(-maxMessages);
128
+ } catch (err) {
129
+ console.error('[openclaw-mem] Failed to read session file:', err.message);
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Start the MCP HTTP API server
136
+ */
137
+ async function startApiServer() {
138
+ if (apiServerStarted) {
139
+ console.log('[openclaw-mem] API server already started');
140
+ return;
141
+ }
142
+
143
+ // Check if server is already running
144
+ try {
145
+ const response = await fetch(`http://127.0.0.1:${MCP_API_PORT}/health`, {
146
+ signal: AbortSignal.timeout(1000)
147
+ });
148
+ if (response.ok) {
149
+ console.log('[openclaw-mem] API server already running on port', MCP_API_PORT);
150
+ apiServerStarted = true;
151
+ return;
152
+ }
153
+ } catch {
154
+ // Server not running, start it
155
+ }
156
+
157
+ const apiScript = path.join(__dirname, 'mcp-http-api.js');
158
+ const logDir = path.join(os.homedir(), '.openclaw-mem', 'logs');
159
+
160
+ // Ensure log directory exists
161
+ try {
162
+ await fs.mkdir(logDir, { recursive: true });
163
+ } catch {}
164
+
165
+ const logFile = path.join(logDir, 'api.log');
166
+
167
+ try {
168
+ // Start API server as detached process
169
+ apiServerProcess = spawn('node', [apiScript], {
170
+ detached: true,
171
+ stdio: ['ignore', 'pipe', 'pipe'],
172
+ env: { ...process.env, OPENCLAW_MEM_API_PORT: String(MCP_API_PORT) },
173
+ cwd: __dirname
174
+ });
175
+
176
+ // Log output to file
177
+ const logStream = await fs.open(logFile, 'a');
178
+ apiServerProcess.stdout?.on('data', (data) => {
179
+ logStream.write(data);
180
+ });
181
+ apiServerProcess.stderr?.on('data', (data) => {
182
+ logStream.write(data);
183
+ });
184
+
185
+ apiServerProcess.unref();
186
+ apiServerStarted = true;
187
+
188
+ console.log(`[openclaw-mem] ✓ API server started on port ${MCP_API_PORT} (PID: ${apiServerProcess.pid})`);
189
+
190
+ // Wait for server to be ready
191
+ for (let i = 0; i < 10; i++) {
192
+ await new Promise(r => setTimeout(r, 200));
193
+ try {
194
+ const response = await fetch(`http://127.0.0.1:${MCP_API_PORT}/health`, {
195
+ signal: AbortSignal.timeout(500)
196
+ });
197
+ if (response.ok) {
198
+ console.log('[openclaw-mem] ✓ API server is ready');
199
+ break;
200
+ }
201
+ } catch {}
202
+ }
203
+ } catch (err) {
204
+ console.error('[openclaw-mem] Failed to start API server:', err.message);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Handle gateway:startup event
210
+ */
211
+ async function handleGatewayStartup(event) {
212
+ console.log('[openclaw-mem] Gateway startup - initializing memory system');
213
+
214
+ if (!await loadModules()) return;
215
+
216
+ const stats = database.getStats();
217
+ console.log(`[openclaw-mem] Memory stats: ${stats.total_sessions} sessions, ${stats.total_observations} observations`);
218
+
219
+ // Start MCP HTTP API server
220
+ await startApiServer();
221
+ }
222
+
223
+ /**
224
+ * Handle agent:bootstrap event
225
+ * Inject historical context into new agent sessions
226
+ * AND capture incoming message to database
227
+ */
228
+ async function handleAgentBootstrap(event) {
229
+ console.log('[openclaw-mem] Agent bootstrap:', event.sessionKey);
230
+ console.log('[openclaw-mem] Event keys:', Object.keys(event));
231
+ console.log('[openclaw-mem] Context exists:', !!event.context);
232
+
233
+ if (!await loadModules()) return;
234
+
235
+ // IMPORTANT: Ensure event.context exists (modify event directly, not a local copy)
236
+ if (!event.context) {
237
+ console.log('[openclaw-mem] WARNING: event.context is missing, creating it');
238
+ event.context = {};
239
+ }
240
+
241
+ console.log('[openclaw-mem] Context keys:', Object.keys(event.context));
242
+
243
+ const workspaceDir = event.context.workspaceDir || path.join(os.homedir(), '.openclaw', 'workspace');
244
+ const sessionKey = event.sessionKey || 'unknown';
245
+
246
+ console.log('[openclaw-mem] workspaceDir:', workspaceDir);
247
+ console.log('[openclaw-mem] bootstrapFiles exists:', !!event.context.bootstrapFiles);
248
+ console.log('[openclaw-mem] bootstrapFiles is array:', Array.isArray(event.context.bootstrapFiles));
249
+ console.log('[openclaw-mem] bootstrapFiles length before:', event.context.bootstrapFiles?.length);
250
+ // Debug: show structure of first file
251
+ if (event.context.bootstrapFiles?.[0]) {
252
+ const sample = event.context.bootstrapFiles[0];
253
+ console.log('[openclaw-mem] Sample file keys:', Object.keys(sample));
254
+ console.log('[openclaw-mem] Sample file name:', sample.name);
255
+ console.log('[openclaw-mem] Sample has content:', !!sample.content);
256
+ }
257
+
258
+ // ============ NEW: Capture incoming messages to database ============
259
+ // This ensures every message through gateway is captured, not just on /new
260
+ // Messages can be in: event.messages (array), event.message, or event.context.userMessage
261
+ let messagesToCapture = [];
262
+
263
+ // ============ Capture messages from session file ============
264
+ // At bootstrap time, the incoming message isn't in the event yet
265
+ // But we can read the session file which contains previous messages
266
+
267
+ // Construct session file path from sessionKey
268
+ // Session files are stored at ~/.openclaw/agents/main/sessions/<sessionKey>.jsonl
269
+ const agentId = event.context?.agentId || 'main';
270
+ const sessionFile = path.join(os.homedir(), '.openclaw', 'agents', agentId, 'sessions', `${sessionKey}.jsonl`);
271
+ console.log('[openclaw-mem] Constructed session file path:', sessionFile);
272
+
273
+ // Check if session file exists
274
+ let sessionFileExists = false;
275
+ try {
276
+ await fs.access(sessionFile);
277
+ sessionFileExists = true;
278
+ } catch {
279
+ sessionFileExists = false;
280
+ }
281
+
282
+ if (sessionFileExists) {
283
+ console.log('[openclaw-mem] Found session file:', sessionFile);
284
+ try {
285
+ const messages = await extractSessionContent(sessionFile, 50);
286
+ if (messages && messages.length > 0) {
287
+ console.log(`[openclaw-mem] Found ${messages.length} messages in session file`);
288
+
289
+ // Get or create session for this sessionKey
290
+ let dbSessionId = getOrCreateSessionForKey(sessionKey, workspaceDir);
291
+
292
+ // Track which messages we've already saved (to avoid duplicates)
293
+ const savedHashes = new Set();
294
+ try {
295
+ const existing = database.getRecentObservations(null, 100);
296
+ for (const obs of existing) {
297
+ // Content is stored in the 'result' field as JSON
298
+ try {
299
+ const result = JSON.parse(obs.result || '{}');
300
+ if (result.content) {
301
+ savedHashes.add(hashContent(result.content));
302
+ }
303
+ } catch {
304
+ // If result isn't JSON, use summary
305
+ if (obs.summary) {
306
+ savedHashes.add(hashContent(obs.summary));
307
+ }
308
+ }
309
+ }
310
+ console.log(`[openclaw-mem] Loaded ${savedHashes.size} existing message hashes`);
311
+ } catch (e) {
312
+ console.log('[openclaw-mem] Could not check existing observations:', e.message);
313
+ }
314
+
315
+ let newCount = 0;
316
+ for (const msg of messages) {
317
+ const contentHash = hashContent(msg.content);
318
+ if (savedHashes.has(contentHash)) {
319
+ continue; // Skip already saved messages
320
+ }
321
+
322
+ const toolName = msg.role === 'assistant' ? 'AssistantMessage' : 'UserMessage';
323
+ const summary = msg.content.slice(0, 100) + (msg.content.length > 100 ? '...' : '');
324
+ database.saveObservation(
325
+ dbSessionId,
326
+ toolName,
327
+ { role: msg.role, sessionKey },
328
+ { content: msg.content },
329
+ {
330
+ summary,
331
+ // Use full message text so FTS can index real topics
332
+ concepts: msg.content,
333
+ tokensDiscovery: estimateTokens(msg.content),
334
+ tokensRead: estimateTokens(summary)
335
+ }
336
+ );
337
+ savedHashes.add(contentHash);
338
+ newCount++;
339
+ }
340
+
341
+ if (newCount > 0) {
342
+ console.log(`[openclaw-mem] ✓ Saved ${newCount} new messages to database`);
343
+ } else {
344
+ console.log('[openclaw-mem] All messages already in database');
345
+ }
346
+ }
347
+ } catch (err) {
348
+ console.log('[openclaw-mem] Could not read session file:', err.message);
349
+ }
350
+ } else {
351
+ console.log('[openclaw-mem] No session file found in context');
352
+ }
353
+ // ============ END: Capture messages ============
354
+
355
+ // Ensure API server is running
356
+ await startApiServer();
357
+
358
+ // Build context to inject (async for LLM extraction)
359
+ const memContext = await contextBuilder.buildContext(workspaceDir, {
360
+ observationLimit: 30,
361
+ fullDetailCount: 3,
362
+ useLLMExtraction: USE_LLM_EXTRACTION
363
+ });
364
+
365
+ // Build tool instructions for memory retrieval
366
+ const toolInstructions = `
367
+ ---
368
+
369
+ ## 🧠 记忆检索工具(重要:必须使用)
370
+
371
+ **⚠️ 当用户问到以下情况时,你必须先调用脚本获取详情,再回答:**
372
+ - "具体内容是什么"、"详细步骤"、"完整版"
373
+ - "之前说过的 XXX 具体是什么"
374
+ - 上面的摘要只有标题,用户想要完整内容
375
+ - 任何需要超出摘要范围的细节
376
+
377
+ ### 使用方法(使用脚本,自动处理中文编码)
378
+
379
+ **1. 搜索记忆(找到相关 ID)**
380
+ \`\`\`bash
381
+ ~/.openclaw/hooks/openclaw-mem/mem-search.sh "关键词" 10
382
+ \`\`\`
383
+
384
+ **2. 获取完整详情(用找到的 ID)**
385
+ \`\`\`bash
386
+ ~/.openclaw/hooks/openclaw-mem/mem-get.sh ID1 ID2
387
+ \`\`\`
388
+
389
+ ### 示例
390
+
391
+ 用户问:"之前讨论的效率提升方法具体有哪些步骤?"
392
+
393
+ 你应该:
394
+ 1. 搜索: \`~/.openclaw/hooks/openclaw-mem/mem-search.sh "效率提升" 10\`
395
+ 2. 获取详情: \`~/.openclaw/hooks/openclaw-mem/mem-get.sh 535 526\`(用搜索到的 ID)
396
+ 3. 根据返回的完整内容回答用户
397
+
398
+ **不要仅凭摘要回答需要详情的问题!**
399
+ `;
400
+
401
+ if (memContext) {
402
+ console.log(`[openclaw-mem] Built context: ${memContext.length} chars`);
403
+
404
+ // Strategy: Write memory context to a dedicated file on disk
405
+ // This ensures AI can read it with the Read tool
406
+ const memContextFile = path.join(workspaceDir, 'SESSION-MEMORY.md');
407
+ try {
408
+ await fs.writeFile(memContextFile, memContext + toolInstructions, 'utf-8');
409
+ console.log(`[openclaw-mem] ✓ Written SESSION-MEMORY.md to disk (${memContext.length} chars + tool instructions)`);
410
+ } catch (err) {
411
+ console.error('[openclaw-mem] Failed to write SESSION-MEMORY.md:', err.message);
412
+ }
413
+
414
+ // Also modify bootstrapFiles array for system prompt injection
415
+ if (event.context.bootstrapFiles && Array.isArray(event.context.bootstrapFiles)) {
416
+ const memoryFile = event.context.bootstrapFiles.find(f => f.name === 'MEMORY.md');
417
+ if (memoryFile && memoryFile.content && !memoryFile.missing) {
418
+ memoryFile.content = memoryFile.content + '\n\n---\n\n# Session Memory\n\nSee SESSION-MEMORY.md for recent activity and conversation history.\n\n' + memContext + toolInstructions;
419
+ console.log('[openclaw-mem] ✓ Appended to MEMORY.md in bootstrapFiles');
420
+ }
421
+ }
422
+ } else {
423
+ console.log('[openclaw-mem] No context to inject (empty memory)');
424
+ // Still write tool instructions even if no context
425
+ const memContextFile = path.join(workspaceDir, 'SESSION-MEMORY.md');
426
+ try {
427
+ await fs.writeFile(memContextFile, '# Session Memory\n\nNo recent observations found.\n' + toolInstructions, 'utf-8');
428
+ console.log('[openclaw-mem] ✓ Written SESSION-MEMORY.md with tool instructions only');
429
+ } catch (err) {
430
+ console.error('[openclaw-mem] Failed to write SESSION-MEMORY.md:', err.message);
431
+ }
432
+ }
433
+ }
434
+
435
+ // Track active sessions by sessionKey
436
+ const activeSessions = new Map();
437
+
438
+ /**
439
+ * Get or create a session ID for a given sessionKey
440
+ */
441
+ function getOrCreateSessionForKey(sessionKey, workspaceDir) {
442
+ if (activeSessions.has(sessionKey)) {
443
+ return activeSessions.get(sessionKey);
444
+ }
445
+
446
+ const sessionId = generateId();
447
+ database.createSession(sessionId, workspaceDir, sessionKey, 'bootstrap');
448
+ activeSessions.set(sessionKey, sessionId);
449
+
450
+ // Clean up old sessions after 1 hour
451
+ setTimeout(() => {
452
+ if (activeSessions.get(sessionKey) === sessionId) {
453
+ activeSessions.delete(sessionKey);
454
+ database.endSession(sessionId);
455
+ }
456
+ }, 60 * 60 * 1000);
457
+
458
+ console.log(`[openclaw-mem] Created new session ${sessionId} for key ${sessionKey}`);
459
+ return sessionId;
460
+ }
461
+
462
+ /**
463
+ * Handle command:new event
464
+ * Save session content before reset
465
+ */
466
+ async function handleCommandNew(event) {
467
+ console.log('[openclaw-mem] Command new - saving session');
468
+
469
+ if (!await loadModules()) return;
470
+
471
+ const context = event.context || {};
472
+ const sessionKey = event.sessionKey || 'unknown';
473
+ const sessionId = generateId();
474
+
475
+ // Get workspace and session info
476
+ const workspaceDir = context.workspaceDir ||
477
+ context.cfg?.agents?.defaults?.workspace ||
478
+ path.join(os.homedir(), '.openclaw', 'workspace');
479
+
480
+ const sessionEntry = context.previousSessionEntry || context.sessionEntry || {};
481
+ const sessionFile = sessionEntry.sessionFile;
482
+
483
+ // Create session record
484
+ database.createSession(sessionId, workspaceDir, sessionKey, context.commandSource || 'command');
485
+
486
+ // Extract session content
487
+ const messages = await extractSessionContent(sessionFile, 20);
488
+
489
+ if (messages && messages.length > 0) {
490
+ console.log(`[openclaw-mem] Extracted ${messages.length} messages from session`);
491
+
492
+ // Save each message as an observation
493
+ for (const msg of messages) {
494
+ const toolName = msg.role === 'user' ? 'UserMessage' : 'AssistantMessage';
495
+ const summary = msg.content.slice(0, 100) + (msg.content.length > 100 ? '...' : '');
496
+
497
+ database.saveObservation(
498
+ sessionId,
499
+ toolName,
500
+ { role: msg.role },
501
+ { content: msg.content },
502
+ {
503
+ summary,
504
+ // Use message body for concepts to keep topic search working
505
+ concepts: msg.content,
506
+ tokensDiscovery: estimateTokens(msg.content),
507
+ tokensRead: estimateTokens(summary)
508
+ }
509
+ );
510
+ }
511
+
512
+ console.log('[openclaw-mem] Session saved successfully');
513
+ console.log('[openclaw-mem] >>> CODE VERSION 2026-02-03-1622 <<<');
514
+ console.log('[openclaw-mem] >>> STARTING AI SUMMARY <<<');
515
+
516
+ // Generate AI summary using DeepSeek
517
+ let aiSummary = null;
518
+ try {
519
+ aiSummary = await summarizeSession(messages, { sessionKey });
520
+ console.log('[openclaw-mem] Kimi summary result:', aiSummary ? 'success' : 'null');
521
+ } catch (err) {
522
+ console.error('[openclaw-mem] Kimi summary error:', err.message);
523
+ }
524
+
525
+ if (aiSummary && (aiSummary.request || aiSummary.learned || aiSummary.completed || aiSummary.next_steps)) {
526
+ const summaryContent = JSON.stringify(aiSummary);
527
+ database.saveSummary(
528
+ sessionId,
529
+ summaryContent,
530
+ aiSummary.request,
531
+ aiSummary.learned,
532
+ aiSummary.completed,
533
+ aiSummary.next_steps
534
+ );
535
+ console.log('[openclaw-mem] ✓ AI summary saved');
536
+ } else {
537
+ // Fallback summary
538
+ const userMessages = messages.filter(m => m.role === 'user');
539
+ const assistantMessages = messages.filter(m => m.role === 'assistant');
540
+ const fallbackRequest = userMessages[0]?.content?.slice(0, 200) || 'Session started';
541
+ const fallbackCompleted = assistantMessages.slice(-1)[0]?.content?.slice(0, 200) || '';
542
+
543
+ database.saveSummary(
544
+ sessionId,
545
+ `Session with ${messages.length} messages`,
546
+ fallbackRequest,
547
+ null,
548
+ fallbackCompleted ? `Discussed: ${fallbackCompleted}` : null,
549
+ null
550
+ );
551
+ console.log('[openclaw-mem] ✓ Fallback summary saved');
552
+ }
553
+ }
554
+
555
+ // End session
556
+ database.endSession(sessionId);
557
+ }
558
+
559
+ /**
560
+ * Handle agent:response event
561
+ * Capture assistant responses to database
562
+ */
563
+ async function handleAgentResponse(event) {
564
+ console.log('[openclaw-mem] Agent response event');
565
+
566
+ if (!await loadModules()) return;
567
+
568
+ const sessionKey = event.sessionKey || 'unknown';
569
+ const response = event.response || event.message || event.content;
570
+ const workspaceDir = event.context?.workspaceDir || path.join(os.homedir(), '.openclaw', 'workspace');
571
+
572
+ if (response && typeof response === 'string' && response.trim()) {
573
+ console.log('[openclaw-mem] Capturing assistant response:', response.slice(0, 50) + '...');
574
+
575
+ let sessionId = getOrCreateSessionForKey(sessionKey, workspaceDir);
576
+
577
+ const summary = response.slice(0, 100) + (response.length > 100 ? '...' : '');
578
+ database.saveObservation(
579
+ sessionId,
580
+ 'AssistantMessage',
581
+ { role: 'assistant', sessionKey },
582
+ { content: response },
583
+ {
584
+ summary,
585
+ // Keep full content in concepts column for better topic recall
586
+ concepts: response,
587
+ tokensDiscovery: estimateTokens(response),
588
+ tokensRead: estimateTokens(summary)
589
+ }
590
+ );
591
+ console.log('[openclaw-mem] ✓ Assistant response saved to database');
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Handle message events
597
+ * Alternative event type for capturing messages
598
+ */
599
+ async function handleMessage(event) {
600
+ console.log('[openclaw-mem] Message event:', event.action || 'unknown');
601
+
602
+ if (!await loadModules()) return;
603
+
604
+ const sessionKey = event.sessionKey || 'unknown';
605
+ const message = event.message || event.content || event.text;
606
+ const role = event.role || event.action || 'user';
607
+ const workspaceDir = event.context?.workspaceDir || path.join(os.homedir(), '.openclaw', 'workspace');
608
+
609
+ if (message && typeof message === 'string' && message.trim() && !message.startsWith('/')) {
610
+ console.log(`[openclaw-mem] Capturing ${role} message:`, message.slice(0, 50) + '...');
611
+
612
+ let sessionId = getOrCreateSessionForKey(sessionKey, workspaceDir);
613
+
614
+ const toolName = role === 'assistant' ? 'AssistantMessage' : 'UserMessage';
615
+ const summary = message.slice(0, 100) + (message.length > 100 ? '...' : '');
616
+ database.saveObservation(
617
+ sessionId,
618
+ toolName,
619
+ { role, sessionKey },
620
+ { content: message },
621
+ {
622
+ summary,
623
+ // Index actual message text (not just role) for topic search
624
+ concepts: message,
625
+ tokensDiscovery: estimateTokens(message),
626
+ tokensRead: estimateTokens(summary)
627
+ }
628
+ );
629
+ console.log(`[openclaw-mem] ✓ ${role} message saved to database`);
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Check if content should be excluded from memory (privacy protection)
635
+ */
636
+ function shouldExclude(content) {
637
+ if (!content || typeof content !== 'string') return false;
638
+
639
+ // <private> tag exclusion
640
+ if (content.includes('<private>') || content.includes('</private>')) return true;
641
+
642
+ // Sensitive file patterns
643
+ const sensitivePatterns = [
644
+ '.env',
645
+ 'credentials',
646
+ 'secret',
647
+ 'password',
648
+ 'api_key',
649
+ 'apikey',
650
+ 'api-key',
651
+ 'private_key',
652
+ 'privatekey',
653
+ 'access_token',
654
+ 'accesstoken',
655
+ 'auth_token',
656
+ 'authtoken'
657
+ ];
658
+
659
+ const lowerContent = content.toLowerCase();
660
+ for (const pattern of sensitivePatterns) {
661
+ if (lowerContent.includes(pattern)) return true;
662
+ }
663
+
664
+ return false;
665
+ }
666
+
667
+ /**
668
+ * Extract files read from tool call
669
+ */
670
+ function extractFilesRead(toolName, toolInput) {
671
+ if (!toolInput) return [];
672
+
673
+ switch (toolName) {
674
+ case 'Read':
675
+ return toolInput.file_path ? [toolInput.file_path] : [];
676
+ case 'Grep':
677
+ return toolInput.path ? [toolInput.path] : [];
678
+ case 'Glob':
679
+ // Glob returns matched files, but input doesn't contain them
680
+ return [];
681
+ default:
682
+ return [];
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Extract files modified from tool call
688
+ */
689
+ function extractFilesModified(toolName, toolInput) {
690
+ if (!toolInput) return [];
691
+
692
+ switch (toolName) {
693
+ case 'Edit':
694
+ return toolInput.file_path ? [toolInput.file_path] : [];
695
+ case 'Write':
696
+ return toolInput.file_path ? [toolInput.file_path] : [];
697
+ case 'NotebookEdit':
698
+ return toolInput.notebook_path ? [toolInput.notebook_path] : [];
699
+ default:
700
+ return [];
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Classify tool call type
706
+ */
707
+ function classifyToolType(toolName, toolInput, toolResponse) {
708
+ // File modification tools
709
+ if (['Edit', 'Write', 'NotebookEdit'].includes(toolName)) {
710
+ return 'modification';
711
+ }
712
+
713
+ // File reading tools
714
+ if (['Read', 'Grep', 'Glob'].includes(toolName)) {
715
+ return 'discovery';
716
+ }
717
+
718
+ // Command execution
719
+ if (toolName === 'Bash') {
720
+ const command = toolInput?.command || '';
721
+ if (command.includes('git commit') || command.includes('git push')) {
722
+ return 'commit';
723
+ }
724
+ if (command.includes('npm test') || command.includes('pytest') || command.includes('jest')) {
725
+ return 'testing';
726
+ }
727
+ if (command.includes('npm install') || command.includes('pip install')) {
728
+ return 'setup';
729
+ }
730
+ return 'command';
731
+ }
732
+
733
+ // Web tools
734
+ if (['WebFetch', 'WebSearch'].includes(toolName)) {
735
+ return 'research';
736
+ }
737
+
738
+ // Task/Agent tools
739
+ if (toolName === 'Task') {
740
+ return 'delegation';
741
+ }
742
+
743
+ return 'other';
744
+ }
745
+
746
+ /**
747
+ * Handle tool:post event
748
+ * Records every tool call for memory tracking
749
+ */
750
+ async function handleToolPost(event) {
751
+ console.log('[openclaw-mem] Tool post event');
752
+
753
+ if (!await loadModules()) return;
754
+
755
+ const toolName = event.tool_name || event.toolName || 'Unknown';
756
+ const toolInput = event.tool_input || event.toolInput || event.input || {};
757
+ const toolResponse = event.tool_response || event.toolResponse || event.response || event.output || {};
758
+ const sessionKey = event.sessionKey || 'unknown';
759
+ const workspaceDir = event.context?.workspaceDir || path.join(os.homedir(), '.openclaw', 'workspace');
760
+
761
+ // Skip certain tools that generate noise
762
+ const skipTools = ['AskUserQuestion', 'TaskList', 'TaskGet'];
763
+ if (skipTools.includes(toolName)) {
764
+ console.log(`[openclaw-mem] Skipping ${toolName} (noise filter)`);
765
+ return;
766
+ }
767
+
768
+ // Privacy check - skip sensitive content
769
+ const inputStr = JSON.stringify(toolInput);
770
+ const responseStr = JSON.stringify(toolResponse);
771
+ if (shouldExclude(inputStr) || shouldExclude(responseStr)) {
772
+ console.log(`[openclaw-mem] Skipping ${toolName} (privacy filter)`);
773
+ return;
774
+ }
775
+
776
+ // Extract metadata
777
+ const filesRead = extractFilesRead(toolName, toolInput);
778
+ const filesModified = extractFilesModified(toolName, toolInput);
779
+ const toolType = classifyToolType(toolName, toolInput, toolResponse);
780
+
781
+ // Build summary
782
+ let summary = '';
783
+ if (toolInput.file_path) {
784
+ summary = `${toolName}: ${toolInput.file_path}`;
785
+ } else if (toolInput.command) {
786
+ summary = `${toolName}: ${toolInput.command.slice(0, 80)}`;
787
+ } else if (toolInput.pattern) {
788
+ summary = `${toolName}: ${toolInput.pattern}`;
789
+ } else if (toolInput.query) {
790
+ summary = `${toolName}: ${toolInput.query.slice(0, 80)}`;
791
+ } else if (toolInput.url) {
792
+ summary = `${toolName}: ${toolInput.url}`;
793
+ } else {
794
+ summary = `${toolName} operation`;
795
+ }
796
+
797
+ // Build basic narrative (fallback)
798
+ let narrative = '';
799
+ if (filesModified.length > 0) {
800
+ narrative = `Modified ${filesModified.join(', ')}`;
801
+ } else if (filesRead.length > 0) {
802
+ narrative = `Read ${filesRead.join(', ')}`;
803
+ } else if (toolInput.command) {
804
+ narrative = `Executed command: ${toolInput.command.slice(0, 100)}`;
805
+ } else if (toolInput.query) {
806
+ narrative = `Searched for: ${toolInput.query}`;
807
+ }
808
+
809
+ // Get or create session
810
+ let sessionId = getOrCreateSessionForKey(sessionKey, workspaceDir);
811
+
812
+ // Try LLM extraction for richer metadata
813
+ let extractedType = toolType;
814
+ let extractedNarrative = narrative;
815
+ let extractedFacts = null;
816
+ let extractedConcepts = `${toolName} ${summary}`.slice(0, 500);
817
+
818
+ if (USE_LLM_EXTRACTION && extractor && extractor.extractFromToolCall) {
819
+ try {
820
+ const extracted = await extractor.extractFromToolCall({
821
+ tool_name: toolName,
822
+ tool_input: toolInput,
823
+ tool_response: toolResponse,
824
+ filesRead,
825
+ filesModified
826
+ });
827
+
828
+ if (extracted) {
829
+ extractedType = extracted.type || toolType;
830
+ extractedNarrative = extracted.narrative || narrative;
831
+ extractedFacts = extracted.facts;
832
+ extractedConcepts = extracted.concepts?.join(', ') || extractedConcepts;
833
+ }
834
+ console.log(`[openclaw-mem] LLM extracted: type=${extractedType}, concepts=${extractedConcepts.slice(0, 50)}...`);
835
+ } catch (err) {
836
+ console.log(`[openclaw-mem] LLM extraction failed, using fallback: ${err.message}`);
837
+ }
838
+ }
839
+
840
+ // Save observation with extended metadata
841
+ database.saveObservation(
842
+ sessionId,
843
+ toolName,
844
+ toolInput,
845
+ toolResponse,
846
+ {
847
+ summary: summary.slice(0, 200),
848
+ concepts: extractedConcepts,
849
+ tokensDiscovery: estimateTokens(responseStr),
850
+ tokensRead: estimateTokens(summary),
851
+ type: extractedType,
852
+ narrative: extractedNarrative.slice(0, 500),
853
+ facts: extractedFacts,
854
+ filesRead: filesRead,
855
+ filesModified: filesModified
856
+ }
857
+ );
858
+
859
+ console.log(`[openclaw-mem] ✓ Tool ${toolName} recorded (type: ${extractedType})`);
860
+ }
861
+
862
+ /**
863
+ * Handle user:prompt:submit event (UserPromptSubmit)
864
+ * Records user prompts to user_prompts table
865
+ */
866
+ async function handleUserPromptSubmit(event) {
867
+ console.log('[openclaw-mem] User prompt submit event');
868
+
869
+ if (!await loadModules()) return;
870
+
871
+ const sessionKey = event.sessionKey || 'unknown';
872
+ const workspaceDir = event.context?.workspaceDir || path.join(os.homedir(), '.openclaw', 'workspace');
873
+
874
+ // Extract user prompt from various possible locations in event
875
+ const prompt = event.prompt || event.content || event.message || event.text || event.input;
876
+
877
+ if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
878
+ console.log('[openclaw-mem] No prompt content found in event');
879
+ return;
880
+ }
881
+
882
+ // Skip slash commands
883
+ if (prompt.trim().startsWith('/')) {
884
+ console.log('[openclaw-mem] Skipping slash command');
885
+ return;
886
+ }
887
+
888
+ // Privacy check
889
+ if (shouldExclude(prompt)) {
890
+ console.log('[openclaw-mem] Skipping prompt (privacy filter)');
891
+ return;
892
+ }
893
+
894
+ // Get or create session
895
+ const sessionId = getOrCreateSessionForKey(sessionKey, workspaceDir);
896
+
897
+ // Save to user_prompts table
898
+ database.saveUserPrompt(sessionId, prompt);
899
+ console.log(`[openclaw-mem] ✓ User prompt saved (${prompt.slice(0, 50)}...)`);
900
+
901
+ // Also save as an observation for searchability
902
+ const summary = prompt.slice(0, 100) + (prompt.length > 100 ? '...' : '');
903
+
904
+ // Try LLM extraction for concepts
905
+ let concepts = prompt;
906
+ if (USE_LLM_EXTRACTION && extractor && extractor.extractConcepts) {
907
+ try {
908
+ const extracted = await extractor.extractConcepts(prompt);
909
+ if (extracted && extracted.length > 0) {
910
+ concepts = extracted.join(', ');
911
+ }
912
+ } catch (err) {
913
+ console.log('[openclaw-mem] LLM extraction failed for prompt:', err.message);
914
+ }
915
+ }
916
+
917
+ database.saveObservation(
918
+ sessionId,
919
+ 'UserPrompt',
920
+ { prompt: prompt.slice(0, 500) },
921
+ { recorded: true },
922
+ {
923
+ summary,
924
+ concepts,
925
+ tokensDiscovery: estimateTokens(prompt),
926
+ tokensRead: estimateTokens(summary),
927
+ type: 'user_input',
928
+ narrative: `User asked: ${summary}`,
929
+ facts: null,
930
+ filesRead: null,
931
+ filesModified: null
932
+ }
933
+ );
934
+ console.log('[openclaw-mem] ✓ User prompt observation saved');
935
+ }
936
+
937
+ /**
938
+ * Handle agent:stop event (Stop)
939
+ * Called when the model stops/completes a turn
940
+ */
941
+ async function handleAgentStop(event) {
942
+ console.log('[openclaw-mem] Agent stop event');
943
+
944
+ if (!await loadModules()) return;
945
+
946
+ const sessionKey = event.sessionKey || 'unknown';
947
+ const workspaceDir = event.context?.workspaceDir || path.join(os.homedir(), '.openclaw', 'workspace');
948
+ const stopReason = event.reason || event.stop_reason || 'unknown';
949
+
950
+ // Get session
951
+ const sessionId = getOrCreateSessionForKey(sessionKey, workspaceDir);
952
+
953
+ // Record the stop event as an observation
954
+ const summary = `Agent stopped: ${stopReason}`;
955
+ database.saveObservation(
956
+ sessionId,
957
+ 'AgentStop',
958
+ { reason: stopReason, sessionKey },
959
+ { stopped: true, timestamp: new Date().toISOString() },
960
+ {
961
+ summary,
962
+ concepts: `stop, ${stopReason}`,
963
+ tokensDiscovery: 10,
964
+ tokensRead: 5,
965
+ type: 'lifecycle',
966
+ narrative: `Agent turn completed with reason: ${stopReason}`,
967
+ facts: null,
968
+ filesRead: null,
969
+ filesModified: null
970
+ }
971
+ );
972
+
973
+ console.log(`[openclaw-mem] ✓ Agent stop recorded (reason: ${stopReason})`);
974
+
975
+ // Generate summary on stop (Claude-Mem parity)
976
+ try {
977
+ const existing = database.getSummaryBySession(sessionId);
978
+ if (existing) {
979
+ console.log('[openclaw-mem] Summary already exists for session, skipping stop summary');
980
+ return;
981
+ }
982
+
983
+ const agentId = event.context?.agentId || 'main';
984
+ const sessionFile = path.join(os.homedir(), '.openclaw', 'agents', agentId, 'sessions', `${sessionKey}.jsonl`);
985
+ let sessionFileExists = false;
986
+ try {
987
+ await fs.access(sessionFile);
988
+ sessionFileExists = true;
989
+ } catch {
990
+ sessionFileExists = false;
991
+ }
992
+
993
+ if (!sessionFileExists) {
994
+ console.log('[openclaw-mem] No session file found for stop summary');
995
+ return;
996
+ }
997
+
998
+ const messages = await extractSessionContent(sessionFile, SUMMARY_MAX_MESSAGES);
999
+ if (!messages || messages.length === 0) {
1000
+ console.log('[openclaw-mem] No messages found for stop summary');
1001
+ return;
1002
+ }
1003
+
1004
+ let summary = null;
1005
+ try {
1006
+ summary = await summarizeSession(messages, { sessionKey });
1007
+ } catch {
1008
+ summary = null;
1009
+ }
1010
+
1011
+ if (summary && (summary.request || summary.learned || summary.completed || summary.next_steps)) {
1012
+ const summaryContent = JSON.stringify(summary);
1013
+ database.saveSummary(
1014
+ sessionId,
1015
+ summaryContent,
1016
+ summary.request,
1017
+ summary.learned,
1018
+ summary.completed,
1019
+ summary.next_steps
1020
+ );
1021
+ console.log('[openclaw-mem] ✓ Stop summary saved');
1022
+ } else {
1023
+ // Fallback summary if LLM failed
1024
+ const userMessages = messages.filter(m => m.role === 'user');
1025
+ const assistantMessages = messages.filter(m => m.role === 'assistant');
1026
+ const summaryContent = `Session with ${messages.length} messages (${userMessages.length} user, ${assistantMessages.length} assistant)`;
1027
+ const firstUserMsg = userMessages[0]?.content?.slice(0, 200) || '';
1028
+ const lastAssistant = assistantMessages.slice(-1)[0]?.content?.slice(0, 100) || 'various topics';
1029
+
1030
+ database.saveSummary(
1031
+ sessionId,
1032
+ summaryContent,
1033
+ firstUserMsg,
1034
+ '',
1035
+ `Discussed: ${lastAssistant}`,
1036
+ null
1037
+ );
1038
+ console.log('[openclaw-mem] ✓ Stop summary saved (fallback)');
1039
+ }
1040
+ } catch (err) {
1041
+ console.error('[openclaw-mem] Stop summary error:', err.message);
1042
+ }
1043
+
1044
+ // If this is an end_turn or max_tokens stop, we might want to
1045
+ // trigger a summary generation for the turn
1046
+ if (stopReason === 'end_turn' || stopReason === 'stop_sequence') {
1047
+ console.log('[openclaw-mem] Turn completed normally');
1048
+ } else if (stopReason === 'max_tokens') {
1049
+ console.log('[openclaw-mem] Turn stopped due to max tokens');
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * Main hook handler
1055
+ */
1056
+ const openclawMemHandler = async (event) => {
1057
+ const eventType = event.type;
1058
+ const eventAction = event.action;
1059
+ const eventSessionKey = event.sessionKey;
1060
+
1061
+ if (isInternalSessionKey(eventSessionKey)) {
1062
+ console.log('[openclaw-mem] Skipping internal session:', eventSessionKey);
1063
+ return;
1064
+ }
1065
+
1066
+ console.log('[openclaw-mem] Event:', eventType, eventAction || '', '(v2026-02-03-1629)');
1067
+
1068
+ try {
1069
+ if (eventType === 'gateway' && eventAction === 'startup') {
1070
+ await handleGatewayStartup(event);
1071
+ return;
1072
+ }
1073
+
1074
+ if (eventType === 'agent' && eventAction === 'bootstrap') {
1075
+ await handleAgentBootstrap(event);
1076
+ return;
1077
+ }
1078
+
1079
+ if (eventType === 'command' && eventAction === 'new') {
1080
+ await handleCommandNew(event);
1081
+ return;
1082
+ }
1083
+
1084
+ // Handle agent response to capture assistant messages
1085
+ if (eventType === 'agent' && eventAction === 'response') {
1086
+ await handleAgentResponse(event);
1087
+ return;
1088
+ }
1089
+
1090
+ // Handle tool:post events to capture tool calls
1091
+ if (eventType === 'tool' && eventAction === 'post') {
1092
+ await handleToolPost(event);
1093
+ return;
1094
+ }
1095
+
1096
+ // Handle user:prompt:submit (UserPromptSubmit) - when user submits a prompt
1097
+ if ((eventType === 'user' && eventAction === 'prompt') ||
1098
+ (eventType === 'prompt' && eventAction === 'submit') ||
1099
+ (eventType === 'user' && eventAction === 'submit')) {
1100
+ await handleUserPromptSubmit(event);
1101
+ return;
1102
+ }
1103
+
1104
+ // Handle agent:stop (Stop) - when model stops/completes a turn
1105
+ if (eventType === 'agent' && eventAction === 'stop') {
1106
+ await handleAgentStop(event);
1107
+ return;
1108
+ }
1109
+
1110
+ // Handle message events (alternative event type)
1111
+ if (eventType === 'message') {
1112
+ await handleMessage(event);
1113
+ return;
1114
+ }
1115
+
1116
+ } catch (err) {
1117
+ console.error('[openclaw-mem] Handler error:', err.message);
1118
+ console.error(err.stack);
1119
+ }
1120
+ };
1121
+
1122
+ export default openclawMemHandler;