@arcreflex/agent-transcripts 0.1.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcreflex/agent-transcripts",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Transform AI coding agent session files into readable transcripts",
5
5
  "type": "module",
6
6
  "repository": {
package/src/cli.ts CHANGED
@@ -14,6 +14,9 @@ import {
14
14
  import { parse, parseToTranscripts } from "./parse.ts";
15
15
  import { render, renderTranscript } from "./render.ts";
16
16
 
17
+ // Read OpenRouter API key from environment for LLM-based slug generation
18
+ const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
19
+
17
20
  // Shared options
18
21
  const inputArg = positional({
19
22
  type: optional(string),
@@ -51,8 +54,12 @@ const parseCmd = command({
51
54
  adapter: adapterOpt,
52
55
  },
53
56
  async handler({ input, output, adapter }) {
57
+ const naming = OPENROUTER_API_KEY
58
+ ? { apiKey: OPENROUTER_API_KEY }
59
+ : undefined;
60
+
54
61
  if (output) {
55
- await parse({ input, output, adapter });
62
+ await parse({ input, output, adapter, naming });
56
63
  } else {
57
64
  // Print JSONL to stdout (one transcript per line)
58
65
  const { transcripts } = await parseToTranscripts({ input, adapter });
@@ -88,9 +95,13 @@ const defaultCmd = command({
88
95
  head: headOpt,
89
96
  },
90
97
  async handler({ input, output, adapter, head }) {
98
+ const naming = OPENROUTER_API_KEY
99
+ ? { apiKey: OPENROUTER_API_KEY }
100
+ : undefined;
101
+
91
102
  if (output) {
92
103
  // Write intermediate JSON and markdown files
93
- const { outputPaths } = await parse({ input, output, adapter });
104
+ const { outputPaths } = await parse({ input, output, adapter, naming });
94
105
  for (const jsonPath of outputPaths) {
95
106
  const mdPath = jsonPath.replace(/\.json$/, ".md");
96
107
  await render({ input: jsonPath, output: mdPath, head });
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,14 +67,33 @@ 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 {
@@ -127,7 +145,12 @@ export async function parse(
127
145
  const { transcripts, inputPath } = await parseToTranscripts(options);
128
146
 
129
147
  // Write output files
130
- const outputPaths = getOutputPaths(transcripts, inputPath, options.output);
148
+ const outputPaths = await getOutputPaths(
149
+ transcripts,
150
+ inputPath,
151
+ options.output,
152
+ options.naming,
153
+ );
131
154
 
132
155
  for (let i = 0; i < transcripts.length; i++) {
133
156
  const json = JSON.stringify(transcripts[i], null, 2);
@@ -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
+ }