decorated-pi 0.5.5 → 0.6.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.
Files changed (67) hide show
  1. package/.codegraph/daemon.pid +6 -0
  2. package/AGENTS.md +92 -0
  3. package/README.md +53 -64
  4. package/commands/dp-model.ts +23 -0
  5. package/commands/dp-settings.ts +23 -0
  6. package/commands/mcp-status.ts +62 -0
  7. package/commands/retry.ts +19 -0
  8. package/commands/usage.ts +544 -0
  9. package/hooks/compaction.ts +204 -0
  10. package/hooks/externalize.ts +70 -0
  11. package/hooks/image-vision.ts +132 -0
  12. package/hooks/inject-agents-md.ts +164 -0
  13. package/hooks/mcp.ts +340 -0
  14. package/hooks/normalize-codeblocks.ts +88 -0
  15. package/hooks/pi-tool-filter.ts +28 -0
  16. package/{extensions/safety → hooks/redact}/types.ts +1 -8
  17. package/hooks/redact.ts +104 -0
  18. package/{extensions → hooks}/rtk.ts +92 -115
  19. package/{extensions → hooks}/session-title.ts +17 -28
  20. package/hooks/skeleton.ts +212 -0
  21. package/hooks/smart-at.ts +318 -0
  22. package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
  23. package/{extensions → hooks}/wakatime.ts +120 -122
  24. package/index.ts +155 -1
  25. package/package.json +5 -4
  26. package/{extensions/settings.ts → settings.ts} +32 -0
  27. package/{extensions → tools}/lsp/client.ts +0 -25
  28. package/{extensions → tools}/lsp/format.ts +2 -99
  29. package/{extensions → tools}/lsp/index.ts +1 -1
  30. package/{extensions → tools}/lsp/servers.ts +1 -1
  31. package/{extensions → tools}/lsp/tools.ts +1 -66
  32. package/{extensions → tools}/lsp/types.ts +0 -11
  33. package/tools/mcp/builtin/codegraph.ts +45 -0
  34. package/tools/mcp/builtin/context7.ts +10 -0
  35. package/tools/mcp/builtin/exa.ts +10 -0
  36. package/tools/mcp/builtin/index.ts +19 -0
  37. package/tools/mcp/cache.ts +124 -0
  38. package/{extensions → tools}/mcp/client.ts +2 -2
  39. package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
  40. package/tools/mcp/index.ts +44 -0
  41. package/tools/mcp/tool-definition.ts +112 -0
  42. package/{extensions/patch.ts → tools/patch/core.ts} +135 -43
  43. package/{extensions/io.ts → tools/patch/index.ts} +24 -206
  44. package/tsconfig.json +10 -1
  45. package/ui/mcp-status.ts +202 -0
  46. package/ui/model-picker.ts +162 -0
  47. package/ui/module-settings.ts +83 -0
  48. package/ui/usage.ts +396 -0
  49. package/extensions/index.ts +0 -154
  50. package/extensions/io-tool-output.ts +0 -33
  51. package/extensions/mcp/index.ts +0 -435
  52. package/extensions/model-integration.ts +0 -531
  53. package/extensions/providers/ark-coding.ts +0 -75
  54. package/extensions/providers/index.ts +0 -9
  55. package/extensions/providers/ollama-cloud.ts +0 -101
  56. package/extensions/providers/qianfan-coding.ts +0 -71
  57. package/extensions/safety/index.ts +0 -102
  58. package/extensions/slash.ts +0 -458
  59. package/extensions/smart-at.ts +0 -481
  60. package/extensions/subdir-agents.ts +0 -151
  61. /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
  62. /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
  63. /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
  64. /package/{extensions → tools}/lsp/env.ts +0 -0
  65. /package/{extensions → tools}/lsp/manager.ts +0 -0
  66. /package/{extensions → tools}/lsp/prompt.ts +0 -0
  67. /package/{extensions → tools}/lsp/protocol.ts +0 -0
@@ -0,0 +1,544 @@
1
+ /**
2
+ * /usage — token usage statistics dashboard.
3
+ *
4
+ * Incrementally syncs pi session JSONL files into a local index
5
+ * (~/.pi/agent/decorated-pi-usage.jsonl), then aggregates by time
6
+ * slice and model for interactive display.
7
+ *
8
+ * Zero agent-loop hooks — purely command-driven.
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as fsPromises from "node:fs/promises";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
17
+ import { Container, matchesKey, Spacer, Text } from "@earendil-works/pi-tui";
18
+
19
+ import { loadUsageIndex, saveUsageIndex } from "../settings.js";
20
+ import { UsageReportComponent } from "../ui/usage.js";
21
+
22
+ // ─── Paths ─────────────────────────────────────────────────────────────────
23
+
24
+ function agentDir(): string {
25
+ return process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
26
+ }
27
+
28
+ function usageFilePath(): string {
29
+ return path.join(agentDir(), "decorated-pi-usage.jsonl");
30
+ }
31
+
32
+ function sessionsDir(): string {
33
+ return path.join(agentDir(), "sessions");
34
+ }
35
+
36
+ // ─── Types ─────────────────────────────────────────────────────────────────
37
+
38
+ interface UsageEntry {
39
+ ts: number;
40
+ model: string;
41
+ sessionFile: string;
42
+ input: number;
43
+ output: number;
44
+ cacheRead: number;
45
+ cacheWrite: number;
46
+ cost: number;
47
+ }
48
+
49
+ export interface Aggregate {
50
+ input: number;
51
+ output: number;
52
+ cacheRead: number;
53
+ cacheWrite: number;
54
+ cost: number;
55
+ turns: number;
56
+ hitRate: number; // 0-100
57
+ }
58
+
59
+ export interface UsageReport {
60
+ currentSession: Aggregate;
61
+ today: Aggregate;
62
+ thisWeek: Aggregate;
63
+ thisMonth: Aggregate;
64
+ allTime: Aggregate;
65
+ byModel: ModelSlice[];
66
+ }
67
+
68
+ export interface ModelSlice {
69
+ model: string;
70
+ currentSession: Aggregate;
71
+ today: Aggregate;
72
+ thisWeek: Aggregate;
73
+ thisMonth: Aggregate;
74
+ allTime: Aggregate;
75
+ }
76
+
77
+ // ─── Session file scanning ─────────────────────────────────────────────────
78
+
79
+ async function collectSessionFiles(
80
+ dir: string,
81
+ files: string[],
82
+ signal?: AbortSignal,
83
+ ): Promise<void> {
84
+ try {
85
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
86
+ for (const entry of entries) {
87
+ if (signal?.aborted) return;
88
+ const full = path.join(dir, entry.name);
89
+ if (entry.isDirectory()) {
90
+ await collectSessionFiles(full, files, signal);
91
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
92
+ files.push(full);
93
+ }
94
+ }
95
+ } catch {
96
+ // skip unreadable dirs
97
+ }
98
+ }
99
+
100
+ // ─── Ingestion ─────────────────────────────────────────────────────────────
101
+
102
+ async function ingestSessionFile(
103
+ filePath: string,
104
+ offset: number,
105
+ seenHashes: Set<string>,
106
+ signal?: AbortSignal,
107
+ ): Promise<void> {
108
+ const fd = await fsPromises.open(filePath, "r");
109
+ try {
110
+ const stat = await fd.stat();
111
+ const remaining = stat.size - offset;
112
+ if (remaining <= 0) return;
113
+
114
+ const buf = Buffer.alloc(remaining);
115
+ await fd.read(buf, 0, remaining, offset);
116
+ const text = buf.toString("utf-8");
117
+
118
+ // When offset > 0, we may start at a line boundary (byte before
119
+ // offset is \n) or in the middle of a line. If mid-line, skip until
120
+ // after the next \n so we don't try to parse a half line. If at
121
+ // a boundary, start at position 0 so we don't drop the first
122
+ // complete line in the delta.
123
+ let textStart = 0;
124
+ if (offset > 0) {
125
+ const prevBuf = Buffer.alloc(1);
126
+ await fd.read(prevBuf, 0, 1, offset - 1);
127
+ if (prevBuf[0] !== 0x0a /* \n */) {
128
+ // mid-line: skip the residual
129
+ const nl = text.indexOf("\n");
130
+ if (nl === -1) return; // no complete line in the delta yet
131
+ textStart = nl + 1;
132
+ }
133
+ // else: at a line boundary, textStart stays 0
134
+ }
135
+
136
+ const lines = text.slice(textStart).split("\n");
137
+
138
+ const entries: UsageEntry[] = [];
139
+
140
+ for (let i = 0; i < lines.length; i++) {
141
+ if (signal?.aborted) return;
142
+ if (i % 500 === 0) await new Promise<void>((r) => setImmediate(r));
143
+
144
+ const line = lines[i];
145
+ if (!line) continue;
146
+
147
+ try {
148
+ const e = JSON.parse(line);
149
+ if (e.type !== "message" || e.message?.role !== "assistant") continue;
150
+
151
+ const msg = e.message;
152
+ const usage = msg.usage;
153
+ if (!usage) continue;
154
+
155
+ const ts =
156
+ typeof msg.timestamp === "number"
157
+ ? msg.timestamp
158
+ : typeof e.timestamp === "string"
159
+ ? new Date(e.timestamp).getTime()
160
+ : Date.now();
161
+
162
+ // dedup across forked/branched session files
163
+ const totalTokens =
164
+ (usage.input || 0) + (usage.output || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0);
165
+ const hash = `${ts}:${totalTokens}`;
166
+ if (seenHashes.has(hash)) continue;
167
+ seenHashes.add(hash);
168
+
169
+ const model = `${msg.provider ?? "unknown"}/${msg.model ?? "unknown"}`;
170
+
171
+ entries.push({
172
+ ts,
173
+ model,
174
+ sessionFile: filePath,
175
+ input: usage.input || 0,
176
+ output: usage.output || 0,
177
+ cacheRead: usage.cacheRead || 0,
178
+ cacheWrite: usage.cacheWrite || 0,
179
+ cost: usage.cost?.total || 0,
180
+ });
181
+ } catch {
182
+ // skip malformed lines
183
+ }
184
+ }
185
+
186
+ if (entries.length > 0) {
187
+ const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
188
+ await fsPromises.appendFile(usageFilePath(), body);
189
+ }
190
+ } finally {
191
+ await fd.close();
192
+ }
193
+ }
194
+
195
+ // ─── Incremental sync ──────────────────────────────────────────────────────
196
+
197
+ export async function syncUsageIndex(signal?: AbortSignal): Promise<void> {
198
+ const index = loadUsageIndex();
199
+ const files: string[] = [];
200
+ const seenHashes = new Set<string>();
201
+
202
+ await collectSessionFiles(sessionsDir(), files, signal);
203
+
204
+ for (const filePath of files) {
205
+ if (signal?.aborted) return;
206
+
207
+ let stat: fs.Stats;
208
+ try {
209
+ stat = await fsPromises.stat(filePath);
210
+ } catch {
211
+ delete index[filePath];
212
+ continue;
213
+ }
214
+
215
+ const cached = index[filePath];
216
+
217
+ // Trigger a full reimport if any of:
218
+ // - no cache entry (first time seeing this file)
219
+ // - inode changed (file was replaced with a different inode)
220
+ // - inode was recycled BUT the file is shorter than we remembered
221
+ // (truncation/rotation)
222
+ if (
223
+ !cached ||
224
+ cached.inode !== stat.ino ||
225
+ stat.size < cached.size
226
+ ) {
227
+ // full reimport
228
+ await ingestSessionFile(filePath, 0, seenHashes, signal);
229
+ } else if (stat.size > cached.size) {
230
+ // append-only → import delta
231
+ await ingestSessionFile(filePath, cached.size, seenHashes, signal);
232
+ }
233
+ // else: unchanged
234
+
235
+ index[filePath] = { inode: stat.ino, size: stat.size, mtime: stat.mtimeMs };
236
+ }
237
+
238
+ // prune stale entries
239
+ const existing = new Set(files);
240
+ for (const key of Object.keys(index)) {
241
+ if (!existing.has(key)) delete index[key];
242
+ }
243
+
244
+ saveUsageIndex(index);
245
+ }
246
+
247
+ /** Full rebuild: clear index + JSONL, re-import everything. */
248
+ export async function rebuildUsageIndex(signal?: AbortSignal): Promise<void> {
249
+ saveUsageIndex({});
250
+ try {
251
+ await fsPromises.truncate(usageFilePath(), 0);
252
+ } catch {
253
+ // file may not exist yet
254
+ }
255
+ await syncUsageIndex(signal);
256
+ }
257
+
258
+ // ─── Read our JSONL ────────────────────────────────────────────────────────
259
+
260
+ export function readUsageEntries(): UsageEntry[] {
261
+ try {
262
+ if (!fs.existsSync(usageFilePath())) return [];
263
+ const text = fs.readFileSync(usageFilePath(), "utf-8");
264
+ const entries: UsageEntry[] = [];
265
+ for (const line of text.trim().split("\n")) {
266
+ if (!line) continue;
267
+ try {
268
+ entries.push(JSON.parse(line) as UsageEntry);
269
+ } catch {
270
+ // skip
271
+ }
272
+ }
273
+ return entries;
274
+ } catch {
275
+ return [];
276
+ }
277
+ }
278
+
279
+ // ─── Aggregation ───────────────────────────────────────────────────────────
280
+
281
+ function emptyAggregate(): Aggregate {
282
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0, hitRate: 0 };
283
+ }
284
+
285
+ function getPeriods(): { todayMs: number; weekStartMs: number; monthStartMs: number } {
286
+ const now = new Date();
287
+ const todayMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
288
+
289
+ const dow = now.getDay();
290
+ const monOffset = dow === 0 ? 6 : dow - 1; // Monday = 0
291
+ const weekStartMs = new Date(now.getFullYear(), now.getMonth(), now.getDate() - monOffset).getTime();
292
+
293
+ const monthStartMs = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
294
+
295
+ return { todayMs, weekStartMs, monthStartMs };
296
+ }
297
+
298
+ function accum(a: Aggregate, e: UsageEntry): void {
299
+ a.input += e.input;
300
+ a.output += e.output;
301
+ a.cacheRead += e.cacheRead;
302
+ a.cacheWrite += e.cacheWrite;
303
+ a.cost += e.cost;
304
+ a.turns++;
305
+ }
306
+
307
+ function computeHitRate(a: Aggregate): void {
308
+ const total = a.input + a.cacheRead + a.cacheWrite;
309
+ a.hitRate = total > 0 ? (100 * a.cacheRead) / total : 0;
310
+ }
311
+
312
+ export function aggregate(entries: UsageEntry[], currentSessionFile?: string): UsageReport {
313
+ const { todayMs, weekStartMs, monthStartMs } = getPeriods();
314
+
315
+ type SliceKey = "currentSession" | "today" | "thisWeek" | "thisMonth" | "allTime";
316
+
317
+ const overall: Record<SliceKey, Aggregate> = {
318
+ currentSession: emptyAggregate(),
319
+ today: emptyAggregate(),
320
+ thisWeek: emptyAggregate(),
321
+ thisMonth: emptyAggregate(),
322
+ allTime: emptyAggregate(),
323
+ };
324
+
325
+ const modelMap = new Map<string, Record<SliceKey, Aggregate>>();
326
+ const modelLastUsed = new Map<string, number>();
327
+ const isCurrentSession = (e: UsageEntry) =>
328
+ currentSessionFile ? e.sessionFile === currentSessionFile : false;
329
+
330
+ for (const e of entries) {
331
+ accum(overall.allTime, e);
332
+ if (e.ts >= todayMs) accum(overall.today, e);
333
+ if (e.ts >= weekStartMs) accum(overall.thisWeek, e);
334
+ if (e.ts >= monthStartMs) accum(overall.thisMonth, e);
335
+ if (isCurrentSession(e)) accum(overall.currentSession, e);
336
+
337
+ let m = modelMap.get(e.model);
338
+ if (!m) {
339
+ m = {
340
+ currentSession: emptyAggregate(),
341
+ today: emptyAggregate(),
342
+ thisWeek: emptyAggregate(),
343
+ thisMonth: emptyAggregate(),
344
+ allTime: emptyAggregate(),
345
+ };
346
+ modelMap.set(e.model, m);
347
+ }
348
+ accum(m.allTime, e);
349
+ if (e.ts >= todayMs) accum(m.today, e);
350
+ if (e.ts >= weekStartMs) accum(m.thisWeek, e);
351
+ if (e.ts >= monthStartMs) accum(m.thisMonth, e);
352
+ if (isCurrentSession(e)) accum(m.currentSession, e);
353
+
354
+ // track most recent usage per model
355
+ if (e.ts > (modelLastUsed.get(e.model) ?? 0)) {
356
+ modelLastUsed.set(e.model, e.ts);
357
+ }
358
+ }
359
+
360
+ // compute hit rates
361
+ for (const key of Object.keys(overall) as SliceKey[]) {
362
+ computeHitRate(overall[key]);
363
+ }
364
+ for (const m of modelMap.values()) {
365
+ for (const key of Object.keys(m) as SliceKey[]) {
366
+ computeHitRate(m[key]);
367
+ }
368
+ }
369
+
370
+ const byModel: ModelSlice[] = Array.from(modelMap.entries())
371
+ .map(([model, slices]) => ({ model, ...slices }))
372
+ .sort(
373
+ (a, b) =>
374
+ (modelLastUsed.get(b.model) ?? 0) - (modelLastUsed.get(a.model) ?? 0) ||
375
+ a.model.localeCompare(b.model),
376
+ );
377
+
378
+ return { ...overall, byModel };
379
+ }
380
+
381
+ // ─── Formatting ────────────────────────────────────────────────────────────
382
+
383
+ export function formatTokens(n: number): string {
384
+ if (n === 0) return "—";
385
+ if (n < 1000) return String(n);
386
+ if (n < 1_000_000) {
387
+ const k = n / 1000;
388
+ return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
389
+ }
390
+ const m = n / 1_000_000;
391
+ return m >= 100 ? `${Math.round(m)}M` : `${m.toFixed(2)}M`;
392
+ }
393
+
394
+ export function formatCost(c: number): string {
395
+ if (c === 0) return "—";
396
+ if (c < 0.01) return "<$0.01";
397
+ if (c < 1000) return `$${c.toFixed(2)}`;
398
+ const k = c / 1000;
399
+ return `$${k.toFixed(1)}k`;
400
+ }
401
+
402
+ export function formatHitRate(hitRate: number, turns: number): string {
403
+ if (turns === 0) return "—";
404
+ return `${hitRate.toFixed(1)}%`;
405
+ }
406
+
407
+ export type ColumnId = "input" | "output" | "cacheRead" | "cacheWrite" | "hitRate" | "cost";
408
+
409
+ const WIDE_COLS: ColumnId[] = ["input", "output", "cacheRead", "cacheWrite", "hitRate", "cost"];
410
+ const MED_COLS: ColumnId[] = ["input", "output", "hitRate", "cost"];
411
+ const NARROW_COLS: ColumnId[] = ["hitRate", "cost"];
412
+
413
+ export function pickColumns(width: number): ColumnId[] {
414
+ if (width >= 80) return WIDE_COLS;
415
+ if (width >= 50) return MED_COLS;
416
+ return NARROW_COLS;
417
+ }
418
+
419
+ export function formatCell(col: ColumnId, agg: Aggregate): string {
420
+ switch (col) {
421
+ case "input":
422
+ return formatTokens(agg.input + agg.cacheRead + agg.cacheWrite);
423
+ case "output":
424
+ return formatTokens(agg.output);
425
+ case "cacheRead":
426
+ return formatTokens(agg.cacheRead);
427
+ case "cacheWrite":
428
+ return formatTokens(agg.cacheWrite);
429
+ case "hitRate":
430
+ return formatHitRate(agg.hitRate, agg.turns);
431
+ case "cost":
432
+ return formatCost(agg.cost);
433
+ }
434
+ }
435
+
436
+ /** Row label → columns → rendered line string */
437
+ export function formatRow(
438
+ label: string,
439
+ labelWidth: number,
440
+ agg: Aggregate,
441
+ cols: ColumnId[],
442
+ colWidths: Record<ColumnId, number>,
443
+ ): string {
444
+ const pad = " ";
445
+ let line = label.padEnd(labelWidth);
446
+ for (const c of cols) {
447
+ const val = formatCell(c, agg);
448
+ line += pad + val.padStart(colWidths[c] ?? 0);
449
+ }
450
+ return line;
451
+ }
452
+
453
+ export function pickModelDisplay(name: string, maxLen: number): string {
454
+ if (name.length <= maxLen) return name;
455
+ if (maxLen <= 3) return name.slice(0, maxLen);
456
+ return name.slice(0, maxLen - 1) + "…";
457
+ }
458
+
459
+ export function formatDivider(cols: ColumnId[], colWidths: Record<ColumnId, number>, labelW: number): string {
460
+ const parts = ["─".repeat(labelW)];
461
+ for (const c of cols) {
462
+ parts.push("─".repeat((colWidths[c] ?? 0) + 2)); // +2 for padding
463
+ }
464
+ return parts.join("");
465
+ }
466
+
467
+ // ─── Command registration ──────────────────────────────────────────────────
468
+
469
+ export function registerUsageCommand(pi: ExtensionAPI): void {
470
+ pi.registerCommand("usage", {
471
+ description: "Show token usage statistics (overall + per-model)",
472
+ handler: async (_args, ctx) => {
473
+ if (!ctx.hasUI) return;
474
+
475
+ const currentSessionFile = (ctx.sessionManager as any).getSessionFile?.() ??
476
+ (ctx.sessionManager as any).getSessionPath?.() ?? undefined;
477
+
478
+ // Phase 1: loading
479
+ const report = await ctx.ui.custom<UsageReport | null>((tui, theme, _kb, done) => {
480
+ const container = new Container();
481
+ const borderFn = (s: string) => theme.fg("border", s);
482
+
483
+ container.addChild(new DynamicBorder(borderFn));
484
+ container.addChild(new Spacer(1));
485
+ container.addChild(new Text(theme.fg("accent", " Usage Statistics"), 1, 0));
486
+ container.addChild(new Text(theme.fg("muted", " Syncing session data..."), 1, 0));
487
+ container.addChild(new Spacer(1));
488
+ container.addChild(new DynamicBorder(borderFn));
489
+ container.addChild(new Text(theme.fg("dim", " q close"), 1, 0));
490
+
491
+ let finished = false;
492
+ const controller = new AbortController();
493
+
494
+ syncUsageIndex(controller.signal)
495
+ .then(() => {
496
+ if (finished) return;
497
+ finished = true;
498
+ try {
499
+ const entries = readUsageEntries();
500
+ done(aggregate(entries, currentSessionFile));
501
+ } catch {
502
+ done(null);
503
+ }
504
+ })
505
+ .catch(() => {
506
+ if (!finished) {
507
+ finished = true;
508
+ done(null);
509
+ }
510
+ });
511
+
512
+ return {
513
+ render: (w: number) => container.render(w),
514
+ invalidate: () => container.invalidate(),
515
+ handleInput: (data: string) => {
516
+ if (matchesKey(data, "escape") || data === "q") {
517
+ if (!finished) {
518
+ finished = true;
519
+ controller.abort();
520
+ done(null);
521
+ }
522
+ }
523
+ },
524
+ };
525
+ });
526
+
527
+ if (!report) return;
528
+
529
+ // Phase 2: display
530
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
531
+ const rebuild = async () => {
532
+ try {
533
+ await rebuildUsageIndex();
534
+ const entries = readUsageEntries();
535
+ return aggregate(entries, currentSessionFile);
536
+ } catch {
537
+ return null;
538
+ }
539
+ };
540
+ return new UsageReportComponent(theme, report, () => tui.requestRender?.(), () => done(), rebuild);
541
+ });
542
+ },
543
+ });
544
+ }