@arcreflex/agent-transcripts 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -15,6 +15,7 @@ src/
15
15
  cli.ts # CLI entry point, subcommand routing
16
16
  parse.ts # Source → intermediate JSON
17
17
  render.ts # Intermediate JSON → markdown
18
+ sync.ts # Batch sync sessions → markdown
18
19
  types.ts # Core types (Transcript, Message, Adapter)
19
20
  adapters/ # Source format adapters (currently: claude-code)
20
21
  utils/ # Helpers (summary extraction)
@@ -43,7 +44,7 @@ Two-stage pipeline: Parse (source → JSON) → Render (JSON → markdown).
43
44
 
44
45
  - `Transcript`: source info, warnings, messages array
45
46
  - `Message`: union of UserMessage | AssistantMessage | SystemMessage | ToolCallGroup | ErrorMessage
46
- - `Adapter`: `{ name: string, parse(content, sourcePath): Transcript[] }`
47
+ - `Adapter`: name, file patterns, parse function
47
48
 
48
49
  ## Adding an Adapter
49
50
 
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.4",
4
4
  "description": "Transform AI coding agent session files into readable transcripts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -335,6 +335,7 @@ function transformConversation(
335
335
 
336
336
  export const claudeCodeAdapter: Adapter = {
337
337
  name: "claude-code",
338
+ filePatterns: ["*.jsonl"],
338
339
 
339
340
  parse(content: string, sourcePath: string): Transcript[] {
340
341
  const { records, warnings } = parseJsonl(content);
@@ -43,3 +43,10 @@ export function getAdapter(name: string): Adapter | undefined {
43
43
  export function listAdapters(): string[] {
44
44
  return Object.keys(adapters);
45
45
  }
46
+
47
+ /**
48
+ * Get all registered adapters.
49
+ */
50
+ export function getAdapters(): Adapter[] {
51
+ return Object.values(adapters);
52
+ }
package/src/cli.ts CHANGED
@@ -10,9 +10,14 @@ import {
10
10
  option,
11
11
  optional,
12
12
  positional,
13
+ flag,
13
14
  } from "cmd-ts";
14
15
  import { parse, parseToTranscripts } from "./parse.ts";
15
16
  import { render, renderTranscript } from "./render.ts";
17
+ import { sync } from "./sync.ts";
18
+
19
+ // Read OpenRouter API key from environment for LLM-based slug generation
20
+ const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
16
21
 
17
22
  // Shared options
18
23
  const inputArg = positional({
@@ -51,8 +56,12 @@ const parseCmd = command({
51
56
  adapter: adapterOpt,
52
57
  },
53
58
  async handler({ input, output, adapter }) {
59
+ const naming = OPENROUTER_API_KEY
60
+ ? { apiKey: OPENROUTER_API_KEY }
61
+ : undefined;
62
+
54
63
  if (output) {
55
- await parse({ input, output, adapter });
64
+ await parse({ input, output, adapter, naming });
56
65
  } else {
57
66
  // Print JSONL to stdout (one transcript per line)
58
67
  const { transcripts } = await parseToTranscripts({ input, adapter });
@@ -77,6 +86,38 @@ const renderCmd = command({
77
86
  },
78
87
  });
79
88
 
89
+ // Sync subcommand
90
+ const syncCmd = command({
91
+ name: "sync",
92
+ description: "Sync session files to markdown transcripts",
93
+ args: {
94
+ source: positional({
95
+ type: string,
96
+ displayName: "source",
97
+ description: "Source directory to scan for session files",
98
+ }),
99
+ output: option({
100
+ type: string,
101
+ long: "output",
102
+ short: "o",
103
+ description: "Output directory (mirrors source structure)",
104
+ }),
105
+ force: flag({
106
+ long: "force",
107
+ short: "f",
108
+ description: "Re-render all sessions, ignoring mtime",
109
+ }),
110
+ quiet: flag({
111
+ long: "quiet",
112
+ short: "q",
113
+ description: "Suppress progress output",
114
+ }),
115
+ },
116
+ async handler({ source, output, force, quiet }) {
117
+ await sync({ source, output, force, quiet });
118
+ },
119
+ });
120
+
80
121
  // Default command: full pipeline (parse → render)
81
122
  const defaultCmd = command({
82
123
  name: "agent-transcripts",
@@ -88,9 +129,13 @@ const defaultCmd = command({
88
129
  head: headOpt,
89
130
  },
90
131
  async handler({ input, output, adapter, head }) {
132
+ const naming = OPENROUTER_API_KEY
133
+ ? { apiKey: OPENROUTER_API_KEY }
134
+ : undefined;
135
+
91
136
  if (output) {
92
137
  // Write intermediate JSON and markdown files
93
- const { outputPaths } = await parse({ input, output, adapter });
138
+ const { outputPaths } = await parse({ input, output, adapter, naming });
94
139
  for (const jsonPath of outputPaths) {
95
140
  const mdPath = jsonPath.replace(/\.json$/, ".md");
96
141
  await render({ input: jsonPath, output: mdPath, head });
@@ -113,6 +158,7 @@ const cli = subcommands({
113
158
  cmds: {
114
159
  parse: parseCmd,
115
160
  render: renderCmd,
161
+ sync: syncCmd,
116
162
  },
117
163
  // Default command when no subcommand is specified
118
164
  });
@@ -121,7 +167,7 @@ const cli = subcommands({
121
167
  const args = process.argv.slice(2);
122
168
 
123
169
  // Check if first arg is a subcommand
124
- if (args[0] === "parse" || args[0] === "render") {
170
+ if (args[0] === "parse" || args[0] === "render" || args[0] === "sync") {
125
171
  run(cli, args);
126
172
  } else {
127
173
  // Run default command for full pipeline
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);
package/src/sync.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Sync command: batch export sessions to markdown transcripts.
3
+ *
4
+ * Discovers session files in source directory, parses them,
5
+ * and writes rendered markdown to output directory.
6
+ * Output structure mirrors source structure with extension changed.
7
+ */
8
+
9
+ import { Glob } from "bun";
10
+ import { dirname, join, relative } from "path";
11
+ import { mkdir, stat } from "fs/promises";
12
+ import { getAdapters } from "./adapters/index.ts";
13
+ import type { Adapter } from "./types.ts";
14
+ import { renderTranscript } from "./render.ts";
15
+
16
+ export interface SyncOptions {
17
+ source: string;
18
+ output: string;
19
+ force?: boolean;
20
+ quiet?: boolean;
21
+ }
22
+
23
+ export interface SyncResult {
24
+ synced: number;
25
+ skipped: number;
26
+ errors: number;
27
+ }
28
+
29
+ interface SessionFile {
30
+ path: string;
31
+ relativePath: string;
32
+ mtime: number;
33
+ adapter: Adapter;
34
+ }
35
+
36
+ /**
37
+ * Discover session files for a specific adapter.
38
+ */
39
+ async function discoverForAdapter(
40
+ source: string,
41
+ adapter: Adapter,
42
+ ): Promise<SessionFile[]> {
43
+ const sessions: SessionFile[] = [];
44
+
45
+ for (const pattern of adapter.filePatterns) {
46
+ const glob = new Glob(`**/${pattern}`);
47
+
48
+ for await (const file of glob.scan({ cwd: source, absolute: false })) {
49
+ const fullPath = join(source, file);
50
+
51
+ try {
52
+ const fileStat = await stat(fullPath);
53
+ sessions.push({
54
+ path: fullPath,
55
+ relativePath: file,
56
+ mtime: fileStat.mtime.getTime(),
57
+ adapter,
58
+ });
59
+ } catch {
60
+ // Skip files we can't stat
61
+ }
62
+ }
63
+ }
64
+
65
+ return sessions;
66
+ }
67
+
68
+ /**
69
+ * Compute output path for a session file.
70
+ * Mirrors input structure, changing extension to .md.
71
+ */
72
+ function computeOutputPath(
73
+ relativePath: string,
74
+ outputDir: string,
75
+ suffix?: string,
76
+ ): string {
77
+ // Replace extension with .md
78
+ const mdPath = relativePath.replace(/\.[^.]+$/, ".md");
79
+ // Add suffix if provided (for multiple transcripts from same file)
80
+ const finalPath = suffix ? mdPath.replace(/\.md$/, `${suffix}.md`) : mdPath;
81
+ return join(outputDir, finalPath);
82
+ }
83
+
84
+ /**
85
+ * Check if output file needs to be re-rendered based on mtime.
86
+ */
87
+ async function needsSync(
88
+ outputPath: string,
89
+ sourceMtime: number,
90
+ force: boolean,
91
+ ): Promise<boolean> {
92
+ if (force) return true;
93
+
94
+ try {
95
+ const outputStat = await stat(outputPath);
96
+ return outputStat.mtime.getTime() < sourceMtime;
97
+ } catch {
98
+ // Output doesn't exist, needs sync
99
+ return true;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Sync session files from source to output directory.
105
+ */
106
+ export async function sync(options: SyncOptions): Promise<SyncResult> {
107
+ const { source, output, force = false, quiet = false } = options;
108
+
109
+ const result: SyncResult = { synced: 0, skipped: 0, errors: 0 };
110
+
111
+ // Discover sessions for each adapter
112
+ const sessions: SessionFile[] = [];
113
+ for (const adapter of getAdapters()) {
114
+ const adapterSessions = await discoverForAdapter(source, adapter);
115
+ sessions.push(...adapterSessions);
116
+ }
117
+
118
+ if (!quiet) {
119
+ console.error(`Found ${sessions.length} session file(s)`);
120
+ }
121
+
122
+ // Process each session
123
+ for (const session of sessions) {
124
+ try {
125
+ // Read and parse using the adapter that discovered this file
126
+ const content = await Bun.file(session.path).text();
127
+ const transcripts = session.adapter.parse(content, session.path);
128
+
129
+ // Process each transcript (usually just one per file)
130
+ for (let i = 0; i < transcripts.length; i++) {
131
+ const transcript = transcripts[i];
132
+ const suffix = transcripts.length > 1 ? `_${i + 1}` : undefined;
133
+ const outputPath = computeOutputPath(
134
+ session.relativePath,
135
+ output,
136
+ suffix,
137
+ );
138
+
139
+ // Check if sync needed
140
+ if (!(await needsSync(outputPath, session.mtime, force))) {
141
+ if (!quiet) {
142
+ console.error(`Skip (up to date): ${outputPath}`);
143
+ }
144
+ result.skipped++;
145
+ continue;
146
+ }
147
+
148
+ // Ensure output directory exists
149
+ await mkdir(dirname(outputPath), { recursive: true });
150
+
151
+ // Render and write
152
+ const markdown = renderTranscript(transcript);
153
+ await Bun.write(outputPath, markdown);
154
+
155
+ if (!quiet) {
156
+ console.error(`Synced: ${outputPath}`);
157
+ }
158
+ result.synced++;
159
+ }
160
+ } catch (error) {
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ console.error(`Error: ${session.relativePath}: ${message}`);
163
+ result.errors++;
164
+ }
165
+ }
166
+
167
+ // Summary
168
+ if (!quiet) {
169
+ console.error(
170
+ `\nSync complete: ${result.synced} synced, ${result.skipped} skipped, ${result.errors} errors`,
171
+ );
172
+ }
173
+
174
+ return result;
175
+ }
package/src/types.ts CHANGED
@@ -70,6 +70,8 @@ export interface ErrorMessage extends BaseMessage {
70
70
  */
71
71
  export interface Adapter {
72
72
  name: string;
73
+ /** Glob patterns for discovering session files (e.g., ["*.jsonl"]) */
74
+ filePatterns: string[];
73
75
  /** Parse source content into one or more transcripts (split by conversation) */
74
76
  parse(content: string, sourcePath: string): Transcript[];
75
77
  }
@@ -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
+ }