agnets done

This commit is contained in:
2026-08-28 11:02:02 +05:30
parent ab9adde6cd
commit eaf08e061d
58 changed files with 3506 additions and 4308 deletions

View File

@@ -21,6 +21,9 @@ starters:
permissions:
owner: demo@krow.app
access: all
tools:
- activity_breakdown
- activity_signals
---
# Activity Agent

View File

@@ -23,6 +23,14 @@ starters:
permissions:
owner: demo@krow.app
access: all
tools:
- workspace_summary
- workforce_attendance
- workforce_overtime
- workforce_coverage
- candidates_quality
- hires_performance
- activity_breakdown
---
# Analytics Agent

View File

@@ -21,6 +21,12 @@ starters:
permissions:
owner: demo@krow.app
access: all
tools:
- candidates_quality
- talent_pool
- hires_recent
- candidates_awaiting
- move_application
---
# Candidates Agent

View File

@@ -25,6 +25,16 @@ starters:
permissions:
owner: demo@krow.app
access: all
tools:
- knowledge_search
- workspace_summary
- operations_risk
- activity_signals
- positions_risk
- workforce_coverage
- candidates_awaiting
sources:
- policy_docs
---
# Control Center Agent

View File

@@ -19,6 +19,9 @@ starters:
permissions:
owner: demo@krow.app
access: all
tools:
- hires_recent
- hires_performance
---
# Hired History Agent

View File

@@ -20,6 +20,9 @@ starters:
permissions:
owner: demo@krow.app
access: all
tools:
- workforce_training
- talent_pool
---
# KROW Forge Agent

View File

@@ -78,6 +78,16 @@ permissions:
people:
- user: demo@krow.app
role: manager
tools:
- workspace_summary
- operations_risk
- positions_risk
- workforce_attendance
- workforce_coverage
- candidates_quality
- talent_pool
sources:
- policy_docs
---
# Krow Workforce Agent

View File

@@ -22,6 +22,15 @@ starters:
permissions:
owner: demo@krow.app
access: all
tools:
- positions_risk
- open_positions
- available_workers
- workforce_coverage
- candidates_quality
- assign_worker
- candidates_awaiting
- move_application
---
# Positions Agent

View File

@@ -19,6 +19,10 @@ starters:
permissions:
owner: demo@krow.app
access: all
tools:
- talent_pool
- workforce_training
- available_workers
---
# Talent Pool Agent

View File

@@ -11,16 +11,19 @@
*
* React → base44Client.js → HTTP → Go API → PostgreSQL
*
* The entity surface is unchanged. `store.js` and `seed.js` are no longer the
* source of data — the seeded dataset now lives in PostgreSQL, loaded by the
* backend's `make seed`. `seed.js` is still imported for one thing: the shape
* of the demo user's default preferences, which the synchronous accessor below
* needs before the first response arrives.
* The entity surface is unchanged. The seeded dataset lives in PostgreSQL,
* loaded by the backend's `make seed`, and nothing in the running app carries a
* copy of it: `store.js` — the localStorage database this replaced — is gone,
* and `seed.js` is now a test fixture that no production module imports.
*
* The one thing still needed before the first response arrives is the *shape*
* of the user's default preferences, because the accessor that reads them is
* synchronous. That is `api/demoUser.js`: a default shape, not a record.
*/
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
import { invokeLLM, uploadFile } from './aiEngine';
import { DEMO_USER } from './seed';
import { DEMO_USER } from './demoUser';
const ENTITY_NAMES = [
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
@@ -33,6 +36,14 @@ const ENTITY_NAMES = [
/* Shifts worked, missed and overrun. The operational record behind
attendance and overtime analysis — see `api/attendanceSeed.js`. */
'ShiftRecord',
/* The agent and skill registry the runtime resolves from.
Authored definitions used to be written into the account's preferences,
which the backend stores faithfully and the runtime never reads — so an
agent created in the editor showed as "published" and answered every
request with 404. These are the endpoints that make an authored agent a
real one. */
'AgentDefinition',
'SkillDefinition',
];
const entities = Object.fromEntries(
@@ -210,7 +221,7 @@ const auth = {
* Preferences, read synchronously.
*
* A plain read of the same record `me()` returns, defaulted with the shape
* from `seed.js` so a key the server has never stored still resolves. See the
* from `demoUser.js` so a key the server has never stored still resolves. See the
* note on `currentUser` for why this must not become async.
*/
preferences() {
@@ -327,6 +338,41 @@ const workflows = {
},
};
/* ── Owliver ────────────────────────────────────────────────────────────── */
/**
* What could usefully be asked on this page.
*
* The one read in this file that is not a table. `GET /owliver/suggestions`
* answers with at most three questions, and the server decides all three: it
* filters them against the caller's role through the same policy table every
* other endpoint consults, and — when nothing has been typed — ranks them
* against the organization's actual state in PostgreSQL. A workspace with
* unfinished drafts is asked about drafts; one with unscored candidates is
* asked about screening.
*
* Nothing is ranked, scored, filtered or reordered on this side. That is the
* point: a suggestion is a claim that the reader could usefully ask something,
* and the only thing that knows whether that is true is the thing holding the
* data and the permissions. The panel requests, renders, and runs whichever one
* is chosen through the capability it names.
*
* `query` is what is in the composer. Sent only when it is non-empty: an absent
* query asks the data what to suggest, and an empty string would be a different
* request from the one the caller means.
*/
const owliver = {
/** @param {any} request */
async suggestions({ page, query = '' } = {}) {
if (!page) return [];
const typed = String(query || '').trim();
const data = await request('GET', '/owliver/suggestions', {
query: { page, ...(typed ? { query: typed } : {}) },
});
return Array.isArray(data?.suggestions) ? data.suggestions : [];
},
};
/* ── Integrations & analytics ───────────────────────────────────────────── */
const integrations = {
@@ -344,7 +390,7 @@ const analytics = {
},
};
export const base44 = { entities, auth, integrations, analytics, workflows };
export const base44 = { entities, auth, integrations, analytics, workflows, owliver };
/** Where the entity data actually comes from, for diagnostics. */
export { API_BASE_URL };

39
src/api/demoUser.js Normal file
View File

@@ -0,0 +1,39 @@
/**
* The shape of a signed-in user, before the server has answered.
*
* This is a **default shape, not a record**. The user lives in PostgreSQL and
* arrives from `GET /me`; what is here is the set of keys that must resolve
* during the first render, in the fraction of a second before that response
* lands.
*
* It exists as its own module for one reason: `base44Client.js` needs exactly
* this, and it used to reach into `api/seed.js` to get it — a 1,900-line
* fixture of demo positions, candidates, interviews, staff and shift records,
* every byte of which was then in the production bundle so that three booleans
* could be defaulted. The fixture is still the right thing for the test scripts
* that read it (`scripts/skill-check.mjs`, `scripts/owliver-capture.mjs`); it
* was never the right thing for the running app. `seed.js` re-exports this
* constant, so those scripts are unchanged and the production import chain no
* longer reaches them.
*
* Nothing here is a source of truth for anything. `preferences` is the one part
* that is read: `auth.preferences()` is synchronous — `AssistantPanelContext`
* decides whether Owliver starts open in a `useState` initialiser — so a key
* the server has never stored still has to resolve to something rather than to
* `undefined`.
*/
export const DEMO_USER = {
id: 'user_demo',
full_name: 'Alex Rivera',
email: 'demo@krow.app',
role: 'admin',
account_type: 'employer',
created_date: '2026-06-01T09:00:00.000Z',
/* Product preferences travel with the account rather than in a store of their
own, so there is one record to persist and one thing to read. */
preferences: {
owliverDefault: true,
compactDensity: false,
emailDigest: true,
},
};

View File

@@ -109,6 +109,11 @@ const RESOURCE_PATHS = {
User: 'users',
Assignment: 'assignments',
ShiftRecord: 'shift-records',
/* Authored agents and skills. These are the registry the BACKEND runs from:
an agent saved here is one Owliver can be asked to run, which is the whole
difference between this and the preferences blob these used to live in. */
AgentDefinition: 'agent-definitions',
SkillDefinition: 'skill-definitions',
};
/* ── Request ────────────────────────────────────────────────────────────── */

View File

@@ -9,6 +9,7 @@
*/
import { SHIFT_RECORDS } from './attendanceSeed';
import { DEMO_USER } from './demoUser';
const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString();
@@ -530,9 +531,14 @@ const JOB_APPLICATIONS = [
job_posting_id: 'job_security', job_title: 'Event Security Officer',
}),
unscreened({
/* The rejected outcome, and the only one reached without a score: turned
down on the licence requirement before screening ran. Rejection overwrites
whatever stage it was reached from, which is why the funnel can place him
no further than screened. */
id: 'app_kevin', applicant_name: 'Kevin Boyle', email: 'kevin.boyle@email.com',
phone: '+1-415-555-0113', years_experience: 7, english_level: 'native',
job_posting_id: 'job_security', job_title: 'Event Security Officer',
status: 'rejected', updated_date: iso('2026-08-07'),
}),
unscreened({
id: 'app_rosa', applicant_name: 'Rosa Delgado', email: 'rosa.delgado@email.com',
@@ -563,7 +569,11 @@ const JOB_APPLICATIONS = [
selfie_url: 'https://i.pravatar.cc/240?img=33',
job_posting_id: 'job_bartender_corp',
job_title: 'Experienced Bartender – Corporate Events',
status: 'hired',
/* Hired and then rostered. `assigned` is the seventh application status and
the last one nothing exercised: it counts as hired everywhere the product
asks how many people a role has, but takes the candidate out of the
running, and it is the only status backed by a row in Assignment. */
status: 'assigned',
ai_score: 92,
ai_match_label: 'Excellent Match',
ai_summary:
@@ -583,6 +593,9 @@ const JOB_APPLICATIONS = [
/* Event Server – Fine Dining: 3 applicants · 3 screened · 0 hired */
{
/* Shortlisted: the strongest screened candidate on this role, moved forward
but not yet interviewed. Without her the shortlisted stage was empty and
every funnel that counts it reported a permanent zero. */
id: 'app_sofia',
applicant_name: 'Sofia Mendez',
email: 'sofia.mendez@email.com',
@@ -599,7 +612,7 @@ const JOB_APPLICATIONS = [
selfie_url: 'https://i.pravatar.cc/240?img=47',
job_posting_id: 'job_server_fine',
job_title: 'Event Server – Fine Dining',
status: 'ai_screened',
status: 'shortlisted',
ai_score: 89,
ai_match_label: 'Excellent Match',
ai_summary:
@@ -1426,9 +1439,18 @@ const WORKER_PROFILES = [
salary_expectations: '$30–$36/hr',
leadership_potential: 92,
ai_interview_score: 93,
krow_score: 94,
reliability_score: 95,
profile_completion: 100,
krow_score: 95,
reliability_score: 96,
profile_completion: 94,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: 98, performance: 93, education: 100,
clientReviews: 98, supervisorReviews: 96,
growth: 100, experience: 80,
},
xp: 1480,
/* Seven years on the floor, as a completed ladder: Server and Leadership to
Advanced, Customer Service to Advanced, Food Safety to Beginner. */
@@ -1479,9 +1501,18 @@ const WORKER_PROFILES = [
industries: ['Hospitality'],
leadership_potential: 24,
ai_interview_score: 0,
krow_score: 12,
reliability_score: 38,
profile_completion: 62,
krow_score: 30,
reliability_score: 37,
profile_completion: 88,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: 92, performance: 0, education: 80,
clientReviews: 0, supervisorReviews: 0,
growth: 12, experience: 15,
},
xp: 120,
/* One year in: the Beginner rung of Server, and one Intermediate module
started — so her card reads "Beginner, advancing" rather than a flat
@@ -1537,9 +1568,18 @@ const WORKER_PROFILES = [
salary_expectations: '$26–$32/hr',
leadership_potential: 68,
ai_interview_score: 86,
krow_score: 82,
krow_score: 87,
reliability_score: 91,
profile_completion: 94,
profile_completion: 88,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: 96, performance: 88, education: 100,
clientReviews: 94, supervisorReviews: 92,
growth: 100, experience: 45,
},
xp: 1180,
/* Server through Intermediate, plus two of the three Advanced modules —
Fine Dining Service Standards is the one thing between him and Advanced,
@@ -1585,9 +1625,18 @@ const WORKER_PROFILES = [
industries: ['Hospitality'],
leadership_potential: 45,
ai_interview_score: 74,
krow_score: 68,
reliability_score: 84,
profile_completion: 80,
krow_score: 78,
reliability_score: 86,
profile_completion: 88,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: 93, performance: 81, education: 100,
clientReviews: 90, supervisorReviews: 88,
growth: 76, experience: 25,
},
xp: 760,
completed_courses: completions(
[...SERVER_BEGINNER, ...SERVER_INTERMEDIATE, ...CS_BEGINNER, ...CS_INTERMEDIATE, ...FOOD_BEGINNER],
@@ -1624,9 +1673,18 @@ const WORKER_PROFILES = [
industries: ['Hospitality'],
leadership_potential: 20,
ai_interview_score: 0,
krow_score: 41,
reliability_score: 72,
profile_completion: 58,
krow_score: 35,
reliability_score: 42,
profile_completion: 63,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: 100, performance: 0, education: 100,
clientReviews: 0, supervisorReviews: 0,
growth: 32, experience: 0,
},
xp: 320,
completed_courses: completions(
[...SERVER_BEGINNER, ...CS_BEGINNER, ...FOOD_BEGINNER, ...LEAD_BEGINNER],
@@ -1665,7 +1723,16 @@ const WORKER_PROFILES = [
ai_interview_score: 0,
krow_score: 0,
reliability_score: 0,
profile_completion: 30,
profile_completion: 56,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: null, performance: 0, education: 0,
clientReviews: 0, supervisorReviews: 0,
growth: 0, experience: 0,
},
xp: 0,
completed_courses: [],
earned_badges: [],
@@ -1700,7 +1767,16 @@ const WORKER_PROFILES = [
ai_interview_score: 0,
krow_score: 0,
reliability_score: 0,
profile_completion: 10,
profile_completion: 31,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: null, performance: 0, education: 0,
clientReviews: 0, supervisorReviews: 0,
growth: 0, experience: 0,
},
xp: 0,
completed_courses: [],
earned_badges: [],
@@ -1735,7 +1811,16 @@ const WORKER_PROFILES = [
ai_interview_score: 0,
krow_score: 0,
reliability_score: 0,
profile_completion: 25,
profile_completion: 56,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: null, performance: 0, education: 0,
clientReviews: 0, supervisorReviews: 0,
growth: 0, experience: 0,
},
xp: 0,
completed_courses: [],
earned_badges: [],
@@ -1770,7 +1855,16 @@ const WORKER_PROFILES = [
ai_interview_score: 0,
krow_score: 0,
reliability_score: 0,
profile_completion: 20,
profile_completion: 56,
/* Derived, not authored: exactly what recalcProfilePatch produces for
this profile. Seeding the engine's own output is what makes the
"auto-recalculated" claim true — completing one course no longer
jumps the score to a different number than the one on screen. */
score_breakdown: {
attendance: null, performance: 0, education: 0,
clientReviews: 0, supervisorReviews: 0,
growth: 0, experience: 0,
},
xp: 0,
completed_courses: [],
earned_badges: [],
@@ -1878,33 +1972,56 @@ const USER_ACTIVITY = ACTIVITY_EVENTS.map(([event_type, user_email, user_name, a
/* ── Signed-in demo user ───────────────────────────────────────────────── */
export const DEMO_USER = {
id: 'user_demo',
full_name: 'Alex Rivera',
email: 'demo@krow.app',
role: 'admin',
account_type: 'employer',
created_date: iso('2026-06-01'),
/* Product preferences travel with the account rather than in a store of their
own, so there is one record to persist and one thing to read. */
preferences: {
owliverDefault: true,
compactDensity: false,
emailDigest: true,
},
};
/**
* Re-exported, not declared.
*
* The default user shape moved to `api/demoUser.js` so that `base44Client.js`
* can have it without importing this file — and with it every position,
* candidate, interview and shift record below — into the production bundle.
* The fixtures here are for the test scripts, and they still read
* `seedData.User` and `DEMO_USER` from this module, so both keep working.
*/
export { DEMO_USER } from './demoUser';
/* ── Assignments ───────────────────────────────────────────────────────────
Who is on which position, and until when — the record that turns a hire into
workforce allocation, and the one that says who is therefore not free for
anything else.
Empty by design. This deployment has no assignment records yet: they are
written by the assignment flow, not shipped as seed. The engine reads an
empty set correctly — every position simply reports nobody assigned — and
the UI omits the demand figures rather than inventing coverage that does
not exist. */
const ASSIGNMENTS = [];
Was empty by design, on the reasoning that assignments are written by the
assignment flow rather than shipped. That held until it turned out to be the
reason nothing exercised `assigned`: the funnel dropped that status out of
every stage bucket, and the agent's own candidate lookup offered somebody
already working a shift as a person to chase. Neither was visible against a
fixture that never produced one.
So: exactly one, the minimum that makes the status real. Every reader still
has to handle the empty case, because eight of the nine positions have no
assignment and report nobody assigned. */
const ASSIGNMENTS = [
/* One assignment, for the one application at `assigned`.
Marco is the right person to carry it: he is the control in the attendance
data, with a recurring corporate bartending pattern across the whole
window, so an open-ended assignment to that position is what his shift
records already describe. `ends_at` is null — the assignment is ongoing,
which is also the case the readers have to handle.
`worker_profile_id` is absent by design: Marco is Staff, and the talent
pool and the roster are deliberately different people. */
{
id: 'assign_marco_bartender',
job_posting_id: 'job_bartender_corp',
application_id: 'app_marco',
worker_email: 'marco.rivera@email.com',
worker_name: 'Marco Rivera',
starts_at: iso('2026-07-01'),
ends_at: null,
status: 'active',
source: 'seed',
match_score: 92,
created_date: iso('2026-07-01'),
updated_date: iso('2026-07-01'),
},
];
/* 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.

View File

@@ -1,173 +0,0 @@
/**
* In-memory entity store backing the KROW demo.
*
* Mirrors the Base44 entity API the app was built against
* (`list` / `filter` / `get` / `create` / `update` / `delete`) so every hook,
* page and component consumes data exactly as it did against the real backend.
* Records live in memory and are mirrored to localStorage, so demo edits —
* screening a candidate, hiring, creating a position — survive a reload.
*/
const STORAGE_KEY = 'krow_demo_db';
const STORAGE_VERSION = 8;
/** Simulated network latency, in ms, so loading states are real. */
const LATENCY = { read: 140, write: 220 };
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Structured clone with a JSON fallback for older Safari. */
const clone = (value) =>
typeof structuredClone === 'function'
? structuredClone(value)
: JSON.parse(JSON.stringify(value));
let _counter = 0;
export function makeId(prefix = 'rec') {
_counter += 1;
return `${prefix}_${Date.now().toString(36)}${_counter.toString(36).padStart(3, '0')}`;
}
/* ── Persistence ───────────────────────────────────────────────────────── */
let db = {};
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: STORAGE_VERSION, db }));
} catch {
// Private browsing / quota — the demo still works from memory.
}
}
function readPersisted() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
// A seed-data change bumps STORAGE_VERSION, which invalidates stale copies.
return parsed?.version === STORAGE_VERSION ? parsed.db : null;
} catch {
return null;
}
}
/**
* Loads the store: persisted snapshot if one matches the current seed version,
* otherwise the seed itself.
*/
export function initStore(seed) {
db = readPersisted() || clone(seed);
// A seed that gained an entity after the snapshot was written still resolves.
for (const [name, records] of Object.entries(seed)) {
if (!db[name]) db[name] = clone(records);
}
persist();
}
/** Drops all local edits and restores the shipped demo data. */
export function resetStore(seed) {
db = clone(seed);
persist();
}
/* ── Query helpers ─────────────────────────────────────────────────────── */
/**
* Applies a Base44-style sort string: `'-ai_score'` descending,
* `'created_date'` ascending. Unknown fields leave order untouched.
*/
function applySort(records, sort) {
if (!sort) return records;
const desc = sort.startsWith('-');
const field = desc ? sort.slice(1) : sort;
return [...records].sort((a, b) => {
const av = a?.[field];
const bv = b?.[field];
if (av === bv) return 0;
if (av === undefined || av === null) return 1;
if (bv === undefined || bv === null) return -1;
const result = typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return desc ? -result : result;
});
}
/** Shallow equality match across every key of `query`, array fields included. */
function matches(record, query) {
return Object.entries(query).every(([key, want]) => {
const got = record?.[key];
if (Array.isArray(want)) return want.includes(got);
return got === want;
});
}
/* ── Entity API ────────────────────────────────────────────────────────── */
/**
* Builds the client surface for one entity. Every method is async and returns
* cloned records, so callers can never mutate the store by reference.
*/
export function createEntity(name) {
const table = () => (db[name] ||= []);
return {
entityName: name,
async list(sort = '-created_date', limit = 100) {
await delay(LATENCY.read);
return clone(applySort(table(), sort).slice(0, limit));
},
async filter(query = {}, sort = '-created_date', limit = 100) {
await delay(LATENCY.read);
const found = table().filter((record) => matches(record, query));
return clone(applySort(found, sort).slice(0, limit));
},
async get(id) {
await delay(LATENCY.read);
const found = table().find((record) => record.id === id);
if (!found) throw new Error(`${name} ${id} not found`);
return clone(found);
},
async create(data) {
await delay(LATENCY.write);
const now = new Date().toISOString();
const record = {
id: makeId(name.toLowerCase()),
created_date: now,
updated_date: now,
...data,
};
table().unshift(record);
persist();
return clone(record);
},
async update(id, data) {
await delay(LATENCY.write);
const index = table().findIndex((record) => record.id === id);
if (index === -1) throw new Error(`${name} ${id} not found`);
const next = { ...table()[index], ...data, updated_date: new Date().toISOString() };
table()[index] = next;
persist();
return clone(next);
},
async delete(id) {
await delay(LATENCY.write);
db[name] = table().filter((record) => record.id !== id);
persist();
return { id };
},
async bulkCreate(records = []) {
const created = [];
for (const record of records) created.push(await this.create(record));
return created;
},
};
}

View File

@@ -1,13 +1,14 @@
import * as React from 'react';
import {
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
SlidersHorizontal, Trash2, X,
SlidersHorizontal, Trash2, Wrench, X,
} from 'lucide-react';
import {
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
SelectValue, Switch, Textarea,
} from '@/components/ds';
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
import { getSkillsForPage } from '@/lib/skills/registry';
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
import {
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
@@ -96,6 +97,16 @@ function useActiveSection(ids) {
/** @param {any} props */
export function AgentConfigure({
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
/* Attaching and detaching a skill goes through the caller's existing write
path, NOT through `set`. Every other control here writes a draft that Save
persists; a skill persists immediately, on purpose — the whole point of the
catalog is that the agent gains the capability there and then. Two write
paths for one field would mean the Configure screen and the catalog
disagreed about when a skill takes effect. */
onToggleSkill = null, pendingSkill = null,
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
rather than fetched here so this component stays a form over `fields`. */
toolCatalogue = [],
}) {
/* 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
@@ -107,6 +118,32 @@ export function AgentConfigure({
const [active, setActive] = useActiveSection(SECTION_IDS);
/**
* The skills these pages offer, and the ones not yet attached.
*
* Derived at render from `fields.pages` rather than held in state, for the
* same reason the rail is: a second copy of a list that is already on screen
* is a second thing to keep correct, and the one that drifts is always the
* copy.
*
* Deduplicated by id because an agent covering several pages will be offered
* the same skill by each of them.
*/
const availableSkills = React.useMemo(() => {
const seen = new Map();
for (const page of fields.pages) {
for (const skill of getSkillsForPage(page, { customSources: customSkills })) {
if (!seen.has(skill.id)) seen.set(skill.id, skill);
}
}
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
}, [fields.pages, customSkills]);
const attachableSkills = React.useMemo(
() => availableSkills.filter((sk) => !fields.skills.includes(sk.id)),
[availableSkills, fields.skills]
);
const set = (patch) => onChange({ ...fields, ...patch });
const instructionWords = React.useMemo(
@@ -176,9 +213,10 @@ export function AgentConfigure({
id: 'capabilities',
icon: Boxes,
label: 'Capabilities',
meta: `${fields.pages.length + fields.knowledge.length}`,
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 },
],
},
@@ -384,6 +422,176 @@ export function AgentConfigure({
</p>
</div>
{/* Skills ── what it can actually do.
The section this screen was missing. Skills were attachable from
the catalog and invisible here, on the one page that lists
everything else an agent carries — so the reading was that this
agent had none.
Availability is bounded by the pages above, which is the rule the
copy under Pages already states: a page decides which skills
exist there, and an agent chooses among them. It can narrow that
list, never widen it. */}
<div className="pt-4">
<GroupHead
id="capabilities-skills"
icon={Boxes}
title="Skills"
count={fields.skills.length}
action={onOpenSkills && (
<Button variant="ghost" size="sm" onClick={onOpenSkills}>
Browse catalog
</Button>
)}
/>
{!fields.pages.length ? (
<p className="mt-2 text-body-sm text-ink-3">
Choose a page first. Skills belong to pages, so there are none to offer until
this agent has somewhere to answer.
</p>
) : (
<div className="mt-2 space-y-3">
{fields.skills.length > 0 && (
<ul className="divide-y divide-border">
{fields.skills.map((id) => {
const skill = availableSkills.find((sk) => sk.id === id);
return (
<ItemRow
key={id}
icon={Boxes}
title={skill?.name || id}
detail={skill?.description}
/* A skill the pages no longer offer is still
attached and still listed — silently dropping it
would edit the definition behind the author's
back. Flagged instead, so removing it is their
decision. */
warning={skill ? null : 'Not available on the pages above.'}
removeLabel={`Remove ${skill?.name || id}`}
onRemove={onToggleSkill ? () => onToggleSkill(id) : undefined}
/>
);
})}
</ul>
)}
{!fields.skills.length && (
<p className="text-body-sm text-ink-3">
No skills attached. This agent answers from the page&apos;s own reader only.
</p>
)}
<Select
value=""
disabled={!onToggleSkill || Boolean(pendingSkill)}
onValueChange={(id) => !fields.skills.includes(id) && onToggleSkill?.(id)}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder={
pendingSkill ? 'Saving...' : 'Add a skill...'
} />
</SelectTrigger>
<SelectContent>
{attachableSkills.map((sk) => (
<SelectItem key={sk.id} value={sk.id}>{sk.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-caption leading-relaxed text-ink-4">
{attachableSkills.length
? 'Attaching a skill takes effect immediately — it does not wait for Save.'
: 'Every skill these pages offer is already attached.'}
</p>
</div>
)}
</div>
{/* Tools ── what it can actually do.
Skills are guidance the model reads; tools are the calls it may
make. An agent with skills and no tools discusses the work and
looks nothing up, which is what every agent authored here was
before this section existed — the editor had no field for it and
the parser dropped `tools:` on the way in.
The catalogue is served by the backend rather than listed here,
so a tool renamed or withdrawn cannot leave a stale option in
this form and an agent built from one that no longer exists. */}
<div className="pt-4">
<GroupHead
id="capabilities-tools"
icon={Wrench}
title="Tools"
count={fields.tools.length}
/>
<div className="mt-2 space-y-3">
{fields.tools.length > 0 && (
<ul className="divide-y divide-border">
{fields.tools.map((name) => {
const tool = toolCatalogue.find((t) => t.name === name);
return (
<ItemRow
key={name}
icon={Wrench}
title={tool?.name || name}
detail={tool?.description}
/* A tool this deployment no longer registers is kept
and flagged rather than dropped: the backend now
refuses to save a definition naming one, so the
author needs to see which. */
warning={tool
? (tool.effect === 'write'
? 'Proposes changes — a person is asked to approve each one.'
: null)
: 'Not available on this deployment.'}
removeLabel={`Remove ${name}`}
onRemove={() => set({ tools: fields.tools.filter((t) => t !== name) })}
/>
);
})}
</ul>
)}
{!fields.tools.length && (
<p className="text-body-sm text-ink-3">
No tools attached. This agent can discuss its subject but cannot look
anything up.
</p>
)}
<Select
value=""
onValueChange={(name) =>
!fields.tools.includes(name) && set({ tools: [...fields.tools, name] })}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder={
toolCatalogue.length ? 'Add a tool...' : 'Loading tools...'
} />
</SelectTrigger>
<SelectContent>
{toolCatalogue
.filter((t) => !fields.tools.includes(t.name))
.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.name}{t.effect === 'write' ? ' — writes' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-caption leading-relaxed text-ink-4">
A tool marked <em>writes</em> can propose a change. Nothing is written until
somebody approves it, and the agent can only reach what you could reach
yourself.
</p>
</div>
</div>
{/* Knowledge ── what it has been told. */}
<div className="pt-4">
<GroupHead

View File

@@ -87,7 +87,8 @@ function FeedbackControls({ feedback, onFeedback }) {
export const Message = React.memo(
/** @param {any} props */
({ role, text, blocks, streaming, stopped, onPrompt, feedback = null, onFeedback = null }) => {
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
feedback = null, onFeedback = null }) => {
if (role === 'user') {
return (
<div className="flex justify-end">
@@ -105,7 +106,7 @@ export const Message = React.memo(
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
</div>
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} />
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} onConfirm={onConfirm} />
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
} from 'lucide-react';
@@ -9,28 +10,28 @@ import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import {
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
useMarkInterviewReady, useShiftRecords, useUpdateJobPosting,
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
useUpdateJobPosting,
usePreferences, useRoleCategories,
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { runAction } from '@/lib/skills/actions';
import { allSkills, skillsForContext } from '@/lib/skills/registry';
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
import { allSkills, pageKeyForContext } from '@/lib/skills/registry';
import { suggestionChips } from '@/lib/skills/serverSuggestions';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
import { groupByRecency } from './history';
import { useAssistantPanel } from './AssistantPanelContext';
import { usePageContext } from './PageContext';
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime';
import { buildIntro } from './dynamic';
import { agentScopedDisabled } from '@/lib/agents/runtime';
import { buildOwliverContext } from '@/lib/agents/context';
import { AgentBadge } from './AgentBadge';
import { useActiveAgent } from './AgentContext';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
import { rankPrompts } from './matchPrompts';
/**
* The chip row's "nothing to offer", as one shared array.
@@ -41,6 +42,51 @@ import { rankPrompts } from './matchPrompts';
*/
const EMPTY_PROMPTS = [];
/**
* The same, for a suggestion response that has not arrived.
*
* Separate from EMPTY_PROMPTS because they are different kinds of empty: one is
* "this page offers nothing", the other is "the server has not answered yet",
* and sharing an array between them would make a pending request and a
* considered refusal indistinguishable in a dependency list.
*/
const EMPTY_SUGGESTIONS = [];
/**
* How long a pause counts as having finished typing.
*
* Short enough that the chips feel like they are keeping up, long enough that a
* word typed at speed is one request rather than eight. The endpoint is cached
* per query, so a reader deleting back to something already asked pays nothing
* either way.
*/
const SUGGEST_DEBOUNCE_MS = 180;
/**
* A value, held still until it stops changing.
*
* Deliberately generic and local: it debounces the composer's contents and
* nothing else, and the alternative — debouncing inside the query hook — would
* make every other caller of that hook pay for a delay it did not ask for.
*/
function useDebounced(value, delay) {
const [settled, setSettled] = React.useState(value);
React.useEffect(() => {
/* An emptied composer settles immediately. Waiting would leave the previous
query's chips under a blank input for a fifth of a second, which reads as
the panel not having noticed. */
if (!value) {
setSettled(value);
return undefined;
}
const timer = setTimeout(() => setSettled(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return settled;
}
/**
* Owliver History — the conversations that came before.
*
@@ -294,10 +340,6 @@ export default function KrowAssistant({
() => preferences.customSkills || [],
[preferences.customSkills]
);
const skills = React.useMemo(
() => skillsForContext(context.id, disabledSkills, customSkills),
[context.id, disabledSkills, customSkills]
);
/**
* What a declared skill reads from.
@@ -307,6 +349,17 @@ export default function KrowAssistant({
* the card on the page and the answer in the panel are two readings of the
* same data rather than two queries that happen to agree.
*/
/**
* The page, as the API names it.
*
* `pageKeyForContext` is the existing translation from an assistant context
* id to a surface key — `admin.positions` → `positions` — and the surface
* keys are the same closed vocabulary the backend validates `page` against.
* So the panel asks about the page it is on without a second table mapping
* one to the other.
*/
const owliverPage = React.useMemo(() => pageKeyForContext(context.id), [context.id]);
const pageContext = usePageContext();
const { statesFor } = useWorkforcePaths();
const trainingPaths = React.useMemo(
@@ -380,6 +433,37 @@ export default function KrowAssistant({
return createJob.mutateAsync(result.data);
}, [createJob]);
/**
* What to ask next, from the server, after something has been written.
*
* The untyped form of the suggestions endpoint: nothing has been typed, so
* the API ranks against the organization's actual state — the rows the
* mutation just changed. Asking it here rather than deriving an answer in the
* panel is the whole point. The panel knows a position now exists; only the
* server knows whether that makes drafts, starved roles or an unscreened
* queue the thing worth raising, and only it knows what this caller's role
* permits.
*
* `fetchQuery` with `staleTime: 0` rather than a bare request: it resolves
* with the fresh answer *and* leaves it in the cache under the key the hook
* reads, so the chips this reply carries and the chips the composer would
* offer are one answer rather than two requests apart. Zero rather than the
* hook's thirty seconds because a cached answer is exactly what must not be
* used here — it was computed before the row existed.
*/
const queryClient = useQueryClient();
const refreshSuggestions = React.useCallback(async () => {
if (!owliverPage) return [];
const fresh = await queryClient.fetchQuery({
/* The empty query string is the untyped request — the same key
`useOwliverSuggestions` uses when the composer is empty. */
queryKey: ['owliverSuggestions', owliverPage, ''],
queryFn: () => fetchOwliverSuggestions({ page: owliverPage }),
staleTime: 0,
});
return suggestionChips(fresh || [], context.id);
}, [queryClient, owliverPage, context.id]);
/**
* Finishing a draft, through the mutations the Create Position form calls.
*
@@ -463,7 +547,7 @@ export default function KrowAssistant({
const {
messages, pending, error, busy, send, announce, stop, reset,
history, conversationId, openConversation, forgetConversation,
submitFeedback, feedback,
submitFeedback, feedback, confirm,
} = useConversation({
contextId: context.id,
facts,
@@ -471,6 +555,7 @@ export default function KrowAssistant({
onNavigate: goToPage,
onAction: performAction,
onCreatePosition: createPosition,
onRefreshSuggestions: refreshSuggestions,
onUpdatePosition,
onGenerateDescription,
onAssignWorkers,
@@ -532,103 +617,26 @@ export default function KrowAssistant({
() => buildIntro(context.id, facts, userName),
[context.id, facts, userName]
);
/**
* Everything this page can be asked, in the chips that already know how to
* ask it.
*
* Unchanged in what it collects and in what order: the agent's starters, the
* declared suggestions of every attached skill, the skills that carry a
* prompt, and the page's own derived prompts, de-duplicated by intent. Each
* chip keeps the metadata that makes it executable — a page `capability`, a
* `skillId`/`skillCapability` pair, a `positionId`, a `route` — because that
* is what `runPrompt` dispatches on.
*
* What changed is only that this is no longer what the panel renders. It is
* the set that `prompts` below chooses from, so an opening panel can offer
* nothing while a typed query can still reach any of it. Building it eagerly
* costs nothing — it is derived from data already in hand and memoized on it
* — and building it lazily would mean the first keystroke paid for the whole
* catalogue.
*/
const catalogue = React.useMemo(() => {
/* A definition's own suggestions come first: they are the only ones written
for this workspace rather than derived from the page, and they are capped
by the resolver so a workspace with several skills attached cannot bury
the page's own. */
const declared = owliverSuggestions(context.id, disabledSkills, customSkills, pageContext);
const skillPrompts = skills
.filter((s) => s.prompt)
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
/* Three sources can propose the same question — a skill's declared
suggestion and the page's own derived one often word it identically —
and two chips reading "Summarize hiring activity" is both a duplicate
React key and a duplicate offer. First wins, so the definition's own
wording survives and the derived copy drops out. */
/* A declared suggestion that cannot answer yet — it reads one record and
none is selected — ranks behind the page's own offers rather than
leading with a question. The lifecycle reads correctly either way:
before a position exists the page's actions lead; once one is open or
has just been created, the readings about it come first. */
const ready = declared.filter((c) => !c.deferred);
const asking = declared.filter((c) => c.deferred);
/**
* One chip per *intent*, not per wording.
*
* De-duplicating on the label alone let two chips through whenever the same
* answer was worded twice — a skill's "Show hiring activity" and its
* "Summarize hiring activity" both resolved to that skill's `summary`
* capability, so the reader was offered the same reading under two names and
* had no way to tell them apart. What a chip *resolves to* is the thing that
* must be unique: a skill capability, the page capability, or, for a chip
* that is neither, the question it sends. The label is compared too, so two
* differently-routed chips still cannot arrive reading identically.
*/
const seen = new Set();
const intentOf = (chip) => {
if (chip?.skillId && chip?.skillCapability) return `skill:${chip.skillId}:${chip.skillCapability}`;
if (chip?.capability) return `page:${chip.capability}`;
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
};
/* 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;
const intent = intentOf(chip);
if (seen.has(intent) || seen.has(`label:${label}`)) return false;
seen.add(intent);
seen.add(`label:${label}`);
return true;
});
/* `pageContext` decides which suggestions can answer without asking, so the
chips re-rank when a position is opened or closed. */
}, [skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]);
/**
* What the chip row actually shows, which is one of three separate things.
*
* They are separate states, not one merged list, because they answer to
* different owners. Follow-ups belong to the answer that raised them; typed
* matches belong to the composer; the catalogue belongs to the page. Only one
* of them can be true at a time, and the order below is that precedence.
* different owners. Follow-ups belong to the answer that raised them; the
* suggestions belong to the server. Only one of them can be true at a time,
* and the order below is that precedence.
*
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
* the answers to it — the role list after "create a position". They are
* never capped and never filtered, and they stand until the next turn or
* until the reader starts typing something else.
*
* 2. Typed matches. From the second meaningful character, the catalogue
* above is ranked against the query and the best three are offered.
* These are the catalogue's own chip objects, so clicking one runs the
* same capability or skill it would have run when the panel offered the
* whole list outright.
* 2. The server's suggestions. From the moment there is something in the
* composer, `GET /api/v1/owliver/suggestions` is asked what this page
* can usefully answer for this query, and its reply is rendered in the
* order it arrived. The panel does not rank, score, filter or reorder
* it: which readings exist depends on the caller's role and on what is
* actually in the database, and neither of those is knowable here.
*
* 3. Nothing. An empty composer offers no chips at all. The panel used to
* open on a dozen of them, which taught the range of what could be asked
@@ -639,10 +647,32 @@ export default function KrowAssistant({
*/
const followUp = messages[messages.length - 1]?.followUp;
const typed = input.trim();
/**
* The request behind (2), debounced.
*
* The endpoint is cheap and cached per query, but a keystroke is not a
* decision — a reader typing "positions" would otherwise fire nine requests
* to see the answer to the ninth. A short delay means one request per pause,
* and `placeholderData` in the hook keeps the previous answer on screen
* meanwhile so the row does not empty and refill.
*
* Only asked while there is something in the composer. An empty one offers no
* chips, so there would be nothing to render the answer into — and a reader
* who starts typing has left the follow-up behind, which is why typing
* supersedes it rather than being ranked against it.
*/
const debouncedQuery = useDebounced(typed, SUGGEST_DEBOUNCE_MS);
const { data: suggested = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
page: owliverPage,
query: debouncedQuery,
enabled: Boolean(debouncedQuery),
});
const prompts = React.useMemo(() => {
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
return rankPrompts(catalogue, typed);
}, [typed, followUp, catalogue]);
return suggestionChips(suggested, context.id);
}, [typed, followUp, suggested, context.id]);
/* The newest assistant turn, which is the one that carries the rating. */
const lastAnswerIndex = React.useMemo(
@@ -936,6 +966,10 @@ export default function KrowAssistant({
blocks={message.blocks}
stopped={message.stopped}
onPrompt={askOwliver}
/* Approving a proposed write. Offered on every assistant turn
that carries one, not just the newest: a proposal scrolled
past is still a decision somebody has to make. */
onConfirm={confirm}
/* 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}

View File

@@ -491,8 +491,107 @@ const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
});
SkillSectionBlock.displayName = 'SkillSectionBlock';
/**
* A write the agent has proposed. The only block a person can act on.
*
* Three things it must do, and they are all about not being clicked past:
*
* - **Say what will happen, in the server's words.** The title, summary and
* details are rendered as they arrived. A browser paraphrasing them would
* be describing a different act than the one the token authorises.
* - **Put warnings above the button.** A clash or an over-headcount is
* exactly what somebody is about to approve without noticing, and a warning
* underneath the decision is a warning read afterwards.
* - **Not pretend to be finished.** Once approved, the block stays visible
* and says so. Replacing it with a tick would lose what was agreed to.
*/
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
const [state, setState] = React.useState('pending');
const approve = React.useCallback(() => {
/* Guarded rather than debounced. The token is single-use server-side, so a
double click costs a confusing refusal rather than a duplicate write —
but a button that visibly does nothing the second time is kinder than
one that reports an error somebody caused by being quick. */
if (state !== 'pending') return;
setState('approved');
onConfirm?.(block);
}, [state, block, onConfirm]);
return (
<div className="rounded-xl border border-krow-blue/30 bg-krow-blue/5 p-3.5">
<div className="flex items-start gap-2.5">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-white">
<TriangleAlert className="h-3 w-3" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<p className="text-body-sm font-semibold text-ink-1">{block.title}</p>
{block.summary && (
<p className="mt-1 text-caption leading-relaxed text-ink-2">{block.summary}</p>
)}
{block.details?.length > 0 && (
<dl className="mt-2.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{block.details.map((d, i) => (
<React.Fragment key={i}>
<dt className="text-caption text-ink-3">{d.label}</dt>
<dd className="text-caption font-medium text-ink-1">{d.value}</dd>
</React.Fragment>
))}
</dl>
)}
{/* Above the button, deliberately. */}
{block.warnings?.length > 0 && (
<ul className="mt-2.5 space-y-1">
{block.warnings.map((w, i) => (
<li key={i} className="flex gap-1.5 text-caption text-amber-700 dark:text-amber-400">
<TriangleAlert className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
<span>{w}</span>
</li>
))}
</ul>
)}
<div className="mt-3 flex items-center gap-2">
{state === 'pending' ? (
<>
<button
type="button"
onClick={approve}
className="rounded-lg bg-krow-blue px-3 py-1.5 text-caption font-semibold text-white
transition hover:opacity-90 focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
>
Approve
</button>
<button
type="button"
onClick={() => setState('dismissed')}
className="rounded-lg px-3 py-1.5 text-caption font-medium text-ink-3
transition hover:text-ink-1 focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
>
Not now
</button>
</>
) : (
<p className="flex items-center gap-1.5 text-caption font-medium text-ink-3">
<Check className="h-3.5 w-3.5" aria-hidden="true" />
{state === 'approved' ? 'Approved — carrying on.' : 'Left for now. Nothing was changed.'}
</p>
)}
</div>
</div>
</div>
</div>
);
});
ConfirmationBlock.displayName = 'ConfirmationBlock';
const RENDERERS = {
text: TextBlock,
confirmation: ConfirmationBlock,
skillSection: SkillSectionBlock,
heading: HeadingBlock,
kpis: KpisBlock,
@@ -515,7 +614,7 @@ const RENDERERS = {
* blocks has consistent rhythm — a heading hugs what follows it, everything
* else breathes.
*/
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt }) => (
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt, onConfirm }) => (
<div className="space-y-3">
{blocks.map((block, i) => {
const Renderer = RENDERERS[block.type];
@@ -525,7 +624,7 @@ export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks =
return (
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
<Renderer block={block} onPrompt={onPrompt} />
<Renderer block={block} onPrompt={onPrompt} onConfirm={onConfirm} />
{/* The cursor trails the final block only while text is still arriving. */}
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
<span

View File

@@ -90,6 +90,25 @@ export const skillSection = (section, data) =>
/** A caveat. Always the last word on a claim, never the headline. */
export const note = (value) => value && { type: 'note', text: value };
/**
* A write the agent has proposed and NOT performed.
*
* The only block in this file that carries a decision rather than information,
* and the only one whose renderer has a button. Everything else here describes
* something that already happened; this describes something that will happen if
* a person says so.
*
* The payload comes from the server verbatim — title, summary, details,
* warnings, token — and is rendered rather than reformatted. The wording was
* composed beside the code that will do the writing, so a browser paraphrasing
* it would be describing a different act than the one the token authorises.
*
* `token` is what the approval sends back. It authorises exactly one call, with
* exactly those arguments, and it expires.
*/
export const confirmation = (payload) =>
payload?.token && { type: 'confirmation', ...payload };
/** A single sentence answer — used by short free-text replies. */
export const answer = (value) => doc(text(value));

File diff suppressed because it is too large Load Diff

View File

@@ -1,592 +0,0 @@
import {
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
HeartPulse, Layers, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target,
} from 'lucide-react';
import {
actions, answer, badges, doc, funnel, heading, insights, kpis, list, meters, note,
status, table, text,
} from '../blocks';
import { plural, verb } from '../insights';
/**
* Employer capabilities — Overview, Candidates, Analytics.
*
* Each returns a block document rather than prose. The shape is chosen by what
* the information is: a comparison is a table, a health check is a status list,
* a pipeline is a funnel. Prose is reserved for the judgement a table cannot
* carry.
*/
/* ── Overview ───────────────────────────────────────────────────────────── */
const hiringSummary = (f) => {
if (!f.total) return answer('Nothing in the pipeline yet. Publish a position and I will start tracking it.');
return doc(
kpis([
{ label: 'Applicants', value: f.total, sub: `${f.openPositions.length} open roles` },
{ label: 'Screened', value: f.screened.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'Interviewing', value: f.interviewing.length },
{ label: 'Hired', value: f.hired.length, sub: f.hiredAvgScore ? `avg ${f.hiredAvgScore}/100` : undefined, tone: 'info' },
]),
heading('Pipeline'),
funnel(f.funnel.map((stage, i) => ({
label: stage.label,
count: stage.count,
rate: i === 0 ? undefined : f.transitions[i - 1]?.rate,
}))),
text(
f.stalled.length
? `The first thing I would fix: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still at AI Screened.`
: f.unscreened.length
? `The first thing I would fix: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
)
);
};
const hiringHealth = (f) => {
const checks = [
{
label: 'Screening coverage',
value: `${f.standardizedPct}%`,
ok: f.standardizedPct >= 80,
note: f.standardizedPct >= 80
? 'Applicants are scored consistently.'
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
},
{
label: 'Response speed',
value: f.timeToHire ? `${f.timeToHire}d` : '—',
ok: f.timeToHire > 0 && f.timeToHire <= 3,
note: f.timeToHire && f.timeToHire <= 3
? 'Inside the window where good candidates are still available.'
: 'Event staff accept other work within days — past 3 days you lose people.',
},
{
label: 'Quality of hire',
value: f.hiredAvgScore ? `${f.hiredAvgScore}/100` : '—',
ok: f.hiredAvgScore >= 80,
note: f.hiredAvgScore >= 80
? 'You are hiring from the top of your own pool.'
: 'Hires are scoring mid-pack — widen the funnel before lowering the bar.',
},
{
label: 'Pipeline supply',
value: `${f.openPositions.length} open`,
ok: f.starvedPositions.length === 0,
note: f.starvedPositions.length
? `${f.starvedPositions.map((p) => p.title).join(', ')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants.`
: 'Every open position has applicants.',
},
];
const passing = checks.filter((c) => c.ok).length;
return doc(
heading(`${passing} of ${checks.length} signals healthy`),
status(checks),
note('Thresholds are event-staffing norms, not universal benchmarks.')
);
};
const buildActions = (f) => [
f.stalled.length && {
title: 'Move your 80+ candidates',
body: `${f.stalled.slice(0, 3).map((a) => `**${a.applicant_name}** (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`,
},
f.unscreened.length && {
title: `Screen ${plural(f.unscreened.length, 'applicant')}`,
body: 'One pass clears the backlog and costs nothing.',
},
f.starvedPositions.length && {
title: 'Fix the postings nobody applies to',
body: `${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`,
},
f.unratedStaff.length && {
title: `Rate ${plural(f.unratedStaff.length, 'hire')}`,
body: 'Ratings feed KROW scores and sharpen future matching.',
},
f.elite.length && {
title: 'Reach out to the pool directly',
body: `${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`,
},
].filter(Boolean);
const pendingActions = (f) => {
const items = buildActions(f);
if (!items.length) {
return answer('Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.');
}
return doc(heading('Pending actions', 'Ordered by what costs you most'), actions(items.slice(0, 4)));
};
const hiringRisks = (f) => {
const risks = [
f.singleCandidateRoles.length && {
tone: 'risk',
title: 'Single-candidate roles',
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`,
},
f.stalled.length && {
tone: 'warning',
title: 'Losing strong candidates to delay',
body: `${plural(f.stalled.length, 'candidate')} at 80+ screened but not moved. This is where good hires quietly disappear.`,
},
f.missingCredentials.length && {
tone: 'risk',
title: 'Compliance exposure',
body: `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`,
},
f.narrowAvailability.length && {
tone: 'warning',
title: 'Availability risk',
body: `${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none — most likely to fall through on the day.`,
},
f.starvedPositions.length && {
tone: 'warning',
title: 'Unfillable as posted',
body: `${plural(f.starvedPositions.length, 'open role')} with zero applicants after going live.`,
},
f.flagged.length && {
tone: 'risk',
title: 'Interview integrity',
body: `${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`,
},
].filter(Boolean);
if (!risks.length) {
return answer('No material risks: coverage is good, no role depends on a single candidate, and credentials check out.');
}
return doc(
heading(`${plural(risks.length, 'risk')} to watch`),
insights(risks),
note('Flags to check, not conclusions. Each is a question for a human.')
);
};
/* ── Candidates ─────────────────────────────────────────────────────────── */
const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
const resumeAnalysis = (f) => {
const c = f.top[0];
if (!c) return answer('No scored candidates yet. Screen the pipeline and I will analyse the strongest.');
return doc(
heading(c.applicant_name, `${c.job_title} · ${plural(c.years_experience, 'year')} · ${c.english_level} English`),
kpis([
{ label: 'AI score', value: `${c.ai_score}`, tone: c.ai_score >= 80 ? 'success' : c.ai_score >= 60 ? 'info' : 'warning' },
{ label: 'Verdict', value: c.ai_match_label || '—' },
]),
c.ai_summary && text(c.ai_summary),
c.certifications?.length
? [heading('Credentials'), badges(c.certifications.map((label) => ({ label, tone: 'success' })))]
: [heading('Credentials'), badges([{ label: 'None on file', tone: 'risk' }])],
heading('Score breakdown'),
meters(DIMENSIONS.map((d) => ({ label: d[0].toUpperCase() + d.slice(1), value: c.score_breakdown?.[d] ?? 0 }))),
c.ai_strengths?.length && [heading('Strengths'), list(c.ai_strengths)],
c.ai_gaps?.length && [heading('Probe these'), list(c.ai_gaps)],
note(
c.certifications?.length
? 'Verify credentials before the first shift — a lapsed card is the most common surprise.'
: 'Request credentials before scheduling.'
)
);
};
const candidateComparison = (f) => {
const [a, b] = f.ranked;
if (!a || !b) return answer('I need at least two scored candidates to compare.');
const gap = a.ai_score - b.ai_score;
const first = (n) => n.split(' ')[0];
const rows = DIMENSIONS.map((d) => {
const av = a.score_breakdown?.[d] ?? 0;
const bv = b.score_breakdown?.[d] ?? 0;
return {
dimension: d[0].toUpperCase() + d.slice(1),
a: { value: av, tone: av > bv ? 'success' : av < bv ? 'neutral' : undefined },
b: { value: bv, tone: bv > av ? 'success' : bv < av ? 'neutral' : undefined },
};
});
const biggest = DIMENSIONS.reduce((best, d) =>
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) >
Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, DIMENSIONS[0]);
return doc(
heading(`${a.applicant_name} vs ${b.applicant_name}`),
table(
[
{ key: 'dimension', label: '' },
{ key: 'a', label: first(a.applicant_name), align: 'right' },
{ key: 'b', label: first(b.applicant_name), align: 'right' },
],
[
{ dimension: 'Overall', a: { value: a.ai_score, tone: 'info' }, b: { value: b.ai_score, tone: 'info' } },
...rows,
],
{ caption: 'Score comparison by dimension' }
),
heading('Where each one wins'),
insights([
{
tone: 'success',
title: `${first(a.applicant_name)} leads on ${biggest}`,
body: a.ai_strengths?.[0] || `Scores ${a.score_breakdown?.[biggest] ?? 0} against ${b.score_breakdown?.[biggest] ?? 0}.`,
},
b.ai_strengths?.[0] && {
tone: 'info',
title: `${first(b.applicant_name)} brings`,
body: b.ai_strengths[0],
},
]),
text(
gap <= 3
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
: `${a.applicant_name} leads by ${gap} points, driven mostly by ${biggest}.`
),
note('Recommendation only. Neither has been met in person.')
);
};
const interviewQuestions = (f) => {
const c = f.stalled[0] || f.ranked[0];
if (!c) return answer('Screen a candidate and I will build questions around their specific gaps.');
const gaps = c.ai_gaps || [];
return doc(
heading(`Questions for ${c.applicant_name}`, 'Built from their gaps, not a generic list'),
list([
`Walk me through your busiest ${c.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
gaps[0]
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
: 'Tell me about a shift that went wrong. What was your part in it?',
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
c.certifications?.length
? `Your ${c.certifications[0]} is on file — when did you last apply it on a live shift?`
: 'Which certifications are you working toward, and why that one?',
'What does your real availability look like over the next eight weeks?',
], { ordered: true }),
note('The scenario question is the one that separates candidates. Let them think.')
);
};
const skillGapAnalysis = (f) => {
if (!f.scored.length) return answer('No scored candidates yet, so there are no gaps to analyse.');
const tally = {};
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
const key = gap.replace(/^Missing certifications?:\s*/i, 'Missing credential: ');
tally[key] = (tally[key] || 0) + 1;
}));
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
if (!ranked.length) return answer('No recurring gaps — the pool matches your postings well.');
return doc(
heading('Recurring gaps', `Across ${plural(f.scored.length, 'scored candidate')}`),
table(
[{ key: 'gap', label: 'Gap' }, { key: 'count', label: 'Candidates', align: 'right' }],
ranked.map(([gap, count]) => ({
gap,
count: { value: `${count}/${f.scored.length}`, tone: count > f.scored.length / 2 ? 'risk' : undefined },
}))
),
text(
f.missingCredentials.length >= 2
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
)
);
};
const hiringRecommendation = (f) => {
if (!f.scored.length) return answer('Nothing scored yet, so I have no basis for a recommendation.');
const open = (a) => !['hired', 'rejected'].includes(a.status);
const groups = [
{ verdict: 'Shortlist', tone: 'success', people: f.ranked.filter((a) => a.ai_score >= 80 && open(a)) },
{ verdict: 'Interview', tone: 'info', people: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a)) },
{ verdict: 'Likely pass', tone: 'risk', people: f.weak.filter(open) },
].filter((g) => g.people.length);
if (!groups.length) return answer('Every scored candidate has already been actioned.');
return doc(
heading('Recommendation'),
table(
[
{ key: 'name', label: 'Candidate' },
{ key: 'score', label: 'Score', align: 'right' },
{ key: 'verdict', label: 'Verdict', align: 'right' },
],
groups.flatMap((g) => g.people.map((p) => ({
name: p.applicant_name,
score: { value: p.ai_score, tone: g.tone },
verdict: { badge: g.verdict, tone: g.tone },
})))
),
note('A recommendation, not a decision. I score what is on file — I have not met these people.')
);
};
/* ── Analytics ──────────────────────────────────────────────────────────── */
const explainCharts = (f) => doc(
heading('What these charts are saying'),
insights([
{
tone: 'info',
title: 'Pipeline Stages',
body: `${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
},
{
tone: 'info',
title: 'Candidate Scores',
body: `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length} — that bucket is your real hiring pool.`,
},
{
tone: f.interviewCompletion >= 70 ? 'success' : 'warning',
title: 'Interview Completion',
body: `${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
},
{
tone: 'success',
title: 'Cost Saved',
body: `$${f.costSaved.toLocaleString()} from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80 — industry per-unit costs for doing it manually, so treat it as a floor.`,
},
]),
f.bottleneck && [
heading('Biggest drop-off'),
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
text(`Most people are lost between **${f.bottleneck.from}** and **${f.bottleneck.to}** — only ${f.bottleneck.rate}% pass through, costing ${plural(f.bottleneck.lost, 'candidate')}.`),
]
);
const forecastHiring = (f) => {
const available = f.ranked.filter((a) => a.ai_score >= 60 && !['hired', 'rejected'].includes(a.status));
const conversion = f.hireRate || 14;
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
const short = f.openPositions.length - expected;
return doc(
heading('Forecast'),
kpis([
{ label: 'In play at 60+', value: available.length },
{ label: 'Projected hires', value: expected, tone: short > 0 ? 'warning' : 'success' },
{ label: 'Open roles', value: f.openPositions.length },
{ label: 'Conversion', value: `${conversion}%` },
]),
short > 0
? insights([{
tone: 'warning',
title: `Roughly ${plural(short, 'role')} short`,
body: `Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`,
}])
: insights([{
tone: 'success',
title: 'Current pool covers your open roles',
body: 'Assuming the strong candidates do not go elsewhere first.',
}]),
note('Projected from your own conversion rate, not a promise — small pools move a lot.')
);
};
const departmentInsights = (f) => {
if (!f.byRole.length) return answer('No open positions to break down.');
const byCategory = {};
f.byRole.forEach((r) => {
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: 0 };
byCategory[r.category].applied += r.applied;
byCategory[r.category].hired += r.hired;
byCategory[r.category].qualified += r.qualified;
byCategory[r.category].roles += 1;
});
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
const starved = entries.filter(([, d]) => d.applied === 0);
return doc(
heading('By role category'),
table(
[
{ key: 'category', label: 'Category' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'qualified', label: '70+', align: 'right' },
{ key: 'hired', label: 'Hired', align: 'right' },
],
entries.map(([category, d]) => ({
category,
applied: { value: d.applied, tone: d.applied === 0 ? 'risk' : undefined },
qualified: d.qualified,
hired: d.hired,
}))
),
text(
starved.length
? `**${starved.map(([c]) => c).join(' and ')}** ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
: 'Every category has applicants, so your reach is working across the board.'
)
);
};
const generateReports = (f) => doc(
// Local date, not toISOString — UTC would show yesterday for anyone behind it.
heading('Hiring report', f.today.toLocaleDateString(undefined, {
year: 'numeric', month: 'long', day: 'numeric',
})),
kpis([
{ label: 'Applicants', value: f.total },
{ label: 'Screened', value: `${f.standardizedPct}%` },
{ label: 'Hires', value: f.hired.length },
{ label: 'Hire rate', value: `${f.hireRate}%` },
{ label: 'Avg hire score', value: f.hiredAvgScore || '—', tone: 'success' },
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
]),
heading('Funnel'),
funnel(f.funnel.map((s, i) => ({ label: s.label, count: s.count, rate: i === 0 ? undefined : f.transitions[i - 1]?.rate }))),
heading('By role'),
table(
[
{ key: 'title', label: 'Role' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'qualified', label: '70+', align: 'right' },
{ key: 'hired', label: 'Hired', align: 'right' },
],
f.byRole.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified, hired: r.hired }))
),
heading('Efficiency'),
kpis([
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
{ label: 'Recruiter hours', value: `${f.hoursSaved}h`, tone: 'success' },
]),
heading('Headline finding'),
insights([
f.bottleneck
? {
tone: 'warning',
title: `${f.bottleneck.from} → ${f.bottleneck.to} passes only ${f.bottleneck.rate}%`,
body: `Losing ${plural(f.bottleneck.lost, 'candidate')} — the single biggest recoverable loss in the funnel.`,
}
: { tone: 'success', title: 'No single bottleneck', body: 'The funnel is passing through evenly.' },
]),
buildActions(f).length && [heading('Recommended actions'), actions(buildActions(f).slice(0, 3))],
note('Copy this into your own template — PDF and CSV export are not wired up in this build.')
);
/* ── Free-text responders ───────────────────────────────────────────────── */
const has = (q, ...words) => words.some((w) => q.includes(w));
const stateOfPlay = (f) => doc(
text('I do not have a specific read on that. Here is where things stand:'),
kpis([
{ label: 'Applicants', value: f.total },
{ label: 'Screened', value: f.screened.length },
{ label: 'Interviewing', value: f.interviewing.length },
{ label: 'Hired', value: f.hired.length },
]),
text('Ask me about any of those and I will go deeper.')
);
const bottleneckAnswer = (f) => {
if (!f.bottleneck) return answer('Not enough pipeline movement yet to locate a bottleneck.');
const b = f.bottleneck;
const diagnosis = {
'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap to close.`,
Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.',
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
}[b.to];
return doc(
heading(`Bottleneck: ${b.from} → ${b.to}`, `${b.rate}% pass through`),
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
insights([{ tone: 'warning', title: `Losing ${plural(b.lost, 'candidate')} here`, body: diagnosis }])
);
};
export const respondOverview = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next', 'slow')) return pendingActions(f);
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f);
if (has(q, 'summar', 'today', 'brief', 'catch me up', 'overview', 'morning', 'afternoon', 'evening')) return hiringSummary(f);
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
return stateOfPlay(f);
};
export const respondCandidates = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'compare', 'versus', ' vs ', 'between', '&')) return candidateComparison(f);
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze', 'review')) return resumeAnalysis(f);
if (has(q, 'unscreened', 'not screened', 'pending')) {
return f.unscreened.length
? doc(
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
table(
[{ key: 'name', label: 'Candidate' }, { key: 'role', label: 'Applied for' }],
f.unscreened.slice(0, 8).map((a) => ({ name: a.applicant_name, role: a.job_title }))
),
f.unscreened.length > 8 && text(`…and ${f.unscreened.length - 8} more.`)
)
: answer('Everyone has been screened.');
}
return stateOfPlay(f);
};
export const respondAnalytics = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'forecast', 'predict', 'projection', 'will i', 'expect')) return forecastHiring(f);
if (has(q, 'report', 'export', 'executive', 'download')) return generateReports(f);
if (has(q, 'bottleneck', 'drop', 'stuck', 'lose', 'why do')) return bottleneckAnswer(f);
if (has(q, 'department', 'category', 'role', 'team', 'break down')) return departmentInsights(f);
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f);
if (has(q, 'hire rate', 'conversion')) {
return answer(`${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`);
}
if (has(q, 'cost', 'saved', 'roi', 'money')) {
return doc(
kpis([
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
{ label: 'Hours returned', value: `${f.hoursSaved}h`, tone: 'success' },
]),
text(`From ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each.`)
);
}
return stateOfPlay(f);
};
/* ── Capability manifests ───────────────────────────────────────────────── */
export const OVERVIEW_CAPABILITIES = [
{ id: 'hiring-summary', label: 'Hiring Summary', icon: Sparkles, run: hiringSummary },
{ id: 'hiring-health', label: 'Hiring Health', icon: HeartPulse, run: hiringHealth },
{ id: 'pending-actions', label: 'Pending Actions', icon: ListChecks, run: pendingActions },
{ id: 'hiring-risks', label: 'Hiring Risks', icon: AlertTriangle, run: hiringRisks },
];
export const CANDIDATE_CAPABILITIES = [
{ id: 'resume-analysis', label: 'Resume Analysis', icon: FileText, run: resumeAnalysis },
{ id: 'candidate-comparison', label: 'Candidate Comparison', icon: GitCompare, run: candidateComparison },
{ id: 'interview-questions', label: 'Interview Question Generator', icon: MessageSquareQuote, run: interviewQuestions },
{ id: 'skill-gap', label: 'Skill Gap Analysis', icon: Target, run: skillGapAnalysis },
{ id: 'hiring-recommendation', label: 'Hiring Recommendation', icon: ClipboardCheck, run: hiringRecommendation },
];
export const ANALYTICS_CAPABILITIES = [
{ id: 'explain-charts', label: 'Explain Charts', icon: BarChart3, run: explainCharts },
{ id: 'forecast-hiring', label: 'Forecast Hiring', icon: LineChart, run: forecastHiring },
{ id: 'department-insights', label: 'Department Insights', icon: Layers, run: departmentInsights },
{ id: 'generate-reports', label: 'Generate Reports', icon: FileSpreadsheet, run: generateReports },
];

View File

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

View File

@@ -1,29 +1,68 @@
import {
ANALYTICS_CAPABILITIES, CANDIDATE_CAPABILITIES, OVERVIEW_CAPABILITIES,
respondAnalytics, respondCandidates, respondOverview,
} from './capabilities/employer';
import {
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, CREATE_POSITION_CAPABILITIES,
FORGE_CAPABILITIES,
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, PROFILE_CAPABILITIES,
TALENT_POOL_CAPABILITIES,
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
respondControlCenter, respondCreatePosition, respondForge, respondHiredHistory,
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';
/**
* The words each page's answers are actually about.
*
* Inlined here when the capability modules were deleted. They used to sit
* beside the functions that computed answers from browser data; those functions
* are gone — the agent answers now — but this vocabulary survives them, because
* it decides something different: whether a question belongs to THIS page or to
* another one. A question matching none of a page's topics is a question the
* reader should be taken elsewhere to ask, and that is still true with an agent
* behind the panel.
*/
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',
];
const 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',
];
const WORKSPACE_TOPICS = [
'workspace', 'agent', 'agents', 'skill', 'skills', 'capability', 'capabilities',
'training', 'path', 'paths', 'library', 'registry', 'owliver', 'extend', 'configure',
'configuration', 'govern', 'development',
];
const WORKSPACE_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',
];
const WORKSPACE_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',
];
const 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',
];
const SKILL_DEVELOPMENT_TOPICS = [
'training', 'path', 'paths', 'progression', 'level', 'levels', 'rung', 'ladder',
'development', 'capability', 'capabilities', 'define', 'definition',
'workspace', 'configure', 'configuration', 'owliver',
];
/**
* Page contexts for the Owliver dashboard panel.
@@ -45,34 +84,24 @@ export const ASSISTANT_CONTEXTS = {
'employer.overview': {
id: 'employer.overview',
page: 'Overview',
capabilities: OVERVIEW_CAPABILITIES,
respond: respondOverview,
},
'employer.candidates': {
id: 'employer.candidates',
page: 'Candidates',
capabilities: CANDIDATE_CAPABILITIES,
respond: respondCandidates,
},
'employer.analytics': {
id: 'employer.analytics',
page: 'Analytics',
capabilities: ANALYTICS_CAPABILITIES,
respond: respondAnalytics,
},
'admin.controlCenter': {
id: 'admin.controlCenter',
page: 'Control Center',
topics: ['health', 'platform', 'attention', 'urgent', 'bottleneck', 'funnel', 'pipeline', 'conversion', 'recommend', 'should', 'summary', 'summarize', 'workforce', 'operation', 'velocity', 'speed', 'hiring', 'how many', 'unusual', 'anomal', 'risk', 'overview', 'status'],
capabilities: CONTROL_CENTER_CAPABILITIES,
respond: respondControlCenter,
},
'admin.positions': {
id: 'admin.positions',
page: 'Positions',
topics: ['position', 'role', 'posting', 'vacancy', 'fill', 'attention', 'priorit', 'bottleneck', 'funnel', 'waiting', 'review', 'screen', 'applicant', 'pipeline', 'strength', 'activity', 'hiring', 'how many', 'which'],
capabilities: POSITIONS_CAPABILITIES,
respond: respondPositions,
},
/* Specifying a role is a different question from managing the ones that
exist, so Create Position is its own context rather than Positions with a
@@ -84,8 +113,6 @@ export const ASSISTANT_CONTEXTS = {
'requirement', 'skill', 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience',
'typical', 'position', 'role', 'job description', 'form', 'field', 'step', 'how do i',
'what do i', 'explain', 'summar', 'flow'],
capabilities: CREATE_POSITION_CAPABILITIES,
respond: respondCreatePosition,
},
/* The Candidates list and Candidates Analysis are separate contexts because they
ask different questions of the same records: the list is triage — who needs a
@@ -94,15 +121,11 @@ export const ASSISTANT_CONTEXTS = {
id: 'admin.candidatesList',
page: 'Candidates',
topics: ['candidate', 'applicant', 'attention', 'waiting', 'interview', 'ready', 'score', 'unscored', 'risk', 'flag', 'strongest', 'best', 'top', 'compare', 'shortlist', 'pipeline', 'summar', 'how many', 'who'],
capabilities: CANDIDATE_LIST_CAPABILITIES,
respond: respondCandidateList,
},
'admin.candidates': {
id: 'admin.candidates',
page: 'Candidates Analysis',
topics: ['candidate', 'applicant', 'gap', 'missing', 'coverage', 'unscored', 'risk', 'flag', 'compare', 'top', 'best', 'strongest', 'shortlist', 'recommend', 'hire', 'who', 'pool', 'quality'],
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
respond: respondAdminCandidates,
},
/* Analytics reads the same records as the Control Center, but as performance
over time rather than as a state to act on — hence its own capabilities. */
@@ -110,8 +133,6 @@ export const ASSISTANT_CONTEXTS = {
id: 'admin.analytics',
page: 'Analytics',
topics: ['trend', 'over time', 'month', 'week', 'department', 'category', 'team', 'perform', 'bottleneck', 'funnel', 'conversion', 'position', 'role', 'score', 'attention', 'rate', 'average', 'breakdown', 'report', 'how many', 'compare'],
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
respond: respondAdminAnalytics,
},
/* Forge, Talent Pool and Hired History read the same platform records as the
contexts above, but ask different questions of them: proof and progression,
@@ -126,22 +147,16 @@ export const ASSISTANT_CONTEXTS = {
'certification', 'publish', 'published', 'draft', 'archive', 'status', 'category',
'owliver', 'evaluat', 'criteri', 'rubric', 'proof', 'evidence', 'verify', 'verification',
'verified', 'workforce', 'gap', 'missing', 'what do we have'],
capabilities: FORGE_CAPABILITIES,
respond: respondForge,
},
'admin.talentPool': {
id: 'admin.talentPool',
page: 'Talent Pool',
topics: ['talent', 'pool', 'worker', 'profile', 'priorit', 'availab', 'verif', 'score', 'unscored', 'segment', 'supply', 'summar', 'how many', 'who', 'shortlist'],
capabilities: TALENT_POOL_CAPABILITIES,
respond: respondTalentPool,
},
'admin.hiredHistory': {
id: 'admin.hiredHistory',
page: 'Hired History',
topics: ['hire', 'hired', 'outcome', 'department', 'quality', 'recent', 'strongest', 'pattern', 'time to hire', 'retention', 'how many', 'who'],
capabilities: HIRED_HISTORY_CAPABILITIES,
respond: respondHiredHistory,
},
/* The account page. A form rather than a workflow, but the questions people
ask about it — what may I do, how is this secured, what did I change — are
@@ -153,8 +168,6 @@ export const ASSISTANT_CONTEXTS = {
'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out',
'preference', 'setting', 'digest', 'density', 'workspace', 'owliver', 'profile',
'name', 'email', 'activity', 'recent', 'do here', 'how do i', 'edit', 'change my'],
capabilities: PROFILE_CAPABILITIES,
respond: respondProfile,
},
/**
* Agent configuration — a workspace page, not an operational one.
@@ -173,8 +186,6 @@ export const ASSISTANT_CONTEXTS = {
id: 'admin.agentConfigure',
page: 'Agent Configure',
topics: AGENT_CONFIGURE_TOPICS,
capabilities: AGENT_CONFIGURE_CAPABILITIES,
respond: respondAgentConfigure,
},
/**
* Settings, and the workspace pages behind it.
@@ -199,50 +210,36 @@ export const ASSISTANT_CONTEXTS = {
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',
topics: ['activity', 'audit', 'event', 'log', 'security', 'breach', 'compliance', 'trace', 'unusual', 'anomal', 'suspicious', 'user', 'who', 'account', 'busiest'],
capabilities: ACTIVITY_CAPABILITIES,
respond: respondActivity,
},
};

View File

@@ -423,6 +423,19 @@ const PLACEHOLDERS = {
/**
* Builds suggestions from what is actually on the page.
*
* NOT what the panel renders, and no longer on the production path. The chips
* Owliver offers now come from `GET /api/v1/owliver/suggestions`, which ranks
* against the rows in PostgreSQL and the caller's permissions — neither of
* which a fact sheet assembled in the browser can speak for. `buildIntro` above
* is unaffected: a greeting states what is on the page, and the page is where
* that is known.
*
* What is left here is the derivation itself, which is still read by
* `scripts/skill-check.mjs` and pinned by the committed Owliver baseline: given
* a fact sheet, which questions does this page's data raise. It is kept because
* the baseline is a record of behaviour rather than of code, and rewriting it
* to match a deletion would erase the comparison it exists to make.
*
* A suggestion that names two real candidates is a different product from one
* that says "Compare candidates" — it proves the assistant already looked.
* Each carries the capability that answers it best, so a click is precise.

View File

@@ -29,4 +29,4 @@ export { AssistantPanel } from './AssistantPanel';
export { default as KrowAssistant } from './KrowAssistant';
export { ASSISTANT_CONTEXTS, getContext } from './contexts';
export { EXCLUDED_ROUTES, enabledRoutes, resolveAssistantContext } from './placement';
export { createHttpProvider, createLocalProvider } from './provider';
export { createAgentProvider, createUnconfiguredProvider } from './provider';

View File

@@ -75,7 +75,14 @@ export function buildFacts({
.filter((e) => e.viable.length === 1);
/* Strong candidates nobody has moved on — the most expensive kind of delay. */
const stalled = ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened');
/* Screened and waiting on a decision — which includes the shortlisted, who
are waiting on exactly that. Restricting this to `ai_screened` read as
correct only while nothing was ever shortlisted: the first shortlisted
candidate would have dropped silently out of the count. positionInsights
draws the same set with ['ai_screened', 'shortlisted']. */
const stalled = ranked.filter(
(a) => a.ai_score >= 80 && ['ai_screened', 'shortlisted'].includes(a.status)
);
const funnel = [
{ key: 'applied', label: 'Applied', count: applications.length },

View File

@@ -1,171 +0,0 @@
/**
* Ranking the panel's own suggestions against what is being typed.
*
* This is not a catalogue and deliberately does not own one. Owliver already
* decides what can be asked on a page — the agent's starters, every attached
* skill's declared suggestions, the page's own derived prompts — and each of
* those chips carries the metadata that makes it *executable*: a `capability`
* that names one of the page's answers, a `skillId`/`skillCapability` pair that
* names a skill's reading, a `positionId` that says which record, a `route` for
* the ones that navigate. All this module does is choose which of those
* already-built chips are worth showing for a given query, and hand them back
* unchanged so that clicking one runs exactly what it always ran.
*
* Returning the original object rather than a copy is the whole contract. A
* ranked chip is the same chip; `runPrompt` cannot tell it apart from one that
* arrived unfiltered, so nothing about how a suggestion executes depends on
* whether it was typed towards or offered outright.
*/
/**
* The shortest query worth ranking. Below it the panel shows nothing at all —
* one character matches most of the catalogue, which is the wall of chips this
* replaced.
*/
export const MIN_QUERY_CHARS = 2;
/** Never more than a row. The composer is what the reader came for. */
export const MAX_MATCHES = 3;
/** One shared empty array, so a keystroke that matches nothing is referentially
stable and does not rerender the chip row. */
const NONE = [];
const lower = (value) => String(value ?? '').toLowerCase();
/**
* Letters and digits only, Unicode aware — the same thing the reader would
* count. Punctuation alone never counts as having typed anything.
*/
const meaningful = (value) => (String(value ?? '').match(/[\p{L}\p{N}]/gu) || []).length;
/** A string as the words worth matching on. */
const words = (value) => lower(value).split(/[^\p{L}\p{N}]+/u).filter(Boolean);
/**
* Words too common to carry a subject.
*
* Only used to stop a query made *entirely* of them from matching the whole
* catalogue: "show me the" should offer nothing rather than everything. A
* stop word alongside a real term is still scored, because "show pipeline"
* should rank a chip saying both above one saying only the second.
*/
const STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'do', 'does', 'did',
'show', 'me', 'my', 'our', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or',
'with', 'what', 'how', 'why', 'can', 'you', 'i', 'it', 'please', 'give',
'tell', 'about', 'this', 'that', 'any', 'all',
]);
/**
* How well one term matches one field.
*
* Prefix matching is what makes this feel like typing rather than searching:
* "platf" has to find "Platform health" four characters before the word is
* finished. Infix matching is allowed only from four characters, where a
* fragment is specific enough that finding it mid-word is a hit rather than an
* accident — "line" must not match "pipeline" while "peli" reasonably does.
*/
function fieldScore(term, fieldWords, exact, partial) {
let best = 0;
for (const word of fieldWords) {
if (word === term) return exact;
if (word.startsWith(term)) best = Math.max(best, partial);
else if (term.length >= 4 && word.includes(term)) best = Math.max(best, partial - 1);
}
return best;
}
/**
* What a chip resolves to, as words.
*
* A capability id is not decoration — `pipeline-health` is the name of the
* reading the chip runs, and it is frequently the only place the subject
* appears. The Control Center's bottleneck chip reads "Bottleneck at
* interviewed" and sends a sentence about candidates dropping between stages;
* nothing in either says "pipeline", and typing that word is exactly how a
* reader would look for it. So the chip's own routing metadata is matched too,
* at the lowest weight of the three fields — it is what the chip *is*, not what
* it says, and a chip that says the word should always rank above one that
* merely resolves to it.
*/
const intentWords = (chip) => words(
[chip?.capability, chip?.skillId, chip?.skillCapability].filter(Boolean).join(' ')
);
/**
* A chip's score for a query, or 0 for "do not offer this".
*
* The label is weighted above the prompt because the label is what the reader
* sees: a chip that reads "Platform health" is a better answer to "platform"
* than one that happens to mention the word in the sentence it sends, even
* though both would answer.
*/
function score(chip, terms, phrase) {
const label = lower(chip?.label);
const prompt = lower(chip?.prompt);
if (!label && !prompt) return 0;
let total = 0;
let matched = 0;
/* The whole query as one phrase, which is the strongest signal there is —
"pipeline health" typed in full should beat two chips that each carry one
of those words. */
if (label.includes(phrase)) total += 12;
else if (prompt.includes(phrase)) total += 7;
const labelWords = words(label);
const promptWords = words(prompt);
const routeWords = intentWords(chip);
for (const term of terms) {
const hit = Math.max(
fieldScore(term, labelWords, 6, 4),
fieldScore(term, promptWords, 4, 2),
fieldScore(term, routeWords, 3, 2)
);
if (hit) {
matched += 1;
total += hit;
}
}
/* A chip has to actually be about something that was typed. */
if (!matched) return 0;
/* Every term landing somewhere is worth more than most of them landing. */
if (matched === terms.length) total += 3;
/* A suggestion that would have to ask which record before it could answer
ranks below one that answers — the same order the resolver already puts
them in when they are offered unfiltered. */
if (chip?.deferred) total -= 3;
return total;
}
/**
* The best few of `prompts` for `query`, in the chips' own objects.
*
* Stable: chips scoring equally keep the order they arrived in, which is the
* order the panel already considers most useful — agent starters, then declared
* skill suggestions, then the page's derived prompts.
*/
export function rankPrompts(prompts = [], query = '', max = MAX_MATCHES) {
const text = String(query ?? '').trim();
if (meaningful(text) < MIN_QUERY_CHARS) return NONE;
const terms = words(text);
if (!terms.length || terms.every((term) => STOP_WORDS.has(term))) return NONE;
const phrase = lower(text);
const scored = [];
prompts.forEach((chip, index) => {
const value = score(chip, terms, phrase);
if (value > 0) scored.push({ chip, value, index });
});
scored.sort((a, b) => b.value - a.value || a.index - b.index);
return scored.length ? scored.slice(0, max).map((entry) => entry.chip) : NONE;
}

View File

@@ -26,182 +26,424 @@
* }
*/
import { getContext } from './contexts';
import { note, toSnapshots } from './blocks';
import { confirmation, note } from './blocks';
/**
* An answer, at the depth the agent asked for.
* The agent provider: the real runtime, over the real API.
*
* 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.
* The only provider that answers. Everything that makes that safe lives on the
* server:
*
* `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.
* - **The principal is the session's.** The body carries a question and
* nothing else about who is asking. The browser cannot name a caller, so it
* cannot ask about records it is not entitled to see.
* - **The tools and corpora are the SPEC's.** Not the request's. An agent
* reads what its published definition says it may read, and no field here
* can widen that.
* - **A write cannot happen from a question.** The backend answers a proposed
* write with a confirmation payload and performs nothing. Approving it is a
* second, explicit call carrying the token — see `confirmation` below.
*
* One snapshot, not a stream. The endpoint is not streaming yet, so this yields
* the finished answer once. The signature is the streaming one because that is
* what the seam is, and switching to real streaming later changes this function
* and nothing else.
*/
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));
/**
* Local provider — answers computed from the dashboard's own data.
*
* Deterministic: the same question against the same data returns the same
* answer, which is what makes the assistant demonstrable and testable.
*/
export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
return {
id: 'local',
id: 'agent',
async *stream({ contextId, capability, question, facts, agent = null, signal }) {
const context = getContext(contextId);
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
const document = capability
? context.capabilities.find((c) => c.id === capability)?.run(facts)
?? 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(shaped)) {
if (signal?.aborted) return;
yield snapshot;
await sleep(frameDelay);
async *stream({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
/* No agent, no run. The panel resolves which agent covers the page before
calling; reaching here without one means the routing layer changed and
this should say so rather than guess at an agent id. */
if (!agent?.id) {
yield [note('No agent is available for this page.')];
return;
}
},
};
}
/**
* HTTP provider — the path to a real backend.
*
* Reads Server-Sent-Event style `data:` lines and yields a growing block array.
* A line may carry a whole block (`{ block: … }`) or a text delta
* (`{ delta: "…" }`), which is appended to a trailing text block. Unused today;
* it exists so the shape of the integration is settled rather than guessed at.
*
* The request body sends `contextId`, `capability` and `question` — not the fact
* sheet. Dashboard data should be read server-side from the caller's own session
* rather than posted from the browser, so the client cannot ask about records it
* is not entitled to see.
*/
export function createHttpProvider({ endpoint, headers = {} }) {
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
return {
id: 'http',
async *stream({ contextId, capability, question, agent = null, owliverContext = null, signal }) {
const response = await fetch(endpoint, {
const response = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent.id)}/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
/* `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 }),
headers: {
'Content-Type': 'application/json',
/* Ask for a stream. The server answers the same run either way — the
final event carries exactly the body the non-streaming path
returns — so a deployment that cannot stream degrades to one late
snapshot rather than to a broken panel. */
Accept: 'text/event-stream, application/json',
},
/* The session cookie. Without it the API answers 401, which is the
correct answer to a browser that is not signed in. */
credentials: 'include',
body: JSON.stringify({
input: question,
confirmation: confirmation || undefined,
/* Pins the conversation to the version it started with. Every answer
comes back carrying its version; sending it on the next turn is what
stops an edit published mid-thread from silently changing which
agent is answering. */
agentVersion: agentVersion || undefined,
}),
signal,
});
if (!response.ok || !response.body) {
throw new Error(`Assistant request failed: ${response.status}`);
if (!response.ok) {
yield [note(await describeFailure(response))];
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const blocks = [];
let buffer = '';
const appendDelta = (delta) => {
const last = blocks[blocks.length - 1];
if (last?.type === 'text') last.text += delta;
else blocks.push({ type: 'text', text: delta });
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// The final element may be a partial line; hold it for the next read.
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
try {
const parsed = JSON.parse(payload);
if (parsed.block) blocks.push(parsed.block);
else if (parsed.delta) appendDelta(parsed.delta);
} catch {
appendDelta(payload);
}
yield [...blocks];
}
/* Not a stream after all — a proxy that buffers, or a server answering
JSON. Read it whole. */
if (!isEventStream(response) || !response.body) {
yield toBlocks(await response.json());
return;
}
yield* readRunStream(response, signal);
},
};
}
/**
* The provider the app uses. Local by default; point
* `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to switch, with no other
* code change.
* Whether the server actually opened a stream, rather than answering JSON.
*
* Defensive about `headers` because the answer to "is this a stream" must be
* NO when anything is unexpected. A response shape this does not recognise gets
* read whole, which works; assuming a stream and finding none would hang.
*/
function isEventStream(response) {
const type = response?.headers?.get?.('Content-Type') || '';
return type.includes('text/event-stream');
}
/**
* Reads the run's event stream, yielding the answer as it grows.
*
* Two kinds of event and they are handled very differently:
*
* - `delta` is a fragment of assistant text. The accumulated text is
* re-parsed into blocks on every one, so a half-written answer renders as
* far as it makes sense to — a table mid-construction stays as text until
* its rows arrive, which reads better than a broken table.
* - `run` is the finished result: the same body the non-streaming path
* returns, carrying confirmations, the termination and the token cost.
* Whatever text streamed is replaced by it, because the final snapshot is
* authoritative and the deltas were a preview of it.
*
* A client that ignored every delta and read only the last event would be in
* exactly the state it would have reached without streaming. That is what keeps
* the two paths honest rather than merely similar.
*/
async function* readRunStream(response, signal) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let text = '';
let final = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
/* The last element may be half a line; hold it for the next read. */
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
let event;
try {
event = JSON.parse(payload);
} catch {
/* A malformed frame is dropped rather than rendered. It cannot be
assistant text — the server encodes every event as JSON — so
showing it would put transport noise in front of a reader. */
continue;
}
if (typeof event.delta === 'string') {
text += event.delta;
yield markdownToBlocks(text);
continue;
}
if (event.run) final = event.run;
if (event.error) {
yield [note(event.error.message || 'The agent could not be reached.')];
return;
}
}
if (signal?.aborted) break;
}
} finally {
/* Releasing matters on an abort: a reader still holding the body keeps the
connection open, and a user who pressed Stop expects it to stop. */
try { reader.cancel(); } catch { /* already closed */ }
}
if (final) {
yield toBlocks(final);
return;
}
/* The stream ended without a final event — the run was aborted, or the
connection dropped mid-answer. Whatever arrived is kept: a half-read answer
is worth more to the reader than an empty panel. */
if (text) yield markdownToBlocks(text);
}
/**
* Turns a run result into blocks.
*
* The termination decides the shape, and every one of the six produces
* something a person can act on. A run that ended without completing is not an
* error to swallow: it has an answer-so-far worth keeping and a reason worth
* reading.
*/
function toBlocks(run) {
const blocks = [];
if (run.output) blocks.push(...markdownToBlocks(run.output));
/* Pending writes before the trailing note: somebody scrolling to the bottom
should meet the decision, not a footnote about token cost. */
for (const c of run.confirmations || []) {
blocks.push(confirmation(c));
}
/* The surface layer's wording for a run that did not complete. Rendered as a
note rather than as prose, so it reads as the system speaking rather than
as the agent's own words. */
if (run.message) blocks.push(note(run.message));
if (!blocks.length) {
blocks.push(note('The agent finished without saying anything.'));
}
return blocks;
}
/**
* Turns a model's markdown into the block vocabulary the panel already renders.
*
* A real model writes markdown — headings, numbered steps, tables. The local
* simulator never did: it emitted short single paragraphs, so the text renderer
* only ever handled inline bold and italic. Point the panel at a real model and
* `## What I'd do, in order` arrives on screen with the hashes still attached,
* and a comparison table arrives as pipes.
*
* The fix is NOT to render markdown inside a text block. The panel already has
* a heading block, a list block and a table block, all styled with the same
* tokens as the dashboard cards beside them — so the honest move is to parse
* into those, and let a generated answer look like it belongs to Krow rather
* than like a chat window that happens to be embedded in it.
*
* Deliberately a small parser and not a markdown library. Four constructs is
* what a model actually produces in an answer; anything else falls through as a
* paragraph, which reads correctly even when it is not styled richly. A full
* parser would be a large dependency in exchange for handling footnotes nobody
* writes.
*/
export function markdownToBlocks(markdown) {
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
const blocks = [];
let paragraph = [];
let listItems = null;
let ordered = false;
const flushParagraph = () => {
const text = paragraph.join(' ').trim();
paragraph = [];
if (text) blocks.push({ type: 'text', text });
};
const flushList = () => {
if (listItems?.length) blocks.push({ type: 'list', items: listItems, ordered });
listItems = null;
};
const flushAll = () => { flushParagraph(); flushList(); };
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) { flushAll(); continue; }
/* A heading. The level is dropped: this panel has one heading style, and
inventing three would give a 380px column a hierarchy it cannot show. */
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
if (heading) {
flushAll();
blocks.push({ type: 'heading', text: stripInline(heading[2]) });
continue;
}
/* A table: a pipe row followed by a separator row. Checked together,
because a single pipe row is far more likely to be prose. */
if (trimmed.startsWith('|') && isSeparatorRow(lines[i + 1])) {
flushAll();
const { block, next } = parseTable(lines, i);
if (block) { blocks.push(block); i = next; continue; }
}
const bullet = /^[-*]\s+(.+)$/.exec(trimmed);
const numbered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
if (bullet || numbered) {
const wantOrdered = Boolean(numbered);
/* A list that changes kind mid-way is two lists. */
if (listItems && ordered !== wantOrdered) flushList();
flushParagraph();
ordered = wantOrdered;
listItems = listItems || [];
listItems.push((bullet || numbered)[1].trim());
continue;
}
flushList();
paragraph.push(trimmed);
}
flushAll();
return blocks.length ? blocks : [{ type: 'text', text: String(markdown).trim() }];
}
/** `|---|---:|` — the row that makes the one above it a header. */
function isSeparatorRow(line) {
return Boolean(line && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-'));
}
function splitRow(line) {
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
}
/**
* Reads a markdown table starting at `start`.
*
* Rows with the wrong number of cells are padded or trimmed rather than
* dropped. A model occasionally miscounts a pipe, and losing a whole row of an
* answer over a formatting slip is worse than showing an empty cell — which the
* renderer already draws as an em dash.
*/
function parseTable(lines, start) {
const header = splitRow(lines[start]);
const columns = header.map((label, i) => ({ key: `c${i}`, label: stripInline(label) }));
const rows = [];
let i = start + 2;
for (; i < lines.length; i += 1) {
const line = lines[i];
if (!line.trim().startsWith('|')) break;
const cells = splitRow(line);
const row = {};
columns.forEach((col, n) => { row[col.key] = stripInline(cells[n] ?? ''); });
rows.push(row);
}
if (!rows.length) return { block: null, next: start };
return { block: { type: 'table', columns, rows }, next: i - 1 };
}
/**
* Removes markdown a cell or heading cannot show.
*
* Table cells and headings are rendered as plain strings by their components,
* so `**Maria**` would appear with the asterisks. Paragraphs and list items are
* left alone — those go through `Inline`, which renders bold properly.
*/
function stripInline(value) {
return String(value).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1').trim();
}
/**
* A failed request, in one sentence a person can act on.
*
* The status is what distinguishes the cases that matter, and they are
* genuinely different actions: sign in again, ask someone for access, or wait.
* Flattening them into "something went wrong" makes the user's next move a
* guess.
*/
async function describeFailure(response) {
let detail = '';
try {
const body = await response.json();
detail = body?.error?.message || '';
} catch {
/* A non-JSON error body is a proxy or a gateway, not this API. The status
still says enough. */
}
switch (response.status) {
case 401:
return 'Your session has expired. Sign in again to keep asking.';
case 404:
/* Deliberately the same answer for "no such agent" and "not yours" — the
API refuses to distinguish them, and repeating the distinction here
would undo that. */
return 'That agent is not available on this workspace.';
case 422:
return detail || 'That agent cannot run right now.';
case 429:
return 'Too many requests just now. Try again in a moment.';
default:
return detail || 'The agent could not be reached. Try again in a moment.';
}
}
/**
* The provider the app uses.
*
* There used to be three: a local simulator that computed answers from data
* already in the browser, a streaming HTTP provider for a deployment that had
* one, and the agent. The simulator is gone, and its removal is the point of
* this file's current shape.
*
* WHY IT WENT
*
* Two answering paths behind one avatar meant the same question got different
* answers depending on phrasing — "assign the strongest free worker" matched a
* template and reported nobody was available, while "put the best free worker
* on" reached the agent, which found somebody and proposed them. A user cannot
* be expected to know which sentence talks to which system, and a product where
* the wording decides the answer is a demo with good manners.
*
* WHAT IT COST, SAID PLAINLY
*
* The simulator was instant, free, and could not be wrong about a figure — it
* read the same cache the page rendered from. The agent takes ten to twenty
* seconds and costs tokens. That is a real regression on speed, accepted in
* exchange for answers that can be followed up, cannot be beaten by a synonym,
* and can act on what they find.
*
* AN UNCONFIGURED DEPLOYMENT NOW SAYS SO
*
* With no VITE_AGENT_API there is nothing to fall back to. That state is
* explicit rather than silent: every question is answered with the reason,
* because a panel that quietly does nothing is the worst of the three
* possibilities and the hardest to diagnose.
*/
export function createAssistantProvider() {
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
return endpoint ? createHttpProvider({ endpoint }) : createLocalProvider();
const base = import.meta.env?.VITE_AGENT_API;
return base ? createAgentProvider({ baseUrl: base }) : createUnconfiguredProvider();
}
/**
* The provider for a deployment with no agent configured.
*
* Answers every question with the same sentence, which is the honest thing to
* do: nothing here can answer, and pretending otherwise is what the simulator
* was doing.
*/
export function createUnconfiguredProvider() {
return {
id: 'unconfigured',
// eslint-disable-next-line require-yield
async *stream() {
yield [note(
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '
+ 'backend a model credential, and this panel will answer from your workspace.'
)];
},
};
}

View File

@@ -166,14 +166,21 @@ export function navigationAnswer(destination) {
/**
* The reply when the question fits neither this page nor another one.
*
* Lists what this page can answer, taken from the capability labels the panel
* already shows as chips — so the offer is always exactly what is on screen.
* Reached only on a deployment with no agent configured. With one, a question
* this page cannot place is handed to the agent rather than declined — see
* `preferAgent`, which turns `outOfScope` into `answer`.
*
* It used to list the page's capability labels, taken from the chips on screen.
* Those labels were the local simulator's readings and went with it. The page's
* TOPICS are what survive, and they are a better offer anyway: a capability
* label named a canned report, where a topic names a subject somebody can
* actually ask about in their own words.
*/
export function outOfScopeAnswer(context) {
const labels = (context?.capabilities || []).map((c) => c.label);
const subjects = (context?.topics || []).slice(0, 6);
return doc(
text(`I do not have that on **${context?.page || 'this page'}**.`),
list(labels.slice(0, 6), { ordered: false }),
subjects.length ? text(`This page covers ${subjects.join(', ')}.`) : null,
note('Ask about one of those, or open the page the question belongs to.')
);
}
@@ -928,3 +935,79 @@ export function resolveIntent({
return { kind: 'outOfScope', doc: outOfScopeAnswer(context) };
}
/* ── Preferring the agent ─────────────────────────────────────────────────── */
/**
* Decides whether a resolved intent should defer to the agent.
*
* BACKGROUND, because this function only makes sense with it.
*
* This panel grew up answering from the browser. Everything above computes an
* answer out of data React Query already fetched — instant, free, and unable to
* be wrong about a figure, because it reads the same cache the page renders
* from. What it cannot do is reason, and it can only answer the question shapes
* somebody wrote a matcher for.
*
* There is now a real agent behind the panel: a model, seventeen authorized
* tools, permissioned document retrieval, and a write path that asks before it
* acts. It can answer anything, and it can be asked a follow-up.
*
* Both paths wearing the same avatar was the problem. "Assign the strongest
* free worker to Picker" matched a template and answered "nobody is both
* qualified and free"; "put the best free worker on Picker" reached the agent,
* which found somebody and proposed them. Same intent, opposite answers,
* decided by which verb the user happened to type. That is not a product.
*
* WHAT THIS CHANGES, AND WHAT IT DELIBERATELY DOES NOT
*
* An intent that only produces TEXT defers to the agent. An intent that DOES
* something does not, and the distinction is the whole of the rule:
*
* - A template answer is one of several possible descriptions of rows the
* agent can also read. The agent's version can be followed up and cannot be
* beaten by a synonym, so it wins.
* - A flow, a draft action, an assignment, a headcount change, an interview
* being marked ready — these perform work the agent has no tool for. They
* are kept exactly as they are. Removing them would lose capability, not
* gimmickry.
* - A guided form that asks "which position?" one question at a time is an
* honest form. It stays, and it is not the agent pretending to converse.
* - `navigate` stays: sending somebody to the page that owns a subject is a
* product decision, not a failure to answer.
*
* `outOfScope` becomes an answer, and that is the clearest win here. It is the
* panel declining a question the agent could simply have answered.
*
* NOTHING IS DELETED. Every matcher above still runs and still returns what it
* always did; this only chooses not to use the text ones while an agent is
* live. Turn the agent off and the panel behaves exactly as it shipped — which
* is what makes this safe to try before anything is removed for good.
*/
export function preferAgent(intent, { modelBacked = false } = {}) {
if (!modelBacked || !intent) return intent;
/* Anything that performs work keeps its path. The presence of one of these
keys IS the definition of "does something" — see the handlers in
useAssistant, which are the only readers of them. */
if (intent.create || intent.perform || intent.assign || intent.headcount ||
intent.interview || intent.action || intent.flow) {
return intent;
}
switch (intent.kind) {
/* Pure text. The agent reads the same rows and can be asked a follow-up. */
case 'answer-doc':
case 'skill':
case 'workforce':
/* A refusal the agent would not have made. */
case 'outOfScope':
return { kind: 'answer' };
/* 'answer' already goes to the agent. 'navigate' and 'constrained' are
deliberate product behaviour and are left alone. */
default:
return intent;
}
}

View File

@@ -6,7 +6,6 @@ import {
useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks';
import { skillsForContext } from '@/lib/skills/registry';
import { suggestionsForPosition } from '@/lib/skills/owliverResolver';
import {
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
} from '@/lib/skills/positionFlow';
@@ -25,12 +24,22 @@ 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 { preferAgent, resolveIntent } from './routing';
import { doc, text as textBlock, toSnapshots } from './blocks';
/** One provider instance for the app's lifetime. */
const provider = createAssistantProvider();
/**
* Whether a real agent is answering, as opposed to the local simulator.
*
* Read once, from the provider the app actually built. Not a separate flag: a
* second switch could disagree with the first, and "the panel thought it had an
* agent and did not" is a failure mode with no visible symptom beyond worse
* answers.
*/
const agentBacked = provider.id === 'agent';
/**
* Reads the same React Query caches the pages render from, so the assistant
* costs no extra requests and cannot be looking at a different snapshot than the
@@ -228,7 +237,8 @@ function withoutAuthoringActions(messages = []) {
* routing applies to both without either knowing it exists.
*/
export function useConversation({
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
contextId, facts, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
onAssignWorkers, onScheduleInterview,
/* Finishing a draft: the same two mutations the Create Position form calls.
Passed in rather than reached for, so this layer still writes nothing
itself and there is one update path for a position. */
@@ -414,6 +424,18 @@ export function useConversation({
* thread, is persisted and archived like any other, and nothing about it is
* simulated — it is the live pipeline pointed at another surface.
*/
/* The most recent question, for the confirmation path. A ref rather than
state: nothing renders from it, and making it state would re-render the
whole panel on every keystroke-completed turn for no visible reason. */
const lastQuestionRef = React.useRef('');
/* The agent version this conversation started on.
§3: running conversations pin the version they started with. Held in a ref
rather than state because nothing renders from it — and reset with the
thread, so a NEW conversation picks up whatever is current rather than
inheriting a version somebody has since moved on from. */
const pinnedVersionRef = React.useRef(0);
const send = React.useCallback(async ({
question, capability = null, positionId = null, scope = null,
}) => {
@@ -463,16 +485,22 @@ export function useConversation({
if (!intent) {
intent = capability
? { kind: 'answer' }
: resolveIntent({
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories,
courses, workforce, skillContext, positionId,
agent: turnAgent,
agentCoversPage: turnCovers,
agentSuggestion, owliverContext,
});
: preferAgent(
resolveIntent({
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories,
courses, workforce, skillContext, positionId,
agent: turnAgent,
agentCoversPage: turnCovers,
agentSuggestion, owliverContext,
}),
/* Only while a real agent is behind the panel. With the local
simulator there is nothing better to defer TO, and deferring
would turn every template answer into a worse one. */
{ modelBacked: agentBacked },
);
}
/**
@@ -482,41 +510,56 @@ export function useConversation({
*/
if (intent.kind === 'flow' && intent.create) {
let created = null;
/* Kept, not swallowed. The reply states the outcome, and "it did not
work" is a worse outcome to state than the reason it did not: a
required field, a refused role, or an API that is not running. */
let failure = null;
try {
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
} catch {
} catch (error) {
created = null;
failure = error;
}
if (created?.id) {
/**
* What can now be asked about the position that was just made.
* What can now be asked, with the position in the database.
*
* A live position is something the page's skills can read; a draft is
* not finished being specified, so offering to analyse it would be
* answering about a record the admin has not committed to. The registry
* decides *which* skills can say something — see
* `suggestionsForPosition` — and this decides only whether it is yet
* the moment to ask them.
* The record exists, so the organization is materially different from
* what it was one turn ago — there is one more role to fill, or one
* more unfinished draft — and what is worth asking has changed with it.
* `onRefreshSuggestions` asks the server that question again rather
* than deriving an answer here: the ranking is the API's, against the
* rows it has just written, filtered by the caller's role.
*
* A draft is offered nothing. It is not finished being specified, and
* suggesting readings of a record the admin has not committed to would
* be answering about something that is not yet true.
*/
const ready = created.status === 'active';
let refreshed = [];
if (ready) {
try {
refreshed = (await onRefreshSuggestions?.()) || [];
} catch {
/* The position was created; failing to fetch what to ask next is
not a reason to report that it was not. */
refreshed = [];
}
}
intent = {
...intent,
flow: null,
doc: positionCreatedReply(created),
followUp: [
...createdFollowUp(created),
...(ready
? suggestionsForPosition(turnContext, turnDisabled, customSkills, created)
: []),
],
followUp: [...createdFollowUp(created), ...refreshed],
};
} else {
/* Keep the answers: the summary is still there to try again from. */
intent = {
...intent,
flow: { ...intent.flow, stage: 'review' },
doc: positionFailedReply(),
doc: positionFailedReply(failure?.message),
followUp: [
{ label: 'Create position', prompt: 'Create position' },
{ label: 'Change details', prompt: 'Change details' },
@@ -694,12 +737,16 @@ export function useConversation({
try {
let latest = [];
/* Held for the confirmation path: approving a proposed write resumes the
run, and a resumed run needs the question that produced the proposal. */
lastQuestionRef.current = text;
for await (const snapshot of provider.stream({
contextId: turnContext, 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: turnAgent ? agentRequest(turnAgent, turnContext) : null,
owliverContext,
agentVersion: pinnedVersionRef.current,
})) {
if (controller.signal.aborted) break;
latest = snapshot;
@@ -730,7 +777,8 @@ export function useConversation({
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onUpdatePosition,
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
onUpdatePosition,
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
@@ -739,6 +787,68 @@ export function useConversation({
const stop = React.useCallback(() => abortRef.current?.abort(), []);
/**
* Approves a proposed write, and lets the run finish.
*
* The second half of the confirmation flow. The first half ended with the
* agent describing something and doing nothing; this carries the person's
* decision back and the server performs exactly the call that description was
* issued against — same tool, same arguments, same caller. A token authorises
* one write and expires; it is not a mode.
*
* The original question is re-sent alongside it, because the run that resumes
* is a NEW run: it has to be able to reach the same tool call again for the
* token to match. That is why the token is not bound to a run id — see
* tools/confirm.go.
*
* Nothing happens locally. This layer does not write, does not optimistically
* mark anything done, and does not tell the user it worked: the answer that
* comes back says what actually happened, including a refusal if the world
* moved between the asking and the answering.
*/
const confirm = React.useCallback(async (block) => {
if (!block?.token) return;
/* The question this confirmation was raised for. Read from the thread
rather than held in state, so approving an older proposal still resends
the right question rather than whatever was typed most recently. */
const question = lastQuestionRef.current;
if (!question) return;
const controller = new AbortController();
abortRef.current = controller;
setError(null);
setPending({ blocks: [], thinking: true });
try {
let latest = [];
for await (const snapshot of provider.stream({
contextId, capability: null, question, facts,
agent: agent ? agentRequest(agent, contextId) : null,
owliverContext,
confirmation: block.token,
agentVersion: pinnedVersionRef.current,
signal: controller.signal,
})) {
if (controller.signal.aborted) break;
latest = snapshot;
setPending({ blocks: snapshot, thinking: false });
}
if (latest.length) {
const next = [...messagesRef.current, { role: 'assistant', blocks: latest }];
messagesRef.current = next;
persist(next);
}
} catch (e) {
if (e?.name !== 'AbortError') {
setError('That approval could not be completed. Nothing was changed.');
}
} finally {
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, agent, owliverContext, persist]);
/**
* States something in the thread without a question having been asked.
*
@@ -859,6 +969,8 @@ export function useConversation({
send,
announce,
stop,
/** Approves a proposed write and resumes the run. See `confirm`. */
confirm,
reset,
submitFeedback,
feedback,

View File

@@ -29,8 +29,12 @@ export default function HiringAnalytics({ applications, staff }) {
const totalApps = applications.length;
const hireRate = totalApps ? Math.round((hiredApps.length / totalApps) * 100) : 0;
const avgHireScore = hiredApps.length
? Math.round(hiredApps.reduce((s, a) => s + (a.ai_score || 0), 0) / hiredApps.length)
/* Averaged over the hires that carry a score. A hire can reach 'hired'
without ever being scored, and counting that as a 0 drags the figure down
by the number of people nobody scored rather than by anything about them. */
const scoredHires = hiredApps.filter(a => a.ai_score > 0);
const avgHireScore = scoredHires.length
? Math.round(scoredHires.reduce((s, a) => s + a.ai_score, 0) / scoredHires.length)
: 0;
const sevenDaysAgo = new Date();
@@ -48,7 +52,7 @@ export default function HiringAnalytics({ applications, staff }) {
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">HIRE RATE</div>
</div>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
<div className="text-2xl font-heading font-bold text-[#333F48]">{avgHireScore}</div>
<div className="text-2xl font-heading font-bold text-[#333F48]">{avgHireScore || '—'}</div>
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">AVG SCORE</div>
</div>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">

View File

@@ -4,7 +4,10 @@ import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react';
export default function ImpactMetrics({ applications, interviews, staff: _staff }) {
const hired = applications.filter(a => a.status === 'hired');
const screened = applications.filter(a => a.ai_score > 0);
/* Named for what it is: the applications the AI actually scored. Elsewhere
in the product "screened" means the stage (status <> 'applied'), which is a
different and larger set — every saving below is per AI screen performed. */
const aiScreened = applications.filter(a => a.ai_score > 0);
// 1. Reduce time-to-hire: avg days from application created to hired
const timeToHire = hired.length
@@ -20,19 +23,20 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
const tthDisplay = timeToHire ? `${timeToHire}d avg` : 'Instant screen';
// 2. Lower recruiting costs: $45 per manual screen + $80 per manual interview saved
const costSaved = screened.length * 45 + interviews.length * 80;
const costSaved = aiScreened.length * 45 + interviews.length * 80;
// 3. Improve quality of hire: avg AI score of hired candidates
const qualityOfHire = hired.length
? Math.round(hired.reduce((s, a) => s + (a.ai_score || 0), 0) / hired.length)
const scoredHires = hired.filter(a => a.ai_score > 0);
const qualityOfHire = scoredHires.length
? Math.round(scoredHires.reduce((s, a) => s + a.ai_score, 0) / scoredHires.length)
: 0;
// 4. Increase recruiter productivity: hours saved (15 min per AI screen + 30 min per interview)
const hoursSaved = Math.round((screened.length * 15 + interviews.length * 30) / 60);
const hoursSaved = Math.round((aiScreened.length * 15 + interviews.length * 30) / 60);
// 5. Standardize hiring decisions: % of applicants with standardized AI scoring applied
const standardizedPct = applications.length
? Math.round((screened.length / applications.length) * 100)
? Math.round((aiScreened.length / applications.length) * 100)
: 0;
// 6. Improve candidate experience: AI responds instantly (interview completion rate as proxy)
@@ -53,7 +57,7 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
icon: DollarSign,
label: 'Cost Saved',
value: `$${costSaved.toLocaleString()}`,
sub: `${screened.length} screens · ${interviews.length} interviews automated`,
sub: `${aiScreened.length} screens · ${interviews.length} interviews automated`,
color: '#333F48',
bg: '#F3F4F6',
},
@@ -61,7 +65,11 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
icon: Award,
label: 'Quality of Hire',
value: qualityOfHire ? `${qualityOfHire}/100` : '—',
sub: hired.length ? `${hired.length} hired candidates` : 'Awaiting first hire',
/* The basis is the scored hires, not every hire, so the caption counts
the ones the figure is actually made of. */
sub: scoredHires.length
? `${scoredHires.length} scored ${scoredHires.length === 1 ? 'hire' : 'hires'}`
: hired.length ? 'No hire has been scored' : 'Awaiting first hire',
color: '#0838E0',
bg: '#FEFCE8',
},
@@ -77,7 +85,7 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
icon: Scale,
label: 'Decisions Standardized',
value: `${standardizedPct}%`,
sub: `${screened.length} of ${applications.length} applicants scored`,
sub: `${aiScreened.length} of ${applications.length} applicants scored`,
color: '#0838E0',
bg: '#EEF3FE',
},

View File

@@ -23,7 +23,11 @@ export default function ScoreDistribution({ applications }) {
return scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
}, [applications]);
const screened = applications.filter(a => a.ai_score > 0).length;
/* Deliberately "scored", not "screened": everywhere else screened means the
stage (status <> 'applied'), and on this corpus that is 14 against 9 here.
Calling both "screened" made this card and the unscreened count on the
dashboard fail to add up to the pool. */
const scored = applications.filter(a => a.ai_score > 0).length;
return (
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
@@ -34,7 +38,9 @@ export default function ScoreDistribution({ applications }) {
<div className="text-[10px] text-[#9CA3AF] tracking-wide">AVG SCORE</div>
</div>
</div>
<p className="text-[12px] text-[#6B7280] mb-5">{screened} candidates screened</p>
<p className="text-[12px] text-[#6B7280] mb-5">
{scored} {scored === 1 ? 'candidate' : 'candidates'} scored
</p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data}>

View File

@@ -6,13 +6,7 @@
* up disagreeing with the drawer it opens.
*/
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
/** Everyone who reached this stage or went past it. */
const atOrBeyond = (applications, stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
};
import { atOrBeyond, HIRED_STATUSES } from '@/lib/hiringRecords';
/**
* Hiring health.
@@ -80,7 +74,7 @@ export function buildPosition(posting, applications) {
const screened = atOrBeyond(apps, 'ai_screened').length;
const shortlisted = atOrBeyond(apps, 'shortlisted').length;
const interviews = atOrBeyond(apps, 'interview').length;
const hired = apps.filter((a) => a.status === 'hired').length;
const hired = apps.filter((a) => HIRED_STATUSES.includes(a.status)).length;
const scored = apps.filter((a) => a.ai_score > 0);
const qualified = scored.filter((a) => a.ai_score >= 70).length;

View File

@@ -300,6 +300,16 @@ export function normalizeAgent(raw, { agentId = '', body = '' } = {}) {
webSearch: data.webSearch === true || data.web_search === true,
pages: normalizePages(data.pages, { errors }),
skills: uniqueStrings(data.skills, { where: 'skills', errors, label: 'a skill id' }),
/* The tools this agent may call, by registry name.
Skills are guidance the model reads; tools are what it can actually do.
An agent with skills and no tools can discuss the work and look nothing
up — which is what every agent authored in this editor was, because
this field did not exist and the parser dropped `tools:` on the way in.
The choices come from GET /api/v1/tools rather than a list kept here,
so a tool renamed in the backend cannot leave a stale option in a form. */
tools: uniqueStrings(data.tools, { where: 'tools', errors, label: 'a tool name' }),
subagents,
knowledge,
starters,

View File

@@ -31,6 +31,7 @@ export const EMPTY_AGENT_FIELDS = Object.freeze({
webSearch: false,
pages: [],
skills: [],
tools: [],
subagents: [],
knowledge: [],
starters: [],
@@ -66,6 +67,7 @@ export function agentFieldsFromSource(source) {
webSearch: agent.webSearch,
pages: [...agent.pages],
skills: [...agent.skills],
tools: [...(agent.tools || [])],
subagents: [...agent.subagents],
knowledge: agent.knowledge.map((k) => ({ ...k })),
starters: agent.starters.map((s) => ({ ...s })),
@@ -116,6 +118,9 @@ export function agentPatch(fields = {}) {
}
if (fields.skills !== undefined) patch.skills = listOrRemove(fields.skills);
/* Capability, as opposed to guidance. Written the same way as skills so the
round trip is the same one: form → frontmatter → parser → form. */
if (fields.tools !== undefined) patch.tools = listOrRemove(fields.tools);
if (fields.subagents !== undefined) patch.subagents = listOrRemove(fields.subagents);
if (fields.starters !== undefined) {

View File

@@ -0,0 +1,181 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { base44 } from '@/api/base44Client';
import { request } from '@/api/httpClient';
import toast from 'react-hot-toast';
import { parseAgent } from './registry';
/**
* The authored-agent registry, over the API.
*
* Authored agents used to live in the account's preferences as Markdown. That
* persisted — preferences are a real column in a real table — but it persisted
* to the wrong place: the runtime resolves an agent from `agent_definitions`
* and never reads preferences, so an agent created in the editor showed as
* "published" in the list and answered every run with 404.
*
* This writes to `/api/v1/agent-definitions`, which is the table the runtime
* loads from. An agent saved here is one Owliver can actually be asked to run.
*
* Markdown stays the artefact on both sides, and the same parser reads it in
* both processes — `internal/definition` is checked against this one by a
* conformance test replaying a capture of the real frontend module graph, so
* "the backend understood it differently" is a failing test rather than a
* support ticket.
*/
const KEY = ['agentDefinitions'];
/** Every authored agent this account can see, newest first. */
export function useAgentDefinitions() {
return useQuery({
queryKey: KEY,
queryFn: () => base44.entities.AgentDefinition.list('-created_date', 200),
});
}
/**
* The stored rows as the registry reader wants them.
*
* `{ path, raw }` is the shape `readAgentRegistry` already takes, so the reader
* did not have to learn where definitions come from — only the store changed.
*/
export function sourcesFrom(rows) {
return (rows || [])
.filter((r) => r && typeof r.markdown === 'string' && r.markdown.trim())
.map((r) => ({ path: `authored/${r.definition_id || r.id}.md`, raw: r.markdown }));
}
/**
* Create or update by definition id.
*
* The id an author writes (`activity-agent`) is not the row's id (a uuid), and
* the two endpoints take different ones: the registry is a CRUD resource keyed
* by uuid, while a run is addressed by the definition id. Resolving that here
* keeps every caller in the author's vocabulary.
*/
export function useSaveAgentDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ definitionId, markdown, visibility = 'personal' }) => {
const rows = qc.getQueryData(KEY) || (await base44.entities.AgentDefinition.list('-created_date', 200));
const existing = (rows || []).find((r) => r.definition_id === definitionId);
/* visibility is immutable after creation, so it is sent only on create —
patching it back is rejected by the service and would turn a plain save
into an error the author cannot act on. */
return existing
? base44.entities.AgentDefinition.update(existing.id, { markdown })
: base44.entities.AgentDefinition.create({ markdown, visibility });
},
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
}
/** Deletes the authored definition. A shipped agent returns to its shipped form. */
export function useDeleteAgentDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (definitionId) => {
const rows = qc.getQueryData(KEY) || (await base44.entities.AgentDefinition.list('-created_date', 200));
const existing = (rows || []).find((r) => r.definition_id === definitionId);
if (!existing) return { ok: true };
return base44.entities.AgentDefinition.delete(existing.id);
},
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
}
/**
* The tools an author may choose from, as the backend reports them.
*
* Served rather than listed here: a tool renamed or removed in the registry
* would otherwise leave a stale option in this form, and the agent built from
* it would fail at resolve time with nothing on screen to explain why.
*/
export function useToolCatalogue() {
return useQuery({
queryKey: ['toolCatalogue'],
queryFn: () => request('GET', '/tools'),
/* The tool set changes when the backend is deployed, not while somebody is
filling in a form. */
staleTime: 10 * 60 * 1000,
});
}
/** Row lookup by the id an author writes, for callers that need the uuid. */
export function useRowFor(rows) {
return useCallback(
(definitionId) => (rows || []).find((r) => r.definition_id === definitionId) || null,
[rows]
);
}
/** Memoised sources, so the registry is not rebuilt on every render. */
export function useAuthoredSources(rows) {
return useMemo(() => sourcesFrom(rows), [rows]);
}
/**
* Moves agents left in the account's preferences into the registry.
*
* Authored agents used to be stored as `preferences.customAgents`. Reading
* moved to the registry, and without this that history would simply stop being
* shown: the rows stay in the preferences column, the list no longer reads
* them, and an agent somebody wrote disappears with no message. Losing an
* author's work quietly is worse than any of the problems this change fixed.
*
* Runs once per session, and only forward:
*
* - an id already in the registry is left alone. Re-posting would overwrite
* a definition the author may have edited since.
* - preferences are cleared only after every write has succeeded, so a failed
* migration can be retried rather than having eaten the originals.
* - a definition the backend refuses (an unknown tool, say) leaves everything
* in place and reports, rather than dropping that one on the floor.
*/
export function useMigrateStoredAgents({ rows, stored, clearStored, enabled }) {
const save = useSaveAgentDefinition();
const done = useRef(false);
useEffect(() => {
if (!enabled || done.current || !stored?.length) return;
done.current = true;
(async () => {
const known = new Set((rows || []).map((r) => r.definition_id));
const failures = [];
let moved = 0;
for (const entry of stored) {
const markdown = typeof entry === 'string' ? entry : entry?.raw;
if (!markdown) continue;
let parsed = null;
try {
parsed = parseAgent(markdown, { custom: true });
} catch {
failures.push('a definition that could not be read');
continue;
}
if (known.has(parsed.id)) continue;
try {
await save.mutateAsync({ definitionId: parsed.id, markdown });
moved += 1;
} catch (error) {
failures.push(`${parsed.id}: ${error?.message || 'refused'}`);
}
}
if (failures.length) {
toast.error(`Some stored agents could not be moved. ${failures.join('; ')}`);
return;
}
if (moved > 0) {
await clearStored();
toast.success(`${moved} stored agent${moved === 1 ? '' : 's'} moved into the registry.`);
} else {
/* Nothing to move — every stored id already exists in the registry, so
the preferences copy is redundant and can go. */
await clearStored();
}
})();
}, [enabled, rows, stored, clearStored, save]);
}

View File

@@ -1,4 +1,4 @@
import { canonicalPage } from '@/lib/skills/surfaces';
import { DOMAIN_SURFACES, canonicalPage } from '@/lib/skills/surfaces';
import { pageKeyForContext } from '@/lib/skills/registry';
import { getAgent } from './registry';
import { reasoningFor } from './vocabulary';
@@ -122,19 +122,33 @@ export function agentsForContext(agents = [], contextId) {
}
/**
* The general agent every page falls back to.
* Whether an agent is a *general* one: it covers every domain surface.
*
* 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*.
* Derived from the definition rather than matched against an id. This used to
* be `FALLBACK_AGENT_ID = 'krow-workforce-agent'` — a literal agent key that
* two functions below branched on, which is precisely the thing agent specs
* being data is supposed to make impossible. With a key in the runtime,
* renaming the general agent silently demotes it, deleting it leaves two dead
* branches, and a workspace can never write a second general agent because
* only one id is privileged.
*
* Reading `pages` instead makes it a fact about the spec: an agent listing
* every surface that holds workforce records is an agent with no speciality,
* which is exactly what makes it the sensible fallback. A general agent may
* list *more* than the domain surfaces — the workforce agent also covers the
* agent workspace — so this is a subset test, never an equality one.
*/
export const FALLBACK_AGENT_ID = 'krow-workforce-agent';
export function isGeneralAgent(agent) {
if (!agent?.pages?.length) return false;
const covered = new Set(agent.pages);
return DOMAIN_SURFACES.every((id) => covered.has(id));
}
/**
* 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
* General agents are deliberately excluded. One 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.
*
@@ -142,20 +156,26 @@ export const FALLBACK_AGENT_ID = 'krow-workforce-agent';
* a broken one: see `resolveDefaultAgent`.
*/
export function nativeAgentForContext(agents = [], contextId) {
return agentsForContext(agents, contextId).find((a) => a.id !== FALLBACK_AGENT_ID) || null;
return agentsForContext(agents, contextId).find((a) => !isGeneralAgent(a)) || null;
}
/**
* The general agent, when it can answer here.
* The general agent, when one 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.
* `agentsForContext` has already narrowed to published agents covering this
* page and sorted them most-specific-first, so general agents sit at the end
* and the *last* of them is the broadest. Taking that one is identical to the
* old behaviour while exactly one general agent exists, and is a stated choice
* rather than an arbitrary one once a workspace has written a second.
*
* Falls through to whichever published agent covers the page when no general
* one does — 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;
const covering = agentsForContext(agents, contextId);
const general = covering.filter(isGeneralAgent);
return general[general.length - 1] || covering[0] || null;
}
/**

View File

@@ -1,8 +1,12 @@
import { useCallback, useMemo } from 'react';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import {
useAgentDefinitions, useAuthoredSources, useDeleteAgentDefinition,
useMigrateStoredAgents, useSaveAgentDefinition,
} from './agentStore';
import { reportSave } from '@/lib/skills/saveFeedback';
import { AGENTS, parseAgent, readAgentRegistry, validateAgentSource } from './registry';
import { customAgentSource, removeCustomAgent, upsertCustomAgent } from './customAgents';
import { customAgentSource } from './customAgents';
import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agentLifecycle';
/**
@@ -24,11 +28,36 @@ import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agen
*/
export function useAgents() {
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
const stored = useMemo(() => preferences.customAgents || [], [preferences.customAgents]);
/* Authored agents come from the registry the runtime resolves from, not from
this account's preferences. They used to come from preferences, and the
consequence was an agent that the list called "published" and every run
answered 404: the backend stored it faithfully, in a table the runtime does
not read. Skills are still preferences-backed — that is a separate move. */
const definitions = useAgentDefinitions();
const saveDefinition = useSaveAgentDefinition();
const deleteDefinition = useDeleteAgentDefinition();
const rows = useMemo(() => definitions.data || [], [definitions.data]);
const stored = useAuthoredSources(rows);
const customSkills = useMemo(() => preferences.customSkills || [], [preferences.customSkills]);
/* Anything an author wrote before the store moved. Without this it would stop
being displayed rather than being carried across — the rows would sit in
the preferences column, unread, and the agent would appear to have been
deleted. Runs once, and clears the old copy only after every write lands. */
const updatePreferences = useUpdatePreferences();
const legacy = useMemo(() => preferences.customAgents || [], [preferences.customAgents]);
useMigrateStoredAgents({
rows,
stored: legacy,
enabled: !definitions.isPending,
clearStored: useCallback(
() => updatePreferences.mutateAsync({ customAgents: [] }),
[updatePreferences]
),
});
const { agents, diagnostics } = useMemo(
() => readAgentRegistry(stored, { customSkills }),
[stored, customSkills]
@@ -63,23 +92,29 @@ export function useAgents() {
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`)
);
const agent = parseAgent(source, { custom: true });
try {
await saveDefinition.mutateAsync({ definitionId: agent.id, markdown: source });
} catch (error) {
/* The service refuses a definition naming a tool that does not exist, and
says which. Surfaced rather than swallowed: the author picked it, and
the alternative is an agent quietly missing the capability. */
return { ok: false, error: error?.message || 'That agent could not be saved.' };
}
reportSave(message || `${agent.name} saved`);
return { ok: true, agent };
}, [stored, updatePreferences]);
}, [saveDefinition]);
/** 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')
);
try {
await deleteDefinition.mutateAsync(id);
} catch (error) {
return { ok: false, error: error?.message || 'That agent could not be removed.' };
}
reportSave(isShipped(id) ? 'Reverted to the shipped definition' : 'Agent removed');
return { ok: true };
}, [stored, updatePreferences, isShipped]);
}, [deleteDefinition, isShipped]);
/**
* Publishes, refusing to overwrite a newer published version.
@@ -123,7 +158,8 @@ export function useAgents() {
return {
agents,
diagnostics,
saving: updatePreferences.isPending,
loading: definitions.isPending,
saving: saveDefinition.isPending || deleteDefinition.isPending,
isShipped,
isOverridden,
sourceFor,

View File

@@ -95,8 +95,47 @@ export function summarise(hires) {
};
}
/** The application stages this product counts, in the order they happen. */
export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
/**
* The application stages this product counts, in the order they happen.
*
* `assigned` sits past `hired`: a candidate assigned to a shift was hired to get
* there, which is why the backend counts the two together everywhere it asks how
* many people a role actually has (`status IN ('hired','assigned')`).
*/
export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired', 'assigned'];
/**
* Every value of the `application_status` enum. Declared here so the ladder can
* be checked against it: the funnel lost `rejected` and `assigned` for as long
* as it did because nothing in the app held the full list to check against.
*/
export const APPLICATION_STATUSES = [
'applied', 'ai_screened', 'shortlisted', 'interview', 'hired', 'rejected', 'assigned',
];
/** Counted as hired. `assigned` is hired and then rostered, not a separate fate. */
export const HIRED_STATUSES = ['hired', 'assigned'];
/**
* How far a candidate got.
*
* `rejected` is terminal from any stage and overwrites the stage it was reached
* from, so its status alone cannot place it on the ladder. The furthest honest
* claim is that somebody assessed them, so it ranks with `ai_screened`: counted
* as screened, never counted as shortlisted.
*
* A status this does not recognise ranks -1 and drops out of every bucket
* including `applied` — which is how `rejected` and `assigned` used to vanish
* from a position's funnel entirely. The suite checks this covers the schema.
*/
export const rankOf = (status) =>
status === 'rejected' ? STAGE_ORDER.indexOf('ai_screened') : STAGE_ORDER.indexOf(status);
/** Everyone who reached this stage or went past it. */
export const atOrBeyond = (applications, stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => rankOf(a.status) >= from);
};
/**
* Applied → screened → shortlisted → interview → hired, with the pass-through
@@ -107,17 +146,14 @@ export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview'
* later stages as larger than earlier ones, which is not a funnel.
*/
export function buildFunnel(applications) {
const atOrBeyond = (stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from).length;
};
const reached = (stage) => atOrBeyond(applications, stage).length;
const stages = [
{ key: 'applied', label: 'Applied', count: applications.length },
{ key: 'ai_screened', label: 'Screened', count: atOrBeyond('ai_screened') },
{ key: 'shortlisted', label: 'Shortlisted', count: atOrBeyond('shortlisted') },
{ key: 'interview', label: 'Interview', count: atOrBeyond('interview') },
{ key: 'hired', label: 'Hired', count: applications.filter((a) => a.status === 'hired').length },
{ key: 'ai_screened', label: 'Screened', count: reached('ai_screened') },
{ key: 'shortlisted', label: 'Shortlisted', count: reached('shortlisted') },
{ key: 'interview', label: 'Interview', count: reached('interview') },
{ key: 'hired', label: 'Hired', count: applications.filter((a) => HIRED_STATUSES.includes(a.status)).length },
];
const transitions = stages.slice(1).map((stage, i) => {

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { base44 } from '@/api/base44Client';
import { API_BASE_URL, base44 } from '@/api/base44Client';
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
import { recalcProfilePatch } from './krowScore';
import { logActivity } from './userTracking';
@@ -177,12 +177,141 @@ export function useUserActivity() {
});
}
/**
* What Owliver could usefully be asked on this page.
*
* The server answers, and the server is the only thing that decides: it filters
* the readings against the caller's role and — with nothing typed — ranks them
* against the organization's actual state in PostgreSQL. This hook requests and
* caches; it does not score, filter or reorder.
*
* Keyed on the page and the query together, so every distinct composer state
* has its own cache entry and typing back to something already asked is
* answered without a request. `placeholderData` keeps the previous answer on
* screen while the next one is in flight, which is what stops the chip row
* emptying and refilling between keystrokes.
*
* A failure resolves to no suggestions rather than to an error state. The panel
* still works with none — the composer is what the reader came for — and an
* error banner over a suggestion is a bigger interruption than the suggestion
* was worth.
*
* The untyped request is the one that reads the database, so it is invalidated
* whenever something the context counts has changed. See `owliverContextKey`.
*/
export const owliverContextKey = ['owliverSuggestions'];
/**
* Whether this backend serves the endpoint at all.
*
* A deployment lag, not a bug, and it has happened: the route exists in the Go
* source and is not yet on the host a given dev server proxies to, which
* answers `404` for it and `200` for everything else. Every keystroke would
* then be a request that cannot succeed, and no later keystroke can change the
* answer — so the first `404` closes the circuit for the rest of the page load.
*
* Only `404` closes it. A `401` means the session is not signed in, a `500`
* means the server had a bad moment, and an unreachable API means it is being
* restarted: all three are worth trying again, and treating them as "this
* backend does not have the route" would silence suggestions permanently over a
* temporary fault. Module-level rather than component state because the fact is
* about the backend, not about one panel.
*/
let routeUnavailable = false;
/**
* Say it once, out loud.
*
* The circuit breaker below stops the requests; it must not stop the reader
* finding out why the chip row is empty. A suggestion that quietly never
* arrives is indistinguishable from a page that has nothing to suggest, and
* that ambiguity is what makes this fault expensive — it looks like a frontend
* bug and it is a deployment lag.
*
* So the panel degrades to no suggestions, and the console says exactly which
* request failed and what would fix it. Nothing is substituted for the missing
* answer: there is no fallback list, and no locally-ranked catalogue standing
* in for the server's. An empty row is the honest rendering of "the backend
* could not tell us".
*/
function reportMissingRoute() {
if (routeUnavailable) return;
routeUnavailable = true;
console.warn(
`[krow] ${API_BASE_URL}/owliver/suggestions answered 404. The backend this ` +
'frontend is talking to does not register that route, so Owliver will show ' +
'no suggestion chips until it is deployed. This is a backend deployment ' +
'version mismatch, not a frontend fault — the route exists in the Go source ' +
'(go-api/internal/httpserver/owliver.go). Nothing is being substituted for ' +
'the missing answer.'
);
}
/**
* Ask the API what could usefully be asked here.
*
* The one place the request is made, so the circuit breaker above cannot be
* bypassed by a caller that reaches for `base44.owliver` directly. Resolves to
* an empty list on any failure: the panel works with no suggestions — the
* composer is what the reader came for — and an error banner over a suggestion
* is a bigger interruption than the suggestion was worth.
*/
/** @param {any} request */
export async function fetchOwliverSuggestions({ page, query = '' } = {}) {
if (!page || routeUnavailable) return [];
try {
return await base44.owliver.suggestions({ page, query });
} catch (error) {
if (error?.status === 404) reportMissingRoute();
return [];
}
}
/**
* What Owliver could usefully be asked on this page.
*
* The server answers, and the server is the only thing that decides: it filters
* the readings against the caller's role and — with nothing typed — ranks them
* against the organization's actual state in PostgreSQL. This hook requests and
* caches; it does not score, filter or reorder.
*
* Keyed on the page and the query together, so every distinct composer state
* has its own cache entry and typing back to something already asked is
* answered without a request. `placeholderData` keeps the previous answer on
* screen while the next one is in flight, which is what stops the chip row
* emptying and refilling between keystrokes.
*
* The untyped request is the one that reads the database, so it is invalidated
* whenever something the context counts has changed. See `owliverContextKey`.
*/
/** @param {any} request */
export function useOwliverSuggestions({ page, query = '', enabled = true } = {}) {
const typed = String(query || '').trim();
return useQuery({
queryKey: ['owliverSuggestions', page || null, typed],
queryFn: () => fetchOwliverSuggestions({ page, query: typed }),
enabled: Boolean(page) && enabled,
/* Long enough that a keystroke returned to is instant, short enough that a
position created in another tab is reflected on the next open. */
staleTime: 30_000,
placeholderData: (previous) => previous,
/* `fetchOwliverSuggestions` never rejects, so a retry would only repeat a
request that already resolved. Said explicitly so the default of one
retry does not read as a safety net that is doing something. */
retry: false,
});
}
export function useCreateJobPosting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
/* The position count the server ranks suggestions against has changed, so
what Owliver offers next has to be asked again rather than read from a
cache written before the record existed. */
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('create_position');
},
});
@@ -195,6 +324,7 @@ export function useUpdateJobPosting() {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
},
});
}
@@ -205,6 +335,7 @@ export function useCreateApplication() {
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('apply_job');
},
});
@@ -216,6 +347,7 @@ export function useUpdateApplication() {
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
},
});
}
@@ -238,6 +370,7 @@ export function useScreenCandidate() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('screen_candidate');
},
});
@@ -268,6 +401,7 @@ export function useScreenAllCandidates() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('screen_candidate');
},
});
@@ -326,6 +460,7 @@ export function useHireCandidate() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
queryClient.invalidateQueries({ queryKey: ['staff'] });
/* The `hire_candidate` entry is written inside the same transaction as the
hire, so there is nothing to log here — only something to refetch. A
@@ -358,6 +493,7 @@ export function useCreateInterview() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interviews'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('start_interview');
},
});
@@ -507,6 +643,7 @@ export function useAssignWorkers() {
looking at three different moments. */
queryClient.invalidateQueries({ queryKey: ['assignments'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
},
@@ -549,6 +686,7 @@ export function useMarkInterviewReady() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
queryClient.invalidateQueries({ queryKey: ['interviews'] });
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
},

View File

@@ -5,7 +5,18 @@
const clamp = (n) => Math.max(0, Math.min(100, n));
export function computeKrowScore(profile) {
const attendance = clamp(profile.attendance_score ?? 100);
/**
* Attendance defaults to 100 for *display*: an unrated worker shows a full bar
* rather than an accusatory 0. That default must not become a scoring input.
*
* With no shifts behind it, attendance is an assumption, and it carries 30% of
* reliability and 12% of the KROW score — enough on its own to lift a profile
* with no evidence whatsoever from 0 to 12, out of "Not yet scored" and into
* the low-scoring band, where every ranking then reads it as a poor worker
* rather than an unassessed one. `shifts_completed` is the record it needs.
*/
const hasAttendanceRecord = (profile.shifts_completed || 0) > 0;
const attendance = hasAttendanceRecord ? clamp(profile.attendance_score ?? 100) : 0;
const performance = clamp(profile.performance_score ?? 0);
const education = clamp(
(profile.completed_courses?.length || 0) * 12 +
@@ -34,7 +45,20 @@ export function computeKrowScore(profile) {
return {
krow_score,
reliability,
breakdown: { attendance, performance, education, clientReviews, supervisorReviews, growth, experience },
/* Rounded for output only — the weighted sums above use the exact values.
These are percentages a person reads, and (4.4 / 5) * 100 is
88.00000000000001 in binary floating point. */
breakdown: {
/* null, not 0, where there is no record — the product's own rule for an
absent dimension, so a reader renders "—" instead of a flat zero. */
attendance: hasAttendanceRecord ? Math.round(attendance) : null,
performance: Math.round(performance),
education: Math.round(education),
clientReviews: Math.round(clientReviews),
supervisorReviews: Math.round(supervisorReviews),
growth: Math.round(growth),
experience: Math.round(experience),
},
};
}
@@ -50,7 +74,11 @@ export function computeProfileCompletion(profile) {
if (profile.completed_courses?.length) filled++;
if (profile.earned_badges?.length) filled++;
if (profile.ai_interview_score > 0) filled++;
const total = CORE_FIELDS.length + 5;
/* Seven optional fields can increment above, not five: languages,
availability, skills, experience, completed_courses, earned_badges and the
interview score. With a denominator of five the percentage ran past 100 —
the most complete seeded profile computed 107% complete. */
const total = CORE_FIELDS.length + 7;
return Math.round((filled / total) * 100);
}

View File

@@ -1,4 +1,5 @@
import { SUPPORTED_PERIODS, periodLabel } from './surfaces';
import { atOrBeyond, HIRED_STATUSES } from '@/lib/hiringRecords';
import { CRITERIA_LABELS } from '@/lib/positionModel';
import { poolFor } from '@/lib/workforce';
import { candidateRoute } from './workforceFlow';
@@ -108,13 +109,8 @@ function matchDetail(row) {
return parts.join(' · ');
}
/** The application stages this product counts, in the order they happen. */
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
const atOrBeyond = (applications, stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
};
/* The stage ladder lives in hiringRecords: one derivation, so a skill and the
page it is read beside cannot disagree about how many were screened. */
/** Applications counted by period — the reading behind an activity section. */
function activityOverTime(applications, periods, now) {
@@ -152,7 +148,7 @@ function pipelineOf(applications) {
{ id: 'screened', label: 'Screened', value: atOrBeyond(applications, 'ai_screened').length },
{ id: 'shortlisted', label: 'Shortlisted', value: atOrBeyond(applications, 'shortlisted').length },
{ id: 'interview', label: 'Interview', value: atOrBeyond(applications, 'interview').length },
{ id: 'hired', label: 'Hired', value: applications.filter((a) => a.status === 'hired').length },
{ id: 'hired', label: 'Hired', value: applications.filter((a) => HIRED_STATUSES.includes(a.status)).length },
];
return {
@@ -304,7 +300,7 @@ const RESOLVERS = {
...interviews
.filter((i) => i.application_id === candidate?.id)
.map((i) => ({ id: i.id, title: 'Interview', detail: i.status, at: i.created_date })),
candidate?.status === 'hired' && {
HIRED_STATUSES.includes(candidate?.status) && {
id: 'hired', title: 'Hired', detail: candidate.job_title, at: candidate.updated_date,
},
].filter(Boolean);
@@ -437,7 +433,7 @@ const RESOLVERS = {
* applications and the staff records they became, never stored.
*/
'hires.performance': ({ applications = [], staff = [] }) => {
const hired = applications.filter((a) => a.status === 'hired');
const hired = applications.filter((a) => HIRED_STATUSES.includes(a.status));
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
const days = hired
.map((a) => Math.round((new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / 86400000))

View File

@@ -44,7 +44,19 @@ const PER_SKILL = 3;
const TOTAL = 4;
/**
* The chips a page's skills offer.
* The chips a page's skills *declare*.
*
* NOT what the panel renders, and no longer on the production path. Which
* suggestions Owliver offers is decided by `GET /api/v1/owliver/suggestions` —
* the backend filters against the caller's role and ranks against the rows in
* PostgreSQL, and `lib/skills/serverSuggestions.js` turns the reply into chips.
* Nothing in `src/` ranks a suggestion any more.
*
* What is left here is the reading of a *definition*: given a skill file, what
* did its author write under `owliver.suggestions`, and which of those name a
* capability the skill actually offers. That is a question about the Markdown
* rather than about the workspace, and it is what the editor's preview and the
* definition checks in `scripts/skill-check.mjs` ask.
*
* Capped deliberately. A workspace with six skills attached would otherwise
* bury the page's own suggestions under twenty of them, and a suggestion nobody
@@ -365,52 +377,3 @@ export function presentable(data) {
export const CAPABILITY_SUMMARIES = OWLIVER_CAPABILITIES.map(
({ id, label, summary }) => ({ id, label, summary })
);
/* ── Suggestions for a record that has just appeared ────────────────────── */
/**
* What can now be asked about a record the conversation just produced.
*
* Creating a position is the moment "who could do this?" becomes worth asking,
* and the panel is the only thing that knows a position now exists. Rather than
* naming a skill to offer — which would put a candidate-matching feature inside
* the position-creation flow — this asks the registry the general question: of
* the skills attached to this page, which declare a capability whose reading is
* *about one position*? Those are exactly the ones that can say something about
* the record just made.
*
* The record's own title is appended to each prompt, so the answer resolves
* against it directly and the reader is never asked to pick from a list that
* includes the position they are looking at. Nothing is named here: a skill
* added tomorrow that reads a position is offered on the same terms.
*/
export function suggestionsForPosition(contextId, disabled = [], customSources = [], position) {
if (!position?.title) return [];
const chips = [];
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
/* Only capabilities that read one position — a workspace-wide reading has
nothing to do with the record that was just created. */
const scoped = skill.owliver.capabilities.filter(
(capability) => skill.owliver.responses[capability]?.context === 'positionId'
);
if (!scoped.length) continue;
const offered = skill.owliver.suggestions
.filter((s) => !s.capability || scoped.includes(s.capability))
.slice(0, 1)
.map((s) => ({
label: s.label,
/* Named, so the reading resolves against this position rather than
asking which one. */
prompt: `${s.prompt} for ${position.title}`,
skillId: skill.id,
skillCapability: s.capability || null,
}));
chips.push(...offered);
}
return chips.slice(0, 2);
}

View File

@@ -477,10 +477,25 @@ export function positionCreatedReply(position) {
);
}
/** The write failed. The draft is kept, so the answers are not lost. */
export function positionFailedReply() {
/**
* The write failed. The draft is kept, so the answers are not lost.
*
* The server's own message is said out loud when there is one. This used to be
* a fixed sentence, and a fixed sentence is the wrong answer to three different
* failures: a validation error naming a field, a permission refusal, and an API
* that is not running all read as "I could not create that position", leaving
* the reader to guess which of the three they are looking at and what to change.
*
* The message comes from `KrowApiError.message`, which `httpClient` sets to the
* server's wording verbatim — so the reason is the API's, not one invented here
* from a status code.
*/
export function positionFailedReply(reason = null) {
const said = String(reason || '').trim();
return doc(
text('I could not create that position.'),
said ? note(said) : null,
note('Nothing was saved. Choose Create position to try again.')
);
}

View File

@@ -0,0 +1,93 @@
import { getContext } from '@/components/ai-assistant/contexts';
/**
* The server's suggestions, as chips the panel can already run.
*
* This module is the whole of the frontend's part in suggestions, and it is
* deliberately small. `GET /api/v1/owliver/suggestions` decides *which*
* questions are worth offering and *in what order* — against the caller's role
* and, when nothing has been typed, against the organization's actual state in
* PostgreSQL. Nothing here re-decides any of that. There is no score, no
* threshold, no reordering and no filter: the list arrives ranked and leaves in
* the same order it arrived.
*
* What is left to do is a translation, and only one. A suggestion names an
* `intent`, which is the id of one of the page's own capabilities — the same id
* the manifests in `components/ai-assistant/capabilities/` declare, held to the
* server's catalogue by `TestIntentIDsAreFrontendCapabilities`. A chip carrying
* that id in its `capability` field runs that capability directly, through the
* path a page chip has always taken. So this maps `intent` onto `capability`
* and hands the chip back.
*
* An `intent` the page does not declare is not dropped and not guessed at: the
* chip is handed over as an ordinary question, and the existing intent routing
* answers it exactly as the same words typed by hand would be. One path, and a
* server that names something this build has not shipped yet degrades to asking
* rather than to a dead chip.
*/
/**
* The capability ids a page context declares.
*
* Read from the same manifest the panel dispatches on, so "can this chip run
* directly" is answered by the thing that would have to run it rather than by a
* list kept alongside.
*/
function capabilityIds(contextId) {
const context = getContext(contextId);
return new Set((context?.capabilities || []).map((capability) => capability.id));
}
/**
* One server suggestion as a chip.
*
* `label` and `prompt` are both the server's text. They are the same string on
* purpose: the text *is* the question, so showing one wording and sending
* another would mean the reader clicked something other than what ran.
*
* `shape` carries the server's `capability` — the section type the query asked
* to be drawn as ("as a flow", "as a table"). It rides along for the renderer
* and is deliberately not merged into `capability`, which names the reading
* rather than its drawing; conflating the two would dispatch a question about
* hiring activity to a capability called `flow` that does not exist.
*/
export function suggestionChip(suggestion, declared) {
const text = String(suggestion?.text || '').trim();
if (!text) return null;
const intent = String(suggestion?.intent || '').trim();
const shape = String(suggestion?.capability || '').trim();
return {
label: text,
prompt: text,
/* Only when the page can actually run it. Anything else is asked. */
...(intent && declared.has(intent) ? { capability: intent } : {}),
...(shape ? { shape } : {}),
/* Provenance, so a chip that came from the API is distinguishable from a
follow-up an answer raised. Nothing dispatches on it; it exists because
"where did this suggestion come from" is the first question anyone asks
of a suggestion that looks wrong. */
source: 'api',
};
}
/**
* A whole response, in the order the server ranked it.
*
* Returns a new array only when there is something in it, so an empty answer is
* referentially stable and a composer that matches nothing does not rerender
* the chip row on every keystroke.
*/
const NONE = [];
export function suggestionChips(suggestions = [], contextId = null) {
if (!Array.isArray(suggestions) || !suggestions.length) return NONE;
const declared = capabilityIds(contextId);
const chips = suggestions
.map((suggestion) => suggestionChip(suggestion, declared))
.filter(Boolean);
return chips.length ? chips : NONE;
}

View File

@@ -14,6 +14,7 @@ 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 { useToolCatalogue } from '@/lib/agents/agentStore';
import { AgentSkillWorkspace } from '@/components/agents/skills/AgentSkillWorkspace';
import { AgentTestPanel } from '@/components/agents/AgentTestPanel';
import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
@@ -81,6 +82,10 @@ export default function AdminAgentDetail() {
agents, saving, isShipped, isOverridden, sourceFor, save, publish,
} = useAgents();
/* The tools this deployment registers. Fetched once and cached: the set
changes when the backend is deployed, not while a form is open. */
const { data: toolCatalogue = [] } = useToolCatalogue();
const creating = !id;
/* The definition this screen started from: a stored or shipped one, or a
@@ -421,6 +426,14 @@ export default function AdminAgentDetail() {
onChange={onChange}
onOpenSkills={() => setView('skills')}
scopeLocked={shipped}
/* The same write path the catalog uses. Configure now lists skills
beside every other capability, and both screens agree about when
one takes effect. */
onToggleSkill={onToggleSkill}
pendingSkill={pendingSkill}
/* What this agent may actually call. Served by the backend, so the
options are the tools that exist rather than a copy kept here. */
toolCatalogue={toolCatalogue}
/>
)}
{view === 'skills' && (

View File

@@ -140,9 +140,14 @@ export default function AdminTalentPool() {
...s,
count: s.members.length,
available: s.members.filter((p) => (p.availability || []).length > 0).length,
avgExperience: s.members.length
? Math.round(s.members.reduce((sum, p) => sum + (p.experience_years || 0), 0) / s.members.length)
: 0,
avgExperience: (() => {
/* 0 years is "not stated", not "first year" — the cards that show it
gate on experience_years > 0, so the average is over the same set. */
const stated = s.members.filter((p) => (p.experience_years || 0) > 0);
return stated.length
? Math.round(stated.reduce((sum, p) => sum + p.experience_years, 0) / stated.length)
: 0;
})(),
}));
}, [profiles]);