claude-code-memory-explorer 0.9.1 → 0.11.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/server.js CHANGED
@@ -5,7 +5,10 @@ const express = require('express');
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
7
  const os = require('os');
8
- const { execFileSync } = require('child_process');
8
+ const { execFileSync, spawn } = require('child_process');
9
+ const crypto = require('crypto');
10
+ const { assertOpenTarget, openInEditor } = require('./lib/open-editor');
11
+ const { createNetGuard } = require('./lib/net-guard');
9
12
 
10
13
  // #region CLI_ARGS
11
14
 
@@ -17,7 +20,7 @@ function getArg(name) {
17
20
  return process.argv[eqIdx + 1] || null;
18
21
  }
19
22
 
20
- const expandHome = (p) => (p || '').replace(/^~/, os.homedir());
23
+ const expandHome = (p) => (typeof p === 'string' ? p : '').replace(/^~/, os.homedir());
21
24
 
22
25
  const PORT = getArg('port') || process.env.PORT || 3544;
23
26
  const AUTO_OPEN = process.argv.includes('--open');
@@ -65,34 +68,78 @@ function fileInfo(filePath) {
65
68
  const content = fs.readFileSync(filePath, 'utf-8');
66
69
  const lines = content.split('\n').length;
67
70
  const bytes = Buffer.byteLength(content, 'utf-8');
71
+ const chars = content.length;
68
72
  const frontmatter = parseFrontmatter(content);
69
- return { path: filePath, content, lines, bytes, frontmatter };
73
+ return { path: filePath, content, lines, bytes, chars, frontmatter };
70
74
  } catch {
71
75
  return null;
72
76
  }
73
77
  }
74
78
 
79
+ function stripQuotes(val) {
80
+ if (val.length >= 2 && ((val[0] === '"' && val.endsWith('"')) || (val[0] === "'" && val.endsWith("'")))) {
81
+ return val.slice(1, -1);
82
+ }
83
+ return val;
84
+ }
85
+
75
86
  function parseFrontmatter(content) {
76
87
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
77
88
  if (!match) return null;
89
+ const lines = match[1].split(/\r?\n/);
78
90
  const fm = {};
79
- for (const line of match[1].split('\n')) {
80
- const kv = line.match(/^(\w+):\s*(.*)/);
81
- if (kv) {
82
- const val = kv[2].trim();
83
- if (!val) {
84
- fm[kv[1]] = [];
85
- } else if (val.startsWith('[') || val.startsWith('"')) {
86
- try { fm[kv[1]] = JSON.parse(val.replace(/'/g, '"')); } catch { fm[kv[1]] = val; }
87
- } else {
88
- fm[kv[1]] = val;
89
- }
91
+ let i = 0;
92
+ while (i < lines.length) {
93
+ const kv = lines[i].match(/^([\w-]+):\s*(.*)$/);
94
+ if (!kv) {
95
+ i++;
96
+ continue;
90
97
  }
91
- const arrItem = line.match(/^\s+-\s+"?(.+?)"?\s*$/);
92
- if (arrItem) {
93
- const lastKey = Object.keys(fm).pop();
94
- if (lastKey && !Array.isArray(fm[lastKey])) fm[lastKey] = [];
95
- if (lastKey) fm[lastKey].push(arrItem[1]);
98
+ const key = kv[1];
99
+ const val = kv[2].trim();
100
+ i++;
101
+ if (/^[>|][+-]?$/.test(val)) {
102
+ // Block scalar (>, >-, |, |-): consume the indented block that follows
103
+ const block = [];
104
+ while (i < lines.length && (/^\s/.test(lines[i]) || !lines[i].trim())) {
105
+ block.push(lines[i]);
106
+ i++;
107
+ }
108
+ while (block.length && !block[block.length - 1].trim()) block.pop();
109
+ const indents = block.filter((l) => l.trim()).map((l) => l.match(/^\s*/)[0].length);
110
+ const indent = indents.length ? Math.min(...indents) : 0;
111
+ const body = block.map((l) => l.slice(indent));
112
+ // Folded (>) joins lines with spaces; literal (|) keeps newlines
113
+ fm[key] = val[0] === '>' ? body.join(' ').replace(/\s+/g, ' ').trim() : body.join('\n');
114
+ } else if (!val) {
115
+ // Nested block: list items or a child map
116
+ const arr = [];
117
+ const map = {};
118
+ while (i < lines.length && (/^\s/.test(lines[i]) || !lines[i].trim())) {
119
+ const item = lines[i].match(/^\s+-\s+"?(.+?)"?\s*$/);
120
+ const child = lines[i].match(/^\s+([\w-]+):\s*(.*)$/);
121
+ if (item) arr.push(item[1]);
122
+ else if (child) map[child[1]] = stripQuotes(child[2].trim());
123
+ i++;
124
+ }
125
+ fm[key] = arr.length ? arr : Object.keys(map).length ? map : [];
126
+ } else if (val.startsWith('[')) {
127
+ try {
128
+ fm[key] = JSON.parse(val.replace(/'/g, '"'));
129
+ } catch {
130
+ fm[key] = val;
131
+ }
132
+ } else {
133
+ // Plain scalar; YAML folds indented continuation lines into the value
134
+ let scalar = val;
135
+ while (
136
+ i < lines.length && lines[i].trim() && /^\s/.test(lines[i]) &&
137
+ !/^\s+(?:[\w-]+:|-\s)/.test(lines[i])
138
+ ) {
139
+ scalar += ` ${lines[i].trim()}`;
140
+ i++;
141
+ }
142
+ fm[key] = stripQuotes(scalar);
96
143
  }
97
144
  }
98
145
  return Object.keys(fm).length ? fm : null;
@@ -243,6 +290,7 @@ function discoverMemorySources(projectPath) {
243
290
 
244
291
  // 4b. Scan subdirectories of projectPath for CLAUDE.md (tree-scoped)
245
292
  const SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next', '.nuxt', 'vendor', '__pycache__', '.venv', 'venv']);
293
+ const nestedSkillDirs = [];
246
294
  function walkForClaudeMd(dir, depth) {
247
295
  if (depth > 5) return;
248
296
  let entries;
@@ -268,6 +316,10 @@ function discoverMemorySources(projectPath) {
268
316
  ...spreadImports(info.path, info.content),
269
317
  });
270
318
  }
319
+ const nestedSkills = path.join(subdir, '.claude', 'skills');
320
+ if (fs.existsSync(nestedSkills)) {
321
+ nestedSkillDirs.push({ dir: nestedSkills, rel: path.relative(projectPath, subdir).replace(/\\/g, '/') });
322
+ }
271
323
  walkForClaudeMd(subdir, depth + 1);
272
324
  }
273
325
  }
@@ -291,6 +343,30 @@ function discoverMemorySources(projectPath) {
291
343
  }
292
344
  }
293
345
 
346
+ // 5b. Project skills (.claude/skills, incl. nested subdirectory skill dirs)
347
+ function pushSkill(file, skillSource, extra = {}) {
348
+ const info = fileInfo(file);
349
+ if (!info) return;
350
+ const dirName = path.basename(path.dirname(file));
351
+ const skillName = (info.frontmatter && typeof info.frontmatter.name === 'string' && info.frontmatter.name) || dirName;
352
+ const display = extra.nestedRel ? `${extra.nestedRel}:${skillName}` : skillName;
353
+ sources.push({
354
+ id: `skill-${skillSource}-${slug(file)}`,
355
+ name: display,
356
+ scope: 'skill',
357
+ load: 'ondemand',
358
+ skillSource,
359
+ skillName,
360
+ ...extra,
361
+ ...info,
362
+ ...spreadImports(info.path, info.content),
363
+ });
364
+ }
365
+ for (const file of findSkillFiles(path.join(projectPath, '.claude', 'skills'))) pushSkill(file, 'project');
366
+ for (const { dir, rel } of nestedSkillDirs) {
367
+ for (const file of findSkillFiles(dir)) pushSkill(file, 'project', { nestedRel: rel });
368
+ }
369
+
294
370
  function pushMemoryDir(dir, scope, idPrefix, extraFields = {}) {
295
371
  const memoryMd = path.join(dir, 'MEMORY.md');
296
372
  const memInfo = fileInfo(memoryMd);
@@ -396,15 +472,20 @@ function discoverMemorySources(projectPath) {
396
472
  return sources;
397
473
  }
398
474
 
399
- function findMdFiles(dir) {
475
+ function findSkillFiles(root) {
476
+ return findMdFiles(root, { name: 'SKILL.md', maxDepth: 4 });
477
+ }
478
+
479
+ function findMdFiles(dir, opts = {}, depth = 0) {
480
+ const { name = null, maxDepth = Infinity } = opts;
400
481
  const results = [];
401
482
  try {
402
483
  const entries = fs.readdirSync(dir, { withFileTypes: true });
403
484
  for (const entry of entries) {
404
485
  const full = path.join(dir, entry.name);
405
486
  if (entry.isDirectory()) {
406
- results.push(...findMdFiles(full));
407
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
487
+ if (depth < maxDepth) results.push(...findMdFiles(full, opts, depth + 1));
488
+ } else if (entry.isFile() && (name ? entry.name === name : entry.name.endsWith('.md'))) {
408
489
  results.push(full);
409
490
  }
410
491
  }
@@ -441,7 +522,10 @@ function findMemoryDir(projectPath) {
441
522
 
442
523
  function findMainWorktreePath(projectPath) {
443
524
  try {
444
- const out = execFileSync('git', ['-C', projectPath, 'rev-parse', '--git-common-dir'], {
525
+ // cwd rather than `-C <path>`: a projectPath beginning with a dash would
526
+ // otherwise be parsed by git as a flag instead of a directory.
527
+ const out = execFileSync('git', ['rev-parse', '--git-common-dir'], {
528
+ cwd: projectPath,
445
529
  encoding: 'utf8',
446
530
  stdio: ['ignore', 'pipe', 'ignore'],
447
531
  timeout: 2000,
@@ -512,6 +596,13 @@ function stripContent(source) {
512
596
  // #region EXPRESS
513
597
 
514
598
  const app = express();
599
+
600
+ // Mounted before express.json() so a rejected request never buffers a body.
601
+ const net = createNetGuard({ appName: 'Claude Code Memory' });
602
+ app.use(net.hostGuard);
603
+ app.use(net.frameGuard);
604
+ app.use(net.originGuard);
605
+
515
606
  app.use(express.json());
516
607
  app.use(express.static(path.join(__dirname, 'public')));
517
608
 
@@ -605,27 +696,446 @@ app.post('/api/memory/cleanup-orphans', (req, res) => {
605
696
  }
606
697
  });
607
698
 
699
+ // #region ANALYZER
700
+
701
+ // Auto-memory audit drafted by spawning Claude Code headlessly (`claude -p`), following
702
+ // the airun-coach-cockpit pattern: prompt via stdin (avoids the Windows arg-length cap),
703
+ // shape enforced by the CLI's native --json-schema flag, result read from the JSON
704
+ // envelope's structured_output field.
705
+ const ANALYSIS_SCHEMA = {
706
+ type: 'object',
707
+ properties: {
708
+ summary: { type: 'string' },
709
+ findings: {
710
+ type: 'array',
711
+ items: {
712
+ type: 'object',
713
+ properties: {
714
+ kind: { type: 'string', enum: ['duplicate', 'contradiction', 'promote', 'merge', 'stale', 'invalidate', 'quality'] },
715
+ severity: { type: 'string', enum: ['high', 'med', 'low'] },
716
+ title: { type: 'string' },
717
+ detail: { type: 'string' },
718
+ files: { type: 'array', items: { type: 'string' } },
719
+ suggestion: { type: 'string' },
720
+ evidence: { type: 'string' },
721
+ },
722
+ required: ['kind', 'severity', 'title', 'detail', 'files', 'suggestion'],
723
+ },
724
+ },
725
+ },
726
+ required: ['summary', 'findings'],
727
+ };
728
+
729
+ // Generous: the run fans out one verification subagent per audited file.
730
+ const ANALYZE_TIMEOUT_MS = 600_000;
731
+
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: [] };
738
+ const MAX_ANALYSIS_RUNS = 10;
739
+
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;
746
+ }
747
+
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
+ }
759
+ }
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
+ 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;
770
+ }
771
+ const loaded = hydrateAnalysisEntry(st);
772
+ analysisStates.set(project, loaded);
773
+ return loaded;
774
+ }
775
+
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];
784
+ try {
785
+ fs.writeFileSync(ANALYSIS_STORE, JSON.stringify(disk), 'utf-8');
786
+ } catch { /* best-effort persistence */ }
787
+ }
788
+
789
+ function memoryContentHash(sources) {
790
+ const h = crypto.createHash('sha1');
791
+ for (const s of sources) h.update(s.path).update('\0').update(s.content).update('\0');
792
+ return h.digest('hex');
793
+ }
794
+
795
+ // Distilled from the "writing-for-agents" skill — inlined so the audit never depends on
796
+ // that skill being installed on the machine running the analysis. Split into a shared
797
+ // core plus per-type rubrics: a good memory file, a good skill, and a good CLAUDE.md
798
+ // have different failure modes, so each type only gets the criteria that apply to it.
799
+ const RUBRIC_CORE = [
800
+ 'Shared criteria (all audited files):',
801
+ '- Positive phrasing: state the target behavior, not a prohibition — negations make the banned behavior more salient.',
802
+ '- No caches of the environment: do not restate what package.json, --help, or the directory layout already says; it goes stale. Record the unwritten convention, the why, the gotcha.',
803
+ '- No no-ops: an instruction the model already does by default is dead weight.',
804
+ '- Single source of truth: one meaning lives in one place; duplication costs maintenance and inflates prominence.',
805
+ ].join('\n');
806
+
807
+ const RUBRIC_MEMORY = [
808
+ 'Memory-file criteria (auto memory and agent memory):',
809
+ '- One fact per file, with the why: a memory missing its rationale ("**Why:**") cannot be applied correctly later.',
810
+ '- Index earns its cost: MEMORY.md lines are always loaded — each line must be a sharp pointer (title + hook), never content.',
811
+ '- Durable, not episodic: record the lesson or convention, not the story of one session; convert relative dates to absolute.',
812
+ '- Still true: a memory naming a file, flag, or command that no longer matches its own description is stale.',
813
+ ].join('\n');
814
+
815
+ const RUBRIC_SKILL = [
816
+ 'Skill criteria (SKILL.md files):',
817
+ '- Pointer wording: the description must front-load its trigger words and list the distinct cases it handles; a weak description means the skill never fires.',
818
+ '- Progressive disclosure: inline what every use needs; push branch-specific reference into sibling files behind pointers. A bloated top file buries its steps.',
819
+ '- Completion criteria: steps should end on a checkable, demanding bound ("every X accounted for"), not a vague one ("understanding reached").',
820
+ ].join('\n');
821
+
822
+ const RUBRIC_CLAUDE_MD = [
823
+ 'CLAUDE.md criteria (always-loaded project instructions):',
824
+ '- Every word pays a per-turn cost, and non-universal content trains the model to ignore the whole file: flag task-specific or situational instructions that belong in a skill, slash command, rule file, or pointed-to doc.',
825
+ '- Flag vague or no-op rules: unbounded adjectives ("format code properly", "be careful") and instructions the model already follows by default ("write clean code") — require specific, checkable directives.',
826
+ '- Flag content the environment already answers cheaply (package.json scripts, --help output, what the code shows): it duplicates a source of truth and goes stale. Keep only unwritten conventions, gotchas, and the reasons behind choices.',
827
+ '- Flag stale or wrong facts: commands that no longer exist, paths that do not match the repo, references to removed tools — verify against the repo.',
828
+ '- Flag emphasis inflation: IMPORTANT/ALWAYS/NEVER/caps on routine guidance dilutes the few rules that genuinely need them.',
829
+ '- Flag wrong scope: personal preferences in a shared project file (belong in the global CLAUDE.md or CLAUDE.local.md), subdirectory-only rules in the root file, project specifics in the global file.',
830
+ '- Flag lint-shaped rules (formatting, import order, naming style) that a deterministic linter or hook should enforce instead of the model.',
831
+ '- Flag surprising or restrictive rules with no WHY: a bare prohibition gets overridden when inconvenient; a one-clause reason makes it generalize.',
832
+ '- Flag instructions Claude cannot act on: rules aimed at humans, tools or files it cannot access, conditions it cannot detect.',
833
+ '- Flag long inlined code snippets or command output where a file pointer would do, and token-wasteful formatting (decorative headers, filler prose, deep nesting for a few bullets).',
834
+ '- 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
+ ].join('\n');
836
+
837
+ function buildAnalyzePrompt(memSources, userClaudeMd, skillSources, agentSources, claudeMdSources) {
838
+ const fullSkills = skillSources || [];
839
+ const fullAgent = agentSources || [];
840
+ const fullClaudeMd = claudeMdSources || [];
841
+ const audited = [];
842
+ if (memSources.length) audited.push('the persistent auto-memory files (an index MEMORY.md plus topic files)');
843
+ if (fullSkills.length) audited.push('project skills (SKILL.md files)');
844
+ if (fullAgent.length) audited.push('agent memory files');
845
+ if (fullClaudeMd.length) audited.push('CLAUDE.md instruction files');
846
+ const parts = [
847
+ `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
+ '',
849
+ 'Finding kinds:',
850
+ '- duplicate: files that say the same thing in different words (memory vs memory, memory vs skill, skill vs skill).',
851
+ '- contradiction: files that give conflicting guidance.',
852
+ '- promote: a memory stating a general preference (not specific to this project) that belongs in the global CLAUDE.md. Skip it if the global CLAUDE.md already covers it.',
853
+ '- merge: several small files on one theme that would be clearer as one.',
854
+ '- stale: content that is outdated — verified against the repo when checkable, or judged from its own text otherwise.',
855
+ '- 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
+ '- quality: content that is vague, missing its why, or unlikely to change agent behavior — reported by the reviewers against their rubrics.',
857
+ '',
858
+ '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.',
861
+ '- 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).',
863
+ '- suggestion: one actionable sentence (what to merge, delete, rewrite, or promote).',
864
+ '- severity: high = actively harmful (contradictions, wrong guidance), med = wasted context or confusion, low = polish.',
865
+ '- summary: 1-2 sentences on the overall health of this set.',
866
+ '',
867
+ 'GLOBAL CLAUDE.md (reference only, do not audit it):',
868
+ '<<<',
869
+ userClaudeMd || '(none)',
870
+ '>>>',
871
+ '',
872
+ ];
873
+ if (memSources.length) {
874
+ parts.push('MEMORY FILES:');
875
+ for (const s of memSources) {
876
+ parts.push(`=== FILE: ${s.name} ===`, s.content, '');
877
+ }
878
+ }
879
+ if (fullSkills.length) {
880
+ parts.push('PROJECT SKILLS (audit these):');
881
+ for (const s of fullSkills) {
882
+ parts.push(`=== SKILL: ${s.name} ===`, s.content, '');
883
+ }
884
+ }
885
+ if (fullAgent.length) {
886
+ parts.push('AGENT MEMORY FILES (audit these):');
887
+ for (const s of fullAgent) {
888
+ parts.push(`=== FILE (agent ${s.agentName || '?'}): ${s.name} ===`, s.content, '');
889
+ }
890
+ }
891
+ if (fullClaudeMd.length) {
892
+ parts.push('CLAUDE.MD FILES (audit these):');
893
+ for (const s of fullClaudeMd) {
894
+ parts.push(`=== FILE (${s.scope}): ${s.name} ===`, s.content, '');
895
+ }
896
+ }
897
+ return parts.join('\n');
898
+ }
899
+
900
+ const ANALYSIS_MODELS = new Set(['sonnet', 'opus', 'fable']);
901
+
902
+ // Custom subagents for the per-file review fan-out (`claude --agents`). Each source
903
+ // type gets its own reviewer whose rubric and verification instructions live in the
904
+ // agent's system prompt — the parent orchestrator only passes file name + content.
905
+ const REVIEWER_COMMON = [
906
+ '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.',
908
+ '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
+ ];
910
+
911
+ const REVIEWER_REPORT = [
912
+ 'Report back compactly, nothing else:',
913
+ '1. CLAIMS: each checkable claim — HOLDS / FAILS / UNCHECKED, with one line of evidence (what you looked at).',
914
+ '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
+ ];
916
+
917
+ function reviewerAgent(description, rubric) {
918
+ return {
919
+ description,
920
+ prompt: [REVIEWER_COMMON.join('\n'), RUBRIC_CORE, rubric, REVIEWER_REPORT.join('\n')].join('\n\n'),
921
+ tools: ['Read', 'Grep', 'Glob'],
922
+ };
923
+ }
924
+
925
+ const ANALYSIS_AGENTS = {
926
+ 'memory-reviewer': reviewerAgent(
927
+ 'Reviews and verifies one auto-memory or agent-memory file against the memory rubric and the repository. One per file, in parallel.',
928
+ RUBRIC_MEMORY,
929
+ ),
930
+ 'skill-reviewer': reviewerAgent(
931
+ 'Reviews and verifies one SKILL.md file against the skill rubric and the repository. One per skill, in parallel.',
932
+ RUBRIC_SKILL,
933
+ ),
934
+ 'claude-md-reviewer': reviewerAgent(
935
+ 'Reviews and verifies one CLAUDE.md instruction file against the CLAUDE.md rubric and the repository. One per file, in parallel.',
936
+ RUBRIC_CLAUDE_MD,
937
+ ),
938
+ };
939
+
940
+ function runClaudeAnalysis(prompt, model, cwd) {
941
+ return new Promise((resolve) => {
942
+ const args = ['-p', '--output-format', 'json', '--json-schema', JSON.stringify(ANALYSIS_SCHEMA)];
943
+ args.push('--agents', JSON.stringify(ANALYSIS_AGENTS));
944
+ if (model) args.push('--model', model);
945
+ let child;
946
+ try {
947
+ // cwd = the project root so the agent can verify claims against the actual
948
+ // files with its read-only tools (Read/Glob/Grep need no permission grants).
949
+ child = spawn('claude', args, { cwd: cwd || os.tmpdir(), windowsHide: true });
950
+ } catch (e) {
951
+ return resolve({ ok: false, error: `Failed to spawn claude: ${e.message}` });
952
+ }
953
+ let out = '';
954
+ let err = '';
955
+ 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);
958
+ child.stdout.on('data', (d) => { out += d; });
959
+ child.stderr.on('data', (d) => { err += d; });
960
+ child.on('error', (e) => finish({ ok: false, error: `claude not found — is Claude Code installed? (${e.message})` }));
961
+ child.on('close', () => {
962
+ try {
963
+ const envelope = JSON.parse(out);
964
+ if (envelope.is_error) return finish({ ok: false, error: String(envelope.result || 'Claude Code reported an error') });
965
+ finish({ ok: true, data: envelope.structured_output, costUsd: envelope.total_cost_usd, durationMs: envelope.duration_ms });
966
+ } catch {
967
+ finish({ ok: false, error: `Claude Code returned an invalid JSON envelope${err ? `: ${err.slice(0, 300)}` : ''}` });
968
+ }
969
+ });
970
+ child.stdin.end(prompt);
971
+ });
972
+ }
973
+
974
+ const CLAUDE_MD_SCOPES = new Set(['project', 'local']);
975
+
976
+ // Everything the analyzer can audit: auto memory, top-level skills, agent memory, CLAUDE.md files.
977
+ function analyzableSources(stack) {
978
+ return stack.filter(
979
+ (s) =>
980
+ s.scope === 'memory' ||
981
+ s.scope === 'agent-memory' ||
982
+ ((s.scope === 'skill' || CLAUDE_MD_SCOPES.has(s.scope)) && !s.parentId),
983
+ );
984
+ }
985
+
986
+ // Resolve the analysis scope: explicit ids from the picker, or the default set
987
+ // (auto memory only — skills, agent memory, and CLAUDE.md files are opt-in).
988
+ function selectAnalysisSources(stack, ids) {
989
+ const all = analyzableSources(stack);
990
+ if (Array.isArray(ids) && ids.length) {
991
+ const wanted = new Set(ids);
992
+ return all.filter((s) => wanted.has(s.id));
993
+ }
994
+ return all.filter((s) => s.scope === 'memory');
995
+ }
996
+
997
+ function scopeDescription(selected) {
998
+ const parts = [];
999
+ const n = (pred) => selected.filter(pred).length;
1000
+ const mem = n((s) => s.scope === 'memory');
1001
+ const skl = n((s) => s.scope === 'skill');
1002
+ const agt = n((s) => s.scope === 'agent-memory');
1003
+ const cmd = n((s) => CLAUDE_MD_SCOPES.has(s.scope));
1004
+ if (mem) parts.push(`${mem} memory`);
1005
+ if (skl) parts.push(`${skl} skill${skl === 1 ? '' : 's'}`);
1006
+ if (agt) parts.push(`${agt} agent`);
1007
+ if (cmd) parts.push(`${cmd} CLAUDE.md`);
1008
+ return parts.join(' + ');
1009
+ }
1010
+
1011
+ app.get('/api/memory/analysis', (_req, res) => {
1012
+ const st = getAnalysisState(currentProjectPath);
1013
+ // Staleness needs a full stack scan + content hash — skip it while a run is in
1014
+ // flight (the client polls every 2.5s and stale is meaningless mid-run anyway).
1015
+ 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;
1020
+ }
1021
+ res.json({
1022
+ running: st.running,
1023
+ result: st.result,
1024
+ error: st.error,
1025
+ ts: st.ts,
1026
+ stale,
1027
+ project: currentProjectPath,
1028
+ runs: st.runs || [],
1029
+ });
1030
+ });
1031
+
1032
+ 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' });
1035
+ const ts = req.body?.ts;
1036
+ const runs = (st.runs || []).filter((run) => run.ts !== ts);
1037
+ 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);
1049
+ res.json({ ok: true, runs: runs.length });
1050
+ });
1051
+
1052
+ app.post('/api/memory/analyze', (req, res) => {
1053
+ const prev = getAnalysisState(currentProjectPath);
1054
+ if (prev.running) return res.status(409).json({ error: 'analysis already running' });
1055
+ const stack = getStack();
1056
+ const ids = req.body?.ids;
1057
+ const selected = selectAnalysisSources(stack, ids);
1058
+ if (!selected.length) return res.status(404).json({ error: 'nothing to analyze for this scope' });
1059
+ const hash = memoryContentHash(selected);
1060
+ if (!req.body?.force && prev.result && prev.hash === hash) {
1061
+ return res.json({ running: false, cached: true });
1062
+ }
1063
+ const memSources = selected.filter((s) => s.scope === 'memory');
1064
+ const skills = selected.filter((s) => s.scope === 'skill');
1065
+ const agentSources = selected.filter((s) => s.scope === 'agent-memory');
1066
+ 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);
1070
+ const stateIds = Array.isArray(ids) && ids.length ? ids : null;
1071
+ const model = ANALYSIS_MODELS.has(req.body?.model) ? req.body.model : null;
1072
+ // Capture the project now — the user may switch projects while the run is in flight,
1073
+ // and the result must land under the project it was computed for.
1074
+ 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) => {
1078
+ 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,
1087
+ error: r.ok ? null : r.error,
1088
+ hash,
1089
+ ts,
1090
+ ids: stateIds,
1091
+ runs,
1092
+ });
1093
+ saveAnalysisState(project);
1094
+ });
1095
+ res.status(202).json({ running: true });
1096
+ });
1097
+
1098
+ // #endregion ANALYZER
1099
+
608
1100
  app.post('/api/open-in-editor', (req, res) => {
609
- const { path: filePath } = req.body;
610
- if (!filePath) return res.status(400).json({ error: 'path required' });
611
- const resolved = expandHome(filePath);
612
- if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'file not found' });
613
- const { exec } = require('child_process');
614
- exec(`code "${resolved}"`, (err) => {
615
- if (err) return res.status(500).json({ error: err.message });
1101
+ try {
1102
+ openInEditor([assertOpenTarget(expandHome(req.body.path))]);
616
1103
  res.json({ ok: true });
617
- });
1104
+ } catch (err) {
1105
+ res.status(err.status || 500).json({ error: err.message });
1106
+ }
618
1107
  });
619
1108
 
1109
+ // A skill's name + description ride the system prompt every session (unless model
1110
+ // invocation is disabled) — that is its standing cost, not the on-demand body.
1111
+ function skillDescMeta(source) {
1112
+ const fm = source.frontmatter || {};
1113
+ if (fm['disable-model-invocation'] === 'true') return null;
1114
+ const desc = typeof fm.description === 'string' ? fm.description : '';
1115
+ return { chars: (source.skillName || source.name || '').length + desc.trim().length };
1116
+ }
1117
+
620
1118
  app.get('/api/summary', (_req, res) => {
621
- const sources = getStack();
1119
+ // Skill bodies load on invocation and markdown-linked docs load only when Claude
1120
+ // follows the link — neither is part of the memory footprint, only hard @imports are.
1121
+ const stack = getStack();
1122
+ // Memory topic files load on demand like skill bodies — only the startup MEMORY.md
1123
+ // 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'
1125
+ && !((s.scope === 'memory' || s.scope === 'agent-memory') && s.load === 'ondemand'));
622
1126
  const totalFiles = sources.length;
623
1127
  const totalLines = sources.reduce((s, f) => s + (f.lines || 0), 0);
624
1128
  const totalBytes = sources.reduce((s, f) => s + (f.bytes || 0), 0);
625
1129
  const alwaysLoaded = sources.filter(s => s.load === 'always' || s.load === 'startup').length;
626
1130
  const conditional = sources.filter(s => s.load === 'conditional').length;
627
1131
  const onDemand = sources.filter(s => s.load === 'ondemand').length;
628
- res.json({ totalFiles, totalLines, totalBytes, alwaysLoaded, conditional, onDemand });
1132
+ const skillDescs = stack.filter(s => s.scope === 'skill').map(skillDescMeta).filter(Boolean);
1133
+ const skillDesc = { count: skillDescs.length, chars: skillDescs.reduce((s, d) => s + d.chars, 0) };
1134
+ // Per-scope char totals so the client budget bar shares this exact footprint filter
1135
+ const scopeChars = {};
1136
+ for (const f of sources) scopeChars[f.scope] = (scopeChars[f.scope] || 0) + (f.chars || 0);
1137
+ 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 });
629
1139
  });
630
1140
 
631
1141
  app.get('/api/stack', (_req, res) => {
@@ -696,27 +1206,22 @@ app.get('/api/imports', (req, res) => {
696
1206
 
697
1207
  // #region STARTUP
698
1208
 
699
- const server = app.listen(PORT, () => {
700
- const addr = server.address();
701
- const port = typeof addr === 'object' ? addr.port : PORT;
1209
+ const onReady = (port) => {
702
1210
  console.log(`Claude Code Memory running at http://localhost:${port}`);
703
1211
  console.log(`Project: ${currentProjectPath}`);
1212
+ const warning = net.exposureWarning();
1213
+ if (warning) console.log(warning);
704
1214
  if (AUTO_OPEN) {
705
1215
  import('open').then(m => m.default(`http://localhost:${port}`)).catch(() => {});
706
1216
  }
707
- });
1217
+ };
1218
+
1219
+ const server = net.listenLoopback(app, PORT, onReady);
708
1220
 
709
1221
  server.on('error', (err) => {
710
1222
  if (err.code === 'EADDRINUSE') {
711
1223
  console.log(`Port ${PORT} busy, trying random port...`);
712
- const retry = app.listen(0, () => {
713
- const addr = retry.address();
714
- const port = typeof addr === 'object' ? addr.port : '?';
715
- console.log(`Claude Code Memory running at http://localhost:${port}`);
716
- if (AUTO_OPEN) {
717
- import('open').then(m => m.default(`http://localhost:${port}`)).catch(() => {});
718
- }
719
- });
1224
+ net.listenLoopback(app, 0, onReady);
720
1225
  }
721
1226
  });
722
1227