claude-code-memory-explorer 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/open-editor.js +4 -1
- package/package.json +1 -1
- package/public/app.js +71 -15
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/index.html +5 -0
- package/server.js +16 -9
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ PORT=8080 Custom port (default: 3544, falls back if busy)
|
|
|
35
35
|
--open Open browser on start
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
The config dir can also be set via the `CLAUDE_CONFIG_DIR` environment variable. Switch projects at runtime with the picker (Shift+P) or `?project=/path`.
|
|
38
|
+
The config dir can also be set via the `CLAUDE_CONFIG_DIR` environment variable; the analyzer's headless `claude` run inherits it. Switch projects at runtime with the picker (Shift+P) or `?project=/path`.
|
|
39
39
|
|
|
40
40
|
## License
|
|
41
41
|
|
package/lib/open-editor.js
CHANGED
|
@@ -221,6 +221,7 @@ function execNoShell(cmd, args, opts = {}) {
|
|
|
221
221
|
let argv = args;
|
|
222
222
|
const spawnOpts = {
|
|
223
223
|
cwd: opts.cwd || undefined,
|
|
224
|
+
env: opts.env,
|
|
224
225
|
timeout: opts.timeout,
|
|
225
226
|
windowsHide: true,
|
|
226
227
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -258,7 +259,9 @@ function execNoShell(cmd, args, opts = {}) {
|
|
|
258
259
|
child.on('error', reject);
|
|
259
260
|
child.on('close', (code, signal) => {
|
|
260
261
|
if (signal) return reject(new Error(`Command terminated by ${signal}: ${stderr.trim()}`));
|
|
261
|
-
|
|
262
|
+
// The claude CLI reports its own failures on stdout, so stderr alone is
|
|
263
|
+
// often empty and the caller would surface only the exit code.
|
|
264
|
+
if (code !== 0) return reject(new Error(stderr.trim() || stdout.trim() || `Command exited with code ${code}`));
|
|
262
265
|
resolve({ stdout, stderr });
|
|
263
266
|
});
|
|
264
267
|
});
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -4,7 +4,7 @@ let projectData = null;
|
|
|
4
4
|
let stackData = [];
|
|
5
5
|
let summaryData = null;
|
|
6
6
|
let selectedFileId = null;
|
|
7
|
-
let healthViewOpen =
|
|
7
|
+
let healthViewOpen = false; // opt-in view; the Analyze button toggles it, selecting a file exits it
|
|
8
8
|
|
|
9
9
|
// #endregion STATE
|
|
10
10
|
|
|
@@ -209,6 +209,8 @@ async function loadProject() {
|
|
|
209
209
|
// Shared by the project picker, the boot restore, and the hub project shim. Throws with the
|
|
210
210
|
// server's error message so callers can surface it directly.
|
|
211
211
|
async function putProject(dirPath) {
|
|
212
|
+
// Recents can only be written under the right key once the config dir is known.
|
|
213
|
+
await projectInfoPromise;
|
|
212
214
|
const res = await fetch('/api/project', {
|
|
213
215
|
method: 'PUT',
|
|
214
216
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -248,10 +250,40 @@ async function submitProjectPicker() {
|
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
const RECENT_PROJECTS_MAX = 20;
|
|
253
|
+
const LEGACY_RECENTS_KEY = 'recentProjects';
|
|
254
|
+
|
|
255
|
+
// Under the hub every config dir's memory app is proxied on one public port, so localStorage is
|
|
256
|
+
// shared between them. Recents are keyed by config dir to keep each one's project separate.
|
|
257
|
+
let claudeConfigDir = '';
|
|
258
|
+
// Retries like the initial load below: in a hub iframe the server may not answer yet.
|
|
259
|
+
const projectInfoPromise = (async () => {
|
|
260
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
261
|
+
try {
|
|
262
|
+
const info = await fetchJSON('/api/project');
|
|
263
|
+
claudeConfigDir = info.configDir || '';
|
|
264
|
+
migrateLegacyRecents();
|
|
265
|
+
return info;
|
|
266
|
+
} catch {
|
|
267
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return {};
|
|
271
|
+
})();
|
|
272
|
+
|
|
273
|
+
function recentsKey() {
|
|
274
|
+
return claudeConfigDir ? `${LEGACY_RECENTS_KEY}::${claudeConfigDir}` : LEGACY_RECENTS_KEY;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function migrateLegacyRecents() {
|
|
278
|
+
const legacy = localStorage.getItem(LEGACY_RECENTS_KEY);
|
|
279
|
+
if (!legacy || !claudeConfigDir) return;
|
|
280
|
+
if (!localStorage.getItem(recentsKey())) localStorage.setItem(recentsKey(), legacy);
|
|
281
|
+
localStorage.removeItem(LEGACY_RECENTS_KEY);
|
|
282
|
+
}
|
|
251
283
|
|
|
252
284
|
function getRecentProjects() {
|
|
253
285
|
try {
|
|
254
|
-
return JSON.parse(localStorage.getItem(
|
|
286
|
+
return JSON.parse(localStorage.getItem(recentsKey()) || '[]');
|
|
255
287
|
} catch {
|
|
256
288
|
return [];
|
|
257
289
|
}
|
|
@@ -260,7 +292,7 @@ function getRecentProjects() {
|
|
|
260
292
|
function addRecentProject(p) {
|
|
261
293
|
const recent = getRecentProjects().filter((r) => r !== p);
|
|
262
294
|
recent.unshift(p);
|
|
263
|
-
localStorage.setItem(
|
|
295
|
+
localStorage.setItem(recentsKey(), JSON.stringify(recent.slice(0, RECENT_PROJECTS_MAX)));
|
|
264
296
|
}
|
|
265
297
|
|
|
266
298
|
// The recents list is the switcher; typing a path is the secondary act behind "+ Add path".
|
|
@@ -330,7 +362,7 @@ function _removeRecentProject(idx, e) {
|
|
|
330
362
|
e.stopPropagation();
|
|
331
363
|
const target = pickerRows[idx];
|
|
332
364
|
if (!target) return;
|
|
333
|
-
localStorage.setItem(
|
|
365
|
+
localStorage.setItem(recentsKey(), JSON.stringify(getRecentProjects().filter((p) => p !== target)));
|
|
334
366
|
renderProjectPicker();
|
|
335
367
|
}
|
|
336
368
|
|
|
@@ -356,6 +388,9 @@ function initProjectPicker() {
|
|
|
356
388
|
e.preventDefault();
|
|
357
389
|
e.stopPropagation();
|
|
358
390
|
});
|
|
391
|
+
const modal = document.getElementById('projectPickerModal');
|
|
392
|
+
onEscapeBlur(filter, modal, () => closeModal('projectPickerModal'));
|
|
393
|
+
onEscapeBlur(document.getElementById('projectPathInput'), modal, () => setPickerMode(false));
|
|
359
394
|
}
|
|
360
395
|
|
|
361
396
|
// #endregion PROJECT
|
|
@@ -366,6 +401,22 @@ function closeModal(id) {
|
|
|
366
401
|
document.getElementById(id).classList.remove('open');
|
|
367
402
|
}
|
|
368
403
|
|
|
404
|
+
// Vimium eats Escape inside a text input and only blurs it, so the page never sees the key.
|
|
405
|
+
// A blur that no click in the modal caused, while the window keeps focus and the input is
|
|
406
|
+
// still shown, is that Escape.
|
|
407
|
+
function onEscapeBlur(input, modal, handler) {
|
|
408
|
+
let pointerDown = false;
|
|
409
|
+
modal.addEventListener('mousedown', () => {
|
|
410
|
+
pointerDown = true;
|
|
411
|
+
});
|
|
412
|
+
document.addEventListener('mouseup', () => {
|
|
413
|
+
pointerDown = false;
|
|
414
|
+
});
|
|
415
|
+
input.addEventListener('blur', () => {
|
|
416
|
+
if (!pointerDown && document.hasFocus() && input.checkVisibility()) handler();
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
369
420
|
// Each entry pairs a left and a right group onto the same grid rows, so their
|
|
370
421
|
// headings sit level and the shorter group just leaves empty rows.
|
|
371
422
|
// `combo` joins the keys with a plus (a chord) instead of listing them as
|
|
@@ -1361,7 +1412,7 @@ function findingPrompt(f) {
|
|
|
1361
1412
|
} else if (paths.length > 1) {
|
|
1362
1413
|
header = 'In the Claude Code instruction files listed below, apply this fix:';
|
|
1363
1414
|
} else if (f.scope === 'user') {
|
|
1364
|
-
const path = stackData.find((s) => s.id === 'user-claude-md')?.path ||
|
|
1415
|
+
const path = stackData.find((s) => s.id === 'user-claude-md')?.path || summaryData.userClaudeMd;
|
|
1365
1416
|
header = `In the user-level Claude Code instructions at ${path}, apply this fix:`;
|
|
1366
1417
|
} else {
|
|
1367
1418
|
const dir = memoryDirPath();
|
|
@@ -2092,20 +2143,24 @@ document.addEventListener('keydown', (e) => {
|
|
|
2092
2143
|
.catch(() => ({}));
|
|
2093
2144
|
if (!cfg.enabled) return;
|
|
2094
2145
|
window.__HUB__ = cfg;
|
|
2095
|
-
|
|
2146
|
+
// e.code travels with e.key because macOS composes Option+<key> into a character (Option+P is
|
|
2147
|
+
// 'π'), so the key alone cannot identify the binding. The hub owns the keymap and normalizes;
|
|
2148
|
+
// these tests only decide whether a press is the hub's to handle.
|
|
2096
2149
|
document.addEventListener('keydown', (e) => {
|
|
2097
|
-
|
|
2150
|
+
const fwd = () => {
|
|
2098
2151
|
e.preventDefault();
|
|
2099
|
-
|
|
2152
|
+
hubPost({ type: 'hub:keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
|
|
2153
|
+
};
|
|
2154
|
+
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
2155
|
+
fwd();
|
|
2100
2156
|
}
|
|
2101
|
-
//
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
fwd(
|
|
2157
|
+
// Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
|
|
2158
|
+
// keymap and ignores unbound letters.
|
|
2159
|
+
if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && (/^[a-z]$/i.test(e.key) || /^Key[A-Z]$/.test(e.code))) {
|
|
2160
|
+
fwd();
|
|
2105
2161
|
}
|
|
2106
|
-
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && /^[1-9]$/.test(e.key)) {
|
|
2107
|
-
|
|
2108
|
-
fwd(e);
|
|
2162
|
+
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code))) {
|
|
2163
|
+
fwd();
|
|
2109
2164
|
}
|
|
2110
2165
|
});
|
|
2111
2166
|
})();
|
|
@@ -2247,6 +2302,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
2247
2302
|
let desiredProject = params.get('project');
|
|
2248
2303
|
// A hub-pushed project outranks the localStorage recent: initHub() is fire-and-forget at script
|
|
2249
2304
|
// eval, so hub:project can land before or during this block.
|
|
2305
|
+
await projectInfoPromise;
|
|
2250
2306
|
if (!desiredProject) desiredProject = hubProjectPath || getRecentProjects()[0] || null;
|
|
2251
2307
|
if (desiredProject) {
|
|
2252
2308
|
try {
|
|
Binary file
|
|
Binary file
|
package/public/index.html
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
|
|
15
15
|
<link rel="manifest" href="/manifest.json">
|
|
16
16
|
<link rel="icon" type="image/svg+xml" href="/icons/icon-svg.svg">
|
|
17
|
+
<!-- Chrome uses the SVG only in the tab strip; app windows, shortcuts and the
|
|
18
|
+
history list need a raster icon or they fall back to /favicon.ico and then to
|
|
19
|
+
whatever icon localhost was last seen with, which is a sibling app's. -->
|
|
20
|
+
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
|
|
21
|
+
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
|
|
17
22
|
<meta name="theme-color" content="#101114" media="(prefers-color-scheme: dark)">
|
|
18
23
|
<meta name="theme-color" content="#e8e6e3" media="(prefers-color-scheme: light)">
|
|
19
24
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
package/server.js
CHANGED
|
@@ -25,7 +25,13 @@ const expandHome = (p) => (typeof p === 'string' ? p : '').replace(/^~/, os.home
|
|
|
25
25
|
const PORT = getArg('port') || process.env.PORT || 3544;
|
|
26
26
|
const AUTO_OPEN = process.argv.includes('--open');
|
|
27
27
|
const claudeDirArg = getArg('dir') || process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR;
|
|
28
|
-
const
|
|
28
|
+
const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
29
|
+
const CLAUDE_DIR = claudeDirArg ? expandHome(claudeDirArg) : DEFAULT_CLAUDE_DIR;
|
|
30
|
+
const USER_CLAUDE_MD = path.join(CLAUDE_DIR, 'CLAUDE.md');
|
|
31
|
+
// Claude Code keeps .claude.json beside ~/.claude by default but inside CLAUDE_CONFIG_DIR
|
|
32
|
+
// when that is set, so the default dir must stay unset for the CLI rather than be spelled out.
|
|
33
|
+
const IS_DEFAULT_CLAUDE_DIR = path.resolve(CLAUDE_DIR) === path.resolve(DEFAULT_CLAUDE_DIR);
|
|
34
|
+
const CLI_ENV = IS_DEFAULT_CLAUDE_DIR ? process.env : { ...process.env, CLAUDE_CONFIG_DIR: CLAUDE_DIR };
|
|
29
35
|
const projectDirArg = getArg('project');
|
|
30
36
|
|
|
31
37
|
// #endregion CLI_ARGS
|
|
@@ -233,8 +239,7 @@ function discoverMemorySources(projectPath) {
|
|
|
233
239
|
}
|
|
234
240
|
|
|
235
241
|
// 2. User CLAUDE.md
|
|
236
|
-
const
|
|
237
|
-
const userInfo = fileInfo(userClaudeMd);
|
|
242
|
+
const userInfo = fileInfo(USER_CLAUDE_MD);
|
|
238
243
|
if (userInfo) {
|
|
239
244
|
sources.push({
|
|
240
245
|
id: 'user-claude-md',
|
|
@@ -617,7 +622,7 @@ app.get('/hub-config', (_req, res) => {
|
|
|
617
622
|
});
|
|
618
623
|
|
|
619
624
|
app.get('/api/project', (_req, res) => {
|
|
620
|
-
res.json({ path: currentProjectPath, name: path.basename(currentProjectPath) });
|
|
625
|
+
res.json({ path: currentProjectPath, name: path.basename(currentProjectPath), configDir: CLAUDE_DIR });
|
|
621
626
|
});
|
|
622
627
|
|
|
623
628
|
app.put('/api/project', (req, res) => {
|
|
@@ -843,7 +848,7 @@ const RUBRIC_CLAUDE_MD = [
|
|
|
843
848
|
const RUBRIC_CLAUDE_MD_USER = [
|
|
844
849
|
'User-level CLAUDE.md criteria (global instructions, loaded in every session of every project):',
|
|
845
850
|
'- Universality: every rule must hold on any project. Flag rules referencing one specific repo, stack, tool chain, or project path — they belong in that project\'s CLAUDE.md (kind: demote).',
|
|
846
|
-
|
|
851
|
+
`- Machine verification only: check that tools the file names exist on PATH (command -v / which / where) and that files, skills, or agents it references exist under ${CLAUDE_DIR}. NEVER verify against the working directory's repository — it is an arbitrary project and proves nothing about a global rule.`,
|
|
847
852
|
'- Self-consistency: flag sections, tags, or mnemonics that contradict or duplicate each other within the file and its imports.',
|
|
848
853
|
'- Executability: flag standards name-dropped without concrete behaviors ("follow style guide X") and rules the model cannot act on — require the 2-3 specific behaviors the user actually wants.',
|
|
849
854
|
'- Flag emphasis inflation (all-caps, NEVER/ALWAYS without stakes) and vague or no-op rules the model cannot act on.',
|
|
@@ -970,7 +975,7 @@ const REVIEWER_COMMON = [
|
|
|
970
975
|
// directory is an arbitrary project and must not influence a global-file audit.
|
|
971
976
|
const REVIEWER_COMMON_USER = [
|
|
972
977
|
'You review the user-level (global) CLAUDE.md that loads in every session of every project; the parent passes its name and full content.',
|
|
973
|
-
|
|
978
|
+
`Verify before judging, against the machine only: check tools the file names with \`command -v\` / \`which\` / \`where\` (the only Bash commands you may run), and check files, skills, or agents it references under ${CLAUDE_DIR} with your read tools. NEVER read or judge against the repository in your working directory — it is one arbitrary project and proves nothing about a global rule.`,
|
|
974
979
|
'Also judge whether the content is still needed at all: if the tool it references is gone from the machine or the file it points to no longer exists, say so explicitly.',
|
|
975
980
|
'A check you could not run is not evidence of absence. If a probe is denied by permissions or errors out, report that claim as UNCHECKED and say why — never turn a failed check into a stale or invalidate finding.',
|
|
976
981
|
];
|
|
@@ -1019,7 +1024,7 @@ const ANALYSIS_AGENTS = {
|
|
|
1019
1024
|
[RUBRIC_CLAUDE_MD, RUBRIC_IMPORTS],
|
|
1020
1025
|
),
|
|
1021
1026
|
'claude-md-user-reviewer': reviewerAgent(
|
|
1022
|
-
|
|
1027
|
+
`Reviews and verifies the user-level (global) CLAUDE.md against the user rubric, PATH, and ${CLAUDE_DIR} — never against the working repository.`,
|
|
1023
1028
|
[RUBRIC_CLAUDE_MD_USER, RUBRIC_IMPORTS],
|
|
1024
1029
|
REVIEWER_COMMON_USER,
|
|
1025
1030
|
),
|
|
@@ -1039,7 +1044,7 @@ function runClaudeAnalysis(prompt, model, cwd) {
|
|
|
1039
1044
|
try {
|
|
1040
1045
|
// cwd = the project root so the agent can verify claims against the actual
|
|
1041
1046
|
// files with its read-only tools (Read/Glob/Grep need no permission grants).
|
|
1042
|
-
child = spawn('claude', args, { cwd: cwd || os.tmpdir(), windowsHide: true });
|
|
1047
|
+
child = spawn('claude', args, { cwd: cwd || os.tmpdir(), env: CLI_ENV, windowsHide: true });
|
|
1043
1048
|
} catch (e) {
|
|
1044
1049
|
return resolve({ ok: false, error: `Failed to spawn claude: ${e.message}` });
|
|
1045
1050
|
}
|
|
@@ -1313,7 +1318,9 @@ app.get('/api/summary', (_req, res) => {
|
|
|
1313
1318
|
for (const f of sources) scopeChars[f.scope] = (scopeChars[f.scope] || 0) + (f.chars || 0);
|
|
1314
1319
|
const totalChars = sources.reduce((s, f) => s + (f.chars || 0), 0) + skillDesc.chars;
|
|
1315
1320
|
// ids: which sources make up the footprint, so the client can highlight them
|
|
1316
|
-
|
|
1321
|
+
// userClaudeMd: where the user-level file lives even when it does not exist yet, so the
|
|
1322
|
+
// client can name it in fix prompts without assuming ~/.claude
|
|
1323
|
+
res.json({ totalFiles, totalLines, totalBytes, totalChars, scopeChars, skillDesc, alwaysLoaded, conditional, onDemand, ids: sources.map(s => s.id), userClaudeMd: USER_CLAUDE_MD });
|
|
1317
1324
|
});
|
|
1318
1325
|
|
|
1319
1326
|
app.get('/api/stack', (_req, res) => {
|