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,804 @@
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 { SUBAGENT_NAMES } from '../config/constants';
8
+ import { log } from '../utils/logger';
9
+ import { getLocalDiscovery } from './local';
10
+
11
+ // ── MCP interfaces ───────────────────────────────────────────────────────────
12
+
13
+ /**
14
+ * Input parameters for discovering MCP servers online.
15
+ */
16
+ export interface McpDiscoveryInput {
17
+ /** Natural-language description of the task at hand. */
18
+ task_description: string;
19
+ /** Keywords that characterise the task (used for search queries). */
20
+ task_keywords: string[];
21
+ /** The subagent that initiated the discovery request. */
22
+ agent_name: string;
23
+ /**
24
+ * MCP servers already known to be installed.
25
+ * Recommendations that match by name will be filtered out.
26
+ */
27
+ existing_mcp_names?: string[];
28
+ /** Maximum number of recommendations to return (default: 5). */
29
+ max_results?: number;
30
+ }
31
+
32
+ /**
33
+ * A single recommendation for an installable MCP server.
34
+ */
35
+ export interface McpRecommendation {
36
+ type: 'mcp';
37
+ /** Canonical name (e.g. 'playwright', 'github'). */
38
+ name: string;
39
+ /** Human-readable summary of what the MCP server provides. */
40
+ description: string;
41
+ /**
42
+ * A ready-to-use JSON config block for the user's opencode config,
43
+ * e.g. `{"mcpServers": {"playwright": {"command": ["npx", "@modelcontextprotocol/server-playwright"]}}}`.
44
+ */
45
+ install_command: string;
46
+ /** Why this recommendation is relevant to the task. */
47
+ relevance_reason: string;
48
+ /** Relevance score from 0 (irrelevant) to 1 (perfect match). */
49
+ relevance_score: number;
50
+ /** URL to the project's homepage, repository, or package page. */
51
+ source_url?: string;
52
+ /** Agent names that are most likely to benefit from this MCP server. */
53
+ recommended_agents: string[];
54
+ /** Categorisation tags (e.g. 'browser', 'github', 'filesystem'). */
55
+ tags: string[];
56
+ /** Whether the user already has this MCP server installed. */
57
+ already_installed?: boolean;
58
+ }
59
+
60
+ /**
61
+ * The complete output of an MCP discovery request.
62
+ */
63
+ export interface McpDiscoveryOutput {
64
+ /** Ordered list of recommendations, highest relevance first. */
65
+ recommendations: McpRecommendation[];
66
+ /** Whether the result was served from cache. */
67
+ from_cache: boolean;
68
+ /** The search queries that were executed. */
69
+ queries_used: string[];
70
+ }
71
+
72
+ // ── Cache ────────────────────────────────────────────────────────────────────
73
+
74
+ /** Maximum number of cache entries before LRU eviction kicks in. */
75
+ const CACHE_MAX_ENTRIES = 100;
76
+
77
+ /** Cache TTL in milliseconds for MCP results (24 hours). */
78
+ const MCP_CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
79
+
80
+ /** Directory for the discovery cache file. */
81
+ const CACHE_DIR = path.join(os.homedir(), '.config', 'opencode');
82
+
83
+ /** Cache file path. */
84
+ const CACHE_FILE = path.join(CACHE_DIR, 'discovery-cache.json');
85
+
86
+ /** Internal cache entry shape stored on disk. */
87
+ interface CacheEntry {
88
+ /** Serialised output data (McpDiscoveryOutput). */
89
+ data: string;
90
+ /** Unix timestamp (ms) when this entry was written. */
91
+ timestamp: number;
92
+ /**
93
+ * Access-order key for LRU eviction.
94
+ * Lower values are older; bumped to `++nextAccessOrder` on read/write.
95
+ */
96
+ accessOrder: number;
97
+ /** Cache TTL for this entry (ms), set at write time. */
98
+ ttl: number;
99
+ }
100
+
101
+ /** Module-level monotonic access-order counter for LRU tracking. */
102
+ let nextAccessOrder = 0;
103
+
104
+ /**
105
+ * Load and parse the on-disk cache file.
106
+ */
107
+ function loadCacheFile(): Map<string, CacheEntry> {
108
+ try {
109
+ if (!fs.existsSync(CACHE_FILE)) {
110
+ return new Map();
111
+ }
112
+ const raw = fs.readFileSync(CACHE_FILE, 'utf-8');
113
+ const parsed: Record<string, CacheEntry> = JSON.parse(raw) ?? {};
114
+ const map = new Map<string, CacheEntry>();
115
+ for (const [key, entry] of Object.entries(parsed)) {
116
+ map.set(key, entry);
117
+ }
118
+ return map;
119
+ } catch (err) {
120
+ log('[discovery/mcp-servers] failed to load cache file', String(err));
121
+ return new Map();
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Persist the cache map to disk.
127
+ */
128
+ function saveCacheFile(cache: Map<string, CacheEntry>): void {
129
+ try {
130
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
131
+ const obj: Record<string, CacheEntry> = {};
132
+ for (const [key, entry] of cache) {
133
+ obj[key] = entry;
134
+ }
135
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2), 'utf-8');
136
+ } catch (err) {
137
+ log('[discovery/mcp-servers] failed to save cache file', String(err));
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Perform LRU eviction on the cache map when it exceeds {@link CACHE_MAX_ENTRIES}.
143
+ */
144
+ function evictLru(cache: Map<string, CacheEntry>): void {
145
+ if (cache.size <= CACHE_MAX_ENTRIES) return;
146
+
147
+ const sorted = [...cache.entries()].sort(
148
+ ([, a], [, b]) => a.accessOrder - b.accessOrder,
149
+ );
150
+ const toRemove = sorted.slice(0, sorted.length - CACHE_MAX_ENTRIES);
151
+ for (const [key] of toRemove) {
152
+ cache.delete(key);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Build a cache key from a namespace prefix and the search-relevant input fields.
158
+ *
159
+ * Uses an MD5 hex digest of the concatenated keywords and agent name.
160
+ * MD5 is sufficient for cache-key hashing (not security sensitive).
161
+ */
162
+ function buildCacheKey(
163
+ prefix: string,
164
+ taskKeywords: string[],
165
+ agentName: string,
166
+ ): string {
167
+ const raw = `${[...taskKeywords].sort().join(',')}|${agentName}`;
168
+ const hash = createHash('md5').update(raw, 'utf-8').digest('hex');
169
+ return `${prefix}:${hash}`;
170
+ }
171
+
172
+ /**
173
+ * Try to read a valid, non-expired entry from the cache.
174
+ *
175
+ * Returns the data and marks the entry as recently accessed when found.
176
+ */
177
+ function readFromCache<T>(
178
+ cache: Map<string, CacheEntry>,
179
+ key: string,
180
+ ): T | null {
181
+ const entry = cache.get(key);
182
+ if (!entry) return null;
183
+
184
+ const age = Date.now() - entry.timestamp;
185
+ if (age > entry.ttl) {
186
+ cache.delete(key);
187
+ return null;
188
+ }
189
+
190
+ // Bump access order
191
+ entry.accessOrder = ++nextAccessOrder;
192
+ try {
193
+ return JSON.parse(entry.data) as T;
194
+ } catch {
195
+ cache.delete(key);
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Write a result to the in-memory cache and persist to disk.
202
+ */
203
+ function writeToCache<T>(
204
+ cache: Map<string, CacheEntry>,
205
+ key: string,
206
+ data: T,
207
+ ttl: number,
208
+ ): void {
209
+ cache.set(key, {
210
+ data: JSON.stringify(data),
211
+ timestamp: Date.now(),
212
+ accessOrder: ++nextAccessOrder,
213
+ ttl,
214
+ });
215
+ evictLru(cache);
216
+ saveCacheFile(cache);
217
+ }
218
+
219
+ // ── MCP search query construction ───────────────────────────────────────────
220
+
221
+ /**
222
+ * Build an array of MCP-focused search query strings from the task keywords
223
+ * and agent name.
224
+ *
225
+ * Produces 2-4 queries depending on how many keywords are provided.
226
+ */
227
+ function buildMcpSearchQueries(
228
+ keywords: string[],
229
+ agentName: string,
230
+ ): string[] {
231
+ const queries: string[] = [];
232
+
233
+ for (const kw of keywords) {
234
+ queries.push(`opencode MCP server ${kw}`);
235
+ queries.push(`modelcontextprotocol ${kw}`);
236
+ }
237
+
238
+ // Add a general query scoped to the requesting agent
239
+ queries.push(`opencode ${agentName} MCP`);
240
+
241
+ return queries;
242
+ }
243
+
244
+ // ── NPM registry search ─────────────────────────────────────────────────────
245
+
246
+ /** URL of the npm registry search endpoint. */
247
+ const NPM_SEARCH_URL = 'https://registry.npmjs.org/-/v1/search';
248
+
249
+ /** npm search result item shape (subset of the API response). */
250
+ interface NpmSearchObject {
251
+ package: {
252
+ name: string;
253
+ description?: string;
254
+ keywords?: string[];
255
+ version: string;
256
+ links?: {
257
+ npm?: string;
258
+ homepage?: string;
259
+ repository?: string;
260
+ };
261
+ };
262
+ score: {
263
+ final: number;
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Known MCP package name substrings that identify an npm package
269
+ * as an MCP server implementation.
270
+ */
271
+ const KNOWN_MCP_NAMES = [
272
+ 'playwright',
273
+ 'puppeteer',
274
+ 'filesystem',
275
+ 'github',
276
+ 'postgres',
277
+ ] as const;
278
+
279
+ /**
280
+ * Search the npm registry for packages matching a query.
281
+ *
282
+ * Returns the raw search objects; the caller is responsible for filtering
283
+ * and mapping to recommendations.
284
+ */
285
+ async function searchNpm(
286
+ query: string,
287
+ size: number,
288
+ signal?: AbortSignal,
289
+ ): Promise<NpmSearchObject[]> {
290
+ const url = `${NPM_SEARCH_URL}?text=${encodeURIComponent(query)}&size=${Math.min(size, 20)}`;
291
+ try {
292
+ const res = await fetch(url, {
293
+ signal,
294
+ headers: {
295
+ Accept: 'application/vnd.npm.install-v1+json',
296
+ 'User-Agent': 'opencode-dux/1.0',
297
+ },
298
+ });
299
+ if (!res.ok) {
300
+ log(
301
+ `[discovery/mcp-servers] npm search failed (${res.status}) for query: ${query}`,
302
+ );
303
+ return [];
304
+ }
305
+ const body = (await res.json()) as {
306
+ objects?: NpmSearchObject[];
307
+ };
308
+ return body.objects ?? [];
309
+ } catch (err) {
310
+ log('[discovery/mcp-servers] npm search error', String(err));
311
+ return [];
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Determine whether an npm package name looks like a verified MCP server.
317
+ *
318
+ * Only matches verified MCP patterns:
319
+ * - `@modelcontextprotocol/server-*` namespace
320
+ * - `@anthropic/mcp-server-*` namespace
321
+ * - Known MCP package names (playwright, puppeteer, filesystem, github, postgres)
322
+ */
323
+ function isMcpLike(pkg: NpmSearchObject): boolean {
324
+ const name = pkg.package.name.toLowerCase();
325
+
326
+ // Check known namespaces
327
+ if (name.startsWith('@modelcontextprotocol/server-')) return true;
328
+ if (name.startsWith('@anthropic/mcp-server-')) return true;
329
+
330
+ // Check known MCP package names
331
+ for (const known of KNOWN_MCP_NAMES) {
332
+ if (name.includes(known)) return true;
333
+ }
334
+
335
+ return false;
336
+ }
337
+
338
+ // ── Relevance scoring ───────────────────────────────────────────────────────
339
+
340
+ /** Known MCP/skill keywords mapped to descriptive tags. */
341
+ const NAME_TAG_MAP: Record<string, string[]> = {
342
+ playwright: ['browser', 'ui', 'testing'],
343
+ github: ['github', 'git', 'version-control'],
344
+ filesystem: ['filesystem', 'file-ops'],
345
+ websearch: ['web', 'search', 'internet'],
346
+ context7: ['docs', 'search', 'reference'],
347
+ 'grep-app': ['search', 'code', 'grep'],
348
+ ast_grep: ['search', 'code', 'ast'],
349
+ puppeteer: ['browser', 'ui', 'testing'],
350
+ postgres: ['database', 'sql'],
351
+ sqlite: ['database', 'sql', 'embedded'],
352
+ redis: ['cache', 'database'],
353
+ slack: ['communication', 'messaging'],
354
+ discord: ['communication', 'messaging'],
355
+ jira: ['project-management', 'issue-tracking'],
356
+ linear: ['project-management', 'issue-tracking'],
357
+ notion: ['docs', 'knowledge', 'wiki'],
358
+ figma: ['design', 'ui', 'prototyping'],
359
+ sentry: ['monitoring', 'error-tracking'],
360
+ stripe: ['payments', 'billing'],
361
+ };
362
+
363
+ /**
364
+ * Derive tags from a package name, description, and npm keywords.
365
+ */
366
+ function deriveTags(
367
+ name: string,
368
+ _description: string,
369
+ npmKeywords?: string[],
370
+ ): string[] {
371
+ const lower = name.toLowerCase();
372
+ const tags: string[] = [];
373
+
374
+ for (const [key, mapped] of Object.entries(NAME_TAG_MAP)) {
375
+ if (lower.includes(key)) {
376
+ tags.push(...mapped);
377
+ }
378
+ }
379
+
380
+ if (npmKeywords) {
381
+ for (const kw of npmKeywords) {
382
+ const lowerKw = kw.toLowerCase().replace(/\s+/g, '-');
383
+ if (!tags.includes(lowerKw)) {
384
+ tags.push(lowerKw);
385
+ }
386
+ }
387
+ }
388
+
389
+ return [...new Set(tags)];
390
+ }
391
+
392
+ /**
393
+ * Score how relevant a recommendation is to the task context.
394
+ *
395
+ * Examines keyword overlap, name-tag matches, and the npm score (when available)
396
+ * to produce a value in the [0, 1] range.
397
+ */
398
+ function scoreRelevance(
399
+ name: string,
400
+ description: string,
401
+ tags: string[],
402
+ keywords: string[],
403
+ npmScore?: number,
404
+ ): number {
405
+ let score = 0;
406
+
407
+ const nameLower = name.toLowerCase();
408
+ const descLower = description.toLowerCase();
409
+ const tagsLower = tags.map((t) => t.toLowerCase());
410
+ const allText = `${nameLower} ${descLower} ${tagsLower.join(' ')}`;
411
+
412
+ for (const kw of keywords) {
413
+ const kwLower = kw.toLowerCase();
414
+ if (nameLower.includes(kwLower)) {
415
+ score += 0.35;
416
+ }
417
+ if (descLower.includes(kwLower)) {
418
+ score += 0.2;
419
+ }
420
+ if (tagsLower.includes(kwLower)) {
421
+ score += 0.15;
422
+ }
423
+ }
424
+
425
+ if (typeof npmScore === 'number' && npmScore > 0) {
426
+ score += npmScore * 0.2;
427
+ }
428
+
429
+ const matchedKeywords = keywords.filter((kw) =>
430
+ allText.includes(kw.toLowerCase()),
431
+ ).length;
432
+ if (keywords.length > 0) {
433
+ score += (matchedKeywords / keywords.length) * 0.1;
434
+ }
435
+
436
+ return Math.min(score, 1);
437
+ }
438
+
439
+ /**
440
+ * Determine which agents would benefit most from an item based on its tags.
441
+ */
442
+ function deriveRecommendedAgents(
443
+ type: 'mcp' | 'skill',
444
+ tags: string[],
445
+ ): string[] {
446
+ const agents: string[] = [];
447
+
448
+ if (type === 'mcp') {
449
+ agents.push(...SUBAGENT_NAMES);
450
+ }
451
+
452
+ const tagSet = new Set(tags.map((t) => t.toLowerCase()));
453
+
454
+ if (tagSet.has('browser') || tagSet.has('ui') || tagSet.has('testing')) {
455
+ agents.push('explorer');
456
+ }
457
+ if (tagSet.has('search') || tagSet.has('docs') || tagSet.has('reference')) {
458
+ agents.push('librarian');
459
+ }
460
+ if (tagSet.has('database') || tagSet.has('sql')) {
461
+ agents.push('oracle');
462
+ }
463
+ if (tagSet.has('design') || tagSet.has('prototyping')) {
464
+ agents.push('designer');
465
+ }
466
+
467
+ return [...new Set(agents)];
468
+ }
469
+
470
+ /**
471
+ * Build a human-readable reason string explaining why a package is relevant.
472
+ */
473
+ function buildRelevanceReason(
474
+ name: string,
475
+ description: string,
476
+ tags: string[],
477
+ keywords: string[],
478
+ ): string {
479
+ const matchedKeywords = keywords.filter(
480
+ (kw) =>
481
+ name.toLowerCase().includes(kw.toLowerCase()) ||
482
+ description.toLowerCase().includes(kw.toLowerCase()) ||
483
+ tags.some((t) => t.includes(kw.toLowerCase())),
484
+ );
485
+
486
+ if (matchedKeywords.length > 0) {
487
+ return `Matches keywords: ${matchedKeywords.join(', ')}`;
488
+ }
489
+ return 'Found in search results for task context';
490
+ }
491
+
492
+ /**
493
+ * Deduplicate recommendations by case-insensitive name, keeping the one with
494
+ * the higher relevance score.
495
+ */
496
+ function deduplicateByName<T extends { name: string; relevance_score: number }>(
497
+ items: T[],
498
+ ): T[] {
499
+ const map = new Map<string, T>();
500
+ for (const item of items) {
501
+ const key = item.name.toLowerCase();
502
+ const existing = map.get(key);
503
+ if (!existing || item.relevance_score > existing.relevance_score) {
504
+ map.set(key, item);
505
+ }
506
+ }
507
+ return [...map.values()];
508
+ }
509
+
510
+ // ── MCP helpers ─────────────────────────────────────────────────────────────
511
+
512
+ /**
513
+ * Extract a short server name from an MCP package name.
514
+ *
515
+ * Examples:
516
+ * - `@modelcontextprotocol/server-playwright` → `playwright`
517
+ * - `@anthropic/mcp-server-playwright` → `playwright`
518
+ */
519
+ function extractMcpServerName(packageName: string): string {
520
+ const lower = packageName.toLowerCase();
521
+ if (lower.startsWith('@modelcontextprotocol/server-')) {
522
+ return packageName.split('/')[1].replace('server-', '');
523
+ }
524
+ if (lower.startsWith('@anthropic/mcp-server-')) {
525
+ return packageName.split('/')[1].replace('mcp-server-', '');
526
+ }
527
+ // For non-standard MCP package names, use the last path segment
528
+ const last = packageName.split('/').pop() ?? packageName;
529
+ // Strip common prefixes
530
+ return last.replace(/^mcp-server-/, '').replace(/^server-/, '');
531
+ }
532
+
533
+ /**
534
+ * Build a ready-to-use mcpServers JSON config block for an MCP package.
535
+ *
536
+ * Returns a JSON string like:
537
+ * ```json
538
+ * {"mcpServers": {"playwright": {"command": ["npx", "@modelcontextprotocol/server-playwright"]}}}
539
+ * ```
540
+ */
541
+ function buildMcpInstallCommand(packageName: string): string {
542
+ const serverName = extractMcpServerName(packageName);
543
+ const config = {
544
+ mcpServers: {
545
+ [serverName]: {
546
+ command: ['npx', packageName],
547
+ },
548
+ },
549
+ };
550
+ return JSON.stringify(config);
551
+ }
552
+
553
+ /**
554
+ * Derive a source URL from a package's npm metadata.
555
+ */
556
+ function deriveSourceUrl(pkg: NpmSearchObject): string | undefined {
557
+ const links = pkg.package.links;
558
+ return links?.homepage ?? links?.repository ?? links?.npm;
559
+ }
560
+
561
+ /**
562
+ * Mark and filter MCP recommendations against already-installed MCP servers.
563
+ *
564
+ * For recommendations matching an installed MCP:
565
+ * - Mark them as `already_installed: true`
566
+ * - Only include them if the relevance score is > 0.8 (significantly better
567
+ * than the installed default, meaning this MCP is highly relevant to the
568
+ * current task)
569
+ *
570
+ * Non-installed recommendations pass through unchanged.
571
+ */
572
+ function filterExistingMcps(
573
+ recommendations: McpRecommendation[],
574
+ existingNames: string[] | undefined,
575
+ ): McpRecommendation[] {
576
+ if (!existingNames || existingNames.length === 0) return recommendations;
577
+
578
+ const installed = new Set(existingNames.map((n) => n.toLowerCase()));
579
+
580
+ return recommendations
581
+ .map((rec) => {
582
+ const lower = rec.name.toLowerCase();
583
+ if (installed.has(lower)) {
584
+ return { ...rec, already_installed: true };
585
+ }
586
+ return rec;
587
+ })
588
+ .filter((rec) => {
589
+ // For already-installed items, only show if relevance_score > 0.8
590
+ // (significantly better enough to mention despite being installed)
591
+ if (rec.already_installed) {
592
+ return rec.relevance_score > 0.8;
593
+ }
594
+ return true;
595
+ });
596
+ }
597
+
598
+ // ── discoverMcpServers ──────────────────────────────────────────────────────
599
+
600
+ /**
601
+ * Run the full MCP discovery flow for a given set of inputs.
602
+ *
603
+ * 1. Builds search queries from keywords and agent name
604
+ * 2. Searches the npm registry for matching MCP packages
605
+ * 3. Maps results to recommendations with relevance scores
606
+ * 4. Marks already-installed items with `already_installed: true` and
607
+ * only includes them when relevance_score > 0.8
608
+ * 5. Returns the top N results
609
+ *
610
+ * Results are cached on disk for 24 hours.
611
+ */
612
+ export async function discoverMcpServers(
613
+ input: McpDiscoveryInput,
614
+ ctx: PluginInput,
615
+ ): Promise<McpDiscoveryOutput> {
616
+ const maxResults = input.max_results ?? 5;
617
+ const queries = buildMcpSearchQueries(input.task_keywords, input.agent_name);
618
+
619
+ // Auto-discover installed MCPs if caller didn't provide existing names
620
+ let existingMcpNames = input.existing_mcp_names;
621
+ if (!existingMcpNames || existingMcpNames.length === 0) {
622
+ try {
623
+ const local = await getLocalDiscovery(ctx);
624
+ existingMcpNames = local.mcps.map((m) => m.name);
625
+ } catch {
626
+ // Best-effort – skip auto-discovery if SDK call fails
627
+ existingMcpNames = [];
628
+ }
629
+ }
630
+ const allRecommendations: McpRecommendation[] = [];
631
+ const seenNames = new Set<string>();
632
+
633
+ const concurrencyLimit = 3;
634
+ const queryBatches: string[][] = [];
635
+ for (let i = 0; i < queries.length; i += concurrencyLimit) {
636
+ queryBatches.push(queries.slice(i, i + concurrencyLimit));
637
+ }
638
+
639
+ for (const batch of queryBatches) {
640
+ const batchResults = await Promise.all(batch.map((q) => searchNpm(q, 5)));
641
+
642
+ for (let i = 0; i < batch.length; i++) {
643
+ const results = batchResults[i];
644
+
645
+ for (const obj of results) {
646
+ const pkgName = obj.package.name;
647
+
648
+ if (seenNames.has(pkgName.toLowerCase())) continue;
649
+ seenNames.add(pkgName.toLowerCase());
650
+
651
+ // Only include verified MCP packages
652
+ if (!isMcpLike(obj)) continue;
653
+
654
+ const description = obj.package.description ?? '';
655
+ const npmKeywords = obj.package.keywords;
656
+ const tags = deriveTags(pkgName, description, npmKeywords);
657
+ const recommendedAgents = deriveRecommendedAgents('mcp', tags);
658
+ const relevanceScore = scoreRelevance(
659
+ pkgName,
660
+ description,
661
+ tags,
662
+ input.task_keywords,
663
+ obj.score.final,
664
+ );
665
+
666
+ if (relevanceScore < 0.05) continue;
667
+
668
+ allRecommendations.push({
669
+ type: 'mcp',
670
+ name: pkgName,
671
+ description,
672
+ install_command: buildMcpInstallCommand(pkgName),
673
+ relevance_reason: buildRelevanceReason(
674
+ pkgName,
675
+ description,
676
+ tags,
677
+ input.task_keywords,
678
+ ),
679
+ relevance_score: relevanceScore,
680
+ source_url: deriveSourceUrl(obj),
681
+ recommended_agents: recommendedAgents,
682
+ tags,
683
+ });
684
+ }
685
+ }
686
+ }
687
+
688
+ const unique = deduplicateByName(allRecommendations);
689
+ unique.sort((a, b) => b.relevance_score - a.relevance_score);
690
+
691
+ const filtered = filterExistingMcps(unique, existingMcpNames);
692
+
693
+ const recommendations = filtered.slice(0, maxResults);
694
+
695
+ return {
696
+ recommendations,
697
+ from_cache: false,
698
+ queries_used: queries,
699
+ };
700
+ }
701
+
702
+ // ── Tool factory ────────────────────────────────────────────────────────────
703
+
704
+ const z = tool.schema;
705
+
706
+ /**
707
+ * Create the `discover_mcp_servers` tool that subagents can call to find
708
+ * installable MCP servers for a given task.
709
+ *
710
+ * The tool:
711
+ * 1. Builds search queries from task keywords and agent name
712
+ * 2. Searches the npm registry for verified MCP packages
713
+ * 3. Scores each result by relevance (0-1)
714
+ * 4. Filters out MCP servers the user already has installed
715
+ * 5. Returns the top N recommendations with ready-to-use mcpServers JSON config
716
+ *
717
+ * Results are cached on disk at `~/.config/opencode/discovery-cache.json`
718
+ * with a 24-hour TTL and LRU eviction (max 100 entries).
719
+ *
720
+ * @param ctx - The OpenCode plugin input (provides client for SDK access)
721
+ * @returns A `ToolDefinition` ready for registration in the plugin's tool hook
722
+ */
723
+ export function createDiscoverMcpServersTool(ctx: PluginInput): ToolDefinition {
724
+ const cache = loadCacheFile();
725
+ const cachePrefix = 'mcp';
726
+
727
+ return tool({
728
+ description:
729
+ 'Search online for popular MCP (Model Context Protocol) servers ' +
730
+ 'that could be installed to help with a given task. ' +
731
+ 'Use this when a subagent lacks the capabilities it needs and you ' +
732
+ 'want to discover what external MCP servers are available. ' +
733
+ 'If existing_mcp_names is not provided, automatically discovers ' +
734
+ "what's already installed and filters recommendations accordingly. " +
735
+ 'Already-installed MCPs are shown only when relevance_score > 0.8 ' +
736
+ '(significantly better). ' +
737
+ 'Returns recommendations with ready-to-use mcpServers config blocks. ' +
738
+ 'Results are cached for 24 hours.',
739
+ args: {
740
+ task_description: z
741
+ .string()
742
+ .describe(
743
+ 'Natural language description of the task the subagent needs help with',
744
+ ),
745
+ task_keywords: z
746
+ .array(z.string())
747
+ .describe(
748
+ 'Keywords characterising the task (used to build search queries)',
749
+ ),
750
+ agent_name: z
751
+ .string()
752
+ .describe('The subagent name requesting the discovery'),
753
+ existing_mcp_names: z
754
+ .array(z.string())
755
+ .optional()
756
+ .describe(
757
+ 'MCP server names already installed (to avoid duplicate recommendations). ' +
758
+ 'If not provided, auto-detects installed MCPs.',
759
+ ),
760
+ max_results: z
761
+ .number()
762
+ .min(1)
763
+ .max(20)
764
+ .default(5)
765
+ .describe('Maximum number of recommendations to return'),
766
+ },
767
+ execute: async (args, toolCtx) => {
768
+ const taskKeywords = args.task_keywords ?? [];
769
+ const agentName = args.agent_name ?? '';
770
+ const maxResults = args.max_results ?? 5;
771
+ const existingNames = args.existing_mcp_names ?? undefined;
772
+ const projectDir = toolCtx.directory || ctx.directory;
773
+
774
+ const cacheKey = buildCacheKey(cachePrefix, taskKeywords, agentName);
775
+
776
+ const cached = readFromCache<McpDiscoveryOutput>(cache, cacheKey);
777
+ if (cached) {
778
+ log('[discovery/mcp-servers] cache hit for', cacheKey);
779
+ return JSON.stringify({
780
+ ...cached,
781
+ from_cache: true,
782
+ recommendations: cached.recommendations.slice(0, maxResults),
783
+ });
784
+ }
785
+
786
+ log(`[discovery/mcp-servers] cache miss for project: ${projectDir}`);
787
+
788
+ const output = await discoverMcpServers(
789
+ {
790
+ task_description: args.task_description ?? '',
791
+ task_keywords: taskKeywords,
792
+ agent_name: agentName,
793
+ existing_mcp_names: existingNames,
794
+ max_results: maxResults,
795
+ },
796
+ ctx,
797
+ );
798
+
799
+ writeToCache(cache, cacheKey, output, MCP_CACHE_TTL_MS);
800
+
801
+ return JSON.stringify(output);
802
+ },
803
+ });
804
+ }