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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55e9735..0b40e44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,23 @@ 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.8.2] - 2026-07-07
+
+### Added
+
+- 🌿 **Fork chats from any response.** Start a fresh copy of a conversation from the current response or with `/fork`, so you can try a different direction without changing the original chat.
+- ✨ **Easier skill access.** Type `/` to find skills alongside chat commands, and admins can control skill behavior from a new Skills page.
+
+### Changed
+
+- 🧠 **Smarter skill upkeep.** Computer can help keep managed skills up to date after real chats, while admins can turn skill features on or off.
+- ⚙️ **Tidier settings.** About, sharing, update, license, and PWA reset controls now live in General, with Skills under Admin.
+
+### Fixed
+
+- 💬 **Clearer chat controls.** Message actions now show helpful tooltips, and commands that need an existing chat stay hidden until they can be used.
+- 📦 **More dependable release downloads.** Release runs now attach download files to the right repository more reliably.
+
## [0.8.1] - 2026-07-06
### Added
diff --git a/README.md b/README.md
index b7bbcd0..48d2294 100644
--- a/README.md
+++ b/README.md
@@ -14,9 +14,9 @@
See more at [cptr.sh](https://cptr.sh/)
-Open WebUI Computer (`cptr`) runs on your machine and serves your whole computer (files, terminal, editor, git) to any browser. It literally is your computer.
+Open WebUI Computer (`cptr`) runs on your machine and serves your whole computer to any browser: files, terminal, editor, git, running sessions, AI agents, and tools. It literally is your computer.
-Use it from your phone, tablet, laptop, another computer, or the machine it's running on. Designed to feel native on every screen. Connect your own AI via API key, or plug in a coding agent you already subscribe to and skip the key entirely. Terminal multiplexer, parallel AI agents, full workstation, one tool, your computer, any device.
+Use it from your phone, tablet, laptop, another computer, or the machine it's running on. Designed to feel native on every screen. Connect your own AI via API key, plug in a coding agent you already subscribe to, or work directly in the terminal. One tool, full workstation, any device.
## Install
@@ -55,23 +55,27 @@ Most tunnels forward to `localhost`, so the default `cptr run` works. If your tu
Or skip networking entirely and connect a [messaging bot](#messaging-bots) instead.
-## What you get
+## The Whole Machine
+
+Open WebUI Computer is the real workstation surface: files, shell, git state, workspaces, chats, tools, and sessions stay together wherever you open it.
| | |
|---|---|
-| 📁 **File browser** | Navigate, create, rename, upload, drag and drop. Icons by type, sizes at a glance. |
-| ⌨️ **Terminal** | Full shell in the browser. Run your tools, your scripts, or your favourite coding agent. |
-| 🔀 **Git** | Stage, commit, diff, branch, push. Visual changes view. No command line required. |
+| 📁 **Real files** | Navigate, create, rename, upload, drag and drop. Icons by type, sizes at a glance. |
| ✏️ **Editor** | Syntax-highlighted editing with tabs. Open multiple files side by side. |
+| 🔀 **Git** | Stage, commit, diff, branch, push. Visual changes view. No command line required. |
+| ⌨️ **Terminal** | Full shell in the browser. Run your tools, your scripts, or your favourite coding agent. |
+| 🔄 **Sessions persist** | Terminal keeps running when you close the tab. Come back on any device. |
| 🗂️ **Tabs** | Open terminals, files, chats, and tools in separate tabs. Rearrange or split your layout. |
| 📂 **Workspaces** | Multiple projects, one instance. Switch without losing your place. |
| 🔍 **Search** | Find files by name, search across file contents and chat history. ⌘K to find anything. |
| 📱 **Mobile-first** | Not a desktop UI made smaller. Built for the screen in your pocket. |
-| 🔄 **Sessions persist** | Terminal keeps running when you close the tab. Come back on any device. |
## AI agent
-Bring your own API key (OpenAI, Anthropic, Ollama, or any OpenAI-compatible endpoint), or connect a coding agent you already subscribe to.
+AI that works where your work actually lives.
+
+Bring your own API key (OpenAI, Anthropic, Ollama, or any OpenAI-compatible endpoint), or connect a coding agent you already subscribe to. It can read the workspace, edit files, run commands, browse the web, use tools, automate recurring work, and spin up parallel sub-agents on the same machine.
| | |
|---|---|
@@ -95,7 +99,7 @@ Bring your own API key (OpenAI, Anthropic, Ollama, or any OpenAI-compatible endp
## Coding agents
-Connect a coding agent as a native backend and use the subscription you already pay for. No separate API key needed.
+Use the subscriptions you already pay for as native backends on your own machine. No separate API key needed.
**Codex** · **Claude Code** · **Cursor** · **Grok** · **OpenCode** · **Cline**
@@ -105,15 +109,15 @@ Prefer to run agents yourself? Any terminal agent (Gemini CLI, Kilo Code, Pi, an
## Messaging bots
-Connect the AI to your chat apps. Full tool access, streaming responses, conversations synced back to the web UI.
+Message your computer from wherever you are. Connect the AI to your chat apps with full tool access, streaming responses, and conversations synced back to the web UI.
**Telegram** · **Discord** · **Slack** · **WhatsApp** · **Signal**
-Message your computer from wherever you are. Ask it to check a build, push a fix, or explain a file. Switch workspaces with `/workspace`, start fresh with `/new`.
+Ask it to check a build, push a fix, or explain a file. Switch workspaces with `/workspace`, start fresh with `/new`.
## Gateway API
-Open WebUI Computer exposes an OpenAI-compatible API (`/v1/chat/completions`). Any client that speaks OpenAI, including [Open WebUI](https://github.com/open-webui/open-webui), can use each Open WebUI Computer workspace as a model with full agent capabilities: file access, terminal, web search, tools.
+Turn each workspace into an OpenAI-compatible agent model with real machine access. Open WebUI Computer exposes `/v1/chat/completions`, so any client that speaks OpenAI, including [Open WebUI](https://github.com/open-webui/open-webui), can use a workspace with full agent capabilities: file access, terminal, web search, tools.
## More
@@ -127,7 +131,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
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..3e301a7
--- /dev/null
+++ b/cptr/frontend/src/lib/components/Admin/Skills.svelte
@@ -0,0 +1,122 @@
+
+
+
+ {#if loading}
+
+ {:else}
+
+
+
+
+ {saving ? 'Saving...' : 'Save'}
+
+
+ {/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'}
- import { t } from '$lib/i18n';
- import { appVersion, showChangelog, updateAvailable, latestVersion } from '$lib/stores';
-
- const REPO_URL = 'https://github.com/open-webui/computer';
- const SHARE_TEXT = 'Check out Computer. Your computer, from anywhere.';
-
- const shareLinks = [
- {
- label: 'X',
- href: `https://x.com/intent/tweet?text=${encodeURIComponent(SHARE_TEXT)}&url=${encodeURIComponent(REPO_URL)}`
- },
- {
- label: 'Reddit',
- href: `https://reddit.com/submit?url=${encodeURIComponent(REPO_URL)}&title=${encodeURIComponent(SHARE_TEXT)}`
- },
- {
- label: 'LinkedIn',
- href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(REPO_URL)}`
- }
- ];
-
- let copied = $state(false);
-
- function copyLink() {
- navigator.clipboard.writeText(REPO_URL);
- copied = true;
- setTimeout(() => (copied = false), 2000);
- }
-
-
-
- {$t('about.title')}
-
-
-
-
-
-
{$t('about.share')}
- {#each shareLinks as link, i}
- {#if i > 0}
-
·
- {/if}
-
{link.label}
- {/each}
-
·
-
- {copied ? $t('about.copied') : $t('about.copyLink')}
-
-
-
-
- {$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 @@
diff --git a/cptr/frontend/src/lib/components/Settings/PWA.svelte b/cptr/frontend/src/lib/components/Settings/PWA.svelte
index e746410..d3d7360 100644
--- a/cptr/frontend/src/lib/components/Settings/PWA.svelte
+++ b/cptr/frontend/src/lib/components/Settings/PWA.svelte
@@ -22,28 +22,6 @@
(e.currentTarget as HTMLSelectElement).value as PwaPreferences['importDestination']
);
}
-
- let resetting = $state(false);
-
- async function resetPwa() {
- resetting = true;
-
- try {
- if ('serviceWorker' in navigator) {
- const registrations = await navigator.serviceWorker.getRegistrations();
- await Promise.all(registrations.map((registration) => registration.unregister()));
- }
-
- if ('caches' in window) {
- const keys = await caches.keys();
- await Promise.all(
- keys.filter((key) => key.startsWith('cptr-')).map((key) => caches.delete(key))
- );
- }
- } finally {
- location.reload();
- }
- }
@@ -80,17 +58,5 @@
updatePref('importFolder', (e.currentTarget as HTMLInputElement).value || undefined)}
/>
{/if}
-
-
{tr('pwa.resetTitle')}
-
- {resetting ? tr('pwa.resetting') : tr('pwa.reset')}
-
-
- {tr('pwa.resetDesc')}
-
diff --git a/cptr/frontend/src/lib/components/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte
index f622db4..7d3c7d0 100644
--- a/cptr/frontend/src/lib/components/SettingsModal.svelte
+++ b/cptr/frontend/src/lib/components/SettingsModal.svelte
@@ -9,11 +9,11 @@
import PWA from './Settings/PWA.svelte';
import Account from './Settings/Account.svelte';
import Keyboard from './Settings/Keyboard.svelte';
- import About from './Settings/About.svelte';
import Users from './Admin/Users.svelte';
import Connections from './Admin/Connections.svelte';
import Agents from './Admin/Agents.svelte';
import Models from './Admin/Models.svelte';
+ import Skills from './Admin/Skills.svelte';
import Messaging from './Admin/Messaging.svelte';
import Gateway from './Admin/Gateway.svelte';
import AudioSettings from './Admin/AudioSettings.svelte';
@@ -32,11 +32,11 @@
| 'pwa'
| 'keyboard'
| 'account'
- | 'about'
| 'users'
| 'connections'
| 'agents'
| 'models'
+ | 'skills'
| 'messaging'
| 'gateway'
| 'audio'
@@ -71,7 +71,8 @@
'web',
'toolservers',
'subagents',
- 'memory'
+ 'memory',
+ 'skills'
];
const personalTabs: SettingsTab[] = $derived.by(() => {
@@ -83,7 +84,6 @@
{ id: 'account', label: $t('settings.account'), icon: 'user' }
];
if (showPwaSettings) tabs.push({ id: 'pwa', label: 'PWA', icon: 'phone' });
- tabs.push({ id: 'about', label: $t('settings.about'), icon: 'info' });
return tabs;
});
@@ -99,7 +99,8 @@
{ id: 'web', label: $t('admin.web'), icon: 'globe' },
{ id: 'toolservers', label: $t('admin.toolServers'), icon: 'plug' },
{ id: 'subagents', label: $t('admin.subagents'), icon: 'user' },
- { id: 'memory', label: $t('settings.memory'), icon: 'brain' }
+ { id: 'memory', label: $t('settings.memory'), icon: 'brain' },
+ { id: 'skills', label: 'Skills', icon: 'spark' }
]);
onMount(() => {
@@ -180,7 +181,7 @@
{#if activeTab === 'general'}
-
+
{:else if activeTab === 'notifications'}
{:else if activeTab === 'appearance'}
@@ -193,8 +194,6 @@
{:else if activeTab === 'account'}
- {:else if activeTab === 'about'}
-
{:else if activeTab === 'users'}
{:else if activeTab === 'connections'}
@@ -203,6 +202,8 @@
{: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}
(showUsageTooltip = true)}
onmouseleave={() => (showUsageTooltip = false)}
aria-label={$t('chat.usageInfo')}
+ use:tooltip={$t('chat.usageInfo')}
>
void;
oncompact?: () => void;
+ onfork?: () => void;
onplan?: () => void;
onstatus?: () => void;
onskillslist?: () => void;
@@ -92,8 +94,10 @@
contextUsage = null,
tasks = [],
queuedMessages = [],
+ hasChatContent = false,
onsend,
oncompact,
+ onfork,
onplan,
onstatus,
onskillslist,
@@ -356,23 +360,32 @@
let skillActiveClientRectFn: (() => DOMRect | null) | null = null;
let skillRepositionRafId: number | null = null;
let cachedSkills: SkillMentionAttrs[] | null = null;
+ let cachedSkillsWorkspace = '';
+ let slashSkillSuggestions = $state([]);
+ let slashSkillsRequestId = 0;
+
+ async function getCachedSkills(): Promise {
+ if (!workspace) return [];
+ if (!cachedSkills || cachedSkillsWorkspace !== workspace) {
+ const data = await getSkills(workspace);
+ cachedSkills = data.map((s) => ({
+ id: s.name,
+ label: s.name,
+ description: s.description,
+ source: s.source
+ }));
+ cachedSkillsWorkspace = workspace;
+ }
+ return cachedSkills;
+ }
async function fetchSkillSuggestions({ query }: { query: string }): Promise {
if (!workspace) return [];
try {
- // Cache skills list (small, doesn't change during a session)
- if (!cachedSkills) {
- const data = await getSkills(workspace);
- cachedSkills = data.map((s) => ({
- id: s.name,
- label: s.name,
- description: s.description,
- source: s.source
- }));
- }
- if (!query) return cachedSkills;
+ const skills = await getCachedSkills();
+ if (!query) return skills;
const q = query.toLowerCase();
- return cachedSkills.filter(
+ return skills.filter(
(s) => s.label.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q)
);
} catch {
@@ -545,19 +558,19 @@
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedSlashCommandIndex =
- (selectedSlashCommandIndex + 1) % Math.max(slashCommandIds.length, 1);
+ (selectedSlashCommandIndex + 1) % Math.max(slashSuggestionIds.length, 1);
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedSlashCommandIndex =
- (selectedSlashCommandIndex - 1 + slashCommandIds.length) %
- Math.max(slashCommandIds.length, 1);
+ (selectedSlashCommandIndex - 1 + slashSuggestionIds.length) %
+ Math.max(slashSuggestionIds.length, 1);
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
- runSlashCommand(slashCommandIds[selectedSlashCommandIndex]);
+ runSlashSuggestion(slashSuggestionIds[selectedSlashCommandIndex]);
return true;
}
}
@@ -930,35 +943,115 @@
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 && onfork && '/fork'.startsWith(slashCommandQuery)) ids.push('fork');
+ 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;
});
- const showSlashCommands = $derived(slashCommandIds.length > 0);
+ const slashSuggestionIds = $derived([
+ ...slashCommandIds.map((id) => `command:${id}`),
+ ...slashSkillSuggestions.map((skill) => `skill:${skill.id}`)
+ ]);
+ const showSlashCommands = $derived(slashSuggestionIds.length > 0);
const contextPercent = $derived(Math.max(0, Math.round(contextUsage?.percent ?? 0)));
const contextCirclePercent = $derived(Math.min(contextPercent, 100));
const contextCircleOffset = $derived(50.27 * (1 - contextCirclePercent / 100));
$effect(() => {
- if (selectedSlashCommandIndex >= slashCommandIds.length) selectedSlashCommandIndex = 0;
+ if (selectedSlashCommandIndex >= slashSuggestionIds.length) selectedSlashCommandIndex = 0;
});
+ $effect(() => {
+ const query = slashCommandQuery;
+ const requestId = ++slashSkillsRequestId;
+ if (!query.startsWith('/') || !workspace) {
+ slashSkillSuggestions = [];
+ return;
+ }
+ void fetchSkillSuggestions({ query: query.slice(1) }).then((items) => {
+ if (requestId !== slashSkillsRequestId) return;
+ slashSkillSuggestions = items.slice(0, 5);
+ });
+ });
+
+ function selectedSlashCommand(commandId: string) {
+ return slashSuggestionIds[selectedSlashCommandIndex] === `command:${commandId}`;
+ }
+
+ function selectSlashCommand(commandId: string) {
+ selectedSlashCommandIndex = slashSuggestionIds.indexOf(`command:${commandId}`);
+ }
+
+ function selectedSlashSkill(skillId: string) {
+ return slashSuggestionIds[selectedSlashCommandIndex] === `skill:${skillId}`;
+ }
+
+ function selectSlashSkill(skillId: string) {
+ selectedSlashCommandIndex = slashSuggestionIds.indexOf(`skill:${skillId}`);
+ }
+
+ function runSlashSuggestion(suggestionId: string | undefined) {
+ if (suggestionId?.startsWith('skill:')) {
+ const skillId = suggestionId.slice('skill:'.length);
+ const skill = slashSkillSuggestions.find((item) => item.id === skillId);
+ runSlashSkill(skill ?? { id: skillId, label: skillId });
+ return;
+ }
+ runSlashCommand(suggestionId?.replace(/^command:/, ''));
+ }
+
+ function runSlashSkill(skill: SkillMentionAttrs) {
+ if (!editor || editor.isDestroyed) {
+ inputText = `$${skill.label} `;
+ return;
+ }
+ editor
+ .chain()
+ .focus()
+ .clearContent()
+ .insertContent([
+ {
+ type: 'skillMention',
+ attrs: {
+ id: skill.id,
+ label: skill.label,
+ description: skill.description,
+ source: skill.source
+ }
+ },
+ { type: 'text', text: ' ' }
+ ])
+ .run();
+ }
+
function runSlashCommand(commandId: string | undefined) {
if (commandId === 'compact' && (sending || streaming)) return;
+ if (commandId === 'fork' && (sending || streaming)) return;
if (commandId === 'compact' && oncompact) {
inputText = '';
oncompact();
return;
}
+ if (commandId === 'fork' && onfork) {
+ inputText = '';
+ onfork();
+ return;
+ }
if (commandId === 'plan' && onplan) {
inputText = '';
onplan();
@@ -982,7 +1075,7 @@
}
function handleSubmit() {
- runSlashCommand(slashCommandIds[0]);
+ runSlashSuggestion(slashSuggestionIds[0]);
}
// Allow sending during streaming (message will be enqueued server-side)
@@ -1033,9 +1126,11 @@
-
- {$t('chat.commands')}
-
+ {#if slashCommandIds.length > 0}
+
+ {$t('chat.commands')}
+
+ {/if}
{#if slashCommandIds.includes('compact')}
e.preventDefault()}
onclick={() => {
runSlashCommand('compact');
}}
- onmouseenter={() => (selectedSlashCommandIndex = slashCommandIds.indexOf('compact'))}
+ onmouseenter={() => selectSlashCommand('compact')}
>
@@ -1096,12 +1189,12 @@
placement: 'right'
}}
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75
- {slashCommandIds[selectedSlashCommandIndex] === 'plan' ? 'app-interactive-active' : ''}"
+ {selectedSlashCommand('plan') ? 'app-interactive-active' : ''}"
onmousedown={(e) => e.preventDefault()}
onclick={() => {
runSlashCommand('plan');
}}
- onmouseenter={() => (selectedSlashCommandIndex = slashCommandIds.indexOf('plan'))}
+ onmouseenter={() => selectSlashCommand('plan')}
>
{/if}
+ {#if slashCommandIds.includes('fork')}
+
e.preventDefault()}
+ onclick={() => {
+ runSlashCommand('fork');
+ }}
+ onmouseenter={() => selectSlashCommand('fork')}
+ >
+
+
+
+
+ {$t('chat.commandFork')}
+
+ {$t('chat.commandForkDesc')}
+
+
+
+ {/if}
{#if slashCommandIds.includes('status')}
e.preventDefault()}
onclick={() => {
runSlashCommand('status');
}}
- onmouseenter={() => (selectedSlashCommandIndex = slashCommandIds.indexOf('status'))}
+ onmouseenter={() => selectSlashCommand('status')}
>
e.preventDefault()}
onclick={() => {
runSlashCommand('skills:list');
}}
- onmouseenter={() => (selectedSlashCommandIndex = slashCommandIds.indexOf('skills:list'))}
+ onmouseenter={() => selectSlashCommand('skills:list')}
>
@@ -1201,13 +1322,12 @@
placement: 'right'
}}
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75
- {slashCommandIds[selectedSlashCommandIndex] === 'skills:create' ? 'app-interactive-active' : ''}"
+ {selectedSlashCommand('skills:create') ? 'app-interactive-active' : ''}"
onmousedown={(e) => e.preventDefault()}
onclick={() => {
runSlashCommand('skills:create');
}}
- onmouseenter={() =>
- (selectedSlashCommandIndex = slashCommandIds.indexOf('skills:create'))}
+ onmouseenter={() => selectSlashCommand('skills:create')}
>
@@ -1218,6 +1338,36 @@
{/if}
+ {#if slashSkillSuggestions.length > 0}
+
Skills
+ {#each slashSkillSuggestions as skill (skill.id)}
+
e.preventDefault()}
+ onclick={() => {
+ runSlashSkill(skill);
+ }}
+ onmouseenter={() => selectSlashSkill(skill.id)}
+ >
+
+
+
+
+ {skill.label}
+ {#if skill.source && skill.source !== 'workspace'}
+ {skill.source}
+ {/if}
+
+
+ {/each}
+ {/if}
{/if}
diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte
index 439cffe..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,
@@ -296,6 +298,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,21 +722,29 @@
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;
}
+ if (hasChatContent && text === '/fork') {
+ await handleForkChat();
+ return;
+ }
if (text === '/plan') {
handlePlanCommand();
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;
@@ -897,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));
}
@@ -1522,7 +1554,7 @@
placeholder={$t('chat.placeholder', { name: workspaceDisplayName })}
tasks={chatTasks}
onsend={send}
- onstatus={handleStatusCommand}
+ onplan={handlePlanCommand}
{queuedMessages}
onqueuesendnow={handleQueueSendNow}
onqueueedit={handleQueueEdit}
@@ -1583,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)}
@@ -1632,9 +1665,11 @@
{streaming}
{workspace}
{contextUsage}
+ {hasChatContent}
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(reason: str = "empty_chat") -> str:
+ if reason == "disabled":
+ return (
+ "[/skills:create] Skill creation is disabled in admin settings. "
+ "Explain this briefly and do not try to create or update a skill."
+ )
+ 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,
+ denial_reason: str = "empty_chat",
+) -> bool:
for message in reversed(messages):
if message.get("role") != "user":
continue
@@ -171,7 +219,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(denial_reason)
+ )
return True
return False
@@ -1315,6 +1367,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 +1453,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 +1465,15 @@ 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_settings = await get_skill_settings()
+ skill_authoring_allowed = _has_prior_real_chat_content(messages, loaded_summary)
+ skill_create_denial_reason = "empty_chat"
+ if not skill_settings["enabled"] or not skill_settings["tool_enabled"]:
+ skill_authoring_allowed = False
+ skill_create_denial_reason = "disabled"
+ skill_create_requested = _apply_skills_create_prompt(
+ messages, allowed=skill_authoring_allowed, denial_reason=skill_create_denial_reason
+ )
memory_message, memory_files = _memory_recall_inputs(messages, regeneration_prompt)
system = await _load_system_prompt(
workspace,
@@ -1629,6 +1700,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 +1741,15 @@ 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_settings = await get_skill_settings()
+ skill_authoring_allowed = _has_prior_real_chat_content(messages, loaded_summary)
+ skill_create_denial_reason = "empty_chat"
+ if not skill_settings["enabled"] or not skill_settings["tool_enabled"]:
+ skill_authoring_allowed = False
+ skill_create_denial_reason = "disabled"
+ skill_create_requested = _apply_skills_create_prompt(
+ messages, allowed=skill_authoring_allowed, denial_reason=skill_create_denial_reason
+ )
memory_message, memory_files = _memory_recall_inputs(messages, regeneration_prompt)
system = await _load_system_prompt(
workspace,
@@ -1684,9 +1764,11 @@ 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)
+ skills = discover_skills(workspace) if skill_settings["enabled"] else []
if not skills:
tools = [t for t in tools if t["name"] != "view_skill"]
@@ -1717,6 +1799,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 +1817,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 +2051,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 +2080,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 +2288,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 +2419,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/prompt_templates.py b/cptr/utils/prompt_templates.py
index 8d82e95..e3fb559 100644
--- a/cptr/utils/prompt_templates.py
+++ b/cptr/utils/prompt_templates.py
@@ -208,7 +208,12 @@ def _render_system_template(template: str, variables: dict[str, str]) -> str:
return re.sub(r"\n{3,}", "\n\n", rendered).strip()
-def _build_template_variables(workspace: str, model: str = "", memory: str = "") -> dict[str, str]:
+def _build_template_variables(
+ workspace: str,
+ model: str = "",
+ memory: str = "",
+ skills_enabled: bool = True,
+) -> dict[str, str]:
"""Build the dict of template variable values for the current context."""
ws_path = Path(workspace)
os_name = platform.system().replace("Darwin", "macOS")
@@ -225,8 +230,7 @@ def _build_template_variables(workspace: str, model: str = "", memory: str = "")
else:
instructions_block = ""
- skills = discover_skills(workspace)
- skills_block = build_catalog_xml(skills)
+ skills_block = build_catalog_xml(discover_skills(workspace)) if skills_enabled else ""
return {
"WORKSPACE_NAME": ws_path.name if ws_path.is_dir() else "",
@@ -310,5 +314,10 @@ async def load_system_prompt(
if memory and "{{MEMORY}}" not in template:
template = template.rstrip() + "\n\n{{MEMORY}}"
- variables = _build_template_variables(workspace, model, memory)
+ try:
+ skills_enabled = (await Config.get("skills.enabled")) not in (False, "false", "0")
+ except Exception:
+ skills_enabled = True
+
+ variables = _build_template_variables(workspace, model, memory, skills_enabled)
return _render_system_template(template, variables)
diff --git a/cptr/utils/skills.py b/cptr/utils/skills.py
index 3fa9259..4150bb6 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,22 @@
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] = {
+ "enabled": True,
+ "tool_enabled": True,
+ "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 +91,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 +163,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 +336,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 +376,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 +391,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 +568,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 +580,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 +634,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 +697,182 @@ 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["enabled"] = settings.get("enabled") not in (False, "false", "0")
+ settings["tool_enabled"] = settings.get("tool_enabled") not in (False, "false", "0")
+ 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["enabled"]
+ or not settings["tool_enabled"]
+ or 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..eba3411 100644
--- a/cptr/utils/tools.py
+++ b/cptr/utils/tools.py
@@ -1611,7 +1611,11 @@ 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.models import Config
+ from cptr.utils.skills import bump_skill_view, load_skill, format_skill_content
+
+ if (await Config.get("skills.enabled")) in (False, "false", "0"):
+ return "Error: skills are disabled by the administrator."
# Deduplication: if already activated, return short notice
if skill_name in _activated_skills:
@@ -1622,11 +1626,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 +1640,44 @@ 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,
+ )
+ from cptr.models import Config
+
+ if (await Config.get("skills.enabled")) in (False, "false", "0"):
+ return json.dumps({"success": False, "error": "skills are disabled"}, ensure_ascii=False)
+ if (await Config.get("skills.tool_enabled")) in (False, "false", "0"):
+ return json.dumps(
+ {"success": False, "error": "skill management is disabled"},
+ ensure_ascii=False,
+ )
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:
@@ -2842,6 +2865,17 @@ async def get_tool_list(builtin_tools: dict | None = None) -> list[dict]:
memory_enabled = (await Config.get("memory.enabled")) not in (False, "false", "0")
if not memory_enabled:
tools.pop("update_memory", None)
+ skills_enabled = (await Config.get("skills.enabled")) not in (False, "false", "0")
+ skills_tool_enabled = (await Config.get("skills.tool_enabled")) not in (
+ False,
+ "false",
+ "0",
+ )
+ if not skills_enabled:
+ tools.pop("view_skill", None)
+ tools.pop("manage_skill", None)
+ elif not skills_tool_enabled:
+ tools.pop("manage_skill", None)
if (await Config.get("browser.enabled")) in (True, "true", "1"):
tools.update(BROWSER_TOOLS)
if (await Config.get("subagents.enabled")) in (True, "true", "1"):
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
diff --git a/pyproject.toml b/pyproject.toml
index e6d91a2..bb2af1e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "cptr"
-version = "0.8.1"
+version = "0.8.2"
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 55fa9e9..b1a10bf 100644
--- a/uv.lock
+++ b/uv.lock
@@ -284,7 +284,7 @@ wheels = [
[[package]]
name = "cptr"
-version = "0.8.1"
+version = "0.8.2"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },