@tacone/prosey 0.2.0 → 0.2.2
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 +12 -0
- package/bin/prosey +151 -12
- package/package.json +1 -1
- package/src/cache.test.ts +29 -1
- package/src/cache.ts +11 -0
- package/src/config.ts +17 -3
- package/src/debug.ts +13 -0
- package/src/default-config.toml +6 -2
- package/src/index.ts +48 -10
- package/src/summarize.test.ts +16 -14
- package/src/summarize.ts +10 -1
package/README.md
CHANGED
|
@@ -153,6 +153,18 @@ Transcripts and summaries are cached to `/tmp/prosey/`. Repeated invocations
|
|
|
153
153
|
for the same video and options are instant and work offline. Use `--no-cache`
|
|
154
154
|
to skip cache reads and force a fresh fetch.
|
|
155
155
|
|
|
156
|
+
When running `prosey summarize`, the command runs inside the cache directory
|
|
157
|
+
for that video. This prevents the AI agent from picking up project-specific
|
|
158
|
+
files like `AGENTS.md` or `CLAUDE.md` from the current folder, and limits its
|
|
159
|
+
ability to modify files outside that directory.
|
|
160
|
+
|
|
161
|
+
## Exit codes
|
|
162
|
+
|
|
163
|
+
| Code | Meaning |
|
|
164
|
+
| ---- | ----------------------- |
|
|
165
|
+
| `0` | Success |
|
|
166
|
+
| `65` | Invalid video URL or ID |
|
|
167
|
+
|
|
156
168
|
## How it works
|
|
157
169
|
|
|
158
170
|
prosey uses YouTube's Innertube API via the
|
package/bin/prosey
CHANGED
|
@@ -13851,11 +13851,25 @@ var FALLBACK_CONFIG_TOML = `# Default prosey configuration
|
|
|
13851
13851
|
# Prompt sent to the command via stdin.
|
|
13852
13852
|
# Customize this to change how transcripts are summarized.
|
|
13853
13853
|
prompt = """
|
|
13854
|
-
|
|
13855
|
-
Focus on the key points and main arguments.
|
|
13854
|
+
Write a comprehensive summary of the following transcription.
|
|
13856
13855
|
"""
|
|
13857
13856
|
|
|
13858
|
-
# Command to execute with the prompt piped via stdin.
|
|
13857
|
+
# Command to execute with the prompt and transcript piped via stdin.
|
|
13858
|
+
# The transcript is appended to the prompt automatically.
|
|
13859
|
+
#
|
|
13860
|
+
# Available options:
|
|
13861
|
+
#
|
|
13862
|
+
# opencode run — full access (default)
|
|
13863
|
+
# opencode run --permissions read — read-only (view files, no edits)
|
|
13864
|
+
#
|
|
13865
|
+
# claude -p "" --print — full access (--print for clean output)
|
|
13866
|
+
# claude --permission-mode plan -p "" --print — read-only (plan/read only)
|
|
13867
|
+
#
|
|
13868
|
+
# copilot -sp "" — full access (-s = silent, -p = prompt)
|
|
13869
|
+
# copilot -sp "" --deny-all-tools — read-only (no shell/write access)
|
|
13870
|
+
#
|
|
13871
|
+
# codex --sandbox default -p "" — full access
|
|
13872
|
+
# codex --sandbox read-only -p "" — read-only
|
|
13859
13873
|
command = "opencode run"
|
|
13860
13874
|
`;
|
|
13861
13875
|
async function readDefaultConfig() {
|
|
@@ -13934,7 +13948,11 @@ async function summarize(options) {
|
|
|
13934
13948
|
|
|
13935
13949
|
${transcript}`;
|
|
13936
13950
|
const output = await executeCommand(command, fullPrompt, cwd);
|
|
13937
|
-
|
|
13951
|
+
const cleaned = output.startsWith(fullPrompt) ? output.slice(fullPrompt.length).replace(/\n+$/, "") : output.replace(/\n+$/, "");
|
|
13952
|
+
if (!cleaned || cleaned === transcript) {
|
|
13953
|
+
throw new Error("Summarization command returned no meaningful output");
|
|
13954
|
+
}
|
|
13955
|
+
return cleaned;
|
|
13938
13956
|
}
|
|
13939
13957
|
|
|
13940
13958
|
// src/cache.ts
|
|
@@ -13942,6 +13960,16 @@ import { createHash } from "node:crypto";
|
|
|
13942
13960
|
import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "node:fs/promises";
|
|
13943
13961
|
import { existsSync as existsSync2 } from "node:fs";
|
|
13944
13962
|
import { join as join2 } from "node:path";
|
|
13963
|
+
var RE_YOUTUBE2 = /(?:v=|\/|v\/|embed\/|watch\?.*v=|youtu\.be\/|\/v\/|e\/|watch\?.*vi?=|\/embed\/|\/v\/|vi?\/|watch\?.*vi?=|youtu\.be\/|\/vi?\/|\/e\/)([a-zA-Z0-9_-]{11})/i;
|
|
13964
|
+
var RE_BARE_ID = /^[a-zA-Z0-9_-]{11}$/;
|
|
13965
|
+
function extractVideoId(input) {
|
|
13966
|
+
if (RE_BARE_ID.test(input))
|
|
13967
|
+
return input;
|
|
13968
|
+
const match = input.match(RE_YOUTUBE2);
|
|
13969
|
+
if (match)
|
|
13970
|
+
return match[1] || null;
|
|
13971
|
+
return null;
|
|
13972
|
+
}
|
|
13945
13973
|
function hashOptions(opts) {
|
|
13946
13974
|
return createHash("sha256").update(JSON.stringify(opts)).digest("hex").slice(0, 8);
|
|
13947
13975
|
}
|
|
@@ -13964,9 +13992,87 @@ async function writeCache(dir, filename, data) {
|
|
|
13964
13992
|
await writeFile2(join2(dir, filename), data, "utf8");
|
|
13965
13993
|
}
|
|
13966
13994
|
|
|
13995
|
+
// src/debug.ts
|
|
13996
|
+
var GRAY = "\x1B[90m";
|
|
13997
|
+
var RESET = "\x1B[0m";
|
|
13998
|
+
var enabled = false;
|
|
13999
|
+
function enableDebug() {
|
|
14000
|
+
enabled = true;
|
|
14001
|
+
}
|
|
14002
|
+
function debug(...args) {
|
|
14003
|
+
if (!enabled)
|
|
14004
|
+
return;
|
|
14005
|
+
console.error(GRAY, ...args, RESET);
|
|
14006
|
+
}
|
|
14007
|
+
// package.json
|
|
14008
|
+
var package_default = {
|
|
14009
|
+
name: "@tacone/prosey",
|
|
14010
|
+
version: "0.2.2",
|
|
14011
|
+
description: "Download YouTube video transcripts from the CLI",
|
|
14012
|
+
module: "src/index.ts",
|
|
14013
|
+
type: "module",
|
|
14014
|
+
bin: {
|
|
14015
|
+
prosey: "bin/prosey"
|
|
14016
|
+
},
|
|
14017
|
+
files: [
|
|
14018
|
+
"bin/",
|
|
14019
|
+
"src/",
|
|
14020
|
+
"package.json",
|
|
14021
|
+
"README.md",
|
|
14022
|
+
"LICENSE"
|
|
14023
|
+
],
|
|
14024
|
+
scripts: {
|
|
14025
|
+
start: "bun run src/index.ts",
|
|
14026
|
+
build: "bun build src/index.ts --target node --outfile bin/prosey",
|
|
14027
|
+
test: "bun test",
|
|
14028
|
+
typecheck: "tsc --noEmit",
|
|
14029
|
+
prettier: "prettier --write .",
|
|
14030
|
+
prepack: "bun run build",
|
|
14031
|
+
prepare: "husky || true"
|
|
14032
|
+
},
|
|
14033
|
+
"lint-staged": {
|
|
14034
|
+
"*": "prettier --write --ignore-unknown"
|
|
14035
|
+
},
|
|
14036
|
+
author: "tacone <tacone@gmail.com>",
|
|
14037
|
+
license: "MIT",
|
|
14038
|
+
repository: {
|
|
14039
|
+
type: "git",
|
|
14040
|
+
url: "git+https://github.com/tacone/prosey.git"
|
|
14041
|
+
},
|
|
14042
|
+
bugs: {
|
|
14043
|
+
url: "https://github.com/tacone/prosey/issues"
|
|
14044
|
+
},
|
|
14045
|
+
homepage: "https://github.com/tacone/prosey#readme",
|
|
14046
|
+
keywords: [
|
|
14047
|
+
"youtube",
|
|
14048
|
+
"transcript",
|
|
14049
|
+
"subtitles",
|
|
14050
|
+
"captions",
|
|
14051
|
+
"cli",
|
|
14052
|
+
"npx",
|
|
14053
|
+
"bun"
|
|
14054
|
+
],
|
|
14055
|
+
engines: {
|
|
14056
|
+
node: ">=20"
|
|
14057
|
+
},
|
|
14058
|
+
devDependencies: {
|
|
14059
|
+
"@types/bun": "latest",
|
|
14060
|
+
husky: "^9.1.7",
|
|
14061
|
+
"lint-staged": "^17.0.7",
|
|
14062
|
+
prettier: "^3.8.4"
|
|
14063
|
+
},
|
|
14064
|
+
peerDependencies: {
|
|
14065
|
+
typescript: "^5"
|
|
14066
|
+
},
|
|
14067
|
+
dependencies: {
|
|
14068
|
+
"js-toml": "^1.1.2",
|
|
14069
|
+
"youtube-transcript-plus": "^2.0.0"
|
|
14070
|
+
}
|
|
14071
|
+
};
|
|
14072
|
+
|
|
13967
14073
|
// src/index.ts
|
|
13968
14074
|
var NAME2 = "prosey";
|
|
13969
|
-
var VERSION2 =
|
|
14075
|
+
var VERSION2 = package_default.version;
|
|
13970
14076
|
function help() {
|
|
13971
14077
|
return `${NAME2} v${VERSION2}
|
|
13972
14078
|
|
|
@@ -13995,6 +14101,7 @@ Options:
|
|
|
13995
14101
|
--no-decode-entities Preserve HTML entities (decoded by default).
|
|
13996
14102
|
--reset-config Reset config file to defaults and exit.
|
|
13997
14103
|
--no-cache Skip cache and overwrite cache files.
|
|
14104
|
+
--debug Print debug information to stderr.
|
|
13998
14105
|
--help Show this help message.
|
|
13999
14106
|
--version Show version.
|
|
14000
14107
|
|
|
@@ -14073,12 +14180,10 @@ if (args.includes("--reset-config")) {
|
|
|
14073
14180
|
}
|
|
14074
14181
|
var config = await loadConfig().catch(() => ({}));
|
|
14075
14182
|
var mode = "transcript";
|
|
14076
|
-
|
|
14077
|
-
|
|
14078
|
-
args
|
|
14079
|
-
|
|
14080
|
-
mode = "summarize";
|
|
14081
|
-
args.splice(0, 1);
|
|
14183
|
+
var subcmdIndex = args.findIndex((a2) => a2 === "info" || a2 === "summarize");
|
|
14184
|
+
if (subcmdIndex !== -1) {
|
|
14185
|
+
mode = args[subcmdIndex];
|
|
14186
|
+
args.splice(subcmdIndex, 1);
|
|
14082
14187
|
}
|
|
14083
14188
|
var videoId = "";
|
|
14084
14189
|
var lang;
|
|
@@ -14089,6 +14194,7 @@ var outputJson = false;
|
|
|
14089
14194
|
var noDecode = false;
|
|
14090
14195
|
var showDetails = true;
|
|
14091
14196
|
var noCache = false;
|
|
14197
|
+
var debugMode = false;
|
|
14092
14198
|
for (let i = 0;i < args.length; i++) {
|
|
14093
14199
|
const arg = args[i];
|
|
14094
14200
|
if (!arg)
|
|
@@ -14119,6 +14225,8 @@ for (let i = 0;i < args.length; i++) {
|
|
|
14119
14225
|
showDetails = false;
|
|
14120
14226
|
} else if (arg === "--no-cache") {
|
|
14121
14227
|
noCache = true;
|
|
14228
|
+
} else if (arg === "--debug") {
|
|
14229
|
+
debugMode = true;
|
|
14122
14230
|
} else if (arg === "--no-decode-entities") {
|
|
14123
14231
|
noDecode = true;
|
|
14124
14232
|
} else if (arg.startsWith("-")) {
|
|
@@ -14133,6 +14241,19 @@ if (!videoId) {
|
|
|
14133
14241
|
console.log(help());
|
|
14134
14242
|
process.exit(1);
|
|
14135
14243
|
}
|
|
14244
|
+
var extracted = extractVideoId(videoId);
|
|
14245
|
+
if (!extracted) {
|
|
14246
|
+
console.error("Error: invalid YouTube video URL or ID");
|
|
14247
|
+
process.exit(65);
|
|
14248
|
+
}
|
|
14249
|
+
videoId = extracted;
|
|
14250
|
+
if (debugMode)
|
|
14251
|
+
enableDebug();
|
|
14252
|
+
debug("Config file:", configPath());
|
|
14253
|
+
debug("Video ID:", videoId);
|
|
14254
|
+
debug("Mode:", mode);
|
|
14255
|
+
if (lang)
|
|
14256
|
+
debug("Language:", lang);
|
|
14136
14257
|
try {
|
|
14137
14258
|
if (mode === "info") {
|
|
14138
14259
|
const result = await fetchTranscript(videoId, { videoDetails: true, lang });
|
|
@@ -14156,24 +14277,35 @@ try {
|
|
|
14156
14277
|
const cachedSegments = await readCache(dir2, "transcript.json");
|
|
14157
14278
|
const cachedSummary = await readCache(dir2, "summary.md");
|
|
14158
14279
|
if (cachedSegments && cachedSummary) {
|
|
14280
|
+
debug("Cache hit:", dir2);
|
|
14159
14281
|
segments2 = JSON.parse(cachedSegments);
|
|
14160
14282
|
summary = cachedSummary;
|
|
14283
|
+
} else {
|
|
14284
|
+
debug("Cache miss:", dir2);
|
|
14161
14285
|
}
|
|
14286
|
+
} else {
|
|
14287
|
+
debug("Cache skipped (--no-cache)");
|
|
14162
14288
|
}
|
|
14163
14289
|
if (!segments2) {
|
|
14290
|
+
debug("Fetching transcript...");
|
|
14164
14291
|
segments2 = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
14292
|
+
debug(`Transcript fetched: ${segments2.length} segments`);
|
|
14165
14293
|
await writeCache(dir2, "transcript.json", JSON.stringify(segments2));
|
|
14294
|
+
debug("Cache written: transcript.json");
|
|
14166
14295
|
}
|
|
14167
14296
|
const prompt = config.summarize.prompt ?? "";
|
|
14168
14297
|
const transcriptText = toText(segments2, !noDecode);
|
|
14169
14298
|
if (!summary) {
|
|
14299
|
+
debug("Running command:", config.summarize.command);
|
|
14170
14300
|
summary = await summarize({
|
|
14171
14301
|
prompt,
|
|
14172
14302
|
command: config.summarize.command,
|
|
14173
14303
|
transcript: transcriptText,
|
|
14174
14304
|
cwd: dir2
|
|
14175
14305
|
});
|
|
14306
|
+
debug("Command exit: 0");
|
|
14176
14307
|
await writeCache(dir2, "summary.md", summary);
|
|
14308
|
+
debug("Cache written: summary.md");
|
|
14177
14309
|
}
|
|
14178
14310
|
if (outputPath) {
|
|
14179
14311
|
await writeFile3(outputPath, summary, "utf8");
|
|
@@ -14193,10 +14325,17 @@ try {
|
|
|
14193
14325
|
let videoDetailsCache = null;
|
|
14194
14326
|
if (!noCache) {
|
|
14195
14327
|
const cached = await readCache(dir, "transcript.json");
|
|
14196
|
-
if (cached)
|
|
14328
|
+
if (cached) {
|
|
14329
|
+
debug("Cache hit:", dir);
|
|
14197
14330
|
segments = JSON.parse(cached);
|
|
14331
|
+
} else {
|
|
14332
|
+
debug("Cache miss:", dir);
|
|
14333
|
+
}
|
|
14334
|
+
} else {
|
|
14335
|
+
debug("Cache skipped (--no-cache)");
|
|
14198
14336
|
}
|
|
14199
14337
|
if (!segments) {
|
|
14338
|
+
debug("Fetching transcript...");
|
|
14200
14339
|
if (showDetails && !outputJson) {
|
|
14201
14340
|
const opts = lang ? { lang, videoDetails: true } : { videoDetails: true };
|
|
14202
14341
|
const result = await fetchTranscript(videoId, opts);
|
package/package.json
CHANGED
package/src/cache.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, expect, test, afterEach } from "bun:test";
|
|
|
2
2
|
import { rm, readFile } from "node:fs/promises";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { cacheKey, cacheDir, readCache, writeCache } from "./cache";
|
|
5
|
+
import { cacheKey, cacheDir, readCache, writeCache, extractVideoId } from "./cache";
|
|
6
6
|
|
|
7
7
|
const testDir = "/tmp/prosey/test-cache-spec";
|
|
8
8
|
|
|
@@ -44,6 +44,34 @@ describe("cacheDir", () => {
|
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
describe("extractVideoId", () => {
|
|
48
|
+
test("bare ID passes through", () => {
|
|
49
|
+
expect(extractVideoId("dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("full watch URL", () => {
|
|
53
|
+
expect(extractVideoId("https://www.youtube.com/watch?v=jNAAG3Ma5K8")).toBe("jNAAG3Ma5K8");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("watch URL with extra query params", () => {
|
|
57
|
+
expect(
|
|
58
|
+
extractVideoId("https://www.youtube.com/watch?v=jNAAG3Ma5K8&pp=ygUKc3BhY2V4IGlwbw%3D%3D"),
|
|
59
|
+
).toBe("jNAAG3Ma5K8");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("short youtu.be URL", () => {
|
|
63
|
+
expect(extractVideoId("https://youtu.be/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("embed URL", () => {
|
|
67
|
+
expect(extractVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("invalid URL without video ID returns null", () => {
|
|
71
|
+
expect(extractVideoId("https://example.com/search?q=hello")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
47
75
|
describe("readCache / writeCache", () => {
|
|
48
76
|
test("writes and reads a file", async () => {
|
|
49
77
|
await writeCache(testDir, "test.txt", "hello world");
|
package/src/cache.ts
CHANGED
|
@@ -3,6 +3,17 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
|
+
const RE_YOUTUBE =
|
|
7
|
+
/(?:v=|\/|v\/|embed\/|watch\?.*v=|youtu\.be\/|\/v\/|e\/|watch\?.*vi?=|\/embed\/|\/v\/|vi?\/|watch\?.*vi?=|youtu\.be\/|\/vi?\/|\/e\/)([a-zA-Z0-9_-]{11})/i;
|
|
8
|
+
const RE_BARE_ID = /^[a-zA-Z0-9_-]{11}$/;
|
|
9
|
+
|
|
10
|
+
export function extractVideoId(input: string): string | null {
|
|
11
|
+
if (RE_BARE_ID.test(input)) return input;
|
|
12
|
+
const match = input.match(RE_YOUTUBE);
|
|
13
|
+
if (match) return match[1] || null;
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
6
17
|
export interface CacheOptions {
|
|
7
18
|
lang?: string;
|
|
8
19
|
timestamps?: boolean;
|
package/src/config.ts
CHANGED
|
@@ -19,11 +19,25 @@ const FALLBACK_CONFIG_TOML = `# Default prosey configuration
|
|
|
19
19
|
# Prompt sent to the command via stdin.
|
|
20
20
|
# Customize this to change how transcripts are summarized.
|
|
21
21
|
prompt = """
|
|
22
|
-
|
|
23
|
-
Focus on the key points and main arguments.
|
|
22
|
+
Write a comprehensive summary of the following transcription.
|
|
24
23
|
"""
|
|
25
24
|
|
|
26
|
-
# Command to execute with the prompt piped via stdin.
|
|
25
|
+
# Command to execute with the prompt and transcript piped via stdin.
|
|
26
|
+
# The transcript is appended to the prompt automatically.
|
|
27
|
+
#
|
|
28
|
+
# Available options:
|
|
29
|
+
#
|
|
30
|
+
# opencode run — full access (default)
|
|
31
|
+
# opencode run --permissions read — read-only (view files, no edits)
|
|
32
|
+
#
|
|
33
|
+
# claude -p "" --print — full access (--print for clean output)
|
|
34
|
+
# claude --permission-mode plan -p "" --print — read-only (plan/read only)
|
|
35
|
+
#
|
|
36
|
+
# copilot -sp "" — full access (-s = silent, -p = prompt)
|
|
37
|
+
# copilot -sp "" --deny-all-tools — read-only (no shell/write access)
|
|
38
|
+
#
|
|
39
|
+
# codex --sandbox default -p "" — full access
|
|
40
|
+
# codex --sandbox read-only -p "" — read-only
|
|
27
41
|
command = "opencode run"
|
|
28
42
|
`;
|
|
29
43
|
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const GRAY = "\x1b[90m";
|
|
2
|
+
const RESET = "\x1b[0m";
|
|
3
|
+
|
|
4
|
+
let enabled = false;
|
|
5
|
+
|
|
6
|
+
export function enableDebug(): void {
|
|
7
|
+
enabled = true;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function debug(...args: unknown[]): void {
|
|
11
|
+
if (!enabled) return;
|
|
12
|
+
console.error(GRAY, ...args, RESET);
|
|
13
|
+
}
|
package/src/default-config.toml
CHANGED
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
# Created automatically on first run. Edit as needed.
|
|
3
3
|
|
|
4
4
|
[summarize]
|
|
5
|
+
|
|
5
6
|
# Prompt sent to the command via stdin.
|
|
6
7
|
# Customize this to change how transcripts are summarized.
|
|
8
|
+
|
|
7
9
|
prompt = """
|
|
8
10
|
Write a comprehensive summary of the following transcription.
|
|
9
11
|
"""
|
|
10
12
|
|
|
11
|
-
# Command to execute with the prompt piped via stdin.
|
|
12
|
-
|
|
13
|
+
# Command to execute with the prompt and transcript piped via stdin.
|
|
14
|
+
# The transcript is appended to the prompt automatically.
|
|
15
|
+
|
|
16
|
+
command = "OPENCODE_PERMISSION='{\"read\":\"allow\",\"write\":\"deny\",\"edit\":\"deny\",\"bash\":\"deny\",\"glob\":\"deny\",\"grep\":\"deny\",\"webfetch\":\"deny\",\"task\":\"deny\",\"todowrite\":\"deny\",\"websearch\":\"deny\",\"lsp\":\"deny\"}' opencode run --pure"
|
package/src/index.ts
CHANGED
|
@@ -4,13 +4,15 @@ import { writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { fetchTranscript, listLanguages } from "youtube-transcript-plus";
|
|
5
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";
|
|
7
|
+
import { loadConfig, resetConfig, configPath } from "./config";
|
|
8
8
|
import type { ProseyConfig } from "./config";
|
|
9
9
|
import { summarize } from "./summarize";
|
|
10
|
-
import { cacheDir, readCache, writeCache } from "./cache";
|
|
10
|
+
import { cacheDir, readCache, writeCache, extractVideoId } from "./cache";
|
|
11
|
+
import { enableDebug, debug } from "./debug";
|
|
12
|
+
import pkg from "../package.json";
|
|
11
13
|
|
|
12
14
|
const NAME = "prosey";
|
|
13
|
-
const VERSION =
|
|
15
|
+
const VERSION = pkg.version;
|
|
14
16
|
|
|
15
17
|
function help(): string {
|
|
16
18
|
return `${NAME} v${VERSION}
|
|
@@ -40,6 +42,7 @@ Options:
|
|
|
40
42
|
--no-decode-entities Preserve HTML entities (decoded by default).
|
|
41
43
|
--reset-config Reset config file to defaults and exit.
|
|
42
44
|
--no-cache Skip cache and overwrite cache files.
|
|
45
|
+
--debug Print debug information to stderr.
|
|
43
46
|
--help Show this help message.
|
|
44
47
|
--version Show version.
|
|
45
48
|
|
|
@@ -138,12 +141,10 @@ if (args.includes("--reset-config")) {
|
|
|
138
141
|
const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
|
|
139
142
|
|
|
140
143
|
let mode = "transcript";
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
args
|
|
144
|
-
|
|
145
|
-
mode = "summarize";
|
|
146
|
-
args.splice(0, 1);
|
|
144
|
+
const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize");
|
|
145
|
+
if (subcmdIndex !== -1) {
|
|
146
|
+
mode = args[subcmdIndex]!;
|
|
147
|
+
args.splice(subcmdIndex, 1);
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
let videoId = "";
|
|
@@ -155,6 +156,7 @@ let outputJson = false;
|
|
|
155
156
|
let noDecode = false;
|
|
156
157
|
let showDetails = true;
|
|
157
158
|
let noCache = false;
|
|
159
|
+
let debugMode = false;
|
|
158
160
|
|
|
159
161
|
for (let i = 0; i < args.length; i++) {
|
|
160
162
|
const arg = args[i];
|
|
@@ -185,6 +187,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
185
187
|
showDetails = false;
|
|
186
188
|
} else if (arg === "--no-cache") {
|
|
187
189
|
noCache = true;
|
|
190
|
+
} else if (arg === "--debug") {
|
|
191
|
+
debugMode = true;
|
|
188
192
|
} else if (arg === "--no-decode-entities") {
|
|
189
193
|
noDecode = true;
|
|
190
194
|
} else if (arg.startsWith("-")) {
|
|
@@ -201,6 +205,21 @@ if (!videoId) {
|
|
|
201
205
|
process.exit(1);
|
|
202
206
|
}
|
|
203
207
|
|
|
208
|
+
const extracted = extractVideoId(videoId);
|
|
209
|
+
|
|
210
|
+
if (!extracted) {
|
|
211
|
+
console.error("Error: invalid YouTube video URL or ID");
|
|
212
|
+
process.exit(65);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
videoId = extracted;
|
|
216
|
+
|
|
217
|
+
if (debugMode) enableDebug();
|
|
218
|
+
debug("Config file:", configPath());
|
|
219
|
+
debug("Video ID:", videoId);
|
|
220
|
+
debug("Mode:", mode);
|
|
221
|
+
if (lang) debug("Language:", lang);
|
|
222
|
+
|
|
204
223
|
try {
|
|
205
224
|
if (mode === "info") {
|
|
206
225
|
const result = await fetchTranscript(videoId, { videoDetails: true, lang } as any);
|
|
@@ -227,27 +246,38 @@ try {
|
|
|
227
246
|
const cachedSegments = await readCache(dir, "transcript.json");
|
|
228
247
|
const cachedSummary = await readCache(dir, "summary.md");
|
|
229
248
|
if (cachedSegments && cachedSummary) {
|
|
249
|
+
debug("Cache hit:", dir);
|
|
230
250
|
segments = JSON.parse(cachedSegments);
|
|
231
251
|
summary = cachedSummary;
|
|
252
|
+
} else {
|
|
253
|
+
debug("Cache miss:", dir);
|
|
232
254
|
}
|
|
255
|
+
} else {
|
|
256
|
+
debug("Cache skipped (--no-cache)");
|
|
233
257
|
}
|
|
234
258
|
|
|
235
259
|
if (!segments) {
|
|
260
|
+
debug("Fetching transcript...");
|
|
236
261
|
segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
262
|
+
debug(`Transcript fetched: ${segments.length} segments`);
|
|
237
263
|
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
264
|
+
debug("Cache written: transcript.json");
|
|
238
265
|
}
|
|
239
266
|
|
|
240
267
|
const prompt = config.summarize.prompt ?? "";
|
|
241
268
|
const transcriptText = toText(segments, !noDecode);
|
|
242
269
|
|
|
243
270
|
if (!summary) {
|
|
271
|
+
debug("Running command:", config.summarize.command);
|
|
244
272
|
summary = await summarize({
|
|
245
273
|
prompt,
|
|
246
274
|
command: config.summarize.command,
|
|
247
275
|
transcript: transcriptText,
|
|
248
276
|
cwd: dir,
|
|
249
277
|
});
|
|
278
|
+
debug("Command exit: 0");
|
|
250
279
|
await writeCache(dir, "summary.md", summary);
|
|
280
|
+
debug("Cache written: summary.md");
|
|
251
281
|
}
|
|
252
282
|
|
|
253
283
|
if (outputPath) {
|
|
@@ -270,10 +300,18 @@ try {
|
|
|
270
300
|
|
|
271
301
|
if (!noCache) {
|
|
272
302
|
const cached = await readCache(dir, "transcript.json");
|
|
273
|
-
if (cached)
|
|
303
|
+
if (cached) {
|
|
304
|
+
debug("Cache hit:", dir);
|
|
305
|
+
segments = JSON.parse(cached);
|
|
306
|
+
} else {
|
|
307
|
+
debug("Cache miss:", dir);
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
debug("Cache skipped (--no-cache)");
|
|
274
311
|
}
|
|
275
312
|
|
|
276
313
|
if (!segments) {
|
|
314
|
+
debug("Fetching transcript...");
|
|
277
315
|
if (showDetails && !outputJson) {
|
|
278
316
|
const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
|
|
279
317
|
const result = (await fetchTranscript(videoId, opts)) as {
|
package/src/summarize.test.ts
CHANGED
|
@@ -2,22 +2,24 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { summarize } from "./summarize";
|
|
3
3
|
|
|
4
4
|
describe("summarize", () => {
|
|
5
|
-
test("
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
test("rejects when command only echoes input", async () => {
|
|
6
|
+
await expect(
|
|
7
|
+
summarize({
|
|
8
|
+
prompt: "Summarize this:",
|
|
9
|
+
command: "cat",
|
|
10
|
+
transcript: "Hello world. This is the transcript.",
|
|
11
|
+
}),
|
|
12
|
+
).rejects.toThrow("Summarization command returned no meaningful output");
|
|
12
13
|
});
|
|
13
14
|
|
|
14
|
-
test("
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
test("rejects when command only echoes input with empty prompt", async () => {
|
|
16
|
+
await expect(
|
|
17
|
+
summarize({
|
|
18
|
+
prompt: "",
|
|
19
|
+
command: "cat",
|
|
20
|
+
transcript: "Just the transcript.",
|
|
21
|
+
}),
|
|
22
|
+
).rejects.toThrow("Summarization command returned no meaningful output");
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
test("preserves response text beyond the input", async () => {
|
package/src/summarize.ts
CHANGED
|
@@ -38,5 +38,14 @@ export async function summarize(options: SummarizeOptions): Promise<string> {
|
|
|
38
38
|
const { prompt, command, transcript, cwd } = options;
|
|
39
39
|
const fullPrompt = `${prompt}\n\n${transcript}`;
|
|
40
40
|
const output = await executeCommand(command, fullPrompt, cwd);
|
|
41
|
-
|
|
41
|
+
|
|
42
|
+
const cleaned = output.startsWith(fullPrompt)
|
|
43
|
+
? output.slice(fullPrompt.length).replace(/\n+$/, "")
|
|
44
|
+
: output.replace(/\n+$/, "");
|
|
45
|
+
|
|
46
|
+
if (!cleaned || cleaned === transcript) {
|
|
47
|
+
throw new Error("Summarization command returned no meaningful output");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return cleaned;
|
|
42
51
|
}
|