opencode-usage-coach 0.2.3 → 0.2.5
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 +74 -28
- package/agents/usage-coach-harness.md +9 -7
- package/dist/index.js +51 -18
- package/dist/tui.js +40 -4
- 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
|
|
4
|
+
flat-rate / quota-metered coding plans where USD cost is meaningless, so it tracks
|
|
5
5
|
**quota windows (5h / weekly / monthly)** and turns them into coaching + loop control.
|
|
6
|
+
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,15 @@ 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
|
+
- **Triggering the harness:** the agent auto-triages — trivial work is done directly,
|
|
27
|
+
substantive multi-step work enters the generate→grade loop. For reliable triggering,
|
|
28
|
+
explicitly ask "run this through the harness" or "use the harness for this". The harness
|
|
29
|
+
tools are only available when the harness agent mode is active (see Install).
|
|
26
30
|
|
|
27
31
|
## Requirements
|
|
28
|
-
- opencode (tested on 1.17.13) with a
|
|
29
|
-
- `codexbar` CLI with
|
|
32
|
+
- opencode (tested on 1.17.13) with a quota-metered provider configured.
|
|
33
|
+
- `codexbar` CLI with your provider key wired (e.g. `codexbar config set-api-key --provider zai --stdin`).
|
|
30
34
|
|
|
31
35
|
## Install (from npm)
|
|
32
36
|
```jsonc
|
|
@@ -69,29 +73,29 @@ This plugin has **four config surfaces**. Only the first two are required to run
|
|
|
69
73
|
|
|
70
74
|
### 2. codexbar (quota data source)
|
|
71
75
|
```bash
|
|
72
|
-
printf '%s' "$
|
|
76
|
+
printf '%s' "$YOUR_PROVIDER_API_KEY" | codexbar config set-api-key --provider <id> --stdin
|
|
73
77
|
```
|
|
74
78
|
|
|
75
79
|
### 3. Harness config — `harness.config.json` (role → model) ★ the main one
|
|
76
|
-
Place in the **work directory
|
|
77
|
-
|
|
80
|
+
Place in the **work directory**. Each role runs on its model, so per-model quota is tracked
|
|
81
|
+
(works for local LLMs — they show 0%).
|
|
78
82
|
```jsonc
|
|
79
83
|
{
|
|
80
|
-
"generator": "
|
|
81
|
-
"grader": "
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
+
"generator": "opencode/deepseek-v4-flash-free", // model that produces the work
|
|
85
|
+
"grader": "opencode/mimo-v2.5-free", // model that grades it
|
|
86
|
+
"provider": "", // codexbar quota provider ("" = default)
|
|
87
|
+
"lighterModel": "" // suggested when throttling
|
|
84
88
|
}
|
|
85
89
|
```
|
|
86
90
|
| Field | Default | Notes |
|
|
87
91
|
|---|---|---|
|
|
88
|
-
| `generator` |
|
|
89
|
-
| `grader` | `
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
92
|
+
| `generator` | **required** | any `provider/model` opencode knows |
|
|
93
|
+
| `grader` | falls back to `generator` | can differ (multi-model) |
|
|
94
|
+
| `provider` | `""` (codexbar default) | which provider's quota the guardian watches |
|
|
95
|
+
| `lighterModel` | `""` | shown in throttle advice; env `UC_LIGHTER_MODEL` overrides |
|
|
92
96
|
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
`generator` is **required** — the tools return a clear error if missing. Copy
|
|
98
|
+
`harness.config.example.json` to get started (both example models are free → never gated).
|
|
95
99
|
|
|
96
100
|
### 4. Env vars (thresholds / tuning)
|
|
97
101
|
| Var | Default | Meaning |
|
|
@@ -101,12 +105,9 @@ Missing roles fall back to `UC_MODEL`. Provider for quota is derived from the mo
|
|
|
101
105
|
| `UC_STOP_WEEKLY` | 95 | weekly STOP % |
|
|
102
106
|
| `UC_THROTTLE_WEEKLY` | 85 | weekly throttle % |
|
|
103
107
|
| `UC_STOP_MONTHLY` | 98 | monthly STOP % |
|
|
104
|
-
| `UC_LIGHTER_MODEL` |
|
|
105
|
-
| `UC_PROVIDER` |
|
|
108
|
+
| `UC_LIGHTER_MODEL` | (config `lighterModel`) | suggested model when throttling |
|
|
109
|
+
| `UC_PROVIDER` | (config `provider`) | codexbar provider for the guardian |
|
|
106
110
|
| `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
111
|
| `UC_DEBUG` | 0 | set to `1` for a diagnostic log at `~/.cache/opencode-usage-coach/coach.log` |
|
|
111
112
|
|
|
112
113
|
## Architecture
|
|
@@ -114,13 +115,58 @@ Missing roles fall back to `UC_MODEL`. Provider for quota is derived from the mo
|
|
|
114
115
|
- **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
116
|
- server↔TUI communicate via a state file (`~/.cache/opencode-usage-coach/*.json`) — they are separate processes.
|
|
116
117
|
|
|
117
|
-
Key lessons (
|
|
118
|
+
**Key lessons (hard-won):** never install `solid-js` in the config dir (conflicts with
|
|
118
119
|
opencode's bundled solid); TUI plugins must be compiled + loaded via `tui.json` file path;
|
|
119
120
|
`codexbar` must be called via `spawn` (the `$` BunShell leaks output to the TUI).
|
|
120
121
|
|
|
122
|
+
## Troubleshooting
|
|
123
|
+
|
|
124
|
+
Recurring issues and fixes — mostly learned the hard way during development.
|
|
125
|
+
|
|
126
|
+
### TUI panel missing or harness not shown
|
|
127
|
+
- **Check `tui.json` points at the `dist/tui.js` file path** — not the `plugins/` directory.
|
|
128
|
+
```jsonc
|
|
129
|
+
// ~/.config/opencode/tui.json
|
|
130
|
+
{ "$schema": "https://opencode.ai/tui.json", "plugin": ["/abs/path/dist/tui.js"] }
|
|
131
|
+
```
|
|
132
|
+
- **Do NOT install `solid-js` in the config dir** — conflicts with opencode's bundled solid, causes a crash. Keep it peer + external only.
|
|
133
|
+
- **Export must be `{ tui }`** (an object) — a bare function is misread as a server plugin.
|
|
134
|
+
- **Color prop is `fg`** (not `foreground`): `style={{ fg: theme.current.success }}`.
|
|
135
|
+
- **Call `codexbar` via `spawn`** — the `$` BunShell leaks command output into the TUI.
|
|
136
|
+
- **Harness not visible?** The TUI scans session subdirectories for the most recent `active:true` harness. A finished harness (`active:false`) is hidden.
|
|
137
|
+
|
|
138
|
+
### LLM model selection (generate/grade)
|
|
139
|
+
- **`generator` is required in `harness.config.json`** — the tools return a clear error if missing (the old z.ai fallback was removed in v0.2.4).
|
|
140
|
+
- Format: `"provider/model"` (e.g. `"opencode/deepseek-v4-flash-free"`, `"opencode/mimo-v2.5-free"` — see `harness.config.example.json`).
|
|
141
|
+
- **Config precedence**: workdir `harness.config.json` > global `~/.config/opencode-usage-coach/harness.config.json`.
|
|
142
|
+
- Omitting `grader` falls back to `generator`. If neither is set, the grade tool returns FAIL + guidance.
|
|
143
|
+
- `provider` (quota source) and `lighterModel` (suggested on throttle) are also configurable in the same file.
|
|
144
|
+
|
|
145
|
+
### Parallel harness execution (generate_batch)
|
|
146
|
+
- **Only INDEPENDENT tasks should be batched** — dependent tasks (B needs A) require sequential `generate` calls.
|
|
147
|
+
- `generate_batch` runs each task in a separate sub-session on the same server (shared model config).
|
|
148
|
+
- runModel polls the sub-session until `idle`/`completed` (max 10 min). Trace with `UC_DEBUG=1`:
|
|
149
|
+
```
|
|
150
|
+
runModel(<generator>): session xxx created, prompt 142 chars
|
|
151
|
+
runModel(<generator>): poll 3s status="running"
|
|
152
|
+
runModel(<generator>): done 48s, 1203 chars
|
|
153
|
+
```
|
|
154
|
+
- `(no output)` means the sub-session returned no text. Check `~/.cache/opencode-usage-coach/coach.log` for the runModel trace (requires `UC_DEBUG=1`).
|
|
155
|
+
- **Note**: runModel only terminates on explicit `idle`/`completed` status. An earlier bug broke on `!status` (undefined) immediately — fixed.
|
|
156
|
+
|
|
157
|
+
### Multi-session (per-session state isolation)
|
|
158
|
+
- Each opencode session has a unique sessionID; harness state is isolated per-session:
|
|
159
|
+
```
|
|
160
|
+
~/.cache/opencode-usage-coach/projects/<dir-hash>/<sessionID>/harness.json
|
|
161
|
+
```
|
|
162
|
+
- Different working directories get separate project state (keyed by path hash).
|
|
163
|
+
- The TUI auto-discovers the most recent `active:true` harness — switching sessions shows that session's harness.
|
|
164
|
+
- Harness completion sets `active:false` → hidden from the TUI.
|
|
165
|
+
- Override the state path with `UC_STATE_DIR` (forces global state).
|
|
166
|
+
|
|
121
167
|
## Status
|
|
122
|
-
- ✅
|
|
123
|
-
- ✅
|
|
124
|
-
- ✅
|
|
168
|
+
- ✅ Quota guardian + TUI panel (per-provider coach view, colors, collapsible Alt+H)
|
|
169
|
+
- ✅ Harness: agent mode (triage) with generate/grade model-specific tools (1 terminal, multi-model)
|
|
170
|
+
- ✅ npm packaging (`opencode plugin install opencode-usage-coach`)
|
|
125
171
|
|
|
126
172
|
License: MIT.
|
|
@@ -34,20 +34,22 @@ Default to the loop only when it genuinely adds value. Do not over-engineer smal
|
|
|
34
34
|
## Harness loop (only for substantive work)
|
|
35
35
|
The user's message is the task source. If it has multiple distinct parts, decompose into discrete tasks (N); if it is one unit, N = 1.
|
|
36
36
|
|
|
37
|
-
**Multi-model, 1 terminal:** the work runs via the plugin tools `generate` and `grade`, which use the configured models (generator/grader from harness.config.json) on the same server — so you get e.g.
|
|
37
|
+
**Multi-model, 1 terminal:** the work runs via the plugin tools `generate` and `grade`, which use the configured models (generator/grader from harness.config.json) on the same server — so you get e.g. a paid generator + a free grader without a second terminal. Do NOT use the `task` tool for harness work; use `generate`/`grade`.
|
|
38
38
|
|
|
39
|
-
**
|
|
39
|
+
**Parallel independent tasks:** if the decomposed tasks are INDEPENDENT, prefer `generate_batch` (one call, all results at once) over multiple sequential `generate` calls. Cap by quota coaching (big-OK → 3-4; throttle → 1-2; STOP → none). DEPENDENT tasks (B needs A) → sequential `generate` calls.
|
|
40
40
|
|
|
41
41
|
1. Call `harness_start(name, N)` to register the run on the panel.
|
|
42
42
|
2. For each task i (1..N):
|
|
43
43
|
a. `task_update(i, title, "generating")`.
|
|
44
|
-
b. **Generate** — call `generate({ prompt: "Task: {title}. Perform it for real in the current directory (write/edit files)." })`. The generator model
|
|
45
|
-
c.
|
|
46
|
-
d.
|
|
47
|
-
e.
|
|
44
|
+
b. **Generate** — call `generate({ prompt: "Task: {title}. Perform it for real in the current directory (write/edit files)." })`. The generator model runs in a sub-session and writes files directly — its return value is a summary, NOT the work itself.
|
|
45
|
+
c. **Verify the work** — after generate returns, read the files it should have produced (use `read`/`glob`) to confirm the work actually exists and is non-trivial. Do not trust the summary alone.
|
|
46
|
+
d. `task_update(i, title, "grading")`.
|
|
47
|
+
e. **Grade** — call `grade({ prompt: "Evaluate the result against the request's intent and general quality. Output PASS or FAIL on the first line, then the reason.\\nRequest: {user request}\\nTask: {title}" })`. The grade tool normalizes the verdict: PASS/FAIL is always on the first line.
|
|
48
|
+
f. Parse the verdict (first non-empty line):
|
|
48
49
|
- `PASS` → `task_update(i, title, "completed", score:"PASS")` → next.
|
|
49
|
-
- `FAIL` and revisions < 2 → `task_update(i, title, "revising", revisions:k)` → `generate({ prompt: "Apply the grading feedback and improve:\\n{grade result}\\nTask: {title}" })` → back to (
|
|
50
|
+
- `FAIL` and revisions < 2 → `task_update(i, title, "revising", revisions:k)` → `generate({ prompt: "Apply the grading feedback and improve:\\n{grade result}\\nTask: {title}" })` → back to (d) re-grade.
|
|
50
51
|
- `FAIL` and revisions exhausted → `task_update(i, title, "failed", score:"FAIL")` → next.
|
|
52
|
+
g. **Error handling:** if `generate` returns text starting with `ERROR:` or `[runModel TIMEOUT`, the sub-session failed. Log it via `task_update(i, title, "failed")` and continue to the next task — do not loop forever on a broken task.
|
|
51
53
|
3. When all tasks are done → `harness_done()`.
|
|
52
54
|
|
|
53
55
|
## Rules
|
package/dist/index.js
CHANGED
|
@@ -74,23 +74,40 @@ function readHarnessCfg(dir) {
|
|
|
74
74
|
}
|
|
75
75
|
async function runModel(client, model, prompt, directory) {
|
|
76
76
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
77
|
+
const t0 = Date.now();
|
|
77
78
|
try {
|
|
78
79
|
const slash = model.indexOf("/");
|
|
79
80
|
const providerID = slash >= 0 ? model.slice(0, slash) : model;
|
|
80
81
|
const modelID = slash >= 0 ? model.slice(slash + 1) : "";
|
|
81
82
|
const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
|
|
82
83
|
const id = s?.data?.info?.id;
|
|
83
|
-
if (!id)
|
|
84
|
+
if (!id) {
|
|
85
|
+
log(`runModel(${model}): no session id returned`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
log(`runModel(${model}): session ${id} created, prompt ${prompt.length} chars`);
|
|
84
89
|
await client.session.prompt({
|
|
85
90
|
path: { id },
|
|
86
91
|
body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
|
|
87
92
|
});
|
|
93
|
+
let lastStatus = "";
|
|
94
|
+
let timedOut = false;
|
|
88
95
|
for (let i = 0; i < 600; i++) {
|
|
89
96
|
await sleep(1e3);
|
|
90
97
|
const st = await client.session.status({ path: { id } });
|
|
91
98
|
const status = st?.data?.[id]?.status ?? st?.data?.status;
|
|
92
|
-
if (status
|
|
99
|
+
if (status !== lastStatus) {
|
|
100
|
+
log(`runModel(${model}): poll ${i}s status="${status}"`);
|
|
101
|
+
lastStatus = String(status);
|
|
102
|
+
}
|
|
103
|
+
if (status === "idle" || status === "completed") break;
|
|
104
|
+
if (status === "error" || status === "failed") {
|
|
105
|
+
log(`runModel(${model}): sub-session ended with "${status}"`);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
if (i === 599) timedOut = true;
|
|
93
109
|
}
|
|
110
|
+
const elapsed = Math.round((Date.now() - t0) / 1e3);
|
|
94
111
|
const msgs = await client.session.messages({ path: { id } });
|
|
95
112
|
const all = msgs?.data ?? [];
|
|
96
113
|
const lastAssistant = all.filter((m) => m?.info?.role === "assistant").pop();
|
|
@@ -100,13 +117,15 @@ async function runModel(client, model, prompt, directory) {
|
|
|
100
117
|
await client.session.remove?.({ path: { id } });
|
|
101
118
|
} catch {
|
|
102
119
|
}
|
|
120
|
+
log(`runModel(${model}): done ${elapsed}s, ${text.length} chars${timedOut ? " [TIMEOUT]" : ""}`);
|
|
121
|
+
if (timedOut) return `[runModel TIMEOUT after ${elapsed}s \u2014 result may be incomplete]
|
|
122
|
+
${text.trim()}`;
|
|
103
123
|
return text.trim() || null;
|
|
104
124
|
} catch (e) {
|
|
105
|
-
log(`runModel err (${model}): ${String(e)}`);
|
|
125
|
+
log(`runModel err (${model}, ${Math.round((Date.now() - t0) / 1e3)}s): ${String(e)}`);
|
|
106
126
|
return null;
|
|
107
127
|
}
|
|
108
128
|
}
|
|
109
|
-
var PROVIDER = process.env.UC_PROVIDER ?? "zai";
|
|
110
129
|
var num = (e, d) => {
|
|
111
130
|
try {
|
|
112
131
|
const v = Number(process.env[e]);
|
|
@@ -120,7 +139,6 @@ var THR_5H = num("UC_THROTTLE_5H", 70);
|
|
|
120
139
|
var STOP_WK = num("UC_STOP_WEEKLY", 95);
|
|
121
140
|
var THR_WK = num("UC_THROTTLE_WEEKLY", 85);
|
|
122
141
|
var STOP_MO = num("UC_STOP_MONTHLY", 98);
|
|
123
|
-
var LIGHTER = process.env.UC_LIGHTER_MODEL ?? "glm-4.5-air";
|
|
124
142
|
function humanRemaining(iso) {
|
|
125
143
|
try {
|
|
126
144
|
if (!iso) return "";
|
|
@@ -191,12 +209,13 @@ async function fetchProvidersCoach() {
|
|
|
191
209
|
}));
|
|
192
210
|
return results.filter(Boolean);
|
|
193
211
|
}
|
|
194
|
-
function fetchQuota() {
|
|
212
|
+
function fetchQuota(provider) {
|
|
195
213
|
return new Promise((resolve2) => {
|
|
196
214
|
let out = "";
|
|
197
215
|
let p;
|
|
198
216
|
try {
|
|
199
|
-
|
|
217
|
+
const args = provider ? ["usage", "--provider", provider, "--json"] : ["usage", "--json"];
|
|
218
|
+
p = spawn("codexbar", args, {
|
|
200
219
|
stdio: ["ignore", "pipe", "ignore"]
|
|
201
220
|
});
|
|
202
221
|
} catch {
|
|
@@ -219,12 +238,12 @@ function fetchQuota() {
|
|
|
219
238
|
});
|
|
220
239
|
});
|
|
221
240
|
}
|
|
222
|
-
function coach(q) {
|
|
241
|
+
function coach(q, lighter) {
|
|
223
242
|
if (!q) return { decision: "GO", advice: "quota unavailable \u2014 proceeding cautiously.", weekly: -1, monthly: -1, fiveHour: -1 };
|
|
224
243
|
const wk = Math.round(q.weekly?.usedPercent ?? 0), mo = Math.round(q.monthly?.usedPercent ?? 0), h5 = Math.round(q.fiveHour?.usedPercent ?? 0);
|
|
225
244
|
const wkR = humanRemaining(q.weekly?.resetsAt), h5R = humanRemaining(q.fiveHour?.resetsAt);
|
|
226
245
|
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 });
|
|
227
|
-
const thr = (r) => ({ decision: "THROTTLE", advice: `Throttle recommend \u2014 ${r}. switch to lighter model (${
|
|
246
|
+
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 });
|
|
228
247
|
if (h5 >= STOP_5H) return stop(`5h window ${h5}% (${h5R})`);
|
|
229
248
|
if (wk >= STOP_WK) return stop(`weekly ${wk}% (${wkR})`);
|
|
230
249
|
if (mo >= STOP_MO) return stop(`monthly ${mo}%`);
|
|
@@ -236,6 +255,9 @@ var LOADING = { decision: "GO", advice: "quota loading\u2026", weekly: -1, month
|
|
|
236
255
|
async function UsageCoachPlugin(input) {
|
|
237
256
|
try {
|
|
238
257
|
setStateDir(input.directory);
|
|
258
|
+
const cfg0 = readHarnessCfg(input.directory);
|
|
259
|
+
const PROVIDER = process.env.UC_PROVIDER ?? cfg0.provider ?? "";
|
|
260
|
+
const LIGHTER = process.env.UC_LIGHTER_MODEL ?? cfg0.lighterModel ?? "a lighter model";
|
|
239
261
|
let last = null;
|
|
240
262
|
let lastFetchedAt = 0;
|
|
241
263
|
let refreshing = false;
|
|
@@ -244,9 +266,9 @@ async function UsageCoachPlugin(input) {
|
|
|
244
266
|
if (refreshing) return;
|
|
245
267
|
if (last && Date.now() - lastFetchedAt < TTL_MS) return;
|
|
246
268
|
refreshing = true;
|
|
247
|
-
fetchQuota().then(async (q) => {
|
|
269
|
+
fetchQuota(PROVIDER).then(async (q) => {
|
|
248
270
|
try {
|
|
249
|
-
last = coach(q);
|
|
271
|
+
last = coach(q, LIGHTER);
|
|
250
272
|
lastFetchedAt = Date.now();
|
|
251
273
|
let providers = [];
|
|
252
274
|
try {
|
|
@@ -356,9 +378,9 @@ async function UsageCoachPlugin(input) {
|
|
|
356
378
|
args: { prompt: tool.schema.string() },
|
|
357
379
|
async execute(args, ctx) {
|
|
358
380
|
const cfg = readHarnessCfg(ctx.directory);
|
|
359
|
-
|
|
360
|
-
const out = await runModel(input.client,
|
|
361
|
-
return out ?? `(generator ${
|
|
381
|
+
if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
|
|
382
|
+
const out = await runModel(input.client, cfg.generator, args.prompt, ctx.directory);
|
|
383
|
+
return out ?? `(generator ${cfg.generator} produced no text)`;
|
|
362
384
|
}
|
|
363
385
|
}),
|
|
364
386
|
generate_batch: tool({
|
|
@@ -366,9 +388,9 @@ async function UsageCoachPlugin(input) {
|
|
|
366
388
|
args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })) },
|
|
367
389
|
async execute(args, ctx) {
|
|
368
390
|
const cfg = readHarnessCfg(ctx.directory);
|
|
369
|
-
|
|
391
|
+
if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
|
|
370
392
|
const results = await Promise.all(args.tasks.map(async (t) => {
|
|
371
|
-
const out = await runModel(input.client,
|
|
393
|
+
const out = await runModel(input.client, cfg.generator, t.prompt, ctx.directory);
|
|
372
394
|
return `[task ${t.id}] ${out ?? "(no output)"}`;
|
|
373
395
|
}));
|
|
374
396
|
return results.join("\n\n");
|
|
@@ -379,9 +401,20 @@ async function UsageCoachPlugin(input) {
|
|
|
379
401
|
args: { prompt: tool.schema.string() },
|
|
380
402
|
async execute(args, ctx) {
|
|
381
403
|
const cfg = readHarnessCfg(ctx.directory);
|
|
382
|
-
const model = cfg.grader ?? cfg.generator
|
|
404
|
+
const model = cfg.grader ?? cfg.generator;
|
|
405
|
+
if (!model) return 'FAIL\n(ERROR: no grader/generator model configured. Set "grader" and "generator" in harness.config.json.)';
|
|
383
406
|
const out = await runModel(input.client, model, args.prompt, ctx.directory);
|
|
384
|
-
|
|
407
|
+
if (!out) return "FAIL\n(grader produced no output \u2014 treating as FAIL to trigger a revise)";
|
|
408
|
+
const firstLine = out.split("\n").find((l) => l.trim()) ?? "";
|
|
409
|
+
const f = firstLine.trim();
|
|
410
|
+
const isPass = /^pass\b/i.test(f);
|
|
411
|
+
const isFail = /^fail\b/i.test(f);
|
|
412
|
+
if (!isPass && !isFail) {
|
|
413
|
+
return `FAIL
|
|
414
|
+
(grader did not output PASS/FAIL on the first line; defaulting to FAIL to trigger a revise)
|
|
415
|
+
${out}`;
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
385
418
|
}
|
|
386
419
|
})
|
|
387
420
|
}
|
package/dist/tui.js
CHANGED
|
@@ -5,7 +5,7 @@ import { effect as _$effect } from "@opentui/solid";
|
|
|
5
5
|
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
6
6
|
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
7
7
|
import { createElement as _$createElement } from "@opentui/solid";
|
|
8
|
-
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs";
|
|
8
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
9
9
|
import { createHash } from "crypto";
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { join, resolve } from "path";
|
|
@@ -29,8 +29,44 @@ function readState() {
|
|
|
29
29
|
}
|
|
30
30
|
function readHarness() {
|
|
31
31
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
let best = null;
|
|
33
|
+
let entries = [];
|
|
34
|
+
try {
|
|
35
|
+
entries = readdirSync(STATE_DIR);
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
for (const d of entries) {
|
|
39
|
+
const sub = join(STATE_DIR, d);
|
|
40
|
+
let isDir = false;
|
|
41
|
+
try {
|
|
42
|
+
isDir = statSync(sub).isDirectory();
|
|
43
|
+
} catch {
|
|
44
|
+
}
|
|
45
|
+
if (!isDir) continue;
|
|
46
|
+
const f = join(sub, "harness.json");
|
|
47
|
+
if (!existsSync(f)) continue;
|
|
48
|
+
let st;
|
|
49
|
+
try {
|
|
50
|
+
st = statSync(f);
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
let active = false;
|
|
55
|
+
try {
|
|
56
|
+
active = !!JSON.parse(readFileSync(f, "utf8")).active;
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
if (!best || active && !best.active || active === best.active && st.mtimeMs > best.mtime) {
|
|
60
|
+
best = {
|
|
61
|
+
file: f,
|
|
62
|
+
mtime: st.mtimeMs,
|
|
63
|
+
active
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (best) return JSON.parse(readFileSync(best.file, "utf8"));
|
|
68
|
+
if (existsSync(HARNESS_FILE)) return JSON.parse(readFileSync(HARNESS_FILE, "utf8"));
|
|
69
|
+
return null;
|
|
34
70
|
} catch {
|
|
35
71
|
return null;
|
|
36
72
|
}
|
|
@@ -334,7 +370,7 @@ function initializeTui(api, disposeRoot) {
|
|
|
334
370
|
_$effect((_$p) => _$setProp(_el$59, "style", st(sKey), _$p));
|
|
335
371
|
return _el$59;
|
|
336
372
|
})());
|
|
337
|
-
const pv = t.model ?
|
|
373
|
+
const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
|
|
338
374
|
const q = pv && h.quotas?.[pv] ? h.quotas[pv] : null;
|
|
339
375
|
const pct = q ? q.fiveHour : 0;
|
|
340
376
|
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.5",
|
|
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": {
|