claude-memory-admin 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -5
- package/package.json +1 -1
- package/public/app.mjs +479 -289
- package/public/dialog.mjs +84 -0
- package/public/dom.mjs +15 -0
- package/public/index.html +1 -1
- package/public/styles.css +1 -1
- package/public/ui.mjs +20 -6
- package/server.mjs +9 -0
- package/src/checks.mjs +58 -0
- package/src/model.mjs +40 -1
- package/src/projects.mjs +6 -2
- package/src/sessions.mjs +174 -0
package/src/sessions.mjs
ADDED
|
@@ -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
|
+
}
|