opencode-usage-report 0.4.0 → 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.
- package/README.md +10 -1
- package/package.json +1 -2
- package/src/context/collect.ts +12 -53
- package/src/context/estimate.ts +2 -27
- package/src/context/format.ts +6 -38
- package/src/context/grid.ts +4 -18
- package/src/context/headroom.ts +1 -4
- package/src/context/system.ts +2 -34
- package/src/context/types.ts +0 -1
- package/src/fallback.ts +1 -6
- package/src/index.ts +172 -16
- package/src/normalize.ts +17 -27
- package/src/providers/chatgpt.ts +24 -78
- package/src/providers/copilot.ts +28 -86
- package/src/providers/http.ts +90 -0
- package/src/providers/kimi.ts +47 -59
- package/src/providers/opencode-go.ts +33 -89
- package/src/quota-lines.ts +4 -21
- package/src/render.ts +11 -14
- package/src/tui-format.ts +12 -0
- package/src/tui.tsx +4 -7
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
|
|
@@ -283,7 +289,7 @@ Releases are **staged**, not published directly. Pushing a `v*` tag triggers
|
|
|
283
289
|
the staged release with 2FA:
|
|
284
290
|
|
|
285
291
|
```sh
|
|
286
|
-
git tag v0.4.
|
|
292
|
+
git tag v0.4.1 && git push origin main v0.4.1
|
|
287
293
|
npm stage list # find the stage id
|
|
288
294
|
npm stage approve <stage-id> # 2FA prompt; or approve in the npm UI
|
|
289
295
|
```
|
|
@@ -293,6 +299,9 @@ so a failing check blocks the stage.
|
|
|
293
299
|
|
|
294
300
|
## Changelog
|
|
295
301
|
|
|
302
|
+
- **0.4.1** — internal: shared provider HTTP plumbing (`src/providers/http.ts`)
|
|
303
|
+
replaces the copy-pasted fetch/retry/error code; context estimates simplified;
|
|
304
|
+
reset times render as `Wed, Sep 16`.
|
|
296
305
|
- **0.4.0** — context usage panel (`/usage` now shows context breakdown, grid,
|
|
297
306
|
compaction headroom and cost); `/usage` became a local TUI command that costs
|
|
298
307
|
no model tokens; sidebar rendering extracted to `src/quota-lines.ts`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-usage-report",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -49,7 +49,6 @@
|
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@opencode-ai/plugin": "^1.0.0",
|
|
51
51
|
"@opentui/core": "^0.5.11",
|
|
52
|
-
"@opentui/keymap": "^0.5.11",
|
|
53
52
|
"@opentui/solid": "^0.5.11",
|
|
54
53
|
"@types/node": "^26.6.2",
|
|
55
54
|
"oxfmt": "^0.70.0",
|
package/src/context/collect.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AssistantMessage, Part } from "@opencode-ai/sdk/v2";
|
|
2
2
|
import type { CollectInput, ContextBreakdown, ContextRow, RowKey, SystemCapture } from "./types";
|
|
3
3
|
import { estimateJson, estimateTokens } from "./estimate";
|
|
4
|
+
import { finiteOrNull, isRecord, safeString } from "../normalize";
|
|
4
5
|
import { buildGrid } from "./grid";
|
|
5
6
|
import { computeHeadroom } from "./headroom";
|
|
6
7
|
|
|
@@ -15,24 +16,6 @@ const ROW_LABELS: Record<RowKey, string> = {
|
|
|
15
16
|
|
|
16
17
|
const MEASURED_KEYS: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
|
|
17
18
|
|
|
18
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
|
-
return typeof value === "object" && value !== null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function finiteOrNull(value: unknown): number | null {
|
|
23
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function safeString(value: unknown): string {
|
|
27
|
-
if (typeof value === "string") return value;
|
|
28
|
-
if (value === null || value === undefined) return "";
|
|
29
|
-
try {
|
|
30
|
-
return String(value);
|
|
31
|
-
} catch {
|
|
32
|
-
return "";
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
19
|
function isAssistant(value: unknown): value is AssistantMessage {
|
|
37
20
|
return isRecord(value) && value.role === "assistant";
|
|
38
21
|
}
|
|
@@ -72,49 +55,25 @@ function isUsableCapture(capture: SystemCapture | null | undefined): capture is
|
|
|
72
55
|
}
|
|
73
56
|
|
|
74
57
|
/**
|
|
75
|
-
* Scales `values` so they sum to `target
|
|
76
|
-
*
|
|
58
|
+
* Scales `values` proportionally so they sum to at most `target`; each row is an
|
|
59
|
+
* estimate, so flooring may leave the five rows a few tokens short of the total.
|
|
77
60
|
*/
|
|
78
|
-
function
|
|
79
|
-
values: readonly number[],
|
|
80
|
-
target: number,
|
|
81
|
-
divisor: number,
|
|
82
|
-
): number[] {
|
|
61
|
+
function scaleToTotal(values: readonly number[], target: number, divisor: number): number[] {
|
|
83
62
|
if (!Number.isFinite(divisor) || divisor <= 0) return values.map(() => 0);
|
|
84
63
|
const total = Math.max(0, Math.round(target));
|
|
85
|
-
|
|
86
|
-
const rounded = scaled.map((value) => Math.floor(value));
|
|
87
|
-
let deficit = total - rounded.reduce((sum, value) => sum + value, 0);
|
|
88
|
-
if (deficit <= 0) return rounded;
|
|
89
|
-
|
|
90
|
-
const order = scaled
|
|
91
|
-
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
|
|
92
|
-
.sort((a, b) => b.fraction - a.fraction);
|
|
93
|
-
for (const { index } of order) {
|
|
94
|
-
if (deficit <= 0) break;
|
|
95
|
-
rounded[index] += 1;
|
|
96
|
-
deficit -= 1;
|
|
97
|
-
}
|
|
98
|
-
return rounded;
|
|
64
|
+
return values.map((value) => (value > 0 ? Math.floor((value * total) / divisor) : 0));
|
|
99
65
|
}
|
|
100
66
|
|
|
101
|
-
function makeRow(
|
|
102
|
-
key: RowKey,
|
|
103
|
-
tokens: number | null,
|
|
104
|
-
exact: boolean,
|
|
105
|
-
limit: number | null,
|
|
106
|
-
): ContextRow {
|
|
67
|
+
function makeRow(key: RowKey, tokens: number | null, limit: number | null): ContextRow {
|
|
107
68
|
const percent =
|
|
108
69
|
tokens !== null && limit !== null && limit > 0
|
|
109
70
|
? Math.min(100, Math.max(0, Math.round((tokens / limit) * 100)))
|
|
110
71
|
: null;
|
|
111
|
-
return { key, label: ROW_LABELS[key], tokens, percent
|
|
72
|
+
return { key, label: ROW_LABELS[key], tokens, percent };
|
|
112
73
|
}
|
|
113
74
|
|
|
114
75
|
function emptyRows(limit: number | null): ContextRow[] {
|
|
115
|
-
return [...MEASURED_KEYS, "free" as RowKey].map((key) =>
|
|
116
|
-
makeRow(key, null, key === "free", limit),
|
|
117
|
-
);
|
|
76
|
+
return [...MEASURED_KEYS, "free" as RowKey].map((key) => makeRow(key, null, limit));
|
|
118
77
|
}
|
|
119
78
|
|
|
120
79
|
/**
|
|
@@ -212,9 +171,9 @@ export function collectContext(input: CollectInput): ContextBreakdown {
|
|
|
212
171
|
let systemTokens = Math.max(0, total - estimatedSum);
|
|
213
172
|
|
|
214
173
|
// chars/4 is an estimate in both directions; when it overshoots the exact
|
|
215
|
-
// total, scale all five measured rows down so they sum to `total` (6.5).
|
|
174
|
+
// total, scale all five measured rows down so they sum to at most `total` (6.5).
|
|
216
175
|
if (estimatedSum > total) {
|
|
217
|
-
const scaled =
|
|
176
|
+
const scaled = scaleToTotal(
|
|
218
177
|
[userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens],
|
|
219
178
|
total,
|
|
220
179
|
estimatedSum,
|
|
@@ -227,9 +186,9 @@ export function collectContext(input: CollectInput): ContextBreakdown {
|
|
|
227
186
|
}
|
|
228
187
|
|
|
229
188
|
const measured = [userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens];
|
|
230
|
-
const rows = MEASURED_KEYS.map((key, index) => makeRow(key, measured[index],
|
|
189
|
+
const rows = MEASURED_KEYS.map((key, index) => makeRow(key, measured[index], limit));
|
|
231
190
|
const freeTokens = limit !== null ? Math.max(0, limit - total) : null;
|
|
232
|
-
rows.push(makeRow("free", freeTokens,
|
|
191
|
+
rows.push(makeRow("free", freeTokens, limit));
|
|
233
192
|
|
|
234
193
|
return {
|
|
235
194
|
ready: true,
|
package/src/context/estimate.ts
CHANGED
|
@@ -5,32 +5,7 @@ export function estimateTokens(text: string): number {
|
|
|
5
5
|
return Math.max(0, Math.round(value.length / 4));
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Estimates tokens for the JSON serialization of an arbitrary value. Circular
|
|
12
|
-
* references and unserializable values are handled without throwing, and the
|
|
13
|
-
* serialized prefix is capped so allocation stays bounded.
|
|
14
|
-
*/
|
|
8
|
+
/** Estimates tokens for the JSON serialization of a value that came from JSON. */
|
|
15
9
|
export function estimateJson(value: unknown): number {
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
const seen = new WeakSet<object>();
|
|
19
|
-
json =
|
|
20
|
-
JSON.stringify(value, (_key, val) => {
|
|
21
|
-
if (typeof val === "bigint") return val.toString();
|
|
22
|
-
if (typeof val === "object" && val !== null) {
|
|
23
|
-
if (seen.has(val)) return "[Circular]";
|
|
24
|
-
seen.add(val);
|
|
25
|
-
}
|
|
26
|
-
return val;
|
|
27
|
-
}) ?? "";
|
|
28
|
-
} catch {
|
|
29
|
-
try {
|
|
30
|
-
json = String(value);
|
|
31
|
-
} catch {
|
|
32
|
-
json = "";
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return estimateTokens(json.length > MAX_JSON_CHARS ? json.slice(0, MAX_JSON_CHARS) : json);
|
|
10
|
+
return estimateTokens(JSON.stringify(value) ?? "");
|
|
36
11
|
}
|
package/src/context/format.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* same shape `buildLines` uses in `src/quota-lines.ts`.
|
|
5
5
|
*/
|
|
6
6
|
import type { ContextBreakdown, ContextRow, GridCell, RowKey } from "./types";
|
|
7
|
+
import { finiteOrNull, nonNegative } from "../normalize";
|
|
8
|
+
import { padLabel } from "../tui-format";
|
|
7
9
|
|
|
8
10
|
/** Theme color names used by the context block. */
|
|
9
11
|
export type ContextTone =
|
|
@@ -40,15 +42,6 @@ const LEGEND_PERCENT_WIDTH = 6;
|
|
|
40
42
|
const LEGEND_GAP = " ";
|
|
41
43
|
const COLUMNS_PER_LEGEND_ROW = 3;
|
|
42
44
|
|
|
43
|
-
const ROW_LABELS: Record<RowKey, string> = {
|
|
44
|
-
user: "User messages",
|
|
45
|
-
agent: "Agent responses",
|
|
46
|
-
reasoning: "Reasoning",
|
|
47
|
-
tools: "Tool calls",
|
|
48
|
-
system: "System & tools",
|
|
49
|
-
free: "Free space",
|
|
50
|
-
};
|
|
51
|
-
|
|
52
45
|
const CATEGORY_TONES: Record<RowKey, ContextTone> = {
|
|
53
46
|
user: "info",
|
|
54
47
|
agent: "success",
|
|
@@ -79,15 +72,6 @@ export function formatMoney(n: number): string {
|
|
|
79
72
|
return MONEY.format(value);
|
|
80
73
|
}
|
|
81
74
|
|
|
82
|
-
function finite(value: unknown): number | null {
|
|
83
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function nonNegative(value: unknown): number | null {
|
|
87
|
-
const n = finite(value);
|
|
88
|
-
return n !== null && n >= 0 ? n : null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
75
|
function glyphFor(fill: number): string {
|
|
92
76
|
if (fill <= 0) return "░░";
|
|
93
77
|
if (fill <= 0.25) return "▒▒";
|
|
@@ -100,19 +84,6 @@ function toneForRowKey(key: unknown): ContextTone {
|
|
|
100
84
|
return CATEGORY_TONES[key as RowKey] ?? "borderSubtle";
|
|
101
85
|
}
|
|
102
86
|
|
|
103
|
-
function truncateLabel(label: string, width: number): string {
|
|
104
|
-
if (label.length <= width) return label;
|
|
105
|
-
return label.slice(0, Math.max(0, width - 1)) + "…";
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function padLabel(label: string, width: number): string {
|
|
109
|
-
return truncateLabel(label, width).padEnd(width);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function rightAlign(value: string, width: number): string {
|
|
113
|
-
return value.length >= width ? value : " ".repeat(width - value.length) + value;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
87
|
/**
|
|
117
88
|
* One-decimal percentage. A non-zero share that would round to `0.0%` reads as
|
|
118
89
|
* "nothing at all", so anything under a tenth of a percent collapses to `<0.1%`.
|
|
@@ -144,10 +115,7 @@ function rowMap(breakdown: ContextBreakdown): Map<RowKey, ContextRow> {
|
|
|
144
115
|
}
|
|
145
116
|
|
|
146
117
|
function legendEntry(key: RowKey, row: ContextRow | undefined, systemDerived: boolean): Segment[] {
|
|
147
|
-
const baseLabel =
|
|
148
|
-
row !== undefined && typeof row.label === "string" && row.label !== ""
|
|
149
|
-
? row.label
|
|
150
|
-
: ROW_LABELS[key];
|
|
118
|
+
const baseLabel = row?.label ?? key;
|
|
151
119
|
const label = key === "system" && systemDerived ? `${baseLabel} (derived)` : baseLabel;
|
|
152
120
|
const tokens = nonNegative(row?.tokens);
|
|
153
121
|
const percent = nonNegative(row?.percent);
|
|
@@ -156,11 +124,11 @@ function legendEntry(key: RowKey, row: ContextRow | undefined, systemDerived: bo
|
|
|
156
124
|
{ text: "● ", fg: CATEGORY_TONES[key] },
|
|
157
125
|
{ text: padLabel(label, LEGEND_LABEL_WIDTH), fg: "text" },
|
|
158
126
|
{
|
|
159
|
-
text:
|
|
127
|
+
text: (tokens === null ? "—" : formatTokens(tokens)).padStart(LEGEND_COUNT_WIDTH, " "),
|
|
160
128
|
fg: "text",
|
|
161
129
|
},
|
|
162
130
|
{ text: " ", fg: "text" },
|
|
163
|
-
{ text:
|
|
131
|
+
{ text: percentText.padStart(LEGEND_PERCENT_WIDTH, " "), fg: "textMuted" },
|
|
164
132
|
];
|
|
165
133
|
}
|
|
166
134
|
|
|
@@ -178,7 +146,7 @@ function gridLines(breakdown: ContextBreakdown): Line[] {
|
|
|
178
146
|
const segments: Segment[] = [];
|
|
179
147
|
for (let c = 0; c < renderCols; c++) {
|
|
180
148
|
const cell = cells[r * cols + c] as GridCell | undefined;
|
|
181
|
-
const fill = cell === undefined ? 0 : Math.min(1, Math.max(0,
|
|
149
|
+
const fill = cell === undefined ? 0 : Math.min(1, Math.max(0, finiteOrNull(cell.fill) ?? 0));
|
|
182
150
|
segments.push({ text: glyphFor(fill), fg: toneForRowKey(cell?.rowKey) });
|
|
183
151
|
}
|
|
184
152
|
lines.push({ segments });
|
package/src/context/grid.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ContextRow, GridCell, RowKey } from "./types";
|
|
2
|
+
import { finiteOrNull } from "../normalize";
|
|
2
3
|
|
|
3
4
|
const GRID_ROWS_DEFAULT = 6;
|
|
4
|
-
const
|
|
5
|
-
const MAX_COLS = 60;
|
|
5
|
+
const GRID_COLS_DEFAULT = 8;
|
|
6
6
|
const CATEGORY_ORDER: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
|
|
7
7
|
|
|
8
8
|
export interface BuildGridInput {
|
|
@@ -13,20 +13,6 @@ export interface BuildGridInput {
|
|
|
13
13
|
gridRows?: number | null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function finiteOrNull(value: unknown): number | null {
|
|
17
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function clampCols(value: number | null | undefined): number {
|
|
21
|
-
const n = finiteOrNull(value);
|
|
22
|
-
return Math.min(MAX_COLS, Math.max(MIN_COLS, n === null ? MIN_COLS : Math.floor(n)));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function clampGridRows(value: number | null | undefined): number {
|
|
26
|
-
const n = finiteOrNull(value);
|
|
27
|
-
return n !== null && Math.floor(n) >= 1 ? Math.floor(n) : GRID_ROWS_DEFAULT;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
16
|
function rowTokens(rows: readonly ContextRow[], key: RowKey): number {
|
|
31
17
|
if (!Array.isArray(rows)) return 0;
|
|
32
18
|
for (const row of rows) {
|
|
@@ -58,8 +44,8 @@ export function buildGrid(input: BuildGridInput): {
|
|
|
58
44
|
rows: number;
|
|
59
45
|
cells: GridCell[];
|
|
60
46
|
} {
|
|
61
|
-
const cols =
|
|
62
|
-
const rows =
|
|
47
|
+
const cols = finiteOrNull(input.cols) ?? GRID_COLS_DEFAULT;
|
|
48
|
+
const rows = finiteOrNull(input.gridRows) ?? GRID_ROWS_DEFAULT;
|
|
63
49
|
const limit = finiteOrNull(input.limit);
|
|
64
50
|
if (limit === null || limit <= 0) return { cols, rows, cells: [] };
|
|
65
51
|
|
package/src/context/headroom.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Model } from "@opencode-ai/sdk/v2";
|
|
2
|
+
import { finiteOrNull } from "../normalize";
|
|
2
3
|
|
|
3
4
|
export interface Headroom {
|
|
4
5
|
usable: number | null;
|
|
@@ -12,10 +13,6 @@ export interface HeadroomInput {
|
|
|
12
13
|
compaction?: { reserved?: number } | null;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
function finiteOrNull(value: unknown): number | null {
|
|
16
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
16
|
/** Compaction headroom, mirroring opencode's overflow check; nulls when unusable. */
|
|
20
17
|
export function computeHeadroom(input: HeadroomInput): Headroom {
|
|
21
18
|
const limit = input.limit;
|
package/src/context/system.ts
CHANGED
|
@@ -7,23 +7,12 @@
|
|
|
7
7
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import type { SystemCapture } from "./types";
|
|
10
|
+
import { isRecord, nonNegative } from "../normalize";
|
|
10
11
|
import { pluginStateDir } from "../paths";
|
|
11
12
|
|
|
12
13
|
const CAPTURE_VERSION = 1;
|
|
13
|
-
const WRITE_THROTTLE_MS = 60_000;
|
|
14
14
|
const MAX_SESSION_ID_LENGTH = 200;
|
|
15
15
|
|
|
16
|
-
/** sessionID -> epoch ms of the last successful write, for throttling. */
|
|
17
|
-
const writeThrottle = new Map<string, number>();
|
|
18
|
-
|
|
19
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function nonNegative(value: unknown): number | null {
|
|
24
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
16
|
/** <pluginStateDir>/context/<sanitized-sessionID>.json */
|
|
28
17
|
export function contextCapturePath(sessionID: string, env?: NodeJS.ProcessEnv): string {
|
|
29
18
|
const raw = typeof sessionID === "string" ? sessionID : "";
|
|
@@ -35,10 +24,6 @@ export function contextCapturePath(sessionID: string, env?: NodeJS.ProcessEnv):
|
|
|
35
24
|
* Reads and validates a capture. Returns null for a missing/unreadable file,
|
|
36
25
|
* invalid JSON, an unsupported version, a session mismatch, non-string
|
|
37
26
|
* provider/model, or non-finite/negative sizes and timestamps.
|
|
38
|
-
*
|
|
39
|
-
* Deliberately independent of the write-throttle map: the server plugin writes
|
|
40
|
-
* and the TUI plugin reads in separate processes, so a reader can never have a
|
|
41
|
-
* throttle entry of its own.
|
|
42
27
|
*/
|
|
43
28
|
export async function readCapture(
|
|
44
29
|
sessionID: string,
|
|
@@ -74,10 +59,7 @@ export async function readCapture(
|
|
|
74
59
|
}
|
|
75
60
|
}
|
|
76
61
|
|
|
77
|
-
/**
|
|
78
|
-
* Atomically persists a capture (temp file + rename), throttled to at most one
|
|
79
|
-
* write per session per 60 s unless the provider/model changed. Never throws.
|
|
80
|
-
*/
|
|
62
|
+
/** Atomically persists a capture (temp file + rename). Never throws. */
|
|
81
63
|
export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessEnv): Promise<void> {
|
|
82
64
|
try {
|
|
83
65
|
if (!isRecord(capture)) return;
|
|
@@ -92,14 +74,6 @@ export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessE
|
|
|
92
74
|
const capturedAt = nonNegative(capture.capturedAt);
|
|
93
75
|
if (systemChars === null || systemTokens === null || capturedAt === null) return;
|
|
94
76
|
|
|
95
|
-
const previous = await readCapture(sessionID, env);
|
|
96
|
-
const modelChanged =
|
|
97
|
-
previous === null || previous.providerID !== providerID || previous.modelID !== modelID;
|
|
98
|
-
|
|
99
|
-
const now = Date.now();
|
|
100
|
-
const last = writeThrottle.get(sessionID);
|
|
101
|
-
if (!modelChanged && typeof last === "number" && now - last < WRITE_THROTTLE_MS) return;
|
|
102
|
-
|
|
103
77
|
const directory = join(pluginStateDir(env), "context");
|
|
104
78
|
await mkdir(directory, { recursive: true });
|
|
105
79
|
const file = contextCapturePath(sessionID, env);
|
|
@@ -115,7 +89,6 @@ export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessE
|
|
|
115
89
|
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
116
90
|
await writeFile(tmp, JSON.stringify(normalized), "utf8");
|
|
117
91
|
await rename(tmp, file);
|
|
118
|
-
writeThrottle.set(sessionID, now);
|
|
119
92
|
} catch {
|
|
120
93
|
// Capture writes are best-effort; a failure must never break a request.
|
|
121
94
|
}
|
|
@@ -140,8 +113,3 @@ export function captureIsFresh(
|
|
|
140
113
|
if (capturedAt === null || since === null) return false;
|
|
141
114
|
return capturedAt >= since;
|
|
142
115
|
}
|
|
143
|
-
|
|
144
|
-
/** Clears the write-throttle map so tests can reset between cases. */
|
|
145
|
-
export function resetCaptureThrottle(): void {
|
|
146
|
-
writeThrottle.clear();
|
|
147
|
-
}
|
package/src/context/types.ts
CHANGED
package/src/fallback.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import type { AdapterResult, UsageWindow } from "./types";
|
|
3
3
|
import { dbPath } from "./paths";
|
|
4
|
+
import { asRecord } from "./providers/http";
|
|
4
5
|
|
|
5
6
|
const HOUR_MS = 60 * 60 * 1000;
|
|
6
7
|
const DAY_MS = 24 * HOUR_MS;
|
|
@@ -15,12 +16,6 @@ interface SqliteDatabase {
|
|
|
15
16
|
}
|
|
16
17
|
type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => SqliteDatabase;
|
|
17
18
|
|
|
18
|
-
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
19
|
-
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
20
|
-
? (value as Record<string, unknown>)
|
|
21
|
-
: null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
19
|
function toNum(value: unknown): number | null {
|
|
25
20
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
26
21
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
287
|
+
export default definition;
|