From 8529afa575c1f83230dc202850abc77da49fccd8 Mon Sep 17 00:00:00 2001 From: owner Date: Thu, 13 Aug 2026 18:21:50 +0300 Subject: [PATCH 1/3] feat: add selective reusable skills --- server/config.ts | 14 ++++++++++- server/index.test.ts | 8 +++++++ server/index.ts | 36 ++++++++++++++++++++++++++++- server/skills.test.ts | 17 ++++++++++++++ server/skills.ts | 23 ++++++++++++++++++ server/store.ts | 1 + src/components/AppSettingsPanel.tsx | 3 +++ src/components/SettingsPanel.tsx | 17 +++++++++++++- src/components/SkillsManager.tsx | 15 ++++++++++++ src/state/store.tsx | 2 ++ 10 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 server/skills.test.ts create mode 100644 server/skills.ts create mode 100644 src/components/SkillsManager.tsx diff --git a/server/config.ts b/server/config.ts index 03da1b0f5..6a877b147 100644 --- a/server/config.ts +++ b/server/config.ts @@ -7,6 +7,16 @@ import { join } from "node:path"; 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); @@ -17,6 +27,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; } @@ -62,7 +74,7 @@ export function saveConfig(patch: Partial): void { } catch { /* first write */ } - for (const key of ["xai", "composio", "box", "profile"] as const) { + for (const key of ["xai", "composio", "box", "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 bcc802270..c0b342a86 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -102,6 +102,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 46dd2a0fb..5f1bbbb51 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url"; import * as box from "./box.ts"; import * as composio from "./composio.ts"; import { ensureDirs, instanceConfigs, loadConfig, saveConfig, EVENTS_DIR, NATIVE_DIR } from "./config.ts"; +import { normalizeSkill, skillPrompt, skillSnapshot } from "./skills.ts"; import type { RuntimeEvent } from "./contracts.ts"; import { BUILT_IN_DRIVERS } from "./drivers/builtIn.ts"; @@ -368,7 +369,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 @@ -623,6 +624,7 @@ function configStatus() { box: { configured: Boolean(cfg.box?.token) }, // not a secret — the sidebar shows it profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "" }, + skills: { items: (cfg.skills?.items ?? []).map(skillSnapshot) }, }; } @@ -885,6 +887,7 @@ const server = createServer(async (req, res) => { for (const key of ["name", "title", "description", "notifications", "modelSelection", "unread", "computer", "color", "mascotExpression", "pinned", "hidden"] as const) { if (body[key] !== undefined) patch[key] = body[key]; } + if (Array.isArray(body.skillIds)) patch.skillIds = body.skillIds.filter((id: unknown): id is string => typeof id === "string").slice(0, 50); const bot = store.patchBot(m[1], patch); if (!bot) return json(res, 404, { error: "no such bot" }); broadcast({ kind: "bot", bot }); @@ -1038,6 +1041,37 @@ const server = createServer(async (req, res) => { } // ── connectors (Composio) ── + 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 }); + } + if (method === "GET" && path === "/api/connectors/catalog") { const { cards, source } = await composio.listToolkits(cfg); return json(res, 200, { configured: Boolean(cfg.composio?.key), source, cards }); 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..cc2974297 --- /dev/null +++ b/server/skills.ts @@ -0,0 +1,23 @@ +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 = ["built-in", "imported", "custom", "taught"].includes(String(input.source)) ? input.source as SkillConfig["source"] : "custom"; + return { id: typeof input.id === "string" && input.id ? input.id.slice(0, 100) : existingId ?? 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 014bbc2ed..221102e5e 100644 --- a/server/store.ts +++ b/server/store.ts @@ -102,6 +102,7 @@ export interface BotRecord { rewound?: boolean; pinned?: boolean; hidden?: boolean; + skillIds?: string[]; busy?: boolean; createdAt: number; } diff --git a/src/components/AppSettingsPanel.tsx b/src/components/AppSettingsPanel.tsx index 68c1f50e4..fb6425150 100644 --- a/src/components/AppSettingsPanel.tsx +++ b/src/components/AppSettingsPanel.tsx @@ -5,6 +5,7 @@ import { X } from "lucide-react"; import { useEffect, useState } from "react"; import { useStore } from "@/state/store"; import { ApiKeyRow } from "./ApiKeys"; +import { SkillsManager } from "./SkillsManager"; import { useUpdaterState } from "@/lib/updater"; /** Name + email, persisted to /api/config {profile} on blur. Prefilled from @@ -140,6 +141,8 @@ export function AppSettingsPanel() { + + diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 94582b62c..44da5ccd9 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -32,7 +32,7 @@ export function SettingsPanel({ bot }: { bot: Bot }) { const { state, dispatch } = useStore(); const patch = ( p: Partial< - Pick + Pick >, ) => dispatch({ type: "updateBot", botId: bot.id, patch: p }); const activeState = stateForBot(bot); @@ -148,6 +148,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.
} +
+
+
Model
diff --git a/src/components/SkillsManager.tsx b/src/components/SkillsManager.tsx new file mode 100644 index 000000000..fb6332256 --- /dev/null +++ b/src/components/SkillsManager.tsx @@ -0,0 +1,15 @@ +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 items = state.config?.skills?.items ?? []; + const refresh = () => api("/api/config").then((config: ConfigStatus) => dispatch({ type: "configStatus", config })); + const add = async () => { try { setError(null); await api("/api/skills", { method: "POST", body: JSON.stringify({ name, description, instructions }) }); setName(""); setDescription(""); setInstructions(""); await refresh(); } catch (reason) { setError(reason instanceof Error ? reason.message : "Could not save skill"); } }; + const toggle = async (id: string, enabled: boolean) => { try { await api(`/api/skills/${id}`, { method: "PATCH", body: JSON.stringify({ enabled: !enabled }) }); await refresh(); } catch (reason) { setError(reason instanceof Error ? reason.message : "Could not update skill"); } }; + const remove = async (id: string) => { try { await api(`/api/skills/${id}`, { method: "DELETE" }); await refresh(); } catch (reason) { setError(reason instanceof Error ? reason.message : "Could not remove skill"); } }; + 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.
setName(e.target.value)} placeholder="Skill name" /> setDescription(e.target.value)} placeholder="What this skill helps with" />