opencode-token-tracker 1.6.2 → 1.6.3

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,74 @@ function cmdHelp() {
901
899
  opencode-tokens config get toast.enabled # Check if toast is enabled
902
900
  `);
903
901
  }
904
- // ============================================================================
905
- // Trend
906
- // ============================================================================
902
+ function getTrendValue(point, metric) {
903
+ return metric === "tokens" ? point.tokens : metric === "messages" ? point.messages : point.cost;
904
+ }
905
+ function formatTrendValue(value, metric) {
906
+ return metric === "tokens" ? formatTokens(value) : metric === "messages" ? String(Math.round(value)) : formatCost(value);
907
+ }
908
+ function metricLabel(metric) {
909
+ return metric === "tokens" ? "Token Trend" : metric === "messages" ? "Message Trend" : "Cost Trend";
910
+ }
911
+ function createTrendRows(height, width, gridRows) {
912
+ const rows = [];
913
+ for (let row = 0; row < height; row++) {
914
+ const grid = gridRows.has(row);
915
+ rows.push(Array.from({ length: width }, (_, x) => grid && x % 2 === 0 ? "·" : " "));
916
+ }
917
+ return rows;
918
+ }
919
+ function drawTrendSegment(rows, from, to) {
920
+ const startX = Math.min(from.x, to.x);
921
+ const endX = Math.max(from.x, to.x);
922
+ const slope = to.y - from.y;
923
+ for (let x = startX; x <= endX; x++) {
924
+ const t = x === from.x ? 0 : (x - from.x) / (to.x - from.x);
925
+ const y = Math.round(from.y + slope * t);
926
+ const char = slope > 0 ? "╱" : slope < 0 ? "╲" : "─";
927
+ rows[y][x] = char;
928
+ }
929
+ }
930
+ function drawTrendArea(rows, points) {
931
+ for (let i = 1; i < points.length; i++) {
932
+ const from = points[i - 1];
933
+ const to = points[i];
934
+ const startX = Math.min(from.x, to.x);
935
+ const endX = Math.max(from.x, to.x);
936
+ for (let x = startX; x <= endX; x++) {
937
+ const t = x === from.x ? 0 : (x - from.x) / (to.x - from.x);
938
+ const y = Math.round(from.y + (to.y - from.y) * t);
939
+ for (let fillY = 0; fillY < y; fillY++) {
940
+ if (rows[fillY][x] === " " || rows[fillY][x] === "·") {
941
+ rows[fillY][x] = "░";
942
+ }
943
+ }
944
+ }
945
+ }
946
+ }
947
+ function drawTrendPoints(rows, points) {
948
+ for (const point of points) {
949
+ rows[point.y][point.x] = "●";
950
+ }
951
+ }
952
+ function buildTrendXAxis(points, chartPoints, chartWidth) {
953
+ const labelChars = Array.from({ length: chartWidth }, () => " ");
954
+ const labelStep = Math.max(1, Math.ceil(points.length / 6));
955
+ for (let i = 0; i < chartPoints.length; i++) {
956
+ if (i % labelStep !== 0 && i !== chartPoints.length - 1)
957
+ continue;
958
+ const date = new Date(points[i][0]);
959
+ const label = `${date.getMonth() + 1}/${date.getDate()}`;
960
+ const start = Math.min(Math.max(0, chartPoints[i].x - Math.floor(label.length / 2)), Math.max(0, chartWidth - label.length));
961
+ const hasSpace = labelChars.slice(Math.max(0, start - 1), Math.min(chartWidth, start + label.length + 1)).every((c) => c === " ");
962
+ if (!hasSpace)
963
+ continue;
964
+ for (let j = 0; j < label.length; j++) {
965
+ labelChars[start + j] = label[j];
966
+ }
967
+ }
968
+ return labelChars.join("");
969
+ }
907
970
  function cmdTrend(flags) {
908
971
  const days = parseInt(String(flagValue(flags, "days") ?? "30"), 10);
909
972
  const metric = flagValue(flags, "metric") ?? "cost";
@@ -914,14 +977,14 @@ function cmdTrend(flags) {
914
977
  console.log(`\n (no data in period)\n`);
915
978
  return;
916
979
  }
917
- // Aggregate by day
918
980
  const dayMap = new Map();
919
981
  for (const e of entries) {
920
982
  const dayStart = getStartOfDay(new Date(e._ts));
921
- if (!dayMap.has(dayStart)) {
922
- dayMap.set(dayStart, { cost: 0, tokens: 0, messages: 0 });
983
+ let d = dayMap.get(dayStart);
984
+ if (!d) {
985
+ d = { cost: 0, tokens: 0, messages: 0 };
986
+ dayMap.set(dayStart, d);
923
987
  }
924
- const d = dayMap.get(dayStart);
925
988
  d.cost += e.cost ?? 0;
926
989
  d.tokens += (e.input ?? 0) + (e.output ?? 0) + (e.reasoning ?? 0);
927
990
  d.messages += 1;
@@ -930,116 +993,60 @@ function cmdTrend(flags) {
930
993
  if (sorted.length < 2) {
931
994
  const only = sorted[0];
932
995
  if (only) {
933
- const v = metric === "tokens" ? formatTokens(only[1].tokens) : metric === "messages" ? String(only[1].messages) : formatCost(only[1].cost);
996
+ const v = formatTrendValue(getTrendValue(only[1], metric), metric);
934
997
  console.log(`\n ${new Date(only[0]).toISOString().slice(0, 10)}: ${v}\n`);
935
998
  }
936
999
  return;
937
1000
  }
938
- const values = sorted.map(([, d]) => metric === "tokens" ? d.tokens : metric === "messages" ? d.messages : d.cost);
1001
+ const values = sorted.map(([, d]) => getTrendValue(d, metric));
939
1002
  const maxVal = Math.max(...values, 1);
1003
+ const minVal = Math.min(...values);
1004
+ const totalVal = values.reduce((sum, value) => sum + value, 0);
1005
+ const avgVal = totalVal / values.length;
1006
+ const deltaVal = values[values.length - 1] - values[0];
940
1007
  const H = Math.max(5, Math.min(Math.floor(width / 3), 20));
941
1008
  if (width < 35) {
942
1009
  // Fallback: simple sparkline
943
1010
  const chars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
944
1011
  const spark = values.map(v => chars[Math.min(Math.floor((v / maxVal) * 7), 7)]).join("");
945
- console.log(`\n ${spark}\n`);
1012
+ console.log(`\n ${metricLabel(metric)} ${spark}\n`);
946
1013
  return;
947
1014
  }
948
- // Build chart — pixel-based rendering
949
- const cols = values.map((v) => ({ value: v, y: Math.round((v / maxVal) * (H - 1)) }));
950
1015
  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
1016
  const yLabelStep = Math.max(1, Math.floor(H / 5));
1017
+ const gridRows = new Set();
1018
+ for (let row = 0; row < H; row++) {
1019
+ if (row === 0 || row === H - 1 || (H - 1 - row) % yLabelStep === 0) {
1020
+ gridRows.add(row);
1021
+ }
1022
+ }
1023
+ const chartPoints = values.map((value, i) => ({
1024
+ x: values.length === 1 ? Math.floor(chartWidth / 2) : Math.round((i / (values.length - 1)) * (chartWidth - 1)),
1025
+ y: Math.round((value / maxVal) * (H - 1)),
1026
+ }));
1027
+ const rows = createTrendRows(H, chartWidth, gridRows);
1028
+ drawTrendArea(rows, chartPoints);
1029
+ for (let i = 1; i < chartPoints.length; i++) {
1030
+ drawTrendSegment(rows, chartPoints[i - 1], chartPoints[i]);
1031
+ }
1032
+ drawTrendPoints(rows, chartPoints);
959
1033
  const lines = [];
1034
+ lines.push(`${metricLabel(metric)} · ${sorted.length} days · peak ${formatTrendValue(maxVal, metric)} · avg ${formatTrendValue(avgVal, metric)} · Δ ${deltaVal >= 0 ? "+" : ""}${formatTrendValue(deltaVal, metric)}`);
1035
+ lines.push(`range ${formatTrendValue(minVal, metric)} → ${formatTrendValue(maxVal, metric)}`);
960
1036
  for (let row = H - 1; row >= 0; row--) {
961
- let line = "";
962
1037
  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)
1038
+ const label = gridRows.has(row)
1039
+ ? formatTrendValue(valAtRow, metric)
965
1040
  : "";
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
- }
1041
+ const line = `${padLeft(label, 9)}${row === 0 ? " ┼" : " ┤"}${rows[row].join("")}`;
1020
1042
  lines.push(line);
1021
1043
  }
1022
- // Bottom axis
1023
- let axis = " ".repeat(9) + " └";
1024
- axis += "─".repeat(chartWidth);
1044
+ const axis = `${" ".repeat(9)} └${"─".repeat(chartWidth)}`;
1025
1045
  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);
1046
+ lines.push(`${" ".repeat(11)}${buildTrendXAxis(sorted, chartPoints, chartWidth)}`);
1040
1047
  console.log();
1041
1048
  for (const l of lines)
1042
- console.log(" " + l);
1049
+ console.log(` ${l}`);
1043
1050
  console.log();
1044
1051
  }
1045
1052
  // ============================================================================
@@ -1075,8 +1082,7 @@ function main() {
1075
1082
  }
1076
1083
  // Default: stats
1077
1084
  let period = "all";
1078
- let breakdown;
1079
- breakdown = flagValue(parsed.flags, "by") || (parsed.flags.has("b") ? String(parsed.flags.get("b")) : undefined);
1085
+ const breakdown = flagValue(parsed.flags, "by") || (parsed.flags.has("b") ? String(parsed.flags.get("b")) : undefined);
1080
1086
  for (const p of ["today", "week", "month", "all"]) {
1081
1087
  if (parsed.positional.includes(p)) {
1082
1088
  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.3",
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",