@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/CLAUDE.md +3 -1
- package/README.md +60 -53
- package/package.json +1 -1
- package/src/adapters/claude-code.ts +1 -0
- package/src/adapters/index.ts +0 -6
- package/src/archive.ts +267 -0
- package/src/cli.ts +96 -63
- package/src/convert.ts +19 -86
- package/src/parse.ts +0 -3
- package/src/render-html.ts +38 -195
- package/src/render-index.ts +15 -178
- package/src/render.ts +25 -88
- package/src/serve.ts +124 -215
- package/src/title.ts +24 -102
- package/src/types.ts +3 -0
- package/src/utils/naming.ts +8 -13
- package/src/utils/summary.ts +1 -4
- package/src/utils/text.ts +5 -0
- package/src/utils/theme.ts +152 -0
- package/src/utils/tree.ts +85 -1
- package/src/watch.ts +111 -0
- package/test/archive.test.ts +264 -0
- package/test/fixtures/claude/branching.input.jsonl +6 -0
- package/test/fixtures/claude/branching.output.md +25 -0
- package/test/naming.test.ts +98 -0
- package/test/summary.test.ts +144 -0
- package/test/tree.test.ts +217 -0
- package/tsconfig.json +1 -1
- package/src/cache.ts +0 -129
- package/src/sync.ts +0 -295
- package/src/utils/provenance.ts +0 -212
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildTree,
|
|
4
|
+
findLatestLeaf,
|
|
5
|
+
tracePath,
|
|
6
|
+
getFirstLine,
|
|
7
|
+
} from "../src/utils/tree.ts";
|
|
8
|
+
import type { Message, UserMessage, AssistantMessage } from "../src/types.ts";
|
|
9
|
+
|
|
10
|
+
function userMsg(
|
|
11
|
+
sourceRef: string,
|
|
12
|
+
timestamp: string,
|
|
13
|
+
content: string,
|
|
14
|
+
parentMessageRef?: string,
|
|
15
|
+
): UserMessage {
|
|
16
|
+
return { type: "user", sourceRef, timestamp, content, parentMessageRef };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assistantMsg(
|
|
20
|
+
sourceRef: string,
|
|
21
|
+
timestamp: string,
|
|
22
|
+
content: string,
|
|
23
|
+
parentMessageRef?: string,
|
|
24
|
+
): AssistantMessage {
|
|
25
|
+
return { type: "assistant", sourceRef, timestamp, content, parentMessageRef };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("buildTree", () => {
|
|
29
|
+
it("builds a linear chain", () => {
|
|
30
|
+
const msgs: Message[] = [
|
|
31
|
+
userMsg("a", "2024-01-01T00:00:00Z", "hi"),
|
|
32
|
+
assistantMsg("b", "2024-01-01T00:01:00Z", "hello", "a"),
|
|
33
|
+
userMsg("c", "2024-01-01T00:02:00Z", "bye", "b"),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const tree = buildTree(msgs);
|
|
37
|
+
|
|
38
|
+
expect(tree.roots).toEqual(["a"]);
|
|
39
|
+
expect(tree.parents.get("b")).toBe("a");
|
|
40
|
+
expect(tree.parents.get("c")).toBe("b");
|
|
41
|
+
expect([...(tree.children.get("a") ?? [])]).toEqual(["b"]);
|
|
42
|
+
expect([...(tree.children.get("b") ?? [])]).toEqual(["c"]);
|
|
43
|
+
expect(tree.children.has("c")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("builds a tree with a branch point", () => {
|
|
47
|
+
const msgs: Message[] = [
|
|
48
|
+
userMsg("a", "2024-01-01T00:00:00Z", "root"),
|
|
49
|
+
assistantMsg("b", "2024-01-01T00:01:00Z", "reply", "a"),
|
|
50
|
+
userMsg("c", "2024-01-01T00:02:00Z", "branch 1", "b"),
|
|
51
|
+
userMsg("d", "2024-01-01T00:03:00Z", "branch 2", "b"),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const tree = buildTree(msgs);
|
|
55
|
+
|
|
56
|
+
expect(tree.roots).toEqual(["a"]);
|
|
57
|
+
const bChildren = [...(tree.children.get("b") ?? [])];
|
|
58
|
+
expect(bChildren.sort()).toEqual(["c", "d"]);
|
|
59
|
+
expect(tree.parents.get("c")).toBe("b");
|
|
60
|
+
expect(tree.parents.get("d")).toBe("b");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("handles empty messages", () => {
|
|
64
|
+
const tree = buildTree([]);
|
|
65
|
+
|
|
66
|
+
expect(tree.roots).toEqual([]);
|
|
67
|
+
expect(tree.bySourceRef.size).toBe(0);
|
|
68
|
+
expect(tree.children.size).toBe(0);
|
|
69
|
+
expect(tree.parents.size).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("handles a single message", () => {
|
|
73
|
+
const msgs: Message[] = [userMsg("only", "2024-01-01T00:00:00Z", "alone")];
|
|
74
|
+
|
|
75
|
+
const tree = buildTree(msgs);
|
|
76
|
+
|
|
77
|
+
expect(tree.roots).toEqual(["only"]);
|
|
78
|
+
expect(tree.parents.size).toBe(0);
|
|
79
|
+
expect(tree.children.size).toBe(0);
|
|
80
|
+
expect(tree.bySourceRef.get("only")).toEqual([msgs[0]]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("ignores parentMessageRef pointing to nonexistent message", () => {
|
|
84
|
+
const msgs: Message[] = [
|
|
85
|
+
userMsg("a", "2024-01-01T00:00:00Z", "hi", "nonexistent"),
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const tree = buildTree(msgs);
|
|
89
|
+
|
|
90
|
+
// "a" has an invalid parent, so it becomes a root
|
|
91
|
+
expect(tree.roots).toEqual(["a"]);
|
|
92
|
+
expect(tree.parents.size).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("groups multiple messages with same sourceRef", () => {
|
|
96
|
+
const msgs: Message[] = [
|
|
97
|
+
assistantMsg("x", "2024-01-01T00:00:00Z", "text part"),
|
|
98
|
+
{
|
|
99
|
+
type: "tool_calls",
|
|
100
|
+
sourceRef: "x",
|
|
101
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
102
|
+
calls: [{ name: "Read", summary: "/foo" }],
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const tree = buildTree(msgs);
|
|
107
|
+
|
|
108
|
+
expect(tree.bySourceRef.get("x")?.length).toBe(2);
|
|
109
|
+
expect(tree.roots).toEqual(["x"]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("findLatestLeaf", () => {
|
|
114
|
+
it("finds the leaf with the latest timestamp", () => {
|
|
115
|
+
const msgs: Message[] = [
|
|
116
|
+
userMsg("a", "2024-01-01T00:00:00Z", "root"),
|
|
117
|
+
assistantMsg("b", "2024-01-01T00:01:00Z", "reply", "a"),
|
|
118
|
+
userMsg("c", "2024-01-01T00:02:00Z", "early branch", "b"),
|
|
119
|
+
userMsg("d", "2024-01-01T00:05:00Z", "late branch", "b"),
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const tree = buildTree(msgs);
|
|
123
|
+
const leaf = findLatestLeaf(tree.bySourceRef, tree.children);
|
|
124
|
+
|
|
125
|
+
expect(leaf).toBe("d");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns undefined for empty maps", () => {
|
|
129
|
+
const leaf = findLatestLeaf(new Map(), new Map());
|
|
130
|
+
expect(leaf).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns the single node when there is only one", () => {
|
|
134
|
+
const msgs: Message[] = [userMsg("only", "2024-01-01T00:00:00Z", "alone")];
|
|
135
|
+
|
|
136
|
+
const tree = buildTree(msgs);
|
|
137
|
+
const leaf = findLatestLeaf(tree.bySourceRef, tree.children);
|
|
138
|
+
|
|
139
|
+
expect(leaf).toBe("only");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("skips non-leaf nodes", () => {
|
|
143
|
+
const msgs: Message[] = [
|
|
144
|
+
userMsg("a", "2024-01-01T10:00:00Z", "root"),
|
|
145
|
+
// "a" has a later timestamp, but it's not a leaf
|
|
146
|
+
assistantMsg("b", "2024-01-01T00:01:00Z", "reply", "a"),
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
const tree = buildTree(msgs);
|
|
150
|
+
const leaf = findLatestLeaf(tree.bySourceRef, tree.children);
|
|
151
|
+
|
|
152
|
+
expect(leaf).toBe("b");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("tracePath", () => {
|
|
157
|
+
it("traces a path from root to target", () => {
|
|
158
|
+
const parents = new Map([
|
|
159
|
+
["c", "b"],
|
|
160
|
+
["b", "a"],
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
expect(tracePath("c", parents)).toEqual(["a", "b", "c"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns single element for a root", () => {
|
|
167
|
+
expect(tracePath("root", new Map())).toEqual(["root"]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("handles deep chains", () => {
|
|
171
|
+
const parents = new Map([
|
|
172
|
+
["e", "d"],
|
|
173
|
+
["d", "c"],
|
|
174
|
+
["c", "b"],
|
|
175
|
+
["b", "a"],
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
expect(tracePath("e", parents)).toEqual(["a", "b", "c", "d", "e"]);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("getFirstLine", () => {
|
|
183
|
+
it("returns first line of user message", () => {
|
|
184
|
+
const msg = userMsg("x", "2024-01-01T00:00:00Z", "first\nsecond\nthird");
|
|
185
|
+
expect(getFirstLine(msg)).toBe("first");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("truncates long lines", () => {
|
|
189
|
+
const long = "a".repeat(100);
|
|
190
|
+
const msg = userMsg("x", "2024-01-01T00:00:00Z", long);
|
|
191
|
+
expect(getFirstLine(msg).length).toBe(63); // 60 + "..."
|
|
192
|
+
expect(getFirstLine(msg).endsWith("...")).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("returns tool names for tool_calls", () => {
|
|
196
|
+
const msg: Message = {
|
|
197
|
+
type: "tool_calls",
|
|
198
|
+
sourceRef: "x",
|
|
199
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
200
|
+
calls: [
|
|
201
|
+
{ name: "Read", summary: "/foo" },
|
|
202
|
+
{ name: "Write", summary: "/bar" },
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
expect(getFirstLine(msg)).toBe("Read, Write");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("returns empty string for unknown type content", () => {
|
|
209
|
+
const msg = assistantMsg("x", "2024-01-01T00:00:00Z", "");
|
|
210
|
+
expect(getFirstLine(msg)).toBe("");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("trims whitespace from first line", () => {
|
|
214
|
+
const msg = userMsg("x", "2024-01-01T00:00:00Z", " padded \nsecond");
|
|
215
|
+
expect(getFirstLine(msg)).toBe("padded");
|
|
216
|
+
});
|
|
217
|
+
});
|
package/tsconfig.json
CHANGED
package/src/cache.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cache module for agent-transcripts.
|
|
3
|
-
*
|
|
4
|
-
* Stores derived content (rendered outputs, titles) keyed by source path,
|
|
5
|
-
* invalidated by content hash. Cache lives at ~/.cache/agent-transcripts/.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { join } from "path";
|
|
9
|
-
import { homedir } from "os";
|
|
10
|
-
import { mkdir, rename, unlink } from "fs/promises";
|
|
11
|
-
|
|
12
|
-
const CACHE_DIR = join(homedir(), ".cache", "agent-transcripts");
|
|
13
|
-
|
|
14
|
-
export interface SegmentCache {
|
|
15
|
-
title?: string;
|
|
16
|
-
html?: string;
|
|
17
|
-
md?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface CacheEntry {
|
|
21
|
-
contentHash: string;
|
|
22
|
-
segments: SegmentCache[];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Compute a hash of file content for cache invalidation.
|
|
27
|
-
*/
|
|
28
|
-
export function computeContentHash(content: string): string {
|
|
29
|
-
return Bun.hash(content).toString(16);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Get the cache file path for a source file.
|
|
34
|
-
* Uses hash of source path to avoid filesystem issues with special chars.
|
|
35
|
-
*/
|
|
36
|
-
function getCachePath(sourcePath: string): string {
|
|
37
|
-
const pathHash = Bun.hash(sourcePath).toString(16);
|
|
38
|
-
return join(CACHE_DIR, `${pathHash}.json`);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Ensure cache directory exists.
|
|
43
|
-
*/
|
|
44
|
-
async function ensureCacheDir(): Promise<void> {
|
|
45
|
-
await mkdir(CACHE_DIR, { recursive: true, mode: 0o755 });
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Load cache entry for a source file.
|
|
50
|
-
* Returns undefined if no cache exists or cache is corrupt.
|
|
51
|
-
*/
|
|
52
|
-
export async function loadCache(
|
|
53
|
-
sourcePath: string,
|
|
54
|
-
): Promise<CacheEntry | undefined> {
|
|
55
|
-
const cachePath = getCachePath(sourcePath);
|
|
56
|
-
try {
|
|
57
|
-
const content = await Bun.file(cachePath).text();
|
|
58
|
-
return JSON.parse(content) as CacheEntry;
|
|
59
|
-
} catch {
|
|
60
|
-
return undefined;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Save cache entry for a source file.
|
|
66
|
-
* Uses atomic write (temp file + rename) to prevent corruption.
|
|
67
|
-
*/
|
|
68
|
-
export async function saveCache(
|
|
69
|
-
sourcePath: string,
|
|
70
|
-
entry: CacheEntry,
|
|
71
|
-
): Promise<void> {
|
|
72
|
-
await ensureCacheDir();
|
|
73
|
-
|
|
74
|
-
const cachePath = getCachePath(sourcePath);
|
|
75
|
-
const tmpPath = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
|
|
76
|
-
|
|
77
|
-
const content = JSON.stringify(entry, null, 2) + "\n";
|
|
78
|
-
await Bun.write(tmpPath, content);
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
await rename(tmpPath, cachePath);
|
|
82
|
-
} catch (err) {
|
|
83
|
-
try {
|
|
84
|
-
await unlink(tmpPath);
|
|
85
|
-
} catch {
|
|
86
|
-
// Ignore cleanup errors
|
|
87
|
-
}
|
|
88
|
-
throw err;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Check if cache is valid for the given content hash and format.
|
|
94
|
-
* Returns the cached segments if valid, undefined otherwise.
|
|
95
|
-
*/
|
|
96
|
-
export function getCachedSegments(
|
|
97
|
-
cached: CacheEntry | undefined,
|
|
98
|
-
contentHash: string,
|
|
99
|
-
format: "html" | "md",
|
|
100
|
-
): SegmentCache[] | undefined {
|
|
101
|
-
if (!cached || cached.contentHash !== contentHash) {
|
|
102
|
-
return undefined;
|
|
103
|
-
}
|
|
104
|
-
// Check that all segments have the requested format
|
|
105
|
-
if (cached.segments.length === 0) {
|
|
106
|
-
return undefined;
|
|
107
|
-
}
|
|
108
|
-
for (const seg of cached.segments) {
|
|
109
|
-
if (!seg[format]) {
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return cached.segments;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Get cached title for a specific segment.
|
|
118
|
-
* Returns undefined if cache is invalid or title not present.
|
|
119
|
-
*/
|
|
120
|
-
export function getCachedTitle(
|
|
121
|
-
cached: CacheEntry | undefined,
|
|
122
|
-
contentHash: string,
|
|
123
|
-
segmentIndex: number,
|
|
124
|
-
): string | undefined {
|
|
125
|
-
if (!cached || cached.contentHash !== contentHash) {
|
|
126
|
-
return undefined;
|
|
127
|
-
}
|
|
128
|
-
return cached.segments[segmentIndex]?.title;
|
|
129
|
-
}
|
package/src/sync.ts
DELETED
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sync command: batch export sessions to transcripts.
|
|
3
|
-
*
|
|
4
|
-
* Discovers session files in source directory, parses them,
|
|
5
|
-
* and writes rendered output (markdown or HTML) to output directory.
|
|
6
|
-
* Tracks provenance via transcripts.json index.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { dirname, join } from "path";
|
|
10
|
-
import { mkdir } from "fs/promises";
|
|
11
|
-
import { existsSync } from "fs";
|
|
12
|
-
import { getAdapters } from "./adapters/index.ts";
|
|
13
|
-
import type { Adapter, DiscoveredSession, Transcript } from "./types.ts";
|
|
14
|
-
import { renderTranscript } from "./render.ts";
|
|
15
|
-
import { renderTranscriptHtml } from "./render-html.ts";
|
|
16
|
-
import { renderIndex } from "./render-index.ts";
|
|
17
|
-
import { generateOutputName, extractSessionId } from "./utils/naming.ts";
|
|
18
|
-
import {
|
|
19
|
-
loadIndex,
|
|
20
|
-
saveIndex,
|
|
21
|
-
setEntry,
|
|
22
|
-
removeEntriesForSource,
|
|
23
|
-
restoreEntries,
|
|
24
|
-
deleteOutputFiles,
|
|
25
|
-
normalizeSourcePath,
|
|
26
|
-
extractFirstUserMessage,
|
|
27
|
-
getOutputsForSource,
|
|
28
|
-
type TranscriptsIndex,
|
|
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";
|
|
41
|
-
|
|
42
|
-
export interface SyncOptions {
|
|
43
|
-
source: string;
|
|
44
|
-
output: string;
|
|
45
|
-
format?: OutputFormat;
|
|
46
|
-
noTitle?: boolean;
|
|
47
|
-
force?: boolean;
|
|
48
|
-
quiet?: boolean;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface SyncResult {
|
|
52
|
-
synced: number;
|
|
53
|
-
skipped: number;
|
|
54
|
-
errors: number;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
interface SessionFile extends DiscoveredSession {
|
|
58
|
-
adapter: Adapter;
|
|
59
|
-
}
|
|
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
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Sync session files from source to output directory.
|
|
95
|
-
*/
|
|
96
|
-
export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
97
|
-
const {
|
|
98
|
-
source,
|
|
99
|
-
output,
|
|
100
|
-
format = "md",
|
|
101
|
-
noTitle = false,
|
|
102
|
-
force = false,
|
|
103
|
-
quiet = false,
|
|
104
|
-
} = options;
|
|
105
|
-
|
|
106
|
-
const ext = format === "html" ? ".html" : ".md";
|
|
107
|
-
const result: SyncResult = { synced: 0, skipped: 0, errors: 0 };
|
|
108
|
-
|
|
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) {
|
|
115
|
-
console.error(
|
|
116
|
-
`Found ${Object.keys(index.entries).length} existing transcript(s) in index`,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Discover sessions from all adapters
|
|
121
|
-
const sessions: SessionFile[] = [];
|
|
122
|
-
for (const adapter of getAdapters()) {
|
|
123
|
-
const discovered = await adapter.discover(source);
|
|
124
|
-
for (const session of discovered) {
|
|
125
|
-
sessions.push({ ...session, adapter });
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (!quiet) {
|
|
130
|
-
console.error(`Found ${sessions.length} session file(s)`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Process each session
|
|
134
|
-
for (const session of sessions) {
|
|
135
|
-
// Normalize source path for consistent index keys
|
|
136
|
-
const sourcePath = normalizeSourcePath(session.path);
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
// Read source and compute content hash
|
|
140
|
-
const content = await Bun.file(session.path).text();
|
|
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)));
|
|
152
|
-
|
|
153
|
-
if (!force && cachedSegments && outputsExist) {
|
|
154
|
-
// Cache hit and outputs exist - skip
|
|
155
|
-
if (!quiet) {
|
|
156
|
-
console.error(`Skip (up to date): ${session.relativePath}`);
|
|
157
|
-
}
|
|
158
|
-
result.skipped++;
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
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
|
-
|
|
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
|
-
// Use title from: (1) harness-provided summary, (2) cache, (3) LLM later
|
|
202
|
-
const title =
|
|
203
|
-
session.summary ||
|
|
204
|
-
(cached?.contentHash === contentHash
|
|
205
|
-
? cached.segments[i]?.title
|
|
206
|
-
: undefined);
|
|
207
|
-
|
|
208
|
-
// Render and write
|
|
209
|
-
const rendered = await renderToFormat(transcript, format, {
|
|
210
|
-
sourcePath,
|
|
211
|
-
title,
|
|
212
|
-
});
|
|
213
|
-
await Bun.write(outputPath, rendered);
|
|
214
|
-
newOutputs.push(relativePath);
|
|
215
|
-
|
|
216
|
-
// Build segment cache
|
|
217
|
-
const segmentCache: SegmentCache = { title };
|
|
218
|
-
segmentCache[format] = rendered;
|
|
219
|
-
newCache.segments.push(segmentCache);
|
|
220
|
-
|
|
221
|
-
// Update index
|
|
222
|
-
setEntry(index, relativePath, {
|
|
223
|
-
source: sourcePath,
|
|
224
|
-
sessionId,
|
|
225
|
-
segmentIndex,
|
|
226
|
-
syncedAt: new Date().toISOString(),
|
|
227
|
-
firstUserMessage,
|
|
228
|
-
title,
|
|
229
|
-
messageCount: transcript.metadata.messageCount,
|
|
230
|
-
startTime: transcript.metadata.startTime,
|
|
231
|
-
endTime: transcript.metadata.endTime,
|
|
232
|
-
cwd: transcript.metadata.cwd,
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
if (!quiet) {
|
|
236
|
-
console.error(`Synced: ${outputPath}`);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Save cache
|
|
241
|
-
await saveCache(sourcePath, newCache);
|
|
242
|
-
|
|
243
|
-
// Success: delete old output files (after new ones are written)
|
|
244
|
-
const oldFilenames = removedEntries.map((e) => e.filename);
|
|
245
|
-
// Only delete files that aren't being reused
|
|
246
|
-
const toDelete = oldFilenames.filter((f) => !newOutputs.includes(f));
|
|
247
|
-
if (toDelete.length > 0) {
|
|
248
|
-
await deleteOutputFiles(output, toDelete, quiet);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
result.synced++;
|
|
252
|
-
} catch (error) {
|
|
253
|
-
// Clean up any newly written files before restoring old entries
|
|
254
|
-
if (newOutputs.length > 0) {
|
|
255
|
-
await deleteOutputFiles(output, newOutputs, quiet);
|
|
256
|
-
}
|
|
257
|
-
// Restore old entries on error to preserve provenance
|
|
258
|
-
restoreEntries(index, removedEntries);
|
|
259
|
-
throw error;
|
|
260
|
-
}
|
|
261
|
-
} catch (error) {
|
|
262
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
263
|
-
console.error(`Error: ${session.relativePath}: ${message}`);
|
|
264
|
-
result.errors++;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Save index
|
|
269
|
-
await saveIndex(output, index);
|
|
270
|
-
|
|
271
|
-
// Generate titles for HTML format (unless --no-title)
|
|
272
|
-
if (format === "html" && !noTitle) {
|
|
273
|
-
if (!quiet) {
|
|
274
|
-
console.error("\nGenerating titles...");
|
|
275
|
-
}
|
|
276
|
-
await generateTitles({ outputDir: output, quiet });
|
|
277
|
-
|
|
278
|
-
// Reload index after title generation and regenerate index.html
|
|
279
|
-
const updatedIndex = await loadIndex(output);
|
|
280
|
-
await writeIndexHtml(output, updatedIndex, quiet);
|
|
281
|
-
} else if (format === "html") {
|
|
282
|
-
// Generate index.html without titles
|
|
283
|
-
const updatedIndex = await loadIndex(output);
|
|
284
|
-
await writeIndexHtml(output, updatedIndex, quiet);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Summary
|
|
288
|
-
if (!quiet) {
|
|
289
|
-
console.error(
|
|
290
|
-
`\nSync complete: ${result.synced} synced, ${result.skipped} skipped, ${result.errors} errors`,
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
return result;
|
|
295
|
-
}
|