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 +12 -5
- package/dist/cli.js +564 -0
- package/dist/index.js +90 -47
- 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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { mkdirSync as
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
440
|
+
var PIPE_LOG = join3(homedir2(), ".cache", "opencode-usage-coach", "pipeline.log");
|
|
427
441
|
function pipeLog(msg) {
|
|
428
442
|
try {
|
|
429
|
-
|
|
430
|
-
|
|
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 =
|
|
437
|
-
var STATE_FILE =
|
|
438
|
-
var LOG_FILE =
|
|
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
|
|
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 =
|
|
447
|
-
LOG_FILE =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
479
|
+
return join3(STATE_DIR, "rules.md");
|
|
466
480
|
}
|
|
467
481
|
function failuresFile() {
|
|
468
|
-
return
|
|
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
|
|
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
|
-
|
|
528
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
1299
|
-
...tryRead(
|
|
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 =
|
|
1304
|
-
const configPath =
|
|
1317
|
+
const configDir = join3(homedir2(), ".config", "opencode-usage-coach");
|
|
1318
|
+
const configPath = join3(configDir, "harness.config.json");
|
|
1305
1319
|
try {
|
|
1306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", () =>
|
|
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", () =>
|
|
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
|
-
|
|
2035
|
-
|
|
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
|
-
|
|
2161
|
-
|
|
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.
|
|
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",
|