860 lines
35 KiB
JavaScript
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');
|