@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.
- package/.prettierignore +6 -0
- package/README.md +58 -0
- package/bin/agent-transcripts +2 -0
- package/bun.lock +54 -0
- package/package.json +31 -0
- package/spec.md +239 -0
- package/src/adapters/claude-code.ts +310 -0
- package/src/adapters/index.ts +45 -0
- package/src/cli.ts +114 -0
- package/src/parse.ts +127 -0
- package/src/render.ts +354 -0
- package/src/types.ts +75 -0
- package/src/utils/summary.ts +43 -0
- package/test/fixtures/claude/basic-conversation.input.jsonl +4 -0
- package/test/fixtures/claude/basic-conversation.output.md +28 -0
- package/test/fixtures/claude/multiple-tools.input.jsonl +2 -0
- package/test/fixtures/claude/multiple-tools.output.md +15 -0
- package/test/fixtures/claude/with-thinking.input.jsonl +2 -0
- package/test/fixtures/claude/with-thinking.output.md +24 -0
- package/test/fixtures/claude/with-tools.input.jsonl +2 -0
- package/test/fixtures/claude/with-tools.output.md +16 -0
- package/test/snapshots.test.ts +67 -0
- package/tsconfig.json +15 -0
|
@@ -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
|
+
}
|