432 lines
16 KiB
JavaScript
432 lines
16 KiB
JavaScript
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));
|
|
}
|