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; }