linkshell-cli 0.5.14 → 0.5.16

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,335 @@
1
+ // Host-side usage aggregator. Scans local agent transcripts on disk and rolls
2
+ // them up into the dimensions a usage dashboard needs (cumulative total, peak
3
+ // day, activity heatmap by day, streaks, longest task). Fully DECOUPLED from any
4
+ // live LinkShell turn — it reads what the agents already wrote to disk, so
5
+ // history and externally-started sessions (any process, not just LinkShell) are
6
+ // all counted, the same way ccusage / vibe-usage / CodeIsland work.
7
+ //
8
+ // Counting convention (matches ccusage): sum usage PER API CALL — context is
9
+ // re-counted every turn, which is why totals reach billions. This is the usage
10
+ // view, the opposite of the single-snapshot "current context occupancy" meter.
11
+
12
+ import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join, basename } from "node:path";
15
+
16
+ export interface UsageTotals {
17
+ inputTokens: number;
18
+ outputTokens: number;
19
+ cacheReadTokens: number;
20
+ cacheWriteTokens: number;
21
+ totalTokens: number;
22
+ calls: number;
23
+ }
24
+
25
+ export interface UsageBucket extends UsageTotals {
26
+ key: string;
27
+ }
28
+
29
+ export interface UsageReport {
30
+ totals: UsageTotals;
31
+ sessions: number;
32
+ /** Highest single-day token total. */
33
+ peakDayTokens: number;
34
+ /** Longest single session span (first→last activity), milliseconds. */
35
+ longestTaskMs: number;
36
+ /** Consecutive active days ending at the most recent active day. */
37
+ currentStreakDays: number;
38
+ /** Longest run of consecutive active days ever. */
39
+ longestStreakDays: number;
40
+ byDay: UsageBucket[]; // key = YYYY-MM-DD, ascending — drives the heatmap
41
+ byHourWeekday: { weekday: number; hour: number; tokens: number }[]; // 0=Sun
42
+ byTool: UsageBucket[]; // claude-code | codex
43
+ byModel: UsageBucket[]; // claude-opus-4.8, gpt-5.5, ...
44
+ byProject: UsageBucket[]; // cwd basename
45
+ generatedAt: number;
46
+ }
47
+
48
+ function emptyTotals(): UsageTotals {
49
+ return {
50
+ inputTokens: 0,
51
+ outputTokens: 0,
52
+ cacheReadTokens: 0,
53
+ cacheWriteTokens: 0,
54
+ totalTokens: 0,
55
+ calls: 0,
56
+ };
57
+ }
58
+
59
+ // One call's worth of usage, tagged with its dimensions, fed into all buckets.
60
+ interface CallSample {
61
+ ts: number; // epoch ms
62
+ sessionId: string; // for per-session duration
63
+ tool: string; // claude-code | codex
64
+ model: string;
65
+ project: string; // cwd basename
66
+ input: number;
67
+ output: number;
68
+ cacheRead: number;
69
+ cacheWrite: number;
70
+ }
71
+
72
+ function dayKeyOf(ts: number): string {
73
+ const d = new Date(ts);
74
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
75
+ }
76
+
77
+ class Accumulator {
78
+ totals = emptyTotals();
79
+ private day = new Map<string, UsageTotals>();
80
+ private hw = new Map<string, number>(); // "weekday:hour" → tokens
81
+ private tool = new Map<string, UsageTotals>();
82
+ private model = new Map<string, UsageTotals>();
83
+ private project = new Map<string, UsageTotals>();
84
+ // Per-session activity timestamps, for longest-continuous-task duration.
85
+ private sessionTimes = new Map<string, number[]>();
86
+
87
+ private bump(map: Map<string, UsageTotals>, key: string, s: CallSample, tokens: number): void {
88
+ let t = map.get(key);
89
+ if (!t) { t = emptyTotals(); map.set(key, t); }
90
+ t.inputTokens += s.input;
91
+ t.outputTokens += s.output;
92
+ t.cacheReadTokens += s.cacheRead;
93
+ t.cacheWriteTokens += s.cacheWrite;
94
+ t.totalTokens += tokens;
95
+ t.calls += 1;
96
+ }
97
+
98
+ add(s: CallSample): void {
99
+ const tokens = s.input + s.output + s.cacheRead + s.cacheWrite;
100
+ if (tokens <= 0) return;
101
+ this.totals.inputTokens += s.input;
102
+ this.totals.outputTokens += s.output;
103
+ this.totals.cacheReadTokens += s.cacheRead;
104
+ this.totals.cacheWriteTokens += s.cacheWrite;
105
+ this.totals.totalTokens += tokens;
106
+ this.totals.calls += 1;
107
+
108
+ this.bump(this.day, dayKeyOf(s.ts), s, tokens);
109
+ const d = new Date(s.ts);
110
+ const hwKey = `${d.getDay()}:${d.getHours()}`;
111
+ this.hw.set(hwKey, (this.hw.get(hwKey) ?? 0) + tokens);
112
+ this.bump(this.tool, s.tool, s, tokens);
113
+ this.bump(this.model, s.model || "(unknown)", s, tokens);
114
+ this.bump(this.project, s.project || "(unknown)", s, tokens);
115
+
116
+ const times = this.sessionTimes.get(s.sessionId);
117
+ if (!times) this.sessionTimes.set(s.sessionId, [s.ts]);
118
+ else times.push(s.ts);
119
+ }
120
+
121
+ private toBuckets(map: Map<string, UsageTotals>): UsageBucket[] {
122
+ return [...map.entries()]
123
+ .map(([key, t]) => ({ key, ...t }))
124
+ .sort((a, b) => b.totalTokens - a.totalTokens);
125
+ }
126
+
127
+ finish(sessions: number, generatedAt: number): UsageReport {
128
+ const byDay = [...this.day.entries()]
129
+ .map(([key, t]) => ({ key, ...t }))
130
+ .sort((a, b) => a.key.localeCompare(b.key));
131
+
132
+ const peakDayTokens = byDay.reduce((m, d) => Math.max(m, d.totalTokens), 0);
133
+
134
+ // Longest CONTINUOUS work segment across all sessions. A Claude session
135
+ // file can be resumed days later, so first→last would absurdly count idle
136
+ // days. Instead, sort each session's call timestamps and split whenever the
137
+ // gap exceeds IDLE_SPLIT_MS; the longest unbroken segment is the task length.
138
+ const IDLE_SPLIT_MS = 30 * 60_000;
139
+ let longestTaskMs = 0;
140
+ for (const times of this.sessionTimes.values()) {
141
+ if (times.length === 0) continue;
142
+ times.sort((a, b) => a - b);
143
+ let segStart = times[0]!;
144
+ let prev = times[0]!;
145
+ for (let i = 1; i < times.length; i++) {
146
+ const cur = times[i]!;
147
+ if (cur - prev > IDLE_SPLIT_MS) {
148
+ longestTaskMs = Math.max(longestTaskMs, prev - segStart);
149
+ segStart = cur;
150
+ }
151
+ prev = cur;
152
+ }
153
+ longestTaskMs = Math.max(longestTaskMs, prev - segStart);
154
+ }
155
+
156
+ const { currentStreakDays, longestStreakDays } = computeStreaks(byDay.map((d) => d.key), generatedAt);
157
+
158
+ const byHourWeekday = [...this.hw.entries()].map(([k, tokens]) => {
159
+ const parts = k.split(":");
160
+ return { weekday: Number(parts[0]), hour: Number(parts[1]), tokens };
161
+ });
162
+
163
+ return {
164
+ totals: this.totals,
165
+ sessions,
166
+ peakDayTokens,
167
+ longestTaskMs,
168
+ currentStreakDays,
169
+ longestStreakDays,
170
+ byDay,
171
+ byHourWeekday,
172
+ byTool: this.toBuckets(this.tool),
173
+ byModel: this.toBuckets(this.model),
174
+ byProject: this.toBuckets(this.project),
175
+ generatedAt,
176
+ };
177
+ }
178
+ }
179
+
180
+ // Streaks over a sorted set of active day-keys (YYYY-MM-DD). Current streak
181
+ // counts back from today (or yesterday — a gap of "today not yet active" still
182
+ // keeps an unbroken run alive). Uses UTC-noon to step days, dodging DST edges.
183
+ function computeStreaks(dayKeys: string[], now: number): { currentStreakDays: number; longestStreakDays: number } {
184
+ if (dayKeys.length === 0) return { currentStreakDays: 0, longestStreakDays: 0 };
185
+ const set = new Set(dayKeys);
186
+ const dayMs = 86_400_000;
187
+ const toKey = (ms: number) => dayKeyOf(ms);
188
+
189
+ // Longest run.
190
+ let longest = 1;
191
+ let run = 1;
192
+ const sorted = [...dayKeys].sort();
193
+ for (let i = 1; i < sorted.length; i++) {
194
+ const prev = new Date(sorted[i - 1] + "T12:00:00").getTime();
195
+ const cur = new Date(sorted[i] + "T12:00:00").getTime();
196
+ const gapDays = Math.round((cur - prev) / dayMs);
197
+ if (gapDays === 1) run += 1;
198
+ else run = 1;
199
+ longest = Math.max(longest, run);
200
+ }
201
+
202
+ // Current run: walk backwards from today; if today isn't active, allow
203
+ // starting from yesterday (today simply hasn't happened yet).
204
+ let cursor = new Date(toKey(now) + "T12:00:00").getTime();
205
+ if (!set.has(toKey(cursor))) cursor -= dayMs; // today inactive → start at yesterday
206
+ let current = 0;
207
+ while (set.has(toKey(cursor))) {
208
+ current += 1;
209
+ cursor -= dayMs;
210
+ }
211
+
212
+ return { currentStreakDays: current, longestStreakDays: longest };
213
+ }
214
+
215
+ function parseTs(value: unknown): number | undefined {
216
+ if (typeof value === "number" && Number.isFinite(value)) {
217
+ return value > 10_000_000_000 ? value : value * 1000;
218
+ }
219
+ if (typeof value === "string" && value.trim()) {
220
+ const p = Date.parse(value);
221
+ return Number.isNaN(p) ? undefined : p;
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ function walkJsonl(dir: string, onFile: (path: string) => void): void {
227
+ let entries: string[];
228
+ try { entries = readdirSync(dir); } catch { return; }
229
+ for (const entry of entries) {
230
+ const full = join(dir, entry);
231
+ let st;
232
+ try { st = statSync(full); } catch { continue; }
233
+ if (st.isDirectory()) walkJsonl(full, onFile);
234
+ else if (entry.endsWith(".jsonl")) onFile(full);
235
+ }
236
+ }
237
+
238
+ // Claude: ~/.claude/projects/**/*.jsonl — each assistant line carries
239
+ // message.usage. Skip <synthetic> (internal, no real usage).
240
+ function ingestClaude(acc: Accumulator): number {
241
+ const root = join(homedir(), ".claude", "projects");
242
+ if (!existsSync(root)) return 0;
243
+ let sessions = 0;
244
+ walkJsonl(root, (path) => {
245
+ sessions += 1;
246
+ const sessionId = basename(path, ".jsonl");
247
+ let text: string;
248
+ try { text = readFileSync(path, "utf8"); } catch { return; }
249
+ let fileCwd = "";
250
+ for (const line of text.split(/\r?\n/)) {
251
+ if (!line.trim()) continue;
252
+ let o: any;
253
+ try { o = JSON.parse(line); } catch { continue; }
254
+ if (typeof o?.cwd === "string" && !fileCwd) fileCwd = o.cwd;
255
+ if (o?.type !== "assistant") continue;
256
+ const msg = o.message;
257
+ const usage = msg?.usage;
258
+ const model = typeof msg?.model === "string" ? msg.model : "";
259
+ if (!usage || model === "<synthetic>" || !model) continue;
260
+ const ts = parseTs(o.timestamp);
261
+ if (ts === undefined) continue;
262
+ acc.add({
263
+ ts,
264
+ sessionId,
265
+ tool: "claude-code",
266
+ model,
267
+ project: basename(o.cwd || fileCwd || "(unknown)"),
268
+ input: usage.input_tokens || 0,
269
+ output: usage.output_tokens || 0,
270
+ cacheRead: usage.cache_read_input_tokens || 0,
271
+ cacheWrite: usage.cache_creation_input_tokens || 0,
272
+ });
273
+ }
274
+ });
275
+ return sessions;
276
+ }
277
+
278
+ // Codex: ~/.codex/sessions/**/rollout-*.jsonl — token_count events carry
279
+ // info.last_token_usage (per-turn delta; total_token_usage is cumulative and
280
+ // would double-count). cached_input_tokens is a subset of input_tokens.
281
+ function ingestCodex(acc: Accumulator): number {
282
+ const root = join(homedir(), ".codex", "sessions");
283
+ if (!existsSync(root)) return 0;
284
+ let sessions = 0;
285
+ walkJsonl(root, (path) => {
286
+ if (!basename(path).startsWith("rollout-")) return;
287
+ sessions += 1;
288
+ const sessionId = basename(path, ".jsonl");
289
+ let text: string;
290
+ try { text = readFileSync(path, "utf8"); } catch { return; }
291
+ let model = "";
292
+ let cwd = "";
293
+ for (const line of text.split(/\r?\n/)) {
294
+ if (!line.trim()) continue;
295
+ let o: any;
296
+ try { o = JSON.parse(line); } catch { continue; }
297
+ const p = o?.payload ?? {};
298
+ const type = p.type ?? o?.type;
299
+ if (type === "session_meta") {
300
+ if (typeof p.cwd === "string") cwd = p.cwd;
301
+ if (typeof p.model === "string") model = p.model;
302
+ continue;
303
+ }
304
+ if (type === "turn_context") {
305
+ if (typeof p.model === "string") model = p.model;
306
+ continue;
307
+ }
308
+ if (type !== "token_count") continue;
309
+ const last = p.info?.last_token_usage;
310
+ if (!last) continue;
311
+ const ts = parseTs(o.timestamp);
312
+ if (ts === undefined) continue;
313
+ const cacheRead = last.cached_input_tokens || 0;
314
+ const rawInput = last.input_tokens || 0;
315
+ acc.add({
316
+ ts,
317
+ sessionId,
318
+ tool: "codex",
319
+ model: model || "gpt-5",
320
+ project: basename(cwd || "(unknown)"),
321
+ input: Math.max(0, rawInput - cacheRead),
322
+ output: (last.output_tokens || 0) + (last.reasoning_output_tokens || 0),
323
+ cacheRead,
324
+ cacheWrite: 0,
325
+ });
326
+ }
327
+ });
328
+ return sessions;
329
+ }
330
+
331
+ export function aggregateUsage(): UsageReport {
332
+ const acc = new Accumulator();
333
+ const sessions = ingestClaude(acc) + ingestCodex(acc);
334
+ return acc.finish(sessions, Date.now());
335
+ }