@black942026/pi-ask-user 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +365 -2
- package/index.ts +2 -0
- package/package.json +62 -4
- package/src/adapters/pi.ts +238 -0
- package/src/answers.ts +84 -0
- package/src/ask.ts +103 -0
- package/src/config.ts +390 -0
- package/src/core.ts +302 -0
- package/src/deadline.ts +78 -0
- package/src/events.ts +96 -0
- package/src/index.ts +358 -0
- package/src/mode.ts +18 -0
- package/src/parse.ts +119 -0
- package/src/route.ts +144 -0
- package/src/schema.ts +255 -0
- package/src/types.ts +343 -0
- package/src/ui/custom.ts +887 -0
- package/src/ui/native.ts +112 -0
package/src/deadline.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Deadline } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Largest delay Node's `setTimeout` accepts (a 32-bit signed integer). Larger
|
|
5
|
+
* values are clamped by Node to ~1 ms, which would fire a timer immediately and
|
|
6
|
+
* turn a huge timeout into an instant one. We clamp instead.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_SAFE_TIMEOUT_MS = 2_147_483_647;
|
|
9
|
+
|
|
10
|
+
/** Clamp a requested timeout to a value `setTimeout` can honour. */
|
|
11
|
+
export function safeTimeoutMs(ms: number): number {
|
|
12
|
+
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
|
13
|
+
return Math.min(Math.floor(ms), MAX_SAFE_TIMEOUT_MS);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a single overall deadline for a questionnaire.
|
|
18
|
+
*
|
|
19
|
+
* The deadline is always `timeoutMs` (which callers compute as
|
|
20
|
+
* `timeoutPerQuestionMs × questionCount`). It is clamped to
|
|
21
|
+
* {@link MAX_SAFE_TIMEOUT_MS} so `setTimeout` never overflows. Every interaction
|
|
22
|
+
* for the questionnaire shares this one deadline; native dialogs receive only
|
|
23
|
+
* the remaining time. A deadline never degrades to another route.
|
|
24
|
+
*/
|
|
25
|
+
export function createDeadline(totalMs: number, now: () => number = Date.now): Deadline {
|
|
26
|
+
const startedAt = now();
|
|
27
|
+
const safeTotal = safeTimeoutMs(totalMs);
|
|
28
|
+
const expiresAt = safeTotal === 0 ? startedAt : startedAt + safeTotal;
|
|
29
|
+
return {
|
|
30
|
+
startedAt,
|
|
31
|
+
expiresAt,
|
|
32
|
+
remainingMs(): number {
|
|
33
|
+
return Math.max(0, expiresAt - now());
|
|
34
|
+
},
|
|
35
|
+
expired(): boolean {
|
|
36
|
+
return now() >= expiresAt;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A signal plus the cleanup for the listeners it installed on its sources. */
|
|
42
|
+
export interface LinkedSignal {
|
|
43
|
+
signal: AbortSignal;
|
|
44
|
+
/** Remove listeners from the source signals. Idempotent. */
|
|
45
|
+
dispose(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Combine the caller's abort signal with a deadline-driven abort signal.
|
|
50
|
+
*
|
|
51
|
+
* Unlike `AbortSignal.any`, this returns an explicit `dispose()` so listeners
|
|
52
|
+
* installed on the caller's signal are removed once the interaction settles and
|
|
53
|
+
* do not accumulate across repeated calls.
|
|
54
|
+
*/
|
|
55
|
+
export function combineSignals(caller: AbortSignal | undefined, deadlineSignal: AbortSignal): LinkedSignal {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const onDeadline = () => controller.abort(deadlineSignal.reason);
|
|
58
|
+
const onCaller = () => controller.abort(caller?.reason);
|
|
59
|
+
let disposed = false;
|
|
60
|
+
|
|
61
|
+
if (deadlineSignal.aborted) controller.abort(deadlineSignal.reason);
|
|
62
|
+
else deadlineSignal.addEventListener("abort", onDeadline, { once: true });
|
|
63
|
+
|
|
64
|
+
if (caller) {
|
|
65
|
+
if (caller.aborted) controller.abort(caller.reason);
|
|
66
|
+
else caller.addEventListener("abort", onCaller, { once: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
dispose() {
|
|
72
|
+
if (disposed) return;
|
|
73
|
+
disposed = true;
|
|
74
|
+
deadlineSignal.removeEventListener("abort", onDeadline);
|
|
75
|
+
if (caller) caller.removeEventListener("abort", onCaller);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime event emission for the registered `ask_user` tool.
|
|
3
|
+
*
|
|
4
|
+
* These events carry *result semantics* that Pi's generic `ui_prompt_start` /
|
|
5
|
+
* `ui_prompt_end` do not: whether the wait ended with an answer, a dismissal, a
|
|
6
|
+
* timeout, or an error — and through which route.
|
|
7
|
+
*
|
|
8
|
+
* Two families are emitted:
|
|
9
|
+
*
|
|
10
|
+
* 1. `herdr:blocked` — `{ active: true, label, callId? }` only when the tool is
|
|
11
|
+
* actually entering the interactive wait, and exactly one matching
|
|
12
|
+
* `{ active: false, callId? }` afterwards, for every outcome (answered,
|
|
13
|
+
* aborted, timeout, error). Invalid or unavailable requests never emit it,
|
|
14
|
+
* because no UI is attempted.
|
|
15
|
+
* 2. `ask:answered` / `ask:aborted` / `ask:timeout` / `ask:error` — exactly once
|
|
16
|
+
* per UI attempt, carrying the correlation id and the route/status. By
|
|
17
|
+
* default the full question, answers, and free text are **never** broadcast;
|
|
18
|
+
* only non-sensitive metadata leaves the extension.
|
|
19
|
+
*
|
|
20
|
+
* A direct {@link createAskUser} caller has no Pi event bus; it may inject an
|
|
21
|
+
* {@link AskUserEventSink} instead (or accept the deliberate absence). This
|
|
22
|
+
* module never fabricates access to a bus.
|
|
23
|
+
*/
|
|
24
|
+
import type { AskUserEventSink, AskUserResult, AskUserRoute } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
/** The minimal event-bus surface used here (structurally matches Pi's EventBus). */
|
|
27
|
+
export interface AskEventBus {
|
|
28
|
+
emit(channel: string, data: unknown): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const HERDR_BLOCKED = "herdr:blocked";
|
|
32
|
+
export const ASK_ANSWERED = "ask:answered";
|
|
33
|
+
export const ASK_ABORTED = "ask:aborted";
|
|
34
|
+
export const ASK_TIMEOUT = "ask:timeout";
|
|
35
|
+
export const ASK_ERROR = "ask:error";
|
|
36
|
+
|
|
37
|
+
/** Fixed label for the blocked/idle indicator. */
|
|
38
|
+
export const WAITING_LABEL = "Waiting for user response";
|
|
39
|
+
|
|
40
|
+
function optionalCallId(callId: string | undefined): { callId?: string } {
|
|
41
|
+
return callId === undefined || callId === "" ? {} : { callId };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the Pi event-bus sink for one tool call. Returns `undefined` when no
|
|
46
|
+
* usable bus is present, so the caller emits nothing rather than faking it.
|
|
47
|
+
* Emission failures are swallowed: events must never break the interaction.
|
|
48
|
+
*/
|
|
49
|
+
export function createAskEventSink(bus: AskEventBus | undefined, callId?: string): AskUserEventSink | undefined {
|
|
50
|
+
if (!bus || typeof bus.emit !== "function") return undefined;
|
|
51
|
+
const emit = (channel: string, data: unknown): void => {
|
|
52
|
+
try {
|
|
53
|
+
bus.emit(channel, data);
|
|
54
|
+
} catch {
|
|
55
|
+
// An observer must never break the interaction.
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
waitStarted(): void {
|
|
60
|
+
emit(HERDR_BLOCKED, { active: true, label: WAITING_LABEL, ...optionalCallId(callId) });
|
|
61
|
+
},
|
|
62
|
+
waitEnded(result: AskUserResult): void {
|
|
63
|
+
emit(HERDR_BLOCKED, { active: false, ...optionalCallId(callId) });
|
|
64
|
+
const correlation: { callId?: string; route?: AskUserRoute } = { ...optionalCallId(callId) };
|
|
65
|
+
if (result.route !== undefined) correlation.route = result.route;
|
|
66
|
+
switch (result.status) {
|
|
67
|
+
case "answered":
|
|
68
|
+
emit(ASK_ANSWERED, { ...correlation, status: "answered" });
|
|
69
|
+
break;
|
|
70
|
+
case "aborted":
|
|
71
|
+
emit(ASK_ABORTED, { ...correlation, status: "aborted", cancelledReason: result.cancelledReason ?? "user" });
|
|
72
|
+
break;
|
|
73
|
+
case "timeout":
|
|
74
|
+
emit(ASK_TIMEOUT, { ...correlation, status: "timeout" });
|
|
75
|
+
break;
|
|
76
|
+
case "error":
|
|
77
|
+
emit(ASK_ERROR, { ...correlation, status: "error", errorCode: result.error?.code });
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The outcome-event channel for a status, exposed for documentation/tests. */
|
|
85
|
+
export function outcomeChannel(status: AskUserResult["status"]): string {
|
|
86
|
+
switch (status) {
|
|
87
|
+
case "answered":
|
|
88
|
+
return ASK_ANSWERED;
|
|
89
|
+
case "aborted":
|
|
90
|
+
return ASK_ABORTED;
|
|
91
|
+
case "timeout":
|
|
92
|
+
return ASK_TIMEOUT;
|
|
93
|
+
case "error":
|
|
94
|
+
return ASK_ERROR;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { createPiHost } from "./adapters/pi.ts";
|
|
4
|
+
import { createAskEventSink } from "./events.ts";
|
|
5
|
+
import { askUserNormalized } from "./core.ts";
|
|
6
|
+
import { AskUserParams, normalizeAskUserRequest } from "./schema.ts";
|
|
7
|
+
import type {
|
|
8
|
+
AskUserDisplayMode,
|
|
9
|
+
AskUserErrorCode,
|
|
10
|
+
AskUserHost,
|
|
11
|
+
AskUserMode,
|
|
12
|
+
AskUserResult,
|
|
13
|
+
AskUserRoute,
|
|
14
|
+
AskUserStatus,
|
|
15
|
+
NormalizedRequest,
|
|
16
|
+
} from "./types.ts";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Re-exports: reusable TypeScript surface for other extensions
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export * from "./types.ts";
|
|
23
|
+
export { askUser, askUserNormalized } from "./core.ts";
|
|
24
|
+
export { createPiHost, createPiCustomRenderer, type PiHostOptions } from "./adapters/pi.ts";
|
|
25
|
+
export { createAskUser, type AskUser, type CreateAskUserOptions } from "./ask.ts";
|
|
26
|
+
export { createNativeRunner, type NativeDialogUI } from "./ui/native.ts";
|
|
27
|
+
export {
|
|
28
|
+
AskUserComponent,
|
|
29
|
+
MIN_USABLE_ROWS,
|
|
30
|
+
SPLIT_MIN_WIDTH,
|
|
31
|
+
type AskUserTheme,
|
|
32
|
+
type CustomUIResult,
|
|
33
|
+
} from "./ui/custom.ts";
|
|
34
|
+
export { nativeInputHint, parseNativeAnswer, type ParsedAnswer, type ParseOutcome } from "./parse.ts";
|
|
35
|
+
export {
|
|
36
|
+
AskUserValidationError,
|
|
37
|
+
AskUserParams,
|
|
38
|
+
DEFAULT_TIMEOUT_PER_QUESTION_MS,
|
|
39
|
+
LIMITS,
|
|
40
|
+
MAX_OPTIONS,
|
|
41
|
+
MAX_QUESTIONS,
|
|
42
|
+
normalizeAskUserRequest,
|
|
43
|
+
} from "./schema.ts";
|
|
44
|
+
export { hostCapabilities, probeRoutes, createAskUserHost, askUserSupport, type CreateAskUserHostOptions } from "./route.ts";
|
|
45
|
+
export { UI_MODES, isUIMode } from "./mode.ts";
|
|
46
|
+
export {
|
|
47
|
+
AskUserConfigError,
|
|
48
|
+
DEFAULT_DISPLAY_MODE,
|
|
49
|
+
DEFAULT_OVERLAY_TOGGLE_KEY,
|
|
50
|
+
USER_CONFIG_DIR,
|
|
51
|
+
USER_CONFIG_PATH,
|
|
52
|
+
isAllowedOverlayToggleSpec,
|
|
53
|
+
normalizeOverlayToggleKey,
|
|
54
|
+
parseAskUserConfig,
|
|
55
|
+
parseAskUserOptions,
|
|
56
|
+
readAskUserConfigFile,
|
|
57
|
+
resolveAskUserConfig,
|
|
58
|
+
toUiPreferences,
|
|
59
|
+
type AskUserConfigOverrides,
|
|
60
|
+
type ResolvedAskUserConfig,
|
|
61
|
+
} from "./config.ts";
|
|
62
|
+
export {
|
|
63
|
+
ASK_ABORTED,
|
|
64
|
+
ASK_ANSWERED,
|
|
65
|
+
ASK_ERROR,
|
|
66
|
+
ASK_TIMEOUT,
|
|
67
|
+
HERDR_BLOCKED,
|
|
68
|
+
WAITING_LABEL,
|
|
69
|
+
createAskEventSink,
|
|
70
|
+
outcomeChannel,
|
|
71
|
+
type AskEventBus,
|
|
72
|
+
} from "./events.ts";
|
|
73
|
+
export { createDeadline, combineSignals, safeTimeoutMs, MAX_SAFE_TIMEOUT_MS, type LinkedSignal } from "./deadline.ts";
|
|
74
|
+
export {
|
|
75
|
+
draftFromDefault,
|
|
76
|
+
draftIsEmpty,
|
|
77
|
+
emptyDraft,
|
|
78
|
+
finalizeAnswers,
|
|
79
|
+
toAnswer,
|
|
80
|
+
type DraftAnswer,
|
|
81
|
+
type FinalizeResult,
|
|
82
|
+
} from "./answers.ts";
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Tool
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export const TOOL_NAME = "ask_user";
|
|
89
|
+
|
|
90
|
+
export interface AskUserToolDetails {
|
|
91
|
+
/** Absent when no route ran (invalid_request / invalid_config). */
|
|
92
|
+
route?: AskUserRoute;
|
|
93
|
+
status: AskUserStatus;
|
|
94
|
+
answers: AskUserResult["answers"];
|
|
95
|
+
warnings?: string[];
|
|
96
|
+
error?: AskUserResult["error"];
|
|
97
|
+
/** Present while streaming progress updates. */
|
|
98
|
+
progress?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const DESCRIPTION = "Ask the user one or more structured questions through the host's interactive UI.";
|
|
102
|
+
|
|
103
|
+
const PROMPT_SNIPPET = "Ask the user structured questions when you would otherwise guess.";
|
|
104
|
+
|
|
105
|
+
const PROMPT_GUIDELINES = [
|
|
106
|
+
"Use ask_user when a choice is high-impact or ambiguous and you cannot infer the answer.",
|
|
107
|
+
"If the tool reports the UI could not run, ask the user the question yourself in ordinary text instead.",
|
|
108
|
+
"Never invent an answer; on aborted or timeout, report it plainly and do not re-ask.",
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Error codes that mean the interactive UI could not be shown or run. For these
|
|
113
|
+
* the tool text advises the model to ask the user in ordinary text instead. An
|
|
114
|
+
* invalid questionnaire or an empty answer is *not* a UI failure.
|
|
115
|
+
*/
|
|
116
|
+
const UI_FAILURE_CODES: readonly AskUserErrorCode[] = [
|
|
117
|
+
"unsupported_mode",
|
|
118
|
+
"invalid_config",
|
|
119
|
+
"not_initialized",
|
|
120
|
+
"custom_ui_failed",
|
|
121
|
+
"native_ui_failed",
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
function isUIFailure(code: AskUserErrorCode | undefined): boolean {
|
|
125
|
+
return code !== undefined && UI_FAILURE_CODES.includes(code);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Render the questionnaire as plain text, used only in the fallback advice. */
|
|
129
|
+
function describeRequestAsText(request: NormalizedRequest): string {
|
|
130
|
+
const lines: string[] = [];
|
|
131
|
+
if (request.header && request.header.trim() !== "") lines.push(request.header.trim());
|
|
132
|
+
request.questions.forEach((question, index) => {
|
|
133
|
+
const prompt = question.prompt?.trim();
|
|
134
|
+
lines.push(`${index + 1}. ${question.title}${prompt ? ` — ${prompt}` : ""}`);
|
|
135
|
+
question.options.forEach((option, optionIndex) => {
|
|
136
|
+
const description = option.description?.trim();
|
|
137
|
+
lines.push(` ${optionIndex + 1}) ${option.label}${description ? ` — ${description}` : ""}`);
|
|
138
|
+
});
|
|
139
|
+
if (question.hasDefault && question.default !== undefined) lines.push(` default: ${question.default}`);
|
|
140
|
+
});
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Model-facing text for a tool result. UI-unavailable/display failures carry an
|
|
146
|
+
* explicit ordinary-text fallback (with the question content); timeouts and
|
|
147
|
+
* aborts do not, because the UI may already have been shown and re-asking would
|
|
148
|
+
* prompt the user twice. In every case the model is told not to invent an answer.
|
|
149
|
+
*/
|
|
150
|
+
function buildModelContent(result: AskUserResult, request: NormalizedRequest): string {
|
|
151
|
+
switch (result.status) {
|
|
152
|
+
case "answered": {
|
|
153
|
+
const lines = result.answers.map((answer) => {
|
|
154
|
+
const parts: string[] = [];
|
|
155
|
+
if (answer.selections.length > 0) parts.push(answer.selections.join(", "));
|
|
156
|
+
if (answer.freeText) parts.push(answer.freeText);
|
|
157
|
+
const value = parts.length > 0 ? parts.join(" | ") : "(empty)";
|
|
158
|
+
const suffix = answer.usedDefault ? " (default)" : "";
|
|
159
|
+
return `- ${answer.title}: ${value}${suffix}`;
|
|
160
|
+
});
|
|
161
|
+
return `The user answered:\n${lines.join("\n")}`;
|
|
162
|
+
}
|
|
163
|
+
case "aborted":
|
|
164
|
+
return result.cancelledReason === "abort"
|
|
165
|
+
? "The request was aborted before an answer. Do not invent one."
|
|
166
|
+
: "The user dismissed the interaction; no answer. Do not invent one or re-ask the same question.";
|
|
167
|
+
case "timeout":
|
|
168
|
+
return "No answer before the deadline. Do not invent one, and do not repeat the same prompt.";
|
|
169
|
+
case "error": {
|
|
170
|
+
const code = result.error?.code;
|
|
171
|
+
const base = `AskUser failed (${code ?? "internal"}): ${result.error?.message ?? "unknown error"}.`;
|
|
172
|
+
if (isUIFailure(code)) {
|
|
173
|
+
return [
|
|
174
|
+
base,
|
|
175
|
+
"The interactive UI could not complete the request. Ask the user the question yourself in ordinary text — this is a fallback message, not an answer:",
|
|
176
|
+
describeRequestAsText(request),
|
|
177
|
+
"Then wait for the user's reply; do not invent an answer.",
|
|
178
|
+
].join("\n");
|
|
179
|
+
}
|
|
180
|
+
if (code === "invalid_request") {
|
|
181
|
+
return `${base} Fix the request and retry the tool call. Do not invent an answer.`;
|
|
182
|
+
}
|
|
183
|
+
// Non-UI failures (empty_answer, internal): report plainly, no retry advice.
|
|
184
|
+
return `${base} Do not invent an answer; report the failure to the user.`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function detailsOf(result: AskUserResult): AskUserToolDetails {
|
|
190
|
+
const details: AskUserToolDetails = { status: result.status, answers: result.answers };
|
|
191
|
+
if (result.route !== undefined) details.route = result.route;
|
|
192
|
+
if (result.warnings !== undefined) details.warnings = result.warnings;
|
|
193
|
+
if (result.error !== undefined) details.error = result.error;
|
|
194
|
+
return details;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface RegisterAskUserOptions {
|
|
198
|
+
/**
|
|
199
|
+
* Explicit UI route for this registration: `custom` | `native`. It has the
|
|
200
|
+
* highest precedence. When omitted, the route is read from the user config
|
|
201
|
+
* file, else probed from the implementations the context supports (preferring
|
|
202
|
+
* `custom`), at host creation — never per request. An invalid value, or a
|
|
203
|
+
* route the environment cannot run, is an actionable error — never a fallback.
|
|
204
|
+
*/
|
|
205
|
+
mode?: AskUserMode;
|
|
206
|
+
/** Explicit display mode; overrides the config file and the model's request. */
|
|
207
|
+
displayMode?: AskUserDisplayMode;
|
|
208
|
+
/** Explicit overlay toggle key; `null` disables it. Overrides the config file. */
|
|
209
|
+
overlayToggleKey?: string | null;
|
|
210
|
+
/** Explicit timeout (ms) per question; overrides the config file and the request. */
|
|
211
|
+
timeoutPerQuestionMs?: number;
|
|
212
|
+
/**
|
|
213
|
+
* Path to the user config file, or `false` to skip reading it. Defaults to
|
|
214
|
+
* `~/.pi/ask-user/config.json`. There are no environment variables.
|
|
215
|
+
*/
|
|
216
|
+
configFile?: string | false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Register the `ask_user` tool for the current extension runtime.
|
|
221
|
+
*
|
|
222
|
+
* The explicit options are captured here, at registration. The host is created
|
|
223
|
+
* once per session from the context delivered to `pi.on("session_start", ...)`,
|
|
224
|
+
* so the capability probe and the user config read run exactly once and every
|
|
225
|
+
* tool call reuses that host. The host is released on `session_shutdown`, which
|
|
226
|
+
* Pi also fires before a reload or session replacement, so the following
|
|
227
|
+
* `session_start` builds a fresh host (and re-reads the config) from the new
|
|
228
|
+
* context.
|
|
229
|
+
*
|
|
230
|
+
* Runtime events: when the tool really enters the interactive wait it emits
|
|
231
|
+
* `herdr:blocked` `{ active: true, ... }`, then exactly one matching
|
|
232
|
+
* `{ active: false, ... }`, plus one outcome event (`ask:answered`,
|
|
233
|
+
* `ask:aborted`, `ask:timeout`, or `ask:error`) carrying the call id and the
|
|
234
|
+
* route/status. Invalid or unavailable requests emit nothing. See `events.ts`.
|
|
235
|
+
*
|
|
236
|
+
* A route the environment cannot run, or a renderer that fails, is reported as
|
|
237
|
+
* an actionable error in the tool text (never a silent fallback and never an
|
|
238
|
+
* unanswered questionnaire handed back as if it were the user's answer).
|
|
239
|
+
*/
|
|
240
|
+
export function registerAskUser(pi: ExtensionAPI, options: RegisterAskUserOptions = {}): void {
|
|
241
|
+
let host: AskUserHost | undefined;
|
|
242
|
+
|
|
243
|
+
pi.on("session_start", (_event, ctx) => {
|
|
244
|
+
host = createPiHost(ctx, options);
|
|
245
|
+
});
|
|
246
|
+
pi.on("session_shutdown", () => {
|
|
247
|
+
host = undefined;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
pi.registerTool<typeof AskUserParams, AskUserToolDetails>({
|
|
251
|
+
name: TOOL_NAME,
|
|
252
|
+
label: "Ask User",
|
|
253
|
+
description: DESCRIPTION,
|
|
254
|
+
promptSnippet: PROMPT_SNIPPET,
|
|
255
|
+
promptGuidelines: PROMPT_GUIDELINES,
|
|
256
|
+
executionMode: "sequential",
|
|
257
|
+
parameters: AskUserParams,
|
|
258
|
+
async execute(toolCallId, params, signal, onUpdate, _ctx: ExtensionContext) {
|
|
259
|
+
let request: NormalizedRequest;
|
|
260
|
+
let warnings: string[] = [];
|
|
261
|
+
try {
|
|
262
|
+
const normalized = normalizeAskUserRequest(params);
|
|
263
|
+
request = normalized.request;
|
|
264
|
+
warnings = normalized.warnings;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
// An invalid questionnaire is a request error, not a UI failure: no
|
|
267
|
+
// ordinary-text fallback is offered here.
|
|
268
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
269
|
+
const details: AskUserToolDetails = {
|
|
270
|
+
status: "error",
|
|
271
|
+
answers: [],
|
|
272
|
+
error: { code: "invalid_request", message },
|
|
273
|
+
};
|
|
274
|
+
return {
|
|
275
|
+
content: [
|
|
276
|
+
{
|
|
277
|
+
type: "text" as const,
|
|
278
|
+
text: `Invalid parameters: ${message} Fix the request and retry the tool call. Do not invent an answer.`,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
details,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The session-scoped host is the single source of truth for the route;
|
|
286
|
+
// no probe happens per call. A call before the session has started is an
|
|
287
|
+
// explicit error, not a fabricated "no UI" result.
|
|
288
|
+
const activeHost = host;
|
|
289
|
+
if (!activeHost) {
|
|
290
|
+
const result: AskUserResult = {
|
|
291
|
+
status: "error",
|
|
292
|
+
answers: [],
|
|
293
|
+
error: {
|
|
294
|
+
code: "not_initialized",
|
|
295
|
+
message: "AskUser has not finished session initialization (no session_start received).",
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
return { content: [{ type: "text" as const, text: buildModelContent(result, request) }], details: detailsOf(result) };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Progress updates advertise the route actually in use; a host that
|
|
302
|
+
// cannot prompt has no route to advertise.
|
|
303
|
+
const onUpdateText = onUpdate
|
|
304
|
+
? (text: string) => {
|
|
305
|
+
const details: AskUserToolDetails = { status: "answered", answers: [], progress: text };
|
|
306
|
+
if (activeHost.support.status === "available") details.route = activeHost.support.route;
|
|
307
|
+
onUpdate({ content: [{ type: "text" as const, text }], details });
|
|
308
|
+
}
|
|
309
|
+
: undefined;
|
|
310
|
+
const askOptions: Parameters<typeof askUserNormalized>[1] = { host: activeHost };
|
|
311
|
+
if (signal) askOptions.signal = signal;
|
|
312
|
+
if (onUpdateText) askOptions.onUpdate = onUpdateText;
|
|
313
|
+
// The event sink is created per call so its correlation id is the tool
|
|
314
|
+
// call id. It is absent when the runtime exposes no usable event bus, in
|
|
315
|
+
// which case nothing is emitted rather than faking a bus.
|
|
316
|
+
const events = createAskEventSink(pi.events, toolCallId);
|
|
317
|
+
if (events) askOptions.events = events;
|
|
318
|
+
|
|
319
|
+
const result = await askUserNormalized(request, askOptions, warnings);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
content: [{ type: "text" as const, text: buildModelContent(result, request) }],
|
|
323
|
+
details: detailsOf(result),
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
renderCall(args, theme) {
|
|
327
|
+
const count = Array.isArray(args.questions) ? args.questions.length : 0;
|
|
328
|
+
const titles = Array.isArray(args.questions)
|
|
329
|
+
? args.questions
|
|
330
|
+
.map((question) => (question as { title?: unknown }).title)
|
|
331
|
+
.filter((title): title is string => typeof title === "string")
|
|
332
|
+
.join(" / ")
|
|
333
|
+
: "";
|
|
334
|
+
const label = `${TOOL_NAME} · ${count} question(s)`;
|
|
335
|
+
return new Text(theme.fg("toolTitle", label) + (titles ? theme.fg("muted", ` — ${titles}`) : ""), 0, 0);
|
|
336
|
+
},
|
|
337
|
+
renderResult(result, renderOptions, theme) {
|
|
338
|
+
const details = result.details as AskUserToolDetails | undefined;
|
|
339
|
+
const text =
|
|
340
|
+
result.content
|
|
341
|
+
.filter((block) => block.type === "text")
|
|
342
|
+
.map((block) => (block as { text?: string }).text ?? "")
|
|
343
|
+
.join("\n") || "";
|
|
344
|
+
if (renderOptions.isPartial) {
|
|
345
|
+
return new Text(theme.fg("muted", text || "Waiting for user input…"), 0, 0);
|
|
346
|
+
}
|
|
347
|
+
const status = details?.status ?? "error";
|
|
348
|
+
const tone = status === "answered" ? "success" : status === "error" ? "error" : "warning";
|
|
349
|
+
const header = theme.fg(tone, `${TOOL_NAME}: ${status} (${details?.route ?? "?"})`);
|
|
350
|
+
return new Text(`${header}\n${theme.fg("toolOutput", text)}`, 0, 0);
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Pi extension entry point. */
|
|
356
|
+
export default function askUserExtension(pi: ExtensionAPI): void {
|
|
357
|
+
registerAskUser(pi);
|
|
358
|
+
}
|
package/src/mode.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AskUserMode } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The UI routes AskUser supports, in probe-priority order.
|
|
5
|
+
*
|
|
6
|
+
* A route is never read from the environment: it is either passed explicitly to
|
|
7
|
+
* the host-factory (`createAskUserHost({ mode })`, `createPiHost(ctx, { mode })`,
|
|
8
|
+
* `registerAskUser(pi, { mode })`) or discovered from the implementations
|
|
9
|
+
* the host can really bind (see `createAskUserHost`). Model tool parameters can
|
|
10
|
+
* never influence it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The accepted routes, in documentation and probe-priority order. */
|
|
14
|
+
export const UI_MODES: readonly AskUserMode[] = ["custom", "native"];
|
|
15
|
+
|
|
16
|
+
export function isUIMode(value: unknown): value is AskUserMode {
|
|
17
|
+
return typeof value === "string" && (UI_MODES as readonly string[]).includes(value);
|
|
18
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { NormalizedQuestion } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parsing of native-dialog answers.
|
|
5
|
+
*
|
|
6
|
+
* The native route uses exactly one Pi native input dialog per question. The
|
|
7
|
+
* number and the optional trailing free text live in the same box:
|
|
8
|
+
*
|
|
9
|
+
* single: `2` `2 | note` `<free text>`
|
|
10
|
+
* multi: `1,3` `1,3 | note` `<free text>`
|
|
11
|
+
* input: `<free text>`
|
|
12
|
+
*
|
|
13
|
+
* The left-hand side of the first `|` is classified as an option-number list or
|
|
14
|
+
* as free text. The rules are deliberately strict so a malformed number list is
|
|
15
|
+
* re-prompted instead of being silently treated as prose:
|
|
16
|
+
*
|
|
17
|
+
* 1. If the left side contains only digits and separators (` ` `,` `,` `、`),
|
|
18
|
+
* it is an option-number list. Tokens are validated for range and count.
|
|
19
|
+
* 2. Otherwise, if the left side begins with a digit run immediately followed by
|
|
20
|
+
* a list separator (`,` `,` `、` `.`) — e.g. `1,a`, `1.5`, `2, please` — it is
|
|
21
|
+
* a malformed option-number list and is rejected, not guessed as free text.
|
|
22
|
+
* 3. Otherwise the whole input is free text. This covers prose such as
|
|
23
|
+
* `2 options please` (a space after the digit, not a list separator).
|
|
24
|
+
*
|
|
25
|
+
* To answer with prose that starts like a number list, write it after an empty
|
|
26
|
+
* left side: `| 1, please explain`. An empty left side always means free input.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface ParsedAnswer {
|
|
30
|
+
selections: number[];
|
|
31
|
+
freeText?: string;
|
|
32
|
+
empty: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ParseOutcome = { ok: true; value: ParsedAnswer } | { ok: false; error: string };
|
|
36
|
+
|
|
37
|
+
const PURE_DIGITS = /^\d+$/;
|
|
38
|
+
/** digits + separators only (spaces, ASCII/CJK commas, enumeration comma). */
|
|
39
|
+
const DIGIT_SEPARATOR_ONLY = /^[\d\s,\uFF0C\u3001]+$/;
|
|
40
|
+
/** a digit run immediately followed by a list separator ⇒ a number-list attempt. */
|
|
41
|
+
const NUMBER_LIST_ATTEMPT = /^\d+\s*[.,\uFF0C\u3001]/;
|
|
42
|
+
|
|
43
|
+
function splitOnSeparators(value: string): string[] {
|
|
44
|
+
return value.split(/[,\uFF0C\u3001\s]+/).filter((token) => token.length > 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseNativeAnswer(question: NormalizedQuestion, raw: string): ParseOutcome {
|
|
48
|
+
const trimmed = raw.trim();
|
|
49
|
+
if (trimmed === "") {
|
|
50
|
+
return { ok: true, value: { selections: [], empty: true } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Free-text-only questions take the whole box verbatim.
|
|
54
|
+
if (question.kind === "input") {
|
|
55
|
+
return { ok: true, value: { selections: [], freeText: trimmed, empty: false } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const pipeIndex = raw.indexOf("|");
|
|
59
|
+
const left = pipeIndex >= 0 ? raw.slice(0, pipeIndex) : raw;
|
|
60
|
+
const right = pipeIndex >= 0 ? raw.slice(pipeIndex + 1) : "";
|
|
61
|
+
const comment = right.trim();
|
|
62
|
+
const leftTrim = left.trim();
|
|
63
|
+
|
|
64
|
+
// No number in the box: the (optional) right side is the free input.
|
|
65
|
+
if (leftTrim === "") {
|
|
66
|
+
return { ok: true, value: { selections: [], freeText: comment, empty: comment === "" } };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isStrictList = DIGIT_SEPARATOR_ONLY.test(leftTrim);
|
|
70
|
+
const isListAttempt = isStrictList || NUMBER_LIST_ATTEMPT.test(leftTrim);
|
|
71
|
+
if (!isListAttempt) {
|
|
72
|
+
// Free text, taken verbatim including any `|`.
|
|
73
|
+
return { ok: true, value: { selections: [], freeText: trimmed, empty: false } };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Tokens are separated only by commas/spaces; a stray character such as `.`
|
|
77
|
+
// or a letter makes the whole thing an invalid number list (re-prompt).
|
|
78
|
+
const tokens = splitOnSeparators(leftTrim);
|
|
79
|
+
const bad = tokens.find((token) => !PURE_DIGITS.test(token));
|
|
80
|
+
if (bad !== undefined || tokens.length === 0) {
|
|
81
|
+
return { ok: false, error: `Invalid number: "${leftTrim}" is not a valid option-number list.` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const indices = tokens.map((token) => Number.parseInt(token, 10) - 1);
|
|
85
|
+
const max = question.options.length;
|
|
86
|
+
const outOfRange = indices.find((idx) => idx < 0 || idx >= max);
|
|
87
|
+
if (outOfRange !== undefined) {
|
|
88
|
+
return { ok: false, error: `Number out of range: enter a number between 1 and ${max}.` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const unique = [...new Set(indices)];
|
|
92
|
+
if (question.kind === "single" && unique.length > 1) {
|
|
93
|
+
return { ok: false, error: "This is a single-select question; enter only one number." };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ordered = question.kind === "single" ? unique : [...unique].sort((a, b) => a - b);
|
|
97
|
+
const value: ParsedAnswer = {
|
|
98
|
+
selections: question.kind === "single" ? [ordered[0]!] : ordered,
|
|
99
|
+
empty: false,
|
|
100
|
+
};
|
|
101
|
+
if (comment !== "") value.freeText = comment;
|
|
102
|
+
return { ok: true, value };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Render the per-question input instruction used by the native route. */
|
|
106
|
+
export function nativeInputHint(question: NormalizedQuestion): string {
|
|
107
|
+
switch (question.kind) {
|
|
108
|
+
case "single":
|
|
109
|
+
return question.options.length > 0
|
|
110
|
+
? 'Single select. Reply with a number (e.g. 2), or "2 | note", or just reply with free text.'
|
|
111
|
+
: "Free input. Reply with text to answer.";
|
|
112
|
+
case "multi":
|
|
113
|
+
return question.options.length > 0
|
|
114
|
+
? 'Multi select. Reply with numbers separated by commas (e.g. 1,3), or "1,3 | note", or just reply with free text.'
|
|
115
|
+
: "Free input. Reply with text to answer.";
|
|
116
|
+
case "input":
|
|
117
|
+
return "Free input. Reply with text to answer.";
|
|
118
|
+
}
|
|
119
|
+
}
|