opencode-usage-coach 0.10.2 → 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 +204 -2
  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);
@@ -667,7 +831,7 @@ function detectLanguage(extCounts) {
667
831
  if ((extCounts["java"] || 0) > 0) return "Java";
668
832
  return "unknown";
669
833
  }
670
- function buildGapPrompt(userRequest, tasks, profile, domainNodes) {
834
+ function buildGapPrompt(userRequest, tasks, profile, domainNodes, webResults, docRefs) {
671
835
  const taskList = tasks.map((t) => `${t.id}: ${t.title}`).join("\n");
672
836
  const profileStr = profile.skipped ? `(skipped \u2014 ${profile.reason || "unknown reason"})` : [
673
837
  `- Language: ${profile.language}`,
@@ -680,6 +844,10 @@ function buildGapPrompt(userRequest, tasks, profile, domainNodes) {
680
844
  `- Total files: ${profile.totalFiles}`
681
845
  ].join("\n");
682
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)";
683
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.
684
852
 
685
853
  USER REQUEST:
@@ -694,6 +862,12 @@ ${profileStr}
694
862
  EXISTING DOMAIN KNOWLEDGE (from local DB):
695
863
  ${domainStr}
696
864
 
865
+ WEB SEARCH RESULTS (from official docs references, GitHub issues, and code search):
866
+ ${webResultsStr}
867
+
868
+ OFFICIAL DOCUMENTATION REFERENCES:
869
+ ${docRefsStr}
870
+
697
871
  Classify into EXACTLY these 4 categories:
698
872
 
699
873
  1. KNOWN KNOWNS \u2014 requirements explicitly stated in the user request.
@@ -832,6 +1006,20 @@ Questions for the user (${r.questions.length}):`);
832
1006
  L.push("\nTask Refinement Suggestions:");
833
1007
  for (const t of r.taskRefinements) L.push(` -> task ${t.taskId}: ${t.action} - ${t.detail}`);
834
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
+ }
835
1023
  if (r.rawAnalysis) L.push(`
836
1024
  Raw analysis: ${r.rawAnalysis.slice(0, 200)}`);
837
1025
  L.push("\n[usage-coach NEXT] unknowns reviewed:");
@@ -1487,6 +1675,18 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
1487
1675
  } catch (e) {
1488
1676
  log(`unknown_scan domain query err: ${String(e)}`);
1489
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
+ }
1490
1690
  const cfg = readHarnessCfg(ctx.directory);
1491
1691
  if (!cfg.generator) {
1492
1692
  const result2 = {
@@ -1529,9 +1729,11 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
1529
1729
  }
1530
1730
  const throttle = decision === "THROTTLE" && cfg.lighterModel;
1531
1731
  const model = throttle ? cfg.lighterModel : cfg.generator;
1532
- const gapPrompt = buildGapPrompt(args.prompt, tasks, profile, domainNodes);
1732
+ const gapPrompt = buildGapPrompt(args.prompt, tasks, profile, domainNodes, webResults, docRefs);
1533
1733
  const raw = await runModel(input.client, model, gapPrompt, ctx.directory, void 0, 15);
1534
1734
  const result = parseGapAnalysis(raw, profile, domainHits);
1735
+ result.webResults = webResults;
1736
+ result.docRefs = docRefs;
1535
1737
  try {
1536
1738
  for (const uk of result.unknownKnowns.slice(0, 5)) {
1537
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.2",
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",