mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-21 15:45:22 +00:00
* 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
238 lines
8 KiB
TypeScript
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;
|