diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx
index ad3faaea..47e5d92b 100644
--- a/packages/ui/src/components/chat/ChatContainer.tsx
+++ b/packages/ui/src/components/chat/ChatContainer.tsx
@@ -14,6 +14,7 @@ import MessageList, { type MessageListHandle } from './MessageList';
import { PermissionCard } from './PermissionCard';
import { QuestionCard } from './QuestionCard';
import { StatusRowContainer } from './StatusRowContainer';
+import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
@@ -266,6 +267,8 @@ const ChatViewport = React.memo(({
)}
+
+
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx
index add61bd4..00d7cccf 100644
--- a/packages/ui/src/components/chat/ChatInput.tsx
+++ b/packages/ui/src/components/chat/ChatInput.tsx
@@ -98,6 +98,7 @@ import {
findAttachmentCitationRanges,
} from './attachmentCitations';
import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
+import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
import type { Part } from '@opencode-ai/sdk/v2/client';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
@@ -4053,6 +4054,16 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
textareaRef.current?.focus({ preventScroll: isCapacitorApp() });
}, []);
+ const applyAssistSuggestion = React.useCallback((text: string) => {
+ setMessage(text);
+ if (isMobile && !mobileComposerExpanded) {
+ expandMobileComposer('focus');
+ } else {
+ requestAnimationFrame(() => textareaRef.current?.focus());
+ }
+ }, [expandMobileComposer, isMobile, mobileComposerExpanded]);
+
+
const handleMobileNewSession = React.useCallback(() => {
if (newSessionDraftOpen) return;
openNewSessionDraft(currentDirectory ? { directoryOverride: currentDirectory } : undefined);
@@ -4744,6 +4755,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
)}
>
{isMobile && !mobileComposerExpanded ? (
+
+
= ({ onOpenSettings, scrollTo
+
) : (
+ <>
+
= ({ onOpenSettings, scrollTo
+ >
)}
{/* Wrapper-level dictation engine + overlay: stays mounted across
the pill ↔ composer swap so a recording started from the pill
diff --git a/packages/ui/src/components/chat/SessionRecapSpacer.tsx b/packages/ui/src/components/chat/SessionRecapSpacer.tsx
new file mode 100644
index 00000000..7d60234d
--- /dev/null
+++ b/packages/ui/src/components/chat/SessionRecapSpacer.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { useSessionAssistState } from '@/hooks/useSessionAssist';
+import { useI18n } from '@/lib/i18n';
+
+interface SessionRecapNoteProps {
+ sessionId: string;
+ isMobile: boolean;
+}
+
+// Quiet one-paragraph recap of the agent's last reply, rendered right under
+// the last message (above the reserved bottom gap). Appears only after the
+// 5-minute quiet window, so the layout shift happens off-screen in practice.
+export const SessionRecapNote: React.FC = React.memo(({ sessionId, isMobile }) => {
+ const { visibleRecap } = useSessionAssistState(sessionId);
+ const { t } = useI18n();
+
+ if (!visibleRecap) {
+ return null;
+ }
+
+ return (
+
+ {/* The last assistant turn carries pb-8 — pull the recap up into that gap. */}
+
+
+ {t('chat.recap.label')}
+ {visibleRecap}
+
+
+
+ );
+});
+
+SessionRecapNote.displayName = 'SessionRecapNote';
diff --git a/packages/ui/src/components/chat/SessionSuggestionChip.tsx b/packages/ui/src/components/chat/SessionSuggestionChip.tsx
new file mode 100644
index 00000000..524d11d8
--- /dev/null
+++ b/packages/ui/src/components/chat/SessionSuggestionChip.tsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { useSessionAssistState } from '@/hooks/useSessionAssist';
+import { useThemeSystem } from '@/contexts/useThemeSystem';
+import { patchSessionMetadata } from '@/sync/session-actions';
+import { useI18n } from '@/lib/i18n';
+
+interface SessionSuggestionChipProps {
+ sessionId: string | null;
+ /** The composer already has content — the suggestion must stay out of the way. */
+ hidden: boolean;
+ onApply: (text: string) => void;
+ className?: string;
+}
+
+const isRecord = (value: unknown): value is Record =>
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+
+// One small-model-suggested follow-up message, styled like the draft starter
+// chips. Tapping it fills the composer (no auto-send); the X patches the
+// suggestion out of the session metadata so it stays dismissed everywhere.
+export const SessionSuggestionChip: React.FC = React.memo(({ sessionId, hidden, onApply, className }) => {
+ const { suggestion } = useSessionAssistState(sessionId ?? '');
+ const { t } = useI18n();
+ const { currentTheme } = useThemeSystem();
+ const [dismissing, setDismissing] = React.useState(false);
+
+ const handleDismiss = React.useCallback(async (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (!sessionId || dismissing) return;
+ setDismissing(true);
+ try {
+ await patchSessionMetadata(sessionId, undefined, (metadata) => {
+ const namespace = isRecord(metadata.openchamber) ? metadata.openchamber : {};
+ const assist = isRecord(namespace.assist) ? namespace.assist : {};
+ const nextAssist = { ...assist };
+ delete nextAssist.suggestion;
+ return { ...metadata, openchamber: { ...namespace, assist: nextAssist } };
+ });
+ } catch (error) {
+ console.warn('Failed to dismiss suggestion:', error);
+ } finally {
+ setDismissing(false);
+ }
+ }, [sessionId, dismissing]);
+
+ if (!suggestion || hidden) {
+ return null;
+ }
+
+ const chipStyle: React.CSSProperties = {
+ backgroundColor: currentTheme?.colors?.surface?.elevated,
+ borderColor: currentTheme?.colors?.interactive?.border,
+ };
+
+ return (
+
+
+
+
+
+
+
+ {suggestion}
+
+
+
+
+
+ );
+});
+
+SessionSuggestionChip.displayName = 'SessionSuggestionChip';
diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
index 5ecf206e..d100267b 100644
--- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
+++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
@@ -10,6 +10,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
+import { summarizeSelectionForNotes } from '@/lib/smallModel';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -518,7 +519,9 @@ export const TextSelectionMenu: React.FC = ({ containerR
try {
setIsAddingToNotes(true);
- const noteText = selectedTextMarkdown || selectedText;
+ // Long selections are distilled into a compact note by the small model;
+ // short ones (and any generation failure) go in verbatim.
+ const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId);
const projectData = await getProjectNotesAndTodos(currentProjectRef);
const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText);
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts
index 60ca2686..5f1df0dc 100644
--- a/packages/ui/src/components/icon/sprite.ts
+++ b/packages/ui/src/components/icon/sprite.ts
@@ -160,7 +160,6 @@ export const iconSpriteData = {
"menu-fold-2": ``,
"menu-search": ``,
"mic": ``,
- "mic-off": ``,
"more-2-fill": ``,
"more-2": ``,
"more": ``,
@@ -168,6 +167,7 @@ export const iconSpriteData = {
"node-tree": ``,
"notification-3": ``,
"palette": ``,
+ "pencil-ai-2": ``,
"pencil-ai": ``,
"pencil": ``,
"picture-in-picture-2": ``,
@@ -211,7 +211,6 @@ export const iconSpriteData = {
"star-fill": ``,
"star": ``,
"sticky-note": ``,
- "stop-circle": ``,
"stop": ``,
"subtract": ``,
"survey": ``,
@@ -228,7 +227,6 @@ export const iconSpriteData = {
"unpin": ``,
"user-3": ``,
"user": ``,
- "voice-recognition": ``,
"volume-up": ``,
"window": ``,
} as const satisfies Record;
diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
index 6f86fee3..a5013bea 100644
--- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
@@ -39,6 +39,9 @@ export const DefaultsSettings: React.FC = () => {
const [defaultModel, setDefaultModel] = React.useState();
const [defaultVariant, setDefaultVariant] = React.useState();
const [defaultAgent, setDefaultAgent] = React.useState();
+ const [smallModelUseDefault, setSmallModelUseDefault] = React.useState(true);
+ const [smallModelOverride, setSmallModelOverride] = React.useState();
+ const [smallModelProviders, setSmallModelProviders] = React.useState();
const [isLoading, setIsLoading] = React.useState(true);
const parsedModel = React.useMemo(() => getDisplayModel(defaultModel), [defaultModel]);
@@ -50,6 +53,8 @@ export const DefaultsSettings: React.FC = () => {
defaultModel?: string;
defaultVariant?: string;
defaultAgent?: string;
+ smallModelUseDefault?: boolean;
+ smallModelOverride?: string;
} | null = null;
if (!data) {
@@ -59,13 +64,16 @@ export const DefaultsSettings: React.FC = () => {
const result = await runtimeSettings.load();
const settings = result?.settings;
if (settings) {
+ const raw = settings as Record;
data = {
defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined,
defaultVariant:
- typeof (settings as Record).defaultVariant === 'string'
- ? ((settings as Record).defaultVariant as string)
+ typeof raw.defaultVariant === 'string'
+ ? (raw.defaultVariant as string)
: undefined,
defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined,
+ smallModelUseDefault: typeof raw.smallModelUseDefault === 'boolean' ? raw.smallModelUseDefault : undefined,
+ smallModelOverride: typeof raw.smallModelOverride === 'string' ? raw.smallModelOverride : undefined,
};
}
} catch {
@@ -101,6 +109,10 @@ export const DefaultsSettings: React.FC = () => {
if (model !== undefined) setDefaultModel(model);
if (variant !== undefined) setDefaultVariant(variant);
if (agent !== undefined) setDefaultAgent(agent);
+ if (typeof data.smallModelUseDefault === 'boolean') setSmallModelUseDefault(data.smallModelUseDefault);
+ if (typeof data.smallModelOverride === 'string' && data.smallModelOverride.trim()) {
+ setSmallModelOverride(data.smallModelOverride.trim());
+ }
}
} catch (error) {
console.warn('Failed to load defaults settings:', error);
@@ -189,6 +201,53 @@ export const DefaultsSettings: React.FC = () => {
[setAgent, setSettingsDefaultAgent]
);
+ const handleSmallModelUseDefaultChange = React.useCallback(
+ async (useDefault: boolean) => {
+ setSmallModelUseDefault(useDefault);
+ try {
+ await updateDesktopSettings({ smallModelUseDefault: useDefault });
+ } catch (error) {
+ console.warn('Failed to save small model preference:', error);
+ }
+ },
+ []
+ );
+
+ const handleSmallModelOverrideChange = React.useCallback(
+ async (providerId: string, modelId: string) => {
+ const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
+ setSmallModelOverride(newValue);
+ try {
+ await updateDesktopSettings({ smallModelOverride: newValue ?? '' });
+ } catch (error) {
+ console.warn('Failed to save small model override:', error);
+ }
+ },
+ []
+ );
+
+ const parsedSmallModel = React.useMemo(() => getDisplayModel(smallModelOverride), [smallModelOverride]);
+
+ React.useEffect(() => {
+ if (smallModelUseDefault || smallModelProviders !== undefined) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ const response = await runtimeFetch('/api/small-model', { method: 'GET', headers: { Accept: 'application/json' } });
+ if (!response.ok) return;
+ const payload = await response.json().catch(() => null) as { authenticatedProviders?: unknown } | null;
+ if (!cancelled && Array.isArray(payload?.authenticatedProviders)) {
+ setSmallModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string'));
+ }
+ } catch {
+ // leave undefined — picker falls back to showing all providers
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [smallModelUseDefault, smallModelProviders]);
+
const availableVariants = React.useMemo(() => {
if (!parsedModel.providerId || !parsedModel.modelId) return [];
const provider = providers.find((p) => p.id === parsedModel.providerId);
@@ -305,6 +364,56 @@ export const DefaultsSettings: React.FC = () => {
+
+
+
+
{t('settings.openchamber.defaults.smallModel.title')}
+
+
+
+
+
+ {t('settings.openchamber.defaults.smallModel.description')}
+
+
+ void handleSmallModelUseDefaultChange(!smallModelUseDefault)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ void handleSmallModelUseDefaultChange(!smallModelUseDefault);
+ }
+ }}
+ >
+ void handleSmallModelUseDefaultChange(checked)}
+ ariaLabel={t('settings.openchamber.defaults.smallModel.useDefaultAria')}
+ />
+ {t('settings.openchamber.defaults.smallModel.useDefault')}
+
+
+ {!smallModelUseDefault ? (
+
+
+ {t('settings.openchamber.defaults.smallModel.overrideModel')}
+
+
+
+
+
+ ) : null}
+
);
};
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
index d149ca43..8bfee71f 100644
--- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
@@ -144,7 +144,7 @@ const VisualSectionContent: React.FC = () => {
// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Follow-up behavior, Persist draft
const ChatSectionContent: React.FC = () => {
- return ;
+ return ;
};
// Sessions section: Default model & agent, Session retention
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
index 94156dc1..a9cf147b 100644
--- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
@@ -245,7 +245,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
-type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
+type VisibleSetting = 'sessionAssist' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -259,6 +259,8 @@ export const OpenChamberVisualSettings: React.FC
const { browserTab } = usePwaDetection();
const directoryShowHidden = useDirectoryShowHidden();
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
+ const sessionAssistEnabled = useUIStore(state => state.sessionAssistEnabled);
+ const setSessionAssistEnabled = useUIStore(state => state.setSessionAssistEnabled);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
@@ -1775,8 +1777,31 @@ export const OpenChamberVisualSettings: React.FC
)}
- {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
+ {(shouldShow('sessionAssist') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
+ {shouldShow('sessionAssist') && (
+ setSessionAssistEnabled(!sessionAssistEnabled)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setSessionAssistEnabled(!sessionAssistEnabled);
+ }
+ }}
+ >
+
+ {t('settings.openchamber.visual.field.sessionAssist')}
+
+ )}
{shouldShow('reasoning') && (
{
>
{t('settings.voice.page.field.ttsInputModeRaw')}
+
diff --git a/packages/ui/src/hooks/useMessageTTS.ts b/packages/ui/src/hooks/useMessageTTS.ts
index ebe83309..b6b4037d 100644
--- a/packages/ui/src/hooks/useMessageTTS.ts
+++ b/packages/ui/src/hooks/useMessageTTS.ts
@@ -12,6 +12,36 @@ import { useSayTTS } from './useSayTTS';
import { useLocalTTS } from './useLocalTTS';
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
import { sanitizeForTTS } from '@/lib/voice/summarize';
+import { runtimeFetch } from '@/lib/runtime-fetch';
+
+// Below this length the reply is comfortable to listen to as-is; summarizing
+// would only add latency.
+const TTS_SUMMARIZE_MIN_CHARS = 600;
+
+const SUMMARIZE_SYSTEM_PROMPT = 'Summarize the assistant reply for text-to-speech listening. Reply with 2-4 sentences of plain spoken prose in the same language as the reply. No markdown, no lists, no code — mention code changes briefly in words instead.';
+
+async function summarizeForSpeech(
+ text: string,
+ preferred: { providerID?: string; modelID?: string },
+): Promise {
+ try {
+ const response = await runtimeFetch('/api/small-model/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ prompt: text,
+ system: SUMMARIZE_SYSTEM_PROMPT,
+ ...(preferred.providerID ? { preferredProviderID: preferred.providerID } : {}),
+ ...(preferred.modelID ? { preferredModelID: preferred.modelID } : {}),
+ }),
+ });
+ if (!response.ok) return null;
+ const payload = await response.json().catch(() => null) as { text?: unknown } | null;
+ return typeof payload?.text === 'string' && payload.text.trim() ? payload.text.trim() : null;
+ } catch {
+ return null;
+ }
+}
export interface UseMessageTTSReturn {
/** Whether TTS is currently playing for this message */
@@ -69,9 +99,24 @@ export function useMessageTTS(): UseMessageTTSReturn {
setIsPlaying(true);
try {
+ // Summarized mode: replace long replies with a short spoken-prose
+ // summary from the small model; fall back to the sanitized
+ // original when summarization is unavailable.
+ let sourceText = text;
+ if (ttsInputMode === 'summarized' && text.length >= TTS_SUMMARIZE_MIN_CHARS) {
+ const { currentProviderId, currentModelId } = useConfigStore.getState();
+ const summary = await summarizeForSpeech(text, {
+ providerID: currentProviderId || undefined,
+ modelID: currentModelId || undefined,
+ });
+ if (summary) {
+ sourceText = summary;
+ }
+ }
+
const shouldUseRaw = ttsInputMode === 'raw' && isServerProvider;
- const sanitizedText = sanitizeForTTS(text);
- const textToSpeak = shouldUseRaw ? text : sanitizedText;
+ const sanitizedText = sanitizeForTTS(sourceText);
+ const textToSpeak = shouldUseRaw ? sourceText : sanitizedText;
if (isServerProvider && isServerTTSAvailable) {
const voice = voiceProvider === 'openai-compatible' ? openaiCompatibleVoice : openaiVoice;
diff --git a/packages/ui/src/hooks/useSessionAssist.ts b/packages/ui/src/hooks/useSessionAssist.ts
new file mode 100644
index 00000000..889c0b42
--- /dev/null
+++ b/packages/ui/src/hooks/useSessionAssist.ts
@@ -0,0 +1,94 @@
+import React from 'react';
+import { useDirectoryStore, useSession, useSessionStatus } from '@/sync/sync-context';
+import { getSessionAssist, type SessionAssistPayload } from '@/lib/sessionAssistMetadata';
+
+// How long the chat must sit untouched before the recap becomes visible.
+// The suggestion has no such delay — it shows as soon as it arrives.
+export const RECAP_VISIBILITY_DELAY_MS = 5 * 60 * 1000;
+
+interface LastMessageSnapshot {
+ id: string;
+ role: string;
+ timestamp: number;
+}
+
+/** Narrow subscription to the last message of a session (id/role/time only). */
+function useLastMessageSnapshot(sessionId: string): LastMessageSnapshot | null {
+ const store = useDirectoryStore();
+ const cacheRef = React.useRef(null);
+
+ const getSnapshot = React.useCallback((): LastMessageSnapshot | null => {
+ if (!sessionId) return null;
+ const messages = store.getState().message[sessionId];
+ const last = messages && messages.length > 0 ? messages[messages.length - 1] : null;
+ const info = last as { id?: string; role?: string; time?: { completed?: number; created?: number } } | null;
+ if (!info?.id) {
+ cacheRef.current = null;
+ return null;
+ }
+ const next: LastMessageSnapshot = {
+ id: info.id,
+ role: typeof info.role === 'string' ? info.role : '',
+ timestamp: info.time?.completed ?? info.time?.created ?? 0,
+ };
+ const cached = cacheRef.current;
+ if (cached && cached.id === next.id && cached.role === next.role && cached.timestamp === next.timestamp) {
+ return cached;
+ }
+ cacheRef.current = next;
+ return next;
+ }, [sessionId, store]);
+
+ const subscribe = React.useCallback((notify: () => void) => {
+ if (!sessionId) return () => undefined;
+ return store.subscribe(notify);
+ }, [sessionId, store]);
+
+ return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+}
+
+export interface SessionAssistState {
+ /** Valid (fresh) assist payload, or null. */
+ assist: SessionAssistPayload | null;
+ /** Recap text, only when the 5-minute quiet window has elapsed. */
+ visibleRecap: string | null;
+ /** Suggestion text — fresh payload, session idle; caller still gates on input emptiness. */
+ suggestion: string | null;
+}
+
+export function useSessionAssistState(sessionId: string): SessionAssistState {
+ const session = useSession(sessionId);
+ const status = useSessionStatus(sessionId);
+ const lastMessage = useLastMessageSnapshot(sessionId);
+
+ const isIdle = !status || status.type === 'idle';
+ const payload = getSessionAssist(session);
+
+ // Fresh = the payload's target message is still the session's last message.
+ const assist = payload
+ && lastMessage
+ && lastMessage.role === 'assistant'
+ && lastMessage.id === payload.forMessageID
+ && isIdle
+ ? payload
+ : null;
+
+ // Recap waits out the quiet window; re-render once when the boundary passes.
+ const lastTimestamp = lastMessage?.timestamp ?? 0;
+ const [, forceTick] = React.useReducer((tick: number) => tick + 1, 0);
+ const quietElapsed = assist ? Date.now() - lastTimestamp >= RECAP_VISIBILITY_DELAY_MS : false;
+
+ React.useEffect(() => {
+ if (!assist || quietElapsed || !lastTimestamp) return undefined;
+ const remaining = RECAP_VISIBILITY_DELAY_MS - (Date.now() - lastTimestamp);
+ if (remaining <= 0) return undefined;
+ const timer = setTimeout(forceTick, remaining + 250);
+ return () => clearTimeout(timer);
+ }, [assist, quietElapsed, lastTimestamp]);
+
+ return {
+ assist,
+ visibleRecap: assist && assist.recap && quietElapsed ? assist.recap : null,
+ suggestion: assist && assist.suggestion ? assist.suggestion : null,
+ };
+}
diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts
index 8e4146ab..ab083612 100644
--- a/packages/ui/src/lib/appearanceAutoSave.ts
+++ b/packages/ui/src/lib/appearanceAutoSave.ts
@@ -6,6 +6,7 @@ import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
type AppearanceSlice = {
showReasoningTraces: boolean;
+ sessionAssistEnabled: boolean;
collapsibleThinkingBlocks: boolean;
showDeletionDialog: boolean;
nativeNotificationsEnabled: boolean;
@@ -50,6 +51,7 @@ export const startAppearanceAutoSave = (): void => {
let previous: AppearanceSlice = {
showReasoningTraces: useUIStore.getState().showReasoningTraces,
+ sessionAssistEnabled: useUIStore.getState().sessionAssistEnabled,
collapsibleThinkingBlocks: useUIStore.getState().collapsibleThinkingBlocks,
showDeletionDialog: useUIStore.getState().showDeletionDialog,
nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled,
@@ -101,6 +103,7 @@ export const startAppearanceAutoSave = (): void => {
useUIStore.subscribe((state) => {
const current: AppearanceSlice = {
showReasoningTraces: state.showReasoningTraces,
+ sessionAssistEnabled: state.sessionAssistEnabled,
collapsibleThinkingBlocks: state.collapsibleThinkingBlocks,
showDeletionDialog: state.showDeletionDialog,
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
@@ -134,6 +137,9 @@ export const startAppearanceAutoSave = (): void => {
if (current.showReasoningTraces !== previous.showReasoningTraces) {
diff.showReasoningTraces = current.showReasoningTraces;
}
+ if (current.sessionAssistEnabled !== previous.sessionAssistEnabled) {
+ diff.sessionAssistEnabled = current.sessionAssistEnabled;
+ }
if (current.collapsibleThinkingBlocks !== previous.collapsibleThinkingBlocks) {
diff.collapsibleThinkingBlocks = current.collapsibleThinkingBlocks;
}
diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts
index 8c3329fd..fd2de600 100644
--- a/packages/ui/src/lib/desktop.ts
+++ b/packages/ui/src/lib/desktop.ts
@@ -113,6 +113,9 @@ export type DesktopSettings = {
defaultModel?: string; // format: "provider/model"
defaultVariant?: string;
defaultAgent?: string;
+ smallModelUseDefault?: boolean;
+ sessionAssistEnabled?: boolean;
+ smallModelOverride?: string; // format: "provider/model"
defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
openInAppId?: string;
autoCreateWorktree?: boolean;
diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts
index a177b7c3..bfa787de 100644
--- a/packages/ui/src/lib/gitApi.ts
+++ b/packages/ui/src/lib/gitApi.ts
@@ -2,6 +2,7 @@
import * as gitHttp from './gitApiHttp';
import { opencodeClient } from './opencode/client';
import { renderMagicPrompt } from './magicPrompts';
+import { runtimeFetch } from './runtime-fetch';
import { materializeOpenDraftSession, useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -210,6 +211,71 @@ export async function deleteRemoteBranch(directory: string, payload: import('./a
return gitHttp.deleteRemoteBranch(directory, payload);
}
+const COMMIT_DIFF_FILE_LIMIT = 30;
+const COMMIT_DIFF_TOTAL_CHAR_LIMIT = 120_000;
+
+const collectSelectedFileDiffs = async (directory: string, files: string[]): Promise => {
+ const limited = files.slice(0, COMMIT_DIFF_FILE_LIMIT);
+ const chunks = await Promise.all(limited.map(async (path) => {
+ try {
+ const [staged, unstaged] = await Promise.all([
+ gitHttp.getGitDiff(directory, { path, staged: true }).catch(() => null),
+ gitHttp.getGitDiff(directory, { path, staged: false }).catch(() => null),
+ ]);
+ const text = [staged?.diff, unstaged?.diff]
+ .filter((diff): diff is string => typeof diff === 'string' && diff.trim().length > 0)
+ .join('\n');
+ return text ? text : `--- ${path} (no textual diff available)`;
+ } catch {
+ return `--- ${path} (diff unavailable)`;
+ }
+ }));
+
+ let total = '';
+ for (const chunk of chunks) {
+ if (total.length + chunk.length > COMMIT_DIFF_TOTAL_CHAR_LIMIT) {
+ total += '\n[remaining diffs truncated]';
+ break;
+ }
+ total += (total ? '\n\n' : '') + chunk;
+ }
+ if (files.length > limited.length) {
+ total += `\n[${files.length - limited.length} more selected files omitted]`;
+ }
+ return total;
+};
+
+const parseCommitStructured = (structured: Record | null): { subject: string; highlights: string[] } => {
+ const subject = typeof structured?.subject === 'string' ? structured.subject.trim() : '';
+ const highlights = Array.isArray(structured?.highlights)
+ ? structured.highlights.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean).slice(0, 3)
+ : [];
+ if (!subject) {
+ throw new Error('Structured output missing subject');
+ }
+ return { subject, highlights };
+};
+
+// Legacy transport: run the structured generation inside the active chat
+// session. Kept as the fallback for setups with no direct provider login
+// (vanilla installs on OpenCode's free models), where the small-model
+// endpoint has nothing to call but the session itself still works.
+async function generateCommitMessageViaSession(
+ directory: string,
+ visiblePrompt: string,
+ hiddenPrompt: string,
+): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
+ const generationSession = await resolveGenerationSessionContext();
+ const structured = await runStructuredGenerationInActiveSession({
+ directory,
+ visiblePrompt,
+ hiddenPrompt,
+ generationSession,
+ kind: 'commit',
+ });
+ return { message: parseCommitStructured(structured) };
+}
+
export async function generateCommitMessage(
directory: string,
files: string[],
@@ -217,17 +283,12 @@ export async function generateCommitMessage(
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
const startedAt = Date.now();
void options;
- const generationSession = await resolveGenerationSessionContext();
console.info('[git-generation][browser] request', {
- transport: 'session',
+ transport: 'small-model',
kind: 'commit',
directory,
selectedFiles: files.length,
- sessionId: generationSession.sessionId,
- providerId: generationSession.providerID,
- modelId: generationSession.modelID,
- agent: generationSession.agent,
});
const visiblePrompt = await renderMagicPrompt('git.commit.generate.visible');
@@ -236,26 +297,44 @@ export async function generateCommitMessage(
});
try {
- const structured = await runStructuredGenerationInActiveSession({
- directory,
- visiblePrompt,
- hiddenPrompt,
- generationSession,
- kind: 'commit',
+ const diffs = await collectSelectedFileDiffs(directory, files);
+ const { currentProviderId, currentModelId } = useConfigStore.getState();
+ const response = await runtimeFetch('/api/small-model/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ system: visiblePrompt,
+ prompt: `${hiddenPrompt}\n\nDiffs of the selected files:\n${diffs}`,
+ directory,
+ ...(currentProviderId ? { preferredProviderID: currentProviderId } : {}),
+ ...(currentModelId ? { preferredModelID: currentModelId } : {}),
+ }),
});
- const subject = typeof structured.subject === 'string' ? structured.subject.trim() : '';
- const highlights = Array.isArray(structured.highlights)
- ? structured.highlights.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean).slice(0, 3)
- : [];
-
- if (!subject) {
- throw new Error('Structured output missing subject');
+ if (response.status === 404) {
+ // No authenticated provider has a small model — fall back to the
+ // session transport so free-model-only setups keep a working button.
+ console.info('[git-generation][browser] small model unavailable, falling back to session transport');
+ const result = await generateCommitMessageViaSession(directory, visiblePrompt, hiddenPrompt);
+ console.info('[git-generation][browser] success', {
+ transport: 'session-fallback',
+ kind: 'commit',
+ elapsedMs: Date.now() - startedAt,
+ subjectLength: result.message.subject.length,
+ highlightsCount: result.message.highlights.length,
+ });
+ return result;
}
- const result = { message: { subject, highlights } };
+ const payload = await response.json().catch(() => null) as { text?: unknown; error?: unknown } | null;
+ if (!response.ok || typeof payload?.text !== 'string') {
+ const message = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`;
+ throw new Error(message);
+ }
+
+ const result = { message: parseCommitStructured(extractJsonObject(payload.text)) };
console.info('[git-generation][browser] success', {
- transport: 'session',
+ transport: 'small-model',
kind: 'commit',
elapsedMs: Date.now() - startedAt,
subjectLength: result.message.subject.length,
@@ -264,7 +343,7 @@ export async function generateCommitMessage(
return result;
} catch (error) {
console.error('[git-generation][browser] failed', {
- transport: 'session',
+ transport: 'small-model',
kind: 'commit',
elapsedMs: Date.now() - startedAt,
message: error instanceof Error ? error.message : String(error),
@@ -279,18 +358,19 @@ export async function generatePullRequestDescription(
payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
): Promise {
const startedAt = Date.now();
- const generationSession = await resolveGenerationSessionContext();
const commitLog = await getGitLog(directory, {
from: payload.base,
to: payload.head,
maxCount: 50,
});
+ const COMMIT_BODY_CHAR_LIMIT = 2_000;
const commits = (Array.isArray(commitLog?.all) ? commitLog.all : [])
.filter((entry) => typeof entry?.hash === 'string' && entry.hash.length > 0)
.map((entry) => ({
hash: entry.hash,
subject: typeof entry.message === 'string' ? entry.message.trim() : '',
+ body: typeof entry.body === 'string' ? entry.body.trim().slice(0, COMMIT_BODY_CHAR_LIMIT) : '',
}));
if (commits.length === 0) {
@@ -317,13 +397,9 @@ export async function generatePullRequestDescription(
const changedFiles = Array.from(filesSet).sort().slice(0, 300);
console.info('[git-generation][browser] request', {
- transport: 'session',
+ transport: 'small-model',
kind: 'pr',
directory,
- sessionId: generationSession.sessionId,
- providerId: generationSession.providerID,
- modelId: generationSession.modelID,
- agent: generationSession.agent,
base: payload.base,
head: payload.head,
commits: commits.length,
@@ -334,26 +410,67 @@ export async function generatePullRequestDescription(
const hiddenPrompt = await renderMagicPrompt('git.pr.generate.instructions', {
base_branch: payload.base,
head_branch: payload.head,
- commits: commits.map((commit) => `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`).join('\n'),
+ commits: commits.map((commit) => {
+ const line = `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`;
+ if (!commit.body) return line;
+ const indentedBody = commit.body.split('\n').map((bodyLine) => ` ${bodyLine}`).join('\n');
+ return `${line}\n${indentedBody}`;
+ }).join('\n'),
changed_files: changedFiles.length > 0 ? changedFiles.map((file) => `- ${file}`).join('\n') : '- none detected',
additional_context_block: payload.context?.trim() ? `\nAdditional context:\n${payload.context.trim()}` : '',
});
+ const parsePrStructured = (structured: Record | null) => ({
+ title: typeof structured?.title === 'string' ? structured.title.trim() : '',
+ body: typeof structured?.body === 'string' ? structured.body.trim() : '',
+ });
+
try {
- const structured = await runStructuredGenerationInActiveSession({
- directory,
- visiblePrompt,
- hiddenPrompt,
- generationSession,
- kind: 'pr',
+ const { currentProviderId, currentModelId } = useConfigStore.getState();
+ const response = await runtimeFetch('/api/small-model/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ system: visiblePrompt,
+ prompt: hiddenPrompt,
+ directory,
+ ...(currentProviderId ? { preferredProviderID: currentProviderId } : {}),
+ ...(currentModelId ? { preferredModelID: currentModelId } : {}),
+ }),
});
- const result = {
- title: typeof structured.title === 'string' ? structured.title.trim() : '',
- body: typeof structured.body === 'string' ? structured.body.trim() : '',
- };
+ if (response.status === 404) {
+ // No authenticated provider has a small model — fall back to the
+ // session transport so free-model-only setups keep working.
+ console.info('[git-generation][browser] small model unavailable, falling back to session transport');
+ const generationSession = await resolveGenerationSessionContext();
+ const structured = await runStructuredGenerationInActiveSession({
+ directory,
+ visiblePrompt,
+ hiddenPrompt,
+ generationSession,
+ kind: 'pr',
+ });
+ const result = parsePrStructured(structured);
+ console.info('[git-generation][browser] success', {
+ transport: 'session-fallback',
+ kind: 'pr',
+ elapsedMs: Date.now() - startedAt,
+ titleLength: result.title.length,
+ bodyLength: result.body.length,
+ });
+ return result;
+ }
+
+ const payload = await response.json().catch(() => null) as { text?: unknown; error?: unknown } | null;
+ if (!response.ok || typeof payload?.text !== 'string') {
+ const message = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`;
+ throw new Error(message);
+ }
+
+ const result = parsePrStructured(extractJsonObject(payload.text));
console.info('[git-generation][browser] success', {
- transport: 'session',
+ transport: 'small-model',
kind: 'pr',
elapsedMs: Date.now() - startedAt,
titleLength: result.title.length,
@@ -362,7 +479,7 @@ export async function generatePullRequestDescription(
return result;
} catch (error) {
console.error('[git-generation][browser] failed', {
- transport: 'session',
+ transport: 'small-model',
kind: 'pr',
elapsedMs: Date.now() - startedAt,
message: error instanceof Error ? error.message : String(error),
diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts
index c659fac1..9deabdc1 100644
--- a/packages/ui/src/lib/i18n/messages/en.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/en.settings.ts
@@ -1397,6 +1397,11 @@ export const settingsDict = {
'settings.openchamber.defaults.field.defaultAgent': 'Default Agent',
'settings.openchamber.defaults.field.showDeletionDialogAria': 'Show deletion dialog',
'settings.openchamber.defaults.field.showDeletionDialog': 'Show Deletion Dialog',
+ 'settings.openchamber.defaults.smallModel.title': 'Small Model',
+ 'settings.openchamber.defaults.smallModel.description': 'A cheap model for quick utility tasks like short recaps and summaries.',
+ 'settings.openchamber.defaults.smallModel.useDefault': 'Use default small model',
+ 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Use default small model',
+ 'settings.openchamber.defaults.smallModel.overrideModel': 'Override model',
'settings.openchamber.defaults.field.openFilesPreviewAria': 'Open files in preview mode',
'settings.openchamber.defaults.field.openFilesPreview': 'Open files in preview mode',
'settings.openchamber.defaults.option.default': 'Default',
@@ -1601,6 +1606,7 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputMode': 'TTS Input Mode',
'settings.voice.page.field.ttsInputModeSanitized': 'Sanitized',
'settings.voice.page.field.ttsInputModeRaw': 'Raw Markdown',
+ 'settings.voice.page.field.ttsInputModeSummarized': 'summarized',
'settings.openchamber.visual.section.colorMode': 'Color Mode',
'settings.openchamber.visual.section.mobileLayout': 'Mobile Layout',
'settings.openchamber.visual.option.mobileLayout.default': 'Old',
@@ -1684,6 +1690,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.userMessageRenderingAria': 'User message rendering: {option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid rendering: {option}',
'settings.openchamber.visual.field.diffLayoutAria': 'Diff layout: {option}',
+ 'settings.openchamber.visual.field.sessionAssist': 'Generate Session Recap & Suggestion',
+ 'settings.openchamber.visual.field.sessionAssistAria': 'Generate a recap and a suggested reply after the agent finishes',
'settings.openchamber.visual.field.showReasoningTracesAria': 'Show reasoning traces',
'settings.openchamber.visual.field.showReasoningTraces': 'Show Reasoning Traces',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Enable collapsible reasoning blocks',
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index 01abb9d4..ecc04004 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -1387,6 +1387,10 @@ export const dict = {
'header.actions.toggleChangesPanelAria': 'Toggle changes panel',
'header.actions.planWithShortcut': 'Plan ({shortcut})',
'header.actions.terminalPanelWithShortcut': 'Terminal panel ({shortcut})',
+ 'chat.recap.aria': 'Session recap',
+ 'chat.recap.label': 'Recap:',
+ 'chat.suggestion.applyAria': 'Use suggested message',
+ 'chat.suggestion.dismissAria': 'Dismiss suggestion',
'header.actions.toggleTerminalPanelAria': 'Toggle terminal panel',
'terminalView.stream.processExitedMessage': '\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n',
'terminalView.stream.processExitedWithCode': ' with code {exitCode}',
diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts
index 94ae6b59..ad6cb599 100644
--- a/packages/ui/src/lib/i18n/messages/es.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/es.settings.ts
@@ -1363,6 +1363,11 @@ export const settingsDict = {
"settings.openchamber.defaults.field.thinkingPlaceholder": "Pensando",
"settings.openchamber.defaults.field.defaultAgent": "Agente por defecto",
"settings.openchamber.defaults.field.showDeletionDialogAria": "Mostrar diálogo de eliminación",
+ "settings.openchamber.defaults.smallModel.title": "Modelo pequeño",
+ "settings.openchamber.defaults.smallModel.description": "Un modelo económico para tareas utilitarias rápidas, como recapitulaciones y resúmenes breves.",
+ "settings.openchamber.defaults.smallModel.useDefault": "Usar el modelo pequeño predeterminado",
+ "settings.openchamber.defaults.smallModel.useDefaultAria": "Usar el modelo pequeño predeterminado",
+ "settings.openchamber.defaults.smallModel.overrideModel": "Modelo de anulación",
"settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación",
"settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir archivos en modo vista previa",
"settings.openchamber.defaults.field.openFilesPreview": "Abrir archivos en modo vista previa",
@@ -1568,6 +1573,7 @@ export const settingsDict = {
"settings.voice.page.field.ttsInputMode": "Modo de entrada TTS",
"settings.voice.page.field.ttsInputModeSanitized": "Texto limpio",
"settings.voice.page.field.ttsInputModeRaw": "Markdown sin procesar",
+ "settings.voice.page.field.ttsInputModeSummarized": "resumido",
"settings.openchamber.visual.section.colorMode": "Modo de color",
"settings.openchamber.visual.section.mobileLayout": "Diseño móvil",
"settings.openchamber.visual.option.mobileLayout.default": "Anterior",
@@ -1651,6 +1657,8 @@ export const settingsDict = {
"settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensajes del usuario: {option}",
"settings.openchamber.visual.field.mermaidRenderingAria": "Renderizado de Mermaid: {option}",
"settings.openchamber.visual.field.diffLayoutAria": "Diseño de comparación: {option}",
+ "settings.openchamber.visual.field.sessionAssist": "Generar resumen y sugerencia de sesión",
+ "settings.openchamber.visual.field.sessionAssistAria": "Generar un resumen y una respuesta sugerida cuando el agente termina",
"settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de razonamiento",
"settings.openchamber.visual.field.showReasoningTraces": "Mostrar trazas de razonamiento",
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar bloques de razonamiento colapsables",
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index fee8511f..5c412588 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -1365,6 +1365,10 @@ export const dict: Record = {
"header.actions.toggleChangesPanelAria": "Alternar panel de cambios",
"header.actions.planWithShortcut": "Plan ({shortcut})",
"header.actions.terminalPanelWithShortcut": "Panel de terminal ({shortcut})",
+ "chat.recap.aria": "Resumen de la sesión",
+ "chat.recap.label": "Resumen:",
+ "chat.suggestion.applyAria": "Usar mensaje sugerido",
+ "chat.suggestion.dismissAria": "Descartar sugerencia",
"header.actions.toggleTerminalPanelAria": "Mostrar u ocultar panel de terminal",
"terminalView.stream.processExitedMessage": "\r\n[Proceso terminado{exitCodeSegment}{signalSegment}]\r\n",
"terminalView.stream.processExitedWithCode": " con código {exitCode}",
diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts
index b3cb5487..c3722240 100644
--- a/packages/ui/src/lib/i18n/messages/fr.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts
@@ -1346,6 +1346,11 @@ export const settingsDict = {
'settings.openchamber.defaults.field.thinkingPlaceholder': 'Pensée',
'settings.openchamber.defaults.field.defaultAgent': 'Agent par défaut',
'settings.openchamber.defaults.field.showDeletionDialogAria': 'Afficher la boîte de dialogue de suppression',
+ 'settings.openchamber.defaults.smallModel.title': 'Petit modèle',
+ 'settings.openchamber.defaults.smallModel.description': 'Un modèle économique pour les tâches utilitaires rapides, comme les récapitulatifs et résumés courts.',
+ 'settings.openchamber.defaults.smallModel.useDefault': 'Utiliser le petit modèle par défaut',
+ 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Utiliser le petit modèle par défaut',
+ 'settings.openchamber.defaults.smallModel.overrideModel': 'Modèle de remplacement',
'settings.openchamber.defaults.field.showDeletionDialog': 'Afficher la boîte de dialogue de suppression',
'settings.openchamber.defaults.field.openFilesPreviewAria': 'Ouvrir les fichiers en mode aperçu',
'settings.openchamber.defaults.field.openFilesPreview': 'Ouvrir les fichiers en mode aperçu',
@@ -1619,6 +1624,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.userMessageRenderingAria': 'Rendu du message utilisateur : {option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Rendu Mermaid : {option}',
'settings.openchamber.visual.field.diffLayoutAria': 'Disposition des différences : {option}',
+ 'settings.openchamber.visual.field.sessionAssist': 'Générer le récapitulatif et la suggestion de session',
+ 'settings.openchamber.visual.field.sessionAssistAria': "Générer un récapitulatif et une réponse suggérée quand l'agent termine",
'settings.openchamber.visual.field.showReasoningTracesAria': 'Afficher les traces de raisonnement',
'settings.openchamber.visual.field.showReasoningTraces': 'Afficher les traces de raisonnement',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Activer les blocs de raisonnement pliables',
@@ -1785,6 +1792,7 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputMode': 'Mode d’entrée TTS',
'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé',
'settings.voice.page.field.ttsInputModeRaw': 'Markdown brut',
+ 'settings.voice.page.field.ttsInputModeSummarized': 'résumé',
'settings.openchamber.visual.section.mobileLayout': 'Mise en page mobile',
'settings.openchamber.visual.option.mobileLayout.default': 'Ancienne',
'settings.openchamber.visual.option.mobileLayout.new': 'Nouvelle',
diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts
index 712b9c5a..efd36d71 100644
--- a/packages/ui/src/lib/i18n/messages/fr.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.ts
@@ -1214,6 +1214,10 @@ export const dict = {
"header.actions.toggleChangesPanelAria": "Basculer le panneau des changements",
'header.actions.planWithShortcut': 'Forfait ({shortcut})',
'header.actions.terminalPanelWithShortcut': 'Panneau à bornes ({shortcut})',
+ 'chat.recap.aria': 'Récapitulatif de la session',
+ 'chat.recap.label': 'Récap :',
+ 'chat.suggestion.applyAria': 'Utiliser le message suggéré',
+ 'chat.suggestion.dismissAria': 'Ignorer la suggestion',
'header.actions.toggleTerminalPanelAria': 'Basculer le panneau à bornes',
'terminalView.stream.processExitedMessage': '[Processus terminé{exitCodeSegment}{signalSegment}]',
'terminalView.stream.processExitedWithCode': 'avec le code {exitCode}',
diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts
index 3e7a1417..e4ea9ca1 100644
--- a/packages/ui/src/lib/i18n/messages/ja.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts
@@ -1396,6 +1396,11 @@ export const settingsDict = {
'settings.openchamber.defaults.field.thinkingPlaceholder': '思考',
'settings.openchamber.defaults.field.defaultAgent': 'デフォルト Agent',
'settings.openchamber.defaults.field.showDeletionDialogAria': '削除ダイアログを表示',
+ 'settings.openchamber.defaults.smallModel.title': '小型モデル',
+ 'settings.openchamber.defaults.smallModel.description': '短い要約やまとめなどの軽いユーティリティタスク用の低コストモデルです。',
+ 'settings.openchamber.defaults.smallModel.useDefault': 'デフォルトの小型モデルを使用',
+ 'settings.openchamber.defaults.smallModel.useDefaultAria': 'デフォルトの小型モデルを使用',
+ 'settings.openchamber.defaults.smallModel.overrideModel': '上書きモデル',
'settings.openchamber.defaults.field.showDeletionDialog': '削除ダイアログを表示',
'settings.openchamber.defaults.field.openFilesPreviewAria': 'ファイルをプレビューモードで開く',
'settings.openchamber.defaults.field.openFilesPreview': 'ファイルをプレビューモードで開く',
@@ -1601,6 +1606,7 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputMode': 'TTS 入力モード',
'settings.voice.page.field.ttsInputModeSanitized': 'サニタイズ',
'settings.voice.page.field.ttsInputModeRaw': '生 Markdown',
+ 'settings.voice.page.field.ttsInputModeSummarized': '要約',
'settings.openchamber.visual.section.colorMode': 'カラーモード',
'settings.openchamber.visual.section.mobileLayout': 'モバイルレイアウト',
'settings.openchamber.visual.option.mobileLayout.default': '旧',
@@ -1684,6 +1690,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.userMessageRenderingAria': 'ユーザーメッセージ表示: {option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 表示: {option}',
'settings.openchamber.visual.field.diffLayoutAria': 'Diff レイアウト: {option}',
+ 'settings.openchamber.visual.field.sessionAssist': 'セッションの要約と提案を生成',
+ 'settings.openchamber.visual.field.sessionAssistAria': 'エージェントの完了後に要約と返信の提案を生成します',
'settings.openchamber.visual.field.showReasoningTracesAria': '推論トレースを表示',
'settings.openchamber.visual.field.showReasoningTraces': '推論トレースを表示',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '折りたたみ可能な推論ブロックを有効化',
diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts
index 09520449..b48f61f3 100644
--- a/packages/ui/src/lib/i18n/messages/ja.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.ts
@@ -1383,6 +1383,10 @@ export const dict: Record = {
'header.actions.toggleChangesPanelAria': '変更パネルの切り替え',
'header.actions.planWithShortcut': '計画({shortcut})',
'header.actions.terminalPanelWithShortcut': 'ターミナルパネル({shortcut})',
+ 'chat.recap.aria': 'セッションの要約',
+ 'chat.recap.label': '要約:',
+ 'chat.suggestion.applyAria': '提案されたメッセージを使用',
+ 'chat.suggestion.dismissAria': '提案を閉じる',
'header.actions.toggleTerminalPanelAria': 'ターミナルパネルの切り替え',
'terminalView.stream.processExitedMessage': '\r\n[プロセスが終了しました{exitCodeSegment}{signalSegment}]\r\n',
'terminalView.stream.processExitedWithCode': ' コード {exitCode}',
diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts
index aa9b90be..3a24a088 100644
--- a/packages/ui/src/lib/i18n/messages/ko.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts
@@ -1363,6 +1363,11 @@ export const settingsDict = {
'settings.openchamber.defaults.field.thinkingPlaceholder': 'Thinking',
'settings.openchamber.defaults.field.defaultAgent': '기본 에이전트',
'settings.openchamber.defaults.field.showDeletionDialogAria': '삭제 확인 대화상자 표시',
+ 'settings.openchamber.defaults.smallModel.title': '소형 모델',
+ 'settings.openchamber.defaults.smallModel.description': '짧은 요약 등 가벼운 유틸리티 작업을 위한 저렴한 모델입니다.',
+ 'settings.openchamber.defaults.smallModel.useDefault': '기본 소형 모델 사용',
+ 'settings.openchamber.defaults.smallModel.useDefaultAria': '기본 소형 모델 사용',
+ 'settings.openchamber.defaults.smallModel.overrideModel': '재정의 모델',
'settings.openchamber.defaults.field.showDeletionDialog': '삭제 확인 대화상자 표시',
'settings.openchamber.defaults.field.openFilesPreviewAria': '파일을 미리보기 모드로 열기',
'settings.openchamber.defaults.field.openFilesPreview': '파일을 미리보기 모드로 열기',
@@ -1568,6 +1573,7 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputMode': 'TTS 입력 모드',
'settings.voice.page.field.ttsInputModeSanitized': '정제된 텍스트',
'settings.voice.page.field.ttsInputModeRaw': '원본 Markdown',
+ 'settings.voice.page.field.ttsInputModeSummarized': '요약',
'settings.openchamber.visual.section.colorMode': '색상 모드',
'settings.openchamber.visual.section.mobileLayout': '모바일 레이아웃',
'settings.openchamber.visual.option.mobileLayout.default': '이전',
@@ -1651,6 +1657,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.userMessageRenderingAria': '사용자 메시지 렌더링: {option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 렌더링: {option}',
'settings.openchamber.visual.field.diffLayoutAria': 'Diff 레이아웃: {option}',
+ 'settings.openchamber.visual.field.sessionAssist': '세션 요약 및 제안 생성',
+ 'settings.openchamber.visual.field.sessionAssistAria': '에이전트가 완료되면 요약과 제안 답장을 생성합니다',
'settings.openchamber.visual.field.showReasoningTracesAria': 'Reasoning trace 표시',
'settings.openchamber.visual.field.showReasoningTraces': 'Reasoning Trace 표시',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '접을 수 있는 추론 블록 활성화',
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index cc1d22ab..91b83456 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -1389,6 +1389,10 @@ export const dict: Record = {
"header.actions.toggleChangesPanelAria": "변경 패널 전환",
'header.actions.planWithShortcut': '플랜 ({shortcut})',
'header.actions.terminalPanelWithShortcut': '터미널 패널 ({shortcut})',
+ 'chat.recap.aria': '세션 요약',
+ 'chat.recap.label': '요약:',
+ 'chat.suggestion.applyAria': '제안된 메시지 사용',
+ 'chat.suggestion.dismissAria': '제안 닫기',
'header.actions.toggleTerminalPanelAria': '토글 터미널 패널',
'terminalView.stream.processExitedMessage': '\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n',
'terminalView.stream.processExitedWithCode': ', 종료 코드 {exitCode}',
diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts
index 86c2caed..75a2f201 100644
--- a/packages/ui/src/lib/i18n/messages/pl.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts
@@ -678,6 +678,11 @@ export const settingsDict = {
'settings.openchamber.defaults.field.openFilesPreviewAria': 'Otwieraj pliki w trybie podglądu',
'settings.openchamber.defaults.field.showDeletionDialog': 'Pokaż dialog usuwania',
'settings.openchamber.defaults.field.showDeletionDialogAria': 'Pokaż dialog usuwania',
+ 'settings.openchamber.defaults.smallModel.title': 'Mały model',
+ 'settings.openchamber.defaults.smallModel.description': 'Tani model do szybkich zadań pomocniczych, takich jak krótkie podsumowania.',
+ 'settings.openchamber.defaults.smallModel.useDefault': 'Używaj domyślnego małego modelu',
+ 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Używaj domyślnego małego modelu',
+ 'settings.openchamber.defaults.smallModel.overrideModel': 'Model zastępczy',
'settings.openchamber.defaults.field.thinkingPlaceholder': 'Myślenie',
'settings.openchamber.defaults.option.default': 'Domyślne',
'settings.openchamber.defaults.option.defaultLowercase': 'domyślne',
@@ -969,6 +974,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.showDotfilesAria': 'Pokaż pliki ukryte',
'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Pokaż rozwinięte narzędzia bash',
'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Pokaż rozwinięte narzędzia edycji',
+ 'settings.openchamber.visual.field.sessionAssist': 'Generuj podsumowanie i sugestię sesji',
+ 'settings.openchamber.visual.field.sessionAssistAria': 'Generuj podsumowanie i sugerowaną odpowiedź po zakończeniu pracy agenta',
'settings.openchamber.visual.field.showReasoningTraces': 'Pokaż ślady rozumowania',
'settings.openchamber.visual.field.showReasoningTracesAria': 'Pokaż ślady rozumowania',
'settings.openchamber.visual.field.collapsibleThinkingBlocks': 'Włącz zwijalne bloki rozumowania',
@@ -1819,6 +1826,7 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputMode': 'Tryb wejścia TTS',
'settings.voice.page.field.ttsInputModeSanitized': 'Oczyszczony tekst',
'settings.voice.page.field.ttsInputModeRaw': 'Surowy Markdown',
+ 'settings.voice.page.field.ttsInputModeSummarized': 'streszczony',
'settings.window.description': 'Okno ustawień OpenChamber.',
'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior',
'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior',
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index aa36d918..2ecc05c4 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -2061,6 +2061,10 @@ export const dict: Record = {
'header.actions.planWithShortcut': 'Plan ({shortcut})',
'header.actions.rightSidebarWithShortcut': 'Prawy panel boczny ({shortcut})',
'header.actions.terminalPanelWithShortcut': 'Panel terminala ({shortcut})',
+ 'chat.recap.aria': 'Podsumowanie sesji',
+ 'chat.recap.label': 'Podsumowanie:',
+ 'chat.suggestion.applyAria': 'Użyj sugerowanej wiadomości',
+ 'chat.suggestion.dismissAria': 'Odrzuć sugestię',
'header.actions.toggleRightSidebarAria': 'Przełącz prawy panel boczny',
'header.actions.toggleTerminalPanelAria': 'Przełącz panel terminala',
'header.changes.availableAria': 'Dostępne zmiany',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
index 4236c670..453bfb72 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
@@ -1363,6 +1363,11 @@ export const settingsDict = {
"settings.openchamber.defaults.field.thinkingPlaceholder": "Pensando",
"settings.openchamber.defaults.field.defaultAgent": "Agente por padrão",
"settings.openchamber.defaults.field.showDeletionDialogAria": "Mostrar diálogo de eliminación",
+ "settings.openchamber.defaults.smallModel.title": "Modelo pequeno",
+ "settings.openchamber.defaults.smallModel.description": "Um modelo barato para tarefas utilitárias rápidas, como recapitulações e resumos curtos.",
+ "settings.openchamber.defaults.smallModel.useDefault": "Usar o modelo pequeno padrão",
+ "settings.openchamber.defaults.smallModel.useDefaultAria": "Usar o modelo pequeno padrão",
+ "settings.openchamber.defaults.smallModel.overrideModel": "Modelo de substituição",
"settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación",
"settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir arquivos em modo prévia",
"settings.openchamber.defaults.field.openFilesPreview": "Abrir arquivos em modo prévia",
@@ -1568,6 +1573,7 @@ export const settingsDict = {
"settings.voice.page.field.ttsInputMode": "Modo de entrada TTS",
"settings.voice.page.field.ttsInputModeSanitized": "Texto limpo",
"settings.voice.page.field.ttsInputModeRaw": "Markdown bruto",
+ "settings.voice.page.field.ttsInputModeSummarized": "resumido",
"settings.openchamber.visual.section.colorMode": "Modo de cor",
"settings.openchamber.visual.section.mobileLayout": "Layout móvel",
"settings.openchamber.visual.option.mobileLayout.default": "Anterior",
@@ -1651,6 +1657,8 @@ export const settingsDict = {
"settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensagens do usuário: {option}",
"settings.openchamber.visual.field.mermaidRenderingAria": "Renderizado de Mermaid: {option}",
"settings.openchamber.visual.field.diffLayoutAria": "Layout de comparação: {option}",
+ "settings.openchamber.visual.field.sessionAssist": "Gerar resumo e sugestão da sessão",
+ "settings.openchamber.visual.field.sessionAssistAria": "Gerar um resumo e uma resposta sugerida quando o agente termina",
"settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de raciocínio",
"settings.openchamber.visual.field.showReasoningTraces": "Mostrar rastros de raciocínio",
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar blocos de raciocínio recolhíveis",
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index 4ff09980..00950b28 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -1365,6 +1365,10 @@ export const dict: Record = {
"header.actions.toggleChangesPanelAria": "Alternar painel de alterações",
"header.actions.planWithShortcut": "Plano ({shortcut})",
"header.actions.terminalPanelWithShortcut": "Painel de terminal ({shortcut})",
+ "chat.recap.aria": "Resumo da sessão",
+ "chat.recap.label": "Resumo:",
+ "chat.suggestion.applyAria": "Usar mensagem sugerida",
+ "chat.suggestion.dismissAria": "Dispensar sugestão",
"header.actions.toggleTerminalPanelAria": "Mostrar ou ocultar painel de terminal",
"terminalView.stream.processExitedMessage": "\r\n[Processo encerrado{exitCodeSegment}{signalSegment}]\r\n",
"terminalView.stream.processExitedWithCode": " com código {exitCode}",
diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts
index 84af19b1..7b030311 100644
--- a/packages/ui/src/lib/i18n/messages/uk.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts
@@ -1363,6 +1363,11 @@ export const settingsDict = {
"settings.openchamber.defaults.field.thinkingPlaceholder": "Мислення",
"settings.openchamber.defaults.field.defaultAgent": "Агент за замовчуванням",
"settings.openchamber.defaults.field.showDeletionDialogAria": "Показати діалогове вікно видалення",
+ "settings.openchamber.defaults.smallModel.title": "Мала модель",
+ "settings.openchamber.defaults.smallModel.description": "Дешева модель для швидких службових задач — коротких підсумків і резюме.",
+ "settings.openchamber.defaults.smallModel.useDefault": "Використовувати типову малу модель",
+ "settings.openchamber.defaults.smallModel.useDefaultAria": "Використовувати типову малу модель",
+ "settings.openchamber.defaults.smallModel.overrideModel": "Модель заміни",
"settings.openchamber.defaults.field.showDeletionDialog": "Показати діалогове вікно видалення",
"settings.openchamber.defaults.field.openFilesPreviewAria": "Відкривати файли в режимі попереднього перегляду",
"settings.openchamber.defaults.field.openFilesPreview": "Відкривати файли в режимі попереднього перегляду",
@@ -1568,6 +1573,7 @@ export const settingsDict = {
"settings.voice.page.field.ttsInputMode": "Режим вводу TTS",
"settings.voice.page.field.ttsInputModeSanitized": "Очищений текст",
"settings.voice.page.field.ttsInputModeRaw": "Сирий Markdown",
+ "settings.voice.page.field.ttsInputModeSummarized": "скорочений",
"settings.openchamber.visual.section.colorMode": "Режим теми",
"settings.openchamber.visual.section.mobileLayout": "Мобільний макет",
"settings.openchamber.visual.option.mobileLayout.default": "Попередній",
@@ -1651,6 +1657,8 @@ export const settingsDict = {
"settings.openchamber.visual.field.userMessageRenderingAria": "Відображення повідомлень користувача: {option}",
"settings.openchamber.visual.field.mermaidRenderingAria": "Візуалізація Mermaid: {option}",
"settings.openchamber.visual.field.diffLayoutAria": "Компонування diff: {option}",
+ "settings.openchamber.visual.field.sessionAssist": "Генерувати підсумок і пропозицію для сесії",
+ "settings.openchamber.visual.field.sessionAssistAria": "Генерувати підсумок і запропоновану відповідь після завершення роботи агента",
"settings.openchamber.visual.field.showReasoningTracesAria": "Показати сліди міркувань",
"settings.openchamber.visual.field.showReasoningTraces": "Показати сліди міркувань",
"settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Увімкнути згортальні блоки міркувань",
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index d1d37f84..2553e302 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -1365,6 +1365,10 @@ export const dict: Record = {
"header.actions.toggleChangesPanelAria": "Перемкнути панель змін",
"header.actions.planWithShortcut": "План ({shortcut})",
"header.actions.terminalPanelWithShortcut": "Термінальна панель ({shortcut})",
+ "chat.recap.aria": "Підсумок сесії",
+ "chat.recap.label": "Підсумок:",
+ "chat.suggestion.applyAria": "Використати запропоноване повідомлення",
+ "chat.suggestion.dismissAria": "Прибрати пропозицію",
"header.actions.toggleTerminalPanelAria": "Перемкнути панель терміналу",
"terminalView.stream.processExitedMessage": "\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n",
"terminalView.stream.processExitedWithCode": " з кодом {exitCode}",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
index b4684193..defb89ce 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
@@ -1363,6 +1363,11 @@ export const settingsDict = {
'settings.openchamber.defaults.field.thinkingPlaceholder': '思考模式',
'settings.openchamber.defaults.field.defaultAgent': '默认智能体',
'settings.openchamber.defaults.field.showDeletionDialogAria': '显示删除对话框',
+ 'settings.openchamber.defaults.smallModel.title': '小模型',
+ 'settings.openchamber.defaults.smallModel.description': '用于快速实用任务(如简短回顾和摘要)的廉价模型。',
+ 'settings.openchamber.defaults.smallModel.useDefault': '使用默认小模型',
+ 'settings.openchamber.defaults.smallModel.useDefaultAria': '使用默认小模型',
+ 'settings.openchamber.defaults.smallModel.overrideModel': '覆盖模型',
'settings.openchamber.defaults.field.showDeletionDialog': '显示删除对话框',
'settings.openchamber.defaults.field.openFilesPreviewAria': '以预览模式打开文件',
'settings.openchamber.defaults.field.openFilesPreview': '以预览模式打开文件',
@@ -1568,6 +1573,7 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputMode': 'TTS 输入模式',
'settings.voice.page.field.ttsInputModeSanitized': '清理后文本',
'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown',
+ 'settings.voice.page.field.ttsInputModeSummarized': '摘要',
'settings.openchamber.visual.section.colorMode': '颜色模式',
'settings.openchamber.visual.section.mobileLayout': '移动端布局',
'settings.openchamber.visual.option.mobileLayout.default': '旧版',
@@ -1651,6 +1657,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.userMessageRenderingAria': '用户消息渲染:{option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 渲染:{option}',
'settings.openchamber.visual.field.diffLayoutAria': '差异布局:{option}',
+ 'settings.openchamber.visual.field.sessionAssist': '生成会话回顾与建议',
+ 'settings.openchamber.visual.field.sessionAssistAria': '代理完成后生成回顾和建议回复',
'settings.openchamber.visual.field.showReasoningTracesAria': '显示推理轨迹',
'settings.openchamber.visual.field.showReasoningTraces': '显示推理轨迹',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '启用可折叠推理块',
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index 2b81880c..eee31d39 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -1353,6 +1353,10 @@ export const dict: Record = {
"header.actions.toggleChangesPanelAria": "切换更改面板",
'header.actions.planWithShortcut': '计划({shortcut})',
'header.actions.terminalPanelWithShortcut': '终端面板({shortcut})',
+ 'chat.recap.aria': '会话回顾',
+ 'chat.recap.label': '回顾:',
+ 'chat.suggestion.applyAria': '使用建议的消息',
+ 'chat.suggestion.dismissAria': '关闭建议',
'header.actions.toggleTerminalPanelAria': '切换终端面板',
'terminalView.stream.processExitedMessage': '\r\n[进程已退出{exitCodeSegment}{signalSegment}]\r\n',
'terminalView.stream.processExitedWithCode': ',退出码 {exitCode}',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
index 04794440..0be05ab2 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
@@ -1279,6 +1279,11 @@
'settings.openchamber.defaults.field.thinkingPlaceholder': '思考模式',
'settings.openchamber.defaults.field.defaultAgent': '預設 Agent',
'settings.openchamber.defaults.field.showDeletionDialogAria': '顯示刪除對話方塊',
+ 'settings.openchamber.defaults.smallModel.title': '小模型',
+ 'settings.openchamber.defaults.smallModel.description': '用於快速實用任務(如簡短回顧與摘要)的廉價模型。',
+ 'settings.openchamber.defaults.smallModel.useDefault': '使用預設小模型',
+ 'settings.openchamber.defaults.smallModel.useDefaultAria': '使用預設小模型',
+ 'settings.openchamber.defaults.smallModel.overrideModel': '覆寫模型',
'settings.openchamber.defaults.field.showDeletionDialog': '顯示刪除對話方塊',
'settings.openchamber.defaults.field.openFilesPreviewAria': '以預覽模式開啟檔案',
'settings.openchamber.defaults.field.openFilesPreview': '以預覽模式開啟檔案',
@@ -1484,6 +1489,7 @@
'settings.voice.page.field.ttsInputMode': 'TTS 輸入模式',
'settings.voice.page.field.ttsInputModeSanitized': '清理後文字',
'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown',
+ 'settings.voice.page.field.ttsInputModeSummarized': '摘要',
'settings.openchamber.visual.section.colorMode': '顏色模式',
'settings.openchamber.visual.section.localization': '在地化',
'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局',
@@ -1567,6 +1573,8 @@
'settings.openchamber.visual.field.userMessageRenderingAria': '使用者訊息渲染:{option}',
'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 渲染:{option}',
'settings.openchamber.visual.field.diffLayoutAria': '差異佈局:{option}',
+ 'settings.openchamber.visual.field.sessionAssist': '產生工作階段回顧與建議',
+ 'settings.openchamber.visual.field.sessionAssistAria': '代理完成後產生回顧與建議回覆',
'settings.openchamber.visual.field.showReasoningTracesAria': '顯示推理軌跡',
'settings.openchamber.visual.field.showReasoningTraces': '顯示推理軌跡',
'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '啟用可摺疊推理區塊',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts
index ab424dd2..b6cac65f 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts
@@ -1357,6 +1357,10 @@ export const dict: Record = {
"header.actions.toggleChangesPanelAria": "切換變更面板",
'header.actions.planWithShortcut': '計畫({shortcut})',
'header.actions.terminalPanelWithShortcut': '終端機面板({shortcut})',
+ 'chat.recap.aria': '工作階段回顧',
+ 'chat.recap.label': '回顧:',
+ 'chat.suggestion.applyAria': '使用建議的訊息',
+ 'chat.suggestion.dismissAria': '關閉建議',
'header.actions.toggleTerminalPanelAria': '切換終端機面板',
'terminalView.stream.processExitedMessage': '\r\n[處理程序已結束{exitCodeSegment}{signalSegment}]\r\n',
'terminalView.stream.processExitedWithCode': ',結束代碼 {exitCode}',
diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts
index 6d12c6e0..d9c89801 100644
--- a/packages/ui/src/lib/magicPrompts.ts
+++ b/packages/ui/src/lib/magicPrompts.ts
@@ -70,7 +70,7 @@ const MAGIC_PROMPT_DEFINITIONS: readonly MagicPromptDefinition[] = [
title: 'Commit Generation Visible Prompt',
group: 'Git',
description: 'Visible user message for commit message generation.',
- template: 'You are generating a Conventional Commits subject line using session context and selected file paths.',
+ template: 'You are generating a Conventional Commits subject line from the diffs of the selected files.',
},
{
id: 'git.commit.generate.instructions',
diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts
index a61317ad..d7ccf5d0 100644
--- a/packages/ui/src/lib/persistence.ts
+++ b/packages/ui/src/lib/persistence.ts
@@ -423,6 +423,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
store.setShowReasoningTraces(settings.showReasoningTraces);
}
+ if (typeof settings.sessionAssistEnabled === 'boolean' && settings.sessionAssistEnabled !== store.sessionAssistEnabled) {
+ store.setSessionAssistEnabled(settings.sessionAssistEnabled);
+ }
if (typeof settings.collapsibleThinkingBlocks === 'boolean' && settings.collapsibleThinkingBlocks !== store.collapsibleThinkingBlocks) {
store.setCollapsibleThinkingBlocks(settings.collapsibleThinkingBlocks);
}
@@ -765,6 +768,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
+ if (typeof candidate.sessionAssistEnabled === 'boolean') {
+ result.sessionAssistEnabled = candidate.sessionAssistEnabled;
+ }
if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
}
@@ -832,6 +838,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
result.defaultAgent = candidate.defaultAgent;
}
+ if (typeof candidate.smallModelUseDefault === 'boolean') {
+ result.smallModelUseDefault = candidate.smallModelUseDefault;
+ }
+ if (typeof candidate.smallModelOverride === 'string' && candidate.smallModelOverride.length > 0) {
+ result.smallModelOverride = candidate.smallModelOverride;
+ }
if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree;
}
diff --git a/packages/ui/src/lib/sessionAssistMetadata.ts b/packages/ui/src/lib/sessionAssistMetadata.ts
new file mode 100644
index 00000000..51bb9549
--- /dev/null
+++ b/packages/ui/src/lib/sessionAssistMetadata.ts
@@ -0,0 +1,36 @@
+import type { Session } from '@opencode-ai/sdk/v2';
+
+// Recap + suggested follow-up generated by the server's session-assist
+// watcher, stored under session.metadata.openchamber.assist. Freshness is
+// encoded in forMessageID: the payload is only valid while that message is
+// still the session's last assistant message.
+export interface SessionAssistPayload {
+ recap: string;
+ suggestion: string;
+ forMessageID: string;
+ generatedAt: number;
+}
+
+const isRecord = (value: unknown): value is Record =>
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+
+export function getSessionAssist(session: Session | null | undefined): SessionAssistPayload | null {
+ const metadata = (session as { metadata?: unknown } | null | undefined)?.metadata;
+ if (!isRecord(metadata)) return null;
+ const namespace = metadata.openchamber;
+ if (!isRecord(namespace)) return null;
+ const assist = namespace.assist;
+ if (!isRecord(assist)) return null;
+
+ const recap = typeof assist.recap === 'string' ? assist.recap.trim() : '';
+ const suggestion = typeof assist.suggestion === 'string' ? assist.suggestion.trim() : '';
+ const forMessageID = typeof assist.forMessageID === 'string' ? assist.forMessageID : '';
+ if (!forMessageID || (!recap && !suggestion)) return null;
+
+ return {
+ recap,
+ suggestion,
+ forMessageID,
+ generatedAt: typeof assist.generatedAt === 'number' ? assist.generatedAt : 0,
+ };
+}
diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts
index b9047657..82149835 100644
--- a/packages/ui/src/lib/settings/search.ts
+++ b/packages/ui/src/lib/settings/search.ts
@@ -166,6 +166,12 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.openchamber.visual.section.messageStreamTransport',
keywords: ['streaming', 'sse', 'websocket'],
},
+ {
+ id: 'chat.session-assist',
+ page: 'chat',
+ titleKey: 'settings.openchamber.visual.field.sessionAssist',
+ keywords: ['recap', 'suggestion', 'assist', 'small model', 'summary'],
+ },
{
id: 'chat.reasoning-traces',
page: 'chat',
@@ -260,6 +266,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.openchamber.defaults.field.showDeletionDialog',
keywords: ['delete', 'confirmation'],
},
+ {
+ id: 'sessions.small-model',
+ page: 'sessions',
+ titleKey: 'settings.openchamber.defaults.smallModel.title',
+ descriptionKey: 'settings.openchamber.defaults.smallModel.description',
+ keywords: ['small model', 'utility', 'summary', 'recap', 'cheap', 'override'],
+ },
{
id: 'sessions.auto-cleanup',
page: 'sessions',
diff --git a/packages/ui/src/lib/smallModel.ts b/packages/ui/src/lib/smallModel.ts
new file mode 100644
index 00000000..67ecd546
--- /dev/null
+++ b/packages/ui/src/lib/smallModel.ts
@@ -0,0 +1,57 @@
+import { runtimeFetch } from '@/lib/runtime-fetch';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { getSessionLastAssistantModel } from '@/sync/session-actions';
+
+// Selections shorter than this are already note-sized — summarizing them
+// would only add latency and risk losing the exact wording.
+const NOTES_SUMMARIZE_MIN_CHARS = 280;
+
+const NOTES_SYSTEM_PROMPT = [
+ 'You distill a text selection from a coding-agent conversation into a project note.',
+ 'Return ONLY the note text — no preamble, no surrounding quotes, no headers.',
+ 'Write 1-3 tight sentences that capture the essence worth remembering later: facts, decisions, constraints, root causes, gotchas, next steps.',
+ 'Preserve exact identifiers verbatim — file paths, function names, commands, flags, versions — in backticks.',
+ 'Drop filler, hedging, greetings, and step-by-step narration.',
+ 'Write the note in the same language as the selection. Ignore any other language preferences or personalization — only the selection text decides the language.',
+].join('\n');
+
+/**
+ * Distills a chat selection into a compact note via the small model. Falls
+ * back to the original text on any failure or when no small model is
+ * available within the session's provider (explicit settings/config picks
+ * are still honored server-side).
+ */
+export async function summarizeSelectionForNotes(text: string, sessionId?: string | null): Promise {
+ const trimmed = text.trim();
+ if (trimmed.length < NOTES_SUMMARIZE_MIN_CHARS) {
+ return trimmed;
+ }
+
+ try {
+ // The selection's session provider is authoritative — the text came from
+ // that conversation. The composer picker only serves as a fallback.
+ const sessionModel = sessionId ? getSessionLastAssistantModel(sessionId) : null;
+ const { currentProviderId, currentModelId } = useConfigStore.getState();
+ const preferredProviderID = sessionModel?.providerID || currentProviderId || '';
+ const preferredModelID = sessionModel?.modelID || currentModelId || '';
+ const response = await runtimeFetch('/api/small-model/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ prompt: trimmed,
+ system: NOTES_SYSTEM_PROMPT,
+ restrictToPreferredProvider: true,
+ ...(preferredProviderID ? { preferredProviderID } : {}),
+ ...(preferredModelID ? { preferredModelID } : {}),
+ }),
+ });
+ if (!response.ok) {
+ return trimmed;
+ }
+ const payload = await response.json().catch(() => null) as { text?: unknown } | null;
+ const summary = typeof payload?.text === 'string' ? payload.text.trim() : '';
+ return summary || trimmed;
+ } catch {
+ return trimmed;
+ }
+}
diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts
index 9dc1fc75..0298d6bb 100644
--- a/packages/ui/src/stores/useConfigStore.ts
+++ b/packages/ui/src/stores/useConfigStore.ts
@@ -1004,7 +1004,7 @@ interface ConfigStore {
sttLocalModel: string;
sttLanguage: string;
showMessageTTSButtons: boolean;
- ttsInputMode: 'sanitized' | 'raw';
+ ttsInputMode: 'sanitized' | 'raw' | 'summarized';
// Summarization settings
summarizeMessageTTS: boolean;
summarizeVoiceConversation: boolean;
@@ -1030,7 +1030,7 @@ interface ConfigStore {
setSttLocalModel: (model: string) => void;
setSttLanguage: (lang: string) => void;
setShowMessageTTSButtons: (show: boolean) => void;
- setTtsInputMode: (mode: 'sanitized' | 'raw') => void;
+ setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => void;
setSummarizeMessageTTS: (enabled: boolean) => void;
setSummarizeVoiceConversation: (enabled: boolean) => void;
setSummarizeCharacterThreshold: (threshold: number) => void;
@@ -1299,6 +1299,7 @@ export const useConfigStore = create()(
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('ttsInputMode');
if (saved === 'raw') return 'raw' as const;
+ if (saved === 'summarized') return 'summarized' as const;
}
return 'sanitized' as const;
})(),
@@ -2925,7 +2926,7 @@ export const useConfigStore = create()(
}
},
- setTtsInputMode: (mode: 'sanitized' | 'raw') => {
+ setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => {
set({ ttsInputMode: mode });
if (typeof window !== 'undefined') {
localStorage.setItem('ttsInputMode', mode);
diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts
index b8abc30c..1daf530a 100644
--- a/packages/ui/src/stores/useUIStore.ts
+++ b/packages/ui/src/stores/useUIStore.ts
@@ -560,6 +560,7 @@ interface UIStore {
eventStreamStatus: EventStreamStatus;
eventStreamHint: string | null;
showReasoningTraces: boolean;
+ sessionAssistEnabled: boolean;
collapsibleThinkingBlocks: boolean;
groupReasoningBlocks: boolean;
chatRenderMode: ChatRenderMode;
@@ -708,6 +709,7 @@ interface UIStore {
setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void;
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
setShowReasoningTraces: (value: boolean) => void;
+ setSessionAssistEnabled: (value: boolean) => void;
setCollapsibleThinkingBlocks: (value: boolean) => void;
setChatRenderMode: (value: ChatRenderMode) => void;
setActivityRenderMode: (value: ActivityRenderMode) => void;
@@ -851,6 +853,7 @@ export const useUIStore = create()(
eventStreamStatus: 'idle',
eventStreamHint: null,
showReasoningTraces: true,
+ sessionAssistEnabled: true,
collapsibleThinkingBlocks: true,
groupReasoningBlocks: true,
chatRenderMode: 'live',
@@ -1543,6 +1546,10 @@ export const useUIStore = create()(
set({ showReasoningTraces: value });
},
+ setSessionAssistEnabled: (value) => {
+ set({ sessionAssistEnabled: value });
+ },
+
setCollapsibleThinkingBlocks: (value) => {
set({ collapsibleThinkingBlocks: value });
},
@@ -2227,6 +2234,7 @@ export const useUIStore = create()(
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
// Note: isSettingsDialogOpen intentionally NOT persisted
showReasoningTraces: state.showReasoningTraces,
+ sessionAssistEnabled: state.sessionAssistEnabled,
collapsibleThinkingBlocks: state.collapsibleThinkingBlocks,
chatRenderMode: state.chatRenderMode,
activityRenderMode: state.activityRenderMode,
diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts
index 6195f127..90a4e89d 100644
--- a/packages/ui/src/sync/session-actions.ts
+++ b/packages/ui/src/sync/session-actions.ts
@@ -131,6 +131,29 @@ function dirStoreForSession(sessionId: string): { store: DirectoryStoreApi; dire
return { store: dirStore(), directory: dir() }
}
+/**
+ * Provider/model of the session's last assistant message — the authoritative
+ * "session provider" for utility calls (notes distillation etc.), independent
+ * of what the composer picker currently points at.
+ */
+export function getSessionLastAssistantModel(sessionId: string): { providerID: string; modelID: string } | null {
+ try {
+ const { store } = dirStoreForSession(sessionId)
+ const messages = store.getState().message[sessionId]
+ if (!messages) return null
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
+ const info = messages[i] as { role?: string; providerID?: string; modelID?: string }
+ if (info?.role === "assistant" && typeof info.providerID === "string" && info.providerID
+ && typeof info.modelID === "string" && info.modelID) {
+ return { providerID: info.providerID, modelID: info.modelID }
+ }
+ }
+ return null
+ } catch {
+ return null
+ }
+}
+
function updateLiveSession(session: Session, directory?: string): void {
const stores = _childStores
if (!stores) return
diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts
index aa4049ab..86b80022 100644
--- a/packages/vscode/src/bridge-settings-runtime.ts
+++ b/packages/vscode/src/bridge-settings-runtime.ts
@@ -291,7 +291,7 @@ export const persistSettings = async (changes: Record, ctx?: Br
const keysToClear = new Set();
- for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) {
+ for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary', 'smallModelOverride']) {
const value = restChanges[key];
if (typeof value === 'string' && value.trim().length === 0) {
keysToClear.add(key);
@@ -299,6 +299,14 @@ export const persistSettings = async (changes: Record, ctx?: Br
}
}
+ if ('smallModelUseDefault' in restChanges && typeof restChanges.smallModelUseDefault !== 'boolean') {
+ delete restChanges.smallModelUseDefault;
+ }
+
+ if ('sessionAssistEnabled' in restChanges && typeof restChanges.sessionAssistEnabled !== 'boolean') {
+ delete restChanges.sessionAssistEnabled;
+ }
+
if (typeof restChanges.usageAutoRefresh !== 'boolean') {
delete restChanges.usageAutoRefresh;
}
diff --git a/packages/web/server/index.js b/packages/web/server/index.js
index 58e12a36..fea5d94b 100644
--- a/packages/web/server/index.js
+++ b/packages/web/server/index.js
@@ -72,6 +72,7 @@ import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolut
import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js';
import { createSessionRuntime } from './lib/opencode/session-runtime.js';
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
+import { createSessionAssistRuntime } from './lib/session-assist/runtime.js';
import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js';
import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js';
import { createTunnelWiringRuntime } from './lib/opencode/tunnel-wiring-runtime.js';
@@ -713,6 +714,12 @@ const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSen
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge();
+const sessionAssistRuntime = createSessionAssistRuntime({
+ buildOpenCodeUrl,
+ getOpenCodeAuthHeaders,
+ getSmallModelService: async () => import('./lib/small-model/index.js'),
+});
+
const globalMessageStreamHub = createGlobalMessageStreamHub({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
@@ -732,6 +739,19 @@ const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
},
});
+// Session-assist subscribes to the hub directly: it needs the envelope's
+// directory to route its own OpenCode calls to the right instance.
+console.log('[session-assist] listening for session events');
+globalMessageStreamHub.subscribeEvent((event) => {
+ const raw = event?.payload;
+ const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw;
+ if (!payload || typeof payload !== 'object') return;
+ const directory = typeof event?.directory === 'string' && event.directory && event.directory !== 'global'
+ ? event.directory
+ : '';
+ sessionAssistRuntime.processPayload(payload, directory);
+});
+
const processForwardedEventPayload = (payload, emitSyntheticEvent) => {
if (!payload || typeof payload !== 'object' || typeof emitSyntheticEvent !== 'function') {
return;
@@ -1014,11 +1034,12 @@ const bootstrapOpenCodeAtStartup = async (...args) => {
if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) {
startHealthMonitoring();
}
- if (ENV_DESKTOP_NOTIFY) {
- void ensureGlobalWatcherStarted().catch((error) => {
- console.warn(`Global event watcher startup failed: ${error?.message || error}`);
- });
- }
+ // The global watcher used to start only for desktop notifications; the
+ // session-assist runtime also rides its event hub, so it now starts
+ // unconditionally once OpenCode is up.
+ void ensureGlobalWatcherStarted().catch((error) => {
+ console.warn(`Global event watcher startup failed: ${error?.message || error}`);
+ });
};
const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args);
const waitForPortRelease = (...args) => openCodeLifecycleRuntime.waitForPortRelease(...args);
@@ -1037,6 +1058,7 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({
},
syncToHmrState,
openCodeWatcherRuntime,
+ sessionAssistRuntime,
sessionRuntime,
getHealthCheckInterval: () => healthCheckInterval,
clearHealthCheckInterval: (value) => clearInterval(value),
diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js
index 697fe528..1c203ed1 100644
--- a/packages/web/server/lib/opencode/core-routes.js
+++ b/packages/web/server/lib/opencode/core-routes.js
@@ -759,6 +759,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
req.path.startsWith('/api/push') ||
req.path.startsWith('/api/notifications') ||
req.path.startsWith('/api/session-folders') ||
+ req.path.startsWith('/api/small-model') ||
req.path.startsWith('/api/text') ||
req.path.startsWith('/api/voice') ||
req.path.startsWith('/api/tts') ||
diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js
index 25de723f..28147717 100644
--- a/packages/web/server/lib/opencode/feature-routes-runtime.js
+++ b/packages/web/server/lib/opencode/feature-routes-runtime.js
@@ -1,5 +1,6 @@
import { registerFsRoutes } from '../fs/routes.js';
import { registerQuotaRoutes } from '../quota/routes.js';
+import { registerSmallModelRoutes } from '../small-model/routes.js';
import { registerGitHubRoutes } from '../github/routes.js';
import { registerGitRoutes } from '../git/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
@@ -54,6 +55,14 @@ export const createFeatureRoutesRuntime = (dependencies) => {
return quotaProviders;
};
+ let smallModelService = null;
+ const getSmallModelService = async () => {
+ if (!smallModelService) {
+ smallModelService = await import('../small-model/index.js');
+ }
+ return smallModelService;
+ };
+
const registerRoutes = async (app, routeDependencies) => {
const {
crypto,
@@ -226,6 +235,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
});
registerQuotaRoutes(app, { getQuotaProviders });
+ registerSmallModelRoutes(app, { getSmallModelService });
registerGitHubRoutes(app);
registerGitRoutes(app);
registerMagicPromptRoutes(app, {
diff --git a/packages/web/server/lib/opencode/models-metadata.js b/packages/web/server/lib/opencode/models-metadata.js
new file mode 100644
index 00000000..0d37a629
--- /dev/null
+++ b/packages/web/server/lib/opencode/models-metadata.js
@@ -0,0 +1,61 @@
+const MODELS_DEV_API_URL = 'https://models.dev/api.json';
+const DEFAULT_TTL_MS = 10 * 60 * 1000;
+const DEFAULT_TIMEOUT_MS = 8000;
+
+// Shared in-process cache of the models.dev catalog. Used by the
+// /api/openchamber/models-metadata route and the small-model resolver so the
+// server fetches the catalog once, not per consumer.
+let cachedMetadata = null;
+let cachedAt = 0;
+let inflight = null;
+
+const fetchCatalog = async (url, timeoutMs) => {
+ const response = await fetch(url, {
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (!response.ok) {
+ throw new Error(`models.dev responded with status ${response.status}`);
+ }
+ const metadata = await response.json();
+ if (!metadata || typeof metadata !== 'object') {
+ throw new Error('models.dev returned an unexpected payload');
+ }
+ return metadata;
+};
+
+/**
+ * Returns the models.dev catalog, serving the in-memory copy while fresh.
+ * On fetch failure a stale cached copy is returned when available; otherwise
+ * the error propagates.
+ */
+export async function getModelsMetadata({
+ url = MODELS_DEV_API_URL,
+ ttlMs = DEFAULT_TTL_MS,
+ timeoutMs = DEFAULT_TIMEOUT_MS,
+} = {}) {
+ const now = Date.now();
+ if (cachedMetadata && now - cachedAt < ttlMs) {
+ return { metadata: cachedMetadata, fromCache: true };
+ }
+
+ if (!inflight) {
+ inflight = fetchCatalog(url, timeoutMs).finally(() => {
+ inflight = null;
+ });
+ }
+
+ try {
+ const metadata = await inflight;
+ cachedMetadata = metadata;
+ cachedAt = Date.now();
+ return { metadata, fromCache: false };
+ } catch (error) {
+ if (cachedMetadata) {
+ return { metadata: cachedMetadata, fromCache: true, stale: true };
+ }
+ throw error;
+ }
+}
+
+export { MODELS_DEV_API_URL };
diff --git a/packages/web/server/lib/opencode/openchamber-routes.js b/packages/web/server/lib/opencode/openchamber-routes.js
index 08b22790..21694abb 100644
--- a/packages/web/server/lib/opencode/openchamber-routes.js
+++ b/packages/web/server/lib/opencode/openchamber-routes.js
@@ -13,9 +13,6 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
getCachedZenModels,
} = dependencies;
- let cachedModelsMetadata = null;
- let cachedModelsMetadataTimestamp = 0;
-
app.get('/api/openchamber/update-check', async (req, res) => {
try {
const { checkForUpdates } = await import('../package-manager.js');
@@ -254,48 +251,18 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
});
app.get('/api/openchamber/models-metadata', async (_req, res) => {
- const now = Date.now();
-
- if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < modelsMetadataCacheTtl) {
- res.setHeader('Cache-Control', 'public, max-age=60');
- return res.json(cachedModelsMetadata);
- }
-
- const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
- const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
-
try {
- const response = await fetch(modelsDevApiUrl, {
- signal: controller?.signal,
- headers: {
- Accept: 'application/json'
- }
+ const { getModelsMetadata } = await import('./models-metadata.js');
+ const { metadata, fromCache, stale } = await getModelsMetadata({
+ url: modelsDevApiUrl,
+ ttlMs: modelsMetadataCacheTtl,
});
-
- if (!response.ok) {
- throw new Error(`models.dev responded with status ${response.status}`);
- }
-
- const metadata = await response.json();
- cachedModelsMetadata = metadata;
- cachedModelsMetadataTimestamp = Date.now();
-
- res.setHeader('Cache-Control', 'public, max-age=300');
+ res.setHeader('Cache-Control', fromCache && !stale ? 'public, max-age=60' : 'public, max-age=300');
res.json(metadata);
} catch (error) {
console.warn('Failed to fetch models.dev metadata via server:', error);
-
- if (cachedModelsMetadata) {
- res.setHeader('Cache-Control', 'public, max-age=60');
- res.json(cachedModelsMetadata);
- } else {
- const statusCode = error?.name === 'AbortError' ? 504 : 502;
- res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
- }
- } finally {
- if (timeout) {
- clearTimeout(timeout);
- }
+ const statusCode = error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 504 : 502;
+ res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
}
});
diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js
index 087b7f8a..e868449e 100644
--- a/packages/web/server/lib/opencode/settings-helpers.js
+++ b/packages/web/server/lib/opencode/settings-helpers.js
@@ -245,6 +245,9 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
+ if (typeof candidate.sessionAssistEnabled === 'boolean') {
+ result.sessionAssistEnabled = candidate.sessionAssistEnabled;
+ }
if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
}
@@ -374,6 +377,13 @@ export const createSettingsHelpers = (dependencies) => {
const trimmed = candidate.defaultAgent.trim();
result.defaultAgent = trimmed.length > 0 ? trimmed : undefined;
}
+ if (typeof candidate.smallModelUseDefault === 'boolean') {
+ result.smallModelUseDefault = candidate.smallModelUseDefault;
+ }
+ if (typeof candidate.smallModelOverride === 'string') {
+ const trimmed = candidate.smallModelOverride.trim();
+ result.smallModelOverride = trimmed.length > 0 ? trimmed : undefined;
+ }
if (typeof candidate.defaultGitIdentityId === 'string') {
const trimmed = candidate.defaultGitIdentityId.trim();
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
diff --git a/packages/web/server/lib/opencode/shutdown-runtime.js b/packages/web/server/lib/opencode/shutdown-runtime.js
index 6f568649..acb6e48b 100644
--- a/packages/web/server/lib/opencode/shutdown-runtime.js
+++ b/packages/web/server/lib/opencode/shutdown-runtime.js
@@ -8,6 +8,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
syncToHmrState,
openCodeWatcherRuntime,
sessionRuntime,
+ sessionAssistRuntime,
scheduledTasksRuntime,
getHealthCheckInterval,
clearHealthCheckInterval,
@@ -41,6 +42,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
openCodeWatcherRuntime.stop();
sessionRuntime.dispose();
+ sessionAssistRuntime?.stop?.();
scheduledTasksRuntime?.stop?.();
const healthCheckInterval = getHealthCheckInterval();
diff --git a/packages/web/server/lib/session-assist/DOCUMENTATION.md b/packages/web/server/lib/session-assist/DOCUMENTATION.md
new file mode 100644
index 00000000..72f2bcf1
--- /dev/null
+++ b/packages/web/server/lib/session-assist/DOCUMENTATION.md
@@ -0,0 +1,64 @@
+# Session Assist
+
+Server-side watcher that generates a short recap of the agent's last reply
+and one suggested user follow-up with the small model
+(`lib/small-model`), storing both on the session's metadata under
+`metadata.openchamber.assist`.
+
+## Flow
+
+1. `createSessionAssistRuntime` is a consumer of the server's global SSE
+ fan-out (`index.js` → `onPayload`), riding the same upstream connection as
+ notifications. Purely event-driven — dormant sessions never generate
+ anything, there is no backfill and no session scanning.
+2. `session.status: idle` arms a 60-second per-session timer; any `busy`/
+ `retry` status or a user `message.updated` clears it (the "1 minute of
+ quiet" rule).
+3. On fire: fetch the session (skip sub-agent sessions with `parentID`),
+ take the LAST exchange only — the final assistant reply plus the user
+ message it answered (assistant `parentID` → user id) — and call
+ `generateSmallModelText` with the
+ session's own provider/model taken from the last assistant message — so
+ the utility call spends the same subscription as the conversation.
+ `restrictToPreferredProvider` forbids the resolver's global fallback:
+ conversation content never goes to a provider the user didn't pick for
+ the session, unless the small model was chosen explicitly (settings
+ override or opencode config). A resolver 404 is silently skipped.
+4. The `{recap, suggestion}` JSON is clamped and PATCHed onto the session
+ metadata together with `forMessageID` (the last assistant message id) and
+ `generatedAt`. Before writing, the session tail is re-checked (a stale
+ result is dropped) and the metadata is merged from a fresh session read so
+ concurrent metadata writes made during generation are preserved.
+
+## Settings gate
+
+`sessionAssistEnabled` in OpenChamber settings (Settings → Chat, default on)
+is a hard generation switch checked at fire time: when off, no small-model
+calls run and nothing is written. Existing payloads keep rendering and can
+still be dismissed — the switch is about generation, not visibility.
+
+## Freshness contract (no clearing writes)
+
+Clients do not need the payload to be deleted: they render it only while
+`assist.forMessageID` still equals the session's last assistant message id
+(and the session is idle). Any new message invalidates the payload
+everywhere instantly and offline; the next idle cycle overwrites it.
+
+## UI consumers (packages/ui)
+
+- `lib/sessionAssistMetadata.ts` — payload parsing.
+- `hooks/useSessionAssist.ts` — freshness gating + the 5-minute quiet window
+ for the recap (single timeout to the boundary, no polling).
+- `components/chat/SessionRecapSpacer.tsx` — renders the recap inside the
+ fixed-height reserved gap under the last message (height never changes).
+- `components/chat/SessionSuggestionChip.tsx` — one tappable suggestion chip
+ near the composer (desktop chips row + above the mobile pill); hidden as
+ soon as the composer has any content. Tap fills the input, never sends.
+
+## Limitations
+
+- The watcher lives in the web server, so VS Code (extension-only, no web
+ server) does not generate assists; it still renders payloads produced by a
+ web/desktop instance of the same OpenCode server via `session.updated`.
+- Metadata payloads ride every `session.updated` event — keep the clamps
+ (`RECAP_CHAR_LIMIT`, `SUGGESTION_CHAR_LIMIT`) small.
diff --git a/packages/web/server/lib/session-assist/runtime.js b/packages/web/server/lib/session-assist/runtime.js
new file mode 100644
index 00000000..5843c289
--- /dev/null
+++ b/packages/web/server/lib/session-assist/runtime.js
@@ -0,0 +1,349 @@
+// Session assist: after a session goes idle and stays quiet, generate a short
+// recap of the agent's last reply plus one suggested user follow-up with the
+// small model, and store both on the session's metadata
+// (metadata.openchamber.assist). Clients decide visibility from
+// assist.forMessageID — a new message makes the payload stale everywhere
+// without any extra writes.
+//
+// Purely event-driven: only sessions that transition busy→idle while the
+// server is running ever generate anything. No backfill, no session scans.
+
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+const OPENCHAMBER_SETTINGS_FILE = path.join(
+ process.env.OPENCHAMBER_DATA_DIR
+ ? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
+ : path.join(os.homedir(), '.config', 'openchamber'),
+ 'settings.json',
+);
+
+// The Chat setting is a hard generation switch (default on): when off, no
+// small-model calls and no metadata writes happen at all. Existing payloads
+// stay untouched — clients keep showing them and dismissal still works.
+const isSessionAssistEnabled = () => {
+ try {
+ const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
+ const settings = JSON.parse(raw);
+ return settings?.sessionAssistEnabled !== false;
+ } catch {
+ return true;
+ }
+};
+
+const IDLE_QUIET_MS = 60_000;
+const TRANSCRIPT_MESSAGE_LIMIT = 12;
+const TRANSCRIPT_PART_CHAR_LIMIT = 6_000;
+const RECAP_CHAR_LIMIT = 320;
+const SUGGESTION_CHAR_LIMIT = 500;
+const FETCH_TIMEOUT_MS = 5_000;
+
+const ASSIST_SYSTEM_PROMPT = [
+ 'You assist a user who chats with a coding agent. Based on the conversation transcript, return exactly one JSON object and nothing else — no prose, no markdown, no code fences.',
+ 'Shape: {"recap": string, "suggestion": string}',
+ 'recap: at most 20 words. State the substance directly — the facts, result, or conclusion, plus the next move if there is one. NEVER narrate ("The assistant explained…", "The agent did…") — write the content itself, like a note the user jotted down.',
+ 'suggestion: the next message to send in this conversation, addressed TO the agent — a concise instruction or question that moves the work forward, e.g. "Run the tests and fix failures" / "Commit this". Imperative or question form. Never explain, never offer help, never say "you can".',
+ 'Both values MUST be written in the same language as the conversation text itself. Ignore any other language preferences or personalization you may have — only the conversation text decides the language.',
+ 'Use double quotes for JSON strings, no trailing commas.',
+].join('\n');
+
+const extractJsonObject = (value) => {
+ const text = String(value ?? '').trim();
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
+ const candidate = (fenced?.[1] ?? text).trim();
+ const start = candidate.indexOf('{');
+ if (start < 0) return null;
+ for (let end = candidate.length; end > start; end -= 1) {
+ if (candidate[end - 1] !== '}') continue;
+ try {
+ const parsed = JSON.parse(candidate.slice(start, end));
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return parsed;
+ }
+ } catch {
+ // keep scanning — models wrap JSON in prose sometimes
+ }
+ }
+ return null;
+};
+
+const extractSessionStatus = (payload) => {
+ if (!payload || payload.type !== 'session.status') return null;
+ const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
+ const status = properties.status && typeof properties.status === 'object' ? properties.status : {};
+ const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
+ const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
+ const type = typeof status.type === 'string'
+ ? status.type.trim()
+ : (typeof info.type === 'string' ? info.type.trim() : '');
+ if (!sessionId || !type) return null;
+ const directory = typeof properties.directory === 'string' && properties.directory
+ ? properties.directory
+ : (typeof info.directory === 'string' ? info.directory : '');
+ return { sessionId, type, directory };
+};
+
+const extractUserMessage = (payload) => {
+ if (!payload || payload.type !== 'message.updated') return null;
+ const info = payload.properties?.info;
+ if (!info || typeof info !== 'object' || info.role !== 'user') return null;
+ if (typeof info.sessionID !== 'string' || !info.sessionID) return null;
+ return {
+ sessionId: info.sessionID,
+ createdAt: typeof info.time?.created === 'number' ? info.time.created : 0,
+ };
+};
+
+const messagePartsToText = (message) => {
+ const parts = Array.isArray(message?.parts) ? message.parts : [];
+ return parts
+ .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : ''))
+ .filter(Boolean)
+ .join('\n')
+ .slice(0, TRANSCRIPT_PART_CHAR_LIMIT);
+};
+
+export const createSessionAssistRuntime = ({
+ buildOpenCodeUrl,
+ getOpenCodeAuthHeaders,
+ getSmallModelService,
+ quietMs = IDLE_QUIET_MS,
+}) => {
+ const timers = new Map();
+ const inflight = new Set();
+ let stopped = false;
+
+ const clearTimer = (sessionId) => {
+ const existing = timers.get(sessionId);
+ if (existing) {
+ clearTimeout(existing.timer);
+ timers.delete(sessionId);
+ }
+ };
+
+ const openCodeFetch = async (path, { directory, method = 'GET', body } = {}) => {
+ const base = buildOpenCodeUrl(path, '');
+ const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
+ const response = await fetch(url, {
+ method,
+ headers: {
+ Accept: 'application/json',
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
+ ...getOpenCodeAuthHeaders(),
+ },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
+ });
+ if (!response.ok) {
+ throw new Error(`OpenCode ${method} ${path} failed with ${response.status}`);
+ }
+ return response.json().catch(() => null);
+ };
+
+ const fetchRecentMessages = async (sessionId, directory) => {
+ const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
+ const params = new URLSearchParams({ limit: String(TRANSCRIPT_MESSAGE_LIMIT) });
+ if (directory) params.set('directory', directory);
+ const response = await fetch(`${base}?${params.toString()}`, {
+ method: 'GET',
+ headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
+ });
+ if (!response.ok) return null;
+ const messages = await response.json().catch(() => null);
+ return Array.isArray(messages) ? messages : null;
+ };
+
+ const generateAssist = async (sessionId, directory) => {
+ if (!isSessionAssistEnabled()) return;
+ const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
+ .catch((error) => {
+ console.warn(`[session-assist] session fetch failed: ${error?.message || error}`);
+ return null;
+ });
+ if (!session || typeof session !== 'object') return;
+ // Sub-agent/task sessions never surface in chat — skip them.
+ if (typeof session.parentID === 'string' && session.parentID) return;
+
+ const messages = await fetchRecentMessages(sessionId, directory);
+ if (!messages || messages.length === 0) {
+ console.warn('[session-assist] no messages fetched');
+ return;
+ }
+
+ let lastAssistant = null;
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
+ const info = messages[i]?.info;
+ if (info?.role === 'assistant') {
+ lastAssistant = messages[i];
+ break;
+ }
+ }
+ const lastAssistantInfo = lastAssistant?.info;
+ if (!lastAssistantInfo?.id) return;
+
+ // Only the last exchange: the assistant reply plus the user message it
+ // answered (assistant info.parentID → user info.id). Everything else is
+ // token waste for a one-line recap and a single suggestion.
+ const parentUserMessage = typeof lastAssistantInfo.parentID === 'string' && lastAssistantInfo.parentID
+ ? messages.find((message) => message?.info?.id === lastAssistantInfo.parentID && message?.info?.role === 'user')
+ : null;
+ const userText = parentUserMessage ? messagePartsToText(parentUserMessage) : '';
+ const assistantText = messagePartsToText(lastAssistant);
+ const transcript = [
+ userText ? `User:\n${userText}` : '',
+ assistantText ? `Assistant:\n${assistantText}` : '',
+ ].filter(Boolean).join('\n\n');
+ if (!transcript) return;
+
+ const { generateSmallModelText } = await getSmallModelService();
+ // Instruct the language by example, not by description — account-side
+ // personalization (e.g. the ChatGPT backend knowing the user's locale)
+ // otherwise leaks a different language into the output.
+ const languageSample = (userText || assistantText).slice(0, 200).replace(/\s+/g, ' ').trim();
+ let generated;
+ try {
+ generated = await generateSmallModelText({
+ // Background feature: conversation content must never leave the
+ // session's own provider unless the user explicitly picked a small
+ // model (settings override / opencode config).
+ restrictToPreferredProvider: true,
+ prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite recap and suggestion in the SAME language as this sample from the conversation: "${languageSample}"`,
+ system: ASSIST_SYSTEM_PROMPT,
+ directory,
+ preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
+ preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
+ });
+ } catch (error) {
+ // No authenticated provider (404) or a transient model failure — this is
+ // background sugar, never retry loops or logs spam.
+ if (Number(error?.statusCode) !== 404) {
+ console.warn('[session-assist] generation failed:', error?.message || error);
+ }
+ return;
+ }
+
+ const structured = extractJsonObject(generated?.text);
+ let recap = typeof structured?.recap === 'string' ? structured.recap.trim().slice(0, RECAP_CHAR_LIMIT) : '';
+ let suggestion = typeof structured?.suggestion === 'string' ? structured.suggestion.trim().slice(0, SUGGESTION_CHAR_LIMIT) : '';
+
+ // Hard guard against language hallucination: if the conversation contains
+ // no Cyrillic/CJK at all, the output must not either (and drop per-field,
+ // so one hallucinated field doesn't kill the other).
+ const hasCyrillic = (text) => /[\u0400-\u04FF]/.test(text);
+ const hasCjk = (text) => /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/.test(text);
+ const inputText = `${userText}\n${assistantText}`;
+ const scriptMismatch = (text) => (hasCyrillic(text) && !hasCyrillic(inputText))
+ || (hasCjk(text) && !hasCjk(inputText));
+ if (recap && scriptMismatch(recap)) {
+ console.warn('[session-assist] dropped recap: language mismatch with conversation');
+ recap = '';
+ }
+ if (suggestion && scriptMismatch(suggestion)) {
+ console.warn('[session-assist] dropped suggestion: language mismatch with conversation');
+ suggestion = '';
+ }
+ if (!recap && !suggestion) return;
+
+ // The session may have moved on while we generated — a stale patch would
+ // flash outdated content, so re-check the tail before writing.
+ const latest = await fetchRecentMessages(sessionId, directory);
+ const latestAssistantId = (() => {
+ if (!latest) return null;
+ for (let i = latest.length - 1; i >= 0; i -= 1) {
+ const info = latest[i]?.info;
+ if (info?.role === 'assistant') return info.id;
+ if (info?.role === 'user') return null;
+ }
+ return null;
+ })();
+ if (latestAssistantId !== lastAssistantInfo.id) {
+ console.log('[session-assist] tail moved on, dropping result');
+ return;
+ }
+
+ // Merge from a FRESH read: generation takes tens of seconds, and merging
+ // from the session snapshot fetched before it would clobber any metadata
+ // written meanwhile (suggestion dismissals, review links, …).
+ const freshSession = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
+ .catch(() => null);
+ const currentMetadata = freshSession?.metadata && typeof freshSession.metadata === 'object'
+ ? freshSession.metadata
+ : (session.metadata && typeof session.metadata === 'object' ? session.metadata : {});
+ const currentNamespace = currentMetadata.openchamber && typeof currentMetadata.openchamber === 'object'
+ ? currentMetadata.openchamber
+ : {};
+
+ console.log(`[session-assist] generated for ${sessionId} via ${generated.providerID}/${generated.modelID}`);
+ await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
+ directory,
+ method: 'PATCH',
+ body: {
+ metadata: {
+ ...currentMetadata,
+ openchamber: {
+ ...currentNamespace,
+ assist: {
+ recap,
+ suggestion,
+ forMessageID: lastAssistantInfo.id,
+ generatedAt: Date.now(),
+ },
+ },
+ },
+ },
+ });
+ };
+
+ const armTimer = (sessionId, directory) => {
+ clearTimer(sessionId);
+ const timer = setTimeout(() => {
+ timers.delete(sessionId);
+ if (stopped || inflight.has(sessionId)) return;
+ inflight.add(sessionId);
+ generateAssist(sessionId, directory)
+ .catch((error) => {
+ console.warn('[session-assist] failed:', error?.message || error);
+ })
+ .finally(() => {
+ inflight.delete(sessionId);
+ });
+ }, quietMs);
+ if (typeof timer?.unref === 'function') timer.unref();
+ timers.set(sessionId, { timer, armedAt: Date.now() });
+ };
+
+ const processPayload = (payload, directoryHint = '') => {
+ if (stopped) return;
+ const status = extractSessionStatus(payload);
+ if (status) {
+ if (status.type === 'idle') {
+ armTimer(status.sessionId, status.directory || directoryHint);
+ } else {
+ clearTimer(status.sessionId);
+ }
+ return;
+ }
+ const userMessage = extractUserMessage(payload);
+ if (userMessage) {
+ // OpenCode re-emits message.updated for OLD user messages after the
+ // session settles (post-completion metadata patches). Only a message
+ // created after the timer was armed means the user actually moved on.
+ const armed = timers.get(userMessage.sessionId);
+ if (armed && userMessage.createdAt >= armed.armedAt) {
+ clearTimer(userMessage.sessionId);
+ }
+ }
+ };
+
+ const stop = () => {
+ stopped = true;
+ for (const { timer } of timers.values()) {
+ clearTimeout(timer);
+ }
+ timers.clear();
+ };
+
+ return { processPayload, stop };
+};
diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md
new file mode 100644
index 00000000..c757d07b
--- /dev/null
+++ b/packages/web/server/lib/small-model/DOCUMENTATION.md
@@ -0,0 +1,78 @@
+# Small Model
+
+Server-side direct LLM calls that reuse the user's existing OpenCode provider
+logins (`~/.local/share/opencode/auth.json`). OpenCode uses a "small model"
+internally (titles, summaries) but does not expose it through the SDK or
+plugins — this module replicates that mechanism as an OpenChamber runtime API.
+
+## Security boundary
+
+Credentials never leave the server process. The client sends only a prompt;
+auth resolution, OAuth refresh, and provider dispatch all happen server-side.
+Routes live under `/api/*` and are gated by the ui-auth middleware like every
+other runtime API.
+
+## Files
+
+- `index.js` — orchestration: `generateSmallModelText()` / `describeSmallModel()`.
+- `resolve.js` — model selection, mirroring OpenCode's `getSmallModel` chain:
+ 0. OpenChamber's own settings override (Settings → Sessions → Small Model):
+ when `smallModelUseDefault` is `false`, `smallModelOverride`
+ (`provider/model`) outranks everything below. Sanitized in
+ `settings-helpers.js` (server), `persistence.ts` (client), and
+ `bridge-settings-runtime.ts` (VS Code).
+ 1. `small_model` from the merged OpenCode config layers (`provider/model`).
+ 2. Family-priority scan (`gemini-flash` → `gpt-nano` → `claude-haiku`)
+ **within the session's provider first** (`preferredProviderID`, like
+ OpenCode resolves within the current provider), then over the other
+ providers with a usable auth entry, newest `release_date` first.
+ 3. GitHub Copilot hidden utility models (`gpt-*-nano/mini`) — these never
+ appear in the catalog, so they participate as the `gpt-nano` family entry
+ and as a final utility fallback.
+ 4. Last resort: the session's own model (`preferredModelID`) when no small
+ model resolves anywhere — costlier, but always valid.
+- Input clamp: the prompt is truncated to the resolved model's catalog
+ `limit.context` (minus an output reserve, ~4 chars/token estimate;
+ conservative default when the model is not in the catalog). Truncation is
+ reported as `inputTruncated: true` in the response.
+- `call.js` — wire formats and per-provider auth, replicating OpenCode's
+ plugin auth loaders:
+ - **GitHub Copilot**: OpenAI-compatible `/chat/completions` on
+ `https://api.githubcopilot.com` (or `copilot-api.`) with the
+ stored device-OAuth token as the bearer — no token exchange, no expiry.
+ - **OpenAI OAuth (ChatGPT plan)**: streaming Responses API on
+ `https://chatgpt.com/backend-api/codex/responses` with
+ `ChatGPT-Account-Id`; expired tokens are refreshed against
+ `auth.openai.com` (single-flight) and written back to `auth.json`.
+ - **Anthropic** (`type: api`): `/v1/messages` with `x-api-key`.
+ - **Google** (`type: api`): `generateContent` with `x-goog-api-key`.
+ - Everything else: OpenAI-compatible `/chat/completions` against the
+ provider's models.dev base URL with `Authorization: Bearer `.
+- `catalog.js` — models.dev catalog via the shared in-process cache
+ (`../opencode/models-metadata.js`, also serving
+ `/api/openchamber/models-metadata`).
+- `routes.js` — `GET /api/small-model` (resolution preview) and
+ `POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?,
+ model?, directory? }` → `{ text, providerID, modelID, source }`).
+
+## Registration
+
+Mounted lazily from `feature-routes-runtime.js` (same pattern as quota): the
+module is imported on first request, not at server startup.
+
+## Known limitations
+
+- OpenCode's free models (`opencode/big-pickle`, `*-free`) work without a
+ token only through OpenCode's own server — direct calls are rejected, and
+ piggybacking on their subsidized infra is out of bounds by design. Every
+ resolution step therefore requires a usable auth entry for the provider:
+ a session on an unauthenticated `opencode` provider falls through to the
+ global scan (or a clean 404 on a vanilla setup with no logins).
+
+- Anthropic OAuth (Claude Pro/Max) entries are not supported — OpenCode itself
+ keeps those outside `auth.json` in this generation; only `type: api` keys
+ work for Anthropic.
+- Amazon Bedrock, GitLab, Azure and other credential-chain providers are out
+ of scope; they need more than a key/token (regions, resource names).
+- Responses from the codex backend are collected from the SSE stream; the
+ endpoint itself is non-streaming by design (small utility calls).
diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js
new file mode 100644
index 00000000..8fd81166
--- /dev/null
+++ b/packages/web/server/lib/small-model/call.js
@@ -0,0 +1,380 @@
+import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
+import { getCatalogProvider } from './catalog.js';
+import { getAuthEntryForProvider } from './resolve.js';
+
+// Direct, non-streaming text generation against the provider APIs, replicating
+// how OpenCode authenticates each of them (see the plugin auth loaders in the
+// opencode repo). auth.json credentials never leave this process.
+
+const REQUEST_TIMEOUT_MS = 60_000;
+// Generous default: thinking models that can't be switched off (DeepSeek,
+// Qwen, …) spend part of this budget on reasoning before the actual answer.
+const DEFAULT_MAX_OUTPUT_TOKENS = 4_000;
+
+const USER_AGENT = 'opencode/1.0 openchamber';
+
+const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
+const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
+const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
+
+const httpError = async (response, provider) => {
+ const body = await response.text().catch(() => '');
+ const snippet = body ? `: ${body.slice(0, 300)}` : '';
+ return new Error(`${provider} request failed with ${response.status}${snippet}`);
+};
+
+// ---------------------------------------------------------------------------
+// OpenAI OAuth (ChatGPT plan / codex) token refresh — single-flight, with the
+// refreshed token written back to auth.json exactly like OpenCode does.
+// ---------------------------------------------------------------------------
+
+let openaiRefreshPromise = null;
+
+const decodeJwtClaims = (token) => {
+ try {
+ const payload = token.split('.')[1];
+ return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
+ } catch {
+ return null;
+ }
+};
+
+const extractChatgptAccountId = (accessToken) => {
+ const claims = decodeJwtClaims(accessToken);
+ const auth = claims?.['https://api.openai.com/auth'];
+ const value = auth?.chatgpt_account_id;
+ return typeof value === 'string' && value ? value : null;
+};
+
+const refreshOpenaiOauth = async (entry) => {
+ if (!openaiRefreshPromise) {
+ openaiRefreshPromise = (async () => {
+ const response = await fetch(CODEX_TOKEN_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ grant_type: 'refresh_token',
+ refresh_token: entry.refresh,
+ client_id: CODEX_CLIENT_ID,
+ }),
+ signal: AbortSignal.timeout(30_000),
+ });
+ if (!response.ok) {
+ throw await httpError(response, 'OpenAI token refresh');
+ }
+ const payload = await response.json();
+ const access = typeof payload?.access_token === 'string' ? payload.access_token : '';
+ if (!access) {
+ throw new Error('OpenAI token refresh returned no access token');
+ }
+ const refreshed = {
+ ...entry,
+ type: 'oauth',
+ access,
+ refresh: typeof payload?.refresh_token === 'string' && payload.refresh_token
+ ? payload.refresh_token
+ : entry.refresh,
+ expires: Date.now() + (Number(payload?.expires_in) > 0 ? Number(payload.expires_in) : 3600) * 1000,
+ };
+ const auth = readAuthFile();
+ auth.openai = refreshed;
+ writeAuthFile(auth);
+ return refreshed;
+ })().finally(() => {
+ openaiRefreshPromise = null;
+ });
+ }
+ return openaiRefreshPromise;
+};
+
+const ensureFreshOpenaiOauth = async (entry) => {
+ if (entry.access && Number(entry.expires) > Date.now()) {
+ return entry;
+ }
+ if (!entry.refresh) {
+ throw new Error('OpenAI OAuth entry has no refresh token');
+ }
+ return refreshOpenaiOauth(entry);
+};
+
+// ---------------------------------------------------------------------------
+// Wire formats
+// ---------------------------------------------------------------------------
+
+const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, extraBody }) => {
+ const trimmedBase = baseURL.replace(/\/+$/, '');
+ const response = await fetch(`${trimmedBase}/chat/completions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ ...headers,
+ },
+ body: JSON.stringify({
+ model: modelID,
+ messages: [
+ ...(system ? [{ role: 'system', content: system }] : []),
+ { role: 'user', content: prompt },
+ ],
+ max_tokens: maxOutputTokens,
+ stream: false,
+ ...(extraBody || {}),
+ }),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ if (!response.ok) {
+ throw await httpError(response, providerLabel);
+ }
+ const payload = await response.json();
+ const message = payload?.choices?.[0]?.message;
+
+ // Providers disagree on the content shape: plain string, an array of
+ // typed parts, or (thinking models) an empty content with the budget spent
+ // on reasoning_content.
+ let text = '';
+ if (typeof message?.content === 'string') {
+ text = message.content;
+ } else if (Array.isArray(message?.content)) {
+ text = message.content
+ .map((part) => (typeof part?.text === 'string' ? part.text : ''))
+ .join('');
+ }
+ if (!text.trim() && typeof message?.reasoning_content === 'string' && message.reasoning_content.trim()) {
+ const finishReason = payload?.choices?.[0]?.finish_reason;
+ throw new Error(
+ `${providerLabel} spent the output budget on reasoning and returned no answer`
+ + (finishReason ? ` (finish_reason: ${finishReason})` : ''),
+ );
+ }
+ if (!text.trim()) {
+ throw new Error(`${providerLabel} returned no message content`);
+ }
+ return text;
+};
+
+const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => {
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ 'x-api-key': apiKey,
+ 'anthropic-version': '2023-06-01',
+ },
+ body: JSON.stringify({
+ model: modelID,
+ max_tokens: maxOutputTokens,
+ ...(system ? { system } : {}),
+ messages: [{ role: 'user', content: prompt }],
+ }),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ if (!response.ok) {
+ throw await httpError(response, 'Anthropic');
+ }
+ const payload = await response.json();
+ const text = (payload?.content || [])
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
+ .map((part) => part.text)
+ .join('');
+ if (!text) {
+ throw new Error('Anthropic returned no text content');
+ }
+ return text;
+};
+
+const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => {
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ 'x-goog-api-key': apiKey,
+ },
+ body: JSON.stringify({
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
+ ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
+ // thinkingBudget 0 switches Gemini Flash thinking off; Flash is the only
+ // family the small-model resolver picks for Google.
+ generationConfig: { maxOutputTokens, thinkingConfig: { thinkingBudget: 0 } },
+ }),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ if (!response.ok) {
+ throw await httpError(response, 'Google');
+ }
+ const payload = await response.json();
+ const text = (payload?.candidates?.[0]?.content?.parts || [])
+ .map((part) => (typeof part?.text === 'string' ? part.text : ''))
+ .join('');
+ if (!text) {
+ throw new Error('Google returned no text content');
+ }
+ return text;
+};
+
+// ChatGPT-plan traffic goes to the codex backend, which only speaks the
+// streaming Responses API — collect the output_text deltas from the SSE body.
+const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, system }) => {
+ const response = await fetch(CODEX_RESPONSES_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'text/event-stream',
+ Authorization: `Bearer ${accessToken}`,
+ ...(accountId ? { 'ChatGPT-Account-Id': accountId } : {}),
+ originator: 'opencode',
+ 'User-Agent': USER_AGENT,
+ },
+ body: JSON.stringify({
+ model: modelID,
+ ...(system ? { instructions: system } : {}),
+ input: [
+ {
+ type: 'message',
+ role: 'user',
+ content: [{ type: 'input_text', text: prompt }],
+ },
+ ],
+ // The codex backend rejects max_output_tokens (OpenCode forces it to
+ // undefined for this provider too).
+ stream: true,
+ store: false,
+ }),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ if (!response.ok) {
+ throw await httpError(response, 'OpenAI (ChatGPT plan)');
+ }
+
+ const raw = await response.text();
+ let text = '';
+ let completedText = '';
+ for (const line of raw.split('\n')) {
+ if (!line.startsWith('data:')) continue;
+ const data = line.slice(5).trim();
+ if (!data || data === '[DONE]') continue;
+ let event;
+ try {
+ event = JSON.parse(data);
+ } catch {
+ continue;
+ }
+ if (event?.type === 'response.output_text.delta' && typeof event.delta === 'string') {
+ text += event.delta;
+ }
+ if (event?.type === 'response.output_text.done' && typeof event.text === 'string') {
+ completedText = event.text;
+ }
+ if (event?.type === 'response.failed' || event?.type === 'error') {
+ const message = event?.response?.error?.message || event?.message || 'response failed';
+ throw new Error(`OpenAI (ChatGPT plan) stream error: ${message}`);
+ }
+ }
+ const result = completedText || text;
+ if (!result) {
+ throw new Error('OpenAI (ChatGPT plan) returned no text output');
+ }
+ return result;
+};
+
+// ---------------------------------------------------------------------------
+// Dispatch
+// ---------------------------------------------------------------------------
+
+export async function callSmallModel({ auth, catalog, providerID, modelID, prompt, system, maxOutputTokens }) {
+ const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
+ const entry = getAuthEntryForProvider(auth, providerID);
+ if (!entry) {
+ throw new Error(`No OpenCode login found for provider "${providerID}"`);
+ }
+
+ if (providerID === 'github-copilot') {
+ // OpenCode uses the stored device-OAuth token directly as the bearer —
+ // access === refresh, no exchange, no expiry.
+ const token = entry.refresh || entry.access || entry.key;
+ if (!token) {
+ throw new Error('GitHub Copilot login has no token');
+ }
+ const baseURL = entry.enterpriseUrl
+ ? `https://copilot-api.${String(entry.enterpriseUrl).replace(/^https?:\/\//, '').replace(/\/+$/, '')}`
+ : 'https://api.githubcopilot.com';
+ return callOpenaiCompatible({
+ baseURL,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'User-Agent': USER_AGENT,
+ 'Openai-Intent': 'conversation-edits',
+ 'x-initiator': 'agent',
+ 'X-GitHub-Api-Version': '2026-06-01',
+ },
+ modelID,
+ prompt,
+ system,
+ maxOutputTokens: tokens,
+ providerLabel: 'GitHub Copilot',
+ });
+ }
+
+ if (providerID === 'openai' && entry.type === 'oauth') {
+ const fresh = await ensureFreshOpenaiOauth(entry);
+ return callCodexResponses({
+ accessToken: fresh.access,
+ accountId: fresh.accountId || extractChatgptAccountId(fresh.access),
+ modelID,
+ prompt,
+ system,
+ });
+ }
+
+ const apiKey = entry.type === 'api' ? entry.key
+ : entry.type === 'wellknown' ? entry.token
+ : entry.access;
+ if (!apiKey) {
+ throw new Error(`OpenCode login for "${providerID}" has no usable credential`);
+ }
+
+ if (providerID === 'anthropic') {
+ return callAnthropic({ apiKey, modelID, prompt, system, maxOutputTokens: tokens });
+ }
+ if (providerID === 'google') {
+ return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens });
+ }
+
+ // Everything else: OpenAI-compatible chat completions against the catalog's
+ // base URL for that provider (openai itself included).
+ const provider = getCatalogProvider(catalog, providerID);
+ const baseURL = providerID === 'openai'
+ ? 'https://api.openai.com/v1'
+ : typeof provider?.api === 'string' && provider.api
+ ? provider.api
+ : null;
+ if (!baseURL) {
+ throw new Error(`Provider "${providerID}" has no known API base URL`);
+ }
+
+ // Thinking models burn the output budget on reasoning and leave content
+ // empty — disable thinking where a wire-format switch exists (mirrors
+ // OpenCode's smallOptions/variants special cases). There is NO universal
+ // parameter: unknown body fields 400 on some providers, so this stays an
+ // explicit allowlist. Models without a switch (DeepSeek, Qwen, Kimi, …)
+ // just get the generous output budget.
+ const lowerModel = modelID.toLowerCase();
+ const supportsThinkingToggle = providerID.includes('zai')
+ || providerID.includes('zhipu')
+ || lowerModel.includes('glm')
+ || lowerModel.includes('minimax-m3');
+ const extraBody = supportsThinkingToggle ? { thinking: { type: 'disabled' } } : undefined;
+
+ return callOpenaiCompatible({
+ baseURL,
+ headers: { Authorization: `Bearer ${apiKey}` },
+ modelID,
+ prompt,
+ system,
+ maxOutputTokens: tokens,
+ providerLabel: provider?.name || providerID,
+ extraBody,
+ });
+}
diff --git a/packages/web/server/lib/small-model/catalog.js b/packages/web/server/lib/small-model/catalog.js
new file mode 100644
index 00000000..abfba4c4
--- /dev/null
+++ b/packages/web/server/lib/small-model/catalog.js
@@ -0,0 +1,13 @@
+import { getModelsMetadata } from '../opencode/models-metadata.js';
+
+// The models.dev catalog is shared with the /api/openchamber/models-metadata
+// route through one in-process cache — no extra fetches, no cache files.
+export async function getModelCatalog() {
+ const { metadata } = await getModelsMetadata();
+ return metadata;
+}
+
+export function getCatalogProvider(catalog, providerID) {
+ const entry = catalog?.[providerID];
+ return entry && typeof entry === 'object' ? entry : null;
+}
diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js
new file mode 100644
index 00000000..43457c32
--- /dev/null
+++ b/packages/web/server/lib/small-model/index.js
@@ -0,0 +1,167 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { readAuthFile } from '../opencode/auth.js';
+import { readConfigLayers } from '../opencode/shared.js';
+import { getModelCatalog } from './catalog.js';
+import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
+import { callSmallModel } from './call.js';
+
+const OPENCHAMBER_SETTINGS_FILE = path.join(
+ process.env.OPENCHAMBER_DATA_DIR
+ ? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
+ : path.join(os.homedir(), '.config', 'openchamber'),
+ 'settings.json',
+);
+
+// OpenChamber's own settings: when the user unchecks "use default small model"
+// their explicit override outranks every other resolution step.
+const readSmallModelSettingsOverride = () => {
+ try {
+ const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
+ const settings = JSON.parse(raw);
+ if (!settings || typeof settings !== 'object') return null;
+ if (settings.smallModelUseDefault !== false) return null;
+ const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
+ return override || null;
+ } catch {
+ return null;
+ }
+};
+
+// Rough safety clamp so a huge input never blows the model's context window.
+// Token estimate is ~4 chars/token; when the catalog has no limit for the
+// model (Copilot/codex utility models are not listed) a conservative default
+// applies.
+const DEFAULT_CONTEXT_TOKENS = 64_000;
+const OUTPUT_RESERVE_TOKENS = 4_000;
+
+const clampPromptToModelLimit = ({ prompt, catalog, providerID, modelID }) => {
+ const limit = catalog?.[providerID]?.models?.[modelID]?.limit;
+ const contextTokens = Number(limit?.context) > 0 ? Number(limit.context) : DEFAULT_CONTEXT_TOKENS;
+ const inputBudgetTokens = Math.max(1_000, contextTokens - OUTPUT_RESERVE_TOKENS);
+ const maxChars = inputBudgetTokens * 4;
+ if (prompt.length <= maxChars) {
+ return { prompt, truncated: false };
+ }
+ return { prompt: `${prompt.slice(0, maxChars)}…`, truncated: true };
+};
+
+const readConfiguredSmallModel = (workingDirectory) => {
+ try {
+ const { mergedConfig } = readConfigLayers(workingDirectory);
+ const value = mergedConfig?.small_model;
+ return typeof value === 'string' ? value : null;
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Generates text with the user's small model, resolved and authenticated
+ * entirely server-side from the OpenCode config and auth store.
+ */
+export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false }) {
+ if (typeof prompt !== 'string' || !prompt.trim()) {
+ throw Object.assign(new Error('prompt is required'), { statusCode: 400 });
+ }
+
+ const auth = readAuthFile();
+ const catalog = await getModelCatalog().catch(() => ({}));
+
+ const explicit = parseModelRef(model);
+ const resolved = explicit
+ ? { ...explicit, source: 'request' }
+ : resolveSmallModel({
+ auth,
+ catalog,
+ settingsSmallModel: readSmallModelSettingsOverride(),
+ configSmallModel: readConfiguredSmallModel(directory),
+ preferredProviderID,
+ preferredModelID,
+ });
+
+ if (!resolved) {
+ throw Object.assign(
+ new Error('No small model available — no authenticated provider has a suitable model'),
+ { statusCode: 404 },
+ );
+ }
+
+ // Callers with a session context can forbid silently switching providers:
+ // an explicit user choice (settings override, opencode config, request
+ // model) is always allowed, anything else must stay on the session's
+ // provider.
+ if (restrictToPreferredProvider
+ && !['settings', 'config', 'request'].includes(resolved.source)
+ && resolved.providerID !== preferredProviderID) {
+ throw Object.assign(
+ new Error('No small model available within the session provider'),
+ { statusCode: 404 },
+ );
+ }
+
+ const clamped = clampPromptToModelLimit({
+ prompt: prompt.trim(),
+ catalog,
+ providerID: resolved.providerID,
+ modelID: resolved.modelID,
+ });
+
+ const text = await callSmallModel({
+ auth,
+ catalog,
+ providerID: resolved.providerID,
+ modelID: resolved.modelID,
+ prompt: clamped.prompt,
+ system: typeof system === 'string' && system.trim() ? system.trim() : undefined,
+ maxOutputTokens,
+ });
+
+ return {
+ text: text.trim(),
+ providerID: resolved.providerID,
+ modelID: resolved.modelID,
+ source: resolved.source,
+ ...(clamped.truncated ? { inputTruncated: true } : {}),
+ };
+}
+
+/**
+ * Provider ids with a usable OpenCode login — the set the small model can
+ * actually call. Used by the settings override picker to hide providers that
+ * would only ever fail (e.g. opencode free models without a token).
+ */
+export function listAuthenticatedProviders() {
+ try {
+ const auth = readAuthFile();
+ const ids = new Set(
+ Object.keys(auth || {}).filter((providerID) => isUsableAuthEntry(auth[providerID])),
+ );
+ // The catalog id is github-copilot while legacy auth entries may sit
+ // under the copilot alias.
+ if (isUsableAuthEntry(getAuthEntryForProvider(auth, 'github-copilot'))) {
+ ids.add('github-copilot');
+ }
+ return Array.from(ids);
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Reports which model would be used, without calling it.
+ */
+export async function describeSmallModel({ directory, preferredProviderID, preferredModelID } = {}) {
+ const auth = readAuthFile();
+ const catalog = await getModelCatalog().catch(() => ({}));
+ const resolved = resolveSmallModel({
+ auth,
+ catalog,
+ settingsSmallModel: readSmallModelSettingsOverride(),
+ configSmallModel: readConfiguredSmallModel(directory),
+ preferredProviderID,
+ preferredModelID,
+ });
+ return resolved;
+}
diff --git a/packages/web/server/lib/small-model/resolve.js b/packages/web/server/lib/small-model/resolve.js
new file mode 100644
index 00000000..4d4a2520
--- /dev/null
+++ b/packages/web/server/lib/small-model/resolve.js
@@ -0,0 +1,131 @@
+import { getCatalogProvider } from './catalog.js';
+
+// Mirrors OpenCode's getSmallModel fallback chain:
+// 1. `small_model` from the merged config layers ("provider/model").
+// 2. GitHub Copilot's hidden utility models when Copilot is logged in.
+// 3. Family-priority scan of the authenticated providers' catalog models.
+const FAMILY_PRIORITY = ['gemini-flash', 'gpt-nano', 'claude-haiku'];
+const COPILOT_UTILITY_MODELS = ['gpt-5.4-nano', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini'];
+// The ChatGPT-plan codex backend only accepts a small allowlist of models
+// (nano/API-key models are rejected with 400) — this is its cheapest one.
+const OPENAI_OAUTH_SMALL_MODEL = 'gpt-5.4-mini';
+
+const AUTH_PROVIDER_ALIASES = {
+ 'github-copilot': ['github-copilot', 'copilot'],
+};
+
+export function getAuthEntryForProvider(auth, providerID) {
+ const aliases = AUTH_PROVIDER_ALIASES[providerID] || [providerID];
+ for (const alias of aliases) {
+ const entry = auth?.[alias];
+ if (entry && typeof entry === 'object') {
+ return entry;
+ }
+ }
+ return null;
+}
+
+export function isUsableAuthEntry(entry) {
+ if (!entry || typeof entry !== 'object') return false;
+ if (entry.type === 'api') return typeof entry.key === 'string' && entry.key.length > 0;
+ if (entry.type === 'oauth') {
+ return (typeof entry.access === 'string' && entry.access.length > 0)
+ || (typeof entry.refresh === 'string' && entry.refresh.length > 0);
+ }
+ if (entry.type === 'wellknown') return typeof entry.token === 'string' && entry.token.length > 0;
+ return false;
+}
+
+export function parseModelRef(value) {
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ const slash = trimmed.indexOf('/');
+ if (slash <= 0 || slash === trimmed.length - 1) return null;
+ return {
+ providerID: trimmed.slice(0, slash),
+ modelID: trimmed.slice(slash + 1),
+ };
+}
+
+const pickByFamily = (models, family) => {
+ const matches = Object.values(models)
+ .filter((model) => model && typeof model === 'object' && model.family === family);
+ if (matches.length === 0) return null;
+ matches.sort((a, b) => String(b.release_date || '').localeCompare(String(a.release_date || '')));
+ return matches[0];
+};
+
+// Small-model candidates within ONE provider, by family priority. Copilot and
+// ChatGPT-plan OpenAI have fixed small models that never appear in the
+// catalog; everyone else is scanned through the catalog families.
+const pickWithinProvider = (providerID, auth, catalog, family) => {
+ if (providerID === 'openai' && auth.openai?.type === 'oauth') {
+ return family === 'gpt-nano'
+ ? { providerID, modelID: OPENAI_OAUTH_SMALL_MODEL, source: 'codex-small' }
+ : null;
+ }
+ if (providerID === 'github-copilot') {
+ return family === 'gpt-nano'
+ ? { providerID, modelID: COPILOT_UTILITY_MODELS[0], source: 'copilot-utility' }
+ : null;
+ }
+ const provider = getCatalogProvider(catalog, providerID);
+ if (!provider || !provider.models || typeof provider.models !== 'object') return null;
+ const model = pickByFamily(provider.models, family);
+ return model?.id ? { providerID, modelID: model.id, source: 'family-scan' } : null;
+};
+
+export function resolveSmallModel({ auth, catalog, settingsSmallModel, configSmallModel, preferredProviderID, preferredModelID }) {
+ // OpenChamber's own setting (Settings → Sessions → Small Model override)
+ // outranks everything, including the OpenCode config.
+ const fromSettings = parseModelRef(settingsSmallModel);
+ if (fromSettings) {
+ return { ...fromSettings, source: 'settings' };
+ }
+
+ const explicit = parseModelRef(configSmallModel);
+ if (explicit) {
+ return { ...explicit, source: 'config' };
+ }
+
+ // Like OpenCode: when the caller has a session context, the utility call
+ // stays on the session's provider. Scan its families for a small model,
+ // otherwise run on the session's own model — never silently switch to a
+ // different provider's subscription.
+ const preferred = typeof preferredProviderID === 'string' && preferredProviderID
+ ? preferredProviderID
+ : null;
+ if (preferred && isUsableAuthEntry(getAuthEntryForProvider(auth, preferred))) {
+ for (const family of FAMILY_PRIORITY) {
+ const match = pickWithinProvider(preferred, auth, catalog, family);
+ if (match) return match;
+ }
+ if (typeof preferredModelID === 'string' && preferredModelID) {
+ return { providerID: preferred, modelID: preferredModelID, source: 'session-model' };
+ }
+ }
+
+ // No session context (or its provider has no usable login): scan all
+ // authenticated providers by family priority.
+ const authedProviders = Object.keys(auth || {}).filter((providerID) =>
+ providerID !== preferred && isUsableAuthEntry(auth[providerID]));
+
+ for (const family of FAMILY_PRIORITY) {
+ for (const providerID of authedProviders) {
+ const match = pickWithinProvider(providerID, auth, catalog, family);
+ if (match) return match;
+ }
+ }
+
+ // Copilot's utility fallback for legacy auth aliases the loop above missed.
+ const copilotEntry = getAuthEntryForProvider(auth, 'github-copilot');
+ if (isUsableAuthEntry(copilotEntry)) {
+ return {
+ providerID: 'github-copilot',
+ modelID: COPILOT_UTILITY_MODELS[0],
+ source: 'copilot-utility',
+ };
+ }
+
+ return null;
+}
diff --git a/packages/web/server/lib/small-model/resolve.test.js b/packages/web/server/lib/small-model/resolve.test.js
new file mode 100644
index 00000000..5e5d1299
--- /dev/null
+++ b/packages/web/server/lib/small-model/resolve.test.js
@@ -0,0 +1,197 @@
+import { describe, it, expect } from 'bun:test';
+import { resolveSmallModel, parseModelRef, isUsableAuthEntry } from './resolve.js';
+
+const catalog = {
+ google: {
+ id: 'google',
+ models: {
+ 'gemini-2.5-flash': { id: 'gemini-2.5-flash', family: 'gemini-flash', release_date: '2025-06-01' },
+ 'gemini-2.0-flash': { id: 'gemini-2.0-flash', family: 'gemini-flash', release_date: '2024-12-01' },
+ 'gemini-2.5-pro': { id: 'gemini-2.5-pro', family: 'gemini-pro', release_date: '2025-06-01' },
+ },
+ },
+ anthropic: {
+ id: 'anthropic',
+ models: {
+ 'claude-haiku-4-5': { id: 'claude-haiku-4-5', family: 'claude-haiku', release_date: '2025-10-01' },
+ 'claude-sonnet-4-5': { id: 'claude-sonnet-4-5', family: 'claude-sonnet', release_date: '2025-09-01' },
+ },
+ },
+};
+
+describe('parseModelRef', () => {
+ it('splits provider/model on the first slash', () => {
+ expect(parseModelRef('anthropic/claude-haiku-4-5')).toEqual({
+ providerID: 'anthropic',
+ modelID: 'claude-haiku-4-5',
+ });
+ });
+
+ it('keeps slashes inside the model id', () => {
+ expect(parseModelRef('openrouter/google/gemini-2.5-flash')).toEqual({
+ providerID: 'openrouter',
+ modelID: 'google/gemini-2.5-flash',
+ });
+ });
+
+ it('rejects values without a provider or model part', () => {
+ expect(parseModelRef('anthropic/')).toBeNull();
+ expect(parseModelRef('/model')).toBeNull();
+ expect(parseModelRef('plain')).toBeNull();
+ expect(parseModelRef(undefined)).toBeNull();
+ });
+});
+
+describe('isUsableAuthEntry', () => {
+ it('accepts api keys, oauth tokens, and wellknown tokens', () => {
+ expect(isUsableAuthEntry({ type: 'api', key: 'sk-x' })).toBe(true);
+ expect(isUsableAuthEntry({ type: 'oauth', access: 'a', refresh: 'r', expires: 0 })).toBe(true);
+ expect(isUsableAuthEntry({ type: 'wellknown', key: 'k', token: 't' })).toBe(true);
+ });
+
+ it('rejects empty or malformed entries', () => {
+ expect(isUsableAuthEntry({ type: 'api', key: '' })).toBe(false);
+ expect(isUsableAuthEntry({ type: 'oauth' })).toBe(false);
+ expect(isUsableAuthEntry(null)).toBe(false);
+ });
+});
+
+describe('resolveSmallModel', () => {
+ it('gives the OpenChamber settings override top priority', () => {
+ const result = resolveSmallModel({
+ auth: { anthropic: { type: 'api', key: 'sk-x' } },
+ catalog,
+ settingsSmallModel: 'anthropic/claude-haiku-4-5',
+ configSmallModel: 'openai/gpt-4o-mini',
+ preferredProviderID: 'anthropic',
+ });
+ expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'settings' });
+ });
+
+ it('prefers the configured small_model', () => {
+ const result = resolveSmallModel({
+ auth: { anthropic: { type: 'api', key: 'sk-x' } },
+ catalog,
+ configSmallModel: 'openai/gpt-4o-mini',
+ });
+ expect(result).toEqual({ providerID: 'openai', modelID: 'gpt-4o-mini', source: 'config' });
+ });
+
+ it('scans authenticated providers by family priority, newest first', () => {
+ const result = resolveSmallModel({
+ auth: {
+ google: { type: 'api', key: 'g-key' },
+ anthropic: { type: 'api', key: 'sk-x' },
+ },
+ catalog,
+ configSmallModel: null,
+ });
+ expect(result).toEqual({ providerID: 'google', modelID: 'gemini-2.5-flash', source: 'family-scan' });
+ });
+
+ it('skips providers without a usable credential', () => {
+ const result = resolveSmallModel({
+ auth: {
+ google: { type: 'api', key: '' },
+ anthropic: { type: 'api', key: 'sk-x' },
+ },
+ catalog,
+ configSmallModel: null,
+ });
+ expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'family-scan' });
+ });
+
+ it('falls back to Copilot utility models when only Copilot is logged in', () => {
+ const result = resolveSmallModel({
+ auth: { 'github-copilot': { type: 'oauth', access: 't', refresh: 't', expires: 0 } },
+ catalog,
+ configSmallModel: null,
+ });
+ expect(result?.providerID).toBe('github-copilot');
+ expect(result?.source).toBe('copilot-utility');
+ });
+
+ it('returns null when nothing is authenticated', () => {
+ expect(resolveSmallModel({ auth: {}, catalog, configSmallModel: null })).toBeNull();
+ });
+
+ it('prefers the session provider over other authenticated providers', () => {
+ const result = resolveSmallModel({
+ auth: {
+ google: { type: 'api', key: 'g-key' },
+ anthropic: { type: 'api', key: 'sk-x' },
+ },
+ catalog,
+ configSmallModel: null,
+ preferredProviderID: 'anthropic',
+ });
+ expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'family-scan' });
+ });
+
+ it('ignores a preferred provider without a usable login', () => {
+ const result = resolveSmallModel({
+ auth: { google: { type: 'api', key: 'g-key' } },
+ catalog,
+ configSmallModel: null,
+ preferredProviderID: 'anthropic',
+ });
+ expect(result).toEqual({ providerID: 'google', modelID: 'gemini-2.5-flash', source: 'family-scan' });
+ });
+
+ it('never uses a session provider without a login (opencode free models)', () => {
+ // Vanilla setups default the picker to opencode/big-pickle with no
+ // opencode token — those free models only work through OpenCode itself
+ // and must never be called directly, so the session context is ignored.
+ const result = resolveSmallModel({
+ auth: { openai: { type: 'oauth', access: 'a', refresh: 'r', expires: Date.now() + 60_000 } },
+ catalog,
+ configSmallModel: null,
+ preferredProviderID: 'opencode',
+ preferredModelID: 'big-pickle',
+ });
+ expect(result).toEqual({ providerID: 'openai', modelID: 'gpt-5.4-mini', source: 'codex-small' });
+ });
+
+ it('resolves nothing on a vanilla setup with no logins at all', () => {
+ const result = resolveSmallModel({
+ auth: {},
+ catalog,
+ configSmallModel: null,
+ preferredProviderID: 'opencode',
+ preferredModelID: 'big-pickle',
+ });
+ expect(result).toBeNull();
+ });
+
+ it('falls back to the session model instead of scanning other providers', () => {
+ const result = resolveSmallModel({
+ auth: {
+ 'opencode-go': { type: 'api', key: 'oc-key' },
+ openai: { type: 'oauth', access: 'a', refresh: 'r', expires: Date.now() + 60_000 },
+ },
+ catalog: {
+ 'opencode-go': {
+ id: 'opencode-go',
+ models: {
+ 'deepseek-v4-flash': { id: 'deepseek-v4-flash', family: 'deepseek-flash', release_date: '2026-01-01' },
+ },
+ },
+ },
+ configSmallModel: null,
+ preferredProviderID: 'opencode-go',
+ preferredModelID: 'deepseek-v4-flash',
+ });
+ expect(result).toEqual({ providerID: 'opencode-go', modelID: 'deepseek-v4-flash', source: 'session-model' });
+ });
+
+ it('falls back to the session model itself when nothing resolves', () => {
+ const result = resolveSmallModel({
+ auth: { mistral: { type: 'api', key: 'm-key' } },
+ catalog,
+ configSmallModel: null,
+ preferredProviderID: 'mistral',
+ preferredModelID: 'mistral-large-latest',
+ });
+ expect(result).toEqual({ providerID: 'mistral', modelID: 'mistral-large-latest', source: 'session-model' });
+ });
+});
diff --git a/packages/web/server/lib/small-model/routes.js b/packages/web/server/lib/small-model/routes.js
new file mode 100644
index 00000000..c53143de
--- /dev/null
+++ b/packages/web/server/lib/small-model/routes.js
@@ -0,0 +1,44 @@
+export function registerSmallModelRoutes(app, { getSmallModelService }) {
+ app.get('/api/small-model', async (req, res) => {
+ try {
+ const { describeSmallModel, listAuthenticatedProviders } = await getSmallModelService();
+ const resolved = await describeSmallModel({
+ directory: typeof req.query.directory === 'string' ? req.query.directory : undefined,
+ preferredProviderID: typeof req.query.providerID === 'string' ? req.query.providerID : undefined,
+ preferredModelID: typeof req.query.modelID === 'string' ? req.query.modelID : undefined,
+ });
+ res.json({
+ available: Boolean(resolved),
+ model: resolved,
+ authenticatedProviders: listAuthenticatedProviders(),
+ });
+ } catch (error) {
+ console.error('Failed to resolve small model:', error);
+ res.status(500).json({ error: error.message || 'Failed to resolve small model' });
+ }
+ });
+
+ app.post('/api/small-model/generate', async (req, res) => {
+ try {
+ const { generateSmallModelText } = await getSmallModelService();
+ const { prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {};
+ const result = await generateSmallModelText({
+ prompt,
+ system,
+ maxOutputTokens,
+ model,
+ directory,
+ preferredProviderID,
+ preferredModelID,
+ restrictToPreferredProvider: restrictToPreferredProvider === true,
+ });
+ res.json(result);
+ } catch (error) {
+ const statusCode = Number(error?.statusCode) || 500;
+ if (statusCode >= 500) {
+ console.error('Small model generation failed:', error);
+ }
+ res.status(statusCode).json({ error: error.message || 'Small model generation failed' });
+ }
+ });
+}
diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs
index c530d099..06ca5441 100755
--- a/scripts/oc-dev.mjs
+++ b/scripts/oc-dev.mjs
@@ -173,6 +173,18 @@ function step(label, fn) {
return result;
}
+function printReleaseNextSteps(version) {
+ log.success(`Release v${version} prepared locally`);
+ log.info('Next steps:');
+ console.log(` git add -A`);
+ console.log(` git commit -m "release v${version}"`);
+ console.log(` git tag v${version}`);
+ console.log(` git push origin main --tags`);
+ console.log('');
+ console.log('This will trigger the GitHub Actions release workflow.');
+ console.log(`Make sure CHANGELOG.md contains a section like "## [${version}] - YYYY-MM-DD" before pushing.`);
+}
+
function normalizeAction(action = '') {
const normalized = action.toLowerCase();
const aliases = {
@@ -575,7 +587,7 @@ async function createRelease(options) {
if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) throw new Error('Invalid version format. Use semver, e.g. 1.4.7 or 1.4.7-beta.1');
step('Validating codebase', () => run('bun', ['run', 'release:prepare']));
step(`Bumping version to ${version}`, () => run('node', ['scripts/bump-version.mjs', version]));
- log.success(`Release v${version} prepared locally`);
+ printReleaseNextSteps(version);
}
async function chooseAction(config) {