@lincoln504/pi-research 0.1.12 → 1.0.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 (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -115
  3. package/dist/cli.mjs +23211 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +102 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +213 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +113 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +64 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1044 -0
  30. package/src/config.ts +1036 -147
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +264 -7
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -174
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -577
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -0,0 +1,259 @@
1
+ /**
2
+ * YouTube PoToken minting (BotGuard / Proof-of-Origin)
3
+ *
4
+ * YouTube's 2026 BotGuard attestation requires a Proof-of-Origin Token (PoToken)
5
+ * for caption/transcript requests. Without one, the timedtext endpoint returns
6
+ * HTTP 200 with an EMPTY body (a silent failure). This module mints tokens with
7
+ * `bgutils-js`, running the BotGuard VM under a `jsdom` DOM.
8
+ *
9
+ * Hard-won implementation details (validated live against youtubei.js@17 /
10
+ * bgutils-js@3 / jsdom@29):
11
+ *
12
+ * 1. Cross-realm typed arrays. The BotGuard VM must run in the SAME JS realm as
13
+ * bgutils' `base64ToU8` (the Node realm). So the interpreter is eval'd via
14
+ * `new Function(interp)()` in the Node realm and jsdom's browser globals
15
+ * (window/document/…) are bridged onto Node `globalThis`. Running the VM in
16
+ * jsdom's own realm makes the minter factory return a non-function
17
+ * ("APF:Failed") because of an `instanceof Uint8Array` realm mismatch.
18
+ *
19
+ * 2. The VM references `window` during EVERY mint call (not just setup), so we
20
+ * mint all identifiers (the session visitorData + every video ID) up front
21
+ * while the realm is bridged, then unbridge and close the DOM.
22
+ *
23
+ * 3. Mutating `globalThis` is process-global, so the whole bridged section is
24
+ * serialized behind a module-level async mutex to prevent concurrent
25
+ * researchers from racing on the shared globals.
26
+ *
27
+ * The mutex only guards youtube-vs-youtube. The one other main-process
28
+ * consumer that feature-detects `window` is @huggingface/transformers, which
29
+ * computes `IS_BROWSER_ENV = typeof window !== 'undefined' && …` ONCE at
30
+ * import time and freezes it (Object.freeze). That import is forced — and the
31
+ * constant frozen as NODE — before any mint can bridge: the deep orchestrator
32
+ * awaits `getService(KNOWLEDGE_STORE)` (→ static transformers import) in its
33
+ * pre-research phase before researchers spawn, and quick mode runs a single
34
+ * agent whose tools never overlap. So the bridge cannot flip the embedder's
35
+ * environment. If you ever make the store load lazily AFTER gathering begins,
36
+ * or make quick mode multi-agent, pre-warm the transformers import explicitly
37
+ * or this invariant breaks.
38
+ *
39
+ * 4. Content binding. timedtext needs a token minted with `identifier =
40
+ * videoId`; the session token (identifier = visitorData) is only used to
41
+ * create the Innertube session. We mint both from a single challenge.
42
+ */
43
+
44
+ import { logger } from '../logger.ts';
45
+ import { metrics } from '../utils/metrics.ts';
46
+
47
+ /**
48
+ * Well-known request key for the YouTube web PoToken program. This is a public
49
+ * constant baked into the YouTube web player; it is overridable via env in case
50
+ * YouTube rotates it (see config YOUTUBE_POTOKEN_REQUEST_KEY).
51
+ */
52
+ export const DEFAULT_REQUEST_KEY = 'O43z0dpjhgX20SCx4KAo';
53
+
54
+ /** Browser globals the BotGuard VM probes; bridged from jsdom onto globalThis. */
55
+ const BRIDGED_GLOBALS = ['window', 'document', 'location', 'navigator', 'origin'] as const;
56
+
57
+ export interface MintOptions {
58
+ /** Override the PoToken program request key (defaults to the web key). */
59
+ requestKey?: string;
60
+ /** Fetch implementation (defaults to global fetch); lets callers route/proxy. */
61
+ fetchImpl?: typeof fetch;
62
+ /** Cancellation signal — aborts the BotGuard challenge/integrity network calls. */
63
+ signal?: AbortSignal;
64
+ }
65
+
66
+ /** A bgutils-js error carrying a `.code` (e.g. VM_ERROR, INTEGRITY_ERROR). */
67
+ function describeError(error: unknown): string {
68
+ if (error && typeof error === 'object') {
69
+ const e = error as { code?: string; message?: string };
70
+ if (e.code && e.message) return `${e.code}: ${e.message}`;
71
+ if (e.message) return e.message;
72
+ }
73
+ return String(error);
74
+ }
75
+
76
+ // Module-level mutex: serialize all globalThis mutation across concurrent callers.
77
+ let mintChain: Promise<unknown> = Promise.resolve();
78
+
79
+ /**
80
+ * Mint a session-bound token (for `identifiers[0]`, the visitorData) plus one
81
+ * content-bound token per remaining identifier (the video IDs), all from a
82
+ * single BotGuard challenge. Returns a map keyed by identifier.
83
+ *
84
+ * Throws on challenge/attestation/integrity failure — the caller decides how to
85
+ * degrade (typically: mark every video's transcript as unavailable).
86
+ */
87
+ export async function mintPoTokens(
88
+ identifiers: string[],
89
+ opts: MintOptions = {},
90
+ ): Promise<Map<string, string>> {
91
+ // Chain on the mutex so only one bridged section runs at a time.
92
+ const run = mintChain.then(() => doMint(identifiers, opts), () => doMint(identifiers, opts));
93
+ // Keep the chain alive regardless of this call's success/failure.
94
+ mintChain = run.then(() => undefined, () => undefined);
95
+ return run;
96
+ }
97
+
98
+ async function doMint(
99
+ identifiers: string[],
100
+ opts: MintOptions,
101
+ ): Promise<Map<string, string>> {
102
+ if (identifiers.length === 0) return new Map();
103
+
104
+ const requestKey = opts.requestKey || process.env['PI_RESEARCH_YOUTUBE_POTOKEN_REQUEST_KEY'] || DEFAULT_REQUEST_KEY;
105
+ const fetchImpl = opts.fetchImpl ?? fetch;
106
+ const started = Date.now();
107
+
108
+ // Lazy-load heavy deps — never imported at module load (keeps startup native-free).
109
+ const bgMod = (await import('bgutils-js')) as unknown as BgModule;
110
+ const BG = bgMod.BG ?? bgMod.default;
111
+ const { buildURL, getHeaders } = bgMod;
112
+ const { JSDOM } = await import('jsdom');
113
+
114
+ const dom = new JSDOM('<!DOCTYPE html><html lang="en"><body></body></html>', {
115
+ url: 'https://www.youtube.com/',
116
+ });
117
+
118
+ const savedDescriptors: Record<string, PropertyDescriptor | undefined> = {};
119
+ const bridge = () => {
120
+ for (const key of BRIDGED_GLOBALS) {
121
+ savedDescriptors[key] = Object.getOwnPropertyDescriptor(globalThis, key);
122
+ try {
123
+ Object.defineProperty(globalThis, key, {
124
+ value: (dom.window as unknown as Record<string, unknown>)[key],
125
+ configurable: true,
126
+ writable: true,
127
+ });
128
+ } catch {
129
+ // Some globals (e.g. navigator) may be non-configurable on certain
130
+ // runtimes; skip — the VM only needs the ones it can read.
131
+ }
132
+ }
133
+ };
134
+ const unbridge = () => {
135
+ for (const key of BRIDGED_GLOBALS) {
136
+ try {
137
+ const saved = savedDescriptors[key];
138
+ if (saved) Object.defineProperty(globalThis, key, saved);
139
+ else delete (globalThis as Record<string, unknown>)[key];
140
+ } catch {
141
+ // Best-effort restore: never let one non-restorable global abort the
142
+ // loop (which would leave later globals dirty and skip DOM teardown).
143
+ }
144
+ }
145
+ };
146
+
147
+ bridge();
148
+ try {
149
+ const bgConfig: BgConfig = {
150
+ fetch: (input: unknown, init?: unknown) =>
151
+ fetchImpl(input as Parameters<typeof fetch>[0], {
152
+ ...(init as RequestInit | undefined),
153
+ signal: (init as RequestInit | undefined)?.signal ?? opts.signal,
154
+ }),
155
+ globalObj: globalThis,
156
+ identifier: identifiers[0]!,
157
+ requestKey,
158
+ };
159
+
160
+ const challenge = await BG.Challenge.create(bgConfig);
161
+ if (!challenge) throw new Error('BotGuard challenge unavailable');
162
+
163
+ const interpreter = challenge.interpreterJavascript?.privateDoNotAccessOrElseSafeScriptWrappedValue;
164
+ if (!interpreter) throw new Error('BotGuard interpreter script missing');
165
+ // Eval in the Node realm so the VM and bgutils share one Uint8Array realm.
166
+ // The interpreter is YouTube's BotGuard VM, fetched over HTTPS from Google's
167
+ // attestation endpoint by bgutils — running it is required and inherent to
168
+ // PoToken generation; trust rests on that TLS connection.
169
+ new Function(interpreter)();
170
+
171
+ const botguard = await BG.BotGuardClient.create({
172
+ program: challenge.program,
173
+ globalName: challenge.globalName,
174
+ globalObj: globalThis,
175
+ });
176
+
177
+ const webPoSignalOutput: unknown[] = [];
178
+ const botguardResponse = await botguard.snapshot({ webPoSignalOutput });
179
+
180
+ const integrityResponse = await fetchImpl(buildURL('GenerateIT', false), {
181
+ method: 'POST',
182
+ headers: getHeaders(),
183
+ body: JSON.stringify([requestKey, botguardResponse]),
184
+ signal: opts.signal,
185
+ });
186
+ const integrityJson = (await integrityResponse.json()) as unknown[];
187
+ const integrityToken = integrityJson?.[0] as string | undefined;
188
+ if (!integrityToken) {
189
+ throw new Error('Integrity token rejected by attestation server (likely a non-residential IP)');
190
+ }
191
+
192
+ const minter = await BG.WebPoMinter.create({ integrityToken }, webPoSignalOutput);
193
+
194
+ const tokens = new Map<string, string>();
195
+ for (const id of identifiers) {
196
+ tokens.set(id, await minter.mintAsWebsafeString(id));
197
+ }
198
+
199
+ metrics.observe('youtube_potoken_mint_duration_ms', Date.now() - started, { status: 'success' });
200
+ metrics.increment('youtube_potoken_mints_total', tokens.size, { status: 'success' });
201
+ logger.log(`[youtube] Minted ${tokens.size} PoToken(s) in ${Date.now() - started}ms`);
202
+ return tokens;
203
+ } catch (error) {
204
+ metrics.increment('youtube_potoken_mints_total', 1, { status: 'error' });
205
+ logger.warn(`[youtube] PoToken minting failed: ${describeError(error)}`);
206
+ throw error instanceof Error ? error : new Error(describeError(error));
207
+ } finally {
208
+ // Always restore globals and tear down the DOM, even if unbridge throws.
209
+ try {
210
+ unbridge();
211
+ } catch {
212
+ // unbridge is already per-key best-effort; this guards the loop itself.
213
+ }
214
+ try {
215
+ dom.window.close();
216
+ } catch {
217
+ // best-effort teardown
218
+ }
219
+ }
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Minimal structural types for the loosely-typed bgutils-js surface we touch.
224
+ // ---------------------------------------------------------------------------
225
+
226
+ interface BgConfig {
227
+ fetch: (input: unknown, init?: unknown) => Promise<Response>;
228
+ globalObj: unknown;
229
+ identifier: string;
230
+ requestKey: string;
231
+ }
232
+
233
+ interface BgNamespace {
234
+ Challenge: {
235
+ create(config: BgConfig): Promise<{
236
+ interpreterJavascript?: { privateDoNotAccessOrElseSafeScriptWrappedValue?: string };
237
+ program: string;
238
+ globalName: string;
239
+ } | undefined>;
240
+ };
241
+ BotGuardClient: {
242
+ create(opts: { program: string; globalName: string; globalObj: unknown }): Promise<{
243
+ snapshot(args: { webPoSignalOutput: unknown[] }): Promise<string>;
244
+ }>;
245
+ };
246
+ WebPoMinter: {
247
+ create(
248
+ integrity: { integrityToken: string },
249
+ webPoSignalOutput: unknown[],
250
+ ): Promise<{ mintAsWebsafeString(identifier: string): Promise<string> }>;
251
+ };
252
+ }
253
+
254
+ interface BgModule {
255
+ BG?: BgNamespace;
256
+ default: BgNamespace;
257
+ buildURL(endpoint: string, useYouTubeAPI?: boolean): string;
258
+ getHeaders(): Record<string, string>;
259
+ }
@@ -0,0 +1,390 @@
1
+ /**
2
+ * YouTube transcript client
3
+ *
4
+ * Fetches transcripts for a batch of video IDs using youtubei.js (InnerTube) for
5
+ * metadata + caption-track discovery and a direct timedtext request for the
6
+ * caption content, authenticated with a content-bound PoToken.
7
+ *
8
+ * Design notes:
9
+ * - `getTranscript()` (the get_transcript endpoint) is deliberately NOT used: it
10
+ * returns 400 FAILED_PRECONDITION even with a valid PoToken. The caption track
11
+ * (timedtext, fmt=json3) is fetched directly instead.
12
+ * - Empty-body guard: the timedtext endpoint returns HTTP 200 with an empty body
13
+ * when the PoToken is missing/invalid. Empty or whitespace-only transcripts are
14
+ * treated as failures and never surfaced as blank successes.
15
+ * - Per-video isolation: one failed/unavailable video never aborts the batch.
16
+ * - All network work is bounded by a per-video timeout and guarded by a shared
17
+ * circuit breaker so a YouTube outage fails fast instead of hanging researchers.
18
+ */
19
+
20
+ import { withTimeout, retryWithBackoff, createTimeoutSignal } from '../web-research/retry-utils.ts';
21
+ import { CircuitBreaker } from '../utils/circuit-breaker.ts';
22
+ import { logger } from '../logger.ts';
23
+ import { metrics } from '../utils/metrics.ts';
24
+ import { mintPoTokens } from './potoken.ts';
25
+ import { watchUrl } from './video-id.ts';
26
+
27
+ export interface VideoTranscript {
28
+ videoId: string;
29
+ url: string;
30
+ success: boolean;
31
+ title?: string;
32
+ author?: string;
33
+ durationSeconds?: number;
34
+ lang?: string;
35
+ text?: string;
36
+ charCount?: number;
37
+ error?: string;
38
+ }
39
+
40
+ export interface FetchTranscriptsOptions {
41
+ /** Preferred caption language (BCP-47 prefix, e.g. 'en'). Defaults to 'en'. */
42
+ lang?: string;
43
+ /** Per-video timeout in milliseconds. Defaults to 20000. */
44
+ timeoutMs?: number;
45
+ /** PoToken request key override. */
46
+ requestKey?: string;
47
+ /** Caller cancellation signal. */
48
+ signal?: AbortSignal;
49
+ /** Fires as each video completes (success or failure) — used for TUI flashes. */
50
+ onVideoComplete?: (videoId: string, success: boolean) => void;
51
+ }
52
+
53
+ // Shared breaker: a sustained YouTube/attestation outage trips it so subsequent
54
+ // researchers fail fast rather than each waiting out the full timeout.
55
+ const breaker = new CircuitBreaker({
56
+ failureThreshold: 5,
57
+ resetTimeoutMs: 30000,
58
+ name: 'YouTube transcript',
59
+ isTransientError: (err) => {
60
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
61
+ return (
62
+ msg.includes('timeout') ||
63
+ // withTimeout() rejects with "...cancelled or timed out" (no contiguous "timeout"),
64
+ // so without this the breaker never counted a genuine YouTube hang toward its
65
+ // failure threshold and every researcher paid the full timeout during an outage.
66
+ msg.includes('timed out') ||
67
+ msg.includes('network') ||
68
+ msg.includes('econn') ||
69
+ msg.includes('fetch failed') ||
70
+ msg.includes('429') ||
71
+ msg.includes('500') ||
72
+ msg.includes('502') ||
73
+ msg.includes('503')
74
+ );
75
+ },
76
+ });
77
+
78
+ interface CaptionTrack {
79
+ base_url: string;
80
+ language_code?: string;
81
+ kind?: string;
82
+ }
83
+
84
+ /**
85
+ * Fetch transcripts for up to a handful of video IDs. Always resolves with one
86
+ * result per input ID (never throws for individual-video problems); only a
87
+ * total infrastructure failure (e.g. youtubei.js unavailable) rejects.
88
+ */
89
+ export async function fetchVideoTranscripts(
90
+ videoIds: string[],
91
+ opts: FetchTranscriptsOptions = {},
92
+ ): Promise<VideoTranscript[]> {
93
+ const lang = opts.lang || 'en';
94
+ const timeoutMs = opts.timeoutMs ?? 20000;
95
+ const signal = opts.signal;
96
+
97
+ if (videoIds.length === 0) return [];
98
+
99
+ // Lazy-load youtubei.js — never imported at module load (keeps startup native-free).
100
+ const yt = await import('youtubei.js');
101
+ const { Innertube } = yt;
102
+ // Silence youtubei.js's own console logging — it writes parser warnings
103
+ // straight to the console, which would corrupt the TUI (stdout is the UI).
104
+ try {
105
+ const log = (yt as unknown as { Log?: { setLevel?: (n: number) => void; Level?: { NONE: number } } }).Log;
106
+ if (log?.setLevel && log.Level) log.setLevel(log.Level.NONE);
107
+ } catch {
108
+ // non-fatal: logging stays at default if the API ever changes
109
+ }
110
+ const fetchImpl: typeof fetch = (input, init) => fetch(input, init);
111
+
112
+ // 1) Throwaway session to obtain stable visitor data for the PoToken binding.
113
+ let visitorData: string;
114
+ try {
115
+ // Route the once-per-batch seed session through the same breaker as fetchOne: the
116
+ // seed + mint are the steps most prone to a SYSTEMIC outage (BotGuard/attestation
117
+ // down, non-residential IP), and without the breaker every batch paid the full
118
+ // timeout on each one. Only transient errors count toward the threshold (the breaker
119
+ // gates on isTransientError), so a permanent "no residential IP" failure won't trip it.
120
+ const seed = await breaker.execute(() => withTimeout(
121
+ Innertube.create({ retrieve_player: false }),
122
+ timeoutMs,
123
+ 'youtube:create-seed-session',
124
+ signal,
125
+ ));
126
+ visitorData = seed.session.context?.client?.visitorData ?? '';
127
+ } catch (error) {
128
+ logger.warn(`[youtube] Failed to create seed session: ${msg(error)}`);
129
+ return videoIds.map((videoId) => failure(videoId, 'Could not initialize YouTube session.'));
130
+ }
131
+ if (!visitorData) {
132
+ return videoIds.map((videoId) => failure(videoId, 'YouTube did not return visitor data.'));
133
+ }
134
+
135
+ // 2) Mint a session token + one content-bound token per video, all at once.
136
+ // Bounded by withTimeout + the caller's signal so a hung BotGuard challenge
137
+ // can never stall the researcher.
138
+ let tokens: Map<string, string>;
139
+ try {
140
+ // Pass a timeout-combined signal INTO the mint, not just to withTimeout's wrapper. doMint
141
+ // threads opts.signal into its BotGuard challenge/integrity fetches, so this makes the timeout
142
+ // actually abort an in-flight mint and release the process-wide mintChain mutex. Previously the
143
+ // raw researcher signal was passed, so on timeout withTimeout rejected the wrapper while doMint
144
+ // kept running and held the mutex forever — one stalled Google socket then hung every later
145
+ // YouTube mint in the run (each surfacing as a 20s "token unavailable").
146
+ const mintSignal = createTimeoutSignal(timeoutMs, signal);
147
+ tokens = await breaker.execute(() => withTimeout(
148
+ mintPoTokens([visitorData, ...videoIds], { requestKey: opts.requestKey, fetchImpl, signal: mintSignal }),
149
+ timeoutMs,
150
+ 'youtube:mint',
151
+ signal,
152
+ ));
153
+ } catch (error) {
154
+ // Without a PoToken, timedtext silently returns empty — so degrade honestly:
155
+ // report every video as unavailable with the real reason.
156
+ const reason = `Bot-protection token unavailable (${msg(error)}). YouTube transcripts require a residential IP.`;
157
+ return videoIds.map((videoId) => failure(videoId, reason));
158
+ }
159
+
160
+ const sessionToken = tokens.get(visitorData);
161
+ if (!sessionToken) {
162
+ return videoIds.map((videoId) => failure(videoId, 'Failed to mint a YouTube session token.'));
163
+ }
164
+
165
+ // 3) Real session, bound to the session token + visitor data, with player
166
+ // retrieval enabled so caption tracks are present on getInfo().
167
+ let session: Awaited<ReturnType<typeof Innertube.create>>;
168
+ try {
169
+ session = await withTimeout(
170
+ Innertube.create({ po_token: sessionToken, visitor_data: visitorData, retrieve_player: true }),
171
+ timeoutMs,
172
+ 'youtube:create-session',
173
+ signal,
174
+ );
175
+ } catch (error) {
176
+ logger.warn(`[youtube] Failed to create authenticated session: ${msg(error)}`);
177
+ return videoIds.map((videoId) => failure(videoId, 'Could not initialize authenticated YouTube session.'));
178
+ }
179
+
180
+ // 4) Fetch each video's transcript independently (bounded + isolated).
181
+ const results = await Promise.all(
182
+ videoIds.map((videoId) =>
183
+ fetchOne(session, videoId, tokens.get(videoId), lang, timeoutMs, signal, fetchImpl)
184
+ .then((result) => {
185
+ // Guard the caller-supplied callback: a throwing callback must never
186
+ // reject Promise.all and break the never-throws contract.
187
+ try {
188
+ opts.onVideoComplete?.(videoId, result.success);
189
+ } catch {
190
+ // ignore callback errors
191
+ }
192
+ return result;
193
+ }),
194
+ ),
195
+ );
196
+
197
+ // youtubei.js Innertube sessions are plain GC'd objects with no disposer and
198
+ // no per-session sockets (Node fetch uses a shared agent), so there is nothing
199
+ // to close here.
200
+ return results;
201
+ }
202
+
203
+ async function fetchOne(
204
+ session: { getInfo(id: string): Promise<unknown> },
205
+ videoId: string,
206
+ contentToken: string | undefined,
207
+ lang: string,
208
+ timeoutMs: number,
209
+ signal: AbortSignal | undefined,
210
+ fetchImpl: typeof fetch,
211
+ ): Promise<VideoTranscript> {
212
+ const started = Date.now();
213
+ try {
214
+ if (!contentToken) {
215
+ return failure(videoId, 'No content-bound token was minted for this video.');
216
+ }
217
+
218
+ const result = await breaker.execute(() =>
219
+ retryWithBackoff(
220
+ async () => {
221
+ const info = (await withTimeout(
222
+ session.getInfo(videoId) as Promise<VideoInfoLike>,
223
+ timeoutMs,
224
+ `youtube:getInfo:${videoId}`,
225
+ signal,
226
+ )) as VideoInfoLike;
227
+
228
+ const tracks = (info.captions?.caption_tracks ?? []) as CaptionTrack[];
229
+ const track = pickTrack(tracks, lang);
230
+ if (!track) {
231
+ // No captions at all — a permanent condition; do not retry.
232
+ throw new PermanentError('No captions/transcript available for this video.');
233
+ }
234
+
235
+ // base_url is parsed out of YouTube's getInfo() response. Defense in
236
+ // depth: it must be an https YouTube/Google timedtext host before we
237
+ // append the PoToken and fetch it — never a private/metadata target.
238
+ assertTrustedTimedtextUrl(track.base_url);
239
+
240
+ const sep = track.base_url.includes('?') ? '&' : '?';
241
+ const url = `${track.base_url}${sep}fmt=json3&c=WEB&pot=${encodeURIComponent(contentToken)}`;
242
+ // Pass a combined timeout+caller signal straight into fetch so BOTH the
243
+ // response headers AND the body read are bounded and tear the socket
244
+ // down on timeout/cancellation (withTimeout alone would not bound text()).
245
+ const fetchSignal = createTimeoutSignal(timeoutMs, signal);
246
+ const response = await fetchImpl(url, {
247
+ signal: fetchSignal,
248
+ headers: { 'User-Agent': 'Mozilla/5.0', 'Accept-Language': lang },
249
+ });
250
+ // Surface the real HTTP status. Without this, a 403 (age-restricted) or
251
+ // 404 (removed) returns HTML that parseJson3 yields '' for, which would
252
+ // be misreported below as a bot-protection/PoToken failure.
253
+ if (!response.ok) {
254
+ throw new Error(`HTTP ${response.status} from timedtext endpoint`);
255
+ }
256
+ const body = await response.text();
257
+ const text = parseJson3(body);
258
+
259
+ // Empty-body guard: HTTP 200 + empty body == silent PoToken failure.
260
+ if (!text) {
261
+ throw new Error('Empty transcript body (bot-protection rejected the token).');
262
+ }
263
+
264
+ return {
265
+ info,
266
+ track,
267
+ text,
268
+ };
269
+ },
270
+ {
271
+ maxRetries: 1,
272
+ initialDelay: 500,
273
+ maxDelay: 2000,
274
+ label: `youtube:${videoId}`,
275
+ isTransientError: (err) => !(err instanceof PermanentError),
276
+ },
277
+ ),
278
+ );
279
+
280
+ const basic = (result.info.basic_info ?? {}) as Record<string, unknown>;
281
+ metrics.observe('youtube_transcript_fetch_duration_ms', Date.now() - started, { status: 'success' });
282
+ metrics.increment('youtube_transcript_videos_total', 1, { status: 'success' });
283
+ return {
284
+ videoId,
285
+ url: watchUrl(videoId),
286
+ success: true,
287
+ title: typeof basic['title'] === 'string' ? (basic['title'] as string) : undefined,
288
+ author: typeof basic['author'] === 'string' ? (basic['author'] as string) : undefined,
289
+ durationSeconds: typeof basic['duration'] === 'number' ? (basic['duration'] as number) : undefined,
290
+ lang: result.track.language_code,
291
+ text: result.text,
292
+ charCount: result.text.length,
293
+ };
294
+ } catch (error) {
295
+ metrics.observe('youtube_transcript_fetch_duration_ms', Date.now() - started, { status: 'error' });
296
+ metrics.increment('youtube_transcript_videos_total', 1, { status: 'error' });
297
+ logger.warn(`[youtube] Transcript fetch failed for ${videoId}: ${msg(error)}`);
298
+ return failure(videoId, normalizeVideoError(error));
299
+ }
300
+ }
301
+
302
+ /** Select the best caption track: exact lang → lang-prefix → human (non-asr) → first. */
303
+ function pickTrack(tracks: CaptionTrack[], lang: string): CaptionTrack | undefined {
304
+ if (tracks.length === 0) return undefined;
305
+ return (
306
+ tracks.find((t) => t.language_code === lang) ??
307
+ tracks.find((t) => t.language_code?.startsWith(lang)) ??
308
+ tracks.find((t) => t.kind !== 'asr') ??
309
+ tracks[0]
310
+ );
311
+ }
312
+
313
+ /** Parse YouTube json3 caption payload into a single whitespace-collapsed string. */
314
+ /**
315
+ * Defense-in-depth SSRF gate for the timedtext URL. The base_url comes from
316
+ * youtubei.js parsing YouTube's getInfo() response; this guarantees we only ever
317
+ * fetch it if it is https on a YouTube/Google-owned host, so a manipulated or
318
+ * misparsed value can never point the PoToken-bearing request at an internal IP
319
+ * or the cloud metadata endpoint. Throws PermanentError (no retry) on mismatch.
320
+ */
321
+ function assertTrustedTimedtextUrl(rawUrl: string): void {
322
+ let parsed: URL;
323
+ try {
324
+ parsed = new URL(rawUrl);
325
+ } catch {
326
+ throw new PermanentError('Caption track URL is not a valid URL.');
327
+ }
328
+ if (parsed.protocol !== 'https:') {
329
+ throw new PermanentError(`Caption track URL must be https (got ${parsed.protocol}).`);
330
+ }
331
+ const host = parsed.hostname.toLowerCase();
332
+ const ok =
333
+ host === 'youtube.com' ||
334
+ host.endsWith('.youtube.com') ||
335
+ host.endsWith('.googlevideo.com') ||
336
+ host.endsWith('.google.com') ||
337
+ host.endsWith('.googleapis.com');
338
+ if (!ok) {
339
+ throw new PermanentError(`Caption track URL host not trusted: ${host}`);
340
+ }
341
+ }
342
+
343
+ function parseJson3(body: string): string {
344
+ if (!body) return '';
345
+ let parsed: unknown;
346
+ try {
347
+ parsed = JSON.parse(body);
348
+ } catch {
349
+ // Log the real cause: an unparseable body is NOT necessarily an empty
350
+ // bot-protection response (the caller's default diagnosis) — it can be an HTML
351
+ // interstitial or a response-shape change. A short snippet disambiguates without
352
+ // dumping a huge/again-sensitive body.
353
+ logger.debug(`[youtube] timedtext body was not valid json3 (first 120 chars): ${body.slice(0, 120)}`);
354
+ return '';
355
+ }
356
+ const events = (parsed as { events?: unknown[] }).events;
357
+ if (!Array.isArray(events)) return '';
358
+ let out = '';
359
+ for (const event of events) {
360
+ const segs = (event as { segs?: unknown[] }).segs;
361
+ if (!Array.isArray(segs)) continue;
362
+ for (const seg of segs) {
363
+ const utf8 = (seg as { utf8?: unknown }).utf8;
364
+ if (typeof utf8 === 'string') out += utf8;
365
+ }
366
+ }
367
+ return out.replace(/\s+/g, ' ').trim();
368
+ }
369
+
370
+ function failure(videoId: string, error: string): VideoTranscript {
371
+ return { videoId, url: watchUrl(videoId), success: false, error };
372
+ }
373
+
374
+ function normalizeVideoError(error: unknown): string {
375
+ const m = msg(error);
376
+ if (/cancelled or timed out/i.test(m)) return 'Timed out fetching this video.';
377
+ return m;
378
+ }
379
+
380
+ function msg(error: unknown): string {
381
+ return error instanceof Error ? error.message : String(error);
382
+ }
383
+
384
+ /** Marks a non-retryable condition (e.g. captions disabled). */
385
+ class PermanentError extends Error {}
386
+
387
+ interface VideoInfoLike {
388
+ basic_info?: Record<string, unknown>;
389
+ captions?: { caption_tracks?: CaptionTrack[] };
390
+ }