claude-memory-admin 1.7.0 → 1.9.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/README.md +43 -11
- package/package.json +3 -3
- package/public/app.mjs +17 -2
- package/public/dom.mjs +1 -0
- package/public/index.html +6 -5
- package/public/state.mjs +3 -1
- package/public/store.mjs +2 -1
- package/public/styles.css +1 -1
- package/public/ui.mjs +4 -3
- package/public/views/ccusage.mjs +150 -0
- package/public/views/header.mjs +4 -1
- package/public/views/rtk.mjs +10 -12
- package/public/views/sessions.mjs +65 -5
- package/public/views/stores.mjs +8 -0
- package/public/views/tools.mjs +100 -32
- package/server.mjs +30 -7
- package/src/ccusage.mjs +122 -0
- package/src/liveSessions.mjs +46 -0
- package/src/rtk.mjs +10 -78
- package/src/toolrun.mjs +78 -0
- package/src/tools.mjs +47 -0
|
@@ -5,6 +5,8 @@ import { state } from '/state.mjs';
|
|
|
5
5
|
import { sizeLabel, sessionDate, isAt } from '/parts.mjs';
|
|
6
6
|
import { selectMemory } from '/store.mjs';
|
|
7
7
|
import { paint } from '/bus.mjs';
|
|
8
|
+
import { goTo } from '/parts.mjs';
|
|
9
|
+
import { toast } from '/api.mjs';
|
|
8
10
|
|
|
9
11
|
const TITLE_SOURCE = {
|
|
10
12
|
'ai-title': 'title Claude Code generated for this session',
|
|
@@ -12,11 +14,32 @@ const TITLE_SOURCE = {
|
|
|
12
14
|
prompt: 'first prompt, no generated title found in the part read',
|
|
13
15
|
};
|
|
14
16
|
|
|
15
|
-
function
|
|
17
|
+
export function focusSession(id) {
|
|
18
|
+
state.aux.sessionFocus = id;
|
|
19
|
+
state.aux.sessionDay = null;
|
|
20
|
+
goTo('environment', 'sessions');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function agoLabel(ms) {
|
|
24
|
+
const seconds = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
25
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
26
|
+
const minutes = Math.round(seconds / 60);
|
|
27
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
28
|
+
return `${Math.round(minutes / 60)}h ago`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sessionRow(session, memoriesFrom, focused, live) {
|
|
16
32
|
const soon = session.expiresInDays !== null && session.expiresInDays <= 7;
|
|
17
|
-
const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
|
|
33
|
+
const caret = node('span', { class: ui.contextCaret, text: focused ? '\u25be' : '\u25b8' });
|
|
18
34
|
|
|
19
35
|
const tags = [];
|
|
36
|
+
if (live) {
|
|
37
|
+
tags.push(node('span', {
|
|
38
|
+
class: ui.badge('ok'),
|
|
39
|
+
text: live.status === 'waiting' ? 'waiting' : live.status || 'active',
|
|
40
|
+
title: live.waitingFor || `updated ${agoLabel(live.updatedAt)}`,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
20
43
|
if (session.gitBranch) tags.push(node('span', { class: ui.badge('neutral'), text: session.gitBranch }));
|
|
21
44
|
if (session.model) tags.push(node('span', { class: ui.badge('neutral'), text: session.model }));
|
|
22
45
|
if (session.expiresInDays !== null) {
|
|
@@ -29,7 +52,7 @@ function sessionRow(session, memoriesFrom) {
|
|
|
29
52
|
tags.push(node('span', { class: ui.badge('project'), text: memory.name }));
|
|
30
53
|
}
|
|
31
54
|
|
|
32
|
-
const body = node('div', { class: ui.contextBody, hidden:
|
|
55
|
+
const body = node('div', { class: ui.contextBody, hidden: !focused });
|
|
33
56
|
const facts = node('dl', { class: ui.metaList });
|
|
34
57
|
const rows = [
|
|
35
58
|
['session', session.id],
|
|
@@ -62,7 +85,7 @@ function sessionRow(session, memoriesFrom) {
|
|
|
62
85
|
|
|
63
86
|
const button = node('button', {
|
|
64
87
|
class: ui.contextRowButton,
|
|
65
|
-
'aria-expanded':
|
|
88
|
+
'aria-expanded': String(Boolean(focused)),
|
|
66
89
|
onclick: (event) => {
|
|
67
90
|
const open = body.hidden;
|
|
68
91
|
body.hidden = !open;
|
|
@@ -188,8 +211,33 @@ export async function renderSessions(container) {
|
|
|
188
211
|
paint('tab');
|
|
189
212
|
};
|
|
190
213
|
|
|
214
|
+
const active = state.activeSessions.filter((s) => s.storeId === state.storeId);
|
|
215
|
+
const byId = new Map(active.map((s) => [s.sessionId, s]));
|
|
216
|
+
|
|
191
217
|
container.append(node('p', { class: ui.noteTight, text: 'The transcripts sitting beside this store\u2019s memory. Claude Code deletes them once they pass the retention period and never touches memory/, so this is the evidence that expires while the memories stay. This tab only reads, and only the head of each file - nothing here deletes a transcript.' }));
|
|
192
218
|
|
|
219
|
+
if (active.length) {
|
|
220
|
+
const list = node('div', { class: ui.card });
|
|
221
|
+
for (const session of active) {
|
|
222
|
+
list.append(node('button', {
|
|
223
|
+
class: ui.contextRowButton,
|
|
224
|
+
onclick: () => focusSession(session.sessionId),
|
|
225
|
+
}, [
|
|
226
|
+
node('div', { class: ui.contextMain }, [
|
|
227
|
+
node('div', { class: ui.contextTags }, [
|
|
228
|
+
node('span', {
|
|
229
|
+
class: ui.badge('ok'),
|
|
230
|
+
text: session.status === 'waiting' ? 'waiting' : session.status || 'active',
|
|
231
|
+
}),
|
|
232
|
+
]),
|
|
233
|
+
node('div', { class: ui.contextFile, text: session.name || session.sessionId }),
|
|
234
|
+
]),
|
|
235
|
+
node('div', { class: ui.contextSize, text: session.waitingFor || agoLabel(session.updatedAt) }),
|
|
236
|
+
]));
|
|
237
|
+
}
|
|
238
|
+
container.append(meterPanel('Active now', null, list));
|
|
239
|
+
}
|
|
240
|
+
|
|
193
241
|
container.append(node('div', { class: ui.meter }, [
|
|
194
242
|
node('div', { class: ui.meterTop }, [
|
|
195
243
|
node('span', { class: ui.meterValue, text: String(data.count) }),
|
|
@@ -227,9 +275,21 @@ export async function renderSessions(container) {
|
|
|
227
275
|
}));
|
|
228
276
|
}
|
|
229
277
|
container.append(listHead);
|
|
278
|
+
const focus = state.aux.sessionFocus;
|
|
279
|
+
state.aux.sessionFocus = null;
|
|
280
|
+
|
|
230
281
|
const card = node('div', { class: ui.card });
|
|
282
|
+
let focused = null;
|
|
231
283
|
for (const session of visible) {
|
|
232
|
-
|
|
284
|
+
const row = sessionRow(session, byOrigin.get(session.id) || [], session.id === focus, byId.get(session.id));
|
|
285
|
+
if (session.id === focus) focused = row;
|
|
286
|
+
card.append(row);
|
|
233
287
|
}
|
|
234
288
|
container.append(card);
|
|
289
|
+
|
|
290
|
+
if (focus && focused) {
|
|
291
|
+
requestAnimationFrame(() => focused.scrollIntoView({ block: 'center' }));
|
|
292
|
+
} else if (focus) {
|
|
293
|
+
toast('That transcript is no longer beside this store - Claude Code has swept it.');
|
|
294
|
+
}
|
|
235
295
|
}
|
package/public/views/stores.mjs
CHANGED
|
@@ -22,10 +22,17 @@ function issueTitle(store) {
|
|
|
22
22
|
return parts.length ? `${store.dir}\n${parts.join(', ')}` : store.dir;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function activeTitle(active) {
|
|
26
|
+
const waiting = active.filter((s) => s.status === 'waiting').length;
|
|
27
|
+
const label = active.length === 1 ? '1 session' : `${active.length} sessions`;
|
|
28
|
+
return waiting ? `${label} - ${waiting} waiting for input` : `${label} ${active.length === 1 ? 'is' : 'are'} busy`;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
function storeButton(store) {
|
|
26
32
|
const global = store.kind === 'global';
|
|
27
33
|
const health = !global && !store.hasMemoryDir ? 'none' : store.severity || 'ok';
|
|
28
34
|
const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
|
|
35
|
+
const active = state.activeSessions.filter((s) => s.storeId === store.id);
|
|
29
36
|
return node('button', {
|
|
30
37
|
class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
|
|
31
38
|
onclick: () => openStore(store.id),
|
|
@@ -34,6 +41,7 @@ function storeButton(store) {
|
|
|
34
41
|
node('span', { class: ui.storeRow }, [
|
|
35
42
|
node('span', { class: ui.dot(health) }),
|
|
36
43
|
node('span', { class: ui.storeName, text: store.label }),
|
|
44
|
+
active.length ? node('span', { class: ui.dot('ok'), title: activeTitle(active) }) : null,
|
|
37
45
|
off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
|
|
38
46
|
global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
|
|
39
47
|
]),
|
package/public/views/tools.mjs
CHANGED
|
@@ -4,73 +4,141 @@ import { api } from '/api.mjs';
|
|
|
4
4
|
import { state } from '/state.mjs';
|
|
5
5
|
import { isAt } from '/parts.mjs';
|
|
6
6
|
import { paint } from '/bus.mjs';
|
|
7
|
-
import { renderRtk } from '/views/rtk.mjs';
|
|
7
|
+
import { renderRtk, rtkSummary } from '/views/rtk.mjs';
|
|
8
|
+
import { renderCcusage, ccusageSummary } from '/views/ccusage.mjs';
|
|
8
9
|
|
|
9
10
|
const OPT_IN = 'toolsOptIn';
|
|
10
11
|
|
|
11
|
-
const
|
|
12
|
+
const PANELS = {
|
|
13
|
+
rtk: { render: renderRtk, summary: rtkSummary },
|
|
14
|
+
ccusage: { render: renderCcusage, summary: ccusageSummary },
|
|
15
|
+
};
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
const installed = () => state.tools.filter((tool) => tool.found && PANELS[tool.id]);
|
|
18
|
+
|
|
19
|
+
const openKey = (id) => `toolOpen:${id}`;
|
|
20
|
+
const isOpen = (id) => localStorage.getItem(openKey(id)) === '1';
|
|
21
|
+
|
|
22
|
+
function setOpen(id, open) {
|
|
23
|
+
if (open) localStorage.setItem(openKey(id), '1');
|
|
24
|
+
else localStorage.removeItem(openKey(id));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function fetchTool(id) {
|
|
28
|
+
const storeId = state.storeId;
|
|
15
29
|
let next;
|
|
16
30
|
try {
|
|
17
|
-
next = { data: await api(`/api/stores/${encodeURIComponent(
|
|
31
|
+
next = { data: await api(`/api/stores/${encodeURIComponent(storeId)}/tools/${encodeURIComponent(id)}`) };
|
|
18
32
|
} catch (err) {
|
|
19
33
|
next = { error: err.message };
|
|
20
34
|
}
|
|
21
|
-
if (state.storeId !==
|
|
22
|
-
state.aux.
|
|
35
|
+
if (state.storeId !== storeId) return;
|
|
36
|
+
state.aux.tools = { ...state.aux.tools, [id]: next };
|
|
23
37
|
if (isAt('environment', 'tools')) paint('tab');
|
|
24
38
|
}
|
|
25
39
|
|
|
26
|
-
function
|
|
40
|
+
function start({ remember = true } = {}) {
|
|
27
41
|
if (remember) localStorage.setItem(OPT_IN, '1');
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
const running = {};
|
|
43
|
+
for (const tool of installed()) running[tool.id] = { running: true };
|
|
44
|
+
state.aux.tools = running;
|
|
45
|
+
for (const tool of installed()) fetchTool(tool.id);
|
|
30
46
|
}
|
|
31
47
|
|
|
32
|
-
function
|
|
33
|
-
|
|
48
|
+
function readAll() {
|
|
49
|
+
start();
|
|
34
50
|
paint('tab');
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
function turnOff() {
|
|
38
54
|
localStorage.removeItem(OPT_IN);
|
|
39
|
-
state.aux.
|
|
55
|
+
state.aux.tools = null;
|
|
40
56
|
paint('tab');
|
|
41
57
|
}
|
|
42
58
|
|
|
43
|
-
function gate(container,
|
|
59
|
+
function gate(container, tools) {
|
|
60
|
+
const names = tools.map((tool) => tool.label).join(' and ');
|
|
61
|
+
const verb = tools.length > 1 ? 'are installed' : 'is installed';
|
|
44
62
|
container.append(node('div', { class: ui.issue(false) }, [
|
|
45
63
|
node('div', { class: ui.issueBody }, [
|
|
46
|
-
node('div', { class: ui.issueTitle, text:
|
|
47
|
-
node('div', { class: ui.issueDetail, text:
|
|
64
|
+
node('div', { class: ui.issueTitle, text: `${names} ${verb}, and not being read` }),
|
|
65
|
+
node('div', { class: ui.issueDetail, text: `Each keeps a ledger of what it saved or spent on your behalf. Reading them runs ${names} on this machine, so it stays off until you ask.` }),
|
|
48
66
|
]),
|
|
49
|
-
node('button', { class: ui.buttonSmall, text: 'Read
|
|
67
|
+
node('button', { class: ui.buttonSmall, text: 'Read tool stats', onclick: readAll }),
|
|
50
68
|
]));
|
|
51
69
|
}
|
|
52
70
|
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
function headline(tool, slot) {
|
|
72
|
+
if (!slot || slot.running) return 'reading…';
|
|
73
|
+
if (slot.error) return 'could not be read';
|
|
74
|
+
return PANELS[tool.id].summary(slot.data);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function subtitle(tool, slot) {
|
|
78
|
+
const report = slot && slot.data;
|
|
79
|
+
if (report && report.version) return `${tool.label} ${report.version}`;
|
|
80
|
+
return tool.label;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function body(tool, slot) {
|
|
84
|
+
const host = node('div');
|
|
85
|
+
if (!slot || slot.running) {
|
|
86
|
+
host.append(node('p', { class: ui.note, text: `Reading the ${tool.label} ledger…` }));
|
|
87
|
+
return host;
|
|
88
|
+
}
|
|
89
|
+
if (slot.error) {
|
|
90
|
+
host.append(node('div', { class: ui.issue(true) }, [
|
|
91
|
+
node('div', { class: ui.issueBody }, [
|
|
92
|
+
node('div', { class: ui.issueTitle, text: `${tool.label} could not be read` }),
|
|
93
|
+
node('div', { class: ui.issueDetail, text: slot.error }),
|
|
94
|
+
]),
|
|
95
|
+
node('button', { class: ui.buttonSmall, text: 'Try again', onclick: readAll }),
|
|
96
|
+
]));
|
|
97
|
+
return host;
|
|
98
|
+
}
|
|
99
|
+
PANELS[tool.id].render(host, slot.data);
|
|
100
|
+
return host;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function accordion(container, tool, slot) {
|
|
104
|
+
const open = isOpen(tool.id);
|
|
105
|
+
const panel = body(tool, slot);
|
|
106
|
+
panel.hidden = !open;
|
|
107
|
+
|
|
108
|
+
const caret = node('span', { class: ui.contextCaret, text: open ? '▾' : '▸' });
|
|
109
|
+
|
|
110
|
+
container.append(node('button', {
|
|
111
|
+
class: ui.contextRowButton,
|
|
112
|
+
'aria-expanded': String(open),
|
|
113
|
+
onclick: (event) => {
|
|
114
|
+
const next = panel.hidden;
|
|
115
|
+
panel.hidden = !next;
|
|
116
|
+
caret.textContent = next ? '▾' : '▸';
|
|
117
|
+
event.currentTarget.setAttribute('aria-expanded', String(next));
|
|
118
|
+
setOpen(tool.id, next);
|
|
119
|
+
},
|
|
120
|
+
}, [
|
|
121
|
+
node('div', { class: ui.contextMain }, [
|
|
122
|
+
node('div', { class: ui.contextFile }, [caret, document.createTextNode(subtitle(tool, slot))]),
|
|
58
123
|
]),
|
|
59
|
-
node('
|
|
60
|
-
node('button', { class: ui.buttonSmall, text: 'Try again', onclick: readRtk }),
|
|
124
|
+
node('div', { class: ui.contextSize, text: headline(tool, slot) }),
|
|
61
125
|
]));
|
|
126
|
+
container.append(panel);
|
|
62
127
|
}
|
|
63
128
|
|
|
64
129
|
export function renderTools(container) {
|
|
65
|
-
const
|
|
66
|
-
if (!
|
|
130
|
+
const tools = installed();
|
|
131
|
+
if (!tools.length) return;
|
|
67
132
|
|
|
68
|
-
if (!state.aux.
|
|
133
|
+
if (!state.aux.tools && localStorage.getItem(OPT_IN) === '1') start({ remember: false });
|
|
134
|
+
if (!state.aux.tools) return gate(container, tools);
|
|
69
135
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
136
|
+
container.append(node('div', { class: ui.listBar }, [
|
|
137
|
+
node('span', { class: ui.sectionLabelInline, text: 'Companion tools' }),
|
|
138
|
+
node('span', { class: ui.listSpacer }),
|
|
139
|
+
node('button', { class: ui.buttonSmall, text: 'Re-read', onclick: readAll }),
|
|
140
|
+
node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: turnOff }),
|
|
141
|
+
]));
|
|
74
142
|
|
|
75
|
-
|
|
143
|
+
for (const tool of tools) accordion(container, tool, state.aux.tools[tool.id]);
|
|
76
144
|
}
|
package/server.mjs
CHANGED
|
@@ -16,8 +16,9 @@ import { listStores } from './src/stores.mjs';
|
|
|
16
16
|
import { forgetPath, rememberPath } from './src/pathcache.mjs';
|
|
17
17
|
import { searchAll } from './src/search.mjs';
|
|
18
18
|
import { verifyPaths } from './src/pathcheck.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import { detectTools, toolReport } from './src/tools.mjs';
|
|
20
20
|
import { sessionsWithSummaries, transcriptDir } from './src/sessions.mjs';
|
|
21
|
+
import { readLiveSessions } from './src/liveSessions.mjs';
|
|
21
22
|
import {
|
|
22
23
|
addIndexEntry,
|
|
23
24
|
addIndexEntryPreview,
|
|
@@ -129,6 +130,14 @@ function requireStore(id) {
|
|
|
129
130
|
return store;
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
const VERSION = (() => {
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || null;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
})();
|
|
140
|
+
|
|
132
141
|
function storeProjectDir(store) {
|
|
133
142
|
// The global store is the user scope itself, which no project owns.
|
|
134
143
|
if (store.kind === 'global') return null;
|
|
@@ -218,7 +227,8 @@ async function handleApi(req, res, url) {
|
|
|
218
227
|
rootSource: origin.path === ROOT ? origin.source : 'flag',
|
|
219
228
|
rootFile: origin.path === ROOT ? origin.file : null,
|
|
220
229
|
rootWarning: origin.invalid,
|
|
221
|
-
|
|
230
|
+
version: VERSION,
|
|
231
|
+
tools: detectTools(),
|
|
222
232
|
stores: listStores(ROOT),
|
|
223
233
|
});
|
|
224
234
|
}
|
|
@@ -233,6 +243,18 @@ async function handleApi(req, res, url) {
|
|
|
233
243
|
return sendJson(res, 200, { stores: issueSweep() });
|
|
234
244
|
}
|
|
235
245
|
|
|
246
|
+
// Same "ahead of requireStore" reasoning as the issues sweep above: real
|
|
247
|
+
// store ids always carry a "kind:" prefix, so "active" can never collide.
|
|
248
|
+
if (segments.length === 3 && segments[1] === 'stores' && segments[2] === 'active' && req.method === 'GET') {
|
|
249
|
+
const live = readLiveSessions();
|
|
250
|
+
const projectStores = listStores(ROOT).filter((s) => s.kind === 'auto' && s.pathExists);
|
|
251
|
+
const sessions = live.map((session) => ({
|
|
252
|
+
...session,
|
|
253
|
+
storeId: projectStores.find((s) => s.path === session.cwd)?.id ?? null,
|
|
254
|
+
}));
|
|
255
|
+
return sendJson(res, 200, { sessions });
|
|
256
|
+
}
|
|
257
|
+
|
|
236
258
|
if (segments[1] !== 'stores' || segments.length < 3) {
|
|
237
259
|
return sendJson(res, 404, { error: 'Unknown endpoint' });
|
|
238
260
|
}
|
|
@@ -278,11 +300,12 @@ async function handleApi(req, res, url) {
|
|
|
278
300
|
return sendJson(res, 200, sessionsWithSummaries(slugDir, { projectDir: storeProjectDir(store) }));
|
|
279
301
|
}
|
|
280
302
|
|
|
281
|
-
if (action
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
303
|
+
if (action.startsWith('tools/') && req.method === 'GET') {
|
|
304
|
+
const target = {
|
|
305
|
+
projectDir: store.kind === 'global' ? null : storeProjectDir(store),
|
|
306
|
+
slug: store.kind === 'auto' ? store.slug : null,
|
|
307
|
+
};
|
|
308
|
+
return sendJson(res, 200, await toolReport(action.slice('tools/'.length), target));
|
|
286
309
|
}
|
|
287
310
|
|
|
288
311
|
if (action === 'settings' && req.method === 'GET') {
|
package/src/ccusage.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
|
|
3
|
+
import { collector, findBinary, num, parseJson, pct, runBinary, text } from './toolrun.mjs';
|
|
4
|
+
|
|
5
|
+
const BINARY = 'ccusage';
|
|
6
|
+
const WINDOW_DAYS = 30;
|
|
7
|
+
const SESSION_LIMIT = 8;
|
|
8
|
+
const MODEL_LIMIT = 6;
|
|
9
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export const findCcusage = (env = process.env) => ({ id: BINARY, ...findBinary(BINARY, env) });
|
|
12
|
+
|
|
13
|
+
const runCcusage = (args, options) => runBinary(BINARY, args, options);
|
|
14
|
+
|
|
15
|
+
const ZERO = { sessions: 0, cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
16
|
+
|
|
17
|
+
function rowOf(session) {
|
|
18
|
+
const models = Array.isArray(session.modelBreakdowns) ? session.modelBreakdowns : [];
|
|
19
|
+
return {
|
|
20
|
+
id: text(session.sessionId),
|
|
21
|
+
slug: text(session.projectPath),
|
|
22
|
+
cost: num(session.totalCost),
|
|
23
|
+
tokens: num(session.totalTokens),
|
|
24
|
+
input: num(session.inputTokens),
|
|
25
|
+
output: num(session.outputTokens),
|
|
26
|
+
cacheRead: num(session.cacheReadTokens),
|
|
27
|
+
cacheWrite: num(session.cacheCreationTokens),
|
|
28
|
+
last: text(session.lastActivity),
|
|
29
|
+
models: models
|
|
30
|
+
.filter((model) => model && typeof model === 'object')
|
|
31
|
+
.map((model) => ({ name: text(model.modelName), cost: num(model.cost), tokens: num(model.outputTokens) }))
|
|
32
|
+
.filter((model) => model.name),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const totalsOf = (rows) => rows.reduce((sum, row) => ({
|
|
37
|
+
sessions: sum.sessions + 1,
|
|
38
|
+
cost: sum.cost + row.cost,
|
|
39
|
+
tokens: sum.tokens + row.tokens,
|
|
40
|
+
input: sum.input + row.input,
|
|
41
|
+
output: sum.output + row.output,
|
|
42
|
+
cacheRead: sum.cacheRead + row.cacheRead,
|
|
43
|
+
cacheWrite: sum.cacheWrite + row.cacheWrite,
|
|
44
|
+
}), ZERO);
|
|
45
|
+
|
|
46
|
+
function modelMix(rows) {
|
|
47
|
+
const byName = new Map();
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
for (const model of row.models) {
|
|
50
|
+
const seen = byName.get(model.name) || { name: model.name, cost: 0, tokens: 0 };
|
|
51
|
+
seen.cost += model.cost;
|
|
52
|
+
seen.tokens += model.tokens;
|
|
53
|
+
byName.set(model.name, seen);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...byName.values()].sort((a, b) => b.cost - a.cost).slice(0, MODEL_LIMIT);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function windowOf(rows, now) {
|
|
60
|
+
const cutoff = now - (WINDOW_DAYS * DAY_MS);
|
|
61
|
+
const recent = rows.filter((row) => {
|
|
62
|
+
const stamp = Date.parse(row.last);
|
|
63
|
+
return Number.isFinite(stamp) && stamp >= cutoff;
|
|
64
|
+
});
|
|
65
|
+
return { days: WINDOW_DAYS, ...totalsOf(recent) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function machineOf(raw, rows) {
|
|
69
|
+
const totals = raw && typeof raw.totals === 'object' && raw.totals ? raw.totals : {};
|
|
70
|
+
return {
|
|
71
|
+
sessions: rows.length,
|
|
72
|
+
cost: num(totals.totalCost),
|
|
73
|
+
tokens: num(totals.totalTokens),
|
|
74
|
+
input: num(totals.inputTokens),
|
|
75
|
+
output: num(totals.outputTokens),
|
|
76
|
+
cacheRead: num(totals.cacheReadTokens),
|
|
77
|
+
cacheWrite: num(totals.cacheCreationTokens),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function ccusageReport(target = {}, { run = runCcusage, home = os.homedir(), now = Date.now() } = {}) {
|
|
82
|
+
const { errors, step } = collector();
|
|
83
|
+
|
|
84
|
+
const [version, raw] = await Promise.all([
|
|
85
|
+
step('version', async () => text(await run(['--version'], { cwd: home })).trim().replace(/^ccusage\s+/, '')),
|
|
86
|
+
step('claude session', async () => parseJson(
|
|
87
|
+
await run(['claude', 'session', '--json', '--offline'], { cwd: home }),
|
|
88
|
+
'ccusage claude session',
|
|
89
|
+
)),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
if (!raw) {
|
|
93
|
+
return { tool: BINARY, version: version || null, slug: target.slug || null, machine: null, project: null, errors };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rows = (Array.isArray(raw.sessions) ? raw.sessions : [])
|
|
97
|
+
.filter((session) => session && typeof session === 'object')
|
|
98
|
+
.map(rowOf)
|
|
99
|
+
.filter((row) => row.id);
|
|
100
|
+
|
|
101
|
+
const machine = machineOf(raw, rows);
|
|
102
|
+
const mine = target.slug ? rows.filter((row) => row.slug === target.slug) : null;
|
|
103
|
+
const mineTotals = mine ? totalsOf(mine) : null;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
tool: BINARY,
|
|
107
|
+
version: version || null,
|
|
108
|
+
slug: target.slug || null,
|
|
109
|
+
machine: { ...machine, window: windowOf(rows, now) },
|
|
110
|
+
project: mine ? {
|
|
111
|
+
...mineTotals,
|
|
112
|
+
share: pct(mineTotals.cost, machine.cost),
|
|
113
|
+
window: windowOf(mine, now),
|
|
114
|
+
models: modelMix(mine),
|
|
115
|
+
top: [...mine]
|
|
116
|
+
.sort((a, b) => b.cost - a.cost)
|
|
117
|
+
.slice(0, SESSION_LIMIT)
|
|
118
|
+
.map(({ id, cost, tokens, last }) => ({ id, cost, tokens, last })),
|
|
119
|
+
} : null,
|
|
120
|
+
errors,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The live-session registry Claude Code keeps at ~/.claude/sessions/<pid>.json,
|
|
2
|
+
// one file per running `claude` process. This is distinct from the transcript
|
|
3
|
+
// history in sessions.mjs: that reads what happened, this reads what is
|
|
4
|
+
// happening right now.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
function isAlive(pid) {
|
|
11
|
+
try {
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return true;
|
|
14
|
+
} catch (err) {
|
|
15
|
+
return err.code !== 'ESRCH';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every session whose process is still running, newest-updated first. A file
|
|
21
|
+
* left behind by a process that has since exited is silently skipped rather
|
|
22
|
+
* than reported as stale - the registry itself is not this app's to clean up.
|
|
23
|
+
*/
|
|
24
|
+
export function readLiveSessions({ dir = path.join(os.homedir(), '.claude', 'sessions') } = {}) {
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sessions = [];
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
35
|
+
let session;
|
|
36
|
+
try {
|
|
37
|
+
session = JSON.parse(fs.readFileSync(path.join(dir, entry.name), 'utf8'));
|
|
38
|
+
} catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!session || typeof session.pid !== 'number' || !isAlive(session.pid)) continue;
|
|
42
|
+
sessions.push(session);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
46
|
+
}
|
package/src/rtk.mjs
CHANGED
|
@@ -1,66 +1,16 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
1
|
import os from 'node:os';
|
|
4
|
-
|
|
2
|
+
|
|
3
|
+
import { collector, findBinary, num, parseJson, pct, runBinary, text, usableDir } from './toolrun.mjs';
|
|
5
4
|
|
|
6
5
|
const BINARY = 'rtk';
|
|
7
|
-
const TIMEOUT_MS = 15000;
|
|
8
|
-
const MAX_BUFFER = 4 * 1024 * 1024;
|
|
9
6
|
const DISCOVER_DAYS = 30;
|
|
10
7
|
const DISCOVER_LIMIT = 10;
|
|
11
8
|
const MISSED_LIMIT = 12;
|
|
12
9
|
const TREND_DAYS = 30;
|
|
13
10
|
|
|
14
|
-
const
|
|
15
|
-
const text = (value) => (typeof value === 'string' ? value : '');
|
|
16
|
-
const pct = (part, whole) => (whole > 0 ? (part / whole) * 100 : 0);
|
|
17
|
-
|
|
18
|
-
function candidateNames(env) {
|
|
19
|
-
if (process.platform !== 'win32') return [BINARY];
|
|
20
|
-
const exts = text(env.PATHEXT || '.EXE').split(';').filter(Boolean);
|
|
21
|
-
return exts.map((ext) => BINARY + ext.toLowerCase());
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function findRtk(env = process.env) {
|
|
25
|
-
const dirs = text(env.PATH).split(path.delimiter).filter(Boolean);
|
|
26
|
-
const names = candidateNames(env);
|
|
27
|
-
for (const dir of dirs) {
|
|
28
|
-
for (const name of names) {
|
|
29
|
-
const candidate = path.join(dir, name);
|
|
30
|
-
try {
|
|
31
|
-
if (!fs.statSync(candidate).isFile()) continue;
|
|
32
|
-
fs.accessSync(candidate, fs.constants.X_OK);
|
|
33
|
-
return { id: BINARY, found: true, path: candidate };
|
|
34
|
-
} catch {
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return { id: BINARY, found: false, path: null };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function runRtk(args, { cwd, timeout = TIMEOUT_MS } = {}) {
|
|
43
|
-
return new Promise((resolve, reject) => {
|
|
44
|
-
const options = { cwd, timeout, maxBuffer: MAX_BUFFER, windowsHide: true, encoding: 'utf8' };
|
|
45
|
-
execFile(BINARY, args, options, (err, stdout, stderr) => {
|
|
46
|
-
if (!err) return resolve(stdout);
|
|
47
|
-
if (err.code === 'ENOENT') return reject(new Error('rtk is not on this process PATH'));
|
|
48
|
-
if (err.killed) return reject(new Error(`rtk ${args[0]} was still running after ${timeout}ms and was stopped`));
|
|
49
|
-
const detail = text(stderr || stdout).trim().split('\n')[0];
|
|
50
|
-
return reject(new Error(detail ? `rtk ${args[0]} failed: ${detail}` : `rtk ${args[0]} exited ${err.code}`));
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
}
|
|
11
|
+
export const findRtk = (env = process.env) => ({ id: BINARY, ...findBinary(BINARY, env) });
|
|
54
12
|
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
const value = JSON.parse(raw);
|
|
58
|
-
if (!value || typeof value !== 'object') throw new Error('not an object');
|
|
59
|
-
return value;
|
|
60
|
-
} catch {
|
|
61
|
-
throw new Error(`rtk ${label} did not answer with JSON`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
13
|
+
const runRtk = (args, options) => runBinary(BINARY, args, options);
|
|
64
14
|
|
|
65
15
|
function summaryOf(raw) {
|
|
66
16
|
const summary = raw && typeof raw.summary === 'object' && raw.summary ? raw.summary : {};
|
|
@@ -133,35 +83,17 @@ function discoverOf(raw) {
|
|
|
133
83
|
};
|
|
134
84
|
}
|
|
135
85
|
|
|
136
|
-
function
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return fs.statSync(dir).isDirectory() ? dir : null;
|
|
140
|
-
} catch {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export async function rtkReport(projectDir, { run = runRtk, home = os.homedir() } = {}) {
|
|
146
|
-
const errors = [];
|
|
147
|
-
const step = async (label, work) => {
|
|
148
|
-
try {
|
|
149
|
-
return await work();
|
|
150
|
-
} catch (err) {
|
|
151
|
-
errors.push({ step: label, message: err.message });
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const dir = usableDir(projectDir);
|
|
86
|
+
export async function rtkReport(target = {}, { run = runRtk, home = os.homedir() } = {}) {
|
|
87
|
+
const { errors, step } = collector();
|
|
88
|
+
const dir = usableDir(target.projectDir);
|
|
157
89
|
|
|
158
90
|
const [version, machine, project, discover] = await Promise.all([
|
|
159
91
|
step('version', async () => text(await run(['--version'], { cwd: home })).trim().replace(/^rtk\s+/, '')),
|
|
160
|
-
step('gain', async () => parseJson(await run(['gain', '-a', '--format', 'json'], { cwd: home }), 'gain')),
|
|
161
|
-
dir ? step('gain --project', async () => parseJson(await run(['gain', '-p', '--format', 'json'], { cwd: dir }), 'gain --project')) : null,
|
|
92
|
+
step('gain', async () => parseJson(await run(['gain', '-a', '--format', 'json'], { cwd: home }), 'rtk gain')),
|
|
93
|
+
dir ? step('gain --project', async () => parseJson(await run(['gain', '-p', '--format', 'json'], { cwd: dir }), 'rtk gain --project')) : null,
|
|
162
94
|
dir ? step('discover', async () => parseJson(
|
|
163
95
|
await run(['discover', '--format', 'json', '-s', String(DISCOVER_DAYS), '-l', String(DISCOVER_LIMIT)], { cwd: dir }),
|
|
164
|
-
'discover',
|
|
96
|
+
'rtk discover',
|
|
165
97
|
)) : null,
|
|
166
98
|
]);
|
|
167
99
|
|