import { aiAgentSkills, allSkills, getSkillsForPage, skillsWithFacet } from './registry'; import { canonicalPage, placementLabel, sectionTypeLabel, surfaceFor } from './surfaces'; import { describeTool } from './tools'; /** * The skill catalog — the registry, read as something a person browses. * * There is **no second list of skills here**. Every entry is a definition * `allSkills` already returned, and every field on it is read off that * definition: what it can be asked for comes from its `owliver.capabilities`, * what it can do comes from its `actions`, and where it applies comes from its * `pages`. A catalog that carried its own copy of any of that would eventually * offer an agent a skill the runtime does not have — which is the exact failure * `AddSkillsModal` was written to avoid, and this keeps. * * What it adds is *grouping*. A definition may declare `category:` and eleven of * them do; the rest are placed by what they demonstrably are, never by their id. * That rule matters: the registry is explicit that nothing downstream may test a * page or a skill by name, so a definition authored tomorrow has to land in a * group without this file being edited. It does — every step below reads a * declaration. */ /** * The groups, in the order a reader meets them. * * Deliberately Krow's own domains rather than a generic "Core / Development / * Data" taxonomy: this workspace hires and rosters people, and a category * called Development would be a heading with nothing under it. * * A definition may still declare a `category:` outside this list — the registry * keeps that field free text on purpose — and `catalogGroups` surfaces it beside * these rather than dropping it. */ export const SKILL_GROUPS = [ { id: 'analytics', label: 'Analytics', blurb: 'The workspace as figures — trends, coverage and the headline picture.', }, { id: 'hiring', label: 'Hiring', blurb: 'The pipeline: open roles, applicants, and who has already been hired.', }, { id: 'workforce', label: 'Workforce', blurb: 'The people already on the roster — attendance, hours and training.', }, { id: 'operations', label: 'Operations', blurb: 'What is happening now, and what is going wrong.', }, { id: 'authoring', label: 'Authoring', blurb: 'Skills that create a record from the conversation rather than reading one.', }, ]; const GROUP_BY_ID = new Map(SKILL_GROUPS.map((g) => [g.id, g])); /** * The group a surface belongs to. * * Keyed on the closed surface vocabulary rather than on skill ids, so this is a * statement about the product's pages — which are a fixed set — and not about * any particular definition. A skill attaching to a surface listed here inherits * its group for free. */ const GROUP_BY_SURFACE = { 'control-center': 'analytics', analytics: 'analytics', positions: 'hiring', 'create-position': 'hiring', candidates: 'hiring', 'candidates-analysis': 'hiring', 'hired-history': 'hiring', 'talent-pool': 'hiring', activity: 'operations', 'krow-forge': 'workforce', profile: 'workforce', }; /** * Whether this definition *writes* rather than reads. * * Read off two declarations, both of which mean the same thing in different * words: a `prompt:` is a definition offering to start a piece of work, and an * action the tool table marks as needing approval is one that changes a record. * Either makes a skill an authoring skill, and neither is a name. */ function isAuthoring(skill) { if (skill.prompt) return true; return (skill.actions || []).some((name) => { const tool = describeTool(name); return Boolean(tool && (tool.mutates || tool.requiresApproval)); }); } /** * Which group a definition belongs to. * * Its own `category:` always wins — an author who wrote one has already * answered this question. Everything after that is inference, in the order of * how much the definition is actually saying: what it does, then where it * applies, then nothing. */ export function groupFor(skill) { const declared = String(skill.category || '').trim().toLowerCase(); if (declared) return declared; if (isAuthoring(skill)) return 'authoring'; for (const page of skill.pages || []) { const group = GROUP_BY_SURFACE[page]; if (group) return group; } return 'general'; } /** A group's label, whether it is one of ours or one an author invented. */ export const groupLabel = (id) => GROUP_BY_ID.get(id)?.label || String(id || '').replace(/[-_]/g, ' ').replace(/^./, (c) => c.toUpperCase()); /** * What a skill can be asked for, in the reader's words. * * The `owliver:` capabilities are the machine-readable half — `summary`, * `table`, `flow` — and the `## Capabilities` bullets are the sentence the * author wrote. Both are shown, because one says what shape an answer takes and * the other says what the answer is about. */ function capabilitiesOf(skill) { const declared = skill.owliver?.capabilities || []; return { /** Response shapes this skill offers in the panel. */ shapes: [...declared], /** The author's own description of what it can do. */ described: skill.capabilities || [], }; } /** The tools a skill reaches, described — never a tool it did not declare. */ function toolsOf(skill) { return (skill.actions || []) .map((name) => describeTool(name) || { name, label: name, summary: '', readOnly: true }) .filter(Boolean); } /** The surfaces a skill answers on, as the product names them. */ function surfacesOf(skill) { return (skill.pages || []).map((page) => ({ id: page, label: surfaceFor(page)?.label || page, })); } /** * One catalog entry: a definition, plus the readings a card and a details panel * need. Nothing is invented — every field traces back to the parsed skill. */ export function catalogEntry(skill) { const group = groupFor(skill); return { id: skill.id, name: skill.name, description: skill.description, group, groupLabel: groupLabel(group), /** Owliver, Board, or both — from the definition's own facets. */ type: capabilityType(skill), capabilities: capabilitiesOf(skill), tools: toolsOf(skill), surfaces: surfacesOf(skill), /** Questions this skill was written to be asked, offered as chips. */ suggestions: (skill.owliver?.suggestions || []).map((s) => ({ label: s.label, prompt: s.prompt, capability: s.capability ?? null, })), /** How many questions its guided flow asks, when it has one. */ questions: (skill.conversation || []).length, prompt: skill.prompt || null, custom: Boolean(skill.custom), status: skill.status, }; } /** * Every skill an agent may carry, as catalog entries. * * The same filter `AddSkillsModal` applied, kept exactly: assistant skills with * the `owliver` facet and an active status. A workforce training path is * something a *person* learns — offering one here would promise an agent * behaviour that does not exist. */ export function skillCatalog(customSkills = [], { pages = null } = {}) { /* `pages: null` means "the whole registry" and is what the skill library wants. An agent always passes its scope, and gets only what it could actually use — see `compatibleSkills`. */ const source = pages ? compatibleSkills(pages, customSkills) : aiAgentSkills(allSkills(customSkills)); return skillsWithFacet(source, 'owliver') .filter((s) => s.status === 'active') .map(catalogEntry) .sort((a, b) => a.name.localeCompare(b.name)); } /* ── Scope, and what is compatible with it ──────────────────────────────── * * The fix for the catalog that offered every agent all eighteen skills. * * An agent is not a container you may put anything in. It answers on a set of * surfaces, a skill declares the surfaces it answers on, and the overlap is the * only set that can ever do anything. Offering Create Position to the Activity * Agent was not merely noisy — it was offering an attachment the runtime would * then refuse, because `skillsForContext` filters by page *before* an agent's * own list is ever consulted. The catalog was advertising attachments that * could not work. * * **One function over declared data, not eight lists.** `getSkillsForPage` is * the registry's own answer to "what belongs here" and is what the live * assistant already routes through, so the catalog and the runtime cannot * disagree by construction. Nothing below tests an agent by name, and a surface * or a skill added tomorrow is scoped correctly without this file being edited. */ /** The surfaces an agent answers on, canonicalized so aliases resolve. */ export const scopeOf = (pages = []) => [...new Set(pages.map((p) => canonicalPage(p) || p).filter(Boolean))]; /** * Every skill compatible with a scope — the union over its surfaces. * * Union rather than intersection: an agent covering Positions and Create * Position may use a skill answering on either, which is exactly what the * runtime does when the reader is standing on one of them. */ export function compatibleSkills(pages = [], customSkills = []) { const seen = new Map(); for (const page of scopeOf(pages)) { for (const skill of getSkillsForPage(page, { customSources: customSkills, kind: 'assistant' })) { if (!seen.has(skill.id)) seen.set(skill.id, skill); } } return [...seen.values()]; } /** Is this capability usable by an agent with this scope? */ export const isCompatible = (skillId, pages = [], customSkills = []) => compatibleSkills(pages, customSkills).some((s) => s.id === skillId); /** * Which surfaces a capability offers itself on — Owliver, Board, or both. * * Read off the facets the parser already derives from what the definition * declares. There is deliberately **no new `capabilityType:` field**: a second * place to say the same thing is a second place for it to be wrong, and a * definition that gains an `owliver:` block tomorrow becomes `both` on its own. * One logical capability, two surfaces — never two definitions. */ export function capabilityType(skill) { const owliver = skill.facets?.includes('owliver'); const board = skill.facets?.includes('ui'); if (owliver && board) return 'both'; return board ? 'board' : 'owliver'; } /** The type, in the words the detail panel uses. */ export const TYPE_LABEL = { owliver: 'Owliver', board: 'Board', both: 'Owliver + Board' }; /* ── Board skills ───────────────────────────────────────────────────────── * * The registry's other facet, read the same way. A Board skill draws a section * on a KROW page; an Owliver skill teaches the assistant what it can be asked. * One registry, one parser, two readings — exactly the split * `WorkspaceSkills` already manages, surfaced here so the agent editor can show * both under one Skills heading without either becoming a second system. * * **The relationship is genuinely different, and this module says so rather * than flattening it.** An Owliver skill is carried by an agent: `agent.skills` * decides what that agent may use, and `agentScopedDisabled` enforces it. A * Board skill is not — `SkillSurface` renders sections from the page and the * account's `disabledSkills`, and consults no agent at all. So a Board entry * carries the surfaces it draws on and whether it is switched on, and never an * "attached to this agent" flag, because there is nothing behind one. */ /** The sections a Board skill declares, flattened with the page each sits on. */ function sectionsOf(skill) { return Object.entries(skill.ui || {}).flatMap(([page, config]) => (config.sections || []).map((section) => ({ id: section.id, title: section.title || sectionTypeLabel(section.type), type: section.type, typeLabel: sectionTypeLabel(section.type), page, pageLabel: surfaceFor(page)?.label || page, placement: section.placement, placementLabel: placementLabel(section.placement), source: section.source || null, })) ); } /** One Board skill, as the agent editor needs to read it. */ export function boardEntry(skill) { const group = groupFor(skill); const sections = sectionsOf(skill); return { id: skill.id, name: skill.name, description: skill.description, group, groupLabel: groupLabel(group), type: capabilityType(skill), sections, /** The pages this skill actually draws on, derived from its sections. */ surfaces: [...new Map(sections.map((s) => [s.page, { id: s.page, label: s.pageLabel }])).values()], custom: Boolean(skill.custom), status: skill.status, }; } /** Every Board skill in the registry, as entries. */ export function boardCatalog(customSkills = [], { pages = null } = {}) { const source = pages ? compatibleSkills(pages, customSkills) : aiAgentSkills(allSkills(customSkills)); return skillsWithFacet(source, 'ui') .filter((s) => s.status === 'active') .map(boardEntry) .sort((a, b) => a.name.localeCompare(b.name)); } /** * Does this Board skill draw on any surface the agent covers? * * The true relationship between an agent and a Board skill, and the only one * there is: they can meet on a page. An agent that answers on Positions stands * beside whatever Board sections Positions renders — it does not own them, and * it cannot switch them on. */ export const boardMeetsAgent = (entry, pages = []) => { if (!pages.length) return false; const covered = new Set(pages); return entry.surfaces.some((s) => covered.has(s.id)); }; /** Board entries, filtered by the same query the Owliver catalog uses. */ export function filterBoard(entries, { query = '' } = {}) { const q = String(query || '').trim().toLowerCase(); if (!q) return entries; return entries.filter((e) => [ e.name, e.description, e.groupLabel, e.id, ...e.surfaces.map((s) => s.label), ...e.sections.map((s) => `${s.title} ${s.typeLabel} ${s.placementLabel}`), ].join(' ').toLowerCase().includes(q)); } /** * The groups this catalog actually contains, in a stable order. * * Derived rather than written down, so a filter can never offer a heading that * matches nothing — and a definition declaring a category nobody anticipated * appears under it instead of vanishing into "everything else". */ export function catalogGroups(entries = []) { const counts = new Map(); for (const entry of entries) { counts.set(entry.group, (counts.get(entry.group) || 0) + 1); } const known = SKILL_GROUPS .filter((g) => counts.has(g.id)) .map((g) => ({ ...g, count: counts.get(g.id) })); const extra = [...counts.keys()] .filter((id) => !GROUP_BY_ID.has(id)) .sort() .map((id) => ({ id, label: groupLabel(id), blurb: '', count: counts.get(id) })); return [...known, ...extra]; } /** * Does this entry match what was typed? * * Name, description, group, the surfaces it answers on, the capabilities it * offers and its id — so "attendance", "Control Center", "table" and * "anomaly-detection" all find something, and a reader who knows the domain * rather than the catalog can still search it. */ export function matchesQuery(entry, query) { const q = String(query || '').trim().toLowerCase(); if (!q) return true; const haystack = [ entry.name, entry.description, entry.groupLabel, entry.id, ...entry.surfaces.map((s) => s.label), ...entry.capabilities.shapes, ...entry.capabilities.described, ...entry.tools.map((t) => t.label), ].join(' ').toLowerCase(); return haystack.includes(q); } /** * The catalog, filtered. * * One function so the count shown beside a filter and the cards under it are * the same reading — two filters written separately is how a heading comes to * say "6 skills" above four cards. */ export function filterCatalog(entries, { query = '', group = 'all' } = {}) { return entries .filter((e) => group === 'all' || e.group === group) .filter((e) => matchesQuery(e, query)); }