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,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
+ };