claude-memory-admin 1.6.0 → 1.8.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.
@@ -0,0 +1,146 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+
4
+ const compact = (value) => {
5
+ const rounded = Math.round(value);
6
+ if (Math.abs(rounded) >= 1000000) return `${(rounded / 1000000).toFixed(1)}M`;
7
+ if (Math.abs(rounded) >= 1000) return `${(rounded / 1000).toFixed(1)}K`;
8
+ return String(rounded);
9
+ };
10
+
11
+ const clamp = (percent) => Math.min(100, Math.max(0, percent));
12
+
13
+ const fact = (value, label) => node('span', {}, [
14
+ node('b', { class: ui.meterFactValue, text: value }),
15
+ document.createTextNode(` ${label}`),
16
+ ]);
17
+
18
+ function meter(percent, unit, note, facts) {
19
+ return node('div', { class: ui.meter }, [
20
+ node('div', { class: ui.meterTop }, [
21
+ node('span', { class: ui.meterValue, text: `${percent.toFixed(1)}%` }),
22
+ node('span', { class: ui.meterUnit, text: unit }),
23
+ ]),
24
+ node('div', { class: ui.meterBar }, [
25
+ node('div', { class: ui.meterFill('accent'), style: `width:${clamp(percent).toFixed(1)}%` }),
26
+ ]),
27
+ note ? node('p', { class: ui.meterNote, text: note }) : null,
28
+ facts.length ? node('div', { class: ui.meterFacts }, facts) : null,
29
+ ]);
30
+ }
31
+
32
+ function projectSection(report) {
33
+ const project = report.project;
34
+ if (!project) return [];
35
+ if (!project.commands) {
36
+ return [
37
+ node('div', { class: ui.sectionLabel, text: 'This project' }),
38
+ node('p', { class: ui.noteTight, text: `rtk has filtered nothing in ${report.projectDir} yet.` }),
39
+ ];
40
+ }
41
+ return [node('div', { class: ui.sectionLabel, text: 'This project' }), meter(
42
+ project.savedPct,
43
+ 'of what rtk read in this project never reached the model',
44
+ 'rtk scopes its ledger by working directory, so this covers every command run from this project path.',
45
+ [
46
+ fact(compact(project.saved), 'tokens saved'),
47
+ fact(compact(project.commands), 'commands filtered'),
48
+ fact(`${compact(project.input)} → ${compact(project.output)}`, 'in / out'),
49
+ fact(`${compact(project.avgMs)}ms`, 'average'),
50
+ ],
51
+ )];
52
+ }
53
+
54
+ function machineMeter(report) {
55
+ const machine = report.machine;
56
+ if (!machine) return null;
57
+ const window = machine.window;
58
+ const facts = [
59
+ fact(compact(machine.saved), 'tokens saved'),
60
+ fact(compact(machine.commands), 'commands filtered'),
61
+ fact(`${compact(machine.avgMs)}ms`, 'average'),
62
+ ];
63
+ if (window && window.commands) {
64
+ facts.push(fact(`${compact(window.saved)} (${window.savedPct.toFixed(1)}%)`, `saved in the last ${window.days} days`));
65
+ }
66
+ return meter(
67
+ machine.savedPct,
68
+ 'of everything rtk has ever filtered on this machine',
69
+ null,
70
+ facts,
71
+ );
72
+ }
73
+
74
+ function missedRow(item) {
75
+ const saving = `${compact(item.savedTokens)} tokens (${item.savedPct.toFixed(0)}%)`;
76
+ return node('div', { class: ui.issue(false) }, [
77
+ node('div', { class: ui.issueBody }, [
78
+ node('div', { class: ui.issueTitle, text: `${item.command} ran ${item.count.toLocaleString()} times unfiltered` }),
79
+ node('div', { class: ui.issueDetail, text: `${item.equivalent} would have saved about ${saving}` }),
80
+ ]),
81
+ node('span', { class: ui.badge('neutral'), text: item.category || 'Other' }),
82
+ ]);
83
+ }
84
+
85
+ function discoverSection(report) {
86
+ const discover = report.discover;
87
+ if (!discover || !discover.commands) return [];
88
+
89
+ const parts = [
90
+ node('div', { class: ui.sectionLabel, text: 'Left on the table' }),
91
+ meter(
92
+ discover.adoptionPct,
93
+ `of the commands in the last ${discover.sinceDays} days already went through rtk`,
94
+ `Counted across ${discover.sessionsScanned.toLocaleString()} session transcripts for this project.`,
95
+ [
96
+ fact(discover.alreadyRtk.toLocaleString(), 'already filtered'),
97
+ fact(discover.commands.toLocaleString(), 'commands seen'),
98
+ fact(discover.sessionsScanned.toLocaleString(), 'sessions scanned'),
99
+ ],
100
+ ),
101
+ ];
102
+
103
+ if (!discover.missed.length) {
104
+ parts.push(node('p', { class: ui.okLine, text: 'Nothing rtk knows how to filter was run raw.' }));
105
+ return parts;
106
+ }
107
+
108
+ for (const item of discover.missed) parts.push(missedRow(item));
109
+ const hidden = discover.missedTotal - discover.missed.length;
110
+ const left = hidden === 1 ? '1 smaller one is not shown' : `${hidden} smaller ones are not shown`;
111
+ parts.push(node('p', { class: ui.note, text: hidden > 0
112
+ ? `Estimates from rtk itself, worst first; ${left}. They count what the filter would have stripped, not what the model would have ignored.`
113
+ : 'Estimates from rtk itself, worst first. They count what the filter would have stripped, not what the model would have ignored.' }));
114
+ return parts;
115
+ }
116
+
117
+ function errorNotes(report) {
118
+ if (!report.errors.length) return [];
119
+ return [
120
+ node('div', { class: ui.sectionLabel, text: 'Not read' }),
121
+ ...report.errors.map((error) => node('p', { class: ui.noteTight, text: `${error.step}: ${error.message}` })),
122
+ ];
123
+ }
124
+
125
+ export function rtkSummary(report) {
126
+ const project = report.project;
127
+ if (project && project.commands) {
128
+ return `${project.savedPct.toFixed(1)}% saved here · ${compact(project.saved)} tokens`;
129
+ }
130
+ if (report.machine) return `${report.machine.savedPct.toFixed(1)}% saved on this machine`;
131
+ return 'no ledger';
132
+ }
133
+
134
+ export function renderRtk(container, report) {
135
+ for (const part of projectSection(report)) container.append(part);
136
+
137
+ container.append(node('div', { class: ui.sectionLabel, text: report.project ? 'Every project' : 'This machine' }));
138
+ const machine = machineMeter(report);
139
+ container.append(machine || node('p', { class: ui.noteTight, text: 'The machine-wide ledger could not be read.' }));
140
+ if (!report.project && !report.projectDir) {
141
+ container.append(node('p', { class: ui.note, text: 'This entry is the user scope rather than a project, so rtk has no working directory to slice its ledger by.' }));
142
+ }
143
+
144
+ for (const part of discoverSection(report)) container.append(part);
145
+ for (const part of errorNotes(report)) container.append(part);
146
+ }
@@ -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,9 +14,15 @@ const TITLE_SOURCE = {
12
14
  prompt: 'first prompt, no generated title found in the part read',
13
15
  };
14
16
 
15
- function sessionRow(session, memoriesFrom) {
17
+ export function focusSession(id) {
18
+ state.aux.sessionFocus = id;
19
+ state.aux.sessionDay = null;
20
+ goTo('environment', 'sessions');
21
+ }
22
+
23
+ function sessionRow(session, memoriesFrom, focused) {
16
24
  const soon = session.expiresInDays !== null && session.expiresInDays <= 7;
17
- const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
25
+ const caret = node('span', { class: ui.contextCaret, text: focused ? '\u25be' : '\u25b8' });
18
26
 
19
27
  const tags = [];
20
28
  if (session.gitBranch) tags.push(node('span', { class: ui.badge('neutral'), text: session.gitBranch }));
@@ -29,7 +37,7 @@ function sessionRow(session, memoriesFrom) {
29
37
  tags.push(node('span', { class: ui.badge('project'), text: memory.name }));
30
38
  }
31
39
 
32
- const body = node('div', { class: ui.contextBody, hidden: true });
40
+ const body = node('div', { class: ui.contextBody, hidden: !focused });
33
41
  const facts = node('dl', { class: ui.metaList });
34
42
  const rows = [
35
43
  ['session', session.id],
@@ -62,7 +70,7 @@ function sessionRow(session, memoriesFrom) {
62
70
 
63
71
  const button = node('button', {
64
72
  class: ui.contextRowButton,
65
- 'aria-expanded': 'false',
73
+ 'aria-expanded': String(Boolean(focused)),
66
74
  onclick: (event) => {
67
75
  const open = body.hidden;
68
76
  body.hidden = !open;
@@ -227,9 +235,21 @@ export async function renderSessions(container) {
227
235
  }));
228
236
  }
229
237
  container.append(listHead);
238
+ const focus = state.aux.sessionFocus;
239
+ state.aux.sessionFocus = null;
240
+
230
241
  const card = node('div', { class: ui.card });
242
+ let focused = null;
231
243
  for (const session of visible) {
232
- card.append(sessionRow(session, byOrigin.get(session.id) || []));
244
+ const row = sessionRow(session, byOrigin.get(session.id) || [], session.id === focus);
245
+ if (session.id === focus) focused = row;
246
+ card.append(row);
233
247
  }
234
248
  container.append(card);
249
+
250
+ if (focus && focused) {
251
+ requestAnimationFrame(() => focused.scrollIntoView({ block: 'center' }));
252
+ } else if (focus) {
253
+ toast('That transcript is no longer beside this store - Claude Code has swept it.');
254
+ }
235
255
  }
@@ -0,0 +1,144 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { api } from '/api.mjs';
4
+ import { state } from '/state.mjs';
5
+ import { isAt } from '/parts.mjs';
6
+ import { paint } from '/bus.mjs';
7
+ import { renderRtk, rtkSummary } from '/views/rtk.mjs';
8
+ import { renderCcusage, ccusageSummary } from '/views/ccusage.mjs';
9
+
10
+ const OPT_IN = 'toolsOptIn';
11
+
12
+ const PANELS = {
13
+ rtk: { render: renderRtk, summary: rtkSummary },
14
+ ccusage: { render: renderCcusage, summary: ccusageSummary },
15
+ };
16
+
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;
29
+ let next;
30
+ try {
31
+ next = { data: await api(`/api/stores/${encodeURIComponent(storeId)}/tools/${encodeURIComponent(id)}`) };
32
+ } catch (err) {
33
+ next = { error: err.message };
34
+ }
35
+ if (state.storeId !== storeId) return;
36
+ state.aux.tools = { ...state.aux.tools, [id]: next };
37
+ if (isAt('environment', 'tools')) paint('tab');
38
+ }
39
+
40
+ function start({ remember = true } = {}) {
41
+ if (remember) localStorage.setItem(OPT_IN, '1');
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);
46
+ }
47
+
48
+ function readAll() {
49
+ start();
50
+ paint('tab');
51
+ }
52
+
53
+ function turnOff() {
54
+ localStorage.removeItem(OPT_IN);
55
+ state.aux.tools = null;
56
+ paint('tab');
57
+ }
58
+
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';
62
+ container.append(node('div', { class: ui.issue(false) }, [
63
+ node('div', { class: ui.issueBody }, [
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.` }),
66
+ ]),
67
+ node('button', { class: ui.buttonSmall, text: 'Read tool stats', onclick: readAll }),
68
+ ]));
69
+ }
70
+
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))]),
123
+ ]),
124
+ node('div', { class: ui.contextSize, text: headline(tool, slot) }),
125
+ ]));
126
+ container.append(panel);
127
+ }
128
+
129
+ export function renderTools(container) {
130
+ const tools = installed();
131
+ if (!tools.length) return;
132
+
133
+ if (!state.aux.tools && localStorage.getItem(OPT_IN) === '1') start({ remember: false });
134
+ if (!state.aux.tools) return gate(container, tools);
135
+
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
+ ]));
142
+
143
+ for (const tool of tools) accordion(container, tool, state.aux.tools[tool.id]);
144
+ }
package/server.mjs CHANGED
@@ -16,6 +16,7 @@ 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 { detectTools, toolReport } from './src/tools.mjs';
19
20
  import { sessionsWithSummaries, transcriptDir } from './src/sessions.mjs';
20
21
  import {
21
22
  addIndexEntry,
@@ -128,6 +129,14 @@ function requireStore(id) {
128
129
  return store;
129
130
  }
130
131
 
132
+ const VERSION = (() => {
133
+ try {
134
+ return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ })();
139
+
131
140
  function storeProjectDir(store) {
132
141
  // The global store is the user scope itself, which no project owns.
133
142
  if (store.kind === 'global') return null;
@@ -217,6 +226,8 @@ async function handleApi(req, res, url) {
217
226
  rootSource: origin.path === ROOT ? origin.source : 'flag',
218
227
  rootFile: origin.path === ROOT ? origin.file : null,
219
228
  rootWarning: origin.invalid,
229
+ version: VERSION,
230
+ tools: detectTools(),
220
231
  stores: listStores(ROOT),
221
232
  });
222
233
  }
@@ -276,6 +287,14 @@ async function handleApi(req, res, url) {
276
287
  return sendJson(res, 200, sessionsWithSummaries(slugDir, { projectDir: storeProjectDir(store) }));
277
288
  }
278
289
 
290
+ if (action.startsWith('tools/') && req.method === 'GET') {
291
+ const target = {
292
+ projectDir: store.kind === 'global' ? null : storeProjectDir(store),
293
+ slug: store.kind === 'auto' ? store.slug : null,
294
+ };
295
+ return sendJson(res, 200, await toolReport(action.slice('tools/'.length), target));
296
+ }
297
+
279
298
  if (action === 'settings' && req.method === 'GET') {
280
299
  return sendJson(res, 200, settingsReport({ projectDir: storeProjectDir(store) }));
281
300
  }
@@ -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
+ }
package/src/rtk.mjs ADDED
@@ -0,0 +1,111 @@
1
+ import os from 'node:os';
2
+
3
+ import { collector, findBinary, num, parseJson, pct, runBinary, text, usableDir } from './toolrun.mjs';
4
+
5
+ const BINARY = 'rtk';
6
+ const DISCOVER_DAYS = 30;
7
+ const DISCOVER_LIMIT = 10;
8
+ const MISSED_LIMIT = 12;
9
+ const TREND_DAYS = 30;
10
+
11
+ export const findRtk = (env = process.env) => ({ id: BINARY, ...findBinary(BINARY, env) });
12
+
13
+ const runRtk = (args, options) => runBinary(BINARY, args, options);
14
+
15
+ function summaryOf(raw) {
16
+ const summary = raw && typeof raw.summary === 'object' && raw.summary ? raw.summary : {};
17
+ return {
18
+ commands: num(summary.total_commands),
19
+ input: num(summary.total_input),
20
+ output: num(summary.total_output),
21
+ saved: num(summary.total_saved),
22
+ savedPct: num(summary.avg_savings_pct),
23
+ totalMs: num(summary.total_time_ms),
24
+ avgMs: num(summary.avg_time_ms),
25
+ };
26
+ }
27
+
28
+ function daysOf(raw) {
29
+ const list = raw && Array.isArray(raw.daily) ? raw.daily : [];
30
+ return list
31
+ .filter((day) => day && typeof day === 'object')
32
+ .map((day) => ({
33
+ date: text(day.date),
34
+ commands: num(day.commands),
35
+ input: num(day.input_tokens),
36
+ saved: num(day.saved_tokens),
37
+ savedPct: num(day.savings_pct),
38
+ }))
39
+ .filter((day) => day.date);
40
+ }
41
+
42
+ function windowOf(days, count) {
43
+ const recent = days.slice(-count);
44
+ const totals = recent.reduce((sum, day) => ({
45
+ commands: sum.commands + day.commands,
46
+ input: sum.input + day.input,
47
+ saved: sum.saved + day.saved,
48
+ }), { commands: 0, input: 0, saved: 0 });
49
+ return {
50
+ days: count,
51
+ active: recent.length,
52
+ commands: totals.commands,
53
+ saved: totals.saved,
54
+ savedPct: pct(totals.saved, totals.input),
55
+ };
56
+ }
57
+
58
+ function discoverOf(raw) {
59
+ const commands = num(raw.total_commands);
60
+ const alreadyRtk = num(raw.already_rtk);
61
+ const supported = Array.isArray(raw.supported) ? raw.supported : [];
62
+ const missed = supported
63
+ .filter((item) => item && typeof item === 'object')
64
+ .map((item) => ({
65
+ command: text(item.command),
66
+ count: num(item.count),
67
+ equivalent: text(item.rtk_equivalent),
68
+ category: text(item.category),
69
+ savedTokens: num(item.estimated_savings_tokens),
70
+ savedPct: num(item.estimated_savings_pct),
71
+ }))
72
+ .filter((item) => item.command)
73
+ .sort((a, b) => b.savedTokens - a.savedTokens);
74
+
75
+ return {
76
+ sessionsScanned: num(raw.sessions_scanned),
77
+ commands,
78
+ alreadyRtk,
79
+ adoptionPct: pct(alreadyRtk, commands),
80
+ sinceDays: num(raw.since_days) || DISCOVER_DAYS,
81
+ missedTotal: missed.length,
82
+ missed: missed.slice(0, MISSED_LIMIT),
83
+ };
84
+ }
85
+
86
+ export async function rtkReport(target = {}, { run = runRtk, home = os.homedir() } = {}) {
87
+ const { errors, step } = collector();
88
+ const dir = usableDir(target.projectDir);
89
+
90
+ const [version, machine, project, discover] = await Promise.all([
91
+ step('version', async () => text(await run(['--version'], { cwd: home })).trim().replace(/^rtk\s+/, '')),
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,
94
+ dir ? step('discover', async () => parseJson(
95
+ await run(['discover', '--format', 'json', '-s', String(DISCOVER_DAYS), '-l', String(DISCOVER_LIMIT)], { cwd: dir }),
96
+ 'rtk discover',
97
+ )) : null,
98
+ ]);
99
+
100
+ const days = daysOf(machine);
101
+
102
+ return {
103
+ tool: BINARY,
104
+ version: version || null,
105
+ projectDir: dir,
106
+ machine: machine ? { ...summaryOf(machine), days, window: windowOf(days, TREND_DAYS) } : null,
107
+ project: project ? summaryOf(project) : null,
108
+ discover: discover ? discoverOf(discover) : null,
109
+ errors,
110
+ };
111
+ }