@arcreflex/agent-transcripts 0.1.15 → 0.1.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcreflex/agent-transcripts",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Transform AI coding agent session files into readable transcripts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -495,6 +495,7 @@ function extractToolCalls(
495
495
  const result = toolResults.get(b.id);
496
496
  return [
497
497
  {
498
+ id: b.id,
498
499
  name: b.name,
499
500
  summary: extractToolSummary(b.name, b.input || {}),
500
501
  input: b.input,
@@ -4,9 +4,11 @@
4
4
 
5
5
  import type { Adapter, SourceSpec } from "../types.ts";
6
6
  import { claudeCodeAdapter } from "./claude-code.ts";
7
+ import { piCodingAgentAdapter } from "./pi-coding-agent.ts";
7
8
 
8
9
  const adapters: Record<string, Adapter> = {
9
10
  "claude-code": claudeCodeAdapter,
11
+ "pi-coding-agent": piCodingAgentAdapter,
10
12
  };
11
13
 
12
14
  /**
@@ -15,6 +17,8 @@ const adapters: Record<string, Adapter> = {
15
17
  const detectionRules: Array<{ pattern: RegExp; adapter: string }> = [
16
18
  // Match .claude/ or /claude/ in path
17
19
  { pattern: /[./]claude[/\\]/, adapter: "claude-code" },
20
+ // Match .pi/agent/sessions/ in path
21
+ { pattern: /[./]pi[/\\]agent[/\\]sessions[/\\]/, adapter: "pi-coding-agent" },
18
22
  ];
19
23
 
20
24
  /**
@@ -0,0 +1,633 @@
1
+ /**
2
+ * pi-coding-agent JSONL adapter.
3
+ *
4
+ * Parses session files from ~/.pi/agent/sessions/{encoded-cwd}/{timestamp}_{uuid}.jsonl
5
+ *
6
+ * Session format: tree-structured JSONL with id/parentId linking (version 3).
7
+ * See: https://github.com/badlogic/pi-mono — pi-coding-agent session docs.
8
+ */
9
+
10
+ import { Glob } from "bun";
11
+ import { join } from "path";
12
+ import { homedir } from "os";
13
+ import { stat } from "fs/promises";
14
+ import type {
15
+ Adapter,
16
+ DiscoveredSession,
17
+ Transcript,
18
+ Message,
19
+ Warning,
20
+ ToolCall,
21
+ } from "../types.ts";
22
+ import { extractToolSummary } from "../utils/summary.ts";
23
+
24
+ // ============================================================================
25
+ // Session file types (pi-coding-agent JSONL format, version 3)
26
+ // ============================================================================
27
+
28
+ interface SessionHeader {
29
+ type: "session";
30
+ version?: number;
31
+ id: string;
32
+ timestamp: string;
33
+ cwd: string;
34
+ parentSession?: string;
35
+ }
36
+
37
+ interface SessionEntryBase {
38
+ type: string;
39
+ id: string;
40
+ parentId: string | null;
41
+ timestamp: string;
42
+ }
43
+
44
+ // Content block types
45
+
46
+ interface TextContent {
47
+ type: "text";
48
+ text: string;
49
+ }
50
+
51
+ interface ImageContent {
52
+ type: "image";
53
+ data: string;
54
+ mimeType: string;
55
+ }
56
+
57
+ interface ThinkingContent {
58
+ type: "thinking";
59
+ thinking: string;
60
+ }
61
+
62
+ interface PiToolCall {
63
+ type: "toolCall";
64
+ id: string;
65
+ name: string;
66
+ arguments: Record<string, unknown>;
67
+ }
68
+
69
+ type ContentBlock = TextContent | ImageContent | ThinkingContent | PiToolCall;
70
+
71
+ // Message types (embedded in SessionMessageEntry)
72
+
73
+ interface UserMessage {
74
+ role: "user";
75
+ content: string | (TextContent | ImageContent)[];
76
+ timestamp: number;
77
+ }
78
+
79
+ interface AssistantMessage {
80
+ role: "assistant";
81
+ content: (TextContent | ThinkingContent | PiToolCall)[];
82
+ api: string;
83
+ provider: string;
84
+ model: string;
85
+ stopReason: string;
86
+ errorMessage?: string;
87
+ timestamp: number;
88
+ }
89
+
90
+ interface ToolResultMessage {
91
+ role: "toolResult";
92
+ toolCallId: string;
93
+ toolName: string;
94
+ content: (TextContent | ImageContent)[];
95
+ isError: boolean;
96
+ timestamp: number;
97
+ }
98
+
99
+ interface BashExecutionMessage {
100
+ role: "bashExecution";
101
+ command: string;
102
+ output: string;
103
+ exitCode: number | undefined;
104
+ cancelled: boolean;
105
+ truncated: boolean;
106
+ timestamp: number;
107
+ }
108
+
109
+ interface CustomMessage {
110
+ role: "custom";
111
+ customType: string;
112
+ content: string | (TextContent | ImageContent)[];
113
+ display: boolean;
114
+ timestamp: number;
115
+ }
116
+
117
+ interface BranchSummaryMessage {
118
+ role: "branchSummary";
119
+ summary: string;
120
+ fromId: string;
121
+ timestamp: number;
122
+ }
123
+
124
+ interface CompactionSummaryMessage {
125
+ role: "compactionSummary";
126
+ summary: string;
127
+ tokensBefore: number;
128
+ timestamp: number;
129
+ }
130
+
131
+ type AgentMessage =
132
+ | UserMessage
133
+ | AssistantMessage
134
+ | ToolResultMessage
135
+ | BashExecutionMessage
136
+ | CustomMessage
137
+ | BranchSummaryMessage
138
+ | CompactionSummaryMessage;
139
+
140
+ // Entry types
141
+
142
+ interface SessionMessageEntry extends SessionEntryBase {
143
+ type: "message";
144
+ message: AgentMessage;
145
+ }
146
+
147
+ interface CompactionEntry extends SessionEntryBase {
148
+ type: "compaction";
149
+ summary: string;
150
+ firstKeptEntryId: string;
151
+ tokensBefore: number;
152
+ }
153
+
154
+ interface BranchSummaryEntry extends SessionEntryBase {
155
+ type: "branch_summary";
156
+ fromId: string;
157
+ summary: string;
158
+ }
159
+
160
+ interface ModelChangeEntry extends SessionEntryBase {
161
+ type: "model_change";
162
+ provider: string;
163
+ modelId: string;
164
+ }
165
+
166
+ interface ThinkingLevelChangeEntry extends SessionEntryBase {
167
+ type: "thinking_level_change";
168
+ thinkingLevel: string;
169
+ }
170
+
171
+ interface SessionInfoEntry extends SessionEntryBase {
172
+ type: "session_info";
173
+ name?: string;
174
+ }
175
+
176
+ // Entries we care about for parsing
177
+ type SessionEntry =
178
+ | SessionMessageEntry
179
+ | CompactionEntry
180
+ | BranchSummaryEntry
181
+ | ModelChangeEntry
182
+ | ThinkingLevelChangeEntry
183
+ | SessionInfoEntry;
184
+
185
+ type FileEntry = SessionHeader | SessionEntry;
186
+
187
+ // ============================================================================
188
+ // Parsing helpers
189
+ // ============================================================================
190
+
191
+ function parseJsonl(content: string): {
192
+ header: SessionHeader | undefined;
193
+ entries: SessionEntry[];
194
+ warnings: Warning[];
195
+ } {
196
+ const entries: SessionEntry[] = [];
197
+ const warnings: Warning[] = [];
198
+ let header: SessionHeader | undefined;
199
+ const lines = content.split("\n");
200
+
201
+ for (let i = 0; i < lines.length; i++) {
202
+ const line = lines[i].trim();
203
+ if (!line) continue;
204
+
205
+ try {
206
+ const record = JSON.parse(line) as FileEntry;
207
+
208
+ if (record.type === "session") {
209
+ header = record as SessionHeader;
210
+ continue;
211
+ }
212
+
213
+ // Skip entry types we don't render (custom, label, custom_message, etc.)
214
+ if (
215
+ record.type === "message" ||
216
+ record.type === "compaction" ||
217
+ record.type === "branch_summary" ||
218
+ record.type === "model_change" ||
219
+ record.type === "thinking_level_change" ||
220
+ record.type === "session_info"
221
+ ) {
222
+ entries.push(record as SessionEntry);
223
+ }
224
+ } catch (e) {
225
+ warnings.push({
226
+ type: "parse_error",
227
+ detail: `Line ${i + 1}: ${e instanceof Error ? e.message : "Invalid JSON"}`,
228
+ });
229
+ }
230
+ }
231
+
232
+ return { header, entries, warnings };
233
+ }
234
+
235
+ function extractText(content: string | ContentBlock[]): string {
236
+ if (typeof content === "string") return content;
237
+ return content
238
+ .flatMap((b) => {
239
+ if (b.type === "text") return [b.text];
240
+ return [];
241
+ })
242
+ .join("\n");
243
+ }
244
+
245
+ function extractThinking(content: ContentBlock[]): string | undefined {
246
+ const parts = content.flatMap((b) =>
247
+ b.type === "thinking" ? [b.thinking] : [],
248
+ );
249
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
250
+ }
251
+
252
+ function extractPiToolCalls(content: ContentBlock[]): PiToolCall[] {
253
+ return content.filter((b): b is PiToolCall => b.type === "toolCall");
254
+ }
255
+
256
+ // ============================================================================
257
+ // Conversation splitting (tree structure via id/parentId)
258
+ // ============================================================================
259
+
260
+ interface SplitResult {
261
+ conversations: SessionEntry[][];
262
+ resolvedParents: Map<string, string | undefined>;
263
+ }
264
+
265
+ function splitConversations(entries: SessionEntry[]): SplitResult {
266
+ if (entries.length === 0) {
267
+ return { conversations: [], resolvedParents: new Map() };
268
+ }
269
+
270
+ const byId = new Map<string, SessionEntry>();
271
+ const children = new Map<string, string[]>();
272
+ const resolvedParents = new Map<string, string | undefined>();
273
+ const roots: string[] = [];
274
+
275
+ for (const entry of entries) {
276
+ byId.set(entry.id, entry);
277
+ }
278
+
279
+ for (const entry of entries) {
280
+ const parentId = entry.parentId;
281
+ if (parentId && byId.has(parentId)) {
282
+ resolvedParents.set(entry.id, parentId);
283
+ const existing = children.get(parentId) || [];
284
+ existing.push(entry.id);
285
+ children.set(parentId, existing);
286
+ } else {
287
+ resolvedParents.set(entry.id, undefined);
288
+ roots.push(entry.id);
289
+ }
290
+ }
291
+
292
+ const visited = new Set<string>();
293
+ const conversations: SessionEntry[][] = [];
294
+
295
+ for (const root of roots) {
296
+ if (visited.has(root)) continue;
297
+
298
+ const conversation: SessionEntry[] = [];
299
+ const queue = [root];
300
+
301
+ while (queue.length > 0) {
302
+ const id = queue.shift();
303
+ if (!id || visited.has(id)) continue;
304
+ visited.add(id);
305
+
306
+ const entry = byId.get(id);
307
+ if (entry) conversation.push(entry);
308
+
309
+ const childIds = children.get(id) || [];
310
+ queue.push(...childIds);
311
+ }
312
+
313
+ if (conversation.length > 0) {
314
+ conversations.push(conversation);
315
+ }
316
+ }
317
+
318
+ // Sort conversations by first entry timestamp
319
+ conversations.sort((a, b) => {
320
+ const ta = new Date(a[0].timestamp).getTime();
321
+ const tb = new Date(b[0].timestamp).getTime();
322
+ return ta - tb;
323
+ });
324
+
325
+ return { conversations, resolvedParents };
326
+ }
327
+
328
+ // ============================================================================
329
+ // Transform entries → transcript messages
330
+ // ============================================================================
331
+
332
+ function transformConversation(
333
+ entries: SessionEntry[],
334
+ sourcePath: string,
335
+ warnings: Warning[],
336
+ resolvedParents: Map<string, string | undefined>,
337
+ cwd: string | undefined,
338
+ ): Transcript {
339
+ const messages: Message[] = [];
340
+
341
+ // Collect tool results: toolCallId → { result, isError, toolName }
342
+ const toolResults = new Map<
343
+ string,
344
+ { result: string; isError: boolean; toolName: string }
345
+ >();
346
+
347
+ // First pass: collect tool results from toolResult messages
348
+ for (const entry of entries) {
349
+ if (entry.type !== "message") continue;
350
+ const msg = entry.message;
351
+ if (msg.role === "toolResult") {
352
+ const text = extractText(msg.content);
353
+ toolResults.set(msg.toolCallId, {
354
+ result: text,
355
+ isError: msg.isError,
356
+ toolName: msg.toolName,
357
+ });
358
+ }
359
+ }
360
+
361
+ // Track which entry IDs produce messages (for parent resolution through skipped entries)
362
+ const skippedParents = new Map<string, string | undefined>();
363
+
364
+ // Identify entries that will be skipped
365
+ for (const entry of entries) {
366
+ let willSkip = false;
367
+
368
+ if (entry.type === "message") {
369
+ const msg = entry.message;
370
+ if (msg.role === "toolResult" || msg.role === "bashExecution") {
371
+ willSkip = true;
372
+ } else if (msg.role === "user") {
373
+ const text = extractText(msg.content);
374
+ if (!text.trim()) willSkip = true;
375
+ } else if (msg.role === "assistant") {
376
+ const text = extractText(msg.content);
377
+ const thinking = extractThinking(msg.content as ContentBlock[]);
378
+ const toolCalls = extractPiToolCalls(msg.content as ContentBlock[]);
379
+ if (!text.trim() && !thinking && toolCalls.length === 0) {
380
+ willSkip = true;
381
+ }
382
+ } else if (
383
+ msg.role === "compactionSummary" ||
384
+ msg.role === "branchSummary" ||
385
+ msg.role === "custom"
386
+ ) {
387
+ willSkip = true;
388
+ }
389
+ } else if (
390
+ entry.type === "model_change" ||
391
+ entry.type === "thinking_level_change" ||
392
+ entry.type === "session_info"
393
+ ) {
394
+ willSkip = true;
395
+ }
396
+ // compaction and branch_summary entries → rendered as system messages, not skipped
397
+
398
+ if (willSkip) {
399
+ skippedParents.set(entry.id, resolvedParents.get(entry.id));
400
+ }
401
+ }
402
+
403
+ // Resolve parent through skipped entries
404
+ function resolveParent(entryId: string): string | undefined {
405
+ let current = resolvedParents.get(entryId);
406
+ const visited = new Set<string>();
407
+ while (current && skippedParents.has(current)) {
408
+ if (visited.has(current)) return undefined;
409
+ visited.add(current);
410
+ current = skippedParents.get(current);
411
+ }
412
+ return current;
413
+ }
414
+
415
+ // Second pass: build messages
416
+ for (const entry of entries) {
417
+ if (skippedParents.has(entry.id)) continue;
418
+
419
+ const sourceRef = entry.id;
420
+ const timestamp = entry.timestamp;
421
+ const parentMessageRef = resolveParent(entry.id);
422
+ const rawJson = JSON.stringify(entry);
423
+
424
+ if (entry.type === "compaction") {
425
+ messages.push({
426
+ type: "system",
427
+ sourceRef,
428
+ timestamp,
429
+ parentMessageRef,
430
+ rawJson,
431
+ content: `[Compaction] ${entry.summary}`,
432
+ });
433
+ } else if (entry.type === "branch_summary") {
434
+ messages.push({
435
+ type: "system",
436
+ sourceRef,
437
+ timestamp,
438
+ parentMessageRef,
439
+ rawJson,
440
+ content: `[Branch summary] ${entry.summary}`,
441
+ });
442
+ } else if (entry.type === "message") {
443
+ const msg = entry.message;
444
+
445
+ if (msg.role === "user") {
446
+ const text = extractText(msg.content);
447
+ if (text.trim()) {
448
+ messages.push({
449
+ type: "user",
450
+ sourceRef,
451
+ timestamp,
452
+ parentMessageRef,
453
+ rawJson,
454
+ content: text,
455
+ });
456
+ }
457
+ } else if (msg.role === "assistant") {
458
+ const blocks = msg.content as ContentBlock[];
459
+ const text = extractText(blocks);
460
+ const thinking = extractThinking(blocks);
461
+ const piToolCalls = extractPiToolCalls(blocks);
462
+
463
+ if (text.trim() || thinking) {
464
+ messages.push({
465
+ type: "assistant",
466
+ sourceRef,
467
+ timestamp,
468
+ parentMessageRef,
469
+ rawJson,
470
+ content: text,
471
+ thinking,
472
+ });
473
+ }
474
+
475
+ if (piToolCalls.length > 0) {
476
+ const calls: ToolCall[] = piToolCalls.map((tc) => {
477
+ const result = toolResults.get(tc.id);
478
+ return {
479
+ id: tc.id,
480
+ name: tc.name,
481
+ summary: extractToolSummary(tc.name, tc.arguments || {}),
482
+ input: tc.arguments,
483
+ result: result?.result,
484
+ error: result?.isError ? result.result : undefined,
485
+ };
486
+ });
487
+ messages.push({
488
+ type: "tool_calls",
489
+ sourceRef,
490
+ timestamp,
491
+ parentMessageRef,
492
+ rawJson,
493
+ calls,
494
+ });
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ // Compute time bounds
501
+ let minTime = Infinity;
502
+ let maxTime = -Infinity;
503
+ for (const msg of messages) {
504
+ const t = new Date(msg.timestamp).getTime();
505
+ if (t < minTime) minTime = t;
506
+ if (t > maxTime) maxTime = t;
507
+ }
508
+ const now = new Date().toISOString();
509
+ const startTime = Number.isFinite(minTime)
510
+ ? new Date(minTime).toISOString()
511
+ : now;
512
+ const endTime = Number.isFinite(maxTime)
513
+ ? new Date(maxTime).toISOString()
514
+ : startTime;
515
+
516
+ return {
517
+ source: {
518
+ file: sourcePath,
519
+ adapter: "pi-coding-agent",
520
+ },
521
+ metadata: {
522
+ warnings,
523
+ messageCount: messages.length,
524
+ startTime,
525
+ endTime,
526
+ cwd,
527
+ },
528
+ messages,
529
+ };
530
+ }
531
+
532
+ // ============================================================================
533
+ // Adapter
534
+ // ============================================================================
535
+
536
+ export const piCodingAgentAdapter: Adapter = {
537
+ name: "pi-coding-agent",
538
+ version: "pi-coding-agent:1",
539
+ defaultSource: join(homedir(), ".pi", "agent", "sessions"),
540
+
541
+ async discover(source: string): Promise<DiscoveredSession[]> {
542
+ const sessions: DiscoveredSession[] = [];
543
+ const glob = new Glob("**/*.jsonl");
544
+
545
+ for await (const file of glob.scan({ cwd: source, absolute: false })) {
546
+ const fullPath = join(source, file);
547
+ try {
548
+ const fileStat = await stat(fullPath);
549
+ sessions.push({
550
+ path: fullPath,
551
+ relativePath: file,
552
+ mtime: fileStat.mtime.getTime(),
553
+ });
554
+ } catch {
555
+ // Skip files we can't stat
556
+ }
557
+ }
558
+
559
+ // Try to extract session name from session_info entries
560
+ for (const session of sessions) {
561
+ try {
562
+ const content = await Bun.file(session.path).text();
563
+ const lines = content.split("\n");
564
+ // Scan for session_info entries (last one wins)
565
+ let name: string | undefined;
566
+ for (const line of lines) {
567
+ if (!line.trim()) continue;
568
+ try {
569
+ const entry = JSON.parse(line);
570
+ if (entry.type === "session_info" && entry.name) {
571
+ name = entry.name;
572
+ }
573
+ } catch {
574
+ // skip
575
+ }
576
+ }
577
+ if (name) {
578
+ session.summary = name;
579
+ }
580
+ } catch {
581
+ // skip
582
+ }
583
+ }
584
+
585
+ return sessions;
586
+ },
587
+
588
+ parse(content: string, sourcePath: string): Transcript[] {
589
+ const { header, entries, warnings } = parseJsonl(content);
590
+ const cwd = header?.cwd;
591
+
592
+ const { conversations, resolvedParents } = splitConversations(entries);
593
+
594
+ if (conversations.length === 0) {
595
+ const now = new Date().toISOString();
596
+ return [
597
+ {
598
+ source: { file: sourcePath, adapter: "pi-coding-agent" },
599
+ metadata: {
600
+ warnings,
601
+ messageCount: 0,
602
+ startTime: now,
603
+ endTime: now,
604
+ cwd,
605
+ },
606
+ messages: [],
607
+ },
608
+ ];
609
+ }
610
+
611
+ if (conversations.length === 1) {
612
+ return [
613
+ transformConversation(
614
+ conversations[0],
615
+ sourcePath,
616
+ warnings,
617
+ resolvedParents,
618
+ cwd,
619
+ ),
620
+ ];
621
+ }
622
+
623
+ return conversations.map((conv, i) =>
624
+ transformConversation(
625
+ conv,
626
+ sourcePath,
627
+ i === 0 ? warnings : [],
628
+ resolvedParents,
629
+ cwd,
630
+ ),
631
+ );
632
+ },
633
+ };
package/src/render.ts CHANGED
@@ -6,10 +6,9 @@ import type { Transcript, Message, ToolCall } from "./types.ts";
6
6
  import { walkTranscriptTree } from "./utils/tree.ts";
7
7
 
8
8
  function formatToolCall(call: ToolCall): string {
9
- if (call.summary) {
10
- return `${call.name} \`${call.summary}\``;
11
- }
12
- return call.name;
9
+ const label = call.summary ? `${call.name} \`${call.summary}\`` : call.name;
10
+ const ref = call.id ? ` <!-- tool:${call.id} -->` : "";
11
+ return `${label}${ref}`;
13
12
  }
14
13
 
15
14
  function formatToolCalls(calls: ToolCall[]): string {
@@ -60,13 +59,14 @@ ${msg.thinking}
60
59
  export interface RenderTranscriptOptions {
61
60
  head?: string; // render branch ending at this message ID
62
61
  sourcePath?: string; // absolute source path for front matter provenance
62
+ title?: string; // override the "# Transcript" heading
63
63
  }
64
64
 
65
65
  export function renderTranscript(
66
66
  transcript: Transcript,
67
67
  options: RenderTranscriptOptions = {},
68
68
  ): string {
69
- const { head, sourcePath } = options;
69
+ const { head, sourcePath, title } = options;
70
70
 
71
71
  const lines: string[] = [];
72
72
 
@@ -79,7 +79,7 @@ export function renderTranscript(
79
79
  }
80
80
 
81
81
  // Header
82
- lines.push("# Transcript");
82
+ lines.push(`# ${title || "Transcript"}`);
83
83
  lines.push("");
84
84
  lines.push(`**Source**: \`${transcript.source.file}\``);
85
85
  lines.push(`**Adapter**: ${transcript.source.adapter}`);