opencode-usage-report 0.2.0 → 0.4.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 +218 -73
- package/package.json +43 -35
- package/src/auth.ts +30 -7
- package/src/cache.ts +2 -2
- package/src/context/collect.ts +251 -0
- package/src/context/dialog.tsx +184 -0
- package/src/context/estimate.ts +36 -0
- package/src/context/format.ts +272 -0
- package/src/context/grid.ts +90 -0
- package/src/context/headroom.ts +42 -0
- package/src/context/system.ts +147 -0
- package/src/context/types.ts +51 -0
- package/src/fallback.ts +2 -2
- package/src/index.ts +33 -15
- package/src/normalize.ts +1 -1
- package/src/providers/chatgpt.ts +194 -0
- package/src/providers/copilot.ts +234 -0
- package/src/providers/index.ts +15 -5
- package/src/providers/kimi.ts +6 -10
- package/src/providers/opencode-go.ts +9 -6
- package/src/quota-lines.ts +116 -0
- package/src/render.ts +10 -5
- package/src/report.ts +11 -8
- package/src/tui-format.ts +2 -4
- package/src/tui.tsx +98 -127
- package/src/types.ts +6 -1
- package/src/warn.ts +5 -4
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure formatting for the context-usage block. Turns a `ContextBreakdown` into
|
|
3
|
+
* pre-colored text lines (theme color names, resolved by the TSX layer) in the
|
|
4
|
+
* same shape `buildLines` uses in `src/quota-lines.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import type { ContextBreakdown, ContextRow, GridCell, RowKey } from "./types";
|
|
7
|
+
|
|
8
|
+
/** Theme color names used by the context block. */
|
|
9
|
+
export type ContextTone =
|
|
10
|
+
| "text"
|
|
11
|
+
| "textMuted"
|
|
12
|
+
| "borderSubtle"
|
|
13
|
+
| "info"
|
|
14
|
+
| "success"
|
|
15
|
+
| "secondary"
|
|
16
|
+
| "warning"
|
|
17
|
+
| "error";
|
|
18
|
+
|
|
19
|
+
export interface Segment {
|
|
20
|
+
text: string;
|
|
21
|
+
fg: ContextTone;
|
|
22
|
+
bold?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Line {
|
|
26
|
+
segments: Segment[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BuildContextLinesOptions {
|
|
30
|
+
/** Fallback shown when `breakdown.modelName` is null (e.g. raw providerID/modelID). */
|
|
31
|
+
modelLabel?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const GRID_ROWS = 6;
|
|
35
|
+
const GRID_MAX_WIDTH = 116;
|
|
36
|
+
const GRID_MAX_COLS = Math.floor(GRID_MAX_WIDTH / 2);
|
|
37
|
+
const LEGEND_LABEL_WIDTH = 24;
|
|
38
|
+
const LEGEND_COUNT_WIDTH = 9;
|
|
39
|
+
const LEGEND_PERCENT_WIDTH = 6;
|
|
40
|
+
const LEGEND_GAP = " ";
|
|
41
|
+
const COLUMNS_PER_LEGEND_ROW = 3;
|
|
42
|
+
|
|
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
|
+
const CATEGORY_TONES: Record<RowKey, ContextTone> = {
|
|
53
|
+
user: "info",
|
|
54
|
+
agent: "success",
|
|
55
|
+
reasoning: "secondary",
|
|
56
|
+
tools: "warning",
|
|
57
|
+
system: "textMuted",
|
|
58
|
+
free: "borderSubtle",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const LEGEND_KEYS: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system", "free"];
|
|
62
|
+
const BAND_TONES: Record<"ok" | "warning" | "error", ContextTone> = {
|
|
63
|
+
ok: "success",
|
|
64
|
+
warning: "warning",
|
|
65
|
+
error: "error",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const MONEY = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
|
69
|
+
|
|
70
|
+
/** Locale-grouped integer token count; non-finite input renders as `0`. */
|
|
71
|
+
export function formatTokens(n: number): string {
|
|
72
|
+
const value = typeof n === "number" && Number.isFinite(n) ? Math.round(n) : 0;
|
|
73
|
+
return value.toLocaleString("en-US");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** USD currency string (e.g. `$0.42`); non-finite input renders as `$0.00`. */
|
|
77
|
+
export function formatMoney(n: number): string {
|
|
78
|
+
const value = typeof n === "number" && Number.isFinite(n) ? n : 0;
|
|
79
|
+
return MONEY.format(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
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
|
+
function glyphFor(fill: number): string {
|
|
92
|
+
if (fill <= 0) return "░░";
|
|
93
|
+
if (fill <= 0.25) return "▒▒";
|
|
94
|
+
if (fill <= 0.75) return "▓▓";
|
|
95
|
+
return "██";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toneForRowKey(key: unknown): ContextTone {
|
|
99
|
+
if (typeof key !== "string") return "borderSubtle";
|
|
100
|
+
return CATEGORY_TONES[key as RowKey] ?? "borderSubtle";
|
|
101
|
+
}
|
|
102
|
+
|
|
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
|
+
/**
|
|
117
|
+
* One-decimal percentage. A non-zero share that would round to `0.0%` reads as
|
|
118
|
+
* "nothing at all", so anything under a tenth of a percent collapses to `<0.1%`.
|
|
119
|
+
*/
|
|
120
|
+
export function formatPercent(percent: number): string {
|
|
121
|
+
const value = typeof percent === "number" && Number.isFinite(percent) ? percent : 0;
|
|
122
|
+
if (value > 0 && value < 0.1) return "<0.1%";
|
|
123
|
+
return `${Math.min(100, Math.max(0, value)).toFixed(1)}%`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function headerMetric(breakdown: ContextBreakdown): string {
|
|
127
|
+
if (breakdown.ready !== true) return "0 (0.0%)";
|
|
128
|
+
const total = nonNegative(breakdown.total) ?? 0;
|
|
129
|
+
const limit = nonNegative(breakdown.limit);
|
|
130
|
+
if (limit === null || limit <= 0) return `${formatTokens(total)} tokens`;
|
|
131
|
+
const percent = (total / limit) * 100;
|
|
132
|
+
return `${formatTokens(total)} / ${formatTokens(limit)} tokens (${formatPercent(percent)})`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function rowMap(breakdown: ContextBreakdown): Map<RowKey, ContextRow> {
|
|
136
|
+
const map = new Map<RowKey, ContextRow>();
|
|
137
|
+
const rows = Array.isArray(breakdown.rows) ? breakdown.rows : [];
|
|
138
|
+
for (const row of rows) {
|
|
139
|
+
if (row !== null && typeof row === "object" && typeof row.key === "string") {
|
|
140
|
+
map.set(row.key, row);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return map;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
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];
|
|
151
|
+
const label = key === "system" && systemDerived ? `${baseLabel} (derived)` : baseLabel;
|
|
152
|
+
const tokens = nonNegative(row?.tokens);
|
|
153
|
+
const percent = nonNegative(row?.percent);
|
|
154
|
+
const percentText = percent === null ? "—" : formatPercent(percent);
|
|
155
|
+
return [
|
|
156
|
+
{ text: "● ", fg: CATEGORY_TONES[key] },
|
|
157
|
+
{ text: padLabel(label, LEGEND_LABEL_WIDTH), fg: "text" },
|
|
158
|
+
{
|
|
159
|
+
text: rightAlign(tokens === null ? "—" : formatTokens(tokens), LEGEND_COUNT_WIDTH),
|
|
160
|
+
fg: "text",
|
|
161
|
+
},
|
|
162
|
+
{ text: " ", fg: "text" },
|
|
163
|
+
{ text: rightAlign(percentText, LEGEND_PERCENT_WIDTH), fg: "textMuted" },
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function gridLines(breakdown: ContextBreakdown): Line[] {
|
|
168
|
+
const grid = breakdown.grid;
|
|
169
|
+
const cols = nonNegative(grid?.cols);
|
|
170
|
+
if (cols === null || cols <= 0) return [];
|
|
171
|
+
const requestedRows = nonNegative(grid?.rows);
|
|
172
|
+
const rows = requestedRows !== null && requestedRows >= 1 ? Math.floor(requestedRows) : GRID_ROWS;
|
|
173
|
+
const renderCols = Math.min(Math.floor(cols), GRID_MAX_COLS);
|
|
174
|
+
const cells: readonly GridCell[] = Array.isArray(grid?.cells) ? grid.cells : [];
|
|
175
|
+
|
|
176
|
+
const lines: Line[] = [];
|
|
177
|
+
for (let r = 0; r < rows; r++) {
|
|
178
|
+
const segments: Segment[] = [];
|
|
179
|
+
for (let c = 0; c < renderCols; c++) {
|
|
180
|
+
const cell = cells[r * cols + c] as GridCell | undefined;
|
|
181
|
+
const fill = cell === undefined ? 0 : Math.min(1, Math.max(0, finite(cell.fill) ?? 0));
|
|
182
|
+
segments.push({ text: glyphFor(fill), fg: toneForRowKey(cell?.rowKey) });
|
|
183
|
+
}
|
|
184
|
+
lines.push({ segments });
|
|
185
|
+
}
|
|
186
|
+
return lines;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function headroomSegments(breakdown: ContextBreakdown): Segment[] {
|
|
190
|
+
const usable = nonNegative(breakdown.headroom?.usable);
|
|
191
|
+
if (usable === null || breakdown.ready !== true) return [];
|
|
192
|
+
const free = nonNegative(breakdown.headroom?.free) ?? 0;
|
|
193
|
+
const band = breakdown.headroom?.band;
|
|
194
|
+
const tone = band === "warning" || band === "error" ? BAND_TONES[band] : BAND_TONES.ok;
|
|
195
|
+
const segments: Segment[] = [
|
|
196
|
+
{ text: `Auto-compacts at ${formatTokens(usable)} · ${formatTokens(free)} headroom`, fg: tone },
|
|
197
|
+
];
|
|
198
|
+
const cost = nonNegative(breakdown.cost);
|
|
199
|
+
if (cost !== null && cost > 0) {
|
|
200
|
+
segments.push({ text: ` · ${formatMoney(cost)} spent`, fg: "textMuted" });
|
|
201
|
+
}
|
|
202
|
+
return segments;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Renders a context breakdown as pre-colored lines; empty until the first response. */
|
|
206
|
+
export function buildContextLines(
|
|
207
|
+
breakdown: ContextBreakdown,
|
|
208
|
+
options: BuildContextLinesOptions = {},
|
|
209
|
+
): Line[] {
|
|
210
|
+
const source = (breakdown ?? {}) as ContextBreakdown;
|
|
211
|
+
const lines: Line[] = [];
|
|
212
|
+
|
|
213
|
+
const modelName =
|
|
214
|
+
typeof source.modelName === "string" && source.modelName !== "" ? source.modelName : null;
|
|
215
|
+
const fallback =
|
|
216
|
+
typeof options.modelLabel === "string" && options.modelLabel !== "" ? options.modelLabel : null;
|
|
217
|
+
const name = modelName ?? fallback;
|
|
218
|
+
|
|
219
|
+
const header: Segment[] = [];
|
|
220
|
+
if (name !== null) {
|
|
221
|
+
header.push({ text: name, fg: "text", bold: true });
|
|
222
|
+
header.push({ text: " · ", fg: "textMuted" });
|
|
223
|
+
}
|
|
224
|
+
header.push({ text: headerMetric(source), fg: "text" });
|
|
225
|
+
lines.push({ segments: header });
|
|
226
|
+
|
|
227
|
+
if (source.ready !== true) {
|
|
228
|
+
lines.push({ segments: [{ text: "awaiting first response", fg: "textMuted" }] });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const limit = nonNegative(source.limit);
|
|
232
|
+
if (limit !== null && limit > 0) {
|
|
233
|
+
lines.push(...gridLines(source));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const rows = rowMap(source);
|
|
237
|
+
const derived = source.systemDerived === true;
|
|
238
|
+
for (let i = 0; i < LEGEND_KEYS.length / 2; i++) {
|
|
239
|
+
const leftKey = LEGEND_KEYS[i];
|
|
240
|
+
const rightKey = LEGEND_KEYS[i + COLUMNS_PER_LEGEND_ROW];
|
|
241
|
+
lines.push({
|
|
242
|
+
segments: [
|
|
243
|
+
...legendEntry(leftKey, rows.get(leftKey), derived),
|
|
244
|
+
{ text: LEGEND_GAP, fg: "text" },
|
|
245
|
+
...legendEntry(rightKey, rows.get(rightKey), derived),
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const captured = nonNegative(source.systemCaptured);
|
|
251
|
+
if (captured !== null) {
|
|
252
|
+
const systemTokens = nonNegative(rows.get("system")?.tokens) ?? 0;
|
|
253
|
+
const framing = Math.max(0, systemTokens - captured);
|
|
254
|
+
const prefix = `↳ ${formatTokens(captured)} system prompt + `;
|
|
255
|
+
const suffix = `${formatTokens(framing)} tools & framing`;
|
|
256
|
+
lines.push({ segments: [{ text: prefix + suffix, fg: "textMuted" }] });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const headroom = headroomSegments(source);
|
|
260
|
+
if (headroom.length > 0) lines.push({ segments: headroom });
|
|
261
|
+
|
|
262
|
+
const pruned = nonNegative(source.prunedToolOutputs);
|
|
263
|
+
if (pruned !== null && pruned > 0) {
|
|
264
|
+
lines.push({
|
|
265
|
+
segments: [
|
|
266
|
+
{ text: `${formatTokens(pruned)} tool outputs pruned from context`, fg: "textMuted" },
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return lines;
|
|
272
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ContextRow, GridCell, RowKey } from "./types";
|
|
2
|
+
|
|
3
|
+
const GRID_ROWS_DEFAULT = 6;
|
|
4
|
+
const MIN_COLS = 8;
|
|
5
|
+
const MAX_COLS = 60;
|
|
6
|
+
const CATEGORY_ORDER: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
|
|
7
|
+
|
|
8
|
+
export interface BuildGridInput {
|
|
9
|
+
rows: readonly ContextRow[];
|
|
10
|
+
limit: number | null;
|
|
11
|
+
total: number | null;
|
|
12
|
+
cols?: number | null;
|
|
13
|
+
gridRows?: number | null;
|
|
14
|
+
}
|
|
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
|
+
function rowTokens(rows: readonly ContextRow[], key: RowKey): number {
|
|
31
|
+
if (!Array.isArray(rows)) return 0;
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
if (row !== null && typeof row === "object" && row.key === key) {
|
|
34
|
+
const tokens = finiteOrNull(row.tokens);
|
|
35
|
+
return tokens !== null && tokens > 0 ? tokens : 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function categoryAt(
|
|
42
|
+
ranges: readonly { key: RowKey; start: number; end: number }[],
|
|
43
|
+
position: number,
|
|
44
|
+
): RowKey | null {
|
|
45
|
+
for (const range of ranges) {
|
|
46
|
+
if (position >= range.start && position < range.end) return range.key;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Lays out the grid of square cells from the five measured rows. Cells are in
|
|
53
|
+
* row-major order; only the cell straddling `total` is partial and free space
|
|
54
|
+
* is never materialised.
|
|
55
|
+
*/
|
|
56
|
+
export function buildGrid(input: BuildGridInput): {
|
|
57
|
+
cols: number;
|
|
58
|
+
rows: number;
|
|
59
|
+
cells: GridCell[];
|
|
60
|
+
} {
|
|
61
|
+
const cols = clampCols(input.cols);
|
|
62
|
+
const rows = clampGridRows(input.gridRows);
|
|
63
|
+
const limit = finiteOrNull(input.limit);
|
|
64
|
+
if (limit === null || limit <= 0) return { cols, rows, cells: [] };
|
|
65
|
+
|
|
66
|
+
const total = Math.max(0, finiteOrNull(input.total) ?? 0);
|
|
67
|
+
const cellCount = cols * rows;
|
|
68
|
+
const tokensPerCell = limit / cellCount;
|
|
69
|
+
|
|
70
|
+
const ranges: { key: RowKey; start: number; end: number }[] = [];
|
|
71
|
+
let acc = 0;
|
|
72
|
+
for (const key of CATEGORY_ORDER) {
|
|
73
|
+
const value = rowTokens(input.rows, key);
|
|
74
|
+
ranges.push({ key, start: acc, end: acc + value });
|
|
75
|
+
acc += value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cells: GridCell[] = [];
|
|
79
|
+
for (let i = 0; i < cellCount; i++) {
|
|
80
|
+
const position = i * tokensPerCell;
|
|
81
|
+
if (total <= 0 || position >= total) {
|
|
82
|
+
cells.push({ rowKey: null, fill: 0 });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const key = categoryAt(ranges, position) ?? CATEGORY_ORDER[CATEGORY_ORDER.length - 1];
|
|
86
|
+
cells.push({ rowKey: key, fill: Math.min(1, Math.max(0, (total - position) / tokensPerCell)) });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { cols, rows, cells };
|
|
90
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Model } from "@opencode-ai/sdk/v2";
|
|
2
|
+
|
|
3
|
+
export interface Headroom {
|
|
4
|
+
usable: number | null;
|
|
5
|
+
free: number | null;
|
|
6
|
+
band: "ok" | "warning" | "error";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface HeadroomInput {
|
|
10
|
+
limit: Model["limit"] | null | undefined;
|
|
11
|
+
total: number | null;
|
|
12
|
+
compaction?: { reserved?: number } | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function finiteOrNull(value: unknown): number | null {
|
|
16
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Compaction headroom, mirroring opencode's overflow check; nulls when unusable. */
|
|
20
|
+
export function computeHeadroom(input: HeadroomInput): Headroom {
|
|
21
|
+
const limit = input.limit;
|
|
22
|
+
const context = finiteOrNull(limit?.context);
|
|
23
|
+
const maxOutput = finiteOrNull(limit?.output);
|
|
24
|
+
if (context === null || maxOutput === null) return { usable: null, free: null, band: "ok" };
|
|
25
|
+
|
|
26
|
+
const reservedDefault = Math.min(20000, Math.max(0, maxOutput));
|
|
27
|
+
const configured = finiteOrNull(input.compaction?.reserved);
|
|
28
|
+
const reserved = configured !== null && configured >= 0 ? configured : reservedDefault;
|
|
29
|
+
|
|
30
|
+
const inputLimit = finiteOrNull(limit?.input);
|
|
31
|
+
const usable =
|
|
32
|
+
inputLimit !== null
|
|
33
|
+
? Math.max(0, inputLimit - reserved)
|
|
34
|
+
: Math.max(0, context - Math.max(0, maxOutput));
|
|
35
|
+
|
|
36
|
+
const total = finiteOrNull(input.total);
|
|
37
|
+
if (total === null) return { usable, free: null, band: "ok" };
|
|
38
|
+
|
|
39
|
+
const free = usable - total;
|
|
40
|
+
const band = free <= usable * 0.05 ? "error" : free <= usable * 0.15 ? "warning" : "ok";
|
|
41
|
+
return { usable, free, band };
|
|
42
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-prompt capture sidecar. The server plugin writes the fully joined
|
|
3
|
+
* system prompt's size once per request; the TUI reads it back to split the
|
|
4
|
+
* residual "System & tools" row. Every operation is best-effort and total:
|
|
5
|
+
* failures degrade to `null` and must never break a request.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { SystemCapture } from "./types";
|
|
10
|
+
import { pluginStateDir } from "../paths";
|
|
11
|
+
|
|
12
|
+
const CAPTURE_VERSION = 1;
|
|
13
|
+
const WRITE_THROTTLE_MS = 60_000;
|
|
14
|
+
const MAX_SESSION_ID_LENGTH = 200;
|
|
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
|
+
/** <pluginStateDir>/context/<sanitized-sessionID>.json */
|
|
28
|
+
export function contextCapturePath(sessionID: string, env?: NodeJS.ProcessEnv): string {
|
|
29
|
+
const raw = typeof sessionID === "string" ? sessionID : "";
|
|
30
|
+
const safe = raw.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, MAX_SESSION_ID_LENGTH) || "_";
|
|
31
|
+
return join(pluginStateDir(env), "context", `${safe}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads and validates a capture. Returns null for a missing/unreadable file,
|
|
36
|
+
* invalid JSON, an unsupported version, a session mismatch, non-string
|
|
37
|
+
* 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
|
+
*/
|
|
43
|
+
export async function readCapture(
|
|
44
|
+
sessionID: string,
|
|
45
|
+
env?: NodeJS.ProcessEnv,
|
|
46
|
+
): Promise<SystemCapture | null> {
|
|
47
|
+
try {
|
|
48
|
+
if (typeof sessionID !== "string") return null;
|
|
49
|
+
|
|
50
|
+
const raw = await readFile(contextCapturePath(sessionID, env), "utf8");
|
|
51
|
+
const parsed: unknown = JSON.parse(raw);
|
|
52
|
+
if (!isRecord(parsed)) return null;
|
|
53
|
+
if (parsed.version !== CAPTURE_VERSION) return null;
|
|
54
|
+
if (parsed.sessionID !== sessionID) return null;
|
|
55
|
+
if (typeof parsed.providerID !== "string" || parsed.providerID === "") return null;
|
|
56
|
+
if (typeof parsed.modelID !== "string" || parsed.modelID === "") return null;
|
|
57
|
+
|
|
58
|
+
const systemChars = nonNegative(parsed.systemChars);
|
|
59
|
+
const systemTokens = nonNegative(parsed.systemTokens);
|
|
60
|
+
const capturedAt = nonNegative(parsed.capturedAt);
|
|
61
|
+
if (systemChars === null || systemTokens === null || capturedAt === null) return null;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
version: CAPTURE_VERSION,
|
|
65
|
+
sessionID,
|
|
66
|
+
providerID: parsed.providerID,
|
|
67
|
+
modelID: parsed.modelID,
|
|
68
|
+
systemChars,
|
|
69
|
+
systemTokens,
|
|
70
|
+
capturedAt,
|
|
71
|
+
};
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
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
|
+
*/
|
|
81
|
+
export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessEnv): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
if (!isRecord(capture)) return;
|
|
84
|
+
const { sessionID, providerID, modelID } = capture;
|
|
85
|
+
if (typeof sessionID !== "string" || sessionID === "") return;
|
|
86
|
+
if (typeof providerID !== "string" || providerID === "") return;
|
|
87
|
+
if (typeof modelID !== "string" || modelID === "") return;
|
|
88
|
+
if (capture.version !== CAPTURE_VERSION) return;
|
|
89
|
+
|
|
90
|
+
const systemChars = nonNegative(capture.systemChars);
|
|
91
|
+
const systemTokens = nonNegative(capture.systemTokens);
|
|
92
|
+
const capturedAt = nonNegative(capture.capturedAt);
|
|
93
|
+
if (systemChars === null || systemTokens === null || capturedAt === null) return;
|
|
94
|
+
|
|
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
|
+
const directory = join(pluginStateDir(env), "context");
|
|
104
|
+
await mkdir(directory, { recursive: true });
|
|
105
|
+
const file = contextCapturePath(sessionID, env);
|
|
106
|
+
const normalized: SystemCapture = {
|
|
107
|
+
version: CAPTURE_VERSION,
|
|
108
|
+
sessionID,
|
|
109
|
+
providerID,
|
|
110
|
+
modelID,
|
|
111
|
+
systemChars,
|
|
112
|
+
systemTokens,
|
|
113
|
+
capturedAt,
|
|
114
|
+
};
|
|
115
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
116
|
+
await writeFile(tmp, JSON.stringify(normalized), "utf8");
|
|
117
|
+
await rename(tmp, file);
|
|
118
|
+
writeThrottle.set(sessionID, now);
|
|
119
|
+
} catch {
|
|
120
|
+
// Capture writes are best-effort; a failure must never break a request.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* True only when the capture matches the session/provider/model and was taken
|
|
126
|
+
* at or after `since`.
|
|
127
|
+
*/
|
|
128
|
+
export function captureIsFresh(
|
|
129
|
+
capture: SystemCapture | null | undefined,
|
|
130
|
+
reference: { sessionID: string; providerID: string; modelID: string; since: number },
|
|
131
|
+
): boolean {
|
|
132
|
+
if (!isRecord(capture)) return false;
|
|
133
|
+
const ref = isRecord(reference) ? reference : null;
|
|
134
|
+
if (ref === null) return false;
|
|
135
|
+
if (capture.sessionID !== ref.sessionID) return false;
|
|
136
|
+
if (capture.providerID !== ref.providerID) return false;
|
|
137
|
+
if (capture.modelID !== ref.modelID) return false;
|
|
138
|
+
const capturedAt = nonNegative(capture.capturedAt);
|
|
139
|
+
const since = nonNegative(ref.since);
|
|
140
|
+
if (capturedAt === null || since === null) return false;
|
|
141
|
+
return capturedAt >= since;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Clears the write-throttle map so tests can reset between cases. */
|
|
145
|
+
export function resetCaptureThrottle(): void {
|
|
146
|
+
writeThrottle.clear();
|
|
147
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Message, Model, Part } from "@opencode-ai/sdk/v2";
|
|
2
|
+
|
|
3
|
+
export type RowKey = "user" | "agent" | "reasoning" | "tools" | "system" | "free";
|
|
4
|
+
|
|
5
|
+
export interface ContextRow {
|
|
6
|
+
key: RowKey;
|
|
7
|
+
label: string;
|
|
8
|
+
tokens: number | null;
|
|
9
|
+
percent: number | null; // 0-100, null when the limit is unknown
|
|
10
|
+
exact: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GridCell {
|
|
14
|
+
rowKey: RowKey | null; // null = free space
|
|
15
|
+
fill: number; // 0..1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ContextBreakdown {
|
|
19
|
+
ready: boolean;
|
|
20
|
+
modelName: string | null;
|
|
21
|
+
total: number | null;
|
|
22
|
+
limit: number | null;
|
|
23
|
+
cost: number;
|
|
24
|
+
rows: ContextRow[];
|
|
25
|
+
grid: { cols: number; rows: number; cells: GridCell[] };
|
|
26
|
+
headroom: { usable: number | null; free: number | null; band: "ok" | "warning" | "error" };
|
|
27
|
+
systemDerived: boolean;
|
|
28
|
+
/** Measured system-prompt tokens when a valid capture was supplied, else null. */
|
|
29
|
+
systemCaptured: number | null;
|
|
30
|
+
prunedToolOutputs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SystemCapture {
|
|
34
|
+
version: 1;
|
|
35
|
+
sessionID: string;
|
|
36
|
+
providerID: string;
|
|
37
|
+
modelID: string;
|
|
38
|
+
systemChars: number;
|
|
39
|
+
systemTokens: number;
|
|
40
|
+
capturedAt: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CollectInput {
|
|
44
|
+
messages: readonly Message[];
|
|
45
|
+
model: Model | null;
|
|
46
|
+
part?: ((messageID: string) => readonly Part[]) | null;
|
|
47
|
+
system?: SystemCapture | null;
|
|
48
|
+
sessionCost?: number;
|
|
49
|
+
cols?: number | null;
|
|
50
|
+
compaction?: { reserved?: number } | null;
|
|
51
|
+
}
|
package/src/fallback.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import type { AdapterResult, UsageWindow } from "./types
|
|
3
|
-
import { dbPath } from "./paths
|
|
2
|
+
import type { AdapterResult, UsageWindow } from "./types";
|
|
3
|
+
import { dbPath } from "./paths";
|
|
4
4
|
|
|
5
5
|
const HOUR_MS = 60 * 60 * 1000;
|
|
6
6
|
const DAY_MS = 24 * HOUR_MS;
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
2
|
import { tool } from "@opencode-ai/plugin";
|
|
3
|
-
import { DEFAULT_OPTIONS, collectReports } from "./report
|
|
4
|
-
import { renderJson, renderText } from "./render
|
|
5
|
-
import { checkWarnings } from "./warn
|
|
6
|
-
import { toNumber } from "./normalize
|
|
7
|
-
import { adapters } from "./providers/index
|
|
8
|
-
import
|
|
3
|
+
import { DEFAULT_OPTIONS, collectReports } from "./report";
|
|
4
|
+
import { renderJson, renderText } from "./render";
|
|
5
|
+
import { checkWarnings } from "./warn";
|
|
6
|
+
import { toNumber } from "./normalize";
|
|
7
|
+
import { adapters } from "./providers/index";
|
|
8
|
+
import { estimateTokens } from "./context/estimate";
|
|
9
|
+
import { writeCapture } from "./context/system";
|
|
10
|
+
import type { PluginOptions } from "./types";
|
|
9
11
|
|
|
10
12
|
const WARN_THROTTLE_MS = 10 * 60 * 1000;
|
|
11
13
|
|
|
@@ -13,9 +15,6 @@ const TOOL_DESCRIPTION =
|
|
|
13
15
|
"Show subscription usage/quota windows (5h, weekly, monthly) for configured inference providers " +
|
|
14
16
|
`(${adapters.map((adapter) => adapter.id).join(", ")})`;
|
|
15
17
|
|
|
16
|
-
const COMMAND_TEMPLATE =
|
|
17
|
-
"Call the usage_report tool with these arguments: $ARGUMENTS and present the result verbatim.";
|
|
18
|
-
|
|
19
18
|
/** Merges user plugin options over defaults, defensively ignoring malformed values. */
|
|
20
19
|
export function coerceOptions(raw: Record<string, unknown> | undefined): PluginOptions {
|
|
21
20
|
const opts: PluginOptions = { ...DEFAULT_OPTIONS };
|
|
@@ -91,12 +90,31 @@ const plugin: Plugin = async ({ client }, options) => {
|
|
|
91
90
|
}),
|
|
92
91
|
},
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
// `/usage` is owned by the TUI plugin so the slash command opens a local
|
|
94
|
+
// dialog instead of routing through the model. This hook's only job is to
|
|
95
|
+
// measure the assembled system prompt, which opencode never persists, so
|
|
96
|
+
// the TUI can split the "System & tools" residual (spec §9.1).
|
|
97
|
+
async "experimental.chat.system.transform"(input, output) {
|
|
98
|
+
try {
|
|
99
|
+
const sessionID = input?.sessionID;
|
|
100
|
+
if (typeof sessionID !== "string" || sessionID === "") return;
|
|
101
|
+
const model = input?.model;
|
|
102
|
+
if (model === undefined) return;
|
|
103
|
+
const system = Array.isArray(output?.system) ? output.system.join("\n") : "";
|
|
104
|
+
if (system === "") return;
|
|
105
|
+
|
|
106
|
+
await writeCapture({
|
|
107
|
+
version: 1,
|
|
108
|
+
sessionID,
|
|
109
|
+
providerID: model.providerID,
|
|
110
|
+
modelID: model.id,
|
|
111
|
+
systemChars: system.length,
|
|
112
|
+
systemTokens: estimateTokens(system),
|
|
113
|
+
capturedAt: Date.now(),
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
// Best-effort: an experimental hook must never be able to break a request.
|
|
117
|
+
}
|
|
100
118
|
},
|
|
101
119
|
|
|
102
120
|
async event({ event }) {
|
package/src/normalize.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { UsageWindow, WindowKind } from "./types
|
|
1
|
+
import type { UsageWindow, WindowKind } from "./types";
|
|
2
2
|
|
|
3
3
|
/** Accepts a number or a numeric string; anything else (including NaN/Infinity) yields null. */
|
|
4
4
|
export function toNumber(v: unknown): number | null {
|