@arcreflex/agent-transcripts 0.1.5 → 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.
@@ -2,113 +2,227 @@
2
2
  * Provenance tracking utilities.
3
3
  *
4
4
  * Tracks the relationship between source files and output transcripts
5
- * via YAML front matter, enabling update-in-place behavior.
5
+ * via transcripts.json index (primary) and YAML front matter (for self-documenting files).
6
6
  */
7
7
 
8
- import { Glob } from "bun";
9
- import { join } from "path";
10
- import { stat, unlink } from "fs/promises";
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
+ // ============================================================================
11
46
 
12
47
  /**
13
- * Extract source path from YAML front matter.
14
- * Returns null if no front matter or no source field.
48
+ * Load transcripts.json index from output directory.
49
+ * Returns empty index if file doesn't exist. Warns on corrupt file.
15
50
  */
16
- export function extractSourceFromFrontMatter(content: string): string | null {
17
- // Match YAML front matter at start of file
18
- const match = content.match(/^---\n([\s\S]*?)\n---/);
19
- if (!match) return null;
20
-
21
- // Extract source field (simple line-based parsing)
22
- const frontMatter = match[1];
23
- const sourceLine = frontMatter
24
- .split("\n")
25
- .find((line) => line.startsWith("source:"));
26
- if (!sourceLine) return null;
27
-
28
- return sourceLine.replace(/^source:\s*/, "").trim();
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
+ }
29
75
  }
30
76
 
31
77
  /**
32
- * Scan output directory for existing transcripts.
33
- * Returns map from absolute source path all output file paths for that source.
78
+ * Save transcripts.json index to output directory.
79
+ * Uses atomic write (write to .tmp, then rename) to prevent corruption.
34
80
  */
35
- export async function scanOutputDirectory(
81
+ export async function saveIndex(
36
82
  outputDir: string,
37
- ): Promise<Map<string, string[]>> {
38
- const sourceToOutputs = new Map<string, string[]>();
39
- const glob = new Glob("**/*.md");
83
+ index: TranscriptsIndex,
84
+ ): Promise<void> {
85
+ const indexPath = join(outputDir, INDEX_FILENAME);
86
+ const tmpPath = `${indexPath}.tmp`;
40
87
 
41
- for await (const file of glob.scan({ cwd: outputDir, absolute: false })) {
42
- const fullPath = join(outputDir, file);
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
43
94
  try {
44
- const content = await Bun.file(fullPath).text();
45
- const sourcePath = extractSourceFromFrontMatter(content);
46
- if (sourcePath) {
47
- const existing = sourceToOutputs.get(sourcePath) || [];
48
- existing.push(fullPath);
49
- sourceToOutputs.set(sourcePath, existing);
50
- }
95
+ await unlink(tmpPath);
51
96
  } catch {
52
- // Skip files we can't read
97
+ // Ignore cleanup errors
53
98
  }
99
+ throw err;
54
100
  }
101
+ }
55
102
 
56
- return sourceToOutputs;
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;
57
121
  }
58
122
 
59
123
  /**
60
- * Find existing outputs for a specific source path.
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
61
130
  */
62
- export async function findExistingOutputs(
63
- outputDir: string,
131
+ export function isStale(
132
+ index: TranscriptsIndex,
64
133
  sourcePath: string,
65
- ): Promise<string[]> {
66
- const allOutputs = await scanOutputDirectory(outputDir);
67
- return allOutputs.get(sourcePath) || [];
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;
68
160
  }
69
161
 
70
162
  /**
71
- * Delete existing output files, with warnings on failure.
163
+ * Set or update an entry in the index.
164
+ * outputPath should be relative to the output directory.
72
165
  */
73
- export async function deleteExistingOutputs(
74
- paths: string[],
75
- quiet = false,
76
- ): Promise<void> {
77
- for (const oldPath of paths) {
78
- try {
79
- await unlink(oldPath);
80
- if (!quiet) {
81
- console.error(`Deleted: ${oldPath}`);
82
- }
83
- } catch (err) {
84
- // Warn but continue - file may already be gone or have permission issues
85
- const msg = err instanceof Error ? err.message : String(err);
86
- console.error(`Warning: could not delete ${oldPath}: ${msg}`);
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];
87
187
  }
88
188
  }
189
+ return removed;
89
190
  }
90
191
 
91
192
  /**
92
- * Check if any outputs are stale relative to source mtime.
193
+ * Restore previously removed entries to the index.
93
194
  */
94
- export async function hasStaleOutputs(
95
- existingOutputs: string[],
96
- expectedCount: number,
97
- sourceMtime: number,
98
- ): Promise<boolean> {
99
- if (existingOutputs.length !== expectedCount) return true;
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
+ // ============================================================================
100
207
 
101
- for (const outputPath of existingOutputs) {
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);
102
218
  try {
103
- const outputStat = await stat(outputPath);
104
- if (outputStat.mtime.getTime() < sourceMtime) {
105
- return true;
219
+ await unlink(fullPath);
220
+ if (!quiet) {
221
+ console.error(`Deleted: ${fullPath}`);
106
222
  }
107
- } catch {
108
- // Output doesn't exist
109
- return true;
223
+ } catch (err) {
224
+ const msg = err instanceof Error ? err.message : String(err);
225
+ console.error(`Warning: could not delete ${fullPath}: ${msg}`);
110
226
  }
111
227
  }
112
-
113
- return false;
114
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
+ });