@openchambery/web 1.18.2-beta.2 → 1.18.2-beta.21

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 (148) hide show
  1. package/dist/assets/{AgentAvatar-B2kZjgaf.js → AgentAvatar-B4fj9PIe.js} +1 -1
  2. package/dist/assets/{AssistantView-DvoyOGkx.js → AssistantView-C00aiEHK.js} +1 -1
  3. package/dist/assets/{ChatContainer-D6yfuigV.js → ChatContainer-BDFeDKu-.js} +39 -39
  4. package/dist/assets/{DiagramView-MGGo9iME.js → DiagramView-CUfSFI3e.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-aeWlaqVB.js → FadeInOnReveal-CKKU6fKI.js} +1 -1
  6. package/dist/assets/{Icon-AXqa-fYB.js → Icon-DiIuWOuC.js} +1 -1
  7. package/dist/assets/{MarkdownRendererImpl-jVOkZkyX.js → MarkdownRendererImpl-Cen6E9Lj.js} +2 -2
  8. package/dist/assets/{MobileDetailNavigation-Q2IzHqGA.js → MobileDetailNavigation-DILi0tN7.js} +1 -1
  9. package/dist/assets/{MobileShareBridge-apXM34Lb.js → MobileShareBridge-PZMRiieI.js} +1 -1
  10. package/dist/assets/MobileSurface-BLYcR0tO.js +2 -0
  11. package/dist/assets/{MultiRunWindow-B-l_O-bd.js → MultiRunWindow-D_2mWZyv.js} +1 -1
  12. package/dist/assets/{OnboardingScreen-CUsgwt4y.js → OnboardingScreen-CYeRZznl.js} +1 -1
  13. package/dist/assets/{OverlayScrollbar-D6NySAxi.js → OverlayScrollbar-BgMLtMIq.js} +1 -1
  14. package/dist/assets/SettingsView-CbDBfQp1.js +25 -0
  15. package/dist/assets/{SettingsWindow-DfVgTQ4I.js → SettingsWindow-Br7XLcSd.js} +1 -1
  16. package/dist/assets/{TerminalView-DNyjfkvT.js → TerminalView-CM5jRKlj.js} +1 -1
  17. package/dist/assets/{ToolOutputDialog-DLG1iThm.js → ToolOutputDialog-D3U6aTZ4.js} +1 -1
  18. package/dist/assets/appThemeRegistry-CDxBJNNB.js +2 -0
  19. package/dist/assets/assistant-guide-hero-BeUHQcxE.jpg +0 -0
  20. package/dist/assets/{button-CDvMVaiO.js → button-DaAoVF4X.js} +1 -1
  21. package/dist/assets/{clipboard-BW7nk_Cx.js → clipboard-BnElHSnO.js} +1 -1
  22. package/dist/assets/{collapsible-C2aamDvD.js → collapsible-BmuQu0dq.js} +1 -1
  23. package/dist/assets/{context-menu-BaxmodD8.js → context-menu-DBOMervd.js} +1 -1
  24. package/dist/assets/{desktopHosts-CBoBpJ1Z.js → desktopHosts-ZK_vkdzL.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-l2R9muux.js → desktopRelayRestore-CewM6-g5.js} +1 -1
  26. package/dist/assets/{diagram-CEon0BQs.js → diagram-DAE4CZy2.js} +1 -1
  27. package/dist/assets/{dialog-DjYfj1Sn.js → dialog-COrLBm9O.js} +1 -1
  28. package/dist/assets/{es-5Lhudh0n.js → es-DIVYdoxi.js} +1 -1
  29. package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-CmWYnRVz.js} +1 -1
  30. package/dist/assets/{fr-Q_UhFmKT.js → fr-CNTlVypq.js} +2 -2
  31. package/dist/assets/{gitApi-BYxg_qGJ.js → gitApi-BQT3QXD2.js} +1 -1
  32. package/dist/assets/gitApiHttp-BxGAFLTf.js +2 -0
  33. package/dist/assets/{input-k9DGGyZ6.js → input-XiL2-2gG.js} +1 -1
  34. package/dist/assets/insertionBoundaries-9a0RuIDc.js +1 -0
  35. package/dist/assets/{ja-CzMPz7bT.js → ja-C_eW5ZM8.js} +1 -1
  36. package/dist/assets/{ko-B-EslUsR.js → ko-CE3cOv7v.js} +1 -1
  37. package/dist/assets/{main-LDphyc-v.js → main-BGfX81Y9.js} +20 -20
  38. package/dist/assets/{main-CO6z5yRB.js → main-CEqSDnE2.js} +2 -2
  39. package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-BDyY0vt6.js} +1 -1
  40. package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-D-aLQZaM.js} +1 -1
  41. package/dist/assets/{miniChat-BZQwTZpx.js → miniChat-CKucSAhg.js} +2 -2
  42. package/dist/assets/mobile-C4bTWKry.js +2 -0
  43. package/dist/assets/{multirun-ZlwlkIy5.js → multirun-DeNtSkgb.js} +1 -1
  44. package/dist/assets/{persistence-DPNMIbF5.js → persistence-D1lIuyqO.js} +1 -1
  45. package/dist/assets/{pl-Dnea6Ppc.js → pl-BZKZOE99.js} +1 -1
  46. package/dist/assets/projectMeta-BunN9pH5.js +3 -0
  47. package/dist/assets/{pt-BR-Ca6QTem7.js → pt-BR-DHDgp8S3.js} +1 -1
  48. package/dist/assets/{radio-CJuhbQ5a.js → radio-Bziafaje.js} +1 -1
  49. package/dist/assets/renderElectronMiniChatApp-D8-FDgWE.js +2 -0
  50. package/dist/assets/renderMobileApp-CdU0aJ-e.js +23 -0
  51. package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-BXCt7ZsQ.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-CK1GhIsm.js +145 -0
  53. package/dist/assets/{sortable-tabs-strip-D8JHUI0z.js → sortable-tabs-strip-pWlBorwQ.js} +1 -1
  54. package/dist/assets/src-8kQFmtjw.css +2 -0
  55. package/dist/assets/{store-wa1ersRf.js → store-CUWqzRt7.js} +2 -2
  56. package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-DpiAJlil.js} +1 -1
  57. package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-BLugzWq-.js} +1 -1
  58. package/dist/assets/{uk-xGc9Pzge.js → uk-CfQK6cz1.js} +1 -1
  59. package/dist/assets/{url-7MqoLs9X.js → url-DMs_PgIC.js} +1 -1
  60. package/dist/assets/useAppFontEffects-IEUQDvE8.js +7 -0
  61. package/dist/assets/useDesktopWindowControlsLayout-MlqJX89k.js +1 -0
  62. package/dist/assets/{useEffectiveDirectory-aUC3zzl_.js → useEffectiveDirectory-GbhkdO6m.js} +1 -1
  63. package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-92Qg-98H.js} +1 -1
  64. package/dist/assets/useMobileNavigationStore-DQHF_ETu.js +476 -0
  65. package/dist/assets/useSessionAutoCleanup-BuU_S_Sh.js +1 -0
  66. package/dist/assets/{useWorkerHighlightedLines-CiYQjBe6.js → useWorkerHighlightedLines-Ca6OgNHF.js} +1 -1
  67. package/dist/assets/{utils-Co19EJEL.js → utils-BxJEIWv5.js} +1 -1
  68. package/dist/assets/{zh-CN-sL4ktGB3.js → zh-CN-DvY2fKWp.js} +1 -1
  69. package/dist/assets/{zh-TW-Cfxps4gq.js → zh-TW-eWe0lm3-.js} +1 -1
  70. package/dist/index.html +8 -8
  71. package/dist/mini-chat.html +8 -8
  72. package/dist/mobile.html +8 -8
  73. package/package.json +1 -1
  74. package/server/index.d.ts +6 -0
  75. package/server/index.js +13 -0
  76. package/server/lib/config-sync/DOCUMENTATION.md +70 -0
  77. package/server/lib/config-sync/config-sync.test.js +197 -0
  78. package/server/lib/config-sync/constants.js +39 -0
  79. package/server/lib/config-sync/contract.js +130 -0
  80. package/server/lib/config-sync/credential-auth.js +39 -0
  81. package/server/lib/config-sync/engine.js +108 -0
  82. package/server/lib/config-sync/index.js +67 -0
  83. package/server/lib/config-sync/local-backup.js +171 -0
  84. package/server/lib/config-sync/plan.js +318 -0
  85. package/server/lib/config-sync/receiver.js +330 -0
  86. package/server/lib/config-sync/routes.js +141 -0
  87. package/server/lib/config-sync/routes.test.js +154 -0
  88. package/server/lib/config-sync/scripts.js +264 -0
  89. package/server/lib/config-sync/selections.js +112 -0
  90. package/server/lib/config-sync/tar.js +35 -0
  91. package/server/lib/config-sync/target-id.js +24 -0
  92. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  93. package/server/lib/event-stream/diff-summary.js +39 -11
  94. package/server/lib/event-stream/diff-summary.test.js +73 -6
  95. package/server/lib/event-stream/protocol.test.js +70 -7
  96. package/server/lib/notifications/DOCUMENTATION.md +2 -2
  97. package/server/lib/notifications/runtime.js +68 -27
  98. package/server/lib/notifications/runtime.test.js +176 -0
  99. package/server/lib/opencode/DOCUMENTATION.md +4 -1
  100. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  101. package/server/lib/opencode/core-routes.js +1 -0
  102. package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
  103. package/server/lib/opencode/openchamber-routes.js +7 -0
  104. package/server/lib/opencode/settings-runtime.js +82 -51
  105. package/server/lib/opencode/settings-runtime.test.js +42 -0
  106. package/server/lib/relay/DOCUMENTATION.md +1 -1
  107. package/server/lib/session-index/DOCUMENTATION.md +12 -6
  108. package/server/lib/session-index/routes.js +34 -0
  109. package/server/lib/session-index/routes.test.js +76 -0
  110. package/server/lib/session-index/service.js +53 -4
  111. package/server/lib/session-index/service.test.js +72 -2
  112. package/server/lib/session-title/DOCUMENTATION.md +19 -14
  113. package/server/lib/session-title/runtime.js +8 -8
  114. package/server/lib/session-title/runtime.test.js +231 -0
  115. package/server/lib/session-turn-pages/DOCUMENTATION.md +26 -6
  116. package/server/lib/session-turn-pages/changes.service.js +156 -0
  117. package/server/lib/session-turn-pages/changes.service.test.js +188 -0
  118. package/server/lib/session-turn-pages/reconcile.routes.test.js +52 -0
  119. package/server/lib/session-turn-pages/reconcile.service.js +3 -1
  120. package/server/lib/session-turn-pages/reconcile.service.test.js +44 -0
  121. package/server/lib/session-turn-pages/routes.js +273 -12
  122. package/server/lib/session-turn-pages/routes.test.js +387 -0
  123. package/server/lib/session-turn-pages/service.js +31 -7
  124. package/server/lib/session-turn-pages/service.test.js +40 -0
  125. package/server/lib/small-model/DOCUMENTATION.md +93 -38
  126. package/server/lib/small-model/catalog.js +10 -2
  127. package/server/lib/small-model/catalog.test.js +3 -1
  128. package/server/lib/small-model/index.js +52 -28
  129. package/server/lib/small-model/opencode-session.js +339 -0
  130. package/server/lib/small-model/opencode-session.test.js +177 -0
  131. package/server/lib/small-model/resolve.js +116 -5
  132. package/server/lib/small-model/resolve.test.js +76 -10
  133. package/server/lib/small-model/summary-settings.test.js +60 -3
  134. package/dist/assets/MobileSurface-htGkf7Ua.js +0 -2
  135. package/dist/assets/SettingsView-DxMpVLlt.js +0 -25
  136. package/dist/assets/appThemeRegistry-CgfOR_0L.js +0 -2
  137. package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
  138. package/dist/assets/insertionBoundaries-DzXMTleb.js +0 -1
  139. package/dist/assets/mobile-BktO3_4R.js +0 -2
  140. package/dist/assets/projectMeta-v-LP2mzz.js +0 -3
  141. package/dist/assets/renderElectronMiniChatApp-Db8uO-6q.js +0 -2
  142. package/dist/assets/renderMobileApp-BCV81Ryl.js +0 -23
  143. package/dist/assets/runtimeEndpointReset-Dg3DMy9v.js +0 -145
  144. package/dist/assets/src-oHrYIqHF.css +0 -2
  145. package/dist/assets/useAppFontEffects-CM0VKsdo.js +0 -7
  146. package/dist/assets/useDesktopWindowControlsLayout-Cq-r4Hcb.js +0 -1
  147. package/dist/assets/useMobileNavigationStore-DnzT4jO2.js +0 -475
  148. package/dist/assets/useSessionAutoCleanup-DxZeceKO.js +0 -1
@@ -0,0 +1,339 @@
1
+ import fs from 'fs';
2
+ import fsPromises from 'fs/promises';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
6
+
7
+ // Temporary OpenCode session path for providers that lack a dedicated
8
+ // small-model adapter (plugin providers, region/credential-chain, …).
9
+ // Auth, endpoint rewrite, and token refresh stay inside the OpenCode runtime.
10
+
11
+ export const SMALL_MODEL_AGENT_NAME = 'openchamber-smallmodel';
12
+ export const SESSION_SETTLE_TIMEOUT_MS = 60_000;
13
+ const SESSION_SETTLE_POLL_MS = 1_000;
14
+ const ARCHIVE_RETRY_MS = 25;
15
+ const INCOMPLETE_ASSISTANT_SETTLE_PROBES = 2;
16
+
17
+ const AGENT_MARKDOWN = `---
18
+ mode: primary
19
+ hidden: true
20
+ permissions:
21
+ - action: "*"
22
+ resource: "*"
23
+ effect: deny
24
+ ---
25
+
26
+ You are a utility text generator. Reply with only the requested text. Do not use tools.
27
+ `;
28
+
29
+ /** @type {string | null} */
30
+ let tempDirectory = null;
31
+ /** @type {Promise<string> | null} */
32
+ let tempDirectoryInflight = null;
33
+
34
+ const isMissing = (result) =>
35
+ result?.error?.status === 404
36
+ || result?.error?.statusCode === 404
37
+ || result?.error?.code === 'not_found'
38
+ || result?.status === 404;
39
+
40
+ const promptAdmitted = (result) =>
41
+ !result?.error
42
+ && (result?.response?.status === 204
43
+ || result?.status === 204
44
+ || result?.data !== undefined
45
+ || result?.response?.ok === true);
46
+
47
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
48
+ if (signal?.aborted) {
49
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
50
+ return;
51
+ }
52
+ const timer = setTimeout(resolve, ms);
53
+ const onAbort = () => {
54
+ clearTimeout(timer);
55
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
56
+ };
57
+ signal?.addEventListener?.('abort', onAbort, { once: true });
58
+ });
59
+
60
+ const sdkErrorMessage = (result, fallback) => {
61
+ const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status;
62
+ const message = result?.error?.message || result?.error?.data?.message || fallback;
63
+ return status ? `${message} (${status})` : message;
64
+ };
65
+
66
+ const readMessageInfo = (message) => {
67
+ if (!message || typeof message !== 'object') return null;
68
+ if (message.info && typeof message.info === 'object') return message.info;
69
+ return message;
70
+ };
71
+
72
+ const assistantTextFromMessages = (messages) => {
73
+ if (!Array.isArray(messages) || messages.length === 0) return '';
74
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
75
+ const message = messages[i];
76
+ const info = readMessageInfo(message);
77
+ if (info?.role !== 'assistant') continue;
78
+ if (info.error) {
79
+ const detail = typeof info.error === 'string'
80
+ ? info.error
81
+ : (info.error?.message || JSON.stringify(info.error));
82
+ throw new Error(`OpenCode small-model session assistant error: ${detail}`);
83
+ }
84
+ const parts = Array.isArray(message?.parts) ? message.parts : [];
85
+ const text = parts
86
+ .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
87
+ .filter(Boolean)
88
+ .join('');
89
+ return text;
90
+ }
91
+ return '';
92
+ };
93
+
94
+ const ensureTempDirectory = async () => {
95
+ if (tempDirectory) return tempDirectory;
96
+ if (!tempDirectoryInflight) {
97
+ tempDirectoryInflight = (async () => {
98
+ const root = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'openchamber-smallmodel-'));
99
+ const agentDir = path.join(root, '.opencode', 'agent');
100
+ await fsPromises.mkdir(agentDir, { recursive: true });
101
+ await fsPromises.writeFile(
102
+ path.join(agentDir, `${SMALL_MODEL_AGENT_NAME}.md`),
103
+ AGENT_MARKDOWN,
104
+ 'utf8',
105
+ );
106
+ tempDirectory = root;
107
+ return root;
108
+ })().finally(() => {
109
+ tempDirectoryInflight = null;
110
+ });
111
+ }
112
+ return tempDirectoryInflight;
113
+ };
114
+
115
+ const createClient = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, directory }) => {
116
+ const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
117
+ return createOpencodeClient({
118
+ baseUrl,
119
+ directory,
120
+ headers: getOpenCodeAuthHeaders(),
121
+ });
122
+ };
123
+
124
+ const archiveCreatedSession = async (client, sessionID, directory) => {
125
+ const archiveAt = Date.now();
126
+ const update = () => client.session.update({
127
+ sessionID,
128
+ directory,
129
+ time: { archived: archiveAt },
130
+ });
131
+ let result = await update();
132
+ if (isMissing(result)) {
133
+ await sleep(ARCHIVE_RETRY_MS);
134
+ result = await update();
135
+ }
136
+ if (result?.error || isMissing(result)) {
137
+ throw new Error(`OpenCode small-model session archive failed: ${sdkErrorMessage(result, 'archive failed')}`);
138
+ }
139
+ };
140
+
141
+ const waitForIdleAssistant = async ({ client, sessionID, directory, signal }) => {
142
+ let incompleteAssistantProbes = 0;
143
+ let emptyIdleProbes = 0;
144
+
145
+ for (;;) {
146
+ signal?.throwIfAborted?.();
147
+
148
+ let sessionBusy = false;
149
+ try {
150
+ const statusResult = await client.session.status({ directory }, { signal });
151
+ if (!statusResult?.error && statusResult?.data && typeof statusResult.data === 'object') {
152
+ const statusValue = statusResult.data[sessionID];
153
+ const type = statusValue?.type ?? statusValue?.status;
154
+ sessionBusy = type === 'busy' || type === 'retry';
155
+ }
156
+ } catch (error) {
157
+ if (signal?.aborted) throw error;
158
+ }
159
+
160
+ if (sessionBusy) {
161
+ incompleteAssistantProbes = 0;
162
+ emptyIdleProbes = 0;
163
+ await sleep(SESSION_SETTLE_POLL_MS, signal);
164
+ continue;
165
+ }
166
+
167
+ try {
168
+ const messagesResult = await client.session.messages({
169
+ sessionID,
170
+ directory,
171
+ limit: 20,
172
+ }, { signal });
173
+ if (!messagesResult?.error && Array.isArray(messagesResult?.data)) {
174
+ const lastInfo = readMessageInfo(messagesResult.data.at(-1));
175
+ if (lastInfo?.role === 'assistant') {
176
+ emptyIdleProbes = 0;
177
+ if (lastInfo.error) {
178
+ const detail = typeof lastInfo.error === 'string'
179
+ ? lastInfo.error
180
+ : (lastInfo.error?.message || JSON.stringify(lastInfo.error));
181
+ throw new Error(`OpenCode small-model session assistant error: ${detail}`);
182
+ }
183
+ if (lastInfo.time?.completed) {
184
+ return messagesResult.data;
185
+ }
186
+ incompleteAssistantProbes += 1;
187
+ if (incompleteAssistantProbes >= INCOMPLETE_ASSISTANT_SETTLE_PROBES) {
188
+ return messagesResult.data;
189
+ }
190
+ } else {
191
+ incompleteAssistantProbes = 0;
192
+ emptyIdleProbes += 1;
193
+ if (emptyIdleProbes >= 5 && lastInfo?.role === 'user') {
194
+ throw new Error('OpenCode small-model session ended without assistant response');
195
+ }
196
+ }
197
+ }
198
+ } catch (error) {
199
+ if (signal?.aborted) throw error;
200
+ if (error instanceof Error && error.message.startsWith('OpenCode small-model')) throw error;
201
+ }
202
+
203
+ await sleep(SESSION_SETTLE_POLL_MS, signal);
204
+ }
205
+ };
206
+
207
+ /**
208
+ * Generate text via a throwaway OpenCode session (deny-all hidden agent).
209
+ * Errors are never masked as empty success.
210
+ *
211
+ * @param {{
212
+ * buildOpenCodeUrl: (pathname: string, search?: string) => string,
213
+ * getOpenCodeAuthHeaders: () => Record<string, string>,
214
+ * providerID: string,
215
+ * modelID: string,
216
+ * prompt: string,
217
+ * system?: string,
218
+ * purpose?: string,
219
+ * directory?: string,
220
+ * }} options
221
+ */
222
+ export async function generateViaOpenCodeSession({
223
+ buildOpenCodeUrl,
224
+ getOpenCodeAuthHeaders,
225
+ providerID,
226
+ modelID,
227
+ prompt,
228
+ system,
229
+ purpose,
230
+ settleTimeoutMs = SESSION_SETTLE_TIMEOUT_MS,
231
+ }) {
232
+ if (typeof prompt !== 'string' || !prompt.trim()) {
233
+ throw new Error('OpenCode small-model session requires a prompt');
234
+ }
235
+ if (typeof providerID !== 'string' || !providerID.trim() || typeof modelID !== 'string' || !modelID.trim()) {
236
+ throw new Error('OpenCode small-model session requires providerID and modelID');
237
+ }
238
+
239
+ const purposeLabel = typeof purpose === 'string' && purpose.trim() ? purpose.trim() : 'utility';
240
+ const budgetMs = Number(settleTimeoutMs) > 0 ? Number(settleTimeoutMs) : SESSION_SETTLE_TIMEOUT_MS;
241
+ const workingDirectory = await ensureTempDirectory();
242
+ const client = createClient({
243
+ buildOpenCodeUrl,
244
+ getOpenCodeAuthHeaders,
245
+ directory: workingDirectory,
246
+ });
247
+
248
+ let sessionID = null;
249
+ const controller = new AbortController();
250
+ const timeout = setTimeout(() => {
251
+ controller.abort(new Error(`OpenCode small-model session timed out after ${budgetMs}ms`));
252
+ }, budgetMs);
253
+
254
+ try {
255
+ const createResult = await client.session.create({
256
+ directory: workingDirectory,
257
+ title: `[small-model] ${purposeLabel}`,
258
+ agent: SMALL_MODEL_AGENT_NAME,
259
+ metadata: {
260
+ openchamber: {
261
+ smallModel: { purpose: purposeLabel },
262
+ },
263
+ },
264
+ }, { signal: controller.signal });
265
+
266
+ sessionID = createResult?.data?.id;
267
+ if (createResult?.error || !sessionID) {
268
+ throw new Error(`OpenCode small-model session create failed: ${sdkErrorMessage(createResult, 'create failed')}`);
269
+ }
270
+
271
+ // Archive before prompting so ordinary session lists never flash system sessions.
272
+ await archiveCreatedSession(client, sessionID, workingDirectory);
273
+
274
+ const promptResult = await client.session.promptAsync({
275
+ sessionID,
276
+ directory: workingDirectory,
277
+ agent: SMALL_MODEL_AGENT_NAME,
278
+ model: { providerID, modelID },
279
+ ...(typeof system === 'string' && system.trim() ? { system: system.trim() } : {}),
280
+ parts: [{ type: 'text', text: prompt, synthetic: false }],
281
+ }, { signal: controller.signal });
282
+
283
+ if (!promptAdmitted(promptResult)) {
284
+ throw new Error(`OpenCode small-model prompt_async failed: ${sdkErrorMessage(promptResult, 'prompt_async failed')}`);
285
+ }
286
+
287
+ const messages = await waitForIdleAssistant({
288
+ client,
289
+ sessionID,
290
+ directory: workingDirectory,
291
+ signal: controller.signal,
292
+ });
293
+ const text = assistantTextFromMessages(messages);
294
+ if (!text.trim()) {
295
+ throw new Error('OpenCode small-model session returned no assistant text');
296
+ }
297
+ return text;
298
+ } finally {
299
+ clearTimeout(timeout);
300
+ if (sessionID) {
301
+ try {
302
+ await client.session.delete({ sessionID, directory: workingDirectory });
303
+ } catch (error) {
304
+ console.warn(
305
+ '[small-model] failed to delete temporary OpenCode session:',
306
+ error?.message || error,
307
+ );
308
+ }
309
+ }
310
+ }
311
+ };
312
+
313
+ /** Best-effort cleanup of the lazy temp directory (agent markdown + empty root). */
314
+ export async function stop() {
315
+ const dir = tempDirectory;
316
+ tempDirectory = null;
317
+ tempDirectoryInflight = null;
318
+ if (!dir) return;
319
+ try {
320
+ await fsPromises.rm(dir, { recursive: true, force: true });
321
+ } catch (error) {
322
+ // Sync fallback if async rm races a partially-created tree.
323
+ try {
324
+ fs.rmSync(dir, { recursive: true, force: true });
325
+ } catch {
326
+ console.warn('[small-model] failed to remove temp directory:', error?.message || error);
327
+ }
328
+ }
329
+ }
330
+
331
+ /** @internal test helpers */
332
+ export const _test = {
333
+ getTempDirectory: () => tempDirectory,
334
+ resetTempDirectory: () => {
335
+ tempDirectory = null;
336
+ tempDirectoryInflight = null;
337
+ },
338
+ AGENT_MARKDOWN,
339
+ };
@@ -0,0 +1,177 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import fsPromises from 'fs/promises';
3
+ import path from 'path';
4
+
5
+ vi.mock('@opencode-ai/sdk/v2', () => ({
6
+ createOpencodeClient: vi.fn(),
7
+ }));
8
+
9
+ const { createOpencodeClient } = await import('@opencode-ai/sdk/v2');
10
+ const {
11
+ generateViaOpenCodeSession,
12
+ stop,
13
+ SMALL_MODEL_AGENT_NAME,
14
+ _test,
15
+ } = await import('./opencode-session.js');
16
+
17
+ const buildOpenCodeUrl = () => 'http://127.0.0.1:4096/';
18
+ const getOpenCodeAuthHeaders = () => ({ authorization: 'Bearer test' });
19
+
20
+ const completedAssistant = (text) => ({
21
+ info: {
22
+ role: 'assistant',
23
+ time: { completed: Date.now() },
24
+ },
25
+ parts: [{ type: 'text', text }],
26
+ });
27
+
28
+ const createMockClient = ({
29
+ create = vi.fn(async () => ({ data: { id: 'ses_small_1' } })),
30
+ update = vi.fn(async () => ({ data: { id: 'ses_small_1' } })),
31
+ promptAsync = vi.fn(async () => ({ response: { status: 204 } })),
32
+ status = vi.fn(async () => ({ data: { ses_small_1: { type: 'idle' } } })),
33
+ messages = vi.fn(async () => ({ data: [completedAssistant('Hello from session')] })),
34
+ deleteSession = vi.fn(async () => ({ data: true })),
35
+ } = {}) => {
36
+ const client = {
37
+ session: {
38
+ create,
39
+ update,
40
+ promptAsync,
41
+ status,
42
+ messages,
43
+ delete: deleteSession,
44
+ },
45
+ };
46
+ createOpencodeClient.mockReturnValue(client);
47
+ return { client, create, update, promptAsync, status, messages, deleteSession };
48
+ };
49
+
50
+ describe('generateViaOpenCodeSession', () => {
51
+ beforeEach(() => {
52
+ createOpencodeClient.mockReset();
53
+ _test.resetTempDirectory();
54
+ });
55
+
56
+ afterEach(async () => {
57
+ await stop();
58
+ _test.resetTempDirectory();
59
+ });
60
+
61
+ it('creates a marked archived session, prompts, returns assistant text, and deletes', async () => {
62
+ const { create, update, promptAsync, deleteSession } = createMockClient();
63
+
64
+ const text = await generateViaOpenCodeSession({
65
+ buildOpenCodeUrl,
66
+ getOpenCodeAuthHeaders,
67
+ providerID: 'codebuddy',
68
+ modelID: 'codebuddy-flash',
69
+ prompt: 'Summarize this',
70
+ system: 'Be brief',
71
+ purpose: 'session-title',
72
+ });
73
+
74
+ expect(text).toBe('Hello from session');
75
+ expect(createOpencodeClient).toHaveBeenCalledWith(expect.objectContaining({
76
+ baseUrl: 'http://127.0.0.1:4096',
77
+ headers: { authorization: 'Bearer test' },
78
+ }));
79
+
80
+ const tempDir = create.mock.calls[0][0].directory;
81
+ expect(tempDir).toMatch(/openchamber-smallmodel-/);
82
+ expect(await fsPromises.readFile(
83
+ path.join(tempDir, '.opencode', 'agent', `${SMALL_MODEL_AGENT_NAME}.md`),
84
+ 'utf8',
85
+ )).toContain('effect: deny');
86
+
87
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({
88
+ directory: tempDir,
89
+ title: '[small-model] session-title',
90
+ agent: SMALL_MODEL_AGENT_NAME,
91
+ metadata: {
92
+ openchamber: {
93
+ smallModel: { purpose: 'session-title' },
94
+ },
95
+ },
96
+ }), expect.any(Object));
97
+
98
+ expect(update).toHaveBeenCalledWith(expect.objectContaining({
99
+ sessionID: 'ses_small_1',
100
+ directory: tempDir,
101
+ time: { archived: expect.any(Number) },
102
+ }));
103
+
104
+ expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({
105
+ sessionID: 'ses_small_1',
106
+ directory: tempDir,
107
+ agent: SMALL_MODEL_AGENT_NAME,
108
+ model: { providerID: 'codebuddy', modelID: 'codebuddy-flash' },
109
+ system: 'Be brief',
110
+ parts: [{ type: 'text', text: 'Summarize this', synthetic: false }],
111
+ }), expect.any(Object));
112
+
113
+ expect(deleteSession).toHaveBeenCalledWith({
114
+ sessionID: 'ses_small_1',
115
+ directory: tempDir,
116
+ });
117
+ });
118
+
119
+ it('throws on settle timeout and still best-effort deletes the session', async () => {
120
+ const { deleteSession } = createMockClient({
121
+ status: vi.fn(async () => ({ data: { ses_small_1: { type: 'busy' } } })),
122
+ messages: vi.fn(async () => ({ data: [] })),
123
+ });
124
+
125
+ await expect(generateViaOpenCodeSession({
126
+ buildOpenCodeUrl,
127
+ getOpenCodeAuthHeaders,
128
+ providerID: 'codebuddy',
129
+ modelID: 'codebuddy-flash',
130
+ prompt: 'hang',
131
+ purpose: 'commit',
132
+ settleTimeoutMs: 80,
133
+ })).rejects.toThrow(/timed out/);
134
+ expect(deleteSession).toHaveBeenCalled();
135
+ });
136
+
137
+ it('does not swallow a successful result when session.delete fails', async () => {
138
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
139
+ createMockClient({
140
+ deleteSession: vi.fn(async () => {
141
+ throw new Error('delete boom');
142
+ }),
143
+ });
144
+
145
+ const text = await generateViaOpenCodeSession({
146
+ buildOpenCodeUrl,
147
+ getOpenCodeAuthHeaders,
148
+ providerID: 'codebuddy',
149
+ modelID: 'codebuddy-flash',
150
+ prompt: 'ok',
151
+ purpose: 'commit',
152
+ });
153
+
154
+ expect(text).toBe('Hello from session');
155
+ expect(warn).toHaveBeenCalled();
156
+ warn.mockRestore();
157
+ });
158
+
159
+ it('throws when the assistant returns empty text', async () => {
160
+ createMockClient({
161
+ messages: vi.fn(async () => ({
162
+ data: [{
163
+ info: { role: 'assistant', time: { completed: Date.now() } },
164
+ parts: [{ type: 'text', text: ' ' }],
165
+ }],
166
+ })),
167
+ });
168
+
169
+ await expect(generateViaOpenCodeSession({
170
+ buildOpenCodeUrl,
171
+ getOpenCodeAuthHeaders,
172
+ providerID: 'codebuddy',
173
+ modelID: 'codebuddy-flash',
174
+ prompt: 'empty',
175
+ })).rejects.toThrow(/no assistant text/);
176
+ });
177
+ });
@@ -1,15 +1,38 @@
1
1
  import { getCatalogProvider } from './catalog.js';
2
2
 
3
- // Mirrors OpenCode's getSmallModel fallback chain:
3
+ // Mirrors OpenCode's getSmallModel fallback chain, with OpenChamber's own
4
+ // keyword + cheapest-first default when no session/family match applies:
4
5
  // 1. `small_model` from the merged config layers ("provider/model").
5
- // 2. GitHub Copilot's hidden utility models when Copilot is logged in.
6
- // 3. Family-priority scan of the authenticated providers' catalog models.
6
+ // 2. Family-priority / keyword scan of authenticated providers' catalog models.
7
+ // 3. GitHub Copilot's hidden utility models when Copilot is logged in.
7
8
  const FAMILY_PRIORITY = ['gemini-flash', 'gpt-nano', 'claude-haiku'];
8
9
  const COPILOT_UTILITY_MODELS = ['gpt-5.4-nano', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini'];
9
10
  // The ChatGPT-plan codex backend only accepts a small allowlist of models
10
11
  // (nano/API-key models are rejected with 400) — this is its cheapest one.
11
12
  const OPENAI_OAUTH_SMALL_MODEL = 'gpt-5.4-mini';
12
13
 
14
+ // Tokenize model ids so "mini" does not match inside "gemini".
15
+ const modelIdTokens = (id) => String(id || '')
16
+ .toLowerCase()
17
+ .split(/[^a-z0-9]+/i)
18
+ .filter(Boolean);
19
+
20
+ const idHasToken = (id, token) => modelIdTokens(id).includes(token);
21
+
22
+ // Small-model keyword tiers (lower = better). Known OpenCode families stay
23
+ // highest; broader name tokens cover plugin / catalog models without a family.
24
+ const SMALL_MODEL_KEYWORD_TIERS = Object.freeze([
25
+ { tier: 0, test: (id, family) => family === 'gemini-flash' || idHasToken(id, 'flash') },
26
+ { tier: 1, test: (id, family) => family === 'gpt-nano' || idHasToken(id, 'nano') },
27
+ { tier: 2, test: (id, family) => family === 'claude-haiku' || idHasToken(id, 'haiku') },
28
+ { tier: 3, test: (id) => idHasToken(id, 'mini') },
29
+ { tier: 4, test: (id) => idHasToken(id, 'lite') },
30
+ { tier: 5, test: (id) => idHasToken(id, 'turbo') },
31
+ { tier: 6, test: (id) => idHasToken(id, 'instant') },
32
+ { tier: 7, test: (id) => idHasToken(id, 'small') },
33
+ { tier: 8, test: (id) => idHasToken(id, 'chat') },
34
+ ]);
35
+
13
36
  const AUTH_PROVIDER_ALIASES = {
14
37
  'github-copilot': ['github-copilot', 'copilot'],
15
38
  };
@@ -51,10 +74,47 @@ const pickByFamily = (models, family) => {
51
74
  const matches = Object.values(models)
52
75
  .filter((model) => model && typeof model === 'object' && model.family === family);
53
76
  if (matches.length === 0) return null;
54
- matches.sort((a, b) => String(b.release_date || '').localeCompare(String(a.release_date || '')));
77
+ matches.sort(compareSmallModelCandidates);
55
78
  return matches[0];
56
79
  };
57
80
 
81
+ const smallModelKeywordTier = (model) => {
82
+ const id = typeof model?.id === 'string' ? model.id : '';
83
+ const family = typeof model?.family === 'string' ? model.family : '';
84
+ for (const entry of SMALL_MODEL_KEYWORD_TIERS) {
85
+ if (entry.test(id, family)) return entry.tier;
86
+ }
87
+ return Number.POSITIVE_INFINITY;
88
+ };
89
+
90
+ const costInputOrLast = (model) => {
91
+ const input = model?.cost?.input;
92
+ return Number.isFinite(input) ? input : Number.POSITIVE_INFINITY;
93
+ };
94
+
95
+ /** Cheapest input cost first; missing cost last; newer release_date wins ties. */
96
+ export function compareSmallModelCandidates(a, b) {
97
+ const costDiff = costInputOrLast(a) - costInputOrLast(b);
98
+ if (costDiff !== 0) return costDiff;
99
+ return String(b.release_date || '').localeCompare(String(a.release_date || ''));
100
+ }
101
+
102
+ /**
103
+ * Rank catalog models for the default (no explicit config) small-model pick:
104
+ * keyword tier → cost.input asc (missing last) → release_date desc.
105
+ */
106
+ export function rankSmallModelCandidates(models) {
107
+ return Object.values(models || {})
108
+ .filter((model) => model && typeof model === 'object' && typeof model.id === 'string' && model.id)
109
+ .map((model) => ({ model, tier: smallModelKeywordTier(model) }))
110
+ .filter((entry) => Number.isFinite(entry.tier))
111
+ .sort((a, b) => {
112
+ if (a.tier !== b.tier) return a.tier - b.tier;
113
+ return compareSmallModelCandidates(a.model, b.model);
114
+ })
115
+ .map((entry) => entry.model);
116
+ }
117
+
58
118
  // Small-model candidates within ONE provider, by family priority. Copilot and
59
119
  // ChatGPT-plan OpenAI have fixed small models that never appear in the
60
120
  // catalog; everyone else is scanned through the catalog families.
@@ -89,6 +149,47 @@ const pickWithinProvider = (providerID, auth, catalog, family) => {
89
149
  return model?.id ? { providerID, modelID: model.id, source: 'family-scan' } : null;
90
150
  };
91
151
 
152
+ const pickKeywordDefault = (auth, catalog, excludeProviderID) => {
153
+ /** @type {Array<{ providerID: string, model: object }>} */
154
+ const candidates = [];
155
+ const providerIDs = Object.keys(auth || {}).filter((providerID) => {
156
+ if (providerID === 'copilot') return false;
157
+ if (excludeProviderID && providerID === excludeProviderID) return false;
158
+ return isUsableAuthEntry(getAuthEntryForProvider(auth, providerID));
159
+ });
160
+ // Surface github-copilot when only the `copilot` alias is present.
161
+ if (
162
+ !providerIDs.includes('github-copilot')
163
+ && isUsableAuthEntry(getAuthEntryForProvider(auth, 'github-copilot'))
164
+ && excludeProviderID !== 'github-copilot'
165
+ ) {
166
+ providerIDs.push('github-copilot');
167
+ }
168
+
169
+ for (const providerID of providerIDs) {
170
+ if (providerID === 'openai' && auth.openai?.type === 'oauth') continue;
171
+ if (providerID === 'github-copilot') continue;
172
+ if (providerID === 'openai' && (auth.openai?.type !== 'api' || !isUsableAuthEntry(auth.openai))) continue;
173
+ if (providerID === 'anthropic' && (auth.anthropic?.type !== 'api' || !isUsableAuthEntry(auth.anthropic))) continue;
174
+ if (providerID === 'google' && (auth.google?.type !== 'api' || !isUsableAuthEntry(auth.google))) continue;
175
+
176
+ const provider = getCatalogProvider(catalog, providerID);
177
+ if (!provider?.models) continue;
178
+ for (const model of rankSmallModelCandidates(provider.models)) {
179
+ candidates.push({ providerID, model });
180
+ }
181
+ }
182
+
183
+ if (candidates.length === 0) return null;
184
+ candidates.sort((a, b) => {
185
+ const tierDiff = smallModelKeywordTier(a.model) - smallModelKeywordTier(b.model);
186
+ if (tierDiff !== 0) return tierDiff;
187
+ return compareSmallModelCandidates(a.model, b.model);
188
+ });
189
+ const best = candidates[0];
190
+ return { providerID: best.providerID, modelID: best.model.id, source: 'keyword-scan' };
191
+ };
192
+
92
193
  export function resolveSmallModel({ auth, catalog, settingsSmallModel, configSmallModel, preferredProviderID, preferredModelID }) {
93
194
  // OpenChamber's own setting (Settings → Sessions → Small Model override)
94
195
  // outranks everything, including the OpenCode config.
@@ -114,13 +215,20 @@ export function resolveSmallModel({ auth, catalog, settingsSmallModel, configSma
114
215
  const match = pickWithinProvider(preferred, auth, catalog, family);
115
216
  if (match) return match;
116
217
  }
218
+ const preferredCatalog = getCatalogProvider(catalog, preferred);
219
+ if (preferredCatalog?.models) {
220
+ const ranked = rankSmallModelCandidates(preferredCatalog.models);
221
+ if (ranked[0]?.id) {
222
+ return { providerID: preferred, modelID: ranked[0].id, source: 'keyword-scan' };
223
+ }
224
+ }
117
225
  if (typeof preferredModelID === 'string' && preferredModelID) {
118
226
  return { providerID: preferred, modelID: preferredModelID, source: 'session-model' };
119
227
  }
120
228
  }
121
229
 
122
230
  // No session context (or its provider has no usable login): scan all
123
- // authenticated providers by family priority.
231
+ // authenticated providers by family priority, then keyword+cost default.
124
232
  const authedProviders = Object.keys(auth || {}).filter((providerID) =>
125
233
  providerID !== preferred && isUsableAuthEntry(auth[providerID]));
126
234
 
@@ -131,6 +239,9 @@ export function resolveSmallModel({ auth, catalog, settingsSmallModel, configSma
131
239
  }
132
240
  }
133
241
 
242
+ const keywordDefault = pickKeywordDefault(auth, catalog, preferred);
243
+ if (keywordDefault) return keywordDefault;
244
+
134
245
  // Copilot's utility fallback for legacy auth aliases the loop above missed.
135
246
  const copilotEntry = getAuthEntryForProvider(auth, 'github-copilot');
136
247
  if (isUsableAuthEntry(copilotEntry)) {