@arcreflex/agent-transcripts 0.1.3 → 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 +26 -2
- package/package.json +1 -1
- package/src/adapters/claude-code.ts +1 -0
- package/src/adapters/index.ts +7 -0
- package/src/cli.ts +76 -17
- package/src/convert.ts +78 -0
- package/src/parse.ts +5 -5
- package/src/render.ts +22 -4
- package/src/sync.ts +181 -0
- package/src/types.ts +2 -0
- package/src/utils/provenance.ts +114 -0
package/CLAUDE.md
ADDED
package/README.md
CHANGED
|
@@ -15,9 +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
|
|
19
|
+
sync.ts # Batch sync sessions → markdown
|
|
18
20
|
types.ts # Core types (Transcript, Message, Adapter)
|
|
19
21
|
adapters/ # Source format adapters (currently: claude-code)
|
|
20
|
-
utils/
|
|
22
|
+
utils/
|
|
23
|
+
naming.ts # Descriptive output file naming
|
|
24
|
+
provenance.ts # Source tracking via YAML front matter
|
|
25
|
+
summary.ts # Summary extraction
|
|
21
26
|
test/
|
|
22
27
|
fixtures/ # Snapshot test inputs/outputs
|
|
23
28
|
snapshots.test.ts
|
|
@@ -31,6 +36,22 @@ bun run test # snapshot tests
|
|
|
31
36
|
bun run format # auto-format
|
|
32
37
|
```
|
|
33
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
|
+
|
|
34
55
|
## Architecture
|
|
35
56
|
|
|
36
57
|
Two-stage pipeline: Parse (source → JSON) → Render (JSON → markdown).
|
|
@@ -38,12 +59,15 @@ Two-stage pipeline: Parse (source → JSON) → Render (JSON → markdown).
|
|
|
38
59
|
- Adapters handle source formats (see `src/adapters/index.ts` for registry)
|
|
39
60
|
- Auto-detection: paths containing `.claude/` → claude-code adapter
|
|
40
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
|
|
41
65
|
|
|
42
66
|
## Key Types
|
|
43
67
|
|
|
44
68
|
- `Transcript`: source info, warnings, messages array
|
|
45
69
|
- `Message`: union of UserMessage | AssistantMessage | SystemMessage | ToolCallGroup | ErrorMessage
|
|
46
|
-
- `Adapter`:
|
|
70
|
+
- `Adapter`: name, file patterns, parse function
|
|
47
71
|
|
|
48
72
|
## Adding an Adapter
|
|
49
73
|
|
package/package.json
CHANGED
|
@@ -335,6 +335,7 @@ function transformConversation(
|
|
|
335
335
|
|
|
336
336
|
export const claudeCodeAdapter: Adapter = {
|
|
337
337
|
name: "claude-code",
|
|
338
|
+
filePatterns: ["*.jsonl"],
|
|
338
339
|
|
|
339
340
|
parse(content: string, sourcePath: string): Transcript[] {
|
|
340
341
|
const { records, warnings } = parseJsonl(content);
|
package/src/adapters/index.ts
CHANGED
|
@@ -43,3 +43,10 @@ export function getAdapter(name: string): Adapter | undefined {
|
|
|
43
43
|
export function listAdapters(): string[] {
|
|
44
44
|
return Object.keys(adapters);
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get all registered adapters.
|
|
49
|
+
*/
|
|
50
|
+
export function getAdapters(): Adapter[] {
|
|
51
|
+
return Object.values(adapters);
|
|
52
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -10,18 +10,21 @@ import {
|
|
|
10
10
|
option,
|
|
11
11
|
optional,
|
|
12
12
|
positional,
|
|
13
|
+
flag,
|
|
13
14
|
} from "cmd-ts";
|
|
14
15
|
import { parse, parseToTranscripts } from "./parse.ts";
|
|
15
16
|
import { render, renderTranscript } from "./render.ts";
|
|
17
|
+
import { sync } from "./sync.ts";
|
|
18
|
+
import { convertToDirectory } from "./convert.ts";
|
|
16
19
|
|
|
17
20
|
// Read OpenRouter API key from environment for LLM-based slug generation
|
|
18
21
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
|
19
22
|
|
|
20
23
|
// Shared options
|
|
21
24
|
const inputArg = positional({
|
|
22
|
-
type:
|
|
25
|
+
type: string,
|
|
23
26
|
displayName: "file",
|
|
24
|
-
description: "Input file (
|
|
27
|
+
description: "Input file (use - for stdin)",
|
|
25
28
|
});
|
|
26
29
|
|
|
27
30
|
const outputOpt = option({
|
|
@@ -84,10 +87,52 @@ const renderCmd = command({
|
|
|
84
87
|
},
|
|
85
88
|
});
|
|
86
89
|
|
|
87
|
-
//
|
|
88
|
-
const
|
|
89
|
-
name: "
|
|
90
|
-
description: "
|
|
90
|
+
// Sync subcommand
|
|
91
|
+
const syncCmd = command({
|
|
92
|
+
name: "sync",
|
|
93
|
+
description: "Sync session files to markdown transcripts",
|
|
94
|
+
args: {
|
|
95
|
+
source: positional({
|
|
96
|
+
type: string,
|
|
97
|
+
displayName: "source",
|
|
98
|
+
description: "Source directory to scan for session files",
|
|
99
|
+
}),
|
|
100
|
+
output: option({
|
|
101
|
+
type: string,
|
|
102
|
+
long: "output",
|
|
103
|
+
short: "o",
|
|
104
|
+
description: "Output directory for transcripts",
|
|
105
|
+
}),
|
|
106
|
+
force: flag({
|
|
107
|
+
long: "force",
|
|
108
|
+
short: "f",
|
|
109
|
+
description: "Re-render all sessions, ignoring mtime",
|
|
110
|
+
}),
|
|
111
|
+
quiet: flag({
|
|
112
|
+
long: "quiet",
|
|
113
|
+
short: "q",
|
|
114
|
+
description: "Suppress progress output",
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
async handler({ source, output, force, quiet }) {
|
|
118
|
+
const naming = OPENROUTER_API_KEY
|
|
119
|
+
? { apiKey: OPENROUTER_API_KEY }
|
|
120
|
+
: undefined;
|
|
121
|
+
await sync({ source, output, force, quiet, naming });
|
|
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)",
|
|
91
136
|
args: {
|
|
92
137
|
input: inputArg,
|
|
93
138
|
output: outputOpt,
|
|
@@ -99,15 +144,24 @@ const defaultCmd = command({
|
|
|
99
144
|
? { apiKey: OPENROUTER_API_KEY }
|
|
100
145
|
: undefined;
|
|
101
146
|
|
|
102
|
-
if (output) {
|
|
103
|
-
//
|
|
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
|
|
104
158
|
const { outputPaths } = await parse({ input, output, adapter, naming });
|
|
105
159
|
for (const jsonPath of outputPaths) {
|
|
106
160
|
const mdPath = jsonPath.replace(/\.json$/, ".md");
|
|
107
161
|
await render({ input: jsonPath, output: mdPath, head });
|
|
108
162
|
}
|
|
109
163
|
} else {
|
|
110
|
-
//
|
|
164
|
+
// No output: stream to stdout
|
|
111
165
|
const { transcripts } = await parseToTranscripts({ input, adapter });
|
|
112
166
|
for (let i = 0; i < transcripts.length; i++) {
|
|
113
167
|
if (i > 0) console.log(); // blank line between transcripts
|
|
@@ -117,24 +171,29 @@ const defaultCmd = command({
|
|
|
117
171
|
},
|
|
118
172
|
});
|
|
119
173
|
|
|
174
|
+
const SUBCOMMANDS = ["convert", "parse", "render", "sync"] as const;
|
|
175
|
+
|
|
120
176
|
// Main CLI with subcommands
|
|
121
177
|
const cli = subcommands({
|
|
122
178
|
name: "agent-transcripts",
|
|
123
179
|
description: "Transform agent session files to readable transcripts",
|
|
124
180
|
cmds: {
|
|
181
|
+
convert: convertCmd,
|
|
125
182
|
parse: parseCmd,
|
|
126
183
|
render: renderCmd,
|
|
184
|
+
sync: syncCmd,
|
|
127
185
|
},
|
|
128
|
-
// Default command when no subcommand is specified
|
|
129
186
|
});
|
|
130
187
|
|
|
131
188
|
// Run CLI
|
|
132
189
|
const args = process.argv.slice(2);
|
|
133
190
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync command: batch export sessions to markdown transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Discovers session files in source directory, parses them,
|
|
5
|
+
* and writes rendered markdown to output directory.
|
|
6
|
+
* Uses LLM-generated descriptive names when API key is available.
|
|
7
|
+
* Tracks provenance via YAML front matter to correlate updates.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Glob } from "bun";
|
|
11
|
+
import { dirname, join } from "path";
|
|
12
|
+
import { mkdir, stat } from "fs/promises";
|
|
13
|
+
import { getAdapters } from "./adapters/index.ts";
|
|
14
|
+
import type { Adapter } from "./types.ts";
|
|
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";
|
|
22
|
+
|
|
23
|
+
export interface SyncOptions {
|
|
24
|
+
source: string;
|
|
25
|
+
output: string;
|
|
26
|
+
force?: boolean;
|
|
27
|
+
quiet?: boolean;
|
|
28
|
+
naming?: NamingOptions;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SyncResult {
|
|
32
|
+
synced: number;
|
|
33
|
+
skipped: number;
|
|
34
|
+
errors: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SessionFile {
|
|
38
|
+
path: string;
|
|
39
|
+
relativePath: string;
|
|
40
|
+
mtime: number;
|
|
41
|
+
adapter: Adapter;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Discover session files for a specific adapter.
|
|
46
|
+
*/
|
|
47
|
+
async function discoverForAdapter(
|
|
48
|
+
source: string,
|
|
49
|
+
adapter: Adapter,
|
|
50
|
+
): Promise<SessionFile[]> {
|
|
51
|
+
const sessions: SessionFile[] = [];
|
|
52
|
+
|
|
53
|
+
for (const pattern of adapter.filePatterns) {
|
|
54
|
+
const glob = new Glob(`**/${pattern}`);
|
|
55
|
+
|
|
56
|
+
for await (const file of glob.scan({ cwd: source, absolute: false })) {
|
|
57
|
+
const fullPath = join(source, file);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const fileStat = await stat(fullPath);
|
|
61
|
+
sessions.push({
|
|
62
|
+
path: fullPath,
|
|
63
|
+
relativePath: file,
|
|
64
|
+
mtime: fileStat.mtime.getTime(),
|
|
65
|
+
adapter,
|
|
66
|
+
});
|
|
67
|
+
} catch {
|
|
68
|
+
// Skip files we can't stat
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return sessions;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sync session files from source to output directory.
|
|
78
|
+
*/
|
|
79
|
+
export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
80
|
+
const { source, output, force = false, quiet = false, naming } = options;
|
|
81
|
+
|
|
82
|
+
const result: SyncResult = { synced: 0, skipped: 0, errors: 0 };
|
|
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
|
+
|
|
96
|
+
// Discover sessions for each adapter
|
|
97
|
+
const sessions: SessionFile[] = [];
|
|
98
|
+
for (const adapter of getAdapters()) {
|
|
99
|
+
const adapterSessions = await discoverForAdapter(source, adapter);
|
|
100
|
+
sessions.push(...adapterSessions);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!quiet) {
|
|
104
|
+
console.error(`Found ${sessions.length} session file(s)`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Process each session
|
|
108
|
+
for (const session of sessions) {
|
|
109
|
+
try {
|
|
110
|
+
// Read and parse using the adapter that discovered this file
|
|
111
|
+
const content = await Bun.file(session.path).text();
|
|
112
|
+
const transcripts = session.adapter.parse(content, session.path);
|
|
113
|
+
|
|
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
|
|
137
|
+
for (let i = 0; i < transcripts.length; i++) {
|
|
138
|
+
const transcript = transcripts[i];
|
|
139
|
+
const suffix = transcripts.length > 1 ? `_${i + 1}` : undefined;
|
|
140
|
+
|
|
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`);
|
|
150
|
+
|
|
151
|
+
// Ensure output directory exists
|
|
152
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
153
|
+
|
|
154
|
+
// Render with provenance front matter and write
|
|
155
|
+
const markdown = renderTranscript(transcript, {
|
|
156
|
+
sourcePath: session.path,
|
|
157
|
+
});
|
|
158
|
+
await Bun.write(outputPath, markdown);
|
|
159
|
+
|
|
160
|
+
if (!quiet) {
|
|
161
|
+
console.error(`Synced: ${outputPath}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
result.synced++;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
168
|
+
console.error(`Error: ${session.relativePath}: ${message}`);
|
|
169
|
+
result.errors++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Summary
|
|
174
|
+
if (!quiet) {
|
|
175
|
+
console.error(
|
|
176
|
+
`\nSync complete: ${result.synced} synced, ${result.skipped} skipped, ${result.errors} errors`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -70,6 +70,8 @@ export interface ErrorMessage extends BaseMessage {
|
|
|
70
70
|
*/
|
|
71
71
|
export interface Adapter {
|
|
72
72
|
name: string;
|
|
73
|
+
/** Glob patterns for discovering session files (e.g., ["*.jsonl"]) */
|
|
74
|
+
filePatterns: string[];
|
|
73
75
|
/** Parse source content into one or more transcripts (split by conversation) */
|
|
74
76
|
parse(content: string, sourcePath: string): Transcript[];
|
|
75
77
|
}
|
|
@@ -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
|
+
}
|