@agimon-ai/doompi-user-feedback 0.0.1-alpha.27 → 0.0.1-alpha.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -4
- package/dist/adapters/pi/rpcQuestionnaireAdapter.cjs +1 -1
- package/dist/adapters/pi/rpcQuestionnaireAdapter.cjs.map +1 -1
- package/dist/adapters/pi/rpcQuestionnaireAdapter.mjs +1 -1
- package/dist/adapters/pi/rpcQuestionnaireAdapter.mjs.map +1 -1
- package/dist/services/answerEnvelope.cjs +2 -0
- package/dist/services/answerEnvelope.cjs.map +1 -0
- package/dist/services/answerEnvelope.mjs +2 -0
- package/dist/services/answerEnvelope.mjs.map +1 -0
- package/dist/types/askUserWire.cjs +2 -0
- package/dist/types/askUserWire.cjs.map +1 -0
- package/dist/types/askUserWire.mjs +2 -0
- package/dist/types/askUserWire.mjs.map +1 -0
- package/dist/types/questionnaire.d.cts +3 -1
- package/dist/types/questionnaire.d.cts.map +1 -1
- package/dist/types/questionnaire.d.mts +3 -1
- package/dist/types/questionnaire.d.mts.map +1 -1
- package/package.json +7 -5
- package/src/types/askUserWire.ts +45 -0
- package/src/types/questionnaire.ts +44 -0
- package/web/AskUserQuestionToolMessage.tsx +3 -1
- package/web/QuestionnairePrompt.tsx +336 -0
- package/web/index.ts +21 -4
- package/web/questionnaireDraft.ts +149 -0
package/README.md
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
# @agimon-ai/doompi-user-feedback
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
Voice handoff.
|
|
3
|
+
Ask structured questions in interactive Pi sessions, including sessions using autonomous Voice.
|
|
5
4
|
|
|
6
5
|
Part of the [DoomPi distribution](https://www.npmjs.com/package/@agimon-ai/doompi).
|
|
7
6
|
|
|
8
|
-
The `ask_user_question` tool presents concrete options
|
|
9
|
-
guess.
|
|
7
|
+
The `ask_user_question` tool presents concrete options and waits for the user to choose. It does not let the model guess.
|
|
10
8
|
|
|
11
9
|
> **Alpha:** tool and Voice-handoff contracts may change between releases.
|
|
12
10
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=`Type something.`,
|
|
1
|
+
const e=require("../../services/answerEnvelope.cjs"),t=require("../../types/askUserWire.cjs"),n=`Type something.`,r=`Next`;function i(e){return{answers:e,cancelled:!0}}function a(n,r){let i=t.decodeAnswerEnvelope(r);if(i.kind===`absent`)return!1;if(i.kind===`malformed`)return n.result={answers:[],cancelled:!0,error:`malformed_answers`},!0;let a=e.readAnswerEnvelope(i.payload,n.params);return n.result=a.ok?{answers:a.answers,cancelled:!1}:{answers:[],cancelled:!0,error:`malformed_answers`},!0}async function o(e,t,n,r){let i=await e.ui.input(t.question,`Type your answer`,{signal:r});if(i!==void 0)return{questionIndex:n,question:t.question,kind:`custom`,answer:i}}async function s(e,t,r,i,s){let c=[...t.options.map(e=>e.label),n],l=await e.ui.select(t.question,c,{signal:s});if(l===void 0||a(i,l))return;if(l===n)return o(e,t,r,s);let u=t.options.find(e=>e.label===l);if(u)return{questionIndex:r,question:t.question,kind:`option`,answer:u.label,...u.preview?{preview:u.preview}:{}}}function c(e,t){return[...e.options.map(e=>`${t.has(e.label)?`[x]`:`[ ]`} ${e.label}`),n,r]}function l(e){return e.replace(/^\[[ x]\]\s/u,``)}async function u(e,t,i,s,u){let d=new Set;for(;!u?.aborted;){let f=await e.ui.select(`${t.question} (choose options, then Next)`,c(t,d),{signal:u});if(f===void 0||a(s,f))return;if(f===n)return o(e,t,i,u);if(f===r)return{questionIndex:i,question:t.question,kind:`multi`,answer:null,selected:[...d]};let p=l(f);t.options.some(e=>e.label===p)&&(d.has(p)?d.delete(p):d.add(p))}}async function d(e,t,n,r){let a=[],o={params:t};for(let c=0;c<t.questions.length;c+=1){if(n?.aborted)return i(a);let l=t.questions[c];if(!l)continue;let d=l.multiSelect?await u(e,l,c,o,n):await s(e,l,c,o,n);if(o.result!==void 0){let e=o.result;return e.cancelled||r?.({...e,answers:e.answers.map(e=>({...e}))}),e}if(!d)return i(a);a.push(d),r?.({answers:a.map(e=>({...e})),cancelled:!1})}return{answers:a,cancelled:!1}}exports.runRpcQuestionnaire=d;
|
|
2
2
|
//# sourceMappingURL=rpcQuestionnaireAdapter.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rpcQuestionnaireAdapter.cjs","names":[],"sources":["../../../src/adapters/pi/rpcQuestionnaireAdapter.ts"],"sourcesContent":["import type { ExtensionContext } from '@earendil-works/pi-coding-agent';\nimport type { QuestionData, QuestionParams } from '../../schemas/questionnaire.js';\nimport type { QuestionAnswer, QuestionnaireResult } from '../../types/questionnaire.js';\n\nconst CUSTOM_LABEL = 'Type something.';\nconst DONE_LABEL = 'Next';\n\nfunction cancelled(answers: QuestionAnswer[]): QuestionnaireResult {\n return { answers, cancelled: true };\n}\n\nasync function askForCustomAnswer(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const answer = await context.ui.input(question.question, 'Type your answer', { signal });\n if (answer === undefined) return undefined;\n return {\n questionIndex,\n question: question.question,\n kind: 'custom',\n answer,\n };\n}\n\nasync function askSingleChoice(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const labels = [...question.options.map((option) => option.label), CUSTOM_LABEL];\n const selected = await context.ui.select(question.question, labels, { signal });\n if (selected === undefined) return undefined;\n if (selected === CUSTOM_LABEL) {\n return askForCustomAnswer(context, question, questionIndex, signal);\n }\n const option = question.options.find((candidate) => candidate.label === selected);\n if (!option) return undefined;\n return {\n questionIndex,\n question: question.question,\n kind: 'option',\n answer: option.label,\n ...(option.preview ? { preview: option.preview } : {}),\n };\n}\n\nfunction multiChoiceLabels(question: QuestionData, selected: ReadonlySet<string>): string[] {\n const options = question.options.map((option) => {\n const marker = selected.has(option.label) ? '[x]' : '[ ]';\n return `${marker} ${option.label}`;\n });\n return [...options, CUSTOM_LABEL, DONE_LABEL];\n}\n\nfunction stripSelectionMarker(value: string): string {\n return value.replace(/^\\[[ x]\\]\\s/u, '');\n}\n\nasync function askMultipleChoice(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const selected = new Set<string>();\n while (!signal?.aborted) {\n const choice = await context.ui.select(\n `${question.question} (choose options, then Next)`,\n multiChoiceLabels(question, selected),\n { signal },\n );\n if (choice === undefined) return undefined;\n if (choice === CUSTOM_LABEL) {\n return askForCustomAnswer(context, question, questionIndex, signal);\n }\n if (choice === DONE_LABEL) {\n return {\n questionIndex,\n question: question.question,\n kind: 'multi',\n answer: null,\n selected: [...selected],\n };\n }\n const label = stripSelectionMarker(choice);\n if (!question.options.some((option) => option.label === label)) continue;\n if (selected.has(label)) selected.delete(label);\n else selected.add(label);\n }\n return undefined;\n}\n\nexport async function runRpcQuestionnaire(\n context: ExtensionContext,\n params: QuestionParams,\n signal?: AbortSignal,\n reportProgress?: (result: QuestionnaireResult) => void,\n): Promise<QuestionnaireResult> {\n const answers: QuestionAnswer[] = [];\n for (let questionIndex = 0; questionIndex < params.questions.length; questionIndex += 1) {\n if (signal?.aborted) return cancelled(answers);\n const question = params.questions[questionIndex];\n if (!question) continue;\n const answer = question.multiSelect\n ? await askMultipleChoice(context, question, questionIndex, signal)\n : await askSingleChoice(context, question, questionIndex, signal);\n if (!answer) return cancelled(answers);\n answers.push(answer);\n reportProgress?.({ answers: answers.map((item) => ({ ...item })), cancelled: false });\n }\n return { answers, cancelled: false };\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"rpcQuestionnaireAdapter.cjs","names":["decodeAnswerEnvelope","readAnswerEnvelope"],"sources":["../../../src/adapters/pi/rpcQuestionnaireAdapter.ts"],"sourcesContent":["import type { ExtensionContext } from '@earendil-works/pi-coding-agent';\nimport type { QuestionData, QuestionParams } from '../../schemas/questionnaire.js';\nimport { readAnswerEnvelope } from '../../services/answerEnvelope.js';\nimport { decodeAnswerEnvelope } from '../../types/askUserWire.js';\nimport type { QuestionAnswer, QuestionnaireResult } from '../../types/questionnaire.js';\n\nconst CUSTOM_LABEL = 'Type something.';\nconst DONE_LABEL = 'Next';\n\nfunction cancelled(answers: QuestionAnswer[]): QuestionnaireResult {\n return { answers, cancelled: true };\n}\n\n/**\n * What a rich client replied, when it replied with the whole questionnaire.\n *\n * A client that can see the whole call answers every question at once rather\n * than walking this loop, so any select response may be an envelope instead of\n * a label. A response that is not one leaves the loop exactly as it was, which\n * is what keeps a plain RPC client working unchanged.\n */\ninterface EnvelopeSink {\n params: QuestionParams;\n /** Set once a select response carried the whole questionnaire; the loop stops and returns it. */\n result?: QuestionnaireResult;\n}\n\n/** True when this response was the whole set, in which case the sink now holds the answer. */\nfunction takeEnvelope(sink: EnvelopeSink, selected: string | undefined): boolean {\n const read = decodeAnswerEnvelope(selected);\n if (read.kind === 'absent') return false;\n if (read.kind === 'malformed') {\n sink.result = { answers: [], cancelled: true, error: 'malformed_answers' };\n return true;\n }\n const envelope = readAnswerEnvelope(read.payload, sink.params);\n sink.result = envelope.ok\n ? { answers: envelope.answers, cancelled: false }\n : { answers: [], cancelled: true, error: 'malformed_answers' };\n return true;\n}\n\nasync function askForCustomAnswer(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const answer = await context.ui.input(question.question, 'Type your answer', { signal });\n if (answer === undefined) return undefined;\n return {\n questionIndex,\n question: question.question,\n kind: 'custom',\n answer,\n };\n}\n\nasync function askSingleChoice(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n sink: EnvelopeSink,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const labels = [...question.options.map((option) => option.label), CUSTOM_LABEL];\n const selected = await context.ui.select(question.question, labels, { signal });\n if (selected === undefined) return undefined;\n if (takeEnvelope(sink, selected)) return undefined;\n if (selected === CUSTOM_LABEL) {\n return askForCustomAnswer(context, question, questionIndex, signal);\n }\n const option = question.options.find((candidate) => candidate.label === selected);\n if (!option) return undefined;\n return {\n questionIndex,\n question: question.question,\n kind: 'option',\n answer: option.label,\n ...(option.preview ? { preview: option.preview } : {}),\n };\n}\n\nfunction multiChoiceLabels(question: QuestionData, selected: ReadonlySet<string>): string[] {\n const options = question.options.map((option) => {\n const marker = selected.has(option.label) ? '[x]' : '[ ]';\n return `${marker} ${option.label}`;\n });\n return [...options, CUSTOM_LABEL, DONE_LABEL];\n}\n\nfunction stripSelectionMarker(value: string): string {\n return value.replace(/^\\[[ x]\\]\\s/u, '');\n}\n\nasync function askMultipleChoice(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n sink: EnvelopeSink,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const selected = new Set<string>();\n while (!signal?.aborted) {\n const choice = await context.ui.select(\n `${question.question} (choose options, then Next)`,\n multiChoiceLabels(question, selected),\n { signal },\n );\n if (choice === undefined) return undefined;\n if (takeEnvelope(sink, choice)) return undefined;\n if (choice === CUSTOM_LABEL) {\n return askForCustomAnswer(context, question, questionIndex, signal);\n }\n if (choice === DONE_LABEL) {\n return {\n questionIndex,\n question: question.question,\n kind: 'multi',\n answer: null,\n selected: [...selected],\n };\n }\n const label = stripSelectionMarker(choice);\n if (!question.options.some((option) => option.label === label)) continue;\n if (selected.has(label)) selected.delete(label);\n else selected.add(label);\n }\n return undefined;\n}\n\nexport async function runRpcQuestionnaire(\n context: ExtensionContext,\n params: QuestionParams,\n signal?: AbortSignal,\n reportProgress?: (result: QuestionnaireResult) => void,\n): Promise<QuestionnaireResult> {\n const answers: QuestionAnswer[] = [];\n const sink: EnvelopeSink = { params };\n for (let questionIndex = 0; questionIndex < params.questions.length; questionIndex += 1) {\n if (signal?.aborted) return cancelled(answers);\n const question = params.questions[questionIndex];\n if (!question) continue;\n const answer = question.multiSelect\n ? await askMultipleChoice(context, question, questionIndex, sink, signal)\n : await askSingleChoice(context, question, questionIndex, sink, signal);\n // A client that answered everything at once ends the walk here; the loop\n // is only how a client that can see one question at a time gets through.\n if (sink.result !== undefined) {\n const whole = sink.result;\n if (!whole.cancelled) {\n reportProgress?.({ ...whole, answers: whole.answers.map((item) => ({ ...item })) });\n }\n return whole;\n }\n if (!answer) return cancelled(answers);\n answers.push(answer);\n reportProgress?.({ answers: answers.map((item) => ({ ...item })), cancelled: false });\n }\n return { answers, cancelled: false };\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const
|
|
1
|
+
import{readAnswerEnvelope as e}from"../../services/answerEnvelope.mjs";import{decodeAnswerEnvelope as t}from"../../types/askUserWire.mjs";const n=`Type something.`,r=`Next`;function i(e){return{answers:e,cancelled:!0}}function a(n,r){let i=t(r);if(i.kind===`absent`)return!1;if(i.kind===`malformed`)return n.result={answers:[],cancelled:!0,error:`malformed_answers`},!0;let a=e(i.payload,n.params);return n.result=a.ok?{answers:a.answers,cancelled:!1}:{answers:[],cancelled:!0,error:`malformed_answers`},!0}async function o(e,t,n,r){let i=await e.ui.input(t.question,`Type your answer`,{signal:r});if(i!==void 0)return{questionIndex:n,question:t.question,kind:`custom`,answer:i}}async function s(e,t,r,i,s){let c=[...t.options.map(e=>e.label),n],l=await e.ui.select(t.question,c,{signal:s});if(l===void 0||a(i,l))return;if(l===n)return o(e,t,r,s);let u=t.options.find(e=>e.label===l);if(u)return{questionIndex:r,question:t.question,kind:`option`,answer:u.label,...u.preview?{preview:u.preview}:{}}}function c(e,t){return[...e.options.map(e=>`${t.has(e.label)?`[x]`:`[ ]`} ${e.label}`),n,r]}function l(e){return e.replace(/^\[[ x]\]\s/u,``)}async function u(e,t,i,s,u){let d=new Set;for(;!u?.aborted;){let f=await e.ui.select(`${t.question} (choose options, then Next)`,c(t,d),{signal:u});if(f===void 0||a(s,f))return;if(f===n)return o(e,t,i,u);if(f===r)return{questionIndex:i,question:t.question,kind:`multi`,answer:null,selected:[...d]};let p=l(f);t.options.some(e=>e.label===p)&&(d.has(p)?d.delete(p):d.add(p))}}async function d(e,t,n,r){let a=[],o={params:t};for(let c=0;c<t.questions.length;c+=1){if(n?.aborted)return i(a);let l=t.questions[c];if(!l)continue;let d=l.multiSelect?await u(e,l,c,o,n):await s(e,l,c,o,n);if(o.result!==void 0){let e=o.result;return e.cancelled||r?.({...e,answers:e.answers.map(e=>({...e}))}),e}if(!d)return i(a);a.push(d),r?.({answers:a.map(e=>({...e})),cancelled:!1})}return{answers:a,cancelled:!1}}export{d as runRpcQuestionnaire};
|
|
2
2
|
//# sourceMappingURL=rpcQuestionnaireAdapter.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rpcQuestionnaireAdapter.mjs","names":[],"sources":["../../../src/adapters/pi/rpcQuestionnaireAdapter.ts"],"sourcesContent":["import type { ExtensionContext } from '@earendil-works/pi-coding-agent';\nimport type { QuestionData, QuestionParams } from '../../schemas/questionnaire.js';\nimport type { QuestionAnswer, QuestionnaireResult } from '../../types/questionnaire.js';\n\nconst CUSTOM_LABEL = 'Type something.';\nconst DONE_LABEL = 'Next';\n\nfunction cancelled(answers: QuestionAnswer[]): QuestionnaireResult {\n return { answers, cancelled: true };\n}\n\nasync function askForCustomAnswer(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const answer = await context.ui.input(question.question, 'Type your answer', { signal });\n if (answer === undefined) return undefined;\n return {\n questionIndex,\n question: question.question,\n kind: 'custom',\n answer,\n };\n}\n\nasync function askSingleChoice(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const labels = [...question.options.map((option) => option.label), CUSTOM_LABEL];\n const selected = await context.ui.select(question.question, labels, { signal });\n if (selected === undefined) return undefined;\n if (selected === CUSTOM_LABEL) {\n return askForCustomAnswer(context, question, questionIndex, signal);\n }\n const option = question.options.find((candidate) => candidate.label === selected);\n if (!option) return undefined;\n return {\n questionIndex,\n question: question.question,\n kind: 'option',\n answer: option.label,\n ...(option.preview ? { preview: option.preview } : {}),\n };\n}\n\nfunction multiChoiceLabels(question: QuestionData, selected: ReadonlySet<string>): string[] {\n const options = question.options.map((option) => {\n const marker = selected.has(option.label) ? '[x]' : '[ ]';\n return `${marker} ${option.label}`;\n });\n return [...options, CUSTOM_LABEL, DONE_LABEL];\n}\n\nfunction stripSelectionMarker(value: string): string {\n return value.replace(/^\\[[ x]\\]\\s/u, '');\n}\n\nasync function askMultipleChoice(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const selected = new Set<string>();\n while (!signal?.aborted) {\n const choice = await context.ui.select(\n `${question.question} (choose options, then Next)`,\n multiChoiceLabels(question, selected),\n { signal },\n );\n if (choice === undefined) return undefined;\n if (choice === CUSTOM_LABEL) {\n return askForCustomAnswer(context, question, questionIndex, signal);\n }\n if (choice === DONE_LABEL) {\n return {\n questionIndex,\n question: question.question,\n kind: 'multi',\n answer: null,\n selected: [...selected],\n };\n }\n const label = stripSelectionMarker(choice);\n if (!question.options.some((option) => option.label === label)) continue;\n if (selected.has(label)) selected.delete(label);\n else selected.add(label);\n }\n return undefined;\n}\n\nexport async function runRpcQuestionnaire(\n context: ExtensionContext,\n params: QuestionParams,\n signal?: AbortSignal,\n reportProgress?: (result: QuestionnaireResult) => void,\n): Promise<QuestionnaireResult> {\n const answers: QuestionAnswer[] = [];\n for (let questionIndex = 0; questionIndex < params.questions.length; questionIndex += 1) {\n if (signal?.aborted) return cancelled(answers);\n const question = params.questions[questionIndex];\n if (!question) continue;\n const answer = question.multiSelect\n ? await askMultipleChoice(context, question, questionIndex, signal)\n : await askSingleChoice(context, question, questionIndex, signal);\n if (!answer) return cancelled(answers);\n answers.push(answer);\n reportProgress?.({ answers: answers.map((item) => ({ ...item })), cancelled: false });\n }\n return { answers, cancelled: false };\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"rpcQuestionnaireAdapter.mjs","names":[],"sources":["../../../src/adapters/pi/rpcQuestionnaireAdapter.ts"],"sourcesContent":["import type { ExtensionContext } from '@earendil-works/pi-coding-agent';\nimport type { QuestionData, QuestionParams } from '../../schemas/questionnaire.js';\nimport { readAnswerEnvelope } from '../../services/answerEnvelope.js';\nimport { decodeAnswerEnvelope } from '../../types/askUserWire.js';\nimport type { QuestionAnswer, QuestionnaireResult } from '../../types/questionnaire.js';\n\nconst CUSTOM_LABEL = 'Type something.';\nconst DONE_LABEL = 'Next';\n\nfunction cancelled(answers: QuestionAnswer[]): QuestionnaireResult {\n return { answers, cancelled: true };\n}\n\n/**\n * What a rich client replied, when it replied with the whole questionnaire.\n *\n * A client that can see the whole call answers every question at once rather\n * than walking this loop, so any select response may be an envelope instead of\n * a label. A response that is not one leaves the loop exactly as it was, which\n * is what keeps a plain RPC client working unchanged.\n */\ninterface EnvelopeSink {\n params: QuestionParams;\n /** Set once a select response carried the whole questionnaire; the loop stops and returns it. */\n result?: QuestionnaireResult;\n}\n\n/** True when this response was the whole set, in which case the sink now holds the answer. */\nfunction takeEnvelope(sink: EnvelopeSink, selected: string | undefined): boolean {\n const read = decodeAnswerEnvelope(selected);\n if (read.kind === 'absent') return false;\n if (read.kind === 'malformed') {\n sink.result = { answers: [], cancelled: true, error: 'malformed_answers' };\n return true;\n }\n const envelope = readAnswerEnvelope(read.payload, sink.params);\n sink.result = envelope.ok\n ? { answers: envelope.answers, cancelled: false }\n : { answers: [], cancelled: true, error: 'malformed_answers' };\n return true;\n}\n\nasync function askForCustomAnswer(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const answer = await context.ui.input(question.question, 'Type your answer', { signal });\n if (answer === undefined) return undefined;\n return {\n questionIndex,\n question: question.question,\n kind: 'custom',\n answer,\n };\n}\n\nasync function askSingleChoice(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n sink: EnvelopeSink,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const labels = [...question.options.map((option) => option.label), CUSTOM_LABEL];\n const selected = await context.ui.select(question.question, labels, { signal });\n if (selected === undefined) return undefined;\n if (takeEnvelope(sink, selected)) return undefined;\n if (selected === CUSTOM_LABEL) {\n return askForCustomAnswer(context, question, questionIndex, signal);\n }\n const option = question.options.find((candidate) => candidate.label === selected);\n if (!option) return undefined;\n return {\n questionIndex,\n question: question.question,\n kind: 'option',\n answer: option.label,\n ...(option.preview ? { preview: option.preview } : {}),\n };\n}\n\nfunction multiChoiceLabels(question: QuestionData, selected: ReadonlySet<string>): string[] {\n const options = question.options.map((option) => {\n const marker = selected.has(option.label) ? '[x]' : '[ ]';\n return `${marker} ${option.label}`;\n });\n return [...options, CUSTOM_LABEL, DONE_LABEL];\n}\n\nfunction stripSelectionMarker(value: string): string {\n return value.replace(/^\\[[ x]\\]\\s/u, '');\n}\n\nasync function askMultipleChoice(\n context: ExtensionContext,\n question: QuestionData,\n questionIndex: number,\n sink: EnvelopeSink,\n signal?: AbortSignal,\n): Promise<QuestionAnswer | undefined> {\n const selected = new Set<string>();\n while (!signal?.aborted) {\n const choice = await context.ui.select(\n `${question.question} (choose options, then Next)`,\n multiChoiceLabels(question, selected),\n { signal },\n );\n if (choice === undefined) return undefined;\n if (takeEnvelope(sink, choice)) return undefined;\n if (choice === CUSTOM_LABEL) {\n return askForCustomAnswer(context, question, questionIndex, signal);\n }\n if (choice === DONE_LABEL) {\n return {\n questionIndex,\n question: question.question,\n kind: 'multi',\n answer: null,\n selected: [...selected],\n };\n }\n const label = stripSelectionMarker(choice);\n if (!question.options.some((option) => option.label === label)) continue;\n if (selected.has(label)) selected.delete(label);\n else selected.add(label);\n }\n return undefined;\n}\n\nexport async function runRpcQuestionnaire(\n context: ExtensionContext,\n params: QuestionParams,\n signal?: AbortSignal,\n reportProgress?: (result: QuestionnaireResult) => void,\n): Promise<QuestionnaireResult> {\n const answers: QuestionAnswer[] = [];\n const sink: EnvelopeSink = { params };\n for (let questionIndex = 0; questionIndex < params.questions.length; questionIndex += 1) {\n if (signal?.aborted) return cancelled(answers);\n const question = params.questions[questionIndex];\n if (!question) continue;\n const answer = question.multiSelect\n ? await askMultipleChoice(context, question, questionIndex, sink, signal)\n : await askSingleChoice(context, question, questionIndex, sink, signal);\n // A client that answered everything at once ends the walk here; the loop\n // is only how a client that can see one question at a time gets through.\n if (sink.result !== undefined) {\n const whole = sink.result;\n if (!whole.cancelled) {\n reportProgress?.({ ...whole, answers: whole.answers.map((item) => ({ ...item })) });\n }\n return whole;\n }\n if (!answer) return cancelled(answers);\n answers.push(answer);\n reportProgress?.({ answers: answers.map((item) => ({ ...item })), cancelled: false });\n }\n return { answers, cancelled: false };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=4e3,t=[`option`,`custom`,`multi`];function n(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function r(t){if(typeof t!=`string`)return;let n=t.trim();return n===``?void 0:n.slice(0,e)}function i(i,a,o){if(!n(i))return`an answer was not an object`;let s=i.questionIndex;if(typeof s!=`number`||!Number.isInteger(s))return`an answer carried no question index`;let c=a.questions[s];if(c===void 0)return`no question ${String(s)} was asked`;if(o.has(s))return`question ${String(s)} was answered twice`;o.add(s);let l=i.kind;if(typeof l!=`string`||!t.includes(l))return`question ${String(s)} carried an unknown answer kind`;let u=r(i.notes),d={questionIndex:s,question:c.question,...u===void 0?{}:{notes:u}};if(l===`multi`){let e=i.selected;if(!Array.isArray(e))return`question ${String(s)} selected nothing readable`;let t=e.filter(e=>typeof e==`string`);return t.length===e.length?t.some(e=>!c.options.some(t=>t.label===e))?`question ${String(s)} selected an option it was not offered`:{...d,kind:`multi`,answer:null,selected:[...new Set(t)]}:`question ${String(s)} selected a non-label`}if(l===`option`){let e=i.answer,t=typeof e==`string`?c.options.find(t=>t.label===e):void 0;return t===void 0?`question ${String(s)} chose an option it was not offered`:{...d,kind:`option`,answer:t.label,...t.preview?{preview:t.preview}:{}}}let f=i.answer;return typeof f==`string`?{...d,kind:`custom`,answer:f.slice(0,e)}:`question ${String(s)} typed nothing readable`}function a(e,t){if(!n(e)||!Array.isArray(e.answers)||e.answers.length===0)return{ok:!1,reason:`no answers were carried`};if(e.answers.length>t.questions.length)return{ok:!1,reason:`more answers than questions`};let r=new Set,a=[];for(let n of e.answers){let e=i(n,t,r);if(typeof e==`string`)return{ok:!1,reason:e};a.push(e)}return a.sort((e,t)=>e.questionIndex-t.questionIndex),{ok:!0,answers:a}}exports.MAX_ANSWER_TEXT_LENGTH=e,exports.readAnswerEnvelope=a;
|
|
2
|
+
//# sourceMappingURL=answerEnvelope.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"answerEnvelope.cjs","names":[],"sources":["../../src/services/answerEnvelope.ts"],"sourcesContent":["import type { QuestionData, QuestionParams } from '../schemas/questionnaire.js';\nimport type { AnswerKind, QuestionAnswer } from '../types/questionnaire.js';\n\n/**\n * Read a whole-questionnaire answer envelope back against the questions it claims to answer.\n *\n * The envelope arrives on a select response, which is reader input rather than\n * anything the tool authored, so nothing in it is taken on trust: an index has\n * to name a question that was asked, a chosen label has to be one that\n * question offered, and free text is capped. An envelope that fails is refused\n * rather than repaired, because a half-read answer set reaches the model as\n * the reader's decision and there is no way to tell from there that part of it\n * was invented.\n *\n * `preview` is the clearest case: it is read from the option the tool\n * declared, never from the wire, so the model cannot be shown a preview that\n * was never offered.\n *\n * Host-neutral by construction: the questions and the payload both arrive as\n * arguments, so the same arguments always produce the same result.\n */\nexport const MAX_ANSWER_TEXT_LENGTH = 4000;\n\nexport type AnswerEnvelopeResult =\n | { readonly ok: true; readonly answers: QuestionAnswer[] }\n | { readonly ok: false; readonly reason: string };\n\nconst ANSWER_KINDS: readonly string[] = ['option', 'custom', 'multi'] satisfies readonly AnswerKind[];\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction optionalNote(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined;\n const trimmed = value.trim();\n return trimmed === '' ? undefined : trimmed.slice(0, MAX_ANSWER_TEXT_LENGTH);\n}\n\n/** One answer, rebuilt from the question it names rather than copied off the wire. */\nfunction readAnswer(entry: unknown, params: QuestionParams, seen: Set<number>): QuestionAnswer | string {\n if (!isRecord(entry)) return 'an answer was not an object';\n const questionIndex = entry.questionIndex;\n if (typeof questionIndex !== 'number' || !Number.isInteger(questionIndex)) {\n return 'an answer carried no question index';\n }\n const question: QuestionData | undefined = params.questions[questionIndex];\n if (question === undefined) return `no question ${String(questionIndex)} was asked`;\n if (seen.has(questionIndex)) return `question ${String(questionIndex)} was answered twice`;\n seen.add(questionIndex);\n\n const kind = entry.kind;\n if (typeof kind !== 'string' || !ANSWER_KINDS.includes(kind)) {\n return `question ${String(questionIndex)} carried an unknown answer kind`;\n }\n const notes = optionalNote(entry.notes);\n const base = { questionIndex, question: question.question, ...(notes === undefined ? {} : { notes }) };\n\n if (kind === 'multi') {\n const selected = entry.selected;\n if (!Array.isArray(selected)) return `question ${String(questionIndex)} selected nothing readable`;\n const labels = selected.filter((label): label is string => typeof label === 'string');\n if (labels.length !== selected.length) return `question ${String(questionIndex)} selected a non-label`;\n if (labels.some((label) => !question.options.some((option) => option.label === label))) {\n return `question ${String(questionIndex)} selected an option it was not offered`;\n }\n return { ...base, kind: 'multi', answer: null, selected: [...new Set(labels)] };\n }\n\n if (kind === 'option') {\n const label = entry.answer;\n const chosen = typeof label === 'string' ? question.options.find((option) => option.label === label) : undefined;\n if (chosen === undefined) return `question ${String(questionIndex)} chose an option it was not offered`;\n // From the option the tool declared, so a preview the reader never saw\n // cannot be put in the model's mouth.\n return { ...base, kind: 'option', answer: chosen.label, ...(chosen.preview ? { preview: chosen.preview } : {}) };\n }\n\n const typed = entry.answer;\n if (typeof typed !== 'string') return `question ${String(questionIndex)} typed nothing readable`;\n return { ...base, kind: 'custom', answer: typed.slice(0, MAX_ANSWER_TEXT_LENGTH) };\n}\n\nexport function readAnswerEnvelope(payload: unknown, params: QuestionParams): AnswerEnvelopeResult {\n if (!isRecord(payload) || !Array.isArray(payload.answers)) return { ok: false, reason: 'no answers were carried' };\n if (payload.answers.length === 0) return { ok: false, reason: 'no answers were carried' };\n if (payload.answers.length > params.questions.length) return { ok: false, reason: 'more answers than questions' };\n\n const seen = new Set<number>();\n const answers: QuestionAnswer[] = [];\n for (const entry of payload.answers) {\n const answer = readAnswer(entry, params, seen);\n if (typeof answer === 'string') return { ok: false, reason: answer };\n answers.push(answer);\n }\n // The model reads them in the order it asked, whatever order they arrived in.\n answers.sort((left, right) => left.questionIndex - right.questionIndex);\n return { ok: true, answers };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=4e3,t=[`option`,`custom`,`multi`];function n(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function r(t){if(typeof t!=`string`)return;let n=t.trim();return n===``?void 0:n.slice(0,e)}function i(i,a,o){if(!n(i))return`an answer was not an object`;let s=i.questionIndex;if(typeof s!=`number`||!Number.isInteger(s))return`an answer carried no question index`;let c=a.questions[s];if(c===void 0)return`no question ${String(s)} was asked`;if(o.has(s))return`question ${String(s)} was answered twice`;o.add(s);let l=i.kind;if(typeof l!=`string`||!t.includes(l))return`question ${String(s)} carried an unknown answer kind`;let u=r(i.notes),d={questionIndex:s,question:c.question,...u===void 0?{}:{notes:u}};if(l===`multi`){let e=i.selected;if(!Array.isArray(e))return`question ${String(s)} selected nothing readable`;let t=e.filter(e=>typeof e==`string`);return t.length===e.length?t.some(e=>!c.options.some(t=>t.label===e))?`question ${String(s)} selected an option it was not offered`:{...d,kind:`multi`,answer:null,selected:[...new Set(t)]}:`question ${String(s)} selected a non-label`}if(l===`option`){let e=i.answer,t=typeof e==`string`?c.options.find(t=>t.label===e):void 0;return t===void 0?`question ${String(s)} chose an option it was not offered`:{...d,kind:`option`,answer:t.label,...t.preview?{preview:t.preview}:{}}}let f=i.answer;return typeof f==`string`?{...d,kind:`custom`,answer:f.slice(0,e)}:`question ${String(s)} typed nothing readable`}function a(e,t){if(!n(e)||!Array.isArray(e.answers)||e.answers.length===0)return{ok:!1,reason:`no answers were carried`};if(e.answers.length>t.questions.length)return{ok:!1,reason:`more answers than questions`};let r=new Set,a=[];for(let n of e.answers){let e=i(n,t,r);if(typeof e==`string`)return{ok:!1,reason:e};a.push(e)}return a.sort((e,t)=>e.questionIndex-t.questionIndex),{ok:!0,answers:a}}export{e as MAX_ANSWER_TEXT_LENGTH,a as readAnswerEnvelope};
|
|
2
|
+
//# sourceMappingURL=answerEnvelope.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"answerEnvelope.mjs","names":[],"sources":["../../src/services/answerEnvelope.ts"],"sourcesContent":["import type { QuestionData, QuestionParams } from '../schemas/questionnaire.js';\nimport type { AnswerKind, QuestionAnswer } from '../types/questionnaire.js';\n\n/**\n * Read a whole-questionnaire answer envelope back against the questions it claims to answer.\n *\n * The envelope arrives on a select response, which is reader input rather than\n * anything the tool authored, so nothing in it is taken on trust: an index has\n * to name a question that was asked, a chosen label has to be one that\n * question offered, and free text is capped. An envelope that fails is refused\n * rather than repaired, because a half-read answer set reaches the model as\n * the reader's decision and there is no way to tell from there that part of it\n * was invented.\n *\n * `preview` is the clearest case: it is read from the option the tool\n * declared, never from the wire, so the model cannot be shown a preview that\n * was never offered.\n *\n * Host-neutral by construction: the questions and the payload both arrive as\n * arguments, so the same arguments always produce the same result.\n */\nexport const MAX_ANSWER_TEXT_LENGTH = 4000;\n\nexport type AnswerEnvelopeResult =\n | { readonly ok: true; readonly answers: QuestionAnswer[] }\n | { readonly ok: false; readonly reason: string };\n\nconst ANSWER_KINDS: readonly string[] = ['option', 'custom', 'multi'] satisfies readonly AnswerKind[];\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction optionalNote(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined;\n const trimmed = value.trim();\n return trimmed === '' ? undefined : trimmed.slice(0, MAX_ANSWER_TEXT_LENGTH);\n}\n\n/** One answer, rebuilt from the question it names rather than copied off the wire. */\nfunction readAnswer(entry: unknown, params: QuestionParams, seen: Set<number>): QuestionAnswer | string {\n if (!isRecord(entry)) return 'an answer was not an object';\n const questionIndex = entry.questionIndex;\n if (typeof questionIndex !== 'number' || !Number.isInteger(questionIndex)) {\n return 'an answer carried no question index';\n }\n const question: QuestionData | undefined = params.questions[questionIndex];\n if (question === undefined) return `no question ${String(questionIndex)} was asked`;\n if (seen.has(questionIndex)) return `question ${String(questionIndex)} was answered twice`;\n seen.add(questionIndex);\n\n const kind = entry.kind;\n if (typeof kind !== 'string' || !ANSWER_KINDS.includes(kind)) {\n return `question ${String(questionIndex)} carried an unknown answer kind`;\n }\n const notes = optionalNote(entry.notes);\n const base = { questionIndex, question: question.question, ...(notes === undefined ? {} : { notes }) };\n\n if (kind === 'multi') {\n const selected = entry.selected;\n if (!Array.isArray(selected)) return `question ${String(questionIndex)} selected nothing readable`;\n const labels = selected.filter((label): label is string => typeof label === 'string');\n if (labels.length !== selected.length) return `question ${String(questionIndex)} selected a non-label`;\n if (labels.some((label) => !question.options.some((option) => option.label === label))) {\n return `question ${String(questionIndex)} selected an option it was not offered`;\n }\n return { ...base, kind: 'multi', answer: null, selected: [...new Set(labels)] };\n }\n\n if (kind === 'option') {\n const label = entry.answer;\n const chosen = typeof label === 'string' ? question.options.find((option) => option.label === label) : undefined;\n if (chosen === undefined) return `question ${String(questionIndex)} chose an option it was not offered`;\n // From the option the tool declared, so a preview the reader never saw\n // cannot be put in the model's mouth.\n return { ...base, kind: 'option', answer: chosen.label, ...(chosen.preview ? { preview: chosen.preview } : {}) };\n }\n\n const typed = entry.answer;\n if (typeof typed !== 'string') return `question ${String(questionIndex)} typed nothing readable`;\n return { ...base, kind: 'custom', answer: typed.slice(0, MAX_ANSWER_TEXT_LENGTH) };\n}\n\nexport function readAnswerEnvelope(payload: unknown, params: QuestionParams): AnswerEnvelopeResult {\n if (!isRecord(payload) || !Array.isArray(payload.answers)) return { ok: false, reason: 'no answers were carried' };\n if (payload.answers.length === 0) return { ok: false, reason: 'no answers were carried' };\n if (payload.answers.length > params.questions.length) return { ok: false, reason: 'more answers than questions' };\n\n const seen = new Set<number>();\n const answers: QuestionAnswer[] = [];\n for (const entry of payload.answers) {\n const answer = readAnswer(entry, params, seen);\n if (typeof answer === 'string') return { ok: false, reason: answer };\n answers.push(answer);\n }\n // The model reads them in the order it asked, whatever order they arrived in.\n answers.sort((left, right) => left.questionIndex - right.questionIndex);\n return { ok: true, answers };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
function e(e){if(e===void 0||!e.startsWith(`doom-ask-user/v1:`))return{kind:`absent`};if(e.length>65536)return{kind:`malformed`};try{return{kind:`envelope`,payload:JSON.parse(e.slice(17))}}catch{return{kind:`malformed`}}}exports.ANSWER_ENVELOPE_PREFIX=`doom-ask-user/v1:`,exports.MAX_ENVELOPE_BYTES=65536,exports.decodeAnswerEnvelope=e;
|
|
2
|
+
//# sourceMappingURL=askUserWire.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"askUserWire.cjs","names":[],"sources":["../../src/types/askUserWire.ts"],"sourcesContent":["import type { QuestionAnswer } from './questionnaire.ts';\n\n/**\n * How a rich client answers the whole questionnaire in one reply.\n *\n * Pi's select request carries a title and a list of labels and nothing else,\n * so over RPC the questionnaire degrades into one request per question, asked\n * forwards only. A client that can already see the whole call (the cockpit\n * reads it from the tool's arguments) has no use for that: it renders every\n * question at once, lets the reader move between them, and replies once.\n *\n * The reply rides on the select response, because that is the only channel a\n * blocked extension has. An ordinary label is still an ordinary label, so a\n * client that knows nothing about this keeps the per-question flow exactly as\n * it was; only a response carrying the prefix is read as the whole set.\n *\n * It lives under src/types because that is the one server root a browser\n * bundle may read, so neither half can drift from the other.\n */\nexport const ANSWER_ENVELOPE_PREFIX = 'doom-ask-user/v1:';\n\n/** A response that claims the prefix but cannot be read is refused, never guessed at. */\nexport type AnswerEnvelopeRead =\n | { readonly kind: 'absent' }\n | { readonly kind: 'malformed' }\n | { readonly kind: 'envelope'; readonly payload: unknown };\n\n/** Beyond this a response is not a questionnaire answer, whatever it claims. */\nexport const MAX_ENVELOPE_BYTES = 64 * 1024;\n\nexport function encodeAnswerEnvelope(answers: readonly QuestionAnswer[]): string {\n return `${ANSWER_ENVELOPE_PREFIX}${JSON.stringify({ answers })}`;\n}\n\nexport function decodeAnswerEnvelope(value: string | undefined): AnswerEnvelopeRead {\n if (value === undefined || !value.startsWith(ANSWER_ENVELOPE_PREFIX)) return { kind: 'absent' };\n if (value.length > MAX_ENVELOPE_BYTES) return { kind: 'malformed' };\n try {\n return { kind: 'envelope', payload: JSON.parse(value.slice(ANSWER_ENVELOPE_PREFIX.length)) };\n } catch {\n // Unreadable JSON behind the prefix: the sender meant an envelope and got\n // it wrong, which is a refusal rather than a label the model should see.\n return { kind: 'malformed' };\n }\n}\n"],"mappings":"AAkCA,SAAgB,EAAqB,EAA+C,CAClF,GAAI,IAAU,IAAA,IAAa,CAAC,EAAM,WAAA,mBAAiC,EAAG,MAAO,CAAE,KAAM,QAAS,EAC9F,GAAI,EAAM,OAAA,MAA6B,MAAO,CAAE,KAAM,WAAY,EAClE,GAAI,CACF,MAAO,CAAE,KAAM,WAAY,QAAS,KAAK,MAAM,EAAM,MAAM,EAA6B,CAAC,CAAE,CAC7F,MAAQ,CAGN,MAAO,CAAE,KAAM,WAAY,CAC7B,CACF"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=`doom-ask-user/v1:`,t=65536;function n(e){if(e===void 0||!e.startsWith(`doom-ask-user/v1:`))return{kind:`absent`};if(e.length>65536)return{kind:`malformed`};try{return{kind:`envelope`,payload:JSON.parse(e.slice(17))}}catch{return{kind:`malformed`}}}export{e as ANSWER_ENVELOPE_PREFIX,t as MAX_ENVELOPE_BYTES,n as decodeAnswerEnvelope};
|
|
2
|
+
//# sourceMappingURL=askUserWire.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"askUserWire.mjs","names":[],"sources":["../../src/types/askUserWire.ts"],"sourcesContent":["import type { QuestionAnswer } from './questionnaire.ts';\n\n/**\n * How a rich client answers the whole questionnaire in one reply.\n *\n * Pi's select request carries a title and a list of labels and nothing else,\n * so over RPC the questionnaire degrades into one request per question, asked\n * forwards only. A client that can already see the whole call (the cockpit\n * reads it from the tool's arguments) has no use for that: it renders every\n * question at once, lets the reader move between them, and replies once.\n *\n * The reply rides on the select response, because that is the only channel a\n * blocked extension has. An ordinary label is still an ordinary label, so a\n * client that knows nothing about this keeps the per-question flow exactly as\n * it was; only a response carrying the prefix is read as the whole set.\n *\n * It lives under src/types because that is the one server root a browser\n * bundle may read, so neither half can drift from the other.\n */\nexport const ANSWER_ENVELOPE_PREFIX = 'doom-ask-user/v1:';\n\n/** A response that claims the prefix but cannot be read is refused, never guessed at. */\nexport type AnswerEnvelopeRead =\n | { readonly kind: 'absent' }\n | { readonly kind: 'malformed' }\n | { readonly kind: 'envelope'; readonly payload: unknown };\n\n/** Beyond this a response is not a questionnaire answer, whatever it claims. */\nexport const MAX_ENVELOPE_BYTES = 64 * 1024;\n\nexport function encodeAnswerEnvelope(answers: readonly QuestionAnswer[]): string {\n return `${ANSWER_ENVELOPE_PREFIX}${JSON.stringify({ answers })}`;\n}\n\nexport function decodeAnswerEnvelope(value: string | undefined): AnswerEnvelopeRead {\n if (value === undefined || !value.startsWith(ANSWER_ENVELOPE_PREFIX)) return { kind: 'absent' };\n if (value.length > MAX_ENVELOPE_BYTES) return { kind: 'malformed' };\n try {\n return { kind: 'envelope', payload: JSON.parse(value.slice(ANSWER_ENVELOPE_PREFIX.length)) };\n } catch {\n // Unreadable JSON behind the prefix: the sender meant an envelope and got\n // it wrong, which is a refusal rather than a label the model should see.\n return { kind: 'malformed' };\n }\n}\n"],"mappings":"AAmBA,MAAa,EAAyB,oBASzB,EAAqB,MAMlC,SAAgB,EAAqB,EAA+C,CAClF,GAAI,IAAU,IAAA,IAAa,CAAC,EAAM,WAAA,mBAAiC,EAAG,MAAO,CAAE,KAAM,QAAS,EAC9F,GAAI,EAAM,OAAA,MAA6B,MAAO,CAAE,KAAM,WAAY,EAClE,GAAI,CACF,MAAO,CAAE,KAAM,WAAY,QAAS,KAAK,MAAM,EAAM,MAAM,EAA6B,CAAC,CAAE,CAC7F,MAAQ,CAGN,MAAO,CAAE,KAAM,WAAY,CAC7B,CACF"}
|
|
@@ -9,7 +9,9 @@ interface QuestionAnswer {
|
|
|
9
9
|
notes?: string;
|
|
10
10
|
preview?: string;
|
|
11
11
|
}
|
|
12
|
-
type QuestionnaireError = 'no_ui' | 'no_custom_ui' | 'no_questions' | 'empty_options' | 'too_many_questions' | 'duplicate_question' | 'duplicate_option_label' | 'reserved_label' | 'session_load_failed' | 'stale_module_cache'
|
|
12
|
+
type QuestionnaireError = 'no_ui' | 'no_custom_ui' | 'no_questions' | 'empty_options' | 'too_many_questions' | 'duplicate_question' | 'duplicate_option_label' | 'reserved_label' | 'session_load_failed' | 'stale_module_cache' |
|
|
13
|
+
/** A client replied with an answer envelope that did not match the questions asked. */
|
|
14
|
+
'malformed_answers';
|
|
13
15
|
interface QuestionnaireResult {
|
|
14
16
|
answers: QuestionAnswer[];
|
|
15
17
|
cancelled: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"questionnaire.d.cts","names":[],"sources":["../../src/types/questionnaire.ts"],"mappings":";KAAY;UAEK;EACf;EACA;EACA,MAAM;EACN;EACA;EACA;EACA;;KAGU;
|
|
1
|
+
{"version":3,"file":"questionnaire.d.cts","names":[],"sources":["../../src/types/questionnaire.ts"],"mappings":";KAAY;UAEK;EACf;EACA;EACA,MAAM;EACN;EACA;EACA;EACA;;KAGU;;;UAkBK;EACf,SAAS;EACT;EACA,QAAQ;EACR;EACA;EACA;;UAGe;EACf,SAAS;IAAQ;IAAc;;EAC/B,SAAS;EACT"}
|
|
@@ -9,7 +9,9 @@ interface QuestionAnswer {
|
|
|
9
9
|
notes?: string;
|
|
10
10
|
preview?: string;
|
|
11
11
|
}
|
|
12
|
-
type QuestionnaireError = 'no_ui' | 'no_custom_ui' | 'no_questions' | 'empty_options' | 'too_many_questions' | 'duplicate_question' | 'duplicate_option_label' | 'reserved_label' | 'session_load_failed' | 'stale_module_cache'
|
|
12
|
+
type QuestionnaireError = 'no_ui' | 'no_custom_ui' | 'no_questions' | 'empty_options' | 'too_many_questions' | 'duplicate_question' | 'duplicate_option_label' | 'reserved_label' | 'session_load_failed' | 'stale_module_cache' |
|
|
13
|
+
/** A client replied with an answer envelope that did not match the questions asked. */
|
|
14
|
+
'malformed_answers';
|
|
13
15
|
interface QuestionnaireResult {
|
|
14
16
|
answers: QuestionAnswer[];
|
|
15
17
|
cancelled: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"questionnaire.d.mts","names":[],"sources":["../../src/types/questionnaire.ts"],"mappings":";KAAY;UAEK;EACf;EACA;EACA,MAAM;EACN;EACA;EACA;EACA;;KAGU;
|
|
1
|
+
{"version":3,"file":"questionnaire.d.mts","names":[],"sources":["../../src/types/questionnaire.ts"],"mappings":";KAAY;UAEK;EACf;EACA;EACA,MAAM;EACN;EACA;EACA;EACA;;KAGU;;;UAkBK;EACf,SAAS;EACT;EACA,QAAQ;EACR;EACA;EACA;;UAGe;EACf,SAAS;IAAQ;IAAc;;EAC/B,SAAS;EACT"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agimon-ai/doompi-user-feedback",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.29",
|
|
4
4
|
"description": "Structured user questions with interactive and autonomous Voice handoff for Pi agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"files": [
|
|
29
29
|
"dist",
|
|
30
30
|
"web",
|
|
31
|
+
"src/types/askUserWire.ts",
|
|
32
|
+
"src/types/questionnaire.ts",
|
|
31
33
|
"README.md",
|
|
32
34
|
"LICENSE",
|
|
33
35
|
"package.json"
|
|
@@ -55,10 +57,10 @@
|
|
|
55
57
|
"dependencies": {
|
|
56
58
|
"@deepseek-ai/cordis": "4.0.1",
|
|
57
59
|
"typebox": "1.3.16",
|
|
58
|
-
"@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.
|
|
59
|
-
"@agimon-ai/doompi-ui": "0.0.1-alpha.
|
|
60
|
-
"@agimon-ai/doompi-web-components": "0.0.
|
|
61
|
-
"@agimon-ai/doompi-web-contracts": "0.0.1-alpha.
|
|
60
|
+
"@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.40",
|
|
61
|
+
"@agimon-ai/doompi-ui": "0.0.1-alpha.40",
|
|
62
|
+
"@agimon-ai/doompi-web-components": "0.0.1-alpha.1",
|
|
63
|
+
"@agimon-ai/doompi-web-contracts": "0.0.1-alpha.3"
|
|
62
64
|
},
|
|
63
65
|
"devDependencies": {
|
|
64
66
|
"@earendil-works/pi-coding-agent": "0.84.3",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { QuestionAnswer } from './questionnaire.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a rich client answers the whole questionnaire in one reply.
|
|
5
|
+
*
|
|
6
|
+
* Pi's select request carries a title and a list of labels and nothing else,
|
|
7
|
+
* so over RPC the questionnaire degrades into one request per question, asked
|
|
8
|
+
* forwards only. A client that can already see the whole call (the cockpit
|
|
9
|
+
* reads it from the tool's arguments) has no use for that: it renders every
|
|
10
|
+
* question at once, lets the reader move between them, and replies once.
|
|
11
|
+
*
|
|
12
|
+
* The reply rides on the select response, because that is the only channel a
|
|
13
|
+
* blocked extension has. An ordinary label is still an ordinary label, so a
|
|
14
|
+
* client that knows nothing about this keeps the per-question flow exactly as
|
|
15
|
+
* it was; only a response carrying the prefix is read as the whole set.
|
|
16
|
+
*
|
|
17
|
+
* It lives under src/types because that is the one server root a browser
|
|
18
|
+
* bundle may read, so neither half can drift from the other.
|
|
19
|
+
*/
|
|
20
|
+
export const ANSWER_ENVELOPE_PREFIX = 'doom-ask-user/v1:';
|
|
21
|
+
|
|
22
|
+
/** A response that claims the prefix but cannot be read is refused, never guessed at. */
|
|
23
|
+
export type AnswerEnvelopeRead =
|
|
24
|
+
| { readonly kind: 'absent' }
|
|
25
|
+
| { readonly kind: 'malformed' }
|
|
26
|
+
| { readonly kind: 'envelope'; readonly payload: unknown };
|
|
27
|
+
|
|
28
|
+
/** Beyond this a response is not a questionnaire answer, whatever it claims. */
|
|
29
|
+
export const MAX_ENVELOPE_BYTES = 64 * 1024;
|
|
30
|
+
|
|
31
|
+
export function encodeAnswerEnvelope(answers: readonly QuestionAnswer[]): string {
|
|
32
|
+
return `${ANSWER_ENVELOPE_PREFIX}${JSON.stringify({ answers })}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function decodeAnswerEnvelope(value: string | undefined): AnswerEnvelopeRead {
|
|
36
|
+
if (value === undefined || !value.startsWith(ANSWER_ENVELOPE_PREFIX)) return { kind: 'absent' };
|
|
37
|
+
if (value.length > MAX_ENVELOPE_BYTES) return { kind: 'malformed' };
|
|
38
|
+
try {
|
|
39
|
+
return { kind: 'envelope', payload: JSON.parse(value.slice(ANSWER_ENVELOPE_PREFIX.length)) };
|
|
40
|
+
} catch {
|
|
41
|
+
// Unreadable JSON behind the prefix: the sender meant an envelope and got
|
|
42
|
+
// it wrong, which is a refusal rather than a label the model should see.
|
|
43
|
+
return { kind: 'malformed' };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type AnswerKind = 'option' | 'custom' | 'multi';
|
|
2
|
+
|
|
3
|
+
export interface QuestionAnswer {
|
|
4
|
+
questionIndex: number;
|
|
5
|
+
question: string;
|
|
6
|
+
kind: AnswerKind;
|
|
7
|
+
answer: string | null;
|
|
8
|
+
selected?: string[];
|
|
9
|
+
notes?: string;
|
|
10
|
+
preview?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type QuestionnaireError =
|
|
14
|
+
| 'no_ui'
|
|
15
|
+
| 'no_custom_ui'
|
|
16
|
+
| 'no_questions'
|
|
17
|
+
| 'empty_options'
|
|
18
|
+
| 'too_many_questions'
|
|
19
|
+
| 'duplicate_question'
|
|
20
|
+
| 'duplicate_option_label'
|
|
21
|
+
| 'reserved_label'
|
|
22
|
+
| 'session_load_failed'
|
|
23
|
+
| 'stale_module_cache'
|
|
24
|
+
/** A client replied with an answer envelope that did not match the questions asked. */
|
|
25
|
+
| 'malformed_answers';
|
|
26
|
+
|
|
27
|
+
export type ExternalEditResult =
|
|
28
|
+
| { readonly status: 'complete'; readonly content: string }
|
|
29
|
+
| { readonly status: 'failed'; readonly message: string };
|
|
30
|
+
|
|
31
|
+
export interface QuestionnaireResult {
|
|
32
|
+
answers: QuestionAnswer[];
|
|
33
|
+
cancelled: boolean;
|
|
34
|
+
error?: QuestionnaireError;
|
|
35
|
+
delivery?: 'voice';
|
|
36
|
+
awaitingResponse?: boolean;
|
|
37
|
+
voicePrompt?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ToolTextResult {
|
|
41
|
+
content: Array<{ type: 'text'; text: string }>;
|
|
42
|
+
details: QuestionnaireResult;
|
|
43
|
+
terminate?: boolean;
|
|
44
|
+
}
|
|
@@ -13,7 +13,9 @@ function AskUserQuestionBody({ result, output, running, isError }: ToolMessageRe
|
|
|
13
13
|
if (running) {
|
|
14
14
|
return (
|
|
15
15
|
<MessageItemBody data-testid="tool-result-ask_user_question">
|
|
16
|
-
|
|
16
|
+
{/* The questionnaire is below, where the input usually is; saying only
|
|
17
|
+
"waiting" would leave the reader looking for it. */}
|
|
18
|
+
<MessageItemStatus tone="running">answer below to continue</MessageItemStatus>
|
|
17
19
|
</MessageItemBody>
|
|
18
20
|
);
|
|
19
21
|
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge,
|
|
3
|
+
Button,
|
|
4
|
+
Kbd,
|
|
5
|
+
Markdown,
|
|
6
|
+
OptionLabel,
|
|
7
|
+
OptionRow,
|
|
8
|
+
SectionLabel,
|
|
9
|
+
Separator,
|
|
10
|
+
Textarea,
|
|
11
|
+
} from '@agimon-ai/doompi-web-components';
|
|
12
|
+
import type { ToolPromptRenderProps } from '@agimon-ai/doompi-web-contracts';
|
|
13
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
14
|
+
import { encodeAnswerEnvelope } from '../src/types/askUserWire.ts';
|
|
15
|
+
import {
|
|
16
|
+
chooseOption,
|
|
17
|
+
CUSTOM_LABEL,
|
|
18
|
+
draftAnswers,
|
|
19
|
+
draftEntry,
|
|
20
|
+
emptyDraft,
|
|
21
|
+
isAnswered,
|
|
22
|
+
isComplete,
|
|
23
|
+
nextUnanswered,
|
|
24
|
+
type PromptQuestion,
|
|
25
|
+
type QuestionnaireDraft,
|
|
26
|
+
readPromptQuestions,
|
|
27
|
+
setCustom,
|
|
28
|
+
setNotes,
|
|
29
|
+
} from './questionnaireDraft.ts';
|
|
30
|
+
|
|
31
|
+
/** The step bar: which questions are answered, which one is open, and a click back to any of them. */
|
|
32
|
+
function StepBar({
|
|
33
|
+
questions,
|
|
34
|
+
draft,
|
|
35
|
+
current,
|
|
36
|
+
onPick,
|
|
37
|
+
}: {
|
|
38
|
+
questions: readonly PromptQuestion[];
|
|
39
|
+
draft: QuestionnaireDraft;
|
|
40
|
+
current: number;
|
|
41
|
+
onPick: (index: number) => void;
|
|
42
|
+
}) {
|
|
43
|
+
return (
|
|
44
|
+
<div data-testid="questionnaire-steps" className="flex min-w-0 flex-wrap items-center gap-1.5">
|
|
45
|
+
{questions.map((question, index) => {
|
|
46
|
+
const answered = isAnswered(draftEntry(draft, index));
|
|
47
|
+
const active = index === current;
|
|
48
|
+
return (
|
|
49
|
+
<Button
|
|
50
|
+
key={question.question}
|
|
51
|
+
variant="ghost"
|
|
52
|
+
size="xs"
|
|
53
|
+
data-testid={`questionnaire-step-${String(index)}`}
|
|
54
|
+
data-step-state={active ? 'current' : answered ? 'answered' : 'pending'}
|
|
55
|
+
onClick={() => onPick(index)}
|
|
56
|
+
className={`gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] ${
|
|
57
|
+
active
|
|
58
|
+
? 'bg-doom-selected text-doom-on-selected'
|
|
59
|
+
: answered
|
|
60
|
+
? 'text-doom-green hover:bg-doom-panel'
|
|
61
|
+
: 'text-doom-faint hover:bg-doom-panel'
|
|
62
|
+
}`}
|
|
63
|
+
>
|
|
64
|
+
<span aria-hidden>{answered ? '✓' : '○'}</span>
|
|
65
|
+
{question.header || `question ${String(index + 1)}`}
|
|
66
|
+
</Button>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One question's options, its typed-answer row, and the preview of whatever is focused. */
|
|
74
|
+
function QuestionBody({
|
|
75
|
+
question,
|
|
76
|
+
index,
|
|
77
|
+
draft,
|
|
78
|
+
cursor,
|
|
79
|
+
onCursor,
|
|
80
|
+
onDraft,
|
|
81
|
+
}: {
|
|
82
|
+
question: PromptQuestion;
|
|
83
|
+
index: number;
|
|
84
|
+
draft: QuestionnaireDraft;
|
|
85
|
+
cursor: number;
|
|
86
|
+
onCursor: (cursor: number) => void;
|
|
87
|
+
/** `settles` is true only for an edit that finishes the question, which is what may move on. */
|
|
88
|
+
onDraft: (draft: QuestionnaireDraft, settles: boolean) => void;
|
|
89
|
+
}) {
|
|
90
|
+
const entry = draftEntry(draft, index);
|
|
91
|
+
const typing = entry.custom !== null;
|
|
92
|
+
const preview = question.options[cursor]?.preview;
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
95
|
+
<p data-testid="questionnaire-question" className="break-words text-[13px] leading-relaxed text-doom-hi">
|
|
96
|
+
{question.question}
|
|
97
|
+
</p>
|
|
98
|
+
|
|
99
|
+
<div role="listbox" aria-label="options" className="flex min-w-0 flex-col">
|
|
100
|
+
{question.options.map((option, optionIndex) => {
|
|
101
|
+
const chosen = entry.selected.includes(option.label);
|
|
102
|
+
return (
|
|
103
|
+
<OptionRow
|
|
104
|
+
key={option.label}
|
|
105
|
+
active={optionIndex === cursor && !typing}
|
|
106
|
+
data-testid={`questionnaire-option-${String(optionIndex)}`}
|
|
107
|
+
data-chosen={chosen}
|
|
108
|
+
onMouseEnter={() => onCursor(optionIndex)}
|
|
109
|
+
// A multiSelect question is not finished by one click: moving on
|
|
110
|
+
// there would take the list away before a second option could be
|
|
111
|
+
// picked, which is the whole point of the question.
|
|
112
|
+
onClick={() =>
|
|
113
|
+
onDraft(chooseOption(draft, index, option.label, question.multiSelect), !question.multiSelect)
|
|
114
|
+
}
|
|
115
|
+
className="min-w-0 items-start gap-2.5 rounded-md px-2.5 py-1.5 whitespace-normal"
|
|
116
|
+
>
|
|
117
|
+
<span className={`mt-[2px] shrink-0 text-[11px] ${chosen ? 'text-doom-green' : 'text-doom-faint'}`}>
|
|
118
|
+
{question.multiSelect ? (chosen ? '[x]' : '[ ]') : chosen ? '●' : '○'}
|
|
119
|
+
</span>
|
|
120
|
+
<OptionLabel className="min-w-0 flex flex-col gap-0.5 text-left">
|
|
121
|
+
<span className={`break-words text-[12px] ${chosen ? 'text-doom-hi' : 'text-doom-text'}`}>
|
|
122
|
+
{option.label}
|
|
123
|
+
</span>
|
|
124
|
+
{option.description ? (
|
|
125
|
+
<span className="break-words text-[11px] leading-relaxed whitespace-normal text-doom-dim">
|
|
126
|
+
{option.description}
|
|
127
|
+
</span>
|
|
128
|
+
) : null}
|
|
129
|
+
</OptionLabel>
|
|
130
|
+
</OptionRow>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
|
|
134
|
+
<OptionRow
|
|
135
|
+
active={typing}
|
|
136
|
+
data-testid="questionnaire-option-custom"
|
|
137
|
+
onClick={() => onDraft(setCustom(draft, index, entry.custom ?? ''), false)}
|
|
138
|
+
className="min-w-0 items-center gap-2.5 rounded-md px-2.5 py-1.5 whitespace-normal"
|
|
139
|
+
>
|
|
140
|
+
<span className="shrink-0 text-[11px] text-doom-faint">✎</span>
|
|
141
|
+
<OptionLabel className="min-w-0 text-[12px] whitespace-normal text-doom-text">{CUSTOM_LABEL}</OptionLabel>
|
|
142
|
+
</OptionRow>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{typing ? (
|
|
146
|
+
<div className="flex flex-col gap-1">
|
|
147
|
+
<SectionLabel>your answer</SectionLabel>
|
|
148
|
+
<Textarea
|
|
149
|
+
data-testid="questionnaire-custom"
|
|
150
|
+
autoFocus
|
|
151
|
+
rows={2}
|
|
152
|
+
value={entry.custom ?? ''}
|
|
153
|
+
placeholder="type your answer…"
|
|
154
|
+
onChange={(event) => onDraft(setCustom(draft, index, event.target.value), false)}
|
|
155
|
+
className="text-[12px]"
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
) : null}
|
|
159
|
+
|
|
160
|
+
{preview ? (
|
|
161
|
+
<div data-testid="questionnaire-preview" className="flex flex-col gap-1">
|
|
162
|
+
<SectionLabel>preview</SectionLabel>
|
|
163
|
+
<div className="max-h-40 overflow-y-auto border-l-2 border-doom-edge-magenta pl-3 text-[11px] text-doom-dim">
|
|
164
|
+
<Markdown text={preview} />
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
|
|
169
|
+
<div className="flex flex-col gap-1">
|
|
170
|
+
<SectionLabel>note (optional)</SectionLabel>
|
|
171
|
+
<Textarea
|
|
172
|
+
data-testid="questionnaire-notes"
|
|
173
|
+
rows={1}
|
|
174
|
+
value={entry.notes}
|
|
175
|
+
placeholder="anything the options do not cover"
|
|
176
|
+
onChange={(event) => onDraft(setNotes(draft, index, event.target.value), false)}
|
|
177
|
+
className="text-[11px]"
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The questionnaire, standing in for the composer input while the agent waits.
|
|
186
|
+
*
|
|
187
|
+
* Every question is here at once, because the cockpit can see the whole call
|
|
188
|
+
* even though the request it answers can only carry one question's labels.
|
|
189
|
+
* That is what makes the step bar honest: nothing is sent until the reader
|
|
190
|
+
* submits, so going back to a step is just moving a cursor, and the agent is
|
|
191
|
+
* asked exactly once.
|
|
192
|
+
*/
|
|
193
|
+
export function QuestionnairePrompt({ args, dialog, answer, cancel }: ToolPromptRenderProps) {
|
|
194
|
+
const questions = useMemo(() => readPromptQuestions(args), [args]);
|
|
195
|
+
const [draft, setDraft] = useState<QuestionnaireDraft>(() => emptyDraft(questions.length));
|
|
196
|
+
const [current, setCurrent] = useState(0);
|
|
197
|
+
const [cursor, setCursor] = useState(0);
|
|
198
|
+
const frame = useRef<HTMLDivElement>(null);
|
|
199
|
+
|
|
200
|
+
// A new request is a new questionnaire; nothing from the last one carries over.
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
setDraft(emptyDraft(questions.length));
|
|
203
|
+
setCurrent(0);
|
|
204
|
+
setCursor(0);
|
|
205
|
+
}, [dialog.id, questions.length]);
|
|
206
|
+
|
|
207
|
+
// The agent is blocked on this, so it takes the keyboard the input had.
|
|
208
|
+
useEffect(() => frame.current?.focus(), [dialog.id]);
|
|
209
|
+
|
|
210
|
+
const question = questions[current];
|
|
211
|
+
const complete = isComplete(draft);
|
|
212
|
+
|
|
213
|
+
const move = (next: QuestionnaireDraft, answeredIndex: number, settles: boolean): void => {
|
|
214
|
+
setDraft(next);
|
|
215
|
+
// Follow the reader forwards the way the terminal does, but only on an
|
|
216
|
+
// edit that finished the question, and only while something is still
|
|
217
|
+
// unanswered: jumping after the last answer would take the step they just
|
|
218
|
+
// finished off the screen before they could look at it.
|
|
219
|
+
if (!settles) return;
|
|
220
|
+
const pending = nextUnanswered(next, answeredIndex);
|
|
221
|
+
if (pending >= 0 && pending !== answeredIndex) {
|
|
222
|
+
setCurrent(pending);
|
|
223
|
+
setCursor(0);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const show = (index: number): void => {
|
|
228
|
+
setCurrent(index);
|
|
229
|
+
setCursor(0);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const submit = (): void => {
|
|
233
|
+
if (!complete) return;
|
|
234
|
+
answer(encodeAnswerEnvelope(draftAnswers(questions, draft)));
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
if (question === undefined) {
|
|
238
|
+
return (
|
|
239
|
+
<div className="flex items-center justify-between gap-3 px-3.5 py-3">
|
|
240
|
+
<span data-testid="questionnaire-empty" className="text-[12px] text-doom-dim">
|
|
241
|
+
the agent asked something this cockpit could not read
|
|
242
|
+
</span>
|
|
243
|
+
<Button variant="outline" size="sm" data-testid="questionnaire-cancel" onClick={cancel}>
|
|
244
|
+
cancel
|
|
245
|
+
</Button>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const typing = draftEntry(draft, current).custom !== null;
|
|
251
|
+
return (
|
|
252
|
+
// The keys belong to the frame rather than to any row, so the reader can
|
|
253
|
+
// move between questions and options without tabbing through both.
|
|
254
|
+
<div
|
|
255
|
+
ref={frame}
|
|
256
|
+
tabIndex={-1}
|
|
257
|
+
data-testid="questionnaire"
|
|
258
|
+
className="flex min-w-0 flex-col overflow-hidden outline-none"
|
|
259
|
+
onKeyDown={(event) => {
|
|
260
|
+
if (event.key === 'Escape') {
|
|
261
|
+
event.preventDefault();
|
|
262
|
+
cancel();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// While typing an answer the arrows belong to the textarea.
|
|
266
|
+
if (typing) return;
|
|
267
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
const count = question.options.length;
|
|
270
|
+
setCursor((event.key === 'ArrowDown' ? cursor + 1 : cursor - 1 + count) % count);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
|
|
274
|
+
event.preventDefault();
|
|
275
|
+
const count = questions.length;
|
|
276
|
+
show((event.key === 'ArrowRight' ? current + 1 : current - 1 + count) % count);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (event.key === 'Enter') {
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
const option = question.options[cursor];
|
|
282
|
+
if (event.metaKey || event.ctrlKey || option === undefined) {
|
|
283
|
+
submit();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
move(chooseOption(draft, current, option.label, question.multiSelect), current, !question.multiSelect);
|
|
287
|
+
}
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2 px-3 pt-2.5 pb-2 sm:gap-3 sm:px-3.5">
|
|
291
|
+
<StepBar questions={questions} draft={draft} current={current} onPick={show} />
|
|
292
|
+
<span className="min-w-0 flex-1" />
|
|
293
|
+
<Badge size="xs" className="shrink-0 border-transparent bg-doom-panel py-0.5 text-[9px] text-doom-dim">
|
|
294
|
+
question {current + 1} / {questions.length}
|
|
295
|
+
</Badge>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<Separator />
|
|
299
|
+
|
|
300
|
+
<div className="max-h-[46vh] min-w-0 overflow-x-hidden overflow-y-auto px-3 py-3 sm:px-3.5">
|
|
301
|
+
<QuestionBody
|
|
302
|
+
question={question}
|
|
303
|
+
index={current}
|
|
304
|
+
draft={draft}
|
|
305
|
+
cursor={cursor}
|
|
306
|
+
onCursor={setCursor}
|
|
307
|
+
onDraft={(next, settles) => move(next, current, settles)}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<Separator />
|
|
312
|
+
|
|
313
|
+
<div className="flex min-w-0 items-center gap-2 px-3 py-2 sm:px-3.5">
|
|
314
|
+
<span data-testid="questionnaire-hint" className="min-w-0 truncate text-[10px] text-doom-faint max-sm:hidden">
|
|
315
|
+
<Kbd>←→</Kbd> questions · <Kbd>↑↓</Kbd> options · <Kbd>enter</Kbd>{' '}
|
|
316
|
+
{question.multiSelect ? 'toggles' : 'selects'} · <Kbd>esc</Kbd> cancels
|
|
317
|
+
</span>
|
|
318
|
+
<span className="flex-1" />
|
|
319
|
+
<Button variant="outline" size="md" data-testid="questionnaire-cancel" onClick={cancel}>
|
|
320
|
+
cancel
|
|
321
|
+
</Button>
|
|
322
|
+
<Button
|
|
323
|
+
variant="primary"
|
|
324
|
+
size="md"
|
|
325
|
+
data-testid="questionnaire-submit"
|
|
326
|
+
onClick={submit}
|
|
327
|
+
disabled={!complete}
|
|
328
|
+
title={complete ? 'send every answer to the agent' : 'answer every question first'}
|
|
329
|
+
className="shrink-0 px-3 max-sm:max-w-32 max-sm:truncate sm:px-3.5"
|
|
330
|
+
>
|
|
331
|
+
submit answers
|
|
332
|
+
</Button>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
}
|
package/web/index.ts
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import { defineWebPlugin } from '@agimon-ai/doompi-web-contracts';
|
|
2
2
|
import { AskUserQuestionToolMessage } from './AskUserQuestionToolMessage.tsx';
|
|
3
|
+
import { QuestionnairePrompt } from './QuestionnairePrompt.tsx';
|
|
4
|
+
import { readPromptQuestions } from './questionnaireDraft.ts';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* This package's cockpit presence: the timeline card for ask_user_question,
|
|
6
|
-
* the web half of the TUI's renderCall and renderResult
|
|
7
|
-
* itself
|
|
8
|
-
*
|
|
8
|
+
* the web half of the TUI's renderCall and renderResult, and the questionnaire
|
|
9
|
+
* itself, which stands in for the composer input while the agent waits.
|
|
10
|
+
*
|
|
11
|
+
* The request carries only the first question's labels, so the prompt claims
|
|
12
|
+
* it by matching the title against the questions the tool was actually called
|
|
13
|
+
* with. Anything else open at the same time belongs to somebody else and keeps
|
|
14
|
+
* the host's dialog.
|
|
9
15
|
*/
|
|
10
16
|
export const webPlugin = defineWebPlugin({
|
|
11
17
|
id: 'ask-user',
|
|
12
|
-
toolRenderers: [
|
|
18
|
+
toolRenderers: [
|
|
19
|
+
{
|
|
20
|
+
tools: ['ask_user_question'],
|
|
21
|
+
message: AskUserQuestionToolMessage,
|
|
22
|
+
prompt: {
|
|
23
|
+
claims: (dialog, args) =>
|
|
24
|
+
dialog.method === 'select' &&
|
|
25
|
+
readPromptQuestions(args).some((question) => dialog.title.startsWith(question.question)),
|
|
26
|
+
component: QuestionnairePrompt,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
],
|
|
13
30
|
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The questionnaire the cockpit is filling in, as a value.
|
|
3
|
+
*
|
|
4
|
+
* Apart from React so the unit suite covers it without a DOM, the same way
|
|
5
|
+
* askUserText.ts is. The questions come from the running tool call's
|
|
6
|
+
* arguments, which are wire JSON: the shapes narrowed here mirror
|
|
7
|
+
* src/schemas/questionnaire.ts, which a browser module may not import.
|
|
8
|
+
*
|
|
9
|
+
* Everything is held here until the reader submits, which is the whole point.
|
|
10
|
+
* A step the reader goes back to still has what they chose, and the agent is
|
|
11
|
+
* asked once rather than once per question.
|
|
12
|
+
*/
|
|
13
|
+
import type { QuestionAnswer } from '../src/types/questionnaire.ts';
|
|
14
|
+
|
|
15
|
+
/** The row every question gets, so a reader always has a way past a bad set of options. */
|
|
16
|
+
export const CUSTOM_LABEL = 'Type something.';
|
|
17
|
+
|
|
18
|
+
export interface PromptOption {
|
|
19
|
+
label: string;
|
|
20
|
+
description: string;
|
|
21
|
+
preview?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PromptQuestion {
|
|
25
|
+
question: string;
|
|
26
|
+
header: string;
|
|
27
|
+
multiSelect: boolean;
|
|
28
|
+
options: PromptOption[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface QuestionDraft {
|
|
32
|
+
/** Chosen option labels: at most one unless the question is multiSelect. */
|
|
33
|
+
selected: string[];
|
|
34
|
+
/** Text typed in place of an option; null while the reader has not typed any. */
|
|
35
|
+
custom: string | null;
|
|
36
|
+
notes: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type QuestionnaireDraft = readonly QuestionDraft[];
|
|
40
|
+
|
|
41
|
+
const EMPTY_ENTRY: QuestionDraft = { selected: [], custom: null, notes: '' };
|
|
42
|
+
|
|
43
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function asString(value: unknown): string {
|
|
48
|
+
return typeof value === 'string' ? value : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readOption(value: unknown): PromptOption | null {
|
|
52
|
+
if (!isRecord(value)) return null;
|
|
53
|
+
const label = asString(value.label);
|
|
54
|
+
if (label === '') return null;
|
|
55
|
+
const preview = asString(value.preview);
|
|
56
|
+
return { label, description: asString(value.description), ...(preview === '' ? {} : { preview }) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The questions as the tool was called with them; an unreadable call yields none. */
|
|
60
|
+
export function readPromptQuestions(args: Readonly<Record<string, unknown>>): PromptQuestion[] {
|
|
61
|
+
if (!Array.isArray(args.questions)) return [];
|
|
62
|
+
return args.questions.flatMap((entry) => {
|
|
63
|
+
if (!isRecord(entry)) return [];
|
|
64
|
+
const question = asString(entry.question);
|
|
65
|
+
if (question === '') return [];
|
|
66
|
+
const options = (Array.isArray(entry.options) ? entry.options : []).flatMap((option) => {
|
|
67
|
+
const read = readOption(option);
|
|
68
|
+
return read === null ? [] : [read];
|
|
69
|
+
});
|
|
70
|
+
return [{ question, header: asString(entry.header), multiSelect: entry.multiSelect === true, options }];
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function emptyDraft(count: number): QuestionnaireDraft {
|
|
75
|
+
return Array.from({ length: count }, () => EMPTY_ENTRY);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function draftEntry(draft: QuestionnaireDraft, index: number): QuestionDraft {
|
|
79
|
+
return draft[index] ?? EMPTY_ENTRY;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function withEntry(draft: QuestionnaireDraft, index: number, patch: Partial<QuestionDraft>): QuestionnaireDraft {
|
|
83
|
+
if (index < 0 || index >= draft.length) return draft;
|
|
84
|
+
return draft.map((entry, position) => (position === index ? { ...entry, ...patch } : entry));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Picks an option. A single-select question replaces its choice; a multiSelect
|
|
89
|
+
* one toggles. Either way the typed answer is dropped, because an option and
|
|
90
|
+
* free text are two answers to the same question and only one can be sent.
|
|
91
|
+
*/
|
|
92
|
+
export function chooseOption(
|
|
93
|
+
draft: QuestionnaireDraft,
|
|
94
|
+
index: number,
|
|
95
|
+
label: string,
|
|
96
|
+
multiSelect: boolean,
|
|
97
|
+
): QuestionnaireDraft {
|
|
98
|
+
const current = draftEntry(draft, index).selected;
|
|
99
|
+
const selected = multiSelect
|
|
100
|
+
? current.includes(label)
|
|
101
|
+
? current.filter((entry) => entry !== label)
|
|
102
|
+
: [...current, label]
|
|
103
|
+
: [label];
|
|
104
|
+
return withEntry(draft, index, { selected, custom: null });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Types an answer instead of choosing one; null returns to the options. */
|
|
108
|
+
export function setCustom(draft: QuestionnaireDraft, index: number, custom: string | null): QuestionnaireDraft {
|
|
109
|
+
return withEntry(draft, index, { custom, selected: [] });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function setNotes(draft: QuestionnaireDraft, index: number, notes: string): QuestionnaireDraft {
|
|
113
|
+
return withEntry(draft, index, { notes });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function isAnswered(entry: QuestionDraft): boolean {
|
|
117
|
+
return entry.selected.length > 0 || (entry.custom !== null && entry.custom.trim() !== '');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The next question still needing an answer, searching after `from` first; -1 when none is left. */
|
|
121
|
+
export function nextUnanswered(draft: QuestionnaireDraft, from: number): number {
|
|
122
|
+
const after = draft.findIndex((entry, index) => index > from && !isAnswered(entry));
|
|
123
|
+
return after >= 0 ? after : draft.findIndex((entry) => !isAnswered(entry));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isComplete(draft: QuestionnaireDraft): boolean {
|
|
127
|
+
return draft.length > 0 && draft.every(isAnswered);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The answers as the tool reads them back. `preview` is left off: the session
|
|
132
|
+
* half looks it up from the option it declared, so sending one here would only
|
|
133
|
+
* be a claim it has to ignore.
|
|
134
|
+
*/
|
|
135
|
+
export function draftAnswers(questions: readonly PromptQuestion[], draft: QuestionnaireDraft): QuestionAnswer[] {
|
|
136
|
+
return questions.flatMap((question, index): QuestionAnswer[] => {
|
|
137
|
+
const entry = draftEntry(draft, index);
|
|
138
|
+
if (!isAnswered(entry)) return [];
|
|
139
|
+
const notes = entry.notes.trim();
|
|
140
|
+
const base = { questionIndex: index, question: question.question, ...(notes === '' ? {} : { notes }) };
|
|
141
|
+
if (entry.custom !== null && entry.custom.trim() !== '') {
|
|
142
|
+
return [{ ...base, kind: 'custom' as const, answer: entry.custom }];
|
|
143
|
+
}
|
|
144
|
+
if (question.multiSelect) {
|
|
145
|
+
return [{ ...base, kind: 'multi' as const, answer: null, selected: [...entry.selected] }];
|
|
146
|
+
}
|
|
147
|
+
return [{ ...base, kind: 'option' as const, answer: entry.selected[0] ?? null }];
|
|
148
|
+
});
|
|
149
|
+
}
|