agentlinkops 0.6.6 → 0.6.8
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/CHANGELOG.md +12 -0
- package/cli/citation.js +9 -6
- package/cli/client.js +1 -1
- package/package.json +1 -1
- package/references/citation-panels.md +3 -1
- package/skills/agentlinkops-campaigns/SKILL.md +1 -1
- package/skills/agentlinkops-connect/references/agentlinkops.md +1 -1
- package/src/citations/browser/chatgpt-citations.js +215 -0
- package/src/citations/browser/engine.js +34 -4
- package/src/citations/browser/gen/lib/browser/domOps.js +5 -3
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +8 -7
- package/src/citations/browser/shims/utils.js +10 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlinkops",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "AgentLinkOps"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlinkops",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "AgentLinkOps"
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
All notable changes to the `agentlinkops` package are recorded here. The version follows the
|
|
4
4
|
plugin manifests in `.claude-plugin/` and `.codex-plugin/`.
|
|
5
5
|
|
|
6
|
+
## 0.6.8 (2026-09-20)
|
|
7
|
+
|
|
8
|
+
- Recognize the anonymous ChatGPT response layout and use the latest visible assistant response consistently when reading answers and citations. Exclude user messages and response controls from answer text.
|
|
9
|
+
- Read citation URLs from a newly opened anonymous Sources panel after one normal click on the latest response's Sources control. Require the exact controlled panel layout, visible publisher-label coverage and three matching valid reads; unresolved, hidden, ambiguous or changed evidence remains unknown.
|
|
10
|
+
- Correct the bundled agent reference version. Package, web and documentation preflight checks now reject stale reference versions or mismatched generated copies.
|
|
11
|
+
|
|
12
|
+
## 0.6.7 (2026-09-20)
|
|
13
|
+
|
|
14
|
+
- Default browser citation measurement to accountless mode. Saved session files are ignored unless `AGENTLINKOPS_BROWSER_AUTH=saved-session` is explicitly selected.
|
|
15
|
+
- Reject custom launch, environment, extension and Firefox-preference overrides in accountless mode. Proxy admission remains required; direct diagnostics still need explicit authorization and configuration.
|
|
16
|
+
- Keep anonymous login walls, blocked pages and failed answers unknown. Accountless mode does not guarantee that a provider will answer.
|
|
17
|
+
|
|
6
18
|
## 0.6.6 (2026-09-20)
|
|
7
19
|
|
|
8
20
|
- Retain supplied browser usage receipts in a bounded local journal after each attempt and cleanup. Missing meter values remain unknown; failed writes stop the run.
|
package/cli/citation.js
CHANGED
|
@@ -28,8 +28,8 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository
|
|
|
28
28
|
tallies land under .agentlinkops/citations/
|
|
29
29
|
agentlinkops citation run PANEL.json --engine chatgpt:web-own-browser
|
|
30
30
|
measure the consumer UI through Camoufox
|
|
31
|
-
(
|
|
32
|
-
|
|
31
|
+
(accountless by default; saved browser sessions
|
|
32
|
+
are not loaded)
|
|
33
33
|
agentlinkops citation sweep PANEL.json --max-usd USD --out INVENTORY.json [--dir DIR]
|
|
34
34
|
run a bounded epoch and freeze its retained evidence
|
|
35
35
|
agentlinkops citation inventory PANEL.json --out INVENTORY.json [--epoch ID] [--dir DIR]
|
|
@@ -52,8 +52,8 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository
|
|
|
52
52
|
agentlinkops citation alert PANEL.json [--webhook URL --report REPORT.html]
|
|
53
53
|
check the latest epoch for declines;
|
|
54
54
|
logs an alert line if any cell declined
|
|
55
|
-
agentlinkops citation login ENGINE
|
|
56
|
-
(
|
|
55
|
+
agentlinkops citation login ENGINE optional legacy saved-session setup
|
|
56
|
+
(not needed for accountless measurement)
|
|
57
57
|
agentlinkops citation logout ENGINE remove a saved session
|
|
58
58
|
agentlinkops citation sessions list saved engine sessions
|
|
59
59
|
agentlinkops citation sync [--dir DIR] [--evidence] push local epoch rows to the hosted
|
|
@@ -65,8 +65,11 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository
|
|
|
65
65
|
|
|
66
66
|
Panel (JSON): targets (domain or url, brand, aliases), prompts, engines
|
|
67
67
|
([{ "engine": "mock" | "perplexity", "model": "…" }]), optional samples and maxUsd.
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
API engines need their configured credentials. Browser measurement defaults to
|
|
69
|
+
AGENTLINKOPS_BROWSER_AUTH=accountless and never loads saved sessions in that mode.
|
|
70
|
+
Optional legacy saved-session mode requires explicit AGENTLINKOPS_BROWSER_AUTH=saved-session.
|
|
71
|
+
Anonymous login walls or failed answers remain unknown; access is not guaranteed.
|
|
72
|
+
The mock engine runs everything at zero cost. Every run states its estimated spend, and the
|
|
70
73
|
cap reserves estimated cost before each call. A supplier charge above that estimate
|
|
71
74
|
is recorded as an overrun and stops subsequent calls. Browser measurement requires
|
|
72
75
|
AGENTLINKOPS_PROXY_URL. Authorized direct diagnostics need
|
package/cli/client.js
CHANGED
|
@@ -2,7 +2,7 @@ import {restResponseContract} from '../shared/response-contract.js';
|
|
|
2
2
|
// A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here.
|
|
3
3
|
// DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers.
|
|
4
4
|
// The version follows the plugin manifests; bump it with them.
|
|
5
|
-
export const CLI_VERSION = '0.6.
|
|
5
|
+
export const CLI_VERSION = '0.6.8';
|
|
6
6
|
export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`;
|
|
7
7
|
export class CloudError extends Error {
|
|
8
8
|
constructor(code, status, details = null) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlinkops",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "A backlink and AI citation ledger that lives in your repository: local link and citation checks with dated evidence, supplier-export imports and optional sync to the hosted AgentLinkOps service.",
|
|
5
5
|
"author": "AgentLinkOps (https://agentlinkops.com)",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -12,7 +12,9 @@ This command defaults to the mock engine. Its suggested questions are writing pr
|
|
|
12
12
|
|
|
13
13
|
Use `--competitors competitors.json` to add domains to check beside your own. The file is an array of objects with `domain`, `brand` and optional `aliases` fields. Each target gets separate results for each question. A target missing from one answer is only missing from that sample; it does not establish absence from the engine.
|
|
14
14
|
|
|
15
|
-
Choose a live engine explicitly after checking access and budget. For
|
|
15
|
+
Choose a live engine explicitly after checking access and budget. For accountless browser measurement, use `--engine chatgpt:web-own-browser` or `--engine grok:web-own-browser`. `--engine google-aio` requires your configured supplier access. This workflow does not create accounts or buy services.
|
|
16
|
+
|
|
17
|
+
Starting with CLI 0.6.7, browser authentication defaults to `AGENTLINKOPS_BROWSER_AUTH=accountless`. This mode does not load saved browser sessions, even if session files already exist. A login wall, blocked page or failed answer remains unknown; anonymous access is not guaranteed. Use accountless measurement for this workflow. The optional legacy `AGENTLINKOPS_BROWSER_AUTH=saved-session` mode is separate and requires an explicit opt-in; logging in is not a prerequisite for the default workflow.
|
|
16
18
|
|
|
17
19
|
Starting with CLI 0.6.5, browser measurement requires `AGENTLINKOPS_PROXY_URL`. Missing, failed or quarantined proxy admission never selects direct traffic. An explicitly authorized direct diagnostic requires `AGENTLINKOPS_BROWSER_EGRESS=direct-diagnostic` with no proxy configured. The standard URL proxy has no usage meter; estimated costs are not a provider invoice cap.
|
|
18
20
|
|
|
@@ -16,6 +16,6 @@ If the user authorizes outreach, use their existing email tool and honor their s
|
|
|
16
16
|
|
|
17
17
|
When a placement is earned, save source and destination URLs and its relationship to the opportunity. Use connected monitoring tools when requested; local campaign work remains useful without cloud access. Do not promise rankings, response rates, exhaustive competitor coverage, or improvements unsupported by evidence.
|
|
18
18
|
|
|
19
|
-
For citation panels, read [the panel authoring reference](../../references/citation-panels.md). Gather real audience questions with the user's available sources and record where each came from. `agentlinkops citation panel` drafts editable JSON with a mock engine by default. Suggested questions are not evidence of search demand. Choose a live engine only after checking the user's access and budget; keep citations, mentions, competitors and locale context separate in the results.
|
|
19
|
+
For citation panels, read [the panel authoring reference](../../references/citation-panels.md). Gather real audience questions with the user's available sources and record where each came from. `agentlinkops citation panel` drafts editable JSON with a mock engine by default. Suggested questions are not evidence of search demand. Choose a live engine only after checking the user's access and budget; keep citations, mentions, competitors and locale context separate in the results. Starting with CLI 0.6.7, browser citation measurement defaults to accountless and does not load saved sessions. Do not ask the user to log in as a prerequisite. Anonymous failures remain unknown. The panel reference keeps required proxy admission separate from an explicitly authorized direct diagnostic.
|
|
20
20
|
|
|
21
21
|
Use the [campaign handoff recipe](../../references/recipes/qualified-campaign-handoff.md) when preparing records and reviewed drafts for the person's CRM or sender. It defines checkpoints and later readback before treating an uncertain write as complete.
|
|
@@ -9,7 +9,7 @@ records. This page is the complete agent reference: the same text prints from
|
|
|
9
9
|
full once per session, then use `describe` for any command's exact schema.
|
|
10
10
|
|
|
11
11
|
<!-- generated:skill-version begin (npm run generate:tools) -->
|
|
12
|
-
Pack version 0.6.
|
|
12
|
+
Pack version 0.6.8. 125 commands in 13 toolsets.
|
|
13
13
|
<!-- generated:skill-version end -->
|
|
14
14
|
|
|
15
15
|
## Install and connect (once per machine)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { PROVIDER_MODEL_RESPONSE_SELECTORS } from './shims/utils.js';
|
|
2
|
+
|
|
3
|
+
// This function is serialized into the browser. Keep all lookups within the
|
|
4
|
+
// latest visible, explicitly identified assistant response.
|
|
5
|
+
export function readChatgptCitationState(input) {
|
|
6
|
+
const selectors = Array.isArray(input) ? input : input.selectors;
|
|
7
|
+
const visible = element => {
|
|
8
|
+
if (!(element instanceof HTMLElement) || !element.isConnected) return false;
|
|
9
|
+
const style = window.getComputedStyle(element);
|
|
10
|
+
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false;
|
|
11
|
+
const rect = element.getBoundingClientRect();
|
|
12
|
+
return rect.width > 0 && rect.height > 0;
|
|
13
|
+
};
|
|
14
|
+
const latestResponse = () => {
|
|
15
|
+
const elements = Array.from(document.querySelectorAll(selectors.join(', '))).filter(visible);
|
|
16
|
+
return elements.filter(element => !elements.some(other => other !== element && other.contains(element))).at(-1);
|
|
17
|
+
};
|
|
18
|
+
const root = latestResponse();
|
|
19
|
+
if (!root || root.getAttribute('aria-busy') === 'true' ||
|
|
20
|
+
(root.getAttribute('data-message-id') || '').startsWith('request-placeholder') ||
|
|
21
|
+
(root.innerText || '').trim().length <= 50) return { found: false, groups: 0, unresolved: true, sources: [] };
|
|
22
|
+
if (input.prepareBinding) {
|
|
23
|
+
const text = root.innerText;
|
|
24
|
+
return {isCurrent:() => latestResponse() === root && visible(root) && root.innerText === text &&
|
|
25
|
+
root.getAttribute('aria-busy') !== 'true' && !(root.getAttribute('data-message-id') || '').startsWith('request-placeholder')};
|
|
26
|
+
}
|
|
27
|
+
const groups = Array.from(root.querySelectorAll('[role="group"][aria-label="Sources"]'));
|
|
28
|
+
const concealed = element => {
|
|
29
|
+
for (let node = element; node; node = node.parentElement) {
|
|
30
|
+
const style = window.getComputedStyle(node);
|
|
31
|
+
if (node.hidden || node.getAttribute('aria-hidden') === 'true' || style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return true;
|
|
32
|
+
if (node === root) break;
|
|
33
|
+
}
|
|
34
|
+
return !visible(element);
|
|
35
|
+
};
|
|
36
|
+
const sources = [];
|
|
37
|
+
let unresolved = false;
|
|
38
|
+
const anonymous = root.matches(selectors.at(-1));
|
|
39
|
+
let panelEligible = anonymous && groups.length > 0 && groups.length <= 50;
|
|
40
|
+
const publisherLabels = [];
|
|
41
|
+
if (anonymous) {
|
|
42
|
+
// A new anonymous layout with unrecognized citation controls is not proof
|
|
43
|
+
// of absence. Never use unrelated page-wide links to fill this gap.
|
|
44
|
+
const actions = root.querySelector('[role="group"][aria-label="Response actions"]');
|
|
45
|
+
const unsupported = Array.from(root.querySelectorAll('a[href], button, [role="button"], cite, sup, [data-citation], [data-citation-id]'));
|
|
46
|
+
if (unsupported.some(element => !actions?.contains(element) && !groups.some(group => group.contains(element)))) {
|
|
47
|
+
unresolved = true; panelEligible = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const group of groups) {
|
|
51
|
+
if (concealed(group)) { unresolved = true; panelEligible = false; continue; }
|
|
52
|
+
if (anonymous) {
|
|
53
|
+
const buttons = Array.from(group.querySelectorAll('button'));
|
|
54
|
+
if (!buttons.length || buttons.length > 50 || group.querySelector('a[href], [role="button"]:not(button)')) panelEligible = false;
|
|
55
|
+
for (const button of buttons) {
|
|
56
|
+
const label = (button.getAttribute('aria-label') || '').trim();
|
|
57
|
+
if (!label || label.length > 200 || concealed(button)) panelEligible = false;
|
|
58
|
+
else publisherLabels.push(label);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let resolved = 0;
|
|
62
|
+
for (const anchor of group.querySelectorAll('a[href]')) {
|
|
63
|
+
if (concealed(anchor)) { unresolved = true; continue; }
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(anchor.href);
|
|
66
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw Error();
|
|
67
|
+
sources.push({ url: url.href, title: (anchor.textContent || '').trim() });
|
|
68
|
+
resolved++;
|
|
69
|
+
} catch { unresolved = true; }
|
|
70
|
+
}
|
|
71
|
+
// Observed anonymous pills are buttons without URLs. Their labels are
|
|
72
|
+
// not URL evidence; a partial anchor list cannot resolve those controls.
|
|
73
|
+
if (!resolved || group.querySelector('button, [role="button"]')) unresolved = true;
|
|
74
|
+
}
|
|
75
|
+
const labels = [...new Set(publisherLabels)];
|
|
76
|
+
panelEligible = panelEligible && labels.length > 0 && labels.length <= 50;
|
|
77
|
+
const state = { found: true, anonymous, groups: groups.length, unresolved, sources, panelEligible };
|
|
78
|
+
if (!input.prepare) return state;
|
|
79
|
+
if (!panelEligible || !unresolved) return {state};
|
|
80
|
+
// Keep the exact assistant and control references in-page. No DOM identities,
|
|
81
|
+
// tokens or control IDs are persisted in observations.
|
|
82
|
+
const footer = root.querySelector(':scope > [role="group"][aria-label="Response actions"]');
|
|
83
|
+
const buttons = Array.from(footer?.querySelectorAll('button') || []).filter(button =>
|
|
84
|
+
!concealed(button) && !button.disabled && Array.from(button.children).some(child =>
|
|
85
|
+
!concealed(child) && child.children.length === 0 && (child.textContent || '').trim() === 'Sources'));
|
|
86
|
+
if (buttons.length !== 1) return {state};
|
|
87
|
+
const button = buttons[0], controlledId = button.getAttribute('aria-controls');
|
|
88
|
+
if (!controlledId || controlledId.length > 256) return {state};
|
|
89
|
+
const before = document.getElementById(controlledId);
|
|
90
|
+
// Only the observed absent/empty target is supported. A populated old panel
|
|
91
|
+
// must not be mistaken for freshly resolved citations from this answer.
|
|
92
|
+
if (before && (visible(before) || (before.textContent || '').trim() || before.querySelector('a'))) return {state};
|
|
93
|
+
const responseText = root.innerText;
|
|
94
|
+
const unavailable = () => ({status:'unavailable'});
|
|
95
|
+
const inspect = () => {
|
|
96
|
+
if (latestResponse() !== root || !root.isConnected || !root.matches(selectors.at(-1)) || concealed(root) ||
|
|
97
|
+
root.innerText !== responseText || root.getAttribute('aria-busy') === 'true' ||
|
|
98
|
+
(root.getAttribute('data-message-id') || '').startsWith('request-placeholder') ||
|
|
99
|
+
!button.isConnected || !footer.contains(button) || !root.contains(footer) || button.getAttribute('aria-controls') !== controlledId) return unavailable();
|
|
100
|
+
const currentState = readChatgptCitationState(selectors);
|
|
101
|
+
if (!currentState.panelEligible || currentState.groups !== state.groups) return unavailable();
|
|
102
|
+
const currentLabels = [...new Set(Array.from(root.querySelectorAll('[role="group"][aria-label="Sources"] button')).map(node => (node.getAttribute('aria-label') || '').trim()))];
|
|
103
|
+
if (currentLabels.length !== labels.length || currentLabels.some(label => !labels.includes(label))) return unavailable();
|
|
104
|
+
const target = document.getElementById(controlledId);
|
|
105
|
+
if (!target || !visible(target)) return {status:'pending'};
|
|
106
|
+
if (target === root || target.contains(root) || ['BODY','MAIN','HTML','NAV'].includes(target.tagName)) return unavailable();
|
|
107
|
+
const articles = (target.matches('article') ? [target] : Array.from(target.querySelectorAll('article'))).filter(visible);
|
|
108
|
+
if (articles.length !== 1) return unavailable();
|
|
109
|
+
const article = articles[0];
|
|
110
|
+
if (concealed(article) || article.querySelector('form,input,textarea,select,[contenteditable="true"],button,[role="button"],[data-message-author-role="user"],[data-turn="user"],[aria-busy="true"],[role="progressbar"]')) return unavailable();
|
|
111
|
+
const headings = article.querySelectorAll('h1,h2,h3,[role="heading"]');
|
|
112
|
+
const header = article.querySelector(':scope > header > h2');
|
|
113
|
+
if (headings.length !== 1 || headings[0] !== header || concealed(header) || (header.textContent || '').trim() !== 'Sources') return unavailable();
|
|
114
|
+
const lists = article.querySelectorAll('ol');
|
|
115
|
+
if (lists.length !== 1 || article.querySelector('ul')) return unavailable();
|
|
116
|
+
const list = lists[0], cards = Array.from(list.children);
|
|
117
|
+
if (concealed(list) || !cards.length || cards.length > 50 || list.querySelectorAll('li').length !== cards.length || article.querySelectorAll('a').length !== cards.length) return unavailable();
|
|
118
|
+
const foundLabels = new Set(), results = [];
|
|
119
|
+
for (const card of cards) {
|
|
120
|
+
if (card.tagName !== 'LI' || concealed(card) || card.children.length !== 1 || card.firstElementChild.tagName !== 'A') return unavailable();
|
|
121
|
+
const anchor = card.firstElementChild, titles = anchor.querySelectorAll('strong');
|
|
122
|
+
if (concealed(anchor) || titles.length !== 1 || concealed(titles[0])) return unavailable();
|
|
123
|
+
const title = (titles[0].textContent || '').trim();
|
|
124
|
+
if (!title || title.length > 1000) return unavailable();
|
|
125
|
+
let url;
|
|
126
|
+
try {
|
|
127
|
+
const raw = anchor.getAttribute('href');
|
|
128
|
+
if (!raw || raw.length > 8192 || /[\u0000-\u0020\u007f]/.test(raw)) return unavailable();
|
|
129
|
+
url = new URL(raw);
|
|
130
|
+
if (!['http:','https:'].includes(url.protocol) || !url.hostname || url.username || url.password) return unavailable();
|
|
131
|
+
} catch { return unavailable(); }
|
|
132
|
+
// Exact visible publisher labels, not guessed domains or URL derivation.
|
|
133
|
+
const matched = [...new Set(Array.from(anchor.querySelectorAll('span')).filter(node =>
|
|
134
|
+
node.children.length === 0 && !concealed(node)).map(node => (node.textContent || '').trim()).filter(label => labels.includes(label)))];
|
|
135
|
+
if (matched.length !== 1) return unavailable();
|
|
136
|
+
foundLabels.add(matched[0]);
|
|
137
|
+
results.push({url:url.href,title});
|
|
138
|
+
}
|
|
139
|
+
if (foundLabels.size !== labels.length) return unavailable();
|
|
140
|
+
return {status:'resolved',sources:results};
|
|
141
|
+
};
|
|
142
|
+
return {state,button,inspect};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const unavailable = () => Object.assign(new Error('ChatGPT citation controls could not be resolved.'), {code:'CITATION_EXTRACTION_UNAVAILABLE'});
|
|
146
|
+
const responseBindings = new WeakMap();
|
|
147
|
+
const bindingCurrent = binding => binding.evaluate(value => typeof value.isCurrent === 'function' && value.isCurrent());
|
|
148
|
+
export async function releaseChatgptResponseBinding(page) {
|
|
149
|
+
const binding = responseBindings.get(page);
|
|
150
|
+
responseBindings.delete(page);
|
|
151
|
+
await binding?.dispose().catch(() => {});
|
|
152
|
+
}
|
|
153
|
+
export async function extractBoundChatgptResponse(page, legacyExtract) {
|
|
154
|
+
const previous = responseBindings.get(page);
|
|
155
|
+
responseBindings.delete(page);
|
|
156
|
+
if (previous) await previous.dispose().catch(() => {});
|
|
157
|
+
let binding;
|
|
158
|
+
try {
|
|
159
|
+
binding = await page.evaluateHandle(readChatgptCitationState, {selectors:PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt,prepareBinding:true});
|
|
160
|
+
if (!await bindingCurrent(binding)) throw unavailable();
|
|
161
|
+
const answer = await legacyExtract(page);
|
|
162
|
+
if (!await bindingCurrent(binding)) throw unavailable();
|
|
163
|
+
responseBindings.set(page,binding);
|
|
164
|
+
return answer;
|
|
165
|
+
} catch {
|
|
166
|
+
await binding?.dispose().catch(() => {});
|
|
167
|
+
throw unavailable();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function extractControlledPanel(page, binding) {
|
|
171
|
+
let handle, buttonHandle;
|
|
172
|
+
try {
|
|
173
|
+
handle = await page.evaluateHandle(readChatgptCitationState, {selectors:PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt,prepare:true});
|
|
174
|
+
buttonHandle = await handle.getProperty('button');
|
|
175
|
+
const button = buttonHandle.asElement();
|
|
176
|
+
if (!button) throw unavailable();
|
|
177
|
+
await button.click({timeout:3000});
|
|
178
|
+
// Bounded DOM render polling only; never another click/provider invocation.
|
|
179
|
+
let previous = null, stable = 0;
|
|
180
|
+
for (let attempt=0; attempt<12; attempt++) {
|
|
181
|
+
const result = await handle.evaluate((state,guard) => guard && !guard.isCurrent() ? {status:'unavailable'} : state.inspect(),binding || null);
|
|
182
|
+
if (result.status === 'resolved') {
|
|
183
|
+
const signature = JSON.stringify(result.sources);
|
|
184
|
+
stable = signature === previous ? stable + 1 : 1;
|
|
185
|
+
previous = signature;
|
|
186
|
+
// Publisher coverage alone is insufficient while more same-publisher
|
|
187
|
+
// cards may still be rendering. Require three identical valid reads.
|
|
188
|
+
if (stable >= 3) return [...new Map(result.sources.map(source => [source.url,source])).values()];
|
|
189
|
+
} else if (result.status === 'pending') { previous = null; stable = 0; }
|
|
190
|
+
else throw unavailable();
|
|
191
|
+
await page.waitForTimeout(250);
|
|
192
|
+
}
|
|
193
|
+
throw unavailable();
|
|
194
|
+
} catch { throw unavailable(); }
|
|
195
|
+
finally { await Promise.allSettled([buttonHandle?.dispose(),handle?.dispose()]); }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function extractChatgptSources(page, legacyExtract) {
|
|
199
|
+
const binding = responseBindings.get(page);
|
|
200
|
+
try {
|
|
201
|
+
if (binding && !await bindingCurrent(binding)) throw unavailable();
|
|
202
|
+
const state = await page.evaluate(readChatgptCitationState, PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt);
|
|
203
|
+
let sources;
|
|
204
|
+
if (!state.found || state.unresolved) {
|
|
205
|
+
if (!state.found || !state.panelEligible) throw unavailable();
|
|
206
|
+
sources = await extractControlledPanel(page,binding);
|
|
207
|
+
} else if (state.anonymous || state.groups) sources = [...new Map(state.sources.map(source => [source.url, source])).values()];
|
|
208
|
+
else sources = await legacyExtract(page);
|
|
209
|
+
if (binding && !await bindingCurrent(binding)) throw unavailable();
|
|
210
|
+
return sources;
|
|
211
|
+
} finally {
|
|
212
|
+
responseBindings.delete(page);
|
|
213
|
+
await binding?.dispose().catch(() => {});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -15,6 +15,7 @@ import { join } from 'node:path';
|
|
|
15
15
|
|
|
16
16
|
import { EngineError } from '../adapter.js';
|
|
17
17
|
import {createProxyAdmission,proxyFailureReason} from './proxy-admission.js';
|
|
18
|
+
import { extractBoundChatgptResponse, extractChatgptSources, releaseChatgptResponseBinding } from './chatgpt-citations.js';
|
|
18
19
|
|
|
19
20
|
const MODULE_LOADERS = {
|
|
20
21
|
'playwright-core': () => import('playwright-core'),
|
|
@@ -61,6 +62,18 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
61
62
|
if (!BROWSER_PROVIDERS.includes(engineName)) {
|
|
62
63
|
throw new EngineError(`unknown browser engine: ${engineName} (known: ${BROWSER_PROVIDERS.join(', ')})`);
|
|
63
64
|
}
|
|
65
|
+
const authentication = env.AGENTLINKOPS_BROWSER_AUTH ?? 'accountless';
|
|
66
|
+
if (!['accountless', 'saved-session'].includes(authentication)) {
|
|
67
|
+
throw Object.assign(new EngineError('Unsupported browser authentication policy.'), { code: 'BROWSER_AUTH_POLICY_INVALID' });
|
|
68
|
+
}
|
|
69
|
+
// The vendored resolver reads process.env directly; arbitrary launch/profile
|
|
70
|
+
// and extension overrides must not reintroduce ambient identity.
|
|
71
|
+
const assertAccountlessLaunch = () => {
|
|
72
|
+
if (authentication === 'accountless' && ['CAMOUFOX_EXTRA_LAUNCH_JSON', 'CAMOUFOX_ENV_JSON', 'CAMOUFOX_ADDONS', 'CAMOUFOX_ARGS', 'CAMOUFOX_FIREFOX_USER_PREFS_JSON'].some(key => env[key] || process.env[key])) {
|
|
73
|
+
throw Object.assign(new EngineError('Accountless measurement does not accept custom browser launch or extension overrides.'), { code: 'BROWSER_AUTH_POLICY_INVALID' });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
assertAccountlessLaunch();
|
|
64
77
|
const identity = { engine: engineName, provider: 'web-own-browser', model: engineName };
|
|
65
78
|
const sessionDir = runtime.sessionDir ?? join(homedir(), '.agentlinkops', 'citations', 'sessions');
|
|
66
79
|
const load = runtime.loadModule ?? lazy;
|
|
@@ -89,6 +102,13 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
89
102
|
const { OWN_PROVIDER_CONFIGS } = await load('./providers.js');
|
|
90
103
|
providerConfig = OWN_PROVIDER_CONFIGS[engineName] ?? PROVIDER_CONFIGS[engineName];
|
|
91
104
|
if (!providerConfig) throw new EngineError(`no provider config for ${engineName}`);
|
|
105
|
+
if (engineName === 'chatgpt') {
|
|
106
|
+
const legacyExtract = providerConfig.extractSources;
|
|
107
|
+
const legacyResponse = providerConfig.extractResponse;
|
|
108
|
+
providerConfig = { ...providerConfig,
|
|
109
|
+
extractResponse: page => extractBoundChatgptResponse(page, legacyResponse),
|
|
110
|
+
extractSources: page => extractChatgptSources(page, legacyExtract) };
|
|
111
|
+
}
|
|
92
112
|
return providerConfig;
|
|
93
113
|
}
|
|
94
114
|
|
|
@@ -110,6 +130,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
110
130
|
const proxy = admission.proxy;
|
|
111
131
|
await event(proxy?'egress: proxy-required':'egress: direct-diagnostic');
|
|
112
132
|
|
|
133
|
+
assertAccountlessLaunch();
|
|
113
134
|
const options = await resolveCamoufoxLaunchOptions({
|
|
114
135
|
provider: engineName,
|
|
115
136
|
proxy: proxy ?? undefined,
|
|
@@ -123,6 +144,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
123
144
|
const launchOpts = { ...options, executablePath: options.executablePath };
|
|
124
145
|
if (proxy) launchOpts.proxy = proxy;
|
|
125
146
|
admission.assertReady();
|
|
147
|
+
assertAccountlessLaunch();
|
|
126
148
|
const browser = await fw.launch(launchOpts);
|
|
127
149
|
|
|
128
150
|
browserHandle = {
|
|
@@ -139,6 +161,8 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
139
161
|
}
|
|
140
162
|
|
|
141
163
|
async function loadStorageState() {
|
|
164
|
+
// Accountless measurement never opens session files, even when they exist.
|
|
165
|
+
if (authentication === 'accountless') return undefined;
|
|
142
166
|
try {
|
|
143
167
|
const { readFile } = await load('node:fs/promises');
|
|
144
168
|
return JSON.parse(await readFile(join(sessionDir, `${engineName}.json`), 'utf8'));
|
|
@@ -205,6 +229,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
205
229
|
locale: 'en-US',
|
|
206
230
|
timezoneId: 'America/New_York',
|
|
207
231
|
});
|
|
232
|
+
let page;
|
|
208
233
|
try {
|
|
209
234
|
// Route-level asset stripping (opt out with AGENTLINKOPS_BROWSER_KEEP_ASSETS=1):
|
|
210
235
|
// images and media are the bulk of the 3-8 MB measured per observation.
|
|
@@ -216,7 +241,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
216
241
|
return route.continue();
|
|
217
242
|
});
|
|
218
243
|
}
|
|
219
|
-
|
|
244
|
+
page = await context.newPage();
|
|
220
245
|
const { runPageDomOp } = await load('./gen/lib/browser/domOps.js');
|
|
221
246
|
page.runDomOp = (operation, params = {}) => runPageDomOp(page, operation, params);
|
|
222
247
|
let answer = '';
|
|
@@ -301,7 +326,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
301
326
|
await config.beforeSubmitHook?.(page);
|
|
302
327
|
|
|
303
328
|
// Submit: the surface's own send button when it exposes one; Enter fallback.
|
|
304
|
-
//
|
|
329
|
+
// Allow the surface to settle; response extraction remains fail-closed.
|
|
305
330
|
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();
|
|
306
331
|
if (await send.isVisible().catch(() => false)) { await send.click(); }
|
|
307
332
|
else { await editor.locator.press('Enter'); }
|
|
@@ -339,6 +364,9 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
339
364
|
screenshotPng,
|
|
340
365
|
};
|
|
341
366
|
} catch (cause) {
|
|
367
|
+
if (cause?.code === 'CITATION_EXTRACTION_UNAVAILABLE') {
|
|
368
|
+
throw Object.assign(new EngineError('Citation response could not be bound.'), {code:'CITATION_EXTRACTION_UNAVAILABLE',unknown:true});
|
|
369
|
+
}
|
|
342
370
|
if (cause instanceof EngineError) throw cause;
|
|
343
371
|
const msg = String(cause?.message ?? cause);
|
|
344
372
|
const retriable = /timeout|timed out|ERR_|net::|closed|Target closed/i.test(msg);
|
|
@@ -346,6 +374,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
346
374
|
error.unknown = true;
|
|
347
375
|
throw error;
|
|
348
376
|
} finally {
|
|
377
|
+
if (engineName === 'chatgpt' && page) await closeQuietly(() => releaseChatgptResponseBinding(page));
|
|
349
378
|
if(!await closeQuietly(() => context.close()))contextCleanupFailed=true;
|
|
350
379
|
}
|
|
351
380
|
},
|
|
@@ -362,10 +391,10 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
362
391
|
},
|
|
363
392
|
};
|
|
364
393
|
const sample=engine.run.bind(engine),closeBrowser=engine.close.bind(engine);
|
|
365
|
-
const policyCodes=new Set(['PROXY_REQUIRED','PROXY_POLICY_INVALID','PROXY_POLICY_CONFLICT','PROXY_CONFIGURATION_INVALID','PROXY_ACQUIRE_FAILED','PROXY_SUPPLIER_INVALID','PROXY_LEASE_INVALID','PROXY_LEASE_EXPIRED','PROXY_QUARANTINE_FAILED','PROXY_RELEASE_FAILED','PROXY_RECEIPT_FAILED','PROXY_CUSTODY_UNAVAILABLE','BROWSER_CLEANUP_UNCONFIRMED','BROWSER_RUN_IN_PROGRESS']);
|
|
394
|
+
const policyCodes=new Set(['CITATION_EXTRACTION_UNAVAILABLE','BROWSER_AUTH_POLICY_INVALID','PROXY_REQUIRED','PROXY_POLICY_INVALID','PROXY_POLICY_CONFLICT','PROXY_CONFIGURATION_INVALID','PROXY_ACQUIRE_FAILED','PROXY_SUPPLIER_INVALID','PROXY_LEASE_INVALID','PROXY_LEASE_EXPIRED','PROXY_QUARANTINE_FAILED','PROXY_RELEASE_FAILED','PROXY_RECEIPT_FAILED','PROXY_CUSTODY_UNAVAILABLE','BROWSER_CLEANUP_UNCONFIRMED','BROWSER_RUN_IN_PROGRESS']);
|
|
366
395
|
const safeError=cause=>{
|
|
367
396
|
const code=policyCodes.has(cause?.code)?cause.code:'BROWSER_RUN_FAILED';
|
|
368
|
-
const error=Object.assign(new EngineError(code==='BROWSER_RUN_FAILED'?'Browser sample failed.':'Browser admission or cleanup failed.',{retriable:code==='BROWSER_RUN_FAILED'&&cause?.retriable===true}),{code});
|
|
397
|
+
const error=Object.assign(new EngineError(code==='CITATION_EXTRACTION_UNAVAILABLE'?'Citation binding or extraction was unavailable.':code==='BROWSER_RUN_FAILED'?'Browser sample failed.':'Browser admission or cleanup failed.',{retriable:code==='BROWSER_RUN_FAILED'&&cause?.retriable===true}),{code});
|
|
369
398
|
if(code==='BROWSER_RUN_FAILED'||cause?.unknown===true)error.unknown=true;
|
|
370
399
|
return error;
|
|
371
400
|
};
|
|
@@ -384,6 +413,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
384
413
|
if(running)throw Object.assign(new EngineError('A browser sample is already running.'),{code:'BROWSER_RUN_IN_PROGRESS'});
|
|
385
414
|
running=true;let result,error,reason=null,admitted=false;
|
|
386
415
|
try{
|
|
416
|
+
assertAccountlessLaunch();
|
|
387
417
|
if(admission.expired)await retire('lease_expiry');
|
|
388
418
|
await admission.begin();admitted=true;
|
|
389
419
|
result=await sample(input);
|
|
@@ -96,9 +96,11 @@ async function runPageDomOp(page, operation, params) {
|
|
|
96
96
|
function findLatestResponseElement(selectors) {
|
|
97
97
|
const selector = (selectors || []).join(", ");
|
|
98
98
|
if (!selector.trim()) return null;
|
|
99
|
-
const
|
|
100
|
-
(el) => el instanceof HTMLElement && isVisible(el)
|
|
101
|
-
)
|
|
99
|
+
const elements = Array.from(document.querySelectorAll(selector)).filter(
|
|
100
|
+
(el) => el instanceof HTMLElement && isVisible(el)
|
|
101
|
+
);
|
|
102
|
+
const element = elements.filter((el) => !elements.some((other) => other !== el && other.contains(el))).at(-1) ?? null;
|
|
103
|
+
if (!element || isResponsePlaceholder(element) || element.innerText.trim().length <= 50) return null;
|
|
102
104
|
return element ? { selector, element } : null;
|
|
103
105
|
}
|
|
104
106
|
function getCachedRawSources(key) {
|
|
@@ -34,15 +34,16 @@ async function hasVisibleGenerationIndicator(page, provider) {
|
|
|
34
34
|
async function getResponseStateSignature(page, provider) {
|
|
35
35
|
return await page.evaluate((selectors) => {
|
|
36
36
|
const visible = (element) => {
|
|
37
|
-
if (!(element instanceof HTMLElement)) return false;
|
|
37
|
+
if (!(element instanceof HTMLElement) || !element.isConnected) return false;
|
|
38
38
|
const style = window.getComputedStyle(element);
|
|
39
|
-
|
|
39
|
+
if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") return false;
|
|
40
|
+
const rect = element.getBoundingClientRect();
|
|
41
|
+
return rect.width > 0 && rect.height > 0;
|
|
40
42
|
};
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
if (!latest) {
|
|
43
|
+
const selector = (selectors || []).join(", ");
|
|
44
|
+
const elements = selector.trim() ? Array.from(document.querySelectorAll(selector)).filter(visible) : [];
|
|
45
|
+
const latest = elements.filter((el) => !elements.some((other) => other !== el && other.contains(el))).at(-1) ?? null;
|
|
46
|
+
if (!latest || latest.getAttribute("aria-busy") === "true" || (latest.getAttribute("data-message-id") || "").startsWith("request-placeholder") || (latest.innerText || "").trim().length <= 50) {
|
|
46
47
|
return { signature: "", textLength: 0 };
|
|
47
48
|
}
|
|
48
49
|
const text = (latest.innerText || "").replace(/\s+/g, " ").trim();
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
// Shim for @oneglanse/utils runtime exports. The selector/timeout constants
|
|
2
|
-
// are re-exported
|
|
2
|
+
// are re-exported except the evidenced anonymous ChatGPT response boundary; logger,
|
|
3
3
|
// getDomain and getFaviconUrls are minimal equivalents.
|
|
4
4
|
export {
|
|
5
5
|
PROVIDER_NO_OUTPUT_TIMEOUT_MS,
|
|
6
6
|
PROVIDER_FORCE_EXIT_STABLE_MS,
|
|
7
7
|
PROVIDER_EDITOR_SELECTORS,
|
|
8
8
|
PROVIDER_SUBMIT_BTN_SELECTORS,
|
|
9
|
-
PROVIDER_MODEL_RESPONSE_SELECTORS,
|
|
10
9
|
PROVIDER_RESPONSE_GENERATION_SELECTORS,
|
|
11
10
|
RETRYABLE_ERRORS,
|
|
12
11
|
} from '../gen/utils/agent-constants.js';
|
|
12
|
+
import { PROVIDER_MODEL_RESPONSE_SELECTORS as vendoredResponseSelectors } from '../gen/utils/agent-constants.js';
|
|
13
|
+
|
|
14
|
+
// Anonymous surface observed 2026-09-20: conversation LI, distinct from its
|
|
15
|
+
// user sibling, with response actions. Never select the conversation itself.
|
|
16
|
+
export const CHATGPT_ANONYMOUS_RESPONSE_SELECTOR = 'ol[aria-label="Conversation"] > li:has(> [role="group"][aria-label="Response actions"] button[aria-label="Copy response"]):not(:has(button[aria-label="Copy message"], [data-message-author-role="user"], [data-turn="user"], form, input, textarea, [contenteditable="true"]))';
|
|
17
|
+
export const PROVIDER_MODEL_RESPONSE_SELECTORS = Object.freeze({
|
|
18
|
+
...vendoredResponseSelectors,
|
|
19
|
+
chatgpt: Object.freeze([...vendoredResponseSelectors.chatgpt, CHATGPT_ANONYMOUS_RESPONSE_SELECTOR]),
|
|
20
|
+
});
|
|
13
21
|
|
|
14
22
|
const envQuiet = () => Boolean(process.env.AGENTLINKOPS_BROWSER_QUIET);
|
|
15
23
|
|