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:
@@ -18,6 +18,7 @@ import runTextLoop from "@/src/harness/text/run-text-loop";
|
||||
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
|
||||
import grabBotFileTools from "@/src/harness/tools/storage/grab-bot-file-tools";
|
||||
import openBotExtensions from "@/src/harness/tools/extensions/open-bot-extensions";
|
||||
import openBotSkills from "@/src/harness/tools/skills/open-bot-skills";
|
||||
import botMessagesToHarness from "./bot-messages-to-harness";
|
||||
import composeComputersNote from "./compose-computers-note";
|
||||
import grabSessionCarryOver from "./grab-session-carry-over";
|
||||
@@ -112,6 +113,14 @@ export default async function runBotReply({
|
||||
signal,
|
||||
});
|
||||
|
||||
/**
|
||||
* The run's skills: one `load_skill` tool and the lines that name them.
|
||||
*/
|
||||
const skills = openBotSkills({
|
||||
skills: extensions.skills,
|
||||
taken: [...tools, ...extensions.tools].map((tool) => tool.name),
|
||||
});
|
||||
|
||||
/**
|
||||
* delegate_task is only there while the coordinator has workers.
|
||||
*/
|
||||
@@ -140,6 +149,7 @@ export default async function runBotReply({
|
||||
in_workspace: Boolean(workspace_id),
|
||||
}),
|
||||
crew_note,
|
||||
skills.note,
|
||||
extensions.note,
|
||||
`Now: ${new Date().toUTCString()}.`,
|
||||
]
|
||||
@@ -153,7 +163,7 @@ export default async function runBotReply({
|
||||
user_id,
|
||||
}),
|
||||
}),
|
||||
tools: [...tools, ...file_tools, ...extensions.tools],
|
||||
tools: [...tools, ...file_tools, ...extensions.tools, ...skills.tools],
|
||||
max_steps: AppData.BotReplyMaxToolSteps,
|
||||
bot_id: bot.id,
|
||||
workspace_id,
|
||||
|
||||
@@ -14,6 +14,7 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-
|
||||
import runTextLoop from "@/src/harness/text/run-text-loop";
|
||||
import openComputerTools from "@/src/harness/tools/computer/open-computer-tools";
|
||||
import openBotExtensions, { type OpenBotExtensions } from "@/src/harness/tools/extensions/open-bot-extensions";
|
||||
import openBotSkills, { type OpenBotSkills } from "@/src/harness/tools/skills/open-bot-skills";
|
||||
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
|
||||
import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool";
|
||||
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
|
||||
@@ -71,6 +72,7 @@ export default async function runBotTask({
|
||||
: null;
|
||||
|
||||
let extensions: OpenBotExtensions | undefined;
|
||||
let skills: OpenBotSkills | undefined;
|
||||
|
||||
try {
|
||||
const session_id = await jobSessionId({ job });
|
||||
@@ -173,6 +175,11 @@ export default async function runBotTask({
|
||||
signal,
|
||||
});
|
||||
|
||||
skills = openBotSkills({
|
||||
skills: extensions.skills,
|
||||
taken: [...tools, ...extensions.tools].map((tool) => tool.name),
|
||||
});
|
||||
|
||||
const result = await runTextLoop({
|
||||
system: [
|
||||
prompt,
|
||||
@@ -200,6 +207,7 @@ export default async function runBotTask({
|
||||
.map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`)
|
||||
.join("\n")}`
|
||||
: "",
|
||||
skills.note,
|
||||
extensions.note,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -214,7 +222,7 @@ export default async function runBotTask({
|
||||
}),
|
||||
},
|
||||
],
|
||||
tools: [...tools, ...extensions.tools],
|
||||
tools: [...tools, ...extensions.tools, ...skills.tools],
|
||||
bot_id: bot.id,
|
||||
workspace_id,
|
||||
signal,
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import type { BotExtensionsPayload } from "@/src/types";
|
||||
import { extensionOptionValue, parseExtensionOptionValue } from "@/src/utils/extension-option-value";
|
||||
import extensionNoun from "@/src/utils/extension-noun";
|
||||
import InlineAddSelect from "./inline-add-select";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
addable: BotExtensionsPayload["addable"];
|
||||
loading?: boolean;
|
||||
onAdd: (params: { kind: "mcp" | "tool"; id: number }) => void;
|
||||
onAdd: (params: { kind: "mcp" | "tool" | "skill"; id: number }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks an MCP server or tool to give a bot.
|
||||
* Picks an MCP server, tool or skill to give a bot.
|
||||
*/
|
||||
export default function ExtensionAddSelect({ id, addable, loading, onAdd }: Props) {
|
||||
return (
|
||||
<InlineAddSelect
|
||||
id={id}
|
||||
noun="tool or MCP server"
|
||||
noun="tool, MCP server or skill"
|
||||
options={addable.map((item) => ({
|
||||
value: extensionOptionValue(item),
|
||||
title: `${item.kind == "mcp" ? "MCP" : "Tool"} · ${item.name}`,
|
||||
title: `${extensionNoun({ kind: item.kind })}: ${item.name}`,
|
||||
}))}
|
||||
loading={loading}
|
||||
onAdd={({ value }) => {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Plug, Wrench } from "lucide-react";
|
||||
import { BookOpen, Plug, Wrench } from "lucide-react";
|
||||
import type { BotExtension } from "@/src/types";
|
||||
|
||||
/**
|
||||
* The icon of an MCP server (a plug) or a tool (a wrench).
|
||||
* The icon of an MCP server (a plug), a tool (a wrench) or a skill (a book).
|
||||
*/
|
||||
export default function extensionIcon({ kind }: { kind: BotExtension["kind"] }) {
|
||||
return kind == "mcp" ? Plug : Wrench;
|
||||
if (kind == "mcp") return Plug;
|
||||
if (kind == "skill") return BookOpen;
|
||||
|
||||
return Wrench;
|
||||
}
|
||||
|
||||
@@ -283,6 +283,14 @@ export const AppData = {
|
||||
*/
|
||||
CustomToolDefaultTimeoutMs: 60 * 1000,
|
||||
CustomToolMaxTimeoutMs: 10 * 60 * 1000,
|
||||
/**
|
||||
* Skills (Settings, under Skills): how long one's body may be, and how
|
||||
* much of it `load_skill` hands back in one call. A runbook past that
|
||||
* is worth splitting into two skills.
|
||||
*/
|
||||
SkillBodyMaxChars: 60000,
|
||||
SkillBodyShownChars: 30000,
|
||||
SkillDescriptionMaxChars: 300,
|
||||
/**
|
||||
* `search_computers`: how long the command may run on each computer,
|
||||
* and how much of each computer's output the bot reads.
|
||||
|
||||
@@ -85,6 +85,18 @@ ${
|
||||
: `- Work that ends in a file on the computer is a task: start one, and it saves what it made into your files with import_file, so it is kept and searchable.\n`
|
||||
}- Never say a file is saved if it only exists on the computer. Say where it is, and that it can be imported.`;
|
||||
|
||||
/**
|
||||
* Added to a run that has skills: what they are, and that the body is read
|
||||
* only with load_skill.
|
||||
*/
|
||||
export const SkillsNote = `# Skills
|
||||
|
||||
You have runbooks (skills): load_skill lists them, with the one line each says about when it applies.
|
||||
|
||||
- A skill's steps are the way that job is done here. Before you start work one covers, call load_skill with its slug and follow it: don't work from memory or guess what it says.
|
||||
- Load one only when it applies to what you're doing; loading everything fills your context with instructions you don't need.
|
||||
- If a skill turns out to be wrong or out of date for the job, follow it as written and tell the user in your reply, so they can fix it in Settings, under Skills.`;
|
||||
|
||||
/**
|
||||
* Added to every run, chat replies and tasks alike: check before you
|
||||
* create, and check after you act.
|
||||
|
||||
@@ -86,6 +86,7 @@ export const JobStepToolLabels: Record<string, string> = {
|
||||
assign_computer: "Changing computer access",
|
||||
update_memory: "Updating memory",
|
||||
update_instructions: "Updating instructions",
|
||||
load_skill: "Reading a skill",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,6 +66,7 @@ export const ReservedToolNames = [
|
||||
"delegate_task",
|
||||
"delete_schedule",
|
||||
"list_schedules",
|
||||
"load_skill",
|
||||
"report_run_failed",
|
||||
"search_computers",
|
||||
"set_bot_workspace_instructions",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Bell,
|
||||
BookOpen,
|
||||
Bot,
|
||||
CpuAi,
|
||||
DatabaseBackup,
|
||||
@@ -61,6 +62,7 @@ export const SettingsPages: SettingsPage[] = [
|
||||
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor, can_view: canManageCrew },
|
||||
{ title: "MCPs", url: "/admin/settings/mcps", Icon: Plug, can_view: canManageCrew },
|
||||
{ title: "Tools", url: "/admin/settings/tools", Icon: Wrench, can_view: canManageCrew },
|
||||
{ title: "Skills", url: "/admin/settings/skills", Icon: BookOpen, can_view: canManageCrew },
|
||||
{
|
||||
title: "Users",
|
||||
url: "/admin/settings/users",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* What a skill is: a runbook a bot follows. It reads the name and
|
||||
* description in its prompt and pulls the body with `load_skill` only when
|
||||
* the skill applies, so a long runbook costs nothing until it's needed.
|
||||
*/
|
||||
export const SkillNoun = "skill";
|
||||
|
||||
/**
|
||||
* The tool a bot calls to read a skill's body.
|
||||
*/
|
||||
export const LoadSkillToolName = "load_skill";
|
||||
|
||||
/**
|
||||
* The slug format, for the settings form and its hint.
|
||||
*/
|
||||
export const SkillSlugPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import grabBotExtensions from "@/src/functions/backend/db/extensions/grab-bot-extensions";
|
||||
import grabRunEnvs from "@/src/functions/backend/db/extensions/grab-run-envs";
|
||||
import type { SkillRecord } from "@/src/types";
|
||||
import mcpToolName from "@/src/utils/mcp-tool-name";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import mcpHarnessTool from "../../mcp/mcp-harness-tool";
|
||||
@@ -30,6 +31,11 @@ export type OpenBotExtensions = {
|
||||
* with no tools listed), so the bot can say so. Empty otherwise.
|
||||
*/
|
||||
note: string;
|
||||
/**
|
||||
* The skills the run has, for `openBotSkills`: read from the same query
|
||||
* as the MCP servers and tools.
|
||||
*/
|
||||
skills: SkillRecord[];
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -47,9 +53,11 @@ export default async function openBotExtensions({
|
||||
taken = [],
|
||||
signal,
|
||||
}: Params): Promise<OpenBotExtensions> {
|
||||
const { servers, tools } = await grabBotExtensions({ bot_id, workspace_id });
|
||||
const { servers, tools, skills } = await grabBotExtensions({ bot_id, workspace_id });
|
||||
|
||||
if (!servers.length && !tools.length) return { tools: [], note: "", close: async () => {} };
|
||||
if (!servers.length && !tools.length) {
|
||||
return { tools: [], note: "", skills, close: async () => {} };
|
||||
}
|
||||
|
||||
const envs = await grabRunEnvs({ bot_id, workspace_id });
|
||||
const secrets = envs.filter((env) => env.secret);
|
||||
@@ -98,6 +106,7 @@ export default async function openBotExtensions({
|
||||
|
||||
return {
|
||||
tools: result,
|
||||
skills,
|
||||
note: notes.length
|
||||
? `These MCP servers were set up for you but you can't use them right now; if the user needs them, say so (an admin fixes them in Settings, under MCPs):\n${notes.join("\n")}`
|
||||
: "",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { SkillRecord } from "@/src/types";
|
||||
import openBotSkills from "./open-bot-skills";
|
||||
|
||||
function skill(overrides: Partial<SkillRecord> & { slug: string }): SkillRecord {
|
||||
return {
|
||||
id: 1,
|
||||
name: overrides.slug,
|
||||
description: `When ${overrides.slug} applies.`,
|
||||
body: `${overrides.slug} body`,
|
||||
all_bots: false,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("openBotSkills", () => {
|
||||
test("no skills means no tool and no note", () => {
|
||||
expect(openBotSkills({ skills: [] })).toEqual({ tools: [], note: "" });
|
||||
});
|
||||
|
||||
test("a disabled skill is left out entirely", () => {
|
||||
const opened = openBotSkills({ skills: [skill({ slug: "seo", enabled: false })] });
|
||||
|
||||
expect(opened.tools).toHaveLength(0);
|
||||
expect(opened.note).toBe("");
|
||||
});
|
||||
|
||||
test("one tool, named load_skill, whose description lists the slugs", () => {
|
||||
const opened = openBotSkills({ skills: [skill({ slug: "seo" }), skill({ slug: "inbox", id: 2 })] });
|
||||
|
||||
expect(opened.tools).toHaveLength(1);
|
||||
expect(opened.tools[0]!.name).toBe("load_skill");
|
||||
expect(opened.tools[0]!.description).toContain("- seo:");
|
||||
expect(opened.tools[0]!.description).toContain("- inbox:");
|
||||
expect(opened.note).toContain("# Skills");
|
||||
});
|
||||
|
||||
test("a built-in that took the name wins", () => {
|
||||
expect(openBotSkills({ skills: [skill({ slug: "seo" })], taken: ["load_skill"] })).toEqual({
|
||||
tools: [],
|
||||
note: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("the tool returns the body of the slug it's given", async () => {
|
||||
const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "1. Pull rankings" })] });
|
||||
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
|
||||
|
||||
expect(output).toBe("1. Pull rankings");
|
||||
});
|
||||
|
||||
test("an unknown slug fails with the slugs it does have", async () => {
|
||||
const opened = openBotSkills({ skills: [skill({ slug: "seo" })] });
|
||||
|
||||
await expect(opened.tools[0]!.run!({ args: { slug: "nope" }, signal: AbortSignal.timeout(1000) })).rejects.toThrow(
|
||||
/no skill called "nope".*seo/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a long body is cut at the limit and says so", async () => {
|
||||
const opened = openBotSkills({
|
||||
skills: [skill({ slug: "seo", body: "x".repeat(AppData.SkillBodyShownChars + 500) })],
|
||||
});
|
||||
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
|
||||
|
||||
expect(output).toContain("Cut at");
|
||||
expect(output.length).toBeLessThan(AppData.SkillBodyShownChars + 200);
|
||||
});
|
||||
|
||||
test("an empty body says it is empty rather than returning nothing", async () => {
|
||||
const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "" })] });
|
||||
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
|
||||
|
||||
expect(output).toContain("is empty");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { SkillsNote } from "@/src/data/harness-prompts";
|
||||
import { LoadSkillToolName } from "@/src/dict/skill-dict";
|
||||
import type { SkillRecord } from "@/src/types";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The skills the run reaches (`grabBotExtensions`).
|
||||
*/
|
||||
skills: SkillRecord[];
|
||||
/**
|
||||
* Names the run's built-in tools already use: they win.
|
||||
*/
|
||||
taken?: string[];
|
||||
};
|
||||
|
||||
export type OpenBotSkills = {
|
||||
tools: HarnessTool[];
|
||||
/**
|
||||
* For the system prompt: one line per skill, and that the body is read
|
||||
* with `load_skill`. Empty when the run has none.
|
||||
*/
|
||||
note: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The skills a run has, as one `load_skill` tool plus the lines that say
|
||||
* which ones exist. A skill's body costs nothing until the bot asks for it,
|
||||
* so a run that never needs a long runbook never pays for it.
|
||||
*/
|
||||
export default function openBotSkills({ skills, taken = [] }: Params): OpenBotSkills {
|
||||
const enabled = skills.filter((skill) => skill.enabled);
|
||||
|
||||
if (!enabled.length || taken.includes(LoadSkillToolName)) return { tools: [], note: "" };
|
||||
|
||||
const by_slug = new Map(enabled.map((skill) => [skill.slug, skill]));
|
||||
const slugs = enabled.map((skill) => skill.slug);
|
||||
|
||||
const list = enabled.map((skill) => `- ${skill.slug}: ${skill.description}`).join("\n");
|
||||
|
||||
const tool: HarnessTool<{ slug: string }> = {
|
||||
name: LoadSkillToolName,
|
||||
description: `Reads one of your skills in full. Pass its slug. The skills you have, and when each applies:\n${list}`,
|
||||
schema: z.object({ slug: z.string().trim().min(1, "Pass the skill's slug") }),
|
||||
run: async ({ args }) => {
|
||||
const skill = by_slug.get(args.slug);
|
||||
|
||||
if (!skill) {
|
||||
throw new Error(
|
||||
`You have no skill called "${args.slug}". Load one of: ${slugs.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = skill.body || "";
|
||||
|
||||
if (!body) return `The skill ${skill.name} (${skill.slug}) is empty.`;
|
||||
|
||||
if (body.length <= AppData.SkillBodyShownChars) return body;
|
||||
|
||||
return `${body.slice(0, AppData.SkillBodyShownChars)}\n\n[Cut at ${AppData.SkillBodyShownChars} characters. The skill continues: ask the user to split it if you need the rest.]`;
|
||||
},
|
||||
};
|
||||
|
||||
return { tools: [tool], note: SkillsNote };
|
||||
}
|
||||
@@ -18,7 +18,7 @@ type Params = {
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* A bot's or workspace's MCP servers and tools, with adding and removing
|
||||
* A bot's or workspace's MCP servers, tools and skills, with adding and removing
|
||||
* them (each asks first). Shared by the bot and workspace settings pages
|
||||
* and the chat's composer.
|
||||
*/
|
||||
|
||||
@@ -17,8 +17,9 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One MCP server or tool the bot can use here, in the composer's tools
|
||||
* modal. One that isn't working says so, one that's off is struck out.
|
||||
* One MCP server, tool or skill the bot can use here, in the composer's
|
||||
* tools modal. One that isn't working says so, one that's off is struck
|
||||
* out. A skill has nothing to connect or run, so it never reports one.
|
||||
*/
|
||||
export default function ComposerToolRow({ item, busy, onRemove }: Props) {
|
||||
const Icon = extensionIcon({ kind: item.kind });
|
||||
@@ -27,11 +28,13 @@ export default function ComposerToolRow({ item, busy, onRemove }: Props) {
|
||||
item.source == "workspace" ? "From this workspace" : "",
|
||||
!item.enabled
|
||||
? "Off for every bot"
|
||||
: item.state == "error"
|
||||
? "Not working"
|
||||
: item.state == "untested"
|
||||
? "Not checked yet"
|
||||
: "",
|
||||
: item.kind == "skill"
|
||||
? ""
|
||||
: item.state == "error"
|
||||
? "Not working"
|
||||
: item.state == "untested"
|
||||
? "Not checked yet"
|
||||
: "",
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,19 +18,19 @@ type Props = {
|
||||
* The extension being changed, as `kind:id`.
|
||||
*/
|
||||
busy?: string;
|
||||
onAdd: (params: { kind: "mcp" | "tool"; id: number }) => Promise<boolean>;
|
||||
onAdd: (params: { kind: "mcp" | "tool" | "skill"; id: number }) => Promise<boolean>;
|
||||
onRemove: (params: {
|
||||
kind: "mcp" | "tool";
|
||||
kind: "mcp" | "tool" | "skill";
|
||||
id: number;
|
||||
name: string;
|
||||
}) => Promise<boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's tools button: how many MCP servers and tools the bot can
|
||||
* use here, opening them in a modal rather than filling the composer with
|
||||
* chips. Super admins and admins remove any it was given (even one every
|
||||
* bot started with) and add more; a workspace's own stay.
|
||||
* The composer's tools button: how many MCP servers, tools and skills the
|
||||
* bot can use here, opening them in a modal rather than filling the
|
||||
* composer with chips. Super admins and admins remove any it was given
|
||||
* (even one every bot started with) and add more; a workspace's own stay.
|
||||
*/
|
||||
export default function ComposerToolsModal({
|
||||
bot_name,
|
||||
@@ -75,8 +75,8 @@ export default function ComposerToolsModal({
|
||||
</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{count
|
||||
? "MCP servers and tools it was given, plus this workspace's. Remove one to take it away, or add another below."
|
||||
: "It has none yet. Give it an MCP server or tool below, or make one under Settings."}
|
||||
? "MCP servers, tools and skills it was given, plus this workspace's. Remove one to take it away, or add another below."
|
||||
: "It has none yet. Give it an MCP server, tool or skill below, or make one under Settings."}
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Under the composer: one button counting the MCP servers and tools the
|
||||
* Under the composer: one button counting the MCP servers, tools and skills the
|
||||
* bot can use here (they open in a modal, so ten of them don't fill the
|
||||
* composer), then the computer it's on, opening it in the viewer.
|
||||
*/
|
||||
|
||||
@@ -51,7 +51,7 @@ type Props = {
|
||||
*/
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* Its MCP servers and tools, listed under the input.
|
||||
* Its MCP servers, tools and skills, listed under the input.
|
||||
*/
|
||||
extensions?: BotExtensionsPayload | null;
|
||||
/**
|
||||
|
||||
@@ -18,13 +18,13 @@ type Props<T> = {
|
||||
/**
|
||||
* Under `/api/admin/`.
|
||||
*/
|
||||
path: "mcps/set-mcp-access" | "tools/set-tool-access";
|
||||
path: "mcps/set-mcp-access" | "tools/set-tool-access" | "skills/set-skill-access";
|
||||
onSaved: (overview: T, msg: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Who gets an MCP server or tool: the bots and workspaces picked here.
|
||||
* Who gets an MCP server, tool or skill: the bots and workspaces picked here.
|
||||
* "Every bot" (super admins only) gives it to every bot now and every new
|
||||
* one; each can still drop it. A workspace gives it to every bot in
|
||||
* it, there.
|
||||
|
||||
@@ -11,22 +11,35 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One MCP server or tool a bot or workspace has, and whether it's working.
|
||||
* The settings page each kind is managed on.
|
||||
*/
|
||||
const ExtensionHrefs = {
|
||||
mcp: "/admin/settings/mcps",
|
||||
tool: "/admin/settings/tools",
|
||||
skill: "/admin/settings/skills",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One MCP server, tool or skill a bot or workspace has, and whether it's
|
||||
* working. A skill has nothing to connect or run, so it never reports a
|
||||
* failure.
|
||||
*/
|
||||
export default function ExtensionRow({ item, busy, onRemove }: Props) {
|
||||
const state = !item.enabled
|
||||
? "Off for every bot"
|
||||
: item.state == "error"
|
||||
? "Not working"
|
||||
: item.state == "untested"
|
||||
? "Not checked yet"
|
||||
: "";
|
||||
: item.kind == "skill"
|
||||
? ""
|
||||
: item.state == "error"
|
||||
? "Not working"
|
||||
: item.state == "untested"
|
||||
? "Not checked yet"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={<IconTile Icon={extensionIcon({ kind: item.kind })} />}
|
||||
title={item.name}
|
||||
href={item.kind == "mcp" ? "/admin/settings/mcps" : "/admin/settings/tools"}
|
||||
href={ExtensionHrefs[item.kind]}
|
||||
subtitle={[item.detail, state].filter(Boolean).join(" · ")}
|
||||
>
|
||||
{onRemove ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { Plug, Wrench } from "lucide-react";
|
||||
import { BookOpen, Plug, Wrench } from "lucide-react";
|
||||
import ExtensionAddSelect from "@/src/components/general/extension-add-select";
|
||||
import useExtensions from "@/src/hooks/use-extensions";
|
||||
import type { BotExtensionsPayload } from "@/src/types";
|
||||
@@ -17,16 +17,16 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The MCP servers and tools a bot or a workspace has, removable, and a
|
||||
* picker to add more. On the bot and workspace settings pages.
|
||||
* The MCP servers, tools and skills a bot or a workspace has, removable,
|
||||
* and a picker to add more. On the bot and workspace settings pages.
|
||||
*/
|
||||
export default function ExtensionsGroup({ owner, owner_name, description, initial }: Props) {
|
||||
const extensions = useExtensions({ owner, owner_name, initial });
|
||||
const id = "bot_id" in owner ? `bot-${owner.bot_id}` : `workspace-${owner.workspace_id}`;
|
||||
|
||||
return (
|
||||
<SettingsGroup id="tools" title="Tools and MCP servers" description={description}>
|
||||
<SettingsList is_empty={!extensions.attached.length} empty="No tools or MCP servers yet.">
|
||||
<SettingsGroup id="tools" title="Tools, MCP servers and skills" description={description}>
|
||||
<SettingsList is_empty={!extensions.attached.length} empty="No tools, MCP servers or skills yet.">
|
||||
{extensions.attached.map((item) => (
|
||||
<ExtensionRow
|
||||
key={`${item.kind}:${item.id}`}
|
||||
@@ -73,6 +73,18 @@ export default function ExtensionsGroup({ owner, owner_name, description, initia
|
||||
>
|
||||
Tools
|
||||
</Button>
|
||||
<Button
|
||||
title="Manage skills"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
href="/admin/settings/skills"
|
||||
beforeIcon={<BookOpen size={14} aria-hidden />}
|
||||
className="gap-1!"
|
||||
>
|
||||
Skills
|
||||
</Button>
|
||||
</Row>
|
||||
<SettingsStatus status={extensions.status} />
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function BotSettingsSection() {
|
||||
<ExtensionsGroup
|
||||
owner={{ bot_id: bot.id }}
|
||||
owner_name={name}
|
||||
description="What it can use besides its own tools, everywhere it works. A workspace's own come on top while it works there."
|
||||
description="What it can use besides its own tools, everywhere it works: MCP servers, tools and skills. A workspace's own come on top while it works there."
|
||||
initial={pageProps?.bot_extensions}
|
||||
/>
|
||||
<ScopedEnvsGroup initial={pageProps?.bot_envs} />
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from "react";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { PublicSkill, SkillsOverviewPayload } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Edits this skill; adds one when not given.
|
||||
*/
|
||||
skill?: PublicSkill;
|
||||
onSaved: (overview: SkillsOverviewPayload, msg: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits a skill: the name the settings page shows, the slug a bot
|
||||
* loads it by, the one line that tells the bot when it applies, and the
|
||||
* runbook itself in markdown.
|
||||
*/
|
||||
export default function SkillModal({ skill, onSaved, onCancel }: Props) {
|
||||
const [name, setName] = useState(skill?.name || "");
|
||||
const [slug, setSlug] = useState(skill?.slug || "");
|
||||
const [description, setDescription] = useState(skill?.description || "");
|
||||
const [body, setBody] = useState(skill?.body || "");
|
||||
const [enabled, setEnabled] = useState(skill?.enabled ?? true);
|
||||
const [all_bots, setAllBots] = useState(skill?.access.all_bots ?? false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const id = `skill-${skill?.id || "new"}`;
|
||||
|
||||
const submit = async () => {
|
||||
if (loading || !window.confirm(skill ? `Save ${skill.name}?` : `Add ${name || "this skill"}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<SkillsOverviewPayload>({
|
||||
path: "skills/save-skill",
|
||||
body: { skill_id: skill?.id, name, slug, description, body, enabled, all_bots },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the skill.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(res.singleRes, res.msg || "Saved.");
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModalShell
|
||||
open
|
||||
setOpen={(next) => !next && onCancel()}
|
||||
title={skill ? `Edit ${skill.name}` : "Add a skill"}
|
||||
description={"Bots get it once it's attached to them."}
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Input
|
||||
id={`${id}-name`}
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
placeholder="Weekly SEO report"
|
||||
defaultValue={name}
|
||||
autoComplete="off"
|
||||
info="What the settings page calls it."
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}-slug`}
|
||||
title="Slug"
|
||||
label="Slug"
|
||||
showLabel
|
||||
placeholder="weekly-seo-report"
|
||||
defaultValue={slug}
|
||||
autoComplete="off"
|
||||
info="What a bot passes to load_skill: lowercase letters, numbers and hyphens. Unique."
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id={`${id}-description`}
|
||||
title="When to use it"
|
||||
label="When to use it"
|
||||
showLabel
|
||||
rows={2}
|
||||
placeholder="Use for the Monday search report: rankings, traffic and what changed."
|
||||
defaultValue={description}
|
||||
info="The one line a bot reads to decide whether this skill applies. Say what job it covers."
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<CodeEditor
|
||||
mode="markdown"
|
||||
className="h-80"
|
||||
title="Runbook"
|
||||
content={body}
|
||||
info={`Markdown. The steps, the checks, and what good looks like. A bot reads it only when it loads the skill, up to ${AppData.SkillBodyShownChars} characters of it at a time.`}
|
||||
onChange={(value) => setBody(value)}
|
||||
/>
|
||||
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
|
||||
<Checkbox
|
||||
title="Every bot"
|
||||
label="Every bot by default: every bot now and every new one, the coordinator included. Each can still drop it."
|
||||
checked={all_bots}
|
||||
changeHandler={setAllBots}
|
||||
/>
|
||||
<AddFormActions submit_label={skill ? "Save" : "Add skill"} loading={loading} error={error} onCancel={onCancel} />
|
||||
</Form>
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import IconTile from "@/src/components/general/icon-tile";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { ExtensionTargets, PublicSkill, SkillsOverviewPayload } from "@/src/types";
|
||||
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
|
||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||
import extensionAccessLabel from "../../(utils)/extension-access-label";
|
||||
import SkillModal from "./skill-modal";
|
||||
|
||||
type Props = {
|
||||
skill: PublicSkill;
|
||||
targets: ExtensionTargets;
|
||||
onChanged: (overview: SkillsOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One skill: its slug and when it applies, and who gets it. Edit and Access
|
||||
* open in a modal.
|
||||
*/
|
||||
export default function SkillRow({ skill, targets, onChanged, onError }: Props) {
|
||||
const [open, setOpen] = useState<"edit" | "access">();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const saved = (overview: SkillsOverviewPayload, msg: string) => {
|
||||
setOpen(undefined);
|
||||
onChanged(overview, msg);
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (busy || !window.confirm(`Delete ${skill.name}? Bots lose it at once.`)) return;
|
||||
|
||||
setBusy(true);
|
||||
const res = await postAdminApi<SkillsOverviewPayload>({
|
||||
path: "skills/delete-skill",
|
||||
body: { skill_id: skill.id },
|
||||
});
|
||||
setBusy(false);
|
||||
|
||||
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Deleted.");
|
||||
else onError(res.msg || "Couldn't delete the skill.");
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<SettingsListRow
|
||||
avatar={<IconTile Icon={BookOpen} />}
|
||||
title={skill.name}
|
||||
badge={skill.enabled ? undefined : "Off"}
|
||||
subtitle={[skill.slug, extensionAccessLabel({ access: skill.access }), skill.description]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
>
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction
|
||||
title={`Edit ${skill.name}`}
|
||||
label="Edit"
|
||||
disabled={busy}
|
||||
onClick={() => setOpen(open == "edit" ? undefined : "edit")}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsRowAction
|
||||
title={`Choose who gets ${skill.name}`}
|
||||
label="Access"
|
||||
disabled={busy}
|
||||
onClick={() => setOpen(open == "access" ? undefined : "access")}
|
||||
/>
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction title={`Delete ${skill.name}`} label="Delete" disabled={busy} onClick={remove} />
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
{open == "edit" ? (
|
||||
<SkillModal skill={skill} onSaved={saved} onCancel={() => setOpen(undefined)} />
|
||||
) : null}
|
||||
{open == "access" ? (
|
||||
<ExtensionAccessModal<SkillsOverviewPayload>
|
||||
id={skill.id}
|
||||
name={skill.name}
|
||||
access={skill.access}
|
||||
targets={targets}
|
||||
path="skills/set-skill-access"
|
||||
onSaved={saved}
|
||||
onCancel={() => setOpen(undefined)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { SkillsOverviewPayload } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsList from "../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import SkillModal from "../(partials)/skill-modal";
|
||||
import SkillRow from "../(partials)/skill-row";
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* Every skill with who gets it. Super admins add, edit and delete them;
|
||||
* admins choose which bots and workspaces get each one.
|
||||
*/
|
||||
export default function SkillsSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [overview, setOverview] = useState(pageProps?.skills_overview);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
/**
|
||||
* Remounts the rows when the data changes under them.
|
||||
*/
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
if (!overview) return null;
|
||||
|
||||
const changed = (next: SkillsOverviewPayload, msg: string) => {
|
||||
setOverview(next);
|
||||
setStatus({ success: true, msg });
|
||||
setVersion((prev) => prev + 1);
|
||||
};
|
||||
const failed = (msg: string) => setStatus({ error: true, msg });
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Skills"
|
||||
description="A runbook for a job a bot does: the steps, the checks and what good looks like. A bot sees each skill's name and when it applies; it reads the whole thing only when it loads it, so a long one costs nothing until it's needed. Give one to a bot on its settings page or in its chat, or to a workspace to reach every bot there."
|
||||
>
|
||||
{adding ? (
|
||||
<SkillModal
|
||||
onSaved={(next, msg) => {
|
||||
setAdding(false);
|
||||
changed(next, msg);
|
||||
}}
|
||||
onCancel={() => setAdding(false)}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsList is_empty={!overview.skills.length} empty="No skills yet.">
|
||||
{overview.skills.map((skill) => (
|
||||
<SkillRow
|
||||
key={`${skill.id}-${version}`}
|
||||
skill={skill}
|
||||
targets={overview}
|
||||
onChanged={changed}
|
||||
onError={failed}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
{overview.can_edit && !adding ? (
|
||||
<Button
|
||||
title="Add a skill"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setAdding(true)}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New skill
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabSkillsOverview from "@/src/functions/backend/db/extensions/grab-skills-overview";
|
||||
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||
|
||||
/**
|
||||
* Super admins and admins: admins only choose who gets each skill.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabCrewSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
skills_overview: await grabSkillsOverview({ user_id: settings.props.person.id }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import SkillsSettingsSection from "./(sections)/skills-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsSkillsPage() {
|
||||
return (
|
||||
<SettingsShell title="Skills" description="Runbooks your bots follow when a job matches one.">
|
||||
<SkillsSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Skills" });
|
||||
+1
-1
@@ -68,7 +68,7 @@ export default function WorkspaceOverview() {
|
||||
<ExtensionsGroup
|
||||
owner={{ workspace_id }}
|
||||
owner_name={name}
|
||||
description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here."
|
||||
description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here: MCP servers, tools and skills."
|
||||
initial={pageProps?.workspace_extensions}
|
||||
/>
|
||||
<ScopedEnvsGroup initial={pageProps?.workspace_envs} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { BotExtensionsPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import extensionNoun, { extensionLogTitle } from "@/src/utils/extension-noun";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
@@ -37,10 +38,10 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPaylo
|
||||
const { bot, name } = await setBotExtension({ user_id: user.id, bot_id, kind, id, attached: true });
|
||||
|
||||
await logEvent({
|
||||
title: "Bot tool added",
|
||||
title: extensionLogTitle({ kind, verb: "added" }),
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} gave ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} to ${bot.name} (#${bot.id}).`,
|
||||
plain_text: `User #${user.id} gave ${extensionNoun({ kind, article: true })} ${name} to ${bot.name} (#${bot.id}).`,
|
||||
metadata: { bot_id, kind, id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { BotExtensionsPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import extensionNoun, { extensionLogTitle } from "@/src/utils/extension-noun";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
@@ -38,10 +39,10 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPaylo
|
||||
const { bot, name } = await setBotExtension({ user_id: user.id, bot_id, kind, id, attached: false });
|
||||
|
||||
await logEvent({
|
||||
title: "Bot tool removed",
|
||||
title: extensionLogTitle({ kind, verb: "removed" }),
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} took ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} from ${bot.name} (#${bot.id}).`,
|
||||
plain_text: `User #${user.id} took ${extensionNoun({ kind, article: true })} ${name} from ${bot.name} (#${bot.id}).`,
|
||||
metadata: { bot_id, kind, id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import deleteSkill from "@/src/functions/backend/db/extensions/delete-skill";
|
||||
import grabSkillsOverview from "@/src/functions/backend/db/extensions/grab-skills-overview";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { SkillIdRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import type { SkillsOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: deletes a skill. Bots lose it at once.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<SkillsOverviewPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { skill_id } = parseWithSchema({ schema: SkillIdRequestSchema, data: body });
|
||||
const skill = await deleteSkill({ skill_id });
|
||||
|
||||
await logEvent({
|
||||
title: "Skill deleted",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} deleted the skill ${skill.name} (#${skill.id}, ${skill.slug}).`,
|
||||
metadata: { skill_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Deleted ${skill.name}.`,
|
||||
singleRes: await grabSkillsOverview({ user_id: user.id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabSkillsOverview from "@/src/functions/backend/db/extensions/grab-skills-overview";
|
||||
import saveSkill from "@/src/functions/backend/db/extensions/save-skill";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { SaveSkillRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import type { SkillsOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: adds or edits a skill.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<SkillsOverviewPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const data = parseWithSchema({ schema: SaveSkillRequestSchema, data: body });
|
||||
const skill_id = await saveSkill({ user_id: user.id, data });
|
||||
|
||||
await logEvent({
|
||||
title: data.skill_id ? "Skill changed" : "Skill added",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} ${data.skill_id ? "changed" : "added"} the skill ${data.name} (#${skill_id}, ${data.slug}).`,
|
||||
metadata: { skill_id, slug: data.slug },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Saved ${data.name}.`,
|
||||
singleRes: await grabSkillsOverview({ user_id: user.id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabSkillsOverview from "@/src/functions/backend/db/extensions/grab-skills-overview";
|
||||
import setExtensionAccess from "@/src/functions/backend/db/extensions/set-extension-access";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { SetExtensionAccessRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import type { SkillsOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admins and admins: attaches a skill to exactly these bots and
|
||||
* workspaces. Only a super admin sets "all bots" (`setExtensionAccess`).
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<SkillsOverviewPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const access = parseWithSchema({ schema: SetExtensionAccessRequestSchema, data: body });
|
||||
|
||||
await setExtensionAccess({ user_id: user.id, kind: "skill", ...access });
|
||||
|
||||
await logEvent({
|
||||
title: "Skill access changed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} changed who gets the skill #${access.id}.`,
|
||||
metadata: access,
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Saved who gets it.`,
|
||||
singleRes: await grabSkillsOverview({ user_id: user.id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import type { BotExtensionsPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import extensionNoun, { extensionLogTitle } from "@/src/utils/extension-noun";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
@@ -37,10 +38,10 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPaylo
|
||||
const { workspace, name } = await setWorkspaceExtension({ user_id: user.id, workspace_id, kind, id, attached: true });
|
||||
|
||||
await logEvent({
|
||||
title: "Workspace tool added",
|
||||
title: extensionLogTitle({ kind, verb: "added" }),
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} gave ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} to the workspace ${workspace.name} (#${workspace.id}).`,
|
||||
plain_text: `User #${user.id} gave ${extensionNoun({ kind, article: true })} ${name} to the workspace ${workspace.name} (#${workspace.id}).`,
|
||||
metadata: { workspace_id, kind, id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { BotExtensionsPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import extensionNoun, { extensionLogTitle } from "@/src/utils/extension-noun";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
@@ -36,10 +37,10 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPaylo
|
||||
const { workspace, name } = await setWorkspaceExtension({ user_id: user.id, workspace_id, kind, id, attached: false });
|
||||
|
||||
await logEvent({
|
||||
title: "Workspace tool removed",
|
||||
title: extensionLogTitle({ kind, verb: "removed" }),
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} took ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} from the workspace ${workspace.name} (#${workspace.id}).`,
|
||||
plain_text: `User #${user.id} took ${extensionNoun({ kind, article: true })} ${name} from the workspace ${workspace.name} (#${workspace.id}).`,
|
||||
metadata: { workspace_id, kind, id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/src/dict/custom-tool-dict";
|
||||
import { McpTransports, type McpTransport } from "@/src/dict/mcp-dict";
|
||||
import checkCustomToolName from "@/src/utils/check-custom-tool-name";
|
||||
import checkSkillSlug from "@/src/utils/check-skill-slug";
|
||||
|
||||
const Id = z.coerce.number().int().positive();
|
||||
const Ids = z.array(Id).max(1000).default([]);
|
||||
@@ -77,7 +78,7 @@ export const SetExtensionAccessRequestSchema = z.object({
|
||||
|
||||
export const SetBotExtensionRequestSchema = z.object({
|
||||
bot_id: Id,
|
||||
kind: z.enum(["mcp", "tool"]),
|
||||
kind: z.enum(["mcp", "tool", "skill"]),
|
||||
id: Id,
|
||||
/**
|
||||
* The workspace the bot is seen in, for the list that comes back.
|
||||
@@ -87,7 +88,7 @@ export const SetBotExtensionRequestSchema = z.object({
|
||||
|
||||
export const SetWorkspaceExtensionRequestSchema = z.object({
|
||||
workspace_id: Id,
|
||||
kind: z.enum(["mcp", "tool"]),
|
||||
kind: z.enum(["mcp", "tool", "skill"]),
|
||||
id: Id,
|
||||
});
|
||||
|
||||
@@ -170,7 +171,39 @@ export const TestCustomToolRequestSchema = z
|
||||
})
|
||||
.refine((value) => value.custom_tool_id || value.draft, { message: "Pick a tool to try." });
|
||||
|
||||
/**
|
||||
* A skill: the runbook, and the line that tells a bot when to load it.
|
||||
*/
|
||||
export const SaveSkillRequestSchema = z.object({
|
||||
skill_id: Id.optional(),
|
||||
name: z.string().trim().min(1, "Enter a name").max(80),
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.superRefine((slug, ctx) => {
|
||||
const problem = checkSkillSlug({ slug });
|
||||
if (problem) ctx.addIssue({ code: "custom", message: problem });
|
||||
}),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Say in one line when a bot should use it")
|
||||
.max(AppData.SkillDescriptionMaxChars),
|
||||
body: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Write the runbook")
|
||||
.max(AppData.SkillBodyMaxChars, `Keep the runbook under ${AppData.SkillBodyMaxChars} characters`),
|
||||
enabled: z.boolean().default(true),
|
||||
all_bots: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const SkillIdRequestSchema = z.object({
|
||||
skill_id: Id,
|
||||
});
|
||||
|
||||
export type SaveMcpServerRequest = z.infer<typeof SaveMcpServerRequestSchema>;
|
||||
export type SaveSkillRequest = z.infer<typeof SaveSkillRequestSchema>;
|
||||
export type SaveCustomToolRequest = z.infer<typeof SaveCustomToolRequestSchema>;
|
||||
export type TestMcpServerRequest = z.infer<typeof TestMcpServerRequestSchema>;
|
||||
export type DraftCustomTool = z.infer<typeof DraftCustomTool>;
|
||||
|
||||
+29
-3
@@ -598,6 +598,27 @@ export type CustomToolRecord = {
|
||||
| { kind: "prompt"; config: CustomToolPromptConfig }
|
||||
);
|
||||
|
||||
/**
|
||||
* A `skills` row: a runbook a bot loads when it applies.
|
||||
*/
|
||||
export type SkillRecord = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
body: string;
|
||||
all_bots: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A skill as the Skills settings page shows it: nothing secret in one, so
|
||||
* it's the row with who gets it instead of `all_bots`.
|
||||
*/
|
||||
export type PublicSkill = Omit<SkillRecord, "all_bots"> & {
|
||||
access: ExtensionAccess;
|
||||
};
|
||||
|
||||
/**
|
||||
* An MCP server as the MCPs settings page shows it. Only super admins get
|
||||
* how it's reached (`url`, `headers`, `command`, `setup_command`, `env`).
|
||||
@@ -650,7 +671,7 @@ export type ExtensionTargets = {
|
||||
* composer show it.
|
||||
*/
|
||||
export type BotExtension = {
|
||||
kind: "mcp" | "tool";
|
||||
kind: "mcp" | "tool" | "skill";
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -668,12 +689,12 @@ export type BotExtension = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot's MCP servers and tools, and what can be added. Only super admins
|
||||
* A bot's MCP servers, tools and skills, and what can be added. Only super admins
|
||||
* and admins (the super admin alone for the coordinator) manage them.
|
||||
*/
|
||||
export type BotExtensionsPayload = {
|
||||
attached: BotExtension[];
|
||||
addable: { kind: "mcp" | "tool"; id: number; name: string }[];
|
||||
addable: { kind: "mcp" | "tool" | "skill"; id: number; name: string }[];
|
||||
can_manage: boolean;
|
||||
};
|
||||
|
||||
@@ -681,6 +702,10 @@ export type McpServersOverviewPayload = ExtensionTargets & {
|
||||
servers: PublicMcpServer[];
|
||||
};
|
||||
|
||||
export type SkillsOverviewPayload = ExtensionTargets & {
|
||||
skills: PublicSkill[];
|
||||
};
|
||||
|
||||
export type CustomToolsOverviewPayload = ExtensionTargets & {
|
||||
tools: PublicCustomTool[];
|
||||
};
|
||||
@@ -1016,6 +1041,7 @@ export type PagePropsType = {
|
||||
push_overview?: PushOverview | null;
|
||||
mcps_overview?: McpServersOverviewPayload | null;
|
||||
tools_overview?: CustomToolsOverviewPayload | null;
|
||||
skills_overview?: SkillsOverviewPayload | null;
|
||||
bot_extensions?: BotExtensionsPayload | null;
|
||||
/**
|
||||
* The files browser's folders (a user's own crew and media, and what a
|
||||
|
||||
@@ -8,6 +8,9 @@ beforeAll(() => {
|
||||
db.run(`CREATE TABLE custom_tools (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled INTEGER)`);
|
||||
db.run(`CREATE TABLE bot_custom_tools (bot_id INTEGER, custom_tool_id INTEGER)`);
|
||||
db.run(`CREATE TABLE workspace_custom_tools (workspace_id INTEGER, custom_tool_id INTEGER)`);
|
||||
db.run(`CREATE TABLE skills (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled INTEGER)`);
|
||||
db.run(`CREATE TABLE bot_skills (bot_id INTEGER, skill_id INTEGER)`);
|
||||
db.run(`CREATE TABLE workspace_skills (workspace_id INTEGER, skill_id INTEGER)`);
|
||||
/**
|
||||
* 1: every bot (given to 5 and 6). 2: bot 5. 3: workspace 9. 4: off,
|
||||
* given to 5. 5: nobody. 6: every bot, but 6 dropped it.
|
||||
@@ -15,6 +18,12 @@ beforeAll(() => {
|
||||
db.run(`INSERT INTO custom_tools VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1), (6, 1, 1)`);
|
||||
db.run(`INSERT INTO bot_custom_tools VALUES (5, 1), (6, 1), (5, 2), (5, 4), (5, 6)`);
|
||||
db.run(`INSERT INTO workspace_custom_tools VALUES (9, 3)`);
|
||||
/**
|
||||
* The same shape, so skills reach a bot the same way.
|
||||
*/
|
||||
db.run(`INSERT INTO skills VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1), (6, 1, 1)`);
|
||||
db.run(`INSERT INTO bot_skills VALUES (5, 1), (6, 1), (5, 2), (5, 4), (5, 6)`);
|
||||
db.run(`INSERT INTO workspace_skills VALUES (9, 3)`);
|
||||
});
|
||||
|
||||
function ids(params: Parameters<typeof botExtensionsSql>[0]) {
|
||||
@@ -37,4 +46,11 @@ describe("botExtensionsSql", () => {
|
||||
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 9 })).toEqual([1, 2, 3, 6]);
|
||||
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2, 6]);
|
||||
});
|
||||
|
||||
test("skills reach a bot the same way", () => {
|
||||
expect(ids({ kind: "skill", bot_id: 5 })).toEqual([1, 2, 6]);
|
||||
expect(ids({ kind: "skill", bot_id: 6 })).toEqual([1]);
|
||||
expect(ids({ kind: "skill", bot_id: 7 })).toEqual([]);
|
||||
expect(ids({ kind: "skill", bot_id: 6, workspace_id: 9 })).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
type Params = {
|
||||
/**
|
||||
* Which kind: MCP servers or custom tools.
|
||||
* Which kind: MCP servers, custom tools or skills.
|
||||
*/
|
||||
kind: "mcp" | "tool";
|
||||
kind: "mcp" | "tool" | "skill";
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the run is in: what's attached to it counts too.
|
||||
@@ -23,10 +23,16 @@ const Tables = {
|
||||
workspace_link: "workspace_custom_tools",
|
||||
column: "custom_tool_id",
|
||||
},
|
||||
skill: {
|
||||
table: "skills",
|
||||
bot_link: "bot_skills",
|
||||
workspace_link: "workspace_skills",
|
||||
column: "skill_id",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The enabled MCP servers or custom tools a bot gets on one run: those
|
||||
* The enabled MCP servers, custom tools or skills a bot gets on one run: those
|
||||
* attached to the bot, and in a workspace those attached to the
|
||||
* workspace. "Every bot" only decides what a bot is given (now and when
|
||||
* it's created, `attachToAllBots`, `attachDefaultExtensions`); one it
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import checkSkillSlug from "./check-skill-slug";
|
||||
|
||||
describe("checkSkillSlug", () => {
|
||||
test("accepts lowercase words joined by single hyphens", () => {
|
||||
expect(checkSkillSlug({ slug: "seo" })).toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly-seo-report" })).toBeNull();
|
||||
expect(checkSkillSlug({ slug: "report2024" })).toBeNull();
|
||||
});
|
||||
|
||||
test("refuses capitals, spaces, underscores and dots", () => {
|
||||
expect(checkSkillSlug({ slug: "Weekly" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly seo" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly_seo" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly.seo" })).not.toBeNull();
|
||||
});
|
||||
|
||||
test("refuses a hyphen at either end and doubled hyphens", () => {
|
||||
expect(checkSkillSlug({ slug: "-weekly" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly-" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "weekly--seo" })).not.toBeNull();
|
||||
});
|
||||
|
||||
test("refuses slugs shorter than 2 or longer than 64 characters", () => {
|
||||
expect(checkSkillSlug({ slug: "a" })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "a".repeat(65) })).not.toBeNull();
|
||||
expect(checkSkillSlug({ slug: "a".repeat(64) })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SkillSlugPattern } from "@/src/dict/skill-dict";
|
||||
|
||||
type Params = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a skill can't take this slug, or null when it can: lowercase letters,
|
||||
* numbers and single hyphens, 2 to 64 characters, never starting or ending
|
||||
* with one. It's what a bot writes in `load_skill`.
|
||||
*/
|
||||
export default function checkSkillSlug({ slug }: Params): string | null {
|
||||
if (slug.length < 2 || slug.length > 64) {
|
||||
return `Use 2 to 64 characters.`;
|
||||
}
|
||||
|
||||
if (!SkillSlugPattern.test(slug)) {
|
||||
return `Use lowercase letters, numbers and hyphens, e.g. weekly-seo-report.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import extensionNoun, { extensionLogTitle } from "./extension-noun";
|
||||
|
||||
describe("extensionNoun", () => {
|
||||
test("names each kind, with or without its article", () => {
|
||||
expect(extensionNoun({ kind: "mcp" })).toBe("MCP server");
|
||||
expect(extensionNoun({ kind: "tool" })).toBe("tool");
|
||||
expect(extensionNoun({ kind: "skill" })).toBe("skill");
|
||||
expect(extensionNoun({ kind: "skill", article: true })).toBe("the skill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extensionLogTitle", () => {
|
||||
test("capitalizes the noun and adds what happened", () => {
|
||||
expect(extensionLogTitle({ kind: "mcp", verb: "added" })).toBe("MCP server added");
|
||||
expect(extensionLogTitle({ kind: "tool", verb: "removed" })).toBe("Tool removed");
|
||||
expect(extensionLogTitle({ kind: "skill", verb: "added" })).toBe("Skill added");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BotExtension } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
kind: BotExtension["kind"];
|
||||
/**
|
||||
* With the article, for a sentence: "the tool", "the MCP server".
|
||||
*/
|
||||
article?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* What an extension kind is called in copy: a tool, an MCP server or a
|
||||
* skill.
|
||||
*/
|
||||
export default function extensionNoun({ kind, article }: Params) {
|
||||
const noun = kind == "mcp" ? "MCP server" : kind == "skill" ? "skill" : "tool";
|
||||
|
||||
return article ? `the ${noun}` : noun;
|
||||
}
|
||||
|
||||
type TitleParams = {
|
||||
kind: BotExtension["kind"];
|
||||
/**
|
||||
* What happened, in one word: "added", "removed".
|
||||
*/
|
||||
verb: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A log entry's title: "MCP server added", "Skill removed".
|
||||
*/
|
||||
export function extensionLogTitle({ kind, verb }: TitleParams) {
|
||||
const noun = extensionNoun({ kind });
|
||||
|
||||
return `${noun.charAt(0).toUpperCase()}${noun.slice(1)} ${verb}`;
|
||||
}
|
||||
@@ -7,11 +7,20 @@ describe("extensionOptionValue", () => {
|
||||
kind: "mcp",
|
||||
id: 3,
|
||||
});
|
||||
expect(parseExtensionOptionValue({ value: extensionOptionValue({ kind: "tool", id: 4 }) })).toEqual({
|
||||
kind: "tool",
|
||||
id: 4,
|
||||
});
|
||||
expect(parseExtensionOptionValue({ value: extensionOptionValue({ kind: "skill", id: 5 }) })).toEqual({
|
||||
kind: "skill",
|
||||
id: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("refuses anything else", () => {
|
||||
expect(parseExtensionOptionValue({ value: "" })).toBeNull();
|
||||
expect(parseExtensionOptionValue({ value: "bot:2" })).toBeNull();
|
||||
expect(parseExtensionOptionValue({ value: "tool:x" })).toBeNull();
|
||||
expect(parseExtensionOptionValue({ value: "skill:0" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BotExtension } from "@/src/types";
|
||||
|
||||
/**
|
||||
* An MCP server or tool as one select value (`mcp:3`), and back.
|
||||
* An MCP server, tool or skill as one select value (`mcp:3`), and back.
|
||||
*/
|
||||
export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"]; id: number }) {
|
||||
return `${kind}:${id}`;
|
||||
@@ -10,7 +10,7 @@ export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"];
|
||||
export function parseExtensionOptionValue({ value }: { value: string }) {
|
||||
const [kind, id] = value.split(":");
|
||||
|
||||
if ((kind != "mcp" && kind != "tool") || !Number(id)) return null;
|
||||
if ((kind != "mcp" && kind != "tool" && kind != "skill") || !Number(id)) return null;
|
||||
|
||||
return { kind: kind as BotExtension["kind"], id: Number(id) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user