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