kiku-agent 1.2.0 → 1.3.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.
Files changed (100) hide show
  1. package/README.md +459 -407
  2. package/package.json +27 -3
  3. package/server/agent/agentCore.js +635 -267
  4. package/server/agent/appPaths.js +2 -0
  5. package/server/agent/catalogEntityResolver.js +629 -0
  6. package/server/agent/citations.js +0 -21
  7. package/server/agent/clarificationAnalytics.js +1 -1
  8. package/server/agent/clarifyingQuestions.js +286 -216
  9. package/server/agent/cli.js +528 -73
  10. package/server/agent/commandFeatures.js +123 -24
  11. package/server/agent/commandRegistry.js +128 -62
  12. package/server/agent/commands.js +1410 -95
  13. package/server/agent/config.js +253 -163
  14. package/server/agent/contextCompactor.js +239 -25
  15. package/server/agent/dynamicCatalog.js +192 -0
  16. package/server/agent/envBootstrap.js +46 -0
  17. package/server/agent/envManager.js +317 -0
  18. package/server/agent/eventBus.js +3 -3
  19. package/server/agent/feedbackLearning.js +0 -8
  20. package/server/agent/gpuMetrics.js +188 -55
  21. package/server/agent/ingestionWorker.js +339 -0
  22. package/server/agent/lanDiscovery.js +369 -0
  23. package/server/agent/llmClient.js +111 -39
  24. package/server/agent/lmStudioWakeup.js +144 -0
  25. package/server/agent/mcpClient.js +2 -2
  26. package/server/agent/memory.js +635 -494
  27. package/server/agent/modelConfig.js +487 -0
  28. package/server/agent/newsIntelligence.js +219 -0
  29. package/server/agent/newsWatch.js +85 -0
  30. package/server/agent/prompts.js +146 -0
  31. package/server/agent/publicTools.js +7 -0
  32. package/server/agent/queryDetails.js +62 -1
  33. package/server/agent/research.js +314 -35
  34. package/server/agent/routingInterceptor.js +77 -0
  35. package/server/agent/rumoredProjects.js +15 -0
  36. package/server/agent/sanitizer.js +117 -17
  37. package/server/agent/subagent.js +52 -9
  38. package/server/agent/suggestionTui.js +33 -13
  39. package/server/agent/suggestions.js +29 -13
  40. package/server/agent/telemetry.js +0 -11
  41. package/server/agent/terminalImage.js +0 -1
  42. package/server/agent/textToolProtocol.js +190 -0
  43. package/server/agent/tokenTracker.js +220 -0
  44. package/server/agent/toolRouter.js +362 -216
  45. package/server/agent/tools.js +1155 -194
  46. package/server/agent/ui.js +144 -41
  47. package/server/agent/vramService.js +572 -0
  48. package/server/agent/webSearch.js +2279 -273
  49. package/server/agent/youtubeAuth.js +82 -73
  50. package/server/agent/youtubeTranscript.js +209 -0
  51. package/server/apiRouter.js +383 -179
  52. package/server/mcp/data/brands.js +5 -5
  53. package/server/mcp/data/discography.js +56 -45
  54. package/server/mcp/data/gallery.js +9 -0
  55. package/server/mcp/data/index.js +1 -0
  56. package/server/mcp/data/industryIndex.js +251 -0
  57. package/server/mcp/data/profile.js +5 -5
  58. package/server/mcp/data/timeline.js +2 -2
  59. package/server/mcp/data/works.js +2 -2
  60. package/server/mcp/handler.js +0 -2
  61. package/server/mcp/prompts.js +42 -2
  62. package/server/mcp/resources.js +5 -1
  63. package/server/mcp/security.js +25 -14
  64. package/server/mcp/server.js +92 -3
  65. package/server/mcp/tools.js +20 -11
  66. package/server/web-chat/core/connection.js +218 -194
  67. package/server/web-chat/core/conversationContext.js +1 -1
  68. package/server/web-chat/core/directCatalogEngine.js +190 -184
  69. package/server/web-chat/core/followUpEngine.js +88 -11
  70. package/server/web-chat/core/security.js +7 -4
  71. package/server/web-chat/router.js +54 -17
  72. package/server/web-chat/routes/bilibiliRoute.js +34 -0
  73. package/server/web-chat/routes/chatRoute.js +488 -397
  74. package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
  75. package/server/web-chat/routes/exportRoute.js +2 -1
  76. package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
  77. package/server/web-chat/routes/newsRoute.js +36 -6
  78. package/server/web-chat/routes/providerRoute.js +87 -0
  79. package/server/web-chat/routes/staticRoute.js +61 -13
  80. package/server/web-chat/routes/statusRoute.js +23 -2
  81. package/server/web-chat/routes/vramRoute.js +22 -0
  82. package/server/web-chat/server.js +135 -106
  83. package/server/web-chat/ui/htmlBuilder.js +8 -8
  84. package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
  85. package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
  86. package/server/web-chat/ui/scripts/client.js +10526 -8397
  87. package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
  88. package/server/web-chat/ui/styles/main.css +7471 -4246
  89. package/server/web-chat/ui/templates/body.html +305 -179
  90. package/src/data/brandsData.js +29 -6
  91. package/src/data/discographyData.js +918 -900
  92. package/src/data/galleryData.js +66 -0
  93. package/src/data/industryIndexData.js +292 -0
  94. package/src/data/musicVideoData.js +48 -5
  95. package/src/data/profileData.js +15 -1
  96. package/src/data/timelineData.js +4 -3
  97. package/src/data/worksData.js +536 -535
  98. package/src/lib/vram/estimator.js +464 -0
  99. package/src/lib/vram/modelRegistry.js +387 -0
  100. package/tokens.css +33 -2
@@ -1,24 +1,32 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { spawn, execSync } from 'child_process';
3
+ import { spawn, execFile } from 'child_process';
4
+ import { promisify } from 'util';
4
5
  import { fileURLToPath } from 'url';
5
6
  import { KIKU_DATA_DIR } from './appPaths.js';
7
+ import { PACKAGE_ENV_PATH, USER_ENV_PATH, upsertEnvKey } from './envManager.js';
8
+ import { bootstrapEnv } from './envBootstrap.js';
6
9
 
7
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
- const ENV_PATH = path.resolve(__dirname, '..', '..', '.env');
11
+
12
+ bootstrapEnv();
13
+
14
+ const execFileAsync = promisify(execFile);
15
+ const getPrimaryEnvPath = () => (fs.existsSync(PACKAGE_ENV_PATH) ? PACKAGE_ENV_PATH : USER_ENV_PATH);
9
16
  const AUTH_CONFIG_PATH = path.join(KIKU_DATA_DIR, 'yt_auth.json');
10
- const SECURE_COOKIE_PATH = path.join(KIKU_DATA_DIR, 'cookies.txt');
11
- loadEnv();
12
- const YTDLP_CMD = process.env.KIKU_YTDLP || 'yt-dlp';
17
+ const COOKIE_FILE_PATH = path.join(KIKU_DATA_DIR, 'cookies.txt');
18
+
19
+ const YTDLP_CMD = () => process.env.KIKU_YTDLP || 'yt-dlp';
13
20
 
14
21
 
15
22
  const BROWSERS = ['firefox', 'chrome', 'edge', 'brave'];
23
+ let browserDetectionCache = null;
16
24
 
17
- function findBrowserExe(browser) {
25
+ async function findBrowserExeAsync(browser, { execImpl = execFileAsync } = {}) {
18
26
  const isWin = process.platform === 'win32';
19
27
  const probes = {
20
28
  firefox: {
21
- cmd: isWin ? 'firefox' : 'firefox',
29
+ cmd: 'firefox',
22
30
  paths: [
23
31
  'C:\\Program Files\\Mozilla Firefox\\firefox.exe',
24
32
  'C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe',
@@ -53,7 +61,7 @@ function findBrowserExe(browser) {
53
61
  const p = probes[browser];
54
62
  if (!p) return null;
55
63
  try {
56
- execSync(isWin ? `where ${p.cmd}` : `which ${p.cmd}`, { stdio: 'ignore', windowsHide: true });
64
+ await execImpl(isWin ? 'where' : 'which', [p.cmd], { windowsHide: true, timeout: 1500 });
57
65
  return p.cmd;
58
66
  } catch {}
59
67
 
@@ -65,12 +73,18 @@ function findBrowserExe(browser) {
65
73
  return null;
66
74
  }
67
75
 
68
- function detectBrowsers() {
69
- return BROWSERS.filter(b => findBrowserExe(b) !== null);
76
+ async function detectBrowsersCached({ execImpl } = {}) {
77
+ if (browserDetectionCache) return browserDetectionCache;
78
+ const found = [];
79
+ for (const b of BROWSERS) {
80
+ if (await findBrowserExeAsync(b, { execImpl })) found.push(b);
81
+ }
82
+ browserDetectionCache = found;
83
+ return found;
70
84
  }
71
85
 
72
- function openBrowser(url, browser) {
73
- const exe = browser ? findBrowserExe(browser) : null;
86
+ async function openBrowser(url, browser) {
87
+ const exe = browser ? await findBrowserExeAsync(browser) : null;
74
88
  const isWin = process.platform === 'win32';
75
89
  try {
76
90
  if (exe) {
@@ -105,8 +119,7 @@ function writeAuthConfig(updates) {
105
119
  } catch {}
106
120
  }
107
121
 
108
- function loadEnv() {
109
-
122
+ function refreshYtAuthEnv() {
110
123
  const userConfig = readAuthConfig();
111
124
  if (userConfig.KIKU_YT_BROWSER && process.env.KIKU_YT_BROWSER === undefined) {
112
125
  process.env.KIKU_YT_BROWSER = userConfig.KIKU_YT_BROWSER;
@@ -114,62 +127,40 @@ function loadEnv() {
114
127
  if (userConfig.KIKU_YT_COOKIES && process.env.KIKU_YT_COOKIES === undefined) {
115
128
  process.env.KIKU_YT_COOKIES = userConfig.KIKU_YT_COOKIES;
116
129
  }
117
-
118
-
119
- try {
120
- if (!fs.existsSync(ENV_PATH)) return;
121
- const text = fs.readFileSync(ENV_PATH, 'utf8');
122
- for (const line of text.split('\n')) {
123
- const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
124
- if (!m) continue;
125
- const key = m[1].trim();
126
- const val = m[2].trim().replace(/^["']|["']$/g, '');
127
- if (process.env[key] === undefined) process.env[key] = val;
128
- }
129
- } catch {}
130
- }
131
-
132
- function readEnvMap() {
133
- const map = {};
134
- try {
135
- if (!fs.existsSync(ENV_PATH)) return map;
136
- const text = fs.readFileSync(ENV_PATH, 'utf8');
137
- for (const line of text.split('\n')) {
138
- const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
139
- if (m) map[m[1]] = m[2].replace(/^["']|["']$/g, '');
140
- }
141
- } catch {}
142
- return map;
143
130
  }
144
131
 
145
132
  function writeEnv(updates) {
146
-
147
- for (const [k, v] of Object.entries(updates)) {
148
- process.env[k] = v;
133
+ const cleanUpdates = {};
134
+ for (const [k, v] of Object.entries(updates || {})) {
135
+ const cleanKey = String(k || '').replace(/[\r\n]+/g, '').trim();
136
+ const cleanVal = String(v ?? '').replace(/[\r\n]+/g, '').trim();
137
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(cleanKey)) {
138
+ cleanUpdates[cleanKey] = cleanVal;
139
+ process.env[cleanKey] = cleanVal;
140
+ }
149
141
  }
150
- writeAuthConfig(updates);
142
+ writeAuthConfig(cleanUpdates);
151
143
 
152
-
153
144
  try {
154
- const map = readEnvMap();
155
- Object.assign(map, updates);
156
- const out = Object.entries(map).map(([k, v]) => `${k}=${v}`).join('\n') + '\n';
157
- fs.writeFileSync(ENV_PATH, out, 'utf8');
145
+ const envFile = getPrimaryEnvPath();
146
+ for (const [key, value] of Object.entries(cleanUpdates)) {
147
+ upsertEnvKey(envFile, key, value);
148
+ }
158
149
  } catch {}
159
150
  }
160
151
 
161
- function hasSession(browser) {
152
+ function hasSession(browser, { timeoutMs = 6000 } = {}) {
162
153
  return new Promise((resolve) => {
163
- const timeoutMs = 8000;
154
+ const probeUrl = process.env.KIKU_YT_PROBE_URL || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
164
155
  const args = [
165
156
  '--cookies-from-browser', browser,
166
157
  '--print', '%(title)s',
167
158
  '--no-warnings',
168
159
  '--no-playlist',
169
160
  '--no-check-certificates',
170
- 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
161
+ probeUrl
171
162
  ];
172
- const p = spawn(YTDLP_CMD, args, {
163
+ const p = spawn(YTDLP_CMD(), args, {
173
164
  windowsHide: true,
174
165
  stdio: ['ignore', 'pipe', 'pipe']
175
166
  });
@@ -182,17 +173,18 @@ function hasSession(browser) {
182
173
  if (!resolved) {
183
174
  resolved = true;
184
175
  try { p.kill('SIGKILL'); } catch {}
185
- resolve(false);
176
+ resolve({ ok: false, reason: 'timeout' });
186
177
  }
187
178
  }, timeoutMs);
188
179
 
189
180
  p.stdout?.on('data', d => { out += d.toString(); });
190
181
  p.stderr?.on('data', d => { err += d.toString(); });
191
- p.on('error', () => {
182
+ p.on('error', (spawnErr) => {
192
183
  if (!resolved) {
193
184
  resolved = true;
194
185
  clearTimeout(timer);
195
- resolve(false);
186
+ const missing = spawnErr?.code === 'ENOENT';
187
+ resolve({ ok: false, reason: missing ? 'yt-dlp-missing' : 'spawn-error', detail: spawnErr?.message });
196
188
  }
197
189
  });
198
190
  p.on('close', (code) => {
@@ -201,7 +193,11 @@ function hasSession(browser) {
201
193
  clearTimeout(timer);
202
194
  const title = out.trim();
203
195
  const blocked = /403|Forbidden|Sign in|confirm you'?re not a bot|Login Required|Private Video|database is locked/i.test(err);
204
- resolve(code === 0 && title.length > 0 && !blocked);
196
+ if (code === 0 && title.length > 0 && !blocked) {
197
+ resolve({ ok: true, reason: 'session-found' });
198
+ } else {
199
+ resolve({ ok: false, reason: blocked ? 'blocked' : 'no-session', detail: err.slice(0, 200) });
200
+ }
205
201
  });
206
202
  });
207
203
  }
@@ -248,18 +244,17 @@ export function isCookieFileValid(filePath) {
248
244
  }
249
245
 
250
246
  async function _ensureYouTubeCookiesImpl(opts = {}) {
251
- loadEnv();
247
+ refreshYtAuthEnv();
252
248
 
253
249
  const cookiesCandidate = (process.env.KIKU_YT_COOKIES && isCookieFileValid(process.env.KIKU_YT_COOKIES))
254
250
  ? process.env.KIKU_YT_COOKIES
255
- : (isCookieFileValid(SECURE_COOKIE_PATH) ? SECURE_COOKIE_PATH : null);
251
+ : (isCookieFileValid(COOKIE_FILE_PATH) ? COOKIE_FILE_PATH : null);
256
252
  if (cookiesCandidate && isCookieFileValid(cookiesCandidate)) {
257
253
  _ensured = { ok: true, source: 'file', cookiesPath: cookiesCandidate };
258
254
  return _ensured;
259
255
  }
260
256
 
261
-
262
- const available = detectBrowsers();
257
+ const available = await detectBrowsersCached();
263
258
  if (!available.length) {
264
259
  _ensured = { ok: false, reason: 'no-browser', detail: 'No supported browser found (Firefox, Chrome, Edge, Brave)' };
265
260
  _lastFailTime = Date.now();
@@ -271,12 +266,21 @@ async function _ensureYouTubeCookiesImpl(opts = {}) {
271
266
  ? [preferred, ...available.filter(b => b !== preferred)]
272
267
  : available;
273
268
 
269
+ let sawYtdlpMissing = false;
274
270
  for (const b of candidates) {
275
- if (await hasSession(b)) {
271
+ const probe = await hasSession(b, { timeoutMs: opts.probeTimeoutMs });
272
+ if (probe.ok) {
276
273
  writeEnv({ KIKU_YT_BROWSER: b });
277
274
  _ensured = { ok: true, source: 'existing-session', browser: b };
278
275
  return _ensured;
279
276
  }
277
+ if (probe.reason === 'yt-dlp-missing') sawYtdlpMissing = true;
278
+ }
279
+
280
+ if (sawYtdlpMissing) {
281
+ _ensured = { ok: false, reason: 'yt-dlp-missing', detail: `yt-dlp was not found (set KIKU_YTDLP in .env, e.g. ${YTDLP_CMD()})` };
282
+ _lastFailTime = Date.now();
283
+ return _ensured;
280
284
  }
281
285
 
282
286
 
@@ -310,7 +314,8 @@ async function _ensureYouTubeCookiesImpl(opts = {}) {
310
314
  }
311
315
 
312
316
  for (const b of available) {
313
- if (await hasSession(b)) {
317
+ const probe = await hasSession(b, { timeoutMs: opts.probeTimeoutMs });
318
+ if (probe.ok) {
314
319
  writeEnv({ KIKU_YT_BROWSER: b });
315
320
  if (process.stdout.isTTY) {
316
321
  process.stdout.write('\r' + ' '.repeat(65) + '\r');
@@ -335,9 +340,9 @@ async function _ensureYouTubeCookiesImpl(opts = {}) {
335
340
  }
336
341
 
337
342
  export async function exportCookiesToFile() {
338
- loadEnv();
343
+ refreshYtAuthEnv();
339
344
 
340
- const available = detectBrowsers();
345
+ const available = await detectBrowsersCached();
341
346
  const preferred = process.env.KIKU_YT_BROWSER;
342
347
  const candidates = preferred && available.includes(preferred)
343
348
  ? [preferred, ...available.filter(b => b !== preferred)]
@@ -348,18 +353,18 @@ export async function exportCookiesToFile() {
348
353
  fs.mkdirSync(KIKU_DATA_DIR, { recursive: true });
349
354
  const args = [
350
355
  '--cookies-from-browser', b,
351
- '--cookies', SECURE_COOKIE_PATH,
356
+ '--cookies', COOKIE_FILE_PATH,
352
357
  '--no-warnings',
353
358
  '--skip-download',
354
- 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
359
+ process.env.KIKU_YT_PROBE_URL || 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
355
360
  ];
356
361
  await new Promise((resolve, reject) => {
357
- const p = spawn(YTDLP_CMD, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] });
362
+ const p = spawn(YTDLP_CMD(), args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] });
358
363
  let err = '';
359
364
  p.stderr?.on('data', d => { err += d.toString(); });
360
365
  p.on('error', reject);
361
366
  p.on('close', code => {
362
- if (code === 0 && fs.existsSync(SECURE_COOKIE_PATH)) {
367
+ if (code === 0 && fs.existsSync(COOKIE_FILE_PATH)) {
363
368
  resolve();
364
369
  } else {
365
370
  reject(new Error(err || `yt-dlp exited ${code}`));
@@ -367,8 +372,11 @@ export async function exportCookiesToFile() {
367
372
  });
368
373
  });
369
374
 
370
- writeEnv({ KIKU_YT_COOKIES: SECURE_COOKIE_PATH });
371
- return { ok: true, browser: b, cookiesPath: SECURE_COOKIE_PATH };
375
+ if (process.platform !== 'win32') {
376
+ try { fs.chmodSync(COOKIE_FILE_PATH, 0o600); } catch {}
377
+ }
378
+ writeEnv({ KIKU_YT_COOKIES: COOKIE_FILE_PATH });
379
+ return { ok: true, browser: b, cookiesPath: COOKIE_FILE_PATH };
372
380
  } catch {}
373
381
  }
374
382
 
@@ -384,12 +392,13 @@ export function resetCookieCheck() {
384
392
  export function getCookieStatus() {
385
393
  const envCookies = (process.env.KIKU_YT_COOKIES && isCookieFileValid(process.env.KIKU_YT_COOKIES))
386
394
  ? process.env.KIKU_YT_COOKIES
387
- : (isCookieFileValid(SECURE_COOKIE_PATH) ? SECURE_COOKIE_PATH : null);
395
+ : (isCookieFileValid(COOKIE_FILE_PATH) ? COOKIE_FILE_PATH : null);
388
396
  return {
389
397
  ensured: _ensured,
390
398
  envBrowser: process.env.KIKU_YT_BROWSER || null,
391
399
  envCookies,
392
- envPath: ENV_PATH,
400
+ envPath: getPrimaryEnvPath(),
401
+ userEnvPath: USER_ENV_PATH,
393
402
  authConfigPath: AUTH_CONFIG_PATH
394
403
  };
395
404
  }
@@ -0,0 +1,209 @@
1
+ import { spawn } from 'child_process';
2
+ import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { ensureYouTubeCookies, isCookieFileValid } from './youtubeAuth.js';
6
+
7
+ const YTDLP_CMD = process.env.KIKU_YTDLP || 'yt-dlp';
8
+
9
+ const BASE_SUB_LANGS = ['th', 'en', 'zh-Hans', 'zh', 'zh-Hant', 'zh-CN', 'zh-TW', 'en-US'];
10
+ const LANG_PREFERENCES = {
11
+ th: ['th', 'th-TH'],
12
+ en: ['en', 'en-US', 'en-GB'],
13
+ zh: ['zh-Hans', 'zh-CN', 'zh', 'zh-Hant', 'zh-TW'],
14
+ };
15
+
16
+ export function extractYouTubeVideoId(input) {
17
+ if (!input || typeof input !== 'string') return null;
18
+ const trimmed = input.trim();
19
+ if (/^[\w-]{11}$/.test(trimmed)) return trimmed;
20
+ const patterns = [
21
+ /youtube\.com\/watch\?(?:[^#]*&)?v=([\w-]{11})/i,
22
+ /youtu\.be\/([\w-]{11})/i,
23
+ /youtube\.com\/shorts\/([\w-]{11})/i,
24
+ /youtube\.com\/embed\/([\w-]{11})/i,
25
+ /youtube\.com\/live\/([\w-]{11})/i,
26
+ ];
27
+ for (const re of patterns) {
28
+ const match = trimmed.match(re);
29
+ if (match) return match[1];
30
+ }
31
+ return null;
32
+ }
33
+
34
+
35
+ export function parseSubtitleToText(content) {
36
+ if (!content || typeof content !== 'string') return '';
37
+ const text = content.startsWith('\uFEFF') ? content.slice(1) : content;
38
+ const lines = text.split(/\r?\n/);
39
+
40
+ const out = [];
41
+ const seen = new Set();
42
+ for (let i = 0; i < lines.length; i++) {
43
+ let l = lines[i].trim();
44
+ if (!l) continue;
45
+ if (/^WEBVTT/i.test(l) || /^(NOTE|STYLE|REGION)\b/i.test(l)) continue;
46
+ if (l.includes('-->')) continue;
47
+
48
+ if (/^\d+$/.test(l)) {
49
+ let j = i + 1;
50
+ while (j < lines.length && !lines[j].trim()) j++;
51
+ if (j < lines.length && lines[j].includes('-->')) continue;
52
+ }
53
+ l = l.replace(/<[^>]+>/g, '');
54
+ l = l
55
+ .replace(/&amp;/g, '&')
56
+ .replace(/&#39;/g, "'")
57
+ .replace(/&apos;/g, "'")
58
+ .replace(/&quot;/g, '"')
59
+ .replace(/&gt;/g, '>')
60
+ .replace(/&lt;/g, '<')
61
+ .replace(/&nbsp;/g, ' ');
62
+ if (!l) continue;
63
+ const key = l.toLowerCase();
64
+ if (seen.has(key)) continue;
65
+ seen.add(key);
66
+ out.push(l);
67
+ }
68
+ return out.join(' ');
69
+ }
70
+
71
+ async function resolveCookieArgs() {
72
+ if (process.env.KIKU_YT_COOKIES && isCookieFileValid(process.env.KIKU_YT_COOKIES)) {
73
+ return ['--cookies', process.env.KIKU_YT_COOKIES];
74
+ }
75
+ if (process.env.KIKU_YT_BROWSER) {
76
+ return ['--cookies-from-browser', process.env.KIKU_YT_BROWSER];
77
+ }
78
+ try {
79
+ const status = await ensureYouTubeCookies({ silent: true });
80
+ if (status?.ok) {
81
+ if (status.cookiesPath) return ['--cookies', status.cookiesPath];
82
+ if (status.browser) return ['--cookies-from-browser', status.browser];
83
+ }
84
+ } catch { }
85
+ return [];
86
+ }
87
+
88
+ function runYtDlp(args, timeoutMs) {
89
+ return new Promise((resolve, reject) => {
90
+ const proc = spawn(YTDLP_CMD, args, { windowsHide: true });
91
+ let stdout = '';
92
+ let stderr = '';
93
+ const timer = setTimeout(() => {
94
+ try { proc.kill('SIGKILL'); } catch { }
95
+ reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
96
+ }, timeoutMs);
97
+ proc.stdout.on('data', d => { stdout += d.toString(); });
98
+ proc.stderr.on('data', d => { stderr += d.toString(); });
99
+ proc.on('error', err => { clearTimeout(timer); reject(err); });
100
+ proc.on('close', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
101
+ });
102
+ }
103
+
104
+ function listSubtitleFiles(dir) {
105
+ try {
106
+ return readdirSync(dir)
107
+ .map(name => {
108
+ const match = name.match(/^transcript\.(.+)\.(vtt|srt)$/i);
109
+ return match ? { file: join(dir, name), lang: match[1] } : null;
110
+ })
111
+ .filter(Boolean);
112
+ } catch {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ function pickBestSubtitleFile(files, prefs) {
118
+ const score = (entry) => {
119
+ const lang = entry.lang.toLowerCase();
120
+ let best = prefs.length > 0 ? 100 : 0;
121
+ prefs.forEach((p, i) => {
122
+ const pLower = p.toLowerCase();
123
+ if (lang === pLower) best = Math.min(best, i);
124
+ else if (lang.startsWith(pLower)) best = Math.min(best, i + 0.5);
125
+ });
126
+ if (/(^|\.)orig$/i.test(lang) || /-orig$/i.test(lang)) best += 10;
127
+ return best;
128
+ };
129
+ return [...files].sort((a, b) => score(a) - score(b))[0];
130
+ }
131
+
132
+ export async function fetchYoutubeTranscript({ url, videoId, lang, maxChars } = {}) {
133
+ const id = videoId ? String(videoId).trim() : extractYouTubeVideoId(url);
134
+ if (!id || !/^[\w-]{11}$/.test(id)) {
135
+ return {
136
+ status: 'error',
137
+ message: 'Provide a valid YouTube URL (watch/youtu.be/shorts/embed/live) or an 11-character videoId.',
138
+ };
139
+ }
140
+
141
+ const dir = mkdtempSync(join(tmpdir(), 'kiku-transcript-'));
142
+ try {
143
+ const prefs = LANG_PREFERENCES[String(lang || '').toLowerCase()] || [];
144
+ const subLangs = [...new Set([...prefs, ...BASE_SUB_LANGS])].join(',');
145
+ const baseArgs = [
146
+ '--skip-download', '--no-warnings', '--quiet',
147
+ '--write-subs', '--write-auto-subs',
148
+ '--sub-format', 'vtt/srt/best',
149
+ '--sub-langs', subLangs,
150
+ '-o', join(dir, 'transcript.%(ext)s'),
151
+ ];
152
+ const watchUrl = `https://www.youtube.com/watch?v=${id}`;
153
+
154
+ const cookieArgs = await resolveCookieArgs();
155
+ let attempt = { code: 1, stdout: '', stderr: '' };
156
+ try {
157
+ attempt = await runYtDlp([...baseArgs, ...cookieArgs, watchUrl], 60_000);
158
+ } catch (err) {
159
+ attempt = { code: 1, stdout: '', stderr: err.message };
160
+ }
161
+
162
+ let files = listSubtitleFiles(dir);
163
+ if (files.length === 0 && cookieArgs.length > 0) {
164
+ try {
165
+ attempt = await runYtDlp([...baseArgs, watchUrl], 60_000);
166
+ } catch (err) {
167
+ attempt = { code: 1, stdout: '', stderr: err.message };
168
+ }
169
+ files = listSubtitleFiles(dir);
170
+ }
171
+
172
+ if (files.length === 0) {
173
+ const blocked = /sign in|confirm you|cookies|age/i.test(attempt.stderr || '');
174
+ return {
175
+ status: 'not_found',
176
+ videoId: id,
177
+ url: watchUrl,
178
+ message: blocked
179
+ ? `YouTube refused the caption request for ${id} (sign-in/cookies or age gate). Set KIKU_YT_COOKIES or KIKU_YT_BROWSER to enable authenticated caption downloads.`
180
+ : `No captions are available for this video (${id}), or yt-dlp could not reach it. Verify the video exists and has captions enabled.`,
181
+ };
182
+ }
183
+
184
+ const best = pickBestSubtitleFile(files, prefs);
185
+ const text = parseSubtitleToText(readFileSync(best.file, 'utf8'));
186
+ if (!text) {
187
+ return {
188
+ status: 'not_found',
189
+ videoId: id,
190
+ url: watchUrl,
191
+ message: `Captions were downloaded for ${id} but contained no readable text.`,
192
+ };
193
+ }
194
+
195
+ const max = Math.max(500, Math.min(Number(maxChars) || 6000, 20000));
196
+ const truncated = text.length > max;
197
+ return {
198
+ status: 'success',
199
+ videoId: id,
200
+ url: watchUrl,
201
+ lang: best.lang,
202
+ charCount: text.length,
203
+ truncated,
204
+ text: truncated ? `${text.slice(0, max)}…[truncated]` : text,
205
+ };
206
+ } finally {
207
+ try { rmSync(dir, { recursive: true, force: true }); } catch { }
208
+ }
209
+ }