update agents skill design

This commit is contained in:
2026-08-20 18:18:10 +05:30
parent 161b237695
commit b2e6868824
75 changed files with 14586 additions and 98 deletions

103
src/lib/activitySignals.js Normal file
View 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;

View 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,
};
}

View 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);
}

View 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
View 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,
});

View 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,
});

View 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
View 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
View 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
View 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
View 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,
};
}

View 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
View 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]);
}

View File

@@ -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'],

View File

@@ -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;
},
};
/**

View File

@@ -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())

View File

@@ -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,
/**

View File

@@ -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
View 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);