@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.
@@ -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
@@ -11,5 +11,5 @@
11
11
  "esModuleInterop": true,
12
12
  "resolveJsonModule": true
13
13
  },
14
- "include": ["src/**/*"]
14
+ "include": ["src/**/*", "test/**/*"]
15
15
  }
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
- }