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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
/**
* Writes the Owliver behaviour baseline.
*
* node scripts/owliver-baseline.mjs # report drift, write nothing
* node scripts/owliver-baseline.mjs --write # (re)generate the snapshot
*
* The snapshot is the existing product's behaviour, recorded before the Agent
* layer was built and asserted by `skill-check.mjs` on every run afterwards.
*
* Regenerating is a deliberate act with a reason attached. A baseline rewritten
* to make a red check go green records the regression instead of catching it,
* which is worse than having no baseline at all — it converts a caught bug into
* a documented one.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { createServer } from 'vite';
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
const ROOT = process.cwd();
const server = await createServer({
root: ROOT,
server: { middlewareMode: true },
appType: 'custom',
logLevel: 'error',
});
const captured = await captureBaseline(server);
await server.close();
const serialized = `${JSON.stringify(captured, null, 2)}\n`;
if (process.argv.includes('--write')) {
mkdirSync(dirname(BASELINE_PATH), { recursive: true });
writeFileSync(BASELINE_PATH, serialized);
const pages = Object.keys(captured.contexts).length;
console.log(`Baseline written — ${pages} contexts, ${captured.skillIds.length} skills, ${Object.keys(captured.routes).length} routes.`);
process.exit(0);
}
if (!existsSync(BASELINE_PATH)) {
console.error('No baseline recorded. Run `node scripts/owliver-baseline.mjs --write`.');
process.exit(1);
}
if (readFileSync(BASELINE_PATH, 'utf8') === serialized) {
console.log('Owliver behaviour matches the baseline.');
process.exit(0);
}
console.error('Owliver behaviour has DRIFTED from the baseline. Run `npm test` for the per-page detail.');
process.exit(1);

158
scripts/owliver-capture.mjs Normal file
View File

@@ -0,0 +1,158 @@
/**
* Today's Owliver behaviour, captured as data.
*
* The Agent layer is additive: opening a Krow page must produce exactly the
* Owliver it produces now. That is a claim about eleven page contexts, their
* skills, their suggestions, their prompts and their intent routing — far more
* than anyone can hold in their head while editing, and all of it silent when
* it breaks. So it is recorded here as a value, committed, and compared on
* every run.
*
* One module, two callers: `owliver-baseline.mjs` writes the snapshot and
* `skill-check.mjs` asserts against it. A second implementation of "what we
* measure" would be a second definition of the contract.
*
* Everything below is pinned. `TODAY` is fixed rather than `new Date()`,
* because `buildFacts` windows records by date and a baseline that drifts
* daily is not a baseline. Lists are sorted; nothing depends on registry
* ordering or on the clock.
*/
import { join } from 'node:path';
/** Where the committed snapshot lives. Named here rather than in the CLI so a
* reader can import the path without also running a Vite server. */
export const BASELINE_PATH = join(process.cwd(), 'scripts/__baseline__/owliver-baseline.json');
/** Fixed so the snapshot means the same thing tomorrow. */
export const TODAY = new Date('2026-08-20T09:00:00.000Z');
/**
* The contexts under contract.
*
* The eight Krow pages the brief protects, plus the three further Admin
* contexts the placement table carries — Create Position, Candidates Analysis
* and Profile. They are page-scoped by exactly the same mechanism, so leaving
* them out would protect most of the boundary and quietly not the rest.
*/
export const CONTEXTS = [
'admin.controlCenter',
'admin.positions',
'admin.createPosition',
'admin.candidatesList',
'admin.candidates',
'admin.hiredHistory',
'admin.talentPool',
'admin.forge',
'admin.analytics',
'admin.activity',
'admin.profile',
];
/**
* What gets asked, per context.
*
* Three kinds on purpose, because they exercise three different branches of
* `resolveIntent` and a change to any one of them is a regression a reader
* would notice: a question the page owns, a question another page owns (which
* must route, not answer), and a question nothing owns (which must decline).
*/
export const QUESTIONS = [
'What needs my attention?',
'Summarize this page',
'Show hiring activity',
'Which positions need attention?',
'Where are candidates dropping off?',
'What are my permissions?',
'What is the weather today?',
];
const sorted = (list) => [...list].sort();
/** Block types only — structure is the contract, wording is not. */
const docShape = (document) =>
Array.isArray(document?.blocks) ? document.blocks.map((b) => b?.type ?? null) : null;
/**
* Loads the real module graph and reads today's behaviour off it.
*
* `ssrLoadModule` rather than a mock, for the reason `skill-check.mjs` gives:
* the pipeline is what is under test, and a mock of it would reproduce none of
* the failures this exists to catch.
*/
export async function captureBaseline(server) {
const registry = await server.ssrLoadModule('/src/lib/skills/registry.js');
const placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js');
const resolver = await server.ssrLoadModule('/src/lib/skills/owliverResolver.js');
const dynamic = await server.ssrLoadModule('/src/components/ai-assistant/dynamic.js');
const routing = await server.ssrLoadModule('/src/components/ai-assistant/routing.js');
const insights = await server.ssrLoadModule('/src/components/ai-assistant/insights.js');
const seed = await server.ssrLoadModule('/src/api/seed.js');
/* The fact sheet the panel builds, from the shipped seed at a fixed instant. */
const facts = insights.buildFacts({
applications: seed.seedData.JobApplication,
postings: seed.seedData.JobPosting,
interviews: seed.seedData.AIInterview,
staff: seed.seedData.Staff,
profiles: seed.seedData.WorkerProfile,
activity: seed.seedData.UserActivity,
courses: seed.seedData.Course,
profile: null,
user: seed.DEMO_USER,
today: TODAY,
});
const contexts = {};
for (const contextId of CONTEXTS) {
const pageKey = registry.pageKeyForContext(contextId);
contexts[contextId] = {
pageKey,
route: pageKey ? registry.routeForPageKey(pageKey) : null,
/* The page boundary itself: which skills this page carries with no
agent, no account customization and nothing disabled. */
skills: sorted(registry.skillsForContext(contextId, [], []).map((s) => s.id)),
suggestions: sorted(
resolver.owliverSuggestions(contextId, [], [], {}).map((s) => s.label)
),
prompts: (dynamic.buildPrompts(contextId, facts, null) || []).map((p) => p.label),
intents: QUESTIONS.map((question) => {
const matched = registry.matchSkill(question, contextId, [], []);
const intent = routing.resolveIntent({ question, contextId });
return {
question,
matchedSkill: matched?.id ?? null,
kind: intent?.kind ?? null,
skill: intent?.skill?.id ?? null,
capability: intent?.capability ?? null,
action: intent?.action?.name ?? null,
destination: intent?.destination?.contextId ?? null,
doc: docShape(intent?.doc),
};
}),
};
}
/* Route → context, for every placement the product declares. An agent must
never become an input to this. */
const routes = {};
for (const route of Object.keys(placement.PLACEMENT_ROUTES).sort()) {
routes[route] = placement.resolveAssistantContext('admin', route)?.id ?? null;
}
return {
/* Bumped only when the shape of what we measure changes — never to make a
failing comparison pass. */
schema: 1,
today: TODAY.toISOString(),
skillIds: sorted(registry.SKILLS.map((s) => s.id)),
diagnostics: registry.readSkillRegistry([]).diagnostics,
routes,
contexts,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,8 @@ import AdminProfile from '@/pages/admin/Profile';
import AdminSettings from '@/pages/admin/Settings';
import AdminWorkspace from '@/pages/admin/Workspace';
import AdminWorkspaceSkills from '@/pages/admin/WorkspaceSkills';
import AdminWorkspaceAgents from '@/pages/admin/WorkspaceAgents';
import AdminAgentDetail from '@/pages/admin/AgentDetail';
import AdminSkillEditor from '@/pages/admin/SkillEditor';
import AdminOwliverSkillEditor from '@/pages/admin/OwliverSkillEditor';
import AdminSkillDevelopment from '@/pages/admin/SkillDevelopment';
@@ -110,6 +112,11 @@ const AuthenticatedApp = () => {
both subjects. */}
<Route path="settings" element={<AdminSettings />} />
<Route path="workspace" element={<AdminWorkspace />} />
<Route path="workspace/agents" element={<AdminWorkspaceAgents />} />
{/* Static before dynamic, so `agents/new` cannot be read as an
agent whose id is "new". */}
<Route path="workspace/agents/new" element={<AdminAgentDetail />} />
<Route path="workspace/agents/:id" element={<AdminAgentDetail />} />
<Route path="workspace/skills" element={<AdminWorkspaceSkills />} />
{/* Two editors, because a UI skill and an Owliver skill configure
different things. Static segments rank above the dynamic `:id`,

View File

@@ -0,0 +1,42 @@
---
id: activity-agent
name: Activity Agent
description: The audit trail — what happened in this workspace, who did it, and what looks unusual.
icon: activity
status: published
version: 1
reasoning: balanced
trigger: Use on Activity, for the event log, who did what, and anything that looks out of pattern.
pages:
- activity
skills:
- activity-analysis
- anomaly-detection
- operational-risk
starters:
- label: What happened recently?
prompt: What has happened in the workspace recently?
- label: Anything unusual?
prompt: Is there any unusual activity?
permissions:
owner: demo@krow.app
access: all
---
# Activity Agent
## Instructions
Answer about what has happened in this workspace: which events, by which
account, and when.
Report something as unusual only when it genuinely departs from the pattern in
the log. Flagging ordinary activity trains the reader to ignore the flag.
This agent carries no skills of its own; Activity answers from its own page
reader.
## Purpose
- Report recent workspace events and who performed them.
- Surface activity that departs from the usual pattern.

View File

@@ -0,0 +1,42 @@
---
id: analytics-agent
name: Analytics Agent
description: Hiring performance over time — trends, conversion, and how departments compare.
icon: bar-chart
status: published
version: 1
reasoning: balanced
trigger: Use on Analytics, for trends over time, conversion rates and department comparisons.
pages:
- analytics
skills:
- analytics-insights
- workforce-analytics
- attendance-analysis
- overtime-analysis
- hiring-pulse-analysis
starters:
- label: What is the hiring trend?
prompt: What is the hiring trend?
- label: Where does the funnel lose people?
prompt: Where does the funnel lose candidates?
permissions:
owner: demo@krow.app
access: all
---
# Analytics Agent
## Instructions
Answer about performance over time: how hiring is trending, where the funnel
converts and where it leaks, and how departments compare.
Explain the figures the Analytics page is already showing rather than producing
different ones. When a movement is small enough to be noise, say so rather than
narrating it as a trend.
## Purpose
- Explain hiring trend and conversion.
- Compare department performance, and identify where the funnel loses people.

View File

@@ -0,0 +1,41 @@
---
id: candidates-agent
name: Candidates Agent
description: The applicant pool — who is waiting on a decision, who is strongest, and where people are dropping off.
icon: users
status: published
version: 1
reasoning: balanced
trigger: Use on Candidates, for screening, shortlisting and pipeline questions about applicants.
pages:
- candidates
- candidates-analysis
skills:
- candidate-search
- candidate-analysis
starters:
- label: Who needs a decision?
prompt: Which candidates are waiting on a decision?
- label: Who is strongest?
prompt: Who are the strongest candidates right now?
permissions:
owner: demo@krow.app
access: all
---
# Candidates Agent
## Instructions
Answer about the people who have applied: who is waiting, who scores well, who
has not been screened, and where the pipeline is losing candidates.
Quote a score only where one has been computed. An unscored candidate is
unscored — say so rather than implying a low score.
Never advance, decline or hire a candidate without being asked to.
## Purpose
- Report who is waiting on a decision, and who is strongest.
- Find candidates matching what a role asks for.

View File

@@ -0,0 +1,47 @@
---
id: control-center-agent
name: Control Center Agent
description: The operational picture — what needs attention across the workspace today.
icon: layers
status: published
version: 1
reasoning: balanced
trigger: Use on the Control Center, for workspace health, urgency and what to do next.
pages:
- control-center
skills:
- executive-summary
- staffing-risk
- operational-risk
- anomaly-detection
- attendance-analysis
- overtime-analysis
- hiring-pulse-analysis
starters:
- label: What needs my attention?
prompt: What needs my attention right now?
- label: How is the pipeline?
prompt: How healthy is my hiring pipeline?
permissions:
owner: demo@krow.app
access: all
---
# Control Center Agent
## Instructions
Answer about the state of the workspace as a whole: what is urgent, where the
funnel is losing people, and what the reader should do next.
Read the figures the Control Center already shows rather than recomputing them,
so the answer and the dashboard beside it can never disagree.
This agent carries no skills of its own. That is deliberate — the Control
Center answers from its own page reader, and inventing skills to fill the list
would promise capabilities that do not exist.
## Purpose
- Say what needs attention across the workspace.
- Explain where the hiring funnel is losing candidates.

View File

@@ -0,0 +1,41 @@
---
id: hired-history-agent
name: Hired History Agent
description: Completed hires — who was hired, for which role, how quickly, and how well.
icon: user-check
status: published
version: 1
reasoning: balanced
trigger: Use on Hired History, for hiring outcomes, time-to-hire and quality by department.
pages:
- hired-history
skills:
- hiring-history-analysis
starters:
- label: Who did we hire recently?
prompt: Who did we hire recently?
- label: How is hire quality?
prompt: How is hire quality by department?
permissions:
owner: demo@krow.app
access: all
---
# Hired History Agent
## Instructions
Answer about hires that have already happened: who, for which role, how long it
took and how they scored.
This is the record after the decision, not the pipeline before it. A question
about people still being considered belongs to Candidates.
This agent carries no skills of its own. Hired History answers from its own
page reader, and a placeholder skill would promise a capability that does not
exist.
## Purpose
- Report recent hires, and how quickly they were made.
- Compare hiring outcomes across departments.

View File

@@ -0,0 +1,41 @@
---
id: krow-forge-agent
name: KROW Forge Agent
description: The training library — what exists, what is published, and how the workforce is progressing.
icon: graduation-cap
status: published
version: 1
reasoning: balanced
trigger: Use on KROW Forge, for training paths, challenges, verification and skill progression.
pages:
- krow-forge
skills:
- forge-skill-management
- learning-analysis
starters:
- label: What is in the library?
prompt: What training does the library hold?
- label: Where are the gaps?
prompt: Where are the gaps in workforce training?
permissions:
owner: demo@krow.app
access: all
---
# KROW Forge Agent
## Instructions
Answer about the training library and what the workforce has proved: which
paths exist, which are published, what a challenge checks, and where coverage
is thin.
A skill in Forge is something a person learns and is verified in. It is not an
Owliver capability — never describe the two as the same thing.
Never publish or archive training without being asked to.
## Purpose
- Report what the training library holds and what is live.
- Identify gaps between what roles need and what is taught.

View File

@@ -0,0 +1,100 @@
---
id: krow-workforce-agent
name: Krow Workforce Agent
description: The general workforce agent. Reasons across every Krow domain, within whatever page you are on.
icon: owliver
status: published
version: 1
reasoning: balanced
trigger: Use when a question spans more than one Krow domain, or when you are on a page whose own agent cannot help.
pages:
- control-center
- positions
- create-position
- candidates
- candidates-analysis
- hired-history
- talent-pool
- krow-forge
- analytics
- activity
- profile
# The agent workspace. Carries no operational skill, so standing here the
# root agent answers about agents and skills and nothing else — which is the
# point: configuring the Analytics Agent must not put the reader on Analytics.
- workspace-agent-configure
# Settings and the rest of the workspace. Nobody wrote a specialist for a
# configuration screen and nobody should: these pages hold no workforce
# records, so what they need is a general agent, not a Settings Agent with
# invented skills. Listing them here is the whole of the fallback — a page
# named by this agent has an agent, and Owliver is alive on it.
- settings
- workspace
- workspace-agents
- workspace-skills
- workspace-skill-configure
- skill-development
skills:
- create-position
- hiring-activity-assistant
- candidate-search
- analytics-insights
- forge-skill-management
- staffing-risk
- attendance-analysis
- overtime-analysis
- candidate-analysis
- talent-pool-analysis
- workforce-analytics
- anomaly-detection
- activity-analysis
- operational-risk
- executive-summary
- hiring-history-analysis
- learning-analysis
- hiring-pulse-analysis
subagents:
- control-center-agent
- positions-agent
- candidates-agent
- hired-history-agent
- talent-pool-agent
- krow-forge-agent
- analytics-agent
- activity-agent
knowledge:
- id: page-boundary
label: What this agent can see
kind: note
body: Owliver answers from the page you are on. Covering every page does not mean reading every page at once — the page you are standing on decides which records are in reach.
starters:
- label: What needs my attention?
prompt: What needs my attention right now?
- label: Summarize this page
prompt: Summarize what this page is showing
permissions:
owner: demo@krow.app
access: all
people:
- user: demo@krow.app
role: manager
---
# Krow Workforce Agent
## Instructions
Answer from the records this workspace holds, for the page the reader is on.
State a figure only where a skill has read it. When a reading needs a position
or a candidate and none is open, ask which one rather than choosing one.
Covering every page is not permission to read every page at once. The page in
front of the reader decides what is in reach; a question that belongs somewhere
else should be answered by naming where it belongs, not by reaching for it.
## Purpose
- Answer questions that span more than one Krow domain.
- Stand in on pages whose own agent carries no skills.
- Hand a question that clearly belongs to another page back to that page.

View File

@@ -0,0 +1,42 @@
---
id: positions-agent
name: Positions Agent
description: Open roles — what they need, who has applied, and which are at risk of going unfilled.
icon: briefcase
status: published
version: 1
reasoning: balanced
trigger: Use on Positions, for open roles, applicant flow, and specifying a new role.
pages:
- positions
- create-position
skills:
- create-position
- hiring-activity-assistant
- staffing-risk
starters:
- label: Which positions need attention?
prompt: Which positions need attention?
- label: Show hiring activity
prompt: Show hiring activity as a flow
permissions:
owner: demo@krow.app
access: all
---
# Positions Agent
## Instructions
Answer about the roles this workspace has open: how they are filling, which are
starved of applicants, and what a role still needs before it can be published.
When a question names a role, answer about that role. When it does not and one
is open on the page, answer about that one. When neither is true, ask which.
Never create or publish a position without being asked to.
## Purpose
- Report how open roles are filling, and which are at risk.
- Help specify a new role and its screening weights.

View File

@@ -0,0 +1,40 @@
---
id: talent-pool-agent
name: Talent Pool Agent
description: Available talent — who is in the pool, who is verified, and who is ready to place.
icon: layers
status: published
version: 1
reasoning: balanced
trigger: Use on Talent Pool, for supply, availability and readiness of known workers.
pages:
- talent-pool
skills:
- talent-pool-analysis
starters:
- label: Who is available?
prompt: Who is available in the talent pool?
- label: How verified is the pool?
prompt: How much of the talent pool is verified?
permissions:
owner: demo@krow.app
access: all
---
# Talent Pool Agent
## Instructions
Answer about the people this workspace already knows: who is in the pool, what
they are verified in, and who could be placed now.
This is supply, not applicants. Someone in the pool has not applied to anything
by being here — do not describe them as a candidate for a role.
This agent carries no skills of its own; Talent Pool answers from its own page
reader.
## Purpose
- Report who is available, and how ready they are.
- Describe the pool's segments and verification coverage.

233
src/api/attendanceSeed.js Normal file
View File

@@ -0,0 +1,233 @@
/**
* Shift records — the workforce actually turning up, or not.
*
* This is the one collection in the demo whose dates are **anchored to now**
* rather than written as calendar dates. Everything else in `seed.js` is a
* fixed narrative: 22 people applied on particular days and three were hired,
* and those dates are the story. Attendance is not a story, it is a rolling
* operational record — "how was attendance last week" has to mean *last week*,
* every week, or the feature reads as permanently empty and looks broken.
*
* The snapshot persists on first load (see `store.js`), so the figures are
* stable for a browser once written; only a fresh workspace re-anchors them.
* Every foreign key points at a fixed seeded record, so correlation with
* positions and hires stays exact however the dates land.
*
* Nothing here is random. The distribution is written out below and generated
* deterministically, so the same workspace always produces the same figures and
* a test can assert against them. What it is *shaped* to contain:
*
* - **Marco** — the control. Reliable, occasional event-night overtime.
* - **Marcus** — attendance degrading in the last fortnight: two absences, a
* no-show and repeated lateness, against a clean record before that. This
* is the attendance anomaly, and it is recent enough to be actionable.
* - **Antoine** — present throughout, but overtime climbing steadily week on
* week. This is the overtime anomaly, and it is a trend rather than a
* spike, which is the kind a person reading a table would miss.
*
* The roster is three people because three people have been hired — `Staff` is
* the workforce, and inventing a fourth to make the charts look busier would be
* inventing an employee.
*/
/** Eight weeks: long enough for a week-on-week trend and a month comparison. */
const WINDOW_DAYS = 56;
/**
* A local instant `n` days back, at a given hour.
*
* Local rather than UTC because a shift belongs to the day it was worked in the
* place it was worked, and `periodRange` windows on local day boundaries too.
*/
function daysAgo(n, hour = 9, minute = 0) {
const d = new Date();
d.setDate(d.getDate() - n);
d.setHours(hour, minute, 0, 0);
return d;
}
const round1 = (n) => Math.round(n * 10) / 10;
const round2 = (n) => Math.round(n * 100) / 100;
const HOUR = 60 * 60 * 1000;
/**
* The roster, joined to the records they were hired against.
*
* `job_posting_id` and `role_category` are the posting's own, so "department"
* means here exactly what it means on Hired History and Analytics — see the
* join in `lib/hiringRecords.js`.
*/
const ROSTER = [
{
staff_id: 'staff_marco',
worker_name: 'Marco Rivera',
worker_email: 'marco.rivera@email.com',
job_posting_id: 'job_bartender_corp',
role: 'Experienced Bartender – Corporate Events',
role_category: 'Bartender',
/* Wed–Sat: corporate events run late in the week. */
weekdays: [3, 4, 5, 6],
startHour: 16,
scheduledHours: 8,
},
{
staff_id: 'staff_marcus',
worker_name: 'Marcus Williams',
worker_email: 'marcus.w@email.com',
job_posting_id: 'job_security',
role: 'Event Security Officer',
role_category: 'Security',
/* Mon–Fri: a fixed security rota, which is what makes the recent
absences stand out rather than read as an irregular schedule. */
weekdays: [1, 2, 3, 4, 5],
startHour: 14,
scheduledHours: 8,
},
{
staff_id: 'staff_antoine',
worker_name: 'Chef Antoine Dubois',
worker_email: 'antoine.dubois@email.com',
job_posting_id: 'job_chef',
role: 'Executive Chef – Catering',
role_category: 'Chef',
/* Tue–Sat: kitchen service. */
weekdays: [2, 3, 4, 5, 6],
startHour: 12,
scheduledHours: 9,
},
];
/**
* How each worker's series behaves, by position in it.
*
* `i` counts back from the most recent shift, so "the last fortnight" is a
* range of small indices and stays that way as the window rolls forward.
* Returning a plain record keeps every rule visible in one place instead of
* spread across the generator.
*/
const BEHAVIOUR = {
/* Reliable. One late arrival every couple of months, and overtime only on
the nights events actually overrun. */
staff_marco: (i, weekday) => ({
status: i === 14 ? 'late' : 'present',
minutesLate: i === 14 ? 9 : 0,
/* Friday and Saturday events overrun; midweek ones do not. */
overtime: weekday === 5 || weekday === 6 ? 1 : 0,
notes: '',
}),
/**
* Clean for six weeks, then coming apart.
*
* Indices 0–9 are roughly the last fortnight. Two absences, one no-show and
* three late arrivals inside that window, against a single late arrival in
* the six weeks before it — a change big enough to be worth surfacing and
* specific enough to act on.
*/
staff_marcus: (i) => {
if (i === 2 || i === 7) {
return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' };
}
if (i === 4) {
return { status: 'no_show', minutesLate: 0, overtime: 0, notes: 'No contact' };
}
if (i === 1) return { status: 'late', minutesLate: 24, overtime: 0, notes: '' };
if (i === 5) return { status: 'late', minutesLate: 16, overtime: 0, notes: '' };
if (i === 9) return { status: 'late', minutesLate: 12, overtime: 0, notes: '' };
if (i === 26) return { status: 'late', minutesLate: 7, overtime: 0, notes: '' };
return { status: 'present', minutesLate: 0, overtime: 0, notes: '' };
},
/**
* Always there, increasingly late leaving.
*
* Overtime rises about half an hour a week as the kitchen carries more
* covers, on the three busiest shifts of each week. A steady climb rather
* than a spike, which is exactly the shape that hides in a table of totals.
*/
staff_antoine: (i, weekday) => {
const weekIndex = Math.floor(i / 5);
const busy = weekday === 4 || weekday === 5 || weekday === 6;
const overtime = busy ? Math.max(0.5, round1(3.5 - weekIndex * 0.45)) : 0;
return { status: 'present', minutesLate: 0, overtime, notes: '' };
},
};
/** Every shift date for one worker, most recent first. */
function shiftOffsets(weekdays) {
const offsets = [];
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
const day = daysAgo(offset).getDay();
if (weekdays.includes(day)) offsets.push(offset);
}
return offsets;
}
const pad = (n) => String(n).padStart(2, '0');
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
function buildShifts() {
const records = [];
for (const worker of ROSTER) {
const offsets = shiftOffsets(worker.weekdays);
offsets.forEach((offset, i) => {
const scheduledStart = daysAgo(offset, worker.startHour);
const weekday = scheduledStart.getDay();
const scheduledEnd = new Date(scheduledStart.getTime() + worker.scheduledHours * HOUR);
const { status, minutesLate, overtime, notes } = BEHAVIOUR[worker.staff_id](i, weekday);
const worked = status !== 'absent' && status !== 'no_show';
const actualStart = worked
? new Date(scheduledStart.getTime() + minutesLate * 60 * 1000)
: null;
const actualEnd = worked
? new Date(scheduledEnd.getTime() + overtime * HOUR)
: null;
records.push({
id: `shift_${worker.staff_id.replace('staff_', '')}_${pad(offsets.length - i)}`,
staff_id: worker.staff_id,
worker_name: worker.worker_name,
worker_email: worker.worker_email,
job_posting_id: worker.job_posting_id,
role: worker.role,
role_category: worker.role_category,
shift_date: localDate(scheduledStart),
scheduled_start: scheduledStart.toISOString(),
scheduled_end: scheduledEnd.toISOString(),
scheduled_hours: worker.scheduledHours,
actual_start: actualStart ? actualStart.toISOString() : null,
actual_end: actualEnd ? actualEnd.toISOString() : null,
/* Arriving late shortens the shift; staying on lengthens it. A missed
shift is zero hours worked, not a short one. */
actual_hours: worked
? round2(worker.scheduledHours - minutesLate / 60 + overtime)
: 0,
overtime_hours: worked ? round1(overtime) : 0,
minutes_late: worked ? minutesLate : 0,
status,
notes,
/* Load-bearing: `inPeriod` in `lib/skills/dataResolver.js` windows every
collection on `created_date`, so a shift's created date *is* the
instant it was worked. Without that, every period reading of this
collection would be empty and nothing would say why. */
created_date: scheduledStart.toISOString(),
updated_date: (actualEnd || scheduledEnd).toISOString(),
});
});
}
/* Most recent first, matching the `-created_date` order every other
collection is listed in. */
return records.sort(
(a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime()
);
}
export const SHIFT_RECORDS = buildShifts();

View File

@@ -22,6 +22,9 @@ const ENTITY_NAMES = [
into workforce allocation: without it a position knows its demand and its
applicants but not who is actually covering it. */
'Assignment',
/* Shifts worked, missed and overrun. The operational record behind
attendance and overtime analysis — see `api/attendanceSeed.js`. */
'ShiftRecord',
];
const entities = Object.fromEntries(

View File

@@ -8,6 +8,8 @@
* decision rate.
*/
import { SHIFT_RECORDS } from './attendanceSeed';
const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString();
/* ── Reference data ────────────────────────────────────────────────────── */
@@ -1904,6 +1906,9 @@ export const DEMO_USER = {
not exist. */
const ASSIGNMENTS = [];
/* Attendance lives in its own module: it is generated rather than written out,
and its dates are anchored to now rather than to this file's fixed calendar.
See the note at the top of `attendanceSeed.js`. */
export const seedData = {
JobPosting: JOB_POSTINGS,
JobApplication: JOB_APPLICATIONS,
@@ -1917,6 +1922,7 @@ export const seedData = {
RoleCategory: ROLE_CATEGORIES,
UserActivity: USER_ACTIVITY,
Assignment: ASSIGNMENTS,
ShiftRecord: SHIFT_RECORDS,
Evidence: EVIDENCE,
User: [DEMO_USER],
};

View File

@@ -0,0 +1,180 @@
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, Modal, SearchInput } from '@/components/ds';
import { allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { surfaceFor } from '@/lib/skills/surfaces';
/**
* Attaching skills to an agent.
*
* Reads **the one skill registry** — the same `allSkills` the Skills page and
* Owliver itself read. There is deliberately no separate list for agents: a
* second one would drift, and an agent would end up offering a skill the
* runtime does not have.
*
* Categories are derived rather than written down. A skill's own `category`
* field when it declares one, and the pages it attaches to otherwise, so a
* filter can never offer a grouping that matches nothing — and a skill added
* tomorrow appears under its own category without this file being edited.
*/
const ALL = 'all';
/** The groupings the registry actually contains, in a stable order. */
function categoriesFor(skills) {
const named = new Set();
for (const skill of skills) {
if (skill.category) named.add(skill.category);
}
return [ALL, ...[...named].sort()];
}
/** What this skill contributes, in the terms the reader is choosing between. */
function skillSummary(skill) {
const pages = skill.pages.map((p) => surfaceFor(p)?.label || p);
const capabilities = skill.owliver?.capabilities || [];
return [
pages.length ? pages.join(', ') : 'No pages',
capabilities.length
? `${capabilities.length} capabilit${capabilities.length === 1 ? 'y' : 'ies'}`
: (skill.actions || []).length ? `${skill.actions.length} action${skill.actions.length === 1 ? '' : 's'}` : null,
].filter(Boolean).join(' · ');
}
/** @param {any} props */
export function AddSkillsModal({ open, onOpenChange, attached = [], customSkills = [], onAdd }) {
const [query, setQuery] = React.useState('');
const [category, setCategory] = React.useState(ALL);
const [picked, setPicked] = React.useState([]);
/* A fresh sheet each time, so a previous selection is not still ticked. */
React.useEffect(() => {
if (open) { setQuery(''); setCategory(ALL); setPicked([]); }
}, [open]);
/* Owliver skills only. A workforce training path is something a person
learns, not something an agent can be asked to do, and offering one here
would promise behaviour that does not exist. */
const available = React.useMemo(
() => skillsWithFacet(allSkills(customSkills), 'owliver')
.filter((s) => s.status === 'active' && !attached.includes(s.id)),
[customSkills, attached]
);
const categories = React.useMemo(() => categoriesFor(available), [available]);
const visible = React.useMemo(() => {
const q = query.trim().toLowerCase();
return available
.filter((s) => category === ALL || s.category === category)
.filter((s) => !q
|| s.name.toLowerCase().includes(q)
|| s.description.toLowerCase().includes(q)
|| s.id.includes(q));
}, [available, category, query]);
const toggle = (id) =>
setPicked((current) => (current.includes(id)
? current.filter((x) => x !== id)
: [...current, id]));
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Add skills"
description="Select skills to extend what this agent can do. A skill still only answers on the pages it names."
size="lg"
footer={
<>
<p className="mr-auto text-body-sm text-ink-3">
{picked.length
? `${picked.length} skill${picked.length === 1 ? '' : 's'} selected`
: 'Nothing selected yet'}
</p>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button disabled={!picked.length} onClick={() => { onAdd(picked); onOpenChange(false); }}>
Add
</Button>
</>
}
>
<div className="space-y-3">
<SearchInput value={query} onChange={setQuery} placeholder="Search skills..." debounce={0} />
{categories.length > 1 && (
<div className="flex flex-wrap gap-1.5">
{categories.map((id) => (
<button
key={id}
type="button"
onClick={() => setCategory(id)}
className={cn(
`rounded-full border px-2.5 py-1 text-caption font-medium capitalize transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
category === id
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-2'
)}
>
{id === ALL ? 'All skills' : id}
</button>
))}
</div>
)}
<div className="max-h-[22rem] space-y-1.5 overflow-y-auto pr-0.5">
{visible.map((skill) => {
const chosen = picked.includes(skill.id);
return (
<button
key={skill.id}
type="button"
onClick={() => toggle(skill.id)}
aria-pressed={chosen}
className={cn(
`flex w-full items-start gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
chosen
? 'border-krow-blue/50 bg-krow-blue-tint/50'
: 'border-border bg-surface hover:border-krow-blue/30 hover:bg-surface-subtle'
)}
>
<span
className={cn(
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border',
chosen ? 'border-krow-blue bg-krow-blue text-white' : 'border-border bg-surface'
)}
aria-hidden="true"
>
{chosen && <Check className="h-3 w-3" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{skill.name}</span>
{skill.description && (
<span className="mt-0.5 block text-caption leading-snug text-ink-3">
{skill.description}
</span>
)}
<span className="mt-1 block truncate text-[10px] text-ink-4">{skillSummary(skill)}</span>
</span>
</button>
);
})}
{!visible.length && (
<p className="px-1 py-6 text-center text-body-sm text-ink-3">
{available.length
? 'No skills match that.'
: 'Every available skill is already attached to this agent.'}
</p>
)}
</div>
</div>
</Modal>
);
}
export default AddSkillsModal;

View File

@@ -0,0 +1,516 @@
import * as React from 'react';
import { ChevronRight, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { agentIconFor } from './icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
/**
* The Agent Configure workspace, as parts.
*
* The screen this builds is an *authoring* surface, and it is the only one in
* the Admin console that is. The eight operational pages report; this one is
* written in. That is the whole reason it is allowed to look different from
* them, and the difference is composition rather than colour: every token here
* — `border-border`, `surface-subtle`, `krow-blue-tint`, the ink scale, the
* duration and easing variables — is the same Control Tower vocabulary the rest
* of the console uses.
*
* Three ideas carry the layout:
*
* 1. **One document, not a stack of cards.** Four equal cards make four
* unrelated settings groups. One surface divided by hairlines makes a thing
* being edited, which is what an agent definition is.
*
* 2. **A spine on the left.** The rail lists what an agent is made of —
* Identity, Instructions, Capabilities, Behavior, and what is inside each —
* with its current value beside it. It is the tree reading of the
* configuration, and it is what lets someone understand the page before
* reading a single field. It is navigation, never a second copy of state.
*
* 3. **Values on the surface.** A row states what it currently holds while
* closed. Nothing has to be opened to be read.
*
* Motion is CSS only. `prefers-reduced-motion` is handled once, globally, in
* `index.css`, so a component that animates with transitions inherits that and
* a component that animates in JavaScript would not.
*/
/* ── The surface ─────────────────────────────────────────────────────────── */
/**
* The workspace: rail beside document, both inside one border.
*
* The rail only becomes a column when there is genuinely room for one. Owliver
* holds a ~380px track on the right of every Admin page, so below `xl` the
* remaining width belongs to the editor and the rail becomes a strip above it.
*/
export function Workspace({ rail, children, className }) {
return (
<div className={cn('w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden', className)}>
<div className="border-b border-border/80 bg-surface-subtle/40 px-4 py-3 sm:px-6">
{rail}
</div>
<div className="min-w-0 w-full divide-y divide-border/60">{children}</div>
</div>
);
}
/* ── Motion ──────────────────────────────────────────────────────────────── */
/**
* Open and close, in height.
*
* `grid-template-rows: 0fr → 1fr` is the one way to transition to an unknown
* height without measuring it in JavaScript. Content stays mounted, which is
* what keeps a section addressable by the rail while it is closed.
*/
/** @param {any} props */
export function Collapse({ open, children, className }) {
return (
<div
className={cn(
'grid transition-[grid-template-rows] duration-slow ease-out',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
className
)}
>
{/* `inert` rather than `aria-hidden`: the content stays mounted so a
closed section is still addressable, and a mounted control that cannot
be seen must not be reachable by Tab either. `aria-hidden` alone would
hide it from a screen reader while leaving it in the tab order, which
is the worse of both. */}
<div
className={cn(
'overflow-hidden transition-opacity duration-base ease-out',
open ? 'opacity-100' : 'opacity-0'
)}
inert={open ? undefined : true}
>
{children}
</div>
</div>
);
}
/* ── The rail ────────────────────────────────────────────────────────────── */
/**
* The spine.
*
* Provides clear section navigation and leaf anchor shortcuts across the
* configuration workspace.
*/
/** @param {any} props */
export function Rail({ sections, activeId, onSelect }) {
const activeSection = sections.find((s) => s.id === activeId);
return (
<nav
aria-label="Agent configuration"
className="flex flex-wrap items-center justify-between gap-2.5"
>
{/* Section anchor pills */}
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{sections.map((section) => {
const active = section.id === activeId;
const Icon = section.icon;
return (
<button
key={section.id}
type="button"
onClick={() => onSelect(section.id)}
aria-current={active ? 'true' : undefined}
className={cn(
`inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-body-sm font-medium
transition-all duration-base focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/40`,
active
? 'bg-krow-blue text-white shadow-xs'
: 'border border-border/80 bg-surface text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<Icon
className={cn('h-3.5 w-3.5 shrink-0 transition-colors', active ? 'text-white' : 'text-ink-4')}
aria-hidden="true"
/>
<span>{section.label}</span>
{section.meta && (
<span
className={cn(
'rounded-full px-1.5 py-0.5 text-caption tabular-nums',
active ? 'bg-white/20 text-white' : 'bg-surface-sunken text-ink-4'
)}
>
{section.meta}
</span>
)}
</button>
);
})}
</div>
{/* Sub-item quick jump leaves */}
{activeSection?.items?.length > 0 && (
<div className="hidden items-center gap-1.5 md:flex">
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">Jump to:</span>
{activeSection.items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSelect(activeSection.id, item.id)}
className={`group inline-flex items-center gap-1 rounded-md border border-border/60 bg-surface
px-2 py-1 text-caption font-medium text-ink-3 transition-colors duration-base
hover:border-krow-blue/40 hover:bg-surface-sunken hover:text-ink-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
<span className="min-w-0 truncate">{item.label}</span>
{item.meta !== undefined && item.meta !== null && item.meta !== '' && (
<span className="tabular-nums text-ink-4">({item.meta})</span>
)}
</button>
))}
</div>
)}
</nav>
);
}
/* ── The document ────────────────────────────────────────────────────────── */
/**
* One section of the document.
*/
/** @param {any} props */
export function DocSection({
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
children,
}) {
return (
<section
id={id}
aria-labelledby={`${id}-title`}
className={cn('scroll-mt-24 w-full', !last && 'border-b border-border/60')}
>
<button
type="button"
aria-expanded={open}
aria-controls={`${id}-body`}
onClick={onToggle}
className={`group flex w-full items-start gap-3 px-5 py-3.5 text-left transition-colors
duration-base hover:bg-surface-subtle/50 focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/40
sm:px-6`}
>
<span
aria-hidden="true"
className={cn(
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg transition-colors duration-base',
emphasis
? 'bg-krow-blue text-white'
: open
? 'bg-krow-blue-tint text-krow-blue'
: 'bg-surface-sunken text-ink-4 group-hover:text-ink-2'
)}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
id={`${id}-title`}
className={cn(
'font-heading font-semibold tracking-tight text-ink-1',
emphasis ? 'text-body font-bold' : 'text-body font-semibold'
)}
>
{title}
</span>
{meta !== undefined && meta !== null && meta !== '' && (
<span className="text-caption tabular-nums text-ink-4 font-normal">({meta})</span>
)}
</span>
{description && (
<span className="mt-0.5 block text-caption leading-relaxed text-ink-3">
{description}
</span>
)}
</span>
<ChevronRight
className={cn(
'mt-1 h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base ease-out',
open && 'rotate-90'
)}
aria-hidden="true"
/>
</button>
<Collapse open={open}>
<div id={`${id}-body`} className="px-5 pb-5 pt-1 sm:px-6 w-full">
{children}
</div>
</Collapse>
</section>
);
}
/** A labelled field in the document body. */
/** @param {any} props */
export function DocField({ id, label, hint, children, className }) {
const reactId = React.useId();
const controlId = id ? `${id}-control` : reactId;
return (
<div id={id} className={cn('scroll-mt-28 space-y-1', className)}>
<label
htmlFor={controlId}
className="block text-body-sm font-medium text-ink-2"
>
{label}
</label>
{React.isValidElement(children)
? React.cloneElement(children, { id: children.props.id || controlId })
: children}
{hint && <p className="text-caption leading-relaxed text-ink-4">{hint}</p>}
</div>
);
}
/**
* The head of a group inside a section — Pages, Skills, Knowledge.
*
* One notch quieter than a section title and one louder than a row, which is
* exactly the level of the thing it names.
*/
/** @param {any} props */
export function GroupHead({ id, icon: Icon, title, count, action, className }) {
return (
<div
id={id}
className={cn('flex scroll-mt-28 items-center gap-2.5 pt-3 pb-1.5', className)}
>
{Icon && <Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
<h4 className="text-body-sm font-semibold text-ink-1">{title}</h4>
{count !== undefined && count !== null && (
<span className="rounded-full bg-surface-sunken px-1.5 py-px text-caption font-medium tabular-nums text-ink-3">
{count}
</span>
)}
{action && <div className="ml-auto">{action}</div>}
</div>
);
}
/**
* A thing attached to the agent — a skill, a subagent — with a way to detach it.
*
* A row, not a card. Five attached skills as five cards is five borders
* competing with the section that holds them; as rows they read as a list,
* which is what they are.
*/
/** @param {any} props */
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }) {
return (
<li
className={`group/item -mx-2 flex items-start gap-2.5 rounded-lg px-2 py-2
transition-colors duration-base hover:bg-surface-subtle`}
>
{Icon && (
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-krow-blue" aria-hidden="true" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{title}</span>
{detail && (
<span className="mt-0.5 block text-caption leading-snug text-ink-3">{detail}</span>
)}
{warning && <span className="mt-0.5 block text-caption text-warning">{warning}</span>}
</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
aria-label={removeLabel}
className={`shrink-0 rounded-md p-1 text-ink-4 opacity-0 transition-all duration-base
hover:bg-destructive-muted hover:text-destructive focus-visible:opacity-100
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40
group-hover/item:opacity-100`}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
</li>
);
}
/**
* A behaviour setting: what it is, what it is set to, and the way to change it.
*
* Two shapes, one row. A setting whose control fits on the row carries it
* there — a switch, a segmented choice — and is not a button, because wrapping
* a switch in a button makes one press mean two things. A setting that needs
* space gets a chevron and opens underneath.
*/
/** @param {any} props */
export function SettingRow({
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
}) {
const expandable = Boolean(children);
const body = (
<>
{Icon && <Icon className="mt-0.5 h-4 w-4 shrink-0 text-ink-4" aria-hidden="true" />}
<span className="min-w-[11rem] flex-1 basis-[14rem]">
<span className="flex flex-wrap items-baseline gap-x-2">
<span className="text-body-sm font-medium text-ink-1">{title}</span>
{value !== undefined && value !== null && value !== '' && (
<span className="text-caption tabular-nums text-ink-3">{value}</span>
)}
</span>
{description && (
<span className="mt-0.5 block text-caption leading-snug text-ink-4">{description}</span>
)}
</span>
</>
);
return (
<div id={id} className={cn('scroll-mt-28', !last && 'border-b border-border/60')}>
{expandable ? (
<button
type="button"
aria-expanded={open}
onClick={onToggle}
className={`-mx-2 flex w-[calc(100%+1rem)] items-start gap-3 rounded-lg px-2 py-2.5 text-left
transition-colors duration-base hover:bg-surface-subtle
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
{body}
<ChevronRight
className={cn(
'mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base ease-out',
open && 'rotate-90'
)}
aria-hidden="true"
/>
</button>
) : (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 py-2.5">
{body}
{control && <div className="ml-auto shrink-0">{control}</div>}
</div>
)}
{expandable && (
<Collapse open={open}>
<div className="pb-3.5 pt-0.5">{children}</div>
</Collapse>
)}
</div>
);
}
/** A page an agent covers, or one it could. */
/** @param {any} props */
export function ScopeChip({ active, children, ...props }) {
return (
<button
type="button"
aria-pressed={active}
className={cn(
`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption font-medium
transition-all duration-base ease-out focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/40`,
active
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-1'
)}
{...props}
>
<span
aria-hidden="true"
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors duration-base',
active ? 'bg-krow-blue' : 'bg-border'
)}
/>
{children}
</button>
);
}
/* ── Identity ────────────────────────────────────────────────────────────── */
/** The icons an agent may wear, as a picker. */
/** @param {any} props */
export function IconPicker({ icons, value, onChange }) {
return (
<div className="grid grid-cols-5 gap-1.5">
{icons.map((icon) => {
const Icon = agentIconFor(icon);
const active = value === icon;
const name = icon.replace(/-/g, ' ');
return (
<button
key={icon}
type="button"
onClick={() => onChange(icon)}
title={name}
aria-label={name}
aria-pressed={active}
className={cn(
`flex h-9 items-center justify-center rounded-lg border transition-all duration-base
ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
active
? 'scale-105 border-krow-blue bg-krow-blue-tint text-krow-blue shadow-xs'
: 'border-border bg-surface text-ink-4 hover:-translate-y-px hover:border-krow-blue/40 hover:text-ink-2'
)}
>
{Icon
? <Icon className="h-4 w-4" />
: <OwliverAvatar className="h-4 w-4" rounded="rounded" />}
</button>
);
})}
</div>
);
}
/**
* How this agent will appear where people actually meet it.
*
* Identity is the one section whose fields have a visible consequence
* elsewhere, so the consequence is shown rather than described. It renders the
* same three things the switcher renders — icon, name, description — and
* nothing it does not.
*/
/** @param {any} props */
export function AgentPreview({ name, description, icon }) {
const Icon = agentIconFor(icon);
return (
<div className="rounded-xl border border-border bg-surface-subtle p-3">
<p className="text-overline uppercase text-ink-4">In Owliver</p>
<div className="mt-2 flex items-start gap-2.5 rounded-lg border border-border bg-surface p-2.5 shadow-xs">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-subtle"
>
{Icon
? <Icon className="h-3.5 w-3.5 text-krow-blue" />
: <OwliverAvatar className="h-4 w-4" rounded="rounded" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-semibold text-ink-1">
{name || 'New agent'}
</span>
<span className="mt-0.5 line-clamp-2 block text-caption leading-snug text-ink-3">
{description || 'No description yet.'}
</span>
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,681 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import {
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
SlidersHorizontal, Trash2, X, Zap,
} from 'lucide-react';
import {
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
SelectValue, Switch, Textarea,
} from '@/components/ds';
import { allSkills } from '@/lib/skills/registry';
import { surfaceFor, SUPPORTED_SKILL_PAGES } from '@/lib/skills/surfaces';
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
import {
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
SettingRow, Workspace,
} from './AgentCanvas';
import { AddSkillsModal } from './AddSkillsModal';
/**
* The agent configuration workspace.
*
* Four sections of one document — Identity, Instructions, Capabilities,
* Behavior — with a rail beside them that lists the same four and what is
* inside each. The rail is the page's table of contents and its scroll
* position; it is never a second source of state, which is why every value it
* shows is read from `fields` at render rather than tracked.
*
* Every control writes a *field*. The caller composes frontmatter from those
* fields at save time, which is what keeps Markdown the storage format while a
* recruiter never meets it.
*/
const SECTION_IDS = ['identity', 'instructions', 'capabilities', 'behavior'];
/**
* Which section the reader is in.
*
* Observed rather than set on click, so scrolling the document moves the rail
* too — a table of contents that only updates when you use it is a menu, not a
* position.
*
* Read from the sections' own top edges against a marker just below the sticky
* header, rather than from intersection ratios. A ratio-based spy cannot
* resolve the case this document actually has: Capabilities is long enough that
* it is still crossing the reading band when Behavior arrives, so "the first
* one intersecting" stays on Capabilities for the rest of the page. The last
* section gets the same exemption for the same reason in reverse — the document
* runs out of scroll before its top reaches the marker, so at the end of the
* page it is simply what is being read.
*/
function useActiveSection(ids) {
const [active, setActive] = React.useState(ids[0]);
React.useEffect(() => {
if (typeof window === 'undefined') return undefined;
let frame = 0;
const measure = () => {
frame = 0;
const marker = 140;
let current = ids[0];
for (const id of ids) {
const top = document.getElementById(id)?.getBoundingClientRect().top;
if (top !== undefined && top <= marker) current = id;
}
/* Only when the document genuinely scrolls. With every section closed it
fits on one screen, and "the end of the page" is then also its start —
which would light the last section while the reader is looking at the
first. */
const doc = document.documentElement;
const scrolls = doc.scrollHeight > window.innerHeight + 8;
const atBottom = window.innerHeight + window.scrollY >= doc.scrollHeight - 4;
if (scrolls && atBottom) current = ids[ids.length - 1];
setActive(current);
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
measure();
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
return () => {
if (frame) cancelAnimationFrame(frame);
window.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
};
}, [ids]);
return [active, setActive];
}
/** @param {any} props */
export function AgentConfigure({ fields, agents, customSkills = [], onChange }) {
const [addingSkills, setAddingSkills] = React.useState(false);
/* Sections are open by default: this is a document, and one that greets its
author with four closed headers hides the thing they came to write. Closing
is for focus, not for the initial reading. */
const [open, setOpen] = React.useState(
() => Object.fromEntries(SECTION_IDS.map((id) => [id, true]))
);
const [setting, setSetting] = React.useState(/** @type {string | null} */ (null));
const [active, setActive] = useActiveSection(SECTION_IDS);
const set = (patch) => onChange({ ...fields, ...patch });
const instructionWords = React.useMemo(
() => (fields.instructions.trim() ? fields.instructions.trim().split(/\s+/).length : 0),
[fields.instructions]
);
const registry = React.useMemo(() => allSkills(customSkills), [customSkills]);
const skillById = React.useMemo(() => new Map(registry.map((s) => [s.id, s])), [registry]);
const availableSubagents = React.useMemo(
() => agents.filter((a) => a.id !== fields.id && a.status === 'published'),
[agents, fields.id]
);
const reasoning = REASONING_MODES.find((m) => m.id === fields.reasoning);
const pageLabel = (p) => surfaceFor(p)?.label || p;
const coveredLabels = fields.pages.map(pageLabel);
const starterCount = fields.starters.filter((s) => s.label.trim()).length;
/**
* Going somewhere.
*
* A closed section is still addressable — the rail lists what is inside it,
* and following one of those leaves has to arrive at the field rather than at
* a header that happens to be shut. Opening first, scrolling on the next
* frame, so the target has a height by the time it is scrolled to.
*/
const goTo = React.useCallback((sectionId, itemId) => {
setOpen((current) => (current[sectionId] ? current : { ...current, [sectionId]: true }));
if (itemId && itemId.startsWith('behavior-')) setSetting(itemId);
setActive(sectionId);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const target = document.getElementById(itemId || sectionId)
|| document.getElementById(sectionId);
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
}, [setActive]);
const toggleSection = (id) => setOpen((current) => ({ ...current, [id]: !current[id] }));
const toggleSetting = (id) => setSetting((current) => (current === id ? null : id));
/* The rail's model. Derived from the same fields the document renders, so a
count on the rail cannot disagree with the list under it. */
const sections = [
{
id: 'identity',
icon: IdCard,
label: 'Identity',
meta: '',
items: [
{ id: 'identity-name', label: 'Name' },
{ id: 'identity-description', label: 'Description' },
{ id: 'identity-icon', label: 'Icon' },
{ id: 'identity-trigger', label: 'When to use' },
],
},
{
id: 'instructions',
icon: FileText,
label: 'Instructions',
meta: instructionWords ? `${instructionWords}w` : '',
items: [{ id: 'instructions-editor', label: 'Editor' }],
},
{
id: 'capabilities',
icon: Boxes,
label: 'Capabilities',
meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`,
items: [
{ id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length },
{ id: 'capabilities-skills', label: 'Skills', meta: fields.skills.length },
{ id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length },
],
},
{
id: 'behavior',
icon: SlidersHorizontal,
label: 'Behavior',
meta: reasoning?.label || '',
items: [
{ id: 'behavior-reasoning', label: 'Reasoning', meta: reasoning?.label },
{ id: 'behavior-starters', label: 'Starters', meta: starterCount },
{ id: 'behavior-web', label: 'Web search', meta: fields.webSearch ? 'On' : 'Off' },
{ id: 'behavior-subagents', label: 'Subagents', meta: fields.subagents.length },
],
},
];
return (
<>
<Workspace rail={<Rail sections={sections} activeId={active} onSelect={goTo} />}>
{/* ── Identity ───────────────────────────────────────────────────── */}
<DocSection
id="identity"
icon={IdCard}
title="Identity"
description="How this agent is presented to people using Owliver."
open={open.identity}
onToggle={() => toggleSection('identity')}
>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
<div className="lg:col-span-7 xl:col-span-7 space-y-3">
<DocField id="identity-name" label="Name">
<Input
value={fields.name}
placeholder="e.g. Positions Agent"
onChange={(e) => set({ name: e.target.value })}
className="w-full"
/>
</DocField>
<DocField
id="identity-description"
label="Description"
hint="One line, shown under the name when choosing an agent."
>
<Input
value={fields.description}
placeholder="e.g. Open roles — what they need and which are at risk."
onChange={(e) => set({ description: e.target.value })}
className="w-full"
/>
</DocField>
<DocField
id="identity-trigger"
label="When to use it"
hint="Helps a reader pick the right agent. Never matched against questions."
>
<Textarea
value={fields.trigger}
rows={3}
placeholder="e.g. Use on Positions, for open roles and applicant flow."
onChange={(e) => set({ trigger: e.target.value })}
className="w-full resize-y"
/>
</DocField>
</div>
<div className="lg:col-span-5 xl:col-span-5 space-y-3">
<AgentPreview
name={fields.name}
description={fields.description}
icon={fields.icon}
/>
<div id="identity-icon" className="scroll-mt-28 rounded-xl border border-border bg-surface-subtle/50 p-3">
<p className="mb-1.5 text-body-sm font-medium text-ink-2">Icon</p>
<IconPicker
icons={AGENT_ICONS}
value={fields.icon}
onChange={(icon) => set({ icon })}
/>
</div>
</div>
</div>
</DocSection>
{/* ── Instructions ───────────────────────────────────────────────── */}
<DocSection
id="instructions"
icon={FileText}
title="Instructions"
description="How this agent should answer, what it is responsible for, and what it should say when it cannot help."
meta={`${instructionWords} word${instructionWords === 1 ? '' : 's'}`}
open={open.instructions}
onToggle={() => toggleSection('instructions')}
emphasis
>
<div
id="instructions-editor"
className={`w-full scroll-mt-28 overflow-hidden rounded-xl border border-border
bg-surface transition-all duration-base ease-out
hover:border-border focus-within:border-krow-blue/50
focus-within:ring-2 focus-within:ring-krow-blue/15`}
>
<Textarea
value={fields.instructions}
rows={8}
placeholder={'Answer from the records this page holds.\n\nState a figure only where a skill has read it. When a reading needs a position and none is open, ask which rather than choosing one.'}
onChange={(e) => set({ instructions: e.target.value })}
className={`min-h-[11rem] resize-y rounded-none border-0 bg-transparent px-4 py-3
text-body-md leading-relaxed focus-visible:ring-0`}
/>
<div className="flex items-center justify-between gap-3 border-t border-border bg-surface-subtle px-4 py-2">
<span className="text-caption tabular-nums text-ink-4">
{instructionWords} word{instructionWords === 1 ? '' : 's'}
</span>
<span className="text-caption text-ink-4">Saved with the agent definition</span>
</div>
</div>
</DocSection>
{/* ── Capabilities ───────────────────────────────────────────────── */}
<DocSection
id="capabilities"
icon={Boxes}
title="Capabilities"
description="What this agent can reach and use when it answers."
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.skills.length} skill${fields.skills.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
open={open.capabilities}
onToggle={() => toggleSection('capabilities')}
>
<div className="divide-y divide-border/60">
{/* Pages ── the boundary. */}
<div className="pb-4">
<GroupHead
id="capabilities-pages"
icon={LayoutGrid}
title="Pages it covers"
count={fields.pages.length}
/>
<p className="pb-2 text-caption leading-relaxed text-ink-3">
{coveredLabels.length
? <>Answers on <span className="font-medium text-ink-1">{coveredLabels.join(', ')}</span>. Anywhere else it is shown as constrained and declines.</>
: 'No page selected — this agent has nowhere to answer yet.'}
</p>
<div className="flex flex-wrap gap-1.5">
{SUPPORTED_SKILL_PAGES.map((page) => (
<ScopeChip
key={page}
active={fields.pages.includes(page)}
onClick={() => set({
pages: fields.pages.includes(page)
? fields.pages.filter((p) => p !== page)
: [...fields.pages, page],
})}
>
{pageLabel(page)}
</ScopeChip>
))}
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-4">
A page decides which skills exist there. An agent chooses among them — it can
narrow that list, never widen it.
</p>
</div>
{/* Skills ── what it can do. */}
<div className="py-4">
<GroupHead
id="capabilities-skills"
icon={Zap}
title="Skills"
count={fields.skills.length}
action={(
<Button variant="outline" size="sm" onClick={() => setAddingSkills(true)}>
<Plus className="h-3.5 w-3.5" /> Add skills
</Button>
)}
/>
{fields.skills.length > 0 ? (
<ul className="divide-y divide-border">
{fields.skills.map((id) => {
const skill = skillById.get(id);
return (
<ItemRow
key={id}
icon={Zap}
title={skill?.name || id}
detail={skill?.description}
warning={skill ? null : 'No skill with this id is registered.'}
removeLabel={`Remove ${skill?.name || id}`}
onRemove={() => set({ skills: fields.skills.filter((x) => x !== id) })}
/>
);
})}
</ul>
) : (
<p className="text-body-sm text-ink-3">
No skills attached. This agent can still answer from the page&apos;s own reader.
</p>
)}
<p className="mt-2 text-caption text-ink-4">
Authored in{' '}
<Link
to="/admin/workspace/skills"
className="font-medium text-krow-blue underline-offset-2 hover:underline"
>
Workspace &rarr; Skills
</Link>
</p>
</div>
{/* Knowledge ── what it has been told. */}
<div className="pt-4">
<GroupHead
id="capabilities-knowledge"
icon={BookOpen}
title="Knowledge"
count={fields.knowledge.length}
action={(
<Button
variant="outline"
size="sm"
onClick={() => set({
knowledge: [
...fields.knowledge,
{ id: '', label: '', kind: 'note', body: '', url: '' },
],
})}
>
<Plus className="h-3.5 w-3.5" /> Add knowledge
</Button>
)}
/>
{fields.knowledge.length ? (
<div className="space-y-2.5">
{fields.knowledge.map((entry, i) => (
<div
key={entry.id || i}
className="animate-fade-in space-y-2.5 rounded-xl border border-border bg-surface-subtle p-3"
>
<div className="grid gap-2.5 sm:grid-cols-[minmax(0,1fr)_9rem]">
<Field label="Title">
<Input
value={entry.label}
placeholder="e.g. Overtime policy"
onChange={(e) => {
const next = [...fields.knowledge];
next[i] = { ...entry, label: e.target.value };
set({ knowledge: next });
}}
/>
</Field>
<Field label="Kind">
<Select
value={entry.kind}
onValueChange={(kind) => {
const next = [...fields.knowledge];
next[i] = { ...entry, kind };
set({ knowledge: next });
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{KNOWLEDGE_KINDS.map((k) => (
<SelectItem key={k} value={k} className="capitalize">
{k.replace('-', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
{entry.kind === 'link' ? (
<Field label="Link">
<Input
value={entry.url}
placeholder="https://…"
onChange={(e) => {
const next = [...fields.knowledge];
next[i] = { ...entry, url: e.target.value };
set({ knowledge: next });
}}
/>
</Field>
) : (
<Field
label="What it says"
hint="Quoted back with its source named. Nothing is invented from it."
>
<Textarea
value={entry.body}
rows={3}
onChange={(e) => {
const next = [...fields.knowledge];
next[i] = { ...entry, body: e.target.value };
set({ knowledge: next });
}}
/>
</Field>
)}
<Button
variant="ghost"
size="sm"
onClick={() => set({
knowledge: fields.knowledge.filter((_, x) => x !== i),
})}
>
<Trash2 className="h-3.5 w-3.5" /> Remove
</Button>
</div>
))}
</div>
) : (
<p className="text-body-sm text-ink-3">
No knowledge attached. Nothing is invented — this agent answers from skills only.
</p>
)}
</div>
</div>
</DocSection>
{/* ── Behavior ───────────────────────────────────────────────────── */}
<DocSection
id="behavior"
icon={SlidersHorizontal}
title="Behavior"
description="How this agent works when it answers."
meta={reasoning?.label}
open={open.behavior}
onToggle={() => toggleSection('behavior')}
last
>
<div>
<SettingRow
id="behavior-reasoning"
icon={Brain}
title="Reasoning"
description={reasoning?.summary || 'How much work an answer is worth.'}
control={(
<SegmentedToggle
size="sm"
ariaLabel="Reasoning depth"
value={fields.reasoning}
onChange={(id) => set({ reasoning: id })}
options={REASONING_MODES.map((m) => ({ value: m.id, label: m.label }))}
/>
)}
/>
<SettingRow
id="behavior-starters"
icon={MessageSquare}
title="Conversation starters"
value={starterCount ? `${starterCount} configured` : 'None'}
description="Questions offered as chips when this agent opens."
open={setting === 'behavior-starters'}
onToggle={() => toggleSetting('behavior-starters')}
>
<div className="space-y-2">
{fields.starters.map((starter, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={starter.label}
placeholder="e.g. Which roles are at risk?"
className="max-w-[30rem]"
onChange={(e) => {
const next = [...fields.starters];
next[i] = { label: e.target.value, prompt: e.target.value };
set({ starters: next });
}}
/>
<Button
variant="ghost"
size="icon"
aria-label="Remove starter"
onClick={() => set({ starters: fields.starters.filter((_, x) => x !== i) })}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => set({ starters: [...fields.starters, { label: '', prompt: '' }] })}
>
<Plus className="h-3.5 w-3.5" /> Add starter
</Button>
</div>
</SettingRow>
<div id="behavior-web" className="scroll-mt-28">
<SettingRow
icon={Globe}
title="Web search"
value={fields.webSearch ? 'On' : 'Off'}
description="Whether this agent may look outside the workspace."
control={(
<Switch
checked={fields.webSearch}
onCheckedChange={(webSearch) => set({ webSearch })}
aria-label="Allow web search"
/>
)}
/>
<Collapse open={fields.webSearch}>
<div className="pb-4">
<Alert tone="warning" title="No search provider is configured">
This deployment has no web search connected, so answers say plainly that
nothing outside the workspace was consulted.
</Alert>
</div>
</Collapse>
</div>
<SettingRow
id="behavior-subagents"
icon={Layers}
title="Subagents"
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'}
description="Other agents whose skills this one may also use. Most need none."
open={setting === 'behavior-subagents'}
onToggle={() => toggleSetting('behavior-subagents')}
last
>
<div className="space-y-3">
{fields.subagents.length > 0 && (
<ul className="divide-y divide-border">
{fields.subagents.map((id) => {
const sub = agents.find((a) => a.id === id);
return (
<ItemRow
key={id}
icon={Layers}
title={sub?.name || id}
detail={sub?.description}
warning={sub ? null : 'No agent with this id is registered.'}
removeLabel={`Remove ${sub?.name || id}`}
onRemove={() => set({
subagents: fields.subagents.filter((x) => x !== id),
})}
/>
);
})}
</ul>
)}
<Select
value=""
onValueChange={(id) => !fields.subagents.includes(id)
&& set({ subagents: [...fields.subagents, id] })}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder="Add a subagent..." />
</SelectTrigger>
<SelectContent>
{availableSubagents
.filter((a) => !fields.subagents.includes(a.id))
.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-caption leading-relaxed text-ink-4">
A subagent&apos;s skills are still bounded by the current page — borrowing them
cannot reach data this page does not hold.
</p>
</div>
</SettingRow>
</div>
</DocSection>
</Workspace>
<AddSkillsModal
open={addingSkills}
onOpenChange={setAddingSkills}
attached={fields.skills}
customSkills={customSkills}
onAdd={(ids) => set({ skills: [...fields.skills, ...ids] })}
/>
</>
);
}
export default AgentConfigure;

View File

@@ -0,0 +1,238 @@
import * as React from 'react';
import {
Activity,
BarChart3,
Clock,
HelpCircle,
MessageSquare,
ThumbsDown,
ThumbsUp,
Wrench,
} from 'lucide-react';
import { Badge, EmptyState } from '@/components/ds';
import { readHistory } from '@/components/ai-assistant/history';
import { conversationStats, reviewRow } from '@/lib/agents/conversationInsights';
import { allSkills } from '@/lib/skills/registry';
import { usePreferences } from '@/lib/krowHooks';
import { cn } from '@/lib/utils';
/**
* What this agent has actually been asked.
*
* Real-time usage analytics computed directly from the local telemetry log.
*/
/** @param {any} props */
function MetricCard({ icon: Icon, label, value, detail, highlight }) {
return (
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-caption font-medium text-ink-4 uppercase tracking-wider">{label}</span>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-surface-subtle text-ink-3">
<Icon className="h-3.5 w-3.5" />
</span>
</div>
<div className="flex items-baseline gap-2">
<span className="font-heading text-title-xl font-bold tabular-nums text-ink-1 sm:text-3xl">
{value}
</span>
{highlight && (
<span className="text-caption font-semibold text-krow-blue">{highlight}</span>
)}
</div>
{detail && <p className="text-caption text-ink-4">{detail}</p>}
</div>
);
}
/** @param {any} props */
export function AgentInsightsPanel({ agentId, agentName }) {
const preferences = usePreferences();
const [records] = React.useState(() => readHistory());
const stats = React.useMemo(
() => conversationStats(records, { agentId }),
[records, agentId]
);
const skillName = React.useMemo(() => {
const byId = new Map(allSkills(preferences.customSkills || []).map((s) => [s.id, s.name]));
return (id) => byId.get(id) || id;
}, [preferences.customSkills]);
const conversations = React.useMemo(
() => records
.filter((r) => (agentId ? r.agentId === agentId : true))
.slice(0, 8)
.map(reviewRow),
[records, agentId]
);
if (stats.empty) {
return (
<div className="w-full rounded-2xl border border-border bg-surface p-12 shadow-xs text-center">
<EmptyState
icon={MessageSquare}
title="No conversations with this agent yet"
description={`Insights are counted from conversations held on this device. Ask ${agentName || 'this agent'} something in Owliver and the figures appear here.`}
/>
</div>
);
}
return (
<div className="w-full space-y-4">
{/* ── KPI Metric Cards ───────────────────────────────────────────── */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
icon={MessageSquare}
label="Total Conversations"
value={stats.total}
detail={`Active across ${stats.activeDays} day${stats.activeDays === 1 ? '' : 's'}`}
/>
<MetricCard
icon={HelpCircle}
label="Questions Asked"
value={stats.turns}
detail={`${stats.averageTurns} per session average`}
/>
<MetricCard
icon={Activity}
label="Surfaces Utilized"
value={stats.pages}
detail="Operational pages touched"
/>
<MetricCard
icon={ThumbsUp}
label="Feedback Rating"
value={stats.feedback.score === null ? '—' : `${stats.feedback.score}%`}
detail={
stats.feedback.score === null
? 'No ratings submitted'
: `${stats.feedback.up} helpful · ${stats.feedback.down} unhelpful`
}
/>
</div>
{/* ── 2-Column Ranking Breakdowns ────────────────────────────────── */}
<div className="grid gap-4 md:grid-cols-2">
{/* Most Used Skills */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-krow-blue" />
<h4 className="font-heading text-body font-semibold text-ink-1">Most Triggered Skills</h4>
</div>
{stats.bySkill.length ? (
<div className="space-y-2">
{stats.bySkill.slice(0, 5).map((row) => (
<div
key={row.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
>
<span className="truncate font-medium text-ink-2">{skillName(row.id)}</span>
<Badge variant="neutral" size="sm" className="tabular-nums">
{row.count} query{row.count === 1 ? '' : 'ies'}
</Badge>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
No skill executions logged yet. Conversations were handled directly by native page readers.
</div>
)}
</div>
{/* Actions Executed */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2">
<Wrench className="h-4 w-4 text-krow-blue" />
<h4 className="font-heading text-body font-semibold text-ink-1">Actions & Tool Usage</h4>
</div>
{stats.byTool.length ? (
<div className="space-y-2">
{stats.byTool.slice(0, 5).map((row) => (
<div
key={row.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
>
<span className="truncate font-medium text-ink-2">{row.id.replace(/_/g, ' ')}</span>
<Badge variant="neutral" size="sm" className="tabular-nums">
{row.count} run{row.count === 1 ? '' : 's'}
</Badge>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
No action tool executions recorded in current audit window.
</div>
)}
</div>
</div>
{/* ── Recent Conversations Table ─────────────────────────────────── */}
<div className="rounded-2xl border border-border bg-surface shadow-xs overflow-hidden">
<div className="border-b border-border/80 bg-surface-subtle/40 px-5 py-4 sm:px-6">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-krow-blue" />
<h4 className="font-heading text-body font-semibold text-ink-1">
Recent Conversation Logs
</h4>
</div>
</div>
<div className="divide-y divide-border/60">
{conversations.map((row) => (
<div
key={row.id}
className="flex flex-wrap items-center justify-between gap-3 px-5 py-3.5 sm:px-6 hover:bg-surface-subtle/30 transition-colors"
>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="truncate text-body-sm font-medium text-ink-1">
{row.title || 'General consultation'}
</p>
<p className="text-caption text-ink-4">
Surface:{' '}
<span className="font-medium text-ink-3">{row.page || 'Direct'}</span>
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-caption tabular-nums text-ink-4">
{row.turns} turn{row.turns === 1 ? '' : 's'}
</span>
{row.feedback && (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
row.feedback.rating === 'up'
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800'
: 'bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-800'
)}
>
{row.feedback.rating === 'up' ? (
<>
<ThumbsUp className="h-2.5 w-2.5" /> Helpful
</>
) : (
<>
<ThumbsDown className="h-2.5 w-2.5" /> Unhelpful
</>
)}
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
export default AgentInsightsPanel;

View File

@@ -0,0 +1,347 @@
import * as React from 'react';
import {
AlertCircle,
BookOpen,
CheckCircle2,
Cpu,
Layers,
Play,
Search,
Sparkles,
Wrench,
} from 'lucide-react';
import {
Badge,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ds';
import { allSkills, matchSkill, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
import { agentCovers, agentScopedDisabled, classifyQuestion } from '@/lib/agents/runtime';
import { toolsForContext } from '@/lib/skills/tools';
import { retrieveKnowledge } from '@/lib/agents/knowledge';
import { cn } from '@/lib/utils';
/**
* What this agent would do, on a page, before it is published.
*
* Runs the live runtime classifier, skill matcher, tool resolver, and
* knowledge retriever to display comprehensive diagnostics.
*/
const CONTEXT_OPTIONS = Object.entries(PLACEMENT_ROUTES).map(([route, contextId]) => ({
contextId,
route,
label: ASSISTANT_CONTEXTS[contextId]?.page || contextId,
}));
const CLASSIFICATION_COPY = {
structured: 'Reads workforce records through an attached skill.',
knowledge: 'Answers directly from reference knowledge.',
combined: 'Requires both structured records and knowledge retrieval.',
};
/** @param {any} props */
export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
const [contextId, setContextId] = React.useState(
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|| CONTEXT_OPTIONS[0].contextId
);
const [question, setQuestion] = React.useState('What needs my attention?');
/* The draft as the runtime sees it */
const draftAgent = React.useMemo(() => ({
id: fields.id || 'draft',
name: fields.name || 'This agent',
pages: fields.pages,
skills: fields.skills,
subagents: fields.subagents,
knowledge: fields.knowledge,
starters: fields.starters,
reasoning: fields.reasoning,
webSearch: fields.webSearch,
status: 'published',
}), [fields]);
const registry = React.useMemo(() => allSkills(customSkills), [customSkills]);
const result = React.useMemo(() => {
const covers = agentCovers(draftAgent, contextId);
const disabled = agentScopedDisabled(draftAgent, registry, []);
const pageOnly = skillsForContext(contextId, [], customSkills);
const scoped = covers ? skillsForContext(contextId, disabled, customSkills) : [];
const matched = covers ? matchSkill(question, contextId, disabled, customSkills) : null;
return {
covers,
pageOnly,
scoped,
matched,
tools: covers ? toolsForContext(contextId, disabled, customSkills) : [],
knowledge: retrieveKnowledge({ agent: draftAgent, contextId, question }),
classification: classifyQuestion({ question }),
};
}, [draftAgent, contextId, question, registry, customSkills]);
const activePageLabel = ASSISTANT_CONTEXTS[contextId]?.page || 'Current Page';
return (
<div className="w-full space-y-4">
{/* ── Test Workbench Card ────────────────────────────────────────── */}
<div className="w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden">
<div className="border-b border-border/80 bg-surface-subtle/40 px-5 py-4 sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-krow-blue-tint text-krow-blue">
<Sparkles className="h-4 w-4" />
</span>
<div>
<h3 className="font-heading text-body font-semibold text-ink-1">
Simulation & Scope Diagnostics
</h3>
<p className="text-caption text-ink-4">
Evaluate real-time intent routing and skill activation without publishing changes.
</p>
</div>
</div>
{dirty && (
<Badge variant="warning" size="sm" className="gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-warning animate-pulse" />
Testing unsaved draft
</Badge>
)}
</div>
</div>
<div className="p-5 sm:p-6 space-y-4">
<div className="grid gap-4 md:grid-cols-12 items-end">
<div className="md:col-span-4 space-y-1.5">
<label className="block text-body-sm font-medium text-ink-2">
Contextual Page
</label>
<Select value={contextId} onValueChange={setContextId}>
<SelectTrigger className="w-full bg-surface">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTEXT_OPTIONS.map((o) => (
<SelectItem key={o.contextId} value={o.contextId}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="md:col-span-8 space-y-1.5">
<label className="block text-body-sm font-medium text-ink-2">
Simulated User Query
</label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-4" />
<Input
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="e.g. Which roles are at risk?"
className="pl-9 w-full bg-surface"
/>
</div>
</div>
</div>
{fields.starters.filter((s) => s.label).length > 0 && (
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-border/50">
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">
Suggested Starters:
</span>
{fields.starters.filter((s) => s.label).map((s, i) => (
<button
key={i}
type="button"
onClick={() => setQuestion(s.prompt || s.label)}
className={cn(
`inline-flex items-center gap-1.5 rounded-lg border border-border/80 bg-surface px-2.5 py-1
text-caption font-medium text-ink-2 transition-all duration-base
hover:border-krow-blue/40 hover:bg-krow-blue-tint/30 hover:text-krow-blue
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`,
question === (s.prompt || s.label) && 'border-krow-blue bg-krow-blue-tint text-krow-blue'
)}
>
<Play className="h-3 w-3 shrink-0" aria-hidden="true" />
<span>{s.label}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* ── Active Status Banner ───────────────────────────────────────── */}
<div
className={cn(
'flex items-start gap-3.5 rounded-2xl border p-4 sm:p-5 transition-colors',
result.covers
? 'border-emerald-200 bg-emerald-50/70 text-emerald-950 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-100'
: 'border-amber-200 bg-amber-50/70 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100'
)}
>
<span
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full mt-0.5',
result.covers
? 'bg-emerald-600 text-white dark:bg-emerald-500'
: 'bg-amber-600 text-white dark:bg-amber-500'
)}
>
{result.covers ? <CheckCircle2 className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
</span>
<div className="min-w-0 flex-1">
<h4 className="font-heading text-body font-semibold">
{result.covers ? `Active on ${activePageLabel}` : `Constrained on ${activePageLabel}`}
</h4>
<p className="mt-0.5 text-body-sm leading-relaxed opacity-90">
{result.covers
? result.scoped.length
? `${result.scoped.length} of ${result.pageOnly.length} operational skills are active and ready on this surface.`
: `This page provides ${result.pageOnly.length} skills. With none attached, this agent will answer using the page's standard reader.`
: `This agent has not been assigned to ${activePageLabel}. When asked on this page, it will politely decline or defer to the workspace reader.`}
</p>
</div>
</div>
{/* ── 4-Quadrant Diagnostic Grid ─────────────────────────────────── */}
<div className="grid gap-4 md:grid-cols-2">
{/* Quadrant 1: Intent & Skill Routing */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2 text-ink-1">
<Cpu className="h-4 w-4 text-krow-blue shrink-0" />
<h4 className="font-heading text-body font-semibold">Intent & Skill Routing</h4>
</div>
<p className="text-caption text-ink-3">
{CLASSIFICATION_COPY[result.classification] || CLASSIFICATION_COPY.structured}
</p>
{result.matched ? (
<div className="rounded-xl border border-krow-blue/40 bg-krow-blue-tint/40 p-3 space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-body-sm font-semibold text-krow-blue">
{result.matched.name}
</span>
<Badge variant="info" size="sm">Matched</Badge>
</div>
<p className="text-caption text-ink-3 leading-relaxed">
{result.matched.description}
</p>
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
No specialized skill claimed this exact wording. The native page reader will process the query.
</div>
)}
</div>
{/* Quadrant 2: Reachable Skills */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center justify-between gap-2 text-ink-1">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-krow-blue shrink-0" />
<h4 className="font-heading text-body font-semibold">Reachable Page Skills</h4>
</div>
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
{result.scoped.length}
</span>
</div>
{result.scoped.length ? (
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
{result.scoped.map((s) => (
<div
key={s.id}
className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
>
<span className="truncate font-medium text-ink-2">{s.name}</span>
<span className="text-caption text-ink-4 tabular-nums">Ready</span>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
No attached skills apply to {activePageLabel}.
</div>
)}
</div>
{/* Quadrant 3: Execution Capabilities */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center justify-between gap-2 text-ink-1">
<div className="flex items-center gap-2">
<Wrench className="h-4 w-4 text-krow-blue shrink-0" />
<h4 className="font-heading text-body font-semibold">Execution Actions & Tools</h4>
</div>
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
{result.tools.length}
</span>
</div>
{result.tools.length ? (
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
{result.tools.map((tool) => (
<div
key={tool.name}
className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
>
<span className="truncate font-medium text-ink-2">{tool.label}</span>
{tool.requiresApproval ? (
<Badge variant="warning" size="sm">Confirmation required</Badge>
) : (
<Badge variant="neutral" size="sm">Read-only</Badge>
)}
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
Read-only operations. No active mutations or system write actions.
</div>
)}
</div>
{/* Quadrant 4: Referenced Knowledge */}
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
<div className="flex items-center gap-2 text-ink-1">
<BookOpen className="h-4 w-4 text-krow-blue shrink-0" />
<h4 className="font-heading text-body font-semibold">Referenced Knowledge</h4>
</div>
{result.knowledge.passages.length ? (
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
{result.knowledge.passages.map((p) => (
<div
key={p.chunkId}
className="rounded-lg border border-border/70 bg-surface-subtle/50 p-2.5 text-body-sm"
>
<span className="block font-medium text-ink-1">{p.label}</span>
<span className="mt-0.5 line-clamp-2 block text-caption text-ink-3 leading-relaxed">
{p.text}
</span>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
{result.knowledge.note || 'No custom reference knowledge attached to this agent.'}
</div>
)}
</div>
</div>
</div>
);
}
export default AgentTestPanel;

View File

@@ -0,0 +1,30 @@
import {
Activity, BarChart3, Briefcase, GraduationCap, Layers, Shield, Sparkles, UserCheck, Users,
} from 'lucide-react';
/**
* Agent icon id → component.
*
* A hand-written table, for the same reason `SECTION_COMPONENTS` is one: a
* definition names a key, and only a key written down here resolves to
* anything. There is no path from a Markdown file to a component nobody chose.
*
* `owliver` is deliberately absent — it is not a lucide glyph but the product's
* own avatar, and the switcher draws it directly. Returning `null` for it is
* the signal to do that, which keeps the primary agent looking like Owliver
* rather than like a generic icon.
*/
const ICONS = {
sparkles: Sparkles,
briefcase: Briefcase,
users: Users,
'user-check': UserCheck,
layers: Layers,
'graduation-cap': GraduationCap,
'bar-chart': BarChart3,
activity: Activity,
shield: Shield,
};
/** The component for an icon id, or null when the avatar should be drawn. */
export const agentIconFor = (id) => ICONS[String(id || '').trim()] ?? null;

View File

@@ -0,0 +1,196 @@
import * as React from 'react';
import { usePreferences } from '@/lib/krowHooks';
import { allAgents } from '@/lib/agents/registry';
import {
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
} from '@/lib/agents/runtime';
/**
* Which agent is answering.
*
* Built on the same principles as `PageContext.jsx`, and deliberately kept
* beside it rather than merged into it:
*
* - **The page is not an input to itself.** This context reads the page's
* assistant context to decide a *default*, and never the other way round.
* Nothing here can change which page the reader is on, which is what makes
* "switching agent cannot change PageContext" true by construction rather
* than by care.
* - **Session-scoped, like the panel's own window state.** Choosing an agent
* for the afternoon should not rewrite an account default, and a new tab
* should open on the page's own agent.
*
* Resolution order: what this session chose *and that still applies here* → the
* account's stated default, on the same condition → the page's own agent → the
* general agent. A page therefore always opens on the agent written for it, a
* page nobody wrote one for opens on the general agent rather than on nothing,
* and a deliberate choice still survives navigation across the pages it covers.
*
* **A selection is made somewhere.** That is the correction this file carries:
* it stores the context a choice was made on, not only the agent's id. Storing
* the id alone meant a specialist chosen on Positions followed the reader onto
* Settings and constrained a page nobody had chosen it for — the panel looked
* broken and the reason was invisible. The rule itself lives in
* `runtime.resolveSelection` as a pure function, so what applies where is
* decided in one place and can be proved without a React tree.
*/
const AgentContext = React.createContext(null);
const SESSION_KEY = 'krow_assistant:agent';
/**
* The stored selection: `{ id, contextId }`.
*
* A bare string is still read, because that is what earlier sessions wrote and
* a stored value from yesterday should not throw. It resolves as a choice made
* nowhere, which is the honest reading of it — and the conservative one, since a
* selection with no context applies only where it legitimately covers.
*/
function readSelection() {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return null;
if (!raw.startsWith('{')) return { id: raw, contextId: null };
const parsed = JSON.parse(raw);
return parsed?.id ? { id: parsed.id, contextId: parsed.contextId ?? null } : null;
} catch {
/* Private mode, or a value that will not parse: the session simply always
opens on the page's own agent. */
return null;
}
}
function writeSelection(selection) {
try {
if (selection?.id) sessionStorage.setItem(SESSION_KEY, JSON.stringify(selection));
else sessionStorage.removeItem(SESSION_KEY);
} catch {
/* Held in memory for this session only. */
}
}
export function AgentProvider({ contextId = null, children }) {
const preferences = usePreferences();
/* Shipped definitions plus anything this account has authored, read through
the one registry so the switcher and the management page cannot disagree
about what exists. */
const agents = React.useMemo(
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
[preferences.customAgents, preferences.customSkills]
);
const [selection, setSelection] = React.useState(() => readSelection());
const select = React.useCallback((id) => {
/* Stamped with where it was chosen. A specialist picked on a page it does
not cover is a deliberate act and is honoured *here*; the stamp is what
stops it from becoming a decision about every other page too. */
const next = id ? { id, contextId } : null;
setSelection(next);
writeSelection(next);
}, [contextId]);
/** Back to whichever agent this page resolves on its own. */
const clearSelection = React.useCallback(() => {
setSelection(null);
writeSelection(null);
}, []);
/* What the stored choice means on the page the reader is on now. */
const applied = React.useMemo(
() => resolveSelection(agents, selection, contextId),
[agents, selection, contextId]
);
/**
* Storage housekeeping, in one place.
*
* Retiring a spent selection and re-stamping a live one are both writes about
* *navigation*, not about a choice, so they belong in an effect rather than in
* `select`. Both converge: the effect only writes when the stored value would
* actually change.
*/
React.useEffect(() => {
if (applied.retire) {
setSelection(null);
writeSelection(null);
return;
}
if (applied.id && selection?.contextId !== contextId) {
const next = { id: applied.id, contextId };
setSelection(next);
writeSelection(next);
}
}, [applied, selection, contextId]);
const value = React.useMemo(() => {
/**
* What this turn asks for.
*
* The session's choice first, then the account default — and the account
* default is put through the same rule, as a choice made nowhere. A stated
* default that does not cover this page is not a decision about this page,
* so it must not constrain it; the page resolves its own agent instead.
*/
const stated = applied.id
|| resolveSelection(agents, preferences.defaultAgentId || null, contextId).id
|| null;
const turn = resolveAgentForTurn(agents, stated, contextId);
/* The page's own agent, offered as the way out of a constrained state.
Null on a page nobody wrote one for — `fallback` is what answers there. */
const native = nativeAgentForContext(agents, contextId);
/* What this page opens on with nothing chosen: its own agent, or the
general one. Never null, so no page is ever left without an agent. */
const fallback = resolveDefaultAgent(agents, contextId);
return {
agents,
/* The agent that will answer. Never silently swapped: a reader who chose
one *for this page* gets that one, with `covers` saying whether it
belongs here. A choice carried in from another page is not that. */
agent: turn.agent,
covers: turn.covers,
native,
/* What this page resolves to with nothing chosen — its own agent, or the
general one. This is what a constrained answer points at, so that the
way out of a constrained state is named on every page rather than only
on the pages that have a specialist. */
defaultAgent: fallback,
/* True while this page is simply showing the agent it resolves on its
own, whether that is its specialist or the general agent. */
isNative: Boolean(fallback && turn.agent && fallback.id === turn.agent.id && !applied.id),
chosenId: applied.id,
select,
clearSelection,
/* Whether a given agent belongs on this page, for the list. */
coversPage: (candidate) => agentCovers(candidate, contextId),
};
}, [agents, applied, preferences.defaultAgentId, contextId, select, clearSelection]);
return <AgentContext.Provider value={value}>{children}</AgentContext.Provider>;
}
/**
* The active agent.
*
* Returns an inert value outside a provider, so a panel rendered in isolation —
* a test, a storybook — behaves as it did before agents existed rather than
* crashing.
*/
export function useActiveAgent() {
return React.useContext(AgentContext) ?? {
agents: [],
agent: null,
covers: true,
native: null,
defaultAgent: null,
isNative: false,
chosenId: null,
select: () => {},
clearSelection: () => {},
coversPage: () => false,
};
}

View File

@@ -0,0 +1,234 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown, Plus, Search, SquareArrowOutUpRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { searchAgents } from '@/lib/agents/registry';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { useActiveAgent } from './AgentContext';
/**
* The agent switcher, inside Owliver's own header.
*
* Designed with high-density Krow Control Tower aesthetics: clean popover
* geometry, smooth row hover/active states, and legible status indicators.
*/
/** The avatar for the primary agent; a lucide glyph for the rest. */
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
const Icon = agentIconFor(agent?.icon);
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
className
)}
aria-hidden="true"
>
<Icon className="h-4 w-4" />
</span>
);
}
/** The label a constrained agent carries, in the switcher and in the header. */
function ConstrainedTag({ className = '' }) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300',
className
)}
>
Constrained
</span>
);
}
/** One row in the list. */
function AgentRow({ agent, active, covers, onSelect }) {
return (
<button
type="button"
role="option"
aria-selected={active}
onClick={() => onSelect(agent.id)}
className={cn(
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-all',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
active
? 'bg-krow-blue-tint/70 text-ink-1'
: 'hover:bg-surface-subtle text-ink-2'
)}
>
<AgentGlyph agent={agent} className="h-7 w-7 shrink-0" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="min-w-0 truncate text-body-sm font-semibold text-ink-1">
{agent.name}
</span>
{!covers && <ConstrainedTag />}
</span>
{agent.description && (
<span className="mt-0.5 block truncate text-[11px] leading-tight text-ink-4">
{agent.description}
</span>
)}
</span>
{active && (
<Check className="h-4 w-4 text-krow-blue shrink-0 ml-1" aria-hidden="true" />
)}
</button>
);
}
export function AgentSwitcher({ page }) {
const navigate = useNavigate();
const { agents, agent, covers, select, coversPage } = useActiveAgent();
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const listRef = React.useRef(null);
React.useEffect(() => {
if (!open) setQuery('');
}, [open]);
const matches = React.useMemo(() => {
const found = searchAgents(agents, query);
return [...found].sort((a, b) => Number(coversPage(b)) - Number(coversPage(a)));
}, [agents, query, coversPage]);
const choose = (id) => {
select(id);
setOpen(false);
};
const onListKeyDown = (event) => {
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
if (!step) return;
const rows = [...(listRef.current?.querySelectorAll('[role="option"]') ?? [])];
if (!rows.length) return;
event.preventDefault();
const at = rows.indexOf(document.activeElement);
rows[Math.max(0, Math.min(rows.length - 1, (at === -1 ? 0 : at + step)))]?.focus();
};
if (!agent) {
return (
<>
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
<div className="min-w-0 flex-1">
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
</div>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`Owliver agent: ${agent.name}. Switch to another agent`}
aria-haspopup="listbox"
aria-expanded={open}
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<AgentGlyph agent={agent} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1">
<span className="min-w-0 truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
Owliver
</span>
<ChevronDown
className="h-3 w-3 shrink-0 text-ink-4 transition-colors group-hover:text-krow-blue"
aria-hidden="true"
/>
</span>
<span className="flex items-center gap-1.5">
<span className="truncate text-caption leading-tight text-ink-3">{page}</span>
{!covers && <ConstrainedTag />}
</span>
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-[21.5rem] p-0 shadow-lg rounded-xl border border-border/80 overflow-hidden">
<div className="border-b border-border/60 bg-surface-subtle/40 px-3.5 py-2.5">
<p className="font-heading text-body-sm font-semibold text-ink-1">Switch agent</p>
<p className="mt-0.5 text-[11px] text-ink-4">
Choose an agent to focus Owliver's scope on this page.
</p>
</div>
<div className="px-3 pt-2.5 pb-1">
<label className="relative block">
<span className="sr-only">Search agents</span>
<Search
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
aria-hidden="true"
/>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search agents..."
className="w-full h-8 rounded-lg border border-border/70 bg-surface-subtle/40 py-1 pl-8 pr-2.5 text-body-sm text-ink-1 placeholder:text-ink-4 transition-all focus:bg-surface focus:border-krow-blue/50 focus:outline-none focus:ring-1 focus:ring-krow-blue/30"
/>
</label>
</div>
<div
ref={listRef}
role="listbox"
aria-label="Agents"
onKeyDown={onListKeyDown}
className="max-h-[16rem] space-y-0.5 overflow-y-auto px-2 py-1.5"
>
{matches.map((candidate) => (
<AgentRow
key={candidate.id}
agent={candidate}
active={candidate.id === agent.id}
covers={coversPage(candidate)}
onSelect={choose}
/>
))}
{!matches.length && (
<p className="px-3 py-4 text-center text-body-sm text-ink-3">No agents match that.</p>
)}
</div>
<div className="flex items-center justify-between border-t border-border/80 bg-surface-subtle/30 px-3 py-2">
<button
type="button"
onClick={() => { setOpen(false); navigate('/admin/workspace/agents'); }}
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-caption font-medium text-ink-3 transition-colors hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<SquareArrowOutUpRight className="h-3 w-3" aria-hidden="true" />
Browse all
</button>
<button
type="button"
onClick={() => { setOpen(false); navigate('/admin/workspace/agents/new'); }}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-caption font-semibold text-krow-blue transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
Create agent
</button>
</div>
</PopoverContent>
</Popover>
);
}
export default AgentSwitcher;

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { ThumbsDown, ThumbsUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
@@ -35,9 +36,58 @@ export function ThinkingIndicator() {
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
* column on padding around them.
*/
/**
* How this conversation was rated.
*
* Offered on the latest answer only, because a rating belongs to the
* conversation rather than to a paragraph of it — thumbs on every turn would
* ask a reader to score an answer out of the exchange that produced it, and
* each one would set the same value anyway.
*
* Hidden until the turn is hovered or focused, the way History's delete control
* already is: a thread of reports should not carry two permanent buttons under
* every answer.
*/
function FeedbackControls({ feedback, onFeedback }) {
const rate = (rating) => onFeedback(feedback?.rating === rating ? null : rating);
return (
<div
className={cn(
'mt-2 flex items-center gap-0.5 transition-opacity',
/* Stays visible once rated, so a reader can see and change their own
answer rather than having to hunt for it again. */
feedback ? 'opacity-100' : 'opacity-0 group-hover/turn:opacity-100 group-focus-within/turn:opacity-100'
)}
>
{[
{ rating: 'up', Icon: ThumbsUp, label: 'This answer was useful' },
{ rating: 'down', Icon: ThumbsDown, label: 'This answer was not useful' },
].map(({ rating, Icon, label }) => (
<button
key={rating}
type="button"
onClick={() => rate(rating)}
aria-label={label}
aria-pressed={feedback?.rating === rating}
className={cn(
`rounded p-1 transition-colors focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
feedback?.rating === rating
? 'text-krow-blue'
: 'text-ink-4 hover:text-ink-2'
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</button>
))}
</div>
);
}
export const Message = React.memo(
/** @param {any} props */
({ role, text, blocks, streaming, stopped, onPrompt }) => {
({ role, text, blocks, streaming, stopped, onPrompt, feedback = null, onFeedback = null }) => {
if (role === 'user') {
return (
<div className="flex justify-end">
@@ -49,7 +99,7 @@ export const Message = React.memo(
}
return (
<div className="animate-slide-up">
<div className="group/turn animate-slide-up">
<div className="mb-2 flex items-center gap-1.5">
<OwliverAvatar className="h-5 w-5" rounded="rounded-full" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
@@ -58,6 +108,12 @@ export const Message = React.memo(
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} />
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
{/* Only where a handler was passed — a turn nobody is collecting feedback
for renders exactly as it did before this existed. */}
{onFeedback && !streaming && (
<FeedbackControls feedback={feedback} onFeedback={onFeedback} />
)}
</div>
);
});

View File

@@ -2,6 +2,7 @@ import * as React from 'react';
import { base44 } from '@/api/base44Client';
import { resolveAssistantContext } from './placement';
import { PageContextProvider } from './PageContext';
import { AgentProvider } from './AgentContext';
/**
* Window state for the Owliver panel: open/collapsed and default/expanded.
@@ -195,9 +196,16 @@ export function AssistantPanelProvider({ role, pathname, children }) {
/* The page's own selection travels beside the window state, mounted here so
both the page and the panel are inside it — a skill's data source resolves
against the record the reader has open, whichever of the two is asking. */
/* The page's own selection travels beside the window state, and the active
agent beside both. `AgentProvider` is given the resolved context id and can
only read it — nothing inside it can change which page the reader is on,
which is what makes "switching agent never moves PageContext" structural
rather than a rule to remember. */
return (
<AssistantPanelContext.Provider value={value}>
<PageContextProvider>{children}</PageContextProvider>
<PageContextProvider>
<AgentProvider contextId={context?.id ?? null}>{children}</AgentProvider>
</PageContextProvider>
</AssistantPanelContext.Provider>
);
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
} from 'lucide-react';
@@ -9,12 +9,12 @@ import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import {
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
useMarkInterviewReady, useUpdateJobPosting,
useMarkInterviewReady, useShiftRecords, useUpdateJobPosting,
usePreferences, useRoleCategories,
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { runAction } from '@/lib/skills/actions';
import { skillsForContext } from '@/lib/skills/registry';
import { allSkills, skillsForContext } from '@/lib/skills/registry';
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
@@ -23,7 +23,10 @@ import { useAssistantPanel } from './AssistantPanelContext';
import { usePageContext } from './PageContext';
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime';
import { buildOwliverContext } from '@/lib/agents/context';
import { AgentSwitcher } from './AgentSwitcher';
import { useActiveAgent } from './AgentContext';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
@@ -215,6 +218,7 @@ export default function KrowAssistant({
const facts = useAssistantFacts();
const userName = useCurrentUserName();
const navigate = useNavigate();
const location = useLocation();
/* A question a page has handed over — see the effect below `runPrompt`. */
const { request: panelRequest, consumeRequest } = useAssistantPanel();
@@ -230,10 +234,36 @@ export default function KrowAssistant({
the app already has rather than from anything written into this component.
A page whose skills change needs no edit here. */
const preferences = usePreferences();
const disabledSkills = React.useMemo(
/* `defaultAgent` rather than `native`: on a page with no agent of its own the
way out of a constrained state is the general agent, and naming nothing
there would leave the reader with a decline and no next step. */
const { agent, covers: agentCoversPage, defaultAgent } = useActiveAgent();
/**
* What this page will not offer.
*
* The account's own switched-off skills, plus everything the active agent
* does not carry. **This one memo is the entire agent scoping**: every
* consumer below — the skill list, the suggestions, the conversation, the
* tools — already takes `disabledSkills`, so none of them needed changing.
*
* It can only ever subtract. `getSkillsForPage` applies the page filter and
* this list in the same expression, so an agent cannot make a skill appear on
* a page that does not carry it, and with no agent the list is exactly the
* account's own.
*/
const accountDisabled = React.useMemo(
() => preferences.disabledSkills || [],
[preferences.disabledSkills]
);
const allRegisteredSkills = React.useMemo(
() => allSkills(preferences.customSkills || []),
[preferences.customSkills]
);
const disabledSkills = React.useMemo(
() => agentScopedDisabled(agent, allRegisteredSkills, accountDisabled),
[agent, allRegisteredSkills, accountDisabled]
);
const { data: customCategories = [] } = useRoleCategories();
const roles = React.useMemo(
() => [...new Set([...ROLE_CATEGORIES, ...customCategories.map((c) => c.name)])],
@@ -270,6 +300,17 @@ export default function KrowAssistant({
[statesFor, facts.forge, facts.profiles, facts.user]
);
const { data: assignments = [] } = useAssignments();
/* Shifts worked, missed and overrun — the records behind attendance and
overtime. Read from the same cache a page would, so a card and an answer
about the same fortnight cannot disagree. */
const { data: shifts = [] } = useShiftRecords();
/* Where the reader is, composed from the page's own channel — see
`lib/agents/context.js`. Read-only: nothing here writes back to the page. */
const owliverContext = React.useMemo(
() => buildOwliverContext({ context, pathname: location.pathname, pageContext }),
[context, location.pathname, pageContext]
);
const skillContext = React.useMemo(() => ({
...pageContext,
applications: facts.applications || [],
@@ -284,8 +325,9 @@ export default function KrowAssistant({
assignments,
staff: facts.staff || [],
activity: facts.activity || [],
shifts,
trainingPaths,
}), [pageContext, facts, assignments, trainingPaths]);
}), [pageContext, facts, assignments, shifts, trainingPaths]);
/**
* Which face of the panel the body is showing.
@@ -407,6 +449,7 @@ export default function KrowAssistant({
const {
messages, pending, error, busy, send, stop, reset,
history, conversationId, openConversation, forgetConversation,
submitFeedback, feedback,
} = useConversation({
contextId: context.id,
facts,
@@ -425,6 +468,10 @@ export default function KrowAssistant({
skillCategories,
courses: facts.forge?.library || [],
skillContext,
agent,
agentCoversPage,
agentSuggestion: defaultAgent,
owliverContext,
});
/* A block inside an answer asking the next question, in place. Same entry
@@ -518,7 +565,12 @@ export default function KrowAssistant({
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
};
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
/* The agent's own starters lead: they were written for this agent, and they
pass through the same de-duplication below, so a starter worded like a
skill suggestion still yields one chip rather than two. An agent that
does not cover this page offers none. */
return [...agentStarters(agent, context.id), ...ready, ...skillPrompts,
...buildPrompts(context.id, facts, workforce), ...asking]
.filter((chip) => {
const label = String(chip?.label ?? '').trim().toLowerCase();
if (!label) return false;
@@ -530,7 +582,13 @@ export default function KrowAssistant({
});
/* `pageContext` decides which suggestions can answer without asking, so the
chips re-rank when a position is opened or closed. */
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext]);
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]);
/* The newest assistant turn, which is the one that carries the rating. */
const lastAnswerIndex = React.useMemo(
() => messages.map((m) => m.role).lastIndexOf('assistant'),
[messages]
);
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
at streaming frequency a smooth scroll never catches up and the thread
@@ -614,12 +672,11 @@ export default function KrowAssistant({
>
{/* Header */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
<div className="min-w-0 flex-1">
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
</div>
{/* The avatar and the two lines beside it, as they always were —
"Owliver" over the page, with the pair doubling as the agent
switcher. The header element, its geometry and the window controls
to the right are unchanged. */}
<AgentSwitcher page={context.page} />
{/* Window controls.
@@ -732,6 +789,14 @@ export default function KrowAssistant({
blocks={message.blocks}
stopped={message.stopped}
onPrompt={askOwliver}
/* A rating belongs to the conversation, so it is offered on
the newest answer only — and never while one streams. */
feedback={message.role === 'assistant' && i === lastAnswerIndex ? feedback : null}
onFeedback={
message.role === 'assistant' && i === lastAnswerIndex && !pending
? submitFeedback
: null
}
/>
</React.Fragment>
))}

View File

@@ -0,0 +1,552 @@
import { doc, heading, list, note, text } from '../blocks';
/* `vocabulary.js` is a leaf — it imports nothing — so naming the reasoning
modes here costs no dependency.
The registries are deliberately **not** imported. `contexts.js` is reached
from `placement.js`, which `skills/registry.js` already depends on, so
importing a registry back into a context closes a cycle and leaves
`PLACEMENT_ROUTES` undefined at module-evaluation time. Counting agents here
would also duplicate what the screen behind this panel already shows. */
import { REASONING_MODES } from '@/lib/agents/vocabulary';
/**
* Owliver on the agent configuration screen.
*
* This page is a **workspace** page, not an operational one. It shows no
* positions, no candidates and no shifts, and Owliver must not behave as though
* it does. What it can genuinely answer from is the two registries — what
* agents exist, what skills exist, what the configuration options mean — which
* is metadata about the workspace itself rather than a reading of workforce
* records.
*
* The distinction matters most when the agent being *edited* is an operational
* one. Configuring the Analytics Agent does not put the reader on Analytics:
* the edited agent is a record being changed, not the page they are standing
* on. So nothing here reaches for analytics data, and no skill declares this
* page — `skillsForContext` returns an empty list, which is the honest answer.
*
* Questions outside these topics are declined by the routing layer rather than
* answered with whatever this page happens to know.
*/
/** What this page's answers are actually about. */
/**
* What this page's answers are actually about.
*
* Deliberately narrow, and it was not narrow enough at first: `what is` and
* `page` were on this list, so "What is our attendance rate?" matched and was
* answered from a screen that holds no attendance records. A topic list on a
* page with no operational data has to name *subjects*, never sentence
* openings — a generic phrase turns the honest decline into a confident answer
* about the wrong thing.
*
* Anything not named here is declined by `routing.js` and pointed at the page
* that holds the records.
*/
export const AGENT_CONFIGURE_TOPICS = [
'agent', 'agents', 'subagent', 'subagents',
'skill', 'skills', 'knowledge',
'reasoning', 'starter', 'starters', 'web search',
'instruction', 'instructions', 'configure', 'configuration', 'setting', 'settings',
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
'workspace',
];
/** What this screen is, and what it can be asked. */
function explainScreen() {
return doc(
heading('Configuring an agent'),
text('An agent decides how Owliver answers on a page: which of that page\u2019s skills it may use, what it knows, and how much work an answer is worth.'),
list([
'It narrows what a page offers. It can never widen it.',
'The same skill can be attached to several agents.',
'Changes are saved to this workspace; publishing puts them into service.',
]),
note('The agents in this workspace are listed on the Agents page, and skills in Workspace \u2192 Skills.')
);
}
/** Where skills come from. */
function skillOverview() {
return doc(
heading('Where skills come from'),
text('Skills are authored once in **Workspace \u2192 Skills** and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.'),
list([
'Add skills \u2014 attaches from the shared library.',
'A skill still only answers on the pages it itself declares.',
'Attaching a skill an agent\u2019s pages do not cover changes nothing on those pages.',
]),
note('Removing a skill here detaches it from this agent. It stays in the library for everything else.')
);
}
/** What the configuration options mean. */
function explainOptions() {
return doc(
heading('What each setting does'),
list([
'Pages it covers — where this agent may answer. It narrows what the page offers; it can never widen it.',
'Skills — what it can do. Attached from the shared library.',
'Knowledge — reference material it can quote, with the source named.',
`Reasoning — how much work an answer is worth: ${REASONING_MODES.map((m) => m.label).join(', ')}.`,
'Conversation starters — the questions offered as chips when this agent opens.',
'Web search — whether it may look outside the workspace.',
'Subagents — other agents whose skills it may also use, still bounded by the current page.',
]),
note('Changes are saved to this workspace. Publishing is what puts them into service.')
);
}
/** The lifecycle, in the words the buttons use. */
function explainLifecycle() {
return doc(
heading('Draft, published, archived'),
list([
'Draft — saved, but not answering anywhere yet.',
'Published — in service, and offered in the Owliver switcher.',
'Archived — taken out of service, definition kept. It can be restored as a draft.',
]),
note('Editing an agent that ships with Krow saves your own version alongside it. The shipped definition is never altered, and reverting brings it back.')
);
}
export const AGENT_CONFIGURE_CAPABILITIES = [
{ id: 'screen', label: 'What this screen configures', run: explainScreen },
{ id: 'skills', label: 'Where skills come from', run: skillOverview },
{ id: 'options', label: 'What each setting does', run: explainOptions },
{ id: 'lifecycle', label: 'Draft, published, archived', run: explainLifecycle },
];
/**
* A free-text question on the configuration screen.
*
* Answers from the registries, or says plainly that this page cannot answer it.
* The one thing it must never do is reach for operational data because the
* agent being edited happens to be an operational agent.
*/
export function respondAgentConfigure(question) {
const q = String(question || '').toLowerCase();
if (/\bskill/.test(q)) return skillOverview();
if (/\b(draft|publish|archive|version|revert|shipped)/.test(q)) return explainLifecycle();
if (/\b(reasoning|knowledge|starter|subagent|web search|instruction|setting|option|page)/.test(q)) {
return explainOptions();
}
if (/\bagent/.test(q)) return explainScreen();
return doc(
text('This is the agent configuration screen, so I can answer about **agents, skills and what each setting does** here.'),
list([
'What agents exist in this workspace',
'What skills I can attach, and where they come from',
'What pages, reasoning, knowledge and subagents mean',
'What draft, published and archived do',
]),
note('For workforce questions — positions, candidates, attendance — open the page that holds those records and ask me there.')
);
}
/* ── The rest of the workspace, and Settings ──────────────────────────────
*
* Every page below is a configuration surface: it holds no positions, no
* candidates and no shifts. That does not make Owliver useless there, and
* treating it as though it did is the bug this section exists to correct — the
* panel did not mount on these pages at all, on a product where Owliver had
* answered perfectly well everywhere before specialised agents existed.
*
* What each page can honestly answer from is the screen itself: what it
* configures, what the controls mean, and which page holds the records a
* workforce question is really about. That is what these produce.
*
* **No skill is invented to make this work.** A skill is a capability over
* records; none of these pages has records, so a skill here would be a fake one
* whose only purpose was to populate a chip. What answers instead is a page
* responder — the same mechanism every context in this table has always used,
* including the eight operational ones.
*/
/**
* One configuration page's answers, from a description of the page.
*
* A factory rather than five near-identical copies. Each section carries the
* questions it answers (`match`), the label the panel offers it under, and the
* document itself — so the capability list shown when a question is declined
* and the routing behind a typed question come from one declaration and cannot
* drift apart.
*
* **Sections are matched in order, specific before general.** The section that
* explains the screen as a whole is deliberately last on every page: it is the
* one whose words appear in every other question, and put first it would answer
* all of them. The same lesson `AGENT_CONFIGURE_TOPICS` records, one level down.
*
* `topics` is what `routing.js` gates on, and it names *subjects* rather than
* sentence openings — a generic opener on a page with no operational data turns
* an honest decline into a confident answer about the wrong thing.
*/
function workspacePage({ label, topics, sections, elsewhere }) {
const capabilities = sections.map((section) => ({
id: section.id,
label: section.label,
run: () => doc(
heading(section.heading),
...(section.text ? [text(section.text)] : []),
...(section.bullets?.length ? [list(section.bullets)] : []),
...(section.note ? [note(section.note)] : [])
),
}));
const byId = new Map(capabilities.map((c) => [c.id, c]));
/**
* A typed question, answered from this page or offered what it can answer.
*
* The default is not "here is the page's report" — these pages have no report.
* It is the offer itself: what can be asked here, and where the records live
* for what cannot. `routing.js` has already declined anything outside
* `topics`, so this only ever sees a question the page is plausibly about.
*/
const respond = (question) => {
const q = String(question || '').toLowerCase();
const matched = sections.find((section) => section.match.test(q));
if (matched) return byId.get(matched.id).run();
return doc(
text(`This is **${label}**, so I can answer about the screen itself — what it configures and what each control does.`),
list(sections.map((section) => section.label)),
note(elsewhere)
);
};
return { topics, capabilities, respond };
}
/** What every configuration page says about where the records are. */
const RECORDS_ELSEWHERE =
'For workforce questions — positions, candidates, attendance, training — open the page that holds those records and ask me there.';
/* ── Settings ───────────────────────────────────────────────────────────── */
const SETTINGS = workspacePage({
label: 'Settings',
topics: [
'setting', 'settings', 'account', 'password', 'credential', 'security', 'two-factor',
'two factor', '2fa', 'permission', 'access', 'organization', 'organisation',
'client', 'user', 'users', 'team', 'audit', 'notification', 'digest', 'email',
'density', 'compact', 'preference', 'automation', 'toggle', 'configure', 'configuration',
'workspace', 'owliver', 'assistant',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'automation',
label: 'What the automation toggles do',
heading: 'Automation controls',
match: /\b(automation|toggle|toggles|digest|notification|notifications|density|compact|assistant)\b/,
bullets: [
'Owliver Workspace Assistant — whether this panel opens by default on the pages that carry it.',
'Compact UI Density — tighter table rows, for high-density monitoring.',
'Daily Executive Email Digest — a scheduled summary to the account address.',
],
note: 'Turning the assistant off closes the panel; it never removes it. The docked control brings it back.',
},
{
id: 'access',
label: 'Permissions and security',
heading: 'Who may do what',
match: /\b(permission|permissions|access|secure|secured|security|password|credential|credentials|audit|2fa|two.factor|user|users|team)\b/,
text: 'Access is configured here and recorded on Activity. The two are deliberately separate: this page states the policy, and the audit trail states what happened under it.',
bullets: [
'Users & Permissions — the roles this workspace grants.',
'Account & Security — credentials and protection for your own account.',
'Audit & System Activity Log — the record of events, read in full on Activity.',
],
note: 'Ask me on Activity for who did what, and when — that page holds the events.',
},
{
id: 'screen',
label: 'What this screen configures',
heading: 'Settings',
match: /\b(configure|configuration|configures|screen|settings|manage|set up|do here|organization|organisation|client|clients)\b/,
text: 'Settings is the account and system side of Krow. It configures who you are and how the workspace behaves — never the workforce records themselves.',
bullets: [
'Account & Security — your profile details and how this account is protected.',
'Organization — the clients, positions and people this workspace is structured around.',
'Users & Permissions — who may do what.',
'Audit & System Activity — the log of what has happened.',
'Automation — the behaviours below, including whether Owliver opens by default.',
],
note: 'Changes here are saved to this account.',
},
],
});
export const SETTINGS_TOPICS = SETTINGS.topics;
export const SETTINGS_CAPABILITIES = SETTINGS.capabilities;
export const respondSettings = SETTINGS.respond;
/* ── Workspace hub ──────────────────────────────────────────────────────── */
const WORKSPACE = workspacePage({
label: 'Workspace',
topics: [
'workspace', 'agent', 'agents', 'skill', 'skills', 'capability', 'capabilities',
'training', 'path', 'paths', 'library', 'registry', 'owliver', 'extend', 'configure',
'configuration', 'govern', 'development',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'agents',
label: 'What an agent is',
heading: 'Agents',
match: /\b(agent|agents|subagent|subagents|reasoning)\b/,
text: 'An agent decides how Owliver answers on a page: which of that page’s skills it may use, what it knows, and how much work an answer is worth.',
bullets: [
'It narrows what a page offers. It can never widen it.',
'A page written for an agent opens on that agent.',
'A page with no agent of its own opens on the general Krow Workforce Agent.',
],
note: 'Agents are listed and edited in Workspace → Agents.',
},
{
id: 'skills',
label: 'Where skills come from',
heading: 'Skills',
match: /\b(skill|skills|library|registry|capability|capabilities)\b/,
text: 'Skills are authored once and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.',
bullets: [
'An Owliver skill answers questions.',
'A Board skill extends a Krow page with a section.',
'A skill only applies on the pages it itself declares.',
],
note: 'Both lists live in Workspace → Skills.',
},
{
id: 'screen',
label: 'What the workspace governs',
heading: 'Workspace',
match: /\b(workspace|govern|governs|overview|configure|configuration|extend)\b/,
text: 'The workspace is where Krow’s capabilities are configured: the agents that answer, the skills they carry, and the training paths the workforce is measured against.',
bullets: [
'Agents — who answers on which page, and how.',
'Skills — what can be answered or drawn, authored once and shared.',
'Skill Development — the training paths the workforce progresses along.',
],
note: 'Nothing configured here reads workforce records on its own. A page decides what is in reach; configuration decides how much of that reach is used.',
},
],
});
export const WORKSPACE_TOPICS = WORKSPACE.topics;
export const WORKSPACE_CAPABILITIES = WORKSPACE.capabilities;
export const respondWorkspace = WORKSPACE.respond;
/* ── Agents list ────────────────────────────────────────────────────────── */
const WORKSPACE_AGENTS = workspacePage({
label: 'Agents',
topics: [
'agent', 'agents', 'subagent', 'subagents', 'skill', 'skills', 'page', 'pages',
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
'configure', 'configuration', 'reasoning', 'workspace', 'owliver',
'constrained', 'cover', 'covers', 'fallback', 'default',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'coverage',
label: 'How a page picks its agent',
heading: 'Which agent answers where',
match: /\b(cover|covers|covered|page|pages|default|fallback|constrained|picks|pick|answers|resolve|resolves)\b/,
bullets: [
'A page with an agent written for it opens on that agent.',
'A page with none opens on the general Krow Workforce Agent.',
'Choosing an agent that does not cover the page you are on shows it as constrained here, and it declines rather than answering.',
],
note: 'An agent is a lens on the page you are standing on. It is never a way to reach a different page.',
},
{
id: 'lifecycle',
label: 'Draft, published, archived',
heading: 'Draft, published, archived',
match: /\b(draft|publish|published|publishing|archive|archived|revert|shipped|version|lifecycle)\b/,
bullets: [
'Draft — saved, but not answering anywhere yet.',
'Published — in service, and offered in the Owliver switcher.',
'Archived — taken out of service, definition kept. It can be restored as a draft.',
],
},
{
id: 'list',
label: 'What this list holds',
heading: 'The agents in this workspace',
match: /\b(list|lists|registered|exist|exists|agent|agents|screen|workspace)\b/,
text: 'Every agent Krow ships, plus anything this account has authored. Opening one configures it; nothing on this screen changes how Owliver is answering right now.',
bullets: [
'A shipped agent can be edited — your version is saved alongside it, and reverting brings the original back.',
'A published agent is offered in the Owliver switcher.',
'An archived agent is out of service, and its definition is kept.',
],
note: 'Configuring an agent never puts you on the page that agent covers.',
},
],
});
export const WORKSPACE_AGENTS_TOPICS = WORKSPACE_AGENTS.topics;
export const WORKSPACE_AGENTS_CAPABILITIES = WORKSPACE_AGENTS.capabilities;
export const respondWorkspaceAgents = WORKSPACE_AGENTS.respond;
/* ── Skills list ────────────────────────────────────────────────────────── */
const WORKSPACE_SKILLS = workspacePage({
label: 'Skills',
topics: [
'skill', 'skills', 'library', 'registry', 'owliver', 'board', 'section', 'surface',
'placement', 'markdown', 'definition', 'upload', 'author', 'attach',
'active', 'draft', 'archive', 'archived', 'category', 'capability', 'capabilities',
'workspace', 'agent', 'agents', 'page', 'pages',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'attaching',
label: 'How skills reach an agent',
heading: 'Attaching a skill',
match: /\b(attach|attached|attaching|agent|agents|reach|reaches|scope)\b/,
bullets: [
'Attaching a skill lets an agent use it — on the pages the skill itself declares.',
'Attaching a skill an agent’s pages do not cover changes nothing on those pages.',
'Removing a skill from an agent leaves it in the library for everything else.',
],
note: 'Agents are configured in Workspace → Agents.',
},
{
id: 'authoring',
label: 'How a skill is written',
heading: 'Writing a skill',
match: /\b(author|authored|authoring|write|written|writing|markdown|definition|upload|edit|edited)\b/,
text: 'A skill is a Markdown file: frontmatter declares what it is and which pages it applies to, and the body documents what it can do.',
bullets: [
'It only applies on the pages it declares.',
'It is attached to an agent from this shared library, never copied into one.',
'A definition naming something the product does not have is refused with a message rather than half-registered.',
],
},
{
id: 'lists',
label: 'The two skill lists',
heading: 'Owliver skills and Board skills',
match: /\b(list|lists|two|owliver|board|kind|kinds|difference|library|registry|skill|skills)\b/,
bullets: [
'An Owliver skill answers questions in this panel.',
'A Board skill extends a Krow page with a section the page renders.',
'A definition can do both; it is filed by what it declares, never by a setting.',
],
note: 'One library behind both lists, so a skill behaves identically wherever it is used.',
},
],
});
export const WORKSPACE_SKILLS_TOPICS = WORKSPACE_SKILLS.topics;
export const WORKSPACE_SKILLS_CAPABILITIES = WORKSPACE_SKILLS.capabilities;
export const respondWorkspaceSkills = WORKSPACE_SKILLS.respond;
/* ── Skill editor ───────────────────────────────────────────────────────── */
const SKILL_CONFIGURE = workspacePage({
label: 'Skill Configure',
topics: [
'skill', 'skills', 'definition', 'markdown', 'frontmatter', 'field', 'fields',
'page', 'pages', 'surface', 'placement', 'section', 'source', 'sources', 'trigger',
'triggers', 'action', 'actions', 'capability', 'capabilities', 'category', 'status',
'active', 'draft', 'archive', 'archived', 'validate', 'validation', 'save', 'publish',
'editor', 'configure', 'configuration', 'owliver', 'board', 'workspace',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'validation',
label: 'Why a definition is refused',
heading: 'Validation',
match: /\b(valid|validate|validated|validation|refuse|refused|error|errors|fail|fails|reject|rejected|wrong)\b/,
text: 'The vocabulary is closed, so everything a definition names is checked before it is stored.',
bullets: [
'A page, placement or source the product does not have is refused with a message.',
'A section that needs a record the placement does not supply is refused rather than drawing an empty card.',
'A definition that parses but loses a field reports what it lost, instead of half-registering.',
],
},
{
id: 'reach',
label: 'Where this skill will apply',
heading: 'Where it applies',
match: /\b(apply|applies|applied|where|reach|reaches|attach|attached|agent|agents|scope|surface|placement)\b/,
bullets: [
'On the pages this definition declares, and nowhere else.',
'For an agent, only once it is attached to that agent.',
'An agent can never use a skill to reach a page the skill does not declare.',
],
note: 'Attach it in Workspace → Agents.',
},
{
id: 'screen',
label: 'What this editor configures',
heading: 'Editing a skill',
match: /\b(editor|edit|editing|configure|configures|configuration|definition|frontmatter|field|fields|status|trigger|triggers|screen|skill|skills)\b/,
text: 'A skill definition, as Markdown. The frontmatter declares what it is and where it applies; the body documents what it can do.',
bullets: [
'Pages — the surfaces this skill applies on. It never applies anywhere else.',
'Triggers — the wordings that reach it.',
'Capabilities — what it can answer or draw.',
'Status — active, draft or archived.',
],
note: 'Nothing is executed from Markdown. A definition names things the product already has, and a name it does not have is refused.',
},
],
});
export const SKILL_CONFIGURE_TOPICS = SKILL_CONFIGURE.topics;
export const SKILL_CONFIGURE_CAPABILITIES = SKILL_CONFIGURE.capabilities;
export const respondSkillConfigure = SKILL_CONFIGURE.respond;
/* ── Skill Development ──────────────────────────────────────────────────── */
const SKILL_DEVELOPMENT = workspacePage({
label: 'Skill Development',
topics: [
'training', 'path', 'paths', 'progression', 'level', 'levels', 'rung', 'ladder',
'development', 'capability', 'capabilities', 'define', 'definition',
'workspace', 'configure', 'configuration', 'owliver',
],
elsewhere: RECORDS_ELSEWHERE,
sections: [
{
id: 'levels',
label: 'How the levels work',
heading: 'Levels',
match: /\b(level|levels|rung|rungs|ladder|progress|progression|stage|stages)\b/,
bullets: [
'Levels are ordered, and each states what someone at that level can do.',
'A path with no levels defines a name and nothing measurable.',
'Editing a level changes the definition, never anyone’s recorded progress.',
],
},
{
id: 'screen',
label: 'What a training path is',
heading: 'Skill Development',
match: /\b(training|path|paths|define|defines|definition|development|capability|capabilities|configure|screen)\b/,
text: 'A training path defines a capability the workforce holds and the levels it progresses through. It is a definition, not a reading: nothing here says who currently holds what.',
bullets: [
'Each path names its levels, from first exposure to independent practice.',
'A path is neither an Owliver skill nor a Board skill — it describes people, not the product.',
'It is authored here and read wherever progression is shown.',
],
note: 'For who holds which capability today, ask me on Talent Pool or KROW Forge — those pages hold the records.',
},
],
});
export const SKILL_DEVELOPMENT_TOPICS = SKILL_DEVELOPMENT.topics;
export const SKILL_DEVELOPMENT_CAPABILITIES = SKILL_DEVELOPMENT.capabilities;
export const respondSkillDevelopment = SKILL_DEVELOPMENT.respond;

View File

@@ -13,6 +13,17 @@ import {
respondPositions,
respondProfile, respondTalentPool,
} from './capabilities/admin';
import {
AGENT_CONFIGURE_CAPABILITIES, AGENT_CONFIGURE_TOPICS,
SETTINGS_CAPABILITIES, SETTINGS_TOPICS,
SKILL_CONFIGURE_CAPABILITIES, SKILL_CONFIGURE_TOPICS,
SKILL_DEVELOPMENT_CAPABILITIES, SKILL_DEVELOPMENT_TOPICS,
WORKSPACE_AGENTS_CAPABILITIES, WORKSPACE_AGENTS_TOPICS,
WORKSPACE_CAPABILITIES, WORKSPACE_SKILLS_CAPABILITIES, WORKSPACE_SKILLS_TOPICS,
WORKSPACE_TOPICS,
respondAgentConfigure, respondSettings, respondSkillConfigure, respondSkillDevelopment,
respondWorkspace, respondWorkspaceAgents, respondWorkspaceSkills,
} from './capabilities/workspace';
/**
* Page contexts for the Owliver dashboard panel.
@@ -145,6 +156,87 @@ export const ASSISTANT_CONTEXTS = {
capabilities: PROFILE_CAPABILITIES,
respond: respondProfile,
},
/**
* Agent configuration — a workspace page, not an operational one.
*
* It holds no workforce records, and no skill declares it, so
* `skillsForContext` returns nothing here. That is deliberate rather than an
* omission: configuring the Analytics Agent must not put the reader on
* Analytics, and the surest way to guarantee that is for this page to have no
* operational reading available at all.
*
* `topics` keeps it honest in the other direction — a workforce question asked
* here is declined and pointed at the page that holds the records, rather than
* answered from whatever this screen happens to know.
*/
'admin.agentConfigure': {
id: 'admin.agentConfigure',
page: 'Agent Configure',
topics: AGENT_CONFIGURE_TOPICS,
capabilities: AGENT_CONFIGURE_CAPABILITIES,
respond: respondAgentConfigure,
},
/**
* Settings, and the workspace pages behind it.
*
* **A page with no agent of its own is not a page without Owliver.** These
* six carry the same panel as the eight operational pages, resolved by the
* same placement table, answered by the same responder mechanism — and they
* open on the general Krow Workforce Agent, because no specialist was written
* for a configuration screen and none needs to be.
*
* What they do not carry is workforce records. No skill declares these
* surfaces, so `skillsForContext` returns an empty list on every one of them,
* and `topics` keeps the decline honest in the other direction: a question
* about positions or attendance asked here is pointed at the page that holds
* those records rather than answered from a configuration screen.
*
* The two halves are the whole design. Remove the first and Owliver is dead
* on half the product; remove the second and it invents figures on a page
* that has none.
*/
'admin.settings': {
id: 'admin.settings',
page: 'Settings',
topics: SETTINGS_TOPICS,
capabilities: SETTINGS_CAPABILITIES,
respond: respondSettings,
},
'admin.workspace': {
id: 'admin.workspace',
page: 'Workspace',
topics: WORKSPACE_TOPICS,
capabilities: WORKSPACE_CAPABILITIES,
respond: respondWorkspace,
},
'admin.workspaceAgents': {
id: 'admin.workspaceAgents',
page: 'Agents',
topics: WORKSPACE_AGENTS_TOPICS,
capabilities: WORKSPACE_AGENTS_CAPABILITIES,
respond: respondWorkspaceAgents,
},
'admin.workspaceSkills': {
id: 'admin.workspaceSkills',
page: 'Skills',
topics: WORKSPACE_SKILLS_TOPICS,
capabilities: WORKSPACE_SKILLS_CAPABILITIES,
respond: respondWorkspaceSkills,
},
'admin.skillConfigure': {
id: 'admin.skillConfigure',
page: 'Skill Configure',
topics: SKILL_CONFIGURE_TOPICS,
capabilities: SKILL_CONFIGURE_CAPABILITIES,
respond: respondSkillConfigure,
},
'admin.skillDevelopment': {
id: 'admin.skillDevelopment',
page: 'Skill Development',
topics: SKILL_DEVELOPMENT_TOPICS,
capabilities: SKILL_DEVELOPMENT_CAPABILITIES,
respond: respondSkillDevelopment,
},
'admin.activity': {
id: 'admin.activity',
page: 'Activity',

View File

@@ -205,6 +205,24 @@ const GREETINGS = {
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week, nothing out of pattern.`;
},
'admin.agentConfigure': () =>
'Configure what this agent covers, its attached skills, reference knowledge, and reasoning behavior.',
/* Settings and the workspace pages. Static rather than computed, because a
configuration screen has no records to read and a line that pretended
otherwise would be the fabrication the whole design refuses. */
'admin.settings': () =>
'Your account, the organization, who may do what, and the behaviours this workspace runs on.',
'admin.workspace': () =>
'The agents that answer, the skills they carry, and the paths the workforce progresses along.',
'admin.workspaceAgents': () =>
'Every agent this workspace has, what each one covers, and which are in service.',
'admin.workspaceSkills': () =>
'The shared library — what Owliver can answer, and what a Krow page can draw.',
'admin.skillConfigure': () =>
'Declare what this skill is, which pages it applies on, and what it can do.',
'admin.skillDevelopment': () =>
'Define the capabilities the workforce holds, and the levels they progress through.',
};
/**
@@ -233,6 +251,13 @@ const TITLES = {
'admin.talentPool': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.hiredHistory': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.profile': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.agentConfigure': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.settings': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.workspace': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.workspaceAgents': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.workspaceSkills': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.skillConfigure': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.skillDevelopment': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
/** Descriptions state what this page's assistant can actually do with the data. */
@@ -262,6 +287,20 @@ const DESCRIPTIONS = {
`Reading ${plural(f.hiring.hires.length, 'hire')} across ${plural(f.hiring.byDepartment.length, 'department')} — quality, speed and outcomes.`,
'admin.profile': () =>
'Reading this account — permissions, security, preferences and your own activity.',
'admin.agentConfigure': () =>
'Ask Owliver about agent settings, lifecycle, attaching skills, or custom instructions.',
'admin.settings': () =>
'Ask Owliver what this screen configures, what each control does, and where the records behind a workforce question live.',
'admin.workspace': () =>
'Ask Owliver what the workspace governs — agents, skills, and how a page decides which agent answers.',
'admin.workspaceAgents': () =>
'Ask Owliver how a page picks its agent, what constrains one, and what draft, published and archived mean.',
'admin.workspaceSkills': () =>
'Ask Owliver about the two skill lists, how a skill is written, and how one reaches an agent.',
'admin.skillConfigure': () =>
'Ask Owliver what this editor declares, why a definition is refused, and where the skill will apply.',
'admin.skillDevelopment': () =>
'Ask Owliver what a training path defines and how its levels work.',
};
/**
@@ -342,6 +381,41 @@ const PLACEHOLDERS = {
'Ask Owliver what you can change here…',
'Ask Owliver about your security settings…',
],
'admin.agentConfigure': () => [
'Ask Owliver about agent configuration…',
'Ask Owliver how skills attach…',
'Ask Owliver about reasoning modes…',
],
'admin.settings': () => [
'Ask Owliver what you can configure here…',
'Ask Owliver what the automation toggles do…',
'Ask Owliver about permissions and security…',
],
'admin.workspace': () => [
'Ask Owliver what the workspace governs…',
'Ask Owliver what an agent is…',
'Ask Owliver where skills come from…',
],
'admin.workspaceAgents': () => [
'Ask Owliver how a page picks its agent…',
'Ask Owliver what constrained means…',
'Ask Owliver about draft and published…',
],
'admin.workspaceSkills': () => [
'Ask Owliver about the two skill lists…',
'Ask Owliver how a skill is written…',
'Ask Owliver how skills reach an agent…',
],
'admin.skillConfigure': () => [
'Ask Owliver what this editor declares…',
'Ask Owliver why a definition is refused…',
'Ask Owliver where this skill will apply…',
],
'admin.skillDevelopment': () => [
'Ask Owliver what a training path is…',
'Ask Owliver how the levels work…',
'Ask Owliver what this screen defines…',
],
};
/* ── Suggested prompts ──────────────────────────────────────────────────── */
@@ -671,6 +745,52 @@ const PROMPTS = {
{ label: 'Who is most active?', prompt: 'Which accounts are most active?', capability: 'user-activity' },
],
'admin.agentConfigure': () => [
{ label: 'Where do skills come from?', prompt: 'Where do skills come from on this screen?' },
{ label: 'Explain reasoning modes', prompt: 'What are the reasoning modes and how do they work?' },
{ label: 'How to write instructions?', prompt: 'How should I write effective instructions for this agent?' },
{ label: 'How does publishing work?', prompt: 'How does agent publishing and versioning work?' },
],
/**
* Settings and the workspace pages.
*
* Every chip below resolves to something this page can actually answer, and
* that is the whole rule: a suggestion is a promise, so a configuration screen
* must not offer "Which positions are at risk?" merely to have chips. None of
* these names a record; each one names the screen. The operational chips above
* are computed from data because those pages have data — these are not, for
* exactly the same reason.
*/
'admin.settings': () => [
{ label: 'What can I configure here?', prompt: 'What can I configure on Settings?' },
{ label: 'What do the automation toggles do?', prompt: 'What do the automation toggles do?' },
{ label: 'Who can access what?', prompt: 'Who can access what, and where is it recorded?' },
],
'admin.workspace': () => [
{ label: 'What does the workspace govern?', prompt: 'What does the workspace govern?' },
{ label: 'What is an agent?', prompt: 'What is an agent, and what does it decide?' },
{ label: 'Where do skills come from?', prompt: 'Where do skills come from?' },
],
'admin.workspaceAgents': () => [
{ label: 'How does a page pick its agent?', prompt: 'How does a page pick its agent?' },
{ label: 'What does draft mean?', prompt: 'What do draft, published and archived mean?' },
{ label: 'What is in this list?', prompt: 'What does this list of agents hold?' },
],
'admin.workspaceSkills': () => [
{ label: 'How do skills reach an agent?', prompt: 'How do skills reach an agent?' },
{ label: 'How is a skill written?', prompt: 'How is a skill written?' },
{ label: 'What are the two lists?', prompt: 'What is the difference between the two skill lists?' },
],
'admin.skillConfigure': () => [
{ label: 'Why is a definition refused?', prompt: 'Why would a definition be refused?' },
{ label: 'Where will this apply?', prompt: 'Where will this skill apply?' },
{ label: 'What does this editor declare?', prompt: 'What does this editor configure?' },
],
'admin.skillDevelopment': () => [
{ label: 'How do the levels work?', prompt: 'How do the levels work?' },
{ label: 'What is a training path?', prompt: 'What is a training path?' },
],
};
/* ── Public API ─────────────────────────────────────────────────────────── */

View File

@@ -31,6 +31,42 @@ const MAX_RECORDS = 100;
const DAY = 24 * 60 * 60 * 1000;
/**
* The shape of a stored record.
*
* Bumped when a record gains fields, so a reader can tell an old record from a
* new one instead of guessing from which keys happen to be present.
*/
const SCHEMA = 2;
/**
* An older record, brought up to date on the way out.
*
* A conversation held before agents existed is still a conversation somebody
* had. It gets the new fields at their empty values and keeps everything it
* already had — in particular `messages` is passed through untouched, so
* nothing a reader wrote is rewritten by a schema change.
*
* Migrating on read rather than rewriting the store means a browser that never
* opens History again still loses nothing, and there is no migration pass that
* can fail halfway.
*/
function migrate(record) {
if (record.schema === SCHEMA) return record;
return {
...record,
schema: SCHEMA,
/* Absent, not unknown: these conversations genuinely had no agent, no
recorded skills and no feedback. */
agentId: record.agentId ?? null,
pageContext: record.pageContext ?? null,
skillsUsed: Array.isArray(record.skillsUsed) ? record.skillsUsed : [],
toolsUsed: Array.isArray(record.toolsUsed) ? record.toolsUsed : [],
knowledgeUsed: Array.isArray(record.knowledgeUsed) ? record.knowledgeUsed : [],
feedback: record.feedback ?? null,
};
}
/** Every archived conversation, newest first. Never throws. */
export function readHistory() {
try {
@@ -42,6 +78,7 @@ export function readHistory() {
return list
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
.map(migrate)
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
} catch {
/* Corrupt or unavailable storage is an empty history, not an error the
@@ -64,13 +101,39 @@ function titleFor(messages) {
* Matched on `id`, so a thread being added to updates in place rather than
* appearing once per turn.
*/
export function saveConversation({ id, contextId, page, messages }) {
export function saveConversation({
id, contextId, page, messages,
/**
* Who answered, where, and what it actually used.
*
* Recorded from what ran rather than from what was available: `skillsUsed`
* is the skill that answered a turn, not every skill the page offered. A
* record of what *could* have happened would make the insight figures
* describe the registry instead of the conversation.
*/
agentId = null, pageContext = null,
skillsUsed = [], toolsUsed = [], knowledgeUsed = [],
}) {
if (!id || !Array.isArray(messages) || !messages.length) return readHistory();
/* Feedback belongs to the conversation, not to the turn that triggered a
save, so an existing rating survives the thread growing. */
const existing = readHistory().find((r) => r.id === id) || null;
const record = {
schema: SCHEMA,
id,
contextId,
page: page || '',
agentId,
/* The *reduced* envelope — where the question was asked, never a copy of
what was on screen. Storing selections and computed figures would write
the dataset into localStorage a turn at a time. */
pageContext,
skillsUsed: [...new Set(skillsUsed.filter(Boolean))],
toolsUsed: [...new Set(toolsUsed.filter(Boolean))],
knowledgeUsed: [...new Set(knowledgeUsed.filter(Boolean))],
feedback: existing?.feedback ?? null,
title: titleFor(messages),
turns: messages.filter((m) => m.role === 'user').length,
updatedAt: new Date().toISOString(),
@@ -95,6 +158,41 @@ export function saveConversation({ id, contextId, page, messages }) {
return next;
}
/**
* Records how a conversation was rated.
*
* Updates in place and never appends: rating a thread twice is a correction,
* not two opinions. Returns the refreshed list so a caller re-renders from one
* read rather than two.
*/
export function recordFeedback(id, feedback) {
const list = readHistory();
const at = list.findIndex((r) => r.id === id);
if (at === -1) return list;
const next = [...list];
next[at] = {
...next[at],
feedback: feedback
? {
rating: feedback.rating === 'up' ? 'up' : 'down',
note: String(feedback.note || '').trim() || null,
at: new Date().toISOString(),
}
/* Clearing is a real action — someone un-rating a thread should leave no
rating behind rather than a neutral one. */
: null,
};
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* The rating stays in memory for this session. */
}
return next;
}
/** Forgets one conversation. */
export function removeConversation(id) {
const next = readHistory().filter((r) => r.id !== id);

View File

@@ -11,6 +11,7 @@
import { isUnlocked } from '@/lib/provingGround';
import { getScoreBand } from '@/lib/talentHome';
import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
@@ -26,10 +27,12 @@ const DAY_MS = 1000 * 60 * 60 * 24;
/**
* Events an auditor looks at first: they change who is employed or what is being
* hired for. Kept here so the fact sheet, the Activity page's severity column and
* the assistant's security answers all agree on what "privileged" means.
* hired for. Defined in `lib/activitySignals.js` alongside the detection that
* uses it, and re-exported here because the fact sheet, the Activity page's
* severity column and the assistant's security answers all import it from this
* module and must keep agreeing on what "privileged" means.
*/
export const PRIVILEGED_EVENTS = ['hire_candidate', 'create_position'];
export { PRIVILEGED_EVENTS };
/** `today` is injected rather than read from the clock so answers are stable. */
export function buildFacts({
@@ -119,64 +122,15 @@ export function buildFacts({
/**
* Out-of-pattern activity.
*
* Detected here rather than in the assistant so the greeting, the answer and
* any future page section all count the same things. "Two unusual patterns"
* has to mean the same two everywhere it is said.
* Computed by `lib/activitySignals.js` rather than here, because it now has
* a second reader: the `activity.signals` data source resolves it for a card
* or an answer, and `dataResolver.js` cannot import from this directory
* without a library reaching up into the component tree.
*
* 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.
* Moved, not changed. "Two unusual patterns" has to mean the same two
* everywhere it is said, and one function is the only way to guarantee that.
*/
const activitySignals = (() => {
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,
};
})();
const signals = activitySignals(activity, today);
/* ── KROW Forge ──────────────────────────────────────────────────────────
Derived with the same gate (`isUnlocked`) and the same completion record the
@@ -386,7 +340,7 @@ export function buildFacts({
activity7d: since(7),
activity24h: since(1),
eventCounts,
activitySignals,
activitySignals: signals,
/* Signed-in worker, and the pages built on these records */
profile,

View File

@@ -28,6 +28,40 @@ const PLACEMENT = {
'/admin/talent-pool': 'admin.talentPool',
'/admin/hired': 'admin.hiredHistory',
'/admin/profile': 'admin.profile',
/* Agent management. Configuring an agent is a workspace task, not an
operational page — see `admin.agentConfigure` for what that means for
what Owliver may read here. `agents/new` is listed exactly so it is
never read as an agent whose id is "new". */
'/admin/workspace/agents/new': 'admin.agentConfigure',
/**
* Settings and the rest of the workspace.
*
* These pages have no agent written for them, and for a while that was read
* as "no Owliver here" — the panel did not mount at all, so a product that
* answered questions on every Admin page before specialists existed
* answered on eight of them afterwards.
*
* The absence of a specialist is not the absence of an assistant. Each of
* these carries the same panel, resolved by this same table, answering from
* the general agent. What they do *not* carry is operational data: no skill
* declares these surfaces, so a workforce question asked here is declined
* and pointed at the page that holds the records rather than answered from
* a configuration screen.
*/
'/admin/settings': 'admin.settings',
'/admin/workspace': 'admin.workspace',
'/admin/workspace/agents': 'admin.workspaceAgents',
'/admin/workspace/skills': 'admin.workspaceSkills',
'/admin/workspace/skill-development': 'admin.skillDevelopment',
/* The skill editor, listed exactly for the same reason `agents/new` is: so
`skills/new` is never read as a skill whose id is "new". This is also the
entry the page key is derived from — `pageKeyForRoute` reads the surface
table, and this route is the one the `workspace-skill-configure` surface
declares. The Owliver editor's addresses reach the same context through
the pattern table below; listing them here too would overwrite that key
with a path tail. */
'/admin/workspace/skills/new': 'admin.skillConfigure',
},
};
@@ -35,9 +69,10 @@ const PLACEMENT = {
* Pages that must never carry the assistant, listed explicitly so the intent is
* documented rather than implied by omission.
*
* Admin: login only. Every other surface — including Profile, where the
* questions are about the account rather than the workforce — carries
* the panel.
* Admin: login only. Every other surface — including Profile and Settings,
* where the questions are about the account rather than the workforce,
* and the workspace pages, where they are about the configuration —
* carries the panel.
* Employer: every page. The contextual panel is an Admin capability.
* Talent: every page. The talent portal has the standalone Owliver product,
* with its own voice experience, branding and workflow.
@@ -63,10 +98,70 @@ export const EXCLUDED_ROUTES = [
*/
export const PLACEMENT_ROUTES = PLACEMENT.admin;
/**
* Routes whose address carries a record id.
*
* The table above is matched exactly, which is deliberate and stays that way:
* `/candidates` must not leak an assistant onto `/candidates/:id`. But an agent
* is configured at `/admin/workspace/agents/<id>`, and no exact table can list
* an address that contains an id nobody has created yet.
*
* So a second, much smaller table is consulted **only after the exact lookup
* misses**. Every one of the eight operational pages is an exact match and
* never reaches this code, so their placement is unchanged by construction
* rather than by care.
*
* `exclude` keeps a sibling literal route — `agents/new` is the same screen and
* is listed exactly — from being read as an id.
*/
const PLACEMENT_PATTERNS = {
admin: [
{
/* Agent configuration: one path segment after `agents/`, and not a
nested route beneath it. */
test: (pathname) => /^\/admin\/workspace\/agents\/[^/]+$/.test(pathname),
contextId: 'admin.agentConfigure',
},
{
/* The Owliver skill editor — `skills/owliver/new` and
`skills/owliver/<id>`. Two segments, so it is matched before the
one-segment pattern below and can never be read as a skill whose id is
"owliver". */
test: (pathname) => /^\/admin\/workspace\/skills\/owliver\/[^/]+$/.test(pathname),
contextId: 'admin.skillConfigure',
},
{
/* The Board skill editor: one segment after `skills/`, and no deeper.
`owliver` is excluded because it is a prefix rather than a skill, and
the product has no page at that address. */
test: (pathname) => /^\/admin\/workspace\/skills\/(?!owliver$)[^/]+$/.test(pathname),
contextId: 'admin.skillConfigure',
},
],
};
/**
* The dynamic routes, as literal examples.
*
* Exported so the registry and the checks can reason about a pattern without
* re-implementing it. These are addresses the pattern genuinely matches.
*/
export const PLACEMENT_PATTERN_ROUTES = {
'/admin/workspace/agents/:id': 'admin.agentConfigure',
'/admin/workspace/skills/:id': 'admin.skillConfigure',
'/admin/workspace/skills/owliver/new': 'admin.skillConfigure',
'/admin/workspace/skills/owliver/:id': 'admin.skillConfigure',
};
/** Returns the context for a role and path, or `null` to render no assistant. */
export function resolveAssistantContext(role, pathname) {
/* Exact first, always. The eight operational pages resolve here and never
reach the patterns below. */
const id = PLACEMENT[role]?.[pathname];
return id ? ASSISTANT_CONTEXTS[id] : null;
if (id) return ASSISTANT_CONTEXTS[id] ?? null;
const pattern = (PLACEMENT_PATTERNS[role] || []).find((p) => p.test(pathname));
return pattern ? ASSISTANT_CONTEXTS[pattern.contextId] ?? null : null;
}
/** Every enabled role/route pair — used by the placement verification. */

View File

@@ -27,7 +27,52 @@
*/
import { getContext } from './contexts';
import { toSnapshots } from './blocks';
import { note, toSnapshots } from './blocks';
/**
* An answer, at the depth the agent asked for.
*
* Three modes, and only two of them do anything: `balanced` is today's
* behaviour exactly, so every existing answer and every agent that declares no
* reasoning is unchanged.
*
* `webSearch` is handled here too, and handled honestly. No search provider is
* configured in this deployment, so an agent with the flag set gets a note
* saying so rather than an answer that quietly came from nowhere. Silently
* ignoring the flag would be worse: the configuration would read as working.
*/
function shapeForDepth(document, agent, facts) {
if (!agent || !document?.blocks?.length) return document;
const blocks = [...document.blocks];
if (agent.reasoning === 'fast') {
/* The headline and the first supporting block. Enough to answer, without
the breakdown someone asking a quick question did not want. */
const trimmed = blocks.slice(0, 2);
return { ...document, blocks: trimmed.length ? trimmed : blocks };
}
if (agent.reasoning === 'deep') {
const counted = [
facts?.applications?.length != null && `${facts.applications.length} applications`,
facts?.postings?.length != null && `${facts.postings.length} positions`,
facts?.staff?.length != null && `${facts.staff.length} hires`,
].filter(Boolean);
if (counted.length) {
blocks.push(note(`Read from ${counted.join(', ')} on this page.`));
}
}
if (agent.webSearch) {
blocks.push(note(
'Web search is enabled on this agent, but no search provider is configured '
+ 'in this deployment, so nothing outside this workspace was consulted.'
));
}
return { ...document, blocks };
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -41,7 +86,7 @@ export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
return {
id: 'local',
async *stream({ contextId, capability, question, facts, signal }) {
async *stream({ contextId, capability, question, facts, agent = null, signal }) {
const context = getContext(contextId);
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
@@ -50,12 +95,25 @@ export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
?? context.respond(question, facts)
: context.respond(question, facts);
/**
* The agent's reasoning mode, as answer depth.
*
* A real configuration effect rather than a label: `fast` returns the
* headline and stops, `deep` says which records the reading counted.
* `balanced` — the default, and what an agent that declares nothing gets
* — is untouched, so this cannot change any existing answer.
*
* Depth never changes *what* was read. The page decided that before the
* provider was called; this only decides how much of it to say.
*/
const shaped = shapeForDepth(document, agent, facts);
// A brief pause before the first frame, so the answer reads as considered
// rather than precomputed.
await sleep(latency);
if (signal?.aborted) return;
for (const snapshot of toSnapshots(document)) {
for (const snapshot of toSnapshots(shaped)) {
if (signal?.aborted) return;
yield snapshot;
await sleep(frameDelay);
@@ -83,11 +141,15 @@ export function createHttpProvider({ endpoint, headers = {} }) {
return {
id: 'http',
async *stream({ contextId, capability, question, signal }) {
async *stream({ contextId, capability, question, agent = null, owliverContext = null, signal }) {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ contextId, capability, question }),
/* `agent` and `owliverContext` travel; the fact sheet still does not.
Dashboard records are read server-side from the caller's own session,
so the client cannot ask about records it is not entitled to see —
and an agent cannot widen that by being named in the body. */
body: JSON.stringify({ contextId, capability, question, agent, owliverContext }),
signal,
});

View File

@@ -178,6 +178,33 @@ export function outOfScopeAnswer(context) {
);
}
/**
* The reply when the selected agent does not cover this page.
*
* Says three things, because leaving any of them out invites the reader to
* assume something untrue: which agent is active, that the *page* is what
* bounds the answer rather than the agent being broken, and which agent is the
* one for here.
*
* It deliberately does not answer from the requested agent's own pages. An
* agent is a lens on the page you are standing on, never a way to reach another
* one — reaching would make selecting an agent a way around the page boundary,
* which is the one thing the design does not permit.
*/
export function constrainedAgentAnswer(agent, context, suggestion) {
const covered = (agent?.pages || []).join(', ');
return doc(
text(`**${agent?.name || 'That agent'}** is selected, but it does not cover **${context?.page || 'this page'}**.`),
note(covered
? `It works on: ${covered}.`
: 'It covers no pages yet.'),
text(suggestion
? `On this page, **${suggestion.name}** is the one that answers. Switch to it, or open a page ${agent?.name || 'this agent'} covers.`
: 'Open a page it covers, or ask something this page can answer.'),
note('An agent narrows what can be asked here. It never widens it, so the records this page holds are the records any agent can read from it.')
);
}
/**
* Resolve a question to an action, ahead of the page responder.
*
@@ -185,6 +212,7 @@ export function outOfScopeAnswer(context) {
* { kind: 'answer' } — let this page respond
* { kind: 'navigate', destination, doc } — other page; go there
* { kind: 'outOfScope', doc } — nothing here can answer it
* { kind: 'constrained', doc } — the agent does not cover this page
*/
/* ── Workforce ──────────────────────────────────────────────────────────── */
@@ -820,6 +848,16 @@ function resolveDraftAction(question, workforce, positionId = null) {
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [], workforce = null, skillContext = null,
/**
* The active agent, and where the reader is.
*
* Both optional and both inert when absent, so a caller that knows nothing
* about agents resolves exactly as it always did. `agent` does not gate the
* skills considered here — that has already happened, in `disabledSkills`,
* which arrives carrying the agent's scoping. What it decides is whether this
* agent should be answering on this page at all.
*/
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
/* The record the control that raised this question was built from, when there
was one. Only the draft flow reads it; a typed question carries none and
resolves exactly as it always did. */
@@ -827,6 +865,18 @@ export function resolveIntent({
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
/**
* 0. The selected agent does not belong here.
*
* Ahead of everything, because every matcher below would otherwise answer
* from this page while the header names an agent that does not cover it — an
* answer attributed to the wrong lens. The page still decides what is
* readable; this only declines to pretend the agent chose it.
*/
if (agent && !agentCoversPage) {
return { kind: 'constrained', agent, doc: constrainedAgentAnswer(agent, context, agentSuggestion) };
}
/**
* 1. Finishing a draft, wherever the reader is standing.
*
@@ -846,7 +896,10 @@ export function resolveIntent({
/* 2. Current page skills — specific triggers, ahead of the general reader. */
const skill = resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
skillContext,
/* The envelope travels beside the collections rather than replacing them:
a resolver reads records, and the envelope says where the reader is. A
source that needs a position still finds it exactly where it always was. */
skillContext: owliverContext ? { ...skillContext, owliver: owliverContext } : skillContext,
});
if (skill) return skill;

View File

@@ -19,9 +19,11 @@ import {
interviewDone, interviewFailed,
} from '@/lib/skills/workforceFlow';
import {
newConversationId, readHistory, removeConversation, saveConversation,
newConversationId, readHistory, recordFeedback, removeConversation, saveConversation,
} from './history';
import { storableContext } from '@/lib/agents/context';
import { buildFacts } from './insights';
import { agentRequest } from '@/lib/agents/runtime';
import { createAssistantProvider } from './provider';
import { resolveIntent } from './routing';
import { toSnapshots } from './blocks';
@@ -233,11 +235,33 @@ export function useConversation({
onUpdatePosition, onGenerateDescription,
workforce = null, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [], skillContext = null,
/**
* The active agent and where the reader is.
*
* Both optional. `disabledSkills` already arrives carrying the agent's
* scoping — the caller applies `agentScopedDisabled` before handing it over —
* so nothing here decides what may be read. These are for attribution: which
* agent answered, and whether it belongs on this page.
*/
agent = null, agentCoversPage = true, agentSuggestion = null, owliverContext = null,
/* The page's own name, recorded with an archived thread so History can say
where a conversation happened without resolving the context again. */
pageLabel = '',
}) {
const storageKey = `krow_assistant:${contextId}`;
/**
* One thread per agent per page.
*
* Switching agent starts a new conversation rather than continuing the last
* one under a different name: the answers in a thread were produced by a
* particular agent's scope, and appending a differently-scoped reply to them
* would make the thread a record of something that never happened.
*
* With no agent the key is exactly what it was, so an existing stored thread
* is still found.
*/
const storageKey = agent?.id
? `krow_assistant:${agent.id}:${contextId}`
: `krow_assistant:${contextId}`;
const flowKey = `${storageKey}:flow`;
const idKey = `${storageKey}:id`;
@@ -267,6 +291,26 @@ export function useConversation({
silently abandon a position half-described. */
const flowRef = React.useRef(null);
/**
* What this conversation actually used.
*
* Accumulated as turns run, never predicted from what was available. A skill
* lands here when it *answered*; a tool when `runAction` was asked to perform
* it. That distinction is the whole value of the record — a list of what the
* page offered would describe the registry, and the insight figures built on
* it would describe the registry too.
*
* A ref rather than state: nothing re-renders when it changes, and it is read
* only at the moment a thread is written.
*/
const usedRef = React.useRef({ skills: [], tools: [], knowledge: [] });
const noteUsed = React.useCallback((kind, id) => {
if (!id) return;
const bucket = usedRef.current[kind];
if (bucket && !bucket.includes(id)) bucket.push(id);
}, []);
const setFlow = React.useCallback((flow) => {
flowRef.current = flow || null;
try {
@@ -298,6 +342,9 @@ export function useConversation({
}
setPending(null);
setError(null);
/* A different page or agent is a different conversation, so what the last
one used does not carry over. */
usedRef.current = { skills: [], tools: [], knowledge: [] };
}, [storageKey, flowKey, idKey]);
/**
@@ -333,8 +380,14 @@ export function useConversation({
contextId,
page: pageLabel,
messages: next,
agentId: agent?.id ?? null,
/* Where the question was asked, reduced — see `storableContext`. */
pageContext: owliverContext ? storableContext(owliverContext) : null,
skillsUsed: usedRef.current.skills,
toolsUsed: usedRef.current.tools,
knowledgeUsed: usedRef.current.knowledge,
}));
}, [storageKey, idKey, contextId, pageLabel]);
}, [storageKey, idKey, contextId, pageLabel, agent, owliverContext]);
/* Abort any in-flight response when the context changes or we unmount. */
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
@@ -387,6 +440,7 @@ export function useConversation({
: resolveIntent({
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
courses, workforce, skillContext, positionId,
agent, agentCoversPage, agentSuggestion, owliverContext,
});
}
@@ -570,6 +624,11 @@ export function useConversation({
collection on every turn — including the turn that starts it. */
if ('flow' in intent) setFlow(intent.flow);
/* What this turn used, recorded from the resolved intent rather than from
the page's offer. `intent.skill` is the definition that answered. */
noteUsed('skills', intent.skill?.id);
noteUsed('tools', intent.action?.name);
if (intent.kind !== 'answer') {
try {
await streamDocument({
@@ -606,6 +665,10 @@ export function useConversation({
let latest = [];
for await (const snapshot of provider.stream({
contextId, capability, question: text, facts, signal: controller.signal,
/* What the agent *is*, never what it may read. The page settled that
before this call, and `agentRequest` carries no records. */
agent: agent ? agentRequest(agent, contextId) : null,
owliverContext,
})) {
if (controller.signal.aborted) break;
latest = snapshot;
@@ -708,6 +771,26 @@ export function useConversation({
if (conversationRef.current === id) reset();
}, [reset]);
/**
* Rates the conversation on screen.
*
* The conversation rather than the turn: a reader judging an answer is
* judging the exchange that produced it, and a per-turn rating would ask them
* to score a paragraph out of context. Returns false when there is nothing to
* rate yet, so a caller can stay honest rather than pretending it landed.
*/
const submitFeedback = React.useCallback((rating, noteText = '') => {
if (!conversationRef.current) return false;
setHistory(recordFeedback(conversationRef.current, rating ? { rating, note: noteText } : null));
return true;
}, []);
/** How the conversation on screen is currently rated, or null. */
const feedback = React.useMemo(
() => history.find((r) => r.id === conversationRef.current)?.feedback ?? null,
[history]
);
return {
messages,
pending,
@@ -716,6 +799,8 @@ export function useConversation({
send,
stop,
reset,
submitFeedback,
feedback,
/** Every archived conversation, newest first. */
history,
/** The conversation on screen, so History can mark it. */

View File

@@ -80,6 +80,14 @@ export {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
/* Radix Collapsible, already present and previously unexported. The agent
configuration screen is a stack of long sections, and a page that opened all
of them at once would bury the one being edited. */
export {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
export { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';

View File

@@ -3,7 +3,7 @@ import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
useApplications, useAssignments, useCourses, useCurrentUser, useInterviews, useJobPostings,
usePreferences, useStaff, useUserActivity, useWorkerProfiles,
usePreferences, useShiftRecords, useStaff, useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { allSkills } from '@/lib/skills/registry';
import { sectionsForPage } from '@/lib/skills/uiConfig';
@@ -84,6 +84,9 @@ export function useSkillDataContext(context) {
the matching engine reads availability from assignments, and without them
everybody would look free. */
const { data: assignments = [] } = useAssignments();
/* Shifts worked, missed and overrun, for the attendance and overtime
sources. Same cache the panel reads. */
const { data: shifts = [] } = useShiftRecords();
const { data: user } = useCurrentUser();
const { statesFor } = useWorkforcePaths();
@@ -107,12 +110,13 @@ export function useSkillDataContext(context) {
assignments,
staff,
activity,
shifts,
trainingPaths,
/* `published` belongs here: it is what changes when the reader edits the
form this panel is sitting beside, and leaving it out froze every
section on the first value the page ever published. */
}), [published, context, applications, positions, interviews, courses, workerProfiles, assignments,
staff, activity, trainingPaths]);
staff, activity, shifts, trainingPaths]);
}
/** One declared section, resolved and drawn. */

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

View File

@@ -0,0 +1,267 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Check, Info, Save, Send } from 'lucide-react';
import {
Badge, Button, EmptyState, Tabs, toast,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { usePreferences } from '@/lib/krowHooks';
import { useAgents } from '@/lib/agents/useAgents';
import { agentTemplate } from '@/lib/agents/customAgents';
import { agentFieldsFromSource, applyAgentFields } from '@/lib/agents/agentFields';
import { validateAgentSource } from '@/lib/agents/registry';
import { AgentConfigure } from '@/components/agents/AgentConfigure';
import { AgentTestPanel } from '@/components/agents/AgentTestPanel';
import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
/**
* One agent, configured.
*
* The screen holds *fields*, never Markdown. Editing composes a definition
* through `applyAgentFields` only at the moment of saving, which is what lets
* the storage format stay exactly what it was while the person configuring an
* agent never meets it.
*
* A draft lives in component state until saved, so a half-finished change does
* not reach Owliver. "Unsaved changes" is therefore a real state and is shown
* as one rather than being auto-saved into the switcher mid-edit.
*/
const STATUS_TONE = { published: 'success', draft: 'neutral', archived: 'warning' };
export default function AdminAgentDetail() {
const { id } = useParams();
const navigate = useNavigate();
const preferences = usePreferences();
const {
agents, saving, isShipped, isOverridden, sourceFor, save, publish,
} = useAgents();
const creating = !id;
/* The definition this screen started from: a stored or shipped one, or a
fresh template when creating. */
const baseSource = useMemo(
() => (creating
? agentTemplate({ pages: [], skills: [] })
: sourceFor(id)),
[creating, id, sourceFor]
);
const [fields, setFields] = useState(() => (baseSource ? agentFieldsFromSource(baseSource) : null));
const [dirty, setDirty] = useState(false);
const [view, setView] = useState('configure');
/* Reload when the address changes, but never overwrite an edit in progress. */
useEffect(() => {
if (dirty) return;
setFields(baseSource ? agentFieldsFromSource(baseSource) : null);
}, [baseSource, dirty]);
const onChange = useCallback((next) => {
setFields(next);
setDirty(true);
}, []);
const agent = agents.find((a) => a.id === id) || null;
/** Composes the definition from the fields and stores it. */
const persist = useCallback(async () => {
if (!fields) return null;
/* A new agent needs an id before it has an address. Derived from the name
so the author never has to invent one. */
const composed = applyAgentFields(baseSource, fields);
const problem = validateAgentSource(composed);
if (problem) { toast.error(problem); return null; }
const result = await save(composed);
if (!result.ok) { toast.error(result.error); return null; }
setDirty(false);
/* A newly created agent gains its own address once it exists. */
if (creating) navigate(`/admin/workspace/agents/${result.agent.id}`, { replace: true });
return result.agent;
}, [fields, baseSource, save, creating, navigate]);
const onPublish = useCallback(async () => {
const stored = dirty ? await persist() : agent;
if (!stored) return;
const result = await publish(stored.id);
if (result?.conflict) toast.error(result.conflict.message);
else if (result?.error) toast.error(result.error);
}, [dirty, persist, agent, publish]);
if (!fields) {
return (
<div className="mx-auto w-full max-w-container">
<EmptyState
title="No such agent"
description="It may have been removed, or the address may be wrong."
action={{ label: 'Back to agents', onClick: () => navigate('/admin/workspace/agents') }}
/>
</div>
);
}
const shipped = !creating && isShipped(id);
const Glyph = agentIconFor(fields.icon);
return (
/**
* An editor, not a dashboard.
*
* The width is deliberately not the console's 80rem measure: Owliver holds
* a ~380px track on the right of every Admin page, and capping the editor
* at the dashboard width leaves a strip of empty canvas between the two on
* a wide screen. The workspace takes what `main` gives it, and readable
* measure is enforced inside the document — on the fields and the
* instruction editor — where it actually belongs.
*/
<div className="w-full space-y-3">
{/**
* The editor header.
*
* One block: where you are, what you are editing, what state it is in,
* and the two things you can do to it — with the tabs docked to its
* baseline. It sits on the page background rather than in a card, so the
* workspace below is the only surface on the screen.
*
* Save and Publish sit beside the title rather than above it. On the
* previous layout the eye met a blue Publish button before it had read
* the name of the thing being published.
*/}
<header>
<button
type="button"
onClick={() => navigate('/admin/workspace/agents')}
className={`-ml-1 inline-flex items-center gap-1 rounded px-1 py-0.5 text-caption font-medium
text-ink-4 transition-colors duration-base hover:text-krow-blue
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" /> Agents
</button>
<div className="mt-1.5 mb-1 flex flex-wrap items-start gap-x-4 gap-y-2.5">
{/* Icon and title are one group, so a narrow column wraps the actions
below them rather than stranding the icon on a line of its own. */}
<div className="flex min-w-[13rem] flex-1 basis-[22rem] items-start gap-3">
<span
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border
border-border bg-surface shadow-xs`}
aria-hidden="true"
>
{Glyph
? <Glyph className="h-[1.125rem] w-[1.125rem] text-krow-blue" />
: <OwliverAvatar className="h-5 w-5" rounded="rounded-lg" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<h1 className="min-w-0 truncate font-heading text-title-lg font-bold tracking-tight text-ink-1">
{fields.name || 'New agent'}
</h1>
{agent && (
<>
<span className="sr-only">v{agent.version}</span>
<Badge variant={STATUS_TONE[agent.status] || 'neutral'} className="capitalize">
{agent.status}
</Badge>
</>
)}
{!agent && <Badge variant="neutral">Draft</Badge>}
{shipped && isOverridden(id) && <Badge variant="info">Customized</Badge>}
</div>
<p className="mt-0.5 line-clamp-2 max-w-[80ch] text-body-sm text-ink-3">
{fields.description || 'Configure what this agent covers and what it can do.'}
</p>
</div>
</div>
{/* Save state as a word, not a permanent button: "Saved" is a fact
about the document, and a disabled button is a worse way to say it
than a word is. */}
<div className="flex shrink-0 items-center gap-2.5">
<span
className={cn(
'inline-flex items-center gap-1.5 text-caption transition-colors duration-base',
dirty ? 'font-medium text-warning' : 'text-ink-4'
)}
>
<span
aria-hidden="true"
className={cn(
'grid h-3.5 w-3.5 place-items-center rounded-full transition-colors duration-base',
dirty ? 'bg-warning/20' : 'bg-transparent'
)}
>
{dirty
? <span className="h-1.5 w-1.5 rounded-full bg-warning" />
: <Check className="h-3 w-3" />}
</span>
{dirty ? 'Unsaved changes' : 'Saved'}
</span>
<Button variant="outline" size="sm" onClick={persist} disabled={!dirty} loading={saving}>
<Save className="h-3.5 w-3.5" /> Save
</Button>
<Button size="sm" onClick={onPublish} loading={saving}>
<Send className="h-3.5 w-3.5" />
{agent?.status === 'published' ? 'Publish update' : 'Publish'}
</Button>
</div>
</div>
{/* A standing fact about this agent, not an alarm. It used to be a
full-width tinted banner — the loudest element on a page whose
loudest element should be the thing being written. */}
{shipped && (
<p className="mt-2 flex max-w-[86ch] items-start gap-2 text-caption leading-relaxed text-ink-3">
<Info className="mt-px h-3.5 w-3.5 shrink-0 text-krow-blue" aria-hidden="true" />
<span>
This agent ships with Krow. Your changes are saved as this workspace&apos;s own
version and take over from the shipped one — the shipped definition is never
altered, and reverting brings it back.
</span>
</p>
)}
<Tabs
className="mt-3 mb-1"
variant="underline"
size="sm"
ariaLabel="Agent views"
value={view}
onChange={setView}
tabs={[
{ value: 'configure', label: 'Configure' },
{ value: 'test', label: 'Test' },
{ value: 'insights', label: 'Insights' },
]}
/>
</header>
{view === 'configure' && (
<AgentConfigure
fields={fields}
agents={agents}
customSkills={preferences.customSkills || []}
onChange={onChange}
/>
)}
{view === 'test' && (
<AgentTestPanel
fields={fields}
dirty={dirty}
customSkills={preferences.customSkills || []}
/>
)}
{view === 'insights' && (
<AgentInsightsPanel agentId={id || null} agentName={fields.name} />
)}
</div>
);
}

View File

@@ -1100,8 +1100,8 @@ export default function AdminPositions() {
{ value: 'paused', label: 'Paused' }, { value: 'draft', label: 'Draft' },
{ value: 'closed', label: 'Closed' },
]} />
<FilterSelect value={department} onChange={setDepartment} label="Department"
options={[{ value: 'all', label: 'All departments' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={department} onChange={setDepartment} label="Role"
options={[{ value: 'all', label: 'All roles' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"

View File

@@ -6,6 +6,7 @@ import {
} from 'lucide-react';
import { usePreferences } from '@/lib/krowHooks';
import { aiAgentSkills, allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { allAgents } from '@/lib/agents/registry';
import { AdminPage } from '@/pages/admin/_shell';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { cn } from '@/lib/utils';
@@ -29,6 +30,12 @@ import { cn } from '@/lib/utils';
export default function AdminWorkspace() {
const preferences = usePreferences();
/* Read through the agent registry, never by counting skills — one registry
answers "how many agents", exactly as the skill registry answers its own. */
const agentCount = useMemo(
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }).length,
[preferences.customAgents, preferences.customSkills]
);
/**
* AI agent skills only.
@@ -94,6 +101,35 @@ export default function AdminWorkspace() {
</div>
</div>
{/* ── Agents ─────────────────────────────────────────────────────
Above the two skill columns because it is the layer over them: an
agent decides which of these skills Owliver may use on a page. */}
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<OwliverAvatar className="w-6 h-6" rounded="rounded-md" />
<h2 className="text-[16px] font-bold text-[#101820]">Owliver Agents</h2>
</div>
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
{agentCount} Registered
</span>
</div>
<p className="text-[13px] text-[#687386]">
An agent is how Owliver answers on a page: which skills it may use, what it knows, and
how much work an answer is worth. Agents narrow what a page offers — they never widen it.
</p>
<Link
to="/admin/workspace/agents"
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.01]"
>
<Sparkles className="w-4 h-4" />
Manage Agents
<ArrowRight className="w-4 h-4" />
</Link>
</div>
{/* ── 2. Full Width 2-Column Grid ───────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
{/* ── Left Column: Owliver AI Capabilities ───────────────────── */}

View File

@@ -0,0 +1,564 @@
import React, { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
ArchiveRestore, Archive, Copy, LayoutGrid, List, MoreHorizontal,
Plus, RotateCcw, Send, Settings2, Trash2,
} from 'lucide-react';
import {
Alert, Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger, EmptyState, SearchInput, SegmentedToggle, toast,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { useAgents } from '@/lib/agents/useAgents';
import { searchAgents } from '@/lib/agents/registry';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { AdminPage } from '@/pages/admin/_shell';
/**
* Agents — the workspace management list.
*
* Modern, clean, and high-density agent directory with streamlined
* status filter tabs, no redundant versioning labels, and full-width layout.
*/
const STATUS_TONE = { published: 'success', draft: 'neutral', archived: 'warning' };
function AgentGlyph({ agent, className = 'h-10 w-10' }) {
const Icon = agentIconFor(agent.icon);
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-xl" />;
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-xl border border-border/80 bg-surface-subtle shadow-2xs transition-colors group-hover:border-krow-blue/40 group-hover:bg-krow-blue-tint/30',
className
)}
aria-hidden="true"
>
<Icon className="h-5 w-5 text-krow-blue" />
</span>
);
}
/** Card representation for Grid view */
function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
return (
<div
className={cn(
'group flex flex-col justify-between rounded-2xl border border-border bg-surface p-5 shadow-xs',
'transition-all duration-200 ease-out hover:border-krow-blue/40 hover:shadow-md'
)}
>
<div className="space-y-3">
{/* Top row: Glyph + Title/Badges + Menu */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0 flex-1">
<AgentGlyph agent={agent} className="h-10 w-10" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={onOpen}
className="font-heading text-body font-semibold text-ink-1 hover:text-krow-blue transition-colors
text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 rounded truncate"
>
{agent.name}
</button>
<Badge variant={STATUS_TONE[agent.status] || 'neutral'} size="sm" className="shrink-0 capitalize">
{agent.status}
</Badge>
{shipped && overridden && (
<Badge variant="info" size="sm" className="shrink-0">Customized</Badge>
)}
{!shipped && <Badge variant="neutral" size="sm" className="shrink-0">Custom</Badge>}
</div>
{agent.description && (
<p className="mt-1 text-body-sm leading-relaxed text-ink-3 line-clamp-2">
{agent.description}
</p>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-ink-4 hover:text-ink-1 hover:bg-surface-sunken rounded-lg"
aria-label={`Actions for ${agent.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onOpen} className="cursor-pointer">
<Settings2 className="mr-2 h-3.5 w-3.5" /> Configure
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction('duplicate', agent)} className="cursor-pointer">
<Copy className="mr-2 h-3.5 w-3.5" /> Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
{agent.status !== 'published' && (
<DropdownMenuItem onClick={() => onAction('publish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish
</DropdownMenuItem>
)}
{agent.status === 'published' && (
<DropdownMenuItem onClick={() => onAction('republish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish update
</DropdownMenuItem>
)}
{agent.status !== 'archived' ? (
<DropdownMenuItem onClick={() => onAction('archive', agent)} className="cursor-pointer">
<Archive className="mr-2 h-3.5 w-3.5" /> Archive
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onAction('restore', agent)} className="cursor-pointer">
<ArchiveRestore className="mr-2 h-3.5 w-3.5" /> Restore as draft
</DropdownMenuItem>
)}
{(overridden || !shipped) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* When to use context */}
{agent.trigger && (
<div className="rounded-xl border border-border/60 bg-surface-subtle/60 px-3 py-2 text-[12px] text-ink-3">
<span className="font-semibold text-ink-2">Trigger: </span>
<span>{agent.trigger}</span>
</div>
)}
</div>
{/* Card bottom */}
<div className="mt-4 pt-3.5 border-t border-border/60 space-y-2.5">
<div className="flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Skills</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.skills.length}</strong>
</span>
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Pages</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.pages.length}</strong>
</span>
{agent.subagents.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Subagents</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.subagents.length}</strong>
</span>
)}
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Knowledge</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.knowledge.length}</strong>
</span>
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Reasoning</span>
<strong className="font-bold capitalize text-ink-1">{agent.reasoning}</strong>
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{agent.pages.map((page) => (
<span
key={page}
className="rounded-md border border-border/80 bg-surface-subtle px-2 py-0.5 text-[11px] font-medium text-ink-3"
>
{page}
</span>
))}
</div>
</div>
</div>
);
}
/** Directory Table Row for List view */
function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
return (
<tr className="group border-b border-border/60 transition-colors hover:bg-surface-subtle/50">
{/* Agent Identity */}
<td className="py-3.5 pl-4 pr-3 align-middle">
<div className="flex items-center gap-3">
<AgentGlyph agent={agent} className="h-9 w-9" />
<div className="min-w-0">
<button
type="button"
onClick={onOpen}
className="font-heading text-body-sm font-semibold text-ink-1 hover:text-krow-blue transition-colors text-left focus-visible:outline-none"
>
{agent.name}
</button>
{agent.description && (
<p className="mt-0.5 text-caption text-ink-3 line-clamp-1 max-w-md">
{agent.description}
</p>
)}
</div>
</div>
</td>
{/* Status */}
<td className="px-3 py-3.5 align-middle whitespace-nowrap">
<div className="flex items-center gap-1.5">
<Badge variant={STATUS_TONE[agent.status] || 'neutral'} size="sm" className="capitalize">
{agent.status}
</Badge>
{shipped && overridden && (
<Badge variant="info" size="sm">Customized</Badge>
)}
{!shipped && <Badge variant="neutral" size="sm">Custom</Badge>}
</div>
</td>
{/* Surfaces / Pages */}
<td className="px-3 py-3.5 align-middle">
<div className="flex flex-wrap gap-1 max-w-xs">
{agent.pages.slice(0, 3).map((page) => (
<span
key={page}
className="rounded-md border border-border/80 bg-surface-subtle px-2 py-0.5 text-[11px] font-medium text-ink-3"
>
{page}
</span>
))}
{agent.pages.length > 3 && (
<span className="rounded-md border border-border/60 bg-surface-subtle px-1.5 py-0.5 text-[11px] font-medium text-ink-4">
+{agent.pages.length - 3}
</span>
)}
</div>
</td>
{/* Capabilities */}
<td className="px-3 py-3.5 align-middle whitespace-nowrap">
<div className="flex items-center gap-2 text-caption text-ink-3">
<span className="rounded bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-2">
<strong className="font-semibold tabular-nums text-ink-1">{agent.skills.length}</strong> skills
</span>
<span className="rounded bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium capitalize text-ink-2">
{agent.reasoning}
</span>
</div>
</td>
{/* Actions */}
<td className="py-3.5 pl-3 pr-4 align-middle text-right whitespace-nowrap">
<div className="inline-flex items-center gap-1.5">
<Button variant="outline" size="sm" onClick={onOpen} className="h-8 text-body-sm font-medium">
<Settings2 className="mr-1.5 h-3.5 w-3.5" /> Configure
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-ink-4 hover:text-ink-1 rounded-lg"
aria-label={`Actions for ${agent.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onOpen} className="cursor-pointer">
<Settings2 className="mr-2 h-3.5 w-3.5" /> Configure
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction('duplicate', agent)} className="cursor-pointer">
<Copy className="mr-2 h-3.5 w-3.5" /> Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
{agent.status !== 'published' && (
<DropdownMenuItem onClick={() => onAction('publish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish
</DropdownMenuItem>
)}
{agent.status === 'published' && (
<DropdownMenuItem onClick={() => onAction('republish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish update
</DropdownMenuItem>
)}
{agent.status !== 'archived' ? (
<DropdownMenuItem onClick={() => onAction('archive', agent)} className="cursor-pointer">
<Archive className="mr-2 h-3.5 w-3.5" /> Archive
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onAction('restore', agent)} className="cursor-pointer">
<ArchiveRestore className="mr-2 h-3.5 w-3.5" /> Restore as draft
</DropdownMenuItem>
)}
{(overridden || !shipped) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
);
}
export default function AdminWorkspaceAgents() {
const navigate = useNavigate();
const {
agents, diagnostics, saving, isShipped, isOverridden,
publish, archive, restore, duplicate, remove,
} = useAgents();
const [query, setQuery] = useState('');
const [status, setStatus] = useState('all');
const [view, setView] = useState(() => {
try {
return localStorage.getItem('krow_agents_view') || 'list';
} catch {
return 'list';
}
});
const [confirming, setConfirming] = useState(null);
const handleViewChange = (newView) => {
setView(newView);
try {
localStorage.setItem('krow_agents_view', newView);
} catch {
/* ignore */
}
};
const visible = useMemo(() => {
const found = searchAgents(agents, query);
return status === 'all' ? found : found.filter((a) => a.status === status);
}, [agents, query, status]);
const publishedCount = agents.filter((a) => a.status === 'published').length;
const draftCount = agents.filter((a) => a.status === 'draft').length;
const archivedCount = agents.filter((a) => a.status === 'archived').length;
/** Runs a lifecycle action and reports honestly when it is refused. */
const run = async (action, agent) => {
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[action];
const result = await fn(agent.id);
if (result?.conflict) toast.error(result.conflict.message);
else if (result?.error) toast.error(result.error);
else if (action === 'duplicate' && result?.id) navigate(`/admin/workspace/agents/${result.id}`);
};
const onAction = (action, agent) => {
/* Destructive and irreversible-looking actions confirm; the rest run. */
if (action === 'remove' || action === 'archive') {
setConfirming({ action, agent });
return;
}
run(action, agent);
};
return (
<div className="w-full">
<AdminPage
title="Agents"
subtitle="The agents Owliver can answer as, what each one covers, and what it may do."
meta={`${agents.length} registered · ${publishedCount} published`}
actions={
<Button onClick={() => navigate('/admin/workspace/agents/new')}>
<Plus className="h-4 w-4" /> Create agent
</Button>
}
>
<div className="space-y-4">
{diagnostics.length > 0 && (
<Alert tone="warning" title={`${diagnostics.length} definition problem${diagnostics.length === 1 ? '' : 's'}`} className="py-2.5 px-3.5">
<ul className="mt-1 space-y-0.5 text-body-sm">
{diagnostics.slice(0, 5).map((d) => (
<li key={`${d.kind}-${d.agentId}-${d.message}`}>{d.message}</li>
))}
</ul>
</Alert>
)}
{/* Unified horizontal management toolbar with clean filter pills */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-3">
<div className="w-full sm:w-72">
<SearchInput value={query} onChange={setQuery} placeholder="Search agents..." />
</div>
{/* Modern status filter tabs */}
<div className="inline-flex items-center rounded-xl border border-border/80 bg-surface-subtle/80 p-1 text-caption">
<button
type="button"
onClick={() => setStatus('all')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'all'
? 'bg-surface text-ink-1 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
All <span className="text-ink-4 font-normal">({agents.length})</span>
</button>
<button
type="button"
onClick={() => setStatus('published')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'published'
? 'bg-surface text-emerald-700 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Published <span className="text-ink-4 font-normal">({publishedCount})</span>
</button>
{draftCount > 0 && (
<button
type="button"
onClick={() => setStatus('draft')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'draft'
? 'bg-surface text-ink-1 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Drafts <span className="text-ink-4 font-normal">({draftCount})</span>
</button>
)}
{archivedCount > 0 && (
<button
type="button"
onClick={() => setStatus('archived')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'archived'
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
</button>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Link
to="/admin/workspace/skills"
className="hidden md:inline-flex items-center gap-1.5 rounded-lg border border-border/70 bg-surface px-3 py-1.5 text-body-sm text-ink-3 transition-colors hover:border-krow-blue/40 hover:text-krow-blue hover:bg-surface-subtle"
>
<span>Skills managed in</span>
<span className="font-semibold text-krow-blue">Workspace &rarr; Skills</span>
</Link>
<SegmentedToggle
value={view}
onChange={handleViewChange}
variant="surface"
size="sm"
options={[
{ value: 'list', icon: List, ariaLabel: 'Directory table view' },
{ value: 'grid', icon: LayoutGrid, ariaLabel: 'Grid cards view' },
]}
/>
</div>
</div>
{visible.length === 0 ? (
<EmptyState
title={agents.length ? 'No agents match that' : 'No agents yet'}
description={
agents.length
? 'Try a different search, or clear the status filter.'
: 'An agent decides which skills Owliver can use on a page. Create one to get started.'
}
/>
) : view === 'list' ? (
/* Clean Modern Directory Table View */
<div className="overflow-hidden rounded-2xl border border-border bg-surface shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-border bg-surface-subtle/60 text-[11px] font-semibold uppercase tracking-wider text-ink-4">
<th className="py-3 pl-4 pr-3 font-semibold">Agent</th>
<th className="px-3 py-3 font-semibold">Status</th>
<th className="px-3 py-3 font-semibold">Surfaces Covered</th>
<th className="px-3 py-3 font-semibold">Capabilities</th>
<th className="py-3 pl-3 pr-4 text-right font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{visible.map((agent) => (
<AgentTableRow
key={agent.id}
agent={agent}
shipped={isShipped(agent.id)}
overridden={isOverridden(agent.id)}
onOpen={() => navigate(`/admin/workspace/agents/${agent.id}`)}
onAction={onAction}
/>
))}
</tbody>
</table>
</div>
</div>
) : (
/* Modern Grid View */
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{visible.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
shipped={isShipped(agent.id)}
overridden={isOverridden(agent.id)}
onOpen={() => navigate(`/admin/workspace/agents/${agent.id}`)}
onAction={onAction}
/>
))}
</div>
)}
</div>
<ConfirmModal
open={Boolean(confirming)}
onOpenChange={(open) => !open && setConfirming(null)}
title={
confirming?.action === 'archive'
? `Archive ${confirming?.agent.name}?`
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : `Delete ${confirming?.agent.name}?`
}
description={
confirming?.action === 'archive'
? 'It stops answering and disappears from the switcher. Its definition is kept, and it can be restored as a draft.'
: isShipped(confirming?.agent?.id)
? 'Your changes to this agent are discarded and the shipped definition takes over again.'
: 'This definition is removed. It cannot be recovered.'
}
confirmLabel={confirming?.action === 'archive' ? 'Archive' : 'Confirm'}
busy={saving}
onConfirm={async () => {
const pending = confirming;
setConfirming(null);
if (pending) await run(pending.action, pending.agent);
}}
/>
</AdminPage>
</div>
);
}

View File

@@ -0,0 +1,99 @@
---
id: activity-analysis
name: Activity Analysis
description: Break down what has happened in this workspace, by kind of event and by account.
category: operations
pages:
- activity
status: active
version: 1
triggers:
- activity breakdown
- event breakdown
- what kind of events
- events by type
- who did what
- busiest account
owliver:
enabled: true
suggestions:
- label: What kinds of event are there?
capability: table
- label: Summarize workspace activity
capability: summary
- label: Activity over recent periods
capability: flow
capabilities:
- summary
- stats
- table
- list
- progress
- flow
responses:
summary:
title: Activity breakdown
source: activity.breakdown
stats:
title: Activity breakdown
source: activity.breakdown
table:
title: Events by kind
source: activity.breakdown
list:
title: Events by kind
source: activity.breakdown
progress:
title: Events by kind
source: activity.breakdown
flow:
title: Activity over time
source: activity.breakdown
periods:
- last-7-days
- this-month
- previous-month
---
# Activity Analysis
## Purpose
- Report what has happened in this workspace and in what proportion.
- Count how many accounts are active.
- Show activity across recent periods.
## Capabilities
- Break events down by kind, with each kind's share.
- Count distinct event kinds and active accounts.
- Window the breakdown by period.
## Data
Reads `activity.breakdown`, which counts `UserActivity` records by `event_type`
and by account.
## Analysis
Events are counted by kind and expressed as a share of the total, because a raw
count means little without knowing whether twelve logins is most of the log or a
fraction of it.
Stored event names are machine keys; they are rendered as words so a reader does
not have to translate `hire_candidate` in their head.
## Output
Total events, number of distinct kinds, number of active accounts, then a row
per kind with its count and share.
## Limitations
- This describes the audit log, not the underlying records. Ten `apply_job`
events mean ten logged actions, which is not a guarantee of ten applications
surviving in the pipeline.
- Overlapping periods are deduplicated by event, so asking for today and the
last seven days together does not double-count today.
- This counts activity; it does not judge it. Whether a pattern is unusual is
Anomaly Detection's question.

View File

@@ -0,0 +1,96 @@
---
id: anomaly-detection
name: Anomaly Detection
description: Surface activity that departs from this workspace's own pattern — and stay quiet when nothing does.
category: operations
pages:
- activity
- control-center
status: active
version: 1
triggers:
- anomaly
- anomalies
- anomalous
- unusual
- out of pattern
- suspicious
owliver:
enabled: true
suggestions:
- label: Is anything unusual?
capability: insight
- label: Show the signals
capability: table
capabilities:
- summary
- insight
- list
- table
- stats
responses:
summary:
title: Activity signals
source: activity.signals
insight:
title: Unusual activity
source: activity.signals
list:
title: Signals
source: activity.signals
table:
title: Signals
source: activity.signals
stats:
title: Activity signals
source: activity.signals
---
# Anomaly Detection
## Purpose
- Surface activity that departs from this workspace's own baseline.
- Explain each signal rather than only naming it.
- Report nothing when nothing departs, so a signal keeps its meaning.
## Capabilities
- Detect concentration, bursts, off-hours activity, silence and privileged-action share.
- Report how many signals are currently raised.
- Explain what each one means.
## Data
Reads `activity.signals`, which is the same detection the assistant's own
greeting counts — one implementation in `lib/activitySignals.js`, so "two
unusual patterns" means the same two wherever it is said.
## Analysis
Five patterns are checked against this workspace's own history:
1. **Concentration** — one account is responsible for half or more of events.
2. **Burst** — more than three actions from one account inside one hour.
3. **Off-hours** — activity before 06:00 or after 22:00.
4. **Silent** — a log that has events but nothing in the last 24 hours.
5. **Privileged share** — more than 30% of events change who is employed or
what is being hired for.
Only patterns that clear their threshold are reported. A workspace with nothing
unusual returns no signals, not a low-severity note.
## Output
A count of raised signals, and one row per signal explaining what triggered it
with the figure behind it.
## Limitations
- **A signal is a deviation from a baseline, not a verdict.** On a live
deployment most resolve to an integration, a bulk import or a busy afternoon.
Nothing here asserts wrongdoing.
- Thresholds are fixed, not learned. A workspace whose normal pattern is one
busy account will report concentration every time it is asked.
- The baseline is the whole activity log, not a rolling window, so a young
workspace has little to compare against.

View File

@@ -0,0 +1,105 @@
---
id: attendance-analysis
name: Attendance Analysis
description: Analyse workforce attendance, lateness and absence, and compare people and departments.
category: workforce
pages:
- analytics
- control-center
status: active
version: 1
triggers:
- attendance
- absence
- absences
- absenteeism
- late arrival
- missed shift
- missed shifts
owliver:
enabled: true
suggestions:
- label: How is attendance?
capability: summary
- label: Compare attendance by person
capability: table
- label: Attendance over recent periods
capability: flow
capabilities:
- summary
- stats
- table
- progress
- flow
- insight
responses:
summary:
title: Attendance
source: workforce.attendance
stats:
title: Attendance
source: workforce.attendance
table:
title: Attendance by person
source: workforce.attendance
progress:
title: Attendance by person
source: workforce.attendance
flow:
title: Attendance over time
source: workforce.attendance
periods:
- last-7-days
- this-month
- previous-month
insight:
title: Attendance
source: workforce.attendance
---
# Attendance Analysis
## Purpose
- Report how reliably the workforce is turning up.
- Separate turning up from turning up on time, because they have different causes.
- Compare people and departments so a problem can be located rather than only counted.
## Capabilities
- Summarize attendance, punctuality and missed shifts.
- Compare attendance per person, worst first.
- Show attendance across recent periods.
## Data
Reads `workforce.attendance`, which counts `ShiftRecord` entries — every shift
scheduled, whether it was worked, how late it started and how long it ran.
Department is the position's `role_category`, the same field Hired History and
Analytics group by.
## Analysis
Attendance is the share of scheduled shifts that were **turned up for at all**,
late or not. Punctuality is reported separately, as the share turned up for on
time. Folding the two together would make a reliably-late team look absent and
a genuinely absent one look better than it is.
Minutes lost to lateness are reported alongside the count, because four late
arrivals says nothing about whether it cost ten minutes or two hours.
## Output
Headline attendance and punctuality rates, missed shifts split into absences and
no-shows, and a row per person with their record. Over periods, one figure per
window.
## Limitations
- Only shifts that were scheduled are counted. Unrostered work does not appear.
- A no-show and an absence are counted separately but both reduce attendance;
the distinction is in the detail line, not in the headline rate.
- The roster is whoever has shift records. This workspace has three, so a
department average is one person's record — the per-person view is the more
honest read at this size.
- Excused absence is a recognised status but none is currently recorded.

View File

@@ -0,0 +1,98 @@
---
id: candidate-analysis
name: Candidate Analysis
description: Analyse the applicant pool's quality, and how much of it has actually been screened.
category: hiring
pages:
- candidates
- candidates-analysis
status: active
version: 1
triggers:
- candidate quality
- quality of candidates
- score band
- score bands
- screening coverage
- how strong*candidates
- how good*candidates
owliver:
enabled: true
suggestions:
- label: How strong is the candidate pool?
capability: summary
- label: Show candidates by score
capability: table
- label: Score bands
capability: progress
capabilities:
- summary
- stats
- table
- list
- progress
- insight
responses:
summary:
title: Candidate quality
source: candidates.quality
stats:
title: Candidate quality
source: candidates.quality
table:
title: Candidates by score
source: candidates.quality
limit: 10
list:
title: Strongest candidates
source: candidates.quality
limit: 5
progress:
title: Candidate quality
source: candidates.quality
insight:
title: Candidate quality
source: candidates.quality
---
# Candidate Analysis
## Purpose
- Report how strong the applicant pool is.
- Report how much of it anyone has actually looked at, beside the quality figure.
- Rank candidates by score so a shortlist has a starting point.
## Capabilities
- Summarize pool size, screening coverage, average score and interview count.
- List or tabulate candidates by score.
- Break the scored pool into quality bands.
## Data
Reads `candidates.quality`, which counts `JobApplication` records and their
`ai_score`, joined to `AIInterview` records for interview coverage.
## Analysis
Coverage is reported next to quality, always. 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.
Scored candidates are grouped into four bands — 80 and above, 70 to 79, 50 to 69,
and below 50 — because a mean hides whether a pool is uniformly mediocre or
split between strong and weak.
## Output
Pool size, share screened, average score across scored candidates only, and
interview count. Then candidates ranked by score with their role and stage.
## Limitations
- Unscored candidates are excluded from the average rather than counted as zero.
They are reported separately as the unscreened share.
- A score is an AI screening score, not an interview outcome or a hiring decision.
- Filtering by period counts applications by when they were received, not by when
they were screened.

View File

@@ -0,0 +1,81 @@
---
id: executive-summary
name: Executive Summary
description: The whole workspace in one reading — positions, candidates, hires, talent and attendance.
category: analytics
pages:
- control-center
status: active
version: 1
triggers:
- executive summary
- workspace summary
- overall summary
- state of the workspace
- brief me
owliver:
enabled: true
suggestions:
- label: Give me an executive summary
capability: summary
- label: Show the headline figures
capability: stats
capabilities:
- summary
- stats
- card
- table
- insight
responses:
summary:
title: Workspace summary
source: workspace.summary
stats:
title: Workspace summary
source: workspace.summary
card:
title: Workspace summary
source: workspace.summary
table:
title: Workspace summary
source: workspace.summary
insight:
title: Workspace summary
source: workspace.summary
---
# Executive Summary
## Purpose
- Give a management-level reading of the whole workspace in one answer.
- Draw every figure from the source that owns it, so the summary cannot drift
from the pages it summarizes.
## Capabilities
- Report open roles, candidates, hires, talent pool size, attendance and logged events.
## Data
Reads `workspace.summary`, which counts `JobPosting`, `JobApplication`, `Staff`,
`WorkerProfile`, `UserActivity` and `ShiftRecord`.
## Analysis
Each figure is read from the domain that owns it rather than recomputed here. A
domain with no records contributes a zero and says so in its detail line — the
summary reports what is there, including an absence.
## Output
Six headline figures: open roles, candidates, hires, talent pool, attendance
rate and events logged, each with a supporting detail.
## Limitations
- This is a count, not a diagnosis. Which figures are a problem is what Staffing
Risk, Operational Risk and Anomaly Detection answer.
- Attendance is 0 where no shifts have been recorded, and the detail line says
so rather than implying nobody turned up.
- No trend or comparison to a previous period is included.

View File

@@ -0,0 +1,88 @@
---
id: hiring-history-analysis
name: Hiring History Analysis
description: Analyse completed hires — who was hired, how quickly, and how well they scored.
category: hiring
pages:
- hired-history
status: active
version: 1
triggers:
- hiring history
- hire quality
- quality of hire
- time to hire
- who did we hire
- recent hires
owliver:
enabled: true
suggestions:
- label: Who did we hire recently?
capability: list
- label: How is hiring performance?
capability: summary
capabilities:
- summary
- stats
- list
- table
- timeline
- insight
responses:
summary:
title: Hiring performance
source: hires.performance
stats:
title: Hiring performance
source: hires.performance
insight:
title: Hiring performance
source: hires.performance
list:
title: Recent hires
source: hires.recent
limit: 10
table:
title: Recent hires
source: hires.recent
timeline:
title: Recent hires
source: hires.recent
---
# Hiring History Analysis
## Purpose
- Report hires that have already happened.
- Report how long they took and how well they scored.
- Keep the record after the decision separate from the pipeline before it.
## Capabilities
- Summarize total hires, average days to hire, quality of hire and conversion rate.
- List recent hires with their role and date.
## Data
Reads `hires.performance` and `hires.recent`, which join `Staff` to their
`JobApplication` and `JobPosting` records.
## Analysis
Time to hire is the span between an application arriving and its final update.
Quality of hire is the average AI score across scored applications. Conversion
is hires as a share of all applications.
## Output
Headline hiring figures, and a list or timeline of recent hires.
## Limitations
- This is the record after the decision. Candidates still under consideration
are Candidate Analysis's question.
- Time to hire is measured from the application record's timestamps, not from
when a role was opened.
- Quality of hire is a screening score, not a performance review. Nothing here
reports how a hire has since worked out.

View File

@@ -0,0 +1,88 @@
---
id: hiring-pulse-analysis
name: Hiring Pulse Analysis
description: Read the recent rhythm of hiring — applications arriving, and how the funnel is converting.
category: hiring
pages:
- control-center
- analytics
status: active
version: 1
triggers:
- hiring pulse
- hiring velocity
- hiring rhythm
- application rate
owliver:
enabled: true
suggestions:
- label: What is the hiring pulse?
capability: summary
- label: Applications over recent periods
capability: flow
capabilities:
- summary
- flow
- stats
- insight
responses:
summary:
title: Hiring pulse
source: candidates.activity
periods:
- today
- last-7-days
- previous-month
flow:
title: Applications over time
source: candidates.activity
periods:
- today
- last-7-days
- this-month
- previous-month
stats:
title: Hiring performance
source: hires.performance
insight:
title: Hiring performance
source: hires.performance
---
# Hiring Pulse Analysis
## Purpose
- Report the recent rhythm of hiring: how many applications are arriving, and
how the funnel is converting them.
- Distinguish a quiet week from a broken pipeline.
## Capabilities
- Count applications arriving across recent periods.
- Report conversion, speed and quality of hire.
## Data
Reads `candidates.activity` for arrival counts over periods, and
`hires.performance` for conversion, time-to-hire and quality.
## Analysis
Arrival counts are reported per period rather than as a single rate, because a
rate averages away the shape — thirty applications in a month is a different
situation depending on whether they arrived steadily or all on one day.
## Output
Applications per period, and headline conversion, speed and quality figures.
## Limitations
- **This is the analysis definition only.** The Hiring Pulse card that appears
on Krow pages is a separate UI configuration with its own placement and
period settings; the two are deliberately not the same definition and changing
one does not change the other.
- Arrival counts are by application creation date, not by when a role opened.
- A period with no applications reports zero, which is a real reading — it does
not distinguish "nobody applied" from "the role was not advertised".

View File

@@ -0,0 +1,80 @@
---
id: learning-analysis
name: Learning Analysis
description: Report what the training library holds and how far the workforce has progressed through it.
category: workforce
pages:
- krow-forge
status: active
version: 1
triggers:
- learning analysis
- training progress
- course progress
- training library
- what training
owliver:
enabled: true
suggestions:
- label: How is training progressing?
capability: progress
- label: What does the library hold?
capability: list
capabilities:
- summary
- progress
- list
- table
- stats
responses:
summary:
title: Training progress
source: workforce.training
progress:
title: Training progress
source: workforce.training
list:
title: Training paths
source: workforce.training
table:
title: Training paths
source: workforce.training
stats:
title: Training progress
source: workforce.training
---
# Learning Analysis
## Purpose
- Report what the training library holds.
- Report how far the workforce has progressed against it.
## Capabilities
- Summarize training paths and progress against them.
- List or tabulate the paths in the library.
## Data
Reads `workforce.training`, the existing source behind the Forge progression
views.
## Analysis
Progress is counted against the paths the library actually defines, so adding a
path changes the denominator rather than being reported as a sudden fall in
completion.
## Output
Training paths with progress against each.
## Limitations
- A skill in Forge is something a person learns and is verified in. It is not an
Owliver capability, and the two must not be described as the same thing.
- Progress is personal to the signed-in worker where their record is loaded, and
library-level otherwise.
- This reports progression, not whether the training is any good.

View File

@@ -0,0 +1,94 @@
---
id: operational-risk
name: Operational Risk
description: Surface what is going wrong operationally across hiring and the roster.
category: operations
pages:
- control-center
- activity
status: active
version: 1
triggers:
- operational risk
- operations risk
- what is going wrong
- backlog
- decisions owed
owliver:
enabled: true
suggestions:
- label: What is going wrong operationally?
capability: list
- label: Summarize operational risk
capability: summary
capabilities:
- summary
- list
- table
- stats
- insight
responses:
summary:
title: Operational risk
source: operations.risk
list:
title: Operational risks
source: operations.risk
table:
title: Operational risks
source: operations.risk
stats:
title: Operational risk
source: operations.risk
insight:
title: Biggest operational risk
source: operations.risk
---
# Operational Risk
## Purpose
- Surface the operational problems that need somebody to act.
- Draw them from every domain, because they feel like one problem to the person
who has to fix them.
- Report nothing when the operation is running.
## Capabilities
- Detect strong candidates left awaiting a decision.
- Detect an unscreened application backlog.
- Detect open roles with no applicants.
- Detect shifts going unworked.
## Data
Reads `operations.risk`, which joins `JobApplication`, `JobPosting` and
`ShiftRecord`.
## Analysis
Four checks, each with a floor so ordinary operation does not trip them:
1. **Decisions owed** — a candidate scoring 70 or above, screened or
shortlisted, and not moved on. Any such candidate counts.
2. **Unscreened backlog** — three or more applications with no score.
3. **Roles with no applicants** — any open role nobody has applied to.
4. **Shifts unworked** — two or more absences or no-shows in the last 7 days.
Findings are ordered most severe first. A finding is included only when it
exists; an empty list means the operation is running, not that the check was
skipped.
## Output
A count of open risks, then a row per risk naming what is wrong and the figures
behind it.
## Limitations
- Thresholds are fixed rather than tuned to this workspace's volume.
- "Decisions owed" assumes a screened, strong candidate should be progressed.
A candidate deliberately held is indistinguishable from one overlooked.
- The shift check covers the last 7 days only; a longer pattern is Attendance
Analysis's question.

View File

@@ -0,0 +1,98 @@
---
id: overtime-analysis
name: Overtime Analysis
description: Analyse overtime hours, who is carrying them, and whether they are growing.
category: workforce
pages:
- analytics
- control-center
status: active
version: 1
triggers:
- overtime
- extra hours
- hours worked
- working late
owliver:
enabled: true
suggestions:
- label: How much overtime are we running?
capability: summary
- label: Who is working the most overtime?
capability: table
- label: Overtime over recent periods
capability: flow
capabilities:
- summary
- stats
- table
- progress
- flow
- insight
responses:
summary:
title: Overtime
source: workforce.overtime
stats:
title: Overtime
source: workforce.overtime
table:
title: Overtime by person
source: workforce.overtime
progress:
title: Overtime by person
source: workforce.overtime
flow:
title: Overtime over time
source: workforce.overtime
periods:
- last-7-days
- this-month
- previous-month
insight:
title: Overtime
source: workforce.overtime
---
# Overtime Analysis
## Purpose
- Report how much overtime the workforce is carrying.
- Say who is carrying it, since a total spread evenly and a total sitting on one
person are different problems.
- Show whether it is growing.
## Capabilities
- Summarize overtime hours and their share of scheduled time.
- Compare overtime per person, most hours first.
- Show overtime across recent periods.
## Data
Reads `workforce.overtime`, which compares scheduled hours to hours actually
worked across `ShiftRecord` entries.
## Analysis
Overtime is reported both as hours and as a share of scheduled time. The ratio
is what makes two teams comparable — forty hours means one thing across a
fortnight and another across a year.
Attendance and overtime are kept separate because 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" figure would report that as healthy.
## Output
Total overtime hours, share of scheduled time, average per shift, and a row per
person. Over periods, hours per window.
## Limitations
- Overtime is derived from recorded shift end times, not from an approvals
process. A shift that ran long appears here whether or not it was authorised.
- A person with no overtime appears with zero, which is a real reading rather
than missing data.
- Cost is not calculated. No pay rate is attached to a shift record.

View File

@@ -0,0 +1,99 @@
---
id: staffing-risk
name: Staffing Risk
description: Identify open roles that will not fill on their own, and say why.
category: workforce
pages:
- positions
- control-center
status: active
version: 1
triggers:
- staffing risk
- staffing gap
# `*` stands for anything in between, so one line covers "roles at risk",
# "roles that are at risk" and "roles most at risk" without listing each.
- roles*at risk
- positions*at risk
- understaffed
owliver:
enabled: true
suggestions:
- label: Which roles are at risk?
capability: list
- label: Summarize staffing risk
capability: summary
capabilities:
- summary
- list
- table
- insight
responses:
summary:
title: Staffing risk
source: positions.risk
list:
title: Roles at risk
source: positions.risk
limit: 5
table:
title: Roles at risk
source: positions.risk
insight:
title: Biggest staffing risk
source: positions.risk
---
# Staffing Risk
## Purpose
- Name the open roles that are not going to fill without intervention.
- Say which of four distinct problems each one has, because each has a
different fix.
- Rank them so the reader knows which to deal with first.
## Capabilities
- Count the open roles currently at risk.
- List those roles, worst first, with the reason for each.
- Identify the single role most in need of attention.
## Data
Reads `positions.risk`, which joins open `JobPosting` records to their
`JobApplication` records through the same `buildPosition` reading the Positions
page renders from. No separate calculation, so a risk reported here and a health
badge shown there cannot disagree.
## Analysis
A role is at risk when any of the following is true. They are kept apart rather
than combined into a score, because the score would hide the only part that
tells the reader what to do:
1. **No applicants yet** — nobody has applied. Needs sourcing.
2. **No candidate scoring 70 or above** — people applied, none are viable.
Needs the requirements or the pay revisiting.
3. **Three or more unscreened** — a backlog nobody has looked at. Needs
screening.
4. **Someone awaiting a decision** — a strong candidate has been screened and
not moved on. Needs a person to decide.
Ranking weights an empty pipeline above a busy one that needs attention, since
an empty pipeline takes longest to recover.
## Output
A count of roles at risk, then a row per role naming its department and its
reasons. A role with no problems is not listed.
## Limitations
- Only open roles are considered. A paused or closed role is not at risk.
- "Viable" means an AI score of 70 or above. An unscored candidate is not
counted as viable, so a role whose applicants nobody has screened will report
both an unscreened backlog and no viable candidate — those are two true
statements about the same cause.
- This reads the pipeline, not the roster. It does not know how many people a
role needs, because no position in this workspace states a headcount.

View File

@@ -0,0 +1,93 @@
---
id: talent-pool-analysis
name: Talent Pool Analysis
description: Analyse the talent already known to this workspace — who is in it, how they score, and who is available.
category: hiring
pages:
- talent-pool
status: active
version: 1
triggers:
- talent pool
- pool health
- available talent
- who is available
- supply of talent
owliver:
enabled: true
suggestions:
- label: How healthy is the talent pool?
capability: summary
- label: Who is in the pool?
capability: table
- label: Strongest people in the pool
capability: list
capabilities:
- summary
- stats
- table
- list
- progress
- insight
responses:
summary:
title: Talent pool
source: talent.pool
stats:
title: Talent pool
source: talent.pool
table:
title: Talent pool
source: talent.pool
list:
title: Strongest in the pool
source: talent.pool
limit: 5
progress:
title: Talent pool
source: talent.pool
insight:
title: Talent pool
source: talent.pool
---
# Talent Pool Analysis
## Purpose
- Report who this workspace already knows and could place.
- Say how much of the pool has been assessed, and how much has not.
- Report availability and certification coverage.
## Capabilities
- Summarize pool size, how many are scored, and average score.
- List or tabulate people by score.
- Report how many have availability on file and how many are certified.
## Data
Reads `talent.pool`, which counts `WorkerProfile` records — their `krow_score`,
`availability`, `certifications` and stated role.
## Analysis
The average score is computed across **scored profiles only**. Counting an
unassessed profile as zero would report a healthy pool as poor in exact
proportion to how much of it nobody has got to yet — a figure that gets worse
as the pool grows, which is the opposite of what it should do.
An unscored person is reported as "Not yet scored" rather than shown with a
zero, because a zero reads as a bad assessment rather than an absent one.
## Output
Pool size, how many are scored and how many are not, average score across the
scored, availability and certification counts, then people ranked by score.
## Limitations
- This is supply, not applicants. Someone in the pool has not applied to anything
by being here, and must not be described as a candidate for a role.
- Availability is what a person stated on their profile, not a live calendar.
- A profile with no score is unassessed, which is not the same as being weak.

View File

@@ -0,0 +1,88 @@
---
id: workforce-analytics
name: Workforce Analytics
description: Report workforce coverage — open roles and who has actually been hired into them.
category: analytics
pages:
- analytics
status: active
version: 1
triggers:
- workforce analytics
- workforce coverage
- roles covered
- coverage
owliver:
enabled: true
suggestions:
- label: How well are open roles covered?
capability: summary
- label: Coverage by role
capability: table
capabilities:
- summary
- stats
- table
- list
- progress
- insight
responses:
summary:
title: Workforce coverage
source: workforce.coverage
stats:
title: Workforce coverage
source: workforce.coverage
table:
title: Coverage by role
source: workforce.coverage
list:
title: Coverage by role
source: workforce.coverage
progress:
title: Coverage by role
source: workforce.coverage
insight:
title: Workforce coverage
source: workforce.coverage
---
# Workforce Analytics
## Purpose
- Report how many open roles have somebody hired into them.
- Name the roles that have nobody yet.
- State plainly where a role has not said how many people it needs.
## Capabilities
- Count open roles, those with someone hired, and those with nobody.
- Report coverage per role.
## Data
Reads `workforce.coverage`, which reads open `JobPosting` records through
`demandFor` — the same reading the Positions page uses — joined to `Staff` by
`job_posting_id`.
## Analysis
A role's coverage is the number of people hired into it against the number it
asked for. Where a role has not declared a headcount, this reports the hires and
says the target is unstated. It does **not** assume one person per role: that
would produce a confident fill percentage that means nothing, and nothing on
screen would say so.
## Output
Counts of open roles, covered roles and uncovered roles, how many roles state a
headcount, and a row per role.
## Limitations
- No position in this workspace currently states a headcount, so no fill
percentage is reported. The count of hires per role is real.
- Only open roles are counted. Paused and closed roles are excluded.
- Assignment records would refine this, but none exist yet; coverage is
therefore counted from hires.