Make the skill runbook limits a general setting

The longest runbook a skill may save and how much of it load_skill hands
back are now set on Settings > General (skill_body_max_chars,
skill_body_shown_chars), defaulting to AppData.SkillBodyMaxChars and
SkillBodyShownChars. Saving, confirming and proposing a skill check the
install's limit; a skill saved before it was lowered can still be toggled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 09:43:00 +01:00
co-authored by Claude Opus 5.5
parent 2408185a65
commit 373210c2a5
22 changed files with 421 additions and 28 deletions
@@ -0,0 +1,53 @@
import { AppData } from "@/src/data/app-data";
import { SkillBodyMaxCharsSettingKey, SkillBodyShownCharsSettingKey } from "@/src/dict/skill-dict";
import readStepLimit from "@/src/utils/read-step-limit";
import type { SkillLimitsOverview } from "@/src/types";
import grabAppSetting from "./grab-app-setting";
/**
* The longest runbook a skill may save: the install's own limit
* (`skill_body_max_chars`, Settings > General), else
* `AppData.SkillBodyMaxChars`.
*/
export async function grabSkillBodyMaxChars() {
return readStepLimit({
saved: await grabAppSetting({ key: SkillBodyMaxCharsSettingKey }),
fallback: AppData.SkillBodyMaxChars,
max: AppData.SkillBodyLimitMax,
});
}
/**
* How much of a runbook `load_skill` hands back in one call: the install's
* own limit (`skill_body_shown_chars`, Settings > General), else
* `AppData.SkillBodyShownChars`.
*/
export async function grabSkillBodyShownChars() {
return readStepLimit({
saved: await grabAppSetting({ key: SkillBodyShownCharsSettingKey }),
fallback: AppData.SkillBodyShownChars,
max: AppData.SkillBodyLimitMax,
});
}
/**
* Both limits with their defaults, for the settings pages.
*/
export default async function grabSkillLimitsOverview() {
const [body_max_chars, body_shown_chars] = await Promise.all([
grabSkillBodyMaxChars(),
grabSkillBodyShownChars(),
]);
const overview: SkillLimitsOverview = {
body_max_chars,
body_shown_chars,
body_max_default: AppData.SkillBodyMaxChars,
body_shown_default: AppData.SkillBodyShownChars,
max: AppData.SkillBodyLimitMax,
};
return overview;
}
export type { SkillLimitsOverview };
@@ -0,0 +1,20 @@
import { grabSkillBodyMaxChars } from "../app-settings/grab-skill-limits";
import AppError from "@/src/utils/app-error";
type Params = {
body: string;
};
/**
* Refuses a runbook longer than the install's limit (Settings > General).
* The request schemas only hold the ceiling, since the limit is a setting.
*/
export default async function assertSkillBodyFits({ body }: Params) {
const max = await grabSkillBodyMaxChars();
if (body.length > max) {
throw new AppError(
`Keep the runbook under ${max} characters (it has ${body.length}). A super admin can raise the limit on Settings > General.`,
);
}
}
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
import type { SkillsOverviewPayload } from "@/src/types";
import grabSkillLimitsOverview from "../app-settings/grab-skill-limits";
import grabCrewRole from "../access/grab-crew-role";
import grabExtensionAccess from "./grab-extension-access";
import grabExtensionTargets from "./grab-extension-targets";
@@ -17,14 +18,16 @@ type Params = {
*/
export default async function grabSkillsOverview({ user_id }: Params): Promise<SkillsOverviewPayload> {
const role = await grabCrewRole({ user_id });
const [rows, access, targets] = await Promise.all([
const [rows, access, targets, limits] = 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 }),
grabSkillLimitsOverview(),
]);
return {
...targets,
limits,
skills: (rows.payload || []).map((row) => {
const { all_bots, ...skill } = parseSkillRow({ row });
@@ -3,6 +3,7 @@ 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 assertSkillBodyFits from "./assert-skill-body-fits";
import grabSkill from "./grab-skill";
type Params = {
@@ -15,7 +16,9 @@ type Params = {
/**
* 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.
* skill by its slug, so two skills can't answer to the same one. A new or
* changed runbook must fit the install's limit; one saved before the limit
* was lowered keeps saving while its runbook is left as it is.
*/
export default async function saveSkill({ user_id, data }: Params) {
const clash = (
@@ -38,6 +41,9 @@ export default async function saveSkill({ user_id, data }: Params) {
if (data.skill_id) {
const skill = await grabSkill({ skill_id: data.skill_id });
if (data.body != skill.body) await assertSkillBodyFits({ body: data.body });
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({
table: "skills",
targetId: skill.id,
@@ -51,6 +57,8 @@ export default async function saveSkill({ user_id, data }: Params) {
return skill.id;
}
await assertSkillBodyFits({ body: data.body });
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({
table: "skills",
data: [{ ...fields, user_id: Number(user_id) }],