@openchambery/web 1.19.7-beta.2 → 1.19.7-beta.20

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 (185) hide show
  1. package/dist/assets/AgentAvatar-DI2zs2WH.js +1 -0
  2. package/dist/assets/AssistantView-C0p-8f2B.js +2 -0
  3. package/dist/assets/DiagramView-CSXRZQ5S.js +1 -0
  4. package/dist/assets/FadeInOnReveal-BWJcUp5P.js +1 -0
  5. package/dist/assets/Icon-4My8YZwt.js +1 -0
  6. package/dist/assets/MarkdownRendererImpl-C6rkoZYH.js +7 -0
  7. package/dist/assets/{MarkstreamRendererImpl-CR0fBG5w.css → MarkstreamRendererImpl-Ca7RATbs.css} +1 -1
  8. package/dist/assets/MarkstreamRendererImpl-CowOCU5W.js +1 -0
  9. package/dist/assets/MobileDetailNavigation-ACMbLTqb.js +1 -0
  10. package/dist/assets/MobileShareBridge-CXR-B1xW.js +4 -0
  11. package/dist/assets/MobileSurface-CKBSKqUH.js +2 -0
  12. package/dist/assets/{MultiRunWindow-khpnWyZO.js → MultiRunWindow-el9_nl25.js} +1 -1
  13. package/dist/assets/OnboardingScreen-BNEtPmvw.js +1 -0
  14. package/dist/assets/OverlayScrollbar-BZB21Yp_.js +2 -0
  15. package/dist/assets/SettingsView-CVSiIRia.js +25 -0
  16. package/dist/assets/{SettingsWindow-Dx5rU9v5.js → SettingsWindow-BXYswtp5.js} +1 -1
  17. package/dist/assets/TerminalView-Bh0IQwa5.js +8 -0
  18. package/dist/assets/{ToolOutputDialog-BfuXazW9.js → ToolOutputDialog-XeIJ2etT.js} +3 -3
  19. package/dist/assets/appThemeRegistry-rSdmh4sW.js +2 -0
  20. package/dist/assets/{button-z0GOBtWi.js → button-Or0daT9o.js} +1 -1
  21. package/dist/assets/clipboard-BXxLshVq.js +1 -0
  22. package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-C-kzSGkq.js} +1 -1
  23. package/dist/assets/context-menu-Dpjla5p4.js +1 -0
  24. package/dist/assets/desktopHosts-BzSE0sK7.js +1 -0
  25. package/dist/assets/desktopRelayRestore-BaKjk1kQ.js +1 -0
  26. package/dist/assets/{diagram-CoEqmReY.js → diagram-D0mT3mo1.js} +1 -1
  27. package/dist/assets/{dialog-HCE4eLUW.js → dialog-BkhHBTd4.js} +1 -1
  28. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
  29. package/dist/assets/{es-D9KcV5TX.js → es-BHqnSjoZ.js} +1 -1
  30. package/dist/assets/fileQueries-DFY6Kiy1.js +1 -0
  31. package/dist/assets/fileReferenceActions-3pPDAJSl.js +1 -0
  32. package/dist/assets/{fr-DPA9qUl3.js → fr-BfrlJY9i.js} +1 -1
  33. package/dist/assets/{gitApi-DNTMAa3F.js → gitApi-Uvb9w70Y.js} +3 -3
  34. package/dist/assets/{input-BG3VWTIF.js → input-BUsGnzwe.js} +1 -1
  35. package/dist/assets/insertionBoundaries-Bgec5nsP.js +4 -0
  36. package/dist/assets/{ja-Dsif3NoD.js → ja-DP-7qt-x.js} +1 -1
  37. package/dist/assets/{ko-Dixx4Ctg.js → ko-E2B5vGQP.js} +1 -1
  38. package/dist/assets/main-COfQt8Sj.js +169 -0
  39. package/dist/assets/main-PPlpRj1-.js +2 -0
  40. package/dist/assets/markdown-worker-wEFz5Hm_.js +3 -0
  41. package/dist/assets/markdownHeightCache-Bj707yG-.js +2 -0
  42. package/dist/assets/markdownTheme-CVS01wHf.js +2 -0
  43. package/dist/assets/miniChat-EZGRqQZr.js +2 -0
  44. package/dist/assets/mobile-DQNqMo5J.js +2 -0
  45. package/dist/assets/multirun-EViX73vm.js +4 -0
  46. package/dist/assets/outsideFileGrants-BB4GPvCq.js +1 -0
  47. package/dist/assets/path-utils-jGozTg9d.js +1 -0
  48. package/dist/assets/persistence-CSZp1CNB.js +3 -0
  49. package/dist/assets/{pl-Bj78ZGgf.js → pl-r8X5gRM5.js} +1 -1
  50. package/dist/assets/platform-CenlWqxu.js +2 -0
  51. package/dist/assets/projectMeta-Bv1L_nEb.js +3 -0
  52. package/dist/assets/{pt-BR-CFL3xSAf.js → pt-BR-DcpdXCf5.js} +1 -1
  53. package/dist/assets/renderElectronMiniChatApp-Nsq4Wc4s.js +2 -0
  54. package/dist/assets/renderMobileApp-5WJDYaMR.js +28 -0
  55. package/dist/assets/runtimeConfig-DKUQS4iY.js +2 -0
  56. package/dist/assets/runtimeEndpointReset-BMeu9Hqj.js +147 -0
  57. package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
  58. package/dist/assets/sessionLookup-Sb2HN3G2.js +1 -0
  59. package/dist/assets/sortable-tabs-strip-C5gGnikj.js +1 -0
  60. package/dist/assets/src-Af9cMFKF.css +2 -0
  61. package/dist/assets/{store-DzpbXWX1.js → store-BDFdnk6l.js} +2 -2
  62. package/dist/assets/terminalApi-CIIs9vFx.js +1 -0
  63. package/dist/assets/textarea-CIO34TZz.js +1 -0
  64. package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BSrJYYYc.js} +1 -1
  65. package/dist/assets/{uk-BY60Ub3Z.js → uk-Di0vl9V-.js} +1 -1
  66. package/dist/assets/url-CE_gAfNa.js +1 -0
  67. package/dist/assets/{ChatContainer-CnOrdAgs.js → useAppFontEffects-B_6vc0u3.js} +46 -41
  68. package/dist/assets/useAssistantUIStore-BHacrxe_.js +1 -0
  69. package/dist/assets/useDesktopWindowControlsLayout-B2L5PWHW.js +1 -0
  70. package/dist/assets/useEffectiveDirectory-CBd5vt8N.js +1 -0
  71. package/dist/assets/useFontPreferences-BxOHKuVw.js +1 -0
  72. package/dist/assets/useMobileNavigationStore-CUGaydRW.js +412 -0
  73. package/dist/assets/useSessionAutoCleanup-Clhzc1wn.js +1 -0
  74. package/dist/assets/useUIStore-CqqO11ZI.js +1 -0
  75. package/dist/assets/useWorkerHighlightedLines-C4iQlbPQ.js +7 -0
  76. package/dist/assets/utils-B0mQXN9L.js +1 -0
  77. package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
  78. package/dist/assets/{zh-CN-Cox1LaOW.js → zh-CN-coFCfeQE.js} +1 -1
  79. package/dist/assets/{zh-TW-B6RU0mxa.js → zh-TW-oDRZ1-yb.js} +1 -1
  80. package/dist/index.html +9 -11
  81. package/dist/mini-chat.html +9 -11
  82. package/dist/mobile.html +15 -17
  83. package/package.json +2 -1
  84. package/server/index.js +2 -0
  85. package/server/lib/assistant-delivery-parts.js +3 -1
  86. package/server/lib/assistant-delivery-parts.test.js +11 -0
  87. package/server/lib/assistants/DOCUMENTATION.md +76 -3
  88. package/server/lib/assistants/assign.js +225 -0
  89. package/server/lib/assistants/assign.test.js +118 -0
  90. package/server/lib/assistants/bubbles.js +45 -0
  91. package/server/lib/assistants/bubbles.test.js +36 -0
  92. package/server/lib/assistants/cards.js +148 -0
  93. package/server/lib/assistants/cards.test.js +116 -0
  94. package/server/lib/assistants/contact-store.js +284 -0
  95. package/server/lib/assistants/contact-store.test.js +131 -0
  96. package/server/lib/assistants/contact-tools.js +761 -0
  97. package/server/lib/assistants/contact-tools.test.js +328 -0
  98. package/server/lib/assistants/contracts.js +20 -1
  99. package/server/lib/assistants/harness.js +551 -0
  100. package/server/lib/assistants/harness.test.js +607 -0
  101. package/server/lib/assistants/routes.js +44 -4
  102. package/server/lib/assistants/service.js +930 -17
  103. package/server/lib/assistants/service.test.js +1163 -45
  104. package/server/lib/llm/DOCUMENTATION.md +84 -0
  105. package/server/lib/llm/catalog.js +100 -0
  106. package/server/lib/llm/catalog.test.js +78 -0
  107. package/server/lib/llm/completions.js +151 -0
  108. package/server/lib/llm/completions.test.js +127 -0
  109. package/server/lib/llm/generate.js +570 -0
  110. package/server/lib/llm/generate.test.js +459 -0
  111. package/server/lib/llm/routes.js +47 -0
  112. package/server/lib/llm/temp-directory.js +37 -0
  113. package/server/lib/notifications/APNS.md +4 -4
  114. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  115. package/server/lib/notifications/apns-runtime.js +139 -20
  116. package/server/lib/notifications/apns-runtime.test.js +66 -7
  117. package/server/lib/notifications/apns-titles.js +62 -62
  118. package/server/lib/notifications/apns-titles.test.js +3 -1
  119. package/server/lib/notifications/routes.js +1 -1
  120. package/server/lib/notifications/runtime.js +81 -49
  121. package/server/lib/notifications/runtime.test.js +61 -0
  122. package/server/lib/opencode/feature-routes-runtime.js +30 -0
  123. package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
  124. package/server/lib/opencode/settings-runtime.js +18 -2
  125. package/server/lib/session-goal/DOCUMENTATION.md +8 -1
  126. package/server/lib/session-goal/runtime.js +20 -0
  127. package/server/lib/session-index/DOCUMENTATION.md +3 -1
  128. package/server/lib/session-index/service.js +10 -4
  129. package/server/lib/session-index/service.test.js +37 -0
  130. package/server/lib/session-title/runtime.js +1 -0
  131. package/server/lib/session-title/runtime.test.js +11 -0
  132. package/dist/assets/AgentAvatar-ziUs4sw3.js +0 -1
  133. package/dist/assets/AssistantView-DF4yXFgS.js +0 -1
  134. package/dist/assets/ContextPanel-Cg_uCHzJ.js +0 -285
  135. package/dist/assets/DiagramView-DYh3Zn2y.js +0 -1
  136. package/dist/assets/FadeInOnReveal-BAlsRFJH.js +0 -1
  137. package/dist/assets/Icon-CLpn1mIz.js +0 -1
  138. package/dist/assets/MarkdownRendererImpl-QzUEpBEm.js +0 -7
  139. package/dist/assets/MarkstreamRendererImpl-CXrfeUia.js +0 -1
  140. package/dist/assets/MobileDetailNavigation-DJXEYfq4.js +0 -1
  141. package/dist/assets/MobileShareBridge-CliVo1YH.js +0 -4
  142. package/dist/assets/MobileSurface-9Xh6WQkY.js +0 -2
  143. package/dist/assets/OnboardingScreen-CJYyR54B.js +0 -1
  144. package/dist/assets/OverlayScrollbar-C-jZ8ITG.js +0 -2
  145. package/dist/assets/SettingsView-DyCZehFG.js +0 -25
  146. package/dist/assets/TerminalView-XSALfUaH.js +0 -8
  147. package/dist/assets/appThemeRegistry-DDWrniMM.js +0 -2
  148. package/dist/assets/clipboard-Bz0Zm9VJ.js +0 -1
  149. package/dist/assets/desktop-CfLlmafK.js +0 -1
  150. package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
  151. package/dist/assets/desktopRelayRestore-LBt0CBOj.js +0 -1
  152. package/dist/assets/fileQueries-Dnn15WsA.js +0 -1
  153. package/dist/assets/insertionBoundaries-DMxuUnL_.js +0 -4
  154. package/dist/assets/main-B9HVtgXE.js +0 -2
  155. package/dist/assets/main-ZNyIs3Sn.js +0 -29
  156. package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
  157. package/dist/assets/markdownHeightCache-CvUyZT4q.js +0 -1
  158. package/dist/assets/markdownTheme-TdhQOZSN.js +0 -2
  159. package/dist/assets/miniChat-q0T0WGkZ.js +0 -2
  160. package/dist/assets/mobile-DEduvLJj.js +0 -2
  161. package/dist/assets/multirun-DU-wrxlG.js +0 -4
  162. package/dist/assets/persistence-CmjKImz2.js +0 -3
  163. package/dist/assets/platform-skqcMARt.js +0 -2
  164. package/dist/assets/projectMeta-DNWx6Uva.js +0 -3
  165. package/dist/assets/radio-BEonIX1k.js +0 -1
  166. package/dist/assets/renderElectronMiniChatApp--JYWY41L.js +0 -2
  167. package/dist/assets/renderMobileApp-B0Ix_uNo.js +0 -25
  168. package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
  169. package/dist/assets/runtimeConfig-BTDIG7mX.js +0 -2
  170. package/dist/assets/runtimeEndpointReset-CZQCYtVI.js +0 -4
  171. package/dist/assets/shikiHighlight-C9Cx56Qg.js +0 -1
  172. package/dist/assets/sortable-tabs-strip-Bj8N0VWf.js +0 -1
  173. package/dist/assets/src-2eFAiBLq.css +0 -2
  174. package/dist/assets/terminalApi-CNqqwjfG.js +0 -1
  175. package/dist/assets/url-KeihbE0d.js +0 -1
  176. package/dist/assets/useAppFontEffects-CKLSamae.js +0 -7
  177. package/dist/assets/useAssistantUIStore-DaplPyb0.js +0 -1
  178. package/dist/assets/useDesktopWindowControlsLayout-DJSkFfhT.js +0 -1
  179. package/dist/assets/useEffectiveDirectory-B3rbY1Kv.js +0 -1
  180. package/dist/assets/useFontPreferences-D8emhcNv.js +0 -1
  181. package/dist/assets/useMobileNavigationStore-CAvVUItx.js +0 -412
  182. package/dist/assets/useSessionAutoCleanup-DxNgqJ8s.js +0 -1
  183. package/dist/assets/useUIStore-B7KJS5G7.js +0 -1
  184. package/dist/assets/useWorkerHighlightedLines-DiDF68LR.js +0 -7
  185. package/dist/assets/utils-CTL16t2f.js +0 -1
@@ -0,0 +1,570 @@
1
+ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
2
+
3
+ const LLM_AGENT_NAME = 'openchamber-llm';
4
+ const GENERATE_TIMEOUT_MS = 90_000;
5
+ const SETTLE_POLL_MS = 250;
6
+ const INCOMPLETE_ASSISTANT_SETTLE_PROBES = 2;
7
+ const EMPTY_IDLE_PROBES = 5;
8
+
9
+ const AGENT_MARKDOWN = `---
10
+ mode: primary
11
+ hidden: true
12
+ permissions:
13
+ - action: "*"
14
+ resource: "*"
15
+ effect: deny
16
+ ---
17
+
18
+ You are a text generator. Reply with only the requested text. Do not use tools.
19
+ `;
20
+
21
+ const isMissing = (result) =>
22
+ result?.error?.status === 404
23
+ || result?.error?.statusCode === 404
24
+ || result?.error?.code === 'not_found'
25
+ || result?.status === 404;
26
+
27
+ const promptAdmitted = (result) =>
28
+ !result?.error
29
+ && (result?.response?.status === 204
30
+ || result?.status === 204
31
+ || result?.data !== undefined
32
+ || result?.response?.ok === true);
33
+
34
+ const sdkErrorMessage = (result, fallback) => {
35
+ const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status;
36
+ const message = result?.error?.message || result?.error?.data?.message || fallback;
37
+ return status ? `${message} (${status})` : message;
38
+ };
39
+
40
+ const failGenerate = (message) => {
41
+ const error = new Error(message);
42
+ error.code = 'upstream_error';
43
+ throw error;
44
+ };
45
+
46
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
47
+ if (signal?.aborted) {
48
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
49
+ return;
50
+ }
51
+ const timer = setTimeout(resolve, ms);
52
+ const onAbort = () => {
53
+ clearTimeout(timer);
54
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
55
+ };
56
+ signal?.addEventListener?.('abort', onAbort, { once: true });
57
+ });
58
+
59
+ const readMessageInfo = (message) => {
60
+ if (!message || typeof message !== 'object') return null;
61
+ if (message.info && typeof message.info === 'object') return message.info;
62
+ return message;
63
+ };
64
+
65
+ const assistantErrorDetail = (error) => {
66
+ if (typeof error === 'string' && error.trim()) return error;
67
+ if (error && typeof error === 'object') {
68
+ if (typeof error.message === 'string' && error.message.trim()) return error.message;
69
+ try {
70
+ return JSON.stringify(error);
71
+ } catch {
72
+ return String(error);
73
+ }
74
+ }
75
+ return '';
76
+ };
77
+
78
+ const assistantTextFromPrompt = (data) => {
79
+ const parts = Array.isArray(data?.parts) ? data.parts : Array.isArray(data?.data?.parts) ? data.data.parts : [];
80
+ const text = parts
81
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
82
+ .map((part) => part.text)
83
+ .join('');
84
+ if (text.trim()) return text;
85
+ const info = data?.info ?? data?.data?.info;
86
+ if (info?.error) {
87
+ const detail = assistantErrorDetail(info.error);
88
+ if (detail) failGenerate(detail);
89
+ }
90
+ return '';
91
+ };
92
+
93
+ const assistantTextFromMessages = (messages) => {
94
+ if (!Array.isArray(messages) || messages.length === 0) return '';
95
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
96
+ const message = messages[i];
97
+ const info = readMessageInfo(message);
98
+ if (info?.role !== 'assistant') continue;
99
+ if (info.error) {
100
+ const detail = assistantErrorDetail(info.error);
101
+ failGenerate(detail || 'OpenCode assistant error');
102
+ }
103
+ const parts = Array.isArray(message?.parts) ? message.parts : [];
104
+ return parts
105
+ .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
106
+ .filter(Boolean)
107
+ .join('');
108
+ }
109
+ return '';
110
+ };
111
+
112
+ const deniedTools = (ids) => {
113
+ const tools = Object.create(null);
114
+ for (const id of ids) {
115
+ if (typeof id === 'string' && id.trim()) tools[id.trim()] = false;
116
+ }
117
+ return tools;
118
+ };
119
+
120
+ const waitForIdleAssistant = async ({ client, sessionID, directory, signal }) => {
121
+ let incompleteAssistantProbes = 0;
122
+ let emptyIdleProbes = 0;
123
+
124
+ for (;;) {
125
+ signal?.throwIfAborted?.();
126
+
127
+ let sessionBusy = false;
128
+ try {
129
+ const statusResult = await client.session.status({ directory }, { signal });
130
+ if (!statusResult?.error && statusResult?.data && typeof statusResult.data === 'object') {
131
+ const statusValue = statusResult.data[sessionID];
132
+ const type = statusValue?.type ?? statusValue?.status;
133
+ sessionBusy = type === 'busy' || type === 'retry';
134
+ }
135
+ } catch (error) {
136
+ if (signal?.aborted) throw error;
137
+ }
138
+
139
+ if (sessionBusy) {
140
+ incompleteAssistantProbes = 0;
141
+ emptyIdleProbes = 0;
142
+ await sleep(SETTLE_POLL_MS, signal);
143
+ continue;
144
+ }
145
+
146
+ try {
147
+ const messagesResult = await client.session.messages({
148
+ sessionID,
149
+ directory,
150
+ limit: 20,
151
+ }, { signal });
152
+ if (!messagesResult?.error && Array.isArray(messagesResult?.data)) {
153
+ const lastInfo = readMessageInfo(messagesResult.data.at(-1));
154
+ if (lastInfo?.role === 'assistant') {
155
+ emptyIdleProbes = 0;
156
+ if (lastInfo.error) {
157
+ const detail = assistantErrorDetail(lastInfo.error);
158
+ failGenerate(detail || 'OpenCode assistant error');
159
+ }
160
+ if (lastInfo.time?.completed) {
161
+ return messagesResult.data;
162
+ }
163
+ incompleteAssistantProbes += 1;
164
+ if (incompleteAssistantProbes >= INCOMPLETE_ASSISTANT_SETTLE_PROBES) {
165
+ return messagesResult.data;
166
+ }
167
+ } else {
168
+ incompleteAssistantProbes = 0;
169
+ emptyIdleProbes += 1;
170
+ if (emptyIdleProbes >= EMPTY_IDLE_PROBES && lastInfo?.role === 'user') {
171
+ failGenerate('OpenCode LLM generator ended without assistant text');
172
+ }
173
+ }
174
+ }
175
+ } catch (error) {
176
+ if (signal?.aborted) throw error;
177
+ if (error?.code === 'upstream_error') throw error;
178
+ }
179
+
180
+ await sleep(SETTLE_POLL_MS, signal);
181
+ }
182
+ };
183
+
184
+ const isJsonContentType = (response) => /json/i.test(response?.headers?.get?.('content-type') || '');
185
+
186
+ const looksLikeJsonObject = (text) => {
187
+ const trimmed = String(text || '').trim();
188
+ if (!trimmed || trimmed.startsWith('<')) return false;
189
+ try {
190
+ const parsed = JSON.parse(trimmed);
191
+ return parsed !== null && typeof parsed === 'object';
192
+ } catch {
193
+ return false;
194
+ }
195
+ };
196
+
197
+ /**
198
+ * Detect a sessionless generate endpoint on the running OpenCode.
199
+ * Bundled 1.18.4 does not expose POST /generate; keep the probe so a later
200
+ * OpenCode that does can be used without changing the public completions API.
201
+ *
202
+ * SPA / OpenCode HTML often answers 200 text/html `<!doctype html>` for
203
+ * unknown paths. That is not generate — only JSON (Content-Type or body)
204
+ * counts as available.
205
+ */
206
+ export async function detectSessionlessGenerate({ fetchImpl, baseUrl, headers }) {
207
+ const clientShape = typeof arguments[0]?.client?.generate === 'function'
208
+ || typeof arguments[0]?.client?.v2?.generate === 'function';
209
+ if (clientShape) {
210
+ return { available: true, mode: 'sdk' };
211
+ }
212
+ const root = String(baseUrl || '').replace(/\/$/, '');
213
+ const candidates = [`${root}/generate`, `${root}/api/generate`];
214
+ for (const url of candidates) {
215
+ try {
216
+ const response = await fetchImpl(url, {
217
+ method: 'POST',
218
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
219
+ body: JSON.stringify({ probe: true }),
220
+ signal: AbortSignal.timeout(4_000),
221
+ });
222
+ if (response.status === 404 || response.status === 405) continue;
223
+ const body = await response.text().catch(() => '');
224
+ if (isJsonContentType(response) || looksLikeJsonObject(body)) {
225
+ return { available: true, mode: 'http', url };
226
+ }
227
+ } catch {
228
+ // Probe failure is not a generate capability.
229
+ }
230
+ }
231
+ return { available: false, mode: 'throwaway-session' };
232
+ }
233
+
234
+ const TEXT_FILE_MIME = /^(text\/|application\/(json|javascript|xml|sql|yaml|x-yaml|toml))/i;
235
+ const MAX_INLINE_FILE_CHARS = 100_000;
236
+
237
+ const messageText = (message) => {
238
+ if (typeof message?.content === 'string') return message.content;
239
+ if (Array.isArray(message?.content)) {
240
+ return message.content.map((part) => (typeof part?.text === 'string' ? part.text : '')).join('');
241
+ }
242
+ return '';
243
+ };
244
+
245
+ const messageFileParts = (message) => {
246
+ const fromParts = Array.isArray(message?.parts) ? message.parts : [];
247
+ const fromContent = Array.isArray(message?.content) ? message.content : [];
248
+ return [...fromParts, ...fromContent]
249
+ .filter((part) => part?.type === 'file' && typeof part.mime === 'string' && typeof part.url === 'string')
250
+ .map((part) => ({
251
+ type: 'file',
252
+ mime: part.mime,
253
+ url: part.url,
254
+ ...(typeof part.filename === 'string' && part.filename.trim() ? { filename: part.filename.trim() } : {}),
255
+ }));
256
+ };
257
+
258
+ const decodeDataUrlText = (url, mime) => {
259
+ if (!TEXT_FILE_MIME.test(mime) || typeof url !== 'string' || !url.startsWith('data:')) return null;
260
+ const comma = url.indexOf(',');
261
+ if (comma < 0) return null;
262
+ const meta = url.slice(5, comma);
263
+ const payload = url.slice(comma + 1);
264
+ try {
265
+ const bytes = meta.includes('base64')
266
+ ? Buffer.from(payload, 'base64')
267
+ : Buffer.from(decodeURIComponent(payload), 'utf8');
268
+ const text = bytes.toString('utf8');
269
+ return text.length > MAX_INLINE_FILE_CHARS ? `${text.slice(0, MAX_INLINE_FILE_CHARS)}\n…` : text;
270
+ } catch {
271
+ return null;
272
+ }
273
+ };
274
+
275
+ const describeContactFilePart = (part) => {
276
+ const name = part.filename || 'attachment';
277
+ const mime = part.mime || 'application/octet-stream';
278
+ if (typeof mime === 'string' && mime.startsWith('image/')) return `[image: ${name} (${mime})]`;
279
+ const decoded = decodeDataUrlText(part.url, mime);
280
+ if (decoded != null) return `[file: ${name} (${mime})]\n${decoded}`;
281
+ return `[file: ${name} (${mime})]`;
282
+ };
283
+
284
+ const flattenMessages = (messages) => {
285
+ const system = [];
286
+ const turns = [];
287
+ const files = [];
288
+ for (const message of messages) {
289
+ if (!message) continue;
290
+ const text = messageText(message);
291
+ const fileParts = message.role === 'user' ? messageFileParts(message) : [];
292
+ if (message.role === 'system') {
293
+ if (text.trim()) system.push(text);
294
+ continue;
295
+ }
296
+ if (message.role !== 'user' && message.role !== 'assistant') continue;
297
+ const body = [text, ...fileParts.map(describeContactFilePart)].filter((item) => String(item || '').trim()).join('\n');
298
+ if (!body && fileParts.length === 0) continue;
299
+ turns.push(`${message.role === 'assistant' ? 'Assistant' : 'User'}: ${body || '[attachment]'}`);
300
+ files.push(...fileParts);
301
+ }
302
+ return {
303
+ system: system.join('\n\n').trim(),
304
+ prompt: turns.join('\n\n').trim(),
305
+ files,
306
+ };
307
+ };
308
+
309
+ const filesForPrompt = (files, forwardImageParts) => {
310
+ if (forwardImageParts) return files;
311
+ return files.filter((part) => !String(part?.mime || '').startsWith('image/'));
312
+ };
313
+
314
+ async function generateViaSessionless({ fetchImpl, url, headers, providerID, modelID, messages, signal }) {
315
+ const response = await fetchImpl(url, {
316
+ method: 'POST',
317
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
318
+ body: JSON.stringify({
319
+ model: { providerID, modelID },
320
+ messages,
321
+ }),
322
+ signal,
323
+ });
324
+ if (!response.ok) {
325
+ const body = await response.text().catch(() => '');
326
+ failGenerate(`OpenCode generate failed (${response.status})${body ? `: ${body.slice(0, 200)}` : ''}`);
327
+ }
328
+ const payload = await response.json();
329
+ const text = typeof payload?.text === 'string'
330
+ ? payload.text
331
+ : typeof payload?.message === 'string'
332
+ ? payload.message
333
+ : '';
334
+ if (!text.trim()) failGenerate('OpenCode generate returned no text');
335
+ return { text: text.trim(), source: 'generate' };
336
+ }
337
+
338
+ const eventPayload = (event) => event?.payload?.payload ?? event?.payload ?? event;
339
+
340
+ const eventDeltaProperties = (payload) => {
341
+ if (!payload || typeof payload !== 'object') return null;
342
+ if (payload.properties && typeof payload.properties === 'object') return payload.properties;
343
+ if (payload.data && typeof payload.data === 'object') return payload.data;
344
+ return null;
345
+ };
346
+
347
+ /**
348
+ * Forward real OpenCode `message.part.delta` text tokens for one throwaway
349
+ * session. Returns an unsubscribe fn, or null when deltas cannot be observed
350
+ * (no callback, no hub, or hub without subscribeEvent). Never fabricates
351
+ * typewriter chunks from a completed string.
352
+ *
353
+ * @param {{ globalEventHub?: { subscribeEvent?: Function } | null, sessionID: string, onTextDelta?: ((text: string) => void) | null }} args
354
+ * @returns {(() => void) | null}
355
+ */
356
+ export function subscribeThrowawayTextDeltas({ globalEventHub, sessionID, onTextDelta }) {
357
+ if (typeof onTextDelta !== 'function') return null;
358
+ if (typeof sessionID !== 'string' || !sessionID) return null;
359
+ if (typeof globalEventHub?.subscribeEvent !== 'function') return null;
360
+
361
+ // Lock to the first assistant messageID seen for this throwaway session so
362
+ // concurrent sessions on the shared hub cannot leak tokens into this generate.
363
+ let assistantMessageID = null;
364
+
365
+ const unsubscribe = globalEventHub.subscribeEvent((event) => {
366
+ const payload = eventPayload(event);
367
+ if (payload?.type !== 'message.part.delta') return;
368
+ const props = eventDeltaProperties(payload);
369
+ if (!props) return;
370
+ if (props.sessionID !== sessionID) return;
371
+ // Text field only — reasoning/other fields stay out of completion tokens.
372
+ if (typeof props.field === 'string' && props.field !== 'text') return;
373
+ if (typeof props.delta !== 'string' || props.delta.length === 0) return;
374
+
375
+ const messageID = typeof props.messageID === 'string' && props.messageID
376
+ ? props.messageID
377
+ : null;
378
+ if (assistantMessageID) {
379
+ if (messageID && messageID !== assistantMessageID) return;
380
+ } else if (messageID) {
381
+ assistantMessageID = messageID;
382
+ }
383
+
384
+ try {
385
+ onTextDelta(props.delta);
386
+ } catch {
387
+ // Caller callback failures must not abort generate or leave the hub broken.
388
+ }
389
+ });
390
+
391
+ return typeof unsubscribe === 'function' ? unsubscribe : null;
392
+ }
393
+
394
+ /**
395
+ * Generate assistant text through OpenCode's connected providers.
396
+ * Sessionless generate if the binary exposes it; otherwise a throwaway
397
+ * archived session used only as a tools-denied text generator.
398
+ *
399
+ * V2 session.prompt only forwards { id, prompt, delivery, resume } and drops
400
+ * model/parts/tools. Use promptAsync (body still has model, parts, tools,
401
+ * system, agent) then wait for idle and read session.messages.
402
+ *
403
+ * Optional internal streaming: pass `onTextDelta(text)` plus a live
404
+ * `globalEventHub` (same hub as UI SSE). On the throwaway path only, real
405
+ * `message.part.delta` tokens for this session are forwarded. Sessionless
406
+ * `/generate` JSON cannot emit deltas — onTextDelta is skipped (no fake
407
+ * typewriter). Public HTTP completions stay non-streaming.
408
+ */
409
+ export async function generateOpenCodeText({
410
+ buildOpenCodeUrl,
411
+ getOpenCodeAuthHeaders,
412
+ providerID,
413
+ modelID,
414
+ messages,
415
+ fetchImpl = globalThis.fetch.bind(globalThis),
416
+ clientFactory,
417
+ ensureTempDirectory,
418
+ detect = detectSessionlessGenerate,
419
+ forwardImageParts = false,
420
+ onTextDelta = null,
421
+ globalEventHub = null,
422
+ }) {
423
+ if (!providerID || !modelID) {
424
+ const error = new Error('providerID and modelID are required');
425
+ error.code = 'validation_error';
426
+ throw error;
427
+ }
428
+ const flattened = flattenMessages(messages);
429
+ const promptFiles = filesForPrompt(flattened.files, forwardImageParts);
430
+ if (!flattened.prompt && promptFiles.length === 0) {
431
+ const error = new Error('messages must include a user turn');
432
+ error.code = 'validation_error';
433
+ throw error;
434
+ }
435
+
436
+ const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
437
+ const headers = getOpenCodeAuthHeaders() || {};
438
+ const probe = await detect({ fetchImpl, baseUrl, headers, client: clientFactory?.() });
439
+ const controller = new AbortController();
440
+ const timeout = setTimeout(() => controller.abort(new Error(`OpenCode LLM generate timed out after ${GENERATE_TIMEOUT_MS}ms`)), GENERATE_TIMEOUT_MS);
441
+
442
+ try {
443
+ if (probe.available && probe.mode === 'http' && probe.url) {
444
+ return await generateViaSessionless({
445
+ fetchImpl,
446
+ url: probe.url,
447
+ headers,
448
+ providerID,
449
+ modelID,
450
+ messages: forwardImageParts
451
+ ? messages
452
+ : messages.map((message) => {
453
+ if (!Array.isArray(message?.parts)) return message;
454
+ const parts = message.parts.filter((part) => part?.type !== 'file' || !String(part.mime || '').startsWith('image/'));
455
+ return parts.length === message.parts.length ? message : { ...message, parts };
456
+ }),
457
+ signal: controller.signal,
458
+ });
459
+ }
460
+
461
+ const workingDirectory = await ensureTempDirectory({
462
+ agentName: LLM_AGENT_NAME,
463
+ agentMarkdown: AGENT_MARKDOWN,
464
+ });
465
+ const client = clientFactory
466
+ ? clientFactory()
467
+ : createOpencodeClient({ baseUrl, directory: workingDirectory, headers });
468
+
469
+ const toolIds = await client.tool.ids({ directory: workingDirectory }).catch(() => ({ data: [] }));
470
+ const tools = deniedTools(Array.isArray(toolIds?.data) ? toolIds.data : []);
471
+
472
+ const created = await client.session.create({
473
+ directory: workingDirectory,
474
+ title: '[openchamber-llm] generate',
475
+ agent: LLM_AGENT_NAME,
476
+ metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
477
+ }, { signal: controller.signal });
478
+ const sessionID = created?.data?.id;
479
+ if (created?.error || !sessionID) {
480
+ failGenerate(`OpenCode LLM session create failed: ${sdkErrorMessage(created, 'create failed')}`);
481
+ }
482
+
483
+ let unsubscribeDeltas = null;
484
+ try {
485
+ const archiveAt = Date.now();
486
+ let archived = await client.session.update({
487
+ sessionID,
488
+ directory: workingDirectory,
489
+ time: { archived: archiveAt },
490
+ });
491
+ if (isMissing(archived)) {
492
+ archived = await client.session.update({
493
+ sessionID,
494
+ directory: workingDirectory,
495
+ time: { archived: archiveAt },
496
+ });
497
+ }
498
+ if (archived?.error) {
499
+ failGenerate(`OpenCode LLM session archive failed: ${sdkErrorMessage(archived, 'archive failed')}`);
500
+ }
501
+
502
+ // Subscribe before promptAsync so early message.part.delta tokens are not missed.
503
+ unsubscribeDeltas = subscribeThrowawayTextDeltas({
504
+ globalEventHub,
505
+ sessionID,
506
+ onTextDelta,
507
+ });
508
+
509
+ // promptAsync still forwards model/parts/tools. v2 session.prompt does not.
510
+ const prompted = await client.session.promptAsync({
511
+ sessionID,
512
+ directory: workingDirectory,
513
+ agent: LLM_AGENT_NAME,
514
+ model: { providerID, modelID },
515
+ ...(flattened.system ? { system: flattened.system } : {}),
516
+ tools,
517
+ parts: [
518
+ { type: 'text', text: flattened.prompt, synthetic: false },
519
+ ...promptFiles,
520
+ ],
521
+ }, { signal: controller.signal });
522
+ if (!promptAdmitted(prompted)) {
523
+ failGenerate(`OpenCode LLM promptAsync failed: ${sdkErrorMessage(prompted, 'promptAsync failed')}`);
524
+ }
525
+
526
+ let text = assistantTextFromPrompt(prompted?.data ?? prompted);
527
+ if (!text.trim()) {
528
+ const settled = await waitForIdleAssistant({
529
+ client,
530
+ sessionID,
531
+ directory: workingDirectory,
532
+ signal: controller.signal,
533
+ });
534
+ text = assistantTextFromMessages(settled);
535
+ }
536
+ if (!text.trim()) {
537
+ failGenerate('OpenCode LLM generator returned no assistant text');
538
+ }
539
+ return { text: text.trim(), source: 'throwaway-session' };
540
+ } finally {
541
+ try {
542
+ unsubscribeDeltas?.();
543
+ } catch {
544
+ // Unsubscribe must not mask generate errors or block session delete.
545
+ }
546
+ unsubscribeDeltas = null;
547
+ try {
548
+ await client.session.delete({ sessionID, directory: workingDirectory });
549
+ } catch (error) {
550
+ console.warn('[llm] failed to delete throwaway OpenCode session:', error?.message || error);
551
+ }
552
+ }
553
+ } finally {
554
+ clearTimeout(timeout);
555
+ }
556
+ }
557
+
558
+ export const _test = {
559
+ flattenMessages,
560
+ filesForPrompt,
561
+ describeContactFilePart,
562
+ deniedTools,
563
+ assistantTextFromPrompt,
564
+ assistantTextFromMessages,
565
+ eventPayload,
566
+ eventDeltaProperties,
567
+ subscribeThrowawayTextDeltas,
568
+ LLM_AGENT_NAME,
569
+ AGENT_MARKDOWN,
570
+ };