update agents skill design

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@
import { isUnlocked } from '@/lib/provingGround';
import { getScoreBand } from '@/lib/talentHome';
import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
@@ -26,10 +27,12 @@ const DAY_MS = 1000 * 60 * 60 * 24;
/**
* Events an auditor looks at first: they change who is employed or what is being
* hired for. Kept here so the fact sheet, the Activity page's severity column and
* the assistant's security answers all agree on what "privileged" means.
* hired for. Defined in `lib/activitySignals.js` alongside the detection that
* uses it, and re-exported here because the fact sheet, the Activity page's
* severity column and the assistant's security answers all import it from this
* module and must keep agreeing on what "privileged" means.
*/
export const PRIVILEGED_EVENTS = ['hire_candidate', 'create_position'];
export { PRIVILEGED_EVENTS };
/** `today` is injected rather than read from the clock so answers are stable. */
export function buildFacts({
@@ -119,64 +122,15 @@ export function buildFacts({
/**
* Out-of-pattern activity.
*
* Detected here rather than in the assistant so the greeting, the answer and
* any future page section all count the same things. "Two unusual patterns"
* has to mean the same two everywhere it is said.
* Computed by `lib/activitySignals.js` rather than here, because it now has
* a second reader: the `activity.signals` data source resolves it for a card
* or an answer, and `dataResolver.js` cannot import from this directory
* without a library reaching up into the component tree.
*
* Each signal is a deviation from this platform's own baseline, not a verdict:
* on a live deployment most of them resolve to an integration or a busy
* afternoon, and the copy that renders them says so.
* Moved, not changed. "Two unusual patterns" has to mean the same two
* everywhere it is said, and one function is the only way to guarantee that.
*/
const activitySignals = (() => {
const privileged = activity.filter((e) => PRIVILEGED_EVENTS.includes(e.event_type));
const perUser = activity.reduce((acc, e) => {
acc[e.user_email] ||= { email: e.user_email, name: e.user_name, count: 0, privileged: 0 };
acc[e.user_email].count += 1;
if (PRIVILEGED_EVENTS.includes(e.event_type)) acc[e.user_email].privileged += 1;
return acc;
}, {});
const accounts = Object.values(perUser).sort((a, b) => b.count - a.count);
const busiest = accounts[0] || null;
const busiestShare = busiest ? pct(busiest.count, activity.length) : 0;
/* A burst is more than three actions from one account inside one hour. */
const perAccountHour = activity.reduce((acc, e) => {
const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`;
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
const bursts = Object.values(perAccountHour).filter((n) => n > 3).length;
const offHours = activity.filter((e) => {
const hour = new Date(e.created_date).getHours();
return hour < 6 || hour >= 22;
});
const privilegedShare = pct(privileged.length, activity.length);
/* The flags, in the order they are worth reading. A count of these is what
the greeting reports, so anything added here changes that number. */
const flags = [
activity.length > 0 && busiestShare >= 50 && 'concentration',
bursts > 0 && 'burst',
offHours.length > 0 && 'off-hours',
activity.length > 0 && since(1).length === 0 && 'silent',
privilegedShare > 30 && 'privileged-share',
].filter(Boolean);
return {
accounts,
busiest,
busiestShare,
bursts,
offHours,
privileged,
privilegedShare,
flags,
};
})();
const signals = activitySignals(activity, today);
/* ── KROW Forge ──────────────────────────────────────────────────────────
Derived with the same gate (`isUnlocked`) and the same completion record the
@@ -386,7 +340,7 @@ export function buildFacts({
activity7d: since(7),
activity24h: since(1),
eventCounts,
activitySignals,
activitySignals: signals,
/* Signed-in worker, and the pages built on these records */
profile,

View File

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

View File

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

View File

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

View File

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