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.
Files changed (3) hide show
  1. package/README.md +3 -0
  2. package/dist/index.js +186 -51
  3. 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 as mkdirSync2, writeFileSync, 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,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 { mkdirSync, appendFileSync, readFileSync, existsSync } from "fs";
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
- var nodesFile = () => join(BASE_DIR, "nodes.ndjson");
17
- var edgesFile = () => join(BASE_DIR, "edges.ndjson");
18
- function readNdjson(path) {
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 (!existsSync(path)) return [];
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
- function readNodes() {
27
- return readNdjson(nodesFile());
28
- }
29
- function readEdges() {
30
- return readNdjson(edgesFile());
31
- }
32
- function uid(prefix) {
33
- return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
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 addDomainNode(node) {
36
- const full = { ...node, id: uid("node"), ts: (/* @__PURE__ */ new Date()).toISOString() };
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
- mkdirSync(BASE_DIR, { recursive: true });
39
- appendFileSync(nodesFile(), JSON.stringify(full) + "\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
+ }
40
115
  } catch {
41
116
  }
42
- return full.id;
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
- const lc = keywords.map((k) => k.toLowerCase());
46
- const nodes = readNodes();
47
- const matched = nodes.filter((n) => {
48
- const hay = (n.name + " " + JSON.stringify(n.props)).toLowerCase();
49
- return lc.some((k) => k && hay.includes(k));
50
- });
51
- const ids = new Set(matched.map((n) => n.id));
52
- 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
+ }
53
147
  return { nodes: matched, edges };
54
148
  }
55
- function saveInvestigationResult(keywords, result, source) {
56
- try {
57
- return addDomainNode({
58
- type: "fact",
59
- name: keywords.join(" "),
60
- props: { result },
61
- source: source || "investigation",
62
- confidence: 0.7
63
- });
64
- } catch {
65
- return "";
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
- appendFileSync2(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
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
- mkdirSync2(STATE_DIR, { recursive: true });
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
- mkdirSync2(dirname(f), { recursive: true });
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
- mkdirSync2(STATE_DIR, { recursive: true });
520
- appendFileSync2(failuresFile(), JSON.stringify(rec) + "\n");
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
- mkdirSync2(STATE_DIR, { recursive: true });
625
- appendFileSync2(rulesFile(), `## Rule (${date})
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.4.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
  }