@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.
- package/.github/workflows/publish.yml +5 -0
- package/CLAUDE.md +10 -0
- package/README.md +44 -5
- package/package.json +1 -1
- package/src/adapters/claude-code.ts +97 -1
- package/src/cli.ts +45 -75
- package/src/convert.ts +126 -0
- package/src/parse.ts +12 -106
- package/src/render.ts +20 -85
- package/src/sync.ts +109 -103
- package/src/types.ts +11 -2
- package/src/utils/naming.ts +30 -143
- package/src/utils/provenance.ts +228 -0
- package/test/snapshots.test.ts +39 -33
|
@@ -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
|
+
}
|
package/test/snapshots.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
29
|
-
const
|
|
30
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|