From 5ae2218f241f22bf1eb3a6eb24355d3a50d7d2a7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 1 Aug 2026 00:52:27 -0400 Subject: [PATCH 01/20] refac --- cptr/utils/chat_task.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 1c683703..813b6532 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -1307,23 +1307,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: From fa2b38b2bc7ab58142f143bfc051d20675111807 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 1 Aug 2026 01:09:48 -0400 Subject: [PATCH 02/20] refac --- cptr/utils/ai.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cptr/utils/ai.py b/cptr/utils/ai.py index f40e69b4..91d614b6 100644 --- a/cptr/utils/ai.py +++ b/cptr/utils/ai.py @@ -779,6 +779,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 +798,11 @@ def complete_reasoning_item() -> dict | None: if item is not None: emitted = True yield {"type": "output", "item": item} + if not emitted: + raise RuntimeError( + "Upstream provider returned an empty completion with no text, " + "output items, tool calls, or usage tokens." + ) emitted = True yield {"type": "done"} return From 7b95de9b5acbc9c8cb234d0c4578336606be594f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 1 Aug 2026 01:27:45 -0400 Subject: [PATCH 03/20] refac --- cptr/frontend/src/lib/apis/admin.ts | 21 ++ .../src/lib/components/Admin/Chat.svelte | 28 -- .../src/lib/components/Admin/Tools.svelte | 245 ++++++++++++++++++ .../src/lib/components/SettingsModal.svelte | 6 + cptr/frontend/src/lib/i18n/locales/en.json | 8 + cptr/routers/admin.py | 46 +++- cptr/utils/chat_task.py | 16 +- cptr/utils/tools.py | 100 ++++--- 8 files changed, 399 insertions(+), 71 deletions(-) create mode 100644 cptr/frontend/src/lib/components/Admin/Tools.svelte 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/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/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte index 07280d01..b2786126 100644 --- a/cptr/frontend/src/lib/components/SettingsModal.svelte +++ b/cptr/frontend/src/lib/components/SettingsModal.svelte @@ -14,6 +14,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'; @@ -40,6 +41,7 @@ | 'agents' | 'models' | 'chat' + | 'tools' | 'git' | 'skills' | 'messaging' @@ -71,6 +73,7 @@ 'agents', 'models', 'chat', + 'tools', 'git', 'messaging', 'gateway', @@ -102,6 +105,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' }, @@ -216,6 +220,8 @@ {:else if activeTab === 'chat'} + {:else if activeTab === 'tools'} + {:else if activeTab === 'git'} {:else if activeTab === 'skills'} diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index c8794d55..8cb6ae5e 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -466,6 +466,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 +477,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/utils/chat_task.py b/cptr/utils/chat_task.py index 813b6532..f4593875 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -56,6 +56,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 +743,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 +756,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], @@ -1925,7 +1928,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 +1956,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 +1987,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 +2003,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 +2497,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", From 9f078c1f06877956b17383678def9cd3431112eb Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 2 Aug 2026 16:40:50 -0400 Subject: [PATCH 04/20] refac --- cptr/routers/workspace.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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", From e2d7018cab9a92c304f5f24db7abf3fb5fe0f526 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 2 Aug 2026 16:46:21 -0400 Subject: [PATCH 05/20] refac --- cptr/utils/agents/opencode.py | 49 ++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/cptr/utils/agents/opencode.py b/cptr/utils/agents/opencode.py index 2875c230..e0c97c03 100644 --- a/cptr/utils/agents/opencode.py +++ b/cptr/utils/agents/opencode.py @@ -109,6 +109,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 +183,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 +199,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): @@ -314,7 +347,11 @@ async def run_opencode_agent( await _request( client, "POST", - ["session.abort", "session/abort"], + [ + f"session/{session_id}/abort", + "session.abort", + "session/abort", + ], headers=headers, json_body={"sessionID": session_id}, ) @@ -346,6 +383,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 +405,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) From c95233d04846d43496698479a6833e3ac6f2e4f1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 2 Aug 2026 16:48:23 -0400 Subject: [PATCH 06/20] refac --- cptr/utils/adapters/signal.py | 14 ++++++++++++-- cptr/utils/bridge.py | 14 ++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) 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/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) From 7f333407f28ae5c5b11f2a5e29fc420d1b7a1186 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 2 Aug 2026 16:49:04 -0400 Subject: [PATCH 07/20] refac --- cptr/utils/ai.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cptr/utils/ai.py b/cptr/utils/ai.py index 91d614b6..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, @@ -799,7 +806,7 @@ def complete_reasoning_item() -> dict | None: emitted = True yield {"type": "output", "item": item} if not emitted: - raise RuntimeError( + raise EmptyCompletionError( "Upstream provider returned an empty completion with no text, " "output items, tool calls, or usage tokens." ) From 64eb831b6147b9372cb3367499201a0d9e6a4ae9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 2 Aug 2026 16:49:31 -0400 Subject: [PATCH 08/20] refac --- .../src/lib/components/Settings/Account.svelte | 9 +++++++-- cptr/frontend/src/lib/components/Terminal.svelte | 13 ++++++++++--- .../src/lib/components/chat/ChatPanel.svelte | 5 +++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/cptr/frontend/src/lib/components/Settings/Account.svelte b/cptr/frontend/src/lib/components/Settings/Account.svelte index 86824915..acb80a94 100644 --- a/cptr/frontend/src/lib/components/Settings/Account.svelte +++ b/cptr/frontend/src/lib/components/Settings/Account.svelte @@ -63,7 +63,9 @@ function resizeImage(file: File): Promise { 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/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; From 61dfbd3bd11d7ac875ac02b45152355f0b574757 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 2 Aug 2026 16:50:01 -0400 Subject: [PATCH 09/20] refac --- .../src/lib/components/ChromeBrowser.svelte | 74 +++++++++++++++++-- cptr/utils/browser/viewer.py | 9 +++ 2 files changed, 76 insertions(+), 7 deletions(-) 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/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]: From 571fc4b4330cb93a90f694f3b107071703f02836 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 2 Aug 2026 17:08:40 -0400 Subject: [PATCH 10/20] refac --- cptr/utils/agents/detection.py | 41 +++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/cptr/utils/agents/detection.py b/cptr/utils/agents/detection.py index cbdd0c9f..960904c2 100644 --- a/cptr/utils/agents/detection.py +++ b/cptr/utils/agents/detection.py @@ -61,24 +61,45 @@ 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): + 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 root in roots: + if not os.path.isdir(root): + continue for name in os.listdir(root): version_dir = os.path.join(root, name) version = _parse_version_tuple(name) @@ -89,7 +110,7 @@ def _find_claude_desktop_command() -> str | None: 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 + return max(candidates, key=lambda item: item[0])[1] if candidates else None async def _run_probe( From d95d6192fe4aef98ec492ddc3b4bdd06e13be8e4 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 2 Aug 2026 17:14:04 -0400 Subject: [PATCH 11/20] refac --- cptr/utils/adapters/telegram.py | 3 +-- cptr/utils/agents/detection.py | 44 +++++++++++++++++---------------- 2 files changed, 24 insertions(+), 23 deletions(-) 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 960904c2..925205f1 100644 --- a/cptr/utils/agents/detection.py +++ b/cptr/utils/agents/detection.py @@ -68,18 +68,19 @@ def _find_claude_desktop_command() -> str | None: 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): - for name in os.listdir(packages_dir): - if name.startswith("Claude_"): - roots.append( - os.path.join( - packages_dir, - name, - "LocalCache", - "Roaming", - "Claude", - "claude-code", + 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: roots = [ @@ -100,16 +101,17 @@ def _find_claude_desktop_command() -> str | None: for root in roots: if not os.path.isdir(root): continue - 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 + 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 From e390ece009ba6fa4138cff5bc2af0503a9d48788 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 2 Aug 2026 17:31:49 -0400 Subject: [PATCH 12/20] refac --- cptr/frontend/src/lib/components/Admin/Agents.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) { From 57d7a66679368235263b4faa51fc8a635d4d0b05 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 2 Aug 2026 17:33:35 -0400 Subject: [PATCH 13/20] refac --- cptr/utils/agents/detection.py | 65 ++++++++----- cptr/utils/agents/opencode.py | 172 +++++++++++++++++++++------------ 2 files changed, 152 insertions(+), 85 deletions(-) diff --git a/cptr/utils/agents/detection.py b/cptr/utils/agents/detection.py index 925205f1..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 = [ @@ -456,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): @@ -512,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 e0c97c03..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}" @@ -306,70 +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", - [ - f"session/{session_id}/abort", - "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. From 9f7a28fc22ad7c10b2e3d98e3dbf6d1329004c07 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 4 Aug 2026 00:23:02 -0500 Subject: [PATCH 14/20] refac --- cptr/frontend/src/lib/apis/chat.ts | 36 ++ cptr/frontend/src/lib/components/Icon.svelte | 7 + .../src/lib/components/Settings/Usage.svelte | 512 ++++++++++++++++++ .../src/lib/components/SettingsModal.svelte | 5 + cptr/frontend/src/lib/i18n/locales/en.json | 33 ++ cptr/routers/chat.py | 216 ++++++++ 6 files changed, 809 insertions(+) create mode 100644 cptr/frontend/src/lib/components/Settings/Usage.svelte diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index 5809ee97..a547e8fc 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -78,6 +78,40 @@ 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 }[]; +} + // ── Queries ───────────────────────────────────────────────── export const getChats = ( @@ -96,6 +130,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/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'} + import { onMount } from 'svelte'; + import { toast } from 'svelte-sonner'; + import Spinner from '$lib/components/common/Spinner.svelte'; + import { getUsage, type UsageHeatmapEntry, type UsageResponse } from '$lib/apis/chat'; + import { i18next } from '$lib/i18n'; + import { chatModels } from '$lib/stores/chat'; + import { tooltip } from '$lib/tooltip'; + + type HeatmapMode = 'daily' | 'weekly' | 'cumulative'; + type HeatmapCell = UsageHeatmapEntry | null; + type MonthLabel = { label: string; column: number; span: number }; + + let usage = $state(null); + let loading = $state(true); + let heatmapMode = $state('daily'); + let heatmapContainerWidth = $state(0); + + const HEATMAP_MIN_CELL_PX = 8; + const HEATMAP_MAX_CELL_PX = 10; + const HEATMAP_GAP_PX = 4; + const MIN_HEATMAP_COLUMNS = 26; + const DEFAULT_HEATMAP_COLUMNS = 26; + const MIN_MONTH_LABEL_GAP = 6; + const tr = (key: string, opts?: Record) => i18next.t(key, opts) as string; + + const heatmapModes: Array<{ value: HeatmapMode; label: string }> = [ + { value: 'daily', label: 'usage.daily' }, + { value: 'weekly', label: 'usage.weekly' }, + { value: 'cumulative', label: 'usage.cumulative' } + ]; + + const palettes = [ + [ + 'bg-gray-100 dark:bg-gray-800', + 'bg-green-100 dark:bg-green-900/40', + 'bg-green-300 dark:bg-green-700/60', + 'bg-green-500 dark:bg-green-600/80', + 'bg-green-700 dark:bg-green-500' + ], + [ + 'bg-gray-100 dark:bg-gray-800', + 'bg-blue-100 dark:bg-blue-900/40', + 'bg-blue-300 dark:bg-blue-700/60', + 'bg-blue-500 dark:bg-blue-600/80', + 'bg-blue-700 dark:bg-blue-500' + ], + [ + 'bg-gray-100 dark:bg-gray-800', + 'bg-purple-100 dark:bg-purple-900/40', + 'bg-purple-300 dark:bg-purple-700/60', + 'bg-purple-500 dark:bg-purple-600/80', + 'bg-purple-700 dark:bg-purple-500' + ], + [ + 'bg-gray-100 dark:bg-gray-800', + 'bg-orange-100 dark:bg-orange-900/40', + 'bg-orange-300 dark:bg-orange-700/60', + 'bg-orange-500 dark:bg-orange-600/80', + 'bg-orange-700 dark:bg-orange-500' + ], + [ + 'bg-gray-100 dark:bg-gray-800', + 'bg-rose-100 dark:bg-rose-900/40', + 'bg-rose-300 dark:bg-rose-700/60', + 'bg-rose-500 dark:bg-rose-600/80', + 'bg-rose-700 dark:bg-rose-500' + ], + [ + 'bg-gray-100 dark:bg-gray-800', + 'bg-yellow-100 dark:bg-yellow-900/40', + 'bg-yellow-300 dark:bg-yellow-700/60', + 'bg-yellow-500 dark:bg-yellow-600/80', + 'bg-yellow-700 dark:bg-yellow-500' + ], + [ + 'bg-gray-100 dark:bg-gray-800', + 'bg-cyan-100 dark:bg-cyan-900/40', + 'bg-cyan-300 dark:bg-cyan-700/60', + 'bg-cyan-500 dark:bg-cyan-600/80', + 'bg-cyan-700 dark:bg-cyan-500' + ], + [ + 'bg-gray-100 dark:bg-gray-800', + 'bg-red-100 dark:bg-red-900/40', + 'bg-red-300 dark:bg-red-700/60', + 'bg-red-500 dark:bg-red-600/80', + 'bg-red-700 dark:bg-red-500' + ], + [ + 'bg-gray-100 dark:bg-gray-800', + 'bg-neutral-200 dark:bg-neutral-800', + 'bg-neutral-400 dark:bg-neutral-600', + 'bg-neutral-600 dark:bg-neutral-400', + 'bg-neutral-800 dark:bg-neutral-200' + ] + ]; + + const modelNames = $derived( + new Map($chatModels.map((model) => [model.id, model.name || model.id])) + ); + const dailyHeatmap = $derived(usage?.heatmap ?? []); + const weeklyHeatmap = $derived( + new Map((usage?.weekly_heatmap ?? []).map((entry) => [entry.date, entry])) + ); + const cumulativeHeatmap = $derived( + new Map((usage?.cumulative_heatmap ?? []).map((entry) => [entry.date, entry])) + ); + const heatmapData = $derived( + buildDisplayHeatmap(dailyHeatmap, heatmapMode, weeklyHeatmap, cumulativeHeatmap) + ); + const totalHeatmapColumns = $derived(Math.max(Math.ceil(heatmapData.length / 7), 1)); + const maxHeatmapColumns = $derived( + heatmapContainerWidth + ? Math.max( + Math.min(totalHeatmapColumns, MIN_HEATMAP_COLUMNS), + Math.floor( + (heatmapContainerWidth + HEATMAP_GAP_PX) / (HEATMAP_MIN_CELL_PX + HEATMAP_GAP_PX) + ) + ) + : Math.min(totalHeatmapColumns, DEFAULT_HEATMAP_COLUMNS) + ); + const visibleHeatmapColumns = $derived(Math.min(totalHeatmapColumns, maxHeatmapColumns)); + const heatmapCells = $derived(buildHeatmapCells(heatmapData, visibleHeatmapColumns)); + const heatmapColumns = $derived(Math.max(Math.ceil(heatmapCells.length / 7), 1)); + const heatmapGridWidth = $derived( + `${heatmapColumns * HEATMAP_MAX_CELL_PX + (heatmapColumns - 1) * HEATMAP_GAP_PX}px` + ); + const monthLabels = $derived(buildMonthLabels(heatmapCells, heatmapColumns)); + const hasUsage = $derived( + (usage?.totals.messages ?? 0) > 0 || (usage?.totals.lifetime_tokens ?? 0) > 0 + ); + + onMount(loadUsage); + + async function loadUsage() { + loading = true; + try { + usage = await getUsage(); + } catch { + usage = null; + toast.error(tr('usage.failedToLoad')); + } finally { + loading = false; + } + } + + function weekStart(dateString: string) { + const date = new Date(`${dateString}T00:00:00`); + const daysSinceMonday = (date.getDay() + 6) % 7; + date.setDate(date.getDate() - daysSinceMonday); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${date.getFullYear()}-${month}-${day}`; + } + + function buildDisplayHeatmap( + data: UsageHeatmapEntry[], + mode: HeatmapMode, + weekly: Map, + cumulative: Map + ) { + if (mode === 'daily') return data; + return data.map((day) => { + const aggregate = + mode === 'weekly' ? weekly.get(weekStart(day.date)) : cumulative.get(day.date); + return aggregate ? { ...aggregate, date: day.date } : day; + }); + } + + function buildHeatmapCells(data: UsageHeatmapEntry[], visibleColumns: number) { + if (data.length === 0) return []; + const cells = data.slice(-Math.max(7, visibleColumns * 7)); + const trailingBlanks = Array.from({ length: (7 - (cells.length % 7)) % 7 }, () => null); + return [...cells, ...trailingBlanks]; + } + + function buildMonthLabels(cells: HeatmapCell[], columns: number): MonthLabel[] { + const labels: MonthLabel[] = []; + let currentMonth = ''; + let lastLabelColumn = -MIN_MONTH_LABEL_GAP; + + cells.forEach((entry, index) => { + if (!entry) return; + const month = entry.date.slice(0, 7); + if (month === currentMonth) return; + + const column = Math.floor(index / 7) + 1; + if (column - lastLabelColumn < MIN_MONTH_LABEL_GAP) { + currentMonth = month; + return; + } + + labels.push({ + label: new Date(`${entry.date}T00:00:00`).toLocaleString(undefined, { month: 'short' }), + column, + span: Math.min(MIN_MONTH_LABEL_GAP, columns - column + 1) + }); + lastLabelColumn = column; + currentMonth = month; + }); + + return labels; + } + + function topItem(entry: UsageHeatmapEntry) { + return Object.entries(entry.models ?? {}).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null; + } + + function modelPalette(modelId: string | null) { + if (!modelId) return palettes[0]; + const index = Math.max(0, modelId.toLowerCase().charCodeAt(0) - 97) % palettes.length; + return palettes[index]; + } + + function intensity(entry: UsageHeatmapEntry) { + if (entry.messages >= 20) return 4; + if (entry.messages >= 10) return 3; + if (entry.messages >= 5) return 2; + if (entry.messages > 0) return 1; + return 0; + } + + function tooltipFor(entry: UsageHeatmapEntry) { + const label = + heatmapMode === 'weekly' + ? `${tr('usage.weekOf')} ${weekStart(entry.date)}` + : heatmapMode === 'cumulative' + ? `${tr('usage.through')} ${entry.date}` + : entry.date; + const model = topItem(entry); + const models = Object.entries(entry.models ?? {}).sort((a, b) => b[1] - a[1]); + const modelSummary = + models.length > 0 + ? models + .slice(0, 3) + .map(([id, count]) => `${modelName(id)} ${count}`) + .join(', ') + : tr('usage.noModelData'); + + return `${label}: ${formatNumber(entry.tokens)} ${tr('usage.tokens')}, ${entry.messages.toLocaleString()} ${tr('usage.messages')}, ${entry.chats.toLocaleString()} ${tr('usage.chats')}${model ? ` (${modelName(model)})` : ''}. ${modelSummary}`; + } + + function formatDuration(seconds: number) { + if (!seconds) return '0m'; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + } + + function formatNumber(value: number) { + return new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 1 + }).format(value); + } + + function modelName(id: string | null) { + return id ? (modelNames.get(id) ?? id) : '-'; + } + + +
+
+

{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 b2786126..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'; @@ -32,6 +33,7 @@ | 'general' | 'notifications' | 'appearance' + | 'usage' | 'memory' | 'pwa' | 'keyboard' @@ -91,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' } @@ -202,6 +205,8 @@ {:else if activeTab === 'appearance'} + {:else if activeTab === 'usage'} + {:else if activeTab === 'memory'} {:else if activeTab === 'pwa' && showPwaSettings} diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 8cb6ae5e..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", diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index bf0ce828..3ef1a183 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,218 @@ async def get_models(request: Request): return {"models": models, "default": default_model} +@router.get("/usage") +async def get_usage(request: Request): + """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.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": {}}) + chat_bounds: dict[str, list[int]] = {} + model_stats: dict[str, dict] = defaultdict(lambda: {"messages": 0, "total_tokens": 0}) + tool_counts: dict[str, int] = defaultdict(int) + models_used: set[str] = set() + 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 + ) + day = datetime.fromtimestamp(created_seconds, tz=timezone.utc).date().isoformat() + day_stats[day]["messages"] += 1 + chat_ids_by_day[day].add(message.chat_id) + + bounds = chat_bounds.setdefault(message.chat_id, [message.created_at, message.created_at]) + bounds[0] = min(bounds[0], message.created_at) + bounds[1] = max(bounds[1], message.created_at) + + if message.role == "user": + user_messages += 1 + elif message.role == "assistant": + assistant_messages += 1 + + 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 + day_stats[day]["tokens"] += tokens + + if message.role == "assistant" and model_id: + models_used.add(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 + + if isinstance(message.output, list): + for item in message.output: + if not isinstance(item, dict): + continue + item_type = str(item.get("type") or "") + if "tool" not in item_type and "function_call" not in item_type: + continue + name = item.get("name") + if not name and isinstance(item.get("function"), dict): + name = item["function"].get("name") + if isinstance(name, str) and name: + tool_counts[name] += 1 + + today = datetime.now(timezone.utc).date() + start = today - timedelta(days=364) + heatmap = [] + cumulative_tokens = 0 + cumulative_messages = 0 + cumulative_chats: set[str] = set() + weekly: dict[str, dict] = defaultdict( + lambda: {"tokens": 0, "messages": 0, "chats": set(), "models": {}} + ) + cumulative = [] + + for offset in range(365): + 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"].update(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.update(chats_for_day) + cumulative.append( + { + "date": key, + "tokens": cumulative_tokens, + "messages": cumulative_messages, + "chats": len(cumulative_chats), + "models": dict(stats["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 = 0 + for first, last in chat_bounds.values(): + longest_chat_seconds = max(longest_chat_seconds, max(0, (last - first) // 1000)) + + weekly_heatmap = [ + { + "date": week, + "tokens": stats["tokens"], + "messages": stats["messages"], + "chats": len(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) if total_chats else 0, + "average_messages_per_active_day": round(total_messages / len(active_days)) + if active_days + else 0, + "user_message_share": round((user_messages / total_messages) * 100) + if total_messages + else 0, + "assistant_message_share": round((assistant_messages / total_messages) * 100) + 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 + ) + ], + "top_tools": [ + {"name": name, "count": count} + for name, count in sorted(tool_counts.items(), key=lambda item: item[1], reverse=True) + ], + } + + async def _fetch_provider_models(conn: dict) -> list[str] | None: """Discover models from a provider's /models endpoint.""" import httpx From 368946e5a114909e90a96340af127ef06ee5d438 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 4 Aug 2026 00:24:46 -0500 Subject: [PATCH 15/20] refac --- dev.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 6dba55b7ffc1f0f4eb151f45b3d7110a8b2b718c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 4 Aug 2026 00:27:36 -0500 Subject: [PATCH 16/20] refac --- cptr/routers/chat.py | 6 +----- cptr/utils/chat_task.py | 8 +++++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index 3ef1a183..8e6aa5e8 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -940,7 +940,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) @@ -950,8 +950,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 = {} @@ -961,8 +959,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/utils/chat_task.py b/cptr/utils/chat_task.py index f4593875..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 @@ -1545,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) @@ -1557,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, @@ -1573,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, @@ -1629,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, From 7321945825b886aedb648277a09e032ea723d48a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 4 Aug 2026 00:36:06 -0500 Subject: [PATCH 17/20] refac --- cptr/frontend/src/lib/apis/chat.ts | 1 + .../src/lib/components/Settings/Usage.svelte | 57 ++++---- cptr/routers/chat.py | 128 ++++++++++++------ 3 files changed, 116 insertions(+), 70 deletions(-) diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index a547e8fc..5ebf89b2 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -110,6 +110,7 @@ export interface UsageResponse { 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 ───────────────────────────────────────────────── diff --git a/cptr/frontend/src/lib/components/Settings/Usage.svelte b/cptr/frontend/src/lib/components/Settings/Usage.svelte index 446caa93..d19caff9 100644 --- a/cptr/frontend/src/lib/components/Settings/Usage.svelte +++ b/cptr/frontend/src/lib/components/Settings/Usage.svelte @@ -120,7 +120,7 @@ ) : Math.min(totalHeatmapColumns, DEFAULT_HEATMAP_COLUMNS) ); - const visibleHeatmapColumns = $derived(Math.min(totalHeatmapColumns, maxHeatmapColumns)); + const visibleHeatmapColumns = $derived(maxHeatmapColumns); const heatmapCells = $derived(buildHeatmapCells(heatmapData, visibleHeatmapColumns)); const heatmapColumns = $derived(Math.max(Math.ceil(heatmapCells.length / 7), 1)); const heatmapGridWidth = $derived( @@ -169,10 +169,15 @@ } function buildHeatmapCells(data: UsageHeatmapEntry[], visibleColumns: number) { - if (data.length === 0) return []; - const cells = data.slice(-Math.max(7, visibleColumns * 7)); + const targetCells = Math.max(7, visibleColumns * 7); + if (data.length === 0) return Array.from({ length: targetCells }, () => null); + const cells = data.slice(-targetCells); const trailingBlanks = Array.from({ length: (7 - (cells.length % 7)) % 7 }, () => null); - return [...cells, ...trailingBlanks]; + const leadingBlanks = Array.from( + { length: Math.max(0, targetCells - cells.length - trailingBlanks.length) }, + () => null + ); + return [...leadingBlanks, ...cells, ...trailingBlanks]; } function buildMonthLabels(cells: HeatmapCell[], columns: number): MonthLabel[] { @@ -279,8 +284,8 @@
{:else}
-
-

{tr('usage.overview')}

+
+

{tr('usage.overview')}

@@ -364,7 +369,7 @@ use:tooltip={tooltipFor(entry)} >
{:else} -
+
{/if} {/each}
@@ -398,17 +403,17 @@
{#if !hasUsage} -
-

{tr('usage.activity')}

+
+

{tr('usage.activity')}

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

+
+

{tr('usage.activityInsights')}

-
-
+
+
{tr('usage.models')} @@ -416,7 +421,7 @@ >{usage.totals.models_used.toLocaleString()}
-
+
{tr('usage.averageTokensPerChat')} @@ -424,7 +429,7 @@ >{formatNumber(usage.insights.average_tokens_per_chat)}
-
+
{tr('usage.averageMessagesPerActiveDay')} @@ -432,7 +437,7 @@ >{usage.insights.average_messages_per_active_day.toLocaleString()}
-
+
{tr('usage.userMessages')} @@ -441,7 +446,7 @@ .user_message_share}%
-
+
{tr('usage.assistantMessages')} @@ -450,7 +455,7 @@ .assistant_message_share}%
-
+
{tr('usage.totalChats')} @@ -461,14 +466,14 @@
-
-

{tr('usage.topModels')}

+
+

{tr('usage.topModels')}

{#if usage.top_models.length === 0}
{tr('usage.noModelUsage')}
{:else} -
+
{#each usage.top_models as model} -
+
{modelName(model.model_id)} @@ -483,13 +488,13 @@
{#if usage.top_tools.length > 0} -
-

+
+

{tr('usage.mostUsedTools')}

-
+
{#each usage.top_tools as tool} -
+
{tool.name} diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index 8e6aa5e8..7748e338 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -346,16 +346,18 @@ async def get_usage(request: Request): message_result = await db.execute( select(ChatMessage) .where(ChatMessage.chat_id.in_([chat.id for chat in chats])) - .order_by(ChatMessage.created_at) + .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": {}}) - chat_bounds: dict[str, list[int]] = {} 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 @@ -369,19 +371,8 @@ async def get_usage(request: Request): if created_at > 10_000_000_000 else 1 ) - day = datetime.fromtimestamp(created_seconds, tz=timezone.utc).date().isoformat() - day_stats[day]["messages"] += 1 - chat_ids_by_day[day].add(message.chat_id) - - bounds = chat_bounds.setdefault(message.chat_id, [message.created_at, message.created_at]) - bounds[0] = min(bounds[0], message.created_at) - bounds[1] = max(bounds[1], message.created_at) - - if message.role == "user": - user_messages += 1 - elif message.role == "assistant": - assistant_messages += 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: @@ -398,40 +389,82 @@ async def get_usage(request: Request): tokens = 0 if tokens: lifetime_tokens += tokens - day_stats[day]["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() + first_day = min((day for _, day, _ in rows), default=today - timedelta(days=364)) + start = max(first_day, today - timedelta(days=729)) + 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 - if isinstance(message.output, list): - for item in message.output: + 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" not in item_type and "function_call" not in item_type: - continue - name = item.get("name") - if not name and isinstance(item.get("function"), dict): - name = item["function"].get("name") - if isinstance(name, str) and name: - tool_counts[name] += 1 + 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]) - today = datetime.now(timezone.utc).date() - start = today - timedelta(days=364) heatmap = [] cumulative_tokens = 0 cumulative_messages = 0 - cumulative_chats: set[str] = set() + cumulative_chats = 0 + cumulative_models: dict[str, int] = {} weekly: dict[str, dict] = defaultdict( - lambda: {"tokens": 0, "messages": 0, "chats": set(), "models": {}} + lambda: {"tokens": 0, "messages": 0, "chats": 0, "models": {}} ) cumulative = [] - for offset in range(365): + 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": {}}) @@ -449,20 +482,22 @@ async def get_usage(request: Request): weekly_entry = weekly[week_key] weekly_entry["tokens"] += stats["tokens"] weekly_entry["messages"] += stats["messages"] - weekly_entry["chats"].update(chats_for_day) + 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.update(chats_for_day) + 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": len(cumulative_chats), - "models": dict(stats["models"]), + "chats": cumulative_chats, + "models": dict(cumulative_models), } ) @@ -483,16 +518,14 @@ async def get_usage(request: Request): longest_streak = max(longest_streak, run) total_chats = len(chats) - longest_chat_seconds = 0 - for first, last in chat_bounds.values(): - longest_chat_seconds = max(longest_chat_seconds, max(0, (last - first) // 1000)) + longest_chat_seconds = max(active_seconds_by_chat.values(), default=0) weekly_heatmap = [ { "date": week, "tokens": stats["tokens"], "messages": stats["messages"], - "chats": len(stats["chats"]), + "chats": stats["chats"], "models": stats["models"], } for week, stats in sorted(weekly.items()) @@ -513,14 +546,16 @@ async def get_usage(request: Request): "total_chats": total_chats, }, "insights": { - "average_tokens_per_chat": round(lifetime_tokens / total_chats) if total_chats else 0, - "average_messages_per_active_day": round(total_messages / len(active_days)) + "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) + "user_message_share": round((user_messages / total_messages) * 100, 1) if total_messages else 0, - "assistant_message_share": round((assistant_messages / total_messages) * 100) + "assistant_message_share": round((assistant_messages / total_messages) * 100, 1) if total_messages else 0, }, @@ -531,12 +566,17 @@ async def get_usage(request: Request): {"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, + }, } From e65aa7070df07645ccd71ccb15fa1f61e2981085 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 4 Aug 2026 00:38:46 -0500 Subject: [PATCH 18/20] refac --- cptr/frontend/src/lib/apis/chat.ts | 2 +- .../src/lib/components/Settings/Usage.svelte | 17 ++++++----------- cptr/routers/chat.py | 5 ++--- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index 5ebf89b2..c605968b 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -131,7 +131,7 @@ export const getChat = (chatId: string, modelId?: string) => { return fetchJSON(`/api/chats/${chatId}${suffix}`); }; -export const getUsage = () => fetchJSON('/api/chats/usage'); +export const getUsage = (days = 730) => fetchJSON(`/api/chats/usage?days=${days}`); export const deleteChat = (chatId: string) => fetchJSON<{ ok: boolean }>(`/api/chats/${chatId}`, { method: 'DELETE' }); diff --git a/cptr/frontend/src/lib/components/Settings/Usage.svelte b/cptr/frontend/src/lib/components/Settings/Usage.svelte index d19caff9..4da93f3b 100644 --- a/cptr/frontend/src/lib/components/Settings/Usage.svelte +++ b/cptr/frontend/src/lib/components/Settings/Usage.svelte @@ -120,7 +120,7 @@ ) : Math.min(totalHeatmapColumns, DEFAULT_HEATMAP_COLUMNS) ); - const visibleHeatmapColumns = $derived(maxHeatmapColumns); + const visibleHeatmapColumns = $derived(Math.min(totalHeatmapColumns, maxHeatmapColumns)); const heatmapCells = $derived(buildHeatmapCells(heatmapData, visibleHeatmapColumns)); const heatmapColumns = $derived(Math.max(Math.ceil(heatmapCells.length / 7), 1)); const heatmapGridWidth = $derived( @@ -136,7 +136,7 @@ async function loadUsage() { loading = true; try { - usage = await getUsage(); + usage = await getUsage(730); } catch { usage = null; toast.error(tr('usage.failedToLoad')); @@ -169,15 +169,10 @@ } function buildHeatmapCells(data: UsageHeatmapEntry[], visibleColumns: number) { - const targetCells = Math.max(7, visibleColumns * 7); - if (data.length === 0) return Array.from({ length: targetCells }, () => null); - const cells = data.slice(-targetCells); + if (data.length === 0) return []; + const cells = data.slice(-Math.max(7, visibleColumns * 7)); const trailingBlanks = Array.from({ length: (7 - (cells.length % 7)) % 7 }, () => null); - const leadingBlanks = Array.from( - { length: Math.max(0, targetCells - cells.length - trailingBlanks.length) }, - () => null - ); - return [...leadingBlanks, ...cells, ...trailingBlanks]; + return [...cells, ...trailingBlanks]; } function buildMonthLabels(cells: HeatmapCell[], columns: number): MonthLabel[] { @@ -369,7 +364,7 @@ use:tooltip={tooltipFor(entry)} >
{:else} -
+
{/if} {/each}
diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index 7748e338..6edee4c9 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -329,7 +329,7 @@ async def get_models(request: Request): @router.get("/usage") -async def get_usage(request: Request): +async def get_usage(request: Request, days: int = Query(730, ge=7, le=732)): """Aggregate personal chat usage for the settings Usage tab.""" user_id = _get_user(request) @@ -402,8 +402,7 @@ async def get_usage(request: Request): rows.append((message, day_date, tokens)) today = datetime.now(timezone.utc).date() - first_day = min((day for _, day, _ in rows), default=today - timedelta(days=364)) - start = max(first_day, today - timedelta(days=729)) + start = today - timedelta(days=days - 1) period_start = int( datetime.combine(start, datetime.min.time(), tzinfo=timezone.utc).timestamp() ) From d6734141e353d2f0c99400b174d5a439a2bbd159 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 4 Aug 2026 00:41:22 -0500 Subject: [PATCH 19/20] refac --- cptr/frontend/src/lib/apis/chat.ts | 2 +- cptr/frontend/src/lib/components/Settings/Usage.svelte | 2 +- cptr/routers/chat.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index c605968b..5ebf89b2 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -131,7 +131,7 @@ export const getChat = (chatId: string, modelId?: string) => { return fetchJSON(`/api/chats/${chatId}${suffix}`); }; -export const getUsage = (days = 730) => fetchJSON(`/api/chats/usage?days=${days}`); +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/Settings/Usage.svelte b/cptr/frontend/src/lib/components/Settings/Usage.svelte index 4da93f3b..453d9c1f 100644 --- a/cptr/frontend/src/lib/components/Settings/Usage.svelte +++ b/cptr/frontend/src/lib/components/Settings/Usage.svelte @@ -136,7 +136,7 @@ async function loadUsage() { loading = true; try { - usage = await getUsage(730); + usage = await getUsage(); } catch { usage = null; toast.error(tr('usage.failedToLoad')); diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index 6edee4c9..e7cbd6f3 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -329,7 +329,7 @@ async def get_models(request: Request): @router.get("/usage") -async def get_usage(request: Request, days: int = Query(730, ge=7, le=732)): +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) @@ -402,6 +402,7 @@ async def get_usage(request: Request, days: int = Query(730, ge=7, le=732)): 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() From a112a9cece85a637fc544846837d4db8db451a04 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 4 Aug 2026 00:42:53 -0500 Subject: [PATCH 20/20] refac --- CHANGELOG.md | 22 ++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) 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/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" },