mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
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:
parent
c41586b85d
commit
5cbe388096
15 changed files with 851 additions and 17 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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...
|
||||
|
|
|
|||
428
apps/x/apps/renderer/src/components/settings/skills-settings.tsx
Normal file
428
apps/x/apps/renderer/src/components/settings/skills-settings.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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({}),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
87
apps/x/packages/core/src/skills/repo.ts
Normal file
87
apps/x/packages/core/src/skills/repo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
115
apps/x/packages/core/src/skills/resolver.ts
Normal file
115
apps/x/packages/core/src/skills/resolver.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
42
apps/x/packages/shared/src/skill.ts
Normal file
42
apps/x/packages/shared/src/skill.ts
Normal 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>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue