opencode-usage-coach 0.3.5 → 0.5.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 +32 -0
- package/agents/usage-coach-harness.md +5 -0
- package/dist/index.js +96 -16
- package/dist/tui.js +5 -5
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -152,6 +152,38 @@ Place in the **work directory**. Each role runs on its model, so per-model quota
|
|
|
152
152
|
| `UC_PROVIDER` | (config `provider`) | codexbar provider for the guardian |
|
|
153
153
|
| `UC_TTL_MS` | 60000 | quota cache TTL (ms) |
|
|
154
154
|
| `UC_DEBUG` | 0 | set to `1` for a diagnostic log at `~/.cache/opencode-usage-coach/coach.log` |
|
|
155
|
+
| `UC_HARNESS_AGENT` | `Usage-Coach-Harness` | comma-separated agent modes allowed to use harness tools + receive quota coaching (case-insensitive; must match the agent id, e.g. `usage-coach-harness` from `agents/usage-coach-harness.md`) |
|
|
156
|
+
| `UC_WORM_MAX_AGE_DAYS` | 180 | domain DB worm (GC): drop nodes not accessed in N days (~6 months) |
|
|
157
|
+
| `UC_WORM_MAX_NODES` | 100000 | domain DB worm (GC): cap node count, evict oldest-accessed beyond this |
|
|
158
|
+
|
|
159
|
+
## Agent-mode scoping
|
|
160
|
+
|
|
161
|
+
Harness tools (`generate`, `grade`, `harness_start`, …) and quota coaching are **scoped to
|
|
162
|
+
the `usage-coach-harness` agent mode**. Other modes (build, general, your custom agents) stay
|
|
163
|
+
completely clean — no harness tools in their tool list, no quota coaching injected into their
|
|
164
|
+
system prompt.
|
|
165
|
+
|
|
166
|
+
This is enforced on two independent layers (defense in depth):
|
|
167
|
+
|
|
168
|
+
1. **Agent definition** (`agents/usage-coach-harness.md`) — its `permission` allowlist names the
|
|
169
|
+
harness tools, so they only appear in this mode. Other agents' permission lists don't name
|
|
170
|
+
them, so they're hidden from those modes automatically (this is the standard opencode
|
|
171
|
+
mechanism — tool visibility is the agent definition's responsibility).
|
|
172
|
+
2. **Plugin runtime gate** (`tool.execute.before`) — even if a harness tool were somehow
|
|
173
|
+
invoked, the plugin resolves the current session's agent (`client.session.get` → `info.agent`,
|
|
174
|
+
60s-cached) and throws unless it matches `UC_HARNESS_AGENT` (default `Usage-Coach-Harness`,
|
|
175
|
+
case-insensitive). The quota system-prompt injection is gated the same way.
|
|
176
|
+
|
|
177
|
+
**To use the harness tools**, switch to the `usage-coach-harness` agent mode.
|
|
178
|
+
|
|
179
|
+
**To allow additional modes**, set `UC_HARNESS_AGENT` to a comma-separated list:
|
|
180
|
+
```bash
|
|
181
|
+
export UC_HARNESS_AGENT="usage-coach-harness,my-other-harness"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
> Why not the v2 plugin API? v2 has no `tool` registration domain, so a plugin that provides
|
|
185
|
+
> custom tools (like this one) cannot be fully rewritten in v2. Agent `permission` allowlists +
|
|
186
|
+
> the v1 runtime gate is the structurally correct way to scope tool visibility.
|
|
155
187
|
|
|
156
188
|
## Architecture
|
|
157
189
|
- **Server module** (`src/index.ts`) — SENSE/DECIDE/ACT + custom harness tools. Loaded via `opencode.json`.
|
|
@@ -12,7 +12,12 @@ permission:
|
|
|
12
12
|
grep: allow
|
|
13
13
|
task: allow
|
|
14
14
|
generate: allow
|
|
15
|
+
generate_batch: allow
|
|
15
16
|
grade: allow
|
|
17
|
+
investigate: allow
|
|
18
|
+
verify_diagnosis: allow
|
|
19
|
+
generalize: allow
|
|
20
|
+
record_failure: allow
|
|
16
21
|
harness_start: allow
|
|
17
22
|
task_update: allow
|
|
18
23
|
harness_done: allow
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { mkdirSync as mkdirSync2, writeFileSync, appendFileSync as appendFileSync2, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
2
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
4
|
import { createHash } from "crypto";
|
|
5
5
|
import { homedir } from "os";
|
|
@@ -7,7 +7,7 @@ import { join as join2, resolve, dirname } from "path";
|
|
|
7
7
|
import { tool } from "@opencode-ai/plugin";
|
|
8
8
|
|
|
9
9
|
// src/domain.ts
|
|
10
|
-
import { mkdirSync, appendFileSync, readFileSync, existsSync } from "fs";
|
|
10
|
+
import { mkdirSync, appendFileSync, readFileSync, existsSync, writeFileSync } from "fs";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
var BASE_DIR = "";
|
|
13
13
|
function initDomain(stateDir) {
|
|
@@ -41,6 +41,14 @@ function addDomainNode(node) {
|
|
|
41
41
|
}
|
|
42
42
|
return full.id;
|
|
43
43
|
}
|
|
44
|
+
function writeNodes(nodes) {
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(BASE_DIR, { recursive: true });
|
|
47
|
+
const lines = nodes.map((n) => JSON.stringify(n));
|
|
48
|
+
writeFileSync(nodesFile(), lines.length ? lines.join("\n") + "\n" : "");
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
44
52
|
function queryDomain(keywords) {
|
|
45
53
|
const lc = keywords.map((k) => k.toLowerCase());
|
|
46
54
|
const nodes = readNodes();
|
|
@@ -48,10 +56,47 @@ function queryDomain(keywords) {
|
|
|
48
56
|
const hay = (n.name + " " + JSON.stringify(n.props)).toLowerCase();
|
|
49
57
|
return lc.some((k) => k && hay.includes(k));
|
|
50
58
|
});
|
|
59
|
+
if (matched.length) touchNodes(new Set(matched.map((n) => n.id)));
|
|
51
60
|
const ids = new Set(matched.map((n) => n.id));
|
|
52
61
|
const edges = readEdges().filter((e) => ids.has(e.from) || ids.has(e.to));
|
|
53
62
|
return { nodes: matched, edges };
|
|
54
63
|
}
|
|
64
|
+
function touchNodes(ids) {
|
|
65
|
+
if (ids.size === 0) return;
|
|
66
|
+
try {
|
|
67
|
+
const nodes = readNodes();
|
|
68
|
+
let changed = false;
|
|
69
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
70
|
+
for (const n of nodes) {
|
|
71
|
+
if (ids.has(n.id)) {
|
|
72
|
+
n.lastAccessed = now;
|
|
73
|
+
n.accessCount = (n.accessCount ?? 0) + 1;
|
|
74
|
+
changed = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (changed) writeNodes(nodes);
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function evictStale(maxAgeDays = 30, maxNodes = 1e3) {
|
|
82
|
+
try {
|
|
83
|
+
const nodes = readNodes();
|
|
84
|
+
if (nodes.length === 0) return { removed: 0, kept: 0 };
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const ageMs = maxAgeDays * 864e5;
|
|
87
|
+
const lastTs = (n) => new Date(n.lastAccessed ?? n.ts).getTime();
|
|
88
|
+
let kept = nodes.filter((n) => now - lastTs(n) < ageMs);
|
|
89
|
+
if (kept.length > maxNodes) {
|
|
90
|
+
kept.sort((a, b) => lastTs(b) - lastTs(a));
|
|
91
|
+
kept = kept.slice(0, maxNodes);
|
|
92
|
+
}
|
|
93
|
+
const removed = nodes.length - kept.length;
|
|
94
|
+
if (removed > 0) writeNodes(kept);
|
|
95
|
+
return { removed, kept: kept.length };
|
|
96
|
+
} catch {
|
|
97
|
+
return { removed: 0, kept: 0 };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
55
100
|
function saveInvestigationResult(keywords, result, source) {
|
|
56
101
|
try {
|
|
57
102
|
return addDomainNode({
|
|
@@ -68,11 +113,9 @@ function saveInvestigationResult(keywords, result, source) {
|
|
|
68
113
|
|
|
69
114
|
// src/index.ts
|
|
70
115
|
var PLUGIN_NAME = "opencode-usage-coach";
|
|
71
|
-
var DEBUG = process.env.UC_DEBUG === "1";
|
|
72
116
|
var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
|
|
73
117
|
var STATE_DIR = join2(homedir(), ".cache", "opencode-usage-coach");
|
|
74
118
|
var STATE_FILE = join2(STATE_DIR, "state.json");
|
|
75
|
-
var HARNESS_FILE = join2(STATE_DIR, "harness.json");
|
|
76
119
|
var LOG_FILE = join2(STATE_DIR, "coach.log");
|
|
77
120
|
function projectStateDir(dir) {
|
|
78
121
|
const abs = resolve(dir || ".");
|
|
@@ -82,7 +125,6 @@ function projectStateDir(dir) {
|
|
|
82
125
|
function setStateDir(dir) {
|
|
83
126
|
STATE_DIR = process.env.UC_STATE_DIR ?? projectStateDir(dir);
|
|
84
127
|
STATE_FILE = join2(STATE_DIR, "state.json");
|
|
85
|
-
HARNESS_FILE = join2(STATE_DIR, "harness.json");
|
|
86
128
|
LOG_FILE = join2(STATE_DIR, "coach.log");
|
|
87
129
|
}
|
|
88
130
|
var NOOP_HOOKS = {};
|
|
@@ -96,7 +138,7 @@ function log(msg) {
|
|
|
96
138
|
function writeState(c) {
|
|
97
139
|
try {
|
|
98
140
|
mkdirSync2(STATE_DIR, { recursive: true });
|
|
99
|
-
|
|
141
|
+
writeFileSync2(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
100
142
|
} catch {
|
|
101
143
|
}
|
|
102
144
|
}
|
|
@@ -149,7 +191,7 @@ function writeHarness(sessionID, h) {
|
|
|
149
191
|
const f = harnessFile(sessionID);
|
|
150
192
|
mkdirSync2(dirname(f), { recursive: true });
|
|
151
193
|
h.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
152
|
-
|
|
194
|
+
writeFileSync2(f, JSON.stringify(h, null, 2));
|
|
153
195
|
} catch {
|
|
154
196
|
}
|
|
155
197
|
}
|
|
@@ -200,6 +242,7 @@ async function runModel(client, model, prompt, directory) {
|
|
|
200
242
|
return `ERROR: runModel exception after ${elapsed}s: ${String(e)}`;
|
|
201
243
|
}
|
|
202
244
|
}
|
|
245
|
+
var HARNESS_AGENTS = (process.env.UC_HARNESS_AGENT ?? "Usage-Coach-Harness").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
203
246
|
var num = (e, d) => {
|
|
204
247
|
try {
|
|
205
248
|
const v = Number(process.env[e]);
|
|
@@ -213,6 +256,8 @@ var THR_5H = num("UC_THROTTLE_5H", 70);
|
|
|
213
256
|
var STOP_WK = num("UC_STOP_WEEKLY", 95);
|
|
214
257
|
var THR_WK = num("UC_THROTTLE_WEEKLY", 85);
|
|
215
258
|
var STOP_MO = num("UC_STOP_MONTHLY", 98);
|
|
259
|
+
var WORM_MAX_AGE_DAYS = num("UC_WORM_MAX_AGE_DAYS", 180);
|
|
260
|
+
var WORM_MAX_NODES = num("UC_WORM_MAX_NODES", 1e5);
|
|
216
261
|
function humanRemaining(iso) {
|
|
217
262
|
try {
|
|
218
263
|
if (!iso) return "";
|
|
@@ -325,6 +370,25 @@ function coach(q, lighter) {
|
|
|
325
370
|
if (wk >= THR_WK) return thr(`weekly ${wk}% (${wkR})`);
|
|
326
371
|
return { decision: "GO", advice: `Comfortable \u2014 weekly ${wk}% \xB7 5h ${h5}% \xB7 monthly ${mo}%. proceed. 5h window ${h5R}.`, weekly: wk, monthly: mo, fiveHour: h5 };
|
|
327
372
|
}
|
|
373
|
+
var agentCache = /* @__PURE__ */ new Map();
|
|
374
|
+
async function resolveAgent(client, sessionID) {
|
|
375
|
+
if (!sessionID) return "";
|
|
376
|
+
const hit = agentCache.get(sessionID);
|
|
377
|
+
if (hit && Date.now() - hit.ts < 6e4) return hit.agent;
|
|
378
|
+
try {
|
|
379
|
+
const s = await client.session.get({ path: { id: sessionID } });
|
|
380
|
+
const agent = String(s?.data?.info?.agent ?? s?.data?.agent ?? s?.info?.agent ?? "");
|
|
381
|
+
agentCache.set(sessionID, { agent, ts: Date.now() });
|
|
382
|
+
return agent;
|
|
383
|
+
} catch (e) {
|
|
384
|
+
log(`resolveAgent err: ${String(e)}`);
|
|
385
|
+
return "";
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function isHarnessAgent(agent) {
|
|
389
|
+
if (!agent) return false;
|
|
390
|
+
return HARNESS_AGENTS.includes(agent.toLowerCase());
|
|
391
|
+
}
|
|
328
392
|
var LOADING = { decision: "GO", advice: "quota loading\u2026", weekly: -1, monthly: -1, fiveHour: -1 };
|
|
329
393
|
async function UsageCoachPlugin(input) {
|
|
330
394
|
try {
|
|
@@ -380,30 +444,46 @@ async function UsageCoachPlugin(input) {
|
|
|
380
444
|
event: async ({ event }) => {
|
|
381
445
|
try {
|
|
382
446
|
if (event.type === "session.created" || event.type === "session.idle") refreshBackground();
|
|
447
|
+
if (event.type === "session.idle") {
|
|
448
|
+
try {
|
|
449
|
+
const r = evictStale(WORM_MAX_AGE_DAYS, WORM_MAX_NODES);
|
|
450
|
+
if (r.removed) log(`evictStale: removed ${r.removed}, kept ${r.kept} (maxAge=${WORM_MAX_AGE_DAYS}d, maxNodes=${WORM_MAX_NODES})`);
|
|
451
|
+
} catch (e) {
|
|
452
|
+
log(`evictStale err: ${String(e)}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
383
455
|
} catch (e) {
|
|
384
456
|
log(`event err: ${String(e)}`);
|
|
385
457
|
}
|
|
386
458
|
},
|
|
387
|
-
// ACT(1) hard gate —
|
|
388
|
-
// General tools (read/edit/bash/grep/task)
|
|
389
|
-
//
|
|
459
|
+
// ACT(1) hard gate — harness tools are restricted to the configured harness
|
|
460
|
+
// agent mode AND gated by quota STOP. General tools (read/edit/bash/grep/task)
|
|
461
|
+
// are NEVER gated, in ANY mode — they don't consume model quota.
|
|
390
462
|
"tool.execute.before": async (_input) => {
|
|
391
|
-
|
|
463
|
+
const harnessTools = ["generate", "generate_batch", "grade", "investigate", "verify_diagnosis", "generalize", "harness_start", "task_update", "harness_done", "record_failure"];
|
|
464
|
+
if (!harnessTools.includes(_input.tool)) return;
|
|
465
|
+
const agent = await resolveAgent(input.client, _input.sessionID);
|
|
466
|
+
if (!isHarnessAgent(agent)) {
|
|
467
|
+
throw new Error(`[${PLUGIN_NAME}] '${_input.tool}' is restricted to agent mode ${JSON.stringify(HARNESS_AGENTS)} (current: ${JSON.stringify(agent || "unknown")}). Switch to that agent mode to use it.`);
|
|
468
|
+
}
|
|
469
|
+
let decision;
|
|
392
470
|
try {
|
|
393
471
|
decision = current().decision;
|
|
394
472
|
} catch {
|
|
395
473
|
decision = "GO";
|
|
396
474
|
}
|
|
397
475
|
if (decision === "STOP") {
|
|
398
|
-
|
|
399
|
-
if (harnessTools.includes(_input.tool)) {
|
|
400
|
-
throw new Error(`[${PLUGIN_NAME}] blocked: quota limit exceeded. ${current().advice}`);
|
|
401
|
-
}
|
|
476
|
+
throw new Error(`[${PLUGIN_NAME}] blocked: quota limit exceeded. ${current().advice}`);
|
|
402
477
|
}
|
|
403
478
|
},
|
|
404
|
-
// ACT(2) inject coaching into system prompt
|
|
479
|
+
// ACT(2) inject coaching into system prompt — ONLY in the harness agent mode,
|
|
480
|
+
// so other modes' system prompts stay completely clean. Silent on error.
|
|
405
481
|
"experimental.chat.system.transform": async (_input, output) => {
|
|
406
482
|
try {
|
|
483
|
+
if (_input.sessionID) {
|
|
484
|
+
const agent = await resolveAgent(input.client, _input.sessionID);
|
|
485
|
+
if (!isHarnessAgent(agent)) return;
|
|
486
|
+
}
|
|
407
487
|
const c = current();
|
|
408
488
|
let instruction = "";
|
|
409
489
|
if (c.decision === "STOP") instruction = `[${PLUGIN_NAME}] QUOTA limit exceeded. ${c.advice} Stop making further tool calls, finish the in-progress work, then report the quota status to the user.`;
|
package/dist/tui.js
CHANGED
|
@@ -131,11 +131,9 @@ function initializeTui(api, disposeRoot) {
|
|
|
131
131
|
tlog(`api probe err: ${String(e)}`);
|
|
132
132
|
}
|
|
133
133
|
const [getState, setState] = createSignal(readState());
|
|
134
|
-
const [getHarness, setHarness] = createSignal(readHarness());
|
|
135
134
|
const timer = setInterval(() => {
|
|
136
135
|
try {
|
|
137
136
|
setState(readState());
|
|
138
|
-
setHarness(readHarness());
|
|
139
137
|
} catch {
|
|
140
138
|
}
|
|
141
139
|
}, 3e3);
|
|
@@ -184,7 +182,7 @@ function initializeTui(api, disposeRoot) {
|
|
|
184
182
|
return _el$;
|
|
185
183
|
})();
|
|
186
184
|
}
|
|
187
|
-
let s
|
|
185
|
+
let s;
|
|
188
186
|
try {
|
|
189
187
|
s = getState();
|
|
190
188
|
} catch {
|
|
@@ -415,16 +413,18 @@ function initializeTui(api, disposeRoot) {
|
|
|
415
413
|
slots: {
|
|
416
414
|
sidebar_footer(ctx) {
|
|
417
415
|
tlog("sidebar_footer slot called");
|
|
416
|
+
let result;
|
|
418
417
|
try {
|
|
419
|
-
|
|
418
|
+
result = panel(ctx);
|
|
420
419
|
} catch (e) {
|
|
421
420
|
tlog(`sidebar_footer err: ${String(e)}`);
|
|
422
|
-
|
|
421
|
+
result = (() => {
|
|
423
422
|
var _el$61 = _$createElement("text");
|
|
424
423
|
_$insertNode(_el$61, _$createTextNode(`usage-coach`));
|
|
425
424
|
return _el$61;
|
|
426
425
|
})();
|
|
427
426
|
}
|
|
427
|
+
return result;
|
|
428
428
|
}
|
|
429
429
|
}
|
|
430
430
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-usage-coach",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "opencode closed-loop usage coach
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsup",
|
|
20
20
|
"typecheck": "tsc --noEmit",
|
|
21
|
+
"lint": "eslint .",
|
|
22
|
+
"lint:fix": "eslint . --fix",
|
|
21
23
|
"prepack": "tsup"
|
|
22
24
|
},
|
|
23
25
|
"files": [
|
|
@@ -44,12 +46,17 @@
|
|
|
44
46
|
"solid-js": ">=1.9.12"
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
|
49
|
+
"@eslint/js": "^10.0.1",
|
|
47
50
|
"@opencode-ai/plugin": "*",
|
|
48
51
|
"@opentui/core": ">=0.4.0",
|
|
49
52
|
"@opentui/solid": ">=0.4.0",
|
|
50
53
|
"esbuild-plugin-solid": "^0.6.0",
|
|
54
|
+
"eslint": "^10.6.0",
|
|
55
|
+
"eslint-plugin-solid": "^0.14.5",
|
|
56
|
+
"globals": "^17.7.0",
|
|
51
57
|
"solid-js": "^1.9",
|
|
52
58
|
"tsup": "^8.5",
|
|
53
|
-
"typescript": "^5"
|
|
59
|
+
"typescript": "^5",
|
|
60
|
+
"typescript-eslint": "^8.63.0"
|
|
54
61
|
}
|
|
55
62
|
}
|