@arcreflex/agent-transcripts 0.1.10 → 0.1.12

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/render.ts CHANGED
@@ -3,16 +3,8 @@
3
3
  */
4
4
 
5
5
  import type { Transcript, Message, ToolCall } from "./types.ts";
6
- import {
7
- buildTree,
8
- findLatestLeaf,
9
- tracePath,
10
- getFirstLine,
11
- } from "./utils/tree.ts";
6
+ import { walkTranscriptTree } from "./utils/tree.ts";
12
7
 
13
- /**
14
- * Format a single tool call.
15
- */
16
8
  function formatToolCall(call: ToolCall): string {
17
9
  if (call.summary) {
18
10
  return `${call.name} \`${call.summary}\``;
@@ -20,9 +12,6 @@ function formatToolCall(call: ToolCall): string {
20
12
  return call.name;
21
13
  }
22
14
 
23
- /**
24
- * Format tool calls group.
25
- */
26
15
  function formatToolCalls(calls: ToolCall[]): string {
27
16
  if (calls.length === 1) {
28
17
  return `**Tool**: ${formatToolCall(calls[0])}`;
@@ -30,9 +19,6 @@ function formatToolCalls(calls: ToolCall[]): string {
30
19
  return `**Tools**:\n${calls.map((c) => `- ${formatToolCall(c)}`).join("\n")}`;
31
20
  }
32
21
 
33
- /**
34
- * Render a message to markdown.
35
- */
36
22
  function renderMessage(msg: Message): string {
37
23
  switch (msg.type) {
38
24
  case "user":
@@ -76,17 +62,11 @@ export interface RenderTranscriptOptions {
76
62
  sourcePath?: string; // absolute source path for front matter provenance
77
63
  }
78
64
 
79
- /**
80
- * Render transcript to markdown with branch awareness.
81
- */
82
65
  export function renderTranscript(
83
66
  transcript: Transcript,
84
- options: RenderTranscriptOptions | string = {},
67
+ options: RenderTranscriptOptions = {},
85
68
  ): string {
86
- // Support legacy signature: renderTranscript(transcript, head?: string)
87
- const opts: RenderTranscriptOptions =
88
- typeof options === "string" ? { head: options } : options;
89
- const { head, sourcePath } = opts;
69
+ const { head, sourcePath } = options;
90
70
 
91
71
  const lines: string[] = [];
92
72
 
@@ -116,76 +96,33 @@ export function renderTranscript(
116
96
  lines.push("");
117
97
  lines.push("---");
118
98
 
119
- // Handle empty transcripts
120
- if (transcript.messages.length === 0) {
121
- return lines.join("\n");
122
- }
123
-
124
- // Build tree
125
- const { bySourceRef, children, parents, roots } = buildTree(
126
- transcript.messages,
127
- );
128
-
129
- // Determine target (head or latest leaf)
130
- let target: string | undefined;
131
- if (head) {
132
- if (!bySourceRef.has(head)) {
133
- lines.push("");
134
- lines.push(`**Error**: Message ID \`${head}\` not found`);
135
- return lines.join("\n");
136
- }
137
- target = head;
138
- } else {
139
- target = findLatestLeaf(bySourceRef, children);
140
- }
99
+ for (const event of walkTranscriptTree(transcript, { head })) {
100
+ switch (event.type) {
101
+ case "empty":
102
+ break;
141
103
 
142
- if (!target) {
143
- // Fallback: just render all messages in order (shouldn't happen normally)
144
- for (const msg of transcript.messages) {
145
- const rendered = renderMessage(msg);
146
- if (rendered) {
104
+ case "head_not_found":
147
105
  lines.push("");
148
- lines.push(rendered);
149
- }
150
- }
151
- return lines.join("\n");
152
- }
153
-
154
- // Trace path from root to target
155
- const path = tracePath(target, parents);
156
- const pathSet = new Set(path);
157
-
158
- // Render messages along the path
159
- for (const sourceRef of path) {
160
- const msgs = bySourceRef.get(sourceRef);
161
- if (!msgs) continue;
106
+ lines.push(`**Error**: Message ID \`${event.head}\` not found`);
107
+ break;
108
+
109
+ case "messages":
110
+ for (const msg of event.messages) {
111
+ const rendered = renderMessage(msg);
112
+ if (rendered) {
113
+ lines.push("");
114
+ lines.push(rendered);
115
+ }
116
+ }
117
+ break;
162
118
 
163
- // Render all messages from this source
164
- for (const msg of msgs) {
165
- const rendered = renderMessage(msg);
166
- if (rendered) {
119
+ case "branch_note":
167
120
  lines.push("");
168
- lines.push(rendered);
169
- }
170
- }
171
-
172
- // Check for other branches at this point (only if not using explicit --head)
173
- if (!head) {
174
- const childSet = children.get(sourceRef);
175
- if (childSet && childSet.size > 1) {
176
- const otherBranches = [...childSet].filter((c) => !pathSet.has(c));
177
- if (otherBranches.length > 0) {
178
- lines.push("");
179
- lines.push("> **Other branches**:");
180
- for (const branchRef of otherBranches) {
181
- const branchMsgs = bySourceRef.get(branchRef);
182
- if (branchMsgs && branchMsgs.length > 0) {
183
- const firstLine = getFirstLine(branchMsgs[0]);
184
- lines.push(`> - \`${branchRef}\` "${firstLine}"`);
185
- }
186
- }
121
+ lines.push("> **Other branches**:");
122
+ for (const branch of event.branches) {
123
+ lines.push(`> - \`${branch.sourceRef}\` "${branch.firstLine}"`);
187
124
  }
188
- }
125
+ break;
189
126
  }
190
127
  }
191
128
 
package/src/serve.ts CHANGED
@@ -1,211 +1,146 @@
1
1
  /**
2
- * Serve command: dynamic HTTP server for transcripts.
2
+ * Serve command: HTTP server for transcripts from the archive.
3
3
  *
4
- * Serves transcripts directly from source files, using the cache
5
- * for rendered HTML and titles. No output directory needed.
4
+ * Reads archive entries, renders HTML on demand with in-memory LRU cache.
6
5
  */
7
6
 
8
- import { getAdapters } from "./adapters/index.ts";
9
- import type { Adapter, DiscoveredSession, Transcript } from "./types.ts";
7
+ import type { ArchiveEntryHeader, TranscriptSummary } from "./archive.ts";
8
+ import { listEntryHeaders, loadEntry, DEFAULT_ARCHIVE_DIR } from "./archive.ts";
10
9
  import { renderTranscriptHtml } from "./render-html.ts";
11
10
  import { renderIndexFromSessions, type SessionEntry } from "./render-index.ts";
12
- import { generateOutputName } from "./utils/naming.ts";
13
- import { extractFirstUserMessage } from "./utils/provenance.ts";
14
- import {
15
- computeContentHash,
16
- loadCache,
17
- saveCache,
18
- getCachedSegments,
19
- type CacheEntry,
20
- } from "./cache.ts";
11
+ import { formatDateTimePrefix } from "./utils/naming.ts";
21
12
 
22
13
  export interface ServeOptions {
23
- source: string;
14
+ archiveDir?: string;
24
15
  port?: number;
25
16
  quiet?: boolean;
26
- noCache?: boolean;
27
17
  }
28
18
 
29
19
  interface SessionInfo {
30
- source: DiscoveredSession;
31
- adapter: Adapter;
32
- baseName: string; // e.g., "2024-01-15-1423-sessionid"
33
- segmentIndex: number; // 0-indexed segment within multi-transcript source
20
+ sessionId: string;
21
+ sourceHash: string;
22
+ title?: string;
23
+ segment: TranscriptSummary;
24
+ segmentIndex: number;
25
+ baseName: string;
34
26
  }
35
27
 
36
- /**
37
- * Discover sessions and build URL mapping.
38
- * Returns map of baseName session info.
39
- */
40
- async function discoverSessions(
41
- sourceDir: string,
42
- ): Promise<Map<string, SessionInfo[]>> {
43
- const sessions = new Map<string, SessionInfo[]>();
44
-
45
- for (const adapter of getAdapters()) {
46
- const discovered = await adapter.discover(sourceDir);
47
- for (const source of discovered) {
48
- // Parse to get transcript info for naming
49
- // We need to parse once to determine the output name
50
- const content = await Bun.file(source.path).text();
51
- const transcripts = adapter.parse(content, source.path);
52
-
53
- for (let i = 0; i < transcripts.length; i++) {
54
- const transcript = transcripts[i];
55
- const baseName = generateOutputName(transcript, source.path);
56
- const suffix = transcripts.length > 1 ? `_${i + 1}` : "";
57
- const fullName = `${baseName}${suffix}`;
58
-
59
- const info: SessionInfo = {
60
- source,
61
- adapter,
62
- baseName: fullName,
63
- segmentIndex: i,
64
- };
65
-
66
- // Store by baseName for lookup
67
- const existing = sessions.get(fullName) || [];
68
- existing.push(info);
69
- sessions.set(fullName, existing);
70
- }
71
- }
72
- }
28
+ /** Simple LRU cache for rendered HTML. */
29
+ class HtmlCache {
30
+ private map = new Map<string, string>();
31
+ constructor(private maxSize: number) {}
73
32
 
74
- return sessions;
75
- }
76
-
77
- /**
78
- * Get or render HTML for a session.
79
- * Uses cache if available and content unchanged (unless noCache is true).
80
- */
81
- async function getSessionHtml(
82
- session: SessionInfo,
83
- segmentIndex: number,
84
- noCache = false,
85
- ): Promise<{
86
- html: string;
87
- transcript: Transcript;
88
- contentHash: string;
89
- } | null> {
90
- const content = await Bun.file(session.source.path).text();
91
- const contentHash = computeContentHash(content);
92
-
93
- // Parse first to validate segment index
94
- const transcripts = session.adapter.parse(content, session.source.path);
95
- if (segmentIndex < 0 || segmentIndex >= transcripts.length) {
96
- return null;
33
+ private key(sessionId: string, segmentIndex: number, sourceHash: string) {
34
+ return `${sessionId}:${segmentIndex}:${sourceHash}`;
97
35
  }
98
- const transcript = transcripts[segmentIndex];
99
-
100
- // Check cache (unless bypassing for dev)
101
- const cached = await loadCache(session.source.path);
102
- if (!noCache) {
103
- const cachedSegments = getCachedSegments(cached, contentHash, "html");
104
- const cachedHtml = cachedSegments?.[segmentIndex]?.html;
105
- if (cachedHtml) {
106
- return { html: cachedHtml, transcript, contentHash };
36
+
37
+ get(
38
+ sessionId: string,
39
+ segmentIndex: number,
40
+ sourceHash: string,
41
+ ): string | undefined {
42
+ const k = this.key(sessionId, segmentIndex, sourceHash);
43
+ const val = this.map.get(k);
44
+ if (val !== undefined) {
45
+ // Move to end (most recently used)
46
+ this.map.delete(k);
47
+ this.map.set(k, val);
107
48
  }
49
+ return val;
108
50
  }
109
51
 
110
- // Still use cached title if available
111
- const title =
112
- cached?.contentHash === contentHash
113
- ? cached.segments[segmentIndex]?.title
114
- : undefined;
115
-
116
- const html = await renderTranscriptHtml(transcript, { title });
117
-
118
- // Update cache (even in noCache mode, for titles)
119
- if (!noCache) {
120
- // Deep copy segments to avoid mutating cached objects
121
- const newCache: CacheEntry = {
122
- contentHash,
123
- segments:
124
- cached?.contentHash === contentHash
125
- ? cached.segments.map((s) => ({ ...s }))
126
- : [],
127
- };
128
- while (newCache.segments.length <= segmentIndex) {
129
- newCache.segments.push({});
52
+ set(
53
+ sessionId: string,
54
+ segmentIndex: number,
55
+ sourceHash: string,
56
+ html: string,
57
+ ) {
58
+ const k = this.key(sessionId, segmentIndex, sourceHash);
59
+ this.map.delete(k);
60
+ this.map.set(k, html);
61
+ if (this.map.size > this.maxSize) {
62
+ // Evict oldest
63
+ const oldest = this.map.keys().next().value!;
64
+ this.map.delete(oldest);
130
65
  }
131
- newCache.segments[segmentIndex].html = html;
132
- if (title) {
133
- newCache.segments[segmentIndex].title = title;
66
+ }
67
+ }
68
+
69
+ function buildSessionMap(
70
+ headers: ArchiveEntryHeader[],
71
+ ): Map<string, SessionInfo> {
72
+ const sessions = new Map<string, SessionInfo>();
73
+
74
+ for (const header of headers) {
75
+ for (let i = 0; i < header.segments.length; i++) {
76
+ const segment = header.segments[i];
77
+ const dateTime = formatDateTimePrefix(segment.firstMessageTimestamp);
78
+ const baseName = `${dateTime}-${header.sessionId}`;
79
+ const suffix = header.segments.length > 1 ? `_${i + 1}` : "";
80
+ const fullName = `${baseName}${suffix}`;
81
+
82
+ sessions.set(fullName, {
83
+ sessionId: header.sessionId,
84
+ sourceHash: header.sourceHash,
85
+ title: header.title,
86
+ segment,
87
+ segmentIndex: i,
88
+ baseName: fullName,
89
+ });
134
90
  }
135
- await saveCache(session.source.path, newCache);
136
91
  }
137
92
 
138
- return { html, transcript, contentHash };
93
+ return sessions;
139
94
  }
140
95
 
141
- /**
142
- * Build session entries for index page.
143
- */
144
- async function buildIndexEntries(
145
- sessions: Map<string, SessionInfo[]>,
146
- ): Promise<SessionEntry[]> {
96
+ function buildIndexEntries(sessions: Map<string, SessionInfo>): SessionEntry[] {
147
97
  const entries: SessionEntry[] = [];
148
98
 
149
- for (const [baseName, infos] of sessions) {
150
- for (const info of infos) {
151
- const { segmentIndex } = info;
152
-
153
- try {
154
- const content = await Bun.file(info.source.path).text();
155
- const contentHash = computeContentHash(content);
156
- const transcripts = info.adapter.parse(content, info.source.path);
157
- const transcript = transcripts[segmentIndex];
158
-
159
- if (!transcript) continue;
160
-
161
- // Get cached title
162
- const cached = await loadCache(info.source.path);
163
- const title =
164
- cached?.contentHash === contentHash
165
- ? cached.segments[segmentIndex]?.title
166
- : undefined;
167
-
168
- const firstUserMessage = extractFirstUserMessage(transcript);
169
- const { messageCount, startTime, endTime, cwd } = transcript.metadata;
170
-
171
- entries.push({
172
- filename: `${baseName}.html`,
173
- title:
174
- title ||
175
- (firstUserMessage.length > 80
176
- ? firstUserMessage.slice(0, 80) + "..."
177
- : firstUserMessage) ||
178
- baseName,
179
- firstUserMessage,
180
- date: startTime,
181
- endDate: endTime,
182
- messageCount,
183
- cwd,
184
- });
185
- } catch {
186
- // Skip sessions that fail to parse
187
- }
188
- }
99
+ for (const [baseName, info] of sessions) {
100
+ const { segment, title } = info;
101
+ if (segment.metadata.messageCount === 0) continue;
102
+
103
+ const { messageCount, startTime, endTime, cwd } = segment.metadata;
104
+
105
+ entries.push({
106
+ filename: `${baseName}.html`,
107
+ title:
108
+ title ||
109
+ (segment.firstUserMessage.length > 80
110
+ ? segment.firstUserMessage.slice(0, 80) + "..."
111
+ : segment.firstUserMessage) ||
112
+ baseName,
113
+ firstUserMessage: segment.firstUserMessage,
114
+ date: startTime,
115
+ endDate: endTime,
116
+ messageCount,
117
+ cwd,
118
+ });
189
119
  }
190
120
 
191
121
  return entries;
192
122
  }
193
123
 
194
- /**
195
- * Start the HTTP server.
196
- */
197
124
  export async function serve(options: ServeOptions): Promise<void> {
198
- const { source, port = 3000, quiet = false, noCache = false } = options;
125
+ const {
126
+ archiveDir = DEFAULT_ARCHIVE_DIR,
127
+ port = 3000,
128
+ quiet = false,
129
+ } = options;
199
130
 
200
131
  if (!quiet) {
201
- console.error(`Discovering sessions in ${source}...`);
132
+ console.error(`Loading archive from ${archiveDir}...`);
202
133
  }
203
134
 
204
- // Discover sessions on startup
205
- const sessions = await discoverSessions(source);
135
+ const headers = await listEntryHeaders(archiveDir);
136
+ const sessions = buildSessionMap(headers);
137
+ const htmlCache = new HtmlCache(200);
138
+
139
+ // Index is static (session map doesn't change), so build once
140
+ const indexHtml = renderIndexFromSessions(buildIndexEntries(sessions));
206
141
 
207
142
  if (!quiet) {
208
- console.error(`Found ${sessions.size} session(s)`);
143
+ console.error(`Found ${sessions.size} transcript(s)`);
209
144
  console.error(`Starting server at http://localhost:${port}`);
210
145
  }
211
146
 
@@ -215,38 +150,43 @@ export async function serve(options: ServeOptions): Promise<void> {
215
150
  const url = new URL(req.url);
216
151
  const path = url.pathname;
217
152
 
218
- // Log request
219
153
  if (!quiet) {
220
154
  console.error(`${req.method} ${path}`);
221
155
  }
222
156
 
223
157
  // Index page
224
158
  if (path === "/" || path === "/index.html") {
225
- const entries = await buildIndexEntries(sessions);
226
- const html = renderIndexFromSessions(entries);
227
- return new Response(html, {
159
+ return new Response(indexHtml, {
228
160
  headers: { "Content-Type": "text/html; charset=utf-8" },
229
161
  });
230
162
  }
231
163
 
232
164
  // Session page
233
165
  if (path.endsWith(".html")) {
234
- const baseName = path.slice(1, -5); // Remove leading "/" and ".html"
166
+ const baseName = path.slice(1, -5);
167
+ const info = sessions.get(baseName);
235
168
 
236
- // First try exact match
237
- const exactInfos = sessions.get(baseName);
238
- if (exactInfos && exactInfos.length > 0) {
169
+ if (info) {
239
170
  try {
240
- const info = exactInfos[0];
241
- const result = await getSessionHtml(
242
- info,
243
- info.segmentIndex,
244
- noCache,
245
- );
246
- if (!result) {
171
+ const { sessionId, segmentIndex, sourceHash, title } = info;
172
+
173
+ const cached = htmlCache.get(sessionId, segmentIndex, sourceHash);
174
+ if (cached) {
175
+ return new Response(cached, {
176
+ headers: { "Content-Type": "text/html; charset=utf-8" },
177
+ });
178
+ }
179
+
180
+ // Load full entry from disk on demand
181
+ const entry = await loadEntry(archiveDir, sessionId);
182
+ const transcript = entry?.transcripts[segmentIndex];
183
+ if (!transcript) {
247
184
  return new Response("Not Found", { status: 404 });
248
185
  }
249
- return new Response(result.html, {
186
+
187
+ const html = await renderTranscriptHtml(transcript, { title });
188
+ htmlCache.set(sessionId, segmentIndex, sourceHash, html);
189
+ return new Response(html, {
250
190
  headers: { "Content-Type": "text/html; charset=utf-8" },
251
191
  });
252
192
  } catch (error) {
@@ -256,35 +196,6 @@ export async function serve(options: ServeOptions): Promise<void> {
256
196
  }
257
197
  }
258
198
 
259
- // Fallback: parse segment suffix (e.g., "name_2" → base "name" + segment 1)
260
- // Handles case where URL has suffix but session was stored without it
261
- const segmentMatch = baseName.match(/^(.+)_(\d+)$/);
262
- if (segmentMatch) {
263
- const lookupName = segmentMatch[1];
264
- const segmentIndex = parseInt(segmentMatch[2], 10) - 1;
265
- const infos = sessions.get(lookupName);
266
- if (infos && infos.length > 0 && segmentIndex >= 0) {
267
- try {
268
- const result = await getSessionHtml(
269
- infos[0],
270
- segmentIndex,
271
- noCache,
272
- );
273
- if (!result) {
274
- return new Response("Not Found", { status: 404 });
275
- }
276
- const { html } = result;
277
- return new Response(html, {
278
- headers: { "Content-Type": "text/html; charset=utf-8" },
279
- });
280
- } catch (error) {
281
- const message =
282
- error instanceof Error ? error.message : String(error);
283
- return new Response(`Error: ${message}`, { status: 500 });
284
- }
285
- }
286
- }
287
-
288
199
  return new Response("Not Found", { status: 404 });
289
200
  }
290
201
 
@@ -292,12 +203,10 @@ export async function serve(options: ServeOptions): Promise<void> {
292
203
  },
293
204
  });
294
205
 
295
- // Keep server running
296
206
  if (!quiet) {
297
207
  console.error(`\nPress Ctrl+C to stop`);
298
208
  }
299
209
 
300
- // Handle shutdown gracefully
301
210
  process.on("SIGINT", () => {
302
211
  if (!quiet) {
303
212
  console.error("\nShutting down...");