opencode-usage-report 0.6.1 → 0.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.
package/README.md CHANGED
@@ -302,6 +302,18 @@ so a failing check blocks the stage.
302
302
 
303
303
  ## Changelog
304
304
 
305
+ - **0.6.3** — Kimi Code CN (`kimi-code-plan-cn`): the 5-hour window falls back
306
+ to the `usages.limit_5h` ratio when the `limits[]` row carries no counts,
307
+ instead of rendering an empty `—` bar; a monthly window is also read from a
308
+ counted 30-day `limits[]` entry when the endpoint reports it there. Includes
309
+ the outstanding Dependabot dev-dependency bumps.
310
+ - **0.6.2** — OpenCode 2 context panel fixes: the V2 adapter now reads the
311
+ model id from the store's `model.id`, so the model lookup succeeds and the
312
+ cell grid, per-category percentages and compaction-headroom line render
313
+ again instead of `—`; the translated messages also carry the session id, and
314
+ the system-prompt capture anchors its freshness check to the turn's user
315
+ message — OpenCode 2 fires the capture hook just before the assistant
316
+ message is stamped, which previously marked every capture `(derived)`.
305
317
  - **0.6.1** — Kimi Code adapter tracks the 2026-09 quota-model API change:
306
318
  usage windows now come from the `usages.limit_5h` / `limit_7d` /
307
319
  `limit_month_total` ratios (with a `limit_month_code` breakdown), and Extra
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-report",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
5
5
  "keywords": [
6
6
  "kimi",
@@ -48,10 +48,10 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@opencode-ai/plugin": "^1.0.0",
51
- "@opentui/core": "^0.5.11",
51
+ "@opentui/core": "^0.6.2",
52
52
  "@opentui/solid": "^0.5.11",
53
53
  "@types/node": "^26.6.2",
54
- "oxfmt": "^0.70.0",
54
+ "oxfmt": "^0.72.0",
55
55
  "oxlint": "^1.85.0",
56
56
  "solid-js": "1.9.12",
57
57
  "tsx": "^4.19.0",
@@ -63,5 +63,9 @@
63
63
  "@opentui/core": ">=0.5.11",
64
64
  "@opentui/solid": ">=0.5.11",
65
65
  "solid-js": ">=1.9.0"
66
+ },
67
+ "overrides": {
68
+ "seroval": "1.6.9",
69
+ "seroval-plugins": "1.6.9"
66
70
  }
67
71
  }
@@ -4,7 +4,7 @@ import type { AssistantMessage, Model } from "@opencode-ai/sdk/v2";
4
4
  import { For, Show, createMemo } from "solid-js";
5
5
  import { buildContextLines, type ContextTone } from "./format";
6
6
  import { collectContext } from "./collect";
7
- import { captureIsFresh } from "./system";
7
+ import { captureIsFresh, captureSince } from "./system";
8
8
  import type { ContextBreakdown, SystemCapture } from "./types";
9
9
  import { buildLines } from "../quota-lines";
10
10
  import type { ProviderReport } from "../types";
@@ -98,7 +98,7 @@ export function UsageDialog(props: UsageDialogProps) {
98
98
  sessionID: last.sessionID,
99
99
  providerID: last.providerID,
100
100
  modelID: last.modelID,
101
- since: last.time?.created ?? 0,
101
+ since: captureSince(messages(), last),
102
102
  });
103
103
  return ok ? capture : null;
104
104
  });
@@ -110,6 +110,42 @@ export function captureIsFresh(
110
110
  if (capture.modelID !== ref.modelID) return false;
111
111
  const capturedAt = nonNegative(capture.capturedAt);
112
112
  const since = nonNegative(ref.since);
113
- if (capturedAt === null || since === null) return false;
113
+ // A zero boundary means the caller had no usable timestamp — treat it as
114
+ // unusable rather than "anything is fresh" (epoch 0 is never a real turn).
115
+ if (capturedAt === null || since === null || since === 0) return false;
114
116
  return capturedAt >= since;
115
117
  }
118
+
119
+ function messageCreated(value: unknown): number | null {
120
+ if (!isRecord(value)) return null;
121
+ const time = isRecord(value.time) ? value.time : {};
122
+ const created = nonNegative(time.created);
123
+ return created !== null && created > 0 ? created : null;
124
+ }
125
+
126
+ /**
127
+ * The `since` boundary for `captureIsFresh`: the creation time of the user
128
+ * message that started the turn `reference` belongs to, falling back to the
129
+ * reference's own creation time. Returns `0` when neither carries a usable
130
+ * timestamp, which `captureIsFresh` rejects.
131
+ *
132
+ * OpenCode 2 assembles the request — firing the system-transform hook — just
133
+ * before the assistant message row gets its `time.created`, so a capture from
134
+ * the current turn is a few milliseconds *older* than the message and the old
135
+ * `capturedAt >= message.created` boundary rejected every fresh capture. The
136
+ * turn's user message predates request assembly under both OpenCode 1 and 2,
137
+ * so it is an ordering-stable boundary; a capture written for an earlier turn
138
+ * still sits before it and is still rejected.
139
+ */
140
+ export function captureSince(messages: readonly unknown[], reference: unknown): number {
141
+ const list = Array.isArray(messages) ? messages : [];
142
+ const index = list.indexOf(reference);
143
+ const start = index === -1 ? list.length : index;
144
+ for (let i = start - 1; i >= 0; i--) {
145
+ const message = list[i];
146
+ if (!isRecord(message) || message.role !== "user") continue;
147
+ const created = messageCreated(message);
148
+ if (created !== null) return created;
149
+ }
150
+ return messageCreated(reference) ?? 0;
151
+ }
@@ -161,13 +161,18 @@ function parse(body: Record<string, unknown>, cred: Credential): AdapterResult {
161
161
 
162
162
  // Count-based windows are authoritative when present; the `usages.limit_*`
163
163
  // ratios are the primary wire on the post-2026-09 quota model, where the
164
- // absolute `usage`/`limits[]` rows were removed.
164
+ // absolute `usage`/`limits[]` rows were removed. Some regional responses
165
+ // send a limits[] 5h row with no counts, so fall back to its ratio instead
166
+ // of rendering an empty bar.
165
167
  const detail5h = findLimitDetail(limits, 300);
166
- if (detail5h !== undefined) {
167
- windows.push(detailWindow("5h", "5-hour", detail5h));
168
- } else {
169
- const limit5h = pick(usages, "limit_5h");
170
- if (limit5h !== undefined) windows.push(ratioWindow("5h", "5-hour", limit5h));
168
+ const limit5h = pick(usages, "limit_5h");
169
+ const fiveHour = detail5h === undefined ? undefined : detailWindow("5h", "5-hour", detail5h);
170
+ if (fiveHour !== undefined && fiveHour.usedPercent !== null) {
171
+ windows.push(fiveHour);
172
+ } else if (limit5h !== undefined) {
173
+ windows.push(ratioWindow("5h", "5-hour", limit5h));
174
+ } else if (fiveHour !== undefined) {
175
+ windows.push(fiveHour);
171
176
  }
172
177
 
173
178
  if (usage !== undefined) {
@@ -179,12 +184,16 @@ function parse(body: Record<string, unknown>, cred: Credential): AdapterResult {
179
184
 
180
185
  // The monthly total joined the quota model; `totalQuota` is the legacy shape.
181
186
  // `limit_month_code` is the code-typed share the official client shows as a
182
- // breakdown under the monthly row.
187
+ // breakdown under the monthly row. Some regional responses carry the monthly
188
+ // window as a counted 30-day entry in `limits[]` instead.
183
189
  const monthTotal = pick(usages, "limit_month_total");
190
+ const detailMonth = findLimitDetail(limits, 43200);
184
191
  if (monthTotal !== undefined) {
185
192
  windows.push(ratioWindow("monthly", "Monthly", monthTotal));
186
193
  const codePercent = ratioPercent(pick(usages, "limit_month_code"));
187
194
  if (codePercent !== null) extras["Monthly code"] = `${codePercent}%`;
195
+ } else if (detailMonth !== undefined) {
196
+ windows.push(detailWindow("monthly", "Monthly", detailMonth));
188
197
  } else {
189
198
  const totalQuota = pick(body, "totalQuota", "total_quota");
190
199
  const quotaUsed = toNumber(pick(totalQuota, "used"));
package/src/v2-tui.ts CHANGED
@@ -62,6 +62,8 @@ export interface V2Route {
62
62
 
63
63
  export interface V2ModelRef {
64
64
  readonly providerID?: string;
65
+ /** The OpenCode 2 store spells the model id `id`; older mirrors used `modelID`. */
66
+ readonly id?: string;
65
67
  readonly modelID?: string;
66
68
  }
67
69
 
@@ -249,7 +251,10 @@ function createdAt(message: { readonly time?: { readonly created?: number } }):
249
251
  * messages carry a single `text` and an optional file list, so those are
250
252
  * synthesized into the same part shape.
251
253
  */
252
- function translateMessage(raw: unknown): {
254
+ function translateMessage(
255
+ raw: unknown,
256
+ sessionID: string,
257
+ ): {
253
258
  record: Record<string, unknown>;
254
259
  parts: Array<Record<string, unknown>>;
255
260
  } {
@@ -264,9 +269,12 @@ function translateMessage(raw: unknown): {
264
269
  return {
265
270
  record: {
266
271
  id,
272
+ sessionID,
267
273
  role: "assistant",
268
274
  providerID: safeString(model.providerID),
269
- modelID: safeString(model.modelID),
275
+ // The store sends `model.id` (e.g. `{ id: "k3", providerID, variant }`);
276
+ // `model.modelID` is kept as a fallback for older hosts and fixtures.
277
+ modelID: safeString(model.modelID) || safeString(model.id),
270
278
  time: createdAt(message),
271
279
  tokens: {
272
280
  input: finiteOrNull(tokens.input) ?? 0,
@@ -294,7 +302,7 @@ function translateMessage(raw: unknown): {
294
302
  }
295
303
  }
296
304
  return {
297
- record: { id, role: "user", time: createdAt(message), tokens: { ...ZERO_TOKENS } },
305
+ record: { id, sessionID, role: "user", time: createdAt(message), tokens: { ...ZERO_TOKENS } },
298
306
  parts,
299
307
  };
300
308
  }
@@ -362,7 +370,7 @@ export function createV2Api(ctx: V2Context): TuiPluginApi {
362
370
  const messages: Array<Record<string, unknown>> = [];
363
371
  partsById.clear();
364
372
  for (const raw of list) {
365
- const { record, parts } = translateMessage(raw);
373
+ const { record, parts } = translateMessage(raw, sessionID);
366
374
  if (typeof record.id !== "string" || record.id === "") continue;
367
375
  messages.push(record);
368
376
  partsById.set(record.id, parts);