Files
doormilxpress_astryx/src/lib/skills/registry.js
2026-08-20 18:18:10 +05:30

860 lines
35 KiB
JavaScript

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');