agentlinkops 0.6.7 → 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 +6 -0
- package/cli/client.js +1 -1
- package/package.json +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 +17 -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,12 @@
|
|
|
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
|
+
|
|
6
12
|
## 0.6.7 (2026-09-20)
|
|
7
13
|
|
|
8
14
|
- Default browser citation measurement to accountless mode. Saved session files are ignored unless `AGENTLINKOPS_BROWSER_AUTH=saved-session` is explicitly selected.
|
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",
|
|
@@ -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'),
|
|
@@ -101,6 +102,13 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
101
102
|
const { OWN_PROVIDER_CONFIGS } = await load('./providers.js');
|
|
102
103
|
providerConfig = OWN_PROVIDER_CONFIGS[engineName] ?? PROVIDER_CONFIGS[engineName];
|
|
103
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
|
+
}
|
|
104
112
|
return providerConfig;
|
|
105
113
|
}
|
|
106
114
|
|
|
@@ -221,6 +229,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
221
229
|
locale: 'en-US',
|
|
222
230
|
timezoneId: 'America/New_York',
|
|
223
231
|
});
|
|
232
|
+
let page;
|
|
224
233
|
try {
|
|
225
234
|
// Route-level asset stripping (opt out with AGENTLINKOPS_BROWSER_KEEP_ASSETS=1):
|
|
226
235
|
// images and media are the bulk of the 3-8 MB measured per observation.
|
|
@@ -232,7 +241,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
232
241
|
return route.continue();
|
|
233
242
|
});
|
|
234
243
|
}
|
|
235
|
-
|
|
244
|
+
page = await context.newPage();
|
|
236
245
|
const { runPageDomOp } = await load('./gen/lib/browser/domOps.js');
|
|
237
246
|
page.runDomOp = (operation, params = {}) => runPageDomOp(page, operation, params);
|
|
238
247
|
let answer = '';
|
|
@@ -317,7 +326,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
317
326
|
await config.beforeSubmitHook?.(page);
|
|
318
327
|
|
|
319
328
|
// Submit: the surface's own send button when it exposes one; Enter fallback.
|
|
320
|
-
//
|
|
329
|
+
// Allow the surface to settle; response extraction remains fail-closed.
|
|
321
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();
|
|
322
331
|
if (await send.isVisible().catch(() => false)) { await send.click(); }
|
|
323
332
|
else { await editor.locator.press('Enter'); }
|
|
@@ -355,6 +364,9 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
355
364
|
screenshotPng,
|
|
356
365
|
};
|
|
357
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
|
+
}
|
|
358
370
|
if (cause instanceof EngineError) throw cause;
|
|
359
371
|
const msg = String(cause?.message ?? cause);
|
|
360
372
|
const retriable = /timeout|timed out|ERR_|net::|closed|Target closed/i.test(msg);
|
|
@@ -362,6 +374,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
362
374
|
error.unknown = true;
|
|
363
375
|
throw error;
|
|
364
376
|
} finally {
|
|
377
|
+
if (engineName === 'chatgpt' && page) await closeQuietly(() => releaseChatgptResponseBinding(page));
|
|
365
378
|
if(!await closeQuietly(() => context.close()))contextCleanupFailed=true;
|
|
366
379
|
}
|
|
367
380
|
},
|
|
@@ -378,10 +391,10 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
378
391
|
},
|
|
379
392
|
};
|
|
380
393
|
const sample=engine.run.bind(engine),closeBrowser=engine.close.bind(engine);
|
|
381
|
-
const policyCodes=new Set(['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']);
|
|
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']);
|
|
382
395
|
const safeError=cause=>{
|
|
383
396
|
const code=policyCodes.has(cause?.code)?cause.code:'BROWSER_RUN_FAILED';
|
|
384
|
-
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});
|
|
385
398
|
if(code==='BROWSER_RUN_FAILED'||cause?.unknown===true)error.unknown=true;
|
|
386
399
|
return error;
|
|
387
400
|
};
|
|
@@ -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
|
|