opencode-usage-coach 0.2.5 → 0.3.1

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.
@@ -36,7 +36,12 @@ The user's message is the task source. If it has multiple distinct parts, decomp
36
36
 
37
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
- **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.
39
+ **Quota-aware loop strategy (the tools handle this):** the quota coaching injected into your system prompt drives loop strategy. You do NOT pick the model — `generate`/`generate_batch` auto-switch to a lighter model on THROTTLE (if `lighterModel` is configured) and refuse on STOP. You pick the **task size and parallelism**:
40
+ - **GO + headroom** → big tasks OK, use `generate_batch` for independent tasks (full parallel).
41
+ - **THROTTLE** → small tasks only, sequential or limited parallelism. Tools cap concurrency at 2 and use a lighter model automatically.
42
+ - **STOP** → finish the in-progress task, then halt. `generate_batch` returns an error on STOP — call `task_update(current, "halted_quota")` and stop.
43
+
44
+ DEPENDENT tasks (B needs A) → always sequential `generate` calls, regardless of quota.
40
45
 
41
46
  1. Call `harness_start(name, N)` to register the run on the panel.
42
47
  2. For each task i (1..N):
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ function setStateDir(dir) {
25
25
  }
26
26
  var NOOP_HOOKS = {};
27
27
  function log(msg) {
28
- if (DEBUG) try {
28
+ try {
29
29
  appendFileSync(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
30
30
  `);
31
31
  } catch {
@@ -80,50 +80,53 @@ async function runModel(client, model, prompt, directory) {
80
80
  const providerID = slash >= 0 ? model.slice(0, slash) : model;
81
81
  const modelID = slash >= 0 ? model.slice(slash + 1) : "";
82
82
  const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
83
- const id = s?.data?.info?.id;
84
- if (!id) {
85
- log(`runModel(${model}): no session id returned`);
86
- return null;
87
- }
83
+ log(`runModel(${model}): create resp = ${JSON.stringify(s?.data ?? s).slice(0, 500)}`);
84
+ const id = s?.data?.info?.id ?? s?.data?.id ?? s?.id;
85
+ if (!id) return `ERROR: session.create returned no id (response: ${JSON.stringify(s?.data ?? s).slice(0, 200)})`;
88
86
  log(`runModel(${model}): session ${id} created, prompt ${prompt.length} chars`);
89
87
  await client.session.prompt({
90
88
  path: { id },
91
89
  body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
92
90
  });
93
- let lastStatus = "";
91
+ log(`runModel(${model}): prompt sent to ${id}`);
92
+ let lastMsgCount = -1;
94
93
  let timedOut = false;
94
+ let text = "";
95
95
  for (let i = 0; i < 600; i++) {
96
96
  await sleep(1e3);
97
- const st = await client.session.status({ path: { id } });
98
- const status = st?.data?.[id]?.status ?? st?.data?.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;
97
+ try {
98
+ const msgs = await client.session.messages({ path: { id } });
99
+ const all = msgs?.data ?? msgs ?? [];
100
+ if (all.length !== lastMsgCount) {
101
+ log(`runModel(${model}): poll[${i}] msgs=${all.length}`);
102
+ lastMsgCount = all.length;
103
+ }
104
+ const lastAssistant = all.filter((m) => (m?.info?.role ?? m?.role) === "assistant").pop();
105
+ const parts = lastAssistant?.parts ?? lastAssistant?.info?.parts ?? [];
106
+ const t = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
107
+ if (t) {
108
+ text = t;
109
+ log(`runModel(${model}): got assistant text at poll[${i}] (${t.length} chars)`);
110
+ break;
111
+ }
112
+ } catch (e) {
113
+ log(`runModel(${model}): poll[${i}] messages err: ${String(e).slice(0, 120)}`);
107
114
  }
108
115
  if (i === 599) timedOut = true;
109
116
  }
110
117
  const elapsed = Math.round((Date.now() - t0) / 1e3);
111
- const msgs = await client.session.messages({ path: { id } });
112
- const all = msgs?.data ?? [];
113
- const lastAssistant = all.filter((m) => m?.info?.role === "assistant").pop();
114
- const parts = lastAssistant?.parts ?? [];
115
- const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
116
118
  try {
117
119
  await client.session.remove?.({ path: { id } });
118
120
  } catch {
119
121
  }
120
122
  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()}`;
123
- return text.trim() || null;
123
+ if (timedOut) return `[runModel TIMEOUT after ${elapsed}s \u2014 no assistant text appeared]
124
+ ${text}`;
125
+ return text || `ERROR: no assistant text after ${elapsed}s (${lastMsgCount} messages seen)`;
124
126
  } catch (e) {
125
- log(`runModel err (${model}, ${Math.round((Date.now() - t0) / 1e3)}s): ${String(e)}`);
126
- return null;
127
+ const elapsed = Math.round((Date.now() - t0) / 1e3);
128
+ log(`runModel err (${model}, ${elapsed}s): ${String(e)}`);
129
+ return `ERROR: runModel exception after ${elapsed}s: ${String(e)}`;
127
130
  }
128
131
  }
129
132
  var num = (e, d) => {
@@ -372,28 +375,53 @@ async function UsageCoachPlugin(input) {
372
375
  return "Harness complete.";
373
376
  }
374
377
  }),
375
- // Per-role model execution (config-driven, same server, no deadlock).
378
+ // Per-role model execution (config-driven, quota-aware, same server, no deadlock).
379
+ // P1: quota decision drives model selection + concurrency.
376
380
  generate: tool({
377
- description: "Run the GENERATOR model on a prompt. The generator can use tools (write/edit files). Returns the model's text response.",
381
+ description: "Run the GENERATOR model on a prompt. Quota-aware: on THROTTLE, auto-switches to lighterModel if configured. Returns the model's text response.",
378
382
  args: { prompt: tool.schema.string() },
379
383
  async execute(args, ctx) {
380
384
  const cfg = readHarnessCfg(ctx.directory);
381
385
  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)`;
386
+ let decision = "GO";
387
+ try {
388
+ decision = current().decision;
389
+ } catch {
390
+ }
391
+ const throttle = decision === "THROTTLE" && cfg.lighterModel;
392
+ const model = throttle ? cfg.lighterModel : cfg.generator;
393
+ const out = await runModel(input.client, model, args.prompt, ctx.directory);
394
+ return out + (throttle ? `
395
+ [usage-coach] quota THROTTLE \u2014 used lighter model ${cfg.lighterModel}` : "");
384
396
  }
385
397
  }),
386
398
  generate_batch: tool({
387
- description: "Run the GENERATOR model on MULTIPLE tasks in PARALLEL. Pass an array of {id, prompt}. Returns all results at once. Use for INDEPENDENT tasks (much faster than sequential generate calls).",
399
+ description: "Run the GENERATOR model on MULTIPLE tasks. Quota-aware: GO = full parallel; THROTTLE = lighter model + concurrency capped at 2; STOP = refused. Use for INDEPENDENT tasks.",
388
400
  args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })) },
389
401
  async execute(args, ctx) {
390
402
  const cfg = readHarnessCfg(ctx.directory);
391
403
  if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
392
- const results = await Promise.all(args.tasks.map(async (t) => {
393
- const out = await runModel(input.client, cfg.generator, t.prompt, ctx.directory);
394
- return `[task ${t.id}] ${out ?? "(no output)"}`;
395
- }));
396
- return results.join("\n\n");
404
+ let decision = "GO";
405
+ try {
406
+ decision = current().decision;
407
+ } catch {
408
+ }
409
+ if (decision === "STOP") return 'ERROR: quota STOP \u2014 halt the harness loop now. Call task_update(current, "halted_quota") and stop.';
410
+ const throttle = decision === "THROTTLE" && cfg.lighterModel;
411
+ const model = throttle ? cfg.lighterModel : cfg.generator;
412
+ const limit = decision === "THROTTLE" ? 2 : args.tasks.length;
413
+ const results = [];
414
+ for (let i = 0; i < args.tasks.length; i += limit) {
415
+ const batch = args.tasks.slice(i, i + limit);
416
+ const out = await Promise.all(batch.map(async (t) => {
417
+ const r = await runModel(input.client, model, t.prompt, ctx.directory);
418
+ return `[task ${t.id}] ${r}`;
419
+ }));
420
+ results.push(...out);
421
+ }
422
+ const note = throttle ? `
423
+ [usage-coach] quota THROTTLE \u2014 lighter model ${cfg.lighterModel}, concurrency capped at ${limit}` : "";
424
+ return results.join("\n\n") + note;
397
425
  }
398
426
  }),
399
427
  grade: tool({
@@ -404,7 +432,8 @@ async function UsageCoachPlugin(input) {
404
432
  const model = cfg.grader ?? cfg.generator;
405
433
  if (!model) return 'FAIL\n(ERROR: no grader/generator model configured. Set "grader" and "generator" in harness.config.json.)';
406
434
  const out = await runModel(input.client, model, args.prompt, ctx.directory);
407
- if (!out) return "FAIL\n(grader produced no output \u2014 treating as FAIL to trigger a revise)";
435
+ if (out.startsWith("ERROR:")) return `FAIL
436
+ (${out})`;
408
437
  const firstLine = out.split("\n").find((l) => l.trim()) ?? "";
409
438
  const f = firstLine.trim();
410
439
  const isPass = /^pass\b/i.test(f);
package/dist/tui.js CHANGED
@@ -173,6 +173,7 @@ function initializeTui(api, disposeRoot) {
173
173
  const hf = join(STATE_DIR, sid, "harness.json");
174
174
  if (existsSync(hf)) h = JSON.parse(readFileSync(hf, "utf8"));
175
175
  }
176
+ if (!h || h.active === false) h = readHarness();
176
177
  } catch {
177
178
  h = null;
178
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
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",