opencode-dux 1.0.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 (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/agents/descriptions.d.ts +6 -0
  4. package/dist/agents/designer.d.ts +2 -0
  5. package/dist/agents/explorer.d.ts +2 -0
  6. package/dist/agents/fixer.d.ts +2 -0
  7. package/dist/agents/index.d.ts +22 -0
  8. package/dist/agents/interpreter.d.ts +2 -0
  9. package/dist/agents/librarian.d.ts +2 -0
  10. package/dist/agents/oracle.d.ts +2 -0
  11. package/dist/agents/orchestrator.d.ts +27 -0
  12. package/dist/agents/overrides.d.ts +18 -0
  13. package/dist/agents/prompt-blocks.d.ts +97 -0
  14. package/dist/agents/steward.d.ts +3 -0
  15. package/dist/cli/config-io.d.ts +24 -0
  16. package/dist/cli/config-manager.d.ts +4 -0
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +1006 -0
  19. package/dist/cli/install.d.ts +2 -0
  20. package/dist/cli/mcps.d.ts +13 -0
  21. package/dist/cli/model-key-normalization.d.ts +1 -0
  22. package/dist/cli/paths.d.ts +35 -0
  23. package/dist/cli/providers.d.ts +137 -0
  24. package/dist/cli/skills.d.ts +22 -0
  25. package/dist/cli/system.d.ts +5 -0
  26. package/dist/cli/types.d.ts +38 -0
  27. package/dist/config/constants.d.ts +12 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/loader.d.ts +40 -0
  30. package/dist/config/runtime-preset.d.ts +12 -0
  31. package/dist/config/schema.d.ts +281 -0
  32. package/dist/config/utils.d.ts +10 -0
  33. package/dist/discovery/local/types.d.ts +79 -0
  34. package/dist/discovery/local.d.ts +73 -0
  35. package/dist/discovery/mcp-servers.d.ts +88 -0
  36. package/dist/discovery/skills.d.ts +94 -0
  37. package/dist/hooks/apply-patch/codec.d.ts +7 -0
  38. package/dist/hooks/apply-patch/errors.d.ts +25 -0
  39. package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
  40. package/dist/hooks/apply-patch/index.d.ts +15 -0
  41. package/dist/hooks/apply-patch/matching.d.ts +26 -0
  42. package/dist/hooks/apply-patch/operations.d.ts +3 -0
  43. package/dist/hooks/apply-patch/patch.d.ts +2 -0
  44. package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
  45. package/dist/hooks/apply-patch/resolution.d.ts +19 -0
  46. package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
  47. package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
  48. package/dist/hooks/apply-patch/types.d.ts +80 -0
  49. package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
  50. package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
  51. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  52. package/dist/hooks/auto-update-checker/index.d.ts +18 -0
  53. package/dist/hooks/auto-update-checker/types.d.ts +22 -0
  54. package/dist/hooks/chat-headers.d.ts +16 -0
  55. package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
  56. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  57. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  58. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  59. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  60. package/dist/hooks/filter-available-skills/index.d.ts +32 -0
  61. package/dist/hooks/foreground-fallback/index.d.ts +72 -0
  62. package/dist/hooks/image-hook.d.ts +5 -0
  63. package/dist/hooks/index.d.ts +14 -0
  64. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  65. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  66. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  67. package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
  68. package/dist/hooks/task-session-manager/index.d.ts +52 -0
  69. package/dist/hooks/todo-continuation/index.d.ts +53 -0
  70. package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
  71. package/dist/index.d.ts +5 -0
  72. package/dist/index.js +31782 -0
  73. package/dist/mcp/context7.d.ts +6 -0
  74. package/dist/mcp/grep-app.d.ts +6 -0
  75. package/dist/mcp/index.d.ts +13 -0
  76. package/dist/mcp/types.d.ts +12 -0
  77. package/dist/mcp/websearch.d.ts +9 -0
  78. package/dist/skills/registry.d.ts +29 -0
  79. package/dist/subscriptions/accounts-store.d.ts +57 -0
  80. package/dist/subscriptions/index.d.ts +13 -0
  81. package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
  82. package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
  83. package/dist/subscriptions/types.d.ts +115 -0
  84. package/dist/subscriptions/usage-service.d.ts +74 -0
  85. package/dist/tools/ast-grep/cli.d.ts +15 -0
  86. package/dist/tools/ast-grep/constants.d.ts +25 -0
  87. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  88. package/dist/tools/ast-grep/index.d.ts +10 -0
  89. package/dist/tools/ast-grep/tools.d.ts +3 -0
  90. package/dist/tools/ast-grep/types.d.ts +30 -0
  91. package/dist/tools/ast-grep/utils.d.ts +4 -0
  92. package/dist/tools/delegate.d.ts +14 -0
  93. package/dist/tools/index.d.ts +5 -0
  94. package/dist/tools/preset-manager.d.ts +27 -0
  95. package/dist/tools/smartfetch/binary.d.ts +3 -0
  96. package/dist/tools/smartfetch/cache.d.ts +6 -0
  97. package/dist/tools/smartfetch/constants.d.ts +12 -0
  98. package/dist/tools/smartfetch/index.d.ts +3 -0
  99. package/dist/tools/smartfetch/network.d.ts +38 -0
  100. package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
  101. package/dist/tools/smartfetch/tool.d.ts +3 -0
  102. package/dist/tools/smartfetch/types.d.ts +122 -0
  103. package/dist/tools/smartfetch/utils.d.ts +18 -0
  104. package/dist/tui-state.d.ts +168 -0
  105. package/dist/tui.d.ts +37 -0
  106. package/dist/tui.js +1896 -0
  107. package/dist/utils/agent-variant.d.ts +63 -0
  108. package/dist/utils/compat.d.ts +30 -0
  109. package/dist/utils/env.d.ts +1 -0
  110. package/dist/utils/index.d.ts +9 -0
  111. package/dist/utils/internal-initiator.d.ts +6 -0
  112. package/dist/utils/logger.d.ts +8 -0
  113. package/dist/utils/polling.d.ts +21 -0
  114. package/dist/utils/session-manager.d.ts +55 -0
  115. package/dist/utils/session.d.ts +90 -0
  116. package/dist/utils/subagent-depth.d.ts +35 -0
  117. package/dist/utils/system-collapse.d.ts +6 -0
  118. package/dist/utils/task.d.ts +4 -0
  119. package/dist/utils/zip-extractor.d.ts +1 -0
  120. package/index.ts +1 -0
  121. package/opencode-dux.schema.json +634 -0
  122. package/package.json +103 -0
  123. package/src/agents/descriptions.ts +55 -0
  124. package/src/agents/designer.test.ts +86 -0
  125. package/src/agents/designer.ts +154 -0
  126. package/src/agents/display-name.test.ts +186 -0
  127. package/src/agents/explorer.test.ts +79 -0
  128. package/src/agents/explorer.ts +144 -0
  129. package/src/agents/fixer.test.ts +79 -0
  130. package/src/agents/fixer.ts +145 -0
  131. package/src/agents/index.test.ts +472 -0
  132. package/src/agents/index.ts +248 -0
  133. package/src/agents/interpreter.ts +136 -0
  134. package/src/agents/librarian.test.ts +80 -0
  135. package/src/agents/librarian.ts +145 -0
  136. package/src/agents/oracle.test.ts +89 -0
  137. package/src/agents/oracle.ts +184 -0
  138. package/src/agents/orchestrator.test.ts +116 -0
  139. package/src/agents/orchestrator.ts +574 -0
  140. package/src/agents/overrides.ts +95 -0
  141. package/src/agents/prompt-blocks.test.ts +114 -0
  142. package/src/agents/prompt-blocks.ts +640 -0
  143. package/src/agents/steward.ts +146 -0
  144. package/src/cli/config-io.test.ts +536 -0
  145. package/src/cli/config-io.ts +473 -0
  146. package/src/cli/config-manager.test.ts +141 -0
  147. package/src/cli/config-manager.ts +4 -0
  148. package/src/cli/index.ts +88 -0
  149. package/src/cli/install.ts +282 -0
  150. package/src/cli/mcps.test.ts +62 -0
  151. package/src/cli/mcps.ts +39 -0
  152. package/src/cli/model-key-normalization.test.ts +21 -0
  153. package/src/cli/model-key-normalization.ts +60 -0
  154. package/src/cli/paths.test.ts +167 -0
  155. package/src/cli/paths.ts +144 -0
  156. package/src/cli/providers.test.ts +118 -0
  157. package/src/cli/providers.ts +141 -0
  158. package/src/cli/skills.test.ts +111 -0
  159. package/src/cli/skills.ts +103 -0
  160. package/src/cli/system.test.ts +91 -0
  161. package/src/cli/system.ts +180 -0
  162. package/src/cli/types.ts +43 -0
  163. package/src/config/constants.ts +58 -0
  164. package/src/config/index.ts +4 -0
  165. package/src/config/loader.test.ts +1194 -0
  166. package/src/config/loader.ts +269 -0
  167. package/src/config/model-resolution.test.ts +176 -0
  168. package/src/config/runtime-preset.test.ts +61 -0
  169. package/src/config/runtime-preset.ts +37 -0
  170. package/src/config/schema.ts +248 -0
  171. package/src/config/utils.test.ts +41 -0
  172. package/src/config/utils.ts +23 -0
  173. package/src/discovery/local/types.ts +85 -0
  174. package/src/discovery/local.ts +322 -0
  175. package/src/discovery/mcp-servers.ts +804 -0
  176. package/src/discovery/skills.ts +959 -0
  177. package/src/hooks/apply-patch/codec.test.ts +184 -0
  178. package/src/hooks/apply-patch/codec.ts +352 -0
  179. package/src/hooks/apply-patch/errors.ts +117 -0
  180. package/src/hooks/apply-patch/execution-context.ts +432 -0
  181. package/src/hooks/apply-patch/hook.test.ts +768 -0
  182. package/src/hooks/apply-patch/index.ts +126 -0
  183. package/src/hooks/apply-patch/matching.test.ts +215 -0
  184. package/src/hooks/apply-patch/matching.ts +586 -0
  185. package/src/hooks/apply-patch/operations.test.ts +1535 -0
  186. package/src/hooks/apply-patch/operations.ts +3 -0
  187. package/src/hooks/apply-patch/patch.ts +9 -0
  188. package/src/hooks/apply-patch/prepared-changes.ts +400 -0
  189. package/src/hooks/apply-patch/resolution.test.ts +420 -0
  190. package/src/hooks/apply-patch/resolution.ts +437 -0
  191. package/src/hooks/apply-patch/rewrite.ts +496 -0
  192. package/src/hooks/apply-patch/test-helpers.ts +52 -0
  193. package/src/hooks/apply-patch/types.ts +111 -0
  194. package/src/hooks/auto-update-checker/cache.test.ts +179 -0
  195. package/src/hooks/auto-update-checker/cache.ts +188 -0
  196. package/src/hooks/auto-update-checker/checker.test.ts +159 -0
  197. package/src/hooks/auto-update-checker/checker.ts +308 -0
  198. package/src/hooks/auto-update-checker/constants.ts +33 -0
  199. package/src/hooks/auto-update-checker/index.test.ts +282 -0
  200. package/src/hooks/auto-update-checker/index.ts +225 -0
  201. package/src/hooks/auto-update-checker/types.ts +26 -0
  202. package/src/hooks/chat-headers.test.ts +236 -0
  203. package/src/hooks/chat-headers.ts +97 -0
  204. package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
  205. package/src/hooks/context-pressure-reminder/index.ts +137 -0
  206. package/src/hooks/delegate-task-retry/guidance.ts +41 -0
  207. package/src/hooks/delegate-task-retry/hook.ts +23 -0
  208. package/src/hooks/delegate-task-retry/index.test.ts +38 -0
  209. package/src/hooks/delegate-task-retry/index.ts +7 -0
  210. package/src/hooks/delegate-task-retry/patterns.ts +79 -0
  211. package/src/hooks/filter-available-skills/index.test.ts +297 -0
  212. package/src/hooks/filter-available-skills/index.ts +160 -0
  213. package/src/hooks/foreground-fallback/index.test.ts +624 -0
  214. package/src/hooks/foreground-fallback/index.ts +374 -0
  215. package/src/hooks/image-hook.ts +6 -0
  216. package/src/hooks/index.ts +17 -0
  217. package/src/hooks/json-error-recovery/hook.ts +73 -0
  218. package/src/hooks/json-error-recovery/index.test.ts +111 -0
  219. package/src/hooks/json-error-recovery/index.ts +6 -0
  220. package/src/hooks/phase-reminder/index.test.ts +74 -0
  221. package/src/hooks/phase-reminder/index.ts +85 -0
  222. package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
  223. package/src/hooks/post-file-tool-nudge/index.ts +63 -0
  224. package/src/hooks/task-session-manager/index.test.ts +833 -0
  225. package/src/hooks/task-session-manager/index.ts +434 -0
  226. package/src/hooks/todo-continuation/index.test.ts +3026 -0
  227. package/src/hooks/todo-continuation/index.ts +878 -0
  228. package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
  229. package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
  230. package/src/index.ts +1672 -0
  231. package/src/mcp/context7.ts +14 -0
  232. package/src/mcp/grep-app.ts +11 -0
  233. package/src/mcp/index.test.ts +96 -0
  234. package/src/mcp/index.ts +66 -0
  235. package/src/mcp/types.ts +16 -0
  236. package/src/mcp/websearch.ts +47 -0
  237. package/src/skills/codemap/README.md +60 -0
  238. package/src/skills/codemap/SKILL.md +174 -0
  239. package/src/skills/codemap/scripts/codemap.mjs +483 -0
  240. package/src/skills/codemap/scripts/codemap.test.ts +129 -0
  241. package/src/skills/registry.ts +218 -0
  242. package/src/skills/simplify/README.md +19 -0
  243. package/src/skills/simplify/SKILL.md +138 -0
  244. package/src/subscriptions/accounts-store.test.ts +236 -0
  245. package/src/subscriptions/accounts-store.ts +184 -0
  246. package/src/subscriptions/index.ts +30 -0
  247. package/src/subscriptions/neuralwatt-scraper.ts +108 -0
  248. package/src/subscriptions/opencode-go-scraper.ts +301 -0
  249. package/src/subscriptions/types.ts +145 -0
  250. package/src/subscriptions/usage-service.test.ts +202 -0
  251. package/src/subscriptions/usage-service.ts +651 -0
  252. package/src/tools/ast-grep/cli.ts +257 -0
  253. package/src/tools/ast-grep/constants.ts +214 -0
  254. package/src/tools/ast-grep/downloader.ts +131 -0
  255. package/src/tools/ast-grep/index.ts +24 -0
  256. package/src/tools/ast-grep/tools.ts +117 -0
  257. package/src/tools/ast-grep/types.ts +51 -0
  258. package/src/tools/ast-grep/utils.ts +126 -0
  259. package/src/tools/delegate-handoff.test.ts +18 -0
  260. package/src/tools/delegate.ts +508 -0
  261. package/src/tools/index.ts +8 -0
  262. package/src/tools/preset-manager.test.ts +795 -0
  263. package/src/tools/preset-manager.ts +332 -0
  264. package/src/tools/smartfetch/binary.ts +58 -0
  265. package/src/tools/smartfetch/cache.test.ts +34 -0
  266. package/src/tools/smartfetch/cache.ts +112 -0
  267. package/src/tools/smartfetch/constants.ts +29 -0
  268. package/src/tools/smartfetch/index.ts +8 -0
  269. package/src/tools/smartfetch/network.test.ts +178 -0
  270. package/src/tools/smartfetch/network.ts +614 -0
  271. package/src/tools/smartfetch/secondary-model.test.ts +85 -0
  272. package/src/tools/smartfetch/secondary-model.ts +276 -0
  273. package/src/tools/smartfetch/tool.test.ts +60 -0
  274. package/src/tools/smartfetch/tool.ts +832 -0
  275. package/src/tools/smartfetch/types.ts +135 -0
  276. package/src/tools/smartfetch/utils.test.ts +24 -0
  277. package/src/tools/smartfetch/utils.ts +456 -0
  278. package/src/tui-state.test.ts +867 -0
  279. package/src/tui-state.ts +1255 -0
  280. package/src/tui.test.ts +336 -0
  281. package/src/tui.ts +1539 -0
  282. package/src/utils/agent-variant.test.ts +244 -0
  283. package/src/utils/agent-variant.ts +187 -0
  284. package/src/utils/compat.ts +91 -0
  285. package/src/utils/env.ts +12 -0
  286. package/src/utils/index.ts +9 -0
  287. package/src/utils/internal-initiator.ts +28 -0
  288. package/src/utils/logger.test.ts +220 -0
  289. package/src/utils/logger.ts +136 -0
  290. package/src/utils/polling.test.ts +191 -0
  291. package/src/utils/polling.ts +67 -0
  292. package/src/utils/session-manager.test.ts +173 -0
  293. package/src/utils/session-manager.ts +356 -0
  294. package/src/utils/session.test.ts +110 -0
  295. package/src/utils/session.ts +389 -0
  296. package/src/utils/subagent-depth.test.ts +170 -0
  297. package/src/utils/subagent-depth.ts +75 -0
  298. package/src/utils/system-collapse.test.ts +86 -0
  299. package/src/utils/system-collapse.ts +24 -0
  300. package/src/utils/task.test.ts +24 -0
  301. package/src/utils/task.ts +20 -0
  302. package/src/utils/zip-extractor.ts +102 -0
@@ -0,0 +1,959 @@
1
+ import { createHash } from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { PluginInput, ToolDefinition } from '@opencode-ai/plugin';
6
+ import { tool } from '@opencode-ai/plugin';
7
+ import { log } from '../utils/logger';
8
+ import { getLocalDiscovery } from './local';
9
+
10
+ // ── Skill interfaces ────────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Input parameters for discovering OpenCode skills online.
14
+ */
15
+ export interface DiscoverSkillsInput {
16
+ /** Natural-language description of the task at hand. */
17
+ task_description: string;
18
+ /** Keywords that characterise the task (used for search queries). */
19
+ task_keywords: string[];
20
+ /** The subagent that initiated the discovery request. */
21
+ agent_name: string;
22
+ /**
23
+ * Skills already known to be installed.
24
+ * Recommendations that match by name will be filtered out or marked.
25
+ */
26
+ existing_skill_names?: string[];
27
+ /** Maximum number of recommendations to return (default: 5). */
28
+ max_results?: number;
29
+ }
30
+
31
+ /**
32
+ * A single recommendation for an installable OpenCode skill.
33
+ */
34
+ export interface SkillRecommendation {
35
+ type: 'skill';
36
+ /** Canonical name (e.g. 'ast-grep', 'codemap'). */
37
+ name: string;
38
+ /** Human-readable summary of what the skill provides. */
39
+ description: string;
40
+ /**
41
+ * A ready-to-use install command, e.g.
42
+ * `npx skills add https://github.com/vercel-labs/skills --skill ast-grep`.
43
+ */
44
+ install_command: string;
45
+ /** Why this recommendation is relevant to the task. */
46
+ relevance_reason: string;
47
+ /** Relevance score from 0 (irrelevant) to 1 (perfect match). */
48
+ relevance_score: number;
49
+ /** GitHub repository URL for the skill. */
50
+ source_url: string;
51
+ /** Agent names that are most likely to benefit from this skill. */
52
+ recommended_agents: string[];
53
+ /** Categorisation tags (e.g. 'search', 'code', 'ast'). */
54
+ tags: string[];
55
+ /** Whether the user already has this skill installed. */
56
+ already_installed?: boolean;
57
+ }
58
+
59
+ /**
60
+ * The complete output of a skill discovery request.
61
+ */
62
+ export interface DiscoverSkillsOutput {
63
+ /** Ordered list of recommendations, highest relevance first. */
64
+ recommendations: SkillRecommendation[];
65
+ /** Whether the result was served from cache. */
66
+ from_cache: boolean;
67
+ /** The search queries that were executed. */
68
+ queries_used: string[];
69
+ }
70
+
71
+ // ── Cache ────────────────────────────────────────────────────────────────────
72
+
73
+ /** Maximum number of cache entries before LRU eviction kicks in. */
74
+ const CACHE_MAX_ENTRIES = 100;
75
+
76
+ /** Cache TTL in milliseconds for skill results (7 days). */
77
+ const SKILL_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
78
+
79
+ /** Directory for the discovery cache file. */
80
+ const CACHE_DIR = path.join(os.homedir(), '.config', 'opencode');
81
+
82
+ /** Cache file path. */
83
+ const CACHE_FILE = path.join(CACHE_DIR, 'discovery-cache.json');
84
+
85
+ /** Internal cache entry shape stored on disk. */
86
+ interface CacheEntry {
87
+ /** Serialised output data (DiscoverSkillsOutput). */
88
+ data: string;
89
+ /** Unix timestamp (ms) when this entry was written. */
90
+ timestamp: number;
91
+ /**
92
+ * Access-order key for LRU eviction.
93
+ * Lower values are older; bumped to `++nextAccessOrder` on read/write.
94
+ */
95
+ accessOrder: number;
96
+ /** Cache TTL for this entry (ms), set at write time. */
97
+ ttl: number;
98
+ }
99
+
100
+ /** Module-level monotonic access-order counter for LRU tracking. */
101
+ let nextAccessOrder = 0;
102
+
103
+ /**
104
+ * Load and parse the on-disk cache file.
105
+ */
106
+ function loadCacheFile(): Map<string, CacheEntry> {
107
+ try {
108
+ if (!fs.existsSync(CACHE_FILE)) {
109
+ return new Map();
110
+ }
111
+ const raw = fs.readFileSync(CACHE_FILE, 'utf-8');
112
+ const parsed: Record<string, CacheEntry> = JSON.parse(raw) ?? {};
113
+ const map = new Map<string, CacheEntry>();
114
+ for (const [key, entry] of Object.entries(parsed)) {
115
+ map.set(key, entry);
116
+ }
117
+ return map;
118
+ } catch (err) {
119
+ log('[discovery/skills] failed to load cache file', String(err));
120
+ return new Map();
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Persist the cache map to disk.
126
+ */
127
+ function saveCacheFile(cache: Map<string, CacheEntry>): void {
128
+ try {
129
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
130
+ const obj: Record<string, CacheEntry> = {};
131
+ for (const [key, entry] of cache) {
132
+ obj[key] = entry;
133
+ }
134
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2), 'utf-8');
135
+ } catch (err) {
136
+ log('[discovery/skills] failed to save cache file', String(err));
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Perform LRU eviction on the cache map when it exceeds {@link CACHE_MAX_ENTRIES}.
142
+ */
143
+ function evictLru(cache: Map<string, CacheEntry>): void {
144
+ if (cache.size <= CACHE_MAX_ENTRIES) return;
145
+
146
+ const sorted = [...cache.entries()].sort(
147
+ ([, a], [, b]) => a.accessOrder - b.accessOrder,
148
+ );
149
+ const toRemove = sorted.slice(0, sorted.length - CACHE_MAX_ENTRIES);
150
+ for (const [key] of toRemove) {
151
+ cache.delete(key);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Build a cache key from a namespace prefix and the search-relevant input fields.
157
+ *
158
+ * Uses an MD5 hex digest of the concatenated keywords and agent name.
159
+ * MD5 is sufficient for cache-key hashing (not security sensitive).
160
+ */
161
+ function buildCacheKey(
162
+ prefix: string,
163
+ taskKeywords: string[],
164
+ agentName: string,
165
+ ): string {
166
+ const raw = `${[...taskKeywords].sort().join(',')}|${agentName}`;
167
+ const hash = createHash('md5').update(raw, 'utf-8').digest('hex');
168
+ return `${prefix}:${hash}`;
169
+ }
170
+
171
+ /**
172
+ * Try to read a valid, non-expired entry from the cache.
173
+ *
174
+ * Returns the data and marks the entry as recently accessed when found.
175
+ */
176
+ function readFromCache<T>(
177
+ cache: Map<string, CacheEntry>,
178
+ key: string,
179
+ ): T | null {
180
+ const entry = cache.get(key);
181
+ if (!entry) return null;
182
+
183
+ const age = Date.now() - entry.timestamp;
184
+ if (age > entry.ttl) {
185
+ cache.delete(key);
186
+ return null;
187
+ }
188
+
189
+ // Bump access order
190
+ entry.accessOrder = ++nextAccessOrder;
191
+ try {
192
+ return JSON.parse(entry.data) as T;
193
+ } catch {
194
+ cache.delete(key);
195
+ return null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Write a result to the in-memory cache and persist to disk.
201
+ */
202
+ function writeToCache<T>(
203
+ cache: Map<string, CacheEntry>,
204
+ key: string,
205
+ data: T,
206
+ ttl: number,
207
+ ): void {
208
+ cache.set(key, {
209
+ data: JSON.stringify(data),
210
+ timestamp: Date.now(),
211
+ accessOrder: ++nextAccessOrder,
212
+ ttl,
213
+ });
214
+ evictLru(cache);
215
+ saveCacheFile(cache);
216
+ }
217
+
218
+ // ── Skill search query construction ─────────────────────────────────────────
219
+
220
+ /**
221
+ * Build an array of skill-focused search query strings from the task keywords.
222
+ *
223
+ * Includes a query targeting the vercel-labs/skills repository and
224
+ * general opencode skill queries.
225
+ */
226
+ function buildSkillSearchQueries(
227
+ keywords: string[],
228
+ agentName: string,
229
+ ): string[] {
230
+ const queries: string[] = [];
231
+
232
+ // Target the known skills repository
233
+ queries.push('org:vercel-labs SKILL.md');
234
+
235
+ // General opencode skill queries
236
+ for (const kw of keywords) {
237
+ queries.push(`opencode skill ${kw}`);
238
+ }
239
+
240
+ // Scoped to the requesting agent
241
+ queries.push(`opencode ${agentName} skill`);
242
+
243
+ return queries;
244
+ }
245
+
246
+ // ── GitHub search ───────────────────────────────────────────────────────────
247
+
248
+ /** Result item from the GitHub code search API. */
249
+ interface GitHubCodeSearchItem {
250
+ name: string;
251
+ path: string;
252
+ html_url?: string;
253
+ repository: {
254
+ full_name: string;
255
+ html_url: string;
256
+ description?: string;
257
+ default_branch?: string;
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Search the GitHub code search API for skill-related results.
263
+ *
264
+ * Returns an empty array on failure (network, auth, rate-limit).
265
+ */
266
+ async function searchGitHubCode(
267
+ query: string,
268
+ signal?: AbortSignal,
269
+ ): Promise<GitHubCodeSearchItem[]> {
270
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
271
+ const headers: Record<string, string> = {
272
+ Accept: 'application/vnd.github.v3+json',
273
+ 'User-Agent': 'opencode-dux/1.0',
274
+ };
275
+ if (token) {
276
+ headers.Authorization = `Bearer ${token}`;
277
+ }
278
+
279
+ const url = `https://api.github.com/search/code?q=${encodeURIComponent(query)}&per_page=5&sort=indexed`;
280
+ try {
281
+ const res = await fetch(url, { signal, headers });
282
+ if (!res.ok) {
283
+ log(
284
+ `[discovery/skills] GitHub search failed (${res.status}) for query: ${query}`,
285
+ );
286
+ return [];
287
+ }
288
+ const body = (await res.json()) as {
289
+ items?: GitHubCodeSearchItem[];
290
+ };
291
+ return body.items ?? [];
292
+ } catch (err) {
293
+ log('[discovery/skills] GitHub search error', String(err));
294
+ return [];
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Get the default-branch content of a file from a public GitHub repo.
300
+ * Used to fetch SKILL.md files to extract skill metadata.
301
+ */
302
+ interface GitHubContentItem {
303
+ name: string;
304
+ type: 'file' | 'dir';
305
+ path: string;
306
+ download_url?: string;
307
+ }
308
+
309
+ async function listGitHubRepoContents(
310
+ owner: string,
311
+ repo: string,
312
+ path: string,
313
+ signal?: AbortSignal,
314
+ ): Promise<GitHubContentItem[]> {
315
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`;
316
+ try {
317
+ const res = await fetch(url, {
318
+ signal,
319
+ headers: {
320
+ Accept: 'application/vnd.github.v3+json',
321
+ 'User-Agent': 'opencode-dux/1.0',
322
+ },
323
+ });
324
+ if (!res.ok) return [];
325
+ const body = (await res.json()) as GitHubContentItem[] | GitHubContentItem;
326
+ return Array.isArray(body) ? body : [body];
327
+ } catch {
328
+ return [];
329
+ }
330
+ }
331
+
332
+ async function fetchRawFile(
333
+ url: string,
334
+ signal?: AbortSignal,
335
+ ): Promise<string> {
336
+ try {
337
+ const res = await fetch(url, { signal });
338
+ if (!res.ok) return '';
339
+ return await res.text();
340
+ } catch {
341
+ return '';
342
+ }
343
+ }
344
+
345
+ // ── Relevance scoring ───────────────────────────────────────────────────────
346
+
347
+ /** Known MCP/skill keywords mapped to descriptive tags. */
348
+ const NAME_TAG_MAP: Record<string, string[]> = {
349
+ playwright: ['browser', 'ui', 'testing'],
350
+ github: ['github', 'git', 'version-control'],
351
+ filesystem: ['filesystem', 'file-ops'],
352
+ websearch: ['web', 'search', 'internet'],
353
+ context7: ['docs', 'search', 'reference'],
354
+ 'grep-app': ['search', 'code', 'grep'],
355
+ ast_grep: ['search', 'code', 'ast'],
356
+ puppeteer: ['browser', 'ui', 'testing'],
357
+ postgres: ['database', 'sql'],
358
+ sqlite: ['database', 'sql', 'embedded'],
359
+ redis: ['cache', 'database'],
360
+ slack: ['communication', 'messaging'],
361
+ discord: ['communication', 'messaging'],
362
+ jira: ['project-management', 'issue-tracking'],
363
+ linear: ['project-management', 'issue-tracking'],
364
+ notion: ['docs', 'knowledge', 'wiki'],
365
+ figma: ['design', 'ui', 'prototyping'],
366
+ sentry: ['monitoring', 'error-tracking'],
367
+ stripe: ['payments', 'billing'],
368
+ };
369
+
370
+ /**
371
+ * Derive tags from a package name, description, and npm keywords.
372
+ */
373
+ function deriveTags(
374
+ name: string,
375
+ _description: string,
376
+ npmKeywords?: string[],
377
+ ): string[] {
378
+ const lower = name.toLowerCase();
379
+ const tags: string[] = [];
380
+
381
+ for (const [key, mapped] of Object.entries(NAME_TAG_MAP)) {
382
+ if (lower.includes(key)) {
383
+ tags.push(...mapped);
384
+ }
385
+ }
386
+
387
+ if (npmKeywords) {
388
+ for (const kw of npmKeywords) {
389
+ const lowerKw = kw.toLowerCase().replace(/\s+/g, '-');
390
+ if (!tags.includes(lowerKw)) {
391
+ tags.push(lowerKw);
392
+ }
393
+ }
394
+ }
395
+
396
+ return [...new Set(tags)];
397
+ }
398
+
399
+ /**
400
+ * Score how relevant a recommendation is to the task context.
401
+ *
402
+ * Examines keyword overlap, name-tag matches, and the npm score (when available)
403
+ * to produce a value in the [0, 1] range.
404
+ */
405
+ function scoreRelevance(
406
+ name: string,
407
+ description: string,
408
+ tags: string[],
409
+ keywords: string[],
410
+ npmScore?: number,
411
+ ): number {
412
+ let score = 0;
413
+
414
+ const nameLower = name.toLowerCase();
415
+ const descLower = description.toLowerCase();
416
+ const tagsLower = tags.map((t) => t.toLowerCase());
417
+ const allText = `${nameLower} ${descLower} ${tagsLower.join(' ')}`;
418
+
419
+ for (const kw of keywords) {
420
+ const kwLower = kw.toLowerCase();
421
+ if (nameLower.includes(kwLower)) {
422
+ score += 0.35;
423
+ }
424
+ if (descLower.includes(kwLower)) {
425
+ score += 0.2;
426
+ }
427
+ if (tagsLower.includes(kwLower)) {
428
+ score += 0.15;
429
+ }
430
+ }
431
+
432
+ if (typeof npmScore === 'number' && npmScore > 0) {
433
+ score += npmScore * 0.2;
434
+ }
435
+
436
+ const matchedKeywords = keywords.filter((kw) =>
437
+ allText.includes(kw.toLowerCase()),
438
+ ).length;
439
+ if (keywords.length > 0) {
440
+ score += (matchedKeywords / keywords.length) * 0.1;
441
+ }
442
+
443
+ return Math.min(score, 1);
444
+ }
445
+
446
+ /**
447
+ * Determine which agents would benefit most from an item based on its tags.
448
+ */
449
+ function deriveRecommendedAgents(
450
+ type: 'mcp' | 'skill',
451
+ tags: string[],
452
+ ): string[] {
453
+ const agents: string[] = [];
454
+
455
+ if (type === 'mcp') {
456
+ // MCP path not used in skills module, but kept for type completeness
457
+ }
458
+
459
+ const tagSet = new Set(tags.map((t) => t.toLowerCase()));
460
+
461
+ if (tagSet.has('browser') || tagSet.has('ui') || tagSet.has('testing')) {
462
+ agents.push('explorer');
463
+ }
464
+ if (tagSet.has('search') || tagSet.has('docs') || tagSet.has('reference')) {
465
+ agents.push('librarian');
466
+ }
467
+ if (tagSet.has('database') || tagSet.has('sql')) {
468
+ agents.push('oracle');
469
+ }
470
+ if (tagSet.has('design') || tagSet.has('prototyping')) {
471
+ agents.push('designer');
472
+ }
473
+
474
+ return [...new Set(agents)];
475
+ }
476
+
477
+ /**
478
+ * Build a human-readable reason string explaining why a package is relevant.
479
+ */
480
+ function buildRelevanceReason(
481
+ name: string,
482
+ description: string,
483
+ tags: string[],
484
+ keywords: string[],
485
+ ): string {
486
+ const matchedKeywords = keywords.filter(
487
+ (kw) =>
488
+ name.toLowerCase().includes(kw.toLowerCase()) ||
489
+ description.toLowerCase().includes(kw.toLowerCase()) ||
490
+ tags.some((t) => t.includes(kw.toLowerCase())),
491
+ );
492
+
493
+ if (matchedKeywords.length > 0) {
494
+ return `Matches keywords: ${matchedKeywords.join(', ')}`;
495
+ }
496
+ return 'Found in search results for task context';
497
+ }
498
+
499
+ // ── Skill helpers ───────────────────────────────────────────────────────────
500
+
501
+ /**
502
+ * Mark and filter skill recommendations against already-installed skills.
503
+ *
504
+ * Same logic as MCP filtering:
505
+ * - Matching items are marked `already_installed: true`
506
+ * - Only included when relevance_score > 0.8
507
+ * - Non-installed items pass through unchanged
508
+ */
509
+ function filterExistingSkills(
510
+ recommendations: SkillRecommendation[],
511
+ existingNames: string[] | undefined,
512
+ ): SkillRecommendation[] {
513
+ if (!existingNames || existingNames.length === 0) return recommendations;
514
+
515
+ const installed = new Set(existingNames.map((n) => n.toLowerCase()));
516
+
517
+ return recommendations
518
+ .map((rec) => {
519
+ const lower = rec.name.toLowerCase();
520
+ if (installed.has(lower)) {
521
+ return { ...rec, already_installed: true };
522
+ }
523
+ return rec;
524
+ })
525
+ .filter((rec) => {
526
+ if (rec.already_installed) {
527
+ return rec.relevance_score > 0.8;
528
+ }
529
+ return true;
530
+ });
531
+ }
532
+
533
+ /**
534
+ * Build an install command for an OpenCode skill.
535
+ */
536
+ function buildSkillInstallCommand(sourceUrl: string, name: string): string {
537
+ return `npx skills add ${sourceUrl} --skill ${name}`;
538
+ }
539
+
540
+ /**
541
+ * Parse a SKILL.md front-matter or heading to extract a skill name and description.
542
+ */
543
+ function parseSkillMd(
544
+ content: string,
545
+ defaultName: string,
546
+ ): { name: string; description: string } {
547
+ const lines = content.split('\n');
548
+ let name = defaultName;
549
+ let description = '';
550
+
551
+ for (const line of lines) {
552
+ const trimmed = line.trim();
553
+ // Check for markdown heading (first # heading is usually the name)
554
+ if (trimmed.startsWith('# ') && !name) {
555
+ name = trimmed.replace(/^#\s+/, '').trim();
556
+ }
557
+ // Check for description after the heading
558
+ if (name && !description && trimmed && !trimmed.startsWith('#')) {
559
+ description = trimmed;
560
+ break;
561
+ }
562
+ }
563
+
564
+ return { name, description };
565
+ }
566
+
567
+ /**
568
+ * Try to discover skills from the vercel-labs/skills repository by
569
+ * listing its top-level directory and fetching SKILL.md files.
570
+ */
571
+ async function discoverSkillsFromVercelLabs(
572
+ _keywords: string[],
573
+ signal?: AbortSignal,
574
+ ): Promise<SkillRecommendation[]> {
575
+ const items: SkillRecommendation[] = [];
576
+
577
+ // List the top-level contents of vercel-labs/skills
578
+ const contents = await listGitHubRepoContents(
579
+ 'vercel-labs',
580
+ 'skills',
581
+ '',
582
+ signal,
583
+ );
584
+
585
+ // Each subdirectory that contains a SKILL.md is a potential skill
586
+ const dirs = contents.filter((c) => c.type === 'dir');
587
+ const seenNames = new Set<string>();
588
+
589
+ for (const dir of dirs.slice(0, 10)) {
590
+ const subContents = await listGitHubRepoContents(
591
+ 'vercel-labs',
592
+ 'skills',
593
+ dir.name,
594
+ signal,
595
+ );
596
+ const skillMd = subContents.find(
597
+ (c) => c.type === 'file' && c.name === 'SKILL.md',
598
+ );
599
+ if (!skillMd || !skillMd.download_url) continue;
600
+
601
+ const raw = await fetchRawFile(skillMd.download_url, signal);
602
+ if (!raw) continue;
603
+
604
+ const { name, description } = parseSkillMd(raw, dir.name);
605
+ if (seenNames.has(name.toLowerCase())) continue;
606
+ seenNames.add(name.toLowerCase());
607
+
608
+ const repoUrl = `https://github.com/vercel-labs/skills`;
609
+ const tags = deriveTags(name, description);
610
+ const relevanceScore = scoreRelevance(name, description, tags, _keywords);
611
+ if (relevanceScore < 0.05) continue;
612
+
613
+ items.push({
614
+ type: 'skill',
615
+ name,
616
+ description,
617
+ install_command: buildSkillInstallCommand(repoUrl, name),
618
+ relevance_reason: buildRelevanceReason(
619
+ name,
620
+ description,
621
+ tags,
622
+ _keywords,
623
+ ),
624
+ relevance_score: relevanceScore,
625
+ source_url: `${repoUrl}/tree/main/${dir.name}`,
626
+ recommended_agents: deriveRecommendedAgents('skill', tags),
627
+ tags,
628
+ });
629
+ }
630
+
631
+ return items;
632
+ }
633
+
634
+ /**
635
+ * Search GitHub broadly for opencode skill-related repositories.
636
+ */
637
+ async function discoverSkillsFromGitHubSearch(
638
+ keywords: string[],
639
+ _agentName: string,
640
+ signal?: AbortSignal,
641
+ ): Promise<SkillRecommendation[]> {
642
+ const queries = buildSkillSearchQueries(keywords, _agentName);
643
+ const seenRepos = new Set<string>();
644
+ const items: SkillRecommendation[] = [];
645
+
646
+ const concurrencyLimit = 2;
647
+ const queryBatches: string[][] = [];
648
+ for (let i = 0; i < queries.length; i += concurrencyLimit) {
649
+ queryBatches.push(queries.slice(i, i + concurrencyLimit));
650
+ }
651
+
652
+ for (const batch of queryBatches) {
653
+ const batchResults = await Promise.all(
654
+ batch.map((q) => searchGitHubCode(q, signal)),
655
+ );
656
+
657
+ for (const results of batchResults) {
658
+ for (const result of results) {
659
+ const repoFullName = result.repository.full_name;
660
+ if (seenRepos.has(repoFullName)) continue;
661
+ seenRepos.add(repoFullName);
662
+
663
+ const name = repoFullName.split('/')[1] ?? repoFullName;
664
+ const description = result.repository.description ?? '';
665
+ const repoUrl = result.repository.html_url;
666
+
667
+ // Only process results from repos other than vercel-labs/skills
668
+ // (already handled by discoverSkillsFromVercelLabs)
669
+ if (repoFullName === 'vercel-labs/skills') continue;
670
+
671
+ const tags = deriveTags(name, description);
672
+ const relevanceScore = scoreRelevance(
673
+ name,
674
+ description,
675
+ tags,
676
+ keywords,
677
+ );
678
+ if (relevanceScore < 0.05) continue;
679
+
680
+ items.push({
681
+ type: 'skill',
682
+ name,
683
+ description,
684
+ install_command: buildSkillInstallCommand(repoUrl, name),
685
+ relevance_reason: buildRelevanceReason(
686
+ name,
687
+ description,
688
+ tags,
689
+ keywords,
690
+ ),
691
+ relevance_score: relevanceScore,
692
+ source_url: repoUrl,
693
+ recommended_agents: deriveRecommendedAgents('skill', tags),
694
+ tags,
695
+ });
696
+ }
697
+ }
698
+ }
699
+
700
+ return items;
701
+ }
702
+
703
+ /**
704
+ * Try to discover skills via the OpenCode SDK's skill endpoint.
705
+ */
706
+ async function discoverSkillsFromSdk(
707
+ _ctx: PluginInput,
708
+ _keywords: string[],
709
+ _signal?: AbortSignal,
710
+ ): Promise<SkillRecommendation[]> {
711
+ // Attempt to use the SDK's client.skill API if available.
712
+ // This is a best-effort call; failures are silently ignored.
713
+ try {
714
+ const client = _ctx.client as unknown as Record<string, unknown>;
715
+ const skillApi = client.skill as
716
+ | {
717
+ list?: (args: Record<string, unknown>) => Promise<{ data?: unknown }>;
718
+ }
719
+ | undefined;
720
+
721
+ if (skillApi?.list) {
722
+ const result = await skillApi.list({});
723
+ const data = result.data as
724
+ | Array<{
725
+ name?: string;
726
+ description?: string;
727
+ source?: string;
728
+ tags?: string[];
729
+ }>
730
+ | undefined;
731
+
732
+ if (Array.isArray(data)) {
733
+ return data
734
+ .filter((s) => s.name)
735
+ .map((s) => {
736
+ const name = s.name ?? 'unknown';
737
+ const description = s.description ?? '';
738
+ const sourceUrl = s.source ?? `https://github.com/opencode/${name}`;
739
+ const tags = (s.tags ?? []).filter(
740
+ (t): t is string => typeof t === 'string',
741
+ );
742
+ const relevanceScore = scoreRelevance(
743
+ name,
744
+ description,
745
+ tags,
746
+ _keywords,
747
+ );
748
+
749
+ return {
750
+ type: 'skill' as const,
751
+ name,
752
+ description,
753
+ install_command: buildSkillInstallCommand(sourceUrl, name),
754
+ relevance_reason: buildRelevanceReason(
755
+ name,
756
+ description,
757
+ tags,
758
+ _keywords,
759
+ ),
760
+ relevance_score: relevanceScore,
761
+ source_url: sourceUrl,
762
+ recommended_agents: deriveRecommendedAgents('skill', tags),
763
+ tags,
764
+ };
765
+ })
766
+ .filter((s) => s.relevance_score >= 0.05);
767
+ }
768
+ }
769
+ } catch {
770
+ // SDK endpoint not available - fall back to GitHub search only
771
+ }
772
+
773
+ return [];
774
+ }
775
+
776
+ // ── discoverSkills ─────────────────────────────────────────────────────────
777
+
778
+ /**
779
+ * Run the full skill discovery flow for a given set of inputs.
780
+ *
781
+ * 1. Builds search queries from keywords and agent name
782
+ * 2. Searches the vercel-labs/skills repository for skill definitions
783
+ * 3. Searches GitHub broadly for opencode skill-related repositories
784
+ * 4. Tries the OpenCode SDK's skill endpoint (if available)
785
+ * 5. Merges, deduplicates, and scores results
786
+ * 6. Returns the top N results
787
+ *
788
+ * Does NOT search npm.
789
+ * Results are cached on disk for 7 days (skills change less often).
790
+ */
791
+ export async function discoverSkills(
792
+ input: DiscoverSkillsInput,
793
+ ctx: PluginInput,
794
+ ): Promise<DiscoverSkillsOutput> {
795
+ const maxResults = input.max_results ?? 5;
796
+
797
+ // Auto-discover installed skills if caller didn't provide existing names
798
+ let existingSkillNames = input.existing_skill_names;
799
+ if (!existingSkillNames || existingSkillNames.length === 0) {
800
+ try {
801
+ const local = await getLocalDiscovery(ctx);
802
+ existingSkillNames = local.skills.map((s) => s.name);
803
+ } catch {
804
+ // Best-effort – skip auto-discovery if SDK call fails
805
+ existingSkillNames = [];
806
+ }
807
+ }
808
+
809
+ const queries = buildSkillSearchQueries(
810
+ input.task_keywords,
811
+ input.agent_name,
812
+ );
813
+ const allRecommendations: SkillRecommendation[] = [];
814
+ const abortController = new AbortController();
815
+
816
+ // Gather results from all sources in parallel
817
+ const [vercelResults, gitHubResults, sdkResults] = await Promise.all([
818
+ discoverSkillsFromVercelLabs(input.task_keywords, abortController.signal),
819
+ discoverSkillsFromGitHubSearch(
820
+ input.task_keywords,
821
+ input.agent_name,
822
+ abortController.signal,
823
+ ),
824
+ discoverSkillsFromSdk(ctx, input.task_keywords, abortController.signal),
825
+ ]);
826
+
827
+ // Merge and deduplicate
828
+ const seen = new Set<string>();
829
+ for (const rec of [...vercelResults, ...gitHubResults, ...sdkResults]) {
830
+ const key = rec.name.toLowerCase();
831
+ if (seen.has(key)) continue;
832
+ seen.add(key);
833
+ allRecommendations.push(rec);
834
+ }
835
+
836
+ // Sort by relevance
837
+ allRecommendations.sort((a, b) => b.relevance_score - a.relevance_score);
838
+
839
+ // Filter out or mark already-installed skills
840
+ const filtered = filterExistingSkills(allRecommendations, existingSkillNames);
841
+
842
+ // Return top N
843
+ const recommendations = filtered.slice(0, maxResults);
844
+
845
+ return {
846
+ recommendations,
847
+ from_cache: false,
848
+ queries_used: queries,
849
+ };
850
+ }
851
+
852
+ // ── Tool factory ────────────────────────────────────────────────────────────
853
+
854
+ const z = tool.schema;
855
+
856
+ /**
857
+ * Create the `discover_skills_online` tool that subagents can call to find
858
+ * installable OpenCode skills for a given task.
859
+ *
860
+ * The tool:
861
+ * 1. Builds search queries from task keywords and agent name
862
+ * 2. Searches the vercel-labs/skills repository and general GitHub for skills
863
+ * 3. Tries the OpenCode SDK's skill endpoint if available
864
+ * 4. Scores each result by relevance (0-1)
865
+ * 5. Marks already-installed skills with `already_installed: true` and
866
+ * only includes them when relevance_score > 0.8
867
+ * 6. Returns the top N recommendations with install commands
868
+ *
869
+ * Does NOT search npm. Skills are knowledge/prompt resources separate from
870
+ * MCP servers (which are tool/capability resources).
871
+ *
872
+ * Results are cached on disk at `~/.config/opencode/discovery-cache.json`
873
+ * with a 7-day TTL and LRU eviction (max 100 entries).
874
+ *
875
+ * @param ctx - The OpenCode plugin input (provides client for SDK access)
876
+ * @returns A `ToolDefinition` ready for registration in the plugin's tool hook
877
+ */
878
+ export function createDiscoverSkillsTool(ctx: PluginInput): ToolDefinition {
879
+ const cache = loadCacheFile();
880
+ const cachePrefix = 'skill';
881
+
882
+ return tool({
883
+ description:
884
+ 'Search online for OpenCode skills that could be installed to help ' +
885
+ 'with a given task. ' +
886
+ 'Use this when you want to discover installable skill packages for ' +
887
+ 'task-specific knowledge or workflows. ' +
888
+ 'If existing_skill_names is not provided, automatically discovers ' +
889
+ "what's already installed and filters recommendations accordingly. " +
890
+ 'Already-installed skills are shown only when relevance_score > 0.8 ' +
891
+ '(significantly better). ' +
892
+ 'Skills are knowledge and prompt resources (not tool/capability resources ' +
893
+ 'like MCP servers). ' +
894
+ 'Results are cached for 7 days.',
895
+ args: {
896
+ task_description: z
897
+ .string()
898
+ .describe(
899
+ 'Natural language description of the task the subagent needs help with',
900
+ ),
901
+ task_keywords: z
902
+ .array(z.string())
903
+ .describe(
904
+ 'Keywords characterising the task (used to build search queries)',
905
+ ),
906
+ agent_name: z
907
+ .string()
908
+ .describe('The subagent name requesting the discovery'),
909
+ existing_skill_names: z
910
+ .array(z.string())
911
+ .optional()
912
+ .describe(
913
+ 'Skill names already installed (to avoid duplicate recommendations). ' +
914
+ 'If not provided, auto-detects installed skills.',
915
+ ),
916
+ max_results: z
917
+ .number()
918
+ .min(1)
919
+ .max(20)
920
+ .default(5)
921
+ .describe('Maximum number of recommendations to return'),
922
+ },
923
+ execute: async (args, toolCtx) => {
924
+ const taskKeywords = args.task_keywords ?? [];
925
+ const agentName = args.agent_name ?? '';
926
+ const maxResults = args.max_results ?? 5;
927
+ const projectDir = toolCtx.directory || ctx.directory;
928
+
929
+ const cacheKey = buildCacheKey(cachePrefix, taskKeywords, agentName);
930
+
931
+ const cached = readFromCache<DiscoverSkillsOutput>(cache, cacheKey);
932
+ if (cached) {
933
+ log('[discovery/skills] cache hit for', cacheKey);
934
+ return JSON.stringify({
935
+ ...cached,
936
+ from_cache: true,
937
+ recommendations: cached.recommendations.slice(0, maxResults),
938
+ });
939
+ }
940
+
941
+ log(`[discovery/skills] cache miss for project: ${projectDir}`);
942
+
943
+ const output = await discoverSkills(
944
+ {
945
+ task_description: args.task_description ?? '',
946
+ task_keywords: taskKeywords,
947
+ agent_name: agentName,
948
+ existing_skill_names: args.existing_skill_names ?? undefined,
949
+ max_results: maxResults,
950
+ },
951
+ ctx,
952
+ );
953
+
954
+ writeToCache(cache, cacheKey, output, SKILL_CACHE_TTL_MS);
955
+
956
+ return JSON.stringify(output);
957
+ },
958
+ });
959
+ }