@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/state.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { AskAnswer, AskOption
|
|
1
|
+
import type { Ask, AskAnswer, AskOption } from "./domain.js";
|
|
2
2
|
|
|
3
|
-
export type QuestionnaireOptionRow = { kind: "option"; option: AskOption };
|
|
3
|
+
export type QuestionnaireOptionRow = { kind: "option"; index: number; option: AskOption };
|
|
4
4
|
export type QuestionnaireFreeformRow = { kind: "freeform" };
|
|
5
5
|
export type QuestionnaireSubmitRow = { kind: "submit" };
|
|
6
6
|
export type QuestionnaireRow =
|
|
@@ -13,15 +13,15 @@ export type QuestionnaireEditor =
|
|
|
13
13
|
| { kind: "comment"; target: QuestionnaireOptionRow; draft: string }
|
|
14
14
|
| { kind: "freeform"; target: QuestionnaireFreeformRow; draft: string };
|
|
15
15
|
|
|
16
|
-
type QuestionnaireConfig = Pick<
|
|
17
|
-
type QuestionnaireInput = QuestionnaireConfig & Pick<
|
|
16
|
+
type QuestionnaireConfig = Pick<Ask, "allowMultiple" | "allowFreeform">;
|
|
17
|
+
type QuestionnaireInput = QuestionnaireConfig & Pick<Ask, "options">;
|
|
18
18
|
|
|
19
19
|
export interface QuestionnaireState {
|
|
20
20
|
config: QuestionnaireConfig;
|
|
21
21
|
rows: readonly QuestionnaireRow[];
|
|
22
22
|
highlightedRow: number;
|
|
23
|
-
checked: Set<
|
|
24
|
-
comments: Map<
|
|
23
|
+
checked: Set<number>;
|
|
24
|
+
comments: Map<number, string>;
|
|
25
25
|
freeformDraft: string;
|
|
26
26
|
freeformChecked: boolean;
|
|
27
27
|
editor: QuestionnaireEditor;
|
|
@@ -80,7 +80,7 @@ export function transitionQuestionnaire(state: QuestionnaireState, event: Questi
|
|
|
80
80
|
next.editor = {
|
|
81
81
|
kind: "comment",
|
|
82
82
|
target: row,
|
|
83
|
-
draft: next.comments.get(row.
|
|
83
|
+
draft: next.comments.get(row.index) ?? "",
|
|
84
84
|
};
|
|
85
85
|
break;
|
|
86
86
|
}
|
|
@@ -92,9 +92,9 @@ export function transitionQuestionnaire(state: QuestionnaireState, event: Questi
|
|
|
92
92
|
const editor = next.editor;
|
|
93
93
|
const saved = editor.draft.trim();
|
|
94
94
|
if (editor.kind === "comment") {
|
|
95
|
-
const
|
|
96
|
-
if (saved) next.comments.set(
|
|
97
|
-
else next.comments.delete(
|
|
95
|
+
const option = editor.target.index;
|
|
96
|
+
if (saved) next.comments.set(option, saved);
|
|
97
|
+
else next.comments.delete(option);
|
|
98
98
|
} else {
|
|
99
99
|
next.freeformDraft = saved;
|
|
100
100
|
if (next.config.allowMultiple) next.freeformChecked = saved.length > 0;
|
|
@@ -117,7 +117,7 @@ export function transitionQuestionnaire(state: QuestionnaireState, event: Questi
|
|
|
117
117
|
|
|
118
118
|
function createRows(input: QuestionnaireInput): QuestionnaireRow[] {
|
|
119
119
|
return [
|
|
120
|
-
...input.options.map((option): QuestionnaireOptionRow => ({ kind: "option", option })),
|
|
120
|
+
...input.options.map((option, index): QuestionnaireOptionRow => ({ kind: "option", index, option })),
|
|
121
121
|
...(input.allowFreeform ? [{ kind: "freeform" } as const] : []),
|
|
122
122
|
...(input.allowMultiple ? [{ kind: "submit" } as const] : []),
|
|
123
123
|
];
|
|
@@ -131,7 +131,7 @@ function activateRow(state: QuestionnaireState): void {
|
|
|
131
131
|
const row = currentRow(state);
|
|
132
132
|
switch (row.kind) {
|
|
133
133
|
case "option":
|
|
134
|
-
toggleOption(state, row.
|
|
134
|
+
toggleOption(state, row.index);
|
|
135
135
|
break;
|
|
136
136
|
case "freeform":
|
|
137
137
|
openFreeform(state, row);
|
|
@@ -146,7 +146,7 @@ function toggleRow(state: QuestionnaireState): void {
|
|
|
146
146
|
const row = currentRow(state);
|
|
147
147
|
switch (row.kind) {
|
|
148
148
|
case "option":
|
|
149
|
-
toggleOption(state, row.
|
|
149
|
+
toggleOption(state, row.index);
|
|
150
150
|
break;
|
|
151
151
|
case "freeform":
|
|
152
152
|
if (state.config.allowMultiple) state.freeformChecked = !state.freeformChecked;
|
|
@@ -158,14 +158,14 @@ function toggleRow(state: QuestionnaireState): void {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
function toggleOption(state: QuestionnaireState, option:
|
|
161
|
+
function toggleOption(state: QuestionnaireState, option: number): void {
|
|
162
162
|
if (!state.config.allowMultiple) {
|
|
163
|
-
state.checked = new Set([option
|
|
163
|
+
state.checked = new Set([option]);
|
|
164
164
|
state.answer = finalAnswer(state);
|
|
165
|
-
} else if (state.checked.has(option
|
|
166
|
-
state.checked.delete(option
|
|
165
|
+
} else if (state.checked.has(option)) {
|
|
166
|
+
state.checked.delete(option);
|
|
167
167
|
} else {
|
|
168
|
-
state.checked.add(option
|
|
168
|
+
state.checked.add(option);
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
@@ -175,13 +175,9 @@ function openFreeform(state: QuestionnaireState, target: QuestionnaireFreeformRo
|
|
|
175
175
|
|
|
176
176
|
function finalAnswer(state: QuestionnaireState): AskAnswer {
|
|
177
177
|
const selections = state.rows.flatMap((row) => {
|
|
178
|
-
if (row.kind !== "option" || !state.checked.has(row.
|
|
179
|
-
const comment = state.comments.get(row.
|
|
180
|
-
return [{
|
|
181
|
-
label: row.option.label,
|
|
182
|
-
...(row.option.description === undefined ? {} : { description: row.option.description }),
|
|
183
|
-
...(comment ? { comment } : {}),
|
|
184
|
-
}];
|
|
178
|
+
if (row.kind !== "option" || !state.checked.has(row.index)) return [];
|
|
179
|
+
const comment = state.comments.get(row.index);
|
|
180
|
+
return [{ option: row.index, ...(comment ? { comment } : {}) }];
|
|
185
181
|
});
|
|
186
182
|
const freeform = state.config.allowMultiple && !state.freeformChecked ? "" : state.freeformDraft.trim();
|
|
187
183
|
return {
|
package/src/config.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
export const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
2
|
-
|
|
3
|
-
type AskEnvironment = Readonly<{
|
|
4
|
-
PI9_ASK_TIMEOUT_MS?: string;
|
|
5
|
-
}>;
|
|
6
|
-
|
|
7
|
-
export function resolveTimeoutMs(
|
|
8
|
-
perCallTimeout: number | undefined,
|
|
9
|
-
env: AskEnvironment,
|
|
10
|
-
): number | undefined {
|
|
11
|
-
if (perCallTimeout !== undefined) {
|
|
12
|
-
return Number.isFinite(perCallTimeout)
|
|
13
|
-
&& Number.isInteger(perCallTimeout)
|
|
14
|
-
&& perCallTimeout > 0
|
|
15
|
-
&& perCallTimeout <= MAX_TIMEOUT_MS
|
|
16
|
-
? perCallTimeout
|
|
17
|
-
: undefined;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const envTimeout = env.PI9_ASK_TIMEOUT_MS;
|
|
21
|
-
if (envTimeout === undefined || !/^\d+$/.test(envTimeout)) return undefined;
|
|
22
|
-
|
|
23
|
-
const timeout = Number(envTimeout);
|
|
24
|
-
return Number.isFinite(timeout)
|
|
25
|
-
&& Number.isInteger(timeout)
|
|
26
|
-
&& timeout > 0
|
|
27
|
-
&& timeout <= MAX_TIMEOUT_MS
|
|
28
|
-
? timeout
|
|
29
|
-
: undefined;
|
|
30
|
-
}
|
package/src/context.ts
DELETED
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
import type { ContextEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Check } from "typebox/value";
|
|
3
|
-
|
|
4
|
-
import { AskAnsweredDetailsSchema } from "./schema.js";
|
|
5
|
-
import {
|
|
6
|
-
ASK_REPLAY_CUSTOM_TYPE,
|
|
7
|
-
parseAskReplayDetails,
|
|
8
|
-
validateStoredArgs,
|
|
9
|
-
} from "./replay.js";
|
|
10
|
-
import type { AskAnswer, AskReplayDetails, ValidatedAskParams } from "./types.js";
|
|
11
|
-
|
|
12
|
-
type AgentMessage = ContextEvent["messages"][number];
|
|
13
|
-
type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
|
|
14
|
-
type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
|
|
15
|
-
type CustomMessage = Extract<AgentMessage, { role: "custom" }>;
|
|
16
|
-
type AskCall = {
|
|
17
|
-
args: ValidatedAskParams;
|
|
18
|
-
message: AssistantMessage;
|
|
19
|
-
standalone: boolean;
|
|
20
|
-
};
|
|
21
|
-
type ReplayRecord = {
|
|
22
|
-
message: CustomMessage;
|
|
23
|
-
details?: AskReplayDetails;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const ASK_SUMMARY_CUSTOM_TYPE = "ask:summary" as const;
|
|
27
|
-
|
|
28
|
-
type AskSummaryPayload = {
|
|
29
|
-
type: "ask_response";
|
|
30
|
-
question: string;
|
|
31
|
-
context?: string;
|
|
32
|
-
selectionMode: "single" | "multi";
|
|
33
|
-
answer: AskAnswer;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Replace completed Ask exchanges with the small custom message that is useful
|
|
38
|
-
* to the model. The session still contains the original protocol messages; the
|
|
39
|
-
* projection must not leave a tool call without its result.
|
|
40
|
-
*/
|
|
41
|
-
export function rewriteAskContext(messages: readonly AgentMessage[]): AgentMessage[] {
|
|
42
|
-
const calls = collectCalls(messages);
|
|
43
|
-
const nativeResults = collectNativeResults(messages);
|
|
44
|
-
const replayRecords = collectReplayRecords(messages);
|
|
45
|
-
|
|
46
|
-
const summaries = new Map<AgentMessage, AgentMessage>();
|
|
47
|
-
const removals = new Set<AgentMessage>();
|
|
48
|
-
for (const [toolCallId, call] of calls) {
|
|
49
|
-
if (!call || !call.standalone) continue;
|
|
50
|
-
|
|
51
|
-
const results = nativeResults.get(toolCallId) ?? [];
|
|
52
|
-
if (results.length > 1) continue;
|
|
53
|
-
const result = results[0];
|
|
54
|
-
|
|
55
|
-
const replays = replayRecords.get(toolCallId) ?? [];
|
|
56
|
-
let answer: AskAnswer | undefined;
|
|
57
|
-
let replay: ReplayRecord | undefined;
|
|
58
|
-
if (replays.length > 0) {
|
|
59
|
-
// A replay is a revision only when it is the one well-formed marker for
|
|
60
|
-
// this call. In particular, do not hide malformed or duplicate markers.
|
|
61
|
-
if (replays.length !== 1) continue;
|
|
62
|
-
const candidate = replays[0]!;
|
|
63
|
-
if (!candidate.details
|
|
64
|
-
|| !matchesCall(candidate.details, call.args)
|
|
65
|
-
|| !answerMatchesCall(candidate.details.answer, call.args)) continue;
|
|
66
|
-
replay = candidate;
|
|
67
|
-
answer = candidate.details.answer;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (result) {
|
|
71
|
-
if (result.toolName !== "ask") continue;
|
|
72
|
-
// A valid replay is the latest answer and takes precedence regardless
|
|
73
|
-
// of whether the earlier native attempt completed successfully.
|
|
74
|
-
if (!replay) {
|
|
75
|
-
if (result.isError) continue;
|
|
76
|
-
const details = parseNativeDetails(result.details);
|
|
77
|
-
if (!details
|
|
78
|
-
|| details.question !== call.args.question
|
|
79
|
-
|| !answerMatchesCall(details.answer, call.args)) continue;
|
|
80
|
-
answer = details.answer;
|
|
81
|
-
}
|
|
82
|
-
} else if (!replay) {
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (!answer) continue;
|
|
87
|
-
const timestamp = replay ? replay.message.timestamp : result!.timestamp;
|
|
88
|
-
const summary = makeSummary(call, answer, timestamp);
|
|
89
|
-
summaries.set(call.message, summary);
|
|
90
|
-
removals.add(call.message);
|
|
91
|
-
if (result) removals.add(result);
|
|
92
|
-
if (replay) removals.add(replay.message);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const rewritten: AgentMessage[] = [];
|
|
96
|
-
for (const message of messages) {
|
|
97
|
-
const summary = summaries.get(message);
|
|
98
|
-
if (summary) {
|
|
99
|
-
rewritten.push(summary);
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
if (!removals.has(message)) rewritten.push(message);
|
|
103
|
-
}
|
|
104
|
-
return rewritten;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function collectCalls(messages: readonly AgentMessage[]): Map<string, AskCall | undefined> {
|
|
108
|
-
const calls = new Map<string, AskCall | undefined>();
|
|
109
|
-
for (const message of messages) {
|
|
110
|
-
if (message.role !== "assistant") continue;
|
|
111
|
-
const toolCalls = message.content.filter(block => block.type === "toolCall");
|
|
112
|
-
for (const block of toolCalls) {
|
|
113
|
-
if (block.name !== "ask") continue;
|
|
114
|
-
const args = validateStoredArgs(block.arguments);
|
|
115
|
-
const call = args
|
|
116
|
-
? { args, message, standalone: toolCalls.length === 1 }
|
|
117
|
-
: undefined;
|
|
118
|
-
// Keep an invalid entry as ambiguous too. A later valid call with the
|
|
119
|
-
// same ID must not make an otherwise malformed exchange transformable.
|
|
120
|
-
calls.set(block.id, calls.has(block.id) ? undefined : call);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return calls;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function collectNativeResults(messages: readonly AgentMessage[]): Map<string, ToolResultMessage[]> {
|
|
127
|
-
const results = new Map<string, ToolResultMessage[]>();
|
|
128
|
-
for (const message of messages) {
|
|
129
|
-
if (message.role !== "toolResult") continue;
|
|
130
|
-
const matching = results.get(message.toolCallId) ?? [];
|
|
131
|
-
matching.push(message);
|
|
132
|
-
results.set(message.toolCallId, matching);
|
|
133
|
-
}
|
|
134
|
-
return results;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function collectReplayRecords(messages: readonly AgentMessage[]): Map<string, ReplayRecord[]> {
|
|
138
|
-
const records = new Map<string, ReplayRecord[]>();
|
|
139
|
-
for (const message of messages) {
|
|
140
|
-
if (message.role !== "custom" || message.customType !== ASK_REPLAY_CUSTOM_TYPE) continue;
|
|
141
|
-
const rawDetails = isRecord(message.details) && typeof message.details.toolCallId === "string"
|
|
142
|
-
? message.details.toolCallId
|
|
143
|
-
: undefined;
|
|
144
|
-
const details = parseAskReplayDetails(message.details);
|
|
145
|
-
const toolCallId = details?.toolCallId ?? rawDetails;
|
|
146
|
-
if (toolCallId === undefined) continue;
|
|
147
|
-
const matching = records.get(toolCallId) ?? [];
|
|
148
|
-
matching.push({ message, ...(details ? { details } : {}) });
|
|
149
|
-
records.set(toolCallId, matching);
|
|
150
|
-
}
|
|
151
|
-
return records;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function parseNativeDetails(value: unknown): { question: string; answer: AskAnswer } | undefined {
|
|
155
|
-
return Check(AskAnsweredDetailsSchema, value) ? value : undefined;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function matchesCall(replay: AskReplayDetails, args: ValidatedAskParams): boolean {
|
|
159
|
-
return replay.question === args.question
|
|
160
|
-
&& replay.context === args.context
|
|
161
|
-
&& replay.allowMultiple === args.allowMultiple;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function answerMatchesCall(answer: AskAnswer, args: ValidatedAskParams): boolean {
|
|
165
|
-
if (answer.selections.length > 1 && !args.allowMultiple) return false;
|
|
166
|
-
if (answer.freeform !== undefined && !args.allowFreeform) return false;
|
|
167
|
-
|
|
168
|
-
const labels = new Set(args.options.map(option => option.label));
|
|
169
|
-
const selected = new Set<string>();
|
|
170
|
-
for (const selection of answer.selections) {
|
|
171
|
-
if (!labels.has(selection.label) || selected.has(selection.label)) return false;
|
|
172
|
-
selected.add(selection.label);
|
|
173
|
-
}
|
|
174
|
-
return true;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function makeSummary(call: AskCall, answer: AskAnswer, timestamp: number): AgentMessage {
|
|
178
|
-
const payload: AskSummaryPayload = {
|
|
179
|
-
type: "ask_response",
|
|
180
|
-
question: call.args.question,
|
|
181
|
-
...(call.args.context !== undefined ? { context: call.args.context } : {}),
|
|
182
|
-
selectionMode: call.args.allowMultiple ? "multi" : "single",
|
|
183
|
-
answer: {
|
|
184
|
-
selections: answer.selections.map(selection => selectedOption(selection, call.args)),
|
|
185
|
-
...(answer.freeform !== undefined ? { freeform: answer.freeform } : {}),
|
|
186
|
-
},
|
|
187
|
-
};
|
|
188
|
-
return {
|
|
189
|
-
role: "custom",
|
|
190
|
-
customType: ASK_SUMMARY_CUSTOM_TYPE,
|
|
191
|
-
display: false,
|
|
192
|
-
content: JSON.stringify(payload),
|
|
193
|
-
timestamp,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function selectedOption(selection: AskAnswer["selections"][number], args: ValidatedAskParams): AskAnswer["selections"][number] {
|
|
198
|
-
const original = args.options.find(option => option.label === selection.label);
|
|
199
|
-
const description = selection.description ?? original?.description;
|
|
200
|
-
return {
|
|
201
|
-
label: selection.label,
|
|
202
|
-
...(description !== undefined ? { description } : {}),
|
|
203
|
-
...(selection.comment !== undefined ? { comment: selection.comment } : {}),
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
208
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
209
|
-
}
|
package/src/replay-renderer.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import type { MessageRenderOptions, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
-
|
|
4
|
-
import { parseAskReplayDetails, type AskReplayDetails } from "./replay.js";
|
|
5
|
-
|
|
6
|
-
interface RenderMessage {
|
|
7
|
-
content?: unknown;
|
|
8
|
-
details?: unknown;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
type Selection = AskReplayDetails["answer"]["selections"][number];
|
|
12
|
-
type RecordValue = Record<string, unknown>;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Render a completed ask replay for registration with
|
|
16
|
-
* `pi.registerMessageRenderer("ask:reanswer", renderAskReanswerMessage)`.
|
|
17
|
-
*
|
|
18
|
-
* The renderer deliberately returns a Text component rather than pre-wrapped
|
|
19
|
-
* strings. Text performs wrapping using terminal column widths, including
|
|
20
|
-
* when the theme has added ANSI styling to the label.
|
|
21
|
-
*/
|
|
22
|
-
export const renderAskReanswerMessage = (
|
|
23
|
-
message: RenderMessage,
|
|
24
|
-
options: Pick<MessageRenderOptions, "expanded">,
|
|
25
|
-
theme: Pick<Theme, "fg"> | undefined,
|
|
26
|
-
): Text => renderMessage(message, options, theme);
|
|
27
|
-
|
|
28
|
-
function renderMessage(
|
|
29
|
-
message: RenderMessage,
|
|
30
|
-
options: Pick<MessageRenderOptions, "expanded">,
|
|
31
|
-
theme: Pick<Theme, "fg"> | undefined,
|
|
32
|
-
): Text {
|
|
33
|
-
const details = parseAskReplayDetails(message.details);
|
|
34
|
-
const content = details
|
|
35
|
-
? formatDetails(details, options.expanded, theme)
|
|
36
|
-
: textualContent(message.content);
|
|
37
|
-
return new Text(content, 0, 0);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function formatDetails(details: AskReplayDetails, expanded: boolean, theme: Pick<Theme, "fg"> | undefined): string {
|
|
41
|
-
const label = colorLabel("Revised answer", theme);
|
|
42
|
-
const selections = details.answer.selections;
|
|
43
|
-
const freeform = details.answer.freeform;
|
|
44
|
-
|
|
45
|
-
if (!expanded) {
|
|
46
|
-
const lines: string[] = [label];
|
|
47
|
-
if (selections.length > 0) lines.push(`Selected: ${selections.map(selection => selection.label).join(", ")}`);
|
|
48
|
-
if (freeform) lines.push(`Freeform: ${freeform}`);
|
|
49
|
-
if (selections.length === 0 && !freeform) lines.push("No answer provided.");
|
|
50
|
-
return lines.join(" · ");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const lines: string[] = [label, `Question: ${details.question}`];
|
|
54
|
-
if (details.context) lines.push(`Context: ${details.context}`);
|
|
55
|
-
if (selections.length > 0) {
|
|
56
|
-
lines.push("Selections:");
|
|
57
|
-
for (const selection of selections) {
|
|
58
|
-
let line = `- ${selection.label}`;
|
|
59
|
-
if (selection.description) line += ` — ${selection.description}`;
|
|
60
|
-
if (selection.comment) line += ` (${selection.comment})`;
|
|
61
|
-
lines.push(line);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
if (freeform) lines.push(`Freeform: ${freeform}`);
|
|
65
|
-
if (selections.length === 0 && !freeform) lines.push("No answer provided.");
|
|
66
|
-
return lines.join("\n");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function textualContent(content: unknown): string {
|
|
70
|
-
if (typeof content === "string") return content;
|
|
71
|
-
if (!Array.isArray(content)) return "";
|
|
72
|
-
return content
|
|
73
|
-
.filter(isRecord)
|
|
74
|
-
.filter(item => item.type === "text" && typeof item.text === "string")
|
|
75
|
-
.map(item => item.text as string)
|
|
76
|
-
.join("\n");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function colorLabel(label: string, theme: Pick<Theme, "fg"> | undefined): string {
|
|
80
|
-
return typeof theme?.fg === "function" ? theme.fg("customMessageLabel", label) : label;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function isRecord(value: unknown): value is RecordValue {
|
|
84
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
85
|
-
}
|
package/src/replay.ts
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import type { SessionEntry, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Check } from "typebox/value";
|
|
3
|
-
|
|
4
|
-
import { AskAnswerSchema, AskParamsSchema, AskReplayDetailsSchema } from "./schema.js";
|
|
5
|
-
import { formatAskAnswer } from "./response.js";
|
|
6
|
-
import type { AskAnswer, AskParams, AskReplayDetails, ValidatedAskParams } from "./types.js";
|
|
7
|
-
import { validateAskParams } from "./validation.js";
|
|
8
|
-
|
|
9
|
-
export const ASK_REPLAY_CUSTOM_TYPE = "ask:reanswer" as const;
|
|
10
|
-
|
|
11
|
-
export type { AskReplayDetails } from "./types.js";
|
|
12
|
-
|
|
13
|
-
export type AskReplayMessage = {
|
|
14
|
-
customType: typeof ASK_REPLAY_CUSTOM_TYPE;
|
|
15
|
-
content: string;
|
|
16
|
-
display: false;
|
|
17
|
-
details: AskReplayDetails;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
/** Parse the canonical answer shape used by native results and replay details. */
|
|
21
|
-
export function parseAskAnswer(value: unknown): AskAnswer | undefined {
|
|
22
|
-
return Check(AskAnswerSchema, value) ? value : undefined;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Parse canonical replay details, without validating the containing message. */
|
|
26
|
-
export function parseAskReplayDetails(value: unknown): AskReplayDetails | undefined {
|
|
27
|
-
return Check(AskReplayDetailsSchema, value) ? value : undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type AskReplayResolution =
|
|
31
|
-
| { status: "resolved"; toolCallId: string; params: ValidatedAskParams }
|
|
32
|
-
| {
|
|
33
|
-
status: "not-replayable";
|
|
34
|
-
reason: "no-entry" | "not-assistant" | "not-ask" | "multiple-tool-calls" | "mixed-tools" | "invalid-arguments";
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export function buildAskReplayMessage(toolCallId: string, params: ValidatedAskParams, answer: AskAnswer): AskReplayMessage {
|
|
38
|
-
return {
|
|
39
|
-
customType: ASK_REPLAY_CUSTOM_TYPE,
|
|
40
|
-
content: formatAskAnswer(answer).replaceAll("\n", " · "),
|
|
41
|
-
display: false,
|
|
42
|
-
details: {
|
|
43
|
-
toolCallId,
|
|
44
|
-
question: params.question,
|
|
45
|
-
...(params.context !== undefined ? { context: params.context } : {}),
|
|
46
|
-
allowMultiple: params.allowMultiple,
|
|
47
|
-
answer,
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Resolve only the selected leaf, or its immediate parent when Pi created a branch summary. */
|
|
53
|
-
export function resolveAskReplayTarget(
|
|
54
|
-
event: Pick<SessionTreeEvent, "newLeafId" | "summaryEntry">,
|
|
55
|
-
getEntry: (id: string) => SessionEntry | undefined | null,
|
|
56
|
-
): AskReplayResolution {
|
|
57
|
-
if (!event.newLeafId) return rejected("no-entry");
|
|
58
|
-
|
|
59
|
-
let entry = getEntry(event.newLeafId);
|
|
60
|
-
const summary = event.summaryEntry?.id === event.newLeafId
|
|
61
|
-
? event.summaryEntry
|
|
62
|
-
: entry?.type === "branch_summary" ? entry : undefined;
|
|
63
|
-
if (summary) entry = summary.parentId ? getEntry(summary.parentId) : undefined;
|
|
64
|
-
if (!entry) return rejected("no-entry");
|
|
65
|
-
|
|
66
|
-
let selectedToolCallId: string | undefined;
|
|
67
|
-
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "ask") {
|
|
68
|
-
selectedToolCallId = entry.message.toolCallId;
|
|
69
|
-
entry = entry.parentId ? getEntry(entry.parentId) : undefined;
|
|
70
|
-
}
|
|
71
|
-
if (!entry) return rejected("no-entry");
|
|
72
|
-
if (entry.type !== "message" || entry.message.role !== "assistant") return rejected("not-assistant");
|
|
73
|
-
|
|
74
|
-
const calls = entry.message.content.filter((item) => item.type === "toolCall");
|
|
75
|
-
const askCalls = calls.filter((call) => call.name === "ask");
|
|
76
|
-
if (calls.length !== 1) {
|
|
77
|
-
if (askCalls.length > 0 && askCalls.length < calls.length) return rejected("mixed-tools");
|
|
78
|
-
if (askCalls.length > 1) return rejected("multiple-tool-calls");
|
|
79
|
-
return rejected("not-ask");
|
|
80
|
-
}
|
|
81
|
-
const call = calls[0];
|
|
82
|
-
if (call.name !== "ask" || (selectedToolCallId !== undefined && call.id !== selectedToolCallId)) return rejected("not-ask");
|
|
83
|
-
|
|
84
|
-
const params = validateStoredArgs(call.arguments);
|
|
85
|
-
if (!params) return rejected("invalid-arguments");
|
|
86
|
-
return { status: "resolved", toolCallId: call.id, params };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function validateStoredArgs(value: unknown): ValidatedAskParams | undefined {
|
|
90
|
-
if (!Check(AskParamsSchema, value)) return undefined;
|
|
91
|
-
try {
|
|
92
|
-
return validateAskParams(value as AskParams);
|
|
93
|
-
} catch {
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function rejected(reason: Exclude<AskReplayResolution, { status: "resolved" }>["reason"]): AskReplayResolution {
|
|
99
|
-
return { status: "not-replayable", reason };
|
|
100
|
-
}
|
package/src/response.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { AskAnswer, AskResponse, AskToolDetails } from "./types.js";
|
|
2
|
-
|
|
3
|
-
export function formatAskAnswer(answer: AskAnswer): string {
|
|
4
|
-
const lines = answer.selections.map((selection) => {
|
|
5
|
-
const description = selection.description ? ` — ${selection.description}` : "";
|
|
6
|
-
const comment = selection.comment ? ` (${selection.comment})` : "";
|
|
7
|
-
return `Selected: ${selection.label}${description}${comment}`;
|
|
8
|
-
});
|
|
9
|
-
if (answer.freeform) lines.push(`Freeform: ${answer.freeform}`);
|
|
10
|
-
return lines.join("\n") || "No answer provided.";
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function buildAnsweredResponse(question: string, answer: AskAnswer): AskResponse {
|
|
14
|
-
return buildResponse(formatAskAnswer(answer), { status: "answered", question, answer });
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function buildUnansweredResponse(question: string): AskResponse {
|
|
18
|
-
return buildResponse("The question timed out without an answer.", { status: "unanswered", question });
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function buildCancelledResponse(question: string): AskResponse {
|
|
22
|
-
return buildResponse("User cancelled the question.", { status: "cancelled", question });
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function buildUiUnavailableResponse(question: string): AskResponse {
|
|
26
|
-
return buildResponse("Interactive UI is unavailable.", { status: "ui_unavailable", question });
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function buildResponse(text: string, details: AskToolDetails): AskResponse {
|
|
30
|
-
return { content: [{ type: "text", text }], details };
|
|
31
|
-
}
|
package/src/schema.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { Type } from "typebox";
|
|
2
|
-
|
|
3
|
-
import { MAX_TIMEOUT_MS } from "./config.js";
|
|
4
|
-
|
|
5
|
-
export const AskOptionSchema = Type.Object({
|
|
6
|
-
label: Type.String({ minLength: 1, description: "Short option title; keep distinct across options." }),
|
|
7
|
-
description: Type.Optional(Type.String({ description: "Brief explanation when the label alone is ambiguous." })),
|
|
8
|
-
preview: Type.Optional(Type.String({ description: "Markdown shown to the user, e.g. code, plans, or comparisons; not returned in the answer." })),
|
|
9
|
-
}, { additionalProperties: false });
|
|
10
|
-
|
|
11
|
-
export const AskParamsSchema = Type.Object({
|
|
12
|
-
question: Type.String({ minLength: 1 }),
|
|
13
|
-
context: Type.Optional(Type.String({ description: "Optional background shown above the question, e.g. what prompted the ask." })),
|
|
14
|
-
options: Type.Array(AskOptionSchema, { minItems: 1, description: "Suggested answers." }),
|
|
15
|
-
allowMultiple: Type.Optional(Type.Boolean({ default: false, description: "Allow selecting multiple options. Defaults to false." })),
|
|
16
|
-
allowFreeform: Type.Optional(Type.Boolean({ default: true, description: "Allow a typed response. Defaults to true." })),
|
|
17
|
-
timeout: Type.Optional(Type.Integer({ minimum: 0, maximum: MAX_TIMEOUT_MS, description: "Timeout in ms for answers that would go stale; the call returns unanswered on expiry. Zero disables any default." })),
|
|
18
|
-
}, { additionalProperties: false });
|
|
19
|
-
|
|
20
|
-
export const AskSelectionSchema = Type.Object({
|
|
21
|
-
label: Type.String({ minLength: 1 }),
|
|
22
|
-
description: Type.Optional(Type.String({ minLength: 1 })),
|
|
23
|
-
comment: Type.Optional(Type.String({ minLength: 1 })),
|
|
24
|
-
}, { additionalProperties: false });
|
|
25
|
-
|
|
26
|
-
export const AskAnswerSchema = Type.Object({
|
|
27
|
-
selections: Type.Array(AskSelectionSchema),
|
|
28
|
-
freeform: Type.Optional(Type.String({ minLength: 1 })),
|
|
29
|
-
}, { additionalProperties: false });
|
|
30
|
-
|
|
31
|
-
export const AskReplayDetailsSchema = Type.Object({
|
|
32
|
-
toolCallId: Type.String({ minLength: 1 }),
|
|
33
|
-
question: Type.String({ minLength: 1 }),
|
|
34
|
-
context: Type.Optional(Type.String({ minLength: 1 })),
|
|
35
|
-
allowMultiple: Type.Boolean(),
|
|
36
|
-
answer: AskAnswerSchema,
|
|
37
|
-
}, { additionalProperties: false });
|
|
38
|
-
|
|
39
|
-
export const AskAnsweredDetailsSchema = Type.Object({
|
|
40
|
-
status: Type.Literal("answered"),
|
|
41
|
-
question: Type.String({ minLength: 1 }),
|
|
42
|
-
answer: AskAnswerSchema,
|
|
43
|
-
}, { additionalProperties: false });
|
package/src/types.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type { Static } from "typebox";
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
AskAnswerSchema,
|
|
5
|
-
AskAnsweredDetailsSchema,
|
|
6
|
-
AskOptionSchema,
|
|
7
|
-
AskParamsSchema,
|
|
8
|
-
AskReplayDetailsSchema,
|
|
9
|
-
AskSelectionSchema,
|
|
10
|
-
} from "./schema.js";
|
|
11
|
-
|
|
12
|
-
export type AskOption = Static<typeof AskOptionSchema>;
|
|
13
|
-
export type AskParams = Static<typeof AskParamsSchema>;
|
|
14
|
-
export type AskSelection = Static<typeof AskSelectionSchema>;
|
|
15
|
-
export type AskAnswer = Static<typeof AskAnswerSchema>;
|
|
16
|
-
export type AskReplayDetails = Static<typeof AskReplayDetailsSchema>;
|
|
17
|
-
export type AskAnsweredDetails = Static<typeof AskAnsweredDetailsSchema>;
|
|
18
|
-
|
|
19
|
-
export type ValidatedAskParams = Omit<AskParams, "allowMultiple" | "allowFreeform"> & {
|
|
20
|
-
allowMultiple: boolean;
|
|
21
|
-
allowFreeform: boolean;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export type AskToolDetails =
|
|
25
|
-
| AskAnsweredDetails
|
|
26
|
-
| { status: "unanswered"; question: string }
|
|
27
|
-
| { status: "cancelled"; question: string }
|
|
28
|
-
| { status: "ui_unavailable"; question: string };
|
|
29
|
-
|
|
30
|
-
export type AskResponse = {
|
|
31
|
-
content: Array<{ type: "text"; text: string }>;
|
|
32
|
-
details: AskToolDetails;
|
|
33
|
-
};
|