import { skillsForContext } from './registry'; import { ACTION_NAMES } from './actions'; /** * Tools, described. * * Boundary 6. This adds **no capability**: every tool here is an action * `actions.js` already performs, and `runAction` remains the only thing that * performs them. What was missing was a description — what a tool does, what it * needs, whether it changes anything, and whether a person should be asked * first. Without that, a caller deciding whether to confirm an action had to * hard-code a list of which ones were dangerous. * * Describing them separately is also what makes them exposable later. A future * MCP surface publishes these descriptors and calls the same `runAction`; * nothing in the business logic moves. That is the whole reason this file is a * table rather than a set of wrappers. * * **The page boundary is inherited, not restated.** `toolsForContext` reads the * skills that are reachable on the current page for the current agent, and * collects what *they* declare. A tool is therefore reachable only when a skill * on this page declares it and the agent carries that skill — so a tool can * never reach data the page was not already offering, and selecting a different * agent can only ever remove tools from that list. */ /** * What each action is, in the terms a person confirming it would need. * * `requiresApproval` is a property of the action, never of the caller: an * action that writes a record needs a person to agree whichever surface asked * for it. `readOnly` actions move the reader somewhere and change nothing. */ export const TOOLS = [ { name: 'create_position', label: 'Create position', summary: 'Writes a new job posting from a draft collected in conversation.', params: ['draft', 'status'], readOnly: false, mutates: 'JobPosting', /* Writes a record other people will act on. Always confirmed. */ requiresApproval: true, }, { name: 'create_employee_role', label: 'Create employee role', summary: "Records what a worker declares they do — role, experience, desired pay and availability — from a conversation.", params: ['draft', 'status'], readOnly: false, mutates: 'EmployeeRole', /* Writes a record ABOUT SOMEBODY ELSE, which is the stronger case for a confirmation rather than the weaker one: the person it names is not the person approving it. */ requiresApproval: true, }, { name: 'open_create_skill_training', label: 'Open Add Skill Training', summary: 'Opens the Forge authoring form, prefilled from the conversation.', params: ['prefill'], readOnly: true, mutates: null, /* Opens a form. Nothing is written until the person submits it, so asking twice would be asking about the same decision twice. */ requiresApproval: false, }, { name: 'open_create_training', label: 'Open Add Training', summary: 'Opens the training authoring form for a course.', params: ['prefill', 'courseId'], readOnly: true, mutates: null, requiresApproval: false, }, { name: 'navigate_to_positions', label: 'Open Positions', summary: 'Takes the reader to the Positions page.', params: [], readOnly: true, mutates: null, requiresApproval: false, }, { name: 'navigate_to_candidates', label: 'Open Candidates', summary: 'Takes the reader to the Candidates page.', params: [], readOnly: true, mutates: null, requiresApproval: false, }, { name: 'navigate_to_forge', label: 'Open KROW Forge', summary: 'Takes the reader to the Forge library.', params: [], readOnly: true, mutates: null, requiresApproval: false, }, { name: 'navigate_to_analytics', label: 'Open Analytics', summary: 'Takes the reader to the Analytics page.', params: [], readOnly: true, mutates: null, requiresApproval: false, }, { /** * Open whichever Krow page a reading belongs to. * * The general form of the four fixed `navigate_to_*` actions above, which * each name one destination. This one takes a page key and resolves it * through the same placement table, so a skill can send the reader to the * page its analysis was about without a new action per destination. * * Still bounded: `routeForPageKey` only knows addresses the product has, * and an unknown key resolves to nothing rather than to a guess. */ name: 'open_related_page', label: 'Open the related page', summary: 'Takes the reader to the Krow page a reading came from.', params: ['page'], readOnly: true, mutates: null, requiresApproval: false, }, ]; const BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool])); /** One tool's description, or null. */ export const describeTool = (name) => BY_NAME.get(name) || null; export const TOOL_NAMES = TOOLS.map((tool) => tool.name); /** Whether an action changes something a person should agree to first. */ export const toolRequiresApproval = (name) => Boolean(BY_NAME.get(name)?.requiresApproval); /** * Every action name a handler exists for but nothing describes. * * A handler with no descriptor is invisible to anything reasoning about tools — * including whatever decides to ask for confirmation — so it would run * unannounced. Asserted in the checks rather than left to review. */ export const undescribedActions = () => ACTION_NAMES.filter((name) => !BY_NAME.has(name)); /** * The tools reachable on this page, for this agent. * * Derived from the scoped skill list, so the page boundary is inherited rather * than re-implemented: a skill the page does not carry contributes no tools, and * a skill the agent does not carry has already been removed from that list by * `agentScopedDisabled`. * * `disabled` is expected to already carry the agent's scoping. Passing the raw * account list yields the page's full tool set, which is what an unscoped * caller should get. */ export function toolsForContext(contextId, disabled = [], customSkills = []) { const reachable = skillsForContext(contextId, disabled, customSkills); const names = new Set(); for (const skill of reachable) { for (const action of skill.actions || []) names.add(action); } return [...names] .map((name) => describeTool(name)) .filter(Boolean) .sort((a, b) => a.label.localeCompare(b.label)); } /** * Whether this tool may run here. * * The check a caller makes before offering a control. Deliberately takes the * resolved list rather than recomputing it, so a caller cannot accidentally ask * the question against a wider scope than the one it rendered from. */ export const toolAllowed = (name, allowed = []) => allowed.some((tool) => tool.name === name); /* ── Typed action intent ────────────────────────────────────────────────── */ /** * How much has to be typed before a partial word counts as an intent. * * Three, and the number is the whole point of the rule. One or two characters * cannot say what somebody meant — "wh" is the start of four unrelated * questions — and offering anything on them is how the composer ended up * interrupting every reader who had already decided what to ask. Three is the * shortest prefix of a real verb, and it still matches nothing unless it is * genuinely the beginning of an action this page can perform. */ const ACTION_INTENT_MIN = 3; /** * A phrase reduced to what it means, so matching is about intent not typing. * * Case, surrounding space and an article are not differences: "Create", * "create" and "create a" are all the beginning of the same request. Kept local * and deliberately tiny — it exists to compare two short phrases, and anything * cleverer would start deciding what a reader meant. */ const canon = (value) => String(value || '') .toLowerCase() .trim() .split(/\s+/) .filter((w) => w && !/^(?:a|an|the)$/.test(w)) .join(' '); /** * The actions this page can perform that the typed text is starting to name. * * Derived, never listed. The phrases come from the skills themselves — a * definition's `prompt:` is the sentence its author wrote for exactly this * purpose — and the candidate set is whatever `skillsForContext` already * resolved, which has the page filter and the agent's scoping applied to it. * So a skill added tomorrow is offered here without this file changing, a skill * switched off in Settings is not offered at all, and there is no second list * of action names to keep in step with the first. * * Only skills that DECLARE an action are eligible. A reading skill has nothing * to autocomplete towards: "which positions are in draft" is a question, and * offering it while somebody types is the generic-catalogue behaviour this * replaced. * * Matched on `canon`, so "Create" reaches "Create a position" — the article and * the case are not differences — and "create position" reaches it too. A prefix * rather than a substring: typing the middle of a phrase is not evidence of * intent, and substring matching is what made every keystroke produce chips. */ export function actionSuggestions(typed, skills = []) { const query = canon(typed); if (query.length < ACTION_INTENT_MIN) return []; const seen = new Set(); const out = []; for (const skill of skills) { if (!skill?.prompt || !skill.actions?.length) continue; const phrase = canon(skill.prompt); if (!phrase.startsWith(query)) continue; if (seen.has(phrase)) continue; seen.add(phrase); out.push({ label: skill.prompt, prompt: skill.prompt }); } return out; }