@arcreflex/agent-transcripts 0.1.1

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,45 @@
1
+ /**
2
+ * Adapter registry with path-based detection.
3
+ */
4
+
5
+ import type { Adapter } from "../types.ts";
6
+ import { claudeCodeAdapter } from "./claude-code.ts";
7
+
8
+ const adapters: Record<string, Adapter> = {
9
+ "claude-code": claudeCodeAdapter,
10
+ };
11
+
12
+ /**
13
+ * Detection rules: path pattern → adapter name
14
+ */
15
+ const detectionRules: Array<{ pattern: RegExp; adapter: string }> = [
16
+ // Match .claude/ or /claude/ in path
17
+ { pattern: /[./]claude[/\\]/, adapter: "claude-code" },
18
+ ];
19
+
20
+ /**
21
+ * Detect adapter from file path.
22
+ * Returns adapter name if detected, undefined if not.
23
+ */
24
+ export function detectAdapter(filePath: string): string | undefined {
25
+ for (const rule of detectionRules) {
26
+ if (rule.pattern.test(filePath)) {
27
+ return rule.adapter;
28
+ }
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ /**
34
+ * Get adapter by name.
35
+ */
36
+ export function getAdapter(name: string): Adapter | undefined {
37
+ return adapters[name];
38
+ }
39
+
40
+ /**
41
+ * List available adapter names.
42
+ */
43
+ export function listAdapters(): string[] {
44
+ return Object.keys(adapters);
45
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * CLI entry point using cmd-ts.
3
+ */
4
+
5
+ import {
6
+ command,
7
+ subcommands,
8
+ run,
9
+ string,
10
+ option,
11
+ optional,
12
+ positional,
13
+ } from "cmd-ts";
14
+ import { parse } from "./parse.ts";
15
+ import { render } from "./render.ts";
16
+
17
+ // Shared options
18
+ const inputArg = positional({
19
+ type: optional(string),
20
+ displayName: "file",
21
+ description: "Input file (reads from stdin if not provided)",
22
+ });
23
+
24
+ const outputOpt = option({
25
+ type: optional(string),
26
+ long: "output",
27
+ short: "o",
28
+ description: "Output path (defaults to current directory)",
29
+ });
30
+
31
+ const adapterOpt = option({
32
+ type: optional(string),
33
+ long: "adapter",
34
+ description:
35
+ "Source format adapter (auto-detected from path if not specified)",
36
+ });
37
+
38
+ const headOpt = option({
39
+ type: optional(string),
40
+ long: "head",
41
+ description: "Render branch ending at this message ID (default: latest)",
42
+ });
43
+
44
+ // Parse subcommand
45
+ const parseCmd = command({
46
+ name: "parse",
47
+ description: "Parse source format to intermediate JSON",
48
+ args: {
49
+ input: inputArg,
50
+ output: outputOpt,
51
+ adapter: adapterOpt,
52
+ },
53
+ async handler({ input, output, adapter }) {
54
+ await parse({ input, output, adapter });
55
+ },
56
+ });
57
+
58
+ // Render subcommand
59
+ const renderCmd = command({
60
+ name: "render",
61
+ description: "Render intermediate JSON to markdown",
62
+ args: {
63
+ input: inputArg,
64
+ output: outputOpt,
65
+ head: headOpt,
66
+ },
67
+ async handler({ input, output, head }) {
68
+ await render({ input, output, head });
69
+ },
70
+ });
71
+
72
+ // Default command: full pipeline (parse → render)
73
+ const defaultCmd = command({
74
+ name: "agent-transcripts",
75
+ description: "Transform agent session files to readable transcripts",
76
+ args: {
77
+ input: inputArg,
78
+ output: outputOpt,
79
+ adapter: adapterOpt,
80
+ head: headOpt,
81
+ },
82
+ async handler({ input, output, adapter, head }) {
83
+ // Parse to JSON - parse() determines output paths and returns them
84
+ const { outputPaths } = await parse({ input, output, adapter });
85
+
86
+ // Render each transcript (JSON path → markdown path)
87
+ for (const jsonPath of outputPaths) {
88
+ const mdPath = jsonPath.replace(/\.json$/, ".md");
89
+ await render({ input: jsonPath, output: mdPath, head });
90
+ }
91
+ },
92
+ });
93
+
94
+ // Main CLI with subcommands
95
+ const cli = subcommands({
96
+ name: "agent-transcripts",
97
+ description: "Transform agent session files to readable transcripts",
98
+ cmds: {
99
+ parse: parseCmd,
100
+ render: renderCmd,
101
+ },
102
+ // Default command when no subcommand is specified
103
+ });
104
+
105
+ // Run CLI
106
+ const args = process.argv.slice(2);
107
+
108
+ // Check if first arg is a subcommand
109
+ if (args[0] === "parse" || args[0] === "render") {
110
+ run(cli, args);
111
+ } else {
112
+ // Run default command for full pipeline
113
+ run(defaultCmd, args);
114
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Parse command: source format → intermediate JSON
3
+ */
4
+
5
+ import { basename, dirname, join } from "path";
6
+ import { mkdir } from "fs/promises";
7
+ import type { Transcript } from "./types.ts";
8
+ import { detectAdapter, getAdapter, listAdapters } from "./adapters/index.ts";
9
+
10
+ export interface ParseOptions {
11
+ input?: string; // file path, undefined for stdin
12
+ output?: string; // output path/dir
13
+ adapter?: string; // explicit adapter name
14
+ }
15
+
16
+ /**
17
+ * Read input content from file or stdin.
18
+ */
19
+ async function readInput(
20
+ input?: string,
21
+ ): Promise<{ content: string; path: string }> {
22
+ if (input) {
23
+ const content = await Bun.file(input).text();
24
+ return { content, path: input };
25
+ }
26
+
27
+ // Read from stdin
28
+ const chunks: string[] = [];
29
+ const reader = Bun.stdin.stream().getReader();
30
+
31
+ while (true) {
32
+ const { done, value } = await reader.read();
33
+ if (done) break;
34
+ chunks.push(new TextDecoder().decode(value));
35
+ }
36
+
37
+ return { content: chunks.join(""), path: "<stdin>" };
38
+ }
39
+
40
+ /**
41
+ * Determine output file paths for transcripts.
42
+ */
43
+ function getOutputPaths(
44
+ transcripts: Transcript[],
45
+ inputPath: string,
46
+ outputOption?: string,
47
+ ): string[] {
48
+ // Determine base name
49
+ let baseName: string;
50
+ if (inputPath === "<stdin>") {
51
+ baseName = "transcript";
52
+ } else {
53
+ const name = basename(inputPath);
54
+ baseName = name.replace(/\.jsonl?$/, "");
55
+ }
56
+
57
+ // Determine output directory
58
+ let outputDir: string;
59
+ if (outputOption) {
60
+ // If output looks like a file (has extension), use its directory
61
+ if (outputOption.match(/\.\w+$/)) {
62
+ outputDir = dirname(outputOption);
63
+ baseName = basename(outputOption).replace(/\.\w+$/, "");
64
+ } else {
65
+ outputDir = outputOption;
66
+ }
67
+ } else {
68
+ outputDir = process.cwd();
69
+ }
70
+
71
+ // Generate paths
72
+ if (transcripts.length === 1) {
73
+ return [join(outputDir, `${baseName}.json`)];
74
+ }
75
+
76
+ return transcripts.map((_, i) =>
77
+ join(outputDir, `${baseName}_${i + 1}.json`),
78
+ );
79
+ }
80
+
81
+ export interface ParseResult {
82
+ transcripts: Transcript[];
83
+ outputPaths: string[];
84
+ }
85
+
86
+ /**
87
+ * Parse source file(s) to intermediate JSON.
88
+ */
89
+ export async function parse(options: ParseOptions): Promise<ParseResult> {
90
+ const { content, path: inputPath } = await readInput(options.input);
91
+
92
+ // Determine adapter
93
+ let adapterName = options.adapter;
94
+ if (!adapterName && options.input) {
95
+ adapterName = detectAdapter(options.input);
96
+ }
97
+
98
+ if (!adapterName) {
99
+ throw new Error(
100
+ `Could not detect adapter for input. Use --adapter to specify. Available: ${listAdapters().join(", ")}`,
101
+ );
102
+ }
103
+
104
+ const adapter = getAdapter(adapterName);
105
+ if (!adapter) {
106
+ throw new Error(
107
+ `Unknown adapter: ${adapterName}. Available: ${listAdapters().join(", ")}`,
108
+ );
109
+ }
110
+
111
+ // Parse
112
+ const transcripts = adapter.parse(content, inputPath);
113
+
114
+ // Write output files
115
+ const outputPaths = getOutputPaths(transcripts, inputPath, options.output);
116
+
117
+ for (let i = 0; i < transcripts.length; i++) {
118
+ const json = JSON.stringify(transcripts[i], null, 2);
119
+ // Ensure directory exists
120
+ const dir = dirname(outputPaths[i]);
121
+ await mkdir(dir, { recursive: true });
122
+ await Bun.write(outputPaths[i], json);
123
+ console.error(`Wrote: ${outputPaths[i]}`);
124
+ }
125
+
126
+ return { transcripts, outputPaths };
127
+ }
package/src/render.ts ADDED
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Render command: intermediate JSON → markdown
3
+ */
4
+
5
+ import { basename, dirname, join } from "path";
6
+ import { mkdir } from "fs/promises";
7
+ import type { Transcript, Message, ToolCall } from "./types.ts";
8
+
9
+ export interface RenderOptions {
10
+ input?: string; // file path, undefined for stdin
11
+ output?: string; // output path
12
+ head?: string; // render branch ending at this message ID
13
+ }
14
+
15
+ /**
16
+ * Read transcript from file or stdin.
17
+ */
18
+ async function readTranscript(
19
+ input?: string,
20
+ ): Promise<{ transcript: Transcript; path: string }> {
21
+ let content: string;
22
+ let path: string;
23
+
24
+ if (input) {
25
+ content = await Bun.file(input).text();
26
+ path = input;
27
+ } else {
28
+ const chunks: string[] = [];
29
+ const reader = Bun.stdin.stream().getReader();
30
+
31
+ while (true) {
32
+ const { done, value } = await reader.read();
33
+ if (done) break;
34
+ chunks.push(new TextDecoder().decode(value));
35
+ }
36
+
37
+ content = chunks.join("");
38
+ path = "<stdin>";
39
+ }
40
+
41
+ const transcript = JSON.parse(content) as Transcript;
42
+ return { transcript, path };
43
+ }
44
+
45
+ /**
46
+ * Format a single tool call.
47
+ */
48
+ function formatToolCall(call: ToolCall): string {
49
+ if (call.summary) {
50
+ return `${call.name} \`${call.summary}\``;
51
+ }
52
+ return call.name;
53
+ }
54
+
55
+ /**
56
+ * Format tool calls group.
57
+ */
58
+ function formatToolCalls(calls: ToolCall[]): string {
59
+ if (calls.length === 1) {
60
+ return `**Tool**: ${formatToolCall(calls[0])}`;
61
+ }
62
+ return `**Tools**:\n${calls.map((c) => `- ${formatToolCall(c)}`).join("\n")}`;
63
+ }
64
+
65
+ /**
66
+ * Render a message to markdown.
67
+ */
68
+ function renderMessage(msg: Message): string {
69
+ switch (msg.type) {
70
+ case "user":
71
+ return `## User\n\n${msg.content}`;
72
+
73
+ case "assistant": {
74
+ const parts: string[] = ["## Assistant"];
75
+
76
+ if (msg.thinking) {
77
+ parts.push(`
78
+ <details>
79
+ <summary>Thinking...</summary>
80
+
81
+ ${msg.thinking}
82
+ </details>`);
83
+ }
84
+
85
+ if (msg.content.trim()) {
86
+ parts.push(msg.content);
87
+ }
88
+
89
+ return parts.join("\n\n");
90
+ }
91
+
92
+ case "system":
93
+ return `## System\n\n\`\`\`\n${msg.content}\n\`\`\``;
94
+
95
+ case "tool_calls":
96
+ return formatToolCalls(msg.calls);
97
+
98
+ case "error":
99
+ return `## Error\n\n\`\`\`\n${msg.content}\n\`\`\``;
100
+
101
+ default:
102
+ return "";
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Get first line of message content for branch reference.
108
+ */
109
+ function getFirstLine(msg: Message): string {
110
+ let text: string;
111
+ switch (msg.type) {
112
+ case "user":
113
+ case "assistant":
114
+ case "system":
115
+ case "error":
116
+ text = msg.content;
117
+ break;
118
+ case "tool_calls":
119
+ text = msg.calls.map((c) => c.name).join(", ");
120
+ break;
121
+ default:
122
+ text = "";
123
+ }
124
+ const firstLine = text.split("\n")[0].trim();
125
+ const maxLen = 60;
126
+ return firstLine.length > maxLen
127
+ ? firstLine.slice(0, maxLen) + "..."
128
+ : firstLine;
129
+ }
130
+
131
+ /**
132
+ * Build tree structure from messages.
133
+ * Returns maps for navigation and the messages grouped by sourceRef.
134
+ */
135
+ function buildTree(messages: Message[]): {
136
+ bySourceRef: Map<string, Message[]>;
137
+ children: Map<string, Set<string>>;
138
+ parents: Map<string, string>;
139
+ roots: string[];
140
+ } {
141
+ // Group messages by sourceRef
142
+ const bySourceRef = new Map<string, Message[]>();
143
+ for (const msg of messages) {
144
+ const existing = bySourceRef.get(msg.sourceRef) || [];
145
+ existing.push(msg);
146
+ bySourceRef.set(msg.sourceRef, existing);
147
+ }
148
+
149
+ // Build parent → children map (at sourceRef level)
150
+ const children = new Map<string, Set<string>>();
151
+ const parents = new Map<string, string>();
152
+
153
+ for (const msg of messages) {
154
+ if (msg.parentMessageRef && bySourceRef.has(msg.parentMessageRef)) {
155
+ parents.set(msg.sourceRef, msg.parentMessageRef);
156
+ const existing = children.get(msg.parentMessageRef) || new Set();
157
+ existing.add(msg.sourceRef);
158
+ children.set(msg.parentMessageRef, existing);
159
+ }
160
+ }
161
+
162
+ // Find roots (no parent in our set)
163
+ const roots: string[] = [];
164
+ for (const sourceRef of bySourceRef.keys()) {
165
+ if (!parents.has(sourceRef)) {
166
+ roots.push(sourceRef);
167
+ }
168
+ }
169
+
170
+ return { bySourceRef, children, parents, roots };
171
+ }
172
+
173
+ /**
174
+ * Find the latest leaf in the tree (for primary branch).
175
+ */
176
+ function findLatestLeaf(
177
+ bySourceRef: Map<string, Message[]>,
178
+ children: Map<string, Set<string>>,
179
+ ): string | undefined {
180
+ let latestLeaf: string | undefined;
181
+ let latestTime = 0;
182
+
183
+ for (const sourceRef of bySourceRef.keys()) {
184
+ const childSet = children.get(sourceRef);
185
+ if (!childSet || childSet.size === 0) {
186
+ // It's a leaf
187
+ const msgs = bySourceRef.get(sourceRef);
188
+ if (msgs && msgs.length > 0) {
189
+ const time = new Date(msgs[0].timestamp).getTime();
190
+ if (time > latestTime) {
191
+ latestTime = time;
192
+ latestLeaf = sourceRef;
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ return latestLeaf;
199
+ }
200
+
201
+ /**
202
+ * Trace path from root to target.
203
+ */
204
+ function tracePath(target: string, parents: Map<string, string>): string[] {
205
+ const path: string[] = [];
206
+ let current: string | undefined = target;
207
+
208
+ while (current) {
209
+ path.unshift(current);
210
+ current = parents.get(current);
211
+ }
212
+
213
+ return path;
214
+ }
215
+
216
+ /**
217
+ * Render transcript to markdown with branch awareness.
218
+ */
219
+ function renderTranscript(transcript: Transcript, head?: string): string {
220
+ const lines: string[] = [];
221
+
222
+ // Header
223
+ lines.push("# Transcript");
224
+ lines.push("");
225
+ lines.push(`**Source**: \`${transcript.source.file}\``);
226
+ lines.push(`**Adapter**: ${transcript.source.adapter}`);
227
+
228
+ // Warnings
229
+ if (transcript.metadata.warnings.length > 0) {
230
+ lines.push("");
231
+ lines.push("**Warnings**:");
232
+ for (const w of transcript.metadata.warnings) {
233
+ lines.push(`- ${w.type}: ${w.detail}`);
234
+ }
235
+ }
236
+
237
+ lines.push("");
238
+ lines.push("---");
239
+
240
+ // Handle empty transcripts
241
+ if (transcript.messages.length === 0) {
242
+ return lines.join("\n");
243
+ }
244
+
245
+ // Build tree
246
+ const { bySourceRef, children, parents, roots } = buildTree(
247
+ transcript.messages,
248
+ );
249
+
250
+ // Determine target (head or latest leaf)
251
+ let target: string | undefined;
252
+ if (head) {
253
+ if (!bySourceRef.has(head)) {
254
+ lines.push("");
255
+ lines.push(`**Error**: Message ID \`${head}\` not found`);
256
+ return lines.join("\n");
257
+ }
258
+ target = head;
259
+ } else {
260
+ target = findLatestLeaf(bySourceRef, children);
261
+ }
262
+
263
+ if (!target) {
264
+ // Fallback: just render all messages in order (shouldn't happen normally)
265
+ for (const msg of transcript.messages) {
266
+ const rendered = renderMessage(msg);
267
+ if (rendered) {
268
+ lines.push("");
269
+ lines.push(rendered);
270
+ }
271
+ }
272
+ return lines.join("\n");
273
+ }
274
+
275
+ // Trace path from root to target
276
+ const path = tracePath(target, parents);
277
+ const pathSet = new Set(path);
278
+
279
+ // Render messages along the path
280
+ for (const sourceRef of path) {
281
+ const msgs = bySourceRef.get(sourceRef);
282
+ if (!msgs) continue;
283
+
284
+ // Render all messages from this source
285
+ for (const msg of msgs) {
286
+ const rendered = renderMessage(msg);
287
+ if (rendered) {
288
+ lines.push("");
289
+ lines.push(rendered);
290
+ }
291
+ }
292
+
293
+ // Check for other branches at this point (only if not using explicit --head)
294
+ if (!head) {
295
+ const childSet = children.get(sourceRef);
296
+ if (childSet && childSet.size > 1) {
297
+ const otherBranches = [...childSet].filter((c) => !pathSet.has(c));
298
+ if (otherBranches.length > 0) {
299
+ lines.push("");
300
+ lines.push("> **Other branches**:");
301
+ for (const branchRef of otherBranches) {
302
+ const branchMsgs = bySourceRef.get(branchRef);
303
+ if (branchMsgs && branchMsgs.length > 0) {
304
+ const firstLine = getFirstLine(branchMsgs[0]);
305
+ lines.push(`> - \`${branchRef}\` "${firstLine}"`);
306
+ }
307
+ }
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ return lines.join("\n");
314
+ }
315
+
316
+ /**
317
+ * Determine output path for markdown.
318
+ */
319
+ function getOutputPath(inputPath: string, outputOption?: string): string {
320
+ if (outputOption) {
321
+ // If it has an extension, use as-is
322
+ if (outputOption.match(/\.\w+$/)) {
323
+ return outputOption;
324
+ }
325
+ // Treat as directory
326
+ const base =
327
+ inputPath === "<stdin>"
328
+ ? "transcript"
329
+ : basename(inputPath).replace(/\.json$/, "");
330
+ return join(outputOption, `${base}.md`);
331
+ }
332
+
333
+ // Default: same name in cwd
334
+ const base =
335
+ inputPath === "<stdin>"
336
+ ? "transcript"
337
+ : basename(inputPath).replace(/\.json$/, "");
338
+ return join(process.cwd(), `${base}.md`);
339
+ }
340
+
341
+ /**
342
+ * Render intermediate JSON to markdown.
343
+ */
344
+ export async function render(options: RenderOptions): Promise<void> {
345
+ const { transcript, path: inputPath } = await readTranscript(options.input);
346
+
347
+ const markdown = renderTranscript(transcript, options.head);
348
+ const outputPath = getOutputPath(inputPath, options.output);
349
+
350
+ // Ensure directory exists
351
+ await mkdir(dirname(outputPath), { recursive: true });
352
+ await Bun.write(outputPath, markdown);
353
+ console.error(`Wrote: ${outputPath}`);
354
+ }
package/src/types.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Intermediate transcript format for agent-transcripts.
3
+ * Designed to be adapter-agnostic - any agent harness can produce this format.
4
+ */
5
+
6
+ export interface Transcript {
7
+ source: {
8
+ file: string;
9
+ adapter: string;
10
+ };
11
+ metadata: {
12
+ warnings: Warning[];
13
+ };
14
+ messages: Message[];
15
+ }
16
+
17
+ export interface Warning {
18
+ type: string;
19
+ detail: string;
20
+ sourceRef?: string;
21
+ }
22
+
23
+ export type Message =
24
+ | UserMessage
25
+ | AssistantMessage
26
+ | SystemMessage
27
+ | ToolCallGroup
28
+ | ErrorMessage;
29
+
30
+ interface BaseMessage {
31
+ sourceRef: string;
32
+ timestamp: string;
33
+ parentMessageRef?: string; // UUID of parent message (for tree reconstruction)
34
+ }
35
+
36
+ export interface UserMessage extends BaseMessage {
37
+ type: "user";
38
+ content: string;
39
+ }
40
+
41
+ export interface AssistantMessage extends BaseMessage {
42
+ type: "assistant";
43
+ content: string;
44
+ thinking?: string;
45
+ }
46
+
47
+ export interface SystemMessage extends BaseMessage {
48
+ type: "system";
49
+ content: string;
50
+ }
51
+
52
+ export interface ToolCallGroup extends BaseMessage {
53
+ type: "tool_calls";
54
+ calls: ToolCall[];
55
+ }
56
+
57
+ export interface ToolCall {
58
+ name: string;
59
+ summary: string;
60
+ error?: string;
61
+ }
62
+
63
+ export interface ErrorMessage extends BaseMessage {
64
+ type: "error";
65
+ content: string;
66
+ }
67
+
68
+ /**
69
+ * Adapter interface - each source format implements this.
70
+ */
71
+ export interface Adapter {
72
+ name: string;
73
+ /** Parse source content into one or more transcripts (split by conversation) */
74
+ parse(content: string, sourcePath: string): Transcript[];
75
+ }