diff --git a/bun.lock b/bun.lock index e82750b2..166c3939 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.4.48", + "@opencode-ai/sdk": "^1.4.49", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -151,7 +151,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.4.48", + "@opencode-ai/sdk": "^1.4.49", "@pierre/diffs": "1.1.0-beta.13", "@simplewebauthn/browser": "13.3.0", "@tanstack/react-virtual": "^3.13.18", @@ -222,7 +222,7 @@ "version": "1.10.4", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.4.48", + "@opencode-ai/sdk": "^1.4.49", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -249,7 +249,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.4.48", + "@opencode-ai/sdk": "^1.4.49", "@simplewebauthn/server": "13.3.0", "adm-zip": "^0.5.16", "better-sqlite3": "^11.7.0", diff --git a/package.json b/package.json index 4625cf67..dfba2d87 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.4.48", + "@opencode-ai/sdk": "^1.4.49", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/package.json b/packages/ui/package.json index c8c10949..dc43f8f4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.4.48", + "@opencode-ai/sdk": "^1.4.49", "@pierre/diffs": "1.1.0-beta.13", "@base-ui/react": "^1.4.0", "@simplewebauthn/browser": "13.3.0", diff --git a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx index f3c0ea3e..3fa97788 100644 --- a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx @@ -6,6 +6,8 @@ import type { ContentChangeReason } from '@/hooks/useChatAutoFollow'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility'; import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug'; +import { GeneratedJsonResultCard } from './GeneratedJsonResultCard'; +import { parseGeneratedJsonResult } from './generatedJsonResult'; type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } }; @@ -71,6 +73,18 @@ const AssistantTextPart: React.FC = ({ return null; } + const generatedResult = !isStreaming && isFinalized ? parseGeneratedJsonResult(displayTextContent) : null; + if (generatedResult) { + return ( +
+ +
+ ); + } + return (
= ({ result }) => { + const { t } = useI18n(); + const [copied, setCopied] = React.useState(false); + + const copyText = React.useMemo(() => { + if (result.kind === 'commit') { + return [result.subject, ...result.highlights.map((highlight) => `- ${highlight}`)].join('\n'); + } + return [result.title, result.body].filter(Boolean).join('\n\n'); + }, [result]); + + const handleCopy = React.useCallback(async () => { + const copyResult = await copyTextToClipboard(copyText || result.raw); + if (!copyResult.ok) return; + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }, [copyText, result.raw]); + + return ( +
+
+ + {result.kind === 'commit' + ? t('chat.generatedResult.commit.title') + : t('chat.generatedResult.pullRequest.title')} + + +
+
+ {result.kind === 'commit' ? ( + <> +
{result.subject}
+ {result.highlights.length > 0 ? ( +
    + {result.highlights.map((highlight, index) => ( +
  • + - + {highlight} +
  • + ))} +
+ ) : null} + + ) : ( + <> + {result.title ? ( +
+
{t('chat.generatedResult.pullRequest.titleLabel')}
+
{result.title}
+
+ ) : null} + {result.body ? ( +
+
{t('chat.generatedResult.pullRequest.bodyLabel')}
+
{result.body}
+
+ ) : null} + + )} +
+
+ ); +}; diff --git a/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts b/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts new file mode 100644 index 00000000..c499bf6c --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts @@ -0,0 +1,67 @@ +export type GeneratedCommitResult = { + kind: 'commit'; + subject: string; + highlights: string[]; + raw: string; +}; + +export type GeneratedPrResult = { + kind: 'pr'; + title: string; + body: string; + raw: string; +}; + +export type GeneratedResult = GeneratedCommitResult | GeneratedPrResult; + +const parseJsonObjects = (value: string): Record[] => { + const text = value.trim(); + const candidates = new Set(); + + const fencedMatches = text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi); + for (const match of fencedMatches) { + if (match[1]) candidates.add(match[1].trim()); + } + + const firstObjectStart = text.indexOf('{'); + if (firstObjectStart >= 0) { + for (let end = text.length; end > firstObjectStart; end -= 1) { + if (text[end - 1] === '}') { + candidates.add(text.slice(firstObjectStart, end)); + break; + } + } + } + + const parsed: Record[] = []; + for (const candidate of candidates) { + try { + const item = JSON.parse(candidate) as unknown; + if (item && typeof item === 'object' && !Array.isArray(item)) { + parsed.push(item as Record); + } + } catch { + // Ignore non-JSON prose; the model may include markdown before the object. + } + } + return parsed; +}; + +export const parseGeneratedJsonResult = (value: string): GeneratedResult | null => { + for (const item of parseJsonObjects(value)) { + const subject = typeof item.subject === 'string' ? item.subject.trim() : ''; + const highlights = Array.isArray(item.highlights) + ? item.highlights.filter((entry) => typeof entry === 'string').map((entry) => entry.trim()).filter(Boolean).slice(0, 3) + : []; + if (subject) { + return { kind: 'commit', subject, highlights, raw: JSON.stringify({ subject, highlights }, null, 2) }; + } + + const title = typeof item.title === 'string' ? item.title.trim() : ''; + const body = typeof item.body === 'string' ? item.body.trim() : ''; + if (title || body) { + return { kind: 'pr', title, body, raw: JSON.stringify({ title, body }, null, 2) }; + } + } + return null; +}; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 43fd0627..c55ba64a 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -58,6 +58,46 @@ const requestChatForceScrollBottom = (sessionId: string) => { })); }; +const extractJsonObject = (value: string): Record | null => { + const text = value.trim(); + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = (fenced?.[1] ?? text).trim(); + const starts = [candidate.indexOf('{')].filter((index) => index >= 0); + + for (const start of starts) { + for (let end = candidate.length; end > start; end -= 1) { + if (candidate[end - 1] !== '}') continue; + try { + const parsed = JSON.parse(candidate.slice(start, end)) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Keep scanning; models sometimes wrap JSON with prose or fences. + } + } + } + + return null; +}; + +const extractAssistantText = (response: unknown): string => { + const data = (response as { data?: { parts?: Array } } | null)?.data; + const parts = Array.isArray(data?.parts) ? data.parts : []; + return parts + .map((part) => { + const item = part as { type?: unknown; text?: unknown; content?: unknown; value?: unknown }; + if (item.type !== 'text') return ''; + if (typeof item.text === 'string') return item.text; + if (typeof item.content === 'string') return item.content; + if (typeof item.value === 'string') return item.value; + return ''; + }) + .filter((text) => text.trim().length > 0) + .join('\n') + .trim(); +}; + export async function checkIsGitRepository(directory: string): Promise { const runtime = getRuntimeGit(); if (runtime) return runtime.checkIsGitRepository(directory); @@ -150,20 +190,6 @@ export async function generateCommitMessage( visiblePrompt, hiddenPrompt, generationSession, - schema: { - type: 'object', - additionalProperties: false, - properties: { - subject: { type: 'string', description: 'Conventional commit subject line.' }, - highlights: { - type: 'array', - items: { type: 'string', description: 'Short user-facing highlight.' }, - maxItems: 3, - description: 'Optional short user-facing highlights.', - }, - }, - required: ['subject', 'highlights'], - }, kind: 'commit', }); @@ -271,15 +297,6 @@ export async function generatePullRequestDescription( visiblePrompt, hiddenPrompt, generationSession, - schema: { - type: 'object', - additionalProperties: false, - properties: { - title: { type: 'string', description: 'Pull request title.' }, - body: { type: 'string', description: 'Pull request markdown description.' }, - }, - required: ['title', 'body'], - }, kind: 'pr', }); @@ -347,14 +364,12 @@ const runStructuredGenerationInActiveSession = async ({ visiblePrompt, hiddenPrompt, generationSession, - schema, kind, }: { directory: string; visiblePrompt: string; hiddenPrompt?: string; generationSession: SessionGenerationContext; - schema: Record; kind: 'commit' | 'pr'; }): Promise> => { const requestStartedAt = Date.now(); @@ -371,7 +386,11 @@ const runStructuredGenerationInActiveSession = async ({ const hiddenPromptText = typeof hiddenPrompt === 'string' ? hiddenPrompt.trim() : ''; const promptParts: Array<{ type: 'text'; text: string; synthetic?: boolean }> = []; if (visiblePromptText) { - promptParts.push({ type: 'text', text: visiblePromptText, synthetic: false }); + promptParts.push({ + type: 'text', + text: hiddenPromptText ? `${visiblePromptText}\n\n` : visiblePromptText, + synthetic: false, + }); } if (hiddenPromptText) { promptParts.push({ type: 'text', text: hiddenPromptText, synthetic: true }); @@ -391,11 +410,6 @@ const runStructuredGenerationInActiveSession = async ({ modelID: generationSession.modelID, }, ...(generationSession.agent ? { agent: generationSession.agent } : {}), - format: { - type: 'json_schema', - schema, - retryCount: 2, - }, parts: promptParts, }); }); @@ -405,21 +419,23 @@ const runStructuredGenerationInActiveSession = async ({ throw new Error(responseError?.message || `Failed to generate ${kind} output`); } - const info = response.data.info as { finish?: string; structured_output?: unknown; structured?: unknown; error?: unknown }; - const structuredOutput = info?.structured_output || info?.structured; - if (!structuredOutput || typeof structuredOutput !== 'object' || Array.isArray(structuredOutput)) { - console.error('[git-generation][browser] invalid structured output', { + const info = response.data.info as { finish?: string; error?: unknown }; + const assistantText = extractAssistantText(response); + const parsedOutput = extractJsonObject(assistantText); + if (!parsedOutput) { + console.error('[git-generation][browser] invalid JSON output', { kind, sessionId: generationSession.sessionId, elapsedMs: Date.now() - requestStartedAt, finish: info?.finish, + assistantText, messageInfo: response.data.info, messageParts: response.data.parts, }); - throw new Error('No structured output returned by session'); + throw new Error('No JSON output returned by session'); } - return structuredOutput as Record; + return parsedOutput; }; export async function listGitWorktrees(directory: string): Promise { diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index c11cb6b1..4bc0285c 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1453,6 +1453,13 @@ export const dict = { 'chat.messageBody.actions.saveAsPlan': 'Save as plan', 'chat.messageBody.actions.startNewSession': 'Start new session from this answer', 'chat.messageBody.actions.startNewMultiRun': 'Start new multi-run from this answer', + 'chat.generatedResult.actions.copy': 'Copy', + 'chat.generatedResult.actions.copied': 'Copied', + 'chat.generatedResult.commit.title': 'Generated commit message', + 'chat.generatedResult.commit.highlights': 'Highlights', + 'chat.generatedResult.pullRequest.title': 'Generated pull request', + 'chat.generatedResult.pullRequest.titleLabel': 'Title', + 'chat.generatedResult.pullRequest.bodyLabel': 'Body', 'chat.messageBody.tts.stopSpeaking': 'Stop speaking', 'chat.messageBody.tts.readAloud': 'Read aloud', 'chat.messageBody.tts.readAloudWithProvider': 'Read aloud ({provider} voice)', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 6afb330c..42765179 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1419,6 +1419,13 @@ export const dict: Record = { "chat.messageBody.actions.saveAsPlan": "Guardar como plan", "chat.messageBody.actions.startNewSession": "Iniciar nueva sesión desde esta respuesta", "chat.messageBody.actions.startNewMultiRun": "Iniciar nuevo multi-run desde esta respuesta", + "chat.generatedResult.actions.copy": "Copiar", + "chat.generatedResult.actions.copied": "Copiado", + "chat.generatedResult.commit.title": "Mensaje de commit generado", + "chat.generatedResult.commit.highlights": "Puntos clave", + "chat.generatedResult.pullRequest.title": "Pull request generado", + "chat.generatedResult.pullRequest.titleLabel": "Título", + "chat.generatedResult.pullRequest.bodyLabel": "Descripción", "chat.messageBody.tts.stopSpeaking": "Dejar de hablar", "chat.messageBody.tts.readAloud": "Leer en voz alta", "chat.messageBody.tts.readAloudWithProvider": "Leer en voz alta ({provider} voz)", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 943886aa..518ad051 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1453,6 +1453,13 @@ export const dict: Record = { 'chat.messageBody.actions.saveAsPlan': '플랜으로 저장', 'chat.messageBody.actions.startNewSession': '이 응답에서 새 세션 시작', 'chat.messageBody.actions.startNewMultiRun': '이 응답에서 새 멀티런 시작', + 'chat.generatedResult.actions.copy': '복사', + 'chat.generatedResult.actions.copied': '복사됨', + 'chat.generatedResult.commit.title': '생성된 커밋 메시지', + 'chat.generatedResult.commit.highlights': '하이라이트', + 'chat.generatedResult.pullRequest.title': '생성된 Pull Request', + 'chat.generatedResult.pullRequest.titleLabel': '제목', + 'chat.generatedResult.pullRequest.bodyLabel': '본문', 'chat.messageBody.tts.stopSpeaking': '읽기 중지', 'chat.messageBody.tts.readAloud': '소리 내어 읽기', 'chat.messageBody.tts.readAloudWithProvider': '소리 내어 읽기({provider} 음성)', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 58f81033..4b313f2b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -516,6 +516,13 @@ export const dict: Record = { 'chat.messageBody.actions.saveAsPlan': 'Zapisz jako plan', 'chat.messageBody.actions.startNewSession': 'Rozpocznij nową sesję z tej odpowiedzi', 'chat.messageBody.actions.startNewMultiRun': 'Rozpocznij nowe wielokrotne uruchomienie z tej odpowiedzi', + 'chat.generatedResult.actions.copy': 'Kopiuj', + 'chat.generatedResult.actions.copied': 'Skopiowano', + 'chat.generatedResult.commit.title': 'Wygenerowana wiadomość commita', + 'chat.generatedResult.commit.highlights': 'Najważniejsze', + 'chat.generatedResult.pullRequest.title': 'Wygenerowany pull request', + 'chat.generatedResult.pullRequest.titleLabel': 'Tytuł', + 'chat.generatedResult.pullRequest.bodyLabel': 'Opis', 'chat.messageBody.tts.stopSpeaking': 'Przestań mówić', 'chat.messageBody.tts.readAloud': 'Czytaj na głos', 'chat.messageBody.tts.readAloudWithProvider': 'Czytaj na głos ({provider} voice)', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 9e1ddc00..7c96e6d1 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1419,6 +1419,13 @@ export const dict: Record = { "chat.messageBody.actions.saveAsPlan": "Salvar como plano", "chat.messageBody.actions.startNewSession": "Iniciar nova sessão a partir desta resposta", "chat.messageBody.actions.startNewMultiRun": "Iniciar novo multi-run a partir desta resposta", + "chat.generatedResult.actions.copy": "Copiar", + "chat.generatedResult.actions.copied": "Copiado", + "chat.generatedResult.commit.title": "Mensagem de commit gerada", + "chat.generatedResult.commit.highlights": "Destaques", + "chat.generatedResult.pullRequest.title": "Pull request gerado", + "chat.generatedResult.pullRequest.titleLabel": "Título", + "chat.generatedResult.pullRequest.bodyLabel": "Descrição", "chat.messageBody.tts.stopSpeaking": "Parar de falar", "chat.messageBody.tts.readAloud": "Ler em voz alta", "chat.messageBody.tts.readAloudWithProvider": "Ler em voz alta (voz {provider})", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 9170a8a9..bb12a831 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1419,6 +1419,13 @@ export const dict: Record = { "chat.messageBody.actions.saveAsPlan": "Зберегти як план", "chat.messageBody.actions.startNewSession": "Почати нову сесію із цієї відповіді", "chat.messageBody.actions.startNewMultiRun": "Почніть новий Multi-run із цієї відповіді", + "chat.generatedResult.actions.copy": "Копіювати", + "chat.generatedResult.actions.copied": "Скопійовано", + "chat.generatedResult.commit.title": "Згенероване повідомлення коміту", + "chat.generatedResult.commit.highlights": "Основне", + "chat.generatedResult.pullRequest.title": "Згенерований pull request", + "chat.generatedResult.pullRequest.titleLabel": "Заголовок", + "chat.generatedResult.pullRequest.bodyLabel": "Опис", "chat.messageBody.tts.stopSpeaking": "Зупинити озвучення", "chat.messageBody.tts.readAloud": "Прочитати вголос", "chat.messageBody.tts.readAloudWithProvider": "Прочитати вголос (голос {provider})", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index d3f03260..5b48c1a7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1419,6 +1419,13 @@ export const dict: Record = { 'chat.messageBody.actions.saveAsPlan': '保存为计划', 'chat.messageBody.actions.startNewSession': '基于此回答开始新会话', 'chat.messageBody.actions.startNewMultiRun': '基于此回答开始新的多运行', + 'chat.generatedResult.actions.copy': '复制', + 'chat.generatedResult.actions.copied': '已复制', + 'chat.generatedResult.commit.title': '生成的提交消息', + 'chat.generatedResult.commit.highlights': '要点', + 'chat.generatedResult.pullRequest.title': '生成的 Pull Request', + 'chat.generatedResult.pullRequest.titleLabel': '标题', + 'chat.generatedResult.pullRequest.bodyLabel': '正文', 'chat.messageBody.tts.stopSpeaking': '停止朗读', 'chat.messageBody.tts.readAloud': '朗读', 'chat.messageBody.tts.readAloudWithProvider': '朗读({provider} 语音)', diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts index d074ba59..c7b30ce9 100644 --- a/packages/ui/src/lib/magicPrompts.ts +++ b/packages/ui/src/lib/magicPrompts.ts @@ -60,7 +60,9 @@ export const MAGIC_PROMPT_DEFINITIONS: readonly MagicPromptDefinition[] = [ placeholders: [ { key: 'selected_files', description: 'Bullet list of currently selected file paths.' }, ], - template: `Return JSON with exactly this shape: + template: `Return exactly one JSON object and nothing else. Do not include prose, markdown, explanations, or code fences. + +The JSON object must have exactly this shape: {"subject": string, "highlights": string[]} Rules: @@ -69,6 +71,8 @@ Rules: - no scope in subject - keep subject concise and user-facing - highlights: 0-3 concise user-facing points +- use double quotes for all JSON strings +- do not include trailing commas or comments Selected files: {{selected_files}}`, @@ -92,13 +96,18 @@ Selected files: { key: 'changed_files', description: 'Bullet list of changed files in base...head.' }, { key: 'additional_context_block', description: 'Optional Additional context block (already formatted).' }, ], - template: `Return JSON with exactly this shape: + template: `Return exactly one JSON object and nothing else. Do not include prose, markdown outside JSON, explanations, or code fences. + +The JSON object must have exactly this shape: {"title": string, "body": string} Rules: - title: concise, outcome-first, conventional style - body: markdown with sections: ## Summary, ## Why, ## Testing - keep output concrete and user-facing +- put all markdown inside the body string +- use double quotes for all JSON strings and escape newlines as \\n +- do not include trailing commas or comments Base branch: {{base_branch}} Head branch: {{head_branch}} diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 5d526f85..87127062 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -243,7 +243,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.4.48", + "@opencode-ai/sdk": "^1.4.49", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index 623925ae..2bf4911e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -25,7 +25,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.4.48", + "@opencode-ai/sdk": "^1.4.49", "@simplewebauthn/server": "13.3.0", "adm-zip": "^0.5.16", "better-sqlite3": "^11.7.0",