@openchambery/web 1.19.20 → 2.0.0-beta.1
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/README.md +13 -11
- package/bin/cli-opencode.test.js +208 -0
- package/bin/cli.js +62 -9
- package/bin/lib/cli-startup.js +105 -2
- package/bin/lib/cli-startup.test.js +165 -0
- package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
- package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
- package/dist/assets/{DiagramView-C_JfbxNl.js → DiagramView-xuSJTnv7.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
- package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
- package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-B1iUwzOz.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-BPtMj3-X.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
- package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
- package/dist/assets/{MobileShareBridge-CKClHsWN.js → MobileShareBridge-BQlP4J-F.js} +1 -1
- package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
- package/dist/assets/{MultiRunWindow-DiA24bnN.js → MultiRunWindow-ChZGob0v.js} +1 -1
- package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
- package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
- package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
- package/dist/assets/{SettingsWindow-XlzRSS7E.js → SettingsWindow-DfnsFa8y.js} +1 -1
- package/dist/assets/{TerminalView-DdIaaLba.js → TerminalView-Dh9MWCpS.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BbwuLdXU.js → ToolOutputDialog-BejmjrRk.js} +1 -1
- package/dist/assets/{appThemeRegistry-ChzfD7mc.js → appThemeRegistry-COGfxTFV.js} +1 -1
- package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
- package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
- package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
- package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
- package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
- package/dist/assets/es-c52LTHHe.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-DyjmxUEN.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
- package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
- package/dist/assets/gitApi-ButwzQ0N.js +9 -0
- package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
- package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
- package/dist/assets/ja-C8Ikun3Z.js +15 -0
- package/dist/assets/ko-ClFys5o2.js +15 -0
- package/dist/assets/main-CCE_4y5F.js +2 -0
- package/dist/assets/main-Dn9rpsgl.js +169 -0
- package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
- package/dist/assets/miniChat-BpjxVqgg.js +2 -0
- package/dist/assets/mobile-nMJCaNq1.js +2 -0
- package/dist/assets/{multirun-8fkF0Ji_.js → multirun-BvSG94mW.js} +1 -1
- package/dist/assets/pl-CnaKWN5F.js +15 -0
- package/dist/assets/platform-CWTIp02q.js +4 -0
- package/dist/assets/{projectMeta--y6tyjrv.js → projectMeta-DOe49dM_.js} +2 -2
- package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
- package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
- package/dist/assets/{runtimeConfig-DYWtBrkW.js → runtimeConfig-BMvqa5we.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-BSWg99jc.js → runtimeEndpointReset-B6BxhvR0.js} +5 -5
- package/dist/assets/{sessionChangeSummary-BwupJF-Q.js → sessionChangeSummary-VVBlEXuh.js} +1 -1
- package/dist/assets/{sessionLookup-DP2X1qRR.js → sessionLookup-gh-hVRIc.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
- package/dist/assets/{src-DZ60kHBP.css → src-BzX5UI-c.css} +1 -1
- package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
- package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
- package/dist/assets/{textarea-BnD799LW.js → textarea-3XR8sUfy.js} +1 -1
- package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
- package/dist/assets/uk-CaX0iqkB.js +15 -0
- package/dist/assets/{useAppFontEffects-DWLxpWlS.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
- package/dist/assets/{useDesktopWindowControlsLayout-BGPR_rq6.js → useDesktopWindowControlsLayout-B1GX59-L.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-CbV9_r1s.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
- package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
- package/dist/assets/{useSessionAutoCleanup-Dnors7hB.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DQqqrOBU.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
- package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
- package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
- package/dist/assets/zh-CN-20MYEjha.js +15 -0
- package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
- package/dist/index.html +6 -6
- package/dist/mini-chat.html +6 -6
- package/dist/mobile.html +6 -6
- package/package.json +2 -2
- package/server/index.js +107 -0
- package/server/lib/assistants/DOCUMENTATION.md +18 -18
- package/server/lib/assistants/assign.js +2 -1
- package/server/lib/assistants/contact-store.js +37 -19
- package/server/lib/assistants/projection-history.test.js +207 -0
- package/server/lib/assistants/routes.js +5 -5
- package/server/lib/assistants/service.js +493 -123
- package/server/lib/assistants/service.test.js +203 -133
- package/server/lib/conversations/DOCUMENTATION.md +4 -4
- package/server/lib/conversations/service.js +86 -57
- package/server/lib/conversations/service.test.js +116 -100
- package/server/lib/event-stream/global-hub.js +2 -1
- package/server/lib/llm/DOCUMENTATION.md +71 -84
- package/server/lib/llm/catalog.js +111 -45
- package/server/lib/llm/catalog.test.js +121 -59
- package/server/lib/llm/completions.js +37 -12
- package/server/lib/llm/completions.test.js +45 -4
- package/server/lib/llm/generate.js +421 -450
- package/server/lib/llm/generate.test.js +553 -487
- package/server/lib/llm/routes.js +6 -4
- package/server/lib/message-queue/DOCUMENTATION.md +5 -5
- package/server/lib/message-queue/opencode-adapter.js +191 -104
- package/server/lib/message-queue/opencode-adapter.test.js +328 -58
- package/server/lib/message-queue/runtime.test.js +6 -6
- package/server/lib/message-queue/worker.js +7 -1
- package/server/lib/opencode/DOCUMENTATION.md +28 -5
- package/server/lib/opencode/config-entity-routes.js +84 -13
- package/server/lib/opencode/config-entity-routes.test.js +41 -10
- package/server/lib/opencode/ensure-cli.js +239 -0
- package/server/lib/opencode/ensure-cli.test.js +119 -0
- package/server/lib/opencode/env-runtime.js +193 -110
- package/server/lib/opencode/env-runtime.test.js +221 -17
- package/server/lib/opencode/feature-routes-runtime.js +37 -1
- package/server/lib/opencode/lifecycle.js +204 -70
- package/server/lib/opencode/lifecycle.test.js +376 -6
- package/server/lib/opencode/network-runtime.js +28 -16
- package/server/lib/opencode/network-runtime.test.js +68 -4
- package/server/lib/opencode/opencode2-pin.js +139 -0
- package/server/lib/opencode/opencode2-pin.test.js +87 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
- package/server/lib/opencode/plugin-compatibility.js +15 -0
- package/server/lib/opencode/plugin-compatibility.test.js +21 -0
- package/server/lib/opencode/plugins.js +4 -3
- package/server/lib/opencode/plugins.test.js +12 -0
- package/server/lib/opencode/proxy.js +153 -16
- package/server/lib/opencode/proxy.test.js +120 -0
- package/server/lib/opencode/routes.js +25 -32
- package/server/lib/opencode/routes.test.js +51 -52
- package/server/lib/opencode/server-utils-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +6 -7
- package/server/lib/opencode/skill-routes.test.js +36 -5
- package/server/lib/opencode/v1-migration-gate.js +157 -0
- package/server/lib/opencode/v1-migration-gate.test.js +111 -0
- package/server/lib/opencode/v2-client.js +49 -0
- package/server/lib/opencode/watcher.js +2 -1
- package/server/lib/opencode/watcher.test.js +1 -1
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
- package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
- package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
- package/server/lib/scheduled-tasks/routes.js +27 -0
- package/server/lib/scheduled-tasks/routes.test.js +94 -0
- package/server/lib/scheduled-tasks/runtime.js +331 -308
- package/server/lib/scheduled-tasks/runtime.test.js +530 -318
- package/server/lib/session-goal/DOCUMENTATION.md +65 -14
- package/server/lib/session-goal/capability.js +57 -0
- package/server/lib/session-goal/capability.test.js +33 -0
- package/server/lib/session-goal/routes.js +231 -2
- package/server/lib/session-goal/routes.test.js +211 -0
- package/server/lib/session-goal/runtime.js +46 -4
- package/server/lib/session-goal/runtime.test.js +62 -1
- package/server/lib/session-index/sync-runtime.js +20 -17
- package/server/lib/session-index/sync-runtime.test.js +9 -8
- package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
- package/server/lib/session-metadata/routes.js +94 -0
- package/server/lib/session-metadata/routes.test.js +111 -0
- package/server/lib/session-metadata/session-metadata-store.js +235 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
- package/server/lib/session-turn-pages/reconcile.service.js +1 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
- package/server/lib/session-turn-pages/routes.js +204 -71
- package/server/lib/session-turn-pages/routes.test.js +285 -0
- package/server/lib/session-turn-pages/service.js +6 -2
- package/server/lib/session-turn-pages/service.test.js +34 -0
- package/server/lib/small-model/DOCUMENTATION.md +5 -4
- package/server/lib/small-model/catalog.js +56 -6
- package/server/lib/small-model/catalog.test.js +47 -0
- package/server/lib/small-model/index.js +3 -3
- package/server/lib/small-model/opencode-session.js +50 -206
- package/server/lib/small-model/opencode-session.test.js +58 -88
- package/server/opencode-proxy.test.js +61 -12
- package/dist/assets/AssistantView-BjvfkSRS.js +0 -3
- package/dist/assets/MarkdownRenderer-Cg82L3fN.js +0 -2
- package/dist/assets/MobileDetailNavigation-s_vvUD_F.js +0 -1
- package/dist/assets/MobileSurface-DHpXHbJp.js +0 -2
- package/dist/assets/SettingsView-l0J7pfYo.js +0 -25
- package/dist/assets/es-rqMiRP7-.js +0 -15
- package/dist/assets/gitApi-CHpibtno.js +0 -7
- package/dist/assets/insertionBoundaries-Yd1Lorjw.js +0 -1
- package/dist/assets/ja-Bgr8-dOg.js +0 -15
- package/dist/assets/ko-B_Gxh-0l.js +0 -15
- package/dist/assets/main-BoRXxmB3.js +0 -2
- package/dist/assets/main-DCDcdV5o.js +0 -169
- package/dist/assets/miniChat-12fxk5KR.js +0 -2
- package/dist/assets/mobile-CkZem9Ad.js +0 -2
- package/dist/assets/pl-DW2XJGC4.js +0 -15
- package/dist/assets/platform-0w_Q9_ji.js +0 -2
- package/dist/assets/pt-BR-CIsanf4T.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-COFc0ZDJ.js +0 -2
- package/dist/assets/renderMobileApp-BKzx7JL3.js +0 -28
- package/dist/assets/uk-BPmPEUYJ.js +0 -15
- package/dist/assets/useMobileNavigationStore-DrB_awgk.js +0 -413
- package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
- package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
|
@@ -1,36 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { OpenCode } from '@opencode/client';
|
|
2
2
|
|
|
3
3
|
const LLM_AGENT_NAME = 'openchamber-llm';
|
|
4
4
|
const GENERATE_TIMEOUT_MS = 90_000;
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
const
|
|
5
|
+
/** Single attachment payload cap (decoded bytes). Composer UI allows larger drafts; gateway enforces this. */
|
|
6
|
+
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
7
|
+
const MAX_INLINE_FILE_CHARS = 100_000;
|
|
8
|
+
const TEXT_FILE_MIME = /^(text\/|application\/(json|javascript|xml|sql|yaml|x-yaml|toml))/i;
|
|
8
9
|
|
|
9
10
|
const positiveMs = (value, fallback) => (
|
|
10
11
|
Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback
|
|
11
12
|
);
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* 1.18.23/1.18.29: create returns the same rule on the session object.
|
|
17
|
-
*/
|
|
18
|
-
export const LLM_SESSION_DENY_PERMISSION = Object.freeze([
|
|
19
|
-
Object.freeze({ permission: '*', pattern: '*', action: 'deny' }),
|
|
20
|
-
]);
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Agent frontmatter for OpenCode 1.18. Runtime /agent metadata uses
|
|
24
|
-
* permission:[{permission,pattern,action}]. Markdown config accepts the
|
|
25
|
-
* PermissionActionConfig map (`"*": deny`) / scalar `deny` / tools:"*":false —
|
|
26
|
-
* not the legacy action/resource/effect list (that loads but does not add
|
|
27
|
-
* a terminal * deny, so build-like allows and skill guidance stay visible).
|
|
15
|
+
* Agent frontmatter for official client agent permissions (action/resource/effect).
|
|
16
|
+
* The final rule must be deny-all; assertLlmAgentDenyAll verifies that before prompt.
|
|
28
17
|
*/
|
|
29
18
|
const AGENT_MARKDOWN = `---
|
|
30
19
|
mode: primary
|
|
31
20
|
hidden: true
|
|
32
|
-
|
|
33
|
-
"*"
|
|
21
|
+
permissions:
|
|
22
|
+
- action: "*"
|
|
23
|
+
resource: "*"
|
|
24
|
+
effect: deny
|
|
34
25
|
---
|
|
35
26
|
|
|
36
27
|
You generate responses for an application-owned assistant. Follow the supplied system instructions and response format.
|
|
@@ -40,51 +31,31 @@ Report execution and failures from supplied tool results only.
|
|
|
40
31
|
Do not call native OpenCode tools, MCP tools, or skill tools. Native permissions are denied for this generator session.
|
|
41
32
|
`;
|
|
42
33
|
|
|
43
|
-
const
|
|
44
|
-
result?.error?.status === 404
|
|
45
|
-
|| result?.error?.statusCode === 404
|
|
46
|
-
|| result?.error?.code === 'not_found'
|
|
47
|
-
|| result?.status === 404;
|
|
48
|
-
|
|
49
|
-
const promptAdmitted = (result) =>
|
|
50
|
-
!result?.error
|
|
51
|
-
&& (result?.response?.status === 204
|
|
52
|
-
|| result?.status === 204
|
|
53
|
-
|| result?.data !== undefined
|
|
54
|
-
|| result?.response?.ok === true);
|
|
55
|
-
|
|
56
|
-
const sdkErrorMessage = (result, fallback) => {
|
|
57
|
-
const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status;
|
|
58
|
-
const message = result?.error?.message || result?.error?.data?.message || fallback;
|
|
59
|
-
return status ? `${message} (${status})` : message;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const failGenerate = (message) => {
|
|
34
|
+
const failGenerate = (message, code = 'upstream_error') => {
|
|
63
35
|
const error = new Error(message);
|
|
64
|
-
error.code =
|
|
36
|
+
error.code = code;
|
|
65
37
|
throw error;
|
|
66
38
|
};
|
|
67
39
|
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const timer = setTimeout(() => {
|
|
74
|
-
signal?.removeEventListener?.('abort', onAbort);
|
|
75
|
-
resolve();
|
|
76
|
-
}, ms);
|
|
77
|
-
const onAbort = () => {
|
|
78
|
-
clearTimeout(timer);
|
|
79
|
-
reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
|
|
80
|
-
};
|
|
81
|
-
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
82
|
-
});
|
|
40
|
+
const clientErrorMessage = (error, fallback) => {
|
|
41
|
+
if (typeof error?.message === 'string' && error.message.trim()) return error.message;
|
|
42
|
+
if (typeof error === 'string' && error.trim()) return error;
|
|
43
|
+
return fallback;
|
|
44
|
+
};
|
|
83
45
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
46
|
+
const isAbortLike = (error, signal) => (
|
|
47
|
+
signal?.aborted
|
|
48
|
+
|| error?.name === 'AbortError'
|
|
49
|
+
|| error?.code === 'ABORT_ERR'
|
|
50
|
+
|| (typeof error?.message === 'string' && /aborted|This operation was aborted/i.test(error.message))
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const rethrowIfAborted = (error, signal) => {
|
|
54
|
+
if (!isAbortLike(error, signal)) return;
|
|
55
|
+
if (error instanceof Error) throw error;
|
|
56
|
+
const next = new Error(clientErrorMessage(error, 'aborted'));
|
|
57
|
+
next.name = 'AbortError';
|
|
58
|
+
throw next;
|
|
88
59
|
};
|
|
89
60
|
|
|
90
61
|
const assistantErrorDetail = (error) => {
|
|
@@ -100,179 +71,6 @@ const assistantErrorDetail = (error) => {
|
|
|
100
71
|
return '';
|
|
101
72
|
};
|
|
102
73
|
|
|
103
|
-
const assistantTextFromPrompt = (data) => {
|
|
104
|
-
const parts = Array.isArray(data?.parts) ? data.parts : Array.isArray(data?.data?.parts) ? data.data.parts : [];
|
|
105
|
-
const text = parts
|
|
106
|
-
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
107
|
-
.map((part) => part.text)
|
|
108
|
-
.join('');
|
|
109
|
-
if (text.trim()) return text;
|
|
110
|
-
const info = data?.info ?? data?.data?.info;
|
|
111
|
-
if (info?.error) {
|
|
112
|
-
const detail = assistantErrorDetail(info.error);
|
|
113
|
-
if (detail) failGenerate(detail);
|
|
114
|
-
}
|
|
115
|
-
return '';
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const assistantTextFromMessages = (messages) => {
|
|
119
|
-
if (!Array.isArray(messages) || messages.length === 0) return '';
|
|
120
|
-
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
121
|
-
const message = messages[i];
|
|
122
|
-
const info = readMessageInfo(message);
|
|
123
|
-
if (info?.role !== 'assistant') continue;
|
|
124
|
-
if (info.error) {
|
|
125
|
-
const detail = assistantErrorDetail(info.error);
|
|
126
|
-
failGenerate(detail || 'OpenCode assistant error');
|
|
127
|
-
}
|
|
128
|
-
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
129
|
-
return parts
|
|
130
|
-
.map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
|
|
131
|
-
.filter(Boolean)
|
|
132
|
-
.join('');
|
|
133
|
-
}
|
|
134
|
-
return '';
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const deniedTools = (ids) => {
|
|
138
|
-
const tools = Object.create(null);
|
|
139
|
-
for (const id of ids) {
|
|
140
|
-
if (typeof id === 'string' && id.trim()) tools[id.trim()] = false;
|
|
141
|
-
}
|
|
142
|
-
return tools;
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
const waitForIdleAssistant = async ({
|
|
146
|
-
client,
|
|
147
|
-
sessionID,
|
|
148
|
-
directory,
|
|
149
|
-
signal,
|
|
150
|
-
onProgress = null,
|
|
151
|
-
settlePollMs = SETTLE_POLL_MS,
|
|
152
|
-
}) => {
|
|
153
|
-
let incompleteAssistantProbes = 0;
|
|
154
|
-
let emptyIdleProbes = 0;
|
|
155
|
-
const pollMs = positiveMs(settlePollMs, SETTLE_POLL_MS);
|
|
156
|
-
|
|
157
|
-
for (;;) {
|
|
158
|
-
signal?.throwIfAborted?.();
|
|
159
|
-
|
|
160
|
-
let sessionBusy = false;
|
|
161
|
-
try {
|
|
162
|
-
const statusResult = await client.session.status({ directory }, { signal });
|
|
163
|
-
if (!statusResult?.error && statusResult?.data && typeof statusResult.data === 'object') {
|
|
164
|
-
const statusValue = statusResult.data[sessionID];
|
|
165
|
-
const type = statusValue?.type ?? statusValue?.status;
|
|
166
|
-
sessionBusy = type === 'busy' || type === 'retry';
|
|
167
|
-
}
|
|
168
|
-
} catch (error) {
|
|
169
|
-
if (signal?.aborted) throw error;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (sessionBusy) {
|
|
173
|
-
incompleteAssistantProbes = 0;
|
|
174
|
-
emptyIdleProbes = 0;
|
|
175
|
-
onProgress?.();
|
|
176
|
-
await sleep(pollMs, signal);
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
const messagesResult = await client.session.messages({
|
|
182
|
-
sessionID,
|
|
183
|
-
directory,
|
|
184
|
-
limit: 20,
|
|
185
|
-
}, { signal });
|
|
186
|
-
if (messagesResult?.error) {
|
|
187
|
-
// Deterministic upstream failures (e.g. 400) must not look like "still settling".
|
|
188
|
-
failGenerate(`OpenCode LLM session.messages failed: ${sdkErrorMessage(messagesResult, 'messages failed')}`);
|
|
189
|
-
}
|
|
190
|
-
if (Array.isArray(messagesResult?.data)) {
|
|
191
|
-
const lastInfo = readMessageInfo(messagesResult.data.at(-1));
|
|
192
|
-
if (lastInfo?.role === 'assistant') {
|
|
193
|
-
emptyIdleProbes = 0;
|
|
194
|
-
if (lastInfo.error) {
|
|
195
|
-
const detail = assistantErrorDetail(lastInfo.error);
|
|
196
|
-
failGenerate(detail || 'OpenCode assistant error');
|
|
197
|
-
}
|
|
198
|
-
if (lastInfo.time?.completed) {
|
|
199
|
-
return messagesResult.data;
|
|
200
|
-
}
|
|
201
|
-
incompleteAssistantProbes += 1;
|
|
202
|
-
onProgress?.();
|
|
203
|
-
if (incompleteAssistantProbes >= INCOMPLETE_ASSISTANT_SETTLE_PROBES) {
|
|
204
|
-
return messagesResult.data;
|
|
205
|
-
}
|
|
206
|
-
} else {
|
|
207
|
-
incompleteAssistantProbes = 0;
|
|
208
|
-
emptyIdleProbes += 1;
|
|
209
|
-
if (emptyIdleProbes >= EMPTY_IDLE_PROBES && lastInfo?.role === 'user') {
|
|
210
|
-
failGenerate('OpenCode LLM generator ended without assistant text');
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
} catch (error) {
|
|
215
|
-
if (signal?.aborted) throw error;
|
|
216
|
-
if (error?.code === 'upstream_error') throw error;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
await sleep(pollMs, signal);
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const isJsonContentType = (response) => /json/i.test(response?.headers?.get?.('content-type') || '');
|
|
224
|
-
|
|
225
|
-
const looksLikeJsonObject = (text) => {
|
|
226
|
-
const trimmed = String(text || '').trim();
|
|
227
|
-
if (!trimmed || trimmed.startsWith('<')) return false;
|
|
228
|
-
try {
|
|
229
|
-
const parsed = JSON.parse(trimmed);
|
|
230
|
-
return parsed !== null && typeof parsed === 'object';
|
|
231
|
-
} catch {
|
|
232
|
-
return false;
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Detect a sessionless generate endpoint on the running OpenCode.
|
|
238
|
-
* Bundled 1.18.4 does not expose POST /generate; keep the probe so a later
|
|
239
|
-
* OpenCode that does can be used without changing the public completions API.
|
|
240
|
-
*
|
|
241
|
-
* SPA / OpenCode HTML often answers 200 text/html `<!doctype html>` for
|
|
242
|
-
* unknown paths. That is not generate — only JSON (Content-Type or body)
|
|
243
|
-
* counts as available.
|
|
244
|
-
*/
|
|
245
|
-
export async function detectSessionlessGenerate({ fetchImpl, baseUrl, headers }) {
|
|
246
|
-
const clientShape = typeof arguments[0]?.client?.generate === 'function'
|
|
247
|
-
|| typeof arguments[0]?.client?.v2?.generate === 'function';
|
|
248
|
-
if (clientShape) {
|
|
249
|
-
return { available: true, mode: 'sdk' };
|
|
250
|
-
}
|
|
251
|
-
const root = String(baseUrl || '').replace(/\/$/, '');
|
|
252
|
-
const candidates = [`${root}/generate`, `${root}/api/generate`];
|
|
253
|
-
for (const url of candidates) {
|
|
254
|
-
try {
|
|
255
|
-
const response = await fetchImpl(url, {
|
|
256
|
-
method: 'POST',
|
|
257
|
-
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
|
|
258
|
-
body: JSON.stringify({ probe: true }),
|
|
259
|
-
signal: AbortSignal.timeout(4_000),
|
|
260
|
-
});
|
|
261
|
-
if (response.status === 404 || response.status === 405) continue;
|
|
262
|
-
const body = await response.text().catch(() => '');
|
|
263
|
-
if (isJsonContentType(response) || looksLikeJsonObject(body)) {
|
|
264
|
-
return { available: true, mode: 'http', url };
|
|
265
|
-
}
|
|
266
|
-
} catch {
|
|
267
|
-
// Probe failure is not a generate capability.
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
return { available: false, mode: 'throwaway-session' };
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const TEXT_FILE_MIME = /^(text\/|application\/(json|javascript|xml|sql|yaml|x-yaml|toml))/i;
|
|
274
|
-
const MAX_INLINE_FILE_CHARS = 100_000;
|
|
275
|
-
|
|
276
74
|
const messageText = (message) => {
|
|
277
75
|
if (typeof message?.content === 'string') return message.content;
|
|
278
76
|
if (Array.isArray(message?.content)) {
|
|
@@ -294,21 +92,51 @@ const messageFileParts = (message) => {
|
|
|
294
92
|
}));
|
|
295
93
|
};
|
|
296
94
|
|
|
297
|
-
|
|
298
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Strict data-URL parse. Returns null when the URI is not a well-formed data URL.
|
|
97
|
+
* Decoded size is checked against MAX_ATTACHMENT_BYTES.
|
|
98
|
+
*/
|
|
99
|
+
export function parseDataUrl(url) {
|
|
100
|
+
if (typeof url !== 'string' || !url.startsWith('data:')) return null;
|
|
299
101
|
const comma = url.indexOf(',');
|
|
300
|
-
if (comma <
|
|
102
|
+
if (comma < 5) return null;
|
|
301
103
|
const meta = url.slice(5, comma);
|
|
302
104
|
const payload = url.slice(comma + 1);
|
|
105
|
+
if (!meta || payload.length === 0) return null;
|
|
106
|
+
const parts = meta.split(';');
|
|
107
|
+
const mime = parts[0] && /^[\w.+-]+\/[\w.+-]+$/i.test(parts[0]) ? parts[0] : null;
|
|
108
|
+
if (!mime) return null;
|
|
109
|
+
const isBase64 = parts.some((part) => part.toLowerCase() === 'base64');
|
|
110
|
+
let bytes;
|
|
303
111
|
try {
|
|
304
|
-
|
|
112
|
+
bytes = isBase64
|
|
305
113
|
? Buffer.from(payload, 'base64')
|
|
306
114
|
: Buffer.from(decodeURIComponent(payload), 'utf8');
|
|
307
|
-
const text = bytes.toString('utf8');
|
|
308
|
-
return text.length > MAX_INLINE_FILE_CHARS ? `${text.slice(0, MAX_INLINE_FILE_CHARS)}\n…` : text;
|
|
309
115
|
} catch {
|
|
310
116
|
return null;
|
|
311
117
|
}
|
|
118
|
+
// Reject corrupt base64 that decodes to empty while payload was non-empty.
|
|
119
|
+
if (isBase64 && bytes.length === 0 && payload.replace(/\s/g, '').length > 0) return null;
|
|
120
|
+
if (bytes.length > MAX_ATTACHMENT_BYTES) {
|
|
121
|
+
const error = new Error(`Attachment exceeds ${MAX_ATTACHMENT_BYTES} byte limit`);
|
|
122
|
+
error.code = 'validation_error';
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
return { mime, bytes, isBase64, byteLength: bytes.length };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const decodeDataUrlText = (url, mime) => {
|
|
129
|
+
if (!TEXT_FILE_MIME.test(mime)) return null;
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = parseDataUrl(url);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error?.code === 'validation_error') throw error;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (!parsed) return null;
|
|
138
|
+
const text = parsed.bytes.toString('utf8');
|
|
139
|
+
return text.length > MAX_INLINE_FILE_CHARS ? `${text.slice(0, MAX_INLINE_FILE_CHARS)}\n…` : text;
|
|
312
140
|
};
|
|
313
141
|
|
|
314
142
|
const describeContactFilePart = (part) => {
|
|
@@ -320,6 +148,25 @@ const describeContactFilePart = (part) => {
|
|
|
320
148
|
return `[file: ${name} (${mime})]`;
|
|
321
149
|
};
|
|
322
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Validate every file part URL before generate. Non-data URLs are rejected
|
|
153
|
+
* (gateway only accepts contact data-URL attachments).
|
|
154
|
+
*/
|
|
155
|
+
export function assertValidAttachmentParts(files) {
|
|
156
|
+
for (const part of files) {
|
|
157
|
+
if (!part || typeof part.url !== 'string') {
|
|
158
|
+
failGenerate('Attachment is missing a data URL', 'validation_error');
|
|
159
|
+
}
|
|
160
|
+
if (!part.url.startsWith('data:')) {
|
|
161
|
+
failGenerate('Attachment URL must be a data URL', 'validation_error');
|
|
162
|
+
}
|
|
163
|
+
const parsed = parseDataUrl(part.url);
|
|
164
|
+
if (!parsed) {
|
|
165
|
+
failGenerate('Attachment data URL is malformed', 'validation_error');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
323
170
|
const flattenMessages = (messages) => {
|
|
324
171
|
const system = [];
|
|
325
172
|
const turns = [];
|
|
@@ -345,50 +192,36 @@ const flattenMessages = (messages) => {
|
|
|
345
192
|
};
|
|
346
193
|
};
|
|
347
194
|
|
|
195
|
+
/** Image bytes only when the catalog model is vision-capable. */
|
|
348
196
|
const filesForPrompt = (files, forwardImageParts) => {
|
|
349
197
|
if (forwardImageParts) return files;
|
|
350
198
|
return files.filter((part) => !String(part?.mime || '').startsWith('image/'));
|
|
351
199
|
};
|
|
352
200
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
});
|
|
364
|
-
if (!response.ok) {
|
|
365
|
-
const body = await response.text().catch(() => '');
|
|
366
|
-
failGenerate(`OpenCode generate failed (${response.status})${body ? `: ${body.slice(0, 200)}` : ''}`);
|
|
367
|
-
}
|
|
368
|
-
const payload = await response.json();
|
|
369
|
-
const text = typeof payload?.text === 'string'
|
|
370
|
-
? payload.text
|
|
371
|
-
: typeof payload?.message === 'string'
|
|
372
|
-
? payload.message
|
|
373
|
-
: '';
|
|
374
|
-
if (!text.trim()) failGenerate('OpenCode generate returned no text');
|
|
375
|
-
return { text: text.trim(), source: 'generate' };
|
|
376
|
-
}
|
|
201
|
+
const imageFilesForSession = (files, forwardImageParts) => {
|
|
202
|
+
if (!forwardImageParts) return [];
|
|
203
|
+
return files.filter((part) => String(part?.mime || '').startsWith('image/'));
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const modelRef = (providerID, modelID, variant) => ({
|
|
207
|
+
id: modelID,
|
|
208
|
+
providerID,
|
|
209
|
+
...(variant ? { variant } : {}),
|
|
210
|
+
});
|
|
377
211
|
|
|
378
212
|
const eventPayload = (event) => event?.payload?.payload ?? event?.payload ?? event;
|
|
379
213
|
|
|
380
|
-
const
|
|
214
|
+
const eventDeltaData = (payload) => {
|
|
381
215
|
if (!payload || typeof payload !== 'object') return null;
|
|
382
|
-
if (payload.properties && typeof payload.properties === 'object') return payload.properties;
|
|
383
216
|
if (payload.data && typeof payload.data === 'object') return payload.data;
|
|
217
|
+
if (payload.properties && typeof payload.properties === 'object') return payload.properties;
|
|
384
218
|
return null;
|
|
385
219
|
};
|
|
386
220
|
|
|
387
221
|
/**
|
|
388
|
-
* Forward real OpenCode `
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* typewriter chunks from a completed string.
|
|
222
|
+
* Forward real OpenCode `session.text.delta` tokens for one throwaway session.
|
|
223
|
+
* Consumes data.sessionID / assistantMessageID / ordinal / delta.
|
|
224
|
+
* Returns unsubscribe, or null when deltas cannot be observed.
|
|
392
225
|
*
|
|
393
226
|
* @param {{ globalEventHub?: { subscribeEvent?: Function } | null, sessionID: string, onTextDelta?: ((text: string) => void) | null }} args
|
|
394
227
|
* @returns {(() => void) | null}
|
|
@@ -398,22 +231,19 @@ export function subscribeThrowawayTextDeltas({ globalEventHub, sessionID, onText
|
|
|
398
231
|
if (typeof sessionID !== 'string' || !sessionID) return null;
|
|
399
232
|
if (typeof globalEventHub?.subscribeEvent !== 'function') return null;
|
|
400
233
|
|
|
401
|
-
// Lock to the first assistant messageID seen for this throwaway session so
|
|
402
|
-
// concurrent sessions on the shared hub cannot leak tokens into this generate.
|
|
403
234
|
let assistantMessageID = null;
|
|
235
|
+
let lastOrdinal = -1;
|
|
404
236
|
|
|
405
237
|
const unsubscribe = globalEventHub.subscribeEvent((event) => {
|
|
406
238
|
const payload = eventPayload(event);
|
|
407
|
-
if (payload?.type !== '
|
|
408
|
-
const
|
|
409
|
-
if (!
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const messageID = typeof props.messageID === 'string' && props.messageID
|
|
416
|
-
? props.messageID
|
|
239
|
+
if (payload?.type !== 'session.text.delta') return;
|
|
240
|
+
const data = eventDeltaData(payload);
|
|
241
|
+
if (!data) return;
|
|
242
|
+
if (data.sessionID !== sessionID) return;
|
|
243
|
+
if (typeof data.delta !== 'string' || data.delta.length === 0) return;
|
|
244
|
+
|
|
245
|
+
const messageID = typeof data.assistantMessageID === 'string' && data.assistantMessageID
|
|
246
|
+
? data.assistantMessageID
|
|
417
247
|
: null;
|
|
418
248
|
if (assistantMessageID) {
|
|
419
249
|
if (messageID && messageID !== assistantMessageID) return;
|
|
@@ -421,8 +251,13 @@ export function subscribeThrowawayTextDeltas({ globalEventHub, sessionID, onText
|
|
|
421
251
|
assistantMessageID = messageID;
|
|
422
252
|
}
|
|
423
253
|
|
|
254
|
+
if (typeof data.ordinal === 'number' && Number.isFinite(data.ordinal)) {
|
|
255
|
+
if (data.ordinal <= lastOrdinal) return;
|
|
256
|
+
lastOrdinal = data.ordinal;
|
|
257
|
+
}
|
|
258
|
+
|
|
424
259
|
try {
|
|
425
|
-
onTextDelta(
|
|
260
|
+
onTextDelta(data.delta);
|
|
426
261
|
} catch {
|
|
427
262
|
// Caller callback failures must not abort generate or leave the hub broken.
|
|
428
263
|
}
|
|
@@ -432,19 +267,246 @@ export function subscribeThrowawayTextDeltas({ globalEventHub, sessionID, onText
|
|
|
432
267
|
}
|
|
433
268
|
|
|
434
269
|
/**
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
|
|
270
|
+
* Verify the openchamber-llm agent ends with deny-all permissions.
|
|
271
|
+
* Title alone is never treated as isolation. Failure must happen before prompt.
|
|
272
|
+
*/
|
|
273
|
+
export async function assertLlmAgentDenyAll({ client, location, signal }) {
|
|
274
|
+
let result;
|
|
275
|
+
try {
|
|
276
|
+
result = await client.agent.get({
|
|
277
|
+
agentID: LLM_AGENT_NAME,
|
|
278
|
+
...(location ? { location } : {}),
|
|
279
|
+
}, { signal });
|
|
280
|
+
} catch (error) {
|
|
281
|
+
failGenerate(
|
|
282
|
+
`OpenCode LLM agent is unavailable (${clientErrorMessage(error, 'agent.get failed')}); attachment generation blocked`,
|
|
283
|
+
'llm_attachment_generation_unavailable',
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const permissions = result?.data?.permissions;
|
|
287
|
+
if (!Array.isArray(permissions) || permissions.length === 0) {
|
|
288
|
+
failGenerate(
|
|
289
|
+
'OpenCode LLM agent permissions are missing; attachment generation blocked',
|
|
290
|
+
'llm_attachment_generation_unavailable',
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const last = permissions[permissions.length - 1];
|
|
294
|
+
if (
|
|
295
|
+
!last
|
|
296
|
+
|| last.action !== '*'
|
|
297
|
+
|| last.resource !== '*'
|
|
298
|
+
|| last.effect !== 'deny'
|
|
299
|
+
) {
|
|
300
|
+
failGenerate(
|
|
301
|
+
'OpenCode LLM agent final permission is not deny-all; attachment generation blocked',
|
|
302
|
+
'llm_attachment_generation_unavailable',
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return result.data;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const assistantTextFromMessages = (messages) => {
|
|
309
|
+
if (!Array.isArray(messages) || messages.length === 0) return '';
|
|
310
|
+
for (const message of messages) {
|
|
311
|
+
// message.list order:desc — first assistant is the newest.
|
|
312
|
+
const role = message?.type ?? message?.role ?? message?.info?.role;
|
|
313
|
+
if (role !== 'assistant') continue;
|
|
314
|
+
const error = message?.error ?? message?.info?.error;
|
|
315
|
+
if (error) {
|
|
316
|
+
const detail = assistantErrorDetail(error);
|
|
317
|
+
failGenerate(detail || 'OpenCode assistant error');
|
|
318
|
+
}
|
|
319
|
+
const finish = message?.finish ?? message?.info?.finish;
|
|
320
|
+
if (finish === 'error') {
|
|
321
|
+
failGenerate(assistantErrorDetail(message?.error) || 'OpenCode assistant finish=error');
|
|
322
|
+
}
|
|
323
|
+
const content = Array.isArray(message?.content)
|
|
324
|
+
? message.content
|
|
325
|
+
: Array.isArray(message?.parts)
|
|
326
|
+
? message.parts
|
|
327
|
+
: [];
|
|
328
|
+
const text = content
|
|
329
|
+
.map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
|
|
330
|
+
.filter(Boolean)
|
|
331
|
+
.join('');
|
|
332
|
+
return text;
|
|
333
|
+
}
|
|
334
|
+
return '';
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const makeClient = ({ baseUrl, headers, clientFactory }) => {
|
|
338
|
+
if (typeof clientFactory === 'function') return clientFactory();
|
|
339
|
+
return OpenCode.make({ baseUrl, headers });
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
async function generateViaTextApi({
|
|
343
|
+
client,
|
|
344
|
+
location,
|
|
345
|
+
providerID,
|
|
346
|
+
modelID,
|
|
347
|
+
variant,
|
|
348
|
+
prompt,
|
|
349
|
+
signal,
|
|
350
|
+
}) {
|
|
351
|
+
let result;
|
|
352
|
+
try {
|
|
353
|
+
result = await client.generate.text({
|
|
354
|
+
...(location ? { location } : {}),
|
|
355
|
+
prompt,
|
|
356
|
+
model: modelRef(providerID, modelID, variant),
|
|
357
|
+
}, { signal });
|
|
358
|
+
} catch (error) {
|
|
359
|
+
rethrowIfAborted(error, signal);
|
|
360
|
+
failGenerate(clientErrorMessage(error, 'OpenCode generate.text failed'));
|
|
361
|
+
}
|
|
362
|
+
const text = typeof result?.text === 'string'
|
|
363
|
+
? result.text.trim()
|
|
364
|
+
: typeof result?.data?.text === 'string'
|
|
365
|
+
? result.data.text.trim()
|
|
366
|
+
: '';
|
|
367
|
+
if (!text) failGenerate('OpenCode generate.text returned no text');
|
|
368
|
+
return { text, source: 'generate.text' };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function generateViaAttachmentSession({
|
|
372
|
+
client,
|
|
373
|
+
workingDirectory,
|
|
374
|
+
providerID,
|
|
375
|
+
modelID,
|
|
376
|
+
variant,
|
|
377
|
+
system,
|
|
378
|
+
prompt,
|
|
379
|
+
imageFiles,
|
|
380
|
+
signal,
|
|
381
|
+
onTextDelta,
|
|
382
|
+
globalEventHub,
|
|
383
|
+
}) {
|
|
384
|
+
const location = { directory: workingDirectory };
|
|
385
|
+
|
|
386
|
+
// Permission isolation must be verified before any prompt. Never use title as isolation.
|
|
387
|
+
await assertLlmAgentDenyAll({ client, location, signal });
|
|
388
|
+
|
|
389
|
+
let sessionID = null;
|
|
390
|
+
let unsubscribeDeltas = null;
|
|
391
|
+
try {
|
|
392
|
+
let created;
|
|
393
|
+
try {
|
|
394
|
+
created = await client.session.create({
|
|
395
|
+
title: '[openchamber-llm] generate',
|
|
396
|
+
agent: LLM_AGENT_NAME,
|
|
397
|
+
model: modelRef(providerID, modelID, variant),
|
|
398
|
+
location,
|
|
399
|
+
}, { signal });
|
|
400
|
+
} catch (error) {
|
|
401
|
+
// Temp workspace invisible to OpenCode must fail explicitly.
|
|
402
|
+
failGenerate(
|
|
403
|
+
`OpenCode LLM session create failed (${clientErrorMessage(error, 'create failed')})`,
|
|
404
|
+
'llm_attachment_generation_unavailable',
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
sessionID = created?.id ?? created?.data?.id;
|
|
408
|
+
if (!sessionID) {
|
|
409
|
+
failGenerate('OpenCode LLM session create returned no id', 'llm_attachment_generation_unavailable');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (system) {
|
|
413
|
+
try {
|
|
414
|
+
await client.session.instructions.entry.put({
|
|
415
|
+
sessionID,
|
|
416
|
+
key: 'system',
|
|
417
|
+
value: system,
|
|
418
|
+
}, { signal });
|
|
419
|
+
} catch (error) {
|
|
420
|
+
failGenerate(clientErrorMessage(error, 'instructions.entry.put failed'));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
unsubscribeDeltas = subscribeThrowawayTextDeltas({
|
|
425
|
+
globalEventHub,
|
|
426
|
+
sessionID,
|
|
427
|
+
onTextDelta,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const files = imageFiles.map((part) => ({
|
|
431
|
+
uri: part.url,
|
|
432
|
+
...(part.filename ? { name: part.filename } : {}),
|
|
433
|
+
}));
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
await client.session.prompt({
|
|
437
|
+
sessionID,
|
|
438
|
+
text: prompt || '[attachment]',
|
|
439
|
+
...(files.length > 0 ? { files } : {}),
|
|
440
|
+
delivery: 'steer',
|
|
441
|
+
}, { signal });
|
|
442
|
+
} catch (error) {
|
|
443
|
+
rethrowIfAborted(error, signal);
|
|
444
|
+
failGenerate(clientErrorMessage(error, 'session.prompt failed'));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
await client.session.wait({ sessionID }, { signal });
|
|
449
|
+
} catch (error) {
|
|
450
|
+
rethrowIfAborted(error, signal);
|
|
451
|
+
failGenerate(clientErrorMessage(error, 'session.wait failed'));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let listed;
|
|
455
|
+
try {
|
|
456
|
+
listed = await client.message.list({
|
|
457
|
+
sessionID,
|
|
458
|
+
limit: 20,
|
|
459
|
+
order: 'desc',
|
|
460
|
+
}, { signal });
|
|
461
|
+
} catch (error) {
|
|
462
|
+
rethrowIfAborted(error, signal);
|
|
463
|
+
failGenerate(clientErrorMessage(error, 'message.list failed'));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const messages = Array.isArray(listed?.data) ? listed.data : [];
|
|
467
|
+
const text = assistantTextFromMessages(messages);
|
|
468
|
+
if (!text.trim()) {
|
|
469
|
+
failGenerate('OpenCode LLM generator returned no assistant text');
|
|
470
|
+
}
|
|
471
|
+
return { text: text.trim(), source: 'attachment-session' };
|
|
472
|
+
} finally {
|
|
473
|
+
try {
|
|
474
|
+
unsubscribeDeltas?.();
|
|
475
|
+
} catch {
|
|
476
|
+
// Unsubscribe must not mask generate errors or block session remove.
|
|
477
|
+
}
|
|
478
|
+
unsubscribeDeltas = null;
|
|
479
|
+
if (sessionID) {
|
|
480
|
+
if (signal?.aborted) {
|
|
481
|
+
try {
|
|
482
|
+
await client.session.interrupt({ sessionID, continue: false });
|
|
483
|
+
} catch {
|
|
484
|
+
// Best-effort interrupt on timeout/abort.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
await client.session.remove({ sessionID });
|
|
489
|
+
} catch (error) {
|
|
490
|
+
console.warn('[llm] failed to remove throwaway OpenCode session:', error?.message || error);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Generate assistant text through OpenCode's connected providers via
|
|
498
|
+
* `@opencode/client`.
|
|
499
|
+
*
|
|
500
|
+
* - Pure text: `generate.text` (no session).
|
|
501
|
+
* - Vision image attachments: dedicated temp session after agent deny-all verify.
|
|
502
|
+
* - Non-vision: keep `[image: …]` descriptions; never forward image bytes.
|
|
438
503
|
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
504
|
+
* Optional internal streaming: `onTextDelta` + `globalEventHub` forwards real
|
|
505
|
+
* `session.text.delta` tokens on the attachment-session path only.
|
|
506
|
+
* `generate.text` cannot emit live deltas (no fake typewriter).
|
|
442
507
|
*
|
|
443
|
-
* Optional
|
|
444
|
-
*
|
|
445
|
-
* `message.part.delta` tokens for this session are forwarded. Sessionless
|
|
446
|
-
* `/generate` JSON cannot emit deltas — onTextDelta is skipped (no fake
|
|
447
|
-
* typewriter). Public HTTP completions stay non-streaming.
|
|
508
|
+
* Optional `signal` from the contact continuation is combined with the generator
|
|
509
|
+
* deadline. Cancellation still runs throwaway-session cleanup.
|
|
448
510
|
*/
|
|
449
511
|
export async function generateOpenCodeText({
|
|
450
512
|
buildOpenCodeUrl,
|
|
@@ -453,26 +515,27 @@ export async function generateOpenCodeText({
|
|
|
453
515
|
modelID,
|
|
454
516
|
messages,
|
|
455
517
|
variant,
|
|
456
|
-
fetchImpl = globalThis.fetch.bind(globalThis),
|
|
457
518
|
clientFactory,
|
|
458
519
|
ensureTempDirectory,
|
|
459
|
-
detect = detectSessionlessGenerate,
|
|
460
520
|
forwardImageParts = false,
|
|
461
521
|
onTextDelta = null,
|
|
462
522
|
globalEventHub = null,
|
|
463
523
|
signal: parentSignal = null,
|
|
464
524
|
timeoutMs = GENERATE_TIMEOUT_MS,
|
|
465
|
-
settlePollMs = SETTLE_POLL_MS,
|
|
466
525
|
}) {
|
|
467
|
-
parentSignal?.throwIfAborted();
|
|
526
|
+
parentSignal?.throwIfAborted?.();
|
|
468
527
|
if (!providerID || !modelID) {
|
|
469
528
|
const error = new Error('providerID and modelID are required');
|
|
470
529
|
error.code = 'validation_error';
|
|
471
530
|
throw error;
|
|
472
531
|
}
|
|
473
532
|
const flattened = flattenMessages(messages);
|
|
474
|
-
const
|
|
475
|
-
|
|
533
|
+
const allFiles = flattened.files;
|
|
534
|
+
assertValidAttachmentParts(allFiles);
|
|
535
|
+
|
|
536
|
+
const imageFiles = imageFilesForSession(allFiles, forwardImageParts);
|
|
537
|
+
// Non-image files stay as prompt descriptions (and inlined text when possible).
|
|
538
|
+
if (!flattened.prompt && imageFiles.length === 0 && allFiles.length === 0) {
|
|
476
539
|
const error = new Error('messages must include a user turn');
|
|
477
540
|
error.code = 'validation_error';
|
|
478
541
|
throw error;
|
|
@@ -480,180 +543,88 @@ export async function generateOpenCodeText({
|
|
|
480
543
|
|
|
481
544
|
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
|
|
482
545
|
const headers = getOpenCodeAuthHeaders() || {};
|
|
483
|
-
const
|
|
484
|
-
|
|
546
|
+
const client = makeClient({ baseUrl, headers, clientFactory });
|
|
547
|
+
|
|
485
548
|
const controller = new AbortController();
|
|
486
|
-
const
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
let stallTimer = setTimeout(() => {
|
|
495
|
-
abortGenerate(`OpenCode LLM generate timed out after ${stallMs}ms without progress`);
|
|
496
|
-
}, stallMs);
|
|
497
|
-
const bumpTimeout = () => {
|
|
498
|
-
if (controller.signal.aborted) return;
|
|
499
|
-
clearTimeout(stallTimer);
|
|
500
|
-
stallTimer = setTimeout(() => {
|
|
501
|
-
abortGenerate(`OpenCode LLM generate timed out after ${stallMs}ms without progress`);
|
|
502
|
-
}, stallMs);
|
|
503
|
-
};
|
|
549
|
+
const deadlineMs = positiveMs(timeoutMs, GENERATE_TIMEOUT_MS);
|
|
550
|
+
const requestSignal = parentSignal
|
|
551
|
+
? AbortSignal.any([parentSignal, controller.signal])
|
|
552
|
+
: controller.signal;
|
|
553
|
+
const timeout = setTimeout(() => {
|
|
554
|
+
controller.abort(new Error(`OpenCode LLM generate timed out after ${deadlineMs}ms`));
|
|
555
|
+
}, deadlineMs);
|
|
556
|
+
timeout.unref?.();
|
|
504
557
|
|
|
505
558
|
try {
|
|
506
|
-
if (
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
559
|
+
if (imageFiles.length > 0) {
|
|
560
|
+
if (typeof ensureTempDirectory !== 'function') {
|
|
561
|
+
failGenerate(
|
|
562
|
+
'LLM temp directory is required for attachment generation',
|
|
563
|
+
'llm_attachment_generation_unavailable',
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
let workingDirectory;
|
|
567
|
+
try {
|
|
568
|
+
workingDirectory = await ensureTempDirectory({
|
|
569
|
+
agentName: LLM_AGENT_NAME,
|
|
570
|
+
agentMarkdown: AGENT_MARKDOWN,
|
|
571
|
+
});
|
|
572
|
+
} catch (error) {
|
|
573
|
+
failGenerate(
|
|
574
|
+
`LLM temp directory failed (${clientErrorMessage(error, 'ensure failed')})`,
|
|
575
|
+
'llm_attachment_generation_unavailable',
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
if (typeof workingDirectory !== 'string' || !workingDirectory.trim()) {
|
|
579
|
+
failGenerate(
|
|
580
|
+
'LLM temp directory is empty; attachment generation blocked',
|
|
581
|
+
'llm_attachment_generation_unavailable',
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
return await generateViaAttachmentSession({
|
|
585
|
+
client,
|
|
586
|
+
workingDirectory: workingDirectory.trim(),
|
|
511
587
|
providerID,
|
|
512
588
|
modelID,
|
|
513
589
|
variant,
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (!Array.isArray(message?.parts)) return message;
|
|
518
|
-
const parts = message.parts.filter((part) => part?.type !== 'file' || !String(part.mime || '').startsWith('image/'));
|
|
519
|
-
return parts.length === message.parts.length ? message : { ...message, parts };
|
|
520
|
-
}),
|
|
590
|
+
system: flattened.system,
|
|
591
|
+
prompt: flattened.prompt,
|
|
592
|
+
imageFiles,
|
|
521
593
|
signal: requestSignal,
|
|
594
|
+
onTextDelta,
|
|
595
|
+
globalEventHub,
|
|
522
596
|
});
|
|
523
597
|
}
|
|
524
598
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
599
|
+
// Text path: no session. System prompt is prepended into the single prompt string.
|
|
600
|
+
const prompt = [flattened.system, flattened.prompt].filter((part) => String(part || '').trim()).join('\n\n');
|
|
601
|
+
return await generateViaTextApi({
|
|
602
|
+
client,
|
|
603
|
+
providerID,
|
|
604
|
+
modelID,
|
|
605
|
+
variant,
|
|
606
|
+
prompt,
|
|
607
|
+
signal: requestSignal,
|
|
528
608
|
});
|
|
529
|
-
const client = clientFactory
|
|
530
|
-
? clientFactory()
|
|
531
|
-
: createOpencodeClient({ baseUrl, directory: workingDirectory, headers });
|
|
532
|
-
|
|
533
|
-
let toolIds;
|
|
534
|
-
try {
|
|
535
|
-
toolIds = await client.tool.ids({ directory: workingDirectory });
|
|
536
|
-
} catch (error) {
|
|
537
|
-
failGenerate(`OpenCode tool.ids failed: ${error?.message || 'tool.ids failed'}`);
|
|
538
|
-
}
|
|
539
|
-
if (toolIds?.error) {
|
|
540
|
-
failGenerate(`OpenCode tool.ids failed: ${sdkErrorMessage(toolIds, 'tool.ids failed')}`);
|
|
541
|
-
}
|
|
542
|
-
const tools = deniedTools(Array.isArray(toolIds?.data) ? toolIds.data : []);
|
|
543
|
-
|
|
544
|
-
const created = await client.session.create({
|
|
545
|
-
directory: workingDirectory,
|
|
546
|
-
title: '[openchamber-llm] generate',
|
|
547
|
-
agent: LLM_AGENT_NAME,
|
|
548
|
-
// Session fence: deny every native OpenCode/MCP/skill tool for this throwaway.
|
|
549
|
-
permission: LLM_SESSION_DENY_PERMISSION,
|
|
550
|
-
metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
|
|
551
|
-
}, { signal: requestSignal });
|
|
552
|
-
const sessionID = created?.data?.id;
|
|
553
|
-
if (created?.error || !sessionID) {
|
|
554
|
-
failGenerate(`OpenCode LLM session create failed: ${sdkErrorMessage(created, 'create failed')}`);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
let unsubscribeDeltas = null;
|
|
558
|
-
try {
|
|
559
|
-
const archiveAt = Date.now();
|
|
560
|
-
let archived = await client.session.update({
|
|
561
|
-
sessionID,
|
|
562
|
-
directory: workingDirectory,
|
|
563
|
-
time: { archived: archiveAt },
|
|
564
|
-
});
|
|
565
|
-
if (isMissing(archived)) {
|
|
566
|
-
archived = await client.session.update({
|
|
567
|
-
sessionID,
|
|
568
|
-
directory: workingDirectory,
|
|
569
|
-
time: { archived: archiveAt },
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
if (archived?.error) {
|
|
573
|
-
failGenerate(`OpenCode LLM session archive failed: ${sdkErrorMessage(archived, 'archive failed')}`);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// Subscribe before promptAsync so early message.part.delta tokens are not missed.
|
|
577
|
-
unsubscribeDeltas = subscribeThrowawayTextDeltas({
|
|
578
|
-
globalEventHub,
|
|
579
|
-
sessionID,
|
|
580
|
-
onTextDelta: (text) => {
|
|
581
|
-
bumpTimeout();
|
|
582
|
-
if (typeof onTextDelta === 'function') onTextDelta(text);
|
|
583
|
-
},
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
// promptAsync still forwards model/parts/tools. v2 session.prompt does not.
|
|
587
|
-
const prompted = await client.session.promptAsync({
|
|
588
|
-
sessionID,
|
|
589
|
-
directory: workingDirectory,
|
|
590
|
-
agent: LLM_AGENT_NAME,
|
|
591
|
-
model: { providerID, modelID },
|
|
592
|
-
...(variant ? { variant } : {}),
|
|
593
|
-
...(flattened.system ? { system: flattened.system } : {}),
|
|
594
|
-
tools,
|
|
595
|
-
parts: [
|
|
596
|
-
{ type: 'text', text: flattened.prompt, synthetic: false },
|
|
597
|
-
...promptFiles,
|
|
598
|
-
],
|
|
599
|
-
}, { signal: requestSignal });
|
|
600
|
-
if (!promptAdmitted(prompted)) {
|
|
601
|
-
failGenerate(`OpenCode LLM promptAsync failed: ${sdkErrorMessage(prompted, 'promptAsync failed')}`);
|
|
602
|
-
}
|
|
603
|
-
bumpTimeout();
|
|
604
|
-
|
|
605
|
-
let text = assistantTextFromPrompt(prompted?.data ?? prompted);
|
|
606
|
-
if (!text.trim()) {
|
|
607
|
-
const settled = await waitForIdleAssistant({
|
|
608
|
-
client,
|
|
609
|
-
sessionID,
|
|
610
|
-
directory: workingDirectory,
|
|
611
|
-
signal: requestSignal,
|
|
612
|
-
onProgress: bumpTimeout,
|
|
613
|
-
settlePollMs,
|
|
614
|
-
});
|
|
615
|
-
text = assistantTextFromMessages(settled);
|
|
616
|
-
}
|
|
617
|
-
if (!text.trim()) {
|
|
618
|
-
failGenerate('OpenCode LLM generator returned no assistant text');
|
|
619
|
-
}
|
|
620
|
-
return { text: text.trim(), source: 'throwaway-session' };
|
|
621
|
-
} finally {
|
|
622
|
-
try {
|
|
623
|
-
unsubscribeDeltas?.();
|
|
624
|
-
} catch {
|
|
625
|
-
// Unsubscribe must not mask generate errors or block session delete.
|
|
626
|
-
}
|
|
627
|
-
unsubscribeDeltas = null;
|
|
628
|
-
try {
|
|
629
|
-
const deleted = await client.session.delete({ sessionID, directory: workingDirectory });
|
|
630
|
-
if (deleted?.error) {
|
|
631
|
-
console.warn(
|
|
632
|
-
'[llm] failed to delete throwaway OpenCode session:',
|
|
633
|
-
sdkErrorMessage(deleted, 'delete failed'),
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
} catch (error) {
|
|
637
|
-
console.warn('[llm] failed to delete throwaway OpenCode session:', error?.message || error);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
609
|
} finally {
|
|
641
|
-
clearTimeout(
|
|
610
|
+
clearTimeout(timeout);
|
|
642
611
|
}
|
|
643
612
|
}
|
|
644
613
|
|
|
645
614
|
export const _test = {
|
|
646
615
|
flattenMessages,
|
|
647
616
|
filesForPrompt,
|
|
617
|
+
imageFilesForSession,
|
|
648
618
|
describeContactFilePart,
|
|
649
|
-
deniedTools,
|
|
650
|
-
assistantTextFromPrompt,
|
|
651
619
|
assistantTextFromMessages,
|
|
652
620
|
eventPayload,
|
|
653
|
-
|
|
621
|
+
eventDeltaData,
|
|
654
622
|
subscribeThrowawayTextDeltas,
|
|
623
|
+
assertLlmAgentDenyAll,
|
|
624
|
+
parseDataUrl,
|
|
625
|
+
assertValidAttachmentParts,
|
|
655
626
|
LLM_AGENT_NAME,
|
|
656
627
|
AGENT_MARKDOWN,
|
|
657
|
-
|
|
628
|
+
MAX_ATTACHMENT_BYTES,
|
|
658
629
|
GENERATE_TIMEOUT_MS,
|
|
659
630
|
};
|