@towles/tool 0.0.18 → 0.0.41

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +9 -10
  3. package/README.md +121 -78
  4. package/bin/run.ts +5 -0
  5. package/package.json +63 -53
  6. package/patches/prompts.patch +34 -0
  7. package/src/commands/base.ts +42 -0
  8. package/src/commands/config.test.ts +15 -0
  9. package/src/commands/config.ts +43 -0
  10. package/src/commands/doctor.ts +133 -0
  11. package/src/commands/gh/branch-clean.ts +110 -0
  12. package/src/commands/gh/branch.test.ts +124 -0
  13. package/src/commands/gh/branch.ts +132 -0
  14. package/src/commands/gh/pr.ts +168 -0
  15. package/src/commands/index.ts +55 -0
  16. package/src/commands/install.ts +148 -0
  17. package/src/commands/journal/daily-notes.ts +66 -0
  18. package/src/commands/journal/meeting.ts +83 -0
  19. package/src/commands/journal/note.ts +83 -0
  20. package/src/commands/journal/utils.ts +399 -0
  21. package/src/commands/observe/graph.test.ts +89 -0
  22. package/src/commands/observe/graph.ts +1640 -0
  23. package/src/commands/observe/report.ts +166 -0
  24. package/src/commands/observe/session.ts +385 -0
  25. package/src/commands/observe/setup.ts +180 -0
  26. package/src/commands/observe/status.ts +146 -0
  27. package/src/commands/ralph/lib/execution.ts +302 -0
  28. package/src/commands/ralph/lib/formatter.ts +298 -0
  29. package/src/commands/ralph/lib/index.ts +4 -0
  30. package/src/commands/ralph/lib/marker.ts +108 -0
  31. package/src/commands/ralph/lib/state.ts +191 -0
  32. package/src/commands/ralph/marker/create.ts +23 -0
  33. package/src/commands/ralph/plan.ts +73 -0
  34. package/src/commands/ralph/progress.ts +44 -0
  35. package/src/commands/ralph/ralph.test.ts +673 -0
  36. package/src/commands/ralph/run.ts +408 -0
  37. package/src/commands/ralph/task/add.ts +105 -0
  38. package/src/commands/ralph/task/done.ts +73 -0
  39. package/src/commands/ralph/task/list.test.ts +48 -0
  40. package/src/commands/ralph/task/list.ts +110 -0
  41. package/src/commands/ralph/task/remove.ts +62 -0
  42. package/src/config/context.ts +7 -0
  43. package/src/config/settings.ts +155 -0
  44. package/src/constants.ts +3 -0
  45. package/src/types/journal.ts +16 -0
  46. package/src/utils/anthropic/types.ts +158 -0
  47. package/src/utils/date-utils.test.ts +96 -0
  48. package/src/utils/date-utils.ts +54 -0
  49. package/src/utils/exec.ts +8 -0
  50. package/src/utils/git/gh-cli-wrapper.test.ts +14 -0
  51. package/src/utils/git/gh-cli-wrapper.ts +54 -0
  52. package/src/utils/git/git-wrapper.test.ts +26 -0
  53. package/src/utils/git/git-wrapper.ts +15 -0
  54. package/src/utils/git/git.ts +25 -0
  55. package/src/utils/render.test.ts +71 -0
  56. package/src/utils/render.ts +34 -0
  57. package/dist/index.d.mts +0 -1
  58. package/dist/index.mjs +0 -794
@@ -0,0 +1,166 @@
1
+ import { Flags } from "@oclif/core";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { x } from "tinyexec";
6
+ import { BaseCommand } from "../base.js";
7
+
8
+ /**
9
+ * Generate token/cost usage report via ccusage
10
+ */
11
+ export default class ObserveReport extends BaseCommand {
12
+ static override description = "Generate token/cost usage report via ccusage";
13
+
14
+ static override examples = [
15
+ "<%= config.bin %> <%= command.id %>",
16
+ "<%= config.bin %> <%= command.id %> --weekly",
17
+ "<%= config.bin %> <%= command.id %> --monthly --output",
18
+ ];
19
+
20
+ static override flags = {
21
+ ...BaseCommand.baseFlags,
22
+ daily: Flags.boolean({
23
+ description: "Show daily breakdown (default)",
24
+ exclusive: ["weekly", "monthly"],
25
+ }),
26
+ weekly: Flags.boolean({
27
+ description: "Show weekly breakdown",
28
+ exclusive: ["daily", "monthly"],
29
+ }),
30
+ monthly: Flags.boolean({
31
+ description: "Show monthly breakdown",
32
+ exclusive: ["daily", "weekly"],
33
+ }),
34
+ output: Flags.boolean({
35
+ char: "o",
36
+ description: "Save report to ~/.claude/reports/",
37
+ }),
38
+ };
39
+
40
+ async run(): Promise<void> {
41
+ const { flags } = await this.parse(ObserveReport);
42
+
43
+ // Determine timeframe
44
+ let timeframe = "daily";
45
+ if (flags.weekly) timeframe = "weekly";
46
+ if (flags.monthly) timeframe = "monthly";
47
+
48
+ this.log(`📊 Running ccusage (${timeframe} breakdown)...\n`);
49
+
50
+ try {
51
+ // Run ccusage with breakdown and json flags using tinyexec
52
+ const result = await x("npx", ["ccusage@latest", timeframe, "--breakdown", "--json"], {
53
+ timeout: 60000,
54
+ });
55
+
56
+ if (result.exitCode !== 0) {
57
+ this.error(`ccusage failed: ${result.stderr}`);
58
+ }
59
+
60
+ const jsonOutput = result.stdout.trim();
61
+
62
+ // Save to file if --output flag
63
+ if (flags.output) {
64
+ const reportsDir = path.join(os.homedir(), ".claude", "reports");
65
+ if (!fs.existsSync(reportsDir)) {
66
+ fs.mkdirSync(reportsDir, { recursive: true });
67
+ }
68
+
69
+ const timestamp = new Date().toISOString().split("T")[0];
70
+ const filename = `report-${timeframe}-${timestamp}.json`;
71
+ const outputPath = path.join(reportsDir, filename);
72
+
73
+ fs.writeFileSync(outputPath, jsonOutput);
74
+ this.log(`✓ Saved to ${outputPath}\n`);
75
+ }
76
+
77
+ // Parse and display formatted output
78
+ this.displayReport(jsonOutput, timeframe);
79
+ } catch (error) {
80
+ if (error instanceof Error && error.message.includes("ENOENT")) {
81
+ this.error("npx not found. Make sure Node.js is installed.");
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ private displayReport(jsonOutput: string, _timeframe: string): void {
88
+ try {
89
+ const data = JSON.parse(jsonOutput);
90
+
91
+ // Handle different ccusage output formats
92
+ if (Array.isArray(data)) {
93
+ this.displayArrayReport(data);
94
+ } else if (data.breakdown) {
95
+ this.displayBreakdownReport(data);
96
+ } else {
97
+ // Fallback: just show raw JSON formatted nicely
98
+ this.log(JSON.stringify(data, null, 2));
99
+ }
100
+ } catch {
101
+ // If JSON parsing fails, show raw output
102
+ this.log(jsonOutput);
103
+ }
104
+ }
105
+
106
+ private displayArrayReport(data: Array<Record<string, unknown>>): void {
107
+ for (const entry of data) {
108
+ const date = entry.date || entry.period || "Unknown";
109
+ const tokens = this.formatNumber(
110
+ (entry.total_tokens as number) || (entry.tokens as number) || 0,
111
+ );
112
+ const cost = this.formatCost((entry.cost_usd as number) || (entry.cost as number) || 0);
113
+
114
+ this.log(`${date}`);
115
+ this.log(` Tokens: ${tokens}`);
116
+ this.log(` Cost: ${cost}`);
117
+
118
+ // Show model breakdown if available
119
+ if (entry.models && typeof entry.models === "object") {
120
+ this.log(" By model:");
121
+ for (const [model, stats] of Object.entries(
122
+ entry.models as Record<string, { tokens?: number; cost?: number }>,
123
+ )) {
124
+ const modelTokens = this.formatNumber(stats.tokens || 0);
125
+ const modelCost = this.formatCost(stats.cost || 0);
126
+ this.log(` ${model}: ${modelTokens} tokens, ${modelCost}`);
127
+ }
128
+ }
129
+ this.log("");
130
+ }
131
+ }
132
+
133
+ private displayBreakdownReport(data: {
134
+ breakdown: Record<string, unknown>;
135
+ total?: unknown;
136
+ }): void {
137
+ if (data.total && typeof data.total === "object") {
138
+ const total = data.total as Record<string, number>;
139
+ this.log("Total");
140
+ this.log(` Tokens: ${this.formatNumber(total.tokens || 0)}`);
141
+ this.log(` Cost: ${this.formatCost(total.cost_usd || total.cost || 0)}`);
142
+ this.log("");
143
+ }
144
+
145
+ this.log("Breakdown by model:");
146
+ for (const [model, stats] of Object.entries(data.breakdown)) {
147
+ if (typeof stats === "object" && stats !== null) {
148
+ const s = stats as Record<string, number>;
149
+ this.log(` ${model}`);
150
+ this.log(` Input: ${this.formatNumber(s.input_tokens || 0)} tokens`);
151
+ this.log(` Output: ${this.formatNumber(s.output_tokens || 0)} tokens`);
152
+ this.log(` Cost: ${this.formatCost(s.cost_usd || s.cost || 0)}`);
153
+ }
154
+ }
155
+ }
156
+
157
+ private formatNumber(n: number): string {
158
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
159
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
160
+ return n.toString();
161
+ }
162
+
163
+ private formatCost(cost: number): string {
164
+ return `$${cost.toFixed(2)}`;
165
+ }
166
+ }
@@ -0,0 +1,385 @@
1
+ import { Args, Flags } from "@oclif/core";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import pc from "picocolors";
6
+ import { BaseCommand } from "../base.js";
7
+
8
+ interface JournalEntry {
9
+ type: string;
10
+ sessionId: string;
11
+ timestamp: string;
12
+ message?: {
13
+ role: "user" | "assistant";
14
+ model?: string;
15
+ usage?: {
16
+ input_tokens?: number;
17
+ output_tokens?: number;
18
+ cache_read_input_tokens?: number;
19
+ cache_creation_input_tokens?: number;
20
+ };
21
+ };
22
+ }
23
+
24
+ interface SessionInfo {
25
+ sessionId: string;
26
+ path: string;
27
+ date: string;
28
+ mtime: number;
29
+ project: string;
30
+ totalTokens: number;
31
+ inputTokens: number;
32
+ outputTokens: number;
33
+ cacheReadTokens: number;
34
+ turnCount: number;
35
+ modelBreakdown: Map<string, { input: number; output: number }>;
36
+ }
37
+
38
+ // Approximate costs per 1M tokens (USD)
39
+ const MODEL_COSTS: Record<string, { input: number; output: number }> = {
40
+ "claude-opus-4": { input: 15, output: 75 },
41
+ "claude-sonnet-4": { input: 3, output: 15 },
42
+ "claude-3-5-sonnet": { input: 3, output: 15 },
43
+ "claude-3-haiku": { input: 0.25, output: 1.25 },
44
+ default: { input: 3, output: 15 }, // Assume sonnet
45
+ };
46
+
47
+ /**
48
+ * List and analyze Claude Code sessions
49
+ */
50
+ export default class ObserveSession extends BaseCommand {
51
+ static override description = "List and analyze Claude Code sessions";
52
+
53
+ static override examples = [
54
+ "<%= config.bin %> <%= command.id %>",
55
+ "<%= config.bin %> <%= command.id %> abc123",
56
+ "<%= config.bin %> <%= command.id %> --limit 20",
57
+ ];
58
+
59
+ static override flags = {
60
+ ...BaseCommand.baseFlags,
61
+ limit: Flags.integer({
62
+ char: "n",
63
+ description: "Number of sessions to list",
64
+ default: 15,
65
+ }),
66
+ };
67
+
68
+ static override args = {
69
+ sessionId: Args.string({
70
+ description: "Session ID to show detailed turn-by-turn breakdown",
71
+ required: false,
72
+ }),
73
+ };
74
+
75
+ async run(): Promise<void> {
76
+ const { args, flags } = await this.parse(ObserveSession);
77
+
78
+ const projectsDir = path.join(os.homedir(), ".claude", "projects");
79
+ if (!fs.existsSync(projectsDir)) {
80
+ this.error("No Claude projects directory found at ~/.claude/projects/");
81
+ }
82
+
83
+ if (args.sessionId) {
84
+ await this.showSessionDetail(projectsDir, args.sessionId);
85
+ } else {
86
+ await this.listSessions(projectsDir, flags.limit);
87
+ }
88
+ }
89
+
90
+ private async listSessions(projectsDir: string, limit: number): Promise<void> {
91
+ this.log(pc.cyan("\n📋 Recent Sessions\n"));
92
+
93
+ const sessions = this.findSessions(projectsDir, limit);
94
+ if (sessions.length === 0) {
95
+ this.log("No sessions found.");
96
+ return;
97
+ }
98
+
99
+ // Table header
100
+ this.log(
101
+ pc.dim(
102
+ `${"Session ID".padEnd(12)} ${"Date".padEnd(12)} ${"Tokens".padEnd(10)} ${"Est. Cost".padEnd(10)} Project`,
103
+ ),
104
+ );
105
+ this.log(pc.dim("─".repeat(80)));
106
+
107
+ for (const s of sessions) {
108
+ const cost = this.estimateCost(s);
109
+ const costStr = cost > 0 ? `$${cost.toFixed(2)}` : "-";
110
+
111
+ this.log(
112
+ `${pc.bold(s.sessionId.slice(0, 10))} ${s.date.padEnd(12)} ${this.formatTokens(s.totalTokens).padEnd(10)} ${costStr.padEnd(10)} ${pc.dim(s.project)}`,
113
+ );
114
+ }
115
+
116
+ this.log(pc.dim("─".repeat(80)));
117
+ this.log(pc.dim(`\nShowing ${sessions.length} sessions. Use --limit to show more.`));
118
+ this.log(pc.dim("Run with session ID for detailed breakdown: tt observe session <id>"));
119
+ }
120
+
121
+ private async showSessionDetail(projectsDir: string, sessionId: string): Promise<void> {
122
+ const sessionPath = this.findSessionPath(projectsDir, sessionId);
123
+ if (!sessionPath) {
124
+ this.error(`Session ${sessionId} not found`);
125
+ }
126
+
127
+ const session = this.analyzeSession(sessionPath, sessionId);
128
+ const turns = this.parseTurns(sessionPath);
129
+
130
+ this.log(pc.cyan(`\n📊 Session: ${sessionId}\n`));
131
+ this.log(`Project: ${pc.dim(session.project)}`);
132
+ this.log(`Date: ${session.date}`);
133
+ this.log(`Turns: ${session.turnCount}`);
134
+ this.log("");
135
+
136
+ // Token summary
137
+ this.log(pc.bold("Token Summary"));
138
+ this.log(` Total: ${this.formatTokens(session.totalTokens)}`);
139
+ this.log(` Input: ${this.formatTokens(session.inputTokens)}`);
140
+ this.log(` Output: ${this.formatTokens(session.outputTokens)}`);
141
+ if (session.cacheReadTokens > 0) {
142
+ this.log(` Cache Read: ${this.formatTokens(session.cacheReadTokens)}`);
143
+ }
144
+ this.log("");
145
+
146
+ // Cost estimate
147
+ const cost = this.estimateCost(session);
148
+ this.log(pc.bold("Estimated Cost"));
149
+ this.log(` Total: ${pc.yellow(`$${cost.toFixed(2)}`)}`);
150
+ this.log("");
151
+
152
+ // Model breakdown
153
+ if (session.modelBreakdown.size > 0) {
154
+ this.log(pc.bold("By Model"));
155
+ for (const [model, usage] of session.modelBreakdown) {
156
+ const total = usage.input + usage.output;
157
+ const pct = ((total / session.totalTokens) * 100).toFixed(0);
158
+ const icon = this.getModelIcon(model);
159
+ this.log(` ${icon} ${model.padEnd(25)} ${this.formatTokens(total).padEnd(10)} (${pct}%)`);
160
+ }
161
+ this.log("");
162
+ }
163
+
164
+ // Turn-by-turn breakdown
165
+ if (turns.length > 0) {
166
+ this.log(pc.bold("Turn-by-Turn"));
167
+ this.log(
168
+ pc.dim(
169
+ `${"#".padStart(4)} ${"Role".padEnd(10)} ${"Model".padEnd(28)} ${"Input".padEnd(8)} ${"Output".padEnd(8)}`,
170
+ ),
171
+ );
172
+ this.log(pc.dim("─".repeat(68)));
173
+
174
+ for (const turn of turns) {
175
+ const icon = this.getModelIcon(turn.model);
176
+ const roleColor = turn.role === "user" ? pc.green : pc.blue;
177
+ const modelName = turn.model.length > 26 ? turn.model.slice(0, 26) + ".." : turn.model;
178
+ this.log(
179
+ `${turn.num.toString().padStart(4)} ${roleColor(turn.role.padEnd(10))} ${icon} ${modelName.padEnd(26)} ${this.formatTokens(turn.input).padEnd(8)} ${this.formatTokens(turn.output).padEnd(8)}`,
180
+ );
181
+ }
182
+ }
183
+ }
184
+
185
+ private parseTurns(filePath: string): Array<{
186
+ num: number;
187
+ role: "user" | "assistant";
188
+ model: string;
189
+ input: number;
190
+ output: number;
191
+ }> {
192
+ const turns: Array<{
193
+ num: number;
194
+ role: "user" | "assistant";
195
+ model: string;
196
+ input: number;
197
+ output: number;
198
+ }> = [];
199
+
200
+ try {
201
+ const content = fs.readFileSync(filePath, "utf-8");
202
+ let turnNum = 0;
203
+
204
+ for (const line of content.split("\n")) {
205
+ if (!line.trim()) continue;
206
+ try {
207
+ const entry = JSON.parse(line) as JournalEntry;
208
+ if (entry.type === "user") {
209
+ turnNum++;
210
+ }
211
+
212
+ if (entry.message?.role) {
213
+ const usage = entry.message.usage || {};
214
+ turns.push({
215
+ num: turnNum,
216
+ role: entry.message.role,
217
+ model: entry.message.model || "unknown",
218
+ input: usage.input_tokens || 0,
219
+ output: usage.output_tokens || 0,
220
+ });
221
+ }
222
+ } catch {
223
+ // Skip invalid lines
224
+ }
225
+ }
226
+ } catch {
227
+ // Return empty on error
228
+ }
229
+
230
+ return turns;
231
+ }
232
+
233
+ private findSessions(projectsDir: string, limit: number): SessionInfo[] {
234
+ const sessions: SessionInfo[] = [];
235
+
236
+ const projectDirs = fs.readdirSync(projectsDir);
237
+ for (const project of projectDirs) {
238
+ const projectPath = path.join(projectsDir, project);
239
+ if (!fs.statSync(projectPath).isDirectory()) continue;
240
+
241
+ const files = fs.readdirSync(projectPath).filter((f) => f.endsWith(".jsonl"));
242
+ for (const file of files) {
243
+ const filePath = path.join(projectPath, file);
244
+ const stat = fs.statSync(filePath);
245
+ const sessionId = file.replace(".jsonl", "");
246
+
247
+ const info = this.analyzeSession(filePath, sessionId);
248
+ info.project = this.decodeProjectPath(project);
249
+ info.mtime = stat.mtimeMs;
250
+
251
+ sessions.push(info);
252
+ }
253
+ }
254
+
255
+ sessions.sort((a, b) => b.mtime - a.mtime);
256
+ return sessions.slice(0, limit);
257
+ }
258
+
259
+ private findSessionPath(projectsDir: string, sessionId: string): string | undefined {
260
+ const projectDirs = fs.readdirSync(projectsDir);
261
+ const matches: string[] = [];
262
+
263
+ for (const project of projectDirs) {
264
+ const projectPath = path.join(projectsDir, project);
265
+ if (!fs.statSync(projectPath).isDirectory()) continue;
266
+
267
+ // Exact match
268
+ const jsonlPath = path.join(projectPath, `${sessionId}.jsonl`);
269
+ if (fs.existsSync(jsonlPath)) {
270
+ return jsonlPath;
271
+ }
272
+
273
+ // Partial match (session ID contains the query)
274
+ const files = fs
275
+ .readdirSync(projectPath)
276
+ .filter((f) => f.endsWith(".jsonl") && f.includes(sessionId));
277
+ for (const f of files) {
278
+ matches.push(path.join(projectPath, f));
279
+ }
280
+ }
281
+
282
+ // Return if exactly one match
283
+ if (matches.length === 1) {
284
+ return matches[0];
285
+ }
286
+ return undefined;
287
+ }
288
+
289
+ private analyzeSession(filePath: string, sessionId: string): SessionInfo {
290
+ const info: SessionInfo = {
291
+ sessionId,
292
+ path: filePath,
293
+ date: "",
294
+ mtime: 0,
295
+ project: "",
296
+ totalTokens: 0,
297
+ inputTokens: 0,
298
+ outputTokens: 0,
299
+ cacheReadTokens: 0,
300
+ turnCount: 0,
301
+ modelBreakdown: new Map(),
302
+ };
303
+
304
+ try {
305
+ const stat = fs.statSync(filePath);
306
+ info.date = stat.mtime.toISOString().split("T")[0];
307
+ info.mtime = stat.mtimeMs;
308
+
309
+ // Parse parent dir for project
310
+ const parentDir = path.basename(path.dirname(filePath));
311
+ info.project = this.decodeProjectPath(parentDir);
312
+
313
+ const content = fs.readFileSync(filePath, "utf-8");
314
+ for (const line of content.split("\n")) {
315
+ if (!line.trim()) continue;
316
+ try {
317
+ const entry = JSON.parse(line) as JournalEntry;
318
+ if (entry.type === "user") {
319
+ info.turnCount++;
320
+ }
321
+
322
+ if (entry.message?.usage) {
323
+ const u = entry.message.usage;
324
+ const input = u.input_tokens || 0;
325
+ const output = u.output_tokens || 0;
326
+ const cacheRead = u.cache_read_input_tokens || 0;
327
+
328
+ info.inputTokens += input;
329
+ info.outputTokens += output;
330
+ info.cacheReadTokens += cacheRead;
331
+ info.totalTokens += input + output;
332
+
333
+ // Track by model
334
+ const model = entry.message.model || "unknown";
335
+ const existing = info.modelBreakdown.get(model) || { input: 0, output: 0 };
336
+ existing.input += input;
337
+ existing.output += output;
338
+ info.modelBreakdown.set(model, existing);
339
+ }
340
+ } catch {
341
+ // Skip invalid lines
342
+ }
343
+ }
344
+ } catch {
345
+ // Return partial info
346
+ }
347
+
348
+ return info;
349
+ }
350
+
351
+ private decodeProjectPath(encoded: string): string {
352
+ // Project dirs are encoded with - for / and other chars
353
+ return encoded.replace(/-/g, "/").slice(0, 35);
354
+ }
355
+
356
+ private estimateCost(session: SessionInfo): number {
357
+ let total = 0;
358
+ for (const [model, usage] of session.modelBreakdown) {
359
+ const rates = this.getModelRates(model);
360
+ total += (usage.input / 1_000_000) * rates.input;
361
+ total += (usage.output / 1_000_000) * rates.output;
362
+ }
363
+ return total;
364
+ }
365
+
366
+ private getModelRates(model: string): { input: number; output: number } {
367
+ if (model.includes("opus")) return MODEL_COSTS["claude-opus-4"];
368
+ if (model.includes("haiku")) return MODEL_COSTS["claude-3-haiku"];
369
+ if (model.includes("sonnet")) return MODEL_COSTS["claude-sonnet-4"];
370
+ return MODEL_COSTS["default"];
371
+ }
372
+
373
+ private getModelIcon(model: string): string {
374
+ if (model.includes("opus")) return "🔴";
375
+ if (model.includes("sonnet")) return "🔵";
376
+ if (model.includes("haiku")) return "🟢";
377
+ return "⚪";
378
+ }
379
+
380
+ private formatTokens(n: number): string {
381
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
382
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
383
+ return n.toString();
384
+ }
385
+ }