opencode-usage-coach 0.3.3 → 0.3.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.
Files changed (3) hide show
  1. package/dist/index.js +113 -1
  2. package/dist/tui.js +47 -19
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -41,6 +41,9 @@ function writeState(c) {
41
41
  function rulesFile() {
42
42
  return join(STATE_DIR, "rules.md");
43
43
  }
44
+ function failuresFile() {
45
+ return join(STATE_DIR, "failures.ndjson");
46
+ }
44
47
  function readRules() {
45
48
  try {
46
49
  const f = rulesFile();
@@ -379,6 +382,110 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
379
382
  return "Harness complete.";
380
383
  }
381
384
  }),
385
+ record_failure: tool({
386
+ description: "Stage 1 (RECORD) of the learning loop. Append a failure record to failures.ndjson for later root-cause analysis.",
387
+ args: {
388
+ task: tool.schema.string(),
389
+ prompt: tool.schema.string(),
390
+ gradeResult: tool.schema.string(),
391
+ model: tool.schema.string().optional(),
392
+ revisions: tool.schema.number().optional()
393
+ },
394
+ async execute(args, _ctx) {
395
+ const rec = { ts: (/* @__PURE__ */ new Date()).toISOString(), task: args.task, prompt: args.prompt, gradeResult: args.gradeResult, model: args.model, revisions: args.revisions };
396
+ try {
397
+ mkdirSync(STATE_DIR, { recursive: true });
398
+ appendFileSync(failuresFile(), JSON.stringify(rec) + "\n");
399
+ } catch (e) {
400
+ log(`record_failure err: ${String(e)}`);
401
+ }
402
+ return `Failure recorded. [usage-coach NEXT] call investigate({failure: ${JSON.stringify(rec)}}) to find the root cause.`;
403
+ }
404
+ }),
405
+ investigate: tool({
406
+ description: "Stage 2 (INVESTIGATE) of the learning loop. Run the generator to analyze the ROOT CAUSE of a failure (not just the symptom).",
407
+ args: {
408
+ task: tool.schema.string(),
409
+ prompt: tool.schema.string(),
410
+ gradeResult: tool.schema.string(),
411
+ model: tool.schema.string().optional()
412
+ },
413
+ async execute(args, ctx) {
414
+ const cfg = readHarnessCfg(ctx.directory);
415
+ if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
416
+ const rcaPrompt = `A task failed. Analyze the ROOT CAUSE (not just the symptom).
417
+ Task: ${args.task}
418
+ What was expected (from grade): ${args.gradeResult}
419
+ Read relevant files in the directory if needed.
420
+ Output a structured root cause:
421
+ category: (one of: constraint-violation, missing-context, tool-misuse, model-limitation, other)
422
+ explanation: <why it failed>
423
+ evidence: <file/line or specific quote>`;
424
+ const out = await runModel(input.client, cfg.generator, rcaPrompt, ctx.directory);
425
+ return out + "\n[usage-coach NEXT] call verify_diagnosis with this diagnosis.";
426
+ }
427
+ }),
428
+ verify_diagnosis: tool({
429
+ description: "Stage 3 (VERIFY) of the learning loop. Run the grader to check whether a diagnosis is CORRECT and ACTIONABLE (leads to a useful rule). Returns PASS/FAIL + a [usage-coach NEXT] directive.",
430
+ args: {
431
+ diagnosis: tool.schema.string(),
432
+ task: tool.schema.string(),
433
+ gradeResult: tool.schema.string()
434
+ },
435
+ async execute(args, ctx) {
436
+ const cfg = readHarnessCfg(ctx.directory);
437
+ const model = cfg.grader ?? cfg.generator;
438
+ if (!model) return "FAIL\n(ERROR: no grader/generator model configured.)\n[usage-coach NEXT] configure grader in harness.config.json, then retry verify_diagnosis.";
439
+ const verifyPrompt = `Verify this root-cause analysis for a failure.
440
+ Task: ${args.task}
441
+ Grade feedback: ${args.gradeResult}
442
+ Diagnosis: ${args.diagnosis}
443
+ Is the diagnosis CORRECT and ACTIONABLE (leads to a useful rule)?
444
+ Output PASS (the diagnosis is right) or FAIL (re-investigate needed), then reason.`;
445
+ const out = await runModel(input.client, model, verifyPrompt, ctx.directory);
446
+ let verdict = "FAIL";
447
+ if (!out.startsWith("ERROR:")) {
448
+ const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
449
+ if (/^pass\b/i.test(f)) verdict = "PASS";
450
+ else verdict = "FAIL";
451
+ }
452
+ const next = verdict === "PASS" ? `
453
+ [usage-coach NEXT] call generalize with this verified diagnosis.` : `
454
+ [usage-coach NEXT] FAIL \u2014 re-investigate the root cause.`;
455
+ return out + "\n" + next;
456
+ }
457
+ }),
458
+ generalize: tool({
459
+ description: "Stage 4 (GENERALIZE) of the learning loop. Run the generator to turn a verified root cause into a reusable rule and append it to rules.md, so the next generate call includes it. Returns the rule text and a [usage-coach NEXT] directive.",
460
+ args: {
461
+ diagnosis: tool.schema.string(),
462
+ task: tool.schema.string()
463
+ },
464
+ async execute(args, ctx) {
465
+ const cfg = readHarnessCfg(ctx.directory);
466
+ if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
467
+ const genPrompt = `Turn this verified root cause into a GENERAL, REUSABLE rule for future tasks of this kind.
468
+ Diagnosis: ${args.diagnosis}
469
+ Failed task: ${args.task}
470
+ Output a single rule in the form: 'For <task-type> tasks, always <check/do X> because <reason>.'
471
+ Keep it concrete and actionable.`;
472
+ const out = await runModel(input.client, cfg.generator, genPrompt, ctx.directory);
473
+ const rule = out;
474
+ try {
475
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
476
+ mkdirSync(STATE_DIR, { recursive: true });
477
+ appendFileSync(rulesFile(), `## Rule (${date})
478
+ ${rule}
479
+ Origin: ${args.task}
480
+
481
+ `);
482
+ } catch (e) {
483
+ log(`generalize err: ${String(e)}`);
484
+ }
485
+ return `${rule}
486
+ [usage-coach NEXT] rule saved to rules.md. The next generate call will include it. Call task_update for the original failed task -> failed, then proceed.`;
487
+ }
488
+ }),
382
489
  // Per-role model execution (config-driven, quota-aware, same server, no deadlock).
383
490
  // P1: quota decision drives model selection + concurrency.
384
491
  generate: tool({
@@ -453,7 +560,12 @@ ${rules}
453
560
  }
454
561
  const next = verdict === "PASS" ? `
455
562
  [usage-coach NEXT] PASS -> call task_update(i, title, "completed", "PASS"), then proceed to next task (or harness_done if last).` : `
456
- [usage-coach NEXT] FAIL -> if revisions < 2: task_update(i, title, "revising", revisions+1) + generate({prompt: "Apply feedback:\\n{grade result}\\nTask: {title}"}); else: task_update(i, title, "failed", "FAIL") -> next task.`;
563
+ [usage-coach NEXT] FAIL -> if revisions < 2: task_update(i, title, "revising", revisions+1) + generate({prompt: "Apply feedback:\\n{grade result}\\nTask: {title}"}); else: run the learning loop before failing \u2014
564
+ 1. record_failure({task, prompt, gradeResult, model, revisions})
565
+ 2. investigate({task, prompt, gradeResult}) -> diagnosis
566
+ 3. verify_diagnosis({diagnosis, task, gradeResult}) -> if PASS: generalize({diagnosis, task}) (saves rule to rules.md)
567
+ 4. task_update(i, title, "failed", "FAIL") -> next task.
568
+ The next generate call will automatically include the new rule.`;
457
569
  return out + "\n" + next;
458
570
  }
459
571
  })
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, readdirSync, statSync } from "fs";
8
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, statSync, appendFileSync } from "fs";
9
9
  import { createHash } from "crypto";
10
10
  import { homedir } from "os";
11
11
  import { join, resolve } from "path";
@@ -91,21 +91,43 @@ function barFill(p) {
91
91
  function barEmpty(p) {
92
92
  return "\u2591".repeat(10 - Math.max(0, Math.min(10, Math.round(p / 10))));
93
93
  }
94
- function short(s, n) {
95
- return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
96
- }
97
94
  function initializeTui(api, disposeRoot) {
98
95
  STATE_DIR = process.env.UC_STATE_DIR ?? projectStateDir(api.state.path.directory);
99
96
  STATE_FILE = join(STATE_DIR, "state.json");
100
97
  HARNESS_FILE = join(STATE_DIR, "harness.json");
101
98
  MARKER = join(STATE_DIR, "tui-loaded.txt");
99
+ const TUI_LOG = join(STATE_DIR, "tui-debug.log");
100
+ const tlog = (msg) => {
101
+ try {
102
+ appendFileSync(MARKER, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
103
+ `);
104
+ appendFileSync(TUI_LOG, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
105
+ `);
106
+ } catch (e) {
107
+ try {
108
+ appendFileSync(MARKER, `TLOG ERR: ${String(e)}
109
+ `);
110
+ } catch {
111
+ }
112
+ }
113
+ };
102
114
  try {
103
115
  mkdirSync(STATE_DIR, {
104
116
  recursive: true
105
117
  });
106
- writeFileSync(MARKER, `loaded ${(/* @__PURE__ */ new Date()).toISOString()} @ ${api.state.path.directory}`);
118
+ writeFileSync(MARKER, `loaded-v2 ${(/* @__PURE__ */ new Date()).toISOString()} @ ${api.state.path.directory}`);
107
119
  } catch {
108
120
  }
121
+ tlog(`init start | dir=${api.state.path.directory} | STATE_DIR=${STATE_DIR}`);
122
+ try {
123
+ tlog(`api keys=${Object.keys(api).join(",")}`);
124
+ tlog(`api.state=${JSON.stringify(api.state).slice(0, 400)}`);
125
+ tlog(`api.state.path=${JSON.stringify(api.state?.path).slice(0, 300)}`);
126
+ const r = api.route;
127
+ tlog(`api.route type=${typeof r} keys=${r && typeof r === "object" ? Object.keys(r).join(",") : "?"} val=${JSON.stringify(r).slice(0, 400)}`);
128
+ } catch (e) {
129
+ tlog(`api probe err: ${String(e)}`);
130
+ }
109
131
  const [getState, setState] = createSignal(readState());
110
132
  const [getHarness, setHarness] = createSignal(readHarness());
111
133
  const timer = setInterval(() => {
@@ -168,7 +190,8 @@ function initializeTui(api, disposeRoot) {
168
190
  }
169
191
  let h = null;
170
192
  try {
171
- const sid = ctx.session_id ?? "";
193
+ const routeSid = api.route?.current?.params?.sessionID ?? "";
194
+ const sid = routeSid || (ctx.session_id ?? "");
172
195
  if (sid) {
173
196
  const hf = join(STATE_DIR, sid, "harness.json");
174
197
  if (existsSync(hf)) h = JSON.parse(readFileSync(hf, "utf8"));
@@ -371,15 +394,17 @@ function initializeTui(api, disposeRoot) {
371
394
  _$insert(_el$59, lbl, _el$62);
372
395
  _$insert(_el$59, rev, _el$62);
373
396
  _$insert(_el$59, elapsedStr, _el$62);
374
- _$insert(_el$59, () => short(t.title, 12), null);
397
+ _$insert(_el$59, () => t.title, null);
375
398
  _$effect((_$p) => _$setProp(_el$59, "style", st(sKey), _$p));
376
399
  return _el$59;
377
400
  })());
378
401
  const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
379
402
  const provCoach = s?.providers?.find((p) => p.id === pv || pv && p.id.startsWith(pv) || pv && pv.startsWith(p.id));
380
- const pct = provCoach ? provCoach.fiveHour : s?.fiveHour ?? 0;
403
+ const rawPct = provCoach ? provCoach.fiveHour : s?.fiveHour ?? 0;
404
+ const pct = rawPct < 0 ? 0 : rawPct;
405
+ const pctLabel = rawPct < 0 ? "n/a" : `${rawPct}%`;
381
406
  nodes.push((() => {
382
- var _el$63 = _$createElement("box"), _el$64 = _$createElement("text"), _el$66 = _$createElement("text"), _el$67 = _$createElement("text"), _el$68 = _$createElement("text"), _el$69 = _$createTextNode(` `), _el$70 = _$createTextNode(`%`);
407
+ var _el$63 = _$createElement("box"), _el$64 = _$createElement("text"), _el$66 = _$createElement("text"), _el$67 = _$createElement("text"), _el$68 = _$createElement("text"), _el$69 = _$createTextNode(` `);
383
408
  _$insertNode(_el$63, _el$64);
384
409
  _$insertNode(_el$63, _el$66);
385
410
  _$insertNode(_el$63, _el$67);
@@ -389,8 +414,7 @@ function initializeTui(api, disposeRoot) {
389
414
  _$insert(_el$66, () => barFill(pct));
390
415
  _$insert(_el$67, () => barEmpty(pct));
391
416
  _$insertNode(_el$68, _el$69);
392
- _$insertNode(_el$68, _el$70);
393
- _$insert(_el$68, pct, _el$70);
417
+ _$insert(_el$68, pctLabel, null);
394
418
  _$effect((_p$) => {
395
419
  var _v$1 = st("text"), _v$10 = st("textMuted");
396
420
  _v$1 !== _p$.e && (_p$.e = _$setProp(_el$66, "style", _v$1, _p$.e));
@@ -405,28 +429,32 @@ function initializeTui(api, disposeRoot) {
405
429
  }
406
430
  }
407
431
  return (() => {
408
- var _el$71 = _$createElement("box");
409
- _$setProp(_el$71, "flexDirection", "column");
410
- _$insert(_el$71, nodes);
411
- return _el$71;
432
+ var _el$70 = _$createElement("box");
433
+ _$setProp(_el$70, "flexDirection", "column");
434
+ _$insert(_el$70, nodes);
435
+ return _el$70;
412
436
  })();
413
437
  };
438
+ tlog("registering slots");
414
439
  api.slots.register({
415
440
  order: 80,
416
441
  slots: {
417
442
  sidebar_footer(ctx) {
443
+ tlog("sidebar_footer slot called");
418
444
  try {
419
445
  return panel(ctx);
420
- } catch {
446
+ } catch (e) {
447
+ tlog(`sidebar_footer err: ${String(e)}`);
421
448
  return (() => {
422
- var _el$72 = _$createElement("text");
423
- _$insertNode(_el$72, _$createTextNode(`usage-coach`));
424
- return _el$72;
449
+ var _el$71 = _$createElement("text");
450
+ _$insertNode(_el$71, _$createTextNode(`usage-coach`));
451
+ return _el$71;
425
452
  })();
426
453
  }
427
454
  }
428
455
  }
429
456
  });
457
+ tlog("slots registered, init complete");
430
458
  }
431
459
  var tui = async (api) => {
432
460
  createRoot((disposeRoot) => initializeTui(api, disposeRoot));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.3.3",
3
+ "version": "0.3.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",