opencode-usage-coach 0.11.4 → 0.12.1

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 (2) hide show
  1. package/dist/index.js +237 -60
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,20 +1,28 @@
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 as mkdirSync3, writeFileSync as writeFileSync2, appendFileSync as appendFileSync3, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
3
3
  import { spawn, spawnSync } from "child_process";
4
4
  import { createHash } from "crypto";
5
- import { homedir } from "os";
6
- import { join as join2, resolve, dirname } from "path";
5
+ import { homedir as homedir2 } from "os";
6
+ import { join as join3, resolve, dirname as dirname2 } from "path";
7
7
  import { tool } from "@opencode-ai/plugin";
8
8
 
9
9
  // src/domain.ts
10
10
  import { mkdirSync, appendFileSync, readFileSync, existsSync, writeFileSync } from "fs";
11
- import { join } from "path";
11
+ import { join, dirname, basename } from "path";
12
12
  var BASE_DIR = "";
13
+ var SHARED_DIR = "";
13
14
  function initDomain(stateDir) {
14
15
  BASE_DIR = stateDir;
16
+ if (basename(dirname(stateDir)) === "projects") {
17
+ SHARED_DIR = join(dirname(dirname(stateDir)), "shared");
18
+ } else {
19
+ SHARED_DIR = join(stateDir, "_shared");
20
+ }
15
21
  }
16
22
  var nodesFile = () => join(BASE_DIR, "nodes.ndjson");
17
23
  var edgesFile = () => join(BASE_DIR, "edges.ndjson");
24
+ var sharedNodesFile = () => join(SHARED_DIR, "nodes.ndjson");
25
+ var sharedEdgesFile = () => join(SHARED_DIR, "edges.ndjson");
18
26
  function readNdjson(path) {
19
27
  try {
20
28
  if (!existsSync(path)) return [];
@@ -24,23 +32,14 @@ function readNdjson(path) {
24
32
  }
25
33
  }
26
34
  function readNodes() {
27
- return readNdjson(nodesFile());
35
+ return [...readNdjson(nodesFile()), ...readNdjson(sharedNodesFile())];
28
36
  }
29
37
  function readEdges() {
30
- return readNdjson(edgesFile());
38
+ return [...readNdjson(edgesFile()), ...readNdjson(sharedEdgesFile())];
31
39
  }
32
40
  function uid(prefix) {
33
41
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
34
42
  }
35
- function addDomainNode(node) {
36
- const full = { ...node, id: uid("node"), ts: (/* @__PURE__ */ new Date()).toISOString() };
37
- try {
38
- mkdirSync(BASE_DIR, { recursive: true });
39
- appendFileSync(nodesFile(), JSON.stringify(full) + "\n");
40
- } catch {
41
- }
42
- return full.id;
43
- }
44
43
  function addDomainEdge(edge) {
45
44
  const full = { ...edge, ts: (/* @__PURE__ */ new Date()).toISOString() };
46
45
  try {
@@ -49,6 +48,9 @@ function addDomainEdge(edge) {
49
48
  } catch {
50
49
  }
51
50
  }
51
+ function readProjectNodes() {
52
+ return readNdjson(nodesFile());
53
+ }
52
54
  function writeNodes(nodes) {
53
55
  try {
54
56
  mkdirSync(BASE_DIR, { recursive: true });
@@ -72,7 +74,7 @@ function queryDomain(keywords) {
72
74
  function touchNodes(ids) {
73
75
  if (ids.size === 0) return;
74
76
  try {
75
- const nodes = readNodes();
77
+ const nodes = readProjectNodes();
76
78
  let changed = false;
77
79
  const now = (/* @__PURE__ */ new Date()).toISOString();
78
80
  for (const n of nodes) {
@@ -88,7 +90,7 @@ function touchNodes(ids) {
88
90
  }
89
91
  function evictStale(maxAgeDays = 30, maxNodes = 1e3) {
90
92
  try {
91
- const nodes = readNodes();
93
+ const nodes = readProjectNodes();
92
94
  if (nodes.length === 0) return { removed: 0, kept: 0 };
93
95
  const now = Date.now();
94
96
  const ageMs = maxAgeDays * 864e5;
@@ -167,19 +169,70 @@ function queryDomainGraph(keywords, maxDepth = 2, opts = {}) {
167
169
  }
168
170
  function saveInvestigationResult(keywords, result, source, confidence = 0.7) {
169
171
  try {
170
- return addDomainNode({
172
+ const nodeId = uid("node");
173
+ const full = {
174
+ id: nodeId,
171
175
  type: "fact",
172
176
  name: keywords.join(" "),
173
- props: { result },
177
+ props: { result, keywords: [...new Set(keywords)] },
174
178
  source: source || "investigation",
175
- confidence
176
- });
179
+ confidence,
180
+ ts: (/* @__PURE__ */ new Date()).toISOString()
181
+ };
182
+ try {
183
+ mkdirSync(SHARED_DIR, { recursive: true });
184
+ appendFileSync(sharedNodesFile(), JSON.stringify(full) + "\n");
185
+ } catch {
186
+ }
187
+ autoLinkKeywords(nodeId, keywords);
188
+ return nodeId;
177
189
  } catch {
178
190
  return "";
179
191
  }
180
192
  }
193
+ function autoLinkKeywords(nodeId, keywords, minOverlap = 2, maxLinks = 8) {
194
+ if (keywords.length < minOverlap) return;
195
+ try {
196
+ const candidates = queryDomain(keywords);
197
+ let linked = 0;
198
+ for (const node of candidates.nodes) {
199
+ if (node.id === nodeId) continue;
200
+ const nodeKw = Array.isArray(node.props?.keywords) ? node.props.keywords : (node.name || "").toLowerCase().split(/[^a-z0-9_-]+/).filter((w) => w.length >= 3);
201
+ const overlap = keywords.filter((k) => nodeKw.includes(k));
202
+ if (overlap.length >= minOverlap) {
203
+ const edge = {
204
+ from: nodeId,
205
+ to: node.id,
206
+ rel: "related-to",
207
+ note: `auto: ${overlap.length} shared (${overlap.slice(0, 5).join(",")})`,
208
+ ts: (/* @__PURE__ */ new Date()).toISOString()
209
+ };
210
+ try {
211
+ mkdirSync(SHARED_DIR, { recursive: true });
212
+ appendFileSync(sharedEdgesFile(), JSON.stringify(edge) + "\n");
213
+ } catch {
214
+ }
215
+ linked++;
216
+ if (linked >= maxLinks) break;
217
+ }
218
+ }
219
+ } catch {
220
+ }
221
+ }
181
222
 
182
223
  // src/web-search.ts
224
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync2 } from "fs";
225
+ import { join as join2 } from "path";
226
+ import { homedir } from "os";
227
+ var WS_LOG_FILE = process.env.UC_STATE_DIR ? join2(process.env.UC_STATE_DIR, "web-search.log") : join2(homedir(), ".cache", "opencode-usage-coach", "web-search.log");
228
+ function wsLog(msg) {
229
+ try {
230
+ mkdirSync2(join2(WS_LOG_FILE, ".."), { recursive: true });
231
+ appendFileSync2(WS_LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
232
+ `);
233
+ } catch {
234
+ }
235
+ }
183
236
  var FRAMEWORK_DOCS = {
184
237
  "react": { name: "React", docs: "https://react.dev", githubOrg: "facebook" },
185
238
  "solid-js": { name: "Solid.js", docs: "https://solidjs.com", githubOrg: "solidjs" },
@@ -242,7 +295,7 @@ async function ghFetch(url, signal) {
242
295
  const errs = ghError?.errors;
243
296
  const errMsg = ghError?.message ?? ghErrorText.slice(0, 300);
244
297
  const detail = errs ? errs.map((e) => typeof e === "string" ? e : `${e?.field ?? "?"}: ${e?.message ?? e?.code ?? JSON.stringify(e)}`).join("; ") : "";
245
- console.error(JSON.stringify({
298
+ wsLog(JSON.stringify({
246
299
  level: "error",
247
300
  module: "web-search",
248
301
  event: "gh-fetch-error",
@@ -278,7 +331,9 @@ async function tier1OfficialDocs(query, fws, results, docRefs, seen, signal) {
278
331
  const entry = FRAMEWORK_DOCS[fw];
279
332
  if (!entry?.githubOrg) continue;
280
333
  try {
281
- const fullQ = `${cleanQuery} org:${entry.githubOrg}`;
334
+ const orgPart = ` org:${entry.githubOrg}`;
335
+ const truncated = cleanQuery.slice(0, GH_QUERY_MAX - orgPart.length);
336
+ const fullQ = `${truncated}${orgPart}`;
282
337
  const url = `https://api.github.com/search/issues?q=${encodeURIComponent(fullQ)}&per_page=3`;
283
338
  const data = await ghFetch(url, signal);
284
339
  for (const item of data.items ?? []) {
@@ -291,7 +346,7 @@ async function tier1OfficialDocs(query, fws, results, docRefs, seen, signal) {
291
346
  }
292
347
  } catch (e) {
293
348
  const m = errMessage(e);
294
- console.error(`[web-search] tier1 ${fw}: ${m}`);
349
+ wsLog(`[web-search] tier1 ${fw}: ${m}`);
295
350
  errors.push(`tier1:${fw}:${m.slice(0, 80)}`);
296
351
  }
297
352
  }
@@ -314,7 +369,7 @@ async function tier2GitHubIssues(query, results, seen, signal) {
314
369
  }
315
370
  } catch (e) {
316
371
  const m = errMessage(e);
317
- console.error(`[web-search] tier2: ${m}`);
372
+ wsLog(`[web-search] tier2: ${m}`);
318
373
  errors.push(`tier2:${m.slice(0, 80)}`);
319
374
  }
320
375
  return errors;
@@ -338,7 +393,7 @@ async function tier3GitHubCode(query, results, seen, signal) {
338
393
  }
339
394
  } catch (e) {
340
395
  const m = errMessage(e);
341
- console.error(`[web-search] tier3: ${m}`);
396
+ wsLog(`[web-search] tier3: ${m}`);
342
397
  errors.push(`tier3:${m.slice(0, 80)}`);
343
398
  }
344
399
  return errors;
@@ -364,7 +419,7 @@ async function searchContext(query, frameworks, keyDeps, timeoutMs) {
364
419
  }
365
420
  } catch (e) {
366
421
  const m = errMessage(e);
367
- console.error(`[web-search] unexpected error: ${m}`);
422
+ wsLog(`[web-search] unexpected error: ${m}`);
368
423
  errors.push(`unexpected:${m.slice(0, 80)}`);
369
424
  } finally {
370
425
  clearTimeout(timer);
@@ -382,49 +437,49 @@ var DEFAULT_MAX_STEPS = Number(process.env.UC_MAX_STEPS ?? 30) || 30;
382
437
  var WATCHDOG_POLL_MS = Math.max(1e3, Number(process.env.UC_WATCHDOG_POLL_MS ?? 3e3) || 3e3);
383
438
  var WALL_TIMEOUT_MS = Math.max(1, Number(process.env.UC_WALL_TIMEOUT_MIN ?? 30) || 30) * 60 * 1e3;
384
439
  var DEFAULT_MAX_QUESTIONS = Math.max(1, Math.round(Number(process.env.UC_MAX_QUESTIONS ?? 7)) || 7);
385
- var PIPE_LOG = join2(homedir(), ".cache", "opencode-usage-coach", "pipeline.log");
440
+ var PIPE_LOG = join3(homedir2(), ".cache", "opencode-usage-coach", "pipeline.log");
386
441
  function pipeLog(msg) {
387
442
  try {
388
- mkdirSync2(dirname(PIPE_LOG), { recursive: true });
389
- appendFileSync2(PIPE_LOG, `[SERVER] ${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
443
+ mkdirSync3(dirname2(PIPE_LOG), { recursive: true });
444
+ appendFileSync3(PIPE_LOG, `[SERVER] ${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
390
445
  `);
391
446
  } catch {
392
447
  }
393
448
  }
394
449
  pipeLog(`MODULE LOADED | node=${process.version} | pid=${process.pid}`);
395
- var STATE_DIR = join2(homedir(), ".cache", "opencode-usage-coach");
396
- var STATE_FILE = join2(STATE_DIR, "state.json");
397
- var LOG_FILE = join2(STATE_DIR, "coach.log");
450
+ var STATE_DIR = join3(homedir2(), ".cache", "opencode-usage-coach");
451
+ var STATE_FILE = join3(STATE_DIR, "state.json");
452
+ var LOG_FILE = join3(STATE_DIR, "coach.log");
398
453
  function projectStateDir(dir) {
399
454
  const abs = resolve(dir || ".");
400
455
  const h = createHash("sha1").update(abs).digest("hex").slice(0, 12);
401
- return join2(homedir(), ".cache", "opencode-usage-coach", "projects", h);
456
+ return join3(homedir2(), ".cache", "opencode-usage-coach", "projects", h);
402
457
  }
403
458
  function setStateDir(dir) {
404
459
  STATE_DIR = process.env.UC_STATE_DIR ?? projectStateDir(dir);
405
- STATE_FILE = join2(STATE_DIR, "state.json");
406
- LOG_FILE = join2(STATE_DIR, "coach.log");
460
+ STATE_FILE = join3(STATE_DIR, "state.json");
461
+ LOG_FILE = join3(STATE_DIR, "coach.log");
407
462
  }
408
463
  var NOOP_HOOKS = {};
409
464
  function log(msg) {
410
465
  try {
411
- appendFileSync2(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
466
+ appendFileSync3(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
412
467
  `);
413
468
  } catch {
414
469
  }
415
470
  }
416
471
  function writeState(c) {
417
472
  try {
418
- mkdirSync2(STATE_DIR, { recursive: true });
473
+ mkdirSync3(STATE_DIR, { recursive: true });
419
474
  writeFileSync2(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
420
475
  } catch {
421
476
  }
422
477
  }
423
478
  function rulesFile() {
424
- return join2(STATE_DIR, "rules.md");
479
+ return join3(STATE_DIR, "rules.md");
425
480
  }
426
481
  function failuresFile() {
427
- return join2(STATE_DIR, "failures.ndjson");
482
+ return join3(STATE_DIR, "failures.ndjson");
428
483
  }
429
484
  function readRules() {
430
485
  try {
@@ -436,7 +491,7 @@ function readRules() {
436
491
  }
437
492
  }
438
493
  function implNotesFile() {
439
- return join2(STATE_DIR, "impl-notes.md");
494
+ return join3(STATE_DIR, "impl-notes.md");
440
495
  }
441
496
  var IMPL_NOTE_INSTRUCTION = `
442
497
  ## Implementation Notes (important!)
@@ -483,8 +538,8 @@ ${notes}
483
538
  Source: generate task "${shortTask}"
484
539
 
485
540
  `;
486
- mkdirSync2(STATE_DIR, { recursive: true });
487
- appendFileSync2(implNotesFile(), entry);
541
+ mkdirSync3(STATE_DIR, { recursive: true });
542
+ appendFileSync3(implNotesFile(), entry);
488
543
  } catch (e) {
489
544
  log(`appendImplNotes err: ${String(e)}`);
490
545
  }
@@ -537,15 +592,137 @@ function readImplNotesByGraph(keywords, limit = 5) {
537
592
  }
538
593
  function extractKeywords(text) {
539
594
  try {
540
- const STOP = /* @__PURE__ */ new Set(["the", "and", "for", "with", "that", "this", "from", "into", "your", "you", "are", "was", "but", "not", "all", "any", "use", "task", "prompt"]);
595
+ const STOP = /* @__PURE__ */ new Set([
596
+ // articles / prepositions / conjunctions
597
+ "the",
598
+ "and",
599
+ "for",
600
+ "with",
601
+ "that",
602
+ "this",
603
+ "from",
604
+ "into",
605
+ "your",
606
+ "you",
607
+ "are",
608
+ "was",
609
+ "but",
610
+ "not",
611
+ "all",
612
+ "any",
613
+ "use",
614
+ "task",
615
+ "prompt",
616
+ // generic verbs
617
+ "apply",
618
+ "add",
619
+ "make",
620
+ "set",
621
+ "get",
622
+ "run",
623
+ "try",
624
+ "put",
625
+ "let",
626
+ "new",
627
+ "will",
628
+ "can",
629
+ "has",
630
+ "had",
631
+ "have",
632
+ "been",
633
+ "were",
634
+ "they",
635
+ "them",
636
+ "when",
637
+ "then",
638
+ "than",
639
+ "also",
640
+ "just",
641
+ "like",
642
+ "what",
643
+ "which",
644
+ "how",
645
+ "should",
646
+ "would",
647
+ "could",
648
+ "must",
649
+ "does",
650
+ "doing",
651
+ "done",
652
+ "via",
653
+ // generic nouns / adjectives
654
+ "some",
655
+ "more",
656
+ "most",
657
+ "such",
658
+ "each",
659
+ "other",
660
+ "very",
661
+ "much",
662
+ "here",
663
+ "there",
664
+ "where",
665
+ "while",
666
+ "about",
667
+ "after",
668
+ "before",
669
+ "main",
670
+ "call",
671
+ "file",
672
+ "code",
673
+ "data",
674
+ "line",
675
+ "name",
676
+ "type",
677
+ "true",
678
+ "false",
679
+ "null",
680
+ "void",
681
+ "return",
682
+ "function",
683
+ "const",
684
+ "note",
685
+ "notes",
686
+ "result",
687
+ "output",
688
+ "input",
689
+ "detail",
690
+ "reason",
691
+ "reasons",
692
+ "improve",
693
+ "fix",
694
+ "fixed",
695
+ "error",
696
+ "issue",
697
+ "thing",
698
+ "things",
699
+ "stuff",
700
+ "case",
701
+ "cases",
702
+ "way",
703
+ "ways",
704
+ "first",
705
+ "second",
706
+ "third",
707
+ "last",
708
+ "next",
709
+ "prev",
710
+ "previous",
711
+ "following",
712
+ "above",
713
+ "below",
714
+ "since",
715
+ "until",
716
+ "without"
717
+ ]);
541
718
  const seen = /* @__PURE__ */ new Set();
542
719
  const out = [];
543
- for (const raw of (text ?? "").toLowerCase().split(/[^a-z0-9_]+/)) {
720
+ for (const raw of (text ?? "").toLowerCase().split(/[^a-z0-9_-]+/)) {
544
721
  const t = raw.trim();
545
722
  if (t.length < 3 || STOP.has(t) || seen.has(t)) continue;
546
723
  seen.add(t);
547
724
  out.push(t);
548
- if (out.length >= 16) break;
725
+ if (out.length >= 8) break;
549
726
  }
550
727
  return out;
551
728
  } catch {
@@ -553,7 +730,7 @@ function extractKeywords(text) {
553
730
  }
554
731
  }
555
732
  function harnessFile(sessionID) {
556
- return join2(STATE_DIR, sessionID || "_default", "harness.json");
733
+ return join3(STATE_DIR, sessionID || "_default", "harness.json");
557
734
  }
558
735
  function readHarness(sessionID) {
559
736
  try {
@@ -567,7 +744,7 @@ function readHarness(sessionID) {
567
744
  function writeHarness(sessionID, h) {
568
745
  try {
569
746
  const f = harnessFile(sessionID);
570
- mkdirSync2(dirname(f), { recursive: true });
747
+ mkdirSync3(dirname2(f), { recursive: true });
571
748
  h.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
572
749
  writeFileSync2(f, JSON.stringify(h, null, 2));
573
750
  } catch {
@@ -590,7 +767,7 @@ function mutateHarness(sessionID, fn) {
590
767
  return next;
591
768
  }
592
769
  function interviewFile(sessionID) {
593
- return join2(STATE_DIR, sessionID || "_default", "interview.json");
770
+ return join3(STATE_DIR, sessionID || "_default", "interview.json");
594
771
  }
595
772
  function readInterview(sessionID) {
596
773
  try {
@@ -604,7 +781,7 @@ function readInterview(sessionID) {
604
781
  function writeInterview(sessionID, s) {
605
782
  try {
606
783
  const f = interviewFile(sessionID);
607
- mkdirSync2(dirname(f), { recursive: true });
784
+ mkdirSync3(dirname2(f), { recursive: true });
608
785
  s.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
609
786
  writeFileSync2(f, JSON.stringify(s, null, 2));
610
787
  } catch {
@@ -802,7 +979,7 @@ function parseFileList(rawList, baseDir) {
802
979
  let testPattern;
803
980
  let testFramework;
804
981
  for (const mf of manifestFiles) {
805
- const full = join2(baseDir, mf);
982
+ const full = join3(baseDir, mf);
806
983
  if (existsSync2(full)) {
807
984
  try {
808
985
  const content = JSON.parse(readFileSync2(full, "utf8"));
@@ -1132,15 +1309,15 @@ function readHarnessCfg(dir) {
1132
1309
  return {};
1133
1310
  };
1134
1311
  return {
1135
- ...tryRead(join2(homedir(), ".config", "opencode-usage-coach", "harness.config.json")),
1136
- ...tryRead(join2(dir, "harness.config.json"))
1312
+ ...tryRead(join3(homedir2(), ".config", "opencode-usage-coach", "harness.config.json")),
1313
+ ...tryRead(join3(dir, "harness.config.json"))
1137
1314
  };
1138
1315
  }
1139
1316
  function writeHarnessCfg(updates) {
1140
- const configDir = join2(homedir(), ".config", "opencode-usage-coach");
1141
- const configPath = join2(configDir, "harness.config.json");
1317
+ const configDir = join3(homedir2(), ".config", "opencode-usage-coach");
1318
+ const configPath = join3(configDir, "harness.config.json");
1142
1319
  try {
1143
- mkdirSync2(configDir, { recursive: true });
1320
+ mkdirSync3(configDir, { recursive: true });
1144
1321
  const existing = (() => {
1145
1322
  try {
1146
1323
  return JSON.parse(readFileSync2(configPath, "utf8"));
@@ -1868,8 +2045,8 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
1868
2045
  async execute(args, _ctx) {
1869
2046
  const rec = { ts: (/* @__PURE__ */ new Date()).toISOString(), task: args.task, prompt: args.prompt, gradeResult: args.gradeResult, model: args.model, revisions: args.revisions };
1870
2047
  try {
1871
- mkdirSync2(STATE_DIR, { recursive: true });
1872
- appendFileSync2(failuresFile(), JSON.stringify(rec) + "\n");
2048
+ mkdirSync3(STATE_DIR, { recursive: true });
2049
+ appendFileSync3(failuresFile(), JSON.stringify(rec) + "\n");
1873
2050
  } catch (e) {
1874
2051
  log(`record_failure err: ${String(e)}`);
1875
2052
  }
@@ -1994,8 +2171,8 @@ Keep it concrete and actionable.`;
1994
2171
  const rule = out;
1995
2172
  try {
1996
2173
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1997
- mkdirSync2(STATE_DIR, { recursive: true });
1998
- appendFileSync2(rulesFile(), `## Rule (${date})
2174
+ mkdirSync3(STATE_DIR, { recursive: true });
2175
+ appendFileSync3(rulesFile(), `## Rule (${date})
1999
2176
  ${rule}
2000
2177
  Origin: ${args.task}
2001
2178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.11.4",
3
+ "version": "0.12.1",
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",