@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.
- package/dist/assets/AgentAvatar-DI2zs2WH.js +1 -0
- package/dist/assets/AssistantView-C0p-8f2B.js +2 -0
- package/dist/assets/DiagramView-CSXRZQ5S.js +1 -0
- package/dist/assets/FadeInOnReveal-BWJcUp5P.js +1 -0
- package/dist/assets/Icon-4My8YZwt.js +1 -0
- package/dist/assets/MarkdownRendererImpl-C6rkoZYH.js +7 -0
- package/dist/assets/{MarkstreamRendererImpl-CR0fBG5w.css → MarkstreamRendererImpl-Ca7RATbs.css} +1 -1
- package/dist/assets/MarkstreamRendererImpl-CowOCU5W.js +1 -0
- package/dist/assets/MobileDetailNavigation-ACMbLTqb.js +1 -0
- package/dist/assets/MobileShareBridge-CXR-B1xW.js +4 -0
- package/dist/assets/MobileSurface-CKBSKqUH.js +2 -0
- package/dist/assets/{MultiRunWindow-khpnWyZO.js → MultiRunWindow-el9_nl25.js} +1 -1
- package/dist/assets/OnboardingScreen-BNEtPmvw.js +1 -0
- package/dist/assets/OverlayScrollbar-BZB21Yp_.js +2 -0
- package/dist/assets/SettingsView-CVSiIRia.js +25 -0
- package/dist/assets/{SettingsWindow-Dx5rU9v5.js → SettingsWindow-BXYswtp5.js} +1 -1
- package/dist/assets/TerminalView-Bh0IQwa5.js +8 -0
- package/dist/assets/{ToolOutputDialog-BfuXazW9.js → ToolOutputDialog-XeIJ2etT.js} +3 -3
- package/dist/assets/appThemeRegistry-rSdmh4sW.js +2 -0
- package/dist/assets/{button-z0GOBtWi.js → button-Or0daT9o.js} +1 -1
- package/dist/assets/clipboard-BXxLshVq.js +1 -0
- package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-C-kzSGkq.js} +1 -1
- package/dist/assets/context-menu-Dpjla5p4.js +1 -0
- package/dist/assets/desktopHosts-BzSE0sK7.js +1 -0
- package/dist/assets/desktopRelayRestore-BaKjk1kQ.js +1 -0
- package/dist/assets/{diagram-CoEqmReY.js → diagram-D0mT3mo1.js} +1 -1
- package/dist/assets/{dialog-HCE4eLUW.js → dialog-BkhHBTd4.js} +1 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
- package/dist/assets/{es-D9KcV5TX.js → es-BHqnSjoZ.js} +1 -1
- package/dist/assets/fileQueries-DFY6Kiy1.js +1 -0
- package/dist/assets/fileReferenceActions-3pPDAJSl.js +1 -0
- package/dist/assets/{fr-DPA9qUl3.js → fr-BfrlJY9i.js} +1 -1
- package/dist/assets/{gitApi-DNTMAa3F.js → gitApi-Uvb9w70Y.js} +3 -3
- package/dist/assets/{input-BG3VWTIF.js → input-BUsGnzwe.js} +1 -1
- package/dist/assets/insertionBoundaries-Bgec5nsP.js +4 -0
- package/dist/assets/{ja-Dsif3NoD.js → ja-DP-7qt-x.js} +1 -1
- package/dist/assets/{ko-Dixx4Ctg.js → ko-E2B5vGQP.js} +1 -1
- package/dist/assets/main-COfQt8Sj.js +169 -0
- package/dist/assets/main-PPlpRj1-.js +2 -0
- package/dist/assets/markdown-worker-wEFz5Hm_.js +3 -0
- package/dist/assets/markdownHeightCache-Bj707yG-.js +2 -0
- package/dist/assets/markdownTheme-CVS01wHf.js +2 -0
- package/dist/assets/miniChat-EZGRqQZr.js +2 -0
- package/dist/assets/mobile-DQNqMo5J.js +2 -0
- package/dist/assets/multirun-EViX73vm.js +4 -0
- package/dist/assets/outsideFileGrants-BB4GPvCq.js +1 -0
- package/dist/assets/path-utils-jGozTg9d.js +1 -0
- package/dist/assets/persistence-CSZp1CNB.js +3 -0
- package/dist/assets/{pl-Bj78ZGgf.js → pl-r8X5gRM5.js} +1 -1
- package/dist/assets/platform-CenlWqxu.js +2 -0
- package/dist/assets/projectMeta-Bv1L_nEb.js +3 -0
- package/dist/assets/{pt-BR-CFL3xSAf.js → pt-BR-DcpdXCf5.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-Nsq4Wc4s.js +2 -0
- package/dist/assets/renderMobileApp-5WJDYaMR.js +28 -0
- package/dist/assets/runtimeConfig-DKUQS4iY.js +2 -0
- package/dist/assets/runtimeEndpointReset-BMeu9Hqj.js +147 -0
- package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
- package/dist/assets/sessionLookup-Sb2HN3G2.js +1 -0
- package/dist/assets/sortable-tabs-strip-C5gGnikj.js +1 -0
- package/dist/assets/src-Af9cMFKF.css +2 -0
- package/dist/assets/{store-DzpbXWX1.js → store-BDFdnk6l.js} +2 -2
- package/dist/assets/terminalApi-CIIs9vFx.js +1 -0
- package/dist/assets/textarea-CIO34TZz.js +1 -0
- package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BSrJYYYc.js} +1 -1
- package/dist/assets/{uk-BY60Ub3Z.js → uk-Di0vl9V-.js} +1 -1
- package/dist/assets/url-CE_gAfNa.js +1 -0
- package/dist/assets/{ChatContainer-CnOrdAgs.js → useAppFontEffects-B_6vc0u3.js} +46 -41
- package/dist/assets/useAssistantUIStore-BHacrxe_.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-B2L5PWHW.js +1 -0
- package/dist/assets/useEffectiveDirectory-CBd5vt8N.js +1 -0
- package/dist/assets/useFontPreferences-BxOHKuVw.js +1 -0
- package/dist/assets/useMobileNavigationStore-CUGaydRW.js +412 -0
- package/dist/assets/useSessionAutoCleanup-Clhzc1wn.js +1 -0
- package/dist/assets/useUIStore-CqqO11ZI.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-C4iQlbPQ.js +7 -0
- package/dist/assets/utils-B0mQXN9L.js +1 -0
- package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
- package/dist/assets/{zh-CN-Cox1LaOW.js → zh-CN-coFCfeQE.js} +1 -1
- package/dist/assets/{zh-TW-B6RU0mxa.js → zh-TW-oDRZ1-yb.js} +1 -1
- package/dist/index.html +9 -11
- package/dist/mini-chat.html +9 -11
- package/dist/mobile.html +15 -17
- package/package.json +2 -1
- package/server/index.js +2 -0
- package/server/lib/assistant-delivery-parts.js +3 -1
- package/server/lib/assistant-delivery-parts.test.js +11 -0
- package/server/lib/assistants/DOCUMENTATION.md +76 -3
- package/server/lib/assistants/assign.js +225 -0
- package/server/lib/assistants/assign.test.js +118 -0
- package/server/lib/assistants/bubbles.js +45 -0
- package/server/lib/assistants/bubbles.test.js +36 -0
- package/server/lib/assistants/cards.js +148 -0
- package/server/lib/assistants/cards.test.js +116 -0
- package/server/lib/assistants/contact-store.js +284 -0
- package/server/lib/assistants/contact-store.test.js +131 -0
- package/server/lib/assistants/contact-tools.js +761 -0
- package/server/lib/assistants/contact-tools.test.js +328 -0
- package/server/lib/assistants/contracts.js +20 -1
- package/server/lib/assistants/harness.js +551 -0
- package/server/lib/assistants/harness.test.js +607 -0
- package/server/lib/assistants/routes.js +44 -4
- package/server/lib/assistants/service.js +930 -17
- package/server/lib/assistants/service.test.js +1163 -45
- package/server/lib/llm/DOCUMENTATION.md +84 -0
- package/server/lib/llm/catalog.js +100 -0
- package/server/lib/llm/catalog.test.js +78 -0
- package/server/lib/llm/completions.js +151 -0
- package/server/lib/llm/completions.test.js +127 -0
- package/server/lib/llm/generate.js +570 -0
- package/server/lib/llm/generate.test.js +459 -0
- package/server/lib/llm/routes.js +47 -0
- package/server/lib/llm/temp-directory.js +37 -0
- package/server/lib/notifications/APNS.md +4 -4
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/apns-runtime.js +139 -20
- package/server/lib/notifications/apns-runtime.test.js +66 -7
- package/server/lib/notifications/apns-titles.js +62 -62
- package/server/lib/notifications/apns-titles.test.js +3 -1
- package/server/lib/notifications/routes.js +1 -1
- package/server/lib/notifications/runtime.js +81 -49
- package/server/lib/notifications/runtime.test.js +61 -0
- package/server/lib/opencode/feature-routes-runtime.js +30 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
- package/server/lib/opencode/settings-runtime.js +18 -2
- package/server/lib/session-goal/DOCUMENTATION.md +8 -1
- package/server/lib/session-goal/runtime.js +20 -0
- package/server/lib/session-index/DOCUMENTATION.md +3 -1
- package/server/lib/session-index/service.js +10 -4
- package/server/lib/session-index/service.test.js +37 -0
- package/server/lib/session-title/runtime.js +1 -0
- package/server/lib/session-title/runtime.test.js +11 -0
- package/dist/assets/AgentAvatar-ziUs4sw3.js +0 -1
- package/dist/assets/AssistantView-DF4yXFgS.js +0 -1
- package/dist/assets/ContextPanel-Cg_uCHzJ.js +0 -285
- package/dist/assets/DiagramView-DYh3Zn2y.js +0 -1
- package/dist/assets/FadeInOnReveal-BAlsRFJH.js +0 -1
- package/dist/assets/Icon-CLpn1mIz.js +0 -1
- package/dist/assets/MarkdownRendererImpl-QzUEpBEm.js +0 -7
- package/dist/assets/MarkstreamRendererImpl-CXrfeUia.js +0 -1
- package/dist/assets/MobileDetailNavigation-DJXEYfq4.js +0 -1
- package/dist/assets/MobileShareBridge-CliVo1YH.js +0 -4
- package/dist/assets/MobileSurface-9Xh6WQkY.js +0 -2
- package/dist/assets/OnboardingScreen-CJYyR54B.js +0 -1
- package/dist/assets/OverlayScrollbar-C-jZ8ITG.js +0 -2
- package/dist/assets/SettingsView-DyCZehFG.js +0 -25
- package/dist/assets/TerminalView-XSALfUaH.js +0 -8
- package/dist/assets/appThemeRegistry-DDWrniMM.js +0 -2
- package/dist/assets/clipboard-Bz0Zm9VJ.js +0 -1
- package/dist/assets/desktop-CfLlmafK.js +0 -1
- package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
- package/dist/assets/desktopRelayRestore-LBt0CBOj.js +0 -1
- package/dist/assets/fileQueries-Dnn15WsA.js +0 -1
- package/dist/assets/insertionBoundaries-DMxuUnL_.js +0 -4
- package/dist/assets/main-B9HVtgXE.js +0 -2
- package/dist/assets/main-ZNyIs3Sn.js +0 -29
- package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
- package/dist/assets/markdownHeightCache-CvUyZT4q.js +0 -1
- package/dist/assets/markdownTheme-TdhQOZSN.js +0 -2
- package/dist/assets/miniChat-q0T0WGkZ.js +0 -2
- package/dist/assets/mobile-DEduvLJj.js +0 -2
- package/dist/assets/multirun-DU-wrxlG.js +0 -4
- package/dist/assets/persistence-CmjKImz2.js +0 -3
- package/dist/assets/platform-skqcMARt.js +0 -2
- package/dist/assets/projectMeta-DNWx6Uva.js +0 -3
- package/dist/assets/radio-BEonIX1k.js +0 -1
- package/dist/assets/renderElectronMiniChatApp--JYWY41L.js +0 -2
- package/dist/assets/renderMobileApp-B0Ix_uNo.js +0 -25
- package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
- package/dist/assets/runtimeConfig-BTDIG7mX.js +0 -2
- package/dist/assets/runtimeEndpointReset-CZQCYtVI.js +0 -4
- package/dist/assets/shikiHighlight-C9Cx56Qg.js +0 -1
- package/dist/assets/sortable-tabs-strip-Bj8N0VWf.js +0 -1
- package/dist/assets/src-2eFAiBLq.css +0 -2
- package/dist/assets/terminalApi-CNqqwjfG.js +0 -1
- package/dist/assets/url-KeihbE0d.js +0 -1
- package/dist/assets/useAppFontEffects-CKLSamae.js +0 -7
- package/dist/assets/useAssistantUIStore-DaplPyb0.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DJSkFfhT.js +0 -1
- package/dist/assets/useEffectiveDirectory-B3rbY1Kv.js +0 -1
- package/dist/assets/useFontPreferences-D8emhcNv.js +0 -1
- package/dist/assets/useMobileNavigationStore-CAvVUItx.js +0 -412
- package/dist/assets/useSessionAutoCleanup-DxNgqJ8s.js +0 -1
- package/dist/assets/useUIStore-B7KJS5G7.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-DiDF68LR.js +0 -7
- 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
|
+
};
|