@giladbarnea/pi-user-agents 0.0.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/transcript.ts ADDED
@@ -0,0 +1,303 @@
1
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, Container, Markdown, Text } from "@earendil-works/pi-tui";
3
+ import type {
4
+ AgentCommandDetails,
5
+ AgentCommandName,
6
+ AgentEntryData,
7
+ AgentMessage,
8
+ AgentResultMessage,
9
+ ExtensionAPI,
10
+ ExtensionCommandContext,
11
+ RunningAgent,
12
+ Theme,
13
+ } from "./shared.js";
14
+ import {
15
+ contextLabel,
16
+ escapeAttribute,
17
+ extractTag,
18
+ formatMs,
19
+ formatToolUses,
20
+ formatTurns,
21
+ mainContextLabel,
22
+ MESSAGE_TYPE,
23
+ truncatePlain,
24
+ } from "./shared.js";
25
+
26
+ export function reportCommandError(
27
+ pi: ExtensionAPI,
28
+ command: AgentCommandName,
29
+ args: string,
30
+ ctx: ExtensionCommandContext,
31
+ message: string,
32
+ ): void {
33
+ if (ctx.hasUI) {
34
+ ctx.ui.notify(message, "error");
35
+ return;
36
+ }
37
+ pi.sendMessage<AgentCommandDetails>({
38
+ customType: MESSAGE_TYPE,
39
+ content: formatCommandErrorMessage(command, args, message),
40
+ display: true,
41
+ details: {
42
+ command,
43
+ inheritedContext: command === "agent",
44
+ model: "",
45
+ modelLabel: "",
46
+ task: args.trim(),
47
+ ok: false,
48
+ error: message,
49
+ },
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Select the joined role messages from a child transcript.
55
+ *
56
+ * @example selectJoinedMessages([{ role: "user", content: "hello", timestamp: 0 }]).length // 1
57
+ */
58
+ type JoinedMessage = Extract<AgentMessage, { role: "user" | "assistant" }>;
59
+
60
+ export function selectJoinedMessages(messages: readonly AgentMessage[]): JoinedMessage[] {
61
+ const selected: JoinedMessage[] = [];
62
+ let latestAssistant: Extract<AgentMessage, { role: "assistant" }> | undefined;
63
+ for (const message of messages) {
64
+ if (message.role === "assistant" && roleMessageText(message).trim()) {
65
+ latestAssistant = message;
66
+ continue;
67
+ }
68
+ if (message.role !== "user") continue;
69
+ if (latestAssistant) selected.push(latestAssistant);
70
+ latestAssistant = undefined;
71
+ selected.push(message);
72
+ }
73
+ if (latestAssistant) selected.push(latestAssistant);
74
+ return selected;
75
+ }
76
+
77
+ export function buildMessageDetails(agent: RunningAgent, ok: boolean): AgentCommandDetails {
78
+ return {
79
+ agentId: agent.id,
80
+ command: agent.command,
81
+ mainContextState: agent.mainContextState,
82
+ inheritedContext: agent.inheritedContext,
83
+ model: agent.model,
84
+ modelLabel: agent.modelLabel,
85
+ task: agent.task,
86
+ ok,
87
+ durationMs: (agent.completedAt ?? Date.now()) - agent.turnStartedAt,
88
+ toolUses: agent.toolUses,
89
+ turnCount: agent.turnCount,
90
+ responseText: ok ? agent.responseText : undefined,
91
+ error: ok ? undefined : agent.error,
92
+ };
93
+ }
94
+
95
+ /** Build the canonical parent-session message used by immediate and late result delivery. */
96
+ export function buildAgentResultMessage(
97
+ agent: RunningAgent,
98
+ outcome: { ok: true; response: string } | { ok: false; error: string },
99
+ options: { display: boolean },
100
+ ): AgentResultMessage {
101
+ return {
102
+ customType: MESSAGE_TYPE,
103
+ content: outcome.ok
104
+ ? formatResultMessage(agent)
105
+ : formatErrorMessage(agent, outcome.error),
106
+ display: options.display,
107
+ details: buildMessageDetails(agent, outcome.ok),
108
+ };
109
+ }
110
+
111
+ const JOINED_CONVERSATION_PREFACE =
112
+ "The user has dispatched a background sub-agent with a task. The sub-agent is done. The following is the back and forth between them:";
113
+
114
+ export function formatResultMessage(agent: RunningAgent): string {
115
+ const messages = selectJoinedMessages(agent.conversationMessages);
116
+ const lines = [
117
+ JOINED_CONVERSATION_PREFACE,
118
+ `<user_agent model="${escapeAttribute(agent.model)}" inherited_context="${agent.inheritedContext}">`,
119
+ ];
120
+ let firstUserMessage = true;
121
+ for (const [index, message] of messages.entries()) {
122
+ const tag = message.role === "user" ? "user_message" : "assistant_response";
123
+ const content =
124
+ message.role === "user" && firstUserMessage ? agent.task : roleMessageText(message);
125
+ if (message.role === "user") firstUserMessage = false;
126
+ lines.push(
127
+ ` <${tag} i=${index + 1}>`,
128
+ ...content.split("\n").map((line) => ` ${line}`),
129
+ ` </${tag}>`,
130
+ );
131
+ }
132
+ lines.push("</user_agent>");
133
+ return lines.join("\n");
134
+ }
135
+
136
+ function roleMessageText(message: JoinedMessage): string {
137
+ if (typeof message.content === "string") return message.content;
138
+ return message.content
139
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
140
+ .join("\n");
141
+ }
142
+
143
+ export function formatErrorMessage(agent: RunningAgent, message: string): string {
144
+ return [
145
+ `<user_agent_error command="/${agent.command}" model="${escapeAttribute(agent.model)}" inherited_context="${agent.inheritedContext}">`,
146
+ ...invocationLines(agent),
147
+ "<task>",
148
+ agent.task,
149
+ "</task>",
150
+ "<error>",
151
+ message,
152
+ "</error>",
153
+ "<duration_ms>",
154
+ String((agent.completedAt ?? Date.now()) - agent.turnStartedAt),
155
+ "</duration_ms>",
156
+ "</user_agent_error>",
157
+ ].join("\n");
158
+ }
159
+
160
+ function invocationLines(agent: RunningAgent): string[] {
161
+ return ["<user_invocation>", agent.invocation, "</user_invocation>"];
162
+ }
163
+
164
+ export function formatCommandErrorMessage(
165
+ command: AgentCommandName,
166
+ args: string,
167
+ message: string,
168
+ ): string {
169
+ return [
170
+ `<user_agent_error command="/${command}">`,
171
+ "<task>",
172
+ args.trim(),
173
+ "</task>",
174
+ "<error>",
175
+ message,
176
+ "</error>",
177
+ "</user_agent_error>",
178
+ ].join("\n");
179
+ }
180
+
181
+ export function formatStartNotification(agent: RunningAgent): string {
182
+ return [
183
+ `Started /${agent.command}`,
184
+ agent.modelLabel,
185
+ contextLabel(agent.inheritedContext),
186
+ mainContextLabel(agent.mainContextState),
187
+ ]
188
+ .filter(Boolean)
189
+ .join(" · ");
190
+ }
191
+
192
+ export function registerUserAgentRenderer(pi: ExtensionAPI): void {
193
+ pi.registerMessageRenderer<AgentCommandDetails>(
194
+ MESSAGE_TYPE,
195
+ (message, { expanded }, theme) =>
196
+ liveAgentCard(
197
+ () => customMessageContentText(message.content),
198
+ () => message.details,
199
+ expanded,
200
+ theme,
201
+ ),
202
+ );
203
+ pi.registerEntryRenderer<AgentEntryData>(
204
+ MESSAGE_TYPE,
205
+ (entry, { expanded }, theme) =>
206
+ entry.data
207
+ ? liveAgentCard(
208
+ () => entry.data!.content,
209
+ () => entry.data!.details,
210
+ expanded,
211
+ theme,
212
+ )
213
+ : undefined,
214
+ );
215
+ }
216
+
217
+ function liveAgentCard(
218
+ content: () => string,
219
+ details: () => AgentCommandDetails | undefined,
220
+ expanded: boolean,
221
+ theme: Theme,
222
+ ): Component {
223
+ const body = renderAgentCardBody(content(), details(), expanded, theme);
224
+ return {
225
+ render: (width) => renderAgentCard(content(), details(), body, theme).render(width),
226
+ invalidate: () => body.invalidate(),
227
+ };
228
+ }
229
+
230
+ function renderAgentCard(
231
+ content: string,
232
+ details: AgentCommandDetails | undefined,
233
+ body: Component,
234
+ theme: Theme,
235
+ ): Container {
236
+ const ok = details?.ok ?? !content.includes("<user_agent_error");
237
+ const command = details ? `/${details.command}` : "/agent";
238
+ const icon = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
239
+ const statusText = ok ? theme.fg("dim", "completed") : theme.fg("error", "failed");
240
+ const parts = buildRendererParts(content, details);
241
+ const task = details?.task ?? extractTag(content, "task");
242
+
243
+ let text = `${icon} ${theme.bold(command)} ${statusText}`;
244
+ if (parts.length > 0) text += ` ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}`;
245
+ if (task) text += `\n ${theme.fg("dim", `task: ${truncatePlain(task, 88)}`)}`;
246
+ const card = new Container();
247
+ card.addChild(new Text(text, 0, 0));
248
+ card.addChild(body);
249
+ return card;
250
+ }
251
+
252
+ function renderAgentCardBody(
253
+ content: string,
254
+ details: AgentCommandDetails | undefined,
255
+ expanded: boolean,
256
+ theme: Theme,
257
+ ): Component {
258
+ const ok = details?.ok ?? !content.includes("<user_agent_error");
259
+ const output =
260
+ details?.responseText ??
261
+ details?.error ??
262
+ extractTag(content, ok ? "response" : "error") ??
263
+ "No output.";
264
+ if (expanded)
265
+ return ok
266
+ ? new Markdown(output, 2, 0, getMarkdownTheme())
267
+ : new Text(theme.fg("error", output), 2, 0);
268
+
269
+ const previewText = `⎿ ${truncatePlain(details?.responsePreview ?? output, 110)}`;
270
+ if (!ok) return new Text(` ${theme.fg("error", previewText)}`, 0, 0);
271
+ return new Markdown(previewText, 2, 0, getMarkdownTheme(), {
272
+ color: (value) => theme.fg("dim", value),
273
+ });
274
+ }
275
+
276
+ function customMessageContentText(content: unknown): string {
277
+ if (typeof content === "string") return content;
278
+ if (!Array.isArray(content)) return String(content ?? "");
279
+ return content
280
+ .filter(
281
+ (part): part is { type: "text"; text: string } =>
282
+ part?.type === "text" && typeof part.text === "string",
283
+ )
284
+ .map((part) => part.text)
285
+ .join("\n");
286
+ }
287
+
288
+ function buildRendererParts(content: string, details: AgentCommandDetails | undefined): string[] {
289
+ const parts: string[] = [];
290
+ if (details?.modelLabel) parts.push(details.modelLabel);
291
+ else if (details?.model) parts.push(details.model);
292
+ if (details) parts.push(contextLabel(details.inheritedContext));
293
+ const mainContext = mainContextLabel(details?.mainContextState);
294
+ if (mainContext) parts.push(mainContext);
295
+ if (details?.turnCount) parts.push(formatTurns(details.turnCount));
296
+ if (details?.toolUses) parts.push(formatToolUses(details.toolUses));
297
+ if (details?.durationMs !== undefined) parts.push(formatMs(details.durationMs));
298
+ else {
299
+ const duration = extractTag(content, "duration_ms");
300
+ if (duration) parts.push(formatMs(Number(duration)));
301
+ }
302
+ return parts;
303
+ }