refactor(ts-migration): Phase 11 batch 7 — the assistant's non-component modules

The eleven `.js` files under `src/components/ai-assistant/`: the blocks
format, contexts, routing, history, placement, viewport, the greeting
and prompt tables in `dynamic`, the derivations in `insights`, and
`uiEdit`, whose boundary batch 1 already typed.

79 errors, and two optional markers cleared 63 of them.

`plural(n, word, irregular)` is called with two arguments sixty times in
`dynamic.ts` and its own body reads `irregular || \`${word}s\``, so the
third parameter has always been optional in everything but the
signature. `heading(value, sub)` is the same: `sub` is spread into the
block and `undefined` is what most callers mean. Marking both optional
is a statement about the existing contract, and the markers erase — the
emitted signatures still read `plural=(n,word,irregular)` and
`heading=(value,sub)`, checked in the output rather than assumed.

Those three `heading` errors landed in `lib/skills/workforceFlow.ts`,
already migrated and untouched here. Worth noting how that works: a
function's arity only starts being enforced on its callers once the file
defining it is TypeScript. Migrating a leaf makes claims about every
file that imports it, which is why this phase moves bottom-up.

The remaining nine were two `reduce` accumulators inferring `{}`, so
`Object.values` over them produced `unknown`. Both are now stated —
`{ label, count }` for the score bands, and the five-field hire grouping
— which is more useful than `any` and exactly what the lines below them
build.

Measured against `dde4ba6`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  83/83 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-18 15:35:15 +05:30
parent dde4ba62c6
commit 3f835eee93
11 changed files with 6 additions and 4 deletions

View File

@@ -0,0 +1,758 @@
/**
* Provider seam for the Owliver dashboard panel.
*
* The UI never generates an answer and never knows where one came from. It calls
* `provider.stream(request)` and renders the snapshots. That single boundary is
* what makes the backend swappable: today a local provider computes answers from
* dashboard data, and later an HTTP provider can call a real service without a
* change to the component.
*
* ── Contract ────────────────────────────────────────────────────────────────
*
* provider.id: string
* provider.stream(request): AsyncIterable<Block[]>
*
* Each yielded value is the response *so far* as an array of blocks (see
* blocks.js). Snapshots rather than deltas because a response is structured:
* a table or a KPI row has no meaningful half-state, and the renderer stays a
* pure function of the latest snapshot.
*
* request = {
* contextId: string, // e.g. 'employer.overview'
* capability: string | null, // capability id, or null for free text
* question: string, // what the user typed, or the chip's prompt
* facts: object, // dashboard fact sheet (see insights.js)
* signal: AbortSignal, // aborts an in-flight response
* }
*/
import { confirmation, note } from './blocks';
/**
* The agent provider: the real runtime, over the real API.
*
* The only provider that answers. Everything that makes that safe lives on the
* server:
*
* - **The principal is the session's.** The body carries a question and
* nothing else about who is asking. The browser cannot name a caller, so it
* cannot ask about records it is not entitled to see.
* - **The tools and corpora are the SPEC's.** Not the request's. An agent
* reads what its published definition says it may read, and no field here
* can widen that.
* - **A write cannot happen from a question.** The backend answers a proposed
* write with a confirmation payload and performs nothing. Approving it is a
* second, explicit call carrying the token — see `confirmation` below.
*
* One snapshot, not a stream. The endpoint is not streaming yet, so this yields
* the finished answer once. The signature is the streaming one because that is
* what the seam is, and switching to real streaming later changes this function
* and nothing else.
*/
export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
return {
id: 'agent',
/**
* Every snapshot leaves through here, and every snapshot is sanitised.
*
* The wrapper is the point. Below it there are four ways a response gets
* built — streamed deltas, a completed run, a bounded run's trailing
* message, and the two failure notes — and only one of them passes through
* the markdown parser that removes citation ids. Sanitising at the yield
* rather than at each construction means a fifth way, added later, cannot
* reintroduce the leak by forgetting a call.
*/
async *stream(request) {
for await (const snapshot of this.run(request)) yield sanitizeBlocks(snapshot);
},
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
/* No agent, no run. The panel resolves which agent covers the page before
calling; reaching here without one means the routing layer changed and
this should say so rather than guess at an agent id. */
if (!agent?.id) {
yield [note('No agent is available for this page.')];
return;
}
const response = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent.id)}/runs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
/* Ask for a stream. The server answers the same run either way — the
final event carries exactly the body the non-streaming path
returns — so a deployment that cannot stream degrades to one late
snapshot rather than to a broken panel. */
Accept: 'text/event-stream, application/json',
},
/* The session cookie. Without it the API answers 401, which is the
correct answer to a browser that is not signed in. */
credentials: 'include',
body: JSON.stringify({
input: question,
confirmation: confirmation || undefined,
/* Pins the conversation to the version it started with. Every answer
comes back carrying its version; sending it on the next turn is what
stops an edit published mid-thread from silently changing which
agent is answering. */
agentVersion: agentVersion || undefined,
}),
signal,
});
if (!response.ok) {
yield [note(await describeFailure(response))];
return;
}
/* Not a stream after all — a proxy that buffers, or a server answering
JSON. Read it whole. */
if (!isEventStream(response) || !response.body) {
yield toBlocks(await response.json());
return;
}
yield* readRunStream(response, signal);
},
};
}
/**
* Whether the server actually opened a stream, rather than answering JSON.
*
* Defensive about `headers` because the answer to "is this a stream" must be
* NO when anything is unexpected. A response shape this does not recognise gets
* read whole, which works; assuming a stream and finding none would hang.
*/
function isEventStream(response) {
const type = response?.headers?.get?.('Content-Type') || '';
return type.includes('text/event-stream');
}
/**
* Reads the run's event stream, yielding the answer as it grows.
*
* Two kinds of event and they are handled very differently:
*
* - `delta` is a fragment of assistant text. The accumulated text is
* re-parsed into blocks on every one, so a half-written answer renders as
* far as it makes sense to — a table mid-construction stays as text until
* its rows arrive, which reads better than a broken table.
* - `run` is the finished result: the same body the non-streaming path
* returns, carrying confirmations, the termination and the token cost.
* Whatever text streamed is replaced by it, because the final snapshot is
* authoritative and the deltas were a preview of it.
*
* A client that ignored every delta and read only the last event would be in
* exactly the state it would have reached without streaming. That is what keeps
* the two paths honest rather than merely similar.
*/
async function* readRunStream(response, signal) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let text = '';
let final = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
/* The last element may be half a line; hold it for the next read. */
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
let event;
try {
event = JSON.parse(payload);
} catch {
/* A malformed frame is dropped rather than rendered. It cannot be
assistant text — the server encodes every event as JSON — so
showing it would put transport noise in front of a reader. */
continue;
}
if (typeof event.delta === 'string') {
text += event.delta;
/* Still arriving: the frontier rules apply, so a citation split
across two frames is never rendered half-written. */
yield markdownToBlocks(text, { partial: true });
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;
}
/* ── Citations ──────────────────────────────────────────────────────────── */
/**
* Why any of this exists.
*
* The backend asks the model to cite: `knowledge/context.go` tells it "Each
* <source> carries an id: cite it when you use what it says", and the
* `knowledge_search` tool repeats it. What neither does is say HOW — so the
* model picks a format, and picks a different one on a different day. The ids
* themselves are `knowledge_chunks.id`, which are UUIDs, and the model quotes
* them whole or truncated to their first block.
*
* The panel has no citation surface to render any of that into, so whatever
* shape the model chose arrives on screen as raw markup. The formats seen so
* far are a `<cite>` tag and a markdown link to an empty anchor; the rules
* below are written against the SHAPE of an identifier rather than against
* either format's syntax, so a third spelling of the same idea is far more
* likely to be caught than to be a new bug.
*
* A citation id is hex and dashes — a UUID or a leading run of one. That is
* what makes `10 applications`, `97% coverage` and `113 shifts` safe: decimal
* counts in prose are not addresses, are never inside citation syntax, and no
* rule here looks at a bare number.
*/
const CITATION_ID = String.raw`[0-9a-f]{4,}(?:-[0-9a-f]{4,})*`;
/**
* The tag spelling — `<cite id="…">…</cite>`, whatever attributes it carries.
*
* Matched by TAG NAME, never by id: the ids are minted per run, so a rule
* written against the ones in today's output would let tomorrow's through.
*/
const CITATION_TAG = /<\/?cit(?:e|ation)\b[^>]*>/gi;
/**
* The link spelling, and the brackets the model wraps a run of them in —
* `([337042b3](#), [2b94bc43](#))`.
*
* Identified by two conditions TOGETHER, never either alone: the target must be
* a bare `#` anchor, AND the label must look like an identifier rather than
* words. A real link has a real href, a real anchor link has a destination
* after the `#`, and a link a person would click has a label they could read.
* Requiring both is what keeps `[staffing policy](#staffing)`, `[Read more](/docs)`
* and even `[Read more](#)` on screen.
*
* The group is removed whole rather than link by link, because removing them
* one at a time leaves `(, )` behind — which reads worse than the ids did.
*/
const CITATION_GROUP = new RegExp(
String.raw`\s*\(\s*\[${CITATION_ID}\]\(#\)(?:\s*,\s*\[${CITATION_ID}\]\(#\))*\s*\)`,
'gi'
);
const CITATION_LINK = new RegExp(String.raw`\s*\[${CITATION_ID}\]\(#\)`, 'gi');
/**
* The prose spelling: the model narrating the attribute rather than marking it
* up — `(id `f34e8ef0-…`)`, `(reference `…`)`, `(source: `…`)`.
*
* Why this exists is the same reason the other two do. `context.go` hands the
* model `<source id="…">` and tells it to cite the id without saying how, so
* the model reaches for whatever syntax feels natural that day. This one is not
* markup at all — it is the id written out in a parenthesis, which is why no
* tag rule and no link rule saw it.
*
* THE WRAPPER IS WHAT IDENTIFIES IT, NEVER THE ID. That distinction is the
* whole rule and it has to survive future edits: a worker's record id is the
* same shape as a chunk id, so a rule that recognised ids rather than wrappers
* would delete real data off a profile. `Worker ID: f34e8ef0-…` has no
* parenthesis, so nothing here reaches it; `(id `f34e8ef0-…`)` is a
* parenthesis containing a reference word and nothing but ids, which is not a
* shape prose takes for any other reason.
*
* Backticks are optional on each side independently, because a model that opens
* a code span and forgets to close it before the bracket must not defeat this.
*/
const REF_WORD = String.raw`(?:ids?|refs?|references?|sources?|citations?)`;
const TICKED_ID = String.raw`\x60?${CITATION_ID}\x60?`;
const CITATION_LABELLED = new RegExp(
String.raw`\s*\(\s*${REF_WORD}\s*:?\s*${TICKED_ID}(?:\s*,\s*${TICKED_ID})*\s*\)`,
'gi'
);
/**
* Ids in square brackets with nothing else in them — `[uuid]`, `[uuid, uuid]`.
*
* A markdown link is `[label](target)`; a bracket holding only identifiers is
* not a link and is not something prose does. The lookahead leaves anything
* followed by `(` to the link rules, so a genuine link whose label happens to
* be a reference number keeps its destination and stays on screen.
*/
/* Stricter than CITATION_ID, and only here. A bare bracket has no reference
word to disambiguate it, so the id itself has to carry the evidence: at least
eight hex characters, or a dashed group. Without that `[2026]` is four hex
digits and a year in brackets would disappear from an answer. */
const BRACKETABLE_ID = String.raw`(?:[0-9a-f]{8,}|[0-9a-f]{4,}(?:-[0-9a-f]{4,})+)`;
const CITATION_BRACKETED = new RegExp(
String.raw`\s*\[\s*\x60?${BRACKETABLE_ID}\x60?(?:\s*,\s*\x60?${BRACKETABLE_ID}\x60?)*\s*\](?!\()`,
'gi'
);
/**
* Whatever is still arriving at the end of the text.
*
* The streaming half of the problem, and it is a real one rather than a
* theoretical one: `readRunStream` re-parses the WHOLE accumulated answer on
* every delta, so a citation split across two frames is a state the reader can
* see. `…before a first shift ([3370` renders for as long as the next delta
* takes to arrive.
*
* Both rules are anchored to the end of the text, so they can only ever
* describe the frontier of the stream and never something the answer has
* already moved past. The fragment is held back until it completes, at which
* point the rules above remove it properly — which is buffering, expressed as
* a parse rather than as a second copy of the text.
*/
const CITATION_TAG_PARTIAL = /<\/?[a-z]*$|<\/?cit(?:e|ation)\b[^>]*$/i;
const CITATION_LINK_PARTIAL = new RegExp([
/* An open bracket holding at least one COMPLETE citation and not yet closed:
`…shift ([337042b3](#)` and `…shift ([337042b3](#), `. Without this the
complete link is removed by the rule above and the `(` is stranded. */
String.raw`\s*\((?:\s*\[${CITATION_ID}\]\(#\)\s*,?)+\s*(?:\[[0-9a-f-]*(?:\](?:\(#?)?)?)?$`,
/* One part-written, with or without its bracket: `([3370`, `[337042b3`,
`[337042b3](`, `[337042b3](#`. */
String.raw`\s*\(?\s*\[[0-9a-f-]*(?:\](?:\(#?)?)?$`,
].join('|'), 'i');
/**
* The same two, part-written.
*
* `Policy (id `f34e8ef0-9a01-5921` is a state the reader can see, because the
* stream re-parses everything on every delta. Both are anchored to the end, so
* they describe only the frontier.
*
* The labelled rule accepts any short leading word rather than only a reference
* word, because `(i` and `(id` are prefixes of one and `(` is a prefix of
* everything. The cost is that an ordinary parenthetical is held back for the
* frames between its bracket and its first non-hex character —
* `Omar Haddad (hired 2026-01-0` waits, `…(hired 2026-01-04) starts` does not.
* A parenthesis arriving a frame late is not something a reader can notice; a
* half-written reference id is exactly what they reported.
*/
const CITATION_LABELLED_PARTIAL = new RegExp(
String.raw`\s*\((?:[a-z]{0,12}[\s:]*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?)?$`,
'i'
);
const CITATION_BRACKETED_PARTIAL = new RegExp(
String.raw`\s*\[\s*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?$`,
'i'
);
/**
* What a lifted citation leaves behind.
*
* `check ()`, `check (, )`, `check .` — the wrapper is gone and its punctuation
* is not, and a sentence ending in an empty bracket reads as broken markup
* rather than as a clean sentence. Applied after the removals, never before.
*/
const EMPTY_PARENS = /\s*\(\s*[,;]*\s*\)/g;
const SPACE_BEFORE_PUNCTUATION = /\s+([.,;:!?])/g;
const DOUBLED_SPACES = / {2,}/g;
/**
* Removes citation markup, keeping the sentence inside it.
*
* The wrapper is addressing, not content — it tells a client which retrieved
* chunk a claim came from — and with nowhere to render it the honest move is to
* show the claim and drop the envelope. The backend's citation metadata is
* untouched: it is still on the run, still in the trajectory, and this only
* decides what reaches a reader.
*
* Content is never altered, only the wrapper around it, so markdown inside a
* citation — bold, a bullet, a table row — parses exactly as it would have
* unwrapped.
*
* If the panel ever grows a real citation affordance, this is the seam: parse
* the ids out here into a block the renderer can draw, rather than discarding
* them. Nothing else has to move.
*/
export function stripCitations(markdown, { partial = false } = {}) {
let out = String(markdown ?? '')
.replace(CITATION_TAG, '')
.replace(CITATION_GROUP, '')
.replace(CITATION_LABELLED, '')
.replace(CITATION_BRACKETED, '');
/**
* The frontier rules, and ONLY while there is a frontier.
*
* They describe something that is still being written, so they are wrong to
* apply to a finished answer — and quietly so. `…and note [12]` ends in a
* bracket holding two hex characters, which is indistinguishable from the
* first two characters of an id still arriving. Mid-stream, holding it back
* for a frame is right. At the end of a completed answer there is nothing
* more coming, the bracket is all there will ever be, and removing it deletes
* a footnote marker from the reader's answer.
*
* The caller knows which it is: `readRunStream` passes `partial` on a delta
* and not on the final snapshot. That is the only place the distinction
* exists, so it is the only place it can be made.
*
* Order is load-bearing. `…shift ([337042b3](#),` is a COMPLETE link inside
* an unclosed bracket — strip the link first and `(,` is left on screen,
* which is the broken bracket this exists to prevent. Matching the
* unterminated group first takes the whole fragment.
*/
if (partial) {
out = out
.replace(CITATION_TAG_PARTIAL, '')
.replace(CITATION_LINK_PARTIAL, '')
.replace(CITATION_LABELLED_PARTIAL, '')
.replace(CITATION_BRACKETED_PARTIAL, '');
}
return out
.replace(CITATION_LINK, '')
.replace(EMPTY_PARENS, '')
.replace(SPACE_BEFORE_PUNCTUATION, '$1')
.replace(DOUBLED_SPACES, ' ');
}
/**
* Keys that carry text a person reads, on any block.
*
* An allow-list rather than a deny-list, because the two mistakes do not cost
* the same: missing a display field leaks an id, while sanitising an address
* field would corrupt a confirmation token, a route or a record id and break
* what it points at. A new block type gets its display keys covered for free; a
* new addressing key is safe by default.
*/
const DISPLAY_KEYS = new Set([
'text', 'sub', 'label', 'title', 'summary', 'caption',
'description', 'detail', 'note', 'heading', 'hint',
]);
/** Keys whose value is a list of sentences rather than one. */
const DISPLAY_LISTS = new Set(['items', 'warnings', 'details', 'lines', 'rows']);
/**
* Citation-proofs a whole response, whatever shape it arrived in.
*
* `markdownToBlocks` strips the model's markdown, and for a completed answer
* that is the whole story. It is NOT the whole story for the response: the same
* provider also emits `note(run.message)` when a run did not complete,
* `note(event.error.message)` when the stream fails, and `confirmation(payload)`
* carrying server wording composed around model-supplied arguments. None of
* those go through the markdown parser, so each was a way for an id to reach
* the DOM without passing the one place that removes them.
*
* Rather than a `stripCitations` call at each — three sites today, and a fourth
* the next time the provider learns to say something — every block the agent
* provider yields goes through here.
*
* Walks recursively so nested shapes are reached (a table's rows, a
* confirmation's warnings, an insight's items) and touches only the keys above:
* `token`, `id`, `route`, `to` and everything else addressing-like is left
* exactly as the server sent it.
*/
export function sanitizeBlocks(blocks) {
return (blocks || []).map((block) => sanitizeValue(block, null));
}
function sanitizeValue(value, key) {
if (typeof value === 'string') {
return DISPLAY_KEYS.has(key) || DISPLAY_LISTS.has(key) ? stripCitations(value) : value;
}
if (Array.isArray(value)) {
/* The key travels into the elements, strings and objects alike. A string in
`items` is display text; an object in `rows` is a row, and only the key
says so — its own cell keys are positional (`c0`, `c1`) and carry no
meaning at all. An object in `columns` still defers to its own keys. */
return value.map((entry) => sanitizeValue(entry, key));
}
if (value && typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) {
/* A table row is `{ c0: 'Applied', c1: '4' }` — the cell keys are
positional and carry no meaning, so the row itself marks them. */
out[k] = key === 'rows' && typeof v === 'string' ? stripCitations(v) : sanitizeValue(v, k);
}
return out;
}
return value;
}
/**
* 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, { partial = false } = {}) {
const lines = stripCitations(markdown, { partial }).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')
/* A link keeps its label and loses its target. Headings and cells are drawn
as plain strings by their components, so an anchor cannot survive here —
and the label alone reads correctly, where the raw `[label](href)` does
not. `Inline` renders the real thing everywhere a link CAN be one. */
.replace(/\[([^\]\n]+)\]\([^)\s]*\)/g, '$1')
.trim();
}
/**
* A failed request, in one sentence a person can act on.
*
* The status is what distinguishes the cases that matter, and they are
* genuinely different actions: sign in again, ask someone for access, or wait.
* Flattening them into "something went wrong" makes the user's next move a
* guess.
*/
async function describeFailure(response) {
let detail = '';
try {
const body = await response.json();
detail = body?.error?.message || '';
} catch {
/* A non-JSON error body is a proxy or a gateway, not this API. The status
still says enough. */
}
switch (response.status) {
case 401:
return 'Your session has expired. Sign in again to keep asking.';
case 404:
/* Deliberately the same answer for "no such agent" and "not yours" — the
API refuses to distinguish them, and repeating the distinction here
would undo that. */
return 'That agent is not available on this workspace.';
case 422:
return detail || 'That agent cannot run right now.';
case 429:
return 'Too many requests just now. Try again in a moment.';
default:
return detail || 'The agent could not be reached. Try again in a moment.';
}
}
/**
* The provider the app uses.
*
* There used to be three: a local simulator that computed answers from data
* already in the browser, a streaming HTTP provider for a deployment that had
* one, and the agent. The simulator is gone, and its removal is the point of
* this file's current shape.
*
* WHY IT WENT
*
* Two answering paths behind one avatar meant the same question got different
* answers depending on phrasing — "assign the strongest free worker" matched a
* template and reported nobody was available, while "put the best free worker
* on" reached the agent, which found somebody and proposed them. A user cannot
* be expected to know which sentence talks to which system, and a product where
* the wording decides the answer is a demo with good manners.
*
* WHAT IT COST, SAID PLAINLY
*
* The simulator was instant, free, and could not be wrong about a figure — it
* read the same cache the page rendered from. The agent takes ten to twenty
* seconds and costs tokens. That is a real regression on speed, accepted in
* exchange for answers that can be followed up, cannot be beaten by a synonym,
* and can act on what they find.
*
* AN UNCONFIGURED DEPLOYMENT NOW SAYS SO
*
* With no VITE_AGENT_API there is nothing to fall back to. That state is
* explicit rather than silent: every question is answered with the reason,
* because a panel that quietly does nothing is the worst of the three
* possibilities and the hardest to diagnose.
*/
export function createAssistantProvider() {
const base = import.meta.env?.VITE_AGENT_API;
return base ? createAgentProvider({ baseUrl: base }) : createUnconfiguredProvider();
}
/**
* The provider for a deployment with no agent configured.
*
* Answers every question with the same sentence, which is the honest thing to
* do: nothing here can answer, and pretending otherwise is what the simulator
* was doing.
*/
export function createUnconfiguredProvider() {
return {
id: 'unconfigured',
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.'
)];
},
};
}