@pi9/ask 0.1.0 → 0.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/README.md +11 -0
- package/package.json +1 -1
- package/src/component.ts +91 -38
- package/src/deadline.ts +29 -4
- package/src/domain.ts +144 -0
- package/src/index.ts +78 -76
- package/src/questionnaire.ts +2 -2
- package/src/rpc.ts +24 -38
- package/src/session.ts +254 -0
- package/src/state.ts +21 -25
- package/src/config.ts +0 -30
- package/src/context.ts +0 -209
- package/src/replay-renderer.ts +0 -85
- package/src/replay.ts +0 -100
- package/src/response.ts +0 -31
- package/src/schema.ts +0 -43
- package/src/types.ts +0 -33
- package/src/validation.ts +0 -48
package/src/index.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import type { ExtensionAPI, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Text, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
|
-
import { rewriteAskContext } from "./context.js";
|
|
5
|
-
import { resolveTimeoutMs } from "./config.js";
|
|
6
4
|
import { createDeadlineSignal } from "./deadline.js";
|
|
5
|
+
import {
|
|
6
|
+
AskParamsSchema,
|
|
7
|
+
buildAskResponse,
|
|
8
|
+
normalizeAsk,
|
|
9
|
+
parseAskReplayDetails,
|
|
10
|
+
type AskAnswer,
|
|
11
|
+
type AskParams,
|
|
12
|
+
type AskToolDetails,
|
|
13
|
+
} from "./domain.js";
|
|
7
14
|
import { CHECKED_BOX, CHECKED_CIRCLE, EMPTY_BOX, EMPTY_CIRCLE } from "./glyphs.js";
|
|
8
15
|
import { launchQuestionnaire } from "./questionnaire.js";
|
|
9
|
-
import { renderAskReanswerMessage } from "./replay-renderer.js";
|
|
10
|
-
import { ASK_REPLAY_CUSTOM_TYPE, buildAskReplayMessage, parseAskReplayDetails, resolveAskReplayTarget } from "./replay.js";
|
|
11
|
-
import { buildAnsweredResponse, buildCancelledResponse, buildUiUnavailableResponse, buildUnansweredResponse } from "./response.js";
|
|
12
16
|
import { askWithRpc } from "./rpc.js";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
import {
|
|
18
|
+
ASK_REPLAY_CUSTOM_TYPE,
|
|
19
|
+
buildAskReplayMessage,
|
|
20
|
+
resolveAskReplayTarget,
|
|
21
|
+
rewriteAskContext,
|
|
22
|
+
} from "./session.js";
|
|
18
23
|
|
|
19
24
|
type AskRendererState = {
|
|
20
25
|
callComponent?: Text;
|
|
@@ -23,16 +28,11 @@ type AskRendererState = {
|
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
type AskReplayState =
|
|
26
|
-
| { status: "idle" }
|
|
27
|
-
| { status: "
|
|
28
|
-
| {
|
|
29
|
-
status: "dispatched";
|
|
30
|
-
details: ReturnType<typeof buildAskReplayMessage>["details"];
|
|
31
|
-
clearEditor: boolean;
|
|
32
|
-
};
|
|
31
|
+
| { status: "idle" | "prompting" }
|
|
32
|
+
| { status: "dispatched"; details: ReturnType<typeof buildAskReplayMessage>["details"] };
|
|
33
33
|
|
|
34
34
|
function renderAskCall(args: AskParams, theme: Theme, state: AskRendererState): string {
|
|
35
|
-
const questionColor = state.status === "answered" ? "text" : "
|
|
35
|
+
const questionColor = state.status === "answered" ? "text" : "muted";
|
|
36
36
|
const title = `${theme.fg("toolTitle", "ask")} ${theme.fg(questionColor, args.question)}`;
|
|
37
37
|
if (state.status) return title;
|
|
38
38
|
|
|
@@ -41,7 +41,7 @@ function renderAskCall(args: AskParams, theme: Theme, state: AskRendererState):
|
|
|
41
41
|
const timeout = args.timeout !== undefined && args.timeout > 0
|
|
42
42
|
? ` · timeout:${formatTimeout(args.timeout)}`
|
|
43
43
|
: "";
|
|
44
|
-
return `${title}\n${theme.fg("
|
|
44
|
+
return `${title}\n${theme.fg("muted", `╰ ${mode}options:${optionCount}${timeout}`)}`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
function formatTimeout(timeoutMs: number): string {
|
|
@@ -50,26 +50,55 @@ function formatTimeout(timeoutMs: number): string {
|
|
|
50
50
|
return `${Number.isInteger(seconds) ? seconds : Number(seconds.toFixed(2))}s`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
53
|
+
class AnsweredOptions implements Component {
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly args: AskParams,
|
|
56
|
+
private readonly answer: AskAnswer,
|
|
57
|
+
private readonly theme: Theme,
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
invalidate(): void {}
|
|
61
|
+
|
|
62
|
+
render(width: number): string[] {
|
|
63
|
+
const selections = new Map(this.answer.selections.map(selection => [selection.option, selection]));
|
|
64
|
+
const checkedGlyph = this.args.allowMultiple === true ? CHECKED_BOX : CHECKED_CIRCLE;
|
|
65
|
+
const emptyGlyph = this.args.allowMultiple === true ? EMPTY_BOX : EMPTY_CIRCLE;
|
|
66
|
+
const rows = this.args.options.map((option, index) => {
|
|
67
|
+
const label = option.label.trim();
|
|
68
|
+
const selection = selections.get(index);
|
|
69
|
+
const comment = selection?.comment ? ` (${selection.comment})` : "";
|
|
70
|
+
return {
|
|
71
|
+
marker: this.theme.fg(selection ? "success" : "muted", selection ? checkedGlyph : emptyGlyph),
|
|
72
|
+
text: this.theme.fg(selection ? "text" : "muted", `${label}${comment}`),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
if (this.answer.freeform) {
|
|
76
|
+
rows.push({
|
|
77
|
+
marker: this.theme.fg("success", checkedGlyph),
|
|
78
|
+
text: this.theme.fg("text", this.answer.freeform),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return rows.flatMap((row, index) => wrapAnsweredRow(
|
|
83
|
+
`${index === 0 ? `${this.theme.fg("muted", "╰")} ` : " "}${row.marker} `,
|
|
84
|
+
row.text,
|
|
85
|
+
width,
|
|
86
|
+
));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function wrapAnsweredRow(prefix: string, text: string, width: number): string[] {
|
|
91
|
+
const safeWidth = Math.max(1, Number.isFinite(width) ? Math.floor(width) : 1);
|
|
92
|
+
const prefixWidth = visibleWidth(prefix);
|
|
93
|
+
if (prefixWidth >= safeWidth) return wrapTextWithAnsi(`${prefix}${text}`, safeWidth);
|
|
94
|
+
|
|
95
|
+
const wrapped = wrapTextWithAnsi(text, safeWidth - prefixWidth);
|
|
96
|
+
const continuation = " ".repeat(prefixWidth);
|
|
97
|
+
return wrapped.map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
|
|
68
98
|
}
|
|
69
99
|
|
|
70
100
|
export default function askExtension(pi: ExtensionAPI) {
|
|
71
101
|
let replayState: AskReplayState = { status: "idle" };
|
|
72
|
-
let replayTreeSelection = false;
|
|
73
102
|
const revisedAnswers = new Map<string, AskAnswer>();
|
|
74
103
|
const rendererStates = new Map<string, AskRendererState>();
|
|
75
104
|
|
|
@@ -100,29 +129,17 @@ export default function askExtension(pi: ExtensionAPI) {
|
|
|
100
129
|
});
|
|
101
130
|
pi.on("before_agent_start", (_event, ctx) => reconcileAskTool(ctx.hasUI));
|
|
102
131
|
pi.on("context", (event) => ({ messages: rewriteAskContext(event.messages) }));
|
|
103
|
-
pi.on("agent_settled", (
|
|
132
|
+
pi.on("agent_settled", () => {
|
|
104
133
|
if (replayState.status !== "dispatched") return;
|
|
105
|
-
if (replayState.clearEditor) {
|
|
106
|
-
replayState = { ...replayState, clearEditor: false };
|
|
107
|
-
setTimeout(() => ctx.ui.setEditorText(""), 0);
|
|
108
|
-
}
|
|
109
134
|
pi.events.emit("ask:reanswered", replayState.details);
|
|
110
135
|
replayState = { status: "idle" };
|
|
111
136
|
});
|
|
112
137
|
pi.on("session_shutdown", () => {
|
|
113
138
|
replayState = { status: "idle" };
|
|
114
|
-
replayTreeSelection = false;
|
|
115
139
|
revisedAnswers.clear();
|
|
116
140
|
rendererStates.clear();
|
|
117
141
|
});
|
|
118
|
-
pi.registerMessageRenderer(ASK_REPLAY_CUSTOM_TYPE, renderAskReanswerMessage);
|
|
119
|
-
pi.on("session_before_tree", (event, ctx) => {
|
|
120
|
-
const target = ctx.sessionManager.getEntry(event.preparation.targetId);
|
|
121
|
-
replayTreeSelection = target?.type === "custom_message" && target.customType === ASK_REPLAY_CUSTOM_TYPE;
|
|
122
|
-
});
|
|
123
142
|
pi.on("session_tree", async (event, ctx) => {
|
|
124
|
-
const suppressEditorRestore = replayTreeSelection;
|
|
125
|
-
replayTreeSelection = false;
|
|
126
143
|
if (ctx.mode !== "tui" || replayState.status !== "idle") return;
|
|
127
144
|
|
|
128
145
|
const entries = ctx.sessionManager.getBranch();
|
|
@@ -137,30 +154,18 @@ export default function askExtension(pi: ExtensionAPI) {
|
|
|
137
154
|
return;
|
|
138
155
|
}
|
|
139
156
|
|
|
140
|
-
// Pi restores selected custom-message content after this hook returns. Keep
|
|
141
|
-
// the editor temporarily non-empty until the replay turn settles so the
|
|
142
|
-
// selected marker text cannot replace the guard.
|
|
143
|
-
const guardEditor = suppressEditorRestore && !ctx.ui.getEditorText().trim();
|
|
144
|
-
if (guardEditor) ctx.ui.setEditorText(TREE_EDITOR_GUARD);
|
|
145
|
-
|
|
146
157
|
replayState = { status: "prompting" };
|
|
147
|
-
const deadline = createDeadlineSignal(
|
|
148
|
-
undefined,
|
|
149
|
-
resolveTimeoutMs(resolution.params.timeout, process.env),
|
|
150
|
-
);
|
|
158
|
+
const deadline = createDeadlineSignal(undefined, resolution.ask.timeout, process.env);
|
|
151
159
|
try {
|
|
152
|
-
const answer = await launchQuestionnaire(ctx, resolution.
|
|
160
|
+
const answer = await launchQuestionnaire(ctx, resolution.ask, deadline.signal);
|
|
153
161
|
if (!answer) return;
|
|
154
|
-
const message = buildAskReplayMessage(resolution.toolCallId,
|
|
162
|
+
const message = buildAskReplayMessage(resolution.toolCallId, answer);
|
|
155
163
|
pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" });
|
|
156
164
|
applyRevision(message.details.toolCallId, message.details.answer);
|
|
157
|
-
replayState = { status: "dispatched", details: message.details
|
|
165
|
+
replayState = { status: "dispatched", details: message.details };
|
|
158
166
|
} finally {
|
|
159
167
|
deadline.dispose();
|
|
160
|
-
if (replayState.status !== "dispatched") {
|
|
161
|
-
replayState = { status: "idle" };
|
|
162
|
-
if (guardEditor) setTimeout(() => ctx.ui.setEditorText(""), 0);
|
|
163
|
-
}
|
|
168
|
+
if (replayState.status !== "dispatched") replayState = { status: "idle" };
|
|
164
169
|
}
|
|
165
170
|
});
|
|
166
171
|
|
|
@@ -177,29 +182,26 @@ export default function askExtension(pi: ExtensionAPI) {
|
|
|
177
182
|
executionMode: "sequential",
|
|
178
183
|
|
|
179
184
|
async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
|
|
180
|
-
const params =
|
|
181
|
-
if (!ctx.hasUI) return
|
|
185
|
+
const params = normalizeAsk(rawParams as AskParams);
|
|
186
|
+
if (!ctx.hasUI) return buildAskResponse(params, { status: "ui_unavailable" });
|
|
182
187
|
|
|
183
|
-
const deadline = createDeadlineSignal(
|
|
184
|
-
signal,
|
|
185
|
-
resolveTimeoutMs(params.timeout, process.env),
|
|
186
|
-
);
|
|
188
|
+
const deadline = createDeadlineSignal(signal, params.timeout, process.env);
|
|
187
189
|
try {
|
|
188
190
|
const answer = ctx.mode === "tui"
|
|
189
191
|
? await launchQuestionnaire(ctx, params, deadline.signal)
|
|
190
192
|
: await askWithRpc(ctx.ui, params, deadline.signal);
|
|
191
193
|
if (answer === null) {
|
|
192
194
|
if (deadline.timedOut) {
|
|
193
|
-
const result =
|
|
195
|
+
const result = buildAskResponse(params, { status: "unanswered" });
|
|
194
196
|
pi.events.emit("ask:unanswered", result.details);
|
|
195
197
|
return result;
|
|
196
198
|
}
|
|
197
|
-
const result =
|
|
199
|
+
const result = buildAskResponse(params, { status: "cancelled" });
|
|
198
200
|
pi.events.emit("ask:cancelled", result.details);
|
|
199
201
|
return result;
|
|
200
202
|
}
|
|
201
203
|
|
|
202
|
-
const result =
|
|
204
|
+
const result = buildAskResponse(params, { status: "answered", answer });
|
|
203
205
|
pi.events.emit("ask:answered", result.details);
|
|
204
206
|
return result;
|
|
205
207
|
} finally {
|
|
@@ -220,7 +222,7 @@ export default function askExtension(pi: ExtensionAPI) {
|
|
|
220
222
|
?? (result.details?.status === "answered" ? result.details.answer : undefined);
|
|
221
223
|
context.state.status = answer === undefined ? "settled" : "answered";
|
|
222
224
|
context.state.callComponent?.setText(renderAskCall(context.args, theme, context.state));
|
|
223
|
-
if (answer) return new
|
|
225
|
+
if (answer) return new AnsweredOptions(context.args, answer, theme);
|
|
224
226
|
const text = result.content.find((item) => item.type === "text")?.text ?? "Ask completed.";
|
|
225
227
|
const color = result.details?.status === "cancelled" || result.details?.status === "unanswered"
|
|
226
228
|
? "muted"
|
package/src/questionnaire.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import { AskComponent } from "./component.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { Ask, AskAnswer } from "./domain.js";
|
|
5
5
|
|
|
6
6
|
interface QuestionnaireLaunchContext {
|
|
7
7
|
ui: Pick<ExtensionUIContext, "custom">;
|
|
@@ -9,7 +9,7 @@ interface QuestionnaireLaunchContext {
|
|
|
9
9
|
|
|
10
10
|
export async function launchQuestionnaire(
|
|
11
11
|
ctx: QuestionnaireLaunchContext,
|
|
12
|
-
params:
|
|
12
|
+
params: Ask,
|
|
13
13
|
signal?: AbortSignal,
|
|
14
14
|
): Promise<AskAnswer | null> {
|
|
15
15
|
let abortListener: (() => void) | undefined;
|
package/src/rpc.ts
CHANGED
|
@@ -1,101 +1,93 @@
|
|
|
1
|
-
import type { AskAnswer, AskOption,
|
|
1
|
+
import type { Ask, AskAnswer, AskOption, AskSelection } from "./domain.js";
|
|
2
2
|
|
|
3
3
|
export interface AskDialogUI {
|
|
4
4
|
select(title: string, options: string[], dialogOptions?: { signal?: AbortSignal }): Promise<string | undefined>;
|
|
5
5
|
input(title: string, placeholder?: string, dialogOptions?: { signal?: AbortSignal }): Promise<string | undefined>;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
type AskSelection = AskAnswer["selections"][number];
|
|
9
|
-
|
|
10
8
|
const FREEFORM_CHOICE = "Type a response…";
|
|
11
9
|
|
|
12
10
|
export async function askWithRpc(
|
|
13
11
|
ui: AskDialogUI,
|
|
14
|
-
|
|
12
|
+
ask: Ask,
|
|
15
13
|
signal?: AbortSignal,
|
|
16
14
|
): Promise<AskAnswer | null> {
|
|
17
|
-
const prompt =
|
|
15
|
+
const prompt = ask.context ? `${ask.context}\n\n${ask.question}` : ask.question;
|
|
18
16
|
if (signal?.aborted) return null;
|
|
19
17
|
|
|
20
|
-
return
|
|
21
|
-
? runMultiSelect(ui, prompt,
|
|
22
|
-
: runSingleSelect(ui, prompt,
|
|
18
|
+
return ask.allowMultiple
|
|
19
|
+
? runMultiSelect(ui, prompt, ask, signal)
|
|
20
|
+
: runSingleSelect(ui, prompt, ask, signal);
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
async function runSingleSelect(
|
|
26
24
|
ui: AskDialogUI,
|
|
27
25
|
prompt: string,
|
|
28
|
-
|
|
29
|
-
allowFreeform: boolean,
|
|
26
|
+
ask: Ask,
|
|
30
27
|
signal?: AbortSignal,
|
|
31
28
|
): Promise<AskAnswer | null> {
|
|
32
|
-
const choices = options.map((option, index) => `${index + 1}. ${formatOption(option)}`);
|
|
33
|
-
if (allowFreeform) choices.push(`${options.length + 1}. ${FREEFORM_CHOICE}`);
|
|
29
|
+
const choices = ask.options.map((option, index) => `${index + 1}. ${formatOption(option)}`);
|
|
30
|
+
if (ask.allowFreeform) choices.push(`${ask.options.length + 1}. ${FREEFORM_CHOICE}`);
|
|
34
31
|
|
|
35
32
|
const selected = await selectDialog(ui, prompt, choices, signal);
|
|
36
33
|
if (selected == null || signal?.aborted) return null;
|
|
37
34
|
|
|
38
35
|
const selectedIndex = choices.indexOf(selected);
|
|
39
36
|
if (selectedIndex === -1) return null;
|
|
40
|
-
if (selectedIndex === options.length) {
|
|
37
|
+
if (selectedIndex === ask.options.length) {
|
|
41
38
|
const freeform = await readInput(ui, prompt, signal);
|
|
42
39
|
return freeform === null ? null : makeAnswer([], freeform);
|
|
43
40
|
}
|
|
44
41
|
|
|
45
|
-
const
|
|
46
|
-
if (!selectedOption) return null;
|
|
47
|
-
const selections = await collectComments(ui, prompt, [selectedOption], signal);
|
|
42
|
+
const selections = await collectComments(ui, prompt, ask.options, [selectedIndex], signal);
|
|
48
43
|
return selections === null ? null : makeAnswer(selections);
|
|
49
44
|
}
|
|
50
45
|
|
|
51
46
|
async function runMultiSelect(
|
|
52
47
|
ui: AskDialogUI,
|
|
53
48
|
prompt: string,
|
|
54
|
-
|
|
55
|
-
allowFreeform: boolean,
|
|
49
|
+
ask: Ask,
|
|
56
50
|
signal?: AbortSignal,
|
|
57
51
|
): Promise<AskAnswer | null> {
|
|
58
|
-
const numberedOptions = options
|
|
52
|
+
const numberedOptions = ask.options
|
|
59
53
|
.map((option, index) => `${index + 1}. ${formatOption(option)}`)
|
|
60
54
|
.join("\n");
|
|
61
55
|
const selectionPrompt = `${prompt}\n\n${numberedOptions}\n\nEnter option numbers separated by commas:`;
|
|
62
56
|
const rawSelection = await readInput(ui, selectionPrompt, signal);
|
|
63
57
|
if (rawSelection === null) return null;
|
|
64
58
|
|
|
65
|
-
const
|
|
66
|
-
const freeform = allowFreeform
|
|
59
|
+
const selected = parseSelections(rawSelection, ask.options.length);
|
|
60
|
+
const freeform = ask.allowFreeform
|
|
67
61
|
? await readInput(ui, `${prompt}\n\nAdditional response (optional):`, signal)
|
|
68
62
|
: undefined;
|
|
69
63
|
if (freeform === null) return null;
|
|
70
64
|
|
|
71
|
-
const selections = await collectComments(ui, prompt,
|
|
65
|
+
const selections = await collectComments(ui, prompt, ask.options, selected, signal);
|
|
72
66
|
return selections === null ? null : makeAnswer(selections, freeform);
|
|
73
67
|
}
|
|
74
68
|
|
|
75
|
-
function parseSelections(raw: string,
|
|
76
|
-
const
|
|
69
|
+
function parseSelections(raw: string, optionCount: number): number[] {
|
|
70
|
+
const selected = new Set<number>();
|
|
77
71
|
for (const token of raw.split(",")) {
|
|
78
72
|
const value = token.trim();
|
|
79
73
|
if (!/^\d+$/.test(value)) continue;
|
|
80
74
|
|
|
81
75
|
const index = Number(value) - 1;
|
|
82
|
-
if (Number.isSafeInteger(index) && index >= 0 && index <
|
|
76
|
+
if (Number.isSafeInteger(index) && index >= 0 && index < optionCount) selected.add(index);
|
|
83
77
|
}
|
|
84
|
-
return
|
|
78
|
+
return [...selected].sort((a, b) => a - b);
|
|
85
79
|
}
|
|
86
80
|
|
|
87
81
|
async function collectComments(
|
|
88
82
|
ui: AskDialogUI,
|
|
89
83
|
prompt: string,
|
|
90
84
|
options: AskOption[],
|
|
85
|
+
selected: number[],
|
|
91
86
|
signal?: AbortSignal,
|
|
92
87
|
): Promise<AskSelection[] | null> {
|
|
93
|
-
const selections: AskSelection[] =
|
|
94
|
-
for (
|
|
95
|
-
const option = options[
|
|
96
|
-
const selection = selections[index];
|
|
97
|
-
if (!option || !selection) continue;
|
|
98
|
-
|
|
88
|
+
const selections: AskSelection[] = selected.map(option => ({ option }));
|
|
89
|
+
for (const selection of selections) {
|
|
90
|
+
const option = options[selection.option]!;
|
|
99
91
|
const comment = await readInput(ui, `${prompt}\n\nComment for \"${option.label}\" (optional):`, signal);
|
|
100
92
|
if (comment === null) return null;
|
|
101
93
|
if (comment) selection.comment = comment;
|
|
@@ -110,12 +102,6 @@ async function readInput(ui: AskDialogUI, title: string, signal?: AbortSignal):
|
|
|
110
102
|
return value.trim();
|
|
111
103
|
}
|
|
112
104
|
|
|
113
|
-
function toSelection(option: AskOption): AskSelection {
|
|
114
|
-
const selection: AskSelection = { label: option.label };
|
|
115
|
-
if (option.description !== undefined) selection.description = option.description;
|
|
116
|
-
return selection;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
105
|
function makeAnswer(selections: AskSelection[], freeform?: string): AskAnswer {
|
|
120
106
|
return freeform ? { selections, freeform } : { selections };
|
|
121
107
|
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContextEvent,
|
|
3
|
+
SessionEntry,
|
|
4
|
+
SessionTreeEvent,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Check } from "typebox/value";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
AskAnsweredDetailsSchema,
|
|
10
|
+
AskParamsSchema,
|
|
11
|
+
answerMatchesAsk,
|
|
12
|
+
normalizeAsk,
|
|
13
|
+
parseAskReplayDetails,
|
|
14
|
+
type Ask,
|
|
15
|
+
type AskAnswer,
|
|
16
|
+
type AskParams,
|
|
17
|
+
type AskReplayDetails,
|
|
18
|
+
} from "./domain.js";
|
|
19
|
+
|
|
20
|
+
export const ASK_REPLAY_CUSTOM_TYPE = "ask:reanswer" as const;
|
|
21
|
+
const ASK_SUMMARY_CUSTOM_TYPE = "ask:summary" as const;
|
|
22
|
+
|
|
23
|
+
type AgentMessage = ContextEvent["messages"][number];
|
|
24
|
+
type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
|
|
25
|
+
type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
|
|
26
|
+
type CustomMessage = Extract<AgentMessage, { role: "custom" }>;
|
|
27
|
+
|
|
28
|
+
type AskCall = {
|
|
29
|
+
ask: Ask;
|
|
30
|
+
message: AssistantMessage;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ReplayRecord = {
|
|
34
|
+
message: CustomMessage;
|
|
35
|
+
details?: AskReplayDetails;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type AskSummaryPayload = {
|
|
39
|
+
type: "ask_response";
|
|
40
|
+
question: string;
|
|
41
|
+
context?: string;
|
|
42
|
+
selectionMode: "single" | "multi";
|
|
43
|
+
answer: {
|
|
44
|
+
selections: Array<{ label: string; description?: string; comment?: string }>;
|
|
45
|
+
freeform?: string;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type AskReplayMessage = {
|
|
50
|
+
customType: typeof ASK_REPLAY_CUSTOM_TYPE;
|
|
51
|
+
content: "";
|
|
52
|
+
display: false;
|
|
53
|
+
details: AskReplayDetails;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type AskReplayResolution =
|
|
57
|
+
| { status: "resolved"; toolCallId: string; ask: Ask }
|
|
58
|
+
| {
|
|
59
|
+
status: "not-replayable";
|
|
60
|
+
reason: "no-entry" | "not-assistant" | "not-ask" | "multiple-tool-calls" | "mixed-tools" | "invalid-arguments";
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function buildAskReplayMessage(toolCallId: string, answer: AskAnswer): AskReplayMessage {
|
|
64
|
+
return {
|
|
65
|
+
customType: ASK_REPLAY_CUSTOM_TYPE,
|
|
66
|
+
content: "",
|
|
67
|
+
display: false,
|
|
68
|
+
details: { toolCallId, answer },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Resolve only the selected leaf, or its immediate parent when Pi created a branch summary. */
|
|
73
|
+
export function resolveAskReplayTarget(
|
|
74
|
+
event: Pick<SessionTreeEvent, "newLeafId" | "summaryEntry">,
|
|
75
|
+
getEntry: (id: string) => SessionEntry | undefined | null,
|
|
76
|
+
): AskReplayResolution {
|
|
77
|
+
if (!event.newLeafId) return rejected("no-entry");
|
|
78
|
+
|
|
79
|
+
let entry = getEntry(event.newLeafId);
|
|
80
|
+
const summary = event.summaryEntry?.id === event.newLeafId
|
|
81
|
+
? event.summaryEntry
|
|
82
|
+
: entry?.type === "branch_summary" ? entry : undefined;
|
|
83
|
+
if (summary) entry = summary.parentId ? getEntry(summary.parentId) : undefined;
|
|
84
|
+
if (!entry) return rejected("no-entry");
|
|
85
|
+
|
|
86
|
+
let selectedToolCallId: string | undefined;
|
|
87
|
+
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "ask") {
|
|
88
|
+
selectedToolCallId = entry.message.toolCallId;
|
|
89
|
+
entry = entry.parentId ? getEntry(entry.parentId) : undefined;
|
|
90
|
+
}
|
|
91
|
+
if (!entry) return rejected("no-entry");
|
|
92
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") return rejected("not-assistant");
|
|
93
|
+
|
|
94
|
+
const calls = entry.message.content.filter(item => item.type === "toolCall");
|
|
95
|
+
const askCalls = calls.filter(call => call.name === "ask");
|
|
96
|
+
if (calls.length !== 1) {
|
|
97
|
+
if (askCalls.length > 0 && askCalls.length < calls.length) return rejected("mixed-tools");
|
|
98
|
+
if (askCalls.length > 1) return rejected("multiple-tool-calls");
|
|
99
|
+
return rejected("not-ask");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const call = calls[0]!;
|
|
103
|
+
if (call.name !== "ask" || (selectedToolCallId !== undefined && call.id !== selectedToolCallId)) {
|
|
104
|
+
return rejected("not-ask");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ask = parseStoredAsk(call.arguments);
|
|
108
|
+
if (!ask) return rejected("invalid-arguments");
|
|
109
|
+
return { status: "resolved", toolCallId: call.id, ask };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parseStoredAsk(value: unknown): Ask | undefined {
|
|
113
|
+
if (!Check(AskParamsSchema, value)) return undefined;
|
|
114
|
+
try {
|
|
115
|
+
return normalizeAsk(value as AskParams);
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Replace completed standalone Ask exchanges with their decision-focused answer. */
|
|
122
|
+
export function rewriteAskContext(messages: readonly AgentMessage[]): AgentMessage[] {
|
|
123
|
+
const calls = collectCalls(messages);
|
|
124
|
+
const nativeResults = collectNativeResults(messages);
|
|
125
|
+
const replayRecords = collectReplayRecords(messages);
|
|
126
|
+
|
|
127
|
+
const summaries = new Map<AgentMessage, AgentMessage>();
|
|
128
|
+
const removals = new Set<AgentMessage>();
|
|
129
|
+
for (const [toolCallId, call] of calls) {
|
|
130
|
+
if (!call) continue;
|
|
131
|
+
|
|
132
|
+
const results = nativeResults.get(toolCallId) ?? [];
|
|
133
|
+
if (results.length > 1) continue;
|
|
134
|
+
const result = results[0];
|
|
135
|
+
|
|
136
|
+
const replays = replayRecords.get(toolCallId) ?? [];
|
|
137
|
+
let answer: AskAnswer | undefined;
|
|
138
|
+
let replay: ReplayRecord | undefined;
|
|
139
|
+
if (replays.length > 0) {
|
|
140
|
+
if (replays.length !== 1) continue;
|
|
141
|
+
const candidate = replays[0]!;
|
|
142
|
+
if (!candidate.details || !answerMatchesAsk(candidate.details.answer, call.ask)) continue;
|
|
143
|
+
replay = candidate;
|
|
144
|
+
answer = candidate.details.answer;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (result) {
|
|
148
|
+
if (result.toolName !== "ask") continue;
|
|
149
|
+
if (!replay) {
|
|
150
|
+
if (result.isError) continue;
|
|
151
|
+
const details = parseNativeDetails(result.details);
|
|
152
|
+
if (!details || !answerMatchesAsk(details.answer, call.ask)) continue;
|
|
153
|
+
answer = details.answer;
|
|
154
|
+
}
|
|
155
|
+
} else if (!replay) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!answer) continue;
|
|
160
|
+
const timestamp = replay ? replay.message.timestamp : result!.timestamp;
|
|
161
|
+
summaries.set(call.message, makeSummary(call, answer, timestamp));
|
|
162
|
+
removals.add(call.message);
|
|
163
|
+
if (result) removals.add(result);
|
|
164
|
+
if (replay) removals.add(replay.message);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return messages.flatMap((message) => {
|
|
168
|
+
const summary = summaries.get(message);
|
|
169
|
+
if (summary) return [summary];
|
|
170
|
+
return removals.has(message) ? [] : [message];
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function collectCalls(messages: readonly AgentMessage[]): Map<string, AskCall | undefined> {
|
|
175
|
+
const calls = new Map<string, AskCall | undefined>();
|
|
176
|
+
for (const message of messages) {
|
|
177
|
+
if (message.role !== "assistant") continue;
|
|
178
|
+
const toolCalls = message.content.filter(block => block.type === "toolCall");
|
|
179
|
+
for (const block of toolCalls) {
|
|
180
|
+
if (block.name !== "ask") continue;
|
|
181
|
+
const ask = toolCalls.length === 1 ? parseStoredAsk(block.arguments) : undefined;
|
|
182
|
+
const call = ask ? { ask, message } : undefined;
|
|
183
|
+
calls.set(block.id, calls.has(block.id) ? undefined : call);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return calls;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function collectNativeResults(messages: readonly AgentMessage[]): Map<string, ToolResultMessage[]> {
|
|
190
|
+
const results = new Map<string, ToolResultMessage[]>();
|
|
191
|
+
for (const message of messages) {
|
|
192
|
+
if (message.role !== "toolResult") continue;
|
|
193
|
+
const matching = results.get(message.toolCallId) ?? [];
|
|
194
|
+
matching.push(message);
|
|
195
|
+
results.set(message.toolCallId, matching);
|
|
196
|
+
}
|
|
197
|
+
return results;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function collectReplayRecords(messages: readonly AgentMessage[]): Map<string, ReplayRecord[]> {
|
|
201
|
+
const records = new Map<string, ReplayRecord[]>();
|
|
202
|
+
for (const message of messages) {
|
|
203
|
+
if (message.role !== "custom" || message.customType !== ASK_REPLAY_CUSTOM_TYPE) continue;
|
|
204
|
+
const rawToolCallId = isRecord(message.details) && typeof message.details.toolCallId === "string"
|
|
205
|
+
? message.details.toolCallId
|
|
206
|
+
: undefined;
|
|
207
|
+
const details = parseAskReplayDetails(message.details);
|
|
208
|
+
const toolCallId = details?.toolCallId ?? rawToolCallId;
|
|
209
|
+
if (toolCallId === undefined) continue;
|
|
210
|
+
const matching = records.get(toolCallId) ?? [];
|
|
211
|
+
matching.push({ message, ...(details ? { details } : {}) });
|
|
212
|
+
records.set(toolCallId, matching);
|
|
213
|
+
}
|
|
214
|
+
return records;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseNativeDetails(value: unknown): { status: "answered"; answer: AskAnswer } | undefined {
|
|
218
|
+
return Check(AskAnsweredDetailsSchema, value) ? value : undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function makeSummary(call: AskCall, answer: AskAnswer, timestamp: number): AgentMessage {
|
|
222
|
+
const payload: AskSummaryPayload = {
|
|
223
|
+
type: "ask_response",
|
|
224
|
+
question: call.ask.question,
|
|
225
|
+
...(call.ask.context !== undefined ? { context: call.ask.context } : {}),
|
|
226
|
+
selectionMode: call.ask.allowMultiple ? "multi" : "single",
|
|
227
|
+
answer: {
|
|
228
|
+
selections: answer.selections.map((selection) => {
|
|
229
|
+
const option = call.ask.options[selection.option]!;
|
|
230
|
+
return {
|
|
231
|
+
label: option.label,
|
|
232
|
+
...(option.description !== undefined ? { description: option.description } : {}),
|
|
233
|
+
...(selection.comment !== undefined ? { comment: selection.comment } : {}),
|
|
234
|
+
};
|
|
235
|
+
}),
|
|
236
|
+
...(answer.freeform !== undefined ? { freeform: answer.freeform } : {}),
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
return {
|
|
240
|
+
role: "custom",
|
|
241
|
+
customType: ASK_SUMMARY_CUSTOM_TYPE,
|
|
242
|
+
display: false,
|
|
243
|
+
content: JSON.stringify(payload),
|
|
244
|
+
timestamp,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function rejected(reason: Exclude<AskReplayResolution, { status: "resolved" }>["reason"]): AskReplayResolution {
|
|
249
|
+
return { status: "not-replayable", reason };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
253
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
254
|
+
}
|