Give bots skills: runbooks they load when a job matches
A skill is a runbook: a name, a unique slug, a one-line description of when it applies, and the markdown body. A run only ever sees the slug and description (one load_skill tool carries them), and reads the whole body only when it calls the tool, so a long runbook costs nothing until needed. Skills are the third extension kind beside MCP servers and custom tools, on the same botExtensionsSql path, so per-bot, per-workspace and all-bots grants work the same way, and attachDefaultExtensions hands an every-bot skill to new bots. load_skill is a reserved tool name, so a custom tool cannot shadow it, and a built-in wins a clash. Managed on Settings > Skills; ExtensionRow links each kind to its own page and a skill never reports a failure, since nothing connects or runs.
This commit is contained in:
@@ -8,7 +8,7 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws unless the user manages this bot's MCP servers and tools: super
|
||||
* Throws unless the user manages this bot's MCP servers, tools and skills: super
|
||||
* admins and admins, the super admin alone for the coordinator. Returns
|
||||
* the bot.
|
||||
*/
|
||||
|
||||
@@ -5,8 +5,8 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Gives a new bot every MCP server and tool set to "every bot". Called
|
||||
* when a bot is created; it can drop any of them afterwards.
|
||||
* Gives a new bot every MCP server, tool and skill set to "every bot".
|
||||
* Called when a bot is created; it can drop any of them afterwards.
|
||||
*/
|
||||
export default async function attachDefaultExtensions({ bot_id }: Params) {
|
||||
const now = Date.now();
|
||||
@@ -22,4 +22,10 @@ export default async function attachDefaultExtensions({ bot_id }: Params) {
|
||||
SELECT ?, ?, ?, id FROM custom_tools WHERE all_bots = 1`,
|
||||
values: [now, now, Number(bot_id)],
|
||||
});
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT OR IGNORE INTO bot_skills (created_at, updated_at, bot_id, skill_id)
|
||||
SELECT ?, ?, ?, id FROM skills WHERE all_bots = 1`,
|
||||
values: [now, now, Number(bot_id)],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import grabSkill from "./grab-skill";
|
||||
|
||||
type Params = {
|
||||
skill_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a skill, and its bot and workspace links with it. The links are
|
||||
* deleted here rather than left to the schema's cascade: foreign keys are
|
||||
* off outside the server process (`enableForeignKeys`), so a delete from a
|
||||
* script would otherwise strand them.
|
||||
*/
|
||||
export default async function deleteSkill({ skill_id }: Params) {
|
||||
const skill = await grabSkill({ skill_id });
|
||||
|
||||
await BunSQLite.sql({ sql: `DELETE FROM bot_skills WHERE skill_id = ?`, values: [skill.id] });
|
||||
await BunSQLite.sql({ sql: `DELETE FROM workspace_skills WHERE skill_id = ?`, values: [skill.id] });
|
||||
|
||||
const res = await BunSQLite.sql({ sql: `DELETE FROM skills WHERE id = ?`, values: [skill.id] });
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't delete the skill: ${res.error}`);
|
||||
|
||||
return skill;
|
||||
}
|
||||
@@ -17,6 +17,13 @@ export const ExtensionTables = {
|
||||
column: "custom_tool_id",
|
||||
label: "tool",
|
||||
},
|
||||
skill: {
|
||||
table: "skills",
|
||||
bot_link: "bot_skills",
|
||||
workspace_link: "workspace_skills",
|
||||
column: "skill_id",
|
||||
label: "skill",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ExtensionKind = keyof typeof ExtensionTables;
|
||||
|
||||
@@ -11,14 +11,14 @@ type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the bot is seen in: its MCP servers and tools show
|
||||
* The workspace the bot is seen in: its MCP servers, tools and skills show
|
||||
* too, not removable from the bot.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot's MCP servers and tools (given to it, and its workspace's there),
|
||||
* A bot's MCP servers, tools and skills (given to it, and its workspace's there),
|
||||
* and the rest of them, which can be added.
|
||||
*/
|
||||
export default async function grabBotExtensionsOverview({
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_CUSTOM_TOOLS,
|
||||
BUN_SQLITE_COBBLE_MCP_SERVERS,
|
||||
BUN_SQLITE_COBBLE_SKILLS,
|
||||
} from "@/db/types/db";
|
||||
import botExtensionsSql from "@/src/utils/bot-extensions-sql";
|
||||
import parseCustomToolRow from "./parse-custom-tool-row";
|
||||
import parseMcpServerRow from "./parse-mcp-server-row";
|
||||
import parseSkillRow from "./parse-skill-row";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
@@ -10,17 +15,19 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The MCP servers and custom tools a bot gets on one run
|
||||
* The MCP servers, custom tools and skills a bot gets on one run
|
||||
* (`botExtensionsSql`): every bot's, its own, and its workspace's there.
|
||||
*/
|
||||
export default async function grabBotExtensions({ bot_id, workspace_id }: Params) {
|
||||
const [servers, tools] = await Promise.all([
|
||||
const [servers, tools, skills] = await Promise.all([
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_MCP_SERVERS>(botExtensionsSql({ kind: "mcp", bot_id, workspace_id })),
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>(botExtensionsSql({ kind: "tool", bot_id, workspace_id })),
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>(botExtensionsSql({ kind: "skill", bot_id, workspace_id })),
|
||||
]);
|
||||
|
||||
return {
|
||||
servers: (servers.payload || []).map((row) => parseMcpServerRow({ row })),
|
||||
tools: (tools.payload || []).map((row) => parseCustomToolRow({ row })),
|
||||
skills: (skills.payload || []).map((row) => parseSkillRow({ row })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_CUSTOM_TOOLS,
|
||||
BUN_SQLITE_COBBLE_MCP_SERVERS,
|
||||
BUN_SQLITE_COBBLE_SKILLS,
|
||||
} from "@/db/types/db";
|
||||
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
|
||||
import { McpTransports } from "@/src/dict/mcp-dict";
|
||||
import type { BotExtension, BotExtensionsPayload } from "@/src/types";
|
||||
import parseCustomToolRow from "./parse-custom-tool-row";
|
||||
import parseMcpServerRow from "./parse-mcp-server-row";
|
||||
import parseSkillRow from "./parse-skill-row";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
@@ -23,7 +28,7 @@ type Params = {
|
||||
type Linked<T> = T & { source: "bot" | "workspace" };
|
||||
|
||||
/**
|
||||
* The MCP servers and tools a bot or a workspace has, and the rest of
|
||||
* The MCP servers, tools and skills a bot or a workspace has, and the rest of
|
||||
* them, which can be added (`grabBotExtensionsOverview`,
|
||||
* `grabWorkspaceExtensionsOverview`).
|
||||
*/
|
||||
@@ -45,8 +50,9 @@ export default async function grabExtensionsOverview({
|
||||
|
||||
const mcp_query = linked("mcp_server_id", "bot_mcp_servers", "workspace_mcp_servers");
|
||||
const tool_query = linked("custom_tool_id", "bot_custom_tools", "workspace_custom_tools");
|
||||
const skill_query = linked("skill_id", "bot_skills", "workspace_skills");
|
||||
|
||||
const [servers, tools, all_servers, all_tools] = await Promise.all([
|
||||
const [servers, tools, skills, all_servers, all_tools, all_skills] = await Promise.all([
|
||||
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_MCP_SERVERS>>({
|
||||
sql: mcp_query.sql.replace("%TABLE%", "mcp_servers"),
|
||||
values: mcp_query.values,
|
||||
@@ -55,12 +61,21 @@ export default async function grabExtensionsOverview({
|
||||
sql: tool_query.sql.replace("%TABLE%", "custom_tools"),
|
||||
values: tool_query.values,
|
||||
}),
|
||||
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_SKILLS>>({
|
||||
sql: skill_query.sql.replace("%TABLE%", "skills"),
|
||||
values: skill_query.values,
|
||||
}),
|
||||
can_manage
|
||||
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM mcp_servers ORDER BY name COLLATE NOCASE` })
|
||||
: null,
|
||||
can_manage
|
||||
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM custom_tools ORDER BY name` })
|
||||
: null,
|
||||
can_manage
|
||||
? BunSQLite.sql<{ id: number; name: string }>({
|
||||
sql: `SELECT id, name FROM skills ORDER BY name COLLATE NOCASE`,
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
const attached: BotExtension[] = [
|
||||
@@ -94,13 +109,27 @@ export default async function grabExtensionsOverview({
|
||||
source: row.source,
|
||||
};
|
||||
}),
|
||||
...(skills.payload || []).map((row) => {
|
||||
const skill = parseSkillRow({ row });
|
||||
|
||||
return {
|
||||
kind: "skill" as const,
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
detail: `Skill · ${skill.slug}`,
|
||||
state: "ok" as const,
|
||||
enabled: skill.enabled,
|
||||
source: row.source,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
/**
|
||||
* A bot can be given one its workspace has; a workspace only what it
|
||||
* hasn't.
|
||||
*/
|
||||
const has = (kind: "mcp" | "tool", id: number) =>
|
||||
const has = (kind: BotExtension["kind"], id: number) =>
|
||||
attached.some((item) => item.kind == kind && item.id == id && (!bot || item.source == "bot"));
|
||||
|
||||
return {
|
||||
@@ -112,6 +141,9 @@ export default async function grabExtensionsOverview({
|
||||
...(all_tools?.payload || [])
|
||||
.filter((row) => !has("tool", Number(row.id)))
|
||||
.map((row) => ({ kind: "tool" as const, id: Number(row.id), name: row.name })),
|
||||
...(all_skills?.payload || [])
|
||||
.filter((row) => !has("skill", Number(row.id)))
|
||||
.map((row) => ({ kind: "skill" as const, id: Number(row.id), name: row.name })),
|
||||
],
|
||||
can_manage,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseSkillRow from "./parse-skill-row";
|
||||
|
||||
type Params = {
|
||||
skill_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One skill, or an `AppError` when there's none.
|
||||
*/
|
||||
export default async function grabSkill({ skill_id }: Params) {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({
|
||||
sql: `SELECT * FROM skills WHERE id = ?`,
|
||||
values: [skill_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row) throw new AppError(`That skill doesn't exist.`);
|
||||
|
||||
return parseSkillRow({ row });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
|
||||
import type { SkillsOverviewPayload } from "@/src/types";
|
||||
import grabCrewRole from "../access/grab-crew-role";
|
||||
import grabExtensionAccess from "./grab-extension-access";
|
||||
import grabExtensionTargets from "./grab-extension-targets";
|
||||
import parseSkillRow from "./parse-skill-row";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Skills settings page: every skill with who gets it, and what it can
|
||||
* be attached to. A skill holds nothing secret, so admins read it too;
|
||||
* only a super admin adds, edits or deletes.
|
||||
*/
|
||||
export default async function grabSkillsOverview({ user_id }: Params): Promise<SkillsOverviewPayload> {
|
||||
const role = await grabCrewRole({ user_id });
|
||||
const [rows, access, targets] = await Promise.all([
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({ sql: `SELECT * FROM skills ORDER BY name COLLATE NOCASE ASC, id ASC` }),
|
||||
grabExtensionAccess({ kind: "skill" }),
|
||||
grabExtensionTargets({ role }),
|
||||
]);
|
||||
|
||||
return {
|
||||
...targets,
|
||||
skills: (rows.payload || []).map((row) => {
|
||||
const { all_bots, ...skill } = parseSkillRow({ row });
|
||||
|
||||
return { ...skill, access: { all_bots, ...access(skill.id) } };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The MCP servers and tools every bot working in a workspace gets there,
|
||||
* The MCP servers, tools and skills every bot working in a workspace gets there,
|
||||
* and the rest of them, which can be added.
|
||||
*/
|
||||
export default async function grabWorkspaceExtensionsOverview({
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
|
||||
import type { SkillRecord } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
row: BUN_SQLITE_COBBLE_SKILLS;
|
||||
};
|
||||
|
||||
/**
|
||||
* A `skills` row, with its flags read as booleans.
|
||||
*/
|
||||
export default function parseSkillRow({ row }: Params): SkillRecord {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: row.name || "",
|
||||
slug: row.slug || "",
|
||||
description: row.description || "",
|
||||
body: row.body || "",
|
||||
all_bots: row.all_bots == 1,
|
||||
enabled: row.enabled != 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import attachToAllBots from "./attach-to-all-bots";
|
||||
import type { BUN_SQLITE_COBBLE_SKILLS, BunSQLiteTables } from "@/db/types/db";
|
||||
import type { SaveSkillRequest } from "@/src/schemas/extension-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabSkill from "./grab-skill";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The super admin saving it (checked by the caller).
|
||||
*/
|
||||
user_id: number | string;
|
||||
data: SaveSkillRequest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits a skill and returns its id. Slugs are unique: a bot loads a
|
||||
* skill by its slug, so two skills can't answer to the same one.
|
||||
*/
|
||||
export default async function saveSkill({ user_id, data }: Params) {
|
||||
const clash = (
|
||||
await BunSQLite.sql<{ id: number }>({
|
||||
sql: `SELECT id FROM skills WHERE slug = ? AND id != ?`,
|
||||
values: [data.slug, data.skill_id || 0],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (clash) throw new AppError(`Another skill already uses the slug ${data.slug}.`);
|
||||
|
||||
const fields = {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
description: data.description,
|
||||
body: data.body,
|
||||
enabled: data.enabled ? 1 : 0,
|
||||
all_bots: data.all_bots ? 1 : 0,
|
||||
} as const;
|
||||
|
||||
if (data.skill_id) {
|
||||
const skill = await grabSkill({ skill_id: data.skill_id });
|
||||
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({
|
||||
table: "skills",
|
||||
targetId: skill.id,
|
||||
data: fields,
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't save the skill: ${res.msg}`);
|
||||
|
||||
if (data.all_bots && !skill.all_bots) await attachToAllBots({ kind: "skill", id: skill.id });
|
||||
|
||||
return skill.id;
|
||||
}
|
||||
|
||||
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({
|
||||
table: "skills",
|
||||
data: [{ ...fields, user_id: Number(user_id) }],
|
||||
});
|
||||
|
||||
const id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||
|
||||
if (!res.success || !id) throw new Error(`Couldn't add the skill: ${res.msg}`);
|
||||
|
||||
if (data.all_bots) await attachToAllBots({ kind: "skill", id: Number(id) });
|
||||
|
||||
return Number(id);
|
||||
}
|
||||
Reference in New Issue
Block a user