@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/CLAUDE.md +4 -0
- package/README.md +40 -3
- package/bun.lock +89 -0
- package/package.json +3 -2
- package/src/adapters/claude-code.ts +203 -32
- package/src/cache.ts +129 -0
- package/src/cli.ts +86 -5
- package/src/convert.ts +6 -14
- package/src/render-html.ts +1096 -0
- package/src/render-index.ts +611 -0
- package/src/render.ts +6 -110
- package/src/serve.ts +308 -0
- package/src/sync.ts +131 -18
- package/src/title.ts +172 -0
- package/src/types.ts +7 -0
- package/src/utils/html.ts +12 -0
- package/src/utils/openrouter.ts +116 -0
- package/src/utils/provenance.ts +25 -41
- package/src/utils/tree.ts +116 -0
- package/test/fixtures/claude/non-message-parents.input.jsonl +9 -0
- package/test/fixtures/claude/non-message-parents.output.md +30 -0
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
|
|
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 {
|
|
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
|
|
139
|
+
// Read source and compute content hash
|
|
82
140
|
const content = await Bun.file(session.path).text();
|
|
83
|
-
const
|
|
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
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
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 (!
|
|
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}
|
|
118
|
-
: join(relativeDir, `${baseName}${suffix}
|
|
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
|
-
//
|
|
125
|
-
const
|
|
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,
|
|
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(
|