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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.json +0 -0
- package/CHANGELOG.md +17 -0
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/README.md +0 -0
- package/bin/agentlinkops.mjs +0 -0
- package/bin/linktrail.mjs +0 -0
- package/cli/adapters/csv.js +0 -0
- package/cli/adapters/sqlite-crm.js +0 -0
- package/cli/adapters/suppliers.js +0 -0
- package/cli/admission.js +130 -0
- package/cli/agent-setup.js +1014 -101
- package/cli/agentlinkops.mjs +0 -0
- package/cli/args.js +0 -0
- package/cli/catalog.js +12837 -4694
- package/cli/check.js +0 -0
- package/cli/citation.js +181 -32
- package/cli/client.js +1 -1
- package/cli/commands.js +3 -3
- package/cli/config.js +2 -1
- package/cli/connection.js +0 -0
- package/cli/context-mcp.js +0 -0
- package/cli/context.js +0 -0
- package/cli/delivery.js +0 -0
- package/cli/disavow.js +54 -0
- package/cli/doctor.js +7 -8
- package/cli/env.js +0 -0
- package/cli/fleet.js +0 -0
- package/cli/gsc-browser.js +0 -0
- package/cli/import.js +0 -0
- package/cli/index-observations.js +89 -0
- package/cli/ledger.js +7 -1
- package/cli/lifecycle.js +40 -0
- package/cli/linktrail.mjs +0 -0
- package/cli/local-result.js +0 -0
- package/cli/main.js +99 -7
- package/cli/mirror.js +0 -0
- package/cli/mix.js +0 -0
- package/cli/platforms.js +0 -0
- package/cli/receipt-performance.js +0 -0
- package/cli/receipts.js +0 -0
- package/cli/report.js +79 -6
- package/cli/setup-plan.js +0 -0
- package/cli/skill.js +41 -4
- package/cli/state.js +0 -0
- package/cli/status.js +0 -0
- package/cli/sync.js +23 -1
- package/package.json +3 -2
- package/references/citation-panels.md +26 -0
- package/references/cli.md +0 -0
- package/references/disavow.md +0 -0
- package/references/discovery-handoff.md +32 -0
- package/references/recipes/catalog.json +148 -0
- package/references/recipes/command-notes.md +9 -0
- package/references/recipes/placement-reconciliation.md +41 -0
- package/references/recipes/qualified-campaign-handoff.md +56 -0
- package/references/recipes/site-context-brief.md +25 -0
- package/references/recipes/sourced-linkable-asset.md +25 -0
- package/references/reports.md +0 -0
- package/scripts/agentlinkops.py +0 -0
- package/scripts/disavow.py +0 -0
- package/scripts/linktrail.py +0 -0
- package/scripts/migrations/001_crm.sql +0 -0
- package/scripts/migrations/002_cloud_events.sql +0 -0
- package/scripts/migrations/003_disavow.sql +0 -0
- package/scripts/migrations/004_placement_costs.sql +0 -0
- package/scripts/report.py +0 -0
- package/shared/admission-contract.js +16 -0
- package/shared/admission-report-contract.js +8 -0
- package/shared/agent-clients.js +0 -0
- package/shared/citation-evidence-contract.js +27 -0
- package/shared/disavow-contract.js +18 -0
- package/shared/index-observation-contract.js +14 -0
- package/shared/lifecycle-contract.js +22 -0
- package/shared/lifecycle-money.js +24 -0
- package/shared/observation-event.js +12 -1
- package/shared/opportunity-record.js +259 -0
- package/shared/response-contract.js +2 -0
- package/shared/search-connection-contract.js +0 -0
- package/skills/agentlinkops-assets/SKILL.md +2 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
- package/skills/agentlinkops-campaigns/SKILL.md +5 -1
- package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/SKILL.md +8 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +9 -5
- package/skills/agentlinkops-crm/SKILL.md +2 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
- package/skills/agentlinkops-discovery/SKILL.md +17 -0
- package/skills/content-defingerprinting/SKILL.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
- package/skills/internal-linking-optimizer/SKILL.md +0 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
- package/skills/pre-publish-review/SKILL.md +0 -0
- package/skills/source-cited-content-builder/SKILL.md +0 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
- package/skills/source-cited-humanizer/SKILL.md +0 -0
- package/src/citations/adapter.js +102 -21
- package/src/citations/alert.js +38 -13
- package/src/citations/browser/engine.js +145 -50
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
- package/src/citations/browser/gen/lib/browser/display.js +0 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
- package/src/citations/browser/gen/utils/agent-constants.js +0 -0
- package/src/citations/browser/providers.js +3 -1
- package/src/citations/browser/shims/errors.js +0 -0
- package/src/citations/browser/shims/types.js +0 -0
- package/src/citations/browser/shims/utils.js +1 -0
- package/src/citations/contract.js +55 -9
- package/src/citations/inventory.js +106 -0
- package/src/citations/login.js +122 -0
- package/src/citations/match.js +12 -0
- package/src/citations/panel-builder.js +44 -39
- package/src/citations/report.js +88 -21
- package/src/citations/runner.js +59 -31
- package/src/citations/stats.js +133 -61
- package/src/citations/sync.js +131 -0
- package/src/citations/watch.js +57 -77
- package/src/competitors/contract.js +0 -0
- package/src/competitors/domain-mix.js +0 -0
- package/src/context/ga4.js +0 -0
- package/src/context/gsc.js +0 -0
- package/src/context/site-profile.js +0 -0
- package/src/context/util.js +0 -0
- package/src/context-tool-registry.js +0 -0
- package/src/corpus/acquire.js +47 -8
- package/src/corpus/seeds.js +132 -19
- package/src/discovery/contract.js +0 -0
- package/src/discovery/import.js +0 -0
- package/src/evidence-reference.js +112 -0
- package/src/index-observation.js +161 -0
- package/src/locator.js +112 -0
- package/src/tool-views.js +6 -2
- package/src/verifier/fetch.js +42 -6
- package/src/verifier/index.js +0 -0
- package/src/verifier/observation-kind.js +0 -0
- package/src/verifier/parser.js +0 -0
- package/src/verifier/readiness.js +0 -0
- package/src/verifier/render-evidence.js +0 -0
- package/src/verifier/robots.js +0 -0
- package/src/verifier/transition.js +0 -0
- package/src/verifier/url.js +0 -0
- package/templates/SITE.template.md +0 -0
- package/templates/campaigns.v1.json +0 -0
- package/src/citations/browser/gen/core/providers/_shared/google.js +0 -4
- package/src/citations/browser/gen/core/providers/types.js +0 -0
- package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -321
- package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -251
- package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -54
- package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -21
- package/src/citations/browser/gen/lib/input/response/getText.js +0 -10
- package/src/citations/fixtures/dataforseo-aio.json +0 -82
package/src/citations/alert.js
CHANGED
|
@@ -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'] =
|
|
36
|
-
msg['From'] =
|
|
37
|
-
msg['To'] =
|
|
38
|
-
msg.attach(MIMEText(
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
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
|
-
|
|
49
|
-
if (err) reject(new Error(
|
|
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
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
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
|
|
67
|
-
const { OWN_PROVIDER_CONFIGS } = await
|
|
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
|
|
77
|
-
await
|
|
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 (
|
|
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:
|
|
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
|
|
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
|
|
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: () =>
|
|
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
|
|
202
|
+
const { browser } = await ensureBrowser();
|
|
203
|
+
const storageState = await loadStorageState();
|
|
132
204
|
const context = await browser.newContext({
|
|
133
|
-
storageState
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
198
|
-
const { PROVIDER_EDITOR_SELECTORS } = await
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
const send = await
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|
-
|
|
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); },
|