opencode-usage-coach 0.12.0 → 0.13.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 CHANGED
@@ -33,16 +33,23 @@ See **[docs/architecture.md](docs/architecture.md)** for the full design.
33
33
  { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-usage-coach/tui"] }
34
34
  ```
35
35
 
36
- Then wire `codexbar` and drop `agents/usage-coach-harness.md` into `~/.config/opencode/agents/` for agent mode:
36
+ Then run setup:
37
37
 
38
38
  ```bash
39
- # provider quota data source
40
- printf '%s' "$YOUR_PROVIDER_API_KEY" | codexbar config set-api-key --provider <id> --stdin
39
+ usage-coach setup # auto-generates harness.config.json + copies agent file
40
+ usage-coach setup --json # machine-readable output (for scripts/CI)
41
+ ```
42
+
43
+ This creates `~/.config/opencode-usage-coach/harness.config.json` (edit `generator`/`grader` or use `/coach-config` at runtime) and copies `agents/usage-coach-harness.md` to `~/.config/opencode/agents/`.
44
+
45
+ If you use `codexbar` for quota sensing:
41
46
 
42
- # harness role → model mapping (place in your work directory)
43
- cp harness.config.example.json harness.config.json # edit generator/grader
47
+ ```bash
48
+ printf '%s' "$YOUR_PROVIDER_API_KEY" | codexbar config set-api-key --provider <id> --stdin
44
49
  ```
45
50
 
51
+ Without codexbar, the plugin runs in GO-only mode (no quota sensing).
52
+
46
53
  For local dev without npm: `bun install && bun run build`, then point both configs at the `dist/` files.
47
54
 
48
55
  ## Configuration
package/dist/cli.js ADDED
@@ -0,0 +1,564 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import {
5
+ readFileSync,
6
+ writeFileSync,
7
+ existsSync,
8
+ readdirSync,
9
+ statSync,
10
+ mkdirSync,
11
+ copyFileSync
12
+ } from "fs";
13
+ import { createHash } from "crypto";
14
+ import { homedir } from "os";
15
+ import { join, resolve, dirname } from "path";
16
+ import { fileURLToPath } from "url";
17
+ import { spawnSync } from "child_process";
18
+ function projectStateDir(dir) {
19
+ const abs = resolve(dir || ".");
20
+ const h = createHash("sha1").update(abs).digest("hex").slice(0, 12);
21
+ return join(homedir(), ".cache", "opencode-usage-coach", "projects", h);
22
+ }
23
+ function resolveStateDir(dir) {
24
+ return process.env.UC_STATE_DIR ?? projectStateDir(dir ?? process.cwd());
25
+ }
26
+ var CACHE_ROOT = join(homedir(), ".cache", "opencode-usage-coach");
27
+ function readJson(path) {
28
+ try {
29
+ if (!existsSync(path)) return null;
30
+ return JSON.parse(readFileSync(path, "utf8"));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+ function readLines(path) {
36
+ try {
37
+ if (!existsSync(path)) return [];
38
+ return readFileSync(path, "utf8").split("\n").filter(Boolean);
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+ function readNdjson(path) {
44
+ return readLines(path).map((l) => {
45
+ try {
46
+ return JSON.parse(l);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }).filter((x) => x !== null);
51
+ }
52
+ function readText(path) {
53
+ try {
54
+ if (!existsSync(path)) return "";
55
+ return readFileSync(path, "utf8");
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+ function countFileLines(path) {
61
+ return readLines(path).length;
62
+ }
63
+ function findHarness(stateDir) {
64
+ let best = null;
65
+ let entries = [];
66
+ try {
67
+ entries = readdirSync(stateDir);
68
+ } catch {
69
+ }
70
+ for (const d of entries) {
71
+ const sub = join(stateDir, d);
72
+ let isDir = false;
73
+ try {
74
+ isDir = statSync(sub).isDirectory();
75
+ } catch {
76
+ }
77
+ if (!isDir) continue;
78
+ const f = join(sub, "harness.json");
79
+ if (!existsSync(f)) continue;
80
+ let st;
81
+ try {
82
+ st = statSync(f);
83
+ } catch {
84
+ continue;
85
+ }
86
+ let active = false;
87
+ try {
88
+ active = !!JSON.parse(readFileSync(f, "utf8")).active;
89
+ } catch {
90
+ }
91
+ if (!best || active && !best.active || active === best.active && st.mtimeMs > best.mtime) {
92
+ best = { file: f, mtime: st.mtimeMs, active };
93
+ }
94
+ }
95
+ if (best) return readJson(best.file);
96
+ return readJson(join(stateDir, "harness.json"));
97
+ }
98
+ function readStatus(dir) {
99
+ const stateDir = resolveStateDir(dir);
100
+ const s = readJson(join(stateDir, "state.json"));
101
+ const h = findHarness(stateDir);
102
+ const rulesCount = parseRules(readText(join(stateDir, "rules.md"))).length;
103
+ const failuresCount = countFileLines(join(stateDir, "failures.ndjson"));
104
+ const domainNodes = countFileLines(join(stateDir, "nodes.ndjson"));
105
+ const domainEdges = countFileLines(join(stateDir, "edges.ndjson"));
106
+ return {
107
+ directory: resolve(dir ?? process.cwd()),
108
+ stateDir,
109
+ quota: s ? {
110
+ decision: s.decision,
111
+ fiveHour: s.fiveHour,
112
+ weekly: s.weekly,
113
+ monthly: s.monthly,
114
+ model: s.model,
115
+ provider: s.provider,
116
+ isFree: s.isFree,
117
+ advice: s.advice
118
+ } : null,
119
+ providers: s?.providers ?? null,
120
+ harness: h ? {
121
+ active: h.active ?? false,
122
+ name: h.name,
123
+ total: h.total,
124
+ current: h.current,
125
+ tasks: h.tasks.map((t) => ({
126
+ id: t.id,
127
+ title: t.title,
128
+ status: t.status,
129
+ score: t.score ?? void 0,
130
+ model: t.model,
131
+ steps: t.subStep
132
+ }))
133
+ } : null,
134
+ learning: { rulesCount, failuresCount, domainNodes, domainEdges },
135
+ updatedAt: s?.updatedAt
136
+ };
137
+ }
138
+ function readAggregateStatus() {
139
+ const projectsDir = join(CACHE_ROOT, "projects");
140
+ let dirs = [];
141
+ try {
142
+ dirs = readdirSync(projectsDir).map((d) => join(projectsDir, d));
143
+ } catch {
144
+ }
145
+ const instances = [];
146
+ const decCount = {};
147
+ let max5h = 0, maxWk = 0, maxMo = 0, activeHarnesses = 0, totalTasks = 0, totalRules = 0, totalFailures = 0, totalDomainNodes = 0, totalDomainEdges = 0;
148
+ for (const d of dirs) {
149
+ let isDir = false;
150
+ try {
151
+ isDir = statSync(d).isDirectory();
152
+ } catch {
153
+ continue;
154
+ }
155
+ if (!isDir) continue;
156
+ const s = readJson(join(d, "state.json"));
157
+ const decision = s?.decision ?? "unknown";
158
+ const fiveHour = s?.fiveHour ?? 0;
159
+ const weekly = s?.weekly ?? 0;
160
+ instances.push({
161
+ directory: d,
162
+ stateDir: d,
163
+ decision,
164
+ fiveHour,
165
+ weekly,
166
+ model: s?.model
167
+ });
168
+ decCount[decision] = (decCount[decision] ?? 0) + 1;
169
+ max5h = Math.max(max5h, fiveHour);
170
+ maxWk = Math.max(maxWk, weekly);
171
+ maxMo = Math.max(maxMo, s?.monthly ?? 0);
172
+ const h = findHarness(d);
173
+ if (h?.active) {
174
+ activeHarnesses++;
175
+ totalTasks += h.tasks.length;
176
+ }
177
+ totalRules += parseRules(readText(join(d, "rules.md"))).length;
178
+ totalFailures += countFileLines(join(d, "failures.ndjson"));
179
+ totalDomainNodes += countFileLines(join(d, "nodes.ndjson"));
180
+ totalDomainEdges += countFileLines(join(d, "edges.ndjson"));
181
+ }
182
+ return {
183
+ instanceCount: instances.length,
184
+ instances,
185
+ aggregate: {
186
+ maxFiveHour: max5h,
187
+ maxWeekly: maxWk,
188
+ maxMonthly: maxMo,
189
+ decisions: decCount,
190
+ activeHarnesses,
191
+ totalTasks,
192
+ totalRules,
193
+ totalFailures,
194
+ totalDomainNodes,
195
+ totalDomainEdges
196
+ }
197
+ };
198
+ }
199
+ function readRules(dir) {
200
+ const stateDir = resolveStateDir(dir);
201
+ const content = readText(join(stateDir, "rules.md"));
202
+ const rules = parseRules(content);
203
+ return { count: rules.length, rules };
204
+ }
205
+ function parseRules(content) {
206
+ if (!content.trim()) return [];
207
+ const blocks = content.split(/^## /m).filter((b) => b.startsWith("Rule"));
208
+ return blocks.map((block) => {
209
+ const headerMatch = block.match(
210
+ /^Rule\s+(\d+)\s*\(([^,]+),\s*category:\s*([^)]+)\)/
211
+ );
212
+ const number = headerMatch ? parseInt(headerMatch[1], 10) : 0;
213
+ const date = headerMatch ? headerMatch[2].trim() : "";
214
+ const category = headerMatch ? headerMatch[3].trim() : "";
215
+ const body = block.slice(headerMatch?.[0]?.length ?? 0).trim();
216
+ const originMatch = body.match(/^Origin:\s*(.+)$/m);
217
+ const text = body.split("\n").filter((l) => !l.startsWith("Origin:")).join(" ").trim();
218
+ return {
219
+ number,
220
+ category,
221
+ date,
222
+ text,
223
+ origin: originMatch ? originMatch[1].trim() : ""
224
+ };
225
+ });
226
+ }
227
+ function readDecisions(dir, limit = 20) {
228
+ const stateDir = resolveStateDir(dir);
229
+ const lines = readLines(join(stateDir, "coach.log"));
230
+ const decisions = [];
231
+ for (let i = lines.length - 1; i >= 0 && decisions.length < limit; i--) {
232
+ const line = lines[i];
233
+ const m = line.match(
234
+ /^(\S+)\s+DECIDE\s+(GO|THROTTLE|STOP)\s+(.*)$/
235
+ );
236
+ if (m) {
237
+ decisions.push({
238
+ ts: m[1],
239
+ decision: m[2],
240
+ detail: m[3]
241
+ });
242
+ }
243
+ }
244
+ return { count: decisions.length, decisions };
245
+ }
246
+ function readDomainStats(dir) {
247
+ const stateDir = resolveStateDir(dir);
248
+ const nodes = readNdjson(join(stateDir, "nodes.ndjson"));
249
+ const edges = readNdjson(join(stateDir, "edges.ndjson"));
250
+ const nodeTypes = {};
251
+ for (const n of nodes) {
252
+ const t = n.type ?? "unknown";
253
+ nodeTypes[t] = (nodeTypes[t] ?? 0) + 1;
254
+ }
255
+ const edgeTypes = {};
256
+ for (const e of edges) {
257
+ const r = e.rel ?? "unknown";
258
+ edgeTypes[r] = (edgeTypes[r] ?? 0) + 1;
259
+ }
260
+ return {
261
+ nodes: nodes.length,
262
+ edges: edges.length,
263
+ nodeTypes,
264
+ edgeTypes
265
+ };
266
+ }
267
+ function bar(pct) {
268
+ const n = pct <= 0 ? 0 : Math.max(1, Math.min(10, Math.round(pct / 10)));
269
+ return "\u2588".repeat(n) + "\u2591".repeat(10 - n);
270
+ }
271
+ function formatStatus(r) {
272
+ const lines = [];
273
+ if (!r.quota) {
274
+ lines.push("usage-coach: no state (is the plugin running?)");
275
+ return lines.join("\n");
276
+ }
277
+ const q = r.quota;
278
+ const tag = q.isFree ? "free" : q.decision;
279
+ const model = q.model ? ` ${q.model.split("/").pop()}` : "";
280
+ lines.push(`usage-coach [${tag}]${model}`);
281
+ if (!q.isFree) {
282
+ lines.push(` 5h ${bar(q.fiveHour)} ${q.fiveHour}%`);
283
+ lines.push(` 1w ${bar(q.weekly)} ${q.weekly}%`);
284
+ }
285
+ if (q.advice) lines.push(` ${q.advice}`);
286
+ if (r.harness?.active) {
287
+ lines.push("");
288
+ lines.push(`harness: ${r.harness.name} ${r.harness.current}/${r.harness.total}`);
289
+ for (const t of r.harness.tasks) {
290
+ const score = t.score ? ` [${t.score}]` : "";
291
+ lines.push(` ${t.id} [${t.status}]${score} ${t.title}`);
292
+ }
293
+ }
294
+ if (r.learning.rulesCount > 0) {
295
+ lines.push("");
296
+ lines.push(
297
+ `learning: ${r.learning.rulesCount} rules, ${r.learning.failuresCount} failures, ${r.learning.domainNodes} domain nodes`
298
+ );
299
+ }
300
+ return lines.join("\n");
301
+ }
302
+ function formatAggregate(r) {
303
+ const lines = [];
304
+ const a = r.aggregate;
305
+ lines.push(`usage-coach aggregate \u2014 ${r.instanceCount} instances`);
306
+ lines.push(` max 5h ${bar(a.maxFiveHour)} ${a.maxFiveHour}%`);
307
+ lines.push(` max 1w ${bar(a.maxWeekly)} ${a.maxWeekly}%`);
308
+ const decs = Object.entries(a.decisions).map(([k, v]) => `${k}:${v}`).join(" ");
309
+ lines.push(` decisions: ${decs}`);
310
+ lines.push(
311
+ ` harnesses: ${a.activeHarnesses} active, ${a.totalTasks} tasks`
312
+ );
313
+ lines.push(
314
+ ` learning: ${a.totalRules} rules, ${a.totalFailures} failures, ${a.totalDomainNodes} domain nodes`
315
+ );
316
+ return lines.join("\n");
317
+ }
318
+ function formatRules(r) {
319
+ if (r.count === 0) return "No rules accumulated yet.";
320
+ const lines = [`${r.count} rules:`];
321
+ for (const rule of r.rules) {
322
+ lines.push(
323
+ ` #${rule.number} (${rule.date}, ${rule.category}): ${rule.text.slice(0, 80)}${rule.text.length > 80 ? "..." : ""}`
324
+ );
325
+ }
326
+ return lines.join("\n");
327
+ }
328
+ function formatDecisions(r) {
329
+ if (r.count === 0) return "No decisions logged.";
330
+ const lines = [`${r.count} recent decisions:`];
331
+ for (const d of r.decisions) {
332
+ lines.push(` ${d.ts} ${d.decision} ${d.detail}`);
333
+ }
334
+ return lines.join("\n");
335
+ }
336
+ function formatDomain(r) {
337
+ if (r.nodes === 0 && r.edges === 0) return "No domain knowledge stored.";
338
+ const nt = Object.entries(r.nodeTypes).map(([k, v]) => `${k}:${v}`).join(" ");
339
+ const et = Object.entries(r.edgeTypes).map(([k, v]) => `${k}:${v}`).join(" ");
340
+ return [
341
+ `domain: ${r.nodes} nodes, ${r.edges} edges`,
342
+ ` node types: ${nt}`,
343
+ ` edge types: ${et}`
344
+ ].join("\n");
345
+ }
346
+ var GLOBAL_CONFIG_DIR = join(homedir(), ".config", "opencode-usage-coach");
347
+ var OPENCODE_AGENTS_DIR = join(homedir(), ".config", "opencode", "agents");
348
+ var DEFAULT_HARNESS_CONFIG = {
349
+ generator: "opencode/deepseek-v4-flash-free",
350
+ grader: "opencode/mimo-v2.5-free",
351
+ provider: "",
352
+ lighterModel: ""
353
+ };
354
+ function resolveAgentSourceFile() {
355
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
356
+ return join(scriptDir, "..", "agents", "usage-coach-harness.md");
357
+ }
358
+ function detectCodexbar() {
359
+ try {
360
+ const r = spawnSync("codexbar", ["--version"], { timeout: 5e3 });
361
+ if (r.status === 0 || r.stdout && r.stdout.toString().trim().length > 0) {
362
+ return { found: true, version: (r.stdout?.toString().trim() ?? "") || "unknown" };
363
+ }
364
+ return { found: false, version: "" };
365
+ } catch {
366
+ return { found: false, version: "" };
367
+ }
368
+ }
369
+ function doSetup(opts = {}) {
370
+ const configDir = opts.configDir ?? GLOBAL_CONFIG_DIR;
371
+ const agentsDir = opts.agentsDir ?? OPENCODE_AGENTS_DIR;
372
+ const agentSource = opts.agentSourceFile ?? resolveAgentSourceFile();
373
+ const configPath = join(configDir, "harness.config.json");
374
+ let configAction;
375
+ if (existsSync(configPath)) {
376
+ configAction = "exists";
377
+ } else {
378
+ mkdirSync(configDir, { recursive: true });
379
+ writeFileSync(
380
+ configPath,
381
+ JSON.stringify(DEFAULT_HARNESS_CONFIG, null, 2) + "\n"
382
+ );
383
+ configAction = "created";
384
+ }
385
+ const codexbar = detectCodexbar();
386
+ const agentDestPath = join(agentsDir, "usage-coach-harness.md");
387
+ let agentAction;
388
+ if (!existsSync(agentSource)) {
389
+ agentAction = "source-not-found";
390
+ } else if (existsSync(agentDestPath)) {
391
+ agentAction = "exists";
392
+ } else {
393
+ mkdirSync(agentsDir, { recursive: true });
394
+ copyFileSync(agentSource, agentDestPath);
395
+ agentAction = "copied";
396
+ }
397
+ return {
398
+ harnessConfig: { action: configAction, path: configPath },
399
+ codexbar,
400
+ agentFile: { action: agentAction, path: agentDestPath }
401
+ };
402
+ }
403
+ function formatSetup(r) {
404
+ const lines = ["usage-coach setup", ""];
405
+ const configCheck = r.harnessConfig.action === "created" ? "\u2705" : "\u2705";
406
+ lines.push(
407
+ ` ${configCheck} harness.config.json ${r.harnessConfig.action === "created" ? "created" : "already exists"}`
408
+ );
409
+ lines.push(` ${r.harnessConfig.path}`);
410
+ if (r.harnessConfig.action === "created") {
411
+ lines.push(
412
+ ` generator: ${DEFAULT_HARNESS_CONFIG.generator} (edit or use /coach-config)`
413
+ );
414
+ }
415
+ if (r.codexbar.found) {
416
+ lines.push("");
417
+ lines.push(` \u2705 codexbar found`);
418
+ lines.push(` ${r.codexbar.version}`);
419
+ } else {
420
+ lines.push("");
421
+ lines.push(` \u26A0\uFE0F codexbar not found`);
422
+ lines.push(` Plugin runs in GO-only mode (no quota sensing)`);
423
+ }
424
+ lines.push("");
425
+ if (r.agentFile.action === "copied") {
426
+ lines.push(` \u2705 Agent file copied`);
427
+ lines.push(` ${r.agentFile.path}`);
428
+ } else if (r.agentFile.action === "exists") {
429
+ lines.push(` \u2705 Agent file already exists`);
430
+ lines.push(` ${r.agentFile.path}`);
431
+ } else {
432
+ lines.push(` \u26A0\uFE0F Agent source not found`);
433
+ lines.push(` Expected: ${r.agentFile.path}`);
434
+ }
435
+ lines.push("");
436
+ lines.push("Setup complete. Restart opencode to apply changes.");
437
+ return lines.join("\n");
438
+ }
439
+ function parseArgs(argv) {
440
+ const args = argv.slice(2);
441
+ let command = "status";
442
+ let json = false;
443
+ let dir;
444
+ let aggregate = false;
445
+ let limit = 20;
446
+ for (let i = 0; i < args.length; i++) {
447
+ const a = args[i];
448
+ if (a === "--json" || a === "-j") json = true;
449
+ else if (a === "--aggregate" || a === "-a") aggregate = true;
450
+ else if (a === "--dir" || a === "-d") {
451
+ dir = args[++i];
452
+ } else if (a === "--limit" || a === "-l") {
453
+ limit = parseInt(args[++i], 10) || 20;
454
+ } else if (a === "--help" || a === "-h") {
455
+ command = "help";
456
+ } else if (a === "--version" || a === "-v") {
457
+ command = "version";
458
+ } else if (!a.startsWith("-")) {
459
+ command = a;
460
+ }
461
+ }
462
+ return { command, json, dir, aggregate, limit };
463
+ }
464
+ var HELP = `usage-coach \u2014 quota intelligence CLI for opencode
465
+
466
+ Commands:
467
+ status Show quota + harness + learning state (default)
468
+ rules List accumulated learning rules
469
+ decisions Show recent GO/THROTTLE/STOP decision history
470
+ domain Show domain knowledge graph stats
471
+ setup Auto-generate harness.config.json, detect codexbar, copy agent file
472
+
473
+ Flags:
474
+ --json, -j Output as JSON (default: human-readable)
475
+ --dir <path> Project directory to query (default: cwd)
476
+ --aggregate, -a Scan all project instances (status only)
477
+ --limit <n> Number of decisions to show (default: 20)
478
+ --help, -h Show this help
479
+ --version, -v Show version
480
+
481
+ Examples:
482
+ usage-coach status --json
483
+ usage-coach status --aggregate --json
484
+ usage-coach rules --json --dir /path/to/project`;
485
+ function getVersion() {
486
+ try {
487
+ const pkgPath = join(
488
+ dirname(fileURLToPath(import.meta.url)),
489
+ "..",
490
+ "package.json"
491
+ );
492
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
493
+ return pkg.version ?? "unknown";
494
+ } catch {
495
+ return "unknown";
496
+ }
497
+ }
498
+ function main() {
499
+ const args = parseArgs(process.argv);
500
+ switch (args.command) {
501
+ case "help": {
502
+ console.log(HELP);
503
+ break;
504
+ }
505
+ case "version": {
506
+ console.log(getVersion());
507
+ break;
508
+ }
509
+ case "status": {
510
+ if (args.aggregate) {
511
+ const r = readAggregateStatus();
512
+ console.log(args.json ? JSON.stringify(r, null, 2) : formatAggregate(r));
513
+ } else {
514
+ const r = readStatus(args.dir);
515
+ console.log(args.json ? JSON.stringify(r, null, 2) : formatStatus(r));
516
+ }
517
+ break;
518
+ }
519
+ case "rules": {
520
+ const r = readRules(args.dir);
521
+ console.log(args.json ? JSON.stringify(r, null, 2) : formatRules(r));
522
+ break;
523
+ }
524
+ case "decisions": {
525
+ const r = readDecisions(args.dir, args.limit);
526
+ console.log(
527
+ args.json ? JSON.stringify(r, null, 2) : formatDecisions(r)
528
+ );
529
+ break;
530
+ }
531
+ case "domain": {
532
+ const r = readDomainStats(args.dir);
533
+ console.log(args.json ? JSON.stringify(r, null, 2) : formatDomain(r));
534
+ break;
535
+ }
536
+ case "setup": {
537
+ const r = doSetup();
538
+ console.log(args.json ? JSON.stringify(r, null, 2) : formatSetup(r));
539
+ break;
540
+ }
541
+ default: {
542
+ console.error(`Unknown command: ${args.command}
543
+
544
+ ${HELP}`);
545
+ process.exit(1);
546
+ }
547
+ }
548
+ }
549
+ var isDirectRun = process.argv[1] && (process.argv[1].endsWith("cli.js") || process.argv[1].endsWith("cli.ts") || process.argv[1].endsWith("usage-coach"));
550
+ if (isDirectRun) {
551
+ main();
552
+ }
553
+ export {
554
+ doSetup,
555
+ parseArgs,
556
+ projectStateDir,
557
+ readAggregateStatus,
558
+ readDecisions,
559
+ readDomainStats,
560
+ readRules,
561
+ readStatus,
562
+ resolveAgentSourceFile,
563
+ resolveStateDir
564
+ };
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // src/index.ts
2
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2, appendFileSync as appendFileSync3, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
3
3
  import { spawn, spawnSync } from "child_process";
4
4
  import { createHash } from "crypto";
5
- import { homedir } from "os";
6
- import { join as join2, resolve, dirname as dirname2 } from "path";
5
+ import { homedir as homedir2 } from "os";
6
+ import { join as join3, resolve, dirname as dirname2 } from "path";
7
7
  import { tool } from "@opencode-ai/plugin";
8
8
 
9
9
  // src/domain.ts
@@ -221,6 +221,18 @@ function autoLinkKeywords(nodeId, keywords, minOverlap = 2, maxLinks = 8) {
221
221
  }
222
222
 
223
223
  // src/web-search.ts
224
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync2 } from "fs";
225
+ import { join as join2 } from "path";
226
+ import { homedir } from "os";
227
+ var WS_LOG_FILE = process.env.UC_STATE_DIR ? join2(process.env.UC_STATE_DIR, "web-search.log") : join2(homedir(), ".cache", "opencode-usage-coach", "web-search.log");
228
+ function wsLog(msg) {
229
+ try {
230
+ mkdirSync2(join2(WS_LOG_FILE, ".."), { recursive: true });
231
+ appendFileSync2(WS_LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
232
+ `);
233
+ } catch {
234
+ }
235
+ }
224
236
  var FRAMEWORK_DOCS = {
225
237
  "react": { name: "React", docs: "https://react.dev", githubOrg: "facebook" },
226
238
  "solid-js": { name: "Solid.js", docs: "https://solidjs.com", githubOrg: "solidjs" },
@@ -283,7 +295,7 @@ async function ghFetch(url, signal) {
283
295
  const errs = ghError?.errors;
284
296
  const errMsg = ghError?.message ?? ghErrorText.slice(0, 300);
285
297
  const detail = errs ? errs.map((e) => typeof e === "string" ? e : `${e?.field ?? "?"}: ${e?.message ?? e?.code ?? JSON.stringify(e)}`).join("; ") : "";
286
- console.error(JSON.stringify({
298
+ wsLog(JSON.stringify({
287
299
  level: "error",
288
300
  module: "web-search",
289
301
  event: "gh-fetch-error",
@@ -319,7 +331,9 @@ async function tier1OfficialDocs(query, fws, results, docRefs, seen, signal) {
319
331
  const entry = FRAMEWORK_DOCS[fw];
320
332
  if (!entry?.githubOrg) continue;
321
333
  try {
322
- const fullQ = `${cleanQuery} org:${entry.githubOrg}`;
334
+ const orgPart = ` org:${entry.githubOrg}`;
335
+ const truncated = cleanQuery.slice(0, GH_QUERY_MAX - orgPart.length);
336
+ const fullQ = `${truncated}${orgPart}`;
323
337
  const url = `https://api.github.com/search/issues?q=${encodeURIComponent(fullQ)}&per_page=3`;
324
338
  const data = await ghFetch(url, signal);
325
339
  for (const item of data.items ?? []) {
@@ -332,7 +346,7 @@ async function tier1OfficialDocs(query, fws, results, docRefs, seen, signal) {
332
346
  }
333
347
  } catch (e) {
334
348
  const m = errMessage(e);
335
- console.error(`[web-search] tier1 ${fw}: ${m}`);
349
+ wsLog(`[web-search] tier1 ${fw}: ${m}`);
336
350
  errors.push(`tier1:${fw}:${m.slice(0, 80)}`);
337
351
  }
338
352
  }
@@ -355,7 +369,7 @@ async function tier2GitHubIssues(query, results, seen, signal) {
355
369
  }
356
370
  } catch (e) {
357
371
  const m = errMessage(e);
358
- console.error(`[web-search] tier2: ${m}`);
372
+ wsLog(`[web-search] tier2: ${m}`);
359
373
  errors.push(`tier2:${m.slice(0, 80)}`);
360
374
  }
361
375
  return errors;
@@ -379,7 +393,7 @@ async function tier3GitHubCode(query, results, seen, signal) {
379
393
  }
380
394
  } catch (e) {
381
395
  const m = errMessage(e);
382
- console.error(`[web-search] tier3: ${m}`);
396
+ wsLog(`[web-search] tier3: ${m}`);
383
397
  errors.push(`tier3:${m.slice(0, 80)}`);
384
398
  }
385
399
  return errors;
@@ -405,7 +419,7 @@ async function searchContext(query, frameworks, keyDeps, timeoutMs) {
405
419
  }
406
420
  } catch (e) {
407
421
  const m = errMessage(e);
408
- console.error(`[web-search] unexpected error: ${m}`);
422
+ wsLog(`[web-search] unexpected error: ${m}`);
409
423
  errors.push(`unexpected:${m.slice(0, 80)}`);
410
424
  } finally {
411
425
  clearTimeout(timer);
@@ -423,49 +437,49 @@ var DEFAULT_MAX_STEPS = Number(process.env.UC_MAX_STEPS ?? 30) || 30;
423
437
  var WATCHDOG_POLL_MS = Math.max(1e3, Number(process.env.UC_WATCHDOG_POLL_MS ?? 3e3) || 3e3);
424
438
  var WALL_TIMEOUT_MS = Math.max(1, Number(process.env.UC_WALL_TIMEOUT_MIN ?? 30) || 30) * 60 * 1e3;
425
439
  var DEFAULT_MAX_QUESTIONS = Math.max(1, Math.round(Number(process.env.UC_MAX_QUESTIONS ?? 7)) || 7);
426
- var PIPE_LOG = join2(homedir(), ".cache", "opencode-usage-coach", "pipeline.log");
440
+ var PIPE_LOG = join3(homedir2(), ".cache", "opencode-usage-coach", "pipeline.log");
427
441
  function pipeLog(msg) {
428
442
  try {
429
- mkdirSync2(dirname2(PIPE_LOG), { recursive: true });
430
- appendFileSync2(PIPE_LOG, `[SERVER] ${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
443
+ mkdirSync3(dirname2(PIPE_LOG), { recursive: true });
444
+ appendFileSync3(PIPE_LOG, `[SERVER] ${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
431
445
  `);
432
446
  } catch {
433
447
  }
434
448
  }
435
449
  pipeLog(`MODULE LOADED | node=${process.version} | pid=${process.pid}`);
436
- var STATE_DIR = join2(homedir(), ".cache", "opencode-usage-coach");
437
- var STATE_FILE = join2(STATE_DIR, "state.json");
438
- var LOG_FILE = join2(STATE_DIR, "coach.log");
450
+ var STATE_DIR = join3(homedir2(), ".cache", "opencode-usage-coach");
451
+ var STATE_FILE = join3(STATE_DIR, "state.json");
452
+ var LOG_FILE = join3(STATE_DIR, "coach.log");
439
453
  function projectStateDir(dir) {
440
454
  const abs = resolve(dir || ".");
441
455
  const h = createHash("sha1").update(abs).digest("hex").slice(0, 12);
442
- return join2(homedir(), ".cache", "opencode-usage-coach", "projects", h);
456
+ return join3(homedir2(), ".cache", "opencode-usage-coach", "projects", h);
443
457
  }
444
458
  function setStateDir(dir) {
445
459
  STATE_DIR = process.env.UC_STATE_DIR ?? projectStateDir(dir);
446
- STATE_FILE = join2(STATE_DIR, "state.json");
447
- LOG_FILE = join2(STATE_DIR, "coach.log");
460
+ STATE_FILE = join3(STATE_DIR, "state.json");
461
+ LOG_FILE = join3(STATE_DIR, "coach.log");
448
462
  }
449
463
  var NOOP_HOOKS = {};
450
464
  function log(msg) {
451
465
  try {
452
- appendFileSync2(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
466
+ appendFileSync3(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
453
467
  `);
454
468
  } catch {
455
469
  }
456
470
  }
457
471
  function writeState(c) {
458
472
  try {
459
- mkdirSync2(STATE_DIR, { recursive: true });
473
+ mkdirSync3(STATE_DIR, { recursive: true });
460
474
  writeFileSync2(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
461
475
  } catch {
462
476
  }
463
477
  }
464
478
  function rulesFile() {
465
- return join2(STATE_DIR, "rules.md");
479
+ return join3(STATE_DIR, "rules.md");
466
480
  }
467
481
  function failuresFile() {
468
- return join2(STATE_DIR, "failures.ndjson");
482
+ return join3(STATE_DIR, "failures.ndjson");
469
483
  }
470
484
  function readRules() {
471
485
  try {
@@ -477,7 +491,7 @@ function readRules() {
477
491
  }
478
492
  }
479
493
  function implNotesFile() {
480
- return join2(STATE_DIR, "impl-notes.md");
494
+ return join3(STATE_DIR, "impl-notes.md");
481
495
  }
482
496
  var IMPL_NOTE_INSTRUCTION = `
483
497
  ## Implementation Notes (important!)
@@ -524,8 +538,8 @@ ${notes}
524
538
  Source: generate task "${shortTask}"
525
539
 
526
540
  `;
527
- mkdirSync2(STATE_DIR, { recursive: true });
528
- appendFileSync2(implNotesFile(), entry);
541
+ mkdirSync3(STATE_DIR, { recursive: true });
542
+ appendFileSync3(implNotesFile(), entry);
529
543
  } catch (e) {
530
544
  log(`appendImplNotes err: ${String(e)}`);
531
545
  }
@@ -716,7 +730,7 @@ function extractKeywords(text) {
716
730
  }
717
731
  }
718
732
  function harnessFile(sessionID) {
719
- return join2(STATE_DIR, sessionID || "_default", "harness.json");
733
+ return join3(STATE_DIR, sessionID || "_default", "harness.json");
720
734
  }
721
735
  function readHarness(sessionID) {
722
736
  try {
@@ -730,7 +744,7 @@ function readHarness(sessionID) {
730
744
  function writeHarness(sessionID, h) {
731
745
  try {
732
746
  const f = harnessFile(sessionID);
733
- mkdirSync2(dirname2(f), { recursive: true });
747
+ mkdirSync3(dirname2(f), { recursive: true });
734
748
  h.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
735
749
  writeFileSync2(f, JSON.stringify(h, null, 2));
736
750
  } catch {
@@ -753,7 +767,7 @@ function mutateHarness(sessionID, fn) {
753
767
  return next;
754
768
  }
755
769
  function interviewFile(sessionID) {
756
- return join2(STATE_DIR, sessionID || "_default", "interview.json");
770
+ return join3(STATE_DIR, sessionID || "_default", "interview.json");
757
771
  }
758
772
  function readInterview(sessionID) {
759
773
  try {
@@ -767,7 +781,7 @@ function readInterview(sessionID) {
767
781
  function writeInterview(sessionID, s) {
768
782
  try {
769
783
  const f = interviewFile(sessionID);
770
- mkdirSync2(dirname2(f), { recursive: true });
784
+ mkdirSync3(dirname2(f), { recursive: true });
771
785
  s.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
772
786
  writeFileSync2(f, JSON.stringify(s, null, 2));
773
787
  } catch {
@@ -965,7 +979,7 @@ function parseFileList(rawList, baseDir) {
965
979
  let testPattern;
966
980
  let testFramework;
967
981
  for (const mf of manifestFiles) {
968
- const full = join2(baseDir, mf);
982
+ const full = join3(baseDir, mf);
969
983
  if (existsSync2(full)) {
970
984
  try {
971
985
  const content = JSON.parse(readFileSync2(full, "utf8"));
@@ -1295,15 +1309,15 @@ function readHarnessCfg(dir) {
1295
1309
  return {};
1296
1310
  };
1297
1311
  return {
1298
- ...tryRead(join2(homedir(), ".config", "opencode-usage-coach", "harness.config.json")),
1299
- ...tryRead(join2(dir, "harness.config.json"))
1312
+ ...tryRead(join3(homedir2(), ".config", "opencode-usage-coach", "harness.config.json")),
1313
+ ...tryRead(join3(dir, "harness.config.json"))
1300
1314
  };
1301
1315
  }
1302
1316
  function writeHarnessCfg(updates) {
1303
- const configDir = join2(homedir(), ".config", "opencode-usage-coach");
1304
- const configPath = join2(configDir, "harness.config.json");
1317
+ const configDir = join3(homedir2(), ".config", "opencode-usage-coach");
1318
+ const configPath = join3(configDir, "harness.config.json");
1305
1319
  try {
1306
- mkdirSync2(configDir, { recursive: true });
1320
+ mkdirSync3(configDir, { recursive: true });
1307
1321
  const existing = (() => {
1308
1322
  try {
1309
1323
  return JSON.parse(readFileSync2(configPath, "utf8"));
@@ -1335,6 +1349,7 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
1335
1349
  const providerID = slash >= 0 ? model.slice(0, slash) : model;
1336
1350
  const modelID = slash >= 0 ? model.slice(slash + 1) : "";
1337
1351
  const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
1352
+ log(`runModel(${model}): session.create ${Date.now() - t0}ms`);
1338
1353
  const id = s?.data?.info?.id ?? s?.data?.id ?? s?.id;
1339
1354
  if (!id) return `ERROR: session.create returned no id (response: ${JSON.stringify(s?.data ?? s).slice(0, 200)})`;
1340
1355
  subId = id;
@@ -1403,6 +1418,7 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
1403
1418
  );
1404
1419
  const resp = await Promise.race([promptP, timeoutSignal.then(() => null)]);
1405
1420
  const elapsed = Math.round((Date.now() - t0) / 1e3);
1421
+ log(`runModel(${model}): prompt resolved ${elapsed}s, timedOut=${timedOut}`);
1406
1422
  if (timedOut) {
1407
1423
  try {
1408
1424
  const summary = await client.session.summarize?.({ path: { id } });
@@ -1410,7 +1426,7 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
1410
1426
  } catch {
1411
1427
  }
1412
1428
  try {
1413
- await client.session.delete?.({ path: { id } });
1429
+ client.session.delete?.({ path: { id } });
1414
1430
  } catch {
1415
1431
  }
1416
1432
  subId = null;
@@ -1420,17 +1436,19 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
1420
1436
  }
1421
1437
  const parts = resp?.data?.parts ?? resp?.parts ?? [];
1422
1438
  const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
1439
+ const cleanupStart = Date.now();
1423
1440
  try {
1424
- const summary = await client.session.summarize?.({ path: { id } });
1425
- log(`runModel(${model}): sub-session summary: ${JSON.stringify(summary?.data ?? summary).slice(0, 300)}`);
1441
+ client.session.summarize?.({ path: { id } }).then((summary) => log(`runModel(${model}): summary ${Date.now() - cleanupStart}ms: ${JSON.stringify(summary?.data ?? summary).slice(0, 300)}`)).catch(() => {
1442
+ });
1426
1443
  } catch {
1427
1444
  }
1428
1445
  try {
1429
- await client.session.delete?.({ path: { id } });
1446
+ client.session.delete?.({ path: { id } }).catch(() => {
1447
+ });
1430
1448
  } catch {
1431
1449
  }
1432
1450
  subId = null;
1433
- log(`runModel(${model}): done ${elapsed}s, ${text.length} chars`);
1451
+ log(`runModel(${model}): done ${elapsed}s, ${text.length} chars (cleanup dispatched)`);
1434
1452
  return text.trim() || `ERROR: no assistant text in prompt response after ${elapsed}s (parts: ${parts.length}, types: ${parts.map((p) => p?.type).join(",")})`;
1435
1453
  } catch (e) {
1436
1454
  const elapsed = Math.round((Date.now() - t0) / 1e3);
@@ -1448,7 +1466,8 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
1448
1466
  }
1449
1467
  if (subId) {
1450
1468
  try {
1451
- await client.session.delete?.({ path: { id: subId } });
1469
+ client.session.delete?.({ path: { id: subId } }).catch(() => {
1470
+ });
1452
1471
  } catch {
1453
1472
  }
1454
1473
  }
@@ -1496,11 +1515,15 @@ function captureStdout(args) {
1496
1515
  p.stdout?.on("data", (d) => {
1497
1516
  out += d.toString();
1498
1517
  });
1499
- p.on("error", () => resolve2(""));
1518
+ p.on("error", (err) => {
1519
+ if (err.code === "ENOENT") codexbarMissing = true;
1520
+ resolve2("");
1521
+ });
1500
1522
  p.on("close", () => resolve2(out));
1501
1523
  });
1502
1524
  }
1503
1525
  async function fetchEnabledProviders() {
1526
+ if (codexbarMissing) return [];
1504
1527
  const out = await captureStdout(["config", "providers"]);
1505
1528
  const ids = [];
1506
1529
  for (const line of out.split("\n")) {
@@ -1572,13 +1595,17 @@ function fetchQuota(provider) {
1572
1595
  p.stdout?.on("data", (d) => {
1573
1596
  out += d.toString();
1574
1597
  });
1575
- p.on("error", () => resolve2(null));
1598
+ p.on("error", (err) => {
1599
+ if (err.code === "ENOENT") codexbarMissing = true;
1600
+ resolve2(null);
1601
+ });
1576
1602
  p.on("close", () => {
1577
1603
  resolve2(parseQuotaResponse(out));
1578
1604
  });
1579
1605
  });
1580
1606
  }
1581
1607
  async function fetchQuotaWithRetry(provider, maxRetries = 3) {
1608
+ if (codexbarMissing) return null;
1582
1609
  for (let attempt = 0; attempt < maxRetries; attempt++) {
1583
1610
  const q = await fetchQuota(provider);
1584
1611
  if (q) return q;
@@ -1607,6 +1634,13 @@ var currentModel = "";
1607
1634
  var currentProvider = "";
1608
1635
  var currentAgent = "";
1609
1636
  var modelChanged = false;
1637
+ var codexbarMissing = false;
1638
+ function isCodexbarMissing() {
1639
+ return codexbarMissing;
1640
+ }
1641
+ function __resetCodexbarMissing() {
1642
+ codexbarMissing = false;
1643
+ }
1610
1644
  function isFreeModel(model, provider) {
1611
1645
  if (!model && !provider) return false;
1612
1646
  if (provider === "opencode") return true;
@@ -1668,6 +1702,12 @@ async function UsageCoachPlugin(input) {
1668
1702
  const refreshBackground = () => {
1669
1703
  try {
1670
1704
  if (refreshing) return;
1705
+ if (codexbarMissing) {
1706
+ last = { decision: "GO", advice: "codexbar not installed \u2014 running in GO-only mode (no quota sensing).", weekly: -3, monthly: -3, fiveHour: -3 };
1707
+ lastFetchedAt = Date.now();
1708
+ refreshing = false;
1709
+ return;
1710
+ }
1671
1711
  if (last && !modelChanged && Date.now() - lastFetchedAt < TTL_MS) return;
1672
1712
  refreshing = true;
1673
1713
  modelChanged = false;
@@ -2031,8 +2071,8 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
2031
2071
  async execute(args, _ctx) {
2032
2072
  const rec = { ts: (/* @__PURE__ */ new Date()).toISOString(), task: args.task, prompt: args.prompt, gradeResult: args.gradeResult, model: args.model, revisions: args.revisions };
2033
2073
  try {
2034
- mkdirSync2(STATE_DIR, { recursive: true });
2035
- appendFileSync2(failuresFile(), JSON.stringify(rec) + "\n");
2074
+ mkdirSync3(STATE_DIR, { recursive: true });
2075
+ appendFileSync3(failuresFile(), JSON.stringify(rec) + "\n");
2036
2076
  } catch (e) {
2037
2077
  log(`record_failure err: ${String(e)}`);
2038
2078
  }
@@ -2157,8 +2197,8 @@ Keep it concrete and actionable.`;
2157
2197
  const rule = out;
2158
2198
  try {
2159
2199
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2160
- mkdirSync2(STATE_DIR, { recursive: true });
2161
- appendFileSync2(rulesFile(), `## Rule (${date})
2200
+ mkdirSync3(STATE_DIR, { recursive: true });
2201
+ appendFileSync3(rulesFile(), `## Rule (${date})
2162
2202
  ${rule}
2163
2203
  Origin: ${args.task}
2164
2204
 
@@ -2632,6 +2672,7 @@ Note: Changes take effect immediately for new generate/grade calls.`,
2632
2672
  }
2633
2673
  }
2634
2674
  export {
2675
+ __resetCodexbarMissing,
2635
2676
  buildGapPrompt,
2636
2677
  buildScanSummary,
2637
2678
  checkScanGate,
@@ -2641,9 +2682,11 @@ export {
2641
2682
  detectLanguage,
2642
2683
  extractImplNotes,
2643
2684
  extractKeywords,
2685
+ fetchQuotaWithRetry,
2644
2686
  findActiveTaskId,
2645
2687
  formatReport,
2646
2688
  humanRemaining,
2689
+ isCodexbarMissing,
2647
2690
  isFreeModel,
2648
2691
  isHarnessAgent,
2649
2692
  parseFileList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "opencode closed-loop usage coach — quota SENSE -> coaching DECIDE -> loop ACT + TUI integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,8 +13,15 @@
13
13
  "./tui": {
14
14
  "types": "./dist/tui.d.ts",
15
15
  "import": "./dist/tui.js"
16
+ },
17
+ "./cli": {
18
+ "types": "./dist/cli.d.ts",
19
+ "import": "./dist/cli.js"
16
20
  }
17
21
  },
22
+ "bin": {
23
+ "usage-coach": "./dist/cli.js"
24
+ },
18
25
  "scripts": {
19
26
  "build": "tsup",
20
27
  "typecheck": "tsc --noEmit",