opencode-usage-coach 0.2.2 → 0.2.4
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 +26 -29
- package/dist/index.js +29 -16
- package/dist/tui.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# opencode-usage-coach
|
|
2
2
|
|
|
3
3
|
A closed-loop usage coach and harness for [OpenCode](https://opencode.ai). Built for
|
|
4
|
-
flat-rate coding plans (e.g. z.ai / GLM) where USD cost
|
|
5
|
-
**quota windows (5h / weekly / monthly)** and turns them
|
|
4
|
+
flat-rate coding plans (e.g. z.ai / GLM, or any quota-metered provider) where USD cost
|
|
5
|
+
is meaningless, so it tracks **quota windows (5h / weekly / monthly)** and turns them
|
|
6
|
+
into coaching + loop control. Provider-agnostic — configure via `harness.config.json`.
|
|
6
7
|
|
|
7
8
|
Existing plugins only *display* usage. This one **senses quota → coaches → stops/advances
|
|
8
|
-
the loop** — and ships a harness agent mode
|
|
9
|
+
the loop** — and ships a harness agent mode.
|
|
9
10
|
|
|
10
11
|
## What it does
|
|
11
12
|
|
|
12
13
|
**Always-on guardian (plugin):**
|
|
13
|
-
- Senses
|
|
14
|
+
- Senses provider quota windows via the `codexbar` CLI (config-driven — any provider codexbar knows).
|
|
14
15
|
- On STOP threshold: blocks tool calls (`tool.execute.before` throws) → the agent self-stops.
|
|
15
16
|
- Injects coaching (how to use right now) into the system prompt (double defense).
|
|
16
17
|
- Surfaces a sidebar panel: per-provider quota meters + harness task states + coaching.
|
|
@@ -21,12 +22,11 @@ the loop** — and ships a harness agent mode + a deterministic orchestrator.
|
|
|
21
22
|
unclear → clarify; substantive → generate→grade→revise→advance.
|
|
22
23
|
- Multi-model via plugin tools `generate`/`grade`: run the configured generator/grader model
|
|
23
24
|
(from `harness.config.json`) in a new session on the same server — no second terminal.
|
|
24
|
-
e.g. GLM generation + free mimo grading in one opencode session.
|
|
25
25
|
- Reports progress via `task_update` → the sidebar panel shows live task states.
|
|
26
26
|
|
|
27
27
|
## Requirements
|
|
28
|
-
- opencode (tested on 1.17.13) with a
|
|
29
|
-
- `codexbar` CLI with
|
|
28
|
+
- opencode (tested on 1.17.13) with a quota-metered provider configured.
|
|
29
|
+
- `codexbar` CLI with your provider key wired (e.g. `codexbar config set-api-key --provider zai --stdin`).
|
|
30
30
|
|
|
31
31
|
## Install (from npm)
|
|
32
32
|
```jsonc
|
|
@@ -69,29 +69,29 @@ This plugin has **four config surfaces**. Only the first two are required to run
|
|
|
69
69
|
|
|
70
70
|
### 2. codexbar (quota data source)
|
|
71
71
|
```bash
|
|
72
|
-
printf '%s' "$
|
|
72
|
+
printf '%s' "$YOUR_PROVIDER_API_KEY" | codexbar config set-api-key --provider <id> --stdin
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
### 3. Harness config — `harness.config.json` (role → model) ★ the main one
|
|
76
|
-
Place in the **work directory
|
|
77
|
-
|
|
76
|
+
Place in the **work directory**. Each role runs on its model, so per-model quota is tracked
|
|
77
|
+
(works for local LLMs — they show 0%).
|
|
78
78
|
```jsonc
|
|
79
79
|
{
|
|
80
|
-
"generator": "
|
|
81
|
-
"grader": "
|
|
82
|
-
"
|
|
83
|
-
"
|
|
80
|
+
"generator": "opencode/deepseek-v4-flash-free", // model that produces the work
|
|
81
|
+
"grader": "opencode/mimo-v2.5-free", // model that grades it
|
|
82
|
+
"provider": "", // codexbar quota provider ("" = default)
|
|
83
|
+
"lighterModel": "" // suggested when throttling
|
|
84
84
|
}
|
|
85
85
|
```
|
|
86
86
|
| Field | Default | Notes |
|
|
87
87
|
|---|---|---|
|
|
88
|
-
| `generator` |
|
|
89
|
-
| `grader` | `
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
88
|
+
| `generator` | **required** | any `provider/model` opencode knows |
|
|
89
|
+
| `grader` | falls back to `generator` | can differ (multi-model) |
|
|
90
|
+
| `provider` | `""` (codexbar default) | which provider's quota the guardian watches |
|
|
91
|
+
| `lighterModel` | `""` | shown in throttle advice; env `UC_LIGHTER_MODEL` overrides |
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
`generator` is **required** — the tools return a clear error if missing. Copy
|
|
94
|
+
`harness.config.example.json` to get started (both example models are free → never gated).
|
|
95
95
|
|
|
96
96
|
### 4. Env vars (thresholds / tuning)
|
|
97
97
|
| Var | Default | Meaning |
|
|
@@ -101,12 +101,9 @@ Missing roles fall back to `UC_MODEL`. Provider for quota is derived from the mo
|
|
|
101
101
|
| `UC_STOP_WEEKLY` | 95 | weekly STOP % |
|
|
102
102
|
| `UC_THROTTLE_WEEKLY` | 85 | weekly throttle % |
|
|
103
103
|
| `UC_STOP_MONTHLY` | 98 | monthly STOP % |
|
|
104
|
-
| `UC_LIGHTER_MODEL` |
|
|
105
|
-
| `UC_PROVIDER` |
|
|
104
|
+
| `UC_LIGHTER_MODEL` | (config `lighterModel`) | suggested model when throttling |
|
|
105
|
+
| `UC_PROVIDER` | (config `provider`) | codexbar provider for the guardian |
|
|
106
106
|
| `UC_TTL_MS` | 60000 | quota cache TTL (ms) |
|
|
107
|
-
| `UC_MODEL` | zai-coding-plan/glm-5.1 | harness fallback model |
|
|
108
|
-
| `UC_TASK_TIMEOUT_MS` | 1800000 | harness per-task timeout (overridden by config) |
|
|
109
|
-
| `UC_MAX_REVISIONS` | 2 | harness revise attempts (overridden by config) |
|
|
110
107
|
| `UC_DEBUG` | 0 | set to `1` for a diagnostic log at `~/.cache/opencode-usage-coach/coach.log` |
|
|
111
108
|
|
|
112
109
|
## Architecture
|
|
@@ -114,13 +111,13 @@ Missing roles fall back to `UC_MODEL`. Provider for quota is derived from the mo
|
|
|
114
111
|
- **TUI module** (`src/tui.tsx`) — SolidJS, reads a state file, renders into `sidebar_footer`/`home_footer`. Loaded via `tui.json`. Bundled with `tsup` + `esbuild-plugin-solid`, solid kept **external** (resolves to opencode's bundle — avoids the duplicate-instance crash). Exports `{ tui }` (a bare function is misread as a server plugin).
|
|
115
112
|
- server↔TUI communicate via a state file (`~/.cache/opencode-usage-coach/*.json`) — they are separate processes.
|
|
116
113
|
|
|
117
|
-
Key lessons (
|
|
114
|
+
**Key lessons (hard-won):** never install `solid-js` in the config dir (conflicts with
|
|
118
115
|
opencode's bundled solid); TUI plugins must be compiled + loaded via `tui.json` file path;
|
|
119
116
|
`codexbar` must be called via `spawn` (the `$` BunShell leaks output to the TUI).
|
|
120
117
|
|
|
121
118
|
## Status
|
|
122
|
-
- ✅
|
|
123
|
-
- ✅
|
|
124
|
-
- ✅
|
|
119
|
+
- ✅ Quota guardian + TUI panel (per-provider coach view, colors, collapsible Alt+H)
|
|
120
|
+
- ✅ Harness: agent mode (triage) with generate/grade model-specific tools (1 terminal, multi-model)
|
|
121
|
+
- ✅ npm packaging (`opencode plugin install opencode-usage-coach`)
|
|
125
122
|
|
|
126
123
|
License: MIT.
|
package/dist/index.js
CHANGED
|
@@ -73,6 +73,7 @@ function readHarnessCfg(dir) {
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
async function runModel(client, model, prompt, directory) {
|
|
76
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
76
77
|
try {
|
|
77
78
|
const slash = model.indexOf("/");
|
|
78
79
|
const providerID = slash >= 0 ? model.slice(0, slash) : model;
|
|
@@ -80,11 +81,20 @@ async function runModel(client, model, prompt, directory) {
|
|
|
80
81
|
const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
|
|
81
82
|
const id = s?.data?.info?.id;
|
|
82
83
|
if (!id) return null;
|
|
83
|
-
|
|
84
|
+
await client.session.prompt({
|
|
84
85
|
path: { id },
|
|
85
86
|
body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
|
|
86
87
|
});
|
|
87
|
-
|
|
88
|
+
for (let i = 0; i < 600; i++) {
|
|
89
|
+
await sleep(1e3);
|
|
90
|
+
const st = await client.session.status({ path: { id } });
|
|
91
|
+
const status = st?.data?.[id]?.status ?? st?.data?.status;
|
|
92
|
+
if (status === "idle" || status === "completed" || !status) break;
|
|
93
|
+
}
|
|
94
|
+
const msgs = await client.session.messages({ path: { id } });
|
|
95
|
+
const all = msgs?.data ?? [];
|
|
96
|
+
const lastAssistant = all.filter((m) => m?.info?.role === "assistant").pop();
|
|
97
|
+
const parts = lastAssistant?.parts ?? [];
|
|
88
98
|
const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
|
|
89
99
|
try {
|
|
90
100
|
await client.session.remove?.({ path: { id } });
|
|
@@ -96,7 +106,6 @@ async function runModel(client, model, prompt, directory) {
|
|
|
96
106
|
return null;
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
|
-
var PROVIDER = process.env.UC_PROVIDER ?? "zai";
|
|
100
109
|
var num = (e, d) => {
|
|
101
110
|
try {
|
|
102
111
|
const v = Number(process.env[e]);
|
|
@@ -110,7 +119,6 @@ var THR_5H = num("UC_THROTTLE_5H", 70);
|
|
|
110
119
|
var STOP_WK = num("UC_STOP_WEEKLY", 95);
|
|
111
120
|
var THR_WK = num("UC_THROTTLE_WEEKLY", 85);
|
|
112
121
|
var STOP_MO = num("UC_STOP_MONTHLY", 98);
|
|
113
|
-
var LIGHTER = process.env.UC_LIGHTER_MODEL ?? "glm-4.5-air";
|
|
114
122
|
function humanRemaining(iso) {
|
|
115
123
|
try {
|
|
116
124
|
if (!iso) return "";
|
|
@@ -181,12 +189,13 @@ async function fetchProvidersCoach() {
|
|
|
181
189
|
}));
|
|
182
190
|
return results.filter(Boolean);
|
|
183
191
|
}
|
|
184
|
-
function fetchQuota() {
|
|
192
|
+
function fetchQuota(provider) {
|
|
185
193
|
return new Promise((resolve2) => {
|
|
186
194
|
let out = "";
|
|
187
195
|
let p;
|
|
188
196
|
try {
|
|
189
|
-
|
|
197
|
+
const args = provider ? ["usage", "--provider", provider, "--json"] : ["usage", "--json"];
|
|
198
|
+
p = spawn("codexbar", args, {
|
|
190
199
|
stdio: ["ignore", "pipe", "ignore"]
|
|
191
200
|
});
|
|
192
201
|
} catch {
|
|
@@ -209,12 +218,12 @@ function fetchQuota() {
|
|
|
209
218
|
});
|
|
210
219
|
});
|
|
211
220
|
}
|
|
212
|
-
function coach(q) {
|
|
221
|
+
function coach(q, lighter) {
|
|
213
222
|
if (!q) return { decision: "GO", advice: "quota unavailable \u2014 proceeding cautiously.", weekly: -1, monthly: -1, fiveHour: -1 };
|
|
214
223
|
const wk = Math.round(q.weekly?.usedPercent ?? 0), mo = Math.round(q.monthly?.usedPercent ?? 0), h5 = Math.round(q.fiveHour?.usedPercent ?? 0);
|
|
215
224
|
const wkR = humanRemaining(q.weekly?.resetsAt), h5R = humanRemaining(q.fiveHour?.resetsAt);
|
|
216
225
|
const stop = (r) => ({ decision: "STOP", advice: `STOP recommend \u2014 ${r}. window nearly exhausted. stop now or it will be force-blocked.`, weekly: wk, monthly: mo, fiveHour: h5 });
|
|
217
|
-
const thr = (r) => ({ decision: "THROTTLE", advice: `Throttle recommend \u2014 ${r}. switch to lighter model (${
|
|
226
|
+
const thr = (r) => ({ decision: "THROTTLE", advice: `Throttle recommend \u2014 ${r}. switch to lighter model (${lighter}) or wait for window reset.`, weekly: wk, monthly: mo, fiveHour: h5 });
|
|
218
227
|
if (h5 >= STOP_5H) return stop(`5h window ${h5}% (${h5R})`);
|
|
219
228
|
if (wk >= STOP_WK) return stop(`weekly ${wk}% (${wkR})`);
|
|
220
229
|
if (mo >= STOP_MO) return stop(`monthly ${mo}%`);
|
|
@@ -226,6 +235,9 @@ var LOADING = { decision: "GO", advice: "quota loading\u2026", weekly: -1, month
|
|
|
226
235
|
async function UsageCoachPlugin(input) {
|
|
227
236
|
try {
|
|
228
237
|
setStateDir(input.directory);
|
|
238
|
+
const cfg0 = readHarnessCfg(input.directory);
|
|
239
|
+
const PROVIDER = process.env.UC_PROVIDER ?? cfg0.provider ?? "";
|
|
240
|
+
const LIGHTER = process.env.UC_LIGHTER_MODEL ?? cfg0.lighterModel ?? "a lighter model";
|
|
229
241
|
let last = null;
|
|
230
242
|
let lastFetchedAt = 0;
|
|
231
243
|
let refreshing = false;
|
|
@@ -234,9 +246,9 @@ async function UsageCoachPlugin(input) {
|
|
|
234
246
|
if (refreshing) return;
|
|
235
247
|
if (last && Date.now() - lastFetchedAt < TTL_MS) return;
|
|
236
248
|
refreshing = true;
|
|
237
|
-
fetchQuota().then(async (q) => {
|
|
249
|
+
fetchQuota(PROVIDER).then(async (q) => {
|
|
238
250
|
try {
|
|
239
|
-
last = coach(q);
|
|
251
|
+
last = coach(q, LIGHTER);
|
|
240
252
|
lastFetchedAt = Date.now();
|
|
241
253
|
let providers = [];
|
|
242
254
|
try {
|
|
@@ -346,9 +358,9 @@ async function UsageCoachPlugin(input) {
|
|
|
346
358
|
args: { prompt: tool.schema.string() },
|
|
347
359
|
async execute(args, ctx) {
|
|
348
360
|
const cfg = readHarnessCfg(ctx.directory);
|
|
349
|
-
|
|
350
|
-
const out = await runModel(input.client,
|
|
351
|
-
return out ?? `(generator ${
|
|
361
|
+
if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
|
|
362
|
+
const out = await runModel(input.client, cfg.generator, args.prompt, ctx.directory);
|
|
363
|
+
return out ?? `(generator ${cfg.generator} produced no text)`;
|
|
352
364
|
}
|
|
353
365
|
}),
|
|
354
366
|
generate_batch: tool({
|
|
@@ -356,9 +368,9 @@ async function UsageCoachPlugin(input) {
|
|
|
356
368
|
args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })) },
|
|
357
369
|
async execute(args, ctx) {
|
|
358
370
|
const cfg = readHarnessCfg(ctx.directory);
|
|
359
|
-
|
|
371
|
+
if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
|
|
360
372
|
const results = await Promise.all(args.tasks.map(async (t) => {
|
|
361
|
-
const out = await runModel(input.client,
|
|
373
|
+
const out = await runModel(input.client, cfg.generator, t.prompt, ctx.directory);
|
|
362
374
|
return `[task ${t.id}] ${out ?? "(no output)"}`;
|
|
363
375
|
}));
|
|
364
376
|
return results.join("\n\n");
|
|
@@ -369,7 +381,8 @@ async function UsageCoachPlugin(input) {
|
|
|
369
381
|
args: { prompt: tool.schema.string() },
|
|
370
382
|
async execute(args, ctx) {
|
|
371
383
|
const cfg = readHarnessCfg(ctx.directory);
|
|
372
|
-
const model = cfg.grader ?? cfg.generator
|
|
384
|
+
const model = cfg.grader ?? cfg.generator;
|
|
385
|
+
if (!model) return 'ERROR: no grader/generator model configured. Set "grader" (and "generator") in harness.config.json (see harness.config.example.json).';
|
|
373
386
|
const out = await runModel(input.client, model, args.prompt, ctx.directory);
|
|
374
387
|
return out ?? `(grader ${model} produced no text)`;
|
|
375
388
|
}
|
package/dist/tui.js
CHANGED
|
@@ -334,7 +334,7 @@ function initializeTui(api, disposeRoot) {
|
|
|
334
334
|
_$effect((_$p) => _$setProp(_el$59, "style", st(sKey), _$p));
|
|
335
335
|
return _el$59;
|
|
336
336
|
})());
|
|
337
|
-
const pv = t.model ?
|
|
337
|
+
const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
|
|
338
338
|
const q = pv && h.quotas?.[pv] ? h.quotas[pv] : null;
|
|
339
339
|
const pct = q ? q.fiveHour : 0;
|
|
340
340
|
nodes.push((() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-usage-coach",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "opencode closed-loop usage coach \u2014 quota SENSE -> coaching DECIDE -> loop ACT + TUI integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"usage",
|
|
34
34
|
"coach",
|
|
35
35
|
"loop",
|
|
36
|
-
"
|
|
37
|
-
"
|
|
36
|
+
"llm",
|
|
37
|
+
"harness"
|
|
38
38
|
],
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"peerDependencies": {
|