@tacone/prosey 0.2.4 → 0.3.0
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 +52 -26
- package/bin/prosey +363 -51
- package/package.json +1 -1
- package/src/config-resolve.test.ts +104 -0
- package/src/config-resolve.ts +17 -0
- package/src/config.test.ts +3 -1
- package/src/config.ts +27 -18
- package/src/default-config.toml +41 -19
- package/src/extract-chapters.test.ts +229 -0
- package/src/extract-chapters.ts +66 -0
- package/src/index.test.ts +12 -0
- package/src/index.ts +275 -26
- package/src/pager.test.ts +1 -1
- package/src/pager.ts +2 -2
- package/src/summarize.test.ts +74 -3
- package/src/summarize.ts +18 -4
- package/src/version-check.ts +32 -0
package/src/index.ts
CHANGED
|
@@ -11,27 +11,57 @@ import { formatWithTimestamps, toText, toJSON, formatDuration, decodeEntities }
|
|
|
11
11
|
import { loadConfig, resetConfig, configPath } from "./config";
|
|
12
12
|
import type { ProseyConfig } from "./config";
|
|
13
13
|
import { summarize } from "./summarize";
|
|
14
|
+
import {
|
|
15
|
+
resolveSummarizeCmd,
|
|
16
|
+
resolveSummarizePrompt,
|
|
17
|
+
resolveTranscribeCmd,
|
|
18
|
+
resolveTranscribePrompt,
|
|
19
|
+
} from "./config-resolve";
|
|
14
20
|
import { cacheDir, readCache, writeCache, extractVideoId } from "./cache";
|
|
21
|
+
import { extractChapters, formatChaptersAsText, formatChaptersAsJson } from "./extract-chapters";
|
|
22
|
+
import { checkVersion } from "./version-check";
|
|
15
23
|
import pkg from "../package.json";
|
|
16
24
|
import prettier from "prettier";
|
|
17
25
|
|
|
26
|
+
process.stdout.on("error", (err: NodeJS.ErrnoException) => {
|
|
27
|
+
if (err.code === "EPIPE") process.exit(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
18
30
|
const NAME = "prosey";
|
|
19
31
|
const VERSION = pkg.version;
|
|
20
32
|
|
|
33
|
+
let latestVersion: string | null = null;
|
|
34
|
+
const versionCheck = checkVersion().then((v) => {
|
|
35
|
+
latestVersion = v;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function exitProcess(code: number): never {
|
|
39
|
+
if (useHints && code === 0 && latestVersion && latestVersion !== VERSION) {
|
|
40
|
+
hint(
|
|
41
|
+
`📦 New version available: ${latestVersion} — use npm/pnpm/bun -g i ${pkg.name} to upgrade`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
process.exit(code);
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
function help(): string {
|
|
22
48
|
return `${NAME} v${VERSION}
|
|
23
49
|
|
|
24
50
|
Usage: ${NAME} [options] <video-url-or-id>
|
|
51
|
+
${NAME} read [options] <video-url-or-id>
|
|
25
52
|
${NAME} info [options] <video-url-or-id>
|
|
26
53
|
${NAME} summarize [options] <video-url-or-id>
|
|
27
54
|
${NAME} config
|
|
55
|
+
${NAME} help
|
|
28
56
|
|
|
29
57
|
Download a YouTube video transcript or show video details.
|
|
30
58
|
|
|
31
59
|
Commands:
|
|
60
|
+
summarize Pipe transcript to the AI command (default command)
|
|
61
|
+
read Download and print a richly formatted transcript
|
|
32
62
|
info Show video metadata (title, channel, duration, etc.)
|
|
33
|
-
summarize Pipe transcript to the command configured in [summarize]
|
|
34
63
|
config Open config file in \$EDITOR
|
|
64
|
+
help Show this help message
|
|
35
65
|
|
|
36
66
|
Arguments:
|
|
37
67
|
video-url-or-id YouTube URL (full or short) or bare video ID
|
|
@@ -41,14 +71,18 @@ Options:
|
|
|
41
71
|
-t, --timestamps Include timestamps [MM:SS] in output.
|
|
42
72
|
--list List available transcript languages and exit.
|
|
43
73
|
-o, --output <path> Write output to file instead of stdout.
|
|
44
|
-
--
|
|
45
|
-
--
|
|
74
|
+
--format <type> Output format: markdown (default), text, or json.
|
|
75
|
+
--json Shortcut for --format json.
|
|
76
|
+
--text Shortcut for --format text.
|
|
77
|
+
--markdown Shortcut for --format markdown.
|
|
46
78
|
--details Prepend video details to transcript (default, text only).
|
|
47
79
|
--no-details Suppress video details, transcript only.
|
|
48
80
|
--no-decode-entities Preserve HTML entities (decoded by default).
|
|
49
81
|
--reset-config Reset config file to defaults and exit.
|
|
50
82
|
--no-cache Skip cache and overwrite cache files.
|
|
51
83
|
--no-format Skip prettier formatting.
|
|
84
|
+
--dry-run Print what would be sent to the AI command and exit.
|
|
85
|
+
--extract-timestamps Extract chapter timestamps from video description.
|
|
52
86
|
--no-pager Disable pager for stdout output.
|
|
53
87
|
--pager Use pager for stdout output (default).
|
|
54
88
|
--no-hints Disable hints.
|
|
@@ -178,26 +212,28 @@ let pagerCmd: string | null = null;
|
|
|
178
212
|
|
|
179
213
|
const args = process.argv.slice(2);
|
|
180
214
|
|
|
181
|
-
if (args.length === 0 || args.includes("--help")) {
|
|
215
|
+
if (args.length === 0 || args.includes("--help") || args.includes("help")) {
|
|
182
216
|
console.log(help());
|
|
183
|
-
|
|
217
|
+
exitProcess(0);
|
|
184
218
|
}
|
|
185
219
|
|
|
186
220
|
if (args.includes("--version")) {
|
|
187
221
|
console.log(VERSION);
|
|
188
|
-
|
|
222
|
+
exitProcess(0);
|
|
189
223
|
}
|
|
190
224
|
|
|
191
225
|
if (args.includes("--reset-config")) {
|
|
192
226
|
const path = await resetConfig();
|
|
193
227
|
console.log(`Config reset to defaults: ${path}`);
|
|
194
|
-
|
|
228
|
+
exitProcess(0);
|
|
195
229
|
}
|
|
196
230
|
|
|
197
231
|
const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
|
|
198
232
|
|
|
199
|
-
let mode = "
|
|
200
|
-
const subcmdIndex = args.findIndex(
|
|
233
|
+
let mode = "summarize";
|
|
234
|
+
const subcmdIndex = args.findIndex(
|
|
235
|
+
(a) => a === "info" || a === "summarize" || a === "config" || a === "read",
|
|
236
|
+
);
|
|
201
237
|
if (subcmdIndex !== -1) {
|
|
202
238
|
mode = args[subcmdIndex]!;
|
|
203
239
|
args.splice(subcmdIndex, 1);
|
|
@@ -209,12 +245,15 @@ let timestamps = false;
|
|
|
209
245
|
let listOnly = false;
|
|
210
246
|
let outputPath: string | undefined;
|
|
211
247
|
let outputJson = false;
|
|
248
|
+
let format: "text" | "json" | "markdown" = "markdown";
|
|
212
249
|
let noDecode = false;
|
|
213
250
|
let showDetails = true;
|
|
214
251
|
let noCache = false;
|
|
215
252
|
let noFormat = false;
|
|
216
253
|
let usePager = true;
|
|
217
254
|
let useHints = true;
|
|
255
|
+
let dryRun = false;
|
|
256
|
+
let extractTimestamps = false;
|
|
218
257
|
let logLevel: LogLevel = "normal";
|
|
219
258
|
|
|
220
259
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -224,7 +263,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
224
263
|
lang = args[++i] ?? undefined;
|
|
225
264
|
if (!lang) {
|
|
226
265
|
console.error("Error: --lang requires a language code");
|
|
227
|
-
|
|
266
|
+
exitProcess(1);
|
|
228
267
|
}
|
|
229
268
|
} else if (arg === "--timestamps" || arg === "-t") {
|
|
230
269
|
timestamps = true;
|
|
@@ -234,12 +273,31 @@ for (let i = 0; i < args.length; i++) {
|
|
|
234
273
|
outputPath = args[++i] ?? undefined;
|
|
235
274
|
if (!outputPath) {
|
|
236
275
|
console.error("Error: -o/--output requires a file path");
|
|
237
|
-
|
|
276
|
+
exitProcess(1);
|
|
238
277
|
}
|
|
239
278
|
} else if (arg === "--json") {
|
|
240
279
|
outputJson = true;
|
|
280
|
+
format = "json";
|
|
241
281
|
} else if (arg === "--text") {
|
|
242
282
|
outputJson = false;
|
|
283
|
+
format = "text";
|
|
284
|
+
} else if (arg === "--markdown") {
|
|
285
|
+
format = "markdown";
|
|
286
|
+
} else if (arg === "--format") {
|
|
287
|
+
const val = args[++i];
|
|
288
|
+
if (val === "json") {
|
|
289
|
+
format = "json";
|
|
290
|
+
outputJson = true;
|
|
291
|
+
} else if (val === "text") {
|
|
292
|
+
format = "text";
|
|
293
|
+
outputJson = false;
|
|
294
|
+
} else if (val === "markdown") {
|
|
295
|
+
format = "markdown";
|
|
296
|
+
outputJson = false;
|
|
297
|
+
} else {
|
|
298
|
+
console.error("Error: --format must be text, json, or markdown");
|
|
299
|
+
exitProcess(1);
|
|
300
|
+
}
|
|
243
301
|
} else if (arg === "--details") {
|
|
244
302
|
showDetails = true;
|
|
245
303
|
} else if (arg === "--no-details") {
|
|
@@ -262,9 +320,13 @@ for (let i = 0; i < args.length; i++) {
|
|
|
262
320
|
logLevel = "verbose";
|
|
263
321
|
} else if (arg === "--no-decode-entities") {
|
|
264
322
|
noDecode = true;
|
|
323
|
+
} else if (arg === "--dry-run") {
|
|
324
|
+
dryRun = true;
|
|
325
|
+
} else if (arg === "--extract-timestamps") {
|
|
326
|
+
extractTimestamps = true;
|
|
265
327
|
} else if (arg.startsWith("-")) {
|
|
266
328
|
console.error(`Unknown option: ${arg}`);
|
|
267
|
-
|
|
329
|
+
exitProcess(1);
|
|
268
330
|
} else {
|
|
269
331
|
videoId = arg;
|
|
270
332
|
}
|
|
@@ -282,23 +344,23 @@ if (mode === "config") {
|
|
|
282
344
|
} else {
|
|
283
345
|
console.log(`Config file: ${path}`);
|
|
284
346
|
}
|
|
285
|
-
|
|
347
|
+
exitProcess(0);
|
|
286
348
|
}
|
|
287
349
|
|
|
288
350
|
if (!videoId) {
|
|
289
351
|
console.error("Error: missing video URL or ID");
|
|
290
352
|
console.log(help());
|
|
291
|
-
|
|
353
|
+
exitProcess(1);
|
|
292
354
|
}
|
|
293
355
|
|
|
294
356
|
const extracted = extractVideoId(videoId);
|
|
295
357
|
|
|
296
358
|
if (!extracted) {
|
|
297
359
|
console.error("Error: invalid YouTube video URL or ID");
|
|
298
|
-
|
|
360
|
+
exitProcess(65);
|
|
299
361
|
}
|
|
300
362
|
|
|
301
|
-
videoId = extracted
|
|
363
|
+
videoId = extracted!;
|
|
302
364
|
|
|
303
365
|
setLevel(logLevel);
|
|
304
366
|
resetTimer();
|
|
@@ -316,7 +378,7 @@ debug("Pager:", pagerCmd ?? "none");
|
|
|
316
378
|
|
|
317
379
|
if (useHints) {
|
|
318
380
|
const hasMarkdownPager =
|
|
319
|
-
pagerCmd === "bat -lmd" || pagerCmd === "glow" || pagerCmd === "mdcat -l -p";
|
|
381
|
+
pagerCmd === "bat -lmd --style plain" || pagerCmd === "glow -p" || pagerCmd === "mdcat -l -p";
|
|
320
382
|
if (!hasMarkdownPager) {
|
|
321
383
|
hint("Tip: install a markdown highlighter for better output (e.g. bat, glow, mdcat)");
|
|
322
384
|
}
|
|
@@ -327,6 +389,28 @@ debug("Video ID:", videoId);
|
|
|
327
389
|
debug("Mode:", mode);
|
|
328
390
|
if (lang) debug("Language:", lang);
|
|
329
391
|
|
|
392
|
+
// Give the version check a moment to complete
|
|
393
|
+
await Promise.race([versionCheck, new Promise((r) => setTimeout(r, 1000))]);
|
|
394
|
+
|
|
395
|
+
if (extractTimestamps) {
|
|
396
|
+
startTimer();
|
|
397
|
+
info("Fetching transcript...");
|
|
398
|
+
const result = (await fetchTranscript(videoId, {
|
|
399
|
+
videoDetails: true,
|
|
400
|
+
lang,
|
|
401
|
+
} as any)) as {
|
|
402
|
+
videoDetails: VideoDetails;
|
|
403
|
+
segments: TranscriptSegment[];
|
|
404
|
+
};
|
|
405
|
+
info("Transcript fetched");
|
|
406
|
+
const chapters = extractChapters(result.videoDetails.description);
|
|
407
|
+
const output = outputJson
|
|
408
|
+
? JSON.stringify(chapters, null, 2) + "\n"
|
|
409
|
+
: formatChaptersAsText(chapters) + "\n";
|
|
410
|
+
await outputText(output);
|
|
411
|
+
exitProcess(0);
|
|
412
|
+
}
|
|
413
|
+
|
|
330
414
|
try {
|
|
331
415
|
if (mode === "info") {
|
|
332
416
|
const result = await fetchTranscript(videoId, { videoDetails: true, lang } as any);
|
|
@@ -335,13 +419,16 @@ try {
|
|
|
335
419
|
} else {
|
|
336
420
|
printVideoInfo(result.videoDetails);
|
|
337
421
|
}
|
|
338
|
-
|
|
422
|
+
exitProcess(0);
|
|
339
423
|
}
|
|
340
424
|
|
|
341
425
|
if (mode === "summarize") {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
426
|
+
const sumCmd = resolveSummarizeCmd(config);
|
|
427
|
+
if (!sumCmd) {
|
|
428
|
+
console.error(
|
|
429
|
+
"Error: no command configured for summarize. Set [ai].command or [summarize].command in config.",
|
|
430
|
+
);
|
|
431
|
+
exitProcess(1);
|
|
345
432
|
}
|
|
346
433
|
|
|
347
434
|
const cacheOpts = { lang, mode: "summarize", noDecode };
|
|
@@ -374,14 +461,25 @@ try {
|
|
|
374
461
|
debug("Cache written: transcript.json");
|
|
375
462
|
}
|
|
376
463
|
|
|
377
|
-
const prompt = config
|
|
464
|
+
const prompt = resolveSummarizePrompt(config) ?? "";
|
|
465
|
+
if (!prompt) {
|
|
466
|
+
console.error(
|
|
467
|
+
"Error: no prompt configured. Set a prompt in the [summarize] section of your config.",
|
|
468
|
+
);
|
|
469
|
+
exitProcess(1);
|
|
470
|
+
}
|
|
378
471
|
const transcriptText = toText(segments, !noDecode);
|
|
379
472
|
|
|
473
|
+
if (dryRun) {
|
|
474
|
+
await outputText(`${prompt}\n\n${transcriptText}\n`);
|
|
475
|
+
exitProcess(0);
|
|
476
|
+
}
|
|
477
|
+
|
|
380
478
|
if (!summary) {
|
|
381
479
|
info(`Summarizing...`);
|
|
382
480
|
summary = await summarize({
|
|
383
481
|
prompt,
|
|
384
|
-
command:
|
|
482
|
+
command: sumCmd,
|
|
385
483
|
transcript: transcriptText,
|
|
386
484
|
cwd: dir,
|
|
387
485
|
});
|
|
@@ -392,11 +490,153 @@ try {
|
|
|
392
490
|
|
|
393
491
|
const formatted = noFormat ? summary : await formatMd(summary);
|
|
394
492
|
await outputText(formatted + "\n");
|
|
395
|
-
|
|
493
|
+
exitProcess(0);
|
|
396
494
|
} else if (listOnly) {
|
|
397
495
|
const languages = await listLanguages(videoId);
|
|
398
496
|
printLanguages(languages);
|
|
399
|
-
|
|
497
|
+
exitProcess(0);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (format === "markdown") {
|
|
501
|
+
const transcribeCmd = resolveTranscribeCmd(config);
|
|
502
|
+
if (!transcribeCmd) {
|
|
503
|
+
console.error(
|
|
504
|
+
"Error: no command configured for transcribe. Set [transcribe].command, [ai].command, or [summarize].command in config.",
|
|
505
|
+
);
|
|
506
|
+
exitProcess(1);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const cacheOpts = { lang, mode: "transcribe", noDecode };
|
|
510
|
+
const dir = cacheDir(videoId, cacheOpts);
|
|
511
|
+
let segments: TranscriptSegment[] | null = null;
|
|
512
|
+
let md: string | null = null;
|
|
513
|
+
|
|
514
|
+
startTimer();
|
|
515
|
+
|
|
516
|
+
let cachedInfo: string | null = null;
|
|
517
|
+
|
|
518
|
+
if (!noCache) {
|
|
519
|
+
const cachedSegments = await readCache(dir, "transcript.json");
|
|
520
|
+
const cachedMd = await readCache(dir, "transcript.md");
|
|
521
|
+
cachedInfo = await readCache(dir, "info.json");
|
|
522
|
+
if (cachedSegments && cachedMd) {
|
|
523
|
+
info("Transcript cached");
|
|
524
|
+
debug("Cache hit:", dir);
|
|
525
|
+
segments = JSON.parse(cachedSegments);
|
|
526
|
+
md = cachedMd;
|
|
527
|
+
} else {
|
|
528
|
+
debug("Cache miss:", dir);
|
|
529
|
+
}
|
|
530
|
+
} else {
|
|
531
|
+
debug("Cache skipped (--no-cache)");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const prompt = resolveTranscribePrompt(config) ?? "";
|
|
535
|
+
if (!prompt) {
|
|
536
|
+
console.error(
|
|
537
|
+
"Error: no prompt configured. Set a prompt in the [transcribe] or [summarize] section of your config.",
|
|
538
|
+
);
|
|
539
|
+
exitProcess(1);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!segments) {
|
|
543
|
+
info("Fetching transcript...");
|
|
544
|
+
const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
|
|
545
|
+
const result = (await fetchTranscript(videoId, opts)) as {
|
|
546
|
+
videoDetails: VideoDetails;
|
|
547
|
+
segments: TranscriptSegment[];
|
|
548
|
+
};
|
|
549
|
+
segments = result.segments;
|
|
550
|
+
const infoJson = JSON.stringify({
|
|
551
|
+
title: result.videoDetails.title,
|
|
552
|
+
channel: result.videoDetails.author,
|
|
553
|
+
description: result.videoDetails.description,
|
|
554
|
+
});
|
|
555
|
+
cachedInfo = infoJson;
|
|
556
|
+
const chapterValue = formatChaptersAsJson(extractChapters(result.videoDetails.description));
|
|
557
|
+
const truncatedInfo = JSON.stringify({
|
|
558
|
+
title: result.videoDetails.title,
|
|
559
|
+
channel: result.videoDetails.author,
|
|
560
|
+
description: result.videoDetails.description.slice(0, 1000),
|
|
561
|
+
});
|
|
562
|
+
const transcriptText = toText(segments, !noDecode);
|
|
563
|
+
const structuredContent = `INFO:\n${truncatedInfo}\n\nTIMESTAMPS:\n${chapterValue}\n\nTEXT:\n${transcriptText}`;
|
|
564
|
+
|
|
565
|
+
if (dryRun) {
|
|
566
|
+
await outputText(`${prompt}\n\n${structuredContent}\n`);
|
|
567
|
+
exitProcess(0);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
info(`Transcript: ${segments.length} segments`);
|
|
571
|
+
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
572
|
+
await writeCache(dir, "info.json", infoJson);
|
|
573
|
+
await writeCache(dir, "chapters.json", chapterValue);
|
|
574
|
+
debug("Cache written: transcript.json, info.json, chapters.json");
|
|
575
|
+
|
|
576
|
+
if (!md) {
|
|
577
|
+
info(`Transcribing...`);
|
|
578
|
+
md = await summarize({
|
|
579
|
+
prompt,
|
|
580
|
+
command: transcribeCmd,
|
|
581
|
+
transcript: structuredContent,
|
|
582
|
+
cwd: dir,
|
|
583
|
+
});
|
|
584
|
+
info("Transcription ready");
|
|
585
|
+
await writeCache(dir, "transcript.md", md);
|
|
586
|
+
debug("Cache written: transcript.md");
|
|
587
|
+
}
|
|
588
|
+
} else {
|
|
589
|
+
let chapterValue: string;
|
|
590
|
+
if (!cachedInfo) {
|
|
591
|
+
debug("Cache missing info.json, re-fetching video details");
|
|
592
|
+
const fallbackOpts = lang
|
|
593
|
+
? { lang, videoDetails: true as const }
|
|
594
|
+
: { videoDetails: true as const };
|
|
595
|
+
const fallbackResult = (await fetchTranscript(videoId, fallbackOpts)) as {
|
|
596
|
+
videoDetails: VideoDetails;
|
|
597
|
+
segments: TranscriptSegment[];
|
|
598
|
+
};
|
|
599
|
+
cachedInfo = JSON.stringify({
|
|
600
|
+
title: fallbackResult.videoDetails.title,
|
|
601
|
+
channel: fallbackResult.videoDetails.author,
|
|
602
|
+
description: fallbackResult.videoDetails.description,
|
|
603
|
+
});
|
|
604
|
+
await writeCache(dir, "info.json", cachedInfo);
|
|
605
|
+
chapterValue = formatChaptersAsJson(
|
|
606
|
+
extractChapters(fallbackResult.videoDetails.description),
|
|
607
|
+
);
|
|
608
|
+
await writeCache(dir, "chapters.json", chapterValue);
|
|
609
|
+
debug("Cache written: info.json, chapters.json");
|
|
610
|
+
} else {
|
|
611
|
+
const cachedChapters = await readCache(dir, "chapters.json");
|
|
612
|
+
chapterValue = cachedChapters ?? "not available";
|
|
613
|
+
}
|
|
614
|
+
const transcriptText = toText(segments, !noDecode);
|
|
615
|
+
const cachedInfoObj = JSON.parse(cachedInfo);
|
|
616
|
+
const truncatedInfo = JSON.stringify({
|
|
617
|
+
title: cachedInfoObj.title,
|
|
618
|
+
channel: cachedInfoObj.channel,
|
|
619
|
+
description: cachedInfoObj.description.slice(0, 1000),
|
|
620
|
+
});
|
|
621
|
+
const structuredContent = `INFO:\n${truncatedInfo}\n\nTIMESTAMPS:\n${chapterValue}\n\nTEXT:\n${transcriptText}`;
|
|
622
|
+
|
|
623
|
+
if (!md) {
|
|
624
|
+
info(`Transcribing...`);
|
|
625
|
+
md = await summarize({
|
|
626
|
+
prompt,
|
|
627
|
+
command: transcribeCmd,
|
|
628
|
+
transcript: structuredContent,
|
|
629
|
+
cwd: dir,
|
|
630
|
+
});
|
|
631
|
+
info("Transcription ready");
|
|
632
|
+
await writeCache(dir, "transcript.md", md);
|
|
633
|
+
debug("Cache written: transcript.md");
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const formatted = noFormat ? md : await formatMd(md);
|
|
638
|
+
await outputText(formatted + "\n");
|
|
639
|
+
exitProcess(0);
|
|
400
640
|
}
|
|
401
641
|
|
|
402
642
|
const decode = !noDecode;
|
|
@@ -420,6 +660,14 @@ try {
|
|
|
420
660
|
debug("Cache skipped (--no-cache)");
|
|
421
661
|
}
|
|
422
662
|
|
|
663
|
+
const prompt = resolveTranscribePrompt(config) ?? "";
|
|
664
|
+
if (!prompt) {
|
|
665
|
+
console.error(
|
|
666
|
+
"Error: no prompt configured. Set a prompt in the [transcribe] or [summarize] section of your config.",
|
|
667
|
+
);
|
|
668
|
+
exitProcess(1);
|
|
669
|
+
}
|
|
670
|
+
|
|
423
671
|
if (!segments) {
|
|
424
672
|
info("Fetching transcript...");
|
|
425
673
|
if (showDetails && !outputJson) {
|
|
@@ -463,8 +711,9 @@ try {
|
|
|
463
711
|
|
|
464
712
|
await outputText(output);
|
|
465
713
|
}
|
|
714
|
+
exitProcess(0);
|
|
466
715
|
} catch (err: unknown) {
|
|
467
716
|
const message = err instanceof Error ? err.message : String(err);
|
|
468
717
|
console.error(`Error: ${message}`);
|
|
469
|
-
|
|
718
|
+
exitProcess(1);
|
|
470
719
|
}
|
package/src/pager.test.ts
CHANGED
|
@@ -58,7 +58,7 @@ describe("detectPager", () => {
|
|
|
58
58
|
// In CI or minimal environments, no pagers may be installed
|
|
59
59
|
// If a pager IS found, it should be one of the expected ones
|
|
60
60
|
if (pager !== null) {
|
|
61
|
-
const known = ["bat -lmd", "glow", "mdcat -l -p", "less"];
|
|
61
|
+
const known = ["bat -lmd --style plain", "glow -p", "mdcat -l -p", "less"];
|
|
62
62
|
expect(known).toContain(pager);
|
|
63
63
|
}
|
|
64
64
|
});
|
package/src/pager.ts
CHANGED
|
@@ -15,8 +15,8 @@ export function detectPager(cfgPager?: string): string | null {
|
|
|
15
15
|
|
|
16
16
|
if (cfgPager !== undefined && cfgPager !== "" && cfgPager !== "auto") return cfgPager;
|
|
17
17
|
|
|
18
|
-
if (hasCommand("bat")) return "bat -lmd";
|
|
19
|
-
if (hasCommand("glow")) return "glow";
|
|
18
|
+
if (hasCommand("bat")) return "bat -lmd --style plain";
|
|
19
|
+
if (hasCommand("glow")) return "glow -p";
|
|
20
20
|
if (hasCommand("mdcat")) return "mdcat -l -p";
|
|
21
21
|
if (hasCommand("less")) return "less";
|
|
22
22
|
return null;
|
package/src/summarize.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { summarize } from "./summarize";
|
|
3
|
+
import type { ExecuteCommand } from "./summarize";
|
|
3
4
|
|
|
4
5
|
describe("summarize", () => {
|
|
5
6
|
test("rejects when command only echoes input", async () => {
|
|
@@ -19,14 +20,13 @@ describe("summarize", () => {
|
|
|
19
20
|
command: "cat",
|
|
20
21
|
transcript: "Just the transcript.",
|
|
21
22
|
}),
|
|
22
|
-
).rejects.toThrow("
|
|
23
|
+
).rejects.toThrow("No prompt configured");
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
test("preserves response text beyond the input", async () => {
|
|
26
|
-
const cmd = "sh -c 'cat -; echo \"RESPONSE\"'";
|
|
27
27
|
const result = await summarize({
|
|
28
28
|
prompt: "Summarize:",
|
|
29
|
-
command:
|
|
29
|
+
command: "sh -c 'cat -; echo \"RESPONSE\"'",
|
|
30
30
|
transcript: "Transcript text.",
|
|
31
31
|
});
|
|
32
32
|
expect(result).toBe("RESPONSE");
|
|
@@ -41,4 +41,75 @@ describe("summarize", () => {
|
|
|
41
41
|
}),
|
|
42
42
|
).rejects.toThrow(/exited with code 1/);
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
test("prompt text is included in stdin sent to the command", async () => {
|
|
46
|
+
let capturedInput = "";
|
|
47
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
48
|
+
capturedInput = input;
|
|
49
|
+
return "done";
|
|
50
|
+
};
|
|
51
|
+
await summarize({ prompt: "TEST_PROMPT", command: "any", transcript: "SOME_TEXT" }, exec);
|
|
52
|
+
expect(capturedInput).toContain("TEST_PROMPT");
|
|
53
|
+
expect(capturedInput).toContain("\n\n");
|
|
54
|
+
expect(capturedInput).toContain("SOME_TEXT");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("transcript text is included in stdin along with the prompt", async () => {
|
|
58
|
+
let capturedInput = "";
|
|
59
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
60
|
+
capturedInput = input;
|
|
61
|
+
return "done";
|
|
62
|
+
};
|
|
63
|
+
await summarize({ prompt: "PROMPT:", command: "any", transcript: "SAMPLE_TRANSCRIPT" }, exec);
|
|
64
|
+
expect(capturedInput).toContain("SAMPLE_TRANSCRIPT");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("prompt appears before transcript text in stdin", async () => {
|
|
68
|
+
let capturedInput = "";
|
|
69
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
70
|
+
capturedInput = input;
|
|
71
|
+
return "done";
|
|
72
|
+
};
|
|
73
|
+
await summarize({ prompt: "FIRST_LINE", command: "any", transcript: "SECOND_LINE" }, exec);
|
|
74
|
+
const lines = capturedInput.split("\n");
|
|
75
|
+
expect(lines[0]).toBe("FIRST_LINE");
|
|
76
|
+
expect(lines[lines.length - 1]).toBe("SECOND_LINE");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("rejects when prompt is empty string", async () => {
|
|
80
|
+
const exec: ExecuteCommand = async () => "some output";
|
|
81
|
+
await expect(
|
|
82
|
+
summarize({ prompt: "", command: "any", transcript: "text" }, exec),
|
|
83
|
+
).rejects.toThrow("No prompt configured");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("execCommand receives the correct content via stdin", async () => {
|
|
87
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
88
|
+
expect(input).toBe("My Prompt\n\nMy Transcript");
|
|
89
|
+
return "result";
|
|
90
|
+
};
|
|
91
|
+
const result = await summarize(
|
|
92
|
+
{ prompt: "My Prompt", command: "any", transcript: "My Transcript" },
|
|
93
|
+
exec,
|
|
94
|
+
);
|
|
95
|
+
expect(result).toBe("result");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("strips echoed input from command output", async () => {
|
|
99
|
+
// Simulate command that echoes the full input then adds response
|
|
100
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
101
|
+
return `${input}EXTRA_RESPONSE`;
|
|
102
|
+
};
|
|
103
|
+
const result = await summarize({ prompt: "P", command: "any", transcript: "T" }, exec);
|
|
104
|
+
expect(result).toBe("EXTRA_RESPONSE");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("throws when execCommand returns only the input", async () => {
|
|
108
|
+
const exec: ExecuteCommand = async (_cmd, input) => {
|
|
109
|
+
return `${input}\n`;
|
|
110
|
+
};
|
|
111
|
+
await expect(summarize({ prompt: "P", command: "any", transcript: "T" }, exec)).rejects.toThrow(
|
|
112
|
+
"Summarization command returned no meaningful output",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
44
115
|
});
|
package/src/summarize.ts
CHANGED
|
@@ -7,7 +7,13 @@ export interface SummarizeOptions {
|
|
|
7
7
|
cwd?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
export type ExecuteCommand = (command: string, input: string, cwd?: string) => Promise<string>;
|
|
11
|
+
|
|
12
|
+
export const defaultExecuteCommand: ExecuteCommand = (
|
|
13
|
+
command: string,
|
|
14
|
+
input: string,
|
|
15
|
+
cwd?: string,
|
|
16
|
+
) => {
|
|
11
17
|
return new Promise((resolve, reject) => {
|
|
12
18
|
const proc = spawn(command, [], { shell: true, stdio: "pipe", cwd });
|
|
13
19
|
|
|
@@ -32,12 +38,20 @@ function executeCommand(command: string, input: string, cwd?: string): Promise<s
|
|
|
32
38
|
proc.stdin!.write(input);
|
|
33
39
|
proc.stdin!.end();
|
|
34
40
|
});
|
|
35
|
-
}
|
|
41
|
+
};
|
|
36
42
|
|
|
37
|
-
export async function summarize(
|
|
43
|
+
export async function summarize(
|
|
44
|
+
options: SummarizeOptions,
|
|
45
|
+
execCommand: ExecuteCommand = defaultExecuteCommand,
|
|
46
|
+
): Promise<string> {
|
|
38
47
|
const { prompt, command, transcript, cwd } = options;
|
|
48
|
+
|
|
49
|
+
if (!prompt) {
|
|
50
|
+
throw new Error("No prompt configured. A prompt is required in the config.");
|
|
51
|
+
}
|
|
52
|
+
|
|
39
53
|
const fullPrompt = `${prompt}\n\n${transcript}`;
|
|
40
|
-
const output = await
|
|
54
|
+
const output = await execCommand(command, fullPrompt, cwd);
|
|
41
55
|
|
|
42
56
|
const cleaned = output.startsWith(fullPrompt)
|
|
43
57
|
? output.slice(fullPrompt.length).replace(/\n+$/, "")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { debug } from "./debug";
|
|
2
|
+
import pkg from "../package.json";
|
|
3
|
+
|
|
4
|
+
const TIMEOUT_MS = 3000;
|
|
5
|
+
|
|
6
|
+
const registryUrl = `https://registry.npmjs.org/${pkg.name}/latest`;
|
|
7
|
+
|
|
8
|
+
export async function checkVersion(): Promise<string | null> {
|
|
9
|
+
try {
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
12
|
+
|
|
13
|
+
const res = await fetch(registryUrl, { signal: controller.signal });
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
debug("Version check failed: HTTP", res.status);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const data = (await res.json()) as { version?: string };
|
|
22
|
+
if (!data.version) {
|
|
23
|
+
debug("Version check: no version field in response");
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return data.version;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
debug("Version check error:", err instanceof Error ? err.message : String(err));
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|