mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-18 20:15:20 +02:00
Move skills into apps/skills/, drop override + sync layers
Skills now ship with the app under /apps/skills/ (sibling of /apps/x).
Forge bundles the directory into Resources/skills/; main resolves it via
process.resourcesPath in production and a workspace-relative path in dev,
then registers it in the DI container. The runtime reads SKILL.md files
directly from the bundle — no copy to ~/.rowboat/skills/, no GitHub
tarball sync.
Drop the override layer (FSSkillsRepo, SkillOverride, edit/diff UI,
skill-update notification) since skills are now read-only and only ship
with app updates. Resolver simplifies to a single source.
Add a placeholder substitution layer so skills that need live data
(currently `tracks`, with {{TRACK_BLOCK_SCHEMA}}) keep dynamic content
without depending on TS-module evaluation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
66c0bc5fa7
commit
e23f4ad6d2
32 changed files with 5999 additions and 1446 deletions
|
|
@ -11,6 +11,9 @@ module.exports = {
|
|||
icon: './icons/icon', // .icns extension added automatically
|
||||
appBundleId: 'com.rowboat.app',
|
||||
appCategoryType: 'public.app-category.productivity',
|
||||
// Bundles <repo>/apps/skills/ into Resources/skills/ in the packaged app.
|
||||
// Read at runtime via process.resourcesPath in main.ts (resolveSkillsDir).
|
||||
extraResource: [path.join(__dirname, '../../../skills')],
|
||||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ 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';
|
||||
|
|
@ -824,7 +823,7 @@ export function setupIpcHandlers() {
|
|||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
// Skills handlers
|
||||
// Skills handlers (read-only)
|
||||
'skills:list': async () => {
|
||||
const resolver = container.resolve<ISkillResolver>('skillResolver');
|
||||
const skills = await resolver.getCatalog();
|
||||
|
|
@ -834,20 +833,6 @@ export function setupIpcHandlers() {
|
|||
const resolver = container.resolve<ISkillResolver>('skillResolver');
|
||||
return await resolver.resolve(args.id);
|
||||
},
|
||||
'skills:getOfficial': async (_event, args) => {
|
||||
const resolver = container.resolve<ISkillResolver>('skillResolver');
|
||||
return await 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();
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.j
|
|||
import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
|
||||
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||
import { init as initSkillSync } from "@x/core/dist/skills/sync.js";
|
||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
|
||||
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
|
||||
|
|
@ -33,7 +32,7 @@ import started from "electron-squirrel-startup";
|
|||
import { execSync, exec, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
import { registerBrowserControlService } from "@x/core/dist/di/container.js";
|
||||
import { registerBrowserControlService, registerSkillsDir } from "@x/core/dist/di/container.js";
|
||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||
|
|
@ -43,6 +42,17 @@ const execAsync = promisify(exec);
|
|||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function resolveSkillsDir(): string {
|
||||
if (app.isPackaged) {
|
||||
// forge.config.cjs ships apps/skills/ as extraResource → Resources/skills/
|
||||
return path.join(process.resourcesPath, "skills");
|
||||
}
|
||||
// Dev: walk up to repo root from this file's bundled location.
|
||||
// main runs from apps/x/apps/main/.package/dist/main.cjs, so 5 levels up
|
||||
// lands at <repo>/apps/x; one more lands at <repo>/apps; skills is its sibling.
|
||||
return path.resolve(__dirname, "..", "..", "..", "..", "..", "skills");
|
||||
}
|
||||
|
||||
// run this as early in the main process as possible
|
||||
if (started) app.quit();
|
||||
|
||||
|
|
@ -233,6 +243,10 @@ app.whenReady().then(async () => {
|
|||
|
||||
registerBrowserControlService(new ElectronBrowserControlService());
|
||||
|
||||
// Skills ship with the app. Register the source directory before any
|
||||
// consumer (resolver, IPC handlers, copilot instructions) resolves.
|
||||
registerSkillsDir(resolveSkillsDir());
|
||||
|
||||
setupIpcHandlers();
|
||||
setupBrowserEventForwarding();
|
||||
|
||||
|
|
@ -287,9 +301,6 @@ app.whenReady().then(async () => {
|
|||
// start background agent runner (scheduled agents)
|
||||
initAgentRunner();
|
||||
|
||||
// start skill sync service
|
||||
initSkillSync();
|
||||
|
||||
// start agent notes learning service
|
||||
initAgentNotes();
|
||||
|
||||
|
|
|
|||
|
|
@ -1511,10 +1511,7 @@ export function SettingsDialog({ children, initialTab }: SettingsDialogProps) {
|
|||
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(() => {
|
||||
if (!open) return
|
||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||
|
|
@ -1525,16 +1522,6 @@ export function SettingsDialog({ children, initialTab }: SettingsDialogProps) {
|
|||
})
|
||||
}, [open])
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTab && open) {
|
||||
setActiveTab(initialTab)
|
||||
|
|
@ -1623,14 +1610,7 @@ export function SettingsDialog({ children, initialTab }: SettingsDialogProps) {
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent
|
||||
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]"
|
||||
)}
|
||||
>
|
||||
<DialogContent className="p-0 gap-0 overflow-hidden max-w-[900px]! w-[900px] h-[600px]">
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<div className="w-48 border-r bg-muted/30 p-2 flex flex-col">
|
||||
|
|
@ -1651,11 +1631,6 @@ export function SettingsDialog({ children, initialTab }: SettingsDialogProps) {
|
|||
>
|
||||
<tab.icon className="size-4" />
|
||||
<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>
|
||||
|
|
@ -1688,7 +1663,7 @@ export function SettingsDialog({ children, initialTab }: SettingsDialogProps) {
|
|||
) : activeTab === "appearance" ? (
|
||||
<AppearanceSettings />
|
||||
) : activeTab === "skills" ? (
|
||||
<SkillsSettings dialogOpen={open} onExpandRequest={setSkillsExpanded} />
|
||||
<SkillsSettings dialogOpen={open} />
|
||||
) : activeTab === "tools" ? (
|
||||
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
||||
) : loading ? (
|
||||
|
|
|
|||
|
|
@ -1,110 +1,22 @@
|
|||
"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 { useState, useEffect, useCallback } from "react"
|
||||
import { ArrowLeft } 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"
|
||||
import type { ResolvedSkill } from "@x/shared/dist/skill.js"
|
||||
|
||||
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) {
|
||||
export function SkillsSettings({ dialogOpen }: 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 [skillContent, setSkillContent] = useState("")
|
||||
|
||||
const loadSkills = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -120,23 +32,15 @@ export function SkillsSettings({ dialogOpen, onExpandRequest }: SkillsSettingsPr
|
|||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
loadSkills()
|
||||
}
|
||||
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")
|
||||
setSkillContent(skill.content)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load skill:", err)
|
||||
|
|
@ -144,285 +48,70 @@ export function SkillsSettings({ dialogOpen, onExpandRequest }: SkillsSettingsPr
|
|||
}
|
||||
}, [])
|
||||
|
||||
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)
|
||||
const selectedSkillData = selectedSkill
|
||||
? skills.find((s) => s.id === selectedSkill)
|
||||
: null
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
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" />
|
||||
<div className="flex flex-col h-full overflow-hidden gap-3">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedSkill(null)}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
</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 className="font-medium text-sm truncate">{selectedSkillData.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{selectedSkillData.summary}</div>
|
||||
</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 className="flex-1 overflow-y-auto rounded-md border bg-muted/20 p-3">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap">{skillContent}</pre>
|
||||
</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 className="flex flex-col h-full overflow-hidden">
|
||||
<p className="text-xs text-muted-foreground shrink-0 mb-3">
|
||||
Skills are read-only guidance bundled with the app. Updates ship with new app releases.
|
||||
</p>
|
||||
<div className="flex-1 overflow-y-auto -mx-1 px-1 space-y-1">
|
||||
{skills.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
No skills available.
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{skill.summary}</p>
|
||||
</button>
|
||||
))}
|
||||
) : (
|
||||
skills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
onClick={() => handleSelectSkill(skill.id)}
|
||||
className={cn(
|
||||
"w-full text-left p-3 rounded-md border bg-card hover:bg-accent transition-colors",
|
||||
)}
|
||||
>
|
||||
<div className="font-medium text-sm">{skill.title}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{skill.summary}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
export const skill = String.raw`
|
||||
# Browser Control Skill
|
||||
|
||||
You have access to the **browser-control** tool, which controls Rowboat's embedded browser pane directly.
|
||||
|
||||
Use this skill when the user asks you to open a website, browse in-app, search the web in the browser pane, click something on a page, fill a form, or otherwise interact with a live webpage inside Rowboat.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. Start with ` + "`browser-control({ action: \"open\" })`" + ` if the browser pane may not already be open.
|
||||
2. Use ` + "`browser-control({ action: \"read-page\" })`" + ` to inspect the current page.
|
||||
3. The tool returns:
|
||||
- ` + "`snapshotId`" + `
|
||||
- page ` + "`url`" + ` and ` + "`title`" + `
|
||||
- visible page text
|
||||
- interactable elements with numbered ` + "`index`" + ` values
|
||||
4. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `.
|
||||
5. After each action, read the returned page snapshot before deciding the next step.
|
||||
|
||||
## Actions
|
||||
|
||||
### open
|
||||
Open the browser pane and ensure an active tab exists.
|
||||
|
||||
### get-state
|
||||
Return the current browser tabs and active tab id.
|
||||
|
||||
### new-tab
|
||||
Open a new browser tab.
|
||||
|
||||
Parameters:
|
||||
- ` + "`target`" + ` (optional): URL or plain-language search query
|
||||
|
||||
### switch-tab
|
||||
Switch to a tab by ` + "`tabId`" + `.
|
||||
|
||||
### close-tab
|
||||
Close a tab by ` + "`tabId`" + `.
|
||||
|
||||
### navigate
|
||||
Navigate the active tab.
|
||||
|
||||
Parameters:
|
||||
- ` + "`target`" + `: URL or plain-language search query
|
||||
|
||||
Plain-language targets are converted into a search automatically.
|
||||
|
||||
### back / forward / reload
|
||||
Standard browser navigation controls.
|
||||
|
||||
### read-page
|
||||
Read the current page and return a compact snapshot.
|
||||
|
||||
Parameters:
|
||||
- ` + "`maxElements`" + ` (optional)
|
||||
- ` + "`maxTextLength`" + ` (optional)
|
||||
|
||||
### click
|
||||
Click an element.
|
||||
|
||||
Prefer:
|
||||
- ` + "`index`" + `: element index from ` + "`read-page`" + `
|
||||
|
||||
Optional:
|
||||
- ` + "`snapshotId`" + `: include it when acting on a recent snapshot
|
||||
- ` + "`selector`" + `: fallback only when no usable index exists
|
||||
|
||||
### type
|
||||
Type into an input, textarea, or contenteditable element.
|
||||
|
||||
Parameters:
|
||||
- ` + "`text`" + `: text to enter
|
||||
- plus the same target fields as ` + "`click`" + `
|
||||
|
||||
### press
|
||||
Send a key press such as ` + "`Enter`" + `, ` + "`Tab`" + `, ` + "`Escape`" + `, or arrow keys.
|
||||
|
||||
Parameters:
|
||||
- ` + "`key`" + `
|
||||
- optional target fields if you need to focus a specific element first
|
||||
|
||||
### scroll
|
||||
Scroll the current page.
|
||||
|
||||
Parameters:
|
||||
- ` + "`direction`" + `: ` + "`\"up\"`" + ` or ` + "`\"down\"`" + ` (optional; defaults down)
|
||||
- ` + "`amount`" + `: pixel distance (optional)
|
||||
|
||||
### wait
|
||||
Wait for the page to settle, useful after async UI changes.
|
||||
|
||||
Parameters:
|
||||
- ` + "`ms`" + `: milliseconds to wait (optional)
|
||||
|
||||
## Important Rules
|
||||
|
||||
- Prefer ` + "`read-page`" + ` before interacting.
|
||||
- Prefer element ` + "`index`" + ` over CSS selectors.
|
||||
- If the tool says the snapshot is stale, call ` + "`read-page`" + ` again.
|
||||
- After navigation, clicking, typing, pressing, or scrolling, use the returned page snapshot instead of assuming the page state.
|
||||
- Use Rowboat's browser for live interaction. Use web search tools for research where a live session is unnecessary.
|
||||
- Do not wrap browser URLs or browser pages in ` + "```filepath" + ` blocks. Filepath cards are only for real files on disk, not web pages or browser tabs.
|
||||
- If you mention a page the browser opened, use plain text for the URL/title instead of trying to create a clickable file card.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
export const skill = String.raw`
|
||||
# Composio Integration
|
||||
|
||||
**Load this skill** when the user asks to interact with ANY third-party service — email, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools.
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **composio-list-toolkits** | List all available integrations and their connection status |
|
||||
| **composio-search-tools** | Search for tools by use case; returns slugs and input schemas |
|
||||
| **composio-execute-tool** | Execute a tool by slug with parameters |
|
||||
| **composio-connect-toolkit** | Connect a service via OAuth (opens browser) |
|
||||
|
||||
## Toolkit Slugs (exact values for toolkitSlug parameter)
|
||||
|
||||
| Service | Slug |
|
||||
|---------|------|
|
||||
| Gmail | \`gmail\` |
|
||||
| Google Calendar | \`googlecalendar\` |
|
||||
| Google Sheets | \`googlesheets\` |
|
||||
| Google Docs | \`googledocs\` |
|
||||
| Google Drive | \`googledrive\` |
|
||||
| Slack | \`slack\` |
|
||||
| GitHub | \`github\` |
|
||||
| Notion | \`notion\` |
|
||||
| Linear | \`linear\` |
|
||||
| Jira | \`jira\` |
|
||||
| Asana | \`asana\` |
|
||||
| Trello | \`trello\` |
|
||||
| HubSpot | \`hubspot\` |
|
||||
| Salesforce | \`salesforce\` |
|
||||
| LinkedIn | \`linkedin\` |
|
||||
| X (Twitter) | \`twitter\` |
|
||||
| Reddit | \`reddit\` |
|
||||
| Dropbox | \`dropbox\` |
|
||||
| OneDrive | \`onedrive\` |
|
||||
| Microsoft Outlook | \`microsoft_outlook\` |
|
||||
| Microsoft Teams | \`microsoft_teams\` |
|
||||
| Calendly | \`calendly\` |
|
||||
| Cal.com | \`cal\` |
|
||||
| Intercom | \`intercom\` |
|
||||
| Zendesk | \`zendesk\` |
|
||||
| Airtable | \`airtable\` |
|
||||
|
||||
**IMPORTANT:** Always use these exact slugs. Do NOT guess — e.g., Google Sheets is \`googlesheets\` (no underscore), not \`google_sheets\`.
|
||||
|
||||
## Critical: Check First, Connect Second
|
||||
|
||||
**BEFORE calling composio-connect-toolkit, ALWAYS check if the service is already connected.** The system prompt includes a "Currently connected" list. If the service is there, skip connecting and go straight to search + execute.
|
||||
|
||||
**Flow:**
|
||||
1. Check if the service is in the "Currently connected" list (in the system prompt above)
|
||||
2. If **connected** → go directly to step 4
|
||||
3. If **NOT connected** → call \`composio-connect-toolkit\` once, wait for user to authenticate, then continue
|
||||
4. Call \`composio-search-tools\` with SHORT keyword queries
|
||||
5. Read the \`inputSchema\` from results — note \`required\` fields
|
||||
6. Call \`composio-execute-tool\` with slug, toolkit, and all required arguments
|
||||
|
||||
**NEVER call composio-connect-toolkit for a service that's already connected.** This creates duplicate connect cards in the UI.
|
||||
|
||||
## Search Query Tips
|
||||
|
||||
Use **short keyword queries**, not full sentences:
|
||||
|
||||
| ✅ Good | ❌ Bad |
|
||||
|---------|--------|
|
||||
| "list issues" | "get all open issues for a GitHub repository" |
|
||||
| "send email" | "send an email to someone using Gmail" |
|
||||
| "get profile" | "fetch the authenticated user's profile details" |
|
||||
| "create spreadsheet" | "create a new Google Sheets spreadsheet with data" |
|
||||
|
||||
If the first search returns 0 results, try a different short query (e.g., "issues" instead of "list issues").
|
||||
|
||||
## Passing Arguments
|
||||
|
||||
**ALWAYS include the \`arguments\` field** when calling \`composio-execute-tool\`, even if the tool has no required parameters.
|
||||
|
||||
- Read the \`inputSchema\` from search results carefully
|
||||
- Extract user-provided values into the correct fields (e.g., "rowboatlabs/rowboat" → \`owner: "rowboatlabs", repo: "rowboat"\`)
|
||||
- For tools with empty \`properties: {}\`, pass \`arguments: {}\`
|
||||
- For tools with required fields, pass all of them
|
||||
|
||||
### Example: GitHub Issues
|
||||
|
||||
User says: "Get me the open issues on rowboatlabs/rowboat"
|
||||
|
||||
1. \`composio-search-tools({ query: "list issues", toolkitSlug: "github" })\`
|
||||
→ finds \`GITHUB_ISSUES_LIST_FOR_REPO\` with required: ["owner", "repo"]
|
||||
2. \`composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })\`
|
||||
|
||||
### Example: Gmail Fetch
|
||||
|
||||
User says: "What's my latest email?"
|
||||
|
||||
1. \`composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })\`
|
||||
→ finds \`GMAIL_FETCH_EMAILS\`
|
||||
2. \`composio-execute-tool({ toolSlug: "GMAIL_FETCH_EMAILS", toolkitSlug: "gmail", arguments: { user_id: "me", max_results: 5 } })\`
|
||||
|
||||
### Example: LinkedIn Profile (no-arg tool)
|
||||
|
||||
User says: "Get my LinkedIn profile"
|
||||
|
||||
1. \`composio-search-tools({ query: "get profile", toolkitSlug: "linkedin" })\`
|
||||
→ finds \`LINKEDIN_GET_MY_INFO\` with properties: {}
|
||||
2. \`composio-execute-tool({ toolSlug: "LINKEDIN_GET_MY_INFO", toolkitSlug: "linkedin", arguments: {} })\`
|
||||
|
||||
## Error Recovery
|
||||
|
||||
- **If a tool call fails** (missing fields, 500 error): Fix the arguments and retry IMMEDIATELY. Do NOT stop and narrate the error to the user.
|
||||
- **If search returns 0 results**: Try a different short query. If still 0, the tool may not exist for that service.
|
||||
- **If a tool requires connection**: Call \`composio-connect-toolkit\` once, then retry after connection.
|
||||
|
||||
## Multi-Part Requests
|
||||
|
||||
When the user says "connect X and then do Y" — complete BOTH parts in one turn:
|
||||
1. If X is already connected (check the connected list), skip to Y immediately
|
||||
2. If X needs connecting, connect it, then proceed to Y after authentication
|
||||
|
||||
## Confirmation Rules
|
||||
|
||||
- **Read-only actions** (fetch, list, get, search): Execute without asking
|
||||
- **Mutating actions** (send email, create issue, post, delete): Show the user what you're about to do and confirm before executing
|
||||
- **Connecting a toolkit**: Always safe — just do it when needed
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -1,475 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
||||
|
||||
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackBlockSchema)).trimEnd();
|
||||
|
||||
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
|
||||
|
||||
The track agent can emit *rich blocks* — special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, instruct the agent explicitly so it doesn't fall back to plain markdown:
|
||||
|
||||
- \`table\` — multi-row data, scoreboards, leaderboards. *"Render as a \`table\` block with columns Rank, Title, Points, Comments."*
|
||||
- \`chart\` — time series, breakdowns, share-of-total. *"Render as a \`chart\` block (line, bar, or pie) with x=date, y=rate."*
|
||||
- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render as a \`mermaid\` diagram."*
|
||||
- \`calendar\` — upcoming events / agenda. *"Render as a \`calendar\` block."*
|
||||
- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."*
|
||||
- \`image\` — single image with caption. *"Render as an \`image\` block."*
|
||||
- \`embed\` — YouTube or Figma. *"Render as an \`embed\` block."*
|
||||
- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Render as an \`iframe\` block pointing to <url>."*
|
||||
- \`transcript\` — long meeting transcripts (collapsible). *"Render as a \`transcript\` block."*
|
||||
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
|
||||
|
||||
You **do not** need to write the block body yourself — describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`track\` and \`task\` block types — those are user-authored input, not agent output.
|
||||
|
||||
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
|
||||
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
|
||||
- Bad: "Show today's calendar." (vague — agent may produce a markdown bullet list when the user wants the rich block)`;
|
||||
|
||||
export const skill = String.raw`
|
||||
# Tracks Skill
|
||||
|
||||
You are helping the user create and manage **track blocks** — YAML-fenced, auto-updating content blocks embedded in notes. Load this skill whenever the user wants to track, monitor, watch, or keep an eye on something in a note, asks for recurring/auto-refreshing content ("every morning...", "show current...", "pin live X here"), or presses Cmd+K and requests auto-updating content at the cursor.
|
||||
|
||||
## First: Just Do It — Do Not Ask About Edit Mode
|
||||
|
||||
Track creation and editing are **action-first**. When the user asks to track, monitor, watch, or pin auto-updating content, you proceed directly — read the file, construct the block, ` + "`" + `workspace-edit` + "`" + ` it in. Do not ask "Should I make edits directly, or show you changes first for approval?" — that prompt belongs to generic document editing, not to tracks.
|
||||
|
||||
- If another skill or an earlier turn already asked about edit mode and is waiting, treat the user's track request as implicit "direct mode" and proceed.
|
||||
- You may still ask **one** short clarifying question when genuinely ambiguous (e.g. which note to add it to). Not about permission to edit.
|
||||
- The Suggested Topics flow below is the one first-turn-confirmation exception — leave it intact.
|
||||
|
||||
## What Is a Track Block
|
||||
|
||||
A track block is a scheduled, agent-run block embedded directly inside a markdown note. Each block has:
|
||||
- A YAML-fenced ` + "`" + `track` + "`" + ` block that defines the instruction, schedule, and metadata.
|
||||
- A sibling "target region" — an HTML-comment-fenced area where the generated output lives. The runner rewrites the target region on each scheduled run.
|
||||
|
||||
**Concrete example** (a track that shows the current time in Chicago every hour):
|
||||
|
||||
` + "```" + `track
|
||||
trackId: chicago-time
|
||||
instruction: |
|
||||
Show the current time in Chicago, IL in 12-hour format.
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:chicago-time-->
|
||||
<!--/track-target:chicago-time-->
|
||||
|
||||
Good use cases:
|
||||
- Weather / air quality for a location
|
||||
- News digests or headlines
|
||||
- Stock or crypto prices
|
||||
- Sports scores
|
||||
- Service status pages
|
||||
- Personal dashboards (today's calendar, steps, focus stats)
|
||||
- Any recurring summary that decays fast
|
||||
|
||||
## Anatomy
|
||||
|
||||
Each track has two parts that live next to each other in the note:
|
||||
|
||||
1. The ` + "`" + `track` + "`" + ` code fence — contains the YAML config. The fence language tag is literally ` + "`" + `track` + "`" + `.
|
||||
2. The target-comment region — ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` with optional content between. The ID must match the ` + "`" + `trackId` + "`" + ` in the YAML.
|
||||
|
||||
The target region is **sibling**, not nested. It must **never** live inside the ` + "`" + "```" + `track` + "`" + ` fence.
|
||||
|
||||
## Canonical Schema
|
||||
|
||||
Below is the authoritative schema for a track block (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML:
|
||||
|
||||
` + "```" + `yaml
|
||||
${schemaYaml}
|
||||
` + "```" + `
|
||||
|
||||
**Runtime-managed fields — never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
|
||||
|
||||
## Choosing a trackId
|
||||
|
||||
- Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `.
|
||||
- **Must be unique within the note file.** Before inserting, read the file and check:
|
||||
- All existing ` + "`" + `trackId:` + "`" + ` lines in ` + "`" + "```" + `track` + "`" + ` blocks
|
||||
- All existing ` + "`" + `<!--track-target:...-->` + "`" + ` comments
|
||||
- If you need disambiguation, add scope: ` + "`" + `btc-price-usd` + "`" + `, ` + "`" + `weather-home` + "`" + `, ` + "`" + `news-ai-2` + "`" + `.
|
||||
- Don't reuse an old ID even if the previous block was deleted — pick a fresh one.
|
||||
|
||||
## Writing a Good Instruction
|
||||
|
||||
### The Frame: This Is a Personal Knowledge Tracker
|
||||
|
||||
Track output lives in a personal knowledge base the user scans frequently. Aim for data-forward, scannable output — the answer to "what's current / what changed?" in the fewest words that carry real information. Not prose. Not decoration.
|
||||
|
||||
### Core Rules
|
||||
|
||||
- **Specific and actionable.** State exactly what to fetch or compute.
|
||||
- **Single-focus.** One block = one purpose. Split "weather + news + stocks" into three blocks, don't bundle.
|
||||
- **Imperative voice, 1-3 sentences.**
|
||||
- **Specify output shape.** Describe it concretely: "one line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items".
|
||||
|
||||
### Self-Sufficiency (critical)
|
||||
|
||||
The instruction runs later, in a background scheduler, with **no chat context and no memory of this conversation**. It must stand alone.
|
||||
|
||||
**Never use phrases that depend on prior conversation or prior runs:**
|
||||
- "as before", "same style as before", "like last time"
|
||||
- "keep the format we discussed", "matching the previous output"
|
||||
- "continue from where you left off" (without stating the state)
|
||||
|
||||
If you want consistent style across runs, **describe the style inline** (e.g. "a 3-column markdown table with headers ` + "`" + `Location` + "`" + `, ` + "`" + `Local Time` + "`" + `, ` + "`" + `Offset` + "`" + `"; "a one-line status: HH:MM, conditions, temp"). The track agent only sees your instruction — not this chat, not what you produced last time.
|
||||
|
||||
### Output Patterns — Match the Data
|
||||
|
||||
Pick a shape that fits what the user is tracking. Five common patterns — the first four are plain markdown; the fifth is a rich rendered block:
|
||||
|
||||
**1. Single metric / status line.**
|
||||
- Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: <rate> (as of <HH:MM IST>)` + "`" + `."
|
||||
- Bad: "Give me a nice update about the dollar rate."
|
||||
|
||||
**2. Compact table.**
|
||||
- Good: "Show current local time for India, Chicago, Indianapolis as a 3-column markdown table: ` + "`" + `Location | Local Time | Offset vs India` + "`" + `. One row per location, no prose."
|
||||
- Bad: "Show a polished, table-first world clock with a pleasant layout."
|
||||
|
||||
**3. Rolling digest.**
|
||||
- Good: "Summarize the top 5 HN front-page stories as bullets: ` + "`" + `- <title> (<points> pts, <comments> comments)` + "`" + `. No commentary."
|
||||
- Bad: "Give me the top HN stories with thoughtful takeaways."
|
||||
|
||||
**4. Status / threshold watch.**
|
||||
- Good: "Check https://status.example.com. Return one line: ` + "`" + `✓ All systems operational` + "`" + ` or ` + "`" + `⚠ <component>: <status>` + "`" + `. If degraded, add one bullet per affected component."
|
||||
- Bad: "Keep an eye on the status page and tell me how it looks."
|
||||
|
||||
${richBlockMenu}
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete.
|
||||
- **References to past state** without a mechanism to access it ("as before", "same as last time").
|
||||
- **Bundling multiple purposes** into one instruction — split into separate track blocks.
|
||||
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
|
||||
- **Output-shape words without a concrete shape** ("dashboard-like", "report-style").
|
||||
|
||||
## YAML String Style (critical — read before writing any ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + `)
|
||||
|
||||
The two free-form fields — ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` — are where YAML parsing usually breaks. The runner re-emits the full YAML block every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the block: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated.
|
||||
|
||||
Real failure seen in the wild — an instruction containing the phrase ` + "`" + `"polished UI style as before: clean, compact..."` + "`" + ` was written as a plain scalar, got re-emitted across multiple lines on the next run, and the ` + "`" + `as before:` + "`" + ` became a phantom key. The block parsed as garbage after that.
|
||||
|
||||
### The rule: always use a safe scalar style
|
||||
|
||||
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.** It is the only style that is robust across the full range of punctuation these fields typically contain, and it is safe even if the content later grows to multiple lines.
|
||||
|
||||
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: |
|
||||
Show current local time for India, Chicago, and Indianapolis as a
|
||||
3-column markdown table: Location | Local Time | Offset vs India.
|
||||
One row per location, 24-hour time (HH:MM), no extra prose.
|
||||
Note: when a location is in DST, reflect that in the offset column.
|
||||
eventMatchCriteria: |
|
||||
Emails from the finance team about Q3 budget or OKRs.
|
||||
` + "```" + `
|
||||
|
||||
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs — all literal. No escaping needed.
|
||||
- **Indent every content line by 2 spaces** relative to the key (` + "`" + `instruction:` + "`" + `). Use spaces, never tabs.
|
||||
- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line, not the same line.
|
||||
- Default chomping (no modifier) is fine. Do **not** add ` + "`" + `-` + "`" + ` or ` + "`" + `+` + "`" + ` unless you know you need them.
|
||||
- A ` + "`" + `|` + "`" + ` block is terminated by a line indented less than the content — typically the next sibling key (` + "`" + `active:` + "`" + `, ` + "`" + `schedule:` + "`" + `).
|
||||
|
||||
### Acceptable alternative: double-quoted on a single line
|
||||
|
||||
Fine for short single-sentence fields with no newline needs:
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: "Show the current time in Chicago, IL in 12-hour format."
|
||||
eventMatchCriteria: "Emails about Q3 planning, OKRs, or roadmap decisions."
|
||||
` + "```" + `
|
||||
|
||||
- Escape ` + "`" + `"` + "`" + ` as ` + "`" + `\"` + "`" + ` and backslash as ` + "`" + `\\` + "`" + `.
|
||||
- Prefer ` + "`" + `|` + "`" + ` the moment the string needs two sentences or a newline.
|
||||
|
||||
### Single-quoted on a single line (only if double-quoted would require heavy escaping)
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: 'He said "hi" at 9:00.'
|
||||
` + "```" + `
|
||||
|
||||
- A literal single quote is escaped by doubling it: ` + "`" + `'it''s fine'` + "`" + `.
|
||||
- No other escape sequences work.
|
||||
|
||||
### Do NOT use plain (unquoted) scalars for these two fields
|
||||
|
||||
Even if the current value looks safe, a future edit (by you or the user) may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits — plain scalars are not.
|
||||
|
||||
### Editing an existing track
|
||||
|
||||
If you ` + "`" + `workspace-edit` + "`" + ` an existing track's ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + ` and find it is still a plain scalar, **upgrade it to ` + "`" + `|` + "`" + `** in the same edit. Don't leave a plain scalar behind that the next run will corrupt.
|
||||
|
||||
### Never-hand-write fields
|
||||
|
||||
` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your ` + "`" + `workspace-edit` + "`" + `'s ` + "`" + `oldString` + "`" + ` happens to include these lines, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
|
||||
|
||||
## Schedules
|
||||
|
||||
Schedule is an **optional** discriminated union. Three types:
|
||||
|
||||
### ` + "`" + `cron` + "`" + ` — recurring at exact times
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
Fires at the exact cron time. Use when the user wants precise timing ("at 9am daily", "every hour on the hour").
|
||||
|
||||
### ` + "`" + `window` + "`" + ` — recurring within a time-of-day range
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: window
|
||||
cron: "0 0 * * 1-5"
|
||||
startTime: "09:00"
|
||||
endTime: "17:00"
|
||||
` + "```" + `
|
||||
|
||||
Fires **at most once per cron occurrence**, but only if the current time is within ` + "`" + `startTime` + "`" + `–` + "`" + `endTime` + "`" + ` (24-hour HH:MM, local). Use when the user wants "sometime in the morning" or "once per weekday during work hours" — flexible timing with bounds.
|
||||
|
||||
### ` + "`" + `once` + "`" + ` — one-shot at a future time
|
||||
|
||||
` + "```" + `yaml
|
||||
schedule:
|
||||
type: once
|
||||
runAt: "2026-04-14T09:00:00"
|
||||
` + "```" + `
|
||||
|
||||
Fires once at ` + "`" + `runAt` + "`" + ` and never again. Local time, no ` + "`" + `Z` + "`" + ` suffix.
|
||||
|
||||
### Cron cookbook
|
||||
|
||||
- ` + "`" + `"*/15 * * * *"` + "`" + ` — every 15 minutes
|
||||
- ` + "`" + `"0 * * * *"` + "`" + ` — every hour on the hour
|
||||
- ` + "`" + `"0 8 * * *"` + "`" + ` — daily at 8am
|
||||
- ` + "`" + `"0 9 * * 1-5"` + "`" + ` — weekdays at 9am
|
||||
- ` + "`" + `"0 0 * * 0"` + "`" + ` — Sundays at midnight
|
||||
- ` + "`" + `"0 0 1 * *"` + "`" + ` — first of month at midnight
|
||||
|
||||
**Omit ` + "`" + `schedule` + "`" + ` entirely for a manual-only track** — the user triggers it via the Play button in the UI.
|
||||
|
||||
## Event Triggers (third trigger type)
|
||||
|
||||
In addition to manual and scheduled, a track can be triggered by **events** — incoming signals from the user's data sources (currently: gmail emails). Set ` + "`" + `eventMatchCriteria` + "`" + ` to a description of what kinds of events should consider this track for an update:
|
||||
|
||||
` + "```" + `track
|
||||
trackId: q3-planning-emails
|
||||
instruction: |
|
||||
Maintain a running summary of decisions and open questions about Q3
|
||||
planning, drawn from emails on the topic.
|
||||
active: true
|
||||
eventMatchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
How it works:
|
||||
1. When a new event arrives (e.g. an email syncs), a fast LLM classifier checks ` + "`" + `eventMatchCriteria` + "`" + ` against the event content.
|
||||
2. If it might match, the track-run agent receives both the event payload and the existing track content, and decides whether to actually update.
|
||||
3. If the event isn't truly relevant on closer inspection, the agent skips the update — no fabricated content.
|
||||
|
||||
When to suggest event triggers:
|
||||
- The user wants to **maintain a living summary** of a topic ("keep notes on everything related to project X").
|
||||
- The content depends on **incoming signals** rather than periodic refresh ("update this whenever a relevant email arrives").
|
||||
- Mention to the user: scheduled (cron) is for time-driven updates; event is for signal-driven updates. They can be combined — a track can have both a ` + "`" + `schedule` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` (it'll run on schedule AND on relevant events).
|
||||
|
||||
Writing good ` + "`" + `eventMatchCriteria` + "`" + `:
|
||||
- Be descriptive but not overly narrow — Pass 1 routing is liberal by design.
|
||||
- Examples: ` + "`" + `"Emails from John about the migration project"` + "`" + `, ` + "`" + `"Calendar events related to customer interviews"` + "`" + `, ` + "`" + `"Meeting notes that mention pricing changes"` + "`" + `.
|
||||
|
||||
Tracks **without** ` + "`" + `eventMatchCriteria` + "`" + ` opt out of events entirely — they'll only run on schedule or manually.
|
||||
|
||||
## Insertion Workflow
|
||||
|
||||
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
|
||||
|
||||
### Cmd+K with cursor context
|
||||
|
||||
When the user invokes Cmd+K, the context includes an attachment mention like:
|
||||
> User has attached the following files:
|
||||
> - notes.md (text/markdown) at knowledge/notes.md (line 42)
|
||||
|
||||
Workflow:
|
||||
1. Extract the ` + "`" + `path` + "`" + ` and ` + "`" + `line N` + "`" + ` from the attachment.
|
||||
2. ` + "`" + `workspace-readFile({ path })` + "`" + ` — always re-read fresh.
|
||||
3. Check existing ` + "`" + `trackId` + "`" + `s in the file to guarantee uniqueness.
|
||||
4. Locate the line. Pick a **unique 2-3 line anchor** around line N (a full heading, a distinctive sentence). Avoid blank lines and generic text.
|
||||
5. Construct the full track block (YAML + target pair).
|
||||
6. ` + "`" + `workspace-edit({ path, oldString: <anchor>, newString: <anchor with block spliced at line N> })` + "`" + `.
|
||||
|
||||
### Sidebar chat with a specific note
|
||||
|
||||
1. If a file is mentioned/attached, read it.
|
||||
2. If ambiguous, ask one question: "Which note should I add the track to?"
|
||||
3. **Default placement: append** to the end of the file. Find the last non-empty line as the anchor. ` + "`" + `newString` + "`" + ` = that line + ` + "`" + `\n\n` + "`" + ` + track block + target pair.
|
||||
4. If the user specified a section ("under the Weather heading"), anchor on that heading.
|
||||
|
||||
### No note context at all
|
||||
|
||||
Ask one question: "Which note should this track live in?" Don't create a new note unless the user asks.
|
||||
|
||||
### Suggested Topics exploration flow
|
||||
|
||||
Sometimes the user arrives from the Suggested Topics panel and gives you a prompt like:
|
||||
- "I am exploring a suggested topic card from the Suggested Topics panel."
|
||||
- a title, category, description, and target folder such as ` + "`" + `knowledge/Topics/` + "`" + ` or ` + "`" + `knowledge/People/` + "`" + `
|
||||
|
||||
In that flow:
|
||||
1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation.
|
||||
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
|
||||
3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists.
|
||||
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask "which note should this live in?".
|
||||
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
|
||||
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
|
||||
7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed.
|
||||
|
||||
## The Exact Text to Insert
|
||||
|
||||
Write it verbatim like this (including the blank line between fence and target):
|
||||
|
||||
` + "```" + `track
|
||||
trackId: <id>
|
||||
instruction: |
|
||||
<instruction, indented 2 spaces, may span multiple lines>
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:<id>-->
|
||||
<!--/track-target:<id>-->
|
||||
|
||||
**Rules:**
|
||||
- One blank line between the closing ` + "`" + "```" + `" + " fence and the ` + "`" + `<!--track-target:ID-->` + "`" + `.
|
||||
- Target pair is **empty on creation**. The runner fills it on the first run.
|
||||
- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, indented 2 spaces. Never a plain (unquoted) scalar — see the YAML String Style section above for why.
|
||||
- **Always quote cron expressions** in YAML — they contain spaces and ` + "`" + `*` + "`" + `.
|
||||
- Use 2-space YAML indent. No tabs.
|
||||
- Top-level markdown only — never inside a code fence, blockquote, or table.
|
||||
|
||||
## After Insertion
|
||||
|
||||
- Confirm in one line: "Added ` + "`" + `chicago-time` + "`" + ` track, refreshing hourly."
|
||||
- **Then offer to run it once now** (see "Running a Track" below) — especially valuable for newly created blocks where the target region is otherwise empty until the next scheduled or event-triggered run.
|
||||
- **Do not** write anything into the ` + "`" + `<!--track-target:...-->` + "`" + ` region yourself — use the ` + "`" + `run-track-block` + "`" + ` tool to delegate to the track agent.
|
||||
|
||||
## Running a Track (the ` + "`" + `run-track-block` + "`" + ` tool)
|
||||
|
||||
The ` + "`" + `run-track-block` + "`" + ` tool manually triggers a track run right now. Equivalent to the user clicking the Play button — but you can pass extra ` + "`" + `context` + "`" + ` to bias what the track agent does on this single run (without modifying the block's ` + "`" + `instruction` + "`" + `).
|
||||
|
||||
### When to proactively offer to run
|
||||
|
||||
These are upsells — ask first, don't run silently.
|
||||
|
||||
- **Just created a new track block.** Before declaring done, offer:
|
||||
> "Want me to run it once now to seed the initial content?"
|
||||
|
||||
This is **especially valuable for event-triggered tracks** (with ` + "`" + `eventMatchCriteria` + "`" + `) — otherwise the target region stays empty until the next matching event arrives.
|
||||
|
||||
For tracks that pull from existing local data (synced emails, calendar, meeting notes), suggest a **backfill** with explicit context (see below).
|
||||
|
||||
- **Just edited an existing track.** Offer:
|
||||
> "Want me to run it now to see the updated output?"
|
||||
|
||||
- **Explicit user request.** "run the X track", "test it", "refresh that block" → call the tool directly.
|
||||
|
||||
### Using the ` + "`" + `context` + "`" + ` parameter (the powerful case)
|
||||
|
||||
The ` + "`" + `context` + "`" + ` parameter is extra guidance for the track agent on this run only. It's the difference between a stock refresh and a smart backfill.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- New track: "Track emails about Q3 planning" → after creating it, run with:
|
||||
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days that match this track's topic (Q3 planning, OKRs, roadmap), and synthesize the initial summary."
|
||||
|
||||
- New track: "Summarize this week's customer calls" → run with:
|
||||
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
|
||||
|
||||
- Manual refresh after the user mentions a recent change:
|
||||
> context: "Focus on changes from the last 7 days only."
|
||||
|
||||
- Plain refresh (user says "run it now"): **omit ` + "`" + `context` + "`" + ` entirely**. Don't invent context — it can mislead the agent.
|
||||
|
||||
### What to do with the result
|
||||
|
||||
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
|
||||
|
||||
- **` + "`" + `action: 'replace'` + "`" + `** → the track was updated. Confirm with one line, optionally citing the first line of ` + "`" + `contentAfter` + "`" + `:
|
||||
> "Done — track now shows: 72°F, partly cloudy in Chicago."
|
||||
|
||||
- **` + "`" + `action: 'no_update'` + "`" + `** → the agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` may explain why.
|
||||
|
||||
- **` + "`" + `error` + "`" + ` set** → surface it concisely. If the error is ` + "`" + `'Already running'` + "`" + ` (concurrency guard), let the user know the track is mid-run and to retry shortly.
|
||||
|
||||
### Don'ts
|
||||
|
||||
- **Don't auto-run** after every edit — ask first.
|
||||
- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh — only when there's specific extra guidance to give.
|
||||
- **Don't use ` + "`" + `run-track-block` + "`" + ` to manually write content** — that's ` + "`" + `update-track-content` + "`" + `'s job (and even that should be rare; the track agent handles content via this tool).
|
||||
- **Don't ` + "`" + `run-track-block` + "`" + ` repeatedly** in a single turn — one run per user-facing action.
|
||||
|
||||
## Proactive Suggestions
|
||||
|
||||
When the user signals interest in recurring or time-decaying info, **offer a track block** instead of a one-off answer. Signals:
|
||||
- "I want to track / monitor / watch / keep an eye on / follow X"
|
||||
- "Can you check on X every morning / hourly / weekly?"
|
||||
- The user just asked a one-off question whose answer decays (weather, score, price, status, news).
|
||||
- The user is building a time-sensitive page (weekly dashboard, morning briefing).
|
||||
|
||||
Suggestion style — one line, concrete:
|
||||
> "I can turn this into a track block that refreshes hourly — want that?"
|
||||
|
||||
Don't upsell aggressively. If the user clearly wants a one-off answer, give them one.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- **Don't reuse** an existing ` + "`" + `trackId` + "`" + ` in the same file.
|
||||
- **Don't add ` + "`" + `schedule` + "`" + `** if the user explicitly wants a manual-only track.
|
||||
- **Don't write** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, or ` + "`" + `lastRunSummary` + "`" + ` — runtime-managed.
|
||||
- **Don't nest** the ` + "`" + `<!--track-target:ID-->` + "`" + ` region inside the ` + "`" + "```" + `track` + "`" + ` fence.
|
||||
- **Don't touch** content between ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` — that's generated content.
|
||||
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
|
||||
- **Don't add a ` + "`" + `Z` + "`" + ` suffix** on ` + "`" + `runAt` + "`" + ` — local time only.
|
||||
- **Don't use ` + "`" + `workspace-writeFile` + "`" + `** to rewrite the whole file — always ` + "`" + `workspace-edit` + "`" + ` with a unique anchor.
|
||||
|
||||
## Editing or Removing an Existing Track
|
||||
|
||||
**Change schedule or instruction:** read the file, ` + "`" + `workspace-edit` + "`" + ` the YAML body. Anchor on the unique ` + "`" + `trackId: <id>` + "`" + ` line plus a few surrounding lines.
|
||||
|
||||
**Pause without deleting:** flip ` + "`" + `active: false` + "`" + `.
|
||||
|
||||
**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full ` + "`" + "```" + `track` + "`" + ` block **plus** the target pair (so generated content also disappears), ` + "`" + `newString` + "`" + ` = empty.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Minimal template:
|
||||
|
||||
` + "```" + `track
|
||||
trackId: <kebab-id>
|
||||
instruction: |
|
||||
<what to produce — always use ` + "`" + `|` + "`" + `, indented 2 spaces>
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
expression: "0 * * * *"
|
||||
` + "```" + `
|
||||
|
||||
<!--track-target:<kebab-id>-->
|
||||
<!--/track-target:<kebab-id>-->
|
||||
|
||||
Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m).
|
||||
|
||||
YAML style reminder: ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -87,7 +87,6 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
return {
|
||||
success: true,
|
||||
skillName: resolved.id,
|
||||
source: resolved.source,
|
||||
content: resolved.content,
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ function ensureDirs() {
|
|||
ensure(path.join(WorkDir, "agents"));
|
||||
ensure(path.join(WorkDir, "config"));
|
||||
ensure(path.join(WorkDir, "knowledge"));
|
||||
ensure(path.join(WorkDir, "skills", "overrides"));
|
||||
ensure(path.join(WorkDir, "skills", "official"));
|
||||
}
|
||||
|
||||
function ensureDefaultConfigs() {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ 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 { FSOfficialSkillsRepo, IOfficialSkillsRepo } from "../skills/official-repo.js";
|
||||
import { SkillResolver, ISkillResolver } from "../skills/resolver.js";
|
||||
import type { IBrowserControlService } from "../application/browser-control/service.js";
|
||||
|
|
@ -43,7 +42,6 @@ container.register({
|
|||
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
||||
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
||||
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
|
||||
skillsRepo: asClass<ISkillsRepo>(FSSkillsRepo).singleton(),
|
||||
officialSkillsRepo: asClass<IOfficialSkillsRepo>(FSOfficialSkillsRepo).singleton(),
|
||||
skillResolver: asClass<ISkillResolver>(SkillResolver).singleton(),
|
||||
});
|
||||
|
|
@ -55,3 +53,9 @@ export function registerBrowserControlService(service: IBrowserControlService):
|
|||
browserControlService: asValue(service),
|
||||
});
|
||||
}
|
||||
|
||||
export function registerSkillsDir(skillsDir: string): void {
|
||||
container.register({
|
||||
skillsDir: asValue(skillsDir),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { parseSkillMd } from "./skill-md-parser.js";
|
||||
import type { SkillDefinition } from "./types.js";
|
||||
|
||||
|
|
@ -10,7 +9,11 @@ export interface IOfficialSkillsRepo {
|
|||
}
|
||||
|
||||
export class FSOfficialSkillsRepo implements IOfficialSkillsRepo {
|
||||
private readonly officialDir = path.join(WorkDir, "skills", "official");
|
||||
private readonly officialDir: string;
|
||||
|
||||
constructor({ skillsDir }: { skillsDir: string }) {
|
||||
this.officialDir = skillsDir;
|
||||
}
|
||||
|
||||
async listOfficial(): Promise<SkillDefinition[]> {
|
||||
const result: SkillDefinition[] = [];
|
||||
|
|
|
|||
23
apps/x/packages/core/src/skills/placeholders.ts
Normal file
23
apps/x/packages/core/src/skills/placeholders.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { z } from "zod";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
import { TrackBlockSchema } from "@x/shared/dist/track-block.js";
|
||||
|
||||
// Lazily computed so we don't pay the cost unless a skill actually uses the placeholder.
|
||||
const renderers: Record<string, () => string> = {
|
||||
TRACK_BLOCK_SCHEMA: () => stringifyYaml(z.toJSONSchema(TrackBlockSchema)).trimEnd(),
|
||||
};
|
||||
|
||||
const PLACEHOLDER = /\{\{([A-Z_][A-Z0-9_]*)\}\}/g;
|
||||
|
||||
export function substitutePlaceholders(content: string): string {
|
||||
return content.replace(PLACEHOLDER, (match, key) => {
|
||||
const renderer = renderers[key];
|
||||
if (!renderer) return match;
|
||||
try {
|
||||
return renderer();
|
||||
} catch (err) {
|
||||
console.error(`[skills] placeholder ${key} failed:`, err);
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +1,37 @@
|
|||
import { ResolvedSkill } from "@x/shared/dist/skill.js";
|
||||
import { IOfficialSkillsRepo } from "./official-repo.js";
|
||||
import { ISkillsRepo } from "./repo.js";
|
||||
import { substitutePlaceholders } from "./placeholders.js";
|
||||
|
||||
export interface ISkillResolver {
|
||||
getCatalog(): Promise<ResolvedSkill[]>;
|
||||
resolve(id: string): Promise<ResolvedSkill | null>;
|
||||
getOfficial(id: string): Promise<ResolvedSkill | null>;
|
||||
generateCatalogMarkdown(): Promise<string>;
|
||||
}
|
||||
|
||||
export class SkillResolver implements ISkillResolver {
|
||||
private readonly officialSkillsRepo: IOfficialSkillsRepo;
|
||||
private readonly skillsRepo: ISkillsRepo;
|
||||
|
||||
constructor({ officialSkillsRepo, skillsRepo }: { officialSkillsRepo: IOfficialSkillsRepo; skillsRepo: ISkillsRepo }) {
|
||||
constructor({ officialSkillsRepo }: { officialSkillsRepo: IOfficialSkillsRepo }) {
|
||||
this.officialSkillsRepo = officialSkillsRepo;
|
||||
this.skillsRepo = skillsRepo;
|
||||
}
|
||||
|
||||
async getCatalog(): Promise<ResolvedSkill[]> {
|
||||
const officials = await this.officialSkillsRepo.listOfficial();
|
||||
const overrides = await this.skillsRepo.listOverrides();
|
||||
const overrideMap = new Map(overrides.map((o) => [o.skillId, o]));
|
||||
|
||||
const results: ResolvedSkill[] = [];
|
||||
|
||||
for (const official of officials) {
|
||||
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;
|
||||
return officials.map((official) => ({
|
||||
id: official.id,
|
||||
title: official.title,
|
||||
summary: official.summary,
|
||||
content: substitutePlaceholders(official.content),
|
||||
}));
|
||||
}
|
||||
|
||||
async resolve(id: string): Promise<ResolvedSkill | null> {
|
||||
const official = await this.officialSkillsRepo.getOfficial(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,
|
||||
content: substitutePlaceholders(official.content),
|
||||
};
|
||||
}
|
||||
|
||||
async getOfficial(id: string): Promise<ResolvedSkill | null> {
|
||||
const official = await this.officialSkillsRepo.getOfficial(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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,168 +0,0 @@
|
|||
import https from "node:https";
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
|
||||
const SYNC_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const REPO_OWNER = "rowboatlabs";
|
||||
const REPO_NAME = "skills";
|
||||
const BRANCH = "main";
|
||||
const TARBALL_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/tarball/${BRANCH}`;
|
||||
|
||||
const officialDir = path.join(WorkDir, "skills", "official");
|
||||
const syncStateFile = path.join(WorkDir, "skills", "last-sync.json");
|
||||
|
||||
interface SyncState {
|
||||
timestamp: string;
|
||||
etag: string | null;
|
||||
}
|
||||
|
||||
function log(msg: string) {
|
||||
console.log(`[SkillSync] ${msg}`);
|
||||
}
|
||||
|
||||
async function readSyncState(): Promise<SyncState | null> {
|
||||
try {
|
||||
const raw = await fsp.readFile(syncStateFile, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSyncState(state: SyncState): Promise<void> {
|
||||
await fsp.writeFile(syncStateFile, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and extract the GitHub tarball to the official skills directory.
|
||||
* Returns true if new skills were downloaded, false if 304 (not modified).
|
||||
*/
|
||||
async function syncFromGitHub(): Promise<boolean> {
|
||||
const state = await readSyncState();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": "Rowboat-SkillSync/1.0",
|
||||
Accept: "application/vnd.github+json",
|
||||
};
|
||||
if (state?.etag) {
|
||||
headers["If-None-Match"] = state.etag;
|
||||
}
|
||||
|
||||
const makeRequest = (url: string) => {
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
mod.get(url, { headers }, (res) => {
|
||||
// Handle redirects (GitHub returns 302 for tarball)
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
const location = res.headers.location;
|
||||
if (location) {
|
||||
makeRequest(location);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (res.statusCode === 304) {
|
||||
log("Skills up to date (304 Not Modified)");
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`GitHub API returned ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const newEtag = res.headers.etag ?? null;
|
||||
const tmpDir = path.join(WorkDir, "skills", ".sync-tmp");
|
||||
|
||||
// Clean tmp dir
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
const tarPath = path.join(tmpDir, "download.tar.gz");
|
||||
const writeStream = fs.createWriteStream(tarPath);
|
||||
|
||||
pipeline(res, writeStream)
|
||||
.then(async () => {
|
||||
// Extract tarball
|
||||
const extractDir = path.join(tmpDir, "extracted");
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
execSync(`tar -xzf "${tarPath}" -C "${extractDir}"`, { stdio: "pipe" });
|
||||
|
||||
// GitHub tarballs have a top-level directory like owner-repo-hash/
|
||||
const entries = await fsp.readdir(extractDir);
|
||||
const topDir = entries[0];
|
||||
if (!topDir) {
|
||||
throw new Error("Extracted tarball is empty");
|
||||
}
|
||||
|
||||
const sourceDir = path.join(extractDir, topDir);
|
||||
|
||||
// Atomic swap: rename old -> .old, new -> official, delete .old
|
||||
const oldDir = path.join(WorkDir, "skills", ".official-old");
|
||||
fs.rmSync(oldDir, { recursive: true, force: true });
|
||||
|
||||
const officialExists = fs.existsSync(officialDir);
|
||||
if (officialExists) {
|
||||
await fsp.rename(officialDir, oldDir);
|
||||
}
|
||||
await fsp.rename(sourceDir, officialDir);
|
||||
if (officialExists) {
|
||||
fs.rmSync(oldDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Cleanup tmp
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
// Update sync state
|
||||
await writeSyncState({
|
||||
timestamp: new Date().toISOString(),
|
||||
etag: newEtag,
|
||||
});
|
||||
|
||||
log("Skills synced from GitHub successfully");
|
||||
resolve(true);
|
||||
})
|
||||
.catch(reject);
|
||||
}).on("error", reject);
|
||||
};
|
||||
|
||||
makeRequest(TARBALL_URL);
|
||||
});
|
||||
}
|
||||
|
||||
async function runSync(): Promise<void> {
|
||||
// Ensure official dir exists
|
||||
await fsp.mkdir(officialDir, { recursive: true });
|
||||
|
||||
// Try syncing from GitHub
|
||||
try {
|
||||
await syncFromGitHub();
|
||||
} catch (error) {
|
||||
log(`Sync failed (will use cached skills): ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
log("Starting skill sync service...");
|
||||
|
||||
// Initial sync
|
||||
await runSync();
|
||||
|
||||
// Periodic sync
|
||||
const loop = async () => {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, SYNC_INTERVAL_MS));
|
||||
log("Running periodic sync...");
|
||||
await runSync();
|
||||
}
|
||||
};
|
||||
loop().catch((error) => {
|
||||
log(`Sync loop error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import { AgentScheduleState } from './agent-schedule-state.js';
|
|||
import { ServiceEvent } from './service-events.js';
|
||||
import { TrackEvent } from './track-block.js';
|
||||
import { UserMessageContent } from './message.js';
|
||||
import { ResolvedSkill, SkillOverride } from './skill.js';
|
||||
import { ResolvedSkill } from './skill.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
import { BrowserStateSchema } from './browser-control.js';
|
||||
|
|
@ -735,30 +735,6 @@ const ipcSchemas = {
|
|||
}),
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// SKILL.md frontmatter schema (Agent Skills spec compliant)
|
||||
// Top-level: name, description, license, compatibility, allowed-tools, metadata
|
||||
// Custom Rowboat fields go under metadata
|
||||
// https://agentskills.io/specification
|
||||
export const SkillFrontmatter = z.object({
|
||||
name: z.string().max(64),
|
||||
description: z.string().max(1024),
|
||||
|
|
@ -19,43 +18,12 @@ export const SkillFrontmatter = z.object({
|
|||
|
||||
export type SkillFrontmatter = z.infer<typeof SkillFrontmatter>;
|
||||
|
||||
// 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)
|
||||
// Skill seen by the agent and the renderer (read-only).
|
||||
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