yt-briefing 0.5.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 +13 -9
- package/README.md +25 -15
- package/dist/bootstrap.js +47 -21
- package/dist/cli.js +1 -1
- 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 +3 -32
- 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 +88 -85
- package/dist/yt-sweep.js +11 -9
- package/dist/yt-transcript.js +3 -3
- package/package.json +1 -1
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yt-search
|
|
3
|
-
description:
|
|
4
|
-
argument-hint: A descriptive intent
|
|
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 --top N (default 10), --scan N, --since YYYY-MM-DD.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## How it works
|
|
8
8
|
|
|
9
|
-
`src/yt-search.ts` is the whole engine:
|
|
9
|
+
`src/yt-search.ts` is the whole engine: list one channel's uploads (cheap — `playlistItems`, ~1 quota unit/page; NOT `search.list`) → re-rank them against your intent on metadata only (title/description, no transcript) → **lazy** one-candidate-at-a-time yield with a rich summary → record keep/skip → on demand synthesize a comparison from everything kept. **Channel-scoped on purpose** — you choose where to look; it does NOT search all of YouTube. Matching is descriptive: the LLM filters the channel's videos by intent. This skill is a thin loop — paste the summary, collect keep/skip, show the final comparison.
|
|
10
10
|
|
|
11
11
|
**Lazy on purpose:** one transcript per step, never a burst — a burst looks like scraping and gets the IP blocked (same reason `/yt` is lazy). Run the engine bare — stdout is a single JSON line, stderr empty; never redirect.
|
|
12
12
|
|
|
13
|
+
## Inputs
|
|
14
|
+
|
|
15
|
+
The user gives a **channel** (`@handle` or a channel URL) and an **intent** (what to look for). Pass the channel via `--channel` and the intent as the quoted positional. If the user names a channel but no clear intent (or vice versa), ask for the missing half before running.
|
|
16
|
+
|
|
13
17
|
## Language
|
|
14
18
|
|
|
15
19
|
Read `data/config.json` → `output_lang` once at the start. Phrase the question text and option descriptions in that language. The two button labels stay the short English words `Keep` / `Skip`. Summaries and the final comparison are already written in `output_lang` by the engine — paste them verbatim.
|
|
@@ -17,11 +21,11 @@ Read `data/config.json` → `output_lang` once at the start. Phrase the question
|
|
|
17
21
|
## Loop
|
|
18
22
|
|
|
19
23
|
```
|
|
20
|
-
out = JSON.parse(`bun run src/yt-search.ts "<intent
|
|
24
|
+
out = JSON.parse(`bun run src/yt-search.ts "<intent>" --channel <@handle|url> --reset`) // first call
|
|
21
25
|
while true:
|
|
22
26
|
out.status:
|
|
23
27
|
"error" → show out.error verbatim, stop
|
|
24
|
-
"no_results" → tell the user nothing
|
|
28
|
+
"no_results" → tell the user nothing in that channel matched, stop
|
|
25
29
|
"rate_limited" → transcript fetch blocked (datacenter IP) — tell the user, stop; recovery in README.md → Running on a VPS
|
|
26
30
|
"decision_needed" → steps A–C
|
|
27
31
|
"done" → step D
|
|
@@ -38,16 +42,16 @@ while true:
|
|
|
38
42
|
- Keep → `bun run src/yt-search.ts --keep`
|
|
39
43
|
- Skip → `bun run src/yt-search.ts --skip`
|
|
40
44
|
- stop/dismissed → `bun run src/yt-search.ts --compare` (skip straight to D)
|
|
41
|
-
- The script reads the pending candidate from cache; pass only `--keep` / `--skip` / `--compare
|
|
45
|
+
- The script reads the pending candidate from cache; pass only `--keep` / `--skip` / `--compare` (no channel/intent again). Its JSON becomes the next `out` — back to the top of the loop.
|
|
42
46
|
|
|
43
47
|
**On `done` (step D):**
|
|
44
48
|
|
|
45
|
-
- If `out.kept > 0` → run `out = JSON.parse(\`bun run src/yt-search.ts --compare\`)`; when it returns `status:"compare"`, paste `out.comparison` **verbatim** as your chat text (
|
|
49
|
+
- If `out.kept > 0` → run `out = JSON.parse(\`bun run src/yt-search.ts --compare\`)`; when it returns `status:"compare"`, paste `out.comparison` **verbatim** as your chat text (the artifact — a decision-grade comparison in `output_lang`). Stop.
|
|
46
50
|
- If `out.kept == 0` → tell the user nothing was kept, so there's nothing to compare. Stop.
|
|
47
51
|
|
|
48
52
|
## Rules
|
|
49
53
|
|
|
50
54
|
- **Verbatim:** paste `summary` and `comparison` exactly as returned; never paste a raw transcript.
|
|
51
55
|
- **Language:** question text + option descriptions follow `output_lang`; button labels stay `Keep` / `Skip`.
|
|
52
|
-
- **
|
|
53
|
-
- **Stateless triage:**
|
|
56
|
+
- **Scope:** one channel per search. Listing is cheap; the cost is the lazy transcript fetches, so let the user keep/skip rather than pulling everything. A bare resume (no `--reset`) continues the same ranked queue. `--scan N` (default 50) caps how many recent uploads are considered; `--since` widens by date.
|
|
57
|
+
- **Stateless triage:** independent of `/yt` (no channel profiles, no ratings written). For the recurring multi-channel briefing use `/yt`; for one known video use `/yt-transcribe`.
|
package/README.md
CHANGED
|
@@ -77,24 +77,17 @@ The skill is installed alongside `/yt` by `init` / `install-skill`. From the pla
|
|
|
77
77
|
npx yt-briefing transcribe <url-or-id> --lang auto # prints the transcript to stdout
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
##
|
|
80
|
+
## Search within a channel
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
Code`. The matching is **descriptive, not exact keywords**: an LLM turns your intent into search
|
|
85
|
-
queries, then ranks the results against what you actually meant (on titles/descriptions — no
|
|
86
|
-
transcripts yet). Then it hands you **one video at a time** with a rich summary; you **Keep** or
|
|
87
|
-
**Skip** each. 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
|
-
|
|
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.
|
|
91
88
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
> Each search calls the YouTube `search.list` endpoint, which costs **100 quota units per query**
|
|
97
|
-
> (plain reads cost 1; the daily free quota is 10,000). `--queries` (default 2) caps how many.
|
|
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`.
|
|
98
91
|
|
|
99
92
|
## Run it
|
|
100
93
|
|
|
@@ -119,6 +112,23 @@ YT_BRIEFING_LLM_MODEL=gemini-2.5-flash
|
|
|
119
112
|
Want something else? Change those three lines for OpenRouter (`https://openrouter.ai/api/v1`),
|
|
120
113
|
OpenAI (`https://api.openai.com/v1`), or a local Ollama (`http://localhost:11434/v1`).
|
|
121
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
|
+
|
|
122
132
|
## Why an API, not the agent's native model
|
|
123
133
|
|
|
124
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/cli.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* yt-briefing sweep [--reset] advance one step; prints a JSON status line
|
|
13
13
|
* yt-briefing rate --rating 0|1 [...] record a rating for the pending video
|
|
14
14
|
* yt-briefing transcribe <url|id> print a single video's transcript
|
|
15
|
-
* yt-briefing search "<intent>"
|
|
15
|
+
* yt-briefing search "<intent>" --channel <@handle|url> search within one channel → triage → compare
|
|
16
16
|
*/
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
18
|
import { script } from "./lib/paths.js";
|
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) {
|
|
@@ -58,7 +58,7 @@ async function listUploads(playlistId, since, maxCount) {
|
|
|
58
58
|
hitOld = true;
|
|
59
59
|
break;
|
|
60
60
|
}
|
|
61
|
-
videos.push({ videoId: item.snippet.resourceId.videoId, title: item.snippet.title, publishedAt });
|
|
61
|
+
videos.push({ videoId: item.snippet.resourceId.videoId, title: item.snippet.title, publishedAt, description: item.snippet.description ?? '' });
|
|
62
62
|
if (maxCount !== null && videos.length >= maxCount)
|
|
63
63
|
return videos;
|
|
64
64
|
}
|
|
@@ -110,35 +110,6 @@ async function enrichWithTypes(videos) {
|
|
|
110
110
|
}
|
|
111
111
|
return out;
|
|
112
112
|
}
|
|
113
|
-
/**
|
|
114
|
-
* Free-text video search via `search.list`. YouTube ranks by relevance, so the query can be
|
|
115
|
-
* descriptive — no exact keyword match required. NOTE: search.list costs 100 quota units per
|
|
116
|
-
* call (plain reads cost 1), so callers should keep the number of queries small.
|
|
117
|
-
*
|
|
118
|
-
* `since` (ISO date) maps to publishedAfter — useful to cut stale results on fast-moving topics.
|
|
119
|
-
*/
|
|
120
|
-
export async function searchVideos(query, opts = {}) {
|
|
121
|
-
const { maxResults = 10, since = null } = opts;
|
|
122
|
-
const params = {
|
|
123
|
-
part: 'snippet',
|
|
124
|
-
q: query,
|
|
125
|
-
type: 'video',
|
|
126
|
-
order: 'relevance',
|
|
127
|
-
maxResults: String(Math.min(Math.max(maxResults, 1), 50)),
|
|
128
|
-
};
|
|
129
|
-
if (since)
|
|
130
|
-
params.publishedAfter = new Date(since).toISOString();
|
|
131
|
-
const data = await get('search', params);
|
|
132
|
-
return (data.items || [])
|
|
133
|
-
.filter((it) => it.id?.videoId)
|
|
134
|
-
.map((it) => ({
|
|
135
|
-
videoId: it.id.videoId,
|
|
136
|
-
title: it.snippet?.title ?? '',
|
|
137
|
-
channelTitle: it.snippet?.channelTitle ?? '',
|
|
138
|
-
publishedAt: it.snippet?.publishedAt ?? '',
|
|
139
|
-
description: it.snippet?.description ?? '',
|
|
140
|
-
}));
|
|
141
|
-
}
|
|
142
113
|
/**
|
|
143
114
|
* List a channel's uploads (newest first). With `enrich` (default true) each video is
|
|
144
115
|
* typed (short/live/longform) and current/upcoming live broadcasts are filtered out.
|
|
@@ -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
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* yt-search.ts —
|
|
4
|
-
* sibling to the channel briefing (yt-sweep) and one-shot transcribe
|
|
3
|
+
* yt-search.ts — search WITHIN one channel by intent, then lazy triage → comparison. The third
|
|
4
|
+
* yt-briefing mode, sibling to the channel briefing (yt-sweep) and one-shot transcribe.
|
|
5
5
|
*
|
|
6
|
-
* You describe
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 5.
|
|
14
|
-
* 6. on demand synthesizes a comparison across everything you kept.
|
|
6
|
+
* You point it at a channel and describe what you're after ("which terminal does he recommend
|
|
7
|
+
* for AI coding"); the engine:
|
|
8
|
+
* 1. lists that channel's uploads (cheap — playlistItems, 1 quota unit/page; NOT search.list),
|
|
9
|
+
* 2. re-ranks them against your intent on metadata only — title/description, NO transcript yet,
|
|
10
|
+
* 3. yields ONE matching video at a time with a rich summary, lazily — never a burst of
|
|
11
|
+
* transcript fetches (a burst looks like scraping and gets the IP blocked),
|
|
12
|
+
* 4. records your keep/skip decision; kept summaries accumulate in a cache,
|
|
13
|
+
* 5. on demand synthesizes a comparison across everything you kept.
|
|
15
14
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Channel-scoped on purpose: you choose where to look. Matching is descriptive — the LLM filters
|
|
16
|
+
* the channel's videos by intent (no exact-keyword needed).
|
|
18
17
|
*
|
|
19
18
|
* Usage (the skill / CLI drives these; one JSON line per call):
|
|
20
|
-
* yt-search "<intent>" [--reset] [--
|
|
19
|
+
* yt-search "<intent>" --channel <@handle|url> [--reset] [--top N] [--scan N] [--since DATE] [--lang auto]
|
|
21
20
|
* yt-search --keep record the pending candidate, advance, yield next
|
|
22
21
|
* yt-search --skip drop the pending candidate, advance, yield next
|
|
23
22
|
* yt-search --compare synthesize a comparison from everything kept
|
|
@@ -26,39 +25,55 @@
|
|
|
26
25
|
* {"status":"decision_needed","summary":"<md>","pending":{videoId,title,channelTitle,publishedAt,position,total}}
|
|
27
26
|
* {"status":"done","kept":N} queue exhausted — caller runs --compare if kept>0
|
|
28
27
|
* {"status":"compare","comparison":"<md>"}
|
|
29
|
-
* {"status":"no_results"}
|
|
28
|
+
* {"status":"no_results"} the channel has no videos matching the intent
|
|
30
29
|
* {"status":"rate_limited"} transcript fetch blocked (datacenter IP — see docs/warp-proxy.md)
|
|
31
|
-
* {"status":"error","error":"<msg>"} setup/config problem (missing key,
|
|
30
|
+
* {"status":"error","error":"<msg>"} setup/config problem (missing key, missing channel, …)
|
|
32
31
|
*
|
|
33
32
|
* Cache (all throwaway, under DATA_DIR/.cache): search-queue.json (ranked candidates + cursor),
|
|
34
33
|
* search-pending.json (current candidate), search-kept.json (kept summaries = compare corpus).
|
|
35
34
|
*/
|
|
36
35
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
37
36
|
import { spawn } from 'node:child_process';
|
|
38
|
-
import
|
|
37
|
+
import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_LLM, REQUIRED_YOUTUBE } from "./lib/env.js";
|
|
39
38
|
import { chat, getModel } from "./lib/llm.js";
|
|
40
39
|
import { outputLang } from "./lib/config.js";
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
|
|
40
|
+
import { fetchChannelVideos } from "./lib/yt-api.js";
|
|
41
|
+
import { normalizeHandle } from "./lib/channels.js";
|
|
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', '--top', '--scan', '--since', '--lang']);
|
|
48
49
|
const has = (f) => argv.includes(f);
|
|
49
50
|
const flagVal = (f) => {
|
|
50
51
|
const i = argv.indexOf(f);
|
|
51
|
-
return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
|
|
52
|
+
return i !== -1 && argv[i + 1] != null ? argv[i + 1] : null;
|
|
52
53
|
};
|
|
53
|
-
// First
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// First token that is neither a flag nor a flag's value is the intent.
|
|
55
|
+
function positionalIntent() {
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i];
|
|
58
|
+
if (a.startsWith('--')) {
|
|
59
|
+
if (VALUE_FLAGS.has(a))
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (i > 0 && VALUE_FLAGS.has(argv[i - 1]))
|
|
64
|
+
continue;
|
|
65
|
+
return a;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const intentArg = positionalIntent();
|
|
56
70
|
const RESET = has('--reset');
|
|
57
71
|
const KEEP = has('--keep');
|
|
58
72
|
const SKIP = has('--skip');
|
|
59
73
|
const COMPARE = has('--compare');
|
|
60
|
-
const
|
|
61
|
-
const
|
|
74
|
+
const CHANNEL = flagVal('--channel');
|
|
75
|
+
const TOP = Math.max(1, parseInt(flagVal('--top') || '10', 10));
|
|
76
|
+
const SCAN = Math.max(1, parseInt(flagVal('--scan') || '50', 10)); // recent uploads to consider when no --since
|
|
62
77
|
const SINCE = flagVal('--since');
|
|
63
78
|
const LANGTRACK = flagVal('--lang') || 'auto';
|
|
64
79
|
function emit(obj) {
|
|
@@ -89,7 +104,7 @@ function fetchTranscript(videoId) {
|
|
|
89
104
|
p.on('error', reject);
|
|
90
105
|
});
|
|
91
106
|
}
|
|
92
|
-
/**
|
|
107
|
+
/** Slice the outermost JSON array from an LLM reply (tolerates stray prose / fences). */
|
|
93
108
|
function parseJsonArray(out) {
|
|
94
109
|
const start = out.indexOf('['), end = out.lastIndexOf(']');
|
|
95
110
|
if (start === -1 || end === -1 || end < start)
|
|
@@ -101,44 +116,27 @@ function parseJsonArray(out) {
|
|
|
101
116
|
return null;
|
|
102
117
|
}
|
|
103
118
|
}
|
|
104
|
-
|
|
105
|
-
async function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
Intent: "${intent}"
|
|
109
|
-
|
|
110
|
-
Output ONLY a raw JSON array of strings, no fences, no commentary. Example: ["query one","query two"]`;
|
|
111
|
-
try {
|
|
112
|
-
const out = await chat(prompt, { system: 'You output ONLY a raw JSON array of search-query strings.', temperature: 0.4 });
|
|
113
|
-
const arr = parseJsonArray(out);
|
|
114
|
-
const qs = (arr ?? []).filter((s) => typeof s === 'string' && s.trim().length > 0).slice(0, QUERIES);
|
|
115
|
-
return qs.length ? qs : [intent];
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
return [intent]; // expansion is best-effort; fall back to the raw intent
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
// ---------- stage 3: re-rank candidates against intent (metadata only) ----------
|
|
122
|
-
async function rerank(intent, hits) {
|
|
123
|
-
if (hits.length === 0)
|
|
119
|
+
/** Re-rank a channel's videos against the intent (metadata only — no transcript). */
|
|
120
|
+
async function rerank(intent, items) {
|
|
121
|
+
if (items.length === 0)
|
|
124
122
|
return [];
|
|
125
|
-
const compact =
|
|
126
|
-
const prompt = `
|
|
123
|
+
const compact = items.map(h => ({ id: h.videoId, title: h.title, published: h.publishedAt, desc: (h.description || '').slice(0, 280) }));
|
|
124
|
+
const prompt = `From this YouTube channel's videos, pick the ones that serve the user's intent and rank them. Judge on title + description only (no transcripts). Drop anything off-topic.
|
|
127
125
|
|
|
128
126
|
Intent: "${intent}"
|
|
129
127
|
|
|
130
|
-
|
|
128
|
+
Videos:
|
|
131
129
|
${JSON.stringify(compact)}
|
|
132
130
|
|
|
133
131
|
Output ONLY a raw JSON array, best first, no fences:
|
|
134
132
|
[{"id":"VIDEO_ID","keep":true,"score":0-100,"reason":"max 12 words"},...]
|
|
135
|
-
Set keep=false for anything not
|
|
133
|
+
Set keep=false for anything not relevant to the intent.`;
|
|
136
134
|
try {
|
|
137
135
|
const out = await chat(prompt, { system: 'You output ONLY a raw JSON array as instructed.', temperature: 0 });
|
|
138
136
|
const arr = parseJsonArray(out);
|
|
139
137
|
if (!arr)
|
|
140
|
-
return
|
|
141
|
-
const byId = new Map(
|
|
138
|
+
return items; // fall back: keep all, original (newest-first) order
|
|
139
|
+
const byId = new Map(items.map(h => [h.videoId, h]));
|
|
142
140
|
const ranked = [];
|
|
143
141
|
for (const r of arr) {
|
|
144
142
|
if (!r || r.keep === false)
|
|
@@ -147,13 +145,13 @@ Set keep=false for anything not worth the user's time.`;
|
|
|
147
145
|
if (h)
|
|
148
146
|
ranked.push({ ...h, score: typeof r.score === 'number' ? r.score : undefined, reason: r.reason });
|
|
149
147
|
}
|
|
150
|
-
return ranked.length ? ranked :
|
|
148
|
+
return ranked.length ? ranked : items;
|
|
151
149
|
}
|
|
152
150
|
catch {
|
|
153
|
-
return
|
|
151
|
+
return items;
|
|
154
152
|
}
|
|
155
153
|
}
|
|
156
|
-
|
|
154
|
+
/** Rich, standalone summary for one candidate — the triage artifact + compare input. */
|
|
157
155
|
async function megaSummary(c, transcript, intent) {
|
|
158
156
|
const prompt = `Summarize this YouTube video in ${LANG} for a user researching: "${intent}". Make it a RICH, standalone summary they can decide on and that will later feed a cross-video comparison — OR return 'OFFTOPIC: <reason>' if the transcript clearly doesn't serve the intent.
|
|
159
157
|
|
|
@@ -181,7 +179,7 @@ Output ONLY the summary OR 'OFFTOPIC: <reason>'. No preamble.`;
|
|
|
181
179
|
model: getModel(),
|
|
182
180
|
});
|
|
183
181
|
}
|
|
184
|
-
|
|
182
|
+
/** Synthesize a comparison across everything kept. */
|
|
185
183
|
async function synthesizeComparison(intent, kept) {
|
|
186
184
|
const corpus = kept.map((k, i) => `--- VIDEO ${i + 1}: ${k.channelTitle} — "${k.title}" (${k.publishedAt})\nhttps://youtube.com/watch?v=${k.videoId}\n${k.summary}`).join('\n\n');
|
|
187
185
|
const prompt = `The user researched "${intent}" and kept ${kept.length} YouTube video summaries below. Synthesize a single comparison in ${LANG} that actually helps them decide.
|
|
@@ -197,7 +195,7 @@ Write:
|
|
|
197
195
|
Language: natural ${LANG}; foreign words only for proper nouns or established technical terms. Cite videos as [1], [2]… matching the order above. Output only the comparison.`;
|
|
198
196
|
return chat(prompt, { system: `You synthesize a decision-grade comparison in ${LANG}. No preamble.`, model: getModel() });
|
|
199
197
|
}
|
|
200
|
-
|
|
198
|
+
/** Lazy yield: advance to the next candidate that has a transcript, summarize, emit. */
|
|
201
199
|
async function yieldNext(queue) {
|
|
202
200
|
while (queue.cursor < queue.candidates.length) {
|
|
203
201
|
const c = queue.candidates[queue.cursor];
|
|
@@ -225,10 +223,14 @@ async function yieldNext(queue) {
|
|
|
225
223
|
}
|
|
226
224
|
emit({ status: 'done', kept: loadKept().length });
|
|
227
225
|
}
|
|
228
|
-
// ---------- main ----------
|
|
229
226
|
async function main() {
|
|
230
|
-
|
|
231
|
-
|
|
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) });
|
|
232
234
|
}
|
|
233
235
|
// --compare: synthesize from kept summaries.
|
|
234
236
|
if (COMPARE) {
|
|
@@ -243,7 +245,7 @@ async function main() {
|
|
|
243
245
|
if (KEEP || SKIP) {
|
|
244
246
|
const queue = loadQueue();
|
|
245
247
|
if (!queue)
|
|
246
|
-
emit({ status: 'error', error: 'No active search. Start one: yt-search "<intent>"
|
|
248
|
+
emit({ status: 'error', error: 'No active search. Start one: yt-search "<intent>" --channel <@handle>.' });
|
|
247
249
|
if (KEEP) {
|
|
248
250
|
const pending = readJSON(SEARCH_PENDING_FILE, null);
|
|
249
251
|
if (pending) {
|
|
@@ -256,37 +258,38 @@ async function main() {
|
|
|
256
258
|
writeJSON(SEARCH_QUEUE_FILE, queue);
|
|
257
259
|
await yieldNext(queue);
|
|
258
260
|
}
|
|
259
|
-
// Resume an in-progress search (bare call, same intent) without rebuilding.
|
|
261
|
+
// Resume an in-progress search (bare call, same intent + channel) without rebuilding.
|
|
260
262
|
const existing = loadQueue();
|
|
261
|
-
if (existing && !RESET && (!intentArg || intentArg === existing.intent)) {
|
|
263
|
+
if (existing && !RESET && (!intentArg || intentArg === existing.intent) && (!CHANNEL || normalizeHandle(CHANNEL) === existing.channel)) {
|
|
262
264
|
await yieldNext(existing);
|
|
263
265
|
}
|
|
264
|
-
// Fresh search
|
|
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) });
|
|
271
|
+
}
|
|
265
272
|
if (!intentArg)
|
|
266
|
-
emit({ status: 'error', error: 'Provide an intent: yt-search "<what to
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
for (const h of batch)
|
|
279
|
-
if (!seen.has(h.videoId)) {
|
|
280
|
-
seen.add(h.videoId);
|
|
281
|
-
hits.push(h);
|
|
282
|
-
}
|
|
273
|
+
emit({ status: 'error', error: 'Provide an intent: yt-search "<what to look for>" --channel <@handle|url>.' });
|
|
274
|
+
if (!CHANNEL)
|
|
275
|
+
emit({ status: 'error', error: 'Provide a channel: --channel <@handle|url>. /yt-search searches within one channel, not all of YouTube.' });
|
|
276
|
+
const handle = normalizeHandle(CHANNEL);
|
|
277
|
+
if (!handle)
|
|
278
|
+
emit({ status: 'error', error: `Could not read a channel handle from "${CHANNEL}" — use @name or the channel URL.` });
|
|
279
|
+
let videos;
|
|
280
|
+
try {
|
|
281
|
+
videos = await fetchChannelVideos(handle, { since: SINCE, limit: SINCE ? null : SCAN, enrich: false });
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
emit({ status: 'error', error: e.message });
|
|
283
285
|
}
|
|
284
|
-
if (
|
|
286
|
+
if (videos.length === 0)
|
|
285
287
|
emit({ status: 'no_results' });
|
|
286
|
-
const
|
|
288
|
+
const pool = videos.map(v => ({ videoId: v.videoId, title: v.title, channelTitle: handle, publishedAt: v.publishedAt, description: v.description }));
|
|
289
|
+
const ranked = (await rerank(intentArg, pool)).slice(0, TOP);
|
|
287
290
|
if (ranked.length === 0)
|
|
288
291
|
emit({ status: 'no_results' });
|
|
289
|
-
const queue = { built_at: new Date().toISOString(), intent: intentArg, candidates: ranked, cursor: 0 };
|
|
292
|
+
const queue = { built_at: new Date().toISOString(), intent: intentArg, channel: handle, candidates: ranked, cursor: 0 };
|
|
290
293
|
writeJSON(SEARCH_QUEUE_FILE, queue);
|
|
291
294
|
writeJSON(SEARCH_KEPT_FILE, []); // fresh search → fresh compare corpus
|
|
292
295
|
await yieldNext(queue);
|
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": {
|