@arcreflex/agent-transcripts 0.1.2 → 0.1.4
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/README.md +2 -1
- package/package.json +1 -1
- package/src/adapters/claude-code.ts +1 -0
- package/src/adapters/index.ts +7 -0
- package/src/cli.ts +49 -3
- package/src/parse.ts +44 -21
- package/src/sync.ts +175 -0
- package/src/types.ts +2 -0
- package/src/utils/naming.ts +173 -0
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ 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
|
+
sync.ts # Batch sync sessions → markdown
|
|
18
19
|
types.ts # Core types (Transcript, Message, Adapter)
|
|
19
20
|
adapters/ # Source format adapters (currently: claude-code)
|
|
20
21
|
utils/ # Helpers (summary extraction)
|
|
@@ -43,7 +44,7 @@ Two-stage pipeline: Parse (source → JSON) → Render (JSON → markdown).
|
|
|
43
44
|
|
|
44
45
|
- `Transcript`: source info, warnings, messages array
|
|
45
46
|
- `Message`: union of UserMessage | AssistantMessage | SystemMessage | ToolCallGroup | ErrorMessage
|
|
46
|
-
- `Adapter`:
|
|
47
|
+
- `Adapter`: name, file patterns, parse function
|
|
47
48
|
|
|
48
49
|
## Adding an Adapter
|
|
49
50
|
|
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,9 +10,14 @@ 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
|
+
|
|
19
|
+
// Read OpenRouter API key from environment for LLM-based slug generation
|
|
20
|
+
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
|
16
21
|
|
|
17
22
|
// Shared options
|
|
18
23
|
const inputArg = positional({
|
|
@@ -51,8 +56,12 @@ const parseCmd = command({
|
|
|
51
56
|
adapter: adapterOpt,
|
|
52
57
|
},
|
|
53
58
|
async handler({ input, output, adapter }) {
|
|
59
|
+
const naming = OPENROUTER_API_KEY
|
|
60
|
+
? { apiKey: OPENROUTER_API_KEY }
|
|
61
|
+
: undefined;
|
|
62
|
+
|
|
54
63
|
if (output) {
|
|
55
|
-
await parse({ input, output, adapter });
|
|
64
|
+
await parse({ input, output, adapter, naming });
|
|
56
65
|
} else {
|
|
57
66
|
// Print JSONL to stdout (one transcript per line)
|
|
58
67
|
const { transcripts } = await parseToTranscripts({ input, adapter });
|
|
@@ -77,6 +86,38 @@ const renderCmd = command({
|
|
|
77
86
|
},
|
|
78
87
|
});
|
|
79
88
|
|
|
89
|
+
// Sync subcommand
|
|
90
|
+
const syncCmd = command({
|
|
91
|
+
name: "sync",
|
|
92
|
+
description: "Sync session files to markdown transcripts",
|
|
93
|
+
args: {
|
|
94
|
+
source: positional({
|
|
95
|
+
type: string,
|
|
96
|
+
displayName: "source",
|
|
97
|
+
description: "Source directory to scan for session files",
|
|
98
|
+
}),
|
|
99
|
+
output: option({
|
|
100
|
+
type: string,
|
|
101
|
+
long: "output",
|
|
102
|
+
short: "o",
|
|
103
|
+
description: "Output directory (mirrors source structure)",
|
|
104
|
+
}),
|
|
105
|
+
force: flag({
|
|
106
|
+
long: "force",
|
|
107
|
+
short: "f",
|
|
108
|
+
description: "Re-render all sessions, ignoring mtime",
|
|
109
|
+
}),
|
|
110
|
+
quiet: flag({
|
|
111
|
+
long: "quiet",
|
|
112
|
+
short: "q",
|
|
113
|
+
description: "Suppress progress output",
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
async handler({ source, output, force, quiet }) {
|
|
117
|
+
await sync({ source, output, force, quiet });
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
80
121
|
// Default command: full pipeline (parse → render)
|
|
81
122
|
const defaultCmd = command({
|
|
82
123
|
name: "agent-transcripts",
|
|
@@ -88,9 +129,13 @@ const defaultCmd = command({
|
|
|
88
129
|
head: headOpt,
|
|
89
130
|
},
|
|
90
131
|
async handler({ input, output, adapter, head }) {
|
|
132
|
+
const naming = OPENROUTER_API_KEY
|
|
133
|
+
? { apiKey: OPENROUTER_API_KEY }
|
|
134
|
+
: undefined;
|
|
135
|
+
|
|
91
136
|
if (output) {
|
|
92
137
|
// Write intermediate JSON and markdown files
|
|
93
|
-
const { outputPaths } = await parse({ input, output, adapter });
|
|
138
|
+
const { outputPaths } = await parse({ input, output, adapter, naming });
|
|
94
139
|
for (const jsonPath of outputPaths) {
|
|
95
140
|
const mdPath = jsonPath.replace(/\.json$/, ".md");
|
|
96
141
|
await render({ input: jsonPath, output: mdPath, head });
|
|
@@ -113,6 +158,7 @@ const cli = subcommands({
|
|
|
113
158
|
cmds: {
|
|
114
159
|
parse: parseCmd,
|
|
115
160
|
render: renderCmd,
|
|
161
|
+
sync: syncCmd,
|
|
116
162
|
},
|
|
117
163
|
// Default command when no subcommand is specified
|
|
118
164
|
});
|
|
@@ -121,7 +167,7 @@ const cli = subcommands({
|
|
|
121
167
|
const args = process.argv.slice(2);
|
|
122
168
|
|
|
123
169
|
// Check if first arg is a subcommand
|
|
124
|
-
if (args[0] === "parse" || args[0] === "render") {
|
|
170
|
+
if (args[0] === "parse" || args[0] === "render" || args[0] === "sync") {
|
|
125
171
|
run(cli, args);
|
|
126
172
|
} else {
|
|
127
173
|
// Run default command for full pipeline
|
package/src/parse.ts
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
* Parse command: source format → intermediate JSON
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
6
|
import { mkdir } from "fs/promises";
|
|
7
7
|
import type { Transcript } from "./types.ts";
|
|
8
8
|
import { detectAdapter, getAdapter, listAdapters } from "./adapters/index.ts";
|
|
9
|
+
import { generateOutputName, type NamingOptions } from "./utils/naming.ts";
|
|
9
10
|
|
|
10
11
|
export interface ParseOptions {
|
|
11
12
|
input?: string; // file path, undefined for stdin
|
|
12
13
|
output?: string; // output path/dir
|
|
13
14
|
adapter?: string; // explicit adapter name
|
|
15
|
+
naming?: NamingOptions; // options for output file naming
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -40,27 +42,24 @@ async function readInput(
|
|
|
40
42
|
/**
|
|
41
43
|
* Determine output file paths for transcripts.
|
|
42
44
|
*/
|
|
43
|
-
function getOutputPaths(
|
|
45
|
+
async function getOutputPaths(
|
|
44
46
|
transcripts: Transcript[],
|
|
45
47
|
inputPath: string,
|
|
46
48
|
outputOption?: string,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
let baseName: string;
|
|
50
|
-
if (inputPath === "<stdin>") {
|
|
51
|
-
baseName = "transcript";
|
|
52
|
-
} else {
|
|
53
|
-
const name = basename(inputPath);
|
|
54
|
-
baseName = name.replace(/\.jsonl?$/, "");
|
|
55
|
-
}
|
|
56
|
-
|
|
49
|
+
namingOptions?: NamingOptions,
|
|
50
|
+
): Promise<string[]> {
|
|
57
51
|
// Determine output directory
|
|
58
52
|
let outputDir: string;
|
|
53
|
+
let explicitBaseName: string | undefined;
|
|
54
|
+
|
|
59
55
|
if (outputOption) {
|
|
60
|
-
// If output looks like a file (has extension), use its directory
|
|
56
|
+
// If output looks like a file (has extension), use its directory and name
|
|
61
57
|
if (outputOption.match(/\.\w+$/)) {
|
|
62
58
|
outputDir = dirname(outputOption);
|
|
63
|
-
|
|
59
|
+
explicitBaseName = outputOption
|
|
60
|
+
.split("/")
|
|
61
|
+
.pop()!
|
|
62
|
+
.replace(/\.\w+$/, "");
|
|
64
63
|
} else {
|
|
65
64
|
outputDir = outputOption;
|
|
66
65
|
}
|
|
@@ -68,14 +67,33 @@ function getOutputPaths(
|
|
|
68
67
|
outputDir = process.cwd();
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
// Generate paths
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
// Generate paths with descriptive names
|
|
71
|
+
const paths: string[] = [];
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < transcripts.length; i++) {
|
|
74
|
+
let baseName: string;
|
|
75
|
+
|
|
76
|
+
if (explicitBaseName) {
|
|
77
|
+
// User provided explicit filename
|
|
78
|
+
baseName = explicitBaseName;
|
|
79
|
+
} else {
|
|
80
|
+
// Generate descriptive name
|
|
81
|
+
baseName = await generateOutputName(
|
|
82
|
+
transcripts[i],
|
|
83
|
+
inputPath,
|
|
84
|
+
namingOptions || {},
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Add suffix for multiple transcripts
|
|
89
|
+
if (transcripts.length > 1) {
|
|
90
|
+
baseName = `${baseName}_${i + 1}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
paths.push(join(outputDir, `${baseName}.json`));
|
|
74
94
|
}
|
|
75
95
|
|
|
76
|
-
return
|
|
77
|
-
join(outputDir, `${baseName}_${i + 1}.json`),
|
|
78
|
-
);
|
|
96
|
+
return paths;
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
export interface ParseResult {
|
|
@@ -127,7 +145,12 @@ export async function parse(
|
|
|
127
145
|
const { transcripts, inputPath } = await parseToTranscripts(options);
|
|
128
146
|
|
|
129
147
|
// Write output files
|
|
130
|
-
const outputPaths = getOutputPaths(
|
|
148
|
+
const outputPaths = await getOutputPaths(
|
|
149
|
+
transcripts,
|
|
150
|
+
inputPath,
|
|
151
|
+
options.output,
|
|
152
|
+
options.naming,
|
|
153
|
+
);
|
|
131
154
|
|
|
132
155
|
for (let i = 0; i < transcripts.length; i++) {
|
|
133
156
|
const json = JSON.stringify(transcripts[i], null, 2);
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
* Output structure mirrors source structure with extension changed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Glob } from "bun";
|
|
10
|
+
import { dirname, join, relative } from "path";
|
|
11
|
+
import { mkdir, stat } from "fs/promises";
|
|
12
|
+
import { getAdapters } from "./adapters/index.ts";
|
|
13
|
+
import type { Adapter } from "./types.ts";
|
|
14
|
+
import { renderTranscript } from "./render.ts";
|
|
15
|
+
|
|
16
|
+
export interface SyncOptions {
|
|
17
|
+
source: string;
|
|
18
|
+
output: string;
|
|
19
|
+
force?: boolean;
|
|
20
|
+
quiet?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SyncResult {
|
|
24
|
+
synced: number;
|
|
25
|
+
skipped: number;
|
|
26
|
+
errors: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SessionFile {
|
|
30
|
+
path: string;
|
|
31
|
+
relativePath: string;
|
|
32
|
+
mtime: number;
|
|
33
|
+
adapter: Adapter;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Discover session files for a specific adapter.
|
|
38
|
+
*/
|
|
39
|
+
async function discoverForAdapter(
|
|
40
|
+
source: string,
|
|
41
|
+
adapter: Adapter,
|
|
42
|
+
): Promise<SessionFile[]> {
|
|
43
|
+
const sessions: SessionFile[] = [];
|
|
44
|
+
|
|
45
|
+
for (const pattern of adapter.filePatterns) {
|
|
46
|
+
const glob = new Glob(`**/${pattern}`);
|
|
47
|
+
|
|
48
|
+
for await (const file of glob.scan({ cwd: source, absolute: false })) {
|
|
49
|
+
const fullPath = join(source, file);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const fileStat = await stat(fullPath);
|
|
53
|
+
sessions.push({
|
|
54
|
+
path: fullPath,
|
|
55
|
+
relativePath: file,
|
|
56
|
+
mtime: fileStat.mtime.getTime(),
|
|
57
|
+
adapter,
|
|
58
|
+
});
|
|
59
|
+
} catch {
|
|
60
|
+
// Skip files we can't stat
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return sessions;
|
|
66
|
+
}
|
|
67
|
+
|
|
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
|
+
/**
|
|
104
|
+
* Sync session files from source to output directory.
|
|
105
|
+
*/
|
|
106
|
+
export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
107
|
+
const { source, output, force = false, quiet = false } = options;
|
|
108
|
+
|
|
109
|
+
const result: SyncResult = { synced: 0, skipped: 0, errors: 0 };
|
|
110
|
+
|
|
111
|
+
// Discover sessions for each adapter
|
|
112
|
+
const sessions: SessionFile[] = [];
|
|
113
|
+
for (const adapter of getAdapters()) {
|
|
114
|
+
const adapterSessions = await discoverForAdapter(source, adapter);
|
|
115
|
+
sessions.push(...adapterSessions);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!quiet) {
|
|
119
|
+
console.error(`Found ${sessions.length} session file(s)`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Process each session
|
|
123
|
+
for (const session of sessions) {
|
|
124
|
+
try {
|
|
125
|
+
// Read and parse using the adapter that discovered this file
|
|
126
|
+
const content = await Bun.file(session.path).text();
|
|
127
|
+
const transcripts = session.adapter.parse(content, session.path);
|
|
128
|
+
|
|
129
|
+
// Process each transcript (usually just one per file)
|
|
130
|
+
for (let i = 0; i < transcripts.length; i++) {
|
|
131
|
+
const transcript = transcripts[i];
|
|
132
|
+
const suffix = transcripts.length > 1 ? `_${i + 1}` : undefined;
|
|
133
|
+
const outputPath = computeOutputPath(
|
|
134
|
+
session.relativePath,
|
|
135
|
+
output,
|
|
136
|
+
suffix,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Check if sync needed
|
|
140
|
+
if (!(await needsSync(outputPath, session.mtime, force))) {
|
|
141
|
+
if (!quiet) {
|
|
142
|
+
console.error(`Skip (up to date): ${outputPath}`);
|
|
143
|
+
}
|
|
144
|
+
result.skipped++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Ensure output directory exists
|
|
149
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
150
|
+
|
|
151
|
+
// Render and write
|
|
152
|
+
const markdown = renderTranscript(transcript);
|
|
153
|
+
await Bun.write(outputPath, markdown);
|
|
154
|
+
|
|
155
|
+
if (!quiet) {
|
|
156
|
+
console.error(`Synced: ${outputPath}`);
|
|
157
|
+
}
|
|
158
|
+
result.synced++;
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
162
|
+
console.error(`Error: ${session.relativePath}: ${message}`);
|
|
163
|
+
result.errors++;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Summary
|
|
168
|
+
if (!quiet) {
|
|
169
|
+
console.error(
|
|
170
|
+
`\nSync complete: ${result.synced} synced, ${result.skipped} skipped, ${result.errors} errors`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return result;
|
|
175
|
+
}
|
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,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output file naming utilities.
|
|
3
|
+
*
|
|
4
|
+
* Generates descriptive filenames for transcripts:
|
|
5
|
+
* - With OpenRouter API key: yyyy-mm-dd-{llm-generated-slug}.{ext}
|
|
6
|
+
* - Without: yyyy-mm-dd-{input-filename-prefix}.{ext}
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Transcript, UserMessage } from "../types.ts";
|
|
10
|
+
import { basename } from "path";
|
|
11
|
+
|
|
12
|
+
export interface NamingOptions {
|
|
13
|
+
apiKey?: string; // OpenRouter API key
|
|
14
|
+
model?: string; // Default: google/gemini-2.0-flash-001
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MODEL = "google/gemini-2.0-flash-001";
|
|
18
|
+
const SLUG_MAX_LENGTH = 40;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract date from transcript's first message timestamp.
|
|
22
|
+
*/
|
|
23
|
+
function extractDate(transcript: Transcript): string {
|
|
24
|
+
const firstMessage = transcript.messages[0];
|
|
25
|
+
if (firstMessage?.timestamp) {
|
|
26
|
+
const date = new Date(firstMessage.timestamp);
|
|
27
|
+
if (!isNaN(date.getTime())) {
|
|
28
|
+
return date.toISOString().slice(0, 10); // yyyy-mm-dd
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Fallback to current date
|
|
32
|
+
return new Date().toISOString().slice(0, 10);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract context from transcript for LLM summarization.
|
|
37
|
+
* Uses first few user messages, truncated.
|
|
38
|
+
*/
|
|
39
|
+
function extractContext(transcript: Transcript): string {
|
|
40
|
+
const userMessages = transcript.messages.filter(
|
|
41
|
+
(m): m is UserMessage => m.type === "user",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const chunks: string[] = [];
|
|
45
|
+
let totalLength = 0;
|
|
46
|
+
const maxLength = 500;
|
|
47
|
+
|
|
48
|
+
for (const msg of userMessages.slice(0, 3)) {
|
|
49
|
+
const content = msg.content.slice(0, 200);
|
|
50
|
+
if (totalLength + content.length > maxLength) break;
|
|
51
|
+
chunks.push(content);
|
|
52
|
+
totalLength += content.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return chunks.join("\n\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sanitize a string into a valid URL slug.
|
|
60
|
+
*/
|
|
61
|
+
function sanitizeSlug(input: string): string {
|
|
62
|
+
return input
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9\s-]/g, "") // remove special chars
|
|
65
|
+
.replace(/\s+/g, "-") // spaces to hyphens
|
|
66
|
+
.replace(/-+/g, "-") // collapse multiple hyphens
|
|
67
|
+
.replace(/^-|-$/g, "") // trim leading/trailing hyphens
|
|
68
|
+
.slice(0, SLUG_MAX_LENGTH);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generate slug via OpenRouter API.
|
|
73
|
+
*/
|
|
74
|
+
async function generateSlugViaLLM(
|
|
75
|
+
context: string,
|
|
76
|
+
options: NamingOptions,
|
|
77
|
+
): Promise<string | null> {
|
|
78
|
+
const { apiKey, model = DEFAULT_MODEL } = options;
|
|
79
|
+
if (!apiKey || !context.trim()) return null;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(
|
|
83
|
+
"https://openrouter.ai/api/v1/chat/completions",
|
|
84
|
+
{
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
Authorization: `Bearer ${apiKey}`,
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
model,
|
|
92
|
+
messages: [
|
|
93
|
+
{
|
|
94
|
+
role: "user",
|
|
95
|
+
content: `Generate a 2-4 word URL slug (lowercase, hyphenated) summarizing this conversation topic. Reply with ONLY the slug, nothing else.\n\n${context}`,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
max_tokens: 20,
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
console.error(
|
|
105
|
+
`OpenRouter API error: ${response.status} ${response.statusText}`,
|
|
106
|
+
);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const data = (await response.json()) as {
|
|
111
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
112
|
+
};
|
|
113
|
+
const content = data.choices?.[0]?.message?.content?.trim();
|
|
114
|
+
|
|
115
|
+
if (!content) return null;
|
|
116
|
+
|
|
117
|
+
const slug = sanitizeSlug(content);
|
|
118
|
+
return slug || null;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(
|
|
121
|
+
`OpenRouter API call failed: ${error instanceof Error ? error.message : error}`,
|
|
122
|
+
);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Generate fallback slug from input filename.
|
|
129
|
+
*/
|
|
130
|
+
function generateFallbackSlug(inputPath: string): string {
|
|
131
|
+
return extractFileId(inputPath, 8) || "transcript";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Extract a short identifier from the input filename.
|
|
136
|
+
* Used as a suffix for traceability back to source.
|
|
137
|
+
*/
|
|
138
|
+
function extractFileId(inputPath: string, length = 6): string {
|
|
139
|
+
if (inputPath === "<stdin>") {
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const name = basename(inputPath);
|
|
144
|
+
const base = name.replace(/\.jsonl?$/, "");
|
|
145
|
+
// Take first N chars, sanitize, and clean up any trailing hyphens
|
|
146
|
+
return sanitizeSlug(base.slice(0, length)).replace(/-+$/, "");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Generate output base name for a transcript.
|
|
151
|
+
* Returns string like "2024-01-15-implement-auth-flow-abc123"
|
|
152
|
+
*/
|
|
153
|
+
export async function generateOutputName(
|
|
154
|
+
transcript: Transcript,
|
|
155
|
+
inputPath: string,
|
|
156
|
+
options: NamingOptions = {},
|
|
157
|
+
): Promise<string> {
|
|
158
|
+
const date = extractDate(transcript);
|
|
159
|
+
const fileId = extractFileId(inputPath);
|
|
160
|
+
|
|
161
|
+
// Try LLM-generated slug if API key available
|
|
162
|
+
if (options.apiKey) {
|
|
163
|
+
const context = extractContext(transcript);
|
|
164
|
+
const slug = await generateSlugViaLLM(context, options);
|
|
165
|
+
if (slug) {
|
|
166
|
+
return fileId ? `${date}-${slug}-${fileId}` : `${date}-${slug}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Fallback to input filename prefix (no need for fileId suffix, it's already the slug)
|
|
171
|
+
const slug = generateFallbackSlug(inputPath);
|
|
172
|
+
return `${date}-${slug}`;
|
|
173
|
+
}
|