@tacone/prosey 0.2.0 → 0.2.1
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 +85 -11
- 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 +46 -9
- 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,6 +13992,19 @@ 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
|
+
|
|
13967
14008
|
// src/index.ts
|
|
13968
14009
|
var NAME2 = "prosey";
|
|
13969
14010
|
var VERSION2 = "0.1.0";
|
|
@@ -13995,6 +14036,7 @@ Options:
|
|
|
13995
14036
|
--no-decode-entities Preserve HTML entities (decoded by default).
|
|
13996
14037
|
--reset-config Reset config file to defaults and exit.
|
|
13997
14038
|
--no-cache Skip cache and overwrite cache files.
|
|
14039
|
+
--debug Print debug information to stderr.
|
|
13998
14040
|
--help Show this help message.
|
|
13999
14041
|
--version Show version.
|
|
14000
14042
|
|
|
@@ -14073,12 +14115,10 @@ if (args.includes("--reset-config")) {
|
|
|
14073
14115
|
}
|
|
14074
14116
|
var config = await loadConfig().catch(() => ({}));
|
|
14075
14117
|
var mode = "transcript";
|
|
14076
|
-
|
|
14077
|
-
|
|
14078
|
-
args
|
|
14079
|
-
|
|
14080
|
-
mode = "summarize";
|
|
14081
|
-
args.splice(0, 1);
|
|
14118
|
+
var subcmdIndex = args.findIndex((a2) => a2 === "info" || a2 === "summarize");
|
|
14119
|
+
if (subcmdIndex !== -1) {
|
|
14120
|
+
mode = args[subcmdIndex];
|
|
14121
|
+
args.splice(subcmdIndex, 1);
|
|
14082
14122
|
}
|
|
14083
14123
|
var videoId = "";
|
|
14084
14124
|
var lang;
|
|
@@ -14089,6 +14129,7 @@ var outputJson = false;
|
|
|
14089
14129
|
var noDecode = false;
|
|
14090
14130
|
var showDetails = true;
|
|
14091
14131
|
var noCache = false;
|
|
14132
|
+
var debugMode = false;
|
|
14092
14133
|
for (let i = 0;i < args.length; i++) {
|
|
14093
14134
|
const arg = args[i];
|
|
14094
14135
|
if (!arg)
|
|
@@ -14119,6 +14160,8 @@ for (let i = 0;i < args.length; i++) {
|
|
|
14119
14160
|
showDetails = false;
|
|
14120
14161
|
} else if (arg === "--no-cache") {
|
|
14121
14162
|
noCache = true;
|
|
14163
|
+
} else if (arg === "--debug") {
|
|
14164
|
+
debugMode = true;
|
|
14122
14165
|
} else if (arg === "--no-decode-entities") {
|
|
14123
14166
|
noDecode = true;
|
|
14124
14167
|
} else if (arg.startsWith("-")) {
|
|
@@ -14133,6 +14176,19 @@ if (!videoId) {
|
|
|
14133
14176
|
console.log(help());
|
|
14134
14177
|
process.exit(1);
|
|
14135
14178
|
}
|
|
14179
|
+
var extracted = extractVideoId(videoId);
|
|
14180
|
+
if (!extracted) {
|
|
14181
|
+
console.error("Error: invalid YouTube video URL or ID");
|
|
14182
|
+
process.exit(65);
|
|
14183
|
+
}
|
|
14184
|
+
videoId = extracted;
|
|
14185
|
+
if (debugMode)
|
|
14186
|
+
enableDebug();
|
|
14187
|
+
debug("Config file:", configPath());
|
|
14188
|
+
debug("Video ID:", videoId);
|
|
14189
|
+
debug("Mode:", mode);
|
|
14190
|
+
if (lang)
|
|
14191
|
+
debug("Language:", lang);
|
|
14136
14192
|
try {
|
|
14137
14193
|
if (mode === "info") {
|
|
14138
14194
|
const result = await fetchTranscript(videoId, { videoDetails: true, lang });
|
|
@@ -14156,24 +14212,35 @@ try {
|
|
|
14156
14212
|
const cachedSegments = await readCache(dir2, "transcript.json");
|
|
14157
14213
|
const cachedSummary = await readCache(dir2, "summary.md");
|
|
14158
14214
|
if (cachedSegments && cachedSummary) {
|
|
14215
|
+
debug("Cache hit:", dir2);
|
|
14159
14216
|
segments2 = JSON.parse(cachedSegments);
|
|
14160
14217
|
summary = cachedSummary;
|
|
14218
|
+
} else {
|
|
14219
|
+
debug("Cache miss:", dir2);
|
|
14161
14220
|
}
|
|
14221
|
+
} else {
|
|
14222
|
+
debug("Cache skipped (--no-cache)");
|
|
14162
14223
|
}
|
|
14163
14224
|
if (!segments2) {
|
|
14225
|
+
debug("Fetching transcript...");
|
|
14164
14226
|
segments2 = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
14227
|
+
debug(`Transcript fetched: ${segments2.length} segments`);
|
|
14165
14228
|
await writeCache(dir2, "transcript.json", JSON.stringify(segments2));
|
|
14229
|
+
debug("Cache written: transcript.json");
|
|
14166
14230
|
}
|
|
14167
14231
|
const prompt = config.summarize.prompt ?? "";
|
|
14168
14232
|
const transcriptText = toText(segments2, !noDecode);
|
|
14169
14233
|
if (!summary) {
|
|
14234
|
+
debug("Running command:", config.summarize.command);
|
|
14170
14235
|
summary = await summarize({
|
|
14171
14236
|
prompt,
|
|
14172
14237
|
command: config.summarize.command,
|
|
14173
14238
|
transcript: transcriptText,
|
|
14174
14239
|
cwd: dir2
|
|
14175
14240
|
});
|
|
14241
|
+
debug("Command exit: 0");
|
|
14176
14242
|
await writeCache(dir2, "summary.md", summary);
|
|
14243
|
+
debug("Cache written: summary.md");
|
|
14177
14244
|
}
|
|
14178
14245
|
if (outputPath) {
|
|
14179
14246
|
await writeFile3(outputPath, summary, "utf8");
|
|
@@ -14193,10 +14260,17 @@ try {
|
|
|
14193
14260
|
let videoDetailsCache = null;
|
|
14194
14261
|
if (!noCache) {
|
|
14195
14262
|
const cached = await readCache(dir, "transcript.json");
|
|
14196
|
-
if (cached)
|
|
14263
|
+
if (cached) {
|
|
14264
|
+
debug("Cache hit:", dir);
|
|
14197
14265
|
segments = JSON.parse(cached);
|
|
14266
|
+
} else {
|
|
14267
|
+
debug("Cache miss:", dir);
|
|
14268
|
+
}
|
|
14269
|
+
} else {
|
|
14270
|
+
debug("Cache skipped (--no-cache)");
|
|
14198
14271
|
}
|
|
14199
14272
|
if (!segments) {
|
|
14273
|
+
debug("Fetching transcript...");
|
|
14200
14274
|
if (showDetails && !outputJson) {
|
|
14201
14275
|
const opts = lang ? { lang, videoDetails: true } : { videoDetails: true };
|
|
14202
14276
|
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,10 +4,11 @@ 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";
|
|
11
12
|
|
|
12
13
|
const NAME = "prosey";
|
|
13
14
|
const VERSION = "0.1.0";
|
|
@@ -40,6 +41,7 @@ Options:
|
|
|
40
41
|
--no-decode-entities Preserve HTML entities (decoded by default).
|
|
41
42
|
--reset-config Reset config file to defaults and exit.
|
|
42
43
|
--no-cache Skip cache and overwrite cache files.
|
|
44
|
+
--debug Print debug information to stderr.
|
|
43
45
|
--help Show this help message.
|
|
44
46
|
--version Show version.
|
|
45
47
|
|
|
@@ -138,12 +140,10 @@ if (args.includes("--reset-config")) {
|
|
|
138
140
|
const config: ProseyConfig = await loadConfig().catch(() => ({}) as ProseyConfig);
|
|
139
141
|
|
|
140
142
|
let mode = "transcript";
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
args
|
|
144
|
-
|
|
145
|
-
mode = "summarize";
|
|
146
|
-
args.splice(0, 1);
|
|
143
|
+
const subcmdIndex = args.findIndex((a) => a === "info" || a === "summarize");
|
|
144
|
+
if (subcmdIndex !== -1) {
|
|
145
|
+
mode = args[subcmdIndex]!;
|
|
146
|
+
args.splice(subcmdIndex, 1);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
let videoId = "";
|
|
@@ -155,6 +155,7 @@ let outputJson = false;
|
|
|
155
155
|
let noDecode = false;
|
|
156
156
|
let showDetails = true;
|
|
157
157
|
let noCache = false;
|
|
158
|
+
let debugMode = false;
|
|
158
159
|
|
|
159
160
|
for (let i = 0; i < args.length; i++) {
|
|
160
161
|
const arg = args[i];
|
|
@@ -185,6 +186,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
185
186
|
showDetails = false;
|
|
186
187
|
} else if (arg === "--no-cache") {
|
|
187
188
|
noCache = true;
|
|
189
|
+
} else if (arg === "--debug") {
|
|
190
|
+
debugMode = true;
|
|
188
191
|
} else if (arg === "--no-decode-entities") {
|
|
189
192
|
noDecode = true;
|
|
190
193
|
} else if (arg.startsWith("-")) {
|
|
@@ -201,6 +204,21 @@ if (!videoId) {
|
|
|
201
204
|
process.exit(1);
|
|
202
205
|
}
|
|
203
206
|
|
|
207
|
+
const extracted = extractVideoId(videoId);
|
|
208
|
+
|
|
209
|
+
if (!extracted) {
|
|
210
|
+
console.error("Error: invalid YouTube video URL or ID");
|
|
211
|
+
process.exit(65);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
videoId = extracted;
|
|
215
|
+
|
|
216
|
+
if (debugMode) enableDebug();
|
|
217
|
+
debug("Config file:", configPath());
|
|
218
|
+
debug("Video ID:", videoId);
|
|
219
|
+
debug("Mode:", mode);
|
|
220
|
+
if (lang) debug("Language:", lang);
|
|
221
|
+
|
|
204
222
|
try {
|
|
205
223
|
if (mode === "info") {
|
|
206
224
|
const result = await fetchTranscript(videoId, { videoDetails: true, lang } as any);
|
|
@@ -227,27 +245,38 @@ try {
|
|
|
227
245
|
const cachedSegments = await readCache(dir, "transcript.json");
|
|
228
246
|
const cachedSummary = await readCache(dir, "summary.md");
|
|
229
247
|
if (cachedSegments && cachedSummary) {
|
|
248
|
+
debug("Cache hit:", dir);
|
|
230
249
|
segments = JSON.parse(cachedSegments);
|
|
231
250
|
summary = cachedSummary;
|
|
251
|
+
} else {
|
|
252
|
+
debug("Cache miss:", dir);
|
|
232
253
|
}
|
|
254
|
+
} else {
|
|
255
|
+
debug("Cache skipped (--no-cache)");
|
|
233
256
|
}
|
|
234
257
|
|
|
235
258
|
if (!segments) {
|
|
259
|
+
debug("Fetching transcript...");
|
|
236
260
|
segments = lang ? await fetchTranscript(videoId, { lang }) : await fetchTranscript(videoId);
|
|
261
|
+
debug(`Transcript fetched: ${segments.length} segments`);
|
|
237
262
|
await writeCache(dir, "transcript.json", JSON.stringify(segments));
|
|
263
|
+
debug("Cache written: transcript.json");
|
|
238
264
|
}
|
|
239
265
|
|
|
240
266
|
const prompt = config.summarize.prompt ?? "";
|
|
241
267
|
const transcriptText = toText(segments, !noDecode);
|
|
242
268
|
|
|
243
269
|
if (!summary) {
|
|
270
|
+
debug("Running command:", config.summarize.command);
|
|
244
271
|
summary = await summarize({
|
|
245
272
|
prompt,
|
|
246
273
|
command: config.summarize.command,
|
|
247
274
|
transcript: transcriptText,
|
|
248
275
|
cwd: dir,
|
|
249
276
|
});
|
|
277
|
+
debug("Command exit: 0");
|
|
250
278
|
await writeCache(dir, "summary.md", summary);
|
|
279
|
+
debug("Cache written: summary.md");
|
|
251
280
|
}
|
|
252
281
|
|
|
253
282
|
if (outputPath) {
|
|
@@ -270,10 +299,18 @@ try {
|
|
|
270
299
|
|
|
271
300
|
if (!noCache) {
|
|
272
301
|
const cached = await readCache(dir, "transcript.json");
|
|
273
|
-
if (cached)
|
|
302
|
+
if (cached) {
|
|
303
|
+
debug("Cache hit:", dir);
|
|
304
|
+
segments = JSON.parse(cached);
|
|
305
|
+
} else {
|
|
306
|
+
debug("Cache miss:", dir);
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
debug("Cache skipped (--no-cache)");
|
|
274
310
|
}
|
|
275
311
|
|
|
276
312
|
if (!segments) {
|
|
313
|
+
debug("Fetching transcript...");
|
|
277
314
|
if (showDetails && !outputJson) {
|
|
278
315
|
const opts = lang ? { lang, videoDetails: true as const } : { videoDetails: true as const };
|
|
279
316
|
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
|
}
|