From 676c17332808019aa61d43bbfcc4eac1998a2b70 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 20:04:46 -0500 Subject: [PATCH 1/9] refac --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b7bbcd0..ea2647c 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ Open WebUI Computer exposes an OpenAI-compatible API (`/v1/chat/completions`). A | 📋 **Audit logging** | Structured audit trail of all API mutations with automatic redaction of sensitive data. | | 🪵 **Diagnostic logging** | Configurable structured logs (text or JSON) with optional upstream request capture. | | ⌨️ **Keyboard shortcuts** | Customisable keybindings with a settings panel. | -| 🌍 **10 languages** | EN, DE, ES, FR, JA, KO, PT-BR, RU, ZH-CN, ZH-TW. | +| 🌍 **10 languages** | English, Deutsch, Español, Français, Português (Brasil), Русский, 日本語, 한국어, 简体中文, 繁體中文. | | 🔐 **Auth** | Username/password with JWT sessions. Signup toggle for admins. | ## Design principles From 36bf4e60afefa6d2260888c29975cd1f081dffd0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 20:12:05 -0500 Subject: [PATCH 2/9] refac --- .github/workflows/pypi.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index b4cf86c..5f0c5ae 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -62,6 +62,7 @@ jobs: id: release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} VERSION: ${{ steps.pkg.outputs.version }} run: | set -euo pipefail @@ -202,6 +203,7 @@ jobs: - name: Attach GitHub release artifacts env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} VERSION: ${{ needs.preflight.outputs.version }} run: | set -euo pipefail From c536fe6e5279f9ced8923fc8051b94c2a1cfb221 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 20:42:34 -0500 Subject: [PATCH 3/9] refac --- .../src/lib/components/chat/ChatInput.svelte | 14 +++++++++++--- .../src/lib/components/chat/ChatPanel.svelte | 18 ++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index cc231e7..993c618 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -72,6 +72,7 @@ contextUsage?: ContextUsage | null; tasks?: ChatTask[]; queuedMessages?: { id: string; content: string }[]; + hasChatContent?: boolean; onsend: () => void; oncompact?: () => void; onplan?: () => void; @@ -92,6 +93,7 @@ contextUsage = null, tasks = [], queuedMessages = [], + hasChatContent = false, onsend, oncompact, onplan, @@ -930,16 +932,22 @@ const slashCommandIds = $derived.by(() => { if (!slashCommandQuery.startsWith('/')) return []; const ids: string[] = []; - if (oncompact && '/compact'.startsWith(slashCommandQuery)) ids.push('compact'); + if (hasChatContent && oncompact && '/compact'.startsWith(slashCommandQuery)) + ids.push('compact'); if (onplan && '/plan'.startsWith(slashCommandQuery)) ids.push('plan'); - if (onstatus && '/status'.startsWith(slashCommandQuery)) ids.push('status'); + if (hasChatContent && onstatus && '/status'.startsWith(slashCommandQuery)) ids.push('status'); if ( + hasChatContent && onskillslist && slashCommandQuery !== '/skills:list' && '/skills:list'.startsWith(slashCommandQuery) ) ids.push('skills:list'); - if (slashCommandQuery !== '/skills:create' && '/skills:create'.startsWith(slashCommandQuery)) + if ( + hasChatContent && + slashCommandQuery !== '/skills:create' && + '/skills:create'.startsWith(slashCommandQuery) + ) ids.push('skills:create'); return ids; }); diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index 439cffe..a07866c 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -296,6 +296,11 @@ const streaming = $derived(allMessages.some((m) => m.role === 'assistant' && !m.done)); const isLanding = $derived(allMessages.length === 0 && !chatId); + const hasChatContent = $derived( + activePath.some( + ({ msg }) => msg.role === 'user' && msg.content.trim() && !msg.content.trim().startsWith('/') + ) + ); const workspaceDisplayName = $derived(getPathDisplayName(workspace, 'workspace')); const displayChatTitle = $derived(chatTitle || firstUserMessageTitle() || workspaceDisplayName); const runningCommandSessions = $derived(commandSessions.filter((session) => !session.done)); @@ -715,7 +720,11 @@ let text = inputText.trim(); if (!text || !selectedModel) return; if (sending) return; - if (text === '/compact') { + if (!hasChatContent && text.startsWith('/') && text !== '/plan') { + toast.error('Only /plan is available before this chat has content'); + return; + } + if (hasChatContent && text === '/compact') { await handleManualCompact(); return; } @@ -724,12 +733,12 @@ inputText = ''; return; } - if (text === '/status') { + if (hasChatContent && text === '/status') { handleStatusCommand(); inputText = ''; return; } - if (text === '/skills:list') { + if (hasChatContent && text === '/skills:list') { await handleSkillsListCommand(); inputText = ''; return; @@ -1522,7 +1531,7 @@ placeholder={$t('chat.placeholder', { name: workspaceDisplayName })} tasks={chatTasks} onsend={send} - onstatus={handleStatusCommand} + onplan={handlePlanCommand} {queuedMessages} onqueuesendnow={handleQueueSendNow} onqueueedit={handleQueueEdit} @@ -1632,6 +1641,7 @@ {streaming} {workspace} {contextUsage} + {hasChatContent} tasks={chatTasks} onsend={send} oncompact={handleManualCompact} From 76da3f5062a01d0555d7a04987afd2f123fc9d70 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 20:57:03 -0500 Subject: [PATCH 4/9] refac --- cptr/frontend/src/lib/apis/chat.ts | 6 + cptr/frontend/src/lib/apis/skills.ts | 8 + .../src/lib/components/Admin/Skills.svelte | 91 +++++ .../src/lib/components/FileBrowser.svelte | 6 + cptr/frontend/src/lib/components/Icon.svelte | 6 + .../src/lib/components/SettingsModal.svelte | 6 + .../components/chat/AssistantMessage.svelte | 21 +- .../src/lib/components/chat/ChatInput.svelte | 212 +++++++++-- .../src/lib/components/chat/ChatPanel.svelte | 25 ++ .../lib/components/chat/UserMessage.svelte | 5 + cptr/frontend/src/lib/i18n/locales/de.json | 1 + cptr/frontend/src/lib/i18n/locales/en.json | 7 + cptr/frontend/src/lib/i18n/locales/es.json | 1 + cptr/frontend/src/lib/i18n/locales/fr.json | 1 + cptr/frontend/src/lib/i18n/locales/ja.json | 1 + cptr/frontend/src/lib/i18n/locales/ko.json | 1 + cptr/frontend/src/lib/i18n/locales/pt-BR.json | 1 + cptr/frontend/src/lib/i18n/locales/ru.json | 1 + cptr/frontend/src/lib/i18n/locales/zh-CN.json | 1 + cptr/frontend/src/lib/i18n/locales/zh-TW.json | 1 + cptr/routers/chat.py | 74 ++++ cptr/routers/skills.py | 18 +- cptr/utils/chat_task.py | 105 +++++- cptr/utils/skills.py | 350 +++++++++++++++++- cptr/utils/tools.py | 28 +- 25 files changed, 924 insertions(+), 53 deletions(-) create mode 100644 cptr/frontend/src/lib/components/Admin/Skills.svelte diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index 9277ba1..6c0d6a8 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -97,6 +97,12 @@ export const getChat = (chatId: string, modelId?: string) => { export const deleteChat = (chatId: string) => fetchJSON<{ ok: boolean }>(`/api/chats/${chatId}`, { method: 'DELETE' }); +export const forkChat = (chatId: string, messageId?: string | null) => + fetchJSON<{ ok: boolean; chat_id: string }>( + `/api/chats/${chatId}/fork`, + jsonBody({ message_id: messageId ?? null }) + ); + // ── Mutations ─────────────────────────────────────────────── export const sendMessage = ( diff --git a/cptr/frontend/src/lib/apis/skills.ts b/cptr/frontend/src/lib/apis/skills.ts index 4bdc386..d040325 100644 --- a/cptr/frontend/src/lib/apis/skills.ts +++ b/cptr/frontend/src/lib/apis/skills.ts @@ -11,6 +11,14 @@ export interface SkillInfo { license?: string; compatibility?: string; managed?: boolean; + created_by?: string | null; + created_from?: string | null; + view_count?: number; + use_count?: number; + update_count?: number; + last_viewed_at?: string | null; + last_used_at?: string | null; + last_updated_at?: string | null; } export const getSkills = (workspace: string) => diff --git a/cptr/frontend/src/lib/components/Admin/Skills.svelte b/cptr/frontend/src/lib/components/Admin/Skills.svelte new file mode 100644 index 0000000..bc24a75 --- /dev/null +++ b/cptr/frontend/src/lib/components/Admin/Skills.svelte @@ -0,0 +1,91 @@ + + +
+

Skills

+ + {#if loading} +
+ {:else} +
+ +

+ Let Computer create or update managed skills after successful reusable work. +

+ + {#if backgroundReview} +
+ +
+ + turns +
+
+ {/if} +
+ +
+ +
+ {/if} +
diff --git a/cptr/frontend/src/lib/components/FileBrowser.svelte b/cptr/frontend/src/lib/components/FileBrowser.svelte index d9c2a72..8b6a69a 100644 --- a/cptr/frontend/src/lib/components/FileBrowser.svelte +++ b/cptr/frontend/src/lib/components/FileBrowser.svelte @@ -882,6 +882,11 @@ closeMenu(); } + function copyPath(entry: TreeEntry) { + navigator.clipboard.writeText(entry.path); + closeMenu(); + } + async function confirmRename(entryPath: string) { const oldName = entryPath.split('/').pop() ?? ''; if (!renameValue.trim() || renameValue === oldName) { @@ -1347,6 +1352,7 @@ { label: '', divider: true, onclick: () => {} } ] : []), + { label: $t('files.copyPath'), icon: 'copy', onclick: () => copyPath(contextMenu!.entry) }, { label: $t('files.rename'), icon: 'pencil', onclick: () => startRename(contextMenu!.entry) }, ...(contextMenu.entry.type !== 'directory' ? [ diff --git a/cptr/frontend/src/lib/components/Icon.svelte b/cptr/frontend/src/lib/components/Icon.svelte index 5054444..72f45a3 100644 --- a/cptr/frontend/src/lib/components/Icon.svelte +++ b/cptr/frontend/src/lib/components/Icon.svelte @@ -62,6 +62,12 @@ /> + {:else if name === 'chat-fork'} + + + + + {:else if name === 'chat-bubble'} {:else if activeTab === 'models'} + {:else if activeTab === 'skills'} + {:else if activeTab === 'messaging'} {:else if activeTab === 'gateway'} diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte index 5eb73b1..bc0de9c 100644 --- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte @@ -28,6 +28,7 @@ speaking?: boolean; onapprove: (messageId: string, callId: string, approved: boolean) => void; onnavigate?: (direction: -1 | 1) => void; + onfork?: () => void; onregenerate?: () => void; onedit?: (content: string, output: any[] | null, submit: boolean) => void; onspeak?: () => void; @@ -45,6 +46,7 @@ speaking = false, onapprove, onnavigate, + onfork, onregenerate, onedit, onspeak @@ -603,6 +605,7 @@ disabled={siblingIndex === 0} onclick={() => onnavigate?.(-1)} aria-label={$t('chat.prevResponse')} + use:tooltip={$t('chat.prevResponse')} > onnavigate?.(1)} aria-label={$t('chat.nextResponse')} + use:tooltip={$t('chat.nextResponse')} > {#if copied} @@ -707,6 +713,7 @@ class="p-0.5 rounded text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-100" onclick={onregenerate} aria-label={$t('chat.regenerateResponse')} + use:tooltip={$t('chat.regenerateResponse')} > {/if} + {#if done && onfork} + + {/if} {#if done && usage && Object.keys(usage).length > 0}
{/if} + {#if slashCommandIds.includes('fork')} + + {/if} {#if slashCommandIds.includes('status')} {/if} + {#if slashSkillSuggestions.length > 0} +
Skills
+ {#each slashSkillSuggestions as skill (skill.id)} + + {/each} + {/if}
{/if} diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index a07866c..68e4a10 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -3,6 +3,7 @@ getChat, getChats, deleteChat as apiDeleteChat, + forkChat as apiForkChat, sendMessage as apiSendMessage, approveToolCall, cancelTask, @@ -30,6 +31,7 @@ import { get } from 'svelte/store'; import { currentWorkspace, + openChatTab, toolApprovalMode, planMode, streamingBehavior, @@ -728,6 +730,10 @@ await handleManualCompact(); return; } + if (hasChatContent && text === '/fork') { + await handleForkChat(); + return; + } if (text === '/plan') { handlePlanCommand(); inputText = ''; @@ -906,6 +912,23 @@ } } + async function handleForkChat(messageId?: string | null) { + if (!chatId || sending || streaming) return; + sending = true; + inputText = ''; + const toastId = toast.loading($t('chat.forking')); + try { + const result = await apiForkChat(chatId, messageId ?? currentMessageId); + openChatTab(result.chat_id); + toast.success($t('chat.forkDone'), { id: toastId }); + } catch (err: any) { + toast.error(err?.message || $t('chat.forkFailed'), { id: toastId }); + } finally { + sending = false; + chatInputEl?.focus(); + } + } + function handlePlanCommand() { planMode.set(!get(planMode)); } @@ -1592,6 +1615,7 @@ siblingTotal={siblingIds.length} speaking={speakingMessageId === msg.id} onnavigate={(dir) => handleNavigate(msg.id, dir)} + onfork={sending || streaming ? undefined : () => handleForkChat(msg.id)} onregenerate={() => handleRegenerate(msg.id)} onedit={(c, o, submit) => handleEditMessage(msg.id, c, o, submit)} onspeak={() => speakMessage(msg.id)} @@ -1645,6 +1669,7 @@ tasks={chatTasks} onsend={send} oncompact={handleManualCompact} + onfork={handleForkChat} onplan={handlePlanCommand} onstatus={handleStatusCommand} onskillslist={handleSkillsListCommand} diff --git a/cptr/frontend/src/lib/components/chat/UserMessage.svelte b/cptr/frontend/src/lib/components/chat/UserMessage.svelte index ab4b84a..0701f92 100644 --- a/cptr/frontend/src/lib/components/chat/UserMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/UserMessage.svelte @@ -4,6 +4,7 @@ import MessageTimestamp from './MessageTimestamp.svelte'; import { fileIconName } from '$lib/utils/fileIcon'; import { openFileTab, setFileBrowserCwd, setActiveTab } from '$lib/stores'; + import { tooltip } from '$lib/tooltip'; import { t } from '$lib/i18n'; interface Props { @@ -253,6 +254,7 @@ disabled={siblingIndex === 0} onclick={() => onnavigate?.(-1)} aria-label={$t('chat.prevMessage')} + use:tooltip={$t('chat.prevMessage')} > onnavigate?.(1)} aria-label={$t('chat.nextMessage')} + use:tooltip={$t('chat.nextMessage')} > {#if copied} str: ) -def _apply_skills_create_prompt(messages: list[dict]) -> bool: +def _message_has_real_content(message: dict) -> bool: + text = _plain_message_text(message.get("content")).strip() + if message.get("role") == "user" and text.startswith("/"): + return False + return bool( + text or message.get("tool_calls") or message.get("reasoning_items") or message.get("output") + ) + + +def _has_prior_real_chat_content(messages: list[dict], loaded_summary: str | None = None) -> bool: + if loaded_summary and loaded_summary.strip(): + return True + last_user_idx = next( + (idx for idx in range(len(messages) - 1, -1, -1) if messages[idx].get("role") == "user"), + len(messages), + ) + return any( + message.get("role") == "user" and _message_has_real_content(message) + for message in messages[:last_user_idx] + ) + + +def _build_skill_create_gate_prompt() -> str: + return ( + "[/skills:create] The user tried to create a skill before this chat had " + "any prior real content. Explain briefly that skill creation needs an " + "existing chat with the workflow or source material already in it, then " + "ask them to continue in a chat with content first." + ) + + +def _apply_skills_create_prompt(messages: list[dict], *, allowed: bool) -> bool: for message in reversed(messages): if message.get("role") != "user": continue @@ -171,7 +203,11 @@ def _apply_skills_create_prompt(messages: list[dict]) -> bool: match = SKILLS_CREATE_RE.match(text.strip()) if not match: return False - message["content"] = _build_skill_create_prompt(match.group(1) or "") + message["content"] = ( + _build_skill_create_prompt(match.group(1) or "") + if allowed + else _build_skill_create_gate_prompt() + ) return True return False @@ -1315,6 +1351,16 @@ async def _emit_done(): content = (msg.content or "") if msg else "" output_items: list[dict] = list(msg.output or []) if msg else [] text_buffer = "" # Accumulates text between tool calls + task_completed_success = False + review_messages: list[dict] = [] + review_model_connection: dict | None = None + review_model_name = "" + review_chat_params: dict = {} + review_builtin_tools: dict | None = None + loaded_skill_names: set[str] = set() + tool_names_used: set[str] = set() + skill_create_requested = False + skill_authoring_allowed = False logger.info( "[task %s] start: existing content=%d chars, output=%d items", @@ -1391,7 +1437,8 @@ async def save_session(agent_target: AgentModelTarget, resume_state: dict | None await Chat.update_meta(chat_id, meta, now_ms()) async def _run_agent_target(agent_target: AgentModelTarget): - nonlocal content, text_buffer + nonlocal content, text_buffer, task_completed_success, skill_authoring_allowed + nonlocal skill_create_requested from cptr.utils.agents.claude_code import run_claude_code_agent from cptr.utils.agents.cline import run_cline_agent from cptr.utils.agents.codex import run_codex_agent @@ -1402,7 +1449,10 @@ 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 {} messages, loaded_summary = await _load_message_history(chat_id, message_id) - _apply_skills_create_prompt(messages) + skill_authoring_allowed = _has_prior_real_chat_content(messages, loaded_summary) + skill_create_requested = _apply_skills_create_prompt( + messages, allowed=skill_authoring_allowed + ) memory_message, memory_files = _memory_recall_inputs(messages, regeneration_prompt) system = await _load_system_prompt( workspace, @@ -1629,6 +1679,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): data={"workspace": event_workspace, "preview": preview}, message=preview, ) + task_completed_success = True return flushed_item = _flush_text() @@ -1669,7 +1720,10 @@ async def _run_agent_target(agent_target: AgentModelTarget): chat_models_config = {} builtin_tools = resolve_builtin_tools_config(chat_models_config, model, configured_model) messages, loaded_summary = await _load_message_history(chat_id, message_id) - _apply_skills_create_prompt(messages) + skill_authoring_allowed = _has_prior_real_chat_content(messages, loaded_summary) + skill_create_requested = _apply_skills_create_prompt( + messages, allowed=skill_authoring_allowed + ) memory_message, memory_files = _memory_recall_inputs(messages, regeneration_prompt) system = await _load_system_prompt( workspace, @@ -1684,6 +1738,8 @@ async def _run_agent_target(agent_target: AgentModelTarget): if regeneration_prompt: messages.append({"role": "user", "content": regeneration_prompt}) tools = await get_tool_list(builtin_tools=builtin_tools) + if not skill_authoring_allowed: + tools = [t for t in tools if t["name"] != "manage_skill"] # Remove view_skill tool if no skills are available skills = discover_skills(workspace) @@ -1717,6 +1773,8 @@ async def _run_agent_target(agent_target: AgentModelTarget): if skill: skill_blocks.append(format_skill_content(skill)) _activated_skills.add(sid) # mark as activated for dedup + loaded_skill_names.add(sid) + bump_skill_view(workspace, sid, skill.source) if skill_blocks: system += "\n\n" + "\n\n".join(skill_blocks) @@ -1733,6 +1791,14 @@ async def _run_agent_target(agent_target: AgentModelTarget): logger.info( "[task %s] plan mode active, %d tools available", message_id[:8], len(tools) ) + review_messages = messages + review_model_connection = connection + review_model_name = model + review_chat_params = { + **chat_params, + "subagent": bool(chat_obj and (chat_obj.meta or {}).get("subagent")), + } + review_builtin_tools = builtin_tools # Tool approval mode: 'ask' | 'auto' | 'full' # ask = require approval for ALL tools (including reads) @@ -1959,6 +2025,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): data={"workspace": event_workspace, "preview": preview}, message=preview, ) + task_completed_success = True return # ── Process collected tool calls ──────────────────── @@ -1987,6 +2054,11 @@ async def _run_agent_target(agent_target: AgentModelTarget): needs_approval = None for tc in pending_calls: name = tc["name"] + tool_names_used.add(name) + if name == "view_skill": + skill_name = str(tc.get("arguments", {}).get("skill_name") or "").strip() + if skill_name: + loaded_skill_names.add(skill_name) tool = ALL_TOOLS.get(name) should_auto = approval_mode == "full" or ( approval_mode == "auto" and tool and tool["auto"] @@ -2190,6 +2262,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): data={"workspace": event_workspace, "preview": preview}, message=preview, ) + task_completed_success = True return # Max iterations reached @@ -2320,6 +2393,26 @@ async def _run_agent_target(agent_target: AgentModelTarget): ) except Exception: logger.debug("[memory] Failed to review conversation", exc_info=True) + try: + from cptr.utils.skills import review_skills_after_turn + + await review_skills_after_turn( + workspace=workspace, + conversation_messages=review_messages, + assistant_reply=content, + model_connection=review_model_connection, + model=review_model_name, + loaded_skill_names=loaded_skill_names, + tool_names=tool_names_used, + skill_create_requested=skill_create_requested, + plan_mode=bool(review_chat_params.get("plan_mode")), + subagent=bool(review_chat_params.get("subagent")), + skills_enabled=task_completed_success + and skill_authoring_allowed + and "manage_skill" not in disabled_builtin_tool_names(review_builtin_tools), + ) + except Exception: + logger.debug("[skills] Failed to review conversation", exc_info=True) # Process any pending user prompts or internal subagent results. try: await process_pending_chat_inputs(chat_id, user_id, workspace) diff --git a/cptr/utils/skills.py b/cptr/utils/skills.py index 3fa9259..bbd4510 100644 --- a/cptr/utils/skills.py +++ b/cptr/utils/skills.py @@ -14,10 +14,16 @@ from __future__ import annotations +import asyncio import logging +import json +import os import re +import shutil +import time from dataclasses import dataclass, field -from typing import Literal +from datetime import datetime, timezone +from typing import Any, Literal from pathlib import Path import yaml @@ -43,6 +49,20 @@ MANAGED_WORKSPACE_SKILL_DIR = ".cptr/skills" MANAGED_GLOBAL_SKILL_DIR = Path.home() / ".cptr" / "skills" ALLOWED_BUNDLE_DIRS = {"references", "templates", "scripts", "assets"} +DEFAULT_SKILL_SETTINGS: dict[str, Any] = { + "background_review_enabled": True, + "review_interval_turns": 10, +} +SKILL_USAGE_DEFAULT: dict[str, Any] = { + "created_from": None, + "view_count": 0, + "use_count": 0, + "update_count": 0, + "last_viewed_at": None, + "last_used_at": None, + "last_updated_at": None, +} +USAGE_FILE_NAME = "usage.json" # Directories to skip during scanning _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv"} @@ -69,6 +89,14 @@ class SkillMeta: metadata: dict | None = None allowed_tools: str | None = None managed: bool = False + created_by: str | None = None + created_from: str | None = None + view_count: int = 0 + use_count: int = 0 + update_count: int = 0 + last_viewed_at: str | None = None + last_used_at: str | None = None + last_updated_at: str | None = None @dataclass @@ -133,6 +161,115 @@ def parse_frontmatter(text: str) -> tuple[dict, str]: return {}, body +def _usage_path(workspace: str, source: str) -> Path: + if source == "global": + return MANAGED_GLOBAL_SKILL_DIR.expanduser().parent / USAGE_FILE_NAME + if not workspace: + raise ValueError("workspace is required for workspace usage") + return Path(workspace).expanduser().resolve() / ".cptr" / USAGE_FILE_NAME + + +def read_usage(workspace: str, source: str = "workspace") -> dict[str, Any]: + path = _usage_path(workspace, source) + if not path.is_file(): + return {"version": 1, "skills": {}} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"version": 1, "skills": {}} + if not isinstance(data, dict): + return {"version": 1, "skills": {}} + skills = data.get("skills") + if not isinstance(skills, dict): + skills = {} + return {"version": 1, "skills": skills} + + +def write_usage(workspace: str, source: str, data: dict[str, Any]) -> None: + path = _usage_path(workspace, source) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.replace(tmp, path) + + +def bump_skill_view(workspace: str, name: str, source: str = "workspace", *, used: bool = True) -> None: + try: + data = read_usage(workspace, source) + skills = data.setdefault("skills", {}) + rec = skills.setdefault(name, dict(SKILL_USAGE_DEFAULT)) + now = datetime.now(timezone.utc).isoformat() + rec["view_count"] = int(rec.get("view_count") or 0) + 1 + rec["last_viewed_at"] = now + if used: + rec["use_count"] = int(rec.get("use_count") or 0) + 1 + rec["last_used_at"] = now + write_usage(workspace, source, data) + except Exception: + logger.debug("[skills] Failed to bump view usage for %s", name, exc_info=True) + + +def bump_skill_update( + workspace: str, + name: str, + source: str = "workspace", + *, + created_from: str | None = None, +) -> None: + try: + data = read_usage(workspace, source) + skills = data.setdefault("skills", {}) + rec = skills.setdefault(name, dict(SKILL_USAGE_DEFAULT)) + rec["update_count"] = int(rec.get("update_count") or 0) + 1 + rec["last_updated_at"] = datetime.now(timezone.utc).isoformat() + if created_from and not rec.get("created_from"): + rec["created_from"] = created_from + write_usage(workspace, source, data) + except Exception: + logger.debug("[skills] Failed to bump update usage for %s", name, exc_info=True) + + +def _frontmatter_bounds(content: str) -> tuple[dict, str] | None: + if not content.startswith("---\n"): + return None + match = re.search(r"\n---\s*\n", content[4:]) + if not match: + return None + end = match.start() + 4 + fm_text = content[4:end] + body = content[match.end() + 4 :] + try: + fm = yaml.safe_load(fm_text) or {} + except yaml.YAMLError: + fm = {} + return fm if isinstance(fm, dict) else {}, body + + +def _with_skill_metadata( + content: str, + *, + created_from: str | None = None, + preserve_created: dict | None = None, +) -> str: + parsed = _frontmatter_bounds(content) + if not parsed: + return content.strip() + "\n" + fm, body = parsed + now = datetime.now(timezone.utc).isoformat() + if preserve_created: + for key in ("created_by", "created_from", "created_at"): + if preserve_created.get(key) and not fm.get(key): + fm[key] = preserve_created[key] + if created_from: + fm.setdefault("created_by", "computer") + fm.setdefault("created_from", created_from) + fm.setdefault("created_at", now) + if fm.get("created_by") == "computer" or created_from or preserve_created: + fm["updated_at"] = now + frontmatter = yaml.safe_dump(fm, sort_keys=False, allow_unicode=True).strip() + return f"---\n{frontmatter}\n---\n{body.lstrip()}".strip() + "\n" + + # ── Name validation ───────────────────────────────────────── _NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$") @@ -197,6 +334,7 @@ def _scan_skills_dir(skills_dir: Path, source: str, workspace: str = "") -> list return results dirs_scanned = 0 + usage = read_usage(workspace, source).get("skills", {}) try: for item in sorted(skills_dir.iterdir()): if dirs_scanned >= _MAX_SCAN_DIRS: @@ -236,6 +374,9 @@ def _scan_skills_dir(skills_dir: Path, source: str, workspace: str = "") -> list warnings = validate_name(name, item.name) for w in warnings: logger.warning("[skills] %s: %s", skill_md, w) + rec = usage.get(name) if isinstance(usage, dict) else {} + if not isinstance(rec, dict): + rec = {} results.append( SkillMeta( @@ -248,6 +389,14 @@ def _scan_skills_dir(skills_dir: Path, source: str, workspace: str = "") -> list metadata=fm.get("metadata") if isinstance(fm.get("metadata"), dict) else None, allowed_tools=fm.get("allowed-tools"), managed=_is_managed_skill(skill_md, workspace), + created_by=fm.get("created_by"), + created_from=fm.get("created_from"), + view_count=int(rec.get("view_count") or 0), + use_count=int(rec.get("use_count") or 0), + update_count=int(rec.get("update_count") or 0), + last_viewed_at=rec.get("last_viewed_at"), + last_used_at=rec.get("last_used_at"), + last_updated_at=rec.get("last_updated_at"), ) ) @@ -417,8 +566,10 @@ def create_managed_skill( name: str, content: str, scope: Literal["workspace", "global"] = "workspace", + created_from: str | None = None, ) -> dict: name = _validate_skill_name(name) + content = _with_skill_metadata(content, created_from=created_from) _validate_skill_content(name, content) if any(skill.name == name for skill in discover_skills(workspace)): raise ValueError(f"skill '{name}' already exists") @@ -427,9 +578,31 @@ def create_managed_skill( if skill_md.exists(): raise ValueError(f"skill '{name}' already exists") _write_text_atomic(skill_md, content.strip() + "\n") + if created_from: + bump_skill_update(workspace, name, scope, created_from=created_from) + return {"success": True, "name": name, "location": str(skill_md.resolve())} + + +def update_managed_skill(workspace: str, name: str, content: str) -> dict: + name = _validate_skill_name(name) + skill_dir = _managed_skill_dir(workspace, name) + skill_md = skill_dir / "SKILL.md" + old = skill_md.read_text(encoding="utf-8", errors="replace") if skill_md.exists() else "" + old_fm, _ = parse_frontmatter(old) + content = _with_skill_metadata(content, preserve_created=old_fm) + _validate_skill_content(name, content) + _write_text_atomic(skill_md, content.strip() + "\n") + source = "global" if skill_dir.is_relative_to(MANAGED_GLOBAL_SKILL_DIR.expanduser().resolve()) else "workspace" + bump_skill_update(workspace, name, source) return {"success": True, "name": name, "location": str(skill_md.resolve())} +def delete_managed_skill(workspace: str, name: str) -> dict: + skill_dir = _managed_skill_dir(workspace, name) + shutil.rmtree(skill_dir) + return {"success": True, "name": name} + + def _managed_skill_dir(workspace: str, name: str) -> Path: name = _validate_skill_name(name) skill = next((s for s in discover_skills(workspace) if s.name == name), None) @@ -459,6 +632,8 @@ def write_managed_skill_file( if not target.is_relative_to(skill_dir): raise ValueError("file_path escapes the skill") _write_text_atomic(target, file_content) + source = "global" if skill_dir.is_relative_to(MANAGED_GLOBAL_SKILL_DIR.expanduser().resolve()) else "workspace" + bump_skill_update(workspace, name, source) return {"success": True, "name": name, "location": str(target)} @@ -520,3 +695,176 @@ def format_skill_content(skill: SkillContent) -> str: parts.append("") return "\n".join(parts) + + +async def get_skill_settings() -> dict[str, Any]: + try: + from cptr.models import Config + + raw = await Config.get_namespace("skills") + except Exception: + raw = {} + settings = dict(DEFAULT_SKILL_SETTINGS) + for key, value in raw.items(): + short = key.removeprefix("skills.") + settings[short] = value + try: + settings["review_interval_turns"] = max(1, int(settings["review_interval_turns"])) + except (TypeError, ValueError): + settings["review_interval_turns"] = DEFAULT_SKILL_SETTINGS["review_interval_turns"] + settings["background_review_enabled"] = settings.get("background_review_enabled") not in ( + False, + "false", + "0", + ) + return settings + + +async def review_skills_after_turn( + *, + workspace: str, + conversation_messages: list[dict[str, Any]], + assistant_reply: str, + model_connection: dict | None, + model: str, + loaded_skill_names: set[str], + tool_names: set[str], + skill_create_requested: bool, + plan_mode: bool, + subagent: bool, + skills_enabled: bool, +) -> None: + if ( + not workspace + or not assistant_reply.strip() + or not model_connection + or plan_mode + or subagent + or not skills_enabled + ): + return + settings = await get_skill_settings() + if not settings["background_review_enabled"]: + return + if not tool_names and not skill_create_requested and not loaded_skill_names: + return + user_turns = sum(1 for message in conversation_messages if message.get("role") == "user") + if user_turns <= 0 or user_turns % int(settings["review_interval_turns"]) != 0: + return + asyncio.create_task( + run_skill_review( + workspace=workspace, + conversation_messages=list(conversation_messages), + assistant_reply=assistant_reply, + model_connection=dict(model_connection), + model=model, + loaded_skill_names=set(loaded_skill_names), + tool_names=set(tool_names), + skill_create_requested=skill_create_requested, + ) + ) + + +async def run_skill_review( + *, + workspace: str, + conversation_messages: list[dict[str, Any]], + assistant_reply: str, + model_connection: dict, + model: str, + loaded_skill_names: set[str], + tool_names: set[str], + skill_create_requested: bool, +) -> None: + try: + from cptr.utils.ai import chat_completion + from cptr.utils.chat_task import _default_base_url + from cptr.utils.config import _get_jwt_secret + from cptr.utils.crypto import decrypt_key + from cptr.utils.json_parser import extract_json + from cptr.utils.memory import summarize_recent_conversation + + skills = discover_skills(workspace) + transcript = summarize_recent_conversation(conversation_messages, assistant_reply) + catalog = "\n".join( + f"- {skill.name} ({skill.source}, {'managed' if skill.managed else 'read-only'}): {skill.description}" + for skill in skills[:80] + ) or "- none" + loaded_blocks = [] + for name in sorted(loaded_skill_names): + skill = load_skill(workspace, name) + if not skill or not skill.managed: + continue + content = skill.content + if len(content) > 5000: + content = content[:4500] + "\n...(truncated)..." + loaded_blocks.append(f"## {name}\n{content}") + loaded_context = "\n\n".join(loaded_blocks) or "None." + prompt = ( + "Review the completed conversation and decide whether Computer learned a reusable " + "workflow that should become or improve a skill. Return ONLY JSON with this shape:\n" + '{"actions":[{"action":"create|update|write_file|none","name":"skill-name",' + '"reason":"short reason","content":"full SKILL.md for create/update",' + '"file_path":"references/example.md","file_content":"support file content"}]}\n\n' + "Rules:\n" + "- Use action=none when nothing durable was learned.\n" + "- Prefer updating a loaded managed skill, then another managed skill, then writing a support file, then creating one new workspace skill.\n" + "- Create or update only for reusable procedures, user corrections, durable commands, verification steps, or workflows likely to recur.\n" + "- Do not save one-off task summaries, transient local failures, secrets, vague preferences, or project facts better suited for memory.\n" + "- Never delete. Never update read-only skills. Never write outside references/, templates/, scripts/, or assets/.\n\n" + f"Workspace: {workspace}\n" + f"Tools used this turn: {', '.join(sorted(tool_names)) or 'none'}\n" + f"/skills:create requested: {skill_create_requested}\n" + f"Loaded skills: {', '.join(sorted(loaded_skill_names)) or 'none'}\n\n" + f"Available skills:\n{catalog}\n\n" + f"Loaded managed skill contents:\n{loaded_context}\n\n" + f"Conversation:\n{transcript}" + ) + provider = model_connection["provider"] + api_key = decrypt_key(model_connection.get("api_key", ""), _get_jwt_secret()) + base_url = model_connection.get("base_url") or _default_base_url(provider) + text = await chat_completion( + provider=provider, + base_url=base_url, + api_key=api_key, + model=model, + messages=[{"role": "user", "content": prompt}], + system="You are Computer's private skill reviewer. Return only valid JSON.", + max_tokens=1800, + api_type=model_connection.get("api_type", "chat_completions"), + ) + parsed = extract_json(text) + if not isinstance(parsed, dict): + return + actions = parsed.get("actions") + if not isinstance(actions, list): + return + managed = {skill.name for skill in discover_skills(workspace) if skill.managed} + for action in actions[:3]: + if not isinstance(action, dict): + continue + kind = action.get("action") + name = str(action.get("name") or "").strip() + if kind == "none": + continue + if kind == "create": + content = action.get("content") + if isinstance(content, str) and content.strip(): + create_managed_skill( + workspace, + name, + content, + scope="workspace", + created_from="background_review", + ) + elif kind == "update": + content = action.get("content") + if name in managed and isinstance(content, str) and content.strip(): + update_managed_skill(workspace, name, content) + elif kind == "write_file": + file_path = action.get("file_path") + file_content = action.get("file_content") + if name in managed and isinstance(file_path, str) and isinstance(file_content, str): + write_managed_skill_file(workspace, name, file_path, file_content) + except Exception: + logger.debug("[skills] Failed to review conversation for skills", exc_info=True) diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index a78d902..d10293b 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -1611,7 +1611,7 @@ async def view_skill( """Load the full instructions and resource listing for an available skill. :param skill_name: The name of the skill to load (from the catalog). """ - from cptr.utils.skills import load_skill, format_skill_content + from cptr.utils.skills import bump_skill_view, load_skill, format_skill_content # Deduplication: if already activated, return short notice if skill_name in _activated_skills: @@ -1622,11 +1622,12 @@ async def view_skill( return f"Error: skill '{skill_name}' not found. Check for valid names." _activated_skills.add(skill_name) + bump_skill_view(workspace, skill_name, skill.source) return format_skill_content(skill) async def manage_skill( - action: Literal["create", "write_file"], + action: Literal["create", "update", "write_file", "delete"], name: str, content: Optional[str] = None, scope: Literal["workspace", "global"] = "workspace", @@ -1635,26 +1636,35 @@ async def manage_skill( *, __context__: dict, ) -> str: - """Create Computer-managed skills and supporting bundle files. + """Create, update, or delete Computer-managed skills and supporting bundle files. - Use this only when the user asks to create a reusable skill. New skills - default to the current workspace. For supporting files, write only under - references/, templates/, scripts/, or assets/. - :param action: "create" to write SKILL.md, or "write_file" to add a bundle file. + Use this only when the user asks to create, update, or delete a reusable skill. + New skills default to the current workspace. For supporting files, write only + under references/, templates/, scripts/, or assets/. + :param action: "create" to write SKILL.md, "update" to replace SKILL.md, "write_file" to add a bundle file, or "delete" to remove a managed skill. :param name: Lowercase hyphenated skill name. - :param content: Full SKILL.md content for action="create". + :param content: Full SKILL.md content for action="create" or action="update". :param scope: "workspace" for .cptr/skills, or "global" for ~/.cptr/skills. :param file_path: Relative bundle path for action="write_file". :param file_content: File content for action="write_file". """ - from cptr.utils.skills import create_managed_skill, write_managed_skill_file + from cptr.utils.skills import ( + create_managed_skill, + delete_managed_skill, + update_managed_skill, + write_managed_skill_file, + ) workspace = __context__.get("workspace", "") try: if action == "create": result = create_managed_skill(workspace, name, content or "", scope) + elif action == "update": + result = update_managed_skill(workspace, name, content or "") elif action == "write_file": result = write_managed_skill_file(workspace, name, file_path or "", file_content) + elif action == "delete": + result = delete_managed_skill(workspace, name) else: result = {"success": False, "error": f"unsupported action '{action}'"} except Exception as e: From e2f4f69702afb65e606c01951433a5e294944994 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 21:04:28 -0500 Subject: [PATCH 5/9] refac --- dev.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev.sh b/dev.sh index b89b503..31da681 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 cptr run --reload --host 0.0.0.0 --port 9741 +uv run --extra all cptr run --reload --host 0.0.0.0 --port 9741 From 556db8aebff900c3b13fd3f343dcda3db17ced73 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 6 Jul 2026 21:05:25 -0500 Subject: [PATCH 6/9] refac --- .../src/lib/components/Admin/Skills.svelte | 83 +++++++++++++------ .../src/lib/components/SettingsModal.svelte | 8 +- cptr/routers/skills.py | 4 + cptr/utils/chat_task.py | 40 +++++++-- cptr/utils/prompt_templates.py | 17 +++- cptr/utils/skills.py | 10 ++- cptr/utils/tools.py | 24 ++++++ 7 files changed, 144 insertions(+), 42 deletions(-) diff --git a/cptr/frontend/src/lib/components/Admin/Skills.svelte b/cptr/frontend/src/lib/components/Admin/Skills.svelte index bc24a75..3e301a7 100644 --- a/cptr/frontend/src/lib/components/Admin/Skills.svelte +++ b/cptr/frontend/src/lib/components/Admin/Skills.svelte @@ -7,12 +7,17 @@ let loading = $state(true); let saving = $state(false); + let enabled = $state(true); + let toolEnabled = $state(true); let backgroundReview = $state(true); let reviewInterval = $state(10); onMount(async () => { try { const config = await getAdminConfig(); + enabled = config['skills.enabled'] !== false && config['skills.enabled'] !== 'false'; + toolEnabled = + config['skills.tool_enabled'] !== false && config['skills.tool_enabled'] !== 'false'; backgroundReview = config['skills.background_review_enabled'] !== false && config['skills.background_review_enabled'] !== 'false'; @@ -27,6 +32,8 @@ saving = true; try { await updateConfig({ + 'skills.enabled': enabled, + 'skills.tool_enabled': toolEnabled, 'skills.background_review_enabled': backgroundReview, 'skills.review_interval_turns': Math.max(1, Number(reviewInterval) || 10) }); @@ -39,48 +46,72 @@ } -
-

Skills

- +
{#if loading}
{:else} -
- -

- Let Computer create or update managed skills after successful reusable work. -

+
+

Skills

+ +

Behavior

+
+ + + {#if enabled} + + + + {/if} +
- {#if backgroundReview} -
- -
+ {#if enabled} +

Limits

+
+
+ - turns
{/if}
-
+
- {/if} - {#if $updateAvailable} - · - {$t('about.updateAvailable', { version: $latestVersion })} - {/if} -
- -

{$t('app.tagline')}

- -

- {$t('about.license')} -

-

- {$t('about.licenseName')} - -
- {$t('about.copyright')} -

-
- -
- {$t('about.share')} - {#each shareLinks as link, i} - {#if i > 0} - · - {/if} - {link.label} - {/each} - · - -
- -

- {$t('about.createdBy')} -

diff --git a/cptr/frontend/src/lib/components/Settings/General.svelte b/cptr/frontend/src/lib/components/Settings/General.svelte index ac7dc2f..d51c1b1 100644 --- a/cptr/frontend/src/lib/components/Settings/General.svelte +++ b/cptr/frontend/src/lib/components/Settings/General.svelte @@ -1,15 +1,130 @@

{$t('general.title')}

+
+
+ Computer + {#if $appVersion} + + {/if} + {#if $updateAvailable} + · + {$t('about.updateAvailable', { version: $latestVersion })} + {/if} +
+ +

{$t('app.tagline')}

+ +
+ {$t('about.share')} + {#each shareLinks as link, i} + {#if i > 0} + · + {/if} + {link.label} + {/each} + · + +
+
+

{$t('general.language')}