@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.
Files changed (189) hide show
  1. package/README.md +13 -11
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +165 -0
  6. package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
  7. package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
  8. package/dist/assets/{DiagramView-C_JfbxNl.js → DiagramView-xuSJTnv7.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
  10. package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-B1iUwzOz.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-BPtMj3-X.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-CKClHsWN.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-DiA24bnN.js → MultiRunWindow-ChZGob0v.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
  19. package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
  20. package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
  21. package/dist/assets/{SettingsWindow-XlzRSS7E.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-DdIaaLba.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BbwuLdXU.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-ChzfD7mc.js → appThemeRegistry-COGfxTFV.js} +1 -1
  25. package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
  26. package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
  27. package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
  28. package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
  29. package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
  30. package/dist/assets/es-c52LTHHe.js +15 -0
  31. package/dist/assets/{fileMentionTouchSelection-DyjmxUEN.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
  32. package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
  33. package/dist/assets/gitApi-ButwzQ0N.js +9 -0
  34. package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
  35. package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
  36. package/dist/assets/ja-C8Ikun3Z.js +15 -0
  37. package/dist/assets/ko-ClFys5o2.js +15 -0
  38. package/dist/assets/main-CCE_4y5F.js +2 -0
  39. package/dist/assets/main-Dn9rpsgl.js +169 -0
  40. package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
  41. package/dist/assets/miniChat-BpjxVqgg.js +2 -0
  42. package/dist/assets/mobile-nMJCaNq1.js +2 -0
  43. package/dist/assets/{multirun-8fkF0Ji_.js → multirun-BvSG94mW.js} +1 -1
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/{projectMeta--y6tyjrv.js → projectMeta-DOe49dM_.js} +2 -2
  47. package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
  48. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
  49. package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
  50. package/dist/assets/{runtimeConfig-DYWtBrkW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BSWg99jc.js → runtimeEndpointReset-B6BxhvR0.js} +5 -5
  52. package/dist/assets/{sessionChangeSummary-BwupJF-Q.js → sessionChangeSummary-VVBlEXuh.js} +1 -1
  53. package/dist/assets/{sessionLookup-DP2X1qRR.js → sessionLookup-gh-hVRIc.js} +1 -1
  54. package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
  55. package/dist/assets/{src-DZ60kHBP.css → src-BzX5UI-c.css} +1 -1
  56. package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
  57. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
  58. package/dist/assets/{textarea-BnD799LW.js → textarea-3XR8sUfy.js} +1 -1
  59. package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
  60. package/dist/assets/uk-CaX0iqkB.js +15 -0
  61. package/dist/assets/{useAppFontEffects-DWLxpWlS.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/{useDesktopWindowControlsLayout-BGPR_rq6.js → useDesktopWindowControlsLayout-B1GX59-L.js} +1 -1
  63. package/dist/assets/{useEffectiveDirectory-CbV9_r1s.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
  64. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
  66. package/dist/assets/{useSessionAutoCleanup-Dnors7hB.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-DQqqrOBU.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
  68. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  69. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  70. package/dist/assets/zh-CN-20MYEjha.js +15 -0
  71. package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
  72. package/dist/index.html +6 -6
  73. package/dist/mini-chat.html +6 -6
  74. package/dist/mobile.html +6 -6
  75. package/package.json +2 -2
  76. package/server/index.js +107 -0
  77. package/server/lib/assistants/DOCUMENTATION.md +18 -18
  78. package/server/lib/assistants/assign.js +2 -1
  79. package/server/lib/assistants/contact-store.js +37 -19
  80. package/server/lib/assistants/projection-history.test.js +207 -0
  81. package/server/lib/assistants/routes.js +5 -5
  82. package/server/lib/assistants/service.js +493 -123
  83. package/server/lib/assistants/service.test.js +203 -133
  84. package/server/lib/conversations/DOCUMENTATION.md +4 -4
  85. package/server/lib/conversations/service.js +86 -57
  86. package/server/lib/conversations/service.test.js +116 -100
  87. package/server/lib/event-stream/global-hub.js +2 -1
  88. package/server/lib/llm/DOCUMENTATION.md +71 -84
  89. package/server/lib/llm/catalog.js +111 -45
  90. package/server/lib/llm/catalog.test.js +121 -59
  91. package/server/lib/llm/completions.js +37 -12
  92. package/server/lib/llm/completions.test.js +45 -4
  93. package/server/lib/llm/generate.js +421 -450
  94. package/server/lib/llm/generate.test.js +553 -487
  95. package/server/lib/llm/routes.js +6 -4
  96. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  97. package/server/lib/message-queue/opencode-adapter.js +191 -104
  98. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  99. package/server/lib/message-queue/runtime.test.js +6 -6
  100. package/server/lib/message-queue/worker.js +7 -1
  101. package/server/lib/opencode/DOCUMENTATION.md +28 -5
  102. package/server/lib/opencode/config-entity-routes.js +84 -13
  103. package/server/lib/opencode/config-entity-routes.test.js +41 -10
  104. package/server/lib/opencode/ensure-cli.js +239 -0
  105. package/server/lib/opencode/ensure-cli.test.js +119 -0
  106. package/server/lib/opencode/env-runtime.js +193 -110
  107. package/server/lib/opencode/env-runtime.test.js +221 -17
  108. package/server/lib/opencode/feature-routes-runtime.js +37 -1
  109. package/server/lib/opencode/lifecycle.js +204 -70
  110. package/server/lib/opencode/lifecycle.test.js +376 -6
  111. package/server/lib/opencode/network-runtime.js +28 -16
  112. package/server/lib/opencode/network-runtime.test.js +68 -4
  113. package/server/lib/opencode/opencode2-pin.js +139 -0
  114. package/server/lib/opencode/opencode2-pin.test.js +87 -0
  115. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
  116. package/server/lib/opencode/plugin-compatibility.js +15 -0
  117. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  118. package/server/lib/opencode/plugins.js +4 -3
  119. package/server/lib/opencode/plugins.test.js +12 -0
  120. package/server/lib/opencode/proxy.js +153 -16
  121. package/server/lib/opencode/proxy.test.js +120 -0
  122. package/server/lib/opencode/routes.js +25 -32
  123. package/server/lib/opencode/routes.test.js +51 -52
  124. package/server/lib/opencode/server-utils-runtime.js +2 -0
  125. package/server/lib/opencode/skill-routes.js +6 -7
  126. package/server/lib/opencode/skill-routes.test.js +36 -5
  127. package/server/lib/opencode/v1-migration-gate.js +157 -0
  128. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  129. package/server/lib/opencode/v2-client.js +49 -0
  130. package/server/lib/opencode/watcher.js +2 -1
  131. package/server/lib/opencode/watcher.test.js +1 -1
  132. package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
  133. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  134. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  135. package/server/lib/scheduled-tasks/routes.js +27 -0
  136. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  137. package/server/lib/scheduled-tasks/runtime.js +331 -308
  138. package/server/lib/scheduled-tasks/runtime.test.js +530 -318
  139. package/server/lib/session-goal/DOCUMENTATION.md +65 -14
  140. package/server/lib/session-goal/capability.js +57 -0
  141. package/server/lib/session-goal/capability.test.js +33 -0
  142. package/server/lib/session-goal/routes.js +231 -2
  143. package/server/lib/session-goal/routes.test.js +211 -0
  144. package/server/lib/session-goal/runtime.js +46 -4
  145. package/server/lib/session-goal/runtime.test.js +62 -1
  146. package/server/lib/session-index/sync-runtime.js +20 -17
  147. package/server/lib/session-index/sync-runtime.test.js +9 -8
  148. package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
  149. package/server/lib/session-metadata/routes.js +94 -0
  150. package/server/lib/session-metadata/routes.test.js +111 -0
  151. package/server/lib/session-metadata/session-metadata-store.js +235 -0
  152. package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
  153. package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
  154. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  155. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  156. package/server/lib/session-turn-pages/routes.js +204 -71
  157. package/server/lib/session-turn-pages/routes.test.js +285 -0
  158. package/server/lib/session-turn-pages/service.js +6 -2
  159. package/server/lib/session-turn-pages/service.test.js +34 -0
  160. package/server/lib/small-model/DOCUMENTATION.md +5 -4
  161. package/server/lib/small-model/catalog.js +56 -6
  162. package/server/lib/small-model/catalog.test.js +47 -0
  163. package/server/lib/small-model/index.js +3 -3
  164. package/server/lib/small-model/opencode-session.js +50 -206
  165. package/server/lib/small-model/opencode-session.test.js +58 -88
  166. package/server/opencode-proxy.test.js +61 -12
  167. package/dist/assets/AssistantView-BjvfkSRS.js +0 -3
  168. package/dist/assets/MarkdownRenderer-Cg82L3fN.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-s_vvUD_F.js +0 -1
  170. package/dist/assets/MobileSurface-DHpXHbJp.js +0 -2
  171. package/dist/assets/SettingsView-l0J7pfYo.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CHpibtno.js +0 -7
  174. package/dist/assets/insertionBoundaries-Yd1Lorjw.js +0 -1
  175. package/dist/assets/ja-Bgr8-dOg.js +0 -15
  176. package/dist/assets/ko-B_Gxh-0l.js +0 -15
  177. package/dist/assets/main-BoRXxmB3.js +0 -2
  178. package/dist/assets/main-DCDcdV5o.js +0 -169
  179. package/dist/assets/miniChat-12fxk5KR.js +0 -2
  180. package/dist/assets/mobile-CkZem9Ad.js +0 -2
  181. package/dist/assets/pl-DW2XJGC4.js +0 -15
  182. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  183. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  184. package/dist/assets/renderElectronMiniChatApp-COFc0ZDJ.js +0 -2
  185. package/dist/assets/renderMobileApp-BKzx7JL3.js +0 -28
  186. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  187. package/dist/assets/useMobileNavigationStore-DrB_awgk.js +0 -413
  188. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  189. package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
@@ -39,7 +39,7 @@ const hasPartType = (parts, type) =>
39
39
  Array.isArray(parts) && parts.some((part) => part && typeof part === 'object' && part.type === type);
40
40
 
41
41
  const isHostedSessionDivider = (record) => {
42
- const id = record?.info?.id;
42
+ const id = record?.info?.id ?? record?.id;
43
43
  return typeof id === 'string' && id.startsWith(ASSISTANT_SESSION_DIVIDER_PREFIX);
44
44
  };
45
45
 
@@ -53,6 +53,10 @@ export const isUserAuthoredTurnBoundary = (record) => {
53
53
  if (!record || typeof record !== 'object') return false;
54
54
  if (isHostedSessionDivider(record)) return false;
55
55
 
56
+ if (typeof record.type === 'string' && record.info == null) {
57
+ return record.type === 'user';
58
+ }
59
+
56
60
  const info = record.info ?? {};
57
61
  const role = typeof info.clientRole === 'string' ? info.clientRole : info.role;
58
62
  if (role !== 'user') return false;
@@ -94,7 +98,7 @@ export const selectTurnRecords = (timeline, turnLimit) => {
94
98
  };
95
99
 
96
100
  const recordId = (record) => {
97
- const id = record?.info?.id;
101
+ const id = record?.info?.id ?? record?.id;
98
102
  return typeof id === 'string' && id.length > 0 ? id : null;
99
103
  };
100
104
 
@@ -1241,3 +1241,37 @@ describe('createSessionTurnPageService', () => {
1241
1241
  });
1242
1242
  });
1243
1243
  });
1244
+
1245
+ describe('SessionMessageInfo turn pages', () => {
1246
+ const v2User = (id, extra = {}) => ({ id, type: 'user', time: { created: 1 }, text: 'hi', ...extra });
1247
+ const v2Assistant = (id, extra = {}) => ({
1248
+ id,
1249
+ type: 'assistant',
1250
+ time: { created: 2, completed: 3 },
1251
+ content: [{ type: 'text', text: 'ok' }],
1252
+ ...extra,
1253
+ });
1254
+
1255
+ it('counts SessionMessageInfo type=user as an authored boundary', () => {
1256
+ expect(isUserAuthoredTurnBoundary(v2User('msg_u1'))).toBe(true);
1257
+ expect(isUserAuthoredTurnBoundary(v2Assistant('msg_a1'))).toBe(false);
1258
+ expect(isUserAuthoredTurnBoundary({ id: 'msg_syn', type: 'synthetic', time: { created: 1 }, text: 'loop' })).toBe(false);
1259
+ expect(isUserAuthoredTurnBoundary({
1260
+ id: `oc_asst_session_divider:ses_old`,
1261
+ type: 'system',
1262
+ time: { created: 0 },
1263
+ })).toBe(false);
1264
+ });
1265
+
1266
+ it('aggregates SessionMessageInfo pages by id and type', async () => {
1267
+ const fetchPage = vi.fn(async () => ({
1268
+ records: [v2User('msg_u1'), v2Assistant('msg_a1')],
1269
+ nextCursor: null,
1270
+ complete: true,
1271
+ }));
1272
+ const service = createSessionTurnPageService({ fetchPage });
1273
+ const result = await service.loadPage({ sessionID: 'ses_1', turns: 1 });
1274
+ expect(result).toMatchObject({ ok: true, turnCount: 1, complete: true });
1275
+ expect(result.records.map((entry) => entry.id)).toEqual(['msg_u1', 'msg_a1']);
1276
+ });
1277
+ });
@@ -24,10 +24,11 @@ every other runtime API.
24
24
  tasks. Tests may call the factory with a mock `getModelCatalog`. Returns
25
25
  `stop()` to remove the lazy temp directory used by the session path (wired
26
26
  from `server/index.js` `stop()`).
27
- - `opencode-session.js` — temporary OpenCode session path for non-dedicated
28
- providers. Lazily creates `mkdtemp('openchamber-smallmodel-')` with a hidden
29
- deny-all agent, then create → archive → `prompt_async` → poll idle → read
30
- assistant text → best-effort `session.delete`.
27
+ - `opencode-session.js` — fallback for non-dedicated providers. Lazily prepares
28
+ `mkdtemp('openchamber-smallmodel-')` with a hidden deny-all agent markdown
29
+ (isolation invariant), then calls official `@opencode/client`
30
+ `generate.text({ location?, prompt, model: { id, providerID } })`. No coding
31
+ session / `promptAsync` / `tool.ids` path. Empty text is never success.
31
32
  - `resolve.js` — model selection, mirroring OpenCode's `getSmallModel` chain:
32
33
  0. OpenChamber's own settings override (Settings → Sessions → Small Model):
33
34
  when `smallModelUseDefault` is `false`, `smallModelOverride`
@@ -1,4 +1,4 @@
1
- import { createOpencodeClient } from '@opencode-ai/sdk/v2';
1
+ import { OpenCode } from '@opencode/client';
2
2
 
3
3
  // Directory-scoped OpenCode provider catalog for small-model resolution.
4
4
  // Never contacts models.dev — source of truth is client.config.providers().
@@ -46,6 +46,51 @@ const normalizeDirectoryKey = (directory) => {
46
46
 
47
47
  const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
48
48
 
49
+ // Compose official v2 provider.list + model.list into the SDK catalog shape
50
+ // consumed by toSmallModelCatalog. Missing arrays are failure, not empty success.
51
+ const composeV2ProviderCatalogSource = (providers, models) => {
52
+ if (!Array.isArray(providers) || !Array.isArray(models)) return null;
53
+
54
+ const modelsByProvider = new Map();
55
+ for (const model of models) {
56
+ if (!isRecord(model)) continue;
57
+ const providerID = typeof model.providerID === 'string' ? model.providerID : '';
58
+ const modelID = typeof model.modelID === 'string' && model.modelID
59
+ ? model.modelID
60
+ : (typeof model.id === 'string' ? model.id : '');
61
+ if (!providerID || !modelID) continue;
62
+ if (!modelsByProvider.has(providerID)) modelsByProvider.set(providerID, Object.create(null));
63
+ const entry = { id: modelID };
64
+ if (typeof model.family === 'string' && model.family) entry.family = model.family;
65
+ if (isRecord(model.limit)) {
66
+ const limit = Object.create(null);
67
+ if (Number.isFinite(model.limit.context)) limit.context = model.limit.context;
68
+ if (Number.isFinite(model.limit.output)) limit.output = model.limit.output;
69
+ if (Object.keys(limit).length > 0) entry.limit = limit;
70
+ }
71
+ if (isRecord(model.api) && typeof model.api.url === 'string' && model.api.url.trim()) {
72
+ entry.api = { url: model.api.url.trim() };
73
+ }
74
+ if (typeof model.time?.released === 'number' && Number.isFinite(model.time.released)) {
75
+ entry.release_date = new Date(model.time.released).toISOString().slice(0, 10);
76
+ } else if (typeof model.release_date === 'string' && model.release_date) {
77
+ entry.release_date = model.release_date;
78
+ }
79
+ modelsByProvider.get(providerID)[modelID] = entry;
80
+ }
81
+
82
+ const catalogProviders = [];
83
+ for (const provider of providers) {
84
+ if (!isRecord(provider) || typeof provider.id !== 'string' || !provider.id) continue;
85
+ catalogProviders.push({
86
+ id: provider.id,
87
+ name: typeof provider.name === 'string' && provider.name ? provider.name : provider.id,
88
+ models: modelsByProvider.get(provider.id) || Object.create(null),
89
+ });
90
+ }
91
+ return { providers: catalogProviders };
92
+ };
93
+
49
94
  /**
50
95
  * Convert the official SDK provider list into the internal small-model catalog
51
96
  * shape. Keeps family / release_date / limit / cost.input|output /
@@ -126,17 +171,22 @@ export function createModelCatalogLoader({
126
171
 
127
172
  const fetchOpenCodeCatalog = async (directory) => {
128
173
  const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
129
- const client = createOpencodeClient({
174
+ const client = OpenCode.make({
130
175
  baseUrl,
131
- ...(directory ? { directory } : {}),
132
176
  headers: getOpenCodeAuthHeaders(),
133
177
  fetch: (request) => fetchImpl(request, { signal: AbortSignal.timeout(timeoutMs) }),
134
178
  });
135
- const response = await client.config.providers(directory ? { directory } : {});
136
- if (response?.error || response?.data === undefined) {
179
+ const location = directory ? { directory } : undefined;
180
+ const request = location ? { location } : undefined;
181
+ const [providersResult, modelsResult] = await Promise.all([
182
+ client.provider.list(request),
183
+ client.model.list(request),
184
+ ]);
185
+ const source = composeV2ProviderCatalogSource(providersResult?.data, modelsResult?.data);
186
+ if (!source) {
137
187
  throw new Error('OpenCode provider catalog is unavailable');
138
188
  }
139
- const catalog = toSmallModelCatalog(response.data);
189
+ const catalog = toSmallModelCatalog(source);
140
190
  if (!catalog) {
141
191
  throw new Error('OpenCode provider catalog returned an unexpected payload');
142
192
  }
@@ -68,6 +68,53 @@ describe('createModelCatalogLoader', () => {
68
68
  expect(Object.keys(catalogA).length).toBeGreaterThan(0);
69
69
  });
70
70
 
71
+ it('composes official v2 provider.list + model.list into the small-model catalog', async () => {
72
+ const loader = createModelCatalogLoader({
73
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096/',
74
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
75
+ fetchImpl: async (url) => {
76
+ const href = String(url);
77
+ const data = href.includes('/api/model')
78
+ ? [{
79
+ id: 'gemini-2.5-flash',
80
+ modelID: 'gemini-2.5-flash',
81
+ providerID: 'google',
82
+ family: 'gemini-flash',
83
+ name: 'Gemini 2.5 Flash',
84
+ capabilities: {},
85
+ variants: [],
86
+ time: { released: Date.parse('2025-06-01T00:00:00Z') },
87
+ cost: [],
88
+ status: 'active',
89
+ enabled: true,
90
+ limit: { context: 1_000_000, output: 8192 },
91
+ api: { url: 'https://generativelanguage.googleapis.com' },
92
+ }]
93
+ : [{ id: 'google', name: 'Google', package: '@ai-sdk/google' }];
94
+ return new Response(JSON.stringify({
95
+ location: { directory: '/proj', project: { id: 'p', directory: '/proj', canonical: '/proj' } },
96
+ data,
97
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
98
+ },
99
+ });
100
+
101
+ const catalog = await loader.getModelCatalog('/proj');
102
+ expect(catalog).not.toBe(MINIMAL_FALLBACK_CATALOG);
103
+ expect(catalog.google).toEqual({
104
+ id: 'google',
105
+ name: 'Google',
106
+ models: {
107
+ 'gemini-2.5-flash': {
108
+ id: 'gemini-2.5-flash',
109
+ family: 'gemini-flash',
110
+ release_date: '2025-06-01',
111
+ limit: { context: 1_000_000, output: 8192 },
112
+ api: { url: 'https://generativelanguage.googleapis.com' },
113
+ },
114
+ },
115
+ });
116
+ });
117
+
71
118
  it('normalizes directory keys so trailing slashes share a bucket', () => {
72
119
  const loader = createModelCatalogLoader({
73
120
  buildOpenCodeUrl: () => 'http://127.0.0.1:9/',
@@ -3,7 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { readAuthFile } from '../opencode/auth.js';
5
5
  import { readConfigLayers } from '../opencode/shared.js';
6
- import { createOpencodeClient } from '@opencode-ai/sdk/v2';
6
+ import { OpenCode } from '@opencode/client';
7
7
  import { loadConnectedCatalog } from '../llm/catalog.js';
8
8
  import { createModelCatalogLoader, getCatalogProvider } from './catalog.js';
9
9
  import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
@@ -215,11 +215,11 @@ export function createSmallModelService(dependencies) {
215
215
  getOpenCodeAuthHeaders,
216
216
  }).getModelCatalog;
217
217
  const getConnectedCatalog = dependencies.getConnectedCatalog || (async (directory) => {
218
- const client = createOpencodeClient({
218
+ const client = OpenCode.make({
219
219
  baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
220
- ...(directory ? { directory } : {}),
221
220
  headers: getOpenCodeAuthHeaders(),
222
221
  });
222
+ void directory;
223
223
  return loadConnectedCatalog(client);
224
224
  });
225
225
 
@@ -2,17 +2,18 @@ import fs from 'fs';
2
2
  import fsPromises from 'fs/promises';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
- import { createOpencodeClient } from '@opencode-ai/sdk/v2';
5
+ import { OpenCode } from '@opencode/client';
6
6
 
7
- // Temporary OpenCode session path for providers that lack a dedicated
8
- // small-model adapter (plugin providers, region/credential-chain, …).
7
+ // Temporary OpenCode path for providers that lack a dedicated small-model
8
+ // adapter (plugin providers, region/credential-chain, …).
9
9
  // Auth, endpoint rewrite, and token refresh stay inside the OpenCode runtime.
10
+ //
11
+ // Prefer official `generate.text` (no session). The deny-all agent markdown in
12
+ // the lazy temp directory remains available if a future caller needs a session
13
+ // workspace, but pure-text small-model calls must not open coding sessions.
10
14
 
11
15
  export const SMALL_MODEL_AGENT_NAME = 'openchamber-smallmodel';
12
16
  export const SESSION_SETTLE_TIMEOUT_MS = 60_000;
13
- const SESSION_SETTLE_POLL_MS = 1_000;
14
- const ARCHIVE_RETRY_MS = 25;
15
- const INCOMPLETE_ASSISTANT_SETTLE_PROBES = 2;
16
17
 
17
18
  const AGENT_MARKDOWN = `---
18
19
  mode: primary
@@ -31,64 +32,9 @@ let tempDirectory = null;
31
32
  /** @type {Promise<string> | null} */
32
33
  let tempDirectoryInflight = null;
33
34
 
34
- const isMissing = (result) =>
35
- result?.error?.status === 404
36
- || result?.error?.statusCode === 404
37
- || result?.error?.code === 'not_found'
38
- || result?.status === 404;
39
-
40
- const promptAdmitted = (result) =>
41
- !result?.error
42
- && (result?.response?.status === 204
43
- || result?.status === 204
44
- || result?.data !== undefined
45
- || result?.response?.ok === true);
46
-
47
- const sleep = (ms, signal) => new Promise((resolve, reject) => {
48
- if (signal?.aborted) {
49
- reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
50
- return;
51
- }
52
- const timer = setTimeout(resolve, ms);
53
- const onAbort = () => {
54
- clearTimeout(timer);
55
- reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'));
56
- };
57
- signal?.addEventListener?.('abort', onAbort, { once: true });
58
- });
59
-
60
- const sdkErrorMessage = (result, fallback) => {
61
- const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status;
62
- const message = result?.error?.message || result?.error?.data?.message || fallback;
63
- return status ? `${message} (${status})` : message;
64
- };
65
-
66
- const readMessageInfo = (message) => {
67
- if (!message || typeof message !== 'object') return null;
68
- if (message.info && typeof message.info === 'object') return message.info;
69
- return message;
70
- };
71
-
72
- const assistantTextFromMessages = (messages) => {
73
- if (!Array.isArray(messages) || messages.length === 0) return '';
74
- for (let i = messages.length - 1; i >= 0; i -= 1) {
75
- const message = messages[i];
76
- const info = readMessageInfo(message);
77
- if (info?.role !== 'assistant') continue;
78
- if (info.error) {
79
- const detail = typeof info.error === 'string'
80
- ? info.error
81
- : (info.error?.message || JSON.stringify(info.error));
82
- throw new Error(`OpenCode small-model session assistant error: ${detail}`);
83
- }
84
- const parts = Array.isArray(message?.parts) ? message.parts : [];
85
- const text = parts
86
- .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
87
- .filter(Boolean)
88
- .join('');
89
- return text;
90
- }
91
- return '';
35
+ const clientErrorMessage = (error, fallback) => {
36
+ if (typeof error?.message === 'string' && error.message.trim()) return error.message;
37
+ return fallback;
92
38
  };
93
39
 
94
40
  const ensureTempDirectory = async () => {
@@ -112,100 +58,16 @@ const ensureTempDirectory = async () => {
112
58
  return tempDirectoryInflight;
113
59
  };
114
60
 
115
- const createClient = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, directory }) => {
61
+ const createClient = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders }) => {
116
62
  const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
117
- return createOpencodeClient({
63
+ return OpenCode.make({
118
64
  baseUrl,
119
- directory,
120
65
  headers: getOpenCodeAuthHeaders(),
121
66
  });
122
67
  };
123
68
 
124
- const archiveCreatedSession = async (client, sessionID, directory) => {
125
- const archiveAt = Date.now();
126
- const update = () => client.session.update({
127
- sessionID,
128
- directory,
129
- time: { archived: archiveAt },
130
- });
131
- let result = await update();
132
- if (isMissing(result)) {
133
- await sleep(ARCHIVE_RETRY_MS);
134
- result = await update();
135
- }
136
- if (result?.error || isMissing(result)) {
137
- throw new Error(`OpenCode small-model session archive failed: ${sdkErrorMessage(result, 'archive failed')}`);
138
- }
139
- };
140
-
141
- const waitForIdleAssistant = async ({ client, sessionID, directory, signal }) => {
142
- let incompleteAssistantProbes = 0;
143
- let emptyIdleProbes = 0;
144
-
145
- for (;;) {
146
- signal?.throwIfAborted?.();
147
-
148
- let sessionBusy = false;
149
- try {
150
- const statusResult = await client.session.status({ directory }, { signal });
151
- if (!statusResult?.error && statusResult?.data && typeof statusResult.data === 'object') {
152
- const statusValue = statusResult.data[sessionID];
153
- const type = statusValue?.type ?? statusValue?.status;
154
- sessionBusy = type === 'busy' || type === 'retry';
155
- }
156
- } catch (error) {
157
- if (signal?.aborted) throw error;
158
- }
159
-
160
- if (sessionBusy) {
161
- incompleteAssistantProbes = 0;
162
- emptyIdleProbes = 0;
163
- await sleep(SESSION_SETTLE_POLL_MS, signal);
164
- continue;
165
- }
166
-
167
- try {
168
- const messagesResult = await client.session.messages({
169
- sessionID,
170
- directory,
171
- limit: 20,
172
- }, { signal });
173
- if (!messagesResult?.error && Array.isArray(messagesResult?.data)) {
174
- const lastInfo = readMessageInfo(messagesResult.data.at(-1));
175
- if (lastInfo?.role === 'assistant') {
176
- emptyIdleProbes = 0;
177
- if (lastInfo.error) {
178
- const detail = typeof lastInfo.error === 'string'
179
- ? lastInfo.error
180
- : (lastInfo.error?.message || JSON.stringify(lastInfo.error));
181
- throw new Error(`OpenCode small-model session assistant error: ${detail}`);
182
- }
183
- if (lastInfo.time?.completed) {
184
- return messagesResult.data;
185
- }
186
- incompleteAssistantProbes += 1;
187
- if (incompleteAssistantProbes >= INCOMPLETE_ASSISTANT_SETTLE_PROBES) {
188
- return messagesResult.data;
189
- }
190
- } else {
191
- incompleteAssistantProbes = 0;
192
- emptyIdleProbes += 1;
193
- if (emptyIdleProbes >= 5 && lastInfo?.role === 'user') {
194
- throw new Error('OpenCode small-model session ended without assistant response');
195
- }
196
- }
197
- }
198
- } catch (error) {
199
- if (signal?.aborted) throw error;
200
- if (error instanceof Error && error.message.startsWith('OpenCode small-model')) throw error;
201
- }
202
-
203
- await sleep(SESSION_SETTLE_POLL_MS, signal);
204
- }
205
- };
206
-
207
69
  /**
208
- * Generate text via a throwaway OpenCode session (deny-all hidden agent).
70
+ * Generate text via official OpenCode `generate.text`.
209
71
  * Errors are never masked as empty success.
210
72
  *
211
73
  * @param {{
@@ -217,6 +79,7 @@ const waitForIdleAssistant = async ({ client, sessionID, directory, signal }) =>
217
79
  * system?: string,
218
80
  * purpose?: string,
219
81
  * directory?: string,
82
+ * settleTimeoutMs?: number,
220
83
  * }} options
221
84
  */
222
85
  export async function generateViaOpenCodeSession({
@@ -226,7 +89,8 @@ export async function generateViaOpenCodeSession({
226
89
  modelID,
227
90
  prompt,
228
91
  system,
229
- purpose,
92
+ purpose: _purpose,
93
+ directory,
230
94
  settleTimeoutMs = SESSION_SETTLE_TIMEOUT_MS,
231
95
  }) {
232
96
  if (typeof prompt !== 'string' || !prompt.trim()) {
@@ -236,79 +100,59 @@ export async function generateViaOpenCodeSession({
236
100
  throw new Error('OpenCode small-model session requires providerID and modelID');
237
101
  }
238
102
 
239
- const purposeLabel = typeof purpose === 'string' && purpose.trim() ? purpose.trim() : 'utility';
240
103
  const budgetMs = Number(settleTimeoutMs) > 0 ? Number(settleTimeoutMs) : SESSION_SETTLE_TIMEOUT_MS;
241
- const workingDirectory = await ensureTempDirectory();
104
+ // Keep the deny-all agent workspace warm for isolation invariants / future
105
+ // session needs, but pure text uses generate.text (no coding session).
106
+ await ensureTempDirectory();
107
+
242
108
  const client = createClient({
243
109
  buildOpenCodeUrl,
244
110
  getOpenCodeAuthHeaders,
245
- directory: workingDirectory,
246
111
  });
247
112
 
248
- let sessionID = null;
113
+ const locationDirectory = typeof directory === 'string' && directory.trim()
114
+ ? directory.trim()
115
+ : null;
116
+ const location = locationDirectory ? { directory: locationDirectory } : undefined;
117
+
118
+ const fullPrompt = [
119
+ typeof system === 'string' && system.trim() ? system.trim() : '',
120
+ prompt.trim(),
121
+ ].filter(Boolean).join('\n\n');
122
+
249
123
  const controller = new AbortController();
250
124
  const timeout = setTimeout(() => {
251
- controller.abort(new Error(`OpenCode small-model session timed out after ${budgetMs}ms`));
125
+ controller.abort(new Error(`OpenCode small-model generate timed out after ${budgetMs}ms`));
252
126
  }, budgetMs);
253
127
 
254
128
  try {
255
- const createResult = await client.session.create({
256
- directory: workingDirectory,
257
- title: `[small-model] ${purposeLabel}`,
258
- agent: SMALL_MODEL_AGENT_NAME,
259
- metadata: {
260
- openchamber: {
261
- smallModel: { purpose: purposeLabel },
262
- },
263
- },
264
- }, { signal: controller.signal });
265
-
266
- sessionID = createResult?.data?.id;
267
- if (createResult?.error || !sessionID) {
268
- throw new Error(`OpenCode small-model session create failed: ${sdkErrorMessage(createResult, 'create failed')}`);
269
- }
270
-
271
- // Archive before prompting so ordinary session lists never flash system sessions.
272
- await archiveCreatedSession(client, sessionID, workingDirectory);
273
-
274
- const promptResult = await client.session.promptAsync({
275
- sessionID,
276
- directory: workingDirectory,
277
- agent: SMALL_MODEL_AGENT_NAME,
278
- model: { providerID, modelID },
279
- ...(typeof system === 'string' && system.trim() ? { system: system.trim() } : {}),
280
- parts: [{ type: 'text', text: prompt, synthetic: false }],
281
- }, { signal: controller.signal });
282
-
283
- if (!promptAdmitted(promptResult)) {
284
- throw new Error(`OpenCode small-model prompt_async failed: ${sdkErrorMessage(promptResult, 'prompt_async failed')}`);
129
+ let result;
130
+ try {
131
+ result = await client.generate.text({
132
+ ...(location ? { location } : {}),
133
+ prompt: fullPrompt,
134
+ model: { id: modelID, providerID },
135
+ }, { signal: controller.signal });
136
+ } catch (error) {
137
+ if (controller.signal.aborted) {
138
+ throw controller.signal.reason instanceof Error
139
+ ? controller.signal.reason
140
+ : new Error(`OpenCode small-model generate timed out after ${budgetMs}ms`);
141
+ }
142
+ throw new Error(
143
+ `OpenCode small-model generate.text failed: ${clientErrorMessage(error, 'generate.text failed')}`,
144
+ );
285
145
  }
286
146
 
287
- const messages = await waitForIdleAssistant({
288
- client,
289
- sessionID,
290
- directory: workingDirectory,
291
- signal: controller.signal,
292
- });
293
- const text = assistantTextFromMessages(messages);
147
+ const text = typeof result?.text === 'string' ? result.text : '';
294
148
  if (!text.trim()) {
295
- throw new Error('OpenCode small-model session returned no assistant text');
149
+ throw new Error('OpenCode small-model generate.text returned no assistant text');
296
150
  }
297
151
  return text;
298
152
  } finally {
299
153
  clearTimeout(timeout);
300
- if (sessionID) {
301
- try {
302
- await client.session.delete({ sessionID, directory: workingDirectory });
303
- } catch (error) {
304
- console.warn(
305
- '[small-model] failed to delete temporary OpenCode session:',
306
- error?.message || error,
307
- );
308
- }
309
- }
310
154
  }
311
- };
155
+ }
312
156
 
313
157
  /** Best-effort cleanup of the lazy temp directory (agent markdown + empty root). */
314
158
  export async function stop() {