diff --git a/apps/server/src/conversation-plan/jev.test.ts b/apps/server/src/conversation-plan/jev.test.ts new file mode 100644 index 00000000..21501237 --- /dev/null +++ b/apps/server/src/conversation-plan/jev.test.ts @@ -0,0 +1,260 @@ +import { expect, test } from "bun:test"; +import { askJev, type JevRequest, validateJevResponse } from "./jev"; + +let request = { + state: "Synthetic", + questions: { flag: { type: "noul" as const, instructions: "Is this true?" } }, +}; +let response = () => + new Response(JSON.stringify({ + model: "jev-1.13.0", + answers: { flag: { type: "noul", noul: 0.9 } }, + usage: { input_tokens: 1, output_tokens: 1 }, + })); + +function delayedFetch(delayMs: number, calls: { count: number }) { + return (_: string, init?: RequestInit): Promise => { + calls.count++; + return new Promise((resolve, reject) => { + let signal = init?.signal; + let timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(response()); + }, delayMs); + function onAbort() { + clearTimeout(timer); + reject(new Error("fetch aborted")); + } + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); + }); + }; +} + +test("Jev accepts a response just beyond the former 20-second budget", async () => { + let calls = { count: 0 }; + let result = await askJev(request, { + apiKey: "test-only", + fetch: delayedFetch(20_050, calls), + }); + expect(result.answers.flag).toEqual({ type: "noul", noul: 0.9 }); + expect(calls.count).toBe(1); +}, 35_000); + +test("Jev aborts after its configured budget without retrying", async () => { + let calls = { count: 0 }; + await expect(askJev(request, { + apiKey: "test-only", + timeoutMs: 100, + fetch: delayedFetch(500, calls), + })).rejects.toThrow("Jev request timed out"); + expect(calls.count).toBe(1); +}); + +test("caller abort ends Jev before the timeout", async () => { + let calls = { count: 0 }; + let controller = new AbortController(); + let pending = askJev(request, { + apiKey: "test-only", + timeoutMs: 1_000, + signal: controller.signal, + fetch: delayedFetch(500, calls), + }); + controller.abort(); + await expect(pending).rejects.toThrow("Jev request aborted"); + expect(calls.count).toBe(1); +}); + +test("Jev timeout also bounds a stalled response body", async () => { + let stream = new ReadableStream({ start() {} }); + await expect(askJev(request, { + apiKey: "test-only", + timeoutMs: 100, + fetch: async () => new Response(stream), + })).rejects.toThrow("Jev request timed out"); +}); + +test("Jev normalizes a response body reader error", async () => { + let stream = new ReadableStream({ + start(controller) { + controller.error(new Error("secret response body")); + }, + }); + await expect(askJev(request, { + apiKey: "test-only", + fetch: async () => new Response(stream), + })).rejects.toThrow("Jev request failed"); +}); + +test("Jev serializes one authenticated request and returns a validated result", async () => { + let calls = 0; + let result = await askJev(request, { + apiKey: "test-only", + model: "jev-fixture", + fetch: async (url, init) => { + calls++; + expect(url).toBe("https://api.typesafe.ai/v1/systemone"); + expect(init?.method).toBe("POST"); + expect(init?.redirect).toBe("error"); + expect(init?.headers).toEqual({ + Authorization: "Bearer test-only", + "Content-Type": "application/json", + }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(JSON.parse(init?.body as string)).toEqual({ model: "jev-fixture", ...request }); + return response(); + }, + }); + expect(calls).toBe(1); + expect(result.model).toBe("jev-1.13.0"); + expect(result.usage).toEqual({ input_tokens: 1, output_tokens: 1 }); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); +}); + +let typedRequest: JevRequest = { + state: { text: "Synthetic" }, + questions: { + flag: request.questions.flag, + action: { type: "choice", instructions: "Choose", criteria: { save: "Save", skip: "Skip" } }, + strength: { type: "score", instructions: "Rate", criteria: ["Low", "High"] }, + }, +}; +function typedResponse() { + return { + model: "jev-fixture", + usage: { input_tokens: 0, output_tokens: 1 }, + answers: { + flag: { type: "noul", noul: 0.9 }, + action: { + type: "choice", + choice: "save", + confidence: 0.8, + probabilities: { save: 0.8, skip: 0.2 }, + }, + strength: { + type: "score", + score: 0.75, + confidence: 0.9, + legend: { "0": "Low", "1": "High" }, + probabilities: { "0": 0.25, "1": 0.75 }, + }, + }, + }; +} + +test("Jev validates noul, winning choice, and weighted score answers", () => { + let value = typedResponse(); + expect(validateJevResponse(value, typedRequest.questions)).toEqual({ + ...value, + latencyMs: 0, + }); +}); + +test("Jev rejects malformed answers instead of returning partial results", () => { + let mutations: ((value: ReturnType) => void)[] = [ + (value) => { + value.model = ""; + }, + (value) => { + value.usage.input_tokens = -1; + }, + (value) => { + value.answers.flag.noul = Number.NaN; + }, + (value) => { + Object.assign(value.answers.flag, { extra: true }); + }, + (value) => { + Object.assign(value.answers, { extra: { type: "noul", noul: 0.5 } }); + }, + (value) => { + value.answers.flag.type = "choice"; + }, + (value) => { + value.answers.action.choice = "skip"; + }, + (value) => { + value.answers.action.confidence = 1.1; + }, + (value) => { + value.answers.action.probabilities.save = 0.1; + }, + (value) => { + Object.assign(value.answers.action.probabilities, { other: 0 }); + }, + (value) => { + value.answers.strength.legend["0"] = "Wrong"; + }, + (value) => { + value.answers.strength.score = 0.1; + }, + ]; + for (let mutate of mutations) { + let value = typedResponse(); + mutate(value); + expect(() => validateJevResponse(value, typedRequest.questions)).toThrow("invalid Jev"); + } +}); + +test("Jev rejects request bounds before invoking transport", async () => { + let calls = 0; + let options = { + apiKey: "test-only", + fetch: async () => { + calls++; + return response(); + }, + }; + let invalid: JevRequest[] = [ + { ...request, questions: {} }, + { ...request, state: "x".repeat(24001) }, + { + state: "", + questions: Object.fromEntries( + ["first", "second"].map(id => [id, { + type: "choice", + instructions: "Choose", + criteria: Object.fromEntries( + Array.from({ length: 255 }, (_, index) => [String(index), "๐Ÿ˜€".repeat(250)]), + ), + }]), + ) as JevRequest["questions"], + }, + { ...request, questions: { "invalid id": request.questions.flag } }, + { ...request, questions: { flag: { ...request.questions.flag, instructions: "" } } }, + { + state: "", + questions: { action: { type: "choice", instructions: "Choose", criteria: { only: "One" } } }, + }, + { + state: "", + questions: { strength: { type: "score", instructions: "Rate", criteria: ["One"] } }, + }, + ]; + for (let value of invalid) await expect(askJev(value, options)).rejects.toThrow("invalid Jev"); + await expect(askJev(request, { ...options, model: "invalid/model" })).rejects.toThrow( + "invalid Jev model selection", + ); + await expect(askJev(request, { ...options, timeoutMs: 99 })).rejects.toThrow( + "invalid Jev timeout", + ); + expect(calls).toBe(0); +}); + +test("Jev rejects oversized and malformed response bodies", async () => { + for ( + let value of [ + new Response("{}", { headers: { "content-length": "262145" } }), + new Response("x".repeat(262145)), + ] + ) { + await expect(askJev(request, { + apiKey: "test-only", + fetch: async () => value, + })).rejects.toThrow("Jev response is too large"); + } + await expect(askJev(request, { + apiKey: "test-only", + fetch: async () => new Response("{broken"), + })).rejects.toThrow("invalid Jev JSON"); +}); diff --git a/apps/server/src/conversation-plan/jev.ts b/apps/server/src/conversation-plan/jev.ts new file mode 100644 index 00000000..71e3e1fa --- /dev/null +++ b/apps/server/src/conversation-plan/jev.ts @@ -0,0 +1,256 @@ +/** Bounded TypeSafe SystemOne transport. Only validated answers leave this boundary. */ +export type JevQuestion = + | { type: "noul"; instructions: string; criteria?: { true: string; false: string } } + | { type: "choice"; instructions: string; criteria: Record } + | { type: "score"; instructions: string; criteria: string[] }; + +export type JevAnswer = + | { type: "noul"; noul: number } + | { type: "choice"; choice: string; confidence: number; probabilities: Record } + | { + type: "score"; + score: number; + confidence: number; + legend: Record; + probabilities: Record; + }; + +export type JevRequest = { + state: object | string | unknown[]; + questions: Record; +}; +export type JevResult = { + model: string; + answers: Record; + usage: { input_tokens: number; output_tokens: number }; + latencyMs: number; +}; +export type JevOptions = { + apiKey?: string; + model?: string; + fetch?: (input: string, init?: RequestInit) => Promise; + timeoutMs?: number; + signal?: AbortSignal; +}; + +const ENDPOINT = "https://api.typesafe.ai/v1/systemone"; + +function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("invalid Jev response"); + } + return value as Record; +} +function probability(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) { + throw new Error("invalid Jev probability"); + } + return value; +} +function keysMatch(actual: Record, expected: readonly string[]): void { + let keys = Object.keys(actual); + if (keys.length !== expected.length || keys.some((key) => !expected.includes(key))) { + throw new Error("invalid Jev answer keys"); + } +} +function distribution(value: unknown, expected: readonly string[]): Record { + let raw = object(value); + keysMatch(raw, expected); + let result: Record = {}; + for (let key of expected) result[key] = probability(raw[key]); + let total = Object.values(result).reduce((sum, item) => sum + item, 0); + if (Math.abs(total - 1) > 0.08) throw new Error("invalid Jev distribution"); + return result; +} + +export function validateJevResponse( + value: unknown, + questions: Record, +): JevResult { + let response = object(value); + if (typeof response.model !== "string" || !response.model || response.model.length > 100) { + throw new Error("invalid Jev model"); + } + let usage = object(response.usage); + for (let count of [usage.input_tokens, usage.output_tokens]) { + if (!Number.isSafeInteger(count) || (count as number) < 0) throw new Error("invalid Jev usage"); + } + let rawAnswers = object(response.answers); + keysMatch(rawAnswers, Object.keys(questions)); + let answers: Record = {}; + for (let [id, question] of Object.entries(questions)) { + let raw = object(rawAnswers[id]); + if (raw.type !== question.type) throw new Error("invalid Jev answer type"); + if (question.type === "noul") { + keysMatch(raw, ["type", "noul"]); + answers[id] = { type: "noul", noul: probability(raw.noul) }; + } else if (question.type === "choice") { + keysMatch(raw, ["type", "choice", "confidence", "probabilities"]); + let options = Object.keys(question.criteria); + let probabilities = distribution(raw.probabilities, options); + if (typeof raw.choice !== "string" || !(raw.choice in question.criteria)) { + throw new Error("invalid Jev choice"); + } + if (probabilities[raw.choice] + 0.01 < Math.max(...Object.values(probabilities))) { + throw new Error("invalid Jev winning choice"); + } + answers[id] = { + type: "choice", + choice: raw.choice, + confidence: probability(raw.confidence), + probabilities, + }; + } else { + keysMatch(raw, ["type", "score", "confidence", "legend", "probabilities"]); + let levels = question.criteria.map((_, index) => String(index)); + let legend = object(raw.legend); + keysMatch(legend, levels); + for (let [index, label] of question.criteria.entries()) { + if (legend[String(index)] !== label) throw new Error("invalid Jev score legend"); + } + let probabilities = distribution(raw.probabilities, levels); + let weighted = levels.reduce((sum, key, index) => sum + index * probabilities[key], 0); + if ( + typeof raw.score !== "number" || !Number.isFinite(raw.score) + || raw.score < 0 || raw.score > levels.length - 1 + || Math.abs(raw.score - weighted) > 0.12 + ) throw new Error("invalid Jev score"); + answers[id] = { + type: "score", + score: raw.score, + confidence: probability(raw.confidence), + legend: legend as Record, + probabilities, + }; + } + } + return { + model: response.model, + answers, + usage: { + input_tokens: usage.input_tokens as number, + output_tokens: usage.output_tokens as number, + }, + latencyMs: 0, + }; +} + +function validateRequest(request: JevRequest): void { + let count = Object.keys(request.questions).length; + if (count < 1 || count > 45 || JSON.stringify(request.state).length > 24000) { + throw new Error("invalid Jev request bounds"); + } + for (let [id, question] of Object.entries(request.questions)) { + if ( + !id || id.length > 100 || !/^[\w:-]+$/.test(id) + || !question.instructions || question.instructions.length > 1000 + ) { + throw new Error("invalid Jev question"); + } + if (question.type === "choice") { + let options = Object.entries(question.criteria); + if ( + options.length < 2 || options.length > 255 + || options.some(([key, description]) => + !key || key.length > 200 || !description || description.length > 500 + ) + ) throw new Error("invalid Jev choice criteria"); + } else if (question.type === "score") { + if ( + question.criteria.length < 2 || question.criteria.length > 10 + || question.criteria.some((label) => !label || label.length > 500) + ) { + throw new Error("invalid Jev score criteria"); + } + } + } +} + +export async function askJev(request: JevRequest, options: JevOptions = {}): Promise { + validateRequest(request); + let apiKey = options.apiKey ?? process.env.JEV_API_KEY ?? process.env.TYPESAFE_API_KEY; + if (!apiKey) throw new Error("Jev API key is not configured"); + let model = options.model ?? "jev-latest"; + if (!model || model.length > 100 || !/^[A-Za-z0-9._-]+$/.test(model)) { + throw new Error("invalid Jev model selection"); + } + let timeoutMs = options.timeoutMs ?? 30_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 60000) { + throw new Error("invalid Jev timeout"); + } + let requestBody = JSON.stringify({ model, state: request.state, questions: request.questions }); + if (Buffer.byteLength(requestBody, "utf8") > 262144) { + throw new Error("invalid Jev request bounds"); + } + let timeout = AbortSignal.timeout(timeoutMs); + let signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + let abortListener!: () => void; + let aborted = new Promise((_, reject) => { + abortListener = () => reject(new Error("Jev request aborted")); + if (signal.aborted) abortListener(); + else signal.addEventListener("abort", abortListener, { once: true }); + }); + let transportError = () => + new Error( + options.signal?.aborted + ? "Jev request aborted" + : timeout.aborted + ? "Jev request timed out" + : "Jev request failed", + ); + let started = performance.now(); + try { + let response: Response; + try { + response = await Promise.race([ + (options.fetch ?? fetch)(ENDPOINT, { + method: "POST", + redirect: "error", + signal, + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: requestBody, + }), + aborted, + ]); + } catch { + throw transportError(); + } + if (!response.ok) throw new Error(`Jev HTTP ${response.status}`); + let size = Number(response.headers.get("content-length")); + if (Number.isFinite(size) && size > 262144) throw new Error("Jev response is too large"); + let reader = response.body?.getReader(); + let decoder = new TextDecoder(); + let body = ""; + let bytes = 0; + if (reader) { + while (true) { + let chunk: Awaited>; + try { + chunk = await Promise.race([reader.read(), aborted]); + } catch { + void reader.cancel().catch(() => {}); + throw transportError(); + } + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > 262144) { + void reader.cancel().catch(() => {}); + throw new Error("Jev response is too large"); + } + body += decoder.decode(chunk.value, { stream: true }); + } + body += decoder.decode(); + } + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + throw new Error("invalid Jev JSON"); + } + let result = validateJevResponse(parsed, request.questions); + result.latencyMs = Math.round(performance.now() - started); + return result; + } finally { + signal.removeEventListener("abort", abortListener); + } +} diff --git a/apps/server/src/conversation-plan/quote-budget.ts b/apps/server/src/conversation-plan/quote-budget.ts new file mode 100644 index 00000000..cd13f9e2 --- /dev/null +++ b/apps/server/src/conversation-plan/quote-budget.ts @@ -0,0 +1,7 @@ +export const MAX_QUOTE_CANDIDATES = 4; + +export function assertQuoteBudget(candidates: readonly unknown[]): void { + if (candidates.length > MAX_QUOTE_CANDIDATES) { + throw new Error(`source quote count exceeds ${MAX_QUOTE_CANDIDATES}`); + } +} diff --git a/apps/server/src/conversation-plan/quotes.test-fixtures.ts b/apps/server/src/conversation-plan/quotes.test-fixtures.ts new file mode 100644 index 00000000..c3c768e3 --- /dev/null +++ b/apps/server/src/conversation-plan/quotes.test-fixtures.ts @@ -0,0 +1,21 @@ +/** Exact development chat excerpts from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2. */ +let messages: Record = { + // Source: evals/conversation-shape/fixtures/development/D19.json, input.steps id m1, kind say. + "D19/m1": + "Before the repository pilot, we need to choose how people sign in and how the hosted agent gets repository credentials.", + // Source: evals/conversation-shape/fixtures/development/D19.json, input.steps id m2, kind say. + "D19/m2": + "For human sign-in, the options are GitHub OAuth or email magic links. Separately, for the hosted agent's repository credentials, we could pass each user's GitHub token or use a GitHub App installation token. Those are two different calls.", + // Source: evals/conversation-shape/fixtures/development/D02.json, input.steps id m2, kind say. + "D02/m2": "a small VPS is enough for this traffic. we'll have to patch it ourselves.", + // Source: evals/conversation-shape/fixtures/development/D03.json, input.steps id m1, kind say. + "D03/m1": "what sends transactional notifications? our SMTP relay, Postmark, or SES?", + // Source: evals/conversation-shape/fixtures/development/D04.json, input.steps id m5, kind say. + "D04/m5": "worth comparing. R2's egress could matter for thumbnails.", +}; + +export async function fixtureMessage(caseId: string, stepId: string): Promise { + let text = messages[`${caseId}/${stepId}`]; + if (text === undefined) throw new Error(`${caseId}/${stepId} is not a message`); + return text; +} diff --git a/apps/server/src/conversation-plan/quotes.test.ts b/apps/server/src/conversation-plan/quotes.test.ts new file mode 100644 index 00000000..8d7d5f0e --- /dev/null +++ b/apps/server/src/conversation-plan/quotes.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "bun:test"; +import { fixtureMessage } from "./quotes.test-fixtures"; +import { extractQuotes } from "./quotes"; + +test("D19 m1 yields two exact independent question spans", async () => { + let m1 = await fixtureMessage("D19", "m1"); + let m1Quotes = extractQuotes(m1); + + /* + * Before the fix m1 was one span [0,119). The fallback only splits sentence + * punctuation and a small imperative-oriented "and" boundary, so it misses + * the two independent question topics joined by "and how". + */ + expect(m1Quotes).toEqual([ + { quote: "how people sign in", start: 47, end: 65 }, + { quote: "how the hosted agent gets repository credentials", start: 70, end: 118 }, + ]); + for (let quote of m1Quotes) { + expect(m1.slice(quote.start, quote.end)).toBe(quote.quote); + } +}); + +test("D19 m2 yields four exact atomic alternatives instead of bundled clauses", async () => { + let m2 = await fixtureMessage("D19", "m2"); + let m2Quotes = extractQuotes(m2); + /* + * Before the fix m2 was [0,69), [70,207), and [208,238): both "or" lists + * stayed bundled and the closing explanation became an unrelated candidate. + */ + expect(m2Quotes).toEqual([ + { quote: "GitHub OAuth", start: 35, end: 47 }, + { quote: "email magic links", start: 51, end: 68 }, + { quote: "each user's GitHub token", start: 143, end: 167 }, + { quote: "GitHub App installation token", start: 177, end: 206 }, + ]); + for (let quote of m2Quotes) { + expect(m2.slice(quote.start, quote.end)).toBe(quote.quote); + expect(quote.quote).not.toMatch(/\bor\b/i); + } +}); + +test("keeps current D02-D04 exact extraction controls", async () => { + let d02 = await fixtureMessage("D02", "m2"); + let d03 = await fixtureMessage("D03", "m1"); + let d04 = await fixtureMessage("D04", "m5"); + expect(extractQuotes(d02)).toEqual([ + { quote: "a small VPS is enough for this traffic.", start: 0, end: 39 }, + { quote: "we'll have to patch it ourselves.", start: 40, end: 73 }, + ]); + expect(extractQuotes(d03)).toEqual([ + { quote: "SMTP relay", start: 44, end: 54 }, + { quote: "Postmark", start: 56, end: 64 }, + { quote: "SES", start: 69, end: 72 }, + ]); + expect(extractQuotes(d04)).toEqual([ + { quote: "worth comparing.", start: 0, end: 16 }, + { quote: "R2's egress could matter for thumbnails.", start: 17, end: 57 }, + ]); +}); + +test("does not split a compound option name or quoted/rejected alternatives", () => { + let compound = + "We could use the Selection and Range APIs, CodeMirror, or our own document model."; + let quoted = + 'The note says, "Use GitHub OAuth or email magic links," but that option was rejected.'; + let rejected = + "We could use GitHub OAuth or email magic links, but the team rejected both options."; + for (let text of [compound, quoted, rejected]) { + expect(extractQuotes(text)).toEqual([{ quote: text, start: 0, end: text.length }]); + } +}); + +test("quote extraction accepts four candidates and rejects a fifth without truncation", () => { + expect(extractQuotes("First. Second. Third. Fourth.")).toEqual([ + { quote: "First.", start: 0, end: 6 }, + { quote: "Second.", start: 7, end: 14 }, + { quote: "Third.", start: 15, end: 21 }, + { quote: "Fourth.", start: 22, end: 29 }, + ]); + expect(() => extractQuotes("First. Second. Third. Fourth. Fifth.")) + .toThrow("source quote count exceeds 4"); +}); + +test.each( + [ + [ + "Which queue fits our launch? Redis, SQS, Postgres, or a small in-process queue with disk replay.", + ["Redis", "SQS", "Postgres", "a small in-process queue with disk replay"], + ], + [ + "Cedar, Elm, Ash, or browser Selection and Range with a custom model.", + ["Cedar", "Elm", "Ash", "browser Selection and Range with a custom model"], + ], + ["Redis, SQS, or Postgres.", ["Redis", "SQS", "Postgres"]], + ] as const, +)("extracts bounded alternatives without domain vocabulary: %s", (text, labels) => { + let quotes = extractQuotes(text); + expect(quotes.map(item => item.quote)).toEqual([...labels]); + for (let quote of quotes) expect(text.slice(quote.start, quote.end)).toBe(quote.quote); +}); + +test("a fifth explicit alternative fails the budget rather than becoming one sentence", () => { + expect(() => extractQuotes("Cedar, Elm, Ash, Pine, or Oak.")).toThrow( + "source quote count exceeds 4", + ); +}); diff --git a/apps/server/src/conversation-plan/quotes.ts b/apps/server/src/conversation-plan/quotes.ts new file mode 100644 index 00000000..d7ac224f --- /dev/null +++ b/apps/server/src/conversation-plan/quotes.ts @@ -0,0 +1,337 @@ +import { assertQuoteBudget, MAX_QUOTE_CANDIDATES } from "./quote-budget"; + +/** Exact UTF-16 spans from the saved message. This path never invents wording. */ +export type QuoteCandidate = { quote: string; start: number; end: number }; + +const DIRECT_DECISION_CLAUSE = + /^(?:(?:(?:for|before|after|during|in|on|regarding|about)\b|as part of\b)[^.!?,]{1,100},\s*)?(?:we|i)\s+(?:need to|have to|should|must)\s+(?:decide|choose)\s*$/i; + +/** Exact two-question structure, before judging whether the request is attributed. */ +function compoundDecisionPrefix( + text: string, + candidates: readonly QuoteCandidate[], +): { prefix: string; context: string } | undefined { + if (text.length > 500 || candidates.length !== 2) return; + let [first, second] = candidates; + if (!first || !second) return; + if ( + ![first.start, first.end, second.start, second.end].every(Number.isSafeInteger) + || first.start < 0 || first.end >= second.start || second.end > text.length + ) return; + if ( + text.slice(first.start, first.end) !== first.quote + || text.slice(second.start, second.end) !== second.quote + ) return; + if (first.quote.toLowerCase() === second.quote.toLowerCase()) return; + let prefix = text.slice(0, first.start); + let between = text.slice(first.end, second.start); + let suffix = text.slice(second.end); + if ( + !/\b(?:we|i)\s+(?:need to|have to|should|must)\s+(?:decide|choose)\s*$/i + .test(prefix) + || !/^\s*,?\s*and\s+$/i.test(between) + || !/^[.!?]?\s*$/.test(suffix) + || !candidates.every(candidate => /^(?:how|what|which)\b[\s\S]{3,160}$/i.test(candidate.quote)) + ) return; + let sentenceStart = + Math.max(prefix.lastIndexOf("."), prefix.lastIndexOf("!"), prefix.lastIndexOf("?")) + 1; + let directPrefix = prefix.slice(sentenceStart); + return { prefix: directPrefix, context: directPrefix + between + suffix }; +} + +/** A compound request lacking a direct decision clause from this speaker. */ +export function isAttributedCompoundDecision( + text: string, + candidates: readonly QuoteCandidate[], +): boolean { + let decision = compoundDecisionPrefix(text, candidates); + return decision !== undefined && !DIRECT_DECISION_CLAUSE.test(decision.prefix.trim()); +} + +/** Two exact question spans introduced by one direct, speaker-owned decision request. */ +export function isExplicitCompoundDecision( + text: string, + candidates: readonly QuoteCandidate[], +): boolean { + let decision = compoundDecisionPrefix(text, candidates); + return decision !== undefined && DIRECT_DECISION_CLAUSE.test(decision.prefix.trim()) + && !/\b(?:according to|if|unless|maybe|perhaps|could|would)\b/i.test(decision.context) + && !/["โ€œโ€โ€˜โ€™๐Ÿ™„]/u.test(decision.context); +} + +const CLAUSE_BOUNDARY = + /\s+and\s+(?=(?:remember|keep|show|ask|make|use|choose|save|reopen|compare|require|add|remove|start|offer|set|let|store|preview)\b)/gi; + +const OPTION_CLAUSE_INTRODUCER = + /\b(?:but|however|yet|because|since|as|although|though|whereas|while|if|unless)\b/i; + +/** Closed-class clause heads cannot begin an atomic provider label. */ +function startsWithClauseHead(quote: string): boolean { + return /^(?:as|because|if|unless|when|while|since|although|though)\b/i.test(quote.trim()); +} + +/** Parse only an explicit bounded list; each returned label is a source slice. */ +function listedAlternatives( + text: string, +): { kind: "question" | "bare"; quotes: QuoteCandidate[] } | undefined { + if (text.length > 500 || /["โ€œโ€โ€˜โ€™๐Ÿ™„]/u.test(text)) return; + let trimmed = text.trim(); + let question = /^(?:what|which|how|where|should)\b[^.!?;]{3,160}\?\s+/i.exec(trimmed); + let kind: "question" | "bare" = question ? "question" : "bare"; + if ( + question + && /\b(?:if|unless|when|according|reported|said|asked|not|never)\b|\bas\s+(?:a|an|the)\b/i + .test(question[0]) + ) return; + if (!question && /^(?:not|never|we|i|they|he|she|the|according)\b/i.test(trimmed)) return; + let bodyStart = text.indexOf(trimmed) + (question?.[0].length ?? 0); + if (question && /^our\s+/i.test(text.slice(bodyStart))) { + bodyStart += /^our\s+/i.exec(text.slice(bodyStart))![0].length; + } + let bodyEnd = text.indexOf(trimmed) + trimmed.length; + let ending = text[bodyEnd - 1]; + if (question ? ending !== "?" && ending !== "." : ending !== ".") return; + bodyEnd--; + let body = text.slice(bodyStart, bodyEnd); + if (!/,\s+or\s+/i.test(body) || /[;!?]|\s\/\s/u.test(body)) return; + let labels = body.split(/,\s*/); + if (labels.length < 3 || !/^or\s+/i.test(labels.at(-1) ?? "")) return; + labels[labels.length - 1] = labels.at(-1)!.replace(/^or\s+/i, ""); + if (labels.length > MAX_QUOTE_CANDIDATES) { + throw new Error(`source quote count exceeds ${MAX_QUOTE_CANDIDATES}`); + } + if ( + labels.some(label => + label.length < 2 || label.length > 80 || label.split(/\s+/).length > 12 + || !/^[\p{L}\p{N}][\p{L}\p{N}\s+&/.#'-]*$/u.test(label) + || /\b(?:or|not|never|because|since|if|unless|when|while|reported|said|asked|rejected|is|are)\b/i + .test(label) + || startsWithClauseHead(label) + ) + ) return; + if (new Set(labels.map(label => label.toLowerCase())).size !== labels.length) return; + let quotes: QuoteCandidate[] = []; + let searchFrom = bodyStart; + for (let label of labels) { + let start = text.indexOf(label, searchFrom); + if (start < 0 || start >= bodyEnd) return; + let end = start + label.length; + quotes.push({ quote: text.slice(start, end), start, end }); + searchFrom = end; + } + return { kind, quotes }; +} + +export function explicitListQuotes(text: string): QuoteCandidate[] { + return listedAlternatives(text)?.quotes ?? []; +} + +/** Alternatives from a direct question, excluding reported or negated choices. */ +export function directAlternativeQuotes(text: string): QuoteCandidate[] { + let trimmed = text.trim(); + if (/\b(?:not|never|don't|shouldn['โ€™]t|avoid)\b|๐Ÿ™„/iu.test(trimmed)) return []; + let listed = listedAlternatives(text); + if (listed?.kind === "question") return listed.quotes; + if (/[.!;]/.test(trimmed.slice(0, -1))) return []; + if ( + /^Which\b/i.test(trimmed) + && /\b(?:did|does|do|said|say|asked|reported|according)\b/i.test( + trimmed.slice(0, trimmed.indexOf(":")), + ) + ) return []; + let match = trimmed.match( + /^Should we\s+(use\s+[^,;?]+),\s*([^,;?]+),\s*or\s+([^,;?]+)\?$/i, + ) ?? trimmed.match( + /^Should we\b[^?]{1,160}\b(?:in|on|at|via|with)\s+([^,;?]+?)\s+or\s+([^,;?]+)\?$/i, + ) ?? trimmed.match( + /^Which\s+(?:[\w-]+\s+){1,4}should\s+[^?:]{1,120}:\s*([^,;?]+?)\s+or\s+([^,;?]+)\?$/i, + ) + ?? trimmed.match(/^Should\b[^?]{1,160}\b(?:use|go\s+in)\s+([^,;?]+?)\s+or\s+([^,;?]+)\?$/i); + if (!match) return []; + let result: QuoteCandidate[] = []; + let offset = text.indexOf(trimmed); + let searchFrom = 0; + for (let raw of match.slice(1)) { + if (/\bor\b/i.test(raw)) return []; + let start = trimmed.indexOf(raw, searchFrom); + if (start < 0) return []; + let end = start + raw.length; + while (/\s/.test(trimmed[start] ?? "")) start++; + while (/\s/.test(trimmed[end - 1] ?? "")) end--; + let opening = trimmed[start]; + let closing = trimmed[end - 1]; + if ((opening === '"' || closing === '"') && (opening !== '"' || closing !== '"')) { + return []; + } + if ((opening === "โ€œ" || closing === "โ€") && (opening !== "โ€œ" || closing !== "โ€")) { + return []; + } + if ((opening === '"' && closing === '"') || (opening === "โ€œ" && closing === "โ€")) { + start++; + end--; + } + if (start >= end) return []; + result.push({ + quote: text.slice(offset + start, offset + end), + start: offset + start, + end: offset + end, + }); + searchFrom = end; + } + return result; +} + +/** A bounded, first-person explanation of two alternatives for a named topic. */ +export function declarativeAlternativeQuotes(text: string): QuoteCandidate[] { + if (text.length > 500) return []; + let match = text.trim().match( + /^By\s+[\w-]+(?:\s+[\w-]+){0,8}\s+I mean\s+([^,;.!?]{2,100}?)\s+or\s+([^,;.!?]{2,100})\.$/i, + ); + if (!match) return []; + let labels = match.slice(1).map(label => label.trim()); + if ( + labels.some(label => + /\b(?:or|not|never|don't|didn't|wouldn't|maybe|perhaps|actually)\b|['"โ€œโ€โ€˜โ€™]/i.test(label) + ) || labels[0]!.toLowerCase() === labels[1]!.toLowerCase() + ) return []; + let result: QuoteCandidate[] = []; + let searchFrom = text.toLowerCase().indexOf("i mean") + "i mean".length; + for (let label of labels) { + let start = text.indexOf(label, searchFrom); + if (start < 0) return []; + let end = start + label.length; + result.push({ quote: text.slice(start, end), start, end }); + searchFrom = end; + } + return result; +} + +/** Exact labels from a speaker-owned, explicit three-way choice. */ +export function multiAlternativeQuotes(text: string): QuoteCandidate[] { + if (text.length > 500) return []; + let prefix = text.match( + /^\s*(?:(?:We|I)\s+(?:could|can|might|should)\s+(?:do|use|choose|try|go with)|By\s+[\w-]+(?:\s+[\w-]+){0,8}\s+I mean)\s+/i, + ); + if (!prefix) return []; + let bodyStart = prefix[0].length; + let bodyEnd = text.trimEnd().length; + if (/[.!?]$/.test(text.slice(0, bodyEnd))) bodyEnd--; + let body = text.slice(bodyStart, bodyEnd); + if ( + /[.!?;:'"โ€œโ€โ€˜โ€™]/u.test(body) + || /\b(?:not|never|don't|wouldn't|instead|none)\b/i.test(body) + || OPTION_CLAUSE_INTRODUCER.test(body) + ) { + return []; + } + let context = body.match(/\s+for\s+[\p{L}\p{N}\s/-]{2,100}$/u); + if (context) bodyEnd -= context[0].length; + let choices = text.slice(bodyStart, bodyEnd); + let separators = [...choices.matchAll(/\s+or\s+|,\s*(?:or\s+)?/gi)]; + if (separators.length !== 2 || !/\bor\b/i.test(separators[1]![0])) return []; + let spans: QuoteCandidate[] = []; + let start = bodyStart; + for (let separator of separators) { + let end = bodyStart + separator.index; + spans.push({ quote: text.slice(start, end), start, end }); + start = end + separator[0].length; + } + spans.push({ quote: text.slice(start, bodyEnd), start, end: bodyEnd }); + if ( + spans.some(({ quote }) => + quote.length < 2 || quote.length > 80 || quote.split(/\s+/).length > 6 + || !/^[\p{L}\p{N}][\p{L}\p{N}\s+&/.#-]*$/u.test(quote) + || /\b(?:and|or|not|never|don't)\b/i.test(quote) + ) + ) return []; + if (new Set(spans.map(({ quote }) => quote.toLowerCase())).size !== 3) return []; + return spans; +} + +/** Explicitly scoped decisions can contain multiple independent atomic spans. */ +function scopedDecisionQuotes(text: string): QuoteCandidate[] { + if (text.length > 500 || /["โ€œโ€]|\b(?:not|never|reject(?:ed)?|dismissed)\b/i.test(text)) { + return []; + } + let question = text.match( + /^.{0,100}\bwe need to (?:choose|decide)\s+(how [^.!?;]{3,100}?)\s+and\s+(how [^.!?;]{3,100})\.\s*$/i, + ); + let alternatives = text.match( + /^\s*For [^.!?;]{3,100}, the options are ([^.!?;]{2,80}?) or ([^.!?;]{2,80})\.\s+Separately, for [^.!?;]{3,100}, we could (?:pass|use) ([^.!?;]{2,100}?) or (?:use|pass) (?:a |an |the )?([^.!?;]{2,100})\.(?:\s+[^.!?;]{1,80}\.)?\s*$/i, + ); + let labels = question?.slice(1) ?? alternatives?.slice(1); + if ( + !labels + || labels.some(label => + /\bor\b|\b(?:not|never|reject(?:ed)?|dismissed)\b|["โ€œโ€]/i.test(label) + || alternatives !== null && label.split(/\s+/).length > 6 + ) + ) return []; + let spans: QuoteCandidate[] = []; + let searchFrom = 0; + for (let label of labels) { + let start = text.indexOf(label, searchFrom); + if (start < 0) return []; + let end = start + label.length; + spans.push({ quote: text.slice(start, end), start, end }); + searchFrom = end; + } + return spans; +} + +export function extractQuotes(text: string): QuoteCandidate[] { + let scoped = scopedDecisionQuotes(text); + if (scoped.length) return scoped; + let listed = listedAlternatives(text); + if (listed) return listed.quotes; + let direct = directAlternativeQuotes(text); + if (direct.length === 3) return direct; + let multi = multiAlternativeQuotes(text); + if (multi.length) return multi; + let declarative = declarativeAlternativeQuotes(text); + if (declarative.length) return declarative; + let candidates: QuoteCandidate[] = []; + let append = (start: number, end: number): void => { + let raw = text.slice(start, end); + let left = raw.length - raw.trimStart().length; + let right = raw.trimEnd().length; + start += left; + end = start + right - left; + if (end > start && end - start <= 2048) { + candidates.push({ quote: text.slice(start, end), start, end }); + assertQuoteBudget(candidates); + } + }; + let segment = (start: number, end: number): void => { + let content = text.slice(start, end); + let alternatives = directAlternativeQuotes(content); + if (alternatives.length) { + if (candidates.length + alternatives.length > MAX_QUOTE_CANDIDATES) { + throw new Error(`source quote count exceeds ${MAX_QUOTE_CANDIDATES}`); + } + for (let alternative of alternatives) { + append(start + alternative.start, start + alternative.end); + } + return; + } + let begin = start; + for (let match of content.matchAll(CLAUSE_BOUNDARY)) { + let split = start + match.index; + let left = text.slice(begin, split).trim(); + let shortAssent = /^(?:yes|yeah|yep|sure|okay|ok|agreed|i agree|sounds good)[,!]?$/i + .test(left); + if (left.split(/\s+/).length < 3 && !shortAssent) continue; + append(begin, split); + begin = split + match[0].length; + } + append(begin, end); + }; + let begin = 0; + let boundaries = /[;.!?](?=\s|$)/g; + for (let match of text.matchAll(boundaries)) { + segment(begin, match.index + match[0].length); + begin = match.index + match[0].length; + } + segment(begin, text.length); + return candidates; +} diff --git a/apps/server/src/conversation-plan/sources.test.ts b/apps/server/src/conversation-plan/sources.test.ts new file mode 100644 index 00000000..f56b4922 --- /dev/null +++ b/apps/server/src/conversation-plan/sources.test.ts @@ -0,0 +1,135 @@ +import { expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { assertSourceShape, validateSource } from "./sources"; + +function message(): Chat.Entry { + return { id: "message-1", author: { kind: "member", handle: "maggie" }, text: "๐Ÿงช Bun", ts: 1 }; +} +function source(): ConversationPlan.SourceRef { + return { + messageId: "message-1", + author: { kind: "member", handle: "maggie" }, + quote: "Bun", + start: 3, + end: 6, + role: "option", + }; +} + +test("a source preserves an exact UTF-16 span and member attribution in a saved entry", () => { + let saved = message(); + let citation = source(); + expect(() => validateSource(citation, saved)).not.toThrow(); + expect(citation).toEqual({ + messageId: "message-1", + author: { kind: "member", handle: "maggie" }, + quote: "Bun", + start: 3, + end: 6, + role: "option", + }); + expect(() => validateSource({ ...citation, quote: "๐Ÿงช", start: 0, end: 2 }, saved)).not.toThrow(); +}); + +test("an agent source must cite a complete saved agent entry", () => { + let saved: Chat.Entry = { ...message(), author: { kind: "agent" } }; + let citation: ConversationPlan.SourceRef = { ...source(), author: { kind: "agent" } }; + expect(() => validateSource(citation, saved)).not.toThrow(); + expect(() => validateSource(citation, { ...saved, streaming: true })) + .toThrow(/does not match saved message/); + expect(() => validateSource(citation, { ...saved, streaming: false })).not.toThrow(); +}); + +test("saved message identity, text, and attribution must all match the source", () => { + let saved = message(); + for ( + let citation of [ + { ...source(), messageId: "another-message" }, + { ...source(), quote: "Deno", end: 7 }, + { ...source(), author: { kind: "agent" as const } }, + { ...source(), author: { kind: "member" as const, handle: "spoofed" } }, + { ...source(), start: 4, end: 7 }, + ] + ) expect(() => validateSource(citation, saved)).toThrow(/does not match saved message/); + expect(() => validateSource(source(), { ...saved, streaming: true })) + .toThrow(/does not match saved message/); + expect(() => validateSource(source(), { ...saved, author: { kind: "system" } })) + .toThrow(/does not match saved message/); +}); + +test("source shape bounds message IDs, quotes, and safe UTF-16 offsets", () => { + expect(() => + assertSourceShape({ + ...source(), + messageId: "x".repeat(200), + quote: "x".repeat(2048), + start: 0, + end: 2048, + }) + ) + .not.toThrow(); + for ( + let override of [ + { messageId: "" }, + { messageId: "x".repeat(201) }, + { messageId: 1 }, + { quote: "" }, + { quote: "x".repeat(2049), start: 0, end: 2049 }, + { quote: 1 }, + { start: -1, end: 2 }, + { start: 3.5, end: 6.5 }, + { start: 3, end: 3 }, + { start: 6, end: 3 }, + { end: 7 }, + { start: Number.NaN }, + { start: Number.MAX_SAFE_INTEGER + 1, end: Number.MAX_SAFE_INTEGER + 4 }, + ] + ) { + expect(() => assertSourceShape({ ...source(), ...override })).toThrow( + /invalid conversation source/, + ); + } +}); + +test("source shape rejects missing fields and unknown source or author fields", () => { + for (let field of ["messageId", "author", "quote", "start", "end", "role"]) { + let value: Record = { ...source() }; + delete value[field]; + expect(() => assertSourceShape(value)).toThrow(/invalid conversation source/); + } + expect(() => assertSourceShape({ ...source(), classifierText: "Bun" })) + .toThrow(/unknown conversation source field/); + expect(() => + assertSourceShape({ ...source(), author: { kind: "member", handle: "maggie", extra: true } }) + ) + .toThrow(/unknown conversation source author field/); + expect(() => assertSourceShape({ ...source(), author: { kind: "agent", handle: "maggie" } })) + .toThrow(/unknown conversation source author field/); +}); + +test("source roles and authors follow the archived allowlist", () => { + let roles: ConversationPlan.SourceRole[] = [ + "question", + "option", + "reason", + "constraint", + "support", + "objection", + "withdrawal", + "verification", + "resolution", + "reopening", + ]; + for (let role of roles) expect(() => assertSourceShape({ ...source(), role })).not.toThrow(); + for ( + let value of [ + { ...source(), role: "decision" }, + { ...source(), author: { kind: "system" } }, + { ...source(), author: { kind: "classifier" } }, + { ...source(), author: { kind: "member", handle: "" } }, + { ...source(), author: { kind: "member", handle: 1 } }, + null, + [], + ] + ) expect(() => assertSourceShape(value)).toThrow(/invalid conversation source/); +}); diff --git a/apps/server/src/conversation-plan/sources.ts b/apps/server/src/conversation-plan/sources.ts new file mode 100644 index 00000000..7d01192d --- /dev/null +++ b/apps/server/src/conversation-plan/sources.ts @@ -0,0 +1,67 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; + +const ROLES = new Set([ + "question", + "option", + "reason", + "constraint", + "support", + "objection", + "withdrawal", + "verification", + "resolution", + "reopening", +]); + +export function assertSourceShape(value: unknown): asserts value is ConversationPlan.SourceRef { + if (!value || typeof value !== "object") throw new Error("invalid conversation source"); + let source = value as Record; + if ( + Object.keys(source).some((key) => + ![ + "messageId", + "author", + "quote", + "start", + "end", + "role", + ].includes(key) + ) + ) throw new Error("unknown conversation source field"); + let author = source.author; + if ( + typeof source.messageId !== "string" || !source.messageId || source.messageId.length > 200 + || typeof source.quote !== "string" || !source.quote || source.quote.length > 2048 + || !Number.isSafeInteger(source.start) || !Number.isSafeInteger(source.end) + || (source.start as number) < 0 || (source.end as number) <= (source.start as number) + || (source.end as number) - (source.start as number) !== source.quote.length + || !ROLES.has(source.role as ConversationPlan.SourceRole) + || !author || typeof author !== "object" + ) throw new Error("invalid conversation source"); + let claimed = author as Record; + if ( + Object.keys(claimed).some((key) => + ![ + "kind", + ...(claimed.kind === "member" ? ["handle"] : []), + ].includes(key) + ) + ) throw new Error("unknown conversation source author field"); + if ( + !((claimed.kind === "member" && typeof claimed.handle === "string" && claimed.handle.length > 0) + || claimed.kind === "agent") + ) throw new Error("invalid conversation source author"); +} + +/** Validates against the saved Chat entry, not candidate text supplied by the classifier. */ +export function validateSource(source: ConversationPlan.SourceRef, message: Chat.Entry): void { + assertSourceShape(source); + if ( + message.id !== source.messageId + || message.streaming + || message.author.kind !== source.author.kind + || (message.author.kind === "member" && source.author.kind === "member" + && message.author.handle !== source.author.handle) + || message.text.slice(source.start, source.end) !== source.quote + ) throw new Error("conversation source quote does not match saved message"); +} diff --git a/packages/protocol/conversation-plan.d.ts b/packages/protocol/conversation-plan.d.ts new file mode 100644 index 00000000..7e523696 --- /dev/null +++ b/packages/protocol/conversation-plan.d.ts @@ -0,0 +1,440 @@ +import type { Chat } from "./chat"; +import type { Frame, Request } from "./index"; + +type KIND = Frame & { kind: K }; + +/** The prototype's sidecar and wire contract. Accepted events are its domain authority. */ +export declare namespace ConversationPlan { + export type Incoming = + | Request + | Request + | Request + | Request + | Request + | Request; + export type Outgoing = + | Snapshot + | Changed + | Corrected + | SavedScopedChoice + | Retried + | Jobs + | RetriedJob + | ResearchConsentResult + | ResearchLinkResult; + + export type JobKind = "heading" | "refine" | "suggest" | "prose"; + /** One durable piece of background Planner work. */ + export type Job = { + id: string; + kind: JobKind; + /** Questionnaire id, or "document" for a heading job. */ + target: string; + /** Chat message or decision that caused this job. */ + trigger: string; + status: "pending" | "running" | "done" | "failed" | "skipped"; + attempts: number; + reason?: string; + /** Short JSON summary of the tool result. */ + output?: string; + /** ISO 8601 time of the last status change. */ + at: string; + }; + + export type SourceAuthor = Extract; + export type Actor = SourceAuthor | { kind: "classifier" }; + /** A withdrawal source supports only a neutral stance that retracts its speaker's pending choice. */ + export type SourceRole = + | "question" + | "option" + | "reason" + | "constraint" + | "support" + | "objection" + | "withdrawal" + | "verification" + | "resolution" + | "reopening"; + + /** UTF-16 offsets into a saved, complete Chat entry. */ + export type SourceRef = { + messageId: string; + author: SourceAuthor; + quote: string; + start: number; + end: number; + role: SourceRole; + }; + /** Exact source span for a Research offer, without a classifier contribution role. */ + export type ResearchSource = Omit; + export type ResearchAction = { + id: string; + kind: "research" | "dismiss"; + /** Supplied by the authorized caller, not by the classifier or client payload. */ + actor: Extract; + /** Stable verified GitHub user ID for an idempotent research request. */ + principalId: string; + at: number; + }; + /** Frozen option wording and event prefix for one bounded current-cost task. */ + export type ResearchTask = + | { + kind: "current-cost-comparison"; + threadId: string; + /** Exact accepted-event prefix and thread version when these labels were captured. */ + observedEventCount: number; + observedThreadVersion: number; + options: [{ id: string; labelAtOffer: string }, { id: string; labelAtOffer: string }]; + } + | { + kind: "current-cost-concern"; + threadId: string; + observedEventCount: number; + observedThreadVersion: number; + /** Every current option, in thread order; no pair is inferred from the concern. */ + options: + | [{ id: string; labelAtOffer: string }, { id: string; labelAtOffer: string }, { + id: string; + labelAtOffer: string; + }] + | [{ id: string; labelAtOffer: string }, { id: string; labelAtOffer: string }, { + id: string; + labelAtOffer: string; + }, { id: string; labelAtOffer: string }]; + /** Only a unique option named by the exact source quote may be focused. */ + focusOptionId?: string; + }; + export type ResearchOffer = { + id: string; + /** Opaque semantic identity; needId with contextId suppresses repeat offers. */ + needId: string; + contextId: string; + source: ResearchSource; + /** Absent on earlier offers, whose brief is the exact source quote. */ + task?: ResearchTask; + /** Deterministic public-worker brief and displayed wording. */ + brief: string; + threadId?: string; + status: "offered" | "dismissed" | "accepted"; + action?: ResearchAction; + }; + + export type Authoring = "quoted" | "scribe" | "human-edited"; + export type ThreadStatus = "exploring" | "leaning" | "decided" | "reopened" | "discarded"; + export type Contribution = { + id: string; + kind: "option" | "reason" | "constraint"; + text: string; + /** Concise card wording; text and sources remain the original evidence. */ + displayLabel?: string; + targetId?: string; + relation?: "supports" | "challenges" | "qualifies"; + authoring: Authoring; + editedBy?: string; + targetEditedBy?: string; + sources: SourceRef[]; + actor: Actor; + }; + export type Stance = { + id: string; + participant: string; + optionId?: string; + position: "support" | "oppose" | "neutral"; + sources: SourceRef[]; + at: number; + corrects?: string; + correctedBy?: string; + }; + export type Decision = { + id: string; + text: string; + optionId?: string; + sources: SourceRef[]; + actor: Extract; + editedBy?: string; + at: number; + }; + export type Candidate = { + id: string; + kind: "resolution" | "reopening"; + text: string; + sources: SourceRef[]; + status: "pending" | "confirmed" | "rejected"; + actedBy?: string; + }; + export type Thread = { + id: string; + question: string; + questionSources: SourceRef[]; + questionAuthoring: Authoring; + questionEditedBy?: string; + status: ThreadStatus; + contributions: Contribution[]; + /** Latest explicit stance for each participant and option. */ + stances: Stance[]; + stanceHistory: Stance[]; + decision?: Decision; + decisionHistory: Decision[]; + candidates: Candidate[]; + /** The decision card that shows this thread in the document. */ + questionnaireId?: string; + /** The latest proposal to settle, until a person decides or it is superseded. */ + pendingSettle?: { optionId: string; proposer: string; messageId: string }; + /** A provisional choice for a spike; this never decides the card. */ + pendingScopedChoice?: { + /** Absent only on snapshots written before scoped agreements existed. */ + proposalId?: string; + cardId: string; + optionId: string; + label: string; + scope: "spike"; + proposer: string; + messageId: string; + }; + version: number; + }; + + export type EventBase = { + id: string; + threadId: string; + observedThreadVersion: number; + origin: "classifier" | "planner" | "human"; + actor: Actor; + at: number; + }; + export type ScopedChoiceSave = { + actionId: string; + threadId: string; + expectedVersion: number; + proposalId: string; + cardId: string; + optionId: string; + expectedLabel?: string; + expectedGeneration: number; + }; + export type Event = + | (EventBase & { type: "thread.opened"; source?: SourceRef; question: string }) + | (EventBase & { + type: "option.added" | "reason.added" | "constraint.added"; + /** Required for sourced chat inference; absent for human and Planner card actions. */ + source?: SourceRef; + contribution: Pick; + }) + | (EventBase & { + type: "stance.changed"; + source: SourceRef; + optionId?: string; + /** Null means reviewed without a scoped target; absent only on persisted legacy events. */ + scopedProposalId?: string | null; + position: Stance["position"]; + }) + | (EventBase & { + type: "option.relabeled"; + optionId: string; + label: string; + observedCardRevision: number; + }) + | (EventBase & { type: "thread.leaning"; source: SourceRef; optionId?: string }) + | (EventBase & { type: "card.linked"; questionnaireId: string }) + | (EventBase & { type: "settle.suggested"; source: SourceRef; optionId: string }) + | (EventBase & { type: "settle.agreed"; source: SourceRef; optionId: string }) + | (EventBase & { type: "settle.deferred"; source: SourceRef; proposalId: string }) + | (EventBase & { + type: "settle.resumed"; + source: SourceRef; + proposalId: string; + deferredEventId: string; + }) + | (EventBase & { + type: "scoped-choice.proposed"; + source: SourceRef; + cardId: string; + optionId: string; + label: string; + scope: "spike"; + }) + | (EventBase & { + type: "scoped-choice.agreed"; + source: SourceRef; + proposalId: string; + cardId: string; + optionId: string; + label: string; + scope: "spike"; + }) + | (EventBase & { + type: "scoped-choice.saved"; + proposalId: string; + /** New saves bind each current support source to its accepted proposal or agreement event. */ + supportEventIds?: string[]; + /** Present on legacy proposal-plus-one-agreement saves. */ + agreementId?: string; + cardId: string; + optionId: string; + label: string; + scope: "spike"; + sources: SourceRef[]; + expectedGeneration: number; + expectedLabel?: string; + }) + | (EventBase & { type: "thread.discarded" }) + | (EventBase & { + type: "decision.recorded"; + source?: SourceRef; + text: string; + optionId?: string; + explicit: true; + }) + | (EventBase & { type: "decision.reopened"; source?: SourceRef; explicit: true }) + | (EventBase & { + type: "candidate.proposed"; + source: SourceRef; + candidate: Pick; + }) + | (EventBase & { + type: "candidate.confirmed" | "candidate.rejected"; + candidateId: string; + }) + | (EventBase & { + type: "card.corrected"; + change: EditChange | MoveChange | StatusChange | RetargetChange; + }); + + export type EditChange = + | { kind: "edit"; field: "question" | "decision"; text: string; contributionId?: never } + | { kind: "edit"; field: "contribution"; text: string; contributionId: string }; + export type MoveChange = { + kind: "move"; + contributionId: string; + targetThreadId: string; + targetVersion: number; + }; + export type StatusChange = { + kind: "set-status"; + status: Exclude; + }; + export type RetargetChange = + | { kind: "retarget-stance"; stanceId: string; optionId?: string } + | { kind: "dismiss-stance"; stanceId: string } + | { kind: "retarget-contribution"; contributionId: string; targetId: string }; + export type CorrectionChange = + | EditChange + | MoveChange + | StatusChange + | RetargetChange + | { + kind: "add-excerpt"; + messageId: string; + start: number; + end: number; + contributionKind: "option" | "reason" | "constraint"; + targetOptionId?: string; + } + | { kind: "record-decision"; text: string; optionId?: string } + | { kind: "confirm-candidate" | "reject-candidate"; candidateId: string }; + export type CorrectionAction = { + /** Stable across client retries, independent of the request's rid. */ + actionId: string; + threadId: string; + expectedVersion: number; + change: CorrectionChange; + }; + + export type QueueItem = { + messageId: string; + status: "pending" | "processing" | "failed"; + attempts: number; + error?: string; + }; + export type Distribution = { [answer: string]: number }; + export type AnalysisAnswer = + | { type: "noul"; noul: number } + | { type: "choice"; choice: string; confidence: number; probabilities: Distribution } + | { + type: "score"; + score: number; + confidence: number; + legend: { [level: string]: string }; + probabilities: Distribution; + }; + export type AnalysisPass = + | { stage: "triage" | "targeting"; answers: { [question: string]: AnalysisAnswer } } + | { + stage: "clarification"; + version: "bare-editor-clarification-1"; + answers: { [question: string]: AnalysisAnswer }; + }; + export type CandidateOutcome = { + start: number; + end: number; + status: "accepted" | "review" | "ignored"; + gate: string; + targetId?: string; + eventIds: string[]; + }; + export type AnalysisRecord = { + messageId: string; + questionSetVersion: string; + modelVersion: string; + status: "queued" | "running" | "applied" | "unlinked" | "failed"; + passes: AnalysisPass[]; + selectedTarget?: string; + quoteValidation?: Array<{ start: number; end: number; valid: boolean }>; + outcomes?: CandidateOutcome[]; + policyGate?: string; + candidates?: Array<{ id: string; kind: Candidate["kind"]; targetId?: string }>; + eventIds: string[]; + latencyMs?: number; + error?: string; + }; + export type State = { + schemaVersion: 1; + revision: number; + events: Event[]; + threads: Thread[]; + queue: QueueItem[]; + /** Bounded diagnostics; accepted event IDs remain in events. */ + analysis: AnalysisRecord[]; + /** Optional for version-1 sidecars saved before research offers existed. */ + researchOffers?: ResearchOffer[]; + }; + + export type Correct = KIND<"conversation-plan:correct"> & CorrectionAction; + export type SaveScopedChoice = KIND<"conversation-plan:scoped-choice-save"> & ScopedChoiceSave; + export type Retry = KIND<"conversation-plan:retry"> & { actionId: string; messageId: string }; + export type ResearchConsent = + & KIND<"conversation-plan:research"> + & ( + | { offerId: string; actionId: string; choice: "research" | "dismiss" } + | { offerId: string; choice: "resume"; actionId?: never } + ); + export type ResearchConsentResult = KIND<"conversation-plan:research"> & { + offerId: string; + status: ResearchOffer["status"]; + revision: number; + execution: "none" | "pending-owner" | "pending-retry" | "started"; + /** Present when this attempt returned an already placed research request. */ + researchRequestId?: string; + }; + export type ResearchLink = KIND<"conversation-plan:research-link"> & { offerId: string }; + export type ResearchLinkResult = KIND<"conversation-plan:research-link"> & { + offerId: string; + status: "pending" | "unlinked" | "linked"; + /** Present for an existing exact request, including before its first job is linked. */ + researchRequestId?: string; + }; + export type Snapshot = KIND<"conversation-plan:snapshot"> & { state: State; jobs?: Job[] }; + export type Changed = KIND<"conversation-plan:changed"> & { state: State }; + export type Jobs = KIND<"conversation-plan:jobs"> & { jobs: Job[] }; + export type Corrected = KIND<"conversation-plan:correct"> & { eventId: string; revision: number }; + export type SavedScopedChoice = KIND<"conversation-plan:scoped-choice-save"> & { + eventId: string; + revision: number; + }; + export type Retried = KIND<"conversation-plan:retry"> & { messageId: string; queued: boolean }; + export type RetryJob = KIND<"conversation-plan:retry-job"> & { jobId: string }; + export type RetriedJob = KIND<"conversation-plan:retry-job"> & { + jobId: string; + queued: boolean; + }; +} diff --git a/packages/protocol/index.d.ts b/packages/protocol/index.d.ts index 252cef77..d31c0b51 100644 --- a/packages/protocol/index.d.ts +++ b/packages/protocol/index.d.ts @@ -111,6 +111,7 @@ export declare namespace Session { export type { Chat } from "./chat"; export type { Comment } from "./comment"; +export type { ConversationPlan } from "./conversation-plan"; export type { Job } from "./job"; export type { Plan } from "./plan"; export type { Question } from "./question";