pi-better-btw-plus 1.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.
@@ -0,0 +1,302 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { isFramingMessage } from "./side-chat-messages.ts";
5
+
6
+ /**
7
+ * Alt+E transcript export: dumps the btw session to
8
+ * `$CWD/.agents/eval/pi-better-btw-<timestamp>.md` as a markdown diagnostic
9
+ * artifact (feature/debug work). The forked main-lane context and the
10
+ * framing block are included verbatim, labeled per segment, so the export
11
+ * shows exactly what the btw session saw in its LLM context.
12
+ */
13
+
14
+ export interface ExportSideChatOptions {
15
+ messages: AgentMessage[];
16
+ /** Current working directory — the `.agents/eval/` dir is created under it. */
17
+ cwd: string;
18
+ modelId: string;
19
+ toolMode: "full" | "read-only";
20
+ /** Number of leading messages injected from the main lane (fork context). */
21
+ forkedMessageCount: number;
22
+ /** Whether the agent was mid-stream when the export was requested. */
23
+ streaming: boolean;
24
+ /** In-flight assistant text at export time (included as a pseudo message). */
25
+ streamingContent?: string;
26
+ exportedAt?: Date;
27
+ }
28
+
29
+ /** Local `YYYY-MM-DD HH:mm:ss` for headers. */
30
+ function formatTimestamp(timestamp: number | Date): string {
31
+ const d = typeof timestamp === "number" ? new Date(timestamp) : timestamp;
32
+ const pad = (n: number) => String(n).padStart(2, "0");
33
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
34
+ }
35
+
36
+ /** Local `YYYYMMDD-HHmmss` for the export filename. */
37
+ function formatFileTimestamp(d: Date): string {
38
+ const pad = (n: number) => String(n).padStart(2, "0");
39
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
40
+ }
41
+
42
+ /** Cap a string with an ellipsis note (safety valve for huge payloads). */
43
+ function cap(text: string, max: number): string {
44
+ return text.length > max
45
+ ? `${text.slice(0, max)}\n… (truncated ${text.length - max} chars)`
46
+ : text;
47
+ }
48
+
49
+ function formatJson(value: unknown): string {
50
+ try {
51
+ return JSON.stringify(value, null, 2);
52
+ } catch {
53
+ return String(value);
54
+ }
55
+ }
56
+
57
+ /** Content block union of the LLM message roles (text/thinking/image/toolCall). */
58
+ type ContentBlock = { type: string } & Record<string, unknown>;
59
+
60
+ /** Render one content block into markdown lines (text/thinking/image/toolCall). */
61
+ function formatBlock(block: ContentBlock, lines: string[]) {
62
+ switch (block.type) {
63
+ case "text":
64
+ lines.push(String(block.text ?? ""));
65
+ break;
66
+ case "thinking":
67
+ if (typeof block.thinking === "string" && block.thinking) {
68
+ lines.push("", "_šŸ’­ thinking:_", "```text", block.thinking, "```");
69
+ } else if (block.redacted) {
70
+ lines.push("_šŸ’­ thinking redacted by safety filters_");
71
+ }
72
+ break;
73
+ case "image":
74
+ lines.push(
75
+ `_šŸ–¼ image (${String(block.mimeType ?? "unknown")}), payload omitted — binary data not exported_`,
76
+ );
77
+ break;
78
+ case "toolCall":
79
+ lines.push(`_šŸ”§ tool call: ${String(block.name ?? "?")}_`);
80
+ lines.push("```json");
81
+ lines.push(cap(formatJson(block.arguments), 10_000));
82
+ lines.push("```");
83
+ break;
84
+ default:
85
+ lines.push(`_content block (${block.type})_`);
86
+ }
87
+ }
88
+
89
+ /** Extract the text of a message content (string or blocks) as markdown lines. */
90
+ function formatContent(content: string | ContentBlock[], lines: string[]) {
91
+ if (typeof content === "string") {
92
+ lines.push(content);
93
+ return;
94
+ }
95
+ for (const block of content) {
96
+ formatBlock(block, lines);
97
+ }
98
+ }
99
+
100
+ /** Role emoji + display label used in the message heading. */
101
+ function roleLabel(msg: AgentMessage): string {
102
+ switch (msg.role) {
103
+ case "user":
104
+ return "šŸ‘¤ user";
105
+ case "assistant":
106
+ return "šŸ¤– assistant";
107
+ case "toolResult":
108
+ return `šŸ”§ toolResult: ${String((msg as { toolName?: unknown }).toolName ?? "?")}`;
109
+ case "branchSummary":
110
+ case "compactionSummary":
111
+ return `šŸ—œ ${msg.role}`;
112
+ case "bashExecution":
113
+ return "šŸ’» bash";
114
+ case "custom":
115
+ return "🧩 custom";
116
+ default:
117
+ return `ā” ${String((msg as { role?: unknown }).role ?? "unknown")}`;
118
+ }
119
+ }
120
+
121
+ function renderMessage(
122
+ msg: AgentMessage,
123
+ index: number,
124
+ tag: string | null,
125
+ ): string[] {
126
+ const lines: string[] = [];
127
+ const time = formatTimestamp(msg.timestamp);
128
+ const tagText = tag ? ` Ā· ${tag}` : "";
129
+ lines.push(`### [#${index}] ${roleLabel(msg)} Ā· ${time}${tagText}`);
130
+
131
+ if (msg.role === "assistant") {
132
+ const a = msg as AgentMessage & {
133
+ model?: unknown;
134
+ stopReason?: unknown;
135
+ usage?: {
136
+ input?: number;
137
+ output?: number;
138
+ cacheRead?: number;
139
+ cacheWrite?: number;
140
+ };
141
+ errorMessage?: unknown;
142
+ };
143
+ const meta: string[] = [];
144
+ if (typeof a.model === "string" && a.model) meta.push(`model ${a.model}`);
145
+ if (a.stopReason) meta.push(`stop ${String(a.stopReason)}`);
146
+ const usage = a.usage as
147
+ | {
148
+ input?: number;
149
+ output?: number;
150
+ cacheRead?: number;
151
+ cacheWrite?: number;
152
+ totalTokens?: number;
153
+ }
154
+ | undefined;
155
+ if (usage && typeof usage.totalTokens === "number") {
156
+ meta.push(
157
+ `tokens ${usage.input ?? 0}/${usage.output ?? 0} (cache ${(usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)})`,
158
+ );
159
+ }
160
+ if (a.errorMessage) meta.push(`error: ${String(a.errorMessage)}`);
161
+ if (meta.length) lines.push(`_${meta.join(" Ā· ")}_`);
162
+ if (msg.content.length === 0 && a.errorMessage) {
163
+ lines.push(String(a.errorMessage));
164
+ }
165
+ } else if (msg.role === "toolResult") {
166
+ const t = msg as AgentMessage & { isError?: unknown; details?: unknown };
167
+ lines.push(`_status: ${t.isError ? "ERROR" : "OK"}_`);
168
+ if (t.details !== undefined) {
169
+ lines.push("_details:_");
170
+ lines.push("```json");
171
+ lines.push(cap(formatJson(t.details), 100_000));
172
+ lines.push("```");
173
+ }
174
+ }
175
+
176
+ const content = (msg as AgentMessage & { content?: string | ContentBlock[] })
177
+ .content;
178
+ if (content !== undefined) {
179
+ formatContent(content, lines);
180
+ } else if (msg.role === "bashExecution") {
181
+ const b = msg as AgentMessage & { command?: unknown };
182
+ if (b.command !== undefined) lines.push(String(b.command));
183
+ } else if (msg.role === "branchSummary" || msg.role === "compactionSummary") {
184
+ const s = msg as AgentMessage & {
185
+ summary?: unknown;
186
+ tokensBefore?: unknown;
187
+ };
188
+ if (s.summary !== undefined) lines.push(String(s.summary));
189
+ if (s.tokensBefore !== undefined)
190
+ lines.push(`_tokens before: ${String(s.tokensBefore)}_`);
191
+ }
192
+ return lines;
193
+ }
194
+
195
+ /** Build the full markdown transcript document. */
196
+ export function buildExportMarkdown(opts: ExportSideChatOptions): string {
197
+ const exportedAt = opts.exportedAt ?? new Date();
198
+ const messages = opts.messages;
199
+ const total = messages.length;
200
+
201
+ // Segment split: forked main-lane context → framing block → btw conversation.
202
+ const forked = messages.slice(
203
+ 0,
204
+ Math.max(0, Math.min(opts.forkedMessageCount, total)),
205
+ );
206
+ const rest = messages.slice(forked.length);
207
+ const framingIdx = rest.findIndex(isFramingMessage);
208
+ const framing = framingIdx >= 0 ? [rest[framingIdx]] : [];
209
+ const conversation =
210
+ framingIdx >= 0
211
+ ? [...rest.slice(0, framingIdx), ...rest.slice(framingIdx + 1)]
212
+ : rest;
213
+
214
+ const lines: string[] = [];
215
+ lines.push("# btw Chat Export — pi-better-btw-plus");
216
+ lines.push("");
217
+ lines.push(
218
+ "_Exported with `Alt+E` from the btw overlay — diagnostic artifact for feature work._",
219
+ );
220
+ lines.push("");
221
+ lines.push(`- exported at: ${formatTimestamp(exportedAt)}`);
222
+ lines.push(`- model: ${opts.modelId}`);
223
+ lines.push(`- tool mode: ${opts.toolMode}`);
224
+ lines.push(`- cwd: \`${opts.cwd}\``);
225
+ lines.push(
226
+ `- transcript: ${total} messages (${forked.length} forked context Ā· ${framing.length} framing Ā· ${conversation.length} conversation)`,
227
+ );
228
+ lines.push(`- streaming at export: ${opts.streaming ? "yes" : "no"}`);
229
+
230
+ const segments: {
231
+ title: string;
232
+ msgs: AgentMessage[];
233
+ tag: string | null;
234
+ }[] = [];
235
+ if (forked.length)
236
+ segments.push({
237
+ title: "forked context from main lane",
238
+ msgs: forked,
239
+ tag: "forked from main lane",
240
+ });
241
+ if (framing.length)
242
+ segments.push({
243
+ title: "framing block",
244
+ msgs: framing,
245
+ tag: "framing block",
246
+ });
247
+ if (conversation.length)
248
+ segments.push({ title: "btw conversation", msgs: conversation, tag: null });
249
+
250
+ let globalIndex = 0;
251
+ for (const segment of segments) {
252
+ lines.push("", "---", "");
253
+ lines.push(
254
+ `## ${segment.title} (${segment.msgs.length} message${segment.msgs.length === 1 ? "" : "s"})`,
255
+ );
256
+ lines.push("");
257
+ for (const msg of segment.msgs) {
258
+ globalIndex += 1;
259
+ lines.push(...renderMessage(msg, globalIndex, segment.tag), "");
260
+ }
261
+ }
262
+
263
+ // Mid-stream snapshot: append the in-flight assistant text as a pseudo message.
264
+ if (opts.streaming && opts.streamingContent) {
265
+ lines.push("---", "");
266
+ lines.push(
267
+ `## in-flight assistant response at export time (${opts.streamingContent.length} chars)`,
268
+ );
269
+ lines.push("");
270
+ lines.push(
271
+ `### [#${globalIndex + 1}] šŸ¤– assistant Ā· ${formatTimestamp(exportedAt)} Ā· streamed, not committed`,
272
+ );
273
+ lines.push(opts.streamingContent);
274
+ }
275
+
276
+ if (!segments.length && !(opts.streaming && opts.streamingContent)) {
277
+ lines.push("", "_No messages to export._");
278
+ }
279
+
280
+ return lines.join("\n");
281
+ }
282
+
283
+ /**
284
+ * Write the export to `<cwd>/.agents/eval/pi-better-btw-<YYYYMMDD-HHmmss>.md`.
285
+ * Collisions get a `-2`, `-3`, … suffix. Returns the absolute written path.
286
+ */
287
+ export function exportChatHistoryToFile(opts: ExportSideChatOptions): string {
288
+ const exportedAt = opts.exportedAt ?? new Date();
289
+ const dir = join(opts.cwd, ".agents", "eval");
290
+ mkdirSync(dir, { recursive: true });
291
+
292
+ const base = `pi-better-btw-${formatFileTimestamp(exportedAt)}`;
293
+ let path = join(dir, `${base}.md`);
294
+ let counter = 2;
295
+ while (existsSync(path)) {
296
+ path = join(dir, `${base}-${counter}.md`);
297
+ counter += 1;
298
+ }
299
+
300
+ writeFileSync(path, buildExportMarkdown(opts), "utf-8");
301
+ return path;
302
+ }