opencode-token-tracker 1.6.2 → 1.6.4

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.
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "../lib/shared.js";
3
- import { readFileSync, existsSync, writeFileSync, copyFileSync, mkdirSync, openSync, readSync, closeSync, statSync } from "fs";
4
- import { join } from "path";
5
- import { homedir } from "os";
2
+ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfMonth, getStartOfWeek, validateConfig } from "../lib/shared.js";
6
6
  const CONFIG_DIR = join(homedir(), ".config", "opencode");
7
7
  const CONFIG_FILE = join(CONFIG_DIR, "token-tracker.json");
8
8
  const LOG_FILE = join(CONFIG_DIR, "logs", "token-tracker", "tokens.jsonl");
@@ -10,15 +10,15 @@ const LOG_FILE = join(CONFIG_DIR, "logs", "token-tracker", "tokens.jsonl");
10
10
  // Helpers
11
11
  // ============================================================================
12
12
  function padRight(str, len) {
13
- return str.length >= len ? str : str + " ".repeat(len - str.length);
13
+ return str.length >= len ? str : `${str}${" ".repeat(len - str.length)}`;
14
14
  }
15
15
  function padLeft(str, len) {
16
- return str.length >= len ? str : " ".repeat(len - str.length) + str;
16
+ return str.length >= len ? str : `${" ".repeat(len - str.length)}${str}`;
17
17
  }
18
18
  function truncateSessionId(sessionId) {
19
19
  if (!sessionId)
20
20
  return "unknown";
21
- return sessionId.length > 16 ? sessionId.slice(0, 14) + "…" : sessionId;
21
+ return sessionId.length > 16 ? `${sessionId.slice(0, 14)}…` : sessionId;
22
22
  }
23
23
  function parseArgs(args) {
24
24
  const positional = [];
@@ -70,9 +70,6 @@ function flagValue(flags, name) {
70
70
  const v = flags.get(name);
71
71
  return typeof v === "string" ? v : undefined;
72
72
  }
73
- function flagBool(flags, name) {
74
- return flags.has(name);
75
- }
76
73
  // ============================================================================
77
74
  // Data Loading
78
75
  // ============================================================================
@@ -178,10 +175,11 @@ function groupBy(entries, keyFn) {
178
175
  const groups = new Map();
179
176
  for (const e of entries) {
180
177
  const key = keyFn(e);
181
- if (!groups.has(key)) {
182
- groups.set(key, createEmptyStats());
178
+ let stats = groups.get(key);
179
+ if (!stats) {
180
+ stats = createEmptyStats();
181
+ groups.set(key, stats);
183
182
  }
184
- const stats = groups.get(key);
185
183
  stats.input += e.input ?? 0;
186
184
  stats.output += e.output ?? 0;
187
185
  stats.reasoning += e.reasoning ?? 0;
@@ -277,7 +275,6 @@ function cmdStats(period, breakdown) {
277
275
  since = getStartOfMonth(now);
278
276
  title = "This Month's Usage";
279
277
  break;
280
- case "all":
281
278
  default:
282
279
  since = undefined;
283
280
  title = "All-Time Usage";
@@ -337,15 +334,15 @@ function cmdPricing() {
337
334
  const p = BUILTIN_PRICING[model];
338
335
  if (!p)
339
336
  continue;
340
- const overridden = config.models?.[model] ? " *" : "";
341
- console.log(` ${padRight(model + overridden, modelWidth)} ${padLeft("$" + p.input.toString(), priceWidth)} ${padLeft("$" + p.output.toString(), priceWidth)} ${padLeft(p.cacheRead ? "$" + p.cacheRead.toString() : "-", priceWidth)} ${padLeft(p.cacheWrite ? "$" + p.cacheWrite.toString() : "-", priceWidth)}`);
337
+ const overridden = config.models[model] ? " *" : "";
338
+ console.log(` ${padRight(`${model}${overridden}`, modelWidth)} ${padLeft(`$${p.input.toString()}`, priceWidth)} ${padLeft(`$${p.output.toString()}`, priceWidth)} ${padLeft(p.cacheRead ? `$${p.cacheRead.toString()}` : "-", priceWidth)} ${padLeft(p.cacheWrite ? `$${p.cacheWrite.toString()}` : "-", priceWidth)}`);
342
339
  }
343
340
  console.log();
344
341
  }
345
342
  console.log(` Default (unknown models)`);
346
343
  console.log(` ${"-".repeat(modelWidth + priceWidth * 4 + 12)}`);
347
- const def = BUILTIN_PRICING["_default"];
348
- console.log(` ${padRight("_default", modelWidth)} ${padLeft("$" + def.input.toString(), priceWidth)} ${padLeft("$" + def.output.toString(), priceWidth)} ${padLeft("-", priceWidth)} ${padLeft("-", priceWidth)}`);
344
+ const def = BUILTIN_PRICING._default;
345
+ console.log(` ${padRight("_default", modelWidth)} ${padLeft(`$${def.input.toString()}`, priceWidth)} ${padLeft(`$${def.output.toString()}`, priceWidth)} ${padLeft("-", priceWidth)} ${padLeft("-", priceWidth)}`);
349
346
  console.log();
350
347
  if (Object.keys(config.models || {}).length > 0) {
351
348
  console.log(` * = overridden in config`);
@@ -363,10 +360,11 @@ function cmdModels() {
363
360
  const model = e.model ?? "unknown";
364
361
  const provider = e.provider ?? "unknown";
365
362
  const key = `${model}|${provider}`;
366
- if (!modelProviders.has(key)) {
367
- modelProviders.set(key, { provider, count: 0, lastUsed: 0 });
363
+ let info = modelProviders.get(key);
364
+ if (!info) {
365
+ info = { provider, count: 0, lastUsed: 0 };
366
+ modelProviders.set(key, info);
368
367
  }
369
- const info = modelProviders.get(key);
370
368
  info.count++;
371
369
  info.lastUsed = Math.max(info.lastUsed, e._ts);
372
370
  }
@@ -569,7 +567,7 @@ function cmdConfig(positional) {
569
567
  return;
570
568
  }
571
569
  }
572
- applyConfigSet(key, value, config);
570
+ applyConfigSet(key, value);
573
571
  console.log(`\n Set ${key} = ${JSON.stringify(value)}\n`);
574
572
  return;
575
573
  }
@@ -584,7 +582,7 @@ function cmdConfig(positional) {
584
582
  console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
585
583
  return;
586
584
  }
587
- applyConfigUnset(key, config);
585
+ applyConfigUnset(key);
588
586
  console.log(`\n Unset ${key} (reverted to default)\n`);
589
587
  return;
590
588
  }
@@ -604,7 +602,7 @@ function cmdConfig(positional) {
604
602
  if (existsSync(CONFIG_FILE)) {
605
603
  console.log(` Contents:`);
606
604
  console.log(` ${"-".repeat(60)}`);
607
- console.log(JSON.stringify(config, null, 2).split("\n").map(l => " " + l).join("\n"));
605
+ console.log(JSON.stringify(config, null, 2).split("\n").map(l => ` ${l}`).join("\n"));
608
606
  console.log();
609
607
  }
610
608
  console.log(` Commands:`);
@@ -648,7 +646,7 @@ function resolveConfigKey(config, key) {
648
646
  }
649
647
  return obj[spec.path[spec.path.length - 1]] ?? spec.default;
650
648
  }
651
- function applyConfigSet(key, value, config) {
649
+ function applyConfigSet(key, value) {
652
650
  const spec = SETTABLE_KEYS[key];
653
651
  const fullConfig = loadOrInitConfig();
654
652
  let obj = fullConfig;
@@ -660,7 +658,7 @@ function applyConfigSet(key, value, config) {
660
658
  obj[spec.path[spec.path.length - 1]] = value;
661
659
  saveConfig(fullConfig);
662
660
  }
663
- function applyConfigUnset(key, config) {
661
+ function applyConfigUnset(key) {
664
662
  const spec = SETTABLE_KEYS[key];
665
663
  const fullConfig = loadOrInitConfig();
666
664
  let obj = fullConfig;
@@ -687,9 +685,9 @@ function saveConfig(raw) {
687
685
  mkdirSync(dir, { recursive: true });
688
686
  }
689
687
  if (existsSync(CONFIG_FILE)) {
690
- copyFileSync(CONFIG_FILE, CONFIG_FILE + ".bak");
688
+ copyFileSync(CONFIG_FILE, `${CONFIG_FILE}.bak`);
691
689
  }
692
- writeFileSync(CONFIG_FILE, JSON.stringify(raw, null, 2) + "\n");
690
+ writeFileSync(CONFIG_FILE, `${JSON.stringify(raw, null, 2)}\n`);
693
691
  }
694
692
  // ============================================================================
695
693
  // Export
@@ -738,7 +736,7 @@ function cmdExport(flags) {
738
736
  e.cacheWrite ?? 0,
739
737
  e.cost ?? 0,
740
738
  ].map(csvEscape).join(","));
741
- output = [headers.join(","), ...rows].join("\n") + "\n";
739
+ output = `${[headers.join(","), ...rows].join("\n")}\n`;
742
740
  }
743
741
  if (outputFile) {
744
742
  writeFileSync(outputFile, output);
@@ -901,9 +899,58 @@ function cmdHelp() {
901
899
  opencode-tokens config get toast.enabled # Check if toast is enabled
902
900
  `);
903
901
  }
904
- // ============================================================================
905
- // Trend
906
- // ============================================================================
902
+ const BRAILLE_DOTS = [0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80];
903
+ function getTrendValue(point, metric) {
904
+ return metric === "tokens" ? point.tokens : metric === "messages" ? point.messages : point.cost;
905
+ }
906
+ function formatTrendValue(value, metric) {
907
+ return metric === "tokens" ? formatTokens(value) : metric === "messages" ? String(Math.round(value)) : formatCost(value);
908
+ }
909
+ function formatSignedTrendValue(value, metric) {
910
+ const sign = value > 0 ? "+" : value < 0 ? "-" : "";
911
+ return `${sign}${formatTrendValue(Math.abs(value), metric)}`;
912
+ }
913
+ function metricLabel(metric) {
914
+ return metric === "tokens" ? "Token Trend" : metric === "messages" ? "Message Trend" : "Cost Trend";
915
+ }
916
+ function addBraillePoint(cells, x, y) {
917
+ const cellX = Math.floor(x / 2);
918
+ const cellY = Math.floor(y / 4);
919
+ const dotX = x % 2;
920
+ const dotY = y % 4;
921
+ cells[cellY][cellX] |= BRAILLE_DOTS[dotX * 4 + dotY];
922
+ }
923
+ function buildBrailleRows(points, width, height) {
924
+ const cells = Array.from({ length: height }, () => Array.from({ length: width }, () => 0));
925
+ for (let i = 1; i < points.length; i++) {
926
+ const from = points[i - 1];
927
+ const to = points[i];
928
+ const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y), 1);
929
+ for (let step = 0; step <= steps; step++) {
930
+ const t = step / steps;
931
+ addBraillePoint(cells, Math.round(from.x + (to.x - from.x) * t), Math.round(from.y + (to.y - from.y) * t));
932
+ }
933
+ }
934
+ return cells.map(row => row.map(mask => mask === 0 ? " " : String.fromCharCode(0x2800 + mask)).join(""));
935
+ }
936
+ function buildTrendXAxis(points, chartPoints, chartWidth) {
937
+ const labelChars = Array.from({ length: chartWidth }, () => " ");
938
+ const labelStep = Math.max(1, Math.ceil(points.length / 6));
939
+ for (let i = 0; i < chartPoints.length; i++) {
940
+ if (i % labelStep !== 0 && i !== chartPoints.length - 1)
941
+ continue;
942
+ const date = new Date(points[i][0]);
943
+ const label = `${date.getMonth() + 1}/${date.getDate()}`;
944
+ const start = Math.min(Math.max(0, chartPoints[i].x - Math.floor(label.length / 2)), Math.max(0, chartWidth - label.length));
945
+ const hasSpace = labelChars.slice(Math.max(0, start - 1), Math.min(chartWidth, start + label.length + 1)).every((c) => c === " ");
946
+ if (!hasSpace)
947
+ continue;
948
+ for (let j = 0; j < label.length; j++) {
949
+ labelChars[start + j] = label[j];
950
+ }
951
+ }
952
+ return labelChars.join("");
953
+ }
907
954
  function cmdTrend(flags) {
908
955
  const days = parseInt(String(flagValue(flags, "days") ?? "30"), 10);
909
956
  const metric = flagValue(flags, "metric") ?? "cost";
@@ -914,14 +961,14 @@ function cmdTrend(flags) {
914
961
  console.log(`\n (no data in period)\n`);
915
962
  return;
916
963
  }
917
- // Aggregate by day
918
964
  const dayMap = new Map();
919
965
  for (const e of entries) {
920
966
  const dayStart = getStartOfDay(new Date(e._ts));
921
- if (!dayMap.has(dayStart)) {
922
- dayMap.set(dayStart, { cost: 0, tokens: 0, messages: 0 });
967
+ let d = dayMap.get(dayStart);
968
+ if (!d) {
969
+ d = { cost: 0, tokens: 0, messages: 0 };
970
+ dayMap.set(dayStart, d);
923
971
  }
924
- const d = dayMap.get(dayStart);
925
972
  d.cost += e.cost ?? 0;
926
973
  d.tokens += (e.input ?? 0) + (e.output ?? 0) + (e.reasoning ?? 0);
927
974
  d.messages += 1;
@@ -930,116 +977,53 @@ function cmdTrend(flags) {
930
977
  if (sorted.length < 2) {
931
978
  const only = sorted[0];
932
979
  if (only) {
933
- const v = metric === "tokens" ? formatTokens(only[1].tokens) : metric === "messages" ? String(only[1].messages) : formatCost(only[1].cost);
980
+ const v = formatTrendValue(getTrendValue(only[1], metric), metric);
934
981
  console.log(`\n ${new Date(only[0]).toISOString().slice(0, 10)}: ${v}\n`);
935
982
  }
936
983
  return;
937
984
  }
938
- const values = sorted.map(([, d]) => metric === "tokens" ? d.tokens : metric === "messages" ? d.messages : d.cost);
985
+ const values = sorted.map(([, d]) => getTrendValue(d, metric));
939
986
  const maxVal = Math.max(...values, 1);
940
- const H = Math.max(5, Math.min(Math.floor(width / 3), 20));
987
+ const minVal = Math.min(...values);
988
+ const totalVal = values.reduce((sum, value) => sum + value, 0);
989
+ const avgVal = totalVal / values.length;
990
+ const deltaVal = values[values.length - 1] - values[0];
991
+ const chartHeight = sorted.length <= 3 ? 6 : Math.max(6, Math.min(Math.floor(width / 4), 12));
941
992
  if (width < 35) {
942
993
  // Fallback: simple sparkline
943
994
  const chars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
944
995
  const spark = values.map(v => chars[Math.min(Math.floor((v / maxVal) * 7), 7)]).join("");
945
- console.log(`\n ${spark}\n`);
996
+ console.log(`\n ${metricLabel(metric)} ${spark}\n`);
946
997
  return;
947
998
  }
948
- // Build chart — pixel-based rendering
949
- const cols = values.map((v) => ({ value: v, y: Math.round((v / maxVal) * (H - 1)) }));
950
999
  const chartWidth = Math.max(width - 12, 20);
951
- // Map data points to pixel x-positions (evenly spaced across chartWidth)
952
- const px = [];
953
- const py = [];
954
- for (let i = 0; i < cols.length; i++) {
955
- px.push(cols.length === 1 ? Math.floor(chartWidth / 2) : Math.round((i / (cols.length - 1)) * (chartWidth - 1)));
956
- py.push(cols[i].y);
957
- }
958
- const yLabelStep = Math.max(1, Math.floor(H / 5));
1000
+ const yLabelStep = Math.max(1, Math.floor(chartHeight / 4));
1001
+ const dotWidth = chartWidth * 2;
1002
+ const dotHeight = chartHeight * 4;
1003
+ const chartPoints = values.map((value, i) => ({
1004
+ x: values.length === 1 ? Math.floor(dotWidth / 2) : Math.round((i / (values.length - 1)) * (dotWidth - 1)),
1005
+ y: Math.max(0, dotHeight - 1 - Math.round((value / maxVal) * (dotHeight - 1))),
1006
+ }));
1007
+ const rows = buildBrailleRows(chartPoints, chartWidth, chartHeight);
1008
+ const labelPoints = chartPoints.map(point => ({ x: Math.floor(point.x / 2), y: point.y }));
959
1009
  const lines = [];
960
- for (let row = H - 1; row >= 0; row--) {
961
- let line = "";
962
- const valAtRow = (row / (H - 1)) * maxVal;
963
- const label = row === H - 1 || row === 0 || (H - 1 - row) % yLabelStep === 0
964
- ? metric === "tokens" ? formatTokens(valAtRow) : metric === "messages" ? String(Math.round(valAtRow)) : formatCost(valAtRow)
1010
+ lines.push(`${metricLabel(metric)} · ${sorted.length} days · peak ${formatTrendValue(maxVal, metric)} · avg ${formatTrendValue(avgVal, metric)} · Δ ${formatSignedTrendValue(deltaVal, metric)}`);
1011
+ lines.push(`range ${formatTrendValue(minVal, metric)} → ${formatTrendValue(maxVal, metric)}`);
1012
+ for (let row = 0; row < chartHeight; row++) {
1013
+ const valueRatio = 1 - row / (chartHeight - 1);
1014
+ const valAtRow = valueRatio * maxVal;
1015
+ const label = row === 0 || row === chartHeight - 1 || row % yLabelStep === 0
1016
+ ? formatTrendValue(valAtRow, metric)
965
1017
  : "";
966
- line += padLeft(label, 9);
967
- line += row === 0 ? " ┼" : " ┤";
968
- // Build a sparse array of characters at specific x-positions for this row
969
- const chars = [];
970
- // Data points that land on this row
971
- for (let i = 0; i < px.length; i++) {
972
- if (py[i] === row) {
973
- if (cols.length === 1) {
974
- chars.push({ x: px[i], c: "─" });
975
- }
976
- else {
977
- const prevSlope = i > 0 ? py[i] - py[i - 1] : 0;
978
- const nextSlope = i < px.length - 1 ? py[i + 1] - py[i] : 0;
979
- let c = "─";
980
- if (i === 0)
981
- c = nextSlope > 0 ? "╭" : nextSlope < 0 ? "╰" : "─";
982
- else if (i === px.length - 1)
983
- c = prevSlope > 0 ? "╮" : prevSlope < 0 ? "╯" : "─";
984
- else if (prevSlope > 0 && nextSlope > 0)
985
- c = "╭";
986
- else if (prevSlope < 0 && nextSlope < 0)
987
- c = "╰";
988
- else if (prevSlope > 0 && nextSlope < 0)
989
- c = "╮";
990
- else if (prevSlope < 0 && nextSlope > 0)
991
- c = "╯";
992
- chars.push({ x: px[i], c });
993
- }
994
- }
995
- }
996
- // Line segments crossing this row (exact x of intersection)
997
- for (let i = 1; i < px.length; i++) {
998
- const y0 = py[i - 1], y1 = py[i];
999
- // Skip if segment doesn't cross this row
1000
- if ((y0 <= row && y1 <= row) || (y0 >= row && y1 >= row))
1001
- continue;
1002
- if (y0 === y1)
1003
- continue;
1004
- const t = (row - y0) / (y1 - y0);
1005
- const cx = Math.round(px[i - 1] + t * (px[i] - px[i - 1]));
1006
- const slope = y1 - y0;
1007
- chars.push({ x: cx, c: slope > 0 ? "╱" : "╲" });
1008
- }
1009
- // Render the row: sort chars by x and fill gaps with spaces
1010
- chars.sort((a, b) => a.x - b.x);
1011
- let prevX = 0;
1012
- for (const { x, c } of chars) {
1013
- while (prevX < x) {
1014
- line += " ";
1015
- prevX++;
1016
- }
1017
- line += c;
1018
- prevX = x + 1;
1019
- }
1018
+ const line = `${padLeft(label, 9)} ┤${rows[row]}`;
1020
1019
  lines.push(line);
1021
1020
  }
1022
- // Bottom axis
1023
- let axis = " ".repeat(9) + " └";
1024
- axis += "─".repeat(chartWidth);
1021
+ const axis = `${" ".repeat(9)} └${"─".repeat(chartWidth)}`;
1025
1022
  lines.push(axis);
1026
- // X axis labels
1027
- const labelStep = Math.max(1, Math.ceil(sorted.length / 6));
1028
- let xLabels = " ".repeat(11);
1029
- for (let i = 0; i < px.length; i++) {
1030
- if (i % labelStep === 0 || i === px.length - 1) {
1031
- const d = new Date(sorted[i][0]);
1032
- const ds = `${d.getMonth() + 1}/${d.getDate()}`;
1033
- const pos = px[i] + 0;
1034
- while (xLabels.length - 11 < pos)
1035
- xLabels += " ";
1036
- xLabels += ds;
1037
- }
1038
- }
1039
- lines.push(xLabels);
1023
+ lines.push(`${" ".repeat(11)}${buildTrendXAxis(sorted, labelPoints, chartWidth)}`);
1040
1024
  console.log();
1041
1025
  for (const l of lines)
1042
- console.log(" " + l);
1026
+ console.log(` ${l}`);
1043
1027
  console.log();
1044
1028
  }
1045
1029
  // ============================================================================
@@ -1075,8 +1059,7 @@ function main() {
1075
1059
  }
1076
1060
  // Default: stats
1077
1061
  let period = "all";
1078
- let breakdown;
1079
- breakdown = flagValue(parsed.flags, "by") || (parsed.flags.has("b") ? String(parsed.flags.get("b")) : undefined);
1062
+ const breakdown = flagValue(parsed.flags, "by") || (parsed.flags.has("b") ? String(parsed.flags.get("b")) : undefined);
1080
1063
  for (const p of ["today", "week", "month", "all"]) {
1081
1064
  if (parsed.positional.includes(p)) {
1082
1065
  period = p;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-token-tracker",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
4
4
  "description": "Real-time token usage and cost tracking plugin for OpenCode with Toast notifications and CLI stats",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",