neoagent 3.2.1-beta.1 → 3.2.1-beta.11

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 (198) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +654 -90
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +157 -24
  6. package/flutter_app/lib/main_integrations.dart +607 -8
  7. package/flutter_app/lib/main_models.dart +7 -2
  8. package/flutter_app/lib/main_operations.dart +334 -370
  9. package/flutter_app/lib/main_security.dart +266 -112
  10. package/flutter_app/lib/main_settings.dart +342 -3
  11. package/flutter_app/lib/main_shared.dart +14 -11
  12. package/flutter_app/lib/src/backend_client.dart +97 -0
  13. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  14. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  15. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  16. package/landing/index.html +3 -1
  17. package/lib/manager.js +106 -89
  18. package/lib/schema_migrations.js +115 -13
  19. package/package.json +30 -15
  20. package/runtime/paths.js +49 -5
  21. package/server/db/database.js +2 -2
  22. package/server/guest-agent.cli.package.json +13 -0
  23. package/server/guest_agent.js +85 -40
  24. package/server/http/middleware.js +24 -0
  25. package/server/http/routes.js +12 -6
  26. package/server/public/.last_build_id +1 -1
  27. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  28. package/server/public/flutter_bootstrap.js +2 -2
  29. package/server/public/main.dart.js +81930 -80509
  30. package/server/routes/admin.js +1 -1
  31. package/server/routes/android.js +30 -34
  32. package/server/routes/behavior.js +80 -0
  33. package/server/routes/browser.js +23 -15
  34. package/server/routes/desktop.js +18 -1
  35. package/server/routes/integrations.js +107 -1
  36. package/server/routes/memory.js +1 -0
  37. package/server/routes/settings.js +20 -5
  38. package/server/routes/social_reach.js +12 -3
  39. package/server/routes/social_video.js +4 -0
  40. package/server/services/agents/manager.js +1 -1
  41. package/server/services/ai/capabilityHealth.js +62 -96
  42. package/server/services/ai/compaction.js +7 -2
  43. package/server/services/ai/history.js +45 -6
  44. package/server/services/ai/integrated_tools/http_request.js +8 -0
  45. package/server/services/ai/loop/agent_engine_core.js +452 -176
  46. package/server/services/ai/loop/blank_recovery.js +5 -4
  47. package/server/services/ai/loop/callbacks.js +1 -0
  48. package/server/services/ai/loop/completion_judge.js +291 -8
  49. package/server/services/ai/loop/conversation_loop.js +515 -342
  50. package/server/services/ai/loop/messaging_delivery.js +176 -57
  51. package/server/services/ai/loop/model_call_guard.js +91 -0
  52. package/server/services/ai/loop/model_io.js +20 -45
  53. package/server/services/ai/loop/progress_classification.js +2 -0
  54. package/server/services/ai/loop/tool_dispatch.js +19 -8
  55. package/server/services/ai/loopPolicy.js +48 -21
  56. package/server/services/ai/messagingFallback.js +17 -17
  57. package/server/services/ai/model_discovery.js +227 -0
  58. package/server/services/ai/model_failure_cache.js +108 -0
  59. package/server/services/ai/model_identity.js +71 -0
  60. package/server/services/ai/models.js +68 -163
  61. package/server/services/ai/providerRetry.js +17 -59
  62. package/server/services/ai/provider_selector.js +166 -0
  63. package/server/services/ai/providers/anthropic.js +2 -2
  64. package/server/services/ai/providers/claudeCode.js +21 -33
  65. package/server/services/ai/providers/githubCopilot.js +41 -20
  66. package/server/services/ai/providers/google.js +135 -97
  67. package/server/services/ai/providers/grok.js +4 -3
  68. package/server/services/ai/providers/grokOauth.js +19 -27
  69. package/server/services/ai/providers/nvidia.js +10 -5
  70. package/server/services/ai/providers/ollama.js +111 -84
  71. package/server/services/ai/providers/ollama_stream.js +142 -0
  72. package/server/services/ai/providers/openai.js +39 -5
  73. package/server/services/ai/providers/openaiCodex.js +11 -4
  74. package/server/services/ai/providers/openrouter.js +29 -7
  75. package/server/services/ai/providers/provider_error.js +36 -0
  76. package/server/services/ai/settings.js +26 -2
  77. package/server/services/ai/systemPrompt.js +32 -123
  78. package/server/services/ai/taskAnalysis.js +104 -11
  79. package/server/services/ai/toolEvidence.js +256 -29
  80. package/server/services/ai/tools.js +248 -117
  81. package/server/services/android/controller.js +770 -237
  82. package/server/services/android/process.js +140 -0
  83. package/server/services/android/sdk_download.js +143 -0
  84. package/server/services/android/uia.js +6 -5
  85. package/server/services/artifacts/store.js +24 -0
  86. package/server/services/behavior/config.js +251 -0
  87. package/server/services/behavior/defaults.js +68 -0
  88. package/server/services/behavior/delivery.js +176 -0
  89. package/server/services/behavior/index.js +43 -0
  90. package/server/services/behavior/model_client.js +35 -0
  91. package/server/services/behavior/modules/agent_identity.js +37 -0
  92. package/server/services/behavior/modules/channel_style.js +28 -0
  93. package/server/services/behavior/modules/index.js +29 -0
  94. package/server/services/behavior/modules/norms.js +101 -0
  95. package/server/services/behavior/modules/persona.js +48 -0
  96. package/server/services/behavior/modules/persona_prompt.js +33 -0
  97. package/server/services/behavior/modules/social_memory.js +86 -0
  98. package/server/services/behavior/modules/social_observability.js +94 -0
  99. package/server/services/behavior/modules/social_signals.js +41 -0
  100. package/server/services/behavior/modules/theory_of_mind.js +110 -0
  101. package/server/services/behavior/modules/turn_taking.js +237 -0
  102. package/server/services/behavior/pipeline.js +285 -0
  103. package/server/services/behavior/registry.js +78 -0
  104. package/server/services/behavior/signals.js +107 -0
  105. package/server/services/behavior/state.js +99 -0
  106. package/server/services/behavior/system_prompt.js +75 -0
  107. package/server/services/browser/controller.js +843 -385
  108. package/server/services/browser/extension/gateway.js +40 -16
  109. package/server/services/browser/extension/protocol.js +15 -1
  110. package/server/services/browser/extension/provider.js +71 -47
  111. package/server/services/browser/extension/registry.js +155 -34
  112. package/server/services/cli/executor.js +62 -9
  113. package/server/services/credentials/bitwarden_cli.js +322 -0
  114. package/server/services/credentials/broker.js +594 -0
  115. package/server/services/desktop/gateway.js +41 -4
  116. package/server/services/desktop/protocol.js +3 -0
  117. package/server/services/desktop/provider.js +39 -42
  118. package/server/services/desktop/registry.js +137 -52
  119. package/server/services/integrations/bitwarden/constants.js +14 -0
  120. package/server/services/integrations/bitwarden/provider.js +197 -0
  121. package/server/services/integrations/bitwarden/snapshot.js +65 -0
  122. package/server/services/integrations/figma/provider.js +78 -12
  123. package/server/services/integrations/github/common.js +11 -6
  124. package/server/services/integrations/github/provider.js +52 -53
  125. package/server/services/integrations/google/provider.js +55 -19
  126. package/server/services/integrations/home_assistant/network.js +17 -20
  127. package/server/services/integrations/home_assistant/provider.js +7 -5
  128. package/server/services/integrations/home_assistant/tools.js +17 -5
  129. package/server/services/integrations/http.js +51 -0
  130. package/server/services/integrations/manager.js +159 -53
  131. package/server/services/integrations/microsoft/provider.js +80 -13
  132. package/server/services/integrations/neoarchive/provider.js +55 -29
  133. package/server/services/integrations/neorecall/client.js +17 -10
  134. package/server/services/integrations/neorecall/provider.js +20 -11
  135. package/server/services/integrations/notion/provider.js +16 -13
  136. package/server/services/integrations/oauth_provider.js +115 -51
  137. package/server/services/integrations/registry.js +2 -0
  138. package/server/services/integrations/slack/provider.js +98 -9
  139. package/server/services/integrations/spotify/provider.js +67 -71
  140. package/server/services/integrations/trello/provider.js +21 -7
  141. package/server/services/integrations/weather/provider.js +18 -12
  142. package/server/services/integrations/whatsapp/provider.js +76 -16
  143. package/server/services/manager.js +110 -1
  144. package/server/services/memory/embedding_index.js +20 -8
  145. package/server/services/memory/embeddings.js +151 -90
  146. package/server/services/memory/ingestion.js +50 -9
  147. package/server/services/memory/ingestion_documents.js +13 -3
  148. package/server/services/memory/manager.js +66 -52
  149. package/server/services/messaging/access_policy.js +10 -6
  150. package/server/services/messaging/automation.js +240 -34
  151. package/server/services/messaging/discord.js +11 -1
  152. package/server/services/messaging/formatting_guides.js +5 -4
  153. package/server/services/messaging/http_platforms.js +37 -16
  154. package/server/services/messaging/inbound_queue.js +143 -28
  155. package/server/services/messaging/inbound_store.js +257 -0
  156. package/server/services/messaging/manager.js +344 -51
  157. package/server/services/messaging/telegram.js +10 -1
  158. package/server/services/messaging/typing_keepalive.js +5 -2
  159. package/server/services/messaging/whatsapp.js +33 -14
  160. package/server/services/network/http.js +210 -0
  161. package/server/services/network/safe_request.js +307 -0
  162. package/server/services/runtime/backends/local-vm.js +227 -67
  163. package/server/services/runtime/docker-vm-manager.js +9 -0
  164. package/server/services/runtime/guest_bootstrap.js +30 -4
  165. package/server/services/runtime/guest_image.js +43 -12
  166. package/server/services/runtime/manager.js +77 -23
  167. package/server/services/runtime/validation.js +7 -6
  168. package/server/services/security/tool_categories.js +6 -0
  169. package/server/services/social_reach/channels/github.js +10 -4
  170. package/server/services/social_reach/channels/reddit.js +4 -4
  171. package/server/services/social_reach/channels/rss.js +2 -2
  172. package/server/services/social_reach/channels/social_video.js +13 -8
  173. package/server/services/social_reach/channels/v2ex.js +21 -8
  174. package/server/services/social_reach/channels/x.js +2 -2
  175. package/server/services/social_reach/channels/xueqiu.js +5 -5
  176. package/server/services/social_reach/service.js +9 -6
  177. package/server/services/social_reach/utils.js +65 -14
  178. package/server/services/social_video/captions.js +2 -2
  179. package/server/services/social_video/service.js +343 -68
  180. package/server/services/tasks/integration_runtime.js +18 -8
  181. package/server/services/tasks/runtime.js +39 -4
  182. package/server/services/voice/agentBridge.js +17 -4
  183. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  184. package/server/services/voice/liveSession.js +31 -0
  185. package/server/services/voice/message.js +1 -1
  186. package/server/services/voice/openaiSpeech.js +33 -8
  187. package/server/services/voice/providers.js +233 -151
  188. package/server/services/voice/runtime.js +2 -2
  189. package/server/services/voice/runtimeManager.js +118 -20
  190. package/server/services/voice/turnRunner.js +6 -0
  191. package/server/services/wearable/firmware_manifest.js +51 -13
  192. package/server/services/wearable/service.js +1 -0
  193. package/server/utils/abort.js +96 -0
  194. package/server/utils/cloud-security.js +110 -3
  195. package/server/utils/files.js +31 -0
  196. package/server/utils/image_payload.js +95 -0
  197. package/server/utils/logger.js +19 -0
  198. package/server/utils/retry.js +107 -0
@@ -8,19 +8,28 @@ const { randomUUID } = require('crypto');
8
8
  const db = require('../../db/database');
9
9
  const { DATA_DIR } = require('../../../runtime/paths');
10
10
  const { CLIExecutor } = require('../cli/executor');
11
+ const { executeSafeHttpRequest } = require('../network/safe_request');
11
12
  const { getAdapterForPlatform } = require('./adapters');
12
- const { decideTranscriptPath, parseCaptionText, pickCaptionTrack } = require('./captions');
13
+ const {
14
+ MAX_VTT_BYTES,
15
+ decideTranscriptPath,
16
+ parseCaptionText,
17
+ pickCaptionTrack,
18
+ } = require('./captions');
13
19
  const { inferImageContentType, pickDeterministicFrameSecond } = require('./frame');
14
20
  const { extractPublicMetadataFromHtml } = require('./metadata');
15
21
  const { shapeSocialVideoResult } = require('./result');
16
22
  const { normalizeAndDetectPlatform } = require('./url');
17
23
  const { isMainAgent } = require('../agents/manager');
18
24
  const { resolveSttModel, transcribeVoiceInput } = require('../voice/providers');
25
+ const { createAbortError, isAbortError, throwIfAborted } = require('../../utils/abort');
19
26
 
20
27
  const SOCIAL_VIDEO_TMP_DIR = path.join(DATA_DIR, 'social-video-temp');
21
28
  fs.mkdirSync(SOCIAL_VIDEO_TMP_DIR, { recursive: true });
22
29
 
23
30
  const HEALTH_CACHE_TTL_MS = 5 * 60 * 1000;
31
+ const MAX_PAGE_HTML_BYTES = 2 * 1024 * 1024;
32
+ const MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024;
24
33
 
25
34
  // A realistic desktop browser UA. Social platforms frequently return 403/bot
26
35
  // challenge pages to the default Node/yt-dlp user agents, so we present a
@@ -44,17 +53,38 @@ const YT_DLP_NETWORK_FLAGS = [
44
53
  // bot detection. For these we always try to attach the user's browser cookies.
45
54
  const COOKIE_ASSISTED_PLATFORMS = new Set(['instagram', 'youtube', 'tiktok', 'x']);
46
55
 
47
- async function fetchWithBrowserHeaders(url, options = {}) {
48
- return fetch(url, {
49
- redirect: 'follow',
50
- ...options,
56
+ async function fetchPublicResource(url, options = {}) {
57
+ const result = await executeSafeHttpRequest({
58
+ url,
59
+ method: 'GET',
60
+ timeout_ms: options.timeoutMs || 30000,
51
61
  headers: {
52
62
  'user-agent': BROWSER_USER_AGENT,
53
63
  'accept-language': 'en-US,en;q=0.9',
54
- accept: '*/*',
64
+ accept: options.accept || '*/*',
55
65
  ...(options.headers || {}),
56
66
  },
67
+ }, {
68
+ signal: options.signal,
69
+ maxResponseBytes: options.maxResponseBytes,
70
+ responseType: options.responseType,
57
71
  });
72
+ if (result.truncated) {
73
+ const error = new Error('Social video response exceeded its safety limit.');
74
+ error.code = 'SOCIAL_VIDEO_RESPONSE_TOO_LARGE';
75
+ throw error;
76
+ }
77
+ if (result.status < 200 || result.status >= 300) {
78
+ const error = new Error(`Social video request failed (${result.status}).`);
79
+ error.status = result.status;
80
+ throw error;
81
+ }
82
+ return result;
83
+ }
84
+
85
+ function rethrowCancellation(error, signal) {
86
+ if (signal?.aborted) throw createAbortError(signal);
87
+ if (isAbortError(error)) throw error;
58
88
  }
59
89
 
60
90
  function shellEscape(value) {
@@ -199,6 +229,36 @@ function firstFileMatching(dirPath, startsWith) {
199
229
  return path.join(dirPath, match);
200
230
  }
201
231
 
232
+ function pickDownloadedCaptionFile(dirPath, preferredLanguages = []) {
233
+ const captionExts = new Set(['.vtt', '.webvtt', '.srt', '.ttml', '.xml', '.json3', '.json']);
234
+ const captionGroups = {};
235
+ for (const name of fs.readdirSync(dirPath)
236
+ .filter((name) => name.startsWith('captions.'))
237
+ .sort()) {
238
+ const fullPath = path.join(dirPath, name);
239
+ const extension = path.extname(name).toLowerCase();
240
+ if (!captionExts.has(extension) || !fileExists(fullPath)) continue;
241
+ const base = path.basename(name, extension);
242
+ const language = base.slice('captions.'.length).toLowerCase();
243
+ captionGroups[language] ||= [];
244
+ captionGroups[language].push({
245
+ url: fullPath,
246
+ ext: extension.replace(/^\./, ''),
247
+ });
248
+ }
249
+ const track = pickCaptionTrack(captionGroups, preferredLanguages);
250
+ if (!track) return null;
251
+ return {
252
+ path: track.url,
253
+ language: track.language,
254
+ ext: track.ext,
255
+ };
256
+ }
257
+
258
+ function cookieArg(cookieFilePath) {
259
+ return cookieFilePath ? ` --cookies ${shellEscape(cookieFilePath)}` : '';
260
+ }
261
+
202
262
  function classifyExtractionError(error) {
203
263
  const message = String(error?.message || error || '').trim();
204
264
  const normalized = message.toLowerCase();
@@ -245,6 +305,7 @@ class SocialVideoService {
245
305
  this.artifactStore = options.artifactStore || null;
246
306
  this.runtimeManager = options.runtimeManager || null;
247
307
  this.cliExecutor = options.cliExecutor || new CLIExecutor();
308
+ this.publicResourceFetcher = options.publicResourceFetcher || fetchPublicResource;
248
309
  this.voiceTranscriber = options.voiceTranscriber || transcribeVoiceInput;
249
310
  this.voiceSettingsResolver = options.voiceSettingsResolver || ((userId, agentId) => this.#resolveVoiceSttConfig(userId, agentId));
250
311
  this.ytDlpBin = String(process.env.YT_DLP_BIN || 'yt-dlp').trim() || 'yt-dlp';
@@ -256,6 +317,7 @@ class SocialVideoService {
256
317
  }
257
318
 
258
319
  async getHealthStatus(options = {}) {
320
+ throwIfAborted(options.signal, 'Social video health check aborted.');
259
321
  const forceRefresh = options.forceRefresh === true;
260
322
  const now = Date.now();
261
323
  if (!forceRefresh && this._healthCache.value && (now - this._healthCache.ts) < HEALTH_CACHE_TTL_MS) {
@@ -263,15 +325,18 @@ class SocialVideoService {
263
325
  }
264
326
 
265
327
  const [ytDlp, ffmpeg] = await Promise.all([
266
- this.#probeBinary(this.ytDlpBin, '--version'),
267
- this.#probeBinary(this.ffmpegBin, '-version'),
328
+ this.#probeBinary(this.ytDlpBin, '--version', options.signal),
329
+ this.#probeBinary(this.ffmpegBin, '-version', options.signal),
268
330
  ]);
269
331
 
270
332
  const health = {
271
- ready: ytDlp.available && ffmpeg.available,
272
- dependencies: [ytDlp, ffmpeg],
333
+ ready: ytDlp.available,
334
+ dependencies: [
335
+ { ...ytDlp, required: true },
336
+ { ...ffmpeg, required: false },
337
+ ],
273
338
  speechToText: {
274
- note: 'Transcript fallback uses the configured voice STT provider from Flutter settings.',
339
+ note: 'Captions are downloaded directly with yt-dlp without social platform API keys. If captions are unavailable, the configured voice STT provider transcribes the media.',
275
340
  },
276
341
  checkedAt: new Date().toISOString(),
277
342
  };
@@ -291,9 +356,12 @@ class SocialVideoService {
291
356
  let jobDir = null;
292
357
 
293
358
  try {
294
- const health = await this.getHealthStatus();
359
+ throwIfAborted(options.signal, 'Social video extraction aborted.');
360
+ const health = await this.getHealthStatus({ signal: options.signal });
295
361
  if (!health.ready) {
296
- const missing = health.dependencies.filter((item) => !item.available).map((item) => item.name);
362
+ const missing = health.dependencies
363
+ .filter((item) => item.required !== false && !item.available)
364
+ .map((item) => item.name);
297
365
  throw new Error(`Missing required dependency: ${missing.join(', ')}`);
298
366
  }
299
367
 
@@ -303,16 +371,32 @@ class SocialVideoService {
303
371
  throw new Error(`No adapter registered for platform: ${platform}`);
304
372
  }
305
373
 
306
- const pageMetadata = await this.#resolvePageMetadata(userId, normalizedUrl, warnings);
374
+ const pageMetadata = await this.#resolvePageMetadata(
375
+ userId,
376
+ normalizedUrl,
377
+ warnings,
378
+ options.signal,
379
+ ).catch((error) => {
380
+ rethrowCancellation(error, options.signal);
381
+ warnings.push(`Public page metadata was unavailable: ${error.message}`);
382
+ return {};
383
+ });
384
+ throwIfAborted(options.signal, 'Social video extraction aborted.');
307
385
  jobDir = await fsp.mkdtemp(path.join(SOCIAL_VIDEO_TMP_DIR, `${platform}-${Date.now()}-`));
308
386
  const cookieFilePath = await this.#resolveCookieFile({
309
387
  userId,
310
388
  platform,
311
389
  jobDir,
312
390
  warnings,
391
+ signal: options.signal,
313
392
  });
314
393
 
315
- const mediaInfo = await this.#readMediaInfo(normalizedUrl, jobDir, cookieFilePath);
394
+ const mediaInfo = await this.#readMediaInfo(
395
+ normalizedUrl,
396
+ jobDir,
397
+ cookieFilePath,
398
+ options.signal,
399
+ );
316
400
  const baseTitle = String(pageMetadata.title || mediaInfo.title || '').trim();
317
401
  const baseDescription = String(pageMetadata.description || mediaInfo.description || '').trim();
318
402
  const resolvedUrl = String(pageMetadata.resolvedUrl || mediaInfo.webpage_url || normalizedUrl).trim();
@@ -333,12 +417,14 @@ class SocialVideoService {
333
417
  sourceUrl: normalizedUrl,
334
418
  mediaInfo,
335
419
  captionTrack,
420
+ preferredLanguages,
336
421
  transcriptDecision,
337
422
  jobDir,
338
423
  cookieFilePath,
339
424
  userId,
340
425
  agentId,
341
426
  warnings,
427
+ signal: options.signal,
342
428
  });
343
429
 
344
430
  const frameImage = options.includeFrame === false
@@ -350,6 +436,7 @@ class SocialVideoService {
350
436
  jobDir,
351
437
  cookieFilePath,
352
438
  warnings,
439
+ signal: options.signal,
353
440
  });
354
441
 
355
442
  return shapeSocialVideoResult({
@@ -372,7 +459,8 @@ class SocialVideoService {
372
459
  errors,
373
460
  });
374
461
  } catch (error) {
375
- const health = await this.getHealthStatus().catch(() => null);
462
+ rethrowCancellation(error, options.signal);
463
+ const health = await this.getHealthStatus({ signal: options.signal }).catch(() => null);
376
464
  errors.push(classifyExtractionError(error));
377
465
  return shapeSocialVideoResult({
378
466
  sourceUrl: source,
@@ -403,14 +491,16 @@ class SocialVideoService {
403
491
  cwd: options.cwd || process.cwd(),
404
492
  timeout: options.timeout || 10 * 60 * 1000,
405
493
  env: options.env,
494
+ signal: options.signal,
406
495
  });
496
+ throwIfAborted(options.signal, 'Social video command aborted.');
407
497
  if (result.exitCode !== 0) {
408
498
  throw new Error(result.stderr || result.stdout || `Command failed: ${command}`);
409
499
  }
410
500
  return result;
411
501
  }
412
502
 
413
- async #probeBinary(binary, versionFlag) {
503
+ async #probeBinary(binary, versionFlag, signal = null) {
414
504
  const name = String(binary || '').trim();
415
505
  const fallback = {
416
506
  name,
@@ -430,7 +520,9 @@ class SocialVideoService {
430
520
  const command = `${shellEscape(name)} ${versionFlag}`;
431
521
  const result = await this.cliExecutor.execute(command, {
432
522
  timeout: 8 * 1000,
523
+ signal,
433
524
  });
525
+ throwIfAborted(signal, 'Social video dependency probe aborted.');
434
526
  if (result.exitCode !== 0) {
435
527
  return {
436
528
  ...fallback,
@@ -447,6 +539,7 @@ class SocialVideoService {
447
539
  error: null,
448
540
  };
449
541
  } catch (error) {
542
+ rethrowCancellation(error, signal);
450
543
  return {
451
544
  ...fallback,
452
545
  error: error.message || String(error),
@@ -454,8 +547,13 @@ class SocialVideoService {
454
547
  }
455
548
  }
456
549
 
457
- async #resolvePageMetadata(userId, normalizedUrl, warnings) {
458
- const browserMetadata = await this.#resolvePageMetadataViaBrowser(userId, normalizedUrl).catch((error) => {
550
+ async #resolvePageMetadata(userId, normalizedUrl, warnings, signal = null) {
551
+ const browserMetadata = await this.#resolvePageMetadataViaBrowser(
552
+ userId,
553
+ normalizedUrl,
554
+ signal,
555
+ ).catch((error) => {
556
+ rethrowCancellation(error, signal);
459
557
  warnings.push(`Browser metadata resolve failed: ${error.message}`);
460
558
  return null;
461
559
  });
@@ -463,21 +561,24 @@ class SocialVideoService {
463
561
  return browserMetadata;
464
562
  }
465
563
 
466
- const response = await fetchWithBrowserHeaders(normalizedUrl);
467
- const html = await response.text();
468
- const metadata = extractPublicMetadataFromHtml(html, response.url || normalizedUrl);
564
+ const response = await this.publicResourceFetcher(normalizedUrl, {
565
+ signal,
566
+ maxResponseBytes: MAX_PAGE_HTML_BYTES,
567
+ accept: 'text/html,*/*',
568
+ });
569
+ const metadata = extractPublicMetadataFromHtml(response.body, response.finalUrl || normalizedUrl);
469
570
  return {
470
571
  ...metadata,
471
- resolvedUrl: String(response.url || normalizedUrl),
572
+ resolvedUrl: String(response.finalUrl || normalizedUrl),
472
573
  };
473
574
  }
474
575
 
475
- async #resolvePageMetadataViaBrowser(userId, normalizedUrl) {
576
+ async #resolvePageMetadataViaBrowser(userId, normalizedUrl, signal = null) {
476
577
  if (!this.runtimeManager || typeof this.runtimeManager.getBrowserProviderForUser !== 'function') {
477
578
  throw new Error('Runtime browser provider is unavailable.');
478
579
  }
479
580
 
480
- const browser = await this.runtimeManager.getBrowserProviderForUser(userId);
581
+ const browser = await this.runtimeManager.getBrowserProviderForUser(userId, { signal });
481
582
  if (!browser || typeof browser.navigate !== 'function' || typeof browser.extract !== 'function') {
482
583
  throw new Error('Runtime browser provider does not support metadata extraction.');
483
584
  }
@@ -485,16 +586,29 @@ class SocialVideoService {
485
586
  const nav = await browser.navigate(normalizedUrl, {
486
587
  screenshot: false,
487
588
  waitUntil: 'domcontentloaded',
589
+ signal,
488
590
  });
489
591
  if (nav?.error) {
490
592
  throw new Error(nav.error);
491
593
  }
492
594
 
493
595
  const [canonicalRaw, descriptionRaw, ogDescriptionRaw, titleTagRaw] = await Promise.all([
494
- browser.extract('link[rel="canonical"]', 'href', false).catch(() => ''),
495
- browser.extract('meta[name="description"]', 'content', false).catch(() => ''),
496
- browser.extract('meta[property="og:description"]', 'content', false).catch(() => ''),
497
- browser.extract('meta[property="og:title"]', 'content', false).catch(() => ''),
596
+ browser.extract('link[rel="canonical"]', 'href', false, { signal }).catch((error) => {
597
+ rethrowCancellation(error, signal);
598
+ return '';
599
+ }),
600
+ browser.extract('meta[name="description"]', 'content', false, { signal }).catch((error) => {
601
+ rethrowCancellation(error, signal);
602
+ return '';
603
+ }),
604
+ browser.extract('meta[property="og:description"]', 'content', false, { signal }).catch((error) => {
605
+ rethrowCancellation(error, signal);
606
+ return '';
607
+ }),
608
+ browser.extract('meta[property="og:title"]', 'content', false, { signal }).catch((error) => {
609
+ rethrowCancellation(error, signal);
610
+ return '';
611
+ }),
498
612
  ]);
499
613
  const canonical = unwrapBrowserExtractValue(canonicalRaw);
500
614
  const description = unwrapBrowserExtractValue(descriptionRaw);
@@ -509,16 +623,16 @@ class SocialVideoService {
509
623
  };
510
624
  }
511
625
 
512
- async #readMediaInfo(normalizedUrl, jobDir, cookieFilePath = null) {
626
+ async #readMediaInfo(normalizedUrl, jobDir, cookieFilePath = null, signal = null) {
513
627
  const infoTemplate = path.join(jobDir, 'media.%(ext)s');
514
628
  const infoPath = path.join(jobDir, 'media.info.json');
515
- const cookieArg = cookieFilePath ? ` --cookies ${shellEscape(cookieFilePath)}` : '';
516
- const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()} --skip-download --write-info-json --no-clean-infojson${cookieArg} -o ${shellEscape(infoTemplate)} -- ${shellEscape(normalizedUrl)}`;
517
- await this.#runCommand(command, { cwd: jobDir, timeout: 4 * 60 * 1000 });
629
+ const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()} --skip-download --write-info-json --no-clean-infojson${cookieArg(cookieFilePath)} -o ${shellEscape(infoTemplate)} -- ${shellEscape(normalizedUrl)}`;
630
+ await this.#runCommand(command, { cwd: jobDir, timeout: 4 * 60 * 1000, signal });
518
631
  if (!fileExists(infoPath)) {
519
632
  throw new Error('yt-dlp did not produce an info JSON artifact.');
520
633
  }
521
634
  const raw = String(await fsp.readFile(infoPath, 'utf8')).trim();
635
+ throwIfAborted(signal, 'Social video extraction aborted.');
522
636
  let parsed;
523
637
  try {
524
638
  parsed = JSON.parse(raw);
@@ -529,8 +643,16 @@ class SocialVideoService {
529
643
  }
530
644
 
531
645
  async #resolveTranscript(context) {
532
- if (context.transcriptDecision.mode === 'captions' && context.captionTrack) {
533
- const captionText = await this.#readTranscriptFromCaption(context.captionTrack).catch((error) => {
646
+ // Prefer captions over STT and never depend on social platform API keys.
647
+ // Caption URLs from metadata are often signed/short-lived or cookie-gated, and
648
+ // auto-captions may exist even when info JSON omits them, so we always attempt
649
+ // caption acquisition before spending STT quota (unless forceStt is set).
650
+ const forceStt = context.transcriptDecision?.mode === 'stt'
651
+ && context.transcriptDecision?.reason === 'forced';
652
+
653
+ if (!forceStt) {
654
+ const captionText = await this.#resolveCaptionTranscript(context).catch((error) => {
655
+ rethrowCancellation(error, context.signal);
534
656
  context.warnings.push(`Caption transcript failed: ${error.message}`);
535
657
  return '';
536
658
  });
@@ -540,10 +662,13 @@ class SocialVideoService {
540
662
  source: 'captions',
541
663
  };
542
664
  }
543
- context.warnings.push('Caption track was present but transcript text was empty. Falling back to speech-to-text.');
665
+ if (context.transcriptDecision?.mode === 'captions') {
666
+ context.warnings.push('Caption extraction did not yield usable text. Falling back to speech-to-text.');
667
+ }
544
668
  }
545
669
 
546
670
  const transcript = await this.#transcribeViaStt(context).catch((error) => {
671
+ rethrowCancellation(error, context.signal);
547
672
  context.warnings.push(`Speech-to-text fallback failed: ${error.message}`);
548
673
  return '';
549
674
  });
@@ -553,34 +678,159 @@ class SocialVideoService {
553
678
  };
554
679
  }
555
680
 
556
- async #readTranscriptFromCaption(captionTrack) {
557
- const response = await fetchWithBrowserHeaders(captionTrack.url);
558
- if (!response.ok) {
559
- throw new Error(`Caption request failed (${response.status}).`);
681
+ async #resolveCaptionTranscript(context) {
682
+ if (context.captionTrack) {
683
+ const direct = await this.#readTranscriptFromCaption(
684
+ context.captionTrack,
685
+ context.sourceUrl,
686
+ context.signal,
687
+ ).catch((error) => {
688
+ rethrowCancellation(error, context.signal);
689
+ context.warnings.push(`Direct caption fetch failed: ${error.message}`);
690
+ return '';
691
+ });
692
+ if (direct) {
693
+ return direct;
694
+ }
560
695
  }
561
- const raw = await response.text();
562
- return parseCaptionText(raw, captionTrack.ext);
696
+
697
+ return this.#downloadCaptionsViaYtDlp(context);
563
698
  }
564
699
 
565
- async #transcribeViaStt(context) {
566
- const template = path.join(context.jobDir, 'audio.%(ext)s');
567
- const cookieArg = context.cookieFilePath ? ` --cookies ${shellEscape(context.cookieFilePath)}` : '';
568
- const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg} -o ${shellEscape(template)} -f bestaudio/best -- ${shellEscape(context.sourceUrl)}`;
569
- await this.#runCommand(command, { cwd: context.jobDir, timeout: 10 * 60 * 1000 });
700
+ async #readTranscriptFromCaption(captionTrack, sourceUrl, signal = null) {
701
+ const response = await this.publicResourceFetcher(captionTrack.url, {
702
+ signal,
703
+ maxResponseBytes: MAX_VTT_BYTES,
704
+ accept: 'text/vtt,text/plain,application/json,application/xml,*/*',
705
+ headers: {
706
+ referer: String(sourceUrl || ''),
707
+ },
708
+ });
709
+ return parseCaptionText(response.body, captionTrack.ext);
710
+ }
570
711
 
571
- const audioPath = firstFileMatching(context.jobDir, 'audio.');
572
- if (!audioPath || !fileExists(audioPath)) {
573
- throw new Error('Audio download succeeded but no audio file was created.');
712
+ async #downloadCaptionsViaYtDlp(context) {
713
+ const preferredLanguages = Array.isArray(context.preferredLanguages)
714
+ ? context.preferredLanguages
715
+ : [];
716
+ const languageHints = preferredLanguages.length > 0
717
+ ? preferredLanguages.join(',')
718
+ : 'en.*,en';
719
+ // Download both manual and auto-generated captions without platform API keys.
720
+ // Prefer formats we can parse directly so caption extraction does not require
721
+ // ffmpeg just to perform a subtitle conversion.
722
+ const outputTemplate = path.join(context.jobDir, 'captions');
723
+ const command = [
724
+ shellEscape(this.ytDlpBin),
725
+ '--quiet',
726
+ '--no-warnings',
727
+ '--no-playlist',
728
+ this.#networkFlags(),
729
+ cookieArg(context.cookieFilePath),
730
+ '--skip-download',
731
+ '--write-subs',
732
+ '--write-auto-subs',
733
+ '--sub-langs', shellEscape(`${languageHints},all,-live_chat`),
734
+ '--sub-format', shellEscape('vtt/srt/ttml/json3/best'),
735
+ '-o', shellEscape(outputTemplate),
736
+ '--',
737
+ shellEscape(context.sourceUrl),
738
+ ].filter(Boolean).join(' ');
739
+
740
+ await this.#runCommand(command, {
741
+ cwd: context.jobDir,
742
+ timeout: 4 * 60 * 1000,
743
+ signal: context.signal,
744
+ });
745
+
746
+ const captionFile = pickDownloadedCaptionFile(context.jobDir, preferredLanguages);
747
+ if (!captionFile) {
748
+ throw new Error('yt-dlp did not produce a usable caption file.');
574
749
  }
575
750
 
751
+ const raw = await fsp.readFile(captionFile.path, 'utf8');
752
+ throwIfAborted(context.signal, 'Social video extraction aborted.');
753
+ const text = parseCaptionText(raw, captionFile.ext);
754
+ if (!text) {
755
+ throw new Error(`Caption file ${path.basename(captionFile.path)} parsed to empty text.`);
756
+ }
757
+ return text;
758
+ }
759
+
760
+ async #transcribeViaStt(context) {
761
+ const audioPath = await this.#downloadAudioForStt(context);
762
+ const preparedPath = await this.#prepareAudioForStt(audioPath, context);
763
+
576
764
  const sttConfig = await Promise.resolve(
577
765
  this.voiceSettingsResolver(context.userId, context.agentId),
578
766
  );
579
- return this.voiceTranscriber(audioPath, {
767
+ throwIfAborted(context.signal, 'Social video transcription aborted.');
768
+ return this.voiceTranscriber(preparedPath, {
580
769
  provider: sttConfig?.provider || 'openai',
581
770
  model: sttConfig?.model || '',
582
- mimeType: detectMimeFromFile(audioPath),
771
+ mimeType: detectMimeFromFile(preparedPath),
772
+ signal: context.signal,
773
+ });
774
+ }
775
+
776
+ async #downloadAudioForStt(context) {
777
+ const template = path.join(context.jobDir, 'audio.%(ext)s');
778
+ const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg(context.cookieFilePath)} -o ${shellEscape(template)} -f "bestaudio/best" --extract-audio --audio-format mp3 --audio-quality 0 -- ${shellEscape(context.sourceUrl)}`;
779
+
780
+ try {
781
+ await this.#runCommand(command, {
782
+ cwd: context.jobDir,
783
+ timeout: 10 * 60 * 1000,
784
+ signal: context.signal,
785
+ });
786
+ } catch (error) {
787
+ rethrowCancellation(error, context.signal);
788
+ // Some extractors reject --extract-audio; fall back to raw bestaudio/best download.
789
+ context.warnings.push(`Audio extract-audio path failed (${error.message}). Retrying raw media download.`);
790
+ const fallbackCommand = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg(context.cookieFilePath)} -o ${shellEscape(template)} -f "bestaudio/best" -- ${shellEscape(context.sourceUrl)}`;
791
+ await this.#runCommand(fallbackCommand, {
792
+ cwd: context.jobDir,
793
+ timeout: 10 * 60 * 1000,
794
+ signal: context.signal,
795
+ });
796
+ }
797
+
798
+ const audioPath = firstFileMatching(context.jobDir, 'audio.');
799
+ if (!audioPath || !fileExists(audioPath)) {
800
+ throw new Error('Audio download succeeded but no audio file was created.');
801
+ }
802
+ return audioPath;
803
+ }
804
+
805
+ async #prepareAudioForStt(audioPath, context) {
806
+ const ext = path.extname(String(audioPath || '')).toLowerCase();
807
+ const mimeType = detectMimeFromFile(audioPath);
808
+ // Prefer formats STT providers accept directly. Normalize anything else
809
+ // (including video containers from bestaudio/best fallbacks) to mono mp3.
810
+ const readyAsIs = (
811
+ mimeType === 'audio/mpeg'
812
+ || mimeType === 'audio/mp4'
813
+ || mimeType === 'audio/wav'
814
+ || mimeType === 'audio/webm'
815
+ || mimeType === 'audio/ogg'
816
+ || mimeType === 'audio/opus'
817
+ ) && !['.mp4', '.mkv', '.mov', '.avi'].includes(ext);
818
+
819
+ if (readyAsIs) {
820
+ return audioPath;
821
+ }
822
+
823
+ const normalizedPath = path.join(context.jobDir, 'audio.stt.mp3');
824
+ const command = `${shellEscape(this.ffmpegBin)} -hwaccel none -y -hide_banner -loglevel error -i ${shellEscape(audioPath)} -vn -ac 1 -ar 16000 -b:a 64k ${shellEscape(normalizedPath)}`;
825
+ await this.#runCommand(command, {
826
+ cwd: context.jobDir,
827
+ timeout: 3 * 60 * 1000,
828
+ signal: context.signal,
583
829
  });
830
+ if (!fileExists(normalizedPath)) {
831
+ throw new Error('ffmpeg did not produce a normalized audio file for transcription.');
832
+ }
833
+ return normalizedPath;
584
834
  }
585
835
 
586
836
  async #resolveVoiceSttConfig(userId, agentId) {
@@ -599,13 +849,19 @@ class SocialVideoService {
599
849
  }
600
850
 
601
851
  const browser = await Promise.resolve(
602
- this.runtimeManager.getBrowserProviderForUser(context.userId),
603
- ).catch(() => null);
852
+ this.runtimeManager.getBrowserProviderForUser(context.userId, {
853
+ signal: context.signal,
854
+ }),
855
+ ).catch((error) => {
856
+ rethrowCancellation(error, context.signal);
857
+ return null;
858
+ });
604
859
  if (!browser || typeof browser.getCookies !== 'function') {
605
860
  return null;
606
861
  }
607
862
 
608
- const payload = await browser.getCookies().catch((error) => {
863
+ const payload = await browser.getCookies({ signal: context.signal }).catch((error) => {
864
+ rethrowCancellation(error, context.signal);
609
865
  context.warnings.push(`Browser cookie export failed: ${error.message}`);
610
866
  return null;
611
867
  });
@@ -619,11 +875,13 @@ class SocialVideoService {
619
875
 
620
876
  const cookieFilePath = path.join(context.jobDir, 'browser.cookies.txt');
621
877
  await fsp.writeFile(cookieFilePath, serializeCookiesForNetscapeJar(cookies), 'utf8');
878
+ throwIfAborted(context.signal, 'Social video cookie export aborted.');
622
879
  return cookieFilePath;
623
880
  }
624
881
 
625
882
  async #resolveFrameImage(context) {
626
883
  const downloadedFrame = await this.#extractFrameFromVideo(context).catch((error) => {
884
+ rethrowCancellation(error, context.signal);
627
885
  context.warnings.push(`Frame extraction failed: ${error.message}`);
628
886
  return null;
629
887
  });
@@ -636,14 +894,21 @@ class SocialVideoService {
636
894
  context.warnings.push('No thumbnail fallback was available after frame extraction failed.');
637
895
  return null;
638
896
  }
639
- return this.#downloadThumbnailArtifact(context.userId, thumbnail.url);
897
+ return this.#downloadThumbnailArtifact(
898
+ context.userId,
899
+ thumbnail.url,
900
+ context.signal,
901
+ );
640
902
  }
641
903
 
642
904
  async #extractFrameFromVideo(context) {
643
905
  const template = path.join(context.jobDir, 'video.%(ext)s');
644
- const cookieArg = context.cookieFilePath ? ` --cookies ${shellEscape(context.cookieFilePath)}` : '';
645
- const downloadCommand = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg} -o ${shellEscape(template)} -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" --merge-output-format mp4 -- ${shellEscape(context.sourceUrl)}`;
646
- await this.#runCommand(downloadCommand, { cwd: context.jobDir, timeout: 14 * 60 * 1000 });
906
+ const downloadCommand = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg(context.cookieFilePath)} -o ${shellEscape(template)} -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" --merge-output-format mp4 -- ${shellEscape(context.sourceUrl)}`;
907
+ await this.#runCommand(downloadCommand, {
908
+ cwd: context.jobDir,
909
+ timeout: 14 * 60 * 1000,
910
+ signal: context.signal,
911
+ });
647
912
 
648
913
  const videoPath = firstFileMatching(context.jobDir, 'video.');
649
914
  if (!videoPath || !fileExists(videoPath)) {
@@ -653,7 +918,11 @@ class SocialVideoService {
653
918
  const framePath = path.join(context.jobDir, 'frame.jpg');
654
919
  const frameSecond = pickDeterministicFrameSecond(context.mediaInfo.duration);
655
920
  const frameCommand = `${shellEscape(this.ffmpegBin)} -hwaccel none -y -hide_banner -loglevel error -ss ${frameSecond} -i ${shellEscape(videoPath)} -frames:v 1 -q:v 2 ${shellEscape(framePath)}`;
656
- await this.#runCommand(frameCommand, { cwd: context.jobDir, timeout: 2 * 60 * 1000 });
921
+ await this.#runCommand(frameCommand, {
922
+ cwd: context.jobDir,
923
+ timeout: 2 * 60 * 1000,
924
+ signal: context.signal,
925
+ });
657
926
 
658
927
  if (!fileExists(framePath)) {
659
928
  throw new Error('ffmpeg did not produce a frame image.');
@@ -661,14 +930,19 @@ class SocialVideoService {
661
930
  return this.#saveImageArtifact(context.userId, framePath, 'frame');
662
931
  }
663
932
 
664
- async #downloadThumbnailArtifact(userId, thumbnailUrl) {
665
- const response = await fetchWithBrowserHeaders(thumbnailUrl);
666
- if (!response.ok) {
667
- throw new Error(`Thumbnail request failed (${response.status}).`);
668
- }
669
- const buffer = Buffer.from(await response.arrayBuffer());
670
- const guessedExtension = path.extname(new URL(response.url || thumbnailUrl).pathname).replace('.', '') || 'jpg';
671
- const mimeType = String(response.headers.get('content-type') || '').trim() || `image/${guessedExtension}`;
933
+ async #downloadThumbnailArtifact(userId, thumbnailUrl, signal = null) {
934
+ const response = await this.publicResourceFetcher(thumbnailUrl, {
935
+ signal,
936
+ maxResponseBytes: MAX_THUMBNAIL_BYTES,
937
+ responseType: 'buffer',
938
+ accept: 'image/*,*/*',
939
+ });
940
+ const buffer = response.body;
941
+ const guessedExtension = path.extname(
942
+ new URL(response.finalUrl || thumbnailUrl).pathname,
943
+ ).replace('.', '') || 'jpg';
944
+ const mimeType = String(response.headers['content-type'] || '').trim()
945
+ || `image/${guessedExtension}`;
672
946
  if (!this.artifactStore || userId == null) {
673
947
  return {
674
948
  url: null,
@@ -745,6 +1019,7 @@ module.exports = {
745
1019
  detectMimeFromFile,
746
1020
  fileExists,
747
1021
  firstFileMatching,
1022
+ pickDownloadedCaptionFile,
748
1023
  pickBestThumbnail,
749
1024
  classifyExtractionError,
750
1025
  resolveVoiceSttConfigFromSettings,