opencode-usage-report 0.4.1 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/src/index.ts +172 -16
package/README.md CHANGED
@@ -71,6 +71,12 @@ Or add the plugin manually to each file and restart opencode:
71
71
  > `opencode plugin opencode-usage-report` prints `Detected server + tui targets`
72
72
  > when the resolution works.
73
73
 
74
+ > **OpenCode 2.x (0.5.0).** The server entrypoint default-exports the V2
75
+ > `{ id, setup }` definition and still carries the V1 `server` hook, so
76
+ > OpenCode 1.18.29+ and 2.x both load it. Before 0.5.0 the module exported a
77
+ > bare V1 function, which OpenCode 2 rejects. The TUI entrypoint is unchanged
78
+ > in this release.
79
+
74
80
  Options can be passed in the tuple form, independently per entrypoint:
75
81
 
76
82
  ```jsonc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-report",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
5
5
  "keywords": [
6
6
  "kimi",
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
1
+ import type { Hooks, PluginInput } from "@opencode-ai/plugin";
2
2
  import { tool } from "@opencode-ai/plugin";
3
3
  import { DEFAULT_OPTIONS, collectReports } from "./report";
4
4
  import { renderJson, renderText } from "./render";
@@ -39,12 +39,16 @@ export function coerceOptions(raw: Record<string, unknown> | undefined): PluginO
39
39
  return opts;
40
40
  }
41
41
 
42
- const plugin: Plugin = async ({ client }, options) => {
43
- const opts = coerceOptions(options);
42
+ /**
43
+ * Throttled warning pass shared by the V1 and V2 plugin APIs (spec §3.6/§3.7).
44
+ * Every failure is swallowed (spec §4): it must never throw into the event bus.
45
+ */
46
+ function makeWarnRunner(
47
+ opts: PluginOptions,
48
+ notify: (message: string) => Promise<unknown> | unknown,
49
+ ): () => Promise<void> {
44
50
  let lastWarnCheck = 0;
45
-
46
- /** Throttled warning pass (spec §3.7). Every failure is swallowed (spec §4). */
47
- const runWarnCheck = async (): Promise<void> => {
51
+ return async (): Promise<void> => {
48
52
  try {
49
53
  if (Date.now() - lastWarnCheck < WARN_THROTTLE_MS) return;
50
54
  lastWarnCheck = Date.now();
@@ -53,9 +57,7 @@ const plugin: Plugin = async ({ client }, options) => {
53
57
  const hits = await checkWarnings(reports, opts.thresholdPercent);
54
58
  for (const hit of hits) {
55
59
  try {
56
- await client.tui.showToast({
57
- body: { title: "Usage warning", message: hit.message, variant: "warning" },
58
- });
60
+ await notify(hit.message);
59
61
  } catch {
60
62
  // Toast delivery is best-effort; it is unavailable in headless/server mode.
61
63
  }
@@ -64,6 +66,159 @@ const plugin: Plugin = async ({ client }, options) => {
64
66
  // Never throw into the event bus.
65
67
  }
66
68
  };
69
+ }
70
+
71
+ async function runUsageReport(
72
+ args: Record<string, unknown> | undefined,
73
+ opts: PluginOptions,
74
+ ): Promise<{ content: string }> {
75
+ const reports = await collectReports({
76
+ providers: args?.provider ? [String(args.provider)] : undefined,
77
+ refresh: args?.refresh === true,
78
+ options: opts,
79
+ });
80
+ return { content: args?.json === true ? renderJson(reports) : renderText(reports) };
81
+ }
82
+
83
+ const TOOL_INPUT_SCHEMA: Record<string, unknown> = {
84
+ type: "object",
85
+ properties: {
86
+ provider: { type: "string" },
87
+ json: { type: "boolean" },
88
+ refresh: { type: "boolean" },
89
+ },
90
+ additionalProperties: false,
91
+ };
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // OpenCode 2.x (V2) plugin API.
95
+ //
96
+ // The harness decodes the module's default export against `{ id, setup }` or
97
+ // `{ id, effect }` and otherwise fails with "Plugin must export a default
98
+ // definition with an id and an effect or setup function" (err_b890e30e). The
99
+ // context types are declared locally so the package needs no runtime dependency
100
+ // on the V2 SDK; opencode erases `import type` anyway.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ interface V2ToolDefinition {
104
+ name: string;
105
+ description: string;
106
+ input: Record<string, unknown>;
107
+ execute: (args: Record<string, unknown> | undefined) => Promise<{ content: string }>;
108
+ }
109
+
110
+ interface V2ToolEditor {
111
+ add(definition: V2ToolDefinition): void;
112
+ }
113
+
114
+ interface V2ContextEvent {
115
+ sessionID?: string;
116
+ model?: { providerID?: string; id?: string };
117
+ system?: Array<{ type?: string; text?: string } | string>;
118
+ }
119
+
120
+ interface V2PluginContext {
121
+ options?: Record<string, unknown>;
122
+ tool: { transform: (callback: (editor: V2ToolEditor) => void) => Promise<unknown> };
123
+ session: {
124
+ hook: (name: "context", handler: (event: V2ContextEvent) => unknown) => Promise<unknown>;
125
+ };
126
+ event: { subscribe: (options: { signal: AbortSignal }) => AsyncIterable<{ type?: string }> };
127
+ /** Not part of the V2 server context; present only if the runtime exposes it. */
128
+ tui?: { showToast?: (input: unknown) => Promise<unknown> };
129
+ }
130
+
131
+ /** Joins V2 system parts (text parts plus plain strings) into one prompt string. */
132
+ function joinSystem(event: V2ContextEvent | undefined): string {
133
+ if (!Array.isArray(event?.system)) return "";
134
+ return event.system
135
+ .map((part) => (typeof part === "string" ? part : (part?.text ?? "")))
136
+ .join("\n");
137
+ }
138
+
139
+ /** Best-effort capture of the assembled system prompt size (spec §9.1). */
140
+ async function captureSystemFromContext(event: V2ContextEvent | undefined): Promise<void> {
141
+ try {
142
+ const sessionID = event?.sessionID;
143
+ const model = event?.model;
144
+ if (typeof sessionID !== "string" || sessionID === "") return;
145
+ if (
146
+ model === undefined ||
147
+ typeof model.providerID !== "string" ||
148
+ typeof model.id !== "string"
149
+ ) {
150
+ return;
151
+ }
152
+
153
+ const system = joinSystem(event);
154
+ if (system === "") return;
155
+
156
+ await writeCapture({
157
+ version: 1,
158
+ sessionID,
159
+ providerID: model.providerID,
160
+ modelID: model.id,
161
+ systemChars: system.length,
162
+ systemTokens: estimateTokens(system),
163
+ capturedAt: Date.now(),
164
+ });
165
+ } catch {
166
+ // Best-effort: an experimental hook must never be able to break a request.
167
+ }
168
+ }
169
+
170
+ async function setup(ctx: V2PluginContext): Promise<() => void> {
171
+ const opts = coerceOptions(ctx?.options);
172
+
173
+ await ctx.tool.transform((editor) => {
174
+ editor.add({
175
+ name: "usage_report",
176
+ description: TOOL_DESCRIPTION,
177
+ input: TOOL_INPUT_SCHEMA,
178
+ execute: (args) => runUsageReport(args, opts),
179
+ });
180
+ });
181
+
182
+ await ctx.session.hook("context", (event) => captureSystemFromContext(event));
183
+
184
+ const runWarnCheck = makeWarnRunner(opts, (message) => {
185
+ const showToast = ctx.tui?.showToast;
186
+ if (typeof showToast !== "function") return;
187
+ return showToast({ body: { title: "Usage warning", message, variant: "warning" } });
188
+ });
189
+
190
+ // Spec §3.6: one throttled warning check at startup, fire-and-forget.
191
+ setTimeout(() => {
192
+ void runWarnCheck();
193
+ }, 0);
194
+
195
+ const controller = new AbortController();
196
+ void (async () => {
197
+ try {
198
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
199
+ if (event?.type === "session.idle") await runWarnCheck();
200
+ }
201
+ } catch {
202
+ // Subscription aborted on unload, or the event transport failed.
203
+ }
204
+ })();
205
+
206
+ return () => controller.abort();
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // OpenCode 1.x (V1) plugin API. Kept from the previous release so packages can
211
+ // serve both APIs during the V2 migration; V2 calls `setup`, V1 calls `server`.
212
+ // ---------------------------------------------------------------------------
213
+
214
+ async function server({ client }: PluginInput, options?: Record<string, unknown>): Promise<Hooks> {
215
+ const opts = coerceOptions(options);
216
+
217
+ const runWarnCheck = makeWarnRunner(opts, (message) =>
218
+ client.tui.showToast({
219
+ body: { title: "Usage warning", message, variant: "warning" },
220
+ }),
221
+ );
67
222
 
68
223
  // Spec §3.6: one throttled warning check at startup, fire-and-forget.
69
224
  setTimeout(() => {
@@ -80,12 +235,7 @@ const plugin: Plugin = async ({ client }, options) => {
80
235
  refresh: tool.schema.boolean().optional(),
81
236
  },
82
237
  async execute(args) {
83
- const reports = await collectReports({
84
- providers: args.provider ? [args.provider] : undefined,
85
- refresh: args.refresh === true,
86
- options: opts,
87
- });
88
- return args.json ? renderJson(reports) : renderText(reports);
238
+ return (await runUsageReport(args, opts)).content;
89
239
  },
90
240
  }),
91
241
  },
@@ -126,6 +276,12 @@ const plugin: Plugin = async ({ client }, options) => {
126
276
  }
127
277
  },
128
278
  };
279
+ }
280
+
281
+ const definition = {
282
+ id: "opencode-usage-report",
283
+ setup,
284
+ server,
129
285
  };
130
286
 
131
- export default plugin;
287
+ export default definition;