update agents skill design
This commit is contained in:
103
src/lib/activitySignals.js
Normal file
103
src/lib/activitySignals.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Out-of-pattern activity, detected in one place.
|
||||
*
|
||||
* This computation used to live inside `buildFacts`, which was right when the
|
||||
* assistant's fact sheet was its only reader. It now has a second: a declared
|
||||
* data source resolves `activity.signals` for a card or an answer, and
|
||||
* `dataResolver.js` cannot import from `components/ai-assistant/` without
|
||||
* inverting the layering — a library reaching up into the component tree.
|
||||
*
|
||||
* So it moved down here, **unchanged**. `insights.js` imports it back and calls
|
||||
* it exactly where the inline version used to run, which is what keeps
|
||||
* `buildFacts` returning byte-identical output. "Two unusual patterns" has to
|
||||
* mean the same two everywhere it is said, and the only way to guarantee that
|
||||
* is for there to be one function saying it.
|
||||
*
|
||||
* Each signal is a deviation from this platform's own baseline, not a verdict:
|
||||
* on a live deployment most of them resolve to an integration or a busy
|
||||
* afternoon, and the copy that renders them says so.
|
||||
*/
|
||||
|
||||
/** Share as a whole percentage, or 0 when there is nothing to be a share of. */
|
||||
const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
||||
|
||||
const DAY_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
/**
|
||||
* Events an auditor looks at first: they change who is employed or what is
|
||||
* being hired for. Re-exported by `insights.js`, which is where the rest of the
|
||||
* product already imports it from.
|
||||
*/
|
||||
export const PRIVILEGED_EVENTS = ['hire_candidate', 'create_position'];
|
||||
|
||||
/**
|
||||
* @param {any[]} activity The audit trail, newest first.
|
||||
* @param {Date} today Injected rather than read from the clock, so the
|
||||
* answer is stable for a given fact sheet.
|
||||
*/
|
||||
export function activitySignals(activity = [], today = new Date()) {
|
||||
const since = (days) => {
|
||||
const cutoff = today.getTime() - days * DAY_MS;
|
||||
return activity.filter((e) => new Date(e.created_date).getTime() >= cutoff);
|
||||
};
|
||||
|
||||
const privileged = activity.filter((e) => PRIVILEGED_EVENTS.includes(e.event_type));
|
||||
|
||||
const perUser = activity.reduce((acc, e) => {
|
||||
acc[e.user_email] ||= { email: e.user_email, name: e.user_name, count: 0, privileged: 0 };
|
||||
acc[e.user_email].count += 1;
|
||||
if (PRIVILEGED_EVENTS.includes(e.event_type)) acc[e.user_email].privileged += 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const accounts = Object.values(perUser).sort((a, b) => b.count - a.count);
|
||||
const busiest = accounts[0] || null;
|
||||
const busiestShare = busiest ? pct(busiest.count, activity.length) : 0;
|
||||
|
||||
/* A burst is more than three actions from one account inside one hour. */
|
||||
const perAccountHour = activity.reduce((acc, e) => {
|
||||
const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`;
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const bursts = Object.values(perAccountHour).filter((n) => n > 3).length;
|
||||
|
||||
const offHours = activity.filter((e) => {
|
||||
const hour = new Date(e.created_date).getHours();
|
||||
return hour < 6 || hour >= 22;
|
||||
});
|
||||
|
||||
const privilegedShare = pct(privileged.length, activity.length);
|
||||
|
||||
/* The flags, in the order they are worth reading. A count of these is what
|
||||
the greeting reports, so anything added here changes that number. */
|
||||
const flags = [
|
||||
activity.length > 0 && busiestShare >= 50 && 'concentration',
|
||||
bursts > 0 && 'burst',
|
||||
offHours.length > 0 && 'off-hours',
|
||||
activity.length > 0 && since(1).length === 0 && 'silent',
|
||||
privilegedShare > 30 && 'privileged-share',
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
busiest,
|
||||
busiestShare,
|
||||
bursts,
|
||||
offHours,
|
||||
privileged,
|
||||
privilegedShare,
|
||||
flags,
|
||||
};
|
||||
}
|
||||
|
||||
/** What each flag means, for a reader who is being shown one. */
|
||||
export const SIGNAL_LABELS = {
|
||||
concentration: 'Most activity comes from one account',
|
||||
burst: 'More than three actions from one account inside an hour',
|
||||
'off-hours': 'Activity outside working hours',
|
||||
silent: 'No activity in the last 24 hours',
|
||||
'privileged-share': 'An unusually high share of privileged actions',
|
||||
};
|
||||
|
||||
export const signalLabel = (flag) => SIGNAL_LABELS[flag] || flag;
|
||||
313
src/lib/agents/agentConfig.js
Normal file
313
src/lib/agents/agentConfig.js
Normal file
@@ -0,0 +1,313 @@
|
||||
import { canonicalPage } from '@/lib/skills/surfaces';
|
||||
import { slugify } from '@/lib/skills/uiConfig';
|
||||
import {
|
||||
AGENT_ACCESS, AGENT_STATUSES, DEFAULT_AGENT_ACCESS, DEFAULT_AGENT_ICON,
|
||||
DEFAULT_AGENT_STATUS, DEFAULT_KNOWLEDGE_KIND, DEFAULT_PERMISSION_ROLE, DEFAULT_REASONING,
|
||||
KNOWLEDGE_KINDS, PERMISSION_ROLES, SUPPORTED_REASONING, isAgentIcon, reasoningFor,
|
||||
} from './vocabulary';
|
||||
|
||||
/**
|
||||
* An agent definition's frontmatter, checked and normalized.
|
||||
*
|
||||
* The same contract `normalizeSkillOwliver` holds, for the same reasons:
|
||||
*
|
||||
* - **Everything is optional.** A definition that declares only an id and a
|
||||
* name normalizes to a working agent with documented defaults. That is what
|
||||
* lets the five zero-skill pages have real agents without inventing skills
|
||||
* to fill them.
|
||||
* - **Nothing unknown survives.** Statuses, reasoning modes, pages, icons,
|
||||
* knowledge kinds and permission roles are checked against the closed
|
||||
* tables in `vocabulary.js`; an unrecognised value is a named error rather
|
||||
* than a dropped key.
|
||||
* - **What validates is kept.** One bad entry costs its author that entry and
|
||||
* a message, never the rest of the file.
|
||||
*
|
||||
* One rule is deliberately *absent*: an agent with no skills is not refused.
|
||||
* A skill with no capabilities genuinely cannot answer, which is why the skill
|
||||
* validator refuses one — but an agent with no skills still has its page's own
|
||||
* responder, which is how Control Center, Hired History, Talent Pool, Activity
|
||||
* and Profile answer today. Refusing them would force placeholder skills into
|
||||
* the registry to make the UI look complete, and a registry that lies about
|
||||
* what exists is worse than a short list.
|
||||
*/
|
||||
|
||||
/** What a definition that declares nothing gets. */
|
||||
export const NO_PERMISSIONS = Object.freeze({
|
||||
owner: '',
|
||||
access: DEFAULT_AGENT_ACCESS,
|
||||
people: [],
|
||||
});
|
||||
|
||||
const asList = (value) => {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value === null || value === undefined || value === '') return [];
|
||||
return [value];
|
||||
};
|
||||
|
||||
const trimmed = (value) => String(value ?? '').trim();
|
||||
|
||||
/** Deduped, order preserved — the order an author wrote is the order shown. */
|
||||
function uniqueStrings(raw, { where, errors, label }) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
|
||||
asList(raw).forEach((entry, index) => {
|
||||
const value = trimmed(entry);
|
||||
if (!value) {
|
||||
errors.push(`${where}[${index}]: ${label} cannot be blank.`);
|
||||
return;
|
||||
}
|
||||
if (seen.has(value)) return;
|
||||
seen.add(value);
|
||||
out.push(value);
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages this agent covers, as canonical surface keys.
|
||||
*
|
||||
* Through `canonicalPage`, so a definition may write an alias — `university`
|
||||
* for `krow-forge` — exactly as a skill may, and the two vocabularies cannot
|
||||
* drift apart. An unknown page is an error rather than a silently dropped
|
||||
* entry, because a page nobody recognises is an agent that will never appear
|
||||
* anywhere and give no reason why.
|
||||
*/
|
||||
function normalizePages(raw, { errors }) {
|
||||
const seen = new Set();
|
||||
const pages = [];
|
||||
|
||||
asList(raw).forEach((entry, index) => {
|
||||
const written = trimmed(entry);
|
||||
if (!written) {
|
||||
errors.push(`pages[${index}]: a page cannot be blank.`);
|
||||
return;
|
||||
}
|
||||
const canonical = canonicalPage(written);
|
||||
if (!canonical) {
|
||||
errors.push(`pages[${index}]: \`${written}\` is not a page this product has.`);
|
||||
return;
|
||||
}
|
||||
if (seen.has(canonical)) return;
|
||||
seen.add(canonical);
|
||||
pages.push(canonical);
|
||||
});
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* One conversation starter, in either the plain-string or the mapping form.
|
||||
*
|
||||
* The same two shapes `normalizeSuggestion` accepts for skills, so an author
|
||||
* who has written one has already written the other.
|
||||
*/
|
||||
function normalizeStarter(raw, { errors, index }) {
|
||||
const where = `starters[${index}]`;
|
||||
|
||||
if (typeof raw === 'string' || typeof raw === 'number') {
|
||||
const label = trimmed(raw);
|
||||
if (!label) {
|
||||
errors.push(`${where}: a starter needs text.`);
|
||||
return null;
|
||||
}
|
||||
return { label, prompt: label };
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
errors.push(`${where}: a starter must be a line of text, or a mapping of options.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = trimmed(raw.label ?? raw.prompt);
|
||||
if (!label) {
|
||||
errors.push(`${where}: a starter needs a \`label\`.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* A starter with no prompt of its own asks what it says. */
|
||||
return { label, prompt: trimmed(raw.prompt) || label };
|
||||
}
|
||||
|
||||
/**
|
||||
* One knowledge entry.
|
||||
*
|
||||
* Modelled as a document with an id and a body even though it is one authored
|
||||
* note today, because that is the shape a retrieval layer reads — see
|
||||
* `knowledge.js`. Getting the shape right now is what makes a later move to a
|
||||
* real store a change of transport rather than a change of format.
|
||||
*/
|
||||
function normalizeKnowledge(raw, { errors, index }) {
|
||||
const where = `knowledge[${index}]`;
|
||||
|
||||
if (typeof raw === 'string' || typeof raw === 'number') {
|
||||
const body = trimmed(raw);
|
||||
if (!body) {
|
||||
errors.push(`${where}: a knowledge entry needs text.`);
|
||||
return null;
|
||||
}
|
||||
return { id: slugify(body.slice(0, 40)) || `k${index + 1}`, label: body.slice(0, 60), kind: DEFAULT_KNOWLEDGE_KIND, body, url: '' };
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
errors.push(`${where}: a knowledge entry must be a line of text, or a mapping of options.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = trimmed(raw.label);
|
||||
const body = trimmed(raw.body);
|
||||
const url = trimmed(raw.url);
|
||||
|
||||
if (!label && !body) {
|
||||
errors.push(`${where}: a knowledge entry needs a \`label\` or a \`body\`.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = trimmed(raw.kind) || DEFAULT_KNOWLEDGE_KIND;
|
||||
if (!KNOWLEDGE_KINDS.includes(kind)) {
|
||||
errors.push(`${where}: \`${kind}\` is not a knowledge kind. Use one of ${KNOWLEDGE_KINDS.join(', ')}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kind === 'link' && !url) {
|
||||
errors.push(`${where}: a \`link\` needs a \`url\`.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: trimmed(raw.id) || slugify(label) || `k${index + 1}`,
|
||||
label: label || body.slice(0, 60),
|
||||
kind,
|
||||
body,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/** Who owns the agent, who may reach it, and what they may do. */
|
||||
function normalizePermissions(raw, { errors }) {
|
||||
if (raw === null || raw === undefined) return { ...NO_PERMISSIONS };
|
||||
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
errors.push('permissions: must be a mapping of `owner`, `access` and `people`.');
|
||||
return { ...NO_PERMISSIONS };
|
||||
}
|
||||
|
||||
const access = trimmed(raw.access) || DEFAULT_AGENT_ACCESS;
|
||||
if (!AGENT_ACCESS.includes(access)) {
|
||||
errors.push(`permissions.access: \`${access}\` is not an access mode. Use one of ${AGENT_ACCESS.join(', ')}.`);
|
||||
}
|
||||
|
||||
const people = [];
|
||||
asList(raw.people).forEach((entry, index) => {
|
||||
const where = `permissions.people[${index}]`;
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
errors.push(`${where}: must be a mapping of \`user\` and \`role\`.`);
|
||||
return;
|
||||
}
|
||||
const user = trimmed(entry.user);
|
||||
if (!user) {
|
||||
errors.push(`${where}: needs a \`user\`.`);
|
||||
return;
|
||||
}
|
||||
const role = trimmed(entry.role) || DEFAULT_PERMISSION_ROLE;
|
||||
if (!PERMISSION_ROLES.includes(role)) {
|
||||
errors.push(`${where}: \`${role}\` is not a role. Use one of ${PERMISSION_ROLES.join(', ')}.`);
|
||||
return;
|
||||
}
|
||||
people.push({ user, role });
|
||||
});
|
||||
|
||||
return {
|
||||
owner: trimmed(raw.owner),
|
||||
access: AGENT_ACCESS.includes(access) ? access : DEFAULT_AGENT_ACCESS,
|
||||
people,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One agent's frontmatter → `{ agent, errors }`.
|
||||
*
|
||||
* `agentId` is the id already derived by the caller, used to reject an agent
|
||||
* that names itself as its own subagent — a cycle the runtime would otherwise
|
||||
* have to defend against on every turn.
|
||||
*/
|
||||
export function normalizeAgent(raw, { agentId = '', body = '' } = {}) {
|
||||
const errors = [];
|
||||
const data = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
||||
|
||||
if (raw && (typeof raw !== 'object' || Array.isArray(raw))) {
|
||||
errors.push('An agent definition must be a mapping of options.');
|
||||
}
|
||||
|
||||
const status = trimmed(data.status) || DEFAULT_AGENT_STATUS;
|
||||
if (!AGENT_STATUSES.includes(status)) {
|
||||
errors.push(`status: \`${status}\` is not a status. Use one of ${AGENT_STATUSES.join(', ')}.`);
|
||||
}
|
||||
|
||||
const reasoning = trimmed(data.reasoning) || DEFAULT_REASONING;
|
||||
if (!reasoningFor(reasoning)) {
|
||||
errors.push(`reasoning: \`${reasoning}\` is not a reasoning mode. Use one of ${SUPPORTED_REASONING.join(', ')}.`);
|
||||
}
|
||||
|
||||
const icon = trimmed(data.icon) || DEFAULT_AGENT_ICON;
|
||||
if (!isAgentIcon(icon)) {
|
||||
errors.push(`icon: \`${icon}\` is not an icon this product has.`);
|
||||
}
|
||||
|
||||
/* A version is an integer that only ever goes up. Anything else is an
|
||||
authoring slip, and reading it as 1 is kinder than refusing the file —
|
||||
but it is still reported, because a definition that thinks it is v3 and
|
||||
registers as v1 will publish over something. */
|
||||
let version = 1;
|
||||
if (data.version !== undefined && data.version !== null && data.version !== '') {
|
||||
const parsed = Number(data.version);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
errors.push(`version: \`${data.version}\` is not a whole number of 1 or more.`);
|
||||
} else {
|
||||
version = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const subagents = uniqueStrings(data.subagents, {
|
||||
where: 'subagents', errors, label: 'a subagent id',
|
||||
}).filter((id) => {
|
||||
if (agentId && id === agentId) {
|
||||
errors.push('subagents: an agent cannot be its own subagent.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const starters = asList(data.starters)
|
||||
.map((entry, index) => normalizeStarter(entry, { errors, index }))
|
||||
.filter(Boolean);
|
||||
|
||||
const knowledge = asList(data.knowledge)
|
||||
.map((entry, index) => normalizeKnowledge(entry, { errors, index }))
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
agent: {
|
||||
status: AGENT_STATUSES.includes(status) ? status : DEFAULT_AGENT_STATUS,
|
||||
version,
|
||||
/* When to reach for this agent, in the author's words. Shown in the
|
||||
switcher and carried to the runtime; never matched on, so it can be
|
||||
prose rather than keywords. */
|
||||
trigger: trimmed(data.trigger),
|
||||
reasoning: reasoningFor(reasoning) ? reasoning : DEFAULT_REASONING,
|
||||
icon: isAgentIcon(icon) ? icon : DEFAULT_AGENT_ICON,
|
||||
webSearch: data.webSearch === true || data.web_search === true,
|
||||
pages: normalizePages(data.pages, { errors }),
|
||||
skills: uniqueStrings(data.skills, { where: 'skills', errors, label: 'a skill id' }),
|
||||
subagents,
|
||||
knowledge,
|
||||
starters,
|
||||
permissions: normalizePermissions(data.permissions, { errors }),
|
||||
/* The body's own sections, read by the caller and passed through here so
|
||||
one record carries everything a definition said. */
|
||||
instructions: trimmed(body),
|
||||
},
|
||||
errors,
|
||||
};
|
||||
}
|
||||
209
src/lib/agents/agentFields.js
Normal file
209
src/lib/agents/agentFields.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { REMOVE, patchFrontmatter } from '@/lib/skills/skillFields';
|
||||
import { canonicalPage } from '@/lib/skills/surfaces';
|
||||
import { parseAgent } from './registry';
|
||||
|
||||
/**
|
||||
* An agent definition ⇄ the fields an editor shows.
|
||||
*
|
||||
* Two directions, one field set. `agentFieldsFromSource` reads a definition
|
||||
* into the form; `agentPatch` writes the form back as a frontmatter patch.
|
||||
* Both name the same keys, so a field cannot exist in one direction only —
|
||||
* which is the bug that made the skill editors' upload path silently edit
|
||||
* nothing.
|
||||
*
|
||||
* The patch is consumed by the **existing** `patchFrontmatter`. There is no
|
||||
* second writer: `writeBlock` already emits nested block maps and
|
||||
* `- key: value` sequences to any depth, and `yaml.js` reads them back, so
|
||||
* `permissions.people` and `knowledge` round-trip with no change to either.
|
||||
* That claim is asserted in `skill-check.mjs` rather than assumed.
|
||||
*/
|
||||
|
||||
/** What a fresh editor screen holds. */
|
||||
export const EMPTY_AGENT_FIELDS = Object.freeze({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
trigger: '',
|
||||
status: 'draft',
|
||||
version: 1,
|
||||
reasoning: 'balanced',
|
||||
webSearch: false,
|
||||
pages: [],
|
||||
skills: [],
|
||||
subagents: [],
|
||||
knowledge: [],
|
||||
starters: [],
|
||||
instructions: '',
|
||||
permissions: { owner: '', access: 'all', people: [] },
|
||||
});
|
||||
|
||||
/**
|
||||
* A definition, as fields.
|
||||
*
|
||||
* Reads through `parseAgent`, so the form is filled from exactly what the
|
||||
* runtime will see rather than from a second reading of the same text.
|
||||
*/
|
||||
export function agentFieldsFromSource(source) {
|
||||
let agent;
|
||||
try {
|
||||
agent = parseAgent(source, { custom: true });
|
||||
} catch {
|
||||
/* A half-typed definition fills nothing rather than emptying fields that
|
||||
are already filled in. The caller decides what to do about that. */
|
||||
return { ...EMPTY_AGENT_FIELDS };
|
||||
}
|
||||
|
||||
return {
|
||||
id: agent.id || '',
|
||||
name: agent.name === 'Untitled agent' ? '' : agent.name,
|
||||
description: agent.description || '',
|
||||
icon: agent.icon || '',
|
||||
trigger: agent.trigger || '',
|
||||
status: agent.status,
|
||||
version: agent.version,
|
||||
reasoning: agent.reasoning,
|
||||
webSearch: agent.webSearch,
|
||||
pages: [...agent.pages],
|
||||
skills: [...agent.skills],
|
||||
subagents: [...agent.subagents],
|
||||
knowledge: agent.knowledge.map((k) => ({ ...k })),
|
||||
starters: agent.starters.map((s) => ({ ...s })),
|
||||
instructions: agent.instructions || '',
|
||||
permissions: {
|
||||
owner: agent.permissions.owner,
|
||||
access: agent.permissions.access,
|
||||
people: agent.permissions.people.map((p) => ({ ...p })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** An empty list clears the key rather than writing `key:` with nothing under it. */
|
||||
const listOrRemove = (list) => (list && list.length ? list : REMOVE);
|
||||
|
||||
/**
|
||||
* The frontmatter a set of fields means.
|
||||
*
|
||||
* `undefined` leaves a key exactly as the author wrote it — so an editor that
|
||||
* only knows about four fields cannot erase the other ten, and a definition
|
||||
* hand-written with comments and key order survives being saved from the form.
|
||||
*/
|
||||
export function agentPatch(fields = {}) {
|
||||
const patch = {};
|
||||
|
||||
const scalar = (key, value) => {
|
||||
if (value === undefined) return;
|
||||
patch[key] = value === '' ? REMOVE : value;
|
||||
};
|
||||
|
||||
scalar('id', fields.id);
|
||||
scalar('name', fields.name);
|
||||
scalar('description', fields.description);
|
||||
scalar('icon', fields.icon);
|
||||
scalar('trigger', fields.trigger);
|
||||
scalar('status', fields.status);
|
||||
if (fields.version !== undefined) patch.version = fields.version;
|
||||
scalar('reasoning', fields.reasoning);
|
||||
if (fields.webSearch !== undefined) patch.webSearch = Boolean(fields.webSearch);
|
||||
|
||||
/* Pages are canonicalized on the way out, so a definition saved from the
|
||||
form names surfaces the way the vocabulary does — an author may still
|
||||
write an alias by hand, and it will still read. */
|
||||
if (fields.pages !== undefined) {
|
||||
patch.pages = listOrRemove(
|
||||
(fields.pages || []).map((p) => canonicalPage(p) || p).filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
if (fields.skills !== undefined) patch.skills = listOrRemove(fields.skills);
|
||||
if (fields.subagents !== undefined) patch.subagents = listOrRemove(fields.subagents);
|
||||
|
||||
if (fields.starters !== undefined) {
|
||||
patch.starters = listOrRemove(
|
||||
(fields.starters || []).map((s) =>
|
||||
/* A starter that asks what it says is one line, not two. */
|
||||
(s.prompt && s.prompt !== s.label ? { label: s.label, prompt: s.prompt } : { label: s.label })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (fields.knowledge !== undefined) {
|
||||
patch.knowledge = listOrRemove(
|
||||
(fields.knowledge || []).map((k) => ({
|
||||
id: k.id || undefined,
|
||||
label: k.label || undefined,
|
||||
kind: k.kind || undefined,
|
||||
body: k.body || undefined,
|
||||
url: k.url || undefined,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (fields.permissions !== undefined) {
|
||||
const { owner, access, people } = fields.permissions || {};
|
||||
patch.permissions = {
|
||||
owner: owner || undefined,
|
||||
access: access || undefined,
|
||||
people: people && people.length
|
||||
? people.map((p) => ({ user: p.user, role: p.role }))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the prose under a `## Heading`, keeping everything around it.
|
||||
*
|
||||
* Instructions are the one configurable thing that does **not** live in
|
||||
* frontmatter: they are prose, and prose belongs under a heading where it can
|
||||
* be written and read as prose. That means `agentPatch` alone cannot save them —
|
||||
* it writes frontmatter, and an edit to instructions would be silently dropped.
|
||||
*
|
||||
* Matches the same section the parser reads (`sectionSource`), so what is
|
||||
* written here is exactly what is read back. A definition with no such heading
|
||||
* gains one rather than losing the edit.
|
||||
*/
|
||||
function writeSection(body, heading, text) {
|
||||
const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`(^|\\n)##\\s+${escaped}\\s*\\n[\\s\\S]*?(?=\\n##\\s|$)`, 'i');
|
||||
const content = String(text ?? '').trim();
|
||||
|
||||
if (pattern.test(body)) {
|
||||
/* An emptied section is removed rather than left as a bare heading with
|
||||
nothing under it, which reads as an author who meant to write something. */
|
||||
return content
|
||||
? body.replace(pattern, `$1## ${heading}\n\n${content}\n`)
|
||||
: body.replace(pattern, '$1');
|
||||
}
|
||||
|
||||
if (!content) return body;
|
||||
|
||||
/* Appended, because a definition that never had this section has no place
|
||||
the author intended it to go. */
|
||||
return `${body.trimEnd()}\n\n## ${heading}\n\n${content}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields, applied to a definition.
|
||||
*
|
||||
* The one place the two halves meet: frontmatter through the existing
|
||||
* `patchFrontmatter`, and the prose sections through `writeSection`. A caller
|
||||
* therefore never has to remember which half a given field lives in — which is
|
||||
* exactly the mistake that made instructions silently unsaveable.
|
||||
*/
|
||||
export function applyAgentFields(source, fields = {}) {
|
||||
const patched = patchFrontmatter(source, agentPatch(fields));
|
||||
if (fields.instructions === undefined) return patched;
|
||||
|
||||
/* Split on the closing fence so the body can be rewritten without touching
|
||||
the frontmatter that was just written. */
|
||||
const match = /^---[ \t]*\n[\s\S]*?\n---[ \t]*\n?/.exec(patched);
|
||||
if (!match) return patched;
|
||||
|
||||
const head = patched.slice(0, match[0].length);
|
||||
const body = patched.slice(match[0].length);
|
||||
|
||||
return head + writeSection(body, 'Instructions', fields.instructions);
|
||||
}
|
||||
83
src/lib/agents/agentLifecycle.js
Normal file
83
src/lib/agents/agentLifecycle.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { patchFrontmatter } from '@/lib/skills/skillFields';
|
||||
import { agentPatch } from './agentFields';
|
||||
import { parseAgent } from './registry';
|
||||
import { slugify } from '@/lib/skills/uiConfig';
|
||||
|
||||
/**
|
||||
* Publishing, archiving and duplicating an agent.
|
||||
*
|
||||
* Pure functions over Markdown: each returns a new definition, and the caller
|
||||
* decides whether to store it. Keeping them out of the components means the
|
||||
* list and the detail page cannot implement "publish" two slightly different
|
||||
* ways — the failure that produces an agent published from one screen and not
|
||||
* the other.
|
||||
*
|
||||
* The one rule worth stating plainly: **publishing never silently overwrites a
|
||||
* published version.** A draft taken from v1 and published while someone else
|
||||
* moved the definition to v2 is a conflict, not a save. Returning it as a
|
||||
* conflict lets the screen say so; overwriting would be indistinguishable from
|
||||
* the other change never having been made.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The definition, published.
|
||||
*
|
||||
* Returns `{ source }` when the publish is safe, or `{ conflict }` when the
|
||||
* stored definition has moved past the version this draft was taken from.
|
||||
*/
|
||||
/** @param {string} source @param {any} [options] */
|
||||
export function publishAgent(source, { publishedVersion = 0 } = {}) {
|
||||
const agent = parseAgent(source, { custom: true });
|
||||
|
||||
if (publishedVersion && agent.version < publishedVersion) {
|
||||
return {
|
||||
conflict: {
|
||||
draftVersion: agent.version,
|
||||
publishedVersion,
|
||||
message: `This draft was taken from v${agent.version}, but v${publishedVersion} is published. `
|
||||
+ 'Publishing would discard that change.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* A first publish keeps its version; republishing an already-published
|
||||
definition moves it on, so "what is live" is always a specific version. */
|
||||
const next = agent.status === 'published' ? agent.version + 1 : Math.max(agent.version, 1);
|
||||
|
||||
return { source: patchFrontmatter(source, agentPatch({ status: 'published', version: next })) };
|
||||
}
|
||||
|
||||
/** The definition, taken out of service. Its content is untouched. */
|
||||
export const archiveAgent = (source) =>
|
||||
patchFrontmatter(source, agentPatch({ status: 'archived' }));
|
||||
|
||||
/** The definition, put back into service as a draft rather than live. */
|
||||
export const restoreAgent = (source) =>
|
||||
patchFrontmatter(source, agentPatch({ status: 'draft' }));
|
||||
|
||||
/**
|
||||
* A copy, under a new id.
|
||||
*
|
||||
* Always a draft at v1, whatever the original was: a duplicate of a published
|
||||
* agent is a starting point, and inheriting `published` would put an unreviewed
|
||||
* copy into the switcher the moment it was made.
|
||||
*/
|
||||
/** @param {string} source @param {any} [options] */
|
||||
export function duplicateAgent(source, { name, existingIds = [] } = {}) {
|
||||
const agent = parseAgent(source, { custom: true });
|
||||
const copyName = name || `${agent.name} copy`;
|
||||
|
||||
/* An id nobody is using. Suffixed rather than randomised so the address stays
|
||||
something a person can read and type. */
|
||||
const base = slugify(copyName) || `${agent.id}-copy`;
|
||||
let id = base;
|
||||
let n = 2;
|
||||
while (existingIds.includes(id)) {
|
||||
id = `${base}-${n}`;
|
||||
n += 1;
|
||||
}
|
||||
|
||||
return patchFrontmatter(source, agentPatch({
|
||||
id, name: copyName, status: 'draft', version: 1,
|
||||
}));
|
||||
}
|
||||
114
src/lib/agents/context.js
Normal file
114
src/lib/agents/context.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
import { pageKeyForContext, routeForPageKey } from '@/lib/skills/registry';
|
||||
import { canonicalPage, surfaceFor } from '@/lib/skills/surfaces';
|
||||
|
||||
/**
|
||||
* The context envelope — what the runtime is told about where the reader is.
|
||||
*
|
||||
* Composed from what already exists rather than replacing it. `PageContext.jsx`
|
||||
* is untouched: it remains the one channel a page publishes its selection
|
||||
* through, and this reads that channel and adds the address of the page around
|
||||
* it. Two reasons that separation is worth keeping:
|
||||
*
|
||||
* - **The page channel is the boundary.** A page publishes records it already
|
||||
* has, and nothing here reaches back into the page. Rebuilding it as an
|
||||
* "agent context" would make the agent the thing that decides what is
|
||||
* visible, which is exactly the inversion this design refuses.
|
||||
* - **A page that publishes nothing still resolves.** Every field below has a
|
||||
* defined empty value, so a page that has adopted none of the optional
|
||||
* publishing conventions produces a valid envelope describing where the
|
||||
* reader is and nothing more — identical behaviour to before this existed.
|
||||
*
|
||||
* @typedef {Object} OwliverContext
|
||||
* @property {string} page The page's own label ("Positions")
|
||||
* @property {string|null} pageKey Its surface key ("positions")
|
||||
* @property {string|null} route The real Admin path
|
||||
* @property {string|null} contextId The assistant context id
|
||||
* @property {string|null} period The period the page is filtered to
|
||||
* @property {Object} filters Whatever the page published as filters
|
||||
* @property {any[]} selectedItems Records the page published as selected
|
||||
* @property {string[]} visibleWidgets Section ids the page published as on screen
|
||||
* @property {Object} metrics Figures the page has already computed
|
||||
* @property {Object|null} position From the existing PageContext channel
|
||||
* @property {Object|null} candidate From the existing PageContext channel
|
||||
* @property {string[]} writableSources Sources this page accepts writes for
|
||||
*/
|
||||
|
||||
/** What a page that publishes nothing produces. */
|
||||
const EMPTY = Object.freeze({
|
||||
filters: Object.freeze({}),
|
||||
selectedItems: Object.freeze([]),
|
||||
visibleWidgets: Object.freeze([]),
|
||||
metrics: Object.freeze({}),
|
||||
});
|
||||
|
||||
const asObject = (value) =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
|
||||
const asArray = (value) => (Array.isArray(value) ? value : []);
|
||||
|
||||
/**
|
||||
* Builds the envelope for the page the reader is on.
|
||||
*
|
||||
* `route` is resolved through the existing placement table rather than being
|
||||
* assembled from strings, so nothing here can name an address the product does
|
||||
* not have.
|
||||
*/
|
||||
export function buildOwliverContext({
|
||||
context = null,
|
||||
pathname = '',
|
||||
pageContext = {},
|
||||
writableSources = [],
|
||||
} = {}) {
|
||||
const contextId = context?.id ?? null;
|
||||
const pageKey = contextId ? pageKeyForContext(contextId) : null;
|
||||
|
||||
/* The path the reader is actually on wins; the placement table answers when
|
||||
no path was handed over (a test, a background read). */
|
||||
const declaredRoute = pageKey ? routeForPageKey(pageKey) : null;
|
||||
const route = PLACEMENT_ROUTES[pathname] ? pathname : declaredRoute;
|
||||
|
||||
const published = asObject(pageContext);
|
||||
|
||||
return {
|
||||
page: context?.page ?? (pageKey ? surfaceFor(pageKey)?.label ?? pageKey : ''),
|
||||
pageKey: pageKey ? canonicalPage(pageKey) || pageKey : null,
|
||||
route: route ?? null,
|
||||
contextId,
|
||||
|
||||
/* The optional publishing convention. A page adopts as much of it as it
|
||||
has, and a page that adopts none of it is not broken — it simply has no
|
||||
filters, no selection and no widgets to declare. */
|
||||
period: published.period ?? null,
|
||||
filters: asObject(published.filters) === published.filters
|
||||
? published.filters
|
||||
: EMPTY.filters,
|
||||
selectedItems: asArray(published.selectedItems),
|
||||
visibleWidgets: asArray(published.visibleWidgets),
|
||||
metrics: asObject(published.metrics),
|
||||
|
||||
/* The two records the existing channel already carries, named explicitly
|
||||
because every source that needs one names one of them. */
|
||||
position: published.position ?? null,
|
||||
candidate: published.candidate ?? null,
|
||||
|
||||
/* What this page will accept a write for. A tool cannot write anywhere the
|
||||
page has not offered — see `tools.js`. */
|
||||
writableSources: asArray(writableSources),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope, reduced to what is worth storing beside a conversation.
|
||||
*
|
||||
* Deliberately not the whole thing. `selectedItems` and `metrics` are records
|
||||
* and computed figures; writing them into storage on every turn would put the
|
||||
* dataset in localStorage a message at a time. What a reviewer needs later is
|
||||
* *where* the question was asked, not a copy of what was on screen.
|
||||
*/
|
||||
export const storableContext = (envelope) => ({
|
||||
page: envelope?.page ?? '',
|
||||
pageKey: envelope?.pageKey ?? null,
|
||||
route: envelope?.route ?? null,
|
||||
period: envelope?.period ?? null,
|
||||
});
|
||||
153
src/lib/agents/conversationInsights.js
Normal file
153
src/lib/agents/conversationInsights.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* What the stored conversations add up to.
|
||||
*
|
||||
* Pure selectors over the archive `history.js` returns: no reading, no React,
|
||||
* no knowledge of who is asking. Both the Insights view and the Conversation
|
||||
* Reviews view read through here, so a figure quoted in one and a list shown in
|
||||
* the other cannot describe different things.
|
||||
*
|
||||
* **Nothing is invented.** Every counter is derived from records that exist,
|
||||
* and a workspace with no conversations returns `empty: true` rather than a row
|
||||
* of zeros. A zero and an absence look identical on a dashboard and mean
|
||||
* completely different things — one says the agent was asked and did nothing,
|
||||
* the other says it has not been asked. The empty flag is what lets the view
|
||||
* say which.
|
||||
*/
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const at = (record) => new Date(record?.updatedAt || 0).getTime();
|
||||
|
||||
/** Records for one agent, or all of them when no agent is named. */
|
||||
export function conversationsForAgent(records = [], agentId = null) {
|
||||
if (!agentId) return [...records];
|
||||
return records.filter((record) => record.agentId === agentId);
|
||||
}
|
||||
|
||||
/** Conversations nobody has rated yet — the queue a reviewer works through. */
|
||||
export const unratedConversations = (records = [], agentId = null) =>
|
||||
conversationsForAgent(records, agentId).filter((record) => !record.feedback);
|
||||
|
||||
/** Counts by a key each record contributes many of. */
|
||||
function tally(records, pick) {
|
||||
const counts = new Map();
|
||||
for (const record of records) {
|
||||
for (const value of pick(record) || []) {
|
||||
if (!value) continue;
|
||||
counts.set(value, (counts.get(value) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** Counts by a key each record contributes one of. */
|
||||
function tallyOne(records, pick) {
|
||||
const counts = new Map();
|
||||
for (const record of records) {
|
||||
const value = pick(record);
|
||||
if (!value) continue;
|
||||
counts.set(value, (counts.get(value) || 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* The figures an Insights view reports.
|
||||
*
|
||||
* `since` windows the archive; `agentId` narrows it to one agent. Both are
|
||||
* optional, and neither invents a record that is not there.
|
||||
*/
|
||||
export function conversationStats(records = [], { agentId = null, since = null } = {}) {
|
||||
const scoped = conversationsForAgent(records, agentId)
|
||||
.filter((record) => (since ? at(record) >= new Date(since).getTime() : true));
|
||||
|
||||
if (!scoped.length) {
|
||||
return {
|
||||
empty: true,
|
||||
total: 0,
|
||||
turns: 0,
|
||||
averageTurns: 0,
|
||||
pages: 0,
|
||||
days: 0,
|
||||
activeDays: 0,
|
||||
feedback: { up: 0, down: 0, unrated: 0, score: null },
|
||||
byAgent: [],
|
||||
byPage: [],
|
||||
bySkill: [],
|
||||
byTool: [],
|
||||
byDay: [],
|
||||
};
|
||||
}
|
||||
|
||||
const turns = scoped.reduce((sum, record) => sum + (record.turns || 0), 0);
|
||||
|
||||
const up = scoped.filter((r) => r.feedback?.rating === 'up').length;
|
||||
const down = scoped.filter((r) => r.feedback?.rating === 'down').length;
|
||||
const rated = up + down;
|
||||
|
||||
/* Conversations per day, oldest first, over the days that actually have
|
||||
one. Padding out empty days would draw a chart mostly made of zeros and
|
||||
make a quiet week look like an outage. */
|
||||
const perDay = new Map();
|
||||
for (const record of scoped) {
|
||||
const day = new Date(at(record));
|
||||
day.setHours(0, 0, 0, 0);
|
||||
const key = day.toISOString().slice(0, 10);
|
||||
perDay.set(key, (perDay.get(key) || 0) + 1);
|
||||
}
|
||||
const byDay = [...perDay.entries()]
|
||||
.map(([day, count]) => ({ id: day, day, count }))
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
|
||||
const span = scoped.length
|
||||
? Math.max(1, Math.round((Math.max(...scoped.map(at)) - Math.min(...scoped.map(at))) / DAY) + 1)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
empty: false,
|
||||
total: scoped.length,
|
||||
turns,
|
||||
averageTurns: Math.round((turns / scoped.length) * 10) / 10,
|
||||
pages: new Set(scoped.map((r) => r.contextId).filter(Boolean)).size,
|
||||
/* Days the archive spans, and days anything was actually asked. Reporting
|
||||
only the first would make a busy afternoon look like a busy fortnight. */
|
||||
days: span,
|
||||
activeDays: byDay.length,
|
||||
feedback: {
|
||||
up,
|
||||
down,
|
||||
unrated: scoped.length - rated,
|
||||
/* Null rather than 0 when nothing is rated: a score of zero reads as
|
||||
unanimous disapproval. */
|
||||
score: rated ? Math.round((up / rated) * 100) : null,
|
||||
},
|
||||
byAgent: tallyOne(scoped, (r) => r.agentId),
|
||||
byPage: tallyOne(scoped, (r) => r.pageContext?.page || r.page),
|
||||
bySkill: tally(scoped, (r) => r.skillsUsed),
|
||||
byTool: tally(scoped, (r) => r.toolsUsed),
|
||||
byDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One conversation, reduced to what a review list shows.
|
||||
*
|
||||
* The thread itself is deliberately not included: a list renders forty of these
|
||||
* and only one is ever opened.
|
||||
*/
|
||||
export const reviewRow = (record) => ({
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
agentId: record.agentId ?? null,
|
||||
page: record.pageContext?.page || record.page || '',
|
||||
route: record.pageContext?.route ?? null,
|
||||
turns: record.turns || 0,
|
||||
skillsUsed: record.skillsUsed || [],
|
||||
toolsUsed: record.toolsUsed || [],
|
||||
feedback: record.feedback ?? null,
|
||||
updatedAt: record.updatedAt,
|
||||
});
|
||||
114
src/lib/agents/customAgents.js
Normal file
114
src/lib/agents/customAgents.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { parseAgent } from './registry';
|
||||
import { agentPatch } from './agentFields';
|
||||
import { patchFrontmatter } from '@/lib/skills/skillFields';
|
||||
|
||||
/**
|
||||
* Account-authored agents, as stored.
|
||||
*
|
||||
* An agent is its Markdown source and nothing else — the same artefact a file
|
||||
* in `src/agents/` is, read back by the same parser. These helpers exist so
|
||||
* every writer produces that one shape; two writers would be a second agent
|
||||
* system by accident.
|
||||
*
|
||||
* Stored in `preferences.customAgents`, mirroring `customSkills`, which means
|
||||
* an agent authored here can be copied into `src/agents/` later with no
|
||||
* conversion — and that editing a shipped agent is the same act as editing a
|
||||
* shipped skill: an account definition of the same id, reported as `shadowed`.
|
||||
*/
|
||||
|
||||
/** The starting definition offered to an author. */
|
||||
export function agentTemplate({
|
||||
id = '', name = '', description = '', pages = [], skills = [], icon = '', trigger = '',
|
||||
} = {}) {
|
||||
const fallback = {
|
||||
id: 'my-agent',
|
||||
name: 'My Agent',
|
||||
description: 'What this agent is for.',
|
||||
};
|
||||
const label = name || fallback.name;
|
||||
|
||||
/* Identity is written here; everything else is written by `agentPatch`, the
|
||||
same writer the editor uses on an existing file. A template that composed
|
||||
its own YAML would be a second field set, and the fields one knew about
|
||||
would not be the fields the other did. */
|
||||
const skeleton = `---
|
||||
id: ${id || fallback.id}
|
||||
name: ${name || fallback.name}
|
||||
description: ${description || fallback.description}
|
||||
---
|
||||
|
||||
# ${label}
|
||||
|
||||
## Instructions
|
||||
|
||||
Describe how this agent should answer: what it is responsible for, what it
|
||||
should say when it cannot help, and how it should use the skills it carries.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Describe one thing this agent is for.
|
||||
- Add more as needed.
|
||||
`;
|
||||
|
||||
return patchFrontmatter(
|
||||
skeleton,
|
||||
agentPatch({
|
||||
id: id || fallback.id,
|
||||
name: label,
|
||||
description: description || fallback.description,
|
||||
/* A fresh agent is a draft. Creating one must never publish it. */
|
||||
status: 'draft',
|
||||
version: 1,
|
||||
icon,
|
||||
trigger,
|
||||
pages,
|
||||
skills,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Parses a stored entry, tolerating the bare-string form. */
|
||||
const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? '');
|
||||
|
||||
/**
|
||||
* The stored list with `source` added or replaced.
|
||||
*
|
||||
* Matched by id, so editing an agent overwrites its own entry rather than
|
||||
* adding a near-duplicate beside it.
|
||||
*/
|
||||
export function upsertCustomAgent(existing = [], source) {
|
||||
const agent = parseAgent(source, { custom: true });
|
||||
const rest = (existing || []).filter((entry) => {
|
||||
try {
|
||||
return parseAgent(sourceOf(entry), { custom: true }).id !== agent.id;
|
||||
} catch {
|
||||
/* An unparseable entry cannot be the one being edited, and dropping it
|
||||
here would delete a definition its author may still want to fix. */
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return { agent, next: [...rest, { path: `custom/${agent.id}.md`, raw: source }] };
|
||||
}
|
||||
|
||||
/** The stored list without the agent of this id. */
|
||||
export function removeCustomAgent(existing = [], id) {
|
||||
return (existing || []).filter((entry) => {
|
||||
try {
|
||||
return parseAgent(sourceOf(entry), { custom: true }).id !== id;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The stored Markdown for one agent, or null if the account has none. */
|
||||
export function customAgentSource(existing = [], id) {
|
||||
for (const entry of existing || []) {
|
||||
try {
|
||||
if (parseAgent(sourceOf(entry), { custom: true }).id === id) return sourceOf(entry);
|
||||
} catch {
|
||||
/* Unparseable entries are not the one being asked for. */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
157
src/lib/agents/knowledge.js
Normal file
157
src/lib/agents/knowledge.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import { agentCovers } from './runtime';
|
||||
|
||||
/**
|
||||
* The knowledge seam.
|
||||
*
|
||||
* Boundary 5 of the architecture, established now and deliberately minimal.
|
||||
* There is no vector store, no embedding model and no document corpus in this
|
||||
* phase, and none is faked to make retrieval look implemented.
|
||||
*
|
||||
* What exists is real: an agent's authored `knowledge:` entries, chunked and
|
||||
* matched on terms. Somebody wrote those entries, an answer that quotes one
|
||||
* says where it came from, and an agent with none returns nothing rather than a
|
||||
* plausible paragraph. That is a small capability honestly delivered, not a
|
||||
* stub pretending to be retrieval.
|
||||
*
|
||||
* **The page boundary applies here exactly as it does to skills.** An agent
|
||||
* that does not cover the current page retrieves nothing — otherwise knowledge
|
||||
* would be the one door through which selecting an agent could reach material
|
||||
* the page was not offering, which is the failure the whole design exists to
|
||||
* prevent. Knowledge is scoped by the same rule as data and tools.
|
||||
*
|
||||
* The shape is what makes this replaceable rather than throwaway: passages come
|
||||
* back as `{documentId, chunkId, text, score}`, which is what a retrieval layer
|
||||
* returns. Moving to Postgres and pgvector later means reimplementing
|
||||
* `retrieveKnowledge` behind the same signature — a change of transport, not a
|
||||
* change of format, and the same discipline `base44Client.js` already applies
|
||||
* to the data layer.
|
||||
*/
|
||||
|
||||
/** Words too common to discriminate between one passage and another. */
|
||||
const STOP_WORDS = new Set([
|
||||
'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'be', 'been',
|
||||
'to', 'of', 'in', 'on', 'at', 'for', 'with', 'by', 'from', 'as', 'that', 'this',
|
||||
'it', 'its', 'we', 'our', 'us', 'you', 'your', 'i', 'me', 'my', 'what', 'which',
|
||||
'who', 'when', 'where', 'how', 'why', 'do', 'does', 'did', 'can', 'could',
|
||||
'should', 'would', 'will', 'about', 'says', 'say',
|
||||
]);
|
||||
|
||||
const terms = (text) =>
|
||||
String(text || '')
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((word) => word.length > 2 && !STOP_WORDS.has(word));
|
||||
|
||||
/**
|
||||
* One knowledge entry, split into passages.
|
||||
*
|
||||
* By sentence, because a knowledge entry is prose and a sentence is the
|
||||
* smallest piece of it that still means something on its own. A fixed-width
|
||||
* chunker would cut mid-clause and quote half a rule, which is worse than not
|
||||
* answering.
|
||||
*/
|
||||
function chunk(entry) {
|
||||
const body = String(entry.body || '').trim();
|
||||
if (!body) return [];
|
||||
|
||||
const sentences = body
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (sentences.length ? sentences : [body]).map((text, i) => ({
|
||||
documentId: entry.id,
|
||||
chunkId: `${entry.id}#${i}`,
|
||||
label: entry.label,
|
||||
kind: entry.kind,
|
||||
url: entry.url || '',
|
||||
text,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Passages from this agent's own knowledge that bear on the question.
|
||||
*
|
||||
* Returns `{ passages, source, available, note }`. `available: false` means
|
||||
* there is nothing to search — no agent, no entries, or an agent that does not
|
||||
* cover this page — and the note says which. A caller must render that rather
|
||||
* than treating an empty result as "the documents say nothing".
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Object|null} options.agent The active agent.
|
||||
* @param {string|null} options.contextId The page's assistant context.
|
||||
* @param {string} options.question What was asked.
|
||||
* @param {number} options.limit Most passages to return.
|
||||
*/
|
||||
/** @param {any} [options] */
|
||||
export function retrieveKnowledge({
|
||||
agent = null, contextId = null, question = '', limit = 3,
|
||||
} = {}) {
|
||||
const empty = (note) => ({
|
||||
passages: [], source: 'declared', available: false, note,
|
||||
});
|
||||
|
||||
if (!agent) return empty('No agent is active, so there is no knowledge to search.');
|
||||
|
||||
/* The page boundary. An agent constrained here contributes no knowledge, for
|
||||
the same reason it contributes no skills. */
|
||||
if (contextId && !agentCovers(agent, contextId)) {
|
||||
return empty(`${agent.name} does not cover this page, so its knowledge is not in reach here.`);
|
||||
}
|
||||
|
||||
const entries = (agent.knowledge || []).filter((entry) => entry.body || entry.url);
|
||||
if (!entries.length) {
|
||||
return empty(`${agent.name} has no knowledge attached.`);
|
||||
}
|
||||
|
||||
const wanted = terms(question);
|
||||
if (!wanted.length) {
|
||||
return {
|
||||
passages: [], source: 'declared', available: true,
|
||||
note: 'Ask about something specific and I will check this agent\'s knowledge.',
|
||||
};
|
||||
}
|
||||
|
||||
const passages = entries
|
||||
.flatMap(chunk)
|
||||
.map((passage) => {
|
||||
const words = new Set(terms(`${passage.label} ${passage.text}`));
|
||||
const hits = wanted.filter((word) => words.has(word));
|
||||
return { ...passage, score: hits.length, matched: hits };
|
||||
})
|
||||
/* A passage that matches nothing is not a weak answer, it is a different
|
||||
subject. Returning it would put unrelated prose under a question and
|
||||
let the reader assume it was relevant. */
|
||||
.filter((passage) => passage.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
|
||||
return {
|
||||
passages,
|
||||
source: 'declared',
|
||||
available: true,
|
||||
note: passages.length
|
||||
? null
|
||||
: `Nothing in ${agent.name}'s knowledge covers that.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a question can be answered from knowledge at all.
|
||||
*
|
||||
* Used to decide whether to say "no source is configured" or "the source has
|
||||
* nothing on this" — two different answers, and reporting the second when the
|
||||
* first is true would imply a corpus exists.
|
||||
*/
|
||||
export const hasKnowledge = (agent) =>
|
||||
Boolean(agent && (agent.knowledge || []).some((entry) => entry.body || entry.url));
|
||||
|
||||
/** The documents an agent carries, for a configuration screen. */
|
||||
export const knowledgeDocuments = (agent) =>
|
||||
(agent?.knowledge || []).map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
kind: entry.kind,
|
||||
url: entry.url || '',
|
||||
chunks: chunk(entry).length,
|
||||
}));
|
||||
276
src/lib/agents/registry.js
Normal file
276
src/lib/agents/registry.js
Normal file
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
allSkills, normalizeDefinition, parseFrontmatter, sectionBullets, sectionSource, sectionText,
|
||||
} from '@/lib/skills/registry';
|
||||
import { slugify } from '@/lib/skills/uiConfig';
|
||||
import { canonicalPage } from '@/lib/skills/surfaces';
|
||||
import { normalizeAgent } from './agentConfig';
|
||||
|
||||
/**
|
||||
* The agent registry.
|
||||
*
|
||||
* An agent is a Markdown file, for the same reason a skill is: frontmatter
|
||||
* declares what it is and what it carries, the body says how it should behave.
|
||||
* Nothing here executes Markdown — an agent names skill ids, and the skill
|
||||
* registry decides what those ids mean.
|
||||
*
|
||||
* **This is not a second parser.** `parseFrontmatter`, `normalizeDefinition`
|
||||
* and the three section readers are imported from the skill registry, so an
|
||||
* agent file and a skill file are read by exactly the same code and cannot
|
||||
* drift into two formats. The BOM, CRLF, blank-line and trailing-space
|
||||
* tolerance a skill definition gets is the tolerance an agent definition gets,
|
||||
* because it is the same function.
|
||||
*
|
||||
* Registration is the filesystem, as it is for skills: `import.meta.glob`
|
||||
* picks up everything under `src/agents/`, so a new agent is registered by
|
||||
* existing.
|
||||
*
|
||||
* What an agent may *do* with what it carries is `runtime.js`'s decision, and
|
||||
* the boundary it cannot cross is the page's — an agent contributes to the
|
||||
* disabled list and nothing else, so it can only ever narrow a page.
|
||||
*/
|
||||
|
||||
/* `import.meta.glob` is a Vite build-time API. `tsc` models only the standard
|
||||
`ImportMeta`, so it reports this as a missing property — a false positive
|
||||
rather than a defect. Suppressed rather than cast: a cast would assert a type
|
||||
nothing here can verify, and this file is the one place the glob appears. */
|
||||
// @ts-ignore -- Vite build-time API, absent from the standard ImportMeta type
|
||||
const FILES = import.meta.glob('/src/agents/**/*.md', { query: '?raw', import: 'default', eager: true });
|
||||
|
||||
/**
|
||||
* One Markdown definition → one agent.
|
||||
*
|
||||
* Shared by the files on disk and by anything authored at runtime, so an agent
|
||||
* written in the editor is parsed by the same code as a shipped one.
|
||||
*/
|
||||
export function parseAgent(raw, { path = 'custom', custom = false } = {}) {
|
||||
const { data, body } = parseFrontmatter(raw);
|
||||
|
||||
/**
|
||||
* The definition's id.
|
||||
*
|
||||
* `id:` when written, and it always wins — an id is the address skills,
|
||||
* subagents and stored preferences refer to, and deriving over the top of
|
||||
* one would silently rename an agent. Falling back to a slug of the name
|
||||
* matches what an author means by leaving it out; falling back to the
|
||||
* filename is right only for a file, which is why it is last.
|
||||
*/
|
||||
const id = data.id || slugify(data.name) || path.split('/').pop().replace(/\.md$/, '');
|
||||
|
||||
const { agent, errors } = normalizeAgent(data, {
|
||||
agentId: id,
|
||||
/* Instructions are the body's own section, not a frontmatter string: they
|
||||
are prose, and prose belongs under a heading where it can be written
|
||||
and read as prose. */
|
||||
body: sectionSource(body, 'Instructions') || '',
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
name: data.name || 'Untitled agent',
|
||||
description: data.description || '',
|
||||
...agent,
|
||||
/* What this definition lost on the way in. Carried on the record rather
|
||||
than thrown, so one bad entry costs its author that entry and a message
|
||||
instead of the whole file — and so `readAgentRegistry` can report it for
|
||||
shipped definitions too, which are otherwise never re-checked. */
|
||||
errors,
|
||||
/* What this agent is for, as bullets, for the switcher and the detail
|
||||
page. Read with the same section reader skills use. */
|
||||
purpose: sectionBullets(body, 'Purpose'),
|
||||
summary: sectionText(body, 'Purpose'),
|
||||
/* The definition as written. The editor edits this; everything else reads
|
||||
the parsed form, so there is one artefact behind all of them. */
|
||||
markdown: raw,
|
||||
source: custom ? 'account' : 'repository',
|
||||
path,
|
||||
body,
|
||||
custom,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every agent on disk, parsed once at module load. */
|
||||
export const AGENTS = Object.entries(FILES)
|
||||
.map(([path, raw]) => parseAgent(raw, { path }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/**
|
||||
* The registry as actually assembled, with everything that went wrong
|
||||
* assembling it.
|
||||
*
|
||||
* The same four failure modes the skill registry reports, because they are the
|
||||
* same four failures and all of them are silent:
|
||||
*
|
||||
* - `unreadable` — a stored definition that will not parse. Dropped without
|
||||
* a word, an agent simply stops existing and the list looks healthy.
|
||||
* - `incomplete` — a definition that parses but lost a field on the way in.
|
||||
* - `shadowed` — an account definition replacing a shipped one of the same
|
||||
* id. Intended, and indistinguishable from the shipped one having broken.
|
||||
* - `unattached` — an agent naming a skill id no registered skill carries.
|
||||
* This is the one that matters most here: a skill renamed or removed
|
||||
* leaves every agent that named it quietly carrying one capability fewer.
|
||||
*/
|
||||
export function readAgentRegistry(customSources = [], { customSkills = [] } = {}) {
|
||||
const diagnostics = [];
|
||||
const custom = [];
|
||||
|
||||
customSources.forEach((entry, i) => {
|
||||
const path = entry?.path || `custom/${i}.md`;
|
||||
let agent = null;
|
||||
try {
|
||||
agent = parseAgent(entry?.raw ?? entry, { path, custom: true });
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unreadable',
|
||||
path,
|
||||
agentId: null,
|
||||
message: `A stored agent could not be read and is not registered. ${error?.message || ''}`.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!agent?.id) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unreadable',
|
||||
path,
|
||||
agentId: null,
|
||||
message: 'A stored agent has no `id` and is not registered.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
custom.push(agent);
|
||||
});
|
||||
|
||||
const byId = new Map(AGENTS.map((a) => [a.id, a]));
|
||||
for (const agent of custom) {
|
||||
if (byId.has(agent.id) && !byId.get(agent.id).custom) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
kind: 'shadowed',
|
||||
path: agent.path,
|
||||
agentId: agent.id,
|
||||
message: `\`${agent.id}\` replaces the built-in agent of the same id. The built-in definition is not registered.`,
|
||||
});
|
||||
}
|
||||
byId.set(agent.id, agent);
|
||||
}
|
||||
|
||||
const agents = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/* A definition that lost part of itself on the way in. Reported for every
|
||||
agent, not only stored ones, so a file on disk that stops resolving after
|
||||
a vocabulary change is just as visible. */
|
||||
for (const agent of agents) {
|
||||
for (const message of agent.errors || []) {
|
||||
diagnostics.push({
|
||||
level: 'error', kind: 'incomplete', path: agent.path, agentId: agent.id, message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* Skills and subagents that do not resolve. Both are addresses, and an
|
||||
address that points at nothing is the failure this catches. */
|
||||
const skillIds = new Set(allSkills(customSkills).map((s) => s.id));
|
||||
const agentIds = new Set(agents.map((a) => a.id));
|
||||
|
||||
for (const agent of agents) {
|
||||
for (const skillId of agent.skills) {
|
||||
if (skillIds.has(skillId)) continue;
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unattached',
|
||||
path: agent.path,
|
||||
agentId: agent.id,
|
||||
message: `\`${agent.id}\` names the skill \`${skillId}\`, which no registered skill provides.`,
|
||||
});
|
||||
}
|
||||
for (const subId of agent.subagents) {
|
||||
if (agentIds.has(subId)) continue;
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unattached',
|
||||
path: agent.path,
|
||||
agentId: agent.id,
|
||||
message: `\`${agent.id}\` names the subagent \`${subId}\`, which no registered agent provides.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { agents, diagnostics };
|
||||
}
|
||||
|
||||
/** Every agent, shipped and stored. */
|
||||
export function allAgents(customSources = [], options = {}) {
|
||||
return readAgentRegistry(customSources, options).agents;
|
||||
}
|
||||
|
||||
/** Everything that went wrong assembling it, for the management page. */
|
||||
export function agentDiagnostics(customSources = [], options = {}) {
|
||||
return readAgentRegistry(customSources, options).diagnostics;
|
||||
}
|
||||
|
||||
/** One agent by id, or null. */
|
||||
export const getAgent = (agents, id) =>
|
||||
(agents || []).find((a) => a.id === id) || null;
|
||||
|
||||
/** The agents an author may actually pick: published, never archived. */
|
||||
export const publishedAgents = (agents = []) =>
|
||||
agents.filter((a) => a.status === 'published');
|
||||
|
||||
/**
|
||||
* Agents matching a search, over the fields a person would search by.
|
||||
*
|
||||
* An empty query is every agent rather than none — the switcher opens with the
|
||||
* box empty, and an empty list would read as "there are no agents".
|
||||
*/
|
||||
export function searchAgents(agents = [], query = '') {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
if (!q) return agents;
|
||||
return agents.filter((a) =>
|
||||
[a.name, a.description, a.trigger, ...(a.purpose || [])]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a definition before it is stored. Returns an error string or null.
|
||||
*
|
||||
* The order is the order an author would fix things in, which is what
|
||||
* `validateSkillSource` does and why this reads the same way.
|
||||
*
|
||||
* Deliberately *not* refused: an agent carrying no skills. Five of this
|
||||
* product's pages have no Owliver skills at all and answer from their own page
|
||||
* responder, so refusing a skill-less agent would mean inventing placeholder
|
||||
* skills to make those pages configurable. See the note in `agentConfig.js`.
|
||||
*/
|
||||
export function validateAgentSource(raw) {
|
||||
if (!String(raw ?? '').trim()) return 'Paste or upload a Markdown definition.';
|
||||
|
||||
let agent;
|
||||
try {
|
||||
agent = parseAgent(raw, { custom: true });
|
||||
} catch (error) {
|
||||
/* The YAML subset reports the line it failed on, which is far more useful
|
||||
than "could not be parsed". */
|
||||
return `That definition could not be parsed. ${error?.message || ''}`.trim();
|
||||
}
|
||||
|
||||
if (!agent.id) return 'The frontmatter needs an `id`.';
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(agent.id)) {
|
||||
return 'The `id` must be lower-case letters, numbers and dashes.';
|
||||
}
|
||||
if (!raw.includes('name:') || agent.name === 'Untitled agent') {
|
||||
return 'The frontmatter needs a `name`.';
|
||||
}
|
||||
if (!agent.pages.length) {
|
||||
return 'An agent needs at least one `pages:` entry, or it can never be offered anywhere.';
|
||||
}
|
||||
if (agent.errors?.length) return agent.errors[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Page keys an agent may cover, for the editor's own guidance. */
|
||||
export const agentPageKeys = (pages = []) =>
|
||||
pages.map((p) => canonicalPage(p) || p).filter(Boolean);
|
||||
388
src/lib/agents/runtime.js
Normal file
388
src/lib/agents/runtime.js
Normal file
@@ -0,0 +1,388 @@
|
||||
import { canonicalPage } from '@/lib/skills/surfaces';
|
||||
import { pageKeyForContext } from '@/lib/skills/registry';
|
||||
import { getAgent } from './registry';
|
||||
import { reasoningFor } from './vocabulary';
|
||||
|
||||
/**
|
||||
* The agent runtime.
|
||||
*
|
||||
* Its whole job is to *narrow*. The page decides what is in reach; an agent
|
||||
* decides how much of that reach to use, and can never extend it.
|
||||
*
|
||||
* Current PageContext
|
||||
* ↓
|
||||
* Agent ← this file
|
||||
* ↓
|
||||
* Agent Skills
|
||||
* ↓
|
||||
* Allowed Data / Knowledge / Tools
|
||||
* ↓
|
||||
* Owliver
|
||||
*
|
||||
* The narrowing is arithmetic rather than policy. `getSkillsForPage` filters on
|
||||
* the page *and* on a list of disabled ids in one expression
|
||||
* (`skills/registry.js`), so an agent participates only by adding ids to that
|
||||
* list. There is no code path by which adding an id can make a skill appear —
|
||||
* which is why "an agent cannot widen a page" is a property of the data flow
|
||||
* and not a rule someone has to remember to enforce.
|
||||
*
|
||||
* Everything downstream — knowledge, tools, the provider request — is derived
|
||||
* from the scoped skill list rather than from the agent directly, so each of
|
||||
* them inherits the same boundary without restating it.
|
||||
*
|
||||
* The stages below are named and individually callable. Today they are called
|
||||
* in order by the existing panel; a future orchestrator can drive them in a
|
||||
* different order without any of them changing, which is the whole reason they
|
||||
* are separate functions rather than one.
|
||||
*/
|
||||
|
||||
/* ── Scope ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The skill ids an agent carries, including one level of subagent.
|
||||
*
|
||||
* One level, with a visited set. A deeper walk would let a chain of agents
|
||||
* assemble a skill list nobody wrote down, and the cycle guard is not
|
||||
* optional — `readAgentRegistry` rejects self-reference, but A→B→A is only
|
||||
* caught here.
|
||||
*
|
||||
* A subagent that is not published contributes nothing: an archived or draft
|
||||
* agent has been taken out of service, and inheriting its skills through a
|
||||
* parent would put it back.
|
||||
*/
|
||||
export function agentSkillIds(agent, agents = []) {
|
||||
if (!agent) return [];
|
||||
|
||||
const ids = new Set(agent.skills || []);
|
||||
const seen = new Set([agent.id]);
|
||||
|
||||
for (const subId of agent.subagents || []) {
|
||||
if (seen.has(subId)) continue;
|
||||
seen.add(subId);
|
||||
const sub = getAgent(agents, subId);
|
||||
if (!sub || sub.status !== 'published') continue;
|
||||
for (const id of sub.skills || []) ids.add(id);
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* The disabled list an agent implies: everything it does not carry.
|
||||
*
|
||||
* **This is the entire mechanism.** Callers pass the result wherever
|
||||
* `disabledSkills` already goes — `skillsForContext`, `matchSkill`,
|
||||
* `owliverSuggestions`, `resolveIntent` — and the page filter does the rest.
|
||||
*
|
||||
* With no agent the input is returned unchanged, so "no agent selected" is
|
||||
* byte-for-byte the behaviour the product had before any of this existed. That
|
||||
* is asserted in `skill-check.mjs` rather than assumed.
|
||||
*/
|
||||
export function agentScopedDisabled(agent, skills = [], disabled = []) {
|
||||
if (!agent) return disabled;
|
||||
|
||||
const carried = new Set(agentSkillIds(agent, []));
|
||||
const withheld = skills
|
||||
.map((s) => (typeof s === 'string' ? s : s.id))
|
||||
.filter((id) => id && !carried.has(id));
|
||||
|
||||
return [...new Set([...disabled, ...withheld])];
|
||||
}
|
||||
|
||||
/** The same, with subagents resolved against the full registry. */
|
||||
export function agentScopedDisabledWith(agent, agents, skills = [], disabled = []) {
|
||||
if (!agent) return disabled;
|
||||
|
||||
const carried = new Set(agentSkillIds(agent, agents));
|
||||
const withheld = skills
|
||||
.map((s) => (typeof s === 'string' ? s : s.id))
|
||||
.filter((id) => id && !carried.has(id));
|
||||
|
||||
return [...new Set([...disabled, ...withheld])];
|
||||
}
|
||||
|
||||
/* ── Coverage ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Does this agent cover the page behind this assistant context? */
|
||||
export function agentCovers(agent, contextId) {
|
||||
if (!agent || !contextId) return false;
|
||||
const pageKey = pageKeyForContext(contextId);
|
||||
if (!pageKey) return false;
|
||||
const wanted = canonicalPage(pageKey) || pageKey;
|
||||
return (agent.pages || []).some((p) => (canonicalPage(p) || p) === wanted);
|
||||
}
|
||||
|
||||
/** Published agents covering this page, most specific first. */
|
||||
export function agentsForContext(agents = [], contextId) {
|
||||
return agents
|
||||
.filter((a) => a.status === 'published' && agentCovers(a, contextId))
|
||||
/* Fewest pages first: a page's own agent is more specific than the root,
|
||||
and specificity is what makes it the sensible default. */
|
||||
.sort((a, b) => a.pages.length - b.pages.length || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* The general agent every page falls back to.
|
||||
*
|
||||
* Named once, here, because two different things need it and neither should
|
||||
* carry its own copy: resolving a default, and deciding whether a page has an
|
||||
* agent *of its own*.
|
||||
*/
|
||||
export const FALLBACK_AGENT_ID = 'krow-workforce-agent';
|
||||
|
||||
/**
|
||||
* The agent written *for* this page, if there is one.
|
||||
*
|
||||
* The general agent is deliberately excluded. It covers every surface — which
|
||||
* is what makes it a fallback — so counting it as a page's own agent would make
|
||||
* "does this page have a native agent?" true everywhere and the distinction
|
||||
* meaningless.
|
||||
*
|
||||
* Returns null on a page nobody wrote an agent for. That is a normal state, not
|
||||
* a broken one: see `resolveDefaultAgent`.
|
||||
*/
|
||||
export function nativeAgentForContext(agents = [], contextId) {
|
||||
return agentsForContext(agents, contextId).find((a) => a.id !== FALLBACK_AGENT_ID) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The general agent, when it can answer here.
|
||||
*
|
||||
* Falls through to whichever published agent covers the page if the general one
|
||||
* has been archived or does not list this surface — a page must never be left
|
||||
* without an agent because of how the registry happens to be configured.
|
||||
*/
|
||||
export function fallbackAgentForContext(agents = [], contextId) {
|
||||
const general = getAgent(agents, FALLBACK_AGENT_ID);
|
||||
if (general && general.status === 'published' && agentCovers(general, contextId)) return general;
|
||||
return agentsForContext(agents, contextId)[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent a page opens with when nobody has chosen one.
|
||||
*
|
||||
* Two modes, and the second is the one that was missing:
|
||||
*
|
||||
* 1. **The page has an agent of its own** — Positions, Analytics, Activity and
|
||||
* the five others. That agent answers, because its instructions and skills
|
||||
* were written for this page.
|
||||
* 2. **The page has none** — Settings, the workspace surfaces, Agent
|
||||
* Configure. The *general* agent answers.
|
||||
*
|
||||
* "No native agent" is not "no Owliver". A page without a specialist is a page
|
||||
* the general agent handles, exactly as Owliver handled every page before
|
||||
* specialists existed. Nothing here can leave a page agent-less, and
|
||||
* `skill-check` asserts the general agent covers every surface a skill may name,
|
||||
* so mode 2 always has something to resolve to.
|
||||
*/
|
||||
export function resolveDefaultAgent(agents = [], contextId) {
|
||||
return nativeAgentForContext(agents, contextId) || fallbackAgentForContext(agents, contextId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent a page opens with. Kept as the name every existing caller uses.
|
||||
*
|
||||
* Behaviourally identical to what it did before — on a page with its own agent
|
||||
* that agent is both "first by specificity" and "the native one" — but it now
|
||||
* says *why* it returns what it returns.
|
||||
*/
|
||||
export function defaultAgentForContext(agents = [], contextId) {
|
||||
return resolveDefaultAgent(agents, contextId);
|
||||
}
|
||||
|
||||
/* ── Selection ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Whether a chosen agent still applies where the reader is now.
|
||||
*
|
||||
* A selection is made *somewhere*. Carrying only its id meant a choice made on
|
||||
* one page followed the reader onto every other one, so choosing the Positions
|
||||
* Agent on Positions and then opening Settings left Settings constrained by an
|
||||
* agent nobody had chosen for it — the page looked broken, and the reason was
|
||||
* invisible.
|
||||
*
|
||||
* So a selection carries the context it was made on, and three cases fall out:
|
||||
*
|
||||
* - **It covers this page.** It applies. This is a selection working as
|
||||
* intended, and it survives navigation across every page it covers.
|
||||
* - **It does not cover this page, but this is where it was chosen.** It
|
||||
* applies, constrained — the reader picked a specialist here on purpose and
|
||||
* is owed the honest "this agent does not cover this page" rather than a
|
||||
* silent swap.
|
||||
* - **It does not cover this page and was chosen elsewhere.** It is stale.
|
||||
* It is retired, and the page resolves its own default.
|
||||
*
|
||||
* `retire` rather than "ignore for now": a constrained choice that the reader
|
||||
* has navigated away from is spent. Keeping it would mean returning to that page
|
||||
* later and finding it constrained by a decision made in a different session of
|
||||
* attention.
|
||||
*
|
||||
* Pure, and takes the selection as a value, so the whole rule is testable
|
||||
* without a browser, a router or a React tree.
|
||||
*/
|
||||
export function resolveSelection(agents = [], selection = null, contextId = null) {
|
||||
/* A bare id is accepted so an account-level default — which was never chosen
|
||||
on any page — can be resolved by the same rule. */
|
||||
const id = typeof selection === 'string' ? selection : selection?.id || null;
|
||||
const chosenOn = typeof selection === 'string' ? null : selection?.contextId || null;
|
||||
|
||||
if (!id) return { id: null, covers: false, retire: false };
|
||||
|
||||
const agent = getAgent(agents, id);
|
||||
/* An agent that no longer exists — deleted, or a stored id from an older
|
||||
registry. Nothing to apply and nothing worth keeping. */
|
||||
if (!agent) return { id: null, covers: false, retire: true };
|
||||
|
||||
if (agentCovers(agent, contextId)) return { id, covers: true, retire: false };
|
||||
if (chosenOn && chosenOn === contextId) return { id, covers: false, retire: false };
|
||||
|
||||
return { id: null, covers: false, retire: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which agent will actually answer, and why.
|
||||
*
|
||||
* Returns the requested agent even when it does not cover the page, together
|
||||
* with `covers: false` and the page's native agent as `suggestion`. Silently
|
||||
* swapping in a different agent would be worse than the honest answer: the
|
||||
* reader chose one, and a panel that quietly answers as another is lying about
|
||||
* which it is.
|
||||
*/
|
||||
export function resolveAgentForTurn(agents = [], activeId, contextId) {
|
||||
const requested = activeId ? getAgent(agents, activeId) : null;
|
||||
const native = defaultAgentForContext(agents, contextId);
|
||||
|
||||
if (!requested) return { agent: native, covers: Boolean(native), requested: null, suggestion: null };
|
||||
|
||||
const covers = agentCovers(requested, contextId);
|
||||
return {
|
||||
agent: requested,
|
||||
covers,
|
||||
requested,
|
||||
suggestion: covers ? null : native,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Starters ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The chips this agent offers, in the shape the existing `PromptChips` reads.
|
||||
*
|
||||
* An agent that does not cover the page offers none: a starter is a promise
|
||||
* that the question will be answered here, and it would not be.
|
||||
*/
|
||||
export function agentStarters(agent, contextId = null) {
|
||||
if (!agent) return [];
|
||||
if (contextId && !agentCovers(agent, contextId)) return [];
|
||||
|
||||
return (agent.starters || []).map((starter) => ({
|
||||
label: starter.label,
|
||||
prompt: starter.prompt || starter.label,
|
||||
/* No capability: a starter is a question, and which skill answers it is
|
||||
decided by the same matcher that handles anything typed. Naming one here
|
||||
would let an agent address a skill the page has not offered. */
|
||||
capability: null,
|
||||
source: 'agent',
|
||||
}));
|
||||
}
|
||||
|
||||
/* ── Question classification ────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Words that ask what a document says rather than what the records show.
|
||||
*
|
||||
* Deliberately narrow. Misreading a structured question as a knowledge one
|
||||
* costs the reader a real answer and replaces it with a policy quotation, which
|
||||
* is a worse failure than the reverse — so anything ambiguous stays structured.
|
||||
*/
|
||||
const KNOWLEDGE_TERMS = [
|
||||
'policy', 'policies', 'procedure', 'guideline', 'guidelines', 'handbook',
|
||||
'rule', 'rules', 'documentation', 'what does it say', 'according to',
|
||||
'are we allowed', 'am i allowed', 'supposed to',
|
||||
];
|
||||
|
||||
/** Words that ask for a figure out of the records. */
|
||||
const STRUCTURED_TERMS = [
|
||||
'how many', 'how much', 'count', 'total', 'average', 'rate', 'trend',
|
||||
'compare', 'list', 'show me', 'who', 'which', 'when', 'breakdown', 'summary',
|
||||
'exceeded', 'more than', 'less than', 'over', 'under',
|
||||
];
|
||||
|
||||
/**
|
||||
* Whole-word matching, not substring.
|
||||
*
|
||||
* `includes` is wrong here and wrong in a way that is hard to see: "overtime"
|
||||
* contains "over", so "what does our overtime policy say?" matched a
|
||||
* comparison term and was classified as needing records. A question about a
|
||||
* document would have been answered with a table.
|
||||
*
|
||||
* Word boundaries on both ends, so a phrase still matches inside a sentence but
|
||||
* a term never matches inside a longer word.
|
||||
*/
|
||||
const hasAny = (text, terms) => terms.some((term) => {
|
||||
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`\\b${escaped}\\b`).test(text);
|
||||
});
|
||||
|
||||
/**
|
||||
* Which sources a question needs.
|
||||
*
|
||||
* Three answers, and the distinction matters because they read different
|
||||
* things:
|
||||
*
|
||||
* - `structured` — "which employees worked more than 20 overtime hours" is a
|
||||
* query over records. It goes to the data resolvers. **Never** to retrieval:
|
||||
* Krow's operational records are not embedded, and answering this from
|
||||
* prose would produce a confident number nobody can trace.
|
||||
* - `knowledge` — "what does our overtime policy say" is a question about a
|
||||
* document.
|
||||
* - `combined` — "which employees exceeded the overtime policy this month"
|
||||
* needs both, and the runtime composes them.
|
||||
*
|
||||
* Keyword matching, like every other matcher in this product: the answers are
|
||||
* computed locally and deterministically, so the routing has to be inspectable
|
||||
* in the same way.
|
||||
*/
|
||||
export function classifyQuestion({ question = '' } = {}) {
|
||||
const text = String(question).toLowerCase();
|
||||
const knowledge = hasAny(text, KNOWLEDGE_TERMS);
|
||||
const structured = hasAny(text, STRUCTURED_TERMS);
|
||||
|
||||
if (knowledge && structured) return 'combined';
|
||||
if (knowledge) return 'knowledge';
|
||||
return 'structured';
|
||||
}
|
||||
|
||||
/* ── Reasoning ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* How much work this turn is worth, as a number.
|
||||
*
|
||||
* Read off the agent's declared mode so the runtime never branches on a mode
|
||||
* name. `balanced` is the default and is deliberately today's behaviour, so an
|
||||
* agent that says nothing about reasoning answers exactly as the panel does now.
|
||||
*/
|
||||
export function reasoningDepth(agent) {
|
||||
return reasoningFor(agent?.reasoning)?.depth ?? 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the runtime tells the provider about the agent.
|
||||
*
|
||||
* Deliberately small and serializable: an id, the instructions, the mode. Not
|
||||
* the skill list, and not the records — the provider is handed what the agent
|
||||
* *is*, and the data it may read has already been decided by the page.
|
||||
*/
|
||||
export function agentRequest(agent, contextId = null) {
|
||||
if (!agent) return null;
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
instructions: agent.instructions || '',
|
||||
trigger: agent.trigger || '',
|
||||
reasoning: agent.reasoning,
|
||||
depth: reasoningDepth(agent),
|
||||
webSearch: Boolean(agent.webSearch),
|
||||
covers: contextId ? agentCovers(agent, contextId) : true,
|
||||
};
|
||||
}
|
||||
137
src/lib/agents/useAgents.js
Normal file
137
src/lib/agents/useAgents.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { reportSave } from '@/lib/skills/saveFeedback';
|
||||
import { AGENTS, parseAgent, readAgentRegistry, validateAgentSource } from './registry';
|
||||
import { customAgentSource, removeCustomAgent, upsertCustomAgent } from './customAgents';
|
||||
import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agentLifecycle';
|
||||
|
||||
/**
|
||||
* Reading and writing agents, in one place.
|
||||
*
|
||||
* Every screen that changes an agent goes through here. That is deliberate: the
|
||||
* list can publish, the detail page can publish, and two implementations of
|
||||
* "publish" would eventually disagree about what publishing means.
|
||||
*
|
||||
* Storage is the account's preferences, holding **Markdown** — the same
|
||||
* artefact a file in `src/agents/` is. The forms never expose it: a person
|
||||
* fills in fields, `agentPatch` writes them into frontmatter, and the parser
|
||||
* reads them back. Markdown stays the definition format without ever being
|
||||
* something an HR user has to see.
|
||||
*
|
||||
* Editing a shipped agent writes an account definition of the same id, which
|
||||
* the registry reports as `shadowed`. That is the existing override mechanism,
|
||||
* not a new one — a shipped agent is never mutated on disk.
|
||||
*/
|
||||
export function useAgents() {
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
|
||||
const stored = useMemo(() => preferences.customAgents || [], [preferences.customAgents]);
|
||||
const customSkills = useMemo(() => preferences.customSkills || [], [preferences.customSkills]);
|
||||
|
||||
const { agents, diagnostics } = useMemo(
|
||||
() => readAgentRegistry(stored, { customSkills }),
|
||||
[stored, customSkills]
|
||||
);
|
||||
|
||||
/** Whether this id is shipped with the product rather than authored here. */
|
||||
const isShipped = useCallback((id) => AGENTS.some((a) => a.id === id), []);
|
||||
|
||||
/** Whether the account has its own definition for this id. */
|
||||
const isOverridden = useCallback((id) => customAgentSource(stored, id) !== null, [stored]);
|
||||
|
||||
/**
|
||||
* The Markdown behind an agent.
|
||||
*
|
||||
* The account's own copy when there is one, otherwise the shipped definition —
|
||||
* so editing a shipped agent starts from what it actually says rather than
|
||||
* from a blank.
|
||||
*/
|
||||
const sourceFor = useCallback(
|
||||
(id) => customAgentSource(stored, id) || AGENTS.find((a) => a.id === id)?.markdown || null,
|
||||
[stored]
|
||||
);
|
||||
|
||||
/**
|
||||
* Writes a definition.
|
||||
*
|
||||
* Validated first and refused with a message rather than stored broken: a
|
||||
* definition that cannot be read is an agent that silently stops existing.
|
||||
* Returns `{ ok, error }` so a form can stay on screen and say why.
|
||||
*/
|
||||
const save = useCallback(async (/** @type {string} */ source, /** @type {any} */ { message } = {}) => {
|
||||
const problem = validateAgentSource(source);
|
||||
if (problem) return { ok: false, error: problem };
|
||||
|
||||
const { agent, next } = upsertCustomAgent(stored, source);
|
||||
await updatePreferences.mutateAsync(
|
||||
{ customAgents: next },
|
||||
reportSave(message || `${agent.name} saved`)
|
||||
);
|
||||
return { ok: true, agent };
|
||||
}, [stored, updatePreferences]);
|
||||
|
||||
/** Removes the account's definition. A shipped agent returns to its shipped form. */
|
||||
const remove = useCallback(async (id) => {
|
||||
const next = removeCustomAgent(stored, id);
|
||||
await updatePreferences.mutateAsync(
|
||||
{ customAgents: next },
|
||||
reportSave(isShipped(id) ? 'Reverted to the shipped definition' : 'Agent removed')
|
||||
);
|
||||
return { ok: true };
|
||||
}, [stored, updatePreferences, isShipped]);
|
||||
|
||||
/**
|
||||
* Publishes, refusing to overwrite a newer published version.
|
||||
*
|
||||
* Returns `{ ok: false, conflict }` when the stored definition has moved on,
|
||||
* so the screen can say what would be lost instead of losing it.
|
||||
*/
|
||||
const publish = useCallback(async (id) => {
|
||||
const source = sourceFor(id);
|
||||
if (!source) return { ok: false, error: 'That agent has no definition to publish.' };
|
||||
|
||||
const live = agents.find((a) => a.id === id);
|
||||
const result = publishAgent(source, {
|
||||
publishedVersion: live?.status === 'published' ? live.version : 0,
|
||||
});
|
||||
if (result.conflict) return { ok: false, conflict: result.conflict };
|
||||
|
||||
return save(result.source, { message: `${live?.name || id} published` });
|
||||
}, [sourceFor, agents, save]);
|
||||
|
||||
const archive = useCallback(async (id) => {
|
||||
const source = sourceFor(id);
|
||||
if (!source) return { ok: false, error: 'That agent has no definition to archive.' };
|
||||
return save(archiveAgent(source), { message: `${agents.find((a) => a.id === id)?.name || id} archived` });
|
||||
}, [sourceFor, agents, save]);
|
||||
|
||||
const restore = useCallback(async (id) => {
|
||||
const source = sourceFor(id);
|
||||
if (!source) return { ok: false, error: 'That agent has no definition to restore.' };
|
||||
return save(restoreAgent(source), { message: 'Restored as a draft' });
|
||||
}, [sourceFor, save]);
|
||||
|
||||
const duplicate = useCallback(async (id) => {
|
||||
const source = sourceFor(id);
|
||||
if (!source) return { ok: false, error: 'That agent has no definition to copy.' };
|
||||
const copy = duplicateAgent(source, { existingIds: agents.map((a) => a.id) });
|
||||
const result = await save(copy, { message: 'Copy created as a draft' });
|
||||
return result.ok ? { ...result, id: parseAgent(copy, { custom: true }).id } : result;
|
||||
}, [sourceFor, agents, save]);
|
||||
|
||||
return {
|
||||
agents,
|
||||
diagnostics,
|
||||
saving: updatePreferences.isPending,
|
||||
isShipped,
|
||||
isOverridden,
|
||||
sourceFor,
|
||||
save,
|
||||
remove,
|
||||
publish,
|
||||
archive,
|
||||
restore,
|
||||
duplicate,
|
||||
};
|
||||
}
|
||||
117
src/lib/agents/vocabulary.js
Normal file
117
src/lib/agents/vocabulary.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The closed vocabulary an agent definition is allowed to use.
|
||||
*
|
||||
* This file is to agents what `surfaces.js` is to skills: a hand-written table
|
||||
* of every value a definition may name, and nothing else. An agent definition
|
||||
* is configuration, and the way it stays configuration is that nothing here is
|
||||
* looked up dynamically, evaluated, or turned into a component — a definition
|
||||
* names a key, and this file answers whether that key exists.
|
||||
*
|
||||
* Icons are ids only. The id → component table lives beside the components
|
||||
* that draw them, for the same reason `SECTION_COMPONENTS` does: a definition
|
||||
* must never be able to reach a component nobody wrote down.
|
||||
*/
|
||||
|
||||
/* ── Lifecycle ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Where an agent is in its life.
|
||||
*
|
||||
* `draft` is the default for anything authored, so creating an agent never
|
||||
* publishes one. Only `published` agents are offered in the switcher or
|
||||
* resolved as a page's native agent — an archived agent keeps its definition
|
||||
* and stops answering.
|
||||
*/
|
||||
export const AGENT_STATUSES = ['draft', 'published', 'archived'];
|
||||
|
||||
export const DEFAULT_AGENT_STATUS = 'draft';
|
||||
|
||||
/* ── Reasoning ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* How much work an answer is worth.
|
||||
*
|
||||
* `depth` is the only thing the runtime reads, so a mode is a number with a
|
||||
* name rather than a branch: adding one is a row here, not a condition
|
||||
* somewhere else. `balanced` is the default and is deliberately today's
|
||||
* behaviour, so an agent that says nothing about reasoning answers exactly as
|
||||
* the panel does now.
|
||||
*/
|
||||
export const REASONING_MODES = [
|
||||
{
|
||||
id: 'fast',
|
||||
label: 'Fast',
|
||||
depth: 1,
|
||||
summary: 'Use for simple questions and quick summaries.',
|
||||
},
|
||||
{
|
||||
id: 'balanced',
|
||||
label: 'Balanced',
|
||||
depth: 2,
|
||||
summary: 'Default mode for normal workforce analysis.',
|
||||
},
|
||||
{
|
||||
id: 'deep',
|
||||
label: 'Deep',
|
||||
depth: 3,
|
||||
summary: 'Use for complex multi-source analysis.',
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORTED_REASONING = REASONING_MODES.map((m) => m.id);
|
||||
export const DEFAULT_REASONING = 'balanced';
|
||||
|
||||
export const reasoningFor = (id) =>
|
||||
REASONING_MODES.find((m) => m.id === String(id || '').trim()) || null;
|
||||
|
||||
export const reasoningLabel = (id) => reasoningFor(id)?.label || id;
|
||||
|
||||
/* ── Knowledge ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The kinds of thing an agent can be told, as distinct from what it can *do*.
|
||||
*
|
||||
* Knowledge is reference material an author wrote down; a skill is a capability
|
||||
* that reads live records. Keeping the two vocabularies apart is what stops a
|
||||
* knowledge entry being mistaken for a computed figure — see `knowledge.js`.
|
||||
*/
|
||||
export const KNOWLEDGE_KINDS = ['note', 'link', 'skill-reference'];
|
||||
|
||||
export const DEFAULT_KNOWLEDGE_KIND = 'note';
|
||||
|
||||
/* ── Permissions ────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Who may reach an agent. */
|
||||
export const AGENT_ACCESS = ['all', 'specific'];
|
||||
|
||||
export const DEFAULT_AGENT_ACCESS = 'all';
|
||||
|
||||
/** What a named person may do with it. */
|
||||
export const PERMISSION_ROLES = ['manager', 'editor', 'viewer'];
|
||||
|
||||
export const DEFAULT_PERMISSION_ROLE = 'viewer';
|
||||
|
||||
/* ── Icons ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The icons an agent may name.
|
||||
*
|
||||
* Ids, not components. Deliberately small — an agent is identified by its name
|
||||
* first, and a long list only makes two agents easier to confuse.
|
||||
*/
|
||||
export const AGENT_ICONS = [
|
||||
'owliver',
|
||||
'sparkles',
|
||||
'briefcase',
|
||||
'users',
|
||||
'user-check',
|
||||
'layers',
|
||||
'graduation-cap',
|
||||
'bar-chart',
|
||||
'activity',
|
||||
'shield',
|
||||
];
|
||||
|
||||
export const DEFAULT_AGENT_ICON = 'owliver';
|
||||
|
||||
export const isAgentIcon = (id) => AGENT_ICONS.includes(String(id || '').trim());
|
||||
389
src/lib/attendance.js
Normal file
389
src/lib/attendance.js
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Attendance and overtime, computed from shift records.
|
||||
*
|
||||
* Pure functions over the `ShiftRecord` collection: no fetching, no React, no
|
||||
* knowledge of who is asking. The Owliver resolvers call these, and so could a
|
||||
* page — which is the point, because a card and an answer that compute the
|
||||
* same figure two different ways will eventually disagree.
|
||||
*
|
||||
* Nothing here is a canned response. Every function returns figures, and the
|
||||
* wording is the caller's problem. That is what stops "Attendance Analysis"
|
||||
* from being a fixed paragraph with numbers dropped into it.
|
||||
*
|
||||
* "Department" is `role_category`, the same field `lib/hiringRecords.js` joins
|
||||
* a hire to its posting for — so a department means one thing across hiring,
|
||||
* analytics and attendance.
|
||||
*/
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
/** A shift that was actually worked, in whole or in part. */
|
||||
export const wasWorked = (shift) => shift?.status !== 'absent' && shift?.status !== 'no_show';
|
||||
|
||||
/** A shift that was missed, however it was missed. */
|
||||
export const wasMissed = (shift) => shift?.status === 'absent' || shift?.status === 'no_show';
|
||||
|
||||
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
|
||||
const round1 = (n) => Math.round(n * 10) / 10;
|
||||
|
||||
/** A percentage of a total, or 0 when there is no total to be a share of. */
|
||||
export const rate = (part, total) => (total ? Math.round((part / total) * 100) : 0);
|
||||
|
||||
/**
|
||||
* The headline attendance figures for a set of shifts.
|
||||
*
|
||||
* `attendanceRate` counts shifts *turned up for*, late or not — being late is a
|
||||
* punctuality problem, not an absence, and folding the two together would make
|
||||
* a reliably-late team look absent and a genuinely absent one look better than
|
||||
* it is. Punctuality is reported separately for the same reason.
|
||||
*/
|
||||
export function attendanceSummary(shifts = []) {
|
||||
const scheduled = shifts.length;
|
||||
const worked = shifts.filter(wasWorked);
|
||||
const late = shifts.filter((s) => s.status === 'late');
|
||||
const absent = shifts.filter((s) => s.status === 'absent');
|
||||
const noShow = shifts.filter((s) => s.status === 'no_show');
|
||||
|
||||
return {
|
||||
scheduled,
|
||||
worked: worked.length,
|
||||
late: late.length,
|
||||
absent: absent.length,
|
||||
noShow: noShow.length,
|
||||
missed: absent.length + noShow.length,
|
||||
attendanceRate: rate(worked.length, scheduled),
|
||||
punctualityRate: rate(worked.length - late.length, scheduled),
|
||||
/* Minutes lost to late arrivals, which is the figure a manager can act on —
|
||||
"four late arrivals" says nothing about whether it cost ten minutes or
|
||||
two hours. */
|
||||
minutesLate: sum(shifts.map((s) => s.minutes_late || 0)),
|
||||
hoursScheduled: round1(sum(shifts.map((s) => s.scheduled_hours || 0))),
|
||||
hoursWorked: round1(sum(shifts.map((s) => s.actual_hours || 0))),
|
||||
empty: scheduled === 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** The headline overtime figures for a set of shifts. */
|
||||
export function overtimeSummary(shifts = []) {
|
||||
const withOvertime = shifts.filter((s) => (s.overtime_hours || 0) > 0);
|
||||
const hours = round1(sum(shifts.map((s) => s.overtime_hours || 0)));
|
||||
const scheduledHours = round1(sum(shifts.map((s) => s.scheduled_hours || 0)));
|
||||
|
||||
return {
|
||||
shifts: shifts.length,
|
||||
shiftsWithOvertime: withOvertime.length,
|
||||
hours,
|
||||
hoursScheduled: scheduledHours,
|
||||
hoursWorked: round1(sum(shifts.map((s) => s.actual_hours || 0))),
|
||||
/* Overtime as a share of scheduled time: 40 hours means one thing across a
|
||||
fortnight and another across a year, and the ratio is what makes the two
|
||||
comparable. */
|
||||
overtimeShare: scheduledHours ? Math.round((hours / scheduledHours) * 100) : 0,
|
||||
averagePerShift: shifts.length ? round1(hours / shifts.length) : 0,
|
||||
empty: shifts.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Shifts grouped by a key, as `[key, shifts]` pairs — most shifts first. */
|
||||
function groupBy(shifts, key) {
|
||||
const groups = new Map();
|
||||
for (const shift of shifts) {
|
||||
const value = shift?.[key] || '—';
|
||||
if (!groups.has(value)) groups.set(value, []);
|
||||
groups.get(value).push(shift);
|
||||
}
|
||||
return [...groups.entries()].sort((a, b) => b[1].length - a[1].length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-worker attendance, worst attendance first.
|
||||
*
|
||||
* Ordered by who needs attention rather than alphabetically: a list of people
|
||||
* sorted by name buries the one person the reader opened it for.
|
||||
*/
|
||||
export function attendanceByWorker(shifts = []) {
|
||||
return groupBy(shifts, 'worker_name')
|
||||
.map(([name, theirs]) => ({
|
||||
id: theirs[0].staff_id,
|
||||
name,
|
||||
email: theirs[0].worker_email,
|
||||
role: theirs[0].role,
|
||||
department: theirs[0].role_category,
|
||||
...attendanceSummary(theirs),
|
||||
}))
|
||||
.sort((a, b) => a.attendanceRate - b.attendanceRate || b.minutesLate - a.minutesLate);
|
||||
}
|
||||
|
||||
/** Per-worker overtime, most overtime first. */
|
||||
export function overtimeByWorker(shifts = []) {
|
||||
return groupBy(shifts, 'worker_name')
|
||||
.map(([name, theirs]) => ({
|
||||
id: theirs[0].staff_id,
|
||||
name,
|
||||
email: theirs[0].worker_email,
|
||||
role: theirs[0].role,
|
||||
department: theirs[0].role_category,
|
||||
...overtimeSummary(theirs),
|
||||
}))
|
||||
.sort((a, b) => b.hours - a.hours);
|
||||
}
|
||||
|
||||
/** Per-department attendance, worst attendance first. */
|
||||
export function attendanceByDepartment(shifts = []) {
|
||||
return groupBy(shifts, 'role_category')
|
||||
.map(([department, theirs]) => ({
|
||||
id: department,
|
||||
name: department,
|
||||
department,
|
||||
people: new Set(theirs.map((s) => s.staff_id)).size,
|
||||
...attendanceSummary(theirs),
|
||||
}))
|
||||
.sort((a, b) => a.attendanceRate - b.attendanceRate);
|
||||
}
|
||||
|
||||
/** Per-department overtime, most overtime first. */
|
||||
export function overtimeByDepartment(shifts = []) {
|
||||
return groupBy(shifts, 'role_category')
|
||||
.map(([department, theirs]) => ({
|
||||
id: department,
|
||||
name: department,
|
||||
department,
|
||||
people: new Set(theirs.map((s) => s.staff_id)).size,
|
||||
...overtimeSummary(theirs),
|
||||
}))
|
||||
.sort((a, b) => b.hours - a.hours);
|
||||
}
|
||||
|
||||
/* ── Trends ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const startOfDay = (d) => {
|
||||
const copy = new Date(d);
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
return copy;
|
||||
};
|
||||
|
||||
/** The Monday of the week a date falls in. */
|
||||
function startOfWeek(date) {
|
||||
const day = startOfDay(date);
|
||||
const weekday = (day.getDay() + 6) % 7;
|
||||
return new Date(day.getTime() - weekday * 24 * HOUR_MS);
|
||||
}
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const isoDay = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
|
||||
/**
|
||||
* Attendance and overtime week by week, oldest first.
|
||||
*
|
||||
* Whole weeks rather than rolling days, because shift patterns *are* weekly —
|
||||
* a seven-day rolling window over a Monday-to-Friday rota reports a different
|
||||
* denominator depending on which day you happen to read it.
|
||||
*
|
||||
* Weeks with no shifts scheduled are dropped rather than reported as 0%
|
||||
* attendance: nobody failed to attend a week they were never rostered for, and
|
||||
* a zero there would read as a catastrophe.
|
||||
*/
|
||||
export function weeklyTrend(shifts = [], { weeks = 8, now = new Date() } = {}) {
|
||||
const buckets = new Map();
|
||||
|
||||
for (const shift of shifts) {
|
||||
const at = new Date(shift.created_date || shift.scheduled_start || 0);
|
||||
if (Number.isNaN(at.getTime())) continue;
|
||||
const key = isoDay(startOfWeek(at));
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key).push(shift);
|
||||
}
|
||||
|
||||
const thisWeek = startOfWeek(now);
|
||||
|
||||
return [...buckets.entries()]
|
||||
.filter(([key]) => {
|
||||
const weeksBack = Math.round((thisWeek.getTime() - new Date(key).getTime()) / (7 * 24 * HOUR_MS));
|
||||
return weeksBack >= 0 && weeksBack < weeks;
|
||||
})
|
||||
.sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime())
|
||||
.map(([key, theirs]) => {
|
||||
const attendance = attendanceSummary(theirs);
|
||||
const overtime = overtimeSummary(theirs);
|
||||
return {
|
||||
id: key,
|
||||
weekStart: key,
|
||||
label: new Date(key).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
scheduled: attendance.scheduled,
|
||||
worked: attendance.worked,
|
||||
missed: attendance.missed,
|
||||
late: attendance.late,
|
||||
attendanceRate: attendance.attendanceRate,
|
||||
overtimeHours: overtime.hours,
|
||||
hoursWorked: attendance.hoursWorked,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Anomalies ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* How many recent weeks count as "now" when comparing against what came before.
|
||||
* Two, because one week is noise — a single bad week is a bad week, and it takes
|
||||
* a second to be a direction.
|
||||
*/
|
||||
const RECENT_WEEKS = 2;
|
||||
|
||||
/**
|
||||
* Changes worth surfacing, and nothing else.
|
||||
*
|
||||
* The rule this file exists to enforce is *not flagging everything*. A report
|
||||
* that lists every movement trains its reader to skim it, and the one finding
|
||||
* that mattered goes past unread. So a finding has to clear a floor on both
|
||||
* axes: enough shifts behind it to mean anything, and a big enough change to
|
||||
* be worth someone's afternoon.
|
||||
*
|
||||
* Each finding carries the figures it was derived from, so a caller can state
|
||||
* *why* rather than asserting that something is wrong.
|
||||
*/
|
||||
export function attendanceAnomalies(shifts = [], { now = new Date() } = {}) {
|
||||
const findings = [];
|
||||
const trend = weeklyTrend(shifts, { weeks: 12, now });
|
||||
if (trend.length < RECENT_WEEKS + 1) return findings;
|
||||
|
||||
const recent = trend.slice(-RECENT_WEEKS);
|
||||
const earlier = trend.slice(0, -RECENT_WEEKS);
|
||||
|
||||
const flatten = (weeks) => ({
|
||||
scheduled: sum(weeks.map((w) => w.scheduled)),
|
||||
missed: sum(weeks.map((w) => w.missed)),
|
||||
late: sum(weeks.map((w) => w.late)),
|
||||
overtime: round1(sum(weeks.map((w) => w.overtimeHours))),
|
||||
});
|
||||
|
||||
const now_ = flatten(recent);
|
||||
const before = flatten(earlier);
|
||||
|
||||
/* Too little to reason about. Saying so is better than dividing by four. */
|
||||
if (now_.scheduled < 4 || before.scheduled < 4) return findings;
|
||||
|
||||
/* ── Missed shifts, workspace-wide ──────────────────────────────────── */
|
||||
const missedNow = rate(now_.missed, now_.scheduled);
|
||||
const missedBefore = rate(before.missed, before.scheduled);
|
||||
if (now_.missed >= 2 && missedNow - missedBefore >= 8) {
|
||||
findings.push({
|
||||
id: 'missed-shifts-rising',
|
||||
kind: 'attendance',
|
||||
severity: missedNow - missedBefore >= 15 ? 'high' : 'medium',
|
||||
title: 'Missed shifts are rising',
|
||||
detail: `${now_.missed} of ${now_.scheduled} shifts missed in the last ${RECENT_WEEKS} weeks (${missedNow}%), against ${missedBefore}% before that.`,
|
||||
metric: 'missed',
|
||||
current: missedNow,
|
||||
previous: missedBefore,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Lateness, workspace-wide ───────────────────────────────────────── */
|
||||
const lateNow = rate(now_.late, now_.scheduled);
|
||||
const lateBefore = rate(before.late, before.scheduled);
|
||||
if (now_.late >= 3 && lateNow - lateBefore >= 10) {
|
||||
findings.push({
|
||||
id: 'lateness-rising',
|
||||
kind: 'attendance',
|
||||
severity: 'medium',
|
||||
title: 'Late arrivals are rising',
|
||||
detail: `${now_.late} late arrivals in the last ${RECENT_WEEKS} weeks (${lateNow}% of shifts), against ${lateBefore}% before that.`,
|
||||
metric: 'late',
|
||||
current: lateNow,
|
||||
previous: lateBefore,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Overtime, as a direction rather than a spike ────────────────────────
|
||||
*
|
||||
* Deliberately not the two-window comparison used above. Overtime that grows
|
||||
* half an hour a week never produces a step big enough to trip a threshold —
|
||||
* each week looks like the last — and yet a rota that has quietly gained
|
||||
* three hours a shift over two months is exactly the finding worth having.
|
||||
* Averaging the recent weeks against the earlier ones actively hides it,
|
||||
* because the mean of a rising series sits in the middle of it.
|
||||
*
|
||||
* So this looks at the *shape*: overtime per scheduled shift, rising through
|
||||
* most of the series and materially higher at the end than the start.
|
||||
*
|
||||
* Per shift, not per week, and complete weeks only. The week in progress has
|
||||
* had fewer shifts worked in it so far, and comparing a part-week total
|
||||
* against whole ones reports a fall in overtime every Monday morning.
|
||||
*/
|
||||
const thisWeekStart = startOfWeek(now).getTime();
|
||||
const complete = trend.filter((w) => new Date(w.weekStart).getTime() < thisWeekStart);
|
||||
|
||||
if (complete.length >= 4) {
|
||||
const perShift = complete.map((w) => (w.scheduled ? w.overtimeHours / w.scheduled : 0));
|
||||
const rising = perShift.slice(1).filter((value, i) => value > perShift[i]).length;
|
||||
const first = perShift[0];
|
||||
const last = perShift[perShift.length - 1];
|
||||
const totalOvertime = round1(sum(complete.map((w) => w.overtimeHours)));
|
||||
|
||||
/* Three quarters of the steps going the same way, a half again at the end,
|
||||
and enough hours behind it to be worth someone's time. Any one of those
|
||||
alone would fire on noise. */
|
||||
const sustained = rising >= Math.ceil((perShift.length - 1) * 0.75);
|
||||
if (sustained && first > 0 && last >= first * 1.5 && totalOvertime >= 8) {
|
||||
const from = complete[0];
|
||||
const to = complete[complete.length - 1];
|
||||
findings.push({
|
||||
id: 'overtime-climbing',
|
||||
kind: 'overtime',
|
||||
severity: last >= first * 2 ? 'high' : 'medium',
|
||||
title: 'Overtime has been climbing week on week',
|
||||
detail: `Up from ${from.overtimeHours}h in the week of ${from.label} to ${to.overtimeHours}h in the week of ${to.label} — rising in ${rising} of the last ${perShift.length - 1} weeks.`,
|
||||
metric: 'overtime-trend',
|
||||
current: to.overtimeHours,
|
||||
previous: from.overtimeHours,
|
||||
weeks: perShift.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Individuals, where the workspace-wide figure hides them ─────────── */
|
||||
const recentFrom = new Date(startOfWeek(now).getTime() - (RECENT_WEEKS - 1) * 7 * 24 * HOUR_MS);
|
||||
const recentShifts = shifts.filter(
|
||||
(s) => new Date(s.created_date || 0).getTime() >= recentFrom.getTime()
|
||||
);
|
||||
|
||||
for (const worker of attendanceByWorker(recentShifts)) {
|
||||
/* Four shifts is the floor for saying anything about a person at all. */
|
||||
if (worker.scheduled < 4) continue;
|
||||
if (worker.missed >= 2 && worker.attendanceRate <= 85) {
|
||||
findings.push({
|
||||
id: `worker-attendance-${worker.id}`,
|
||||
kind: 'attendance',
|
||||
severity: worker.attendanceRate <= 75 ? 'high' : 'medium',
|
||||
title: `${worker.name} has missed ${worker.missed} of ${worker.scheduled} recent shifts`,
|
||||
detail: `${worker.attendanceRate}% attendance over the last ${RECENT_WEEKS} weeks in ${worker.department}.`,
|
||||
metric: 'attendance',
|
||||
current: worker.attendanceRate,
|
||||
previous: null,
|
||||
workerId: worker.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const worker of overtimeByWorker(recentShifts)) {
|
||||
if (worker.shifts < 4) continue;
|
||||
/* A quarter of scheduled time again is where overtime stops being a busy
|
||||
fortnight and starts being how the rota is actually staffed. */
|
||||
if (worker.overtimeShare >= 25) {
|
||||
findings.push({
|
||||
id: `worker-overtime-${worker.id}`,
|
||||
kind: 'overtime',
|
||||
severity: worker.overtimeShare >= 40 ? 'high' : 'medium',
|
||||
title: `${worker.name} is working ${worker.overtimeShare}% overtime`,
|
||||
detail: `${worker.hours}h of overtime across ${worker.shifts} recent shifts in ${worker.department}.`,
|
||||
metric: 'overtime-share',
|
||||
current: worker.overtimeShare,
|
||||
previous: null,
|
||||
workerId: worker.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rank = { high: 0, medium: 1, low: 2 };
|
||||
return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
||||
}
|
||||
@@ -94,6 +94,21 @@ export function useInterviews() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shifts worked, missed and overrun.
|
||||
*
|
||||
* A higher limit than the other collections because this is the one that grows
|
||||
* per person per day: three workers over eight weeks is already north of a
|
||||
* hundred records, and a truncated read would silently under-report attendance
|
||||
* rather than fail.
|
||||
*/
|
||||
export function useShiftRecords() {
|
||||
return useQuery({
|
||||
queryKey: ['shiftRecords'],
|
||||
queryFn: () => base44.entities.ShiftRecord.list('-created_date', 500),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStaff() {
|
||||
return useQuery({
|
||||
queryKey: ['staff'],
|
||||
|
||||
@@ -475,6 +475,19 @@ const HANDLERS = {
|
||||
navigate_to_candidates: () => ({ type: 'navigate', route: routeForPageKey('candidates') }),
|
||||
navigate_to_forge: () => ({ type: 'navigate', route: routeForPageKey('university') }),
|
||||
navigate_to_analytics: () => ({ type: 'navigate', route: routeForPageKey('analytics') }),
|
||||
|
||||
/**
|
||||
* Open whichever page a reading belongs to.
|
||||
*
|
||||
* The general form of the four fixed destinations above. `routeForPageKey`
|
||||
* only knows addresses in the placement table, so an unrecognised page key
|
||||
* resolves to nothing rather than to a guessed path — a skill cannot invent
|
||||
* a destination by asking for one.
|
||||
*/
|
||||
open_related_page: ({ page }) => {
|
||||
const route = routeForPageKey(String(page || ''));
|
||||
return route ? { type: 'navigate', route } : null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,14 @@ import { SUPPORTED_PERIODS, periodLabel } from './surfaces';
|
||||
import { CRITERIA_LABELS } from '@/lib/positionModel';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { candidateRoute } from './workforceFlow';
|
||||
import {
|
||||
attendanceAnomalies, attendanceByDepartment, attendanceByWorker, attendanceSummary,
|
||||
overtimeByWorker, overtimeSummary, weeklyTrend,
|
||||
} from '@/lib/attendance';
|
||||
import { activitySignals, signalLabel } from '@/lib/activitySignals';
|
||||
import { demandFor } from '@/lib/workforce';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import { buildPosition } from '@/pages/admin/positionInsights';
|
||||
|
||||
/**
|
||||
* The one place a skill's declared data source becomes real data.
|
||||
@@ -464,6 +472,696 @@ const RESOLVERS = {
|
||||
},
|
||||
|
||||
/** The workspace audit trail, most recent first. */
|
||||
/**
|
||||
* Open roles that will not fill on their own.
|
||||
*
|
||||
* Ranked by how stuck they are rather than by age: a role posted this morning
|
||||
* with no applicants is not yet a problem, and one posted three weeks ago with
|
||||
* a strong candidate nobody has moved on is. `buildPosition` is the same
|
||||
* reading the Positions page renders from, so a risk here and a health badge
|
||||
* there cannot disagree.
|
||||
*/
|
||||
'positions.risk': ({ positions = [], applications = [] }, section) => {
|
||||
const open = positions.filter((p) => p.status === 'active');
|
||||
|
||||
const rows = open.map((posting) => {
|
||||
const built = buildPosition(posting, applications);
|
||||
const { applied, unscreened, qualified, readyForInterview } = built.stats;
|
||||
|
||||
/* Each reason is a distinct failure with a distinct fix — no applicants
|
||||
needs sourcing, an unscreened backlog needs screening, and a decision
|
||||
owed needs a person. Collapsing them into one score would lose the
|
||||
only part a reader can act on. */
|
||||
const reasons = [
|
||||
applied === 0 && 'no applicants yet',
|
||||
applied > 0 && qualified === 0 && 'no candidate scoring 70 or above',
|
||||
unscreened >= 3 && `${unscreened} unscreened`,
|
||||
readyForInterview > 0 && `${readyForInterview} awaiting a decision`,
|
||||
].filter(Boolean);
|
||||
|
||||
/* Severity is the count of distinct problems, weighted so an empty
|
||||
pipeline outranks a busy one that needs attention. */
|
||||
const severity = (applied === 0 ? 3 : 0)
|
||||
+ (applied > 0 && qualified === 0 ? 2 : 0)
|
||||
+ (unscreened >= 3 ? 1 : 0)
|
||||
+ (readyForInterview > 0 ? 1 : 0);
|
||||
|
||||
return {
|
||||
id: posting.id,
|
||||
title: posting.title,
|
||||
label: posting.title,
|
||||
department: posting.role_category || '—',
|
||||
value: severity,
|
||||
applied,
|
||||
qualified,
|
||||
unscreened,
|
||||
readyForInterview,
|
||||
reasons,
|
||||
detail: reasons.length
|
||||
? `${posting.role_category || 'Uncategorized'} · ${reasons.join(' · ')}`
|
||||
: `${posting.role_category || 'Uncategorized'} · filling normally`,
|
||||
to: `/admin/positions`,
|
||||
};
|
||||
});
|
||||
|
||||
const atRisk = rows
|
||||
.filter((row) => row.reasons.length > 0)
|
||||
.sort((a, b) => b.value - a.value || b.unscreened - a.unscreened);
|
||||
const limited = section.limit ? atRisk.slice(0, section.limit) : atRisk;
|
||||
|
||||
return {
|
||||
items: limited,
|
||||
steps: [
|
||||
{ id: 'at-risk', label: 'Roles at risk', title: 'Roles at risk', value: atRisk.length },
|
||||
{ id: 'open', label: 'Open roles', title: 'Open roles', value: open.length },
|
||||
{ id: 'starved', label: 'No applicants', title: 'No applicants', value: rows.filter((r) => r.applied === 0).length },
|
||||
{ id: 'owed', label: 'Decisions owed', title: 'Decisions owed', value: rows.reduce((n, r) => n + r.readyForInterview, 0) },
|
||||
],
|
||||
total: atRisk.length,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Position' },
|
||||
{ key: 'detail', label: 'Why' },
|
||||
],
|
||||
/* No open roles and no risks are different states, and saying "nothing is
|
||||
at risk" when nothing is posted would be a false reassurance. */
|
||||
empty: atRisk.length === 0,
|
||||
emptyNote: open.length
|
||||
? 'Every open role is filling normally.'
|
||||
: 'No positions are open.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* How strong the applicant pool is, and how much of it anyone has looked at.
|
||||
*
|
||||
* Coverage sits beside quality deliberately: an average score computed from a
|
||||
* fifth of the pool is not the pool's average, and reporting the first
|
||||
* without the second is how a hiring dashboard talks itself into confidence.
|
||||
*/
|
||||
'candidates.quality': ({ applications = [], interviews = [] }, section, now) => {
|
||||
const pool = section.periods?.length
|
||||
? section.periods
|
||||
.filter((p) => SUPPORTED_PERIODS.includes(p))
|
||||
.flatMap((p) => inPeriod(applications, p, now))
|
||||
: applications;
|
||||
|
||||
/* Deduped: overlapping windows — `today` inside `last-7-days` — would
|
||||
otherwise count the same application twice. */
|
||||
const unique = [...new Map(pool.map((a) => [a.id, a])).values()];
|
||||
|
||||
const scored = unique.filter((a) => a.ai_score > 0);
|
||||
const bands = [
|
||||
{ id: 'strong', label: 'Strong (80+)', title: 'Strong (80+)', value: scored.filter((a) => a.ai_score >= 80).length },
|
||||
{ id: 'viable', label: 'Viable (70–79)', title: 'Viable (70–79)', value: scored.filter((a) => a.ai_score >= 70 && a.ai_score < 80).length },
|
||||
{ id: 'marginal', label: 'Marginal (50–69)', title: 'Marginal (50–69)', value: scored.filter((a) => a.ai_score >= 50 && a.ai_score < 70).length },
|
||||
{ id: 'weak', label: 'Below 50', title: 'Below 50', value: scored.filter((a) => a.ai_score < 50).length },
|
||||
];
|
||||
|
||||
const interviewed = new Set(interviews.map((i) => i.application_id));
|
||||
const avgScore = scored.length
|
||||
? Math.round(scored.reduce((sum, a) => sum + a.ai_score, 0) / scored.length)
|
||||
: 0;
|
||||
|
||||
const steps = [
|
||||
{ id: 'pool', label: 'Candidates', title: 'Candidates', value: unique.length },
|
||||
{ id: 'coverage', label: 'Screened', title: 'Screened', value: unique.length ? Math.round((scored.length / unique.length) * 100) : 0, max: 100, detail: `${scored.length} of ${unique.length} scored` },
|
||||
{ id: 'quality', label: 'Average score', title: 'Average score', value: avgScore, max: 100, detail: scored.length ? `across ${scored.length} scored` : 'nothing scored yet' },
|
||||
{ id: 'interviews', label: 'Interviewed', title: 'Interviewed', value: unique.filter((a) => interviewed.has(a.id)).length },
|
||||
];
|
||||
|
||||
const ranked = [...scored].sort((a, b) => b.ai_score - a.ai_score);
|
||||
const limited = section.limit ? ranked.slice(0, section.limit) : ranked;
|
||||
|
||||
return {
|
||||
steps,
|
||||
bands,
|
||||
items: limited.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.applicant_name,
|
||||
label: a.applicant_name,
|
||||
value: a.ai_score,
|
||||
max: 100,
|
||||
detail: `${a.job_title || 'Unassigned'} · ${String(a.status || '').replace(/_/g, ' ')}`,
|
||||
to: `/admin/candidates/${a.id}`,
|
||||
})),
|
||||
total: unique.length,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Candidate' },
|
||||
{ key: 'detail', label: 'Role' },
|
||||
{ key: 'value', label: 'Score', align: 'right' },
|
||||
],
|
||||
empty: unique.length === 0,
|
||||
emptyNote: applications.length
|
||||
? 'No candidates applied in that period.'
|
||||
: 'No candidates have applied yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The talent this workspace already knows.
|
||||
*
|
||||
* Profiles with no score are counted separately rather than averaged in as
|
||||
* zero: an unscored profile is unscored, and folding it into the mean would
|
||||
* make a healthy pool look poor in exact proportion to how much of it nobody
|
||||
* has assessed.
|
||||
*/
|
||||
'talent.pool': ({ profiles = [], workerProfiles = [] }, section) => {
|
||||
const pool = profiles.length ? profiles : workerProfiles;
|
||||
const scored = pool.filter((p) => (p.krow_score || 0) > 0);
|
||||
const available = pool.filter((p) => (p.availability || []).length > 0);
|
||||
const certified = pool.filter((p) => (p.certifications || []).length > 0);
|
||||
|
||||
const avgScore = scored.length
|
||||
? Math.round(scored.reduce((sum, p) => sum + (p.krow_score || 0), 0) / scored.length)
|
||||
: 0;
|
||||
|
||||
const steps = [
|
||||
{ id: 'size', label: 'In the pool', title: 'In the pool', value: pool.length },
|
||||
{ id: 'scored', label: 'Scored', title: 'Scored', value: scored.length, detail: `${pool.length - scored.length} not yet assessed` },
|
||||
{ id: 'quality', label: 'Average score', title: 'Average score', value: avgScore, max: 100, detail: scored.length ? `across ${scored.length} scored` : 'nothing scored yet' },
|
||||
{ id: 'available', label: 'With availability', title: 'With availability', value: available.length },
|
||||
{ id: 'certified', label: 'Certified', title: 'Certified', value: certified.length },
|
||||
];
|
||||
|
||||
const ranked = [...pool].sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0));
|
||||
const limited = section.limit ? ranked.slice(0, section.limit) : ranked;
|
||||
|
||||
return {
|
||||
steps,
|
||||
items: limited.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.full_name,
|
||||
label: p.full_name,
|
||||
value: p.krow_score || 0,
|
||||
max: 100,
|
||||
detail: [
|
||||
p.current_position || p.desired_position || 'No role stated',
|
||||
/* Stated plainly rather than shown as a zero, which reads as a bad
|
||||
score rather than an absent one. */
|
||||
(p.krow_score || 0) > 0 ? getScoreBand(p.krow_score).label : 'Not yet scored',
|
||||
(p.availability || []).length ? (p.availability || []).join(', ') : 'Availability not on file',
|
||||
].join(' · '),
|
||||
})),
|
||||
total: pool.length,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Person' },
|
||||
{ key: 'detail', label: 'Profile' },
|
||||
{ key: 'value', label: 'Score', align: 'right' },
|
||||
],
|
||||
empty: pool.length === 0,
|
||||
emptyNote: 'No talent profiles have been created yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Open roles against the people hired into them.
|
||||
*
|
||||
* `demandFor` is the same reading the Positions page uses, and it refuses to
|
||||
* invent a headcount for a position that never declared one. That refusal is
|
||||
* carried through here rather than papered over: where no role states how
|
||||
* many people it wants, this reports hires and says the target is unstated
|
||||
* instead of quietly assuming one person per role and reporting a fill rate
|
||||
* that means nothing.
|
||||
*/
|
||||
'workforce.coverage': ({ positions = [], staff = [], assignments = [] }, section) => {
|
||||
const open = positions.filter((p) => p.status === 'active');
|
||||
const rows = open.map((posting) => {
|
||||
const demand = demandFor(posting, { assignments, staff });
|
||||
return {
|
||||
id: posting.id,
|
||||
title: posting.title,
|
||||
label: posting.title,
|
||||
department: posting.role_category || '—',
|
||||
declared: demand.declared,
|
||||
required: demand.required,
|
||||
assigned: demand.assigned,
|
||||
value: demand.assigned,
|
||||
max: demand.declared ? demand.required : undefined,
|
||||
detail: demand.declared
|
||||
? `${demand.assigned}/${demand.required} filled · ${posting.role_category || 'Uncategorized'}`
|
||||
: `${demand.assigned} hired · headcount not stated · ${posting.role_category || 'Uncategorized'}`,
|
||||
};
|
||||
});
|
||||
|
||||
const declaring = rows.filter((r) => r.declared);
|
||||
const covered = rows.filter((r) => r.assigned > 0);
|
||||
const limited = section.limit ? rows.slice(0, section.limit) : rows;
|
||||
|
||||
const steps = [
|
||||
{ id: 'open', label: 'Open roles', title: 'Open roles', value: open.length },
|
||||
{ id: 'covered', label: 'With someone hired', title: 'With someone hired', value: covered.length },
|
||||
{ id: 'uncovered', label: 'Nobody hired yet', title: 'Nobody hired yet', value: open.length - covered.length },
|
||||
{
|
||||
id: 'declared',
|
||||
label: 'Stating a headcount',
|
||||
title: 'Stating a headcount',
|
||||
value: declaring.length,
|
||||
/* Said out loud, because a coverage figure computed against an unstated
|
||||
target is the kind of number that gets quoted in a meeting. */
|
||||
detail: declaring.length
|
||||
? `${declaring.length} of ${open.length} open roles`
|
||||
: 'No open role states how many people it needs',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
steps,
|
||||
items: limited,
|
||||
total: open.length,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Position' },
|
||||
{ key: 'detail', label: 'Coverage' },
|
||||
],
|
||||
empty: open.length === 0,
|
||||
emptyNote: 'No positions are open.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Activity that departs from this workspace's own pattern.
|
||||
*
|
||||
* The detection is `lib/activitySignals.js`, the same function `buildFacts`
|
||||
* calls, so a flag counted in the greeting and a flag drawn on a card are the
|
||||
* same flag. Each is a deviation from a baseline, not a verdict — the wording
|
||||
* here says so rather than asserting wrongdoing.
|
||||
*/
|
||||
'activity.signals': ({ activity = [] }, section, now) => {
|
||||
const signals = activitySignals(activity, now);
|
||||
|
||||
const items = signals.flags.map((flag) => ({
|
||||
id: flag,
|
||||
title: signalLabel(flag),
|
||||
label: signalLabel(flag),
|
||||
detail: flag === 'concentration'
|
||||
? `${signals.busiest?.name || 'One account'} accounts for ${signals.busiestShare}% of events`
|
||||
: flag === 'burst'
|
||||
? `${signals.bursts} burst${signals.bursts === 1 ? '' : 's'} of more than three actions in an hour`
|
||||
: flag === 'off-hours'
|
||||
? `${signals.offHours.length} event${signals.offHours.length === 1 ? '' : 's'} outside working hours`
|
||||
: flag === 'silent'
|
||||
? 'Nothing has happened in the last 24 hours'
|
||||
: `${signals.privilegedShare}% of events change who is employed or what is being hired for`,
|
||||
value: 1,
|
||||
}));
|
||||
|
||||
const limited = section.limit ? items.slice(0, section.limit) : items;
|
||||
|
||||
return {
|
||||
items: limited,
|
||||
steps: [
|
||||
{ id: 'signals', label: 'Signals', title: 'Signals', value: signals.flags.length },
|
||||
{ id: 'events', label: 'Events', title: 'Events', value: activity.length },
|
||||
{ id: 'accounts', label: 'Accounts', title: 'Accounts', value: signals.accounts.length },
|
||||
{ id: 'privileged', label: 'Privileged actions', title: 'Privileged actions', value: signals.privileged.length, detail: `${signals.privilegedShare}% of events` },
|
||||
],
|
||||
signals,
|
||||
total: signals.flags.length,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Signal' },
|
||||
{ key: 'detail', label: 'Detail' },
|
||||
],
|
||||
/* Nothing out of pattern is a real and good answer, distinct from having
|
||||
no log to read. */
|
||||
empty: signals.flags.length === 0,
|
||||
emptyNote: activity.length
|
||||
? 'Nothing in the activity log departs from the usual pattern.'
|
||||
: 'No activity has been recorded yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/** What happened, counted by kind and by who did it. */
|
||||
'activity.breakdown': ({ activity = [] }, section, now) => {
|
||||
const windowed = section.periods?.length
|
||||
? section.periods.filter((p) => SUPPORTED_PERIODS.includes(p))
|
||||
: [];
|
||||
|
||||
const events = windowed.length
|
||||
? [...new Map(
|
||||
windowed.flatMap((p) => inPeriod(activity, p, now)).map((e) => [e.id, e])
|
||||
).values()]
|
||||
: activity;
|
||||
|
||||
const byType = events.reduce((acc, e) => {
|
||||
acc[e.event_type] = (acc[e.event_type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const byAccount = events.reduce((acc, e) => {
|
||||
acc[e.user_name || e.user_email] = (acc[e.user_name || e.user_email] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const rows = Object.entries(byType)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, count]) => ({
|
||||
id: type,
|
||||
/* The stored event name is a machine key; a reader should not have to
|
||||
translate `hire_candidate` in their head. */
|
||||
title: type.replace(/_/g, ' '),
|
||||
label: type.replace(/_/g, ' '),
|
||||
value: count,
|
||||
detail: `${Math.round((count / (events.length || 1)) * 100)}% of events`,
|
||||
}));
|
||||
|
||||
const limited = section.limit ? rows.slice(0, section.limit) : rows;
|
||||
|
||||
return {
|
||||
items: limited,
|
||||
steps: [
|
||||
{ id: 'events', label: 'Events', title: 'Events', value: events.length },
|
||||
{ id: 'kinds', label: 'Kinds of event', title: 'Kinds of event', value: Object.keys(byType).length },
|
||||
{ id: 'accounts', label: 'Accounts active', title: 'Accounts active', value: Object.keys(byAccount).length },
|
||||
],
|
||||
byType,
|
||||
byAccount,
|
||||
total: events.length,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Event' },
|
||||
{ key: 'detail', label: 'Share' },
|
||||
{ key: 'value', label: 'Count', align: 'right' },
|
||||
],
|
||||
empty: events.length === 0,
|
||||
emptyNote: activity.length
|
||||
? 'No activity in that period.'
|
||||
: 'No activity has been recorded yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* What is going wrong operationally, across domains.
|
||||
*
|
||||
* Cross-domain on purpose. A decision owed to a strong candidate, a backlog
|
||||
* nobody has screened and shifts going unworked are stored in three different
|
||||
* places and are the same kind of problem to the person who has to fix them.
|
||||
*
|
||||
* Findings are only included when they exist — an empty list here means the
|
||||
* operation is running, not that the check did not run.
|
||||
*/
|
||||
'operations.risk': ({ applications = [], positions = [], shifts = [] }, section, now) => {
|
||||
const findings = [];
|
||||
|
||||
/* Screened, strong, and nobody has moved on them. */
|
||||
const owed = applications.filter(
|
||||
(a) => (a.ai_score || 0) >= 70 && ['ai_screened', 'shortlisted'].includes(a.status)
|
||||
);
|
||||
if (owed.length) {
|
||||
findings.push({
|
||||
id: 'decisions-owed',
|
||||
title: `${owed.length} strong candidate${owed.length === 1 ? '' : 's'} awaiting a decision`,
|
||||
label: 'Decisions owed',
|
||||
value: owed.length,
|
||||
severity: owed.length >= 5 ? 'high' : 'medium',
|
||||
detail: owed
|
||||
.slice(0, 3)
|
||||
.map((a) => `${a.applicant_name} (${a.ai_score})`)
|
||||
.join(', ') + (owed.length > 3 ? `, +${owed.length - 3} more` : ''),
|
||||
});
|
||||
}
|
||||
|
||||
const unscreened = applications.filter((a) => a.status === 'applied');
|
||||
if (unscreened.length >= 3) {
|
||||
findings.push({
|
||||
id: 'unscreened-backlog',
|
||||
title: `${unscreened.length} applications not yet screened`,
|
||||
label: 'Unscreened backlog',
|
||||
value: unscreened.length,
|
||||
severity: unscreened.length >= 10 ? 'high' : 'medium',
|
||||
detail: `${Math.round((unscreened.length / applications.length) * 100)}% of the pool has no score`,
|
||||
});
|
||||
}
|
||||
|
||||
const starved = positions.filter(
|
||||
(p) => p.status === 'active' && !applications.some((a) => a.job_posting_id === p.id)
|
||||
);
|
||||
if (starved.length) {
|
||||
findings.push({
|
||||
id: 'starved-positions',
|
||||
title: `${starved.length} open role${starved.length === 1 ? '' : 's'} with no applicants`,
|
||||
label: 'Roles with no applicants',
|
||||
value: starved.length,
|
||||
severity: 'medium',
|
||||
detail: starved.slice(0, 3).map((p) => p.title).join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
/* Shifts going unworked, over the last fortnight — the operational half of
|
||||
the same question the attendance source answers analytically. */
|
||||
const recent = [...inPeriod(shifts, 'last-7-days', now)];
|
||||
const missed = recent.filter((s) => s.status === 'absent' || s.status === 'no_show');
|
||||
if (missed.length >= 2) {
|
||||
findings.push({
|
||||
id: 'shifts-unworked',
|
||||
title: `${missed.length} shifts went unworked in the last 7 days`,
|
||||
label: 'Shifts unworked',
|
||||
value: missed.length,
|
||||
severity: missed.length >= 4 ? 'high' : 'medium',
|
||||
detail: `${Math.round((missed.length / recent.length) * 100)}% of ${recent.length} scheduled`,
|
||||
});
|
||||
}
|
||||
|
||||
const rank = { high: 0, medium: 1, low: 2 };
|
||||
findings.sort((a, b) => rank[a.severity] - rank[b.severity] || b.value - a.value);
|
||||
const limited = section.limit ? findings.slice(0, section.limit) : findings;
|
||||
|
||||
return {
|
||||
items: limited,
|
||||
steps: [
|
||||
{ id: 'risks', label: 'Open risks', title: 'Open risks', value: findings.length },
|
||||
{ id: 'owed', label: 'Decisions owed', title: 'Decisions owed', value: owed.length },
|
||||
{ id: 'unscreened', label: 'Unscreened', title: 'Unscreened', value: unscreened.length },
|
||||
{ id: 'starved', label: 'Roles with no applicants', title: 'Roles with no applicants', value: starved.length },
|
||||
],
|
||||
findings,
|
||||
total: findings.length,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Risk' },
|
||||
{ key: 'detail', label: 'Detail' },
|
||||
],
|
||||
empty: findings.length === 0,
|
||||
emptyNote: applications.length || shifts.length
|
||||
? 'Nothing is currently at operational risk.'
|
||||
: 'There is nothing to assess yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The whole workspace in one row of figures.
|
||||
*
|
||||
* Every number here is read from a source that owns it rather than recomputed,
|
||||
* so a summary and the page it summarizes cannot drift apart. A domain with
|
||||
* no records contributes a zero and says so in its detail line — the summary
|
||||
* reports what is there, including the absence.
|
||||
*/
|
||||
'workspace.summary': ({
|
||||
positions = [], applications = [], staff = [], profiles = [], workerProfiles = [],
|
||||
activity = [], shifts = [],
|
||||
}) => {
|
||||
const open = positions.filter((p) => p.status === 'active');
|
||||
const pool = profiles.length ? profiles : workerProfiles;
|
||||
const scored = applications.filter((a) => a.ai_score > 0);
|
||||
const attendance = attendanceSummary(shifts);
|
||||
|
||||
const steps = [
|
||||
{ id: 'positions', label: 'Open roles', title: 'Open roles', value: open.length, detail: `${positions.length} in total` },
|
||||
{ id: 'candidates', label: 'Candidates', title: 'Candidates', value: applications.length, detail: `${scored.length} scored` },
|
||||
{ id: 'hires', label: 'Hires', title: 'Hires', value: staff.length },
|
||||
{ id: 'talent', label: 'Talent pool', title: 'Talent pool', value: pool.length },
|
||||
{
|
||||
id: 'attendance',
|
||||
label: 'Attendance',
|
||||
title: 'Attendance',
|
||||
value: attendance.scheduled ? attendance.attendanceRate : 0,
|
||||
max: 100,
|
||||
detail: attendance.scheduled
|
||||
? `${attendance.worked} of ${attendance.scheduled} shifts worked`
|
||||
: 'No shifts recorded',
|
||||
},
|
||||
{ id: 'activity', label: 'Events logged', title: 'Events logged', value: activity.length },
|
||||
];
|
||||
|
||||
return {
|
||||
steps,
|
||||
items: steps,
|
||||
total: steps.length,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Measure' },
|
||||
{ key: 'value', label: 'Value', align: 'right' },
|
||||
],
|
||||
/* Only genuinely empty when the workspace holds nothing at all. */
|
||||
empty: !positions.length && !applications.length && !pool.length && !activity.length,
|
||||
emptyNote: 'This workspace has no records yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Attendance, read whichever way the definition asks for it.
|
||||
*
|
||||
* One source, several shapes, because "how is attendance" is four different
|
||||
* questions depending on what is drawn: a headline, a trend, a comparison
|
||||
* between people, or the one thing worth acting on. Which one a definition
|
||||
* gets is decided by its declared capability, never by parsing the question —
|
||||
* the same rule every other source here follows.
|
||||
*
|
||||
* Every figure comes from `lib/attendance.js`, which the pages could call
|
||||
* too. Nothing is computed twice, and nothing is written down as prose.
|
||||
*/
|
||||
'workforce.attendance': ({ shifts = [] }, section, now) => {
|
||||
const windowed = section.periods?.length
|
||||
? section.periods.filter((p) => SUPPORTED_PERIODS.includes(p))
|
||||
: [];
|
||||
|
||||
/* A period-shaped reading: one step per window, for a flow or a timeline. */
|
||||
if (windowed.length) {
|
||||
const steps = windowed.map((period) => {
|
||||
const records = inPeriod(shifts, period, now);
|
||||
const summary = attendanceSummary(records);
|
||||
return {
|
||||
id: period,
|
||||
label: periodLabel(period),
|
||||
title: periodLabel(period),
|
||||
value: summary.scheduled ? summary.attendanceRate : 0,
|
||||
max: 100,
|
||||
detail: summary.scheduled
|
||||
? `${summary.worked}/${summary.scheduled} worked · ${summary.missed} missed · ${summary.late} late`
|
||||
: 'No shifts scheduled',
|
||||
records,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
steps,
|
||||
items: steps,
|
||||
total: shifts.length,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Period' },
|
||||
{ key: 'value', label: 'Attendance %', align: 'right' },
|
||||
],
|
||||
empty: steps.every((step) => !step.records.length),
|
||||
emptyNote: shifts.length
|
||||
? 'No shifts fall in those periods.'
|
||||
: 'No shift records have been logged yet.',
|
||||
};
|
||||
}
|
||||
|
||||
const summary = attendanceSummary(shifts);
|
||||
const workers = attendanceByWorker(shifts);
|
||||
const limited = section.limit ? workers.slice(0, section.limit) : workers;
|
||||
|
||||
/* The headline figures, in the `{id,label,title,value}` shape every stats
|
||||
and card renderer already reads. */
|
||||
const steps = [
|
||||
{ id: 'rate', label: 'Attendance', title: 'Attendance', value: summary.attendanceRate, max: 100, detail: `${summary.worked} of ${summary.scheduled} shifts worked` },
|
||||
{ id: 'punctuality', label: 'Punctuality', title: 'Punctuality', value: summary.punctualityRate, max: 100, detail: `${summary.late} late arrival${summary.late === 1 ? '' : 's'}` },
|
||||
{ id: 'missed', label: 'Missed shifts', title: 'Missed shifts', value: summary.missed, detail: `${summary.absent} absent · ${summary.noShow} no-show` },
|
||||
{ id: 'scheduled', label: 'Shifts scheduled', title: 'Shifts scheduled', value: summary.scheduled, detail: `${summary.hoursWorked}h worked` },
|
||||
];
|
||||
|
||||
return {
|
||||
steps,
|
||||
/* Per person for a list or a table — attendance is a question about
|
||||
people, and a row per person is what a reader can act on. */
|
||||
items: limited.map((worker) => ({
|
||||
id: worker.id,
|
||||
title: worker.name,
|
||||
label: worker.name,
|
||||
value: worker.attendanceRate,
|
||||
max: 100,
|
||||
detail: `${worker.department} · ${worker.worked}/${worker.scheduled} worked · ${worker.missed} missed · ${worker.late} late`,
|
||||
})),
|
||||
summary,
|
||||
workers,
|
||||
departments: attendanceByDepartment(shifts),
|
||||
total: summary.scheduled,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Worker' },
|
||||
{ key: 'detail', label: 'Record' },
|
||||
{ key: 'value', label: 'Attendance %', align: 'right' },
|
||||
],
|
||||
empty: summary.empty,
|
||||
emptyNote: 'No shift records have been logged yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Overtime — scheduled hours against the hours actually worked.
|
||||
*
|
||||
* Reports the share as well as the total, because forty hours of overtime
|
||||
* means one thing across a fortnight and another across a year, and only the
|
||||
* ratio makes two teams comparable.
|
||||
*/
|
||||
'workforce.overtime': ({ shifts = [] }, section, now) => {
|
||||
const windowed = section.periods?.length
|
||||
? section.periods.filter((p) => SUPPORTED_PERIODS.includes(p))
|
||||
: [];
|
||||
|
||||
if (windowed.length) {
|
||||
const steps = windowed.map((period) => {
|
||||
const records = inPeriod(shifts, period, now);
|
||||
const summary = overtimeSummary(records);
|
||||
return {
|
||||
id: period,
|
||||
label: periodLabel(period),
|
||||
title: periodLabel(period),
|
||||
value: summary.hours,
|
||||
detail: records.length
|
||||
? `${summary.hours}h across ${summary.shiftsWithOvertime} of ${summary.shifts} shifts`
|
||||
: 'No shifts scheduled',
|
||||
records,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
steps,
|
||||
items: steps,
|
||||
total: shifts.length,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Period' },
|
||||
{ key: 'value', label: 'Overtime hours', align: 'right' },
|
||||
],
|
||||
empty: steps.every((step) => !step.records.length),
|
||||
emptyNote: shifts.length
|
||||
? 'No shifts fall in those periods.'
|
||||
: 'No shift records have been logged yet.',
|
||||
};
|
||||
}
|
||||
|
||||
const summary = overtimeSummary(shifts);
|
||||
const workers = overtimeByWorker(shifts);
|
||||
const limited = section.limit ? workers.slice(0, section.limit) : workers;
|
||||
|
||||
const steps = [
|
||||
{ id: 'hours', label: 'Overtime hours', title: 'Overtime hours', value: summary.hours, detail: `across ${summary.shiftsWithOvertime} of ${summary.shifts} shifts` },
|
||||
{ id: 'share', label: 'Share of scheduled', title: 'Share of scheduled', value: summary.overtimeShare, max: 100, detail: `${summary.hoursWorked}h worked against ${summary.hoursScheduled}h scheduled` },
|
||||
{ id: 'average', label: 'Avg per shift', title: 'Avg per shift', value: summary.averagePerShift, detail: 'hours' },
|
||||
{ id: 'scheduled', label: 'Hours scheduled', title: 'Hours scheduled', value: summary.hoursScheduled },
|
||||
];
|
||||
|
||||
return {
|
||||
steps,
|
||||
items: limited.map((worker) => ({
|
||||
id: worker.id,
|
||||
title: worker.name,
|
||||
label: worker.name,
|
||||
value: worker.hours,
|
||||
detail: `${worker.department} · ${worker.overtimeShare}% of scheduled · ${worker.shiftsWithOvertime}/${worker.shifts} shifts`,
|
||||
})),
|
||||
summary,
|
||||
workers,
|
||||
/* The findings this reading supports, for a definition that asks for an
|
||||
insight rather than a table. Empty when nothing clears the bar — which
|
||||
is the point of `attendanceAnomalies`. */
|
||||
anomalies: attendanceAnomalies(shifts, { now }),
|
||||
trend: weeklyTrend(shifts, { now }),
|
||||
total: summary.hours,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Worker' },
|
||||
{ key: 'detail', label: 'Overtime' },
|
||||
{ key: 'value', label: 'Hours', align: 'right' },
|
||||
],
|
||||
empty: summary.empty,
|
||||
emptyNote: 'No shift records have been logged yet.',
|
||||
};
|
||||
},
|
||||
|
||||
'activity.events': ({ activity = [] }, section) => {
|
||||
const items = [...activity]
|
||||
.sort((a, b) => new Date(b.created_date || 0).getTime() - new Date(a.created_date || 0).getTime())
|
||||
|
||||
@@ -79,7 +79,7 @@ export function parseFrontmatter(raw) {
|
||||
* 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.
|
||||
*/
|
||||
function sectionSource(body, heading) {
|
||||
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;
|
||||
@@ -106,7 +106,7 @@ function sectionSource(body, heading) {
|
||||
*/
|
||||
const LIST_ITEM = /^\s*(?:-|\*|\d+[.)])\s+(.*)$/;
|
||||
|
||||
function sectionBullets(body, heading) {
|
||||
export function sectionBullets(body, heading) {
|
||||
const source = sectionSource(body, heading);
|
||||
if (source == null) return [];
|
||||
|
||||
@@ -164,7 +164,7 @@ function sectionSteps(body, heading) {
|
||||
* one line of summary. Used for a level's description, which is a sentence
|
||||
* rather than a list.
|
||||
*/
|
||||
function sectionText(body, heading) {
|
||||
export function sectionText(body, heading) {
|
||||
const source = sectionSource(body, heading);
|
||||
if (source == null) return '';
|
||||
return source
|
||||
@@ -373,6 +373,16 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
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,
|
||||
/**
|
||||
|
||||
@@ -149,6 +149,84 @@ export const SKILL_SURFACES = [
|
||||
},
|
||||
/* Not in the eight product surfaces, but skills already attach to it and the
|
||||
account page reads them. Kept so nothing that works today stops working. */
|
||||
{
|
||||
/**
|
||||
* Agent configuration.
|
||||
*
|
||||
* A surface so the page has a name the registry can resolve, and
|
||||
* deliberately with **no placements**: nothing renders skill cards here, and
|
||||
* a `ui:` skill that tried to attach would be refused rather than validating
|
||||
* and then drawing nothing. No skill declares it, so an agent configured
|
||||
* here still reaches no operational data.
|
||||
*/
|
||||
id: 'workspace-agent-configure',
|
||||
label: 'Agent Configure',
|
||||
route: '/admin/workspace/agents/new',
|
||||
placements: [],
|
||||
provides: {},
|
||||
},
|
||||
/**
|
||||
* The rest of the workspace, and Settings.
|
||||
*
|
||||
* Named for exactly one reason: a page needs a key before an agent can say it
|
||||
* covers it, and Owliver needs an agent before it can answer anywhere. These
|
||||
* are configuration surfaces — they hold no workforce records — so like Agent
|
||||
* Configure every one of them declares **no placements**, and no skill
|
||||
* declares them. A `ui:` skill that tried to attach is refused at validation
|
||||
* rather than validating and drawing nothing, and `skillsForContext` returns
|
||||
* an empty list here, which is the honest answer.
|
||||
*
|
||||
* Being named is what makes the general agent's fallback architectural: it
|
||||
* covers every surface in this table, so a page without a specialist has an
|
||||
* agent rather than nothing. `skill-check` asserts that coverage, so adding a
|
||||
* surface here and forgetting the agent fails the build rather than quietly
|
||||
* producing a dead panel.
|
||||
*/
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
route: '/admin/settings',
|
||||
placements: [],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: 'Workspace',
|
||||
route: '/admin/workspace',
|
||||
placements: [],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'workspace-agents',
|
||||
label: 'Agents',
|
||||
route: '/admin/workspace/agents',
|
||||
placements: [],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'workspace-skills',
|
||||
label: 'Skills',
|
||||
route: '/admin/workspace/skills',
|
||||
placements: [],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
/* The skill editors, both of them. The same screen in the sense that
|
||||
matters here: a definition is being written, and nothing on it is a
|
||||
workforce record. */
|
||||
id: 'workspace-skill-configure',
|
||||
label: 'Skill Configure',
|
||||
route: '/admin/workspace/skills/new',
|
||||
placements: [],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'skill-development',
|
||||
label: 'Skill Development',
|
||||
route: '/admin/workspace/skill-development',
|
||||
placements: [],
|
||||
provides: {},
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profile',
|
||||
@@ -563,6 +641,144 @@ export const DATA_SOURCES = [
|
||||
summary: 'Hires, time-to-hire, quality and conversion, counted together.',
|
||||
shapes: ['stats', 'card', 'table', 'flow', 'insight'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Open roles that are not going to fill on their own.
|
||||
*
|
||||
* State rather than events, so it takes no `periods`: "which roles are at
|
||||
* risk" is a question about now, and windowing it to last week would report
|
||||
* the risks of last week.
|
||||
*/
|
||||
id: 'positions.risk',
|
||||
label: 'Staffing risk',
|
||||
context: null,
|
||||
summary: 'Open roles with no applicants, no viable candidate, or nobody screened.',
|
||||
shapes: ['list', 'table', 'stats', 'insight', 'card'],
|
||||
options: ['limit'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* How good the applicant pool is, and how much of it has been looked at.
|
||||
*
|
||||
* Windowable, because applications are dated and "how has candidate quality
|
||||
* moved this month" is a real question.
|
||||
*/
|
||||
id: 'candidates.quality',
|
||||
label: 'Candidate quality',
|
||||
context: null,
|
||||
summary: 'Score bands, how much of the pool is screened, and interview coverage.',
|
||||
shapes: ['stats', 'progress', 'table', 'list', 'flow', 'insight', 'card'],
|
||||
options: ['periods', 'limit'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* The talent already known to this workspace — supply, not applicants.
|
||||
*
|
||||
* Someone in the pool has not applied to anything by being here, which is
|
||||
* why this is a separate source from `candidates.quality` rather than a
|
||||
* filter on it.
|
||||
*/
|
||||
id: 'talent.pool',
|
||||
label: 'Talent pool',
|
||||
context: null,
|
||||
summary: 'Who is in the pool, how they score, and how complete their profiles are.',
|
||||
shapes: ['stats', 'progress', 'table', 'list', 'insight', 'card'],
|
||||
options: ['limit'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Open roles against the people actually hired into them.
|
||||
*
|
||||
* Reads `demandFor`, which is the same reading the Positions page uses —
|
||||
* including its refusal to invent a headcount for a position that never
|
||||
* declared one.
|
||||
*/
|
||||
id: 'workforce.coverage',
|
||||
label: 'Workforce coverage',
|
||||
context: null,
|
||||
summary: 'Open roles, and who has been hired into them.',
|
||||
shapes: ['stats', 'progress', 'table', 'list', 'insight', 'card'],
|
||||
options: ['limit'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Activity that departs from this workspace's own pattern.
|
||||
*
|
||||
* Not windowable: a pattern is computed over the whole log, and a signal
|
||||
* detected inside a seven-day window would be a different measurement
|
||||
* wearing the same name.
|
||||
*/
|
||||
id: 'activity.signals',
|
||||
label: 'Activity signals',
|
||||
context: null,
|
||||
summary: 'Concentration, bursts, off-hours work and privileged actions.',
|
||||
shapes: ['insight', 'list', 'table', 'stats', 'card'],
|
||||
options: ['limit'],
|
||||
},
|
||||
{
|
||||
/** What happened, counted by kind and by who did it. */
|
||||
id: 'activity.breakdown',
|
||||
label: 'Activity breakdown',
|
||||
context: null,
|
||||
summary: 'Events by type and by account.',
|
||||
shapes: ['stats', 'table', 'list', 'progress', 'flow', 'card'],
|
||||
options: ['periods', 'limit'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* What is going wrong operationally, across domains.
|
||||
*
|
||||
* Deliberately cross-domain — a decision owed to a strong candidate, an
|
||||
* unscreened backlog and a no-show rate are the same kind of problem to the
|
||||
* person on shift, however differently they are stored.
|
||||
*/
|
||||
id: 'operations.risk',
|
||||
label: 'Operational risk',
|
||||
context: null,
|
||||
summary: 'Decisions owed, unscreened backlog, and shifts going unworked.',
|
||||
shapes: ['list', 'table', 'stats', 'insight', 'card'],
|
||||
options: ['limit'],
|
||||
},
|
||||
{
|
||||
/** The whole workspace in one row of figures. */
|
||||
id: 'workspace.summary',
|
||||
label: 'Workspace summary',
|
||||
context: null,
|
||||
summary: 'Positions, candidates, hires, talent and attendance, counted together.',
|
||||
shapes: ['stats', 'card', 'table', 'list', 'insight'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Shifts worked, missed and overrun.
|
||||
*
|
||||
* A workspace-level reading, so it needs no record in context and answers
|
||||
* on any page that carries it. `periods` windows it the same way every
|
||||
* other dated collection is windowed — on `created_date`, which for a
|
||||
* shift is the instant it was worked.
|
||||
*/
|
||||
id: 'workforce.attendance',
|
||||
label: 'Workforce attendance',
|
||||
context: null,
|
||||
summary: 'Shifts worked, late arrivals, absences and no-shows.',
|
||||
shapes: ['stats', 'progress', 'table', 'list', 'flow', 'timeline', 'insight', 'card'],
|
||||
options: ['periods', 'limit'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Scheduled hours against hours actually worked.
|
||||
*
|
||||
* Separate from attendance because they answer different questions and one
|
||||
* hides the other: a team can have perfect attendance and be running on
|
||||
* thirty hours of overtime a week, and a single "workforce hours" source
|
||||
* would report that as healthy.
|
||||
*/
|
||||
id: 'workforce.overtime',
|
||||
label: 'Workforce overtime',
|
||||
context: null,
|
||||
summary: 'Scheduled against actual hours, and the overtime that resulted.',
|
||||
shapes: ['stats', 'progress', 'table', 'list', 'flow', 'insight', 'card'],
|
||||
options: ['periods', 'limit'],
|
||||
},
|
||||
{
|
||||
id: 'activity.events',
|
||||
label: 'Workspace activity',
|
||||
|
||||
177
src/lib/skills/tools.js
Normal file
177
src/lib/skills/tools.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import { skillsForContext } from './registry';
|
||||
import { ACTION_NAMES } from './actions';
|
||||
|
||||
/**
|
||||
* Tools, described.
|
||||
*
|
||||
* Boundary 6. This adds **no capability**: every tool here is an action
|
||||
* `actions.js` already performs, and `runAction` remains the only thing that
|
||||
* performs them. What was missing was a description — what a tool does, what it
|
||||
* needs, whether it changes anything, and whether a person should be asked
|
||||
* first. Without that, a caller deciding whether to confirm an action had to
|
||||
* hard-code a list of which ones were dangerous.
|
||||
*
|
||||
* Describing them separately is also what makes them exposable later. A future
|
||||
* MCP surface publishes these descriptors and calls the same `runAction`;
|
||||
* nothing in the business logic moves. That is the whole reason this file is a
|
||||
* table rather than a set of wrappers.
|
||||
*
|
||||
* **The page boundary is inherited, not restated.** `toolsForContext` reads the
|
||||
* skills that are reachable on the current page for the current agent, and
|
||||
* collects what *they* declare. A tool is therefore reachable only when a skill
|
||||
* on this page declares it and the agent carries that skill — so a tool can
|
||||
* never reach data the page was not already offering, and selecting a different
|
||||
* agent can only ever remove tools from that list.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What each action is, in the terms a person confirming it would need.
|
||||
*
|
||||
* `requiresApproval` is a property of the action, never of the caller: an
|
||||
* action that writes a record needs a person to agree whichever surface asked
|
||||
* for it. `readOnly` actions move the reader somewhere and change nothing.
|
||||
*/
|
||||
export const TOOLS = [
|
||||
{
|
||||
name: 'create_position',
|
||||
label: 'Create position',
|
||||
summary: 'Writes a new job posting from a draft collected in conversation.',
|
||||
params: ['draft', 'status'],
|
||||
readOnly: false,
|
||||
mutates: 'JobPosting',
|
||||
/* Writes a record other people will act on. Always confirmed. */
|
||||
requiresApproval: true,
|
||||
},
|
||||
{
|
||||
name: 'open_create_skill_training',
|
||||
label: 'Open Add Skill Training',
|
||||
summary: 'Opens the Forge authoring form, prefilled from the conversation.',
|
||||
params: ['prefill'],
|
||||
readOnly: true,
|
||||
mutates: null,
|
||||
/* Opens a form. Nothing is written until the person submits it, so asking
|
||||
twice would be asking about the same decision twice. */
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
name: 'open_create_training',
|
||||
label: 'Open Add Training',
|
||||
summary: 'Opens the training authoring form for a course.',
|
||||
params: ['prefill', 'courseId'],
|
||||
readOnly: true,
|
||||
mutates: null,
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
name: 'navigate_to_positions',
|
||||
label: 'Open Positions',
|
||||
summary: 'Takes the reader to the Positions page.',
|
||||
params: [],
|
||||
readOnly: true,
|
||||
mutates: null,
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
name: 'navigate_to_candidates',
|
||||
label: 'Open Candidates',
|
||||
summary: 'Takes the reader to the Candidates page.',
|
||||
params: [],
|
||||
readOnly: true,
|
||||
mutates: null,
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
name: 'navigate_to_forge',
|
||||
label: 'Open KROW Forge',
|
||||
summary: 'Takes the reader to the Forge library.',
|
||||
params: [],
|
||||
readOnly: true,
|
||||
mutates: null,
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
name: 'navigate_to_analytics',
|
||||
label: 'Open Analytics',
|
||||
summary: 'Takes the reader to the Analytics page.',
|
||||
params: [],
|
||||
readOnly: true,
|
||||
mutates: null,
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Open whichever Krow page a reading belongs to.
|
||||
*
|
||||
* The general form of the four fixed `navigate_to_*` actions above, which
|
||||
* each name one destination. This one takes a page key and resolves it
|
||||
* through the same placement table, so a skill can send the reader to the
|
||||
* page its analysis was about without a new action per destination.
|
||||
*
|
||||
* Still bounded: `routeForPageKey` only knows addresses the product has,
|
||||
* and an unknown key resolves to nothing rather than to a guess.
|
||||
*/
|
||||
name: 'open_related_page',
|
||||
label: 'Open the related page',
|
||||
summary: 'Takes the reader to the Krow page a reading came from.',
|
||||
params: ['page'],
|
||||
readOnly: true,
|
||||
mutates: null,
|
||||
requiresApproval: false,
|
||||
},
|
||||
];
|
||||
|
||||
const BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool]));
|
||||
|
||||
/** One tool's description, or null. */
|
||||
export const describeTool = (name) => BY_NAME.get(name) || null;
|
||||
|
||||
export const TOOL_NAMES = TOOLS.map((tool) => tool.name);
|
||||
|
||||
/** Whether an action changes something a person should agree to first. */
|
||||
export const toolRequiresApproval = (name) => Boolean(BY_NAME.get(name)?.requiresApproval);
|
||||
|
||||
/**
|
||||
* Every action name a handler exists for but nothing describes.
|
||||
*
|
||||
* A handler with no descriptor is invisible to anything reasoning about tools —
|
||||
* including whatever decides to ask for confirmation — so it would run
|
||||
* unannounced. Asserted in the checks rather than left to review.
|
||||
*/
|
||||
export const undescribedActions = () =>
|
||||
ACTION_NAMES.filter((name) => !BY_NAME.has(name));
|
||||
|
||||
/**
|
||||
* The tools reachable on this page, for this agent.
|
||||
*
|
||||
* Derived from the scoped skill list, so the page boundary is inherited rather
|
||||
* than re-implemented: a skill the page does not carry contributes no tools, and
|
||||
* a skill the agent does not carry has already been removed from that list by
|
||||
* `agentScopedDisabled`.
|
||||
*
|
||||
* `disabled` is expected to already carry the agent's scoping. Passing the raw
|
||||
* account list yields the page's full tool set, which is what an unscoped
|
||||
* caller should get.
|
||||
*/
|
||||
export function toolsForContext(contextId, disabled = [], customSkills = []) {
|
||||
const reachable = skillsForContext(contextId, disabled, customSkills);
|
||||
|
||||
const names = new Set();
|
||||
for (const skill of reachable) {
|
||||
for (const action of skill.actions || []) names.add(action);
|
||||
}
|
||||
|
||||
return [...names]
|
||||
.map((name) => describeTool(name))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this tool may run here.
|
||||
*
|
||||
* The check a caller makes before offering a control. Deliberately takes the
|
||||
* resolved list rather than recomputing it, so a caller cannot accidentally ask
|
||||
* the question against a wider scope than the one it rendered from.
|
||||
*/
|
||||
export const toolAllowed = (name, allowed = []) =>
|
||||
allowed.some((tool) => tool.name === name);
|
||||
Reference in New Issue
Block a user