@arcreflex/agent-transcripts 0.1.2 → 0.1.3
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/package.json +1 -1
- package/src/cli.ts +13 -2
- package/src/parse.ts +44 -21
- package/src/utils/naming.ts +173 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
import { parse, parseToTranscripts } from "./parse.ts";
|
|
15
15
|
import { render, renderTranscript } from "./render.ts";
|
|
16
16
|
|
|
17
|
+
// Read OpenRouter API key from environment for LLM-based slug generation
|
|
18
|
+
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
|
19
|
+
|
|
17
20
|
// Shared options
|
|
18
21
|
const inputArg = positional({
|
|
19
22
|
type: optional(string),
|
|
@@ -51,8 +54,12 @@ const parseCmd = command({
|
|
|
51
54
|
adapter: adapterOpt,
|
|
52
55
|
},
|
|
53
56
|
async handler({ input, output, adapter }) {
|
|
57
|
+
const naming = OPENROUTER_API_KEY
|
|
58
|
+
? { apiKey: OPENROUTER_API_KEY }
|
|
59
|
+
: undefined;
|
|
60
|
+
|
|
54
61
|
if (output) {
|
|
55
|
-
await parse({ input, output, adapter });
|
|
62
|
+
await parse({ input, output, adapter, naming });
|
|
56
63
|
} else {
|
|
57
64
|
// Print JSONL to stdout (one transcript per line)
|
|
58
65
|
const { transcripts } = await parseToTranscripts({ input, adapter });
|
|
@@ -88,9 +95,13 @@ const defaultCmd = command({
|
|
|
88
95
|
head: headOpt,
|
|
89
96
|
},
|
|
90
97
|
async handler({ input, output, adapter, head }) {
|
|
98
|
+
const naming = OPENROUTER_API_KEY
|
|
99
|
+
? { apiKey: OPENROUTER_API_KEY }
|
|
100
|
+
: undefined;
|
|
101
|
+
|
|
91
102
|
if (output) {
|
|
92
103
|
// Write intermediate JSON and markdown files
|
|
93
|
-
const { outputPaths } = await parse({ input, output, adapter });
|
|
104
|
+
const { outputPaths } = await parse({ input, output, adapter, naming });
|
|
94
105
|
for (const jsonPath of outputPaths) {
|
|
95
106
|
const mdPath = jsonPath.replace(/\.json$/, ".md");
|
|
96
107
|
await render({ input: jsonPath, output: mdPath, head });
|
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);
|
|
@@ -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
|
+
}
|