opencode-usage-coach 0.11.3 → 0.12.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 (2) hide show
  1. package/dist/index.js +212 -27
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3,18 +3,26 @@ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, appendFileSyn
3
3
  import { spawn, spawnSync } from "child_process";
4
4
  import { createHash } from "crypto";
5
5
  import { homedir } from "os";
6
- import { join as join2, resolve, dirname } from "path";
6
+ import { join as join2, 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,17 +169,56 @@ 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
183
224
  var FRAMEWORK_DOCS = {
@@ -231,8 +272,30 @@ function effectiveFrameworks(frameworks, keyDeps) {
231
272
  async function ghFetch(url, signal) {
232
273
  const res = await fetch(url, { headers: ghHeaders(), signal });
233
274
  if (!res.ok) {
234
- const tag = res.status === 403 || res.status === 429 ? " (rate limit)" : "";
235
- throw new Error(`HTTP ${res.status}${tag}`);
275
+ let ghError = null;
276
+ let ghErrorText = "";
277
+ try {
278
+ ghErrorText = await res.text();
279
+ ghError = JSON.parse(ghErrorText);
280
+ } catch {
281
+ }
282
+ const tag = res.status === 403 || res.status === 429 ? " (rate limit)" : res.status === 422 ? " (validation failed)" : "";
283
+ const errs = ghError?.errors;
284
+ const errMsg = ghError?.message ?? ghErrorText.slice(0, 300);
285
+ const detail = errs ? errs.map((e) => typeof e === "string" ? e : `${e?.field ?? "?"}: ${e?.message ?? e?.code ?? JSON.stringify(e)}`).join("; ") : "";
286
+ console.error(JSON.stringify({
287
+ level: "error",
288
+ module: "web-search",
289
+ event: "gh-fetch-error",
290
+ status: res.status,
291
+ tag,
292
+ url,
293
+ ghMessage: errMsg,
294
+ ghErrors: detail,
295
+ rateLimitRemaining: res.headers.get("x-ratelimit-remaining"),
296
+ rateLimitReset: res.headers.get("x-ratelimit-reset")
297
+ }));
298
+ throw new Error(`HTTP ${res.status}${tag}: ${errMsg}${detail ? ` | ${detail}` : ""}`);
236
299
  }
237
300
  return await res.json();
238
301
  }
@@ -363,7 +426,7 @@ var DEFAULT_MAX_QUESTIONS = Math.max(1, Math.round(Number(process.env.UC_MAX_QUE
363
426
  var PIPE_LOG = join2(homedir(), ".cache", "opencode-usage-coach", "pipeline.log");
364
427
  function pipeLog(msg) {
365
428
  try {
366
- mkdirSync2(dirname(PIPE_LOG), { recursive: true });
429
+ mkdirSync2(dirname2(PIPE_LOG), { recursive: true });
367
430
  appendFileSync2(PIPE_LOG, `[SERVER] ${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
368
431
  `);
369
432
  } catch {
@@ -515,15 +578,137 @@ function readImplNotesByGraph(keywords, limit = 5) {
515
578
  }
516
579
  function extractKeywords(text) {
517
580
  try {
518
- const STOP = /* @__PURE__ */ new Set(["the", "and", "for", "with", "that", "this", "from", "into", "your", "you", "are", "was", "but", "not", "all", "any", "use", "task", "prompt"]);
581
+ const STOP = /* @__PURE__ */ new Set([
582
+ // articles / prepositions / conjunctions
583
+ "the",
584
+ "and",
585
+ "for",
586
+ "with",
587
+ "that",
588
+ "this",
589
+ "from",
590
+ "into",
591
+ "your",
592
+ "you",
593
+ "are",
594
+ "was",
595
+ "but",
596
+ "not",
597
+ "all",
598
+ "any",
599
+ "use",
600
+ "task",
601
+ "prompt",
602
+ // generic verbs
603
+ "apply",
604
+ "add",
605
+ "make",
606
+ "set",
607
+ "get",
608
+ "run",
609
+ "try",
610
+ "put",
611
+ "let",
612
+ "new",
613
+ "will",
614
+ "can",
615
+ "has",
616
+ "had",
617
+ "have",
618
+ "been",
619
+ "were",
620
+ "they",
621
+ "them",
622
+ "when",
623
+ "then",
624
+ "than",
625
+ "also",
626
+ "just",
627
+ "like",
628
+ "what",
629
+ "which",
630
+ "how",
631
+ "should",
632
+ "would",
633
+ "could",
634
+ "must",
635
+ "does",
636
+ "doing",
637
+ "done",
638
+ "via",
639
+ // generic nouns / adjectives
640
+ "some",
641
+ "more",
642
+ "most",
643
+ "such",
644
+ "each",
645
+ "other",
646
+ "very",
647
+ "much",
648
+ "here",
649
+ "there",
650
+ "where",
651
+ "while",
652
+ "about",
653
+ "after",
654
+ "before",
655
+ "main",
656
+ "call",
657
+ "file",
658
+ "code",
659
+ "data",
660
+ "line",
661
+ "name",
662
+ "type",
663
+ "true",
664
+ "false",
665
+ "null",
666
+ "void",
667
+ "return",
668
+ "function",
669
+ "const",
670
+ "note",
671
+ "notes",
672
+ "result",
673
+ "output",
674
+ "input",
675
+ "detail",
676
+ "reason",
677
+ "reasons",
678
+ "improve",
679
+ "fix",
680
+ "fixed",
681
+ "error",
682
+ "issue",
683
+ "thing",
684
+ "things",
685
+ "stuff",
686
+ "case",
687
+ "cases",
688
+ "way",
689
+ "ways",
690
+ "first",
691
+ "second",
692
+ "third",
693
+ "last",
694
+ "next",
695
+ "prev",
696
+ "previous",
697
+ "following",
698
+ "above",
699
+ "below",
700
+ "since",
701
+ "until",
702
+ "without"
703
+ ]);
519
704
  const seen = /* @__PURE__ */ new Set();
520
705
  const out = [];
521
- for (const raw of (text ?? "").toLowerCase().split(/[^a-z0-9_]+/)) {
706
+ for (const raw of (text ?? "").toLowerCase().split(/[^a-z0-9_-]+/)) {
522
707
  const t = raw.trim();
523
708
  if (t.length < 3 || STOP.has(t) || seen.has(t)) continue;
524
709
  seen.add(t);
525
710
  out.push(t);
526
- if (out.length >= 16) break;
711
+ if (out.length >= 8) break;
527
712
  }
528
713
  return out;
529
714
  } catch {
@@ -545,7 +730,7 @@ function readHarness(sessionID) {
545
730
  function writeHarness(sessionID, h) {
546
731
  try {
547
732
  const f = harnessFile(sessionID);
548
- mkdirSync2(dirname(f), { recursive: true });
733
+ mkdirSync2(dirname2(f), { recursive: true });
549
734
  h.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
550
735
  writeFileSync2(f, JSON.stringify(h, null, 2));
551
736
  } catch {
@@ -582,7 +767,7 @@ function readInterview(sessionID) {
582
767
  function writeInterview(sessionID, s) {
583
768
  try {
584
769
  const f = interviewFile(sessionID);
585
- mkdirSync2(dirname(f), { recursive: true });
770
+ mkdirSync2(dirname2(f), { recursive: true });
586
771
  s.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
587
772
  writeFileSync2(f, JSON.stringify(s, null, 2));
588
773
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.11.3",
3
+ "version": "0.12.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",