claude-memory-admin 1.3.0 → 1.5.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/src/model.mjs CHANGED
@@ -15,6 +15,9 @@ import { parseIndex, parseFrontmatter, extractWikilinks } from './parse.mjs';
15
15
  import { listMemoryFiles, memoryDir, resolveProjectPath, shortLabel } from './projects.mjs';
16
16
  import { autoMemoryState } from './settings.mjs';
17
17
  import { ageInDays, estimateTokens, findDuplicates, indexStats } from './stats.mjs';
18
+ import { memoryChecks, sessionChecks } from './checks.mjs';
19
+ import { listSessions, originSession, retention, transcriptDir } from './sessions.mjs';
20
+ import { rememberedPath } from './pathcache.mjs';
18
21
 
19
22
  export const TRASH_DIR = '.trash';
20
23
 
@@ -94,9 +97,30 @@ function buildResolver(memories) {
94
97
  return (target) => byName.get(target) || byStem.get(target) || null;
95
98
  }
96
99
 
100
+ function provenance(memory, resolveOrigin) {
101
+ const sessionId = memory.metadata?.originSessionId;
102
+ if (typeof sessionId !== 'string' || !sessionId) return null;
103
+ const session = resolveOrigin ? resolveOrigin(sessionId) : null;
104
+ return { sessionId, present: Boolean(session), modified: session ? session.modified : null };
105
+ }
106
+
107
+ function sessionContext(store) {
108
+ const slugDir = transcriptDir(store);
109
+ if (!slugDir) return { slugDir: null, retention: null, resolveOrigin: null, remembered: false };
110
+
111
+ const projectDir = store.pathExists ? store.path : null;
112
+ return {
113
+ slugDir,
114
+ retention: retention(listSessions(slugDir), { projectDir }),
115
+ resolveOrigin: (sessionId) => originSession(slugDir, sessionId),
116
+ remembered: Boolean(store.slug && rememberedPath(store.slug)),
117
+ };
118
+ }
119
+
97
120
  export function buildStore(store) {
98
121
  const { dir } = store;
99
122
  const hasMemoryDir = fs.existsSync(dir);
123
+ const sessions = sessionContext(store);
100
124
 
101
125
  const indexRaw = readIfExists(path.join(dir, 'MEMORY.md'));
102
126
  const index = indexRaw === null ? null : parseIndex(indexRaw);
@@ -138,6 +162,7 @@ export function buildStore(store) {
138
162
  : referencedFiles.has(memory.file)
139
163
  ? 'referenced'
140
164
  : 'orphan';
165
+ memory.origin = provenance(memory, sessions.resolveOrigin);
141
166
  }
142
167
 
143
168
  const existingFiles = new Set(files);
@@ -173,8 +198,17 @@ export function buildStore(store) {
173
198
  ...(health.longHooks.length
174
199
  ? [{ kind: 'long-hooks', severity: 'warn', count: health.longHooks.length, longest: health.longHooks[0] }]
175
200
  : []),
201
+ ...memoryChecks(memories, index),
202
+ ...sessionChecks(store, memories, sessions.retention, {
203
+ remembered: sessions.remembered,
204
+ resolveOrigin: sessions.resolveOrigin,
205
+ }),
176
206
  ];
207
+ health.issues.sort((a, b) => (a.severity === 'bad' ? 0 : 1) - (b.severity === 'bad' ? 0 : 1));
177
208
  health.issueCount = health.issues.length;
209
+ health.severity = health.issues.some((item) => item.severity === 'bad')
210
+ ? 'bad'
211
+ : health.issues.length ? 'warn' : 'ok';
178
212
 
179
213
  return {
180
214
  ...store,
@@ -203,6 +237,16 @@ export function buildStore(store) {
203
237
  },
204
238
  duplicates: findDuplicates(memories),
205
239
  trash: listTrash(dir),
240
+ sessions: sessions.retention
241
+ ? {
242
+ count: sessions.retention.count,
243
+ bytes: sessions.retention.bytes,
244
+ retentionDays: sessions.retention.days,
245
+ evidenceExpiresInDays: sessions.retention.evidenceExpiresInDays,
246
+ expiringCount: sessions.retention.expiringCount,
247
+ remembered: sessions.remembered,
248
+ }
249
+ : null,
206
250
  };
207
251
  }
208
252
 
@@ -218,6 +262,7 @@ export function buildProject(root, slug) {
218
262
  kind: 'auto',
219
263
  dir: memoryDir(root, slug),
220
264
  path: resolved.path,
265
+ pathExists: resolved.exists,
221
266
  label: shortLabel(resolved.path),
222
267
  resolvedBy: resolved.resolvedBy,
223
268
  // The decode that could not be confirmed, offered as a starting point when
@@ -0,0 +1,108 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const MAX_PATH_CHECKS = 200;
5
+ export const MAX_INDEXED_FILES = 60000;
6
+
7
+ export const SKIP_DIRS = new Set([
8
+ '.git', 'node_modules', 'bin', 'obj', 'dist', 'build', 'out', 'target', 'vendor',
9
+ '.next', '.nuxt', '.svelte-kit', '.venv', 'venv', '__pycache__', '.gradle', '.idea',
10
+ 'Pods', 'DerivedData', 'coverage', '.turbo', '.cache', '.angular', '.parcel-cache',
11
+ ]);
12
+
13
+ const CODE_SPAN = /`([^`\n]+)`/g;
14
+ const SOURCE_EXT = /\.(mjs|cjs|jsx?|tsx?|json|jsonc|md|mdx|css|scss|less|html|vue|svelte|py|rb|go|rs|java|kt|swift|cs|fs|php|sh|zsh|ya?ml|toml|xml|sql|proto|lock|gradle|csproj|sln)$/i;
15
+ const BARE_EXTENSION = /^\.[A-Za-z0-9]+$/;
16
+ const LINE_SUFFIX = /:\d+(?:[-:]\d+)?$/;
17
+ const GLOB_OR_SHELL = /[*?{}[\]()<>|$=!"'\\]/;
18
+
19
+ function candidate(raw) {
20
+ let token = raw.trim();
21
+ if (!token || /\s/.test(token)) return null;
22
+ if (token.includes('://') || token.startsWith('@')) return null;
23
+ if (GLOB_OR_SHELL.test(token)) return null;
24
+
25
+ token = token.replace(LINE_SUFFIX, '').replace(/[.,;:]+$/, '').replace(/\/+$/, '');
26
+ if (token.startsWith('./')) token = token.slice(2);
27
+ if (!token || token.startsWith('/') || token.startsWith('~')) return null;
28
+ if (token.split('/').includes('..')) return null;
29
+ if (token.includes('...') || token.length > 200) return null;
30
+ if (BARE_EXTENSION.test(token)) return null;
31
+ if (!SOURCE_EXT.test(token)) return null;
32
+ if (token.split('/').some((segment) => SKIP_DIRS.has(segment))) return null;
33
+
34
+ return token;
35
+ }
36
+
37
+ export function pathCandidates(body) {
38
+ const found = new Set();
39
+ for (const match of String(body || '').matchAll(CODE_SPAN)) {
40
+ const token = candidate(match[1]);
41
+ if (token) found.add(token);
42
+ }
43
+ return [...found];
44
+ }
45
+
46
+ export function indexProject(root, { skipDirs = SKIP_DIRS, limit = MAX_INDEXED_FILES } = {}) {
47
+ const byLastSegment = new Map();
48
+ let count = 0;
49
+ let truncated = false;
50
+
51
+ const walk = (dir, prefix) => {
52
+ if (truncated) return;
53
+ let entries;
54
+ try {
55
+ entries = fs.readdirSync(dir, { withFileTypes: true });
56
+ } catch {
57
+ return;
58
+ }
59
+ for (const entry of entries) {
60
+ if (count >= limit) {
61
+ truncated = true;
62
+ return;
63
+ }
64
+ if (skipDirs.has(entry.name)) continue;
65
+
66
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
67
+ count += 1;
68
+ if (!byLastSegment.has(entry.name)) byLastSegment.set(entry.name, []);
69
+ byLastSegment.get(entry.name).push(relative);
70
+
71
+ if (entry.isDirectory()) walk(path.join(dir, entry.name), relative);
72
+ }
73
+ };
74
+
75
+ walk(root, '');
76
+ return { byLastSegment, count, truncated };
77
+ }
78
+
79
+ function resolvesInProject(index, token) {
80
+ const matches = index.byLastSegment.get(token.split('/').pop());
81
+ if (!matches) return false;
82
+ if (!token.includes('/')) return true;
83
+ return matches.some((relative) => relative === token || relative.endsWith(`/${token}`));
84
+ }
85
+
86
+ export function verifyPaths(projectDir, memories) {
87
+ const root = path.resolve(projectDir);
88
+ const index = indexProject(root);
89
+ const missing = [];
90
+ let checked = 0;
91
+ let capped = false;
92
+
93
+ for (const memory of memories) {
94
+ for (const token of pathCandidates(memory.body)) {
95
+ if (checked >= MAX_PATH_CHECKS) {
96
+ capped = true;
97
+ break;
98
+ }
99
+ checked += 1;
100
+ if (!resolvesInProject(index, token)) {
101
+ missing.push({ kind: 'stale-path', severity: 'warn', file: memory.file, name: memory.name, token });
102
+ }
103
+ }
104
+ if (capped) break;
105
+ }
106
+
107
+ return { projectDir: root, checked, capped, indexed: index.count, truncated: index.truncated, missing };
108
+ }
package/src/projects.mjs CHANGED
@@ -51,12 +51,16 @@ export function projectsRoot() {
51
51
  return resolveRoot().path;
52
52
  }
53
53
 
54
+ // The escalation ladder for reading the head of a transcript: the small read
55
+ // almost always suffices and the large one is the fallback.
56
+ export const HEAD_READS = [16384, 131072];
57
+
54
58
  /**
55
59
  * Read the head of a file without pulling a multi-megabyte transcript into
56
60
  * memory. The `cwd` field shows up within the first few lines in practice, so
57
61
  * the small read almost always suffices and the large one is a fallback.
58
62
  */
59
- function readHead(file, bytes) {
63
+ export function readHead(file, bytes) {
60
64
  const fd = fs.openSync(file, 'r');
61
65
  try {
62
66
  const buf = Buffer.alloc(bytes);
@@ -90,7 +94,7 @@ function findTranscripts(dir, depth = 2) {
90
94
 
91
95
  /** The first `cwd` in one transcript, escalating the read only if the small one missed. */
92
96
  function cwdInTranscript(file) {
93
- for (const size of [16384, 131072]) {
97
+ for (const size of HEAD_READS) {
94
98
  let head;
95
99
  try {
96
100
  head = readHead(file, size);
@@ -0,0 +1,174 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { HEAD_READS, readHead } from './projects.mjs';
4
+ import { cleanupPeriodDays } from './settings.mjs';
5
+ import { ageInDays } from './stats.mjs';
6
+
7
+ export const EXPIRY_WARNING_DAYS = 7;
8
+ export const PROMPT_TITLE_CHARS = 80;
9
+ export const PROMPT_PREVIEW_CHARS = 400;
10
+
11
+ const SESSION_ID = /^[0-9a-fA-F][0-9a-fA-F-]{7,63}$/;
12
+ const SELF_DESCRIBING_KEYS = ['slug', 'gitBranch', 'model', 'version', 'cwd'];
13
+ const WRAPPED_BY_CLAUDE_CODE = /^<(local-command-[a-z]+|command-(message|name|args)|system-reminder)>/;
14
+
15
+ const isSubagentTranscript = (name) => name.startsWith('agent-');
16
+ const isSessionTranscript = (name) => name.endsWith('.jsonl') && !isSubagentTranscript(name);
17
+
18
+ export function listSessions(slugDir) {
19
+ let entries;
20
+ try {
21
+ entries = fs.readdirSync(slugDir, { withFileTypes: true });
22
+ } catch {
23
+ return [];
24
+ }
25
+
26
+ const sessions = [];
27
+ for (const entry of entries) {
28
+ if (!entry.isFile() || !isSessionTranscript(entry.name)) continue;
29
+ const file = path.join(slugDir, entry.name);
30
+ let stat;
31
+ try {
32
+ stat = fs.statSync(file);
33
+ } catch {
34
+ continue;
35
+ }
36
+ sessions.push({
37
+ id: entry.name.replace(/\.jsonl$/, ''),
38
+ file,
39
+ bytes: stat.size,
40
+ modified: new Date(stat.mtimeMs).toISOString(),
41
+ modifiedMs: stat.mtimeMs,
42
+ });
43
+ }
44
+ return sessions.sort((a, b) => b.modifiedMs - a.modifiedMs);
45
+ }
46
+
47
+ function promptText(content) {
48
+ if (typeof content === 'string') return content;
49
+ if (!Array.isArray(content)) return null;
50
+ const joined = content
51
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
52
+ .map((block) => block.text)
53
+ .join(' ');
54
+ return joined || null;
55
+ }
56
+
57
+ function absorbRecord(record, found) {
58
+ if (!record || typeof record !== 'object') return;
59
+
60
+ if (record.type === 'ai-title' && typeof record.aiTitle === 'string' && record.aiTitle) {
61
+ found.aiTitle = record.aiTitle;
62
+ }
63
+ for (const key of SELF_DESCRIBING_KEYS) {
64
+ const value = typeof record[key] === 'string' ? record[key] : record.message?.[key];
65
+ if (found[key] === null && typeof value === 'string' && value) found[key] = value;
66
+ }
67
+ if (found.prompt === null && record.type === 'user' && !record.isSidechain) {
68
+ const text = promptText(record.message?.content)?.trim();
69
+ if (text && !WRAPPED_BY_CLAUDE_CODE.test(text)) found.prompt = text;
70
+ }
71
+ }
72
+
73
+ function absorbChunk(head, found) {
74
+ for (const line of head.split('\n')) {
75
+ if (!line.startsWith('{')) continue;
76
+ try {
77
+ absorbRecord(JSON.parse(line), found);
78
+ } catch {
79
+ continue;
80
+ }
81
+ }
82
+ }
83
+
84
+ function condense(text, chars) {
85
+ const flat = text.replace(/\s+/g, ' ').trim();
86
+ return flat.length > chars ? `${flat.slice(0, chars - 1)}…` : flat;
87
+ }
88
+
89
+ export function sessionSummary(file, { bytes = null } = {}) {
90
+ const found = { aiTitle: null, slug: null, gitBranch: null, model: null, version: null, cwd: null, prompt: null };
91
+
92
+ let size = bytes;
93
+ if (size === null) {
94
+ try {
95
+ size = fs.statSync(file).size;
96
+ } catch {
97
+ size = 0;
98
+ }
99
+ }
100
+
101
+ for (const read of HEAD_READS) {
102
+ let head;
103
+ try {
104
+ head = readHead(file, read);
105
+ } catch {
106
+ break;
107
+ }
108
+ absorbChunk(head, found);
109
+ if (found.aiTitle || size <= read) break;
110
+ }
111
+
112
+ const titleFrom = found.aiTitle ? 'ai-title' : found.slug ? 'slug' : found.prompt ? 'prompt' : null;
113
+ const title = found.aiTitle
114
+ || found.slug
115
+ || (found.prompt ? condense(found.prompt, PROMPT_TITLE_CHARS) : null);
116
+
117
+ return {
118
+ title,
119
+ titleFrom,
120
+ slug: found.slug,
121
+ gitBranch: found.gitBranch,
122
+ model: found.model,
123
+ version: found.version,
124
+ cwd: found.cwd,
125
+ prompt: found.prompt ? condense(found.prompt, PROMPT_PREVIEW_CHARS) : null,
126
+ };
127
+ }
128
+
129
+ export function retention(sessions, { projectDir = null, now = Date.now() } = {}) {
130
+ const days = cleanupPeriodDays(projectDir ? { projectDir } : {});
131
+ const dated = sessions.map((session) => {
132
+ const ageDays = ageInDays(session.modified, now);
133
+ return { ...session, ageDays, expiresInDays: ageDays === null ? null : Math.max(0, days - ageDays) };
134
+ });
135
+ const remaining = dated.map((session) => session.expiresInDays).filter((value) => value !== null);
136
+
137
+ return {
138
+ days,
139
+ dateFrom: 'mtime',
140
+ sessions: dated,
141
+ count: dated.length,
142
+ bytes: dated.reduce((sum, session) => sum + session.bytes, 0),
143
+ evidenceExpiresInDays: remaining.length ? Math.max(...remaining) : null,
144
+ expiringCount: remaining.filter((value) => value <= EXPIRY_WARNING_DAYS).length,
145
+ };
146
+ }
147
+
148
+ export function originSession(slugDir, sessionId) {
149
+ if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) return null;
150
+ const file = path.join(slugDir, `${sessionId}.jsonl`);
151
+ let stat;
152
+ try {
153
+ stat = fs.statSync(file);
154
+ } catch {
155
+ return null;
156
+ }
157
+ if (!stat.isFile()) return null;
158
+ return { id: sessionId, file, bytes: stat.size, modified: new Date(stat.mtimeMs).toISOString() };
159
+ }
160
+
161
+ export function transcriptDir(store) {
162
+ return store && store.kind === 'auto' && store.dir ? path.dirname(store.dir) : null;
163
+ }
164
+
165
+ export function sessionsWithSummaries(slugDir, { projectDir = null, now = Date.now() } = {}) {
166
+ const report = retention(listSessions(slugDir), { projectDir, now });
167
+ return {
168
+ ...report,
169
+ sessions: report.sessions.map((session) => ({
170
+ ...session,
171
+ ...sessionSummary(session.file, { bytes: session.bytes }),
172
+ })),
173
+ };
174
+ }
package/src/settings.mjs CHANGED
@@ -19,6 +19,21 @@ export const USER_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json')
19
19
  /** Default when nothing sets cleanupPeriodDays; transcripts older than this are swept. */
20
20
  export const DEFAULT_CLEANUP_PERIOD_DAYS = 30;
21
21
 
22
+ export const SETTINGS_SEVERITY = {
23
+ unparseable: 'bad',
24
+ unreadable: 'bad',
25
+ 'not-object': 'bad',
26
+ 'invalid-auto-memory-directory': 'bad',
27
+ };
28
+
29
+ export function summariseSettings(problems) {
30
+ const list = problems || [];
31
+ return {
32
+ count: list.length,
33
+ severity: list.some((problem) => problem.severity === 'bad') ? 'bad' : list.length ? 'warn' : 'ok',
34
+ };
35
+ }
36
+
22
37
  function managedDir() {
23
38
  if (process.platform === 'darwin') return '/Library/Application Support/ClaudeCode';
24
39
  if (process.platform === 'win32') return 'C:\\Program Files\\ClaudeCode';
@@ -226,12 +241,19 @@ export function settingsReport(options = {}) {
226
241
 
227
242
  const problems = layers
228
243
  .filter((layer) => layer.status !== 'ok' && layer.status !== 'absent')
229
- .map((layer) => ({ kind: layer.status, scope: layer.scope, file: layer.file, detail: layer.error }));
244
+ .map((layer) => ({
245
+ kind: layer.status,
246
+ severity: SETTINGS_SEVERITY[layer.status] || 'warn',
247
+ scope: layer.scope,
248
+ file: layer.file,
249
+ detail: layer.error,
250
+ }));
230
251
 
231
252
  const directory = resolveMemoryDirectory(options);
232
253
  if (directory && directory.invalid) {
233
254
  problems.push({
234
255
  kind: 'invalid-auto-memory-directory',
256
+ severity: SETTINGS_SEVERITY['invalid-auto-memory-directory'],
235
257
  scope: directory.scope,
236
258
  file: directory.file,
237
259
  detail: `"${directory.raw}" is ${directory.invalid}`,