@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/.github/workflows/publish.yml +5 -0
- package/CLAUDE.md +4 -0
- package/README.md +70 -17
- package/bun.lock +89 -0
- package/package.json +3 -2
- package/src/adapters/claude-code.ts +300 -33
- package/src/cache.ts +129 -0
- package/src/cli.ts +95 -68
- package/src/convert.ts +82 -42
- package/src/parse.ts +7 -101
- package/src/render-html.ts +1096 -0
- package/src/render-index.ts +611 -0
- package/src/render.ts +7 -194
- package/src/serve.ts +308 -0
- package/src/sync.ts +211 -98
- package/src/title.ts +172 -0
- package/src/types.ts +18 -2
- package/src/utils/html.ts +12 -0
- package/src/utils/naming.ts +30 -143
- package/src/utils/openrouter.ts +116 -0
- package/src/utils/provenance.ts +167 -69
- 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/test/snapshots.test.ts +39 -33
package/src/sync.ts
CHANGED
|
@@ -1,31 +1,51 @@
|
|
|
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.
|
|
6
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
*
|
|
62
|
+
* Render a transcript to the specified format.
|
|
46
63
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 ${
|
|
116
|
+
`Found ${Object.keys(index.entries).length} existing transcript(s) in index`,
|
|
93
117
|
);
|
|
94
118
|
}
|
|
95
119
|
|
|
96
|
-
// Discover sessions
|
|
120
|
+
// Discover sessions from all adapters
|
|
97
121
|
const sessions: SessionFile[] = [];
|
|
98
122
|
for (const adapter of getAdapters()) {
|
|
99
|
-
const
|
|
100
|
-
|
|
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
|
|
139
|
+
// Read source and compute content hash
|
|
111
140
|
const content = await Bun.file(session.path).text();
|
|
112
|
-
const
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
//
|
|
134
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
74
|
-
|
|
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
|
}
|