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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +173 -93
  3. 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 as mkdirSync2, writeFileSync as writeFileSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
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 { mkdirSync, appendFileSync, readFileSync, existsSync, writeFileSync } from "fs";
10
+ import { Database, Connection } from "@ladybugdb/core";
11
11
  import { join } from "path";
12
- var BASE_DIR = "";
13
- function initDomain(stateDir) {
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
- if (!existsSync(path)) return [];
21
- return readFileSync(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
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
- function readNodes() {
27
- return readNdjson(nodesFile());
28
- }
29
- function readEdges() {
30
- return readNdjson(edgesFile());
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 addDomainNode(node) {
36
- const full = { ...node, id: uid("node"), ts: (/* @__PURE__ */ new Date()).toISOString() };
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
- mkdirSync(BASE_DIR, { recursive: true });
39
- appendFileSync(nodesFile(), JSON.stringify(full) + "\n");
53
+ if (r.props) props = JSON.parse(r.props);
40
54
  } catch {
41
55
  }
42
- return full.id;
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 writeNodes(nodes) {
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
- mkdirSync(BASE_DIR, { recursive: true });
47
- const lines = nodes.map((n) => JSON.stringify(n));
48
- writeFileSync(nodesFile(), lines.length ? lines.join("\n") + "\n" : "");
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
- const lc = keywords.map((k) => k.toLowerCase());
54
- const nodes = readNodes();
55
- const matched = nodes.filter((n) => {
56
- const hay = (n.name + " " + JSON.stringify(n.props)).toLowerCase();
57
- return lc.some((k) => k && hay.includes(k));
58
- });
59
- if (matched.length) touchNodes(new Set(matched.map((n) => n.id)));
60
- const ids = new Set(matched.map((n) => n.id));
61
- const edges = readEdges().filter((e) => ids.has(e.from) || ids.has(e.to));
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 touchNodes(ids) {
149
+ async function _touchNodes(ids) {
65
150
  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 {
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
- 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 };
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
- try {
102
- return addDomainNode({
103
- type: "fact",
104
- name: keywords.join(" "),
105
- props: { result },
106
- source: source || "investigation",
107
- confidence: 0.7
108
- });
109
- } catch {
110
- return "";
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
- appendFileSync2(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
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
- mkdirSync2(STATE_DIR, { recursive: true });
141
- writeFileSync2(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
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
- mkdirSync2(dirname(f), { recursive: true });
272
+ mkdirSync(dirname(f), { recursive: true });
193
273
  h.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
194
- writeFileSync2(f, JSON.stringify(h, null, 2));
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
- mkdirSync2(STATE_DIR, { recursive: true });
575
- appendFileSync2(failuresFile(), JSON.stringify(rec) + "\n");
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
- mkdirSync2(STATE_DIR, { recursive: true });
680
- appendFileSync2(rulesFile(), `## Rule (${date})
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.5.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
  }