diff --git a/server/config.ts b/server/config.ts index ff9e02340..4ee25406f 100644 --- a/server/config.ts +++ b/server/config.ts @@ -8,6 +8,16 @@ import { join } from "node:path"; import { writeFileAtomic } from "./atomic.ts"; import type { InstanceConfigMap } from "./contracts.ts"; +export interface SkillConfig { + id: string; + name: string; + description?: string; + instructions: string; + version?: string; + source?: "built-in" | "imported" | "custom" | "taught"; + enabled?: boolean; +} + export interface AppConfig { xai?: { key?: string; url?: string }; /** key = ck_… Connect consumer key (connections + agent tools); @@ -21,6 +31,8 @@ export interface AppConfig { /** The person using the app (collected in onboarding, shown in the * sidebar). Not a secret — echoed back by GET /api/config. */ profile?: { name?: string; email?: string }; + /** Local reusable instructions; selected by id per bot at turn time. */ + skills?: { items?: SkillConfig[] }; instances?: InstanceConfigMap; } @@ -67,7 +79,7 @@ export function saveConfig(patch: Partial): void { } catch { /* first write */ } - for (const key of ["xai", "composio", "box", "tts", "profile"] as const) { + for (const key of ["xai", "composio", "box", "tts", "profile", "skills"] as const) { if (patch[key] && typeof patch[key] === "object") { disk[key] = { ...(disk[key] as object), ...patch[key] }; } diff --git a/server/index.test.ts b/server/index.test.ts index d8ffab440..20141087d 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -164,6 +164,14 @@ describe("harness HTTP API", () => { expect(body.instances[0].snapshot.reason).toContain("not-a-real-driver"); }); + it("creates and removes local skill metadata without exposing instructions", async () => { + const created = await api("POST", "/api/skills", { id: "writing", name: "Writing", description: "Clear prose", instructions: "Keep answers concise." }); + expect(created.status).toBe(201); expect(JSON.stringify(created.body)).not.toContain("Keep answers concise"); + const listed = await api("GET", "/api/skills"); expect(listed.body.items).toMatchObject([{ id: "writing", enabled: true }]); + expect((await api("PATCH", "/api/skills/writing", { enabled: false })).body.item.enabled).toBe(false); + expect((await api("DELETE", "/api/skills/writing")).status).toBe(200); + }); + it("creates, patches, and deletes a bot", async () => { const created = await api("POST", "/api/bots"); expect(created.status).toBe(201); diff --git a/server/index.ts b/server/index.ts index 8e0df4c95..763816d39 100644 --- a/server/index.ts +++ b/server/index.ts @@ -22,6 +22,7 @@ import * as tts from "./tts/index.ts"; import { narrateTool, toUtterances } from "./tts/speech-text.ts"; import { readCuaConnection } from "./local-computer.ts"; import { RoutineManager, type RoutineRunOn } from "./routines.ts"; +import { normalizeSkill, skillPrompt, skillSnapshot } from "./skills.ts"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); const STATIC_DIR = process.env.OMB_STATIC_DIR || null; @@ -487,7 +488,7 @@ async function startTurn( bot.description && `About: ${bot.description}`, ] .filter(Boolean) - .join(" "); + .join(" ") + skillPrompt(cfg.skills?.items, bot.skillIds); // busy flips immediately so the composer locks; the dispatch itself runs // in the background — box provisioning can take ~90s and must never @@ -794,6 +795,7 @@ function configStatus() { tts: tts.describeVoice(cfg), // not a secret — the sidebar shows it profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "" }, + skills: { items: (cfg.skills?.items ?? []).map(skillSnapshot) }, }; } @@ -1135,6 +1137,10 @@ const server = createServer(async (req, res) => { } patch.alwaysAllow = [...new Set(body.alwaysAllow as string[])].slice(0, 200); } + if (Array.isArray(body.skillIds)) { + const known = new Set((cfg.skills?.items ?? []).map((skill) => skill.id)); + patch.skillIds = [...new Set(body.skillIds.filter((id: unknown): id is string => typeof id === "string" && known.has(id)))].slice(0, 50); + } const bot = store.patchBot(m[1], patch); if (!bot) return json(res, 404, { error: "no such bot" }); broadcast({ kind: "bot", bot }); @@ -1355,7 +1361,7 @@ const server = createServer(async (req, res) => { if ((method === "PUT" || method === "PATCH") && path === "/api/config") { const body = await readBody(req); const patch: Record = {}; - for (const key of ["xai", "composio", "box", "tts", "profile"] as const) { + for (const key of ["xai", "composio", "box", "tts", "profile", "skills"] as const) { if (body[key] && typeof body[key] === "object") patch[key] = body[key]; } if (!Object.keys(patch).length) return json(res, 400, { error: "nothing to save" }); @@ -1429,6 +1435,38 @@ const server = createServer(async (req, res) => { } } + // ── skills ──────────────────────────────────────────────────────── + if (method === "GET" && path === "/api/skills") return json(res, 200, { items: (cfg.skills?.items ?? []).map(skillSnapshot) }); + if (method === "POST" && path === "/api/skills") { + try { + const item = normalizeSkill(await readBody(req)); + const items = cfg.skills?.items ?? []; + if (items.some((skill) => skill.id === item.id)) return json(res, 409, { error: "skill id already exists" }); + saveConfig({ skills: { items: [...items, item] } }); Object.assign(cfg, loadConfig()); + const status = configStatus(); broadcast({ kind: "config", ...status }); + return json(res, 201, { item: skillSnapshot(item) }); + } catch (error) { return json(res, 400, { error: error instanceof Error ? error.message : "Invalid skill" }); } + } + m = path.match(/^\/api\/skills\/([\w-]+)$/); + if (m && (method === "PATCH" || method === "PUT")) { + const items = cfg.skills?.items ?? []; const current = items.find((skill) => skill.id === m![1]); + if (!current) return json(res, 404, { error: "no such skill" }); + try { + const item = normalizeSkill({ ...current, ...(await readBody(req)) }, current.id); + saveConfig({ skills: { items: items.map((skill) => skill.id === item.id ? item : skill) } }); Object.assign(cfg, loadConfig()); + const status = configStatus(); broadcast({ kind: "config", ...status }); + return json(res, 200, { item: skillSnapshot(item) }); + } catch (error) { return json(res, 400, { error: error instanceof Error ? error.message : "Invalid skill" }); } + } + if (m && method === "DELETE") { + const items = cfg.skills?.items ?? []; + if (!items.some((skill) => skill.id === m![1])) return json(res, 404, { error: "no such skill" }); + saveConfig({ skills: { items: items.filter((skill) => skill.id !== m![1]) } }); Object.assign(cfg, loadConfig()); + for (const bot of store.bots.filter((bot) => bot.skillIds?.includes(m![1]))) store.patchBot(bot.id, { skillIds: bot.skillIds!.filter((id) => id !== m![1]) }); + const status = configStatus(); broadcast({ kind: "config", ...status }); + return json(res, 200, { ok: true }); + } + // ── connectors (Composio) ── if (method === "GET" && path === "/api/connectors/catalog") { const { cards, source } = await composio.listToolkits(cfg); diff --git a/server/skills.test.ts b/server/skills.test.ts new file mode 100644 index 000000000..0979000e0 --- /dev/null +++ b/server/skills.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSkill, skillPrompt, skillSnapshot } from "./skills.ts"; + +describe("selective skills", () => { + it("keeps content out of catalog snapshots", () => { + const skill = normalizeSkill({ id: "write", name: "Writing", instructions: "Secret local guidance", description: "Write clearly" }); + expect(skillSnapshot(skill)).not.toHaveProperty("instructions"); + }); + it("loads only assigned, enabled skills", () => { + const writing = normalizeSkill({ id: "write", name: "Writing", instructions: "Use short sentences" }); + const disabled = normalizeSkill({ id: "disabled", name: "Disabled", instructions: "Never show", enabled: false }); + expect(skillPrompt([writing, disabled], ["write", "disabled", "unknown"])).toContain("Use short sentences"); + expect(skillPrompt([writing, disabled], ["write", "disabled", "unknown"])).not.toContain("Never show"); + expect(skillPrompt([writing], [])).toBe(""); + }); + it("rejects incomplete skills", () => expect(() => normalizeSkill({ name: "Missing instructions" })).toThrow(/needs a name and instructions/)); +}); diff --git a/server/skills.ts b/server/skills.ts new file mode 100644 index 000000000..0a570946f --- /dev/null +++ b/server/skills.ts @@ -0,0 +1,25 @@ +import type { SkillConfig } from "./config.ts"; + +export interface SkillSnapshot { id: string; name: string; description: string; version: string; source: "built-in" | "imported" | "custom" | "taught"; enabled: boolean; } + +export function normalizeSkill(raw: unknown, existingId?: string): SkillConfig { + if (!raw || typeof raw !== "object") throw new Error("Skill configuration is required"); + const input = raw as Record; + const name = typeof input.name === "string" ? input.name.trim().slice(0, 120) : ""; + const instructions = typeof input.instructions === "string" ? input.instructions.trim().slice(0, 20_000) : ""; + if (!name || !instructions) throw new Error("A skill needs a name and instructions"); + const source = typeof input.source === "string" && ["built-in", "imported", "custom", "taught"].includes(input.source) ? input.source as SkillConfig["source"] : "custom"; + const suppliedId = typeof input.id === "string" ? input.id.trim() : ""; + if (!existingId && suppliedId && !/^[\w-]+$/.test(suppliedId)) throw new Error("Skill id must contain only letters, numbers, underscores, or hyphens"); + return { id: existingId ?? (suppliedId || crypto.randomUUID()), name, instructions, description: typeof input.description === "string" ? input.description.trim().slice(0, 500) : "", version: typeof input.version === "string" ? input.version.trim().slice(0, 40) : "1", source, enabled: input.enabled !== false }; +} + +export function skillSnapshot(skill: SkillConfig): SkillSnapshot { return { id: skill.id, name: skill.name, description: skill.description ?? "", version: skill.version ?? "1", source: skill.source ?? "custom", enabled: skill.enabled !== false }; } + +/** Inject only enabled skills explicitly assigned to the current bot. */ +export function skillPrompt(items: SkillConfig[] | undefined, ids: string[] | undefined): string { + if (!ids?.length) return ""; + const available = new Map((items ?? []).filter((skill) => skill.enabled !== false).map((skill) => [skill.id, skill])); + const selected = ids.flatMap((id) => { const skill = available.get(id); return skill ? [`[Skill: ${skill.name}]\n${skill.instructions}`] : []; }); + return selected.length ? `\n\nFollow these assigned reusable skills when relevant:\n\n${selected.join("\n\n")}` : ""; +} diff --git a/server/store.ts b/server/store.ts index abe6ccbc4..cb632a2dc 100644 --- a/server/store.ts +++ b/server/store.ts @@ -155,6 +155,7 @@ export interface BotRecord { rewound?: boolean; pinned?: boolean; hidden?: boolean; + skillIds?: string[]; busy?: boolean; createdAt: number; } diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 37fad8b91..3bce88c20 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -47,6 +47,7 @@ export function SettingsPanel({ bot }: { bot: Bot }) { | "autoApprove" | "speakReplies" | "voice" + | "skillIds" > >, ) => dispatch({ type: "updateBot", botId: bot.id, patch: p }); @@ -213,6 +214,21 @@ export function SettingsPanel({ bot }: { bot: Bot }) { +
+
Skills
+
Only enabled skills selected here are added to this bot's context.
+
+ {(state.config?.skills?.items ?? []).filter((skill) => skill.enabled).map((skill) => { + const selected = bot.skillIds?.includes(skill.id) ?? false; + return ; + })} + {!(state.config?.skills?.items ?? []).some((skill) => skill.enabled) &&
No enabled skills yet. Add one in App Settings.
} +
+
+
Auto mode
diff --git a/src/components/SkillsManager.tsx b/src/components/SkillsManager.tsx new file mode 100644 index 000000000..7029d33f5 --- /dev/null +++ b/src/components/SkillsManager.tsx @@ -0,0 +1,52 @@ +import { Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { api, useStore, type ConfigStatus } from "@/state/store"; + +export function SkillsManager() { + const { state, dispatch } = useStore(); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [instructions, setInstructions] = useState(""); + const [error, setError] = useState(null); + const [mutating, setMutating] = useState(false); + const items = state.config?.skills?.items ?? []; + const refresh = () => api("/api/config").then((config: ConfigStatus) => dispatch({ type: "configStatus", config })); + const refreshAfterWrite = async () => { + try { await refresh(); } + catch { setError("Skill saved, but could not refresh the library."); } + }; + const add = async () => { + setError(null); setMutating(true); + try { + await api("/api/skills", { method: "POST", body: JSON.stringify({ name, description, instructions }) }); + setName(""); setDescription(""); setInstructions(""); + await refreshAfterWrite(); + } catch (reason) { setError(reason instanceof Error ? reason.message : "Could not save skill"); } + finally { setMutating(false); } + }; + const toggle = async (id: string, enabled: boolean) => { + setError(null); setMutating(true); + try { await api(`/api/skills/${id}`, { method: "PATCH", body: JSON.stringify({ enabled: !enabled }) }); await refreshAfterWrite(); } + catch (reason) { setError(reason instanceof Error ? reason.message : "Could not update skill"); } + finally { setMutating(false); } + }; + const remove = async (id: string) => { + setError(null); setMutating(true); + try { await api(`/api/skills/${id}`, { method: "DELETE" }); await refreshAfterWrite(); } + catch (reason) { setError(reason instanceof Error ? reason.message : "Could not remove skill"); } + finally { setMutating(false); } + }; + const input = "w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[13px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none"; + return
+
Skills library
+
Reusable local instructions. Enable and assign them per bot.
+
+ + +