yt-briefing 0.6.0 → 0.7.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/.claude/skills/yt-search/SKILL.md +1 -1
- package/README.md +24 -16
- package/dist/bootstrap.js +47 -21
- package/dist/lib/env.js +38 -0
- package/dist/lib/llm.js +10 -9
- package/dist/lib/paths.js +7 -0
- package/dist/lib/yt-api.js +2 -2
- package/dist/yt-channel-pending.js +3 -3
- package/dist/yt-channel-videos.js +2 -3
- package/dist/yt-rating.js +3 -3
- package/dist/yt-search.js +20 -10
- package/dist/yt-sweep.js +11 -9
- package/dist/yt-transcript.js +3 -3
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yt-search
|
|
3
3
|
description: Search WITHIN one YouTube channel by intent — name a channel and what you're after; the engine lists that channel's uploads, ranks them against your intent (metadata only, no transcript yet), then lazily yields ONE matching video at a time with a rich summary. You keep or skip each; at the end it synthesizes a comparison from everything you kept. Channel-scoped, not whole-YouTube. Same transcript engine + proxy as /yt; lazy on purpose (no transcript bursts → no IP block). Summaries and prompts use the language chosen at onboarding.
|
|
4
|
-
argument-hint: A channel (@handle or URL) and a descriptive intent, e.g. "@t3dotgg which terminal for AI coding". Optional --
|
|
4
|
+
argument-hint: A channel (@handle or URL) and a descriptive intent, e.g. "@t3dotgg which terminal for AI coding". Optional --top N (default 10), --scan N, --since YYYY-MM-DD.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## How it works
|
package/README.md
CHANGED
|
@@ -79,24 +79,15 @@ npx yt-briefing transcribe <url-or-id> --lang auto # prints the transcript to
|
|
|
79
79
|
|
|
80
80
|
## Search within a channel
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`@t3dotgg which terminal for AI coding`. It lists that channel's uploads, ranks them against your
|
|
85
|
-
intent (titles/descriptions — no transcripts yet, matching is **descriptive, not exact keywords**),
|
|
86
|
-
then hands you **one matching video at a time** with a rich summary; you **Keep** or **Skip** each.
|
|
87
|
-
At the end it synthesizes a **comparison** from everything you kept.
|
|
82
|
+
Mine one channel's videos for a topic and get a comparison. Run `/yt-search` with a channel and
|
|
83
|
+
an intent — e.g. `/yt-search @betterstack which terminal for AI coding`.
|
|
88
84
|
|
|
89
|
-
It's
|
|
90
|
-
|
|
91
|
-
|
|
85
|
+
It covers the channel's **whole history** (not just recent uploads), re-ranks every upload against
|
|
86
|
+
your intent, then lazily yields one matching video at a time to keep or skip — and synthesizes a
|
|
87
|
+
comparison from everything you kept.
|
|
92
88
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
> Listing a channel's uploads is cheap (`playlistItems`, ~1 quota unit per 50 videos — not the
|
|
98
|
-
> 100-unit `search.list`). `--scan` (default 50) caps how many recent uploads are considered;
|
|
99
|
-
> `--since` widens by date.
|
|
89
|
+
The one flag is `--top N` — how many of the top re-ranked matches to triage (**default 10**). Raise
|
|
90
|
+
it to go deeper, lower it for a quicker pass: `/yt-search @betterstack which terminal --top 20`.
|
|
100
91
|
|
|
101
92
|
## Run it
|
|
102
93
|
|
|
@@ -121,6 +112,23 @@ YT_BRIEFING_LLM_MODEL=gemini-2.5-flash
|
|
|
121
112
|
Want something else? Change those three lines for OpenRouter (`https://openrouter.ai/api/v1`),
|
|
122
113
|
OpenAI (`https://api.openai.com/v1`), or a local Ollama (`http://localhost:11434/v1`).
|
|
123
114
|
|
|
115
|
+
### Where the keys live
|
|
116
|
+
|
|
117
|
+
Keys are read from your project's **root `.env`** only — there is no fallback file. Put them there
|
|
118
|
+
(or export them in the shell / CI — an exported var wins). `bun run init` writes them into your
|
|
119
|
+
root `.env`, merging without clobbering any other variables already in it.
|
|
120
|
+
|
|
121
|
+
These are **required**, and missing one fails fast naming exactly which (no silent defaults):
|
|
122
|
+
|
|
123
|
+
```ini
|
|
124
|
+
YT_BRIEFING_LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
|
|
125
|
+
YT_BRIEFING_LLM_API_KEY=<your-key>
|
|
126
|
+
YT_BRIEFING_LLM_MODEL=gemini-2.5-flash
|
|
127
|
+
YT_BRIEFING_YOUTUBE_API_KEY=<your-key>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`YT_BRIEFING_PROXY` and `YT_BRIEFING_DLP_PATH` are optional. Keep `.env` gitignored.
|
|
131
|
+
|
|
124
132
|
## Why an API, not the agent's native model
|
|
125
133
|
|
|
126
134
|
The filtering and the summaries go through a plain OpenAI-compatible API call from the engine,
|
package/dist/bootstrap.js
CHANGED
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
* Re-running is safe: it warns before overwriting existing data and lets you bail.
|
|
14
14
|
* Everything it writes is plain Markdown / JSON you can also edit by hand afterwards.
|
|
15
15
|
*/
|
|
16
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
|
-
import { DATA_DIR, BASE_DIR, PKG_ROOT, CHANNELS_DIR, CHANNELS_MD, STATE_MD, CONFIG_JSON,
|
|
18
|
+
import { DATA_DIR, BASE_DIR, PKG_ROOT, CHANNELS_DIR, CHANNELS_MD, STATE_MD, CONFIG_JSON, ROOT_ENV_PATH, profilePath, } from "./lib/paths.js";
|
|
19
19
|
import { AGENTS, installSkills, projectSkillsRoot, customSkillsRootDefault, isPackageDevCwd } from "./lib/skill-install.js";
|
|
20
|
+
import { loadEnv } from "./lib/env.js";
|
|
20
21
|
import { question } from "./lib/prompt.js";
|
|
21
22
|
import { normalizeHandle, slugify, serializeChannels, serializeState, profileBody, baselineStateRow } from "./lib/channels.js";
|
|
22
23
|
const ask = (q, def = '') => {
|
|
@@ -29,8 +30,34 @@ const askYN = (q, def = true) => {
|
|
|
29
30
|
return def;
|
|
30
31
|
return a.startsWith('y');
|
|
31
32
|
};
|
|
33
|
+
// Secrets: if already provided by the environment (root .env or an exported var), offer to keep it
|
|
34
|
+
// without echoing the value back to the terminal. Returns the kept/typed value.
|
|
35
|
+
const askSecret = (q, preset) => {
|
|
36
|
+
if (preset)
|
|
37
|
+
return question(`${q} [Enter = keep the value from your environment]:`).trim() || preset;
|
|
38
|
+
return ask(q);
|
|
39
|
+
};
|
|
40
|
+
// Merge vars into the project root .env without clobbering: only append keys not already present
|
|
41
|
+
// (so a value the user keeps from their existing root .env stays the single source). Skips empties.
|
|
42
|
+
function mergeRootEnv(vars) {
|
|
43
|
+
const existing = existsSync(ROOT_ENV_PATH) ? readFileSync(ROOT_ENV_PATH, 'utf8') : '';
|
|
44
|
+
const present = new Set(existing.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))
|
|
45
|
+
.map(l => l.slice(0, l.indexOf('=')).trim()).filter(Boolean));
|
|
46
|
+
const additions = vars.filter(([k, v]) => v !== '' && !present.has(k)).map(([k, v]) => `${k}=${v}`);
|
|
47
|
+
if (additions.length) {
|
|
48
|
+
const sep = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
49
|
+
writeFileSync(ROOT_ENV_PATH, existing + sep + additions.join('\n') + '\n', 'utf8');
|
|
50
|
+
}
|
|
51
|
+
return additions.map(l => l.slice(0, l.indexOf('=')));
|
|
52
|
+
}
|
|
32
53
|
function main() {
|
|
33
54
|
console.log('\n yt-briefing — onboarding\n ' + '─'.repeat(40) + '\n');
|
|
55
|
+
// See what the environment already provides (root .env / exported vars) so we can offer to keep
|
|
56
|
+
// those secrets instead of re-asking, and avoid baking a second copy into .yt-briefing/.env.
|
|
57
|
+
loadEnv();
|
|
58
|
+
const presetLlmKey = process.env.YT_BRIEFING_LLM_API_KEY ?? '';
|
|
59
|
+
const presetYtKey = process.env.YT_BRIEFING_YOUTUBE_API_KEY ?? '';
|
|
60
|
+
const presetProxy = process.env.YT_BRIEFING_PROXY ?? '';
|
|
34
61
|
if (existsSync(CHANNELS_MD)) {
|
|
35
62
|
console.log(` Existing data found at ${DATA_DIR}`);
|
|
36
63
|
if (!askYN(' Overwrite it?', false)) {
|
|
@@ -61,7 +88,7 @@ function main() {
|
|
|
61
88
|
const picked = PROVIDERS[ask(' Your choice', '1')];
|
|
62
89
|
if (picked) {
|
|
63
90
|
console.log(`\n → ${picked.name}. Get your key here: ${picked.keyUrl}`);
|
|
64
|
-
llmKey =
|
|
91
|
+
llmKey = askSecret(' Paste your API key', presetLlmKey);
|
|
65
92
|
llmBaseUrl = picked.base;
|
|
66
93
|
llmModel = ask(' Model (Enter to accept)', picked.model);
|
|
67
94
|
}
|
|
@@ -69,13 +96,13 @@ function main() {
|
|
|
69
96
|
console.log('\n → Custom / local endpoint (e.g. Ollama at http://localhost:11434/v1)');
|
|
70
97
|
llmBaseUrl = ask(' YT_BRIEFING_LLM_BASE_URL', 'http://localhost:11434/v1');
|
|
71
98
|
llmModel = ask(' YT_BRIEFING_LLM_MODEL', 'llama3.1');
|
|
72
|
-
llmKey =
|
|
99
|
+
llmKey = askSecret(' YT_BRIEFING_LLM_API_KEY (blank for local)', presetLlmKey);
|
|
73
100
|
}
|
|
74
101
|
console.log('\n 3) YouTube Data API (needed to list channel uploads)');
|
|
75
102
|
console.log(' Get a key: https://console.cloud.google.com → YouTube Data API v3');
|
|
76
|
-
const ytKey =
|
|
103
|
+
const ytKey = askSecret(' YT_BRIEFING_YOUTUBE_API_KEY', presetYtKey);
|
|
77
104
|
console.log('\n 4) Proxy (optional — only needed on datacenter/VPS IPs; see docs/warp-proxy.md)');
|
|
78
|
-
const ytProxy =
|
|
105
|
+
const ytProxy = askSecret(' YT_BRIEFING_PROXY (blank = direct)', presetProxy);
|
|
79
106
|
// 5. Channels ----------------------------------------------------------------
|
|
80
107
|
// Just collect a flat list. No categories, no per-channel rules to define up front —
|
|
81
108
|
// each channel's profile LEARNS what to skip as you rate it (## Skip titles / ## Notes).
|
|
@@ -118,21 +145,20 @@ function main() {
|
|
|
118
145
|
const customDir = AGENTS[agentKey] ? '' : ask(' Skills folder to install into', customSkillsRootDefault());
|
|
119
146
|
// 7. Write everything --------------------------------------------------------
|
|
120
147
|
mkdirSync(CHANNELS_DIR, { recursive: true });
|
|
121
|
-
// .env
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
'',
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// (for sync). In a dev clone (BASE_DIR === PKG_ROOT) the repo's own .gitignore already covers it.
|
|
148
|
+
// Secrets live in the project ROOT .env (the only file the engine reads). Merge into it without
|
|
149
|
+
// clobbering — keys already there (e.g. kept from the environment) stay the single source. The
|
|
150
|
+
// engine has no fallback file, so a key not set here (or exported) is a hard error at run time.
|
|
151
|
+
const envAdded = mergeRootEnv([
|
|
152
|
+
['YT_BRIEFING_LLM_BASE_URL', llmBaseUrl],
|
|
153
|
+
['YT_BRIEFING_LLM_API_KEY', llmKey],
|
|
154
|
+
['YT_BRIEFING_LLM_MODEL', llmModel],
|
|
155
|
+
['YT_BRIEFING_YOUTUBE_API_KEY', ytKey],
|
|
156
|
+
['YT_BRIEFING_PROXY', ytProxy],
|
|
157
|
+
]);
|
|
158
|
+
// Keep the throwaway cache out of git for the consume layout. data/ stays versionable (for sync).
|
|
159
|
+
// No secrets live under .yt-briefing/ anymore, so nothing else needs ignoring here.
|
|
134
160
|
if (BASE_DIR !== PKG_ROOT) {
|
|
135
|
-
writeFileSync(join(BASE_DIR, '.gitignore'), '
|
|
161
|
+
writeFileSync(join(BASE_DIR, '.gitignore'), 'data/.cache/\n', 'utf8');
|
|
136
162
|
}
|
|
137
163
|
// config.json
|
|
138
164
|
writeFileSync(CONFIG_JSON, JSON.stringify({ output_lang: outputLang }, null, 2) + '\n', 'utf8');
|
|
@@ -144,7 +170,7 @@ function main() {
|
|
|
144
170
|
writeFileSync(profilePath(c.slug), profileBody(c.handle, c.slug), 'utf8');
|
|
145
171
|
console.log(' ' + '─'.repeat(40));
|
|
146
172
|
console.log(` Done. Wrote:`);
|
|
147
|
-
console.log(` ${
|
|
173
|
+
console.log(` ${ROOT_ENV_PATH} ${envAdded.length ? `(+${envAdded.join(', ')})` : '(no new keys — already set)'}`);
|
|
148
174
|
console.log(` ${CONFIG_JSON}`);
|
|
149
175
|
console.log(` ${CHANNELS_MD}`);
|
|
150
176
|
console.log(` ${STATE_MD}`);
|
package/dist/lib/env.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env loader + required-variable preflight — the one place that reads secrets off disk.
|
|
3
|
+
*
|
|
4
|
+
* Keys come from the project's root `.env` ONLY. There is no fallback file: dotenv loads root
|
|
5
|
+
* `.env` into process.env (anything already exported wins, since dotenv never overrides). Missing
|
|
6
|
+
* a required variable is a hard error that names exactly which one — never a silent default.
|
|
7
|
+
*
|
|
8
|
+
* Call `loadEnv()` once at every entrypoint, then `requireEnv([...])` for what that command needs.
|
|
9
|
+
*/
|
|
10
|
+
import dotenv from 'dotenv';
|
|
11
|
+
import { ROOT_ENV_PATH } from "./paths.js";
|
|
12
|
+
let done = false;
|
|
13
|
+
/** Load the project's root `.env` into process.env. The only file we read. */
|
|
14
|
+
export function loadEnv() {
|
|
15
|
+
if (done)
|
|
16
|
+
return;
|
|
17
|
+
done = true;
|
|
18
|
+
dotenv.config({ path: ROOT_ENV_PATH });
|
|
19
|
+
}
|
|
20
|
+
/** The required vars per capability — single source of truth for the preflight checks. */
|
|
21
|
+
export const REQUIRED_LLM = ['YT_BRIEFING_LLM_BASE_URL', 'YT_BRIEFING_LLM_API_KEY', 'YT_BRIEFING_LLM_MODEL'];
|
|
22
|
+
export const REQUIRED_YOUTUBE = ['YT_BRIEFING_YOUTUBE_API_KEY'];
|
|
23
|
+
/** Names from `names` that are missing or empty in the environment, in order. */
|
|
24
|
+
export function missingEnv(names) {
|
|
25
|
+
return names.filter(n => !process.env[n]);
|
|
26
|
+
}
|
|
27
|
+
/** Human-readable error for a set of missing vars — names each one and where to set it. */
|
|
28
|
+
export function missingEnvMessage(missing) {
|
|
29
|
+
const plural = missing.length > 1;
|
|
30
|
+
return `Missing required environment variable${plural ? 's' : ''}: ${missing.join(', ')}. ` +
|
|
31
|
+
`Set ${plural ? 'them' : 'it'} in your project root .env (see README → Providers → Where the keys live).`;
|
|
32
|
+
}
|
|
33
|
+
/** Throw a clear, named error if any required var is missing. */
|
|
34
|
+
export function requireEnv(names) {
|
|
35
|
+
const missing = missingEnv(names);
|
|
36
|
+
if (missing.length)
|
|
37
|
+
throw new Error(missingEnvMessage(missing));
|
|
38
|
+
}
|
package/dist/lib/llm.js
CHANGED
|
@@ -10,21 +10,22 @@
|
|
|
10
10
|
* the default — cheap and fast enough for the batch title filter, capable enough for
|
|
11
11
|
* the summaries.
|
|
12
12
|
*
|
|
13
|
-
* Env (see .env.example):
|
|
14
|
-
* YT_BRIEFING_LLM_BASE_URL
|
|
13
|
+
* Env (see .env.example) — all required, no defaults:
|
|
14
|
+
* YT_BRIEFING_LLM_BASE_URL required (e.g. https://openrouter.ai/api/v1)
|
|
15
15
|
* YT_BRIEFING_LLM_API_KEY required
|
|
16
|
-
* YT_BRIEFING_LLM_MODEL
|
|
16
|
+
* YT_BRIEFING_LLM_MODEL required (e.g. google/gemini-2.5-flash)
|
|
17
17
|
*/
|
|
18
|
-
|
|
19
|
-
const DEFAULT_MODEL = "google/gemini-2.5-flash";
|
|
18
|
+
import { requireEnv, REQUIRED_LLM } from "./env.js";
|
|
20
19
|
export function getModel() {
|
|
21
|
-
|
|
20
|
+
const model = process.env.YT_BRIEFING_LLM_MODEL;
|
|
21
|
+
if (!model)
|
|
22
|
+
throw new Error("Missing required environment variable: YT_BRIEFING_LLM_MODEL. Set it in your project root .env.");
|
|
23
|
+
return model;
|
|
22
24
|
}
|
|
23
25
|
export async function chat(prompt, opts = {}) {
|
|
24
|
-
|
|
26
|
+
requireEnv(REQUIRED_LLM);
|
|
27
|
+
const baseUrl = process.env.YT_BRIEFING_LLM_BASE_URL.replace(/\/+$/, "");
|
|
25
28
|
const apiKey = process.env.YT_BRIEFING_LLM_API_KEY;
|
|
26
|
-
if (!apiKey)
|
|
27
|
-
throw new Error("YT_BRIEFING_LLM_API_KEY not set (see .env.example)");
|
|
28
29
|
const model = opts.model || getModel();
|
|
29
30
|
const messages = [];
|
|
30
31
|
if (opts.system)
|
package/dist/lib/paths.js
CHANGED
|
@@ -33,6 +33,13 @@ export const BASE_DIR = process.env.YT_BRIEFING_BASE_DIR
|
|
|
33
33
|
: CONSUMED ? join(process.cwd(), '.yt-briefing') : PKG_ROOT;
|
|
34
34
|
process.env.YT_BRIEFING_BASE_DIR = BASE_DIR; // pin for children (their cwd differs)
|
|
35
35
|
export const ENV_PATH = join(BASE_DIR, '.env');
|
|
36
|
+
/**
|
|
37
|
+
* The project's root `.env` — the conventional, user-owned home for secrets (12-factor). When
|
|
38
|
+
* consumed, BASE_DIR is `<project>/.yt-briefing`, so the root is its parent; in a dev clone
|
|
39
|
+
* BASE_DIR === PKG_ROOT, so the root `.env` *is* ENV_PATH (one file, loaded once). Read-only:
|
|
40
|
+
* the loader never writes here, so it can't clobber the user's other variables. See lib/env.ts.
|
|
41
|
+
*/
|
|
42
|
+
export const ROOT_ENV_PATH = CONSUMED ? join(dirname(BASE_DIR), '.env') : ENV_PATH;
|
|
36
43
|
export const DATA_DIR = process.env.YT_BRIEFING_DATA_DIR
|
|
37
44
|
? resolve(process.env.YT_BRIEFING_DATA_DIR)
|
|
38
45
|
: join(BASE_DIR, 'data');
|
package/dist/lib/yt-api.js
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* cost ~7s to load from a cold FS cache on every fresh process. The Data API is a
|
|
6
6
|
* trivial REST surface, so direct fetch keeps cold-start near the runtime's own startup.
|
|
7
7
|
*
|
|
8
|
-
* Auth: YT_BRIEFING_YOUTUBE_API_KEY — the entrypoint loads it (
|
|
8
|
+
* Auth: YT_BRIEFING_YOUTUBE_API_KEY — the entrypoint loads it (loadEnv() in lib/env.ts) before
|
|
9
9
|
* calling; this module only reads process.env at call time.
|
|
10
10
|
*/
|
|
11
11
|
const API = 'https://www.googleapis.com/youtube/v3';
|
|
12
12
|
function apiKey() {
|
|
13
13
|
const k = process.env.YT_BRIEFING_YOUTUBE_API_KEY;
|
|
14
14
|
if (!k)
|
|
15
|
-
throw new Error('YT_BRIEFING_YOUTUBE_API_KEY
|
|
15
|
+
throw new Error('Missing required environment variable: YT_BRIEFING_YOUTUBE_API_KEY. Set it in your project root .env (see README → Providers → Where the keys live).');
|
|
16
16
|
return k;
|
|
17
17
|
}
|
|
18
18
|
async function get(path, params) {
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
* Empty result is valid (channel has no new content).
|
|
13
13
|
*/
|
|
14
14
|
import { readFileSync } from 'fs';
|
|
15
|
-
import
|
|
15
|
+
import { loadEnv } from "./lib/env.js";
|
|
16
16
|
import { parseState } from "./lib/yt-lib.js";
|
|
17
|
-
import { STATE_MD
|
|
17
|
+
import { STATE_MD } from "./lib/paths.js";
|
|
18
18
|
import { fetchChannelVideos } from "./lib/yt-api.js";
|
|
19
|
-
|
|
19
|
+
loadEnv();
|
|
20
20
|
const handle = process.argv[2];
|
|
21
21
|
if (!handle) {
|
|
22
22
|
console.error('Usage: yt-channel-pending @HANDLE (internal helper)');
|
|
@@ -17,10 +17,9 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Quota: ~1 unit per page (playlistItems.list) + 1 unit per 50 videos (videos.list).
|
|
19
19
|
*/
|
|
20
|
-
import
|
|
21
|
-
import { ENV_PATH } from "./lib/paths.js";
|
|
20
|
+
import { loadEnv } from "./lib/env.js";
|
|
22
21
|
import { fetchChannelVideos } from "./lib/yt-api.js";
|
|
23
|
-
|
|
22
|
+
loadEnv();
|
|
24
23
|
const args = process.argv.slice(2);
|
|
25
24
|
const handleOrId = args[0];
|
|
26
25
|
const sinceIdx = args.indexOf('--since');
|
package/dist/yt-rating.js
CHANGED
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
* bullets are de-duplicated; a state.md re-bump is a no-op.
|
|
18
18
|
*/
|
|
19
19
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
20
|
-
import
|
|
20
|
+
import { loadEnv } from "./lib/env.js";
|
|
21
21
|
import { parseChannels, appendSkipTitle, appendNote, bumpStatePointer } from "./lib/yt-lib.js";
|
|
22
|
-
import { CHANNELS_MD, STATE_MD, PENDING_FILE,
|
|
23
|
-
|
|
22
|
+
import { CHANNELS_MD, STATE_MD, PENDING_FILE, profilePath } from "./lib/paths.js";
|
|
23
|
+
loadEnv();
|
|
24
24
|
function getArg(args, name) {
|
|
25
25
|
const idx = args.indexOf(name);
|
|
26
26
|
return idx !== -1 && args[idx + 1] !== undefined ? args[idx + 1] : null;
|
package/dist/yt-search.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* the channel's videos by intent (no exact-keyword needed).
|
|
17
17
|
*
|
|
18
18
|
* Usage (the skill / CLI drives these; one JSON line per call):
|
|
19
|
-
* yt-search "<intent>" --channel <@handle|url> [--reset] [--
|
|
19
|
+
* yt-search "<intent>" --channel <@handle|url> [--reset] [--top N] [--scan N] [--since DATE] [--lang auto]
|
|
20
20
|
* yt-search --keep record the pending candidate, advance, yield next
|
|
21
21
|
* yt-search --skip drop the pending candidate, advance, yield next
|
|
22
22
|
* yt-search --compare synthesize a comparison from everything kept
|
|
@@ -34,18 +34,18 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
36
36
|
import { spawn } from 'node:child_process';
|
|
37
|
-
import
|
|
37
|
+
import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_LLM, REQUIRED_YOUTUBE } from "./lib/env.js";
|
|
38
38
|
import { chat, getModel } from "./lib/llm.js";
|
|
39
39
|
import { outputLang } from "./lib/config.js";
|
|
40
40
|
import { fetchChannelVideos } from "./lib/yt-api.js";
|
|
41
41
|
import { normalizeHandle } from "./lib/channels.js";
|
|
42
|
-
import { PKG_ROOT,
|
|
43
|
-
|
|
42
|
+
import { PKG_ROOT, CACHE_DIR, SEARCH_QUEUE_FILE, SEARCH_PENDING_FILE, SEARCH_KEPT_FILE, script, } from "./lib/paths.js";
|
|
43
|
+
loadEnv();
|
|
44
44
|
mkdirSync(CACHE_DIR, { recursive: true });
|
|
45
45
|
const RUNTIME = process.execPath;
|
|
46
46
|
const LANG = outputLang();
|
|
47
47
|
const argv = process.argv.slice(2);
|
|
48
|
-
const VALUE_FLAGS = new Set(['--channel', '--
|
|
48
|
+
const VALUE_FLAGS = new Set(['--channel', '--top', '--scan', '--since', '--lang']);
|
|
49
49
|
const has = (f) => argv.includes(f);
|
|
50
50
|
const flagVal = (f) => {
|
|
51
51
|
const i = argv.indexOf(f);
|
|
@@ -72,7 +72,7 @@ const KEEP = has('--keep');
|
|
|
72
72
|
const SKIP = has('--skip');
|
|
73
73
|
const COMPARE = has('--compare');
|
|
74
74
|
const CHANNEL = flagVal('--channel');
|
|
75
|
-
const
|
|
75
|
+
const TOP = Math.max(1, parseInt(flagVal('--top') || '10', 10));
|
|
76
76
|
const SCAN = Math.max(1, parseInt(flagVal('--scan') || '50', 10)); // recent uploads to consider when no --since
|
|
77
77
|
const SINCE = flagVal('--since');
|
|
78
78
|
const LANGTRACK = flagVal('--lang') || 'auto';
|
|
@@ -224,6 +224,14 @@ async function yieldNext(queue) {
|
|
|
224
224
|
emit({ status: 'done', kept: loadKept().length });
|
|
225
225
|
}
|
|
226
226
|
async function main() {
|
|
227
|
+
// LLM is needed on every path (rerank, summaries, compare). Fail fast naming any missing var
|
|
228
|
+
// (the throw is turned into a status:"error" by the .catch below). YouTube is checked separately,
|
|
229
|
+
// only when building a fresh queue (the compare/keep/skip paths work off cache, no API).
|
|
230
|
+
{
|
|
231
|
+
const missing = missingEnv(REQUIRED_LLM);
|
|
232
|
+
if (missing.length)
|
|
233
|
+
emit({ status: 'error', error: missingEnvMessage(missing) });
|
|
234
|
+
}
|
|
227
235
|
// --compare: synthesize from kept summaries.
|
|
228
236
|
if (COMPARE) {
|
|
229
237
|
const queue = loadQueue();
|
|
@@ -255,9 +263,11 @@ async function main() {
|
|
|
255
263
|
if (existing && !RESET && (!intentArg || intentArg === existing.intent) && (!CHANNEL || normalizeHandle(CHANNEL) === existing.channel)) {
|
|
256
264
|
await yieldNext(existing);
|
|
257
265
|
}
|
|
258
|
-
// Fresh search: needs an intent AND a channel.
|
|
259
|
-
|
|
260
|
-
|
|
266
|
+
// Fresh search: needs an intent AND a channel AND the YouTube key (to list the channel).
|
|
267
|
+
{
|
|
268
|
+
const missing = missingEnv(REQUIRED_YOUTUBE);
|
|
269
|
+
if (missing.length)
|
|
270
|
+
emit({ status: 'error', error: missingEnvMessage(missing) });
|
|
261
271
|
}
|
|
262
272
|
if (!intentArg)
|
|
263
273
|
emit({ status: 'error', error: 'Provide an intent: yt-search "<what to look for>" --channel <@handle|url>.' });
|
|
@@ -276,7 +286,7 @@ async function main() {
|
|
|
276
286
|
if (videos.length === 0)
|
|
277
287
|
emit({ status: 'no_results' });
|
|
278
288
|
const pool = videos.map(v => ({ videoId: v.videoId, title: v.title, channelTitle: handle, publishedAt: v.publishedAt, description: v.description }));
|
|
279
|
-
const ranked = (await rerank(intentArg, pool)).slice(0,
|
|
289
|
+
const ranked = (await rerank(intentArg, pool)).slice(0, TOP);
|
|
280
290
|
if (ranked.length === 0)
|
|
281
291
|
emit({ status: 'no_results' });
|
|
282
292
|
const queue = { built_at: new Date().toISOString(), intent: intentArg, channel: handle, candidates: ranked, cursor: 0 };
|
package/dist/yt-sweep.js
CHANGED
|
@@ -49,12 +49,12 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, renameSync, appendFileSync } from 'node:fs';
|
|
51
51
|
import { spawn } from 'node:child_process';
|
|
52
|
-
import
|
|
52
|
+
import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_LLM, REQUIRED_YOUTUBE } from "./lib/env.js";
|
|
53
53
|
import { parseChannels, parseState, bumpStatePointer } from "./lib/yt-lib.js";
|
|
54
54
|
import { chat, getModel } from "./lib/llm.js";
|
|
55
55
|
import { outputLang } from "./lib/config.js";
|
|
56
|
-
import { PKG_ROOT,
|
|
57
|
-
|
|
56
|
+
import { PKG_ROOT, CHANNELS_MD, STATE_MD, CACHE_DIR, QUEUE_FILE, REST_FILE, PENDING_FILE, PREFETCH_FILE, LOG_FILE, profilePath, script, } from "./lib/paths.js";
|
|
57
|
+
loadEnv();
|
|
58
58
|
mkdirSync(CACHE_DIR, { recursive: true });
|
|
59
59
|
// Re-invoke sibling scripts with the SAME runtime that launched us (bun/node/deno),
|
|
60
60
|
// never a hardcoded binary — the tool must run wherever the user installed it.
|
|
@@ -542,12 +542,14 @@ if (reset) {
|
|
|
542
542
|
clearRest();
|
|
543
543
|
clearPrefetch();
|
|
544
544
|
}
|
|
545
|
-
// Fatal config
|
|
546
|
-
// exited above). A missing
|
|
547
|
-
// error
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
|
|
545
|
+
// Fatal config preflight, foreground only (the detached --fill / --prefetch children already
|
|
546
|
+
// exited above). A missing key would otherwise surface as a misleading `status:"done"` ("no new
|
|
547
|
+
// videos") — the YouTube error is collapsed by the per-channel catch, and a missing LLM key is
|
|
548
|
+
// swallowed by the title-filter's keep-all fallback. Fail fast naming every missing var instead.
|
|
549
|
+
{
|
|
550
|
+
const missing = missingEnv([...REQUIRED_LLM, ...REQUIRED_YOUTUBE]);
|
|
551
|
+
if (missing.length)
|
|
552
|
+
emit({ status: 'error', error: missingEnvMessage(missing) });
|
|
551
553
|
}
|
|
552
554
|
const queue = loadQueue() ?? buildQueue();
|
|
553
555
|
await advance(queue);
|
package/dist/yt-transcript.js
CHANGED
|
@@ -25,11 +25,11 @@ import { spawnSync } from 'node:child_process';
|
|
|
25
25
|
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
26
26
|
import { join, dirname } from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
-
import
|
|
29
|
-
import { CACHE_DIR
|
|
28
|
+
import { loadEnv } from "./lib/env.js";
|
|
29
|
+
import { CACHE_DIR } from "./lib/paths.js";
|
|
30
30
|
// Load .env so YT_BRIEFING_PROXY is set when run standalone under Node (Bun auto-loads it; Node doesn't).
|
|
31
31
|
// When spawned by yt-sweep, the parent already loaded it and the child inherits the env.
|
|
32
|
-
|
|
32
|
+
loadEnv();
|
|
33
33
|
/** Resolve the yt-dlp binary: explicit env → project-local ./bin → PATH (Windows-aware). */
|
|
34
34
|
function resolveYtDlp() {
|
|
35
35
|
if (process.env.YT_BRIEFING_DLP_PATH)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yt-briefing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A self-learning YouTube briefing engine: it sweeps the channels you follow, filters noise in two stages (title, then transcript), summarizes the rest in your language, and adapts to your ratings — one video at a time.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|