import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement'; import { parseYaml } from './yaml'; import { normalizeSkillUi, slugify } from './uiConfig'; import { normalizeSkillOwliver } from './owliverConfig'; import { SUPPORTED_SKILL_PAGES, canonicalPage, contextLabel, placementProvides, surfaceFor, surfaceForRoute, } from './surfaces'; /** * 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, as data. * * Skills grew declarative UI configuration, which is nested, so this reads the * YAML subset in `yaml.js` rather than the flat `key: value` pairs it used to. * The old shapes are a strict subset of the new one — a definition written for * the previous parser parses identically here. * * A file whose frontmatter cannot be read raises rather than registering a * half-understood definition; `parseSkill` decides what to do with that. */ /** * A definition's text, as the parser needs to see it. * * Files arrive from editors, from Windows, from copy-paste and from downloads, * and four of the things they arrive with used to take the entire frontmatter * block down: a UTF-8 byte-order mark before the opening fence, a blank line * above it, `\r\n` line endings, and trailing spaces after `---`. In every one * of those cases the fence did not match, `parseFrontmatter` returned an empty * record, and the definition registered as `Untitled skill` with no pages — * the file was read, and none of it was believed. * * None of this is a lenient parser: the YAML subset inside the fences is as * strict as it ever was. This is only about recognising that a fence is a * fence. */ export const normalizeDefinition = (raw) => String(raw ?? '') .replace(/^\uFEFF/, '') .replace(/\r\n?/g, '\n') .replace(/^\s*\n+/, ''); /** Whether this text opens with a frontmatter block at all. */ export const hasFrontmatter = (raw) => /^---[ \t]*\n[\s\S]*?\n---[ \t]*(?=\n|$)/ .test(normalizeDefinition(raw)); export function parseFrontmatter(raw) { const text = normalizeDefinition(raw); const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(text); if (!match) return { data: {}, body: text }; const data = parseYaml(match[1]); return { data: data && typeof data === 'object' && !Array.isArray(data) ? data : {}, body: text.slice(match[0].length).trim(), }; } /** * The text under a `## Heading`, up to the next one. * * The heading is escaped before it becomes a pattern. It used to be * interpolated raw, so a heading containing regular-expression punctuation — * `## Capabilities (v2)` is the obvious one — compiled to a pattern that could * not match the words it was built from, and the whole section silently read as * absent. A section that is present and unreadable is the failure this parser * must never have: it looks exactly like a section the author did not write. */ export function sectionSource(body, heading) { const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const section = new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body); return section ? section[1] : null; } /** * List items under a `## Heading`, for the capability list shown in Settings. * * Two kinds of content were being dropped without a word, and both are the same * mistake — treating "a line I do not recognise" as "a line that is not there": * * - **Wrapped items.** A bullet long enough to run onto a second line kept * only its first line. `create-position.md` documented reading a request * "out of a single sentence" and the registry held the sentence without its * last three words. An item is now everything up to the next item or the * blank line that ends the list. * - **Numbered items.** Only `-` counted, so a `1.` list — the natural way to * write ordered instructions — parsed as an empty section. Both markers are * list items in Markdown and both are read as one here. * * Neither change alters any definition currently on disk: every one of them * uses single-line `-` bullets, so this widens what can be written without * moving what already was. */ const LIST_ITEM = /^\s*(?:-|\*|\d+[.)])\s+(.*)$/; export function sectionBullets(body, heading) { const source = sectionSource(body, heading); if (source == null) return []; const items = []; for (const line of source.split(/\r?\n/)) { const item = LIST_ITEM.exec(line); if (item) { items.push(item[1].trim()); continue; } /* A blank line closes the current item; anything else indented under one is its continuation and belongs to it. Prose before the first item — the explanatory paragraph `## Conversation` opens with — matches neither and is ignored, exactly as before. */ if (!line.trim()) { if (items.length) items.push(''); continue; } if (items.length && items[items.length - 1] !== '' && /^\s+/.test(line)) { items[items.length - 1] += ` ${line.trim()}`; } } return items.map((i) => i.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 prose under a `## Heading`, with its bullets and blank lines stripped to * one line of summary. Used for a level's description, which is a sentence * rather than a list. */ export function sectionText(body, heading) { const source = sectionSource(body, heading); if (source == null) return ''; return source .split(/\r?\n/) .map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim()) .filter(Boolean) .join(' ') .trim(); } /** * The rungs a workforce skill defines, read from its own body. * * A definition names its ladder as `## Beginner`, `## Intermediate` and so on, * each followed by what a person must be able to do at that level. Only the * headings that are actually present become rungs, so a skill that tops out at * Advanced has a three-rung ladder rather than a fourth empty one — the ladder * is what the author wrote, not a fixed shape they are padded into. */ const LEVEL_HEADINGS = ['Beginner', 'Intermediate', 'Advanced', 'Expert']; function sectionLevels(body) { return LEVEL_HEADINGS .map((heading) => ({ level: heading.toLowerCase(), label: heading, summary: sectionText(body, heading), })) .filter((rung) => rung.summary); } /** * The page key a route belongs to — `/admin/positions` → `positions`. * * The surface table answers first, because a surface already states its own * route and its key is not always the path tail: `/admin/positions/new` is * `create-position`, not `positions/new`. Falling back to the tail keeps every * route that has no declared surface behaving exactly as it did. */ export function pageKeyForRoute(route) { const surface = surfaceForRoute(route); if (surface) return surface.id; 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; /** * The two management surfaces a definition can belong to. * * `ui` extends a KROW page; `owliver` extends the assistant. They share the * parser, the registry, the validator, the persistence and the data resolver — * only the authoring and management experience is separate, which is what this * classification serves. */ export const SKILL_FACETS = ['ui', 'owliver']; /** * Which of them a definition belongs to. * * Declared, never configured: a `ui:` block is a page extension, and an * `owliver:` block, triggers, actions, a prompt or a conversation is an * assistant extension. A definition that declares neither is an assistant * skill — that is what every definition written before the split was, and * reading it any other way would drop it out of both lists. * * Workforce paths are neither. They define a capability the workforce holds * and are managed in Skill Development, so they carry no facet and appear on * neither list. */ export function skillFacets({ data = {}, kind, ui = {}, owliver, conversation = [] }) { if (kind === 'workforce') return []; const extendsPage = Object.keys(ui).length > 0; /** * Behaviour Owliver actually gains: something to answer with, something to * open, or questions to ask. * * Triggers alone are deliberately not on this list. A trigger is a way of * being *named*, and a page-drawing definition that names itself is still a * page-drawing definition — listing it as an Owliver skill would offer an * author a capability list it never declared. A definition with no `ui:` is * the other way round: triggers are all it has, and they are what it does. */ const teachesOwliver = Boolean( data.owliver || (Array.isArray(data.actions) && data.actions.length) || data.prompt || conversation.length || owliver?.capabilities?.length || owliver?.suggestions?.length ); return [ extendsPage ? 'ui' : null, teachesOwliver || !extendsPage ? 'owliver' : null, ].filter(Boolean); } /** * 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 declaredPages = Array.isArray(data.pages) ? data.pages : []; /** * The definition's id. * * `id:` when it is written, and it always wins — an explicit id is an * address other definitions and stored preferences refer to, and deriving * over the top of one would silently rename a skill. * * The fallback used to be the filename, which is right for a file in * `src/skills/` and wrong for everything else: an uploaded definition is * parsed with the placeholder path `custom`, so a file omitting `id:` was * registered as the skill `custom` and the ID field filled in with the word * "custom". Slugging the name is what an author means by leaving it out. */ const id = data.id || slugify(data.name) || path.split('/').pop().replace(/\.md$/, ''); const levels = sectionLevels(body); /** * Two things wear the same format. * * An *assistant* skill teaches Owliver to do something on a page. A * *workforce* skill defines a capability the workforce can hold, at levels, * and says which pages may surface it. A definition that names a ladder is * the second kind — nothing else distinguishes them, so an author declares a * workforce skill by writing one, not by setting a flag. */ const kind = data.kind || (levels.length ? 'workforce' : 'assistant'); /* The declarative UI this definition contributes, checked against the closed vocabulary in `surfaces.js`. A definition with no `ui:` block is exactly what it was before this existed. */ const { ui, errors: uiErrors } = normalizeSkillUi(data.ui, { declaredPages, skillId: id, }); /** * Where this definition applies. * * `pages:` when it is written. When it is not, the pages its `ui:` entries * name — because a definition that says "put this on Positions and that on * Analytics" has already declared its reach, and making it repeat the list * above the block is the format asking twice. A definition that declares * neither still has none, which is what `validateSkillSource` refuses on. */ const pages = declaredPages.length ? declaredPages : Object.keys(ui); /* The same definition's second consumer. `owliver:` declares what can be asked for in the panel, reading the source the page section already names — so one file answers "what does this page show" and "what can Owliver be asked here" without either being written twice. A definition with no `owliver:` block is exactly what it was before this existed. */ const { owliver, errors: owliverErrors } = normalizeSkillOwliver(data.owliver, { ui, skillId: id, skillName: data.name || '', }); /* The questions this skill asks, when it collects its input in the chat rather than by opening something. */ const conversation = sectionSteps(body, 'Conversation'); return { id, kind, ui, uiErrors, owliver, owliverErrors, /** * What this definition extends, derived from what it declares. * * Two things wear the same format and are managed as different lists: a * definition with a `ui:` block extends a *page*, and one that teaches * Owliver — an `owliver:` block, triggers, actions, a conversation — * extends the *assistant*. Reading that off the declaration rather than * off a `type:` field is what makes the split free: every definition * already written classifies itself, nothing stored has to be migrated, * and a definition doing both is listed in both places rather than * losing half of itself to a category. */ facets: skillFacets({ data, kind, ui, owliver, conversation }), name: data.name || 'Untitled skill', description: data.description || '', /** * The family a definition belongs to, for grouping in a picker. * * Free text and entirely optional — a definition that omits it is * uncategorized and behaves exactly as it did before this existed. * Deliberately not a closed vocabulary: unlike a page or a data source, * a category names nothing the runtime has to resolve, so constraining it * would buy nothing and would make every new grouping a code change. */ category: typeof data.category === 'string' ? data.category.trim() : '', status: data.status === 'inactive' ? 'inactive' : 'active', pages, /** * The capability in the skill graph this definition governs. * * Declared as `skill:`, or inferred by dropping a `-training` suffix and * swapping dashes for underscores — so `server-training.md` governs * `server` and `customer-service-training.md` governs `customer_service` * without the author restating it. Only meaningful for workforce skills. */ skillId: kind === 'workforce' ? (data.skill || id.replace(/-training$/, '')).replace(/-/g, '_') : null, /* The ladder, in order, each rung carrying what it means to hold it. */ levels, /* The definition as written. Forge edits this; every other page reads the parsed form, so there is exactly one artefact behind all of them. */ markdown: raw, source: custom ? 'account' : 'repository', /* 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()), /** * Whether those triggers were *claimed* or merely inherited. * * The fallback above is convenient and, until this field existed, * indistinguishable from the real thing — so a definition that only draws * a card was silently claiming its own name as a phrase Owliver answers * to, and could take a question from a definition written to answer it. * Keeping the distinction lets the matcher weigh a claim differently from * a default without changing what `triggers` contains. */ declaredTriggers: Boolean(Array.isArray(data.triggers) && data.triggers.length), prompt: data.prompt || null, capabilities: sectionBullets(body, 'Capabilities'), purpose: sectionBullets(body, 'Purpose'), 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 registry as actually assembled, with everything that went wrong assembling * it. * * This function exists because the three ways a definition could fail to arrive * intact were all *silent*, and between them they are the whole of the * "sometimes a skill only half works" report: * * 1. **A stored definition that will not parse was dropped.** The `catch` * here returned `null` and the entry vanished. Nothing anywhere said a * skill had been discarded, so the skill simply stopped answering and the * registry looked healthy. * 2. **A definition that parses can still register incomplete.** * `normalizeSkillOwliver` keeps the capabilities that resolve and reports * the rest as errors — correct, and the reason a single bad response does * not cost an author their whole file. But only `validateSkillSource` * reads those errors, and that runs in the authoring dialog. A definition * arriving from storage was registered with its broken capability quietly * missing, which is exactly "some checks work and others do not". * 3. **A custom definition silently replaces a built-in of the same id.** * Intended — it is how a shipped skill is overridden — but indistinguishable * from the built-in having broken, because nothing reported the override. * * Why deployment exposed all three: custom definitions live in per-account * `preferences`, not in the repository. The bundled `.md` files are identical * everywhere — verified — so a definition that behaves differently in a * deployed workspace differs because of what that *account* has stored, and * because stored Markdown is validated when it is written and never again. * Ship a change to the supported vocabulary and yesterday's valid definition * silently loses a capability on next load. * * Nothing is dropped that used to register, and nothing new registers. The only * change is that the failures now have somewhere to be read from. */ export function readSkillRegistry(customSources = []) { const diagnostics = []; const custom = []; customSources.forEach((entry, i) => { const path = entry?.path || `custom/${i}.md`; let skill = null; try { skill = parseSkill(entry?.raw ?? entry, { path, custom: true }); } catch (error) { diagnostics.push({ level: 'error', kind: 'unreadable', path, skillId: null, message: `A stored skill could not be read and is not registered. ${error?.message || ''}`.trim(), }); return; } if (!skill?.id) { diagnostics.push({ level: 'error', kind: 'unreadable', path, skillId: null, message: 'A stored skill has no `id` and is not registered.', }); return; } custom.push(skill); }); const byId = new Map(SKILLS.map((s) => [s.id, s])); for (const skill of custom) { if (byId.has(skill.id) && !byId.get(skill.id).custom) { diagnostics.push({ level: 'warning', kind: 'shadowed', path: skill.path, skillId: skill.id, message: `\`${skill.id}\` replaces the built-in skill of the same id. The built-in definition is not registered.`, }); } byId.set(skill.id, skill); } const skills = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); /* A registered definition that lost part of itself on the way in. Reported for every skill, not only custom ones, so a file on disk that stops resolving after a vocabulary change is just as visible. */ for (const skill of skills) { for (const message of [...(skill.owliverErrors || []), ...(skill.uiErrors || [])]) { diagnostics.push({ level: 'error', kind: 'incomplete', path: skill.path, skillId: skill.id, message, }); } } /** * A section that registered perfectly and can never draw anything. * * The same check `validateSkillSource` refuses on, run again at load — because * a definition stored before the rule existed was validated under the old one * and is never re-checked. Without this it stays in the workspace as a titled * card reporting that it needs a record the page has no way of giving it, and * the Skills page calls the workspace healthy. */ for (const skill of skills) { for (const problem of unresolvableSections(skill)) { diagnostics.push({ level: 'error', kind: 'unresolvable', path: skill.path, skillId: skill.id, message: problem.message, }); } } /** * Two definitions claiming one phrase on one page. * * Only the first is ever consulted, and "first" means alphabetically by name — * so the losing definition answers nothing, with no way to tell that from it * being switched off. Reported per page, because a shared trigger on two * different pages is not a contest. */ const claims = new Map(); for (const skill of skills) { if (skill.status !== 'active' || !skill.declaredTriggers) continue; for (const page of skill.pages) { const key = canonicalPage(page) || page; for (const trigger of new Set(skill.triggers)) { const at = `${key}::${trigger}`; if (!claims.has(at)) claims.set(at, []); claims.get(at).push(skill); } } } for (const [at, claimants] of claims) { if (claimants.length < 2) continue; const [page, trigger] = at.split('::'); diagnostics.push({ level: 'warning', kind: 'trigger-collision', path: claimants[0].path, skillId: claimants[0].id, message: `On ${page}, "${trigger}" is claimed by ${claimants.map((s) => s.id).join(' and ')}. ` + `Only ${claimants[0].id} is consulted.`, }); } return { skills, diagnostics }; } /** * 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. * * Unchanged in what it returns; `readSkillRegistry` is where the same work is * done with its failures kept. */ export function allSkills(customSources = []) { return readSkillRegistry(customSources).skills; } /** Everything that went wrong assembling the registry, for the Skills page. */ export function skillDiagnostics(customSources = []) { return readSkillRegistry(customSources).diagnostics; } /** * Sections that can never resolve where they are attached. * * A source declares the record it needs; a placement either hands one over or * does not. Nothing compared the two, so the commonest authoring mistake in the * product — `position.activity`, the Board editor's own default, on a page with * no position — validated cleanly, registered, drew its title and then reported * "This section needs a position to read" for good. The definition was never * wrong about anything the product had told it to care about. * * Deliberately *only* the `ui:` half. An Owliver response with an unmet need is * not a dead panel: `resolveEntity` asks which position is meant, and answers * once told — which is why `hiring-activity-assistant` reads `position.activity` * on Positions and works. A card cannot ask. That asymmetry is the reason one * is refused and the other is left alone. */ export function unresolvableSections(skill) { const problems = []; for (const [page, config] of Object.entries(skill?.ui || {})) { for (const section of config.sections || []) { if (!section.context) continue; if (placementProvides(page, section.placement).includes(section.context)) continue; problems.push({ page, placement: section.placement, source: section.source, message: `\`${section.source}\` needs ${contextLabel(section.context)} to read, and ` + `\`${page}\` supplies none at \`${section.placement}\`. ` + `Attach it to a placement that does, or read a source that needs nothing.`, }); } } return problems; } /** * Validates a definition before it is stored. Returns an error string or null. * * Frontmatter first, then the declarative UI — an author is told the first * thing that is wrong, in the order they would fix it. */ export function validateSkillSource(raw) { if (!String(raw).trim()) return 'Paste or upload a Markdown definition.'; let skill; try { skill = parseSkill(raw, { custom: true }); } catch (error) { /* The YAML subset reports the line it failed on; that is far more useful than "could not be parsed". */ return `That definition could not be parsed. ${error.message || ''}`.trim(); } 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) => !surfaceFor(p)); if (unknown.length) { return `Unsupported page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`; } /* A UI block that names something the product does not offer is refused outright rather than registered with the offending section dropped. */ if (skill.uiErrors?.length) return skill.uiErrors[0]; /* Same rule for the panel half of the definition: a capability, source or step the product cannot honour is refused now rather than registered as something Owliver offers and then cannot answer. */ if (skill.owliverErrors?.length) return skill.owliverErrors[0]; /* A section attached where its source can never be read. See `unresolvableSections` — this is the difference between a definition that is wrong and one that merely looks right. */ const unresolvable = unresolvableSections(skill); if (unresolvable.length) return unresolvable[0].message; /** * An Owliver block that can answer nothing. * * `owliver: enabled: true` with no capabilities is what the template produces * before an author fills anything in. It registers, claims its own name as a * trigger, can take a question from a definition written to answer it, and * then reads its own description back. Refused here rather than saved and * reported later as "the skill does not work". */ if (skill.owliver?.enabled && !skill.owliver.capabilities.length && !skill.conversation.length && !skill.actions.length) { return 'This skill declares no capabilities, so Owliver could only read its description ' + 'back. Add a capability, or remove the `owliver:` block.'; } 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 || ROUTE_BY_PAGE_KEY[pageKeyForRoute(surfaceFor(key)?.route || '')]?.contextId) .filter(Boolean); } /** * Every skill attached to a page — the one answer to "what belongs here". * * This is what `pages:` is for. A definition listing `positions` is not * documenting itself; it is saying that the Positions experience may surface it, * and this function is the only place that question is answered. Pages call it * and render what comes back, which is what lets a skill authored tomorrow * appear on the right pages tonight without any page component being edited. * * Nothing downstream may test a page name against a skill id. The moment a page * asks "is this Server Training?" the attachment has stopped being data. */ export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) { if (!pageId) return []; const wanted = canonicalPage(pageId) || pageId; return allSkills(customSources).filter( (s) => s.status === 'active' && !disabled.includes(s.id) && s.pages.some((p) => (canonicalPage(p) || p) === wanted) && (!kind || s.kind === kind) ); } /** * 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. * * Assistant skills only: a workforce skill defines a capability, not something * Owliver can be asked to do, and offering one as a chat action would promise * behaviour that does not exist. */ export function skillsForContext(contextId, disabled = [], customSources = []) { const pageKey = PAGE_KEY_BY_CONTEXT[contextId]; if (!pageKey) return []; return getSkillsForPage(pageKey, { disabled, customSources, kind: 'assistant' }); } /** * 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. * * A definition has to be *addressable by the assistant* before its triggers * count, and there are two ways to be: teach Owliver something — an `owliver:` * block, an action, a conversation — or explicitly claim a phrase with * `triggers:`. A definition that does neither is a page extension that happens * to have a name, and matching it here means answering a question with a * restatement of a card's description. * * That was live: `hiring-activity` draws a flow on the Positions page, declares * no triggers, and inherited "hiring activity" from its own name — enough to * take "show hiring activity" from the definition written to answer it. * * Both conditions are read off what the definition declares, so a skill written * tomorrow is admitted or excluded by the same rule, and nothing that claimed a * phrase loses it. */ const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver'); /** * Every skill on this page whose triggers match — in registry order. * * `matchSkill` answers with the first and is what routing uses, because one * question gets one answer. The full list is what makes the choice *auditable*: * when two definitions claim the same phrase, the loser was previously * invisible, and the winner was decided by `allSkills`'s alphabetical sort — * a definition renamed from "Hiring…" to "Activity…" could take over a phrase * without either file's triggers changing. Nothing here changes which skill * answers; it makes the fact that there was a contest something a caller can * see and report. */ export function matchSkills(question, contextId, disabled = [], customSources = []) { const q = String(question).toLowerCase(); return skillsForContext(contextId, disabled, customSources).filter( (s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q)) ); } export function matchSkill(question, contextId, disabled = [], customSources = []) { return matchSkills(question, contextId, disabled, customSources)[0] ?? null; } /** * The definitions belonging to one management surface. * * The single answer to "what belongs on the UI Skills list" and "what belongs * on the Owliver Skills list". Both lists come from `allSkills` — one registry, * two readings of it — so a definition cannot exist on one list and be unknown * to the other system. */ export const skillsWithFacet = (skills = [], facet) => skills.filter((s) => s.facets?.includes(facet)); /** * AI agent skills — the ones Workspace & Skills governs. * * Two different things live in this registry, and they belong to two different * products: * * - **AI agent skills** (`kind: 'assistant'`) are capabilities Owliver gains. * An Owliver skill teaches it what it can be asked; a Board skill draws a * section on a page. Both are governed in Workspace & Skills. * - **Workforce training** (`kind: 'workforce'`) is what a *person* learns and * proves — Bartending, Food Safety, Customer Service. That belongs to KROW * Forge, and is read through `workforceSkillStates`. * * This exists so no surface has to reach for `allSkills()` and hope. A page that * calls `allSkills()` gets both kinds, which is how five training paths ended up * counted as Owliver capabilities on the Workspace landing page — the registry * was right and the reading was not. Asking for agent skills by name means a * training path added tomorrow cannot appear there by default. */ export const aiAgentSkills = (skills = []) => skills.filter((s) => s.kind === 'assistant'); /** Workforce training definitions — KROW Forge's half of the same registry. */ export const workforceTrainingSkills = (skills = []) => skills.filter((s) => s.kind === 'workforce');