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
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OpenClaw-Mem HTTP API Server
4
+ *
5
+ * HTTP 接口替代 MCP(用于 OpenClaw 不支持 MCP 的情况)
6
+ * 启动: node mcp-http-api.js
7
+ * 端口: 18790
8
+ */
9
+
10
+ import http from 'http';
11
+ import database from './database.js';
12
+ import { callGatewayEmbeddings } from './gateway-llm.js';
13
+
14
+ const PORT = process.env.OPENCLAW_MEM_API_PORT || 18790;
15
+
16
+ // ============ 工具函数 ============
17
+
18
+ function formatTime(timestamp) {
19
+ if (!timestamp) return '';
20
+ const date = new Date(timestamp);
21
+ return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
22
+ }
23
+
24
+ function formatDate(timestamp) {
25
+ if (!timestamp) return '';
26
+ const date = new Date(timestamp);
27
+ if (Number.isNaN(date.getTime())) return '';
28
+ return date.toISOString().split('T')[0];
29
+ }
30
+
31
+ function formatDateHeading(dateOrKey) {
32
+ if (!dateOrKey) return '';
33
+ let date;
34
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dateOrKey)) {
35
+ date = new Date(`${dateOrKey}T00:00:00`);
36
+ } else {
37
+ date = new Date(dateOrKey);
38
+ }
39
+ if (Number.isNaN(date.getTime())) return '';
40
+ return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' });
41
+ }
42
+
43
+ function truncateText(text, max = 80) {
44
+ if (!text) return '';
45
+ const clean = String(text).replace(/\s+/g, ' ').trim();
46
+ if (clean.length <= max) return clean;
47
+ return clean.slice(0, Math.max(0, max - 3)) + '...';
48
+ }
49
+
50
+ function estimateTokens(text) {
51
+ if (!text) return 0;
52
+ return Math.ceil(String(text).length / 4);
53
+ }
54
+
55
+ const TYPE_EMOJI = {
56
+ 'session-request': '📋',
57
+ 'discovery': '🔵',
58
+ 'bugfix': '🔴',
59
+ 'feature': '🟣',
60
+ 'refactor': '🔄',
61
+ 'change': '✅',
62
+ 'decision': '⚖️',
63
+ };
64
+
65
+ function getTypeLabel(observation) {
66
+ const type = observation?.type || 'discovery';
67
+ return TYPE_EMOJI[type] || '🔵';
68
+ }
69
+
70
+ function normalizeIds(input) {
71
+ const ids = [];
72
+ const pushId = (value) => {
73
+ if (value === null || value === undefined) return;
74
+ const cleaned = String(value).replace(/^#/, '').trim();
75
+ if (!cleaned) return;
76
+ const parsed = Number(cleaned);
77
+ if (!Number.isNaN(parsed)) ids.push(parsed);
78
+ };
79
+
80
+ if (Array.isArray(input)) {
81
+ input.forEach(pushId);
82
+ return ids;
83
+ }
84
+
85
+ if (typeof input === 'string') {
86
+ input.split(/[,\s]+/).forEach(pushId);
87
+ return ids;
88
+ }
89
+
90
+ pushId(input);
91
+ return ids;
92
+ }
93
+
94
+ // ============ Hybrid Search ============
95
+
96
+ function mergeHybridResults(ftsResults, vectorResults, limit) {
97
+ let ftsMin = Infinity, ftsMax = -Infinity;
98
+ for (const r of ftsResults) {
99
+ const rank = Math.abs(r.rank ?? 0);
100
+ if (rank < ftsMin) ftsMin = rank;
101
+ if (rank > ftsMax) ftsMax = rank;
102
+ }
103
+ const ftsRange = ftsMax - ftsMin || 1;
104
+
105
+ const scoreMap = new Map();
106
+
107
+ for (const r of ftsResults) {
108
+ const rank = Math.abs(r.rank ?? 0);
109
+ const ftsScore = 1 - ((rank - ftsMin) / ftsRange);
110
+ scoreMap.set(r.id, { obs: r, ftsScore, vecScore: 0 });
111
+ }
112
+
113
+ for (const v of vectorResults) {
114
+ const vecScore = 1 - (v.distance ?? 0);
115
+ const existing = scoreMap.get(v.observation_id);
116
+ if (existing) {
117
+ existing.vecScore = vecScore;
118
+ } else {
119
+ const obs = database.getObservation(v.observation_id);
120
+ if (obs) {
121
+ scoreMap.set(v.observation_id, { obs, ftsScore: 0, vecScore });
122
+ }
123
+ }
124
+ }
125
+
126
+ const scored = [];
127
+ for (const [id, entry] of scoreMap) {
128
+ const { obs, ftsScore, vecScore } = entry;
129
+ const inBoth = ftsScore > 0 && vecScore > 0;
130
+ const combined = (0.4 * ftsScore) + (0.6 * vecScore) + (inBoth ? 0.2 : 0);
131
+ scored.push({ obs, combined });
132
+ }
133
+
134
+ scored.sort((a, b) => b.combined - a.combined);
135
+ return scored.slice(0, limit).map(s => s.obs);
136
+ }
137
+
138
+ // ============ API 功能 ============
139
+
140
+ async function search(args = {}) {
141
+ const query = typeof args === 'string' ? args : (args.query || args.q || '*');
142
+ const limit = args.limit ?? 30;
143
+
144
+ let results;
145
+ if (query === '*' || !query) {
146
+ results = database.getRecentObservations(null, limit);
147
+ } else {
148
+ // Hybrid search: FTS + vector
149
+ const ftsResults = database.searchObservations(query, limit * 2);
150
+
151
+ let vectorResults = [];
152
+ try {
153
+ const embedding = await callGatewayEmbeddings(query);
154
+ if (embedding) {
155
+ vectorResults = database.searchByVector(embedding, limit * 2);
156
+ }
157
+ } catch (err) {
158
+ console.error('[openclaw-mem-api] Vector search error:', err.message);
159
+ }
160
+
161
+ if (vectorResults.length > 0) {
162
+ results = mergeHybridResults(ftsResults, vectorResults, limit);
163
+ console.log(`[openclaw-mem-api] Hybrid: ${ftsResults.length} FTS + ${vectorResults.length} vector → ${results.length} merged`);
164
+ } else {
165
+ results = ftsResults.slice(0, limit);
166
+ }
167
+ }
168
+
169
+ // 按日期分组
170
+ const grouped = new Map();
171
+ for (const obs of results) {
172
+ const dateKey = formatDate(obs.timestamp) || 'Unknown';
173
+ if (!grouped.has(dateKey)) {
174
+ grouped.set(dateKey, []);
175
+ }
176
+ grouped.get(dateKey).push(obs);
177
+ }
178
+
179
+ const lines = [`Found ${results.length} result(s)`, ''];
180
+
181
+ for (const [dateKey, obs] of grouped.entries()) {
182
+ lines.push(`### ${formatDateHeading(dateKey) || dateKey}`);
183
+ lines.push('| ID | Time | T | Title | Read |');
184
+ lines.push('|----|------|---|-------|------|');
185
+
186
+ for (const o of obs) {
187
+ const title = truncateText(o.narrative || o.summary || o.tool_name, 60);
188
+ lines.push(`| #${o.id} | ${formatTime(o.timestamp)} | ${getTypeLabel(o)} | ${title} | ~${o.tokens_read || estimateTokens(title)} |`);
189
+ }
190
+ lines.push('');
191
+ }
192
+
193
+ return lines.join('\n');
194
+ }
195
+
196
+ function timeline(args = {}) {
197
+ let anchorId = args.anchor ?? args.id;
198
+ if (!anchorId && args.query) {
199
+ const searchResults = database.searchObservations(args.query, 1);
200
+ if (searchResults.length > 0) anchorId = searchResults[0].id;
201
+ }
202
+
203
+ anchorId = Number(String(anchorId ?? '').replace(/^#/, ''));
204
+ if (Number.isNaN(anchorId)) {
205
+ return 'No anchor ID provided';
206
+ }
207
+
208
+ const depthBefore = args.depth_before ?? 3;
209
+ const depthAfter = args.depth_after ?? 2;
210
+
211
+ const allObs = database.getRecentObservations(null, 100);
212
+ const anchorIdx = allObs.findIndex(o => o.id === anchorId);
213
+
214
+ if (anchorIdx === -1) {
215
+ const anchor = database.getObservation(anchorId);
216
+ return anchor ? get_observations({ ids: [anchorId] }) : `Observation #${anchorId} not found`;
217
+ }
218
+
219
+ const startIdx = Math.max(0, anchorIdx - depthAfter);
220
+ const endIdx = Math.min(allObs.length, anchorIdx + depthBefore + 1);
221
+ const timelineObs = allObs.slice(startIdx, endIdx).reverse();
222
+
223
+ const lines = [`## Timeline around #${anchorId}`, '', '| | Time | T | ID | Title |', '|---|------|---|-----|-------|'];
224
+
225
+ for (const o of timelineObs) {
226
+ const marker = o.id === anchorId ? '→' : '';
227
+ const title = truncateText(o.narrative || o.summary || o.tool_name, 70);
228
+ lines.push(`| ${marker} | ${formatTime(o.timestamp)} | ${getTypeLabel(o)} | #${o.id} | ${title} |`);
229
+ }
230
+
231
+ return lines.join('\n');
232
+ }
233
+
234
+ function get_observations(args = {}) {
235
+ const ids = normalizeIds(args.ids ?? args.id ?? []);
236
+ if (!ids.length) return 'No IDs provided';
237
+
238
+ const observations = database.getObservations(ids);
239
+ if (!observations.length) return `No observations found for IDs: ${ids.join(', ')}`;
240
+
241
+ const lines = [];
242
+ for (const o of observations) {
243
+ lines.push(`## #${o.id} ${getTypeLabel(o)} ${truncateText(o.narrative || o.summary || o.tool_name, 80)}`);
244
+ lines.push('');
245
+ if (o.timestamp) lines.push(`**Time**: ${formatDateHeading(o.timestamp)} ${formatTime(o.timestamp)}`);
246
+ if (o.tool_name) lines.push(`**Tool**: ${o.tool_name}`);
247
+ if (o.type) lines.push(`**Type**: ${o.type}`);
248
+ lines.push('');
249
+
250
+ // 优先显示完整内容(concepts 字段),而不是截断的 summary
251
+ const fullContent = o.concepts || o.summary || '';
252
+ if (fullContent) {
253
+ lines.push(`**内容**:`);
254
+ lines.push('');
255
+ lines.push(fullContent);
256
+ lines.push('');
257
+ }
258
+
259
+ let facts = o.facts;
260
+ if (typeof facts === 'string') try { facts = JSON.parse(facts); } catch { facts = null; }
261
+ if (Array.isArray(facts) && facts.length > 0) {
262
+ lines.push('**Facts**:');
263
+ facts.slice(0, 8).forEach(f => f && lines.push(`- ${f}`));
264
+ lines.push('');
265
+ }
266
+
267
+ lines.push('---');
268
+ lines.push('');
269
+ }
270
+
271
+ return lines.join('\n');
272
+ }
273
+
274
+ function getStats() {
275
+ const stats = database.getStats();
276
+ return JSON.stringify(stats, null, 2);
277
+ }
278
+
279
+ // ============ HTTP Server ============
280
+
281
+ const server = http.createServer((req, res) => {
282
+ // CORS
283
+ res.setHeader('Access-Control-Allow-Origin', '*');
284
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
285
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
286
+
287
+ if (req.method === 'OPTIONS') {
288
+ res.writeHead(204);
289
+ res.end();
290
+ return;
291
+ }
292
+
293
+ let body = '';
294
+ req.on('data', chunk => body += chunk);
295
+ req.on('end', async () => {
296
+ // 处理未编码的中文 URL - 手动编码非 ASCII 字符
297
+ let safeUrl = req.url;
298
+ try {
299
+ // 检查 URL 是否包含未编码的非 ASCII 字符
300
+ if (/[^\x00-\x7F]/.test(req.url)) {
301
+ // 只编码查询字符串部分的非 ASCII 字符
302
+ const [pathPart, queryPart] = req.url.split('?');
303
+ if (queryPart) {
304
+ const encodedQuery = queryPart.replace(/[^\x00-\x7F]/g, (char) => encodeURIComponent(char));
305
+ safeUrl = pathPart + '?' + encodedQuery;
306
+ }
307
+ }
308
+ } catch (e) {
309
+ // 编码失败时使用原始 URL
310
+ }
311
+ const url = new URL(safeUrl, `http://localhost:${PORT}`);
312
+
313
+ // 记录 API 请求(用于监控)- 详细日志
314
+ if (url.pathname !== '/health') {
315
+ const ts = new Date().toISOString();
316
+ console.log(`[${ts}] ${req.method} ${url.pathname}`);
317
+ console.log(` Raw URL: ${req.url}`);
318
+ console.log(` Query: ${url.search}`);
319
+ if (body) console.log(` Body: ${body.slice(0, 200)}`);
320
+ }
321
+
322
+ // 解析参数
323
+ let args = {};
324
+ if (body) {
325
+ try { args = JSON.parse(body); } catch { args = {}; }
326
+ }
327
+ // GET 参数
328
+ for (const [key, value] of url.searchParams) {
329
+ args[key] = value;
330
+ }
331
+
332
+ let result;
333
+ let contentType = 'text/plain; charset=utf-8';
334
+
335
+ try {
336
+ switch (url.pathname) {
337
+ case '/':
338
+ case '/health':
339
+ result = JSON.stringify({ status: 'ok', version: '1.0.0' });
340
+ contentType = 'application/json';
341
+ break;
342
+
343
+ case '/search':
344
+ result = await search(args);
345
+ break;
346
+
347
+ case '/timeline':
348
+ result = timeline(args);
349
+ break;
350
+
351
+ case '/get_observations':
352
+ case '/observations':
353
+ result = get_observations(args);
354
+ break;
355
+
356
+ case '/stats':
357
+ result = getStats();
358
+ contentType = 'application/json';
359
+ break;
360
+
361
+ case '/help':
362
+ result = `# OpenClaw-Mem HTTP API
363
+
364
+ ## Endpoints
365
+
366
+ ### GET/POST /search
367
+ Search memory observations.
368
+ Params: query, limit
369
+
370
+ ### GET/POST /timeline
371
+ Get context around an observation.
372
+ Params: anchor (ID), query, depth_before, depth_after
373
+
374
+ ### GET/POST /get_observations
375
+ Get full details for specific IDs.
376
+ Params: ids (array or comma-separated)
377
+
378
+ ### GET /stats
379
+ Get database statistics.
380
+
381
+ ## Examples
382
+
383
+ curl "http://localhost:${PORT}/search?query=database&limit=10"
384
+ curl "http://localhost:${PORT}/timeline?anchor=123"
385
+ curl -X POST "http://localhost:${PORT}/get_observations" -d '{"ids":[123,124]}'
386
+ `;
387
+ break;
388
+
389
+ default:
390
+ res.writeHead(404);
391
+ res.end('Not found. Try /help');
392
+ return;
393
+ }
394
+
395
+ res.writeHead(200, { 'Content-Type': contentType });
396
+ res.end(result);
397
+ } catch (error) {
398
+ console.error('[openclaw-mem-api] Error:', error.message);
399
+ res.writeHead(500, { 'Content-Type': 'application/json' });
400
+ res.end(JSON.stringify({ error: error.message }));
401
+ }
402
+ });
403
+ });
404
+
405
+ server.listen(PORT, '127.0.0.1', () => {
406
+ console.log(`[openclaw-mem] HTTP API running on http://127.0.0.1:${PORT}`);
407
+ console.log(`[openclaw-mem] Try: curl "http://127.0.0.1:${PORT}/help"`);
408
+
409
+ // Preload embedding model in background
410
+ callGatewayEmbeddings('warmup').then(() => {
411
+ console.log('[openclaw-mem] Embedding model preloaded for HTTP API');
412
+ }).catch(() => {});
413
+ });
414
+
415
+ // 优雅关闭
416
+ process.on('SIGTERM', () => {
417
+ console.log('[openclaw-mem] Shutting down...');
418
+ server.close(() => process.exit(0));
419
+ });
420
+
421
+ process.on('SIGINT', () => {
422
+ console.log('[openclaw-mem] Shutting down...');
423
+ server.close(() => process.exit(0));
424
+ });