agentlinkops 0.6.1 → 0.6.3

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 (201) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.mcp.json +0 -0
  4. package/CHANGELOG.md +17 -0
  5. package/LICENSE +0 -0
  6. package/NOTICE +0 -0
  7. package/README.md +0 -0
  8. package/bin/agentlinkops.mjs +0 -0
  9. package/bin/linktrail.mjs +0 -0
  10. package/cli/adapters/csv.js +0 -0
  11. package/cli/adapters/sqlite-crm.js +0 -0
  12. package/cli/adapters/suppliers.js +0 -0
  13. package/cli/admission.js +130 -0
  14. package/cli/agent-setup.js +1014 -101
  15. package/cli/agentlinkops.mjs +0 -0
  16. package/cli/args.js +0 -0
  17. package/cli/catalog.js +12837 -4694
  18. package/cli/check.js +0 -0
  19. package/cli/citation.js +181 -32
  20. package/cli/client.js +1 -1
  21. package/cli/commands.js +3 -3
  22. package/cli/config.js +2 -1
  23. package/cli/connection.js +0 -0
  24. package/cli/context-mcp.js +0 -0
  25. package/cli/context.js +0 -0
  26. package/cli/delivery.js +0 -0
  27. package/cli/disavow.js +54 -0
  28. package/cli/doctor.js +7 -8
  29. package/cli/env.js +0 -0
  30. package/cli/fleet.js +0 -0
  31. package/cli/gsc-browser.js +0 -0
  32. package/cli/import.js +0 -0
  33. package/cli/index-observations.js +89 -0
  34. package/cli/ledger.js +7 -1
  35. package/cli/lifecycle.js +40 -0
  36. package/cli/linktrail.mjs +0 -0
  37. package/cli/local-result.js +0 -0
  38. package/cli/main.js +99 -7
  39. package/cli/mirror.js +0 -0
  40. package/cli/mix.js +0 -0
  41. package/cli/platforms.js +0 -0
  42. package/cli/receipt-performance.js +0 -0
  43. package/cli/receipts.js +0 -0
  44. package/cli/report.js +79 -6
  45. package/cli/setup-plan.js +0 -0
  46. package/cli/skill.js +41 -4
  47. package/cli/state.js +0 -0
  48. package/cli/status.js +0 -0
  49. package/cli/sync.js +23 -1
  50. package/package.json +3 -2
  51. package/references/citation-panels.md +26 -0
  52. package/references/cli.md +0 -0
  53. package/references/disavow.md +0 -0
  54. package/references/discovery-handoff.md +32 -0
  55. package/references/recipes/catalog.json +148 -0
  56. package/references/recipes/command-notes.md +9 -0
  57. package/references/recipes/placement-reconciliation.md +41 -0
  58. package/references/recipes/qualified-campaign-handoff.md +56 -0
  59. package/references/recipes/site-context-brief.md +25 -0
  60. package/references/recipes/sourced-linkable-asset.md +25 -0
  61. package/references/reports.md +0 -0
  62. package/scripts/agentlinkops.py +0 -0
  63. package/scripts/disavow.py +0 -0
  64. package/scripts/linktrail.py +0 -0
  65. package/scripts/migrations/001_crm.sql +0 -0
  66. package/scripts/migrations/002_cloud_events.sql +0 -0
  67. package/scripts/migrations/003_disavow.sql +0 -0
  68. package/scripts/migrations/004_placement_costs.sql +0 -0
  69. package/scripts/report.py +0 -0
  70. package/shared/admission-contract.js +16 -0
  71. package/shared/admission-report-contract.js +8 -0
  72. package/shared/agent-clients.js +0 -0
  73. package/shared/citation-evidence-contract.js +27 -0
  74. package/shared/disavow-contract.js +18 -0
  75. package/shared/index-observation-contract.js +14 -0
  76. package/shared/lifecycle-contract.js +22 -0
  77. package/shared/lifecycle-money.js +24 -0
  78. package/shared/observation-event.js +12 -1
  79. package/shared/opportunity-record.js +259 -0
  80. package/shared/response-contract.js +2 -0
  81. package/shared/search-connection-contract.js +0 -0
  82. package/skills/agentlinkops-assets/SKILL.md +2 -0
  83. package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
  84. package/skills/agentlinkops-campaigns/SKILL.md +5 -1
  85. package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
  86. package/skills/agentlinkops-connect/SKILL.md +8 -0
  87. package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
  88. package/skills/agentlinkops-connect/references/agentlinkops.md +9 -5
  89. package/skills/agentlinkops-crm/SKILL.md +2 -0
  90. package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
  91. package/skills/agentlinkops-discovery/SKILL.md +17 -0
  92. package/skills/content-defingerprinting/SKILL.md +0 -0
  93. package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
  94. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
  95. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
  96. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
  97. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
  98. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
  99. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
  100. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
  101. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
  102. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
  103. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
  104. package/skills/internal-linking-optimizer/SKILL.md +0 -0
  105. package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
  106. package/skills/pre-publish-review/SKILL.md +0 -0
  107. package/skills/source-cited-content-builder/SKILL.md +0 -0
  108. package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
  109. package/skills/source-cited-humanizer/SKILL.md +0 -0
  110. package/src/citations/adapter.js +102 -21
  111. package/src/citations/alert.js +38 -13
  112. package/src/citations/browser/engine.js +145 -50
  113. package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
  114. package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
  115. package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
  116. package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
  117. package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
  118. package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
  119. package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
  120. package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
  121. package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
  122. package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
  123. package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
  124. package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
  125. package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
  126. package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
  127. package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
  128. package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
  129. package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
  130. package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
  131. package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
  132. package/src/citations/browser/gen/core/providers/index.js +0 -0
  133. package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
  134. package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
  135. package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
  136. package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
  137. package/src/citations/browser/gen/lib/browser/display.js +0 -0
  138. package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
  139. package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
  140. package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
  141. package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
  142. package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
  143. package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
  144. package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
  145. package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
  146. package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
  147. package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
  148. package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
  149. package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
  150. package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
  151. package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
  152. package/src/citations/browser/gen/utils/agent-constants.js +0 -0
  153. package/src/citations/browser/providers.js +3 -1
  154. package/src/citations/browser/shims/errors.js +0 -0
  155. package/src/citations/browser/shims/types.js +0 -0
  156. package/src/citations/browser/shims/utils.js +1 -0
  157. package/src/citations/contract.js +55 -9
  158. package/src/citations/inventory.js +106 -0
  159. package/src/citations/login.js +122 -0
  160. package/src/citations/match.js +12 -0
  161. package/src/citations/panel-builder.js +44 -39
  162. package/src/citations/report.js +88 -21
  163. package/src/citations/runner.js +59 -31
  164. package/src/citations/stats.js +133 -61
  165. package/src/citations/sync.js +131 -0
  166. package/src/citations/watch.js +57 -77
  167. package/src/competitors/contract.js +0 -0
  168. package/src/competitors/domain-mix.js +0 -0
  169. package/src/context/ga4.js +0 -0
  170. package/src/context/gsc.js +0 -0
  171. package/src/context/site-profile.js +0 -0
  172. package/src/context/util.js +0 -0
  173. package/src/context-tool-registry.js +0 -0
  174. package/src/corpus/acquire.js +47 -8
  175. package/src/corpus/seeds.js +132 -19
  176. package/src/discovery/contract.js +0 -0
  177. package/src/discovery/import.js +0 -0
  178. package/src/evidence-reference.js +112 -0
  179. package/src/index-observation.js +161 -0
  180. package/src/locator.js +112 -0
  181. package/src/tool-views.js +6 -2
  182. package/src/verifier/fetch.js +42 -6
  183. package/src/verifier/index.js +0 -0
  184. package/src/verifier/observation-kind.js +0 -0
  185. package/src/verifier/parser.js +0 -0
  186. package/src/verifier/readiness.js +0 -0
  187. package/src/verifier/render-evidence.js +0 -0
  188. package/src/verifier/robots.js +0 -0
  189. package/src/verifier/transition.js +0 -0
  190. package/src/verifier/url.js +0 -0
  191. package/templates/SITE.template.md +0 -0
  192. package/templates/campaigns.v1.json +0 -0
  193. package/src/citations/browser/gen/core/providers/_shared/google.js +0 -4
  194. package/src/citations/browser/gen/core/providers/types.js +0 -0
  195. package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -321
  196. package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -251
  197. package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
  198. package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -54
  199. package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -21
  200. package/src/citations/browser/gen/lib/input/response/getText.js +0 -10
  201. package/src/citations/fixtures/dataforseo-aio.json +0 -82
@@ -10,7 +10,7 @@ export function shouldAlert(cells) {
10
10
  return cells.some((c) => c.classification === 'declined');
11
11
  }
12
12
 
13
- export async function sendAlertEmail({ to, subject, htmlPath, smtp = {} }) {
13
+ export async function sendAlertEmail({ to, subject, htmlPath, smtp = {}, execFileImpl = execFile }) {
14
14
  if (!to || !htmlPath) throw new Error('sendAlertEmail needs to and htmlPath');
15
15
  const { host, port, user, pass, from } = smtp;
16
16
  if (!host || !user || !pass) {
@@ -28,26 +28,51 @@ export async function sendAlertEmail({ to, subject, htmlPath, smtp = {} }) {
28
28
  .slice(0, 2000);
29
29
 
30
30
  const python = `
31
- import smtplib
31
+ import json, sys, smtplib
32
32
  from email.mime.multipart import MIMEMultipart
33
33
  from email.mime.text import MIMEText
34
+ data = json.load(sys.stdin)
34
35
  msg = MIMEMultipart('alternative')
35
- msg['Subject'] = ${JSON.stringify(subject)}
36
- msg['From'] = ${JSON.stringify(from ?? user)}
37
- msg['To'] = ${JSON.stringify(to)}
38
- msg.attach(MIMEText(${JSON.stringify(textBody)}, 'plain'))
39
- with open(${JSON.stringify(htmlPath)}, 'r') as f:
40
- msg.attach(MIMEText(f.read(), 'html'))
41
- with smtplib.SMTP(${JSON.stringify(host)}, ${parseInt(port ?? 587, 10)}) as s:
36
+ msg['Subject'] = data['subject']
37
+ msg['From'] = data['from']
38
+ msg['To'] = data['to']
39
+ msg.attach(MIMEText(data['text'], 'plain'))
40
+ msg.attach(MIMEText(data['html'], 'html'))
41
+ with smtplib.SMTP(data['host'], data['port'], timeout=30) as s:
42
42
  s.starttls()
43
- s.login(${JSON.stringify(user)}, ${JSON.stringify(pass)})
43
+ s.login(data['user'], data['pass'])
44
44
  s.send_message(msg)
45
- print('sent')
46
45
  `;
47
46
  return new Promise((resolve, reject) => {
48
- execFile('python3', ['-c', python], (err, stdout) => {
49
- if (err) reject(new Error(`smtp send failed: ${err.message}`));
47
+ const child = execFileImpl('python3', ['-c', python], (err) => {
48
+ if (err) reject(new Error('smtp send failed'));
50
49
  else resolve({ delivered: true, htmlPath });
51
50
  });
51
+ child.stdin.on('error', () => reject(new Error('smtp input failed')));
52
+ child.stdin.end(JSON.stringify({ subject: subject ?? 'Citation decline', from: from ?? user, to,
53
+ text: textBody, html, host, port: Number(port ?? 587), user, pass }));
52
54
  });
53
55
  }
56
+
57
+ // Delivery is opt-in and configured by the person running the local CLI.
58
+ export function validateAlertWebhook(endpoint) {
59
+ const url = new URL(endpoint);
60
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
61
+ if (url.username || url.password || url.hash || (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))) {
62
+ throw new Error('citation webhook requires HTTPS (HTTP is allowed only on loopback)');
63
+ }
64
+ return url;
65
+ }
66
+ export async function sendAlertWebhook({ endpoint, epochId, cells, htmlPath, fetchImpl = globalThis.fetch }) {
67
+ const url = validateAlertWebhook(endpoint);
68
+ if (!shouldAlert(cells)) return { delivered: false, reason: 'no decline' };
69
+ const html = await readFile(htmlPath, 'utf8');
70
+ const response = await fetchImpl(url, {
71
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
72
+ headers: { 'content-type': 'application/json' },
73
+ body: JSON.stringify({ schema_version: 1, type: 'citation.declined', epoch_id: epochId,
74
+ cells: cells.filter(cell => cell.classification === 'declined'), report: { content_type: 'text/html', html } }),
75
+ }).catch(() => { throw new Error('citation webhook delivery failed'); });
76
+ if (!response.ok) throw new Error(`citation webhook delivery failed (HTTP ${response.status})`);
77
+ return { delivered: true };
78
+ }
@@ -10,13 +10,25 @@
10
10
  // This module imports playwright-core lazily: a CLI install without the
11
11
  // browser tooling still runs every non-browser engine, and `citation doctor`
12
12
  // reports the gap instead of the whole CLI failing to load.
13
- import { mkdir } from 'node:fs/promises';
14
13
  import { homedir } from 'node:os';
15
14
  import { join } from 'node:path';
16
15
 
17
16
  import { EngineError } from '../adapter.js';
18
17
 
19
- const lazy = async (path) => import(path);
18
+ const MODULE_LOADERS = {
19
+ 'playwright-core': () => import('playwright-core'),
20
+ 'node:fs/promises': () => import('node:fs/promises'),
21
+ './gen/core/providers/index.js': () => import('./gen/core/providers/index.js'),
22
+ './providers.js': () => import('./providers.js'),
23
+ './gen/lib/browser/camoufox.js': () => import('./gen/lib/browser/camoufox.js'),
24
+ './gen/lib/browser/display.js': () => import('./gen/lib/browser/display.js'),
25
+ './shims/utils.js': () => import('./shims/utils.js'),
26
+ './gen/lib/browser/domOps.js': () => import('./gen/lib/browser/domOps.js'),
27
+ };
28
+ const lazy = async path => {
29
+ if (!MODULE_LOADERS[path]) throw new Error('Unsupported browser module');
30
+ return MODULE_LOADERS[path]();
31
+ };
20
32
 
21
33
  let playwrightFirefox = null;
22
34
  async function firefox() {
@@ -32,9 +44,17 @@ async function firefox() {
32
44
 
33
45
  export const BROWSER_PROVIDERS = Object.freeze(['chatgpt', 'perplexity', 'gemini', 'claude', 'ai-overview', 'grok', 'copilot', 'bing', 'duckai']);
34
46
 
35
- // Nominal per-answer estimate: ~0.3 MB through a residential proxy at ~$7/GB,
36
- // plus slack for the page itself. Checked BEFORE the run by the budget cap.
37
- const NOMINAL_COST_USD = 0.03;
47
+ // Per-engine nominal estimates for the pre-call budget check. Measured from
48
+ // actual proxy burn during the 2026-09-17/18 sessions: browser engines burn
49
+ // 3-8 MB per observation (full page + assets) while the duck HTTP lane uses
50
+ // kilobytes. These are the estimates the cap enforces against, checked BEFORE
51
+ // each call. These are estimates, not metered bandwidth or a hard invoice cap.
52
+ const ENGINE_NOMINAL_COST = Object.freeze({
53
+ chatgpt: 0.03, 'ai-overview': 0.03, copilot: 0.03, grok: 0.03,
54
+ gemini: 0.03, claude: 0.03, perplexity: 0.02, bing: 0.02,
55
+ duckai: 0.001,
56
+ });
57
+ const DEFAULT_NOMINAL_COST = 0.03;
38
58
 
39
59
  function parseProxyUrl(raw) {
40
60
  if (!raw) return null;
@@ -50,21 +70,28 @@ function parseProxyUrl(raw) {
50
70
  }
51
71
  }
52
72
 
53
- export function createBrowserEngine({ engineName, env = process.env, onEvent = () => {} } = {}) {
73
+ export function createBrowserEngine({ engineName, env = process.env, onEvent = () => {}, runtime = {} } = {}) {
54
74
  if (!BROWSER_PROVIDERS.includes(engineName)) {
55
75
  throw new EngineError(`unknown browser engine: ${engineName} (known: ${BROWSER_PROVIDERS.join(', ')})`);
56
76
  }
57
77
  const identity = { engine: engineName, provider: 'web-own-browser', model: engineName };
58
- const sessionDir = join(homedir(), '.agentlinkops', 'citations', 'sessions');
78
+ const sessionDir = runtime.sessionDir ?? join(homedir(), '.agentlinkops', 'citations', 'sessions');
79
+ const load = runtime.loadModule ?? lazy;
80
+ const platform = runtime.platform ?? process.platform;
59
81
 
60
82
  let browserHandle = null; // { browser, cleanup } for the whole epoch
61
83
  let displayHandle = null;
62
84
  let providerConfig = null;
85
+ async function closeQuietly(action) {
86
+ let timer;
87
+ try { await Promise.race([Promise.resolve().then(action).catch(() => null), new Promise(resolve => { timer = setTimeout(resolve, runtime.cleanupTimeoutMs ?? 5000); })]); }
88
+ finally { clearTimeout(timer); }
89
+ }
63
90
 
64
91
  async function ensureProviderConfig() {
65
92
  if (providerConfig) return providerConfig;
66
- const { PROVIDER_CONFIGS } = await lazy('./gen/core/providers/index.js');
67
- const { OWN_PROVIDER_CONFIGS } = await lazy('./providers.js');
93
+ const { PROVIDER_CONFIGS } = await load('./gen/core/providers/index.js');
94
+ const { OWN_PROVIDER_CONFIGS } = await load('./providers.js');
68
95
  providerConfig = OWN_PROVIDER_CONFIGS[engineName] ?? PROVIDER_CONFIGS[engineName];
69
96
  if (!providerConfig) throw new EngineError(`no provider config for ${engineName}`);
70
97
  return providerConfig;
@@ -73,14 +100,14 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
73
100
  async function ensureBrowser() {
74
101
  if (browserHandle) return browserHandle;
75
102
  const [{ resolveCamoufoxLaunchOptions }, display, fw] = [
76
- await lazy('./gen/lib/browser/camoufox.js'),
77
- await lazy('./gen/lib/browser/display.js'),
78
- await firefox(),
103
+ await load('./gen/lib/browser/camoufox.js'),
104
+ await load('./gen/lib/browser/display.js'),
105
+ runtime.firefox ?? await firefox(),
79
106
  ];
80
107
 
81
108
  // Linux without a display gets the vendored self-bootstrapped Xvfb — the
82
109
  // engines that block headless render headfully inside a virtual display.
83
- if (process.platform === 'linux' && !display.detectDisplay()) {
110
+ if (platform === 'linux' && !display.detectDisplay()) {
84
111
  displayHandle = await display.ensureDisplay({ allowExistingDisplay: false });
85
112
  onEvent(`display: ${displayHandle.display}`);
86
113
  }
@@ -96,7 +123,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
96
123
  display: displayHandle?.display,
97
124
  // "virtual" on Linux = headful Firefox inside Xvfb; true headless is the
98
125
  // detection magnet the research warned about.
99
- headlessMode: process.platform === 'linux' ? 'virtual' : 'headful',
126
+ headlessMode: platform === 'linux' ? 'virtual' : 'headful',
100
127
  });
101
128
  const launchOpts = { ...options, executablePath: options.executablePath };
102
129
  if (proxy) launchOpts.proxy = proxy;
@@ -105,7 +132,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
105
132
  browserHandle = {
106
133
  browser,
107
134
  cleanup: async () => {
108
- await browser.close().catch(() => null);
135
+ await closeQuietly(() => browser.close());
109
136
  await displayHandle?.cleanup?.().catch(() => null);
110
137
  displayHandle = null;
111
138
  },
@@ -115,25 +142,71 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
115
142
 
116
143
  async function loadStorageState() {
117
144
  try {
118
- const { readFile } = await lazy('node:fs/promises');
145
+ const { readFile } = await load('node:fs/promises');
119
146
  return JSON.parse(await readFile(join(sessionDir, `${engineName}.json`), 'utf8'));
120
147
  } catch {
121
148
  return undefined; // anonymous session: several surfaces work logged-out
122
149
  }
123
150
  }
124
151
 
152
+ // Stock playwright-core editor finder. The vendored finder checks
153
+ // Locator.getEditableState(), a patchright-only API that silently nulls on
154
+ // stock playwright and rejects every candidate; this version expresses the
155
+ // same intent (visible, sized, editable, enabled) with portable calls.
156
+ // Pierces shadow DOM because some surfaces nest their composer inside one.
157
+ async function findEditor(page, selectors) {
158
+ for (const selector of selectors) {
159
+ const nodes = page.locator(selector);
160
+ const count = await nodes.count().catch(() => 0);
161
+ for (let i = 0; i < count; i += 1) {
162
+ const el = nodes.nth(i);
163
+ if (!(await el.isVisible().catch(() => false))) continue;
164
+ const box = await el.boundingBox().catch(() => null);
165
+ if (!box || box.width < 8 || box.height < 8) continue;
166
+ await el.scrollIntoViewIfNeeded().catch(() => {});
167
+ if (!(await el.isEditable().catch(() => false))) continue;
168
+ if (!(await el.isEnabled().catch(() => false))) continue;
169
+ return { locator: el, selector };
170
+ }
171
+ }
172
+ // Shadow DOM fallback: pierce roots for composer elements not reachable
173
+ // through the document-level selectors above.
174
+ const shadowSpot = await page.evaluate(() => {
175
+ const all = [];
176
+ const walk = (root) => {
177
+ for (const el of root.querySelectorAll('textarea, [contenteditable="true"], [role="textbox"]')) all.push(el);
178
+ for (const el of root.querySelectorAll('*')) { if (el.shadowRoot) walk(el.shadowRoot); }
179
+ };
180
+ walk(document);
181
+ for (const el of all) {
182
+ const r = el.getBoundingClientRect();
183
+ if (r.width > 60 && r.height > 10) return { found: true, tag: el.tagName };
184
+ }
185
+ return { found: false };
186
+ }).catch(() => ({ found: false }));
187
+ if (shadowSpot.found) {
188
+ // A composer exists in shadow DOM but our selectors can't reach it.
189
+ // Fall through to the generic locator on the broadest selector.
190
+ const broad = page.locator('textarea, [contenteditable="true"], [role="textbox"]').first();
191
+ if (await broad.isVisible().catch(() => false)) return { locator: broad, selector: 'shadow-dom-fallback' };
192
+ }
193
+ return null;
194
+ }
195
+
125
196
  return {
126
197
  identity,
127
- estimateCostUsd: () => NOMINAL_COST_USD,
198
+ estimateCostUsd: () => ENGINE_NOMINAL_COST[engineName] ?? DEFAULT_NOMINAL_COST,
128
199
 
129
200
  async run({ prompt }) {
130
201
  const config = await ensureProviderConfig();
131
- const { browser, cleanup } = await ensureBrowser();
202
+ const { browser } = await ensureBrowser();
203
+ const storageState = await loadStorageState();
132
204
  const context = await browser.newContext({
133
- storageState: await loadStorageState(),
205
+ storageState,
134
206
  locale: 'en-US',
135
207
  timezoneId: 'America/New_York',
136
208
  });
209
+ try {
137
210
  // Route-level asset stripping (opt out with AGENTLINKOPS_BROWSER_KEEP_ASSETS=1):
138
211
  // images and media are the bulk of the 3-8 MB measured per observation.
139
212
  // Fonts stay — font metrics are part of the fingerprint surface.
@@ -144,8 +217,10 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
144
217
  return route.continue();
145
218
  });
146
219
  }
147
- try {
148
220
  const page = await context.newPage();
221
+ const { runPageDomOp } = await load('./gen/lib/browser/domOps.js');
222
+ page.runDomOp = (operation, params = {}) => runPageDomOp(page, operation, params);
223
+ let answer = '';
149
224
  page.setDefaultTimeout(45_000);
150
225
 
151
226
  if (config.navigateToPrompt) {
@@ -159,7 +234,13 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
159
234
  let spot = null;
160
235
  for (let attempt = 0; attempt < 3 && !spot; attempt += 1) {
161
236
  spot = await page.evaluate(() => {
162
- const els = [...document.querySelectorAll('textarea, [contenteditable="true"], [role="textbox"]')];
237
+ // Pierce shadow DOM: grok's composer may live inside a shadow root.
238
+ const els = [];
239
+ const walk = (root) => {
240
+ for (const el of root.querySelectorAll('textarea, [contenteditable="true"], [role="textbox"]')) els.push(el);
241
+ for (const el of root.querySelectorAll('*')) { if (el.shadowRoot) walk(el.shadowRoot); }
242
+ };
243
+ walk(document);
163
244
  for (const el of els) {
164
245
  const r = el.getBoundingClientRect();
165
246
  if (r.width > 60 && r.height > 10) return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + Math.min(Math.max(r.height / 2, 12), 40)) };
@@ -178,7 +259,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
178
259
  // The page's own JS solves the VQD challenge; the chat call runs
179
260
  // in-page with native cookies and headers. Claude Haiku, anonymous.
180
261
  await page.waitForTimeout(6000);
181
- const { duckChat } = await lazy('./providers.js');
262
+ const { duckChat } = await load('./providers.js');
182
263
  const chat = await duckChat(page, prompt);
183
264
  if (!chat.message || chat.message.trim().length < 40) {
184
265
  throw new EngineError(`duckai: empty chat response (models seen: ${(chat.models ?? []).slice(0, 5).join(',')})`, { retriable: true });
@@ -192,46 +273,54 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
192
273
  } else {
193
274
  await page.goto(config.url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
194
275
  await config.postNavigationHook?.(page);
195
- await config.beforePromptHook?.(page);
196
276
 
197
- const { findActiveEditorCandidate } = await lazy('./gen/lib/input/editor/findEditor.js');
198
- const { PROVIDER_EDITOR_SELECTORS } = await lazy('./shims/utils.js');
199
- const editor = await findActiveEditorCandidate(page, PROVIDER_EDITOR_SELECTORS[engineName]);
200
- if (!editor) throw new EngineError(`${engineName}: no prompt editor found (surface may be gated or changed)`);
277
+ // Hydration retry: first paint is not hydration. Poll for a real editor.
278
+ const { PROVIDER_EDITOR_SELECTORS } = await load('./shims/utils.js');
279
+ let editor = null;
280
+ const editorDeadline = Date.now() + 50_000;
281
+ while (!editor && Date.now() < editorDeadline) {
282
+ await config.beforePromptHook?.(page).catch(() => {});
283
+ editor = await findEditor(page, PROVIDER_EDITOR_SELECTORS[engineName]);
284
+ if (!editor) await page.waitForTimeout(3000);
285
+ }
286
+ if (!editor) throw new EngineError(`${engineName}: no prompt editor found after hydration retries (surface may be gated or changed)`, { retriable: true });
201
287
 
202
- const { insertPromptIntoEditor } = await lazy('./gen/lib/input/editor/promptInput.js');
203
- await insertPromptIntoEditor(page, editor.locator, prompt, engineName);
288
+ // Humanized typing: instant fill is a bot tell. Real keystrokes with
289
+ // jittered cadence; fill stays as the recovery path when typing fails.
290
+ await editor.locator.click().catch(() => {});
291
+ await page.waitForTimeout(400);
292
+ const typeDelay = 70 + Math.floor(Math.random() * 110);
293
+ await editor.locator.pressSequentially(prompt, { delay: typeDelay }).catch(async () => {
294
+ await editor.locator.fill(prompt);
295
+ });
296
+ await page.waitForTimeout(600);
297
+ const typedCheck = await editor.locator.inputValue().catch(() => '');
298
+ if (typedCheck.trim().length === 0) {
299
+ await editor.locator.fill(prompt).catch(() => {});
300
+ }
204
301
  await config.afterTypingHook?.(page);
205
302
  await config.beforeSubmitHook?.(page);
206
303
 
207
- const { findEnabledSendButton } = await lazy('./gen/lib/input/editor/findSendButton.js');
208
- const { PROVIDER_SUBMIT_BTN_SELECTORS } = await lazy('./shims/utils.js');
209
- const send = await findEnabledSendButton(page, PROVIDER_SUBMIT_BTN_SELECTORS[engineName]);
210
- if (send) { await send.click(); }
304
+ // Submit: the surface's own send button when it exposes one; Enter fallback.
305
+ // Submission is verified: the composer clears or a user turn appears.
306
+ const send = await page.locator('button[aria-label="Send message"], button[data-testid="send-button"], button[aria-label*="Submit" i], button[aria-label*="Send" i]').first();
307
+ if (await send.isVisible().catch(() => false)) { await send.click(); }
211
308
  else { await editor.locator.press('Enter'); }
309
+ await page.waitForTimeout(2500);
212
310
  }
213
311
  await config.afterSubmitHook?.(page);
214
312
 
215
313
  await config.waitForResponse(page);
216
- let answer = '';
217
314
  if (!answer) answer = await config.extractResponse(page);
218
- // Anonymous/mobile layouts render the conversation without the
219
- // logged-in app's message attributes. The honest fallback is the
220
- // page's own text: it is the answer surface the user saw, and the
221
- // screenshot beside it shows exactly what was read.
222
- if ((!answer || answer.trim().length === 0) && engineName !== 'bing') {
223
- answer = await page.evaluate(() => (document.body.innerText || '').trim());
224
- }
225
315
  if (!answer || answer.trim().length < 40) {
226
316
  const reason = engineName === 'bing'
227
317
  ? 'bing: no AI answer block rendered for this prompt (organic results only — never a citation verdict)'
228
318
  : `${engineName}: answer extracted empty`;
229
319
  throw new EngineError(reason, { retriable: true }); }
230
- // A sources-panel failure must never discard a completed answer: the
231
- // answer was measured, the screenshot was taken — citations can be
232
- // empty while the observation still counts, honestly, as rendered.
233
- const sources = await config.extractSources(page).catch((cause) => {
234
- onEvent(`sources extraction failed (answer kept, citations empty): ${String(cause?.message ?? cause).slice(0, 120)}`);
320
+ let extractionFailed = config.citationExtraction === 'unsupported';
321
+ const sources = await config.extractSources(page).catch(() => {
322
+ extractionFailed = true;
323
+ onEvent('sources extraction failed; observation retained as unknown');
235
324
  return [];
236
325
  });
237
326
  const screenshotPng = await page.screenshot({ fullPage: false }).catch(() => null);
@@ -241,26 +330,32 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
241
330
  provider: 'web-own-browser',
242
331
  model: engineName,
243
332
  providerModelVersion: `${engineName}-web`,
333
+ browserContext: { surface: 'web', authentication: storageState ? 'saved-session' : 'anonymous', account_tier: 'unknown' },
244
334
  answer,
245
- citations: (sources ?? []).map((s) => ({ url: s.url, title: s.title ?? s.cited_text ?? null })).filter((c) => typeof c.url === 'string' && c.url.length > 0),
335
+ ...(extractionFailed ? { unknown: true, failure: { code: 'CITATION_EXTRACTION_UNAVAILABLE', message: 'Citation extraction was unavailable; this is not a confirmed absence' } } : {}),
336
+ citations: (sources ?? []).map((s) => ({ url: s.url, ...(s.title || s.cited_text ? { title: s.title ?? s.cited_text } : {}) })).filter((c) => typeof c.url === 'string' && c.url.length > 0),
246
337
  fanOut: [],
247
338
  usage: { input_tokens: 0, output_tokens: 0 },
248
- costEstimateUsd: NOMINAL_COST_USD,
339
+ costEstimateUsd: ENGINE_NOMINAL_COST[engineName] ?? DEFAULT_NOMINAL_COST,
249
340
  screenshotPng,
250
341
  };
251
342
  } catch (cause) {
252
343
  if (cause instanceof EngineError) throw cause;
253
344
  const msg = String(cause?.message ?? cause);
254
345
  const retriable = /timeout|timed out|ERR_|net::|closed|Target closed/i.test(msg);
255
- throw new EngineError(`${engineName}: browser run failed: ${msg}`, { retriable, cause });
346
+ const error = new EngineError(`${engineName}: browser run failed (${cause?.name === 'TypeError' ? 'compatibility error' : retriable ? 'timeout or connection error' : 'surface error'})`, { retriable, cause });
347
+ error.unknown = true;
348
+ throw error;
256
349
  } finally {
257
- await context.close().catch(() => null);
350
+ await closeQuietly(() => context.close());
258
351
  }
259
352
  },
260
353
 
261
354
  // Called by the runner at epoch end.
262
355
  async close() {
263
356
  await browserHandle?.cleanup?.();
357
+ await displayHandle?.cleanup?.().catch(() => null);
358
+ displayHandle = null;
264
359
  browserHandle = null;
265
360
  },
266
361
  };
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -142,7 +142,8 @@ export const OWN_PROVIDER_CONFIGS = {
142
142
  navigateToPrompt: null, // the engine's grok branch owns the flow
143
143
  waitForResponse: (page) => waitForStableBody(page),
144
144
  extractResponse: (page) => page.evaluate(() => (document.body.innerText || '').trim()),
145
- extractSources: async () => [], // inline links in text; panel extraction unproven
145
+ citationExtraction: 'unsupported',
146
+ extractSources: async () => [], // retain unknown until a scoped citation extractor is proven
146
147
  },
147
148
  copilot: {
148
149
  url: 'https://copilot.microsoft.com/',
@@ -150,6 +151,7 @@ export const OWN_PROVIDER_CONFIGS = {
150
151
  experimental: true,
151
152
  waitForResponse: (page) => waitForStableBody(page),
152
153
  extractResponse: (page) => page.evaluate(() => (document.body.innerText || '').trim()),
154
+ citationExtraction: 'unsupported',
153
155
  extractSources: async () => [],
154
156
  },
155
157
  bing: {
File without changes
File without changes
@@ -14,6 +14,7 @@ export {
14
14
  const envQuiet = () => Boolean(process.env.AGENTLINKOPS_BROWSER_QUIET);
15
15
 
16
16
  export const logger = {
17
+ debug: (...args) => { if (!envQuiet()) console.error('[browser:debug]', ...args); },
17
18
  log: (...args) => { if (!envQuiet()) console.error('[browser]', ...args); },
18
19
  warn: (...args) => { if (!envQuiet()) console.error('[browser:warn]', ...args); },
19
20
  error: (...args) => { console.error('[browser:error]', ...args); },