opencode-usage-coach 0.10.1 → 0.11.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 +225 -16
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -179,6 +179,170 @@ function saveInvestigationResult(keywords, result, source, confidence = 0.7) {
179
179
  }
180
180
  }
181
181
 
182
+ // src/web-search.ts
183
+ var FRAMEWORK_DOCS = {
184
+ "react": { name: "React", docs: "https://react.dev", githubOrg: "facebook" },
185
+ "solid-js": { name: "Solid.js", docs: "https://solidjs.com", githubOrg: "solidjs" },
186
+ "vue": { name: "Vue", docs: "https://vuejs.org", githubOrg: "vuejs" },
187
+ "svelte": { name: "Svelte", docs: "https://svelte.dev", githubOrg: "sveltejs" },
188
+ "express": { name: "Express", docs: "https://expressjs.com", githubOrg: "expressjs" },
189
+ "fastify": { name: "Fastify", docs: "https://fastify.dev", githubOrg: "fastify" },
190
+ "hono": { name: "Hono", docs: "https://hono.dev", githubOrg: "honojs" },
191
+ "vitest": { name: "Vitest", docs: "https://vitest.dev", githubOrg: "vitest-dev" },
192
+ "jest": { name: "Jest", docs: "https://jestjs.io", githubOrg: "jestjs" },
193
+ "tsup": { name: "tsup", docs: "https://tsup.egoist.dev", githubOrg: "egoist" },
194
+ "eslint": { name: "ESLint", docs: "https://eslint.org", githubOrg: "eslint" },
195
+ "cloudflare": { name: "Cloudflare", docs: "https://developers.cloudflare.com", githubOrg: "cloudflare" }
196
+ };
197
+ var DEFAULT_TIMEOUT_MS = 8e3;
198
+ var TARGET_RESULT_COUNT = 5;
199
+ function ghToken() {
200
+ return process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
201
+ }
202
+ function ghHeaders() {
203
+ const headers = {
204
+ "Accept": "application/vnd.github.v3+json",
205
+ "User-Agent": "opencode-usage-coach"
206
+ };
207
+ const token = ghToken();
208
+ if (token) headers["Authorization"] = `Bearer ${token}`;
209
+ return headers;
210
+ }
211
+ function errMessage(e) {
212
+ return e instanceof Error ? e.message : String(e);
213
+ }
214
+ function truncate(input, max) {
215
+ const text = (input ?? "").replace(/[\r\n]+/g, " ").trim();
216
+ return text.length > max ? `${text.slice(0, max)}...` : text;
217
+ }
218
+ function effectiveFrameworks(frameworks, keyDeps) {
219
+ const set = new Set((frameworks || []).filter(Boolean));
220
+ if (keyDeps) {
221
+ for (const dep of keyDeps) {
222
+ if (dep === "wrangler" || dep.startsWith("@cloudflare/")) set.add("cloudflare");
223
+ }
224
+ }
225
+ return [...set];
226
+ }
227
+ async function ghFetch(url, signal) {
228
+ const res = await fetch(url, { headers: ghHeaders(), signal });
229
+ if (!res.ok) {
230
+ const tag = res.status === 403 || res.status === 429 ? " (rate limit)" : "";
231
+ throw new Error(`HTTP ${res.status}${tag}`);
232
+ }
233
+ return await res.json();
234
+ }
235
+ function pushResult(results, seen, r) {
236
+ if (r.url && !seen.has(r.url)) {
237
+ seen.add(r.url);
238
+ results.push(r);
239
+ }
240
+ }
241
+ async function tier1OfficialDocs(query, fws, results, docRefs, seen, signal) {
242
+ const errors = [];
243
+ for (const fw of fws) {
244
+ const entry = FRAMEWORK_DOCS[fw];
245
+ if (entry) docRefs.push({ name: entry.name, url: entry.docs });
246
+ }
247
+ if (!query) return errors;
248
+ for (const fw of fws) {
249
+ if (signal.aborted || results.length >= TARGET_RESULT_COUNT) break;
250
+ const entry = FRAMEWORK_DOCS[fw];
251
+ if (!entry?.githubOrg) continue;
252
+ try {
253
+ const q = `${query} org:${entry.githubOrg}`;
254
+ const url = `https://api.github.com/search/issues?q=${encodeURIComponent(q)}&per_page=3`;
255
+ const data = await ghFetch(url, signal);
256
+ for (const item of data.items ?? []) {
257
+ pushResult(results, seen, {
258
+ tier: "official-docs",
259
+ title: item.title,
260
+ url: item.html_url,
261
+ snippet: truncate(item.body, 200)
262
+ });
263
+ }
264
+ } catch (e) {
265
+ const m = errMessage(e);
266
+ console.error(`[web-search] tier1 ${fw}: ${m}`);
267
+ errors.push(`tier1:${fw}:${m.slice(0, 80)}`);
268
+ }
269
+ }
270
+ return errors;
271
+ }
272
+ async function tier2GitHubIssues(query, results, seen, signal) {
273
+ const errors = [];
274
+ try {
275
+ const url = `https://api.github.com/search/issues?q=${encodeURIComponent(query)}&sort=relevance&per_page=5`;
276
+ const data = await ghFetch(url, signal);
277
+ for (const item of data.items ?? []) {
278
+ pushResult(results, seen, {
279
+ tier: "github-issues",
280
+ title: item.title,
281
+ url: item.html_url,
282
+ snippet: truncate(item.body, 200)
283
+ });
284
+ }
285
+ } catch (e) {
286
+ const m = errMessage(e);
287
+ console.error(`[web-search] tier2: ${m}`);
288
+ errors.push(`tier2:${m.slice(0, 80)}`);
289
+ }
290
+ return errors;
291
+ }
292
+ async function tier3GitHubCode(query, results, seen, signal) {
293
+ const errors = [];
294
+ if (!ghToken()) return errors;
295
+ try {
296
+ const url = `https://api.github.com/search/code?q=${encodeURIComponent(query)}&per_page=3`;
297
+ const data = await ghFetch(url, signal);
298
+ for (const item of data.items ?? []) {
299
+ const repo = item.repository?.full_name;
300
+ pushResult(results, seen, {
301
+ tier: "github-code",
302
+ title: item.path || item.name,
303
+ url: item.html_url,
304
+ snippet: repo ? `${repo} \u2014 ${item.path}` : item.path
305
+ });
306
+ }
307
+ } catch (e) {
308
+ const m = errMessage(e);
309
+ console.error(`[web-search] tier3: ${m}`);
310
+ errors.push(`tier3:${m.slice(0, 80)}`);
311
+ }
312
+ return errors;
313
+ }
314
+ async function searchContext(query, frameworks, keyDeps, timeoutMs) {
315
+ const results = [];
316
+ const docRefs = [];
317
+ const errors = [];
318
+ const seen = /* @__PURE__ */ new Set();
319
+ const timeout = Math.max(100, Number(timeoutMs ?? DEFAULT_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS);
320
+ const controller = new AbortController();
321
+ const timer = setTimeout(() => controller.abort(), timeout);
322
+ const signal = controller.signal;
323
+ try {
324
+ const q = (query || "").trim();
325
+ const fws = effectiveFrameworks(frameworks, keyDeps);
326
+ errors.push(...await tier1OfficialDocs(q, fws, results, docRefs, seen, signal));
327
+ if (q && results.length < TARGET_RESULT_COUNT && !signal.aborted) {
328
+ errors.push(...await tier2GitHubIssues(q, results, seen, signal));
329
+ }
330
+ if (q && results.length < TARGET_RESULT_COUNT && !signal.aborted) {
331
+ errors.push(...await tier3GitHubCode(q, results, seen, signal));
332
+ }
333
+ } catch (e) {
334
+ const m = errMessage(e);
335
+ console.error(`[web-search] unexpected error: ${m}`);
336
+ errors.push(`unexpected:${m.slice(0, 80)}`);
337
+ } finally {
338
+ clearTimeout(timer);
339
+ }
340
+ const response = { results, docRefs };
341
+ const joined = errors.filter(Boolean).join("; ");
342
+ if (joined) response.error = joined;
343
+ return response;
344
+ }
345
+
182
346
  // src/index.ts
183
347
  var PLUGIN_NAME = "opencode-usage-coach";
184
348
  var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
@@ -377,6 +541,22 @@ function writeHarness(sessionID, h) {
377
541
  } catch {
378
542
  }
379
543
  }
544
+ var harnessQueue = /* @__PURE__ */ new Map();
545
+ function mutateHarness(sessionID, fn) {
546
+ const prev = harnessQueue.get(sessionID) ?? Promise.resolve();
547
+ const next = prev.then(() => {
548
+ const h = readHarness(sessionID);
549
+ if (!h) return;
550
+ const result = fn(h);
551
+ writeHarness(sessionID, result ?? h);
552
+ }).catch(() => {
553
+ });
554
+ harnessQueue.set(sessionID, next);
555
+ next.finally(() => {
556
+ if (harnessQueue.get(sessionID) === next) harnessQueue.delete(sessionID);
557
+ });
558
+ return next;
559
+ }
380
560
  function interviewFile(sessionID) {
381
561
  return join2(STATE_DIR, sessionID || "_default", "interview.json");
382
562
  }
@@ -508,29 +688,20 @@ Output JSON ONLY (no markdown fences, no prose):
508
688
  return formatCompleteOutput(state);
509
689
  }
510
690
  function updateSubSession(sessionID, taskId, fields) {
511
- try {
512
- const h = readHarness(sessionID);
513
- if (!h) return;
691
+ return mutateHarness(sessionID, (h) => {
514
692
  const t = h.tasks.find((x) => x.id === taskId);
515
- if (!t) return;
516
- Object.assign(t, fields);
517
- writeHarness(sessionID, h);
518
- } catch {
519
- }
693
+ if (t) Object.assign(t, fields);
694
+ });
520
695
  }
521
696
  function clearSubSession(sessionID, taskId) {
522
- try {
523
- const h = readHarness(sessionID);
524
- if (!h) return;
697
+ return mutateHarness(sessionID, (h) => {
525
698
  const t = h.tasks.find((x) => x.id === taskId);
526
699
  if (!t) return;
527
700
  t.subSessionId = void 0;
528
701
  t.subStep = void 0;
529
702
  t.lastActivity = void 0;
530
703
  t.subElapsed = void 0;
531
- writeHarness(sessionID, h);
532
- } catch {
533
- }
704
+ });
534
705
  }
535
706
  function findActiveTaskId(sessionID, status) {
536
707
  try {
@@ -660,7 +831,7 @@ function detectLanguage(extCounts) {
660
831
  if ((extCounts["java"] || 0) > 0) return "Java";
661
832
  return "unknown";
662
833
  }
663
- function buildGapPrompt(userRequest, tasks, profile, domainNodes) {
834
+ function buildGapPrompt(userRequest, tasks, profile, domainNodes, webResults, docRefs) {
664
835
  const taskList = tasks.map((t) => `${t.id}: ${t.title}`).join("\n");
665
836
  const profileStr = profile.skipped ? `(skipped \u2014 ${profile.reason || "unknown reason"})` : [
666
837
  `- Language: ${profile.language}`,
@@ -673,6 +844,10 @@ function buildGapPrompt(userRequest, tasks, profile, domainNodes) {
673
844
  `- Total files: ${profile.totalFiles}`
674
845
  ].join("\n");
675
846
  const domainStr = domainNodes.length ? domainNodes.slice(0, 15).map((n) => `- ${n.name}: ${JSON.stringify(n.props).slice(0, 200)}`).join("\n") : "(empty \u2014 no prior knowledge stored)";
847
+ const webResultsStr = webResults && webResults.length ? webResults.slice(0, 8).map((r, i) => `${i + 1}. [${r.tier}] ${r.title}
848
+ ${r.url}
849
+ ${r.snippet}`).join("\n") : "(no web results \u2014 either network issue or no relevant findings)";
850
+ const docRefsStr = docRefs && docRefs.length ? docRefs.map((d) => `- ${d.name}: ${d.url}`).join("\n") : "(none)";
676
851
  return `You are a pre-flight gap analyst. Compare the user's request (the MAP) with the actual codebase (the TERRITORY) and classify every gap.
677
852
 
678
853
  USER REQUEST:
@@ -687,6 +862,12 @@ ${profileStr}
687
862
  EXISTING DOMAIN KNOWLEDGE (from local DB):
688
863
  ${domainStr}
689
864
 
865
+ WEB SEARCH RESULTS (from official docs references, GitHub issues, and code search):
866
+ ${webResultsStr}
867
+
868
+ OFFICIAL DOCUMENTATION REFERENCES:
869
+ ${docRefsStr}
870
+
690
871
  Classify into EXACTLY these 4 categories:
691
872
 
692
873
  1. KNOWN KNOWNS \u2014 requirements explicitly stated in the user request.
@@ -825,6 +1006,20 @@ Questions for the user (${r.questions.length}):`);
825
1006
  L.push("\nTask Refinement Suggestions:");
826
1007
  for (const t of r.taskRefinements) L.push(` -> task ${t.taskId}: ${t.action} - ${t.detail}`);
827
1008
  }
1009
+ if (r.webResults && r.webResults.length) {
1010
+ L.push(`
1011
+ WEB SEARCH (${r.webResults.length} results):`);
1012
+ if (r.docRefs && r.docRefs.length) {
1013
+ L.push(` Official docs: ${r.docRefs.map((d) => d.name).join(", ")}`);
1014
+ }
1015
+ L.push(" Top results:");
1016
+ for (let i = 0; i < Math.min(r.webResults.length, 5); i++) {
1017
+ const w = r.webResults[i];
1018
+ L.push(` ${i + 1}. [${w.tier}] ${w.title}`);
1019
+ L.push(` ${w.url}`);
1020
+ L.push(` ${w.snippet.slice(0, 100)}`);
1021
+ }
1022
+ }
828
1023
  if (r.rawAnalysis) L.push(`
829
1024
  Raw analysis: ${r.rawAnalysis.slice(0, 200)}`);
830
1025
  L.push("\n[usage-coach NEXT] unknowns reviewed:");
@@ -1480,6 +1675,18 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
1480
1675
  } catch (e) {
1481
1676
  log(`unknown_scan domain query err: ${String(e)}`);
1482
1677
  }
1678
+ let webResults = [];
1679
+ let docRefs = [];
1680
+ try {
1681
+ const searchQuery = `${args.prompt} ${tasks.map((t) => t.title).join(" ")}`.slice(0, 500);
1682
+ const webResp = await searchContext(searchQuery, profile.frameworks, profile.keyDeps);
1683
+ webResults = webResp.results;
1684
+ docRefs = webResp.docRefs;
1685
+ if (webResp.error) log(`unknown_scan web search: ${webResp.error}`);
1686
+ log(`unknown_scan web search: ${webResults.length} results, ${docRefs.length} doc refs`);
1687
+ } catch (e) {
1688
+ log(`unknown_scan web search err: ${String(e).slice(0, 200)}`);
1689
+ }
1483
1690
  const cfg = readHarnessCfg(ctx.directory);
1484
1691
  if (!cfg.generator) {
1485
1692
  const result2 = {
@@ -1522,9 +1729,11 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
1522
1729
  }
1523
1730
  const throttle = decision === "THROTTLE" && cfg.lighterModel;
1524
1731
  const model = throttle ? cfg.lighterModel : cfg.generator;
1525
- const gapPrompt = buildGapPrompt(args.prompt, tasks, profile, domainNodes);
1732
+ const gapPrompt = buildGapPrompt(args.prompt, tasks, profile, domainNodes, webResults, docRefs);
1526
1733
  const raw = await runModel(input.client, model, gapPrompt, ctx.directory, void 0, 15);
1527
1734
  const result = parseGapAnalysis(raw, profile, domainHits);
1735
+ result.webResults = webResults;
1736
+ result.docRefs = docRefs;
1528
1737
  try {
1529
1738
  for (const uk of result.unknownKnowns.slice(0, 5)) {
1530
1739
  const kw = extractKeywords(uk.finding);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.10.1",
3
+ "version": "0.11.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",