@fitchmultz/pi-ask-question 0.6.5 → 0.6.6

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.
@@ -1,14 +1,19 @@
1
+ /** Runtime-local observer for the cooperative answer-bus protocol. */
2
+
1
3
  import { createHash, randomUUID } from "node:crypto";
2
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { isRecord, validId } from "./validate.ts";
3
6
 
4
- type Target = {
7
+ /** A prompt target: which prompt, question, and revision an answer applies to. */
8
+ export type Target = {
5
9
  promptId: string;
6
10
  questionId: string;
7
11
  revision: number;
8
12
  deadline: number;
9
13
  };
10
14
 
11
- type Waiting = Target & {
15
+ /** The waiting state an owner publishes while a prompt is accepting answers. */
16
+ export type Waiting = Target & {
12
17
  status: "waiting";
13
18
  timeoutActive: boolean;
14
19
  question: string;
@@ -16,120 +21,191 @@ type Waiting = Target & {
16
21
  multiSelect: boolean;
17
22
  };
18
23
 
19
- function record(data: unknown): Record<string, unknown> | undefined {
20
- return data !== null && typeof data === "object" ? (data as Record<string, unknown>) : undefined;
21
- }
24
+ /** Identity fields shared by every protocol message from one owner. */
25
+ export type ObserverIdentity = {
26
+ protocolVersion: number;
27
+ ownerGeneration: string;
28
+ nativeRoot: string | undefined;
29
+ };
22
30
 
23
- function validId(value: unknown): value is string {
24
- return typeof value === "string" && Boolean(value.trim()) && value.length <= 128;
25
- }
31
+ /** A recorded answer receipt: target plus request identity and fingerprint. */
32
+ type Receipt = Target & { requestId: string; answerFingerprint: string };
33
+
34
+ /** The observer surface consumed by the ask UI. */
35
+ export type QuestionObserver = {
36
+ identify: (ctx: ExtensionContext) => void;
37
+ identity: () => ObserverIdentity;
38
+ watch: (getState: () => Waiting | undefined) => () => void;
39
+ matches: (request: Record<string, unknown>, deadline: number) => boolean;
40
+ accept: (target: Target, requestId: string, answer: string | string[]) => void;
41
+ };
42
+
43
+ /**
44
+ * Track the owning extension instance on the cooperative answer bus.
45
+ * Runtime-local receipts attest application only. They are not a durable answer queue.
46
+ */
47
+ class RuntimeObserver implements QuestionObserver {
48
+ private readonly pi: ExtensionAPI;
49
+ private readonly ownerGeneration = randomUUID();
50
+ private readonly protocolVersion = 1;
51
+ private readonly accepted = new Map<string, Receipt>();
52
+ private readonly unsubscribeQuery: () => void;
53
+ private readonly unsubscribeLookup: () => void;
54
+ private nativeRoot: string | undefined;
55
+ private mode: ExtensionContext["mode"] | undefined;
56
+ private current: (() => Waiting | undefined) | undefined;
57
+
58
+ constructor(pi: ExtensionAPI) {
59
+ this.pi = pi;
60
+ this.unsubscribeQuery = pi.events.on("pi-ask-question:query", (data) => {
61
+ if (!isRecord(data)) {
62
+ return;
63
+ }
64
+ this.emitSnapshot(data);
65
+ });
66
+ this.unsubscribeLookup = pi.events.on("pi-ask-question:lookup", (data) => {
67
+ if (!isRecord(data)) {
68
+ return;
69
+ }
70
+ this.emitLookupResult(data);
71
+ });
72
+ pi.on("session_start", (_event, ctx) => {
73
+ this.identify(ctx);
74
+ });
75
+ pi.on("session_shutdown", () => {
76
+ this.current = undefined;
77
+ this.accepted.clear();
78
+ this.unsubscribeQuery();
79
+ this.unsubscribeLookup();
80
+ });
81
+ }
82
+
83
+ identify(ctx: ExtensionContext): void {
84
+ this.nativeRoot = ctx.sessionManager.getSessionId();
85
+ this.mode = ctx.mode;
86
+ }
26
87
 
27
- // Runtime-local receipts attest application only. They are not a durable answer queue.
28
- export function createQuestionObserver(pi: ExtensionAPI) {
29
- const ownerGeneration = randomUUID();
30
- const protocolVersion = 1;
31
- const accepted = new Map<string, Target & { requestId: string; answerFingerprint: string }>();
32
- let nativeRoot: string | undefined;
33
- let mode: ExtensionContext["mode"] | undefined;
34
- let current: (() => Waiting | undefined) | undefined;
35
-
36
- const identity = () => ({ protocolVersion, ownerGeneration, nativeRoot });
37
- const identify = (ctx: ExtensionContext) => {
38
- nativeRoot = ctx.sessionManager.getSessionId();
39
- mode = ctx.mode;
40
- };
41
- const unsubscribeQuery = pi.events.on("pi-ask-question:query", (data) => {
42
- const query = record(data);
43
- if (!query || !validId(query.queryId)) return;
88
+ identity(): ObserverIdentity {
89
+ return {
90
+ protocolVersion: this.protocolVersion,
91
+ ownerGeneration: this.ownerGeneration,
92
+ nativeRoot: this.nativeRoot,
93
+ };
94
+ }
95
+
96
+ watch(getState: () => Waiting | undefined): () => void {
97
+ this.current = getState;
98
+ return () => {
99
+ if (this.current === getState) {
100
+ this.current = undefined;
101
+ }
102
+ };
103
+ }
104
+
105
+ matches(request: Record<string, unknown>, deadline: number): boolean {
106
+ // Existing unversioned clients keep their original prompt/revision contract.
107
+ return (
108
+ request.protocolVersion === undefined ||
109
+ (request.protocolVersion === this.protocolVersion &&
110
+ request.ownerGeneration === this.ownerGeneration &&
111
+ request.nativeRoot === this.nativeRoot &&
112
+ request.deadline === deadline)
113
+ );
114
+ }
115
+
116
+ accept(target: Target, requestId: string, answer: string | string[]): void {
117
+ const fingerprint = createHash("sha256")
118
+ .update(
119
+ JSON.stringify([
120
+ this.protocolVersion,
121
+ this.ownerGeneration,
122
+ this.nativeRoot,
123
+ target.promptId,
124
+ target.questionId,
125
+ target.revision,
126
+ target.deadline,
127
+ answer,
128
+ ]),
129
+ )
130
+ .digest("hex");
131
+ this.accepted.set(JSON.stringify([target.promptId, requestId]), {
132
+ ...target,
133
+ requestId,
134
+ answerFingerprint: fingerprint,
135
+ });
136
+ if (this.accepted.size > 1024) {
137
+ const oldest = this.accepted.keys().next().value;
138
+ if (oldest !== undefined) {
139
+ this.accepted.delete(oldest);
140
+ }
141
+ }
142
+ }
143
+
144
+ private emitSnapshot(query: Record<string, unknown>): void {
145
+ if (!validId(query.queryId)) {
146
+ return;
147
+ }
44
148
  const waiting =
45
- query.protocolVersion === protocolVersion && mode === "tui" ? current?.() : undefined;
46
- pi.events.emit("pi-ask-question:snapshot", {
149
+ query.protocolVersion === this.protocolVersion && this.mode === "tui"
150
+ ? this.current?.()
151
+ : undefined;
152
+ this.pi.events.emit("pi-ask-question:snapshot", {
47
153
  queryId: query.queryId,
48
- ...identity(),
49
- mode,
50
- status: query.protocolVersion !== protocolVersion || mode !== "tui" ? "unsupported" : "idle",
154
+ ...this.identity(),
155
+ mode: this.mode,
156
+ status: this.snapshotStatus(query),
51
157
  ...waiting,
52
158
  });
53
- });
54
- const unsubscribeLookup = pi.events.on("pi-ask-question:lookup", (data) => {
55
- const query = record(data);
159
+ }
160
+
161
+ private snapshotStatus(query: Record<string, unknown>): "unsupported" | "idle" {
162
+ if (query.protocolVersion !== this.protocolVersion || this.mode !== "tui") {
163
+ return "unsupported";
164
+ }
165
+ return "idle";
166
+ }
167
+
168
+ private emitLookupResult(query: Record<string, unknown>): void {
56
169
  if (
57
- !query ||
58
170
  !validId(query.queryId) ||
59
171
  !validId(query.requestId) ||
60
172
  typeof query.promptId !== "string"
61
- )
173
+ ) {
62
174
  return;
63
- const status =
64
- query.protocolVersion !== protocolVersion || mode !== "tui"
65
- ? "unsupported"
66
- : query.ownerGeneration !== ownerGeneration || query.nativeRoot !== nativeRoot
67
- ? "stale"
68
- : undefined;
69
- const receipt = status
70
- ? undefined
71
- : accepted.get(JSON.stringify([query.promptId, query.requestId]));
72
- pi.events.emit("pi-ask-question:lookup-result", {
175
+ }
176
+ const status = lookupStatus(query, this.owner());
177
+ const receipt =
178
+ status === undefined
179
+ ? this.accepted.get(JSON.stringify([query.promptId, query.requestId]))
180
+ : undefined;
181
+ this.pi.events.emit("pi-ask-question:lookup-result", {
73
182
  queryId: query.queryId,
74
- ...identity(),
183
+ ...this.identity(),
75
184
  promptId: query.promptId,
76
185
  requestId: query.requestId,
77
- status: status ?? (receipt ? "answered" : "unknown"),
186
+ status: status ?? (receipt !== undefined ? "answered" : "unknown"),
78
187
  ...receipt,
79
188
  });
80
- });
81
- pi.on("session_start", (_event, ctx) => {
82
- identify(ctx);
83
- });
84
- pi.on("session_shutdown", () => {
85
- current = undefined;
86
- accepted.clear();
87
- unsubscribeQuery();
88
- unsubscribeLookup();
89
- });
90
-
91
- return {
92
- identify,
93
- identity,
94
- watch(getState: () => Waiting | undefined) {
95
- current = getState;
96
- return () => {
97
- if (current === getState) current = undefined;
98
- };
99
- },
100
- matches(request: Record<string, unknown>, deadline: number) {
101
- // Existing unversioned clients keep their original prompt/revision contract.
102
- return (
103
- request.protocolVersion === undefined ||
104
- (request.protocolVersion === protocolVersion &&
105
- request.ownerGeneration === ownerGeneration &&
106
- request.nativeRoot === nativeRoot &&
107
- request.deadline === deadline)
108
- );
109
- },
110
- accept(target: Target, requestId: string, answer: string | string[]) {
111
- const answerFingerprint = createHash("sha256")
112
- .update(
113
- JSON.stringify([
114
- protocolVersion,
115
- ownerGeneration,
116
- nativeRoot,
117
- target.promptId,
118
- target.questionId,
119
- target.revision,
120
- target.deadline,
121
- answer,
122
- ]),
123
- )
124
- .digest("hex");
125
- accepted.set(JSON.stringify([target.promptId, requestId]), {
126
- ...target,
127
- requestId,
128
- answerFingerprint,
129
- });
130
- if (accepted.size > 1024) accepted.delete(accepted.keys().next().value!);
131
- },
132
- };
189
+ }
190
+
191
+ private owner(): ObserverIdentity & { mode: ExtensionContext["mode"] | undefined } {
192
+ return { ...this.identity(), mode: this.mode };
193
+ }
133
194
  }
134
195
 
135
- export type QuestionObserver = ReturnType<typeof createQuestionObserver>;
196
+ export function createQuestionObserver(pi: ExtensionAPI): QuestionObserver {
197
+ return new RuntimeObserver(pi);
198
+ }
199
+
200
+ function lookupStatus(
201
+ query: Record<string, unknown>,
202
+ owner: ObserverIdentity & { mode: ExtensionContext["mode"] | undefined },
203
+ ): "unsupported" | "stale" | undefined {
204
+ if (query.protocolVersion !== owner.protocolVersion || owner.mode !== "tui") {
205
+ return "unsupported";
206
+ }
207
+ if (query.ownerGeneration !== owner.ownerGeneration || query.nativeRoot !== owner.nativeRoot) {
208
+ return "stale";
209
+ }
210
+ return undefined;
211
+ }
@@ -0,0 +1,73 @@
1
+ /** Cooperative answer-bus protocol: request shapes and validation. */
2
+
3
+ import { clean } from "./normalize.ts";
4
+ import { MAX_REMOTE_ANSWER_LENGTH, type NormalizedQuestion } from "./types.ts";
5
+ import { isRecord, isStringArray, validId } from "./validate.ts";
6
+
7
+ /** A shape-checked request from the cooperative answer bus. */
8
+ export type RemoteAnswerRequest = {
9
+ requestId: string;
10
+ [key: string]: unknown;
11
+ };
12
+
13
+ /** The published waiting state for one prompt on the cooperative bus. */
14
+ export type WaitingPromptState = {
15
+ status: "waiting";
16
+ protocolVersion: number;
17
+ ownerGeneration: string;
18
+ nativeRoot: string | undefined;
19
+ promptId: string;
20
+ questionId: string;
21
+ revision: number;
22
+ deadline: number;
23
+ timeoutActive: boolean;
24
+ question: string;
25
+ options: string[];
26
+ multiSelect: boolean;
27
+ };
28
+
29
+ /** A validated remote answer, discriminated by kind. */
30
+ export type ValidatedRemoteAnswer =
31
+ | { kind: "text"; text: string }
32
+ | { kind: "options"; options: string[] };
33
+
34
+ /** Shape-check one cooperative-bus answer event. */
35
+ export function parseRemoteAnswerRequest(data: unknown): RemoteAnswerRequest | undefined {
36
+ if (!isRecord(data)) {
37
+ return undefined;
38
+ }
39
+ const requestId = data.requestId;
40
+ if (!validId(requestId)) {
41
+ return undefined;
42
+ }
43
+ return { ...data, requestId };
44
+ }
45
+
46
+ /** Validate an answer payload against the question contract. */
47
+ export function validateRemoteAnswer(
48
+ answer: unknown,
49
+ question: NormalizedQuestion,
50
+ ): ValidatedRemoteAnswer | undefined {
51
+ if (typeof answer === "string") {
52
+ const valid = answer.length <= MAX_REMOTE_ANSWER_LENGTH && clean(answer) !== undefined;
53
+ return valid ? { kind: "text", text: answer } : undefined;
54
+ }
55
+ if (!isStringArray(answer)) {
56
+ return undefined;
57
+ }
58
+ if (!question.multiSelect || !validMultiSelectOptions(answer, question)) {
59
+ return undefined;
60
+ }
61
+ return { kind: "options", options: answer };
62
+ }
63
+
64
+ /** Multi-select answers must be unique, known, and within size limits. */
65
+ function validMultiSelectOptions(answer: string[], question: NormalizedQuestion): boolean {
66
+ const unique = new Set(answer).size === answer.length;
67
+ const known = answer.every((value) => question.options.includes(value));
68
+ const sized =
69
+ answer.length > 0 &&
70
+ answer.length <= question.options.length &&
71
+ answer.reduce((size, value) => size + value.length, 0) <= MAX_REMOTE_ANSWER_LENGTH;
72
+ return unique && known && sized;
73
+ }
@@ -0,0 +1,49 @@
1
+ /** Tool parameter schemas for ask_question. */
2
+
3
+ import { Type } from "typebox";
4
+
5
+ const QuestionSchema = Type.Object(
6
+ {
7
+ id: Type.Optional(
8
+ Type.String({
9
+ description: "Stable answer id. Defaults to question_<n>; duplicates are auto-suffixed.",
10
+ }),
11
+ ),
12
+ question: Type.String({ minLength: 1, description: "Question to ask the user." }),
13
+ options: Type.Optional(
14
+ Type.Array(Type.String({ minLength: 1 }), {
15
+ description: "Choices, ordered from most recommended to least recommended.",
16
+ }),
17
+ ),
18
+ multiSelect: Type.Optional(
19
+ Type.Boolean({
20
+ description: "Allow selecting more than one option for this question. Defaults to false.",
21
+ }),
22
+ ),
23
+ },
24
+ { additionalProperties: false },
25
+ );
26
+
27
+ export const AskQuestionParams = Type.Object(
28
+ {
29
+ question: Type.Optional(Type.String({ minLength: 1, description: "Single question to ask." })),
30
+ options: Type.Optional(
31
+ Type.Array(Type.String({ minLength: 1 }), {
32
+ description: "Choices, ordered from most recommended to least recommended.",
33
+ }),
34
+ ),
35
+ multiSelect: Type.Optional(
36
+ Type.Boolean({
37
+ description:
38
+ "Allow selecting more than one option for the single question. Defaults to false.",
39
+ }),
40
+ ),
41
+ questions: Type.Optional(
42
+ Type.Array(QuestionSchema, {
43
+ description:
44
+ "Ask several questions in order. When provided, takes precedence over the single-question fields.",
45
+ }),
46
+ ),
47
+ },
48
+ { additionalProperties: false },
49
+ );
@@ -0,0 +1,32 @@
1
+ /** Text rendering helpers that preserve exact answer text. */
2
+
3
+ import { sliceByColumn, Text, visibleWidth, type Component } from "@earendil-works/pi-tui";
4
+
5
+ /** Word wrapping drops spaces at line breaks; JSON answer text must keep them. */
6
+ export function wrapByColumn(text: string, width: number): string[] {
7
+ const lines: string[] = [];
8
+ let rest = text;
9
+ while (visibleWidth(rest) > width) {
10
+ const wide = sliceByColumn(rest, 0, width, true);
11
+ const part = wide !== "" ? wide : sliceByColumn(rest, 0, width);
12
+ lines.push(part);
13
+ rest = rest.slice(part.length);
14
+ }
15
+ return [...lines, rest];
16
+ }
17
+
18
+ /** Render text without re-wrapping, so exact JSON answer text survives. */
19
+ export function exactText(text: string): Component {
20
+ return {
21
+ render(width: number): string[] {
22
+ const wrapped = text
23
+ .split("\n")
24
+ .flatMap((line) => wrapByColumn(line, width))
25
+ .join("\n");
26
+ return new Text(wrapped, 0, 0).render(width);
27
+ },
28
+ invalidate() {
29
+ // Nothing is cached; every render recomputes from the exact text.
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,150 @@
1
+ /** The ask_question tool definition. */
2
+
3
+ import {
4
+ defineTool,
5
+ type AgentToolResult,
6
+ type ExtensionAPI,
7
+ type Theme,
8
+ type ToolDefinition,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { Text, type Component } from "@earendil-works/pi-tui";
11
+ import type { Static } from "typebox";
12
+ import { askWithDialogs } from "./ask-dialogs.ts";
13
+ import { askWithKeyboard } from "./ask-keyboard.ts";
14
+ import { formatAnswer, normalize, summarize } from "./normalize.ts";
15
+ import type { QuestionObserver } from "./question-observer.ts";
16
+ import { AskQuestionParams } from "./schema.ts";
17
+ import { exactText } from "./text.ts";
18
+ import {
19
+ QUESTION_TIMEOUT_MS,
20
+ TIMEOUT_MESSAGE,
21
+ type AskQuestionDetails,
22
+ type QuestionTimeout,
23
+ } from "./types.ts";
24
+
25
+ function createQuestionTimeout(): {
26
+ timeout: QuestionTimeout;
27
+ signal: AbortSignal;
28
+ dispose: () => void;
29
+ } {
30
+ const controller = new AbortController();
31
+ const deadline = Date.now() + QUESTION_TIMEOUT_MS;
32
+ const timer = setTimeout(() => controller.abort(), QUESTION_TIMEOUT_MS);
33
+ let active = true;
34
+ const timeout: QuestionTimeout = {
35
+ deadline,
36
+ get active() {
37
+ return active;
38
+ },
39
+ stop() {
40
+ if (!active) {
41
+ return false;
42
+ }
43
+ active = false;
44
+ clearTimeout(timer);
45
+ return true;
46
+ },
47
+ };
48
+ return { timeout, signal: controller.signal, dispose: () => clearTimeout(timer) };
49
+ }
50
+
51
+ function renderAskCall(args: Static<typeof AskQuestionParams>, theme: Theme): Component {
52
+ const questions = normalize(args);
53
+ const count = questions.length;
54
+ const labels = questions.map((question) => question.id).join(", ");
55
+ let text = theme.fg("toolTitle", theme.bold("ask_question "));
56
+ text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
57
+ if (labels !== "") {
58
+ text += theme.fg("dim", ` (${labels})`);
59
+ }
60
+ return new Text(text, 0, 0);
61
+ }
62
+
63
+ function renderAskResult(result: AgentToolResult<AskQuestionDetails>, theme: Theme): Component {
64
+ const details = result.details;
65
+ if (details.timedOut) {
66
+ const content = result.content[0];
67
+ const text = theme.fg("warning", content?.type === "text" ? content.text : TIMEOUT_MESSAGE);
68
+ return details.answers.some((answer) => answer.selectedOptions !== undefined)
69
+ ? exactText(text)
70
+ : new Text(text, 0, 0);
71
+ }
72
+ if (details.cancelled) {
73
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
74
+ }
75
+ const lines = details.questions.map((question) => {
76
+ const answer = details.answers.find((entry) => entry.id === question.id);
77
+ return `${theme.fg("success", "✓ ")}${theme.fg("accent", question.id)}: ${formatAnswer(answer)}`;
78
+ });
79
+ const text = lines.join("\n");
80
+ return details.answers.some((answer) => answer.selectedOptions !== undefined)
81
+ ? exactText(text)
82
+ : new Text(text, 0, 0);
83
+ }
84
+
85
+ /** Build the registered ask_question tool bound to one observer. */
86
+ export function createAskQuestionTool(
87
+ pi: ExtensionAPI,
88
+ observer: QuestionObserver,
89
+ ): ToolDefinition<typeof AskQuestionParams, AskQuestionDetails> {
90
+ return defineTool<typeof AskQuestionParams, AskQuestionDetails>({
91
+ name: "ask_question",
92
+ label: "Ask Question",
93
+ description:
94
+ "Ask the user one or more clarifying questions through pi's UI. After 5 minutes without interaction, returns an AFK reply so you can continue using your best judgement.",
95
+ promptSnippet: "Ask the user clarifying questions through pi's UI",
96
+ promptGuidelines: [
97
+ "For ask_question, list options from most recommended to least; the first option is the recommended choice, and do not label it as recommended.",
98
+ "For ask_question, set multiSelect:true only when the user may need to choose more than one option.",
99
+ "For ask_question, a typed custom-answer option is added automatically; do not include your own.",
100
+ ],
101
+ parameters: AskQuestionParams,
102
+ executionMode: "sequential",
103
+
104
+ // oxlint-disable-next-line eslint/max-params -- QX-001: defineTool's execute callback signature is fixed by the Pi SDK at five parameters.
105
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
106
+ const questions = normalize(params);
107
+ if (questions.length === 0) {
108
+ throw new Error("ask_question needs either question or questions[].");
109
+ }
110
+ if (!ctx.hasUI) {
111
+ throw new Error("ask_question needs Pi TUI or RPC UI support.");
112
+ }
113
+
114
+ observer.identify(ctx);
115
+ const { timeout, signal: timeoutSignal, dispose } = createQuestionTimeout();
116
+ const questionSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
117
+ try {
118
+ const result =
119
+ ctx.mode === "tui"
120
+ ? await askWithKeyboard({
121
+ questions,
122
+ ui: ctx.ui,
123
+ pi,
124
+ observer,
125
+ timeout,
126
+ signal: questionSignal,
127
+ })
128
+ : await askWithDialogs(questions, ctx.ui, timeout, questionSignal);
129
+ const answers = new Map(result.answers.map((answer) => [answer.id, answer]));
130
+ const timedOut = result.cancelled && timeoutSignal.aborted && signal?.aborted !== true;
131
+ const cancelled = result.cancelled && !timedOut;
132
+
133
+ return {
134
+ content: [{ type: "text", text: summarize(questions, answers, cancelled, timedOut) }],
135
+ details: {
136
+ questions,
137
+ answers: result.answers,
138
+ cancelled,
139
+ timedOut,
140
+ },
141
+ };
142
+ } finally {
143
+ dispose();
144
+ }
145
+ },
146
+
147
+ renderCall: (args, theme, _context) => renderAskCall(args, theme),
148
+ renderResult: (result, _options, theme, _context) => renderAskResult(result, theme),
149
+ });
150
+ }
@@ -0,0 +1,56 @@
1
+ /** Shared domain types and constants for the ask_question extension. */
2
+
3
+ /** A single question as received through the tool schema. */
4
+ export type Question = {
5
+ id?: string;
6
+ question: string;
7
+ options?: string[];
8
+ multiSelect?: boolean;
9
+ };
10
+
11
+ /** A question after normalization: identified, cleaned, and complete. */
12
+ export type NormalizedQuestion = {
13
+ id: string;
14
+ question: string;
15
+ options: string[];
16
+ multiSelect: boolean;
17
+ };
18
+
19
+ /** A user answer for one question. */
20
+ export type Answer = {
21
+ id: string;
22
+ question: string;
23
+ answer: string;
24
+ selectedOptions?: string[];
25
+ wasCustom: boolean;
26
+ };
27
+
28
+ /** Structured details attached to a finished ask_question tool result. */
29
+ export type AskQuestionDetails = {
30
+ questions: NormalizedQuestion[];
31
+ answers: Answer[];
32
+ cancelled: boolean;
33
+ timedOut: boolean;
34
+ };
35
+
36
+ /** Outcome of asking one question set through any UI mode. */
37
+ export type QuestionResult = {
38
+ answers: Answer[];
39
+ cancelled: boolean;
40
+ };
41
+
42
+ /** Cooperative five-minute inactivity timeout for one question set. */
43
+ export type QuestionTimeout = {
44
+ deadline: number;
45
+ readonly active: boolean;
46
+ stop: () => boolean;
47
+ };
48
+
49
+ export const QUESTION_TIMEOUT_MS = 5 * 60 * 1000;
50
+ export const TIMEOUT_MESSAGE =
51
+ "Mitch is currently AFK. Use your best judgement to choose the option Mitch would choose.";
52
+ export const CUSTOM_OPTION = "Type a custom answer";
53
+ export const DONE_OPTION = "Done selecting";
54
+ export const MAX_REMOTE_ANSWER_LENGTH = 16_384;
55
+ // ponytail: bound deduplication to 1024 commits per prompt; raise with a higher-volume client.
56
+ export const MAX_REMOTE_REQUESTS = 1024;