tokentracker-cli 0.21.3 → 0.22.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 (48) hide show
  1. package/README.ja.md +457 -0
  2. package/README.ko.md +457 -0
  3. package/README.md +45 -6
  4. package/README.zh-CN.md +45 -6
  5. package/dashboard/dist/assets/{Card-Cv4wn6W8.js → Card-CD18G4Ge.js} +1 -1
  6. package/dashboard/dist/assets/DashboardPage-DKY_Mi9v.js +64 -0
  7. package/dashboard/dist/assets/DevicePage-BkavlAal.js +1 -0
  8. package/dashboard/dist/assets/{FadeIn-DjQyRfLZ.js → FadeIn-CVNJ4aZy.js} +1 -1
  9. package/dashboard/dist/assets/{HeaderGithubStar-D2BjLT1b.js → HeaderGithubStar-COu1Xy3I.js} +1 -1
  10. package/dashboard/dist/assets/{IpCheckPage-D0uvbHPe.js → IpCheckPage-B2HjZ3vY.js} +1 -1
  11. package/dashboard/dist/assets/{LandingPage-DGJcVAg7.js → LandingPage-9PSLFnys.js} +1 -1
  12. package/dashboard/dist/assets/{LeaderboardPage-Dnt_YLsP.js → LeaderboardPage-CQT5dBHU.js} +1 -1
  13. package/dashboard/dist/assets/{LeaderboardProfilePage-DM7S9_kG.js → LeaderboardProfilePage-aPP-Raey.js} +1 -1
  14. package/dashboard/dist/assets/{LimitsPage-COomwRa6.js → LimitsPage-eFrAHmoA.js} +2 -2
  15. package/dashboard/dist/assets/{LoginPage-k0k50kws.js → LoginPage-CczTNZ_P.js} +1 -1
  16. package/dashboard/dist/assets/{PopoverPopup-DctOj5-q.js → PopoverPopup-CEvWSWgZ.js} +2 -2
  17. package/dashboard/dist/assets/{ProviderIcon-DGlYzr9I.js → ProviderIcon-ewev19y3.js} +1 -1
  18. package/dashboard/dist/assets/SettingsPage-taBxq6ux.js +1 -0
  19. package/dashboard/dist/assets/SkillsPage-cqHO3rMB.js +1 -0
  20. package/dashboard/dist/assets/{WidgetsPage-DsMj8Qcz.js → WidgetsPage-B53b1hwG.js} +1 -1
  21. package/dashboard/dist/assets/WrappedPage-DwAhprTa.js +1 -0
  22. package/dashboard/dist/assets/check-JnFJsHgI.js +1 -0
  23. package/dashboard/dist/assets/{chevron-down-kcaroSaH.js → chevron-down-zOKEzHdv.js} +1 -1
  24. package/dashboard/dist/assets/{download-DKMK6oF8.js → download-BZZ4vKc1.js} +1 -1
  25. package/dashboard/dist/assets/{leaderboard-columns-BZ06dD2h.js → leaderboard-columns-BNGlMUsD.js} +1 -1
  26. package/dashboard/dist/assets/main-A_x5MMU-.css +1 -0
  27. package/dashboard/dist/assets/{main-DKVBnAOd.js → main-D0Irg9xR.js} +62 -17
  28. package/dashboard/dist/assets/{use-limits-display-prefs-Bx-K-27B.js → use-limits-display-prefs-BTuSZo27.js} +1 -1
  29. package/dashboard/dist/assets/{use-native-settings-DtuifRKC.js → use-native-settings-BPXVZrWe.js} +1 -1
  30. package/dashboard/dist/assets/{use-reduced-motion-Cen-UCKO.js → use-reduced-motion-HUOV_JD1.js} +1 -1
  31. package/dashboard/dist/assets/{use-usage-limits-CAWz6ijv.js → use-usage-limits-G6-vCBcN.js} +1 -1
  32. package/dashboard/dist/index.html +2 -2
  33. package/dashboard/dist/share.html +2 -2
  34. package/package.json +3 -2
  35. package/src/cli.js +11 -0
  36. package/src/commands/device-login.js +161 -0
  37. package/src/commands/status.js +199 -1
  38. package/src/commands/sync.js +85 -2
  39. package/src/commands/wrapped.js +150 -0
  40. package/src/lib/local-api.js +37 -2
  41. package/src/lib/passive-mode.js +185 -0
  42. package/src/lib/pricing/seed-snapshot.json +1 -1
  43. package/src/lib/rollout.js +913 -0
  44. package/src/lib/wrapped-aggregator.js +225 -0
  45. package/dashboard/dist/assets/DashboardPage-DsfcNgai.js +0 -1
  46. package/dashboard/dist/assets/SettingsPage-D2sqM9g_.js +0 -1
  47. package/dashboard/dist/assets/SkillsPage-B8K--edc.js +0 -1
  48. package/dashboard/dist/assets/main-DX38hz5f.css +0 -1
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+ const fs = require("node:fs");
6
+
7
+ const { resolveTrackerPaths } = require("../lib/tracker-paths");
8
+ const { aggregateWrapped, formatCompact } = require("../lib/wrapped-aggregator");
9
+
10
+ async function readQueueRows(queuePath) {
11
+ if (!fs.existsSync(queuePath)) return [];
12
+ const raw = await fs.promises.readFile(queuePath, "utf8");
13
+ const rows = [];
14
+ for (const line of raw.split("\n")) {
15
+ if (!line.trim()) continue;
16
+ try {
17
+ rows.push(JSON.parse(line));
18
+ } catch (_e) {
19
+ // skip malformed line — production queues sometimes hold a partial
20
+ // tail row mid-write that becomes valid on the next read.
21
+ }
22
+ }
23
+ return rows;
24
+ }
25
+
26
+ function parseArgs(argv) {
27
+ const out = { year: null, json: false };
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ if (a === "--json") out.json = true;
31
+ else if (a === "--year") {
32
+ const v = argv[++i];
33
+ const y = Number(v);
34
+ if (!Number.isInteger(y) || y < 2000 || y > 2100) {
35
+ throw new Error(`--year expects a 4-digit year, got: ${v}`);
36
+ }
37
+ out.year = y;
38
+ } else throw new Error(`Unknown option: ${a}`);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function renderAscii(wrapped) {
44
+ const lines = [];
45
+ const w = 60;
46
+ const border = "═".repeat(w - 2);
47
+ const center = (s) => {
48
+ const text = String(s);
49
+ const padTotal = Math.max(0, w - 2 - text.length);
50
+ const left = Math.floor(padTotal / 2);
51
+ const right = padTotal - left;
52
+ return `║${" ".repeat(left)}${text}${" ".repeat(right)}║`;
53
+ };
54
+ const row = (label, value) => {
55
+ const text = ` ${label}: ${value}`;
56
+ const right = Math.max(0, w - 2 - text.length);
57
+ return `║${text}${" ".repeat(right)}║`;
58
+ };
59
+
60
+ lines.push(`╔${border}╗`);
61
+ lines.push(center(""));
62
+ lines.push(center(`TokenTracker Wrapped · ${wrapped.year}`));
63
+ lines.push(center(""));
64
+ lines.push(`╠${border}╣`);
65
+ lines.push(row("Total tokens", formatCompact(wrapped.totals.tokens)));
66
+ lines.push(row("Conversations", wrapped.totals.conversations.toLocaleString("en-US")));
67
+ lines.push(row("Active days", `${wrapped.totals.active_days} / 365`));
68
+ lines.push(row("Tools used", String(wrapped.totals.sources)));
69
+ lines.push(row("Models used", String(wrapped.totals.models)));
70
+ if (wrapped.peak_hour) {
71
+ lines.push(
72
+ row(
73
+ "Peak hour",
74
+ `${String(wrapped.peak_hour.hour).padStart(2, "0")}:00 UTC (${formatCompact(wrapped.peak_hour.tokens)})`,
75
+ ),
76
+ );
77
+ }
78
+ if (wrapped.longest_streak.days > 0) {
79
+ lines.push(
80
+ row("Longest streak", `${wrapped.longest_streak.days} days (${wrapped.longest_streak.from} → ${wrapped.longest_streak.to})`),
81
+ );
82
+ }
83
+ lines.push(`╠${border}╣`);
84
+
85
+ if (wrapped.top.sources.length > 0) {
86
+ lines.push(row("Top tools", ""));
87
+ for (const s of wrapped.top.sources.slice(0, 3)) {
88
+ lines.push(row(` ${s.source}`, `${formatCompact(s.tokens)} (${(s.share * 100).toFixed(0)}%)`));
89
+ }
90
+ }
91
+ if (wrapped.top.models.length > 0) {
92
+ lines.push(`╠${border}╣`);
93
+ lines.push(row("Top models", ""));
94
+ for (const m of wrapped.top.models.slice(0, 3)) {
95
+ lines.push(row(` ${m.model}`, `${formatCompact(m.tokens)} (${(m.share * 100).toFixed(0)}%)`));
96
+ }
97
+ }
98
+ if (wrapped.top.days.length > 0) {
99
+ lines.push(`╠${border}╣`);
100
+ lines.push(row("Top days", ""));
101
+ for (const d of wrapped.top.days.slice(0, 3)) {
102
+ lines.push(row(` ${d.day}`, formatCompact(d.tokens)));
103
+ }
104
+ }
105
+ if (wrapped.highlights.length > 0) {
106
+ lines.push(`╠${border}╣`);
107
+ const max = w - 4; // 2 borders + 2 padding
108
+ for (const h of wrapped.highlights) {
109
+ // Wrap long highlights into multiple rows.
110
+ for (let start = 0; start < h.length; start += max) {
111
+ const segment = h.slice(start, start + max);
112
+ const padded = ` ${segment}`;
113
+ const right = Math.max(0, w - 2 - padded.length);
114
+ lines.push(`║${padded}${" ".repeat(right)}║`);
115
+ }
116
+ }
117
+ }
118
+ lines.push(`╚${border}╝`);
119
+ return lines.join("\n");
120
+ }
121
+
122
+ async function cmdWrapped(argv = []) {
123
+ const opts = parseArgs(argv);
124
+ const home = os.homedir();
125
+ const { trackerDir } = await resolveTrackerPaths({ home });
126
+ const queuePath = path.join(trackerDir, "queue.jsonl");
127
+
128
+ const rows = await readQueueRows(queuePath);
129
+ if (rows.length === 0) {
130
+ if (opts.json) {
131
+ process.stdout.write(JSON.stringify({ error: "no data" }, null, 2) + "\n");
132
+ } else {
133
+ process.stdout.write(
134
+ "No queue data found yet. Run `tracker sync` first to ingest some history.\n",
135
+ );
136
+ }
137
+ return;
138
+ }
139
+
140
+ const wrapped = aggregateWrapped(rows, opts.year ? { year: opts.year } : {});
141
+
142
+ if (opts.json) {
143
+ process.stdout.write(JSON.stringify(wrapped, null, 2) + "\n");
144
+ return;
145
+ }
146
+
147
+ process.stdout.write(renderAscii(wrapped) + "\n");
148
+ }
149
+
150
+ module.exports = { cmdWrapped };
@@ -175,6 +175,12 @@ function aggregateByDay(rows, timeZoneContext = null) {
175
175
  a.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
176
176
  a.reasoning_output_tokens += row.reasoning_output_tokens || 0;
177
177
  a.conversation_count += row.conversation_count || 0;
178
+
179
+ if (!a.models) {
180
+ a.models = {};
181
+ }
182
+ const model = row.model || "unknown";
183
+ a.models[model] = (a.models[model] || 0) + (row.total_tokens || 0);
178
184
  }
179
185
  return Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day));
180
186
  }
@@ -999,6 +1005,17 @@ function createLocalApiHandler({ queuePath }) {
999
1005
  return true;
1000
1006
  }
1001
1007
 
1008
+ // --- wrapped (year-end summary, à la Spotify Wrapped) ---
1009
+ if (p === "/functions/tokentracker-wrapped") {
1010
+ const yearParam = url.searchParams.get("year");
1011
+ const year = yearParam ? Number(yearParam) : null;
1012
+ const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
1013
+ const { aggregateWrapped } = require("./wrapped-aggregator");
1014
+ const summary = aggregateWrapped(rows, year ? { year } : {});
1015
+ json(res, { scope, excluded_sources: excludedSources, ...summary });
1016
+ return true;
1017
+ }
1018
+
1002
1019
  // --- usage-summary ---
1003
1020
  if (p === "/functions/tokentracker-usage-summary") {
1004
1021
  const from = url.searchParams.get("from") || "";
@@ -1111,14 +1128,32 @@ function createLocalApiHandler({ queuePath }) {
1111
1128
  const day = cursor.toISOString().slice(0, 10);
1112
1129
  const data = byDay.get(day);
1113
1130
  const billable = data?.billable_total_tokens || 0;
1114
- cells.push({ day, total_tokens: data?.total_tokens || 0, billable_total_tokens: billable, level: calcLevel(billable) });
1131
+ cells.push({ day, total_tokens: data?.total_tokens || 0, billable_total_tokens: billable, level: calcLevel(billable), models: data?.models || null });
1115
1132
  cursor.setUTCDate(cursor.getUTCDate() + 1);
1116
1133
  }
1117
1134
  const weeksArr = [];
1118
1135
  for (let i = 0; i < cells.length; i += 7) {
1119
1136
  weeksArr.push(cells.slice(i, i + 7));
1120
1137
  }
1121
- json(res, { from, to, scope, excluded_sources: excludedSources, week_starts_on: "sun", active_days: cells.filter((c) => c.billable_total_tokens > 0).length, streak_days: 0, weeks: weeksArr });
1138
+
1139
+ let totalCostUsd = 0;
1140
+ for (const d of daily) {
1141
+ if (d.day >= from && d.day <= to) {
1142
+ totalCostUsd += d.total_cost_usd || 0;
1143
+ }
1144
+ }
1145
+
1146
+ json(res, {
1147
+ from,
1148
+ to,
1149
+ scope,
1150
+ excluded_sources: excludedSources,
1151
+ week_starts_on: "sun",
1152
+ active_days: cells.filter((c) => c.billable_total_tokens > 0).length,
1153
+ streak_days: 0,
1154
+ weeks: weeksArr,
1155
+ total_cost_usd: totalCostUsd
1156
+ });
1122
1157
  return true;
1123
1158
  }
1124
1159
 
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Passive-mode detection.
3
+ *
4
+ * Token Tracker normally collects token usage by installing SessionEnd hooks
5
+ * into supported CLIs (Claude Code, Codex CLI, Gemini CLI, …). The hook
6
+ * fires `notify.cjs` after each turn → sync runs immediately → data is
7
+ * fresh within seconds.
8
+ *
9
+ * On environments where hook installation fails — WSL with read-only mount,
10
+ * UNC paths on Windows, locked `settings.json`, sandboxed processes —
11
+ * SessionEnd hooks never fire, but the underlying CLIs *still* write
12
+ * session logs (`~/.claude/projects/`, `~/.gemini/sessions/`, etc.). Our
13
+ * parsers can read those logs directly without the hook; the only loss is
14
+ * "data is fresh within seconds" → it's now "data is fresh after the next
15
+ * scheduled `tracker sync`".
16
+ *
17
+ * This module surfaces that fact: for each hook-driven provider, decide
18
+ * whether the local install is in **passive mode** (no hook + log dir
19
+ * present). `tracker status` reports it, and the dashboard can show a
20
+ * single banner asking the user whether to acknowledge & continue in
21
+ * passive mode or attempt re-install (`tracker init`).
22
+ *
23
+ * Detection is purely best-effort — we never *fail* sync because hooks are
24
+ * missing; we just label the source so users / AI agents know latency is
25
+ * minutes, not seconds.
26
+ */
27
+ "use strict";
28
+
29
+ const fs = require("node:fs");
30
+ const path = require("node:path");
31
+
32
+ /**
33
+ * @typedef {Object} PassiveProvider
34
+ * @property {string} name - canonical provider name
35
+ * @property {boolean} hook_expected - whether this provider supports hooks
36
+ * @property {boolean} hook_installed - true when the hook is set
37
+ * @property {boolean} logs_present - true when the provider's log dir
38
+ * exists and contains at least one
39
+ * session file we can parse
40
+ * @property {boolean} passive - true iff hook_expected &&
41
+ * !hook_installed && logs_present
42
+ * @property {string|null} hook_failure_reason - if hook_expected and not
43
+ * installed, why we think it failed
44
+ * (e.g. "settings.json not writable",
45
+ * "config dir not found")
46
+ */
47
+
48
+ function dirHasFile(dir, predicate) {
49
+ if (!dir || !fs.existsSync(dir)) return false;
50
+ let entries;
51
+ try {
52
+ entries = fs.readdirSync(dir, { withFileTypes: true });
53
+ } catch (_e) {
54
+ return false;
55
+ }
56
+ for (const entry of entries) {
57
+ const full = path.join(dir, entry.name);
58
+ // Let the predicate accept either a file OR a directory name (so
59
+ // "sessions" or "tmp" as a bare subdirectory still counts).
60
+ if (!predicate || predicate(full, entry.name, entry.isDirectory())) return true;
61
+ if (entry.isDirectory()) {
62
+ // Shallow recurse — at most 1 level (covers projects/<name>/*.jsonl)
63
+ if (dirHasFile(full, predicate)) return true;
64
+ }
65
+ }
66
+ return false;
67
+ }
68
+
69
+ function classifyWritableFailure(filePath) {
70
+ if (!filePath) return "no path";
71
+ try {
72
+ fs.accessSync(filePath, fs.constants.W_OK);
73
+ return "writable (hook may have been removed externally)";
74
+ } catch (e) {
75
+ if (e && e.code === "ENOENT") return "settings file missing";
76
+ if (e && e.code === "EACCES") return "permission denied";
77
+ if (e && e.code === "EROFS") return "read-only filesystem";
78
+ return e?.code || "unknown";
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Detect passive mode for each hook-driven provider.
84
+ *
85
+ * @param {Object} opts
86
+ * @param {string} opts.home - user home directory
87
+ * @param {Object} opts.hookStatus - per-provider hook-installed booleans
88
+ * (already collected by status.js); shape: { claude, gemini, codex,
89
+ * every_code, opencode, openclaw, codebuddy, grok }
90
+ * @returns {PassiveProvider[]}
91
+ */
92
+ function detectPassiveProviders({ home, hookStatus }) {
93
+ const out = [];
94
+
95
+ // Claude Code — logs at ~/.claude/projects/<name>/*.jsonl
96
+ out.push(buildEntry({
97
+ name: "claude",
98
+ hookExpected: true,
99
+ hookInstalled: Boolean(hookStatus?.claude),
100
+ logsDir: path.join(home, ".claude", "projects"),
101
+ logsPredicate: (full) => full.endsWith(".jsonl"),
102
+ settingsPath: path.join(home, ".claude", "settings.json"),
103
+ }));
104
+
105
+ // Gemini CLI — logs at ~/.gemini/tmp/<session-id>/logs.json
106
+ // (path varies by version; presence of ~/.gemini/sessions or
107
+ // ~/.gemini/tmp is enough to call it "active")
108
+ out.push(buildEntry({
109
+ name: "gemini",
110
+ hookExpected: true,
111
+ hookInstalled: Boolean(hookStatus?.gemini),
112
+ logsDir: path.join(home, ".gemini"),
113
+ logsPredicate: (_full, name, isDir) =>
114
+ (isDir && (name === "tmp" || name === "sessions")) || name === "logs.json",
115
+ settingsPath: path.join(home, ".gemini", "settings.json"),
116
+ }));
117
+
118
+ // Codex CLI — logs at ~/.codex/sessions/
119
+ // Match either a "sessions" subdir (even if empty — the CLI created it)
120
+ // or any *.jsonl session file.
121
+ out.push(buildEntry({
122
+ name: "codex",
123
+ hookExpected: true,
124
+ hookInstalled: Boolean(hookStatus?.codex_notify ?? hookStatus?.codex),
125
+ logsDir: path.join(home, ".codex"),
126
+ logsPredicate: (_full, name, isDir) =>
127
+ (isDir && name === "sessions") || (!isDir && name.endsWith(".jsonl")),
128
+ settingsPath: path.join(home, ".codex", "config.toml"),
129
+ }));
130
+
131
+ // Every Code — same shape as Codex, different home
132
+ out.push(buildEntry({
133
+ name: "every_code",
134
+ hookExpected: true,
135
+ hookInstalled: Boolean(hookStatus?.every_code_notify ?? hookStatus?.every_code),
136
+ logsDir: path.join(home, ".code"),
137
+ logsPredicate: (_full, name, isDir) =>
138
+ (isDir && name === "sessions") || (!isDir && name.endsWith(".jsonl")),
139
+ settingsPath: path.join(home, ".code", "config.toml"),
140
+ }));
141
+
142
+ // CodeBuddy — Claude-fork; hook in ~/.codebuddy/settings.json
143
+ out.push(buildEntry({
144
+ name: "codebuddy",
145
+ hookExpected: true,
146
+ hookInstalled: Boolean(hookStatus?.codebuddy),
147
+ logsDir: path.join(home, ".codebuddy"),
148
+ logsPredicate: (_full, name) => name === "projects" || name.endsWith(".jsonl"),
149
+ settingsPath: path.join(home, ".codebuddy", "settings.json"),
150
+ }));
151
+
152
+ return out;
153
+ }
154
+
155
+ function buildEntry({ name, hookExpected, hookInstalled, logsDir, logsPredicate, settingsPath }) {
156
+ const logsPresent = dirHasFile(logsDir, logsPredicate);
157
+ const passive = hookExpected && !hookInstalled && logsPresent;
158
+ let reason = null;
159
+ if (hookExpected && !hookInstalled) {
160
+ reason = classifyWritableFailure(settingsPath);
161
+ }
162
+ return {
163
+ name,
164
+ hook_expected: hookExpected,
165
+ hook_installed: hookInstalled,
166
+ logs_present: logsPresent,
167
+ passive,
168
+ hook_failure_reason: reason,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Convenience boolean: is at least one provider in passive mode?
174
+ */
175
+ function isPassiveModeActive(providers) {
176
+ return providers.some((p) => p.passive);
177
+ }
178
+
179
+ module.exports = {
180
+ detectPassiveProviders,
181
+ isPassiveModeActive,
182
+ // Exposed for unit testing
183
+ dirHasFile,
184
+ classifyWritableFailure,
185
+ };