update owliver agent
This commit is contained in:
181
src/lib/agents/capabilityTest.js
Normal file
181
src/lib/agents/capabilityTest.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
|
||||
import { allSkills, matchSkill, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
|
||||
import { canonicalPage, surfaceFor } from '@/lib/skills/surfaces';
|
||||
import { toolsForContext } from '@/lib/skills/tools';
|
||||
import { agentCovers, agentScopedDisabled } from './runtime';
|
||||
|
||||
/**
|
||||
* Trying a capability before committing to it.
|
||||
*
|
||||
* Two things have to be true for this to be worth having, and both are about
|
||||
* refusing to fake something:
|
||||
*
|
||||
* 1. **The evaluation is the live runtime.** `agentCovers`, `skillsForContext`,
|
||||
* `agentScopedDisabled`, `matchSkill` and `toolsForContext` are the same
|
||||
* functions the assistant routes every real question through. Nothing here
|
||||
* re-implements any of them, so a green result cannot mean something
|
||||
* different from what will happen on the page.
|
||||
*
|
||||
* 2. **Testing is not saving.** The agent this reasons about is *hypothetical*
|
||||
* — the draft's fields with the candidate capability added — and it is
|
||||
* never written anywhere. A reader can try six capabilities and leave with
|
||||
* the agent exactly as they found it.
|
||||
*
|
||||
* The other half of the test is the answer itself, and that is deliberately not
|
||||
* here: `scopeFor` returns what `send({ scope })` needs so the question runs in
|
||||
* the *existing* Owliver panel against the target page. One conversation, one
|
||||
* pipeline, no second chat.
|
||||
*/
|
||||
|
||||
/** contextId → the page key it stands for, resolved once. */
|
||||
const CONTEXTS = Object.values(PLACEMENT_ROUTES).map((contextId) => ({
|
||||
contextId,
|
||||
pageKey: pageKeyForContext(contextId),
|
||||
}));
|
||||
|
||||
/** The assistant context for a surface, or null if the page carries no panel. */
|
||||
export function contextForPage(pageKey) {
|
||||
const wanted = canonicalPage(pageKey) || pageKey;
|
||||
const hit = CONTEXTS.find((c) => c.pageKey && (canonicalPage(c.pageKey) || c.pageKey) === wanted);
|
||||
return hit?.contextId || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The surfaces a capability can actually be tried on, for this agent.
|
||||
*
|
||||
* The intersection of what the agent covers and what the capability declares —
|
||||
* anywhere else the runtime would decline, and offering it as a test target
|
||||
* would be offering a test guaranteed to fail for a reason that is not about
|
||||
* the capability.
|
||||
*/
|
||||
export function testTargets(agentPages = [], skillPages = []) {
|
||||
const skill = new Set((skillPages || []).map((p) => canonicalPage(p) || p));
|
||||
return (agentPages || [])
|
||||
.map((p) => canonicalPage(p) || p)
|
||||
.filter((p) => skill.has(p))
|
||||
.map((pageKey) => ({
|
||||
pageKey,
|
||||
contextId: contextForPage(pageKey),
|
||||
label: surfaceFor(pageKey)?.label || pageKey,
|
||||
}))
|
||||
.filter((t) => t.contextId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent as it *would* be with this capability attached.
|
||||
*
|
||||
* Published on purpose: an unpublished draft is refused by the runtime for a
|
||||
* reason that has nothing to do with the capability being tried, and a test
|
||||
* that always says "this agent is not published" answers the wrong question.
|
||||
*/
|
||||
function hypotheticalAgent(fields, skillId) {
|
||||
const skills = skillId && !fields.skills.includes(skillId)
|
||||
? [...fields.skills, skillId]
|
||||
: fields.skills;
|
||||
|
||||
return {
|
||||
id: fields.id || 'draft',
|
||||
name: fields.name || 'This agent',
|
||||
pages: fields.pages,
|
||||
skills,
|
||||
subagents: fields.subagents || [],
|
||||
knowledge: fields.knowledge || [],
|
||||
starters: fields.starters || [],
|
||||
reasoning: fields.reasoning,
|
||||
webSearch: fields.webSearch,
|
||||
status: 'published',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the runtime would do with this question, on this page, with this
|
||||
* capability added.
|
||||
*
|
||||
* `matched` is the honest headline: it is the skill that would actually answer.
|
||||
* When that is the capability under test, the test proves the capability;
|
||||
* when it is a different one, it says so rather than claiming a pass — two
|
||||
* definitions claiming the same phrase is a real condition and the reader
|
||||
* should see it here rather than discover it in production.
|
||||
*/
|
||||
export function evaluateCapability({
|
||||
fields, skillId, contextId, question, customSkills = [], entry = null,
|
||||
}) {
|
||||
const agent = hypotheticalAgent(fields, skillId);
|
||||
const registry = allSkills(customSkills);
|
||||
const disabledSkills = agentScopedDisabled(agent, registry, []);
|
||||
|
||||
const covers = agentCovers(agent, contextId);
|
||||
const reachable = covers ? skillsForContext(contextId, disabledSkills, customSkills) : [];
|
||||
const offered = reachable.some((s) => s.id === skillId);
|
||||
|
||||
/**
|
||||
* There are two ways a question reaches a skill, and conflating them made
|
||||
* this panel lie about its own suggestions.
|
||||
*
|
||||
* A **typed** question is routed by `matchSkill` against declared triggers. A
|
||||
* **suggestion** is not routed at all: it is a chip, it carries the capability
|
||||
* it asks for, and `send` short-circuits intent resolution for exactly that
|
||||
* reason (`capability ? { kind: 'answer' }`). So "What kinds of event are
|
||||
* there?" — a suggestion Activity Analysis declares — matches none of its
|
||||
* triggers and is still answered by it every time.
|
||||
*
|
||||
* Reporting that as "no skill claims this wording" was true of the matcher and
|
||||
* false of the product. Both paths are modelled here, and the one that applies
|
||||
* is named, so the reader is told *how* it would be answered rather than being
|
||||
* shown a warning about a question that works.
|
||||
*/
|
||||
const declared = (entry?.suggestions || []).find(
|
||||
(sug) => (sug.prompt || sug.label) === question
|
||||
);
|
||||
|
||||
const matched = covers && question && !declared
|
||||
? matchSkill(question, contextId, disabledSkills, customSkills)
|
||||
: null;
|
||||
|
||||
return {
|
||||
agent,
|
||||
disabledSkills,
|
||||
covers,
|
||||
/** Is the capability under test offered at all on this page? */
|
||||
reachable: offered,
|
||||
reachableCount: reachable.length,
|
||||
/** The skill that would answer a *typed* question, if any. */
|
||||
matched,
|
||||
/** True when the capability being tested is the one that answers. */
|
||||
claims: Boolean(offered && (declared || (matched && matched.id === skillId))),
|
||||
/** How it would be answered: its own suggestion, or a matched trigger. */
|
||||
route: declared ? 'suggestion' : matched ? 'trigger' : null,
|
||||
/** The capability a suggestion asks for — passed to `send` as a chip would. */
|
||||
capability: declared?.capability || null,
|
||||
tools: covers ? toolsForContext(contextId, disabledSkills, customSkills) : [],
|
||||
pageLabel: ASSISTANT_CONTEXTS[contextId]?.page || contextId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What `send({ scope })` needs to run this turn against the target page.
|
||||
*
|
||||
* Deliberately the same `disabledSkills` the evaluation used, so the answer the
|
||||
* reader reads is produced under exactly the conditions the diagnostics above
|
||||
* described.
|
||||
*/
|
||||
export const scopeFor = (evaluation, contextId) => ({
|
||||
contextId,
|
||||
disabledSkills: evaluation.disabledSkills,
|
||||
agent: evaluation.agent,
|
||||
});
|
||||
|
||||
/**
|
||||
* Questions worth trying, taken from the capability itself.
|
||||
*
|
||||
* A definition's `owliver.suggestions` are the questions its author wrote it to
|
||||
* answer, so they are the fairest test of it — and they keep the test from
|
||||
* being a blank box the reader has to guess at. A definition with none falls
|
||||
* back to its own name, which is what its default trigger matches.
|
||||
*/
|
||||
export function testQuestions(entry) {
|
||||
const declared = (entry?.suggestions || []).map((s) => s.prompt || s.label).filter(Boolean);
|
||||
if (declared.length) return declared.slice(0, 3);
|
||||
return entry?.name ? [entry.name] : [];
|
||||
}
|
||||
431
src/lib/skills/catalog.js
Normal file
431
src/lib/skills/catalog.js
Normal file
@@ -0,0 +1,431 @@
|
||||
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));
|
||||
}
|
||||
@@ -735,7 +735,7 @@ export function contextIdsForSkill(skill) {
|
||||
* 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 } = {}) {
|
||||
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind = undefined } = {}) {
|
||||
if (!pageId) return [];
|
||||
const wanted = canonicalPage(pageId) || pageId;
|
||||
return allSkills(customSources).filter(
|
||||
|
||||
@@ -250,6 +250,22 @@ for (const surface of SKILL_SURFACES) {
|
||||
}
|
||||
|
||||
/** Every name a definition may use for a surface, for error messages. */
|
||||
/**
|
||||
* The surfaces that are product *domains* rather than configuration screens.
|
||||
*
|
||||
* Read off `placements`, which is already the honest distinction: a domain
|
||||
* surface offers places for a section to sit because it holds workforce
|
||||
* records, and a configuration screen offers none because it holds none. That
|
||||
* is why Settings, Workspace and the two editors declare `placements: []`.
|
||||
*
|
||||
* The agent editor uses this so its scope picker offers the pages an agent
|
||||
* could sensibly answer *about*, instead of every route the vocabulary happens
|
||||
* to name. Derived, so a surface added tomorrow classifies itself.
|
||||
*/
|
||||
export const DOMAIN_SURFACES = SKILL_SURFACES
|
||||
.filter((s) => s.placements.length > 0)
|
||||
.map((s) => s.id);
|
||||
|
||||
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user