@arcreflex/agent-transcripts 0.1.4 → 0.1.5
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 +10 -0
- package/README.md +24 -1
- package/package.json +1 -1
- package/src/cli.ts +43 -19
- package/src/convert.ts +78 -0
- package/src/parse.ts +5 -5
- package/src/render.ts +22 -4
- package/src/sync.ts +61 -55
- package/src/utils/provenance.ts +114 -0
package/CLAUDE.md
ADDED
package/README.md
CHANGED
|
@@ -15,10 +15,14 @@ src/
|
|
|
15
15
|
cli.ts # CLI entry point, subcommand routing
|
|
16
16
|
parse.ts # Source → intermediate JSON
|
|
17
17
|
render.ts # Intermediate JSON → markdown
|
|
18
|
+
convert.ts # Full pipeline with provenance tracking
|
|
18
19
|
sync.ts # Batch sync sessions → markdown
|
|
19
20
|
types.ts # Core types (Transcript, Message, Adapter)
|
|
20
21
|
adapters/ # Source format adapters (currently: claude-code)
|
|
21
|
-
utils/
|
|
22
|
+
utils/
|
|
23
|
+
naming.ts # Descriptive output file naming
|
|
24
|
+
provenance.ts # Source tracking via YAML front matter
|
|
25
|
+
summary.ts # Summary extraction
|
|
22
26
|
test/
|
|
23
27
|
fixtures/ # Snapshot test inputs/outputs
|
|
24
28
|
snapshots.test.ts
|
|
@@ -32,6 +36,22 @@ bun run test # snapshot tests
|
|
|
32
36
|
bun run format # auto-format
|
|
33
37
|
```
|
|
34
38
|
|
|
39
|
+
## CLI Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Subcommands (convert is default if omitted)
|
|
43
|
+
agent-transcripts convert <file> # Full pipeline: parse → render
|
|
44
|
+
agent-transcripts parse <file> # Source → intermediate JSON
|
|
45
|
+
agent-transcripts render <file> # JSON → markdown
|
|
46
|
+
agent-transcripts sync <dir> -o <out> # Batch sync sessions
|
|
47
|
+
|
|
48
|
+
# Use "-" for stdin
|
|
49
|
+
cat session.jsonl | agent-transcripts -
|
|
50
|
+
|
|
51
|
+
# Environment variables
|
|
52
|
+
OPENROUTER_API_KEY=... # Enables LLM-based descriptive output naming
|
|
53
|
+
```
|
|
54
|
+
|
|
35
55
|
## Architecture
|
|
36
56
|
|
|
37
57
|
Two-stage pipeline: Parse (source → JSON) → Render (JSON → markdown).
|
|
@@ -39,6 +59,9 @@ Two-stage pipeline: Parse (source → JSON) → Render (JSON → markdown).
|
|
|
39
59
|
- Adapters handle source formats (see `src/adapters/index.ts` for registry)
|
|
40
60
|
- Auto-detection: paths containing `.claude/` → claude-code adapter
|
|
41
61
|
- Branching conversations preserved via `parentMessageRef` on messages
|
|
62
|
+
- Provenance tracking: rendered markdown includes YAML front matter with source path
|
|
63
|
+
- Descriptive naming: output files named by date + summary (LLM-enhanced if API key set)
|
|
64
|
+
- Sync uses mtime to skip unchanged sources
|
|
42
65
|
|
|
43
66
|
## Key Types
|
|
44
67
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -15,15 +15,16 @@ import {
|
|
|
15
15
|
import { parse, parseToTranscripts } from "./parse.ts";
|
|
16
16
|
import { render, renderTranscript } from "./render.ts";
|
|
17
17
|
import { sync } from "./sync.ts";
|
|
18
|
+
import { convertToDirectory } from "./convert.ts";
|
|
18
19
|
|
|
19
20
|
// Read OpenRouter API key from environment for LLM-based slug generation
|
|
20
21
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
|
21
22
|
|
|
22
23
|
// Shared options
|
|
23
24
|
const inputArg = positional({
|
|
24
|
-
type:
|
|
25
|
+
type: string,
|
|
25
26
|
displayName: "file",
|
|
26
|
-
description: "Input file (
|
|
27
|
+
description: "Input file (use - for stdin)",
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
const outputOpt = option({
|
|
@@ -100,7 +101,7 @@ const syncCmd = command({
|
|
|
100
101
|
type: string,
|
|
101
102
|
long: "output",
|
|
102
103
|
short: "o",
|
|
103
|
-
description: "Output directory
|
|
104
|
+
description: "Output directory for transcripts",
|
|
104
105
|
}),
|
|
105
106
|
force: flag({
|
|
106
107
|
long: "force",
|
|
@@ -114,14 +115,24 @@ const syncCmd = command({
|
|
|
114
115
|
}),
|
|
115
116
|
},
|
|
116
117
|
async handler({ source, output, force, quiet }) {
|
|
117
|
-
|
|
118
|
+
const naming = OPENROUTER_API_KEY
|
|
119
|
+
? { apiKey: OPENROUTER_API_KEY }
|
|
120
|
+
: undefined;
|
|
121
|
+
await sync({ source, output, force, quiet, naming });
|
|
118
122
|
},
|
|
119
123
|
});
|
|
120
124
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Check if output looks like a directory (no extension) vs a specific file.
|
|
127
|
+
*/
|
|
128
|
+
function isDirectoryOutput(output: string): boolean {
|
|
129
|
+
return !output.match(/\.\w+$/);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Convert subcommand: full pipeline (parse → render) - the default
|
|
133
|
+
const convertCmd = command({
|
|
134
|
+
name: "convert",
|
|
135
|
+
description: "Full pipeline: parse source and render to markdown (default)",
|
|
125
136
|
args: {
|
|
126
137
|
input: inputArg,
|
|
127
138
|
output: outputOpt,
|
|
@@ -133,15 +144,24 @@ const defaultCmd = command({
|
|
|
133
144
|
? { apiKey: OPENROUTER_API_KEY }
|
|
134
145
|
: undefined;
|
|
135
146
|
|
|
136
|
-
if (output) {
|
|
137
|
-
//
|
|
147
|
+
if (output && isDirectoryOutput(output)) {
|
|
148
|
+
// Directory output: use sync-like behavior with provenance tracking
|
|
149
|
+
await convertToDirectory({
|
|
150
|
+
input,
|
|
151
|
+
outputDir: output,
|
|
152
|
+
adapter,
|
|
153
|
+
head,
|
|
154
|
+
naming,
|
|
155
|
+
});
|
|
156
|
+
} else if (output) {
|
|
157
|
+
// Explicit file output: write intermediate JSON and markdown
|
|
138
158
|
const { outputPaths } = await parse({ input, output, adapter, naming });
|
|
139
159
|
for (const jsonPath of outputPaths) {
|
|
140
160
|
const mdPath = jsonPath.replace(/\.json$/, ".md");
|
|
141
161
|
await render({ input: jsonPath, output: mdPath, head });
|
|
142
162
|
}
|
|
143
163
|
} else {
|
|
144
|
-
//
|
|
164
|
+
// No output: stream to stdout
|
|
145
165
|
const { transcripts } = await parseToTranscripts({ input, adapter });
|
|
146
166
|
for (let i = 0; i < transcripts.length; i++) {
|
|
147
167
|
if (i > 0) console.log(); // blank line between transcripts
|
|
@@ -151,25 +171,29 @@ const defaultCmd = command({
|
|
|
151
171
|
},
|
|
152
172
|
});
|
|
153
173
|
|
|
174
|
+
const SUBCOMMANDS = ["convert", "parse", "render", "sync"] as const;
|
|
175
|
+
|
|
154
176
|
// Main CLI with subcommands
|
|
155
177
|
const cli = subcommands({
|
|
156
178
|
name: "agent-transcripts",
|
|
157
179
|
description: "Transform agent session files to readable transcripts",
|
|
158
180
|
cmds: {
|
|
181
|
+
convert: convertCmd,
|
|
159
182
|
parse: parseCmd,
|
|
160
183
|
render: renderCmd,
|
|
161
184
|
sync: syncCmd,
|
|
162
185
|
},
|
|
163
|
-
// Default command when no subcommand is specified
|
|
164
186
|
});
|
|
165
187
|
|
|
166
188
|
// Run CLI
|
|
167
189
|
const args = process.argv.slice(2);
|
|
168
190
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
191
|
+
// If first arg isn't a subcommand (and isn't a help flag), prepend "convert" as the default
|
|
192
|
+
const isSubcommand =
|
|
193
|
+
args.length > 0 &&
|
|
194
|
+
SUBCOMMANDS.includes(args[0] as (typeof SUBCOMMANDS)[number]);
|
|
195
|
+
const isHelpFlag =
|
|
196
|
+
args.length === 0 || args[0] === "--help" || args[0] === "-h";
|
|
197
|
+
const effectiveArgs = isSubcommand || isHelpFlag ? args : ["convert", ...args];
|
|
198
|
+
|
|
199
|
+
run(cli, effectiveArgs);
|
package/src/convert.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert command: full pipeline with provenance tracking.
|
|
3
|
+
*
|
|
4
|
+
* When output is a directory, uses the same replace-existing behavior
|
|
5
|
+
* as sync: scans for existing outputs by provenance and replaces them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { dirname, join, resolve } from "path";
|
|
9
|
+
import { mkdir, stat } from "fs/promises";
|
|
10
|
+
import { parseToTranscripts } from "./parse.ts";
|
|
11
|
+
import { renderTranscript } from "./render.ts";
|
|
12
|
+
import { generateOutputName, type NamingOptions } from "./utils/naming.ts";
|
|
13
|
+
import {
|
|
14
|
+
findExistingOutputs,
|
|
15
|
+
deleteExistingOutputs,
|
|
16
|
+
} from "./utils/provenance.ts";
|
|
17
|
+
|
|
18
|
+
export interface ConvertToDirectoryOptions {
|
|
19
|
+
input: string;
|
|
20
|
+
outputDir: string;
|
|
21
|
+
adapter?: string;
|
|
22
|
+
head?: string;
|
|
23
|
+
naming?: NamingOptions;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert source file to markdown in output directory.
|
|
28
|
+
* Uses provenance tracking to replace existing outputs.
|
|
29
|
+
*/
|
|
30
|
+
export async function convertToDirectory(
|
|
31
|
+
options: ConvertToDirectoryOptions,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const { input, outputDir, adapter, head, naming } = options;
|
|
34
|
+
|
|
35
|
+
// Parse input to transcripts
|
|
36
|
+
const { transcripts, inputPath } = await parseToTranscripts({
|
|
37
|
+
input,
|
|
38
|
+
adapter,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Resolve absolute source path for provenance tracking
|
|
42
|
+
const sourcePath = inputPath === "<stdin>" ? "<stdin>" : resolve(inputPath);
|
|
43
|
+
|
|
44
|
+
// Find and delete existing outputs for this source
|
|
45
|
+
if (sourcePath !== "<stdin>") {
|
|
46
|
+
const existingOutputs = await findExistingOutputs(outputDir, sourcePath);
|
|
47
|
+
if (existingOutputs.length > 0) {
|
|
48
|
+
await deleteExistingOutputs(existingOutputs);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Generate fresh outputs
|
|
53
|
+
for (let i = 0; i < transcripts.length; i++) {
|
|
54
|
+
const transcript = transcripts[i];
|
|
55
|
+
const suffix = transcripts.length > 1 ? `_${i + 1}` : undefined;
|
|
56
|
+
|
|
57
|
+
// Generate descriptive name
|
|
58
|
+
const baseName = await generateOutputName(
|
|
59
|
+
transcript,
|
|
60
|
+
inputPath,
|
|
61
|
+
naming || {},
|
|
62
|
+
);
|
|
63
|
+
const finalName = suffix ? `${baseName}${suffix}` : baseName;
|
|
64
|
+
const outputPath = join(outputDir, `${finalName}.md`);
|
|
65
|
+
|
|
66
|
+
// Ensure output directory exists
|
|
67
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
68
|
+
|
|
69
|
+
// Render with provenance front matter
|
|
70
|
+
const markdown = renderTranscript(transcript, {
|
|
71
|
+
head,
|
|
72
|
+
sourcePath: sourcePath !== "<stdin>" ? sourcePath : undefined,
|
|
73
|
+
});
|
|
74
|
+
await Bun.write(outputPath, markdown);
|
|
75
|
+
|
|
76
|
+
console.error(`Wrote: ${outputPath}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/parse.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { detectAdapter, getAdapter, listAdapters } from "./adapters/index.ts";
|
|
|
9
9
|
import { generateOutputName, type NamingOptions } from "./utils/naming.ts";
|
|
10
10
|
|
|
11
11
|
export interface ParseOptions {
|
|
12
|
-
input
|
|
12
|
+
input: string; // file path, or "-" for stdin
|
|
13
13
|
output?: string; // output path/dir
|
|
14
14
|
adapter?: string; // explicit adapter name
|
|
15
15
|
naming?: NamingOptions; // options for output file naming
|
|
@@ -19,14 +19,14 @@ export interface ParseOptions {
|
|
|
19
19
|
* Read input content from file or stdin.
|
|
20
20
|
*/
|
|
21
21
|
async function readInput(
|
|
22
|
-
input
|
|
22
|
+
input: string,
|
|
23
23
|
): Promise<{ content: string; path: string }> {
|
|
24
|
-
if (input) {
|
|
24
|
+
if (input !== "-") {
|
|
25
25
|
const content = await Bun.file(input).text();
|
|
26
26
|
return { content, path: input };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Read from stdin
|
|
29
|
+
// Read from stdin (when input is "-")
|
|
30
30
|
const chunks: string[] = [];
|
|
31
31
|
const reader = Bun.stdin.stream().getReader();
|
|
32
32
|
|
|
@@ -115,7 +115,7 @@ export async function parseToTranscripts(
|
|
|
115
115
|
|
|
116
116
|
// Determine adapter
|
|
117
117
|
let adapterName = options.adapter;
|
|
118
|
-
if (!adapterName && options.input) {
|
|
118
|
+
if (!adapterName && options.input !== "-") {
|
|
119
119
|
adapterName = detectAdapter(options.input);
|
|
120
120
|
}
|
|
121
121
|
|
package/src/render.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { mkdir } from "fs/promises";
|
|
|
7
7
|
import type { Transcript, Message, ToolCall } from "./types.ts";
|
|
8
8
|
|
|
9
9
|
export interface RenderOptions {
|
|
10
|
-
input
|
|
10
|
+
input: string; // file path, or "-" for stdin
|
|
11
11
|
output?: string; // output path
|
|
12
12
|
head?: string; // render branch ending at this message ID
|
|
13
13
|
}
|
|
@@ -16,12 +16,12 @@ export interface RenderOptions {
|
|
|
16
16
|
* Read transcript from file or stdin.
|
|
17
17
|
*/
|
|
18
18
|
async function readTranscript(
|
|
19
|
-
input
|
|
19
|
+
input: string,
|
|
20
20
|
): Promise<{ transcript: Transcript; path: string }> {
|
|
21
21
|
let content: string;
|
|
22
22
|
let path: string;
|
|
23
23
|
|
|
24
|
-
if (input) {
|
|
24
|
+
if (input !== "-") {
|
|
25
25
|
content = await Bun.file(input).text();
|
|
26
26
|
path = input;
|
|
27
27
|
} else {
|
|
@@ -213,15 +213,33 @@ function tracePath(target: string, parents: Map<string, string>): string[] {
|
|
|
213
213
|
return path;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
export interface RenderTranscriptOptions {
|
|
217
|
+
head?: string; // render branch ending at this message ID
|
|
218
|
+
sourcePath?: string; // absolute source path for front matter provenance
|
|
219
|
+
}
|
|
220
|
+
|
|
216
221
|
/**
|
|
217
222
|
* Render transcript to markdown with branch awareness.
|
|
218
223
|
*/
|
|
219
224
|
export function renderTranscript(
|
|
220
225
|
transcript: Transcript,
|
|
221
|
-
|
|
226
|
+
options: RenderTranscriptOptions | string = {},
|
|
222
227
|
): string {
|
|
228
|
+
// Support legacy signature: renderTranscript(transcript, head?: string)
|
|
229
|
+
const opts: RenderTranscriptOptions =
|
|
230
|
+
typeof options === "string" ? { head: options } : options;
|
|
231
|
+
const { head, sourcePath } = opts;
|
|
232
|
+
|
|
223
233
|
const lines: string[] = [];
|
|
224
234
|
|
|
235
|
+
// YAML front matter (for provenance tracking)
|
|
236
|
+
if (sourcePath) {
|
|
237
|
+
lines.push("---");
|
|
238
|
+
lines.push(`source: ${sourcePath}`);
|
|
239
|
+
lines.push("---");
|
|
240
|
+
lines.push("");
|
|
241
|
+
}
|
|
242
|
+
|
|
225
243
|
// Header
|
|
226
244
|
lines.push("# Transcript");
|
|
227
245
|
lines.push("");
|
package/src/sync.ts
CHANGED
|
@@ -3,21 +3,29 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Discovers session files in source directory, parses them,
|
|
5
5
|
* and writes rendered markdown to output directory.
|
|
6
|
-
*
|
|
6
|
+
* Uses LLM-generated descriptive names when API key is available.
|
|
7
|
+
* Tracks provenance via YAML front matter to correlate updates.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { Glob } from "bun";
|
|
10
|
-
import { dirname, join
|
|
11
|
+
import { dirname, join } from "path";
|
|
11
12
|
import { mkdir, stat } from "fs/promises";
|
|
12
13
|
import { getAdapters } from "./adapters/index.ts";
|
|
13
14
|
import type { Adapter } from "./types.ts";
|
|
14
15
|
import { renderTranscript } from "./render.ts";
|
|
16
|
+
import { generateOutputName, type NamingOptions } from "./utils/naming.ts";
|
|
17
|
+
import {
|
|
18
|
+
scanOutputDirectory,
|
|
19
|
+
deleteExistingOutputs,
|
|
20
|
+
hasStaleOutputs,
|
|
21
|
+
} from "./utils/provenance.ts";
|
|
15
22
|
|
|
16
23
|
export interface SyncOptions {
|
|
17
24
|
source: string;
|
|
18
25
|
output: string;
|
|
19
26
|
force?: boolean;
|
|
20
27
|
quiet?: boolean;
|
|
28
|
+
naming?: NamingOptions;
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
export interface SyncResult {
|
|
@@ -65,49 +73,26 @@ async function discoverForAdapter(
|
|
|
65
73
|
return sessions;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
|
-
/**
|
|
69
|
-
* Compute output path for a session file.
|
|
70
|
-
* Mirrors input structure, changing extension to .md.
|
|
71
|
-
*/
|
|
72
|
-
function computeOutputPath(
|
|
73
|
-
relativePath: string,
|
|
74
|
-
outputDir: string,
|
|
75
|
-
suffix?: string,
|
|
76
|
-
): string {
|
|
77
|
-
// Replace extension with .md
|
|
78
|
-
const mdPath = relativePath.replace(/\.[^.]+$/, ".md");
|
|
79
|
-
// Add suffix if provided (for multiple transcripts from same file)
|
|
80
|
-
const finalPath = suffix ? mdPath.replace(/\.md$/, `${suffix}.md`) : mdPath;
|
|
81
|
-
return join(outputDir, finalPath);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Check if output file needs to be re-rendered based on mtime.
|
|
86
|
-
*/
|
|
87
|
-
async function needsSync(
|
|
88
|
-
outputPath: string,
|
|
89
|
-
sourceMtime: number,
|
|
90
|
-
force: boolean,
|
|
91
|
-
): Promise<boolean> {
|
|
92
|
-
if (force) return true;
|
|
93
|
-
|
|
94
|
-
try {
|
|
95
|
-
const outputStat = await stat(outputPath);
|
|
96
|
-
return outputStat.mtime.getTime() < sourceMtime;
|
|
97
|
-
} catch {
|
|
98
|
-
// Output doesn't exist, needs sync
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
76
|
/**
|
|
104
77
|
* Sync session files from source to output directory.
|
|
105
78
|
*/
|
|
106
79
|
export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
107
|
-
const { source, output, force = false, quiet = false } = options;
|
|
80
|
+
const { source, output, force = false, quiet = false, naming } = options;
|
|
108
81
|
|
|
109
82
|
const result: SyncResult = { synced: 0, skipped: 0, errors: 0 };
|
|
110
83
|
|
|
84
|
+
// Scan output directory for existing transcripts (source → output paths)
|
|
85
|
+
const existingOutputs = await scanOutputDirectory(output);
|
|
86
|
+
if (!quiet && existingOutputs.size > 0) {
|
|
87
|
+
const totalFiles = [...existingOutputs.values()].reduce(
|
|
88
|
+
(sum, paths) => sum + paths.length,
|
|
89
|
+
0,
|
|
90
|
+
);
|
|
91
|
+
console.error(
|
|
92
|
+
`Found ${totalFiles} existing transcript(s) from ${existingOutputs.size} source(s)`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
111
96
|
// Discover sessions for each adapter
|
|
112
97
|
const sessions: SessionFile[] = [];
|
|
113
98
|
for (const adapter of getAdapters()) {
|
|
@@ -126,37 +111,58 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
126
111
|
const content = await Bun.file(session.path).text();
|
|
127
112
|
const transcripts = session.adapter.parse(content, session.path);
|
|
128
113
|
|
|
129
|
-
//
|
|
114
|
+
// Get all existing outputs for this source
|
|
115
|
+
const existingPaths = existingOutputs.get(session.path) || [];
|
|
116
|
+
|
|
117
|
+
// Check if sync needed (force, count mismatch, or any stale)
|
|
118
|
+
const needsUpdate =
|
|
119
|
+
force ||
|
|
120
|
+
(await hasStaleOutputs(
|
|
121
|
+
existingPaths,
|
|
122
|
+
transcripts.length,
|
|
123
|
+
session.mtime,
|
|
124
|
+
));
|
|
125
|
+
if (!needsUpdate) {
|
|
126
|
+
if (!quiet) {
|
|
127
|
+
console.error(`Skip (up to date): ${session.relativePath}`);
|
|
128
|
+
}
|
|
129
|
+
result.skipped++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Delete existing outputs before regenerating
|
|
134
|
+
await deleteExistingOutputs(existingPaths, quiet);
|
|
135
|
+
|
|
136
|
+
// Generate fresh outputs for all transcripts
|
|
130
137
|
for (let i = 0; i < transcripts.length; i++) {
|
|
131
138
|
const transcript = transcripts[i];
|
|
132
139
|
const suffix = transcripts.length > 1 ? `_${i + 1}` : undefined;
|
|
133
|
-
const outputPath = computeOutputPath(
|
|
134
|
-
session.relativePath,
|
|
135
|
-
output,
|
|
136
|
-
suffix,
|
|
137
|
-
);
|
|
138
140
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
// Generate descriptive name, preserving directory structure
|
|
142
|
+
const baseName = await generateOutputName(
|
|
143
|
+
transcript,
|
|
144
|
+
session.path,
|
|
145
|
+
naming || {},
|
|
146
|
+
);
|
|
147
|
+
const finalName = suffix ? `${baseName}${suffix}` : baseName;
|
|
148
|
+
const relativeDir = dirname(session.relativePath);
|
|
149
|
+
const outputPath = join(output, relativeDir, `${finalName}.md`);
|
|
147
150
|
|
|
148
151
|
// Ensure output directory exists
|
|
149
152
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
150
153
|
|
|
151
|
-
// Render and write
|
|
152
|
-
const markdown = renderTranscript(transcript
|
|
154
|
+
// Render with provenance front matter and write
|
|
155
|
+
const markdown = renderTranscript(transcript, {
|
|
156
|
+
sourcePath: session.path,
|
|
157
|
+
});
|
|
153
158
|
await Bun.write(outputPath, markdown);
|
|
154
159
|
|
|
155
160
|
if (!quiet) {
|
|
156
161
|
console.error(`Synced: ${outputPath}`);
|
|
157
162
|
}
|
|
158
|
-
result.synced++;
|
|
159
163
|
}
|
|
164
|
+
|
|
165
|
+
result.synced++;
|
|
160
166
|
} catch (error) {
|
|
161
167
|
const message = error instanceof Error ? error.message : String(error);
|
|
162
168
|
console.error(`Error: ${session.relativePath}: ${message}`);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provenance tracking utilities.
|
|
3
|
+
*
|
|
4
|
+
* Tracks the relationship between source files and output transcripts
|
|
5
|
+
* via YAML front matter, enabling update-in-place behavior.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Glob } from "bun";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { stat, unlink } from "fs/promises";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract source path from YAML front matter.
|
|
14
|
+
* Returns null if no front matter or no source field.
|
|
15
|
+
*/
|
|
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();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Scan output directory for existing transcripts.
|
|
33
|
+
* Returns map from absolute source path → all output file paths for that source.
|
|
34
|
+
*/
|
|
35
|
+
export async function scanOutputDirectory(
|
|
36
|
+
outputDir: string,
|
|
37
|
+
): Promise<Map<string, string[]>> {
|
|
38
|
+
const sourceToOutputs = new Map<string, string[]>();
|
|
39
|
+
const glob = new Glob("**/*.md");
|
|
40
|
+
|
|
41
|
+
for await (const file of glob.scan({ cwd: outputDir, absolute: false })) {
|
|
42
|
+
const fullPath = join(outputDir, file);
|
|
43
|
+
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
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Skip files we can't read
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return sourceToOutputs;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Find existing outputs for a specific source path.
|
|
61
|
+
*/
|
|
62
|
+
export async function findExistingOutputs(
|
|
63
|
+
outputDir: string,
|
|
64
|
+
sourcePath: string,
|
|
65
|
+
): Promise<string[]> {
|
|
66
|
+
const allOutputs = await scanOutputDirectory(outputDir);
|
|
67
|
+
return allOutputs.get(sourcePath) || [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Delete existing output files, with warnings on failure.
|
|
72
|
+
*/
|
|
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}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if any outputs are stale relative to source mtime.
|
|
93
|
+
*/
|
|
94
|
+
export async function hasStaleOutputs(
|
|
95
|
+
existingOutputs: string[],
|
|
96
|
+
expectedCount: number,
|
|
97
|
+
sourceMtime: number,
|
|
98
|
+
): Promise<boolean> {
|
|
99
|
+
if (existingOutputs.length !== expectedCount) return true;
|
|
100
|
+
|
|
101
|
+
for (const outputPath of existingOutputs) {
|
|
102
|
+
try {
|
|
103
|
+
const outputStat = await stat(outputPath);
|
|
104
|
+
if (outputStat.mtime.getTime() < sourceMtime) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// Output doesn't exist
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return false;
|
|
114
|
+
}
|