opencode-usage-coach 0.4.0 → 0.6.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 +3 -0
- package/dist/index.js +186 -51
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -153,6 +153,9 @@ Place in the **work directory**. Each role runs on its model, so per-model quota
|
|
|
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
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
|
+
| `UC_DOMAIN_TIMEOUT_MS` | 5000 | domain DB query timeout (ms) — a slow/hung query resolves to a safe fallback instead of blocking the plugin |
|
|
156
159
|
|
|
157
160
|
## Agent-mode scoping
|
|
158
161
|
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { mkdirSync
|
|
2
|
+
import { mkdirSync, writeFileSync, appendFileSync, 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,63 +7,188 @@ 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 {
|
|
10
|
+
import { Database, Connection } from "@ladybugdb/core";
|
|
11
11
|
import { join } from "path";
|
|
12
|
+
import { existsSync, readFileSync } from "fs";
|
|
13
|
+
var QUERY_TIMEOUT_MS = (() => {
|
|
14
|
+
try {
|
|
15
|
+
const v = Number(process.env.UC_DOMAIN_TIMEOUT_MS);
|
|
16
|
+
return Number.isFinite(v) && v > 0 ? v : 5e3;
|
|
17
|
+
} catch {
|
|
18
|
+
return 5e3;
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
12
21
|
var BASE_DIR = "";
|
|
22
|
+
var DB_PATH = "";
|
|
23
|
+
var db = null;
|
|
24
|
+
var conn = null;
|
|
25
|
+
var schemaReady = false;
|
|
26
|
+
var migrated = false;
|
|
13
27
|
function initDomain(stateDir) {
|
|
14
28
|
BASE_DIR = stateDir;
|
|
29
|
+
DB_PATH = join(BASE_DIR, "domain.ladybug");
|
|
30
|
+
db = null;
|
|
31
|
+
conn = null;
|
|
32
|
+
schemaReady = false;
|
|
33
|
+
migrated = false;
|
|
34
|
+
}
|
|
35
|
+
function uid(prefix) {
|
|
36
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
37
|
+
}
|
|
38
|
+
function esc(s) {
|
|
39
|
+
return String(s ?? "").replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
15
40
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
function timed(op, fallback) {
|
|
42
|
+
let timer;
|
|
43
|
+
const timeout = new Promise((resolve2) => {
|
|
44
|
+
timer = setTimeout(() => resolve2(fallback), QUERY_TIMEOUT_MS);
|
|
45
|
+
});
|
|
46
|
+
return Promise.race([op().catch(() => fallback), timeout]).finally(() => {
|
|
47
|
+
if (timer) clearTimeout(timer);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function parseNode(r) {
|
|
51
|
+
let props = {};
|
|
19
52
|
try {
|
|
20
|
-
if (
|
|
21
|
-
return readFileSync(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
53
|
+
if (r.props) props = JSON.parse(r.props);
|
|
22
54
|
} catch {
|
|
23
|
-
return [];
|
|
24
55
|
}
|
|
56
|
+
return {
|
|
57
|
+
id: r.id,
|
|
58
|
+
type: r.type,
|
|
59
|
+
name: r.name,
|
|
60
|
+
props,
|
|
61
|
+
source: r.source ?? "",
|
|
62
|
+
confidence: r.confidence ?? 0,
|
|
63
|
+
ts: r.ts,
|
|
64
|
+
lastAccessed: r.lastAccessed || void 0,
|
|
65
|
+
accessCount: r.accessCount ?? void 0
|
|
66
|
+
};
|
|
25
67
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
68
|
+
var NODE_COLS = "n.id AS id, n.type AS type, n.name AS name, n.props AS props, n.source AS source, n.confidence AS confidence, n.ts AS ts, n.lastAccessed AS lastAccessed, n.accessCount AS accessCount";
|
|
69
|
+
async function ready() {
|
|
70
|
+
if (!conn) {
|
|
71
|
+
db = new Database(DB_PATH);
|
|
72
|
+
conn = new Connection(db);
|
|
73
|
+
}
|
|
74
|
+
if (!schemaReady) {
|
|
75
|
+
try {
|
|
76
|
+
await conn.query("CREATE NODE TABLE DomainNode(id STRING, type STRING, name STRING, props STRING, source STRING, confidence DOUBLE, lastAccessed STRING, accessCount INT64 DEFAULT 0, ts STRING, PRIMARY KEY(id))");
|
|
77
|
+
} catch {
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
await conn.query("CREATE REL TABLE Related(FROM DomainNode TO DomainNode, rel STRING, note STRING, ts STRING)");
|
|
81
|
+
} catch {
|
|
82
|
+
}
|
|
83
|
+
schemaReady = true;
|
|
84
|
+
}
|
|
85
|
+
if (!migrated) {
|
|
86
|
+
migrated = true;
|
|
87
|
+
await migrateFromNdjson();
|
|
88
|
+
}
|
|
89
|
+
return conn;
|
|
34
90
|
}
|
|
35
|
-
function
|
|
36
|
-
|
|
91
|
+
async function migrateFromNdjson() {
|
|
92
|
+
if (!conn) return;
|
|
93
|
+
const nf = join(BASE_DIR, "nodes.ndjson");
|
|
94
|
+
const ef = join(BASE_DIR, "edges.ndjson");
|
|
95
|
+
if (!existsSync(nf)) return;
|
|
37
96
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
97
|
+
const existing = await conn.query("MATCH (n:DomainNode) RETURN count(n) AS c");
|
|
98
|
+
const cnt = (await existing.getAll())[0]?.c ?? 0;
|
|
99
|
+
if (cnt > 0) return;
|
|
100
|
+
const readNdjson = (p) => {
|
|
101
|
+
try {
|
|
102
|
+
return readFileSync(p, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
for (const n of readNdjson(nf)) {
|
|
108
|
+
await conn.query(`CREATE (n:DomainNode {id:'${esc(n.id)}',type:'${esc(n.type)}',name:'${esc(n.name)}',props:'${esc(JSON.stringify(n.props ?? {}))}',source:'${esc(n.source ?? "")}',confidence:${Number(n.confidence ?? 0)},ts:'${esc(n.ts ?? "")}',lastAccessed:'${esc(n.lastAccessed ?? "")}',accessCount:${Number(n.accessCount ?? 0)}})`);
|
|
109
|
+
}
|
|
110
|
+
if (existsSync(ef)) {
|
|
111
|
+
for (const e of readNdjson(ef)) {
|
|
112
|
+
await conn.query(`MATCH (a:DomainNode {id:'${esc(e.from)}'}), (b:DomainNode {id:'${esc(e.to)}'}) CREATE (a)-[:Related {rel:'${esc(e.rel)}',note:'${esc(e.note ?? "")}',ts:'${esc(e.ts ?? "")}'}]->(b)`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
40
115
|
} catch {
|
|
41
116
|
}
|
|
42
|
-
|
|
117
|
+
}
|
|
118
|
+
async function _readNodes() {
|
|
119
|
+
const c = await ready();
|
|
120
|
+
const r = await c.query(`MATCH (n:DomainNode) RETURN ${NODE_COLS}`);
|
|
121
|
+
return (await r.getAll()).map(parseNode);
|
|
122
|
+
}
|
|
123
|
+
async function _addDomainNode(node) {
|
|
124
|
+
const c = await ready();
|
|
125
|
+
const id = uid("node");
|
|
126
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
127
|
+
await c.query(`CREATE (n:DomainNode {id:'${esc(id)}',type:'${esc(node.type)}',name:'${esc(node.name)}',props:'${esc(JSON.stringify(node.props ?? {}))}',source:'${esc(node.source ?? "")}',confidence:${Number(node.confidence ?? 0)},ts:'${esc(now)}',lastAccessed:'',accessCount:0})`);
|
|
128
|
+
return id;
|
|
43
129
|
}
|
|
44
130
|
function queryDomain(keywords) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
131
|
+
return timed(() => _queryDomain(keywords), { nodes: [], edges: [] });
|
|
132
|
+
}
|
|
133
|
+
async function _queryDomain(keywords) {
|
|
134
|
+
const lc = keywords.map((k) => k.toLowerCase()).filter(Boolean);
|
|
135
|
+
if (lc.length === 0) return { nodes: [], edges: [] };
|
|
136
|
+
const c = await ready();
|
|
137
|
+
const conds = lc.map((kw) => `(lower(n.name) CONTAINS '${esc(kw)}' OR lower(n.props) CONTAINS '${esc(kw)}')`).join(" OR ");
|
|
138
|
+
const r = await c.query(`MATCH (n:DomainNode) WHERE ${conds} RETURN ${NODE_COLS}`);
|
|
139
|
+
const matched = (await r.getAll()).map(parseNode);
|
|
140
|
+
if (matched.length) await _touchNodes(new Set(matched.map((n) => n.id)));
|
|
141
|
+
let edges = [];
|
|
142
|
+
if (matched.length) {
|
|
143
|
+
const ids = matched.map((n) => `'${esc(n.id)}'`).join(",");
|
|
144
|
+
const er = await c.query(`MATCH (a:DomainNode)-[r:Related]->(b:DomainNode) WHERE a.id IN [${ids}] OR b.id IN [${ids}] RETURN a.id AS from, b.id AS to, r.rel AS rel, r.note AS note, r.ts AS ts`);
|
|
145
|
+
edges = await er.getAll();
|
|
146
|
+
}
|
|
53
147
|
return { nodes: matched, edges };
|
|
54
148
|
}
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
149
|
+
async function _touchNodes(ids) {
|
|
150
|
+
if (ids.size === 0) return;
|
|
151
|
+
const c = await ready();
|
|
152
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
153
|
+
const idList = [...ids].map((id) => `'${esc(id)}'`).join(",");
|
|
154
|
+
await c.query(`MATCH (n:DomainNode) WHERE n.id IN [${idList}] SET n.lastAccessed = '${esc(now)}', n.accessCount = COALESCE(n.accessCount, 0) + 1`);
|
|
155
|
+
}
|
|
156
|
+
function evictStale(maxAgeDays = 30, maxNodes = 1e5) {
|
|
157
|
+
return timed(() => _evictStale(maxAgeDays, maxNodes), { removed: 0, kept: 0 });
|
|
158
|
+
}
|
|
159
|
+
async function _evictStale(maxAgeDays, maxNodes) {
|
|
160
|
+
const c = await ready();
|
|
161
|
+
const all = await _readNodes();
|
|
162
|
+
if (all.length === 0) return { removed: 0, kept: 0 };
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
const ageMs = maxAgeDays * 864e5;
|
|
165
|
+
const lastTs = (n) => {
|
|
166
|
+
const la = n.lastAccessed ? new Date(n.lastAccessed).getTime() : 0;
|
|
167
|
+
return la || new Date(n.ts).getTime();
|
|
168
|
+
};
|
|
169
|
+
let kept = all.filter((n) => now - lastTs(n) < ageMs);
|
|
170
|
+
if (kept.length > maxNodes) {
|
|
171
|
+
kept.sort((a, b) => lastTs(b) - lastTs(a));
|
|
172
|
+
kept = kept.slice(0, maxNodes);
|
|
66
173
|
}
|
|
174
|
+
const keepIds = new Set(kept.map((n) => n.id));
|
|
175
|
+
const toRemove = all.filter((n) => !keepIds.has(n.id));
|
|
176
|
+
for (const n of toRemove) {
|
|
177
|
+
await c.query(`MATCH (n:DomainNode {id:'${esc(n.id)}'}) DETACH DELETE n`);
|
|
178
|
+
}
|
|
179
|
+
return { removed: toRemove.length, kept: kept.length };
|
|
180
|
+
}
|
|
181
|
+
function saveInvestigationResult(keywords, result, source) {
|
|
182
|
+
return timed(() => _saveInvestigationResult(keywords, result, source), "");
|
|
183
|
+
}
|
|
184
|
+
async function _saveInvestigationResult(keywords, result, source) {
|
|
185
|
+
return _addDomainNode({
|
|
186
|
+
type: "fact",
|
|
187
|
+
name: keywords.join(" "),
|
|
188
|
+
props: { result },
|
|
189
|
+
source: source || "investigation",
|
|
190
|
+
confidence: 0.7
|
|
191
|
+
});
|
|
67
192
|
}
|
|
68
193
|
|
|
69
194
|
// src/index.ts
|
|
@@ -85,14 +210,14 @@ function setStateDir(dir) {
|
|
|
85
210
|
var NOOP_HOOKS = {};
|
|
86
211
|
function log(msg) {
|
|
87
212
|
try {
|
|
88
|
-
|
|
213
|
+
appendFileSync(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
|
|
89
214
|
`);
|
|
90
215
|
} catch {
|
|
91
216
|
}
|
|
92
217
|
}
|
|
93
218
|
function writeState(c) {
|
|
94
219
|
try {
|
|
95
|
-
|
|
220
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
96
221
|
writeFileSync(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
97
222
|
} catch {
|
|
98
223
|
}
|
|
@@ -144,7 +269,7 @@ function readHarness(sessionID) {
|
|
|
144
269
|
function writeHarness(sessionID, h) {
|
|
145
270
|
try {
|
|
146
271
|
const f = harnessFile(sessionID);
|
|
147
|
-
|
|
272
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
148
273
|
h.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
149
274
|
writeFileSync(f, JSON.stringify(h, null, 2));
|
|
150
275
|
} catch {
|
|
@@ -211,6 +336,8 @@ var THR_5H = num("UC_THROTTLE_5H", 70);
|
|
|
211
336
|
var STOP_WK = num("UC_STOP_WEEKLY", 95);
|
|
212
337
|
var THR_WK = num("UC_THROTTLE_WEEKLY", 85);
|
|
213
338
|
var STOP_MO = num("UC_STOP_MONTHLY", 98);
|
|
339
|
+
var WORM_MAX_AGE_DAYS = num("UC_WORM_MAX_AGE_DAYS", 180);
|
|
340
|
+
var WORM_MAX_NODES = num("UC_WORM_MAX_NODES", 1e5);
|
|
214
341
|
function humanRemaining(iso) {
|
|
215
342
|
try {
|
|
216
343
|
if (!iso) return "";
|
|
@@ -397,6 +524,14 @@ async function UsageCoachPlugin(input) {
|
|
|
397
524
|
event: async ({ event }) => {
|
|
398
525
|
try {
|
|
399
526
|
if (event.type === "session.created" || event.type === "session.idle") refreshBackground();
|
|
527
|
+
if (event.type === "session.idle") {
|
|
528
|
+
try {
|
|
529
|
+
const r = await evictStale(WORM_MAX_AGE_DAYS, WORM_MAX_NODES);
|
|
530
|
+
if (r.removed) log(`evictStale: removed ${r.removed}, kept ${r.kept} (maxAge=${WORM_MAX_AGE_DAYS}d, maxNodes=${WORM_MAX_NODES})`);
|
|
531
|
+
} catch (e) {
|
|
532
|
+
log(`evictStale err: ${String(e)}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
400
535
|
} catch (e) {
|
|
401
536
|
log(`event err: ${String(e)}`);
|
|
402
537
|
}
|
|
@@ -516,8 +651,8 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
|
|
|
516
651
|
async execute(args, _ctx) {
|
|
517
652
|
const rec = { ts: (/* @__PURE__ */ new Date()).toISOString(), task: args.task, prompt: args.prompt, gradeResult: args.gradeResult, model: args.model, revisions: args.revisions };
|
|
518
653
|
try {
|
|
519
|
-
|
|
520
|
-
|
|
654
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
655
|
+
appendFileSync(failuresFile(), JSON.stringify(rec) + "\n");
|
|
521
656
|
} catch (e) {
|
|
522
657
|
log(`record_failure err: ${String(e)}`);
|
|
523
658
|
}
|
|
@@ -541,7 +676,7 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
|
|
|
541
676
|
try {
|
|
542
677
|
keywords = extractKeywords(`${args.task} ${args.gradeResult}`);
|
|
543
678
|
if (keywords.length) {
|
|
544
|
-
const { nodes, edges } = queryDomain(keywords);
|
|
679
|
+
const { nodes, edges } = await queryDomain(keywords);
|
|
545
680
|
if (nodes && nodes.length || edges && edges.length) {
|
|
546
681
|
domainEmpty = false;
|
|
547
682
|
domainPrefix = `Known facts from domain DB: ${JSON.stringify({ nodes, edges })}. Use these if relevant.
|
|
@@ -565,7 +700,7 @@ evidence: <file/line or specific quote>`;
|
|
|
565
700
|
const out = await runModel(input.client, cfg.generator, domainPrefix + rcaPrompt, ctx.directory);
|
|
566
701
|
if (domainEmpty && keywords.length) {
|
|
567
702
|
try {
|
|
568
|
-
saveInvestigationResult(keywords, out, "investigate");
|
|
703
|
+
await saveInvestigationResult(keywords, out, "investigate");
|
|
569
704
|
} catch (e) {
|
|
570
705
|
log(`investigate save err: ${String(e)}`);
|
|
571
706
|
}
|
|
@@ -621,8 +756,8 @@ Keep it concrete and actionable.`;
|
|
|
621
756
|
const rule = out;
|
|
622
757
|
try {
|
|
623
758
|
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
624
|
-
|
|
625
|
-
|
|
759
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
760
|
+
appendFileSync(rulesFile(), `## Rule (${date})
|
|
626
761
|
${rule}
|
|
627
762
|
Origin: ${args.task}
|
|
628
763
|
|
|
@@ -661,7 +796,7 @@ ${rules}
|
|
|
661
796
|
try {
|
|
662
797
|
keywords = extractKeywords(args.prompt);
|
|
663
798
|
if (keywords.length) {
|
|
664
|
-
const { nodes, edges } = queryDomain(keywords);
|
|
799
|
+
const { nodes, edges } = await queryDomain(keywords);
|
|
665
800
|
if (nodes && nodes.length || edges && edges.length) {
|
|
666
801
|
domainEmpty = false;
|
|
667
802
|
prefix = `Known facts from domain DB: ${JSON.stringify({ nodes, edges })}. Use these if relevant.
|
|
@@ -677,7 +812,7 @@ ${rules}
|
|
|
677
812
|
const out = await runModel(input.client, model, prefix + args.prompt, ctx.directory);
|
|
678
813
|
if (domainEmpty && keywords.length) {
|
|
679
814
|
try {
|
|
680
|
-
saveInvestigationResult(keywords, out, "generate");
|
|
815
|
+
await saveInvestigationResult(keywords, out, "generate");
|
|
681
816
|
} catch (e) {
|
|
682
817
|
log(`generate save err: ${String(e)}`);
|
|
683
818
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-usage-coach",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -58,5 +58,11 @@
|
|
|
58
58
|
"tsup": "^8.5",
|
|
59
59
|
"typescript": "^5",
|
|
60
60
|
"typescript-eslint": "^8.63.0"
|
|
61
|
-
}
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@ladybugdb/core": "^0.18.0"
|
|
64
|
+
},
|
|
65
|
+
"trustedDependencies": [
|
|
66
|
+
"@ladybugdb/core"
|
|
67
|
+
]
|
|
62
68
|
}
|