@arcreflex/agent-transcripts 0.1.4 → 0.1.8

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,228 @@
1
+ /**
2
+ * Provenance tracking utilities.
3
+ *
4
+ * Tracks the relationship between source files and output transcripts
5
+ * via transcripts.json index (primary) and YAML front matter (for self-documenting files).
6
+ */
7
+
8
+ import { join, resolve } from "path";
9
+ import { existsSync } from "fs";
10
+ import { rename, unlink } from "fs/promises";
11
+
12
+ const INDEX_FILENAME = "transcripts.json";
13
+
14
+ // ============================================================================
15
+ // Index Types
16
+ // ============================================================================
17
+
18
+ export interface TranscriptEntry {
19
+ source: string; // absolute path to source
20
+ sourceMtime: number; // ms since epoch
21
+ sessionId: string; // full session ID from source filename
22
+ segmentIndex?: number; // for multi-transcript sources (1-indexed)
23
+ syncedAt: string; // ISO timestamp
24
+ }
25
+
26
+ export interface TranscriptsIndex {
27
+ version: 1;
28
+ entries: Record<string, TranscriptEntry>; // outputFilename → entry
29
+ }
30
+
31
+ // ============================================================================
32
+ // Path Utilities
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Normalize a source path to absolute for consistent index keys.
37
+ */
38
+ export function normalizeSourcePath(sourcePath: string): string {
39
+ if (sourcePath === "<stdin>") return sourcePath;
40
+ return resolve(sourcePath);
41
+ }
42
+
43
+ // ============================================================================
44
+ // Index I/O
45
+ // ============================================================================
46
+
47
+ /**
48
+ * Load transcripts.json index from output directory.
49
+ * Returns empty index if file doesn't exist. Warns on corrupt file.
50
+ */
51
+ export async function loadIndex(outputDir: string): Promise<TranscriptsIndex> {
52
+ const indexPath = join(outputDir, INDEX_FILENAME);
53
+ try {
54
+ const content = await Bun.file(indexPath).text();
55
+ const data = JSON.parse(content) as TranscriptsIndex;
56
+ // Validate version
57
+ if (data.version !== 1) {
58
+ console.error(
59
+ `Warning: Unknown index version ${data.version}, creating fresh index`,
60
+ );
61
+ return { version: 1, entries: {} };
62
+ }
63
+ return data;
64
+ } catch (err) {
65
+ // Distinguish between missing file (expected) and corrupt file (unexpected)
66
+ const isEnoent =
67
+ err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT";
68
+ if (!isEnoent) {
69
+ console.error(
70
+ `Warning: Could not parse index file, starting fresh: ${err instanceof Error ? err.message : String(err)}`,
71
+ );
72
+ }
73
+ return { version: 1, entries: {} };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Save transcripts.json index to output directory.
79
+ * Uses atomic write (write to .tmp, then rename) to prevent corruption.
80
+ */
81
+ export async function saveIndex(
82
+ outputDir: string,
83
+ index: TranscriptsIndex,
84
+ ): Promise<void> {
85
+ const indexPath = join(outputDir, INDEX_FILENAME);
86
+ const tmpPath = `${indexPath}.tmp`;
87
+
88
+ const content = JSON.stringify(index, null, 2) + "\n";
89
+ await Bun.write(tmpPath, content);
90
+ try {
91
+ await rename(tmpPath, indexPath);
92
+ } catch (err) {
93
+ // Clean up temp file on failure
94
+ try {
95
+ await unlink(tmpPath);
96
+ } catch {
97
+ // Ignore cleanup errors
98
+ }
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ // ============================================================================
104
+ // Index Operations
105
+ // ============================================================================
106
+
107
+ /**
108
+ * Get all output filenames for a given source path.
109
+ */
110
+ export function getOutputsForSource(
111
+ index: TranscriptsIndex,
112
+ sourcePath: string,
113
+ ): string[] {
114
+ const outputs: string[] = [];
115
+ for (const [filename, entry] of Object.entries(index.entries)) {
116
+ if (entry.source === sourcePath) {
117
+ outputs.push(filename);
118
+ }
119
+ }
120
+ return outputs;
121
+ }
122
+
123
+ /**
124
+ * Check if outputs for a source are stale.
125
+ * Returns true if:
126
+ * - No outputs exist for this source
127
+ * - Output count doesn't match expected
128
+ * - Any output file is missing from disk
129
+ * - Source mtime is newer than recorded mtime
130
+ */
131
+ export function isStale(
132
+ index: TranscriptsIndex,
133
+ sourcePath: string,
134
+ sourceMtime: number,
135
+ expectedCount: number,
136
+ outputDir: string,
137
+ ): boolean {
138
+ const outputs = getOutputsForSource(index, sourcePath);
139
+
140
+ if (outputs.length !== expectedCount) {
141
+ return true;
142
+ }
143
+
144
+ // Check if outputs actually exist on disk
145
+ for (const filename of outputs) {
146
+ if (!existsSync(join(outputDir, filename))) {
147
+ return true;
148
+ }
149
+ }
150
+
151
+ // Check if source has been modified since last sync
152
+ for (const filename of outputs) {
153
+ const entry = index.entries[filename];
154
+ if (entry && entry.sourceMtime < sourceMtime) {
155
+ return true;
156
+ }
157
+ }
158
+
159
+ return false;
160
+ }
161
+
162
+ /**
163
+ * Set or update an entry in the index.
164
+ * outputPath should be relative to the output directory.
165
+ */
166
+ export function setEntry(
167
+ index: TranscriptsIndex,
168
+ outputPath: string,
169
+ entry: TranscriptEntry,
170
+ ): void {
171
+ index.entries[outputPath] = entry;
172
+ }
173
+
174
+ /**
175
+ * Remove all entries for a given source path.
176
+ * Returns the removed entries (for potential restoration on error).
177
+ */
178
+ export function removeEntriesForSource(
179
+ index: TranscriptsIndex,
180
+ sourcePath: string,
181
+ ): Array<{ filename: string; entry: TranscriptEntry }> {
182
+ const removed: Array<{ filename: string; entry: TranscriptEntry }> = [];
183
+ for (const [filename, entry] of Object.entries(index.entries)) {
184
+ if (entry.source === sourcePath) {
185
+ removed.push({ filename, entry });
186
+ delete index.entries[filename];
187
+ }
188
+ }
189
+ return removed;
190
+ }
191
+
192
+ /**
193
+ * Restore previously removed entries to the index.
194
+ */
195
+ export function restoreEntries(
196
+ index: TranscriptsIndex,
197
+ entries: Array<{ filename: string; entry: TranscriptEntry }>,
198
+ ): void {
199
+ for (const { filename, entry } of entries) {
200
+ index.entries[filename] = entry;
201
+ }
202
+ }
203
+
204
+ // ============================================================================
205
+ // File Operations
206
+ // ============================================================================
207
+
208
+ /**
209
+ * Delete output files, with warnings on failure.
210
+ */
211
+ export async function deleteOutputFiles(
212
+ outputDir: string,
213
+ filenames: string[],
214
+ quiet = false,
215
+ ): Promise<void> {
216
+ for (const filename of filenames) {
217
+ const fullPath = join(outputDir, filename);
218
+ try {
219
+ await unlink(fullPath);
220
+ if (!quiet) {
221
+ console.error(`Deleted: ${fullPath}`);
222
+ }
223
+ } catch (err) {
224
+ const msg = err instanceof Error ? err.message : String(err);
225
+ console.error(`Warning: could not delete ${fullPath}: ${msg}`);
226
+ }
227
+ }
228
+ }
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { join, dirname } from "path";
3
3
  import { Glob } from "bun";
4
+ import { getAdapter } from "../src/adapters/index.ts";
5
+ import { renderTranscript } from "../src/render.ts";
4
6
 
5
7
  const fixturesDir = join(dirname(import.meta.path), "fixtures/claude");
6
8
  const binPath = join(dirname(import.meta.path), "../bin/agent-transcripts");
@@ -25,43 +27,47 @@ describe("snapshot tests", () => {
25
27
 
26
28
  const expectedOutput = await Bun.file(expectedPath).text();
27
29
 
28
- // Run the CLI: parse to temp JSON, then render to temp MD
29
- const tempJson = `/tmp/test-${name}-${Date.now()}.json`;
30
- const tempMd = `/tmp/test-${name}-${Date.now()}.md`;
30
+ // Direct function call: parse with adapter, then render
31
+ const adapter = getAdapter("claude-code")!;
32
+ const content = await Bun.file(relativeInputPath).text();
33
+ const transcripts = adapter.parse(content, relativeInputPath);
31
34
 
32
- // Parse
33
- const parseResult = Bun.spawnSync([
34
- binPath,
35
- "parse",
36
- relativeInputPath,
37
- "--adapter",
38
- "claude-code",
39
- "-o",
40
- tempJson,
41
- ]);
42
- expect(parseResult.exitCode).toBe(0);
35
+ expect(transcripts.length).toBeGreaterThan(0);
43
36
 
44
- // Render
45
- const renderResult = Bun.spawnSync([
46
- binPath,
47
- "render",
48
- tempJson,
49
- "-o",
50
- tempMd,
51
- ]);
52
- expect(renderResult.exitCode).toBe(0);
37
+ // Render the first transcript (our fixtures are single-transcript)
38
+ const actualOutput = renderTranscript(transcripts[0]);
53
39
 
54
- // Compare output
55
- const actualOutput = await Bun.file(tempMd).text();
56
40
  expect(actualOutput.trimEnd()).toBe(expectedOutput.trimEnd());
57
-
58
- // Cleanup
59
- await Bun.file(tempJson)
60
- .delete()
61
- .catch(() => {});
62
- await Bun.file(tempMd)
63
- .delete()
64
- .catch(() => {});
65
41
  });
66
42
  }
67
43
  });
44
+
45
+ describe("CLI integration", () => {
46
+ it("convert to stdout works", async () => {
47
+ const inputFile = inputFiles[0];
48
+ if (!inputFile) {
49
+ throw new Error("No input fixtures found");
50
+ }
51
+
52
+ const relativeInputPath = `test/fixtures/claude/${inputFile}`;
53
+ const expectedPath = join(
54
+ fixturesDir,
55
+ inputFile.replace(".input.jsonl", ".output.md"),
56
+ );
57
+ const expectedOutput = await Bun.file(expectedPath).text();
58
+
59
+ // Run CLI: convert with stdout output
60
+ const result = Bun.spawnSync([
61
+ binPath,
62
+ "convert",
63
+ relativeInputPath,
64
+ "--adapter",
65
+ "claude-code",
66
+ ]);
67
+
68
+ expect(result.exitCode).toBe(0);
69
+
70
+ const actualOutput = result.stdout.toString();
71
+ expect(actualOutput.trimEnd()).toBe(expectedOutput.trimEnd());
72
+ });
73
+ });