opencode-usage-coach 0.5.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 +1 -0
- package/dist/index.js +173 -93
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -155,6 +155,7 @@ Place in the **work directory**. Each role runs on its model, so per-model quota
|
|
|
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
156
|
| `UC_WORM_MAX_AGE_DAYS` | 180 | domain DB worm (GC): drop nodes not accessed in N days (~6 months) |
|
|
157
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 |
|
|
158
159
|
|
|
159
160
|
## Agent-mode scoping
|
|
160
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,108 +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
|
-
|
|
13
|
-
|
|
14
|
-
BASE_DIR = stateDir;
|
|
15
|
-
}
|
|
16
|
-
var nodesFile = () => join(BASE_DIR, "nodes.ndjson");
|
|
17
|
-
var edgesFile = () => join(BASE_DIR, "edges.ndjson");
|
|
18
|
-
function readNdjson(path) {
|
|
12
|
+
import { existsSync, readFileSync } from "fs";
|
|
13
|
+
var QUERY_TIMEOUT_MS = (() => {
|
|
19
14
|
try {
|
|
20
|
-
|
|
21
|
-
return
|
|
15
|
+
const v = Number(process.env.UC_DOMAIN_TIMEOUT_MS);
|
|
16
|
+
return Number.isFinite(v) && v > 0 ? v : 5e3;
|
|
22
17
|
} catch {
|
|
23
|
-
return
|
|
18
|
+
return 5e3;
|
|
24
19
|
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
})();
|
|
21
|
+
var BASE_DIR = "";
|
|
22
|
+
var DB_PATH = "";
|
|
23
|
+
var db = null;
|
|
24
|
+
var conn = null;
|
|
25
|
+
var schemaReady = false;
|
|
26
|
+
var migrated = false;
|
|
27
|
+
function initDomain(stateDir) {
|
|
28
|
+
BASE_DIR = stateDir;
|
|
29
|
+
DB_PATH = join(BASE_DIR, "domain.ladybug");
|
|
30
|
+
db = null;
|
|
31
|
+
conn = null;
|
|
32
|
+
schemaReady = false;
|
|
33
|
+
migrated = false;
|
|
31
34
|
}
|
|
32
35
|
function uid(prefix) {
|
|
33
36
|
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
34
37
|
}
|
|
35
|
-
function
|
|
36
|
-
|
|
38
|
+
function esc(s) {
|
|
39
|
+
return String(s ?? "").replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
40
|
+
}
|
|
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 = {};
|
|
37
52
|
try {
|
|
38
|
-
|
|
39
|
-
appendFileSync(nodesFile(), JSON.stringify(full) + "\n");
|
|
53
|
+
if (r.props) props = JSON.parse(r.props);
|
|
40
54
|
} catch {
|
|
41
55
|
}
|
|
42
|
-
return
|
|
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
|
+
};
|
|
67
|
+
}
|
|
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;
|
|
43
90
|
}
|
|
44
|
-
function
|
|
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;
|
|
45
96
|
try {
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
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
|
+
}
|
|
49
115
|
} catch {
|
|
50
116
|
}
|
|
51
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;
|
|
129
|
+
}
|
|
52
130
|
function queryDomain(keywords) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
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
|
+
}
|
|
62
147
|
return { nodes: matched, edges };
|
|
63
148
|
}
|
|
64
|
-
function
|
|
149
|
+
async function _touchNodes(ids) {
|
|
65
150
|
if (ids.size === 0) return;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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);
|
|
79
173
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 };
|
|
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`);
|
|
98
178
|
}
|
|
179
|
+
return { removed: toRemove.length, kept: kept.length };
|
|
99
180
|
}
|
|
100
181
|
function saveInvestigationResult(keywords, result, source) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
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
|
+
});
|
|
112
192
|
}
|
|
113
193
|
|
|
114
194
|
// src/index.ts
|
|
@@ -130,15 +210,15 @@ function setStateDir(dir) {
|
|
|
130
210
|
var NOOP_HOOKS = {};
|
|
131
211
|
function log(msg) {
|
|
132
212
|
try {
|
|
133
|
-
|
|
213
|
+
appendFileSync(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
|
|
134
214
|
`);
|
|
135
215
|
} catch {
|
|
136
216
|
}
|
|
137
217
|
}
|
|
138
218
|
function writeState(c) {
|
|
139
219
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
220
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
221
|
+
writeFileSync(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
142
222
|
} catch {
|
|
143
223
|
}
|
|
144
224
|
}
|
|
@@ -189,9 +269,9 @@ function readHarness(sessionID) {
|
|
|
189
269
|
function writeHarness(sessionID, h) {
|
|
190
270
|
try {
|
|
191
271
|
const f = harnessFile(sessionID);
|
|
192
|
-
|
|
272
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
193
273
|
h.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
194
|
-
|
|
274
|
+
writeFileSync(f, JSON.stringify(h, null, 2));
|
|
195
275
|
} catch {
|
|
196
276
|
}
|
|
197
277
|
}
|
|
@@ -446,7 +526,7 @@ async function UsageCoachPlugin(input) {
|
|
|
446
526
|
if (event.type === "session.created" || event.type === "session.idle") refreshBackground();
|
|
447
527
|
if (event.type === "session.idle") {
|
|
448
528
|
try {
|
|
449
|
-
const r = evictStale(WORM_MAX_AGE_DAYS, WORM_MAX_NODES);
|
|
529
|
+
const r = await evictStale(WORM_MAX_AGE_DAYS, WORM_MAX_NODES);
|
|
450
530
|
if (r.removed) log(`evictStale: removed ${r.removed}, kept ${r.kept} (maxAge=${WORM_MAX_AGE_DAYS}d, maxNodes=${WORM_MAX_NODES})`);
|
|
451
531
|
} catch (e) {
|
|
452
532
|
log(`evictStale err: ${String(e)}`);
|
|
@@ -571,8 +651,8 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
|
|
|
571
651
|
async execute(args, _ctx) {
|
|
572
652
|
const rec = { ts: (/* @__PURE__ */ new Date()).toISOString(), task: args.task, prompt: args.prompt, gradeResult: args.gradeResult, model: args.model, revisions: args.revisions };
|
|
573
653
|
try {
|
|
574
|
-
|
|
575
|
-
|
|
654
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
655
|
+
appendFileSync(failuresFile(), JSON.stringify(rec) + "\n");
|
|
576
656
|
} catch (e) {
|
|
577
657
|
log(`record_failure err: ${String(e)}`);
|
|
578
658
|
}
|
|
@@ -596,7 +676,7 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
|
|
|
596
676
|
try {
|
|
597
677
|
keywords = extractKeywords(`${args.task} ${args.gradeResult}`);
|
|
598
678
|
if (keywords.length) {
|
|
599
|
-
const { nodes, edges } = queryDomain(keywords);
|
|
679
|
+
const { nodes, edges } = await queryDomain(keywords);
|
|
600
680
|
if (nodes && nodes.length || edges && edges.length) {
|
|
601
681
|
domainEmpty = false;
|
|
602
682
|
domainPrefix = `Known facts from domain DB: ${JSON.stringify({ nodes, edges })}. Use these if relevant.
|
|
@@ -620,7 +700,7 @@ evidence: <file/line or specific quote>`;
|
|
|
620
700
|
const out = await runModel(input.client, cfg.generator, domainPrefix + rcaPrompt, ctx.directory);
|
|
621
701
|
if (domainEmpty && keywords.length) {
|
|
622
702
|
try {
|
|
623
|
-
saveInvestigationResult(keywords, out, "investigate");
|
|
703
|
+
await saveInvestigationResult(keywords, out, "investigate");
|
|
624
704
|
} catch (e) {
|
|
625
705
|
log(`investigate save err: ${String(e)}`);
|
|
626
706
|
}
|
|
@@ -676,8 +756,8 @@ Keep it concrete and actionable.`;
|
|
|
676
756
|
const rule = out;
|
|
677
757
|
try {
|
|
678
758
|
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
679
|
-
|
|
680
|
-
|
|
759
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
760
|
+
appendFileSync(rulesFile(), `## Rule (${date})
|
|
681
761
|
${rule}
|
|
682
762
|
Origin: ${args.task}
|
|
683
763
|
|
|
@@ -716,7 +796,7 @@ ${rules}
|
|
|
716
796
|
try {
|
|
717
797
|
keywords = extractKeywords(args.prompt);
|
|
718
798
|
if (keywords.length) {
|
|
719
|
-
const { nodes, edges } = queryDomain(keywords);
|
|
799
|
+
const { nodes, edges } = await queryDomain(keywords);
|
|
720
800
|
if (nodes && nodes.length || edges && edges.length) {
|
|
721
801
|
domainEmpty = false;
|
|
722
802
|
prefix = `Known facts from domain DB: ${JSON.stringify({ nodes, edges })}. Use these if relevant.
|
|
@@ -732,7 +812,7 @@ ${rules}
|
|
|
732
812
|
const out = await runModel(input.client, model, prefix + args.prompt, ctx.directory);
|
|
733
813
|
if (domainEmpty && keywords.length) {
|
|
734
814
|
try {
|
|
735
|
-
saveInvestigationResult(keywords, out, "generate");
|
|
815
|
+
await saveInvestigationResult(keywords, out, "generate");
|
|
736
816
|
} catch (e) {
|
|
737
817
|
log(`generate save err: ${String(e)}`);
|
|
738
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
|
}
|