claude-code-memory-explorer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/release.md +54 -0
- package/.github/workflows/pages.yml +36 -0
- package/CLAUDE.md +49 -0
- package/README.md +92 -0
- package/assets/main-dark.png +0 -0
- package/assets/main-light.png +0 -0
- package/biome.json +33 -0
- package/docs/assets/main-dark.png +0 -0
- package/docs/assets/main-light.png +0 -0
- package/docs/index.html +988 -0
- package/package.json +26 -0
- package/public/app.js +720 -0
- package/public/icons/icon-svg.svg +6 -0
- package/public/index.html +145 -0
- package/public/manifest.json +14 -0
- package/public/style.css +789 -0
- package/public/sw.js +34 -0
- package/server.js +579 -0
package/public/sw.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const CACHE = 'cc-memory-v1';
|
|
2
|
+
const PRECACHE = ['/', '/style.css', '/app.js'];
|
|
3
|
+
|
|
4
|
+
self.addEventListener('install', (e) => {
|
|
5
|
+
e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
|
|
6
|
+
self.skipWaiting();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
self.addEventListener('activate', (e) => {
|
|
10
|
+
e.waitUntil(
|
|
11
|
+
caches.keys().then(keys =>
|
|
12
|
+
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
|
13
|
+
)
|
|
14
|
+
);
|
|
15
|
+
self.clients.claim();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
self.addEventListener('fetch', (e) => {
|
|
19
|
+
const url = new URL(e.request.url);
|
|
20
|
+
if (url.pathname.startsWith('/api/')) return;
|
|
21
|
+
if (url.origin !== location.origin) {
|
|
22
|
+
e.respondWith(caches.match(e.request).then(r => r || fetch(e.request).then(res => {
|
|
23
|
+
const clone = res.clone();
|
|
24
|
+
caches.open(CACHE).then(c => c.put(e.request, clone));
|
|
25
|
+
return res;
|
|
26
|
+
})));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
e.respondWith(fetch(e.request).then(res => {
|
|
30
|
+
const clone = res.clone();
|
|
31
|
+
caches.open(CACHE).then(c => c.put(e.request, clone));
|
|
32
|
+
return res;
|
|
33
|
+
}).catch(() => caches.match(e.request)));
|
|
34
|
+
});
|
package/server.js
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const express = require('express');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
|
|
9
|
+
// #region CLI_ARGS
|
|
10
|
+
|
|
11
|
+
function getArg(name) {
|
|
12
|
+
const eqIdx = process.argv.findIndex(a => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
13
|
+
if (eqIdx === -1) return null;
|
|
14
|
+
const arg = process.argv[eqIdx];
|
|
15
|
+
if (arg.includes('=')) return arg.split('=').slice(1).join('=');
|
|
16
|
+
return process.argv[eqIdx + 1] || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PORT = getArg('port') || process.env.PORT || 3459;
|
|
20
|
+
const AUTO_OPEN = process.argv.includes('--open');
|
|
21
|
+
const claudeDirArg = getArg('dir');
|
|
22
|
+
const CLAUDE_DIR = claudeDirArg
|
|
23
|
+
? claudeDirArg.replace(/^~/, os.homedir())
|
|
24
|
+
: path.join(os.homedir(), '.claude');
|
|
25
|
+
const projectDirArg = getArg('project');
|
|
26
|
+
|
|
27
|
+
// #endregion CLI_ARGS
|
|
28
|
+
|
|
29
|
+
// #region STATE
|
|
30
|
+
|
|
31
|
+
let currentProjectPath = projectDirArg
|
|
32
|
+
? path.resolve(projectDirArg.replace(/^~/, os.homedir()))
|
|
33
|
+
: process.cwd();
|
|
34
|
+
|
|
35
|
+
const cache = {};
|
|
36
|
+
const CACHE_TTL = 30_000;
|
|
37
|
+
|
|
38
|
+
function cached(key, fn) {
|
|
39
|
+
const entry = cache[key];
|
|
40
|
+
if (entry && Date.now() - entry.ts < CACHE_TTL) return entry.data;
|
|
41
|
+
const data = fn();
|
|
42
|
+
cache[key] = { data, ts: Date.now() };
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clearCache() {
|
|
47
|
+
for (const k of Object.keys(cache)) delete cache[k];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// #endregion STATE
|
|
51
|
+
|
|
52
|
+
// #region FILESYSTEM_SCANNING
|
|
53
|
+
|
|
54
|
+
const MANAGED_POLICY_PATHS = process.platform === 'win32'
|
|
55
|
+
? [path.join(process.env.ProgramFiles || 'C:\\Program Files', 'ClaudeCode', 'CLAUDE.md')]
|
|
56
|
+
: process.platform === 'darwin'
|
|
57
|
+
? ['/Library/Application Support/ClaudeCode/CLAUDE.md']
|
|
58
|
+
: ['/etc/claude-code/CLAUDE.md'];
|
|
59
|
+
|
|
60
|
+
function fileInfo(filePath) {
|
|
61
|
+
try {
|
|
62
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
63
|
+
const lines = content.split('\n').length;
|
|
64
|
+
const bytes = Buffer.byteLength(content, 'utf-8');
|
|
65
|
+
const frontmatter = parseFrontmatter(content);
|
|
66
|
+
return { path: filePath, content, lines, bytes, frontmatter };
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseFrontmatter(content) {
|
|
73
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
74
|
+
if (!match) return null;
|
|
75
|
+
const fm = {};
|
|
76
|
+
for (const line of match[1].split('\n')) {
|
|
77
|
+
const kv = line.match(/^(\w+):\s*(.*)/);
|
|
78
|
+
if (kv) {
|
|
79
|
+
const val = kv[2].trim();
|
|
80
|
+
if (!val) {
|
|
81
|
+
fm[kv[1]] = [];
|
|
82
|
+
} else if (val.startsWith('[') || val.startsWith('"')) {
|
|
83
|
+
try { fm[kv[1]] = JSON.parse(val.replace(/'/g, '"')); } catch { fm[kv[1]] = val; }
|
|
84
|
+
} else {
|
|
85
|
+
fm[kv[1]] = val;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const arrItem = line.match(/^\s+-\s+"?(.+?)"?\s*$/);
|
|
89
|
+
if (arrItem) {
|
|
90
|
+
const lastKey = Object.keys(fm).pop();
|
|
91
|
+
if (lastKey && !Array.isArray(fm[lastKey])) fm[lastKey] = [];
|
|
92
|
+
if (lastKey) fm[lastKey].push(arrItem[1]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return Object.keys(fm).length ? fm : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseImports(content) {
|
|
99
|
+
const imports = [];
|
|
100
|
+
const softLinks = [];
|
|
101
|
+
// Match @path/to/file.ext — must contain / or \ to be a file path import
|
|
102
|
+
const re = /@(~?[\w./-]+\/[\w./-]+|~\/[\w./-]+)/g;
|
|
103
|
+
let m;
|
|
104
|
+
while ((m = re.exec(content)) !== null) {
|
|
105
|
+
if (m[1].includes('.') && !m[1].includes('/')) continue;
|
|
106
|
+
// Skip npm scoped packages (e.g. @biomejs/biome) — require file extension in last segment
|
|
107
|
+
const lastSeg = m[1].split('/').pop();
|
|
108
|
+
if (!lastSeg.includes('.')) continue;
|
|
109
|
+
imports.push(m[1]);
|
|
110
|
+
}
|
|
111
|
+
// Also match standalone @filename.md references (no path separator needed)
|
|
112
|
+
const re2 = /(?:^|\s)@([\w-]+\.md)\b/gm;
|
|
113
|
+
while ((m = re2.exec(content)) !== null) {
|
|
114
|
+
if (!imports.includes(m[1])) imports.push(m[1]);
|
|
115
|
+
}
|
|
116
|
+
// Match markdown links [text](path.md) — soft references, don't change load type
|
|
117
|
+
const re3 = /\[.*?\]\(((?!https?:\/\/)[^)]+\.md)\)/g;
|
|
118
|
+
while ((m = re3.exec(content)) !== null) {
|
|
119
|
+
if (!imports.includes(m[1]) && !softLinks.includes(m[1])) softLinks.push(m[1]);
|
|
120
|
+
}
|
|
121
|
+
return { imports, softLinks };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolveImport(importPath, fromFile) {
|
|
125
|
+
let resolved = importPath;
|
|
126
|
+
if (resolved.startsWith('~')) {
|
|
127
|
+
resolved = resolved.replace(/^~/, os.homedir());
|
|
128
|
+
} else {
|
|
129
|
+
resolved = path.resolve(path.dirname(fromFile), resolved);
|
|
130
|
+
}
|
|
131
|
+
return resolved;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveAllImports(filePath, content) {
|
|
135
|
+
const { imports: raw, softLinks } = parseImports(content);
|
|
136
|
+
const resolved = [];
|
|
137
|
+
const resolvedSoft = [];
|
|
138
|
+
const unresolved = [];
|
|
139
|
+
for (const imp of raw) {
|
|
140
|
+
const abs = resolveImport(imp, filePath);
|
|
141
|
+
if (fs.existsSync(abs)) resolved.push(abs);
|
|
142
|
+
else unresolved.push(imp);
|
|
143
|
+
}
|
|
144
|
+
for (const imp of softLinks) {
|
|
145
|
+
const abs = resolveImport(imp, filePath);
|
|
146
|
+
if (fs.existsSync(abs)) resolvedSoft.push(abs);
|
|
147
|
+
else unresolved.push(imp);
|
|
148
|
+
}
|
|
149
|
+
return { resolved, resolvedSoft, unresolved };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveExistingImports(filePath, content) {
|
|
153
|
+
const { resolved, resolvedSoft } = resolveAllImports(filePath, content);
|
|
154
|
+
return [...resolved, ...resolvedSoft];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function spreadImports(filePath, content) {
|
|
158
|
+
const { resolved, resolvedSoft, unresolved } = resolveAllImports(filePath, content);
|
|
159
|
+
return { imports: resolved, softImports: resolvedSoft, unresolvedImports: unresolved };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function discoverMemorySources(projectPath) {
|
|
163
|
+
const sources = [];
|
|
164
|
+
|
|
165
|
+
// 1. Managed policy
|
|
166
|
+
for (const p of MANAGED_POLICY_PATHS) {
|
|
167
|
+
const info = fileInfo(p);
|
|
168
|
+
if (info) {
|
|
169
|
+
sources.push({
|
|
170
|
+
id: 'policy-claude-md',
|
|
171
|
+
name: 'CLAUDE.md',
|
|
172
|
+
scope: 'policy',
|
|
173
|
+
load: 'always',
|
|
174
|
+
...info,
|
|
175
|
+
...spreadImports(info.path, info.content),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 2. User CLAUDE.md
|
|
181
|
+
const userClaudeMd = path.join(CLAUDE_DIR, 'CLAUDE.md');
|
|
182
|
+
const userInfo = fileInfo(userClaudeMd);
|
|
183
|
+
if (userInfo) {
|
|
184
|
+
sources.push({
|
|
185
|
+
id: 'user-claude-md',
|
|
186
|
+
name: 'CLAUDE.md',
|
|
187
|
+
scope: 'user',
|
|
188
|
+
load: 'always',
|
|
189
|
+
...userInfo,
|
|
190
|
+
...spreadImports(userInfo.path, userInfo.content),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 3. User rules (~/.claude/rules/*.md)
|
|
195
|
+
const userRulesDir = path.join(CLAUDE_DIR, 'rules');
|
|
196
|
+
if (fs.existsSync(userRulesDir)) {
|
|
197
|
+
for (const file of findMdFiles(userRulesDir)) {
|
|
198
|
+
const info = fileInfo(file);
|
|
199
|
+
if (!info) continue;
|
|
200
|
+
sources.push({
|
|
201
|
+
id: `user-rule-${path.basename(file, '.md')}`,
|
|
202
|
+
name: path.basename(file),
|
|
203
|
+
scope: 'rule',
|
|
204
|
+
load: 'conditional',
|
|
205
|
+
...info,
|
|
206
|
+
ruleSource: 'user',
|
|
207
|
+
...spreadImports(info.path, info.content),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 4. Walk up from projectPath to find CLAUDE.md and CLAUDE.local.md
|
|
213
|
+
const ancestors = getAncestorDirs(projectPath);
|
|
214
|
+
const seenPaths = new Set(sources.map(s => s.path));
|
|
215
|
+
for (const dir of ancestors) {
|
|
216
|
+
for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) {
|
|
217
|
+
const filePath = path.join(dir, name);
|
|
218
|
+
if (seenPaths.has(filePath)) continue;
|
|
219
|
+
const info = fileInfo(filePath);
|
|
220
|
+
if (!info) continue;
|
|
221
|
+
seenPaths.add(filePath);
|
|
222
|
+
const isProjectRoot = path.resolve(dir) === path.resolve(projectPath);
|
|
223
|
+
sources.push({
|
|
224
|
+
id: `project-claude-md-${dir.replace(/[^a-zA-Z0-9]/g, '-')}`,
|
|
225
|
+
name: name.includes('/') ? '.claude/CLAUDE.md' : 'CLAUDE.md',
|
|
226
|
+
scope: 'project',
|
|
227
|
+
load: 'always',
|
|
228
|
+
...info,
|
|
229
|
+
dir,
|
|
230
|
+
isProjectRoot,
|
|
231
|
+
...spreadImports(info.path, info.content),
|
|
232
|
+
});
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const localPath = path.join(dir, 'CLAUDE.local.md');
|
|
237
|
+
if (seenPaths.has(localPath)) continue;
|
|
238
|
+
const localInfo = fileInfo(localPath);
|
|
239
|
+
if (localInfo) {
|
|
240
|
+
seenPaths.add(localPath);
|
|
241
|
+
sources.push({
|
|
242
|
+
id: `local-claude-md-${dir.replace(/[^a-zA-Z0-9]/g, '-')}`,
|
|
243
|
+
name: 'CLAUDE.local.md',
|
|
244
|
+
scope: 'project',
|
|
245
|
+
load: 'always',
|
|
246
|
+
...localInfo,
|
|
247
|
+
dir,
|
|
248
|
+
...spreadImports(localInfo.path, localInfo.content),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 5. Project rules (.claude/rules/*.md)
|
|
254
|
+
const projectRulesDir = path.join(projectPath, '.claude', 'rules');
|
|
255
|
+
if (fs.existsSync(projectRulesDir)) {
|
|
256
|
+
for (const file of findMdFiles(projectRulesDir)) {
|
|
257
|
+
const info = fileInfo(file);
|
|
258
|
+
if (!info) continue;
|
|
259
|
+
sources.push({
|
|
260
|
+
id: `project-rule-${path.basename(file, '.md')}`,
|
|
261
|
+
name: path.basename(file),
|
|
262
|
+
scope: 'rule',
|
|
263
|
+
load: 'conditional',
|
|
264
|
+
...info,
|
|
265
|
+
ruleSource: 'project',
|
|
266
|
+
...spreadImports(info.path, info.content),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 6. Auto memory
|
|
272
|
+
const memoryDir = findMemoryDir(projectPath);
|
|
273
|
+
if (memoryDir && fs.existsSync(memoryDir)) {
|
|
274
|
+
const memoryMd = path.join(memoryDir, 'MEMORY.md');
|
|
275
|
+
const memInfo = fileInfo(memoryMd);
|
|
276
|
+
if (memInfo) {
|
|
277
|
+
sources.push({
|
|
278
|
+
id: 'memory-index',
|
|
279
|
+
name: 'MEMORY.md',
|
|
280
|
+
scope: 'memory',
|
|
281
|
+
load: 'startup',
|
|
282
|
+
...memInfo,
|
|
283
|
+
maxLines: 200,
|
|
284
|
+
maxBytes: 25 * 1024,
|
|
285
|
+
...spreadImports(memInfo.path, memInfo.content),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
for (const file of findMdFiles(memoryDir)) {
|
|
289
|
+
if (path.basename(file) === 'MEMORY.md') continue;
|
|
290
|
+
const info = fileInfo(file);
|
|
291
|
+
if (!info) continue;
|
|
292
|
+
sources.push({
|
|
293
|
+
id: `memory-${path.basename(file, '.md')}`,
|
|
294
|
+
name: path.basename(file),
|
|
295
|
+
scope: 'memory',
|
|
296
|
+
load: 'ondemand',
|
|
297
|
+
...info,
|
|
298
|
+
...spreadImports(info.path, info.content),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Resolve imports recursively — add imported files as sources if not already present
|
|
304
|
+
const seen = new Set(sources.map(s => s.path));
|
|
305
|
+
const sourceByPath = Object.fromEntries(sources.map(s => [s.path, s]));
|
|
306
|
+
// Hard imports (@) get load:'import'; soft imports (markdown links) just reparent
|
|
307
|
+
const queue = sources.flatMap(s => [
|
|
308
|
+
...(s.imports || []).map(imp => ({ imp, parent: s, hard: true })),
|
|
309
|
+
...(s.softImports || []).map(imp => ({ imp, parent: s, hard: false })),
|
|
310
|
+
]);
|
|
311
|
+
let depth = 0;
|
|
312
|
+
while (queue.length && depth < 5) {
|
|
313
|
+
const batch = queue.splice(0, queue.length);
|
|
314
|
+
for (const { imp, parent, hard } of batch) {
|
|
315
|
+
if (seen.has(imp)) {
|
|
316
|
+
const existing = sourceByPath[imp];
|
|
317
|
+
if (existing && !existing.parentId && existing.scope === 'memory') {
|
|
318
|
+
existing.parentId = parent.id;
|
|
319
|
+
if (hard) existing.load = 'import';
|
|
320
|
+
}
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
seen.add(imp);
|
|
324
|
+
const info = fileInfo(imp);
|
|
325
|
+
if (!info) continue;
|
|
326
|
+
const { resolved: imports, resolvedSoft: softImports, unresolved: unresolvedImports } = resolveAllImports(imp, info.content);
|
|
327
|
+
const source = {
|
|
328
|
+
id: `import-${path.basename(imp, '.md')}-${depth}`,
|
|
329
|
+
name: path.basename(imp),
|
|
330
|
+
scope: parent.scope,
|
|
331
|
+
load: 'import',
|
|
332
|
+
...info,
|
|
333
|
+
importedBy: parent.path,
|
|
334
|
+
parentId: parent.id,
|
|
335
|
+
imports,
|
|
336
|
+
softImports,
|
|
337
|
+
unresolvedImports,
|
|
338
|
+
};
|
|
339
|
+
sources.push(source);
|
|
340
|
+
sourceByPath[imp] = source;
|
|
341
|
+
for (const child of imports) queue.push({ imp: child, parent: source, hard: true });
|
|
342
|
+
for (const child of softImports) queue.push({ imp: child, parent: source, hard: false });
|
|
343
|
+
}
|
|
344
|
+
depth++;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return sources;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function findMdFiles(dir) {
|
|
351
|
+
const results = [];
|
|
352
|
+
try {
|
|
353
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
const full = path.join(dir, entry.name);
|
|
356
|
+
if (entry.isDirectory()) {
|
|
357
|
+
results.push(...findMdFiles(full));
|
|
358
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
359
|
+
results.push(full);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch { /* skip unreadable dirs */ }
|
|
363
|
+
return results;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function getAncestorDirs(projectPath) {
|
|
367
|
+
const dirs = [];
|
|
368
|
+
let current = path.resolve(projectPath);
|
|
369
|
+
const root = path.parse(current).root;
|
|
370
|
+
while (current !== root) {
|
|
371
|
+
dirs.push(current);
|
|
372
|
+
const parent = path.dirname(current);
|
|
373
|
+
if (parent === current) break;
|
|
374
|
+
current = parent;
|
|
375
|
+
}
|
|
376
|
+
return dirs;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function findMemoryDir(projectPath) {
|
|
380
|
+
const projectsDir = path.join(CLAUDE_DIR, 'projects');
|
|
381
|
+
if (!fs.existsSync(projectsDir)) return null;
|
|
382
|
+
const encoded = encodeProjectPath(projectPath);
|
|
383
|
+
const memDir = path.join(projectsDir, encoded, 'memory');
|
|
384
|
+
if (fs.existsSync(memDir)) return memDir;
|
|
385
|
+
return findMemoryDirBySubstring(projectPath);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function encodeProjectPath(projectPath) {
|
|
389
|
+
// Claude Code uses double-dash encoding: C--Users-nikiforovall-dev-foo
|
|
390
|
+
return projectPath
|
|
391
|
+
.replace(/\\/g, '/')
|
|
392
|
+
.replace(/^([A-Za-z]):/, '$1') // strip colon but keep drive letter
|
|
393
|
+
.replace(/\//g, '-'); // slashes become single dash; drive letter boundary becomes double dash naturally
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function findMemoryDirBySubstring(projectPath) {
|
|
397
|
+
const projectsDir = path.join(CLAUDE_DIR, 'projects');
|
|
398
|
+
if (!fs.existsSync(projectsDir)) return null;
|
|
399
|
+
// Match by last 2-3 path segments
|
|
400
|
+
const segments = projectPath.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
401
|
+
const suffix = segments.slice(-2).join('-').toLowerCase();
|
|
402
|
+
try {
|
|
403
|
+
const dirs = fs.readdirSync(projectsDir);
|
|
404
|
+
for (const d of dirs) {
|
|
405
|
+
if (d.toLowerCase().endsWith(suffix)) {
|
|
406
|
+
const candidate = path.join(projectsDir, d, 'memory');
|
|
407
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} catch { /* skip */ }
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// #endregion FILESYSTEM_SCANNING
|
|
415
|
+
|
|
416
|
+
// #region API_ENDPOINTS
|
|
417
|
+
|
|
418
|
+
const micromatch = require('micromatch');
|
|
419
|
+
|
|
420
|
+
function getStack() {
|
|
421
|
+
return cached('stack', () => discoverMemorySources(currentProjectPath));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function stripContent(source) {
|
|
425
|
+
const { content, ...rest } = source;
|
|
426
|
+
return rest;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// #endregion API_ENDPOINTS
|
|
430
|
+
|
|
431
|
+
// #region EXPRESS
|
|
432
|
+
|
|
433
|
+
const app = express();
|
|
434
|
+
app.use(express.json());
|
|
435
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
436
|
+
|
|
437
|
+
app.get('/hub-config', (_req, res) => {
|
|
438
|
+
res.json({
|
|
439
|
+
name: 'Claude Code Memory',
|
|
440
|
+
icon: 'brain',
|
|
441
|
+
description: 'Explore Claude Code memory sources',
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
app.get('/api/project', (_req, res) => {
|
|
446
|
+
res.json({ path: currentProjectPath, name: path.basename(currentProjectPath) });
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
app.put('/api/project', (req, res) => {
|
|
450
|
+
const { path: dirPath } = req.body;
|
|
451
|
+
if (!dirPath) return res.status(400).json({ error: 'path required' });
|
|
452
|
+
const resolved = path.resolve(dirPath.replace(/^~/, os.homedir()));
|
|
453
|
+
if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'directory not found' });
|
|
454
|
+
currentProjectPath = resolved;
|
|
455
|
+
clearCache();
|
|
456
|
+
res.json({ path: currentProjectPath, name: path.basename(currentProjectPath) });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
app.post('/api/refresh', (_req, res) => {
|
|
460
|
+
clearCache();
|
|
461
|
+
res.json({ ok: true });
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
app.post('/api/open-in-editor', (req, res) => {
|
|
465
|
+
const { path: filePath } = req.body;
|
|
466
|
+
if (!filePath) return res.status(400).json({ error: 'path required' });
|
|
467
|
+
const resolved = filePath.replace(/^~/, os.homedir());
|
|
468
|
+
if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'file not found' });
|
|
469
|
+
const { exec } = require('child_process');
|
|
470
|
+
exec(`code "${resolved}"`, (err) => {
|
|
471
|
+
if (err) return res.status(500).json({ error: err.message });
|
|
472
|
+
res.json({ ok: true });
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
app.get('/api/summary', (_req, res) => {
|
|
477
|
+
const sources = getStack();
|
|
478
|
+
const totalFiles = sources.length;
|
|
479
|
+
const totalLines = sources.reduce((s, f) => s + (f.lines || 0), 0);
|
|
480
|
+
const totalBytes = sources.reduce((s, f) => s + (f.bytes || 0), 0);
|
|
481
|
+
const alwaysLoaded = sources.filter(s => s.load === 'always' || s.load === 'startup').length;
|
|
482
|
+
const conditional = sources.filter(s => s.load === 'conditional').length;
|
|
483
|
+
const onDemand = sources.filter(s => s.load === 'ondemand').length;
|
|
484
|
+
res.json({ totalFiles, totalLines, totalBytes, alwaysLoaded, conditional, onDemand });
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
app.get('/api/stack', (_req, res) => {
|
|
488
|
+
const sources = getStack();
|
|
489
|
+
res.json(sources.map(stripContent));
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
app.get('/api/memory', (_req, res) => {
|
|
493
|
+
const sources = getStack().filter(s => s.scope === 'memory');
|
|
494
|
+
res.json(sources.map(stripContent));
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
app.get('/api/rules', (_req, res) => {
|
|
498
|
+
const sources = getStack().filter(s => s.scope === 'rule');
|
|
499
|
+
res.json(sources.map(stripContent));
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
app.get('/api/rules/match', (req, res) => {
|
|
503
|
+
const filePath = req.query.file;
|
|
504
|
+
if (!filePath) return res.status(400).json({ error: 'file query param required' });
|
|
505
|
+
const rules = getStack().filter(s => s.scope === 'rule');
|
|
506
|
+
const matched = rules.filter(r => {
|
|
507
|
+
if (!r.frontmatter || !r.frontmatter.paths) return true;
|
|
508
|
+
const patterns = Array.isArray(r.frontmatter.paths) ? r.frontmatter.paths : [r.frontmatter.paths];
|
|
509
|
+
return micromatch.isMatch(filePath, patterns);
|
|
510
|
+
});
|
|
511
|
+
res.json(matched.map(stripContent));
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
app.get('/api/file', (req, res) => {
|
|
515
|
+
const filePath = req.query.path;
|
|
516
|
+
if (!filePath) return res.status(400).json({ error: 'path query param required' });
|
|
517
|
+
const sources = getStack();
|
|
518
|
+
const source = sources.find(s => s.path === filePath);
|
|
519
|
+
if (source) return res.json(source);
|
|
520
|
+
const info = fileInfo(filePath);
|
|
521
|
+
if (!info) return res.status(404).json({ error: 'file not found' });
|
|
522
|
+
res.json({ ...info, imports: resolveExistingImports(filePath, info.content) });
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
app.get('/api/imports', (req, res) => {
|
|
526
|
+
const filePath = req.query.path;
|
|
527
|
+
if (!filePath) return res.status(400).json({ error: 'path query param required' });
|
|
528
|
+
const maxDepth = 5;
|
|
529
|
+
const chain = [];
|
|
530
|
+
const visited = new Set();
|
|
531
|
+
|
|
532
|
+
function walk(fp, depth) {
|
|
533
|
+
if (depth > maxDepth || visited.has(fp)) return;
|
|
534
|
+
visited.add(fp);
|
|
535
|
+
const info = fileInfo(fp);
|
|
536
|
+
if (!info) { chain.push({ path: fp, error: 'not found' }); return; }
|
|
537
|
+
const imports = parseImports(info.content);
|
|
538
|
+
const node = { path: fp, lines: info.lines, bytes: info.bytes, imports: [] };
|
|
539
|
+
chain.push(node);
|
|
540
|
+
for (const imp of imports) {
|
|
541
|
+
const resolved = resolveImport(imp, fp);
|
|
542
|
+
node.imports.push(resolved);
|
|
543
|
+
walk(resolved, depth + 1);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
walk(filePath, 0);
|
|
548
|
+
res.json(chain);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// #endregion EXPRESS
|
|
552
|
+
|
|
553
|
+
// #region STARTUP
|
|
554
|
+
|
|
555
|
+
const server = app.listen(PORT, () => {
|
|
556
|
+
const addr = server.address();
|
|
557
|
+
const port = typeof addr === 'object' ? addr.port : PORT;
|
|
558
|
+
console.log(`Claude Code Memory running at http://localhost:${port}`);
|
|
559
|
+
console.log(`Project: ${currentProjectPath}`);
|
|
560
|
+
if (AUTO_OPEN) {
|
|
561
|
+
import('open').then(m => m.default(`http://localhost:${port}`)).catch(() => {});
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
server.on('error', (err) => {
|
|
566
|
+
if (err.code === 'EADDRINUSE') {
|
|
567
|
+
console.log(`Port ${PORT} busy, trying random port...`);
|
|
568
|
+
const retry = app.listen(0, () => {
|
|
569
|
+
const addr = retry.address();
|
|
570
|
+
const port = typeof addr === 'object' ? addr.port : '?';
|
|
571
|
+
console.log(`Claude Code Memory running at http://localhost:${port}`);
|
|
572
|
+
if (AUTO_OPEN) {
|
|
573
|
+
import('open').then(m => m.default(`http://localhost:${port}`)).catch(() => {});
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// #endregion STARTUP
|