claude-code-memory-explorer 1.2.0 → 1.3.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/package.json +1 -1
- package/public/app.js +598 -41
- package/public/index.html +3 -1
- package/public/style.css +466 -0
- package/server.js +201 -55
package/server.js
CHANGED
|
@@ -38,6 +38,7 @@ const projectDirArg = getArg('project');
|
|
|
38
38
|
|
|
39
39
|
// #region STATE
|
|
40
40
|
|
|
41
|
+
// null is the user scope: only the sources every session loads, whatever the project.
|
|
41
42
|
let currentProjectPath = projectDirArg ? path.resolve(expandHome(projectDirArg)) : process.cwd();
|
|
42
43
|
|
|
43
44
|
const cache = {};
|
|
@@ -214,6 +215,19 @@ function spreadImports(filePath, content) {
|
|
|
214
215
|
return { imports: resolved, softImports: resolvedSoft, unresolvedImports: unresolved };
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
function fmName(info) {
|
|
219
|
+
const n = info.frontmatter?.name;
|
|
220
|
+
return typeof n === 'string' ? n : '';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A skill's name + description ride the system prompt every session (unless model
|
|
224
|
+
// invocation is disabled) — that is its standing cost, not the on-demand body.
|
|
225
|
+
// A subagent's name + description ride the Agent tool's listing the same way.
|
|
226
|
+
function descChars(name, info) {
|
|
227
|
+
const desc = info.frontmatter?.description;
|
|
228
|
+
return name.length + (typeof desc === 'string' ? desc.trim().length : 0);
|
|
229
|
+
}
|
|
230
|
+
|
|
217
231
|
function hasPathsFilter(frontmatter) {
|
|
218
232
|
if (!frontmatter?.paths) return false;
|
|
219
233
|
return Array.isArray(frontmatter.paths) ? frontmatter.paths.length > 0 : true;
|
|
@@ -269,7 +283,7 @@ function discoverMemorySources(projectPath) {
|
|
|
269
283
|
}
|
|
270
284
|
|
|
271
285
|
// 4. Walk up from projectPath to find CLAUDE.md and CLAUDE.local.md
|
|
272
|
-
const ancestors = getAncestorDirs(projectPath);
|
|
286
|
+
const ancestors = projectPath ? getAncestorDirs(projectPath) : [];
|
|
273
287
|
const seenPaths = new Set(sources.map(s => s.path));
|
|
274
288
|
for (const dir of ancestors) {
|
|
275
289
|
const isProjectRoot = path.resolve(dir) === path.resolve(projectPath);
|
|
@@ -327,11 +341,11 @@ function discoverMemorySources(projectPath) {
|
|
|
327
341
|
walkForClaudeMd(subdir, depth + 1);
|
|
328
342
|
}
|
|
329
343
|
}
|
|
330
|
-
walkForClaudeMd(projectPath, 0);
|
|
344
|
+
if (projectPath) walkForClaudeMd(projectPath, 0);
|
|
331
345
|
|
|
332
346
|
// 5. Project rules (.claude/rules/*.md)
|
|
333
|
-
const projectRulesDir = path.join(projectPath, '.claude', 'rules');
|
|
334
|
-
if (fs.existsSync(projectRulesDir)) {
|
|
347
|
+
const projectRulesDir = projectPath && path.join(projectPath, '.claude', 'rules');
|
|
348
|
+
if (projectRulesDir && fs.existsSync(projectRulesDir)) {
|
|
335
349
|
for (const file of findMdFiles(projectRulesDir)) {
|
|
336
350
|
const info = fileInfo(file);
|
|
337
351
|
if (!info) continue;
|
|
@@ -352,7 +366,7 @@ function discoverMemorySources(projectPath) {
|
|
|
352
366
|
const info = fileInfo(file);
|
|
353
367
|
if (!info) return;
|
|
354
368
|
const dirName = path.basename(path.dirname(file));
|
|
355
|
-
const skillName = (info
|
|
369
|
+
const skillName = fmName(info) || dirName;
|
|
356
370
|
const display = extra.nestedRel ? `${extra.nestedRel}:${skillName}` : skillName;
|
|
357
371
|
sources.push({
|
|
358
372
|
id: `skill-${skillSource}-${slug(file)}`,
|
|
@@ -361,16 +375,40 @@ function discoverMemorySources(projectPath) {
|
|
|
361
375
|
load: 'ondemand',
|
|
362
376
|
skillSource,
|
|
363
377
|
skillName,
|
|
378
|
+
descChars: info.frontmatter?.['disable-model-invocation'] === 'true' ? null : descChars(skillName, info),
|
|
364
379
|
...extra,
|
|
365
380
|
...info,
|
|
366
381
|
...spreadImports(info.path, info.content),
|
|
367
382
|
});
|
|
368
383
|
}
|
|
369
|
-
for (const file of findSkillFiles(path.join(
|
|
384
|
+
for (const file of findSkillFiles(path.join(CLAUDE_DIR, 'skills'))) pushSkill(file, 'user');
|
|
385
|
+
if (projectPath) {
|
|
386
|
+
for (const file of findSkillFiles(path.join(projectPath, '.claude', 'skills'))) pushSkill(file, 'project');
|
|
387
|
+
}
|
|
370
388
|
for (const { dir, rel } of nestedSkillDirs) {
|
|
371
389
|
for (const file of findSkillFiles(dir)) pushSkill(file, 'project', { nestedRel: rel });
|
|
372
390
|
}
|
|
373
391
|
|
|
392
|
+
// 5c. Subagent definitions: the description is listed in every session, the body loads when the agent runs
|
|
393
|
+
const agentRoots = [{ root: path.join(CLAUDE_DIR, 'agents'), agentSource: 'user' }];
|
|
394
|
+
if (projectPath) agentRoots.push({ root: path.join(projectPath, '.claude', 'agents'), agentSource: 'project' });
|
|
395
|
+
for (const { root, agentSource } of agentRoots) {
|
|
396
|
+
for (const file of findMdFiles(root)) {
|
|
397
|
+
const info = fileInfo(file);
|
|
398
|
+
if (!info) continue;
|
|
399
|
+
const name = fmName(info) || path.basename(file, '.md');
|
|
400
|
+
sources.push({
|
|
401
|
+
id: `agent-${agentSource}-${slug(file)}`,
|
|
402
|
+
name,
|
|
403
|
+
scope: 'agent',
|
|
404
|
+
load: 'ondemand',
|
|
405
|
+
agentSource,
|
|
406
|
+
descChars: descChars(name, info),
|
|
407
|
+
...info,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
374
412
|
function pushMemoryDir(dir, scope, idPrefix, extraFields = {}) {
|
|
375
413
|
const memoryMd = path.join(dir, 'MEMORY.md');
|
|
376
414
|
const memInfo = fileInfo(memoryMd);
|
|
@@ -404,7 +442,7 @@ function discoverMemorySources(projectPath) {
|
|
|
404
442
|
}
|
|
405
443
|
|
|
406
444
|
// 6. Auto memory (projects base honors `autoMemoryDirectory` user setting)
|
|
407
|
-
const memoryDir = findMemoryDir(projectPath);
|
|
445
|
+
const memoryDir = projectPath && findMemoryDir(projectPath);
|
|
408
446
|
if (memoryDir && fs.existsSync(memoryDir)) {
|
|
409
447
|
pushMemoryDir(memoryDir, 'memory', 'memory');
|
|
410
448
|
}
|
|
@@ -413,11 +451,13 @@ function discoverMemorySources(projectPath) {
|
|
|
413
451
|
// user: ~/.claude/agent-memory/<agent>/
|
|
414
452
|
// project: <project>/.claude/agent-memory/<agent>/
|
|
415
453
|
// local: <project>/.claude/agent-memory-local/<agent>/
|
|
416
|
-
const agentMemoryRoots = [
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
454
|
+
const agentMemoryRoots = [{ root: path.join(CLAUDE_DIR, 'agent-memory'), agentScope: 'user' }];
|
|
455
|
+
if (projectPath) {
|
|
456
|
+
agentMemoryRoots.push(
|
|
457
|
+
{ root: path.join(projectPath, '.claude', 'agent-memory'), agentScope: 'project' },
|
|
458
|
+
{ root: path.join(projectPath, '.claude', 'agent-memory-local'), agentScope: 'local' },
|
|
459
|
+
);
|
|
460
|
+
}
|
|
421
461
|
for (const { root, agentScope } of agentMemoryRoots) {
|
|
422
462
|
let agentDirs;
|
|
423
463
|
try { agentDirs = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
|
|
@@ -437,8 +477,9 @@ function discoverMemorySources(projectPath) {
|
|
|
437
477
|
...(s.imports || []).map(imp => ({ imp, parent: s, hard: true })),
|
|
438
478
|
...(s.softImports || []).map(imp => ({ imp, parent: s, hard: false })),
|
|
439
479
|
]);
|
|
480
|
+
// https://code.claude.com/docs/en/memory: imports nest up to four hops.
|
|
440
481
|
let depth = 0;
|
|
441
|
-
while (queue.length && depth <
|
|
482
|
+
while (queue.length && depth < 4) {
|
|
442
483
|
const batch = queue.splice(0, queue.length);
|
|
443
484
|
for (const { imp, parent, hard } of batch) {
|
|
444
485
|
if (seen.has(imp)) {
|
|
@@ -476,8 +517,16 @@ function discoverMemorySources(projectPath) {
|
|
|
476
517
|
return sources;
|
|
477
518
|
}
|
|
478
519
|
|
|
479
|
-
|
|
480
|
-
|
|
520
|
+
// A SKILL.md inside a skill's folder (templates, examples) is not a skill of its own.
|
|
521
|
+
function findSkillFiles(dir, depth = 0) {
|
|
522
|
+
let entries;
|
|
523
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
524
|
+
if (entries.some((e) => e.isFile() && e.name === 'SKILL.md')) return [path.join(dir, 'SKILL.md')];
|
|
525
|
+
if (depth >= 4) return [];
|
|
526
|
+
// Claude Code parks removed synced skills in skills/.trash/ and does not load them.
|
|
527
|
+
return entries
|
|
528
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
529
|
+
.flatMap((e) => findSkillFiles(path.join(dir, e.name), depth + 1));
|
|
481
530
|
}
|
|
482
531
|
|
|
483
532
|
function findMdFiles(dir, opts = {}, depth = 0) {
|
|
@@ -553,11 +602,9 @@ function encodeProjectPath(projectPath) {
|
|
|
553
602
|
.replace(/\//g, '-');
|
|
554
603
|
}
|
|
555
604
|
|
|
556
|
-
//
|
|
605
|
+
// Managed, then user `autoMemoryDirectory` — Claude Code rejects this key from project/local settings for security.
|
|
557
606
|
function getProjectsBaseDir() {
|
|
558
|
-
|
|
559
|
-
try { settings = JSON.parse(fs.readFileSync(path.join(CLAUDE_DIR, 'settings.json'), 'utf-8')); } catch { /* no settings */ }
|
|
560
|
-
const raw = settings.autoMemoryDirectory;
|
|
607
|
+
const raw = readJson(managedSettingsPath())?.autoMemoryDirectory || readJson(USER_SETTINGS_PATH)?.autoMemoryDirectory;
|
|
561
608
|
if (typeof raw === 'string' && raw.trim()) return path.resolve(expandHome(raw));
|
|
562
609
|
return path.join(CLAUDE_DIR, 'projects');
|
|
563
610
|
}
|
|
@@ -612,20 +659,33 @@ app.use(express.json());
|
|
|
612
659
|
if (process.env.HUB_SDK_SERVER) require(process.env.HUB_SDK_SERVER).mount(app);
|
|
613
660
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
614
661
|
|
|
662
|
+
function projectInfo() {
|
|
663
|
+
const p = currentProjectPath;
|
|
664
|
+
return { path: p, name: p ? path.basename(p) : 'User', user: !p, configDir: CLAUDE_DIR };
|
|
665
|
+
}
|
|
666
|
+
|
|
615
667
|
app.get('/api/project', (_req, res) => {
|
|
616
|
-
res.json(
|
|
668
|
+
res.json(projectInfo());
|
|
617
669
|
});
|
|
618
670
|
|
|
671
|
+
// Body: {path} opens a project, {user: true} the user scope.
|
|
619
672
|
app.put('/api/project', (req, res) => {
|
|
620
|
-
const { path: dirPath } = req.body;
|
|
673
|
+
const { path: dirPath, user } = req.body || {};
|
|
674
|
+
if (user === true) {
|
|
675
|
+
if (currentProjectPath) {
|
|
676
|
+
currentProjectPath = null;
|
|
677
|
+
clearCache();
|
|
678
|
+
}
|
|
679
|
+
return res.json(projectInfo());
|
|
680
|
+
}
|
|
621
681
|
if (!dirPath) return res.status(400).json({ error: 'path required' });
|
|
622
682
|
const resolved = path.resolve(expandHome(dirPath));
|
|
623
683
|
if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'directory not found' });
|
|
624
|
-
if (resolved !== path.resolve(currentProjectPath)) {
|
|
684
|
+
if (!currentProjectPath || resolved !== path.resolve(currentProjectPath)) {
|
|
625
685
|
currentProjectPath = resolved;
|
|
626
686
|
clearCache();
|
|
627
687
|
}
|
|
628
|
-
res.json(
|
|
688
|
+
res.json(projectInfo());
|
|
629
689
|
});
|
|
630
690
|
|
|
631
691
|
app.post('/api/refresh', (_req, res) => {
|
|
@@ -636,11 +696,8 @@ app.post('/api/refresh', (_req, res) => {
|
|
|
636
696
|
function resolveAllowedPath(filePath) {
|
|
637
697
|
if (!filePath) return { error: { status: 400, message: 'path required' } };
|
|
638
698
|
const resolved = path.resolve(expandHome(filePath));
|
|
639
|
-
const roots = [
|
|
640
|
-
|
|
641
|
-
path.resolve(currentProjectPath),
|
|
642
|
-
getProjectsBaseDir(),
|
|
643
|
-
];
|
|
699
|
+
const roots = [path.resolve(CLAUDE_DIR), getProjectsBaseDir()];
|
|
700
|
+
if (currentProjectPath) roots.push(path.resolve(currentProjectPath));
|
|
644
701
|
const inRoot = roots.some((r) => resolved === r || resolved.startsWith(r + path.sep));
|
|
645
702
|
if (!inRoot) return { error: { status: 403, message: 'path outside allowed roots' } };
|
|
646
703
|
return { resolved };
|
|
@@ -1089,7 +1146,8 @@ function selectAnalysisSources(stack, ids) {
|
|
|
1089
1146
|
const wanted = new Set(ids);
|
|
1090
1147
|
return all.filter((s) => wanted.has(s.id));
|
|
1091
1148
|
}
|
|
1092
|
-
|
|
1149
|
+
// The user scope has no auto memory, so its default is the user CLAUDE.md.
|
|
1150
|
+
return all.filter((s) => s.scope === (currentProjectPath ? 'memory' : 'user'));
|
|
1093
1151
|
}
|
|
1094
1152
|
|
|
1095
1153
|
function scopeDescription(selected) {
|
|
@@ -1108,12 +1166,17 @@ function scopeDescription(selected) {
|
|
|
1108
1166
|
return parts.join(' + ');
|
|
1109
1167
|
}
|
|
1110
1168
|
|
|
1169
|
+
// The user scope has no project entry: its runs are the shared user-scope entry's.
|
|
1170
|
+
const analysisProject = () => currentProjectPath || USER_SCOPE;
|
|
1171
|
+
|
|
1111
1172
|
app.get('/api/memory/analysis', (_req, res) => {
|
|
1112
|
-
const st = getAnalysisState(currentProjectPath);
|
|
1113
|
-
const running = st.pending.some((p) => !p.stalled);
|
|
1114
1173
|
// The shared user-scope entry rides along on every project's payload; its
|
|
1115
1174
|
// staleness tracks the user CLAUDE.md itself, independent of the project.
|
|
1116
1175
|
const us = getAnalysisState(USER_SCOPE);
|
|
1176
|
+
const st = currentProjectPath
|
|
1177
|
+
? getAnalysisState(currentProjectPath)
|
|
1178
|
+
: { ...EMPTY_ANALYSIS, pending: us.pending, error: us.error };
|
|
1179
|
+
const running = st.pending.some((p) => !p.stalled);
|
|
1117
1180
|
// Staleness needs a full stack scan + content hash — skip it while a run is in
|
|
1118
1181
|
// flight (the client polls every 2.5s and stale is meaningless mid-run anyway).
|
|
1119
1182
|
let stale = false;
|
|
@@ -1126,8 +1189,8 @@ app.get('/api/memory/analysis', (_req, res) => {
|
|
|
1126
1189
|
stale = st.hash !== hash;
|
|
1127
1190
|
}
|
|
1128
1191
|
if (us.result) {
|
|
1129
|
-
const
|
|
1130
|
-
userStale = us.hash !== (
|
|
1192
|
+
const selected = us.ids ? selectAnalysisSources(stack, us.ids) : [];
|
|
1193
|
+
userStale = us.hash !== (selected.length ? memoryContentHash(selected) : null);
|
|
1131
1194
|
}
|
|
1132
1195
|
}
|
|
1133
1196
|
res.json({
|
|
@@ -1146,7 +1209,7 @@ app.get('/api/memory/analysis', (_req, res) => {
|
|
|
1146
1209
|
// scope:'user' targets the shared user-scope entry, so the action holds across
|
|
1147
1210
|
// projects; anything else targets the current project's entry.
|
|
1148
1211
|
function analysisTarget(req) {
|
|
1149
|
-
return req.body?.scope === 'user' ? USER_SCOPE :
|
|
1212
|
+
return req.body?.scope === 'user' ? USER_SCOPE : analysisProject();
|
|
1150
1213
|
}
|
|
1151
1214
|
|
|
1152
1215
|
app.post('/api/memory/analysis/delete-run', (req, res) => {
|
|
@@ -1176,7 +1239,7 @@ app.post('/api/memory/analysis/dismiss', (req, res) => {
|
|
|
1176
1239
|
});
|
|
1177
1240
|
|
|
1178
1241
|
app.post('/api/memory/analyze', (req, res) => {
|
|
1179
|
-
const prev = getAnalysisState(
|
|
1242
|
+
const prev = getAnalysisState(analysisProject());
|
|
1180
1243
|
const stack = getStack();
|
|
1181
1244
|
const ids = req.body?.ids;
|
|
1182
1245
|
const selected = selectAnalysisSources(stack, ids);
|
|
@@ -1198,13 +1261,16 @@ app.post('/api/memory/analyze', (req, res) => {
|
|
|
1198
1261
|
const claudeMdSources = selected.filter((s) => CLAUDE_MD_SCOPES.has(s.scope));
|
|
1199
1262
|
const userSrc = userSelected || stack.find((s) => s.id === 'user-claude-md');
|
|
1200
1263
|
const prompt = buildAnalyzePrompt(memSources, skills, agentSources, claudeMdSources, userSrc, !!userSelected);
|
|
1201
|
-
|
|
1264
|
+
// The user entry is also read from projects, where the default selection differs, so it keeps its ids.
|
|
1265
|
+
const stateIds = Array.isArray(ids) && ids.length ? ids : currentProjectPath ? null : selected.map((s) => s.id);
|
|
1202
1266
|
const model = ANALYSIS_MODELS.has(req.body?.model) ? req.body.model : null;
|
|
1203
1267
|
// Capture the project now — the user may switch projects while the run is in flight,
|
|
1204
1268
|
// and the result must land under the project it was computed for.
|
|
1205
|
-
const project =
|
|
1269
|
+
const project = analysisProject();
|
|
1206
1270
|
const runId = crypto.randomUUID();
|
|
1207
1271
|
const scopeDesc = scopeDescription(selected);
|
|
1272
|
+
// In the user scope the run already lands in the user entry, so there is nothing to split off.
|
|
1273
|
+
const splitUser = !!userSelected && project !== USER_SCOPE;
|
|
1208
1274
|
saveAnalysisState(project, {
|
|
1209
1275
|
...prev,
|
|
1210
1276
|
// Starting a fresh run retires stalled leftovers — the user has moved on.
|
|
@@ -1216,12 +1282,12 @@ app.post('/api/memory/analyze', (req, res) => {
|
|
|
1216
1282
|
// file is owned by the user-scope entry's snapshot, not the project's.
|
|
1217
1283
|
// Snapshotting up front also keeps `selected` (full file contents) out of the
|
|
1218
1284
|
// completion closure, which can live for hours on a run that never returns.
|
|
1219
|
-
const audited = selected.filter((s) => s.scope !== 'user').map((s) => ({ id: s.id, name: s.name, scope: s.scope, lines: s.lines }));
|
|
1285
|
+
const audited = selected.filter((s) => !splitUser || s.scope !== 'user').map((s) => ({ id: s.id, name: s.name, scope: s.scope, lines: s.lines }));
|
|
1220
1286
|
const userAudited = userSelected ? [{ id: userSelected.id, name: userSelected.name, scope: 'user', lines: userSelected.lines }] : null;
|
|
1221
1287
|
const userId = userSelected?.id || null;
|
|
1222
1288
|
// Prepend a run to an entry's history, newest first, capped.
|
|
1223
1289
|
const withRun = (cur, run) => [run, ...(cur.runs || [])].slice(0, MAX_ANALYSIS_RUNS);
|
|
1224
|
-
runClaudeAnalysis(prompt, model, userOnly ? os.homedir() : project).then((r) => {
|
|
1290
|
+
runClaudeAnalysis(prompt, model, userOnly || project === USER_SCOPE ? os.homedir() : project).then((r) => {
|
|
1225
1291
|
const ts = Date.now();
|
|
1226
1292
|
let result = r.ok ? { ...r.data, costUsd: r.costUsd, durationMs: r.durationMs, scopeDesc, model } : null;
|
|
1227
1293
|
if (result) {
|
|
@@ -1232,7 +1298,7 @@ app.post('/api/memory/analyze', (req, res) => {
|
|
|
1232
1298
|
}
|
|
1233
1299
|
// Findings about the user CLAUDE.md alone are shared across projects: split them
|
|
1234
1300
|
// into the user-scope entry; project and cross findings stay with the project.
|
|
1235
|
-
if (result &&
|
|
1301
|
+
if (result && splitUser) {
|
|
1236
1302
|
const all = result.findings;
|
|
1237
1303
|
const uCur = getAnalysisState(USER_SCOPE);
|
|
1238
1304
|
const uRun = {
|
|
@@ -1247,7 +1313,7 @@ app.post('/api/memory/analyze', (req, res) => {
|
|
|
1247
1313
|
}
|
|
1248
1314
|
// Re-read from disk — parallel runs or another instance may have finished meanwhile.
|
|
1249
1315
|
const cur = getAnalysisState(project);
|
|
1250
|
-
const runs = result && !userOnly ? withRun(cur, { result, ts, hash, ids: stateIds, audited }) : cur.runs || [];
|
|
1316
|
+
const runs = result && !(userOnly && splitUser) ? withRun(cur, { result, ts, hash, ids: stateIds, audited }) : cur.runs || [];
|
|
1251
1317
|
saveAnalysisState(project, {
|
|
1252
1318
|
...cur,
|
|
1253
1319
|
pending: cur.pending.filter((p) => p.id !== runId),
|
|
@@ -1269,13 +1335,15 @@ app.post('/api/open-in-editor', (req, res) => {
|
|
|
1269
1335
|
}
|
|
1270
1336
|
});
|
|
1271
1337
|
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
function
|
|
1275
|
-
const
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1338
|
+
// Claude Code lists one definition per name: a user skill wins over a project skill, a project
|
|
1339
|
+
// agent over a user agent. The stack holds user definitions before project ones.
|
|
1340
|
+
function descTotal(stack, scope) {
|
|
1341
|
+
const byName = new Map();
|
|
1342
|
+
for (const s of stack) {
|
|
1343
|
+
if (s.scope !== scope || s.descChars == null) continue;
|
|
1344
|
+
if (scope === 'agent' || !byName.has(s.name)) byName.set(s.name, s.descChars);
|
|
1345
|
+
}
|
|
1346
|
+
return { count: byName.size, chars: [...byName.values()].reduce((n, c) => n + c, 0) };
|
|
1279
1347
|
}
|
|
1280
1348
|
|
|
1281
1349
|
app.get('/api/summary', (_req, res) => {
|
|
@@ -1294,7 +1362,7 @@ app.get('/api/summary', (_req, res) => {
|
|
|
1294
1362
|
if (s.parentId && treeIds.has(s.parentId) && !treeIds.has(s.id)) { treeIds.add(s.id); grew = true; }
|
|
1295
1363
|
}
|
|
1296
1364
|
}
|
|
1297
|
-
const sources = stack.filter(s => s.scope !== 'skill' && s.load !== 'link' && !treeIds.has(s.id)
|
|
1365
|
+
const sources = stack.filter(s => s.scope !== 'skill' && s.scope !== 'agent' && s.load !== 'link' && !treeIds.has(s.id)
|
|
1298
1366
|
&& !((s.scope === 'memory' || s.scope === 'agent-memory') && s.load === 'ondemand'));
|
|
1299
1367
|
const totalFiles = sources.length;
|
|
1300
1368
|
const totalLines = sources.reduce((s, f) => s + (f.lines || 0), 0);
|
|
@@ -1302,16 +1370,94 @@ app.get('/api/summary', (_req, res) => {
|
|
|
1302
1370
|
const alwaysLoaded = sources.filter(s => s.load === 'always' || s.load === 'startup').length;
|
|
1303
1371
|
const conditional = sources.filter(s => s.load === 'conditional').length;
|
|
1304
1372
|
const onDemand = sources.filter(s => s.load === 'ondemand').length;
|
|
1305
|
-
const
|
|
1306
|
-
const
|
|
1373
|
+
const skillDesc = descTotal(stack, 'skill');
|
|
1374
|
+
const agentDesc = descTotal(stack, 'agent');
|
|
1307
1375
|
// Per-scope char totals so the client budget bar shares this exact footprint filter
|
|
1308
1376
|
const scopeChars = {};
|
|
1309
1377
|
for (const f of sources) scopeChars[f.scope] = (scopeChars[f.scope] || 0) + (f.chars || 0);
|
|
1310
|
-
const totalChars = sources.reduce((s, f) => s + (f.chars || 0), 0) + skillDesc.chars;
|
|
1378
|
+
const totalChars = sources.reduce((s, f) => s + (f.chars || 0), 0) + skillDesc.chars + agentDesc.chars;
|
|
1311
1379
|
// ids: which sources make up the footprint, so the client can highlight them
|
|
1312
1380
|
// userClaudeMd: where the user-level file lives even when it does not exist yet, so the
|
|
1313
1381
|
// client can name it in fix prompts without assuming ~/.claude
|
|
1314
|
-
res.json({ totalFiles, totalLines, totalBytes, totalChars, scopeChars, skillDesc, alwaysLoaded, conditional, onDemand, ids: sources.map(s => s.id), userClaudeMd: USER_CLAUDE_MD });
|
|
1382
|
+
res.json({ totalFiles, totalLines, totalBytes, totalChars, scopeChars, skillDesc, agentDesc, alwaysLoaded, conditional, onDemand, ids: sources.map(s => s.id), userClaudeMd: USER_CLAUDE_MD });
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
const MANAGED_SETTINGS_PATH = path.join(path.dirname(MANAGED_POLICY_PATHS[0]), 'managed-settings.json');
|
|
1386
|
+
// Claude Code caches server-managed settings (claude.ai admin console) in the config dir. Within the
|
|
1387
|
+
// managed tier they win over the system file, and the sources do not merge.
|
|
1388
|
+
const REMOTE_SETTINGS_PATH = path.join(CLAUDE_DIR, 'remote-settings.json');
|
|
1389
|
+
const USER_SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
|
|
1390
|
+
const managedSettingsPath = () => (fs.existsSync(REMOTE_SETTINGS_PATH) ? REMOTE_SETTINGS_PATH : MANAGED_SETTINGS_PATH);
|
|
1391
|
+
|
|
1392
|
+
function readJson(file) {
|
|
1393
|
+
try {
|
|
1394
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
1395
|
+
} catch {
|
|
1396
|
+
return null;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Managed settings override user settings; project and local settings are out of the user scope.
|
|
1401
|
+
function effectiveSetting(layers, key, fallback) {
|
|
1402
|
+
for (const { from, data } of layers) {
|
|
1403
|
+
if (data && data[key] !== undefined) return { value: data[key], from };
|
|
1404
|
+
}
|
|
1405
|
+
return { value: fallback, from: 'default' };
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function findOutputStyleFile(name) {
|
|
1409
|
+
const want = String(name).toLowerCase();
|
|
1410
|
+
const files = findMdFiles(path.join(CLAUDE_DIR, 'output-styles'));
|
|
1411
|
+
const byBase = files.find((f) => path.basename(f, '.md').toLowerCase() === want);
|
|
1412
|
+
if (byBase) return fileInfo(byBase);
|
|
1413
|
+
for (const file of files) {
|
|
1414
|
+
const info = fileInfo(file);
|
|
1415
|
+
if (info && fmName(info).toLowerCase() === want) return info;
|
|
1416
|
+
}
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function discoverHome() {
|
|
1421
|
+
const managedPath = managedSettingsPath();
|
|
1422
|
+
const managed = readJson(managedPath);
|
|
1423
|
+
const user = readJson(USER_SETTINGS_PATH);
|
|
1424
|
+
const layers = [
|
|
1425
|
+
{ from: 'managed', data: managed },
|
|
1426
|
+
{ from: 'user', data: user },
|
|
1427
|
+
];
|
|
1428
|
+
const style = effectiveSetting(layers, 'outputStyle', 'default');
|
|
1429
|
+
const styleFile = style.from === 'default' ? null : findOutputStyleFile(style.value);
|
|
1430
|
+
const envLayers = [
|
|
1431
|
+
{ from: 'managed', data: managed?.env },
|
|
1432
|
+
{ from: 'user', data: user?.env },
|
|
1433
|
+
{ from: 'env', data: process.env },
|
|
1434
|
+
];
|
|
1435
|
+
return {
|
|
1436
|
+
settingsPaths: { user: USER_SETTINGS_PATH, managed: managedPath },
|
|
1437
|
+
managed: {
|
|
1438
|
+
source: managedPath === REMOTE_SETTINGS_PATH ? 'server' : 'file',
|
|
1439
|
+
keys: managed && typeof managed === 'object' ? Object.keys(managed) : null,
|
|
1440
|
+
claudeMdChars: typeof managed?.claudeMd === 'string' ? managed.claudeMd.length : null,
|
|
1441
|
+
},
|
|
1442
|
+
outputStyle: {
|
|
1443
|
+
name: style.value,
|
|
1444
|
+
from: style.from,
|
|
1445
|
+
path: styleFile?.path || null,
|
|
1446
|
+
chars: styleFile?.chars ?? null,
|
|
1447
|
+
keepCodingInstructions: styleFile?.frontmatter?.['keep-coding-instructions'] ?? null,
|
|
1448
|
+
},
|
|
1449
|
+
settings: {
|
|
1450
|
+
autoMemoryEnabled: effectiveSetting(layers, 'autoMemoryEnabled', true),
|
|
1451
|
+
autoMemoryDirectory: effectiveSetting(layers, 'autoMemoryDirectory', null),
|
|
1452
|
+
claudeMdExcludes: effectiveSetting(layers, 'claudeMdExcludes', []),
|
|
1453
|
+
cleanupPeriodDays: effectiveSetting(layers, 'cleanupPeriodDays', 30),
|
|
1454
|
+
CLAUDE_CODE_DISABLE_AUTO_MEMORY: effectiveSetting(envLayers, 'CLAUDE_CODE_DISABLE_AUTO_MEMORY', null),
|
|
1455
|
+
},
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
app.get('/api/home', (_req, res) => {
|
|
1460
|
+
res.json(cached('home', discoverHome));
|
|
1315
1461
|
});
|
|
1316
1462
|
|
|
1317
1463
|
app.get('/api/stack', (_req, res) => {
|
|
@@ -1355,7 +1501,7 @@ app.get('/api/file', (req, res) => {
|
|
|
1355
1501
|
app.get('/api/imports', (req, res) => {
|
|
1356
1502
|
const filePath = req.query.path;
|
|
1357
1503
|
if (!filePath) return res.status(400).json({ error: 'path query param required' });
|
|
1358
|
-
const maxDepth =
|
|
1504
|
+
const maxDepth = 4;
|
|
1359
1505
|
const chain = [];
|
|
1360
1506
|
const visited = new Set();
|
|
1361
1507
|
|
|
@@ -1364,7 +1510,7 @@ app.get('/api/imports', (req, res) => {
|
|
|
1364
1510
|
visited.add(fp);
|
|
1365
1511
|
const info = fileInfo(fp);
|
|
1366
1512
|
if (!info) { chain.push({ path: fp, error: 'not found' }); return; }
|
|
1367
|
-
const imports = parseImports(info.content);
|
|
1513
|
+
const { imports } = parseImports(info.content);
|
|
1368
1514
|
const node = { path: fp, lines: info.lines, bytes: info.bytes, imports: [] };
|
|
1369
1515
|
chain.push(node);
|
|
1370
1516
|
for (const imp of imports) {
|