yt-briefing 0.1.0 → 0.2.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.
@@ -9,7 +9,7 @@ description: Briefing from the YouTube channels you follow — the engine sweeps
9
9
 
10
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
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`.
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_BRIEFING_DEBUG=1` — it appends per-stage timings to the gitignored `data/.cache/sweep.log`.
13
13
 
14
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
15
 
package/README.md CHANGED
@@ -9,6 +9,16 @@ It also gets better the more you use it. You give each summary a quick rating, w
9
9
  or not, and from that it learns what to keep showing you and what to drop. Over time the queue
10
10
  becomes yours: less noise, more of what you care about.
11
11
 
12
+ ## First run vs later
13
+
14
+ On a channel's first sweep there is no history, so yt-briefing takes the latest video of each
15
+ kind: the newest long-form, the newest short, and the newest live. That gives you a baseline
16
+ without pulling the whole back catalog.
17
+
18
+ After that it works from history. Each rating moves a per-type cursor forward, so later runs
19
+ only surface videos newer than the ones you already handled, and a session just continues where
20
+ the last one left off.
21
+
12
22
  ## Setup
13
23
 
14
24
  You'll need Node 18+ or Bun, a YouTube Data API v3 key, an LLM key (a
@@ -55,9 +65,9 @@ Any OpenAI-compatible endpoint works. Gemini 2.5 Flash is the easy default. It's
55
65
  and free to start at [Google AI Studio](https://aistudio.google.com/apikey):
56
66
 
57
67
  ```ini
58
- LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
59
- LLM_API_KEY=<gemini-key>
60
- LLM_MODEL=gemini-2.5-flash
68
+ YT_BRIEFING_LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
69
+ YT_BRIEFING_LLM_API_KEY=<gemini-key>
70
+ YT_BRIEFING_LLM_MODEL=gemini-2.5-flash
61
71
  ```
62
72
 
63
73
  > On the free tier Gemini sometimes returns a "model is overloaded / high demand" error. Retry,
@@ -92,7 +102,7 @@ flowing.
92
102
 
93
103
  ## Sync across machines
94
104
 
95
- Your state is plain files in `.yt-briefing/data/`. Version that folder (or point `YT_DATA_DIR`
105
+ Your state is plain files in `.yt-briefing/data/`. Version that folder (or point `YT_BRIEFING_DATA_DIR`
96
106
  at a separate private repo) and commit after each rating. Recipe:
97
107
  [docs/sync-across-machines.md](./docs/sync-across-machines.md).
98
108
 
package/dist/bootstrap.js CHANGED
@@ -14,7 +14,8 @@
14
14
  * Everything it writes is plain Markdown / JSON you can also edit by hand afterwards.
15
15
  */
16
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";
17
+ import { join } from 'node:path';
18
+ import { DATA_DIR, BASE_DIR, PKG_ROOT, CHANNELS_DIR, CHANNELS_MD, STATE_MD, CONFIG_JSON, ENV_PATH, profilePath, } from "./lib/paths.js";
18
19
  import { AGENTS, installSkill, projectSkillDir, customSkillDirDefault, isPackageDevCwd } from "./lib/skill-install.js";
19
20
  import { question } from "./lib/prompt.js";
20
21
  const ask = (q, def = '') => {
@@ -94,15 +95,15 @@ function main() {
94
95
  }
95
96
  else {
96
97
  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)', '');
98
+ llmBaseUrl = ask(' YT_BRIEFING_LLM_BASE_URL', 'http://localhost:11434/v1');
99
+ llmModel = ask(' YT_BRIEFING_LLM_MODEL', 'llama3.1');
100
+ llmKey = ask(' YT_BRIEFING_LLM_API_KEY (blank for local)', '');
100
101
  }
101
102
  console.log('\n 3) YouTube Data API (needed to list channel uploads)');
102
103
  console.log(' Get a key: https://console.cloud.google.com → YouTube Data API v3');
103
- const ytKey = ask(' YOUTUBE_API_KEY');
104
+ const ytKey = ask(' YT_BRIEFING_YOUTUBE_API_KEY');
104
105
  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
+ const ytProxy = ask(' YT_BRIEFING_PROXY (blank = direct)', '');
106
107
  // 5. Channels ----------------------------------------------------------------
107
108
  // Just collect a flat list. No categories, no per-channel rules to define up front —
108
109
  // each channel's profile LEARNS what to skip as you rate it (## Skip titles / ## Notes).
@@ -146,14 +147,20 @@ function main() {
146
147
  mkdirSync(CHANNELS_DIR, { recursive: true });
147
148
  // .env
148
149
  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}`,
150
+ `YT_BRIEFING_LLM_BASE_URL=${llmBaseUrl}`,
151
+ `YT_BRIEFING_LLM_API_KEY=${llmKey}`,
152
+ `YT_BRIEFING_LLM_MODEL=${llmModel}`,
153
+ `YT_BRIEFING_YOUTUBE_API_KEY=${ytKey}`,
154
+ `YT_BRIEFING_PROXY=${ytProxy}`,
154
155
  '',
155
156
  ].join('\n');
156
157
  writeFileSync(ENV_PATH, envBody, 'utf8');
158
+ // Secret-safety for the consume layout: drop a .gitignore inside .yt-briefing/ so .env never
159
+ // gets committed regardless of the host project's own ignore rules. data/ stays versionable
160
+ // (for sync). In a dev clone (BASE_DIR === PKG_ROOT) the repo's own .gitignore already covers it.
161
+ if (BASE_DIR !== PKG_ROOT) {
162
+ writeFileSync(join(BASE_DIR, '.gitignore'), '.env\ndata/.cache/\n', 'utf8');
163
+ }
157
164
  // config.json
158
165
  writeFileSync(CONFIG_JSON, JSON.stringify({ output_lang: outputLang }, null, 2) + '\n', 'utf8');
159
166
  // channels.md — a flat list of the channels you follow.
package/dist/lib/llm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Minimal OpenAI-compatible chat client — the only LLM dependency in yt-briefing.
3
3
  *
4
- * Provider-agnostic: point LLM_BASE_URL at any OpenAI-compatible endpoint —
4
+ * Provider-agnostic: point YT_BRIEFING_LLM_BASE_URL at any OpenAI-compatible endpoint —
5
5
  * OpenRouter (default, "any model, one key"), Google Gemini's OpenAI-compat
6
6
  * endpoint, OpenAI itself, a local Ollama, etc. The tool depends only on an API key
7
7
  * here — not on any specific vendor and not on a coding agent being installed.
@@ -11,20 +11,20 @@
11
11
  * the summaries.
12
12
  *
13
13
  * Env (see .env.example):
14
- * LLM_BASE_URL default https://openrouter.ai/api/v1
15
- * LLM_API_KEY required
16
- * LLM_MODEL default google/gemini-2.5-flash
14
+ * YT_BRIEFING_LLM_BASE_URL default https://openrouter.ai/api/v1
15
+ * YT_BRIEFING_LLM_API_KEY required
16
+ * YT_BRIEFING_LLM_MODEL default google/gemini-2.5-flash
17
17
  */
18
18
  const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
19
19
  const DEFAULT_MODEL = "google/gemini-2.5-flash";
20
20
  export function getModel() {
21
- return process.env.LLM_MODEL || DEFAULT_MODEL;
21
+ return process.env.YT_BRIEFING_LLM_MODEL || DEFAULT_MODEL;
22
22
  }
23
23
  export async function chat(prompt, opts = {}) {
24
- const baseUrl = (process.env.LLM_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
25
- const apiKey = process.env.LLM_API_KEY;
24
+ const baseUrl = (process.env.YT_BRIEFING_LLM_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
25
+ const apiKey = process.env.YT_BRIEFING_LLM_API_KEY;
26
26
  if (!apiKey)
27
- throw new Error("LLM_API_KEY not set (see .env.example)");
27
+ throw new Error("YT_BRIEFING_LLM_API_KEY not set (see .env.example)");
28
28
  const model = opts.model || getModel();
29
29
  const messages = [];
30
30
  if (opts.system)
package/dist/lib/paths.js CHANGED
@@ -7,9 +7,9 @@
7
7
  * itself) this IS the package root, so the repo layout (`.env`, `data/`) is
8
8
  * unchanged. When the package is *consumed as a dependency* — PKG_ROOT sits
9
9
  * inside node_modules — that would be wiped on reinstall, so BASE_DIR moves to
10
- * `<your project>/.yt-briefing/` instead. Override explicitly with YT_BASE_DIR.
10
+ * `<your project>/.yt-briefing/` instead. Override explicitly with YT_BRIEFING_BASE_DIR.
11
11
  * DATA_DIR the mutable state (subscriptions, profiles, cursor, throwaway cache).
12
- * Defaults to <BASE_DIR>/data; override with YT_DATA_DIR to keep it anywhere
12
+ * Defaults to <BASE_DIR>/data; override with YT_BRIEFING_DATA_DIR to keep it anywhere
13
13
  * (e.g. a synced git folder, separate from secrets).
14
14
  *
15
15
  * BASE_DIR and DATA_DIR are pinned back into the environment so detached child processes —
@@ -28,15 +28,15 @@ export const PKG_ROOT = resolve(HERE, '../..'); // package root
28
28
  const SCRIPT_EXT = extname(SELF) || '.ts';
29
29
  // Consumed as a dependency? Then PKG_ROOT lives under node_modules and must not hold user state.
30
30
  const CONSUMED = PKG_ROOT.split(sep).includes('node_modules');
31
- export const BASE_DIR = process.env.YT_BASE_DIR
32
- ? resolve(process.env.YT_BASE_DIR)
31
+ export const BASE_DIR = process.env.YT_BRIEFING_BASE_DIR
32
+ ? resolve(process.env.YT_BRIEFING_BASE_DIR)
33
33
  : CONSUMED ? join(process.cwd(), '.yt-briefing') : PKG_ROOT;
34
- process.env.YT_BASE_DIR = BASE_DIR; // pin for children (their cwd differs)
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
- export const DATA_DIR = process.env.YT_DATA_DIR
37
- ? resolve(process.env.YT_DATA_DIR)
36
+ export const DATA_DIR = process.env.YT_BRIEFING_DATA_DIR
37
+ ? resolve(process.env.YT_BRIEFING_DATA_DIR)
38
38
  : join(BASE_DIR, 'data');
39
- process.env.YT_DATA_DIR = DATA_DIR; // pin for children (their cwd differs)
39
+ process.env.YT_BRIEFING_DATA_DIR = DATA_DIR; // pin for children (their cwd differs)
40
40
  export const CHANNELS_MD = join(DATA_DIR, 'channels.md');
41
41
  export const STATE_MD = join(DATA_DIR, 'state.md');
42
42
  export const CONFIG_JSON = join(DATA_DIR, 'config.json');
@@ -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: YOUTUBE_API_KEY — the entrypoint loads it (dotenv.config from ENV_PATH) before
8
+ * Auth: YT_BRIEFING_YOUTUBE_API_KEY — the entrypoint loads it (dotenv.config from ENV_PATH) 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
- const k = process.env.YOUTUBE_API_KEY;
13
+ const k = process.env.YT_BRIEFING_YOUTUBE_API_KEY;
14
14
  if (!k)
15
- throw new Error('YOUTUBE_API_KEY env var not set (see .env.example)');
15
+ throw new Error('YT_BRIEFING_YOUTUBE_API_KEY env var not set (see .env.example)');
16
16
  return k;
17
17
  }
18
18
  async function get(path, params) {
@@ -13,7 +13,7 @@
13
13
  * Filtered OUT: current and upcoming live broadcasts (no transcript yet, never consumable).
14
14
  * --no-enrich → skip the videos.list pass; type/durationSeconds omitted; no filtering.
15
15
  *
16
- * Requires: YOUTUBE_API_KEY (see .env.example). Thin CLI wrapper over lib/yt-api.ts.
16
+ * Requires: YT_BRIEFING_YOUTUBE_API_KEY (see .env.example). Thin CLI wrapper over lib/yt-api.ts.
17
17
  *
18
18
  * Quota: ~1 unit per page (playlistItems.list) + 1 unit per 50 videos (videos.list).
19
19
  */
package/dist/yt-sweep.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * {"status":"rate_limited"}
18
18
  *
19
19
  * The engine ONLY writes files under DATA_DIR — it never runs git or any VCS. If you
20
- * want your briefing state versioned, commit DATA_DIR yourself (or point YT_DATA_DIR at
20
+ * want your briefing state versioned, commit DATA_DIR yourself (or point YT_BRIEFING_DATA_DIR at
21
21
  * a synced folder). Keeping persistence out of the engine is deliberate: it stays a pure
22
22
  * data tool with zero host coupling.
23
23
  *
@@ -71,9 +71,9 @@ const prefetchTarget = pfIdx !== -1 ? argv[pfIdx + 1] : null;
71
71
  const today = new Date().toISOString().slice(0, 10);
72
72
  const LANG = outputLang();
73
73
  // Diagnostics sink. Default: silent (stdout stays a pure JSON line — the caller never
74
- // has to redirect anything, so no /tmp). With YT_DEBUG set, timing + child stderr append
74
+ // has to redirect anything, so no /tmp). With YT_BRIEFING_DEBUG set, timing + child stderr append
75
75
  // to <DATA_DIR>/.cache/sweep.log (gitignored) — never an OS temp dir.
76
- const DEBUG = !!process.env.YT_DEBUG;
76
+ const DEBUG = !!process.env.YT_BRIEFING_DEBUG;
77
77
  const T0 = Date.now();
78
78
  const log = (msg) => { if (DEBUG)
79
79
  appendFileSync(LOG_FILE, `⏱ ${msg} (+${Date.now() - T0}ms)\n`); };
@@ -85,7 +85,7 @@ function run(cmd) {
85
85
  let stdout = '';
86
86
  p.stdout.on('data', d => { stdout += d.toString(); });
87
87
  // Child stderr → the gitignored debug log only (never parent stderr / stdout), so a
88
- // bare invocation emits nothing but the JSON line. Silent unless YT_DEBUG.
88
+ // bare invocation emits nothing but the JSON line. Silent unless YT_BRIEFING_DEBUG.
89
89
  if (DEBUG)
90
90
  p.stderr.on('data', d => appendFileSync(LOG_FILE, d.toString()));
91
91
  else
@@ -12,13 +12,13 @@
12
12
  * Uses yt-dlp for subtitle extraction (handles all YouTube caption formats).
13
13
  *
14
14
  * yt-dlp lookup (so a project-local install needs no global PATH pollution):
15
- * 1. $YT_DLP_PATH if set
15
+ * 1. $YT_BRIEFING_DLP_PATH if set
16
16
  * 2. <package>/bin/yt-dlp (yt-dlp.exe on Windows) if present
17
17
  * 3. `yt-dlp` on PATH (yt-dlp.exe on Windows)
18
18
  * See README.md → Requirements for per-OS install methods.
19
19
  *
20
- * YT_PROXY env (optional): HTTP proxy URL — required on datacenter/VPS IPs which
21
- * YouTube blocks. Example: YT_PROXY=http://127.0.0.1:1080 (Cloudflare WARP via
20
+ * YT_BRIEFING_PROXY env (optional): HTTP proxy URL — required on datacenter/VPS IPs which
21
+ * YouTube blocks. Example: YT_BRIEFING_PROXY=http://127.0.0.1:1080 (Cloudflare WARP via
22
22
  * Docker). See docs/warp-proxy.md.
23
23
  */
24
24
  import { spawnSync } from 'node:child_process';
@@ -27,13 +27,13 @@ import { join, dirname } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import dotenv from 'dotenv';
29
29
  import { CACHE_DIR, ENV_PATH } from "./lib/paths.js";
30
- // Load .env so YT_PROXY is set when run standalone under Node (Bun auto-loads it; Node doesn't).
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
  dotenv.config({ path: ENV_PATH });
33
33
  /** Resolve the yt-dlp binary: explicit env → project-local ./bin → PATH (Windows-aware). */
34
34
  function resolveYtDlp() {
35
- if (process.env.YT_DLP_PATH)
36
- return process.env.YT_DLP_PATH;
35
+ if (process.env.YT_BRIEFING_DLP_PATH)
36
+ return process.env.YT_BRIEFING_DLP_PATH;
37
37
  const exe = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp';
38
38
  const local = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', exe);
39
39
  return existsSync(local) ? local : exe; // bare name → looked up on PATH
@@ -61,7 +61,7 @@ function extractVideoId(input) {
61
61
  process.exit(3);
62
62
  }
63
63
  const videoId = extractVideoId(rawInput);
64
- const proxyUrl = process.env.YT_PROXY;
64
+ const proxyUrl = process.env.YT_BRIEFING_PROXY;
65
65
  function vttToText(vtt) {
66
66
  let prev = '';
67
67
  const parts = [];
@@ -106,7 +106,7 @@ catch { } };
106
106
  if (result.error) {
107
107
  cleanup();
108
108
  const msg = result.error.code === 'ENOENT'
109
- ? `yt-dlp not found (looked for "${YT_DLP}") — install it or set YT_DLP_PATH (see README.md → Requirements)`
109
+ ? `yt-dlp not found (looked for "${YT_DLP}") — install it or set YT_BRIEFING_DLP_PATH (see README.md → Requirements)`
110
110
  : `yt-dlp spawn error: ${result.error.message}`;
111
111
  console.error(msg);
112
112
  process.exit(3);
@@ -23,11 +23,11 @@ your project's own git: push from machine A, pull on machine B. Keep `.yt-briefi
23
23
  git-ignored — secrets stay per machine.
24
24
 
25
25
  Want briefing state in **its own** repo instead (e.g. a laptop and a headless VPS that share
26
- nothing else)? Point `YT_DATA_DIR` at a folder you control and version that:
26
+ nothing else)? Point `YT_BRIEFING_DATA_DIR` at a folder you control and version that:
27
27
 
28
28
  ```bash
29
29
  # .env (per machine — secrets never sync)
30
- YT_DATA_DIR=/home/you/yt-briefing-data
30
+ YT_BRIEFING_DATA_DIR=/home/you/yt-briefing-data
31
31
  ```
32
32
 
33
33
  ```bash
@@ -38,7 +38,7 @@ npx yt-briefing init # onboard into this folder (or move exi
38
38
  git add -A && git commit -m "initial" && git push -u origin main
39
39
  ```
40
40
 
41
- On the second machine: clone that repo, set the same `YT_DATA_DIR`, drop in your `.env`.
41
+ On the second machine: clone that repo, set the same `YT_BRIEFING_DATA_DIR`, drop in your `.env`.
42
42
 
43
43
  ---
44
44
 
@@ -71,7 +71,7 @@ Save as `yt-sync.sh` (anywhere), `chmod +x`:
71
71
  #!/usr/bin/env bash
72
72
  # Persist yt-briefing state to git, sync-safe across machines. Best-effort, never blocks.
73
73
  set -uo pipefail
74
- DATA="${YT_DATA_DIR:-$PWD/.yt-briefing/data}" # the folder you version (default: in-project)
74
+ DATA="${YT_BRIEFING_DATA_DIR:-$PWD/.yt-briefing/data}" # the folder you version (default: in-project)
75
75
  cd "$DATA" || exit 0
76
76
 
77
77
  git add channels.md state.md channels/ config.json 2>/dev/null || exit 0
@@ -113,7 +113,7 @@ yt-briefing rate --rating 0 && /path/to/yt-sync.sh
113
113
 
114
114
  > Optional but recommended: also `git pull --rebase` **before** the first sweep of a session
115
115
  > (so a machine starts on the latest cursor), e.g. a `PreToolUse` hook matching
116
- > `*yt-sweep*--reset*`, or just `cd "$YT_DATA_DIR" && git pull --rebase` before you start.
116
+ > `*yt-sweep*--reset*`, or just `cd "$YT_BRIEFING_DATA_DIR" && git pull --rebase` before you start.
117
117
 
118
118
  ---
119
119
 
@@ -38,10 +38,10 @@ either; we use HTTP.
38
38
 
39
39
  ```bash
40
40
  # .env
41
- YT_PROXY=http://127.0.0.1:1080
41
+ YT_BRIEFING_PROXY=http://127.0.0.1:1080
42
42
  ```
43
43
 
44
- yt-briefing reads `YT_PROXY` and routes all yt-dlp traffic through it. No env var → direct
44
+ yt-briefing reads `YT_BRIEFING_PROXY` and routes all yt-dlp traffic through it. No env var → direct
45
45
  fetch (the residential default).
46
46
 
47
47
  ### Health check
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": {