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 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
 
@@ -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
- if (code !== 0) return reject(new Error(stderr.trim() || `Command exited with code ${code}`));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-memory-explorer",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Memory explorer dashboard for Claude Code",
5
5
  "main": "server.js",
6
6
  "bin": {
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 = true; // health dashboard is the default view; selecting a file exits it
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('recentProjects') || '[]');
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('recentProjects', JSON.stringify(recent.slice(0, RECENT_PROJECTS_MAX)));
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('recentProjects', JSON.stringify(getRecentProjects().filter((p) => p !== target)));
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 || '~/.claude/CLAUDE.md';
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
- const fwd = (e) => hubPost({ type: 'hub:keydown', key: e.key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
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
- if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
2150
+ const fwd = () => {
2098
2151
  e.preventDefault();
2099
- fwd(e);
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
- // Its own branch: the Alt+digit case below requires !ctrlKey.
2102
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'p') {
2103
- e.preventDefault();
2104
- fwd(e);
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
- e.preventDefault();
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 CLAUDE_DIR = claudeDirArg ? expandHome(claudeDirArg) : path.join(os.homedir(), '.claude');
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 userClaudeMd = path.join(CLAUDE_DIR, 'CLAUDE.md');
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
- '- 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. NEVER verify against the working directory\'s repository — it is an arbitrary project and proves nothing about a global rule.',
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
- '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 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.',
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
- 'Reviews and verifies the user-level (global) CLAUDE.md against the user rubric, PATH, and ~/.claude — never against the working repository.',
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
- res.json({ totalFiles, totalLines, totalBytes, totalChars, scopeChars, skillDesc, alwaysLoaded, conditional, onDemand, ids: sources.map(s => s.id) });
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) => {