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.
- package/HOOK.md +125 -0
- package/LICENSE +1 -1
- package/MCP.json +11 -0
- package/README.md +158 -167
- package/backfill-embeddings.js +79 -0
- package/context-builder.js +703 -0
- package/database.js +625 -0
- package/debug-logger.js +280 -0
- package/extractor.js +268 -0
- package/gateway-llm.js +250 -0
- package/handler.js +941 -0
- package/mcp-http-api.js +424 -0
- package/mcp-server.js +605 -0
- package/mem-get.sh +24 -0
- package/mem-search.sh +17 -0
- package/monitor.js +112 -0
- package/package.json +58 -30
- package/realtime-monitor.js +371 -0
- package/session-watcher.js +192 -0
- package/setup.js +114 -0
- package/sync-recent.js +63 -0
- package/README_CN.md +0 -201
- package/bin/openclaw-mem.js +0 -117
- package/docs/locales/README_AR.md +0 -35
- package/docs/locales/README_DE.md +0 -35
- package/docs/locales/README_ES.md +0 -35
- package/docs/locales/README_FR.md +0 -35
- package/docs/locales/README_HE.md +0 -35
- package/docs/locales/README_HI.md +0 -35
- package/docs/locales/README_ID.md +0 -35
- package/docs/locales/README_IT.md +0 -35
- package/docs/locales/README_JA.md +0 -57
- package/docs/locales/README_KO.md +0 -35
- package/docs/locales/README_NL.md +0 -35
- package/docs/locales/README_PL.md +0 -35
- package/docs/locales/README_PT.md +0 -35
- package/docs/locales/README_RU.md +0 -35
- package/docs/locales/README_TH.md +0 -35
- package/docs/locales/README_TR.md +0 -35
- package/docs/locales/README_UK.md +0 -35
- package/docs/locales/README_VI.md +0 -35
- package/docs/logo.svg +0 -32
- package/lib/context-builder.js +0 -415
- package/lib/database.js +0 -309
- package/lib/handler.js +0 -494
- package/scripts/commands.js +0 -141
- package/scripts/init.js +0 -248
package/mcp-http-api.js
ADDED
|
@@ -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
|
+
});
|