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