@geoqiao/pi-ask 1.2.3 → 1.3.0
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/CHANGELOG.md +61 -85
- package/README.md +11 -3
- package/docs/architecture.md +6 -1
- package/docs/configuration.md +4 -3
- package/docs/contract.md +34 -14
- package/docs/remote-events.md +14 -14
- package/package.json +17 -17
- package/skills/ask-user/SKILL.md +2 -0
- package/src/answer-commands.ts +37 -10
- package/src/answer-extraction.ts +155 -115
- package/src/ask-payload-store.ts +23 -1
- package/src/ask-tool-helpers.ts +12 -11
- package/src/ask-tool.ts +2 -0
- package/src/constants/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/pending-ask.ts +128 -0
- package/src/remote-ask.ts +11 -6
- package/src/result-format.ts +13 -3
- package/src/result.ts +1 -1
- package/src/resume-pending-ask.ts +95 -0
- package/src/rpc/controller.ts +2 -1
- package/src/schema.ts +59 -24
- package/src/state/create.ts +1 -1
- package/src/state/normalize.ts +50 -31
- package/src/state/result.ts +3 -0
- package/src/types.ts +1 -0
- package/src/ui/render-helpers.ts +9 -2
- package/src/ui/render-question.ts +39 -7
- package/src/ui/view-models/question.ts +2 -0
package/src/ask-tool-helpers.ts
CHANGED
|
@@ -13,13 +13,15 @@ import type {
|
|
|
13
13
|
} from "./types.ts";
|
|
14
14
|
|
|
15
15
|
export const ASK_TOOL_DESCRIPTION =
|
|
16
|
-
"Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. TUI mode supports single-select, multi-select, and preview-pane questions; RPC mode presents questions sequentially, offers one portable choice per question plus a typed-answer fallback, and flattens preview details into option text. Always include a machine-readable `value` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.";
|
|
16
|
+
"Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. TUI mode supports single-select, multi-select, and preview-pane questions; RPC mode presents questions sequentially, offers one portable choice per question plus a typed-answer fallback, and flattens preview details into option text. Always include a stable `id` and non-empty `prompt` for every question, plus a machine-readable `value` and visible `label` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.";
|
|
17
17
|
|
|
18
18
|
export const ASK_TOOL_PROMPT_GUIDELINES = [
|
|
19
19
|
"Use `ask_user` before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
|
|
20
20
|
"When multiple valid directions exist, call `ask_user` with 1-3 concise questions instead of committing to one path on your own.",
|
|
21
21
|
"When calling `ask_user`, prefer one focused decision per question. Use short labels. Provide clear, distinct options. Do not add filler options.",
|
|
22
|
-
"When calling `ask_user`, always include a non-empty
|
|
22
|
+
"When calling `ask_user`, always include a stable `id` and non-empty `prompt` for every question.",
|
|
23
|
+
"When calling `ask_user`, always include a non-empty machine-readable `value` and visible `label` for every option.",
|
|
24
|
+
"When calling `ask_user`, mark grounded preferences with `recommended: true` and use the option `description` to state the reason.",
|
|
23
25
|
"When calling `ask_user`, choose question `type` from the question semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail.",
|
|
24
26
|
'When calling `ask_user`, use `type: "preview"` only when every option includes non-empty `preview` text. Option descriptions do not satisfy this requirement.',
|
|
25
27
|
"After an `ask_user` elaboration or follow-up note, prefer another structured `ask_user` follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
|
|
@@ -89,7 +91,7 @@ export function renderAskToolCall(args: unknown, theme: ToolTheme) {
|
|
|
89
91
|
? params.questions
|
|
90
92
|
.map(
|
|
91
93
|
(question: AskQuestionInput, index) =>
|
|
92
|
-
question.label || `Q${index + 1}`
|
|
94
|
+
question.label?.trim() || `Q${index + 1}`
|
|
93
95
|
)
|
|
94
96
|
.join(", ")
|
|
95
97
|
: "";
|
|
@@ -113,17 +115,16 @@ export function renderAskToolResult(
|
|
|
113
115
|
theme: ToolTheme
|
|
114
116
|
) {
|
|
115
117
|
const details = result.details;
|
|
116
|
-
if (!details) {
|
|
118
|
+
if (!(details && Array.isArray(details.questions))) {
|
|
117
119
|
const text = result.content[0];
|
|
118
120
|
return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
|
|
119
121
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return new Text(renderResultText(details), 0, 0);
|
|
122
|
+
const text = renderResultText(details);
|
|
123
|
+
return new Text(
|
|
124
|
+
details.error || details.cancelled ? theme.fg("warning", text) : text,
|
|
125
|
+
0,
|
|
126
|
+
0
|
|
127
|
+
);
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
function errorResultDetails(
|
package/src/ask-tool.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { getAskConfigStore } from "./config/store.ts";
|
|
|
17
17
|
import type { RemoteAskRuntime } from "./remote-ask.ts";
|
|
18
18
|
import { runRpcAskFlow } from "./rpc/controller.ts";
|
|
19
19
|
import { AskParamsSchema } from "./schema.ts";
|
|
20
|
+
import { prepareAskParams } from "./state/normalize.ts";
|
|
20
21
|
import type { AskParams } from "./types.ts";
|
|
21
22
|
import { runAskFlow } from "./ui/controller.ts";
|
|
22
23
|
|
|
@@ -32,6 +33,7 @@ export function registerAskTool(
|
|
|
32
33
|
"Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding",
|
|
33
34
|
promptGuidelines: [...ASK_TOOL_PROMPT_GUIDELINES],
|
|
34
35
|
parameters: AskParamsSchema,
|
|
36
|
+
prepareArguments: (args) => prepareAskParams(args) as AskParams,
|
|
35
37
|
execute: (toolCallId, params, signal, onUpdate, ctx) =>
|
|
36
38
|
executeAskTool(
|
|
37
39
|
pi,
|
package/src/constants/ui.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { registerAskSettingsCommand } from "./ask-settings-command.ts";
|
|
|
6
6
|
import { registerAskTool } from "./ask-tool.ts";
|
|
7
7
|
import { resetAskConfigStore } from "./config/store.ts";
|
|
8
8
|
import { createRemoteAskRuntime } from "./remote-ask.ts";
|
|
9
|
+
import { registerPendingAskResume } from "./resume-pending-ask.ts";
|
|
9
10
|
|
|
10
11
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
12
|
const CONFIGURATION_DOC_PATH = resolve(
|
|
@@ -27,4 +28,5 @@ export default function askExtension(pi: ExtensionAPI) {
|
|
|
27
28
|
registerAskTool(pi, remoteAsk);
|
|
28
29
|
registerAskSettingsCommand(pi);
|
|
29
30
|
registerAnswerCommands(pi, remoteAsk);
|
|
31
|
+
registerPendingAskResume(pi, remoteAsk);
|
|
30
32
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ToolCall } from "@earendil-works/pi-ai";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
SessionEntry,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Value } from "typebox/value";
|
|
8
|
+
import { findPayloadForSourceEntry } from "./ask-payload-store.ts";
|
|
9
|
+
import { validateParams } from "./ask-tool-helpers.ts";
|
|
10
|
+
import { AskParamsSchema } from "./schema.ts";
|
|
11
|
+
import type { AskParams } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export const ASK_PENDING_DISMISSED_ENTRY_TYPE = "ask:pending-dismissed";
|
|
14
|
+
const ASK_TOOL_NAME = "ask_user";
|
|
15
|
+
|
|
16
|
+
export interface PendingAskToolCall {
|
|
17
|
+
params: AskParams;
|
|
18
|
+
toolCallId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function appendPendingAskDismissal(
|
|
22
|
+
pi: Pick<ExtensionAPI, "appendEntry">,
|
|
23
|
+
toolCallId: string
|
|
24
|
+
): void {
|
|
25
|
+
pi.appendEntry(ASK_PENDING_DISMISSED_ENTRY_TYPE, { toolCallId });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function findPendingAskToolCall(
|
|
29
|
+
ctx: Pick<ExtensionContext, "sessionManager">
|
|
30
|
+
): PendingAskToolCall | undefined {
|
|
31
|
+
const branch = ctx.sessionManager.getBranch();
|
|
32
|
+
const resolvedToolCallIds = collectResolvedToolCallIds(branch);
|
|
33
|
+
|
|
34
|
+
for (let entryIndex = branch.length - 1; entryIndex >= 0; entryIndex--) {
|
|
35
|
+
const toolCall = findUnresolvedAskToolCall(
|
|
36
|
+
branch[entryIndex],
|
|
37
|
+
resolvedToolCallIds
|
|
38
|
+
);
|
|
39
|
+
if (!toolCall) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const params = resolvePendingAskParams(ctx, toolCall);
|
|
44
|
+
if (params) {
|
|
45
|
+
return { params, toolCallId: toolCall.id };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function collectResolvedToolCallIds(
|
|
52
|
+
branch: readonly SessionEntry[]
|
|
53
|
+
): Set<string> {
|
|
54
|
+
const resolved = new Set<string>();
|
|
55
|
+
for (const entry of branch) {
|
|
56
|
+
const dismissedToolCallId = getDismissedToolCallId(entry);
|
|
57
|
+
if (dismissedToolCallId) {
|
|
58
|
+
resolved.add(dismissedToolCallId);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (entry.type === "message" && entry.message.role === "toolResult") {
|
|
62
|
+
resolved.add(entry.message.toolCallId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findUnresolvedAskToolCall(
|
|
69
|
+
entry: SessionEntry,
|
|
70
|
+
resolvedToolCallIds: ReadonlySet<string>
|
|
71
|
+
): ToolCall | undefined {
|
|
72
|
+
if (
|
|
73
|
+
entry.type !== "message" ||
|
|
74
|
+
entry.message.role !== "assistant" ||
|
|
75
|
+
entry.message.stopReason !== "toolUse"
|
|
76
|
+
) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (
|
|
81
|
+
let partIndex = entry.message.content.length - 1;
|
|
82
|
+
partIndex >= 0;
|
|
83
|
+
partIndex--
|
|
84
|
+
) {
|
|
85
|
+
const part = entry.message.content[partIndex];
|
|
86
|
+
if (
|
|
87
|
+
part.type === "toolCall" &&
|
|
88
|
+
part.name === ASK_TOOL_NAME &&
|
|
89
|
+
!resolvedToolCallIds.has(part.id)
|
|
90
|
+
) {
|
|
91
|
+
return part;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolvePendingAskParams(
|
|
98
|
+
ctx: Pick<ExtensionContext, "sessionManager">,
|
|
99
|
+
toolCall: ToolCall
|
|
100
|
+
): AskParams | undefined {
|
|
101
|
+
const persistedPayload = findPayloadForSourceEntry(ctx, toolCall.id, "tool");
|
|
102
|
+
if (persistedPayload) {
|
|
103
|
+
return persistedPayload.params;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const argumentsFallback = toolCall.arguments;
|
|
107
|
+
if (
|
|
108
|
+
Value.Check(AskParamsSchema, argumentsFallback) &&
|
|
109
|
+
validateParams(argumentsFallback).ok
|
|
110
|
+
) {
|
|
111
|
+
return argumentsFallback;
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getDismissedToolCallId(entry: SessionEntry): string | undefined {
|
|
117
|
+
if (
|
|
118
|
+
entry.type !== "custom" ||
|
|
119
|
+
entry.customType !== ASK_PENDING_DISMISSED_ENTRY_TYPE ||
|
|
120
|
+
!entry.data ||
|
|
121
|
+
typeof entry.data !== "object"
|
|
122
|
+
) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const toolCallId = (entry.data as { toolCallId?: unknown }).toolCallId;
|
|
127
|
+
return typeof toolCallId === "string" ? toolCallId : undefined;
|
|
128
|
+
}
|
package/src/remote-ask.ts
CHANGED
|
@@ -7,12 +7,17 @@ import type {
|
|
|
7
7
|
AskStateAnswer,
|
|
8
8
|
} from "./types.ts";
|
|
9
9
|
|
|
10
|
-
export const PI_ASK_STARTED_EVENT = "@
|
|
11
|
-
export const PI_ASK_COMPLETED_EVENT = "@
|
|
12
|
-
export const PI_ASK_SUBMIT_EVENT = "@
|
|
13
|
-
export const PI_ASK_SUBMIT_RESULT_EVENT = "@
|
|
14
|
-
|
|
15
|
-
export type RemoteAskSource =
|
|
10
|
+
export const PI_ASK_STARTED_EVENT = "@geoqiao/pi-ask:started";
|
|
11
|
+
export const PI_ASK_COMPLETED_EVENT = "@geoqiao/pi-ask:completed";
|
|
12
|
+
export const PI_ASK_SUBMIT_EVENT = "@geoqiao/pi-ask:submit";
|
|
13
|
+
export const PI_ASK_SUBMIT_RESULT_EVENT = "@geoqiao/pi-ask:submit-result";
|
|
14
|
+
|
|
15
|
+
export type RemoteAskSource =
|
|
16
|
+
| "tool"
|
|
17
|
+
| "answer"
|
|
18
|
+
| "answer:again"
|
|
19
|
+
| "ask:replay"
|
|
20
|
+
| "ask:resume";
|
|
16
21
|
|
|
17
22
|
export interface RemoteAskAnswer {
|
|
18
23
|
customText?: string;
|
package/src/result-format.ts
CHANGED
|
@@ -13,13 +13,14 @@ export function formatResultLines(
|
|
|
13
13
|
for (const question of result.questions) {
|
|
14
14
|
const answer = result.answers[question.id];
|
|
15
15
|
if (!answer) {
|
|
16
|
+
lines.push(formatUnansweredLine(question.label, options.mode));
|
|
16
17
|
continue;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const answerLine = formatAnswerLine(question.label, answer, options.mode);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
lines.push(
|
|
22
|
+
answerLine ?? formatUnansweredLine(question.label, options.mode)
|
|
23
|
+
);
|
|
23
24
|
|
|
24
25
|
if (hasPresentedTypeOverride(question.type, question.presentedType)) {
|
|
25
26
|
hasPresentationOverride = true;
|
|
@@ -44,6 +45,15 @@ export function formatResultLines(
|
|
|
44
45
|
return lines;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
function formatUnansweredLine(
|
|
49
|
+
questionLabel: string,
|
|
50
|
+
mode: "summary" | "render"
|
|
51
|
+
): string {
|
|
52
|
+
return mode === "summary"
|
|
53
|
+
? `${questionLabel}: (no answer)`
|
|
54
|
+
: `? ${questionLabel}: (no answer)`;
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
function formatAnswerLine(
|
|
48
58
|
questionLabel: string,
|
|
49
59
|
answer: AskResult["answers"][string],
|
package/src/result.ts
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
SessionStartEvent,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { successfulResponse } from "./ask-tool-helpers.ts";
|
|
7
|
+
import {
|
|
8
|
+
appendPendingAskDismissal,
|
|
9
|
+
findPendingAskToolCall,
|
|
10
|
+
type PendingAskToolCall,
|
|
11
|
+
} from "./pending-ask.ts";
|
|
12
|
+
import type { RemoteAskRuntime } from "./remote-ask.ts";
|
|
13
|
+
import { runAskFlow } from "./ui/controller.ts";
|
|
14
|
+
|
|
15
|
+
const REOPEN_REASONS: ReadonlySet<SessionStartEvent["reason"]> = new Set([
|
|
16
|
+
"startup",
|
|
17
|
+
"resume",
|
|
18
|
+
"fork",
|
|
19
|
+
]);
|
|
20
|
+
const DISMISS_NOTICE =
|
|
21
|
+
"Unanswered ask_user form dismissed; use /ask:replay to reopen it.";
|
|
22
|
+
|
|
23
|
+
export function registerPendingAskResume(
|
|
24
|
+
pi: ExtensionAPI,
|
|
25
|
+
remoteAsk: RemoteAskRuntime
|
|
26
|
+
): void {
|
|
27
|
+
let reopening = false;
|
|
28
|
+
|
|
29
|
+
pi.on("session_start", (event, ctx) => {
|
|
30
|
+
if (reopening || ctx.mode !== "tui" || !REOPEN_REASONS.has(event.reason)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pendingAsk = findPendingAskToolCall(ctx);
|
|
35
|
+
if (!pendingAsk) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
reopening = true;
|
|
40
|
+
queueMicrotask(() => {
|
|
41
|
+
reopenPendingAsk(pi, ctx, pendingAsk, remoteAsk)
|
|
42
|
+
.catch((error) => {
|
|
43
|
+
ctx.ui.notify(
|
|
44
|
+
`Could not reopen unanswered ask_user form: ${formatError(error)}`,
|
|
45
|
+
"error"
|
|
46
|
+
);
|
|
47
|
+
})
|
|
48
|
+
.finally(() => {
|
|
49
|
+
reopening = false;
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function reopenPendingAsk(
|
|
56
|
+
pi: Pick<ExtensionAPI, "appendEntry" | "sendUserMessage">,
|
|
57
|
+
ctx: ExtensionContext,
|
|
58
|
+
pendingAsk: PendingAskToolCall,
|
|
59
|
+
remoteAsk: RemoteAskRuntime
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
ctx.ui.notify(
|
|
62
|
+
`Reopening unanswered ask_user form: ${pendingAsk.params.questions.length} question(s).`,
|
|
63
|
+
"info"
|
|
64
|
+
);
|
|
65
|
+
ctx.ui.setWorkingVisible(false);
|
|
66
|
+
|
|
67
|
+
let result: Awaited<ReturnType<typeof runAskFlow>>;
|
|
68
|
+
try {
|
|
69
|
+
result = await runAskFlow(ctx, pendingAsk.params, {
|
|
70
|
+
remote: {
|
|
71
|
+
runtime: remoteAsk,
|
|
72
|
+
source: "ask:resume",
|
|
73
|
+
toolCallId: pendingAsk.toolCallId,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
} finally {
|
|
77
|
+
ctx.ui.setWorkingVisible(true);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
appendPendingAskDismissal(pi, pendingAsk.toolCallId);
|
|
81
|
+
if (result.cancelled) {
|
|
82
|
+
ctx.ui.notify(DISMISS_NOTICE, "info");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const text = successfulResponse(result).content[0].text;
|
|
87
|
+
pi.sendUserMessage(
|
|
88
|
+
text,
|
|
89
|
+
ctx.isIdle() ? undefined : { deliverAs: "followUp" }
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatError(error: unknown): string {
|
|
94
|
+
return error instanceof Error ? error.message : String(error);
|
|
95
|
+
}
|
package/src/rpc/controller.ts
CHANGED
|
@@ -247,13 +247,14 @@ function formatTitle(
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
function formatOption(option: AskDisplayOption, optionIndex: number): string {
|
|
250
|
+
const recommendation = option.recommended ? " (recommended)" : "";
|
|
250
251
|
const description = option.description
|
|
251
252
|
? ` — ${compactText(option.description)}`
|
|
252
253
|
: "";
|
|
253
254
|
const preview = option.preview
|
|
254
255
|
? ` — Preview: ${compactText(option.preview)}`
|
|
255
256
|
: "";
|
|
256
|
-
return `${optionIndex + 1}. ${compactText(option.label)}${description}${preview}`;
|
|
257
|
+
return `${optionIndex + 1}. ${compactText(option.label)}${recommendation}${description}${preview}`;
|
|
257
258
|
}
|
|
258
259
|
|
|
259
260
|
function compactText(value: string): string {
|
package/src/schema.ts
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
1
2
|
import { Type } from "typebox";
|
|
2
3
|
|
|
3
4
|
export const AskOptionSchema = Type.Object({
|
|
4
|
-
value: Type.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Type.String({
|
|
12
|
-
description: "Required short visible option label shown in the list",
|
|
13
|
-
})
|
|
14
|
-
),
|
|
5
|
+
value: Type.String({
|
|
6
|
+
description:
|
|
7
|
+
"Required machine-readable value returned for this option in the result",
|
|
8
|
+
}),
|
|
9
|
+
label: Type.String({
|
|
10
|
+
description: "Required short visible option label shown in the list",
|
|
11
|
+
}),
|
|
15
12
|
description: Type.Optional(
|
|
16
13
|
Type.String({
|
|
17
14
|
description: "Optional one-line explanation to help the user choose",
|
|
@@ -23,28 +20,30 @@ export const AskOptionSchema = Type.Object({
|
|
|
23
20
|
"Optional preview content shown in the dedicated preview pane for preview questions",
|
|
24
21
|
})
|
|
25
22
|
),
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export const AskQuestionSchema = Type.Object({
|
|
29
|
-
id: Type.Optional(
|
|
30
|
-
Type.String({
|
|
23
|
+
recommended: Type.Optional(
|
|
24
|
+
Type.Boolean({
|
|
31
25
|
description:
|
|
32
|
-
"
|
|
26
|
+
"Optional presentation marker for a grounded preference; use the description to explain the reason",
|
|
33
27
|
})
|
|
34
28
|
),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const AskQuestionSchema = Type.Object({
|
|
32
|
+
id: Type.String({
|
|
33
|
+
description:
|
|
34
|
+
"Required stable question identifier used as the key in returned answers",
|
|
35
|
+
}),
|
|
35
36
|
label: Type.Optional(
|
|
36
37
|
Type.String({
|
|
37
38
|
description: "Short tab label, e.g. Goal, Audience, Tone, Scope",
|
|
38
39
|
})
|
|
39
40
|
),
|
|
40
|
-
prompt: Type.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
45
|
-
),
|
|
41
|
+
prompt: Type.String({
|
|
42
|
+
description:
|
|
43
|
+
"Required direct question shown to the user; ask about one decision at a time",
|
|
44
|
+
}),
|
|
46
45
|
type: Type.Optional(
|
|
47
|
-
|
|
46
|
+
StringEnum(["single", "multi", "preview"] as const, {
|
|
48
47
|
description:
|
|
49
48
|
"Question type: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.",
|
|
50
49
|
})
|
|
@@ -72,3 +71,39 @@ export const AskParamsSchema = Type.Object({
|
|
|
72
71
|
description: "Questions to ask in the interactive clarification flow",
|
|
73
72
|
}),
|
|
74
73
|
});
|
|
74
|
+
|
|
75
|
+
const AnswerExtractionOptionSchema = Type.Object(
|
|
76
|
+
{
|
|
77
|
+
value: Type.String({ description: "Machine-readable option value" }),
|
|
78
|
+
label: Type.String({ description: "Short visible option label" }),
|
|
79
|
+
description: AskOptionSchema.properties.description,
|
|
80
|
+
preview: AskOptionSchema.properties.preview,
|
|
81
|
+
freeform: Type.Optional(
|
|
82
|
+
Type.Boolean({
|
|
83
|
+
description:
|
|
84
|
+
"Use only when the assistant offered no concrete choices and the user should type an answer",
|
|
85
|
+
})
|
|
86
|
+
),
|
|
87
|
+
},
|
|
88
|
+
{ additionalProperties: false }
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const AnswerExtractionQuestionSchema = Type.Object({
|
|
92
|
+
id: Type.String({ description: "Stable snake_case question identifier" }),
|
|
93
|
+
label: AskQuestionSchema.properties.label,
|
|
94
|
+
prompt: Type.String({ description: "Direct question shown to the user" }),
|
|
95
|
+
type: AskQuestionSchema.properties.type,
|
|
96
|
+
required: AskQuestionSchema.properties.required,
|
|
97
|
+
options: Type.Array(AnswerExtractionOptionSchema, {
|
|
98
|
+
description:
|
|
99
|
+
"Choices explicitly offered by the assistant, or one freeform option when the user should type an answer",
|
|
100
|
+
minItems: 1,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const AnswerExtractionParamsSchema = Type.Object({
|
|
105
|
+
...AskParamsSchema.properties,
|
|
106
|
+
questions: Type.Array(AnswerExtractionQuestionSchema, {
|
|
107
|
+
description: "Questions extracted from the assistant message",
|
|
108
|
+
}),
|
|
109
|
+
});
|
package/src/state/create.ts
CHANGED
package/src/state/normalize.ts
CHANGED
|
@@ -38,6 +38,51 @@ export function collectValidationIssues(
|
|
|
38
38
|
return collector.issues;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function prepareAskParams(input: unknown): unknown {
|
|
42
|
+
if (!(isRecord(input) && Array.isArray(input.questions))) {
|
|
43
|
+
return input;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
...input,
|
|
47
|
+
questions: input.questions.map((question) => {
|
|
48
|
+
if (!(isRecord(question) && Array.isArray(question.options))) {
|
|
49
|
+
return question;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
...question,
|
|
53
|
+
options: question.options.map(prepareOption),
|
|
54
|
+
};
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function prepareOption(input: unknown): unknown {
|
|
60
|
+
if (!isRecord(input)) {
|
|
61
|
+
return input;
|
|
62
|
+
}
|
|
63
|
+
if (
|
|
64
|
+
input.label !== undefined &&
|
|
65
|
+
!(typeof input.label === "string" && !input.label.trim())
|
|
66
|
+
) {
|
|
67
|
+
return input;
|
|
68
|
+
}
|
|
69
|
+
if (typeof input.value !== "string") {
|
|
70
|
+
return input;
|
|
71
|
+
}
|
|
72
|
+
const words = input.value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
|
|
73
|
+
if (!words) {
|
|
74
|
+
return input;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
...input,
|
|
78
|
+
label: words.charAt(0).toUpperCase() + words.slice(1),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
83
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
41
86
|
function normalizeQuestion(
|
|
42
87
|
question: AskQuestionInput,
|
|
43
88
|
index: number,
|
|
@@ -65,8 +110,11 @@ function normalizeOption(option: AskOption): AskOption {
|
|
|
65
110
|
return {
|
|
66
111
|
value: option.value.trim(),
|
|
67
112
|
label: option.label.trim(),
|
|
68
|
-
description: option.description?.trim(),
|
|
69
|
-
preview: option.preview?.trim(),
|
|
113
|
+
description: option.description?.trim() || undefined,
|
|
114
|
+
preview: option.preview?.trim() || undefined,
|
|
115
|
+
...(option.recommended === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: { recommended: option.recommended }),
|
|
70
118
|
...(option.freeform ? { freeform: true } : {}),
|
|
71
119
|
};
|
|
72
120
|
}
|
|
@@ -118,12 +166,6 @@ function validateQuestion(
|
|
|
118
166
|
`${questionPath}.id`,
|
|
119
167
|
`Question ${questionNumber}: duplicate question id "${questionId}"`
|
|
120
168
|
);
|
|
121
|
-
assertOptionalText(
|
|
122
|
-
question.label,
|
|
123
|
-
collector,
|
|
124
|
-
`${questionPath}.label`,
|
|
125
|
-
`Question ${questionNumber}: label must not be empty`
|
|
126
|
-
);
|
|
127
169
|
assertRequired(
|
|
128
170
|
question.prompt?.trim(),
|
|
129
171
|
collector,
|
|
@@ -218,18 +260,6 @@ function validateOption(
|
|
|
218
260
|
`${optionPath}.label`,
|
|
219
261
|
`${prefix}: label is required`
|
|
220
262
|
);
|
|
221
|
-
assertOptionalText(
|
|
222
|
-
option.description,
|
|
223
|
-
collector,
|
|
224
|
-
`${optionPath}.description`,
|
|
225
|
-
`${prefix}: description must not be empty`
|
|
226
|
-
);
|
|
227
|
-
assertOptionalText(
|
|
228
|
-
option.preview,
|
|
229
|
-
collector,
|
|
230
|
-
`${optionPath}.preview`,
|
|
231
|
-
`${prefix}: preview must not be empty`
|
|
232
|
-
);
|
|
233
263
|
if (questionType === "preview") {
|
|
234
264
|
assertRequired(
|
|
235
265
|
optionPreview,
|
|
@@ -287,17 +317,6 @@ function assertRequired(
|
|
|
287
317
|
}
|
|
288
318
|
}
|
|
289
319
|
|
|
290
|
-
function assertOptionalText(
|
|
291
|
-
value: string | undefined,
|
|
292
|
-
collector: IssueCollector,
|
|
293
|
-
path: string,
|
|
294
|
-
message: string
|
|
295
|
-
) {
|
|
296
|
-
if (value !== undefined && !value.trim()) {
|
|
297
|
-
collector.add(path, message);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
320
|
function assertUnique(
|
|
302
321
|
seen: Set<string>,
|
|
303
322
|
value: string | undefined,
|
package/src/state/result.ts
CHANGED
|
@@ -205,6 +205,9 @@ function cloneOption(option: AskState["questions"][number]["options"][number]) {
|
|
|
205
205
|
label: option.label,
|
|
206
206
|
...(option.description ? { description: option.description } : {}),
|
|
207
207
|
...(option.preview ? { preview: option.preview } : {}),
|
|
208
|
+
...(option.recommended === undefined
|
|
209
|
+
? {}
|
|
210
|
+
: { recommended: option.recommended }),
|
|
208
211
|
};
|
|
209
212
|
}
|
|
210
213
|
|
package/src/types.ts
CHANGED
package/src/ui/render-helpers.ts
CHANGED
|
@@ -311,16 +311,23 @@ export function mergeColumns(
|
|
|
311
311
|
}
|
|
312
312
|
|
|
313
313
|
export function measurePreviewLeftWidth(
|
|
314
|
-
options: Array<{
|
|
314
|
+
options: Array<{
|
|
315
|
+
description?: string;
|
|
316
|
+
label: string;
|
|
317
|
+
recommended?: boolean;
|
|
318
|
+
}>,
|
|
315
319
|
width: number
|
|
316
320
|
): number {
|
|
317
321
|
let widest = 0;
|
|
318
322
|
for (let index = 0; index < options.length; index++) {
|
|
319
323
|
const option = options[index];
|
|
324
|
+
const description = option.recommended
|
|
325
|
+
? `${UI_TEXT.recommendedMarker}${option.description ? ` | ${option.description}` : ""}`
|
|
326
|
+
: option.description;
|
|
320
327
|
widest = Math.max(
|
|
321
328
|
widest,
|
|
322
329
|
visibleWidth(`${index + 1}. ${option.label}`),
|
|
323
|
-
|
|
330
|
+
description ? visibleWidth(description) : 0
|
|
324
331
|
);
|
|
325
332
|
}
|
|
326
333
|
|