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,332 @@
1
+ import type { PluginInput } from '@opencode-ai/plugin';
2
+ import type {
3
+ AgentOverrideConfig,
4
+ ModelEntry,
5
+ PluginConfig,
6
+ Preset,
7
+ } from '../config';
8
+ import { AGENT_ALIASES } from '../config/constants';
9
+ import {
10
+ getActiveRuntimePreset,
11
+ rollbackRuntimePreset,
12
+ setActiveRuntimePresetWithPrevious,
13
+ } from '../config/runtime-preset';
14
+
15
+ import { createInternalAgentTextPart } from '../utils';
16
+
17
+ const COMMAND_NAME = 'preset';
18
+
19
+ /**
20
+ * Creates a preset manager for the /preset slash command.
21
+ *
22
+ * Uses the OpenCode SDK's client.config.update() to change agent models
23
+ * and temperatures without restarting. The server invalidates its agent
24
+ * cache and re-reads config on the next prompt.
25
+ *
26
+ * Note: activePreset is tracked in-memory only and resets on plugin reload.
27
+ * If the user manually edits config or another mechanism changes agents,
28
+ * this tracker may become stale until the next /preset call.
29
+ */
30
+ export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
31
+ // Sync from module-level state in case of plugin re-init - the runtime
32
+ // preset persists across dispose()/re-init cycles.
33
+ let activePreset: string | null =
34
+ getActiveRuntimePreset() ?? config.preset ?? null;
35
+
36
+ /**
37
+ * Handle the /preset command from command.execute.before hook.
38
+ *
39
+ * - No arguments: list available presets
40
+ * - With argument: switch to the named preset
41
+ */
42
+ async function handleCommandExecuteBefore(
43
+ input: {
44
+ command: string;
45
+ sessionID: string;
46
+ arguments: string;
47
+ },
48
+ output: { parts: Array<{ type: string; text?: string }> },
49
+ ): Promise<void> {
50
+ if (input.command !== COMMAND_NAME) {
51
+ return;
52
+ }
53
+
54
+ // Clear the template so OpenCode doesn't send it to the LLM
55
+ output.parts.length = 0;
56
+
57
+ const arg = input.arguments.trim();
58
+ const presets = config.presets ?? {};
59
+
60
+ if (!arg) {
61
+ // List available presets
62
+ output.parts.push(createInternalAgentTextPart(formatPresetList(presets)));
63
+ return;
64
+ }
65
+
66
+ // Guard against multi-word arguments
67
+ if (/\s/.test(arg)) {
68
+ const suggestion = arg.split(/\s+/)[0];
69
+ output.parts.push(
70
+ createInternalAgentTextPart(
71
+ `Preset names cannot contain spaces. Did you mean: /preset ${suggestion}?`,
72
+ ),
73
+ );
74
+ return;
75
+ }
76
+
77
+ // Switch to named preset
78
+ await switchPreset(arg, presets, output);
79
+ }
80
+
81
+ /**
82
+ * Register the /preset command in the OpenCode config.
83
+ */
84
+ function registerCommand(opencodeConfig: Record<string, unknown>): void {
85
+ const configCommand = opencodeConfig.command as
86
+ | Record<string, unknown>
87
+ | undefined;
88
+ if (!configCommand?.[COMMAND_NAME]) {
89
+ if (!opencodeConfig.command) {
90
+ opencodeConfig.command = {};
91
+ }
92
+ (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
93
+ template: 'List available presets and switch between them',
94
+ description:
95
+ 'Switch agent presets at runtime (e.g., /preset cheap, /preset powerful)',
96
+ };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Switch to the given preset name by calling client.config.update().
102
+ */
103
+ async function switchPreset(
104
+ presetName: string,
105
+ presets: Record<string, Preset>,
106
+ output: { parts: Array<{ type: string; text?: string }> },
107
+ ): Promise<void> {
108
+ const preset = presets[presetName];
109
+ if (!preset) {
110
+ const available = Object.keys(presets);
111
+ const hint =
112
+ available.length > 0
113
+ ? `Available presets: ${available.join(', ')}`
114
+ : 'No presets configured. Define presets in opencode-dux.jsonc.';
115
+ output.parts.push(
116
+ createInternalAgentTextPart(
117
+ `Preset "${presetName}" not found. ${hint}`,
118
+ ),
119
+ );
120
+ return;
121
+ }
122
+
123
+ // Build the agent config overrides from the preset.
124
+ // Each preset value is { agentName: AgentOverrideConfig }.
125
+ // We need to convert to SDK AgentConfig format:
126
+ // { agent: { agentName: { model, temperature, ... } } }
127
+ const agentUpdates: Record<
128
+ string,
129
+ {
130
+ model?: string;
131
+ temperature?: number;
132
+ variant?: string;
133
+ options?: Record<string, unknown>;
134
+ }
135
+ > = {};
136
+ for (const [agentName, override] of Object.entries(preset)) {
137
+ const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
138
+ const agentConfig = mapOverrideToAgentConfig(override);
139
+ if (Object.keys(agentConfig).length > 0) {
140
+ agentUpdates[resolvedName] = agentConfig;
141
+ }
142
+ }
143
+
144
+ // Build reset updates for agents in the old preset but not the new one.
145
+ // The SDK accumulates client.config.update() calls, so switching from
146
+ // Preset A to Preset B leaks A's variant/temperature/options on agents
147
+ // that aren't in B. Reset them to the config-file baseline values.
148
+ const currentRuntimePreset = getActiveRuntimePreset();
149
+ const resetUpdates: Record<
150
+ string,
151
+ {
152
+ model?: string;
153
+ temperature?: number;
154
+ variant?: string;
155
+ options?: Record<string, unknown>;
156
+ }
157
+ > = {};
158
+ if (currentRuntimePreset && config.presets?.[currentRuntimePreset]) {
159
+ const oldPreset = config.presets[currentRuntimePreset];
160
+ for (const rawName of Object.keys(oldPreset)) {
161
+ const resolvedOld = AGENT_ALIASES[rawName] ?? rawName;
162
+ if (resolvedOld in agentUpdates) continue; // new preset handles this agent
163
+ const baseline = config.agents?.[resolvedOld];
164
+ if (baseline) {
165
+ // Note: mapOverrideToAgentConfig(baseline) only emits fields
166
+ // the baseline defines. Scalar fields (variant/temperature/options)
167
+ // not in baseline are NOT cleared here. The config() hook in
168
+ // src/index.ts handles complete cleanup using the previous
169
+ // preset's override keys to drive deletion.
170
+ resetUpdates[resolvedOld] = mapOverrideToAgentConfig(baseline);
171
+ }
172
+ }
173
+ }
174
+
175
+ const hasAgentUpdates = Object.keys(agentUpdates).length > 0;
176
+ const allUpdates = { ...resetUpdates, ...agentUpdates };
177
+ if (!hasAgentUpdates) {
178
+ output.parts.push(
179
+ createInternalAgentTextPart(
180
+ `Preset "${presetName}" is empty (no agent overrides defined).`,
181
+ ),
182
+ );
183
+ return;
184
+ }
185
+
186
+ const previousPreset = activePreset;
187
+ setActiveRuntimePresetWithPrevious(presetName);
188
+
189
+ try {
190
+ await ctx.client.config.update({
191
+ body: { agent: allUpdates },
192
+ });
193
+
194
+ activePreset = presetName;
195
+
196
+ const summaryParts: string[] = [];
197
+ for (const [name, cfg] of Object.entries(agentUpdates)) {
198
+ const parts: string[] = [name];
199
+ if (cfg.model) parts.push(`model: ${cfg.model}`);
200
+ if (cfg.variant) parts.push(`variant: ${cfg.variant}`);
201
+ if (cfg.temperature !== undefined)
202
+ parts.push(`temp: ${cfg.temperature}`);
203
+ if (cfg.options) parts.push('options: yes');
204
+ summaryParts.push(parts.join(' → '));
205
+ }
206
+ if (Object.keys(resetUpdates).length > 0) {
207
+ summaryParts.push(
208
+ `Reset to baseline: ${Object.keys(resetUpdates).join(', ')}`,
209
+ );
210
+ }
211
+
212
+ output.parts.push(
213
+ createInternalAgentTextPart(
214
+ `Switched to preset "${presetName}":\n${summaryParts.join('\n')}`,
215
+ ),
216
+ );
217
+ } catch (err) {
218
+ rollbackRuntimePreset(previousPreset);
219
+ output.parts.push(
220
+ createInternalAgentTextPart(
221
+ `Failed to switch preset "${presetName}": ${String(err)}`,
222
+ ),
223
+ );
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Map an AgentOverrideConfig (from plugin config) to the subset of
229
+ * SDK AgentConfig fields that client.config.update() can apply at runtime.
230
+ *
231
+ * Excluded fields and why:
232
+ * - prompt: resolved at init by config() hook, not part of SDK AgentConfig
233
+ * - skills: plugin-level concern, not part of SDK AgentConfig
234
+ * - displayName: plugin-level concern, not part of SDK AgentConfig
235
+ */
236
+ function mapOverrideToAgentConfig(override: AgentOverrideConfig): {
237
+ model?: string;
238
+ temperature?: number;
239
+ variant?: string;
240
+ options?: Record<string, unknown>;
241
+ } {
242
+ const agentConfig: {
243
+ model?: string;
244
+ temperature?: number;
245
+ variant?: string;
246
+ options?: Record<string, unknown>;
247
+ } = {};
248
+
249
+ if (typeof override.model === 'string') {
250
+ agentConfig.model = override.model;
251
+ } else if (Array.isArray(override.model) && override.model.length > 0) {
252
+ // Array-form model (fallback chain): pick the first entry.
253
+ // The full chain resolution only happens at init time via config() hook,
254
+ // so at runtime we use the primary model from the array.
255
+ const first = override.model[0];
256
+ agentConfig.model = typeof first === 'string' ? first : first.id;
257
+ if (typeof first !== 'string' && first.variant) {
258
+ agentConfig.variant = first.variant;
259
+ }
260
+ }
261
+
262
+ if (typeof override.temperature === 'number') {
263
+ agentConfig.temperature = override.temperature;
264
+ }
265
+
266
+ if (typeof override.variant === 'string') {
267
+ agentConfig.variant = override.variant;
268
+ }
269
+
270
+ if (
271
+ override.options &&
272
+ typeof override.options === 'object' &&
273
+ !Array.isArray(override.options)
274
+ ) {
275
+ agentConfig.options = override.options;
276
+ }
277
+
278
+ return agentConfig;
279
+ }
280
+
281
+ /**
282
+ * Format the list of available presets with the active one highlighted.
283
+ */
284
+ function formatPresetList(presets: Record<string, Preset>): string {
285
+ const names = Object.keys(presets);
286
+ if (names.length === 0) {
287
+ return 'No presets configured. Define presets in opencode-dux.jsonc under the "presets" field.';
288
+ }
289
+
290
+ const lines = ['Available presets:'];
291
+ for (const name of names) {
292
+ const marker = name === activePreset ? ' ← active' : '';
293
+ const preset = presets[name];
294
+ const agentNames = Object.keys(preset);
295
+ const models = agentNames
296
+ .map((a) => {
297
+ const cfg = preset[a];
298
+ const modelStr =
299
+ typeof cfg.model === 'string'
300
+ ? cfg.model
301
+ : Array.isArray(cfg.model) && cfg.model.length > 0
302
+ ? resolveFirstModel(cfg.model)
303
+ : undefined;
304
+ return modelStr ? ` ${a} → ${modelStr}` : ` ${a}`;
305
+ })
306
+ .join('\n');
307
+ lines.push(` ${name}${marker}`);
308
+ lines.push(models);
309
+ }
310
+ lines.push('\nUsage: /preset <name> to switch.');
311
+
312
+ return lines.join('\n');
313
+ }
314
+
315
+ /**
316
+ * Resolve the first model from an array-form model entry.
317
+ */
318
+ function resolveFirstModel(
319
+ models: Array<string | ModelEntry>,
320
+ ): string | undefined {
321
+ if (models.length === 0) return undefined;
322
+ const first = models[0];
323
+ return typeof first === 'string' ? first : first.id;
324
+ }
325
+
326
+ return {
327
+ handleCommandExecuteBefore,
328
+ registerCommand,
329
+ };
330
+ }
331
+
332
+ export type PresetManager = ReturnType<typeof createPresetManager>;
@@ -0,0 +1,58 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { BinaryFetch } from './types';
4
+
5
+ function extensionForMime(contentType: string) {
6
+ const mime = contentType.split(';')[0]?.trim().toLowerCase();
7
+ const map: Record<string, string> = {
8
+ 'application/pdf': 'pdf',
9
+ 'image/png': 'png',
10
+ 'image/jpeg': 'jpg',
11
+ 'image/webp': 'webp',
12
+ 'application/zip': 'zip',
13
+ };
14
+ return map[mime] || 'bin';
15
+ }
16
+
17
+ export function buildBinaryResultMessage(
18
+ fetchResult: BinaryFetch,
19
+ savedPath?: string,
20
+ ) {
21
+ const subject = fetchResult.binaryKind.toUpperCase();
22
+ if (savedPath) return `${subject} content saved to ${savedPath}`;
23
+ return `${subject} content omitted because it exceeds the download limit.`;
24
+ }
25
+
26
+ export async function saveBinary(
27
+ binaryDir: string,
28
+ data: Uint8Array,
29
+ contentType: string,
30
+ filename?: string,
31
+ ) {
32
+ await mkdir(binaryDir, { recursive: true });
33
+ const initialName =
34
+ filename || `webfetch-${Date.now()}.${extensionForMime(contentType)}`;
35
+ const parsed = path.parse(initialName);
36
+ for (let attempt = 0; attempt < 1000; attempt++) {
37
+ const candidateName =
38
+ attempt === 0
39
+ ? initialName
40
+ : `${parsed.name}-${attempt}${parsed.ext || `.${extensionForMime(contentType)}`}`;
41
+ const file = path.join(binaryDir, candidateName);
42
+ try {
43
+ await writeFile(file, data, { flag: 'wx' });
44
+ return file;
45
+ } catch (error: unknown) {
46
+ if (
47
+ typeof error === 'object' &&
48
+ error &&
49
+ 'code' in error &&
50
+ error.code === 'EEXIST'
51
+ ) {
52
+ continue;
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+ throw new Error('Unable to allocate unique filename for binary content');
58
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { buildCacheKey } from './cache';
3
+
4
+ describe('smartfetch/cache', () => {
5
+ test('includes save_binary but not format in the cache key', () => {
6
+ const markdownKey = buildCacheKey(
7
+ 'https://example.com/docs',
8
+ true,
9
+ 'auto',
10
+ false,
11
+ );
12
+ const htmlKey = buildCacheKey(
13
+ 'https://example.com/docs',
14
+ true,
15
+ 'auto',
16
+ false,
17
+ );
18
+ const binaryKey = buildCacheKey(
19
+ 'https://example.com/docs',
20
+ true,
21
+ 'auto',
22
+ true,
23
+ );
24
+
25
+ expect(markdownKey).toBe(htmlKey);
26
+ expect(markdownKey).not.toBe(binaryKey);
27
+ expect(JSON.parse(markdownKey)).toMatchObject({
28
+ saveBinary: false,
29
+ });
30
+ expect(JSON.parse(binaryKey)).toMatchObject({
31
+ saveBinary: true,
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,112 @@
1
+ import { LRUCache } from 'lru-cache';
2
+ import { canUseCanonicalCacheAlias, isHtmlLikeContentType } from './network';
3
+ import type { FetchResult } from './types';
4
+
5
+ export const CACHE = new LRUCache<string, FetchResult>({
6
+ maxSize: 50 * 1024 * 1024,
7
+ ttl: 15 * 60 * 1000,
8
+ sizeCalculation: (value: FetchResult) => {
9
+ if ('binary' in value) return value.data?.byteLength ?? 1024;
10
+ const rawContent =
11
+ value.rawContent ?? value.html ?? value.markdown ?? value.text ?? '';
12
+ return (
13
+ Buffer.byteLength(rawContent) +
14
+ Buffer.byteLength(value.html) +
15
+ Buffer.byteLength(value.markdown) +
16
+ Buffer.byteLength(value.text)
17
+ );
18
+ },
19
+ });
20
+
21
+ export function buildCacheKey(
22
+ url: string,
23
+ extractMain: boolean,
24
+ preferLlmsTxt: 'auto' | 'always' | 'never',
25
+ saveBinary: boolean,
26
+ ) {
27
+ const parsed = new URL(url);
28
+ return JSON.stringify({
29
+ url: parsed.toString(),
30
+ extractMain,
31
+ preferLlmsTxt,
32
+ saveBinary,
33
+ });
34
+ }
35
+
36
+ function cacheKeysFor(
37
+ fetchResult: FetchResult,
38
+ extractMain: boolean,
39
+ preferLlmsTxt: 'auto' | 'always' | 'never',
40
+ saveBinary: boolean,
41
+ ) {
42
+ const keys = new Set<string>();
43
+ keys.add(
44
+ buildCacheKey(
45
+ fetchResult.requestedUrl,
46
+ extractMain,
47
+ preferLlmsTxt,
48
+ saveBinary,
49
+ ),
50
+ );
51
+ keys.add(
52
+ buildCacheKey(fetchResult.finalUrl, extractMain, preferLlmsTxt, saveBinary),
53
+ );
54
+ if (
55
+ fetchResult.canonicalUrl &&
56
+ canUseCanonicalCacheAlias(fetchResult.finalUrl, fetchResult.canonicalUrl)
57
+ ) {
58
+ keys.add(
59
+ buildCacheKey(
60
+ fetchResult.canonicalUrl,
61
+ extractMain,
62
+ preferLlmsTxt,
63
+ saveBinary,
64
+ ),
65
+ );
66
+ }
67
+ return [...keys];
68
+ }
69
+
70
+ export function cacheFetchResult(
71
+ fetchResult: FetchResult,
72
+ extractMain: boolean,
73
+ preferLlmsTxt: 'auto' | 'always' | 'never',
74
+ saveBinary: boolean,
75
+ ) {
76
+ for (const key of cacheKeysFor(
77
+ fetchResult,
78
+ extractMain,
79
+ preferLlmsTxt,
80
+ saveBinary,
81
+ )) {
82
+ CACHE.set(key, fetchResult);
83
+ }
84
+ }
85
+
86
+ export function isInvalidLlmsResult(fetchResult: FetchResult | undefined) {
87
+ if (!fetchResult || 'binary' in fetchResult) return false;
88
+ if (!fetchResult.usedLlmsTxt || fetchResult.sourceKind !== 'llms_txt') {
89
+ return false;
90
+ }
91
+ const finalPath = (() => {
92
+ try {
93
+ return new URL(fetchResult.finalUrl).pathname.toLowerCase();
94
+ } catch {
95
+ return '';
96
+ }
97
+ })();
98
+ if (
99
+ !(finalPath.endsWith('/llms.txt') || finalPath.endsWith('/llms-full.txt'))
100
+ ) {
101
+ return true;
102
+ }
103
+ if (isHtmlLikeContentType(fetchResult.contentType)) return true;
104
+ if (/^\s*(<!doctype html|<html\b)/i.test(fetchResult.rawContent)) return true;
105
+ if (
106
+ /<title>\s*(log in|sign in|login)\b/i.test(fetchResult.rawContent) ||
107
+ /\blog[ -]?in\b/i.test(fetchResult.finalUrl)
108
+ ) {
109
+ return true;
110
+ }
111
+ return false;
112
+ }
@@ -0,0 +1,29 @@
1
+ export const DOCS_HOST_SUFFIXES = [
2
+ '.readthedocs.io',
3
+ '.readthedocs.org',
4
+ '.gitbook.io',
5
+ '.netlify.app',
6
+ '.vercel.app',
7
+ 'docs.rs',
8
+ ];
9
+
10
+ export const DOCS_HOST_PREFIXES = ['docs.', 'developer.', 'dev.', 'wiki.'];
11
+ export const MAX_REDIRECTS = 10;
12
+ export const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
13
+ export const MAX_BINARY_DOWNLOAD_BYTES = 2 * 1024 * 1024;
14
+ export const DEFAULT_TIMEOUT_SECONDS = 30;
15
+ export const MAX_TIMEOUT_SECONDS = 120;
16
+ export const MAX_LLMS_PROBE_TIMEOUT_MS = 8000;
17
+ export const MAX_MODEL_CONTENT_CHARS = 100_000;
18
+ export const DEFAULT_ACCEPT_LANGUAGE = 'en;q=0.8,*;q=0.5';
19
+ export const BINARY_PREFIXES = [
20
+ 'image/',
21
+ 'audio/',
22
+ 'video/',
23
+ 'application/pdf',
24
+ 'application/zip',
25
+ 'application/octet-stream',
26
+ ];
27
+
28
+ export const WEBFETCH_DESCRIPTION =
29
+ 'Fetch a URL with better extraction for static/docs pages. Supports llms.txt probing, content-focused HTML extraction, metadata, redirects, and an optional prompt processed by a cheap secondary model.';
@@ -0,0 +1,8 @@
1
+ export { WEBFETCH_DESCRIPTION } from './constants';
2
+ export { createWebfetchTool } from './tool';
3
+ export type {
4
+ BinaryFetch,
5
+ CachedFetch,
6
+ FetchResult,
7
+ SmartfetchOptions,
8
+ } from './types';