openclaw-mem 1.0.3 → 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.
package/database.js ADDED
@@ -0,0 +1,520 @@
1
+ /**
2
+ * OpenClaw-Mem Database Module
3
+ * SQLite-based storage for observations, sessions, and summaries
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import Database from 'better-sqlite3';
10
+
11
+ const DATA_DIR = path.join(os.homedir(), '.openclaw-mem');
12
+ const DB_PATH = path.join(DATA_DIR, 'memory.db');
13
+
14
+ // Ensure data directory exists
15
+ if (!fs.existsSync(DATA_DIR)) {
16
+ fs.mkdirSync(DATA_DIR, { recursive: true });
17
+ }
18
+
19
+ // Initialize database
20
+ const db = new Database(DB_PATH);
21
+ db.pragma('journal_mode = WAL');
22
+
23
+ // Create tables (base schema without new columns for backward compatibility)
24
+ db.exec(`
25
+ -- Sessions table
26
+ CREATE TABLE IF NOT EXISTS sessions (
27
+ id TEXT PRIMARY KEY,
28
+ project_path TEXT,
29
+ session_key TEXT,
30
+ started_at TEXT DEFAULT (datetime('now')),
31
+ ended_at TEXT,
32
+ status TEXT DEFAULT 'active',
33
+ source TEXT
34
+ );
35
+
36
+ -- Observations table (tool calls) - base schema
37
+ CREATE TABLE IF NOT EXISTS observations (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ session_id TEXT,
40
+ timestamp TEXT DEFAULT (datetime('now')),
41
+ tool_name TEXT NOT NULL,
42
+ tool_input TEXT,
43
+ tool_response TEXT,
44
+ summary TEXT,
45
+ concepts TEXT,
46
+ tokens_discovery INTEGER DEFAULT 0,
47
+ tokens_read INTEGER DEFAULT 0,
48
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
49
+ );
50
+
51
+ -- User prompts table (for tracking user inputs)
52
+ CREATE TABLE IF NOT EXISTS user_prompts (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ session_id TEXT,
55
+ content TEXT,
56
+ timestamp TEXT DEFAULT (datetime('now')),
57
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
58
+ );
59
+
60
+ -- Summaries table
61
+ CREATE TABLE IF NOT EXISTS summaries (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ session_id TEXT,
64
+ content TEXT,
65
+ request TEXT,
66
+ learned TEXT,
67
+ completed TEXT,
68
+ next_steps TEXT,
69
+ created_at TEXT DEFAULT (datetime('now')),
70
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
71
+ );
72
+
73
+ -- Base indexes
74
+ CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
75
+ CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp DESC);
76
+ CREATE INDEX IF NOT EXISTS idx_observations_tool ON observations(tool_name);
77
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path);
78
+ CREATE INDEX IF NOT EXISTS idx_user_prompts_session ON user_prompts(session_id);
79
+ `);
80
+
81
+ // Migrate existing database - add new columns if they don't exist
82
+ // Must be done before creating indexes on these columns
83
+ const migrations = [
84
+ `ALTER TABLE observations ADD COLUMN type TEXT`,
85
+ `ALTER TABLE observations ADD COLUMN narrative TEXT`,
86
+ `ALTER TABLE observations ADD COLUMN facts TEXT`,
87
+ `ALTER TABLE observations ADD COLUMN files_read TEXT`,
88
+ `ALTER TABLE observations ADD COLUMN files_modified TEXT`,
89
+ `ALTER TABLE summaries ADD COLUMN learned TEXT`
90
+ ];
91
+
92
+ for (const migration of migrations) {
93
+ try {
94
+ db.exec(migration);
95
+ } catch (e) {
96
+ // Column already exists, ignore
97
+ }
98
+ }
99
+
100
+ // Create index on type column after migration
101
+ try {
102
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type)`);
103
+ } catch (e) {
104
+ // Index might already exist
105
+ }
106
+
107
+ // Create/recreate FTS5 table with extended fields
108
+ // Drop old triggers first to avoid conflicts
109
+ try {
110
+ db.exec(`DROP TRIGGER IF EXISTS observations_ai`);
111
+ db.exec(`DROP TRIGGER IF EXISTS observations_ad`);
112
+ db.exec(`DROP TRIGGER IF EXISTS observations_au`);
113
+ } catch (e) { /* triggers don't exist */ }
114
+
115
+ // Check if FTS table needs to be recreated with new columns
116
+ const ftsInfo = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='observations_fts'`).get();
117
+ const needsRecreate = ftsInfo && !ftsInfo.sql.includes('narrative');
118
+
119
+ if (needsRecreate) {
120
+ try {
121
+ db.exec(`DROP TABLE IF EXISTS observations_fts`);
122
+ } catch (e) { /* table doesn't exist */ }
123
+ }
124
+
125
+ // Create FTS5 table with extended fields
126
+ db.exec(`
127
+ CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
128
+ tool_name,
129
+ summary,
130
+ concepts,
131
+ narrative,
132
+ facts,
133
+ content='observations',
134
+ content_rowid='id'
135
+ );
136
+ `);
137
+
138
+ // Rebuild FTS index if we recreated the table
139
+ if (needsRecreate) {
140
+ try {
141
+ const allObs = db.prepare(`SELECT id, tool_name, summary, concepts, narrative, facts FROM observations`).all();
142
+ const insertFts = db.prepare(`INSERT INTO observations_fts(rowid, tool_name, summary, concepts, narrative, facts) VALUES (?, ?, ?, ?, ?, ?)`);
143
+ for (const obs of allObs) {
144
+ insertFts.run(obs.id, obs.tool_name, obs.summary, obs.concepts, obs.narrative, obs.facts);
145
+ }
146
+ } catch (e) {
147
+ console.error('[openclaw-mem] FTS rebuild error:', e.message);
148
+ }
149
+ }
150
+
151
+ // Create triggers for FTS sync
152
+ db.exec(`
153
+ CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
154
+ INSERT INTO observations_fts(rowid, tool_name, summary, concepts, narrative, facts)
155
+ VALUES (new.id, new.tool_name, new.summary, new.concepts, new.narrative, new.facts);
156
+ END;
157
+
158
+ CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
159
+ INSERT INTO observations_fts(observations_fts, rowid, tool_name, summary, concepts, narrative, facts)
160
+ VALUES ('delete', old.id, old.tool_name, old.summary, old.concepts, old.narrative, old.facts);
161
+ END;
162
+
163
+ CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
164
+ INSERT INTO observations_fts(observations_fts, rowid, tool_name, summary, concepts, narrative, facts)
165
+ VALUES ('delete', old.id, old.tool_name, old.summary, old.concepts, old.narrative, old.facts);
166
+ INSERT INTO observations_fts(rowid, tool_name, summary, concepts, narrative, facts)
167
+ VALUES (new.id, new.tool_name, new.summary, new.concepts, new.narrative, new.facts);
168
+ END;
169
+ `);
170
+
171
+ // Prepared statements
172
+ const stmts = {
173
+ // Sessions
174
+ createSession: db.prepare(`
175
+ INSERT INTO sessions (id, project_path, session_key, source)
176
+ VALUES (?, ?, ?, ?)
177
+ `),
178
+
179
+ getSession: db.prepare(`
180
+ SELECT * FROM sessions WHERE id = ?
181
+ `),
182
+
183
+ endSession: db.prepare(`
184
+ UPDATE sessions SET ended_at = datetime('now'), status = 'completed'
185
+ WHERE id = ?
186
+ `),
187
+
188
+ getActiveSession: db.prepare(`
189
+ SELECT * FROM sessions WHERE session_key = ? AND status = 'active'
190
+ ORDER BY started_at DESC LIMIT 1
191
+ `),
192
+
193
+ // Observations - extended with type, narrative, facts, files tracking
194
+ saveObservation: db.prepare(`
195
+ INSERT INTO observations (session_id, tool_name, tool_input, tool_response, summary, concepts, tokens_discovery, tokens_read, type, narrative, facts, files_read, files_modified)
196
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
197
+ `),
198
+
199
+ getObservation: db.prepare(`
200
+ SELECT * FROM observations WHERE id = ?
201
+ `),
202
+
203
+ getObservations: db.prepare(`
204
+ SELECT * FROM observations WHERE id IN (SELECT value FROM json_each(?))
205
+ `),
206
+
207
+ updateObservationSummary: db.prepare(`
208
+ UPDATE observations SET summary = ?, concepts = ?, tokens_read = ?
209
+ WHERE id = ?
210
+ `),
211
+
212
+ getRecentObservations: db.prepare(`
213
+ SELECT o.*, s.project_path
214
+ FROM observations o
215
+ JOIN sessions s ON o.session_id = s.id
216
+ WHERE s.project_path = ?
217
+ ORDER BY o.timestamp DESC
218
+ LIMIT ?
219
+ `),
220
+
221
+ getRecentObservationsAll: db.prepare(`
222
+ SELECT o.*, s.project_path
223
+ FROM observations o
224
+ JOIN sessions s ON o.session_id = s.id
225
+ ORDER BY o.timestamp DESC
226
+ LIMIT ?
227
+ `),
228
+
229
+ getObservationsByType: db.prepare(`
230
+ SELECT o.*, s.project_path
231
+ FROM observations o
232
+ JOIN sessions s ON o.session_id = s.id
233
+ WHERE o.type = ?
234
+ ORDER BY o.timestamp DESC
235
+ LIMIT ?
236
+ `),
237
+
238
+ searchObservations: db.prepare(`
239
+ SELECT o.*, s.project_path,
240
+ highlight(observations_fts, 1, '<mark>', '</mark>') as summary_highlight
241
+ FROM observations_fts fts
242
+ JOIN observations o ON fts.rowid = o.id
243
+ JOIN sessions s ON o.session_id = s.id
244
+ WHERE observations_fts MATCH ?
245
+ ORDER BY rank
246
+ LIMIT ?
247
+ `),
248
+
249
+ // User prompts
250
+ saveUserPrompt: db.prepare(`
251
+ INSERT INTO user_prompts (session_id, content)
252
+ VALUES (?, ?)
253
+ `),
254
+
255
+ getRecentUserPrompts: db.prepare(`
256
+ SELECT * FROM user_prompts
257
+ WHERE session_id = ?
258
+ ORDER BY timestamp DESC
259
+ LIMIT ?
260
+ `),
261
+
262
+ // Summaries
263
+ saveSummary: db.prepare(`
264
+ INSERT INTO summaries (session_id, content, request, learned, completed, next_steps)
265
+ VALUES (?, ?, ?, ?, ?, ?)
266
+ `),
267
+
268
+ getRecentSummaries: db.prepare(`
269
+ SELECT su.*, s.project_path
270
+ FROM summaries su
271
+ JOIN sessions s ON su.session_id = s.id
272
+ WHERE s.project_path = ?
273
+ ORDER BY su.created_at DESC
274
+ LIMIT ?
275
+ `),
276
+
277
+ getSummaryBySession: db.prepare(`
278
+ SELECT * FROM summaries
279
+ WHERE session_id = ?
280
+ ORDER BY id DESC
281
+ LIMIT 1
282
+ `),
283
+
284
+ getSummaryBySessionKey: db.prepare(`
285
+ SELECT su.*, s.session_key
286
+ FROM summaries su
287
+ JOIN sessions s ON su.session_id = s.id
288
+ WHERE s.session_key = ?
289
+ ORDER BY su.id DESC
290
+ LIMIT 1
291
+ `),
292
+
293
+ // Stats
294
+ getStats: db.prepare(`
295
+ SELECT
296
+ (SELECT COUNT(*) FROM sessions) as total_sessions,
297
+ (SELECT COUNT(*) FROM observations) as total_observations,
298
+ (SELECT COUNT(*) FROM summaries) as total_summaries,
299
+ (SELECT COUNT(*) FROM user_prompts) as total_user_prompts,
300
+ (SELECT SUM(tokens_discovery) FROM observations) as total_discovery_tokens,
301
+ (SELECT SUM(tokens_read) FROM observations) as total_read_tokens
302
+ `)
303
+ };
304
+
305
+ // Database API
306
+ export const database = {
307
+ // Session operations
308
+ createSession(id, projectPath, sessionKey, source = 'unknown') {
309
+ try {
310
+ stmts.createSession.run(id, projectPath, sessionKey, source);
311
+ return { success: true, id };
312
+ } catch (err) {
313
+ // Session might already exist
314
+ return { success: false, error: err.message };
315
+ }
316
+ },
317
+
318
+ getSession(id) {
319
+ return stmts.getSession.get(id);
320
+ },
321
+
322
+ getActiveSession(sessionKey) {
323
+ return stmts.getActiveSession.get(sessionKey);
324
+ },
325
+
326
+ endSession(id) {
327
+ stmts.endSession.run(id);
328
+ },
329
+
330
+ // Observation operations - extended with type, narrative, facts, files tracking
331
+ saveObservation(sessionId, toolName, toolInput, toolResponse, options = {}) {
332
+ const {
333
+ summary = null,
334
+ concepts = null,
335
+ tokensDiscovery = 0,
336
+ tokensRead = 0,
337
+ type = null,
338
+ narrative = null,
339
+ facts = null,
340
+ filesRead = null,
341
+ filesModified = null
342
+ } = options;
343
+
344
+ const result = stmts.saveObservation.run(
345
+ sessionId,
346
+ toolName,
347
+ JSON.stringify(toolInput),
348
+ JSON.stringify(toolResponse),
349
+ summary,
350
+ concepts,
351
+ tokensDiscovery,
352
+ tokensRead,
353
+ type,
354
+ narrative,
355
+ typeof facts === 'string' ? facts : JSON.stringify(facts),
356
+ typeof filesRead === 'string' ? filesRead : JSON.stringify(filesRead),
357
+ typeof filesModified === 'string' ? filesModified : JSON.stringify(filesModified)
358
+ );
359
+
360
+ return { success: true, id: result.lastInsertRowid };
361
+ },
362
+
363
+ getObservation(id) {
364
+ const row = stmts.getObservation.get(id);
365
+ if (row) {
366
+ row.tool_input = JSON.parse(row.tool_input || '{}');
367
+ row.tool_response = JSON.parse(row.tool_response || '{}');
368
+ }
369
+ return row;
370
+ },
371
+
372
+ getObservations(ids) {
373
+ const rows = stmts.getObservations.all(JSON.stringify(ids));
374
+ return rows.map(row => ({
375
+ ...row,
376
+ tool_input: JSON.parse(row.tool_input || '{}'),
377
+ tool_response: JSON.parse(row.tool_response || '{}')
378
+ }));
379
+ },
380
+
381
+ updateObservationSummary(id, summary, concepts, tokensRead) {
382
+ stmts.updateObservationSummary.run(summary, concepts, tokensRead, id);
383
+ },
384
+
385
+ getRecentObservations(projectPath, limit = 50) {
386
+ const rows = projectPath
387
+ ? stmts.getRecentObservations.all(projectPath, limit)
388
+ : stmts.getRecentObservationsAll.all(limit);
389
+
390
+ return rows.map(row => ({
391
+ ...row,
392
+ tool_input: JSON.parse(row.tool_input || '{}'),
393
+ tool_response: JSON.parse(row.tool_response || '{}')
394
+ }));
395
+ },
396
+
397
+ searchObservations(query, limit = 20) {
398
+ try {
399
+ // Check if query contains CJK characters (Chinese/Japanese/Korean)
400
+ const hasCJK = /[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u309f\u30a0-\u30ff]/.test(query);
401
+
402
+ let rows = [];
403
+
404
+ if (!hasCJK) {
405
+ // For non-CJK, use FTS5 search
406
+ // Escape FTS5 special characters by wrapping in double quotes
407
+ const safeQuery = query.includes('.') || query.includes('*') || query.includes('+')
408
+ ? `"${query.replace(/"/g, '""')}"`
409
+ : query;
410
+ rows = stmts.searchObservations.all(safeQuery, limit);
411
+ }
412
+
413
+ // If FTS5 returned no results or query has CJK, use LIKE fallback
414
+ if (rows.length === 0) {
415
+ // Split query by spaces and search for each term (AND logic)
416
+ const terms = query.split(/\s+/).filter(t => t.length > 0);
417
+
418
+ if (terms.length === 1) {
419
+ // Single term - simple LIKE
420
+ const likeQuery = `%${terms[0]}%`;
421
+ rows = db.prepare(`
422
+ SELECT o.*, s.project_path
423
+ FROM observations o
424
+ JOIN sessions s ON o.session_id = s.id
425
+ WHERE o.summary LIKE ?
426
+ OR o.concepts LIKE ?
427
+ OR o.narrative LIKE ?
428
+ OR o.facts LIKE ?
429
+ ORDER BY o.timestamp DESC
430
+ LIMIT ?
431
+ `).all(likeQuery, likeQuery, likeQuery, likeQuery, limit);
432
+ } else {
433
+ // Multiple terms - search for first term, results should contain all terms
434
+ const firstTerm = `%${terms[0]}%`;
435
+ const candidates = db.prepare(`
436
+ SELECT o.*, s.project_path
437
+ FROM observations o
438
+ JOIN sessions s ON o.session_id = s.id
439
+ WHERE o.summary LIKE ?
440
+ OR o.concepts LIKE ?
441
+ OR o.narrative LIKE ?
442
+ OR o.facts LIKE ?
443
+ ORDER BY o.timestamp DESC
444
+ LIMIT 100
445
+ `).all(firstTerm, firstTerm, firstTerm, firstTerm);
446
+
447
+ // Filter to rows containing all terms
448
+ rows = candidates.filter(row => {
449
+ const text = `${row.summary || ''} ${row.concepts || ''} ${row.narrative || ''} ${row.facts || ''}`.toLowerCase();
450
+ return terms.every(term => text.includes(term.toLowerCase()));
451
+ }).slice(0, limit);
452
+ }
453
+ }
454
+
455
+ return rows.map(row => ({
456
+ ...row,
457
+ tool_input: JSON.parse(row.tool_input || '{}'),
458
+ tool_response: JSON.parse(row.tool_response || '{}'),
459
+ facts: row.facts ? JSON.parse(row.facts) : null,
460
+ files_read: row.files_read ? JSON.parse(row.files_read) : null,
461
+ files_modified: row.files_modified ? JSON.parse(row.files_modified) : null
462
+ }));
463
+ } catch (err) {
464
+ console.error('[openclaw-mem] Search error:', err.message);
465
+ return [];
466
+ }
467
+ },
468
+
469
+ getObservationsByType(type, limit = 20) {
470
+ const rows = stmts.getObservationsByType.all(type, limit);
471
+ return rows.map(row => ({
472
+ ...row,
473
+ tool_input: JSON.parse(row.tool_input || '{}'),
474
+ tool_response: JSON.parse(row.tool_response || '{}'),
475
+ facts: row.facts ? JSON.parse(row.facts) : null,
476
+ files_read: row.files_read ? JSON.parse(row.files_read) : null,
477
+ files_modified: row.files_modified ? JSON.parse(row.files_modified) : null
478
+ }));
479
+ },
480
+
481
+ // User prompt operations
482
+ saveUserPrompt(sessionId, content) {
483
+ const result = stmts.saveUserPrompt.run(sessionId, content);
484
+ return { success: true, id: result.lastInsertRowid };
485
+ },
486
+
487
+ getRecentUserPrompts(sessionId, limit = 10) {
488
+ return stmts.getRecentUserPrompts.all(sessionId, limit);
489
+ },
490
+
491
+ // Summary operations
492
+ saveSummary(sessionId, content, request = null, learned = null, completed = null, nextSteps = null) {
493
+ const result = stmts.saveSummary.run(sessionId, content, request, learned, completed, nextSteps);
494
+ return { success: true, id: result.lastInsertRowid };
495
+ },
496
+
497
+ getRecentSummaries(projectPath, limit = 5) {
498
+ return stmts.getRecentSummaries.all(projectPath, limit);
499
+ },
500
+
501
+ getSummaryBySession(sessionId) {
502
+ return stmts.getSummaryBySession.get(sessionId);
503
+ },
504
+
505
+ getSummaryBySessionKey(sessionKey) {
506
+ return stmts.getSummaryBySessionKey.get(sessionKey);
507
+ },
508
+
509
+ // Stats
510
+ getStats() {
511
+ return stmts.getStats.get();
512
+ },
513
+
514
+ // Close database
515
+ close() {
516
+ db.close();
517
+ }
518
+ };
519
+
520
+ export default database;