openclaw-memory-mapper 1.0.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.
@@ -0,0 +1,541 @@
1
+ import express, { Request, Response } from 'express';
2
+ import { readdir, readFile, stat } from 'fs/promises';
3
+ import { existsSync } from 'fs';
4
+ import path from 'path';
5
+ import { homedir } from 'os';
6
+ import { fileURLToPath } from 'url';
7
+ import { parse as csvParse } from 'csv-parse/sync';
8
+
9
+ const { join, relative, extname, basename, dirname } = path;
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
14
+ const app = express();
15
+ const PORT = process.env.PORT || 3001;
16
+
17
+ // Parse JSON bodies
18
+ app.use(express.json());
19
+
20
+ // Serve static files from the built frontend in production
21
+ // When compiled: server is at dist/server/index.js, frontend at dist/
22
+ // When running from source: server is at server/index.ts, frontend at dist/
23
+ const isCompiledServer = __dirname.includes('dist/server') || __dirname.includes('dist\\server');
24
+ const distPath = isCompiledServer
25
+ ? join(__dirname, '..') // dist/server -> dist/
26
+ : join(__dirname, '..', 'dist'); // server/ -> dist/
27
+
28
+ if (existsSync(join(distPath, 'index.html'))) {
29
+ app.use(express.static(distPath));
30
+ }
31
+
32
+ // Default memory paths (can be overridden via /api/config/sources)
33
+ const OPENCLAW_PATH = join(homedir(), '.openclaw');
34
+
35
+ // Configured source paths from client
36
+ interface ConfiguredSource {
37
+ id: string;
38
+ path: string;
39
+ }
40
+ let configuredSources: ConfiguredSource[] = [];
41
+
42
+ // Helper to expand ~ and validate path is within home directory
43
+ function expandAndValidatePath(pathParam: string): { valid: boolean; expanded: string; error?: string } {
44
+ const expanded = pathParam.startsWith('~')
45
+ ? join(homedir(), pathParam.slice(1))
46
+ : pathParam;
47
+
48
+ const resolved = path.resolve(expanded);
49
+ const home = homedir();
50
+
51
+ if (!resolved.startsWith(home + path.sep) && resolved !== home) {
52
+ return { valid: false, expanded, error: 'Path must be within home directory' };
53
+ }
54
+
55
+ return { valid: true, expanded };
56
+ }
57
+
58
+ // Get base path for a source (uses configured sources if available, falls back to defaults)
59
+ function getBasePath(sourceId: string): string | null {
60
+ const configured = configuredSources.find(s => s.id === sourceId);
61
+ if (configured) {
62
+ const { valid, expanded } = expandAndValidatePath(configured.path);
63
+ if (valid) return expanded;
64
+ }
65
+
66
+ // Fallback to default
67
+ if (sourceId === 'openclaw') return OPENCLAW_PATH;
68
+ return null;
69
+ }
70
+
71
+ // Security: sanitize error messages to avoid leaking sensitive info in production
72
+ const IS_PRODUCTION = process.env.NODE_ENV === 'production';
73
+ function sanitizeError(error: unknown): string | undefined {
74
+ if (!IS_PRODUCTION) {
75
+ return String(error);
76
+ }
77
+ // In production, only return generic message - log full error server-side
78
+ console.error('Server error:', error);
79
+ return undefined;
80
+ }
81
+
82
+ interface FileNode {
83
+ name: string;
84
+ path: string;
85
+ type: 'file' | 'directory';
86
+ size?: number;
87
+ modified?: string;
88
+ children?: FileNode[];
89
+ }
90
+
91
+ interface LogEntry {
92
+ timestamp: string;
93
+ session_id: string;
94
+ turn_number: string;
95
+ agent_name: string;
96
+ action_type: string;
97
+ target_file: string;
98
+ content_summary: string;
99
+ tokens_used: string;
100
+ latency_ms: string;
101
+ success: string;
102
+ error_message: string;
103
+ }
104
+
105
+ // Recursively read directory structure
106
+ async function readDirRecursive(dirPath: string, basePath: string): Promise<FileNode[]> {
107
+ const entries = await readdir(dirPath, { withFileTypes: true });
108
+ const nodes: FileNode[] = [];
109
+
110
+ for (const entry of entries) {
111
+ const fullPath = join(dirPath, entry.name);
112
+ const relativePath = relative(basePath, fullPath);
113
+
114
+ if (entry.isDirectory()) {
115
+ // Skip hidden directories and node_modules
116
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
117
+
118
+ const children = await readDirRecursive(fullPath, basePath);
119
+ nodes.push({
120
+ name: entry.name,
121
+ path: relativePath,
122
+ type: 'directory',
123
+ children,
124
+ });
125
+ } else {
126
+ const stats = await stat(fullPath);
127
+ nodes.push({
128
+ name: entry.name,
129
+ path: relativePath,
130
+ type: 'file',
131
+ size: stats.size,
132
+ modified: stats.mtime.toISOString(),
133
+ });
134
+ }
135
+ }
136
+
137
+ // Sort: directories first, then alphabetically
138
+ return nodes.sort((a, b) => {
139
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
140
+ return a.name.localeCompare(b.name);
141
+ });
142
+ }
143
+
144
+ // Count files in directory tree
145
+ function countFiles(nodes: FileNode[]): number {
146
+ let count = 0;
147
+ for (const node of nodes) {
148
+ if (node.type === 'file') count++;
149
+ if (node.children) count += countFiles(node.children);
150
+ }
151
+ return count;
152
+ }
153
+
154
+ // Get total size of directory tree
155
+ function totalSize(nodes: FileNode[]): number {
156
+ let size = 0;
157
+ for (const node of nodes) {
158
+ if (node.type === 'file' && node.size) size += node.size;
159
+ if (node.children) size += totalSize(node.children);
160
+ }
161
+ return size;
162
+ }
163
+
164
+ // API: Configure source paths
165
+ app.post('/api/config/sources', (req: Request, res: Response) => {
166
+ const { sources } = req.body as { sources: ConfiguredSource[] };
167
+
168
+ if (!Array.isArray(sources)) {
169
+ res.status(400).json({ error: 'sources must be an array' });
170
+ return;
171
+ }
172
+
173
+ // Validate all paths
174
+ const validated: ConfiguredSource[] = [];
175
+ for (const source of sources) {
176
+ if (!source.id || !source.path) continue;
177
+ const { valid, expanded, error } = expandAndValidatePath(source.path);
178
+ if (!valid) {
179
+ res.status(400).json({ error: `Invalid path for ${source.id}: ${error}` });
180
+ return;
181
+ }
182
+ validated.push({ id: source.id, path: expanded });
183
+ }
184
+
185
+ configuredSources = validated;
186
+ res.json({ success: true, sources: validated });
187
+ });
188
+
189
+ // API: List all files in memory directories
190
+ app.get('/api/files', async (_req: Request, res: Response) => {
191
+ try {
192
+ const result: Record<string, { tree: FileNode[]; fileCount: number; totalSize: number; path: string } | null> = {};
193
+
194
+ // Use configured sources, or fall back to default
195
+ const sourcesToScan = configuredSources.length > 0
196
+ ? configuredSources
197
+ : [
198
+ { id: 'openclaw', path: OPENCLAW_PATH },
199
+ ];
200
+
201
+ for (const source of sourcesToScan) {
202
+ try {
203
+ const basePath = getBasePath(source.id) || source.path;
204
+ const tree = await readDirRecursive(basePath, basePath);
205
+ result[source.id] = {
206
+ tree,
207
+ fileCount: countFiles(tree),
208
+ totalSize: totalSize(tree),
209
+ path: basePath,
210
+ };
211
+ } catch {
212
+ // Directory doesn't exist or not accessible
213
+ result[source.id] = null;
214
+ }
215
+ }
216
+
217
+ res.json(result);
218
+ } catch (error) {
219
+ res.status(500).json({ error: 'Failed to read files', details: sanitizeError(error) });
220
+ }
221
+ });
222
+
223
+ // API: Read a specific file
224
+ app.get('/api/file', async (req: Request, res: Response) => {
225
+ try {
226
+ const { path: filePath, source } = req.query;
227
+
228
+ if (!filePath || typeof filePath !== 'string') {
229
+ res.status(400).json({ error: 'Missing path parameter' });
230
+ return;
231
+ }
232
+
233
+ if (!source || typeof source !== 'string') {
234
+ res.status(400).json({ error: 'Missing source parameter' });
235
+ return;
236
+ }
237
+
238
+ // Get base path from configured sources or defaults
239
+ const basePath = getBasePath(source);
240
+ if (!basePath) {
241
+ res.status(400).json({ error: `Unknown source: ${source}` });
242
+ return;
243
+ }
244
+
245
+ const resolvedBase = path.resolve(basePath);
246
+ const fullPath = path.resolve(basePath, filePath);
247
+
248
+ // Security: ensure path is within the source directory
249
+ if (!fullPath.startsWith(resolvedBase + path.sep) && fullPath !== resolvedBase) {
250
+ res.status(403).json({ error: 'Access denied' });
251
+ return;
252
+ }
253
+
254
+ const content = await readFile(fullPath, 'utf-8');
255
+ const stats = await stat(fullPath);
256
+ const ext = extname(filePath).toLowerCase();
257
+
258
+ res.json({
259
+ path: filePath,
260
+ name: basename(filePath),
261
+ content,
262
+ size: stats.size,
263
+ modified: stats.mtime.toISOString(),
264
+ extension: ext,
265
+ });
266
+ } catch (error) {
267
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
268
+ res.status(404).json({ error: 'File not found' });
269
+ } else {
270
+ res.status(500).json({ error: 'Failed to read file', details: sanitizeError(error) });
271
+ }
272
+ }
273
+ });
274
+
275
+ // API: Get openclaw config
276
+ app.get('/api/config', async (_req: Request, res: Response) => {
277
+ try {
278
+ const configPath = join(OPENCLAW_PATH, 'openclaw.json');
279
+ const content = await readFile(configPath, 'utf-8');
280
+ const config = JSON.parse(content);
281
+ res.json({ config, path: configPath });
282
+ } catch (error) {
283
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
284
+ res.status(404).json({ error: 'Config file not found', path: join(OPENCLAW_PATH, 'openclaw.json') });
285
+ } else {
286
+ res.status(500).json({ error: 'Failed to read config', details: sanitizeError(error) });
287
+ }
288
+ }
289
+ });
290
+
291
+ // API: Get agent logs from memory-swarm/logs
292
+ app.get('/api/logs', async (_req: Request, res: Response) => {
293
+ try {
294
+ const logsPath = join(OPENCLAW_PATH, 'memory-swarm', 'logs');
295
+ const files = await readdir(logsPath);
296
+ const csvFiles = files.filter(f => f.endsWith('.csv')).sort().reverse();
297
+
298
+ const logs: { file: string; entries: LogEntry[] }[] = [];
299
+
300
+ // Read up to 5 most recent log files
301
+ for (const file of csvFiles.slice(0, 5)) {
302
+ try {
303
+ const content = await readFile(join(logsPath, file), 'utf-8');
304
+ const records = csvParse(content, {
305
+ columns: true,
306
+ skip_empty_lines: true,
307
+ relax_column_count: true,
308
+ }) as LogEntry[];
309
+ logs.push({ file, entries: records });
310
+ } catch {
311
+ // Skip files that can't be parsed
312
+ }
313
+ }
314
+
315
+ res.json({ logs, path: logsPath });
316
+ } catch (error) {
317
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
318
+ res.status(404).json({ error: 'Logs directory not found', path: join(OPENCLAW_PATH, 'memory-swarm', 'logs') });
319
+ } else {
320
+ res.status(500).json({ error: 'Failed to read logs', details: sanitizeError(error) });
321
+ }
322
+ }
323
+ });
324
+
325
+ // API: Get memory layer stats
326
+ app.get('/api/stats', async (_req: Request, res: Response) => {
327
+ try {
328
+ const stats = {
329
+ layers: {
330
+ session: { path: 'memory/session', files: 0, size: 0, cloud: false },
331
+ daily: { path: 'memory', files: 0, size: 0, cloud: false },
332
+ supermemory: { path: 'supermemory', files: 0, size: 0, cloud: false },
333
+ qmd: { path: 'memory-swarm', files: 0, size: 0, cloud: false },
334
+ config: { path: '.openclaw', files: 0, size: 0, cloud: false },
335
+ },
336
+ connected: {
337
+ openclaw: false,
338
+ },
339
+ };
340
+
341
+ // Check openclaw directories
342
+ try {
343
+ await stat(OPENCLAW_PATH);
344
+ stats.connected.openclaw = true;
345
+
346
+ // Daily logs: count .md files directly in ~/.openclaw/memory/
347
+ try {
348
+ const memoryPath = join(OPENCLAW_PATH, 'memory');
349
+ const entries = await readdir(memoryPath, { withFileTypes: true });
350
+ const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
351
+ let totalMdSize = 0;
352
+ for (const file of mdFiles) {
353
+ const fileStat = await stat(join(memoryPath, file.name));
354
+ totalMdSize += fileStat.size;
355
+ }
356
+ stats.layers.daily.files = mdFiles.length;
357
+ stats.layers.daily.size = totalMdSize;
358
+ } catch {
359
+ // memory directory doesn't exist
360
+ }
361
+
362
+ // QMD Swarm
363
+ try {
364
+ const qmdPath = join(OPENCLAW_PATH, 'memory-swarm');
365
+ const tree = await readDirRecursive(qmdPath, qmdPath);
366
+ stats.layers.qmd.files = countFiles(tree);
367
+ stats.layers.qmd.size = totalSize(tree);
368
+ } catch {
369
+ // memory-swarm doesn't exist
370
+ }
371
+
372
+ // Config files
373
+ const configTree = await readDirRecursive(OPENCLAW_PATH, OPENCLAW_PATH);
374
+ stats.layers.config.files = countFiles(configTree);
375
+ stats.layers.config.size = totalSize(configTree);
376
+ } catch {
377
+ // openclaw not accessible
378
+ }
379
+
380
+ res.json(stats);
381
+ } catch (error) {
382
+ res.status(500).json({ error: 'Failed to get stats', details: sanitizeError(error) });
383
+ }
384
+ });
385
+
386
+ // Health check
387
+ app.get('/api/health', (_req: Request, res: Response) => {
388
+ const openclawExists = existsSync(OPENCLAW_PATH);
389
+
390
+ res.json({
391
+ status: 'ok',
392
+ timestamp: new Date().toISOString(),
393
+ openclaw: openclawExists,
394
+ paths: {
395
+ openclaw: OPENCLAW_PATH,
396
+ },
397
+ });
398
+ });
399
+
400
+ // Check if a path exists
401
+ // Security: only allows checking if directories exist, not reading contents
402
+ // Path must be within user's home directory
403
+ app.get('/api/check-path', (req: Request, res: Response) => {
404
+ const pathParam = req.query.path as string;
405
+ if (!pathParam) {
406
+ res.status(400).json({ error: 'Path parameter required' });
407
+ return;
408
+ }
409
+
410
+ // Expand ~ to home directory
411
+ const expandedPath = pathParam.startsWith('~')
412
+ ? join(homedir(), pathParam.slice(1))
413
+ : pathParam;
414
+
415
+ // Security: only allow checking paths within user's home directory
416
+ const resolvedPath = path.resolve(expandedPath);
417
+ const home = homedir();
418
+
419
+ if (!resolvedPath.startsWith(home + path.sep) && resolvedPath !== home) {
420
+ res.status(403).json({ error: 'Access denied: path must be within home directory' });
421
+ return;
422
+ }
423
+
424
+ const exists = existsSync(expandedPath);
425
+
426
+ res.json({
427
+ path: pathParam,
428
+ expandedPath,
429
+ exists,
430
+ });
431
+ });
432
+
433
+ // Layer documentation configuration
434
+ interface LayerDocConfig {
435
+ title: string;
436
+ summary: string;
437
+ details: string;
438
+ updateFrequency: string;
439
+ components: string[];
440
+ docFiles: string[]; // Files to try reading for additional content
441
+ }
442
+
443
+ const layerDocConfigs: Record<string, LayerDocConfig> = {
444
+ input: {
445
+ title: 'User Input',
446
+ summary: 'Your question or message to the AI, analyzed to determine context needs.',
447
+ details: 'Every conversation starts here. Your input is analyzed to identify what memories and context might be relevant. The system looks for keywords, topics, and patterns that match stored information across all memory layers.',
448
+ updateFrequency: 'Real-time',
449
+ components: ['Query parsing', 'Intent detection', 'Topic extraction', 'Context triggers'],
450
+ docFiles: [],
451
+ },
452
+ session: {
453
+ title: 'Session Context',
454
+ summary: 'Current conversation held in working memory during your session.',
455
+ details: 'Maintains immediate conversation context and working memory. This includes what you\'ve discussed, recent tool usage patterns, active tasks, and context switches. Cleared on restart but persists within a session for continuity.',
456
+ updateFrequency: 'Real-time during session',
457
+ components: ['Current conversation thread', 'Recent tool usage', 'Active task tracking', 'Context switches'],
458
+ docFiles: ['memory/CONTEXT_BUILDER.md'],
459
+ },
460
+ daily: {
461
+ title: 'Daily Logs',
462
+ summary: 'Markdown files capturing insights and summaries from each day.',
463
+ details: 'Written at session end, these logs capture important decisions, preferences learned, and context from each day. They bridge sessions so the AI remembers yesterday\'s discussions and decisions without needing to reload everything.',
464
+ updateFrequency: 'Daily (end of session)',
465
+ components: ['Session summaries', 'Decisions made', 'Preferences learned', 'Important context'],
466
+ docFiles: ['memory/PERSISTENT_MEMORY.md', 'MEMORY.md'],
467
+ },
468
+ supermemory: {
469
+ title: 'SuperMemory',
470
+ summary: 'Cloud-based semantic search over long-term memories.',
471
+ details: 'A cloud service that provides semantic search over your entire memory history. When you ask about past projects, preferences, or decisions from weeks or months ago, SuperMemory finds relevant context using vector embeddings and similarity search.',
472
+ updateFrequency: 'Continuous (cloud sync)',
473
+ components: ['Vector embeddings', 'Semantic search', 'Long-term storage', 'Cross-session recall'],
474
+ docFiles: ['memory/ADVANCED_MEMORY_ARCHITECTURE.md'],
475
+ },
476
+ qmd: {
477
+ title: 'QMD Swarm',
478
+ summary: 'Multi-agent system that orchestrates memory retrieval and synthesis.',
479
+ details: 'The Query-Memory-Dispatch system coordinates retrieval from all memory layers. It runs on every query, combining results from session context, daily logs, and SuperMemory into a coherent context package that informs the response.',
480
+ updateFrequency: 'Every query',
481
+ components: ['Query analysis', 'Parallel retrieval', 'Result ranking', 'Context synthesis'],
482
+ docFiles: ['AGENTS.md', 'memory/ADVANCED_MEMORY_ARCHITECTURE.md'],
483
+ },
484
+ output: {
485
+ title: 'Response',
486
+ summary: 'AI\'s answer enriched with retrieved memories and full context.',
487
+ details: 'The final response is generated after gathering context from all memory layers. It\'s personalized based on your history, preferences, and the specific context retrieved for your query.',
488
+ updateFrequency: 'Per response',
489
+ components: ['Context integration', 'Personalization', 'Response generation', 'Memory-informed answers'],
490
+ docFiles: [],
491
+ },
492
+ };
493
+
494
+ // API: Get layer documentation
495
+ app.get('/api/layer-docs/:layer', async (req: Request, res: Response) => {
496
+ const layer = req.params.layer as string;
497
+
498
+ const config = layerDocConfigs[layer];
499
+ if (!config) {
500
+ res.status(404).json({ error: 'Layer not found' });
501
+ return;
502
+ }
503
+
504
+ // Try to read additional content from doc files
505
+ let additionalContent = '';
506
+ const sourceFiles: string[] = [];
507
+
508
+ for (const docFile of config.docFiles) {
509
+ try {
510
+ const fullPath = join(OPENCLAW_PATH, docFile);
511
+ const content = await readFile(fullPath, 'utf-8');
512
+ additionalContent += `\n\n---\n\n**From ${docFile}:**\n\n${content.slice(0, 2000)}${content.length > 2000 ? '...' : ''}`;
513
+ sourceFiles.push(docFile);
514
+ } catch {
515
+ // File not found, skip
516
+ }
517
+ }
518
+
519
+ res.json({
520
+ layer,
521
+ ...config,
522
+ additionalContent: additionalContent || null,
523
+ sourceFiles,
524
+ });
525
+ });
526
+
527
+ // Catch-all: serve index.html for client-side routing (production only)
528
+ const indexPath = join(distPath, 'index.html');
529
+ if (existsSync(indexPath)) {
530
+ app.get('{*path}', (_req: Request, res: Response) => {
531
+ res.sendFile(indexPath);
532
+ });
533
+ }
534
+
535
+ // Bind to localhost only to prevent network exposure
536
+ const HOST = '127.0.0.1';
537
+
538
+ app.listen(Number(PORT), HOST, () => {
539
+ console.log(`OpenClaw Memory Mapper API server running on http://${HOST}:${PORT}`);
540
+ console.log(` OPENCLAW_PATH: ${OPENCLAW_PATH}`);
541
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "declaration": false,
10
+ "outDir": "./dist"
11
+ },
12
+ "include": ["./**/*.ts"]
13
+ }