@tacone/prosey 0.2.6 → 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 +51 -25
- package/bin/prosey +308 -33
- 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 +26 -17
- package/src/default-config.toml +39 -11
- 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 +240 -10
- package/src/summarize.test.ts +74 -3
- package/src/summarize.ts +18 -4
package/src/index.ts
CHANGED
|
@@ -11,11 +11,22 @@ 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";
|
|
15
22
|
import { checkVersion } from "./version-check";
|
|
16
23
|
import pkg from "../package.json";
|
|
17
24
|
import prettier from "prettier";
|
|
18
25
|
|
|
26
|
+
process.stdout.on("error", (err: NodeJS.ErrnoException) => {
|
|
27
|
+
if (err.code === "EPIPE") process.exit(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
19
30
|
const NAME = "prosey";
|
|
20
31
|
const VERSION = pkg.version;
|
|
21
32
|
|
|
@@ -37,16 +48,20 @@ function help(): string {
|
|
|
37
48
|
return `${NAME} v${VERSION}
|
|
38
49
|
|
|
39
50
|
Usage: ${NAME} [options] <video-url-or-id>
|
|
51
|
+
${NAME} read [options] <video-url-or-id>
|
|
40
52
|
${NAME} info [options] <video-url-or-id>
|
|
41
53
|
${NAME} summarize [options] <video-url-or-id>
|
|
42
54
|
${NAME} config
|
|
55
|
+
${NAME} help
|
|
43
56
|
|
|
44
57
|
Download a YouTube video transcript or show video details.
|
|
45
58
|
|
|
46
59
|
Commands:
|
|
60
|
+
summarize Pipe transcript to the AI command (default command)
|
|
61
|
+
read Download and print a richly formatted transcript
|
|
47
62
|
info Show video metadata (title, channel, duration, etc.)
|
|
48
|
-
summarize Pipe transcript to the command configured in [summarize]
|
|
49
63
|
config Open config file in \$EDITOR
|
|
64
|
+
help Show this help message
|
|
50
65
|
|
|
51
66
|
Arguments:
|
|
52
67
|
video-url-or-id YouTube URL (full or short) or bare video ID
|
|
@@ -56,14 +71,18 @@ Options:
|
|
|
56
71
|
-t, --timestamps Include timestamps [MM:SS] in output.
|
|
57
72
|
--list List available transcript languages and exit.
|
|
58
73
|
-o, --output <path> Write output to file instead of stdout.
|
|
59
|
-
--
|
|
60
|
-
--
|
|
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.
|
|
61
78
|
--details Prepend video details to transcript (default, text only).
|
|
62
79
|
--no-details Suppress video details, transcript only.
|
|
63
80
|
--no-decode-entities Preserve HTML entities (decoded by default).
|
|
64
81
|
--reset-config Reset config file to defaults and exit.
|
|
65
82
|
--no-cache Skip cache and overwrite cache files.
|
|
66
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.
|
|
67
86
|
--no-pager Disable pager for stdout output.
|
|
68
87
|
--pager Use pager for stdout output (default).
|
|
69
88
|
--no-hints Disable hints.
|
|
@@ -193,7 +212,7 @@ let pagerCmd: string | null = null;
|
|
|
193
212
|
|
|
194
213
|
const args = process.argv.slice(2);
|
|
195
214
|
|
|
196
|
-
if (args.length === 0 || args.includes("--help")) {
|
|
215
|
+
if (args.length === 0 || args.includes("--help") || args.includes("help")) {
|
|
197
216
|
console.log(help());
|
|
198
217
|
exitProcess(0);
|
|
199
218
|
}
|
|
@@ -211,8 +230,10 @@ if (args.includes("--reset-config")) {
|
|
|
211
230
|
|
|
212
231
|
const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
|
|
213
232
|
|
|
214
|
-
let mode = "
|
|
215
|
-
const subcmdIndex = args.findIndex(
|
|
233
|
+
let mode = "summarize";
|
|
234
|
+
const subcmdIndex = args.findIndex(
|
|
235
|
+
(a) => a === "info" || a === "summarize" || a === "config" || a === "read",
|
|
236
|
+
);
|
|
216
237
|
if (subcmdIndex !== -1) {
|
|
217
238
|
mode = args[subcmdIndex]!;
|
|
218
239
|
args.splice(subcmdIndex, 1);
|
|
@@ -224,12 +245,15 @@ let timestamps = false;
|
|
|
224
245
|
let listOnly = false;
|
|
225
246
|
let outputPath: string | undefined;
|
|
226
247
|
let outputJson = false;
|
|
248
|
+
let format: "text" | "json" | "markdown" = "markdown";
|
|
227
249
|
let noDecode = false;
|
|
228
250
|
let showDetails = true;
|
|
229
251
|
let noCache = false;
|
|
230
252
|
let noFormat = false;
|
|
231
253
|
let usePager = true;
|
|
232
254
|
let useHints = true;
|
|
255
|
+
let dryRun = false;
|
|
256
|
+
let extractTimestamps = false;
|
|
233
257
|
let logLevel: LogLevel = "normal";
|
|
234
258
|
|
|
235
259
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -253,8 +277,27 @@ for (let i = 0; i < args.length; i++) {
|
|
|
253
277
|
}
|
|
254
278
|
} else if (arg === "--json") {
|
|
255
279
|
outputJson = true;
|
|
280
|
+
format = "json";
|
|
256
281
|
} else if (arg === "--text") {
|
|
257
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
|
+
}
|
|
258
301
|
} else if (arg === "--details") {
|
|
259
302
|
showDetails = true;
|
|
260
303
|
} else if (arg === "--no-details") {
|
|
@@ -277,6 +320,10 @@ for (let i = 0; i < args.length; i++) {
|
|
|
277
320
|
logLevel = "verbose";
|
|
278
321
|
} else if (arg === "--no-decode-entities") {
|
|
279
322
|
noDecode = true;
|
|
323
|
+
} else if (arg === "--dry-run") {
|
|
324
|
+
dryRun = true;
|
|
325
|
+
} else if (arg === "--extract-timestamps") {
|
|
326
|
+
extractTimestamps = true;
|
|
280
327
|
} else if (arg.startsWith("-")) {
|
|
281
328
|
console.error(`Unknown option: ${arg}`);
|
|
282
329
|
exitProcess(1);
|
|
@@ -345,6 +392,25 @@ if (lang) debug("Language:", lang);
|
|
|
345
392
|
// Give the version check a moment to complete
|
|
346
393
|
await Promise.race([versionCheck, new Promise((r) => setTimeout(r, 1000))]);
|
|
347
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
|
+
|
|
348
414
|
try {
|
|
349
415
|
if (mode === "info") {
|
|
350
416
|
const result = await fetchTranscript(videoId, { videoDetails: true, lang } as any);
|
|
@@ -357,8 +423,11 @@ try {
|
|
|
357
423
|
}
|
|
358
424
|
|
|
359
425
|
if (mode === "summarize") {
|
|
360
|
-
|
|
361
|
-
|
|
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
|
+
);
|
|
362
431
|
exitProcess(1);
|
|
363
432
|
}
|
|
364
433
|
|
|
@@ -392,14 +461,25 @@ try {
|
|
|
392
461
|
debug("Cache written: transcript.json");
|
|
393
462
|
}
|
|
394
463
|
|
|
395
|
-
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
|
+
}
|
|
396
471
|
const transcriptText = toText(segments, !noDecode);
|
|
397
472
|
|
|
473
|
+
if (dryRun) {
|
|
474
|
+
await outputText(`${prompt}\n\n${transcriptText}\n`);
|
|
475
|
+
exitProcess(0);
|
|
476
|
+
}
|
|
477
|
+
|
|
398
478
|
if (!summary) {
|
|
399
479
|
info(`Summarizing...`);
|
|
400
480
|
summary = await summarize({
|
|
401
481
|
prompt,
|
|
402
|
-
command:
|
|
482
|
+
command: sumCmd,
|
|
403
483
|
transcript: transcriptText,
|
|
404
484
|
cwd: dir,
|
|
405
485
|
});
|
|
@@ -417,6 +497,148 @@ try {
|
|
|
417
497
|
exitProcess(0);
|
|
418
498
|
}
|
|
419
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);
|
|
640
|
+
}
|
|
641
|
+
|
|
420
642
|
const decode = !noDecode;
|
|
421
643
|
const cacheOpts = { lang, timestamps, json: outputJson, noDecode };
|
|
422
644
|
const dir = cacheDir(videoId, cacheOpts);
|
|
@@ -438,6 +660,14 @@ try {
|
|
|
438
660
|
debug("Cache skipped (--no-cache)");
|
|
439
661
|
}
|
|
440
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
|
+
|
|
441
671
|
if (!segments) {
|
|
442
672
|
info("Fetching transcript...");
|
|
443
673
|
if (showDetails && !outputJson) {
|
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+$/, "")
|