tokwise 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sebastian Crossa
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,185 @@
1
+ # Tokwise CLI
2
+
3
+ Local-first command line tooling for turning saved short-form videos into a searchable, transcript-centered knowledge base.
4
+
5
+ Tokwise syncs clips into local files, builds a search index, downloads media, transcribes audio, classifies themes, exports Markdown, compiles a wiki, answers questions against local evidence, and installs an agent skill.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g tokwise
11
+ ```
12
+
13
+ Or run without installing:
14
+
15
+ ```bash
16
+ npx tokwise status
17
+ ```
18
+
19
+ Main command:
20
+
21
+ ```bash
22
+ tokwise status
23
+ ```
24
+
25
+ Short alias:
26
+
27
+ ```bash
28
+ tw status
29
+ ```
30
+
31
+ Requires Node.js 20+. Media download requires `yt-dlp` on PATH. Transcription requires either OpenAI Whisper CLI, whisper.cpp, or a custom command.
32
+
33
+ ### Develop
34
+
35
+ ```bash
36
+ npm install
37
+ npm run build
38
+ npm link
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ```bash
44
+ # Optional: save a browser cookie for private collections or liked videos.
45
+ # Easiest on macOS: pull it straight from a logged-in Chromium browser.
46
+ tokwise auth from-browser # auto-detects Chrome, Brave, Edge, Arc, or Chromium
47
+ tokwise auth refresh # re-pull later when the session goes stale
48
+
49
+ # Or paste a cookie manually (works everywhere).
50
+ tokwise auth set --cookie "YOUR_COOKIE"
51
+
52
+ # Tokwise tries to detect the @handle tied to your cookie. Set it manually if needed.
53
+ tokwise auth set-username your-handle
54
+
55
+ # Sync a collection, download audio, transcribe, classify, and index.
56
+ # --collection accepts a full URL, an @user/collection/slug path, or a bare slug.
57
+ tokwise sync --collection "name-123" \
58
+ --limit 200 \
59
+ --download --audio \
60
+ --transcribe --stt-engine whisper --stt-model base \
61
+ --classify
62
+
63
+ # Search and explore.
64
+ tokwise search "how to choose a career"
65
+ tokwise similar <video-id>
66
+ tokwise categories
67
+ tokwise stats
68
+ tokwise md
69
+ tokwise wiki
70
+ tokwise ask "What patterns show up in my saved advice videos?" --engine ollama --model llama3.1
71
+ ```
72
+
73
+ Every `tokwise` command can also be run with `tw`.
74
+
75
+ ## Core Commands
76
+
77
+ ```bash
78
+ tokwise sync Sync URLs, collections, playlists, liked videos, or imports
79
+ tokwise fetch-media Download video/audio with yt-dlp for existing records
80
+ tokwise transcribe Run Whisper, whisper.cpp, or a custom STT command
81
+ tokwise index Rebuild the BM25 search index
82
+ tokwise search <query> Full-text search across descriptions and transcripts
83
+ tokwise list Filter by author, date, category, domain, collection, transcript state
84
+ tokwise show <id> Show one video in detail
85
+ tokwise similar <id> Find transcript-similar videos
86
+ tokwise stats Counts, date range, top authors, transcript coverage
87
+ tokwise viz Terminal dashboard with simple bars
88
+ tokwise categories Category distribution
89
+ tokwise domains Domain distribution
90
+ tokwise collections Collection/source distribution
91
+ tokwise classify Regex or local Ollama classification
92
+ tokwise model View or change default local model preferences
93
+ tokwise md Export one Markdown file per video
94
+ tokwise wiki Compile an interlinked local wiki
95
+ tokwise ask <question> Ask against top local matches, optionally via Ollama
96
+ tokwise lint Check generated wiki links
97
+ tokwise library ... Manage local Markdown library pages
98
+ tokwise commands ... Manage reusable local command notes
99
+ tokwise skill ... Install/show/uninstall an agent skill
100
+ tokwise paths/status/path Show local data locations and health
101
+ ```
102
+
103
+ ## Data Layout
104
+
105
+ By default data lives under `~/.tokwise/`.
106
+
107
+ ```text
108
+ ~/.tokwise/
109
+ videos/
110
+ videos.jsonl # one normalized video record per line
111
+ search-index.json # local BM25 index
112
+ auth.json # optional browser cookie, chmod 600
113
+ media/ # yt-dlp video files
114
+ audio/ # yt-dlp extracted audio files
115
+ transcripts/ # .json and .txt STT outputs
116
+ library/
117
+ index.md # generated wiki entry point
118
+ videos/*.md # one markdown page per video
119
+ categories/*.md
120
+ domains/*.md
121
+ commands/
122
+ *.md # portable command notes for agents
123
+ ```
124
+
125
+ Override locations with:
126
+
127
+ ```bash
128
+ export TOKWISE_DATA_DIR=/path/to/data
129
+ export TOKWISE_LIBRARY_DIR=/path/to/library
130
+ export TOKWISE_COMMANDS_DIR=/path/to/commands
131
+ ```
132
+
133
+ Legacy `TT_*` environment variables and `~/.tiktoktheory` are still read so existing local archives keep working after the rename.
134
+
135
+ ## Sources
136
+
137
+ ```bash
138
+ tokwise sync --collection <url | @user/collection/slug | slug-or-id>
139
+ tokwise sync --playlist <playlist-id-or-url>
140
+ tokwise sync --liked <username>
141
+ tokwise sync --user <username>
142
+ tokwise sync --search-video "life advice"
143
+ tokwise sync --url "https://www.tiktok.com/@user/video/123"
144
+ tokwise sync --urls-file urls.txt
145
+ tokwise sync --input export.jsonl
146
+ ```
147
+
148
+ `--collection` accepts three forms, from most to least explicit:
149
+
150
+ ```bash
151
+ tokwise sync --collection "https://www.tiktok.com/@user/collection/name-123"
152
+ tokwise sync --collection "@user/collection/name-123"
153
+ tokwise sync --collection "name-123" # uses the @handle saved with your cookie
154
+ ```
155
+
156
+ The bare-slug form needs the username tied to your cookie. Tokwise tries to detect it automatically when you run `tokwise auth set` or `tokwise auth from-browser`; you can also set it explicitly with `tokwise auth set-username <handle>` or `--username` on those commands. `tokwise auth show` reports the saved handle.
157
+
158
+ Private collections usually require a fresh browser cookie from a logged-in session. On macOS, `tokwise auth from-browser` reads and decrypts it straight from a logged-in Chromium browser (Chrome, Brave, Edge, Arc, or Chromium) via the macOS Keychain, and `tokwise auth refresh` re-pulls it when the session goes stale. On other platforms or browsers, paste it manually with `tokwise auth set`. Cookies are stored locally only (`auth.json`, chmod 600).
159
+
160
+ ## Transcription
161
+
162
+ Whisper CLI:
163
+
164
+ ```bash
165
+ tokwise transcribe --engine whisper --model base --language en
166
+ ```
167
+
168
+ whisper.cpp:
169
+
170
+ ```bash
171
+ tokwise transcribe --engine whisper-cpp --command whisper-cli --model /path/to/ggml-base.en.bin
172
+ ```
173
+
174
+ Custom command:
175
+
176
+ ```bash
177
+ tokwise transcribe --engine custom \
178
+ --command 'my-stt --input "{input}" --output "{output}" --language "{language}"'
179
+ ```
180
+
181
+ The custom command should write JSON, plain text, or print the transcript to stdout.
182
+
183
+ ## Attribution
184
+
185
+ Tokwise was inspired by [afar1/fieldtheory-cli](https://github.com/afar1/fieldtheory-cli), especially its local-first approach to syncing personal saved content into searchable files, Markdown, and agent-readable workflows.
package/dist/ask.js ADDED
@@ -0,0 +1,58 @@
1
+ export async function answerQuestion(question, results, options) {
2
+ if (results.length === 0)
3
+ return "No local evidence matched that question.";
4
+ if (options.engine === "ollama")
5
+ return answerWithOllama(question, results, options);
6
+ return answerExtractively(question, results);
7
+ }
8
+ function answerExtractively(question, results) {
9
+ return [
10
+ `Question: ${question}`,
11
+ "",
12
+ "Top local evidence:",
13
+ "",
14
+ ...results.slice(0, 8).map((result, idx) => {
15
+ const video = result.video;
16
+ const author = video.author?.username ? `@${video.author.username}` : "unknown";
17
+ const summary = video.classification?.summary ?? result.highlights[0] ?? video.description ?? "No text.";
18
+ return `${idx + 1}. ${video.id} ${author}\n ${summary}\n ${video.canonicalUrl ?? video.url}`;
19
+ }),
20
+ "",
21
+ "Use --engine ollama --model <model> for a synthesized answer from a local model.",
22
+ ].join("\n");
23
+ }
24
+ async function answerWithOllama(question, results, options) {
25
+ const baseUrl = options.ollamaBaseUrl ?? "http://localhost:11434";
26
+ const model = options.model ?? "llama3.1";
27
+ const context = results
28
+ .slice(0, 10)
29
+ .map((result, idx) => {
30
+ const video = result.video;
31
+ return [
32
+ `[${idx + 1}] ${video.id} ${video.canonicalUrl ?? video.url}`,
33
+ `Author: ${video.author?.username ?? "unknown"}`,
34
+ `Category: ${video.classification?.category ?? "unknown"}`,
35
+ `Summary: ${video.classification?.summary ?? ""}`,
36
+ `Transcript: ${(video.transcript?.text ?? video.description ?? "").slice(0, 2500)}`,
37
+ ].join("\n");
38
+ })
39
+ .join("\n\n");
40
+ const prompt = [
41
+ "Answer the user's question using only the saved clip evidence below.",
42
+ "Cite video ids in brackets when making claims. If evidence is thin, say so.",
43
+ "",
44
+ `Question: ${question}`,
45
+ "",
46
+ "Evidence:",
47
+ context,
48
+ ].join("\n");
49
+ const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/generate`, {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json" },
52
+ body: JSON.stringify({ model, prompt, stream: false }),
53
+ });
54
+ if (!response.ok)
55
+ throw new Error(`Ollama ask failed: ${response.status} ${response.statusText}`);
56
+ const body = (await response.json());
57
+ return body.response?.trim() || "Ollama returned an empty answer.";
58
+ }
@@ -0,0 +1,160 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { runProcess } from "./process.js";
6
+ export const SUPPORTED_BROWSERS = ["chrome", "brave", "edge", "arc", "chromium"];
7
+ const CHANNELS = {
8
+ chrome: { dir: "Google/Chrome", service: "Chrome Safe Storage", account: "Chrome" },
9
+ brave: { dir: "BraveSoftware/Brave-Browser", service: "Brave Safe Storage", account: "Brave" },
10
+ edge: { dir: "Microsoft Edge", service: "Microsoft Edge Safe Storage", account: "Microsoft Edge" },
11
+ arc: { dir: "Arc/User Data", service: "Arc Safe Storage", account: "Arc" },
12
+ chromium: { dir: "Chromium", service: "Chromium Safe Storage", account: "Chromium" },
13
+ };
14
+ export function isChromiumBrowser(value) {
15
+ return SUPPORTED_BROWSERS.includes(value);
16
+ }
17
+ export function chromiumTargets(browser, profile) {
18
+ const channel = CHANNELS[browser];
19
+ return {
20
+ cookieDbPath: path.join(os.homedir(), "Library", "Application Support", channel.dir, profile, "Cookies"),
21
+ keychainService: channel.service,
22
+ keychainAccount: channel.account,
23
+ };
24
+ }
25
+ export function deriveKey(password) {
26
+ return crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
27
+ }
28
+ export function decryptCookieValue(encrypted, key, hostKey) {
29
+ const prefix = encrypted.subarray(0, 3).toString("latin1");
30
+ const body = prefix === "v10" || prefix === "v11" ? encrypted.subarray(3) : encrypted;
31
+ const iv = Buffer.alloc(16, 0x20);
32
+ const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
33
+ decipher.setAutoPadding(false);
34
+ const padded = Buffer.concat([decipher.update(body), decipher.final()]);
35
+ const unpadded = removePkcs7Padding(padded);
36
+ const stripped = stripDomainHashPrefix(unpadded, hostKey);
37
+ return stripped.toString("utf8");
38
+ }
39
+ function removePkcs7Padding(buffer) {
40
+ if (buffer.length === 0)
41
+ return buffer;
42
+ const padLength = buffer[buffer.length - 1] ?? 0;
43
+ if (padLength > 0 && padLength <= 16 && padLength <= buffer.length) {
44
+ return buffer.subarray(0, buffer.length - padLength);
45
+ }
46
+ return buffer;
47
+ }
48
+ // Recent Chrome builds prepend a 32-byte SHA-256 hash of the cookie's domain to
49
+ // the decrypted plaintext. When the host key is known and matches that prefix,
50
+ // strip it so the clean cookie value is recovered.
51
+ function stripDomainHashPrefix(buffer, hostKey) {
52
+ if (!hostKey || buffer.length < 32)
53
+ return buffer;
54
+ const domainHash = crypto.createHash("sha256").update(hostKey).digest();
55
+ if (buffer.subarray(0, 32).equals(domainHash)) {
56
+ return buffer.subarray(32);
57
+ }
58
+ return buffer;
59
+ }
60
+ export function buildCookieHeader(rows, key) {
61
+ const pairs = [];
62
+ for (const row of rows) {
63
+ if (!row.name || !row.encrypted_hex)
64
+ continue;
65
+ try {
66
+ const value = decryptCookieValue(Buffer.from(row.encrypted_hex, "hex"), key, row.host_key);
67
+ if (value)
68
+ pairs.push(`${row.name}=${value}`);
69
+ }
70
+ catch {
71
+ continue;
72
+ }
73
+ }
74
+ return pairs.join("; ");
75
+ }
76
+ export async function readKeychainPassword(service, account) {
77
+ const result = await runProcess("security", ["find-generic-password", "-w", "-a", account, "-s", service]);
78
+ if (result.code !== 0) {
79
+ throw new Error(`Keychain access for "${service}" failed. Re-run and click Allow when macOS asks, or pass --cookie manually.`);
80
+ }
81
+ const password = result.stdout.trim();
82
+ if (!password) {
83
+ throw new Error(`Keychain returned an empty password for "${service}".`);
84
+ }
85
+ return password;
86
+ }
87
+ export async function readTikTokCookieRows(cookieDbPath) {
88
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tokwise-cookies-"));
89
+ const tmpDb = path.join(tmpDir, "Cookies");
90
+ try {
91
+ await fs.copyFile(cookieDbPath, tmpDb);
92
+ for (const suffix of ["-wal", "-shm"]) {
93
+ try {
94
+ await fs.copyFile(`${cookieDbPath}${suffix}`, `${tmpDb}${suffix}`);
95
+ }
96
+ catch {
97
+ // Sidecar files are optional; ignore when absent.
98
+ }
99
+ }
100
+ const sql = "SELECT host_key, name, hex(encrypted_value) AS encrypted_hex FROM cookies WHERE host_key LIKE '%tiktok.com%';";
101
+ const result = await runProcess("/usr/bin/sqlite3", ["-json", tmpDb, sql]);
102
+ if (result.code !== 0) {
103
+ throw new Error(`Could not read cookies database: ${result.stderr || result.stdout || "unknown error"}`);
104
+ }
105
+ return parseSqliteJsonRows(result.stdout);
106
+ }
107
+ finally {
108
+ await fs.rm(tmpDir, { recursive: true, force: true });
109
+ }
110
+ }
111
+ export function parseSqliteJsonRows(stdout) {
112
+ const trimmed = stdout.trim();
113
+ if (!trimmed)
114
+ return [];
115
+ const parsed = JSON.parse(trimmed);
116
+ if (!Array.isArray(parsed))
117
+ return [];
118
+ return parsed.flatMap((entry) => {
119
+ if (typeof entry !== "object" || entry === null)
120
+ return [];
121
+ const record = entry;
122
+ const host_key = typeof record.host_key === "string" ? record.host_key : "";
123
+ const name = typeof record.name === "string" ? record.name : "";
124
+ const encrypted_hex = typeof record.encrypted_hex === "string" ? record.encrypted_hex : "";
125
+ return [{ host_key, name, encrypted_hex }];
126
+ });
127
+ }
128
+ async function fileExists(filePath) {
129
+ try {
130
+ await fs.access(filePath);
131
+ return true;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ }
137
+ export async function extractTikTokCookie(options) {
138
+ const candidates = options.browser ? [options.browser] : SUPPORTED_BROWSERS;
139
+ const detected = [];
140
+ for (const browser of candidates) {
141
+ const target = chromiumTargets(browser, options.profile);
142
+ if (await fileExists(target.cookieDbPath))
143
+ detected.push(browser);
144
+ }
145
+ if (detected.length === 0) {
146
+ throw new Error(`No supported Chromium browser found on macOS for profile "${options.profile}" (looked for: ${candidates.join(", ")}). Use \`tw auth set --cookie\` instead.`);
147
+ }
148
+ for (const browser of detected) {
149
+ const target = chromiumTargets(browser, options.profile);
150
+ const rows = await readTikTokCookieRows(target.cookieDbPath);
151
+ if (rows.length === 0)
152
+ continue;
153
+ const password = await readKeychainPassword(target.keychainService, target.keychainAccount);
154
+ const cookie = buildCookieHeader(rows, deriveKey(password));
155
+ if (!cookie)
156
+ continue;
157
+ return { cookie, browser, profile: options.profile };
158
+ }
159
+ throw new Error(`Found ${detected.join(", ")} but no tiktok.com cookies in profile "${options.profile}". Open tiktok.com in your browser, log in, then retry.`);
160
+ }
@@ -0,0 +1,118 @@
1
+ import { searchableText, tokenize } from "./search.js";
2
+ const CATEGORY_RULES = [
3
+ { label: "career", keywords: ["career", "job", "work", "interview", "manager", "promotion", "resume", "business"] },
4
+ { label: "relationships", keywords: ["relationship", "friend", "partner", "dating", "marriage", "family", "boundaries"] },
5
+ { label: "health", keywords: ["health", "sleep", "fitness", "diet", "body", "therapy", "mental", "anxiety", "stress"] },
6
+ { label: "money", keywords: ["money", "finance", "invest", "budget", "saving", "debt", "wealth", "income"] },
7
+ { label: "productivity", keywords: ["productivity", "habit", "routine", "focus", "discipline", "calendar", "system"] },
8
+ { label: "learning", keywords: ["learn", "study", "read", "book", "skill", "practice", "teach"] },
9
+ { label: "mindset", keywords: ["mindset", "confidence", "fear", "failure", "motivation", "identity", "belief"] },
10
+ { label: "creativity", keywords: ["create", "writing", "artist", "idea", "taste", "creative", "make"] },
11
+ { label: "spirituality", keywords: ["meaning", "purpose", "gratitude", "meditation", "spiritual", "presence"] },
12
+ ];
13
+ const DOMAIN_RULES = [
14
+ { label: "decision-making", keywords: ["choice", "decision", "tradeoff", "choose", "option", "clarity"] },
15
+ { label: "self-regulation", keywords: ["emotion", "calm", "stress", "discipline", "impulse", "nervous"] },
16
+ { label: "social-dynamics", keywords: ["people", "friend", "relationship", "boundary", "conversation", "trust"] },
17
+ { label: "work-and-ambition", keywords: ["career", "job", "interview", "promotion", "work", "business", "goal", "ambition", "manager"] },
18
+ { label: "health-and-energy", keywords: ["sleep", "health", "body", "exercise", "food", "energy"] },
19
+ { label: "money-and-security", keywords: ["money", "budget", "wealth", "debt", "invest", "rent"] },
20
+ { label: "meaning-and-values", keywords: ["meaning", "purpose", "values", "life", "death", "legacy"] },
21
+ ];
22
+ export function classifyRegex(video) {
23
+ const text = searchableText(video).toLowerCase();
24
+ const tokens = new Set(tokenize(text));
25
+ const category = bestRule(tokens, CATEGORY_RULES) ?? "life-advice";
26
+ const domain = bestRule(tokens, DOMAIN_RULES) ?? "general-life";
27
+ const topics = topTopics(video, tokens);
28
+ return {
29
+ category,
30
+ domain,
31
+ topics,
32
+ summary: summarize(video),
33
+ engine: "regex",
34
+ classifiedAt: new Date().toISOString(),
35
+ };
36
+ }
37
+ export async function classifyOllama(video, options) {
38
+ const baseUrl = options.ollamaBaseUrl ?? "http://localhost:11434";
39
+ const model = options.model ?? "llama3.1";
40
+ const prompt = [
41
+ "Classify this saved short-form life-advice video.",
42
+ "Return compact JSON with keys: category, domain, topics, summary.",
43
+ "Categories should be short lowercase labels. Topics should be 3 to 7 short phrases.",
44
+ "",
45
+ `Author: ${video.author?.username ?? "unknown"}`,
46
+ `Description: ${video.description ?? ""}`,
47
+ `Transcript: ${(video.transcript?.text ?? "").slice(0, 6000)}`,
48
+ ].join("\n");
49
+ const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/generate`, {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json" },
52
+ body: JSON.stringify({ model, prompt, stream: false, format: "json" }),
53
+ });
54
+ if (!response.ok)
55
+ throw new Error(`Ollama classify failed: ${response.status} ${response.statusText}`);
56
+ const body = (await response.json());
57
+ const parsed = parseJsonObject(body.response ?? "{}");
58
+ const fallback = classifyRegex(video);
59
+ return {
60
+ category: stringValue(parsed.category) ?? fallback.category,
61
+ domain: stringValue(parsed.domain) ?? fallback.domain,
62
+ topics: arrayOfStrings(parsed.topics) ?? fallback.topics,
63
+ summary: stringValue(parsed.summary) ?? fallback.summary,
64
+ engine: "ollama",
65
+ model,
66
+ classifiedAt: new Date().toISOString(),
67
+ };
68
+ }
69
+ export async function classifyOne(video, options) {
70
+ if (options.engine === "ollama")
71
+ return classifyOllama(video, options);
72
+ return classifyRegex(video);
73
+ }
74
+ function bestRule(tokens, rules) {
75
+ let best;
76
+ for (const rule of rules) {
77
+ const score = rule.keywords.reduce((sum, keyword) => sum + (tokens.has(keyword) ? 1 : 0), 0);
78
+ if (score > 0 && (!best || score > best.score))
79
+ best = { label: rule.label, score };
80
+ }
81
+ return best?.label;
82
+ }
83
+ function topTopics(video, tokens) {
84
+ const hashtags = video.hashtags.slice(0, 8).map((tag) => tag.toLowerCase());
85
+ const meaningful = [...tokens].filter((token) => token.length > 4).slice(0, 8);
86
+ return [...new Set([...hashtags, ...meaningful])].slice(0, 7);
87
+ }
88
+ function summarize(video) {
89
+ const text = video.transcript?.text || video.description || "";
90
+ const firstSentence = text.split(/(?<=[.!?])\s+/)[0]?.replace(/\s+/g, " ").trim();
91
+ return firstSentence?.slice(0, 260) || "No transcript summary available yet.";
92
+ }
93
+ function parseJsonObject(text) {
94
+ try {
95
+ const parsed = JSON.parse(text);
96
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
97
+ }
98
+ catch {
99
+ const match = text.match(/\{[\s\S]*\}/);
100
+ if (!match)
101
+ return {};
102
+ try {
103
+ return JSON.parse(match[0]);
104
+ }
105
+ catch {
106
+ return {};
107
+ }
108
+ }
109
+ }
110
+ function stringValue(value) {
111
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
112
+ }
113
+ function arrayOfStrings(value) {
114
+ if (!Array.isArray(value))
115
+ return undefined;
116
+ const strings = value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
117
+ return strings.length > 0 ? strings : undefined;
118
+ }