initial new skill system:

- allow users to edit skills
 - ui for skills insdie settings
 - alerts for updated skills + skill diff
This commit is contained in:
tusharmagar 2026-03-23 12:15:29 +05:30
parent c41586b85d
commit 5cbe388096
15 changed files with 851 additions and 17 deletions

View file

@ -23,6 +23,8 @@ const execAsync = promisify(exec);
import { RunEvent } from '@x/shared/dist/runs.js';
import { ServiceEvent } from '@x/shared/dist/service-events.js';
import container from '@x/core/dist/di/container.js';
import type { ISkillsRepo } from '@x/core/dist/skills/repo.js';
import type { ISkillResolver } from '@x/core/dist/skills/resolver.js';
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
import { testModelConnection } from '@x/core/dist/models/models.js';
import { isSignedIn } from '@x/core/dist/account/account.js';
@ -722,6 +724,30 @@ export function setupIpcHandlers() {
'voice:getDeepgramToken': async () => {
return voice.getDeepgramToken();
},
// Skills handlers
'skills:list': async () => {
const resolver = container.resolve<ISkillResolver>('skillResolver');
const skills = await resolver.getCatalog();
return { skills };
},
'skills:get': async (_event, args) => {
const resolver = container.resolve<ISkillResolver>('skillResolver');
return await resolver.resolve(args.id);
},
'skills:getOfficial': async (_event, args) => {
const resolver = container.resolve<ISkillResolver>('skillResolver');
return resolver.getOfficial(args.id);
},
'skills:saveOverride': async (_event, args) => {
const repo = container.resolve<ISkillsRepo>('skillsRepo');
await repo.saveOverride(args.skillId, args.meta, args.content);
return { success: true as const };
},
'skills:deleteOverride': async (_event, args) => {
const repo = container.resolve<ISkillsRepo>('skillsRepo');
await repo.deleteOverride(args.skillId);
return { success: true as const };
},
// Billing handler
'billing:getInfo': async () => {
return await getBillingInfo();

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X, User, Plug } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X, User, Plug, Sparkles } from "lucide-react"
import {
Dialog,
@ -24,8 +24,9 @@ import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
import { SkillsSettings } from "@/components/settings/skills-settings"
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "note-tagging"
export type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "note-tagging" | "skills"
interface TabConfig {
id: ConfigTab
@ -82,10 +83,17 @@ const tabs: TabConfig[] = [
path: "config/tags.json",
description: "Configure tags for notes and emails",
},
{
id: "skills",
label: "Skills",
icon: Sparkles,
description: "View and customize copilot skills",
},
]
interface SettingsDialogProps {
children: React.ReactNode
initialTab?: ConfigTab
}
// --- Theme option for Appearance tab ---
@ -1238,15 +1246,17 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
// --- Main Settings Dialog ---
export function SettingsDialog({ children }: SettingsDialogProps) {
export function SettingsDialog({ children, initialTab }: SettingsDialogProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<ConfigTab>("models")
const [activeTab, setActiveTab] = useState<ConfigTab>(initialTab ?? "models")
const [content, setContent] = useState("")
const [originalContent, setOriginalContent] = useState("")
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rowboatConnected, setRowboatConnected] = useState(false)
const [skillUpdateCount, setSkillUpdateCount] = useState(0)
const [skillsExpanded, setSkillsExpanded] = useState(false)
// Check if user is signed in to Rowboat
useEffect(() => {
@ -1259,6 +1269,24 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
})
}, [open])
// Check for skill updates
useEffect(() => {
if (!open) return
window.ipc.invoke('skills:list', null).then((result) => {
const count = result.skills.filter((s: { hasUpdate?: boolean }) => s.hasUpdate).length
setSkillUpdateCount(count)
}).catch(() => {
setSkillUpdateCount(0)
})
}, [open])
// Handle initialTab changes (e.g. when opened from sidebar notification)
useEffect(() => {
if (initialTab && open) {
setActiveTab(initialTab)
}
}, [initialTab, open])
const visibleTabs = useMemo(() => tabs, [])
const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0]
@ -1329,6 +1357,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
}, [open, activeTab, isJsonTab, loadConfig])
const handleTabChange = (tab: ConfigTab) => {
if (tab !== "skills") setSkillsExpanded(false)
if (isJsonTab && hasChanges) {
if (!confirm("You have unsaved changes. Discard them?")) {
return
@ -1341,7 +1370,12 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
className={cn(
"p-0 gap-0 overflow-hidden transition-all duration-200",
skillsExpanded
? "max-w-[1200px]! w-[1200px] h-[80vh]"
: "max-w-[900px]! w-[900px] h-[600px]"
)}
>
<div className="flex h-full overflow-hidden">
{/* Sidebar */}
@ -1362,7 +1396,12 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
)}
>
<tab.icon className="size-4" />
{tab.label}
<span className="flex-1">{tab.label}</span>
{tab.id === "skills" && skillUpdateCount > 0 && (
<span className="flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-amber-500 text-white text-[10px] font-medium">
{skillUpdateCount}
</span>
)}
</button>
))}
</nav>
@ -1381,7 +1420,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "account" || activeTab === "connected-accounts" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "account" || activeTab === "connected-accounts" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : activeTab === "skills" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connected-accounts" ? (
@ -1394,6 +1433,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : activeTab === "skills" ? (
<SkillsSettings dialogOpen={open} onExpandRequest={setSkillsExpanded} />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...

View file

@ -0,0 +1,428 @@
"use client"
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { ArrowLeft, RotateCcw, Save, Pencil, ArrowUpCircle, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import type { ResolvedSkill, SkillOverride } from "@x/shared/dist/skill.js"
type ViewMode = "view" | "edit" | "compare"
interface SkillsSettingsProps {
dialogOpen: boolean
onExpandRequest?: (expanded: boolean) => void
}
// ── Simple line-based diff ──────────────────────────────────────────────
type DiffLine = { type: "same" | "add" | "del"; text: string }
function computeDiff(oldText: string, newText: string): DiffLine[] {
const oldLines = oldText.split("\n")
const newLines = newText.split("\n")
// Simple LCS-based diff
const m = oldLines.length
const n = newLines.length
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
}
// Backtrack to build diff
const result: DiffLine[] = []
let i = m, j = n
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
result.push({ type: "same", text: oldLines[i - 1] })
i--; j--
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.push({ type: "add", text: newLines[j - 1] })
j--
} else {
result.push({ type: "del", text: oldLines[i - 1] })
i--
}
}
return result.reverse()
}
function DiffView({ oldText, newText }: { oldText: string; newText: string }) {
const lines = useMemo(() => computeDiff(oldText, newText), [oldText, newText])
const stats = useMemo(() => {
const added = lines.filter((l) => l.type === "add").length
const removed = lines.filter((l) => l.type === "del").length
return { added, removed }
}, [lines])
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex items-center gap-3 mb-2 text-xs text-muted-foreground shrink-0">
<span className="text-emerald-600 dark:text-emerald-400 font-medium">+{stats.added} added</span>
<span className="text-red-600 dark:text-red-400 font-medium">-{stats.removed} removed</span>
</div>
<div className="flex-1 overflow-y-auto rounded-md border bg-muted/20">
<pre className="text-xs font-mono p-0 m-0">
{lines.map((line, i) => (
<div
key={i}
className={cn(
"px-3 py-0.5 border-l-2",
line.type === "add" && "bg-emerald-500/10 border-l-emerald-500 text-emerald-800 dark:text-emerald-300",
line.type === "del" && "bg-red-500/10 border-l-red-500 text-red-800 dark:text-red-300 line-through opacity-70",
line.type === "same" && "border-l-transparent text-muted-foreground"
)}
>
<span className="inline-block w-6 text-right mr-3 opacity-40 select-none text-[10px]">
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
{line.text || " "}
</div>
))}
</pre>
</div>
</div>
)
}
// ── Main component ──────────────────────────────────────────────────────
export function SkillsSettings({ dialogOpen, onExpandRequest }: SkillsSettingsProps) {
const [skills, setSkills] = useState<ResolvedSkill[]>([])
const [loading, setLoading] = useState(true)
const [selectedSkill, setSelectedSkill] = useState<string | null>(null)
const [editContent, setEditContent] = useState("")
const [officialContent, setOfficialContent] = useState("")
const [viewMode, setViewMode] = useState<ViewMode>("view")
const [saving, setSaving] = useState(false)
const loadSkills = useCallback(async () => {
try {
setLoading(true)
const result = await window.ipc.invoke("skills:list", null)
setSkills(result.skills)
} catch (err) {
console.error("Failed to load skills:", err)
toast.error("Failed to load skills")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (dialogOpen) {
loadSkills()
}
}, [dialogOpen, loadSkills])
// Notify parent to expand/shrink when entering/leaving compare mode
useEffect(() => {
onExpandRequest?.(viewMode === "compare")
}, [viewMode, onExpandRequest])
const handleSelectSkill = useCallback(async (skillId: string) => {
try {
const skill = await window.ipc.invoke("skills:get", { id: skillId })
if (skill) {
setSelectedSkill(skillId)
setEditContent(skill.content)
setViewMode("view")
}
} catch (err) {
console.error("Failed to load skill:", err)
toast.error("Failed to load skill")
}
}, [])
const handleCustomize = useCallback(async () => {
if (!selectedSkill) return
setViewMode("edit")
}, [selectedSkill])
const handleSave = useCallback(async () => {
if (!selectedSkill) return
const skill = skills.find((s) => s.id === selectedSkill)
if (!skill) return
try {
setSaving(true)
const meta: SkillOverride = {
base_skill_id: selectedSkill,
base_version: skill.version,
}
await window.ipc.invoke("skills:saveOverride", {
skillId: selectedSkill,
meta,
content: editContent,
})
toast.success("Skill customization saved")
setViewMode("view")
await loadSkills()
const updated = await window.ipc.invoke("skills:get", { id: selectedSkill })
if (updated) {
setEditContent(updated.content)
}
} catch (err) {
console.error("Failed to save skill override:", err)
toast.error("Failed to save")
} finally {
setSaving(false)
}
}, [selectedSkill, editContent, skills, loadSkills])
const handleReset = useCallback(async () => {
if (!selectedSkill) return
try {
await window.ipc.invoke("skills:deleteOverride", { skillId: selectedSkill })
toast.success("Skill reset to official version")
await loadSkills()
const official = await window.ipc.invoke("skills:get", { id: selectedSkill })
if (official) {
setEditContent(official.content)
}
setViewMode("view")
} catch (err) {
console.error("Failed to reset skill:", err)
toast.error("Failed to reset")
}
}, [selectedSkill, loadSkills])
const handleCompareUpdate = useCallback(async () => {
if (!selectedSkill) return
try {
const official = await window.ipc.invoke("skills:getOfficial", { id: selectedSkill })
if (official) {
setOfficialContent(official.content)
setViewMode("compare")
}
} catch (err) {
console.error("Failed to load official skill:", err)
toast.error("Failed to load official version")
}
}, [selectedSkill])
const handleAcceptUpdate = useCallback(async () => {
if (!selectedSkill) return
try {
await window.ipc.invoke("skills:deleteOverride", { skillId: selectedSkill })
toast.success("Updated to latest official version")
await loadSkills()
const updated = await window.ipc.invoke("skills:get", { id: selectedSkill })
if (updated) {
setEditContent(updated.content)
}
setViewMode("view")
} catch (err) {
console.error("Failed to accept update:", err)
toast.error("Failed to accept update")
}
}, [selectedSkill, loadSkills])
const handleAcceptAndRecustomize = useCallback(async () => {
if (!selectedSkill) return
const skill = skills.find((s) => s.id === selectedSkill)
if (!skill) return
try {
const meta: SkillOverride = {
base_skill_id: selectedSkill,
base_version: skill.version,
}
await window.ipc.invoke("skills:saveOverride", {
skillId: selectedSkill,
meta,
content: editContent,
})
toast.success("Base version updated — your customizations are preserved")
await loadSkills()
setViewMode("edit")
} catch (err) {
console.error("Failed to update base version:", err)
toast.error("Failed to update")
}
}, [selectedSkill, editContent, skills, loadSkills])
const handleBack = useCallback(() => {
setSelectedSkill(null)
setViewMode("view")
}, [])
const selectedSkillData = skills.find((s) => s.id === selectedSkill)
if (loading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading skills...
</div>
)
}
// ── Compare view — unified diff ────────────────────────────────────
if (selectedSkill && selectedSkillData && viewMode === "compare") {
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 pb-3 border-b mb-3 shrink-0">
<Button variant="ghost" size="sm" onClick={() => setViewMode("view")} className="h-7 w-7 p-0">
<X className="size-4" />
</Button>
<div className="flex-1 min-w-0">
<span className="font-medium text-sm">Review Update: {selectedSkillData.title}</span>
<p className="text-xs text-muted-foreground mt-0.5">
Changes from v{selectedSkillData.baseVersion} to v{selectedSkillData.version}
</p>
</div>
</div>
{/* Diff */}
<div className="flex-1 min-h-0 overflow-hidden">
<DiffView oldText={editContent} newText={officialContent} />
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 pt-3 border-t mt-3 shrink-0">
<Button variant="default" size="sm" onClick={handleAcceptUpdate} className="text-xs gap-1.5">
<ArrowUpCircle className="size-3.5" />
Accept Update
</Button>
<Button variant="outline" size="sm" onClick={handleAcceptAndRecustomize} className="text-xs gap-1.5">
<Pencil className="size-3.5" />
Keep Mine, Dismiss
</Button>
<Button variant="ghost" size="sm" onClick={() => setViewMode("view")} className="text-xs">
Cancel
</Button>
</div>
</div>
)
}
// ── Skill detail / editor view ─────────────────────────────────────
if (selectedSkill && selectedSkillData) {
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 pb-3 border-b mb-3 shrink-0">
<Button variant="ghost" size="sm" onClick={handleBack} className="h-7 w-7 p-0">
<ArrowLeft className="size-4" />
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{selectedSkillData.title}</span>
<SourceBadge source={selectedSkillData.source} baseVersion={selectedSkillData.baseVersion} />
</div>
</div>
<div className="flex items-center gap-1.5">
{selectedSkillData.source === "override" && viewMode !== "edit" && (
<Button variant="ghost" size="sm" onClick={handleReset} className="h-7 text-xs gap-1">
<RotateCcw className="size-3" />
Reset
</Button>
)}
{viewMode !== "edit" ? (
<Button variant="ghost" size="sm" onClick={handleCustomize} className="h-7 text-xs gap-1">
<Pencil className="size-3" />
{selectedSkillData.source === "override" ? "Edit" : "Customize"}
</Button>
) : (
<Button variant="default" size="sm" onClick={handleSave} disabled={saving} className="h-7 text-xs gap-1">
<Save className="size-3" />
{saving ? "Saving..." : "Save"}
</Button>
)}
</div>
</div>
{/* Update banner */}
{selectedSkillData.hasUpdate && viewMode !== "edit" && (
<button
onClick={handleCompareUpdate}
className="flex items-center gap-3 px-3 py-2.5 mb-3 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 hover:bg-amber-100 dark:hover:bg-amber-950/50 transition-colors shrink-0"
>
<ArrowUpCircle className="size-4 text-amber-600 dark:text-amber-400 shrink-0" />
<div className="flex-1 text-left">
<span className="text-xs font-medium text-amber-800 dark:text-amber-300">
Official update available (v{selectedSkillData.version})
</span>
<p className="text-[11px] text-amber-600 dark:text-amber-400 mt-0.5">
Your version is based on v{selectedSkillData.baseVersion}. Click to review changes.
</p>
</div>
<span className="text-xs font-medium text-amber-700 dark:text-amber-300 shrink-0">
Review
</span>
</button>
)}
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{viewMode === "edit" ? (
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full h-full resize-none bg-muted/50 rounded-md p-3 font-mono text-xs border-0 focus:outline-none focus:ring-1 focus:ring-ring"
spellCheck={false}
/>
) : (
<div className="h-full overflow-y-auto">
<pre className="whitespace-pre-wrap text-xs font-mono text-muted-foreground p-3 bg-muted/30 rounded-md">
{editContent}
</pre>
</div>
)}
</div>
</div>
)
}
// ── Skills list view ───────────────────────────────────────────────
return (
<div className="h-full overflow-y-auto space-y-1">
{skills.map((skill) => (
<button
key={skill.id}
onClick={() => handleSelectSkill(skill.id)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-md transition-colors",
"hover:bg-muted/50 border border-transparent hover:border-border",
skill.hasUpdate && "border-amber-200 dark:border-amber-800/50 bg-amber-50/50 dark:bg-amber-950/10"
)}
>
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-medium truncate">{skill.title}</span>
<SourceBadge source={skill.source} baseVersion={skill.baseVersion} />
{skill.hasUpdate && (
<Badge className="bg-amber-500 hover:bg-amber-500 text-white text-[10px] px-1.5 py-0">
Update
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-1">{skill.summary}</p>
</button>
))}
</div>
)
}
function SourceBadge({ source, baseVersion }: { source: string; baseVersion?: string }) {
if (source === "override") {
return (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
Customized{baseVersion ? ` from v${baseVersion}` : ""}
</Badge>
)
}
return null
}

View file

@ -405,6 +405,7 @@ export function SidebarContentPanel({
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [loggingIn, setLoggingIn] = useState(false)
const { billing } = useBilling(isRowboatConnected)
const [skillUpdateCount, setSkillUpdateCount] = useState(0)
const handleRowboatLogin = useCallback(async () => {
try {
@ -449,6 +450,14 @@ export function SidebarContentPanel({
setLoggingIn(false)
})
// Check for skill updates
window.ipc.invoke('skills:list', null).then((result) => {
if (mounted) {
const count = result.skills.filter((s: { hasUpdate?: boolean }) => s.hasUpdate).length
setSkillUpdateCount(count)
}
}).catch(() => {})
return () => {
mounted = false
cleanup()
@ -597,6 +606,11 @@ export function SidebarContentPanel({
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
<Settings className="size-4" />
<span>Settings</span>
{skillUpdateCount > 0 && (
<span className="ml-auto text-[10px] text-amber-600 dark:text-amber-400 font-medium">
{skillUpdateCount} skill {skillUpdateCount === 1 ? "update" : "updates"}
</span>
)}
</button>
</SettingsDialog>
<HelpPopover>

View file

@ -11,8 +11,10 @@ import { execTool } from "../application/lib/exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { CopilotAgent } from "../application/assistant/agent.js";
import { SKILL_CATALOG_PLACEHOLDER } from "../application/assistant/instructions.js";
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import container from "../di/container.js";
import { ISkillResolver } from "../skills/resolver.js";
import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js";
import { isSignedIn } from "../account/account.js";
@ -314,7 +316,12 @@ function formatLlmStreamError(rawError: unknown): string {
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
if (id === "copilot" || id === "rowboatx") {
return CopilotAgent;
const resolver = container.resolve<ISkillResolver>("skillResolver");
const catalogMarkdown = await resolver.generateCatalogMarkdown();
return {
...CopilotAgent,
instructions: CopilotAgent.instructions.replace(SKILL_CATALOG_PLACEHOLDER, catalogMarkdown),
};
}
if (id === 'note_creation') {

View file

@ -1,7 +1,8 @@
import { skillCatalog } from "./skills/index.js";
import { WorkDir as BASE_DIR } from "../../config/config.js";
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
export const SKILL_CATALOG_PLACEHOLDER = "{{SKILL_CATALOG}}";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
@ -117,7 +118,7 @@ Use the catalog below to decide which skills to load for each user request. Befo
- Call the \`loadSkill\` tool with the skill's name or path so you can read its guidance string.
- Apply the instructions from every loaded skill while working on the request.
\${skillCatalog}
${SKILL_CATALOG_PLACEHOLDER}
Always consult this catalog first so you load the right skills before taking action.

View file

@ -16,10 +16,11 @@ import appNavigationSkill from "./app-navigation/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
type SkillDefinition = {
export type SkillDefinition = {
id: string; // Also used as folder name
title: string;
summary: string;
version: string; // semver
content: string;
};
@ -29,82 +30,94 @@ type ResolvedSkill = {
content: string;
};
const definitions: SkillDefinition[] = [
export const officialDefinitions: SkillDefinition[] = [
{
id: "create-presentations",
title: "Create Presentations",
summary: "Create PDF presentations and slide decks from natural language requests using knowledge base context.",
version: "1.0.0",
content: createPresentationsSkill,
},
{
id: "doc-collab",
title: "Document Collaboration",
summary: "Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.",
version: "1.0.0",
content: docCollabSkill,
},
{
id: "draft-emails",
title: "Draft Emails",
summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.",
version: "1.1.0",
content: draftEmailsSkill,
},
{
id: "meeting-prep",
title: "Meeting Prep",
summary: "Prepare for meetings by gathering context about attendees from the knowledge base.",
version: "1.0.0",
content: meetingPrepSkill,
},
{
id: "organize-files",
title: "Organize Files",
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
version: "1.0.0",
content: organizeFilesSkill,
},
{
id: "slack",
title: "Slack Integration",
summary: "Send Slack messages, view channel history, search conversations, find users, and manage team communication.",
version: "1.0.0",
content: slackSkill,
},
{
id: "background-agents",
title: "Background Agents",
summary: "Creating, editing, and scheduling background agents. Configure schedules in agent-schedule.json and build multi-agent workflows.",
version: "1.0.0",
content: backgroundAgentsSkill,
},
{
id: "builtin-tools",
title: "Builtin Tools Reference",
summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.",
version: "1.0.0",
content: builtinToolsSkill,
},
{
id: "mcp-integration",
title: "MCP Integration Guidance",
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
version: "1.0.0",
content: mcpIntegrationSkill,
},
{
id: "web-search",
title: "Web Search",
summary: "Searching the web or researching a topic. Guidance on when to use web-search vs research-search, and how many searches to do.",
version: "1.0.0",
content: webSearchSkill,
},
{
id: "deletion-guardrails",
title: "Deletion Guardrails",
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
version: "1.0.0",
content: deletionGuardrailsSkill,
},
{
id: "app-navigation",
title: "App Navigation",
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
version: "1.0.0",
content: appNavigationSkill,
},
];
const skillEntries = definitions.map((definition) => ({
const skillEntries = officialDefinitions.map((definition) => ({
...definition,
catalogPath: `${CATALOG_PREFIX}/${definition.id}/skill.ts`,
}));

View file

@ -4,7 +4,8 @@ import * as fs from "fs/promises";
import { execSync } from "child_process";
import { glob } from "glob";
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { availableSkills } from "../assistant/skills/index.js";
import { ISkillResolver } from "../../skills/resolver.js";
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
import container from "../../di/container.js";
import { IMcpConfigRepo } from "../..//mcp/repo.js";
@ -61,10 +62,11 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
loadSkill: {
description: "Load a Rowboat skill definition into context by fetching its guidance string",
inputSchema: z.object({
skillName: z.string().describe("Skill identifier or path (e.g., 'workflow-run-ops' or 'src/application/assistant/skills/workflow-run-ops/skill.ts')"),
skillName: z.string().describe("Skill identifier (e.g., 'doc-collab', 'web-search')"),
}),
execute: async ({ skillName }: { skillName: string }) => {
const resolved = resolveSkill(skillName);
const resolver = container.resolve<ISkillResolver>("skillResolver");
const resolved = await resolver.resolve(skillName);
if (!resolved) {
return {
@ -76,12 +78,26 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
return {
success: true,
skillName: resolved.id,
path: resolved.catalogPath,
source: resolved.source,
content: resolved.content,
};
},
},
listSkills: {
description: "List all available skills with their metadata, source, and customization status",
inputSchema: z.object({}),
execute: async () => {
const resolver = container.resolve<ISkillResolver>("skillResolver");
const catalog = await resolver.getCatalog();
return {
success: true,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
skills: catalog.map(({ content, ...rest }) => rest),
};
},
},
'workspace-getRoot': {
description: 'Get the workspace root directory path',
inputSchema: z.object({}),

View file

@ -16,6 +16,7 @@ function ensureDirs() {
ensure(path.join(WorkDir, "agents"));
ensure(path.join(WorkDir, "config"));
ensure(path.join(WorkDir, "knowledge"));
ensure(path.join(WorkDir, "skills", "overrides"));
}
function ensureDefaultConfigs() {

View file

@ -15,6 +15,8 @@ import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
import { FSSkillsRepo, ISkillsRepo } from "../skills/repo.js";
import { SkillResolver, ISkillResolver } from "../skills/resolver.js";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -39,6 +41,8 @@ container.register({
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
skillsRepo: asClass<ISkillsRepo>(FSSkillsRepo).singleton(),
skillResolver: asClass<ISkillResolver>(SkillResolver).singleton(),
});
export default container;

View file

@ -0,0 +1,87 @@
import { WorkDir } from "../config/config.js";
import fs from "fs/promises";
import path from "path";
import { parse, stringify } from "yaml";
import { SkillOverride, SkillOverrideEntry } from "@x/shared/dist/skill.js";
export interface ISkillsRepo {
listOverrides(): Promise<SkillOverrideEntry[]>;
getOverride(skillId: string): Promise<SkillOverrideEntry | null>;
saveOverride(skillId: string, meta: SkillOverride, content: string): Promise<void>;
deleteOverride(skillId: string): Promise<void>;
}
export class FSSkillsRepo implements ISkillsRepo {
private readonly overridesDir = path.join(WorkDir, "skills", "overrides");
async listOverrides(): Promise<SkillOverrideEntry[]> {
const result: SkillOverrideEntry[] = [];
let files: string[];
try {
files = await fs.readdir(this.overridesDir);
} catch {
return result;
}
for (const file of files) {
if (!file.endsWith(".md")) continue;
try {
const entry = await this.parseOverrideMd(path.join(this.overridesDir, file));
result.push(entry);
} catch (error) {
console.error(`Error parsing skill override ${file}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return result;
}
async getOverride(skillId: string): Promise<SkillOverrideEntry | null> {
const filePath = path.join(this.overridesDir, `${skillId}.md`);
try {
await fs.access(filePath);
return await this.parseOverrideMd(filePath);
} catch {
return null;
}
}
async saveOverride(skillId: string, meta: SkillOverride, content: string): Promise<void> {
await fs.mkdir(this.overridesDir, { recursive: true });
const frontmatter = stringify(meta);
const fileContent = `---\n${frontmatter}---\n${content}`;
await fs.writeFile(path.join(this.overridesDir, `${skillId}.md`), fileContent);
}
async deleteOverride(skillId: string): Promise<void> {
const filePath = path.join(this.overridesDir, `${skillId}.md`);
try {
await fs.unlink(filePath);
} catch {
// File doesn't exist, nothing to delete
}
}
private async parseOverrideMd(filePath: string): Promise<SkillOverrideEntry> {
const raw = await fs.readFile(filePath, "utf8");
const skillId = path.basename(filePath, ".md");
if (!raw.startsWith("---")) {
throw new Error(`Skill override ${skillId} missing frontmatter`);
}
const end = raw.indexOf("\n---", 3);
if (end === -1) {
throw new Error(`Skill override ${skillId} has malformed frontmatter`);
}
const fm = raw.slice(3, end).trim();
const body = raw.slice(end + 4).trim();
const meta = SkillOverride.parse(parse(fm));
return {
skillId,
meta,
content: body,
};
}
}

View file

@ -0,0 +1,115 @@
import { ResolvedSkill } from "@x/shared/dist/skill.js";
import { officialDefinitions, type SkillDefinition } from "../application/assistant/skills/index.js";
import { ISkillsRepo } from "./repo.js";
export interface ISkillResolver {
getCatalog(): Promise<ResolvedSkill[]>;
resolve(id: string): Promise<ResolvedSkill | null>;
getOfficial(id: string): ResolvedSkill | null;
generateCatalogMarkdown(): Promise<string>;
}
export class SkillResolver implements ISkillResolver {
private readonly officialMap: Map<string, SkillDefinition>;
private readonly skillsRepo: ISkillsRepo;
constructor({ skillsRepo }: { skillsRepo: ISkillsRepo }) {
this.skillsRepo = skillsRepo;
this.officialMap = new Map(
officialDefinitions.map((d) => [d.id, d]),
);
}
async getCatalog(): Promise<ResolvedSkill[]> {
const overrides = await this.skillsRepo.listOverrides();
const overrideMap = new Map(overrides.map((o) => [o.skillId, o]));
const results: ResolvedSkill[] = [];
for (const official of officialDefinitions) {
const override = overrideMap.get(official.id);
if (override) {
results.push({
id: official.id,
title: override.meta.title ?? official.title,
summary: override.meta.summary ?? official.summary,
version: official.version,
source: "override",
content: override.content,
hasUpdate: override.meta.base_version !== official.version,
baseVersion: override.meta.base_version,
});
} else {
results.push({
id: official.id,
title: official.title,
summary: official.summary,
version: official.version,
source: "official",
content: official.content,
});
}
}
return results;
}
async resolve(id: string): Promise<ResolvedSkill | null> {
const official = this.officialMap.get(id);
if (!official) return null;
const override = await this.skillsRepo.getOverride(id);
if (override) {
return {
id: official.id,
title: override.meta.title ?? official.title,
summary: override.meta.summary ?? official.summary,
version: official.version,
source: "override",
content: override.content,
hasUpdate: override.meta.base_version !== official.version,
baseVersion: override.meta.base_version,
};
}
return {
id: official.id,
title: official.title,
summary: official.summary,
version: official.version,
source: "official",
content: official.content,
};
}
getOfficial(id: string): ResolvedSkill | null {
const official = this.officialMap.get(id);
if (!official) return null;
return {
id: official.id,
title: official.title,
summary: official.summary,
version: official.version,
source: "official",
content: official.content,
};
}
async generateCatalogMarkdown(): Promise<string> {
const catalog = await this.getCatalog();
const sections = catalog.map((skill) => [
`## ${skill.title}`,
`- **Skill file:** \`${skill.id}\``,
`- **Use it for:** ${skill.summary}`,
].join("\n"));
return [
"# Rowboat Skill Catalog",
"",
"Use this catalog to see which specialized skills you can load. Each entry lists the skill id plus a short description of when it helps.",
"",
sections.join("\n\n"),
].join("\n");
}
}

View file

@ -11,4 +11,5 @@ export * as inlineTask from './inline-task.js';
export * as blocks from './blocks.js';
export * as frontmatter from './frontmatter.js';
export * as bases from './bases.js';
export * as skill from './skill.js';
export { PrefixLogger };

View file

@ -7,6 +7,7 @@ import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
import { UserMessageContent } from './message.js';
import { ResolvedSkill, SkillOverride } from './skill.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@ -550,6 +551,43 @@ const ipcSchemas = {
}),
},
// Billing channels
// Skills channels
'skills:list': {
req: z.null(),
res: z.object({
skills: z.array(ResolvedSkill),
}),
},
'skills:get': {
req: z.object({
id: z.string(),
}),
res: ResolvedSkill.nullable(),
},
'skills:getOfficial': {
req: z.object({
id: z.string(),
}),
res: ResolvedSkill.nullable(),
},
'skills:saveOverride': {
req: z.object({
skillId: z.string(),
meta: SkillOverride,
content: z.string(),
}),
res: z.object({
success: z.literal(true),
}),
},
'skills:deleteOverride': {
req: z.object({
skillId: z.string(),
}),
res: z.object({
success: z.literal(true),
}),
},
'billing:getInfo': {
req: z.null(),
res: z.object({

View file

@ -0,0 +1,42 @@
import { z } from 'zod';
// Official skill metadata (bundled with app)
export const OfficialSkillMeta = z.object({
id: z.string(),
title: z.string(),
summary: z.string(),
version: z.string(),
source: z.literal("official"),
});
// User override metadata (stored on disk as YAML frontmatter)
export const SkillOverride = z.object({
base_skill_id: z.string(),
base_version: z.string(),
title: z.string().optional(),
summary: z.string().optional(),
});
// Parsed override entry (metadata + content)
export const SkillOverrideEntry = z.object({
skillId: z.string(),
meta: SkillOverride,
content: z.string(),
});
// Resolved skill seen by the agent (source-agnostic)
export const ResolvedSkill = z.object({
id: z.string(),
title: z.string(),
summary: z.string(),
version: z.string(),
source: z.enum(["official", "override", "installed"]),
content: z.string(),
hasUpdate: z.boolean().optional(),
baseVersion: z.string().optional(),
});
export type OfficialSkillMeta = z.infer<typeof OfficialSkillMeta>;
export type SkillOverride = z.infer<typeof SkillOverride>;
export type SkillOverrideEntry = z.infer<typeof SkillOverrideEntry>;
export type ResolvedSkill = z.infer<typeof ResolvedSkill>;