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,461 @@
1
+ import express 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
+ const { join, relative, extname, basename, dirname } = path;
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const app = express();
12
+ const PORT = process.env.PORT || 3001;
13
+ // Parse JSON bodies
14
+ app.use(express.json());
15
+ // Serve static files from the built frontend in production
16
+ // When compiled: server is at dist/server/index.js, frontend at dist/
17
+ // When running from source: server is at server/index.ts, frontend at dist/
18
+ const isCompiledServer = __dirname.includes('dist/server') || __dirname.includes('dist\\server');
19
+ const distPath = isCompiledServer
20
+ ? join(__dirname, '..') // dist/server -> dist/
21
+ : join(__dirname, '..', 'dist'); // server/ -> dist/
22
+ if (existsSync(join(distPath, 'index.html'))) {
23
+ app.use(express.static(distPath));
24
+ }
25
+ // Default memory paths (can be overridden via /api/config/sources)
26
+ const OPENCLAW_PATH = join(homedir(), '.openclaw');
27
+ let configuredSources = [];
28
+ // Helper to expand ~ and validate path is within home directory
29
+ function expandAndValidatePath(pathParam) {
30
+ const expanded = pathParam.startsWith('~')
31
+ ? join(homedir(), pathParam.slice(1))
32
+ : pathParam;
33
+ const resolved = path.resolve(expanded);
34
+ const home = homedir();
35
+ if (!resolved.startsWith(home + path.sep) && resolved !== home) {
36
+ return { valid: false, expanded, error: 'Path must be within home directory' };
37
+ }
38
+ return { valid: true, expanded };
39
+ }
40
+ // Get base path for a source (uses configured sources if available, falls back to defaults)
41
+ function getBasePath(sourceId) {
42
+ const configured = configuredSources.find(s => s.id === sourceId);
43
+ if (configured) {
44
+ const { valid, expanded } = expandAndValidatePath(configured.path);
45
+ if (valid)
46
+ return expanded;
47
+ }
48
+ // Fallback to default
49
+ if (sourceId === 'openclaw')
50
+ return OPENCLAW_PATH;
51
+ return null;
52
+ }
53
+ // Security: sanitize error messages to avoid leaking sensitive info in production
54
+ const IS_PRODUCTION = process.env.NODE_ENV === 'production';
55
+ function sanitizeError(error) {
56
+ if (!IS_PRODUCTION) {
57
+ return String(error);
58
+ }
59
+ // In production, only return generic message - log full error server-side
60
+ console.error('Server error:', error);
61
+ return undefined;
62
+ }
63
+ // Recursively read directory structure
64
+ async function readDirRecursive(dirPath, basePath) {
65
+ const entries = await readdir(dirPath, { withFileTypes: true });
66
+ const nodes = [];
67
+ for (const entry of entries) {
68
+ const fullPath = join(dirPath, entry.name);
69
+ const relativePath = relative(basePath, fullPath);
70
+ if (entry.isDirectory()) {
71
+ // Skip hidden directories and node_modules
72
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
73
+ continue;
74
+ const children = await readDirRecursive(fullPath, basePath);
75
+ nodes.push({
76
+ name: entry.name,
77
+ path: relativePath,
78
+ type: 'directory',
79
+ children,
80
+ });
81
+ }
82
+ else {
83
+ const stats = await stat(fullPath);
84
+ nodes.push({
85
+ name: entry.name,
86
+ path: relativePath,
87
+ type: 'file',
88
+ size: stats.size,
89
+ modified: stats.mtime.toISOString(),
90
+ });
91
+ }
92
+ }
93
+ // Sort: directories first, then alphabetically
94
+ return nodes.sort((a, b) => {
95
+ if (a.type !== b.type)
96
+ return a.type === 'directory' ? -1 : 1;
97
+ return a.name.localeCompare(b.name);
98
+ });
99
+ }
100
+ // Count files in directory tree
101
+ function countFiles(nodes) {
102
+ let count = 0;
103
+ for (const node of nodes) {
104
+ if (node.type === 'file')
105
+ count++;
106
+ if (node.children)
107
+ count += countFiles(node.children);
108
+ }
109
+ return count;
110
+ }
111
+ // Get total size of directory tree
112
+ function totalSize(nodes) {
113
+ let size = 0;
114
+ for (const node of nodes) {
115
+ if (node.type === 'file' && node.size)
116
+ size += node.size;
117
+ if (node.children)
118
+ size += totalSize(node.children);
119
+ }
120
+ return size;
121
+ }
122
+ // API: Configure source paths
123
+ app.post('/api/config/sources', (req, res) => {
124
+ const { sources } = req.body;
125
+ if (!Array.isArray(sources)) {
126
+ res.status(400).json({ error: 'sources must be an array' });
127
+ return;
128
+ }
129
+ // Validate all paths
130
+ const validated = [];
131
+ for (const source of sources) {
132
+ if (!source.id || !source.path)
133
+ continue;
134
+ const { valid, expanded, error } = expandAndValidatePath(source.path);
135
+ if (!valid) {
136
+ res.status(400).json({ error: `Invalid path for ${source.id}: ${error}` });
137
+ return;
138
+ }
139
+ validated.push({ id: source.id, path: expanded });
140
+ }
141
+ configuredSources = validated;
142
+ res.json({ success: true, sources: validated });
143
+ });
144
+ // API: List all files in memory directories
145
+ app.get('/api/files', async (_req, res) => {
146
+ try {
147
+ const result = {};
148
+ // Use configured sources, or fall back to default
149
+ const sourcesToScan = configuredSources.length > 0
150
+ ? configuredSources
151
+ : [
152
+ { id: 'openclaw', path: OPENCLAW_PATH },
153
+ ];
154
+ for (const source of sourcesToScan) {
155
+ try {
156
+ const basePath = getBasePath(source.id) || source.path;
157
+ const tree = await readDirRecursive(basePath, basePath);
158
+ result[source.id] = {
159
+ tree,
160
+ fileCount: countFiles(tree),
161
+ totalSize: totalSize(tree),
162
+ path: basePath,
163
+ };
164
+ }
165
+ catch {
166
+ // Directory doesn't exist or not accessible
167
+ result[source.id] = null;
168
+ }
169
+ }
170
+ res.json(result);
171
+ }
172
+ catch (error) {
173
+ res.status(500).json({ error: 'Failed to read files', details: sanitizeError(error) });
174
+ }
175
+ });
176
+ // API: Read a specific file
177
+ app.get('/api/file', async (req, res) => {
178
+ try {
179
+ const { path: filePath, source } = req.query;
180
+ if (!filePath || typeof filePath !== 'string') {
181
+ res.status(400).json({ error: 'Missing path parameter' });
182
+ return;
183
+ }
184
+ if (!source || typeof source !== 'string') {
185
+ res.status(400).json({ error: 'Missing source parameter' });
186
+ return;
187
+ }
188
+ // Get base path from configured sources or defaults
189
+ const basePath = getBasePath(source);
190
+ if (!basePath) {
191
+ res.status(400).json({ error: `Unknown source: ${source}` });
192
+ return;
193
+ }
194
+ const resolvedBase = path.resolve(basePath);
195
+ const fullPath = path.resolve(basePath, filePath);
196
+ // Security: ensure path is within the source directory
197
+ if (!fullPath.startsWith(resolvedBase + path.sep) && fullPath !== resolvedBase) {
198
+ res.status(403).json({ error: 'Access denied' });
199
+ return;
200
+ }
201
+ const content = await readFile(fullPath, 'utf-8');
202
+ const stats = await stat(fullPath);
203
+ const ext = extname(filePath).toLowerCase();
204
+ res.json({
205
+ path: filePath,
206
+ name: basename(filePath),
207
+ content,
208
+ size: stats.size,
209
+ modified: stats.mtime.toISOString(),
210
+ extension: ext,
211
+ });
212
+ }
213
+ catch (error) {
214
+ if (error.code === 'ENOENT') {
215
+ res.status(404).json({ error: 'File not found' });
216
+ }
217
+ else {
218
+ res.status(500).json({ error: 'Failed to read file', details: sanitizeError(error) });
219
+ }
220
+ }
221
+ });
222
+ // API: Get openclaw config
223
+ app.get('/api/config', async (_req, res) => {
224
+ try {
225
+ const configPath = join(OPENCLAW_PATH, 'openclaw.json');
226
+ const content = await readFile(configPath, 'utf-8');
227
+ const config = JSON.parse(content);
228
+ res.json({ config, path: configPath });
229
+ }
230
+ catch (error) {
231
+ if (error.code === 'ENOENT') {
232
+ res.status(404).json({ error: 'Config file not found', path: join(OPENCLAW_PATH, 'openclaw.json') });
233
+ }
234
+ else {
235
+ res.status(500).json({ error: 'Failed to read config', details: sanitizeError(error) });
236
+ }
237
+ }
238
+ });
239
+ // API: Get agent logs from memory-swarm/logs
240
+ app.get('/api/logs', async (_req, res) => {
241
+ try {
242
+ const logsPath = join(OPENCLAW_PATH, 'memory-swarm', 'logs');
243
+ const files = await readdir(logsPath);
244
+ const csvFiles = files.filter(f => f.endsWith('.csv')).sort().reverse();
245
+ const logs = [];
246
+ // Read up to 5 most recent log files
247
+ for (const file of csvFiles.slice(0, 5)) {
248
+ try {
249
+ const content = await readFile(join(logsPath, file), 'utf-8');
250
+ const records = csvParse(content, {
251
+ columns: true,
252
+ skip_empty_lines: true,
253
+ relax_column_count: true,
254
+ });
255
+ logs.push({ file, entries: records });
256
+ }
257
+ catch {
258
+ // Skip files that can't be parsed
259
+ }
260
+ }
261
+ res.json({ logs, path: logsPath });
262
+ }
263
+ catch (error) {
264
+ if (error.code === 'ENOENT') {
265
+ res.status(404).json({ error: 'Logs directory not found', path: join(OPENCLAW_PATH, 'memory-swarm', 'logs') });
266
+ }
267
+ else {
268
+ res.status(500).json({ error: 'Failed to read logs', details: sanitizeError(error) });
269
+ }
270
+ }
271
+ });
272
+ // API: Get memory layer stats
273
+ app.get('/api/stats', async (_req, res) => {
274
+ try {
275
+ const stats = {
276
+ layers: {
277
+ session: { path: 'memory/session', files: 0, size: 0, cloud: false },
278
+ daily: { path: 'memory', files: 0, size: 0, cloud: false },
279
+ supermemory: { path: 'supermemory', files: 0, size: 0, cloud: false },
280
+ qmd: { path: 'memory-swarm', files: 0, size: 0, cloud: false },
281
+ config: { path: '.openclaw', files: 0, size: 0, cloud: false },
282
+ },
283
+ connected: {
284
+ openclaw: false,
285
+ },
286
+ };
287
+ // Check openclaw directories
288
+ try {
289
+ await stat(OPENCLAW_PATH);
290
+ stats.connected.openclaw = true;
291
+ // Daily logs: count .md files directly in ~/.openclaw/memory/
292
+ try {
293
+ const memoryPath = join(OPENCLAW_PATH, 'memory');
294
+ const entries = await readdir(memoryPath, { withFileTypes: true });
295
+ const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
296
+ let totalMdSize = 0;
297
+ for (const file of mdFiles) {
298
+ const fileStat = await stat(join(memoryPath, file.name));
299
+ totalMdSize += fileStat.size;
300
+ }
301
+ stats.layers.daily.files = mdFiles.length;
302
+ stats.layers.daily.size = totalMdSize;
303
+ }
304
+ catch {
305
+ // memory directory doesn't exist
306
+ }
307
+ // QMD Swarm
308
+ try {
309
+ const qmdPath = join(OPENCLAW_PATH, 'memory-swarm');
310
+ const tree = await readDirRecursive(qmdPath, qmdPath);
311
+ stats.layers.qmd.files = countFiles(tree);
312
+ stats.layers.qmd.size = totalSize(tree);
313
+ }
314
+ catch {
315
+ // memory-swarm doesn't exist
316
+ }
317
+ // Config files
318
+ const configTree = await readDirRecursive(OPENCLAW_PATH, OPENCLAW_PATH);
319
+ stats.layers.config.files = countFiles(configTree);
320
+ stats.layers.config.size = totalSize(configTree);
321
+ }
322
+ catch {
323
+ // openclaw not accessible
324
+ }
325
+ res.json(stats);
326
+ }
327
+ catch (error) {
328
+ res.status(500).json({ error: 'Failed to get stats', details: sanitizeError(error) });
329
+ }
330
+ });
331
+ // Health check
332
+ app.get('/api/health', (_req, res) => {
333
+ const openclawExists = existsSync(OPENCLAW_PATH);
334
+ res.json({
335
+ status: 'ok',
336
+ timestamp: new Date().toISOString(),
337
+ openclaw: openclawExists,
338
+ paths: {
339
+ openclaw: OPENCLAW_PATH,
340
+ },
341
+ });
342
+ });
343
+ // Check if a path exists
344
+ // Security: only allows checking if directories exist, not reading contents
345
+ // Path must be within user's home directory
346
+ app.get('/api/check-path', (req, res) => {
347
+ const pathParam = req.query.path;
348
+ if (!pathParam) {
349
+ res.status(400).json({ error: 'Path parameter required' });
350
+ return;
351
+ }
352
+ // Expand ~ to home directory
353
+ const expandedPath = pathParam.startsWith('~')
354
+ ? join(homedir(), pathParam.slice(1))
355
+ : pathParam;
356
+ // Security: only allow checking paths within user's home directory
357
+ const resolvedPath = path.resolve(expandedPath);
358
+ const home = homedir();
359
+ if (!resolvedPath.startsWith(home + path.sep) && resolvedPath !== home) {
360
+ res.status(403).json({ error: 'Access denied: path must be within home directory' });
361
+ return;
362
+ }
363
+ const exists = existsSync(expandedPath);
364
+ res.json({
365
+ path: pathParam,
366
+ expandedPath,
367
+ exists,
368
+ });
369
+ });
370
+ const layerDocConfigs = {
371
+ input: {
372
+ title: 'User Input',
373
+ summary: 'Your question or message to the AI, analyzed to determine context needs.',
374
+ 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.',
375
+ updateFrequency: 'Real-time',
376
+ components: ['Query parsing', 'Intent detection', 'Topic extraction', 'Context triggers'],
377
+ docFiles: [],
378
+ },
379
+ session: {
380
+ title: 'Session Context',
381
+ summary: 'Current conversation held in working memory during your session.',
382
+ 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.',
383
+ updateFrequency: 'Real-time during session',
384
+ components: ['Current conversation thread', 'Recent tool usage', 'Active task tracking', 'Context switches'],
385
+ docFiles: ['memory/CONTEXT_BUILDER.md'],
386
+ },
387
+ daily: {
388
+ title: 'Daily Logs',
389
+ summary: 'Markdown files capturing insights and summaries from each day.',
390
+ 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.',
391
+ updateFrequency: 'Daily (end of session)',
392
+ components: ['Session summaries', 'Decisions made', 'Preferences learned', 'Important context'],
393
+ docFiles: ['memory/PERSISTENT_MEMORY.md', 'MEMORY.md'],
394
+ },
395
+ supermemory: {
396
+ title: 'SuperMemory',
397
+ summary: 'Cloud-based semantic search over long-term memories.',
398
+ 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.',
399
+ updateFrequency: 'Continuous (cloud sync)',
400
+ components: ['Vector embeddings', 'Semantic search', 'Long-term storage', 'Cross-session recall'],
401
+ docFiles: ['memory/ADVANCED_MEMORY_ARCHITECTURE.md'],
402
+ },
403
+ qmd: {
404
+ title: 'QMD Swarm',
405
+ summary: 'Multi-agent system that orchestrates memory retrieval and synthesis.',
406
+ 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.',
407
+ updateFrequency: 'Every query',
408
+ components: ['Query analysis', 'Parallel retrieval', 'Result ranking', 'Context synthesis'],
409
+ docFiles: ['AGENTS.md', 'memory/ADVANCED_MEMORY_ARCHITECTURE.md'],
410
+ },
411
+ output: {
412
+ title: 'Response',
413
+ summary: 'AI\'s answer enriched with retrieved memories and full context.',
414
+ 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.',
415
+ updateFrequency: 'Per response',
416
+ components: ['Context integration', 'Personalization', 'Response generation', 'Memory-informed answers'],
417
+ docFiles: [],
418
+ },
419
+ };
420
+ // API: Get layer documentation
421
+ app.get('/api/layer-docs/:layer', async (req, res) => {
422
+ const layer = req.params.layer;
423
+ const config = layerDocConfigs[layer];
424
+ if (!config) {
425
+ res.status(404).json({ error: 'Layer not found' });
426
+ return;
427
+ }
428
+ // Try to read additional content from doc files
429
+ let additionalContent = '';
430
+ const sourceFiles = [];
431
+ for (const docFile of config.docFiles) {
432
+ try {
433
+ const fullPath = join(OPENCLAW_PATH, docFile);
434
+ const content = await readFile(fullPath, 'utf-8');
435
+ additionalContent += `\n\n---\n\n**From ${docFile}:**\n\n${content.slice(0, 2000)}${content.length > 2000 ? '...' : ''}`;
436
+ sourceFiles.push(docFile);
437
+ }
438
+ catch {
439
+ // File not found, skip
440
+ }
441
+ }
442
+ res.json({
443
+ layer,
444
+ ...config,
445
+ additionalContent: additionalContent || null,
446
+ sourceFiles,
447
+ });
448
+ });
449
+ // Catch-all: serve index.html for client-side routing (production only)
450
+ const indexPath = join(distPath, 'index.html');
451
+ if (existsSync(indexPath)) {
452
+ app.get('{*path}', (_req, res) => {
453
+ res.sendFile(indexPath);
454
+ });
455
+ }
456
+ // Bind to localhost only to prevent network exposure
457
+ const HOST = '127.0.0.1';
458
+ app.listen(Number(PORT), HOST, () => {
459
+ console.log(`OpenClaw Memory Mapper API server running on http://${HOST}:${PORT}`);
460
+ console.log(` OPENCLAW_PATH: ${OPENCLAW_PATH}`);
461
+ });
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "openclaw-memory-mapper",
3
+ "version": "1.0.0",
4
+ "description": "Visualize your Claude agent's memory architecture",
5
+ "type": "module",
6
+ "bin": {
7
+ "openclaw-memory-mapper": "bin/cli.js",
8
+ "memory-mapper": "bin/cli.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "dist",
13
+ "server"
14
+ ],
15
+ "scripts": {
16
+ "start": "node bin/cli.js",
17
+ "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
18
+ "dev:client": "vite",
19
+ "dev:server": "tsx watch server/index.ts",
20
+ "build": "npm run build:client && npm run build:server",
21
+ "build:client": "vite build",
22
+ "build:server": "tsc -p tsconfig.server.json",
23
+ "lint": "eslint .",
24
+ "typecheck": "tsc --noEmit",
25
+ "preview": "vite preview",
26
+ "prepare": "husky",
27
+ "prepublishOnly": "npm run build",
28
+ "postinstall": "rm -rf node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error"
29
+ },
30
+ "dependencies": {
31
+ "csv-parse": "^5.6.0",
32
+ "express": "^5.2.1"
33
+ },
34
+ "devDependencies": {
35
+ "@commitlint/cli": "^20.3.1",
36
+ "@commitlint/config-conventional": "^20.3.1",
37
+ "@eslint/js": "^9.39.2",
38
+ "@radix-ui/react-dialog": "^1.1.15",
39
+ "@radix-ui/react-scroll-area": "^1.2.10",
40
+ "@radix-ui/react-separator": "^1.1.8",
41
+ "@radix-ui/react-slot": "^1.2.4",
42
+ "@radix-ui/react-tabs": "^1.1.13",
43
+ "@radix-ui/react-toast": "^1.2.15",
44
+ "@radix-ui/react-tooltip": "^1.2.8",
45
+ "@semantic-release/changelog": "^6.0.3",
46
+ "@semantic-release/git": "^10.0.1",
47
+ "@semantic-release/github": "^12.0.2",
48
+ "@tailwindcss/vite": "^4.1.18",
49
+ "@types/express": "^5.0.6",
50
+ "@types/node": "^24.10.9",
51
+ "@types/react": "^19.2.10",
52
+ "@types/react-dom": "^19.2.3",
53
+ "@vitejs/plugin-react": "^5.1.2",
54
+ "@xyflow/react": "^12.10.0",
55
+ "autoprefixer": "^10.4.23",
56
+ "class-variance-authority": "^0.7.1",
57
+ "clsx": "^2.1.1",
58
+ "commitlint": "^20.3.1",
59
+ "concurrently": "^9.2.1",
60
+ "eslint": "^9.39.2",
61
+ "eslint-plugin-react-hooks": "7.0.1",
62
+ "eslint-plugin-react-refresh": "^0.4.26",
63
+ "globals": "^16.5.0",
64
+ "husky": "^9.1.7",
65
+ "lucide-react": "^0.563.0",
66
+ "postcss": "^8.5.6",
67
+ "react": "^19.2.4",
68
+ "react-dom": "^19.2.4",
69
+ "semantic-release": "^25.0.2",
70
+ "tailwind-merge": "^3.4.0",
71
+ "tailwindcss": "^4.1.18",
72
+ "tsx": "^4.21.0",
73
+ "typescript": "~5.9.3",
74
+ "typescript-eslint": "^8.54.0",
75
+ "vite": "^6.3.0",
76
+ "zod-validation-error": "4",
77
+ "zustand": "^5.0.10"
78
+ },
79
+ "overrides": {
80
+ "zod-validation-error": "4"
81
+ },
82
+ "publishConfig": {
83
+ "access": "public"
84
+ }
85
+ }