@arcreflex/agent-transcripts 0.1.5 → 0.1.9

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/src/cli.ts CHANGED
@@ -7,18 +7,28 @@ import {
7
7
  subcommands,
8
8
  run,
9
9
  string,
10
+ number,
10
11
  option,
11
12
  optional,
12
13
  positional,
13
14
  flag,
14
15
  } from "cmd-ts";
15
- import { parse, parseToTranscripts } from "./parse.ts";
16
- import { render, renderTranscript } from "./render.ts";
17
- import { sync } from "./sync.ts";
16
+ import { parseToTranscripts } from "./parse.ts";
17
+ import { renderTranscript } from "./render.ts";
18
+ import { sync, type OutputFormat } from "./sync.ts";
18
19
  import { convertToDirectory } from "./convert.ts";
19
-
20
- // Read OpenRouter API key from environment for LLM-based slug generation
21
- const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
20
+ import { generateTitles } from "./title.ts";
21
+ import { serve } from "./serve.ts";
22
+
23
+ // Custom type for format option
24
+ const formatType = {
25
+ async from(value: string): Promise<OutputFormat> {
26
+ if (value !== "md" && value !== "html") {
27
+ throw new Error(`Invalid format: ${value}. Must be "md" or "html".`);
28
+ }
29
+ return value;
30
+ },
31
+ };
22
32
 
23
33
  // Shared options
24
34
  const inputArg = positional({
@@ -31,7 +41,7 @@ const outputOpt = option({
31
41
  type: optional(string),
32
42
  long: "output",
33
43
  short: "o",
34
- description: "Output path (prints to stdout if not specified)",
44
+ description: "Output directory (prints to stdout if not specified)",
35
45
  });
36
46
 
37
47
  const adapterOpt = option({
@@ -47,50 +57,10 @@ const headOpt = option({
47
57
  description: "Render branch ending at this message ID (default: latest)",
48
58
  });
49
59
 
50
- // Parse subcommand
51
- const parseCmd = command({
52
- name: "parse",
53
- description: "Parse source format to intermediate JSON",
54
- args: {
55
- input: inputArg,
56
- output: outputOpt,
57
- adapter: adapterOpt,
58
- },
59
- async handler({ input, output, adapter }) {
60
- const naming = OPENROUTER_API_KEY
61
- ? { apiKey: OPENROUTER_API_KEY }
62
- : undefined;
63
-
64
- if (output) {
65
- await parse({ input, output, adapter, naming });
66
- } else {
67
- // Print JSONL to stdout (one transcript per line)
68
- const { transcripts } = await parseToTranscripts({ input, adapter });
69
- for (const transcript of transcripts) {
70
- console.log(JSON.stringify(transcript));
71
- }
72
- }
73
- },
74
- });
75
-
76
- // Render subcommand
77
- const renderCmd = command({
78
- name: "render",
79
- description: "Render intermediate JSON to markdown",
80
- args: {
81
- input: inputArg,
82
- output: outputOpt,
83
- head: headOpt,
84
- },
85
- async handler({ input, output, head }) {
86
- await render({ input, output, head });
87
- },
88
- });
89
-
90
60
  // Sync subcommand
91
61
  const syncCmd = command({
92
62
  name: "sync",
93
- description: "Sync session files to markdown transcripts",
63
+ description: "Sync session files to transcripts (markdown or HTML)",
94
64
  args: {
95
65
  source: positional({
96
66
  type: string,
@@ -103,6 +73,15 @@ const syncCmd = command({
103
73
  short: "o",
104
74
  description: "Output directory for transcripts",
105
75
  }),
76
+ format: option({
77
+ type: optional(formatType),
78
+ long: "format",
79
+ description: "Output format: md (default) or html",
80
+ }),
81
+ noTitle: flag({
82
+ long: "no-title",
83
+ description: "Skip LLM title generation (for HTML format)",
84
+ }),
106
85
  force: flag({
107
86
  long: "force",
108
87
  short: "f",
@@ -114,11 +93,65 @@ const syncCmd = command({
114
93
  description: "Suppress progress output",
115
94
  }),
116
95
  },
117
- async handler({ source, output, force, quiet }) {
118
- const naming = OPENROUTER_API_KEY
119
- ? { apiKey: OPENROUTER_API_KEY }
120
- : undefined;
121
- await sync({ source, output, force, quiet, naming });
96
+ async handler({ source, output, format, noTitle, force, quiet }) {
97
+ await sync({ source, output, format, noTitle, force, quiet });
98
+ },
99
+ });
100
+
101
+ // Title subcommand
102
+ const titleCmd = command({
103
+ name: "title",
104
+ description: "Generate LLM titles for transcripts.json entries",
105
+ args: {
106
+ output: positional({
107
+ type: string,
108
+ displayName: "output",
109
+ description: "Output directory containing transcripts.json",
110
+ }),
111
+ force: flag({
112
+ long: "force",
113
+ short: "f",
114
+ description: "Regenerate all titles, not just missing ones",
115
+ }),
116
+ quiet: flag({
117
+ long: "quiet",
118
+ short: "q",
119
+ description: "Suppress progress output",
120
+ }),
121
+ },
122
+ async handler({ output, force, quiet }) {
123
+ await generateTitles({ outputDir: output, force, quiet });
124
+ },
125
+ });
126
+
127
+ // Serve subcommand
128
+ const serveCmd = command({
129
+ name: "serve",
130
+ description: "Serve transcripts via HTTP (dynamic rendering with caching)",
131
+ args: {
132
+ source: positional({
133
+ type: string,
134
+ displayName: "source",
135
+ description: "Source directory to scan for session files",
136
+ }),
137
+ port: option({
138
+ type: optional(number),
139
+ long: "port",
140
+ short: "p",
141
+ description: "Port to listen on (default: 3000)",
142
+ }),
143
+ quiet: flag({
144
+ long: "quiet",
145
+ short: "q",
146
+ description: "Suppress request logging",
147
+ }),
148
+ noCache: flag({
149
+ long: "no-cache",
150
+ description: "Bypass HTML cache (for development)",
151
+ }),
152
+ },
153
+ async handler({ source, port, quiet, noCache }) {
154
+ await serve({ source, port: port ?? 3000, quiet, noCache });
122
155
  },
123
156
  });
124
157
 
@@ -140,26 +173,20 @@ const convertCmd = command({
140
173
  head: headOpt,
141
174
  },
142
175
  async handler({ input, output, adapter, head }) {
143
- const naming = OPENROUTER_API_KEY
144
- ? { apiKey: OPENROUTER_API_KEY }
145
- : undefined;
146
-
147
176
  if (output && isDirectoryOutput(output)) {
148
- // Directory output: use sync-like behavior with provenance tracking
177
+ // Directory output: use provenance tracking
149
178
  await convertToDirectory({
150
179
  input,
151
180
  outputDir: output,
152
181
  adapter,
153
182
  head,
154
- naming,
155
183
  });
156
184
  } else if (output) {
157
- // Explicit file output: write intermediate JSON and markdown
158
- const { outputPaths } = await parse({ input, output, adapter, naming });
159
- for (const jsonPath of outputPaths) {
160
- const mdPath = jsonPath.replace(/\.json$/, ".md");
161
- await render({ input: jsonPath, output: mdPath, head });
162
- }
185
+ // Explicit file output: not supported anymore (use directory)
186
+ console.error(
187
+ "Error: Explicit file output not supported. Use a directory path instead.",
188
+ );
189
+ process.exit(1);
163
190
  } else {
164
191
  // No output: stream to stdout
165
192
  const { transcripts } = await parseToTranscripts({ input, adapter });
@@ -171,7 +198,7 @@ const convertCmd = command({
171
198
  },
172
199
  });
173
200
 
174
- const SUBCOMMANDS = ["convert", "parse", "render", "sync"] as const;
201
+ const SUBCOMMANDS = ["convert", "sync", "title", "serve"] as const;
175
202
 
176
203
  // Main CLI with subcommands
177
204
  const cli = subcommands({
@@ -179,9 +206,9 @@ const cli = subcommands({
179
206
  description: "Transform agent session files to readable transcripts",
180
207
  cmds: {
181
208
  convert: convertCmd,
182
- parse: parseCmd,
183
- render: renderCmd,
184
209
  sync: syncCmd,
210
+ title: titleCmd,
211
+ serve: serveCmd,
185
212
  },
186
213
  });
187
214
 
package/src/convert.ts CHANGED
@@ -1,18 +1,24 @@
1
1
  /**
2
2
  * Convert command: full pipeline with provenance tracking.
3
3
  *
4
- * When output is a directory, uses the same replace-existing behavior
5
- * as sync: scans for existing outputs by provenance and replaces them.
4
+ * When output is a directory, uses provenance tracking via transcripts.json
5
+ * index to manage output files.
6
6
  */
7
7
 
8
- import { dirname, join, resolve } from "path";
9
- import { mkdir, stat } from "fs/promises";
8
+ import { join } from "path";
9
+ import { mkdir } from "fs/promises";
10
10
  import { parseToTranscripts } from "./parse.ts";
11
11
  import { renderTranscript } from "./render.ts";
12
- import { generateOutputName, type NamingOptions } from "./utils/naming.ts";
12
+ import { generateOutputName, extractSessionId } from "./utils/naming.ts";
13
13
  import {
14
- findExistingOutputs,
15
- deleteExistingOutputs,
14
+ loadIndex,
15
+ saveIndex,
16
+ removeEntriesForSource,
17
+ restoreEntries,
18
+ deleteOutputFiles,
19
+ setEntry,
20
+ normalizeSourcePath,
21
+ extractFirstUserMessage,
16
22
  } from "./utils/provenance.ts";
17
23
 
18
24
  export interface ConvertToDirectoryOptions {
@@ -20,7 +26,6 @@ export interface ConvertToDirectoryOptions {
20
26
  outputDir: string;
21
27
  adapter?: string;
22
28
  head?: string;
23
- naming?: NamingOptions;
24
29
  }
25
30
 
26
31
  /**
@@ -30,7 +35,10 @@ export interface ConvertToDirectoryOptions {
30
35
  export async function convertToDirectory(
31
36
  options: ConvertToDirectoryOptions,
32
37
  ): Promise<void> {
33
- const { input, outputDir, adapter, head, naming } = options;
38
+ const { input, outputDir, adapter, head } = options;
39
+
40
+ // Ensure output directory exists
41
+ await mkdir(outputDir, { recursive: true });
34
42
 
35
43
  // Parse input to transcripts
36
44
  const { transcripts, inputPath } = await parseToTranscripts({
@@ -38,41 +46,73 @@ export async function convertToDirectory(
38
46
  adapter,
39
47
  });
40
48
 
41
- // Resolve absolute source path for provenance tracking
42
- const sourcePath = inputPath === "<stdin>" ? "<stdin>" : resolve(inputPath);
49
+ // Normalize source path for consistent index keys
50
+ const sourcePath = normalizeSourcePath(inputPath);
51
+
52
+ // Load index and handle existing outputs
53
+ const index = await loadIndex(outputDir);
54
+
55
+ // Remove old entries (save for restoration on error)
56
+ const removedEntries =
57
+ sourcePath !== "<stdin>" ? removeEntriesForSource(index, sourcePath) : [];
58
+
59
+ const sessionId = extractSessionId(inputPath);
60
+ const newOutputs: string[] = [];
61
+
62
+ try {
63
+ // Generate fresh outputs
64
+ for (let i = 0; i < transcripts.length; i++) {
65
+ const transcript = transcripts[i];
66
+ const segmentIndex = transcripts.length > 1 ? i + 1 : undefined;
67
+
68
+ // Generate deterministic name
69
+ const baseName = generateOutputName(transcript, inputPath);
70
+ const suffix = segmentIndex ? `_${segmentIndex}` : "";
71
+ const relativePath = `${baseName}${suffix}.md`;
72
+ const outputPath = join(outputDir, relativePath);
43
73
 
44
- // Find and delete existing outputs for this source
45
- if (sourcePath !== "<stdin>") {
46
- const existingOutputs = await findExistingOutputs(outputDir, sourcePath);
47
- if (existingOutputs.length > 0) {
48
- await deleteExistingOutputs(existingOutputs);
74
+ // Render with provenance front matter
75
+ const markdown = renderTranscript(transcript, {
76
+ head,
77
+ sourcePath: sourcePath !== "<stdin>" ? sourcePath : undefined,
78
+ });
79
+ await Bun.write(outputPath, markdown);
80
+ newOutputs.push(relativePath);
81
+
82
+ // Update index (only for non-stdin sources)
83
+ if (sourcePath !== "<stdin>") {
84
+ setEntry(index, relativePath, {
85
+ source: sourcePath,
86
+ sessionId,
87
+ segmentIndex,
88
+ syncedAt: new Date().toISOString(),
89
+ firstUserMessage: extractFirstUserMessage(transcript),
90
+ messageCount: transcript.metadata.messageCount,
91
+ startTime: transcript.metadata.startTime,
92
+ endTime: transcript.metadata.endTime,
93
+ cwd: transcript.metadata.cwd,
94
+ });
95
+ }
96
+
97
+ console.error(`Wrote: ${outputPath}`);
49
98
  }
50
- }
51
99
 
52
- // Generate fresh outputs
53
- for (let i = 0; i < transcripts.length; i++) {
54
- const transcript = transcripts[i];
55
- const suffix = transcripts.length > 1 ? `_${i + 1}` : undefined;
56
-
57
- // Generate descriptive name
58
- const baseName = await generateOutputName(
59
- transcript,
60
- inputPath,
61
- naming || {},
62
- );
63
- const finalName = suffix ? `${baseName}${suffix}` : baseName;
64
- const outputPath = join(outputDir, `${finalName}.md`);
65
-
66
- // Ensure output directory exists
67
- await mkdir(dirname(outputPath), { recursive: true });
68
-
69
- // Render with provenance front matter
70
- const markdown = renderTranscript(transcript, {
71
- head,
72
- sourcePath: sourcePath !== "<stdin>" ? sourcePath : undefined,
73
- });
74
- await Bun.write(outputPath, markdown);
75
-
76
- console.error(`Wrote: ${outputPath}`);
100
+ // Success: delete old output files (after new ones are written)
101
+ const oldFilenames = removedEntries.map((e) => e.filename);
102
+ const toDelete = oldFilenames.filter((f) => !newOutputs.includes(f));
103
+ if (toDelete.length > 0) {
104
+ await deleteOutputFiles(outputDir, toDelete);
105
+ }
106
+ } catch (error) {
107
+ // Clean up any newly written files before restoring old entries
108
+ if (newOutputs.length > 0) {
109
+ await deleteOutputFiles(outputDir, newOutputs);
110
+ }
111
+ // Restore old entries on error to preserve provenance
112
+ restoreEntries(index, removedEntries);
113
+ throw error;
77
114
  }
115
+
116
+ // Save index
117
+ await saveIndex(outputDir, index);
78
118
  }
package/src/parse.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  /**
2
- * Parse command: source format → intermediate JSON
2
+ * Parse: source format → intermediate transcript format
3
3
  */
4
4
 
5
- import { dirname, join } from "path";
6
- import { mkdir } from "fs/promises";
7
5
  import type { Transcript } from "./types.ts";
8
6
  import { detectAdapter, getAdapter, listAdapters } from "./adapters/index.ts";
9
- import { generateOutputName, type NamingOptions } from "./utils/naming.ts";
10
7
 
11
8
  export interface ParseOptions {
12
9
  input: string; // file path, or "-" for stdin
13
- output?: string; // output path/dir
14
10
  adapter?: string; // explicit adapter name
15
- naming?: NamingOptions; // options for output file naming
11
+ }
12
+
13
+ export interface ParseResult {
14
+ transcripts: Transcript[];
15
+ inputPath: string;
16
16
  }
17
17
 
18
18
  /**
@@ -40,73 +40,7 @@ async function readInput(
40
40
  }
41
41
 
42
42
  /**
43
- * Determine output file paths for transcripts.
44
- */
45
- async function getOutputPaths(
46
- transcripts: Transcript[],
47
- inputPath: string,
48
- outputOption?: string,
49
- namingOptions?: NamingOptions,
50
- ): Promise<string[]> {
51
- // Determine output directory
52
- let outputDir: string;
53
- let explicitBaseName: string | undefined;
54
-
55
- if (outputOption) {
56
- // If output looks like a file (has extension), use its directory and name
57
- if (outputOption.match(/\.\w+$/)) {
58
- outputDir = dirname(outputOption);
59
- explicitBaseName = outputOption
60
- .split("/")
61
- .pop()!
62
- .replace(/\.\w+$/, "");
63
- } else {
64
- outputDir = outputOption;
65
- }
66
- } else {
67
- outputDir = process.cwd();
68
- }
69
-
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`));
94
- }
95
-
96
- return paths;
97
- }
98
-
99
- export interface ParseResult {
100
- transcripts: Transcript[];
101
- inputPath: string;
102
- }
103
-
104
- export interface ParseAndWriteResult extends ParseResult {
105
- outputPaths: string[];
106
- }
107
-
108
- /**
109
- * Parse source file(s) to transcripts (no file I/O beyond reading input).
43
+ * Parse source file(s) to transcripts.
110
44
  */
111
45
  export async function parseToTranscripts(
112
46
  options: ParseOptions,
@@ -135,31 +69,3 @@ export async function parseToTranscripts(
135
69
  const transcripts = adapter.parse(content, inputPath);
136
70
  return { transcripts, inputPath };
137
71
  }
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);
146
-
147
- // Write output files
148
- const outputPaths = await getOutputPaths(
149
- transcripts,
150
- inputPath,
151
- options.output,
152
- options.naming,
153
- );
154
-
155
- for (let i = 0; i < transcripts.length; i++) {
156
- const json = JSON.stringify(transcripts[i], null, 2);
157
- // Ensure directory exists
158
- const dir = dirname(outputPaths[i]);
159
- await mkdir(dir, { recursive: true });
160
- await Bun.write(outputPaths[i], json);
161
- console.error(`Wrote: ${outputPaths[i]}`);
162
- }
163
-
164
- return { transcripts, inputPath, outputPaths };
165
- }