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-server.js
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* OpenClaw-Mem MCP Server
|
|
4
|
+
*
|
|
5
|
+
* 实现 MCP (Model Context Protocol) 标准接口
|
|
6
|
+
* 提供 3 层记忆检索工作流:search → timeline → get_observations
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
+
import {
|
|
12
|
+
CallToolRequestSchema,
|
|
13
|
+
ListToolsRequestSchema,
|
|
14
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
15
|
+
import database from './database.js';
|
|
16
|
+
import { callGatewayEmbeddings } from './gateway-llm.js';
|
|
17
|
+
|
|
18
|
+
// ============ 工具函数 ============
|
|
19
|
+
|
|
20
|
+
function formatTime(timestamp) {
|
|
21
|
+
if (!timestamp) return '';
|
|
22
|
+
const date = new Date(timestamp);
|
|
23
|
+
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatDate(timestamp) {
|
|
27
|
+
if (!timestamp) return '';
|
|
28
|
+
const date = new Date(timestamp);
|
|
29
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
30
|
+
return date.toISOString().split('T')[0];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatDateHeading(dateOrKey) {
|
|
34
|
+
if (!dateOrKey) return '';
|
|
35
|
+
let date;
|
|
36
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(dateOrKey)) {
|
|
37
|
+
date = new Date(`${dateOrKey}T00:00:00`);
|
|
38
|
+
} else {
|
|
39
|
+
date = new Date(dateOrKey);
|
|
40
|
+
}
|
|
41
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
42
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function truncateText(text, max = 80) {
|
|
46
|
+
if (!text) return '';
|
|
47
|
+
const clean = String(text).replace(/\s+/g, ' ').trim();
|
|
48
|
+
if (clean.length <= max) return clean;
|
|
49
|
+
return clean.slice(0, Math.max(0, max - 3)) + '...';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function estimateTokens(text) {
|
|
53
|
+
if (!text) return 0;
|
|
54
|
+
return Math.ceil(String(text).length / 4);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Type mapping
|
|
58
|
+
const TYPE_EMOJI = {
|
|
59
|
+
'session-request': '📋',
|
|
60
|
+
'discovery': '🔵',
|
|
61
|
+
'bugfix': '🔴',
|
|
62
|
+
'feature': '🟣',
|
|
63
|
+
'refactor': '🔄',
|
|
64
|
+
'change': '✅',
|
|
65
|
+
'decision': '⚖️',
|
|
66
|
+
'problem-solution': '💡',
|
|
67
|
+
'gotcha': '⚠️',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function getTypeLabel(observation) {
|
|
71
|
+
const type = observation?.type || 'discovery';
|
|
72
|
+
return TYPE_EMOJI[type] || '🔵';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeIds(input) {
|
|
76
|
+
const ids = [];
|
|
77
|
+
const pushId = (value) => {
|
|
78
|
+
if (value === null || value === undefined) return;
|
|
79
|
+
const cleaned = String(value).replace(/^#/, '').trim();
|
|
80
|
+
if (!cleaned) return;
|
|
81
|
+
const parsed = Number(cleaned);
|
|
82
|
+
if (!Number.isNaN(parsed)) ids.push(parsed);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(input)) {
|
|
86
|
+
input.forEach(pushId);
|
|
87
|
+
return ids;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof input === 'string') {
|
|
91
|
+
input.split(/[,\s]+/).forEach(pushId);
|
|
92
|
+
return ids;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
pushId(input);
|
|
96
|
+
return ids;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ============ 搜索功能 ============
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Hybrid search: merge FTS5 keyword results with vector KNN results.
|
|
103
|
+
* FTS results get fts_score (normalized 0-1), vector results get vec_score (1 - distance).
|
|
104
|
+
* Results found in both get a 0.2 intersection bonus.
|
|
105
|
+
*/
|
|
106
|
+
function mergeHybridResults(ftsResults, vectorResults, limit) {
|
|
107
|
+
// Normalize FTS scores (rank is negative, lower is better)
|
|
108
|
+
let ftsMin = Infinity, ftsMax = -Infinity;
|
|
109
|
+
for (const r of ftsResults) {
|
|
110
|
+
const rank = Math.abs(r.rank ?? 0);
|
|
111
|
+
if (rank < ftsMin) ftsMin = rank;
|
|
112
|
+
if (rank > ftsMax) ftsMax = rank;
|
|
113
|
+
}
|
|
114
|
+
const ftsRange = ftsMax - ftsMin || 1;
|
|
115
|
+
|
|
116
|
+
const scoreMap = new Map(); // id -> { obs, ftsScore, vecScore }
|
|
117
|
+
|
|
118
|
+
for (const r of ftsResults) {
|
|
119
|
+
const rank = Math.abs(r.rank ?? 0);
|
|
120
|
+
const ftsScore = 1 - ((rank - ftsMin) / ftsRange); // normalize to 0-1, higher is better
|
|
121
|
+
scoreMap.set(r.id, { obs: r, ftsScore, vecScore: 0 });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const v of vectorResults) {
|
|
125
|
+
const vecScore = 1 - (v.distance ?? 0); // cosine distance -> similarity
|
|
126
|
+
const existing = scoreMap.get(v.observation_id);
|
|
127
|
+
if (existing) {
|
|
128
|
+
existing.vecScore = vecScore;
|
|
129
|
+
} else {
|
|
130
|
+
// Need to fetch the full observation for vector-only results
|
|
131
|
+
const obs = database.getObservation(v.observation_id);
|
|
132
|
+
if (obs) {
|
|
133
|
+
scoreMap.set(v.observation_id, { obs, ftsScore: 0, vecScore });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Calculate combined scores
|
|
139
|
+
const scored = [];
|
|
140
|
+
for (const [id, entry] of scoreMap) {
|
|
141
|
+
const { obs, ftsScore, vecScore } = entry;
|
|
142
|
+
const inBoth = ftsScore > 0 && vecScore > 0;
|
|
143
|
+
const combined = (0.4 * ftsScore) + (0.6 * vecScore) + (inBoth ? 0.2 : 0);
|
|
144
|
+
scored.push({ obs, combined, ftsScore, vecScore });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
scored.sort((a, b) => b.combined - a.combined);
|
|
148
|
+
return scored.slice(0, limit);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function search(args = {}) {
|
|
152
|
+
const query = typeof args === 'string' ? args : (args.query || args.q || '*');
|
|
153
|
+
const limit = args.limit ?? args.maxResults ?? 30;
|
|
154
|
+
const project = args.project || null;
|
|
155
|
+
const type = args.type || args.obs_type || null;
|
|
156
|
+
const dateStart = args.dateStart || null;
|
|
157
|
+
const dateEnd = args.dateEnd || null;
|
|
158
|
+
|
|
159
|
+
let results;
|
|
160
|
+
|
|
161
|
+
if (query === '*' || !query) {
|
|
162
|
+
// 获取最近的 observations — no embedding needed for recent listing
|
|
163
|
+
results = database.getRecentObservations(project, limit * 2);
|
|
164
|
+
} else {
|
|
165
|
+
// Hybrid search: FTS5 + vector KNN
|
|
166
|
+
const ftsResults = database.searchObservations(query, limit * 2);
|
|
167
|
+
|
|
168
|
+
// Try vector search in parallel
|
|
169
|
+
let vectorResults = [];
|
|
170
|
+
try {
|
|
171
|
+
const embedding = await callGatewayEmbeddings(query);
|
|
172
|
+
if (embedding) {
|
|
173
|
+
vectorResults = database.searchByVector(embedding, limit * 2);
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error('[openclaw-mem-mcp] Vector search error:', err.message);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (vectorResults.length > 0) {
|
|
180
|
+
// Merge hybrid results
|
|
181
|
+
const merged = mergeHybridResults(ftsResults, vectorResults, limit * 2);
|
|
182
|
+
results = merged.map(m => m.obs);
|
|
183
|
+
console.error(`[openclaw-mem-mcp] Hybrid search: ${ftsResults.length} FTS + ${vectorResults.length} vector → ${results.length} merged`);
|
|
184
|
+
} else {
|
|
185
|
+
// Fallback to FTS-only
|
|
186
|
+
results = ftsResults;
|
|
187
|
+
console.error(`[openclaw-mem-mcp] FTS-only search: ${results.length} results`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 过滤
|
|
192
|
+
if (type) {
|
|
193
|
+
results = results.filter(r => r.type === type);
|
|
194
|
+
}
|
|
195
|
+
if (dateStart) {
|
|
196
|
+
const start = new Date(dateStart).getTime();
|
|
197
|
+
results = results.filter(r => new Date(r.timestamp).getTime() >= start);
|
|
198
|
+
}
|
|
199
|
+
if (dateEnd) {
|
|
200
|
+
const end = new Date(dateEnd).getTime() + 86400000; // 包含当天
|
|
201
|
+
results = results.filter(r => new Date(r.timestamp).getTime() < end);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
results = results.slice(0, limit);
|
|
205
|
+
|
|
206
|
+
if (results.length === 0) {
|
|
207
|
+
return `No observations found for query: "${query}"`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 按日期分组
|
|
211
|
+
const grouped = new Map();
|
|
212
|
+
for (const obs of results) {
|
|
213
|
+
const dateKey = formatDate(obs.timestamp) || 'Unknown';
|
|
214
|
+
if (!grouped.has(dateKey)) {
|
|
215
|
+
grouped.set(dateKey, []);
|
|
216
|
+
}
|
|
217
|
+
grouped.get(dateKey).push(obs);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const lines = [
|
|
221
|
+
`Found ${results.length} result(s) matching "${query}"`,
|
|
222
|
+
''
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
for (const [dateKey, obs] of grouped.entries()) {
|
|
226
|
+
const heading = formatDateHeading(dateKey) || dateKey;
|
|
227
|
+
lines.push(`### ${heading}`);
|
|
228
|
+
lines.push('');
|
|
229
|
+
lines.push('| ID | Time | T | Title | Read |');
|
|
230
|
+
lines.push('|----|------|---|-------|------|');
|
|
231
|
+
|
|
232
|
+
for (const o of obs) {
|
|
233
|
+
const id = `#${o.id}`;
|
|
234
|
+
const time = formatTime(o.timestamp);
|
|
235
|
+
const typeLabel = getTypeLabel(o);
|
|
236
|
+
const title = truncateText(o.narrative || o.summary || `${o.tool_name} operation`, 60);
|
|
237
|
+
const tokens = `~${o.tokens_read || estimateTokens(title)}`;
|
|
238
|
+
lines.push(`| ${id} | ${time} | ${typeLabel} | ${title} | ${tokens} |`);
|
|
239
|
+
}
|
|
240
|
+
lines.push('');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
lines.push(`*Use \`timeline\` or \`get_observations\` for full details.*`);
|
|
244
|
+
|
|
245
|
+
return lines.join('\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ============ Timeline 功能 ============
|
|
249
|
+
|
|
250
|
+
function timeline(args = {}) {
|
|
251
|
+
let anchorId;
|
|
252
|
+
|
|
253
|
+
if (typeof args === 'number' || typeof args === 'string') {
|
|
254
|
+
anchorId = Number(String(args).replace(/^#/, ''));
|
|
255
|
+
} else {
|
|
256
|
+
const anchor = args.anchor ?? args.id ?? args.observation_id ?? args.observationId;
|
|
257
|
+
|
|
258
|
+
// 如果提供了 query,自动查找 anchor
|
|
259
|
+
if (!anchor && args.query) {
|
|
260
|
+
const searchResults = database.searchObservations(args.query, 1);
|
|
261
|
+
if (searchResults.length > 0) {
|
|
262
|
+
anchorId = searchResults[0].id;
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
anchorId = Number(String(anchor ?? '').replace(/^#/, ''));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (Number.isNaN(anchorId)) {
|
|
270
|
+
return 'No anchor ID provided. Use timeline(anchor=<ID>) or timeline(query="...")';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const depthBefore = Number(args.depth_before ?? args.before ?? 3);
|
|
274
|
+
const depthAfter = Number(args.depth_after ?? args.after ?? 2);
|
|
275
|
+
|
|
276
|
+
const anchor = database.getObservation(anchorId);
|
|
277
|
+
if (!anchor) {
|
|
278
|
+
return `Observation #${anchorId} not found`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 获取周围的 observations
|
|
282
|
+
const allObs = database.getRecentObservations(null, 100);
|
|
283
|
+
const anchorIdx = allObs.findIndex(o => o.id === anchorId);
|
|
284
|
+
|
|
285
|
+
if (anchorIdx === -1) {
|
|
286
|
+
// 只返回 anchor 本身
|
|
287
|
+
return buildFullDetails([anchor], 1);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 注意:列表是 DESC 排序,所以 "after" 在时间上是 "before" 在索引上
|
|
291
|
+
const startIdx = Math.max(0, anchorIdx - depthAfter);
|
|
292
|
+
const endIdx = Math.min(allObs.length, anchorIdx + depthBefore + 1);
|
|
293
|
+
const timelineObs = allObs.slice(startIdx, endIdx).reverse();
|
|
294
|
+
|
|
295
|
+
const lines = [
|
|
296
|
+
`## Timeline around #${anchorId}`,
|
|
297
|
+
'',
|
|
298
|
+
'| | Time | T | ID | Title |',
|
|
299
|
+
'|---|------|---|-----|-------|'
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
for (const o of timelineObs) {
|
|
303
|
+
const marker = o.id === anchorId ? '→' : '';
|
|
304
|
+
const time = formatTime(o.timestamp);
|
|
305
|
+
const typeLabel = getTypeLabel(o);
|
|
306
|
+
const title = truncateText(o.narrative || o.summary || `${o.tool_name} operation`, 70);
|
|
307
|
+
lines.push(`| ${marker} | ${time} | ${typeLabel} | #${o.id} | ${title} |`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
lines.push('');
|
|
311
|
+
lines.push(`*Use \`get_observations(ids=[...])\` for full details.*`);
|
|
312
|
+
|
|
313
|
+
return lines.join('\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ============ Get Observations 功能 ============
|
|
317
|
+
|
|
318
|
+
function buildFullDetails(observations, limit = 10) {
|
|
319
|
+
if (!observations || observations.length === 0) {
|
|
320
|
+
return 'No observations found.';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const toShow = observations.slice(0, limit);
|
|
324
|
+
const lines = [];
|
|
325
|
+
|
|
326
|
+
for (const o of toShow) {
|
|
327
|
+
const title = (o.narrative || o.summary || `${o.tool_name} operation`).replace(/\s+/g, ' ').trim();
|
|
328
|
+
const typeLabel = getTypeLabel(o);
|
|
329
|
+
const dateLabel = formatDateHeading(o.timestamp);
|
|
330
|
+
const timeLabel = formatTime(o.timestamp);
|
|
331
|
+
|
|
332
|
+
lines.push(`## #${o.id} ${typeLabel} ${truncateText(title, 100)}`);
|
|
333
|
+
lines.push('');
|
|
334
|
+
|
|
335
|
+
if (dateLabel || timeLabel) {
|
|
336
|
+
lines.push(`**Time**: ${[dateLabel, timeLabel].filter(Boolean).join(' ')}`);
|
|
337
|
+
}
|
|
338
|
+
if (o.tool_name) {
|
|
339
|
+
lines.push(`**Tool**: ${o.tool_name}`);
|
|
340
|
+
}
|
|
341
|
+
if (o.type) {
|
|
342
|
+
lines.push(`**Type**: ${o.type}`);
|
|
343
|
+
}
|
|
344
|
+
lines.push('');
|
|
345
|
+
|
|
346
|
+
if (o.summary) {
|
|
347
|
+
lines.push(`**Summary**: ${o.summary}`);
|
|
348
|
+
lines.push('');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (o.narrative && o.narrative !== o.summary) {
|
|
352
|
+
lines.push(`**Narrative**: ${o.narrative}`);
|
|
353
|
+
lines.push('');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 解析 facts
|
|
357
|
+
let facts = o.facts;
|
|
358
|
+
if (typeof facts === 'string') {
|
|
359
|
+
try {
|
|
360
|
+
facts = JSON.parse(facts);
|
|
361
|
+
} catch {
|
|
362
|
+
facts = null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (Array.isArray(facts) && facts.length > 0) {
|
|
366
|
+
lines.push('**Facts**:');
|
|
367
|
+
for (const fact of facts.slice(0, 8)) {
|
|
368
|
+
if (fact) lines.push(`- ${fact}`);
|
|
369
|
+
}
|
|
370
|
+
lines.push('');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 文件信息
|
|
374
|
+
let filesRead = o.files_read;
|
|
375
|
+
let filesModified = o.files_modified;
|
|
376
|
+
if (typeof filesRead === 'string') {
|
|
377
|
+
try { filesRead = JSON.parse(filesRead); } catch { filesRead = null; }
|
|
378
|
+
}
|
|
379
|
+
if (typeof filesModified === 'string') {
|
|
380
|
+
try { filesModified = JSON.parse(filesModified); } catch { filesModified = null; }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (Array.isArray(filesRead) && filesRead.length > 0) {
|
|
384
|
+
lines.push(`**Files Read**: ${filesRead.map(f => `\`${f}\``).join(', ')}`);
|
|
385
|
+
}
|
|
386
|
+
if (Array.isArray(filesModified) && filesModified.length > 0) {
|
|
387
|
+
lines.push(`**Files Modified**: ${filesModified.map(f => `\`${f}\``).join(', ')}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Tool input 关键信息
|
|
391
|
+
let input = o.tool_input;
|
|
392
|
+
if (typeof input === 'string') {
|
|
393
|
+
try { input = JSON.parse(input); } catch { input = {}; }
|
|
394
|
+
}
|
|
395
|
+
input = input || {};
|
|
396
|
+
|
|
397
|
+
const inputFacts = [];
|
|
398
|
+
if (input.file_path) inputFacts.push(`File: \`${input.file_path}\``);
|
|
399
|
+
if (input.command) inputFacts.push(`Command: \`${input.command.slice(0, 100)}\``);
|
|
400
|
+
if (input.pattern) inputFacts.push(`Pattern: \`${input.pattern}\``);
|
|
401
|
+
if (input.query) inputFacts.push(`Query: ${input.query.slice(0, 100)}`);
|
|
402
|
+
if (input.url) inputFacts.push(`URL: ${input.url}`);
|
|
403
|
+
|
|
404
|
+
if (inputFacts.length > 0) {
|
|
405
|
+
lines.push('');
|
|
406
|
+
lines.push('**Details**: ' + inputFacts.join(' | '));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
lines.push('');
|
|
410
|
+
lines.push('---');
|
|
411
|
+
lines.push('');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return lines.join('\n');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function get_observations(args = {}) {
|
|
418
|
+
const ids = Array.isArray(args)
|
|
419
|
+
? normalizeIds(args)
|
|
420
|
+
: normalizeIds(args.ids ?? args.id ?? args.observation_ids ?? args.observationIds);
|
|
421
|
+
|
|
422
|
+
if (!ids.length) {
|
|
423
|
+
return 'No observation IDs provided. Use get_observations(ids=[1, 2, 3])';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const observations = database.getObservations(ids);
|
|
427
|
+
|
|
428
|
+
if (observations.length === 0) {
|
|
429
|
+
return `No observations found for IDs: ${ids.join(', ')}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return buildFullDetails(observations, observations.length);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ============ __IMPORTANT 功能 ============
|
|
436
|
+
|
|
437
|
+
function __IMPORTANT() {
|
|
438
|
+
return `## 3-LAYER MEMORY RETRIEVAL WORKFLOW
|
|
439
|
+
|
|
440
|
+
**ALWAYS follow this workflow to minimize token usage:**
|
|
441
|
+
|
|
442
|
+
1. **search(query)** → Get index with IDs (~50-100 tokens/result)
|
|
443
|
+
\`search(query="...", limit=20)\`
|
|
444
|
+
|
|
445
|
+
2. **timeline(anchor=ID)** → Get context around interesting results
|
|
446
|
+
\`timeline(anchor=<ID>, depth_before=3, depth_after=2)\`
|
|
447
|
+
|
|
448
|
+
3. **get_observations(ids=[...])** → Fetch full details ONLY for filtered IDs
|
|
449
|
+
\`get_observations(ids=[1, 2, 3])\`
|
|
450
|
+
|
|
451
|
+
**NEVER fetch full details without filtering first. 10x token savings.**
|
|
452
|
+
|
|
453
|
+
### Quick Examples
|
|
454
|
+
|
|
455
|
+
- Search recent: \`search(query="*", limit=10)\`
|
|
456
|
+
- Search topic: \`search(query="database migration")\`
|
|
457
|
+
- Get context: \`timeline(anchor=123)\`
|
|
458
|
+
- Get details: \`get_observations(ids=[123, 124, 125])\`
|
|
459
|
+
`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ============ MCP Server 设置 ============
|
|
463
|
+
|
|
464
|
+
const TOOLS = [
|
|
465
|
+
{
|
|
466
|
+
name: '__IMPORTANT',
|
|
467
|
+
description: '3-LAYER WORKFLOW: 1. search(query) → index 2. timeline(anchor) → context 3. get_observations(ids) → details. NEVER fetch details without filtering first.',
|
|
468
|
+
inputSchema: {
|
|
469
|
+
type: 'object',
|
|
470
|
+
properties: {},
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
name: 'search',
|
|
475
|
+
description: 'Step 1: Search memory. Returns index with IDs. Params: query, limit, project, type, dateStart, dateEnd',
|
|
476
|
+
inputSchema: {
|
|
477
|
+
type: 'object',
|
|
478
|
+
properties: {
|
|
479
|
+
query: { type: 'string', description: 'Search query (use "*" for recent)' },
|
|
480
|
+
limit: { type: 'number', description: 'Max results (default 30)' },
|
|
481
|
+
project: { type: 'string', description: 'Filter by project path' },
|
|
482
|
+
type: { type: 'string', description: 'Filter by type: discovery, bugfix, feature, refactor, change, decision' },
|
|
483
|
+
dateStart: { type: 'string', description: 'Start date (YYYY-MM-DD)' },
|
|
484
|
+
dateEnd: { type: 'string', description: 'End date (YYYY-MM-DD)' },
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
name: 'timeline',
|
|
490
|
+
description: 'Step 2: Get context around results. Params: anchor (observation ID) OR query (finds anchor automatically), depth_before, depth_after',
|
|
491
|
+
inputSchema: {
|
|
492
|
+
type: 'object',
|
|
493
|
+
properties: {
|
|
494
|
+
anchor: { type: 'number', description: 'Observation ID to center on' },
|
|
495
|
+
query: { type: 'string', description: 'Auto-find anchor from search query' },
|
|
496
|
+
depth_before: { type: 'number', description: 'Observations before anchor (default 3)' },
|
|
497
|
+
depth_after: { type: 'number', description: 'Observations after anchor (default 2)' },
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
name: 'get_observations',
|
|
503
|
+
description: 'Step 3: Fetch full details for filtered IDs. Params: ids (array of observation IDs, required)',
|
|
504
|
+
inputSchema: {
|
|
505
|
+
type: 'object',
|
|
506
|
+
properties: {
|
|
507
|
+
ids: {
|
|
508
|
+
type: 'array',
|
|
509
|
+
items: { type: 'number' },
|
|
510
|
+
description: 'Array of observation IDs to fetch (required)',
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
required: ['ids'],
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
];
|
|
517
|
+
|
|
518
|
+
// 创建 MCP Server
|
|
519
|
+
const server = new Server(
|
|
520
|
+
{
|
|
521
|
+
name: 'openclaw-mem-search',
|
|
522
|
+
version: '1.0.0',
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
capabilities: {
|
|
526
|
+
tools: {},
|
|
527
|
+
},
|
|
528
|
+
}
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
// 注册工具列表
|
|
532
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
533
|
+
return { tools: TOOLS };
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// 处理工具调用
|
|
537
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
538
|
+
const { name, arguments: args } = request.params;
|
|
539
|
+
|
|
540
|
+
console.error(`[openclaw-mem-mcp] Tool called: ${name}`);
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
let result;
|
|
544
|
+
|
|
545
|
+
switch (name) {
|
|
546
|
+
case '__IMPORTANT':
|
|
547
|
+
result = __IMPORTANT();
|
|
548
|
+
break;
|
|
549
|
+
|
|
550
|
+
case 'search':
|
|
551
|
+
result = await search(args || {});
|
|
552
|
+
break;
|
|
553
|
+
|
|
554
|
+
case 'timeline':
|
|
555
|
+
result = timeline(args || {});
|
|
556
|
+
break;
|
|
557
|
+
|
|
558
|
+
case 'get_observations':
|
|
559
|
+
result = get_observations(args || {});
|
|
560
|
+
break;
|
|
561
|
+
|
|
562
|
+
default:
|
|
563
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
content: [
|
|
568
|
+
{
|
|
569
|
+
type: 'text',
|
|
570
|
+
text: result,
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
};
|
|
574
|
+
} catch (error) {
|
|
575
|
+
console.error(`[openclaw-mem-mcp] Error:`, error.message);
|
|
576
|
+
return {
|
|
577
|
+
content: [
|
|
578
|
+
{
|
|
579
|
+
type: 'text',
|
|
580
|
+
text: `Error: ${error.message}`,
|
|
581
|
+
},
|
|
582
|
+
],
|
|
583
|
+
isError: true,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// 启动服务器
|
|
589
|
+
async function main() {
|
|
590
|
+
const transport = new StdioServerTransport();
|
|
591
|
+
await server.connect(transport);
|
|
592
|
+
console.error('[openclaw-mem-mcp] MCP Server started (stdio)');
|
|
593
|
+
|
|
594
|
+
// Preload embedding model in background so first search doesn't timeout
|
|
595
|
+
callGatewayEmbeddings('warmup').then(() => {
|
|
596
|
+
console.error('[openclaw-mem-mcp] Embedding model preloaded');
|
|
597
|
+
}).catch(() => {
|
|
598
|
+
console.error('[openclaw-mem-mcp] Embedding model preload failed (will retry on first search)');
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
main().catch((error) => {
|
|
603
|
+
console.error('[openclaw-mem-mcp] Fatal error:', error);
|
|
604
|
+
process.exit(1);
|
|
605
|
+
});
|
package/mem-get.sh
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# mem-get.sh - 获取记忆详情
|
|
3
|
+
# 用法: mem-get.sh ID1 [ID2 ID3 ...]
|
|
4
|
+
|
|
5
|
+
if [ -z "$1" ]; then
|
|
6
|
+
echo "用法: mem-get.sh ID1 [ID2 ID3 ...]"
|
|
7
|
+
exit 1
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
PORT=18790
|
|
11
|
+
|
|
12
|
+
# 构建 JSON 数组
|
|
13
|
+
IDS=""
|
|
14
|
+
for id in "$@"; do
|
|
15
|
+
if [ -n "$IDS" ]; then
|
|
16
|
+
IDS="$IDS,$id"
|
|
17
|
+
else
|
|
18
|
+
IDS="$id"
|
|
19
|
+
fi
|
|
20
|
+
done
|
|
21
|
+
|
|
22
|
+
curl -s -X POST "http://127.0.0.1:$PORT/get_observations" \
|
|
23
|
+
-H "Content-Type: application/json" \
|
|
24
|
+
-d "{\"ids\":[$IDS]}"
|
package/mem-search.sh
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# mem-search.sh - 记忆搜索包装脚本(自动处理中文 URL 编码)
|
|
3
|
+
# 用法: mem-search.sh "搜索词" [limit]
|
|
4
|
+
|
|
5
|
+
QUERY="$1"
|
|
6
|
+
LIMIT="${2:-10}"
|
|
7
|
+
PORT=18790
|
|
8
|
+
|
|
9
|
+
if [ -z "$QUERY" ]; then
|
|
10
|
+
echo "用法: mem-search.sh \"搜索词\" [limit]"
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
# 使用 POST + JSON 避免 URL 编码问题
|
|
15
|
+
curl -s -X POST "http://127.0.0.1:$PORT/search" \
|
|
16
|
+
-H "Content-Type: application/json" \
|
|
17
|
+
-d "{\"query\":\"$QUERY\",\"limit\":$LIMIT}"
|