yt-briefing 0.1.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/SKILL.md +54 -0
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/data.example/.gitattributes +7 -0
- package/data.example/README.md +19 -0
- package/data.example/channels/_template.md +45 -0
- package/dist/bootstrap.js +243 -0
- package/dist/cli.js +29 -0
- package/dist/install-skill.js +51 -0
- package/dist/lib/config.js +23 -0
- package/dist/lib/llm.js +57 -0
- package/dist/lib/paths.js +56 -0
- package/dist/lib/prompt.js +39 -0
- package/dist/lib/skill-install.js +66 -0
- package/dist/lib/yt-api.js +122 -0
- package/dist/lib/yt-lib.js +157 -0
- package/dist/yt-channel-pending.js +85 -0
- package/dist/yt-channel-videos.js +43 -0
- package/dist/yt-rating.js +110 -0
- package/dist/yt-sweep.js +546 -0
- package/dist/yt-transcript.js +156 -0
- package/docs/sync-across-machines.md +127 -0
- package/docs/warp-proxy.md +81 -0
- package/package.json +56 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: yt
|
|
3
|
+
description: Briefing from the YouTube channels you follow — the engine sweeps each channel, filters videos in two stages (title, then transcript+content), and lazily yields one video to rate per call. This skill is a thin loop that shows the summary and collects the rating (via AskUserQuestion), which writes durable signal straight into the channel profile. Summaries and the rating question use the language chosen at onboarding.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## How it works
|
|
7
|
+
|
|
8
|
+
`src/yt-sweep.ts` is **the whole engine**: channel sweep, filters (title filter, then content filter on the transcript via the LLM API), and skip handling — all inside, lazy. The only state it does **not** write is the rating itself — that's `yt-rating.ts` in step D. This skill **does not run filters, read profiles, or format summaries** — it runs the script, pastes the summary, collects the rating.
|
|
9
|
+
|
|
10
|
+
`data/state.md` is the only persistent cursor. Session state — queue, pending, prefetch, background fill — lives in `data/.cache/` (rebuilt each run, never important to keep). Internals — lazy queue build, filters, summary format, LLM model, transcript fetching, prefetch, proxy — are in `README.md`, not here. No manual pre-flight: the engine self-invalidates a stale queue (new day or `--reset`).
|
|
11
|
+
|
|
12
|
+
**Run the engine bare — no redirects.** Its stdout is a pure JSON line and stderr is empty, so `JSON.parse` of the raw output just works; **never** redirect stderr to `/tmp` or any OS temp dir. For timing diagnostics ("why is the sweep slow") run it once with `YT_DEBUG=1` — it appends per-stage timings to the gitignored `data/.cache/sweep.log`.
|
|
13
|
+
|
|
14
|
+
For fast first paint, the engine expands channels in parallel waves until it has the first ratable video, while a detached background process expands the rest in parallel. And while the user rates a video, it warms the **next** video's summary in another background process, so the following step usually emits instantly. All fully internal — the loop below is unchanged.
|
|
15
|
+
|
|
16
|
+
## Rating language
|
|
17
|
+
|
|
18
|
+
Read `data/config.json` → `output_lang` once at the start of the loop. Phrase the rating **question text** and **option descriptions** in that language. The two button **labels** stay the short English words `OK` / `Weak` (deliberately universal). Summaries are already written in `output_lang` by the engine — paste them verbatim.
|
|
19
|
+
|
|
20
|
+
## Rating loop
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
out = JSON.parse(`bun run src/yt-sweep.ts --reset`) // Bash — first call: --reset rebuilds the queue fresh
|
|
24
|
+
while true:
|
|
25
|
+
out.status:
|
|
26
|
+
"done" → sweep finished — tell the user, stop
|
|
27
|
+
"rate_limited" → transcript fetch blocked (usually a blocked egress IP) — tell the user, stop; recovery in README.md → Proxy
|
|
28
|
+
"rating_needed" → steps A–E
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**On `rating_needed` — literally:**
|
|
32
|
+
|
|
33
|
+
- **A.** Take `out.summary` (markdown) and `out.pending` (metadata).
|
|
34
|
+
- **B.** In the same turn, as **your chat text** (NOT command output — the UI does not show it), paste `summary` **verbatim**: no paraphrase, no shortening, no comment, no "see above". The user must see it before the popup. _(If the user says "I don't see the summary" — you skipped B.)_
|
|
35
|
+
- **C.** In the same message call `AskUserQuestion` — **1 call, 1 question** (everything in one step), phrased in `output_lang`:
|
|
36
|
+
- The question (e.g. "Rating?") with two options whose descriptions explain: **OK** = neutral (no effect on the filter), **Weak** = worthless (teach the filter to skip such titles). The digits never appear in the popup — internally map to `--rating`: **OK → 1, Weak → 0**. There is **no positive rating** — keeping the channel is the implicit positive; you only down-rate noise (`0`) or steer with a comment.
|
|
37
|
+
- **Other** is the comment / stop channel (no second question): the user types free text. If it equals `stop` (case-insensitive, trimmed) — or the popup is dismissed (✕) — **end the loop**. Otherwise it is a **comment**: **distill** the user's raw text into a clean, generalizable rule, and infer the rating — clearly negative → `0`, otherwise → `1`.
|
|
38
|
+
- **D.** Act on the answer:
|
|
39
|
+
- `stop` / dismissed → **end the loop** (state already on disk).
|
|
40
|
+
- A rating option → `bun run src/yt-rating.ts --rating <1|0>`.
|
|
41
|
+
- A comment (Other, not `stop`) → `bun run src/yt-rating.ts --rating <inferred 1|0> --comment "<distilled rule>"`.
|
|
42
|
+
|
|
43
|
+
In every non-stop case **pass only the rating (+ comment)**; the script reads channel/id/title/type from `data/.cache/pending.json`. The write is **immediate and durable — no consolidation**: `rating=0` appends to `## Skip titles` (title filter learns to skip such titles from the next run) and a comment appends the rule to `## Notes` (seen by both filters). A neutral `1` writes nothing — it only bumps the state cursor.
|
|
44
|
+
- **E.** Re-run `bun run src/yt-sweep.ts` **bare** (no `--reset` — resumes the same queue; only the loop's first call uses `--reset`). Its JSON becomes the next iteration's `out` — back to the top of the loop.
|
|
45
|
+
|
|
46
|
+
## No consolidation
|
|
47
|
+
|
|
48
|
+
There is no post-loop step. Each rating is **durable immediately**: `yt-rating.ts` writes `rating=0` straight to `## Skip titles` and a comment straight to `## Notes` (FIFO-capped, de-duplicated). `rating=1` only bumps the cursor. The title filter reads those sections live, so the signal takes effect on the very next sweep — nothing to flush, batch, or trigger.
|
|
49
|
+
|
|
50
|
+
## Rules
|
|
51
|
+
|
|
52
|
+
- **Language:** question text, option descriptions, and loop messages follow `output_lang`; the two rating **labels** are `OK` / `Weak`. Summaries are written in `output_lang` by the content-filter prompt in the engine — not here.
|
|
53
|
+
- **Transcripts:** never paste a raw transcript into chat; the summary is the artifact.
|
|
54
|
+
- **Resuming:** the skill can be re-run any time — `data/state.md` is the source of truth, so a re-run always skips what's already rated. A queue from a previous day self-invalidates; the loop's first call passes `--reset` to also force a **same-day** rebuild, catching videos published since that morning's queue.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michał Ryzio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# yt-briefing
|
|
2
|
+
|
|
3
|
+
Save hours on YouTube. yt-briefing watches the channels you follow so you don't have to. It
|
|
4
|
+
turns each new video into a short summary in your own language that keeps what matters and
|
|
5
|
+
skips the filler. Reading it takes a fraction of the time the video would, so you stay on top
|
|
6
|
+
of everything and only watch what's actually worth it.
|
|
7
|
+
|
|
8
|
+
It also gets better the more you use it. You give each summary a quick rating, worth my time
|
|
9
|
+
or not, and from that it learns what to keep showing you and what to drop. Over time the queue
|
|
10
|
+
becomes yours: less noise, more of what you care about.
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
You'll need Node 18+ or Bun, a YouTube Data API v3 key, an LLM key (a
|
|
15
|
+
[free Gemini key](https://aistudio.google.com/apikey) works, see [Providers](#providers)), and
|
|
16
|
+
a tool that runs skills: [Claude Code](https://claude.com/claude-code),
|
|
17
|
+
[Cursor](https://cursor.com), or anything else that loads `SKILL.md`.
|
|
18
|
+
|
|
19
|
+
1. Install yt-dlp (it pulls the subtitles):
|
|
20
|
+
|
|
21
|
+
| OS | Command |
|
|
22
|
+
|----|---------|
|
|
23
|
+
| macOS | `brew install yt-dlp` |
|
|
24
|
+
| Windows | `winget install yt-dlp` |
|
|
25
|
+
| Linux / any Python | `pipx install yt-dlp` |
|
|
26
|
+
|
|
27
|
+
Keep it current with `yt-dlp -U`. YouTube changes often.
|
|
28
|
+
|
|
29
|
+
2. Add the package with any package manager:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm i yt-briefing
|
|
33
|
+
pnpm add yt-briefing
|
|
34
|
+
yarn add yt-briefing
|
|
35
|
+
bun add yt-briefing
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
3. Onboard:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx yt-briefing init # or: bunx yt-briefing init
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`init` asks for your language, your keys, the channels to follow, and which tool runs `/yt`.
|
|
45
|
+
|
|
46
|
+
## Run it
|
|
47
|
+
|
|
48
|
+
Open your project in Claude Code or Cursor and run `/yt`. If it's not listed, start a fresh
|
|
49
|
+
session. To install the skill again for another tool or project, run
|
|
50
|
+
`npx yt-briefing install-skill`.
|
|
51
|
+
|
|
52
|
+
## Providers
|
|
53
|
+
|
|
54
|
+
Any OpenAI-compatible endpoint works. Gemini 2.5 Flash is the easy default. It's fast, cheap,
|
|
55
|
+
and free to start at [Google AI Studio](https://aistudio.google.com/apikey):
|
|
56
|
+
|
|
57
|
+
```ini
|
|
58
|
+
LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
|
|
59
|
+
LLM_API_KEY=<gemini-key>
|
|
60
|
+
LLM_MODEL=gemini-2.5-flash
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
> On the free tier Gemini sometimes returns a "model is overloaded / high demand" error. Retry,
|
|
64
|
+
> or switch to a paid key (enable billing, same model) to avoid it.
|
|
65
|
+
|
|
66
|
+
Want something else? Change those three lines for OpenRouter (`https://openrouter.ai/api/v1`),
|
|
67
|
+
OpenAI (`https://api.openai.com/v1`), or a local Ollama (`http://localhost:11434/v1`).
|
|
68
|
+
|
|
69
|
+
## Why an API, not the agent's native model
|
|
70
|
+
|
|
71
|
+
The filtering and the summaries go through a plain OpenAI-compatible API call from the engine,
|
|
72
|
+
not through the coding agent's own model. Two reasons.
|
|
73
|
+
|
|
74
|
+
Speed. The engine works ahead in the background. It expands channels in parallel and starts
|
|
75
|
+
summarizing the next video while you rate the current one, so the following step is usually
|
|
76
|
+
ready with no wait. An agent's turn-by-turn loop cannot prefetch like that, and every step pays
|
|
77
|
+
its own cold start, which adds up across a whole queue.
|
|
78
|
+
|
|
79
|
+
Compatibility. A standard API plus a small skill means one engine runs everywhere: Claude Code,
|
|
80
|
+
Cursor, any other tool that loads a skill, or the plain CLI. A tool-native approach would tie it
|
|
81
|
+
to that one tool and one model.
|
|
82
|
+
|
|
83
|
+
## Why one transcript at a time
|
|
84
|
+
|
|
85
|
+
yt-briefing pulls transcripts lazily. It fetches the one you are about to read, warms the next
|
|
86
|
+
one in the background while you rate, and stops there. It never grabs the whole queue up front.
|
|
87
|
+
|
|
88
|
+
That pacing is deliberate. Pulling many transcripts in a quick burst looks like scraping to
|
|
89
|
+
YouTube and gets your IP rate-limited or blocked, which is easy to hit on a server. Fetching
|
|
90
|
+
them at the speed you actually work through the queue keeps you under the radar and the queue
|
|
91
|
+
flowing.
|
|
92
|
+
|
|
93
|
+
## Sync across machines
|
|
94
|
+
|
|
95
|
+
Your state is plain files in `.yt-briefing/data/`. Version that folder (or point `YT_DATA_DIR`
|
|
96
|
+
at a separate private repo) and commit after each rating. Recipe:
|
|
97
|
+
[docs/sync-across-machines.md](./docs/sync-across-machines.md).
|
|
98
|
+
|
|
99
|
+
## Running on a VPS
|
|
100
|
+
|
|
101
|
+
YouTube blocks datacenter IPs, so transcript fetches fail on most servers. Route them through a
|
|
102
|
+
free Cloudflare WARP proxy. See [docs/warp-proxy.md](./docs/warp-proxy.md).
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT, see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Copy this into your *data* repo (the folder YT_DATA_DIR points at) if you sync state
|
|
2
|
+
# across machines. See docs/sync-across-machines.md.
|
|
3
|
+
#
|
|
4
|
+
# Channel profiles are append-only logs (ratings → ## Skip titles / ## Notes). Union merge
|
|
5
|
+
# keeps BOTH machines' additions instead of conflicting. state.md is intentionally NOT
|
|
6
|
+
# union-merged (it's a table — union would garble rows; a real collision should surface).
|
|
7
|
+
channels/*.md merge=union
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# data.example — what onboarding generates
|
|
2
|
+
|
|
3
|
+
`bun run init` creates a real `data/` directory next to this one. This folder is a
|
|
4
|
+
reference for the shape of that data, and a home for the channel-profile template.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
data/
|
|
8
|
+
├── config.json { "output_lang": "English" } ← your summary/rating language
|
|
9
|
+
├── channels.md the channels you follow (a flat list)
|
|
10
|
+
├── state.md per-channel per-type cursor (last video seen of each type)
|
|
11
|
+
├── channels/
|
|
12
|
+
│ ├── _template.md the profile section template (see this folder)
|
|
13
|
+
│ └── <slug>.md one profile per channel — its policy, skip-titles, notes
|
|
14
|
+
└── .cache/ throwaway session state (queue/pending/prefetch); safe to delete
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Everything is plain Markdown / JSON. After onboarding you can edit any of it by hand —
|
|
18
|
+
add a channel, tighten a `## Channel policy`, or curate `## Notes`. The two filters pick
|
|
19
|
+
up the changes on the next sweep. See `channels/_template.md` for the section conventions.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: yt-channel-profile-template
|
|
3
|
+
description: Section template for a channel profile. A section exists only when it has real content — never placeholders. An empty profile is just frontmatter + heading.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Channel profile — section template
|
|
7
|
+
|
|
8
|
+
A channel profile is the durable memory of what's worth watching on a channel. It feeds the engine's two filters (`src/yt-sweep.ts`):
|
|
9
|
+
|
|
10
|
+
- **Title filter** (keep/skip *before* fetching the transcript) reads: `## Skip titles` (+ rules in `## Notes`). Keep-by-default — it only learns *what to drop* (there is no positive list).
|
|
11
|
+
- **Content filter** (writing the summary) gets the **whole profile**; it leans on `## Channel policy`, `## Summary format`, `## Cut sections`, `## Episode types`, `## Notes`.
|
|
12
|
+
|
|
13
|
+
Frontmatter (always):
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
---
|
|
17
|
+
type: yt-channel-profile
|
|
18
|
+
name: <slug>-profile
|
|
19
|
+
channel: @Handle
|
|
20
|
+
channel_url: https://www.youtube.com/@Handle
|
|
21
|
+
updated: YYYY-MM-DD
|
|
22
|
+
sessions_observed: <int>
|
|
23
|
+
---
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Optional sections. **A section exists ⟺ it has content.** Don't create empty placeholders.
|
|
27
|
+
|
|
28
|
+
| Section | Function | Source |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `## Channel policy` | Per-channel base policy — short, conceptual: *what to pay attention to on this channel.* | By hand (optional) |
|
|
31
|
+
| `## Summary format` | Deviation from the default (numbered thematic sections). Use it when a channel needs a different style (headline-only, "what it was about", per-segment digest). | By hand |
|
|
32
|
+
| `## Episode types` | Taxonomy of formats (solo / interview / report / multi-segment). Steers summary style per type. | By hand |
|
|
33
|
+
| `## Skip titles` | **Negative** few-shots for the title filter (keep-by-default; learn only what to drop). FIFO cap 10. Format: `- "<title>" — <type>`. | `yt-rating.ts` on `rating=0` |
|
|
34
|
+
| `## Cut sections` | Typical intro/outro/sponsor segments to drop before summarizing. Cap 5. | By hand |
|
|
35
|
+
| `## Notes` | Durable rules: hard flags, edge cases, links (e.g. a sibling channel's mirror profile). **The permanent home for rules distilled from comments.** | By hand + `yt-rating.ts` on a comment |
|
|
36
|
+
|
|
37
|
+
## Rating scale
|
|
38
|
+
|
|
39
|
+
Three signals (UI: one step, two buttons + `Other` for a comment). **The write is immediate and durable — no buffer, no consolidation:**
|
|
40
|
+
|
|
41
|
+
- **`1` — neutral.** Watched, no signal. **Zero effect** — only bumps the cursor in `state.md` (video checked off); writes nothing to the profile.
|
|
42
|
+
- **`0` — worthless.** **Immediately** → `## Skip titles` (cap 10); the title filter learns to skip such titles from the very next sweep.
|
|
43
|
+
- **comment** — a generalizable rule. **Immediately** → `## Notes` (seen by both filters). The agent distills the user's raw comment into a clean rule before writing.
|
|
44
|
+
|
|
45
|
+
There is no positive rating — keeping the channel subscribed is the implicit "plus".
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bootstrap.ts — interactive onboarding wizard. Run once after install:
|
|
4
|
+
*
|
|
5
|
+
* bun run init (or: yt-briefing init)
|
|
6
|
+
*
|
|
7
|
+
* Asks for, and writes:
|
|
8
|
+
* 1. Output language for summaries + ratings → DATA_DIR/config.json
|
|
9
|
+
* 2. LLM provider / model / key, YouTube key, optional proxy → .env
|
|
10
|
+
* 3. The channels you follow — just a flat list of handles
|
|
11
|
+
* → DATA_DIR/channels.md, DATA_DIR/state.md, DATA_DIR/channels/<slug>.md
|
|
12
|
+
*
|
|
13
|
+
* Re-running is safe: it warns before overwriting existing data and lets you bail.
|
|
14
|
+
* Everything it writes is plain Markdown / JSON you can also edit by hand afterwards.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { DATA_DIR, CHANNELS_DIR, CHANNELS_MD, STATE_MD, CONFIG_JSON, ENV_PATH, profilePath, } from "./lib/paths.js";
|
|
18
|
+
import { AGENTS, installSkill, projectSkillDir, customSkillDirDefault, isPackageDevCwd } from "./lib/skill-install.js";
|
|
19
|
+
import { question } from "./lib/prompt.js";
|
|
20
|
+
const ask = (q, def = '') => {
|
|
21
|
+
const a = question(def ? `${q} [${def}]:` : `${q}:`).trim();
|
|
22
|
+
return a || def;
|
|
23
|
+
};
|
|
24
|
+
const askYN = (q, def = true) => {
|
|
25
|
+
const a = ask(`${q} (${def ? 'Y/n' : 'y/N'})`).toLowerCase();
|
|
26
|
+
if (!a)
|
|
27
|
+
return def;
|
|
28
|
+
return a.startsWith('y');
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Accept any of: `@betterstack`, `betterstack`, `https://www.youtube.com/@betterstack`
|
|
32
|
+
* (with or without a trailing `/videos` etc.) → canonical `@betterstack`. Returns null if
|
|
33
|
+
* no handle can be read (e.g. a bare `/channel/UC…` URL — ask the user for the @handle).
|
|
34
|
+
*/
|
|
35
|
+
function normalizeHandle(input) {
|
|
36
|
+
let s = input.trim();
|
|
37
|
+
if (/youtube\.com/i.test(s) || /^https?:\/\//i.test(s)) {
|
|
38
|
+
const m = s.match(/@[A-Za-z0-9._-]+/); // pull the @handle out of a URL
|
|
39
|
+
s = m ? m[0] : '';
|
|
40
|
+
}
|
|
41
|
+
s = s.replace(/^@+/, '').replace(/[^A-Za-z0-9._-].*$/, ''); // bare token
|
|
42
|
+
return s ? `@${s}` : null;
|
|
43
|
+
}
|
|
44
|
+
/** kebab-case slug that also breaks CamelCase: "BetterStack" → "better-stack". */
|
|
45
|
+
function slugify(s) {
|
|
46
|
+
return s
|
|
47
|
+
.replace(/@/g, '')
|
|
48
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
49
|
+
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
50
|
+
.replace(/-+/g, '-')
|
|
51
|
+
.replace(/^-|-$/g, '')
|
|
52
|
+
.toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
55
|
+
// Fresh channels start "baseline": the first sweep surfaces the latest upload per type.
|
|
56
|
+
// updated is set this far back so that window actually contains a recent video.
|
|
57
|
+
const BASELINE_LOOKBACK_DAYS = 60;
|
|
58
|
+
const lookback = new Date(Date.now() - BASELINE_LOOKBACK_DAYS * 864e5).toISOString().slice(0, 10);
|
|
59
|
+
function main() {
|
|
60
|
+
console.log('\n yt-briefing — onboarding\n ' + '─'.repeat(40) + '\n');
|
|
61
|
+
if (existsSync(CHANNELS_MD)) {
|
|
62
|
+
console.log(` Existing data found at ${DATA_DIR}`);
|
|
63
|
+
if (!askYN(' Overwrite it?', false)) {
|
|
64
|
+
console.log(' Aborted — nothing changed.\n');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
console.log('');
|
|
68
|
+
}
|
|
69
|
+
// 1. Language ----------------------------------------------------------------
|
|
70
|
+
console.log(' 1) Language');
|
|
71
|
+
const outputLang = ask(' Output language for summaries and ratings', 'English');
|
|
72
|
+
// 2. LLM provider (.env) -----------------------------------------------------
|
|
73
|
+
// Pick a provider → we prefill its endpoint + a sensible model and ask ONLY for the
|
|
74
|
+
// key (with the exact link to get it). The recommended free path is the default, so
|
|
75
|
+
// pressing Enter lands on it — no long URL to paste, nothing to guess.
|
|
76
|
+
console.log('\n 2) Which AI writes the filtering + summaries? Pick a provider:\n');
|
|
77
|
+
console.log(' 1) Gemini — FREE key, best way to start · https://aistudio.google.com/apikey');
|
|
78
|
+
console.log(' 2) OpenRouter — one key for Gemini + GPT + … · https://openrouter.ai/keys');
|
|
79
|
+
console.log(' 3) OpenAI — GPT models · https://platform.openai.com/api-keys');
|
|
80
|
+
console.log(' 4) Other / local (Ollama or any custom endpoint)\n');
|
|
81
|
+
console.log(' Type 1, 2, 3 or 4 and press Enter. (Just press Enter for 1 — Gemini, recommended.)');
|
|
82
|
+
const PROVIDERS = {
|
|
83
|
+
'1': { name: 'Gemini', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.5-flash', keyUrl: 'https://aistudio.google.com/apikey' },
|
|
84
|
+
'2': { name: 'OpenRouter', base: 'https://openrouter.ai/api/v1', model: 'google/gemini-2.5-flash', keyUrl: 'https://openrouter.ai/keys' },
|
|
85
|
+
'3': { name: 'OpenAI', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', keyUrl: 'https://platform.openai.com/api-keys' },
|
|
86
|
+
};
|
|
87
|
+
let llmBaseUrl, llmModel, llmKey;
|
|
88
|
+
const picked = PROVIDERS[ask(' Your choice', '1')];
|
|
89
|
+
if (picked) {
|
|
90
|
+
console.log(`\n → ${picked.name}. Get your key here: ${picked.keyUrl}`);
|
|
91
|
+
llmKey = ask(' Paste your API key');
|
|
92
|
+
llmBaseUrl = picked.base;
|
|
93
|
+
llmModel = ask(' Model (Enter to accept)', picked.model);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
console.log('\n → Custom / local endpoint (e.g. Ollama at http://localhost:11434/v1)');
|
|
97
|
+
llmBaseUrl = ask(' LLM_BASE_URL', 'http://localhost:11434/v1');
|
|
98
|
+
llmModel = ask(' LLM_MODEL', 'llama3.1');
|
|
99
|
+
llmKey = ask(' LLM_API_KEY (blank for local)', '');
|
|
100
|
+
}
|
|
101
|
+
console.log('\n 3) YouTube Data API (needed to list channel uploads)');
|
|
102
|
+
console.log(' Get a key: https://console.cloud.google.com → YouTube Data API v3');
|
|
103
|
+
const ytKey = ask(' YOUTUBE_API_KEY');
|
|
104
|
+
console.log('\n 4) Proxy (optional — only needed on datacenter/VPS IPs; see docs/warp-proxy.md)');
|
|
105
|
+
const ytProxy = ask(' YT_PROXY (blank = direct)', '');
|
|
106
|
+
// 5. Channels ----------------------------------------------------------------
|
|
107
|
+
// Just collect a flat list. No categories, no per-channel rules to define up front —
|
|
108
|
+
// each channel's profile LEARNS what to skip as you rate it (## Skip titles / ## Notes).
|
|
109
|
+
console.log('\n 5) Channels you follow');
|
|
110
|
+
console.log(' Add one per line — paste whichever form you have, all work as-is:');
|
|
111
|
+
console.log(' eg. @betterstack, betterstack or https://www.youtube.com/@betterstack');
|
|
112
|
+
console.log(' (Paste the full URL directly — it reads the @handle for you) Empty line to finish.');
|
|
113
|
+
console.log(' Each channel learns what to skip as you rate it.\n');
|
|
114
|
+
const channels = [];
|
|
115
|
+
while (true) {
|
|
116
|
+
const raw = ask(' Channel (blank to finish)');
|
|
117
|
+
if (!raw)
|
|
118
|
+
break;
|
|
119
|
+
const handle = normalizeHandle(raw);
|
|
120
|
+
if (!handle) {
|
|
121
|
+
console.log(` ! couldn't read a handle from "${raw}" — use @name or the channel URL.\n`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const slug = slugify(handle);
|
|
125
|
+
if (channels.some(c => c.slug === slug)) {
|
|
126
|
+
console.log(` ! ${handle} already added — skipping.\n`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
channels.push({ handle, slug });
|
|
130
|
+
console.log(` ✓ ${handle}\n`);
|
|
131
|
+
}
|
|
132
|
+
if (channels.length === 0) {
|
|
133
|
+
console.log('\n No channels added — you can add them later by editing data/channels.md.\n');
|
|
134
|
+
}
|
|
135
|
+
// 6. Coding agent ------------------------------------------------------------
|
|
136
|
+
// Place the skill INTO THIS PROJECT (the package folder you open in the agent) — never a
|
|
137
|
+
// home-global dir (that's the npm -g antipattern: machine-wide, invisible, easy to forget).
|
|
138
|
+
// Claude Code reads .claude/skills/ (already shipped here); Cursor reads .cursor/skills/.
|
|
139
|
+
// 1/2 = known agents; 3 = any other agent (a project folder you name).
|
|
140
|
+
console.log('\n 6) Which agent will you run /yt in?');
|
|
141
|
+
console.log(' 1) Claude Code 2) Cursor 3) Custom folder (any other agent)\n');
|
|
142
|
+
const agentKey = ask(' Your agent', '1');
|
|
143
|
+
// For a custom target, ask the folder now (keeps all prompts in the interactive block).
|
|
144
|
+
const customDir = AGENTS[agentKey] ? '' : ask(' Folder to install the skill into', customSkillDirDefault());
|
|
145
|
+
// 7. Write everything --------------------------------------------------------
|
|
146
|
+
mkdirSync(CHANNELS_DIR, { recursive: true });
|
|
147
|
+
// .env
|
|
148
|
+
const envBody = [
|
|
149
|
+
`LLM_BASE_URL=${llmBaseUrl}`,
|
|
150
|
+
`LLM_API_KEY=${llmKey}`,
|
|
151
|
+
`LLM_MODEL=${llmModel}`,
|
|
152
|
+
`YOUTUBE_API_KEY=${ytKey}`,
|
|
153
|
+
`YT_PROXY=${ytProxy}`,
|
|
154
|
+
'',
|
|
155
|
+
].join('\n');
|
|
156
|
+
writeFileSync(ENV_PATH, envBody, 'utf8');
|
|
157
|
+
// config.json
|
|
158
|
+
writeFileSync(CONFIG_JSON, JSON.stringify({ output_lang: outputLang }, null, 2) + '\n', 'utf8');
|
|
159
|
+
// channels.md — a flat list of the channels you follow.
|
|
160
|
+
const chParts = [
|
|
161
|
+
'---',
|
|
162
|
+
'type: yt-config',
|
|
163
|
+
'name: yt-channels',
|
|
164
|
+
'description: The channels you follow. Per-channel learned signal lives in channels/<slug>.md.',
|
|
165
|
+
`updated: ${today}`,
|
|
166
|
+
'---',
|
|
167
|
+
'',
|
|
168
|
+
'# Channels',
|
|
169
|
+
'',
|
|
170
|
+
];
|
|
171
|
+
for (const c of channels) {
|
|
172
|
+
chParts.push(`- [${c.handle}](https://www.youtube.com/${c.handle}) → [[channels/${c.slug}]]`);
|
|
173
|
+
}
|
|
174
|
+
writeFileSync(CHANNELS_MD, chParts.join('\n') + '\n', 'utf8');
|
|
175
|
+
// state.md — one flat table (baseline rows: pointers "—", updated = lookback so the first
|
|
176
|
+
// sweep finds the latest upload per type).
|
|
177
|
+
const stParts = [
|
|
178
|
+
'---',
|
|
179
|
+
'type: yt-state',
|
|
180
|
+
'name: yt-state',
|
|
181
|
+
'description: Per-channel per-type cursor. The sweep reads and updates it.',
|
|
182
|
+
`updated: ${today}`,
|
|
183
|
+
'---',
|
|
184
|
+
'',
|
|
185
|
+
'# State',
|
|
186
|
+
'',
|
|
187
|
+
'| Channel | last_longform_id | last_short_id | last_live_id | updated | session |',
|
|
188
|
+
'|---|---|---|---|---|---|',
|
|
189
|
+
];
|
|
190
|
+
for (const c of channels) {
|
|
191
|
+
stParts.push(`| ${c.handle} | — | — | — | ${lookback} | 0 |`);
|
|
192
|
+
}
|
|
193
|
+
writeFileSync(STATE_MD, stParts.join('\n') + '\n', 'utf8');
|
|
194
|
+
// per-channel profiles
|
|
195
|
+
for (const c of channels) {
|
|
196
|
+
const pParts = [
|
|
197
|
+
'---',
|
|
198
|
+
'type: yt-channel-profile',
|
|
199
|
+
`name: ${c.slug}-profile`,
|
|
200
|
+
`channel: ${c.handle}`,
|
|
201
|
+
`channel_url: https://www.youtube.com/${c.handle}`,
|
|
202
|
+
`updated: ${today}`,
|
|
203
|
+
'sessions_observed: 0',
|
|
204
|
+
'---',
|
|
205
|
+
'',
|
|
206
|
+
`# ${c.handle} — Profile`,
|
|
207
|
+
'',
|
|
208
|
+
];
|
|
209
|
+
writeFileSync(profilePath(c.slug), pParts.join('\n') + '\n', 'utf8');
|
|
210
|
+
}
|
|
211
|
+
console.log(' ' + '─'.repeat(40));
|
|
212
|
+
console.log(` Done. Wrote:`);
|
|
213
|
+
console.log(` ${ENV_PATH}`);
|
|
214
|
+
console.log(` ${CONFIG_JSON}`);
|
|
215
|
+
console.log(` ${CHANNELS_MD}`);
|
|
216
|
+
console.log(` ${STATE_MD}`);
|
|
217
|
+
console.log(` ${channels.length} profile(s) in ${CHANNELS_DIR}/`);
|
|
218
|
+
// Install the /yt skill for the chosen agent (step 6), into THIS project — process.cwd(),
|
|
219
|
+
// i.e. wherever you ran the command (the package clone in dev, or your own project when the
|
|
220
|
+
// package is a dependency). The command baked in is the shipped `bun run src` only for the
|
|
221
|
+
// dev-in-clone case; otherwise the compiled `dist/` command (so a consumed package works).
|
|
222
|
+
const agent = AGENTS[agentKey];
|
|
223
|
+
try {
|
|
224
|
+
const target = agent
|
|
225
|
+
? installSkill(projectSkillDir(agentKey, process.cwd()), /* dist */ !isPackageDevCwd())
|
|
226
|
+
: installSkill(customDir, /* dist */ true);
|
|
227
|
+
console.log(` /yt skill → ${target}`);
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
console.log(` ! Couldn't install the skill (${e.message}) — run yt-briefing install-skill later.`);
|
|
231
|
+
}
|
|
232
|
+
console.log('\n Next:');
|
|
233
|
+
console.log(` 1. Open this folder in ${agent ? agent.name : 'your agent'}.`);
|
|
234
|
+
console.log(' 2. Start a new chat and type /yt');
|
|
235
|
+
console.log('\n No agent? Run it in the terminal instead — see the README.\n');
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
main();
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
console.error(err);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Thin CLI dispatcher so the tool is usable as `yt-briefing <cmd>` once installed,
|
|
4
|
+
* mirroring the `bun run <script>` entry points. Each subcommand just forwards to the
|
|
5
|
+
* matching engine script with the same runtime as this process (`process.execPath` — Node
|
|
6
|
+
* or Bun), passing remaining args through.
|
|
7
|
+
*
|
|
8
|
+
* yt-briefing init interactive onboarding wizard
|
|
9
|
+
* yt-briefing install-skill install the /yt skill into a coding agent
|
|
10
|
+
* yt-briefing sweep [--reset] advance one step; prints a JSON status line
|
|
11
|
+
* yt-briefing rate --rating 0|1 [...] record a rating for the pending video
|
|
12
|
+
* yt-briefing transcribe <url|id> print a single video's transcript
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { script } from "./lib/paths.js";
|
|
16
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
17
|
+
const TARGETS = {
|
|
18
|
+
init: 'bootstrap',
|
|
19
|
+
'install-skill': 'install-skill',
|
|
20
|
+
sweep: 'yt-sweep',
|
|
21
|
+
rate: 'yt-rating',
|
|
22
|
+
transcribe: 'yt-transcript',
|
|
23
|
+
};
|
|
24
|
+
if (!cmd || !TARGETS[cmd]) {
|
|
25
|
+
console.error('Usage: yt-briefing <init|install-skill|sweep|rate|transcribe> [args...]');
|
|
26
|
+
process.exit(cmd ? 1 : 0);
|
|
27
|
+
}
|
|
28
|
+
const res = spawnSync(process.execPath, [script(TARGETS[cmd]), ...rest], { stdio: 'inherit' });
|
|
29
|
+
process.exit(res.status ?? 1);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* install-skill — copy the `/yt` skill into a coding agent's skills directory so the
|
|
4
|
+
* agent detects it. For any target that isn't this package under Bun, the command is baked to
|
|
5
|
+
* `"<this runtime>" "<abs>/dist/X.js"` (the compiled build), so it works no matter the agent's
|
|
6
|
+
* working directory or runtime (the engine resolves data/.env from its own location).
|
|
7
|
+
*
|
|
8
|
+
* yt-briefing install-skill # interactive: pick agent + scope
|
|
9
|
+
*
|
|
10
|
+
* `bun run init` already installs this skill into the project as its final step; this
|
|
11
|
+
* standalone command is for re-installing, a different project, or a second agent. There is
|
|
12
|
+
* deliberately no home-global install — the skill lives with the project that uses it.
|
|
13
|
+
*
|
|
14
|
+
* Both Claude Code and Cursor load `SKILL.md` skills and invoke them as `/<name>`.
|
|
15
|
+
* Cursor also reads `.claude/skills/` for compatibility, so the copy this package already
|
|
16
|
+
* ships often works in both — this command just (re)places it where you want.
|
|
17
|
+
*/
|
|
18
|
+
import { AGENTS, installSkill, projectSkillDir, customSkillDirDefault, isPackageDevCwd } from "./lib/skill-install.js";
|
|
19
|
+
import { question } from "./lib/prompt.js";
|
|
20
|
+
const ask = (q, def = '') => question(def ? `${q} [${def}]:` : `${q}:`).trim() || def;
|
|
21
|
+
function done(target) {
|
|
22
|
+
console.log(`\n ✓ Installed → ${target}`);
|
|
23
|
+
console.log(' Start a fresh agent session, then run /yt\n');
|
|
24
|
+
}
|
|
25
|
+
// 1) which agent → which skills subdir
|
|
26
|
+
console.log('\n Install the /yt skill — which agent?\n');
|
|
27
|
+
console.log(' 1) Claude Code');
|
|
28
|
+
console.log(' 2) Cursor (also reads Claude\'s .claude/skills)');
|
|
29
|
+
console.log(' 3) Custom folder (any other agent)\n');
|
|
30
|
+
const agentKey = ask(' Agent', '1');
|
|
31
|
+
const agent = AGENTS[agentKey];
|
|
32
|
+
// 3) Custom — write SKILL.md straight into a folder the user names (their agent's skills dir).
|
|
33
|
+
// Arbitrary location → bake the absolute dist command so it works whatever the agent's cwd is.
|
|
34
|
+
if (!agent) {
|
|
35
|
+
done(installSkill(ask(' Folder to install the skill into', customSkillDirDefault()), true));
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
// 2) Known agent → which project (default: the current folder). No home-global option by
|
|
39
|
+
// design — the skill is always scoped to a project that uses it.
|
|
40
|
+
console.log(`\n ${agent.name} — which project?\n`);
|
|
41
|
+
console.log(' 1) This project (current folder) — recommended');
|
|
42
|
+
console.log(' 2) Another project folder\n');
|
|
43
|
+
if (ask(' Where', '1') === '2') {
|
|
44
|
+
// A different project → the agent's cwd won't be the package, so bake the absolute dist command.
|
|
45
|
+
done(installSkill(projectSkillDir(agentKey, ask(' Project folder', process.cwd())), true));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// Current folder: shipped `bun run src` only when developing in the package clone under Bun;
|
|
49
|
+
// otherwise (incl. consuming the package as a dependency) bake the compiled dist command.
|
|
50
|
+
done(installSkill(projectSkillDir(agentKey, process.cwd()), !isPackageDevCwd()));
|
|
51
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User preferences for yt-briefing — currently just the output language, decided at
|
|
3
|
+
* onboarding and stored in DATA_DIR/config.json. Kept separate from .env on purpose:
|
|
4
|
+
* .env holds secrets (API keys, proxy), config.json holds non-secret preferences that
|
|
5
|
+
* both the engine and the agent (skill) read. The skill reads `output_lang` to ask the
|
|
6
|
+
* rating question in the user's language; the engine reads it to write summaries in it.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { CONFIG_JSON } from "./paths.js";
|
|
10
|
+
export function loadConfig() {
|
|
11
|
+
if (existsSync(CONFIG_JSON)) {
|
|
12
|
+
try {
|
|
13
|
+
const c = JSON.parse(readFileSync(CONFIG_JSON, 'utf8'));
|
|
14
|
+
if (typeof c.output_lang === 'string' && c.output_lang.trim()) {
|
|
15
|
+
return { output_lang: c.output_lang.trim() };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch { /* malformed → fall through to env/default */ }
|
|
19
|
+
}
|
|
20
|
+
return { output_lang: process.env.OUTPUT_LANG?.trim() || 'English' };
|
|
21
|
+
}
|
|
22
|
+
/** The language summaries and ratings are written in. Default English. */
|
|
23
|
+
export const outputLang = () => loadConfig().output_lang;
|