Files
doormilxpress_astryx/src/lib/skills/registry.js

255 lines
9.4 KiB
JavaScript

import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
/**
* Owliver skill registry.
*
* A skill is a Markdown file: frontmatter declares what it is and where it
* applies, the body documents what it can do. Nothing here executes Markdown —
* a skill names actions, and `actions.js` decides what those names mean. The
* split is the point: adding a capability is a file, not a change to Owliver.
*
* Registration is the filesystem. `import.meta.glob` picks up every file under
* `src/skills/`, so a new page's skill is registered by existing, and no
* component contains a route check to go with it.
*/
const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: 'default', eager: true });
/**
* Frontmatter, parsed to the subset the format actually uses: `key: value` and
* `key:` followed by an indented `- item` list.
*
* Deliberately not a YAML library — the app has none, this needs no dependency,
* and a skill file that reaches for anchors or nested maps has outgrown being a
* declaration anyway.
*/
function parseFrontmatter(raw) {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
if (!match) return { data: {}, body: raw };
const data = {};
let listKey = null;
for (const line of match[1].split(/\r?\n/)) {
if (!line.trim()) continue;
const item = /^\s*-\s+(.*)$/.exec(line);
if (item && listKey) {
data[listKey].push(item[1].trim());
continue;
}
const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
if (!pair) continue;
const [, key, value] = pair;
if (value === '') {
listKey = key;
data[key] = [];
} else {
listKey = null;
data[key] = value.trim();
}
}
return { data, body: raw.slice(match[0].length).trim() };
}
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
function sectionBullets(body, heading) {
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
if (!section) return [];
return section[1]
.split(/\r?\n/)
.map((l) => /^\s*-\s+(.*)$/.exec(l)?.[1]?.trim())
.filter(Boolean);
}
/**
* The questions a skill asks, from its `## Conversation` section.
*
* One bullet per question: `field | question | suggestions | required?`, where
* suggestions are separated by `;`. A skill that declares no conversation gets
* an empty list and behaves as it always did — this is additive.
*
* Nothing is executed and no suggestion is interpreted here. What a field name
* means, and what a suggestion resolves to, is `positionFlow.js`'s decision, the
* same way `actions.js` is the only place an action name means anything.
*/
function sectionSteps(body, heading) {
return sectionBullets(body, heading)
.map((line) => {
const [field, question, options = '', flag = ''] = line.split('|').map((p) => p.trim());
if (!field || !question) return null;
return {
field,
question,
options: options.split(';').map((o) => o.trim()).filter(Boolean),
required: !/^optional$/i.test(flag),
};
})
.filter(Boolean);
}
/**
* The page key a route belongs to — `/admin/positions` → `positions`.
*
* Derived from the placement table rather than written down again, so a route
* added there is addressable by a skill without touching this file.
*/
export function pageKeyForRoute(route) {
const tail = route.replace(/^\/admin\/?/, '');
return tail === '' ? 'control-center' : tail;
}
/** contextId → page key, for resolving skills from the assistant's context. */
const PAGE_KEY_BY_CONTEXT = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route, contextId]) => {
acc[contextId] = pageKeyForRoute(route);
return acc;
}, {});
/** page key → route, so an action can navigate without hard-coding a path. */
const ROUTE_BY_PAGE_KEY = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route, contextId]) => {
acc[pageKeyForRoute(route)] = { route, contextId };
return acc;
}, {});
export const routeForPageKey = (key) => ROUTE_BY_PAGE_KEY[key]?.route ?? null;
export const pageKeyForContext = (contextId) => PAGE_KEY_BY_CONTEXT[contextId] ?? null;
/**
* One Markdown definition → one skill.
*
* Shared by the files on disk and by anything added at runtime, so a skill
* written in the Add Skill dialog is parsed by exactly the same code as
* `create-position.md` and cannot drift into a second format.
*/
export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
{
const { data, body } = parseFrontmatter(raw);
const pages = Array.isArray(data.pages) ? data.pages : [];
return {
id: data.id || path.split('/').pop().replace(/\.md$/, ''),
name: data.name || 'Untitled skill',
description: data.description || '',
status: data.status === 'inactive' ? 'inactive' : 'active',
pages,
/* The page label Settings shows, taken from the context the page carries
so the two never disagree. */
actions: Array.isArray(data.actions) ? data.actions : [],
/* A skill with no declared triggers answers to its own name, so a
definition that omits the field is still reachable by asking for it.
Explicit triggers replace the fallback rather than adding to it. */
triggers: (Array.isArray(data.triggers) && data.triggers.length
? data.triggers
: [data.name].filter(Boolean)
).map((t) => String(t).toLowerCase()),
prompt: data.prompt || null,
capabilities: sectionBullets(body, 'Capabilities'),
purpose: sectionBullets(body, 'Purpose'),
/* The questions this skill asks, when it collects its input in the chat
rather than by opening something. */
conversation: sectionSteps(body, 'Conversation'),
path,
body,
custom,
};
}
}
/** Every skill on disk, parsed once at module load. */
export const SKILLS = Object.entries(FILES)
.map(([path, raw]) => parseSkill(raw, { path }))
.sort((a, b) => a.name.localeCompare(b.name));
/**
* The built-in skills plus any the account has added.
*
* Custom definitions are stored as their Markdown source, so what is persisted
* is the same artefact a file on disk would be — nothing is half-parsed into a
* bespoke record. A custom skill sharing an id with a built-in replaces it,
* which is how one would be overridden without editing the repository.
*/
export function allSkills(customSources = []) {
const custom = customSources
.map((entry, i) => {
try {
return parseSkill(entry.raw ?? entry, { path: entry.path || `custom/${i}.md`, custom: true });
} catch {
return null;
}
})
.filter((s) => s && s.id);
const byId = new Map(SKILLS.map((s) => [s.id, s]));
custom.forEach((s) => byId.set(s.id, s));
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/** Validates a definition before it is stored. Returns an error string or null. */
export function validateSkillSource(raw) {
if (!String(raw).trim()) return 'Paste or upload a Markdown definition.';
let skill;
try {
skill = parseSkill(raw, { custom: true });
} catch {
return 'That definition could not be parsed.';
}
if (!skill.id) return 'The frontmatter needs an `id`.';
if (!/^[a-z0-9][a-z0-9-]*$/.test(skill.id)) return '`id` must be lower-case letters, numbers and dashes.';
if (!skill.name) return 'The frontmatter needs a `name`.';
if (!skill.pages.length) return 'The frontmatter needs at least one `pages` entry.';
const unknown = skill.pages.filter((p) => !ROUTE_BY_PAGE_KEY[p]);
if (unknown.length) {
return `Unknown page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Known pages: ${Object.keys(ROUTE_BY_PAGE_KEY).join(', ')}.`;
}
return null;
}
/** Page keys a skill may attach to, for the dialog's own guidance. */
export const PAGE_KEYS = Object.keys(ROUTE_BY_PAGE_KEY).sort();
/** Context ids a skill applies to, resolved through the placement table. */
export function contextIdsForSkill(skill) {
return skill.pages.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId).filter(Boolean);
}
/**
* Skills available on a page.
*
* `disabled` is the account's list of switched-off skill ids, so a skill can be
* turned off from Settings without being deleted from disk.
*/
export function skillsForContext(contextId, disabled = [], customSources = []) {
const pageKey = PAGE_KEY_BY_CONTEXT[contextId];
if (!pageKey) return [];
return allSkills(customSources).filter(
(s) => s.status === 'active' && !disabled.includes(s.id) && s.pages.includes(pageKey)
);
}
/**
* Does one trigger match?
*
* A trigger is a phrase. `*` stands for "anything in between", which is what
* lets one line cover "create a position", "create a chef position" and
* "create a bartender position in Chennai" without listing every role — the
* roles come from data, so they must not be written into triggers.
*/
function triggerMatches(trigger, question) {
if (!trigger.includes('*')) return question.includes(trigger);
const pattern = trigger
.split('*')
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('[\\s\\S]{0,40}?');
return new RegExp(pattern).test(question);
}
/** The first skill on this page whose triggers match the question. */
export function matchSkill(question, contextId, disabled = [], customSources = []) {
const q = String(question).toLowerCase();
return skillsForContext(contextId, disabled, customSources).find(
(s) => s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
) ?? null;
}