@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/sync.ts CHANGED
@@ -1,31 +1,51 @@
1
1
  /**
2
- * Sync command: batch export sessions to markdown transcripts.
2
+ * Sync command: batch export sessions to transcripts.
3
3
  *
4
4
  * Discovers session files in source directory, parses them,
5
- * and writes rendered markdown to output directory.
6
- * Uses LLM-generated descriptive names when API key is available.
7
- * Tracks provenance via YAML front matter to correlate updates.
5
+ * and writes rendered output (markdown or HTML) to output directory.
6
+ * Tracks provenance via transcripts.json index.
8
7
  */
9
8
 
10
- import { Glob } from "bun";
11
9
  import { dirname, join } from "path";
12
- import { mkdir, stat } from "fs/promises";
10
+ import { mkdir } from "fs/promises";
11
+ import { existsSync } from "fs";
13
12
  import { getAdapters } from "./adapters/index.ts";
14
- import type { Adapter } from "./types.ts";
13
+ import type { Adapter, DiscoveredSession, Transcript } from "./types.ts";
15
14
  import { renderTranscript } from "./render.ts";
16
- import { generateOutputName, type NamingOptions } from "./utils/naming.ts";
15
+ import { renderTranscriptHtml } from "./render-html.ts";
16
+ import { renderIndex } from "./render-index.ts";
17
+ import { generateOutputName, extractSessionId } from "./utils/naming.ts";
17
18
  import {
18
- scanOutputDirectory,
19
- deleteExistingOutputs,
20
- hasStaleOutputs,
19
+ loadIndex,
20
+ saveIndex,
21
+ setEntry,
22
+ removeEntriesForSource,
23
+ restoreEntries,
24
+ deleteOutputFiles,
25
+ normalizeSourcePath,
26
+ extractFirstUserMessage,
27
+ getOutputsForSource,
28
+ type TranscriptsIndex,
21
29
  } from "./utils/provenance.ts";
30
+ import { generateTitles } from "./title.ts";
31
+ import {
32
+ computeContentHash,
33
+ loadCache,
34
+ saveCache,
35
+ getCachedSegments,
36
+ type CacheEntry,
37
+ type SegmentCache,
38
+ } from "./cache.ts";
39
+
40
+ export type OutputFormat = "md" | "html";
22
41
 
23
42
  export interface SyncOptions {
24
43
  source: string;
25
44
  output: string;
45
+ format?: OutputFormat;
46
+ noTitle?: boolean;
26
47
  force?: boolean;
27
48
  quiet?: boolean;
28
- naming?: NamingOptions;
29
49
  }
30
50
 
31
51
  export interface SyncResult {
@@ -34,70 +54,76 @@ export interface SyncResult {
34
54
  errors: number;
35
55
  }
36
56
 
37
- interface SessionFile {
38
- path: string;
39
- relativePath: string;
40
- mtime: number;
57
+ interface SessionFile extends DiscoveredSession {
41
58
  adapter: Adapter;
42
59
  }
43
60
 
44
61
  /**
45
- * Discover session files for a specific adapter.
62
+ * Render a transcript to the specified format.
46
63
  */
47
- async function discoverForAdapter(
48
- source: string,
49
- adapter: Adapter,
50
- ): Promise<SessionFile[]> {
51
- const sessions: SessionFile[] = [];
52
-
53
- for (const pattern of adapter.filePatterns) {
54
- const glob = new Glob(`**/${pattern}`);
55
-
56
- for await (const file of glob.scan({ cwd: source, absolute: false })) {
57
- const fullPath = join(source, file);
58
-
59
- try {
60
- const fileStat = await stat(fullPath);
61
- sessions.push({
62
- path: fullPath,
63
- relativePath: file,
64
- mtime: fileStat.mtime.getTime(),
65
- adapter,
66
- });
67
- } catch {
68
- // Skip files we can't stat
69
- }
70
- }
64
+ function renderToFormat(
65
+ transcript: Transcript,
66
+ format: OutputFormat,
67
+ options: { sourcePath?: string; title?: string },
68
+ ): Promise<string> {
69
+ if (format === "html") {
70
+ return renderTranscriptHtml(transcript, { title: options.title });
71
71
  }
72
+ return Promise.resolve(
73
+ renderTranscript(transcript, { sourcePath: options.sourcePath }),
74
+ );
75
+ }
72
76
 
73
- return sessions;
77
+ /**
78
+ * Generate index.html for HTML output.
79
+ */
80
+ async function writeIndexHtml(
81
+ outputDir: string,
82
+ index: TranscriptsIndex,
83
+ quiet: boolean,
84
+ ): Promise<void> {
85
+ const indexHtml = renderIndex(index);
86
+ const indexPath = join(outputDir, "index.html");
87
+ await Bun.write(indexPath, indexHtml);
88
+ if (!quiet) {
89
+ console.error(`Generated: ${indexPath}`);
90
+ }
74
91
  }
75
92
 
76
93
  /**
77
94
  * Sync session files from source to output directory.
78
95
  */
79
96
  export async function sync(options: SyncOptions): Promise<SyncResult> {
80
- const { source, output, force = false, quiet = false, naming } = options;
97
+ const {
98
+ source,
99
+ output,
100
+ format = "md",
101
+ noTitle = false,
102
+ force = false,
103
+ quiet = false,
104
+ } = options;
81
105
 
106
+ const ext = format === "html" ? ".html" : ".md";
82
107
  const result: SyncResult = { synced: 0, skipped: 0, errors: 0 };
83
108
 
84
- // Scan output directory for existing transcripts (source → output paths)
85
- const existingOutputs = await scanOutputDirectory(output);
86
- if (!quiet && existingOutputs.size > 0) {
87
- const totalFiles = [...existingOutputs.values()].reduce(
88
- (sum, paths) => sum + paths.length,
89
- 0,
90
- );
109
+ // Ensure output directory exists
110
+ await mkdir(output, { recursive: true });
111
+
112
+ // Load index
113
+ const index = await loadIndex(output);
114
+ if (!quiet && Object.keys(index.entries).length > 0) {
91
115
  console.error(
92
- `Found ${totalFiles} existing transcript(s) from ${existingOutputs.size} source(s)`,
116
+ `Found ${Object.keys(index.entries).length} existing transcript(s) in index`,
93
117
  );
94
118
  }
95
119
 
96
- // Discover sessions for each adapter
120
+ // Discover sessions from all adapters
97
121
  const sessions: SessionFile[] = [];
98
122
  for (const adapter of getAdapters()) {
99
- const adapterSessions = await discoverForAdapter(source, adapter);
100
- sessions.push(...adapterSessions);
123
+ const discovered = await adapter.discover(source);
124
+ for (const session of discovered) {
125
+ sessions.push({ ...session, adapter });
126
+ }
101
127
  }
102
128
 
103
129
  if (!quiet) {
@@ -106,23 +132,26 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
106
132
 
107
133
  // Process each session
108
134
  for (const session of sessions) {
135
+ // Normalize source path for consistent index keys
136
+ const sourcePath = normalizeSourcePath(session.path);
137
+
109
138
  try {
110
- // Read and parse using the adapter that discovered this file
139
+ // Read source and compute content hash
111
140
  const content = await Bun.file(session.path).text();
112
- const transcripts = session.adapter.parse(content, session.path);
141
+ const contentHash = computeContentHash(content);
142
+
143
+ // Check cache
144
+ const cached = await loadCache(sourcePath);
145
+ const cachedSegments = getCachedSegments(cached, contentHash, format);
146
+
147
+ // Check if we can use cached output
148
+ const existingOutputs = getOutputsForSource(index, sourcePath);
149
+ const outputsExist =
150
+ existingOutputs.length > 0 &&
151
+ existingOutputs.every((f) => existsSync(join(output, f)));
113
152
 
114
- // Get all existing outputs for this source
115
- const existingPaths = existingOutputs.get(session.path) || [];
116
-
117
- // Check if sync needed (force, count mismatch, or any stale)
118
- const needsUpdate =
119
- force ||
120
- (await hasStaleOutputs(
121
- existingPaths,
122
- transcripts.length,
123
- session.mtime,
124
- ));
125
- if (!needsUpdate) {
153
+ if (!force && cachedSegments && outputsExist) {
154
+ // Cache hit and outputs exist - skip
126
155
  if (!quiet) {
127
156
  console.error(`Skip (up to date): ${session.relativePath}`);
128
157
  }
@@ -130,39 +159,104 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
130
159
  continue;
131
160
  }
132
161
 
133
- // Delete existing outputs before regenerating
134
- await deleteExistingOutputs(existingPaths, quiet);
135
-
136
- // Generate fresh outputs for all transcripts
137
- for (let i = 0; i < transcripts.length; i++) {
138
- const transcript = transcripts[i];
139
- const suffix = transcripts.length > 1 ? `_${i + 1}` : undefined;
140
-
141
- // Generate descriptive name, preserving directory structure
142
- const baseName = await generateOutputName(
143
- transcript,
144
- session.path,
145
- naming || {},
146
- );
147
- const finalName = suffix ? `${baseName}${suffix}` : baseName;
148
- const relativeDir = dirname(session.relativePath);
149
- const outputPath = join(output, relativeDir, `${finalName}.md`);
150
-
151
- // Ensure output directory exists
152
- await mkdir(dirname(outputPath), { recursive: true });
153
-
154
- // Render with provenance front matter and write
155
- const markdown = renderTranscript(transcript, {
156
- sourcePath: session.path,
157
- });
158
- await Bun.write(outputPath, markdown);
162
+ // Need to sync: either cache miss, content changed, or force
163
+ // Parse the source
164
+ const transcripts = session.adapter.parse(content, session.path);
159
165
 
160
- if (!quiet) {
161
- console.error(`Synced: ${outputPath}`);
166
+ // Remove entries from index (save for potential restoration on error)
167
+ const removedEntries = removeEntriesForSource(index, sourcePath);
168
+
169
+ // Track new outputs for this session
170
+ const newOutputs: string[] = [];
171
+ const sessionId = extractSessionId(session.path);
172
+
173
+ // Build new cache entry
174
+ const newCache: CacheEntry = {
175
+ contentHash,
176
+ segments: [],
177
+ };
178
+
179
+ try {
180
+ // Generate fresh outputs for all transcripts
181
+ for (let i = 0; i < transcripts.length; i++) {
182
+ const transcript = transcripts[i];
183
+ const segmentIndex = transcripts.length > 1 ? i + 1 : undefined;
184
+
185
+ // Extract first user message
186
+ const firstUserMessage = extractFirstUserMessage(transcript);
187
+
188
+ // Generate deterministic name
189
+ const baseName = generateOutputName(transcript, session.path);
190
+ const suffix = segmentIndex ? `_${segmentIndex}` : "";
191
+ const relativeDir = dirname(session.relativePath);
192
+ const relativePath =
193
+ relativeDir === "."
194
+ ? `${baseName}${suffix}${ext}`
195
+ : join(relativeDir, `${baseName}${suffix}${ext}`);
196
+ const outputPath = join(output, relativePath);
197
+
198
+ // Ensure output directory exists
199
+ await mkdir(dirname(outputPath), { recursive: true });
200
+
201
+ // Preserve title from cache if content unchanged
202
+ const cachedTitle =
203
+ cached?.contentHash === contentHash
204
+ ? cached.segments[i]?.title
205
+ : undefined;
206
+
207
+ // Render and write
208
+ const rendered = await renderToFormat(transcript, format, {
209
+ sourcePath,
210
+ title: cachedTitle,
211
+ });
212
+ await Bun.write(outputPath, rendered);
213
+ newOutputs.push(relativePath);
214
+
215
+ // Build segment cache
216
+ const segmentCache: SegmentCache = { title: cachedTitle };
217
+ segmentCache[format] = rendered;
218
+ newCache.segments.push(segmentCache);
219
+
220
+ // Update index
221
+ setEntry(index, relativePath, {
222
+ source: sourcePath,
223
+ sessionId,
224
+ segmentIndex,
225
+ syncedAt: new Date().toISOString(),
226
+ firstUserMessage,
227
+ title: cachedTitle,
228
+ messageCount: transcript.metadata.messageCount,
229
+ startTime: transcript.metadata.startTime,
230
+ endTime: transcript.metadata.endTime,
231
+ cwd: transcript.metadata.cwd,
232
+ });
233
+
234
+ if (!quiet) {
235
+ console.error(`Synced: ${outputPath}`);
236
+ }
237
+ }
238
+
239
+ // Save cache
240
+ await saveCache(sourcePath, newCache);
241
+
242
+ // Success: delete old output files (after new ones are written)
243
+ const oldFilenames = removedEntries.map((e) => e.filename);
244
+ // Only delete files that aren't being reused
245
+ const toDelete = oldFilenames.filter((f) => !newOutputs.includes(f));
246
+ if (toDelete.length > 0) {
247
+ await deleteOutputFiles(output, toDelete, quiet);
162
248
  }
163
- }
164
249
 
165
- result.synced++;
250
+ result.synced++;
251
+ } catch (error) {
252
+ // Clean up any newly written files before restoring old entries
253
+ if (newOutputs.length > 0) {
254
+ await deleteOutputFiles(output, newOutputs, quiet);
255
+ }
256
+ // Restore old entries on error to preserve provenance
257
+ restoreEntries(index, removedEntries);
258
+ throw error;
259
+ }
166
260
  } catch (error) {
167
261
  const message = error instanceof Error ? error.message : String(error);
168
262
  console.error(`Error: ${session.relativePath}: ${message}`);
@@ -170,6 +264,25 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
170
264
  }
171
265
  }
172
266
 
267
+ // Save index
268
+ await saveIndex(output, index);
269
+
270
+ // Generate titles for HTML format (unless --no-title)
271
+ if (format === "html" && !noTitle) {
272
+ if (!quiet) {
273
+ console.error("\nGenerating titles...");
274
+ }
275
+ await generateTitles({ outputDir: output, quiet });
276
+
277
+ // Reload index after title generation and regenerate index.html
278
+ const updatedIndex = await loadIndex(output);
279
+ await writeIndexHtml(output, updatedIndex, quiet);
280
+ } else if (format === "html") {
281
+ // Generate index.html without titles
282
+ const updatedIndex = await loadIndex(output);
283
+ await writeIndexHtml(output, updatedIndex, quiet);
284
+ }
285
+
173
286
  // Summary
174
287
  if (!quiet) {
175
288
  console.error(
package/src/title.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Title generation command.
3
+ *
4
+ * Adds LLM-generated titles to transcripts.json entries that don't have them.
5
+ * Can be run standalone or called from sync.
6
+ */
7
+
8
+ import { join } from "path";
9
+ import { loadIndex, saveIndex } from "./utils/provenance.ts";
10
+ import { getAdapters } from "./adapters/index.ts";
11
+ import { renderTranscript } from "./render.ts";
12
+ import { renderTranscriptHtml } from "./render-html.ts";
13
+ import { generateTitle } from "./utils/openrouter.ts";
14
+ import {
15
+ computeContentHash,
16
+ loadCache,
17
+ saveCache,
18
+ getCachedTitle,
19
+ type CacheEntry,
20
+ } from "./cache.ts";
21
+
22
+ export interface TitleOptions {
23
+ outputDir: string;
24
+ force?: boolean; // regenerate all titles, not just missing ones
25
+ quiet?: boolean;
26
+ }
27
+
28
+ export interface TitleResult {
29
+ generated: number;
30
+ skipped: number;
31
+ errors: number;
32
+ }
33
+
34
+ /**
35
+ * Generate titles for transcripts.json entries that don't have them.
36
+ */
37
+ export async function generateTitles(
38
+ options: TitleOptions,
39
+ ): Promise<TitleResult> {
40
+ const { outputDir, force = false, quiet = false } = options;
41
+
42
+ const result: TitleResult = { generated: 0, skipped: 0, errors: 0 };
43
+
44
+ if (!process.env.OPENROUTER_API_KEY) {
45
+ if (!quiet) {
46
+ console.error("OPENROUTER_API_KEY not set, skipping title generation");
47
+ }
48
+ return result;
49
+ }
50
+
51
+ const index = await loadIndex(outputDir);
52
+ const entries = Object.entries(index.entries);
53
+
54
+ if (entries.length === 0) {
55
+ if (!quiet) {
56
+ console.error("No entries in transcripts.json");
57
+ }
58
+ return result;
59
+ }
60
+
61
+ const adapters = getAdapters();
62
+ const adapterMap = new Map(adapters.map((a) => [a.name, a]));
63
+
64
+ // Process entries that need titles
65
+ for (const [filename, entry] of entries) {
66
+ // Skip if already has title (unless force)
67
+ if (entry.title && !force) {
68
+ result.skipped++;
69
+ continue;
70
+ }
71
+
72
+ try {
73
+ // Read source and compute content hash
74
+ const content = await Bun.file(entry.source).text();
75
+ const contentHash = computeContentHash(content);
76
+
77
+ // Check cache for existing title
78
+ const cached = await loadCache(entry.source);
79
+ const segmentIndex = entry.segmentIndex ? entry.segmentIndex - 1 : 0;
80
+ const cachedTitle = getCachedTitle(cached, contentHash, segmentIndex);
81
+
82
+ if (cachedTitle && !force) {
83
+ entry.title = cachedTitle;
84
+ result.skipped++;
85
+ continue;
86
+ }
87
+
88
+ // Determine adapter from filename pattern (HTML files were synced with an adapter)
89
+ // We need to find which adapter was used - check the source path
90
+ let adapter = adapterMap.get("claude-code"); // default
91
+ for (const a of adapters) {
92
+ if (entry.source.includes(".claude/")) {
93
+ adapter = a;
94
+ break;
95
+ }
96
+ }
97
+
98
+ if (!adapter) {
99
+ console.error(`Warning: No adapter found for ${entry.source}`);
100
+ result.errors++;
101
+ continue;
102
+ }
103
+
104
+ const transcripts = adapter.parse(content, entry.source);
105
+
106
+ // Find the right transcript (by segment index if applicable)
107
+ const transcript = transcripts[segmentIndex];
108
+
109
+ if (!transcript) {
110
+ console.error(`Warning: Transcript not found for ${filename}`);
111
+ result.errors++;
112
+ continue;
113
+ }
114
+
115
+ const markdown = renderTranscript(transcript);
116
+ const title = await generateTitle(markdown);
117
+
118
+ if (title) {
119
+ entry.title = title;
120
+ result.generated++;
121
+ if (!quiet) {
122
+ console.error(`Title: ${filename} → ${title}`);
123
+ }
124
+
125
+ // Update cache with new title
126
+ // Start fresh if content changed to avoid stale md/html
127
+ // Deep copy segments to avoid mutating cached object
128
+ const newCache: CacheEntry = {
129
+ contentHash,
130
+ segments:
131
+ cached?.contentHash === contentHash
132
+ ? cached.segments.map((s) => ({ ...s }))
133
+ : [],
134
+ };
135
+ // Ensure segment array is long enough
136
+ while (newCache.segments.length <= segmentIndex) {
137
+ newCache.segments.push({});
138
+ }
139
+ newCache.segments[segmentIndex].title = title;
140
+
141
+ // Re-render HTML with title if this is an HTML file
142
+ if (filename.endsWith(".html")) {
143
+ const html = await renderTranscriptHtml(transcript, { title });
144
+ const outputPath = join(outputDir, filename);
145
+ await Bun.write(outputPath, html);
146
+ newCache.segments[segmentIndex].html = html;
147
+ }
148
+
149
+ await saveCache(entry.source, newCache);
150
+ } else {
151
+ result.skipped++;
152
+ if (!quiet) {
153
+ console.error(`Skip (no title generated): ${filename}`);
154
+ }
155
+ }
156
+ } catch (error) {
157
+ const message = error instanceof Error ? error.message : String(error);
158
+ console.error(`Error: ${filename}: ${message}`);
159
+ result.errors++;
160
+ }
161
+ }
162
+
163
+ await saveIndex(outputDir, index);
164
+
165
+ if (!quiet) {
166
+ console.error(
167
+ `\nTitle generation complete: ${result.generated} generated, ${result.skipped} skipped, ${result.errors} errors`,
168
+ );
169
+ }
170
+
171
+ return result;
172
+ }
package/src/types.ts CHANGED
@@ -10,6 +10,10 @@ export interface Transcript {
10
10
  };
11
11
  metadata: {
12
12
  warnings: Warning[];
13
+ messageCount: number;
14
+ startTime: string; // ISO timestamp of first message
15
+ endTime: string; // ISO timestamp of last message
16
+ cwd?: string; // Working directory (if known)
13
17
  };
14
18
  messages: Message[];
15
19
  }
@@ -31,6 +35,7 @@ interface BaseMessage {
31
35
  sourceRef: string;
32
36
  timestamp: string;
33
37
  parentMessageRef?: string; // UUID of parent message (for tree reconstruction)
38
+ rawJson?: string; // Original JSON for raw view toggle
34
39
  }
35
40
 
36
41
  export interface UserMessage extends BaseMessage {
@@ -57,6 +62,8 @@ export interface ToolCallGroup extends BaseMessage {
57
62
  export interface ToolCall {
58
63
  name: string;
59
64
  summary: string;
65
+ input?: Record<string, unknown>;
66
+ result?: string;
60
67
  error?: string;
61
68
  }
62
69
 
@@ -65,13 +72,22 @@ export interface ErrorMessage extends BaseMessage {
65
72
  content: string;
66
73
  }
67
74
 
75
+ /**
76
+ * A session file discovered by an adapter.
77
+ */
78
+ export interface DiscoveredSession {
79
+ path: string;
80
+ relativePath: string;
81
+ mtime: number;
82
+ }
83
+
68
84
  /**
69
85
  * Adapter interface - each source format implements this.
70
86
  */
71
87
  export interface Adapter {
72
88
  name: string;
73
- /** Glob patterns for discovering session files (e.g., ["*.jsonl"]) */
74
- filePatterns: string[];
89
+ /** Discover session files in the given directory */
90
+ discover(source: string): Promise<DiscoveredSession[]>;
75
91
  /** Parse source content into one or more transcripts (split by conversation) */
76
92
  parse(content: string, sourcePath: string): Transcript[];
77
93
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * HTML rendering utilities.
3
+ */
4
+
5
+ export function escapeHtml(text: string): string {
6
+ return text
7
+ .replace(/&/g, "&amp;")
8
+ .replace(/</g, "&lt;")
9
+ .replace(/>/g, "&gt;")
10
+ .replace(/"/g, "&quot;")
11
+ .replace(/'/g, "&#039;");
12
+ }