@pi9/ask 0.2.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 +5 -5
- package/src/deadline.ts +29 -4
- package/src/domain.ts +144 -0
- package/src/index.ts +32 -60
- 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/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
|
+
}
|
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
|
-
}
|