@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/render.ts CHANGED
@@ -3,6 +3,12 @@
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
12
 
7
13
  /**
8
14
  * Format a single tool call.
@@ -65,116 +71,6 @@ ${msg.thinking}
65
71
  }
66
72
  }
67
73
 
68
- /**
69
- * Get first line of message content for branch reference.
70
- */
71
- function getFirstLine(msg: Message): string {
72
- let text: string;
73
- switch (msg.type) {
74
- case "user":
75
- case "assistant":
76
- case "system":
77
- case "error":
78
- text = msg.content;
79
- break;
80
- case "tool_calls":
81
- text = msg.calls.map((c) => c.name).join(", ");
82
- break;
83
- default:
84
- text = "";
85
- }
86
- const firstLine = text.split("\n")[0].trim();
87
- const maxLen = 60;
88
- return firstLine.length > maxLen
89
- ? firstLine.slice(0, maxLen) + "..."
90
- : firstLine;
91
- }
92
-
93
- /**
94
- * Build tree structure from messages.
95
- * Returns maps for navigation and the messages grouped by sourceRef.
96
- */
97
- function buildTree(messages: Message[]): {
98
- bySourceRef: Map<string, Message[]>;
99
- children: Map<string, Set<string>>;
100
- parents: Map<string, string>;
101
- roots: string[];
102
- } {
103
- // Group messages by sourceRef
104
- const bySourceRef = new Map<string, Message[]>();
105
- for (const msg of messages) {
106
- const existing = bySourceRef.get(msg.sourceRef) || [];
107
- existing.push(msg);
108
- bySourceRef.set(msg.sourceRef, existing);
109
- }
110
-
111
- // Build parent → children map (at sourceRef level)
112
- const children = new Map<string, Set<string>>();
113
- const parents = new Map<string, string>();
114
-
115
- for (const msg of messages) {
116
- if (msg.parentMessageRef && bySourceRef.has(msg.parentMessageRef)) {
117
- parents.set(msg.sourceRef, msg.parentMessageRef);
118
- const existing = children.get(msg.parentMessageRef) || new Set();
119
- existing.add(msg.sourceRef);
120
- children.set(msg.parentMessageRef, existing);
121
- }
122
- }
123
-
124
- // Find roots (no parent in our set)
125
- const roots: string[] = [];
126
- for (const sourceRef of bySourceRef.keys()) {
127
- if (!parents.has(sourceRef)) {
128
- roots.push(sourceRef);
129
- }
130
- }
131
-
132
- return { bySourceRef, children, parents, roots };
133
- }
134
-
135
- /**
136
- * Find the latest leaf in the tree (for primary branch).
137
- */
138
- function findLatestLeaf(
139
- bySourceRef: Map<string, Message[]>,
140
- children: Map<string, Set<string>>,
141
- ): string | undefined {
142
- let latestLeaf: string | undefined;
143
- let latestTime = 0;
144
-
145
- for (const sourceRef of bySourceRef.keys()) {
146
- const childSet = children.get(sourceRef);
147
- if (!childSet || childSet.size === 0) {
148
- // It's a leaf
149
- const msgs = bySourceRef.get(sourceRef);
150
- if (msgs && msgs.length > 0) {
151
- const time = new Date(msgs[0].timestamp).getTime();
152
- if (time > latestTime) {
153
- latestTime = time;
154
- latestLeaf = sourceRef;
155
- }
156
- }
157
- }
158
- }
159
-
160
- return latestLeaf;
161
- }
162
-
163
- /**
164
- * Trace path from root to target.
165
- */
166
- function tracePath(target: string, parents: Map<string, string>): string[] {
167
- const path: string[] = [];
168
- let current: string | undefined = target;
169
-
170
- while (current) {
171
- path.unshift(current);
172
- current = parents.get(current);
173
- }
174
-
175
- return path;
176
- }
177
-
178
74
  export interface RenderTranscriptOptions {
179
75
  head?: string; // render branch ending at this message ID
180
76
  sourcePath?: string; // absolute source path for front matter provenance
package/src/serve.ts ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Serve command: dynamic HTTP server for transcripts.
3
+ *
4
+ * Serves transcripts directly from source files, using the cache
5
+ * for rendered HTML and titles. No output directory needed.
6
+ */
7
+
8
+ import { getAdapters } from "./adapters/index.ts";
9
+ import type { Adapter, DiscoveredSession, Transcript } from "./types.ts";
10
+ import { renderTranscriptHtml } from "./render-html.ts";
11
+ 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";
21
+
22
+ export interface ServeOptions {
23
+ source: string;
24
+ port?: number;
25
+ quiet?: boolean;
26
+ noCache?: boolean;
27
+ }
28
+
29
+ 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
34
+ }
35
+
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
+ }
73
+
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;
97
+ }
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 };
107
+ }
108
+ }
109
+
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({});
130
+ }
131
+ newCache.segments[segmentIndex].html = html;
132
+ if (title) {
133
+ newCache.segments[segmentIndex].title = title;
134
+ }
135
+ await saveCache(session.source.path, newCache);
136
+ }
137
+
138
+ return { html, transcript, contentHash };
139
+ }
140
+
141
+ /**
142
+ * Build session entries for index page.
143
+ */
144
+ async function buildIndexEntries(
145
+ sessions: Map<string, SessionInfo[]>,
146
+ ): Promise<SessionEntry[]> {
147
+ const entries: SessionEntry[] = [];
148
+
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
+ }
189
+ }
190
+
191
+ return entries;
192
+ }
193
+
194
+ /**
195
+ * Start the HTTP server.
196
+ */
197
+ export async function serve(options: ServeOptions): Promise<void> {
198
+ const { source, port = 3000, quiet = false, noCache = false } = options;
199
+
200
+ if (!quiet) {
201
+ console.error(`Discovering sessions in ${source}...`);
202
+ }
203
+
204
+ // Discover sessions on startup
205
+ const sessions = await discoverSessions(source);
206
+
207
+ if (!quiet) {
208
+ console.error(`Found ${sessions.size} session(s)`);
209
+ console.error(`Starting server at http://localhost:${port}`);
210
+ }
211
+
212
+ const server = Bun.serve({
213
+ port,
214
+ async fetch(req) {
215
+ const url = new URL(req.url);
216
+ const path = url.pathname;
217
+
218
+ // Log request
219
+ if (!quiet) {
220
+ console.error(`${req.method} ${path}`);
221
+ }
222
+
223
+ // Index page
224
+ if (path === "/" || path === "/index.html") {
225
+ const entries = await buildIndexEntries(sessions);
226
+ const html = renderIndexFromSessions(entries);
227
+ return new Response(html, {
228
+ headers: { "Content-Type": "text/html; charset=utf-8" },
229
+ });
230
+ }
231
+
232
+ // Session page
233
+ if (path.endsWith(".html")) {
234
+ const baseName = path.slice(1, -5); // Remove leading "/" and ".html"
235
+
236
+ // First try exact match
237
+ const exactInfos = sessions.get(baseName);
238
+ if (exactInfos && exactInfos.length > 0) {
239
+ try {
240
+ const info = exactInfos[0];
241
+ const result = await getSessionHtml(
242
+ info,
243
+ info.segmentIndex,
244
+ noCache,
245
+ );
246
+ if (!result) {
247
+ return new Response("Not Found", { status: 404 });
248
+ }
249
+ return new Response(result.html, {
250
+ headers: { "Content-Type": "text/html; charset=utf-8" },
251
+ });
252
+ } catch (error) {
253
+ const message =
254
+ error instanceof Error ? error.message : String(error);
255
+ return new Response(`Error: ${message}`, { status: 500 });
256
+ }
257
+ }
258
+
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
+ return new Response("Not Found", { status: 404 });
289
+ }
290
+
291
+ return new Response("Not Found", { status: 404 });
292
+ },
293
+ });
294
+
295
+ // Keep server running
296
+ if (!quiet) {
297
+ console.error(`\nPress Ctrl+C to stop`);
298
+ }
299
+
300
+ // Handle shutdown gracefully
301
+ process.on("SIGINT", () => {
302
+ if (!quiet) {
303
+ console.error("\nShutting down...");
304
+ }
305
+ server.stop();
306
+ process.exit(0);
307
+ });
308
+ }
package/src/sync.ts CHANGED
@@ -1,31 +1,49 @@
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.
5
+ * and writes rendered output (markdown or HTML) to output directory.
6
6
  * Tracks provenance via transcripts.json index.
7
7
  */
8
8
 
9
9
  import { dirname, join } from "path";
10
10
  import { mkdir } from "fs/promises";
11
+ import { existsSync } from "fs";
11
12
  import { getAdapters } from "./adapters/index.ts";
12
- import type { Adapter, DiscoveredSession } from "./types.ts";
13
+ import type { Adapter, DiscoveredSession, Transcript } from "./types.ts";
13
14
  import { renderTranscript } from "./render.ts";
15
+ import { renderTranscriptHtml } from "./render-html.ts";
16
+ import { renderIndex } from "./render-index.ts";
14
17
  import { generateOutputName, extractSessionId } from "./utils/naming.ts";
15
18
  import {
16
19
  loadIndex,
17
20
  saveIndex,
18
- isStale,
19
21
  setEntry,
20
22
  removeEntriesForSource,
21
23
  restoreEntries,
22
24
  deleteOutputFiles,
23
25
  normalizeSourcePath,
26
+ extractFirstUserMessage,
27
+ getOutputsForSource,
28
+ type TranscriptsIndex,
24
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";
25
41
 
26
42
  export interface SyncOptions {
27
43
  source: string;
28
44
  output: string;
45
+ format?: OutputFormat;
46
+ noTitle?: boolean;
29
47
  force?: boolean;
30
48
  quiet?: boolean;
31
49
  }
@@ -40,12 +58,52 @@ interface SessionFile extends DiscoveredSession {
40
58
  adapter: Adapter;
41
59
  }
42
60
 
61
+ /**
62
+ * Render a transcript to the specified format.
63
+ */
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
+ }
72
+ return Promise.resolve(
73
+ renderTranscript(transcript, { sourcePath: options.sourcePath }),
74
+ );
75
+ }
76
+
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
+ }
91
+ }
92
+
43
93
  /**
44
94
  * Sync session files from source to output directory.
45
95
  */
46
96
  export async function sync(options: SyncOptions): Promise<SyncResult> {
47
- const { source, output, force = false, quiet = false } = options;
97
+ const {
98
+ source,
99
+ output,
100
+ format = "md",
101
+ noTitle = false,
102
+ force = false,
103
+ quiet = false,
104
+ } = options;
48
105
 
106
+ const ext = format === "html" ? ".html" : ".md";
49
107
  const result: SyncResult = { synced: 0, skipped: 0, errors: 0 };
50
108
 
51
109
  // Ensure output directory exists
@@ -78,16 +136,22 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
78
136
  const sourcePath = normalizeSourcePath(session.path);
79
137
 
80
138
  try {
81
- // Read and parse using the adapter that discovered this file
139
+ // Read source and compute content hash
82
140
  const content = await Bun.file(session.path).text();
83
- 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);
84
146
 
85
- // Check if sync needed (force or stale)
86
- const needsUpdate =
87
- force ||
88
- isStale(index, sourcePath, session.mtime, transcripts.length, output);
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)));
89
152
 
90
- if (!needsUpdate) {
153
+ if (!force && cachedSegments && outputsExist) {
154
+ // Cache hit and outputs exist - skip
91
155
  if (!quiet) {
92
156
  console.error(`Skip (up to date): ${session.relativePath}`);
93
157
  }
@@ -95,6 +159,10 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
95
159
  continue;
96
160
  }
97
161
 
162
+ // Need to sync: either cache miss, content changed, or force
163
+ // Parse the source
164
+ const transcripts = session.adapter.parse(content, session.path);
165
+
98
166
  // Remove entries from index (save for potential restoration on error)
99
167
  const removedEntries = removeEntriesForSource(index, sourcePath);
100
168
 
@@ -102,39 +170,65 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
102
170
  const newOutputs: string[] = [];
103
171
  const sessionId = extractSessionId(session.path);
104
172
 
173
+ // Build new cache entry
174
+ const newCache: CacheEntry = {
175
+ contentHash,
176
+ segments: [],
177
+ };
178
+
105
179
  try {
106
180
  // Generate fresh outputs for all transcripts
107
181
  for (let i = 0; i < transcripts.length; i++) {
108
182
  const transcript = transcripts[i];
109
183
  const segmentIndex = transcripts.length > 1 ? i + 1 : undefined;
110
184
 
185
+ // Extract first user message
186
+ const firstUserMessage = extractFirstUserMessage(transcript);
187
+
111
188
  // Generate deterministic name
112
189
  const baseName = generateOutputName(transcript, session.path);
113
190
  const suffix = segmentIndex ? `_${segmentIndex}` : "";
114
191
  const relativeDir = dirname(session.relativePath);
115
192
  const relativePath =
116
193
  relativeDir === "."
117
- ? `${baseName}${suffix}.md`
118
- : join(relativeDir, `${baseName}${suffix}.md`);
194
+ ? `${baseName}${suffix}${ext}`
195
+ : join(relativeDir, `${baseName}${suffix}${ext}`);
119
196
  const outputPath = join(output, relativePath);
120
197
 
121
198
  // Ensure output directory exists
122
199
  await mkdir(dirname(outputPath), { recursive: true });
123
200
 
124
- // Render with provenance front matter and write
125
- const markdown = renderTranscript(transcript, {
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, {
126
209
  sourcePath,
210
+ title: cachedTitle,
127
211
  });
128
- await Bun.write(outputPath, markdown);
212
+ await Bun.write(outputPath, rendered);
129
213
  newOutputs.push(relativePath);
130
214
 
215
+ // Build segment cache
216
+ const segmentCache: SegmentCache = { title: cachedTitle };
217
+ segmentCache[format] = rendered;
218
+ newCache.segments.push(segmentCache);
219
+
131
220
  // Update index
132
221
  setEntry(index, relativePath, {
133
222
  source: sourcePath,
134
- sourceMtime: session.mtime,
135
223
  sessionId,
136
224
  segmentIndex,
137
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,
138
232
  });
139
233
 
140
234
  if (!quiet) {
@@ -142,6 +236,9 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
142
236
  }
143
237
  }
144
238
 
239
+ // Save cache
240
+ await saveCache(sourcePath, newCache);
241
+
145
242
  // Success: delete old output files (after new ones are written)
146
243
  const oldFilenames = removedEntries.map((e) => e.filename);
147
244
  // Only delete files that aren't being reused
@@ -170,6 +267,22 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
170
267
  // Save index
171
268
  await saveIndex(output, index);
172
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(