diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c764f58..fdd7b460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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.17] - 2026-07-31 + +### Changed + +- 🤖 **Agent chats remember more when they start.** Codex and Claude Code now get helpful recent context at the beginning of a session, so follow-up requests are less likely to feel disconnected. +- 💬 **Open WebUI conversations carry over more naturally.** When a chat starts through Open WebUI, Computer now brings along earlier messages before answering the latest one. + +### Fixed + +- 🔔 **Task notifications are clearer when Computer needs you.** Notifications now say when a task is waiting for approval or an answer, with wording in every supported language. + ## [0.9.16] - 2026-07-31 ### Added diff --git a/cptr/env.py b/cptr/env.py index 14ad9d4a..fb6eb43b 100644 --- a/cptr/env.py +++ b/cptr/env.py @@ -63,6 +63,9 @@ def _env_int(name: str, default: int) -> int: CHAT_TOOL_MAX_CHARS = int(os.environ.get("CHAT_TOOL_MAX_CHARS", "50000")) CHAT_TOOL_COMMAND_MAX_CHARS = int(os.environ.get("CHAT_TOOL_COMMAND_MAX_CHARS", "8000")) CHAT_COMPACT_TOKEN_THRESHOLD = int(os.environ.get("CHAT_COMPACT_TOKEN_THRESHOLD", "80000")) +AGENT_SEED_TRANSCRIPT_MAX_CHARS = max( + 0, _env_int("CHAT_AGENT_SEED_TRANSCRIPT_MAX_CHARS", 12000) +) # Claude SDK stdout JSON buffer; chat/tool output caps apply later after parsing. CLAUDE_CODE_MAX_BUFFER_SIZE = _env_int("CPTR_CLAUDE_CODE_MAX_BUFFER_SIZE", 128 * 1024 * 1024) diff --git a/cptr/frontend/src/lib/i18n/locales/de.json b/cptr/frontend/src/lib/i18n/locales/de.json index c0fbd75a..7815cd8a 100644 --- a/cptr/frontend/src/lib/i18n/locales/de.json +++ b/cptr/frontend/src/lib/i18n/locales/de.json @@ -850,6 +850,12 @@ "chat.speakResponses": "Antworten vorlesen", "chat.stopSpeaking": "Vorlesen stoppen", "chat.fallbackTitle": "Unterhaltung", + "chat.notification.approvalTitle": "Genehmigung erforderlich: {{title}}", + "chat.notification.inputTitle": "Eingabe erforderlich: {{title}}", + "chat.notification.approvalBody": "Erlaube oder verweigere {{tool}}, um fortzufahren.", + "chat.notification.inputBody": "Beantworte die Eingabeaufforderung, um fortzufahren.", + "chat.notification.finishedBody": "Aufgabe abgeschlossen.", + "chat.notification.thisTool": "dieses Tool", "chat.greeting": "Wie kann ich Ihnen helfen?", "admin.toolServers": "Tool-Server", "toolServers.title": "Tool-Server", diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index da0acd33..29ebdc56 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -887,6 +887,12 @@ "chat.speakResponses": "Speak responses", "chat.stopSpeaking": "Stop speaking", "chat.fallbackTitle": "Chat", + "chat.notification.approvalTitle": "Approval needed: {{title}}", + "chat.notification.inputTitle": "Input needed: {{title}}", + "chat.notification.approvalBody": "Allow or deny {{tool}} to continue.", + "chat.notification.inputBody": "Answer the prompt to continue.", + "chat.notification.finishedBody": "Task finished.", + "chat.notification.thisTool": "this tool", "chat.greeting": "What can I help you with?", "admin.toolServers": "Tool Servers", "toolServers.title": "Tool Servers", diff --git a/cptr/frontend/src/lib/i18n/locales/es.json b/cptr/frontend/src/lib/i18n/locales/es.json index c43b0c2e..f535ab3c 100644 --- a/cptr/frontend/src/lib/i18n/locales/es.json +++ b/cptr/frontend/src/lib/i18n/locales/es.json @@ -850,6 +850,12 @@ "chat.speakResponses": "Leer respuestas", "chat.stopSpeaking": "Detener lectura", "chat.fallbackTitle": "Conversación", + "chat.notification.approvalTitle": "Aprobación necesaria: {{title}}", + "chat.notification.inputTitle": "Entrada necesaria: {{title}}", + "chat.notification.approvalBody": "Permite o deniega {{tool}} para continuar.", + "chat.notification.inputBody": "Responde la solicitud para continuar.", + "chat.notification.finishedBody": "Tarea finalizada.", + "chat.notification.thisTool": "esta herramienta", "chat.greeting": "¿En qué puedo ayudarte?", "admin.toolServers": "Servidores de herramientas", "toolServers.title": "Servidores de herramientas", diff --git a/cptr/frontend/src/lib/i18n/locales/fr.json b/cptr/frontend/src/lib/i18n/locales/fr.json index 059e1204..a9fd9028 100644 --- a/cptr/frontend/src/lib/i18n/locales/fr.json +++ b/cptr/frontend/src/lib/i18n/locales/fr.json @@ -850,6 +850,12 @@ "chat.speakResponses": "Lire les réponses", "chat.stopSpeaking": "Arrêter la lecture", "chat.fallbackTitle": "Discussion", + "chat.notification.approvalTitle": "Approbation requise : {{title}}", + "chat.notification.inputTitle": "Saisie requise : {{title}}", + "chat.notification.approvalBody": "Autorisez ou refusez {{tool}} pour continuer.", + "chat.notification.inputBody": "Répondez à la demande pour continuer.", + "chat.notification.finishedBody": "Tâche terminée.", + "chat.notification.thisTool": "cet outil", "chat.greeting": "Comment puis-je vous aider ?", "admin.toolServers": "Serveurs d'outils", "toolServers.title": "Serveurs d'outils", diff --git a/cptr/frontend/src/lib/i18n/locales/ja.json b/cptr/frontend/src/lib/i18n/locales/ja.json index 3993cdb2..8b7a0a8c 100644 --- a/cptr/frontend/src/lib/i18n/locales/ja.json +++ b/cptr/frontend/src/lib/i18n/locales/ja.json @@ -850,6 +850,12 @@ "chat.speakResponses": "応答を読み上げる", "chat.stopSpeaking": "読み上げを停止", "chat.fallbackTitle": "チャット", + "chat.notification.approvalTitle": "承認が必要: {{title}}", + "chat.notification.inputTitle": "入力が必要: {{title}}", + "chat.notification.approvalBody": "続行するには {{tool}} を許可または拒否してください。", + "chat.notification.inputBody": "続行するにはプロンプトに回答してください。", + "chat.notification.finishedBody": "タスクが完了しました。", + "chat.notification.thisTool": "このツール", "chat.greeting": "何かお手伝いできますか?", "admin.toolServers": "ツールサーバー", "toolServers.title": "ツールサーバー", diff --git a/cptr/frontend/src/lib/i18n/locales/ko.json b/cptr/frontend/src/lib/i18n/locales/ko.json index 340a194b..2cb6f35f 100644 --- a/cptr/frontend/src/lib/i18n/locales/ko.json +++ b/cptr/frontend/src/lib/i18n/locales/ko.json @@ -850,6 +850,12 @@ "chat.speakResponses": "응답 읽기", "chat.stopSpeaking": "읽기 중지", "chat.fallbackTitle": "채팅", + "chat.notification.approvalTitle": "승인 필요: {{title}}", + "chat.notification.inputTitle": "입력 필요: {{title}}", + "chat.notification.approvalBody": "계속하려면 {{tool}}을(를) 허용하거나 거부하세요.", + "chat.notification.inputBody": "계속하려면 프롬프트에 답하세요.", + "chat.notification.finishedBody": "작업이 완료되었습니다.", + "chat.notification.thisTool": "이 도구", "chat.greeting": "무엇을 도와드릴까요?", "admin.toolServers": "도구 서버", "toolServers.title": "도구 서버", diff --git a/cptr/frontend/src/lib/i18n/locales/pt-BR.json b/cptr/frontend/src/lib/i18n/locales/pt-BR.json index d9cb79a2..2dac8af1 100644 --- a/cptr/frontend/src/lib/i18n/locales/pt-BR.json +++ b/cptr/frontend/src/lib/i18n/locales/pt-BR.json @@ -850,6 +850,12 @@ "chat.speakResponses": "Ler respostas", "chat.stopSpeaking": "Parar leitura", "chat.fallbackTitle": "Conversa", + "chat.notification.approvalTitle": "Aprovação necessária: {{title}}", + "chat.notification.inputTitle": "Entrada necessária: {{title}}", + "chat.notification.approvalBody": "Permita ou negue {{tool}} para continuar.", + "chat.notification.inputBody": "Responda à solicitação para continuar.", + "chat.notification.finishedBody": "Tarefa concluída.", + "chat.notification.thisTool": "esta ferramenta", "chat.greeting": "Como posso ajudar?", "admin.toolServers": "Servidores de ferramentas", "toolServers.title": "Servidores de ferramentas", diff --git a/cptr/frontend/src/lib/i18n/locales/ru.json b/cptr/frontend/src/lib/i18n/locales/ru.json index 33a92d76..98cb5427 100644 --- a/cptr/frontend/src/lib/i18n/locales/ru.json +++ b/cptr/frontend/src/lib/i18n/locales/ru.json @@ -850,6 +850,12 @@ "chat.speakResponses": "Озвучивать ответы", "chat.stopSpeaking": "Остановить озвучивание", "chat.fallbackTitle": "Чат", + "chat.notification.approvalTitle": "Требуется подтверждение: {{title}}", + "chat.notification.inputTitle": "Требуется ввод: {{title}}", + "chat.notification.approvalBody": "Разрешите или запретите {{tool}}, чтобы продолжить.", + "chat.notification.inputBody": "Ответьте на запрос, чтобы продолжить.", + "chat.notification.finishedBody": "Задача завершена.", + "chat.notification.thisTool": "этот инструмент", "chat.greeting": "Чем могу помочь?", "admin.toolServers": "Серверы инструментов", "toolServers.title": "Серверы инструментов", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-CN.json b/cptr/frontend/src/lib/i18n/locales/zh-CN.json index 0a34144f..6e750780 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-CN.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-CN.json @@ -850,6 +850,12 @@ "chat.speakResponses": "朗读响应", "chat.stopSpeaking": "停止朗读", "chat.fallbackTitle": "聊天", + "chat.notification.approvalTitle": "需要批准:{{title}}", + "chat.notification.inputTitle": "需要输入:{{title}}", + "chat.notification.approvalBody": "允许或拒绝 {{tool}} 以继续。", + "chat.notification.inputBody": "回答提示以继续。", + "chat.notification.finishedBody": "任务已完成。", + "chat.notification.thisTool": "此工具", "chat.greeting": "有什么可以帮您的?", "admin.toolServers": "工具服务器", "toolServers.title": "工具服务器", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-TW.json b/cptr/frontend/src/lib/i18n/locales/zh-TW.json index 67c0a202..18d5866d 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-TW.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-TW.json @@ -850,6 +850,12 @@ "chat.speakResponses": "朗讀回應", "chat.stopSpeaking": "停止朗讀", "chat.fallbackTitle": "聊天", + "chat.notification.approvalTitle": "需要批准:{{title}}", + "chat.notification.inputTitle": "需要輸入:{{title}}", + "chat.notification.approvalBody": "允許或拒絕 {{tool}} 以繼續。", + "chat.notification.inputBody": "回答提示以繼續。", + "chat.notification.finishedBody": "任務已完成。", + "chat.notification.thisTool": "此工具", "chat.greeting": "有什麼可以幫您的?", "admin.toolServers": "工具伺服器", "toolServers.title": "工具伺服器", diff --git a/cptr/frontend/src/lib/stores/chat.ts b/cptr/frontend/src/lib/stores/chat.ts index 3ccffe8a..04d289ef 100644 --- a/cptr/frontend/src/lib/stores/chat.ts +++ b/cptr/frontend/src/lib/stores/chat.ts @@ -7,6 +7,7 @@ import { fetchJSON } from '$lib/apis'; import { socketStore } from '$lib/stores/socket.svelte'; import { activeTab } from '$lib/stores'; import { getPathDisplayName, isSupportedWorkspacePath } from '$lib/utils/paths'; +import { i18next } from '$lib/i18n'; export const chatEnabled = writable(false); @@ -139,9 +140,11 @@ export function bindGlobalChatListener() { (data: { type?: string; chat_id: string; + status?: string; done?: boolean; title?: string; content?: string; + tool_name?: string; workspace?: string; workspace_name?: string; active?: boolean; @@ -187,8 +190,24 @@ export function bindGlobalChatListener() { const workspacePath = isSupportedWorkspacePath(data.workspace) ? data.workspace : ''; const workspaceDisplayName = data.workspace_name || getPathDisplayName(workspacePath); const workspaceTitlePrefix = workspaceDisplayName ? `[${workspaceDisplayName}] ` : ''; - const title = `${workspaceTitlePrefix}${data.title || 'Chat'}`; - const body = data.content || ''; + const chatTitle = data.title || i18next.t('chat.fallbackTitle'); + const approvalRequired = data.status === 'approval_required'; + const inputRequired = data.status === 'input_required'; + const toolName = data.tool_name?.replace(/_/g, ' '); + const title = approvalRequired + ? `${workspaceTitlePrefix}${i18next.t('chat.notification.approvalTitle', { title: chatTitle })}` + : inputRequired + ? `${workspaceTitlePrefix}${i18next.t('chat.notification.inputTitle', { title: chatTitle })}` + : `${workspaceTitlePrefix}${chatTitle}`; + const body = + data.content || + (approvalRequired + ? i18next.t('chat.notification.approvalBody', { + tool: toolName || i18next.t('chat.notification.thisTool') + }) + : inputRequired + ? i18next.t('chat.notification.inputBody') + : i18next.t('chat.notification.finishedBody')); const openChat = async () => { const { goto } = await import('$app/navigation'); const workspaceQuery = workspacePath diff --git a/cptr/routers/gateway.py b/cptr/routers/gateway.py index 3f62d0d5..92fc613b 100644 --- a/cptr/routers/gateway.py +++ b/cptr/routers/gateway.py @@ -31,6 +31,7 @@ from cptr.models import Chat, ChatMessage, Config from cptr.models.workspaces import Workspace +from cptr.utils.agents.prompts import message_text from cptr.utils.config import now_ms logger = logging.getLogger(__name__) @@ -281,11 +282,41 @@ async def _resolve_messages( (m for m in reversed(messages) if m.get("role") == "user"), None, ) - user_content = (last_user.get("content", "") if last_user else "") or "" + user_content = message_text(last_user).strip() if last_user else "" # Determine parent for the user message cptr_parent_id = chat.current_message_id if chat else None + # First time this OWUI chat reaches cptr, copy the request's existing + # transcript into the parent chain so the native agent can cold-start with it. + if chat and not chat.current_message_id and messages: + last_user_index = None + for index in range(len(messages) - 1, -1, -1): + if messages[index].get("role") == "user": + last_user_index = index + break + if last_user_index: + created_at = now_ms() + for offset, message in enumerate(messages[:last_user_index]): + role = message.get("role") + if role not in ("user", "assistant"): + continue + content = message_text(message).strip() + if not content: + continue + imported = await ChatMessage.create( + chat_id=chat_id, + role=role, + content=content, + parent_id=cptr_parent_id, + done=True, + created_at=created_at + offset, + ) + cptr_parent_id = imported.id + owui_id = str(message.get("id") or "").strip() + if owui_id: + msg_map[owui_id] = imported.id + if owui_user_id and owui_user_id in msg_map: # User message already exists in cptr (regeneration case) existing_user_msg = await ChatMessage.get_by_id(msg_map[owui_user_id]) diff --git a/cptr/utils/agents/claude_code.py b/cptr/utils/agents/claude_code.py index 7ded3876..3f02b076 100644 --- a/cptr/utils/agents/claude_code.py +++ b/cptr/utils/agents/claude_code.py @@ -17,7 +17,7 @@ AgentTextDelta, AgentToolUpdate, ) -from cptr.utils.agents.prompts import latest_user_text +from cptr.utils.agents.prompts import session_turn_prompt_text _claude_clients: dict[str, tuple[Any, tuple[Any, ...]]] = {} @@ -125,7 +125,6 @@ async def run_claude_code_agent( yield AgentError("Claude Code support requires the claude-agent-sdk Python package") return - prompt = latest_user_text(messages) env = os.environ.copy() home = os.path.expanduser(str(profile["home"])) if profile.get("home") else None if home: @@ -143,6 +142,7 @@ async def run_claude_code_agent( if resume_state: value = resume_state.get("session_id") session_id = value if isinstance(value, str) and value else None + prompt = session_turn_prompt_text(messages, resumed=bool(session_id)) options_kwargs: dict[str, Any] = { "system_prompt": system_prompt or None, diff --git a/cptr/utils/agents/codex.py b/cptr/utils/agents/codex.py index ff209eee..66e557a2 100644 --- a/cptr/utils/agents/codex.py +++ b/cptr/utils/agents/codex.py @@ -21,7 +21,7 @@ AgentToolOutputDelta, AgentToolUpdate, ) -from cptr.utils.agents.prompts import latest_user_text +from cptr.utils.agents.prompts import session_turn_prompt_text CODEX_STDOUT_CHUNK_SIZE = 64 * 1024 CODEX_MAX_WIRE_MESSAGE_CHARS = 16 * 1024 * 1024 @@ -375,7 +375,7 @@ async def run_codex_agent( yield AgentError("Codex did not return a thread id") return - prompt = latest_user_text(messages) + prompt = session_turn_prompt_text(messages, resumed=bool(_resume_thread_id(resume_state))) turn_input: list[dict[str, Any]] = [] if prompt: turn_input.append({"type": "text", "text": prompt}) diff --git a/cptr/utils/agents/prompts.py b/cptr/utils/agents/prompts.py index 6b4a3887..ee253b32 100644 --- a/cptr/utils/agents/prompts.py +++ b/cptr/utils/agents/prompts.py @@ -4,6 +4,8 @@ from typing import Any +from cptr.env import AGENT_SEED_TRANSCRIPT_MAX_CHARS + def message_text(message: dict[str, Any]) -> str: content = message.get("content", "") @@ -14,17 +16,62 @@ def message_text(message: dict[str, Any]) -> str: return str(content or "") -def latest_user_text(messages: list[dict[str, Any]]) -> str: - """Return only the current user turn for native-session agents.""" +def session_turn_prompt_text(messages: list[dict[str, Any]], resumed: bool) -> str: + """Return the prompt for a native-session agent turn.""" + + last_user_index = None + for index in range(len(messages) - 1, -1, -1): + if messages[index].get("role") == "user": + last_user_index = index + break + if last_user_index is None: + return "" + + prompt = message_text(messages[last_user_index]).strip() + if resumed or AGENT_SEED_TRANSCRIPT_MAX_CHARS <= 0: + return prompt + + parts = [] + for message in messages[:last_user_index]: + role = message.get("role") + if role not in ("user", "assistant"): + continue + text = message_text(message).strip() + if text: + parts.append(f"<{role}>\n{text}\n") - for message in reversed(messages): - if message.get("role") == "user": - return message_text(message).strip() - return "" + selected = [] + total = 0 + truncated = False + for part in reversed(parts): + if len(part) > AGENT_SEED_TRANSCRIPT_MAX_CHARS: + selected.append( + "[Earlier conversation truncated.]\n" + + part[-AGENT_SEED_TRANSCRIPT_MAX_CHARS:] + ) + truncated = True + break + next_total = total + len(part) + (2 if selected else 0) + if selected and next_total > AGENT_SEED_TRANSCRIPT_MAX_CHARS: + truncated = True + break + selected.append(part) + total = next_total + if truncated and not selected[-1].startswith("[Earlier conversation truncated.]"): + selected.append("[Earlier conversation truncated.]") + transcript = "\n\n".join(reversed(selected)) + if not transcript: + return prompt + return ( + "The following is prior conversation context from before this native agent " + "session began. Use it for context, then answer only the latest user request.\n\n" + f"\n{transcript}\n\n\n" + f"\n{prompt}\n" + ) def turn_prompt_text(messages: list[dict[str, Any]], system_prompt: str, resumed: bool) -> str: - prompt = latest_user_text(messages) + prompt = session_turn_prompt_text(messages, resumed) if system_prompt and not resumed: return f"{system_prompt}\n\n{prompt}" if prompt else system_prompt return prompt diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index adc57bdd..a84589bc 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -2290,7 +2290,15 @@ async def auto_answer(): pass asyncio.create_task(auto_answer()) - await emit(done=True) + await emit( + done=True, + status="input_required", + title=chat_obj.title if chat_obj else None, + workspace=workspace, + workspace_name=workspace.rstrip("/").rsplit("/", 1)[-1] + if workspace + else "", + ) return # Check if any call needs approval @@ -2333,7 +2341,16 @@ async def auto_answer(): await emit(output=flushed_item) await emit(output=item) _task_state.pop(message_id, None) - await emit(done=True) + await emit( + done=True, + status="approval_required", + title=chat_obj.title if chat_obj else None, + tool_name=tc["name"], + workspace=workspace, + workspace_name=workspace.rstrip("/").rsplit("/", 1)[-1] + if workspace + else "", + ) return # All calls are auto-approved — build UI items diff --git a/pyproject.toml b/pyproject.toml index 5386e5b0..04967c4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.9.16" +version = "0.9.17" 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 84ceba83..7dd155a3 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.9.16" +version = "0.9.17" source = { editable = "." } dependencies = [ { name = "aiosqlite" },