@tacone/prosey 0.1.0 → 0.2.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 +49 -31
- package/bin/prosey +14243 -0
- package/package.json +5 -5
- package/src/cache.test.ts +64 -0
- package/src/cache.ts +37 -0
- package/src/config.test.ts +98 -0
- package/src/config.ts +79 -0
- package/src/default-config.toml +12 -0
- package/src/index.ts +106 -16
- package/src/summarize.test.ts +42 -0
- package/src/summarize.ts +42 -0
- package/bin/prosey.js +0 -604
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tacone/prosey",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Download YouTube video transcripts from the CLI",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"prosey": "bin/prosey
|
|
8
|
+
"prosey": "bin/prosey"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin/",
|
|
@@ -16,12 +16,11 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"start": "bun run src/index.ts",
|
|
19
|
-
"build": "bun build src/index.ts --
|
|
20
|
-
"build:node": "bun build src/index.ts --target node --outfile bin/prosey.js",
|
|
19
|
+
"build": "bun build src/index.ts --target node --outfile bin/prosey",
|
|
21
20
|
"test": "bun test",
|
|
22
21
|
"typecheck": "tsc --noEmit",
|
|
23
22
|
"prettier": "prettier --write .",
|
|
24
|
-
"prepack": "bun run build
|
|
23
|
+
"prepack": "bun run build",
|
|
25
24
|
"prepare": "husky || true"
|
|
26
25
|
},
|
|
27
26
|
"lint-staged": {
|
|
@@ -59,6 +58,7 @@
|
|
|
59
58
|
"typescript": "^5"
|
|
60
59
|
},
|
|
61
60
|
"dependencies": {
|
|
61
|
+
"js-toml": "^1.1.2",
|
|
62
62
|
"youtube-transcript-plus": "^2.0.0"
|
|
63
63
|
}
|
|
64
64
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test, afterEach } from "bun:test";
|
|
2
|
+
import { rm, readFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { cacheKey, cacheDir, readCache, writeCache } from "./cache";
|
|
6
|
+
|
|
7
|
+
const testDir = "/tmp/prosey/test-cache-spec";
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await rm(testDir, { recursive: true, force: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("cacheKey", () => {
|
|
14
|
+
test("includes video ID and hash", () => {
|
|
15
|
+
const key = cacheKey("dQw4w9WgXcQ", {});
|
|
16
|
+
expect(key).toStartWith("dQw4w9WgXcQ_");
|
|
17
|
+
expect(key.length).toBe(20); // 11 + 1 + 8
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("different lang produces different key", () => {
|
|
21
|
+
const a = cacheKey("dQw4w9WgXcQ", { lang: "en" });
|
|
22
|
+
const b = cacheKey("dQw4w9WgXcQ", { lang: "fr" });
|
|
23
|
+
expect(a).not.toBe(b);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("different flags produce different key", () => {
|
|
27
|
+
const a = cacheKey("dQw4w9WgXcQ", {});
|
|
28
|
+
const b = cacheKey("dQw4w9WgXcQ", { timestamps: true });
|
|
29
|
+
expect(a).not.toBe(b);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("mode distinguishes transcript from summarize", () => {
|
|
33
|
+
const a = cacheKey("dQw4w9WgXcQ", {});
|
|
34
|
+
const b = cacheKey("dQw4w9WgXcQ", { mode: "summarize" });
|
|
35
|
+
expect(a).not.toBe(b);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("cacheDir", () => {
|
|
40
|
+
test("returns path under /tmp/prosey with cache key", () => {
|
|
41
|
+
const key = cacheKey("abc123def45", {});
|
|
42
|
+
const dir = cacheDir("abc123def45", {});
|
|
43
|
+
expect(dir).toBe(`/tmp/prosey/${key}`);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("readCache / writeCache", () => {
|
|
48
|
+
test("writes and reads a file", async () => {
|
|
49
|
+
await writeCache(testDir, "test.txt", "hello world");
|
|
50
|
+
const content = await readCache(testDir, "test.txt");
|
|
51
|
+
expect(content).toBe("hello world");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns null for missing file", async () => {
|
|
55
|
+
const content = await readCache(testDir, "nonexistent.txt");
|
|
56
|
+
expect(content).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("creates directory on write", async () => {
|
|
60
|
+
expect(existsSync(testDir)).toBe(false);
|
|
61
|
+
await writeCache(testDir, "createdir.txt", "data");
|
|
62
|
+
expect(existsSync(testDir)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export interface CacheOptions {
|
|
7
|
+
lang?: string;
|
|
8
|
+
timestamps?: boolean;
|
|
9
|
+
json?: boolean;
|
|
10
|
+
noDecode?: boolean;
|
|
11
|
+
mode?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hashOptions(opts: CacheOptions): string {
|
|
15
|
+
return createHash("sha256").update(JSON.stringify(opts)).digest("hex").slice(0, 8);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function cacheKey(videoId: string, opts: CacheOptions): string {
|
|
19
|
+
return `${videoId}_${hashOptions(opts)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function cacheDir(videoId: string, opts: CacheOptions): string {
|
|
23
|
+
return join("/tmp", "prosey", cacheKey(videoId, opts));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readCache(dir: string, filename: string): Promise<string | null> {
|
|
27
|
+
try {
|
|
28
|
+
return await readFile(join(dir, filename), "utf8");
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function writeCache(dir: string, filename: string, data: string): Promise<void> {
|
|
35
|
+
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
|
|
36
|
+
await writeFile(join(dir, filename), data, "utf8");
|
|
37
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, test, afterEach } from "bun:test";
|
|
2
|
+
import { rm, readFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { configPath, loadConfig, resetConfig } from "./config";
|
|
7
|
+
|
|
8
|
+
const ORIGINAL_XDG = process.env.XDG_CONFIG_HOME;
|
|
9
|
+
const ORIGINAL_PROSEY_CONFIG = process.env.PROSEY_CONFIG_PATH;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
if (ORIGINAL_XDG) process.env.XDG_CONFIG_HOME = ORIGINAL_XDG;
|
|
13
|
+
else delete process.env.XDG_CONFIG_HOME;
|
|
14
|
+
if (ORIGINAL_PROSEY_CONFIG) process.env.PROSEY_CONFIG_PATH = ORIGINAL_PROSEY_CONFIG;
|
|
15
|
+
else delete process.env.PROSEY_CONFIG_PATH;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("configPath", () => {
|
|
19
|
+
test("default path uses XDG_CONFIG_HOME when set", () => {
|
|
20
|
+
process.env.XDG_CONFIG_HOME = "/custom/xdg";
|
|
21
|
+
delete process.env.PROSEY_CONFIG_PATH;
|
|
22
|
+
expect(configPath()).toBe("/custom/xdg/prosey/config.toml");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("default path falls back to ~/.config", () => {
|
|
26
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
27
|
+
delete process.env.PROSEY_CONFIG_PATH;
|
|
28
|
+
expect(configPath()).toBe(join(homedir(), ".config", "prosey", "config.toml"));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("PROSEY_CONFIG_PATH overrides everything", () => {
|
|
32
|
+
process.env.XDG_CONFIG_HOME = "/custom/xdg";
|
|
33
|
+
process.env.PROSEY_CONFIG_PATH = "/override/path/config.toml";
|
|
34
|
+
expect(configPath()).toBe("/override/path/config.toml");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("loadConfig", () => {
|
|
39
|
+
const tmpConfig = join(import.meta.dir, "..", "tmp-test-config.toml");
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
try {
|
|
43
|
+
await rm(tmpConfig);
|
|
44
|
+
} catch {}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("auto-creates config file when missing", async () => {
|
|
48
|
+
process.env.PROSEY_CONFIG_PATH = tmpConfig;
|
|
49
|
+
expect(existsSync(tmpConfig)).toBe(false);
|
|
50
|
+
|
|
51
|
+
const config = await loadConfig();
|
|
52
|
+
expect(config).toEqual({});
|
|
53
|
+
expect(existsSync(tmpConfig)).toBe(true);
|
|
54
|
+
|
|
55
|
+
const content = await readFile(tmpConfig, "utf8");
|
|
56
|
+
expect(content).toContain("[summarize]");
|
|
57
|
+
expect(content).toContain('command = "opencode run"');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("reads existing config file", async () => {
|
|
61
|
+
process.env.PROSEY_CONFIG_PATH = tmpConfig;
|
|
62
|
+
|
|
63
|
+
await loadConfig();
|
|
64
|
+
const config = await loadConfig();
|
|
65
|
+
expect(config.summarize?.prompt).toBeString();
|
|
66
|
+
expect(config.summarize?.command).toBe("opencode run");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("handles invalid TOML gracefully", async () => {
|
|
70
|
+
process.env.PROSEY_CONFIG_PATH = tmpConfig;
|
|
71
|
+
await Bun.write(tmpConfig, "invalid [[\ntoml{{{");
|
|
72
|
+
|
|
73
|
+
const config = await loadConfig();
|
|
74
|
+
expect(config).toEqual({});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("resetConfig", () => {
|
|
79
|
+
const tmpConfig = join(import.meta.dir, "..", "tmp-reset-config.toml");
|
|
80
|
+
|
|
81
|
+
afterEach(async () => {
|
|
82
|
+
try {
|
|
83
|
+
await rm(tmpConfig);
|
|
84
|
+
} catch {}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("overwrites existing file with defaults", async () => {
|
|
88
|
+
process.env.PROSEY_CONFIG_PATH = tmpConfig;
|
|
89
|
+
|
|
90
|
+
await Bun.write(tmpConfig, "# garbage");
|
|
91
|
+
const path = await resetConfig();
|
|
92
|
+
expect(path).toBe(tmpConfig);
|
|
93
|
+
|
|
94
|
+
const content = await readFile(tmpConfig, "utf8");
|
|
95
|
+
expect(content).toContain("[summarize]");
|
|
96
|
+
expect(content).toContain('command = "opencode run"');
|
|
97
|
+
});
|
|
98
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { load } from "js-toml";
|
|
7
|
+
|
|
8
|
+
export interface ProseyConfig {
|
|
9
|
+
summarize?: {
|
|
10
|
+
prompt?: string;
|
|
11
|
+
command?: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const FALLBACK_CONFIG_TOML = `# Default prosey configuration
|
|
16
|
+
# Created automatically on first run. Edit as needed.
|
|
17
|
+
|
|
18
|
+
[summarize]
|
|
19
|
+
# Prompt sent to the command via stdin.
|
|
20
|
+
# Customize this to change how transcripts are summarized.
|
|
21
|
+
prompt = """
|
|
22
|
+
Summarize the following transcript.
|
|
23
|
+
Focus on the key points and main arguments.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# Command to execute with the prompt piped via stdin.
|
|
27
|
+
command = "opencode run"
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
async function readDefaultConfig(): Promise<string> {
|
|
31
|
+
const paths = [
|
|
32
|
+
join(dirname(fileURLToPath(import.meta.url)), "default-config.toml"),
|
|
33
|
+
join(dirname(fileURLToPath(import.meta.url)), "..", "src", "default-config.toml"),
|
|
34
|
+
];
|
|
35
|
+
for (const p of paths) {
|
|
36
|
+
if (existsSync(p)) return readFile(p, "utf8");
|
|
37
|
+
}
|
|
38
|
+
return FALLBACK_CONFIG_TOML;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function configDir(): string {
|
|
42
|
+
const env = process.env.XDG_CONFIG_HOME;
|
|
43
|
+
if (env) return join(env, "prosey");
|
|
44
|
+
return join(homedir(), ".config", "prosey");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function configPath(): string {
|
|
48
|
+
return process.env.PROSEY_CONFIG_PATH ?? join(configDir(), "config.toml");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ensureDir(path: string): Promise<void> {
|
|
52
|
+
return mkdir(path, { recursive: true }) as Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function loadConfig(): Promise<ProseyConfig> {
|
|
56
|
+
const path = configPath();
|
|
57
|
+
|
|
58
|
+
if (!existsSync(path)) {
|
|
59
|
+
const dir = configDir();
|
|
60
|
+
await ensureDir(dir);
|
|
61
|
+
await writeFile(path, await readDefaultConfig(), "utf8");
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const raw = await readFile(path, "utf8");
|
|
66
|
+
try {
|
|
67
|
+
return load(raw) as ProseyConfig;
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function resetConfig(): Promise<string> {
|
|
74
|
+
const path = configPath();
|
|
75
|
+
const dir = configDir();
|
|
76
|
+
await ensureDir(dir);
|
|
77
|
+
await writeFile(path, await readDefaultConfig(), "utf8");
|
|
78
|
+
return path;
|
|
79
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Default prosey configuration
|
|
2
|
+
# Created automatically on first run. Edit as needed.
|
|
3
|
+
|
|
4
|
+
[summarize]
|
|
5
|
+
# Prompt sent to the command via stdin.
|
|
6
|
+
# Customize this to change how transcripts are summarized.
|
|
7
|
+
prompt = """
|
|
8
|
+
Write a comprehensive summary of the following transcription.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Command to execute with the prompt piped via stdin.
|
|
12
|
+
command = "opencode run"
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { writeFile } from "node:fs/promises";
|
|
4
4
|
import { fetchTranscript, listLanguages } from "youtube-transcript-plus";
|
|
5
|
-
import type { CaptionTrackInfo, VideoDetails } from "youtube-transcript-plus";
|
|
5
|
+
import type { CaptionTrackInfo, VideoDetails, TranscriptSegment } from "youtube-transcript-plus";
|
|
6
6
|
import { formatWithTimestamps, toText, toJSON, formatDuration, decodeEntities } from "./format";
|
|
7
|
+
import { loadConfig, resetConfig } from "./config";
|
|
8
|
+
import type { ProseyConfig } from "./config";
|
|
9
|
+
import { summarize } from "./summarize";
|
|
10
|
+
import { cacheDir, readCache, writeCache } from "./cache";
|
|
7
11
|
|
|
8
12
|
const NAME = "prosey";
|
|
9
13
|
const VERSION = "0.1.0";
|
|
@@ -13,11 +17,13 @@ function help(): string {
|
|
|
13
17
|
|
|
14
18
|
Usage: ${NAME} [options] <video-url-or-id>
|
|
15
19
|
${NAME} info [options] <video-url-or-id>
|
|
20
|
+
${NAME} summarize [options] <video-url-or-id>
|
|
16
21
|
|
|
17
22
|
Download a YouTube video transcript or show video details.
|
|
18
23
|
|
|
19
24
|
Commands:
|
|
20
25
|
info Show video metadata (title, channel, duration, etc.)
|
|
26
|
+
summarize Pipe transcript to the command configured in [summarize]
|
|
21
27
|
|
|
22
28
|
Arguments:
|
|
23
29
|
video-url-or-id YouTube URL (full or short) or bare video ID
|
|
@@ -32,6 +38,8 @@ Options:
|
|
|
32
38
|
--details Prepend video details to transcript (default, text only).
|
|
33
39
|
--no-details Suppress video details, transcript only.
|
|
34
40
|
--no-decode-entities Preserve HTML entities (decoded by default).
|
|
41
|
+
--reset-config Reset config file to defaults and exit.
|
|
42
|
+
--no-cache Skip cache and overwrite cache files.
|
|
35
43
|
--help Show this help message.
|
|
36
44
|
--version Show version.
|
|
37
45
|
|
|
@@ -121,10 +129,21 @@ if (args.includes("--version")) {
|
|
|
121
129
|
process.exit(0);
|
|
122
130
|
}
|
|
123
131
|
|
|
132
|
+
if (args.includes("--reset-config")) {
|
|
133
|
+
const path = await resetConfig();
|
|
134
|
+
console.log(`Config reset to defaults: ${path}`);
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
|
|
139
|
+
|
|
124
140
|
let mode = "transcript";
|
|
125
141
|
if (args[0] === "info") {
|
|
126
142
|
mode = "info";
|
|
127
143
|
args.splice(0, 1);
|
|
144
|
+
} else if (args[0] === "summarize") {
|
|
145
|
+
mode = "summarize";
|
|
146
|
+
args.splice(0, 1);
|
|
128
147
|
}
|
|
129
148
|
|
|
130
149
|
let videoId = "";
|
|
@@ -135,6 +154,7 @@ let outputPath: string | undefined;
|
|
|
135
154
|
let outputJson = false;
|
|
136
155
|
let noDecode = false;
|
|
137
156
|
let showDetails = true;
|
|
157
|
+
let noCache = false;
|
|
138
158
|
|
|
139
159
|
for (let i = 0; i < args.length; i++) {
|
|
140
160
|
const arg = args[i];
|
|
@@ -163,6 +183,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
163
183
|
showDetails = true;
|
|
164
184
|
} else if (arg === "--no-details") {
|
|
165
185
|
showDetails = false;
|
|
186
|
+
} else if (arg === "--no-cache") {
|
|
187
|
+
noCache = true;
|
|
166
188
|
} else if (arg === "--no-decode-entities") {
|
|
167
189
|
noDecode = true;
|
|
168
190
|
} else if (arg.startsWith("-")) {
|
|
@@ -190,25 +212,97 @@ try {
|
|
|
190
212
|
process.exit(0);
|
|
191
213
|
}
|
|
192
214
|
|
|
193
|
-
if (
|
|
215
|
+
if (mode === "summarize") {
|
|
216
|
+
if (!config.summarize?.command) {
|
|
217
|
+
console.error("Error: [summarize] section with a command is required in config");
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const cacheOpts = { lang, mode: "summarize", noDecode };
|
|
222
|
+
const dir = cacheDir(videoId, cacheOpts);
|
|
223
|
+
let segments: TranscriptSegment[] | null = null;
|
|
224
|
+
let summary: string | null = null;
|
|
225
|
+
|
|
226
|
+
if (!noCache) {
|
|
227
|
+
const cachedSegments = await readCache(dir, "transcript.json");
|
|
228
|
+
const cachedSummary = await readCache(dir, "summary.md");
|
|
229
|
+
if (cachedSegments && cachedSummary) {
|
|
230
|
+
segments = JSON.parse(cachedSegments);
|
|
231
|
+
summary = cachedSummary;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!segments) {
|
|
236
|
+
segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
237
|
+
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const prompt = config.summarize.prompt ?? "";
|
|
241
|
+
const transcriptText = toText(segments, !noDecode);
|
|
242
|
+
|
|
243
|
+
if (!summary) {
|
|
244
|
+
summary = await summarize({
|
|
245
|
+
prompt,
|
|
246
|
+
command: config.summarize.command,
|
|
247
|
+
transcript: transcriptText,
|
|
248
|
+
cwd: dir,
|
|
249
|
+
});
|
|
250
|
+
await writeCache(dir, "summary.md", summary);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (outputPath) {
|
|
254
|
+
await writeFile(outputPath, summary, "utf8");
|
|
255
|
+
} else {
|
|
256
|
+
console.log(summary);
|
|
257
|
+
}
|
|
258
|
+
process.exit(0);
|
|
259
|
+
} else if (listOnly) {
|
|
194
260
|
const languages = await listLanguages(videoId);
|
|
195
261
|
printLanguages(languages);
|
|
196
262
|
process.exit(0);
|
|
197
263
|
}
|
|
198
264
|
|
|
199
265
|
const decode = !noDecode;
|
|
266
|
+
const cacheOpts = { lang, timestamps, json: outputJson, noDecode };
|
|
267
|
+
const dir = cacheDir(videoId, cacheOpts);
|
|
268
|
+
let segments: TranscriptSegment[] | null = null;
|
|
269
|
+
let videoDetailsCache: VideoDetails | null = null;
|
|
270
|
+
|
|
271
|
+
if (!noCache) {
|
|
272
|
+
const cached = await readCache(dir, "transcript.json");
|
|
273
|
+
if (cached) segments = JSON.parse(cached);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!segments) {
|
|
277
|
+
if (showDetails && !outputJson) {
|
|
278
|
+
const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
|
|
279
|
+
const result = (await fetchTranscript(videoId, opts)) as {
|
|
280
|
+
videoDetails: VideoDetails;
|
|
281
|
+
segments: TranscriptSegment[];
|
|
282
|
+
};
|
|
283
|
+
segments = result.segments;
|
|
284
|
+
videoDetailsCache = result.videoDetails;
|
|
285
|
+
} else {
|
|
286
|
+
segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
287
|
+
}
|
|
288
|
+
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
289
|
+
}
|
|
200
290
|
|
|
201
291
|
if (showDetails && !outputJson) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
292
|
+
if (!videoDetailsCache) {
|
|
293
|
+
const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
|
|
294
|
+
const result = (await fetchTranscript(videoId, opts)) as {
|
|
295
|
+
videoDetails: VideoDetails;
|
|
296
|
+
segments: TranscriptSegment[];
|
|
297
|
+
};
|
|
298
|
+
videoDetailsCache = result.videoDetails;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const detailsBlock = formatDetailsBlock(videoDetailsCache);
|
|
302
|
+
const transcriptText = timestamps
|
|
303
|
+
? formatWithTimestamps(segments, decode)
|
|
304
|
+
: toText(segments, decode);
|
|
305
|
+
const output = detailsBlock + "\n\n\n" + transcriptText + "\n";
|
|
212
306
|
|
|
213
307
|
if (outputPath) {
|
|
214
308
|
await writeFile(outputPath, output, "utf8");
|
|
@@ -216,10 +310,6 @@ try {
|
|
|
216
310
|
console.log(output);
|
|
217
311
|
}
|
|
218
312
|
} else {
|
|
219
|
-
const segments = lang
|
|
220
|
-
? await fetchTranscript(videoId, { lang })
|
|
221
|
-
: await fetchTranscript(videoId);
|
|
222
|
-
|
|
223
313
|
const output = outputJson
|
|
224
314
|
? toJSON(segments, decode) + "\n"
|
|
225
315
|
: timestamps
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { summarize } from "./summarize";
|
|
3
|
+
|
|
4
|
+
describe("summarize", () => {
|
|
5
|
+
test("strips input text from output", async () => {
|
|
6
|
+
const result = await summarize({
|
|
7
|
+
prompt: "Summarize this:",
|
|
8
|
+
command: "cat",
|
|
9
|
+
transcript: "Hello world. This is the transcript.",
|
|
10
|
+
});
|
|
11
|
+
expect(result).toBe("");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("strips input text from output with empty prompt", async () => {
|
|
15
|
+
const result = await summarize({
|
|
16
|
+
prompt: "",
|
|
17
|
+
command: "cat",
|
|
18
|
+
transcript: "Just the transcript.",
|
|
19
|
+
});
|
|
20
|
+
expect(result).toBe("");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("preserves response text beyond the input", async () => {
|
|
24
|
+
const cmd = "sh -c 'cat -; echo \"RESPONSE\"'";
|
|
25
|
+
const result = await summarize({
|
|
26
|
+
prompt: "Summarize:",
|
|
27
|
+
command: cmd,
|
|
28
|
+
transcript: "Transcript text.",
|
|
29
|
+
});
|
|
30
|
+
expect(result).toBe("RESPONSE");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("rejects on non-zero exit code", async () => {
|
|
34
|
+
await expect(
|
|
35
|
+
summarize({
|
|
36
|
+
prompt: "test",
|
|
37
|
+
command: "false",
|
|
38
|
+
transcript: "whatever",
|
|
39
|
+
}),
|
|
40
|
+
).rejects.toThrow(/exited with code 1/);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/summarize.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface SummarizeOptions {
|
|
4
|
+
prompt: string;
|
|
5
|
+
command: string;
|
|
6
|
+
transcript: string;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function executeCommand(command: string, input: string, cwd?: string): Promise<string> {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const proc = spawn(command, [], { shell: true, stdio: "pipe", cwd });
|
|
13
|
+
|
|
14
|
+
let stdout = "";
|
|
15
|
+
let stderr = "";
|
|
16
|
+
|
|
17
|
+
proc.stdout!.on("data", (data: Buffer) => {
|
|
18
|
+
stdout += data.toString();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
proc.stderr!.on("data", (data: Buffer) => {
|
|
22
|
+
stderr += data.toString();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
proc.on("close", (code: number | null) => {
|
|
26
|
+
if (code === 0) resolve(stdout);
|
|
27
|
+
else reject(new Error(`Command exited with code ${code}: ${stderr}`));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
proc.on("error", (err: Error) => reject(err));
|
|
31
|
+
|
|
32
|
+
proc.stdin!.write(input);
|
|
33
|
+
proc.stdin!.end();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function summarize(options: SummarizeOptions): Promise<string> {
|
|
38
|
+
const { prompt, command, transcript, cwd } = options;
|
|
39
|
+
const fullPrompt = `${prompt}\n\n${transcript}`;
|
|
40
|
+
const output = await executeCommand(command, fullPrompt, cwd);
|
|
41
|
+
return output.replace(fullPrompt, "").replace(/\n+$/, "");
|
|
42
|
+
}
|