From edca58595fd2e4f6792c08f223d39643ee346fe3 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Fri, 9 Oct 2026 01:59:10 +0800 Subject: [PATCH] feat(console): render private answers and bounded briefs Query builds answer views on the server, delivers private answers, derives a bounded since-last-visit brief from durable scoped memory, and distils learnings into governed claim writes. The brief and browse surfaces share the Query page. --- apps/console/src/answer_live.ts | 188 ++++++++++++++++++++++++ apps/console/src/brief.ts | 71 +++++++++ apps/console/src/entry.ts | 19 ++- apps/console/src/index.ts | 76 ++++++++-- apps/console/src/learn.ts | 23 +++ apps/console/src/live.ts | 51 +++++-- apps/console/src/live_browse.ts | 2 +- apps/console/src/publish.ts | 21 +++ apps/console/src/render.ts | 79 ++++++++++ apps/console/test/answer_live.test.ts | 97 ++++++++++++ apps/console/test/answer_memory.test.ts | 128 ++++++++++++++++ apps/console/test/brief.test.ts | 48 ++++++ apps/console/test/console.test.ts | 160 ++++++++++++++++++++ apps/console/test/learn.test.ts | 44 ++++++ apps/console/test/publish.test.ts | 21 +++ apps/console/test/render.test.ts | 39 +++++ spec/71-console.md | 2 + spec/adr/A-read.md | 2 + spec/cards/surface.md | 3 +- spec/guide/surface.md | 7 +- spec/pins.toml | 2 +- spec/spec.lock.json | 41 ++++-- spec/status.md | 15 +- spec/terms/surface.toml | 1 + 24 files changed, 1083 insertions(+), 57 deletions(-) create mode 100644 apps/console/src/answer_live.ts create mode 100644 apps/console/src/brief.ts create mode 100644 apps/console/src/learn.ts create mode 100644 apps/console/src/publish.ts create mode 100644 apps/console/src/render.ts create mode 100644 apps/console/test/answer_live.test.ts create mode 100644 apps/console/test/answer_memory.test.ts create mode 100644 apps/console/test/brief.test.ts create mode 100644 apps/console/test/learn.test.ts create mode 100644 apps/console/test/publish.test.ts create mode 100644 apps/console/test/render.test.ts diff --git a/apps/console/src/answer_live.ts b/apps/console/src/answer_live.ts new file mode 100644 index 000000000..31874c7a3 --- /dev/null +++ b/apps/console/src/answer_live.ts @@ -0,0 +1,188 @@ +import { createHash } from "node:crypto"; +import type { Operator, TurnInput } from "./index.ts"; +import type { Arrival, Conclusion } from "./brief.ts"; +import { learnAfterAnswer, type Learning } from "./learn.ts"; +import { consoleDenylist, createLiveTurn, openReader, redactText, selectTable, type LiveOptions } from "./live.ts"; +import { ConsoleError } from "./turn.ts"; + +type AnswerOptions = LiveOptions & { clock?: () => number }; + +function object(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function scope(...parts: string[]): string { + return createHash("sha256").update(JSON.stringify(parts)).digest("hex"); +} + +function observed(subject: string, question: string, answer: string): boolean { + const word = subject.trim().toLowerCase(); + return word.length >= 3 && (`${question} ${answer}`).toLowerCase().includes(word); +} + +function observedLearning(learning: string, answer: string): boolean { + const statement = learning.trim().replace(/\s+/g, " ").toLowerCase(); + return statement.length >= 3 && answer.replace(/\s+/g, " ").toLowerCase().includes(statement); +} + +function memoryTable(description: unknown): string | null { + const tables = object(description) && Array.isArray(description.tables) ? description.tables : []; + const memory = tables.flatMap((table) => object(table) && table.kind === "memory" && typeof table.table === "string" ? [table.table] : []); + return memory.length === 1 ? memory[0] : null; +} + +function rows(value: unknown): { columns: string[]; rows: unknown[][] } { + if (!object(value)) return { columns: [], rows: [] }; + return { columns: Array.isArray(value.columns) ? value.columns.filter((column): column is string => typeof column === "string") : [], + rows: Array.isArray(value.rows) ? value.rows.filter(Array.isArray) : [] }; +} + +function matchesEntity(question: string, subject: string): boolean { + const asked = new Set(question.toLowerCase().match(/[a-z0-9]{3,}/g) ?? []); + return (subject.toLowerCase().match(/[a-z0-9]{3,}/g) ?? []).some((part) => asked.has(part)); +} + +function namedSubjects(question: string): string[] { + const stop = new Set(["Which", "What", "Who", "When", "Where", "Why", "How", "The"]); + return (question.match(/\b[A-Z][A-Za-z0-9]{2,}\b/g) ?? []).filter((name) => !stop.has(name)); +} + +export function createLiveAnswer(options: AnswerOptions) { + const { env, fetcher = fetch, clock = Date.now } = options; + const endpoint = env.CONTEXTFUL_MODEL_ENDPOINT; + const modelId = env.CONTEXTFUL_MODEL_ID; + if (!endpoint || !modelId) throw new Error("ConsoleModelUnconfigured"); + const modelUrl = new URL("chat/completions", `${endpoint.replace(/\/$/, "")}/`); + const configuredDenylist = consoleDenylist(env); + const liveTurn = createLiveTurn(options); + const turns = new Map(); + const sessionScope = (operator: Operator, store: string) => scope(operator.subject, store, operator.assertion ?? ""); + const denied = (operator: Operator) => [...configuredDenylist, ...(operator.assertion ? [operator.assertion] : [])]; + + async function distill(question: string, answer: string, operator: Operator): Promise { + try { + const response = await fetcher(modelUrl, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: modelId, messages: [ + { role: "system", content: "Distil at most three observed facts into JSON entries with subject, key, learning. Only subjects present in the question or answer qualify." }, + { role: "user", content: JSON.stringify({ question, answer }) }, + ] }), + }); + if (!response.ok) return []; + const body: unknown = await response.json(); + const choices = object(body) && Array.isArray(body.choices) ? body.choices : []; + const message = choices.length && object(choices[0]) ? choices[0].message : undefined; + if (!object(message) || typeof message.content !== "string") return []; + const parsed: unknown = JSON.parse(message.content); + const entries = object(parsed) && Array.isArray(parsed.entries) ? parsed.entries : []; + return entries.flatMap((entry): Learning[] => { + if (!object(entry) || typeof entry.subject !== "string" || typeof entry.key !== "string" || typeof entry.learning !== "string") return []; + if (!observed(entry.subject, question, answer) || !observedLearning(entry.learning, answer)) return []; + return [{ subject: redactText(entry.subject, denied(operator)), key: redactText(entry.key, denied(operator)), + learning: redactText(entry.learning, denied(operator)) }]; + }); + } catch { return []; } + } + + async function readMemory(operator: Operator, storeId: string, question?: string) { + if (!operator.session || !operator.assertion) return null; + const reader = await openReader(options, operator, storeId); + if (!reader.exchanged) return null; + const table = memoryTable(await reader.call("context.describe", {})); + if (!table) return null; + const expectedScope = `console:${operator.subject}:${operator.session}`; + let names = { columns: [] as string[], rows: [] as unknown[][] }; + try { + names = rows(await reader.call("context.query", { + sql: `SELECT DISTINCT subject FROM "${table.replaceAll('"', '""')}" LIMIT 100`, limit: 100, + })); + } catch (error) { + if (!(error instanceof ConsoleError && error.code === "ConsoleStoreReadRefused" && error.status === 400)) throw error; + } + const subjectAt = names.columns.indexOf("subject"); + const known = names.rows.map((row) => row[subjectAt]).filter((value): value is string => + typeof value === "string" && (question === undefined || matchesEntity(question, value))); + const subjects = [...new Set([...known, ...(question === undefined ? [] : namedSubjects(question))])].slice(0, 3); + const entries: Learning[] = []; + for (const subject of subjects) { + const response = rows(await reader.call("memory.recall", { table, subject, limit: 3 })); + const index = (name: string) => response.columns.indexOf(name); + for (const row of response.rows) { + if (row[index("scope")] !== expectedScope) continue; + const predicate = row[index("predicate")]; + const value = row[index("object")]; + if (typeof predicate === "string" && typeof value === "string") entries.push({ subject, key: predicate, learning: value }); + } + } + return { table, credential: reader.credential, scope: expectedScope, entries }; + } + + async function turn(input: TurnInput) { + const memory = await readMemory(input.operator, input.store, input.question); + const recallOverlay = (memory?.entries ?? []).map((entry) => + redactText(`${entry.subject} ${entry.key}: ${entry.learning}`, denied(input.operator))).join("\n").slice(0, 8000); + const result = await liveTurn({ ...input, recallOverlay }); + const session = sessionScope(input.operator, input.store); + if (memory && result.evidence?.length) { + const stream = (async function* () { yield result.answer; })(); + await learnAfterAnswer({ scope: memory.scope, question: input.question, stream, + distill: ({ question, answer }) => distill(question, answer, input.operator), + land: async (_key, entry) => { + const claim = { subject: entry.subject, predicate: entry.key, object: entry.learning, + confidence: 0.8, evidence: result.evidence }; + const dedupKey = scope(input.operator.subject, input.operator.session ?? "", input.question, result.answer, + entry.subject, entry.key, entry.learning); + let response: Response; + try { + response = await fetcher(new URL("/memory/claims", options.stores.find((store) => store.id === input.store)!.endpoint), { + method: "POST", headers: { Authorization: `Bearer ${memory.credential}`, "Content-Type": "application/json" }, + body: JSON.stringify({ into: memory.table, actor: input.operator.subject, session: input.operator.session, + dedup_key: dedupKey, claim }), + }); + } catch { throw new ConsoleError("ConsoleLearningWriteRefused", "memory write unavailable", 503); } + if (!response.ok) throw new ConsoleError("ConsoleLearningWriteRefused", "memory write refused", 503); + let receipt: unknown; + try { receipt = await response.json(); } + catch { throw new ConsoleError("ConsoleLearningWriteRefused", "memory receipt malformed", 503); } + if (!object(receipt) || receipt.scope !== memory.scope || typeof receipt.landed !== "boolean") { + throw new ConsoleError("ConsoleLearningWriteRefused", "memory receipt unbound", 503); + } + }, + }); + } + turns.set(session, (turns.get(session) ?? 0) + 1); + return result; + } + + async function brief(operator: Operator, store: string) { + const durable = await readMemory(operator, store); + const entries = durable?.entries ?? []; + const conclusions: Conclusion[] = entries.map((entry) => ({ subject: entry.subject, text: entry.learning, live: true })); + return { + session: { turns: turns.get(sessionScope(operator, store)) ?? 0, vantage: "present" }, + conclusions, + now: clock(), + loadArrivals: async (): Promise => { + const { call } = await openReader(options, operator, store); + const description = await call("context.describe", {}); + const question = entries.map((entry) => `${entry.subject} ${entry.key} ${entry.learning}`).join(" "); + const table = selectTable(question, description.tables); + if (!table) return []; + const result = await call("context.query", { sql: `SELECT * FROM "${table.replaceAll('"', '""')}"`, limit: 20 }); + const columns = Array.isArray(result.columns) ? result.columns.filter((value): value is string => typeof value === "string") : []; + const rows = Array.isArray(result.rows) ? result.rows.filter(Array.isArray) : []; + const at = (row: unknown[], names: string[]) => names.map((name) => row[columns.indexOf(name)]).find((value) => typeof value === "string" && value) as string | undefined; + return rows.flatMap((row): Arrival[] => { + const id = at(row, ["filing_id", "id", "document_id"]); + const label = at(row, ["title", "label", "summary"]); + const arrivedAt = at(row, ["published_at", "arrived_at", "date"]); + if (!id || !label || !arrivedAt) return []; + const topics = [label, at(row, ["summary"]) ?? ""].map((value) => redactText(value, denied(operator))); + return [{ id: redactText(id, denied(operator)), label: redactText(label, denied(operator)), topics, arrivedAt }]; + }).slice(0, 20); + }, + }; + } + + return { turn, brief, briefBudgetMs: 250, redactView: (operator: Operator, value: string) => redactText(value, denied(operator)) }; +} diff --git a/apps/console/src/brief.ts b/apps/console/src/brief.ts new file mode 100644 index 000000000..e9b947b5f --- /dev/null +++ b/apps/console/src/brief.ts @@ -0,0 +1,71 @@ +export type Conclusion = { subject: string; text: string; live: boolean }; +export type Arrival = { id: string; label: string; topics: string[]; arrivedAt: string }; +export type Brief = { windowDays: number; subjects: Array<{ subject: string; articles: Arrival[] }> }; + +function tokens(text: string): Set { + return new Set((text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).map((word) => word.length > 4 && word.endsWith("s") ? word.slice(0, -1) : word)); +} + +export function topicMatch(conclusion: Conclusion, row: Arrival): boolean { + const subject = tokens(conclusion.subject); + const left = tokens(`${conclusion.subject} ${conclusion.text}`); + const right = tokens(`${row.label} ${row.topics.join(" ")}`); + const shared = [...left].filter((word) => right.has(word)); + return shared.length >= 2 && shared.some((word) => subject.has(word)); +} + +export type BriefInput = { + session: { turns: number; vantage: string }; + conclusions: Conclusion[]; + arrivals?: Arrival[]; + loadArrivals?: () => Promise; + now: number; + windowDays?: number; + budgetMs: number; +}; + +export async function withinBriefBudget(budgetMs: number, action: (checkDeadline: () => void) => Promise): Promise { + if (!Number.isFinite(budgetMs) || budgetMs <= 0) throw new Error("ConsoleBriefUnavailable"); + const deadline = performance.now() + budgetMs; + const checkDeadline = () => { + if (performance.now() >= deadline) throw new Error("ConsoleBriefUnavailable"); + }; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve().then(() => action(checkDeadline)).then((value) => { + checkDeadline(); + return value; + }), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("ConsoleBriefUnavailable")), budgetMs); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +export async function deriveBrief(input: BriefInput): Promise { + if (!Number.isFinite(input.budgetMs) || input.budgetMs <= 0) throw new Error("ConsoleBriefUnavailable"); + try { + return await withinBriefBudget(input.budgetMs, async (checkDeadline) => { + if (input.session.turns !== 0 || input.session.vantage !== "present") return null; + const live = input.conclusions.filter((entry) => { checkDeadline(); return entry.live; }); + if (live.length === 0) return null; + const windowDays = Math.max(0, Math.min(input.windowDays ?? 7, 7)); + const arrivals = input.loadArrivals ? await input.loadArrivals() : input.arrivals ?? []; + const since = input.now - windowDays * 86_400_000; + const recent = arrivals.filter((row) => { + checkDeadline(); + const arrived = Date.parse(row.arrivedAt); + return Number.isFinite(arrived) && arrived >= since && arrived <= input.now; + }); + const subjects = live.map((entry) => ({ subject: entry.subject, articles: recent.filter((row) => { checkDeadline(); return topicMatch(entry, row); }).slice(0, 3) })) + .filter((entry) => entry.articles.length > 0).slice(0, 3); + return subjects.length ? { windowDays, subjects } : null; + }); + } catch { + throw new Error("ConsoleBriefUnavailable"); + } +} diff --git a/apps/console/src/entry.ts b/apps/console/src/entry.ts index e5a0d39c3..bfe23b4c7 100644 --- a/apps/console/src/entry.ts +++ b/apps/console/src/entry.ts @@ -2,12 +2,14 @@ import { createPublicKey } from "node:crypto"; import { pathToFileURL } from "node:url"; import { isAbsolute, resolve } from "node:path"; import { registryFromEnv } from "../../gateway/src/index.ts"; -import { createLiveTurn } from "./live.ts"; +import { createLiveAnswer } from "./answer_live.ts"; import { createLiveBrowse } from "./live_browse.ts"; import type { ConsoleAdapters, Identity } from "./index.ts"; import { serveConsole } from "./server.ts"; -type AdapterFactory = (input: { stores: ConsoleAdapters["stores"]; env: NodeJS.ProcessEnv }) => Promise & { read?: ConsoleAdapters["read"]; browse?: ConsoleAdapters["browse"] }>; +type HostedAdapters = Pick & + Partial>; +type AdapterFactory = (input: { stores: ConsoleAdapters["stores"]; env: NodeJS.ProcessEnv }) => Promise; function required(name: string): string { const value = process.env[name]; @@ -39,6 +41,7 @@ async function identity(): Promise { sessionSecret: required("CONTEXTFUL_COGNITO_SESSION_SECRET"), queryGroup: required("CONTEXTFUL_COGNITO_QUERY_GROUP"), adminGroup: required("CONTEXTFUL_COGNITO_ADMIN_GROUP"), + memorySessionClaim: process.env.CONTEXTFUL_CONSOLE_MEMORY_SESSION_CLAIM, issuer: required("CONTEXTFUL_COGNITO_ISSUER"), clientId: required("CONTEXTFUL_COGNITO_CLIENT_ID"), authorizeUrl: required("CONTEXTFUL_COGNITO_AUTHORIZE_URL"), @@ -52,6 +55,7 @@ async function identity(): Promise { issuer: required("CONTEXTFUL_ACCESS_ISSUER"), queryAudience: required("CONTEXTFUL_QUERY_ACCESS_AUDIENCE"), adminAudience: required("CONTEXTFUL_ADMIN_ACCESS_AUDIENCE"), + memorySessionClaim: process.env.CONTEXTFUL_CONSOLE_MEMORY_SESSION_CLAIM, keys: await keysAt(required("CONTEXTFUL_ACCESS_JWKS_URL")), }; } @@ -83,10 +87,14 @@ async function main(): Promise { const registry = registryFromEnv(process.env.CONTEXTFUL_STORES_JSON); const stores = registry.entries.map(({ id, label }) => ({ id, label })); const modulePath = process.env.CONTEXTFUL_CONSOLE_ADAPTER_MODULE; - let adapters: Pick & { read?: ConsoleAdapters["read"]; browse?: ConsoleAdapters["browse"] } = { + const live = modulePath ? null : createLiveAnswer({ stores: registry.entries, env: process.env }); + let adapters: HostedAdapters = { ...unavailableAdapters(), - turn: modulePath ? unavailableAdapters().turn : createLiveTurn({ stores: registry.entries, env: process.env }), + turn: live?.turn ?? unavailableAdapters().turn, browse: modulePath ? undefined : createLiveBrowse({ stores: registry.entries, env: process.env }), + brief: live?.brief, + briefBudgetMs: live?.briefBudgetMs, + redactView: live?.redactView, }; if (modulePath) { const absolute = isAbsolute(modulePath) ? modulePath : resolve(modulePath); @@ -100,6 +108,9 @@ async function main(): Promise { stores, adminCapability: process.env.CONTEXTFUL_ADMIN_CAPABILITY, turn: adapters.turn, + brief: adapters.brief, + briefBudgetMs: adapters.briefBudgetMs, + redactView: adapters.redactView, read: adapters.read ?? { list: async () => stores }, browse: adapters.browse, control: adapters.control, diff --git a/apps/console/src/index.ts b/apps/console/src/index.ts index b5175bae8..43a3814ac 100644 --- a/apps/console/src/index.ts +++ b/apps/console/src/index.ts @@ -1,15 +1,19 @@ import { createHash, createHmac, createPublicKey, randomBytes, timingSafeEqual, verify, type KeyObject } from "node:crypto"; +import { deriveBrief, withinBriefBudget, type BriefInput } from "./brief.ts"; +import { answerDelivery } from "./publish.ts"; +import { buildView, sanitizeView, type ResultRows } from "./render.ts"; import { ConsoleError } from "./turn.ts"; import { parseVantage } from "./turn.ts"; import { BrowseError } from "./browse.ts"; -export type Operator = { subject: string; grants: ReadonlySet<"query" | "admin">; assertion?: string }; +export type Operator = { subject: string; grants: ReadonlySet<"query" | "admin">; assertion?: string; session?: string }; export type Store = { id: string; label: string }; export type AccessIdentity = { kind: "access"; issuer: string; queryAudience: string; adminAudience: string; + memorySessionClaim?: string; publicKey?: KeyObject | string; keys?: ReadonlyMap; }; @@ -18,6 +22,7 @@ export type CognitoIdentity = { sessionSecret: string; queryGroup: string; adminGroup: string; + memorySessionClaim?: string; issuer?: string; clientId?: string; authorizeUrl?: string; @@ -29,13 +34,17 @@ export type CognitoIdentity = { }; export type Identity = AccessIdentity | CognitoIdentity; export type TurnInput = { operator: Operator; store: string; question: string }; -export type TurnResult = { answer: string; sources: unknown[]; widgets: unknown[] }; +export type TurnResult = { answer: string; sources: unknown[]; widgets: unknown[]; resultRows?: ResultRows; + evidence?: Array<{ table: string; run: string; seq: number }>; accessExplanation?: boolean; share?: boolean }; export type PackList = { entries: unknown[]; truncated: boolean; declined: number }; export type ConsoleAdapters = { identity: Identity; stores: Store[]; adminCapability?: string; turn: (input: TurnInput) => Promise; + brief?: (operator: Operator, store: string) => Promise>; + briefBudgetMs?: number; + redactView?: (operator: Operator, value: string) => string; read: { list: (operator: Operator) => Promise }; browse?: { discover: (input: { operator: Operator; store: string; asOf?: string }) => Promise; @@ -50,7 +59,7 @@ export type ConsoleAdapters = { }; }; -type Session = { subject: string; groups: string[]; expiresAt: number; assertion?: string }; +type Session = { subject: string; groups: string[]; expiresAt: number; assertion?: string; session?: string }; function encoded(value: unknown): string { return Buffer.from(JSON.stringify(value)).toString("base64url"); @@ -91,11 +100,13 @@ function verifyCognitoSession(cookie: string, identity: CognitoIdentity): Operat if (!object(session) || typeof session.subject !== "string" || !session.subject || !Number.isSafeInteger(session.expiresAt) || (session.expiresAt as number) <= Math.floor(Date.now() / 1000) || !Array.isArray(session.groups) || !session.groups.every((group) => typeof group === "string") || - (session.assertion !== undefined && typeof session.assertion !== "string")) return null; + (session.assertion !== undefined && typeof session.assertion !== "string") || + (session.session !== undefined && typeof session.session !== "string")) return null; const grants = new Set<"query" | "admin">(); if (session.groups.includes(identity.queryGroup)) grants.add("query"); if (session.groups.includes(identity.adminGroup)) grants.add("admin"); - return { subject: session.subject, grants, assertion: typeof session.assertion === "string" ? session.assertion : undefined }; + return { subject: session.subject, grants, assertion: typeof session.assertion === "string" ? session.assertion : undefined, + session: session.session }; } function verifyAccess(assertion: string, identity: AccessIdentity): Operator | null { @@ -119,7 +130,8 @@ function verifyAccess(assertion: string, identity: AccessIdentity): Operator | n typeof identity.publicKey === "string" ? createPublicKey(identity.publicKey) : identity.publicKey; if (!key) return null; if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, signature)) return null; - return { subject: claims.sub, grants, assertion }; + const session = identity.memorySessionClaim ? claims[identity.memorySessionClaim] : undefined; + return { subject: claims.sub, grants, assertion, session: typeof session === "string" && session.trim() ? session : undefined }; } function operatorFor(request: Request, identity: Identity): Operator | null { @@ -188,7 +200,9 @@ async function cognitoCallback(request: Request, identity: CognitoIdentity): Pro if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"))) return refusal("ConsolePageForbidden"); const groups = Array.isArray(claims["cognito:groups"]) ? claims["cognito:groups"].filter((group): group is string => typeof group === "string") : []; const lifetime = Math.min(claims.exp as number, Math.floor(Date.now() / 1000) + 3600); - const session = issueCognitoSession({ subject: claims.sub, groups, expiresAt: lifetime, assertion: tokens.id_token }, identity.sessionSecret); + const task = identity.memorySessionClaim ? claims[identity.memorySessionClaim] : undefined; + const session = issueCognitoSession({ subject: claims.sub, groups, expiresAt: lifetime, assertion: tokens.id_token, + session: typeof task === "string" && task.trim() ? task : undefined }, identity.sessionSecret); const destination = groups.includes(identity.adminGroup) ? "/admin" : "/query"; return new Response(null, { status: 302, headers: { Location: destination, @@ -289,17 +303,45 @@ export function createConsole(adapters: ConsoleAdapters): { fetch: (request: Req throw error; } } + if (request.method === "GET" && path === "/query/api/brief") { + const store = url.searchParams.get("store"); + if (!store || !adapters.stores.some((entry) => entry.id === store)) return refusal("ConsoleRequestMalformed", 400); + const brief = adapters.brief; + if (!brief) return refusal("ConsoleAdapterUnavailable", 503); + const budgetMs = adapters.briefBudgetMs ?? NaN; + try { + const card = await withinBriefBudget(budgetMs, async () => { + const input = await brief(operator, store); + return deriveBrief({ ...input, budgetMs }); + }); + return card ? json(card) : new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } }); + } catch { + return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } }); + } + } if (request.method === "POST" && path === "/query/api/ask") { let input: unknown; try { input = await body(request); } catch (error) { return bodyFailure(error); } if (!object(input) || typeof input.store !== "string" || typeof input.question !== "string" || !input.question.trim() || !adapters.stores.some((store) => store.id === input.store)) return refusal("ConsoleRequestMalformed", 400); - try { - return json(await adapters.turn({ operator, store: input.store, question: input.question })); - } catch (error) { + if ("view" in input || "widgets" in input) return refusal("ConsoleViewNotServerBuilt", 400); + let turn: TurnResult; + try { turn = await adapters.turn({ operator, store: input.store, question: input.question }); } + catch (error) { if (error instanceof ConsoleError) return refusal(error.code, error.status); throw error; } + let answer: ReturnType; + try { + answer = answerDelivery({ operator: operator.subject, answer: turn.answer, accessExplanation: turn.accessExplanation ?? false, share: turn.share }); + } catch (error) { + return refusal(error instanceof Error ? error.message : "VisibilityShareAffordance"); + } + const redactView = adapters.redactView; + const clean = turn.resultRows && redactView ? + sanitizeView({ component: "table.v1", props: turn.resultRows }, (value) => redactView(operator, value)) : null; + const built = clean ? buildView(clean.props) : null; + return json({ answer: answer.answer, sources: turn.sources, widgets: built ? [built] : [] }); } } else { const store = url.searchParams.get("store"); @@ -331,15 +373,17 @@ const sharedStyle = ``; -const queryPage = `Query · Contextful${sharedStyle}

Ask the store

Answers cite the rows your access permits.

Explore

Files

Conversation

Your answer appears here.

Results

`; const adminPage = `Admin · Contextful${sharedStyle}

Store operations

Pipelines, schedules, steps and run outcomes come from the store.

Workflow canvas

Operational record

Control document