@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/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, ValidatedAskParams } from "./types.js";
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<ValidatedAskParams, "allowMultiple" | "allowFreeform">;
17
- type QuestionnaireInput = QuestionnaireConfig & Pick<ValidatedAskParams, "options">;
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<string>;
24
- comments: Map<string, string>;
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.option.label) ?? "",
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 label = editor.target.option.label;
96
- if (saved) next.comments.set(label, saved);
97
- else next.comments.delete(label);
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.option);
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.option);
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: AskOption): void {
161
+ function toggleOption(state: QuestionnaireState, option: number): void {
162
162
  if (!state.config.allowMultiple) {
163
- state.checked = new Set([option.label]);
163
+ state.checked = new Set([option]);
164
164
  state.answer = finalAnswer(state);
165
- } else if (state.checked.has(option.label)) {
166
- state.checked.delete(option.label);
165
+ } else if (state.checked.has(option)) {
166
+ state.checked.delete(option);
167
167
  } else {
168
- state.checked.add(option.label);
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.option.label)) return [];
179
- const comment = state.comments.get(row.option.label);
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
- }
@@ -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
- }