diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e157f1..556dc51b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.21] - 2026-08-04 + +### Added + +- πŸ“Š **Usage has its own Settings page.** See your token activity, active streaks, favorite models, busy days, and most used tools in one place. +- πŸ› οΈ **Tool approval has its own Admin page.** Admins can choose the review model and decide which built-in tools can run automatically. + +### Changed + +- πŸ€– **Agent chats can start from Home.** Coding agents can now answer even when a chat is not attached to a workspace. +- 🌐 **Browser sessions recover more smoothly.** Browser streaming catches up faster when video falls behind and cleans up more reliably when a session closes. +- 🧰 **OpenCode on Docker is easier to connect.** Computer now tries the right host address when needed and gives clearer setup guidance if it cannot connect. +- πŸ“ **More C and C++ files open with the right language.** Files ending in `.cc`, `.cxx`, `.hh`, and `.hxx` are now recognized. + +### Fixed + +- πŸ’¬ **Empty replies are retried instead of getting stuck.** If a chat service sends back nothing useful, Computer now tries again before showing a failure. +- πŸ”Š **Text-to-speech cleans up after playback.** Listening to a message no longer leaves old audio resources around. +- 🧰 **Deleted agent profiles stay deleted right away.** Removing a saved agent now saves the change immediately. +- πŸ’» **Terminal and browser sessions close more cleanly.** Leaving a session is less likely to leave old listeners or connections behind. +- πŸ“± **Signal message edits work more naturally.** Updated replies are now sent as edits when Signal supports it. + ## [0.9.20] - 2026-08-01 ### Added diff --git a/cptr/frontend/src/lib/apis/admin.ts b/cptr/frontend/src/lib/apis/admin.ts index b5674534..bbf41519 100644 --- a/cptr/frontend/src/lib/apis/admin.ts +++ b/cptr/frontend/src/lib/apis/admin.ts @@ -211,6 +211,27 @@ export const updateModelConfig = ( method: 'PUT' }); +// ── Tool Approval ─────────────────────────────────────────── + +export type ToolApprovalPolicy = 'allow' | 'review'; + +export interface ToolApprovalGroup { + id: string; + tools: { + name: string; + default_approval: ToolApprovalPolicy | null; + }[]; +} + +export interface ToolApprovalResponse { + default_approval: ToolApprovalPolicy; + overrides: Record; + groups: ToolApprovalGroup[]; +} + +export const getToolApproval = async (): Promise => + fetchJSON('/api/admin/tools/approval'); + // ── Tool Servers ──────────────────────────────────────────── export interface ToolServer { diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index 5809ee97..5ebf89b2 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -78,6 +78,41 @@ export interface CompactChatResult { context_usage?: ContextUsage | null; } +export interface UsageHeatmapEntry { + date: string; + tokens: number; + messages: number; + chats: number; + models: Record; +} + +export interface UsageResponse { + totals: { + lifetime_tokens: number; + peak_daily_tokens: number; + longest_chat_seconds: number; + current_streak: number; + longest_streak: number; + models_used: number; + user_messages: number; + assistant_messages: number; + messages: number; + total_chats: number; + }; + insights: { + average_tokens_per_chat: number; + average_messages_per_active_day: number; + user_message_share: number; + assistant_message_share: number; + }; + heatmap: UsageHeatmapEntry[]; + weekly_heatmap: UsageHeatmapEntry[]; + cumulative_heatmap: UsageHeatmapEntry[]; + top_models: { model_id: string; messages: number; total_tokens: number }[]; + top_tools: { name: string; count: number }[]; + period: { start_date: number; end_date: number; days: number }; +} + // ── Queries ───────────────────────────────────────────────── export const getChats = ( @@ -96,6 +131,8 @@ export const getChat = (chatId: string, modelId?: string) => { return fetchJSON(`/api/chats/${chatId}${suffix}`); }; +export const getUsage = () => fetchJSON('/api/chats/usage'); + export const deleteChat = (chatId: string) => fetchJSON<{ ok: boolean }>(`/api/chats/${chatId}`, { method: 'DELETE' }); diff --git a/cptr/frontend/src/lib/components/Admin/Agents.svelte b/cptr/frontend/src/lib/components/Admin/Agents.svelte index 889acea7..6eaa831a 100644 --- a/cptr/frontend/src/lib/components/Admin/Agents.svelte +++ b/cptr/frontend/src/lib/components/Admin/Agents.svelte @@ -167,7 +167,7 @@ modal = null; } - function deleteProfile() { + async function deleteProfile() { const currentModal = modal; if (currentModal?.mode !== 'edit') { modal = null; @@ -181,6 +181,7 @@ deletedProfile = true; } modal = null; + await save(); } function toggleProfile(index: number) { diff --git a/cptr/frontend/src/lib/components/Admin/Chat.svelte b/cptr/frontend/src/lib/components/Admin/Chat.svelte index 2f8f1369..5afde22d 100644 --- a/cptr/frontend/src/lib/components/Admin/Chat.svelte +++ b/cptr/frontend/src/lib/components/Admin/Chat.svelte @@ -12,7 +12,6 @@ let titleGenerationEnabled = $state(true); let titleGenerationModel = $state(null); let contextCompactionModel = $state(null); - let toolApprovalReviewModel = $state(null); let compactTokenThreshold = $state(80000); onMount(async () => { @@ -29,10 +28,6 @@ typeof config['chat.context_compaction.model'] === 'string' ? config['chat.context_compaction.model'] : null; - toolApprovalReviewModel = - typeof config['tool_approval.review.model'] === 'string' - ? config['tool_approval.review.model'] - : null; compactTokenThreshold = Number(config['chat.compact_token_threshold']) || 80000; } catch { toast.error($t('admin.failedToLoadConfig')); @@ -48,7 +43,6 @@ 'chat.title_generation.enabled': titleGenerationEnabled, 'chat.title_generation.model': titleGenerationModel, 'chat.context_compaction.model': contextCompactionModel, - 'tool_approval.review.model': toolApprovalReviewModel, 'chat.compact_token_threshold': Math.max(10000, Number(compactTokenThreshold) || 80000) }); toast.success($t('settings.saved')); @@ -153,28 +147,6 @@

- -

- {$t('admin.toolApproval')} -

-
-
- - {$t('admin.toolApprovalReviewModel')} - -
- -
-
-

- {$t('admin.toolApprovalReviewModelHint')} -

-
diff --git a/cptr/frontend/src/lib/components/Admin/Tools.svelte b/cptr/frontend/src/lib/components/Admin/Tools.svelte new file mode 100644 index 00000000..604eba17 --- /dev/null +++ b/cptr/frontend/src/lib/components/Admin/Tools.svelte @@ -0,0 +1,245 @@ + + +
+ {#if loading} +
+ {:else} +
+

{$t('admin.tools')}

+ +

+ {$t('admin.toolApproval')} +

+
+
+
+ + {$t('admin.toolApprovalReviewModel')} + +
+ +
+
+

+ {$t('admin.toolApprovalReviewModelHint')} +

+
+ +
+
+ + {$t('admin.toolApprovalDefaultBuiltinApproval')} + +
+ + +
+
+

+ {$t('admin.toolApprovalDefaultBuiltinApprovalHint')} +

+
+
+ +
+
+

+ {$t('admin.toolApprovalBuiltinTools')} +

+ {#if Object.keys(overrides).length > 0} + + {/if} +
+
+ {#each groups as group} +
+
+
+ {$t(`models.builtinTools.${group.id}`)} +
+
+ {$t(`models.builtinTools.${group.id}Desc`)} +
+
+
+ {#each group.tools as tool} +
+
+ + {tool.name} + +
+ + + +
+
+

+ {$t('admin.toolApprovalEffective', { + value: approvalLabel(effective(tool)) + })} +

+
+ {/each} +
+
+ {/each} +
+
+
+ +
+ +
+ {/if} +
diff --git a/cptr/frontend/src/lib/components/ChromeBrowser.svelte b/cptr/frontend/src/lib/components/ChromeBrowser.svelte index 4c4c3d0a..d5885b28 100644 --- a/cptr/frontend/src/lib/components/ChromeBrowser.svelte +++ b/cptr/frontend/src/lib/components/ChromeBrowser.svelte @@ -79,6 +79,7 @@ let keepKeyboardFocus = false; let audioClockOrigin: number | undefined; let reapplyViewportAfterConfig = true; + let keyframeRequested = false; const pressedKeys = new Map>(); const touchPoints = new Map>(); const macClient = /Mac|iPhone|iPad/.test(navigator.userAgent); @@ -88,6 +89,52 @@ if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); } + function requestKeyframe() { + if (keyframeRequested) return; + keyframeRequested = true; + send({ type: 'request_keyframe' }); + } + + function closeSocket() { + const current = socket; + socket = undefined; + if (!current) return; + current.onopen = null; + current.onmessage = null; + current.onclose = null; + current.onerror = null; + if (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING) { + current.close(); + } + } + + function resetInputState() { + pressedKeys.clear(); + touchPoints.clear(); + pendingPointer = undefined; + if (pointerFrame) { + cancelAnimationFrame(pointerFrame); + pointerFrame = 0; + } + } + + function resetMediaState() { + try { + decoder?.close(); + } catch {} + try { + audioDecoder?.close(); + } catch {} + decoder = undefined; + audioDecoder = undefined; + audioClockOrigin = undefined; + keyframeRequested = false; + if (audioContext) { + void audioContext.close(); + audioContext = undefined; + } + } + async function collectDeviceProfile(): Promise { const uaData = (navigator as Navigator & { userAgentData?: UserAgentData }).userAgentData; let highEntropy: Record = {}; @@ -140,6 +187,14 @@ function connect() { if (disposed) return; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = undefined; + } + releaseKeys(); + resetInputState(); + resetMediaState(); + closeSocket(); ready = false; lastViewport = ''; reapplyViewportAfterConfig = true; @@ -155,6 +210,7 @@ socket.onmessage = receive; socket.onclose = () => { if (disposed) return; + socket = undefined; onstatus('lost', 'Chrome connection lost'); const delays = [1000, 2000, 5000]; reconnectTimer = setTimeout(connect, delays[Math.min(reconnectAttempt++, delays.length - 1)]); @@ -212,6 +268,11 @@ const media = view.getUint8(0); const timestamp = Number(view.getBigUint64(6)); if (media === 1 && decoder?.state === 'configured') { + // Drop video when WebCodecs is falling behind; a fresh keyframe catches up. + if (decoder.decodeQueueSize > 3) { + requestKeyframe(); + return; + } try { decoder.decode( new EncodedVideoChunk({ @@ -221,7 +282,7 @@ }) ); } catch { - send({ type: 'request_keyframe' }); + requestKeyframe(); } } else if (media === 2 && audioDecoder?.state === 'configured') { try { @@ -248,9 +309,10 @@ hasFrame = true; onstatus('playing'); } + keyframeRequested = false; }, error() { - send({ type: 'request_keyframe' }); + requestKeyframe(); } }); decoder.configure({ codec: config.codec, optimizeForLatency: true }); @@ -637,15 +699,13 @@ onDestroy(() => { disposed = true; releaseKeys(); + resetInputState(); observer?.disconnect(); window.visualViewport?.removeEventListener('resize', visualViewportResize); if (reconnectTimer) clearTimeout(reconnectTimer); if (viewportTimer) clearTimeout(viewportTimer); - if (pointerFrame) cancelAnimationFrame(pointerFrame); - socket?.close(); - decoder?.close(); - audioDecoder?.close(); - if (audioContext) void audioContext.close(); + closeSocket(); + resetMediaState(); }); diff --git a/cptr/frontend/src/lib/components/Icon.svelte b/cptr/frontend/src/lib/components/Icon.svelte index e8d66130..7018ed43 100644 --- a/cptr/frontend/src/lib/components/Icon.svelte +++ b/cptr/frontend/src/lib/components/Icon.svelte @@ -121,6 +121,13 @@ + {:else if name === 'usage'} + + + + + + {:else if name === 'half-moon'} { return new Promise((resolve, reject) => { const img = new Image(); + const url = URL.createObjectURL(file); img.onload = () => { + URL.revokeObjectURL(url); const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 256; @@ -80,8 +82,11 @@ 'image/png' ); }; - img.onerror = () => reject(new Error('Failed to load image')); - img.src = URL.createObjectURL(file); + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load image')); + }; + img.src = url; }); } diff --git a/cptr/frontend/src/lib/components/Settings/Usage.svelte b/cptr/frontend/src/lib/components/Settings/Usage.svelte new file mode 100644 index 00000000..453d9c1f --- /dev/null +++ b/cptr/frontend/src/lib/components/Settings/Usage.svelte @@ -0,0 +1,512 @@ + + +
+
+

{tr('usage.title')}

+
+ + {#if loading} +
+ +
+ {:else if !usage} +
+ {tr('usage.failedToLoad')} +
+ {:else} +
+
+

{tr('usage.overview')}

+
+
+
+ {formatNumber(usage.totals.lifetime_tokens)} +
+
+ {tr('usage.lifetimeTokens')} +
+
+
+
+ {formatNumber(usage.totals.peak_daily_tokens)} +
+
+ {tr('usage.peakTokens')} +
+
+
+
+ {formatDuration(usage.totals.longest_chat_seconds)} +
+
+ {tr('usage.longestActiveChat')} +
+
+
+
+ {usage.totals.current_streak.toLocaleString()} +
+
+ {tr('usage.currentStreak')} +
+
+
+
+ {usage.totals.longest_streak.toLocaleString()} +
+
+ {tr('usage.longestStreak')} +
+
+
+
+ +
+
+

+ {tr('usage.tokenActivity')} +

+
+ {#each heatmapModes as mode} + + {/each} +
+
+ +
+
+
+ {#each heatmapCells as entry} + {#if entry} +
+ {:else} +
+ {/if} + {/each} +
+ +
+ {#each monthLabels as month} +
+ {month.label} +
+ {/each} +
+
+ + {#if usage.top_models.length > 0} +
+ {#each usage.top_models.slice(0, 6) as model} +
+ + {modelName(model.model_id)} +
+ {/each} +
+ {/if} +
+
+ + {#if !hasUsage} +
+

{tr('usage.activity')}

+
{tr('usage.noData')}
+
+ {:else} +
+

+ {tr('usage.activityInsights')} +

+
+
+ {tr('usage.models')} + {usage.totals.models_used.toLocaleString()} +
+
+ {tr('usage.averageTokensPerChat')} + {formatNumber(usage.insights.average_tokens_per_chat)} +
+
+ {tr('usage.averageMessagesPerActiveDay')} + {usage.insights.average_messages_per_active_day.toLocaleString()} +
+
+ {tr('usage.userMessages')} + {usage.totals.user_messages.toLocaleString()} Β· {usage.insights + .user_message_share}% +
+
+ {tr('usage.assistantMessages')} + {usage.totals.assistant_messages.toLocaleString()} Β· {usage.insights + .assistant_message_share}% +
+
+ {tr('usage.totalChats')} + {usage.totals.total_chats.toLocaleString()} +
+
+
+ +
+

{tr('usage.topModels')}

+ {#if usage.top_models.length === 0} +
{tr('usage.noModelUsage')}
+ {:else} +
+ {#each usage.top_models as model} +
+ {modelName(model.model_id)} + + {model.messages.toLocaleString()} + {tr('usage.messages')} Β· {formatNumber(model.total_tokens)} + +
+ {/each} +
+ {/if} +
+ + {#if usage.top_tools.length > 0} +
+

+ {tr('usage.mostUsedTools')} +

+
+ {#each usage.top_tools as tool} +
+ {tool.name} + + {tool.count.toLocaleString()} + {tr('usage.runs')} + +
+ {/each} +
+
+ {/if} + {/if} + +
+ {tr('usage.estimateNote')} +
+
+ {/if} +
diff --git a/cptr/frontend/src/lib/components/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte index 07280d01..28009fa0 100644 --- a/cptr/frontend/src/lib/components/SettingsModal.svelte +++ b/cptr/frontend/src/lib/components/SettingsModal.svelte @@ -5,6 +5,7 @@ import General from './Settings/General.svelte'; import Notifications from './Settings/Notifications.svelte'; import Appearance from './Settings/Appearance.svelte'; + import Usage from './Settings/Usage.svelte'; import Memory from './Settings/Memory.svelte'; import PWA from './Settings/PWA.svelte'; import Account from './Settings/Account.svelte'; @@ -14,6 +15,7 @@ import Agents from './Admin/Agents.svelte'; import Models from './Admin/Models.svelte'; import Chat from './Admin/Chat.svelte'; + import Tools from './Admin/Tools.svelte'; import Git from './Admin/Git.svelte'; import Skills from './Admin/Skills.svelte'; import Messaging from './Admin/Messaging.svelte'; @@ -31,6 +33,7 @@ | 'general' | 'notifications' | 'appearance' + | 'usage' | 'memory' | 'pwa' | 'keyboard' @@ -40,6 +43,7 @@ | 'agents' | 'models' | 'chat' + | 'tools' | 'git' | 'skills' | 'messaging' @@ -71,6 +75,7 @@ 'agents', 'models', 'chat', + 'tools', 'git', 'messaging', 'gateway', @@ -88,6 +93,7 @@ const tabs: SettingsTab[] = [ { id: 'general', label: $t('settings.general'), icon: 'settings' }, { id: 'appearance', label: $t('settings.appearance'), icon: 'sun-light' }, + { id: 'usage', label: 'Usage', icon: 'usage' }, { id: 'notifications', label: $t('general.notifications'), icon: 'chat-bubble' }, { id: 'keyboard', label: $t('settings.keyboard'), icon: 'terminal' }, { id: 'account', label: $t('settings.account'), icon: 'user' } @@ -102,6 +108,7 @@ { id: 'agents', label: $t('admin.agents'), icon: 'terminal' }, { id: 'models', label: $t('admin.models'), icon: 'cube' }, { id: 'chat', label: $t('admin.chat'), icon: 'chat-bubble' }, + { id: 'tools', label: $t('admin.tools'), icon: 'terminal' }, { id: 'git', label: $t('admin.git'), icon: 'git-branch' }, { id: 'messaging', label: $t('admin.messaging'), icon: 'chat-bubble' }, { id: 'gateway', label: $t('admin.gateway.tab'), icon: 'gateway' }, @@ -198,6 +205,8 @@ {:else if activeTab === 'appearance'} + {:else if activeTab === 'usage'} + {:else if activeTab === 'memory'} {:else if activeTab === 'pwa' && showPwaSettings} @@ -216,6 +225,8 @@ {:else if activeTab === 'chat'} + {:else if activeTab === 'tools'} + {:else if activeTab === 'git'} {:else if activeTab === 'skills'} diff --git a/cptr/frontend/src/lib/components/Terminal.svelte b/cptr/frontend/src/lib/components/Terminal.svelte index 95f78bad..12ef07be 100644 --- a/cptr/frontend/src/lib/components/Terminal.svelte +++ b/cptr/frontend/src/lib/components/Terminal.svelte @@ -62,6 +62,7 @@ let fitAddon: FitAddon | null = null; let ws: WebSocket | null = null; let resizeObserver: ResizeObserver | null = null; + let themeObserver: MutationObserver | null = null; let resizeTimeout: ReturnType | null = null; let reconnectTimer: ReturnType | null = null; let destroyed = false; @@ -262,12 +263,12 @@ }); // Watch for theme changes - const observer = new MutationObserver(() => { + themeObserver = new MutationObserver(() => { if (term) { term.options.theme = terminalTheme(); } }); - observer.observe(document.documentElement, { + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] }); @@ -506,8 +507,14 @@ if (reconnectTimer) clearTimeout(reconnectTimer); if (hapticTimer) clearTimeout(hapticTimer); resizeObserver?.disconnect(); + themeObserver?.disconnect(); releaseWakeLock(); - ws?.close(); + if (ws) { + ws.onmessage = null; + ws.onclose = null; + ws.onerror = null; + ws.close(); + } term?.dispose(); }); diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index 6e38f08f..77503f02 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -1721,6 +1721,11 @@ showTtsFailure(err?.message || 'playback failed'); } } finally { + if (generation === ttsGeneration && ttsObjectUrl) { + URL.revokeObjectURL(ttsObjectUrl); + ttsObjectUrl = null; + } + if (generation === ttsGeneration) ttsAudio = null; if (generation === ttsGeneration) ttsPlaying = false; if (generation === ttsGeneration) speakingMessageId = null; if (generation === ttsGeneration) ttsStopRequested = false; diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index c8794d55..2a8f0d04 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -122,6 +122,7 @@ "settings.back": "Back", "settings.general": "General", "settings.appearance": "Appearance", + "settings.usage": "Usage", "settings.memory": "Memory", "pwa.settingsTitle": "Progressive Web App", "intent.chooseWorkspace": "Workspace", @@ -152,6 +153,38 @@ "settings.save": "Save", "settings.saved": "Saved", "settings.saving": "Saving…", + "usage.title": "Usage", + "usage.failedToLoad": "Failed to load usage", + "usage.overview": "Overview", + "usage.lifetimeTokens": "Lifetime tokens", + "usage.peakTokens": "Peak tokens", + "usage.longestActiveChat": "Longest active chat", + "usage.currentStreak": "Current streak", + "usage.longestStreak": "Longest streak", + "usage.tokenActivity": "Token activity", + "usage.daily": "Daily", + "usage.weekly": "Weekly", + "usage.cumulative": "Cumulative", + "usage.weekOf": "Week of", + "usage.through": "Through", + "usage.tokens": "tokens", + "usage.messages": "messages", + "usage.chats": "chats", + "usage.runs": "runs", + "usage.noModelData": "No model data", + "usage.activity": "Activity", + "usage.noData": "No usage data found", + "usage.activityInsights": "Activity insights", + "usage.models": "Models", + "usage.averageTokensPerChat": "Average tokens per chat", + "usage.averageMessagesPerActiveDay": "Average messages per active day", + "usage.userMessages": "User messages", + "usage.assistantMessages": "Assistant messages", + "usage.totalChats": "Total chats", + "usage.topModels": "Top models", + "usage.noModelUsage": "No model usage found", + "usage.mostUsedTools": "Most used tools", + "usage.estimateNote": "Token counts are estimates and may not reflect actual API usage", "memory.failedToLoad": "Failed to load memory", "memory.failedToSaveSettings": "Failed to save memory settings", "memory.failedToUpdate": "Failed to update memory", @@ -466,6 +499,7 @@ "admin.browserFirecrawlBaseUrlHint": "Change for self-hosted Firecrawl instances", "admin.models": "Models", "admin.chat": "Chat", + "admin.tools": "Tools", "admin.chatTitles": "Titles", "admin.chatTitleGeneration": "Chat title generation", "admin.chatTitleGenerationHint": "Name new chats after the first response.", @@ -476,6 +510,13 @@ "admin.toolApproval": "Tool approval", "admin.toolApprovalReviewModel": "Review model", "admin.toolApprovalReviewModelHint": "Reviews pending tool calls in Auto mode.", + "admin.toolApprovalDefaultBuiltinApproval": "Default built-in tool approval", + "admin.toolApprovalDefaultBuiltinApprovalHint": "Used when a built-in tool does not define its own policy.", + "admin.toolApprovalBuiltinTools": "Built-in tool approval", + "admin.toolApprovalDefault": "Default", + "admin.toolApprovalAllow": "Allow", + "admin.toolApprovalReview": "Review", + "admin.toolApprovalEffective": "Effective: {{value}}", "admin.git": "Git", "admin.gitCommitMessageModel": "Commit message model", "admin.gitCommitMessageModelHint": "Drafts messages from staged changes.", diff --git a/cptr/routers/admin.py b/cptr/routers/admin.py index 7921e708..99c61b07 100644 --- a/cptr/routers/admin.py +++ b/cptr/routers/admin.py @@ -477,9 +477,7 @@ class UpdateModelConfigRequest(BaseModel): @router.put("/models/{model_id:path}/config") -async def update_model_config( - model_id: str, body: UpdateModelConfigRequest, request: Request -): +async def update_model_config(model_id: str, body: UpdateModelConfigRequest, request: Request): """Update config for a specific model (or '*' for global defaults).""" require_admin(request) all_config = await Config.get(CONFIG_KEY_CHAT_MODELS) or {} @@ -532,6 +530,48 @@ def _mask_tool_server(server: dict) -> dict: return masked +@router.get("/tools/approval") +async def get_tool_approval(request: Request): + """Return built-in tool approval defaults for the Chat settings UI.""" + require_admin(request) + from cptr.utils.tools import ( + ALL_TOOLS, + BUILTIN_TOOL_GROUPS, + normalize_tool_approval, + ) + + raw_default = await Config.get("tool_approval.default_builtin_approval") + raw_overrides = await Config.get("tool_approval.builtin_tools") or {} + overrides = {} + if isinstance(raw_overrides, dict): + overrides = { + name: policy + for name, value in raw_overrides.items() + if (policy := normalize_tool_approval(value)) + } + + groups = [] + for group_id, names in BUILTIN_TOOL_GROUPS.items(): + tools = [] + for name in names: + tool = ALL_TOOLS.get(name) + if tool: + tools.append( + { + "name": name, + "default_approval": normalize_tool_approval(tool.get("approval")), + } + ) + if tools: + groups.append({"id": group_id, "tools": tools}) + + return { + "default_approval": normalize_tool_approval(raw_default) or "review", + "overrides": overrides, + "groups": groups, + } + + @router.get("/tools/servers") async def list_tool_servers(request: Request): """List all configured tool servers (keys masked).""" diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index bf0ce828..e7cbd6f3 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -3,17 +3,21 @@ from __future__ import annotations import asyncio +from collections import defaultdict from copy import deepcopy +from datetime import date, datetime, timedelta, timezone import json import logging from typing import List, Optional from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel +from sqlalchemy import select from cptr.models import Chat, ChatMessage, Config, is_internal_chat from cptr.utils.config import check_access, now_ms, _get_jwt_secret from cptr.utils.crypto import decrypt_key +from cptr.utils.db import get_db from cptr.utils.workspace import ensure_cptr_gitignored from cptr.utils.chat_export import chat_directory @@ -324,6 +328,258 @@ async def get_models(request: Request): return {"models": models, "default": default_model} +@router.get("/usage") +async def get_usage(request: Request, days: int | None = Query(None, ge=7, le=732)): + """Aggregate personal chat usage for the settings Usage tab.""" + user_id = _get_user(request) + + async with await get_db() as db: + chat_result = await db.execute(select(Chat).where(Chat.user_id == user_id)) + chats = [ + chat + for chat in chat_result.scalars().all() + if not is_internal_chat(chat.meta if isinstance(chat.meta, dict) else None) + ] + + messages = [] + if chats: + message_result = await db.execute( + select(ChatMessage) + .where(ChatMessage.chat_id.in_([chat.id for chat in chats])) + .order_by(ChatMessage.chat_id, ChatMessage.created_at) + ) + messages = list(message_result.scalars().all()) + + chat_ids_by_day: dict[str, set[str]] = defaultdict(set) + day_stats: dict[str, dict] = defaultdict(lambda: {"tokens": 0, "messages": 0, "models": {}}) + model_stats: dict[str, dict] = defaultdict(lambda: {"messages": 0, "total_tokens": 0}) + tool_counts: dict[str, int] = defaultdict(int) + models_used: set[str] = set() + rows = [] + last_message_at_by_chat: dict[str, int] = {} + active_seconds_by_chat: dict[str, int] = defaultdict(int) + user_messages = 0 + assistant_messages = 0 + lifetime_tokens = 0 + + for message in messages: + created_at = int(message.created_at or 0) + created_seconds = created_at / ( + 1_000_000_000 + if created_at > 10_000_000_000_000 + else 1000 + if created_at > 10_000_000_000 + else 1 + ) + created_seconds = int(created_seconds) + day_date = datetime.fromtimestamp(created_seconds, tz=timezone.utc).date() + model_id = message.model or None + usage = message.usage if isinstance(message.usage, dict) else {} + try: + tokens = max( + 0, + int( + usage.get("total_tokens") + or (usage.get("input_tokens") or usage.get("prompt_tokens") or 0) + + (usage.get("output_tokens") or usage.get("completion_tokens") or 0) + or 0 + ), + ) + except (TypeError, ValueError): + tokens = 0 + if tokens: + lifetime_tokens += tokens + + if message.role == "assistant" and model_id: + models_used.add(model_id) + + last_message_at = last_message_at_by_chat.get(message.chat_id) + if last_message_at is not None: + delta = created_seconds - last_message_at + if 0 < delta <= 30 * 60: + active_seconds_by_chat[message.chat_id] += delta + last_message_at_by_chat[message.chat_id] = created_seconds + rows.append((message, day_date, tokens)) + + today = datetime.now(timezone.utc).date() + days = days or 730 + start = today - timedelta(days=days - 1) + period_start = int( + datetime.combine(start, datetime.min.time(), tzinfo=timezone.utc).timestamp() + ) + period_end = int(datetime.combine(today, datetime.max.time(), tzinfo=timezone.utc).timestamp()) + + for message, day_date, tokens in rows: + if day_date < start or day_date > today: + continue + + day = day_date.isoformat() + day_stats[day]["messages"] += 1 + chat_ids_by_day[day].add(message.chat_id) + + if message.role == "user": + user_messages += 1 + elif message.role == "assistant": + assistant_messages += 1 + + model_id = message.model or None + if tokens: + day_stats[day]["tokens"] += tokens + + if message.role == "assistant" and model_id: + day_models = day_stats[day]["models"] + day_models[model_id] = day_models.get(model_id, 0) + 1 + model_stats[model_id]["messages"] += 1 + model_stats[model_id]["total_tokens"] += tokens + + for value in (message.output, message.meta): + stack = [value] + while stack: + item = stack.pop() + if isinstance(item, list): + stack.extend(item) + continue + if not isinstance(item, dict): + continue + item_type = str(item.get("type") or "") + if "tool" in item_type or item_type in {"function_call", "function_call_output"}: + for name in (item.get("name"), item.get("tool_name")): + if isinstance(name, str) and name.strip() and len(name.strip()) <= 128: + tool_counts[name.strip()] += 1 + if isinstance(item.get("function"), dict): + name = item["function"].get("name") + if isinstance(name, str) and name.strip() and len(name.strip()) <= 128: + tool_counts[name.strip()] += 1 + for key in ("tool_calls", "tools", "output", "meta"): + if key in item: + stack.append(item[key]) + + heatmap = [] + cumulative_tokens = 0 + cumulative_messages = 0 + cumulative_chats = 0 + cumulative_models: dict[str, int] = {} + weekly: dict[str, dict] = defaultdict( + lambda: {"tokens": 0, "messages": 0, "chats": 0, "models": {}} + ) + cumulative = [] + + for offset in range((today - start).days + 1): + current = start + timedelta(days=offset) + key = current.isoformat() + stats = day_stats.get(key, {"tokens": 0, "messages": 0, "models": {}}) + chats_for_day = chat_ids_by_day.get(key, set()) + entry = { + "date": key, + "tokens": stats["tokens"], + "messages": stats["messages"], + "chats": len(chats_for_day), + "models": stats["models"], + } + heatmap.append(entry) + + week_key = (current - timedelta(days=current.weekday())).isoformat() + weekly_entry = weekly[week_key] + weekly_entry["tokens"] += stats["tokens"] + weekly_entry["messages"] += stats["messages"] + weekly_entry["chats"] += len(chats_for_day) + for model_id, count in stats["models"].items(): + weekly_entry["models"][model_id] = weekly_entry["models"].get(model_id, 0) + count + + cumulative_tokens += stats["tokens"] + cumulative_messages += stats["messages"] + cumulative_chats += len(chats_for_day) + for model_id, count in stats["models"].items(): + cumulative_models[model_id] = cumulative_models.get(model_id, 0) + count + cumulative.append( + { + "date": key, + "tokens": cumulative_tokens, + "messages": cumulative_messages, + "chats": cumulative_chats, + "models": dict(cumulative_models), + } + ) + + active_days = [day for day, stats in day_stats.items() if stats["messages"] > 0] + active_dates = {date.fromisoformat(day) for day in active_days} + current_streak = 0 + cursor = today + while cursor in active_dates: + current_streak += 1 + cursor -= timedelta(days=1) + + longest_streak = 0 + run = 0 + previous = None + for active_date in sorted(active_dates): + run = run + 1 if previous and active_date == previous + timedelta(days=1) else 1 + previous = active_date + longest_streak = max(longest_streak, run) + + total_chats = len(chats) + longest_chat_seconds = max(active_seconds_by_chat.values(), default=0) + + weekly_heatmap = [ + { + "date": week, + "tokens": stats["tokens"], + "messages": stats["messages"], + "chats": stats["chats"], + "models": stats["models"], + } + for week, stats in sorted(weekly.items()) + ] + total_messages = user_messages + assistant_messages + + return { + "totals": { + "lifetime_tokens": lifetime_tokens, + "peak_daily_tokens": max((entry["tokens"] for entry in heatmap), default=0), + "longest_chat_seconds": longest_chat_seconds, + "current_streak": current_streak, + "longest_streak": longest_streak, + "models_used": len(models_used), + "user_messages": user_messages, + "assistant_messages": assistant_messages, + "messages": total_messages, + "total_chats": total_chats, + }, + "insights": { + "average_tokens_per_chat": round(lifetime_tokens / total_chats, 1) + if total_chats + else 0, + "average_messages_per_active_day": round(total_messages / len(active_days), 1) + if active_days + else 0, + "user_message_share": round((user_messages / total_messages) * 100, 1) + if total_messages + else 0, + "assistant_message_share": round((assistant_messages / total_messages) * 100, 1) + if total_messages + else 0, + }, + "heatmap": heatmap, + "weekly_heatmap": weekly_heatmap, + "cumulative_heatmap": cumulative, + "top_models": [ + {"model_id": model_id, **stats} + for model_id, stats in sorted( + model_stats.items(), key=lambda item: item[1]["messages"], reverse=True + )[:5] + ], + "top_tools": [ + {"name": name, "count": count} + for name, count in sorted(tool_counts.items(), key=lambda item: item[1], reverse=True) + ][:5], + "period": { + "start_date": period_start, + "end_date": period_end, + "days": (today - start).days + 1, + }, + } + + async def _fetch_provider_models(conn: dict) -> list[str] | None: """Discover models from a provider's /models endpoint.""" import httpx @@ -724,7 +980,7 @@ async def send_message(body: SendMessageRequest, request: Request): """ user_id = _get_user(request) - from cptr.utils.model_targets import AgentModelTarget, resolve_model_target + from cptr.utils.model_targets import resolve_model_target target = await resolve_model_target(body.model_id, request.app.state) @@ -734,8 +990,6 @@ async def send_message(body: SendMessageRequest, request: Request): if not chat or chat.user_id != user_id: raise HTTPException(404, "chat not found") workspace = (chat.meta or {}).get("workspace") or None - if not workspace and isinstance(target, AgentModelTarget): - raise HTTPException(400, "Home chats require an API model") # Sync params into chat meta if chat.meta is None: chat.meta = {} @@ -745,8 +999,6 @@ async def send_message(body: SendMessageRequest, request: Request): await Chat.update_meta(chat.id, chat.meta) else: workspace = body.workspace or None - if not workspace and isinstance(target, AgentModelTarget): - raise HTTPException(400, "Home chats require an API model") title = body.content[:50].strip() or "New Chat" meta = { "params": body.params, diff --git a/cptr/routers/workspace.py b/cptr/routers/workspace.py index 37da2753..d2c3bb88 100644 --- a/cptr/routers/workspace.py +++ b/cptr/routers/workspace.py @@ -121,6 +121,10 @@ def _scan() -> list[FileEntry]: ".java", ".c", ".h", + ".cc", + ".cxx", + ".hh", + ".hxx", ".cpp", ".hpp", ".rb", @@ -193,6 +197,10 @@ def _detect_language(name: str) -> Optional[str]: ".java": "java", ".c": "c", ".h": "c", + ".cc": "cpp", + ".cxx": "cpp", + ".hh": "cpp", + ".hxx": "cpp", ".cpp": "cpp", ".hpp": "cpp", ".rb": "ruby", diff --git a/cptr/utils/adapters/signal.py b/cptr/utils/adapters/signal.py index 506bbff6..2a2f689a 100644 --- a/cptr/utils/adapters/signal.py +++ b/cptr/utils/adapters/signal.py @@ -140,8 +140,18 @@ async def send(self, chat_id: str, text: str) -> str | None: return timestamp async def edit(self, chat_id: str, message_id: str, text: str) -> None: - """Signal has no edit API; send the updated text as a new message.""" - await self.send(chat_id, text) + if not self._http: + return + resp = await self._http.post( + f"{self._base_url}/v2/send", + json={ + "message": text[:MAX_MESSAGE_LEN], + "number": self._phone, + "recipients": [chat_id], + "edit_timestamp": int(message_id), + }, + ) + resp.raise_for_status() async def send_typing(self, chat_id: str) -> None: """Send typing indicator via signal-cli.""" diff --git a/cptr/utils/adapters/telegram.py b/cptr/utils/adapters/telegram.py index 03ab97b5..a20d2668 100644 --- a/cptr/utils/adapters/telegram.py +++ b/cptr/utils/adapters/telegram.py @@ -240,7 +240,7 @@ async def _process_update(self, update: dict) -> None: text = message.get("text") or message.get("caption") or "" # Collect attachments from media types - attachments: list = [] + attachments: list[Attachment] = [] # Photos β€” Telegram sends multiple sizes, pick the largest if message.get("photo"): @@ -286,7 +286,6 @@ async def _process_update(self, update: dict) -> None: audio = message["audio"] file_data = await self._download_file(audio["file_id"]) if file_data: - from cptr.utils.bridge import Attachment fname = audio.get("file_name", "audio.mp3") attachments.append(Attachment( type="audio", diff --git a/cptr/utils/agents/detection.py b/cptr/utils/agents/detection.py index cbdd0c9f..f67d5c86 100644 --- a/cptr/utils/agents/detection.py +++ b/cptr/utils/agents/detection.py @@ -21,6 +21,7 @@ model_id_for_profile, normalize_agent_profiles, ) +from cptr.utils.agents.opencode import opencode_server_url_candidates DETECTION_TTL_SECONDS = 30 CLAUDE_MODEL_FALLBACKS = [ @@ -61,35 +62,58 @@ def _resolve_command(command: str) -> str | None: def _find_claude_desktop_command() -> str | None: if os.name == "nt": + roots = [] appdata = os.environ.get("APPDATA") - root = os.path.join(appdata, "Claude", "claude-code") if appdata else None + if appdata: + roots.append(os.path.join(appdata, "Claude", "claude-code")) + localappdata = os.environ.get("LOCALAPPDATA") + packages_dir = os.path.join(localappdata, "Packages") if localappdata else None + if packages_dir and os.path.isdir(packages_dir): + with suppress(OSError): + for name in os.listdir(packages_dir): + if name.startswith("Claude_"): + roots.append( + os.path.join( + packages_dir, + name, + "LocalCache", + "Roaming", + "Claude", + "claude-code", + ) + ) relative_paths = (("claude.exe",),) else: - root = os.path.join( - os.path.expanduser("~"), - "Library", - "Application Support", - "Claude", - "claude-code", - ) + roots = [ + os.path.join( + os.path.expanduser("~"), + "Library", + "Application Support", + "Claude", + "claude-code", + ) + ] relative_paths = ( ("claude.app", "Contents", "MacOS", "claude"), ("claude",), ) candidates: list[tuple[tuple[int, int, int], str]] = [] - if root is not None and os.path.isdir(root): - for name in os.listdir(root): - version_dir = os.path.join(root, name) - version = _parse_version_tuple(name) - if version is None or not os.path.isdir(version_dir): - continue - for relative_path in relative_paths: - candidate = os.path.join(version_dir, *relative_path) - if os.path.isfile(candidate) and os.access(candidate, os.X_OK): - candidates.append((version, candidate)) - break - return max(candidates)[1] if candidates else None + for root in roots: + if not os.path.isdir(root): + continue + with suppress(OSError): + for name in os.listdir(root): + version_dir = os.path.join(root, name) + version = _parse_version_tuple(name) + if version is None or not os.path.isdir(version_dir): + continue + for relative_path in relative_paths: + candidate = os.path.join(version_dir, *relative_path) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + candidates.append((version, candidate)) + break + return max(candidates, key=lambda item: item[0])[1] if candidates else None async def _run_probe( @@ -433,7 +457,9 @@ async def _probe_cursor_models(command: str, profile: dict[str, Any]) -> list[st ) try: await asyncio.wait_for(client.start(), timeout=10) - result = await asyncio.wait_for(client.request("cursor/list_available_models", {}), timeout=5) + result = await asyncio.wait_for( + client.request("cursor/list_available_models", {}), timeout=5 + ) models = [] for item in result.get("models") or []: if isinstance(item, dict) and isinstance(item.get("value"), str): @@ -489,30 +515,42 @@ async def _probe_opencode_models(command: str, profile: dict[str, Any]) -> list[ env={**env, "OPENCODE_CONFIG_CONTENT": "{}"}, ) server_url = await _read_opencode_server_url(proc, port) - async with httpx.AsyncClient(base_url=server_url, timeout=5) as client: - headers = {} - if password: - import base64 - - token = base64.b64encode(f"opencode:{password}".encode()).decode() - headers["Authorization"] = f"Basic {token}" - providers = await _opencode_json(client, ["provider.list", "provider/list", "provider"], headers) - provider_list = providers.get("data") if isinstance(providers.get("data"), dict) else providers - connected = set(provider_list.get("connected") or []) - all_providers = provider_list.get("all") or [] - models: list[str] = [] - for provider in all_providers: - if not isinstance(provider, dict): - continue - provider_id = provider.get("id") - if not isinstance(provider_id, str) or provider_id not in connected: - continue - raw_models = provider.get("models") - if isinstance(raw_models, dict): - for model_id in raw_models: - if isinstance(model_id, str) and model_id.strip(): - models.append(f"{provider_id}/{model_id.strip()}") - return models or None + headers = {} + if password: + import base64 + + token = base64.b64encode(f"opencode:{password}".encode()).decode() + headers["Authorization"] = f"Basic {token}" + for candidate_url in opencode_server_url_candidates(server_url): + try: + async with httpx.AsyncClient(base_url=candidate_url, timeout=5) as client: + providers = await _opencode_json( + client, ["provider.list", "provider/list", "provider"], headers + ) + provider_list = ( + providers.get("data") + if isinstance(providers.get("data"), dict) + else providers + ) + connected = set(provider_list.get("connected") or []) + all_providers = provider_list.get("all") or [] + models: list[str] = [] + for provider in all_providers: + if not isinstance(provider, dict): + continue + provider_id = provider.get("id") + if not isinstance(provider_id, str) or provider_id not in connected: + continue + raw_models = provider.get("models") + if isinstance(raw_models, dict): + for model_id in raw_models: + if isinstance(model_id, str) and model_id.strip(): + models.append(f"{provider_id}/{model_id.strip()}") + if models: + return models + except Exception: + continue + return None except Exception: return None finally: diff --git a/cptr/utils/agents/opencode.py b/cptr/utils/agents/opencode.py index 2875c230..4d80a804 100644 --- a/cptr/utils/agents/opencode.py +++ b/cptr/utils/agents/opencode.py @@ -11,6 +11,7 @@ from contextlib import asynccontextmanager, suppress from pathlib import Path from typing import Any, AsyncIterator +from urllib.parse import urlsplit, urlunsplit import httpx @@ -25,12 +26,35 @@ from cptr.utils.agents.prompts import turn_prompt_text +_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + + def _free_port() -> int: with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) +def opencode_server_url_candidates(server_url: str) -> list[str]: + server_url = server_url.strip() + if not server_url: + return [] + parsed = urlsplit(server_url) + in_container = Path("/.dockerenv").exists() or Path("/run/.containerenv").exists() + if not in_container or parsed.hostname not in _LOOPBACK_HOSTS: + return [server_url] + docker_url = urlunsplit( + ( + parsed.scheme, + f"host.docker.internal{':' + str(parsed.port) if parsed.port else ''}", + parsed.path, + parsed.query, + parsed.fragment, + ) + ) + return [server_url] if docker_url == server_url else [server_url, docker_url] + + async def _server_url_from_stdout(proc: asyncio.subprocess.Process, port: int) -> str: assert proc.stdout is not None fallback = f"http://127.0.0.1:{port}" @@ -109,6 +133,8 @@ async def _request( try: response = await client.request(method, f"/{path}", headers=headers, json=json_body) response.raise_for_status() + if not response.content: + return {} data = response.json() return data if isinstance(data, dict) else {} except Exception as exc: # noqa: BLE001 - try alternate generated route names. @@ -181,7 +207,13 @@ async def _start_opencode_prompt( await _request( client, "POST", - ["session.promptAsync", "session/promptAsync", "session/prompt"], + [ + f"session/{session_id}/prompt_async", + f"session/{session_id}/message", + "session.promptAsync", + "session/promptAsync", + "session/prompt", + ], headers=headers, json_body={ "sessionID": session_id, @@ -191,16 +223,41 @@ async def _start_opencode_prompt( ) -def _text_from_event(event: dict[str, Any], emitted: dict[str, str]) -> str | None: +def _role_update_from_event(event: dict[str, Any]) -> tuple[str, str] | None: + if event.get("type") != "message.updated": + return None + props = event.get("properties") if isinstance(event.get("properties"), dict) else {} + info = props.get("info") if isinstance(props.get("info"), dict) else {} + message_id = info.get("id") + role = info.get("role") + if isinstance(message_id, str) and isinstance(role, str): + return message_id, role + return None + + +def _text_from_event( + event: dict[str, Any], + emitted: dict[str, str], + message_roles: dict[str, str], +) -> str | None: event_type = event.get("type") props = event.get("properties") if isinstance(event.get("properties"), dict) else {} if event_type == "message.part.delta": + message_id = props.get("messageID") + if isinstance(message_id, str) and message_roles.get(message_id) == "user": + return None delta = props.get("delta") + part_id = props.get("partID") + if isinstance(part_id, str) and isinstance(delta, str): + emitted[part_id] = f"{emitted.get(part_id, '')}{delta}" return delta if isinstance(delta, str) and delta else None if event_type == "message.part.updated": part = props.get("part") if isinstance(props.get("part"), dict) else {} if part.get("type") not in ("text", "reasoning"): return None + message_id = part.get("messageID") + if isinstance(message_id, str) and message_roles.get(message_id) == "user": + return None part_id = part.get("id") text = part.get("text") if not isinstance(part_id, str) or not isinstance(text, str): @@ -273,66 +330,98 @@ async def run_opencode_agent( try: async with _opencode_server(profile, workspace) as (server_url, _proc): headers = _headers(profile) - async with httpx.AsyncClient(base_url=server_url, timeout=None) as client: - session_id = _resume_session_id(resume_state) - resumed = bool(session_id) - if session_id is None: - session_id = await _create_opencode_session(client, headers) - - parsed_model = _parse_model(model) - while True: - emitted: dict[str, str] = {} - event_queue: asyncio.Queue[AgentEvent | None] = asyncio.Queue() - event_task = asyncio.create_task( - _collect_opencode_events(client, headers, session_id, emitted, event_queue) - ) - - prompt = turn_prompt_text(messages, system_prompt, resumed=resumed) - parts = _opencode_parts(prompt, attachments) - try: - try: - await _start_opencode_prompt( - client, headers, session_id, parsed_model, parts - ) - except Exception: - if not resumed: - raise - event_task.cancel() - with suppress(asyncio.CancelledError): - await event_task + urls = opencode_server_url_candidates(server_url) + last_connect_error: Exception | None = None + for index, candidate_url in enumerate(urls): + try: + async with httpx.AsyncClient( + base_url=candidate_url, + timeout=httpx.Timeout(None, connect=5), + ) as client: + session_id = _resume_session_id(resume_state) + resumed = bool(session_id) + if session_id is None: session_id = await _create_opencode_session(client, headers) - resumed = False - continue + parsed_model = _parse_model(model) while True: - item = await event_queue.get() - if item is None: - break - yield item - except asyncio.CancelledError: - with suppress(Exception): - await _request( - client, - "POST", - ["session.abort", "session/abort"], - headers=headers, - json_body={"sessionID": session_id}, + emitted: dict[str, str] = {} + event_queue: asyncio.Queue[AgentEvent | None] = asyncio.Queue() + event_task = asyncio.create_task( + _collect_opencode_events( + client, headers, session_id, emitted, event_queue + ) ) - raise - finally: - event_task.cancel() - with suppress(asyncio.CancelledError): - await event_task - break - - yield AgentDone( - resume_state={ - "profile_id": profile["id"], - "session_id": session_id, - "workspace": workspace, - "model": model, - } - ) + + prompt = turn_prompt_text(messages, system_prompt, resumed=resumed) + parts = _opencode_parts(prompt, attachments) + try: + try: + await _start_opencode_prompt( + client, headers, session_id, parsed_model, parts + ) + except Exception: + if not resumed: + raise + event_task.cancel() + with suppress(asyncio.CancelledError): + await event_task + session_id = await _create_opencode_session(client, headers) + resumed = False + continue + + while True: + item = await event_queue.get() + if item is None: + break + yield item + except asyncio.CancelledError: + with suppress(Exception): + await _request( + client, + "POST", + [ + f"session/{session_id}/abort", + "session.abort", + "session/abort", + ], + headers=headers, + json_body={"sessionID": session_id}, + ) + raise + finally: + event_task.cancel() + with suppress(asyncio.CancelledError): + await event_task + break + + yield AgentDone( + resume_state={ + "profile_id": profile["id"], + "session_id": session_id, + "workspace": workspace, + "model": model, + } + ) + return + except (httpx.ConnectError, httpx.ConnectTimeout) as exc: + last_connect_error = exc + if index < len(urls) - 1: + continue + if len(urls) > 1: + port = urlsplit(server_url).port or 4096 + raise RuntimeError( + "Unable to connect to OpenCode from Docker. If OpenCode is running " + "on the host, start it with " + f"`opencode serve --hostname 0.0.0.0 --port {port}` and set the " + f"OpenCode Server URL to `http://host.docker.internal:{port}`. " + "On Linux Docker, add " + "`--add-host=host.docker.internal:host-gateway` if that hostname " + "is unavailable." + ) from exc + raise + if last_connect_error: + raise last_connect_error except asyncio.CancelledError: raise except Exception as exc: # noqa: BLE001 - surfaced in chat. @@ -346,6 +435,7 @@ async def _collect_opencode_events( emitted: dict[str, str], queue: asyncio.Queue[AgentEvent | None], ) -> None: + message_roles: dict[str, str] = {} try: for path in ("event.subscribe", "event/subscribe", "event"): try: @@ -367,7 +457,10 @@ async def _collect_opencode_events( ) if props.get("sessionID") != session_id: continue - text = _text_from_event(event, emitted) + role_update = _role_update_from_event(event) + if role_update: + message_roles[role_update[0]] = role_update[1] + text = _text_from_event(event, emitted, message_roles) if text: await queue.put(AgentTextDelta(text)) tool = _tool_from_event(event) diff --git a/cptr/utils/ai.py b/cptr/utils/ai.py index f40e69b4..3d103f50 100644 --- a/cptr/utils/ai.py +++ b/cptr/utils/ai.py @@ -61,7 +61,14 @@ def _openrouter_headers(url: str) -> dict[str, str]: read=STREAM_READ_TIMEOUT_SECONDS, write=STREAM_WRITE_TIMEOUT_SECONDS, ) + + +class EmptyCompletionError(RuntimeError): + pass + + _STREAM_RETRY_ERRORS = ( + EmptyCompletionError, httpx.ConnectError, httpx.ConnectTimeout, httpx.HTTPStatusError, @@ -779,6 +786,12 @@ def complete_reasoning_item() -> dict | None: emitted = True yield {"type": "output", "item": item} raw = chunk["usage"] + usage_tokens = sum( + raw.get(key, 0) + for key in ("prompt_tokens", "completion_tokens", "total_tokens") + ) + if not emitted and usage_tokens <= 0: + continue emitted = True yield { "type": "usage", @@ -792,6 +805,11 @@ def complete_reasoning_item() -> dict | None: if item is not None: emitted = True yield {"type": "output", "item": item} + if not emitted: + raise EmptyCompletionError( + "Upstream provider returned an empty completion with no text, " + "output items, tool calls, or usage tokens." + ) emitted = True yield {"type": "done"} return diff --git a/cptr/utils/bridge.py b/cptr/utils/bridge.py index 3b6b11fd..7181cc4f 100644 --- a/cptr/utils/bridge.py +++ b/cptr/utils/bridge.py @@ -13,11 +13,10 @@ import asyncio import logging -import time import uuid from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Any, Awaitable, Callable, Optional +from typing import Awaitable, Callable, Optional logger = logging.getLogger(__name__) _current_bot_manager: "BotManager | None" = None @@ -612,9 +611,6 @@ async def _process_attachments( file_entries match the web UI format so _load_message_history handles them automatically (base64 for images, file:// refs for documents). """ - from cptr.models import Config - from cptr.utils.config import _get_jwt_secret - from cptr.utils.crypto import decrypt_key from cptr.utils.storage import get_storage file_entries: list[dict] = [] @@ -915,13 +911,14 @@ async def _stream_loop( except Exception: logger.exception("[bridge] Failed to send final chunk") else: - # Discord: edit the placeholder, then send overflow + # Edit the placeholder, then send overflow. if len(final_display) <= max_len and platform_msg_id: try: await adapter.edit(platform_chat_id, platform_msg_id, final_display) return except Exception: - pass + logger.debug("[bridge] Final edit failed", exc_info=True) + return chunks = chunk_message(final_display, max_len) if platform_msg_id and chunks: @@ -929,7 +926,8 @@ async def _stream_loop( await adapter.edit(platform_chat_id, platform_msg_id, chunks[0]) chunks = chunks[1:] except Exception: - pass + logger.debug("[bridge] Final chunk edit failed", exc_info=True) + return for chunk in chunks: try: await adapter.send(platform_chat_id, chunk) diff --git a/cptr/utils/browser/viewer.py b/cptr/utils/browser/viewer.py index 46a9b0ff..f4b696f7 100644 --- a/cptr/utils/browser/viewer.py +++ b/cptr/utils/browser/viewer.py @@ -1676,6 +1676,15 @@ async def stop(self, session_id: str, owner: str) -> bool: await viewer.controller_cdp.close() await viewer.host.close_target(viewer.target_id) await viewer.host.close_target(viewer.controller_id) + if viewer.host.source == "managed": + has_managed_viewers = any( + item.session.owner == owner and item.host.source == "managed" + for item in self.viewers.values() + ) + if not has_managed_viewers: + host = self.hosts.pop((owner, ""), None) + if host: + await host.close() return True async def clear_managed_profile(self, owner: str) -> list[str]: diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 1c683703..91923cc0 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -10,6 +10,7 @@ import logging import re import uuid +from pathlib import Path from typing import Any from cptr.events import EVENTS, publish_event @@ -56,6 +57,7 @@ disabled_builtin_tool_names, execute_tool, get_tool_list, + resolve_builtin_tool_approval, _fn_to_schema, ) from cptr.utils.chat_export import export_chat_to_file @@ -742,6 +744,7 @@ async def review_tool_approval( model: str, tool_name: str, arguments: dict, + approval_policy: str = "review", ) -> bool: """Return True when Auto mode can run a pending tool without prompting.""" latest_user = "" @@ -754,8 +757,9 @@ async def review_tool_approval( args_text = args_text[:3500] + "\n...(truncated)" configured_model = await Config.get("tool_approval.review.model") logger.info( - "[tool-approval] auto review start tool=%s active_model=%s review_model=%s args=%s", + "[tool-approval] auto review start tool=%s policy=%s active_model=%s review_model=%s args=%s", tool_name, + approval_policy, model, configured_model or "", args_text[:1000], @@ -1307,23 +1311,23 @@ def _find_safe_split(messages: list[dict], target_keep: int) -> int: """Find a safe split index that doesn't break tool call pairs. Returns the index where keep_zone starts. Ensures: + - Prefer starting compacted history on a user turn - Never splits between an assistant tool_call and its tool result - keep_zone doesn't start with a tool result message - At least 2 messages are kept """ n = len(messages) - split = max(2, n - target_keep) - - # Walk forward from the initial split to find a safe boundary - while split < n - 1: - msg = messages[split] - # Don't start keep_zone with a tool result β€” it needs its preceding assistant - if msg.get("role") == "tool": - split += 1 - continue - break + split = min(max(2, n - target_keep), max(0, n - 2)) + + for idx in range(split, n - 1): + if messages[idx].get("role") == "user": + return idx + + # Don't start keep_zone with a tool result; keep its assistant call too. + while split > 0 and messages[split].get("role") == "tool": + split -= 1 - return min(split, n - 2) # always keep at least 2 + return split def _summary_checkpoint_message_id(keep_zone: list[dict], fallback: str) -> str: @@ -1542,6 +1546,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): chat_obj = await Chat.get_by_id(chat_id) chat_params = (chat_obj.meta or {}).get("params", {}) if chat_obj else {} + agent_workspace = workspace or str(Path.home()) messages, loaded_summary = await _load_message_history(chat_id, message_id) skill_settings = await get_skill_settings() skill_authoring_allowed = _has_prior_real_chat_content(messages, loaded_summary) @@ -1554,7 +1559,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) memory_message, memory_files = _memory_recall_inputs(messages, regeneration_prompt) system = await _load_system_prompt( - workspace, + agent_workspace, agent_target.full_model_id, user_id=user_id, current_message=memory_message, @@ -1570,7 +1575,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): if isinstance(meta_files, list): current_user_files = meta_files agent_attachments = await prepare_agent_attachments( - workspace=workspace, + workspace=agent_workspace, chat_id=chat_id, message_id=(msg.parent_id if msg and msg.parent_id else message_id), files=current_user_files, @@ -1626,7 +1631,7 @@ async def _finish_reasoning_item(): async for event in runner( profile=agent_target.config, model=agent_target.model, - workspace=workspace, + workspace=agent_workspace, messages=messages, system_prompt=system, chat_params=chat_params, @@ -1925,7 +1930,7 @@ async def _finish_reasoning_item(): # Plan mode: strip write tools, inject prompt as user message (not system, to preserve cache) plan_mode = chat_params.get("plan_mode", False) if plan_mode: - tools = [t for t in tools if not ALL_TOOLS.get(t["name"], {}).get("ask", True)] + tools = [t for t in tools if await resolve_builtin_tool_approval(t["name"]) == "allow"] tools = [t for t in tools if t["name"] not in {"delegate_task", "update_memory"}] tools.append(_fn_to_schema("create_artifact", create_artifact)) tools.append(ASK_USER_SCHEMA) @@ -1953,7 +1958,7 @@ async def _finish_reasoning_item(): # Tool approval mode: 'ask' | 'auto' | 'full' # ask = require approval for ALL tools (including reads) - # auto = run ask:false tools; review ask:true tools before prompting + # auto = run allow tools; review review tools before prompting # full = auto-approve everything approval_mode = chat_params.get("tool_approval_mode", "auto") # Legacy compat: old boolean auto_approve_tools @@ -1984,8 +1989,9 @@ async def run_queued_tool_calls(tool_ctx: dict) -> str: name = item.get("name", "") tool = ALL_TOOLS.get(name) + tool_approval = await resolve_builtin_tool_approval(name) if tool else "review" should_auto = approval_mode == "full" or ( - approval_mode == "auto" and tool and not tool.get("ask", True) + approval_mode == "auto" and tool and tool_approval == "allow" ) if ( not should_auto @@ -1999,6 +2005,7 @@ async def run_queued_tool_calls(tool_ctx: dict) -> str: model=model, tool_name=name, arguments=item.get("arguments") or {}, + approval_policy=tool_approval, ) ): item["approved"] = True @@ -2492,8 +2499,9 @@ async def auto_answer(): if skill_name: loaded_skill_names.add(skill_name) tool = ALL_TOOLS.get(name) + tool_approval = await resolve_builtin_tool_approval(name) if tool else "review" should_auto = approval_mode == "full" or ( - approval_mode == "auto" and tool and not tool.get("ask", True) + approval_mode == "auto" and tool and tool_approval == "allow" ) if not should_auto: needs_approval = tc diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index 2e1b62c2..b07bec38 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -2277,45 +2277,53 @@ async def notify(message: str, target: str = "", title: str = "", *, __context__ # ── Registry ──────────────────────────────────────────────── +ToolApprovalPolicy = Literal["allow", "review"] +TOOL_APPROVAL_POLICIES = {"allow", "review"} + + +def normalize_tool_approval(value: Any) -> ToolApprovalPolicy | None: + return value if isinstance(value, str) and value in TOOL_APPROVAL_POLICIES else None + + TOOLS: dict[str, dict] = { # Auto mode runs these without asking. - "read_file": {"fn": read_file, "ask": False}, - "list_directory": {"fn": list_directory, "ask": False}, - "search_files": {"fn": search_files, "ask": False}, - "check_task": {"fn": check_task, "ask": False}, - "web_search": {"fn": web_search, "ask": False}, - "read_url": {"fn": read_url, "ask": False}, - "search_chats": {"fn": search_chats, "ask": False}, - "list_automations": {"fn": list_automations, "ask": False}, - "view_skill": {"fn": view_skill, "ask": False}, - "update_tasks": {"fn": update_tasks, "ask": False}, - # Auto mode reviews these first, then asks if denied or unclear. - "create_file": {"fn": create_file, "ask": True}, - "display_file": {"fn": display_file, "ask": True}, - "edit_file": {"fn": edit_file, "ask": True}, - "multi_edit_file": {"fn": multi_edit_file, "ask": True}, - "write_file": {"fn": write_file, "ask": True}, - "run_command": {"fn": run_command, "ask": True}, - "send_input": {"fn": send_input, "ask": True}, - "kill_task": {"fn": kill_task, "ask": True}, - "create_automation": {"fn": create_automation, "ask": True}, - "update_automation": {"fn": update_automation, "ask": True}, - "toggle_automation": {"fn": toggle_automation, "ask": True}, - "delete_automation": {"fn": delete_automation, "ask": True}, - "notify": {"fn": notify, "ask": True}, - "image_generate": {"fn": image_generate, "ask": True}, - "manage_skill": {"fn": manage_skill, "ask": True}, - "update_memory": {"fn": update_memory, "ask": False}, + "read_file": {"fn": read_file, "approval": "allow"}, + "list_directory": {"fn": list_directory, "approval": "allow"}, + "search_files": {"fn": search_files, "approval": "allow"}, + "check_task": {"fn": check_task, "approval": "allow"}, + "web_search": {"fn": web_search, "approval": "allow"}, + "read_url": {"fn": read_url, "approval": "allow"}, + "search_chats": {"fn": search_chats, "approval": "allow"}, + "list_automations": {"fn": list_automations, "approval": "allow"}, + "view_skill": {"fn": view_skill, "approval": "allow"}, + "update_tasks": {"fn": update_tasks, "approval": "allow"}, + # Missing approval inherits tool_approval.default_builtin_approval. + "create_file": {"fn": create_file}, + "display_file": {"fn": display_file}, + "edit_file": {"fn": edit_file}, + "multi_edit_file": {"fn": multi_edit_file}, + "write_file": {"fn": write_file}, + "run_command": {"fn": run_command}, + "send_input": {"fn": send_input}, + "kill_task": {"fn": kill_task}, + "create_automation": {"fn": create_automation}, + "update_automation": {"fn": update_automation}, + "toggle_automation": {"fn": toggle_automation}, + "delete_automation": {"fn": delete_automation}, + "notify": {"fn": notify}, + "image_generate": {"fn": image_generate}, + "manage_skill": {"fn": manage_skill}, + "update_memory": {"fn": update_memory, "approval": "allow"}, } # Browser tools β€” conditionally included in schemas based on browser.enabled BROWSER_TOOLS: dict[str, dict] = { - "browser_navigate": {"fn": browser_navigate, "ask": True}, - "browser_snapshot": {"fn": browser_snapshot, "ask": False}, - "browser_click": {"fn": browser_click, "ask": True}, - "browser_type": {"fn": browser_type, "ask": True}, - "browser_screenshot": {"fn": browser_screenshot, "ask": False}, - "browser_evaluate": {"fn": browser_evaluate, "ask": True}, + "browser_navigate": {"fn": browser_navigate}, + "browser_snapshot": {"fn": browser_snapshot, "approval": "allow"}, + "browser_click": {"fn": browser_click}, + "browser_type": {"fn": browser_type}, + "browser_screenshot": {"fn": browser_screenshot, "approval": "allow"}, + "browser_evaluate": {"fn": browser_evaluate}, } @@ -2668,14 +2676,36 @@ async def _run_subagent_chat( SUBAGENT_TOOLS: dict[str, dict] = { - "delegate_task": {"fn": delegate_task, "ask": False}, - "timer": {"fn": timer, "ask": True}, + "delegate_task": {"fn": delegate_task, "approval": "allow"}, + "timer": {"fn": timer}, } # Combined lookup for execution and approval (always available regardless of config) ALL_TOOLS: dict[str, dict] = {**TOOLS, **BROWSER_TOOLS, **SUBAGENT_TOOLS} +async def resolve_builtin_tool_approval(name: str) -> ToolApprovalPolicy: + """Resolve built-in tool approval. Unknown tools stay conservative.""" + tool = ALL_TOOLS.get(name) + if tool is None: + return "review" + + from cptr.models import Config + + overrides = await Config.get("tool_approval.builtin_tools") or {} + if isinstance(overrides, dict): + override = normalize_tool_approval(overrides.get(name)) + if override: + return override + + registry = normalize_tool_approval(tool.get("approval")) + if registry: + return registry + + default = normalize_tool_approval(await Config.get("tool_approval.default_builtin_approval")) + return default or "review" + + BUILTIN_TOOL_GROUPS: dict[str, tuple[str, ...]] = { "files": ( "read_file", diff --git a/dev.sh b/dev.sh index 31da6812..949ef3be 100755 --- a/dev.sh +++ b/dev.sh @@ -1,3 +1,3 @@ #!/bin/bash export CPTR_DATA_DIR="${CPTR_DATA_DIR:-$(cd "$(dirname "$0")" && pwd)/.cptr}" -uv run --extra all cptr run --reload --host 0.0.0.0 --port 9741 +uv run --extra all cptr run --reload --host 0.0.0.0 --port 9741 --headless diff --git a/pyproject.toml b/pyproject.toml index e9bb75f4..28f0ea5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.9.20" +version = "0.9.21" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md" diff --git a/uv.lock b/uv.lock index 75c879a6..1dda52f9 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.9.20" +version = "0.9.21" source = { editable = "." } dependencies = [ { name = "aiosqlite" },