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/HOOK.md +125 -0
- package/LICENSE +1 -1
- package/MCP.json +11 -0
- package/README.md +140 -129
- package/context-builder.js +703 -0
- package/database.js +520 -0
- package/debug-logger.js +280 -0
- package/extractor.js +211 -0
- package/gateway-llm.js +155 -0
- package/handler.js +1122 -0
- package/mcp-http-api.js +356 -0
- package/mcp-server.js +525 -0
- package/mem-get.sh +24 -0
- package/mem-search.sh +17 -0
- package/monitor.js +112 -0
- package/package.json +53 -29
- 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 -184
- package/bin/openclaw-mem.js +0 -117
- 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
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw-Mem Context Builder
|
|
3
|
+
* Generates context to inject into new sessions using progressive disclosure
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import database from './database.js';
|
|
8
|
+
import { batchExtractConcepts } from './extractor.js';
|
|
9
|
+
|
|
10
|
+
// Token estimation (4 chars ≈ 1 token)
|
|
11
|
+
const CHARS_PER_TOKEN = 4;
|
|
12
|
+
|
|
13
|
+
function estimateTokens(text) {
|
|
14
|
+
if (!text) return 0;
|
|
15
|
+
return Math.ceil(String(text).length / CHARS_PER_TOKEN);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Type label mapping (Claude-Mem style)
|
|
19
|
+
const LEGEND_ORDER = [
|
|
20
|
+
'session-request',
|
|
21
|
+
'gotcha',
|
|
22
|
+
'problem-solution',
|
|
23
|
+
'decision',
|
|
24
|
+
'bugfix',
|
|
25
|
+
'feature',
|
|
26
|
+
'refactor',
|
|
27
|
+
'discovery'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const TYPE_DISPLAY_MAP = {
|
|
31
|
+
decision: 'decision',
|
|
32
|
+
bugfix: 'bugfix',
|
|
33
|
+
feature: 'feature',
|
|
34
|
+
refactor: 'refactor',
|
|
35
|
+
discovery: 'discovery',
|
|
36
|
+
testing: 'problem-solution',
|
|
37
|
+
setup: 'session-request',
|
|
38
|
+
modification: 'refactor',
|
|
39
|
+
command: 'problem-solution',
|
|
40
|
+
commit: 'decision',
|
|
41
|
+
research: 'discovery',
|
|
42
|
+
delegation: 'session-request',
|
|
43
|
+
other: 'discovery',
|
|
44
|
+
user_input: 'session-request',
|
|
45
|
+
userprompt: 'session-request',
|
|
46
|
+
userpromptsubmit: 'session-request',
|
|
47
|
+
usermessage: 'session-request',
|
|
48
|
+
assistantmessage: 'discovery'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const TOOL_NAME_TYPE_MAP = {
|
|
52
|
+
UserPrompt: 'session-request',
|
|
53
|
+
UserMessage: 'session-request',
|
|
54
|
+
UserPromptSubmit: 'session-request',
|
|
55
|
+
AssistantMessage: 'discovery',
|
|
56
|
+
Read: 'discovery',
|
|
57
|
+
Grep: 'discovery',
|
|
58
|
+
Glob: 'discovery',
|
|
59
|
+
WebFetch: 'discovery',
|
|
60
|
+
WebSearch: 'discovery',
|
|
61
|
+
Edit: 'refactor',
|
|
62
|
+
Write: 'refactor',
|
|
63
|
+
NotebookEdit: 'refactor',
|
|
64
|
+
Bash: 'problem-solution'
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function getTypeLabel(observation) {
|
|
68
|
+
const rawType = observation?.type ? String(observation.type).toLowerCase() : '';
|
|
69
|
+
if (rawType) {
|
|
70
|
+
return TYPE_DISPLAY_MAP[rawType] || rawType;
|
|
71
|
+
}
|
|
72
|
+
const toolName = observation?.tool_name || observation?.toolName || '';
|
|
73
|
+
if (toolName && TOOL_NAME_TYPE_MAP[toolName]) {
|
|
74
|
+
return TOOL_NAME_TYPE_MAP[toolName];
|
|
75
|
+
}
|
|
76
|
+
return 'discovery';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Format timestamp
|
|
80
|
+
function formatTime(timestamp) {
|
|
81
|
+
if (!timestamp) return '';
|
|
82
|
+
const date = new Date(timestamp);
|
|
83
|
+
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatDate(timestamp) {
|
|
87
|
+
if (!timestamp) return '';
|
|
88
|
+
const date = new Date(timestamp);
|
|
89
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
90
|
+
return date.toISOString().split('T')[0];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatDateHeading(dateOrKey) {
|
|
94
|
+
if (!dateOrKey) return '';
|
|
95
|
+
let date;
|
|
96
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(dateOrKey)) {
|
|
97
|
+
date = new Date(`${dateOrKey}T00:00:00`);
|
|
98
|
+
} else {
|
|
99
|
+
date = new Date(dateOrKey);
|
|
100
|
+
}
|
|
101
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
102
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getProjectName(projectPath) {
|
|
106
|
+
if (!projectPath || typeof projectPath !== 'string') return '';
|
|
107
|
+
return path.basename(projectPath) || projectPath;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeText(text) {
|
|
111
|
+
if (!text) return '';
|
|
112
|
+
return String(text).replace(/\s+/g, ' ').trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stripMarkup(text) {
|
|
116
|
+
if (!text) return '';
|
|
117
|
+
return String(text).replace(/<[^>]+>/g, '');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function truncateText(text, max = 80) {
|
|
121
|
+
if (!text) return '';
|
|
122
|
+
const clean = normalizeText(text);
|
|
123
|
+
if (clean.length <= max) return clean;
|
|
124
|
+
return clean.slice(0, Math.max(0, max - 3)) + '...';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function safeJsonParse(value, fallback) {
|
|
128
|
+
if (value === null || value === undefined) return fallback;
|
|
129
|
+
if (typeof value !== 'string') return value;
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(value);
|
|
132
|
+
} catch {
|
|
133
|
+
return fallback;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeArray(value) {
|
|
138
|
+
if (!value) return [];
|
|
139
|
+
const parsed = safeJsonParse(value, []);
|
|
140
|
+
if (Array.isArray(parsed)) return parsed;
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeObservation(observation) {
|
|
145
|
+
if (!observation) return observation;
|
|
146
|
+
return {
|
|
147
|
+
...observation,
|
|
148
|
+
tool_input: safeJsonParse(observation.tool_input, {}),
|
|
149
|
+
tool_response: safeJsonParse(observation.tool_response, {}),
|
|
150
|
+
facts: normalizeArray(observation.facts),
|
|
151
|
+
files_read: normalizeArray(observation.files_read),
|
|
152
|
+
files_modified: normalizeArray(observation.files_modified)
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Filter out low-value observations (like recall test queries)
|
|
157
|
+
function filterHighValueObservations(observations) {
|
|
158
|
+
const lowValuePatterns = [
|
|
159
|
+
'请查看 SESSION-MEMORY',
|
|
160
|
+
'SESSION-MEMORY.md 里没有',
|
|
161
|
+
'请查看 SESSION-MEMORY.md,告诉我',
|
|
162
|
+
'记忆检索当前不可用',
|
|
163
|
+
'/memory search',
|
|
164
|
+
'/memory get',
|
|
165
|
+
'之前没有记录到',
|
|
166
|
+
'没有任何关于'
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
return observations.filter(obs => {
|
|
170
|
+
const summary = obs.summary || '';
|
|
171
|
+
// Always filter out observations matching low-value patterns
|
|
172
|
+
const isLowValue = lowValuePatterns.some(pattern => summary.includes(pattern));
|
|
173
|
+
if (isLowValue) return false;
|
|
174
|
+
|
|
175
|
+
// Keep observations that have actual content (not just metadata)
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Group observations by date
|
|
181
|
+
function groupByDate(observations) {
|
|
182
|
+
const groups = new Map();
|
|
183
|
+
for (const obs of observations) {
|
|
184
|
+
const dateKey = formatDate(obs.timestamp) || 'Unknown';
|
|
185
|
+
if (!groups.has(dateKey)) {
|
|
186
|
+
groups.set(dateKey, []);
|
|
187
|
+
}
|
|
188
|
+
groups.get(dateKey).push(obs);
|
|
189
|
+
}
|
|
190
|
+
return groups;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Build index table (Layer 1 - compact)
|
|
194
|
+
function buildIndexTable(observations, projectName = '') {
|
|
195
|
+
if (!observations || observations.length === 0) {
|
|
196
|
+
return '*(No recent observations)*';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const grouped = groupByDate(observations);
|
|
200
|
+
const lines = [];
|
|
201
|
+
|
|
202
|
+
for (const [dateKey, obs] of grouped.entries()) {
|
|
203
|
+
const heading = formatDateHeading(dateKey) || dateKey;
|
|
204
|
+
lines.push(`### ${heading}`);
|
|
205
|
+
if (projectName) {
|
|
206
|
+
lines.push(`**Project: ${projectName}**`);
|
|
207
|
+
}
|
|
208
|
+
lines.push('');
|
|
209
|
+
lines.push('| ID | Time | T | Title | Tokens |');
|
|
210
|
+
lines.push('|----|------|---|-------|--------|');
|
|
211
|
+
|
|
212
|
+
for (const o of obs) {
|
|
213
|
+
const id = `#${o.id}`;
|
|
214
|
+
const time = formatTime(o.timestamp);
|
|
215
|
+
const typeLabel = getTypeLabel(o);
|
|
216
|
+
const title = o.narrative || o.summary || `${o.tool_name} operation`;
|
|
217
|
+
const truncTitle = truncateText(title, 72);
|
|
218
|
+
const tokens = `~${o.tokens_read || estimateTokens(title)}`;
|
|
219
|
+
|
|
220
|
+
lines.push(`| ${id} | ${time} | ${typeLabel} | ${truncTitle} | ${tokens} |`);
|
|
221
|
+
}
|
|
222
|
+
lines.push('');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return lines.join('\n');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Build full details (Layer 3 - expensive)
|
|
229
|
+
function buildFullDetails(observations, limit = 5) {
|
|
230
|
+
if (!observations || observations.length === 0) {
|
|
231
|
+
return '';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const toShow = observations.slice(0, limit);
|
|
235
|
+
const lines = [];
|
|
236
|
+
|
|
237
|
+
for (const raw of toShow) {
|
|
238
|
+
const o = normalizeObservation(raw);
|
|
239
|
+
const title = normalizeText(o.narrative || o.summary || `${o.tool_name} operation`);
|
|
240
|
+
const typeLabel = getTypeLabel(o);
|
|
241
|
+
const dateLabel = formatDateHeading(o.timestamp);
|
|
242
|
+
const timeLabel = formatTime(o.timestamp);
|
|
243
|
+
|
|
244
|
+
lines.push(`#### #${o.id} - ${truncateText(title, 120)}`);
|
|
245
|
+
lines.push('');
|
|
246
|
+
|
|
247
|
+
if (typeLabel) {
|
|
248
|
+
lines.push(`**Type**: ${typeLabel}`);
|
|
249
|
+
}
|
|
250
|
+
if (dateLabel || timeLabel) {
|
|
251
|
+
const when = [dateLabel, timeLabel].filter(Boolean).join(' ');
|
|
252
|
+
lines.push(`**Time**: ${when}`);
|
|
253
|
+
}
|
|
254
|
+
if (o.tool_name) {
|
|
255
|
+
lines.push(`**Tool**: ${o.tool_name}`);
|
|
256
|
+
}
|
|
257
|
+
lines.push('');
|
|
258
|
+
|
|
259
|
+
if (o.summary) {
|
|
260
|
+
lines.push(`**Summary**: ${normalizeText(o.summary)}`);
|
|
261
|
+
lines.push('');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (o.narrative && o.narrative !== o.summary) {
|
|
265
|
+
lines.push(`**Narrative**: ${normalizeText(o.narrative)}`);
|
|
266
|
+
lines.push('');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const observationFacts = Array.isArray(o.facts) ? o.facts.filter(Boolean) : [];
|
|
270
|
+
if (observationFacts.length > 0) {
|
|
271
|
+
lines.push('**Facts**:');
|
|
272
|
+
for (const fact of observationFacts.slice(0, 6)) {
|
|
273
|
+
lines.push(`- ${normalizeText(fact)}`);
|
|
274
|
+
}
|
|
275
|
+
lines.push('');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (Array.isArray(o.files_read) && o.files_read.length > 0) {
|
|
279
|
+
const files = o.files_read.map(f => `\`${f}\``).join(', ');
|
|
280
|
+
lines.push(`**Files Read**: ${files}`);
|
|
281
|
+
lines.push('');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (Array.isArray(o.files_modified) && o.files_modified.length > 0) {
|
|
285
|
+
const files = o.files_modified.map(f => `\`${f}\``).join(', ');
|
|
286
|
+
lines.push(`**Files Modified**: ${files}`);
|
|
287
|
+
lines.push('');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Show key facts from tool input
|
|
291
|
+
const input = o.tool_input || {};
|
|
292
|
+
const inputFacts = [];
|
|
293
|
+
|
|
294
|
+
if (input.file_path) inputFacts.push(`- File: \`${input.file_path}\``);
|
|
295
|
+
if (input.command) inputFacts.push(`- Command: \`${input.command.slice(0, 100)}\``);
|
|
296
|
+
if (input.pattern) inputFacts.push(`- Pattern: \`${input.pattern}\``);
|
|
297
|
+
if (input.query) inputFacts.push(`- Query: ${input.query.slice(0, 100)}`);
|
|
298
|
+
if (input.url) inputFacts.push(`- URL: ${input.url}`);
|
|
299
|
+
|
|
300
|
+
if (inputFacts.length > 0) {
|
|
301
|
+
lines.push('**Details**:');
|
|
302
|
+
lines.push(...inputFacts);
|
|
303
|
+
lines.push('');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
lines.push('---');
|
|
307
|
+
lines.push('');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return lines.join('\n');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Build token economics summary
|
|
314
|
+
function buildTokenEconomics(observations) {
|
|
315
|
+
let totalDiscovery = 0;
|
|
316
|
+
let totalRead = 0;
|
|
317
|
+
|
|
318
|
+
for (const o of observations) {
|
|
319
|
+
totalDiscovery += o.tokens_discovery || 0;
|
|
320
|
+
totalRead += o.tokens_read || estimateTokens(o.summary || '');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const savings = totalDiscovery - totalRead;
|
|
324
|
+
const savingsPercent = totalDiscovery > 0 ? Math.round((savings / totalDiscovery) * 100) : 0;
|
|
325
|
+
|
|
326
|
+
if (totalDiscovery === 0) {
|
|
327
|
+
return `**Observations**: ${observations.length} | **Read cost**: ~${totalRead} tokens`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return `**Token ROI**: Discovery ~${totalDiscovery} | Read ~${totalRead} | Saved ~${savings} (${savingsPercent}%)`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function buildLegendLine(observations) {
|
|
334
|
+
const typesSeen = new Set();
|
|
335
|
+
for (const obs of observations) {
|
|
336
|
+
typesSeen.add(getTypeLabel(obs));
|
|
337
|
+
}
|
|
338
|
+
const extras = [...typesSeen].filter(t => !LEGEND_ORDER.includes(t)).sort();
|
|
339
|
+
const legend = [...LEGEND_ORDER, ...extras];
|
|
340
|
+
return `**Legend:** ${legend.join(' | ')}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Build retrieval instructions
|
|
344
|
+
function buildRetrievalInstructions() {
|
|
345
|
+
return `
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
**MCP 3-Layer Retrieval (progressive disclosure)**:
|
|
349
|
+
1. \`search({ query, limit })\` → index only
|
|
350
|
+
2. \`timeline({ anchor, depth_before, depth_after })\` → local context
|
|
351
|
+
3. \`get_observations({ ids })\` → full details (only after filtering)
|
|
352
|
+
|
|
353
|
+
**Chat aliases**:
|
|
354
|
+
- \`/memory search <query>\`
|
|
355
|
+
- \`/memory timeline <id>\`
|
|
356
|
+
- \`/memory get <id>\`
|
|
357
|
+
`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Build topic summaries from user's actual recorded concepts
|
|
362
|
+
* Uses LLM to extract meaningful keywords from full message content
|
|
363
|
+
*/
|
|
364
|
+
async function buildTopicSummaries() {
|
|
365
|
+
// Get all recent observations
|
|
366
|
+
const recentObs = database.getRecentObservations(null, 50);
|
|
367
|
+
|
|
368
|
+
if (recentObs.length === 0) return '';
|
|
369
|
+
|
|
370
|
+
// Collect unique message contents for LLM extraction
|
|
371
|
+
const textsToExtract = [];
|
|
372
|
+
const textToObsMap = new Map();
|
|
373
|
+
|
|
374
|
+
for (const obs of recentObs) {
|
|
375
|
+
if (obs.concepts && obs.concepts.length > 20) {
|
|
376
|
+
const text = obs.concepts.slice(0, 500);
|
|
377
|
+
if (!textToObsMap.has(text)) {
|
|
378
|
+
textsToExtract.push(text);
|
|
379
|
+
textToObsMap.set(text, [obs]);
|
|
380
|
+
} else {
|
|
381
|
+
textToObsMap.get(text).push(obs);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Limit to 10 unique texts for API efficiency
|
|
387
|
+
const limitedTexts = textsToExtract.slice(0, 10);
|
|
388
|
+
|
|
389
|
+
// Use LLM to extract concepts from messages
|
|
390
|
+
let conceptsMap;
|
|
391
|
+
try {
|
|
392
|
+
conceptsMap = await batchExtractConcepts(limitedTexts);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
console.error('[openclaw-mem] LLM extraction failed:', err.message);
|
|
395
|
+
return '';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Count keyword frequency
|
|
399
|
+
const keywordCounts = {};
|
|
400
|
+
const keywordToObs = {};
|
|
401
|
+
|
|
402
|
+
for (const [text, concepts] of conceptsMap.entries()) {
|
|
403
|
+
const observations = textToObsMap.get(text) || [];
|
|
404
|
+
for (const concept of concepts) {
|
|
405
|
+
keywordCounts[concept] = (keywordCounts[concept] || 0) + observations.length;
|
|
406
|
+
if (!keywordToObs[concept]) {
|
|
407
|
+
keywordToObs[concept] = [];
|
|
408
|
+
}
|
|
409
|
+
keywordToObs[concept].push(...observations);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Get top keywords (mentioned at least twice)
|
|
414
|
+
const topKeywords = Object.entries(keywordCounts)
|
|
415
|
+
.filter(([_, count]) => count >= 2)
|
|
416
|
+
.sort((a, b) => b[1] - a[1])
|
|
417
|
+
.slice(0, 8)
|
|
418
|
+
.map(([keyword, _]) => keyword);
|
|
419
|
+
|
|
420
|
+
if (topKeywords.length === 0) return '';
|
|
421
|
+
|
|
422
|
+
const sections = [];
|
|
423
|
+
const seenIds = new Set();
|
|
424
|
+
|
|
425
|
+
// Build sections for each top keyword
|
|
426
|
+
for (const keyword of topKeywords.slice(0, 5)) {
|
|
427
|
+
// Get observations associated with this keyword
|
|
428
|
+
const relatedObs = keywordToObs[keyword] || [];
|
|
429
|
+
const newObs = relatedObs.filter(o => !seenIds.has(o.id));
|
|
430
|
+
|
|
431
|
+
if (newObs.length > 0) {
|
|
432
|
+
sections.push(`### ${keyword}`);
|
|
433
|
+
sections.push('');
|
|
434
|
+
for (const obs of newObs.slice(0, 2)) {
|
|
435
|
+
seenIds.add(obs.id);
|
|
436
|
+
const summary = obs.summary || '';
|
|
437
|
+
if (summary.length > 10) {
|
|
438
|
+
sections.push(`- **#${obs.id}**: ${summary.slice(0, 150)}${summary.length > 150 ? '...' : ''}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
sections.push('');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (sections.length > 0) {
|
|
446
|
+
return '## Historical Topics\n\nKey concepts extracted from your conversations:\n\n' + sections.join('\n');
|
|
447
|
+
}
|
|
448
|
+
return '';
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Build complete context for session injection
|
|
453
|
+
* @param {string} projectPath - Project path for filtering
|
|
454
|
+
* @param {object} options - Configuration options
|
|
455
|
+
* @returns {Promise<string|null>} - Generated context or null if empty
|
|
456
|
+
*/
|
|
457
|
+
export async function buildContext(projectPath, options = {}) {
|
|
458
|
+
const {
|
|
459
|
+
observationLimit = 50,
|
|
460
|
+
fullDetailCount = 5,
|
|
461
|
+
showTokenEconomics = true,
|
|
462
|
+
showRetrievalInstructions = true,
|
|
463
|
+
useLLMExtraction = true
|
|
464
|
+
} = options;
|
|
465
|
+
|
|
466
|
+
// Fetch recent observations and filter out low-value ones
|
|
467
|
+
const rawObservations = database.getRecentObservations(projectPath, observationLimit * 3); // Fetch more to compensate for filtering
|
|
468
|
+
const observations = filterHighValueObservations(rawObservations).slice(0, observationLimit);
|
|
469
|
+
|
|
470
|
+
if (observations.length === 0) {
|
|
471
|
+
return null; // No context to inject
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Fetch recent summaries
|
|
475
|
+
const summaries = database.getRecentSummaries(projectPath, 3);
|
|
476
|
+
const projectName = getProjectName(projectPath || observations[0]?.project_path);
|
|
477
|
+
|
|
478
|
+
// Build context parts
|
|
479
|
+
const parts = [];
|
|
480
|
+
|
|
481
|
+
// Header
|
|
482
|
+
parts.push('<openclaw-mem-context>');
|
|
483
|
+
parts.push('# [openclaw-mem] recent context');
|
|
484
|
+
parts.push('');
|
|
485
|
+
parts.push(buildLegendLine(observations));
|
|
486
|
+
parts.push('');
|
|
487
|
+
|
|
488
|
+
// Token economics
|
|
489
|
+
if (showTokenEconomics) {
|
|
490
|
+
parts.push(buildTokenEconomics(observations));
|
|
491
|
+
parts.push('');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Index table (all observations, compact)
|
|
495
|
+
parts.push(buildIndexTable(observations, projectName));
|
|
496
|
+
parts.push('');
|
|
497
|
+
|
|
498
|
+
// Topic summaries (from LLM extraction)
|
|
499
|
+
if (useLLMExtraction) {
|
|
500
|
+
try {
|
|
501
|
+
const topicSummaries = await buildTopicSummaries();
|
|
502
|
+
if (topicSummaries) {
|
|
503
|
+
parts.push(topicSummaries);
|
|
504
|
+
parts.push('');
|
|
505
|
+
}
|
|
506
|
+
} catch (err) {
|
|
507
|
+
console.error('[openclaw-mem] Topic extraction failed:', err.message);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Full details (top N)
|
|
512
|
+
if (fullDetailCount > 0) {
|
|
513
|
+
const details = buildFullDetails(observations, fullDetailCount);
|
|
514
|
+
if (details) {
|
|
515
|
+
parts.push('## Recent Details');
|
|
516
|
+
parts.push('');
|
|
517
|
+
parts.push(details);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Session summaries
|
|
522
|
+
if (summaries.length > 0) {
|
|
523
|
+
parts.push('## Latest Session Summary');
|
|
524
|
+
parts.push('');
|
|
525
|
+
const s = summaries[0];
|
|
526
|
+
if (s.request) parts.push(`- **Goal**: ${normalizeText(s.request)}`);
|
|
527
|
+
if (s.learned) parts.push(`- **Learned**: ${normalizeText(s.learned)}`);
|
|
528
|
+
if (s.completed) parts.push(`- **Completed**: ${normalizeText(s.completed)}`);
|
|
529
|
+
if (s.next_steps) parts.push(`- **Next**: ${normalizeText(s.next_steps)}`);
|
|
530
|
+
parts.push('');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Retrieval instructions
|
|
534
|
+
if (showRetrievalInstructions) {
|
|
535
|
+
parts.push(buildRetrievalInstructions());
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
parts.push('</openclaw-mem-context>');
|
|
539
|
+
|
|
540
|
+
return parts.join('\n');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Search observations and return formatted results
|
|
545
|
+
*/
|
|
546
|
+
export function searchContext(query, limit = 20) {
|
|
547
|
+
const results = database.searchObservations(query, limit);
|
|
548
|
+
|
|
549
|
+
if (results.length === 0) {
|
|
550
|
+
return `No observations found for query: "${query}"`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const lines = [
|
|
554
|
+
`## Search Results for "${query}"`,
|
|
555
|
+
'',
|
|
556
|
+
'| ID | Time | T | Title | Tokens |',
|
|
557
|
+
'|----|------|---|-------|--------|'
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
for (const r of results) {
|
|
561
|
+
const summary = r.summary_highlight || r.summary || `${r.tool_name} operation`;
|
|
562
|
+
const cleanSummary = stripMarkup(summary);
|
|
563
|
+
const title = truncateText(cleanSummary, 72);
|
|
564
|
+
const time = formatTime(r.timestamp);
|
|
565
|
+
const typeLabel = getTypeLabel(r);
|
|
566
|
+
const tokens = `~${r.tokens_read || estimateTokens(cleanSummary)}`;
|
|
567
|
+
lines.push(`| #${r.id} | ${time} | ${typeLabel} | ${title} | ${tokens} |`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
lines.push('');
|
|
571
|
+
lines.push(`*${results.length} results. Use \`timeline\` or \`get_observations\` for full details.*`);
|
|
572
|
+
|
|
573
|
+
return lines.join('\n');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Get full observation details by IDs
|
|
578
|
+
*/
|
|
579
|
+
export function getObservationDetails(ids) {
|
|
580
|
+
const observations = database.getObservations(ids);
|
|
581
|
+
|
|
582
|
+
if (observations.length === 0) {
|
|
583
|
+
return `No observations found for IDs: ${ids.join(', ')}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return buildFullDetails(observations, observations.length);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Get timeline around an observation
|
|
591
|
+
*/
|
|
592
|
+
export function getTimeline(anchorId, depthBefore = 3, depthAfter = 2) {
|
|
593
|
+
const anchor = database.getObservation(anchorId);
|
|
594
|
+
if (!anchor) {
|
|
595
|
+
return `Observation #${anchorId} not found`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Get surrounding observations from same session
|
|
599
|
+
const allObs = database.getRecentObservations(null, 100);
|
|
600
|
+
const anchorIdx = allObs.findIndex(o => o.id === anchorId);
|
|
601
|
+
|
|
602
|
+
if (anchorIdx === -1) {
|
|
603
|
+
return buildFullDetails([anchor], 1);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const startIdx = Math.max(0, anchorIdx - depthAfter); // Note: list is DESC, so after = before in time
|
|
607
|
+
const endIdx = Math.min(allObs.length, anchorIdx + depthBefore + 1);
|
|
608
|
+
const timeline = allObs.slice(startIdx, endIdx).reverse();
|
|
609
|
+
|
|
610
|
+
const lines = [
|
|
611
|
+
`## Timeline around #${anchorId}`,
|
|
612
|
+
''
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
for (const o of timeline) {
|
|
616
|
+
const marker = o.id === anchorId ? '→' : ' ';
|
|
617
|
+
const time = formatTime(o.timestamp);
|
|
618
|
+
const typeLabel = getTypeLabel(o);
|
|
619
|
+
const title = truncateText(o.narrative || o.summary || `${o.tool_name} operation`, 90);
|
|
620
|
+
lines.push(`${marker} ${time} ${typeLabel} #${o.id}: ${title}`);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return lines.join('\n');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function normalizeIds(input) {
|
|
627
|
+
const ids = [];
|
|
628
|
+
const pushId = (value) => {
|
|
629
|
+
if (value === null || value === undefined) return;
|
|
630
|
+
const cleaned = String(value).replace(/^#/, '').trim();
|
|
631
|
+
if (!cleaned) return;
|
|
632
|
+
const parsed = Number(cleaned);
|
|
633
|
+
if (!Number.isNaN(parsed)) ids.push(parsed);
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
if (Array.isArray(input)) {
|
|
637
|
+
input.forEach(pushId);
|
|
638
|
+
return ids;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (typeof input === 'string') {
|
|
642
|
+
input.split(/[,\s]+/).forEach(pushId);
|
|
643
|
+
return ids;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
pushId(input);
|
|
647
|
+
return ids;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* MCP-style unified interfaces
|
|
652
|
+
*/
|
|
653
|
+
export function search(args = {}) {
|
|
654
|
+
if (typeof args === 'string') {
|
|
655
|
+
return searchContext(args);
|
|
656
|
+
}
|
|
657
|
+
const query = args.query || args.q;
|
|
658
|
+
const limit = args.limit ?? args.maxResults ?? 20;
|
|
659
|
+
if (!query) return 'No query provided.';
|
|
660
|
+
return searchContext(query, limit);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export function timeline(args = {}) {
|
|
664
|
+
if (typeof args === 'number' || typeof args === 'string') {
|
|
665
|
+
const anchorId = Number(String(args).replace(/^#/, ''));
|
|
666
|
+
if (Number.isNaN(anchorId)) return 'No anchor ID provided.';
|
|
667
|
+
return getTimeline(anchorId);
|
|
668
|
+
}
|
|
669
|
+
const anchor = args.anchor ?? args.id ?? args.observation_id ?? args.observationId;
|
|
670
|
+
const depthBefore = Number(args.depth_before ?? args.before ?? 3);
|
|
671
|
+
const depthAfter = Number(args.depth_after ?? args.after ?? 2);
|
|
672
|
+
const anchorId = Number(String(anchor ?? '').replace(/^#/, ''));
|
|
673
|
+
if (Number.isNaN(anchorId)) return 'No anchor ID provided.';
|
|
674
|
+
return getTimeline(anchorId, depthBefore, depthAfter);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function get_observations(args = {}) {
|
|
678
|
+
const ids = Array.isArray(args)
|
|
679
|
+
? normalizeIds(args)
|
|
680
|
+
: normalizeIds(args.ids ?? args.id ?? args.observation_ids ?? args.observationIds);
|
|
681
|
+
if (!ids.length) return 'No observation IDs provided.';
|
|
682
|
+
return getObservationDetails(ids);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function __IMPORTANT() {
|
|
686
|
+
return [
|
|
687
|
+
'Use the 3-layer workflow for memory retrieval:',
|
|
688
|
+
'1) search → index only',
|
|
689
|
+
'2) timeline → local context',
|
|
690
|
+
'3) get_observations → full details after filtering'
|
|
691
|
+
].join('\n');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export default {
|
|
695
|
+
buildContext,
|
|
696
|
+
searchContext,
|
|
697
|
+
getObservationDetails,
|
|
698
|
+
getTimeline,
|
|
699
|
+
search,
|
|
700
|
+
timeline,
|
|
701
|
+
get_observations,
|
|
702
|
+
__IMPORTANT
|
|
703
|
+
};
|