opencode-usage-coach 0.10.2 → 0.11.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 +208 -2
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -179,6 +179,174 @@ 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
+ var GH_QUERY_MAX = 256;
200
+ function ghToken() {
201
+ return process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
202
+ }
203
+ function ghHeaders() {
204
+ const headers = {
205
+ "Accept": "application/vnd.github.v3+json",
206
+ "User-Agent": "opencode-usage-coach"
207
+ };
208
+ const token = ghToken();
209
+ if (token) headers["Authorization"] = `Bearer ${token}`;
210
+ return headers;
211
+ }
212
+ function errMessage(e) {
213
+ return e instanceof Error ? e.message : String(e);
214
+ }
215
+ function truncate(input, max) {
216
+ const text = (input ?? "").replace(/[\r\n]+/g, " ").trim();
217
+ return text.length > max ? `${text.slice(0, max)}...` : text;
218
+ }
219
+ function sanitizeQuery(raw) {
220
+ return raw.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ").replace(/[#*_[\]()>|]/g, " ").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, GH_QUERY_MAX);
221
+ }
222
+ function effectiveFrameworks(frameworks, keyDeps) {
223
+ const set = new Set((frameworks || []).filter(Boolean));
224
+ if (keyDeps) {
225
+ for (const dep of keyDeps) {
226
+ if (dep === "wrangler" || dep.startsWith("@cloudflare/")) set.add("cloudflare");
227
+ }
228
+ }
229
+ return [...set];
230
+ }
231
+ async function ghFetch(url, signal) {
232
+ const res = await fetch(url, { headers: ghHeaders(), signal });
233
+ if (!res.ok) {
234
+ const tag = res.status === 403 || res.status === 429 ? " (rate limit)" : "";
235
+ throw new Error(`HTTP ${res.status}${tag}`);
236
+ }
237
+ return await res.json();
238
+ }
239
+ function pushResult(results, seen, r) {
240
+ if (r.url && !seen.has(r.url)) {
241
+ seen.add(r.url);
242
+ results.push(r);
243
+ }
244
+ }
245
+ async function tier1OfficialDocs(query, fws, results, docRefs, seen, signal) {
246
+ const errors = [];
247
+ for (const fw of fws) {
248
+ const entry = FRAMEWORK_DOCS[fw];
249
+ if (entry) docRefs.push({ name: entry.name, url: entry.docs });
250
+ }
251
+ if (!query) return errors;
252
+ for (const fw of fws) {
253
+ if (signal.aborted || results.length >= TARGET_RESULT_COUNT) break;
254
+ const entry = FRAMEWORK_DOCS[fw];
255
+ if (!entry?.githubOrg) continue;
256
+ try {
257
+ const q = `${query} org:${entry.githubOrg}`;
258
+ const url = `https://api.github.com/search/issues?q=${encodeURIComponent(sanitizeQuery(q))}&per_page=3`;
259
+ const data = await ghFetch(url, signal);
260
+ for (const item of data.items ?? []) {
261
+ pushResult(results, seen, {
262
+ tier: "official-docs",
263
+ title: item.title,
264
+ url: item.html_url,
265
+ snippet: truncate(item.body, 200)
266
+ });
267
+ }
268
+ } catch (e) {
269
+ const m = errMessage(e);
270
+ console.error(`[web-search] tier1 ${fw}: ${m}`);
271
+ errors.push(`tier1:${fw}:${m.slice(0, 80)}`);
272
+ }
273
+ }
274
+ return errors;
275
+ }
276
+ async function tier2GitHubIssues(query, results, seen, signal) {
277
+ const errors = [];
278
+ try {
279
+ const url = `https://api.github.com/search/issues?q=${encodeURIComponent(sanitizeQuery(query))}&per_page=5`;
280
+ const data = await ghFetch(url, signal);
281
+ for (const item of data.items ?? []) {
282
+ pushResult(results, seen, {
283
+ tier: "github-issues",
284
+ title: item.title,
285
+ url: item.html_url,
286
+ snippet: truncate(item.body, 200)
287
+ });
288
+ }
289
+ } catch (e) {
290
+ const m = errMessage(e);
291
+ console.error(`[web-search] tier2: ${m}`);
292
+ errors.push(`tier2:${m.slice(0, 80)}`);
293
+ }
294
+ return errors;
295
+ }
296
+ async function tier3GitHubCode(query, results, seen, signal) {
297
+ const errors = [];
298
+ if (!ghToken()) return errors;
299
+ try {
300
+ const url = `https://api.github.com/search/code?q=${encodeURIComponent(sanitizeQuery(query))}&per_page=3`;
301
+ const data = await ghFetch(url, signal);
302
+ for (const item of data.items ?? []) {
303
+ const repo = item.repository?.full_name;
304
+ pushResult(results, seen, {
305
+ tier: "github-code",
306
+ title: item.path || item.name,
307
+ url: item.html_url,
308
+ snippet: repo ? `${repo} \u2014 ${item.path}` : item.path
309
+ });
310
+ }
311
+ } catch (e) {
312
+ const m = errMessage(e);
313
+ console.error(`[web-search] tier3: ${m}`);
314
+ errors.push(`tier3:${m.slice(0, 80)}`);
315
+ }
316
+ return errors;
317
+ }
318
+ async function searchContext(query, frameworks, keyDeps, timeoutMs) {
319
+ const results = [];
320
+ const docRefs = [];
321
+ const errors = [];
322
+ const seen = /* @__PURE__ */ new Set();
323
+ const timeout = Math.max(100, Number(timeoutMs ?? DEFAULT_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS);
324
+ const controller = new AbortController();
325
+ const timer = setTimeout(() => controller.abort(), timeout);
326
+ const signal = controller.signal;
327
+ try {
328
+ const q = (query || "").trim();
329
+ const fws = effectiveFrameworks(frameworks, keyDeps);
330
+ errors.push(...await tier1OfficialDocs(q, fws, results, docRefs, seen, signal));
331
+ if (q && results.length < TARGET_RESULT_COUNT && !signal.aborted) {
332
+ errors.push(...await tier2GitHubIssues(q, results, seen, signal));
333
+ }
334
+ if (q && results.length < TARGET_RESULT_COUNT && !signal.aborted) {
335
+ errors.push(...await tier3GitHubCode(q, results, seen, signal));
336
+ }
337
+ } catch (e) {
338
+ const m = errMessage(e);
339
+ console.error(`[web-search] unexpected error: ${m}`);
340
+ errors.push(`unexpected:${m.slice(0, 80)}`);
341
+ } finally {
342
+ clearTimeout(timer);
343
+ }
344
+ const response = { results, docRefs };
345
+ const joined = errors.filter(Boolean).join("; ");
346
+ if (joined) response.error = joined;
347
+ return response;
348
+ }
349
+
182
350
  // src/index.ts
183
351
  var PLUGIN_NAME = "opencode-usage-coach";
184
352
  var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
@@ -667,7 +835,7 @@ function detectLanguage(extCounts) {
667
835
  if ((extCounts["java"] || 0) > 0) return "Java";
668
836
  return "unknown";
669
837
  }
670
- function buildGapPrompt(userRequest, tasks, profile, domainNodes) {
838
+ function buildGapPrompt(userRequest, tasks, profile, domainNodes, webResults, docRefs) {
671
839
  const taskList = tasks.map((t) => `${t.id}: ${t.title}`).join("\n");
672
840
  const profileStr = profile.skipped ? `(skipped \u2014 ${profile.reason || "unknown reason"})` : [
673
841
  `- Language: ${profile.language}`,
@@ -680,6 +848,10 @@ function buildGapPrompt(userRequest, tasks, profile, domainNodes) {
680
848
  `- Total files: ${profile.totalFiles}`
681
849
  ].join("\n");
682
850
  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)";
851
+ const webResultsStr = webResults && webResults.length ? webResults.slice(0, 8).map((r, i) => `${i + 1}. [${r.tier}] ${r.title}
852
+ ${r.url}
853
+ ${r.snippet}`).join("\n") : "(no web results \u2014 either network issue or no relevant findings)";
854
+ const docRefsStr = docRefs && docRefs.length ? docRefs.map((d) => `- ${d.name}: ${d.url}`).join("\n") : "(none)";
683
855
  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
856
 
685
857
  USER REQUEST:
@@ -694,6 +866,12 @@ ${profileStr}
694
866
  EXISTING DOMAIN KNOWLEDGE (from local DB):
695
867
  ${domainStr}
696
868
 
869
+ WEB SEARCH RESULTS (from official docs references, GitHub issues, and code search):
870
+ ${webResultsStr}
871
+
872
+ OFFICIAL DOCUMENTATION REFERENCES:
873
+ ${docRefsStr}
874
+
697
875
  Classify into EXACTLY these 4 categories:
698
876
 
699
877
  1. KNOWN KNOWNS \u2014 requirements explicitly stated in the user request.
@@ -832,6 +1010,20 @@ Questions for the user (${r.questions.length}):`);
832
1010
  L.push("\nTask Refinement Suggestions:");
833
1011
  for (const t of r.taskRefinements) L.push(` -> task ${t.taskId}: ${t.action} - ${t.detail}`);
834
1012
  }
1013
+ if (r.webResults && r.webResults.length) {
1014
+ L.push(`
1015
+ WEB SEARCH (${r.webResults.length} results):`);
1016
+ if (r.docRefs && r.docRefs.length) {
1017
+ L.push(` Official docs: ${r.docRefs.map((d) => d.name).join(", ")}`);
1018
+ }
1019
+ L.push(" Top results:");
1020
+ for (let i = 0; i < Math.min(r.webResults.length, 5); i++) {
1021
+ const w = r.webResults[i];
1022
+ L.push(` ${i + 1}. [${w.tier}] ${w.title}`);
1023
+ L.push(` ${w.url}`);
1024
+ L.push(` ${w.snippet.slice(0, 100)}`);
1025
+ }
1026
+ }
835
1027
  if (r.rawAnalysis) L.push(`
836
1028
  Raw analysis: ${r.rawAnalysis.slice(0, 200)}`);
837
1029
  L.push("\n[usage-coach NEXT] unknowns reviewed:");
@@ -1487,6 +1679,18 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
1487
1679
  } catch (e) {
1488
1680
  log(`unknown_scan domain query err: ${String(e)}`);
1489
1681
  }
1682
+ let webResults = [];
1683
+ let docRefs = [];
1684
+ try {
1685
+ const searchQuery = `${args.prompt} ${tasks.map((t) => t.title).join(" ")}`.slice(0, 256);
1686
+ const webResp = await searchContext(searchQuery, profile.frameworks, profile.keyDeps);
1687
+ webResults = webResp.results;
1688
+ docRefs = webResp.docRefs;
1689
+ if (webResp.error) log(`unknown_scan web search: ${webResp.error}`);
1690
+ log(`unknown_scan web search: ${webResults.length} results, ${docRefs.length} doc refs`);
1691
+ } catch (e) {
1692
+ log(`unknown_scan web search err: ${String(e).slice(0, 200)}`);
1693
+ }
1490
1694
  const cfg = readHarnessCfg(ctx.directory);
1491
1695
  if (!cfg.generator) {
1492
1696
  const result2 = {
@@ -1529,9 +1733,11 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
1529
1733
  }
1530
1734
  const throttle = decision === "THROTTLE" && cfg.lighterModel;
1531
1735
  const model = throttle ? cfg.lighterModel : cfg.generator;
1532
- const gapPrompt = buildGapPrompt(args.prompt, tasks, profile, domainNodes);
1736
+ const gapPrompt = buildGapPrompt(args.prompt, tasks, profile, domainNodes, webResults, docRefs);
1533
1737
  const raw = await runModel(input.client, model, gapPrompt, ctx.directory, void 0, 15);
1534
1738
  const result = parseGapAnalysis(raw, profile, domainHits);
1739
+ result.webResults = webResults;
1740
+ result.docRefs = docRefs;
1535
1741
  try {
1536
1742
  for (const uk of result.unknownKnowns.slice(0, 5)) {
1537
1743
  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.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",