Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down Expand Up @@ -67,7 +79,7 @@ export function saveConfig(patch: Partial<AppConfig>): 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] };
}
Expand Down
8 changes: 8 additions & 0 deletions server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
42 changes: 40 additions & 2 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) },
};
}

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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<string, object> = {};
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" });
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions server/skills.test.ts
Original file line number Diff line number Diff line change
@@ -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/));
});
25 changes: 25 additions & 0 deletions server/skills.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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")}` : "";
}
1 change: 1 addition & 0 deletions server/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export interface BotRecord {
rewound?: boolean;
pinned?: boolean;
hidden?: boolean;
skillIds?: string[];
busy?: boolean;
createdAt: number;
}
Expand Down
16 changes: 16 additions & 0 deletions src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function SettingsPanel({ bot }: { bot: Bot }) {
| "autoApprove"
| "speakReplies"
| "voice"
| "skillIds"
>
>,
) => dispatch({ type: "updateBot", botId: bot.id, patch: p });
Expand Down Expand Up @@ -213,6 +214,21 @@ export function SettingsPanel({ bot }: { bot: Bot }) {
</div>
</div>

<div className="rounded-xl bg-card p-4">
<div className="text-[15px] font-medium text-ink">Skills</div>
<div className="mt-0.5 text-[13px] text-ink-secondary">Only enabled skills selected here are added to this bot's context.</div>
<div className="mt-3 flex flex-col gap-2">
{(state.config?.skills?.items ?? []).filter((skill) => skill.enabled).map((skill) => {
const selected = bot.skillIds?.includes(skill.id) ?? false;
return <label key={skill.id} className="flex cursor-pointer items-start gap-2 rounded-lg bg-inset px-3 py-2 text-[13px] text-ink">
<input type="checkbox" checked={selected} onChange={() => patch({ skillIds: selected ? (bot.skillIds ?? []).filter((id) => id !== skill.id) : [...(bot.skillIds ?? []), skill.id] })} className="mt-0.5" />
<span><span className="font-medium">{skill.name}</span>{skill.description && <span className="block text-[11px] text-ink-secondary">{skill.description}</span>}</span>
</label>;
})}
{!(state.config?.skills?.items ?? []).some((skill) => skill.enabled) && <div className="rounded-lg bg-inset px-3 py-2 text-[13px] text-ink-secondary">No enabled skills yet. Add one in App Settings.</div>}
</div>
</div>

<div className="flex items-center justify-between gap-4 rounded-xl bg-card p-4">
<div>
<div className="text-[15px] font-medium text-ink">Auto mode</div>
Expand Down
52 changes: 52 additions & 0 deletions src/components/SkillsManager.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 <div className="mt-4 rounded-xl bg-card p-4">
<div className="text-[15px] font-medium text-ink">Skills library</div>
<div className="mt-0.5 text-[13px] text-ink-secondary">Reusable local instructions. Enable and assign them per bot.</div>
<div className="mt-3 flex flex-col gap-2">
<label className="text-[12px] text-ink-secondary">Skill name<input className={input} value={name} onChange={(e) => setName(e.target.value)} placeholder="Skill name" /></label>
<label className="text-[12px] text-ink-secondary">Description<input className={input} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What this skill helps with" /></label>
<label className="text-[12px] text-ink-secondary">Instructions<textarea className={`${input} min-h-[76px] resize-y`} value={instructions} onChange={(e) => setInstructions(e.target.value)} placeholder="Instructions the selected bot should follow" /></label>
<button disabled={mutating || !name.trim() || !instructions.trim()} onClick={() => void add()} className="flex items-center justify-center gap-1 rounded-lg bg-accent px-3 py-2 text-[13px] font-medium text-white disabled:opacity-50"><Plus size={14} /> Add skill</button>
</div>
{error && <div className="mt-2 text-[12px] text-danger">{error}</div>}
<div className="mt-3 flex flex-col gap-2">{items.length === 0 && <div className="rounded-lg bg-inset px-3 py-2 text-[13px] text-ink-secondary">No skills yet.</div>}{items.map((skill) => <div key={skill.id} className="flex items-start justify-between gap-2 rounded-lg bg-inset px-3 py-2"><div><div className="text-[13px] font-medium text-ink">{skill.name}</div><div className="text-[11px] text-ink-secondary">{skill.description || `${skill.source} · v${skill.version}`}</div></div><div className="flex gap-1"><button disabled={mutating} onClick={() => void toggle(skill.id, skill.enabled)} className="rounded px-2 py-1 text-[11px] text-ink-secondary hover:bg-raised">{skill.enabled ? "On" : "Off"}</button><button disabled={mutating} onClick={() => void remove(skill.id)} className="rounded p-1 text-danger hover:bg-raised" aria-label={`Remove ${skill.name}`}><Trash2 size={14} /></button></div></div>)}</div>
</div>;
}
2 changes: 2 additions & 0 deletions src/state/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export interface Bot {
voice?: string;
pinned?: boolean;
hidden?: boolean;
skillIds?: string[];
messages: Message[];
/** leaf of the visible conversation branch (see visibleMessages) */
activeLeafId?: string | null;
Expand Down Expand Up @@ -156,6 +157,7 @@ export interface ConfigStatus {
tts?: { configured: boolean; ready: boolean; voice: string };
/** who's using the app — collected in onboarding, shown in the sidebar */
profile?: { name: string; email: string };
skills?: { items: Array<{ id: string; name: string; description: string; version: string; source: string; enabled: boolean }> };
}

/** One row of GET /api/instances — the model picker's data. */
Expand Down
Loading