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.
@@ -2,28 +2,56 @@
2
2
  // Shared types, pricing data, and utilities for opencode-token-tracker
3
3
  // ============================================================================
4
4
  // ============================================================================
5
- // Built-in Pricing (USD per 1M tokens) - Updated 2026-02-11
5
+ // 内置价格(USD / 1M tokens);核验日期按型号维护,本轮仅更新 DeepSeek 和 Kimi。
6
6
  // Sources:
7
- // - Anthropic: https://www.anthropic.com/pricing#api
8
- // - OpenAI: https://openai.com/api/pricing/
7
+ // - Anthropic: https://platform.claude.com/docs/en/about-claude/pricing
8
+ // - OpenAI: https://developers.openai.com/api/docs/pricing
9
9
  // - DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
10
10
  // - Google: https://cloud.google.com/vertex-ai/generative-ai/pricing
11
11
  // ============================================================================
12
12
  export const BUILTIN_PRICING_META = {
13
- pricingLastUpdated: "2026-02-11",
14
- metadataLastUpdated: "2026-05-24",
13
+ pricingLastUpdated: "2026-10-05",
14
+ metadataLastUpdated: "2026-10-05",
15
+ baselineLastUpdated: "2026-05-29",
15
16
  source: "Provider official pricing pages",
16
- notes: "Manually maintained. Report stale prices: https://github.com/tongsh6/opencode-token-tracker/issues/new",
17
+ 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.",
18
+ };
19
+ export const PRICING_STALE_AFTER_DAYS = 90;
20
+ const DEEPSEEK_PEAK_AUDIT = {
21
+ reviewedAt: "2026-10-05",
22
+ basis: "peak",
23
+ exactOrPrefixed: true,
24
+ sourceUrl: "https://api-docs.deepseek.com/quick_start/pricing/",
25
+ };
26
+ const DEEPSEEK_LEGACY_AUDIT = {
27
+ reviewedAt: "2026-05-29",
28
+ expiresAt: "2026-07-24T16:00:00Z",
29
+ sourceUrl: "https://api-docs.deepseek.com/news/news260424/",
30
+ };
31
+ export const BUILTIN_PRICING_AUDITS = {
32
+ "deepseek-flash": DEEPSEEK_PEAK_AUDIT,
33
+ "deepseek-v4-flash": DEEPSEEK_PEAK_AUDIT,
34
+ "deepseek-v4-flash-vision-exp": DEEPSEEK_PEAK_AUDIT,
35
+ "deepseek-v4-pro": DEEPSEEK_PEAK_AUDIT,
36
+ "deepseek-chat": DEEPSEEK_LEGACY_AUDIT,
37
+ "deepseek-reasoner": DEEPSEEK_LEGACY_AUDIT,
38
+ "kimi-k2.7-code": {
39
+ reviewedAt: "2026-10-05",
40
+ exactOrPrefixed: true,
41
+ sourceUrl: "https://www.kimi.ai/resources/kimi-k2-7-code",
42
+ },
17
43
  };
18
44
  export const BUILTIN_PRICING = {
19
- // Anthropic Claude (https://www.anthropic.com/pricing#api)
20
- // Opus 4.6: $5 input, $25 output (≤200K), cache write $6.25, cache read $0.50
45
+ // Anthropic Claude (https://platform.claude.com/docs/en/about-claude/pricing)
46
+ // Opus 4.8/4.7/4.6/4.5: $5 input, $25 output, cache write $6.25, cache read $0.50
47
+ "claude-opus-4.8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
48
+ "claude-opus-4.7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
21
49
  "claude-opus-4.6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
22
- // Opus 4.5 (legacy): same pricing as Opus 4.6
23
50
  "claude-opus-4.5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
24
- // Sonnet 4.5: $3 input, $15 output (≤200K), cache write $3.75, cache read $0.30
51
+ // Sonnet 4.6/4.5: $3 input, $15 output, cache write $3.75, cache read $0.30
52
+ "claude-sonnet-4.6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
25
53
  "claude-sonnet-4.5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
26
- // Sonnet 4: $3 input, $15 output
54
+ // Sonnet 4 (deprecated): $3 input, $15 output
27
55
  "claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
28
56
  // Haiku 4.5: $1 input, $5 output, cache write $1.25, cache read $0.10
29
57
  "claude-haiku-4.5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
@@ -32,38 +60,67 @@ export const BUILTIN_PRICING = {
32
60
  "claude-opus-4.1": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
33
61
  "claude-opus-4": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
34
62
  "claude-haiku-3": { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 },
35
- // OpenAI GPT (https://openai.com/api/pricing/)
36
- // GPT-5.2: $1.75 input, $14 output (flagship)
63
+ // OpenAI GPT (https://developers.openai.com/api/docs/pricing)
64
+ // GPT-5.5/5.4 standard short-context text pricing.
65
+ "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5 },
66
+ "gpt-5.5-pro": { input: 30, output: 180 },
67
+ "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25 },
68
+ "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075 },
69
+ "gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02 },
70
+ "gpt-5.4-pro": { input: 30, output: 180 },
71
+ "gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175 },
72
+ "gpt-5.3-chat-latest": { input: 1.75, output: 14, cacheRead: 0.175 },
73
+ // GPT-5.2 and earlier GPT-5 family.
37
74
  "gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175 },
38
75
  "gpt-5.2-pro": { input: 21, output: 168 },
39
76
  "gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025 },
40
- "gpt-5.1": { input: 2, output: 8 },
41
- "gpt-5": { input: 5, output: 15 },
42
- // GPT-4.1 series (fine-tuning prices, base may differ)
43
- "gpt-4.1": { input: 3, output: 12, cacheRead: 0.75 },
44
- "gpt-4.1-mini": { input: 0.8, output: 3.2, cacheRead: 0.2 },
45
- "gpt-4.1-nano": { input: 0.2, output: 0.8, cacheRead: 0.05 },
77
+ "gpt-5-nano": { input: 0.05, output: 0.4, cacheRead: 0.005 },
78
+ "gpt-5.1": { input: 1.25, output: 10, cacheRead: 0.125 },
79
+ "gpt-5.1-chat-latest": { input: 1.25, output: 10, cacheRead: 0.125 },
80
+ "gpt-5.1-codex-max": { input: 1.25, output: 10, cacheRead: 0.125 },
81
+ "gpt-5.1-codex": { input: 1.25, output: 10, cacheRead: 0.125 },
82
+ "gpt-5.1-codex-mini": { input: 0.25, output: 2, cacheRead: 0.025 },
83
+ "gpt-5": { input: 1.25, output: 10, cacheRead: 0.125 },
84
+ "gpt-5-chat-latest": { input: 1.25, output: 10, cacheRead: 0.125 },
85
+ "gpt-5-codex": { input: 1.25, output: 10, cacheRead: 0.125 },
86
+ // GPT-4.1 series
87
+ "gpt-4.1": { input: 2, output: 8, cacheRead: 0.5 },
88
+ "gpt-4.1-mini": { input: 0.4, output: 1.6, cacheRead: 0.1 },
89
+ "gpt-4.1-nano": { input: 0.1, output: 0.4, cacheRead: 0.025 },
46
90
  // GPT-4o series (may be deprecated)
47
- "gpt-4o": { input: 2.5, output: 10 },
48
- "gpt-4o-mini": { input: 0.15, output: 0.6 },
91
+ "gpt-4o": { input: 2.5, output: 10, cacheRead: 1.25 },
92
+ "gpt-4o-mini": { input: 0.15, output: 0.6, cacheRead: 0.075 },
49
93
  // Reasoning models
50
- "o3": { input: 10, output: 40 },
51
- "o3-mini": { input: 1.1, output: 4.4 },
52
- "o4-mini": { input: 4, output: 16, cacheRead: 1 },
53
- "o1": { input: 15, output: 60 },
54
- "o1-mini": { input: 1.1, output: 4.4 },
94
+ "o3": { input: 2, output: 8, cacheRead: 0.5 },
95
+ "o3-mini": { input: 1.1, output: 4.4, cacheRead: 0.55 },
96
+ "o4-mini": { input: 1.1, output: 4.4, cacheRead: 0.275 },
97
+ "o1": { input: 15, output: 60, cacheRead: 7.5 },
98
+ "o1-mini": { input: 1.1, output: 4.4, cacheRead: 0.55 },
55
99
  // DeepSeek (https://api-docs.deepseek.com/quick_start/pricing)
56
- // DeepSeek-V4 Flash compatibility pricing for deepseek-chat / deepseek-reasoner.
57
- // $0.14 input (cache miss), $0.0028 input (cache hit), $0.28 output
100
+ // 已停用的旧别名保留历史估算值,由核验元数据提示到期。
58
101
  "deepseek-chat": { input: 0.14, output: 0.28, cacheRead: 0.0028 },
59
102
  "deepseek-reasoner": { input: 0.14, output: 0.28, cacheRead: 0.0028 },
103
+ // 采用峰时保守估算;谷时价格为一半,不在运行时自动判断时段或节假日。
104
+ // V4 Flash 旧名目前由 V4.1 Flash 服务,按当前 Flash 价格估算。
105
+ "deepseek-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 },
106
+ "deepseek-v4-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 },
107
+ "deepseek-v4-flash-vision-exp": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 },
108
+ "deepseek-v4-pro": { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0 },
109
+ // Kimi 官方 API 按量价格,不适用于 Kimi Code 订阅或未核价的高速变体。
110
+ "kimi-k2.7-code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 },
60
111
  // Google Gemini (https://cloud.google.com/vertex-ai/generative-ai/pricing)
61
- // Gemini 3 Pro Preview: $2 input, $12 output (≤200K)
112
+ // Standard global text pricing at <=200K input tokens where tiered pricing applies.
113
+ // Gemini 3.1 Pro Preview: $2 input, $12 output, cache read $0.20
114
+ "gemini-3.1-pro-preview": { input: 2, output: 12, cacheRead: 0.2 },
62
115
  "gemini-3-pro": { input: 2, output: 12, cacheRead: 0.2 },
63
116
  "gemini-3-pro-preview": { input: 2, output: 12, cacheRead: 0.2 },
117
+ // Gemini 3.5 Flash: $1.50 input, $9 output, cache read $0.15
118
+ "gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15 },
64
119
  // Gemini 3 Flash Preview: $0.5 input, $3 output
65
120
  "gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05 },
66
121
  "gemini-3-flash-preview": { input: 0.5, output: 3, cacheRead: 0.05 },
122
+ // Gemini 3.1 Flash-Lite: $0.25 input, $1.50 output, cache read $0.025
123
+ "gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cacheRead: 0.025 },
67
124
  // Gemini 2.5 Pro: $1.25 input, $10 output (≤200K)
68
125
  "gemini-2.5-pro": { input: 1.25, output: 10, cacheRead: 0.125 },
69
126
  // Gemini 2.5 Flash: $0.3 input, $2.5 output
@@ -99,6 +156,12 @@ export function formatTokens(tokens, millionDecimals = 1) {
99
156
  return `${(tokens / 1_000).toFixed(1)}K`;
100
157
  return tokens.toString();
101
158
  }
159
+ export function hasBillableTokenUsage(usage) {
160
+ return (usage.input ?? 0) > 0
161
+ || (usage.output ?? 0) > 0
162
+ || (usage.cacheRead ?? 0) > 0
163
+ || (usage.cacheWrite ?? 0) > 0;
164
+ }
102
165
  // ============================================================================
103
166
  // Time Utilities
104
167
  // ============================================================================
@@ -107,6 +170,12 @@ export function getStartOfDay(date = new Date()) {
107
170
  d.setHours(0, 0, 0, 0);
108
171
  return d.getTime();
109
172
  }
173
+ export function formatLocalDateKey(date) {
174
+ const year = date.getFullYear();
175
+ const month = String(date.getMonth() + 1).padStart(2, "0");
176
+ const day = String(date.getDate()).padStart(2, "0");
177
+ return `${year}-${month}-${day}`;
178
+ }
110
179
  export function getStartOfWeek(date = new Date()) {
111
180
  const d = new Date(date);
112
181
  const day = d.getDay();
@@ -367,6 +436,34 @@ function validateBudget(raw, warnings) {
367
436
  }
368
437
  return result;
369
438
  }
439
+ export function formatBudgetMessage(status) {
440
+ const pct = Math.round(status.percentage * 100);
441
+ const periodLabel = status.period.charAt(0).toUpperCase() + status.period.slice(1);
442
+ return `${periodLabel}: ${formatCost(status.spent)}/${formatCost(status.limit)} (${pct}%)`;
443
+ }
444
+ export function buildMessageToast(input) {
445
+ if (input.budget?.exceeded) {
446
+ return {
447
+ title: "⚠️ Budget exceeded!",
448
+ message: formatBudgetMessage(input.budget),
449
+ variant: "error",
450
+ };
451
+ }
452
+ const title = `${formatTokens(input.messageTokens)} tokens`;
453
+ const messageCost = formatCost(input.messageCost);
454
+ if (input.budget?.warning) {
455
+ return {
456
+ title,
457
+ message: `${messageCost} | Session: ${formatTokens(input.sessionTokens)} · ${formatBudgetMessage(input.budget)}`,
458
+ variant: "warning",
459
+ };
460
+ }
461
+ return {
462
+ title,
463
+ message: `${messageCost} | Session: ${formatTokens(input.sessionTokens)} · ${formatCost(input.sessionCost)}`,
464
+ variant: "info",
465
+ };
466
+ }
370
467
  export function evaluateBudgetStatus(budget, spent, initialized) {
371
468
  if (!initialized)
372
469
  return null;
@@ -395,6 +492,38 @@ export function evaluateBudgetStatus(budget, spent, initialized) {
395
492
  candidates.sort((a, b) => severity(b) - severity(a) || b.percentage - a.percentage);
396
493
  return candidates[0] ?? null;
397
494
  }
495
+ // 计价、来源标签和时效诊断复用同一内置匹配入口,防止三者口径分叉。
496
+ function findBuiltinPricingKey(model) {
497
+ if (BUILTIN_PRICING[model])
498
+ return model;
499
+ const modelLower = model.toLowerCase();
500
+ const keys = Object.keys(BUILTIN_PRICING)
501
+ .filter(key => key !== "_default")
502
+ .sort((a, b) => b.length - a.length);
503
+ return keys.find(key => BUILTIN_PRICING_AUDITS[key]?.exactOrPrefixed
504
+ ? modelLower === key || modelLower.endsWith(`/${key}`)
505
+ : modelLower.includes(key.toLowerCase()));
506
+ }
507
+ export function getBuiltinPricingAudit(model) {
508
+ const key = findBuiltinPricingKey(model);
509
+ if (!key || key === "_default")
510
+ return undefined;
511
+ return BUILTIN_PRICING_AUDITS[key] ?? { reviewedAt: BUILTIN_PRICING_META.baselineLastUpdated };
512
+ }
513
+ export function getPricingFreshness(audit, now = Date.now()) {
514
+ const reviewedAt = Date.parse(`${audit.reviewedAt}T00:00:00Z`);
515
+ const expiresAt = audit.expiresAt ? Date.parse(audit.expiresAt) : undefined;
516
+ if (!Number.isFinite(now) || !Number.isFinite(reviewedAt) || reviewedAt > now
517
+ || (expiresAt !== undefined && !Number.isFinite(expiresAt)))
518
+ return "unknown";
519
+ if (expiresAt !== undefined && now >= expiresAt)
520
+ return "expired";
521
+ return now - reviewedAt > PRICING_STALE_AFTER_DAYS * 86_400_000 ? "stale" : "recent";
522
+ }
523
+ export function getEffectivePricingAudit(config, model, provider) {
524
+ // 用户已覆盖价格时,不应拿未使用的内置日期给用户配置打上过期标签。
525
+ return resolvePricingStatus(config, model, provider) === "built-in" ? getBuiltinPricingAudit(model) : undefined;
526
+ }
398
527
  export function resolvePricingStatus(config, model, provider) {
399
528
  // Step 1: config.providers[provider] -> "provider cfg"
400
529
  if (config.providers && config.providers[provider]) {
@@ -404,20 +533,9 @@ export function resolvePricingStatus(config, model, provider) {
404
533
  if (findModelConfigPricing(config.models, model, provider, false)) {
405
534
  return "model cfg";
406
535
  }
407
- // Step 3: Exact-match in BUILTIN_PRICING -> "built-in"
408
- if (BUILTIN_PRICING[model]) {
536
+ // Step 3/4: 内置精确匹配,其次按型号规则匹配。
537
+ if (findBuiltinPricingKey(model))
409
538
  return "built-in";
410
- }
411
- // Step 4: Partial-match in BUILTIN_PRICING (sorted by key length desc) -> "built-in"
412
- const modelLower = model.toLowerCase();
413
- const sortedBuiltin = Object.keys(BUILTIN_PRICING)
414
- .filter(k => k !== "_default")
415
- .sort((a, b) => b.length - a.length);
416
- for (const key of sortedBuiltin) {
417
- if (modelLower.includes(key.toLowerCase())) {
418
- return "built-in";
419
- }
420
- }
421
539
  // Step 5: Partial-match in config.models (sorted by key length desc) -> "model cfg"
422
540
  if (findModelConfigPricing(config.models, model, provider, true)) {
423
541
  return "model cfg";
@@ -462,18 +580,9 @@ function getModelPricing(model, provider, config) {
462
580
  return configuredPricing;
463
581
  }
464
582
  }
465
- if (BUILTIN_PRICING[model]) {
466
- return BUILTIN_PRICING[model];
467
- }
468
- const modelLower = model.toLowerCase();
469
- const sortedKeys = Object.keys(BUILTIN_PRICING)
470
- .filter(k => k !== "_default")
471
- .sort((a, b) => b.length - a.length);
472
- for (const key of sortedKeys) {
473
- if (modelLower.includes(key.toLowerCase())) {
474
- return BUILTIN_PRICING[key];
475
- }
476
- }
583
+ const builtinKey = findBuiltinPricingKey(model);
584
+ if (builtinKey)
585
+ return BUILTIN_PRICING[builtinKey];
477
586
  if (config && config.models) {
478
587
  const partialUserPricing = findModelConfigPricing(config.models, model, provider, true);
479
588
  if (partialUserPricing) {
@@ -513,3 +622,128 @@ export function calculateCost(model, provider, input, output, cacheRead = 0, cac
513
622
  const cacheWriteCost = (cacheWrite / 1_000_000) * finalCacheWritePrice;
514
623
  return inputCost + outputCost + cacheReadCost + cacheWriteCost;
515
624
  }
625
+ /**
626
+ * Resolve a session id to its root by following parentID links.
627
+ * Stops when no parent is known or a cycle is detected, so an unknown
628
+ * parent still yields a stable root id to group descendants under.
629
+ */
630
+ export function resolveRootSession(sessionId, parentOf) {
631
+ let current = sessionId;
632
+ const seen = new Set();
633
+ while (true) {
634
+ const parent = parentOf.get(current);
635
+ if (!parent || parent === current || seen.has(parent)) {
636
+ return current;
637
+ }
638
+ seen.add(current);
639
+ current = parent;
640
+ }
641
+ }
642
+ /**
643
+ * Sum per-session stats across every session that resolves to the same root as
644
+ * `currentSessionId`, rolling a sub-agent session's usage up into its top-level
645
+ * (parent) session for toast / idle display.
646
+ *
647
+ * Aggregation happens at read time: a `parentID` learned only after a session's
648
+ * first message still merges on the next call (eventual consistency), so stored
649
+ * per-session stats never need re-bucketing. `startTime` is the earliest among
650
+ * the grouped sessions so duration reflects the whole task.
651
+ */
652
+ export function aggregateRootSession(sessionStats, parentOf, currentSessionId) {
653
+ const root = resolveRootSession(currentSessionId, parentOf);
654
+ const agg = {
655
+ totalInput: 0,
656
+ totalOutput: 0,
657
+ totalReasoning: 0,
658
+ totalCacheRead: 0,
659
+ totalCacheWrite: 0,
660
+ totalCost: 0,
661
+ messageCount: 0,
662
+ startTime: 0,
663
+ };
664
+ let earliest = Infinity;
665
+ for (const [sessionId, stats] of sessionStats) {
666
+ if (resolveRootSession(sessionId, parentOf) !== root)
667
+ continue;
668
+ agg.totalInput += stats.totalInput;
669
+ agg.totalOutput += stats.totalOutput;
670
+ agg.totalReasoning += stats.totalReasoning;
671
+ agg.totalCacheRead += stats.totalCacheRead;
672
+ agg.totalCacheWrite += stats.totalCacheWrite;
673
+ agg.totalCost += stats.totalCost;
674
+ agg.messageCount += stats.messageCount;
675
+ if (stats.startTime < earliest)
676
+ earliest = stats.startTime;
677
+ }
678
+ agg.startTime = Number.isFinite(earliest) ? earliest : 0;
679
+ return agg;
680
+ }
681
+ /**
682
+ * Merge raw sessions.jsonl records into one SessionMeta per session id.
683
+ * Later non-empty fields win; empty/whitespace titles never clobber a real
684
+ * one (session.created may arrive before a title is generated).
685
+ */
686
+ export function mergeSessionMeta(records) {
687
+ const map = new Map();
688
+ for (const r of records) {
689
+ const id = r.sessionId;
690
+ if (!id)
691
+ continue;
692
+ const existing = map.get(id) ?? { sessionId: id, _ts: 0 };
693
+ const title = typeof r.title === "string" ? r.title.trim() : "";
694
+ if (title)
695
+ existing.title = title;
696
+ if (r.parentID)
697
+ existing.parentID = r.parentID;
698
+ if (r.directory)
699
+ existing.directory = r.directory;
700
+ if (typeof r._ts === "number" && r._ts > existing._ts)
701
+ existing._ts = r._ts;
702
+ map.set(id, existing);
703
+ }
704
+ return map;
705
+ }
706
+ /** Truncate a label to maxWidth, appending an ellipsis when it overflows. */
707
+ export function truncateLabel(str, maxWidth) {
708
+ if (str.length <= maxWidth)
709
+ return str;
710
+ if (maxWidth <= 1)
711
+ return "…";
712
+ return `${str.slice(0, maxWidth - 1)}…`;
713
+ }
714
+ /** Distinctive short code for a session id (its tail), for fallback display. */
715
+ export function shortSessionCode(sessionId) {
716
+ if (!sessionId)
717
+ return "unknown";
718
+ return sessionId.length > 12 ? `…${sessionId.slice(-10)}` : sessionId;
719
+ }
720
+ /** Pick a human label for a (root) session: title when known, else short code. */
721
+ export function sessionDisplayLabel(rootId, meta, maxWidth) {
722
+ const title = meta?.title?.trim();
723
+ if (title)
724
+ return truncateLabel(title, maxWidth);
725
+ return shortSessionCode(rootId);
726
+ }
727
+ /**
728
+ * Build a session metadata record to persist, or null when there is nothing
729
+ * worth recording yet (no title and no parent link). Title is trimmed; a
730
+ * parentID is recorded even before a title exists so child sessions can be
731
+ * rolled up into their parent.
732
+ */
733
+ export function buildSessionRecord(info) {
734
+ const sessionId = info.id;
735
+ if (!sessionId)
736
+ return null;
737
+ const title = typeof info.title === "string" ? info.title.trim() : "";
738
+ const parentID = info.parentID;
739
+ if (!title && !parentID)
740
+ return null;
741
+ const record = { sessionId };
742
+ if (title)
743
+ record.title = title;
744
+ if (parentID)
745
+ record.parentID = parentID;
746
+ if (info.directory)
747
+ record.directory = info.directory;
748
+ return record;
749
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-token-tracker",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
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",
@@ -11,7 +11,10 @@
11
11
  "scripts": {
12
12
  "build": "tsc",
13
13
  "test": "tsc && node --test dist/test/*.test.js",
14
- "prepublishOnly": "npm run build"
14
+ "prepublishOnly": "npm run build",
15
+ "release:check": "node scripts/release.js check",
16
+ "release:prepare": "node scripts/release.js prepare",
17
+ "release:tag": "node scripts/release.js tag"
15
18
  },
16
19
  "keywords": [
17
20
  "opencode",