opencode-token-tracker 1.7.0 → 1.8.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.
package/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
- import { DEFAULT_CONFIG, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig, evaluateBudgetStatus, calculateCost } from "./lib/shared.js";
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, openSync, readSync, closeSync } from "fs";
1
+ import { DEFAULT_CONFIG, aggregateRootSession, buildMessageToast, buildSessionRecord, calculateCost, evaluateBudgetStatus, formatCost, formatTokens, getStartOfDay, getStartOfMonth, getStartOfWeek, hasBillableTokenUsage, validateConfig, } from "./lib/shared.js";
2
+ import { appendFileSync, createReadStream, existsSync, mkdirSync, readFileSync, statSync, openSync, readSync, closeSync } from "fs";
3
3
  import { open } from "fs/promises";
4
4
  import { join } from "path";
5
5
  import { homedir } from "os";
6
+ import { createInterface } from "node:readline";
6
7
  const CONFIG_DIR = join(homedir(), ".config", "opencode");
7
8
  const CONFIG_FILE = join(CONFIG_DIR, "token-tracker.json");
8
9
  const LOG_DIR = join(CONFIG_DIR, "logs", "token-tracker");
9
10
  const LOG_FILE = join(LOG_DIR, "tokens.jsonl");
11
+ const SESSIONS_LOG_FILE = join(LOG_DIR, "sessions.jsonl");
10
12
  // ============================================================================
11
13
  // Configuration
12
14
  // ============================================================================
@@ -51,6 +53,8 @@ function ensureLatestConfig() {
51
53
  }
52
54
  }
53
55
  const sessionStats = new Map();
56
+ // 从侧车日志和实时会话事件学习父子关系,展示时归并至顶层任务。
57
+ const parentOf = new Map();
54
58
  function getOrCreateSessionStats(sessionId) {
55
59
  if (!sessionStats.has(sessionId)) {
56
60
  sessionStats.set(sessionId, {
@@ -66,6 +70,31 @@ function getOrCreateSessionStats(sessionId) {
66
70
  }
67
71
  return sessionStats.get(sessionId);
68
72
  }
73
+ function rememberSessionParent(info) {
74
+ // 仅标题更新不应抹去已有关系,与 CLI 的元数据合并口径一致。
75
+ if (!info.id || !info.parentID)
76
+ return;
77
+ parentOf.set(info.id, info.parentID);
78
+ }
79
+ async function restoreSessionParents() {
80
+ try {
81
+ const lines = createInterface({ input: createReadStream(SESSIONS_LOG_FILE), crlfDelay: Infinity });
82
+ for await (const line of lines) {
83
+ try {
84
+ const record = JSON.parse(line);
85
+ if (record?.type !== "session" || typeof record.sessionId !== "string" || typeof record.parentID !== "string")
86
+ continue;
87
+ rememberSessionParent({ id: record.sessionId, parentID: record.parentID });
88
+ }
89
+ catch {
90
+ // 跳过损坏行,后续有效记录仍可恢复关系。
91
+ }
92
+ }
93
+ }
94
+ catch {
95
+ // 无历史元数据或读取失败时,继续通过实时会话事件学习关系。
96
+ }
97
+ }
69
98
  // ============================================================================
70
99
  // Deduplication
71
100
  // ============================================================================
@@ -89,11 +118,32 @@ function ensureLogDir() {
89
118
  mkdirSync(LOG_DIR, { recursive: true });
90
119
  }
91
120
  }
92
- function logJson(data) {
121
+ function logJson(data, recordedAt) {
93
122
  ensureLogDir();
94
- const entry = JSON.stringify({ ...data, _ts: Date.now() }) + "\n";
123
+ const entry = JSON.stringify({ ...data, _ts: recordedAt }) + "\n";
95
124
  appendFileSync(LOG_FILE, entry);
96
125
  }
126
+ // Append-only sidecar of session metadata (id/title/parentID/directory) used
127
+ // by the CLI to label `--by session` rows and roll child sessions up to their
128
+ // parent. Per-process dedup keeps writes to actual title/parent changes only.
129
+ const seenSessions = new Map();
130
+ function logSessionMeta(info) {
131
+ const record = buildSessionRecord(info);
132
+ if (!record)
133
+ return;
134
+ const signature = `${record.title ?? ""}|${record.parentID ?? ""}|${record.directory ?? ""}`;
135
+ if (seenSessions.get(record.sessionId) === signature)
136
+ return;
137
+ seenSessions.set(record.sessionId, signature);
138
+ // Bound memory the same way the message dedup set does.
139
+ if (seenSessions.size > 10000) {
140
+ const stale = Array.from(seenSessions.keys()).slice(0, 5000);
141
+ for (const key of stale)
142
+ seenSessions.delete(key);
143
+ }
144
+ ensureLogDir();
145
+ appendFileSync(SESSIONS_LOG_FILE, JSON.stringify({ type: "session", ...record, _ts: Date.now() }) + "\n");
146
+ }
97
147
  const budgetTracker = {
98
148
  dailySpent: 0,
99
149
  weeklySpent: 0,
@@ -272,17 +322,14 @@ async function initBudgetTracker() {
272
322
  }
273
323
  budgetTracker.initialized = true;
274
324
  }
275
- /**
276
- * Accumulate cost into budgetTracker after a new token entry is logged.
277
- */
278
- function accumulateBudget(cost) {
325
+ // 在写入当前消息之前切换周期,避免重载结果已经包含当前消耗。
326
+ function refreshBudgetPeriods(recordedAt) {
279
327
  if (!budgetTracker.initialized)
280
328
  return;
281
- const now = new Date();
329
+ const now = new Date(recordedAt);
282
330
  const currentDayStart = getStartOfDay(now);
283
331
  const currentWeekStart = getStartOfWeek(now);
284
332
  const currentMonthStart = getStartOfMonth(now);
285
- // Period rollover detection — reset and reload from file for accuracy
286
333
  if (currentDayStart !== budgetTracker.dayStart) {
287
334
  budgetTracker.dayStart = currentDayStart;
288
335
  budgetTracker.dailySpent = loadCostsSince(currentDayStart);
@@ -295,6 +342,11 @@ function accumulateBudget(cost) {
295
342
  budgetTracker.monthStart = currentMonthStart;
296
343
  budgetTracker.monthlySpent = loadCostsSince(currentMonthStart);
297
344
  }
345
+ }
346
+ // 只在日志成功写入后累计;周期判断和日志使用同一个 recordedAt。
347
+ function accumulateBudget(cost) {
348
+ if (!budgetTracker.initialized)
349
+ return;
298
350
  budgetTracker.dailySpent += cost;
299
351
  budgetTracker.weeklySpent += cost;
300
352
  budgetTracker.monthlySpent += cost;
@@ -307,11 +359,6 @@ function checkBudgetStatus() {
307
359
  };
308
360
  return evaluateBudgetStatus(config.budget, snapshot, budgetTracker.initialized);
309
361
  }
310
- function formatBudgetMessage(status) {
311
- const pct = Math.round(status.percentage * 100);
312
- const periodLabel = status.period.charAt(0).toUpperCase() + status.period.slice(1);
313
- return `${periodLabel}: ${formatCost(status.spent)}/${formatCost(status.limit)} (${pct}%)`;
314
- }
315
362
  export const TokenTrackerPlugin = async ({ directory, client }) => {
316
363
  try {
317
364
  // Load config on plugin init (with validation)
@@ -322,6 +369,7 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
322
369
  }
323
370
  // Initialize in-memory budget tracker (reads JSONL once)
324
371
  await initBudgetTracker();
372
+ await restoreSessionParents();
325
373
  // 不再写 type:"init" 标记:OpenCode 会在多个子进程(LSP、工具 runner 等)独立加载
326
374
  // plugin,每次启动会向 JSONL 写多份重复的 init 行,污染日志且无计费价值。
327
375
  // 跨进程去重的根因修复留待后续版本。
@@ -366,8 +414,7 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
366
414
  const reasoning = info.tokens.reasoning ?? 0;
367
415
  const cacheRead = info.tokens.cache?.read ?? 0;
368
416
  const cacheWrite = info.tokens.cache?.write ?? 0;
369
- const hasTokens = input > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0;
370
- if (!hasTokens)
417
+ if (!hasBillableTokenUsage({ input, output, cacheRead, cacheWrite }))
371
418
  return;
372
419
  const dedupeKey = `${messageId}-${input}-${output}-${cacheRead}-${cacheWrite}`;
373
420
  if (isDuplicate(dedupeKey))
@@ -384,6 +431,8 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
384
431
  stats.totalCacheWrite += cacheWrite;
385
432
  stats.totalCost += cost;
386
433
  stats.messageCount += 1;
434
+ const recordedAt = Date.now();
435
+ refreshBudgetPeriods(recordedAt);
387
436
  // Log to file
388
437
  logJson({
389
438
  type: "tokens",
@@ -399,35 +448,28 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
399
448
  cacheRead,
400
449
  cacheWrite,
401
450
  cost,
402
- });
451
+ }, recordedAt);
403
452
  // Accumulate cost into in-memory budget tracker
404
453
  accumulateBudget(cost);
405
454
  // Show toast for this message
406
455
  if (config.toast.enabled) {
407
456
  const totalTokens = input + output;
457
+ // Roll sub-agent sessions up to their top-level session so
458
+ // `Session:` reflects the whole task, not just this agent's slice.
459
+ const rootStats = aggregateRootSession(sessionStats, parentOf, sessionId);
408
460
  // Check budget status
409
461
  const budgetStatus = checkBudgetStatus();
410
- let title = `${formatTokens(totalTokens)} tokens`;
411
- let message = `${formatCost(cost)} | Session: ${formatCost(stats.totalCost)}`;
412
- let variant = "info";
413
- // Add budget warning/alert if applicable
414
- if (budgetStatus) {
415
- if (budgetStatus.exceeded) {
416
- title = `⚠️ Budget exceeded!`;
417
- message = formatBudgetMessage(budgetStatus);
418
- variant = "error";
419
- }
420
- else if (budgetStatus.warning) {
421
- message = `${formatCost(cost)} | ${formatBudgetMessage(budgetStatus)}`;
422
- variant = "warning";
423
- }
424
- }
462
+ const toast = buildMessageToast({
463
+ messageTokens: totalTokens,
464
+ messageCost: cost,
465
+ sessionTokens: rootStats.totalInput + rootStats.totalOutput,
466
+ sessionCost: rootStats.totalCost,
467
+ budget: budgetStatus,
468
+ });
425
469
  try {
426
470
  await client.tui.showToast({
427
471
  body: {
428
- title,
429
- message,
430
- variant,
472
+ ...toast,
431
473
  duration: budgetStatus?.exceeded ? 5000 : config.toast.duration,
432
474
  },
433
475
  });
@@ -444,16 +486,17 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
444
486
  const sessionId = props?.sessionID;
445
487
  if (!sessionId)
446
488
  return;
447
- const stats = sessionStats.get(sessionId);
448
- if (!stats || stats.messageCount === 0)
489
+ // 主会话可能尚未产生消息,是否展示摘要应由整组消耗决定。
490
+ const rootStats = aggregateRootSession(sessionStats, parentOf, sessionId);
491
+ if (rootStats.messageCount === 0)
449
492
  return;
450
- const duration = Math.round((Date.now() - stats.startTime) / 1000 / 60);
451
- const totalTokens = stats.totalInput + stats.totalOutput;
493
+ const duration = Math.round((Date.now() - rootStats.startTime) / 1000 / 60);
494
+ const totalTokens = rootStats.totalInput + rootStats.totalOutput;
452
495
  try {
453
496
  await client.tui.showToast({
454
497
  body: {
455
498
  title: `Session: ${formatTokens(totalTokens)} tokens`,
456
- message: `${formatCost(stats.totalCost)} | ${stats.messageCount} msgs | ${duration}min`,
499
+ message: `${formatCost(rootStats.totalCost)} | ${rootStats.messageCount} msgs | ${duration}min`,
457
500
  variant: "info",
458
501
  duration: 5000,
459
502
  },
@@ -461,6 +504,16 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
461
504
  }
462
505
  catch { }
463
506
  }
507
+ // Capture session metadata (title/parentID) for the CLI session view
508
+ // and the in-memory parent map used to roll sub-agent usage up to the
509
+ // top-level session in toasts.
510
+ if (event.type === "session.created" || event.type === "session.updated") {
511
+ const props = event.properties;
512
+ if (props?.info) {
513
+ rememberSessionParent(props.info);
514
+ logSessionMeta(props.info);
515
+ }
516
+ }
464
517
  }
465
518
  catch { }
466
519
  },
@@ -4,15 +4,32 @@ export interface ModelPricing {
4
4
  cacheRead?: number;
5
5
  cacheWrite?: number;
6
6
  }
7
+ export interface BillableTokenUsage {
8
+ input?: number;
9
+ output?: number;
10
+ cacheRead?: number;
11
+ cacheWrite?: number;
12
+ }
7
13
  export interface ProviderModelPricingMap {
8
14
  [provider: string]: ModelPricing;
9
15
  }
10
16
  export declare const BUILTIN_PRICING_META: {
11
- readonly pricingLastUpdated: "2026-02-11";
12
- readonly metadataLastUpdated: "2026-05-24";
17
+ readonly pricingLastUpdated: "2026-10-05";
18
+ readonly metadataLastUpdated: "2026-10-05";
19
+ readonly baselineLastUpdated: "2026-05-29";
13
20
  readonly source: "Provider official pricing pages";
14
- readonly notes: "Manually maintained. Report stale prices: https://github.com/tongsh6/opencode-token-tracker/issues/new";
21
+ readonly notes: "Partial refresh: DeepSeek Flash/Pro and Kimi K2.7 Code only. Other entries retain the baseline audit date. Manually maintained; recent audits do not guarantee unchanged prices.";
15
22
  };
23
+ export interface BuiltinPricingAudit {
24
+ reviewedAt: string;
25
+ expiresAt?: string;
26
+ basis?: "peak";
27
+ exactOrPrefixed?: boolean;
28
+ sourceUrl?: string;
29
+ }
30
+ export type PricingFreshness = "recent" | "stale" | "expired" | "unknown";
31
+ export declare const PRICING_STALE_AFTER_DAYS = 90;
32
+ export declare const BUILTIN_PRICING_AUDITS: Record<string, BuiltinPricingAudit>;
16
33
  export declare const BUILTIN_PRICING: Record<string, ModelPricing>;
17
34
  export declare function formatCost(cost: number): string;
18
35
  /**
@@ -21,7 +38,9 @@ export declare function formatCost(cost: number): string;
21
38
  * @param millionDecimals - decimal places for M-level values (default 1)
22
39
  */
23
40
  export declare function formatTokens(tokens: number, millionDecimals?: number): string;
41
+ export declare function hasBillableTokenUsage(usage: BillableTokenUsage): boolean;
24
42
  export declare function getStartOfDay(date?: Date): number;
43
+ export declare function formatLocalDateKey(date: Date): string;
25
44
  export declare function getStartOfWeek(date?: Date): number;
26
45
  export declare function getStartOfMonth(date?: Date): number;
27
46
  export interface ToastConfig {
@@ -60,14 +79,102 @@ export interface BudgetStatus {
60
79
  exceeded: boolean;
61
80
  warning: boolean;
62
81
  }
82
+ export interface MessageToastInput {
83
+ messageTokens: number;
84
+ messageCost: number;
85
+ sessionTokens: number;
86
+ sessionCost: number;
87
+ budget: BudgetStatus | null;
88
+ }
89
+ export interface MessageToastBody {
90
+ title: string;
91
+ message: string;
92
+ variant: "info" | "warning" | "error";
93
+ }
94
+ export declare function formatBudgetMessage(status: BudgetStatus): string;
95
+ export declare function buildMessageToast(input: MessageToastInput): MessageToastBody;
63
96
  export interface BudgetSpentSnapshot {
64
97
  dailySpent: number;
65
98
  weeklySpent: number;
66
99
  monthlySpent: number;
67
100
  }
68
101
  export declare function evaluateBudgetStatus(budget: BudgetConfig, spent: BudgetSpentSnapshot, initialized: boolean): BudgetStatus | null;
102
+ export declare function getBuiltinPricingAudit(model: string): BuiltinPricingAudit | undefined;
103
+ export declare function getPricingFreshness(audit: BuiltinPricingAudit, now?: number): PricingFreshness;
104
+ export declare function getEffectivePricingAudit(config: TrackerConfig, model: string, provider: string): BuiltinPricingAudit | undefined;
69
105
  export declare function resolvePricingStatus(config: TrackerConfig, model: string, provider: string): "provider cfg" | "model cfg" | "built-in" | "default";
70
106
  export declare function round2(val: number): number;
71
107
  export type ProviderFamily = "anthropic" | "openai" | "deepseek" | "google" | "other";
72
108
  export declare function getProviderFamily(model: string, provider: string): ProviderFamily;
73
109
  export declare function calculateCost(model: string, provider: string, input: number, output: number, cacheRead?: number, cacheWrite?: number, config?: TrackerConfig): number;
110
+ export interface SessionMeta {
111
+ sessionId: string;
112
+ title?: string;
113
+ parentID?: string;
114
+ directory?: string;
115
+ _ts: number;
116
+ }
117
+ /** Shape of a raw line in sessions.jsonl (all fields optional/untrusted). */
118
+ export interface SessionMetaRecord {
119
+ sessionId?: string;
120
+ title?: string;
121
+ parentID?: string;
122
+ directory?: string;
123
+ _ts?: number;
124
+ }
125
+ /**
126
+ * Resolve a session id to its root by following parentID links.
127
+ * Stops when no parent is known or a cycle is detected, so an unknown
128
+ * parent still yields a stable root id to group descendants under.
129
+ */
130
+ export declare function resolveRootSession(sessionId: string, parentOf: Map<string, string | undefined>): string;
131
+ /** Per-session usage counters, summed across a root session's whole subtree. */
132
+ export interface SessionAggregate {
133
+ totalInput: number;
134
+ totalOutput: number;
135
+ totalReasoning: number;
136
+ totalCacheRead: number;
137
+ totalCacheWrite: number;
138
+ totalCost: number;
139
+ messageCount: number;
140
+ startTime: number;
141
+ }
142
+ /**
143
+ * Sum per-session stats across every session that resolves to the same root as
144
+ * `currentSessionId`, rolling a sub-agent session's usage up into its top-level
145
+ * (parent) session for toast / idle display.
146
+ *
147
+ * Aggregation happens at read time: a `parentID` learned only after a session's
148
+ * first message still merges on the next call (eventual consistency), so stored
149
+ * per-session stats never need re-bucketing. `startTime` is the earliest among
150
+ * the grouped sessions so duration reflects the whole task.
151
+ */
152
+ export declare function aggregateRootSession(sessionStats: ReadonlyMap<string, SessionAggregate>, parentOf: Map<string, string | undefined>, currentSessionId: string): SessionAggregate;
153
+ /**
154
+ * Merge raw sessions.jsonl records into one SessionMeta per session id.
155
+ * Later non-empty fields win; empty/whitespace titles never clobber a real
156
+ * one (session.created may arrive before a title is generated).
157
+ */
158
+ export declare function mergeSessionMeta(records: SessionMetaRecord[]): Map<string, SessionMeta>;
159
+ /** Truncate a label to maxWidth, appending an ellipsis when it overflows. */
160
+ export declare function truncateLabel(str: string, maxWidth: number): string;
161
+ /** Distinctive short code for a session id (its tail), for fallback display. */
162
+ export declare function shortSessionCode(sessionId?: string): string;
163
+ /** Pick a human label for a (root) session: title when known, else short code. */
164
+ export declare function sessionDisplayLabel(rootId: string, meta: SessionMeta | undefined, maxWidth: number): string;
165
+ /** Subset of an OpenCode Session event payload the tracker cares about. */
166
+ export interface SessionInfoInput {
167
+ id?: string;
168
+ title?: string;
169
+ parentID?: string;
170
+ directory?: string;
171
+ }
172
+ /**
173
+ * Build a session metadata record to persist, or null when there is nothing
174
+ * worth recording yet (no title and no parent link). Title is trimmed; a
175
+ * parentID is recorded even before a title exists so child sessions can be
176
+ * rolled up into their parent.
177
+ */
178
+ export declare function buildSessionRecord(info: SessionInfoInput): (SessionMetaRecord & {
179
+ sessionId: string;
180
+ }) | null;