opencode-usage-coach 0.12.1 → 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 +12 -5
- package/dist/cli.js +564 -0
- package/dist/index.js +37 -8
- package/package.json +8 -1
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
|
|
36
|
+
Then run setup:
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
#
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
@@ -1349,6 +1349,7 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
|
|
|
1349
1349
|
const providerID = slash >= 0 ? model.slice(0, slash) : model;
|
|
1350
1350
|
const modelID = slash >= 0 ? model.slice(slash + 1) : "";
|
|
1351
1351
|
const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
|
|
1352
|
+
log(`runModel(${model}): session.create ${Date.now() - t0}ms`);
|
|
1352
1353
|
const id = s?.data?.info?.id ?? s?.data?.id ?? s?.id;
|
|
1353
1354
|
if (!id) return `ERROR: session.create returned no id (response: ${JSON.stringify(s?.data ?? s).slice(0, 200)})`;
|
|
1354
1355
|
subId = id;
|
|
@@ -1417,6 +1418,7 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
|
|
|
1417
1418
|
);
|
|
1418
1419
|
const resp = await Promise.race([promptP, timeoutSignal.then(() => null)]);
|
|
1419
1420
|
const elapsed = Math.round((Date.now() - t0) / 1e3);
|
|
1421
|
+
log(`runModel(${model}): prompt resolved ${elapsed}s, timedOut=${timedOut}`);
|
|
1420
1422
|
if (timedOut) {
|
|
1421
1423
|
try {
|
|
1422
1424
|
const summary = await client.session.summarize?.({ path: { id } });
|
|
@@ -1424,7 +1426,7 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
|
|
|
1424
1426
|
} catch {
|
|
1425
1427
|
}
|
|
1426
1428
|
try {
|
|
1427
|
-
|
|
1429
|
+
client.session.delete?.({ path: { id } });
|
|
1428
1430
|
} catch {
|
|
1429
1431
|
}
|
|
1430
1432
|
subId = null;
|
|
@@ -1434,17 +1436,19 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
|
|
|
1434
1436
|
}
|
|
1435
1437
|
const parts = resp?.data?.parts ?? resp?.parts ?? [];
|
|
1436
1438
|
const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
|
|
1439
|
+
const cleanupStart = Date.now();
|
|
1437
1440
|
try {
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
+
});
|
|
1440
1443
|
} catch {
|
|
1441
1444
|
}
|
|
1442
1445
|
try {
|
|
1443
|
-
|
|
1446
|
+
client.session.delete?.({ path: { id } }).catch(() => {
|
|
1447
|
+
});
|
|
1444
1448
|
} catch {
|
|
1445
1449
|
}
|
|
1446
1450
|
subId = null;
|
|
1447
|
-
log(`runModel(${model}): done ${elapsed}s, ${text.length} chars`);
|
|
1451
|
+
log(`runModel(${model}): done ${elapsed}s, ${text.length} chars (cleanup dispatched)`);
|
|
1448
1452
|
return text.trim() || `ERROR: no assistant text in prompt response after ${elapsed}s (parts: ${parts.length}, types: ${parts.map((p) => p?.type).join(",")})`;
|
|
1449
1453
|
} catch (e) {
|
|
1450
1454
|
const elapsed = Math.round((Date.now() - t0) / 1e3);
|
|
@@ -1462,7 +1466,8 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
|
|
|
1462
1466
|
}
|
|
1463
1467
|
if (subId) {
|
|
1464
1468
|
try {
|
|
1465
|
-
|
|
1469
|
+
client.session.delete?.({ path: { id: subId } }).catch(() => {
|
|
1470
|
+
});
|
|
1466
1471
|
} catch {
|
|
1467
1472
|
}
|
|
1468
1473
|
}
|
|
@@ -1510,11 +1515,15 @@ function captureStdout(args) {
|
|
|
1510
1515
|
p.stdout?.on("data", (d) => {
|
|
1511
1516
|
out += d.toString();
|
|
1512
1517
|
});
|
|
1513
|
-
p.on("error", () =>
|
|
1518
|
+
p.on("error", (err) => {
|
|
1519
|
+
if (err.code === "ENOENT") codexbarMissing = true;
|
|
1520
|
+
resolve2("");
|
|
1521
|
+
});
|
|
1514
1522
|
p.on("close", () => resolve2(out));
|
|
1515
1523
|
});
|
|
1516
1524
|
}
|
|
1517
1525
|
async function fetchEnabledProviders() {
|
|
1526
|
+
if (codexbarMissing) return [];
|
|
1518
1527
|
const out = await captureStdout(["config", "providers"]);
|
|
1519
1528
|
const ids = [];
|
|
1520
1529
|
for (const line of out.split("\n")) {
|
|
@@ -1586,13 +1595,17 @@ function fetchQuota(provider) {
|
|
|
1586
1595
|
p.stdout?.on("data", (d) => {
|
|
1587
1596
|
out += d.toString();
|
|
1588
1597
|
});
|
|
1589
|
-
p.on("error", () =>
|
|
1598
|
+
p.on("error", (err) => {
|
|
1599
|
+
if (err.code === "ENOENT") codexbarMissing = true;
|
|
1600
|
+
resolve2(null);
|
|
1601
|
+
});
|
|
1590
1602
|
p.on("close", () => {
|
|
1591
1603
|
resolve2(parseQuotaResponse(out));
|
|
1592
1604
|
});
|
|
1593
1605
|
});
|
|
1594
1606
|
}
|
|
1595
1607
|
async function fetchQuotaWithRetry(provider, maxRetries = 3) {
|
|
1608
|
+
if (codexbarMissing) return null;
|
|
1596
1609
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
1597
1610
|
const q = await fetchQuota(provider);
|
|
1598
1611
|
if (q) return q;
|
|
@@ -1621,6 +1634,13 @@ var currentModel = "";
|
|
|
1621
1634
|
var currentProvider = "";
|
|
1622
1635
|
var currentAgent = "";
|
|
1623
1636
|
var modelChanged = false;
|
|
1637
|
+
var codexbarMissing = false;
|
|
1638
|
+
function isCodexbarMissing() {
|
|
1639
|
+
return codexbarMissing;
|
|
1640
|
+
}
|
|
1641
|
+
function __resetCodexbarMissing() {
|
|
1642
|
+
codexbarMissing = false;
|
|
1643
|
+
}
|
|
1624
1644
|
function isFreeModel(model, provider) {
|
|
1625
1645
|
if (!model && !provider) return false;
|
|
1626
1646
|
if (provider === "opencode") return true;
|
|
@@ -1682,6 +1702,12 @@ async function UsageCoachPlugin(input) {
|
|
|
1682
1702
|
const refreshBackground = () => {
|
|
1683
1703
|
try {
|
|
1684
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
|
+
}
|
|
1685
1711
|
if (last && !modelChanged && Date.now() - lastFetchedAt < TTL_MS) return;
|
|
1686
1712
|
refreshing = true;
|
|
1687
1713
|
modelChanged = false;
|
|
@@ -2646,6 +2672,7 @@ Note: Changes take effect immediately for new generate/grade calls.`,
|
|
|
2646
2672
|
}
|
|
2647
2673
|
}
|
|
2648
2674
|
export {
|
|
2675
|
+
__resetCodexbarMissing,
|
|
2649
2676
|
buildGapPrompt,
|
|
2650
2677
|
buildScanSummary,
|
|
2651
2678
|
checkScanGate,
|
|
@@ -2655,9 +2682,11 @@ export {
|
|
|
2655
2682
|
detectLanguage,
|
|
2656
2683
|
extractImplNotes,
|
|
2657
2684
|
extractKeywords,
|
|
2685
|
+
fetchQuotaWithRetry,
|
|
2658
2686
|
findActiveTaskId,
|
|
2659
2687
|
formatReport,
|
|
2660
2688
|
humanRemaining,
|
|
2689
|
+
isCodexbarMissing,
|
|
2661
2690
|
isFreeModel,
|
|
2662
2691
|
isHarnessAgent,
|
|
2663
2692
|
parseFileList,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-usage-coach",
|
|
3
|
-
"version": "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",
|