agentlinkops 0.6.8 → 0.6.10
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 +20 -0
- package/README.md +44 -12
- package/cli/admission.js +9 -1
- package/cli/agent-setup.js +189 -20
- package/cli/catalog.js +7938 -1357
- package/cli/client.js +89 -23
- package/cli/cloud-error.js +21 -0
- package/cli/commands.js +37 -6
- package/cli/connection.js +74 -12
- package/cli/doctor.js +24 -9
- package/cli/gsc-browser.js +19 -6
- package/cli/ledger.js +4 -7
- package/cli/lifecycle.js +5 -2
- package/cli/local-result.js +4 -4
- package/cli/main.js +43 -28
- package/cli/mcp-client.js +198 -0
- package/cli/native-lock.js +22 -0
- package/cli/oauth-credentials.js +196 -0
- package/cli/oauth-flow.js +259 -0
- package/cli/setup-plan.js +5 -5
- package/cli/status.js +14 -2
- package/cli/sync-lock.js +105 -0
- package/cli/sync.js +14 -6
- package/package.json +6 -3
- package/references/cli.md +22 -0
- package/references/local-crm-draft-handoff.md +76 -0
- package/references/portable-ledger-handoff.md +50 -0
- package/references/recipes/catalog.json +9 -9
- package/references/recipes/command-notes.md +17 -1
- package/references/recipes/placement-reconciliation.md +19 -5
- package/references/recipes/qualified-campaign-handoff.md +16 -4
- package/references/recipes/site-context-brief.md +2 -2
- package/references/recipes/sourced-linkable-asset.md +8 -8
- package/scripts/agentlinkops.py +30 -5
- package/scripts/outreach-crm-draft.py +121 -0
- package/scripts/portable-handoff.py +184 -0
- package/shared/exec-grammar.js +342 -0
- package/shared/lifecycle-contract.js +2 -2
- package/shared/response-contract.js +7 -1
- package/skills/agentlinkops-assets/SKILL.md +2 -2
- package/skills/agentlinkops-campaigns/SKILL.md +3 -3
- package/skills/agentlinkops-connect/SKILL.md +29 -6
- package/skills/agentlinkops-connect/references/agentlinkops.md +62 -26
- package/skills/agentlinkops-crm/SKILL.md +17 -3
- package/src/citations/adapter.js +3 -2
- package/src/citations/browser/chatgpt-citations.js +60 -6
- package/src/citations/browser/engine.js +106 -18
- package/src/citations/browser/shims/utils.js +9 -2
- package/src/citations/login.js +42 -5
- package/src/discovery/contract.js +1 -1
- package/src/response-shaping.js +58 -0
- package/src/tool-views.js +31 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agentlinkops-connect
|
|
3
3
|
description: How to use AgentLinkOps correctly through its MCP tools, the agentlinkops CLI or HTTP. Load before the first AgentLinkOps tool call in a session, and whenever a task connects a client, picks an MCP view, finds a command, watches an earned link, reads a check result or evidence, or checks usage. Not for outreach, browsing or local ledger edits.
|
|
4
|
-
allowed-tools: Read Bash(agentlinkops skill *) Bash(agentlinkops tools *) Bash(agentlinkops describe *) Bash(agentlinkops call *) Bash(agentlinkops agent *) Bash(agentlinkops doctor *)
|
|
4
|
+
allowed-tools: Read Bash(agentlinkops skill *) Bash(agentlinkops check *) Bash(agentlinkops setup --plan *) Bash(agentlinkops tools *) Bash(agentlinkops describe *) Bash(agentlinkops call *) Bash(agentlinkops agent *) Bash(agentlinkops doctor *)
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Connect to AgentLinkOps
|
|
@@ -14,9 +14,26 @@ AgentLinkOps stores verified backlink observations, monitoring history and evide
|
|
|
14
14
|
|
|
15
15
|
## Set up once
|
|
16
16
|
|
|
17
|
-
`npx -y agentlinkops agent setup`
|
|
17
|
+
Inspect `npx -y agentlinkops agent setup --help` for the installed version before selecting payloads. CLI 0.6.10 installs the connection skill and its reference by default, with MCP setup for detected clients. Select optional guidance explicitly. CLI 0.6.9 and earlier can install the complete pack. Native-client acceptance remains separate from package checks. Setup stores no credential.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
For a chosen hosted task, sign in through the client's OAuth flow. If the CLI is not installed, connect the client to `https://app.agentlinkops.com/mcp` by hand and let the person approve the workspace and scopes. `agentlinkops agent status` shows what is configured.
|
|
20
|
+
|
|
21
|
+
## Check a supplied pair (after reading the reference)
|
|
22
|
+
|
|
23
|
+
With an available CLI, use the person's chosen URLs as literal arguments:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
agentlinkops check --source SOURCE_URL --target TARGET_URL --scope exact --json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
When the person asks to save the portable result, add `--out FILE` with their
|
|
30
|
+
chosen path, such as `--out native-result.json`. The command saves the portable
|
|
31
|
+
JSON result and still prints it. Read the saved JSON before reporting its state,
|
|
32
|
+
check date, coverage and evidence limits.
|
|
33
|
+
|
|
34
|
+
This fetches the source page and returns an accountless observation. It needs no hosted connection, repository, ledger, CRM or publisher recipe. Read the structured state and evidence limits: exit zero means a result was returned, and unknown does not prove removal. A usage or configuration failure supplies no observation.
|
|
35
|
+
|
|
36
|
+
## Hosted example (after reading the reference)
|
|
20
37
|
|
|
21
38
|
1. `get_workspace` with `{}`: limits, usage and membership.
|
|
22
39
|
2. `list_projects` with `{"limit": 20}`: the websites this credential can see. Take ids from this answer.
|
|
@@ -34,8 +51,14 @@ On the CLI the same three verbs are `agentlinkops tools [TOOLSET]`, `agentlinkop
|
|
|
34
51
|
|
|
35
52
|
## Installed skill set
|
|
36
53
|
|
|
37
|
-
Run `agentlinkops skill --list` for the versioned set installed with this CLI.
|
|
54
|
+
Run `agentlinkops skill --list` for the versioned set installed with this CLI. The bundled optional set includes discovery (sourced prospect research), campaigns (campaign judgment), CRM (local records) and assets (a specific linkable resource). Install only what the person chooses with `agentlinkops agent setup --skill ID`. These skills and local records are not prerequisites for checking supplied links. Read an individual skill with its full name, such as `agentlinkops skill agentlinkops-discovery`. Research and local work do not require buying a supplier subscription.
|
|
55
|
+
|
|
56
|
+
## Optional recipes and customer policy
|
|
57
|
+
|
|
58
|
+
CLI 0.6.10's default connection setup excludes campaign, CRM and publisher workflows. Use the selectors only when the installed CLI’s help supports them. Inspect `agentlinkops setup --plan --goal GOAL --mode MODE` when a task needs a plan. Select an optional recipe with `agentlinkops agent setup --recipe ID`: `site-context-brief`, `sourced-linkable-asset`, `qualified-campaign-handoff` or `placement-reconciliation`. Repeat the flag for multiple recipes. The installer reports their versions and associated skills. Installed recipes live in `../../references/recipes/`; read a selected recipe that fits the person's task. For the optional site brief, select it separately with `agentlinkops agent setup --recipe site-context-brief` before reading [the site brief recipe](../../references/recipes/site-context-brief.md).
|
|
59
|
+
|
|
60
|
+
Preview `agentlinkops agent remove --recipe ID` or `--skill ID`, then add `--apply` to remove unchanged installer-owned files. The installer refuses skill removal while a recipe it owns still needs the skill. Keep the skill or preview selecting that recipe and its skill together after reconciling customer edits. The installer preserves edited dependent bodies, which can still block joint removal. It also refuses removal when it cannot trust the dependency metadata. Targeted connection-skill removal refuses surviving optional payloads or retained MCP ownership.
|
|
38
61
|
|
|
39
|
-
|
|
62
|
+
For an intended complete uninstall, preview `agentlinkops agent remove --scope project` (or `user`), then add `--apply`. Recipe-only removal preserves the connection, other skills, shared support and customer edits. A connection-skill upgrade retains earlier optional payloads until explicitly removed. Full-pack setup is available with `--all-skills`.
|
|
40
63
|
|
|
41
|
-
|
|
64
|
+
Follow the customer's existing instructions, editorial rules and tools. These optional methods never authorize outreach, publication, spending or provider activation. The installer reports conflicts instead of replacing customer files. A connection or installation is separate from a successful link check and a published package release.
|
|
@@ -6,21 +6,33 @@ for links a person supplies. Your agent keeps campaign judgment, browsing, email
|
|
|
6
6
|
records. This page is the complete agent reference: the same text prints from
|
|
7
7
|
`agentlinkops skill`, ships inside the installed `agentlinkops-connect` skill as
|
|
8
8
|
`references/agentlinkops.md`, and is served at `https://agentlinkops.com/SKILL.md`. Read it in
|
|
9
|
-
full once per session, then use `describe` for any command's exact schema.
|
|
9
|
+
full once per session, then use `describe` (or `exec describe` on MCP) 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.10. 139 commands in 15 toolsets.
|
|
13
13
|
<!-- generated:skill-version end -->
|
|
14
14
|
|
|
15
15
|
## Install and connect (once per machine)
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
npx -y agentlinkops
|
|
19
|
-
|
|
18
|
+
npx -y agentlinkops --help # inspect this package version's setup options
|
|
19
|
+
npx -y agentlinkops agent setup # writes the detected client's MCP entry; stores no credential
|
|
20
20
|
agentlinkops agent status # what is installed where
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
CLI 0.6.10 installs the connection skill and its reference by default. Select
|
|
24
|
+
optional guidance explicitly. CLI 0.6.9 and earlier can install the complete pack.
|
|
25
|
+
Check the installed version's help before using selection flags. Native-client
|
|
26
|
+
acceptance remains separate from package checks.
|
|
27
|
+
|
|
28
|
+
On a version supporting selections, add only the guidance the person chooses:
|
|
29
|
+
`agentlinkops agent setup --skill ID` or `--recipe ID`, repeating the flag for several
|
|
30
|
+
choices. `--all-skills` selects the complete pack. Recipes remain optional; their
|
|
31
|
+
versioned procedures do not replace the person's existing instructions or authorize
|
|
32
|
+
publication, outreach, spending or provider access. Checking supplied links does not
|
|
33
|
+
require a campaign, content workflow or local CRM.
|
|
34
|
+
|
|
35
|
+
`npx -y skills add https://agentlinkops.com` installs the product skills alone; then add
|
|
24
36
|
`https://app.agentlinkops.com/mcp` as a remote HTTP MCP server in the client by hand. If the
|
|
25
37
|
CLI is not installed, give the person that server URL and the next step instead of claiming
|
|
26
38
|
the connection exists.
|
|
@@ -34,13 +46,18 @@ works:
|
|
|
34
46
|
npx -y agentlinkops check --source https://publisher.example/resources --target https://your-site.example/guide
|
|
35
47
|
```
|
|
36
48
|
|
|
49
|
+
When the person asks to save the portable JSON result, add `--json --out FILE`
|
|
50
|
+
with their chosen file path. Read the saved result before reporting its state,
|
|
51
|
+
check date, coverage and evidence limits. Preserve the person's other records.
|
|
52
|
+
|
|
37
53
|
REST and the CLI use a scoped API key created in **Agent access > Create API key** in the
|
|
38
54
|
[workspace](https://app.agentlinkops.com/app), supplied as `AGENTLINKOPS_API_KEY` in the
|
|
39
55
|
environment (`AGENTLINKOPS_TOKEN` also works; the older `LINKTRAIL_*` names keep working
|
|
40
56
|
during the pilot compatibility window with one warning per process). MCP OAuth tokens are
|
|
41
57
|
bound to `/mcp` and are not API keys.
|
|
42
58
|
|
|
43
|
-
Verify: on MCP call `get_workspace` with `{}`
|
|
59
|
+
Verify: on MCP call `get_workspace` with `{}` — directly, or as
|
|
60
|
+
`exec: {"command": "call get_workspace {}"}` (a workspace name in the answer means the
|
|
44
61
|
connection works); on the CLI run `agentlinkops doctor` (its `cloud` and `token` lines say
|
|
45
62
|
`ok` when the origin answers and the key is accepted).
|
|
46
63
|
|
|
@@ -49,9 +66,12 @@ connection works); on the CLI run `agentlinkops doctor` (its `cloud` and `token`
|
|
|
49
66
|
One catalog, several endpoints. Every command is callable on every view; only the listing
|
|
50
67
|
changes. `agentlinkops agent setup` picks the right one per client.
|
|
51
68
|
|
|
52
|
-
- `/mcp` (default):
|
|
53
|
-
|
|
54
|
-
|
|
69
|
+
- `/mcp` (default): one tool, `exec`, carries the whole catalog. Pass it CLI-style command
|
|
70
|
+
strings — `search`, `tools`, `describe`, `schema`, `call` — and the server instructions list
|
|
71
|
+
every command by toolset. Every command is also callable directly on this view (a client
|
|
72
|
+
that already knows the name may call it), and the older discovery tools
|
|
73
|
+
(`search_tools`, `describe_tools`, `run_read_command`, `run_write_command`) still answer
|
|
74
|
+
direct calls, unlisted.
|
|
55
75
|
- `/mcp/all`: the flat catalog, for a client with its own tool search (Claude Code, Hermes).
|
|
56
76
|
- `/mcp/{toolset}`: one group from the table at the end.
|
|
57
77
|
- `/mcp/code`: `search`, `describe` and `execute`; `execute` runs your JavaScript in an isolated
|
|
@@ -61,21 +81,35 @@ changes. `agentlinkops agent setup` picks the right one per client.
|
|
|
61
81
|
|
|
62
82
|
## Find, describe, call
|
|
63
83
|
|
|
64
|
-
The same
|
|
84
|
+
The same verbs on every surface. Never invent a command name or an argument. On MCP everything
|
|
85
|
+
goes through the one `exec` tool; its `command` string is one command per call, and several
|
|
86
|
+
`exec` calls can run in parallel.
|
|
65
87
|
|
|
66
|
-
| Step | MCP | CLI | HTTP |
|
|
88
|
+
| Step | MCP (`exec`) | CLI | HTTP |
|
|
67
89
|
| --- | --- | --- | --- |
|
|
68
|
-
| index | `
|
|
69
|
-
|
|
|
70
|
-
|
|
|
90
|
+
| index | `exec: {"command": "tools"}` | `agentlinkops tools [TOOLSET]` | `GET /v1/commands` |
|
|
91
|
+
| find | `exec: {"command": "search destination health"}` | `agentlinkops tools [TOOLSET]` | `GET /v1/commands` |
|
|
92
|
+
| describe | `exec: {"command": "describe monitor_link"}` | `agentlinkops describe NAME` | `GET /v1/commands/{name}` |
|
|
93
|
+
| drill one field | `exec: {"command": "schema monitor_link targetScope"}` | `agentlinkops schema NAME [PATH]` | `GET /v1/commands/{name}` |
|
|
94
|
+
| call | `exec: {"command": "call monitor_link {\"projectId\":\"pr_..\",\"sourceUrl\":\"https://..\",\"targetUrl\":\"https://..\"}"}` | `agentlinkops call NAME --args JSON` | `POST /v1/commands/{name}` |
|
|
95
|
+
|
|
96
|
+
`search` takes `--toolset` and `--limit`; `describe` takes `--output-schema`; `call` takes
|
|
97
|
+
`--json` for full rows instead of the concise projection. A `describe` or `schema` answer that
|
|
98
|
+
exceeds the context budget is summarized, and any field carrying a `hint` expands with
|
|
99
|
+
`schema <name> <field.path>`. Find with `search`, `describe` a name once, then `call` and
|
|
100
|
+
reuse the schema; an unknown name answers with the `search` that finds the current one.
|
|
71
101
|
|
|
72
102
|
Read the description first; `describe` before any write. List commands answer concise rows on
|
|
73
|
-
MCP and code mode, detailed rows on REST and the CLI; pass `format` (`concise` or `detailed`)
|
|
74
|
-
to choose and read `defaults_applied` to know which you got.
|
|
75
|
-
resolves to its canonical command for twelve months and the
|
|
103
|
+
MCP and code mode, detailed rows on REST and the CLI; pass `format` (`concise` or `detailed`),
|
|
104
|
+
or `call --json` through `exec`, to choose, and read `defaults_applied` to know which you got.
|
|
105
|
+
A retired command name still resolves to its canonical command for twelve months and the
|
|
106
|
+
description says so.
|
|
76
107
|
|
|
77
108
|
## Start a session
|
|
78
109
|
|
|
110
|
+
For a hosted task, inspect the connection and granted scopes first. The accountless local
|
|
111
|
+
check above needs no hosted session, repository, ledger or optional recipe.
|
|
112
|
+
|
|
79
113
|
1. `get_workspace` with `{}`: limits, usage and membership. Needs `projects:read`, the default
|
|
80
114
|
advertised scope; an authenticated connection alone does not grant more.
|
|
81
115
|
2. `list_projects` with `{"limit": 20}`: the websites this credential can see. Take ids from
|
|
@@ -225,9 +259,9 @@ counts, superlatives or claims about other products beyond dated public statemen
|
|
|
225
259
|
invitation-only hosted access; `cadenceSeconds` between 3600 and 2592000; no availability
|
|
226
260
|
percentage is published.
|
|
227
261
|
4. **Usage and exit are readable:** `get_usage` totals with a `check_budget` block;
|
|
228
|
-
`export_link_watches` pages monitored placements out for
|
|
229
|
-
payment collection is not active in this build; the
|
|
230
|
-
|
|
262
|
+
`export_link_watches` pages monitored placements out for the person's records. Boundary:
|
|
263
|
+
payment collection is not active in this build; the person chooses where to keep their
|
|
264
|
+
campaign records.
|
|
231
265
|
|
|
232
266
|
The human-readable version is
|
|
233
267
|
[what your agent can prove to you](https://agentlinkops.com/what-your-agent-can-prove/).
|
|
@@ -235,16 +269,18 @@ The human-readable version is
|
|
|
235
269
|
## Tool reference
|
|
236
270
|
|
|
237
271
|
<!-- generated:skill-tools begin (npm run generate:tools; source: src/tool-registry.js) -->
|
|
238
|
-
|
|
272
|
+
139 commands in 15 toolsets, generated from `src/tool-registry.js` (`npm run generate:tools`). Core on every view: `get_workspace`, `list_projects`, `list_link_watches`, `monitor_link`, `list_events`. The index verbs list the rest by name; `describe` is the source of truth for input schemas. Admission-gated today: prepare_domain_baseline, start_domain_baseline, request_competitor_inventory, request_domain_overview, discover_backlinks; these refuse before any persistence.
|
|
239
273
|
|
|
240
274
|
| Toolset | Commands | What it covers |
|
|
241
275
|
| --- | --- | --- |
|
|
242
276
|
| `monitoring` | 17 | Watch earned links and destination URLs: create, list, update, import, export, recheck. |
|
|
243
277
|
| `evidence` | 17 | What a check observed: histories, snapshots, change feeds, check jobs, published contacts. |
|
|
278
|
+
| `rank` | 5 | Synthetic DataForSEO rank schedules and dated history; hosted collection disabled. |
|
|
244
279
|
| `discovery` | 16 | Import candidate rows, read stored runs, verify selected candidates, enroll them. |
|
|
280
|
+
| `baseline` | 7 | Scoped backlink baselines; supplier admission disabled. |
|
|
245
281
|
| `library` | 3 | Read and export the coverage-stated opportunity library. |
|
|
246
282
|
| `competitors` | 17 | Competitor sets, dated inventories, scheduled refresh, gap and domain-mix reports. |
|
|
247
|
-
| `reports` |
|
|
283
|
+
| `reports` | 7 | Profile, anchor and report summaries over the tracked dataset. |
|
|
248
284
|
| `workspace` | 16 | Workspace, projects, usage, members, invitations, scratch-resource cleanup. |
|
|
249
285
|
| `notifications` | 6 | Email notification preferences, previews, tests and deliveries. |
|
|
250
286
|
| `webhooks` | 6 | Webhook endpoints, state, secrets and delivery records. |
|
|
@@ -260,11 +296,11 @@ AgentLinkOps grants no access to the mailbox or local files, has no independent
|
|
|
260
296
|
backlink index, authority metrics or email sending, does not browse sites for you and
|
|
261
297
|
monitors supplied URLs. Finding prospects, campaign decisions, navigating sites and authorized
|
|
262
298
|
outreach stay with the person's existing agent, research, browser and email tools; prospects,
|
|
263
|
-
contacts, outreach references and earned placements
|
|
264
|
-
|
|
299
|
+
contacts, outreach references and earned placements remain in their chosen files, CRM or
|
|
300
|
+
other record system. The campaign and local CRM skills are optional guidance.
|
|
265
301
|
|
|
266
|
-
|
|
302
|
+
If the person chooses the ledger CLI, `agentlinkops connect --workspace WORKSPACE_ID --project-id
|
|
267
303
|
PROJECT_ID --selection selected-links.json` saves only connection metadata and a selection of
|
|
268
304
|
existing ledger ids; `sync --dry-run` previews uploads offline and `sync --pull-only`
|
|
269
305
|
retrieves history without creating watches. Review the plan before authorizing `sync`. The
|
|
270
|
-
Python CRM helper separately needs `AGENTLINKOPS_API_URL` and `AGENTLINKOPS_API_KEY`.
|
|
306
|
+
optional Python CRM helper separately needs `AGENTLINKOPS_API_URL` and `AGENTLINKOPS_API_KEY`.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agentlinkops-crm
|
|
3
3
|
description: Manage the AgentLinkOps local backlink and outreach CRM, user-selected disavow lists, branded HTML reports and cloud event sync. Use for durable campaign records, link reporting or explicit disavow-list management; does not connect a mailbox, send outreach or upload disavow files.
|
|
4
|
-
allowed-tools: Read Bash(python3 ../../scripts/agentlinkops.py *) Bash(node cli/agentlinkops.mjs *) Bash(agentlinkops *)
|
|
4
|
+
allowed-tools: Read Bash(python3 ../../scripts/agentlinkops.py *) Bash(python3 ../../scripts/outreach-crm-draft.py *) Bash(node cli/agentlinkops.mjs *) Bash(agentlinkops *)
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**For the optional bundled CLI, the local surface is the ledger file.** `.agentlinkops/links.jsonl` in the user's repository (or a `.linktrail/` directory from an earlier install, read until `agentlinkops migrate` renames it) declares the links they expect, want and have earned, and `agentlinkops check` runs the exact cloud verifier against it without an account. Run the CLI as `node cli/agentlinkops.mjs` from the repository or plugin-package root (`cli/linktrail.mjs` still works and prints a one-line notice); the packaged plugin ships the CLI, its pinned verifier modules and their pinned runtime dependencies inside the package, so the installed package needs no npm install; checking a publisher still requires a read-only network fetch. It needs Node 22 or newer (`adopt` reads SQLite databases through `node:sqlite`); the Python helper below needs Python 3.10+. This CRM is one optional adapter beside CSV and JSONL: it is available, tested and unchanged, and nothing requires it. When a user already keeps links here, `agentlinkops adopt <db>` reads it into ledger entries — read-only, adding no schema — and previews unless `--write` is given. Do not propose the CRM as the place a user should start keeping links; propose it when they want contacts and outreach activity beside them, or when they already have one.
|
|
8
8
|
|
|
9
9
|
To record a link in the ledger: `agentlinkops add --source URL --target URL --intent expected [--anchor TEXT] [--ref ID] [--note TEXT]` (`--intent wanted` for a link not yet earned; `agentlinkops init` first in a repository without `.agentlinkops/`). `agentlinkops check` then verifies every expected link and exits 0 when all are present, 1 when one is observed absent with complete evidence, 2 on a usage or ledger error; an unknown never fails.
|
|
10
10
|
|
|
@@ -26,4 +26,18 @@ For recurring cloud changes, use the host's already connected AgentLinkOps tools
|
|
|
26
26
|
|
|
27
27
|
Sync updates `cloud_state` on placements and preserves local status, notes and opportunity relationships. A fetch failure is not proof that a backlink disappeared. Keep `unknown`, `suspected_missing` and confirmed outcomes distinct. Read the new events and decide what action, if any, the user's agent should take; receipt of an event does not authorize sending an email.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Recipes are optional. Choose `agentlinkops agent setup --recipe placement-reconciliation` before reading `../../references/recipes/placement-reconciliation.md` for an exact source and destination, retained evidence, checkpoints and optional monitoring. Removing the recipe preserves your local records and existing reconciliation process.
|
|
30
|
+
|
|
31
|
+
For a draft-only file from an existing frozen report, read [portable ledger
|
|
32
|
+
handoff](../../references/portable-ledger-handoff.md). It needs explicit customer
|
|
33
|
+
workspace/project/site and external-ID mappings, verifies the report hash and
|
|
34
|
+
refuses mismatched sites or conflicting output. It does not verify hosted grants,
|
|
35
|
+
import into a vendor or send messages. Keep replied, suppressed and unknown facts
|
|
36
|
+
separate from monitoring state; a saved file does not establish destination
|
|
37
|
+
readback or permission to send.
|
|
38
|
+
|
|
39
|
+
When the user selects an existing local Outreach CRM with `projects/<project>/drafts`,
|
|
40
|
+
use [local CRM draft handoff](../../references/local-crm-draft-handoff.md) for exact
|
|
41
|
+
body/manifest saving, interrupted-save recovery and readback. This adapter preserves
|
|
42
|
+
the complete original request and explicit site binding. It does not import contacts,
|
|
43
|
+
check current sender authority, activate sequences or provide a native vendor API.
|
package/src/citations/adapter.js
CHANGED
|
@@ -256,7 +256,8 @@ export function createOpenAiCompatibleEngine({ baseUrl, model, apiKey, fetchImpl
|
|
|
256
256
|
// overview linked to — the citation equivalent of a chat engine's source list.
|
|
257
257
|
// The fixture below proves the parser against the documented response shape;
|
|
258
258
|
// the live adapter activates with credentials in fleet, never before.
|
|
259
|
-
|
|
259
|
+
const GOOGLE_AIO_BASE_COST_USD = 0.002;
|
|
260
|
+
export function createGoogleAioEngine({ fetchImpl, credentials, costEstimateUsd = GOOGLE_AIO_BASE_COST_USD, locationCode = 2840, country, languageCode = 'en', device = 'desktop', timeoutMs = CITATION_LIMITS.timeoutMs, maxResponseBytes = CITATION_LIMITS.answerBytes } = {}) {
|
|
260
261
|
const doFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
261
262
|
const error = (code, message, details = {}) => Object.assign(new EngineError(message), { code, ...details });
|
|
262
263
|
if (country !== undefined) {
|
|
@@ -390,6 +391,6 @@ function parseAioResponse(body, prompt) {
|
|
|
390
391
|
citations,
|
|
391
392
|
fanOut: [],
|
|
392
393
|
usage: { input_tokens: 0, output_tokens: 0 },
|
|
393
|
-
costEstimateUsd: body?.cost ??
|
|
394
|
+
costEstimateUsd: body?.cost ?? GOOGLE_AIO_BASE_COST_USD,
|
|
394
395
|
};
|
|
395
396
|
}
|
|
@@ -38,13 +38,48 @@ export function readChatgptCitationState(input) {
|
|
|
38
38
|
const anonymous = root.matches(selectors.at(-1));
|
|
39
39
|
let panelEligible = anonymous && groups.length > 0 && groups.length <= 50;
|
|
40
40
|
const publisherLabels = [];
|
|
41
|
+
const strayKinds = {};
|
|
42
|
+
// Current anonymous layout (observed 2026-09-25): inline citations and source pills are
|
|
43
|
+
// buttons whose sources travel as JSON in data-assistant-sources-payload, each entry
|
|
44
|
+
// {attribution, title, url}. A payload that does not parse to valid URLs is unresolved.
|
|
45
|
+
const payloadButtons = new Set();
|
|
46
|
+
const payloadSources = [];
|
|
47
|
+
if (anonymous) {
|
|
48
|
+
for (const element of Array.from(root.querySelectorAll('[data-assistant-sources-payload]'))) {
|
|
49
|
+
let entries = null;
|
|
50
|
+
try { entries = JSON.parse(element.getAttribute('data-assistant-sources-payload')); } catch { entries = null; }
|
|
51
|
+
const parsed = (Array.isArray(entries) ? entries : []).map(entry => {
|
|
52
|
+
try {
|
|
53
|
+
const url = new URL(entry?.url);
|
|
54
|
+
return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password ? { url: url.href, title: String(entry.title || entry.attribution || '').trim() } : null;
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
});
|
|
57
|
+
if (!parsed.length || parsed.some(item => !item) || concealed(element)) { unresolved = true; panelEligible = false; continue; }
|
|
58
|
+
payloadButtons.add(element);
|
|
59
|
+
payloadSources.push(...parsed);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
41
62
|
if (anonymous) {
|
|
42
63
|
// A new anonymous layout with unrecognized citation controls is not proof
|
|
43
|
-
// of absence. Never use unrelated page-wide links to fill this gap.
|
|
64
|
+
// of absence. Never use unrelated page-wide links to fill this gap. Three control kinds
|
|
65
|
+
// observed 2026-09-25 never carry sources and are recognized explicitly: code-block
|
|
66
|
+
// Copy code buttons inside <pre>, table Copy table buttons (data-table-copy-state), and
|
|
67
|
+
// entity chips (data-content-reference-type="entity"), which open an entity panel. A brand
|
|
68
|
+
// named in an entity chip is still counted as a mention from the answer text.
|
|
44
69
|
const actions = root.querySelector('[role="group"][aria-label="Response actions"]');
|
|
45
70
|
const unsupported = Array.from(root.querySelectorAll('a[href], button, [role="button"], cite, sup, [data-citation], [data-citation-id]'));
|
|
46
|
-
|
|
47
|
-
|
|
71
|
+
const recognizedControl = element => element.tagName === 'BUTTON' && (Boolean(element.closest?.('pre')) ||
|
|
72
|
+
element.getAttribute('data-table-copy-state') !== null ||
|
|
73
|
+
(element.getAttribute('data-content-reference-type') === 'entity' && element.getAttribute('data-assistant-entity-reference') !== null));
|
|
74
|
+
const stray = unsupported.filter(element => !actions?.contains(element) && !groups.some(group => group.contains(element)) &&
|
|
75
|
+
!payloadButtons.has(element) && !recognizedControl(element));
|
|
76
|
+
if (stray.length) { unresolved = true; panelEligible = false; }
|
|
77
|
+
// Diagnostics only: which kinds of element blocked the read, by tag and a sanitized
|
|
78
|
+
// role. Never URLs, text or attributes that could carry page content.
|
|
79
|
+
for (const element of stray) {
|
|
80
|
+
const role = (element.getAttribute('role') || '').toLowerCase();
|
|
81
|
+
const kind = String(element.tagName || 'unknown').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 20) + (role ? `[${/^[a-z-]{1,20}$/.test(role) ? role : 'other'}]` : '');
|
|
82
|
+
if (kind in strayKinds || Object.keys(strayKinds).length < 10) strayKinds[kind] = (strayKinds[kind] || 0) + 1;
|
|
48
83
|
}
|
|
49
84
|
}
|
|
50
85
|
for (const group of groups) {
|
|
@@ -70,11 +105,14 @@ export function readChatgptCitationState(input) {
|
|
|
70
105
|
}
|
|
71
106
|
// Observed anonymous pills are buttons without URLs. Their labels are
|
|
72
107
|
// not URL evidence; a partial anchor list cannot resolve those controls.
|
|
73
|
-
|
|
108
|
+
// A group whose every control carries a parsed sources payload is resolved by it.
|
|
109
|
+
const payloadResolved = payloadButtons.size > 0 && (() => { const controls = Array.from(group.querySelectorAll('button, [role="button"]')); return controls.length > 0 && controls.every(control => payloadButtons.has(control)); })();
|
|
110
|
+
if (!payloadResolved && (!resolved || group.querySelector('button, [role="button"]'))) unresolved = true;
|
|
74
111
|
}
|
|
112
|
+
sources.push(...payloadSources);
|
|
75
113
|
const labels = [...new Set(publisherLabels)];
|
|
76
114
|
panelEligible = panelEligible && labels.length > 0 && labels.length <= 50;
|
|
77
|
-
const state = { found: true, anonymous, groups: groups.length, unresolved, sources, panelEligible };
|
|
115
|
+
const state = { found: true, anonymous, groups: groups.length, unresolved, sources, panelEligible, strayKinds };
|
|
78
116
|
if (!input.prepare) return state;
|
|
79
117
|
if (!panelEligible || !unresolved) return {state};
|
|
80
118
|
// Keep the exact assistant and control references in-page. No DOM identities,
|
|
@@ -145,6 +183,22 @@ export function readChatgptCitationState(input) {
|
|
|
145
183
|
const unavailable = () => Object.assign(new Error('ChatGPT citation controls could not be resolved.'), {code:'CITATION_EXTRACTION_UNAVAILABLE'});
|
|
146
184
|
const responseBindings = new WeakMap();
|
|
147
185
|
const bindingCurrent = binding => binding.evaluate(value => typeof value.isCurrent === 'function' && value.isCurrent());
|
|
186
|
+
// ChatGPT renders some failures as the assistant turn itself ("Something went wrong...").
|
|
187
|
+
// Such a turn is not an answer: read as one, it would record a false not_cited. Only a short
|
|
188
|
+
// turn made of a known failure message counts, so a real answer that quotes one is kept.
|
|
189
|
+
const CHATGPT_FAILURE_MESSAGES = [
|
|
190
|
+
/^something went wrong\b/i,
|
|
191
|
+
/^there was an error generating a response\b/i,
|
|
192
|
+
/^hmm\.{0,3}\s*something seems to have gone wrong\b/i,
|
|
193
|
+
/^network error\b/i,
|
|
194
|
+
/^too many requests\b/i,
|
|
195
|
+
/^you('|’)ve reached our limit of messages\b/i,
|
|
196
|
+
];
|
|
197
|
+
export function isChatgptFailureMessage(text) {
|
|
198
|
+
const body = String(text ?? '').replace(/^\s*#{1,6}\s*ChatGPT said:\s*/i, '').trim();
|
|
199
|
+
return body.length > 0 && body.length < 400 && CHATGPT_FAILURE_MESSAGES.some(pattern => pattern.test(body));
|
|
200
|
+
}
|
|
201
|
+
|
|
148
202
|
export async function releaseChatgptResponseBinding(page) {
|
|
149
203
|
const binding = responseBindings.get(page);
|
|
150
204
|
responseBindings.delete(page);
|
|
@@ -202,7 +256,7 @@ export async function extractChatgptSources(page, legacyExtract) {
|
|
|
202
256
|
const state = await page.evaluate(readChatgptCitationState, PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt);
|
|
203
257
|
let sources;
|
|
204
258
|
if (!state.found || state.unresolved) {
|
|
205
|
-
if (!state.found || !state.panelEligible) throw unavailable();
|
|
259
|
+
if (!state.found || !state.panelEligible) throw Object.assign(unavailable(), { strayKinds: state.strayKinds ?? {} });
|
|
206
260
|
sources = await extractControlledPanel(page,binding);
|
|
207
261
|
} else if (state.anonymous || state.groups) sources = [...new Map(state.sources.map(source => [source.url, source])).values()];
|
|
208
262
|
else sources = await legacyExtract(page);
|
|
@@ -11,11 +11,13 @@
|
|
|
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
13
|
import { homedir } from 'node:os';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
14
16
|
import { join } from 'node:path';
|
|
15
17
|
|
|
16
18
|
import { EngineError } from '../adapter.js';
|
|
17
19
|
import {createProxyAdmission,proxyFailureReason} from './proxy-admission.js';
|
|
18
|
-
import { extractBoundChatgptResponse, extractChatgptSources, releaseChatgptResponseBinding } from './chatgpt-citations.js';
|
|
20
|
+
import { extractBoundChatgptResponse, extractChatgptSources, releaseChatgptResponseBinding, isChatgptFailureMessage } from './chatgpt-citations.js';
|
|
19
21
|
|
|
20
22
|
const MODULE_LOADERS = {
|
|
21
23
|
'playwright-core': () => import('playwright-core'),
|
|
@@ -58,6 +60,46 @@ const ENGINE_NOMINAL_COST = Object.freeze({
|
|
|
58
60
|
});
|
|
59
61
|
const DEFAULT_NOMINAL_COST = 0.03;
|
|
60
62
|
|
|
63
|
+
// The vendored display bootstrap stops Xvfb only through cleanup(). A Node process that is
|
|
64
|
+
// killed or crashes leaves Xvfb running under init (the measurement VPS held 32 of them on
|
|
65
|
+
// 2026-09-24), so a detached watchdog ends it within five seconds of this process
|
|
66
|
+
// disappearing. The pid comes from Xvfb's own lock file and is killed only while it is still
|
|
67
|
+
// Xvfb, so a recycled pid is never signalled. Returns the function that retires the watchdog.
|
|
68
|
+
export function guardDisplay(display, { spawnProcess = spawn, readLock = readFileSync, parentPid = process.pid } = {}) {
|
|
69
|
+
const number = Number(String(display).replace(/^:/, ''));
|
|
70
|
+
let xvfbPid;
|
|
71
|
+
try { xvfbPid = Number(String(readLock(`/tmp/.X${number}-lock`, 'utf8')).trim()); } catch { return () => {}; }
|
|
72
|
+
if (![number, xvfbPid, parentPid].every(Number.isInteger) || xvfbPid <= 1) return () => {};
|
|
73
|
+
const script = `while kill -0 ${parentPid} 2>/dev/null; do sleep 5; done; [ "$(cat /proc/${xvfbPid}/comm 2>/dev/null)" = Xvfb ] && kill ${xvfbPid}`;
|
|
74
|
+
let watchdog;
|
|
75
|
+
try { watchdog = spawnProcess('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }); watchdog.unref?.(); }
|
|
76
|
+
catch { return () => {}; }
|
|
77
|
+
return () => { try { watchdog.kill('SIGKILL'); } catch {} };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A browser composer can lose keystrokes (a login modal taking focus mid-typing is the
|
|
81
|
+
// observed case: on 2026-09-25, 4 of 30 ChatGPT slots submitted a truncated prompt and were
|
|
82
|
+
// answered as a different question). The prompt must be exactly what the panel says, both
|
|
83
|
+
// in the composer before submit and in the submitted turn afterwards.
|
|
84
|
+
export const normalizePrompt = text => String(text ?? '').replace(/^\s*you said:\s*/i, '').replace(/\s+/g, ' ').trim();
|
|
85
|
+
export async function ensurePromptEntered(prompt, { readComposer, fill, settle = async () => {} }) {
|
|
86
|
+
const expected = normalizePrompt(prompt);
|
|
87
|
+
if (normalizePrompt(await readComposer()) === expected) return 'typed';
|
|
88
|
+
await fill(prompt); await settle();
|
|
89
|
+
if (normalizePrompt(await readComposer()) === expected) return 'filled';
|
|
90
|
+
throw new EngineError('the prompt could not be entered completely', { retriable: true });
|
|
91
|
+
}
|
|
92
|
+
// Runs in the page: the text of the latest user turn, or null when none is found.
|
|
93
|
+
// Observed 2026-09-25: li[data-message-role="user"] > ... > p[data-user-message-copy].
|
|
94
|
+
// Older layouts used data-message-author-role / data-turn.
|
|
95
|
+
export function readChatgptSubmittedPrompt() {
|
|
96
|
+
for (const selector of ['[data-message-role="user"] [data-user-message-copy]', '[data-message-role="user"]', '[data-message-author-role="user"]', '[data-turn="user"]']) {
|
|
97
|
+
const node = Array.from(document.querySelectorAll(selector)).at(-1);
|
|
98
|
+
if (node) return node.innerText;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
61
103
|
export function createBrowserEngine({ engineName, env = process.env, onEvent = () => {}, runtime = {} } = {}) {
|
|
62
104
|
if (!BROWSER_PROVIDERS.includes(engineName)) {
|
|
63
105
|
throw new EngineError(`unknown browser engine: ${engineName} (known: ${BROWSER_PROVIDERS.join(', ')})`);
|
|
@@ -89,6 +131,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
89
131
|
let running=false,contextCleanupFailed=false;
|
|
90
132
|
let browserHandle = null; // { browser, cleanup } for the whole epoch
|
|
91
133
|
let displayHandle = null;
|
|
134
|
+
let releaseDisplayGuard = null;
|
|
92
135
|
let providerConfig = null;
|
|
93
136
|
async function closeQuietly(action) {
|
|
94
137
|
let timer;
|
|
@@ -96,6 +139,17 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
96
139
|
finally { clearTimeout(timer); }
|
|
97
140
|
}
|
|
98
141
|
|
|
142
|
+
// The watchdog is retired only after a confirmed close; otherwise it stays armed and
|
|
143
|
+
// ends Xvfb once this process exits.
|
|
144
|
+
async function closeDisplay() {
|
|
145
|
+
// A second call after a failed close finds no handle; that is not a confirmed close.
|
|
146
|
+
if (!displayHandle) return true;
|
|
147
|
+
const closed = await closeQuietly(() => displayHandle.cleanup?.());
|
|
148
|
+
if (closed) { releaseDisplayGuard?.(); releaseDisplayGuard = null; }
|
|
149
|
+
displayHandle = null;
|
|
150
|
+
return closed;
|
|
151
|
+
}
|
|
152
|
+
|
|
99
153
|
async function ensureProviderConfig() {
|
|
100
154
|
if (providerConfig) return providerConfig;
|
|
101
155
|
const { PROVIDER_CONFIGS } = await load('./gen/core/providers/index.js');
|
|
@@ -124,6 +178,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
124
178
|
// engines that block headless render headfully inside a virtual display.
|
|
125
179
|
if (platform === 'linux' && !display.detectDisplay()) {
|
|
126
180
|
displayHandle = await display.ensureDisplay({ allowExistingDisplay: false });
|
|
181
|
+
releaseDisplayGuard = (runtime.guardDisplay ?? guardDisplay)(displayHandle.display);
|
|
127
182
|
await event(`display: ${displayHandle.display}`);
|
|
128
183
|
}
|
|
129
184
|
|
|
@@ -151,8 +206,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
151
206
|
browser,
|
|
152
207
|
cleanup: async () => {
|
|
153
208
|
const browserClosed=await closeQuietly(() => browser.close());
|
|
154
|
-
const displayClosed=await
|
|
155
|
-
displayHandle = null;
|
|
209
|
+
const displayClosed=await closeDisplay();
|
|
156
210
|
if(browserClosed)contextCleanupFailed=false;
|
|
157
211
|
return browserClosed&&displayClosed;
|
|
158
212
|
},
|
|
@@ -163,11 +217,26 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
163
217
|
async function loadStorageState() {
|
|
164
218
|
// Accountless measurement never opens session files, even when they exist.
|
|
165
219
|
if (authentication === 'accountless') return undefined;
|
|
220
|
+
const failure = () => Object.assign(new EngineError('Saved browser session is unavailable or unsafe. Restore private session storage or run citation login again.'), { code: 'BROWSER_SESSION_INVALID' });
|
|
221
|
+
let handle;
|
|
166
222
|
try {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
223
|
+
const storage = await load('node:fs/promises');
|
|
224
|
+
const directory = await storage.lstat(sessionDir);
|
|
225
|
+
if (!directory.isDirectory() || (platform !== 'win32' && (directory.mode & 0o7777) !== 0o700)) throw failure();
|
|
226
|
+
const filename = join(sessionDir, `${engineName}.json`);
|
|
227
|
+
const before = await storage.lstat(filename);
|
|
228
|
+
if (!before.isFile() || (platform !== 'win32' && (before.mode & 0o7777) !== 0o600)) throw failure();
|
|
229
|
+
handle = await storage.open(filename, 'r');
|
|
230
|
+
const opened = await handle.stat();
|
|
231
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || (platform !== 'win32' && (opened.mode & 0o7777) !== 0o600)) throw failure();
|
|
232
|
+
const state = JSON.parse(await handle.readFile('utf8'));
|
|
233
|
+
if (!state || !Array.isArray(state.cookies) || !Array.isArray(state.origins)) throw failure();
|
|
234
|
+
return state;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (error.code === 'ENOENT') return undefined;
|
|
237
|
+
throw failure();
|
|
238
|
+
} finally {
|
|
239
|
+
try { await handle?.close(); } catch { throw failure(); }
|
|
171
240
|
}
|
|
172
241
|
}
|
|
173
242
|
|
|
@@ -220,10 +289,10 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
220
289
|
estimateCostUsd: () => ENGINE_NOMINAL_COST[engineName] ?? DEFAULT_NOMINAL_COST,
|
|
221
290
|
|
|
222
291
|
async run({ prompt }) {
|
|
292
|
+
const storageState = await loadStorageState();
|
|
223
293
|
const config = await ensureProviderConfig();
|
|
224
294
|
const { browser } = await ensureBrowser();
|
|
225
295
|
admission.assertReady();
|
|
226
|
-
const storageState = await loadStorageState();
|
|
227
296
|
const context = await browser.newContext({
|
|
228
297
|
storageState,
|
|
229
298
|
locale: 'en-US',
|
|
@@ -318,12 +387,16 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
318
387
|
await editor.locator.fill(prompt);
|
|
319
388
|
});
|
|
320
389
|
await page.waitForTimeout(600);
|
|
321
|
-
const typedCheck = await editor.locator.inputValue().catch(() => '');
|
|
322
|
-
if (typedCheck.trim().length === 0) {
|
|
323
|
-
await editor.locator.fill(prompt).catch(() => {});
|
|
324
|
-
}
|
|
325
390
|
await config.afterTypingHook?.(page);
|
|
326
391
|
await config.beforeSubmitHook?.(page);
|
|
392
|
+
// Checked after the modal hooks, immediately before submit: the composer must hold
|
|
393
|
+
// the whole prompt. One fill is the recovery; otherwise the sample fails closed.
|
|
394
|
+
const entered = await ensurePromptEntered(prompt, {
|
|
395
|
+
readComposer: () => editor.locator.evaluate(node => (typeof node.value === 'string' ? node.value : node.innerText) || '').catch(() => ''),
|
|
396
|
+
fill: text => editor.locator.fill(text).catch(() => {}),
|
|
397
|
+
settle: () => page.waitForTimeout(400),
|
|
398
|
+
});
|
|
399
|
+
if (entered === 'filled') await event('prompt refilled after incomplete typing');
|
|
327
400
|
|
|
328
401
|
// Submit: the surface's own send button when it exposes one; Enter fallback.
|
|
329
402
|
// Allow the surface to settle; response extraction remains fail-closed.
|
|
@@ -336,15 +409,29 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
336
409
|
|
|
337
410
|
await config.waitForResponse(page);
|
|
338
411
|
if (!answer) answer = await config.extractResponse(page);
|
|
412
|
+
if (engineName === 'chatgpt') {
|
|
413
|
+
// The answer belongs to the prompt only if the submitted turn is the panel prompt.
|
|
414
|
+
const submitted = await page.evaluate(readChatgptSubmittedPrompt).catch(() => null);
|
|
415
|
+
if (typeof submitted !== 'string') await event('submitted prompt could not be read; not verified');
|
|
416
|
+
else if (normalizePrompt(submitted) !== normalizePrompt(prompt)) {
|
|
417
|
+
throw new EngineError('chatgpt: the submitted prompt differs from the panel prompt', { retriable: true });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (engineName === 'chatgpt' && isChatgptFailureMessage(answer)) {
|
|
421
|
+
throw new EngineError('chatgpt: the surface returned an error message instead of an answer', { retriable: true });
|
|
422
|
+
}
|
|
339
423
|
if (!answer || answer.trim().length < 40) {
|
|
340
424
|
const reason = engineName === 'bing'
|
|
341
425
|
? 'bing: no AI answer block rendered for this prompt (organic results only — never a citation verdict)'
|
|
342
426
|
: `${engineName}: answer extracted empty`;
|
|
343
427
|
throw new EngineError(reason, { retriable: true }); }
|
|
344
428
|
let extractionFailed = config.citationExtraction === 'unsupported';
|
|
345
|
-
const sources = await config.extractSources(page).catch(async
|
|
429
|
+
const sources = await config.extractSources(page).catch(async cause => {
|
|
346
430
|
extractionFailed = true;
|
|
347
|
-
|
|
431
|
+
// Tag and role counts only (see readChatgptCitationState); they say which layout
|
|
432
|
+
// change blocked the read without carrying page content.
|
|
433
|
+
const kinds = Object.entries(cause?.strayKinds ?? {}).map(([kind, count]) => `${kind}x${count}`).join(', ');
|
|
434
|
+
await event(`sources extraction failed; observation retained as unknown${kinds ? ` (unrecognized: ${kinds})` : ''}`);
|
|
348
435
|
return [];
|
|
349
436
|
});
|
|
350
437
|
const screenshotPng = await page.screenshot({ fullPage: false }).catch(() => null);
|
|
@@ -384,17 +471,18 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
384
471
|
let confirmed=true;
|
|
385
472
|
try{if(browserHandle)confirmed=await browserHandle.cleanup();}
|
|
386
473
|
finally{
|
|
387
|
-
const displayClosed=await
|
|
388
|
-
confirmed=confirmed&&displayClosed;
|
|
474
|
+
const displayClosed=await closeDisplay();
|
|
475
|
+
confirmed=confirmed&&displayClosed;browserHandle=null;
|
|
389
476
|
}
|
|
390
477
|
return confirmed;
|
|
391
478
|
},
|
|
392
479
|
};
|
|
393
480
|
const sample=engine.run.bind(engine),closeBrowser=engine.close.bind(engine);
|
|
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']);
|
|
481
|
+
const policyCodes=new Set(['CITATION_EXTRACTION_UNAVAILABLE','BROWSER_AUTH_POLICY_INVALID','BROWSER_SESSION_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']);
|
|
395
482
|
const safeError=cause=>{
|
|
396
483
|
const code=policyCodes.has(cause?.code)?cause.code:'BROWSER_RUN_FAILED';
|
|
397
|
-
const
|
|
484
|
+
const message=code==='BROWSER_SESSION_INVALID'?'Saved browser session is unavailable or unsafe. Restore private session storage or run citation login again.':code==='CITATION_EXTRACTION_UNAVAILABLE'?'Citation binding or extraction was unavailable.':code==='BROWSER_RUN_FAILED'?'Browser sample failed.':'Browser admission or cleanup failed.';
|
|
485
|
+
const error=Object.assign(new EngineError(message,{retriable:code==='BROWSER_RUN_FAILED'&&cause?.retriable===true}),{code});
|
|
398
486
|
if(code==='BROWSER_RUN_FAILED'||cause?.unknown===true)error.unknown=true;
|
|
399
487
|
return error;
|
|
400
488
|
};
|