openclaw-mem 1.0.4 → 1.3.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 (47) hide show
  1. package/HOOK.md +125 -0
  2. package/LICENSE +1 -1
  3. package/MCP.json +11 -0
  4. package/README.md +158 -167
  5. package/backfill-embeddings.js +79 -0
  6. package/context-builder.js +703 -0
  7. package/database.js +625 -0
  8. package/debug-logger.js +280 -0
  9. package/extractor.js +268 -0
  10. package/gateway-llm.js +250 -0
  11. package/handler.js +941 -0
  12. package/mcp-http-api.js +424 -0
  13. package/mcp-server.js +605 -0
  14. package/mem-get.sh +24 -0
  15. package/mem-search.sh +17 -0
  16. package/monitor.js +112 -0
  17. package/package.json +58 -30
  18. package/realtime-monitor.js +371 -0
  19. package/session-watcher.js +192 -0
  20. package/setup.js +114 -0
  21. package/sync-recent.js +63 -0
  22. package/README_CN.md +0 -201
  23. package/bin/openclaw-mem.js +0 -117
  24. package/docs/locales/README_AR.md +0 -35
  25. package/docs/locales/README_DE.md +0 -35
  26. package/docs/locales/README_ES.md +0 -35
  27. package/docs/locales/README_FR.md +0 -35
  28. package/docs/locales/README_HE.md +0 -35
  29. package/docs/locales/README_HI.md +0 -35
  30. package/docs/locales/README_ID.md +0 -35
  31. package/docs/locales/README_IT.md +0 -35
  32. package/docs/locales/README_JA.md +0 -57
  33. package/docs/locales/README_KO.md +0 -35
  34. package/docs/locales/README_NL.md +0 -35
  35. package/docs/locales/README_PL.md +0 -35
  36. package/docs/locales/README_PT.md +0 -35
  37. package/docs/locales/README_RU.md +0 -35
  38. package/docs/locales/README_TH.md +0 -35
  39. package/docs/locales/README_TR.md +0 -35
  40. package/docs/locales/README_UK.md +0 -35
  41. package/docs/locales/README_VI.md +0 -35
  42. package/docs/logo.svg +0 -32
  43. package/lib/context-builder.js +0 -415
  44. package/lib/database.js +0 -309
  45. package/lib/handler.js +0 -494
  46. package/scripts/commands.js +0 -141
  47. package/scripts/init.js +0 -248
package/handler.js ADDED
@@ -0,0 +1,941 @@
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, callGatewayEmbeddings } 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 = true;
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
+ // Raw messages are no longer stored individually — session summaries capture the important bits.
259
+ // This eliminates noise from greetings and low-value messages.
260
+ console.log('[openclaw-mem] Skipping per-message capture (handled via session summary)');
261
+
262
+ // Ensure API server is running
263
+ await startApiServer();
264
+
265
+ // Build context to inject (async for LLM extraction)
266
+ const memContext = await contextBuilder.buildContext(workspaceDir, {
267
+ observationLimit: 30,
268
+ fullDetailCount: 3,
269
+ useLLMExtraction: USE_LLM_EXTRACTION
270
+ });
271
+
272
+ // Build tool instructions for memory retrieval
273
+ const toolInstructions = `
274
+ ---
275
+
276
+ ## 🧠 记忆检索工具(重要:必须使用)
277
+
278
+ **⚠️ 当用户问到以下情况时,你必须先调用脚本获取详情,再回答:**
279
+ - "具体内容是什么"、"详细步骤"、"完整版"
280
+ - "之前说过的 XXX 具体是什么"
281
+ - 上面的摘要只有标题,用户想要完整内容
282
+ - 任何需要超出摘要范围的细节
283
+
284
+ ### 使用方法(使用脚本,自动处理中文编码)
285
+
286
+ **1. 搜索记忆(找到相关 ID)**
287
+ \`\`\`bash
288
+ ~/.openclaw/hooks/openclaw-mem/mem-search.sh "关键词" 10
289
+ \`\`\`
290
+
291
+ **2. 获取完整详情(用找到的 ID)**
292
+ \`\`\`bash
293
+ ~/.openclaw/hooks/openclaw-mem/mem-get.sh ID1 ID2
294
+ \`\`\`
295
+
296
+ ### 示例
297
+
298
+ 用户问:"之前讨论的效率提升方法具体有哪些步骤?"
299
+
300
+ 你应该:
301
+ 1. 搜索: \`~/.openclaw/hooks/openclaw-mem/mem-search.sh "效率提升" 10\`
302
+ 2. 获取详情: \`~/.openclaw/hooks/openclaw-mem/mem-get.sh 535 526\`(用搜索到的 ID)
303
+ 3. 根据返回的完整内容回答用户
304
+
305
+ **不要仅凭摘要回答需要详情的问题!**
306
+ `;
307
+
308
+ if (memContext) {
309
+ console.log(`[openclaw-mem] Built context: ${memContext.length} chars`);
310
+
311
+ // Strategy: Write memory context to a dedicated file on disk
312
+ // This ensures AI can read it with the Read tool
313
+ const memContextFile = path.join(workspaceDir, 'SESSION-MEMORY.md');
314
+ try {
315
+ await fs.writeFile(memContextFile, memContext + toolInstructions, 'utf-8');
316
+ console.log(`[openclaw-mem] ✓ Written SESSION-MEMORY.md to disk (${memContext.length} chars + tool instructions)`);
317
+ } catch (err) {
318
+ console.error('[openclaw-mem] Failed to write SESSION-MEMORY.md:', err.message);
319
+ }
320
+
321
+ // Also modify bootstrapFiles array for system prompt injection
322
+ if (event.context.bootstrapFiles && Array.isArray(event.context.bootstrapFiles)) {
323
+ const memoryFile = event.context.bootstrapFiles.find(f => f.name === 'MEMORY.md');
324
+ if (memoryFile && memoryFile.content && !memoryFile.missing) {
325
+ 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;
326
+ console.log('[openclaw-mem] ✓ Appended to MEMORY.md in bootstrapFiles');
327
+ }
328
+ }
329
+ } else {
330
+ console.log('[openclaw-mem] No context to inject (empty memory)');
331
+ // Still write tool instructions even if no context
332
+ const memContextFile = path.join(workspaceDir, 'SESSION-MEMORY.md');
333
+ try {
334
+ await fs.writeFile(memContextFile, '# Session Memory\n\nNo recent observations found.\n' + toolInstructions, 'utf-8');
335
+ console.log('[openclaw-mem] ✓ Written SESSION-MEMORY.md with tool instructions only');
336
+ } catch (err) {
337
+ console.error('[openclaw-mem] Failed to write SESSION-MEMORY.md:', err.message);
338
+ }
339
+ }
340
+ }
341
+
342
+ // Track active sessions by sessionKey
343
+ const activeSessions = new Map();
344
+
345
+ /**
346
+ * Get or create a session ID for a given sessionKey
347
+ */
348
+ function getOrCreateSessionForKey(sessionKey, workspaceDir) {
349
+ if (activeSessions.has(sessionKey)) {
350
+ return activeSessions.get(sessionKey);
351
+ }
352
+
353
+ const sessionId = generateId();
354
+ database.createSession(sessionId, workspaceDir, sessionKey, 'bootstrap');
355
+ activeSessions.set(sessionKey, sessionId);
356
+
357
+ // Clean up old sessions after 1 hour
358
+ setTimeout(() => {
359
+ if (activeSessions.get(sessionKey) === sessionId) {
360
+ activeSessions.delete(sessionKey);
361
+ database.endSession(sessionId);
362
+ }
363
+ }, 60 * 60 * 1000);
364
+
365
+ console.log(`[openclaw-mem] Created new session ${sessionId} for key ${sessionKey}`);
366
+ return sessionId;
367
+ }
368
+
369
+ /**
370
+ * Handle command:new event
371
+ * Save session content before reset
372
+ */
373
+ async function handleCommandNew(event) {
374
+ console.log('[openclaw-mem] Command new - saving session');
375
+
376
+ if (!await loadModules()) return;
377
+
378
+ const context = event.context || {};
379
+ const sessionKey = event.sessionKey || 'unknown';
380
+ const sessionId = generateId();
381
+
382
+ // Get workspace and session info
383
+ const workspaceDir = context.workspaceDir ||
384
+ context.cfg?.agents?.defaults?.workspace ||
385
+ path.join(os.homedir(), '.openclaw', 'workspace');
386
+
387
+ const sessionEntry = context.previousSessionEntry || context.sessionEntry || {};
388
+ const sessionFile = sessionEntry.sessionFile;
389
+
390
+ // Create session record
391
+ database.createSession(sessionId, workspaceDir, sessionKey, context.commandSource || 'command');
392
+
393
+ // Extract session content
394
+ const messages = await extractSessionContent(sessionFile, 20);
395
+
396
+ if (messages && messages.length > 0) {
397
+ console.log(`[openclaw-mem] Extracted ${messages.length} messages from session`);
398
+ // Raw messages are no longer stored individually — only the AI summary matters.
399
+ console.log('[openclaw-mem] Generating AI summary...');
400
+
401
+ // Generate AI summary using DeepSeek
402
+ let aiSummary = null;
403
+ try {
404
+ aiSummary = await summarizeSession(messages, { sessionKey });
405
+ console.log('[openclaw-mem] Kimi summary result:', aiSummary ? 'success' : 'null');
406
+ } catch (err) {
407
+ console.error('[openclaw-mem] Kimi summary error:', err.message);
408
+ }
409
+
410
+ if (aiSummary && (aiSummary.request || aiSummary.learned || aiSummary.completed || aiSummary.next_steps)) {
411
+ const summaryContent = JSON.stringify(aiSummary);
412
+ database.saveSummary(
413
+ sessionId,
414
+ summaryContent,
415
+ aiSummary.request,
416
+ aiSummary.investigated || null,
417
+ aiSummary.learned,
418
+ aiSummary.completed,
419
+ aiSummary.next_steps
420
+ );
421
+ console.log('[openclaw-mem] ✓ AI summary saved');
422
+ } else {
423
+ // Fallback summary
424
+ const userMessages = messages.filter(m => m.role === 'user');
425
+ const assistantMessages = messages.filter(m => m.role === 'assistant');
426
+ const fallbackRequest = userMessages[0]?.content?.slice(0, 200) || 'Session started';
427
+ const fallbackCompleted = assistantMessages.slice(-1)[0]?.content?.slice(0, 200) || '';
428
+
429
+ database.saveSummary(
430
+ sessionId,
431
+ `Session with ${messages.length} messages`,
432
+ fallbackRequest,
433
+ null,
434
+ null,
435
+ fallbackCompleted ? `Discussed: ${fallbackCompleted}` : null,
436
+ null
437
+ );
438
+ console.log('[openclaw-mem] ✓ Fallback summary saved');
439
+ }
440
+ }
441
+
442
+ // End session
443
+ database.endSession(sessionId);
444
+ }
445
+
446
+ /**
447
+ * Handle agent:response event
448
+ * Skip storing raw assistant messages — session summary at stop/new captures the important bits.
449
+ * This avoids noise from greetings, acknowledgments, and other low-value messages.
450
+ */
451
+ async function handleAgentResponse(event) {
452
+ console.log('[openclaw-mem] Agent response event (skipped — captured via session summary)');
453
+ }
454
+
455
+ /**
456
+ * Handle message events
457
+ * Skip storing raw messages — session summary at stop/new captures the important bits.
458
+ * This avoids noise from greetings, acknowledgments, and other low-value messages.
459
+ */
460
+ async function handleMessage(event) {
461
+ console.log('[openclaw-mem] Message event (skipped — captured via session summary)');
462
+ }
463
+
464
+ /**
465
+ * Check if content should be excluded from memory (privacy protection)
466
+ */
467
+ function shouldExclude(content) {
468
+ if (!content || typeof content !== 'string') return false;
469
+
470
+ // <private> tag exclusion
471
+ if (content.includes('<private>') || content.includes('</private>')) return true;
472
+
473
+ // Sensitive file patterns
474
+ const sensitivePatterns = [
475
+ '.env',
476
+ 'credentials',
477
+ 'secret',
478
+ 'password',
479
+ 'api_key',
480
+ 'apikey',
481
+ 'api-key',
482
+ 'private_key',
483
+ 'privatekey',
484
+ 'access_token',
485
+ 'accesstoken',
486
+ 'auth_token',
487
+ 'authtoken'
488
+ ];
489
+
490
+ const lowerContent = content.toLowerCase();
491
+ for (const pattern of sensitivePatterns) {
492
+ if (lowerContent.includes(pattern)) return true;
493
+ }
494
+
495
+ return false;
496
+ }
497
+
498
+ /**
499
+ * Extract files read from tool call
500
+ */
501
+ function extractFilesRead(toolName, toolInput) {
502
+ if (!toolInput) return [];
503
+
504
+ switch (toolName) {
505
+ case 'Read':
506
+ return toolInput.file_path ? [toolInput.file_path] : [];
507
+ case 'Grep':
508
+ return toolInput.path ? [toolInput.path] : [];
509
+ case 'Glob':
510
+ // Glob returns matched files, but input doesn't contain them
511
+ return [];
512
+ default:
513
+ return [];
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Extract files modified from tool call
519
+ */
520
+ function extractFilesModified(toolName, toolInput) {
521
+ if (!toolInput) return [];
522
+
523
+ switch (toolName) {
524
+ case 'Edit':
525
+ return toolInput.file_path ? [toolInput.file_path] : [];
526
+ case 'Write':
527
+ return toolInput.file_path ? [toolInput.file_path] : [];
528
+ case 'NotebookEdit':
529
+ return toolInput.notebook_path ? [toolInput.notebook_path] : [];
530
+ default:
531
+ return [];
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Classify tool call type
537
+ */
538
+ function classifyToolType(toolName, toolInput, toolResponse) {
539
+ // File modification tools
540
+ if (['Edit', 'Write', 'NotebookEdit'].includes(toolName)) {
541
+ return 'modification';
542
+ }
543
+
544
+ // File reading tools
545
+ if (['Read', 'Grep', 'Glob'].includes(toolName)) {
546
+ return 'discovery';
547
+ }
548
+
549
+ // Command execution
550
+ if (toolName === 'Bash') {
551
+ const command = toolInput?.command || '';
552
+ if (command.includes('git commit') || command.includes('git push')) {
553
+ return 'commit';
554
+ }
555
+ if (command.includes('npm test') || command.includes('pytest') || command.includes('jest')) {
556
+ return 'testing';
557
+ }
558
+ if (command.includes('npm install') || command.includes('pip install')) {
559
+ return 'setup';
560
+ }
561
+ return 'command';
562
+ }
563
+
564
+ // Web tools
565
+ if (['WebFetch', 'WebSearch'].includes(toolName)) {
566
+ return 'research';
567
+ }
568
+
569
+ // Task/Agent tools
570
+ if (toolName === 'Task') {
571
+ return 'delegation';
572
+ }
573
+
574
+ return 'other';
575
+ }
576
+
577
+ /**
578
+ * Handle tool:post event
579
+ * Records every tool call for memory tracking
580
+ */
581
+ async function handleToolPost(event) {
582
+ console.log('[openclaw-mem] Tool post event');
583
+
584
+ if (!await loadModules()) return;
585
+
586
+ const toolName = event.tool_name || event.toolName || 'Unknown';
587
+ const toolInput = event.tool_input || event.toolInput || event.input || {};
588
+ const toolResponse = event.tool_response || event.toolResponse || event.response || event.output || {};
589
+ const sessionKey = event.sessionKey || 'unknown';
590
+ const workspaceDir = event.context?.workspaceDir || path.join(os.homedir(), '.openclaw', 'workspace');
591
+
592
+ // Skip certain tools that generate noise
593
+ const skipTools = ['AskUserQuestion', 'TaskList', 'TaskGet'];
594
+ if (skipTools.includes(toolName)) {
595
+ console.log(`[openclaw-mem] Skipping ${toolName} (noise filter)`);
596
+ return;
597
+ }
598
+
599
+ // Privacy check - skip sensitive content
600
+ const inputStr = JSON.stringify(toolInput);
601
+ const responseStr = JSON.stringify(toolResponse);
602
+ if (shouldExclude(inputStr) || shouldExclude(responseStr)) {
603
+ console.log(`[openclaw-mem] Skipping ${toolName} (privacy filter)`);
604
+ return;
605
+ }
606
+
607
+ // Extract metadata
608
+ const filesRead = extractFilesRead(toolName, toolInput);
609
+ const filesModified = extractFilesModified(toolName, toolInput);
610
+ const toolType = classifyToolType(toolName, toolInput, toolResponse);
611
+
612
+ // Build summary
613
+ let summary = '';
614
+ if (toolInput.file_path) {
615
+ summary = `${toolName}: ${toolInput.file_path}`;
616
+ } else if (toolInput.command) {
617
+ summary = `${toolName}: ${toolInput.command.slice(0, 80)}`;
618
+ } else if (toolInput.pattern) {
619
+ summary = `${toolName}: ${toolInput.pattern}`;
620
+ } else if (toolInput.query) {
621
+ summary = `${toolName}: ${toolInput.query.slice(0, 80)}`;
622
+ } else if (toolInput.url) {
623
+ summary = `${toolName}: ${toolInput.url}`;
624
+ } else {
625
+ summary = `${toolName} operation`;
626
+ }
627
+
628
+ // Build basic narrative (fallback)
629
+ let narrative = '';
630
+ if (filesModified.length > 0) {
631
+ narrative = `Modified ${filesModified.join(', ')}`;
632
+ } else if (filesRead.length > 0) {
633
+ narrative = `Read ${filesRead.join(', ')}`;
634
+ } else if (toolInput.command) {
635
+ narrative = `Executed command: ${toolInput.command.slice(0, 100)}`;
636
+ } else if (toolInput.query) {
637
+ narrative = `Searched for: ${toolInput.query}`;
638
+ }
639
+
640
+ // Get or create session
641
+ let sessionId = getOrCreateSessionForKey(sessionKey, workspaceDir);
642
+
643
+ // Try LLM extraction for richer metadata
644
+ let extractedType = toolType;
645
+ let extractedNarrative = narrative;
646
+ let extractedFacts = null;
647
+ let extractedConcepts = `${toolName} ${summary}`.slice(0, 500);
648
+
649
+ if (USE_LLM_EXTRACTION && extractor && extractor.extractFromToolCall) {
650
+ try {
651
+ const extracted = await extractor.extractFromToolCall({
652
+ tool_name: toolName,
653
+ tool_input: toolInput,
654
+ tool_response: toolResponse,
655
+ filesRead,
656
+ filesModified
657
+ });
658
+
659
+ if (extracted) {
660
+ extractedType = extracted.type || toolType;
661
+ extractedNarrative = extracted.narrative || narrative;
662
+ extractedFacts = extracted.facts;
663
+ extractedConcepts = extracted.concepts?.join(', ') || extractedConcepts;
664
+ // Use LLM-generated title as summary if available
665
+ if (extracted.title) {
666
+ summary = extracted.title;
667
+ }
668
+ }
669
+ console.log(`[openclaw-mem] LLM extracted: type=${extractedType}, title=${summary.slice(0, 60)}, concepts=${extractedConcepts}`);
670
+ } catch (err) {
671
+ console.log(`[openclaw-mem] LLM extraction failed, using fallback: ${err.message}`);
672
+ }
673
+ }
674
+
675
+ // Save observation with extended metadata
676
+ const saveResult = database.saveObservation(
677
+ sessionId,
678
+ toolName,
679
+ toolInput,
680
+ toolResponse,
681
+ {
682
+ summary: summary.slice(0, 200),
683
+ concepts: extractedConcepts,
684
+ tokensDiscovery: estimateTokens(responseStr),
685
+ tokensRead: estimateTokens(summary),
686
+ type: extractedType,
687
+ narrative: extractedNarrative.slice(0, 1000),
688
+ facts: extractedFacts,
689
+ filesRead: filesRead,
690
+ filesModified: filesModified
691
+ }
692
+ );
693
+
694
+ console.log(`[openclaw-mem] ✓ Tool ${toolName} recorded (type: ${extractedType})`);
695
+
696
+ // Fire-and-forget: generate embedding for the new observation
697
+ if (saveResult.success && saveResult.id) {
698
+ const embeddingText = [summary, extractedNarrative].filter(Boolean).join(' ').trim();
699
+ if (embeddingText.length > 10) {
700
+ callGatewayEmbeddings(embeddingText).then(embedding => {
701
+ if (embedding) {
702
+ database.saveEmbedding(Number(saveResult.id), embedding);
703
+ console.log(`[openclaw-mem] ✓ Embedding saved for observation #${saveResult.id}`);
704
+ }
705
+ }).catch(err => {
706
+ console.log(`[openclaw-mem] Embedding generation failed: ${err.message}`);
707
+ });
708
+ }
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Handle user:prompt:submit event (UserPromptSubmit)
714
+ * Records user prompts to user_prompts table
715
+ */
716
+ async function handleUserPromptSubmit(event) {
717
+ console.log('[openclaw-mem] User prompt submit event');
718
+
719
+ if (!await loadModules()) return;
720
+
721
+ const sessionKey = event.sessionKey || 'unknown';
722
+ const workspaceDir = event.context?.workspaceDir || path.join(os.homedir(), '.openclaw', 'workspace');
723
+
724
+ // Extract user prompt from various possible locations in event
725
+ const prompt = event.prompt || event.content || event.message || event.text || event.input;
726
+
727
+ if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
728
+ console.log('[openclaw-mem] No prompt content found in event');
729
+ return;
730
+ }
731
+
732
+ // Skip slash commands
733
+ if (prompt.trim().startsWith('/')) {
734
+ console.log('[openclaw-mem] Skipping slash command');
735
+ return;
736
+ }
737
+
738
+ // Privacy check
739
+ if (shouldExclude(prompt)) {
740
+ console.log('[openclaw-mem] Skipping prompt (privacy filter)');
741
+ return;
742
+ }
743
+
744
+ // Get or create session
745
+ const sessionId = getOrCreateSessionForKey(sessionKey, workspaceDir);
746
+
747
+ // Save to user_prompts table
748
+ database.saveUserPrompt(sessionId, prompt);
749
+ console.log(`[openclaw-mem] ✓ User prompt saved (${prompt.slice(0, 50)}...)`);
750
+
751
+ // User prompts are saved to user_prompts table only (no observation duplication).
752
+ }
753
+
754
+ /**
755
+ * Handle agent:stop event (Stop)
756
+ * Called when the model stops/completes a turn
757
+ */
758
+ async function handleAgentStop(event) {
759
+ console.log('[openclaw-mem] Agent stop event');
760
+
761
+ if (!await loadModules()) return;
762
+
763
+ const sessionKey = event.sessionKey || 'unknown';
764
+ const workspaceDir = event.context?.workspaceDir || path.join(os.homedir(), '.openclaw', 'workspace');
765
+ const stopReason = event.reason || event.stop_reason || 'unknown';
766
+
767
+ // Get session
768
+ const sessionId = getOrCreateSessionForKey(sessionKey, workspaceDir);
769
+
770
+ // Record the stop event as an observation
771
+ const summary = `Agent stopped: ${stopReason}`;
772
+ database.saveObservation(
773
+ sessionId,
774
+ 'AgentStop',
775
+ { reason: stopReason, sessionKey },
776
+ { stopped: true, timestamp: new Date().toISOString() },
777
+ {
778
+ summary,
779
+ concepts: `stop, ${stopReason}`,
780
+ tokensDiscovery: 10,
781
+ tokensRead: 5,
782
+ type: 'lifecycle',
783
+ narrative: `Agent turn completed with reason: ${stopReason}`,
784
+ facts: null,
785
+ filesRead: null,
786
+ filesModified: null
787
+ }
788
+ );
789
+
790
+ console.log(`[openclaw-mem] ✓ Agent stop recorded (reason: ${stopReason})`);
791
+
792
+ // Generate summary on stop (Claude-Mem parity)
793
+ try {
794
+ const existing = database.getSummaryBySession(sessionId);
795
+ if (existing) {
796
+ console.log('[openclaw-mem] Summary already exists for session, skipping stop summary');
797
+ return;
798
+ }
799
+
800
+ const agentId = event.context?.agentId || 'main';
801
+ const sessionFile = path.join(os.homedir(), '.openclaw', 'agents', agentId, 'sessions', `${sessionKey}.jsonl`);
802
+ let sessionFileExists = false;
803
+ try {
804
+ await fs.access(sessionFile);
805
+ sessionFileExists = true;
806
+ } catch {
807
+ sessionFileExists = false;
808
+ }
809
+
810
+ if (!sessionFileExists) {
811
+ console.log('[openclaw-mem] No session file found for stop summary');
812
+ return;
813
+ }
814
+
815
+ const messages = await extractSessionContent(sessionFile, SUMMARY_MAX_MESSAGES);
816
+ if (!messages || messages.length === 0) {
817
+ console.log('[openclaw-mem] No messages found for stop summary');
818
+ return;
819
+ }
820
+
821
+ let summary = null;
822
+ try {
823
+ summary = await summarizeSession(messages, { sessionKey });
824
+ } catch {
825
+ summary = null;
826
+ }
827
+
828
+ if (summary && (summary.request || summary.learned || summary.completed || summary.next_steps)) {
829
+ const summaryContent = JSON.stringify(summary);
830
+ database.saveSummary(
831
+ sessionId,
832
+ summaryContent,
833
+ summary.request,
834
+ summary.investigated || null,
835
+ summary.learned,
836
+ summary.completed,
837
+ summary.next_steps
838
+ );
839
+ console.log('[openclaw-mem] ✓ Stop summary saved');
840
+ } else {
841
+ // Fallback summary if LLM failed
842
+ const userMessages = messages.filter(m => m.role === 'user');
843
+ const assistantMessages = messages.filter(m => m.role === 'assistant');
844
+ const summaryContent = `Session with ${messages.length} messages (${userMessages.length} user, ${assistantMessages.length} assistant)`;
845
+ const firstUserMsg = userMessages[0]?.content?.slice(0, 200) || '';
846
+ const lastAssistant = assistantMessages.slice(-1)[0]?.content?.slice(0, 100) || 'various topics';
847
+
848
+ database.saveSummary(
849
+ sessionId,
850
+ summaryContent,
851
+ firstUserMsg,
852
+ null,
853
+ null,
854
+ `Discussed: ${lastAssistant}`,
855
+ null
856
+ );
857
+ console.log('[openclaw-mem] ✓ Stop summary saved (fallback)');
858
+ }
859
+ } catch (err) {
860
+ console.error('[openclaw-mem] Stop summary error:', err.message);
861
+ }
862
+
863
+ // If this is an end_turn or max_tokens stop, we might want to
864
+ // trigger a summary generation for the turn
865
+ if (stopReason === 'end_turn' || stopReason === 'stop_sequence') {
866
+ console.log('[openclaw-mem] Turn completed normally');
867
+ } else if (stopReason === 'max_tokens') {
868
+ console.log('[openclaw-mem] Turn stopped due to max tokens');
869
+ }
870
+ }
871
+
872
+ /**
873
+ * Main hook handler
874
+ */
875
+ const openclawMemHandler = async (event) => {
876
+ const eventType = event.type;
877
+ const eventAction = event.action;
878
+ const eventSessionKey = event.sessionKey;
879
+
880
+ if (isInternalSessionKey(eventSessionKey)) {
881
+ console.log('[openclaw-mem] Skipping internal session:', eventSessionKey);
882
+ return;
883
+ }
884
+
885
+ console.log('[openclaw-mem] Event:', eventType, eventAction || '', '(v2026-02-03-1629)');
886
+
887
+ try {
888
+ if (eventType === 'gateway' && eventAction === 'startup') {
889
+ await handleGatewayStartup(event);
890
+ return;
891
+ }
892
+
893
+ if (eventType === 'agent' && eventAction === 'bootstrap') {
894
+ await handleAgentBootstrap(event);
895
+ return;
896
+ }
897
+
898
+ if (eventType === 'command' && eventAction === 'new') {
899
+ await handleCommandNew(event);
900
+ return;
901
+ }
902
+
903
+ // Handle agent response to capture assistant messages
904
+ if (eventType === 'agent' && eventAction === 'response') {
905
+ await handleAgentResponse(event);
906
+ return;
907
+ }
908
+
909
+ // Handle tool:post events to capture tool calls
910
+ if (eventType === 'tool' && eventAction === 'post') {
911
+ await handleToolPost(event);
912
+ return;
913
+ }
914
+
915
+ // Handle user:prompt:submit (UserPromptSubmit) - when user submits a prompt
916
+ if ((eventType === 'user' && eventAction === 'prompt') ||
917
+ (eventType === 'prompt' && eventAction === 'submit') ||
918
+ (eventType === 'user' && eventAction === 'submit')) {
919
+ await handleUserPromptSubmit(event);
920
+ return;
921
+ }
922
+
923
+ // Handle agent:stop (Stop) - when model stops/completes a turn
924
+ if (eventType === 'agent' && eventAction === 'stop') {
925
+ await handleAgentStop(event);
926
+ return;
927
+ }
928
+
929
+ // Handle message events (alternative event type)
930
+ if (eventType === 'message') {
931
+ await handleMessage(event);
932
+ return;
933
+ }
934
+
935
+ } catch (err) {
936
+ console.error('[openclaw-mem] Handler error:', err.message);
937
+ console.error(err.stack);
938
+ }
939
+ };
940
+
941
+ export default openclawMemHandler;