@toninho09/opencode-usage 1.0.0 → 1.1.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenCode Usage Plugin
2
2
 
3
- Track your AI coding assistant usage in one place. This plugin shows quota information and usage statistics for GitHub Copilot, Claude Code, and Z.ai with a single command.
3
+ Track your AI coding assistant usage in one place. This plugin shows both provider quota information and real-time token usage statistics for GitHub Copilot, Claude Code, and Z.ai.
4
4
 
5
5
  ## Table of Contents
6
6
 
@@ -69,7 +69,7 @@ npm install
69
69
  #### Option B: Install via npm (coming soon)
70
70
 
71
71
  ```bash
72
- npm install -g opencode-usage
72
+ npm install -g @toninho09/opencode-usage@latest
73
73
  ```
74
74
 
75
75
  Then add to your `opencode.json` config file:
@@ -99,31 +99,68 @@ Now check your usage:
99
99
  ## What You'll See
100
100
 
101
101
  ```
102
- ╔════════════════════════════════════════╗
103
- GITHUB COPILOT
104
- ╚════════════════════════════════════════╝
105
- Plan: individual
106
- Premium: [## ] 11% (33/300)
107
- Chat: Unlimited
108
- Completions: Unlimited
109
- Quota Resets: 18d 20h (9999-12-31 23:59 UTC-03:00)
110
- ╔════════════════════════════════════════╗
111
- CLAUDE CODE
112
- ╚════════════════════════════════════════╝
113
- 5 Hour: [################### ] 94%
114
- 7 Day: [# ] 7%
115
- 5h Resets: 2h (9999-12-31 23:59 UTC-03:00)
116
- 7d Resets: 6d 21h (9999-12-31 23:59 UTC-03:00)
117
- Extra Usage: Disabled
118
- ╔════════════════════════════════════════╗
119
- Z.AI CODING PLAN
120
- ╚════════════════════════════════════════╝
121
- Account: xxxxxxxx............ (Z.AI Coding Plan)
122
- Tokens: [# ] 4%
123
- 5h Resets: 3h (9999-12-31 23:59 UTC-03:00)
124
- MCP Searches: [ ] 0% (0/100)
102
+ ╔════════════════════════════════════════════════════════════════════════════════╗
103
+ GITHUB COPILOT
104
+ ╚════════════════════════════════════════════════════════════════════════════════╝
105
+ Plan: individual
106
+ Premium: [## ] 11% (33/300)
107
+ Chat: Unlimited
108
+ Completions: Unlimited
109
+ Quota Resets: 18d 0h (2026-02-28 21:00 UTC-03:00)
110
+ ╔════════════════════════════════════════════════════════════════════════════════╗
111
+ CLAUDE CODE
112
+ ╚════════════════════════════════════════════════════════════════════════════════╝
113
+ 5 Hour: [### ] 15%
114
+ 7 Day: [## ] 11%
115
+ 5h Resets: 4h (2026-02-11 00:59 UTC-03:00)
116
+ 7d Resets: 6d 1h (2026-02-16 21:59 UTC-03:00)
117
+ Extra Usage: Disabled
118
+ ╔════════════════════════════════════════════════════════════════════════════════╗
119
+ Z.AI CODING PLAN
120
+ ╚════════════════════════════════════════════════════════════════════════════════╝
121
+ Account: xxxxxxxx............ (Z.AI Coding Plan)
122
+ Tokens: [ ] 1%
123
+ 5h Resets: 4h (2026-02-11 00:45 UTC-03:00)
124
+ MCP Searches: [ ] 0% (0/100)
125
+ ╔════════════════════════════════════════════════════════════════════════════════╗
126
+ ║ Current Session ║
127
+ ╚════════════════════════════════════════════════════════════════════════════════╝
128
+ Total Tokens: 14,650
129
+ Messages: 2
130
+ Models:
131
+ ────────────────────────────────────────────────────────────────────────────
132
+ github-copilot/gpt-5-mini
133
+ In: 14,190 Out: 460 Rea: 0 Cache-Rd: 0 Cache-Wr: 0 Messages: 2
134
+ ╔════════════════════════════════════════════════════════════════════════════════╗
135
+ ║ All Sessions (2 sessions) ║
136
+ ╚════════════════════════════════════════════════════════════════════════════════╝
137
+ Total Tokens: 30,332
138
+ Messages: 4
139
+ Models:
140
+ ────────────────────────────────────────────────────────────────────────────
141
+ zai-coding-plan/glm-4.7-flash
142
+ In: 15,102 Out: 492 Rea: 0 Cache-Rd: 88 Cache-Wr: 0 Messages: 2
143
+ github-copilot/gpt-5-mini
144
+ In: 14,190 Out: 460 Rea: 0 Cache-Rd: 0 Cache-Wr: 0 Messages: 2
125
145
  ```
126
146
 
147
+ **Token Tracking Key:**
148
+ - `In` = Input tokens
149
+ - `Out` = Output tokens
150
+ - `Rea` = Reasoning tokens
151
+ - `Cache-Rd` = Cache read tokens
152
+ - `Cache-Wr` = Cache write tokens
153
+ - `Messages` = Message count
154
+
155
+ ## Features
156
+
157
+ - ✅ **Provider quota monitoring**: Shows usage limits and remaining quota for Copilot, Claude, and Z.ai
158
+ - ✅ **Real-time token tracking**: Automatically monitors token usage from all AI messages
159
+ - ✅ **Provider/Model breakdown**: See usage grouped by AI provider and model
160
+ - ✅ **Token type tracking**: Input, output, reasoning, cache read, and cache write tokens
161
+ - ✅ **Multi-session support**: View current session and all sessions history
162
+ - ✅ **No external dependencies**: Uses only in-memory tracking (reset when app closes)
163
+
127
164
  ## License
128
165
 
129
166
  MIT
package/index.ts CHANGED
@@ -1,30 +1,141 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
2
  import { handleUsageCommand } from "./lib/usage-handler"
3
+ import { state } from "./lib/tracking/types"
4
+ import { addTokens } from "./lib/tracking/tracker"
3
5
 
4
6
  export const TestPlugin: Plugin = async ({ client }) => {
5
7
  return {
8
+ event: async ({ event }) => {
9
+ // Send notification on session completion
10
+ if (event.type === "message.updated") {
11
+ try {
12
+ const sessionID = event.properties?.info.sessionID;
13
+ const info = event.properties?.info;
14
+ const app = client.app
15
+ await app.log({
16
+ body: {
17
+ service: "usage-plugin",
18
+ level: "debug",
19
+ message: "message.updated event received",
20
+ extra: { sessionID, role: info?.role },
21
+ },
22
+ })
23
+
24
+ if (sessionID && info?.role === "assistant" && info?.tokens) {
25
+ const { input, output, reasoning, cache } = info.tokens;
26
+ const app = client.app
27
+
28
+ await app.log({
29
+ body: {
30
+ service: "usage-plugin",
31
+ level: "debug",
32
+ message: "Tokens detected in event",
33
+ extra: { input, output, reasoning, cacheRead: cache?.read, cacheWrite: cache?.write },
34
+ },
35
+ })
36
+
37
+ if ((input ?? 0) > 0 || (output ?? 0) > 0) {
38
+ const provider = info.providerID || "unknown";
39
+ const model = info.modelID || "unknown";
40
+ const app = client.app
41
+
42
+ await app.log({
43
+ body: {
44
+ service: "usage-plugin",
45
+ level: "debug",
46
+ message: "Adding tokens to session",
47
+ extra: { provider, model },
48
+ },
49
+ })
50
+ state.currentSessionID = sessionID;
51
+
52
+ addTokens(
53
+ sessionID,
54
+ provider,
55
+ model,
56
+ {
57
+ input: input ?? 0,
58
+ output: output ?? 0,
59
+ reasoning: reasoning ?? 0,
60
+ cacheRead: cache?.read ?? 0,
61
+ cacheWrite: cache?.write ?? 0,
62
+ },
63
+ );
64
+ } else {
65
+ const app = client.app
66
+
67
+ await app.log({
68
+ body: {
69
+ service: "usage-plugin",
70
+ level: "warn",
71
+ message: "Zero or negative tokens - Ignoring",
72
+ extra: { input, output, reasoning },
73
+ },
74
+ })
75
+ }
76
+ } else {
77
+ const app = client.app
78
+
79
+ await app.log({
80
+ body: {
81
+ service: "usage-plugin",
82
+ level: "debug",
83
+ message: "Event does not contain token data",
84
+ extra: { sessionID, role: info?.role },
85
+ },
86
+ })
87
+ }
88
+ } catch (error) {
89
+ const app = client.app
90
+
91
+ await app.log({
92
+ body: {
93
+ service: "usage-plugin",
94
+ level: "error",
95
+ message: "Error in message.updated",
96
+ extra: { error: error instanceof Error ? error.message : String(error) },
97
+ },
98
+ })
99
+ }
100
+ }
101
+ },
6
102
  config: async (opencodeConfig) => {
7
103
  opencodeConfig.command ??= {}
8
104
  opencodeConfig.command["usage"] = {
9
105
  template: "",
10
- description: "Shows usage for Copilot, Claude Code and Z.ai",
106
+ description: "Shows token usage for all sessions by provider and model",
11
107
  }
12
108
  },
13
109
  "command.execute.before": async (input, output) => {
14
110
  if (input.command === "usage") {
15
111
  try {
112
+ const app = client.app
113
+ await app.log({
114
+ body: {
115
+ service: "usage-plugin",
116
+ level: "info",
117
+ message: "/usage command executed",
118
+ },
119
+ })
16
120
  await handleUsageCommand({
17
121
  client,
18
122
  sessionID: input.sessionID,
19
123
  params: output,
20
124
  })
21
125
  } catch (err) {
22
- console.error(err instanceof Error ? err.message : String(err))
126
+ const app = client.app
127
+ await app.log({
128
+ body: {
129
+ service: "usage-plugin",
130
+ level: "error",
131
+ message: err instanceof Error ? err.message : String(err),
132
+ },
133
+ })
23
134
  }
24
135
 
25
136
  throw new Error("__USAGE_COMMAND_HANDLED__")
26
137
  }
27
- },
138
+ }
28
139
  }
29
140
  }
30
141
 
@@ -1,5 +1,5 @@
1
1
  import { formatResetLine } from "../../shared/formatting";
2
- import { createUsedProgressBar } from "../../shared/utils";
2
+ import { createUsedProgressBar, boxHeader } from "../../shared/utils";
3
3
  import type { ClaudeUsageResponse, QuotaPeriod } from "./types";
4
4
 
5
5
  export class ClaudeFormatter {
@@ -8,13 +8,13 @@ export class ClaudeFormatter {
8
8
  */
9
9
  private formatQuotaLine(name: string, quota: QuotaPeriod | null): string {
10
10
  if (!quota) {
11
- return `${name.padEnd(16)} N/A`;
11
+ return ` ${name.padEnd(16)} N/A`;
12
12
  }
13
13
 
14
14
  const percentUsed = Math.round(quota.utilization);
15
15
  const progressBar = createUsedProgressBar(percentUsed, 20);
16
16
 
17
- return `${name.padEnd(16)} ${progressBar} ${percentUsed}%`;
17
+ return ` ${name.padEnd(16)} ${progressBar} ${percentUsed}%`;
18
18
  }
19
19
 
20
20
  /**
@@ -23,19 +23,17 @@ export class ClaudeFormatter {
23
23
  format(data: ClaudeUsageResponse): string {
24
24
  const lines: string[] = [];
25
25
 
26
- lines.push("╔════════════════════════════════════════╗");
27
- lines.push("║ CLAUDE CODE ║");
28
- lines.push("╚════════════════════════════════════════╝");
26
+ lines.push(boxHeader("CLAUDE CODE", 80));
29
27
 
30
28
  lines.push(this.formatQuotaLine("5 Hour:", data.five_hour));
31
29
  lines.push(this.formatQuotaLine("7 Day:", data.seven_day));
32
30
 
33
31
  if (data.five_hour) {
34
- lines.push(formatResetLine("5h Resets:", data.five_hour.resets_at, 16));
32
+ lines.push(" " + formatResetLine("5h Resets:", data.five_hour.resets_at, 15));
35
33
  }
36
34
 
37
35
  if (data.seven_day) {
38
- lines.push(formatResetLine("7d Resets:", data.seven_day.resets_at, 16));
36
+ lines.push(" " + formatResetLine("7d Resets:", data.seven_day.resets_at, 15));
39
37
  }
40
38
 
41
39
  const extraUsage = data.extra_usage;
@@ -46,9 +44,9 @@ export class ClaudeFormatter {
46
44
  extraUsage.used_credits !== null && extraUsage.monthly_limit !== null
47
45
  ? `${extraUsage.used_credits}/${extraUsage.monthly_limit}`
48
46
  : "N/A";
49
- lines.push(`Extra Usage: Enabled - ${utilization} (${credits})`);
47
+ lines.push(` Extra Usage: Enabled - ${utilization} (${credits})`);
50
48
  } else {
51
- lines.push("Extra Usage: Disabled");
49
+ lines.push(" Extra Usage: Disabled");
52
50
  }
53
51
 
54
52
  return lines.join("\n");
@@ -8,7 +8,7 @@ const formatter = new ClaudeFormatter();
8
8
  export const claudeProvider: UsageProvider = {
9
9
  name: "Claude Code",
10
10
  id: "claude",
11
- description: "Monitoramento de uso do Claude Code",
11
+ description: "Claude Code usage monitoring",
12
12
 
13
13
  async getUsageData(): Promise<ProviderMessage | null> {
14
14
  try {
@@ -1,5 +1,5 @@
1
1
  import { formatResetLine } from "../../shared/formatting";
2
- import { createUsedProgressBar } from "../../shared/utils";
2
+ import { createUsedProgressBar, boxHeader } from "../../shared/utils";
3
3
  import type { CopilotUsageResponse, QuotaDetail } from "./types";
4
4
 
5
5
  export class CopilotFormatter {
@@ -12,7 +12,7 @@ export class CopilotFormatter {
12
12
  }
13
13
 
14
14
  if (quota.unlimited) {
15
- return `${name.padEnd(16)} Unlimited`;
15
+ return ` ${name.padEnd(16)} Unlimited`;
16
16
  }
17
17
 
18
18
  const total = quota.entitlement;
@@ -20,7 +20,7 @@ export class CopilotFormatter {
20
20
  const percentUsed = Math.round((used / total) * 100);
21
21
  const progressBar = createUsedProgressBar(percentUsed, 20);
22
22
 
23
- return `${name.padEnd(16)} ${progressBar} ${percentUsed}% (${used}/${total})`;
23
+ return ` ${name.padEnd(16)} ${progressBar} ${percentUsed}% (${used}/${total})`;
24
24
  }
25
25
 
26
26
  /**
@@ -29,10 +29,8 @@ export class CopilotFormatter {
29
29
  format(data: CopilotUsageResponse): string {
30
30
  const lines: string[] = [];
31
31
 
32
- lines.push("╔════════════════════════════════════════╗");
33
- lines.push("║ GITHUB COPILOT ║");
34
- lines.push("╚════════════════════════════════════════╝");
35
- lines.push(`Plan: ${data.copilot_plan}`);
32
+ lines.push(boxHeader("GITHUB COPILOT", 80));
33
+ lines.push(` Plan: ${data.copilot_plan}`);
36
34
 
37
35
  const premium = data.quota_snapshots.premium_interactions;
38
36
  if (premium) {
@@ -58,7 +56,7 @@ export class CopilotFormatter {
58
56
  }
59
57
  }
60
58
 
61
- lines.push(formatResetLine("Quota Resets:", data.quota_reset_date, 16));
59
+ lines.push(formatResetLine("Quota Resets:", data.quota_reset_date, 18));
62
60
 
63
61
  return lines.join("\n");
64
62
  }
@@ -8,7 +8,7 @@ const formatter = new CopilotFormatter();
8
8
  export const copilotProvider: UsageProvider = {
9
9
  name: "GitHub Copilot",
10
10
  id: "copilot",
11
- description: "Monitoramento de uso do GitHub Copilot",
11
+ description: "GitHub Copilot usage monitoring",
12
12
 
13
13
  async getUsageData(): Promise<ProviderMessage | null> {
14
14
  try {
@@ -1,5 +1,5 @@
1
1
  import { formatResetLine, formatTokens, maskApiKey } from "../../shared/formatting";
2
- import { createUsedProgressBar } from "../../shared/utils";
2
+ import { createUsedProgressBar, boxHeader } from "../../shared/utils";
3
3
  import type { ZaiUsageResponse, UsageLimitItem } from "./types";
4
4
 
5
5
  export class ZaiFormatter {
@@ -36,13 +36,11 @@ export class ZaiFormatter {
36
36
  const limits = data.data.limits;
37
37
 
38
38
  const maskedKey = maskApiKey(apiKey);
39
- lines.push("╔════════════════════════════════════════╗");
40
- lines.push("║ Z.AI CODING PLAN ║");
41
- lines.push("╚════════════════════════════════════════╝");
42
- lines.push(`Account: ${maskedKey} (Z.AI Coding Plan)`);
39
+ lines.push(boxHeader("Z.AI CODING PLAN", 80));
40
+ lines.push(` Account: ${maskedKey} (Z.AI Coding Plan)`);
43
41
 
44
42
  if (!limits || limits.length === 0) {
45
- lines.push("No quota data available");
43
+ lines.push(" No quota data available");
46
44
  return lines.join("\n");
47
45
  }
48
46
 
@@ -52,10 +50,10 @@ export class ZaiFormatter {
52
50
  const progressBar = createUsedProgressBar(percentUsed, 20);
53
51
  const usageDisplay = this.calculateUsageDisplay(tokensLimit);
54
52
 
55
- lines.push(`Tokens: ${progressBar} ${percentUsed}%${usageDisplay}`);
53
+ lines.push(` Tokens: ${progressBar} ${percentUsed}%${usageDisplay}`);
56
54
 
57
55
  if (tokensLimit.nextResetTime) {
58
- lines.push(formatResetLine("5h Resets:", tokensLimit.nextResetTime, 16));
56
+ lines.push(" " + formatResetLine("5h Resets:", tokensLimit.nextResetTime, 15));
59
57
  }
60
58
  }
61
59
 
@@ -68,7 +66,7 @@ export class ZaiFormatter {
68
66
  const total = timeLimit.usage ?? (timeLimit.remaining ?? 0);
69
67
 
70
68
  lines.push(
71
- `MCP Searches: ${progressBar} ${percentUsed}% (${currentValue}/${total})`,
69
+ ` MCP Searches: ${progressBar} ${percentUsed}% (${currentValue}/${total})`,
72
70
  );
73
71
  }
74
72
 
@@ -8,7 +8,7 @@ const formatter = new ZaiFormatter();
8
8
  export const zaiProvider: UsageProvider = {
9
9
  name: "Z.ai Coding Plan",
10
10
  id: "zai",
11
- description: "Monitoramento de uso do Z.ai Coding Plan",
11
+ description: "Z.ai Coding Plan usage monitoring",
12
12
 
13
13
  async getUsageData(): Promise<ProviderMessage | null> {
14
14
  try {
@@ -33,15 +33,16 @@ export function formatResetLine(
33
33
  label: string,
34
34
  resetDate: string | number | undefined,
35
35
  labelWidth: number = 15,
36
+ prefix: string = "",
36
37
  ): string {
37
38
  if (!resetDate) {
38
- return `${label.padEnd(labelWidth)} N/A`;
39
+ return `${prefix}${label.padEnd(labelWidth)} N/A`;
39
40
  }
40
41
 
41
42
  const countdown = formatResetCountdown(resetDate);
42
43
  const friendlyDate = formatFriendlyDate(resetDate);
43
44
 
44
- return `${label.padEnd(labelWidth)} ${countdown} (${friendlyDate})`;
45
+ return `${prefix}${label.padEnd(labelWidth)} ${countdown} (${friendlyDate})`;
45
46
  }
46
47
 
47
48
  /**
@@ -2,23 +2,12 @@ export async function sendIgnoredMessage(
2
2
  client: any,
3
3
  sessionID: string,
4
4
  text: string,
5
- params: any,
6
5
  ): Promise<void> {
7
- const agent = params.agent || undefined
8
- const variant = params.variant || undefined
9
- const model = params.providerId && params.modelId ? {
10
- providerID: params.providerId,
11
- modelID: params.modelId,
12
- } : undefined
13
-
14
6
  try {
15
7
  await client.session.prompt({
16
8
  path: { id: sessionID },
17
9
  body: {
18
10
  noReply: true,
19
- agent,
20
- model,
21
- variant,
22
11
  parts: [{ type: "text", text, ignored: true }],
23
12
  },
24
13
  })
@@ -41,3 +41,26 @@ export function formatFriendlyDate(dateInput: string | number | Date): string {
41
41
 
42
42
  return `${year}-${month}-${day} ${hours}:${minutes} ${timezone}`;
43
43
  }
44
+
45
+ /**
46
+ * Creates a centered box header with title
47
+ * Width is the inner content width (excludes the border characters)
48
+ * Example with width=80:
49
+ * ╔══════════════════════════════════════════════════════════════════════════════════╗
50
+ * ║ MY TITLE ║
51
+ * ╚══════════════════════════════════════════════════════════════════════════════════╝
52
+ */
53
+ export function boxHeader(title: string, width: number = 80): string {
54
+ const topLine = "╔" + "═".repeat(width) + "╗";
55
+ const bottomLine = "╚" + "═".repeat(width) + "╝";
56
+
57
+ // Center the title within the width
58
+ const padding = Math.max(0, width - title.length);
59
+ const leftPad = Math.floor(padding / 2);
60
+ const rightPad = padding - leftPad;
61
+ const centerTitle = " ".repeat(leftPad) + title + " ".repeat(rightPad);
62
+
63
+ const middleLine = "║" + centerTitle + "║";
64
+
65
+ return topLine + "\n" + middleLine + "\n" + bottomLine;
66
+ }
@@ -0,0 +1,89 @@
1
+ import { state, SessionUsage, TokenMetrics } from "./types";
2
+
3
+ const logToApp = async (level: "debug" | "info" | "warn", message: string, extra?: Record<string, any>) => {
4
+ try {
5
+ const client = await (window as any).__opencodeClient
6
+ if (client?.app?.log) {
7
+ await client.app.log({
8
+ body: {
9
+ service: "tracking-plugin",
10
+ level,
11
+ message,
12
+ extra,
13
+ },
14
+ })
15
+ }
16
+ } catch {
17
+ }
18
+ }
19
+
20
+ export function getCurrentSession(): SessionUsage | null {
21
+ if (!state.currentSessionID) {
22
+ return null;
23
+ }
24
+ return getSession(state.currentSessionID);
25
+ }
26
+
27
+ export function getSession(sessionID: string): SessionUsage {
28
+ if (!state.sessions.has(sessionID)) {
29
+ logToApp("info", "Creating new session", { sessionID })
30
+ state.sessions.set(sessionID, {
31
+ sessionID,
32
+ total: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
33
+ byModel: new Map(),
34
+ });
35
+ } else {
36
+ logToApp("debug", "Session found", { sessionID })
37
+ }
38
+ return state.sessions.get(sessionID)!;
39
+ }
40
+
41
+ export function updateSession(sessionID: string, sessionData: Partial<SessionUsage>): void {
42
+ const session = getSession(sessionID);
43
+ logToApp("debug", "Updating session", { sessionID, sessionData })
44
+ Object.assign(session, sessionData);
45
+ }
46
+
47
+ export function addTokens(
48
+ sessionID: string,
49
+ provider: string,
50
+ model: string,
51
+ tokens: TokenMetrics,
52
+ ): void {
53
+ logToApp("debug", "Adding tokens", { sessionID, provider, model, tokens })
54
+
55
+ const session = getSession(sessionID);
56
+
57
+ session.total.input += tokens.input;
58
+ session.total.output += tokens.output;
59
+ session.total.reasoning += tokens.reasoning;
60
+ session.total.cacheRead += tokens.cacheRead;
61
+ session.total.cacheWrite += tokens.cacheWrite;
62
+
63
+ const key = `${provider}/${model}`;
64
+ let modelStats = session.byModel.get(key);
65
+ if (!modelStats) {
66
+ logToApp("info", "Creating new model stats", { key })
67
+ modelStats = {
68
+ provider,
69
+ model,
70
+ metrics: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
71
+ messageCount: 0,
72
+ };
73
+ session.byModel.set(key, modelStats);
74
+ }
75
+ modelStats.metrics.input += tokens.input;
76
+ modelStats.metrics.output += tokens.output;
77
+ modelStats.metrics.reasoning += tokens.reasoning;
78
+ modelStats.metrics.cacheRead += tokens.cacheRead;
79
+ modelStats.metrics.cacheWrite += tokens.cacheWrite;
80
+ modelStats.messageCount++;
81
+
82
+ logToApp("debug", "Tokens updated", { totalInput: session.total.input, totalOutput: session.total.output })
83
+ }
84
+
85
+ export function getAllSessions(): SessionUsage[] {
86
+ const sessions = Array.from(state.sessions.values());
87
+ logToApp("debug", "Returning total sessions", { count: sessions.length })
88
+ return sessions;
89
+ }
@@ -0,0 +1,30 @@
1
+ export interface TokenMetrics {
2
+ input: number;
3
+ output: number;
4
+ reasoning: number;
5
+ cacheRead: number;
6
+ cacheWrite: number;
7
+ }
8
+
9
+ export interface ModelStats {
10
+ provider: string;
11
+ model: string;
12
+ metrics: TokenMetrics;
13
+ messageCount: number;
14
+ }
15
+
16
+ export interface SessionUsage {
17
+ sessionID: string;
18
+ total: TokenMetrics;
19
+ byModel: Map<string, ModelStats>;
20
+ }
21
+
22
+ export interface UsageState {
23
+ sessions: Map<string, SessionUsage>;
24
+ currentSessionID: string | null;
25
+ }
26
+
27
+ export const state: UsageState = {
28
+ sessions: new Map(),
29
+ currentSessionID: null,
30
+ };
@@ -1,5 +1,7 @@
1
1
  import { sendIgnoredMessage } from "./shared/notification";
2
+ import { getCurrentSession, getAllSessions } from "./tracking/tracker";
2
3
  import { registry } from "./providers/registry";
4
+ import { boxHeader } from "./shared/utils";
3
5
 
4
6
  interface UsageContext {
5
7
  client: any;
@@ -9,47 +11,183 @@ interface UsageContext {
9
11
 
10
12
  /**
11
13
  * Main handler for the /usage command
12
- * Fetches data from all registered providers and displays to user
14
+ * Shows provider usage data and token usage tracked from message events
13
15
  */
14
16
  export async function handleUsageCommand(ctx: UsageContext): Promise<void> {
15
17
  const { client, sessionID, params } = ctx;
16
18
 
17
- // Gets all registered providers
18
- const providers = registry.getAll();
19
-
20
- // Fetches data from all providers in parallel
21
- const results = await Promise.all(
22
- providers.map(async (provider) => {
23
- try {
24
- const result = await provider.getUsageData();
25
- if (result && result.content) {
26
- return {
27
- provider: provider.name,
28
- content: result.content,
29
- };
19
+ try {
20
+ let output = "";
21
+
22
+ // Fetch provider data (Copilot, Claude, Z.ai)
23
+ const providers = registry.getAll();
24
+ const providerResults = await Promise.all(
25
+ providers.map(async (provider) => {
26
+ try {
27
+ const result = await provider.getUsageData();
28
+ if (result && result.content) {
29
+ return {
30
+ provider: provider.name,
31
+ content: result.content,
32
+ };
33
+ }
34
+ return null;
35
+ } catch (error) {
36
+ console.error(`[${provider.name} Error]`, error);
37
+ return null;
38
+ }
39
+ }),
40
+ );
41
+
42
+ const validProviderResults = providerResults.filter(
43
+ (r): r is { provider: string; content: string } => r !== null,
44
+ );
45
+
46
+ // Add provider data if available
47
+ if (validProviderResults.length > 0) {
48
+ output += validProviderResults.map((r) => r.content).join("\n\n");
49
+ output += "\n";
50
+ }
51
+
52
+ // Fetch and add token tracking data
53
+ const current = getCurrentSession();
54
+ const allSessions = getAllSessions();
55
+
56
+ if (allSessions.length === 0) {
57
+ if (validProviderResults.length === 0) {
58
+ output = "No usage data available yet.\nConfigure providers or start a conversation to track token usage.";
59
+ } else {
60
+ output += "\nNo token usage data available yet.\nStart a conversation to track token usage.";
61
+ }
62
+ } else {
63
+ if (current) {
64
+ output += formatSession(current, "Current Session");
65
+ } else {
66
+ output += "No active session.\n";
67
+ }
68
+
69
+ if (allSessions.length > 1) {
70
+ output += formatAllSessions(allSessions, "All Sessions");
71
+ }
72
+ }
73
+
74
+ await sendIgnoredMessage(client, sessionID, output);
75
+ } catch (error) {
76
+ console.error("Error in handleUsageCommand:", error);
77
+ }
78
+ }
79
+
80
+ function formatSession(session: any, title: string): string {
81
+ const { total, byModel } = session;
82
+
83
+ let output = `\n${boxHeader(title, 80)}\n`;
84
+
85
+ const totalTokens =
86
+ total.input + total.output + total.reasoning + total.cacheRead + total.cacheWrite;
87
+ output += ` Total Tokens: ${formatNumber(totalTokens)}\n`;
88
+
89
+ const messageCount =
90
+ byModel.size > 0
91
+ ? Array.from(byModel.values()).reduce((a: number, b: any) => a + b.messageCount, 0)
92
+ : 0;
93
+ output += ` Messages: ${formatNumber(messageCount)}\n`;
94
+
95
+ if (byModel.size > 0) {
96
+ output += `\n Models:\n`;
97
+ output += ` ${"─".repeat(76)}\n`;
98
+
99
+ const models = Array.from(byModel.values())
100
+ .sort((a: any, b: any) => {
101
+ const totalA = sumMetrics(a.metrics);
102
+ const totalB = sumMetrics(b.metrics);
103
+ return totalB - totalA;
104
+ });
105
+
106
+ models.forEach((modelStats: any) => {
107
+ const key = `${modelStats.provider}/${modelStats.model}`;
108
+ output += ` ${key}\n`;
109
+ output += ` ${formatMetricsLine(modelStats.metrics, modelStats.messageCount)}\n`;
110
+ });
111
+ }
112
+
113
+ return output;
114
+ }
115
+
116
+ function formatAllSessions(sessions: any[], title: string): string {
117
+ if (sessions.length <= 1) return "";
118
+
119
+ // Aggregate all sessions into a single summary
120
+ const aggregated = {
121
+ total: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
122
+ byModel: new Map<string, any>(),
123
+ };
124
+
125
+ sessions.forEach((session: any) => {
126
+ aggregated.total.input += session.total.input;
127
+ aggregated.total.output += session.total.output;
128
+ aggregated.total.reasoning += session.total.reasoning;
129
+ aggregated.total.cacheRead += session.total.cacheRead;
130
+ aggregated.total.cacheWrite += session.total.cacheWrite;
131
+
132
+ if (session.byModel && session.byModel.size > 0) {
133
+ for (const [key, modelStats] of session.byModel.entries()) {
134
+ const existing = aggregated.byModel.get(key);
135
+ if (existing) {
136
+ existing.metrics.input += modelStats.metrics.input;
137
+ existing.metrics.output += modelStats.metrics.output;
138
+ existing.metrics.reasoning += modelStats.metrics.reasoning;
139
+ existing.metrics.cacheRead += modelStats.metrics.cacheRead;
140
+ existing.metrics.cacheWrite += modelStats.metrics.cacheWrite;
141
+ existing.messageCount += modelStats.messageCount;
142
+ } else {
143
+ aggregated.byModel.set(key, {
144
+ provider: modelStats.provider,
145
+ model: modelStats.model,
146
+ metrics: { ...modelStats.metrics },
147
+ messageCount: modelStats.messageCount,
148
+ });
30
149
  }
31
- return null;
32
- } catch (error) {
33
- console.error(`[${provider.name} Error]`, error);
34
- return null;
35
150
  }
36
- }),
37
- );
38
-
39
- // Filters only providers with valid data
40
- const validResults = results.filter((r): r is { provider: string; content: string } => r !== null);
41
-
42
- // If no provider returned data, shows error message
43
- if (validResults.length === 0) {
44
- const message =
45
- "No usage service configured.\nConfigure GitHub Copilot, Claude Code or Z.ai in your auth file.";
46
- await sendIgnoredMessage(client, sessionID, message, params);
47
- return;
151
+ }
152
+ });
153
+
154
+ let output = `\n${boxHeader(`${title} (${sessions.length} sessions)`, 80)}\n`;
155
+
156
+ const totalTokens =
157
+ aggregated.total.input + aggregated.total.output + aggregated.total.reasoning +
158
+ aggregated.total.cacheRead + aggregated.total.cacheWrite;
159
+ output += ` Total Tokens: ${formatNumber(totalTokens)}\n`;
160
+
161
+ const messageCount = aggregated.byModel.size > 0
162
+ ? Array.from(aggregated.byModel.values()).reduce((a: number, b: any) => a + b.messageCount, 0)
163
+ : 0;
164
+ output += ` Messages: ${formatNumber(messageCount)}\n`;
165
+
166
+ if (aggregated.byModel.size > 0) {
167
+ output += `\n Models:\n`;
168
+ output += ` ${"─".repeat(76)}\n`;
169
+
170
+ const models = Array.from(aggregated.byModel.values())
171
+ .sort((a: any, b: any) => sumMetrics(b.metrics) - sumMetrics(a.metrics));
172
+
173
+ models.forEach((modelStats: any) => {
174
+ const key = `${modelStats.provider}/${modelStats.model}`;
175
+ output += ` ${key}\n`;
176
+ output += ` ${formatMetricsLine(modelStats.metrics, modelStats.messageCount)}\n`;
177
+ });
48
178
  }
49
179
 
50
- // Joins all provider messages
51
- const message = validResults.map((r) => r.content).join("\n\n");
180
+ return output;
181
+ }
182
+
183
+ function formatMetricsLine(metrics: any, messageCount: number): string {
184
+ return `In: ${formatNumber(metrics.input)} Out: ${formatNumber(metrics.output)} Rea: ${formatNumber(metrics.reasoning)} Cache-Rd: ${formatNumber(metrics.cacheRead)} Cache-Wr: ${formatNumber(metrics.cacheWrite)} Messages: ${formatNumber(messageCount)}`;
185
+ }
186
+
187
+ function formatNumber(num: number): string {
188
+ return num.toLocaleString("en-US");
189
+ }
52
190
 
53
- // Sends final message to user
54
- await sendIgnoredMessage(client, sessionID, message, params);
191
+ function sumMetrics(metrics: any): number {
192
+ return metrics.input + metrics.output + metrics.reasoning + metrics.cacheRead + metrics.cacheWrite;
55
193
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toninho09/opencode-usage",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "dependencies": {