claude-code-memory-explorer 0.11.0 → 0.11.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/server.js CHANGED
@@ -598,7 +598,7 @@ function stripContent(source) {
598
598
  const app = express();
599
599
 
600
600
  // Mounted before express.json() so a rejected request never buffers a body.
601
- const net = createNetGuard({ appName: 'Claude Code Memory' });
601
+ const net = createNetGuard({ appName: 'Memory Diagnoser' });
602
602
  app.use(net.hostGuard);
603
603
  app.use(net.frameGuard);
604
604
  app.use(net.originGuard);
@@ -608,7 +608,7 @@ app.use(express.static(path.join(__dirname, 'public')));
608
608
 
609
609
  app.get('/hub-config', (_req, res) => {
610
610
  res.json({
611
- name: 'Claude Code Memory',
611
+ name: 'Memory Diagnoser',
612
612
  icon: 'brain',
613
613
  description: 'Explore Claude Code memory sources',
614
614
  enabled: !!process.env.CLAUDE_HUB,
@@ -711,78 +711,81 @@ const ANALYSIS_SCHEMA = {
711
711
  items: {
712
712
  type: 'object',
713
713
  properties: {
714
- kind: { type: 'string', enum: ['duplicate', 'contradiction', 'promote', 'merge', 'stale', 'invalidate', 'quality'] },
714
+ kind: { type: 'string', enum: ['duplicate', 'contradiction', 'promote', 'merge', 'stale', 'invalidate', 'quality', 'override', 'shadow', 'demote'] },
715
715
  severity: { type: 'string', enum: ['high', 'med', 'low'] },
716
+ scope: { type: 'string', enum: ['user', 'project', 'cross'] },
716
717
  title: { type: 'string' },
717
718
  detail: { type: 'string' },
718
719
  files: { type: 'array', items: { type: 'string' } },
719
720
  suggestion: { type: 'string' },
720
721
  evidence: { type: 'string' },
721
722
  },
722
- required: ['kind', 'severity', 'title', 'detail', 'files', 'suggestion'],
723
+ required: ['kind', 'severity', 'scope', 'title', 'detail', 'files', 'suggestion'],
723
724
  },
724
725
  },
725
726
  },
726
727
  required: ['summary', 'findings'],
727
728
  };
728
729
 
729
- // Generous: the run fans out one verification subagent per audited file.
730
- const ANALYZE_TIMEOUT_MS = 600_000;
730
+ // Analysis state lives in one file per project under ~/.claude/memory-analysis/,
731
+ // named by the same path encoding Claude Code uses for its projects dir. Disk is the
732
+ // single source of truth — in-flight runs are persisted as `pending` entries so they
733
+ // survive page reloads and are visible to other server instances (hub + standalone).
734
+ const ANALYSIS_DIR = path.join(CLAUDE_DIR, 'memory-analysis');
731
735
 
732
- // Analysis results keyed by project path — switching projects keeps each project's
733
- // last result. Completed runs are persisted to disk so they survive server restarts.
734
- const ANALYSIS_STORE = path.join(CLAUDE_DIR, 'claude-code-memory-analysis.json');
735
- const analysisStates = new Map();
736
-
737
- const EMPTY_ANALYSIS = { running: false, result: null, error: null, hash: null, ts: null, ids: null, runs: [] };
736
+ const EMPTY_ANALYSIS = { pending: [], result: null, error: null, hash: null, ts: null, ids: null, runs: [], dismissed: [] };
738
737
  const MAX_ANALYSIS_RUNS = 10;
739
738
 
740
- function hydrateAnalysisEntry(st) {
741
- const loaded = { ...EMPTY_ANALYSIS, ...st, running: false };
742
- // Migrate pre-runs entries: the stored latest result becomes the first history run.
743
- if (loaded.result && !(loaded.runs || []).length)
744
- loaded.runs = [{ result: loaded.result, ts: loaded.ts, hash: loaded.hash, ids: loaded.ids }];
745
- return loaded;
739
+ // Findings about the user-level CLAUDE.md are shared across projects: they live in
740
+ // their own entry so any project's view (and dismissals) sees the same audit.
741
+ // The sentinel contains ':' mid-string, which encodeProjectPath output never keeps,
742
+ // so it cannot collide with a real project file.
743
+ const USER_SCOPE = '::user::';
744
+
745
+ function analysisFile(project) {
746
+ if (project === USER_SCOPE) return path.join(ANALYSIS_DIR, 'user-scope.json');
747
+ return path.join(ANALYSIS_DIR, `${encodeProjectPath(project)}.json`);
746
748
  }
747
749
 
748
- let analysisStoreCache = null; // { mtimeMs, data } — reparse only when the shared file changes
749
- function readAnalysisStore() {
750
- try {
751
- const { mtimeMs } = fs.statSync(ANALYSIS_STORE);
752
- if (analysisStoreCache?.mtimeMs !== mtimeMs) {
753
- analysisStoreCache = { mtimeMs, data: JSON.parse(fs.readFileSync(ANALYSIS_STORE, 'utf-8')) };
754
- }
755
- return analysisStoreCache.data;
756
- } catch {
757
- return {}; // no store yet, or unreadable
758
- }
750
+ // A pending entry whose server died mid-run would otherwise show as running forever.
751
+ // Instead of deleting it (losing the trace), mark it stalled: the UI stops spinning
752
+ // but the entry survives — if its run ever completes, the completion handler still
753
+ // removes it by id and the result surfaces. Runs are never killed, so stalled is a
754
+ // hint ("probably orphaned by a server restart"), not a verdict.
755
+ const PENDING_STALL_MS = 1_800_000;
756
+ function markStalePending(pending) {
757
+ const cutoff = Date.now() - PENDING_STALL_MS;
758
+ return (pending || []).map((p) => (p.startedAt > cutoff ? p : { ...p, stalled: true }));
759
759
  }
760
760
 
761
- // The store file is shared between server instances (hub + standalone), so disk is
762
- // the source of truth whenever this instance has no run in flight for the project.
763
761
  function getAnalysisState(project) {
764
- const cur = analysisStates.get(project);
765
- if (cur?.running) return cur;
766
- const st = readAnalysisStore()[project];
767
- if (!st) {
768
- analysisStates.delete(project);
769
- return EMPTY_ANALYSIS;
762
+ let st = null;
763
+ try {
764
+ st = JSON.parse(fs.readFileSync(analysisFile(project), 'utf-8'));
765
+ } catch {
766
+ return { ...EMPTY_ANALYSIS };
770
767
  }
771
- const loaded = hydrateAnalysisEntry(st);
772
- analysisStates.set(project, loaded);
768
+ const loaded = { ...EMPTY_ANALYSIS, ...st };
769
+ loaded.pending = markStalePending(loaded.pending);
770
+ // result/ts/hash/ids are a projection of the newest run — derived here so no
771
+ // writer can persist them out of sync with the runs history.
772
+ const latest = (loaded.runs || [])[0];
773
+ loaded.result = latest?.result || null;
774
+ loaded.ts = latest?.ts || null;
775
+ loaded.hash = latest?.hash || null;
776
+ loaded.ids = latest ? latest.ids : null;
773
777
  return loaded;
774
778
  }
775
779
 
776
- // Write only this project's entry, merged over what is on disk — a blind full
777
- // rewrite would clobber runs another instance saved since we last read.
778
- function saveAnalysisState(project) {
779
- const disk = readAnalysisStore();
780
- const st = analysisStates.get(project);
781
- if (st && (st.result || st.error || (st.runs || []).length))
782
- disk[project] = { result: st.result, error: st.error, hash: st.hash, ts: st.ts, ids: st.ids, runs: st.runs || [] };
783
- else delete disk[project];
780
+ function saveAnalysisState(project, st) {
781
+ const { pending = [], error = null, runs = [], dismissed = [] } = st;
784
782
  try {
785
- fs.writeFileSync(ANALYSIS_STORE, JSON.stringify(disk), 'utf-8');
783
+ if (!pending.length && !error && !runs.length && !dismissed.length) {
784
+ fs.rmSync(analysisFile(project), { force: true });
785
+ return;
786
+ }
787
+ fs.mkdirSync(ANALYSIS_DIR, { recursive: true });
788
+ fs.writeFileSync(analysisFile(project), JSON.stringify({ pending, error, runs, dismissed }), 'utf-8');
786
789
  } catch { /* best-effort persistence */ }
787
790
  }
788
791
 
@@ -834,7 +837,20 @@ const RUBRIC_CLAUDE_MD = [
834
837
  '- Missing high-value content is also a finding: the build/test/run commands, non-obvious architecture facts, and repo-specific gotchas are exactly what the file exists to carry.',
835
838
  ].join('\n');
836
839
 
837
- function buildAnalyzePrompt(memSources, userClaudeMd, skillSources, agentSources, claudeMdSources) {
840
+ // The user-level CLAUDE.md loads in every session of every project, so its rubric is
841
+ // project-blind: rules must hold everywhere, and verification runs against the machine
842
+ // (PATH, ~/.claude), never against whichever repo happens to be the working directory.
843
+ const RUBRIC_CLAUDE_MD_USER = [
844
+ 'User-level CLAUDE.md criteria (global instructions, loaded in every session of every project):',
845
+ '- 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.',
847
+ '- Self-consistency: flag sections, tags, or mnemonics that contradict or duplicate each other within the file and its imports.',
848
+ '- 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
+ '- Flag emphasis inflation (all-caps, NEVER/ALWAYS without stakes) and vague or no-op rules the model cannot act on.',
850
+ '- Preferences should carry their why: a bare prohibition gets overridden when inconvenient; a one-clause reason makes it generalize.',
851
+ ].join('\n');
852
+
853
+ function buildAnalyzePrompt(memSources, skillSources, agentSources, claudeMdSources, userSrc, userAudited) {
838
854
  const fullSkills = skillSources || [];
839
855
  const fullAgent = agentSources || [];
840
856
  const fullClaudeMd = claudeMdSources || [];
@@ -843,6 +859,8 @@ function buildAnalyzePrompt(memSources, userClaudeMd, skillSources, agentSources
843
859
  if (fullSkills.length) audited.push('project skills (SKILL.md files)');
844
860
  if (fullAgent.length) audited.push('agent memory files');
845
861
  if (fullClaudeMd.length) audited.push('CLAUDE.md instruction files');
862
+ if (userAudited) audited.push('the user-level (global) CLAUDE.md');
863
+ const crossScope = userAudited && fullClaudeMd.length;
846
864
  const parts = [
847
865
  `You are auditing what shapes Claude Code behavior in this project: ${audited.join(', ')}. Find real problems; an empty findings list is a valid answer. Do not pad.`,
848
866
  '',
@@ -854,22 +872,38 @@ function buildAnalyzePrompt(memSources, userClaudeMd, skillSources, agentSources
854
872
  '- stale: content that is outdated — verified against the repo when checkable, or judged from its own text otherwise.',
855
873
  '- invalidate: a rule or memory that verification shows no longer applies at all (the thing it guards against is gone, the file/tool it references was removed, the convention is now enforced elsewhere) — suggest deleting it.',
856
874
  '- quality: content that is vague, missing its why, or unlikely to change agent behavior — reported by the reviewers against their rubrics.',
875
+ ];
876
+ if (crossScope) {
877
+ parts.push(
878
+ '- override: a project rule that contradicts a user-level rule. This is often intentional (a project legitimately specializes global preferences) — suggest making the override explicit ("overrides global X because Y"), not deleting either side.',
879
+ '- shadow: a project rule that restates a user-level rule near-verbatim; the project copy is the deletion candidate.',
880
+ );
881
+ }
882
+ if (userAudited) {
883
+ parts.push('- demote: a user-level rule that is specific to one project or stack and belongs in that project\'s CLAUDE.md — the inverse of promote.');
884
+ }
885
+ parts.push(
857
886
  '',
858
887
  'Rules:',
859
- '- You are the orchestrator; do not review files yourself. Your working directory is the project root. For every audited file, launch the type-matched reviewer subagent via your Agent tool — "memory-reviewer" for memory and agent-memory files, "skill-reviewer" for skills, "claude-md-reviewer" for CLAUDE.md files — all in a single parallel batch. Pass each one only the file name and its full content; the reviewers carry their own criteria and verify claims against the repo.',
860
- '- Your own job: merge the reviewers\' per-file findings and add the cross-file kinds (duplicate, contradiction, merge, promote) by comparing the files side by side.',
888
+ `- You are the orchestrator; do not review files yourself. Your working directory is the project root. For every audited file, launch the type-matched reviewer subagent via your Agent tool — "memory-reviewer" for memory and agent-memory files, "skill-reviewer" for skills, "claude-md-reviewer" for project CLAUDE.md files${userAudited ? ', "claude-md-user-reviewer" for the user-level CLAUDE.md' : ''} — all in a single parallel batch of blocking calls (never background/async agents — you must hold your final report until every reviewer has returned). Pass each one only the file name and its full content; the reviewers carry their own criteria and verify claims against the repo${userAudited ? ' (the user-level reviewer verifies against the machine, never the repo)' : ''}.`,
889
+ `- Your own job: merge the reviewers' per-file findings and add the cross-file kinds (duplicate, contradiction, merge, promote${crossScope ? ', override, shadow' : ''}) by comparing the files side by side.`,
861
890
  '- Verification is a filter: drop any finding a reviewer\'s claim checks disproved, and never report "likely outdated" for a claim a reviewer confirmed still HOLDS. Record what was checked in the finding\'s "evidence" field (one sentence, internal bookkeeping — it is not shown to the user). Omit "evidence" for judgment-only findings.',
862
- '- files: the exact file names involved as listed below (e.g. feedback_foo.md, MEMORY.md, or a skill name like my-skill).',
891
+ '- files: the exact file names involved as listed below (e.g. feedback_foo.md, MEMORY.md, or a skill name like my-skill). Bare names only — never append scope markers like "(user)"; scope is carried by the scope field.',
892
+ '- scope: "user" if the finding only cites the user-level CLAUDE.md (demote findings are always "user"); "cross" if it cites both the user-level file and any project-side file (override, shadow, and promote findings are "cross"); "project" for everything else.',
863
893
  '- suggestion: one actionable sentence (what to merge, delete, rewrite, or promote).',
864
894
  '- severity: high = actively harmful (contradictions, wrong guidance), med = wasted context or confusion, low = polish.',
865
895
  '- summary: 1-2 sentences on the overall health of this set.',
896
+ // The Sonnet orchestrator has been observed serializing findings as XML-ish text
897
+ // inside "summary" (failing schema validation), then "probing" with placeholder
898
+ // payloads until one passed — losing the real report. Spell the output contract out.
899
+ '- Final structured output: every finding goes as an object in the top-level "findings" array; "summary" is a short plain string. Never embed findings inside the summary text, never use XML-style tags in any field, and never submit placeholder or probe output (e.g. "Test", or a truncated findings list) just to pass validation. If your output is rejected for a schema mismatch, resubmit the SAME complete set of findings as valid JSON matching the schema — do not shrink, drop, or simplify it.',
866
900
  '',
867
- 'GLOBAL CLAUDE.md (reference only, do not audit it):',
868
- '<<<',
869
- userClaudeMd || '(none)',
870
- '>>>',
871
- '',
872
- ];
901
+ );
902
+ if (userAudited) {
903
+ parts.push('USER CLAUDE.MD (audit this with claude-md-user-reviewer):', `=== FILE (user): ${userSrc.name} ===`, userSrc.content, '');
904
+ } else {
905
+ parts.push('GLOBAL CLAUDE.md (reference only, do not audit it):', '<<<', userSrc?.content || '(none)', '>>>', '');
906
+ }
873
907
  if (memSources.length) {
874
908
  parts.push('MEMORY FILES:');
875
909
  for (const s of memSources) {
@@ -904,21 +938,31 @@ const ANALYSIS_MODELS = new Set(['sonnet', 'opus', 'fable']);
904
938
  // agent's system prompt — the parent orchestrator only passes file name + content.
905
939
  const REVIEWER_COMMON = [
906
940
  'You review exactly one file that shapes Claude Code behavior; the parent passes its name and full content.',
907
- 'Verify before judging: for every claim that references the repository in your working directory — a file, directory, command, script, flag, or path — check whether it still holds with your read-only tools. Never call something "likely stale" when you can check it.',
941
+ 'Verify before judging: for every claim that references the repository in your working directory — a file, directory, command, script, flag, or path — check whether it still holds with your read-only tools. For tools the file expects on the machine, check existence with `command -v` / `which` / `where` (the only Bash commands you may run). Never call something "likely stale" when you can check it.',
908
942
  'Also judge whether the content is still needed at all: if the thing it guards against is gone (the tool was removed, the convention is enforced elsewhere, the referenced file no longer exists), say so explicitly.',
909
943
  ];
910
944
 
945
+ // The user reviewer swaps repo verification for machine verification — the working
946
+ // directory is an arbitrary project and must not influence a global-file audit.
947
+ const REVIEWER_COMMON_USER = [
948
+ 'You review the user-level (global) CLAUDE.md that loads in every session of every project; the parent passes its name and full content.',
949
+ '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.',
950
+ '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.',
951
+ ];
952
+
911
953
  const REVIEWER_REPORT = [
912
954
  'Report back compactly, nothing else:',
913
955
  '1. CLAIMS: each checkable claim — HOLDS / FAILS / UNCHECKED, with one line of evidence (what you looked at).',
914
956
  '2. FINDINGS: candidate findings for this file only (kind: stale, invalidate, or quality; severity high/med/low; title; detail; one-sentence fix). An empty list is a valid answer — do not pad.',
915
957
  ];
916
958
 
917
- function reviewerAgent(description, rubric) {
959
+ function reviewerAgent(description, rubric, common = REVIEWER_COMMON) {
918
960
  return {
919
961
  description,
920
- prompt: [REVIEWER_COMMON.join('\n'), RUBRIC_CORE, rubric, REVIEWER_REPORT.join('\n')].join('\n\n'),
921
- tools: ['Read', 'Grep', 'Glob'],
962
+ prompt: [common.join('\n'), RUBRIC_CORE, rubric, REVIEWER_REPORT.join('\n')].join('\n\n'),
963
+ // Bash is permission-scoped to command -v / which / where by the spawn's
964
+ // --allowedTools — anything else the reviewer tries is auto-denied in -p mode.
965
+ tools: ['Read', 'Grep', 'Glob', 'Bash'],
922
966
  };
923
967
  }
924
968
 
@@ -935,12 +979,22 @@ const ANALYSIS_AGENTS = {
935
979
  'Reviews and verifies one CLAUDE.md instruction file against the CLAUDE.md rubric and the repository. One per file, in parallel.',
936
980
  RUBRIC_CLAUDE_MD,
937
981
  ),
982
+ 'claude-md-user-reviewer': reviewerAgent(
983
+ 'Reviews and verifies the user-level (global) CLAUDE.md against the user rubric, PATH, and ~/.claude — never against the working repository.',
984
+ RUBRIC_CLAUDE_MD_USER,
985
+ REVIEWER_COMMON_USER,
986
+ ),
938
987
  };
939
988
 
989
+ // Tool-existence checks are the only Bash the reviewers get; everything else
990
+ // stays auto-denied by headless mode's default permissions.
991
+ const ANALYSIS_ALLOWED_TOOLS = ['Bash(command -v:*)', 'Bash(which:*)', 'Bash(where:*)'];
992
+
940
993
  function runClaudeAnalysis(prompt, model, cwd) {
941
994
  return new Promise((resolve) => {
942
995
  const args = ['-p', '--output-format', 'json', '--json-schema', JSON.stringify(ANALYSIS_SCHEMA)];
943
996
  args.push('--agents', JSON.stringify(ANALYSIS_AGENTS));
997
+ args.push('--allowedTools', ...ANALYSIS_ALLOWED_TOOLS);
944
998
  if (model) args.push('--model', model);
945
999
  let child;
946
1000
  try {
@@ -953,8 +1007,9 @@ function runClaudeAnalysis(prompt, model, cwd) {
953
1007
  let out = '';
954
1008
  let err = '';
955
1009
  let done = false;
956
- const finish = (r) => { if (done) return; done = true; clearTimeout(timer); resolve(r); };
957
- const timer = setTimeout(() => { child.kill('SIGKILL'); finish({ ok: false, error: 'Claude Code timed out' }); }, ANALYZE_TIMEOUT_MS);
1010
+ // No timeout: a long run is never killed — it runs until claude exits on its
1011
+ // own. Past PENDING_STALL_MS the UI just shows it as stalled instead of spinning.
1012
+ const finish = (r) => { if (done) return; done = true; resolve(r); };
958
1013
  child.stdout.on('data', (d) => { out += d; });
959
1014
  child.stderr.on('data', (d) => { err += d; });
960
1015
  child.on('error', (e) => finish({ ok: false, error: `claude not found — is Claude Code installed? (${e.message})` }));
@@ -979,7 +1034,7 @@ function analyzableSources(stack) {
979
1034
  (s) =>
980
1035
  s.scope === 'memory' ||
981
1036
  s.scope === 'agent-memory' ||
982
- ((s.scope === 'skill' || CLAUDE_MD_SCOPES.has(s.scope)) && !s.parentId),
1037
+ ((s.scope === 'skill' || s.scope === 'user' || CLAUDE_MD_SCOPES.has(s.scope)) && !s.parentId),
983
1038
  );
984
1039
  }
985
1040
 
@@ -1001,98 +1056,163 @@ function scopeDescription(selected) {
1001
1056
  const skl = n((s) => s.scope === 'skill');
1002
1057
  const agt = n((s) => s.scope === 'agent-memory');
1003
1058
  const cmd = n((s) => CLAUDE_MD_SCOPES.has(s.scope));
1059
+ const usr = n((s) => s.scope === 'user');
1004
1060
  if (mem) parts.push(`${mem} memory`);
1005
1061
  if (skl) parts.push(`${skl} skill${skl === 1 ? '' : 's'}`);
1006
1062
  if (agt) parts.push(`${agt} agent`);
1007
1063
  if (cmd) parts.push(`${cmd} CLAUDE.md`);
1064
+ if (usr) parts.push('user CLAUDE.md');
1008
1065
  return parts.join(' + ');
1009
1066
  }
1010
1067
 
1011
1068
  app.get('/api/memory/analysis', (_req, res) => {
1012
1069
  const st = getAnalysisState(currentProjectPath);
1070
+ const running = st.pending.some((p) => !p.stalled);
1071
+ // The shared user-scope entry rides along on every project's payload; its
1072
+ // staleness tracks the user CLAUDE.md itself, independent of the project.
1073
+ const us = getAnalysisState(USER_SCOPE);
1013
1074
  // Staleness needs a full stack scan + content hash — skip it while a run is in
1014
1075
  // flight (the client polls every 2.5s and stale is meaningless mid-run anyway).
1015
1076
  let stale = false;
1016
- if (!st.running && st.result) {
1017
- const selected = selectAnalysisSources(getStack(), st.ids);
1018
- const hash = selected.length ? memoryContentHash(selected) : null;
1019
- stale = st.hash !== hash;
1077
+ let userStale = false;
1078
+ if (!running && (st.result || us.result)) {
1079
+ const stack = getStack();
1080
+ if (st.result) {
1081
+ const selected = selectAnalysisSources(stack, st.ids);
1082
+ const hash = selected.length ? memoryContentHash(selected) : null;
1083
+ stale = st.hash !== hash;
1084
+ }
1085
+ if (us.result) {
1086
+ const userSrc = stack.find((s) => s.id === 'user-claude-md');
1087
+ userStale = us.hash !== (userSrc ? memoryContentHash([userSrc]) : null);
1088
+ }
1020
1089
  }
1021
1090
  res.json({
1022
- running: st.running,
1091
+ pending: st.pending,
1023
1092
  result: st.result,
1024
1093
  error: st.error,
1025
1094
  ts: st.ts,
1026
1095
  stale,
1027
1096
  project: currentProjectPath,
1028
1097
  runs: st.runs || [],
1098
+ dismissed: st.dismissed || [],
1099
+ user: { result: us.result, ts: us.ts, stale: userStale, runs: us.runs || [], dismissed: us.dismissed || [] },
1029
1100
  });
1030
1101
  });
1031
1102
 
1103
+ // scope:'user' targets the shared user-scope entry, so the action holds across
1104
+ // projects; anything else targets the current project's entry.
1105
+ function analysisTarget(req) {
1106
+ return req.body?.scope === 'user' ? USER_SCOPE : currentProjectPath;
1107
+ }
1108
+
1032
1109
  app.post('/api/memory/analysis/delete-run', (req, res) => {
1033
- const st = getAnalysisState(currentProjectPath);
1034
- if (st.running) return res.status(409).json({ error: 'analysis running' });
1110
+ const target = analysisTarget(req);
1111
+ const st = getAnalysisState(target);
1035
1112
  const ts = req.body?.ts;
1036
1113
  const runs = (st.runs || []).filter((run) => run.ts !== ts);
1037
1114
  if (runs.length === (st.runs || []).length) return res.status(404).json({ error: 'run not found' });
1038
- const latest = runs[0] || null;
1039
- analysisStates.set(currentProjectPath, {
1040
- ...st,
1041
- result: latest?.result || null,
1042
- ts: latest?.ts || null,
1043
- hash: latest?.hash || null,
1044
- ids: latest ? latest.ids : null,
1045
- error: null,
1046
- runs,
1047
- });
1048
- saveAnalysisState(currentProjectPath);
1115
+ saveAnalysisState(target, { ...st, error: null, runs });
1049
1116
  res.json({ ok: true, runs: runs.length });
1050
1117
  });
1051
1118
 
1119
+ // Dismissals persist per project so they survive reloads and re-runs.
1120
+ // Body: {key} adds, {all:true} clears.
1121
+ app.post('/api/memory/analysis/dismiss', (req, res) => {
1122
+ const target = analysisTarget(req);
1123
+ const st = getAnalysisState(target);
1124
+ const set = new Set(st.dismissed || []);
1125
+ if (req.body?.all) set.clear();
1126
+ else {
1127
+ const key = req.body?.key;
1128
+ if (typeof key !== 'string' || !key) return res.status(400).json({ error: 'key required' });
1129
+ set.add(key);
1130
+ }
1131
+ saveAnalysisState(target, { ...st, dismissed: [...set] });
1132
+ res.json({ ok: true, dismissed: [...set] });
1133
+ });
1134
+
1052
1135
  app.post('/api/memory/analyze', (req, res) => {
1053
1136
  const prev = getAnalysisState(currentProjectPath);
1054
- if (prev.running) return res.status(409).json({ error: 'analysis already running' });
1055
1137
  const stack = getStack();
1056
1138
  const ids = req.body?.ids;
1057
1139
  const selected = selectAnalysisSources(stack, ids);
1058
1140
  if (!selected.length) return res.status(404).json({ error: 'nothing to analyze for this scope' });
1059
1141
  const hash = memoryContentHash(selected);
1060
- if (!req.body?.force && prev.result && prev.hash === hash) {
1061
- return res.json({ running: false, cached: true });
1142
+ // The user CLAUDE.md is audited when picked; otherwise it stays reference
1143
+ // context for the 'promote' kind.
1144
+ const userSelected = selected.find((s) => s.scope === 'user');
1145
+ // A user-only run's result lives in the shared user-scope entry, so that is
1146
+ // where its cache hit must be checked.
1147
+ const userOnly = !!userSelected && selected.length === 1;
1148
+ const cacheEntry = userOnly ? getAnalysisState(USER_SCOPE) : prev;
1149
+ if (!req.body?.force && cacheEntry.result && cacheEntry.hash === hash) {
1150
+ return res.json({ cached: true });
1062
1151
  }
1063
1152
  const memSources = selected.filter((s) => s.scope === 'memory');
1064
1153
  const skills = selected.filter((s) => s.scope === 'skill');
1065
1154
  const agentSources = selected.filter((s) => s.scope === 'agent-memory');
1066
1155
  const claudeMdSources = selected.filter((s) => CLAUDE_MD_SCOPES.has(s.scope));
1067
- // The global CLAUDE.md is reference context for the 'promote' kind (never audited itself).
1068
- const userClaudeMd = stack.find((s) => s.id === 'user-claude-md');
1069
- const prompt = buildAnalyzePrompt(memSources, userClaudeMd?.content, skills, agentSources, claudeMdSources);
1156
+ const userSrc = userSelected || stack.find((s) => s.id === 'user-claude-md');
1157
+ const prompt = buildAnalyzePrompt(memSources, skills, agentSources, claudeMdSources, userSrc, !!userSelected);
1070
1158
  const stateIds = Array.isArray(ids) && ids.length ? ids : null;
1071
1159
  const model = ANALYSIS_MODELS.has(req.body?.model) ? req.body.model : null;
1072
1160
  // Capture the project now — the user may switch projects while the run is in flight,
1073
1161
  // and the result must land under the project it was computed for.
1074
1162
  const project = currentProjectPath;
1075
- const prevRuns = prev.runs || [];
1076
- analysisStates.set(project, { running: true, result: null, error: null, hash, ts: null, ids: stateIds, runs: prevRuns });
1077
- runClaudeAnalysis(prompt, model, project).then((r) => {
1163
+ const runId = crypto.randomUUID();
1164
+ const scopeDesc = scopeDescription(selected);
1165
+ saveAnalysisState(project, {
1166
+ ...prev,
1167
+ // Starting a fresh run retires stalled leftovers — the user has moved on.
1168
+ pending: [...prev.pending.filter((p) => !p.stalled), { id: runId, startedAt: Date.now(), model, scopeDesc }],
1169
+ });
1170
+ const userHash = userSelected ? memoryContentHash([userSelected]) : null;
1171
+ // Snapshot the audited files into the run — the stack changes over time, so
1172
+ // historical runs must not re-derive their scope from the live stack. The user
1173
+ // file is owned by the user-scope entry's snapshot, not the project's.
1174
+ // Snapshotting up front also keeps `selected` (full file contents) out of the
1175
+ // completion closure, which can live for hours on a run that never returns.
1176
+ const audited = selected.filter((s) => s.scope !== 'user').map((s) => ({ id: s.id, name: s.name, scope: s.scope, lines: s.lines }));
1177
+ const userAudited = userSelected ? [{ id: userSelected.id, name: userSelected.name, scope: 'user', lines: userSelected.lines }] : null;
1178
+ const userId = userSelected?.id || null;
1179
+ // Prepend a run to an entry's history, newest first, capped.
1180
+ const withRun = (cur, run) => [run, ...(cur.runs || [])].slice(0, MAX_ANALYSIS_RUNS);
1181
+ runClaudeAnalysis(prompt, model, userOnly ? os.homedir() : project).then((r) => {
1078
1182
  const ts = Date.now();
1079
- const result = r.ok ? { ...r.data, costUsd: r.costUsd, durationMs: r.durationMs, scopeDesc: scopeDescription(selected), model } : null;
1080
- // Another instance may have added runs while this one was in flight — merge by ts.
1081
- const diskRuns = readAnalysisStore()[project]?.runs || [];
1082
- const baseRuns = [...new Map([...prevRuns, ...diskRuns].map((run) => [run.ts, run])).values()].sort((a, b) => b.ts - a.ts);
1083
- const runs = (result ? [{ result, ts, hash, ids: stateIds }, ...baseRuns] : baseRuns).slice(0, MAX_ANALYSIS_RUNS);
1084
- analysisStates.set(project, {
1085
- running: false,
1086
- result,
1183
+ let result = r.ok ? { ...r.data, costUsd: r.costUsd, durationMs: r.durationMs, scopeDesc, model } : null;
1184
+ if (result) {
1185
+ // Reviewers sometimes copy the prompt's scope marker into file names
1186
+ // ("CLAUDE.md (user)") — canonicalize before persisting so no consumer sees it.
1187
+ const findings = (result.findings || []).map((f) => ({ ...f, files: (f.files || []).map((n) => n.replace(/\s*\((user|project|local)\)$/, '')) }));
1188
+ result = { ...result, findings };
1189
+ }
1190
+ // Findings about the user CLAUDE.md alone are shared across projects: split them
1191
+ // into the user-scope entry; project and cross findings stay with the project.
1192
+ if (result && userId) {
1193
+ const all = result.findings;
1194
+ const uCur = getAnalysisState(USER_SCOPE);
1195
+ const uRun = {
1196
+ result: { ...result, findings: all.filter((f) => f.scope === 'user'), scopeDesc: 'user CLAUDE.md' },
1197
+ ts,
1198
+ hash: userHash,
1199
+ ids: [userId],
1200
+ audited: userAudited,
1201
+ };
1202
+ saveAnalysisState(USER_SCOPE, { ...uCur, runs: withRun(uCur, uRun) });
1203
+ result = { ...result, findings: all.filter((f) => f.scope !== 'user') };
1204
+ }
1205
+ // Re-read from disk — parallel runs or another instance may have finished meanwhile.
1206
+ const cur = getAnalysisState(project);
1207
+ const runs = result && !userOnly ? withRun(cur, { result, ts, hash, ids: stateIds, audited }) : cur.runs || [];
1208
+ saveAnalysisState(project, {
1209
+ ...cur,
1210
+ pending: cur.pending.filter((p) => p.id !== runId),
1087
1211
  error: r.ok ? null : r.error,
1088
- hash,
1089
- ts,
1090
- ids: stateIds,
1091
1212
  runs,
1092
1213
  });
1093
- saveAnalysisState(project);
1094
1214
  });
1095
- res.status(202).json({ running: true });
1215
+ res.status(202).json({ ok: true });
1096
1216
  });
1097
1217
 
1098
1218
  // #endregion ANALYZER
@@ -1121,7 +1241,17 @@ app.get('/api/summary', (_req, res) => {
1121
1241
  const stack = getStack();
1122
1242
  // Memory topic files load on demand like skill bodies — only the startup MEMORY.md
1123
1243
  // index is standing cost, so ondemand memory-scope files stay out of the footprint.
1124
- const sources = stack.filter(s => s.scope !== 'skill' && s.load !== 'link'
1244
+ // Nested CLAUDE.md files (load:'tree') load progressively when Claude works in their
1245
+ // directory, so they and their import chains are not standing cost either.
1246
+ const treeIds = new Set(stack.filter(s => s.load === 'tree').map(s => s.id));
1247
+ let grew = true;
1248
+ while (grew) {
1249
+ grew = false;
1250
+ for (const s of stack) {
1251
+ if (s.parentId && treeIds.has(s.parentId) && !treeIds.has(s.id)) { treeIds.add(s.id); grew = true; }
1252
+ }
1253
+ }
1254
+ const sources = stack.filter(s => s.scope !== 'skill' && s.load !== 'link' && !treeIds.has(s.id)
1125
1255
  && !((s.scope === 'memory' || s.scope === 'agent-memory') && s.load === 'ondemand'));
1126
1256
  const totalFiles = sources.length;
1127
1257
  const totalLines = sources.reduce((s, f) => s + (f.lines || 0), 0);
@@ -1135,7 +1265,8 @@ app.get('/api/summary', (_req, res) => {
1135
1265
  const scopeChars = {};
1136
1266
  for (const f of sources) scopeChars[f.scope] = (scopeChars[f.scope] || 0) + (f.chars || 0);
1137
1267
  const totalChars = sources.reduce((s, f) => s + (f.chars || 0), 0) + skillDesc.chars;
1138
- res.json({ totalFiles, totalLines, totalBytes, totalChars, scopeChars, skillDesc, alwaysLoaded, conditional, onDemand });
1268
+ // ids: which sources make up the footprint, so the client can highlight them
1269
+ res.json({ totalFiles, totalLines, totalBytes, totalChars, scopeChars, skillDesc, alwaysLoaded, conditional, onDemand, ids: sources.map(s => s.id) });
1139
1270
  });
1140
1271
 
1141
1272
  app.get('/api/stack', (_req, res) => {
@@ -1207,7 +1338,7 @@ app.get('/api/imports', (req, res) => {
1207
1338
  // #region STARTUP
1208
1339
 
1209
1340
  const onReady = (port) => {
1210
- console.log(`Claude Code Memory running at http://localhost:${port}`);
1341
+ console.log(`Memory Diagnoser running at http://localhost:${port}`);
1211
1342
  console.log(`Project: ${currentProjectPath}`);
1212
1343
  const warning = net.exposureWarning();
1213
1344
  if (warning) console.log(warning);