@arcreflex/agent-transcripts 0.1.8 → 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/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
 
@@ -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
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * OpenRouter API client for LLM-based title generation.
3
+ *
4
+ * Uses Gemini 2.5 Flash for fast, cheap title generation.
5
+ * Gracefully handles missing API key or API failures.
6
+ */
7
+
8
+ const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
9
+ const MODEL = "google/gemini-2.5-flash";
10
+
11
+ // Approximate token limit for context (conservative estimate)
12
+ // Gemini Flash has 1M context, but we don't need anywhere near that
13
+ const MAX_CHARS = 32000; // ~8k tokens
14
+
15
+ /**
16
+ * Truncate content with middle-cut strategy.
17
+ * Keeps beginning and end, removes middle if over limit.
18
+ */
19
+ function truncateMiddle(content: string, maxChars: number): string {
20
+ if (content.length <= maxChars) return content;
21
+
22
+ const halfLimit = Math.floor(maxChars / 2);
23
+ const start = content.slice(0, halfLimit);
24
+ const end = content.slice(-halfLimit);
25
+
26
+ return `${start}\n\n[... middle truncated ...]\n\n${end}`;
27
+ }
28
+
29
+ interface OpenRouterResponse {
30
+ choices?: Array<{
31
+ message?: {
32
+ content?: string;
33
+ };
34
+ }>;
35
+ error?: {
36
+ message?: string;
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Generate a title for a transcript using OpenRouter.
42
+ *
43
+ * @param markdownContent - The full markdown transcript
44
+ * @returns Generated title, or undefined if generation fails/skipped
45
+ */
46
+ export async function generateTitle(
47
+ markdownContent: string,
48
+ ): Promise<string | undefined> {
49
+ const apiKey = process.env.OPENROUTER_API_KEY;
50
+
51
+ if (!apiKey) {
52
+ // Silently skip - no API key means user doesn't want title generation
53
+ return undefined;
54
+ }
55
+
56
+ const truncated = truncateMiddle(markdownContent, MAX_CHARS);
57
+
58
+ const prompt = `Generate a concise title (5-10 words) for this AI coding session transcript. The title should capture the main task or topic discussed.
59
+
60
+ Reply with just the title, no quotes, no punctuation at the end, no explanation.
61
+
62
+ Transcript:
63
+ ${truncated}`;
64
+
65
+ try {
66
+ const response = await fetch(OPENROUTER_API_URL, {
67
+ method: "POST",
68
+ headers: {
69
+ Authorization: `Bearer ${apiKey}`,
70
+ "Content-Type": "application/json",
71
+ "HTTP-Referer": "https://github.com/arcreflex/agent-transcripts",
72
+ "X-Title": "agent-transcripts",
73
+ },
74
+ body: JSON.stringify({
75
+ model: MODEL,
76
+ messages: [{ role: "user", content: prompt }],
77
+ max_tokens: 50,
78
+ temperature: 0.3,
79
+ }),
80
+ });
81
+
82
+ if (!response.ok) {
83
+ const text = await response.text();
84
+ console.error(
85
+ `Warning: OpenRouter API error (${response.status}): ${text.slice(0, 200)}`,
86
+ );
87
+ return undefined;
88
+ }
89
+
90
+ const data = (await response.json()) as OpenRouterResponse;
91
+
92
+ if (data.error) {
93
+ console.error(
94
+ `Warning: OpenRouter error: ${data.error.message || "Unknown error"}`,
95
+ );
96
+ return undefined;
97
+ }
98
+
99
+ const title = data.choices?.[0]?.message?.content?.trim();
100
+
101
+ if (!title) {
102
+ console.error("Warning: OpenRouter returned empty title");
103
+ return undefined;
104
+ }
105
+
106
+ // Clean up: remove quotes if present, trim trailing punctuation
107
+ return title
108
+ .replace(/^["']|["']$/g, "")
109
+ .replace(/[.!?]+$/, "")
110
+ .trim();
111
+ } catch (err) {
112
+ const message = err instanceof Error ? err.message : String(err);
113
+ console.error(`Warning: OpenRouter request failed: ${message}`);
114
+ return undefined;
115
+ }
116
+ }
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import { join, resolve } from "path";
9
- import { existsSync } from "fs";
10
9
  import { rename, unlink } from "fs/promises";
11
10
 
12
11
  const INDEX_FILENAME = "transcripts.json";
@@ -17,10 +16,15 @@ const INDEX_FILENAME = "transcripts.json";
17
16
 
18
17
  export interface TranscriptEntry {
19
18
  source: string; // absolute path to source
20
- sourceMtime: number; // ms since epoch
21
19
  sessionId: string; // full session ID from source filename
22
20
  segmentIndex?: number; // for multi-transcript sources (1-indexed)
23
21
  syncedAt: string; // ISO timestamp
22
+ firstUserMessage: string; // first user message content (for display)
23
+ title?: string; // copied from cache for index.html convenience
24
+ messageCount: number;
25
+ startTime: string; // ISO timestamp
26
+ endTime: string; // ISO timestamp
27
+ cwd?: string;
24
28
  }
25
29
 
26
30
  export interface TranscriptsIndex {
@@ -120,45 +124,6 @@ export function getOutputsForSource(
120
124
  return outputs;
121
125
  }
122
126
 
123
- /**
124
- * Check if outputs for a source are stale.
125
- * Returns true if:
126
- * - No outputs exist for this source
127
- * - Output count doesn't match expected
128
- * - Any output file is missing from disk
129
- * - Source mtime is newer than recorded mtime
130
- */
131
- export function isStale(
132
- index: TranscriptsIndex,
133
- sourcePath: string,
134
- sourceMtime: number,
135
- expectedCount: number,
136
- outputDir: string,
137
- ): boolean {
138
- const outputs = getOutputsForSource(index, sourcePath);
139
-
140
- if (outputs.length !== expectedCount) {
141
- return true;
142
- }
143
-
144
- // Check if outputs actually exist on disk
145
- for (const filename of outputs) {
146
- if (!existsSync(join(outputDir, filename))) {
147
- return true;
148
- }
149
- }
150
-
151
- // Check if source has been modified since last sync
152
- for (const filename of outputs) {
153
- const entry = index.entries[filename];
154
- if (entry && entry.sourceMtime < sourceMtime) {
155
- return true;
156
- }
157
- }
158
-
159
- return false;
160
- }
161
-
162
127
  /**
163
128
  * Set or update an entry in the index.
164
129
  * outputPath should be relative to the output directory.
@@ -226,3 +191,22 @@ export async function deleteOutputFiles(
226
191
  }
227
192
  }
228
193
  }
194
+
195
+ // ============================================================================
196
+ // Transcript Metadata Extraction
197
+ // ============================================================================
198
+
199
+ import type { Transcript } from "../types.ts";
200
+
201
+ /**
202
+ * Extract the first user message from a transcript.
203
+ * Returns empty string if no user message found.
204
+ */
205
+ export function extractFirstUserMessage(transcript: Transcript): string {
206
+ for (const msg of transcript.messages) {
207
+ if (msg.type === "user") {
208
+ return msg.content;
209
+ }
210
+ }
211
+ return "";
212
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tree/branch navigation utilities for transcript messages.
3
+ */
4
+
5
+ import type { Message } from "../types.ts";
6
+
7
+ export interface MessageTree {
8
+ bySourceRef: Map<string, Message[]>;
9
+ children: Map<string, Set<string>>;
10
+ parents: Map<string, string>;
11
+ roots: string[];
12
+ }
13
+
14
+ /**
15
+ * Build tree structure from messages.
16
+ * Returns maps for navigation and the messages grouped by sourceRef.
17
+ */
18
+ export function buildTree(messages: Message[]): MessageTree {
19
+ const bySourceRef = new Map<string, Message[]>();
20
+ for (const msg of messages) {
21
+ const existing = bySourceRef.get(msg.sourceRef) || [];
22
+ existing.push(msg);
23
+ bySourceRef.set(msg.sourceRef, existing);
24
+ }
25
+
26
+ const children = new Map<string, Set<string>>();
27
+ const parents = new Map<string, string>();
28
+
29
+ for (const msg of messages) {
30
+ if (msg.parentMessageRef && bySourceRef.has(msg.parentMessageRef)) {
31
+ parents.set(msg.sourceRef, msg.parentMessageRef);
32
+ const existing = children.get(msg.parentMessageRef) || new Set();
33
+ existing.add(msg.sourceRef);
34
+ children.set(msg.parentMessageRef, existing);
35
+ }
36
+ }
37
+
38
+ const roots: string[] = [];
39
+ for (const sourceRef of bySourceRef.keys()) {
40
+ if (!parents.has(sourceRef)) {
41
+ roots.push(sourceRef);
42
+ }
43
+ }
44
+
45
+ return { bySourceRef, children, parents, roots };
46
+ }
47
+
48
+ /**
49
+ * Find the latest leaf in the tree (for primary branch).
50
+ */
51
+ export function findLatestLeaf(
52
+ bySourceRef: Map<string, Message[]>,
53
+ children: Map<string, Set<string>>,
54
+ ): string | undefined {
55
+ let latestLeaf: string | undefined;
56
+ let latestTime = 0;
57
+
58
+ for (const sourceRef of bySourceRef.keys()) {
59
+ const childSet = children.get(sourceRef);
60
+ if (!childSet || childSet.size === 0) {
61
+ const msgs = bySourceRef.get(sourceRef);
62
+ if (msgs && msgs.length > 0) {
63
+ const time = new Date(msgs[0].timestamp).getTime();
64
+ if (time > latestTime) {
65
+ latestTime = time;
66
+ latestLeaf = sourceRef;
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ return latestLeaf;
73
+ }
74
+
75
+ /**
76
+ * Trace path from root to target.
77
+ */
78
+ export function tracePath(
79
+ target: string,
80
+ parents: Map<string, string>,
81
+ ): string[] {
82
+ const path: string[] = [];
83
+ let current: string | undefined = target;
84
+
85
+ while (current) {
86
+ path.unshift(current);
87
+ current = parents.get(current);
88
+ }
89
+
90
+ return path;
91
+ }
92
+
93
+ /**
94
+ * Get first line of message content for branch reference display.
95
+ */
96
+ export function getFirstLine(msg: Message): string {
97
+ let text: string;
98
+ switch (msg.type) {
99
+ case "user":
100
+ case "assistant":
101
+ case "system":
102
+ case "error":
103
+ text = msg.content;
104
+ break;
105
+ case "tool_calls":
106
+ text = msg.calls.map((c) => c.name).join(", ");
107
+ break;
108
+ default:
109
+ text = "";
110
+ }
111
+ const firstLine = text.split("\n")[0].trim();
112
+ const maxLen = 60;
113
+ return firstLine.length > maxLen
114
+ ? firstLine.slice(0, maxLen) + "..."
115
+ : firstLine;
116
+ }
@@ -0,0 +1,9 @@
1
+ {"type": "user", "uuid": "msg-1", "message": {"role": "user", "content": "First message"}, "timestamp": "2024-01-15T10:00:00Z"}
2
+ {"type": "assistant", "uuid": "msg-2", "parentUuid": "msg-1", "message": {"role": "assistant", "content": [{"type": "text", "text": "First response"}]}, "timestamp": "2024-01-15T10:00:05Z"}
3
+ {"type": "progress", "uuid": "progress-1", "parentUuid": "msg-2", "content": "Processing...", "timestamp": "2024-01-15T10:00:10Z"}
4
+ {"type": "user", "uuid": "msg-3", "parentUuid": "progress-1", "message": {"role": "user", "content": "Second message (parent is progress record)"}, "timestamp": "2024-01-15T10:00:15Z"}
5
+ {"type": "assistant", "uuid": "msg-4", "parentUuid": "msg-3", "message": {"role": "assistant", "content": [{"type": "text", "text": "Second response"}]}, "timestamp": "2024-01-15T10:00:20Z"}
6
+ {"type": "progress", "uuid": "progress-2", "parentUuid": "msg-4", "content": "More processing...", "timestamp": "2024-01-15T10:00:25Z"}
7
+ {"type": "progress", "uuid": "progress-3", "parentUuid": "progress-2", "content": "Nested progress...", "timestamp": "2024-01-15T10:00:26Z"}
8
+ {"type": "user", "uuid": "msg-5", "parentUuid": "progress-3", "message": {"role": "user", "content": "Third message (parent is nested progress)"}, "timestamp": "2024-01-15T10:00:30Z"}
9
+ {"type": "assistant", "uuid": "msg-6", "parentUuid": "msg-5", "message": {"role": "assistant", "content": [{"type": "text", "text": "Third response"}]}, "timestamp": "2024-01-15T10:00:35Z"}
@@ -0,0 +1,30 @@
1
+ # Transcript
2
+
3
+ **Source**: `test/fixtures/claude/non-message-parents.input.jsonl`
4
+ **Adapter**: claude-code
5
+
6
+ ---
7
+
8
+ ## User
9
+
10
+ First message
11
+
12
+ ## Assistant
13
+
14
+ First response
15
+
16
+ ## User
17
+
18
+ Second message (parent is progress record)
19
+
20
+ ## Assistant
21
+
22
+ Second response
23
+
24
+ ## User
25
+
26
+ Third message (parent is nested progress)
27
+
28
+ ## Assistant
29
+
30
+ Third response