@arcreflex/agent-transcripts 0.1.1 → 0.1.3

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,24 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: oven-sh/setup-bun@v2
17
+
18
+ - run: bun install
19
+
20
+ - run: bun run check
21
+
22
+ - run: bun test
23
+
24
+ - run: npm publish --provenance --access public
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcreflex/agent-transcripts",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Transform AI coding agent session files into readable transcripts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -199,6 +199,26 @@ function isToolResultOnly(content: string | ContentBlock[]): boolean {
199
199
  return hasToolResult && !hasText;
200
200
  }
201
201
 
202
+ /**
203
+ * Resolve a parent reference through any skipped messages.
204
+ * When messages are skipped (e.g., tool-result-only user messages),
205
+ * we redirect parent references to the skipped message's parent.
206
+ */
207
+ function resolveParent(
208
+ parentUuid: string | null | undefined,
209
+ skippedParents: Map<string, string | undefined>,
210
+ ): string | undefined {
211
+ if (!parentUuid) return undefined;
212
+
213
+ // Follow the chain through any skipped messages
214
+ let current: string | undefined = parentUuid;
215
+ while (current && skippedParents.has(current)) {
216
+ current = skippedParents.get(current);
217
+ }
218
+
219
+ return current;
220
+ }
221
+
202
222
  /**
203
223
  * Transform a conversation into our intermediate format.
204
224
  */
@@ -208,11 +228,45 @@ function transformConversation(
208
228
  warnings: Warning[],
209
229
  ): Transcript {
210
230
  const messages: Message[] = [];
231
+ // Track skipped message UUIDs → their parent UUIDs for chain repair
232
+ const skippedParents = new Map<string, string | undefined>();
233
+
234
+ // First pass: identify which messages will be skipped
235
+ for (const rec of records) {
236
+ if (!rec.uuid) continue;
237
+
238
+ let willSkip = false;
239
+
240
+ if (rec.type === "user" && rec.message) {
241
+ if (isToolResultOnly(rec.message.content)) {
242
+ willSkip = true;
243
+ } else {
244
+ const text = extractText(rec.message.content);
245
+ if (!text.trim()) willSkip = true;
246
+ }
247
+ } else if (rec.type === "assistant" && rec.message) {
248
+ const text = extractText(rec.message.content);
249
+ const thinking = extractThinking(rec.message.content);
250
+ const toolCalls = extractToolCalls(rec.message.content);
251
+ // Only skip if no text, no thinking, AND no tool calls
252
+ if (!text.trim() && !thinking && toolCalls.length === 0) {
253
+ willSkip = true;
254
+ }
255
+ } else if (rec.type === "system") {
256
+ const text = rec.content || "";
257
+ if (!text.trim()) willSkip = true;
258
+ }
259
+
260
+ if (willSkip) {
261
+ skippedParents.set(rec.uuid, rec.parentUuid || undefined);
262
+ }
263
+ }
211
264
 
265
+ // Second pass: build messages with corrected parent references
212
266
  for (const rec of records) {
213
267
  const sourceRef = rec.uuid || "";
214
268
  const timestamp = rec.timestamp || new Date().toISOString();
215
- const parentMessageRef = rec.parentUuid || undefined;
269
+ const parentMessageRef = resolveParent(rec.parentUuid, skippedParents);
216
270
 
217
271
  if (rec.type === "user" && rec.message) {
218
272
  // Skip tool-result-only user messages (they're just tool responses)
package/src/cli.ts CHANGED
@@ -11,8 +11,11 @@ import {
11
11
  optional,
12
12
  positional,
13
13
  } from "cmd-ts";
14
- import { parse } from "./parse.ts";
15
- import { render } from "./render.ts";
14
+ import { parse, parseToTranscripts } from "./parse.ts";
15
+ import { render, renderTranscript } from "./render.ts";
16
+
17
+ // Read OpenRouter API key from environment for LLM-based slug generation
18
+ const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
16
19
 
17
20
  // Shared options
18
21
  const inputArg = positional({
@@ -25,7 +28,7 @@ const outputOpt = option({
25
28
  type: optional(string),
26
29
  long: "output",
27
30
  short: "o",
28
- description: "Output path (defaults to current directory)",
31
+ description: "Output path (prints to stdout if not specified)",
29
32
  });
30
33
 
31
34
  const adapterOpt = option({
@@ -51,7 +54,19 @@ const parseCmd = command({
51
54
  adapter: adapterOpt,
52
55
  },
53
56
  async handler({ input, output, adapter }) {
54
- await parse({ input, output, adapter });
57
+ const naming = OPENROUTER_API_KEY
58
+ ? { apiKey: OPENROUTER_API_KEY }
59
+ : undefined;
60
+
61
+ if (output) {
62
+ await parse({ input, output, adapter, naming });
63
+ } else {
64
+ // Print JSONL to stdout (one transcript per line)
65
+ const { transcripts } = await parseToTranscripts({ input, adapter });
66
+ for (const transcript of transcripts) {
67
+ console.log(JSON.stringify(transcript));
68
+ }
69
+ }
55
70
  },
56
71
  });
57
72
 
@@ -80,13 +95,24 @@ const defaultCmd = command({
80
95
  head: headOpt,
81
96
  },
82
97
  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 });
98
+ const naming = OPENROUTER_API_KEY
99
+ ? { apiKey: OPENROUTER_API_KEY }
100
+ : undefined;
85
101
 
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 });
102
+ if (output) {
103
+ // Write intermediate JSON and markdown files
104
+ const { outputPaths } = await parse({ input, output, adapter, naming });
105
+ for (const jsonPath of outputPaths) {
106
+ const mdPath = jsonPath.replace(/\.json$/, ".md");
107
+ await render({ input: jsonPath, output: mdPath, head });
108
+ }
109
+ } else {
110
+ // Stream to stdout - no intermediate files
111
+ const { transcripts } = await parseToTranscripts({ input, adapter });
112
+ for (let i = 0; i < transcripts.length; i++) {
113
+ if (i > 0) console.log(); // blank line between transcripts
114
+ console.log(renderTranscript(transcripts[i], head));
115
+ }
90
116
  }
91
117
  },
92
118
  });
package/src/parse.ts CHANGED
@@ -2,15 +2,17 @@
2
2
  * Parse command: source format → intermediate JSON
3
3
  */
4
4
 
5
- import { basename, dirname, join } from "path";
5
+ import { dirname, join } from "path";
6
6
  import { mkdir } from "fs/promises";
7
7
  import type { Transcript } from "./types.ts";
8
8
  import { detectAdapter, getAdapter, listAdapters } from "./adapters/index.ts";
9
+ import { generateOutputName, type NamingOptions } from "./utils/naming.ts";
9
10
 
10
11
  export interface ParseOptions {
11
12
  input?: string; // file path, undefined for stdin
12
13
  output?: string; // output path/dir
13
14
  adapter?: string; // explicit adapter name
15
+ naming?: NamingOptions; // options for output file naming
14
16
  }
15
17
 
16
18
  /**
@@ -40,27 +42,24 @@ async function readInput(
40
42
  /**
41
43
  * Determine output file paths for transcripts.
42
44
  */
43
- function getOutputPaths(
45
+ async function getOutputPaths(
44
46
  transcripts: Transcript[],
45
47
  inputPath: string,
46
48
  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
-
49
+ namingOptions?: NamingOptions,
50
+ ): Promise<string[]> {
57
51
  // Determine output directory
58
52
  let outputDir: string;
53
+ let explicitBaseName: string | undefined;
54
+
59
55
  if (outputOption) {
60
- // If output looks like a file (has extension), use its directory
56
+ // If output looks like a file (has extension), use its directory and name
61
57
  if (outputOption.match(/\.\w+$/)) {
62
58
  outputDir = dirname(outputOption);
63
- baseName = basename(outputOption).replace(/\.\w+$/, "");
59
+ explicitBaseName = outputOption
60
+ .split("/")
61
+ .pop()!
62
+ .replace(/\.\w+$/, "");
64
63
  } else {
65
64
  outputDir = outputOption;
66
65
  }
@@ -68,25 +67,50 @@ function getOutputPaths(
68
67
  outputDir = process.cwd();
69
68
  }
70
69
 
71
- // Generate paths
72
- if (transcripts.length === 1) {
73
- return [join(outputDir, `${baseName}.json`)];
70
+ // Generate paths with descriptive names
71
+ const paths: string[] = [];
72
+
73
+ for (let i = 0; i < transcripts.length; i++) {
74
+ let baseName: string;
75
+
76
+ if (explicitBaseName) {
77
+ // User provided explicit filename
78
+ baseName = explicitBaseName;
79
+ } else {
80
+ // Generate descriptive name
81
+ baseName = await generateOutputName(
82
+ transcripts[i],
83
+ inputPath,
84
+ namingOptions || {},
85
+ );
86
+ }
87
+
88
+ // Add suffix for multiple transcripts
89
+ if (transcripts.length > 1) {
90
+ baseName = `${baseName}_${i + 1}`;
91
+ }
92
+
93
+ paths.push(join(outputDir, `${baseName}.json`));
74
94
  }
75
95
 
76
- return transcripts.map((_, i) =>
77
- join(outputDir, `${baseName}_${i + 1}.json`),
78
- );
96
+ return paths;
79
97
  }
80
98
 
81
99
  export interface ParseResult {
82
100
  transcripts: Transcript[];
101
+ inputPath: string;
102
+ }
103
+
104
+ export interface ParseAndWriteResult extends ParseResult {
83
105
  outputPaths: string[];
84
106
  }
85
107
 
86
108
  /**
87
- * Parse source file(s) to intermediate JSON.
109
+ * Parse source file(s) to transcripts (no file I/O beyond reading input).
88
110
  */
89
- export async function parse(options: ParseOptions): Promise<ParseResult> {
111
+ export async function parseToTranscripts(
112
+ options: ParseOptions,
113
+ ): Promise<ParseResult> {
90
114
  const { content, path: inputPath } = await readInput(options.input);
91
115
 
92
116
  // Determine adapter
@@ -108,11 +132,25 @@ export async function parse(options: ParseOptions): Promise<ParseResult> {
108
132
  );
109
133
  }
110
134
 
111
- // Parse
112
135
  const transcripts = adapter.parse(content, inputPath);
136
+ return { transcripts, inputPath };
137
+ }
138
+
139
+ /**
140
+ * Parse source file(s) to intermediate JSON and write to files.
141
+ */
142
+ export async function parse(
143
+ options: ParseOptions,
144
+ ): Promise<ParseAndWriteResult> {
145
+ const { transcripts, inputPath } = await parseToTranscripts(options);
113
146
 
114
147
  // Write output files
115
- const outputPaths = getOutputPaths(transcripts, inputPath, options.output);
148
+ const outputPaths = await getOutputPaths(
149
+ transcripts,
150
+ inputPath,
151
+ options.output,
152
+ options.naming,
153
+ );
116
154
 
117
155
  for (let i = 0; i < transcripts.length; i++) {
118
156
  const json = JSON.stringify(transcripts[i], null, 2);
@@ -123,5 +161,5 @@ export async function parse(options: ParseOptions): Promise<ParseResult> {
123
161
  console.error(`Wrote: ${outputPaths[i]}`);
124
162
  }
125
163
 
126
- return { transcripts, outputPaths };
164
+ return { transcripts, inputPath, outputPaths };
127
165
  }
package/src/render.ts CHANGED
@@ -216,7 +216,10 @@ function tracePath(target: string, parents: Map<string, string>): string[] {
216
216
  /**
217
217
  * Render transcript to markdown with branch awareness.
218
218
  */
219
- function renderTranscript(transcript: Transcript, head?: string): string {
219
+ export function renderTranscript(
220
+ transcript: Transcript,
221
+ head?: string,
222
+ ): string {
220
223
  const lines: string[] = [];
221
224
 
222
225
  // Header
@@ -345,10 +348,15 @@ export async function render(options: RenderOptions): Promise<void> {
345
348
  const { transcript, path: inputPath } = await readTranscript(options.input);
346
349
 
347
350
  const markdown = renderTranscript(transcript, options.head);
348
- const outputPath = getOutputPath(inputPath, options.output);
349
351
 
350
- // Ensure directory exists
351
- await mkdir(dirname(outputPath), { recursive: true });
352
- await Bun.write(outputPath, markdown);
353
- console.error(`Wrote: ${outputPath}`);
352
+ if (options.output) {
353
+ const outputPath = getOutputPath(inputPath, options.output);
354
+ // Ensure directory exists
355
+ await mkdir(dirname(outputPath), { recursive: true });
356
+ await Bun.write(outputPath, markdown);
357
+ console.error(`Wrote: ${outputPath}`);
358
+ } else {
359
+ // Default: print to stdout
360
+ console.log(markdown);
361
+ }
354
362
  }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Output file naming utilities.
3
+ *
4
+ * Generates descriptive filenames for transcripts:
5
+ * - With OpenRouter API key: yyyy-mm-dd-{llm-generated-slug}.{ext}
6
+ * - Without: yyyy-mm-dd-{input-filename-prefix}.{ext}
7
+ */
8
+
9
+ import type { Transcript, UserMessage } from "../types.ts";
10
+ import { basename } from "path";
11
+
12
+ export interface NamingOptions {
13
+ apiKey?: string; // OpenRouter API key
14
+ model?: string; // Default: google/gemini-2.0-flash-001
15
+ }
16
+
17
+ const DEFAULT_MODEL = "google/gemini-2.0-flash-001";
18
+ const SLUG_MAX_LENGTH = 40;
19
+
20
+ /**
21
+ * Extract date from transcript's first message timestamp.
22
+ */
23
+ function extractDate(transcript: Transcript): string {
24
+ const firstMessage = transcript.messages[0];
25
+ if (firstMessage?.timestamp) {
26
+ const date = new Date(firstMessage.timestamp);
27
+ if (!isNaN(date.getTime())) {
28
+ return date.toISOString().slice(0, 10); // yyyy-mm-dd
29
+ }
30
+ }
31
+ // Fallback to current date
32
+ return new Date().toISOString().slice(0, 10);
33
+ }
34
+
35
+ /**
36
+ * Extract context from transcript for LLM summarization.
37
+ * Uses first few user messages, truncated.
38
+ */
39
+ function extractContext(transcript: Transcript): string {
40
+ const userMessages = transcript.messages.filter(
41
+ (m): m is UserMessage => m.type === "user",
42
+ );
43
+
44
+ const chunks: string[] = [];
45
+ let totalLength = 0;
46
+ const maxLength = 500;
47
+
48
+ for (const msg of userMessages.slice(0, 3)) {
49
+ const content = msg.content.slice(0, 200);
50
+ if (totalLength + content.length > maxLength) break;
51
+ chunks.push(content);
52
+ totalLength += content.length;
53
+ }
54
+
55
+ return chunks.join("\n\n");
56
+ }
57
+
58
+ /**
59
+ * Sanitize a string into a valid URL slug.
60
+ */
61
+ function sanitizeSlug(input: string): string {
62
+ return input
63
+ .toLowerCase()
64
+ .replace(/[^a-z0-9\s-]/g, "") // remove special chars
65
+ .replace(/\s+/g, "-") // spaces to hyphens
66
+ .replace(/-+/g, "-") // collapse multiple hyphens
67
+ .replace(/^-|-$/g, "") // trim leading/trailing hyphens
68
+ .slice(0, SLUG_MAX_LENGTH);
69
+ }
70
+
71
+ /**
72
+ * Generate slug via OpenRouter API.
73
+ */
74
+ async function generateSlugViaLLM(
75
+ context: string,
76
+ options: NamingOptions,
77
+ ): Promise<string | null> {
78
+ const { apiKey, model = DEFAULT_MODEL } = options;
79
+ if (!apiKey || !context.trim()) return null;
80
+
81
+ try {
82
+ const response = await fetch(
83
+ "https://openrouter.ai/api/v1/chat/completions",
84
+ {
85
+ method: "POST",
86
+ headers: {
87
+ Authorization: `Bearer ${apiKey}`,
88
+ "Content-Type": "application/json",
89
+ },
90
+ body: JSON.stringify({
91
+ model,
92
+ messages: [
93
+ {
94
+ role: "user",
95
+ content: `Generate a 2-4 word URL slug (lowercase, hyphenated) summarizing this conversation topic. Reply with ONLY the slug, nothing else.\n\n${context}`,
96
+ },
97
+ ],
98
+ max_tokens: 20,
99
+ }),
100
+ },
101
+ );
102
+
103
+ if (!response.ok) {
104
+ console.error(
105
+ `OpenRouter API error: ${response.status} ${response.statusText}`,
106
+ );
107
+ return null;
108
+ }
109
+
110
+ const data = (await response.json()) as {
111
+ choices?: Array<{ message?: { content?: string } }>;
112
+ };
113
+ const content = data.choices?.[0]?.message?.content?.trim();
114
+
115
+ if (!content) return null;
116
+
117
+ const slug = sanitizeSlug(content);
118
+ return slug || null;
119
+ } catch (error) {
120
+ console.error(
121
+ `OpenRouter API call failed: ${error instanceof Error ? error.message : error}`,
122
+ );
123
+ return null;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Generate fallback slug from input filename.
129
+ */
130
+ function generateFallbackSlug(inputPath: string): string {
131
+ return extractFileId(inputPath, 8) || "transcript";
132
+ }
133
+
134
+ /**
135
+ * Extract a short identifier from the input filename.
136
+ * Used as a suffix for traceability back to source.
137
+ */
138
+ function extractFileId(inputPath: string, length = 6): string {
139
+ if (inputPath === "<stdin>") {
140
+ return "";
141
+ }
142
+
143
+ const name = basename(inputPath);
144
+ const base = name.replace(/\.jsonl?$/, "");
145
+ // Take first N chars, sanitize, and clean up any trailing hyphens
146
+ return sanitizeSlug(base.slice(0, length)).replace(/-+$/, "");
147
+ }
148
+
149
+ /**
150
+ * Generate output base name for a transcript.
151
+ * Returns string like "2024-01-15-implement-auth-flow-abc123"
152
+ */
153
+ export async function generateOutputName(
154
+ transcript: Transcript,
155
+ inputPath: string,
156
+ options: NamingOptions = {},
157
+ ): Promise<string> {
158
+ const date = extractDate(transcript);
159
+ const fileId = extractFileId(inputPath);
160
+
161
+ // Try LLM-generated slug if API key available
162
+ if (options.apiKey) {
163
+ const context = extractContext(transcript);
164
+ const slug = await generateSlugViaLLM(context, options);
165
+ if (slug) {
166
+ return fileId ? `${date}-${slug}-${fileId}` : `${date}-${slug}`;
167
+ }
168
+ }
169
+
170
+ // Fallback to input filename prefix (no need for fileId suffix, it's already the slug)
171
+ const slug = generateFallbackSlug(inputPath);
172
+ return `${date}-${slug}`;
173
+ }
@@ -0,0 +1,6 @@
1
+ {"type": "user", "uuid": "msg-1", "message": {"role": "user", "content": "Read the config file"}, "timestamp": "2024-01-15T10:00:00Z"}
2
+ {"type": "assistant", "uuid": "msg-2", "parentUuid": "msg-1", "message": {"role": "assistant", "content": [{"type": "text", "text": "I'll read the config file for you."}, {"type": "tool_use", "id": "tool-1", "name": "Read", "input": {"file_path": "/project/config.json"}}]}, "timestamp": "2024-01-15T10:00:02Z"}
3
+ {"type": "user", "uuid": "msg-3", "parentUuid": "msg-2", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tool-1", "content": "config contents here"}]}, "timestamp": "2024-01-15T10:00:03Z"}
4
+ {"type": "assistant", "uuid": "msg-4", "parentUuid": "msg-3", "message": {"role": "assistant", "content": [{"type": "text", "text": "The config file contains your settings."}]}, "timestamp": "2024-01-15T10:00:05Z"}
5
+ {"type": "user", "uuid": "msg-5", "parentUuid": "msg-4", "message": {"role": "user", "content": "Thanks!"}, "timestamp": "2024-01-15T10:00:10Z"}
6
+ {"type": "assistant", "uuid": "msg-6", "parentUuid": "msg-5", "message": {"role": "assistant", "content": [{"type": "text", "text": "You're welcome!"}]}, "timestamp": "2024-01-15T10:00:12Z"}
@@ -0,0 +1,28 @@
1
+ # Transcript
2
+
3
+ **Source**: `test/fixtures/claude/skipped-message-chain.input.jsonl`
4
+ **Adapter**: claude-code
5
+
6
+ ---
7
+
8
+ ## User
9
+
10
+ Read the config file
11
+
12
+ ## Assistant
13
+
14
+ I'll read the config file for you.
15
+
16
+ **Tool**: Read `/project/config.json`
17
+
18
+ ## Assistant
19
+
20
+ The config file contains your settings.
21
+
22
+ ## User
23
+
24
+ Thanks!
25
+
26
+ ## Assistant
27
+
28
+ You're welcome!