255 lines
9.4 KiB
JavaScript
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;
|
|
}
|