LibreChat/packages/data-schemas/src/schema/skill.ts
Danny Avila 6357ea10c1
🧭 feat: Scope Model Spec Skills (#13522)
* feat: scope model spec skills

* style: format skill catalog limit

* fix: serialize model spec skill resolution

* test: satisfy model spec load config typing

* fix: apply model spec skills to added conversations

* fix: support alwaysApply frontmatter alias

* fix: address model spec skills review
2026-06-05 10:22:02 -04:00

238 lines
8 KiB
TypeScript

import { Schema } from 'mongoose';
import type { ISkillDocument } from '~/types/skill';
/** Max length for a skill `name` (kebab-case identifier). */
const SKILL_NAME_MAX_LENGTH = 64;
/** Max length for a skill `description`. */
const SKILL_DESCRIPTION_MAX_LENGTH = 1024;
/** Max length for the skill `body` (the SKILL.md content). */
const SKILL_BODY_MAX_LENGTH = 100_000;
/** Max length for the human-friendly `displayTitle`. */
const SKILL_DISPLAY_TITLE_MAX_LENGTH = 128;
const skillNamePattern = /^[a-z0-9][a-z0-9-]*$/;
/**
* Brand namespaces reserved for first-party skills. Matched as prefixes —
* `anthropic-helper` is rejected but `my-helper` is fine. Keep this in sync
* with `RESERVED_NAME_PREFIXES` in `methods/skill.ts`.
*/
const RESERVED_NAME_PREFIXES = ['anthropic-', 'claude-'];
/**
* Slash-command names that collide with LibreChat / Claude Code CLI commands.
* Matched exactly against the full skill name. Keep this in sync with
* `RESERVED_NAME_WORDS` in `methods/skill.ts`.
*/
const RESERVED_NAME_WORDS = new Set([
'help',
'clear',
'compact',
'model',
'exit',
'quit',
'settings',
'anthropic',
'claude',
]);
const skillSchema: Schema<ISkillDocument> = new Schema(
{
/**
* Machine-readable identifier. Kebab-case, max 64 chars, unique per
* (author, tenantId). This is what Claude sees in its system prompt when
* deciding to trigger the skill, and what slash-command integrations key
* off — so it must be stable, concise, and ASCII-friendly. User-facing
* labels live on `displayTitle` instead.
*/
name: {
type: String,
required: true,
index: true,
maxlength: [SKILL_NAME_MAX_LENGTH, `Name cannot exceed ${SKILL_NAME_MAX_LENGTH} characters`],
validate: {
validator: function (value: string): boolean {
if (!skillNamePattern.test(value)) {
return false;
}
const lowered = value.toLowerCase();
if (RESERVED_NAME_PREFIXES.some((prefix) => lowered.startsWith(prefix))) {
return false;
}
if (RESERVED_NAME_WORDS.has(lowered)) {
return false;
}
return true;
},
message:
'Name must be kebab-case (lowercase letters, digits, hyphens), cannot start with "anthropic-" or "claude-", and cannot be a reserved CLI word.',
},
},
/**
* Human-readable label shown in the LibreChat UI (skill list, detail
* header, sharing dialogs). NOT sent to Claude and NOT used to trigger
* the skill — `name` + `description` drive triggering. Purely cosmetic:
* useful when the author wants a prettier label than the kebab-case
* identifier while keeping the identifier stable.
*/
displayTitle: {
type: String,
maxlength: [
SKILL_DISPLAY_TITLE_MAX_LENGTH,
`Display title cannot exceed ${SKILL_DISPLAY_TITLE_MAX_LENGTH} characters`,
],
},
/**
* "When to use this skill" sentence that Claude reads from the system
* prompt to decide whether to invoke the skill. This is the highest-
* leverage field for triggering accuracy — a vague description causes
* undertriggering. Denormalized from the YAML frontmatter for indexed
* querying.
*/
description: {
type: String,
required: true,
maxlength: [
SKILL_DESCRIPTION_MAX_LENGTH,
`Description cannot exceed ${SKILL_DESCRIPTION_MAX_LENGTH} characters`,
],
},
/** The SKILL.md body (markdown after the YAML frontmatter). */
body: {
type: String,
default: '',
maxlength: [SKILL_BODY_MAX_LENGTH, `Body cannot exceed ${SKILL_BODY_MAX_LENGTH} characters`],
},
/**
* Structured YAML frontmatter bag (everything except `name`/`description`,
* which live as first-class columns). Validated in strict mode against
* `validateSkillFrontmatter` before write — unknown keys are rejected
* so any expansion of the allowed set is an explicit code change.
*/
frontmatter: {
type: Schema.Types.Mixed,
default: {},
},
/**
* When `true`, the model cannot invoke this skill via the `skill` tool
* and the skill is excluded from the catalog injected into the agent's
* additional_instructions. Manual `$` invocation is unaffected. Mirrors
* the `disable-model-invocation` frontmatter field. Filtering currently
* happens application-side after `listSkillsByAccess` returns, so no
* index — add one if/when a query starts filtering by this column at
* the DB level.
*/
disableModelInvocation: {
type: Boolean,
default: false,
},
/**
* When `false`, the skill is hidden from the `$` popover and rejected
* by the manual-invocation resolver. Defaults to `true` so existing
* skills remain user-invocable without a migration. Mirrors the
* `user-invocable` frontmatter field.
*/
userInvocable: {
type: Boolean,
default: true,
},
/**
* Skill-declared tool allowlist forwarded verbatim from frontmatter.
* Surfaced on resolved skill primes so the agent's effective tool set
* for the turn can union these in alongside the agent-configured tools.
* `default: undefined` (not `[]`) preserves the distinction between
* "author declared no extras" and "author explicitly declared none".
*
* Phase 6 wires this in for **manually-primed** skills (Phase 5's
* always-apply primes will pass through the same union once that
* resolver lands). Model-invoked skills (via the `skill` tool
* mid-turn) do NOT trigger tool union at execution time — adding a
* tool partway through a turn would require a graph rebuild that
* isn't worth the complexity for v1. If the author wants the model
* to have access to extra tools when the model picks the skill, they
* should add those tools to the agent directly.
*/
allowedTools: {
type: [String],
default: undefined,
},
category: {
type: String,
default: '',
index: true,
},
author: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
authorName: {
type: String,
required: true,
},
version: {
type: Number,
default: 1,
min: 1,
},
/**
* Provenance of this skill's canonical definition.
*
* - `inline` — authored directly inside LibreChat (the only path wired
* up in phase 1).
* - `github` / `notion` — **reserved for phase 2+ external sync**. No
* code path currently produces these values. The column exists now so
* a future sync worker can populate `source` + `sourceMetadata` without
* a schema migration.
*/
source: {
type: String,
enum: ['inline', 'github', 'notion'],
default: 'inline',
},
/**
* Arbitrary JSON provenance payload keyed by `source`. Phase 2+ sync
* workers will use this to store the upstream commit SHA (github),
* page id (notion), etc. Unused in phase 1 — kept `Mixed` to avoid
* committing to a shape before the sync paths exist.
*/
sourceMetadata: {
type: Schema.Types.Mixed,
},
fileCount: {
type: Number,
default: 0,
min: 0,
},
/**
* When `true`, the skill's SKILL.md body is auto-primed into every turn
* without user `$` invocation or model discretion. Mirrors the
* always-apply YAML frontmatter field and is kept as a first-class
* column so the `listAlwaysApplySkills` query at the top of every
* request is an indexed lookup, not a frontmatter scan.
*/
alwaysApply: {
type: Boolean,
default: false,
index: true,
},
tenantId: {
type: String,
index: true,
},
},
{
timestamps: true,
},
);
skillSchema.index({ author: 1, tenantId: 1 });
skillSchema.index({ category: 1, updatedAt: -1 });
skillSchema.index({ updatedAt: -1, _id: 1 });
skillSchema.index({ name: 1, author: 1, tenantId: 1 }, { unique: true });
export default skillSchema;