agnets done

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

54
.env
View File

@@ -1,41 +1,19 @@
# ============================================================================ # Local development against the deployed backend.
# Krow frontend — example environment
# #
# Copy to .env and adjust. .env is gitignored. # The dev server proxies /api and /health to VITE_API_PROXY_TARGET, so the app
# # talks to the deployment through its own origin — which is what keeps the
# cp .env.example .env # session cookie (Secure, SameSite=Lax, host-scoped) working in the browser.
#
# Vite only exposes variables prefixed with VITE_ to client code, and it reads
# these at build/dev-server start — changing one needs a restart, not a reload.
# ============================================================================
# Where the browser sends API requests.
#
# A SAME-ORIGIN PATH, not a host. The browser asks its own origin for
# /api/v1/..., and something on that origin forwards it to the Go API:
# in development the Vite proxy (see `server.proxy` in vite.config.js), in
# production the same web server that serves the built assets (see nginx.conf).
#
# browser → localhost:5173/api/v1 → Vite proxy → 127.0.0.1:8080/api/v1
#
# THIS MUST STAY A PATH. Pointing it at http://127.0.0.1:8080/api/v1 makes every
# request cross-site, and the session cookie stops working in two separate ways:
#
# 1. The cookie is SameSite=Lax, and a Lax cookie is not sent on a cross-site
# subresource request. A browser treats localhost:5173 and 127.0.0.1:8080
# as different sites, so the cookie would be set at login and then never
# sent again.
# 2. Because the transport sends `credentials: 'include'`, the browser
# requires `Access-Control-Allow-Credentials: true` on the preflight
# response. The API does not send it — deliberately, because the supported
# arrangement is same-origin — so Chrome discards the preflight and never
# dispatches the real request. The symptom is an OPTIONS that answers 204
# followed by a POST that never reaches the server at all.
#
# Both failures are silent from the page's point of view, which is why this
# comment is longer than the value.
VITE_API_BASE_URL=/api/v1 VITE_API_BASE_URL=/api/v1
# Where the Vite dev proxy forwards /api. Only read by vite.config.js, never by
# client code.
VITE_API_PROXY_TARGET=https://mcp.krowforce.com VITE_API_PROXY_TARGET=https://mcp.krowforce.com
# Owliver's agent endpoint.
#
# Relative FOR DEVELOPMENT ONLY: vite proxies it to VITE_API_PROXY_TARGET above.
# The production image cannot use a relative value — nginx serves it as a static
# path and answers a run POST with 405 — so Dockerfile passes an absolute URL as
# a build arg instead. Changing this file does not change what ships.
#
# Empty is what it was, and an empty value is not a broken panel: the provider
# answers every question with "Owliver is not configured on this deployment",
# deliberately, because a panel that quietly does nothing is harder to diagnose.
VITE_AGENT_API=/api/v1

View File

@@ -56,3 +56,22 @@ VITE_API_BASE_URL=/api/v1
# Where the Vite dev proxy forwards /api. Only read by vite.config.js, never by # Where the Vite dev proxy forwards /api. Only read by vite.config.js, never by
# client code. # client code.
VITE_API_PROXY_TARGET=https://mcp.krowforce.com VITE_API_PROXY_TARGET=https://mcp.krowforce.com
# ── The agent runtime ───────────────────────────────────────────────────────
#
# Set this and the assistant panel stops simulating and starts calling the real
# backend: a model, the tool layer, permission-aware retrieval, and a write path
# that cannot act without a person approving it.
#
# VITE_AGENT_API=/api/v1
#
# Same-origin path, like VITE_API_BASE — the proxy above forwards it. Unset by
# default, deliberately: with no value the panel uses the local simulator, which
# is deterministic, costs nothing, and is what every check in `npm test` runs
# against. Making the real API the default would make the whole suite depend on
# a credential.
#
# The backend must be running with ANTHROPIC_API_KEY set. Without one it does
# not register the run routes at all, and the panel will report that no agent is
# available — which is true, rather than a broken-looking 500.
VITE_AGENT_API=

7
.gitignore vendored
View File

@@ -1,4 +1,9 @@
#env # Local configuration is never committed. This rule read "#env" — commented out,
# so it matched nothing and every .env in this directory was committable. The
# `!.env.example` negation below only makes sense against a rule that excludes
# them, which is what gave it away.
.env
.env.*
# ...but the template belongs in the repository: it is the one place the API's # ...but the template belongs in the repository: it is the one place the API's
# location is documented, and a checkout with no .env needs it. # location is documented, and a checkout with no .env needs it.

View File

@@ -33,6 +33,21 @@ COPY . .
ARG VITE_API_BASE_URL=https://mcp.krowforce.com/api/v1 ARG VITE_API_BASE_URL=https://mcp.krowforce.com/api/v1
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
# Owliver's agent endpoint, for exactly the same reason and with exactly the
# same failure if it is missing.
#
# In development this is left relative and vite's proxy carries it to the
# backend, which is why the .env in the repository sets `/api/v1`. That value
# cannot reach this build — .dockerignore excludes .env* — and a relative path
# here would be served by the nginx stage as a static file, so a run POST would
# come back 405 Method Not Allowed rather than an answer.
#
# Empty is not neutral either: with no value the panel reports that no agent is
# configured, which is honest but is not what a deployment carrying agents
# should say.
ARG VITE_AGENT_API=https://mcp.krowforce.com/api/v1
ENV VITE_AGENT_API=$VITE_AGENT_API
RUN npm run build RUN npm run build
# ---- Runtime stage ---- # ---- Runtime stage ----

View File

@@ -263,14 +263,21 @@ ids, so they are suppression preferences over a namespace, not definitions.
`GET /api/v1/owliver/suggestions?page={surface}&query={typed}` — specified in `GET /api/v1/owliver/suggestions?page={surface}&query={typed}` — specified in
the backend contract's §2A. the backend contract's §2A.
**Called, registered in backend source, and NOT deployed.** This is the one row **Called, registered in backend source, and NOT deployed on every host.** This
in this document where those three come apart, so it is worth stating plainly: is the one row in this document where those three come apart, so it is worth
stating plainly:
| | | | | |
| --- | --- | | --- | --- |
| Frontend calls it | `src/api/base44Client.js:300`, via `useSuggestions.js` | | Frontend calls it | `src/api/base44Client.js` (`base44.owliver.suggestions`) → `src/lib/krowHooks.js` (`fetchOwliverSuggestions`, `useOwliverSuggestions`) → `src/lib/skills/serverSuggestions.js` → `KrowAssistant.jsx` |
| Backend source registers it | `go-api/internal/httpserver/owliver.go:22`, commit `b6f8655`, pushed to `origin/main` | | Backend source registers it | `go-api/internal/httpserver/owliver.go:22` |
| `https://mcp.krowforce.com` serves it | **No — 404** | | `https://mcp.krowforce.com` serves it | **No — 404 as of this writing** |
The endpoint now also builds real context: with no `query`, the backend counts
the organization's positions, applications, interviews, staff, profiles,
courses and activity in one statement and ranks the page's readings against
them, so a suggestion changes when the data does. See the backend contract's
§2A.
So a signed-in dev server pointed at `mcp.krowforce.com` answers this route So a signed-in dev server pointed at `mcp.krowforce.com` answers this route
`404 Not Found`, and no prompt chips appear. **That is a deployment lag, not a `404 Not Found`, and no prompt chips appear. **That is a deployment lag, not a
@@ -291,10 +298,12 @@ reaching for the frontend code again:
means the session is not signed in; `404` means the session is fine and the means the session is not signed in; `404` means the session is fine and the
route is not on that backend. route is not on that backend.
The panel treats the two differently as of `useSuggestions.js`: a `404` closes a The panel treats the two differently, in `fetchOwliverSuggestions`
circuit breaker and it stops asking for the rest of the page load, because no (`src/lib/krowHooks.js`): a `404` closes a circuit breaker and it stops asking
further keystroke can change the answer. A `401`, a `500` and an unreachable API for the rest of the page load, because no further keystroke can change the
are all still retried. answer. A `401`, a `500` and an unreachable API are all still retried. Against a
host that does not serve the route the chip row is simply empty — the panel, the
composer and every answer are unaffected.
The endpoint replaces the *selection*, not The endpoint replaces the *selection*, not
the answering: it returns at most three `{ text, intent }` pairs, where `intent` the answering: it returns at most three `{ text, intent }` pairs, where `intent`
@@ -636,8 +645,8 @@ Recorded, not fixed — each belongs in its own repo.
| Where | Issue | | Where | Issue |
| --- | --- | | --- | --- |
| `krow-backend/docs/api-contract.md` §1 | Says "Auth: None in v1. Every endpoint is unauthenticated." Sessions and cookie middleware have been implemented since. | | `krow-backend/docs/api-contract.md` §1 | Says "Auth: None in v1. Every endpoint is unauthenticated." Sessions and cookie middleware have been implemented since. |
| `krow-demo/src/api/store.js` | 173 lines, **imported by nothing**. Superseded by `httpClient.js`. Dead. | | ~~`krow-demo/src/api/store.js`~~ | **Fixed.** The file is deleted. It was 173 lines and imported by nothing. |
| `krow-demo/src/api/seed.js` | 1,928 lines, retained for one export — `DEMO_USER.preferences`, the default shape the synchronous accessor needs. Entity data lives in PostgreSQL. | | ~~`krow-demo/src/api/seed.js`~~ | **Fixed.** The default user shape moved to `src/api/demoUser.js`, so no production module imports the fixture and its 1,928 lines are out of the bundle. `seed.js` remains, serving `scripts/skill-check.mjs` and `scripts/owliver-capture.mjs` as the test fixture it now is. |
| `krow-demo/src/lib/krowHooks.js:537` | `useBadges` has no consumers and no route. | | `krow-demo/src/lib/krowHooks.js:537` | `useBadges` has no consumers and no route. |
| `krow-demo/src/api/httpClient.js:96` | `User: 'users'` names a resource the backend does not have. No call site, so harmless today. | | `krow-demo/src/api/httpClient.js:96` | `User: 'users'` names a resource the backend does not have. No call site, so harmless today. |

View File

@@ -9,6 +9,8 @@
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "node scripts/skill-check.mjs", "test": "node scripts/skill-check.mjs",
"seed:fixture": "node scripts/seed-fixture.mjs --write",
"seed:check": "node scripts/seed-fixture.mjs",
"typecheck": "tsc -p ./jsconfig.json", "typecheck": "tsc -p ./jsconfig.json",
"preview": "vite preview" "preview": "vite preview"
}, },

View File

@@ -0,0 +1,65 @@
# Owliver behaviour baseline
`owliver-baseline.json` records how the panel routed and answered before the
agent layer existed. `skill-check.mjs` asserts against it on every run.
Regenerating it is a deliberate act, and the reason belongs here.
## 2026-08-27 — the seed gained the three statuses nothing exercised
`application_status` has seven values. The fixture produced four: `applied`,
`ai_screened`, `interview`, `hired`. `shortlisted`, `rejected` and `assigned`
existed only in the schema, and `Assignment` shipped empty by design.
That gap was hiding real defects, all found the same week and none visible
against the old data:
- `atOrBeyond` ranks a status by its index in `STAGE_ORDER`, which listed five
of the seven. `rejected` and `assigned` scored -1 and dropped out of *every*
bucket including `applied`, so a position's funnel lost people and a role
whose candidates had all been assigned read as unfilled.
- The agent's `candidates_awaiting` excluded `hired` and `rejected` from "still
in the running", so somebody already working a shift was offered as a person
to chase.
- `insights.stalled` counted "screened and waiting on a decision" as
`status === 'ai_screened'` only, silently dropping the shortlisted — the
people that phrase most describes. Caught by this regeneration, not before it.
**The change:** three existing applications were converted, not added, so every
regression anchor holds — 24 applications, 9 scored averaging 76, 3 staff,
8 postings, all unchanged.
app_kevin applied → rejected (never scored; a hard requirement)
app_sofia ai_screened → shortlisted (the strongest screened candidate)
app_marco hired → assigned (hired, then rostered)
Plus one `Assignment` for Marco — the minimum that makes `assigned` real. Eight
of the nine positions still have none, so every reader still meets the empty
case.
**What drifted, verified before regenerating:** one number, in four prompts.
Kevin left `applied`, so the unscored count reads 9 where it read 10:
"Show the 10 applications waiting for review" → "…9 applications…"
"10 have no score" / "10 unscored applicants" / "10 unscored applications"
Nothing else moved. `1 waiting on a decision` briefly became a fallback prompt
and that was the `stalled` defect above — fixed in `insights.js` rather than
absorbed into the baseline, and the prompt came back on its own.
## 2026-08-27 — the local simulator was removed
The panel used to answer from two places: an agent, and ~3,300 lines of
browser-side templates. Two paths behind one avatar meant the same question got
different answers depending on phrasing, so the templates were deleted.
**What actually drifted, verified before regenerating:** one thing. The
"I do not have that on this page" decline used to list the page's capability
labels as bullets — those labels were the simulator's canned readings and went
with it. It now names the page's topics in a sentence.
doc shape: ["text","list","note"] → ["text","text","note"]
Every intent `kind` was unchanged. No routing moved, no skill matching changed.
That is why this regeneration was safe: the diff was read first, and it was one
cosmetic change on a path that only runs when no agent is configured at all.

View File

@@ -2,16 +2,29 @@
"schema": 1, "schema": 1,
"today": "2026-08-20T09:00:00.000Z", "today": "2026-08-20T09:00:00.000Z",
"skillIds": [ "skillIds": [
"activity-analysis",
"analytics-insights", "analytics-insights",
"anomaly-detection",
"attendance-analysis",
"bartending-training", "bartending-training",
"candidate-analysis",
"candidate-search", "candidate-search",
"create-position", "create-position",
"customer-service-training", "customer-service-training",
"executive-summary",
"food-safety-training", "food-safety-training",
"forge-skill-management", "forge-skill-management",
"hiring-activity-assistant", "hiring-activity-assistant",
"hiring-history-analysis",
"hiring-pulse-analysis",
"leadership-training", "leadership-training",
"server-training" "learning-analysis",
"operational-risk",
"overtime-analysis",
"server-training",
"staffing-risk",
"talent-pool-analysis",
"workforce-analytics"
], ],
"diagnostics": [], "diagnostics": [],
"routes": { "routes": {
@@ -24,15 +37,35 @@
"/admin/positions": "admin.positions", "/admin/positions": "admin.positions",
"/admin/positions/new": "admin.createPosition", "/admin/positions/new": "admin.createPosition",
"/admin/profile": "admin.profile", "/admin/profile": "admin.profile",
"/admin/settings": "admin.settings",
"/admin/talent-pool": "admin.talentPool", "/admin/talent-pool": "admin.talentPool",
"/admin/university": "admin.forge" "/admin/university": "admin.forge",
"/admin/workspace": "admin.workspace",
"/admin/workspace/agents": "admin.workspaceAgents",
"/admin/workspace/agents/new": "admin.agentConfigure",
"/admin/workspace/skill-development": "admin.skillDevelopment",
"/admin/workspace/skills": "admin.workspaceSkills",
"/admin/workspace/skills/new": "admin.skillConfigure"
}, },
"contexts": { "contexts": {
"admin.controlCenter": { "admin.controlCenter": {
"pageKey": "control-center", "pageKey": "control-center",
"route": "/admin", "route": "/admin",
"skills": [], "skills": [
"suggestions": [], "anomaly-detection",
"attendance-analysis",
"executive-summary",
"hiring-pulse-analysis",
"operational-risk",
"overtime-analysis",
"staffing-risk"
],
"suggestions": [
"Compare attendance by person",
"How is attendance?",
"Is anything unusual?",
"Show the signals"
],
"prompts": [ "prompts": [
"What needs attention?", "What needs attention?",
"Platform health", "Platform health",
@@ -118,7 +151,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
} }
@@ -129,16 +162,19 @@
"route": "/admin/positions", "route": "/admin/positions",
"skills": [ "skills": [
"create-position", "create-position",
"hiring-activity-assistant" "hiring-activity-assistant",
"staffing-risk"
], ],
"suggestions": [ "suggestions": [
"Show hiring activity as a flow", "Show hiring activity as a flow",
"Summarize hiring activity for this position" "Summarize hiring activity for this position",
"Summarize staffing risk",
"Which roles are at risk?"
], ],
"prompts": [ "prompts": [
"What needs attention?", "What needs attention?",
"Which position has the strongest pipeline?", "Which position has the strongest pipeline?",
"Show the 10 applications waiting for review", "Show the 9 applications waiting for review",
"Summarize hiring activity across all positions" "Summarize hiring activity across all positions"
], ],
"intents": [ "intents": [
@@ -162,7 +198,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -226,7 +262,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
} }
@@ -254,7 +290,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -278,7 +314,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -328,7 +364,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
} }
@@ -338,14 +374,19 @@
"pageKey": "candidates", "pageKey": "candidates",
"route": "/admin/candidates", "route": "/admin/candidates",
"skills": [ "skills": [
"candidate-analysis",
"candidate-search" "candidate-search"
], ],
"suggestions": [], "suggestions": [
"How strong is the candidate pool?",
"Score bands",
"Show candidates by score"
],
"prompts": [ "prompts": [
"1 waiting on a decision", "1 waiting on a decision",
"Who are the strongest candidates?", "Who are the strongest candidates?",
"Which candidates are ready for interview?", "Which candidates are ready for interview?",
"10 have no score", "9 have no score",
"Summarize the candidate pipeline", "Summarize the candidate pipeline",
"Identify candidate risks" "Identify candidate risks"
], ],
@@ -380,7 +421,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -427,7 +468,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
} }
@@ -437,13 +478,18 @@
"pageKey": "candidates-analysis", "pageKey": "candidates-analysis",
"route": "/admin/candidates-analysis", "route": "/admin/candidates-analysis",
"skills": [ "skills": [
"candidate-analysis",
"candidate-search" "candidate-search"
], ],
"suggestions": [], "suggestions": [
"How strong is the candidate pool?",
"Score bands",
"Show candidates by score"
],
"prompts": [ "prompts": [
"Compare Chef & Marcus", "Compare Chef & Marcus",
"1 missing credentials", "1 missing credentials",
"10 unscored applicants", "9 unscored applicants",
"Recommend next actions", "Recommend next actions",
"Recruitment insights" "Recruitment insights"
], ],
@@ -458,7 +504,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -472,7 +518,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -486,7 +532,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -536,7 +582,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
} }
@@ -545,8 +591,13 @@
"admin.hiredHistory": { "admin.hiredHistory": {
"pageKey": "hired-history", "pageKey": "hired-history",
"route": "/admin/hired", "route": "/admin/hired",
"skills": [], "skills": [
"suggestions": [], "hiring-history-analysis"
],
"suggestions": [
"How is hiring performance?",
"Who did we hire recently?"
],
"prompts": [ "prompts": [
"3 hires unrated", "3 hires unrated",
"Bartender leads on hires", "Bartender leads on hires",
@@ -565,7 +616,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -579,7 +630,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -593,7 +644,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -646,7 +697,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
} }
@@ -655,8 +706,14 @@
"admin.talentPool": { "admin.talentPool": {
"pageKey": "talent-pool", "pageKey": "talent-pool",
"route": "/admin/talent-pool", "route": "/admin/talent-pool",
"skills": [], "skills": [
"suggestions": [], "talent-pool-analysis"
],
"suggestions": [
"How healthy is the talent pool?",
"Strongest people in the pool",
"Who is in the pool?"
],
"prompts": [ "prompts": [
"Why does Maria rank first?", "Why does Maria rank first?",
"4 profiles unscored", "4 profiles unscored",
@@ -675,7 +732,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -699,7 +756,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -752,7 +809,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
} }
@@ -762,9 +819,13 @@
"pageKey": "krow-forge", "pageKey": "krow-forge",
"route": "/admin/university", "route": "/admin/university",
"skills": [ "skills": [
"forge-skill-management" "forge-skill-management",
"learning-analysis"
],
"suggestions": [
"How is training progressing?",
"What does the library hold?"
], ],
"suggestions": [],
"prompts": [ "prompts": [
"Create a skill training", "Create a skill training",
"40 published", "40 published",
@@ -783,7 +844,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -797,7 +858,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -811,7 +872,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -864,7 +925,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
} }
@@ -874,16 +935,25 @@
"pageKey": "analytics", "pageKey": "analytics",
"route": "/admin/analytics", "route": "/admin/analytics",
"skills": [ "skills": [
"analytics-insights" "analytics-insights",
"attendance-analysis",
"hiring-pulse-analysis",
"overtime-analysis",
"workforce-analytics"
],
"suggestions": [
"Attendance over recent periods",
"Compare attendance by person",
"How is attendance?",
"What is the hiring pulse?"
], ],
"suggestions": [],
"prompts": [ "prompts": [
"What is the hiring trend?", "What is the hiring trend?",
"Which department is performing best?", "Which department is performing best?",
"Biggest drop at Hired", "Biggest drop at Hired",
"Which positions are converting best?", "Which positions are converting best?",
"Summarize hiring performance", "Summarize hiring performance",
"10 unscored applications" "9 unscored applications"
], ],
"intents": [ "intents": [
{ {
@@ -906,7 +976,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -920,7 +990,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -970,7 +1040,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
} }
@@ -979,8 +1049,17 @@
"admin.activity": { "admin.activity": {
"pageKey": "activity", "pageKey": "activity",
"route": "/admin/activity", "route": "/admin/activity",
"skills": [], "skills": [
"suggestions": [], "activity-analysis",
"anomaly-detection",
"operational-risk"
],
"suggestions": [
"Activity over recent periods",
"Is anything unusual?",
"Summarize workspace activity",
"What kinds of event are there?"
],
"prompts": [ "prompts": [
"3 unusual patterns", "3 unusual patterns",
"Summarize today's activity", "Summarize today's activity",
@@ -999,7 +1078,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -1013,7 +1092,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -1076,7 +1155,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
} }
@@ -1105,7 +1184,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -1119,7 +1198,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
}, },
@@ -1179,7 +1258,7 @@
"destination": null, "destination": null,
"doc": [ "doc": [
"text", "text",
"list", "text",
"note" "note"
] ]
} }

72
scripts/seed-fixture.mjs Normal file
View File

@@ -0,0 +1,72 @@
/**
* Writes the backend's seed fixture from the frontend's seed module.
*
* node scripts/seed-fixture.mjs # check: exits non-zero if stale
* node scripts/seed-fixture.mjs --write # regenerate
*
* `src/api/seed.js` is the authored source: it has the helpers, the reasoning in
* comments, and the values a person edits. `seed/fixtures/seed.json` is what the
* Go seeder loads. They used to be maintained in parallel with nothing checking
* them, which is a duplication that only fails quietly — the demo and the API
* would answer the same question differently, and the first symptom would be a
* number on a page disagreeing with a number from an agent.
*
* `ShiftRecord` is deliberately not carried across. Its dates are anchored to
* the day the seeder runs rather than to this file's fixed calendar, so the Go
* side generates it (internal/seeder/shifts.go, a port of attendanceSeed.js).
* A frozen snapshot of it would read as permanently empty a fortnight later.
*/
import { createServer } from 'vite';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
export const FIXTURE_PATH = join(process.cwd(), '..', 'krow-backend', 'seed', 'fixtures', 'seed.json');
/** The fixture the frontend seed implies. Pure — takes the loaded module. */
export const GENERATED_NOTE =
'Generated from krow-demo/src/api/seed.js — do not edit by hand. ' +
'Regenerate with: npm run seed:fixture';
export function fixtureFrom(seedModule) {
const { ShiftRecord, ...entities } = seedModule.seedData;
/* First key, so it is the first thing anyone opening the file reads. The Go
loader unmarshals into a struct of demoUser + entities and ignores the
rest, so this costs nothing on the reading side. */
return { _generated: GENERATED_NOTE, demoUser: seedModule.DEMO_USER, entities };
}
/** Serialised exactly as the committed file is: 2-space indent, no trailing newline. */
export const serialise = (fixture) => JSON.stringify(fixture, null, 2);
export async function buildFixture(server) {
return serialise(fixtureFrom(await server.ssrLoadModule('/src/api/seed.js')));
}
/* ── CLI ──────────────────────────────────────────────────────────────────── */
/* pathToFileURL, not string concatenation: this repo lives under a path with a
space in it, and import.meta.url percent-encodes it while `file://${path}`
does not — the guard silently never fires and the script does nothing. */
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const server = await createServer({
root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
});
const built = await buildFixture(server);
await server.close();
if (process.argv.includes('--write')) {
writeFileSync(FIXTURE_PATH, built);
const { entities } = JSON.parse(built);
const n = Object.values(entities).reduce((s, v) => s + v.length, 0);
console.log(`seed.json written — ${Object.keys(entities).length} collections, ${n} records.`);
} else if (!existsSync(FIXTURE_PATH)) {
console.error('seed.json is missing. Run: node scripts/seed-fixture.mjs --write');
process.exit(1);
} else if (readFileSync(FIXTURE_PATH, 'utf8') !== built) {
console.error('seed.json is stale — it no longer matches src/api/seed.js.');
console.error('Run: node scripts/seed-fixture.mjs --write');
process.exit(1);
} else {
console.log('seed.json is in step with src/api/seed.js.');
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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